Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b3637576d | ||
|
|
8811f14732 |
@@ -99,6 +99,21 @@ 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.
|
||||
- **Faster battles.** A colour-coded **turn-order track** (PCs blue, NPCs
|
||||
green, monsters red) shows this round, a 🏁 flag where it ends, and the
|
||||
next. Click a name (or press 1–9) to **target**. Whoever is on turn picks a
|
||||
**weapon or spell**; the d20 is typed in or rolled and the app says **hit,
|
||||
miss or critical**, or, for a spell, asks each target for its **saving
|
||||
throw** against the caster's DC. The DM overrules anything and applies it
|
||||
with one click. With **auto-roll** on, a monster's or NPC's turn opens with
|
||||
its attack already aimed and rolled, to accept, edit or discard. A "What
|
||||
now" line always says what the scene is waiting on.
|
||||
- **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.27.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;
|
||||
@@ -0,0 +1,51 @@
|
||||
-- Battle actions: an attack or spell from being declared to its damage
|
||||
-- landing; named attacks for monsters; and how each spell lands (a spell
|
||||
-- attack, or a saving throw). Spells already in campaigns that came from
|
||||
-- the library are marked by name.
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "monsters" ADD COLUMN "attacks" JSONB NOT NULL DEFAULT '[]';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "spells" ADD COLUMN "attackRoll" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "halfOnSave" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "saveAbility" "Ability";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "encounter_actions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"encounterId" TEXT NOT NULL,
|
||||
"round" INTEGER NOT NULL,
|
||||
"actorParticipantId" TEXT,
|
||||
"actorName" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"detail" JSONB NOT NULL,
|
||||
"targets" JSONB NOT NULL,
|
||||
"damage" JSONB,
|
||||
"status" TEXT NOT NULL DEFAULT 'PENDING',
|
||||
"auto" BOOLEAN NOT NULL DEFAULT false,
|
||||
"hidden" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"appliedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "encounter_actions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "encounter_actions_encounterId_status_idx" ON "encounter_actions"("encounterId", "status");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_actions" ADD CONSTRAINT "encounter_actions_encounterId_fkey" FOREIGN KEY ("encounterId") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- How the library's spells land, for copies already in campaigns.
|
||||
UPDATE "spells" SET "attackRoll" = true WHERE lower("name") IN ('chill touch', 'eldritch blast', 'fire bolt', 'produce flame', 'ray of frost', 'shocking grasp', 'thorn whip', 'guiding bolt', 'witch bolt', 'scorching ray', 'spiritual weapon', 'contagion');
|
||||
UPDATE "spells" SET "saveAbility" = 'STR', "halfOnSave" = false WHERE lower("name") IN ('entangle', 'gust of wind');
|
||||
UPDATE "spells" SET "saveAbility" = 'DEX', "halfOnSave" = true WHERE lower("name") IN ('burning hands', 'flaming sphere', 'call lightning', 'fireball', 'lightning bolt', 'guardian of faith', 'ice storm', 'wall of fire', 'flame strike');
|
||||
UPDATE "spells" SET "saveAbility" = 'DEX', "halfOnSave" = false WHERE lower("name") IN ('acid splash', 'sacred flame', 'faerie fire', 'web', 'sleet storm');
|
||||
UPDATE "spells" SET "saveAbility" = 'CON', "halfOnSave" = true WHERE lower("name") IN ('thunderwave', 'moonbeam', 'shatter', 'blight', 'cloudkill', 'cone of cold', 'insect plague');
|
||||
UPDATE "spells" SET "saveAbility" = 'CON', "halfOnSave" = false WHERE lower("name") IN ('poison spray', 'blindness/deafness', 'levitate', 'stinking cloud');
|
||||
UPDATE "spells" SET "saveAbility" = 'WIS', "halfOnSave" = true WHERE lower("name") IN ('spirit guardians');
|
||||
UPDATE "spells" SET "saveAbility" = 'WIS', "halfOnSave" = false WHERE lower("name") IN ('vicious mockery', 'animal friendship', 'charm person', 'command', 'hold person', 'suggestion', 'fear', 'slow', 'confusion', 'polymorph', 'dominate person', 'hold monster');
|
||||
UPDATE "spells" SET "saveAbility" = 'CHA', "halfOnSave" = false WHERE lower("name") IN ('bane', 'zone of truth', 'banishment');
|
||||
@@ -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
|
||||
@@ -626,6 +656,13 @@ model Spell {
|
||||
// Brings back the dead: a character who can cast it may revive a dead ally
|
||||
// on their turn in a battle (encounters.routes.js).
|
||||
revives Boolean @default(false)
|
||||
// How it lands in a battle (game/combat.js). attackRoll: the caster rolls a
|
||||
// spell attack against AC. saveAbility: each target saves against the
|
||||
// caster's spell DC with this ability, taking half on a success when
|
||||
// halfOnSave. Neither: it simply takes effect.
|
||||
attackRoll Boolean @default(false)
|
||||
saveAbility Ability?
|
||||
halfOnSave Boolean @default(false)
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
known CharacterSpell[]
|
||||
@@ -669,6 +706,11 @@ model Monster {
|
||||
challengeRating String?
|
||||
specialTrait String?
|
||||
notes String?
|
||||
// Named attacks, for a monster with more than the one above: a list of
|
||||
// { name, attackBonus, damageDice, damageType?, count? }. `count` is how
|
||||
// many times it is made each turn (a multiattack). Empty: the single
|
||||
// attackBonus/damageDice above is its attack.
|
||||
attacks Json @default("[]")
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
encounterAppearances EncounterParticipant[]
|
||||
@@ -739,11 +781,49 @@ model Encounter {
|
||||
participants EncounterParticipant[]
|
||||
rewards EncounterReward[]
|
||||
wares SaleOffer[]
|
||||
actions EncounterAction[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@map("encounters")
|
||||
}
|
||||
|
||||
// An attack or a spell in a battle, from the moment it is declared to the
|
||||
// moment its damage lands (game/combat.js). A player declares their own on
|
||||
// their turn; the DM declares anyone's; and with the campaign's auto-roll
|
||||
// settings the server declares a monster's or NPC's, already rolled. While
|
||||
// PENDING it gathers what it needs (a d20 against each target's AC, or each
|
||||
// target's saving throw, and the damage); the DM then applies it, changed as
|
||||
// they see fit, or discards it. `hidden` keeps it from the players until it
|
||||
// is applied.
|
||||
model EncounterAction {
|
||||
id String @id @default(cuid())
|
||||
encounterId String
|
||||
round Int
|
||||
actorParticipantId String?
|
||||
actorName String
|
||||
kind String // ATTACK (a d20 against AC) | SAVE (targets save against a DC) | AUTO (it just lands)
|
||||
name String // "Longsword", "Fireball", "Bite"
|
||||
// What it rolls with: { attackBonus?, saveAbility?, saveDC?, halfOnSave?,
|
||||
// damageDice?, damageBonus, damageType?, characterId?, slotLevel? }.
|
||||
detail Json
|
||||
// One entry per target: { participantId, name, kind, ac, penalty, saveBonus,
|
||||
// d20, outcome (set by the DM to overrule the dice), damage (likewise) }.
|
||||
targets Json
|
||||
// The damage once rolled or entered: { total, dice?, modifier?, crit? }.
|
||||
damage Json?
|
||||
status String @default("PENDING") // PENDING | APPLIED | DISCARDED
|
||||
auto Boolean @default(false) // declared and rolled by the server
|
||||
hidden Boolean @default(false)
|
||||
createdById String?
|
||||
createdAt DateTime @default(now())
|
||||
appliedAt DateTime?
|
||||
|
||||
encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([encounterId, status])
|
||||
@@map("encounter_actions")
|
||||
}
|
||||
|
||||
// Loot the DM lines up for an encounter and later hands to a character. Until
|
||||
// it is awarded it is the DM's secret; awarding it is what actually changes
|
||||
// the character (gold ledger entry, or the weapon/item added to what they hold).
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// Combat: how an attack or a spell is resolved in a battle. An "action" is
|
||||
// one attack or spell from being declared to its damage landing
|
||||
// (EncounterAction): who it is aimed at, the d20 rolled against each target
|
||||
// (or each target's saving throw), and the damage. Pure: no DB calls. The
|
||||
// random source can be passed in for tests.
|
||||
|
||||
const { parseDice, leadingDice, rollExpression, rollCritical, rollDie } = require('./dice');
|
||||
|
||||
// What an attack roll comes to against a target. A natural 20 is a critical
|
||||
// hit and a natural 1 a miss, whatever the numbers; otherwise the d20 plus the
|
||||
// bonus has to reach the AC (with any area-attack penalty added).
|
||||
function attackOutcome(d20, attackBonus, ac, penalty = 0) {
|
||||
if (d20 === 20) return 'CRIT';
|
||||
if (d20 === 1) return 'FUMBLE';
|
||||
return d20 + attackBonus >= ac + penalty ? 'HIT' : 'MISS';
|
||||
}
|
||||
|
||||
// Whether a saving throw succeeds: the d20 plus the bonus reaches the DC.
|
||||
const saveSucceeds = (d20, saveBonus, dc) => d20 + saveBonus >= dc;
|
||||
|
||||
const lands = (outcome) => outcome === 'HIT' || outcome === 'CRIT';
|
||||
|
||||
// A target as the dice have it (or as the DM has overruled them), with the
|
||||
// damage it takes. `outcome` and `damage` on the target are the DM's
|
||||
// overrides; null leaves it to the dice.
|
||||
// ATTACK: total, outcome HIT | MISS | CRIT | FUMBLE (null until a d20 is in).
|
||||
// SAVE: total, outcome SAVED | FAILED (null until the save is in).
|
||||
// AUTO: outcome HIT.
|
||||
// `finalDamage` is null until it can be known.
|
||||
function settleTarget(action, target) {
|
||||
const { kind, detail } = action;
|
||||
const base = action.damage ? action.damage.total : null;
|
||||
const crit = action.damage && action.damage.crit ? action.damage.crit.total : null;
|
||||
const noDamage = !detail.damageDice && base == null;
|
||||
let total = null;
|
||||
let rolled = null; // what the dice say, before any override
|
||||
if (kind === 'ATTACK') {
|
||||
if (target.d20 != null) {
|
||||
total = target.d20 + (detail.attackBonus || 0);
|
||||
rolled = attackOutcome(target.d20, detail.attackBonus || 0, target.ac ?? 10, target.penalty || 0);
|
||||
}
|
||||
} else if (kind === 'SAVE') {
|
||||
if (target.d20 != null) {
|
||||
total = target.d20 + (target.saveBonus || 0);
|
||||
rolled = saveSucceeds(target.d20, target.saveBonus || 0, detail.saveDC || 10) ? 'SAVED' : 'FAILED';
|
||||
}
|
||||
} else {
|
||||
rolled = 'HIT';
|
||||
}
|
||||
const outcome = target.outcome || rolled;
|
||||
|
||||
let finalDamage = null;
|
||||
if (target.damage != null) finalDamage = target.damage;
|
||||
else if (outcome === 'MISS' || outcome === 'FUMBLE') finalDamage = 0;
|
||||
else if (noDamage) finalDamage = outcome ? 0 : null;
|
||||
else if (outcome && base != null) {
|
||||
if (outcome === 'CRIT') finalDamage = crit != null ? crit : base;
|
||||
else if (outcome === 'SAVED') finalDamage = detail.halfOnSave ? Math.floor(base / 2) : 0;
|
||||
else finalDamage = base;
|
||||
}
|
||||
return { ...target, total, rolled, outcome, overruled: Boolean(target.outcome) && target.outcome !== rolled, finalDamage };
|
||||
}
|
||||
|
||||
// An action with every target settled, and what it is still waiting for:
|
||||
// `needsRolls` (targets with no outcome yet), `needsDamage` (someone is hit
|
||||
// and the damage is not known), `needsCrit` (someone is critically hit and the
|
||||
// critical damage has not been rolled), and `ready` when none of those.
|
||||
function settle(action) {
|
||||
const targets = (action.targets || []).map((target) => settleTarget(action, target));
|
||||
const needsRolls = targets.filter((t) => !t.outcome).length;
|
||||
const hurts = targets.some((t) => t.damage == null && (lands(t.outcome) || t.outcome === 'FAILED' || (t.outcome === 'SAVED' && action.detail.halfOnSave)));
|
||||
const hasDice = Boolean(action.detail.damageDice);
|
||||
const needsDamage = hasDice && hurts && !action.damage;
|
||||
const needsCrit = hasDice && Boolean(action.damage) && !action.damage.crit && !action.damage.entered && targets.some((t) => t.outcome === 'CRIT' && t.damage == null);
|
||||
return { ...action, targets, needsRolls, needsDamage, needsCrit, ready: needsRolls === 0 && !needsDamage && targets.length > 0 };
|
||||
}
|
||||
|
||||
// Rolls an action's damage: its dice plus its bonus, and, when a target is
|
||||
// critically hit, the critical damage by the campaign's rule. Null for an
|
||||
// action with no dice (or dice that can't be read).
|
||||
function rollDamage(detail, { critical = false, critRule = 'DOUBLE_DICE', roll = rollDie } = {}) {
|
||||
const dice = leadingDice(detail.damageDice || '');
|
||||
const parsed = dice ? parseDice(dice) : null;
|
||||
if (!parsed) return null;
|
||||
const withBonus = { dice: parsed.dice, flat: parsed.flat + (detail.damageBonus || 0) };
|
||||
const normal = rollExpression(withBonus, { roll });
|
||||
return {
|
||||
total: Math.max(0, normal.total),
|
||||
dice: normal.dice,
|
||||
modifier: normal.modifier,
|
||||
crit: critical ? critOf(withBonus, critRule, roll) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function critOf(parsed, critRule, roll) {
|
||||
const crit = rollCritical(parsed, critRule, { roll });
|
||||
return { total: Math.max(0, crit.total), dice: crit.dice, modifier: crit.modifier };
|
||||
}
|
||||
|
||||
// The critical damage alone, for an action whose normal damage is already rolled.
|
||||
function rollCritDamage(detail, { critRule = 'DOUBLE_DICE', roll = rollDie } = {}) {
|
||||
const dice = leadingDice(detail.damageDice || '');
|
||||
const parsed = dice ? parseDice(dice) : null;
|
||||
if (!parsed) return null;
|
||||
return critOf({ dice: parsed.dice, flat: parsed.flat + (detail.damageBonus || 0) }, critRule, roll);
|
||||
}
|
||||
|
||||
// Which side a combatant fights on: the party (characters and friendly NPCs),
|
||||
// its enemies (monsters and hostile NPCs), or neither (a neutral NPC).
|
||||
// Takes { kind: 'CHARACTER' | 'MONSTER' | 'NPC', disposition? }.
|
||||
function sideOf(combatant) {
|
||||
if (combatant.kind === 'CHARACTER') return 'PARTY';
|
||||
if (combatant.kind === 'MONSTER') return 'ENEMY';
|
||||
if (combatant.disposition === 'FRIENDLY') return 'PARTY';
|
||||
if (combatant.disposition === 'HOSTILE') return 'ENEMY';
|
||||
return 'NEUTRAL';
|
||||
}
|
||||
|
||||
// Who a combatant acting by itself could go for: everyone standing on the
|
||||
// other side. A neutral has no enemies.
|
||||
function opponentsOf(actor, combatants) {
|
||||
const side = sideOf(actor);
|
||||
if (side === 'NEUTRAL') return [];
|
||||
return combatants.filter((c) => c.id !== actor.id && !c.fallen && sideOf(c) !== 'NEUTRAL' && sideOf(c) !== side);
|
||||
}
|
||||
|
||||
// How likely each opponent is to be picked, relative to the others. Everyone
|
||||
// starts equal; whoever the actor went for last, and whoever has hurt it,
|
||||
// count for more (a creature keeps at what it is fighting), and so, a little,
|
||||
// does someone easier to hit.
|
||||
function targetWeights(candidates, { lastTargetId = null, hurtBy = [] } = {}) {
|
||||
return candidates.map((c) => {
|
||||
let weight = 2;
|
||||
if (c.id === lastTargetId) weight += 3;
|
||||
if (hurtBy.includes(c.id)) weight += 3;
|
||||
weight += Math.max(0, Math.min(2, (16 - (c.ac ?? 12)) / 3));
|
||||
return weight;
|
||||
});
|
||||
}
|
||||
|
||||
// Picks one opponent at random by those weights; null when there is nobody.
|
||||
// `random` gives a number from 0 (inclusive) to 1 (exclusive).
|
||||
function pickTarget(candidates, context = {}, random = Math.random) {
|
||||
if (candidates.length === 0) return null;
|
||||
const weights = targetWeights(candidates, context);
|
||||
let at = random() * weights.reduce((sum, w) => sum + w, 0);
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
at -= weights[i];
|
||||
if (at < 0) return candidates[i];
|
||||
}
|
||||
return candidates[candidates.length - 1];
|
||||
}
|
||||
|
||||
// The attacks a monster or NPC makes on its turn, one entry per attack made
|
||||
// (a multiattack's `count` is spread out): [{ name, attackBonus, damageDice,
|
||||
// damageType }]. A monster's named `attacks` when it has any, otherwise its
|
||||
// single attack; an NPC's single attack. Empty for one with nothing to hit with.
|
||||
function attacksOf({ monster = null, npc = null }) {
|
||||
const named = monster && Array.isArray(monster.attacks) ? monster.attacks.filter((a) => a && a.name) : [];
|
||||
if (named.length > 0) {
|
||||
return named.flatMap((a) =>
|
||||
Array.from({ length: Math.max(1, Math.min(10, Number(a.count) || 1)) }, () => ({
|
||||
name: a.name,
|
||||
attackBonus: Number(a.attackBonus) || 0,
|
||||
damageDice: a.damageDice || null,
|
||||
damageType: a.damageType || null,
|
||||
}))
|
||||
);
|
||||
}
|
||||
const stats = monster || npc;
|
||||
if (!stats || (stats.attackBonus == null && !stats.damageDice)) return [];
|
||||
return [{ name: 'Attack', attackBonus: stats.attackBonus || 0, damageDice: stats.damageDice || null, damageType: null }];
|
||||
}
|
||||
|
||||
module.exports = { attackOutcome, saveSucceeds, lands, settleTarget, settle, rollDamage, rollCritDamage, sideOf, opponentsOf, targetWeights, pickTarget, attacksOf };
|
||||
@@ -0,0 +1,142 @@
|
||||
const { attackOutcome, saveSucceeds, settle, rollDamage, rollCritDamage, sideOf, opponentsOf, targetWeights, pickTarget, attacksOf } = require('./combat');
|
||||
|
||||
const fixed = (...faces) => {
|
||||
let i = 0;
|
||||
return () => faces[i++ % faces.length];
|
||||
};
|
||||
|
||||
describe('an attack roll', () => {
|
||||
test('hits when the d20 plus the bonus reaches the AC', () => {
|
||||
expect(attackOutcome(10, 5, 15)).toBe('HIT');
|
||||
expect(attackOutcome(9, 5, 15)).toBe('MISS');
|
||||
});
|
||||
|
||||
test('a natural 20 always hits, critically, and a natural 1 always misses', () => {
|
||||
expect(attackOutcome(20, -5, 30)).toBe('CRIT');
|
||||
expect(attackOutcome(1, 20, 5)).toBe('FUMBLE');
|
||||
});
|
||||
|
||||
test('an area attack counts each AC higher', () => {
|
||||
expect(attackOutcome(10, 5, 15, 2)).toBe('MISS');
|
||||
expect(attackOutcome(12, 5, 15, 2)).toBe('HIT');
|
||||
});
|
||||
});
|
||||
|
||||
test('a saving throw succeeds on the DC or more', () => {
|
||||
expect(saveSucceeds(12, 3, 15)).toBe(true);
|
||||
expect(saveSucceeds(11, 3, 15)).toBe(false);
|
||||
});
|
||||
|
||||
describe('settling an action', () => {
|
||||
const attack = (targets, damage = null) => ({ kind: 'ATTACK', detail: { attackBonus: 5, damageDice: '1d8', damageBonus: 3 }, targets, damage });
|
||||
|
||||
test('waits for a d20 for each target, then for the damage', () => {
|
||||
const waiting = settle(attack([{ participantId: 'a', ac: 15, d20: null }]));
|
||||
expect(waiting).toMatchObject({ needsRolls: 1, ready: false });
|
||||
const hit = settle(attack([{ participantId: 'a', ac: 15, d20: 12 }]));
|
||||
expect(hit.targets[0]).toMatchObject({ total: 17, outcome: 'HIT', finalDamage: null });
|
||||
expect(hit).toMatchObject({ needsRolls: 0, needsDamage: true, ready: false });
|
||||
const done = settle(attack([{ participantId: 'a', ac: 15, d20: 12 }], { total: 7 }));
|
||||
expect(done.targets[0].finalDamage).toBe(7);
|
||||
expect(done.ready).toBe(true);
|
||||
});
|
||||
|
||||
test('a miss needs no damage at all', () => {
|
||||
const miss = settle(attack([{ participantId: 'a', ac: 15, d20: 4 }]));
|
||||
expect(miss.targets[0]).toMatchObject({ outcome: 'MISS', finalDamage: 0 });
|
||||
expect(miss).toMatchObject({ needsDamage: false, ready: true });
|
||||
});
|
||||
|
||||
test('a critical hit takes the critical damage, once it is rolled', () => {
|
||||
const crit = settle(attack([{ participantId: 'a', ac: 15, d20: 20 }], { total: 7 }));
|
||||
expect(crit.needsCrit).toBe(true);
|
||||
expect(settle(attack([{ participantId: 'a', ac: 15, d20: 20 }], { total: 7, crit: { total: 13 } })).targets[0].finalDamage).toBe(13);
|
||||
});
|
||||
|
||||
test('the DM can overrule the dice, and set a target’s damage by hand', () => {
|
||||
const overruled = settle(attack([{ participantId: 'a', ac: 15, d20: 4, outcome: 'HIT' }, { participantId: 'b', ac: 12, d20: 15, damage: 2 }], { total: 9 }));
|
||||
expect(overruled.targets[0]).toMatchObject({ rolled: 'MISS', outcome: 'HIT', overruled: true, finalDamage: 9 });
|
||||
expect(overruled.targets[1]).toMatchObject({ outcome: 'HIT', overruled: false, finalDamage: 2 });
|
||||
});
|
||||
|
||||
test('a save spell deals full damage on a failure, half or none on a success', () => {
|
||||
const spell = (halfOnSave) =>
|
||||
settle({
|
||||
kind: 'SAVE',
|
||||
detail: { saveAbility: 'DEX', saveDC: 15, halfOnSave, damageDice: '8d6' },
|
||||
targets: [{ participantId: 'a', saveBonus: 2, d20: 13 }, { participantId: 'b', saveBonus: 2, d20: 12 }, { participantId: 'c', saveBonus: 0, d20: null }],
|
||||
damage: { total: 27 },
|
||||
});
|
||||
const fireball = spell(true);
|
||||
expect(fireball.targets.map((t) => [t.outcome, t.finalDamage])).toEqual([['SAVED', 13], ['FAILED', 27], [null, null]]);
|
||||
expect(fireball).toMatchObject({ needsRolls: 1, ready: false });
|
||||
expect(spell(false).targets[0].finalDamage).toBe(0);
|
||||
});
|
||||
|
||||
test('something that just lands hits everyone it is aimed at', () => {
|
||||
const missile = settle({ kind: 'AUTO', detail: { damageDice: '3d4+3' }, targets: [{ participantId: 'a' }], damage: { total: 10 } });
|
||||
expect(missile.targets[0]).toMatchObject({ outcome: 'HIT', finalDamage: 10 });
|
||||
expect(missile.ready).toBe(true);
|
||||
// With no dice at all (a utility spell) it is ready as it stands.
|
||||
expect(settle({ kind: 'AUTO', detail: {}, targets: [{ participantId: 'a' }], damage: null })).toMatchObject({ ready: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('damage', () => {
|
||||
test('is the dice plus the bonus', () => {
|
||||
expect(rollDamage({ damageDice: '1d8', damageBonus: 3 }, { roll: fixed(5) })).toMatchObject({ total: 8, crit: null });
|
||||
expect(rollDamage({ damageDice: '2d6+1 fire', damageBonus: 0 }, { roll: fixed(2, 3) }).total).toBe(6);
|
||||
expect(rollDamage({ damageDice: 'a nasty bite' })).toBeNull();
|
||||
});
|
||||
|
||||
test('a critical hit doubles the dice, never the bonus', () => {
|
||||
const rolled = rollDamage({ damageDice: '1d8', damageBonus: 3 }, { critical: true, roll: fixed(5, 4, 6) });
|
||||
expect(rolled.total).toBe(8);
|
||||
expect(rolled.crit.total).toBe(4 + 6 + 3);
|
||||
expect(rollCritDamage({ damageDice: '1d8', damageBonus: 3 }, { critRule: 'MAX_PLUS_ROLL', roll: fixed(2) }).total).toBe(8 + 2 + 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sides', () => {
|
||||
const aria = { id: 'aria', kind: 'CHARACTER', ac: 16 };
|
||||
const bo = { id: 'bo', kind: 'CHARACTER', ac: 12 };
|
||||
const goblin = { id: 'goblin', kind: 'MONSTER', ac: 13 };
|
||||
const guard = { id: 'guard', kind: 'NPC', disposition: 'FRIENDLY', ac: 14 };
|
||||
const thug = { id: 'thug', kind: 'NPC', disposition: 'HOSTILE', ac: 11 };
|
||||
const baker = { id: 'baker', kind: 'NPC', disposition: 'NEUTRAL', ac: 10 };
|
||||
const everyone = [aria, bo, goblin, guard, thug, baker];
|
||||
|
||||
test('characters and friendly NPCs against monsters and hostile NPCs', () => {
|
||||
expect(everyone.map(sideOf)).toEqual(['PARTY', 'PARTY', 'ENEMY', 'PARTY', 'ENEMY', 'NEUTRAL']);
|
||||
expect(opponentsOf(goblin, everyone).map((c) => c.id)).toEqual(['aria', 'bo', 'guard']);
|
||||
expect(opponentsOf(guard, everyone).map((c) => c.id)).toEqual(['goblin', 'thug']);
|
||||
expect(opponentsOf(baker, everyone)).toEqual([]);
|
||||
});
|
||||
|
||||
test('the fallen are nobody’s target', () => {
|
||||
expect(opponentsOf(goblin, [aria, { ...bo, fallen: true }]).map((c) => c.id)).toEqual(['aria']);
|
||||
});
|
||||
|
||||
test('a creature favours who it fought last, who hurt it, and the easier mark', () => {
|
||||
const [ariaWeight, boWeight] = targetWeights([aria, bo]);
|
||||
expect(boWeight).toBeGreaterThan(ariaWeight); // lower AC
|
||||
const [again] = targetWeights([aria, bo], { lastTargetId: 'aria', hurtBy: ['aria'] });
|
||||
expect(again).toBeGreaterThan(boWeight);
|
||||
});
|
||||
|
||||
test('picks by weight, and nobody when there is nobody', () => {
|
||||
expect(pickTarget([aria, bo], {}, () => 0).id).toBe('aria');
|
||||
expect(pickTarget([aria, bo], {}, () => 0.999).id).toBe('bo');
|
||||
expect(pickTarget([], {})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('what a creature attacks with', () => {
|
||||
test('its single attack, or each of its named attacks as often as it makes it', () => {
|
||||
expect(attacksOf({ monster: { attackBonus: 4, damageDice: '1d6+2', attacks: [] } })).toEqual([{ name: 'Attack', attackBonus: 4, damageDice: '1d6+2', damageType: null }]);
|
||||
const owlbear = attacksOf({ monster: { attackBonus: 7, damageDice: '2d8+5', attacks: [{ name: 'Claw', attackBonus: 7, damageDice: '2d8+5', damageType: 'slashing', count: 2 }, { name: 'Beak', attackBonus: 7, damageDice: '1d10+5' }] } });
|
||||
expect(owlbear.map((a) => a.name)).toEqual(['Claw', 'Claw', 'Beak']);
|
||||
expect(attacksOf({ npc: { attackBonus: 3, damageDice: '1d6' } })).toHaveLength(1);
|
||||
expect(attacksOf({ npc: { attackBonus: null, damageDice: null } })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -155,4 +155,61 @@ const SPELL_LIBRARY = [
|
||||
{ key: 'wall-of-stone', name: 'Wall of Stone', level: 5, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 10 minutes', description: 'A nonmagical wall of solid stone springs into existence.', classes: ['DRUID', 'SORCERER', 'WIZARD'] },
|
||||
];
|
||||
|
||||
// How each spell lands in a battle (game/combat.js): a spell attack against
|
||||
// AC, or a saving throw by each target, [ability, half damage on a success].
|
||||
// A spell in neither list simply takes effect. Written onto the entries
|
||||
// below, as `attackRoll`, `saveAbility` and `halfOnSave`.
|
||||
const SPELL_ATTACKS = ['chill-touch', 'eldritch-blast', 'fire-bolt', 'produce-flame', 'ray-of-frost', 'shocking-grasp', 'thorn-whip', 'guiding-bolt', 'witch-bolt', 'scorching-ray', 'spiritual-weapon', 'contagion'];
|
||||
const SPELL_SAVES = {
|
||||
'acid-splash': ['DEX', false],
|
||||
'poison-spray': ['CON', false],
|
||||
'sacred-flame': ['DEX', false],
|
||||
'vicious-mockery': ['WIS', false],
|
||||
'burning-hands': ['DEX', true],
|
||||
'thunderwave': ['CON', true],
|
||||
'bane': ['CHA', false],
|
||||
'charm-person': ['WIS', false],
|
||||
'command': ['WIS', false],
|
||||
'entangle': ['STR', false],
|
||||
'faerie-fire': ['DEX', false],
|
||||
'animal-friendship': ['WIS', false],
|
||||
'blindness-deafness': ['CON', false],
|
||||
'hold-person': ['WIS', false],
|
||||
'flaming-sphere': ['DEX', true],
|
||||
'moonbeam': ['CON', true],
|
||||
'shatter': ['CON', true],
|
||||
'suggestion': ['WIS', false],
|
||||
'web': ['DEX', false],
|
||||
'zone-of-truth': ['CHA', false],
|
||||
'levitate': ['CON', false],
|
||||
'gust-of-wind': ['STR', false],
|
||||
'call-lightning': ['DEX', true],
|
||||
'fear': ['WIS', false],
|
||||
'fireball': ['DEX', true],
|
||||
'lightning-bolt': ['DEX', true],
|
||||
'sleet-storm': ['DEX', false],
|
||||
'slow': ['WIS', false],
|
||||
'spirit-guardians': ['WIS', true],
|
||||
'stinking-cloud': ['CON', false],
|
||||
'banishment': ['CHA', false],
|
||||
'blight': ['CON', true],
|
||||
'confusion': ['WIS', false],
|
||||
'ice-storm': ['DEX', true],
|
||||
'polymorph': ['WIS', false],
|
||||
'wall-of-fire': ['DEX', true],
|
||||
'guardian-of-faith': ['DEX', true],
|
||||
'cloudkill': ['CON', true],
|
||||
'cone-of-cold': ['CON', true],
|
||||
'dominate-person': ['WIS', false],
|
||||
'flame-strike': ['DEX', true],
|
||||
'hold-monster': ['WIS', false],
|
||||
'insect-plague': ['CON', true],
|
||||
};
|
||||
for (const spell of SPELL_LIBRARY) {
|
||||
spell.attackRoll = SPELL_ATTACKS.includes(spell.key);
|
||||
const save = SPELL_SAVES[spell.key];
|
||||
spell.saveAbility = save ? save[0] : null;
|
||||
spell.halfOnSave = save ? save[1] : false;
|
||||
}
|
||||
|
||||
module.exports = { SPELL_LIBRARY };
|
||||
|
||||
@@ -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,260 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('battle actions', () => {
|
||||
// A battle with a fighter (a longsword in hand), a wizard and two goblins,
|
||||
// the fighter on turn. Everyone's numbers are set, so the sums can be checked.
|
||||
async function battle(prefix, settings = {}) {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign(prefix);
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const heroId = await buildAs(campaign.id, playerAuth, 'Aria', 'FIGHTER', 3);
|
||||
const mageId = await buildAs(campaign.id, dmAuth, 'Mira', 'WIZARD', 5);
|
||||
await prisma.character.updateMany({ where: { id: { in: [heroId, mageId] } }, data: { species: 'HUMAN', strScore: 15, dexScore: 13, intScore: 15, speciesBonusChoices: {} } });
|
||||
const sword = (await request(app).post(`${c}/weapons`).set(dmAuth).send({ name: 'Longsword', damageDice: '1d8', damageType: 'slashing', attackAbility: 'STR', properties: [] })).body.weapon;
|
||||
await request(app).post(`/characters/${heroId}/weapons`).set(dmAuth).send({ weaponId: sword.id });
|
||||
await request(app).patch(`/characters/${heroId}/weapons/${sword.id}`).set(playerAuth).send({ equipped: true });
|
||||
await request(app).post(`${c}/spells/from-library`).set(dmAuth).send({ key: 'fireball' });
|
||||
await request(app).post(`${c}/spells/from-library`).set(dmAuth).send({ key: 'fire-bolt' });
|
||||
for (const spell of (await request(app).get(`${c}/spells`).set(dmAuth)).body.spells) {
|
||||
await request(app).post(`/characters/${mageId}/spells`).set(dmAuth).send({ spellId: spell.id });
|
||||
}
|
||||
const goblin = (await request(app).post(`${c}/monsters/from-library`).set(dmAuth).send({ key: 'goblin' })).body.monster;
|
||||
const e = `/encounters/${(await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Ambush' })).body.encounter.id}`;
|
||||
const add = async (body) => (await request(app).post(`${e}/participants`).set(dmAuth).send(body)).body.participant;
|
||||
const hero = await add({ characterId: heroId, initiativeRoll: 20 });
|
||||
const mage = await add({ characterId: mageId, initiativeRoll: 15 });
|
||||
const one = await add({ monsterId: goblin.id, displayName: 'Goblin 1', initiativeRoll: 10 });
|
||||
const two = await add({ monsterId: goblin.id, displayName: 'Goblin 2', initiativeRoll: 5 });
|
||||
if (Object.keys(settings).length > 0) await request(app).patch(c).set(dmAuth).send(settings);
|
||||
await request(app).patch(e).set(dmAuth).send({ status: 'ACTIVE' });
|
||||
const view = async (auth) => (await request(app).get(e).set(auth)).body.encounter;
|
||||
const hp = async (id) => (await view(dmAuth)).participants.find((p) => p.id === id).currentHp;
|
||||
return { dmAuth, playerAuth, campaign, e, hero, mage, one, two, goblin, heroId, mageId, view, hp };
|
||||
}
|
||||
|
||||
itIfDb('a player declares an attack on their turn and enters the d20; the DM sees hit or miss, and applies it', async () => {
|
||||
const { dmAuth, playerAuth, e, one, two, mage, view, hp } = await battle('act');
|
||||
const declare = (auth, body) => request(app).post(`${e}/actions`).set(auth).send(body);
|
||||
const patch = (auth, id, body) => request(app).patch(`${e}/actions/${id}`).set(auth).send(body);
|
||||
const apply = (auth, id) => request(app).post(`${e}/actions/${id}/apply`).set(auth).send({});
|
||||
|
||||
const info = (await view(playerAuth)).participants.find((p) => p.displayName === 'Aria').characterCombatInfo;
|
||||
const sword = info.weapons[0];
|
||||
// Strength 16 (15, +1 human): +3, and proficiency +2.
|
||||
expect(sword).toMatchObject({ name: 'Longsword', attackBonus: 5, damageBonus: 3 });
|
||||
|
||||
// No target marked: nothing to aim at. A player has no "basic" attack.
|
||||
expect((await declare(playerAuth, { source: { type: 'weapon', characterWeaponId: sword.characterWeaponId } })).status).toBe(400);
|
||||
await request(app).put(`${e}/targets`).set(playerAuth).send({ participantIds: [one.id] });
|
||||
expect((await declare(playerAuth, { source: { type: 'basic' } })).status).toBe(403);
|
||||
|
||||
const declared = await declare(playerAuth, { source: { type: 'weapon', characterWeaponId: sword.characterWeaponId } });
|
||||
expect(declared.status).toBe(201);
|
||||
const actionId = declared.body.actionId;
|
||||
let action = (await view(dmAuth)).actions[0];
|
||||
expect(action).toMatchObject({ id: actionId, kind: 'ATTACK', name: 'Longsword', actorName: 'Aria', needsRolls: 1, ready: false });
|
||||
expect(action.targets[0]).toMatchObject({ name: 'Goblin 1', ac: 15, d20: null, outcome: null });
|
||||
// The player sees their attack, but not the goblin's AC.
|
||||
expect((await view(playerAuth)).actions[0].targets[0].ac).toBeNull();
|
||||
|
||||
// 9 + 5 = 14 against AC 15: a miss. Then the DM overrules it, and takes that back.
|
||||
expect((await patch(playerAuth, actionId, { rolls: [{ participantId: one.id, d20: 21 }] })).status).toBe(400);
|
||||
action = (await patch(playerAuth, actionId, { rolls: [{ participantId: one.id, d20: 9 }] })).body.encounter.actions[0];
|
||||
expect(action.targets[0]).toMatchObject({ d20: 9, total: 14, outcome: 'MISS', finalDamage: 0 });
|
||||
expect(action.ready).toBe(true);
|
||||
expect((await patch(playerAuth, actionId, { outcomes: [{ participantId: one.id, outcome: 'HIT' }] })).status).toBe(403);
|
||||
action = (await patch(dmAuth, actionId, { outcomes: [{ participantId: one.id, outcome: 'HIT' }] })).body.encounter.actions[0];
|
||||
expect(action.targets[0]).toMatchObject({ rolled: 'MISS', outcome: 'HIT', overruled: true });
|
||||
expect(action).toMatchObject({ needsDamage: true, ready: false });
|
||||
await patch(dmAuth, actionId, { outcomes: [{ participantId: one.id, outcome: null }] });
|
||||
|
||||
// 10 + 5 = 15: a hit. The damage is typed in, and only the DM lets it land.
|
||||
action = (await patch(playerAuth, actionId, { rolls: [{ participantId: one.id, d20: 10 }] })).body.encounter.actions[0];
|
||||
expect(action.targets[0]).toMatchObject({ total: 15, outcome: 'HIT' });
|
||||
expect((await apply(dmAuth, actionId)).status).toBe(400); // no damage yet
|
||||
action = (await patch(playerAuth, actionId, { damage: 4 })).body.encounter.actions[0];
|
||||
expect(action.targets[0].finalDamage).toBe(4);
|
||||
expect((await apply(playerAuth, actionId)).status).toBe(403);
|
||||
const before = await hp(one.id);
|
||||
const applied = await apply(dmAuth, actionId);
|
||||
expect(applied.status).toBe(200);
|
||||
expect(await hp(one.id)).toBe(before - 4);
|
||||
expect(applied.body.encounter.actions).toEqual([]);
|
||||
expect(applied.body.encounter.recentActions[0]).toMatchObject({ id: actionId, status: 'APPLIED' });
|
||||
expect((await apply(dmAuth, actionId)).status).toBe(400); // once
|
||||
|
||||
// The server can roll: the d20, then the damage where it hits. A natural 20 doubles the dice.
|
||||
await request(app).put(`${e}/targets`).set(dmAuth).send({ participantIds: [two.id] });
|
||||
const second = (await declare(playerAuth, { source: { type: 'weapon', characterWeaponId: sword.characterWeaponId } })).body.actionId;
|
||||
action = (await patch(playerAuth, second, { rollRemaining: true })).body.encounter.actions[0];
|
||||
expect(action.targets[0].d20).toBeGreaterThanOrEqual(1);
|
||||
action = (await patch(dmAuth, second, { rolls: [{ participantId: two.id, d20: 20 }], rollDamage: true })).body.encounter.actions[0];
|
||||
expect(action.targets[0].outcome).toBe('CRIT');
|
||||
expect(action.damage.crit.dice).toHaveLength(2); // 1d8 rolled twice
|
||||
expect(action.targets[0].finalDamage).toBe(action.damage.crit.total);
|
||||
// The DM can set what a target takes by hand, and call the whole thing off.
|
||||
action = (await patch(dmAuth, second, { targetDamage: [{ participantId: two.id, damage: 1 }] })).body.encounter.actions[0];
|
||||
expect(action.targets[0].finalDamage).toBe(1);
|
||||
expect((await patch(dmAuth, second, { discard: true })).body.encounter.actions).toEqual([]);
|
||||
|
||||
// It is not the wizard's turn (and she is the DM's character): the player cannot act for her.
|
||||
expect((await declare(playerAuth, { actorParticipantId: mage.id, source: { type: 'basic' } })).status).toBe(403);
|
||||
});
|
||||
|
||||
itIfDb('a save spell asks each target for a save against the caster\u2019s DC, halves on a success, and takes its slot', async () => {
|
||||
const { dmAuth, playerAuth, e, one, two, mageId, view, hp } = await battle('actspell');
|
||||
await request(app).post(`${e}/next-turn`).set(dmAuth); // the wizard's turn
|
||||
const mira = (await view(dmAuth)).participants.find((p) => p.displayName === 'Mira').characterCombatInfo;
|
||||
const fireball = mira.spells.find((s) => s.name === 'Fireball');
|
||||
expect(fireball).toMatchObject({ saveAbility: 'DEX', halfOnSave: true, attackRoll: false });
|
||||
expect(mira.spells.find((s) => s.name === 'Fire Bolt')).toMatchObject({ attackRoll: true, saveAbility: null });
|
||||
const dc = mira.spellcasting.saveDC; // 8 + 3 proficiency + 3 Intelligence
|
||||
expect(dc).toBe(14);
|
||||
|
||||
await request(app).put(`${e}/targets`).set(dmAuth).send({ participantIds: [one.id, two.id] });
|
||||
const actionId = (await request(app).post(`${e}/actions`).set(dmAuth).send({ source: { type: 'spell', characterSpellId: fireball.characterSpellId } })).body.actionId;
|
||||
let action = (await view(dmAuth)).actions[0];
|
||||
// A goblin's Dexterity is 14: +2 to the save.
|
||||
expect(action).toMatchObject({ kind: 'SAVE', name: 'Fireball', needsRolls: 2 });
|
||||
expect(action.detail).toMatchObject({ saveAbility: 'DEX', saveDC: 14, halfOnSave: true, slotLevel: 3 });
|
||||
expect(action.targets.map((t) => t.saveBonus)).toEqual([2, 2]);
|
||||
|
||||
const patch = (body) => request(app).patch(`${e}/actions/${actionId}`).set(dmAuth).send(body);
|
||||
// 12 + 2 = 14 saves; 11 + 2 = 13 fails. 20 damage: 10 and 20.
|
||||
action = (await patch({ rolls: [{ participantId: one.id, d20: 12 }, { participantId: two.id, d20: 11 }], damage: 20 })).body.encounter.actions[0];
|
||||
expect(action.targets.map((t) => [t.outcome, t.finalDamage])).toEqual([['SAVED', 10], ['FAILED', 20]]);
|
||||
expect(action.ready).toBe(true);
|
||||
// A player cannot enter a monster's save.
|
||||
expect((await request(app).patch(`${e}/actions/${actionId}`).set(playerAuth).send({ rolls: [{ participantId: one.id, d20: 1 }] })).status).toBe(403);
|
||||
|
||||
const slotsBefore = (await request(app).get(`/characters/${mageId}`).set(dmAuth)).body.character.sheet.spellSlots.levels.find((l) => l.level === 3).remaining;
|
||||
const hpOne = await hp(one.id);
|
||||
const applied = await request(app).post(`${e}/actions/${actionId}/apply`).set(dmAuth).send({});
|
||||
expect(applied.status).toBe(200);
|
||||
expect(await hp(one.id)).toBe(Math.max(0, hpOne - 10));
|
||||
expect(await hp(two.id)).toBe(0); // 7 HP, 20 damage
|
||||
const slotsAfter = (await request(app).get(`/characters/${mageId}`).set(dmAuth)).body.character.sheet.spellSlots.levels.find((l) => l.level === 3).remaining;
|
||||
expect(slotsAfter).toBe(slotsBefore - 1);
|
||||
});
|
||||
|
||||
itIfDb('with auto-roll on, a monster\u2019s turn opens with its attack aimed and rolled, kept from the players until the DM accepts it', async () => {
|
||||
const { dmAuth, playerAuth, e, hero, mage, one, view } = await battle('actauto', { autoRollMonsters: true });
|
||||
// Aria, then Mira, then Goblin 1.
|
||||
await request(app).post(`${e}/next-turn`).set(dmAuth);
|
||||
expect((await view(dmAuth)).actions).toEqual([]); // a character's turn: nothing declared for them
|
||||
const turn = (await request(app).post(`${e}/next-turn`).set(dmAuth)).body.encounter;
|
||||
expect(turn.activeParticipantId).toBe(one.id);
|
||||
expect(turn.actions).toHaveLength(1);
|
||||
const action = turn.actions[0];
|
||||
expect(action).toMatchObject({ actorName: 'Goblin 1', kind: 'ATTACK', auto: true, hidden: true, needsRolls: 0 });
|
||||
// It goes for someone on the other side, with the d20 already rolled, and damage where it hits.
|
||||
expect([hero.id, mage.id]).toContain(action.targets[0].participantId);
|
||||
expect(action.targets[0].d20).toBeGreaterThanOrEqual(1);
|
||||
expect(action.targets[0].total).toBe(action.targets[0].d20 + 4); // a goblin's +4
|
||||
if (['HIT', 'CRIT'].includes(action.targets[0].outcome)) expect(action.damage.total).toBeGreaterThan(0);
|
||||
expect(action.ready).toBe(true);
|
||||
// The players see nothing of it yet.
|
||||
expect((await view(playerAuth)).actions).toEqual([]);
|
||||
|
||||
// The DM changes its mind about the target: the same d20, against the other's AC.
|
||||
const other = action.targets[0].participantId === hero.id ? mage.id : hero.id;
|
||||
const moved = (await request(app).patch(`${e}/actions/${action.id}`).set(dmAuth).send({ targetIds: [other] })).body.encounter.actions[0];
|
||||
expect(moved.targets[0]).toMatchObject({ participantId: other, d20: action.targets[0].d20 });
|
||||
|
||||
// The DM makes sure it hits for 3, and accepts: the damage lands and everyone sees what happened.
|
||||
await request(app).patch(`${e}/actions/${action.id}`).set(dmAuth).send({ outcomes: [{ participantId: other, outcome: 'HIT' }], targetDamage: [{ participantId: other, damage: 3 }] });
|
||||
const before = (await view(dmAuth)).participants.find((p) => p.id === other).currentHp;
|
||||
const applied = (await request(app).post(`${e}/actions/${action.id}/apply`).set(dmAuth).send({})).body.encounter;
|
||||
expect(applied.participants.find((p) => p.id === other).currentHp).toBe(before - 3);
|
||||
expect((await view(playerAuth)).recentActions[0]).toMatchObject({ actorName: 'Goblin 1', status: 'APPLIED', hidden: false });
|
||||
|
||||
// Passing the turn clears anything left unresolved, and the next goblin declares its own.
|
||||
const next = (await request(app).post(`${e}/next-turn`).set(dmAuth)).body.encounter;
|
||||
expect(next.actions.every((a) => a.actorName === 'Goblin 2')).toBe(true);
|
||||
});
|
||||
|
||||
itIfDb('without the setting nothing is declared for a monster, and the DM can spell out an attack of their own', async () => {
|
||||
const { dmAuth, e, hero, one, view } = await battle('actmanual');
|
||||
await request(app).post(`${e}/next-turn`).set(dmAuth);
|
||||
const turn = (await request(app).post(`${e}/next-turn`).set(dmAuth)).body.encounter;
|
||||
expect(turn.activeParticipantId).toBe(one.id);
|
||||
expect(turn.actions).toEqual([]);
|
||||
|
||||
// The goblin's own attack, aimed by hand.
|
||||
const basic = await request(app).post(`${e}/actions`).set(dmAuth).send({ source: { type: 'basic' }, targetIds: [hero.id] });
|
||||
expect(basic.status).toBe(201);
|
||||
expect((await view(dmAuth)).actions[0]).toMatchObject({ kind: 'ATTACK', actorName: 'Goblin 1', detail: { attackBonus: 4 } });
|
||||
|
||||
// A trap, spelled out: Dexterity save DC 13, 2d6, half on a success. It replaces the unfinished attack.
|
||||
const trap = await request(app)
|
||||
.post(`${e}/actions`)
|
||||
.set(dmAuth)
|
||||
.send({ source: { type: 'custom', name: 'Dart trap', kind: 'SAVE', saveAbility: 'DEX', saveDC: 13, halfOnSave: true, damageDice: '2d6' }, targetIds: [hero.id] });
|
||||
expect(trap.status).toBe(201);
|
||||
const actions = (await view(dmAuth)).actions;
|
||||
expect(actions).toHaveLength(1);
|
||||
expect(actions[0]).toMatchObject({ kind: 'SAVE', name: 'Dart trap', detail: { saveDC: 13 } });
|
||||
const rolled = (await request(app).patch(`${e}/actions/${actions[0].id}`).set(dmAuth).send({ rollRemaining: true, rollDamage: true })).body.encounter.actions[0];
|
||||
expect(rolled.ready).toBe(true);
|
||||
expect(rolled.damage.dice).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -644,6 +645,11 @@ const spellSchema = z.object({
|
||||
damageDice: z.string().max(20).optional().nullable(),
|
||||
isStartingOption: z.boolean().optional(),
|
||||
revives: z.boolean().optional(), // brings back the dead
|
||||
// How it lands in a battle: a spell attack against AC, or a save by each
|
||||
// target against the caster's DC (half damage on a success when halfOnSave).
|
||||
attackRoll: z.boolean().optional(),
|
||||
saveAbility: z.enum(['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA']).optional().nullable(),
|
||||
halfOnSave: z.boolean().optional(),
|
||||
});
|
||||
|
||||
router.post('/:id/spells', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
@@ -771,6 +777,20 @@ const monsterSchema = z.object({
|
||||
challengeRating: z.string().max(10).optional().nullable(),
|
||||
specialTrait: z.string().max(500).optional().nullable(),
|
||||
notes: z.string().max(2000).optional().nullable(),
|
||||
// Named attacks, for a monster with more than the one above. `count` is how
|
||||
// often it is made each turn (a multiattack).
|
||||
attacks: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().trim().min(1).max(40),
|
||||
attackBonus: z.number().int().min(-20).max(50),
|
||||
damageDice: z.string().max(60).optional().nullable(),
|
||||
damageType: z.string().max(30).optional().nullable(),
|
||||
count: z.number().int().min(1).max(10).optional(),
|
||||
})
|
||||
)
|
||||
.max(8)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
router.post('/:id/monsters', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
@@ -1243,4 +1263,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;
|
||||
|
||||
@@ -453,7 +453,8 @@ router.post('/:id/spell-slots', requireCharacterAccess({ write: true }), async (
|
||||
if (input.action !== 'use' && !req.isDM) {
|
||||
return res.status(403).json({ error: 'Only the DM can give a spell slot back.' });
|
||||
}
|
||||
const slots = spellSlotState(character.characterClass, character.level, character.spellSlotsUsed);
|
||||
// By the sheet, so that a casting subclass and a multiclass count.
|
||||
const slots = sheetOf(await loadFullCharacter(character.id)).spellSlots;
|
||||
if (!slots) return res.status(400).json({ error: `${character.name} has no spell slots.` });
|
||||
|
||||
// The stored tally, squared up with what the class has at this level.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const crypto = require('crypto');
|
||||
const express = require('express');
|
||||
const { z } = require('zod');
|
||||
const { Prisma } = require('@prisma/client');
|
||||
const prisma = require('../db');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { loadCampaignMembership } = require('../middleware/campaignAccess');
|
||||
@@ -10,6 +11,9 @@ const { moveCharacters, visitedLocationIds } = require('../utils/locations');
|
||||
const { SHEET_INCLUDE } = require('../utils/sheet');
|
||||
const { NO_DEATH_SAVES, lifeState, deathSaveInfo, changeHp } = require('../utils/hp');
|
||||
const { subclassName } = require('../game/data/subclasses');
|
||||
const { settle, rollDamage, rollCritDamage } = require('../game/combat');
|
||||
const { rollDie } = require('../game/dice');
|
||||
const { ActionError, targetRows, describeSource, autoActions } = require('../utils/actions');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -184,6 +188,10 @@ function characterCombatInfo(character, subclasses = true) {
|
||||
description: cs.spell.description,
|
||||
damageDice: cs.spell.damageDice,
|
||||
prepared: cs.prepared,
|
||||
// How it lands: a spell attack, a save by each target, or neither.
|
||||
attackRoll: cs.spell.attackRoll,
|
||||
saveAbility: cs.spell.saveAbility,
|
||||
halfOnSave: cs.spell.halfOnSave,
|
||||
}));
|
||||
return {
|
||||
level: character.level,
|
||||
@@ -305,6 +313,30 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
|
||||
return out;
|
||||
});
|
||||
|
||||
// Attacks and spells in play: those still being resolved, and the last few
|
||||
// that landed. One the DM is keeping to themselves (a monster's auto-rolled
|
||||
// attack) is theirs alone until it is applied.
|
||||
const actionRows = await prisma.encounterAction.findMany({
|
||||
where: { encounterId, status: { in: ['PENDING', 'APPLIED'] } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 30,
|
||||
});
|
||||
const showAction = (action) => {
|
||||
const settled = settle(action);
|
||||
// A player is not told a monster's or NPC's AC.
|
||||
const targets = isDM ? settled.targets : settled.targets.map((t) => (t.kind === 'CHARACTER' ? t : { ...t, ac: null }));
|
||||
const { encounterId: _encounterId, createdById, ...rest } = settled;
|
||||
return { ...rest, targets };
|
||||
};
|
||||
const actions = actionRows
|
||||
.filter((a) => a.status === 'PENDING' && (isDM || !a.hidden))
|
||||
.reverse()
|
||||
.map(showAction);
|
||||
const recentActions = actionRows
|
||||
.filter((a) => a.status === 'APPLIED')
|
||||
.slice(0, 6)
|
||||
.map(showAction);
|
||||
|
||||
const { dmNotes, rewards, campaign, location, ...scene } = encounter;
|
||||
const knowsPlace = isDM || (location && (await visitedLocationIds(prisma, viewerId, encounter.campaignId)).has(location.id));
|
||||
return {
|
||||
@@ -316,6 +348,8 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
|
||||
targetParticipantIds: isDM ? scene.targetParticipantIds : scene.targetParticipantIds.filter((id) => !hiddenIds.has(id)),
|
||||
participants,
|
||||
fallen,
|
||||
actions,
|
||||
recentActions,
|
||||
rewards: isDM ? rewards : rewards.filter((r) => r.awardedAt),
|
||||
};
|
||||
}
|
||||
@@ -392,7 +426,8 @@ router.patch('/:id', requireEncounterAccess, requireDM, async (req, res, next) =
|
||||
// Targets belong to the moment; beginning, ending or reopening clears them.
|
||||
if (data.status && data.status !== req.encounter.status) data.targetParticipantIds = [];
|
||||
|
||||
await prisma.encounter.update({ where: { id: req.encounter.id }, data });
|
||||
const updated = await prisma.encounter.update({ where: { id: req.encounter.id }, data });
|
||||
if (data.status === 'ACTIVE' && req.encounter.status !== 'ACTIVE') await declareAuto(req, updated);
|
||||
await respondWithEncounter(req, res);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -741,7 +776,7 @@ router.post('/:id/next-turn', requireEncounterAccess, requireDM, async (req, res
|
||||
const index = order.findIndex((p) => p.id === req.encounter.activeParticipantId);
|
||||
const upNext = standingFrom(order, index + 1);
|
||||
const wrapped = index !== -1 && order.indexOf(upNext) <= index;
|
||||
await prisma.encounter.update({
|
||||
const updated = await prisma.encounter.update({
|
||||
where: { id: req.encounter.id },
|
||||
data: {
|
||||
status: 'ACTIVE',
|
||||
@@ -750,6 +785,7 @@ router.post('/:id/next-turn', requireEncounterAccess, requireDM, async (req, res
|
||||
...(wrapped ? { currentRound: { increment: 1 } } : {}),
|
||||
},
|
||||
});
|
||||
await declareAuto(req, updated);
|
||||
// What wore off as the new round began, for the DM to read out.
|
||||
const expired = wrapped ? await tickConditions(req) : [];
|
||||
await respondWithEncounter(req, res, 200, { expired });
|
||||
@@ -772,6 +808,7 @@ router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req,
|
||||
},
|
||||
});
|
||||
const expired = await tickConditions(req);
|
||||
await declareAuto(req, updated);
|
||||
await syncCharacterLocations(req);
|
||||
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('encounter:round-advanced', {
|
||||
encounterId: updated.id,
|
||||
@@ -783,6 +820,329 @@ router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req,
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions — an attack or a spell, from being declared to its damage landing
|
||||
// (game/combat.js, utils/actions.js). A player declares their own character's
|
||||
// on their turn and rolls for it; the DM declares anyone's, enters or
|
||||
// overrules any roll, and is the one who applies it. With the campaign's
|
||||
// auto-roll settings a monster's or NPC's turn begins with its attack already
|
||||
// declared and rolled (declareAuto), for the DM to accept, change or discard.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Everything an action needs of the scene's combatants.
|
||||
const COMBATANTS = {
|
||||
character: { include: { ...SHEET_INCLUDE, spells: { include: { spell: true } } } },
|
||||
monster: true,
|
||||
npc: true,
|
||||
};
|
||||
|
||||
// What is left of the turn before: nothing. A turn passing discards whatever
|
||||
// was still being resolved, and then, where the campaign says so, the
|
||||
// combatant now on turn (a monster, or an NPC) declares its own attacks.
|
||||
async function declareAuto(req, encounter) {
|
||||
await prisma.encounterAction.updateMany({ where: { encounterId: encounter.id, status: 'PENDING' }, data: { status: 'DISCARDED' } });
|
||||
if (encounter.mode !== 'BATTLE' || encounter.status !== 'ACTIVE' || !encounter.activeParticipantId) return;
|
||||
const { autoRollMonsters, autoRollNpcs, areaAttackAcPenalty, critRule, hideMonsterRolls } = req.campaign;
|
||||
if (!autoRollMonsters && !autoRollNpcs) return;
|
||||
|
||||
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: encounter.id }, include: COMBATANTS });
|
||||
const actor = participants.find((p) => p.id === encounter.activeParticipantId);
|
||||
if (!actor || actor.character) return;
|
||||
if (actor.npc ? !autoRollNpcs : !autoRollMonsters) return;
|
||||
|
||||
const history = await prisma.encounterAction.findMany({ where: { encounterId: encounter.id, status: 'APPLIED' }, orderBy: { appliedAt: 'desc' }, take: 40 });
|
||||
const declared = autoActions(actor, participants, { penaltyStep: areaAttackAcPenalty, critRule, history });
|
||||
for (const action of declared) {
|
||||
await prisma.encounterAction.create({ data: { ...action, encounterId: encounter.id, round: encounter.currentRound, hidden: hideMonsterRolls, createdById: null } });
|
||||
}
|
||||
}
|
||||
|
||||
const abilityEnum = z.enum(['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA']);
|
||||
|
||||
const actionSourceSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('weapon'), characterWeaponId: z.string() }),
|
||||
z.object({ type: z.literal('spell'), characterSpellId: z.string(), slotLevel: z.number().int().min(1).max(9).optional() }),
|
||||
z.object({ type: z.literal('basic'), index: z.number().int().min(0).max(20).optional() }),
|
||||
z.object({
|
||||
type: z.literal('custom'),
|
||||
name: z.string().trim().min(1).max(60),
|
||||
kind: z.enum(['ATTACK', 'SAVE', 'AUTO']),
|
||||
attackBonus: z.number().int().min(-20).max(50).optional(),
|
||||
saveAbility: abilityEnum.optional(),
|
||||
saveDC: z.number().int().min(1).max(40).optional(),
|
||||
halfOnSave: z.boolean().optional(),
|
||||
damageDice: z.string().max(60).optional().nullable(),
|
||||
damageType: z.string().max(30).optional().nullable(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const createActionSchema = z.object({
|
||||
// Who is acting; whoever is on turn when left out.
|
||||
actorParticipantId: z.string().optional(),
|
||||
source: actionSourceSchema,
|
||||
// Who it is aimed at; the targets marked in the encounter when left out.
|
||||
targetIds: z.array(z.string()).max(50).optional(),
|
||||
hidden: z.boolean().optional(), // the DM's: keep it from the players until applied
|
||||
});
|
||||
|
||||
// Whether the requester's own character is this participant.
|
||||
const ownsParticipant = (req, p) => Boolean(p && p.character && p.character.userId === req.user.id);
|
||||
|
||||
router.post('/:id/actions', requireEncounterAccess, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = createActionSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { source } = parsed.data;
|
||||
if (req.encounter.status !== 'ACTIVE') return res.status(400).json({ error: 'Begin the encounter before anyone attacks.' });
|
||||
|
||||
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id }, include: COMBATANTS });
|
||||
const actorId = parsed.data.actorParticipantId || req.encounter.activeParticipantId;
|
||||
const actor = participants.find((p) => p.id === actorId) || null;
|
||||
if (!req.isDM) {
|
||||
// A player acts for their own character, on its turn, with what it has.
|
||||
if (!ownsParticipant(req, actor) || actor.id !== req.encounter.activeParticipantId) {
|
||||
return res.status(403).json({ error: "You can only attack on your own character's turn." });
|
||||
}
|
||||
if (source.type === 'custom' || source.type === 'basic') return res.status(403).json({ error: 'Pick one of your weapons or spells.' });
|
||||
}
|
||||
|
||||
const targetIds = [...new Set(parsed.data.targetIds || req.encounter.targetParticipantIds)];
|
||||
const targets = targetIds.map((id) => participants.find((p) => p.id === id));
|
||||
if (targets.length === 0) return res.status(400).json({ error: 'Pick a target first.' });
|
||||
if (targets.some((p) => !p)) return res.status(400).json({ error: 'Combatant not found in this encounter' });
|
||||
|
||||
let described;
|
||||
try {
|
||||
described = describeSource(actor, source);
|
||||
} catch (err) {
|
||||
if (!(err instanceof ActionError)) throw err;
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
const { kind, name, detail } = described;
|
||||
|
||||
// One thing at a time: a new action takes the place of the actor's unfinished one.
|
||||
await prisma.encounterAction.updateMany({
|
||||
where: { encounterId: req.encounter.id, status: 'PENDING', actorParticipantId: actor ? actor.id : null },
|
||||
data: { status: 'DISCARDED' },
|
||||
});
|
||||
const action = await prisma.encounterAction.create({
|
||||
data: {
|
||||
encounterId: req.encounter.id,
|
||||
round: req.encounter.currentRound,
|
||||
actorParticipantId: actor ? actor.id : null,
|
||||
actorName: actor ? actor.displayName : 'The DM',
|
||||
kind,
|
||||
name,
|
||||
detail,
|
||||
targets: targetRows(kind, detail, targets, req.campaign.areaAttackAcPenalty),
|
||||
hidden: req.isDM && Boolean(parsed.data.hidden),
|
||||
createdById: req.user.id,
|
||||
},
|
||||
});
|
||||
await respondWithEncounter(req, res, 201, { actionId: action.id });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const d20Schema = z.number().int().min(1, 'A d20 shows 1 to 20').max(20, 'A d20 shows 1 to 20');
|
||||
|
||||
const patchActionSchema = z.object({
|
||||
// A d20 typed in for a target: the attack roll against them, or their saving throw. null clears it.
|
||||
rolls: z.array(z.object({ participantId: z.string(), d20: d20Schema.nullable() })).max(50).optional(),
|
||||
// Have the server roll every d20 still missing that the requester may roll.
|
||||
rollRemaining: z.boolean().optional(),
|
||||
// The DM overruling the dice for a target; null goes back to what was rolled.
|
||||
outcomes: z.array(z.object({ participantId: z.string(), outcome: z.enum(['HIT', 'MISS', 'CRIT', 'SAVED', 'FAILED']).nullable() })).max(50).optional(),
|
||||
// The damage, typed in (dice rolled at the table), or rolled by the server.
|
||||
damage: z.number().int().min(0).max(100000).nullable().optional(),
|
||||
rollDamage: z.boolean().optional(),
|
||||
// The DM setting what one target takes; null goes back to what the action deals.
|
||||
targetDamage: z.array(z.object({ participantId: z.string(), damage: z.number().int().min(0).max(100000).nullable() })).max(50).optional(),
|
||||
// The DM aiming it at someone else: rolls already made stay with their place in the list.
|
||||
targetIds: z.array(z.string()).min(1).max(50).optional(),
|
||||
hidden: z.boolean().optional(),
|
||||
discard: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// Loads a pending action of this encounter with its actor, or answers and returns null.
|
||||
async function loadAction(req, res) {
|
||||
const action = await prisma.encounterAction.findUnique({ where: { id: req.params.actionId } });
|
||||
if (!action || action.encounterId !== req.encounter.id) {
|
||||
res.status(404).json({ error: 'That attack is not part of this encounter.' });
|
||||
return null;
|
||||
}
|
||||
if (action.status !== 'PENDING') {
|
||||
res.status(400).json({ error: action.status === 'APPLIED' ? 'That attack has already landed.' : 'That attack was discarded.' });
|
||||
return null;
|
||||
}
|
||||
if (!req.isDM && action.hidden) {
|
||||
res.status(404).json({ error: 'That attack is not part of this encounter.' });
|
||||
return null;
|
||||
}
|
||||
return action;
|
||||
}
|
||||
|
||||
router.patch('/:id/actions/:actionId', requireEncounterAccess, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = patchActionSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const action = await loadAction(req, res);
|
||||
if (!action) return;
|
||||
const input = parsed.data;
|
||||
|
||||
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id }, include: COMBATANTS });
|
||||
const actor = participants.find((p) => p.id === action.actorParticipantId) || null;
|
||||
const actsForActor = req.isDM || ownsParticipant(req, actor);
|
||||
// Who may put in the d20 for a target: for an attack, whoever is
|
||||
// attacking; for a save, the target's own player. The DM, always.
|
||||
const mayRollFor = (target) =>
|
||||
req.isDM || (action.kind === 'ATTACK' ? actsForActor : ownsParticipant(req, participants.find((p) => p.id === target.participantId)));
|
||||
// A player follows the campaign's dice; the DM enters or rolls as they like.
|
||||
const { diceMode } = req.campaign;
|
||||
const mayType = req.isDM || diceMode !== 'DIGITAL';
|
||||
const mayAsk = req.isDM || diceMode !== 'PHYSICAL';
|
||||
|
||||
if (input.discard) {
|
||||
if (!actsForActor) return res.status(403).json({ error: 'Only the DM or whoever declared it can call it off.' });
|
||||
await prisma.encounterAction.update({ where: { id: action.id }, data: { status: 'DISCARDED' } });
|
||||
return await respondWithEncounter(req, res);
|
||||
}
|
||||
const dmOnly = input.outcomes || input.targetDamage || input.targetIds || input.hidden !== undefined;
|
||||
if (dmOnly && !req.isDM) return res.status(403).json({ error: 'Only the DM overrules a roll, sets damage by hand or changes the targets.' });
|
||||
|
||||
let targets = action.targets.map((t) => ({ ...t }));
|
||||
let damage = action.damage;
|
||||
const data = {};
|
||||
|
||||
if (input.targetIds) {
|
||||
const next = [...new Set(input.targetIds)].map((id) => participants.find((p) => p.id === id));
|
||||
if (next.some((p) => !p)) return res.status(400).json({ error: 'Combatant not found in this encounter' });
|
||||
const fresh = targetRows(action.kind, action.detail, next, req.campaign.areaAttackAcPenalty);
|
||||
// A target kept keeps its roll; a new one in an old one's place takes the
|
||||
// roll already made (the die was cast before the aim was changed).
|
||||
targets = fresh.map((row, i) => {
|
||||
const kept = targets.find((t) => t.participantId === row.participantId);
|
||||
const d20 = kept ? kept.d20 : targets[i] && !next.some((p) => p.id === targets[i].participantId) ? targets[i].d20 : null;
|
||||
return { ...row, d20: action.kind === 'SAVE' && !kept ? null : d20 };
|
||||
});
|
||||
}
|
||||
|
||||
for (const { participantId, d20 } of input.rolls || []) {
|
||||
const target = targets.find((t) => t.participantId === participantId);
|
||||
if (!target) return res.status(400).json({ error: 'That is not one of its targets.' });
|
||||
if (!mayRollFor(target)) return res.status(403).json({ error: action.kind === 'SAVE' ? 'You can only enter the save for your own character.' : 'Only the attacker (or the DM) enters the attack roll.' });
|
||||
if (!mayType) return res.status(400).json({ error: 'This campaign plays with digital dice: the app rolls.' });
|
||||
target.d20 = d20;
|
||||
}
|
||||
if (input.rollRemaining) {
|
||||
if (!mayAsk) return res.status(400).json({ error: 'This campaign plays with physical dice: roll at the table and enter what the d20 shows.' });
|
||||
for (const target of targets) if (target.d20 == null && action.kind !== 'AUTO' && mayRollFor(target)) target.d20 = rollDie(20);
|
||||
}
|
||||
for (const { participantId, outcome } of input.outcomes || []) {
|
||||
const target = targets.find((t) => t.participantId === participantId);
|
||||
if (!target) return res.status(400).json({ error: 'That is not one of its targets.' });
|
||||
const allowed = action.kind === 'SAVE' ? ['SAVED', 'FAILED'] : ['HIT', 'MISS', 'CRIT'];
|
||||
if (outcome !== null && !allowed.includes(outcome)) return res.status(400).json({ error: `For this it is ${allowed.join(', ')}.` });
|
||||
target.outcome = outcome;
|
||||
}
|
||||
for (const { participantId, damage: amount } of input.targetDamage || []) {
|
||||
const target = targets.find((t) => t.participantId === participantId);
|
||||
if (!target) return res.status(400).json({ error: 'That is not one of its targets.' });
|
||||
target.damage = amount;
|
||||
}
|
||||
|
||||
if (input.damage !== undefined) {
|
||||
if (!actsForActor) return res.status(403).json({ error: 'Only the attacker (or the DM) enters the damage.' });
|
||||
if (!mayType) return res.status(400).json({ error: 'This campaign plays with digital dice: the app rolls.' });
|
||||
damage = input.damage === null ? null : { total: input.damage, entered: true };
|
||||
}
|
||||
const settledNow = () => settle({ ...action, targets, damage });
|
||||
if (input.rollDamage) {
|
||||
if (!actsForActor) return res.status(403).json({ error: 'Only the attacker (or the DM) rolls the damage.' });
|
||||
if (!mayAsk) return res.status(400).json({ error: 'This campaign plays with physical dice: roll the damage at the table and enter the total.' });
|
||||
const critical = settledNow().targets.some((t) => t.outcome === 'CRIT');
|
||||
damage = rollDamage(action.detail, { critical, critRule: req.campaign.critRule });
|
||||
if (!damage) return res.status(400).json({ error: 'It has no damage dice to roll. Enter the damage instead.' });
|
||||
}
|
||||
// A critical hit that turns up after the damage was rolled gets its dice.
|
||||
if (damage && !damage.entered && !damage.crit && settledNow().needsCrit) {
|
||||
damage = { ...damage, crit: rollCritDamage(action.detail, { critRule: req.campaign.critRule }) };
|
||||
}
|
||||
|
||||
if (input.hidden !== undefined) data.hidden = input.hidden;
|
||||
await prisma.encounterAction.update({ where: { id: action.id }, data: { ...data, targets, damage: damage === null ? Prisma.DbNull : damage } });
|
||||
await respondWithEncounter(req, res);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// The DM lets it land: every target takes what the action comes to for them
|
||||
// (temp HP first, death saves at 0, as with any damage), a spell takes its
|
||||
// slot, and the action is done and shown to everyone. Answers with a line for
|
||||
// anyone hurt while concentrating, for the DM to call the save.
|
||||
router.post('/:id/actions/:actionId/apply', requireEncounterAccess, requireDM, async (req, res, next) => {
|
||||
try {
|
||||
const action = await loadAction(req, res);
|
||||
if (!action) return;
|
||||
const settled = settle(action);
|
||||
if (!settled.ready) {
|
||||
return res.status(400).json({
|
||||
error: settled.needsRolls > 0 ? `Still waiting on ${settled.needsRolls} d20${settled.needsRolls === 1 ? '' : 's'}.` : 'The damage has not been rolled or entered yet.',
|
||||
});
|
||||
}
|
||||
|
||||
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id }, include: COMBATANTS });
|
||||
const concentration = [];
|
||||
for (const target of settled.targets) {
|
||||
const p = participants.find((x) => x.id === target.participantId);
|
||||
const amount = target.finalDamage || 0;
|
||||
if (!p || amount <= 0) continue;
|
||||
const conditions = p.character ? p.character.conditions : p.conditions;
|
||||
if (conditions.includes('Concentrating')) concentration.push({ name: p.displayName, damage: amount, dc: Math.max(10, Math.floor(amount / 2)) });
|
||||
if (p.character) {
|
||||
if (lifeState(p.character) === 'DEAD') continue;
|
||||
const maxHp = computeCharacterSheet(p.character, p.character.skillProficiencies, p.character.saveProficiencies).hp.max;
|
||||
const after = changeHp(p.character, p.character.currentHp - amount, maxHp);
|
||||
await prisma.character.update({ where: { id: p.character.id }, data: after });
|
||||
await prisma.encounterParticipant.update({ where: { id: p.id }, data: { currentHp: after.currentHp, maxHp } });
|
||||
emitCharacterUpdated(req, p.character.id);
|
||||
} else if (p.maxHp > 0) {
|
||||
await prisma.encounterParticipant.update({ where: { id: p.id }, data: { currentHp: clamp(p.currentHp - amount, 0, p.maxHp) } });
|
||||
}
|
||||
}
|
||||
|
||||
// A spell takes a slot of the level it was cast at, when the caster has one left.
|
||||
let slotNote = null;
|
||||
const { characterId, slotLevel } = action.detail;
|
||||
const caster = slotLevel ? participants.map((p) => p.character).find((c) => c && c.id === characterId) : null;
|
||||
if (caster) {
|
||||
const slots = computeCharacterSheet(caster, caster.skillProficiencies, caster.saveProficiencies).spellSlots;
|
||||
const slot = slots ? slots.levels.find((l) => l.level === slotLevel) : null;
|
||||
if (slot && slot.remaining > 0) {
|
||||
const used = Array.from({ length: slots.levels[slots.levels.length - 1].level }, (_, i) => {
|
||||
const at = slots.levels.find((l) => l.level === i + 1);
|
||||
return at ? at.used + (at.level === slotLevel ? 1 : 0) : 0;
|
||||
});
|
||||
await prisma.character.update({ where: { id: caster.id }, data: { spellSlotsUsed: used } });
|
||||
emitCharacterUpdated(req, caster.id);
|
||||
} else {
|
||||
slotNote = `${caster.name} had no level ${slotLevel} spell slot left for ${action.name}: nothing was spent.`;
|
||||
}
|
||||
}
|
||||
|
||||
// Kept as it landed, so later turns know who hurt whom.
|
||||
await prisma.encounterAction.update({
|
||||
where: { id: action.id },
|
||||
data: { status: 'APPLIED', appliedAt: new Date(), hidden: false, targets: settled.targets },
|
||||
});
|
||||
await respondWithEncounter(req, res, 200, { concentration, slotNote });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Experience — awarded once, shared evenly between the characters in the scene
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -75,6 +75,10 @@ const POOLS = {
|
||||
description: optionalText(4000),
|
||||
isStartingOption: z.boolean().optional(),
|
||||
revives: z.boolean().optional(),
|
||||
// How it lands in a battle: a spell attack, or a save by each target.
|
||||
attackRoll: z.boolean().optional(),
|
||||
saveAbility: z.enum(['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA']).nullish(),
|
||||
halfOnSave: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
monsters: {
|
||||
@@ -95,6 +99,19 @@ const POOLS = {
|
||||
challengeRating: optionalText(10),
|
||||
specialTrait: optionalText(500),
|
||||
notes: optionalText(2000),
|
||||
// Named attacks, for more than the one above; `count` makes it a multiattack.
|
||||
attacks: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
name: text(40),
|
||||
attackBonus: z.number().int().min(-10).max(30),
|
||||
damageDice: damage,
|
||||
damageType: optionalText(30),
|
||||
count: z.number().int().min(1).max(10).optional(),
|
||||
})
|
||||
)
|
||||
.max(8)
|
||||
.optional(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
// Battle actions against the database's shapes: reading a combatant's numbers
|
||||
// off a loaded participant, and putting together the action a weapon, a spell
|
||||
// or a creature's attack makes (EncounterAction). The rules themselves are in
|
||||
// game/combat.js. No queries here: everything is passed in loaded.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { computeCharacterSheet, computeWeaponStats, abilityModifier, canUseGear } = require('../game/calculations');
|
||||
const { attacksOf, opponentsOf, pickTarget, rollDamage, attackOutcome, lands } = require('../game/combat');
|
||||
const { rollDie } = require('../game/dice');
|
||||
const { lifeState } = require('./hp');
|
||||
|
||||
// A problem with what was asked for: the message is for whoever asked.
|
||||
class ActionError extends Error {}
|
||||
|
||||
const sheetOf = (character) => computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
|
||||
|
||||
const kindOf = (p) => (p.npc || p.npcId ? 'NPC' : p.isMonster ? 'MONSTER' : 'CHARACTER');
|
||||
|
||||
// Out of the fight: a dead character, or a monster or NPC at 0 HP.
|
||||
function isFallen(p) {
|
||||
if (p.character) return lifeState(p.character) === 'DEAD';
|
||||
return p.maxHp > 0 && p.currentHp <= 0;
|
||||
}
|
||||
|
||||
// A participant's AC: the sheet's for a character, the stat block's for a
|
||||
// monster or NPC (10 for an NPC with no combat stats).
|
||||
function armorClassOf(p) {
|
||||
if (p.character) return sheetOf(p.character).armorClass;
|
||||
if (p.monster) return p.monster.ac;
|
||||
return p.npc && p.npc.ac != null ? p.npc.ac : 10;
|
||||
}
|
||||
|
||||
// What a participant adds to a saving throw with an ability: the sheet's save
|
||||
// bonus for a character, the ability modifier for a monster, nothing for an NPC.
|
||||
function saveBonusOf(p, ability) {
|
||||
if (p.character) return sheetOf(p.character).saves[ability].bonus;
|
||||
if (p.monster) return abilityModifier(p.monster[`${ability.toLowerCase()}Score`] ?? 10);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// A participant as game/combat.js sees a combatant.
|
||||
const combatantOf = (p) => ({ id: p.id, name: p.displayName, kind: kindOf(p), disposition: p.npc ? p.npc.disposition : null, ac: armorClassOf(p), fallen: isFallen(p) });
|
||||
|
||||
const areaPenalty = (targetCount, step) => Math.max(0, targetCount - 1) * step;
|
||||
|
||||
// The targets of an action as it starts: who, and the number each roll is
|
||||
// against. `penaltyStep` is the campaign's area-attack AC penalty.
|
||||
function targetRows(kind, detail, targets, penaltyStep) {
|
||||
const penalty = kind === 'ATTACK' ? areaPenalty(targets.length, penaltyStep) : 0;
|
||||
return targets.map((p) => ({
|
||||
participantId: p.id,
|
||||
name: p.displayName,
|
||||
kind: kindOf(p),
|
||||
ac: kind === 'ATTACK' ? armorClassOf(p) : null,
|
||||
penalty,
|
||||
saveBonus: kind === 'SAVE' ? saveBonusOf(p, detail.saveAbility) : null,
|
||||
d20: null,
|
||||
outcome: null,
|
||||
damage: null,
|
||||
}));
|
||||
}
|
||||
|
||||
// What an action is made of, from what it is made with:
|
||||
// { type: 'weapon', characterWeaponId } a character's equipped weapon
|
||||
// { type: 'spell', characterSpellId, slotLevel? } a spell a character knows
|
||||
// { type: 'basic', index? } a monster's or NPC's own attack
|
||||
// { type: 'custom', name, kind, attackBonus?, saveAbility?, saveDC?, halfOnSave?, damageDice? }
|
||||
// anything else, spelled out (the DM's: a breath weapon, a trap)
|
||||
// Returns { kind, name, detail }. Throws ActionError when the actor can't.
|
||||
function describeSource(actor, source) {
|
||||
const character = actor ? actor.character : null;
|
||||
if (source.type === 'custom') {
|
||||
const kind = source.kind || 'ATTACK';
|
||||
return {
|
||||
kind,
|
||||
name: source.name || 'Attack',
|
||||
detail: {
|
||||
attackBonus: kind === 'ATTACK' ? source.attackBonus || 0 : undefined,
|
||||
saveAbility: kind === 'SAVE' ? source.saveAbility || 'DEX' : undefined,
|
||||
saveDC: kind === 'SAVE' ? source.saveDC || 10 : undefined,
|
||||
halfOnSave: kind === 'SAVE' ? Boolean(source.halfOnSave) : undefined,
|
||||
damageDice: source.damageDice || null,
|
||||
damageBonus: 0,
|
||||
damageType: source.damageType || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (!actor) throw new ActionError('Nobody is on turn to make that attack.');
|
||||
|
||||
if (source.type === 'basic') {
|
||||
const attacks = attacksOf({ monster: actor.monster, npc: actor.npc });
|
||||
const attack = attacks[source.index || 0];
|
||||
if (!attack) throw new ActionError(`${actor.displayName} has no attack to make. Give it an attack bonus and damage first.`);
|
||||
return { kind: 'ATTACK', name: attack.name, detail: { attackBonus: attack.attackBonus, damageDice: attack.damageDice, damageBonus: 0, damageType: attack.damageType } };
|
||||
}
|
||||
|
||||
if (!character) throw new ActionError(`${actor.displayName} fights with its own attack, not a weapon or a spell.`);
|
||||
const sheet = sheetOf(character);
|
||||
|
||||
if (source.type === 'weapon') {
|
||||
const held = character.weapons.find((cw) => cw.id === source.characterWeaponId);
|
||||
if (!held) throw new ActionError(`${character.name} does not hold that weapon.`);
|
||||
if (!held.equipped || !canUseGear(character.level, held.level)) throw new ActionError(`${held.weapon.name} has to be equipped (and within ${character.name}'s level) to be used.`);
|
||||
const stats = computeWeaponStats(held.weapon, sheet.abilities, sheet.proficiencyBonus, true, held.level);
|
||||
return {
|
||||
kind: 'ATTACK',
|
||||
name: held.weapon.name,
|
||||
detail: { attackBonus: stats.attackBonus, damageDice: stats.damageDice, damageBonus: stats.damageBonus, damageType: stats.damageType, characterId: character.id },
|
||||
};
|
||||
}
|
||||
|
||||
if (source.type === 'spell') {
|
||||
const known = character.spells.find((cs) => cs.id === source.characterSpellId);
|
||||
if (!known) throw new ActionError(`${character.name} does not know that spell.`);
|
||||
const spell = known.spell;
|
||||
if (!canUseGear(character.level, spell.level)) throw new ActionError(`${spell.name} is beyond ${character.name}'s level.`);
|
||||
const casting = sheet.spellcasting;
|
||||
// A cantrip takes no slot; any other spell takes one of its own level or higher.
|
||||
const slotLevel = spell.level > 0 ? Math.max(spell.level, source.slotLevel || spell.level) : null;
|
||||
const base = { damageDice: spell.damageDice || null, damageBonus: 0, damageType: null, characterId: character.id, spellLevel: spell.level, slotLevel };
|
||||
if (spell.attackRoll) return { kind: 'ATTACK', name: spell.name, detail: { ...base, attackBonus: casting ? casting.attackBonus : 0 } };
|
||||
if (spell.saveAbility) {
|
||||
return { kind: 'SAVE', name: spell.name, detail: { ...base, saveAbility: spell.saveAbility, saveDC: casting ? casting.saveDC : 10, halfOnSave: spell.halfOnSave } };
|
||||
}
|
||||
return { kind: 'AUTO', name: spell.name, detail: base };
|
||||
}
|
||||
throw new ActionError('That is not something to attack with.');
|
||||
}
|
||||
|
||||
// A fair number from 0 up to (not including) 1, for picking a target.
|
||||
const randomUnit = () => crypto.randomInt(0, 1000000) / 1000000;
|
||||
|
||||
// The attacks a monster or NPC makes by itself on its turn, each already
|
||||
// aimed and rolled: who it goes for (game/combat.js, pickTarget), the d20, and
|
||||
// the damage where it hits. `history` is the encounter's applied actions,
|
||||
// newest first, for whom it fought last and who has hurt it. Returns the
|
||||
// actions to create (without ids); empty when it has no attack or nobody to
|
||||
// go for.
|
||||
function autoActions(actor, participants, { penaltyStep = 0, critRule = 'DOUBLE_DICE', history = [] } = {}) {
|
||||
const attacks = attacksOf({ monster: actor.monster, npc: actor.npc });
|
||||
const combatants = participants.map(combatantOf);
|
||||
const opponents = opponentsOf(combatantOf(actor), combatants);
|
||||
if (attacks.length === 0 || opponents.length === 0) return [];
|
||||
|
||||
const mine = history.find((action) => action.actorParticipantId === actor.id);
|
||||
let lastTargetId = mine && mine.targets[0] ? mine.targets[0].participantId : null;
|
||||
const hurtBy = history
|
||||
.filter((action) => (action.targets || []).some((t) => t.participantId === actor.id && t.finalDamage > 0))
|
||||
.map((action) => action.actorParticipantId)
|
||||
.filter(Boolean);
|
||||
|
||||
return attacks.map((attack) => {
|
||||
const picked = pickTarget(opponents, { lastTargetId, hurtBy }, randomUnit);
|
||||
lastTargetId = picked.id; // a creature with several attacks tends to keep at one foe
|
||||
const target = participants.find((p) => p.id === picked.id);
|
||||
const detail = { attackBonus: attack.attackBonus, damageDice: attack.damageDice, damageBonus: 0, damageType: attack.damageType };
|
||||
const [row] = targetRows('ATTACK', detail, [target], penaltyStep);
|
||||
row.d20 = rollDie(20);
|
||||
const outcome = attackOutcome(row.d20, detail.attackBonus, row.ac, row.penalty);
|
||||
return {
|
||||
actorParticipantId: actor.id,
|
||||
actorName: actor.displayName,
|
||||
kind: 'ATTACK',
|
||||
name: attack.name,
|
||||
detail,
|
||||
targets: [row],
|
||||
damage: lands(outcome) ? rollDamage(detail, { critical: outcome === 'CRIT', critRule }) : null,
|
||||
auto: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { ActionError, kindOf, isFallen, armorClassOf, saveBonusOf, combatantOf, areaPenalty, targetRows, describeSource, autoActions };
|
||||
@@ -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
|
||||
|
||||
|
||||
+131
-11
@@ -319,6 +319,21 @@ battles only.
|
||||
combatant it returns to the top and the **round** goes up by one.
|
||||
- **Next Round** skips straight to the top of the next round.
|
||||
- Anyone out of the fight (section 6, "The fallen") is passed over.
|
||||
- **The turn-order track.** While a battle is in progress a line of names
|
||||
stays at the top of the screen: whoever is on turn first (larger, with ▶),
|
||||
then everyone still to act this round, then a **🏁 Round N+1** flag where
|
||||
the round ends, then the whole order again for the round after. It is names
|
||||
only, coloured by what each is: **blue** for a player character, **green**
|
||||
for an NPC, **red** for a monster. A struck-through name is dying. The DM
|
||||
can read off who is next, and who follows the end of the round, at a glance.
|
||||
Before the battle begins the older strip (names with HP) is shown instead.
|
||||
- **What now.** Under the track the DM gets one line saying what the scene is
|
||||
waiting on: "Aria's turn: waiting for them to pick a target", "Aria's
|
||||
Longsword: enter the d20 against Goblin 1 (AC 15)", "Fireball: 3 saving
|
||||
throws to enter (DEX, DC 14)", "Goblin 2's Attack is ready (Aria: hit for
|
||||
5). Accept, change or discard it." A player gets the same line for what is
|
||||
theirs to do: "Your turn! Pick a target", "Roll your d20 for Longsword",
|
||||
"make a DEX saving throw and enter your d20".
|
||||
- **The list follows the turn.** While a battle is in progress, whoever is on
|
||||
turn is at the top of "Initiative order" with those still to act this round
|
||||
beneath them. Each combatant whose turn has passed moves down to a **Next
|
||||
@@ -333,8 +348,10 @@ Combatants can be marked as **targets**: whoever is being attacked (or
|
||||
healed) right now. One target is an ordinary attack; more than one is an
|
||||
**area attack**.
|
||||
|
||||
- Click **🎯 Target** beside a combatant's name to mark them; click it again
|
||||
to unmark. Each one marked is added to the targets.
|
||||
- Three ways to mark someone, each a toggle: click their **name on the
|
||||
turn-order track**; click **🎯 Target** beside their name on their card; or
|
||||
press **1 to 9** for the first nine combatants in turn order (leaving out
|
||||
whoever is on turn). Each one marked is added to the targets.
|
||||
- The **DM** can mark anyone at any time, in a battle or a quest. A **player**
|
||||
can while their own character is on turn in a battle, so they can show the
|
||||
DM who they are going for. A player cannot mark someone who is out of the
|
||||
@@ -344,8 +361,13 @@ healed) right now. One target is an ordinary attack; more than one is an
|
||||
on their own turn, use the buttons on their card.
|
||||
- A target's card is outlined in red for everyone and reads "Targeted by
|
||||
<whoever is on turn>".
|
||||
- While anyone is marked the DM gets an **attack bar** above the list, which
|
||||
stays in view while scrolling. It shows who is attacking whom, each target's
|
||||
- **In hand.** The card of whoever is on turn, and of every target, shows a
|
||||
highlighted "In hand" line: the weapons the character has equipped, with
|
||||
their to-hit and damage (or, for the DM, a creature's attacks). So both
|
||||
sides of an exchange show what they are fighting with.
|
||||
- While anyone is marked the DM also gets a quick **damage bar** above the
|
||||
list, for changing HP by hand without an attack (below). It shows who is
|
||||
attacking whom, each target's
|
||||
AC and HP, and the attacker's attacks: a character's equipped weapons (none,
|
||||
if nothing is equipped) and damaging spells, or a monster's or NPC's
|
||||
attack and damage. The amount box is ready to type in; **Damage** (or
|
||||
@@ -381,8 +403,96 @@ An attack on several targets is harder to land and its damage is shared.
|
||||
is applied.
|
||||
- A target with no hit points to track is left out of the sharing.
|
||||
|
||||
The app does not roll the attack or decide whether it hits: the DM compares
|
||||
the roll with the AC shown and types the damage.
|
||||
The damage bar does not roll or decide anything: it is for when the DM
|
||||
already knows the number. An attack that is to be rolled and judged goes
|
||||
through **Attacks and spells**, next.
|
||||
|
||||
### Attacks and spells
|
||||
|
||||
An attack or a spell is **declared**, **rolled**, and then **applied** by the
|
||||
DM. The app works out whether it hits (or whether each target saves) from the
|
||||
d20 that is entered, and the DM can overrule it at every step.
|
||||
|
||||
**1. Declare.** Once a target is marked, a box "<whoever is on turn> attacks
|
||||
with…" lists what they can use, one button each:
|
||||
|
||||
- a character's **equipped weapons** (outlined, so the weapon in hand stands
|
||||
out), each with its to-hit and damage;
|
||||
- the **spells** they know, each saying how it lands: a spell attack ("+6 to
|
||||
hit"), a saving throw ("DC 14 DEX save"), or no roll;
|
||||
- for the DM, a monster's or NPC's **own attacks**;
|
||||
- for the DM, **✎ Something else…**: an attack roll, a saving throw or an
|
||||
effect spelled out on the spot (a trap, a breath weapon, a monster's spell):
|
||||
its name, bonus or DC and ability, and damage dice.
|
||||
|
||||
A **player** declares for their own character, on its turn. The **DM**
|
||||
declares for anyone. Declaring replaces that combatant's unfinished action.
|
||||
|
||||
**2. Roll.** The action appears as a card for everyone, with a row per target:
|
||||
|
||||
| The action is | Each target's row asks for | And says |
|
||||
|---|---|---|
|
||||
| An attack (a weapon, a spell attack, a creature's attack) | The attacker's **d20** against that target | "17 vs AC 15": **HIT**, **MISS**, **CRITICAL HIT** on a natural 20, a miss on a natural 1 |
|
||||
| A saving throw spell | That target's **d20** for the save; their save bonus is filled in | "14 vs DC 14": **SAVED** or **FAILED** |
|
||||
| Something that just lands (Magic Missile) | Nothing | It hits |
|
||||
|
||||
- The d20 is **typed in** (press Enter or click away) or **rolled by the
|
||||
app** with "🎲 Roll the d20". A player follows the campaign's dice setting;
|
||||
the DM can always do either. With several targets an attack takes one d20
|
||||
per target, and the area-attack AC penalty is counted in.
|
||||
- The attacker's player enters the attack rolls; a player enters the **save
|
||||
for their own character** when a spell or trap is aimed at them; the DM can
|
||||
enter anything.
|
||||
- **Damage**: once something hits (or someone fails a save), "🎲 Roll damage"
|
||||
rolls the dice shown, or the total rolled at the table is typed in. On a
|
||||
**critical hit** the app rolls the critical damage as well, by the
|
||||
campaign's rule (the dice twice, or the dice at their most plus one roll),
|
||||
and that target takes it. A save that succeeds takes half, or nothing,
|
||||
as the spell says.
|
||||
|
||||
**3. Overrule (DM).** Beside each target the DM has **Hit / Miss / Crit** (or
|
||||
**Saved / Failed**) buttons that rule the outcome whatever the dice say (a
|
||||
ruling shows a ✎; pressing it again goes back to the dice), and a **dmg** box
|
||||
to set what that one target takes. "Aim it at" moves a single-target action
|
||||
to someone else, keeping the d20 already rolled.
|
||||
|
||||
**4. Apply (DM).** **✔ Apply** (or the **A** key) lets it land: each target
|
||||
loses what the card says, with temp HP, death saves and the fallen handled as
|
||||
for any damage; a spell spends a slot of its level from its caster (if they
|
||||
have none left it says so and spends nothing); anyone hurt while
|
||||
Concentrating gets a line telling the DM the save to call for. The card then
|
||||
moves to **⚔ What just happened** in the side column, where the last few
|
||||
actions stay for everyone to read. **✕ Discard** throws it away; a player can
|
||||
call off their own.
|
||||
|
||||
Passing the turn discards whatever was left unfinished.
|
||||
|
||||
### Auto-rolled monster and NPC attacks
|
||||
|
||||
With **Auto-roll monster attacks** (and, separately, **Auto-roll NPC
|
||||
attacks**) on in the campaign settings, a monster's or NPC's turn begins with
|
||||
its attack **already declared and rolled** by the server:
|
||||
|
||||
- **Who it goes for.** Monsters and hostile NPCs attack the party (characters
|
||||
and friendly NPCs); friendly NPCs attack monsters and hostile NPCs; a
|
||||
neutral NPC attacks nobody. Among those still standing the target is drawn
|
||||
at random, weighted: whoever it attacked last and whoever has hurt it count
|
||||
for more, and so, a little, does a lower AC. A creature with several
|
||||
attacks tends to keep at one foe.
|
||||
- **What is rolled.** The d20 against that target's AC, and, on a hit, the
|
||||
damage (critical damage on a natural 20). A monster with **named attacks**
|
||||
([MONSTERS.md](MONSTERS.md), section 2) makes each of them, as many times
|
||||
as its multiattack says: one card each.
|
||||
- **The DM decides.** The card reads, for example, "🤖 Goblin 2: Attack → Aria:
|
||||
17 vs AC 15, HIT, −5 HP". **✔ Accept** applies it; or change the target,
|
||||
the d20, the outcome or the damage first; or **✕ Discard** it and run the
|
||||
turn by hand.
|
||||
- **Players do not see it until it is accepted**, when the campaign keeps the
|
||||
DM's monster rolls hidden (the default); then it appears in "What just
|
||||
happened" like any other.
|
||||
|
||||
A creature with no attack bonus or damage, or with nobody to attack, declares
|
||||
nothing.
|
||||
|
||||
## 8. Conditions
|
||||
|
||||
@@ -570,7 +680,9 @@ when there is something to trade, and always for the DM in a quest.
|
||||
| Rewards | All | Only awarded ones |
|
||||
| Difficulty and the monsters' XP | Yes | Never (awarded XP shows on their own sheet) |
|
||||
| Exchange rates and things for sale | Yes | Yes, for NPCs they can see and for the scene itself |
|
||||
| Controls (add, damage, turns, rewards) | Yes | None |
|
||||
| An attack or spell being resolved | Yes, all | Yes, but not a monster's or NPC's AC, and not the DM's hidden (auto-rolled) ones until they are accepted |
|
||||
| What just happened (the last few actions) | Yes | Yes |
|
||||
| Controls (add, damage, turns, rewards) | Yes | None, apart from their own attacks and rolls |
|
||||
|
||||
The server only sends a player what the right-hand column lists, so hidden
|
||||
information cannot be dug out of the page.
|
||||
@@ -588,6 +700,12 @@ information cannot be dug out of the page.
|
||||
| Change HP or conditions | Yes | No |
|
||||
| Enter an initiative roll | Yes, for anyone | For their own character only |
|
||||
| Mark targets | Yes, any time | On their own character's turn |
|
||||
| Declare an attack or a spell | Yes, for anyone, and anything spelled out | For their own character, on its turn, with its weapons and spells |
|
||||
| Enter or roll an attack's d20 and damage | Yes | For their own character's attack |
|
||||
| Enter or roll a saving throw | Yes, for anyone | For their own character |
|
||||
| Overrule a roll, set a target's damage, change the target | Yes | No |
|
||||
| Apply or accept an action | Yes | No |
|
||||
| Discard an action | Yes | Their own |
|
||||
| Enter a death save, or stabilise | Yes, for anyone | For their own character only |
|
||||
| Set death-save tallies by hand | Yes | No |
|
||||
| Revive a dead character outright | Yes | No |
|
||||
@@ -608,10 +726,12 @@ People outside the campaign cannot open its encounters.
|
||||
|
||||
- Rolling initiative for characters into their field (a player clicks their
|
||||
Init chip to roll it, then types what came up)
|
||||
- Applying an attack's damage automatically: attacks and damage are rolled
|
||||
with a click ([CHARACTERS.md](CHARACTERS.md), "Rolling from the sheet") and
|
||||
the DM enters the result
|
||||
- Rolls that everyone at the table sees
|
||||
- Advantage and disadvantage on an attack or save within an action (roll both
|
||||
d20s and enter the one that counts), resistances and vulnerabilities (set
|
||||
the target's damage by hand), and upcasting a spell for more damage
|
||||
- Healing through an action (use + Heal on the card or the damage bar)
|
||||
- A monster choosing among different attacks by itself: with auto-roll it
|
||||
makes all of its named attacks, or its single one
|
||||
- Death saves for monsters and NPCs, and prompting for a save on a dying
|
||||
character's turn
|
||||
- A roll, a spell slot or a time limit for reviving the dead
|
||||
|
||||
+9
-3
@@ -40,12 +40,17 @@ This describes the code as it stands. The built-in list is
|
||||
| Ability scores | STR, DEX, CON, INT, WIS, CHA. 10 unless set otherwise |
|
||||
| Attack bonus | Added to the monster's attack roll |
|
||||
| Damage | Dice for its main attack, e.g. `1d6+2`, with the damage type if you like: `1d6+2 necrotic` |
|
||||
| Named attacks | Optional, up to 8: each a name, an attack bonus, damage dice, a damage type and **how many times it is made each turn** (a multiattack: "Claw ×2, Bite ×1"). With any listed, these are what the monster attacks with in an encounter, in place of the single attack above |
|
||||
| Challenge rating | A rough danger rating, e.g. `1/4`, `2`. It sets what the monster is worth in XP and how hard an encounter is ([ENCOUNTERS.md](ENCOUNTERS.md), section 9); a monster with none, or with text that is not a rating, is left out of both |
|
||||
| Special trait | A short note such as "Pack Tactics" |
|
||||
| Notes | Anything else: what it attacks with, how it behaves |
|
||||
|
||||
Apart from the challenge rating, none of these are used in formulas. They
|
||||
are reference numbers shown to the DM during a fight. A monster's ability
|
||||
The challenge rating feeds the XP and difficulty sums. The AC, the attack
|
||||
bonus and damage (or the named attacks) and the ability scores are used when
|
||||
an attack or a spell is resolved in an encounter: an attack roll is judged
|
||||
against the AC, a saving throw adds the ability's modifier, and with
|
||||
auto-roll on the monster's attacks are aimed and rolled for the DM
|
||||
([ENCOUNTERS.md](ENCOUNTERS.md), "Attacks and spells"). A monster's ability
|
||||
modifiers are worked out from its scores for display, for its initiative
|
||||
bonus (Dexterity) and for the rolls a click makes (section 5).
|
||||
|
||||
@@ -186,7 +191,8 @@ Then they get the health bar only.
|
||||
|
||||
## 8. What is not modelled
|
||||
|
||||
- Multiple attacks, spells, legendary actions and resistances (use Notes)
|
||||
- Spells, legendary actions and resistances (use Notes; in an encounter the
|
||||
DM can spell out any attack or save on the spot)
|
||||
- Trade: monsters do not sell things or change money (NPCs do)
|
||||
- Saving throw proficiencies and skills (on an encounter card a click on an
|
||||
ability score rolls a d20 with its modifier, for a check or a save)
|
||||
|
||||
@@ -164,6 +164,25 @@ A spell's damage is whatever is picked in its Damage drop-down, shown beside
|
||||
the spell on the sheet and in encounters. It does not change with the character's
|
||||
level, and nothing is added to it.
|
||||
|
||||
### How a spell lands in a battle
|
||||
|
||||
Each spell says how it is resolved when it is cast in an encounter
|
||||
([ENCOUNTERS.md](ENCOUNTERS.md), "Attacks and spells"). The DM sets it under
|
||||
**In a battle** when adding a spell, or on its pop-out:
|
||||
|
||||
| Setting | In an encounter |
|
||||
|---|---|
|
||||
| It just takes effect | No roll: it lands (Magic Missile, a buff) |
|
||||
| The caster rolls a spell attack | A d20 plus the caster's spell attack bonus against each target's AC |
|
||||
| A save with an ability, half damage on a success | Each target rolls a d20 plus their save bonus against the caster's spell save DC; a success takes half (Fireball) |
|
||||
| A save with an ability, nothing on a success | The same, but a success takes nothing (Sacred Flame, Hold Person) |
|
||||
|
||||
The library's spells come already set (Fire Bolt and Guiding Bolt as spell
|
||||
attacks; Fireball, Burning Hands, Hold Person and the like as saves), and
|
||||
copies already in a campaign were marked to match when this was added.
|
||||
Casting a spell of 1st level or higher this way spends a slot of its level
|
||||
when the DM applies it.
|
||||
|
||||
## 7. Spellcasting numbers
|
||||
|
||||
Shown only for classes with a spellcasting ability:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.25.0",
|
||||
"version": "1.27.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from './client';
|
||||
import type {
|
||||
ActionPatch,
|
||||
ActionSource,
|
||||
BuildOptions,
|
||||
Campaign,
|
||||
CharacterClass,
|
||||
@@ -9,6 +11,7 @@ import type {
|
||||
CharacterDetail,
|
||||
CharacterSummary,
|
||||
Currency,
|
||||
DiceRollRecord,
|
||||
DiceMode,
|
||||
Encounter,
|
||||
EncounterMode,
|
||||
@@ -28,6 +31,7 @@ import type {
|
||||
QuickStats,
|
||||
RestResult,
|
||||
RestType,
|
||||
RollSpec,
|
||||
Spell,
|
||||
Weapon,
|
||||
} from './types';
|
||||
@@ -380,6 +384,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({
|
||||
@@ -960,6 +986,39 @@ export function useSetTargets(encounterId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// Declares an attack or a spell: by whoever is on turn unless an actor is
|
||||
// named, at the targets marked unless some are given.
|
||||
export function useDeclareAction(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { source: ActionSource; actorParticipantId?: string; targetIds?: string[]; hidden?: boolean }) =>
|
||||
(await apiClient.post(`/encounters/${encounterId}/actions`, data)).data,
|
||||
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
// Puts a roll, a ruling or the damage into an action still being resolved.
|
||||
export function usePatchAction(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ actionId, ...data }: ActionPatch & { actionId: string }) => (await apiClient.patch(`/encounters/${encounterId}/actions/${actionId}`, data)).data,
|
||||
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
// The DM lets an action land. Answers with a save to call for anyone hurt while concentrating.
|
||||
export function useApplyAction(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (actionId: string) =>
|
||||
(await apiClient.post<{ concentration: { name: string; damage: number; dc: number }[]; slotNote: string | null }>(`/encounters/${encounterId}/actions/${actionId}/apply`)).data,
|
||||
onSuccess: (data) => {
|
||||
keepEncounter(qc, encounterId, data);
|
||||
qc.invalidateQueries({ queryKey: ['character'] }); // HP, and a spell slot spent
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// The character on turn trying to bring a dead ally back; the server decides
|
||||
// whether they have the means.
|
||||
export function useReviveParticipant(encounterId: string) {
|
||||
|
||||
@@ -433,6 +433,11 @@ export interface Spell {
|
||||
damageDice: string | null;
|
||||
isStartingOption: boolean;
|
||||
revives: boolean; // brings back the dead: lets its caster revive a dead ally in a battle
|
||||
// How it lands in a battle: a spell attack against AC, or a save by each
|
||||
// target against the caster's DC (half damage on a success when halfOnSave).
|
||||
attackRoll: boolean;
|
||||
saveAbility: Ability | null;
|
||||
halfOnSave: boolean;
|
||||
}
|
||||
|
||||
export interface CharacterSpell {
|
||||
@@ -566,6 +571,16 @@ export interface Monster {
|
||||
challengeRating: string | null;
|
||||
specialTrait: string | null;
|
||||
notes: string | null;
|
||||
// Named attacks, for more than the one above; `count` makes it a multiattack.
|
||||
attacks: MonsterAttack[];
|
||||
}
|
||||
|
||||
export interface MonsterAttack {
|
||||
name: string;
|
||||
attackBonus: number;
|
||||
damageDice?: string | null;
|
||||
damageType?: string | null;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export type EncounterStatus = 'PLANNING' | 'ACTIVE' | 'COMPLETE';
|
||||
@@ -657,6 +672,79 @@ export interface CombatSpell {
|
||||
description: string | null;
|
||||
damageDice: string | null;
|
||||
prepared: boolean;
|
||||
attackRoll: boolean;
|
||||
saveAbility: Ability | null;
|
||||
halfOnSave: boolean;
|
||||
}
|
||||
|
||||
export type ActionOutcome = 'HIT' | 'MISS' | 'CRIT' | 'FUMBLE' | 'SAVED' | 'FAILED';
|
||||
|
||||
// One target of an action, as the dice (or the DM) have it.
|
||||
export interface ActionTarget {
|
||||
participantId: string;
|
||||
name: string;
|
||||
kind: 'CHARACTER' | 'MONSTER' | 'NPC';
|
||||
ac: number | null; // the AC an attack is against; a player is not sent a monster's
|
||||
penalty: number; // what an area attack adds to it
|
||||
saveBonus: number | null; // what they add to the save, for a save
|
||||
d20: number | null;
|
||||
total: number | null; // the d20 plus the bonus
|
||||
rolled: ActionOutcome | null; // what the dice say
|
||||
outcome: ActionOutcome | null; // what stands: the dice, or the DM's ruling
|
||||
overruled: boolean;
|
||||
damage: number | null; // set by the DM for this target alone
|
||||
finalDamage: number | null; // what they take; null until it can be known
|
||||
}
|
||||
|
||||
// An attack or a spell in a battle, from being declared to its damage landing.
|
||||
export interface EncounterAction {
|
||||
id: string;
|
||||
round: number;
|
||||
actorParticipantId: string | null;
|
||||
actorName: string;
|
||||
kind: 'ATTACK' | 'SAVE' | 'AUTO'; // a d20 against AC | targets save against a DC | it just lands
|
||||
name: string;
|
||||
detail: {
|
||||
attackBonus?: number;
|
||||
saveAbility?: Ability;
|
||||
saveDC?: number;
|
||||
halfOnSave?: boolean;
|
||||
damageDice?: string | null;
|
||||
damageBonus?: number;
|
||||
damageType?: string | null;
|
||||
slotLevel?: number | null;
|
||||
};
|
||||
targets: ActionTarget[];
|
||||
damage: { total: number; dice?: RolledDie[]; modifier?: number; entered?: boolean; crit?: { total: number; dice: RolledDie[]; modifier: number } | null } | null;
|
||||
status: 'PENDING' | 'APPLIED' | 'DISCARDED';
|
||||
auto: boolean; // declared and rolled by the server, for a monster or NPC
|
||||
hidden: boolean; // the DM's alone until applied
|
||||
needsRolls: number; // targets still waiting on a d20
|
||||
needsDamage: boolean;
|
||||
needsCrit: boolean;
|
||||
ready: boolean;
|
||||
createdAt: string;
|
||||
appliedAt: string | null;
|
||||
}
|
||||
|
||||
// What an action is made with (POST /encounters/:id/actions).
|
||||
export type ActionSource =
|
||||
| { type: 'weapon'; characterWeaponId: string }
|
||||
| { type: 'spell'; characterSpellId: string; slotLevel?: number }
|
||||
| { type: 'basic'; index?: number }
|
||||
| { type: 'custom'; name: string; kind: 'ATTACK' | 'SAVE' | 'AUTO'; attackBonus?: number; saveAbility?: Ability; saveDC?: number; halfOnSave?: boolean; damageDice?: string | null };
|
||||
|
||||
// What can be changed on an action still being resolved (PATCH).
|
||||
export interface ActionPatch {
|
||||
rolls?: { participantId: string; d20: number | null }[];
|
||||
rollRemaining?: boolean;
|
||||
outcomes?: { participantId: string; outcome: 'HIT' | 'MISS' | 'CRIT' | 'SAVED' | 'FAILED' | null }[];
|
||||
damage?: number | null;
|
||||
rollDamage?: boolean;
|
||||
targetDamage?: { participantId: string; damage: number | null }[];
|
||||
targetIds?: string[];
|
||||
hidden?: boolean;
|
||||
discard?: boolean;
|
||||
}
|
||||
|
||||
export interface CharacterCombatInfo {
|
||||
@@ -746,6 +834,10 @@ export interface Encounter {
|
||||
// The dead characters in the scene. Players do not get their cards, only
|
||||
// this, so that an ally can try to revive them.
|
||||
fallen?: { participantId: string; characterId: string; name: string }[];
|
||||
// Attacks and spells still being resolved (a player does not get the DM's
|
||||
// hidden ones), and the last few that landed.
|
||||
actions?: EncounterAction[];
|
||||
recentActions?: EncounterAction[];
|
||||
rewards?: EncounterReward[]; // players only receive the awarded ones
|
||||
wares?: SaleOffer[]; // for sale at the scene itself, not by an NPC in it
|
||||
}
|
||||
@@ -957,6 +1049,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>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import { EffectsEditor } from '../EffectsEditor';
|
||||
import { WaresEditor } from '../Wares';
|
||||
import { extractErrorMessage, fmtMod, spellLevelLabel, spellRequirementLabel } from '../../lib/format';
|
||||
import { abilityLine, rateLabel, type EntryKind, type EntryRef } from '../../lib/campaignEntries';
|
||||
import { Field, MonsterForm, ScalingSelect, SpellDamageSelect, StartLevelSelect } from './parts';
|
||||
import { Field, MonsterForm, ScalingSelect, SpellDamageSelect, SpellLandingSelect, StartLevelSelect } from './parts';
|
||||
import { landingValue } from '../../lib/campaignEntries';
|
||||
import type { GearEffect, Item, Monster, Npc, Spell, Weapon } from '../../api/types';
|
||||
|
||||
const KIND_NOUNS: Record<EntryKind, string> = { weapon: 'weapon', item: 'item', spell: 'spell', monster: 'monster', npc: 'NPC' };
|
||||
@@ -140,6 +141,9 @@ function SpellDetail({ campaignId, spell, added, onClose }: { added: ReactNode;
|
||||
<Field label="Damage">
|
||||
{(id) => <SpellDamageSelect id={id} spellName={spell.name} value={spell.damageDice} onChange={(damageDice) => patch.mutate({ id: spell.id, damageDice }, { onError })} />}
|
||||
</Field>
|
||||
<Field label="In a battle">
|
||||
{(id) => <SpellLandingSelect id={id} value={landingValue(spell)} onChange={(landing) => patch.mutate({ id: spell.id, ...landing }, { onError })} />}
|
||||
</Field>
|
||||
</div>
|
||||
<label className="checkbox-row mt-md">
|
||||
<input type="checkbox" checked={spell.isStartingOption} onChange={(e) => patch.mutate({ id: spell.id, isStartingOption: e.target.checked }, { onError })} />
|
||||
@@ -191,6 +195,12 @@ function MonsterDetail({ campaignId, monster, added, onClose }: { added: ReactNo
|
||||
<Chip label="Speed">{monster.speed}</Chip>
|
||||
<Chip label="Attack">{fmtMod(monster.attackBonus)}</Chip>
|
||||
<Chip label="Damage">{monster.damageDice || '—'}</Chip>
|
||||
{(monster.attacks ?? []).map((a, i) => (
|
||||
<Chip key={i} label={`${a.name}${(a.count ?? 1) > 1 ? ` ×${a.count}` : ''}`}>
|
||||
{fmtMod(a.attackBonus)} · {a.damageDice || '—'}
|
||||
{a.damageType ? ` ${a.damageType}` : ''}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
<p className="mono mt-sm" style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{abilityLine(monster)}</p>
|
||||
{monster.specialTrait && (
|
||||
|
||||
@@ -18,7 +18,8 @@ import { effectsSummary } from '../../lib/gear';
|
||||
import { extractErrorMessage, fmtMod, spellLevelLabel, spellRequirementLabel } from '../../lib/format';
|
||||
import { MAX_GEAR_LEVEL } from '../../lib/previewCalc';
|
||||
import { abilityLine, rateLabel, type EntryRef } from '../../lib/campaignEntries';
|
||||
import { EntryList, Field, LibraryPicker, MonsterForm, ScalingSelect, SpellDamageSelect, StartLevelSelect } from './parts';
|
||||
import { EntryList, Field, LibraryPicker, MonsterForm, ScalingSelect, SpellDamageSelect, SpellLandingSelect, StartLevelSelect } from './parts';
|
||||
import { landingFrom } from '../../lib/campaignEntries';
|
||||
import type { CampaignLibrary, Item, LibraryItem, LibraryMonster, LibrarySpell, LibraryWeapon, Monster, Npc, NpcDisposition, Spell, Weapon } from '../../api/types';
|
||||
|
||||
interface SectionProps<T> {
|
||||
@@ -347,6 +348,7 @@ export function SpellsSection({ campaignId, entries, onOpen, formRequested, onFo
|
||||
damageDice: String(fd.get('damageDice') || '') || null,
|
||||
isStartingOption: fd.get('isStartingOption') === 'on',
|
||||
revives: fd.get('revives') === 'on',
|
||||
...landingFrom(String(fd.get('landing') || '')),
|
||||
});
|
||||
close();
|
||||
onOpen({ kind: 'spell', id: spell.id, isNew: true });
|
||||
@@ -412,6 +414,7 @@ export function SpellsSection({ campaignId, entries, onOpen, formRequested, onFo
|
||||
</Field>
|
||||
<Field label="School (optional)">{(id) => <input id={id} name="school" />}</Field>
|
||||
<Field label="Damage">{(id) => <SpellDamageSelect id={id} name="damageDice" />}</Field>
|
||||
<Field label="In a battle" style={{ gridColumn: '1 / -1' }}>{(id) => <SpellLandingSelect id={id} name="landing" />}</Field>
|
||||
<Field label="What it does (one sentence)" style={{ gridColumn: '1 / -1' }}>
|
||||
{(id) => <input id={id} name="description" maxLength={4000} required />}
|
||||
</Field>
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Pagination } from '../Paginated';
|
||||
import { usePagination } from '../../hooks/usePagination';
|
||||
import { SPELL_DAMAGE_GROUPS, SPELL_DAMAGE_SPECIALS } from '../../lib/format';
|
||||
import { ABILITY_ORDER, DAMAGE_SCALING_LABELS, MAX_GEAR_LEVEL } from '../../lib/previewCalc';
|
||||
import { matchesSearch, scoreField, type MonsterInput } from '../../lib/campaignEntries';
|
||||
import type { Monster } from '../../api/types';
|
||||
import { landingFrom, matchesSearch, scoreField, type MonsterInput, type SpellLanding } from '../../lib/campaignEntries';
|
||||
import type { Ability, Monster, MonsterAttack } from '../../api/types';
|
||||
|
||||
// A labelled field in a pop-out form. The child is handed the id its label points at.
|
||||
export function Field({ label, style, children }: { label: string; style?: CSSProperties; children: (id: string) => ReactNode }) {
|
||||
@@ -19,6 +19,30 @@ export function Field({ label, style, children }: { label: string; style?: CSSPr
|
||||
);
|
||||
}
|
||||
|
||||
const LANDING_ABILITIES: Ability[] = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
|
||||
const ABILITY_NAMES: Record<Ability, string> = { STR: 'Strength', DEX: 'Dexterity', CON: 'Constitution', INT: 'Intelligence', WIS: 'Wisdom', CHA: 'Charisma' };
|
||||
|
||||
// How a spell lands in a battle, as one choice (lib/campaignEntries.ts, SpellLanding).
|
||||
export function SpellLandingSelect({ id, name, value, onChange }: { id?: string; name?: string; value?: string; onChange?: (landing: SpellLanding) => void }) {
|
||||
return (
|
||||
<select
|
||||
id={id}
|
||||
name={name}
|
||||
title="In a battle: whether the caster rolls to hit, each target makes a saving throw, or it simply happens"
|
||||
{...(onChange ? { value, onChange: (e) => onChange(landingFrom(e.target.value)) } : { defaultValue: '' })}
|
||||
>
|
||||
<option value="">It just takes effect (no roll)</option>
|
||||
<option value="ATTACK">The caster rolls a spell attack</option>
|
||||
{LANDING_ABILITIES.map((a) => (
|
||||
<optgroup key={a} label={`${ABILITY_NAMES[a]} save`}>
|
||||
<option value={`${a}:HALF`}>{ABILITY_NAMES[a]} save, half damage on a success</option>
|
||||
<option value={`${a}:NONE`}>{ABILITY_NAMES[a]} save, nothing on a success</option>
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
// How fast a weapon's or item's dice grow as the DM levels a held copy up.
|
||||
export function ScalingSelect({ id, name, value, onChange }: { id?: string; name?: string; value?: number; onChange?: (scaling: number) => void }) {
|
||||
return (
|
||||
@@ -119,6 +143,17 @@ export function MonsterForm({
|
||||
onSave: (data: MonsterInput) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
// Named attacks, as typed: kept as text until saved.
|
||||
const [attacks, setAttacks] = useState<{ name: string; attackBonus: string; damageDice: string; damageType: string; count: string }[]>(() =>
|
||||
(monster?.attacks ?? []).map((a) => ({
|
||||
name: a.name,
|
||||
attackBonus: String(a.attackBonus),
|
||||
damageDice: a.damageDice ?? '',
|
||||
damageType: a.damageType ?? '',
|
||||
count: String(a.count ?? 1),
|
||||
}))
|
||||
);
|
||||
const setAttack = (index: number, change: Partial<(typeof attacks)[number]>) => setAttacks((rows) => rows.map((row, i) => (i === index ? { ...row, ...change } : row)));
|
||||
const field = (name: string, label: string, input: ReactNode, style?: CSSProperties) => (
|
||||
<div className="field" style={{ marginBottom: 0, ...style }}>
|
||||
<label htmlFor={`monster-${name}`}>{label}</label>
|
||||
@@ -164,6 +199,17 @@ export function MonsterForm({
|
||||
chaScore: num('chaScore'),
|
||||
specialTrait: text('specialTrait'),
|
||||
notes: text('notes'),
|
||||
attacks: attacks
|
||||
.filter((a) => a.name.trim())
|
||||
.map(
|
||||
(a): MonsterAttack => ({
|
||||
name: a.name.trim(),
|
||||
attackBonus: Math.trunc(Number(a.attackBonus)) || 0,
|
||||
damageDice: a.damageDice.trim() || null,
|
||||
damageType: a.damageType.trim() || null,
|
||||
count: Math.min(10, Math.max(1, Math.trunc(Number(a.count)) || 1)),
|
||||
})
|
||||
),
|
||||
});
|
||||
} catch {
|
||||
// Whoever opened the form shows the error; what was typed stays.
|
||||
@@ -190,6 +236,30 @@ export function MonsterForm({
|
||||
<div className="grid mt-sm" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))' }}>
|
||||
{ABILITY_ORDER.map((a) => number(scoreField(a), a, 1, 30, 10))}
|
||||
</div>
|
||||
<div className="field" style={{ marginTop: '0.5rem', marginBottom: 0 }}>
|
||||
<label>Named attacks (optional)</label>
|
||||
<p className="combatant-sub" style={{ margin: '0 0 0.3rem' }}>
|
||||
For a creature with more than the one attack above: each with its own bonus and damage, and how many times it is made each turn (a multiattack).
|
||||
With any listed here, these are what it attacks with.
|
||||
</p>
|
||||
{attacks.map((a, i) => (
|
||||
<div key={i} className="flex-row flex-wrap gap-sm" style={{ marginBottom: '0.3rem' }}>
|
||||
<input aria-label="Attack name" placeholder="Claw" value={a.name} maxLength={40} onChange={(e) => setAttack(i, { name: e.target.value })} style={{ flex: '2 1 110px' }} />
|
||||
<NumberInput aria-label="Attack bonus" placeholder="+hit" min={-20} max={50} value={a.attackBonus} onChange={(e) => setAttack(i, { attackBonus: e.target.value })} style={{ width: 70 }} />
|
||||
<input aria-label="Damage dice" placeholder="2d6+3" value={a.damageDice} maxLength={60} onChange={(e) => setAttack(i, { damageDice: e.target.value })} style={{ flex: '1 1 80px' }} />
|
||||
<input aria-label="Damage type" placeholder="slashing" value={a.damageType} maxLength={30} onChange={(e) => setAttack(i, { damageType: e.target.value })} style={{ flex: '1 1 80px' }} />
|
||||
<NumberInput aria-label="Times per turn" title="How many times it is made each turn" min={1} max={10} value={a.count} onChange={(e) => setAttack(i, { count: e.target.value })} style={{ width: 60 }} />
|
||||
<button type="button" className="btn btn-ghost" aria-label={`Remove attack ${a.name || i + 1}`} onClick={() => setAttacks((rows) => rows.filter((_, at) => at !== i))}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{attacks.length < 8 && (
|
||||
<button type="button" className="btn btn-sm" onClick={() => setAttacks((rows) => [...rows, { name: '', attackBonus: String(monster?.attackBonus ?? 0), damageDice: '', damageType: '', count: '1' }])}>
|
||||
+ Add an attack
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{field(
|
||||
'specialTrait',
|
||||
'Special trait',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
import { useState } from 'react';
|
||||
import { useApplyAction, useDeclareAction, usePatchAction } from '../../api/queries';
|
||||
import { ABILITY_LABELS, fmtMod, slotLevelLabel, spellLevelLabel } from '../../lib/format';
|
||||
import { NumberInput } from '../NumberInput';
|
||||
import { DiceRow } from '../dice/Die';
|
||||
import type { Ability, ActionOutcome, ActionSource, ActionTarget, CampaignDiceMode, EncounterAction, EncounterParticipant, MonsterAttack } from '../../api/types';
|
||||
|
||||
const ABILITIES: Ability[] = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
|
||||
|
||||
const OUTCOME_LABELS: Record<ActionOutcome, string> = { HIT: 'HIT', MISS: 'MISS', CRIT: 'CRITICAL HIT', FUMBLE: 'NATURAL 1: MISS', SAVED: 'SAVED', FAILED: 'FAILED' };
|
||||
const OUTCOME_CLASS: Record<ActionOutcome, string> = { HIT: 'is-hit', CRIT: 'is-crit', MISS: 'is-miss', FUMBLE: 'is-miss', SAVED: 'is-saved', FAILED: 'is-hit' };
|
||||
|
||||
// What an action rolls with, in a few words: "+5 to hit", "DC 14 Dexterity save, half on a success".
|
||||
function rollLine(action: EncounterAction): string {
|
||||
const { detail } = action;
|
||||
if (action.kind === 'ATTACK') return `${fmtMod(detail.attackBonus ?? 0)} to hit`;
|
||||
if (action.kind === 'SAVE') {
|
||||
return `DC ${detail.saveDC} ${detail.saveAbility ? ABILITY_LABELS[detail.saveAbility] : ''} save, ${detail.halfOnSave ? 'half damage' : 'nothing'} on a success`;
|
||||
}
|
||||
return 'no roll: it just lands';
|
||||
}
|
||||
|
||||
function damageLine(action: EncounterAction): string | null {
|
||||
const { damageDice, damageBonus, damageType } = action.detail;
|
||||
if (!damageDice) return null;
|
||||
return `${damageDice}${damageBonus ? ` ${fmtMod(damageBonus)}` : ''}${damageType ? ` ${damageType}` : ''}`;
|
||||
}
|
||||
|
||||
// One attack or spell being resolved: the d20 for each target (typed in or
|
||||
// rolled), whether it hits or they save, the damage, and, for the DM, the
|
||||
// means to overrule any of it and let it land. A monster's auto-rolled attack
|
||||
// arrives here with everything already rolled, to accept, change or discard.
|
||||
export function ActionCard({
|
||||
action,
|
||||
participants,
|
||||
isDM,
|
||||
userId,
|
||||
diceMode,
|
||||
encounterId,
|
||||
onNotice,
|
||||
onError,
|
||||
}: {
|
||||
action: EncounterAction;
|
||||
participants: EncounterParticipant[];
|
||||
isDM: boolean;
|
||||
userId: string | undefined;
|
||||
diceMode: CampaignDiceMode;
|
||||
encounterId: string;
|
||||
// Lines for the DM once it has landed: a concentration save to call for, a slot that was not there.
|
||||
onNotice: (...lines: string[]) => void;
|
||||
onError: (err: unknown) => void;
|
||||
}) {
|
||||
const patch = usePatchAction(encounterId);
|
||||
const apply = useApplyAction(encounterId);
|
||||
const [damageTyped, setDamageTyped] = useState('');
|
||||
const fail = { onError };
|
||||
const send = (data: Parameters<typeof patch.mutate>[0]) => patch.mutate(data, fail);
|
||||
|
||||
const ownerOf = (participantId: string | null) => participants.find((p) => p.id === participantId)?.character?.userId;
|
||||
const mine = Boolean(userId) && ownerOf(action.actorParticipantId) === userId;
|
||||
const actsForActor = isDM || mine;
|
||||
// Who may put in the d20 for a target: the attacker for an attack; the target's own player for a save.
|
||||
const mayRollFor = (t: ActionTarget) => isDM || (action.kind === 'ATTACK' ? mine : Boolean(userId) && ownerOf(t.participantId) === userId);
|
||||
// A player follows the campaign's dice; the DM types or rolls as they like.
|
||||
const mayType = isDM || diceMode !== 'DIGITAL';
|
||||
const mayAsk = isDM || diceMode !== 'PHYSICAL';
|
||||
const waitingOnMe = action.targets.filter((t) => t.d20 == null && mayRollFor(t));
|
||||
const busy = patch.isPending || apply.isPending;
|
||||
const outcomes: ('HIT' | 'MISS' | 'CRIT' | 'SAVED' | 'FAILED')[] = action.kind === 'SAVE' ? ['SAVED', 'FAILED'] : ['HIT', 'MISS', 'CRIT'];
|
||||
const damage = damageLine(action);
|
||||
const total = action.targets.reduce((sum, t) => sum + (t.finalDamage ?? 0), 0);
|
||||
|
||||
function land() {
|
||||
apply.mutate(action.id, {
|
||||
onError,
|
||||
onSuccess: (data) =>
|
||||
onNotice(
|
||||
...data.concentration.map((c) => `${c.name} is concentrating and took ${c.damage} damage: Constitution save, DC ${c.dc}. On a failure the spell ends: remove Concentrating.`),
|
||||
...(data.slotNote ? [data.slotNote] : [])
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`card action-card${action.auto ? ' is-auto' : ''}${action.ready ? ' is-ready' : ''}`}>
|
||||
<div className="flex-between flex-wrap">
|
||||
<div className="action-title">
|
||||
{action.auto ? '🤖' : action.kind === 'SAVE' ? '✨' : '⚔'} {action.actorName}: <strong>{action.name}</strong>
|
||||
</div>
|
||||
<div className="flex-row flex-wrap gap-sm">
|
||||
{action.auto && <span className="badge badge-dm" title="Aimed and rolled by the app as this turn began">auto-rolled</span>}
|
||||
{action.hidden && <span className="badge" title="The players do not see this until you accept it">🙈 hidden from players</span>}
|
||||
{action.detail.slotLevel != null && <span className="badge">{slotLevelLabel(action.detail.slotLevel)}-level slot</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="combatant-sub">
|
||||
{rollLine(action)}
|
||||
{damage && ` · ${damage}`}
|
||||
</div>
|
||||
|
||||
{action.targets.map((t) => {
|
||||
const canEnter = mayRollFor(t) && mayType && action.kind !== 'AUTO';
|
||||
const bonus = action.kind === 'SAVE' ? (t.saveBonus ?? 0) : (action.detail.attackBonus ?? 0);
|
||||
const against = action.kind === 'ATTACK' ? (t.ac != null ? `AC ${t.ac + t.penalty}${t.penalty > 0 ? ` (${t.ac} +${t.penalty} area)` : ''}` : 'their AC') : action.kind === 'SAVE' ? `DC ${action.detail.saveDC}` : '';
|
||||
return (
|
||||
<div key={t.participantId} className="action-target">
|
||||
<span className="action-target-name">
|
||||
{action.kind === 'SAVE' ? '🛡' : '🎯'} {t.name}
|
||||
</span>
|
||||
{action.kind !== 'AUTO' && (
|
||||
<span className="action-roll">
|
||||
{canEnter ? (
|
||||
<NumberInput
|
||||
// Remounted when the roll changes elsewhere (the server rolled it, or someone else typed it).
|
||||
key={`${t.participantId}:${t.d20}`}
|
||||
min={1}
|
||||
max={20}
|
||||
placeholder="d20"
|
||||
aria-label={action.kind === 'SAVE' ? `${t.name}'s saving throw (d20)` : `Attack roll against ${t.name} (d20)`}
|
||||
defaultValue={t.d20 ?? ''}
|
||||
autoFocus={t.d20 == null && waitingOnMe[0]?.participantId === t.participantId}
|
||||
style={{ width: 64 }}
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()}
|
||||
onBlur={(e) => {
|
||||
const d20 = e.target.value === '' ? null : Number(e.target.value);
|
||||
if (d20 !== t.d20) send({ actionId: action.id, rolls: [{ participantId: t.participantId, d20 }] });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="mono">{t.d20 ?? '—'}</span>
|
||||
)}
|
||||
<span className="mono combatant-sub">
|
||||
{fmtMod(bonus)}
|
||||
{t.total != null && ` = ${t.total}`}
|
||||
{against && ` vs ${against}`}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{t.outcome ? (
|
||||
<span className={`action-outcome ${OUTCOME_CLASS[t.outcome]}`}>
|
||||
{OUTCOME_LABELS[t.outcome]}
|
||||
{t.overruled && <span title={`The dice said ${t.rolled ? OUTCOME_LABELS[t.rolled] : 'nothing yet'}; you ruled otherwise`}> ✎</span>}
|
||||
</span>
|
||||
) : (
|
||||
<span className="action-outcome is-waiting">{action.kind === 'SAVE' ? 'waiting for their save' : 'waiting for the d20'}</span>
|
||||
)}
|
||||
{t.finalDamage != null && t.finalDamage > 0 && <span className="action-damage">−{t.finalDamage} HP</span>}
|
||||
{isDM && (
|
||||
<span className="action-overrule">
|
||||
{outcomes.map((o) => (
|
||||
<button
|
||||
key={o}
|
||||
type="button"
|
||||
className={`btn btn-sm${t.outcome === o || (o === 'MISS' && t.outcome === 'FUMBLE') ? ' is-on' : ''}`}
|
||||
title={t.overruled && t.outcome === o ? 'Go back to what the dice say' : `Rule it ${OUTCOME_LABELS[o].toLowerCase()}, whatever the dice say`}
|
||||
disabled={busy}
|
||||
onClick={() => send({ actionId: action.id, outcomes: [{ participantId: t.participantId, outcome: t.overruled && t.outcome === o ? null : o }] })}
|
||||
>
|
||||
{o === 'CRIT' ? 'Crit' : o.charAt(0) + o.slice(1).toLowerCase()}
|
||||
</button>
|
||||
))}
|
||||
<NumberInput
|
||||
key={`dmg:${t.participantId}:${t.damage}`}
|
||||
min={0}
|
||||
max={99999}
|
||||
placeholder="dmg"
|
||||
title="Set what this target takes by hand. Empty: what the action deals."
|
||||
aria-label={`Damage ${t.name} takes, by hand`}
|
||||
defaultValue={t.damage ?? ''}
|
||||
style={{ width: 64 }}
|
||||
onBlur={(e) => {
|
||||
const amount = e.target.value === '' ? null : Number(e.target.value);
|
||||
if (amount !== t.damage) send({ actionId: action.id, targetDamage: [{ participantId: t.participantId, damage: amount }] });
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* The DM aims a creature's own attack elsewhere: the d20 already rolled stays. */}
|
||||
{isDM && action.targets.length === 1 && action.actorParticipantId && (
|
||||
<label className="flex-row flex-wrap gap-sm mt-sm combatant-sub">
|
||||
Aim it at
|
||||
<select value={action.targets[0].participantId} disabled={busy} onChange={(e) => send({ actionId: action.id, targetIds: [e.target.value] })} style={{ width: 'auto' }}>
|
||||
{participants
|
||||
.filter((p) => !p.hidden && p.id !== action.actorParticipantId)
|
||||
.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span>(the d20 already rolled stays)</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{action.damage && (
|
||||
<div className="action-damage-roll">
|
||||
<span className="dice-label">Damage</span>
|
||||
{action.damage.dice && <DiceRow dice={action.damage.dice} size={30} />}
|
||||
<strong className="mono">{action.damage.total}</strong>
|
||||
{action.damage.entered && <span className="combatant-sub">entered by hand</span>}
|
||||
{action.damage.crit && (
|
||||
<>
|
||||
<span className="dice-label">Critical</span>
|
||||
<DiceRow dice={action.damage.crit.dice} size={30} />
|
||||
<strong className="mono">{action.damage.crit.total}</strong>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{action.targets.some((t) => t.outcome === 'CRIT') && action.damage?.entered && (
|
||||
<p className="combatant-sub" style={{ margin: '0.3rem 0 0' }}>
|
||||
A critical hit with damage entered by hand: roll the damage dice twice and put the total in that target’s “dmg” box.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
{waitingOnMe.length > 0 && mayAsk && action.kind !== 'AUTO' && (
|
||||
<button className="btn" disabled={busy} onClick={() => send({ actionId: action.id, rollRemaining: true })}>
|
||||
🎲 Roll {waitingOnMe.length === 1 ? 'the d20' : `${waitingOnMe.length} d20s`}
|
||||
</button>
|
||||
)}
|
||||
{actsForActor && damage && (action.needsDamage || action.damage) && (
|
||||
<>
|
||||
{mayAsk && (
|
||||
<button className={`btn${action.needsDamage ? ' btn-primary' : ''}`} disabled={busy} onClick={() => send({ actionId: action.id, rollDamage: true })}>
|
||||
🎲 {action.damage ? 'Reroll' : 'Roll'} damage
|
||||
</button>
|
||||
)}
|
||||
{mayType && (
|
||||
<span className="flex-row gap-sm">
|
||||
<NumberInput
|
||||
min={0}
|
||||
max={99999}
|
||||
placeholder="damage"
|
||||
aria-label="Damage rolled at the table"
|
||||
value={damageTyped}
|
||||
onChange={(e) => setDamageTyped(e.target.value)}
|
||||
style={{ width: 90 }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && damageTyped !== '') {
|
||||
send({ actionId: action.id, damage: Number(damageTyped) });
|
||||
setDamageTyped('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="btn"
|
||||
disabled={busy || damageTyped === ''}
|
||||
onClick={() => {
|
||||
send({ actionId: action.id, damage: Number(damageTyped) });
|
||||
setDamageTyped('');
|
||||
}}
|
||||
>
|
||||
Enter damage
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<span style={{ flex: 1 }} />
|
||||
{isDM ? (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={busy || !action.ready}
|
||||
title={
|
||||
action.ready
|
||||
? 'Let it land: the damage is applied and everyone sees what happened. Shortcut: A'
|
||||
: action.needsRolls > 0
|
||||
? `Waiting on ${action.needsRolls} d20${action.needsRolls === 1 ? '' : 's'}`
|
||||
: 'Waiting on the damage'
|
||||
}
|
||||
onClick={land}
|
||||
>
|
||||
✔ {action.auto ? 'Accept' : 'Apply'}
|
||||
{action.ready && total > 0 ? ` (${total} damage)` : ''}
|
||||
</button>
|
||||
<button className="btn btn-ghost" disabled={busy} onClick={() => send({ actionId: action.id, discard: true })}>
|
||||
✕ Discard
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
mine && (
|
||||
<>
|
||||
<span className="combatant-sub">{action.ready ? 'Ready: your DM lets it land.' : ''}</span>
|
||||
<button className="btn btn-ghost" disabled={busy} onClick={() => send({ actionId: action.id, discard: true })}>
|
||||
✕ Call it off
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// What a monster or NPC attacks with, as the server works it out
|
||||
// (game/combat.js, attacksOf): its named attacks, each as often as it makes
|
||||
// it, or its single attack. Empty for a player (who is not sent the stats).
|
||||
function creatureAttacks(p: EncounterParticipant): (MonsterAttack & { index: number })[] {
|
||||
const named = (p.monster?.attacks ?? []).filter((a) => a.name);
|
||||
if (named.length > 0) {
|
||||
return named.flatMap((a) => Array.from({ length: Math.max(1, a.count ?? 1) }, () => a)).map((a, index) => ({ ...a, index }));
|
||||
}
|
||||
const stats = p.monster ?? p.npc;
|
||||
if (!stats || (stats.attackBonus == null && !stats.damageDice)) return [];
|
||||
return [{ name: 'Attack', attackBonus: stats.attackBonus ?? 0, damageDice: stats.damageDice, index: 0 }];
|
||||
}
|
||||
|
||||
// What whoever is acting can attack with, one button each: a character's
|
||||
// equipped weapons and their spells (each saying how it lands), or a
|
||||
// creature's own attacks. Pressing one declares it against the targets
|
||||
// marked. The DM can also spell out something else (a trap, a breath weapon).
|
||||
export function ActionComposer({
|
||||
actor,
|
||||
targetCount,
|
||||
isDM,
|
||||
encounterId,
|
||||
onError,
|
||||
}: {
|
||||
actor: EncounterParticipant | undefined;
|
||||
targetCount: number;
|
||||
isDM: boolean;
|
||||
encounterId: string;
|
||||
onError: (err: unknown) => void;
|
||||
}) {
|
||||
const declare = useDeclareAction(encounterId);
|
||||
const [custom, setCustom] = useState(false);
|
||||
const [kind, setKind] = useState<'ATTACK' | 'SAVE' | 'AUTO'>('ATTACK');
|
||||
const info = actor?.characterCombatInfo;
|
||||
const attacks = actor && !info ? creatureAttacks(actor) : [];
|
||||
const noTarget = targetCount === 0;
|
||||
const go = (source: ActionSource) => declare.mutate({ source, actorParticipantId: actor?.id }, { onError });
|
||||
const slotsLeft = (level: number) => info?.spellSlots?.levels.find((l) => l.level === level)?.remaining ?? 0;
|
||||
|
||||
return (
|
||||
<div className="card action-composer">
|
||||
<div className="flex-between flex-wrap">
|
||||
<div className="action-title">{actor ? `${actor.displayName} attacks with…` : 'An attack from nobody in particular'}</div>
|
||||
{noTarget && <span className="badge badge-hostile">Pick a target first: click a name on the track, or 🎯 on a card</span>}
|
||||
</div>
|
||||
|
||||
<div className="action-options">
|
||||
{info?.weapons.map((w) => (
|
||||
<button
|
||||
key={w.characterWeaponId}
|
||||
className="btn action-option is-equipped"
|
||||
disabled={noTarget || declare.isPending}
|
||||
title={`${w.name}: equipped. Roll a d20 ${fmtMod(w.attackBonus)} against the target's AC.`}
|
||||
onClick={() => go({ type: 'weapon', characterWeaponId: w.characterWeaponId })}
|
||||
>
|
||||
<span className="action-option-name">⚔ {w.name}</span>
|
||||
<span className="combatant-sub mono">
|
||||
{fmtMod(w.attackBonus)} to hit · {w.damageDice} {fmtMod(w.damageBonus)} {w.damageType}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{info && info.weapons.length === 0 && <span className="combatant-sub">No weapon equipped: equip one on the sheet to attack with it.</span>}
|
||||
{info?.spells.map((s) => {
|
||||
const cantrip = s.level === 0;
|
||||
const spent = !cantrip && Boolean(info.spellSlots) && slotsLeft(s.level) === 0;
|
||||
const how = s.attackRoll
|
||||
? `${fmtMod(info.spellcasting?.attackBonus ?? 0)} to hit`
|
||||
: s.saveAbility
|
||||
? `DC ${info.spellcasting?.saveDC ?? '?'} ${s.saveAbility} save`
|
||||
: 'no roll';
|
||||
return (
|
||||
<button
|
||||
key={s.characterSpellId}
|
||||
className="btn action-option"
|
||||
disabled={noTarget || declare.isPending}
|
||||
title={[s.description, spent ? `No ${slotLevelLabel(s.level)}-level slot left: it can still be declared, but no slot will be spent.` : null].filter(Boolean).join(' — ')}
|
||||
onClick={() => go({ type: 'spell', characterSpellId: s.characterSpellId })}
|
||||
>
|
||||
<span className="action-option-name">
|
||||
✨ {s.name} {spent && <span className="badge badge-hostile">no slot</span>}
|
||||
</span>
|
||||
<span className="combatant-sub mono">
|
||||
{spellLevelLabel(s.level)} · {how}
|
||||
{s.damageDice ? ` · ${s.damageDice}` : ''}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{attacks.map((a) => (
|
||||
<button
|
||||
key={a.index}
|
||||
className="btn action-option is-equipped"
|
||||
disabled={noTarget || declare.isPending}
|
||||
onClick={() => go({ type: 'basic', index: a.index })}
|
||||
>
|
||||
<span className="action-option-name">⚔ {a.name}</span>
|
||||
<span className="combatant-sub mono">
|
||||
{fmtMod(a.attackBonus)} to hit{a.damageDice ? ` · ${a.damageDice}` : ''}
|
||||
{a.damageType ? ` ${a.damageType}` : ''}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{isDM && (
|
||||
<button className={`btn action-option${custom ? ' is-on' : ''}`} onClick={() => setCustom((c) => !c)} title="Spell out an attack, a save or an effect of your own: a trap, a breath weapon, a monster's spell">
|
||||
<span className="action-option-name">✎ Something else…</span>
|
||||
<span className="combatant-sub">a trap, a breath weapon, a monster’s spell</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isDM && custom && (
|
||||
<form
|
||||
className="flex-row flex-wrap gap-sm mt-sm"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target as HTMLFormElement);
|
||||
go({
|
||||
type: 'custom',
|
||||
name: String(fd.get('name') || '').trim() || 'Attack',
|
||||
kind,
|
||||
attackBonus: Number(fd.get('attackBonus')) || 0,
|
||||
saveAbility: (fd.get('saveAbility') as Ability) || 'DEX',
|
||||
saveDC: Number(fd.get('saveDC')) || 10,
|
||||
halfOnSave: fd.get('halfOnSave') === 'on',
|
||||
damageDice: String(fd.get('damageDice') || '').trim() || null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<input name="name" placeholder="What it is (Fire breath)" maxLength={60} aria-label="What it is" style={{ flex: '2 1 140px' }} />
|
||||
<select value={kind} onChange={(e) => setKind(e.target.value as typeof kind)} aria-label="How it lands" style={{ width: 'auto' }}>
|
||||
<option value="ATTACK">Attack roll vs AC</option>
|
||||
<option value="SAVE">Targets make a save</option>
|
||||
<option value="AUTO">It just lands</option>
|
||||
</select>
|
||||
{kind === 'ATTACK' && <NumberInput name="attackBonus" min={-20} max={50} placeholder="+ to hit" aria-label="Attack bonus" style={{ width: 90 }} />}
|
||||
{kind === 'SAVE' && (
|
||||
<>
|
||||
<NumberInput name="saveDC" min={1} max={40} placeholder="DC" aria-label="Save DC" style={{ width: 70 }} />
|
||||
<select name="saveAbility" defaultValue="DEX" aria-label="Save ability" style={{ width: 'auto' }}>
|
||||
{ABILITIES.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{a}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" name="halfOnSave" defaultChecked /> half on a success
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<input name="damageDice" placeholder="Damage (6d6)" maxLength={60} aria-label="Damage dice" style={{ flex: '1 1 100px' }} />
|
||||
<button type="submit" className="btn btn-primary" disabled={noTarget || declare.isPending}>
|
||||
Declare
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The last few attacks and spells that landed, newest first, one line each.
|
||||
export function RecentActions({ actions }: { actions: EncounterAction[] }) {
|
||||
if (actions.length === 0) return <p className="subtitle">Nothing has landed yet.</p>;
|
||||
return (
|
||||
<div className="roll-feed">
|
||||
{actions.map((action) => (
|
||||
<div key={action.id} className="roll-line">
|
||||
<div className="roll-line-head">
|
||||
<span>
|
||||
<strong>{action.actorName}</strong> · {action.name}
|
||||
</span>
|
||||
<span className="combatant-sub">Round {action.round}</span>
|
||||
</div>
|
||||
{action.targets.map((t) => (
|
||||
<div key={t.participantId} className="combatant-sub">
|
||||
→ {t.name}: {t.outcome ? OUTCOME_LABELS[t.outcome].toLowerCase() : '—'}
|
||||
{t.d20 != null && ` (d20 ${t.d20})`}
|
||||
{t.finalDamage ? `, ${t.finalDamage} damage` : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { EncounterParticipant } from '../../api/types';
|
||||
|
||||
const KIND_CLASS = { CHARACTER: 'track-pc', NPC: 'track-npc', MONSTER: 'track-monster' } as const;
|
||||
const KIND_WORD = { CHARACTER: 'player character', NPC: 'NPC', MONSTER: 'monster' } as const;
|
||||
|
||||
// Who acts when, as one line of names: whoever is on turn first, then
|
||||
// everyone still to act this round, a flag where the round ends, and the
|
||||
// whole order again for the round after. Names only, coloured by what they
|
||||
// are (player character, NPC, monster), so the DM can see what is coming.
|
||||
//
|
||||
// Clicking a name marks them as a target when the viewer may pick targets
|
||||
// (the DM, or a player on their own turn); otherwise it goes to their card.
|
||||
export function InitiativeTrack({
|
||||
participants,
|
||||
activeId,
|
||||
round,
|
||||
targetIds,
|
||||
userId,
|
||||
canTarget,
|
||||
onTarget,
|
||||
onJump,
|
||||
}: {
|
||||
participants: EncounterParticipant[]; // those still standing, in initiative order
|
||||
activeId: string | undefined;
|
||||
round: number;
|
||||
targetIds: string[];
|
||||
userId: string | undefined;
|
||||
canTarget: boolean;
|
||||
onTarget: (participantId: string) => void;
|
||||
onJump: (participantId: string) => void;
|
||||
}) {
|
||||
const activeIndex = participants.findIndex((p) => p.id === activeId);
|
||||
// This round: from whoever is on turn to the end of the order.
|
||||
const thisRound = activeIndex >= 0 ? participants.slice(activeIndex) : [];
|
||||
|
||||
const chip = (p: EncounterParticipant, key: string, upcoming: boolean) => {
|
||||
const isActive = !upcoming && p.id === activeId;
|
||||
const isTargeted = targetIds.includes(p.id);
|
||||
const isMine = p.character != null && p.character.userId === userId;
|
||||
const targetable = canTarget && p.id !== activeId;
|
||||
const dying = p.characterCombatInfo?.deathSaves.state === 'DYING';
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`track-chip ${KIND_CLASS[p.kind]}${isActive ? ' is-active' : ''}${isTargeted ? ' is-targeted' : ''}${upcoming ? ' is-upcoming' : ''}${dying ? ' is-dying' : ''}`}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
aria-pressed={targetable ? isTargeted : undefined}
|
||||
title={[
|
||||
`${p.displayName} (${KIND_WORD[p.kind]})`,
|
||||
isActive && 'on turn',
|
||||
dying && 'dying',
|
||||
isTargeted && 'targeted',
|
||||
targetable ? (isTargeted ? 'click to stop targeting' : 'click to target') : 'click to go to their card',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
onClick={() => (targetable ? onTarget(p.id) : onJump(p.id))}
|
||||
>
|
||||
{isActive && <span aria-hidden="true">▶ </span>}
|
||||
{isMine && <span aria-hidden="true">★ </span>}
|
||||
{isTargeted && <span aria-hidden="true">🎯 </span>}
|
||||
{p.displayName}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="initiative-track" aria-label="Turn order: this round, then the next">
|
||||
<span className="track-round">Round {round}</span>
|
||||
{thisRound.map((p) => chip(p, `now-${p.id}`, false))}
|
||||
<span className="track-flag" title={`The round ends here. Round ${round + 1} starts from the top of the order.`}>
|
||||
🏁 Round {round + 1}
|
||||
</span>
|
||||
{participants.map((p) => chip(p, `next-${p.id}`, true))}
|
||||
<span className="track-legend" aria-hidden="true">
|
||||
<span className="track-dot track-pc" /> PC
|
||||
<span className="track-dot track-npc" /> NPC
|
||||
<span className="track-dot track-monster" /> Monster
|
||||
</span>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+532
-4
@@ -612,6 +612,536 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* The battle: the turn-order track, the DM's cue, and attacks being */
|
||||
/* resolved (components/encounter/*.tsx). */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
/* Who acts when, as one line of names: this round, a flag, the next round. */
|
||||
.initiative-track {
|
||||
position: sticky;
|
||||
top: 3.6rem;
|
||||
z-index: 25;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
overflow-x: auto;
|
||||
margin: 0 -0.25rem 0.75rem;
|
||||
padding: 0.5rem 0.25rem;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.track-round,
|
||||
.track-flag {
|
||||
flex: 0 0 auto;
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.track-flag {
|
||||
padding: 0.2rem 0.6rem;
|
||||
margin: 0 0.25rem;
|
||||
border-left: 2px dashed var(--accent);
|
||||
border-right: 2px dashed var(--accent);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
.track-chip {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
border-left-width: 5px;
|
||||
background: var(--bg-inset);
|
||||
color: var(--text);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.track-chip:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
/* What they are, by colour: a player character, an NPC, a monster. */
|
||||
.track-pc {
|
||||
border-color: var(--arcane-strong);
|
||||
background: rgba(92, 127, 176, 0.16);
|
||||
}
|
||||
.track-npc {
|
||||
border-color: var(--success-strong);
|
||||
background: rgba(95, 138, 79, 0.16);
|
||||
}
|
||||
.track-monster {
|
||||
border-color: var(--danger-strong);
|
||||
background: rgba(178, 58, 58, 0.16);
|
||||
}
|
||||
.track-chip.is-active {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
padding: 0.4rem 0.9rem;
|
||||
box-shadow: 0 0 0 2px var(--accent-strong), 0 0 14px rgba(201, 162, 39, 0.35);
|
||||
}
|
||||
.track-chip.is-targeted {
|
||||
box-shadow: 0 0 0 2px var(--danger-strong);
|
||||
}
|
||||
.track-chip.is-upcoming {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.track-chip.is-dying {
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: var(--danger-strong);
|
||||
}
|
||||
.track-legend {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
margin-left: 0.75rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.track-dot {
|
||||
display: inline-block;
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid;
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
|
||||
/* What the scene is waiting on, in a sentence. */
|
||||
.cue-card {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
padding: 0.55rem 0.9rem;
|
||||
border-left: 4px solid var(--accent);
|
||||
}
|
||||
.cue-card.is-urgent {
|
||||
border-color: var(--accent-strong);
|
||||
box-shadow: 0 0 14px rgba(201, 162, 39, 0.2);
|
||||
}
|
||||
.cue-label {
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
.cue-keys {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* An attack or a spell being resolved. */
|
||||
.action-card {
|
||||
border-color: var(--accent);
|
||||
border-left-width: 4px;
|
||||
}
|
||||
.action-card.is-auto {
|
||||
border-color: var(--danger-strong);
|
||||
}
|
||||
.action-card.is-ready {
|
||||
box-shadow: 0 0 14px rgba(122, 178, 102, 0.25);
|
||||
}
|
||||
.action-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.action-target {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
}
|
||||
.action-target-name {
|
||||
font-family: var(--font-display);
|
||||
min-width: 7rem;
|
||||
}
|
||||
.action-roll {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.action-outcome {
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.action-outcome.is-hit {
|
||||
color: var(--danger-strong);
|
||||
border-color: var(--danger-strong);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
.action-outcome.is-crit {
|
||||
color: #1a140a;
|
||||
border-color: var(--accent-strong);
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
.action-outcome.is-miss,
|
||||
.action-outcome.is-saved {
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-inset);
|
||||
}
|
||||
.action-outcome.is-waiting {
|
||||
color: var(--text-dim);
|
||||
border-style: dashed;
|
||||
font-weight: 400;
|
||||
}
|
||||
.action-damage {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
color: var(--danger-strong);
|
||||
}
|
||||
.action-overrule {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
.action-overrule .btn.is-on,
|
||||
.action-option.is-on {
|
||||
border-color: var(--accent-strong);
|
||||
color: var(--accent-strong);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.action-damage-roll {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.action-damage-roll strong {
|
||||
font-size: 1.3rem;
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
.action-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.action-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.1rem;
|
||||
text-align: left;
|
||||
padding: 0.4rem 0.6rem;
|
||||
}
|
||||
.action-option-name {
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
/* The equipped weapon stands out among what can be attacked with. */
|
||||
.action-option.is-equipped {
|
||||
border-color: var(--accent-strong);
|
||||
box-shadow: inset 3px 0 0 var(--accent-strong);
|
||||
}
|
||||
|
||||
/* What whoever is on turn (and anyone targeted) is fighting with. */
|
||||
.in-hand {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px solid var(--accent-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.in-hand-label {
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
.in-hand-weapon {
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.in-hand-weapon .mono {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
/* Quick stats drawer */
|
||||
.drawer-backdrop {
|
||||
position: fixed;
|
||||
@@ -912,11 +1442,9 @@ table.data-table tbody tr:hover {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
/* The DM's attack bar stays in reach while scrolling the initiative order. */
|
||||
/* The DM's quick damage bar. (Attacks being resolved sit above it, so it no
|
||||
longer follows the scroll.) */
|
||||
.attack-bar {
|
||||
position: sticky;
|
||||
top: 4.5rem;
|
||||
z-index: 20;
|
||||
border-color: var(--danger-strong);
|
||||
}
|
||||
.attack-target {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ABILITY_ORDER } from './previewCalc';
|
||||
import { formatMoney } from './format';
|
||||
import type { Monster, Npc } from '../api/types';
|
||||
import type { Ability, Monster, Npc } from '../api/types';
|
||||
|
||||
// One thing in a campaign's lists that opens in a pop-out.
|
||||
export type EntryKind = 'weapon' | 'item' | 'spell' | 'monster' | 'npc';
|
||||
@@ -33,3 +33,25 @@ export function matchesSearch(query: string, ...fields: (string | number | null
|
||||
const text = fields.filter((f) => f !== null && f !== undefined).join(' ').toLowerCase();
|
||||
return words.every((word) => text.includes(word));
|
||||
}
|
||||
|
||||
// How a spell lands in a battle, as one choice: it just takes effect, the
|
||||
// caster rolls a spell attack, or each target makes a save with an ability
|
||||
// (taking half damage on a success, or none). `landingValue` and
|
||||
// `landingFrom` turn it into the value of a select and back.
|
||||
export interface SpellLanding {
|
||||
attackRoll: boolean;
|
||||
saveAbility: Ability | null;
|
||||
halfOnSave: boolean;
|
||||
}
|
||||
|
||||
export function landingValue(spell: SpellLanding): string {
|
||||
if (spell.attackRoll) return 'ATTACK';
|
||||
return spell.saveAbility ? `${spell.saveAbility}:${spell.halfOnSave ? 'HALF' : 'NONE'}` : '';
|
||||
}
|
||||
|
||||
export function landingFrom(value: string): SpellLanding {
|
||||
if (value === 'ATTACK') return { attackRoll: true, saveAbility: null, halfOnSave: false };
|
||||
if (!value) return { attackRoll: false, saveAbility: null, halfOnSave: false };
|
||||
const [ability, half] = value.split(':');
|
||||
return { attackRoll: false, saveAbility: ability as Ability, halfOnSave: half === 'HALF' };
|
||||
}
|
||||
|
||||
@@ -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,15 @@ import {
|
||||
useAddReward,
|
||||
useDeleteReward,
|
||||
useAwardReward,
|
||||
useRolls,
|
||||
useApplyAction,
|
||||
type NewReward,
|
||||
} from '../api/queries';
|
||||
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { RollFeed } from '../components/RollFeed';
|
||||
import { InitiativeTrack } from '../components/encounter/InitiativeTrack';
|
||||
import { ActionCard, ActionComposer, RecentActions } from '../components/encounter/ActionPanel';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { NumberInput } from '../components/NumberInput';
|
||||
import { HpBar } from '../components/HpBar';
|
||||
@@ -49,6 +54,7 @@ import type {
|
||||
CharacterSummary,
|
||||
Currency,
|
||||
Encounter,
|
||||
EncounterAction,
|
||||
EncounterMode,
|
||||
EncounterParticipant,
|
||||
EncounterReward,
|
||||
@@ -296,6 +302,50 @@ function HeadlineChips({ participant: p }: { participant: EncounterParticipant }
|
||||
);
|
||||
}
|
||||
|
||||
// What a combatant is fighting with right now, set apart so it catches the
|
||||
// eye: a character's equipped weapons, or a creature's attacks. Shown on the
|
||||
// card of whoever is on turn and of anyone targeted. A player is not sent a
|
||||
// monster's attacks, so for them a monster shows nothing here.
|
||||
function InHand({ participant: p }: { participant: EncounterParticipant }) {
|
||||
const info = p.characterCombatInfo;
|
||||
if (info) {
|
||||
return (
|
||||
<div className="in-hand" title="Equipped: what they fight with">
|
||||
<span className="in-hand-label">In hand</span>
|
||||
{info.weapons.length === 0 && <span className="combatant-sub">nothing equipped</span>}
|
||||
{info.weapons.map((w) => (
|
||||
<span key={w.characterWeaponId} className="in-hand-weapon">
|
||||
⚔ {w.name}{' '}
|
||||
<span className="mono">
|
||||
{fmtMod(w.attackBonus)} · {w.damageDice} {fmtMod(w.damageBonus)} {w.damageType}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const named = (p.monster?.attacks ?? []).filter((a) => a.name);
|
||||
const stats = p.monster ?? p.npc;
|
||||
const single = stats && (stats.attackBonus != null || stats.damageDice) ? [{ name: 'Attack', attackBonus: stats.attackBonus ?? 0, damageDice: stats.damageDice, count: 1 }] : [];
|
||||
const attacks = named.length > 0 ? named : single;
|
||||
if (attacks.length === 0) return null;
|
||||
return (
|
||||
<div className="in-hand" title="What it attacks with">
|
||||
<span className="in-hand-label">Attacks</span>
|
||||
{attacks.map((a, i) => (
|
||||
<span key={i} className="in-hand-weapon">
|
||||
⚔ {a.name}
|
||||
{(a.count ?? 1) > 1 ? ` ×${a.count}` : ''}{' '}
|
||||
<span className="mono">
|
||||
{fmtMod(a.attackBonus)}
|
||||
{a.damageDice ? ` · ${a.damageDice}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// "2 rd" on a condition that wears off by itself.
|
||||
function roundsNote(p: EncounterParticipant, condition: string): string | undefined {
|
||||
const left = p.conditionRounds?.[condition];
|
||||
@@ -599,6 +649,7 @@ function CombatantCard({
|
||||
</div>
|
||||
|
||||
<HeadlineChips participant={p} />
|
||||
{(isActive || isTargeted) && <InHand participant={p} />}
|
||||
|
||||
{/* An NPC's description is what players are here for, so it is never tucked away. */}
|
||||
{npc?.description && <p style={{ margin: '0.5rem 0 0', whiteSpace: 'pre-wrap' }}>{npc.description}</p>}
|
||||
@@ -862,6 +913,68 @@ function AttackBar({
|
||||
);
|
||||
}
|
||||
|
||||
// The one thing the scene is waiting on, in a sentence: for the DM, what to
|
||||
// do next; for a player, what is theirs to do. `urgent` when it is a roll or a
|
||||
// ruling that holds the fight up. Null when there is nothing to say.
|
||||
function whatNow({
|
||||
encounter,
|
||||
isDM,
|
||||
active,
|
||||
targets,
|
||||
actions,
|
||||
myTurn,
|
||||
unrolled,
|
||||
userId,
|
||||
}: {
|
||||
encounter: Encounter;
|
||||
isDM: boolean;
|
||||
active: EncounterParticipant | undefined;
|
||||
targets: EncounterParticipant[];
|
||||
actions: EncounterAction[];
|
||||
myTurn: boolean;
|
||||
unrolled: number;
|
||||
userId: string | undefined;
|
||||
}): { text: string; urgent: boolean } | null {
|
||||
if (encounter.mode !== 'BATTLE') return null;
|
||||
const names = (list: { name: string }[]) => list.map((t) => t.name).join(', ');
|
||||
if (!isDM) {
|
||||
const mine = (encounter.participants ?? []).filter((p) => p.character?.userId === userId).map((p) => p.id);
|
||||
const save = actions.find((a) => a.kind === 'SAVE' && a.targets.some((t) => t.d20 == null && mine.includes(t.participantId)));
|
||||
if (save) return { text: `${save.actorName}'s ${save.name}: make a ${save.detail.saveAbility} saving throw and enter your d20.`, urgent: true };
|
||||
const own = actions.find((a) => a.actorParticipantId != null && mine.includes(a.actorParticipantId));
|
||||
if (own?.needsRolls) return { text: `Roll your d20 for ${own.name} against ${names(own.targets.filter((t) => t.d20 == null))}.`, urgent: true };
|
||||
if (own?.needsDamage) return { text: `It hits! Roll the damage for ${own.name}.`, urgent: true };
|
||||
if (own?.ready) return { text: `${own.name} is ready: your DM lets it land.`, urgent: false };
|
||||
if (myTurn) return targets.length === 0 ? { text: 'Your turn! Pick a target: click a name on the track above.', urgent: true } : { text: `Your turn: choose what you attack ${names(targets.map((t) => ({ name: t.displayName })))} with.`, urgent: true };
|
||||
return null;
|
||||
}
|
||||
if (encounter.status === 'PLANNING') {
|
||||
return { text: unrolled > 0 ? `Setting up: ${unrolled} still to roll initiative. Then press Begin Battle.` : 'Everyone has rolled initiative. Press Begin Battle.', urgent: false };
|
||||
}
|
||||
if (encounter.status !== 'ACTIVE') return null;
|
||||
const pending = actions[0];
|
||||
if (pending) {
|
||||
const who = `${pending.actorName}'s ${pending.name}`;
|
||||
const waiting = pending.targets.filter((t) => !t.outcome);
|
||||
if (pending.needsRolls > 0) {
|
||||
return pending.kind === 'SAVE'
|
||||
? { text: `${who}: ${waiting.length} saving throw${waiting.length === 1 ? '' : 's'} to enter (${pending.detail.saveAbility}, DC ${pending.detail.saveDC}) for ${names(waiting)}.`, urgent: true }
|
||||
: { text: `${who}: enter the d20 against ${names(waiting)}${waiting[0]?.ac != null ? ` (AC ${waiting[0].ac + waiting[0].penalty})` : ''}.`, urgent: true };
|
||||
}
|
||||
if (pending.needsDamage) return { text: `${who} hits: roll or enter the damage.`, urgent: true };
|
||||
const result = pending.targets.map((t) => `${t.name}: ${t.outcome?.toLowerCase() ?? '—'}${t.finalDamage ? ` for ${t.finalDamage}` : ''}`).join('; ');
|
||||
return { text: `${who} is ready (${result}). ${pending.auto ? 'Accept, change or discard it.' : 'Apply it, or overrule anything first.'}`, urgent: true };
|
||||
}
|
||||
if (unrolled > 0) return { text: `${unrolled} combatant${unrolled === 1 ? ' has' : 's have'} no initiative roll yet.`, urgent: false };
|
||||
if (!active) return null;
|
||||
if (targets.length === 0) {
|
||||
return active.character
|
||||
? { text: `${active.displayName}'s turn: waiting for them to pick a target (or pick one for them: click a name).`, urgent: false }
|
||||
: { text: `${active.displayName}'s turn: click a name to pick its target, or press Next Turn.`, urgent: false };
|
||||
}
|
||||
return { text: `${active.displayName} → ${names(targets.map((t) => ({ name: t.displayName })))}: choose the attack below, or apply damage by hand.`, urgent: false };
|
||||
}
|
||||
|
||||
const RATING_LABELS = { TRIVIAL: 'Trivial', EASY: 'Easy', MEDIUM: 'Medium', HARD: 'Hard', DEADLY: 'Deadly' } as const;
|
||||
const RATING_BADGES = { TRIVIAL: '', EASY: 'badge-friendly', MEDIUM: 'badge-dm', HARD: 'badge-hostile', DEADLY: 'badge-hostile' } as const;
|
||||
|
||||
@@ -1182,6 +1295,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);
|
||||
@@ -1198,6 +1313,7 @@ export function EncounterPage() {
|
||||
const nextTurn = useNextTurn(encounterId);
|
||||
const setTargets = useSetTargets(encounterId);
|
||||
const revive = useReviveParticipant(encounterId);
|
||||
const applyAction = useApplyAction(encounterId);
|
||||
const patchEncounter = usePatchEncounter(encounterId);
|
||||
const setArchived = useSetEncounterArchived(encounter?.campaignId ?? '');
|
||||
const deleteEncounter = useDeleteEncounter(encounter?.campaignId ?? '');
|
||||
@@ -1224,12 +1340,40 @@ export function EncounterPage() {
|
||||
// The combatant whose stats are kept in view in place of whoever is on turn.
|
||||
const [pinnedId, setPinnedId] = useState<string | null>(null);
|
||||
|
||||
// The DM's keys: N passes the turn, S opens the sheet of whoever is on turn.
|
||||
// The DM's keys: N passes the turn, S opens the sheet of whoever is on turn,
|
||||
// A accepts the first attack that is ready. For whoever may pick targets
|
||||
// (the DM, or a player on their own turn), 1 to 9 mark the combatants in
|
||||
// turn order, leaving out whoever is on turn.
|
||||
const onTurn = encounter?.participants?.find((p) => p.id === encounter.activeParticipantId);
|
||||
const dmRunning = isDM && encounter?.mode === 'BATTLE' && encounter.status === 'ACTIVE';
|
||||
const running = encounter?.mode === 'BATTLE' && encounter.status === 'ACTIVE';
|
||||
const dmRunning = isDM && running;
|
||||
const marked = encounter?.targetParticipantIds ?? [];
|
||||
const mayMark = isDM || (running && onTurn?.character != null && onTurn.character.userId === user?.id);
|
||||
const byKey = (encounter?.participants ?? []).filter((p) => !p.hidden && !(running && p.id === encounter?.activeParticipantId)).slice(0, 9);
|
||||
const readyAction = (encounter?.actions ?? []).find((a) => a.ready);
|
||||
useHotkeys({
|
||||
n: dmRunning && !nextTurn.isPending ? () => nextTurn.mutate(undefined, turnPassed) : undefined,
|
||||
s: dmRunning && onTurn ? () => (onTurn.character ? openCharacterPanel(onTurn.character.id) : setPinnedId(onTurn.id)) : undefined,
|
||||
a:
|
||||
isDM && readyAction && !applyAction.isPending
|
||||
? () =>
|
||||
applyAction.mutate(readyAction.id, {
|
||||
onError: (err: unknown) => setFormError(extractErrorMessage(err)),
|
||||
onSuccess: (data) =>
|
||||
notify(
|
||||
...data.concentration.map((c) => `${c.name} is concentrating and took ${c.damage} damage: Constitution save, DC ${c.dc}. On a failure the spell ends: remove Concentrating.`),
|
||||
...(data.slotNote ? [data.slotNote] : [])
|
||||
),
|
||||
})
|
||||
: undefined,
|
||||
...Object.fromEntries(
|
||||
byKey.map((p, i) => [
|
||||
String(i + 1),
|
||||
mayMark
|
||||
? () => setTargets.mutate(marked.includes(p.id) ? marked.filter((id) => id !== p.id) : [...marked, p.id], { onError: (err: unknown) => setFormError(extractErrorMessage(err)) })
|
||||
: undefined,
|
||||
])
|
||||
),
|
||||
});
|
||||
|
||||
if (isLoading) return <Spinner label="Setting the scene…" />;
|
||||
@@ -1257,6 +1401,14 @@ export function EncounterPage() {
|
||||
const builtCharacters = characters?.filter((c) => c.buildStage === 'COMPLETE') ?? [];
|
||||
const onError = (err: unknown) => setFormError(extractErrorMessage(err));
|
||||
const fail = { onError };
|
||||
const toggleTarget = (participantId: string) =>
|
||||
setTargets.mutate(targetIds.includes(participantId) ? targetIds.filter((id) => id !== participantId) : [...targetIds, participantId], fail);
|
||||
// Attacks and spells being resolved, and whether it is this viewer's own character's turn.
|
||||
const actions = encounter.actions ?? [];
|
||||
const myTurn = active?.character != null && active.character.userId === user?.id;
|
||||
const diceMode = campaignData?.campaign.diceMode ?? 'BOTH';
|
||||
// What the DM is waiting on right now, in one line; for a player, what is theirs to do.
|
||||
const cue = whatNow({ encounter, isDM, active, targets, actions, myTurn, unrolled: participants.filter((p) => p.initiativeRoll == null && !p.hidden).length, userId: user?.id });
|
||||
|
||||
const pickOptions: { id: string; name: string }[] =
|
||||
pickType === 'character'
|
||||
@@ -1314,7 +1466,7 @@ export function EncounterPage() {
|
||||
onTarget={
|
||||
// Nobody targets themselves: the combatant on turn has no button.
|
||||
canTarget && p.id !== active?.id
|
||||
? () => setTargets.mutate(targetIds.includes(p.id) ? targetIds.filter((id) => id !== p.id) : [...targetIds, p.id], fail)
|
||||
? () => toggleTarget(p.id)
|
||||
: undefined
|
||||
}
|
||||
isPinned={p.id === pinnedId}
|
||||
@@ -1478,8 +1630,26 @@ export function EncounterPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{standing.length > 1 && (
|
||||
<TurnStrip participants={standing} activeId={active?.id} targetIds={targetIds} userId={user?.id} hasActed={hasActed} />
|
||||
{active ? (
|
||||
<InitiativeTrack
|
||||
participants={standing}
|
||||
activeId={active.id}
|
||||
round={encounter.currentRound}
|
||||
targetIds={targetIds}
|
||||
userId={user?.id}
|
||||
canTarget={canTarget}
|
||||
onTarget={toggleTarget}
|
||||
onJump={jumpToCard}
|
||||
/>
|
||||
) : (
|
||||
standing.length > 1 && <TurnStrip participants={standing} activeId={undefined} targetIds={targetIds} userId={user?.id} hasActed={hasActed} />
|
||||
)}
|
||||
{cue && (
|
||||
<div className={`card cue-card${cue.urgent ? ' is-urgent' : ''}`} role="status">
|
||||
<span className="cue-label">{isDM ? 'What now' : 'Your move'}</span>
|
||||
<span>{cue.text}</span>
|
||||
{isDM && running && <span className="combatant-sub cue-keys">Keys: 1–9 target · A accept · N next turn · S sheet</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isDM || encounter.description) && (
|
||||
@@ -1510,6 +1680,22 @@ export function EncounterPage() {
|
||||
|
||||
<div className="encounter-layout mt-md">
|
||||
<div>
|
||||
{actions.map((action) => (
|
||||
<ActionCard
|
||||
key={action.id}
|
||||
action={action}
|
||||
participants={participants}
|
||||
isDM={isDM}
|
||||
userId={user?.id}
|
||||
diceMode={diceMode}
|
||||
encounterId={encounterId}
|
||||
onNotice={notify}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
{encounter.status === 'ACTIVE' && isBattle && (isDM || myTurn) && (active || isDM) && (
|
||||
<ActionComposer actor={active} targetCount={targets.length} isDM={isDM} encounterId={encounterId} onError={onError} />
|
||||
)}
|
||||
{isDM && targets.length > 0 && (
|
||||
<AttackBar
|
||||
attacker={active}
|
||||
@@ -1654,6 +1840,27 @@ export function EncounterPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBattle && (encounter.recentActions ?? []).length > 0 && (
|
||||
<details className="card" open>
|
||||
<summary className="card-title" style={{ cursor: 'pointer', marginBottom: 0 }}>
|
||||
⚔ What just happened
|
||||
</summary>
|
||||
<div className="mt-sm">
|
||||
<RecentActions actions={encounter.recentActions ?? []} />
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
{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.27.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.25.0",
|
||||
"version": "1.27.0",
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend"
|
||||
]
|
||||
},
|
||||
"backend": {
|
||||
"version": "1.25.0",
|
||||
"version": "1.27.0",
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.0",
|
||||
"@prisma/client": "^7.10.0",
|
||||
@@ -33,7 +33,7 @@
|
||||
}
|
||||
},
|
||||
"frontend": {
|
||||
"version": "1.25.0",
|
||||
"version": "1.27.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.27.0",
|
||||
"private": true,
|
||||
"description": "D&D 5e campaign manager — monorepo root",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user