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:
@@ -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,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;
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.25.0",
|
||||
"version": "1.26.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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}
|
||||
|
||||
Generated
+4
-4
@@ -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
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user