Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
555765916f | ||
|
|
9b3637576d |
@@ -99,6 +99,21 @@ including a character sheet or an encounter) and **Account**.
|
||||
checklist at level 2 or 3, and shown beside the class everywhere; the DM can
|
||||
change it from the sheet, and switch a 2014 path to its 2024 update. Every
|
||||
sheet shows **the road ahead**: what each level from 1 to 20 gives.
|
||||
- **Free roam**, a third kind of encounter beside battles and quests: the DM
|
||||
ticks the nearby locations, the whole party is in it from the start, and
|
||||
each player sends their own character to a place to meet who is found there
|
||||
and shop (a place's own shop, or an NPC's wares) where they stand.
|
||||
- **Completed encounters are hidden** from the campaign's list until "Show
|
||||
completed" is ticked.
|
||||
- **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,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.26.0",
|
||||
"version": "1.28.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');
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Free roam: a kind of encounter where the party wanders between places the
|
||||
-- DM has laid out. A location can have NPCs found there and things for sale.
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "EncounterMode" ADD VALUE 'FREE_ROAM';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "npcs" ADD COLUMN "locationId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "sale_offers" ADD COLUMN "locationId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "encounter_locations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"encounterId" TEXT NOT NULL,
|
||||
"locationId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "encounter_locations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "encounter_locations_encounterId_locationId_key" ON "encounter_locations"("encounterId", "locationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sale_offers_locationId_idx" ON "sale_offers"("locationId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "npcs" ADD CONSTRAINT "npcs_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_locations" ADD CONSTRAINT "encounter_locations_encounterId_fkey" FOREIGN KEY ("encounterId") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_locations" ADD CONSTRAINT "encounter_locations_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -134,11 +134,13 @@ enum EncounterStatus {
|
||||
COMPLETE
|
||||
}
|
||||
|
||||
// What kind of scene an encounter is: a fight run in initiative order, or a
|
||||
// quest scene built around NPCs and an objective.
|
||||
// What kind of scene an encounter is: a fight run in initiative order, a
|
||||
// quest scene built around NPCs and an objective, or free roam, where the
|
||||
// party wanders between places the DM has laid out (to shop, to meet people).
|
||||
enum EncounterMode {
|
||||
BATTLE
|
||||
QUEST
|
||||
FREE_ROAM
|
||||
}
|
||||
|
||||
enum NpcDisposition {
|
||||
@@ -335,6 +337,10 @@ model Location {
|
||||
characters Character[]
|
||||
encounters Encounter[]
|
||||
visits CharacterLocationVisit[]
|
||||
// Who is found here, what is for sale here, and the free-roam scenes it is a stop in.
|
||||
npcs Npc[]
|
||||
wares SaleOffer[]
|
||||
freeRoams EncounterLocation[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@map("locations")
|
||||
@@ -656,6 +662,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 +712,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[]
|
||||
@@ -725,9 +743,13 @@ model Npc {
|
||||
// for exchangeCash cash, either way. Both null: this NPC does not exchange.
|
||||
exchangeGold Int?
|
||||
exchangeCash Int?
|
||||
// Where this NPC is usually found. In a free-roam scene they are met (and
|
||||
// traded with) by whoever goes there.
|
||||
locationId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
||||
encounterAppearances EncounterParticipant[]
|
||||
wares SaleOffer[]
|
||||
|
||||
@@ -769,11 +791,66 @@ model Encounter {
|
||||
participants EncounterParticipant[]
|
||||
rewards EncounterReward[]
|
||||
wares SaleOffer[]
|
||||
actions EncounterAction[]
|
||||
// Free roam: the places the party can go to in this scene.
|
||||
places EncounterLocation[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@map("encounters")
|
||||
}
|
||||
|
||||
// A place that is a stop in a free-roam scene: one of the campaign's
|
||||
// locations, picked by the DM as somewhere the party can go right now.
|
||||
model EncounterLocation {
|
||||
id String @id @default(cuid())
|
||||
encounterId String
|
||||
locationId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
|
||||
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([encounterId, locationId])
|
||||
@@map("encounter_locations")
|
||||
}
|
||||
|
||||
// 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).
|
||||
@@ -801,15 +878,17 @@ model EncounterReward {
|
||||
}
|
||||
|
||||
// Something for sale: a weapon or item from the campaign pools, at a price.
|
||||
// It belongs to an NPC (and is on offer wherever that NPC is met) or to an
|
||||
// encounter itself (a stall, a vending machine). Only the DM sets these up;
|
||||
// players see them, and buy, in a quest encounter in progress
|
||||
// (encounters.routes.js).
|
||||
// It belongs to an NPC (and is on offer wherever that NPC is met), to an
|
||||
// encounter itself (a stall, a vending machine), or to a location (a shop
|
||||
// that is always there, open to whoever goes to it in a free-roam scene).
|
||||
// Only the DM sets these up; players see them, and buy, in a quest or
|
||||
// free-roam encounter in progress (encounters.routes.js).
|
||||
model SaleOffer {
|
||||
id String @id @default(cuid())
|
||||
campaignId String
|
||||
npcId String? // exactly one of npcId / encounterId is set
|
||||
npcId String? // exactly one of npcId / encounterId / locationId is set
|
||||
encounterId String?
|
||||
locationId String?
|
||||
weaponId String? // exactly one of weaponId / itemId is set
|
||||
itemId String?
|
||||
price Int // for one, in `currency`
|
||||
@@ -821,12 +900,14 @@ model SaleOffer {
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
npc Npc? @relation(fields: [npcId], references: [id], onDelete: Cascade)
|
||||
encounter Encounter? @relation(fields: [encounterId], references: [id], onDelete: Cascade)
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||||
weapon Weapon? @relation(fields: [weaponId], references: [id], onDelete: Cascade)
|
||||
item Item? @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([campaignId])
|
||||
@@index([npcId])
|
||||
@@index([encounterId])
|
||||
@@index([locationId])
|
||||
@@map("sale_offers")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// Combat: how an attack or a spell is resolved in a battle. An "action" is
|
||||
// one attack or spell from being declared to its damage landing
|
||||
// (EncounterAction): who it is aimed at, the d20 rolled against each target
|
||||
// (or each target's saving throw), and the damage. Pure: no DB calls. The
|
||||
// random source can be passed in for tests.
|
||||
|
||||
const { parseDice, leadingDice, rollExpression, rollCritical, rollDie } = require('./dice');
|
||||
|
||||
// What an attack roll comes to against a target. A natural 20 is a critical
|
||||
// hit and a natural 1 a miss, whatever the numbers; otherwise the d20 plus the
|
||||
// bonus has to reach the AC (with any area-attack penalty added).
|
||||
function attackOutcome(d20, attackBonus, ac, penalty = 0) {
|
||||
if (d20 === 20) return 'CRIT';
|
||||
if (d20 === 1) return 'FUMBLE';
|
||||
return d20 + attackBonus >= ac + penalty ? 'HIT' : 'MISS';
|
||||
}
|
||||
|
||||
// Whether a saving throw succeeds: the d20 plus the bonus reaches the DC.
|
||||
const saveSucceeds = (d20, saveBonus, dc) => d20 + saveBonus >= dc;
|
||||
|
||||
const lands = (outcome) => outcome === 'HIT' || outcome === 'CRIT';
|
||||
|
||||
// A target as the dice have it (or as the DM has overruled them), with the
|
||||
// damage it takes. `outcome` and `damage` on the target are the DM's
|
||||
// overrides; null leaves it to the dice.
|
||||
// ATTACK: total, outcome HIT | MISS | CRIT | FUMBLE (null until a d20 is in).
|
||||
// SAVE: total, outcome SAVED | FAILED (null until the save is in).
|
||||
// AUTO: outcome HIT.
|
||||
// `finalDamage` is null until it can be known.
|
||||
function settleTarget(action, target) {
|
||||
const { kind, detail } = action;
|
||||
const base = action.damage ? action.damage.total : null;
|
||||
const crit = action.damage && action.damage.crit ? action.damage.crit.total : null;
|
||||
const noDamage = !detail.damageDice && base == null;
|
||||
let total = null;
|
||||
let rolled = null; // what the dice say, before any override
|
||||
if (kind === 'ATTACK') {
|
||||
if (target.d20 != null) {
|
||||
total = target.d20 + (detail.attackBonus || 0);
|
||||
rolled = attackOutcome(target.d20, detail.attackBonus || 0, target.ac ?? 10, target.penalty || 0);
|
||||
}
|
||||
} else if (kind === 'SAVE') {
|
||||
if (target.d20 != null) {
|
||||
total = target.d20 + (target.saveBonus || 0);
|
||||
rolled = saveSucceeds(target.d20, target.saveBonus || 0, detail.saveDC || 10) ? 'SAVED' : 'FAILED';
|
||||
}
|
||||
} else {
|
||||
rolled = 'HIT';
|
||||
}
|
||||
const outcome = target.outcome || rolled;
|
||||
|
||||
let finalDamage = null;
|
||||
if (target.damage != null) finalDamage = target.damage;
|
||||
else if (outcome === 'MISS' || outcome === 'FUMBLE') finalDamage = 0;
|
||||
else if (noDamage) finalDamage = outcome ? 0 : null;
|
||||
else if (outcome && base != null) {
|
||||
if (outcome === 'CRIT') finalDamage = crit != null ? crit : base;
|
||||
else if (outcome === 'SAVED') finalDamage = detail.halfOnSave ? Math.floor(base / 2) : 0;
|
||||
else finalDamage = base;
|
||||
}
|
||||
return { ...target, total, rolled, outcome, overruled: Boolean(target.outcome) && target.outcome !== rolled, finalDamage };
|
||||
}
|
||||
|
||||
// An action with every target settled, and what it is still waiting for:
|
||||
// `needsRolls` (targets with no outcome yet), `needsDamage` (someone is hit
|
||||
// and the damage is not known), `needsCrit` (someone is critically hit and the
|
||||
// critical damage has not been rolled), and `ready` when none of those.
|
||||
function settle(action) {
|
||||
const targets = (action.targets || []).map((target) => settleTarget(action, target));
|
||||
const needsRolls = targets.filter((t) => !t.outcome).length;
|
||||
const hurts = targets.some((t) => t.damage == null && (lands(t.outcome) || t.outcome === 'FAILED' || (t.outcome === 'SAVED' && action.detail.halfOnSave)));
|
||||
const hasDice = Boolean(action.detail.damageDice);
|
||||
const needsDamage = hasDice && hurts && !action.damage;
|
||||
const needsCrit = hasDice && Boolean(action.damage) && !action.damage.crit && !action.damage.entered && targets.some((t) => t.outcome === 'CRIT' && t.damage == null);
|
||||
return { ...action, targets, needsRolls, needsDamage, needsCrit, ready: needsRolls === 0 && !needsDamage && targets.length > 0 };
|
||||
}
|
||||
|
||||
// Rolls an action's damage: its dice plus its bonus, and, when a target is
|
||||
// critically hit, the critical damage by the campaign's rule. Null for an
|
||||
// action with no dice (or dice that can't be read).
|
||||
function rollDamage(detail, { critical = false, critRule = 'DOUBLE_DICE', roll = rollDie } = {}) {
|
||||
const dice = leadingDice(detail.damageDice || '');
|
||||
const parsed = dice ? parseDice(dice) : null;
|
||||
if (!parsed) return null;
|
||||
const withBonus = { dice: parsed.dice, flat: parsed.flat + (detail.damageBonus || 0) };
|
||||
const normal = rollExpression(withBonus, { roll });
|
||||
return {
|
||||
total: Math.max(0, normal.total),
|
||||
dice: normal.dice,
|
||||
modifier: normal.modifier,
|
||||
crit: critical ? critOf(withBonus, critRule, roll) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function critOf(parsed, critRule, roll) {
|
||||
const crit = rollCritical(parsed, critRule, { roll });
|
||||
return { total: Math.max(0, crit.total), dice: crit.dice, modifier: crit.modifier };
|
||||
}
|
||||
|
||||
// The critical damage alone, for an action whose normal damage is already rolled.
|
||||
function rollCritDamage(detail, { critRule = 'DOUBLE_DICE', roll = rollDie } = {}) {
|
||||
const dice = leadingDice(detail.damageDice || '');
|
||||
const parsed = dice ? parseDice(dice) : null;
|
||||
if (!parsed) return null;
|
||||
return critOf({ dice: parsed.dice, flat: parsed.flat + (detail.damageBonus || 0) }, critRule, roll);
|
||||
}
|
||||
|
||||
// Which side a combatant fights on: the party (characters and friendly NPCs),
|
||||
// its enemies (monsters and hostile NPCs), or neither (a neutral NPC).
|
||||
// Takes { kind: 'CHARACTER' | 'MONSTER' | 'NPC', disposition? }.
|
||||
function sideOf(combatant) {
|
||||
if (combatant.kind === 'CHARACTER') return 'PARTY';
|
||||
if (combatant.kind === 'MONSTER') return 'ENEMY';
|
||||
if (combatant.disposition === 'FRIENDLY') return 'PARTY';
|
||||
if (combatant.disposition === 'HOSTILE') return 'ENEMY';
|
||||
return 'NEUTRAL';
|
||||
}
|
||||
|
||||
// Who a combatant acting by itself could go for: everyone standing on the
|
||||
// other side. A neutral has no enemies.
|
||||
function opponentsOf(actor, combatants) {
|
||||
const side = sideOf(actor);
|
||||
if (side === 'NEUTRAL') return [];
|
||||
return combatants.filter((c) => c.id !== actor.id && !c.fallen && sideOf(c) !== 'NEUTRAL' && sideOf(c) !== side);
|
||||
}
|
||||
|
||||
// How likely each opponent is to be picked, relative to the others. Everyone
|
||||
// starts equal; whoever the actor went for last, and whoever has hurt it,
|
||||
// count for more (a creature keeps at what it is fighting), and so, a little,
|
||||
// does someone easier to hit.
|
||||
function targetWeights(candidates, { lastTargetId = null, hurtBy = [] } = {}) {
|
||||
return candidates.map((c) => {
|
||||
let weight = 2;
|
||||
if (c.id === lastTargetId) weight += 3;
|
||||
if (hurtBy.includes(c.id)) weight += 3;
|
||||
weight += Math.max(0, Math.min(2, (16 - (c.ac ?? 12)) / 3));
|
||||
return weight;
|
||||
});
|
||||
}
|
||||
|
||||
// Picks one opponent at random by those weights; null when there is nobody.
|
||||
// `random` gives a number from 0 (inclusive) to 1 (exclusive).
|
||||
function pickTarget(candidates, context = {}, random = Math.random) {
|
||||
if (candidates.length === 0) return null;
|
||||
const weights = targetWeights(candidates, context);
|
||||
let at = random() * weights.reduce((sum, w) => sum + w, 0);
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
at -= weights[i];
|
||||
if (at < 0) return candidates[i];
|
||||
}
|
||||
return candidates[candidates.length - 1];
|
||||
}
|
||||
|
||||
// The attacks a monster or NPC makes on its turn, one entry per attack made
|
||||
// (a multiattack's `count` is spread out): [{ name, attackBonus, damageDice,
|
||||
// damageType }]. A monster's named `attacks` when it has any, otherwise its
|
||||
// single attack; an NPC's single attack. Empty for one with nothing to hit with.
|
||||
function attacksOf({ monster = null, npc = null }) {
|
||||
const named = monster && Array.isArray(monster.attacks) ? monster.attacks.filter((a) => a && a.name) : [];
|
||||
if (named.length > 0) {
|
||||
return named.flatMap((a) =>
|
||||
Array.from({ length: Math.max(1, Math.min(10, Number(a.count) || 1)) }, () => ({
|
||||
name: a.name,
|
||||
attackBonus: Number(a.attackBonus) || 0,
|
||||
damageDice: a.damageDice || null,
|
||||
damageType: a.damageType || null,
|
||||
}))
|
||||
);
|
||||
}
|
||||
const stats = monster || npc;
|
||||
if (!stats || (stats.attackBonus == null && !stats.damageDice)) return [];
|
||||
return [{ name: 'Attack', attackBonus: stats.attackBonus || 0, damageDice: stats.damageDice || null, damageType: null }];
|
||||
}
|
||||
|
||||
module.exports = { attackOutcome, saveSucceeds, lands, settleTarget, settle, rollDamage, rollCritDamage, sideOf, opponentsOf, targetWeights, pickTarget, attacksOf };
|
||||
@@ -0,0 +1,142 @@
|
||||
const { attackOutcome, saveSucceeds, settle, rollDamage, rollCritDamage, sideOf, opponentsOf, targetWeights, pickTarget, attacksOf } = require('./combat');
|
||||
|
||||
const fixed = (...faces) => {
|
||||
let i = 0;
|
||||
return () => faces[i++ % faces.length];
|
||||
};
|
||||
|
||||
describe('an attack roll', () => {
|
||||
test('hits when the d20 plus the bonus reaches the AC', () => {
|
||||
expect(attackOutcome(10, 5, 15)).toBe('HIT');
|
||||
expect(attackOutcome(9, 5, 15)).toBe('MISS');
|
||||
});
|
||||
|
||||
test('a natural 20 always hits, critically, and a natural 1 always misses', () => {
|
||||
expect(attackOutcome(20, -5, 30)).toBe('CRIT');
|
||||
expect(attackOutcome(1, 20, 5)).toBe('FUMBLE');
|
||||
});
|
||||
|
||||
test('an area attack counts each AC higher', () => {
|
||||
expect(attackOutcome(10, 5, 15, 2)).toBe('MISS');
|
||||
expect(attackOutcome(12, 5, 15, 2)).toBe('HIT');
|
||||
});
|
||||
});
|
||||
|
||||
test('a saving throw succeeds on the DC or more', () => {
|
||||
expect(saveSucceeds(12, 3, 15)).toBe(true);
|
||||
expect(saveSucceeds(11, 3, 15)).toBe(false);
|
||||
});
|
||||
|
||||
describe('settling an action', () => {
|
||||
const attack = (targets, damage = null) => ({ kind: 'ATTACK', detail: { attackBonus: 5, damageDice: '1d8', damageBonus: 3 }, targets, damage });
|
||||
|
||||
test('waits for a d20 for each target, then for the damage', () => {
|
||||
const waiting = settle(attack([{ participantId: 'a', ac: 15, d20: null }]));
|
||||
expect(waiting).toMatchObject({ needsRolls: 1, ready: false });
|
||||
const hit = settle(attack([{ participantId: 'a', ac: 15, d20: 12 }]));
|
||||
expect(hit.targets[0]).toMatchObject({ total: 17, outcome: 'HIT', finalDamage: null });
|
||||
expect(hit).toMatchObject({ needsRolls: 0, needsDamage: true, ready: false });
|
||||
const done = settle(attack([{ participantId: 'a', ac: 15, d20: 12 }], { total: 7 }));
|
||||
expect(done.targets[0].finalDamage).toBe(7);
|
||||
expect(done.ready).toBe(true);
|
||||
});
|
||||
|
||||
test('a miss needs no damage at all', () => {
|
||||
const miss = settle(attack([{ participantId: 'a', ac: 15, d20: 4 }]));
|
||||
expect(miss.targets[0]).toMatchObject({ outcome: 'MISS', finalDamage: 0 });
|
||||
expect(miss).toMatchObject({ needsDamage: false, ready: true });
|
||||
});
|
||||
|
||||
test('a critical hit takes the critical damage, once it is rolled', () => {
|
||||
const crit = settle(attack([{ participantId: 'a', ac: 15, d20: 20 }], { total: 7 }));
|
||||
expect(crit.needsCrit).toBe(true);
|
||||
expect(settle(attack([{ participantId: 'a', ac: 15, d20: 20 }], { total: 7, crit: { total: 13 } })).targets[0].finalDamage).toBe(13);
|
||||
});
|
||||
|
||||
test('the DM can overrule the dice, and set a target’s damage by hand', () => {
|
||||
const overruled = settle(attack([{ participantId: 'a', ac: 15, d20: 4, outcome: 'HIT' }, { participantId: 'b', ac: 12, d20: 15, damage: 2 }], { total: 9 }));
|
||||
expect(overruled.targets[0]).toMatchObject({ rolled: 'MISS', outcome: 'HIT', overruled: true, finalDamage: 9 });
|
||||
expect(overruled.targets[1]).toMatchObject({ outcome: 'HIT', overruled: false, finalDamage: 2 });
|
||||
});
|
||||
|
||||
test('a save spell deals full damage on a failure, half or none on a success', () => {
|
||||
const spell = (halfOnSave) =>
|
||||
settle({
|
||||
kind: 'SAVE',
|
||||
detail: { saveAbility: 'DEX', saveDC: 15, halfOnSave, damageDice: '8d6' },
|
||||
targets: [{ participantId: 'a', saveBonus: 2, d20: 13 }, { participantId: 'b', saveBonus: 2, d20: 12 }, { participantId: 'c', saveBonus: 0, d20: null }],
|
||||
damage: { total: 27 },
|
||||
});
|
||||
const fireball = spell(true);
|
||||
expect(fireball.targets.map((t) => [t.outcome, t.finalDamage])).toEqual([['SAVED', 13], ['FAILED', 27], [null, null]]);
|
||||
expect(fireball).toMatchObject({ needsRolls: 1, ready: false });
|
||||
expect(spell(false).targets[0].finalDamage).toBe(0);
|
||||
});
|
||||
|
||||
test('something that just lands hits everyone it is aimed at', () => {
|
||||
const missile = settle({ kind: 'AUTO', detail: { damageDice: '3d4+3' }, targets: [{ participantId: 'a' }], damage: { total: 10 } });
|
||||
expect(missile.targets[0]).toMatchObject({ outcome: 'HIT', finalDamage: 10 });
|
||||
expect(missile.ready).toBe(true);
|
||||
// With no dice at all (a utility spell) it is ready as it stands.
|
||||
expect(settle({ kind: 'AUTO', detail: {}, targets: [{ participantId: 'a' }], damage: null })).toMatchObject({ ready: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('damage', () => {
|
||||
test('is the dice plus the bonus', () => {
|
||||
expect(rollDamage({ damageDice: '1d8', damageBonus: 3 }, { roll: fixed(5) })).toMatchObject({ total: 8, crit: null });
|
||||
expect(rollDamage({ damageDice: '2d6+1 fire', damageBonus: 0 }, { roll: fixed(2, 3) }).total).toBe(6);
|
||||
expect(rollDamage({ damageDice: 'a nasty bite' })).toBeNull();
|
||||
});
|
||||
|
||||
test('a critical hit doubles the dice, never the bonus', () => {
|
||||
const rolled = rollDamage({ damageDice: '1d8', damageBonus: 3 }, { critical: true, roll: fixed(5, 4, 6) });
|
||||
expect(rolled.total).toBe(8);
|
||||
expect(rolled.crit.total).toBe(4 + 6 + 3);
|
||||
expect(rollCritDamage({ damageDice: '1d8', damageBonus: 3 }, { critRule: 'MAX_PLUS_ROLL', roll: fixed(2) }).total).toBe(8 + 2 + 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sides', () => {
|
||||
const aria = { id: 'aria', kind: 'CHARACTER', ac: 16 };
|
||||
const bo = { id: 'bo', kind: 'CHARACTER', ac: 12 };
|
||||
const goblin = { id: 'goblin', kind: 'MONSTER', ac: 13 };
|
||||
const guard = { id: 'guard', kind: 'NPC', disposition: 'FRIENDLY', ac: 14 };
|
||||
const thug = { id: 'thug', kind: 'NPC', disposition: 'HOSTILE', ac: 11 };
|
||||
const baker = { id: 'baker', kind: 'NPC', disposition: 'NEUTRAL', ac: 10 };
|
||||
const everyone = [aria, bo, goblin, guard, thug, baker];
|
||||
|
||||
test('characters and friendly NPCs against monsters and hostile NPCs', () => {
|
||||
expect(everyone.map(sideOf)).toEqual(['PARTY', 'PARTY', 'ENEMY', 'PARTY', 'ENEMY', 'NEUTRAL']);
|
||||
expect(opponentsOf(goblin, everyone).map((c) => c.id)).toEqual(['aria', 'bo', 'guard']);
|
||||
expect(opponentsOf(guard, everyone).map((c) => c.id)).toEqual(['goblin', 'thug']);
|
||||
expect(opponentsOf(baker, everyone)).toEqual([]);
|
||||
});
|
||||
|
||||
test('the fallen are nobody’s target', () => {
|
||||
expect(opponentsOf(goblin, [aria, { ...bo, fallen: true }]).map((c) => c.id)).toEqual(['aria']);
|
||||
});
|
||||
|
||||
test('a creature favours who it fought last, who hurt it, and the easier mark', () => {
|
||||
const [ariaWeight, boWeight] = targetWeights([aria, bo]);
|
||||
expect(boWeight).toBeGreaterThan(ariaWeight); // lower AC
|
||||
const [again] = targetWeights([aria, bo], { lastTargetId: 'aria', hurtBy: ['aria'] });
|
||||
expect(again).toBeGreaterThan(boWeight);
|
||||
});
|
||||
|
||||
test('picks by weight, and nobody when there is nobody', () => {
|
||||
expect(pickTarget([aria, bo], {}, () => 0).id).toBe('aria');
|
||||
expect(pickTarget([aria, bo], {}, () => 0.999).id).toBe('bo');
|
||||
expect(pickTarget([], {})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('what a creature attacks with', () => {
|
||||
test('its single attack, or each of its named attacks as often as it makes it', () => {
|
||||
expect(attacksOf({ monster: { attackBonus: 4, damageDice: '1d6+2', attacks: [] } })).toEqual([{ name: 'Attack', attackBonus: 4, damageDice: '1d6+2', damageType: null }]);
|
||||
const owlbear = attacksOf({ monster: { attackBonus: 7, damageDice: '2d8+5', attacks: [{ name: 'Claw', attackBonus: 7, damageDice: '2d8+5', damageType: 'slashing', count: 2 }, { name: 'Beak', attackBonus: 7, damageDice: '1d10+5' }] } });
|
||||
expect(owlbear.map((a) => a.name)).toEqual(['Claw', 'Claw', 'Beak']);
|
||||
expect(attacksOf({ npc: { attackBonus: 3, damageDice: '1d6' } })).toHaveLength(1);
|
||||
expect(attacksOf({ npc: { attackBonus: null, damageDice: null } })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -155,4 +155,61 @@ const SPELL_LIBRARY = [
|
||||
{ key: 'wall-of-stone', name: 'Wall of Stone', level: 5, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 10 minutes', description: 'A nonmagical wall of solid stone springs into existence.', classes: ['DRUID', 'SORCERER', 'WIZARD'] },
|
||||
];
|
||||
|
||||
// How each spell lands in a battle (game/combat.js): a spell attack against
|
||||
// AC, or a saving throw by each target, [ability, half damage on a success].
|
||||
// A spell in neither list simply takes effect. Written onto the entries
|
||||
// below, as `attackRoll`, `saveAbility` and `halfOnSave`.
|
||||
const SPELL_ATTACKS = ['chill-touch', 'eldritch-blast', 'fire-bolt', 'produce-flame', 'ray-of-frost', 'shocking-grasp', 'thorn-whip', 'guiding-bolt', 'witch-bolt', 'scorching-ray', 'spiritual-weapon', 'contagion'];
|
||||
const SPELL_SAVES = {
|
||||
'acid-splash': ['DEX', false],
|
||||
'poison-spray': ['CON', false],
|
||||
'sacred-flame': ['DEX', false],
|
||||
'vicious-mockery': ['WIS', false],
|
||||
'burning-hands': ['DEX', true],
|
||||
'thunderwave': ['CON', true],
|
||||
'bane': ['CHA', false],
|
||||
'charm-person': ['WIS', false],
|
||||
'command': ['WIS', false],
|
||||
'entangle': ['STR', false],
|
||||
'faerie-fire': ['DEX', false],
|
||||
'animal-friendship': ['WIS', false],
|
||||
'blindness-deafness': ['CON', false],
|
||||
'hold-person': ['WIS', false],
|
||||
'flaming-sphere': ['DEX', true],
|
||||
'moonbeam': ['CON', true],
|
||||
'shatter': ['CON', true],
|
||||
'suggestion': ['WIS', false],
|
||||
'web': ['DEX', false],
|
||||
'zone-of-truth': ['CHA', false],
|
||||
'levitate': ['CON', false],
|
||||
'gust-of-wind': ['STR', false],
|
||||
'call-lightning': ['DEX', true],
|
||||
'fear': ['WIS', false],
|
||||
'fireball': ['DEX', true],
|
||||
'lightning-bolt': ['DEX', true],
|
||||
'sleet-storm': ['DEX', false],
|
||||
'slow': ['WIS', false],
|
||||
'spirit-guardians': ['WIS', true],
|
||||
'stinking-cloud': ['CON', false],
|
||||
'banishment': ['CHA', false],
|
||||
'blight': ['CON', true],
|
||||
'confusion': ['WIS', false],
|
||||
'ice-storm': ['DEX', true],
|
||||
'polymorph': ['WIS', false],
|
||||
'wall-of-fire': ['DEX', true],
|
||||
'guardian-of-faith': ['DEX', true],
|
||||
'cloudkill': ['CON', true],
|
||||
'cone-of-cold': ['CON', true],
|
||||
'dominate-person': ['WIS', false],
|
||||
'flame-strike': ['DEX', true],
|
||||
'hold-monster': ['WIS', false],
|
||||
'insect-plague': ['CON', true],
|
||||
};
|
||||
for (const spell of SPELL_LIBRARY) {
|
||||
spell.attackRoll = SPELL_ATTACKS.includes(spell.key);
|
||||
const save = SPELL_SAVES[spell.key];
|
||||
spell.saveAbility = save ? save[0] : null;
|
||||
spell.halfOnSave = save ? save[1] : false;
|
||||
}
|
||||
|
||||
module.exports = { SPELL_LIBRARY };
|
||||
|
||||
@@ -3848,3 +3848,287 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('free roam', () => {
|
||||
itIfDb('the party is in it from the start, wanders between its places, and shops where it stands', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('roam');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const heroId = await buildCharacter(campaign.id, playerAuth, 'Aria');
|
||||
const allyId = await buildCharacter(campaign.id, dmAuth, 'Bo');
|
||||
await request(app).post(`/characters/${heroId}/gold`).set(dmAuth).send({ amount: 50, reason: 'Purse' });
|
||||
const place = async (name) => (await request(app).post(`${c}/locations`).set(dmAuth).send({ name, description: `${name}.` })).body.location;
|
||||
const [market, inn, docks] = [await place('Market'), await place('Inn'), await place('Docks')];
|
||||
const rope = (await request(app).post(`${c}/items`).set(dmAuth).send({ name: 'Rope' })).body.item;
|
||||
const ale = (await request(app).post(`${c}/items`).set(dmAuth).send({ name: 'Ale' })).body.item;
|
||||
|
||||
// A shop at the market, and an innkeeper found at the inn who sells ale.
|
||||
expect((await request(app).post(`${c}/wares`).set(dmAuth).send({ locationId: market.id, itemId: rope.id, price: 2 })).status).toBe(201);
|
||||
expect((await request(app).post(`${c}/wares`).set(dmAuth).send({ locationId: market.id, npcId: 'x', itemId: rope.id, price: 2 })).status).toBe(400);
|
||||
const keeper = (await request(app).post(`${c}/npcs`).set(dmAuth).send({ name: 'Mara', role: 'Innkeeper', dmNotes: 'Waters the ale.', locationId: inn.id })).body.npc;
|
||||
expect((await request(app).post(`${c}/npcs`).set(dmAuth).send({ name: 'Nobody', locationId: 'nowhere' })).status).toBe(400);
|
||||
await request(app).post(`${c}/wares`).set(dmAuth).send({ npcId: keeper.id, itemId: ale.id, price: 1 });
|
||||
|
||||
const created = await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'A day in town', mode: 'FREE_ROAM', placeIds: [market.id, inn.id] });
|
||||
expect(created.status).toBe(201);
|
||||
const e = `/encounters/${created.body.encounter.id}`;
|
||||
const view = async (auth) => (await request(app).get(e).set(auth)).body.encounter;
|
||||
|
||||
// Everyone alive is in it without being added, so the player can open it.
|
||||
let scene = await view(playerAuth);
|
||||
expect(scene.mode).toBe('FREE_ROAM');
|
||||
expect(scene.participants.map((p) => p.displayName).sort()).toEqual(['Aria', 'Bo']);
|
||||
expect(scene.places.map((p) => p.name)).toEqual(['Market', 'Inn']);
|
||||
expect(scene.places[0].wares[0].item.name).toBe('Rope');
|
||||
expect(scene.places[1].npcs[0]).toMatchObject({ name: 'Mara', role: 'Innkeeper' });
|
||||
expect(scene.places[1].npcs[0].dmNotes).toBeUndefined(); // the DM's alone
|
||||
expect(scene.places[1].npcs[0].wares[0].item.name).toBe('Ale');
|
||||
expect((await view(dmAuth)).places[1].npcs[0].dmNotes).toBe('Waters the ale.');
|
||||
|
||||
const visit = (auth, characterId, locationId) => request(app).post(`${e}/visit`).set(auth).send({ characterId, locationId });
|
||||
const buy = (auth, wareId, characterId) => request(app).post(`${e}/wares/${wareId}/buy`).set(auth).send({ characterId });
|
||||
const ropeWare = scene.places[0].wares[0].id;
|
||||
const aleWare = scene.places[1].npcs[0].wares[0].id;
|
||||
|
||||
// Nothing moves, and nothing is sold, before the scene begins.
|
||||
expect((await visit(playerAuth, heroId, market.id)).status).toBe(400);
|
||||
await request(app).patch(e).set(dmAuth).send({ status: 'ACTIVE' });
|
||||
expect((await buy(playerAuth, ropeWare, heroId)).status).toBe(400); // not at the market yet
|
||||
expect((await visit(playerAuth, heroId, docks.id)).status).toBe(400); // not one of the scene's places
|
||||
expect((await visit(playerAuth, allyId, market.id)).status).toBe(403); // not their character
|
||||
|
||||
scene = (await visit(playerAuth, heroId, market.id)).body.encounter;
|
||||
expect(scene.places[0].here.map((h) => h.name)).toEqual(['Aria']);
|
||||
expect((await buy(playerAuth, ropeWare, heroId)).status).toBe(201);
|
||||
// The innkeeper is at the inn: go there to buy her ale.
|
||||
const tooFar = await buy(playerAuth, aleWare, heroId);
|
||||
expect(tooFar.status).toBe(400);
|
||||
expect(tooFar.body.error).toMatch(/go to Inn first/);
|
||||
await visit(playerAuth, heroId, inn.id);
|
||||
expect((await buy(playerAuth, aleWare, heroId)).status).toBe(201);
|
||||
const hero = (await request(app).get(`/characters/${heroId}`).set(playerAuth)).body.character;
|
||||
expect(hero.inventoryItems.map((i) => i.item.name).sort()).toEqual(['Ale', 'Rope']);
|
||||
expect(hero.currentLocation.name).toBe('Inn');
|
||||
// The DM can send anyone, and opens another place while the party roams.
|
||||
expect((await visit(dmAuth, allyId, market.id)).status).toBe(200);
|
||||
expect((await request(app).put(`${e}/places`).set(playerAuth).send({ locationIds: [] })).status).toBe(403);
|
||||
const more = (await request(app).put(`${e}/places`).set(dmAuth).send({ locationIds: [market.id, inn.id, docks.id] })).body.encounter;
|
||||
expect(more.places.map((p) => p.name)).toEqual(['Market', 'Inn', 'Docks']);
|
||||
// Having been there, the player now knows the place for good.
|
||||
const known = (await request(app).get(`${c}/locations`).set(playerAuth)).body.locations.map((l) => l.name).sort();
|
||||
expect(known).toEqual(['Inn', 'Market']);
|
||||
});
|
||||
|
||||
itIfDb('a shop at a place does not sell in a quest, and only a free-roam scene has places', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('roamnot');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const heroId = await buildCharacter(campaign.id, playerAuth, 'Aria');
|
||||
const market = (await request(app).post(`${c}/locations`).set(dmAuth).send({ name: 'Market' })).body.location;
|
||||
const rope = (await request(app).post(`${c}/items`).set(dmAuth).send({ name: 'Rope' })).body.item;
|
||||
const ware = (await request(app).post(`${c}/wares`).set(dmAuth).send({ locationId: market.id, itemId: rope.id, price: 1 })).body.ware;
|
||||
const quest = `/encounters/${(await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Errand', mode: 'QUEST' })).body.encounter.id}`;
|
||||
await request(app).post(`${quest}/participants`).set(dmAuth).send({ characterId: heroId });
|
||||
await request(app).patch(quest).set(dmAuth).send({ status: 'ACTIVE' });
|
||||
expect((await request(app).post(`${quest}/wares/${ware.id}/buy`).set(playerAuth).send({ characterId: heroId })).status).toBe(404);
|
||||
expect((await request(app).post(`${quest}/visit`).set(playerAuth).send({ characterId: heroId, locationId: market.id })).status).toBe(400);
|
||||
expect((await request(app).get(quest).set(playerAuth)).body.encounter.places).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
@@ -834,9 +853,11 @@ router.get('/:id/encounters', requireCampaignMember(), async (req, res, next) =>
|
||||
|
||||
const createEncounterSchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
mode: z.enum(['BATTLE', 'QUEST']).optional(),
|
||||
mode: z.enum(['BATTLE', 'QUEST', 'FREE_ROAM']).optional(),
|
||||
description: z.string().max(4000).optional().nullable(),
|
||||
locationId: z.string().optional().nullable(),
|
||||
// Free roam: the places the party can go to, from the campaign's locations.
|
||||
placeIds: z.array(z.string()).max(50).optional(),
|
||||
// Where it goes in the campaign's order: the top, the bottom, or straight
|
||||
// after another encounter (which wins over `position`). The bottom if
|
||||
// neither is given.
|
||||
@@ -848,11 +869,15 @@ router.post('/:id/encounters', requireCampaignMember(), requireDM(), async (req,
|
||||
try {
|
||||
const parsed = createEncounterSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { position, afterEncounterId, ...fields } = parsed.data;
|
||||
const { position, afterEncounterId, placeIds = [], ...fields } = parsed.data;
|
||||
if (fields.locationId) {
|
||||
const location = await prisma.location.findFirst({ where: { id: fields.locationId, campaignId: req.campaign.id } });
|
||||
if (!location) return res.status(400).json({ error: 'That location is not part of this campaign.' });
|
||||
}
|
||||
const places = [...new Set(placeIds)];
|
||||
if (places.length > 0 && (await prisma.location.count({ where: { id: { in: places }, campaignId: req.campaign.id } })) !== places.length) {
|
||||
return res.status(400).json({ error: 'Pick places from this campaign\'s locations.' });
|
||||
}
|
||||
|
||||
const order = await prisma.encounter.findMany({
|
||||
where: { campaignId: req.campaign.id },
|
||||
@@ -880,6 +905,26 @@ router.post('/:id/encounters', requireCampaignMember(), requireDM(), async (req,
|
||||
}
|
||||
return created;
|
||||
});
|
||||
|
||||
// Free roam is for the whole party: its places are set, and everyone
|
||||
// alive and finished is in it from the start (so their players see it).
|
||||
if (encounter.mode === 'FREE_ROAM') {
|
||||
if (places.length > 0) await prisma.encounterLocation.createMany({ data: places.map((locationId) => ({ encounterId: encounter.id, locationId })) });
|
||||
const party = await prisma.character.findMany({ where: { campaignId: req.campaign.id, buildStage: 'COMPLETE' }, include: SHEET_INCLUDE, orderBy: { createdAt: 'asc' } });
|
||||
const present = party.filter((character) => lifeState(character) !== 'DEAD');
|
||||
if (present.length > 0) {
|
||||
await prisma.encounterParticipant.createMany({
|
||||
data: present.map((character) => ({
|
||||
encounterId: encounter.id,
|
||||
characterId: character.id,
|
||||
displayName: character.name,
|
||||
currentHp: character.currentHp,
|
||||
maxHp: sheetOf(character).hp.max,
|
||||
isMonster: false,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
res.status(201).json({ encounter });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -942,8 +987,16 @@ const npcSchema = z.object({
|
||||
// null for an NPC who does not change money.
|
||||
exchangeGold: z.number().int().min(1).max(1000000).optional().nullable(),
|
||||
exchangeCash: z.number().int().min(1).max(1000000).optional().nullable(),
|
||||
// Where they are usually found: met there in a free-roam scene.
|
||||
locationId: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
// Whether a location named for an NPC, a ware or a scene is one of this campaign's.
|
||||
async function locationInCampaign(locationId, campaignId) {
|
||||
return !locationId || Boolean(await prisma.location.findFirst({ where: { id: locationId, campaignId }, select: { id: true } }));
|
||||
}
|
||||
const NOT_A_PLACE = 'That location is not part of this campaign.';
|
||||
|
||||
// A rate has two sides: both are given, or both are cleared.
|
||||
function halfARate(data) {
|
||||
const sides = [data.exchangeGold, data.exchangeCash];
|
||||
@@ -963,6 +1016,7 @@ router.post('/:id/npcs', requireCampaignMember(), requireDM(), async (req, res,
|
||||
const parsed = npcSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
if (halfARate(parsed.data)) return res.status(400).json({ error: HALF_A_RATE });
|
||||
if (!(await locationInCampaign(parsed.data.locationId, req.campaign.id))) return res.status(400).json({ error: NOT_A_PLACE });
|
||||
const npc = await prisma.npc.create({ data: { ...parsed.data, campaignId: req.campaign.id } });
|
||||
res.status(201).json({ npc });
|
||||
} catch (err) {
|
||||
@@ -975,6 +1029,7 @@ router.patch('/:id/npcs/:npcId', requireCampaignMember(), requireDM(), async (re
|
||||
const parsed = npcSchema.partial().safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
if (halfARate(parsed.data)) return res.status(400).json({ error: HALF_A_RATE });
|
||||
if (!(await locationInCampaign(parsed.data.locationId, req.campaign.id))) return res.status(400).json({ error: NOT_A_PLACE });
|
||||
const npc = await updatePoolEntry('npc', req.params.npcId, req.campaign.id, parsed.data);
|
||||
if (!npc) return res.status(404).json({ error: 'NPC not found in this campaign' });
|
||||
emitTradeChanged(req);
|
||||
@@ -1087,6 +1142,7 @@ const wareSchema = z
|
||||
.object({
|
||||
npcId: z.string().optional(),
|
||||
encounterId: z.string().optional(),
|
||||
locationId: z.string().optional(), // a shop that is always at this place
|
||||
weaponId: z.string().optional(),
|
||||
itemId: z.string().optional(),
|
||||
price: z.number().int().min(1).max(1000000),
|
||||
@@ -1094,7 +1150,7 @@ const wareSchema = z
|
||||
level: z.number().int().min(1).max(MAX_GEAR_LEVEL).optional(),
|
||||
stock: z.number().int().min(0).max(9999).optional().nullable(), // null: no limit
|
||||
})
|
||||
.refine((data) => Boolean(data.npcId) !== Boolean(data.encounterId), { message: 'Say who sells it: an NPC or an encounter, not both' })
|
||||
.refine((data) => [data.npcId, data.encounterId, data.locationId].filter(Boolean).length === 1, { message: 'Say where it is sold: by an NPC, at an encounter, or at a location (one of them)' })
|
||||
.refine((data) => Boolean(data.weaponId) !== Boolean(data.itemId), { message: 'Pick one weapon or one item to sell' });
|
||||
|
||||
router.post('/:id/wares', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
@@ -1104,7 +1160,7 @@ router.post('/:id/wares', requireCampaignMember(), requireDM(), async (req, res,
|
||||
const data = parsed.data;
|
||||
const inCampaign = async (model, id) => (id ? prisma[model].findFirst({ where: { id, campaignId: req.campaign.id } }) : null);
|
||||
|
||||
const seller = (await inCampaign('npc', data.npcId)) || (await inCampaign('encounter', data.encounterId));
|
||||
const seller = (await inCampaign('npc', data.npcId)) || (await inCampaign('encounter', data.encounterId)) || (await inCampaign('location', data.locationId));
|
||||
if (!seller) return res.status(400).json({ error: 'That seller is not part of this campaign.' });
|
||||
const entry = (await inCampaign('weapon', data.weaponId)) || (await inCampaign('item', data.itemId));
|
||||
if (!entry) return res.status(400).json({ error: `That ${data.weaponId ? 'weapon' : 'item'} is not part of this campaign.` });
|
||||
|
||||
@@ -453,7 +453,8 @@ router.post('/:id/spell-slots', requireCharacterAccess({ write: true }), async (
|
||||
if (input.action !== 'use' && !req.isDM) {
|
||||
return res.status(403).json({ error: 'Only the DM can give a spell slot back.' });
|
||||
}
|
||||
const slots = spellSlotState(character.characterClass, character.level, character.spellSlotsUsed);
|
||||
// By the sheet, so that a casting subclass and a multiclass count.
|
||||
const slots = sheetOf(await loadFullCharacter(character.id)).spellSlots;
|
||||
if (!slots) return res.status(400).json({ error: `${character.name} has no spell slots.` });
|
||||
|
||||
// The stored tally, squared up with what the class has at this level.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const crypto = require('crypto');
|
||||
const express = require('express');
|
||||
const { z } = require('zod');
|
||||
const { Prisma } = require('@prisma/client');
|
||||
const prisma = require('../db');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { loadCampaignMembership } = require('../middleware/campaignAccess');
|
||||
@@ -10,6 +11,9 @@ const { moveCharacters, visitedLocationIds } = require('../utils/locations');
|
||||
const { SHEET_INCLUDE } = require('../utils/sheet');
|
||||
const { NO_DEATH_SAVES, lifeState, deathSaveInfo, changeHp } = require('../utils/hp');
|
||||
const { subclassName } = require('../game/data/subclasses');
|
||||
const { settle, rollDamage, rollCritDamage } = require('../game/combat');
|
||||
const { rollDie } = require('../game/dice');
|
||||
const { ActionError, targetRows, describeSource, autoActions } = require('../utils/actions');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -184,6 +188,10 @@ function characterCombatInfo(character, subclasses = true) {
|
||||
description: cs.spell.description,
|
||||
damageDice: cs.spell.damageDice,
|
||||
prepared: cs.prepared,
|
||||
// How it lands: a spell attack, a save by each target, or neither.
|
||||
attackRoll: cs.spell.attackRoll,
|
||||
saveAbility: cs.spell.saveAbility,
|
||||
halfOnSave: cs.spell.halfOnSave,
|
||||
}));
|
||||
return {
|
||||
level: character.level,
|
||||
@@ -288,13 +296,16 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
|
||||
// Likewise their conditions: the character's own, which outlast the scene.
|
||||
out.conditions = character.conditions;
|
||||
out.character = { id: character.id, name: character.name, userId: character.userId };
|
||||
// Where each character is, is part of a free-roam scene; elsewhere it stays on their sheet.
|
||||
if (encounter.mode === 'FREE_ROAM') out.character.currentLocationId = character.currentLocationId;
|
||||
out.characterCombatInfo = combatInfo;
|
||||
}
|
||||
out.conditionRounds = roundsFor(p.conditionRounds, out.conditions);
|
||||
if (monster) out.monster = isDM ? monster : { id: monster.id, name: monster.name };
|
||||
if (npc) {
|
||||
const { dmNotes, ac, maxHp, attackBonus, damageDice, campaignId, createdAt, ...publicNpc } = npc;
|
||||
out.npc = isDM ? { ...publicNpc, dmNotes, ac, maxHp, attackBonus, damageDice } : publicNpc;
|
||||
// Where they are usually found is the DM's to know, like their notes and stats.
|
||||
const { dmNotes, ac, maxHp, attackBonus, damageDice, locationId, campaignId, createdAt, ...publicNpc } = npc;
|
||||
out.npc = isDM ? { ...publicNpc, dmNotes, ac, maxHp, attackBonus, damageDice, locationId } : publicNpc;
|
||||
}
|
||||
if (hideHp && !character) {
|
||||
// Rounded up, so anything still standing shows a sliver of bar.
|
||||
@@ -305,6 +316,55 @@ 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);
|
||||
|
||||
// Free roam: the places the party can go to, each with who is found there,
|
||||
// what is for sale there, and which of the characters in the scene are
|
||||
// there now. Everyone in the scene sees every place in it. An NPC's secrets
|
||||
// and stats stay with the DM, as anywhere.
|
||||
let places;
|
||||
if (encounter.mode === 'FREE_ROAM') {
|
||||
const rows = await prisma.encounterLocation.findMany({
|
||||
where: { encounterId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: { location: { include: { npcs: { include: { wares: WARES }, orderBy: { name: 'asc' } }, wares: WARES } } },
|
||||
});
|
||||
places = rows.map(({ location: place }) => ({
|
||||
id: place.id,
|
||||
name: place.name,
|
||||
description: place.description,
|
||||
wares: place.wares,
|
||||
npcs: place.npcs.map(({ dmNotes: secrets, ac, maxHp, attackBonus, damageDice, locationId, campaignId, createdAt, ...publicNpc }) =>
|
||||
isDM ? { ...publicNpc, dmNotes: secrets, ac, maxHp, attackBonus, damageDice, locationId } : publicNpc
|
||||
),
|
||||
here: encounter.participants
|
||||
.filter((p) => p.character && p.character.currentLocationId === place.id)
|
||||
.map((p) => ({ characterId: p.character.id, name: p.displayName, userId: p.character.userId })),
|
||||
}));
|
||||
}
|
||||
|
||||
const { dmNotes, rewards, campaign, location, ...scene } = encounter;
|
||||
const knowsPlace = isDM || (location && (await visitedLocationIds(prisma, viewerId, encounter.campaignId)).has(location.id));
|
||||
return {
|
||||
@@ -316,6 +376,9 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
|
||||
targetParticipantIds: isDM ? scene.targetParticipantIds : scene.targetParticipantIds.filter((id) => !hiddenIds.has(id)),
|
||||
participants,
|
||||
fallen,
|
||||
actions,
|
||||
recentActions,
|
||||
...(places ? { places } : {}),
|
||||
rewards: isDM ? rewards : rewards.filter((r) => r.awardedAt),
|
||||
};
|
||||
}
|
||||
@@ -338,7 +401,8 @@ async function syncCharacterLocations(req) {
|
||||
where: { id: req.encounter.id },
|
||||
include: { participants: { where: { characterId: { not: null } }, include: { character: { select: { id: true, currentLocationId: true } } } } },
|
||||
});
|
||||
if (!encounter || encounter.status !== 'ACTIVE' || !encounter.locationId) return;
|
||||
// In free roam each character goes where their player sends them.
|
||||
if (!encounter || encounter.status !== 'ACTIVE' || !encounter.locationId || encounter.mode === 'FREE_ROAM') return;
|
||||
|
||||
const elsewhere = encounter.participants.map((p) => p.character).filter((c) => c.currentLocationId !== encounter.locationId);
|
||||
await moveCharacters(prisma, elsewhere.map((c) => c.id), encounter.locationId);
|
||||
@@ -362,7 +426,7 @@ router.get('/:id', requireEncounterAccess, async (req, res, next) => {
|
||||
|
||||
const patchEncounterSchema = z.object({
|
||||
status: z.enum(['PLANNING', 'ACTIVE', 'COMPLETE']).optional(),
|
||||
mode: z.enum(['BATTLE', 'QUEST']).optional(),
|
||||
mode: z.enum(['BATTLE', 'QUEST', 'FREE_ROAM']).optional(),
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
description: z.string().max(4000).nullable().optional(),
|
||||
dmNotes: z.string().max(4000).nullable().optional(),
|
||||
@@ -392,7 +456,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 +806,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 +815,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 +838,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 +850,381 @@ 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);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Free roam — the party wanders between the places the DM has laid out for
|
||||
// the scene: to shop, to meet whoever is found there. The DM sets the places;
|
||||
// each player sends their own character to one (the DM can send anyone).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The whole list of places; an empty one clears it.
|
||||
router.put('/:id/places', requireEncounterAccess, requireDM, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z.object({ locationIds: z.array(z.string()).max(50) }).safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const locationIds = [...new Set(parsed.data.locationIds)];
|
||||
if ((await prisma.location.count({ where: { id: { in: locationIds }, campaignId: req.encounter.campaignId } })) !== locationIds.length) {
|
||||
return res.status(400).json({ error: "Pick places from this campaign's locations." });
|
||||
}
|
||||
const had = await prisma.encounterLocation.findMany({ where: { encounterId: req.encounter.id }, select: { locationId: true } });
|
||||
const keep = new Set(had.map((row) => row.locationId));
|
||||
await prisma.$transaction([
|
||||
prisma.encounterLocation.deleteMany({ where: { encounterId: req.encounter.id, locationId: { notIn: locationIds } } }),
|
||||
// Added one at a time, in the order given, so the list keeps that order.
|
||||
...locationIds.filter((id) => !keep.has(id)).map((locationId) => prisma.encounterLocation.create({ data: { encounterId: req.encounter.id, locationId } })),
|
||||
]);
|
||||
await respondWithEncounter(req, res);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// A character goes to one of the scene's places: their own player sends them, or the DM.
|
||||
router.post('/:id/visit', requireEncounterAccess, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z.object({ characterId: z.string(), locationId: z.string() }).safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { characterId, locationId } = parsed.data;
|
||||
if (req.encounter.mode !== 'FREE_ROAM') return res.status(400).json({ error: 'Only a free-roam scene has places to go to.' });
|
||||
if (req.encounter.status !== 'ACTIVE') return res.status(400).json({ error: 'The scene has not begun yet.' });
|
||||
|
||||
const row = await prisma.encounterParticipant.findFirst({ where: { encounterId: req.encounter.id, characterId }, include: { character: true } });
|
||||
if (!row) return res.status(400).json({ error: 'That character is not in this scene.' });
|
||||
if (!req.isDM && row.character.userId !== req.user.id) return res.status(403).json({ error: 'You can only move your own character.' });
|
||||
if (isFallen(row)) return res.status(400).json({ error: `${row.character.name} is dead.` });
|
||||
const place = await prisma.encounterLocation.findFirst({ where: { encounterId: req.encounter.id, locationId } });
|
||||
if (!place) return res.status(400).json({ error: 'That is not one of the places in this scene.' });
|
||||
|
||||
await moveCharacters(prisma, [characterId], locationId);
|
||||
emitCharacterUpdated(req, characterId);
|
||||
await respondWithEncounter(req, res);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Experience — awarded once, shared evenly between the characters in the scene
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -973,10 +1415,18 @@ function money(amount, currency) {
|
||||
}
|
||||
|
||||
// Trading takes someone to trade with and the time to do it, so it happens
|
||||
// in a quest in progress and never in a battle.
|
||||
// in a quest or a free-roam scene in progress, and never in a battle.
|
||||
function assertTradeOpen(encounter) {
|
||||
if (encounter.mode !== 'QUEST') throw new TradeError('Trading happens in a quest, not in a battle.');
|
||||
if (encounter.status !== 'ACTIVE') throw new TradeError('Trading opens once the quest is in progress.');
|
||||
if (encounter.mode === 'BATTLE') throw new TradeError('Trading happens in a quest or while roaming, not in a battle.');
|
||||
if (encounter.status !== 'ACTIVE') throw new TradeError(`Trading opens once the ${encounter.mode === 'QUEST' ? 'quest' : 'scene'} is in progress.`);
|
||||
}
|
||||
|
||||
// In a free-roam scene a character trades where they are: the place has to
|
||||
// be one of the scene's, and they have to have gone there.
|
||||
async function assertAtPlace(req, character, locationId, what) {
|
||||
const place = locationId ? await prisma.encounterLocation.findFirst({ where: { encounterId: req.encounter.id, locationId }, include: { location: true } }) : null;
|
||||
if (!place) throw new TradeError(`${what} is not here to trade with.`);
|
||||
if (character.currentLocationId !== locationId) throw new TradeError(`${character.name} has to go to ${place.location.name} first.`);
|
||||
}
|
||||
|
||||
// The character doing the trading: in this scene, alive, and the player's
|
||||
@@ -989,8 +1439,15 @@ async function loadTrader(req, characterId) {
|
||||
return row.character;
|
||||
}
|
||||
|
||||
// An NPC to trade with has to be in the scene and still standing.
|
||||
async function loadTradingNpc(req, npcId) {
|
||||
// An NPC to trade with has to be in the scene and still standing; in a
|
||||
// free-roam scene, at one of its places, with the character there too.
|
||||
async function loadTradingNpc(req, npcId, character) {
|
||||
if (req.encounter.mode === 'FREE_ROAM') {
|
||||
const npc = await prisma.npc.findFirst({ where: { id: npcId, campaignId: req.encounter.campaignId } });
|
||||
if (!npc) throw new TradeError('That NPC is not here to trade with.');
|
||||
await assertAtPlace(req, character, npc.locationId, npc.name);
|
||||
return npc;
|
||||
}
|
||||
const row = await prisma.encounterParticipant.findFirst({ where: { encounterId: req.encounter.id, npcId }, include: { npc: true } });
|
||||
if (!row || isFallen(row)) throw new TradeError('That NPC is not here to trade with.');
|
||||
return row.npc;
|
||||
@@ -1023,7 +1480,7 @@ router.post(
|
||||
const to = from === 'GOLD' ? 'CASH' : 'GOLD';
|
||||
|
||||
const character = await loadTrader(req, characterId);
|
||||
const npc = await loadTradingNpc(req, npcId);
|
||||
const npc = await loadTradingNpc(req, npcId, character);
|
||||
if (!npc.exchangeGold || !npc.exchangeCash) throw new TradeError(`${npc.name} does not change money.`);
|
||||
const rate = { GOLD: npc.exchangeGold, CASH: npc.exchangeCash };
|
||||
if (amount % rate[from] !== 0) {
|
||||
@@ -1065,7 +1522,12 @@ router.post(
|
||||
if (!ware || ware.campaignId !== req.encounter.campaignId || (ware.encounterId && ware.encounterId !== req.encounter.id)) {
|
||||
throw new TradeError('That is not for sale here.', 404);
|
||||
}
|
||||
const seller = ware.npcId ? (await loadTradingNpc(req, ware.npcId)).name : null;
|
||||
// A shop at a place sells to whoever has gone there, while that place is part of the scene.
|
||||
if (ware.locationId) {
|
||||
if (req.encounter.mode !== 'FREE_ROAM') throw new TradeError('That is not for sale here.', 404);
|
||||
await assertAtPlace(req, character, ware.locationId, 'That shop');
|
||||
}
|
||||
const seller = ware.npcId ? (await loadTradingNpc(req, ware.npcId, character)).name : null;
|
||||
const kind = ware.weapon ? 'weapon' : 'item';
|
||||
const entry = ware[kind];
|
||||
const total = ware.price * quantity;
|
||||
|
||||
@@ -75,6 +75,10 @@ const POOLS = {
|
||||
description: optionalText(4000),
|
||||
isStartingOption: z.boolean().optional(),
|
||||
revives: z.boolean().optional(),
|
||||
// How it lands in a battle: a spell attack, or a save by each target.
|
||||
attackRoll: z.boolean().optional(),
|
||||
saveAbility: z.enum(['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA']).nullish(),
|
||||
halfOnSave: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
monsters: {
|
||||
@@ -95,6 +99,19 @@ const POOLS = {
|
||||
challengeRating: optionalText(10),
|
||||
specialTrait: optionalText(500),
|
||||
notes: optionalText(2000),
|
||||
// Named attacks, for more than the one above; `count` makes it a multiattack.
|
||||
attacks: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
name: text(40),
|
||||
attackBonus: z.number().int().min(-10).max(30),
|
||||
damageDice: damage,
|
||||
damageType: optionalText(30),
|
||||
count: z.number().int().min(1).max(10).optional(),
|
||||
})
|
||||
)
|
||||
.max(8)
|
||||
.optional(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
// Battle actions against the database's shapes: reading a combatant's numbers
|
||||
// off a loaded participant, and putting together the action a weapon, a spell
|
||||
// or a creature's attack makes (EncounterAction). The rules themselves are in
|
||||
// game/combat.js. No queries here: everything is passed in loaded.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { computeCharacterSheet, computeWeaponStats, abilityModifier, canUseGear } = require('../game/calculations');
|
||||
const { attacksOf, opponentsOf, pickTarget, rollDamage, attackOutcome, lands } = require('../game/combat');
|
||||
const { rollDie } = require('../game/dice');
|
||||
const { lifeState } = require('./hp');
|
||||
|
||||
// A problem with what was asked for: the message is for whoever asked.
|
||||
class ActionError extends Error {}
|
||||
|
||||
const sheetOf = (character) => computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
|
||||
|
||||
const kindOf = (p) => (p.npc || p.npcId ? 'NPC' : p.isMonster ? 'MONSTER' : 'CHARACTER');
|
||||
|
||||
// Out of the fight: a dead character, or a monster or NPC at 0 HP.
|
||||
function isFallen(p) {
|
||||
if (p.character) return lifeState(p.character) === 'DEAD';
|
||||
return p.maxHp > 0 && p.currentHp <= 0;
|
||||
}
|
||||
|
||||
// A participant's AC: the sheet's for a character, the stat block's for a
|
||||
// monster or NPC (10 for an NPC with no combat stats).
|
||||
function armorClassOf(p) {
|
||||
if (p.character) return sheetOf(p.character).armorClass;
|
||||
if (p.monster) return p.monster.ac;
|
||||
return p.npc && p.npc.ac != null ? p.npc.ac : 10;
|
||||
}
|
||||
|
||||
// What a participant adds to a saving throw with an ability: the sheet's save
|
||||
// bonus for a character, the ability modifier for a monster, nothing for an NPC.
|
||||
function saveBonusOf(p, ability) {
|
||||
if (p.character) return sheetOf(p.character).saves[ability].bonus;
|
||||
if (p.monster) return abilityModifier(p.monster[`${ability.toLowerCase()}Score`] ?? 10);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// A participant as game/combat.js sees a combatant.
|
||||
const combatantOf = (p) => ({ id: p.id, name: p.displayName, kind: kindOf(p), disposition: p.npc ? p.npc.disposition : null, ac: armorClassOf(p), fallen: isFallen(p) });
|
||||
|
||||
const areaPenalty = (targetCount, step) => Math.max(0, targetCount - 1) * step;
|
||||
|
||||
// The targets of an action as it starts: who, and the number each roll is
|
||||
// against. `penaltyStep` is the campaign's area-attack AC penalty.
|
||||
function targetRows(kind, detail, targets, penaltyStep) {
|
||||
const penalty = kind === 'ATTACK' ? areaPenalty(targets.length, penaltyStep) : 0;
|
||||
return targets.map((p) => ({
|
||||
participantId: p.id,
|
||||
name: p.displayName,
|
||||
kind: kindOf(p),
|
||||
ac: kind === 'ATTACK' ? armorClassOf(p) : null,
|
||||
penalty,
|
||||
saveBonus: kind === 'SAVE' ? saveBonusOf(p, detail.saveAbility) : null,
|
||||
d20: null,
|
||||
outcome: null,
|
||||
damage: null,
|
||||
}));
|
||||
}
|
||||
|
||||
// What an action is made of, from what it is made with:
|
||||
// { type: 'weapon', characterWeaponId } a character's equipped weapon
|
||||
// { type: 'spell', characterSpellId, slotLevel? } a spell a character knows
|
||||
// { type: 'basic', index? } a monster's or NPC's own attack
|
||||
// { type: 'custom', name, kind, attackBonus?, saveAbility?, saveDC?, halfOnSave?, damageDice? }
|
||||
// anything else, spelled out (the DM's: a breath weapon, a trap)
|
||||
// Returns { kind, name, detail }. Throws ActionError when the actor can't.
|
||||
function describeSource(actor, source) {
|
||||
const character = actor ? actor.character : null;
|
||||
if (source.type === 'custom') {
|
||||
const kind = source.kind || 'ATTACK';
|
||||
return {
|
||||
kind,
|
||||
name: source.name || 'Attack',
|
||||
detail: {
|
||||
attackBonus: kind === 'ATTACK' ? source.attackBonus || 0 : undefined,
|
||||
saveAbility: kind === 'SAVE' ? source.saveAbility || 'DEX' : undefined,
|
||||
saveDC: kind === 'SAVE' ? source.saveDC || 10 : undefined,
|
||||
halfOnSave: kind === 'SAVE' ? Boolean(source.halfOnSave) : undefined,
|
||||
damageDice: source.damageDice || null,
|
||||
damageBonus: 0,
|
||||
damageType: source.damageType || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (!actor) throw new ActionError('Nobody is on turn to make that attack.');
|
||||
|
||||
if (source.type === 'basic') {
|
||||
const attacks = attacksOf({ monster: actor.monster, npc: actor.npc });
|
||||
const attack = attacks[source.index || 0];
|
||||
if (!attack) throw new ActionError(`${actor.displayName} has no attack to make. Give it an attack bonus and damage first.`);
|
||||
return { kind: 'ATTACK', name: attack.name, detail: { attackBonus: attack.attackBonus, damageDice: attack.damageDice, damageBonus: 0, damageType: attack.damageType } };
|
||||
}
|
||||
|
||||
if (!character) throw new ActionError(`${actor.displayName} fights with its own attack, not a weapon or a spell.`);
|
||||
const sheet = sheetOf(character);
|
||||
|
||||
if (source.type === 'weapon') {
|
||||
const held = character.weapons.find((cw) => cw.id === source.characterWeaponId);
|
||||
if (!held) throw new ActionError(`${character.name} does not hold that weapon.`);
|
||||
if (!held.equipped || !canUseGear(character.level, held.level)) throw new ActionError(`${held.weapon.name} has to be equipped (and within ${character.name}'s level) to be used.`);
|
||||
const stats = computeWeaponStats(held.weapon, sheet.abilities, sheet.proficiencyBonus, true, held.level);
|
||||
return {
|
||||
kind: 'ATTACK',
|
||||
name: held.weapon.name,
|
||||
detail: { attackBonus: stats.attackBonus, damageDice: stats.damageDice, damageBonus: stats.damageBonus, damageType: stats.damageType, characterId: character.id },
|
||||
};
|
||||
}
|
||||
|
||||
if (source.type === 'spell') {
|
||||
const known = character.spells.find((cs) => cs.id === source.characterSpellId);
|
||||
if (!known) throw new ActionError(`${character.name} does not know that spell.`);
|
||||
const spell = known.spell;
|
||||
if (!canUseGear(character.level, spell.level)) throw new ActionError(`${spell.name} is beyond ${character.name}'s level.`);
|
||||
const casting = sheet.spellcasting;
|
||||
// A cantrip takes no slot; any other spell takes one of its own level or higher.
|
||||
const slotLevel = spell.level > 0 ? Math.max(spell.level, source.slotLevel || spell.level) : null;
|
||||
const base = { damageDice: spell.damageDice || null, damageBonus: 0, damageType: null, characterId: character.id, spellLevel: spell.level, slotLevel };
|
||||
if (spell.attackRoll) return { kind: 'ATTACK', name: spell.name, detail: { ...base, attackBonus: casting ? casting.attackBonus : 0 } };
|
||||
if (spell.saveAbility) {
|
||||
return { kind: 'SAVE', name: spell.name, detail: { ...base, saveAbility: spell.saveAbility, saveDC: casting ? casting.saveDC : 10, halfOnSave: spell.halfOnSave } };
|
||||
}
|
||||
return { kind: 'AUTO', name: spell.name, detail: base };
|
||||
}
|
||||
throw new ActionError('That is not something to attack with.');
|
||||
}
|
||||
|
||||
// A fair number from 0 up to (not including) 1, for picking a target.
|
||||
const randomUnit = () => crypto.randomInt(0, 1000000) / 1000000;
|
||||
|
||||
// The attacks a monster or NPC makes by itself on its turn, each already
|
||||
// aimed and rolled: who it goes for (game/combat.js, pickTarget), the d20, and
|
||||
// the damage where it hits. `history` is the encounter's applied actions,
|
||||
// newest first, for whom it fought last and who has hurt it. Returns the
|
||||
// actions to create (without ids); empty when it has no attack or nobody to
|
||||
// go for.
|
||||
function autoActions(actor, participants, { penaltyStep = 0, critRule = 'DOUBLE_DICE', history = [] } = {}) {
|
||||
const attacks = attacksOf({ monster: actor.monster, npc: actor.npc });
|
||||
const combatants = participants.map(combatantOf);
|
||||
const opponents = opponentsOf(combatantOf(actor), combatants);
|
||||
if (attacks.length === 0 || opponents.length === 0) return [];
|
||||
|
||||
const mine = history.find((action) => action.actorParticipantId === actor.id);
|
||||
let lastTargetId = mine && mine.targets[0] ? mine.targets[0].participantId : null;
|
||||
const hurtBy = history
|
||||
.filter((action) => (action.targets || []).some((t) => t.participantId === actor.id && t.finalDamage > 0))
|
||||
.map((action) => action.actorParticipantId)
|
||||
.filter(Boolean);
|
||||
|
||||
return attacks.map((attack) => {
|
||||
const picked = pickTarget(opponents, { lastTargetId, hurtBy }, randomUnit);
|
||||
lastTargetId = picked.id; // a creature with several attacks tends to keep at one foe
|
||||
const target = participants.find((p) => p.id === picked.id);
|
||||
const detail = { attackBonus: attack.attackBonus, damageDice: attack.damageDice, damageBonus: 0, damageType: attack.damageType };
|
||||
const [row] = targetRows('ATTACK', detail, [target], penaltyStep);
|
||||
row.d20 = rollDie(20);
|
||||
const outcome = attackOutcome(row.d20, detail.attackBonus, row.ac, row.penalty);
|
||||
return {
|
||||
actorParticipantId: actor.id,
|
||||
actorName: actor.displayName,
|
||||
kind: 'ATTACK',
|
||||
name: attack.name,
|
||||
detail,
|
||||
targets: [row],
|
||||
damage: lands(outcome) ? rollDamage(detail, { critical: outcome === 'CRIT', critRule }) : null,
|
||||
auto: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { ActionError, kindOf, isFallen, armorClassOf, saveBonusOf, combatantOf, areaPenalty, targetRows, describeSource, autoActions };
|
||||
+190
-29
@@ -11,7 +11,7 @@ This describes the code as it stands. The rules live in
|
||||
## Contents
|
||||
|
||||
1. [The basics](#1-the-basics)
|
||||
2. [Battle or quest](#2-battle-or-quest)
|
||||
2. [Battle, quest or free roam](#2-battle-quest-or-free-roam)
|
||||
3. [Status](#3-status)
|
||||
4. [The scene and DM notes](#4-the-scene-and-dm-notes)
|
||||
5. [Who is in the scene](#5-who-is-in-the-scene)
|
||||
@@ -28,8 +28,8 @@ This describes the code as it stands. The rules live in
|
||||
|
||||
## 1. The basics
|
||||
|
||||
- An encounter is one **scene** the DM runs: a fight, or a meeting with
|
||||
people who want something.
|
||||
- An encounter is one **scene** the DM runs: a fight, a meeting with people
|
||||
who want something, or a stretch of free roaming between places.
|
||||
- Encounters belong to a campaign and are listed on the campaign page. The DM
|
||||
creates them and sees all of them.
|
||||
- A player only sees an encounter that **one of their own characters is in**.
|
||||
@@ -39,31 +39,62 @@ This describes the code as it stands. The rules live in
|
||||
- The encounter page updates live for everyone who has it open.
|
||||
- The DM runs everything. Players watch, and see less than the DM does.
|
||||
|
||||
## 2. Battle or quest
|
||||
## 2. Battle, quest or free roam
|
||||
|
||||
Every encounter has a mode, chosen when it is created and switchable at any
|
||||
time from the top of the page.
|
||||
|
||||
| | Battle | NPC quest |
|
||||
|---|---|---|
|
||||
| For | A fight | A scene built around people and an objective |
|
||||
| Layout | Everyone in one list, in initiative order; once in progress, those who have had their turn move to "Next Round" at the bottom | Three groups: "People you meet", "The party", "Creatures" |
|
||||
| Rounds and turns | Yes | No |
|
||||
| Initiative | The d20 roll, typed in per combatant; the bonus is added | The same; each group lists highest first |
|
||||
| Start button | Begin Battle | Begin Quest |
|
||||
| End button | End Battle | Complete Quest |
|
||||
| Trade (section 10) | No | Yes, while in progress |
|
||||
| | Battle | NPC quest | Free roam |
|
||||
|---|---|---|---|
|
||||
| For | A fight | A scene built around people and an objective | The party wandering between places: shopping, meeting people |
|
||||
| Layout | Everyone in one list, in initiative order; once in progress, those who have had their turn move to "Next Round" at the bottom | Three groups: "People you meet", "The party", "Creatures" | A card per place, then the party |
|
||||
| Rounds and turns | Yes | No | No |
|
||||
| Initiative | The d20 roll, typed in per combatant; the bonus is added | The same; each group lists highest first | Not needed |
|
||||
| Start button | Begin Battle | Begin Quest | Begin Roaming |
|
||||
| End button | End Battle | Complete Quest | End Free Roam |
|
||||
| Trade (section 10) | No | Yes, while in progress, with the NPCs in the scene | Yes, while in progress, at the place a character has gone to |
|
||||
| Who is in it | Whoever the DM adds | Whoever the DM adds | The whole party, from the start |
|
||||
|
||||
Everything else (hit points, conditions, rewards, the scene description, DM
|
||||
notes) works the same in both.
|
||||
notes) works the same in all three.
|
||||
|
||||
### Free roam
|
||||
|
||||
A free-roam encounter lays out **places the party can go to**: some of the
|
||||
campaign's locations, picked by the DM as the ones nearby.
|
||||
|
||||
- **Creating one** puts every finished, living character in it straight away,
|
||||
so every player sees it in their list without being added.
|
||||
- **Places.** On its page the DM ticks which of the campaign's locations are
|
||||
part of the scene ("Places in this scene"), and can tick more, or untick
|
||||
some, while it is in progress. Locations are made on the campaign page.
|
||||
- **Each place is a card** showing its description, who of the party is there
|
||||
now, the **NPCs found there** (name, role, description, what they sell and
|
||||
the rate they change money at), and the place's **own shop**. Everyone in
|
||||
the scene sees every place in it, whether or not they have been there.
|
||||
- **Going somewhere.** Once the DM presses Begin Roaming, a player picks their
|
||||
character and presses **Go here** on a place: the character is now there
|
||||
(it shows on their sheet too, and the place joins the ones they know). The
|
||||
DM can send anyone.
|
||||
- **Shopping.** A character buys from a place's shop, and buys from or changes
|
||||
money with an NPC found there, only while they are **at that place**. The
|
||||
Buy button says so otherwise.
|
||||
- **Setting it up (DM).** A place's own shop is stocked from its card ("Set up
|
||||
this place's own shop"): weapons and items from the campaign's pools, with a
|
||||
price and stock, exactly as an NPC's wares are. It belongs to the location,
|
||||
so it is there in every free-roam scene that includes the place. An NPC is
|
||||
put at a place with **Found at** on the NPC's pop-out
|
||||
([NPCS.md](NPCS.md)); what they sell is set there too.
|
||||
- An encounter's own location (the picker at the top) does not pull the party
|
||||
together in free roam: each character is where they were sent.
|
||||
|
||||
## 3. Status
|
||||
|
||||
| Status | Meaning | How to get there |
|
||||
|---|---|---|
|
||||
| Planning | Being set up | Every new encounter starts here |
|
||||
| In progress | Being played | Begin Battle / Begin Quest, or Reopen |
|
||||
| Complete | Finished | End Battle / Complete Quest |
|
||||
| In progress | Being played | Begin Battle / Begin Quest / Begin Roaming, or Reopen |
|
||||
| Complete | Finished | End Battle / Complete Quest / End Free Roam |
|
||||
|
||||
Status is a label. Nothing is locked when an encounter is complete: the DM
|
||||
can still change HP, add people and award rewards. Encounters cannot be
|
||||
@@ -96,6 +127,13 @@ with three pages or more, a menu to jump to any page), and
|
||||
the DM's archived list is paged the same way. The order runs on across pages:
|
||||
moving the last encounter on a page down carries it to the top of the next.
|
||||
|
||||
**Completed encounters are hidden.** An encounter that is **Complete** is
|
||||
left off the list, for the DM and the players alike, so the list is what is
|
||||
still to play. A **Show completed (N)** tick box above the list brings them
|
||||
back; the choice is remembered per browser. ▲ and ▼ move an encounter past its
|
||||
neighbour on the list as shown. Opening a completed encounter from a link
|
||||
still works, and nothing about it changes.
|
||||
|
||||
**Archive.** "Archive" (on the list or the encounter page) puts an encounter
|
||||
away without changing it. It moves to the DM's "Archived" section and
|
||||
disappears from the players' list; a player whose character is in it can still
|
||||
@@ -319,6 +357,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 +386,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 +399,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 +441,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
|
||||
|
||||
@@ -514,8 +662,9 @@ characters' sheets. XP never levels anyone up by itself
|
||||
|
||||
## 10. Trade
|
||||
|
||||
In an NPC quest the party can **change money** and **buy things**. Both take
|
||||
someone to deal with and the time to do it, so:
|
||||
In an NPC quest the party can **change money** and **buy things**. (In free
|
||||
roam they do the same at the place each character has gone to: section 2,
|
||||
"Free roam".) Both take someone to deal with and the time to do it, so:
|
||||
|
||||
- Trading is open only in an **NPC quest that is in progress**. Before "Begin
|
||||
Quest" and after "Complete Quest" everything on offer is still shown, but
|
||||
@@ -570,7 +719,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 +739,14 @@ 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 |
|
||||
| Pick a free-roam scene's places, stock a place's shop | Yes | No |
|
||||
| Send a character to a place in free roam | Yes, anyone | Their own character |
|
||||
| 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 +767,12 @@ People outside the campaign cannot open its encounters.
|
||||
|
||||
- Rolling initiative for characters into their field (a player clicks their
|
||||
Init chip to roll it, then types what came up)
|
||||
- Applying an attack's damage automatically: attacks and damage are rolled
|
||||
with a click ([CHARACTERS.md](CHARACTERS.md), "Rolling from the sheet") and
|
||||
the DM enters the result
|
||||
- Rolls that everyone at the table sees
|
||||
- Advantage and disadvantage on an attack or save within an action (roll both
|
||||
d20s and enter the one that counts), resistances and vulnerabilities (set
|
||||
the target's damage by hand), and upcasting a spell for more damage
|
||||
- Healing through an action (use + Heal on the card or the damage bar)
|
||||
- A monster choosing among different attacks by itself: with auto-roll it
|
||||
makes all of its named attacks, or its single one
|
||||
- Death saves for monsters and NPCs, and prompting for a save on a dying
|
||||
character's turn
|
||||
- A roll, a spell slot or a time limit for reviving the dead
|
||||
|
||||
+9
-3
@@ -40,12 +40,17 @@ This describes the code as it stands. The built-in list is
|
||||
| Ability scores | STR, DEX, CON, INT, WIS, CHA. 10 unless set otherwise |
|
||||
| Attack bonus | Added to the monster's attack roll |
|
||||
| Damage | Dice for its main attack, e.g. `1d6+2`, with the damage type if you like: `1d6+2 necrotic` |
|
||||
| Named attacks | Optional, up to 8: each a name, an attack bonus, damage dice, a damage type and **how many times it is made each turn** (a multiattack: "Claw ×2, Bite ×1"). With any listed, these are what the monster attacks with in an encounter, in place of the single attack above |
|
||||
| Challenge rating | A rough danger rating, e.g. `1/4`, `2`. It sets what the monster is worth in XP and how hard an encounter is ([ENCOUNTERS.md](ENCOUNTERS.md), section 9); a monster with none, or with text that is not a rating, is left out of both |
|
||||
| Special trait | A short note such as "Pack Tactics" |
|
||||
| Notes | Anything else: what it attacks with, how it behaves |
|
||||
|
||||
Apart from the challenge rating, none of these are used in formulas. They
|
||||
are reference numbers shown to the DM during a fight. A monster's ability
|
||||
The challenge rating feeds the XP and difficulty sums. The AC, the attack
|
||||
bonus and damage (or the named attacks) and the ability scores are used when
|
||||
an attack or a spell is resolved in an encounter: an attack roll is judged
|
||||
against the AC, a saving throw adds the ability's modifier, and with
|
||||
auto-roll on the monster's attacks are aimed and rolled for the DM
|
||||
([ENCOUNTERS.md](ENCOUNTERS.md), "Attacks and spells"). A monster's ability
|
||||
modifiers are worked out from its scores for display, for its initiative
|
||||
bonus (Dexterity) and for the rolls a click makes (section 5).
|
||||
|
||||
@@ -186,7 +191,8 @@ Then they get the health bar only.
|
||||
|
||||
## 8. What is not modelled
|
||||
|
||||
- Multiple attacks, spells, legendary actions and resistances (use Notes)
|
||||
- Spells, legendary actions and resistances (use Notes; in an encounter the
|
||||
DM can spell out any attack or save on the spot)
|
||||
- Trade: monsters do not sell things or change money (NPCs do)
|
||||
- Saving throw proficiencies and skills (on an encounter card a click on an
|
||||
ability score rolls a d20 with its modifier, for a check or a save)
|
||||
|
||||
@@ -50,6 +50,13 @@ This describes the code as it stands. Related: [ENCOUNTERS.md](ENCOUNTERS.md),
|
||||
|
||||
A new NPC is Neutral unless you choose otherwise.
|
||||
|
||||
**Found at.** An NPC can be given one of the campaign's locations as the place
|
||||
they are usually found, on their pop-out. In a **free-roam** encounter that
|
||||
includes that place, the NPC appears on its card, and a character who goes
|
||||
there can buy from them and change money with them
|
||||
([ENCOUNTERS.md](ENCOUNTERS.md), section 2, "Free roam"). It changes nothing in
|
||||
a quest or a battle, where an NPC takes part because the DM added them.
|
||||
|
||||
## 3. Managing NPCs
|
||||
|
||||
The NPC list is the "NPCs" part of the campaign page (DM only). Each NPC is a
|
||||
|
||||
@@ -164,6 +164,25 @@ A spell's damage is whatever is picked in its Damage drop-down, shown beside
|
||||
the spell on the sheet and in encounters. It does not change with the character's
|
||||
level, and nothing is added to it.
|
||||
|
||||
### How a spell lands in a battle
|
||||
|
||||
Each spell says how it is resolved when it is cast in an encounter
|
||||
([ENCOUNTERS.md](ENCOUNTERS.md), "Attacks and spells"). The DM sets it under
|
||||
**In a battle** when adding a spell, or on its pop-out:
|
||||
|
||||
| Setting | In an encounter |
|
||||
|---|---|
|
||||
| It just takes effect | No roll: it lands (Magic Missile, a buff) |
|
||||
| The caster rolls a spell attack | A d20 plus the caster's spell attack bonus against each target's AC |
|
||||
| A save with an ability, half damage on a success | Each target rolls a d20 plus their save bonus against the caster's spell save DC; a success takes half (Fireball) |
|
||||
| A save with an ability, nothing on a success | The same, but a success takes nothing (Sacred Flame, Hold Person) |
|
||||
|
||||
The library's spells come already set (Fire Bolt and Guiding Bolt as spell
|
||||
attacks; Fireball, Burning Hands, Hold Person and the like as saves), and
|
||||
copies already in a campaign were marked to match when this was added.
|
||||
Casting a spell of 1st level or higher this way spends a slot of its level
|
||||
when the DM applies it.
|
||||
|
||||
## 7. Spellcasting numbers
|
||||
|
||||
Shown only for classes with a spellcasting ability:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.26.0",
|
||||
"version": "1.28.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from './client';
|
||||
import type {
|
||||
Ability,
|
||||
ActionPatch,
|
||||
ActionSource,
|
||||
BuildOptions,
|
||||
Campaign,
|
||||
CharacterClass,
|
||||
@@ -644,6 +647,11 @@ export function usePatchPoolEntry(campaignId: string, pool: Pool) {
|
||||
exchangeCash?: number | null;
|
||||
effects?: GearEffect[];
|
||||
effectNote?: string | null;
|
||||
// A spell: how it lands in a battle. An NPC: where they are found.
|
||||
attackRoll?: boolean;
|
||||
saveAbility?: Ability | null;
|
||||
halfOnSave?: boolean;
|
||||
locationId?: string | null;
|
||||
}) =>
|
||||
(await apiClient.patch(`/campaigns/${campaignId}/${pool}/${id}`, data)).data,
|
||||
onSuccess: () => {
|
||||
@@ -838,7 +846,7 @@ export function useCreateEncounter(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
// Where it goes in the campaign's order: the top, the bottom (the default), or straight after another.
|
||||
mutationFn: async (data: { name: string; mode?: EncounterMode; position?: 'TOP' | 'BOTTOM'; afterEncounterId?: string }) =>
|
||||
mutationFn: async (data: { name: string; mode?: EncounterMode; position?: 'TOP' | 'BOTTOM'; afterEncounterId?: string; placeIds?: string[] }) =>
|
||||
(await apiClient.post<{ encounter: Encounter }>(`/campaigns/${campaignId}/encounters`, data)).data.encounter,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] }),
|
||||
});
|
||||
@@ -984,6 +992,61 @@ export function useSetTargets(encounterId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// Free roam: the DM sets the places the party can go to in the scene.
|
||||
export function useSetPlaces(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (locationIds: string[]) => (await apiClient.put(`/encounters/${encounterId}/places`, { locationIds })).data,
|
||||
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
// Free roam: a character goes to one of the scene's places.
|
||||
export function useVisitPlace(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { characterId: string; locationId: string }) => (await apiClient.post(`/encounters/${encounterId}/visit`, data)).data,
|
||||
onSuccess: (data, { characterId }) => {
|
||||
keepEncounter(qc, encounterId, data);
|
||||
qc.invalidateQueries({ queryKey: ['character', characterId] });
|
||||
qc.invalidateQueries({ queryKey: ['campaign'] }); // the places a player knows, and where the party is
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -1086,7 +1149,7 @@ export function useCreateNpc(campaignId: string) {
|
||||
// in progress, characters in the scene change money and buy
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
export type NewWare = ({ npcId: string } | { encounterId: string }) &
|
||||
export type NewWare = ({ npcId: string } | { encounterId: string } | { locationId: string }) &
|
||||
({ weaponId: string } | { itemId: string }) & { price: number; currency: Currency; level?: number; stock: number | null };
|
||||
|
||||
// Wares reach the screen inside the NPC list and inside each encounter.
|
||||
|
||||
+110
-2
@@ -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,10 +571,21 @@ 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';
|
||||
export type EncounterMode = 'BATTLE' | 'QUEST';
|
||||
// A fight in initiative order, a quest scene around NPCs, or free roam between places the DM has laid out.
|
||||
export type EncounterMode = 'BATTLE' | 'QUEST' | 'FREE_ROAM';
|
||||
export type NpcDisposition = 'FRIENDLY' | 'NEUTRAL' | 'HOSTILE';
|
||||
|
||||
// Something for sale: a weapon or item from the campaign pools, at a price.
|
||||
@@ -579,6 +595,7 @@ export interface SaleOffer {
|
||||
campaignId: string;
|
||||
npcId: string | null;
|
||||
encounterId: string | null;
|
||||
locationId: string | null; // a shop that is always at this place
|
||||
weaponId: string | null;
|
||||
itemId: string | null;
|
||||
price: number; // for one, in `currency`
|
||||
@@ -601,6 +618,18 @@ export interface NpcPublic {
|
||||
exchangeGold: number | null;
|
||||
exchangeCash: number | null;
|
||||
wares?: SaleOffer[]; // what they have for sale
|
||||
locationId?: string | null; // where they are usually found: met there in a free-roam scene
|
||||
}
|
||||
|
||||
// A place the party can go to in a free-roam scene: who is found there, what
|
||||
// is for sale there, and which of the characters in the scene are there now.
|
||||
export interface FreeRoamPlace {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
wares: SaleOffer[];
|
||||
npcs: (NpcPublic & Partial<Npc>)[];
|
||||
here: { characterId: string; name: string; userId: string | null }[];
|
||||
}
|
||||
|
||||
// The DM's full record. dmNotes and the combat stats never reach a player.
|
||||
@@ -657,6 +686,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 {
|
||||
@@ -714,7 +816,8 @@ export interface EncounterParticipant {
|
||||
// hidden from the players. Only the DM is ever sent one of these.
|
||||
hidden: boolean;
|
||||
npcId: string | null;
|
||||
character?: { id: string; name: string; userId: string | null } | null;
|
||||
// currentLocationId is sent in a free-roam scene only: where they have gone.
|
||||
character?: { id: string; name: string; userId: string | null; currentLocationId?: string | null } | null;
|
||||
// The DM gets the whole stat block; players only the id and name.
|
||||
monster?: ({ id: string; name: string } & Partial<Monster>) | null;
|
||||
npc?: (NpcPublic & Partial<Npc>) | null;
|
||||
@@ -746,6 +849,11 @@ 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[];
|
||||
places?: FreeRoamPlace[]; // a free-roam scene's places; absent for any other kind
|
||||
rewards?: EncounterReward[]; // players only receive the awarded ones
|
||||
wares?: SaleOffer[]; // for sale at the scene itself, not by an NPC in it
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { CharacterSummary, Encounter, Item, SaleOffer, Weapon } from '../ap
|
||||
|
||||
type ExchangeSide = 'GOLD' | 'CASH';
|
||||
|
||||
// (A free-roam scene has its own panel: components/encounter/FreeRoam.tsx.)
|
||||
// Trade in a quest: changing gold and cash with an NPC who does that, at
|
||||
// their own rate, and buying what the NPCs in the scene — or the scene itself
|
||||
// — have for sale. Everyone sees what is on offer; a player trades for their
|
||||
|
||||
@@ -73,8 +73,8 @@ function WareRow({ campaignId, ware, onError }: { campaignId: string; ware: Sale
|
||||
);
|
||||
}
|
||||
|
||||
// The DM's list of what one seller has for sale — an NPC, or an encounter
|
||||
// itself — with a form to put something from the campaign pools up for sale.
|
||||
// The DM's list of what one seller has for sale — an NPC, an encounter
|
||||
// itself, or a location (a shop that is always there) — with a form to put something from the campaign pools up for sale.
|
||||
export function WaresEditor({
|
||||
campaignId,
|
||||
seller,
|
||||
@@ -84,7 +84,7 @@ export function WaresEditor({
|
||||
onError,
|
||||
}: {
|
||||
campaignId: string;
|
||||
seller: { npcId: string } | { encounterId: string };
|
||||
seller: { npcId: string } | { encounterId: string } | { locationId: string };
|
||||
wares: SaleOffer[];
|
||||
weapons: Weapon[];
|
||||
items: Item[];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useDeleteMonster, useDeletePoolEntry, usePatchPoolEntry, useUpdateMonster } from '../../api/queries';
|
||||
import { useCampaignLocations, useDeleteMonster, useDeletePoolEntry, usePatchPoolEntry, useUpdateMonster } from '../../api/queries';
|
||||
import { Modal } from '../Modal';
|
||||
import { NumberInput } from '../NumberInput';
|
||||
import { ErrorBanner } from '../Spinner';
|
||||
@@ -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 && (
|
||||
@@ -220,6 +230,7 @@ function MonsterDetail({ campaignId, monster, added, onClose }: { added: ReactNo
|
||||
function NpcDetail({ campaignId, npc, weapons, items, added, onClose }: { added: ReactNode; campaignId: string; npc: Npc; weapons: Weapon[]; items: Item[]; onClose: () => void }) {
|
||||
const patch = usePatchPoolEntry(campaignId, 'npcs');
|
||||
const remove = useDeletePoolEntry(campaignId, 'npcs');
|
||||
const { data: locations } = useCampaignLocations(campaignId);
|
||||
const [gold, setGold] = useState(npc.exchangeGold ? String(npc.exchangeGold) : '');
|
||||
const [cash, setCash] = useState(npc.exchangeCash ? String(npc.exchangeCash) : '');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -253,6 +264,26 @@ function NpcDetail({ campaignId, npc, weapons, items, added, onClose }: { added:
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="section-label mt-md">Found at</div>
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
Where the party meets {npc.name} in a free-roam scene: whoever goes to that place can talk and trade with them there. (In a quest, they
|
||||
are met when you add them to it.)
|
||||
</p>
|
||||
<select
|
||||
aria-label={`Where ${npc.name} is found`}
|
||||
value={npc.locationId ?? ''}
|
||||
disabled={patch.isPending}
|
||||
onChange={(e) => patch.mutate({ id: npc.id, locationId: e.target.value || null }, { onError })}
|
||||
style={{ maxWidth: 320 }}
|
||||
>
|
||||
<option value="">Nowhere in particular</option>
|
||||
{(locations ?? []).map((loc) => (
|
||||
<option key={loc.id} value={loc.id}>
|
||||
📍 {loc.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="section-label mt-md">Changes money</div>
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
Set a rate and the party can change gold into cash, or cash into gold, with {npc.name}. Silver and copper are not
|
||||
|
||||
@@ -18,7 +18,8 @@ import { effectsSummary } from '../../lib/gear';
|
||||
import { extractErrorMessage, fmtMod, spellLevelLabel, spellRequirementLabel } from '../../lib/format';
|
||||
import { MAX_GEAR_LEVEL } from '../../lib/previewCalc';
|
||||
import { abilityLine, rateLabel, type EntryRef } from '../../lib/campaignEntries';
|
||||
import { EntryList, Field, LibraryPicker, MonsterForm, ScalingSelect, SpellDamageSelect, StartLevelSelect } from './parts';
|
||||
import { EntryList, Field, LibraryPicker, MonsterForm, ScalingSelect, SpellDamageSelect, SpellLandingSelect, StartLevelSelect } from './parts';
|
||||
import { landingFrom } from '../../lib/campaignEntries';
|
||||
import type { CampaignLibrary, Item, LibraryItem, LibraryMonster, LibrarySpell, LibraryWeapon, Monster, Npc, NpcDisposition, Spell, Weapon } from '../../api/types';
|
||||
|
||||
interface SectionProps<T> {
|
||||
@@ -347,6 +348,7 @@ export function SpellsSection({ campaignId, entries, onOpen, formRequested, onFo
|
||||
damageDice: String(fd.get('damageDice') || '') || null,
|
||||
isStartingOption: fd.get('isStartingOption') === 'on',
|
||||
revives: fd.get('revives') === 'on',
|
||||
...landingFrom(String(fd.get('landing') || '')),
|
||||
});
|
||||
close();
|
||||
onOpen({ kind: 'spell', id: spell.id, isNew: true });
|
||||
@@ -412,6 +414,7 @@ export function SpellsSection({ campaignId, entries, onOpen, formRequested, onFo
|
||||
</Field>
|
||||
<Field label="School (optional)">{(id) => <input id={id} name="school" />}</Field>
|
||||
<Field label="Damage">{(id) => <SpellDamageSelect id={id} name="damageDice" />}</Field>
|
||||
<Field label="In a battle" style={{ gridColumn: '1 / -1' }}>{(id) => <SpellLandingSelect id={id} name="landing" />}</Field>
|
||||
<Field label="What it does (one sentence)" style={{ gridColumn: '1 / -1' }}>
|
||||
{(id) => <input id={id} name="description" maxLength={4000} required />}
|
||||
</Field>
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Pagination } from '../Paginated';
|
||||
import { usePagination } from '../../hooks/usePagination';
|
||||
import { SPELL_DAMAGE_GROUPS, SPELL_DAMAGE_SPECIALS } from '../../lib/format';
|
||||
import { ABILITY_ORDER, DAMAGE_SCALING_LABELS, MAX_GEAR_LEVEL } from '../../lib/previewCalc';
|
||||
import { matchesSearch, scoreField, type MonsterInput } from '../../lib/campaignEntries';
|
||||
import type { Monster } from '../../api/types';
|
||||
import { landingFrom, matchesSearch, scoreField, type MonsterInput, type SpellLanding } from '../../lib/campaignEntries';
|
||||
import type { Ability, Monster, MonsterAttack } from '../../api/types';
|
||||
|
||||
// A labelled field in a pop-out form. The child is handed the id its label points at.
|
||||
export function Field({ label, style, children }: { label: string; style?: CSSProperties; children: (id: string) => ReactNode }) {
|
||||
@@ -19,6 +19,30 @@ export function Field({ label, style, children }: { label: string; style?: CSSPr
|
||||
);
|
||||
}
|
||||
|
||||
const LANDING_ABILITIES: Ability[] = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
|
||||
const ABILITY_NAMES: Record<Ability, string> = { STR: 'Strength', DEX: 'Dexterity', CON: 'Constitution', INT: 'Intelligence', WIS: 'Wisdom', CHA: 'Charisma' };
|
||||
|
||||
// How a spell lands in a battle, as one choice (lib/campaignEntries.ts, SpellLanding).
|
||||
export function SpellLandingSelect({ id, name, value, onChange }: { id?: string; name?: string; value?: string; onChange?: (landing: SpellLanding) => void }) {
|
||||
return (
|
||||
<select
|
||||
id={id}
|
||||
name={name}
|
||||
title="In a battle: whether the caster rolls to hit, each target makes a saving throw, or it simply happens"
|
||||
{...(onChange ? { value, onChange: (e) => onChange(landingFrom(e.target.value)) } : { defaultValue: '' })}
|
||||
>
|
||||
<option value="">It just takes effect (no roll)</option>
|
||||
<option value="ATTACK">The caster rolls a spell attack</option>
|
||||
{LANDING_ABILITIES.map((a) => (
|
||||
<optgroup key={a} label={`${ABILITY_NAMES[a]} save`}>
|
||||
<option value={`${a}:HALF`}>{ABILITY_NAMES[a]} save, half damage on a success</option>
|
||||
<option value={`${a}:NONE`}>{ABILITY_NAMES[a]} save, nothing on a success</option>
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
// How fast a weapon's or item's dice grow as the DM levels a held copy up.
|
||||
export function ScalingSelect({ id, name, value, onChange }: { id?: string; name?: string; value?: number; onChange?: (scaling: number) => void }) {
|
||||
return (
|
||||
@@ -119,6 +143,17 @@ export function MonsterForm({
|
||||
onSave: (data: MonsterInput) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
// Named attacks, as typed: kept as text until saved.
|
||||
const [attacks, setAttacks] = useState<{ name: string; attackBonus: string; damageDice: string; damageType: string; count: string }[]>(() =>
|
||||
(monster?.attacks ?? []).map((a) => ({
|
||||
name: a.name,
|
||||
attackBonus: String(a.attackBonus),
|
||||
damageDice: a.damageDice ?? '',
|
||||
damageType: a.damageType ?? '',
|
||||
count: String(a.count ?? 1),
|
||||
}))
|
||||
);
|
||||
const setAttack = (index: number, change: Partial<(typeof attacks)[number]>) => setAttacks((rows) => rows.map((row, i) => (i === index ? { ...row, ...change } : row)));
|
||||
const field = (name: string, label: string, input: ReactNode, style?: CSSProperties) => (
|
||||
<div className="field" style={{ marginBottom: 0, ...style }}>
|
||||
<label htmlFor={`monster-${name}`}>{label}</label>
|
||||
@@ -164,6 +199,17 @@ export function MonsterForm({
|
||||
chaScore: num('chaScore'),
|
||||
specialTrait: text('specialTrait'),
|
||||
notes: text('notes'),
|
||||
attacks: attacks
|
||||
.filter((a) => a.name.trim())
|
||||
.map(
|
||||
(a): MonsterAttack => ({
|
||||
name: a.name.trim(),
|
||||
attackBonus: Math.trunc(Number(a.attackBonus)) || 0,
|
||||
damageDice: a.damageDice.trim() || null,
|
||||
damageType: a.damageType.trim() || null,
|
||||
count: Math.min(10, Math.max(1, Math.trunc(Number(a.count)) || 1)),
|
||||
})
|
||||
),
|
||||
});
|
||||
} catch {
|
||||
// Whoever opened the form shows the error; what was typed stays.
|
||||
@@ -190,6 +236,30 @@ export function MonsterForm({
|
||||
<div className="grid mt-sm" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))' }}>
|
||||
{ABILITY_ORDER.map((a) => number(scoreField(a), a, 1, 30, 10))}
|
||||
</div>
|
||||
<div className="field" style={{ marginTop: '0.5rem', marginBottom: 0 }}>
|
||||
<label>Named attacks (optional)</label>
|
||||
<p className="combatant-sub" style={{ margin: '0 0 0.3rem' }}>
|
||||
For a creature with more than the one attack above: each with its own bonus and damage, and how many times it is made each turn (a multiattack).
|
||||
With any listed here, these are what it attacks with.
|
||||
</p>
|
||||
{attacks.map((a, i) => (
|
||||
<div key={i} className="flex-row flex-wrap gap-sm" style={{ marginBottom: '0.3rem' }}>
|
||||
<input aria-label="Attack name" placeholder="Claw" value={a.name} maxLength={40} onChange={(e) => setAttack(i, { name: e.target.value })} style={{ flex: '2 1 110px' }} />
|
||||
<NumberInput aria-label="Attack bonus" placeholder="+hit" min={-20} max={50} value={a.attackBonus} onChange={(e) => setAttack(i, { attackBonus: e.target.value })} style={{ width: 70 }} />
|
||||
<input aria-label="Damage dice" placeholder="2d6+3" value={a.damageDice} maxLength={60} onChange={(e) => setAttack(i, { damageDice: e.target.value })} style={{ flex: '1 1 80px' }} />
|
||||
<input aria-label="Damage type" placeholder="slashing" value={a.damageType} maxLength={30} onChange={(e) => setAttack(i, { damageType: e.target.value })} style={{ flex: '1 1 80px' }} />
|
||||
<NumberInput aria-label="Times per turn" title="How many times it is made each turn" min={1} max={10} value={a.count} onChange={(e) => setAttack(i, { count: e.target.value })} style={{ width: 60 }} />
|
||||
<button type="button" className="btn btn-ghost" aria-label={`Remove attack ${a.name || i + 1}`} onClick={() => setAttacks((rows) => rows.filter((_, at) => at !== i))}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{attacks.length < 8 && (
|
||||
<button type="button" className="btn btn-sm" onClick={() => setAttacks((rows) => [...rows, { name: '', attackBonus: String(monster?.attackBonus ?? 0), damageDice: '', damageType: '', count: '1' }])}>
|
||||
+ Add an attack
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{field(
|
||||
'specialTrait',
|
||||
'Special trait',
|
||||
|
||||
@@ -0,0 +1,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,266 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useBuyWare, useExchangeWithNpc, useSetPlaces, useVisitPlace } from '../../api/queries';
|
||||
import { NumberInput } from '../NumberInput';
|
||||
import { WaresEditor } from '../Wares';
|
||||
import { stockLabel, wareName } from '../../lib/wares';
|
||||
import { formatMoney, formatPurse } from '../../lib/format';
|
||||
import type { CharacterSummary, Encounter, FreeRoamPlace, Item, Location, SaleOffer, Weapon } from '../../api/types';
|
||||
|
||||
type ExchangeSide = 'GOLD' | 'CASH';
|
||||
|
||||
// A free-roam scene: the places the DM has laid out, each with who is found
|
||||
// there and what is for sale, and who of the party is there now. A player
|
||||
// sends their own character to a place and shops where they stand; the DM
|
||||
// can send anyone, picks which of the campaign's locations are part of the
|
||||
// scene, and stocks each place's own shop here. (What an NPC sells, and where
|
||||
// they are found, is set on the NPC, on the campaign page.)
|
||||
export function FreeRoamPanel({
|
||||
encounter,
|
||||
isDM,
|
||||
userId,
|
||||
characters,
|
||||
locations,
|
||||
weapons,
|
||||
items,
|
||||
onError,
|
||||
}: {
|
||||
encounter: Encounter;
|
||||
isDM: boolean;
|
||||
userId: string | undefined;
|
||||
characters: CharacterSummary[];
|
||||
locations: Location[]; // the campaign's, for the DM to pick places from
|
||||
weapons: Weapon[];
|
||||
items: Item[];
|
||||
onError: (err: unknown) => void;
|
||||
}) {
|
||||
const setPlaces = useSetPlaces(encounter.id);
|
||||
const visit = useVisitPlace(encounter.id);
|
||||
const buy = useBuyWare(encounter.id);
|
||||
const exchange = useExchangeWithNpc(encounter.id);
|
||||
const [roamerId, setRoamerId] = useState('');
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
const [amounts, setAmounts] = useState<Record<string, string>>({});
|
||||
const [sides, setSides] = useState<Record<string, ExchangeSide>>({});
|
||||
|
||||
const places = encounter.places ?? [];
|
||||
const open = encounter.status === 'ACTIVE';
|
||||
// Who can be moved and can shop: a player's own characters in the scene; for the DM, anyone's.
|
||||
const roamers = (encounter.participants ?? []).flatMap((p) => (p.character && !p.hidden && (isDM || p.character.userId === userId) ? [p.character] : []));
|
||||
const roamer = roamers.find((c) => c.id === roamerId) ?? roamers[0];
|
||||
const purse = characters.find((c) => c.id === roamer?.id);
|
||||
const whereabouts = places.find((place) => place.here.some((h) => h.characterId === roamer?.id));
|
||||
const busy = visit.isPending || buy.isPending || exchange.isPending;
|
||||
const placeIds = places.map((p) => p.id);
|
||||
|
||||
function go(place: FreeRoamPlace) {
|
||||
if (!roamer) return;
|
||||
setDone(null);
|
||||
visit.mutate({ characterId: roamer.id, locationId: place.id }, { onError, onSuccess: () => setDone(`${roamer.name} goes to ${place.name}.`) });
|
||||
}
|
||||
|
||||
function handleBuy(ware: SaleOffer) {
|
||||
if (!roamer) return;
|
||||
setDone(null);
|
||||
buy.mutate(
|
||||
{ wareId: ware.id, characterId: roamer.id },
|
||||
{ onError, onSuccess: () => setDone(`${roamer.name} bought ${(ware.weapon ?? ware.item)?.name} for ${formatMoney(ware.price, ware.currency, true)}.`) }
|
||||
);
|
||||
}
|
||||
|
||||
const wareRow = (ware: SaleOffer, atPlace: boolean) => (
|
||||
<div key={ware.id} className="reward-row">
|
||||
<div className="flex-between">
|
||||
<span>
|
||||
<strong>{wareName(ware)}</strong>
|
||||
<span className="combatant-sub"> · {stockLabel(ware)}</span>
|
||||
</span>
|
||||
<button
|
||||
className="btn"
|
||||
disabled={!open || !roamer || !atPlace || ware.stock === 0 || busy}
|
||||
title={!open ? 'The scene has not begun' : !atPlace ? `${roamer?.name ?? 'Your character'} has to go here first` : undefined}
|
||||
onClick={() => handleBuy(ware)}
|
||||
>
|
||||
Buy · {formatMoney(ware.price, ware.currency)}
|
||||
</button>
|
||||
</div>
|
||||
{ware.item?.description && <div className="combatant-sub">{ware.item.description}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
<div className="card-title">
|
||||
<span>🧭 Places to go</span>
|
||||
{!open && <span className="badge">{encounter.status === 'PLANNING' ? 'Not begun yet' : 'Over'}</span>}
|
||||
</div>
|
||||
{roamers.length > 0 ? (
|
||||
<div className="flex-row flex-wrap gap-sm">
|
||||
<label className="flex-row gap-sm" style={{ flex: 1, minWidth: 180 }}>
|
||||
{roamers.length > 1 ? 'Moving' : 'You are'}
|
||||
<select aria-label="Which character is moving and shopping" value={roamer?.id ?? ''} onChange={(e) => setRoamerId(e.target.value)} style={{ flex: 1 }}>
|
||||
{roamers.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span className="combatant-sub">📍 {whereabouts ? whereabouts.name : 'not at any of these places yet'}</span>
|
||||
{purse && purse.gold !== null && <span className="mono">{formatPurse(purse)}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<p className="combatant-sub" style={{ margin: 0 }}>
|
||||
{isDM ? 'No characters are in this scene yet: add them from "Add to the scene".' : 'Your character has to be in the scene to go anywhere.'}
|
||||
</p>
|
||||
)}
|
||||
{done && <p className="combatant-sub" style={{ marginBottom: 0 }}>✓ {done}</p>}
|
||||
{places.length === 0 && (
|
||||
<p className="combatant-sub" style={{ marginBottom: 0 }}>
|
||||
{isDM ? 'No places yet. Tick the locations the party can go to, below.' : 'The DM has not opened any places yet.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isDM && (
|
||||
<details className="mt-sm" open={places.length === 0}>
|
||||
<summary className="section-label" style={{ cursor: 'pointer' }}>
|
||||
Places in this scene ({places.length} of {locations.length} locations)
|
||||
</summary>
|
||||
{locations.length === 0 ? (
|
||||
<p className="combatant-sub">
|
||||
This campaign has no locations yet. Add some under Locations on the <Link to={`/campaigns/${encounter.campaignId}`}>campaign page</Link>.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
{locations.map((loc) => {
|
||||
const included = placeIds.includes(loc.id);
|
||||
return (
|
||||
<label key={loc.id} className="checkbox-row" title={loc.description ?? undefined}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={included}
|
||||
disabled={setPlaces.isPending}
|
||||
onChange={() => setPlaces.mutate(included ? placeIds.filter((id) => id !== loc.id) : [...placeIds, loc.id], { onError })}
|
||||
/>
|
||||
📍 {loc.name}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="place-grid">
|
||||
{places.map((place) => {
|
||||
const atPlace = Boolean(roamer) && place.here.some((h) => h.characterId === roamer?.id);
|
||||
return (
|
||||
<div key={place.id} className={`card place-card${atPlace ? ' is-here' : ''}`}>
|
||||
<div className="card-title">
|
||||
<span>📍 {place.name}</span>
|
||||
{atPlace ? (
|
||||
<span className="badge badge-live">{roamer?.name} is here</span>
|
||||
) : (
|
||||
<button className="btn btn-primary" disabled={!open || !roamer || busy} onClick={() => go(place)}>
|
||||
Go here
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{place.description && <p style={{ margin: '0 0 0.4rem', whiteSpace: 'pre-wrap' }}>{place.description}</p>}
|
||||
<div className="combatant-sub">{place.here.length > 0 ? `Here now: ${place.here.map((h) => h.name).join(', ')}` : 'Nobody from the party is here.'}</div>
|
||||
|
||||
{place.npcs.map((npc) => {
|
||||
const rate = { GOLD: npc.exchangeGold ?? 0, CASH: npc.exchangeCash ?? 0 };
|
||||
const changes = rate.GOLD > 0 && rate.CASH > 0;
|
||||
const from = sides[npc.id] ?? 'GOLD';
|
||||
const to = from === 'GOLD' ? 'CASH' : 'GOLD';
|
||||
const amount = Math.floor(Number(amounts[npc.id]));
|
||||
const whole = changes && amount > 0 && amount % rate[from] === 0;
|
||||
return (
|
||||
<div key={npc.id} className="mt-sm">
|
||||
<div className="section-label">
|
||||
🧑 {npc.name}
|
||||
{npc.role ? `, ${npc.role}` : ''}
|
||||
</div>
|
||||
{npc.description && <p className="combatant-sub" style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{npc.description}</p>}
|
||||
{npc.dmNotes && (
|
||||
<p className="combatant-sub" style={{ margin: 0 }} title="Only you can see this">
|
||||
🔒 {npc.dmNotes}
|
||||
</p>
|
||||
)}
|
||||
{(npc.wares ?? []).map((ware) => wareRow(ware, atPlace))}
|
||||
{changes && (
|
||||
<div className="reward-row">
|
||||
<span className="combatant-sub">
|
||||
💱 Changes {formatMoney(rate.GOLD, 'GOLD', true)} for {formatMoney(rate.CASH, 'CASH')}, either way.
|
||||
</span>
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={1000000}
|
||||
placeholder="Amount"
|
||||
aria-label={`How much to change with ${npc.name}`}
|
||||
value={amounts[npc.id] ?? ''}
|
||||
onChange={(e) => setAmounts((a) => ({ ...a, [npc.id]: e.target.value }))}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
<select aria-label={`What to hand ${npc.name}`} value={from} onChange={(e) => setSides((s) => ({ ...s, [npc.id]: e.target.value as ExchangeSide }))}>
|
||||
<option value="GOLD">gold into cash</option>
|
||||
<option value="CASH">cash into gold</option>
|
||||
</select>
|
||||
<button
|
||||
className="btn"
|
||||
disabled={!open || !roamer || !atPlace || !whole || busy}
|
||||
onClick={() => {
|
||||
if (!roamer) return;
|
||||
setDone(null);
|
||||
exchange.mutate(
|
||||
{ characterId: roamer.id, npcId: npc.id, from, amount },
|
||||
{
|
||||
onError,
|
||||
onSuccess: ({ received, currency }) => {
|
||||
setDone(`${roamer.name} changed ${formatMoney(amount, from, true)} for ${formatMoney(received, currency, true)} with ${npc.name}.`);
|
||||
setAmounts((a) => ({ ...a, [npc.id]: '' }));
|
||||
},
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
{whole ? `Get ${formatMoney((amount / rate[from]) * rate[to], to, true)}` : 'Exchange'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(npc.wares ?? []).length === 0 && !changes && <div className="combatant-sub">Nothing to sell: someone to talk to.</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{place.wares.length > 0 && (
|
||||
<div className="mt-sm">
|
||||
<div className="section-label">🛒 For sale here</div>
|
||||
{place.wares.map((ware) => wareRow(ware, atPlace))}
|
||||
</div>
|
||||
)}
|
||||
{place.npcs.length === 0 && place.wares.length === 0 && !isDM && <div className="combatant-sub mt-sm">Nothing to buy here.</div>}
|
||||
|
||||
{isDM && (
|
||||
<details className="mt-sm">
|
||||
<summary className="section-label" style={{ cursor: 'pointer' }}>
|
||||
Set up this place’s own shop
|
||||
</summary>
|
||||
<p className="combatant-sub" style={{ marginTop: '0.3rem' }}>
|
||||
Sold here whenever this place is part of a free-roam scene. To have an NPC found here, set their location on the NPC (campaign page).
|
||||
</p>
|
||||
<WaresEditor campaignId={encounter.campaignId} seller={{ locationId: place.id }} wares={place.wares} weapons={weapons} items={items} onError={onError} />
|
||||
</details>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
+285
-4
@@ -875,6 +875,289 @@ 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;
|
||||
}
|
||||
|
||||
/* Free roam: a card per place the party can go to. */
|
||||
.place-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 0.9rem;
|
||||
margin-top: 0.9rem;
|
||||
align-items: start;
|
||||
}
|
||||
.place-card {
|
||||
margin: 0;
|
||||
}
|
||||
.place-card.is-here {
|
||||
border-color: var(--accent-strong);
|
||||
box-shadow: 0 0 14px rgba(201, 162, 39, 0.2);
|
||||
}
|
||||
|
||||
/* 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 +1458,9 @@ table.data-table tbody tr:hover {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
/* The DM's attack bar stays in reach while scrolling the initiative order. */
|
||||
/* The DM's quick damage bar. (Attacks being resolved sit above it, so it no
|
||||
longer follows the scroll.) */
|
||||
.attack-bar {
|
||||
position: sticky;
|
||||
top: 4.5rem;
|
||||
z-index: 20;
|
||||
border-color: var(--danger-strong);
|
||||
}
|
||||
.attack-target {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ABILITY_ORDER } from './previewCalc';
|
||||
import { formatMoney } from './format';
|
||||
import type { Monster, Npc } from '../api/types';
|
||||
import type { Ability, Monster, Npc } from '../api/types';
|
||||
|
||||
// One thing in a campaign's lists that opens in a pop-out.
|
||||
export type EntryKind = 'weapon' | 'item' | 'spell' | 'monster' | 'npc';
|
||||
@@ -33,3 +33,25 @@ export function matchesSearch(query: string, ...fields: (string | number | null
|
||||
const text = fields.filter((f) => f !== null && f !== undefined).join(' ').toLowerCase();
|
||||
return words.every((word) => text.includes(word));
|
||||
}
|
||||
|
||||
// How a spell lands in a battle, as one choice: it just takes effect, the
|
||||
// caster rolls a spell attack, or each target makes a save with an ability
|
||||
// (taking half damage on a success, or none). `landingValue` and
|
||||
// `landingFrom` turn it into the value of a select and back.
|
||||
export interface SpellLanding {
|
||||
attackRoll: boolean;
|
||||
saveAbility: Ability | null;
|
||||
halfOnSave: boolean;
|
||||
}
|
||||
|
||||
export function landingValue(spell: SpellLanding): string {
|
||||
if (spell.attackRoll) return 'ATTACK';
|
||||
return spell.saveAbility ? `${spell.saveAbility}:${spell.halfOnSave ? 'HALF' : 'NONE'}` : '';
|
||||
}
|
||||
|
||||
export function landingFrom(value: string): SpellLanding {
|
||||
if (value === 'ATTACK') return { attackRoll: true, saveAbility: null, halfOnSave: false };
|
||||
if (!value) return { attackRoll: false, saveAbility: null, halfOnSave: false };
|
||||
const [ability, half] = value.split(':');
|
||||
return { attackRoll: false, saveAbility: ability as Ability, halfOnSave: half === 'HALF' };
|
||||
}
|
||||
|
||||
@@ -68,6 +68,17 @@ function storedEncounterSort(): EncounterSort {
|
||||
const ENCOUNTER_PLACE_KEY = 'dnd_encounter_place';
|
||||
const AFTER = 'after:';
|
||||
|
||||
// Finished encounters are left off the list until asked for; the choice is remembered.
|
||||
const SHOW_COMPLETED_KEY = 'dnd_show_completed_encounters';
|
||||
|
||||
function storedShowCompleted(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(SHOW_COMPLETED_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function storedEncounterPlace(): string {
|
||||
return localStorage.getItem(ENCOUNTER_PLACE_KEY) === 'TOP' ? 'TOP' : 'BOTTOM';
|
||||
}
|
||||
@@ -80,7 +91,7 @@ function sortEncounters(encounters: Encounter[], sort: EncounterSort): Encounter
|
||||
custom: () => 0,
|
||||
name: (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }),
|
||||
status: (a, b) => STATUS_RANK[a.status] - STATUS_RANK[b.status],
|
||||
type: (a, b) => Number(a.mode === 'QUEST') - Number(b.mode === 'QUEST'),
|
||||
type: (a, b) => MODE_RANK[a.mode] - MODE_RANK[b.mode],
|
||||
newest: (a, b) => time(b) - time(a),
|
||||
oldest: (a, b) => time(a) - time(b),
|
||||
};
|
||||
@@ -135,7 +146,8 @@ function CharacterRow({ character, isDM, mine = false, onDelete }: { character:
|
||||
);
|
||||
}
|
||||
|
||||
const MODE_LABELS: Record<EncounterMode, string> = { BATTLE: '⚔ Battle', QUEST: '📜 Quest' };
|
||||
const MODE_LABELS: Record<EncounterMode, string> = { BATTLE: '⚔ Battle', QUEST: '📜 Quest', FREE_ROAM: '🧭 Free roam' };
|
||||
const MODE_RANK: Record<EncounterMode, number> = { BATTLE: 0, QUEST: 1, FREE_ROAM: 2 };
|
||||
|
||||
// A long note, cut down to a line for a search result.
|
||||
const snippet = (text: string) => (text.length > 110 ? `${text.slice(0, 110).trimEnd()}…` : text);
|
||||
@@ -193,13 +205,16 @@ export function CampaignHomePage() {
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [resting, setResting] = useState(false);
|
||||
const [levelling, setLevelling] = useState(false);
|
||||
const [showCompleted, setShowCompleted] = useState(storedShowCompleted);
|
||||
const [deleteConfirmName, setDeleteConfirmName] = useState('');
|
||||
|
||||
const liveEncounters = encounters?.filter((e) => !e.archivedAt) || [];
|
||||
// An encounter picked to go after may have been archived or deleted since.
|
||||
const encounterPlace = newEncounterPlace.startsWith(AFTER) && !liveEncounters.some((e) => AFTER + e.id === newEncounterPlace) ? 'BOTTOM' : newEncounterPlace;
|
||||
const archivedEncounters = encounters?.filter((e) => e.archivedAt) || [];
|
||||
const shownEncounters = sortEncounters(liveEncounters, encounterSort);
|
||||
// Complete encounters are hidden unless "Show completed" is ticked.
|
||||
const completedCount = liveEncounters.filter((e) => e.status === 'COMPLETE').length;
|
||||
const shownEncounters = sortEncounters(showCompleted ? liveEncounters : liveEncounters.filter((e) => e.status !== 'COMPLETE'), encounterSort);
|
||||
const encountersPage = usePagination(shownEncounters, 10);
|
||||
const archivedEncountersPage = usePagination(archivedEncounters, 10);
|
||||
const sharedNotes = notes?.filter((n) => n.visibility === 'SHARED');
|
||||
@@ -243,11 +258,15 @@ export function CampaignHomePage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Swaps an encounter with its neighbour in the campaign's own order.
|
||||
function moveEncounter(index: number, step: -1 | 1) {
|
||||
// Swaps an encounter with its neighbour on the list as shown (which may
|
||||
// leave the completed ones out) in the campaign's own order.
|
||||
function moveEncounter(shownIndex: number, step: -1 | 1) {
|
||||
const neighbour = shownEncounters[shownIndex + step];
|
||||
const moving = shownEncounters[shownIndex];
|
||||
if (!neighbour || !moving) return;
|
||||
const ids = liveEncounters.map((e) => e.id);
|
||||
const target = index + step;
|
||||
if (target < 0 || target >= ids.length) return;
|
||||
const index = ids.indexOf(moving.id);
|
||||
const target = ids.indexOf(neighbour.id);
|
||||
[ids[index], ids[target]] = [ids[target], ids[index]];
|
||||
reorderEncounters.mutate([...ids, ...archivedEncounters.map((e) => e.id)], {
|
||||
onError: (err) => setFormError(extractErrorMessage(err)),
|
||||
@@ -511,6 +530,23 @@ export function CampaignHomePage() {
|
||||
<div className="section-block">
|
||||
<div className="page-header" style={{ marginBottom: '0.75rem' }}>
|
||||
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>Encounters</h2>
|
||||
{completedCount > 0 && (
|
||||
<label className="checkbox-row subtitle" title="Encounters that are complete are left off the list until this is ticked">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showCompleted}
|
||||
onChange={(e) => {
|
||||
setShowCompleted(e.target.checked);
|
||||
try {
|
||||
localStorage.setItem(SHOW_COMPLETED_KEY, e.target.checked ? '1' : '0');
|
||||
} catch {
|
||||
// Private browsing: the choice lasts as long as the page.
|
||||
}
|
||||
}}
|
||||
/>
|
||||
Show completed ({completedCount})
|
||||
</label>
|
||||
)}
|
||||
{liveEncounters.length > 1 && (
|
||||
<label className="flex-row subtitle">
|
||||
Sort by
|
||||
@@ -542,6 +578,7 @@ export function CampaignHomePage() {
|
||||
<select aria-label="Encounter type" value={newEncounterMode} onChange={(e) => setNewEncounterMode(e.target.value as EncounterMode)}>
|
||||
<option value="BATTLE">Battle</option>
|
||||
<option value="QUEST">NPC quest</option>
|
||||
<option value="FREE_ROAM">Free roam</option>
|
||||
</select>
|
||||
{liveEncounters.length > 0 && (
|
||||
<select
|
||||
@@ -566,7 +603,7 @@ export function CampaignHomePage() {
|
||||
</select>
|
||||
)}
|
||||
<button type="submit" className="btn btn-primary" disabled={createEncounter.isPending}>
|
||||
{newEncounterMode === 'QUEST' ? '📜 Start Quest' : '⚔ Start Encounter'}
|
||||
{newEncounterMode === 'QUEST' ? '📜 Start Quest' : newEncounterMode === 'FREE_ROAM' ? '🧭 Start Free Roam' : '⚔ Start Encounter'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
@@ -579,6 +616,9 @@ export function CampaignHomePage() {
|
||||
{encounters && liveEncounters.length === 0 && (
|
||||
<div className="empty-state">{archivedEncounters.length > 0 ? 'Every encounter is archived.' : 'No encounters yet.'}</div>
|
||||
)}
|
||||
{liveEncounters.length > 0 && shownEncounters.length === 0 && (
|
||||
<div className="empty-state">Every encounter here is complete. Tick “Show completed” to see them.</div>
|
||||
)}
|
||||
{encountersPage.pageItems.map((enc) => {
|
||||
// Its place in the whole list, not on this page: that is what moving it changes.
|
||||
const index = shownEncounters.indexOf(enc);
|
||||
|
||||
@@ -25,11 +25,15 @@ 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 { FreeRoamPanel } from '../components/encounter/FreeRoam';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { NumberInput } from '../components/NumberInput';
|
||||
import { HpBar } from '../components/HpBar';
|
||||
@@ -51,6 +55,7 @@ import type {
|
||||
CharacterSummary,
|
||||
Currency,
|
||||
Encounter,
|
||||
EncounterAction,
|
||||
EncounterMode,
|
||||
EncounterParticipant,
|
||||
EncounterReward,
|
||||
@@ -68,7 +73,7 @@ const areaPenalty = (targetCount: number, step: number) => Math.max(0, targetCou
|
||||
|
||||
const KIND_LABELS = { CHARACTER: 'PC', MONSTER: 'Monster', NPC: 'NPC' } as const;
|
||||
const KIND_BADGES = { CHARACTER: 'badge-player', MONSTER: 'badge-hostile', NPC: 'badge-friendly' } as const;
|
||||
const MODE_LABELS: Record<EncounterMode, string> = { BATTLE: '⚔ Battle', QUEST: '📜 Quest' };
|
||||
const MODE_LABELS: Record<EncounterMode, string> = { BATTLE: '⚔ Battle', QUEST: '📜 Quest', FREE_ROAM: '🧭 Free roam' };
|
||||
const STATUS_LABELS = { PLANNING: 'Planning', ACTIVE: 'In progress', COMPLETE: 'Complete' } as const;
|
||||
|
||||
function StatChip({ label, children }: { label: string; children: ReactNode }) {
|
||||
@@ -298,6 +303,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 +650,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 +914,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 +1314,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,18 +1341,47 @@ 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…" />;
|
||||
if (error || !encounter) return <ErrorBanner message="Could not load this encounter." />;
|
||||
|
||||
const isBattle = encounter.mode === 'BATTLE';
|
||||
const isRoam = encounter.mode === 'FREE_ROAM';
|
||||
const participants = encounter.participants ?? []; // already in initiative order
|
||||
// The fallen: dead characters, and monsters and NPCs at 0 HP. Only the DM is
|
||||
// sent their cards, and they sit apart from those still in the fight.
|
||||
@@ -1261,6 +1403,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 +1468,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}
|
||||
@@ -1359,7 +1509,7 @@ export function EncounterPage() {
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>
|
||||
{isBattle ? '⚔' : '📜'} {encounter.name}
|
||||
{isBattle ? '⚔' : isRoam ? '🧭' : '📜'} {encounter.name}
|
||||
</h1>
|
||||
<div className="flex-row flex-wrap">
|
||||
<span className="badge badge-dm">{MODE_LABELS[encounter.mode]}</span>
|
||||
@@ -1391,6 +1541,7 @@ export function EncounterPage() {
|
||||
>
|
||||
<option value="BATTLE">Battle</option>
|
||||
<option value="QUEST">NPC quest</option>
|
||||
<option value="FREE_ROAM">Free roam</option>
|
||||
</select>
|
||||
<select
|
||||
aria-label="Location"
|
||||
@@ -1412,7 +1563,7 @@ export function EncounterPage() {
|
||||
)}
|
||||
{encounter.status === 'PLANNING' && (
|
||||
<button className="btn btn-primary" onClick={() => patchEncounter.mutate({ status: 'ACTIVE' }, fail)}>
|
||||
{isBattle ? 'Begin Battle' : 'Begin Quest'}
|
||||
{isBattle ? 'Begin Battle' : isRoam ? 'Begin Roaming' : 'Begin Quest'}
|
||||
</button>
|
||||
)}
|
||||
{encounter.status === 'ACTIVE' && isBattle && (
|
||||
@@ -1432,7 +1583,7 @@ export function EncounterPage() {
|
||||
)}
|
||||
{encounter.status === 'ACTIVE' && (
|
||||
<button className="btn btn-danger" onClick={() => patchEncounter.mutate({ status: 'COMPLETE' }, fail)}>
|
||||
{isBattle ? 'End Battle' : 'Complete Quest'}
|
||||
{isBattle ? 'End Battle' : isRoam ? 'End Free Roam' : 'Complete Quest'}
|
||||
</button>
|
||||
)}
|
||||
{encounter.status === 'COMPLETE' && (
|
||||
@@ -1482,13 +1633,31 @@ 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) && (
|
||||
<div className="card">
|
||||
<div className="card-title">{isBattle ? 'The Scene' : 'Quest'}</div>
|
||||
<div className="card-title">{isBattle ? 'The Scene' : isRoam ? 'Where the party is' : 'Quest'}</div>
|
||||
{isDM ? (
|
||||
<textarea
|
||||
key={`description:${encounter.description ?? ''}`}
|
||||
@@ -1499,7 +1668,9 @@ export function EncounterPage() {
|
||||
placeholder={
|
||||
isBattle
|
||||
? 'Describe the battlefield: terrain, cover, hazards… (shown to players)'
|
||||
: 'What is being asked of the party, and by whom? (shown to players)'
|
||||
: isRoam
|
||||
? 'Where the party finds itself, and what is around to visit… (shown to players)'
|
||||
: 'What is being asked of the party, and by whom? (shown to players)'
|
||||
}
|
||||
onBlur={(e) => {
|
||||
const description = e.target.value.trim() || null;
|
||||
@@ -1512,8 +1683,37 @@ export function EncounterPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRoam && (
|
||||
<FreeRoamPanel
|
||||
encounter={encounter}
|
||||
isDM={isDM}
|
||||
userId={user?.id}
|
||||
characters={builtCharacters}
|
||||
locations={locations ?? []}
|
||||
weapons={weapons ?? []}
|
||||
items={items ?? []}
|
||||
onError={onError}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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 +1858,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 }}>
|
||||
|
||||
Generated
+4
-4
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.26.0",
|
||||
"version": "1.28.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.26.0",
|
||||
"version": "1.28.0",
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend"
|
||||
]
|
||||
},
|
||||
"backend": {
|
||||
"version": "1.26.0",
|
||||
"version": "1.28.0",
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.0",
|
||||
"@prisma/client": "^7.10.0",
|
||||
@@ -33,7 +33,7 @@
|
||||
}
|
||||
},
|
||||
"frontend": {
|
||||
"version": "1.26.0",
|
||||
"version": "1.28.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"axios": "^1.20.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.26.0",
|
||||
"version": "1.28.0",
|
||||
"private": true,
|
||||
"description": "D&D 5e campaign manager — monorepo root",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user