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>
This commit is contained in:
2026-10-12 08:46:22 +02:00
co-authored by Claude Opus 5.5
parent c3f2b0dfa7
commit 8811f14732
22 changed files with 1345 additions and 167 deletions
+6
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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.25.0",
"version": "1.26.0",
"private": true,
"type": "commonjs",
"main": "src/index.js",
@@ -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;
+30
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
+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);
});
});
+64 -4
View File
@@ -3748,7 +3748,9 @@ describe('approved level-ups', () => {
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 before = (await request(app).get(`/characters/${id}`).set(dmAuth)).body.character.sheet.hp.max;
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 });
@@ -3756,15 +3758,15 @@ describe('approved level-ups', () => {
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 and +2 Constitution (CON 14), where the average would have given 6 + 2.
expect(low.body.character.sheet.hp.max).toBe(before + 2 + 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 + 4 + rolled.body.hpRolled + 2);
expect(rolled.body.character.sheet.hp.max).toBe(before + 2 + con + rolled.body.hpRolled + con);
});
});
@@ -3788,3 +3790,61 @@ describe('a player picking spells on a level up', () => {
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);
});
});
+125
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');
@@ -1243,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;
+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
+32 -9
View File
@@ -862,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 |
|---|---|
@@ -883,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.
@@ -900,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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.25.0",
"version": "1.26.0",
"type": "module",
"scripts": {
"dev": "vite",
+24
View File
@@ -9,6 +9,7 @@ import type {
CharacterDetail,
CharacterSummary,
Currency,
DiceRollRecord,
DiceMode,
Encounter,
EncounterMode,
@@ -28,6 +29,7 @@ import type {
QuickStats,
RestResult,
RestType,
RollSpec,
Spell,
Weapon,
} from './types';
@@ -380,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({
+42
View File
@@ -957,6 +957,48 @@ export interface LevelUpApproval {
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>
+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>
);
}
+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>
);
}
+6
View File
@@ -66,6 +66,10 @@ export function useCampaignSocket(campaignId: string | undefined) {
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']);
};
@@ -85,6 +89,7 @@ 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);
@@ -98,6 +103,7 @@ export function useCampaignSocket(campaignId: string | undefined) {
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);
+263
View File
@@ -612,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);
+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}
+4 -4
View File
@@ -1,19 +1,19 @@
{
"name": "dnd-campaign-manager",
"version": "1.25.0",
"version": "1.26.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dnd-campaign-manager",
"version": "1.25.0",
"version": "1.26.0",
"workspaces": [
"backend",
"frontend"
]
},
"backend": {
"version": "1.25.0",
"version": "1.26.0",
"dependencies": {
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
@@ -33,7 +33,7 @@
}
},
"frontend": {
"version": "1.25.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.25.0",
"version": "1.26.0",
"private": true,
"description": "D&D 5e campaign manager — monorepo root",
"workspaces": [