Compare commits

...
2 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 8811f14732 V1.26.0: dice that tumble, rolled by the server, seen by the DM
- Each die is drawn as its own shape, tumbles in and lands on its result;
  a natural 20 glows, a natural 1 shakes; respects reduced motion
- In a campaign the server makes every digital roll and keeps it
- The DM sees each roll as it lands: an "Everyone's rolls" tab in the
  roller, a pop-up beside the dice button, and a list on the encounter page
- Players see everyone's rolls or only their own, by the campaign setting;
  the DM can roll hidden
- Physical dice are typed in, one box per die, and checked; with "both",
  each person chooses in the roller
- game/dice.js: parsing, rolling, checking typed dice, critical damage

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:46:22 +02:00
joshuaandClaude Opus 5.5 c3f2b0dfa7 V1.25.0: the DM approves a level, each player takes their own
- "Level up..." beside Rest approves a level for any of the party at once;
  with XP tracked, those who have the XP start ticked
- Each player is told with a notice and picks for themselves in the checklist:
  class (multiclass), subclass, Boon, spells within the level's allowance,
  the weapon upgrade; a waiting level shows on the sheet and campaign page
- The DM sees who still has picks to make, and can finish or take back any
- Hit points on a level up can be rolled (campaign setting): by the site, or
  at the table and typed in with physical dice
- Toasts: notices that slide in over any page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:30:31 +02:00
33 changed files with 2116 additions and 222 deletions
+13 -3
View File
@@ -99,6 +99,12 @@ including a character sheet or an encounter) and **Account**.
checklist at level 2 or 3, and shown beside the class everywhere; the DM can
change it from the sheet, and switch a 2014 path to its 2024 update. Every
sheet shows **the road ahead**: what each level from 1 to 20 gives.
- **Dice that roll.** Each die is drawn as its own shape, tumbles in and
lands on its result; a natural 20 glows. In a campaign the server makes
every roll and the **DM sees each one** as it lands, in the roller's
"Everyone's rolls" tab, beside the dice button, and on the encounter page.
A campaign can play with digital dice, real dice typed in (and checked), or
both, and can keep each player's rolls between them and the DM.
- **Multiclassing** (a campaign setting): the level-up checklist asks which
class gains the level, checks both classes' ability requirements, and works
out HP, Hit Dice, spell slots, counters and features by each class's own
@@ -114,9 +120,13 @@ including a character sheet or an encounter) and **Account**.
who sees a roll. **Combat**: auto-rolled monster and NPC attacks, the
critical hit rule, monster HP, the area penalty. **Progression**: XP
tracking (off for milestone levelling) and HP on a level up.
- **⬆ Level Up…** on a sheet (DM) takes the character up a level with the
whole checklist: HP, the class's new special abilities, a Boon, spells and
the free weapon upgrade.
- **⬆ Level up…** (DM, beside Rest) approves a level for any of the party at
once. Each player gets a notice, sees what the level gives and makes their
own picks in the checklist: HP (the average, or a roll where the campaign
rolls), the class's new special abilities, a subclass, a Boon, spells and
the free weapon upgrade. A level waiting shows on the sheet and the
campaign page until it is taken; the DM sees who still has picks to make,
and can finish any of them.
- The character sheet is long, so it starts with a row of **section links**
that jump straight to that part of the page.
- On a small screen every table (weapons, items, spells, monsters, the party
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.24.0",
"version": "1.26.0",
"private": true,
"type": "commonjs",
"main": "src/index.js",
@@ -0,0 +1,5 @@
-- Level-ups the DM approves for players to take, and rolled HP on a level up.
-- AlterTable
ALTER TABLE "characters" ADD COLUMN "hpRollAdjust" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "levelUpsPending" INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,28 @@
-- Rolls made at the table's dice roller, kept so the DM can see each one.
-- CreateTable
CREATE TABLE "dice_rolls" (
"id" TEXT NOT NULL,
"campaignId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"rollerName" TEXT NOT NULL,
"label" TEXT,
"notation" TEXT NOT NULL,
"dice" JSONB NOT NULL,
"modifier" INTEGER NOT NULL DEFAULT 0,
"total" INTEGER NOT NULL,
"natural" INTEGER,
"mode" TEXT NOT NULL DEFAULT 'NORMAL',
"physical" BOOLEAN NOT NULL DEFAULT false,
"hidden" BOOLEAN NOT NULL DEFAULT false,
"encounterId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "dice_rolls_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "dice_rolls_campaignId_createdAt_idx" ON "dice_rolls"("campaignId", "createdAt");
-- AddForeignKey
ALTER TABLE "dice_rolls" ADD CONSTRAINT "dice_rolls_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+37
View File
@@ -290,10 +290,40 @@ model Campaign {
saleOffers SaleOffer[]
notes Note[]
locations Location[]
diceRolls DiceRoll[]
@@map("campaigns")
}
// A roll made at the table's dice roller, kept so the table (and above all
// the DM) can see it. Digital rolls are made by the server; physical ones are
// typed in and checked against their dice. Who sees a roll follows the
// campaign's rollVisibility; a `hidden` one is the DM's alone.
model DiceRoll {
id String @id @default(cuid())
campaignId String
userId String
rollerName String // who rolled, as shown: kept so a roll outlives a renamed account
label String? // what it was for: "Aria: Stealth"
notation String // "1d20+3", "2d6+1d8+2"
// Every die, in order: [{ sides, value, kept, sign }]. `kept` is false for
// the d20 that advantage or disadvantage dropped.
dice Json
modifier Int @default(0)
total Int
natural Int? // the d20 that counted, for a roll of a single d20
mode String @default("NORMAL") // NORMAL | ADVANTAGE | DISADVANTAGE
physical Boolean @default(false) // rolled at the table and typed in
hidden Boolean @default(false) // the DM's alone
encounterId String?
createdAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
@@index([campaignId, createdAt])
@@map("dice_rolls")
}
model Location {
id String @id @default(cuid())
campaignId String
@@ -357,6 +387,13 @@ model Character {
// until the class reaches the level it chooses at.
subclass String?
level Int @default(1)
// Levels the DM has approved that the character has not yet taken: each is
// one level-up checklist waiting for its picks. The player resolves them
// (or the DM does), one at a time.
levelUpsPending Int @default(0)
// Where the campaign rolls HP on a level up: how far the rolls so far are
// from the average they replace, in all (a 2 on a d8 is -3). Added to max HP.
hpRollAdjust Int @default(0)
xp Int @default(0)
motivation String?
+6 -1
View File
@@ -325,6 +325,7 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
speedOverride = null,
spellSlotsUsed = [],
hitDiceUsed = 0,
hpRollAdjust = 0,
} = character;
const rolled = { STR: strScore, DEX: dexScore, CON: conScore, INT: intScore, WIS: wisScore, CHA: chaScore };
@@ -389,7 +390,11 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
initiative: initiative(abilities.DEX.modifier) + fromGear('INITIATIVE'),
speed: Math.max(0, effectiveSpeed(species, speedOverride) + fromGear('SPEED')),
hp: {
max: Math.max(1, (multi ? multiclass.multiclassHpMax(classes, abilities.CON.modifier) : hpMax(level, hitDieSize, abilities.CON.modifier)) + fromGear('MAX_HP')),
// hpRollAdjust: how far HP rolled on level-ups is from the average counted here.
max: Math.max(
1,
(multi ? multiclass.multiclassHpMax(classes, abilities.CON.modifier) : hpMax(level, hitDieSize, abilities.CON.modifier)) + (Number(hpRollAdjust) || 0) + fromGear('MAX_HP')
),
},
hitDice: multi ? multiclass.multiclassHitDice(classes, hitDiceUsed) : hitDiceState(characterClass, level, hitDiceUsed),
saves,
+105
View File
@@ -0,0 +1,105 @@
// Dice: reading an expression ("2d6+3"), rolling it, and checking dice that
// were rolled at the table and typed in. Used by the table's dice roller
// (POST /campaigns/:id/rolls) and by attacks in an encounter (game/combat.js).
// Pure but for the random source, which can be passed in for tests.
const crypto = require('crypto');
const rollDie = (sides) => crypto.randomInt(1, sides + 1);
const MAX_DICE = 100;
const MAX_SIDES = 1000;
// Reads the dice expressions the app writes: "1d8", "2d6", "1d6+1d8",
// "2d4+2", "1d8 - 1". Returns { dice: [{ count, sides, sign }], flat }, or null
// for anything else (free text is allowed where a DM types dice) and for
// something too big to be a real roll. Mirrors parseDice in the frontend's
// lib/diceRoller.ts.
function parseDice(expression) {
const terms = String(expression || '')
.replace(/\s+/g, '')
.replace(/[−–]/g, '-')
.toLowerCase()
.replace(/-/g, '+-')
.split('+')
.filter(Boolean);
if (terms.length === 0) return null;
const parsed = { dice: [], flat: 0 };
let total = 0;
for (const term of terms) {
const die = /^(-?)(\d*)d(\d+)$/.exec(term);
if (die) {
const count = Number(die[2]) || 1;
const sides = Number(die[3]);
total += count;
if (total > MAX_DICE || sides < 2 || sides > MAX_SIDES) return null;
parsed.dice.push({ count, sides, sign: die[1] ? -1 : 1 });
} else if (/^-?\d+$/.test(term)) {
parsed.flat += Number(term);
} else {
return null;
}
}
return parsed.dice.length > 0 ? parsed : null;
}
// The dice at the start of a line of text that says more: "2d8+3" from
// "2d8+3 fire". Null when it does not start with dice.
function leadingDice(text) {
const match = /^\s*((?:\d*d\d+|\d+)(?:\s*[+\-−–]\s*(?:\d*d\d+|\d+))*)/i.exec(String(text || ''));
return match && parseDice(match[1]) ? match[1].replace(/\s+/g, '') : null;
}
// Every die of a parsed expression, one entry each, in order: [{ sides, sign }].
function diceOf(parsed) {
return parsed.dice.flatMap((term) => Array.from({ length: term.count }, () => ({ sides: term.sides, sign: term.sign })));
}
// Whether `values` could be what these dice showed: one whole number per die,
// each on its die.
function validValues(dice, values) {
return Array.isArray(values) && values.length === dice.length && dice.every((die, i) => Number.isInteger(values[i]) && values[i] >= 1 && values[i] <= die.sides);
}
// Rolls a parsed expression, or takes `values` (dice rolled at the table,
// already checked with validValues) in place of rolling. Returns
// { dice: [{ sides, value, kept, sign }], modifier, total }.
function rollExpression(parsed, { values = null, roll = rollDie } = {}) {
const dice = diceOf(parsed).map((die, i) => ({ sides: die.sides, value: values ? values[i] : roll(die.sides), kept: true, sign: die.sign }));
return { dice, modifier: parsed.flat, total: dice.reduce((sum, die) => sum + die.sign * die.value, 0) + parsed.flat };
}
const D20_MODES = ['NORMAL', 'ADVANTAGE', 'DISADVANTAGE'];
// How many d20s a mode rolls: two with advantage or disadvantage.
const d20Count = (mode) => (mode === 'NORMAL' ? 1 : 2);
// A d20 plus a modifier, as the mode has it: one die, or two with the higher
// (advantage) or lower (disadvantage) kept. `values` as in rollExpression.
// Returns { dice, modifier, total, natural }: `natural` is the d20 that counted.
function rollD20(modifier = 0, mode = 'NORMAL', { values = null, roll = rollDie } = {}) {
const rolled = Array.from({ length: d20Count(mode) }, (_, i) => (values ? values[i] : roll(20)));
const natural = mode === 'ADVANTAGE' ? Math.max(...rolled) : mode === 'DISADVANTAGE' ? Math.min(...rolled) : rolled[0];
// Of two equal dice only the first is marked as the one kept.
const keptIndex = rolled.indexOf(natural);
return {
dice: rolled.map((value, i) => ({ sides: 20, value, kept: i === keptIndex, sign: 1 })),
modifier,
total: natural + modifier,
natural,
};
}
// What a critical hit does to damage dice (Campaign.critRule). DOUBLE_DICE:
// every die is rolled twice. MAX_PLUS_ROLL: every die counts at its maximum,
// and is rolled once more on top. Takes and returns a parsed expression's
// roll; the flat modifier is never doubled.
function rollCritical(parsed, critRule = 'DOUBLE_DICE', { roll = rollDie } = {}) {
const base = diceOf(parsed);
const first = base.map((die) => ({ sides: die.sides, value: critRule === 'MAX_PLUS_ROLL' ? die.sides : roll(die.sides), kept: true, sign: die.sign, maxed: critRule === 'MAX_PLUS_ROLL' }));
const second = base.map((die) => ({ sides: die.sides, value: roll(die.sides), kept: true, sign: die.sign }));
const dice = [...first, ...second];
return { dice, modifier: parsed.flat, total: dice.reduce((sum, die) => sum + die.sign * die.value, 0) + parsed.flat };
}
module.exports = { rollDie, parseDice, leadingDice, diceOf, validValues, rollExpression, rollD20, d20Count, rollCritical, D20_MODES, MAX_DICE };
+77
View File
@@ -0,0 +1,77 @@
const { parseDice, leadingDice, diceOf, validValues, rollExpression, rollD20, rollCritical } = require('./dice');
// A die that always shows the same face, or the faces given in turn.
const fixed = (...faces) => {
let i = 0;
return () => faces[i++ % faces.length];
};
describe('parseDice', () => {
test('reads the expressions the app writes', () => {
expect(parseDice('2d6+3')).toEqual({ dice: [{ count: 2, sides: 6, sign: 1 }], flat: 3 });
expect(parseDice('1d6 + 1d8 − 1')).toEqual({ dice: [{ count: 1, sides: 6, sign: 1 }, { count: 1, sides: 8, sign: 1 }], flat: -1 });
expect(parseDice('d20')).toEqual({ dice: [{ count: 1, sides: 20, sign: 1 }], flat: 0 });
});
test('refuses free text, bare numbers and absurd rolls', () => {
expect(parseDice('a big bite')).toBeNull();
expect(parseDice('7')).toBeNull();
expect(parseDice('101d6')).toBeNull();
expect(parseDice('60d6+60d6')).toBeNull();
expect(parseDice('1d1')).toBeNull();
});
test('leadingDice takes the dice off the front of a line', () => {
expect(leadingDice('2d8+3 fire')).toBe('2d8+3');
expect(leadingDice('fire, 2d8')).toBeNull();
});
});
describe('rolling', () => {
test('rolls every die and adds the flat part', () => {
const roll = rollExpression(parseDice('2d6+1d4+2'), { roll: fixed(3, 5, 4) });
expect(roll.dice.map((d) => [d.sides, d.value])).toEqual([[6, 3], [6, 5], [4, 4]]);
expect(roll).toMatchObject({ modifier: 2, total: 14 });
});
test('a subtracted die takes away', () => {
expect(rollExpression(parseDice('1d8-1d4'), { roll: fixed(6, 2) }).total).toBe(4);
});
test('dice rolled at the table are checked against each die, then used as they are', () => {
const dice = diceOf(parseDice('1d8+1d6'));
expect(validValues(dice, [8, 6])).toBe(true);
expect(validValues(dice, [9, 1])).toBe(false);
expect(validValues(dice, [3])).toBe(false);
expect(validValues(dice, [3, 2.5])).toBe(false);
expect(rollExpression(parseDice('1d8+1d6+1'), { values: [8, 6] }).total).toBe(15);
});
});
describe('a d20', () => {
test('rolls one die and adds the modifier', () => {
expect(rollD20(5, 'NORMAL', { roll: fixed(12) })).toMatchObject({ total: 17, natural: 12, dice: [{ sides: 20, value: 12, kept: true }] });
});
test('keeps the higher with advantage and the lower with disadvantage', () => {
const adv = rollD20(2, 'ADVANTAGE', { roll: fixed(7, 15) });
expect(adv).toMatchObject({ natural: 15, total: 17 });
expect(adv.dice.map((d) => d.kept)).toEqual([false, true]);
expect(rollD20(0, 'DISADVANTAGE', { roll: fixed(7, 15) }).natural).toBe(7);
expect(rollD20(0, 'ADVANTAGE', { values: [20, 20] }).dice.map((d) => d.kept)).toEqual([true, false]);
});
});
describe('a critical hit', () => {
test('rolls every damage die twice, and the flat part once', () => {
const crit = rollCritical(parseDice('1d8+3'), 'DOUBLE_DICE', { roll: fixed(5, 7) });
expect(crit.dice.map((d) => d.value)).toEqual([5, 7]);
expect(crit.total).toBe(15);
});
test('or takes the dice at their most and rolls them once more', () => {
const crit = rollCritical(parseDice('2d6+1'), 'MAX_PLUS_ROLL', { roll: fixed(2, 3) });
expect(crit.dice.map((d) => d.value)).toEqual([6, 6, 2, 3]);
expect(crit.total).toBe(18);
});
});
+154
View File
@@ -3694,3 +3694,157 @@ describe('multiclassing', () => {
expect(res.body.paths.map((p) => p.key)).toContain('SORLOCK');
});
});
describe('approved level-ups', () => {
itIfDb('the DM approves for many at once; each player then takes their own level, once', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('approve');
const ariaId = await buildAs(campaign.id, playerAuth, 'Aria', 'FIGHTER', 1);
const boId = await buildAs(campaign.id, dmAuth, 'Bo', 'WIZARD', 1);
const approve = (auth, body) => request(app).post(`/campaigns/${campaign.id}/level-up`).set(auth).send(body);
const levelUp = (id, auth, body = {}) => request(app).post(`/characters/${id}/level-up`).set(auth).send(body);
// Not the player's to take until it is approved, and not theirs to approve.
expect((await request(app).get(`/characters/${ariaId}/level-up`).set(playerAuth)).status).toBe(403);
expect((await levelUp(ariaId, playerAuth)).status).toBe(403);
expect((await approve(playerAuth, { characterIds: [ariaId] })).status).toBe(403);
const approved = await approve(dmAuth, { characterIds: [ariaId, boId] });
expect(approved.status).toBe(200);
expect(approved.body.results.map((r) => [r.name, r.levelUpsPending, r.skipped])).toEqual([['Aria', 1, null], ['Bo', 1, null]]);
const listed = (await request(app).get(`/campaigns/${campaign.id}/characters`).set(playerAuth)).body.characters;
expect(listed.find((c) => c.id === ariaId).levelUpsPending).toBe(1);
// The player sees the plan and takes the level; a second try has nothing approved.
const plan = await request(app).get(`/characters/${ariaId}/level-up`).set(playerAuth);
expect(plan.status).toBe(200);
expect(plan.body.plan).toMatchObject({ toLevel: 2, hp: { method: 'AVERAGE', die: 10, average: 6 } });
const taken = await levelUp(ariaId, playerAuth);
expect(taken.status).toBe(200);
expect(taken.body.character).toMatchObject({ level: 2, levelUpsPending: 0 });
expect(taken.body.hpRolled).toBeNull();
expect((await levelUp(ariaId, playerAuth)).status).toBe(403);
// Bo is the DM's own character; another player cannot take that level.
expect((await levelUp(boId, playerAuth)).status).toBe(403);
// An approval can be taken back, and stops at the top level.
const revoked = await approve(dmAuth, { characterIds: [boId, ariaId], revoke: true });
expect(revoked.body.results.find((r) => r.name === 'Bo')).toMatchObject({ levelUpsPending: 0, skipped: null });
expect(revoked.body.results.find((r) => r.name === 'Aria').skipped).toMatch(/no level up waiting/);
await prisma.character.update({ where: { id: boId }, data: { level: 20 } });
expect((await approve(dmAuth, { characterIds: [boId] })).body.results[0].skipped).toMatch(/level 20/);
});
itIfDb('an approved level is no longer shown as due', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('approvedue');
const id = await buildAs(campaign.id, playerAuth, 'Aria', 'FIGHTER', 1);
await request(app).patch(`/characters/${id}`).set(dmAuth).send({ xp: 300 });
const read = async () => (await request(app).get(`/characters/${id}`).set(playerAuth)).body.character;
expect((await read()).xpProgress.levelUpDue).toBe(true);
await request(app).post(`/campaigns/${campaign.id}/level-up`).set(dmAuth).send({ characterIds: [id] });
expect(await read()).toMatchObject({ levelUpsPending: 1, xpProgress: { levelUpDue: false } });
});
itIfDb('where the campaign rolls hit points, the die is rolled here or typed in from the table', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('hproll');
const id = await buildAs(campaign.id, playerAuth, 'Aria', 'FIGHTER', 1);
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ hpOnLevelUp: 'ROLL', diceMode: 'PHYSICAL' });
const start = (await request(app).get(`/characters/${id}`).set(dmAuth)).body.character.sheet;
const before = start.hp.max;
const con = start.abilities.CON.modifier; // whatever the species made of it
const levelUp = (body) => request(app).post(`/characters/${id}/level-up`).set(dmAuth).send(body);
expect((await request(app).get(`/characters/${id}/level-up`).set(dmAuth)).body.plan.hp).toEqual({ method: 'ROLL', die: 10, average: 6, physical: true });
expect((await levelUp({})).status).toBe(400); // the roll has to be entered
expect((await levelUp({ hpRoll: 11 })).status).toBe(400); // not on a d10
const low = await levelUp({ hpRoll: 2 });
expect(low.body.hpRolled).toBe(2);
// 2 from the die plus Constitution, where the average would have given 6.
expect(low.body.character.sheet.hp.max).toBe(before + 2 + con);
// Digital dice: the server rolls, and the sheet follows whatever came up.
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ diceMode: 'DIGITAL' });
const rolled = await levelUp({ hpRoll: 10, subclass: 'CHAMPION' }); // a typed value is ignored
expect(rolled.body.hpRolled).toBeGreaterThanOrEqual(1);
expect(rolled.body.hpRolled).toBeLessThanOrEqual(10);
expect(rolled.body.character.sheet.hp.max).toBe(before + 2 + con + rolled.body.hpRolled + con);
});
});
describe('a player picking spells on a level up', () => {
itIfDb('is offered the campaign spells they can cast, and held to the level’s allowance', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('playerspells');
const id = await buildAs(campaign.id, playerAuth, 'Mira', 'WIZARD', 1);
const spell = async (name, level) => (await request(app).post(`/campaigns/${campaign.id}/spells`).set(dmAuth).send({ name, level })).body.spell.id;
const [a, b, c, high] = [await spell('Alarm', 1), await spell('Shield', 1), await spell('Sleep', 1), await spell('Fireball', 3)];
await request(app).post(`/campaigns/${campaign.id}/level-up`).set(dmAuth).send({ characterIds: [id] });
const plan = (await request(app).get(`/characters/${id}/level-up`).set(playerAuth)).body.plan;
expect(plan.spells).toMatchObject({ newSpells: 2, maxSpellLevel: 1 });
expect(plan.spellOptions.map((s) => s.name)).toEqual(['Alarm', 'Shield', 'Sleep']); // not Fireball: too high
const levelUp = (spellIds) => request(app).post(`/characters/${id}/level-up`).set(playerAuth).send({ subclass: 'EVOCATION', spellIds });
expect((await levelUp([a, b, c])).status).toBe(400); // one too many
expect((await levelUp([a, high])).status).toBe(400); // out of reach
const done = await levelUp([a, b]);
expect(done.status).toBe(200);
expect(done.body.character.spells.map((s) => s.spell.name).sort()).toEqual(['Alarm', 'Shield']);
});
});
describe('dice rolls', () => {
itIfDb('are rolled by the server, kept, and shown to whoever may see them', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('rolls');
const roll = (auth, body) => request(app).post(`/campaigns/${campaign.id}/rolls`).set(auth).send(body);
const seen = async (auth) => (await request(app).get(`/campaigns/${campaign.id}/rolls`).set(auth)).body.rolls;
const check = await roll(playerAuth, { kind: 'check', label: 'Aria: Stealth', modifier: 5, mode: 'ADVANTAGE' });
expect(check.status).toBe(201);
const r = check.body.roll;
expect(r).toMatchObject({ label: 'Aria: Stealth', notation: '1d20+5', mode: 'ADVANTAGE', physical: false, hidden: false, rollerName: 'Player' });
expect(r.dice).toHaveLength(2);
expect(r.natural).toBe(Math.max(...r.dice.map((d) => d.value)));
expect(r.total).toBe(r.natural + 5);
const damage = (await roll(playerAuth, { kind: 'dice', expression: '2d6 + 3' })).body.roll;
expect(damage.notation).toBe('2d6+3');
expect(damage.total).toBe(damage.dice[0].value + damage.dice[1].value + 3);
expect((await roll(playerAuth, { kind: 'dice', expression: 'a bite' })).status).toBe(400);
expect((await roll(playerAuth, { kind: 'dice' })).status).toBe(400);
// A player cannot roll behind the screen; the DM can, and only the DM sees it.
expect((await roll(playerAuth, { kind: 'check', hidden: true })).body.roll.hidden).toBe(false);
const secret = (await roll(dmAuth, { kind: 'check', label: 'Goblin: Stealth', hidden: true })).body.roll;
expect(secret.hidden).toBe(true);
expect((await seen(dmAuth)).map((x) => x.id)).toContain(secret.id);
expect((await seen(playerAuth)).map((x) => x.id)).not.toContain(secret.id);
expect((await seen(dmAuth))[0].id).toBe(secret.id); // newest first
// With rolls kept to oneself, a player sees only their own; the DM still sees all.
const open = (await roll(dmAuth, { kind: 'check', label: 'In the open' })).body.roll;
expect((await seen(playerAuth)).map((x) => x.id)).toContain(open.id);
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ rollVisibility: 'OWN' });
expect((await seen(playerAuth)).every((x) => x.rollerName === 'Player')).toBe(true);
expect((await seen(dmAuth)).length).toBeGreaterThan((await seen(playerAuth)).length);
});
itIfDb('follow the campaign\u2019s dice: typed in and checked with physical dice, never typed in with digital', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('rollsmode');
const roll = (body) => request(app).post(`/campaigns/${campaign.id}/rolls`).set(playerAuth).send(body);
const set = (diceMode) => request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ diceMode });
// Both: either way.
expect((await roll({ kind: 'check', values: [14], modifier: 2 })).body.roll).toMatchObject({ total: 16, natural: 14, physical: true });
expect((await roll({ kind: 'check', values: [21] })).status).toBe(400);
expect((await roll({ kind: 'check', mode: 'DISADVANTAGE', values: [14] })).status).toBe(400); // two d20s
expect((await roll({ kind: 'check', mode: 'DISADVANTAGE', values: [14, 6] })).body.roll.natural).toBe(6);
expect((await roll({ kind: 'dice', expression: '1d8+1d6+1', values: [8, 6] })).body.roll.total).toBe(15);
expect((await roll({ kind: 'dice', expression: '1d8+1d6', values: [8, 7] })).status).toBe(400);
await set('PHYSICAL');
expect((await roll({ kind: 'check' })).status).toBe(400);
expect((await roll({ kind: 'check', values: [3] })).status).toBe(201);
await set('DIGITAL');
expect((await roll({ kind: 'check', values: [20] })).status).toBe(400);
expect((await roll({ kind: 'check' })).status).toBe(201);
});
});
+184 -1
View File
@@ -14,6 +14,7 @@ const { hitDiceProblem, shortRest, longRest } = require('../utils/rest');
const { CURRENCIES } = require('../utils/grants');
const { subclassName } = require('../game/data/subclasses');
const { classLevels } = require('../game/multiclass');
const { parseDice, diceOf, validValues, rollExpression, rollD20, d20Count, D20_MODES, MAX_DICE } = require('../game/dice');
const { findSeed, normalizeCode, CODE_LENGTH } = require('../seeds');
const { createCampaignFromSeed } = require('../seeds/createCampaign');
@@ -255,6 +256,7 @@ function characterSummary(character, subclasses = true) {
characterClass: character.characterClass,
subclassName: subclasses ? subclassName(character.characterClass, character.subclass) : null,
level: character.level,
levelUpsPending: character.levelUpsPending,
// Every class it has levels in; one entry unless it has multiclassed.
classes: classLevels(character).map((entry) => ({
characterClass: entry.characterClass,
@@ -262,7 +264,8 @@ function characterSummary(character, subclasses = true) {
subclassName: subclasses ? subclassName(entry.characterClass, entry.subclass) : null,
})),
xp: character.xp,
xpProgress: xpProgress(character.xp, character.level),
// A level already approved is no longer due.
xpProgress: xpProgress(character.xp, Math.min(MAX_LEVEL, character.level + character.levelUpsPending)),
buildStage: character.buildStage,
currentHp: character.currentHp,
tempHp: character.tempHp,
@@ -369,6 +372,62 @@ router.post('/:id/characters', requireCampaignMember(), async (req, res, next) =
}
});
// ---------------------------------------------------------------------------
// Level-ups — the DM approves a level for any of the party at once. Each
// approval is a checklist waiting (Character.levelUpsPending): the
// character's player is told and makes their own picks (POST
// /characters/:id/level-up), so five players levelling together do not queue
// for the DM. A character with no player is the DM's to finish.
// ---------------------------------------------------------------------------
const approveLevelUpSchema = z.object({
characterIds: z.array(z.string()).min(1).max(200),
// Takes an approval back (one not yet used) instead of giving one.
revoke: z.boolean().default(false),
});
router.post('/:id/level-up', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = approveLevelUpSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { revoke } = parsed.data;
const ids = [...new Set(parsed.data.characterIds)];
const characters = await prisma.character.findMany({
where: { id: { in: ids }, campaignId: req.campaign.id, buildStage: 'COMPLETE' },
orderBy: { createdAt: 'asc' },
});
if (characters.length !== ids.length) return res.status(400).json({ error: 'Pick finished characters from this campaign.' });
const results = characters.map((character) => {
const who = { characterId: character.id, name: character.name, userId: character.userId, level: character.level };
if (revoke) {
return character.levelUpsPending > 0
? { ...who, levelUpsPending: character.levelUpsPending - 1, skipped: null }
: { ...who, levelUpsPending: 0, skipped: `${character.name} has no level up waiting.` };
}
if (character.level + character.levelUpsPending >= MAX_LEVEL) {
return { ...who, levelUpsPending: character.levelUpsPending, skipped: `${character.name} is already at (or approved up to) level ${MAX_LEVEL}.` };
}
return { ...who, levelUpsPending: character.levelUpsPending + 1, skipped: null };
});
const changed = results.filter((result) => !result.skipped);
await prisma.$transaction(
changed.map((result) => prisma.character.update({ where: { id: result.characterId }, data: { levelUpsPending: result.levelUpsPending } }))
);
const io = req.app.get('io');
for (const { characterId, userId } of changed) {
io.to(`campaign:${req.campaign.id}`).emit('character:updated', { characterId });
// Tells the character's player there is a level to take (ids only).
if (!revoke) io.to(`campaign:${req.campaign.id}`).emit('character:level-up-granted', { characterId, userId });
}
res.json({ revoke, results });
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Rests — the DM rests any of the party at once (utils/rest.js)
// ---------------------------------------------------------------------------
@@ -1185,4 +1244,128 @@ router.post('/:id/monsters/from-library', requireCampaignMember(), requireDM(),
}
});
// ---------------------------------------------------------------------------
// Dice rolls — the table's dice roller (game/dice.js). A digital roll is made
// here, never taken from the client; a physical one is typed in and checked
// against its dice. Every roll is kept and announced, so the table (and above
// all the DM) sees it.
// ---------------------------------------------------------------------------
const rollSchema = z
.object({
label: z.string().max(120).optional().nullable(),
// A d20 plus `modifier` (a check, a save, an attack), or a dice expression (damage).
kind: z.enum(['check', 'dice']),
modifier: z.number().int().min(-99).max(99).default(0),
mode: z.enum(D20_MODES).default('NORMAL'),
expression: z.string().max(60).optional(),
// Dice rolled at the table: what each showed, in order. Left out, the dice are rolled here.
values: z.array(z.number().int().min(1).max(1000)).max(MAX_DICE).optional(),
hidden: z.boolean().default(false), // the DM's alone
encounterId: z.string().optional().nullable(),
})
.refine((roll) => roll.kind === 'check' || roll.expression, { message: 'A dice roll needs its dice, like "2d6+3".' });
// Rolls older than this are cleared away as new ones come in.
const ROLL_KEEP_DAYS = 14;
const publicRoll = ({ id, userId, rollerName, label, notation, dice, modifier, total, natural, mode, physical, hidden, encounterId, createdAt }) => ({
id,
userId,
rollerName,
label,
notation,
dice,
modifier,
total,
natural,
mode,
physical,
hidden,
encounterId,
createdAt,
});
// Which rolls a member may see: the DM every one; a player their own, and
// everyone's (but the DM's hidden ones) where the campaign shows all rolls.
function visibleRolls(req) {
if (req.membership.role === 'DM') return {};
if (req.campaign.rollVisibility === 'OWN') return { userId: req.user.id };
return { OR: [{ userId: req.user.id }, { hidden: false }] };
}
router.post('/:id/rolls', requireCampaignMember(), async (req, res, next) => {
try {
const parsed = rollSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { label, kind, modifier, mode, expression, values, encounterId } = parsed.data;
const isDM = req.membership.role === 'DM';
// The campaign's dice decide whether a roll is made here or typed in.
const diceMode = req.campaign.diceMode;
if (values && diceMode === 'DIGITAL') return res.status(400).json({ error: 'This campaign plays with digital dice: the app rolls.' });
if (!values && diceMode === 'PHYSICAL') return res.status(400).json({ error: 'This campaign plays with physical dice: roll at the table and enter what the dice show.' });
let rolled;
let notation;
if (kind === 'check') {
const dice = Array.from({ length: d20Count(mode) }, () => ({ sides: 20 }));
if (values && !validValues(dice, values)) {
return res.status(400).json({ error: `Enter ${dice.length === 1 ? 'the d20' : 'both d20s'} you rolled: whole numbers from 1 to 20.` });
}
rolled = rollD20(modifier, mode, { values });
notation = `1d20${modifier ? (modifier > 0 ? `+${modifier}` : `${modifier}`) : ''}`;
} else {
const dice = parseDice(expression);
if (!dice) return res.status(400).json({ error: 'Those dice cannot be read. Write them like "2d6+3".' });
if (values && !validValues(diceOf(dice), values)) {
return res.status(400).json({ error: 'Enter what each die showed, in order, each within its die.' });
}
rolled = { ...rollExpression(dice, { values }), natural: null };
notation = expression.replace(/\s+/g, '');
}
const [roll] = await prisma.$transaction([
prisma.diceRoll.create({
data: {
campaignId: req.campaign.id,
userId: req.user.id,
rollerName: req.user.displayName || 'Someone',
label: label || null,
notation,
dice: rolled.dice,
modifier: rolled.modifier,
total: rolled.total,
natural: rolled.natural,
mode: kind === 'check' ? mode : 'NORMAL',
physical: Boolean(values),
hidden: isDM && parsed.data.hidden, // only the DM rolls behind the screen
encounterId: encounterId || null,
},
}),
prisma.diceRoll.deleteMany({ where: { campaignId: req.campaign.id, createdAt: { lt: new Date(Date.now() - ROLL_KEEP_DAYS * 24 * 60 * 60 * 1000) } } }),
]);
// Ids only: each member's page asks for the roll and gets it only if it is theirs to see.
req.app.get('io').to(`campaign:${req.campaign.id}`).emit('roll:created', { rollId: roll.id, userId: roll.userId });
res.status(201).json({ roll: publicRoll(roll) });
} catch (err) {
next(err);
}
});
// GET /campaigns/:id/rolls — the latest rolls this member may see, newest first.
router.get('/:id/rolls', requireCampaignMember(), async (req, res, next) => {
try {
const limit = Math.min(Math.max(Number(req.query.limit) || 30, 1), 100);
const rolls = await prisma.diceRoll.findMany({
where: { campaignId: req.campaign.id, ...visibleRolls(req) },
orderBy: { createdAt: 'desc' },
take: limit,
});
res.json({ rolls: rolls.map(publicRoll) });
} catch (err) {
next(err);
}
});
module.exports = router;
+65 -8
View File
@@ -8,6 +8,7 @@ const {
computeCharacterSheet,
computeWeaponStats,
getClassSaveProficiencies,
getClassHitDieSize,
getClassSpellcastingAbility,
scaledDamageDice,
canUseGear,
@@ -65,7 +66,7 @@ async function loadFullCharacter(id) {
spells: { include: { spell: true } },
currentLocation: true,
classLevels: true,
campaign: { select: { subclassesEnabled: true, multiclassEnabled: true, diceMode: true } },
campaign: { select: { subclassesEnabled: true, multiclassEnabled: true, diceMode: true, hpOnLevelUp: true } },
},
});
}
@@ -103,6 +104,8 @@ function serializeCharacter(character, isOwner, isDM = true) {
// The 2024 subclass that updates the one the character follows, if any.
subclassUpgrade: subclasses && newerVersionOf(character.characterClass, character.subclass) ? subclassSummary(newerVersionOf(character.characterClass, character.subclass)) : null,
level: character.level,
// Levels the DM has approved that are still to be taken (their picks made).
levelUpsPending: character.levelUpsPending,
// Every class the character has levels in, the first first; one entry
// unless they have multiclassed.
classes: classLevels(character).map((entry) => ({
@@ -112,7 +115,8 @@ function serializeCharacter(character, isOwner, isDM = true) {
subclassName: subclasses ? subclassName(entry.characterClass, entry.subclass) : null,
})),
xp: character.xp,
xpProgress: xpProgress(character.xp, character.level),
// A level already approved is no longer due.
xpProgress: xpProgress(character.xp, Math.min(MAX_LEVEL, character.level + character.levelUpsPending)),
motivation: character.motivation,
speciesBonusChoices: character.speciesBonusChoices || {},
rolledScores: {
@@ -549,11 +553,14 @@ router.post('/:id/resources', requireCharacterAccess({ write: true }), async (re
// and the free weapon upgrade.
// ---------------------------------------------------------------------------
// Answers the request and returns false when this character cannot be levelled up.
// Answers the request and returns false when this character cannot be levelled
// up. The DM always can; the character's own player can once the DM has
// approved a level for them (levelUpsPending).
function canLevelUp(req, res, character) {
let refusal = null;
if (!req.isDM) refusal = [403, 'Only the DM can level a character up.'];
else if (character.buildStage !== 'COMPLETE') refusal = [400, 'Character build is not finished yet.'];
if (!req.isDM && !(req.isOwner && character.levelUpsPending > 0)) {
refusal = [403, req.isOwner ? 'Your DM has not approved a level up yet.' : 'Only the DM can level a character up.'];
} else if (character.buildStage !== 'COMPLETE') refusal = [400, 'Character build is not finished yet.'];
else if (character.level >= MAX_LEVEL) refusal = [400, `${character.name} is already level ${MAX_LEVEL}, the highest there is.`];
if (refusal) res.status(refusal[0]).json({ error: refusal[1] });
return !refusal;
@@ -586,6 +593,19 @@ function planFor(req, res, character, toClass) {
return { plan, sheet };
}
// How the level's hit points are come by: the campaign's method, the die of
// the class taking the level, the average it would count, and whether the
// die is rolled at the table (physical dice) and typed in.
function hpGain(character, plan) {
const die = getClassHitDieSize(plan.characterClass);
return {
method: (character.campaign && character.campaign.hpOnLevelUp) || 'AVERAGE',
die,
average: Math.floor(die / 2) + 1,
physical: effectiveDiceMode(character.campaign, character) === 'PHYSICAL',
};
}
// What the next level brings, for the DM to look over before saying yes.
// `?class=` asks about a level in another class (multiclassing).
router.get('/:id/level-up', requireCharacterAccess(), async (req, res, next) => {
@@ -596,7 +616,17 @@ router.get('/:id/level-up', requireCharacterAccess(), async (req, res, next) =>
if (!planned) return;
const { plan, sheet } = planned;
const after = sheetOf({ ...withLevelIn(character, plan.characterClass), spellSlotsUsed: [] });
res.json({ plan: { ...plan, maxHp: { from: sheet.hp.max, to: after.hp.max }, spellSlots: after.spellSlots } });
// The spells that could be granted now: the campaign's, of a level the
// class can cast, that the character does not know. Sent with the plan
// because a player otherwise sees only the spells they hold.
const spellOptions = plan.spells
? await prisma.spell.findMany({
where: { campaignId: character.campaignId, level: { lte: plan.spells.maxSpellLevel }, id: { notIn: character.spells.map((held) => held.spellId) } },
select: { id: true, name: true, level: true, school: true, description: true },
orderBy: [{ level: 'asc' }, { name: 'asc' }],
})
: [];
res.json({ plan: { ...plan, maxHp: { from: sheet.hp.max, to: after.hp.max }, hp: hpGain(character, plan), spellSlots: after.spellSlots, spellOptions } });
} catch (err) {
next(err);
}
@@ -607,6 +637,7 @@ const levelUpSchema = z.object({
subclass: z.string().optional(), // which subclass, at the level the class chooses one
boon: z.string().optional(), // which Boon, at a level that grants one
weaponId: z.string().nullable().optional(), // the weapon that gets the free upgrade
hpRoll: z.number().int().min(1).max(12).optional(), // the hit die rolled at the table, where HP is rolled with physical dice
spellIds: z.array(z.string()).max(50).default([]), // spells newly granted
});
@@ -635,6 +666,14 @@ router.post('/:id/level-up', requireCharacterAccess({ write: true }), async (req
if (new Set(spellIds).size !== spellIds.length || spellsFound !== spellIds.length) {
return res.status(400).json({ error: "Pick the new spells from this campaign's spell list, each once." });
}
// The DM grants what they like; a player picks within what the level allows.
if (!req.isDM && spellIds.length > 0) {
const allowed = plan.spells ? plan.spells.newSpells + plan.spells.newCantrips : 0;
const inReach = plan.spells ? await prisma.spell.count({ where: { id: { in: spellIds }, level: { lte: plan.spells.maxSpellLevel } } }) : 0;
if (spellIds.length > allowed || inReach !== spellIds.length) {
return res.status(400).json({ error: allowed > 0 ? `This level allows ${allowed} new spell${allowed === 1 ? '' : 's'}, of a level you can cast.` : 'This level grants no new spell.' });
}
}
// The subclass picked, or the one taken for them where the campaign has no choosing.
const newSubclass = subclass || plan.subclassTaken || null;
@@ -648,7 +687,21 @@ router.post('/:id/level-up', requireCharacterAccess({ write: true }), async (req
return res.status(400).json({ error: err.message });
}
const armorBonus = clamp(character.armorBonus + gained.armorBonus, -30, 30);
const maxHp = sheetOf({ ...withLevelIn(character, plan.characterClass, newSubclass || undefined), armorBonus }).hp.max;
// Rolled hit points: the die is rolled here, or typed in from the table.
const hp = hpGain(character, plan);
let hpRolled = null;
if (hp.method === 'ROLL') {
if (hp.physical) {
if (!validateManualRoll(hp.die, parsed.data.hpRoll)) {
return res.status(400).json({ error: `Roll a d${hp.die} for hit points and enter what it shows (1 to ${hp.die}).` });
}
hpRolled = parsed.data.hpRoll;
} else {
hpRolled = rollDie(hp.die);
}
}
const hpRollAdjust = character.hpRollAdjust + (hpRolled == null ? 0 : hpRolled - hp.average);
const maxHp = sheetOf({ ...withLevelIn(character, plan.characterClass, newSubclass || undefined), armorBonus, hpRollAdjust }).hp.max;
await prisma.$transaction(async (tx) => {
if (weaponId) {
@@ -670,6 +723,9 @@ router.post('/:id/level-up', requireCharacterAccess({ write: true }), async (req
where: { id: character.id },
data: {
level: plan.toLevel,
hpRollAdjust,
// An approved level is now taken.
levelUpsPending: Math.max(0, character.levelUpsPending - 1),
...(first && newSubclass ? { subclass: newSubclass } : {}),
specialAbilities: gained.specialAbilities,
armorBonus,
@@ -683,7 +739,8 @@ router.post('/:id/level-up', requireCharacterAccess({ write: true }), async (req
const full = await loadFullCharacter(character.id);
emitCharacterUpdated(req);
res.json({ character: serializeCharacter(full, req.isOwner) });
// hpRolled: what the hit die showed, where HP is rolled (null otherwise).
res.json({ character: serializeCharacter(full, req.isOwner, req.isDM), hpRolled });
} catch (err) {
next(err);
}
+2 -2
View File
@@ -27,8 +27,8 @@ turning XP tracking off keeps everyone's XP; it just stops showing it.
| Setting | Default | What it does |
|---|---|---|
| `diceMode` | `BOTH` | `DIGITAL`: the app rolls every die. `PHYSICAL`: everyone rolls real dice and types in what they show (the server checks each value is on the die). `BOTH`: each character picks one or the other before their first build roll. |
| `rollVisibility` | `ALL` | `ALL`: everyone sees every roll. `OWN`: a player sees only their own rolls. The DM always sees every roll. |
| `diceMode` | `BOTH` | `DIGITAL`: the app rolls every die. `PHYSICAL`: everyone rolls real dice and types in what they show (the server checks each value is on the die). `BOTH`: either. In the builder each character picks one before their first roll; in the dice roller each person ticks "I roll real dice" when they want to. It covers the builder, the dice roller and the hit point roll on a level up. |
| `rollVisibility` | `ALL` | Who sees a roll made in the dice roller. `ALL`: everyone sees every roll. `OWN`: a player sees only their own rolls. The DM always sees every roll, and can roll hidden either way. |
| `hideMonsterRolls` | on | The DM's rolls for monsters and NPCs stay hidden from players until they are put into play. |
## Combat
+79 -19
View File
@@ -362,8 +362,10 @@ Ability scores cannot be changed after the build is finalized.
## 6. Level and XP
- Every character starts at **level 1**. The range is 1 to 20.
- Only the DM can change level or XP. There are two ways to change a level,
both on the sheet: the **⬆ Level Up…** button, and the Level field.
- Only the DM decides that a character levels, and only the DM changes XP. A
level is taken in one of three ways: the DM **approves** it and the player
takes it themselves (below), the DM levels the character directly with
**⬆ Level up now…** on the sheet, or the DM types into the Level field.
- **XP** (experience points) is shown under the character's name on the sheet
as "37 / 300 XP": what they have, and what their next level takes. The party
overview shows the same under each level.
@@ -372,7 +374,7 @@ Ability scores cannot be changed after the build is finalized.
The DM can also type a character's XP into the **XP** field beside Level.
- **XP does not level a character up.** When a character has the XP for
their next level the sheet shows "⬆ Level up due" (the party overview shows
⬆), and the DM levels them with "⬆ Level Up…" when the table is ready. A DM
⬆), and the DM approves the level when the table is ready. A DM
who levels by milestones can turn **Track experience points** off in the
campaign settings. That hides XP everywhere, closes the award panel, and
leaves levelling entirely to the DM.
@@ -385,16 +387,50 @@ Ability scores cannot be changed after the build is finalized.
| 4 | 2,700 | 9 | 48,000 | 14 | 140,000 | 19 | 305,000 |
| 5 | 6,500 | 10 | 64,000 | 15 | 165,000 | 20 | 355,000 |
### Approving a level, and who takes it
So that five players levelling together do not queue for the DM, the DM
**approves** and each player **takes** their own level:
1. The DM presses **⬆ Level up…** on the campaign page or the party overview
(beside 🛏 Rest…). A pop-out lists the party; with XP tracked, those who have
the XP for their next level start ticked ("Everyone", "Those with the XP"
and "Nobody" tick in one go). **Approve** gives each ticked character one
level to take. One character can also be approved from their sheet
(**⬆ Approve a level up**).
2. Each approved character's player is told at once, wherever in the campaign
they are: a notice slides in ("⬆ Aria can level up!") with a button
straight to the checklist. If they are not online, it waits: their sheet
and their row on the campaign page show "⬆ Level up ready" until it is taken.
3. The player opens the checklist, sees what the level gives, makes the picks
that are theirs (the class for a multiclass, subclass, Boon, new spells,
the weapon upgrade, the hit point roll) and confirms. The level is theirs
then, healed to full, as if the DM had done it.
Things to know:
- An approval is one level. Approving twice lets a character take two, one
checklist at a time. "Take one back" in the pop-out undoes an approval that
has not been used.
- The DM sees who still has picks to make: a banner on the party overview,
and a badge on each character. The DM can open the checklist and finish it
for anyone ("Open the checklist" on the sheet).
- A character with no player (an ally the DM runs) is the DM's to finish.
- A player may pick as many new spells as the level allows, of a level they
can cast; the DM, doing it for them, is not held to that.
- Once approved, a level no longer shows as "due".
### Level Up: the checklist
"⬆ Level Up…" takes a character up **one level** and does everything that
goes with it. It opens a pop-out that lists the five steps and asks for the
choices; nothing changes until the DM presses "Level up to N".
The checklist takes a character up **one level** and does everything that
goes with it. It opens as a pop-out that lists the steps and asks for the
choices; nothing changes until "Level up to N" is pressed. It is the same
whether a player takes an approved level or the DM uses **⬆ Level up now…**.
| Step | What the site does | What the DM chooses |
| Step | What the site does | What is chosen |
|---|---|---|
| 1. Level | Raises the level by one. Proficiency bonus, saves, skills and spell DC follow | Nothing |
| 2. Hit points | Works out the new max HP and heals to it (a dead character stays dead). One more Hit Die | Nothing |
| 1. Level | Raises the level by one. Proficiency bonus, saves, skills and spell DC follow | With multiclassing on: which class gains the level |
| 2. Hit points | Works out the new max HP and heals to it (a dead character stays dead). One more Hit Die. Where the campaign **rolls** hit points, the class's die is rolled by the site, or rolled at the table and typed in (physical dice); the roll takes the place of the average for good | The die roll, with physical dice |
| 3. Special abilities | Writes the class's and the subclass's new features for that level into Special Abilities ([section 21](#21-what-each-class-gains-as-it-levels)) | The **subclass**, at the level the class chooses one; the **Boon**, at a level that grants one |
| 4. Spells | Gives a caster a fresh set of spell slots for the new level, and grants the spells ticked | Which spells to grant, from the campaign's spell list |
| 5. Gear | Raises one weapon by one gear level | Which weapon, or none |
@@ -826,9 +862,11 @@ calculations but is only ever set if the DM supplies it during step 5.
### Rolling from the sheet
Numbers with a dotted underline roll when clicked. The dice roller opens with
the result under a label saying what it was ("Aria: Stealth"), the die that
came up, the bonus and the total. A natural 20 or 1 is called out.
Numbers with a dotted underline roll when clicked. The dice roller opens
under a label saying what the roll is ("Aria: Stealth"): the dice tumble in,
each drawn as its own shape (a d4's triangle, a d20's hexagon…), land on what
they rolled, and the total follows with the bonus. A natural 20 glows and a
natural 1 shakes.
| Click | Rolls |
|---|---|
@@ -847,10 +885,30 @@ came up, the bonus and the total. A natural 20 or 1 is called out.
- **Advantage and disadvantage.** The roller has Disadv. / Normal / Adv.
buttons. Whichever is lit applies to every d20 rolled until it is changed:
two d20s are rolled and the higher or lower kept, and both are shown.
- A roll is made in the browser of whoever clicked. It is not sent to the
server, nobody else sees it, and it changes nothing: a damage roll is not
applied, a spell slot is not spent, and an initiative roll still has to be
typed into its field.
- **The server rolls.** In a campaign every digital roll is made by the
server, never by the browser, and is kept. A roll changes nothing by
itself: a damage roll is not applied, a spell slot is not spent, and an
initiative roll still has to be typed into its field.
- **The DM sees every roll.** Each roll is announced to the table as it is
made. The roller's second tab ("Everyone's rolls" for the DM, "Table" for a
player) lists them, newest first: who rolled, what for, the dice as they
fell, the total, and whether it was a natural 20 or 1, rolled with
advantage, or rolled with real dice. Somebody else's roll also pops up
beside the dice button for a few seconds as it lands, and an encounter page
has the same list in its side column for the DM. What a **player** sees
follows the campaign's "Who sees a roll" setting: everyone's rolls, or only
their own.
- **Hidden rolls.** The DM can tick "🙈 Roll hidden" in the roller: such a
roll is the DM's alone, whatever the setting.
- **Real dice.** The campaign's "Dice at this table" setting decides how a
roll is made. *Digital*: the app rolls. *Physical*: the roller asks for the
dice to be rolled at the table and typed in, one box per die (both d20s
with advantage or disadvantage), and the server checks each value is on its
die. *Both*: the app rolls unless the roller's "🎯 I roll real dice" is
ticked, which each person chooses for themselves. A typed-in roll is marked
"at the table" in the list.
- Rolls are kept for two weeks. Outside a campaign (nothing opened yet) the
roller still works, rolling in the browser and showing nobody.
- Of dice written with more after them ("2d8+3 fire") the dice are rolled.
Text that does not start with dice (a DM may type anything as an item's or
a spell's dice) is shown as plain text.
@@ -864,8 +922,9 @@ advantage or disadvantage; untick anyone who is not rolling. Each character
gets their own d20 and their own bonus, and with a DC each is marked "made it"
or "failed", with how many made it and whether a group check (at least half)
succeeds. On the party overview it offers every finished character who is not
dead; in an encounter, the characters in it who are still up. These rolls are
the DM's own, like any other: the players do not see them.
dead; in an encounter, the characters in it who are still up. These are the
DM's own quick rolls, made on the DM's page: they are not in the table's list
and the players do not see them.
## 10. Gear pools, starting options and gear levels
@@ -1238,7 +1297,8 @@ Another Campaign" card on its sheet.
| Give inspiration, or take it away | No | Yes | No |
| Spend inspiration | Yes | Yes | No |
| Set exhaustion; give or remove a condition | No | Yes | No |
| Level a character up (the checklist), pick a subclass or a Boon | No | Yes | No |
| Approve a level up, level a character directly, change the Level field | No | Yes | No |
| Take an approved level (the checklist): pick the subclass, Boon, spells, weapon | Yes, once approved | Yes | No |
| Set or change the subclass on the sheet (in a campaign that uses subclasses) | No | Yes | No |
| Switch subclasses on or off for the campaign | No | Yes | No |
| Edit special abilities, background, appearance (after the build) | No | Yes | No |
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.24.0",
"version": "1.26.0",
"type": "module",
"scripts": {
"dev": "vite",
+42 -3
View File
@@ -9,6 +9,7 @@ import type {
CharacterDetail,
CharacterSummary,
Currency,
DiceRollRecord,
DiceMode,
Encounter,
EncounterMode,
@@ -16,6 +17,7 @@ import type {
GearEffect,
GoldTransaction,
Item,
LevelUpApproval,
LevelUpPlan,
Location,
MulticlassReference,
@@ -27,6 +29,7 @@ import type {
QuickStats,
RestResult,
RestType,
RollSpec,
Spell,
Weapon,
} from './types';
@@ -268,9 +271,9 @@ export function useLevelUpPlan(characterId: string, enabled: boolean, toClass: C
export function useLevelUp(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { characterClass?: CharacterClass; subclass?: string; boon?: string; weaponId?: string | null; spellIds?: string[] }) =>
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/level-up`, data)).data.character,
onSuccess: (character) => {
mutationFn: async (data: { characterClass?: CharacterClass; subclass?: string; boon?: string; weaponId?: string | null; spellIds?: string[]; hpRoll?: number }) =>
(await apiClient.post<{ character: CharacterDetail; hpRolled: number | null }>(`/characters/${characterId}/level-up`, data)).data,
onSuccess: ({ character }) => {
invalidateCharacter(qc, characterId);
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows the sheet's HP
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
@@ -294,6 +297,20 @@ export function useRest(campaignId: string) {
});
}
// The DM approves a level for any of the party at once (or takes an approval
// back); each character's player then takes theirs.
export function useApproveLevelUps(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { characterIds: string[]; revoke?: boolean }) =>
(await apiClient.post<{ revoke: boolean; results: LevelUpApproval[] }>(`/campaigns/${campaignId}/level-up`, data)).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['character'] });
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
},
});
}
export function useDeleteCharacter(campaignId: string) {
const qc = useQueryClient();
return useMutation({
@@ -365,6 +382,28 @@ export function useBuildOptions() {
});
}
// The latest rolls at the table that this member may see, newest first.
export function useRolls(campaignId: string | null | undefined) {
return useQuery({
queryKey: ['campaign', campaignId, 'rolls'],
enabled: Boolean(campaignId),
queryFn: async () => (await apiClient.get<{ rolls: DiceRollRecord[] }>(`/campaigns/${campaignId}/rolls`, { params: { limit: 40 } })).data.rolls,
});
}
// Makes a roll at the table: the server rolls it (or checks dice typed in),
// keeps it and tells the table.
export function useRoll(campaignId: string | null | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (spec: RollSpec) => (await apiClient.post<{ roll: DiceRollRecord }>(`/campaigns/${campaignId}/rolls`, spec)).data.roll,
onSuccess: (roll) => {
// Shown at once in the feed, without waiting for the server to say so.
qc.setQueryData<DiceRollRecord[]>(['campaign', campaignId, 'rolls'], (rolls) => (rolls && !rolls.some((r) => r.id === roll.id) ? [roll, ...rolls] : rolls));
},
});
}
// Multiclassing requirements and the suggested routes.
export function useMulticlassReference(enabled: boolean) {
return useQuery({
+60
View File
@@ -303,6 +303,7 @@ export interface CharacterSummary extends CharacterCondition {
characterClass: CharacterClass | null;
subclassName: string | null;
level: number;
levelUpsPending: number; // levels the DM has approved, still to be taken
classes: ClassLevel[];
xp: number;
xpProgress: XpProgress;
@@ -508,6 +509,7 @@ export interface CharacterDetail extends CharacterCondition {
// The 2024 subclass that updates the one followed, if there is one.
subclassUpgrade: SubclassSummary | null;
level: number;
levelUpsPending: number; // levels the DM has approved, still to be taken
classes: ClassLevel[];
xp: number;
xpProgress: XpProgress;
@@ -922,6 +924,9 @@ export interface LevelUpPlan {
// With multiclassing on: every class the level could go to, and why not.
classChoice: { options: { characterClass: CharacterClass; level: number; eligible: boolean; problems: string[] }[] } | null;
maxHp: { from: number; to: number };
// How the level's hit points are come by: the average, or a roll of the
// class's die (typed in from the table when `physical`).
hp: { method: HpMethod; die: number; average: number; physical: boolean };
// Set when a subclass has to be picked on the way to this level: the level
// the class chooses at, and each option with everything it would write.
subclass: { level: number; options: { key: string; name: string; summary: string; features: { name: string; text: string }[] }[] } | null;
@@ -934,11 +939,66 @@ export interface LevelUpPlan {
// What the book lets a caster be granted now; null for a class with no spells.
spells: { maxSpellLevel: number; unlocksSpellLevel: number | null; newSpells: number; newCantrips: number } | null;
spellSlots: SpellSlots | null; // the slots at the new level
// The campaign's spells that could be granted now: of a level the class can
// cast, and not yet known.
spellOptions: { id: string; name: string; level: number; school: string | null; description: string | null }[];
// Held weapons that can take the free upgrade of one level.
weapons: { weaponId: string; name: string; equipped: boolean; level: number; damageDice: string; nextDamageDice: string }[];
beyondTables: boolean; // past level 20, where the class tables stop
}
// What approving (or taking back) a level did for one character (POST /campaigns/:id/level-up).
export interface LevelUpApproval {
characterId: string;
name: string;
userId: string | null;
level: number;
levelUpsPending: number;
skipped: string | null; // why nothing changed for them
}
// One die of a roll: its size, what it showed, whether it counted (the lower
// d20 with advantage does not), and whether it adds (1) or takes away (-1).
// `maxed`: counted at its most rather than rolled (a critical hit).
export interface RolledDie {
sides: number;
value: number;
kept: boolean;
sign: number;
maxed?: boolean;
}
// A roll made at the table's dice roller, as everyone who may see it gets it.
export interface DiceRollRecord {
id: string;
userId: string;
rollerName: string;
label: string | null; // what it was for: "Aria: Stealth"
notation: string; // "1d20+3"
dice: RolledDie[];
modifier: number;
total: number;
natural: number | null; // the d20 that counted, for a single d20
mode: 'NORMAL' | 'ADVANTAGE' | 'DISADVANTAGE';
physical: boolean; // rolled at the table and typed in
hidden: boolean; // the DM's alone
encounterId: string | null;
createdAt: string;
}
// What is asked of the server for a roll: a d20 plus a modifier, or dice.
// `values` are dice rolled at the table, in order.
export interface RollSpec {
label?: string | null;
kind: 'check' | 'dice';
modifier?: number;
mode?: 'NORMAL' | 'ADVANTAGE' | 'DISADVANTAGE';
expression?: string;
values?: number[];
hidden?: boolean;
encounterId?: string | null;
}
export type RestType = 'SHORT' | 'LONG';
// What a rest did for one character (POST /campaigns/:id/rest).
+361 -145
View File
@@ -1,10 +1,21 @@
import { useEffect, useRef, useState } from 'react';
import { fmtMod } from '../lib/format';
import { useCampaign, useRoll, useRolls } from '../api/queries';
import { useAuth } from '../auth/useAuth';
import { useLastCampaignId } from '../lib/lastCampaign';
import { extractErrorMessage, fmtMod } from '../lib/format';
import { onRollRequest, parseDice, rollD20, rollDie, type RollMode, type RollRequest } from '../lib/diceRoller';
import { DiceRow } from './dice/Die';
import { settleMs } from '../lib/diceAnimation';
import { RollFeed, RollLine } from './RollFeed';
import { NumberInput } from './NumberInput';
import type { DiceRollRecord, RollSpec, RolledDie } from '../api/types';
const DICE = [4, 6, 8, 10, 12, 20, 100];
const MAX_DICE = 20;
const HISTORY_LENGTH = 8;
// How long another player's roll stays up beside the dice button.
const INCOMING_MS = 6000;
const BY_HAND_KEY = 'dice-by-hand';
const MODES: { mode: RollMode; label: string; title: string }[] = [
{ mode: 'DISADVANTAGE', label: 'Disadv.', title: 'Disadvantage: roll two d20s and keep the lower' },
@@ -12,77 +23,164 @@ const MODES: { mode: RollMode; label: string; title: string }[] = [
{ mode: 'ADVANTAGE', label: 'Adv.', title: 'Advantage: roll two d20s and keep the higher' },
];
interface Roll {
id: number;
label?: string; // what it was for, when it was asked for from a sheet
notation: string; // "1d20+3", "1d8+1d6+2"
dice: number[]; // every die rolled, in order
modifier: number;
total: number;
// The d20 that counted, for a roll of a single d20 (a natural 20 or 1 means something).
natural: number | null;
mode: RollMode; // how a single d20 was rolled
}
function notation(count: number, sides: number, modifier: number): string {
return `${count}d${sides}${modifier ? fmtMod(modifier) : ''}`;
}
// A d20 plus a bonus, with advantage or disadvantage as chosen.
function d20Roll(modifier: number, mode: RollMode, label?: string): Omit<Roll, 'id'> {
const { kept, dice } = rollD20(mode);
return { label, notation: notation(1, 20, modifier), dice, modifier, total: kept + modifier, natural: kept, mode };
// What a spec is written as: "1d20+3", "2d6+1".
function specNotation(spec: RollSpec): string {
return spec.kind === 'check' ? notation(1, 20, spec.modifier ?? 0) : (spec.expression ?? '').replace(/\s+/g, '');
}
// What a request from a sheet comes to; null for dice that cannot be read.
function answer(request: RollRequest, mode: RollMode): Omit<Roll, 'id'> | null {
if (request.kind === 'check') return d20Roll(request.modifier, mode, request.label);
const parsed = parseDice(request.expression);
if (!parsed) return null;
const dice: number[] = [];
let total = parsed.flat;
for (const term of parsed.dice) {
for (let i = 0; i < term.count; i++) {
const die = rollDie(term.sides);
dice.push(die);
total += term.sign * die;
}
// The size of each die a spec rolls, in order; null for dice that cannot be read.
function specDice(spec: RollSpec): number[] | null {
if (spec.kind === 'check') return spec.mode && spec.mode !== 'NORMAL' ? [20, 20] : [20];
const parsed = parseDice(spec.expression ?? '');
return parsed ? parsed.dice.flatMap((term) => Array.from({ length: term.count }, () => term.sides)) : null;
}
let localId = 0;
// A roll made in the browser, in the shape the server gives: used outside a
// campaign, where there is no table to show it to. `values` are dice typed in.
function localRoll(spec: RollSpec, rollerName: string, values?: number[]): DiceRollRecord | null {
const base = { id: `local-${++localId}`, userId: 'local', rollerName, label: spec.label ?? null, physical: Boolean(values), hidden: false, encounterId: null, createdAt: new Date().toISOString() };
if (spec.kind === 'check') {
const mode = spec.mode ?? 'NORMAL';
const rolled = values ?? rollD20(mode).dice;
const natural = mode === 'ADVANTAGE' ? Math.max(...rolled) : mode === 'DISADVANTAGE' ? Math.min(...rolled) : rolled[0];
const keptAt = rolled.indexOf(natural);
const dice: RolledDie[] = rolled.map((value, i) => ({ sides: 20, value, kept: i === keptAt, sign: 1 }));
return { ...base, notation: specNotation(spec), dice, modifier: spec.modifier ?? 0, total: natural + (spec.modifier ?? 0), natural, mode };
}
return { label: request.label, notation: request.expression.replace(/\s+/g, ''), dice, modifier: parsed.flat, total, natural: null, mode: 'NORMAL' };
const parsed = parseDice(spec.expression ?? '');
if (!parsed) return null;
let at = 0;
const dice: RolledDie[] = parsed.dice.flatMap((term) =>
Array.from({ length: term.count }, () => ({ sides: term.sides, value: values ? values[at++] : rollDie(term.sides), kept: true, sign: term.sign }))
);
return { ...base, notation: specNotation(spec), dice, modifier: parsed.flat, total: dice.reduce((sum, d) => sum + d.sign * d.value, 0) + parsed.flat, natural: null, mode: 'NORMAL' };
}
// A table-side dice roller, reachable from every page. It rolls what is set
// up here, and whatever is clicked on a sheet or an encounter card (a skill,
// a save, an attack, damage: lib/diceRoller.ts), with the bonus already in.
// These rolls are for the table: they are not sent to the server and do not
// feed the character builder, whose dice stay locked in server-side.
// The table's dice roller, reachable from every page. It rolls what is set up
// here, and whatever is clicked on a sheet or an encounter card (a skill, a
// save, an attack, damage: lib/diceRoller.ts), with the bonus already in.
//
// In a campaign the server makes every digital roll, keeps it and tells the
// table: the DM sees each roll as it lands, and players see what the
// campaign's "who sees a roll" setting allows. The campaign's dice setting
// decides how a roll is made: by the app, by real dice typed in, or either.
// Outside a campaign the dice are rolled here and go nowhere.
export function DiceRoller() {
const { user } = useAuth();
const lastCampaignId = useLastCampaignId();
const { data: campaignData } = useCampaign(user && lastCampaignId ? lastCampaignId : undefined);
const campaign = campaignData?.campaign;
const campaignId = campaign ? campaign.id : null;
const isDM = campaign?.role === 'DM';
const diceMode = campaign?.diceMode ?? 'DIGITAL';
const { data: feed = [] } = useRolls(campaignId);
const send = useRoll(campaignId);
const [open, setOpen] = useState(false);
const [tab, setTab] = useState<'roll' | 'table'>('roll');
const [sides, setSides] = useState(20);
const [count, setCount] = useState(1);
const [modifier, setModifier] = useState(0);
const [mode, setMode] = useState<RollMode>('NORMAL');
const [history, setHistory] = useState<Roll[]>([]);
const [hidden, setHidden] = useState(false);
const [byHand, setByHand] = useState(() => {
try {
return localStorage.getItem(BY_HAND_KEY) === '1';
} catch {
return false;
}
});
const [latest, setLatest] = useState<DiceRollRecord | null>(null);
const [history, setHistory] = useState<DiceRollRecord[]>([]); // outside a campaign only
// A roll waiting for real dice to be typed in, and what has been typed.
const [pending, setPending] = useState<RollSpec | null>(null);
const [typed, setTyped] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
// The id of somebody else's roll that has just arrived, while it is shown.
const [incomingId, setIncomingId] = useState<string | null>(null);
const push = (roll: Omit<Roll, 'id'>) => setHistory((prev) => [{ ...roll, id: (prev[0]?.id ?? 0) + 1 }, ...prev].slice(0, HISTORY_LENGTH));
// Real dice are typed in when the campaign plays with them, or allows both and the roller prefers them.
const typing = diceMode === 'PHYSICAL' || (diceMode === 'BOTH' && byHand);
// A roll asked for from elsewhere on the page: rolled, and shown. It is
// rolled the way the roller is set at that moment.
const currentMode = useRef(mode);
async function perform(spec: RollSpec, values?: number[]) {
setError(null);
let roll: DiceRollRecord | null;
if (campaignId) {
try {
roll = await send.mutateAsync({ ...spec, values, hidden: isDM && hidden });
} catch (err) {
setError(extractErrorMessage(err));
return;
}
} else {
roll = localRoll(spec, 'You', values);
if (!roll) return;
setHistory((prev) => [roll!, ...prev].slice(0, HISTORY_LENGTH));
}
setLatest(roll);
setPending(null);
setTyped([]);
}
// A roll to make: straight away, or held until its dice are typed in.
function ask(spec: RollSpec) {
if (!specDice(spec)) return;
setTab('roll');
setOpen(true);
if (typing) {
setPending(spec);
setTyped([]);
setError(null);
} else {
perform(spec);
}
}
// A roll asked for from elsewhere on the page, rolled the way the roller is set at that moment.
const current = useRef({ ask, mode });
useEffect(() => {
currentMode.current = mode;
current.current = { ask, mode };
});
useEffect(
() =>
onRollRequest((request) => {
const roll = answer(request, currentMode.current);
if (!roll) return;
push(roll);
setOpen(true);
onRollRequest((request: RollRequest) => {
const { ask: askNow, mode: modeNow } = current.current;
askNow(request.kind === 'check' ? { kind: 'check', label: request.label, modifier: request.modifier, mode: modeNow } : { kind: 'dice', label: request.label, expression: request.expression });
}),
[]
);
// Somebody else's roll arriving: shown beside the dice button for a moment,
// so the DM sees each roll without opening anything. What the feed held
// when it first loaded is not news.
const newest = feed[0];
const newestId = newest?.id ?? null;
const newestIsMine = newest?.userId === user?.id;
const seen = useRef<string | null | undefined>(undefined);
useEffect(() => {
if (!campaignId) return;
if (seen.current === undefined) {
seen.current = newestId;
return;
}
if (!newestId || newestId === seen.current) return;
seen.current = newestId;
// One's own roll is not news, and takes down whatever was showing.
const show = setTimeout(() => setIncomingId(newestIsMine ? null : newestId), 0);
const hide = setTimeout(() => setIncomingId((shown) => (shown === newestId ? null : shown)), INCOMING_MS);
return () => {
clearTimeout(show);
clearTimeout(hide);
};
}, [campaignId, newestId, newestIsMine]);
const incoming = incomingId ? (feed.find((roll) => roll.id === incomingId) ?? null) : null;
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false);
@@ -90,17 +188,29 @@ export function DiceRoller() {
return () => window.removeEventListener('keydown', onKey);
}, [open]);
const latest = history[0];
const singleD20 = sides === 20 && count === 1;
const setup: RollSpec = singleD20 ? { kind: 'check', modifier, mode } : { kind: 'dice', expression: notation(count, sides, modifier) };
const pendingDice = pending ? (specDice(pending) ?? []) : [];
const typedValues = pendingDice.map((_, i) => Number(typed[i]));
const typedOk = pendingDice.length > 0 && pendingDice.every((dieSides, i) => typed[i] && Number.isInteger(typedValues[i]) && typedValues[i] >= 1 && typedValues[i] <= dieSides);
function roll() {
if (singleD20) return push(d20Roll(modifier, mode));
const dice = Array.from({ length: count }, () => rollDie(sides));
push({ notation: notation(count, sides, modifier), dice, modifier, total: dice.reduce((sum, d) => sum + d, 0) + modifier, natural: null, mode: 'NORMAL' });
function chooseByHand(value: boolean) {
setByHand(value);
try {
localStorage.setItem(BY_HAND_KEY, value ? '1' : '0');
} catch {
// Private browsing: the choice lasts as long as the page.
}
if (!value && pending) {
// "Let the app roll" with a roll waiting: it is rolled now.
const spec = pending;
setPending(null);
perform(spec);
}
}
const natural = latest?.natural ?? null;
const modeNote = (r: Roll) => (r.mode === 'ADVANTAGE' ? 'advantage' : r.mode === 'DISADVANTAGE' ? 'disadvantage' : '');
const settle = latest ? settleMs(latest.dice.length) : 0;
return (
<>
@@ -114,111 +224,217 @@ export function DiceRoller() {
🎲
</button>
{incoming && !(open && tab === 'table') && (
<button className="dice-incoming" onClick={() => {
setTab('table');
setOpen(true);
setIncomingId(null);
}} title="Open the table's rolls">
<RollLine roll={incoming} mine={false} rolling size={30} />
</button>
)}
{open && (
<div className="dice-panel" role="dialog" aria-label="Dice roller">
<div className="flex-between">
<div className="card-title" style={{ margin: 0 }}>
Roll Dice
</div>
{campaignId ? (
<div className="dice-tabs" role="tablist">
<button role="tab" aria-selected={tab === 'roll'} className={`die-btn${tab === 'roll' ? ' is-selected' : ''}`} onClick={() => setTab('roll')}>
Roll
</button>
<button role="tab" aria-selected={tab === 'table'} className={`die-btn${tab === 'table' ? ' is-selected' : ''}`} onClick={() => setTab('table')}>
{isDM ? 'Everyone’s rolls' : 'Table'} {feed.length > 0 && <span className="combatant-sub">({feed.length})</span>}
</button>
</div>
) : (
<div className="card-title" style={{ margin: 0 }}>
Roll Dice
</div>
)}
<button className="btn btn-ghost" onClick={() => setOpen(false)} aria-label="Close the dice roller">
✕
</button>
</div>
<div className="dice-choices mt-sm" role="group" aria-label="Which die">
{DICE.map((d) => (
<button key={d} className={`die-btn${d === sides ? ' is-selected' : ''}`} aria-pressed={d === sides} onClick={() => setSides(d)}>
d{d}
</button>
))}
</div>
<div className="flex-between mt-sm">
<div className="flex-row gap-sm">
<span className="dice-label">Dice</span>
<button className="btn" aria-label="One fewer die" disabled={count <= 1} onClick={() => setCount(count - 1)}>
−
</button>
<span className="mono" aria-live="polite" style={{ minWidth: '1.5ch', textAlign: 'center' }}>
{count}
</span>
<button className="btn" aria-label="One more die" disabled={count >= MAX_DICE} onClick={() => setCount(count + 1)}>
+
</button>
</div>
<label className="flex-row gap-sm">
<span className="dice-label">Bonus</span>
<input
type="number"
min={-99}
max={99}
value={modifier}
onChange={(e) => setModifier(Math.max(-99, Math.min(99, Math.trunc(Number(e.target.value)) || 0)))}
style={{ width: 70 }}
/>
</label>
</div>
{/* For any single d20: one rolled here, and every check, save and attack clicked on a sheet. */}
<div className="dice-choices mt-sm" role="group" aria-label="How a d20 is rolled">
{MODES.map((m) => (
<button key={m.mode} className={`die-btn${m.mode === mode ? ' is-selected' : ''}`} aria-pressed={m.mode === mode} title={m.title} onClick={() => setMode(m.mode)}>
{m.label}
</button>
))}
</div>
<button className="btn btn-primary btn-block btn-lg mt-sm" onClick={roll}>
🎲 Roll {notation(count, sides, modifier)}
{singleD20 && mode !== 'NORMAL' && ` (${modeNote({ mode } as Roll)})`}
</button>
<div className="dice-result" aria-live="polite">
{latest ? (
<>
{latest.label && <div className="dice-label roll-label">{latest.label}</div>}
{/* Keyed on the roll so the pop animation replays every time, even on a repeat result. */}
<div key={latest.id} className={`roll-result animate${natural === 20 ? ' is-crit' : natural === 1 ? ' is-fumble' : ''}`}>
{latest.total}
</div>
{natural === 20 && <div className="dice-callout">Natural 20!</div>}
{natural === 1 && <div className="dice-callout is-fumble">Natural 1</div>}
{(latest.dice.length > 1 || latest.modifier !== 0 || latest.label) && (
<div className="dice-breakdown mono">
{latest.natural != null && latest.dice.length > 1
? `${latest.dice.join(' / ')} → ${latest.natural} (${modeNote(latest)})`
: latest.natural != null
? latest.natural
: `${latest.notation}: ${latest.dice.join(', ')}`}
{latest.modifier !== 0 && ` ${latest.modifier > 0 ? '+' : '−'} ${Math.abs(latest.modifier)}`} = {latest.total}
</div>
)}
</>
) : (
<p className="subtitle" style={{ margin: 0 }}>
Pick a die and roll, or click a skill, save or attack on a sheet.
{tab === 'table' && campaignId ? (
<div className="mt-sm">
<p className="combatant-sub" style={{ marginTop: 0 }}>
{isDM
? 'Every roll made at this table, as it lands. Hidden ones are yours alone.'
: campaign?.rollVisibility === 'OWN'
? 'Your rolls. This campaign keeps each player’s rolls between them and the DM.'
: 'Rolls made at this table.'}
</p>
)}
</div>
{history.length > 1 && (
<RollFeed rolls={feed} userId={user?.id} empty="Nobody has rolled yet." />
</div>
) : (
<>
<div className="flex-between">
<span className="dice-label">Earlier rolls</span>
<button className="btn btn-ghost" onClick={() => setHistory([])}>
Clear
</button>
<div className="dice-choices mt-sm" role="group" aria-label="Which die">
{DICE.map((d) => (
<button key={d} className={`die-btn${d === sides ? ' is-selected' : ''}`} aria-pressed={d === sides} onClick={() => setSides(d)}>
d{d}
</button>
))}
</div>
{history.slice(1).map((r) => (
<div key={r.id} className="flex-between dice-history-row">
<span>
{r.label ? `${r.label} · ` : ''}
{r.notation}
{r.dice.length > 1 && <span className="dice-history-dice"> ({r.dice.join(', ')})</span>}
<div className="flex-between mt-sm">
<div className="flex-row gap-sm">
<span className="dice-label">Dice</span>
<button className="btn" aria-label="One fewer die" disabled={count <= 1} onClick={() => setCount(count - 1)}>
−
</button>
<span className="mono" aria-live="polite" style={{ minWidth: '1.5ch', textAlign: 'center' }}>
{count}
</span>
<strong className="mono">{r.total}</strong>
<button className="btn" aria-label="One more die" disabled={count >= MAX_DICE} onClick={() => setCount(count + 1)}>
+
</button>
</div>
))}
<label className="flex-row gap-sm">
<span className="dice-label">Bonus</span>
<input
type="number"
min={-99}
max={99}
value={modifier}
onChange={(e) => setModifier(Math.max(-99, Math.min(99, Math.trunc(Number(e.target.value)) || 0)))}
style={{ width: 70 }}
/>
</label>
</div>
{/* For any single d20: one rolled here, and every check, save and attack clicked on a sheet. */}
<div className="dice-choices mt-sm" role="group" aria-label="How a d20 is rolled">
{MODES.map((m) => (
<button key={m.mode} className={`die-btn${m.mode === mode ? ' is-selected' : ''}`} aria-pressed={m.mode === mode} title={m.title} onClick={() => setMode(m.mode)}>
{m.label}
</button>
))}
</div>
{(diceMode === 'BOTH' || (isDM && campaignId)) && (
<div className="flex-row flex-wrap gap-sm mt-sm">
{diceMode === 'BOTH' && (
<label className="checkbox-row" title="Roll real dice at the table and type in what they show">
<input type="checkbox" checked={byHand} onChange={(e) => chooseByHand(e.target.checked)} />
🎯 I roll real dice
</label>
)}
{isDM && campaignId && (
<label className="checkbox-row" title="Only you see a hidden roll">
<input type="checkbox" checked={hidden} onChange={(e) => setHidden(e.target.checked)} />
🙈 Roll hidden
</label>
)}
</div>
)}
{diceMode === 'PHYSICAL' && <p className="combatant-sub mt-sm" style={{ marginBottom: 0 }}>This campaign plays with real dice: roll at the table and enter what they show.</p>}
{pending ? (
<form
className="dice-entry mt-sm"
onSubmit={(e) => {
e.preventDefault();
if (typedOk) perform(pending, typedValues);
}}
>
<div className="dice-label roll-label">{pending.label || 'Your roll'}</div>
<p style={{ margin: '0.2rem 0 0.4rem' }}>
Roll <strong className="mono">{specNotation(pending)}</strong>
{pending.kind === 'check' && pending.mode && pending.mode !== 'NORMAL' ? ` with ${pending.mode.toLowerCase()} (both d20s)` : ''} and enter what{' '}
{pendingDice.length === 1 ? 'it shows' : 'each die shows'}:
</p>
<div className="flex-row flex-wrap gap-sm">
{pendingDice.map((dieSides, i) => (
<label key={i} className="dice-entry-die">
<span className="dice-label">d{dieSides}</span>
<NumberInput
min={1}
max={dieSides}
placeholder={`1–${dieSides}`}
value={typed[i] ?? ''}
autoFocus={i === 0}
onChange={(e) => setTyped((prev) => Object.assign([...prev], { [i]: e.target.value }))}
style={{ width: 64 }}
/>
</label>
))}
</div>
<div className="flex-row gap-sm mt-sm">
<button type="submit" className="btn btn-primary" disabled={!typedOk || send.isPending}>
Enter roll
</button>
<button type="button" className="btn btn-ghost" onClick={() => setPending(null)}>
Cancel
</button>
{diceMode === 'BOTH' && (
<button type="button" className="btn btn-ghost" onClick={() => chooseByHand(false)}>
Let the app roll
</button>
)}
</div>
</form>
) : (
<button className="btn btn-primary btn-block btn-lg mt-sm" onClick={() => ask(setup)} disabled={send.isPending}>
{typing ? '🎯' : '🎲'} Roll {notation(count, sides, modifier)}
{singleD20 && mode !== 'NORMAL' && ` (${mode === 'ADVANTAGE' ? 'advantage' : 'disadvantage'})`}
</button>
)}
{error && <p className="dice-error">{error}</p>}
<div className="dice-result" aria-live="polite">
{latest ? (
// Keyed on the roll so the dice tumble again every time, even on a repeat result.
<div key={latest.id} className="dice-stage">
{latest.label && <div className="dice-label roll-label">{latest.label}</div>}
<DiceRow dice={latest.dice} rolling size={latest.dice.length > 6 ? 40 : 58} />
<div className={`roll-result roll-reveal${natural === 20 ? ' is-crit' : natural === 1 ? ' is-fumble' : ''}`} style={{ animationDelay: `${settle}ms` }}>
{latest.total}
</div>
<div className="roll-reveal" style={{ animationDelay: `${settle}ms` }}>
{natural === 20 && <div className="dice-callout">Natural 20!</div>}
{natural === 1 && <div className="dice-callout is-fumble">Natural 1</div>}
<div className="dice-breakdown mono">
{latest.notation}
{latest.mode !== 'NORMAL' && ` (${latest.mode === 'ADVANTAGE' ? 'advantage' : 'disadvantage'})`}
{latest.modifier !== 0 && `: dice ${latest.total - latest.modifier} ${latest.modifier > 0 ? '+' : '−'} ${Math.abs(latest.modifier)}`} = {latest.total}
{latest.hidden && ' · hidden'}
</div>
</div>
</div>
) : (
<p className="subtitle" style={{ margin: 0 }}>
Pick a die and roll, or click a skill, save or attack on a sheet.
</p>
)}
</div>
{!campaignId && history.length > 1 && (
<>
<div className="flex-between">
<span className="dice-label">Earlier rolls</span>
<button className="btn btn-ghost" onClick={() => setHistory([])}>
Clear
</button>
</div>
{history.slice(1).map((r) => (
<div key={r.id} className="flex-between dice-history-row">
<span>
{r.label ? `${r.label} · ` : ''}
{r.notation}
{r.dice.length > 1 && <span className="dice-history-dice"> ({r.dice.map((d) => d.value).join(', ')})</span>}
</span>
<strong className="mono">{r.total}</strong>
</div>
))}
</>
)}
{campaignId && (
<p className="combatant-sub" style={{ margin: 0 }}>
{isDM ? 'Your rolls are shown to the table unless hidden.' : campaign?.rollVisibility === 'OWN' ? 'Your DM sees this roll.' : 'The table sees this roll.'}
</p>
)}
</>
)}
</div>
+2
View File
@@ -4,6 +4,7 @@ import { useAuth } from '../auth/useAuth';
import { useLastCampaignId, useRememberCampaign } from '../lib/lastCampaign';
import { DiceRoller } from './DiceRoller';
import { CharacterPanel } from './CharacterPanel';
import { Toasts } from './Toasts';
export function Layout() {
const { user, logout } = useAuth();
@@ -70,6 +71,7 @@ export function Layout() {
)}
<DiceRoller />
{user && <CharacterPanel />}
<Toasts />
</div>
);
}
@@ -0,0 +1,135 @@
import { useState } from 'react';
import { useApproveLevelUps } from '../api/queries';
import { classLabel, extractErrorMessage } from '../lib/format';
import { Modal } from './Modal';
import { ErrorBanner } from './Spinner';
import type { CharacterSummary, LevelUpApproval } from '../api/types';
const MAX_LEVEL = 20;
// The DM's level-up button: approve a level for any of the party at once.
// Each approval is a checklist waiting for its character: their player is
// told and makes the picks themselves, so nobody queues for the DM. A
// character without a player is the DM's to finish, from its sheet.
// `trackXp`: with XP kept, those who have the XP for their next level start
// ticked; without it (milestones) the DM ticks who levels.
export function LevelUpBulkModal({
campaignId,
characters,
trackXp,
onClose,
}: {
campaignId: string;
characters: CharacterSummary[];
trackXp: boolean;
onClose: () => void;
}) {
const approve = useApproveLevelUps(campaignId);
const party = characters.filter((c) => c.buildStage === 'COMPLETE');
const canLevel = (c: CharacterSummary) => c.level + c.levelUpsPending < MAX_LEVEL;
const [picked, setPicked] = useState<Set<string>>(() => new Set(party.filter((c) => trackXp && c.xpProgress.levelUpDue && canLevel(c)).map((c) => c.id)));
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState<LevelUpApproval[] | null>(null);
function toggle(id: string) {
setPicked((current) => {
const next = new Set(current);
if (!next.delete(id)) next.add(id);
return next;
});
}
async function send(characterIds: string[], revoke = false) {
setError(null);
try {
const { results } = await approve.mutateAsync({ characterIds, revoke });
if (!revoke) setDone(results);
} catch (err) {
setError(extractErrorMessage(err));
}
}
if (done) {
return (
<Modal title="Level ups approved" subtitle="Each player makes their own picks" onClose={onClose}>
{done.map((result) => (
<div key={result.characterId} className="reward-row">
<strong>{result.name}</strong>
{result.skipped ? (
<div className="combatant-sub">{result.skipped}</div>
) : (
<div className="combatant-sub">
Level {result.level} → {result.level + 1} is theirs to take.{' '}
{result.userId ? 'Their player has been told and chooses what they gain.' : 'No player: finish it from the sheet.'}
</div>
)}
</div>
))}
<div className="modal-foot">
<button className="btn btn-primary" onClick={onClose}>
Done
</button>
</div>
</Modal>
);
}
return (
<Modal title="Level up" subtitle="Approve a level for the party; each player then picks what they gain" wide onClose={onClose}>
{error && <ErrorBanner message={error} />}
<div className="flex-row flex-wrap gap-sm">
<button className="btn" onClick={() => setPicked(new Set(party.filter(canLevel).map((c) => c.id)))}>
Everyone
</button>
{trackXp && (
<button className="btn" onClick={() => setPicked(new Set(party.filter((c) => c.xpProgress.levelUpDue && canLevel(c)).map((c) => c.id)))}>
Those with the XP
</button>
)}
<button className="btn btn-ghost" onClick={() => setPicked(new Set())}>
Nobody
</button>
</div>
<div className="mt-md">
{party.length === 0 && <p className="combatant-sub">No finished characters yet.</p>}
{party.map((c) => (
<label key={c.id} className="checkbox-row level-choice" style={canLevel(c) ? undefined : { opacity: 0.55 }}>
<input type="checkbox" checked={picked.has(c.id)} disabled={!canLevel(c)} onChange={() => toggle(c.id)} />
<span style={{ flex: 1 }}>
<strong>{c.name}</strong>{' '}
<span className="combatant-sub">
{classLabel(c.characterClass, c.subclassName, c.classes)} · Level {c.level}
</span>
{trackXp && c.xpProgress.levelUpDue && <span className="badge badge-live" style={{ marginLeft: 6 }}>has the XP</span>}
{c.levelUpsPending > 0 && (
<span className="badge" style={{ marginLeft: 6 }}>
{c.levelUpsPending} waiting on their picks
</span>
)}
{!c.userId && <span className="badge" style={{ marginLeft: 6 }}>no player: yours to finish</span>}
</span>
{c.levelUpsPending > 0 && (
<button
type="button"
className="btn btn-ghost btn-sm"
title="Takes back one approved level that has not been taken yet"
disabled={approve.isPending}
onClick={(e) => {
e.preventDefault();
send([c.id], true);
}}
>
Take one back
</button>
)}
</label>
))}
</div>
<div className="modal-foot">
<button className="btn btn-primary" disabled={picked.size === 0 || approve.isPending} onClick={() => send([...picked])}>
{approve.isPending ? 'Approving…' : `Approve a level for ${picked.size} character${picked.size === 1 ? '' : 's'}`}
</button>
</div>
</Modal>
);
}
+61 -17
View File
@@ -2,8 +2,10 @@ import { useState, type ReactNode } from 'react';
import { useLevelUp, useLevelUpPlan } from '../api/queries';
import { CLASS_LABELS, classLabel, extractErrorMessage, slotLevelLabel, slotsSummary, spellLevelLabel } from '../lib/format';
import { Modal } from './Modal';
import { NumberInput } from './NumberInput';
import { Spinner, ErrorBanner } from './Spinner';
import type { CharacterClass, CharacterDetail, LevelUpPlan, Spell } from '../api/types';
import { showToast } from '../lib/toasts';
import type { CharacterClass, CharacterDetail, LevelUpPlan } from '../api/types';
// One numbered step of the checklist.
function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) {
@@ -27,8 +29,10 @@ function allowance(spells: NonNullable<LevelUpPlan['spells']>): string {
// The levelling-up checklist for one character, one level at a time: the
// level, HP, the class's new special abilities (with a subclass or a Boon
// where the level calls for one), new spells and fresh slots, and the free weapon upgrade. The
// site does every step; the DM makes the choices and says yes.
export function LevelUpModal({ character, campaignSpells, onClose }: { character: CharacterDetail; campaignSpells: Spell[]; onClose: () => void }) {
// site does every step; whoever is levelling makes the choices and says yes:
// the DM, or the character's own player once the DM has approved the level.
// A player picks spells within what the level allows; the DM grants freely.
export function LevelUpModal({ character, isDM, onClose }: { character: CharacterDetail; isDM: boolean; onClose: () => void }) {
// The class taking the level: the first one, unless another is picked (multiclassing).
const [toClass, setToClass] = useState<CharacterClass | null>(null);
const { data: plan, isLoading, error: planError } = useLevelUpPlan(character.id, true, toClass);
@@ -38,6 +42,7 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
// undefined until picked: the equipped weapon is offered first.
const [weaponChoice, setWeaponChoice] = useState<string | undefined>(undefined);
const [spellIds, setSpellIds] = useState<Set<string>>(new Set());
const [hpRoll, setHpRoll] = useState('');
const [error, setError] = useState<string | null>(null);
if (isLoading || !plan) {
@@ -49,13 +54,16 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
}
const weaponId = weaponChoice ?? plan.weapons.find((w) => w.equipped)?.weaponId ?? plan.weapons[0]?.weaponId ?? '';
const known = new Set(character.spells.map((s) => s.spellId));
const grantable = plan.spells
? campaignSpells.filter((s) => !known.has(s.id) && s.level <= plan.spells!.maxSpellLevel).sort((a, b) => a.level - b.level || a.name.localeCompare(b.name))
: [];
const grantable = plan.spellOptions;
// A player picks up to the level's allowance; the DM is not held to it.
const spellAllowance = plan.spells ? plan.spells.newSpells + plan.spells.newCantrips : 0;
const spellsFull = !isDM && spellIds.size >= spellAllowance;
const needsHpRoll = plan.hp.method === 'ROLL' && plan.hp.physical;
const hpRollValid = Number.isInteger(Number(hpRoll)) && Number(hpRoll) >= 1 && Number(hpRoll) <= plan.hp.die;
const pickedBoon = plan.boon?.find((b) => b.key === boon);
const pickedSubclass = plan.subclass?.options.find((option) => option.key === subclass);
const owed = plan.subclass && !subclass ? 'Choose a subclass first' : plan.boon && !boon ? 'Choose a Boon first' : null;
const owed =
plan.subclass && !subclass ? 'Choose a subclass first' : plan.boon && !boon ? 'Choose a Boon first' : needsHpRoll && !hpRollValid ? `Roll your d${plan.hp.die} for hit points` : null;
function toggleSpell(id: string) {
setSpellIds((current) => {
@@ -68,9 +76,14 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
async function handleLevelUp() {
setError(null);
try {
await levelUp.mutateAsync({
const { hpRolled } = await levelUp.mutateAsync({
hpRoll: needsHpRoll ? Number(hpRoll) : undefined,
characterClass: plan!.characterClass,
subclass: plan!.subclass ? subclass : undefined, boon: plan!.boon ? boon : undefined, weaponId: weaponId || null, spellIds: [...spellIds] });
showToast({
tone: 'good',
text: `${character.name} is level ${plan!.toLevel}!${hpRolled != null ? ` The d${plan!.hp.die} for hit points came up ${hpRolled}.` : ''}`,
});
onClose();
} catch (err) {
setError(extractErrorMessage(err));
@@ -124,9 +137,32 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
</Step>
<Step n={2} title="Hit points">
<p>
Max HP <strong>{plan.maxHp.from}</strong> → <strong>{plan.maxHp.to}</strong>, and healed to full. One more Hit Die.
</p>
{plan.hp.method === 'AVERAGE' ? (
<p>
Max HP <strong>{plan.maxHp.from}</strong> → <strong>{plan.maxHp.to}</strong>, and healed to full. One more Hit Die (d{plan.hp.die}).
</p>
) : needsHpRoll ? (
<div>
<p style={{ marginBottom: '0.3rem' }}>
This campaign rolls for hit points. Roll a <strong>d{plan.hp.die}</strong> at the table and enter what it shows; your Constitution
modifier is added for you. Healed to full, and one more Hit Die.
</p>
<label className="flex-row gap-sm">
I rolled
<NumberInput min={1} max={plan.hp.die} placeholder={`1–${plan.hp.die}`} value={hpRoll} onChange={(e) => setHpRoll(e.target.value)} style={{ width: 80 }} />
{hpRollValid && (
<span className="combatant-sub">
Max HP {plan.maxHp.from} → {plan.maxHp.to - plan.hp.average + Number(hpRoll)}
</span>
)}
</label>
</div>
) : (
<p>
This campaign rolls for hit points: a <strong>d{plan.hp.die}</strong> is rolled when you confirm (between {plan.maxHp.to - plan.hp.average + 1} and{' '}
{plan.maxHp.to - plan.hp.average + plan.hp.die} max HP), plus healing to full and one more Hit Die.
</p>
)}
</Step>
<Step n={3} title="Special abilities">
@@ -190,7 +226,9 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
</div>
)}
{(plan.features.length > 0 || plan.boon || plan.subclass) && (
<p className="combatant-sub">Written into Special Abilities for you. Where a line says "choose", edit it there once the player has.</p>
<p className="combatant-sub">
Written into Special Abilities for you. {isDM ? 'Where a line says "choose", edit it there once the player has.' : 'Where a line says "choose", agree it with your DM.'}
</p>
)}
</Step>
@@ -216,16 +254,22 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
<div className="level-spells">
{grantable.map((spell) => (
<label key={spell.id} className="checkbox-row level-choice">
<input type="checkbox" checked={spellIds.has(spell.id)} onChange={() => toggleSpell(spell.id)} />
<span>
{spell.name} <span className="combatant-sub">{spellLevelLabel(spell.level)}</span>
<input type="checkbox" checked={spellIds.has(spell.id)} disabled={spellsFull && !spellIds.has(spell.id)} onChange={() => toggleSpell(spell.id)} />
<span title={spell.description ?? undefined}>
{spell.name}{' '}
<span className="combatant-sub">
{spellLevelLabel(spell.level)}
{spell.school ? ` · ${spell.school}` : ''}
</span>
</span>
</label>
))}
</div>
)}
<p className="combatant-sub">
{spellIds.size > 0 && `${spellIds.size} picked. `}A player may also swap one known spell for another: take the old one away on the sheet.
{isDM
? `${spellIds.size > 0 ? `${spellIds.size} picked. ` : ''}A player may also swap one known spell for another: take the old one away on the sheet.`
: `${spellIds.size} of ${spellAllowance} picked. Hover a spell to read what it does.`}
</p>
</>
)}
+53
View File
@@ -0,0 +1,53 @@
import { DiceRow } from './dice/Die';
import type { DiceRollRecord } from '../api/types';
const MODE_NOTE = { NORMAL: '', ADVANTAGE: 'advantage', DISADVANTAGE: 'disadvantage' } as const;
function timeOf(iso: string): string {
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
// One roll as a line: who, what for, the dice as they fell, and the total.
export function RollLine({ roll, mine, rolling = false, size = 34 }: { roll: DiceRollRecord; mine: boolean; rolling?: boolean; size?: number }) {
const crit = roll.natural === 20;
const fumble = roll.natural === 1;
return (
<div className={`roll-line${crit ? ' is-crit' : fumble ? ' is-fumble' : ''}`}>
<div className="roll-line-head">
<span>
<strong>{mine ? 'You' : roll.rollerName}</strong>
{roll.label && <span> · {roll.label}</span>}
</span>
<span className="combatant-sub">{timeOf(roll.createdAt)}</span>
</div>
<div className="roll-line-body">
<DiceRow dice={roll.dice} rolling={rolling} size={size} />
<span className="roll-line-math mono">
{roll.modifier !== 0 && `${roll.modifier > 0 ? '+' : '−'} ${Math.abs(roll.modifier)} `}=
</span>
<strong className="roll-line-total mono">{roll.total}</strong>
</div>
<div className="roll-line-tags">
<span className="combatant-sub mono">{roll.notation}</span>
{roll.mode !== 'NORMAL' && <span className="badge">{MODE_NOTE[roll.mode]}</span>}
{crit && <span className="badge badge-friendly">Natural 20</span>}
{fumble && <span className="badge badge-hostile">Natural 1</span>}
{roll.physical && <span className="badge" title="Rolled with real dice and typed in">🎯 at the table</span>}
{roll.hidden && <span className="badge badge-dm" title="Only you see this roll">🙈 hidden</span>}
</div>
</div>
);
}
// The table's rolls, newest first: every roll this member may see. For the
// DM that is every roll anyone makes.
export function RollFeed({ rolls, userId, empty }: { rolls: DiceRollRecord[]; userId: string | undefined; empty: string }) {
if (rolls.length === 0) return <p className="subtitle">{empty}</p>;
return (
<div className="roll-feed">
{rolls.map((roll) => (
<RollLine key={roll.id} roll={roll} mine={roll.userId === userId} />
))}
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { dismissToast, subscribeToasts, type Toast } from '../lib/toasts';
// The stack of toasts (lib/toasts.ts), mounted once in the layout.
export function Toasts() {
const [toasts, setToasts] = useState<Toast[]>([]);
useEffect(() => subscribeToasts(setToasts), []);
if (toasts.length === 0) return null;
return (
<div className="toasts" role="status" aria-live="polite">
{toasts.map((toast) => (
<div key={toast.id} className={`toast${toast.tone ? ` toast-${toast.tone}` : ''}`}>
<span className="toast-text">{toast.text}</span>
{toast.action && (
<Link to={toast.action.to} className="btn btn-primary btn-sm" onClick={() => dismissToast(toast.id)}>
{toast.action.label}
</Link>
)}
<button className="btn btn-ghost btn-sm" aria-label="Dismiss" onClick={() => dismissToast(toast.id)}>
✕
</button>
</div>
))}
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useState } from 'react';
import { STAGGER_MS, TUMBLE_MS, prefersStill } from '../../lib/diceAnimation';
import type { RolledDie } from '../../api/types';
// The outline of each die, seen face on, in a 100 × 100 box: the shape that
// says which die it is, and the lines that give it facets.
const SHAPES: Record<number, { outline: string; facets?: string[] }> = {
4: { outline: '50,8 94,86 6,86', facets: ['50,8 50,86'] },
6: { outline: '14,14 86,14 86,86 14,86' },
8: { outline: '50,4 94,50 50,96 6,50', facets: ['6,50 94,50'] },
10: { outline: '50,4 92,40 50,96 8,40', facets: ['8,40 50,58 92,40', '50,58 50,96'] },
12: { outline: '50,4 93,35 77,90 23,90 7,35', facets: ['50,4 50,28', '93,35 72,42', '77,90 64,70', '23,90 36,70', '7,35 28,42', '50,28 72,42 64,70 36,70 28,42 50,28'] },
20: { outline: '50,3 91,26 91,74 50,97 9,74 9,26', facets: ['50,22 80,70 20,70 50,22', '50,3 50,22', '91,74 80,70', '9,74 20,70', '9,26 50,22 91,26', '20,70 50,97 80,70'] },
};
// Where the number sits: lower in a triangle, whose middle is below its centre.
const TEXT_Y: Record<number, number> = { 4: 66, 10: 46, 20: 58 };
const randomFace = (sides: number) => Math.floor(Math.random() * sides) + 1;
// One die, drawn as its own shape with the number it shows. With `rolling` it
// tumbles in, flickering through faces, and lands on its value; `order` is
// its place among the dice rolled with it. A die that was dropped (the lower
// d20 with advantage) is faded; a natural 20 or 1 on a d20 is marked.
export function Die({ die, rolling = false, order = 0, size = 56 }: { die: RolledDie; rolling?: boolean; order?: number; size?: number }) {
const animate = rolling && !prefersStill();
const [landed, setLanded] = useState(!animate);
const [face, setFace] = useState(animate ? randomFace(die.sides) : die.value);
useEffect(() => {
if (!animate) return;
const flicker = setInterval(() => setFace(randomFace(die.sides)), 70);
const land = setTimeout(
() => {
clearInterval(flicker);
setFace(die.value);
setLanded(true);
},
TUMBLE_MS + order * STAGGER_MS
);
return () => {
clearInterval(flicker);
clearTimeout(land);
};
}, [animate, die.sides, die.value, order]);
const shape = SHAPES[die.sides];
const shown = landed ? die.value : face;
const crit = landed && die.sides === 20 && die.kept && die.value === 20;
const fumble = landed && die.sides === 20 && die.kept && die.value === 1;
const classes = ['die', `die-d${shape ? die.sides : 'x'}`, landed ? 'is-landed' : 'is-rolling', die.kept ? '' : 'is-dropped', crit ? 'is-crit' : '', fumble ? 'is-fumble' : '', die.sign < 0 ? 'is-minus' : '']
.filter(Boolean)
.join(' ');
return (
<span
className={classes}
style={{ width: size, height: size, animationDelay: animate ? `${order * STAGGER_MS}ms` : undefined, animationDuration: animate ? `${TUMBLE_MS}ms` : undefined }}
title={`d${die.sides}: ${die.value}${die.kept ? '' : ' (dropped)'}${die.maxed ? ' (at its most, for the critical hit)' : ''}`}
aria-label={`d${die.sides} showing ${landed ? die.value : 'rolling'}`}
>
<svg viewBox="0 0 100 100" width={size} height={size} aria-hidden="true">
{shape ? (
<>
<polygon className="die-body" points={shape.outline} strokeLinejoin="round" />
{shape.facets?.map((points) => (
<polyline key={points} className="die-facet" points={points} fill="none" />
))}
</>
) : (
// Any other die (a d100, a d3...): a plain round one.
<circle className="die-body" cx="50" cy="50" r="44" />
)}
<text className="die-face" x="50" y={TEXT_Y[die.sides] ?? 52} textAnchor="middle" dominantBaseline="middle" fontSize={shown > 99 ? 26 : shown > 9 ? 34 : 40}>
{shown}
</text>
</svg>
<span className="die-kind">d{die.sides}</span>
</span>
);
}
// A roll's dice in a row, landing one after another.
export function DiceRow({ dice, rolling = false, size = 56 }: { dice: RolledDie[]; rolling?: boolean; size?: number }) {
return (
<span className="dice-row">
{dice.map((die, i) => (
<Die key={i} die={die} rolling={rolling} order={i} size={size} />
))}
</span>
);
}
+20 -2
View File
@@ -3,6 +3,8 @@ import { useQueryClient, type QueryKey } from '@tanstack/react-query';
import { io, type Socket } from 'socket.io-client';
import { API_URL } from '../api/client';
import { useAuth } from '../auth/useAuth';
import { showToast } from '../lib/toasts';
import type { CharacterDetail } from '../api/types';
let sharedSocket: Socket | null = null;
@@ -24,8 +26,9 @@ const FLUSH_MS = 50;
// sync with server-pushed events (HP/condition changes, encounter rounds,
// new characters) so every open tab in the campaign updates live.
export function useCampaignSocket(campaignId: string | undefined) {
const { token } = useAuth();
const { token, user } = useAuth();
const qc = useQueryClient();
const userId = user?.id;
useEffect(() => {
if (!campaignId || !token) return;
@@ -56,6 +59,17 @@ export function useCampaignSocket(campaignId: string | undefined) {
// A character arriving somewhere new adds it to the places their player can see.
invalidate(['campaign', campaignId, 'locations']);
};
// The DM approved a level for a character: its player is told, wherever
// in the campaign they are, with a way straight to the checklist.
const onLevelUpGranted = ({ characterId, userId: ownerId }: { characterId?: string; userId?: string | null } = {}) => {
if (!characterId || !ownerId || ownerId !== userId) return;
const name = qc.getQueryData<CharacterDetail>(['character', characterId])?.name ?? 'Your character';
showToast({ text: `⬆ ${name} can level up! See what you gain and make your picks.`, tone: 'good', action: { label: 'Level up', to: `/characters/${characterId}?levelup=1` } });
};
// Somebody rolled: the feed asks again, and gets the roll if it is this member's to see.
const onRollCreated = () => {
invalidate(['campaign', campaignId, 'rolls']);
};
const onCharacterCreated = () => {
invalidate(['campaign', campaignId, 'characters']);
};
@@ -74,6 +88,8 @@ export function useCampaignSocket(campaignId: string | undefined) {
socket.on('character:updated', onCharacterUpdated);
socket.on('character:created', onCharacterCreated);
socket.on('character:level-up-granted', onLevelUpGranted);
socket.on('roll:created', onRollCreated);
socket.on('encounter:participant-updated', onParticipantUpdated);
socket.on('encounter:round-advanced', onRoundAdvanced);
socket.on('campaign:deleted', onCampaignDeleted);
@@ -86,9 +102,11 @@ export function useCampaignSocket(campaignId: string | undefined) {
socket.emit('campaign:leave', campaignId);
socket.off('character:updated', onCharacterUpdated);
socket.off('character:created', onCharacterCreated);
socket.off('character:level-up-granted', onLevelUpGranted);
socket.off('roll:created', onRollCreated);
socket.off('encounter:participant-updated', onParticipantUpdated);
socket.off('encounter:round-advanced', onRoundAdvanced);
socket.off('campaign:deleted', onCampaignDeleted);
};
}, [campaignId, token, qc]);
}, [campaignId, token, qc, userId]);
}
+322
View File
@@ -403,6 +403,65 @@ input[type='radio'] {
font-size: 0.9rem;
}
/* Toasts: notices that slide in over any page (components/Toasts.tsx). */
.toasts {
position: fixed;
top: 4.2rem;
right: 1rem;
z-index: 1200;
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: min(26rem, calc(100vw - 2rem));
}
.toast {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.8rem;
background: var(--bg-card);
border: 1px solid var(--accent);
border-radius: var(--radius);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
animation: toastIn 0.25s ease-out;
}
.toast-good {
border-color: var(--accent-strong);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45), 0 0 14px rgba(201, 162, 39, 0.3);
}
.toast-text {
flex: 1;
font-size: 0.92rem;
}
@keyframes toastIn {
from {
opacity: 0;
transform: translateX(1.5rem);
}
to {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.toast {
animation: none;
}
}
/* A notice across the top of a card or page that something is waiting. */
.notice-banner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.9rem;
margin-bottom: 0.9rem;
border: 1px solid var(--accent-strong);
border-radius: var(--radius);
background: linear-gradient(160deg, var(--bg-card-hover) 0%, var(--bg-inset) 100%);
}
/* A group of campaign settings under its own heading. */
.settings-group {
border: 1px solid var(--border);
@@ -553,6 +612,269 @@ input[type='radio'] {
animation: rollPop 0.4s ease-out;
}
/* ---------------------------------------------------------------------- */
/* Dice (components/dice/Die.tsx): each die drawn as its own shape. It */
/* tumbles in, flickering through faces, and lands on what it rolled. */
/* ---------------------------------------------------------------------- */
.dice-row {
display: inline-flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: flex-end;
justify-content: center;
}
.die {
position: relative;
display: inline-flex;
flex-direction: column;
align-items: center;
filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.55));
}
.die-body {
fill: #2b2212;
stroke: var(--accent);
stroke-width: 4;
}
.die-facet {
stroke: var(--accent);
stroke-width: 1.5;
opacity: 0.35;
}
.die-face {
font-family: var(--font-display);
font-weight: 700;
fill: var(--accent-strong);
paint-order: stroke;
stroke: #1a140a;
stroke-width: 5;
}
.die-kind {
font-size: 0.6rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-dim);
line-height: 1;
}
/* Each kind of die has its own tint, as a set of real dice might. */
.die-d4 .die-body { fill: #2a1d1d; }
.die-d6 .die-body { fill: #2b2212; }
.die-d8 .die-body { fill: #1c2a22; }
.die-d10 .die-body { fill: #1b2431; }
.die-d12 .die-body { fill: #2a1f31; }
.die-d20 .die-body { fill: #33260f; }
.die.is-rolling {
animation-name: dieTumble;
animation-timing-function: cubic-bezier(0.2, 0.7, 0.3, 1);
animation-fill-mode: both;
}
.die.is-rolling .die-face {
opacity: 0.75;
}
.die.is-landed {
animation: dieLand 0.28s ease-out;
}
.die.is-dropped {
opacity: 0.4;
}
.die.is-dropped .die-face {
text-decoration: line-through;
}
.die.is-minus .die-body {
stroke: var(--danger-strong);
}
.die.is-crit .die-body {
stroke: var(--success-strong);
fill: #20331a;
}
.die.is-crit {
filter: drop-shadow(0 0 10px rgba(122, 178, 102, 0.9));
animation: dieLand 0.28s ease-out, dieGlow 1.2s ease-in-out 0.28s 2;
}
.die.is-crit .die-face {
fill: #c9f0b8;
}
.die.is-fumble .die-body {
stroke: var(--danger-strong);
fill: #331616;
}
.die.is-fumble {
filter: drop-shadow(0 0 8px rgba(216, 84, 84, 0.8));
animation: dieShake 0.45s ease-in-out;
}
.die.is-fumble .die-face {
fill: #f1b0b0;
}
@keyframes dieTumble {
0% {
transform: translate(-70px, -90px) rotate(-540deg) scale(0.5);
opacity: 0;
}
25% {
opacity: 1;
}
55% {
transform: translate(10px, 6px) rotate(-90deg) scale(1.08);
}
75% {
transform: translate(-4px, -10px) rotate(20deg) scale(1);
}
100% {
transform: translate(0, 0) rotate(0deg) scale(1);
}
}
@keyframes dieLand {
0% {
transform: scale(1.18);
}
100% {
transform: scale(1);
}
}
@keyframes dieGlow {
50% {
filter: drop-shadow(0 0 18px rgba(122, 178, 102, 1));
}
}
@keyframes dieShake {
20% {
transform: translateX(-4px) rotate(-6deg);
}
40% {
transform: translateX(4px) rotate(5deg);
}
60% {
transform: translateX(-3px) rotate(-3deg);
}
80% {
transform: translateX(2px) rotate(2deg);
}
}
/* The total, and what goes with it, appear once the dice have landed. */
.roll-reveal {
animation: rollReveal 0.35s ease-out both;
}
@keyframes rollReveal {
from {
opacity: 0;
transform: scale(0.6);
}
to {
opacity: 1;
transform: scale(1);
}
}
.dice-stage {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
width: 100%;
}
.dice-tabs {
display: flex;
gap: 0.4rem;
}
.dice-tabs .die-btn {
padding: 0.4rem 0.8rem;
}
.dice-entry {
padding: 0.6rem;
border: 1px dashed var(--accent);
border-radius: var(--radius);
background: var(--bg-inset);
}
.dice-entry-die {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.dice-error {
color: var(--danger-strong);
font-size: 0.9rem;
margin: 0.5rem 0 0;
}
/* A roll as a line of the table's feed (components/RollFeed.tsx). */
.roll-feed {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.roll-line {
padding: 0.45rem 0.6rem;
border: 1px solid var(--border-soft);
border-left: 3px solid var(--accent);
border-radius: 6px;
background: var(--bg-inset);
text-align: left;
}
.roll-line.is-crit {
border-left-color: var(--success-strong);
}
.roll-line.is-fumble {
border-left-color: var(--danger-strong);
}
.roll-line-head {
display: flex;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.88rem;
}
.roll-line-body {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
margin: 0.25rem 0;
}
.roll-line-body .dice-row {
justify-content: flex-start;
}
.roll-line-total {
font-size: 1.5rem;
color: var(--accent-strong);
}
.roll-line-tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
}
.roll-line-tags .badge {
font-size: 0.68rem;
}
/* Somebody else's roll, shown beside the dice button as it lands. */
.dice-incoming {
position: fixed;
bottom: 1.25rem;
left: calc(1.25rem + 60px + 0.6rem);
z-index: 84;
width: min(300px, calc(100vw - 6.5rem));
padding: 0;
border: none;
background: none;
color: var(--text);
font-family: var(--font-body);
cursor: pointer;
animation: toastIn 0.25s ease-out;
}
.dice-incoming .roll-line {
border-color: var(--accent);
background: linear-gradient(180deg, #1c160d 0%, #120e08 100%);
box-shadow: var(--shadow-deep);
}
@media (prefers-reduced-motion: reduce) {
.die.is-rolling,
.die.is-landed,
.die.is-crit,
.die.is-fumble,
.roll-reveal,
.dice-incoming {
animation: none;
}
}
/* Quick stats drawer */
.drawer-backdrop {
position: fixed;
+13
View File
@@ -0,0 +1,13 @@
// How the dice move (components/dice/Die.tsx), for whatever needs to wait for
// them: the total appears once the last die has landed.
// How long a die tumbles before it lands, and how much later each die after
// the first lands, so a handful settle one after another.
export const TUMBLE_MS = 850;
export const STAGGER_MS = 110;
// Whether the visitor has asked their device for less motion.
export const prefersStill = () => typeof window !== 'undefined' && Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches);
// How long a roll of this many dice takes to settle.
export const settleMs = (count: number) => (prefersStill() ? 0 : TUMBLE_MS + Math.max(0, count - 1) * STAGGER_MS);
+45
View File
@@ -0,0 +1,45 @@
// Toasts: short notices that slide in over any page ("Aria can level up").
// A tiny store of its own, so anything can raise one (a socket event, a
// mutation) without being inside a particular component. <Toasts />
// (components/Toasts.tsx), mounted once in the layout, shows them.
export interface Toast {
id: number;
text: string;
// Where "tone" is 'good' the toast is a piece of good news; default is neutral.
tone?: 'good' | 'warn';
// A link to follow up on it: its label and where it goes.
action?: { label: string; to: string };
}
type Listener = (toasts: Toast[]) => void;
let toasts: Toast[] = [];
let nextId = 1;
const listeners = new Set<Listener>();
const publish = () => listeners.forEach((listener) => listener(toasts));
// How long a toast stays before it goes by itself.
const TOAST_MS = 12000;
export function showToast(toast: Omit<Toast, 'id'>): number {
const id = nextId++;
toasts = [...toasts, { ...toast, id }].slice(-5);
publish();
setTimeout(() => dismissToast(id), TOAST_MS);
return id;
}
export function dismissToast(id: number) {
if (!toasts.some((toast) => toast.id === id)) return;
toasts = toasts.filter((toast) => toast.id !== id);
publish();
}
export function subscribeToasts(listener: Listener): () => void {
listeners.add(listener);
listener(toasts);
return () => {
listeners.delete(listener);
};
}
+38 -8
View File
@@ -32,13 +32,14 @@ import { NumberInput } from '../components/NumberInput';
import { SectionNav, type Section } from '../components/SectionNav';
import { LifeBadge } from '../components/DeathSaves';
import { RestModal } from '../components/RestModal';
import { LevelUpBulkModal } from '../components/LevelUpBulkModal';
import { Pagination } from '../components/Paginated';
import { usePagination } from '../hooks/usePagination';
import { CampaignSearch, type SearchGroup } from '../components/campaign/CampaignSearch';
import { EntryDetail } from '../components/campaign/EntryDetail';
import { ItemsSection, MonstersSection, NpcsSection, SpellsSection, WeaponsSection } from '../components/campaign/PoolSections';
import { effectsSummary } from '../lib/gear';
import { extractErrorMessage, spellLevelLabel } from '../lib/format';
import { SPECIES_LABELS, classLabel, extractErrorMessage, spellLevelLabel } from '../lib/format';
import { matchesSearch, type EntryKind, type EntryRef } from '../lib/campaignEntries';
import { speciesIcon } from '../lib/speciesIcons';
import type { CampaignDiceMode, CharacterSummary, CritRule, Encounter, EncounterMode, HpMethod, RollVisibility, ScoreMethod } from '../api/types';
@@ -86,7 +87,8 @@ function sortEncounters(encounters: Encounter[], sort: EncounterSort): Encounter
return [...encounters].sort(compare[sort]);
}
function CharacterRow({ character, isDM, onDelete }: { character: CharacterSummary; isDM: boolean; onDelete: () => void }) {
// `mine`: the viewer's own character, who gets the way into a level up that is waiting.
function CharacterRow({ character, isDM, mine = false, onDelete }: { character: CharacterSummary; isDM: boolean; mine?: boolean; onDelete: () => void }) {
const inProgress = character.buildStage !== 'COMPLETE';
return (
<div className="card flex-between flex-wrap">
@@ -97,10 +99,15 @@ function CharacterRow({ character, isDM, onDelete }: { character: CharacterSumma
{character.name}
</Link>
{inProgress && <span className="badge">Building…</span>}
{character.levelUpsPending > 0 && (isDM || mine) && (
<span className="badge badge-live" title={mine ? 'Your DM has approved a level: open the sheet to choose what you gain' : 'Approved, and waiting for the picks to be made'}>
⬆ Level up {mine ? 'ready' : 'waiting on picks'}
</span>
)}
</div>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.9rem' }}>
{character.species ? `${character.species} ` : ''}
{character.characterClass || (inProgress ? 'Not yet built' : '')}{character.subclassName && ` (${character.subclassName})`} · Level {character.level}
{character.species ? `${SPECIES_LABELS[character.species] ?? character.species} ` : ''}
{character.characterClass ? classLabel(character.characterClass, character.subclassName, character.classes) : inProgress ? 'Not yet built' : ''} · Level {character.level}
{character.currentLocationName && ` · 📍 ${character.currentLocationName}`}
</p>
</div>
@@ -110,6 +117,11 @@ function CharacterRow({ character, isDM, onDelete }: { character: CharacterSumma
<LifeBadge deathSaves={character.deathSaves} /> HP {character.currentHp}/{character.maxHp} · AC {character.armorClass}
</div>
)}
{mine && character.levelUpsPending > 0 && !inProgress && (
<Link to={`/characters/${character.id}?levelup=1`} className="btn btn-primary">
⬆ Level up
</Link>
)}
<Link to={inProgress ? `/characters/${character.id}/build` : `/characters/${character.id}`} className="btn">
{inProgress ? 'Continue Build' : 'Open Sheet'}
</Link>
@@ -180,6 +192,7 @@ export function CampaignHomePage() {
const [showSettings, setShowSettings] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [resting, setResting] = useState(false);
const [levelling, setLevelling] = useState(false);
const [deleteConfirmName, setDeleteConfirmName] = useState('');
const liveEncounters = encounters?.filter((e) => !e.archivedAt) || [];
@@ -440,7 +453,7 @@ export function CampaignHomePage() {
<div className="section-block">
<h2 style={{ fontSize: '1.1rem' }}>Your Character{myCharacters.length > 1 ? 's' : ''}</h2>
{myCharacters.map((c) => (
<CharacterRow key={c.id} character={c} isDM={false} onDelete={() => {}} />
<CharacterRow key={c.id} character={c} isDM={false} mine onDelete={() => {}} />
))}
</div>
)}
@@ -450,9 +463,23 @@ export function CampaignHomePage() {
<div className="page-header" style={{ marginBottom: '0.75rem' }}>
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>{isDM ? 'Characters' : 'Rest of the Party'}</h2>
{isDM && characters?.some((c) => c.buildStage === 'COMPLETE') && (
<button className="btn" onClick={() => setResting(true)} title="A short or long rest for any of the party: Hit Dice, HP and spell slots">
🛏 Rest…
</button>
<div className="flex-row flex-wrap">
<button
className="btn"
onClick={() => setLevelling(true)}
title="Approve a level for any of the party at once: each player is told, and picks what they gain themselves"
>
⬆ Level up…
{campaign.trackXp && characters.some((c) => c.xpProgress.levelUpDue) && (
<span className="badge badge-live" style={{ marginLeft: 6 }}>
{characters.filter((c) => c.xpProgress.levelUpDue).length} due
</span>
)}
</button>
<button className="btn" onClick={() => setResting(true)} title="A short or long rest for any of the party: Hit Dice, HP and spell slots">
🛏 Rest…
</button>
</div>
)}
</div>
<form onSubmit={handleCreateCharacter} className="flex-row flex-wrap mt-sm" style={{ marginBottom: '0.75rem' }}>
@@ -1010,6 +1037,9 @@ export function CampaignHomePage() {
)}
{isDM && resting && <RestModal campaignId={campaignId} characters={characters ?? []} onClose={() => setResting(false)} />}
{isDM && levelling && (
<LevelUpBulkModal campaignId={campaignId} characters={characters ?? []} trackXp={campaign.trackXp} onClose={() => setLevelling(false)} />
)}
{isDM && opened && (
<EntryDetail
+45 -6
View File
@@ -1,5 +1,5 @@
import { useState, type FormEvent } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
useCharacter,
usePatchCharacter,
@@ -27,6 +27,7 @@ import {
useCampaigns,
useCopyCharacter,
useCampaignLocations,
useApproveLevelUps,
} from '../api/queries';
import { useAuth } from '../auth/useAuth';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
@@ -251,6 +252,7 @@ export function CharacterSheetPage() {
const { data: character, isLoading, error } = useCharacter(characterId);
useRememberCampaign(character?.campaignId);
const patchCharacter = usePatchCharacter(characterId);
const approveLevelUp = useApproveLevelUps(character?.campaignId ?? '');
useCampaignSocket(character?.campaignId);
const { data: campaignData } = useCampaign(character?.campaignId);
@@ -292,7 +294,9 @@ export function CharacterSheetPage() {
const [pickedSpellId, setPickedSpellId] = useState('');
const [copyTargetId, setCopyTargetId] = useState('');
const [formError, setFormError] = useState<string | null>(null);
const [levellingUp, setLevellingUp] = useState(false);
// ?levelup=1 (the link in the "can level up" notice) opens the checklist straight away.
const [searchParams, setSearchParams] = useSearchParams();
const [levellingUp, setLevellingUp] = useState(searchParams.get('levelup') === '1');
const weaponsPage = usePagination(character?.weapons, 8);
const spellsPage = usePagination(character?.spells, 8);
@@ -428,6 +432,22 @@ export function CharacterSheetPage() {
return (
<div>
{character.levelUpsPending > 0 && canEdit && (
<div className="notice-banner" role="status">
<span style={{ flex: 1 }}>
<strong>⬆ {isOwner ? 'You can level up!' : `${character.name} has a level up waiting.`}</strong>{' '}
{isOwner
? `Level ${character.level} → ${character.level + 1}: see what you gain and make your picks.`
: character.userId
? 'Their player has been told; you can also finish it for them.'
: 'No player: it is yours to finish.'}
{character.levelUpsPending > 1 && ` (${character.levelUpsPending} levels approved.)`}
</span>
<button className="btn btn-primary" onClick={() => setLevellingUp(true)}>
{isOwner ? 'Choose what you gain' : 'Open the checklist'}
</button>
</div>
)}
<div className="page-header">
<div>
<h1>
@@ -606,13 +626,23 @@ export function CharacterSheetPage() {
/>
</label>
)}
{character.userId && character.userId !== user?.id && (
<button
className="btn btn-primary"
onClick={() => approveLevelUp.mutate({ characterIds: [character.id] }, { onError: (err) => setFormError(extractErrorMessage(err)) })}
disabled={character.level + character.levelUpsPending >= 20 || approveLevelUp.isPending}
title="Approves one level: their player is told, sees what they gain and makes the picks themselves"
>
⬆ Approve a level up{character.levelUpsPending > 0 ? ` (${character.levelUpsPending} waiting)` : ''}
</button>
)}
<button
className="btn btn-primary"
className="btn"
onClick={() => setLevellingUp(true)}
disabled={character.level >= 20}
title="Go up one level with the levelling-up checklist: HP, new special abilities, spells and the free weapon upgrade"
title="Go up one level yourself with the levelling-up checklist: HP, new special abilities, spells and the free weapon upgrade"
>
⬆ Level Up…
⬆ Level up now…
</button>
<label className="flex-row gap-sm" title="Hit Dice this character still has to spend on a short rest. The Rest button on the campaign page keeps count for you.">
Hit Dice left
@@ -1288,7 +1318,16 @@ export function CharacterSheetPage() {
<Pagination page={notesPage.page} pageCount={notesPage.pageCount} onPrev={notesPage.prevPage} onNext={notesPage.nextPage} onJump={notesPage.setPage} />
</div>
{isDM && levellingUp && <LevelUpModal character={character} campaignSpells={campaignSpells ?? []} onClose={() => setLevellingUp(false)} />}
{(isDM || (isOwner && character.levelUpsPending > 0)) && levellingUp && (
<LevelUpModal
character={character}
isDM={isDM}
onClose={() => {
setLevellingUp(false);
if (searchParams.has('levelup')) setSearchParams({}, { replace: true });
}}
/>
)}
</div>
);
+15
View File
@@ -24,10 +24,12 @@ import {
useAddReward,
useDeleteReward,
useAwardReward,
useRolls,
type NewReward,
} from '../api/queries';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { useAuth } from '../auth/useAuth';
import { RollFeed } from '../components/RollFeed';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput';
import { HpBar } from '../components/HpBar';
@@ -1182,6 +1184,8 @@ export function EncounterPage() {
useRememberCampaign(encounter?.campaignId);
const { data: campaignData } = useCampaign(encounter?.campaignId);
// The table's rolls, for the DM to watch beside the fight.
const { data: rolls } = useRolls(encounter?.campaignId);
const isDM = campaignData?.campaign.role === 'DM';
const { data: characters } = useCampaignCharacters(encounter?.campaignId);
const { data: monsters } = useCampaignMonsters(encounter?.campaignId, isDM);
@@ -1654,6 +1658,17 @@ export function EncounterPage() {
</div>
)}
{isDM && (
<details className="card" open={(rolls ?? []).length > 0}>
<summary className="card-title" style={{ cursor: 'pointer', marginBottom: 0 }}>
🎲 Rolls at the table
</summary>
<div className="mt-sm">
<RollFeed rolls={(rolls ?? []).slice(0, 8)} userId={user?.id} empty="Nobody has rolled yet. Every roll made in the dice roller shows here." />
</div>
</details>
)}
<TradePanel
encounter={encounter}
isDM={isDM}
+23
View File
@@ -9,6 +9,7 @@ import { usePagination } from '../hooks/usePagination';
import { InfoTip } from '../components/InfoTip';
import { SPECIES_LABELS, classLabel, formatPurse, slotsSummary, xpLabel } from '../lib/format';
import { RestModal } from '../components/RestModal';
import { LevelUpBulkModal } from '../components/LevelUpBulkModal';
import { HpBar } from '../components/HpBar';
import { LifeBadge } from '../components/DeathSaves';
import { ConditionChip, StateBadges } from '../components/CharacterState';
@@ -29,6 +30,7 @@ export function PartyOverviewPage() {
const { data: characters, isLoading: charsLoading, error: charsError } = useCampaignCharacters(campaignId);
const { pageItems, page, pageCount, prevPage, nextPage, setPage } = usePagination(characters, 12);
const [resting, setResting] = useState(false);
const [levelling, setLevelling] = useState(false);
const [checking, setChecking] = useState(false);
if (campaignLoading || charsLoading) return <Spinner label="Gathering the party…" />;
@@ -51,6 +53,11 @@ export function PartyOverviewPage() {
🎲 Call for a roll…
</button>
)}
{isDM && characters?.some((c) => c.buildStage === 'COMPLETE') && (
<button className="btn" onClick={() => setLevelling(true)} title="Approve a level for any of the party at once: each player is told, and picks what they gain themselves">
⬆ Level up…
</button>
)}
{isDM && characters?.some((c) => c.buildStage === 'COMPLETE') && (
<button className="btn" onClick={() => setResting(true)} title="A short or long rest for any of the party: Hit Dice, HP and spell slots">
🛏 Rest…
@@ -64,6 +71,18 @@ export function PartyOverviewPage() {
{characters?.length === 0 && <div className="empty-state">No characters yet.</div>}
{isDM && characters?.some((c) => c.levelUpsPending > 0) && (
<div className="notice-banner" role="status">
<span style={{ flex: 1 }}>
<strong>⬆ Level ups waiting on picks:</strong>{' '}
{characters
.filter((c) => c.levelUpsPending > 0)
.map((c) => `${c.name}${c.userId ? '' : ' (no player: yours to finish)'}`)
.join(', ')}
</span>
</div>
)}
{characters && characters.length > 0 && (
<div className="table-wrap">
<table className="data-table">
@@ -125,6 +144,7 @@ export function PartyOverviewPage() {
<td data-label="Class">{c.characterClass ? classLabel(c.characterClass, c.subclassName, c.classes) : inProgress ? 'Building…' : '—'}</td>
<td data-label="Level">
{c.level}
{c.levelUpsPending > 0 && <span title="A level up is approved and waiting for its picks"> ⬆</span>}
{!inProgress && data?.campaign.trackXp && (
<div className="combatant-sub mono" style={{ whiteSpace: 'nowrap' }}>
{xpLabel(c.xp, c.xpProgress)}
@@ -167,6 +187,9 @@ export function PartyOverviewPage() {
<Pagination page={page} pageCount={pageCount} onPrev={prevPage} onNext={nextPage} onJump={setPage} />
{isDM && checking && <GroupCheckModal characters={checkers} onClose={() => setChecking(false)} />}
{isDM && resting && <RestModal campaignId={campaignId} characters={characters ?? []} onClose={() => setResting(false)} />}
{isDM && levelling && (
<LevelUpBulkModal campaignId={campaignId} characters={characters ?? []} trackXp={data?.campaign.trackXp ?? true} onClose={() => setLevelling(false)} />
)}
</div>
);
}
+4 -4
View File
@@ -1,19 +1,19 @@
{
"name": "dnd-campaign-manager",
"version": "1.24.0",
"version": "1.26.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dnd-campaign-manager",
"version": "1.24.0",
"version": "1.26.0",
"workspaces": [
"backend",
"frontend"
]
},
"backend": {
"version": "1.24.0",
"version": "1.26.0",
"dependencies": {
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
@@ -33,7 +33,7 @@
}
},
"frontend": {
"version": "1.24.0",
"version": "1.26.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.24.0",
"version": "1.26.0",
"private": true,
"description": "D&D 5e campaign manager — monorepo root",
"workspaces": [