diff --git a/README.md b/README.md index 96efd56..36ecf8c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/package.json b/backend/package.json index 6729730..d7c5abe 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "backend", - "version": "1.25.0", + "version": "1.26.0", "private": true, "type": "commonjs", "main": "src/index.js", diff --git a/backend/prisma/migrations/20261012063211_dice_rolls/migration.sql b/backend/prisma/migrations/20261012063211_dice_rolls/migration.sql new file mode 100644 index 0000000..e71edc7 --- /dev/null +++ b/backend/prisma/migrations/20261012063211_dice_rolls/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 3ff7760..f985492 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/src/game/dice.js b/backend/src/game/dice.js new file mode 100644 index 0000000..3fb1fb3 --- /dev/null +++ b/backend/src/game/dice.js @@ -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 }; diff --git a/backend/src/game/dice.test.js b/backend/src/game/dice.test.js new file mode 100644 index 0000000..9453e6d --- /dev/null +++ b/backend/src/game/dice.test.js @@ -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); + }); +}); diff --git a/backend/src/routes/api.integration.test.js b/backend/src/routes/api.integration.test.js index ac56378..6287a2c 100644 --- a/backend/src/routes/api.integration.test.js +++ b/backend/src/routes/api.integration.test.js @@ -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); + }); +}); diff --git a/backend/src/routes/campaigns.routes.js b/backend/src/routes/campaigns.routes.js index c92bdab..42eeb6e 100644 --- a/backend/src/routes/campaigns.routes.js +++ b/backend/src/routes/campaigns.routes.js @@ -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; diff --git a/docs/CAMPAIGN_SETTINGS.md b/docs/CAMPAIGN_SETTINGS.md index 531ba74..f16e74a 100644 --- a/docs/CAMPAIGN_SETTINGS.md +++ b/docs/CAMPAIGN_SETTINGS.md @@ -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 diff --git a/docs/CHARACTERS.md b/docs/CHARACTERS.md index 0fbefa9..c6a5a08 100644 --- a/docs/CHARACTERS.md +++ b/docs/CHARACTERS.md @@ -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 diff --git a/frontend/package.json b/frontend/package.json index ad17ff4..72b317d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.25.0", + "version": "1.26.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/queries.ts b/frontend/src/api/queries.ts index 4c26a55..ed87bfe 100644 --- a/frontend/src/api/queries.ts +++ b/frontend/src/api/queries.ts @@ -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(['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({ diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 9281517..2ed2363 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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). diff --git a/frontend/src/components/DiceRoller.tsx b/frontend/src/components/DiceRoller.tsx index 172923a..eecd766 100644 --- a/frontend/src/components/DiceRoller.tsx +++ b/frontend/src/components/DiceRoller.tsx @@ -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 { - 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 | 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('NORMAL'); - const [history, setHistory] = useState([]); + const [hidden, setHidden] = useState(false); + const [byHand, setByHand] = useState(() => { + try { + return localStorage.getItem(BY_HAND_KEY) === '1'; + } catch { + return false; + } + }); + const [latest, setLatest] = useState(null); + const [history, setHistory] = useState([]); // outside a campaign only + // A roll waiting for real dice to be typed in, and what has been typed. + const [pending, setPending] = useState(null); + const [typed, setTyped] = useState([]); + const [error, setError] = useState(null); + // The id of somebody else's roll that has just arrived, while it is shown. + const [incomingId, setIncomingId] = useState(null); - const push = (roll: Omit) => 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(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() { 🎲 + {incoming && !(open && tab === 'table') && ( + + )} + {open && (
-
- Roll Dice -
+ {campaignId ? ( +
+ + +
+ ) : ( +
+ Roll Dice +
+ )}
-
- {DICE.map((d) => ( - - ))} -
- -
-
- Dice - - - {count} - - -
- -
- - {/* For any single d20: one rolled here, and every check, save and attack clicked on a sheet. */} -
- {MODES.map((m) => ( - - ))} -
- - - -
- {latest ? ( - <> - {latest.label &&
{latest.label}
} - {/* Keyed on the roll so the pop animation replays every time, even on a repeat result. */} -
- {latest.total} -
- {natural === 20 &&
Natural 20!
} - {natural === 1 &&
Natural 1
} - {(latest.dice.length > 1 || latest.modifier !== 0 || latest.label) && ( -
- {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} -
- )} - - ) : ( -

- Pick a die and roll, or click a skill, save or attack on a sheet. + {tab === 'table' && campaignId ? ( +

+

+ {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.'}

- )} -
- - {history.length > 1 && ( + +
+ ) : ( <> -
- Earlier rolls - +
+ {DICE.map((d) => ( + + ))}
- {history.slice(1).map((r) => ( -
- - {r.label ? `${r.label} · ` : ''} - {r.notation} - {r.dice.length > 1 && ({r.dice.join(', ')})} + +
+
+ Dice + + + {count} - {r.total} +
- ))} + +
+ + {/* For any single d20: one rolled here, and every check, save and attack clicked on a sheet. */} +
+ {MODES.map((m) => ( + + ))} +
+ + {(diceMode === 'BOTH' || (isDM && campaignId)) && ( +
+ {diceMode === 'BOTH' && ( + + )} + {isDM && campaignId && ( + + )} +
+ )} + {diceMode === 'PHYSICAL' &&

This campaign plays with real dice: roll at the table and enter what they show.

} + + {pending ? ( +
{ + e.preventDefault(); + if (typedOk) perform(pending, typedValues); + }} + > +
{pending.label || 'Your roll'}
+

+ Roll {specNotation(pending)} + {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'}: +

+
+ {pendingDice.map((dieSides, i) => ( + + ))} +
+
+ + + {diceMode === 'BOTH' && ( + + )} +
+
+ ) : ( + + )} + + {error &&

{error}

} + +
+ {latest ? ( + // Keyed on the roll so the dice tumble again every time, even on a repeat result. +
+ {latest.label &&
{latest.label}
} + 6 ? 40 : 58} /> +
+ {latest.total} +
+
+ {natural === 20 &&
Natural 20!
} + {natural === 1 &&
Natural 1
} +
+ {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'} +
+
+
+ ) : ( +

+ Pick a die and roll, or click a skill, save or attack on a sheet. +

+ )} +
+ + {!campaignId && history.length > 1 && ( + <> +
+ Earlier rolls + +
+ {history.slice(1).map((r) => ( +
+ + {r.label ? `${r.label} · ` : ''} + {r.notation} + {r.dice.length > 1 && ({r.dice.map((d) => d.value).join(', ')})} + + {r.total} +
+ ))} + + )} + {campaignId && ( +

+ {isDM ? 'Your rolls are shown to the table unless hidden.' : campaign?.rollVisibility === 'OWN' ? 'Your DM sees this roll.' : 'The table sees this roll.'} +

+ )} )}
diff --git a/frontend/src/components/RollFeed.tsx b/frontend/src/components/RollFeed.tsx new file mode 100644 index 0000000..a03fd31 --- /dev/null +++ b/frontend/src/components/RollFeed.tsx @@ -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 ( +
+
+ + {mine ? 'You' : roll.rollerName} + {roll.label && · {roll.label}} + + {timeOf(roll.createdAt)} +
+
+ + + {roll.modifier !== 0 && `${roll.modifier > 0 ? '+' : '−'} ${Math.abs(roll.modifier)} `}= + + {roll.total} +
+
+ {roll.notation} + {roll.mode !== 'NORMAL' && {MODE_NOTE[roll.mode]}} + {crit && Natural 20} + {fumble && Natural 1} + {roll.physical && 🎯 at the table} + {roll.hidden && 🙈 hidden} +
+
+ ); +} + +// 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

{empty}

; + return ( +
+ {rolls.map((roll) => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/dice/Die.tsx b/frontend/src/components/dice/Die.tsx new file mode 100644 index 0000000..c784ac5 --- /dev/null +++ b/frontend/src/components/dice/Die.tsx @@ -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 = { + 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 = { 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 ( + + + d{die.sides} + + ); +} + +// 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 ( + + {dice.map((die, i) => ( + + ))} + + ); +} diff --git a/frontend/src/hooks/useCampaignSocket.ts b/frontend/src/hooks/useCampaignSocket.ts index 666db95..ca03491 100644 --- a/frontend/src/hooks/useCampaignSocket.ts +++ b/frontend/src/hooks/useCampaignSocket.ts @@ -66,6 +66,10 @@ export function useCampaignSocket(campaignId: string | undefined) { const name = qc.getQueryData(['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); diff --git a/frontend/src/index.css b/frontend/src/index.css index 11d069b..396d01a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; diff --git a/frontend/src/lib/diceAnimation.ts b/frontend/src/lib/diceAnimation.ts new file mode 100644 index 0000000..e03de09 --- /dev/null +++ b/frontend/src/lib/diceAnimation.ts @@ -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); diff --git a/frontend/src/pages/EncounterPage.tsx b/frontend/src/pages/EncounterPage.tsx index fcd885c..9fced67 100644 --- a/frontend/src/pages/EncounterPage.tsx +++ b/frontend/src/pages/EncounterPage.tsx @@ -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() {
)} + {isDM && ( +
0}> + + 🎲 Rolls at the table + +
+ +
+
+ )} +