Compare commits

...
1 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 9b3637576d V1.27.0: battles that run themselves: track, targets, hit or miss, saves, auto-rolls
- Turn-order track: names coloured by PC, NPC or monster, this round, a flag
  where it ends, and the next round
- Target by clicking a name on the track, the card, or keys 1-9; whoever is
  on turn and every target show what they have in hand
- Attacks and spells are declared, rolled and applied: type or roll the d20
  and the app says hit, miss or critical; a save spell asks each target for
  its save against the caster's DC; the DM overrules anything and applies
- Critical damage by the campaign's rule; a spell spends its slot; a
  concentration save is called for
- Auto-rolled monster and NPC attacks: aimed (weighted), rolled, and offered
  to the DM to accept, edit or discard; hidden from players until accepted
- Monsters can have named attacks and multiattacks; spells say how they land
- A "What now" line tells the DM (and each player) what the scene waits on

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:14:04 +02:00
29 changed files with 2693 additions and 38 deletions
+9
View File
@@ -99,6 +99,15 @@ 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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.26.0",
"version": "1.27.0",
"private": true,
"type": "commonjs",
"main": "src/index.js",
@@ -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');
+50
View File
@@ -656,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[]
@@ -699,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[]
@@ -769,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).
+175
View File
@@ -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 };
+142
View File
@@ -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([]);
});
});
+57
View File
@@ -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 };
+199
View File
@@ -3848,3 +3848,202 @@ describe('dice rolls', () => {
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);
});
});
+19
View File
@@ -645,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) => {
@@ -772,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) => {
+2 -1
View File
@@ -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.
+362 -2
View File
@@ -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
// ---------------------------------------------------------------------------
+17
View File
@@ -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(),
}),
},
};
+173
View File
@@ -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 };
+131 -11
View File
@@ -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
View File
@@ -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)
+19
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.26.0",
"version": "1.27.0",
"type": "module",
"scripts": {
"dev": "vite",
+35
View File
@@ -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,
@@ -984,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) {
+92
View File
@@ -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
}
@@ -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>
+72 -2
View File
@@ -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,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>
);
}
+269 -4
View File
@@ -875,6 +875,273 @@ input[type='radio'] {
}
}
/* ---------------------------------------------------------------------- */
/* 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;
@@ -1175,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 {
+23 -1
View File
@@ -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' };
}
+197 -5
View File
@@ -25,11 +25,14 @@ import {
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';
@@ -51,6 +54,7 @@ import type {
CharacterSummary,
Currency,
Encounter,
EncounterAction,
EncounterMode,
EncounterParticipant,
EncounterReward,
@@ -298,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];
@@ -601,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>}
@@ -864,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;
@@ -1202,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 ?? '');
@@ -1228,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…" />;
@@ -1261,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'
@@ -1318,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}
@@ -1482,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) && (
@@ -1514,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}
@@ -1658,6 +1840,16 @@ 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 }}>
+4 -4
View File
@@ -1,19 +1,19 @@
{
"name": "dnd-campaign-manager",
"version": "1.26.0",
"version": "1.27.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dnd-campaign-manager",
"version": "1.26.0",
"version": "1.27.0",
"workspaces": [
"backend",
"frontend"
]
},
"backend": {
"version": "1.26.0",
"version": "1.27.0",
"dependencies": {
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
@@ -33,7 +33,7 @@
}
},
"frontend": {
"version": "1.26.0",
"version": "1.27.0",
"dependencies": {
"@tanstack/react-query": "^5.103.2",
"axios": "^1.20.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dnd-campaign-manager",
"version": "1.26.0",
"version": "1.27.0",
"private": true,
"description": "D&D 5e campaign manager — monorepo root",
"workspaces": [