Compare commits

...
7 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 f8462a6e31 V1.7.2: the combatant on turn cannot be their own target
- In a battle under way, whoever is on turn has no Target button on their
  card, and the server refuses a target list that includes them.
- All packages at version 1.7.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:28:28 +02:00
joshuaandClaude Opus 5.5 754eb0ee68 V1.7.1: area attack AC penalty is a campaign setting
- The amount an area attack adds to each target's AC per extra target is
  now set per campaign, from 0 (no penalty) to 10. It starts at 2, so
  existing campaigns behave as before.
- The DM changes it in the campaign settings; the attack bar and the
  targets' cards use it.
- All packages at version 1.7.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:11:46 +02:00
joshuaandClaude Opus 5.5 f8cee43ca2 V1.7.0: area attacks on several targets
- More than one combatant can be marked as a target; the Target button now
  adds to the targets instead of moving the mark.
- Several targets make an area attack, which is harder to land: every
  target's AC counts as 2 higher for each target beyond the first. The
  attack bar shows the AC to beat, and each target's card names the penalty.
- The DM unticks any target the roll missed, then applies one total: split
  evenly between those hit (remainder to the first marked), or the full
  amount to each. Each share is shown before it is applied.
- The single target column from V1.6.0 becomes a list, carried over by the
  migration.
- All packages at version 1.7.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:01:42 +02:00
joshuaandClaude Opus 5.5 e4456b0df2 V1.6.0: targets and an attack bar in encounters
- One combatant at a time can be marked as the target with the Target
  button on its card. The DM can mark anyone at any time; a player can
  while their own character is on turn in a battle.
- The target's card is outlined for everyone and names who is going for it.
- While a target is marked the DM gets an attack bar above the list: who is
  attacking whom, the target's AC and HP, the attacker's weapons, spells or
  monster attack, and a box to apply damage or healing straight away.
- The target is cleared when the turn or round moves on, when the encounter
  begins, ends or is reopened, or when the target is removed.
- All packages at version 1.6.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:53:18 +02:00
joshuaandClaude Opus 5.5 ec48b65481 V1.5.0: death saves for characters at 0 HP
- A character at 0 HP is Dying, Stable or Dead. The tallies live on the
  character, so the sheet and the encounter card always agree.
- Death saves are typed in as the d20 showed: 10 or more succeeds, less
  fails, a 1 fails twice, a 20 puts the character back up at 1 HP. Three
  successes make them stable; three failures kill.
- Damage taken at 0 HP is a failed save and ends stable. A blow that leaves
  as much as max HP still to take after reaching 0 kills outright.
- Healing from 0 clears the tallies. The dead cannot be healed; the DM
  revives them at 1 HP.
- A player rolls and stabilises for their own character; the DM for anyone,
  and only the DM sets tallies by hand or revives.
- The party overview, campaign list and quick stats show the state.
- All packages at version 1.5.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:37:38 +02:00
joshuaandClaude Opus 5.5 4362b7423e V1.4.0: silver and copper, with automatic change
- A character's purse holds gold, silver and copper as separate coins:
  1 gold = 10 silver = 100 copper. The money log records each entry's coin.
- Spending and giving are paid from the whole purse: coins of the kind
  named go first, then the nearest bigger coin is broken (change comes back
  in as few coins as possible), then smaller coins are counted out. A
  payment is refused only when the whole purse isn't worth enough.
- Exchange converts one coin into another at the fixed worth.
- Encounter rewards and gifts between characters can be in any coin; the
  party overview shows the whole purse.
- All packages at version 1.4.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:56:24 +02:00
joshuaandClaude Opus 5.5 98a89185c1 V1.3.1: initiative in quests, tooltips no longer clipped, deploy script
- Quests take initiative rolls like battles do; each quest group lists
  highest first. The total is roll plus bonus and can exceed 20 (test added).
- Info tooltips are drawn against the viewport, so the party overview
  table (or any scrolling container) can't cut them off.
- backend: `npm run deploy` applies migrations and regenerates the Prisma
  client in one step; README explains why both are needed on a server.
- All packages at version 1.3.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:28:24 +02:00
31 changed files with 1821 additions and 154 deletions
+6 -2
View File
@@ -80,8 +80,12 @@ cd backend
npm run seed:hollowcrown
```
On a server, apply new migrations with `npx prisma migrate deploy`. Change
your password from the account page (click your name in the top bar).
On a server, after pulling a new version, run `npm run deploy` in `backend`
and then restart the backend. It applies new migrations **and** regenerates
the Prisma client; without the second half the server keeps running with the
old data model and pages that use anything new fail to load (for example
"Could not load this encounter"). Change your password from the account page
(click your name in the top bar).
## Tests
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.3.0",
"version": "1.7.2",
"private": true,
"type": "commonjs",
"main": "src/index.js",
@@ -15,6 +15,7 @@
"prisma:migrate": "prisma migrate dev",
"prisma:generate": "prisma generate",
"prisma:seed": "prisma db seed",
"deploy": "prisma migrate deploy && prisma generate",
"seed:hollowcrown": "node prisma/seed-hollowcrown.js",
"prisma:studio": "prisma studio"
},
@@ -0,0 +1,10 @@
-- CreateEnum: the three coins. Silver is worth 0.1 gold, copper 0.01 gold.
CREATE TYPE "Currency" AS ENUM ('GOLD', 'SILVER', 'COPPER');
-- AlterTable: a character's purse holds each coin separately
ALTER TABLE "characters" ADD COLUMN "silver" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "characters" ADD COLUMN "copper" INTEGER NOT NULL DEFAULT 0;
-- AlterTable: every ledger entry and coin reward is in one coin; all existing ones are gold
ALTER TABLE "gold_transactions" ADD COLUMN "currency" "Currency" NOT NULL DEFAULT 'GOLD';
ALTER TABLE "encounter_rewards" ADD COLUMN "currency" "Currency" NOT NULL DEFAULT 'GOLD';
@@ -0,0 +1,4 @@
-- AlterTable: death saves, counted while a character is at 0 HP
ALTER TABLE "characters" ADD COLUMN "deathSaveSuccesses" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "deathSaveFailures" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "stable" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,2 @@
-- AlterTable: the combatant currently marked as the target
ALTER TABLE "encounters" ADD COLUMN "targetParticipantId" TEXT;
@@ -0,0 +1,9 @@
-- AlterTable: several combatants can be marked as targets at once (an area attack)
ALTER TABLE "encounters" ADD COLUMN "targetParticipantIds" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- Carry over a single target marked before this.
UPDATE "encounters"
SET "targetParticipantIds" = ARRAY["targetParticipantId"]
WHERE "targetParticipantId" IS NOT NULL;
ALTER TABLE "encounters" DROP COLUMN "targetParticipantId";
@@ -0,0 +1,2 @@
-- AlterTable: how much an area attack adds to each AC per extra target
ALTER TABLE "campaigns" ADD COLUMN "areaAttackAcPenalty" INTEGER NOT NULL DEFAULT 2;
+30 -3
View File
@@ -118,6 +118,14 @@ enum RewardType {
ITEM
}
// The coins a character carries. Their worth is fixed in utils/grants.js:
// 1 gold = 10 silver = 100 copper.
enum Currency {
GOLD
SILVER
COPPER
}
enum NoteVisibility {
DM_ONLY
SHARED
@@ -160,6 +168,9 @@ model Campaign {
skipMotivationRoll Boolean @default(false)
// Off: players see only a health bar for monsters and NPCs, not the numbers.
showMonsterHp Boolean @default(true)
// An attack on several targets is harder to land: each target's AC counts
// as this much higher for every target beyond the first. 0 turns it off.
areaAttackAcPenalty Int @default(2)
ownerId String
// Set while the campaign is archived: kept in full, but moved off the main
// list and closed to new members until the DM restores it.
@@ -256,13 +267,22 @@ model Character {
currentHp Int @default(0)
tempHp Int @default(0)
// Death saves, counted while the character is at 0 HP (utils/hp.js). Three
// failures is dead; three successes sets `stable` and clears the tallies.
deathSaveSuccesses Int @default(0)
deathSaveFailures Int @default(0)
stable Boolean @default(false) // at 0 HP but no longer rolling
armorBonus Int @default(0) // flat AC bonus from equipped armor/shield
speedOverride Int?
// The next three are composed by the builder from the player's picks
// (game/data/characterOptions.js). After that only the DM can edit them.
specialAbilities String? // shown in the quick-stats panel
// The purse: each coin is counted on its own and only changes through the
// money ledger (GoldTransaction).
gold Int @default(0)
silver Int @default(0)
copper Int @default(0)
background String?
appearance String?
@@ -403,12 +423,14 @@ model InventoryItem {
@@map("inventory_items")
}
// Ledger behind Character.gold: every change to the balance is one row here.
// Ledger behind a character's purse: every change to a coin balance is one
// row here. (The name predates silver and copper.)
model GoldTransaction {
id String @id @default(cuid())
characterId String
currency Currency @default(GOLD) // which coin this entry is in
amount Int // signed: positive = gained, negative = spent
balanceAfter Int
balanceAfter Int // that coin's balance afterwards
reason String?
createdById String?
createdAt DateTime @default(now())
@@ -516,6 +538,10 @@ model Encounter {
currentRound Int @default(1)
// Whose turn it is in a battle; null until the fight starts.
activeParticipantId String?
// Who is being attacked (or healed) right now, shown to everyone, in the
// order they were picked. More than one makes it an area attack. Picked by
// the DM or by the player on turn, and cleared when the turn passes.
targetParticipantIds String[] @default([])
description String? // the scene / objective, shown to everyone
dmNotes String? // never sent to players
// Where the scene takes place. While the encounter is in progress, the
@@ -544,7 +570,8 @@ model EncounterReward {
id String @id @default(cuid())
encounterId String
type RewardType
gold Int? // GOLD
gold Int? // GOLD: how many coins
currency Currency @default(GOLD) // GOLD: which coin they are
weaponId String? // WEAPON
itemId String? // ITEM
quantity Int @default(1)
+302 -1
View File
@@ -584,7 +584,7 @@ describe('hit points', () => {
const full = (await patch(playerAuth, { currentHp: 9999 })).body.character;
expect(full.currentHp).toBe(level5.sheet.hp.max);
expect((await patch(playerAuth, { currentHp: -50 })).body.character.currentHp).toBe(0);
expect((await patch(playerAuth, { currentHp: -1 })).body.character.currentHp).toBe(0);
await patch(playerAuth, { currentHp: 9999 });
const level1 = (await patch(dmAuth, { level: 1 })).body.character;
@@ -1896,6 +1896,15 @@ describe('initiative', () => {
expect(playerView.Goblin.initiative).toBe(12);
expect(playerView.Quick.character.userId).toBeTruthy();
// Only the die stops at 20: a natural 20 plus a bonus goes past it.
expect((await patch(goblinRow, dmAuth, { initiativeRoll: 20 })).body.participant).toMatchObject({ initiativeRoll: 20, initiative: 22 });
// Quests take initiative exactly as battles do.
await request(app).patch(e).set(dmAuth).send({ mode: 'QUEST' });
expect((await patch(heroRow, playerAuth, { initiativeRoll: 19 })).body.participant.initiative).toBe(19 + heroBonus);
await request(app).patch(e).set(dmAuth).send({ mode: 'BATTLE' });
await patch(heroRow, playerAuth, { initiativeRoll: 20 });
// The DM can re-roll anyone, or clear a roll.
expect((await patch(goblinRow, dmAuth, { initiativeRoll: 1 })).body.participant.initiative).toBe(3);
expect((await patch(heroRow, dmAuth, { initiativeRoll: null })).body.participant).toMatchObject({ initiativeRoll: null, initiative: 0 });
@@ -1903,6 +1912,68 @@ describe('initiative', () => {
});
});
describe('targets', () => {
itIfDb('are picked by the DM or the player on turn, shown to all, and go when the turn does', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('target');
const c = `/campaigns/${campaign.id}`;
const heroId = await buildCharacter(campaign.id, playerAuth, 'Archer');
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 first = await add({ monsterId: goblin.id, initiativeRoll: 10 });
const second = await add({ monsterId: goblin.id, initiativeRoll: 1 });
const target = (auth, ...participantIds) => request(app).put(`${e}/targets`).set(auth).send({ participantIds });
const seen = async (auth) => (await request(app).get(e).set(auth)).body.encounter.targetParticipantIds;
// Before the battle only the DM can mark anyone.
expect((await target(playerAuth, first.id)).status).toBe(403);
expect((await target(dmAuth, first.id)).body.encounter.targetParticipantIds).toEqual([first.id]);
expect(await seen(playerAuth)).toEqual([first.id]);
expect((await target(dmAuth, first.id, 'nobody')).status).toBe(400);
// Beginning the battle clears them and puts the hero on turn: now the
// player can, and several at once is an area attack, kept in the order picked.
await request(app).patch(e).set(dmAuth).send({ status: 'ACTIVE' });
expect(await seen(dmAuth)).toEqual([]);
expect((await target(playerAuth, second.id, first.id, second.id)).status).toBe(200);
expect(await seen(dmAuth)).toEqual([second.id, first.id]);
// Removing a target unmarks it and leaves the rest.
await request(app).delete(`${e}/participants/${second.id}`).set(dmAuth);
expect(await seen(dmAuth)).toEqual([first.id]);
// The turn passing clears them, and the player is no longer on turn.
await request(app).post(`${e}/next-turn`).set(dmAuth);
expect(await seen(dmAuth)).toEqual([]);
expect((await target(playerAuth, first.id)).status).toBe(403);
// Whoever is on turn can't be a target, their own or as part of a group.
expect((await target(dmAuth, first.id)).status).toBe(400);
expect((await target(dmAuth, hero.id, first.id)).status).toBe(400);
// The goblin's turn: the DM marks the hero, and can clear it again.
expect((await target(dmAuth, hero.id)).body.encounter.targetParticipantIds).toEqual([hero.id]);
expect((await target(dmAuth)).body.encounter.targetParticipantIds).toEqual([]);
});
});
describe('area attack AC penalty', () => {
itIfDb('is a campaign setting the DM changes, 2 unless told otherwise', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('areapenalty');
const c = `/campaigns/${campaign.id}`;
expect(campaign.areaAttackAcPenalty).toBe(2);
expect((await request(app).patch(c).set(playerAuth).send({ areaAttackAcPenalty: 0 })).status).toBe(403);
expect((await request(app).patch(c).set(dmAuth).send({ areaAttackAcPenalty: 11 })).status).toBe(400);
expect((await request(app).patch(c).set(dmAuth).send({ areaAttackAcPenalty: -1 })).status).toBe(400);
expect((await request(app).patch(c).set(dmAuth).send({ areaAttackAcPenalty: 0 })).body.campaign.areaAttackAcPenalty).toBe(0);
// Players are told it too: their cards show the penalty on a marked target.
expect((await request(app).get(c).set(playerAuth)).body.campaign.areaAttackAcPenalty).toBe(0);
});
});
describe('number limits', () => {
itIfDb('are enforced by the server, whatever a form sends', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('limits');
@@ -1963,3 +2034,233 @@ describe('temporary hit points', () => {
expect([now.currentHp, now.tempHp]).toEqual([0, 0]);
});
});
describe('death saves', () => {
itIfDb('start at 0 HP, are settled by three of a kind, and end with healing', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('deathsaves');
const heroId = await buildCharacter(campaign.id, playerAuth, 'Fallen');
const ch = `/characters/${heroId}`;
const sheet = async () => (await request(app).get(ch).set(playerAuth)).body.character;
const hp = (auth, currentHp) => request(app).patch(ch).set(auth).send({ currentHp });
const save = (auth, body) => request(app).post(`${ch}/death-saves`).set(auth).send(body);
const max = (await sheet()).sheet.hp.max;
expect((await sheet()).deathSaves).toEqual({ state: 'UP', successes: 0, failures: 0 });
// No saves while standing, and a save is a d20.
expect((await save(playerAuth, { action: 'roll', roll: 12 })).status).toBe(400);
await hp(playerAuth, 0);
expect((await sheet()).deathSaves).toEqual({ state: 'DYING', successes: 0, failures: 0 });
expect((await save(playerAuth, { action: 'roll', roll: 21 })).status).toBe(400);
// 10 and up succeeds, below fails, and a 1 fails twice.
expect((await save(playerAuth, { action: 'roll', roll: 10 })).body.character.deathSaves).toEqual({ state: 'DYING', successes: 1, failures: 0 });
expect((await save(playerAuth, { action: 'roll', roll: 9 })).body.character.deathSaves).toEqual({ state: 'DYING', successes: 1, failures: 1 });
// Being hit while down is a failed save; healing clears the lot.
expect((await hp(dmAuth, -1)).body.character.deathSaves).toEqual({ state: 'DYING', successes: 1, failures: 2 });
expect((await hp(playerAuth, 3)).body.character.deathSaves).toEqual({ state: 'UP', successes: 0, failures: 0 });
// Three successes: stable, at 0 HP, with nothing left to roll.
await hp(playerAuth, 0);
for (const roll of [15, 11, 19]) await save(playerAuth, { action: 'roll', roll });
let now = await sheet();
expect([now.currentHp, now.deathSaves]).toEqual([0, { state: 'STABLE', successes: 0, failures: 0 }]);
expect((await save(playerAuth, { action: 'roll', roll: 12 })).status).toBe(400);
// Damage ends that; a 20 puts them back up with 1 HP.
expect((await hp(dmAuth, -1)).body.character.deathSaves).toEqual({ state: 'DYING', successes: 0, failures: 1 });
now = (await save(playerAuth, { action: 'roll', roll: 20 })).body.character;
expect([now.currentHp, now.deathSaves]).toEqual([1, { state: 'UP', successes: 0, failures: 0 }]);
// Someone else can steady them without a roll.
await hp(playerAuth, 0);
expect((await save(playerAuth, { action: 'stabilize' })).body.character.deathSaves.state).toBe('STABLE');
// The tallies are the DM's to set by hand; a 1 on top of two failures kills.
expect((await save(playerAuth, { action: 'set', successes: 0, failures: 0 })).status).toBe(403);
expect((await save(dmAuth, { action: 'set', successes: 2, failures: 2 })).body.character.deathSaves).toEqual({ state: 'DYING', successes: 2, failures: 2 });
expect((await save(playerAuth, { action: 'roll', roll: 1 })).body.character.deathSaves.state).toBe('DEAD');
// The dead can't be healed or roll; only the DM brings them back.
expect((await hp(playerAuth, max)).status).toBe(400);
expect((await save(playerAuth, { action: 'roll', roll: 20 })).status).toBe(400);
expect((await save(playerAuth, { action: 'revive' })).status).toBe(403);
now = (await save(dmAuth, { action: 'revive' })).body.character;
expect([now.currentHp, now.deathSaves]).toEqual([1, { state: 'UP', successes: 0, failures: 0 }]);
});
itIfDb('follow the character into an encounter, where one huge blow kills outright', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('deathsaves-enc');
const heroId = await buildCharacter(campaign.id, playerAuth, 'Doomed');
const ch = `/characters/${heroId}`;
const max = (await request(app).get(ch).set(playerAuth)).body.character.sheet.hp.max;
const e = `/encounters/${(await request(app).post(`/campaigns/${campaign.id}/encounters`).set(dmAuth).send({ name: 'Last Stand' })).body.encounter.id}`;
const row = (await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: heroId })).body.participant;
const strike = (currentHp) => request(app).patch(`${e}/participants/${row.id}`).set(dmAuth).send({ currentHp });
const combatant = async () => (await request(app).get(e).set(playerAuth)).body.encounter.participants[0];
// Temp HP that soaks the whole blow keeps them on their feet.
await request(app).patch(ch).set(playerAuth).send({ tempHp: 2 });
await strike(max - 2);
expect((await combatant()).characterCombatInfo.deathSaves.state).toBe('UP');
// Dropped by the DM in the tracker; the player's own save shows there.
await strike(1 - max); // max - 1 past zero: one short of killing outright
expect((await combatant()).characterCombatInfo.deathSaves).toEqual({ state: 'DYING', successes: 0, failures: 0 });
await request(app).post(`${ch}/death-saves`).set(playerAuth).send({ action: 'roll', roll: 4 });
expect((await combatant()).characterCombatInfo.deathSaves).toEqual({ state: 'DYING', successes: 0, failures: 1 });
// The party list carries it too.
const listed = (await request(app).get(`/campaigns/${campaign.id}/characters`).set(dmAuth)).body.characters.find((c) => c.id === heroId);
expect(listed.deathSaves.state).toBe('DYING');
// Healed, then hit for max HP more than they have left.
await strike(max);
await strike(-max);
expect((await combatant()).characterCombatInfo.deathSaves.state).toBe('DEAD');
expect((await strike(5)).status).toBe(400);
});
});
describe('silver and copper', () => {
itIfDb('are kept as separate coins, exchanged at fixed worth, given and rewarded', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('coins');
const c = `/campaigns/${campaign.id}`;
await request(app).patch(c).set(dmAuth).send({ startingGold: 5 });
const heroId = await buildCharacter(campaign.id, playerAuth, 'Purse');
const friendId = await buildCharacter(campaign.id, dmAuth, 'Friend');
const ch = `/characters/${heroId}`;
const purse = async (id = heroId, auth = playerAuth) => {
const { gold, silver, copper } = (await request(app).get(`/characters/${id}`).set(auth)).body.character;
return [gold, silver, copper];
};
const money = (body) => request(app).post(`${ch}/gold`).set(playerAuth).send(body);
const exchange = (body) => request(app).post(`${ch}/gold/exchange`).set(playerAuth).send(body);
expect(await purse()).toEqual([5, 0, 0]);
// Each coin has its own balance; an amount with no coin named is gold.
expect((await money({ amount: 7, currency: 'SILVER', reason: 'Sold a pelt' })).status).toBe(201);
expect((await money({ amount: 3, currency: 'COPPER' })).body.copper).toBe(3);
expect((await money({ amount: 1 })).body.gold).toBe(6);
expect(await purse()).toEqual([6, 7, 3]);
expect((await money({ amount: 1, currency: 'PLATINUM' })).status).toBe(400);
// A spend the whole purse can't cover is refused and moves nothing.
const short = await money({ amount: -1000, currency: 'SILVER' });
expect(short.status).toBe(400);
expect(short.body.error).toMatch(/Not enough money/);
expect(await purse()).toEqual([6, 7, 3]);
expect((await request(app).patch(ch).set(dmAuth).send({ silver: 99 })).status).toBe(400);
// 1 gold = 10 silver = 100 copper.
expect((await exchange({ from: 'GOLD', to: 'SILVER', amount: 1 })).body.received).toBe(10);
expect(await purse()).toEqual([5, 17, 3]);
expect((await exchange({ from: 'GOLD', to: 'COPPER', amount: 1 })).body.received).toBe(100);
expect((await exchange({ from: 'SILVER', to: 'COPPER', amount: 1 })).body.received).toBe(10);
expect(await purse()).toEqual([4, 16, 113]);
expect((await exchange({ from: 'COPPER', to: 'SILVER', amount: 20 })).body.received).toBe(2);
expect((await exchange({ from: 'SILVER', to: 'GOLD', amount: 10 })).body.received).toBe(1);
expect((await exchange({ from: 'COPPER', to: 'SILVER', amount: 90 })).body.received).toBe(9);
expect(await purse()).toEqual([5, 17, 3]);
// It has to come to whole coins, be affordable, and be between two different coins.
const uneven = await exchange({ from: 'SILVER', to: 'GOLD', amount: 5 });
expect(uneven.status).toBe(400);
expect(uneven.body.error).toMatch(/multiples of 10/);
expect((await exchange({ from: 'COPPER', to: 'SILVER', amount: 1 })).status).toBe(400);
expect((await exchange({ from: 'COPPER', to: 'GOLD', amount: 50 })).body.error).toMatch(/multiples of 100/);
expect((await exchange({ from: 'SILVER', to: 'COPPER', amount: 18 })).status).toBe(400);
expect((await exchange({ from: 'GOLD', to: 'GOLD', amount: 1 })).status).toBe(400);
expect((await request(app).post(`/characters/${friendId}/gold/exchange`).set(playerAuth).send({ from: 'GOLD', to: 'SILVER', amount: 1 })).status).toBe(403);
expect(await purse()).toEqual([5, 17, 3]);
// The ledger says which coin every entry is in.
const ledger = (await request(app).get(`${ch}/gold`).set(playerAuth)).body;
expect([ledger.gold, ledger.silver, ledger.copper]).toEqual([5, 17, 3]);
expect(ledger.transactions[0]).toMatchObject({ currency: 'SILVER', amount: 9, balanceAfter: 17, reason: 'Exchanged from 90 copper' });
expect(ledger.transactions.find((t) => t.reason === 'Sold a pelt')).toMatchObject({ currency: 'SILVER', amount: 7, balanceAfter: 7 });
// Giving and rewards work per coin.
expect((await request(app).post(`${ch}/give`).set(playerAuth).send({ kind: 'gold', currency: 'SILVER', amount: 3, toCharacterId: friendId })).status).toBe(200);
expect((await request(app).post(`${ch}/give`).set(playerAuth).send({ kind: 'gold', currency: 'COPPER', amount: 5000, toCharacterId: friendId })).status).toBe(400);
expect(await purse()).toEqual([5, 14, 3]);
expect(await purse(friendId, dmAuth)).toEqual([5, 3, 0]);
const e = `/encounters/${(await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Loose change' })).body.encounter.id}`;
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: heroId });
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: friendId });
const reward = (await request(app).post(`${e}/rewards`).set(dmAuth).send({ type: 'GOLD', gold: 9, currency: 'COPPER' })).body.reward;
expect(reward).toMatchObject({ gold: 9, currency: 'COPPER' });
await request(app).post(`${e}/rewards/${reward.id}/award`).set(dmAuth).send({ split: true });
const split = [(await purse())[2] - 3, (await purse(friendId, dmAuth))[2]].sort();
expect(split).toEqual([4, 5]); // 9 copper between two
// Another player still sees none of it.
const outsiderView = (await request(app).get(`/characters/${friendId}`).set(playerAuth)).body.character;
expect([outsiderView.gold, outsiderView.silver, outsiderView.copper]).toEqual([null, null, null]);
});
});
describe('paying with change', () => {
itIfDb('a spend or a gift is paid from the whole purse: bigger coins are broken, smaller ones counted out', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('change');
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ startingGold: 5 });
const heroId = await buildCharacter(campaign.id, playerAuth, 'Spender');
const friendId = await buildCharacter(campaign.id, dmAuth, 'Friend');
const ch = `/characters/${heroId}`;
const purse = async (id = heroId, auth = playerAuth) => {
const { gold, silver, copper } = (await request(app).get(`/characters/${id}`).set(auth)).body.character;
return [gold, silver, copper];
};
const money = (body) => request(app).post(`${ch}/gold`).set(playerAuth).send(body);
const newest = async (count) =>
(await request(app).get(`${ch}/gold`).set(playerAuth)).body.transactions.slice(0, count).map((t) => [t.currency, t.amount, t.balanceAfter, t.reason]);
// 3 silver from a purse of 5 gold: one gold is broken, 7 silver comes back.
const boots = await money({ amount: -3, currency: 'SILVER', reason: 'New boots' });
expect(boots.status).toBe(201);
expect([boots.body.gold, boots.body.silver, boots.body.copper]).toEqual([4, 7, 0]);
expect(await purse()).toEqual([4, 7, 0]);
expect((await newest(2)).sort()).toEqual([
['GOLD', -1, 4, 'New boots (paid 3 silver, with change)'],
['SILVER', 7, 7, 'New boots (paid 3 silver, with change)'],
]);
// 5 copper: the nearest bigger coin is a silver, so that is what breaks.
expect((await money({ amount: -5, currency: 'COPPER' })).status).toBe(201);
expect(await purse()).toEqual([4, 6, 5]);
// Enough of the coin named: nothing else moves, and the entry reads as usual.
await money({ amount: -2, reason: 'Horse feed' });
expect(await purse()).toEqual([2, 6, 5]);
expect((await newest(1))[0]).toEqual(['GOLD', -2, 2, 'Horse feed']);
// More than the whole purse is worth (2.65 gold): refused, nothing moves.
const tooMuch = await money({ amount: -3 });
expect(tooMuch.status).toBe(400);
expect(tooMuch.body.error).toMatch(/Not enough money/);
expect(tooMuch.body.error).toMatch(/2\.65 gold/);
expect(await purse()).toEqual([2, 6, 5]);
// No bigger coin to break: smaller ones are counted out, biggest first.
await money({ amount: -2 });
await money({ amount: 40, currency: 'COPPER' });
expect(await purse()).toEqual([0, 6, 45]);
expect((await money({ amount: -1, reason: 'Toll' })).status).toBe(201);
expect(await purse()).toEqual([0, 0, 5]);
// Giving works the same way for the giver; the recipient gets the coins named.
await money({ amount: 1 });
expect((await request(app).post(`${ch}/give`).set(playerAuth).send({ kind: 'gold', currency: 'SILVER', amount: 4, toCharacterId: friendId })).status).toBe(200);
expect(await purse()).toEqual([0, 6, 5]);
expect(await purse(friendId, dmAuth)).toEqual([5, 4, 0]);
const beyond = await request(app).post(`${ch}/give`).set(playerAuth).send({ kind: 'gold', amount: 1, toCharacterId: friendId });
expect(beyond.status).toBe(400);
expect(beyond.body.error).toMatch(/Not enough money/);
// Exchanging is the exception: it moves exactly the coins named.
expect((await request(app).post(`${ch}/gold/exchange`).set(playerAuth).send({ from: 'SILVER', to: 'COPPER', amount: 7 })).status).toBe(400);
expect(await purse()).toEqual([0, 6, 5]);
});
});
+7 -1
View File
@@ -7,6 +7,7 @@ const { generateInviteCode } = require('../utils/inviteCode');
const { computeCharacterSheet, MAX_DAMAGE_SCALING, MAX_GEAR_LEVEL } = require('../game/calculations');
const { WEAPON_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../game/data/library');
const { visitedLocationIds } = require('../utils/locations');
const { deathSaveInfo } = require('../utils/hp');
const router = express.Router();
@@ -24,6 +25,7 @@ function campaignSummary(campaign, role) {
startingSpellCount: campaign.startingSpellCount,
skipMotivationRoll: campaign.skipMotivationRoll,
showMonsterHp: campaign.showMonsterHp,
areaAttackAcPenalty: campaign.areaAttackAcPenalty,
ownerId: campaign.ownerId,
archivedAt: campaign.archivedAt,
role,
@@ -53,6 +55,7 @@ const createCampaignSchema = z.object({
startingSpellCount: z.number().int().min(0).max(50).optional(),
skipMotivationRoll: z.boolean().optional(),
showMonsterHp: z.boolean().optional(),
areaAttackAcPenalty: z.number().int().min(0).max(10).optional(),
});
// POST /campaigns — create, creator becomes DM/owner
@@ -194,7 +197,10 @@ function characterSummary(character) {
buildStage: character.buildStage,
currentHp: character.currentHp,
tempHp: character.tempHp,
deathSaves: deathSaveInfo(character),
gold: character.gold,
silver: character.silver,
copper: character.copper,
maxHp: character.buildStage === 'COMPLETE' ? sheet.hp.max : null,
armorClass: character.buildStage === 'COMPLETE' ? sheet.armorClass : null,
currentLocationId: character.currentLocationId,
@@ -219,7 +225,7 @@ router.get('/:id/characters', requireCampaignMember(), async (req, res, next) =>
characters: summaries.map((c) => {
if (c.userId === req.user.id) return c;
const knowsPlace = c.currentLocationId && visited.has(c.currentLocationId);
return { ...c, gold: null, ...(knowsPlace ? {} : { currentLocationId: null, currentLocationName: null }) };
return { ...c, gold: null, silver: null, copper: null, ...(knowsPlace ? {} : { currentLocationId: null, currentLocationName: null }) };
}),
});
} catch (err) {
+159 -27
View File
@@ -29,9 +29,9 @@ const {
} = require('../game/buildFlow');
const characterOptions = require('../game/data/characterOptions');
const { SKILLS } = require('../game/data/skills');
const { InsufficientGoldError, adjustGold, giveGear } = require('../utils/grants');
const { CURRENCIES, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, spendCoins, worthInGold, giveGear } = require('../utils/grants');
const { recordVisits } = require('../utils/locations');
const { absorbDamage } = require('../utils/hp');
const { absorbDamage, NO_DEATH_SAVES, lifeState, deathSaveInfo, settleDeathSaves, changeHp, rollDeathSave } = require('../utils/hp');
const router = express.Router();
@@ -85,6 +85,7 @@ function serializeCharacter(character, isOwner, isDM = true) {
},
currentHp: character.currentHp,
tempHp: character.tempHp,
deathSaves: deathSaveInfo(character),
armorBonus: character.armorBonus,
specialAbilities: character.specialAbilities,
background: character.background,
@@ -94,6 +95,8 @@ function serializeCharacter(character, isOwner, isDM = true) {
currentLocationId: restricted ? null : character.currentLocationId,
currentLocation: restricted ? null : character.currentLocation || null,
gold: restricted ? null : character.gold,
silver: restricted ? null : character.silver,
copper: restricted ? null : character.copper,
restricted,
buildStage: character.buildStage,
buildState: character.buildStage === 'COMPLETE' ? null : character.buildState,
@@ -170,8 +173,8 @@ function clamp(value, min, max) {
router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
if (req.body && req.body.gold !== undefined) {
return res.status(400).json({ error: 'Gold changes go through the money tracker.' });
if (req.body && ['gold', 'silver', 'copper'].some((coin) => req.body[coin] !== undefined)) {
return res.status(400).json({ error: 'Gold, silver and copper changes go through the money tracker.' });
}
const parsed = patchCharacterSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
@@ -182,18 +185,28 @@ router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, n
// Damage is soaked by temp HP before it reaches real HP — unless this
// same request sets temp HP outright.
if (parsed.data.currentHp !== undefined && parsed.data.tempHp === undefined) {
const settingHp = parsed.data.currentHp !== undefined;
const settingTempHp = parsed.data.tempHp !== undefined;
if (req.character.buildStage === 'COMPLETE' && (settingHp || parsed.data.level !== undefined)) {
// Current HP stays within 0..max, including when a level change moves max.
const maxHp = computeCharacterSheet({ ...req.character, ...parsed.data }).hp.max;
if (!settingHp) {
parsed.data.currentHp = clamp(req.character.currentHp, 0, maxHp);
} else {
if (lifeState(req.character) === 'DEAD' && parsed.data.currentHp > 0) {
return res.status(400).json({ error: `${req.character.name} is dead. The DM has to revive them before they can be healed.` });
}
// Reaching 0, being hit while there, and being healed back up all
// move the death saves along with the HP.
const { tempHp, ...after } = changeHp(settingTempHp ? { ...req.character, tempHp: 0 } : req.character, parsed.data.currentHp, maxHp);
Object.assign(parsed.data, after, settingTempHp ? {} : { tempHp });
}
} else if (settingHp && !settingTempHp) {
const after = absorbDamage(req.character.currentHp, req.character.tempHp, parsed.data.currentHp);
parsed.data.currentHp = after.currentHp;
if (after.tempHp !== req.character.tempHp) parsed.data.tempHp = after.tempHp;
}
// Current HP stays within 0..max, including when a level change moves max.
if (req.character.buildStage === 'COMPLETE' && (parsed.data.currentHp !== undefined || parsed.data.level !== undefined)) {
const maxHp = computeCharacterSheet({ ...req.character, ...parsed.data }).hp.max;
parsed.data.currentHp = clamp(parsed.data.currentHp ?? req.character.currentHp, 0, maxHp);
}
// privateNotes is for the owning player only — a DM editing someone
// else's character (or another player, though write access already
// blocks that) should never be able to set it through this route.
@@ -232,6 +245,61 @@ router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, n
}
});
// ---------------------------------------------------------------------------
// Death saves — what happens to a character at 0 HP (utils/hp.js)
// ---------------------------------------------------------------------------
const deathSaveTally = z.number().int().min(0).max(3);
const deathSaveRoll = z.number().int().min(1, 'A death save is a d20: 1 to 20').max(20, 'A death save is a d20: 1 to 20');
const deathSaveSchema = z.discriminatedUnion('action', [
// What the d20 showed; the server works out what it means.
z.object({ action: z.literal('roll'), roll: deathSaveRoll }),
// Steadied by someone else: a Medicine check, a healer's kit, a spell.
z.object({ action: z.literal('stabilize') }),
// The DM setting the tallies by hand: a critical hit's second failure, or
// undoing a mistake.
z.object({ action: z.literal('set'), successes: deathSaveTally, failures: deathSaveTally }),
// The DM bringing back the dead, at 1 HP.
z.object({ action: z.literal('revive') }),
]);
// The owning player rolls their own death saves and can be steadied; setting
// the tallies by hand and raising the dead are the DM's.
router.post('/:id/death-saves', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
const parsed = deathSaveSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { character } = req;
if (character.buildStage !== 'COMPLETE') return res.status(400).json({ error: 'Character build is not finished yet.' });
const input = parsed.data;
const state = lifeState(character);
if ((input.action === 'set' || input.action === 'revive') && !req.isDM) {
return res.status(403).json({ error: 'Only the DM can do that.' });
}
let data;
if (input.action === 'roll' || input.action === 'stabilize') {
if (state !== 'DYING') return res.status(400).json({ error: `${character.name} is not dying, so there is no death save to make.` });
data = input.action === 'roll' ? rollDeathSave(character, input.roll) : { ...NO_DEATH_SAVES, stable: true };
} else if (input.action === 'set') {
if (state === 'UP') return res.status(400).json({ error: `${character.name} is not at 0 HP.` });
data = settleDeathSaves(input.successes, input.failures);
} else {
if (state !== 'DEAD') return res.status(400).json({ error: `${character.name} is not dead.` });
data = { currentHp: 1, ...NO_DEATH_SAVES };
}
await prisma.character.update({ where: { id: character.id }, data });
const full = await loadFullCharacter(character.id);
req.app.get('io').to(`campaign:${full.campaignId}`).emit('character:updated', { characterId: full.id });
res.json({ character: serializeCharacter(full, req.isOwner) });
} catch (err) {
next(err);
}
});
router.delete('/:id', requireCharacterAccess(), async (req, res, next) => {
try {
if (!req.isDM) return res.status(403).json({ error: 'DM only' });
@@ -355,6 +423,8 @@ router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
speedOverride: source.speedOverride,
specialAbilities: source.specialAbilities,
gold: source.gold,
silver: source.silver,
copper: source.copper,
background: source.background,
appearance: source.appearance,
// Same privacy boundary as serializeCharacter: a DM copying a
@@ -370,10 +440,13 @@ router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
},
inventoryItems: { create: [...items.values()] },
goldTransactions: {
create:
source.gold > 0
? [{ amount: source.gold, balanceAfter: source.gold, reason: 'Carried over from another campaign', createdById: req.user.id }]
: [],
create: CURRENCIES.filter((currency) => source[COIN_COLUMN[currency]] > 0).map((currency) => ({
currency,
amount: source[COIN_COLUMN[currency]],
balanceAfter: source[COIN_COLUMN[currency]],
reason: 'Carried over from another campaign',
createdById: req.user.id,
})),
},
weapons: { create: [...weapons.values()] },
spells: { create: [...spells.values()] },
@@ -837,6 +910,7 @@ router.get('/:id/quick-stats', requireCharacterAccess(), async (req, res, next)
currentHp: character.currentHp,
maxHp: sheet.hp.max,
tempHp: character.tempHp,
deathSaves: deathSaveInfo(character),
armorClass: sheet.armorClass,
initiative: sheet.initiative,
speed: sheet.speed,
@@ -857,6 +931,18 @@ router.get('/:id/quick-stats', requireCharacterAccess(), async (req, res, next)
const GOLD_TRANSACTION_INCLUDE = { createdBy: { select: { displayName: true } } };
// Which coin an amount is in; gold when none is given.
const currencySchema = z.enum(CURRENCIES).default('GOLD');
function coinName(currency) {
return currency.toLowerCase();
}
// For a payment the whole purse can't cover.
function notEnoughMoney(character, amount, currency) {
return `Not enough money — ${character.name} has ${character.gold} gold, ${character.silver} silver and ${character.copper} copper (worth ${worthInGold(character)} gold), which doesn't cover ${amount} ${coinName(currency)}.`;
}
router.get('/:id/gold', requireCharacterAccess(), async (req, res, next) => {
try {
if (!req.isDM && !req.isOwner) return res.status(403).json({ error: "Only the character's player and the DM can see its money" });
@@ -865,7 +951,7 @@ router.get('/:id/gold', requireCharacterAccess(), async (req, res, next) => {
include: GOLD_TRANSACTION_INCLUDE,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
});
res.json({ gold: req.character.gold, transactions });
res.json({ gold: req.character.gold, silver: req.character.silver, copper: req.character.copper, transactions });
} catch (err) {
next(err);
}
@@ -873,30 +959,74 @@ router.get('/:id/gold', requireCharacterAccess(), async (req, res, next) => {
// amount is signed: positive = gained, negative = spent. The owning player
// and the DM can both record entries; each one is stamped with who made it.
// A spend is paid from the whole purse: if there aren't enough of the coin
// named, bigger coins are broken (or smaller ones counted out) to cover it.
router.post('/:id/gold', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
const parsed = z
.object({
amount: z.number().int().min(-1000000).max(1000000).refine((n) => n !== 0, 'Amount cannot be zero'),
currency: currencySchema,
reason: z.string().trim().max(200).optional().nullable(),
})
.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { amount, reason } = parsed.data;
const { amount, currency, reason } = parsed.data;
let transaction;
let transactions;
try {
// Throwing inside the transaction rolls the balance change back.
transaction = await prisma.$transaction((tx) =>
adjustGold(tx, { characterId: req.character.id, amount, reason, userId: req.user.id, include: GOLD_TRANSACTION_INCLUDE })
);
transactions = await prisma.$transaction(async (tx) => {
const entry = { characterId: req.character.id, currency, reason, userId: req.user.id, include: GOLD_TRANSACTION_INCLUDE };
return amount > 0 ? [await adjustGold(tx, { ...entry, amount })] : spendCoins(tx, { ...entry, amount: -amount });
});
} catch (err) {
if (!(err instanceof InsufficientGoldError)) throw err;
return res.status(400).json({ error: `Not enough gold — the balance is ${req.character.gold}.` });
return res.status(400).json({ error: notEnoughMoney(req.character, -amount, currency) });
}
emitCharacterUpdated(req);
res.status(201).json({ gold: transaction.balanceAfter, transaction });
const purse = await prisma.character.findUnique({ where: { id: req.character.id }, select: { gold: true, silver: true, copper: true } });
res.status(201).json({ ...purse, transaction: transactions[0], transactions });
} catch (err) {
next(err);
}
});
// Changes coins of one kind into another at their fixed worth (1 gold = 10
// silver = 100 copper). `amount` is how many `from` coins to hand over; it has
// to come to a whole number of `to` coins. Both sides go on the ledger. Unlike
// a spend, this moves exactly the coins named: no change is made.
router.post('/:id/gold/exchange', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
const parsed = z
.object({ from: z.enum(CURRENCIES), to: z.enum(CURRENCIES), amount: z.number().int().min(1).max(1000000) })
.refine((data) => data.from !== data.to, { message: 'Pick two different coins' })
.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { from, to, amount } = parsed.data;
const worth = amount * COIN_VALUE[from];
if (worth % COIN_VALUE[to] !== 0) {
const step = COIN_VALUE[to] / COIN_VALUE[from];
return res.status(400).json({ error: `${amount} ${coinName(from)} doesn't make a whole number of ${coinName(to)} — exchange in multiples of ${step}.` });
}
const received = worth / COIN_VALUE[to];
try {
await prisma.$transaction(async (tx) => {
const base = { characterId: req.character.id, userId: req.user.id };
await adjustGold(tx, { ...base, currency: from, amount: -amount, reason: `Exchanged for ${received} ${coinName(to)}` });
await adjustGold(tx, { ...base, currency: to, amount: received, reason: `Exchanged from ${amount} ${coinName(from)}` });
});
} catch (err) {
if (!(err instanceof InsufficientGoldError)) throw err;
return res.status(400).json({ error: `Not enough ${coinName(from)} — the balance is ${req.character[COIN_COLUMN[from]]}.` });
}
emitCharacterUpdated(req);
const character = await loadFullCharacter(req.character.id);
res.status(201).json({ character: serializeCharacter(character, req.isOwner, req.isDM), received });
} catch (err) {
next(err);
}
@@ -1040,7 +1170,7 @@ for (const gear of GEAR) {
// ---------------------------------------------------------------------------
const giveSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('gold'), toCharacterId: z.string(), amount: z.number().int().min(1) }),
z.object({ kind: z.literal('gold'), toCharacterId: z.string(), amount: z.number().int().min(1), currency: z.enum(CURRENCIES).default('GOLD') }),
z.object({ kind: z.literal('weapon'), toCharacterId: z.string(), id: z.string(), quantity: z.number().int().min(1).default(1) }),
z.object({ kind: z.literal('item'), toCharacterId: z.string(), id: z.string(), quantity: z.number().int().min(1).default(1) }),
z.object({ kind: z.literal('spell'), toCharacterId: z.string(), id: z.string() }),
@@ -1067,8 +1197,10 @@ router.post('/:id/give', requireCharacterAccess({ write: true }), async (req, re
try {
await prisma.$transaction(async (tx) => {
if (give.kind === 'gold') {
await adjustGold(tx, { characterId: giver.id, amount: -give.amount, reason: `Gave to ${recipient.name}`, userId: req.user.id });
await adjustGold(tx, { characterId: recipient.id, amount: give.amount, reason: `Received from ${giver.name}`, userId: req.user.id });
// The giver pays from their whole purse, with change; the recipient gets the coins named.
const coins = { amount: give.amount, currency: give.currency, userId: req.user.id };
await spendCoins(tx, { ...coins, characterId: giver.id, reason: `Gave to ${recipient.name}` });
await adjustGold(tx, { ...coins, characterId: recipient.id, reason: `Received from ${giver.name}` });
return;
}
@@ -1102,7 +1234,7 @@ router.post('/:id/give', requireCharacterAccess({ write: true }), async (req, re
});
} catch (err) {
if (err instanceof InsufficientGoldError) {
return res.status(400).json({ error: `Not enough gold — ${giver.name} has ${giver.gold}.` });
return res.status(400).json({ error: notEnoughMoney(giver, give.amount, give.currency || 'GOLD') });
}
if (err instanceof GiveError) return res.status(400).json({ error: err.message });
throw err;
+73 -12
View File
@@ -4,9 +4,9 @@ const prisma = require('../db');
const { requireAuth } = require('../middleware/auth');
const { loadCampaignMembership } = require('../middleware/campaignAccess');
const { computeCharacterSheet, computeWeaponStats, canUseGear, abilityModifier, MAX_GEAR_LEVEL } = require('../game/calculations');
const { adjustGold, giveGear } = require('../utils/grants');
const { CURRENCIES, adjustGold, giveGear } = require('../utils/grants');
const { moveCharacters, visitedLocationIds } = require('../utils/locations');
const { absorbDamage } = require('../utils/hp');
const { lifeState, deathSaveInfo, changeHp } = require('../utils/hp');
const router = express.Router();
@@ -103,6 +103,7 @@ function characterCombatInfo(character) {
armorClass: sheet.armorClass,
maxHp: sheet.hp.max,
tempHp: character.tempHp,
deathSaves: deathSaveInfo(character),
speed: sheet.speed,
initiativeBonus: sheet.initiative,
passivePerception: sheet.passivePerception,
@@ -261,6 +262,9 @@ router.patch('/:id', requireEncounterAccess, requireDM, async (req, res, next) =
data.activeParticipantId = inTurnOrder(participants)[0]?.id ?? null;
}
// 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 });
await respondWithEncounter(req, res);
} catch (err) {
@@ -399,10 +403,14 @@ router.patch('/:id/participants/:participantId', requireEncounterAccess, async (
const maxHp = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies).hp.max;
data.maxHp = maxHp;
if (data.currentHp !== undefined) {
// Temp HP soaks damage first; what is left lands on the character.
const after = absorbDamage(character.currentHp, character.tempHp, data.currentHp);
data.currentHp = clamp(after.currentHp, 0, maxHp);
await prisma.character.update({ where: { id: character.id }, data: { currentHp: data.currentHp, tempHp: after.tempHp } });
if (lifeState(character) === 'DEAD' && data.currentHp > 0) {
return res.status(400).json({ error: `${character.name} is dead. Revive them before healing.` });
}
// Temp HP soaks damage first; what is left lands on the character,
// and at 0 HP it moves their death saves too.
const after = changeHp(character, data.currentHp, maxHp);
data.currentHp = after.currentHp;
await prisma.character.update({ where: { id: character.id }, data: after });
emitCharacterUpdated(req, character.id);
}
} else if (data.currentHp !== undefined) {
@@ -425,10 +433,15 @@ router.delete('/:id/participants/:participantId', requireEncounterAccess, requir
const index = order.findIndex((p) => p.id === req.params.participantId);
if (index === -1) return res.status(404).json({ error: 'Combatant not found in this encounter' });
// Removing whoever is on turn passes the turn to the next combatant.
if (req.encounter.activeParticipantId === req.params.participantId) {
// Removing whoever is on turn passes the turn to the next combatant, and
// their targets go with the turn. Removing a target just unmarks it.
const { activeParticipantId, targetParticipantIds } = req.encounter;
if (activeParticipantId === req.params.participantId) {
const next = order.length > 1 ? order[(index + 1) % order.length] : null;
await prisma.encounter.update({ where: { id: req.encounter.id }, data: { activeParticipantId: next ? next.id : null } });
await prisma.encounter.update({ where: { id: req.encounter.id }, data: { activeParticipantId: next ? next.id : null, targetParticipantIds: [] } });
} else if (targetParticipantIds.includes(req.params.participantId)) {
const left = targetParticipantIds.filter((id) => id !== req.params.participantId);
await prisma.encounter.update({ where: { id: req.encounter.id }, data: { targetParticipantIds: left } });
}
await prisma.encounterParticipant.delete({ where: { id: req.params.participantId } });
await respondWithEncounter(req, res);
@@ -437,6 +450,52 @@ router.delete('/:id/participants/:participantId', requireEncounterAccess, requir
}
});
// ---------------------------------------------------------------------------
// Targets — who the combatant on turn is going for
// ---------------------------------------------------------------------------
// The whole list, in the order picked; an empty one clears it.
const targetsSchema = z.object({ participantIds: z.array(z.string()).max(50) });
// Combatants can be marked as targets, for everyone to see; the DM's screen
// then offers to apply damage to them. One is an ordinary attack, more is an
// area attack. The DM can mark anyone at any time. A player can while their
// own character is on turn in a battle. Nobody can mark the combatant who is
// on turn: you don't target yourself.
router.put('/:id/targets', requireEncounterAccess, async (req, res, next) => {
try {
const parsed = targetsSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const participantIds = [...new Set(parsed.data.participantIds)];
// Whoever is on turn, when a battle is under way.
const { mode, status, activeParticipantId } = req.encounter;
const onTurnId = mode === 'BATTLE' && status === 'ACTIVE' ? activeParticipantId : null;
if (!req.isDM) {
const ownTurn = onTurnId
? await prisma.encounterParticipant.findFirst({
where: { id: onTurnId, encounterId: req.encounter.id, character: { userId: req.user.id } },
select: { id: true },
})
: null;
if (!ownTurn) return res.status(403).json({ error: "You can only pick targets on your own character's turn" });
}
if (onTurnId && participantIds.includes(onTurnId)) {
return res.status(400).json({ error: 'The combatant on turn cannot be their own target' });
}
const found = await prisma.encounterParticipant.count({ where: { id: { in: participantIds }, encounterId: req.encounter.id } });
if (found !== participantIds.length) return res.status(400).json({ error: 'Combatant not found in this encounter' });
await prisma.encounter.update({ where: { id: req.encounter.id }, data: { targetParticipantIds: participantIds } });
await respondWithEncounter(req, res);
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Turns and rounds
// ---------------------------------------------------------------------------
@@ -456,6 +515,7 @@ router.post('/:id/next-turn', requireEncounterAccess, requireDM, async (req, res
data: {
status: 'ACTIVE',
activeParticipantId: order[wrapped || index === -1 ? 0 : index + 1].id,
targetParticipantIds: [], // the next combatant picks their own
...(wrapped ? { currentRound: { increment: 1 } } : {}),
},
});
@@ -473,6 +533,7 @@ router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req,
data: {
currentRound: { increment: 1 },
status: 'ACTIVE',
targetParticipantIds: [],
// A new round starts back at the top of the initiative order.
...(req.encounter.mode === 'BATTLE' ? { activeParticipantId: inTurnOrder(participants)[0]?.id ?? null } : {}),
},
@@ -493,7 +554,7 @@ router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req,
// ---------------------------------------------------------------------------
const rewardSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('GOLD'), gold: z.number().int().min(1).max(1000000), note: z.string().trim().max(200).optional().nullable() }),
z.object({ type: z.literal('GOLD'), gold: z.number().int().min(1).max(1000000), currency: z.enum(CURRENCIES).default('GOLD'), note: z.string().trim().max(200).optional().nullable() }),
z.object({
type: z.literal('WEAPON'),
weaponId: z.string(),
@@ -570,7 +631,7 @@ router.post('/:id/rewards/:rewardId/award', requireEncounterAccess, requireDM, a
let recipients;
if (parsed.data.split) {
if (reward.type !== 'GOLD') return res.status(400).json({ error: 'Only gold can be split.' });
if (reward.type !== 'GOLD') return res.status(400).json({ error: 'Only coins can be split.' });
const participants = await prisma.encounterParticipant.findMany({
where: { encounterId: req.encounter.id, characterId: { not: null } },
include: { character: true },
@@ -599,7 +660,7 @@ router.post('/:id/rewards/:rewardId/award', requireEncounterAccess, requireDM, a
const remainder = reward.gold % recipients.length;
for (const [i, character] of recipients.entries()) {
const amount = share + (i < remainder ? 1 : 0);
if (amount > 0) await adjustGold(tx, { characterId: character.id, amount, reason, userId: req.user.id });
if (amount > 0) await adjustGold(tx, { characterId: character.id, amount, currency: reward.currency, reason, userId: req.user.id });
}
} else {
const kind = reward.type === 'WEAPON' ? 'weapon' : 'item';
+99 -9
View File
@@ -1,24 +1,114 @@
// The two ways a character's belongings change: gold through the ledger, and
// The two ways a character's belongings change: coins through the ledger, and
// gear from the campaign pools. Shared by the character sheet routes and by
// encounter rewards so both follow the same rules. Every function takes a
// Prisma client or transaction client as `db`.
// The coins, and what each is worth counted in copper: silver is 0.1 gold and
// copper is 0.01 gold, so 1 gold = 10 silver = 100 copper and 1 silver = 10
// copper. Whole numbers keep exchanges exact.
const CURRENCIES = ['GOLD', 'SILVER', 'COPPER'];
const COIN_VALUE = { GOLD: 100, SILVER: 10, COPPER: 1 };
// Each coin is its own column on the character.
const COIN_COLUMN = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper' };
class InsufficientGoldError extends Error {}
// amount is signed: positive = gained, negative = spent. Writes the ledger row
// and returns it; throws InsufficientGoldError if the balance would go below 0
// (inside a transaction, that rolls the whole thing back).
async function adjustGold(db, { characterId, amount, reason, userId, include }) {
// amount is signed: positive = gained, negative = spent, in `currency` coins.
// Writes the ledger row and returns it; throws InsufficientGoldError if that
// coin's balance would go below 0 (inside a transaction, that rolls the whole
// thing back). This moves exactly the coins named and nothing else; to pay a
// price out of whatever coins the purse holds, use spendCoins.
async function adjustGold(db, { characterId, amount, currency = 'GOLD', reason, userId, include }) {
const column = COIN_COLUMN[currency];
// The atomic increment is the balance check: two spends racing each other
// can't both pass a stale read.
const updated = await db.character.update({ where: { id: characterId }, data: { gold: { increment: amount } } });
if (updated.gold < 0) throw new InsufficientGoldError();
const updated = await db.character.update({ where: { id: characterId }, data: { [column]: { increment: amount } } });
if (updated[column] < 0) throw new InsufficientGoldError();
return db.goldTransaction.create({
data: { characterId, amount, balanceAfter: updated.gold, reason: reason || null, createdById: userId },
data: { characterId, currency, amount, balanceAfter: updated[column], reason: reason || null, createdById: userId },
include,
});
}
// Works out which coins leave (and come back as change) when a purse pays
// `amount` coins of `currency`. Returns the change to each balance, e.g.
// { GOLD: -1, SILVER: 7, COPPER: 0 } for 3 silver paid from a purse with only
// gold in it — or null if the whole purse isn't worth that much.
// 1. Coins of the kind asked for go first.
// 2. Any shortfall is met by breaking the nearest bigger coin; the change
// comes back in as few coins as possible.
// 3. Only if there are no bigger coins left are smaller ones counted out.
function planPayment(purse, currency, amount) {
const worth = (coin) => COIN_VALUE[coin];
const delta = { GOLD: 0, SILVER: 0, COPPER: 0 };
const left = (coin) => purse[coin] + delta[coin];
let owed = amount * worth(currency);
if (CURRENCIES.reduce((sum, coin) => sum + purse[coin] * worth(coin), 0) < owed) return null;
const pay = (coin, count) => {
delta[coin] -= count;
owed -= count * worth(coin);
};
pay(currency, Math.min(purse[currency], amount));
const bigger = CURRENCIES.filter((coin) => worth(coin) > worth(currency)).reverse(); // nearest first
for (const coin of bigger) {
if (owed <= 0) break;
pay(coin, Math.min(left(coin), Math.ceil(owed / worth(coin))));
// Overpaid: hand the difference back, biggest coins first.
let change = -owed;
for (const small of CURRENCIES.filter((c) => worth(c) < worth(coin))) {
if (change <= 0) break;
const count = Math.floor(change / worth(small));
delta[small] += count;
change -= count * worth(small);
}
if (owed < 0) owed = 0;
}
for (const coin of CURRENCIES.filter((c) => worth(c) < worth(currency))) {
if (owed <= 0) break;
pay(coin, Math.min(left(coin), Math.floor(owed / worth(coin))));
}
return delta;
}
// Pays `amount` coins of `currency` out of a character's purse, making change
// as needed (see planPayment). Every coin that moved gets its own ledger row;
// the rows are returned, the one in `currency` first when there is one. Throws
// InsufficientGoldError if the purse as a whole can't cover it.
async function spendCoins(db, { characterId, amount, currency = 'GOLD', reason, userId, include }) {
const character = await db.character.findUnique({ where: { id: characterId }, select: { gold: true, silver: true, copper: true } });
const purse = { GOLD: character.gold, SILVER: character.silver, COPPER: character.copper };
const delta = planPayment(purse, currency, amount);
if (!delta) throw new InsufficientGoldError();
const moved = CURRENCIES.filter((coin) => delta[coin] !== 0).sort((a, b) => (b === currency) - (a === currency));
const madeChange = moved.some((coin) => coin !== currency);
const label = reason || 'Spent';
const rows = [];
for (const coin of moved) {
rows.push(
await adjustGold(db, {
characterId,
amount: delta[coin],
currency: coin,
// Says what the payment was when the coins that moved aren't the ones asked for.
reason: madeChange ? `${label} (paid ${amount} ${currency.toLowerCase()}, with change)` : reason,
userId,
include,
})
);
}
return rows;
}
// A purse's whole worth as one figure in gold, for messages: 265 copper-worth -> 2.65.
function worthInGold(character) {
const copper = CURRENCIES.reduce((sum, coin) => sum + character[COIN_COLUMN[coin]] * COIN_VALUE[coin], 0);
return copper / COIN_VALUE.GOLD;
}
const HELD = {
weapon: { model: 'characterWeapon', key: 'weaponId' },
item: { model: 'inventoryItem', key: 'itemId' },
@@ -37,4 +127,4 @@ async function giveGear(db, kind, { characterId, entry, quantity = 1, level, equ
});
}
module.exports = { InsufficientGoldError, adjustGold, giveGear };
module.exports = { CURRENCIES, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, planPayment, spendCoins, worthInGold, giveGear };
+67 -1
View File
@@ -9,4 +9,70 @@ function absorbDamage(currentHp, tempHp, requestedHp) {
return { currentHp: requestedHp + absorbed, tempHp: tempHp - absorbed };
}
module.exports = { absorbDamage };
// ---------------------------------------------------------------------------
// At 0 HP — death saves
// ---------------------------------------------------------------------------
//
// A character at 0 HP is dying: they roll a d20 each turn, and three successes
// or three failures settle it. The tallies and the "stable" flag live on the
// Character row, next to the HP they belong to.
// Three successes steady a character; three failures kill them.
const DEATH_SAVE_LIMIT = 3;
const NO_DEATH_SAVES = { deathSaveSuccesses: 0, deathSaveFailures: 0, stable: false };
// UP: on their feet. DYING: at 0 HP and rolling death saves. STABLE: at 0 HP
// but no longer rolling. DEAD: three failures, or one blow too big to survive.
function lifeState(character) {
if (character.deathSaveFailures >= DEATH_SAVE_LIMIT) return 'DEAD';
if (character.currentHp > 0) return 'UP';
return character.stable ? 'STABLE' : 'DYING';
}
// What the API sends about it, the same shape wherever a character's HP goes.
function deathSaveInfo(character) {
return { state: lifeState(character), successes: character.deathSaveSuccesses, failures: character.deathSaveFailures };
}
// Turns a pair of tallies into what gets stored: the third failure is death,
// and the third success makes the character stable, which clears the tallies.
function settleDeathSaves(successes, failures) {
if (failures >= DEATH_SAVE_LIMIT) return { deathSaveSuccesses: successes, deathSaveFailures: DEATH_SAVE_LIMIT, stable: false };
if (successes >= DEATH_SAVE_LIMIT) return { ...NO_DEATH_SAVES, stable: true };
return { deathSaveSuccesses: successes, deathSaveFailures: failures, stable: false };
}
// Damage or healing on a finished character. `requestedHp` is the HP that was
// asked for, which may be below zero or above max. Returns every column that
// should be written: HP and temp HP, plus the death-save columns when the
// change touches them.
//
// - Damage comes off temp HP first.
// - Dropping to 0 starts the death saves from a clean slate.
// - Damage taken while already at 0 is one failed save, and ends "stable".
// - Damage that would leave as much as max HP still to take once HP is at 0
// kills outright.
// - Any healing from 0 puts the character back up and clears the tallies.
function changeHp(character, requestedHp, maxHp) {
const soaked = absorbDamage(character.currentHp, character.tempHp, requestedHp);
const after = { currentHp: Math.min(Math.max(soaked.currentHp, 0), maxHp), tempHp: soaked.tempHp };
if (after.currentHp > 0) return { ...after, ...NO_DEATH_SAVES };
const damage = character.currentHp - soaked.currentHp; // what got past temp HP
if (damage <= 0) return after;
if (-soaked.currentHp >= maxHp) return { ...after, ...settleDeathSaves(0, DEATH_SAVE_LIMIT) };
if (character.currentHp > 0) return { ...after, ...NO_DEATH_SAVES };
return { ...after, ...settleDeathSaves(character.deathSaveSuccesses, character.deathSaveFailures + 1) };
}
// A dying character's death save, from what the d20 showed: 10 or more is a
// success, less is a failure, a 1 is two failures, and a 20 puts them back on
// their feet with 1 HP.
function rollDeathSave(character, roll) {
if (roll === 20) return { currentHp: 1, ...NO_DEATH_SAVES };
if (roll >= 10) return settleDeathSaves(character.deathSaveSuccesses + 1, character.deathSaveFailures);
return settleDeathSaves(character.deathSaveSuccesses, character.deathSaveFailures + (roll === 1 ? 2 : 1));
}
module.exports = { absorbDamage, DEATH_SAVE_LIMIT, NO_DEATH_SAVES, lifeState, deathSaveInfo, settleDeathSaves, changeHp, rollDeathSave };
+84
View File
@@ -0,0 +1,84 @@
const { lifeState, settleDeathSaves, changeHp, rollDeathSave } = require('./hp');
const CLEAR = { deathSaveSuccesses: 0, deathSaveFailures: 0, stable: false };
const hero = (over = {}) => ({ currentHp: 10, tempHp: 0, ...CLEAR, ...over });
describe('lifeState', () => {
test('reads up, dying, stable and dead off the row', () => {
expect(lifeState(hero())).toBe('UP');
expect(lifeState(hero({ currentHp: 0 }))).toBe('DYING');
expect(lifeState(hero({ currentHp: 0, stable: true }))).toBe('STABLE');
expect(lifeState(hero({ currentHp: 0, deathSaveFailures: 3 }))).toBe('DEAD');
});
});
describe('changeHp', () => {
test('ordinary damage and healing leave the death saves clear', () => {
expect(changeHp(hero(), 4, 20)).toEqual({ currentHp: 4, tempHp: 0, ...CLEAR });
expect(changeHp(hero(), 99, 20)).toEqual({ currentHp: 20, tempHp: 0, ...CLEAR });
});
test('dropping to 0 starts the saves from nothing', () => {
expect(changeHp(hero(), -5, 20)).toEqual({ currentHp: 0, tempHp: 0, ...CLEAR });
});
test('damage at 0 HP is a failed save and ends stable', () => {
const down = hero({ currentHp: 0, deathSaveSuccesses: 2, deathSaveFailures: 1 });
expect(changeHp(down, -3, 20)).toMatchObject({ currentHp: 0, deathSaveSuccesses: 2, deathSaveFailures: 2, stable: false });
expect(changeHp(hero({ currentHp: 0, stable: true }), -3, 20)).toMatchObject({ deathSaveFailures: 1, stable: false });
expect(lifeState(changeHp(hero({ currentHp: 0, deathSaveFailures: 2 }), -1, 20))).toBe('DEAD');
});
test('temp HP that soaks the whole blow costs no save', () => {
const down = hero({ currentHp: 0, tempHp: 5, deathSaveFailures: 1 });
expect(changeHp(down, -5, 20)).toEqual({ currentHp: 0, tempHp: 0 });
});
test('asking for the HP they already have at 0 changes nothing', () => {
expect(changeHp(hero({ currentHp: 0, deathSaveFailures: 2 }), 0, 20)).toEqual({ currentHp: 0, tempHp: 0 });
});
test('damage of max HP or more past zero kills outright', () => {
expect(lifeState(changeHp(hero(), -19, 20))).toBe('DYING');
expect(lifeState(changeHp(hero(), -20, 20))).toBe('DEAD');
expect(lifeState(changeHp(hero({ currentHp: 0 }), -20, 20))).toBe('DEAD');
// Temp HP counts against the blow first.
expect(lifeState(changeHp(hero({ tempHp: 1 }), -20, 20))).toBe('DYING');
});
test('healing from 0 puts them back up with a clean slate', () => {
const down = hero({ currentHp: 0, deathSaveSuccesses: 1, deathSaveFailures: 2 });
expect(changeHp(down, 1, 20)).toEqual({ currentHp: 1, tempHp: 0, ...CLEAR });
expect(changeHp(hero({ currentHp: 0, stable: true }), 6, 20)).toEqual({ currentHp: 6, tempHp: 0, ...CLEAR });
});
});
describe('rollDeathSave', () => {
const down = (successes, failures) => hero({ currentHp: 0, deathSaveSuccesses: successes, deathSaveFailures: failures });
test('10 or more succeeds, less fails, a 1 fails twice', () => {
expect(rollDeathSave(down(0, 0), 10)).toEqual({ deathSaveSuccesses: 1, deathSaveFailures: 0, stable: false });
expect(rollDeathSave(down(0, 0), 19)).toEqual({ deathSaveSuccesses: 1, deathSaveFailures: 0, stable: false });
expect(rollDeathSave(down(0, 0), 9)).toEqual({ deathSaveSuccesses: 0, deathSaveFailures: 1, stable: false });
expect(rollDeathSave(down(0, 0), 1)).toEqual({ deathSaveSuccesses: 0, deathSaveFailures: 2, stable: false });
});
test('the third success makes them stable and clears the tallies', () => {
expect(rollDeathSave(down(2, 2), 14)).toEqual({ ...CLEAR, stable: true });
});
test('the third failure is death, and never counts past three', () => {
expect(rollDeathSave(down(2, 2), 5)).toMatchObject({ deathSaveFailures: 3 });
expect(rollDeathSave(down(0, 2), 1)).toMatchObject({ deathSaveFailures: 3 });
});
test('a 20 is 1 HP and a clean slate', () => {
expect(rollDeathSave(down(0, 2), 20)).toEqual({ currentHp: 1, ...CLEAR });
});
});
describe('settleDeathSaves', () => {
test('failures win a tie at three', () => {
expect(settleDeathSaves(3, 3)).toMatchObject({ deathSaveFailures: 3, stable: false });
});
});
+74 -11
View File
@@ -325,6 +325,47 @@ Example: a Fighter (d10) with CON 14 (+2) has 12 at level 1 and 28 at level 3.
**Temp HP** is a tracked number shown beside HP. Damage is not taken from it
automatically.
### At 0 HP: death saves
A character at 0 HP is in one of three states, shown on the sheet under the
HP bar, on their encounter card, in the quick stats and in the party lists.
| State | Meaning | How it ends |
|---|---|---|
| Dying | Rolling death saves | Three successes, three failures, healing, or being stabilised |
| Stable | At 0 HP, no longer rolling | Healing wakes them; damage makes them Dying again |
| Dead | Three failures, or one blow too big to survive | Only the DM's **Revive at 1 HP** |
**Rolling.** While Dying, type what the d20 showed and click **Death save**.
The app does not roll it.
| Roll | Result |
|---|---|
| 1 | Two failures |
| 2 to 9 | One failure |
| 10 to 19 | One success |
| 20 | Back up with 1 HP, tallies cleared |
- **Three successes:** Stable. The tallies are cleared.
- **Three failures:** Dead.
- **Stabilise** makes a Dying character Stable without a roll, for when
someone steadies them (a Medicine check, a healer's kit, a spell).
- **Damage at 0 HP** is one failed save, and makes a Stable character Dying
again. Damage soaked entirely by temp HP does not count. The app cannot tell
a critical hit; the DM adds its second failure by clicking a pip.
- **Massive damage:** if a blow leaves as much as the character's max HP
still to take once HP reaches 0, they are Dead at once. A character with
12 max HP on 5 HP dies from 17 damage or more.
- **Healing** from 0, by any amount, puts the character back up and clears
the tallies and Stable.
- **Dead** characters cannot be healed: Heal is refused with a message. The
DM clicks **Revive at 1 HP**, then heals as normal.
- The **DM** can click a pip to set either tally by hand, to fix a mistake.
Dropping to 0 always starts from empty tallies. None of this changes any
other number on the sheet, and nothing is added to the conditions in an
encounter.
## 8. Other derived stats
| Stat | Formula |
@@ -509,17 +550,35 @@ character already has adds to the stack. Items have no effect on any stat. An
item can carry optional dice (a potion's healing, a bomb's damage) that grow
with its level exactly like a weapon's (section 10).
**Money** is a single gold balance with a log behind it.
**Money** is a purse of three coins, each counted on its own, with a log
behind it.
- The sheet's Money card shows the balance and every change to it: the amount,
what it was for, who recorded it, when, and the balance afterwards.
- The **owning player and the DM** can both record a gain or a spend. Other
members can see the log but not add to it.
- The balance **cannot go below zero**; a spend larger than the balance is
refused.
- The log is the only way the balance changes. Entries cannot be edited or
| Coin | Worth |
|---|---|
| Gold (gp) | 1 gold |
| Silver (sp) | 0.1 gold — 10 to the gold |
| Copper (cp) | 0.01 gold — 100 to the gold, 10 to the silver |
- The sheet's Money card shows the three balances, what the purse is worth in
gold altogether, and every change: the amount and coin, what it was for, who
recorded it, when, and that coin's balance afterwards.
- The **owning player and the DM** can both record a gain or a spend, choosing
the coin. Other players cannot see the purse or the log.
- **Spending makes change for you.** A spend (or a gift to another character)
is paid from the whole purse. Coins of the kind named go first; if there
aren't enough, the nearest bigger coin is broken and the change comes back
in as few coins as possible; if there are no bigger coins, smaller ones are
counted out. Spending 3 silver from a purse of 5 gold leaves 4 gold and 7
silver. Each coin that moved gets its own log entry, marked "with change".
- A spend is refused only when the **whole purse** isn't worth enough.
- **Exchange** turns coins of one kind into another at the worth above, and
logs both sides. It moves exactly the coins named and has to come to whole
coins: silver into gold goes in tens, copper into silver in tens, copper
into gold in hundreds.
- The log is the only way a balance changes. Entries cannot be edited or
deleted; to correct a mistake, record the opposite entry.
- There is one currency (gold). Silver, copper and so on are not tracked.
- Starting gold (a campaign setting) is gold only. Encounter rewards and gifts
between characters can be in any coin.
### Giving to another character
@@ -620,7 +679,9 @@ players never receive.
- "Begin Battle" puts the highest initiative on turn. "Next Turn" passes the
turn down the order, and going past the last combatant starts the next
round. "Next Round" jumps straight to the top of the next round.
- A combatant at 0 HP is marked Down but still takes its turn in the order.
- A monster or NPC at 0 HP is marked Down; a character is Dying and makes
death saves on its card (section 7). Either still has its place in the
order.
- Conditions (Prone, Poisoned, …) are picked from a list and removed with a
click.
@@ -671,6 +732,8 @@ Another Campaign" card on its sheet.
| View private notes | Yes | No | No |
| Run the builder | Yes | Yes | No |
| Change HP, temp HP, armor bonus | Yes | Yes | No |
| Enter a death save, or stabilise | Yes | Yes | No |
| Set death-save tallies by hand, revive from dead | No | Yes | No |
| Edit special abilities, background, appearance (after the build) | No | Yes | No |
| Tick Equipped / Prepared | Yes | Yes | No |
| Give, remove or re-count weapons, spells and items | No | Yes | No |
@@ -709,7 +772,7 @@ calculations for them.
- Rolling attacks, damage, saves or checks for a character (the dice roller,
bottom left of every page, is a plain roller that knows nothing about the
sheet)
- Death saves, conditions on the sheet, and resting
- Conditions on the sheet, and resting
- Automatic levelling from XP
- Alignment, languages, tool proficiencies and backgrounds-as-rules
- Encumbrance and item weight
+94 -6
View File
@@ -48,7 +48,7 @@ time from the top of the page.
| For | A fight | A scene built around people and an objective |
| Layout | Everyone in one list, in initiative order | 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 | Not shown |
| 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 |
@@ -149,18 +149,46 @@ The DM types an amount and clicks Damage or Heal on a card.
monsters and NPCs it depends on the campaign setting "Players see the exact
hit points of monsters and NPCs": on (the default) shows the numbers, off
shows only the health bar. The DM always sees the numbers.
- At 0 HP a combatant is marked **Down** and dimmed. It keeps its place in
the order; nothing is removed automatically.
- At 0 HP a monster or NPC is marked **Down** and dimmed. It keeps its place
in the order; nothing is removed automatically.
- A **character** at 0 HP is **Dying** instead, and its card grows a death-save
box: see below.
- A character's temporary HP is shown beside its HP but is not used up
automatically.
### A character at 0 HP
The full rules are in [CHARACTERS.md](CHARACTERS.md), section 7; the box on
the card is the same one as on the sheet, and both change the same character.
- **Dying.** The box shows two tallies, Saved and Failed, three pips each.
Type what the d20 showed and click **Death save**: 10 or more is a success,
less is a failure, a 1 is two failures, a 20 puts the character back up at
1 HP. The DM can do this for anyone; a player for their own character.
- **Damage while down** is one failed save, added when the DM clicks Damage.
For a critical hit the DM clicks the next Failed pip to add the second.
- **Stabilise** stops the saves without a roll (a Medicine check, a spell).
Three successes do the same. A stable character stays at 0 HP.
- **Heal** on a dying or stable character puts them back up and clears the
tallies.
- **Dead** after three failures, or when one blow leaves as much as the
character's max HP still to take after reaching 0. Healing is refused; the
DM clicks **Revive at 1 HP**.
- The DM can click any pip to set a tally by hand.
Nothing rolls or advances by itself: the app does not prompt for a save when
the turn comes round, and a dying, stable or dead character keeps its place
in the order.
## 7. Initiative, turns and rounds
Battles only.
Initiative is entered in battles and quests alike. Turns and rounds are for
battles only.
- Each combatant has an **initiative roll** field. Type what the d20 showed,
1 to 20; the field accepts nothing else. The app does not roll the die, but
it adds the bonus for you and shows the total: a character's initiative
it adds the bonus for you and shows the total, which can go past 20 (a 20 on
the die with a +3 bonus is 23): a character's initiative
bonus from their sheet, a monster's Dexterity modifier, nothing for an NPC.
- The DM can enter the roll for anyone, when adding them or on the card. A
player can enter the roll for **their own character** on its card.
@@ -174,6 +202,62 @@ 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.
### Targets
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.
- 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.
- The combatant **on turn** cannot be marked: nobody targets themselves, and
their card has no Target button until the turn passes. To heal or hurt them
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
AC and HP, and the attacker's attacks: a character's equipped weapons (all
of them if none are equipped) and damaging spells, or a monster's or NPC's
attack and damage. The amount box is ready to type in; **Damage** (or
Enter) and **Heal** change HP exactly as the buttons on a card do.
- Targets stay marked after damage, for a second attack. They are cleared
when the turn or round moves on, when the encounter begins, ends or is
reopened; a target that is removed from the encounter is unmarked.
**Area attacks**
An attack on several targets is harder to land and its damage is shared.
- **Harder to hit.** Every target's AC counts as higher for each target
beyond the first, by the campaign's **area attack AC penalty**. The DM sets
it in the campaign settings, from 0 (no penalty) to 10; it starts at 2, which
gives:
| Targets | Added to each AC |
|---|---|
| 1 | 0 |
| 2 | +2 |
| 3 | +4 |
| 4 | +6 |
The attack bar shows the AC to beat with the penalty already added
("17 (15 +2)"), and each target's card says "area attack, AC +2".
- **Who was hit.** Each target has a tick box, ticked to begin with. Untick
anyone the roll missed; they take nothing.
- **Sharing the amount.** Type the total. "Split between those hit" (the
default) shares it evenly, any remainder going one point each to the first
targets marked: 7 between three is 3, 2 and 2. "Full amount to each" gives
every target hit the whole amount. Each target's share is shown before it
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.
## 8. Conditions
The DM adds a condition to any card from the "+ Condition" list, and removes
@@ -243,6 +327,9 @@ information cannot be dug out of the page.
| Add or remove anyone | Yes | No |
| 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 |
| Enter a death save, or stabilise | Yes, for anyone | For their own character only |
| Set death-save tallies by hand, revive the dead | Yes | No |
| Advance turns and rounds | Yes | No |
| Add, remove or award rewards | Yes | No |
@@ -253,7 +340,8 @@ People outside the campaign cannot open its encounters.
- Rolling initiative, attacks, damage or saves (use the dice roller, bottom
left of every page)
- Applying an attack's damage automatically
- Death saves and stabilising
- Death saves for monsters and NPCs, and prompting for a save on a dying
character's turn
- Conditions with durations or mechanical effects
- Maps, positions, distances and areas of effect
- Experience points and encounter difficulty
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.3.0",
"version": "1.7.2",
"type": "module",
"scripts": {
"dev": "vite",
+47 -4
View File
@@ -7,6 +7,7 @@ import type {
CampaignMemberSummary,
CharacterDetail,
CharacterSummary,
Currency,
DiceMode,
Encounter,
EncounterMode,
@@ -162,6 +163,28 @@ export function usePatchCharacter(characterId: string) {
});
}
// What can be done for a character at 0 HP: enter a death save (what the d20
// showed), steady them, or — the DM only — set the tallies by hand or bring
// back the dead.
export type DeathSaveAction =
| { action: 'roll'; roll: number }
| { action: 'stabilize' }
| { action: 'set'; successes: number; failures: number }
| { action: 'revive' };
export function useDeathSave(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: DeathSaveAction) =>
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/death-saves`, data)).data.character,
onSuccess: (character) => {
invalidateCharacter(qc, characterId); // the sheet and its quick stats
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows the sheet's HP
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
},
});
}
export function useDeleteCharacter(campaignId: string) {
const qc = useQueryClient();
return useMutation({
@@ -172,7 +195,7 @@ export function useDeleteCharacter(campaignId: string) {
// What one character can hand to another. Whatever is given leaves the giver.
export type Gift =
| { kind: 'gold'; amount: number }
| { kind: 'gold'; amount: number; currency?: Currency }
| { kind: 'weapon' | 'item'; id: string; quantity: number }
| { kind: 'spell'; id: string };
@@ -211,6 +234,16 @@ export function useQuickStats(characterId: string | undefined, enabled: boolean)
});
}
// Changes `amount` coins of one kind into another at their fixed worth.
export function useExchangeCoins(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { from: Currency; to: Currency; amount: number }) =>
(await apiClient.post<{ character: CharacterDetail; received: number }>(`/characters/${characterId}/gold/exchange`, data)).data,
onSuccess: () => invalidateCharacter(qc, characterId),
});
}
// --------------------------------------------------------------------------
// Character build flow
// --------------------------------------------------------------------------
@@ -388,8 +421,8 @@ export function useGoldLedger(characterId: string | undefined, enabled = true) {
export function useAdjustGold(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { amount: number; reason?: string }) =>
(await apiClient.post<{ gold: number; transaction: GoldTransaction }>(`/characters/${characterId}/gold`, data)).data,
mutationFn: async (data: { amount: number; currency?: Currency; reason?: string }) =>
(await apiClient.post<{ transaction: GoldTransaction }>(`/characters/${characterId}/gold`, data)).data,
onSuccess: () => invalidateCharacter(qc, characterId), // prefix match also refreshes the ledger
});
}
@@ -717,6 +750,16 @@ export function usePatchEncounter(encounterId: string) {
}
type EncounterStatusInput = 'PLANNING' | 'ACTIVE' | 'COMPLETE';
// Sets which combatants are marked as targets (an empty list clears them).
// The DM at any time; a player while their own character is on turn.
export function useSetTargets(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (participantIds: string[]) => (await apiClient.put(`/encounters/${encounterId}/targets`, { participantIds })).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
});
}
export function useNextTurn(encounterId: string) {
const qc = useQueryClient();
return useMutation({
@@ -730,7 +773,7 @@ export function useNextTurn(encounterId: string) {
// --------------------------------------------------------------------------
export type NewReward =
| { type: 'GOLD'; gold: number; note?: string }
| { type: 'GOLD'; gold: number; currency?: Currency; note?: string }
| { type: 'WEAPON'; weaponId: string; quantity?: number; level?: number; note?: string }
| { type: 'ITEM'; itemId: string; quantity?: number; level?: number; note?: string };
+33 -3
View File
@@ -93,6 +93,8 @@ export interface Campaign {
startingSpellCount: number;
skipMotivationRoll: boolean;
showMonsterHp: boolean; // off: players get a health bar for monsters and NPCs, no numbers
// Added to each target's AC for every target beyond the first in an area attack; 0 is off.
areaAttackAcPenalty: number;
ownerId: string;
archivedAt: string | null; // set while the campaign is archived
role: 'DM' | 'PLAYER';
@@ -105,6 +107,20 @@ export interface CampaignMemberSummary {
role: 'DM' | 'PLAYER';
}
// The coins a character carries. 1 gold = 10 silver = 100 copper.
export type Currency = 'GOLD' | 'SILVER' | 'COPPER';
// Where a character stands at 0 HP. UP: on their feet. DYING: rolling death
// saves, three of either kind settles it. STABLE: at 0 HP but no longer
// rolling. DEAD: three failures, or one blow too big to survive.
export type LifeState = 'UP' | 'DYING' | 'STABLE' | 'DEAD';
export interface DeathSaves {
state: LifeState;
successes: number;
failures: number;
}
export interface CharacterSummary {
id: string;
campaignId: string;
@@ -118,7 +134,11 @@ export interface CharacterSummary {
buildStage: BuildStage;
currentHp: number;
tempHp: number;
gold: number | null; // null for another player's character
deathSaves: DeathSaves;
// The purse; all three are null for another player's character.
gold: number | null;
silver: number | null;
copper: number | null;
maxHp: number | null;
armorClass: number | null;
currentLocationId: string | null;
@@ -233,8 +253,9 @@ export interface InventoryItem {
export interface GoldTransaction {
id: string;
characterId: string;
currency: Currency; // which coin this entry is in
amount: number; // signed: positive = gained, negative = spent
balanceAfter: number;
balanceAfter: number; // that coin's balance afterwards
reason: string | null;
createdAt: string;
createdBy: { displayName: string } | null;
@@ -267,6 +288,7 @@ export interface CharacterDetail {
rolledScores: Record<Ability, number>;
currentHp: number;
tempHp: number;
deathSaves: DeathSaves;
armorBonus: number;
specialAbilities: string | null;
background: string | null;
@@ -278,6 +300,8 @@ export interface CharacterDetail {
currentLocationId: string | null;
currentLocation: Location | null;
gold: number | null;
silver: number | null;
copper: number | null;
// True when this is another player's character: no gold, inventory or
// location is sent, only what the party can see across the table.
restricted: boolean;
@@ -337,7 +361,8 @@ export interface EncounterReward {
id: string;
encounterId: string;
type: 'GOLD' | 'WEAPON' | 'ITEM';
gold: number | null;
gold: number | null; // for a GOLD reward: how many coins...
currency: Currency; // ...and which coin
weaponId: string | null;
itemId: string | null;
quantity: number;
@@ -376,6 +401,7 @@ export interface CharacterCombatInfo {
characterClass: CharacterClass | null;
armorClass: number;
tempHp: number;
deathSaves: DeathSaves;
speed: number;
initiativeBonus: number;
passivePerception: number;
@@ -423,6 +449,9 @@ export interface Encounter {
status: EncounterStatus;
currentRound: number;
activeParticipantId: string | null;
// The combatants marked as targets right now, for everyone to see, in the
// order they were picked. More than one is an area attack.
targetParticipantIds: string[];
description: string | null;
dmNotes?: string | null; // DM only
// Where the scene is. A player is only told once their character has been there.
@@ -514,6 +543,7 @@ export interface QuickStats {
currentHp: number;
maxHp: number;
tempHp: number;
deathSaves: DeathSaves;
armorClass: number;
initiative: number;
speed: number;
+118
View File
@@ -0,0 +1,118 @@
import { useState } from 'react';
import { useDeathSave } from '../api/queries';
import { InfoTip } from './InfoTip';
import { NumberInput } from './NumberInput';
import type { DeathSaves as DeathSavesInfo } from '../api/types';
const STATE_LABELS = { DYING: 'Dying', STABLE: 'Stable', DEAD: '☠ Dead' } as const;
// Nothing for a character on their feet; otherwise where they stand at 0 HP.
export function LifeBadge({ deathSaves }: { deathSaves: DeathSavesInfo }) {
if (deathSaves.state === 'UP') return null;
return <span className={`badge ${deathSaves.state === 'STABLE' ? '' : 'badge-hostile'}`}>{STATE_LABELS[deathSaves.state]}</span>;
}
// Three pips for one tally. For the DM each is a button: clicking a pip sets
// the tally to it, and clicking the last filled one takes it back off.
function Pips({ kind, count, onSet }: { kind: 'success' | 'failure'; count: number; onSet?: (count: number) => void }) {
const noun = kind === 'success' ? 'successes' : 'failures';
return (
<span className="death-pips" role="group" aria-label={`${count} of 3 ${noun}`}>
{[1, 2, 3].map((n) => {
const className = `death-pip is-${kind}${n <= count ? ' is-filled' : ''}`;
return onSet ? (
<button key={n} type="button" className={className} aria-label={`Set ${noun} to ${n === count ? n - 1 : n}`} onClick={() => onSet(n === count ? n - 1 : n)} />
) : (
<span key={n} className={className} />
);
})}
</span>
);
}
// What happens to a character at 0 HP: the death-save tallies, and the ways
// out of them. `canAct` is the character's own player or the DM; setting the
// tallies by hand and raising the dead are the DM's alone.
export function DeathSaves({
characterId,
name,
deathSaves,
canAct,
isDM,
onError,
}: {
characterId: string;
name: string;
deathSaves: DeathSavesInfo;
canAct: boolean;
isDM: boolean;
onError?: (err: unknown) => void;
}) {
const deathSave = useDeathSave(characterId);
const [roll, setRoll] = useState('');
const { state, successes, failures } = deathSaves;
if (state === 'UP') return null;
const send = (action: Parameters<typeof deathSave.mutate>[0]) => deathSave.mutate(action, { onError });
function handleRoll() {
const value = Number(roll);
if (!roll || !Number.isInteger(value)) return;
send({ action: 'roll', roll: value });
setRoll('');
}
return (
<div className={`death-saves is-${state.toLowerCase()}`}>
<div className="flex-row flex-wrap">
<LifeBadge deathSaves={deathSaves} />
{state === 'DYING' && (
<>
<span className="death-tally">
Saved <Pips kind="success" count={successes} onSet={isDM ? (n) => send({ action: 'set', successes: n, failures }) : undefined} />
</span>
<span className="death-tally">
Failed <Pips kind="failure" count={failures} onSet={isDM ? (n) => send({ action: 'set', successes, failures: n }) : undefined} />
</span>
<InfoTip term="DEATH_SAVES" />
</>
)}
{state === 'STABLE' && <span className="combatant-sub">Out cold at 0 HP, but no longer rolling. Any healing wakes them; damage starts the saves again.</span>}
{state === 'DEAD' && <span className="combatant-sub">{isDM ? 'Healing does nothing now.' : 'Only the DM can bring them back.'}</span>}
</div>
{state === 'DYING' && canAct && (
<div className="flex-row flex-wrap gap-sm mt-sm">
<NumberInput
min={1}
max={20}
placeholder="d20"
aria-label={`Death save roll for ${name}`}
title="Type what the d20 showed (1 to 20)."
value={roll}
onChange={(e) => setRoll(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleRoll()}
style={{ width: 64 }}
/>
<button className="btn" onClick={handleRoll} disabled={!roll || deathSave.isPending}>
Death save
</button>
<button
className="btn btn-ghost"
title="Someone steadied them: a Medicine check, a healer's kit or a spell. They stay at 0 HP but stop rolling."
onClick={() => send({ action: 'stabilize' })}
disabled={deathSave.isPending}
>
Stabilise
</button>
</div>
)}
{state === 'DEAD' && isDM && (
<button className="btn mt-sm" onClick={() => send({ action: 'revive' })} disabled={deathSave.isPending}>
Revive at 1 HP
</button>
)}
</div>
);
}
+40 -6
View File
@@ -1,4 +1,5 @@
import { useId, useState } from 'react';
import { useEffect, useId, useRef, useState, type CSSProperties } from 'react';
import { createPortal } from 'react-dom';
// Short explanations for the stat labels sprinkled across the sheet, quick
// stats, party table, and builder — "what is this, and how is it worked
@@ -7,6 +8,7 @@ export const STAT_HELP: Record<string, string> = {
AC: 'Armor Class — how hard you are to hit. 10 + your Dexterity modifier + any armor bonus.',
HP: 'Hit Points — damage you can take before falling unconscious. Max HP is set by your class hit die, Constitution modifier, and level.',
TEMP_HP: 'Temporary Hit Points — an extra buffer that absorbs damage first and disappears when it runs out (it does not stack with more temp HP).',
DEATH_SAVES: 'Death saves — at 0 HP you roll a d20 at the start of each of your turns. 10 or more is a success, less is a failure; a 1 counts as two failures and a 20 puts you back up with 1 HP. Three successes and you are stable; three failures and you die. Being hit while down is a failure (two if it was a critical hit). Any healing ends it.',
INITIATIVE: 'Initiative — added to a d20 roll at the start of a fight to decide turn order. Equal to your Dexterity modifier.',
PROFICIENCY_BONUS: 'Proficiency Bonus — added to attacks, saves, and skills you are trained in. Grows with level (+2 at level 1, up to +6 at level 20).',
PASSIVE_PERCEPTION: 'Passive Perception — your always-on alertness score, used instead of rolling when you are not actively searching. 10 + your Perception skill bonus.',
@@ -38,8 +40,36 @@ export const STAT_HELP: Record<string, string> = {
// (a species, a skill, an ability) without adding it to the shared list.
export function InfoTip({ term, text: custom, label = 'What is this?' }: { term?: keyof typeof STAT_HELP; text?: string; label?: string }) {
const [open, setOpen] = useState(false);
const [place, setPlace] = useState<CSSProperties | null>(null);
const trigger = useRef<HTMLButtonElement>(null);
const id = useId();
const text = custom ?? (term ? STAT_HELP[term] : undefined);
// The bubble is drawn on the page itself, fixed to where the ⓘ is on
// screen, rather than inside whatever holds the ⓘ: a scrolling table or a
// drawer would otherwise cut it off. It sits above the ⓘ when there is room
// and below when there isn't, and never runs off the sides.
useEffect(() => {
if (!open || !trigger.current) return;
const rect = trigger.current.getBoundingClientRect();
const halfWidth = Math.min(130, window.innerWidth / 2 - 8);
const centre = Math.min(Math.max(rect.left + rect.width / 2, halfWidth + 8), window.innerWidth - halfWidth - 8);
const above = rect.top > 160;
setPlace({
left: centre,
...(above ? { bottom: window.innerHeight - rect.top + 8 } : { top: rect.bottom + 8 }),
});
// Its position is only right until the page moves.
const close = () => setOpen(false);
window.addEventListener('scroll', close, true);
window.addEventListener('resize', close);
return () => {
window.removeEventListener('scroll', close, true);
window.removeEventListener('resize', close);
setPlace(null); // so the next opening is measured afresh, not shown where the last one was
};
}, [open]);
if (!text) return null;
return (
@@ -49,6 +79,7 @@ export function InfoTip({ term, text: custom, label = 'What is this?' }: { term?
className="info-tip-trigger"
aria-describedby={id}
aria-label={label}
ref={trigger}
onMouseEnter={() => setOpen(true)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
@@ -56,11 +87,14 @@ export function InfoTip({ term, text: custom, label = 'What is this?' }: { term?
>
ⓘ
</button>
{open && (
<span role="tooltip" id={id} className="info-tip-bubble">
{text}
</span>
)}
{open &&
place &&
createPortal(
<span role="tooltip" id={id} className="info-tip-bubble" style={place}>
{text}
</span>,
document.body,
)}
</span>
);
}
@@ -3,6 +3,7 @@ import { useQuickStats } from '../api/queries';
import { Spinner, ErrorBanner } from './Spinner';
import { fmtMod } from '../lib/format';
import { HpBar } from './HpBar';
import { DeathSaves } from './DeathSaves';
export function QuickStatsFab({ characterId }: { characterId: string }) {
const [open, setOpen] = useState(false);
@@ -52,6 +53,8 @@ export function QuickStatsDrawer({ characterId, onClose }: { characterId: string
</div>
</div>
<HpBar current={data.currentHp} max={data.maxHp} temp={data.tempHp} />
{/* Read-only here; the saves are rolled on the sheet or in the encounter. */}
<DeathSaves characterId={characterId} name={data.name} deathSaves={data.deathSaves} canAct={false} isDM={false} />
{data.spellcasting && (
<div className="card mt-md">
+98 -4
View File
@@ -631,10 +631,9 @@ input[type='radio'] {
color: var(--accent-strong);
outline: none;
}
/* Placed by InfoTip.tsx against the viewport, so no scrolling container can clip it. */
.info-tip-bubble {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
position: fixed;
transform: translateX(-50%);
width: max-content;
max-width: 260px;
@@ -648,7 +647,7 @@ input[type='radio'] {
letter-spacing: normal;
color: var(--text);
box-shadow: var(--shadow-deep);
z-index: 100;
z-index: 200;
pointer-events: none;
}
@@ -806,6 +805,52 @@ table.data-table tbody tr:hover {
.combatant.is-down {
opacity: 0.6;
}
/* The marked target stands out even when it is down or not on turn. */
.combatant.is-targeted {
opacity: 1;
border-color: var(--danger-strong);
box-shadow: 0 0 0 1px var(--danger-strong), 0 0 22px rgba(216, 84, 84, 0.3), var(--shadow-deep);
}
.target-marker {
color: var(--danger-strong);
font-family: var(--font-display);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.btn-target {
padding: 0.15rem 0.6rem;
font-size: 0.8rem;
}
.btn-target.is-on {
border-color: var(--danger-strong);
color: var(--danger-strong);
background: var(--danger-soft);
}
/* The DM's attack bar stays in reach while scrolling the initiative order. */
.attack-bar {
position: sticky;
top: 4.5rem;
z-index: 20;
border-color: var(--danger-strong);
}
.attack-target {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-top: 0.5rem;
}
.attack-target-name {
font-family: var(--font-display);
}
.attack-bar-title {
font-family: var(--font-display);
font-size: 1.1rem;
color: var(--text);
}
.combatant-name {
font-family: var(--font-display);
font-size: 1.1rem;
@@ -870,6 +915,55 @@ table.data-table tbody tr:hover {
line-height: 1;
}
/* At 0 HP: the death-save tallies and what can be done about them. */
.death-saves {
margin-top: 0.6rem;
padding: 0.6rem 0.75rem;
border: 1px solid var(--danger);
border-radius: 6px;
background: var(--danger-soft);
}
.death-saves.is-stable {
border-color: var(--border);
background: var(--bg-inset);
}
.death-tally {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.death-pips {
display: inline-flex;
gap: 0.25rem;
}
.death-pip {
width: 0.9rem;
height: 0.9rem;
padding: 0;
border-radius: 50%;
border: 2px solid var(--text-dim);
background: transparent;
}
button.death-pip {
cursor: pointer;
}
.death-pip.is-success {
border-color: var(--success-strong);
}
.death-pip.is-failure {
border-color: var(--danger-strong);
}
.death-pip.is-success.is-filled {
background: var(--success-strong);
}
.death-pip.is-failure.is-filled {
background: var(--danger-strong);
}
.combatant-details {
margin-top: 0.75rem;
padding-top: 0.75rem;
+25
View File
@@ -1,3 +1,5 @@
import type { Currency } from '../api/types';
export const SPECIES_LABELS: Record<string, string> = {
HUMAN: 'Human',
HALF_ELF: 'Half-Elf',
@@ -70,6 +72,29 @@ export function hpBarClass(current: number, max: number): string {
return 'hp-bar-fill';
}
// Coins. Silver is worth 0.1 gold and copper 0.01 gold.
export const CURRENCIES: Currency[] = ['GOLD', 'SILVER', 'COPPER'];
export const COIN_LABELS: Record<Currency, string> = { GOLD: 'Gold', SILVER: 'Silver', COPPER: 'Copper' };
export const COIN_SHORT: Record<Currency, string> = { GOLD: 'gp', SILVER: 'sp', COPPER: 'cp' };
export const COIN_FIELD: Record<Currency, 'gold' | 'silver' | 'copper'> = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper' };
// What one coin is worth, counted in copper.
export const COIN_VALUE: Record<Currency, number> = { GOLD: 100, SILVER: 10, COPPER: 1 };
type Purse = { gold: number | null; silver: number | null; copper: number | null };
// "12 gp · 5 sp · 3 cp". Gold always shows; silver and copper only when held.
export function formatPurse(purse: Purse): string {
return CURRENCIES.filter((c) => c === 'GOLD' || (purse[COIN_FIELD[c]] ?? 0) > 0)
.map((c) => `${purse[COIN_FIELD[c]] ?? 0} ${COIN_SHORT[c]}`)
.join(' · ');
}
// The whole purse as one figure in gold, e.g. 12.65.
export function purseWorthInGold(purse: Purse): number {
const copper = CURRENCIES.reduce((sum, c) => sum + (purse[COIN_FIELD[c]] ?? 0) * COIN_VALUE[c], 0);
return copper / COIN_VALUE.GOLD;
}
export function extractErrorMessage(err: unknown): string {
const anyErr = err as { response?: { data?: { error?: unknown } } };
const apiError = anyErr?.response?.data?.error;
+16 -1
View File
@@ -41,6 +41,7 @@ import { useAuth } from '../auth/AuthContext';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput';
import { LifeBadge } from '../components/DeathSaves';
import { usePagination, Pagination } from '../components/Paginated';
import { SPELL_DAMAGE_GROUPS, SPELL_DAMAGE_SPECIALS, extractErrorMessage, spellLevelLabel, spellRequirementLabel } from '../lib/format';
import { speciesIcon } from '../lib/speciesIcons';
@@ -102,7 +103,7 @@ function CharacterRow({ character, isDM, onDelete }: { character: CharacterSumma
<div className="flex-row">
{!inProgress && (
<div style={{ textAlign: 'right', fontSize: '0.85rem', color: 'var(--text-muted)' }}>
HP {character.currentHp}/{character.maxHp} · AC {character.armorClass}
<LifeBadge deathSaves={character.deathSaves} /> HP {character.currentHp}/{character.maxHp} · AC {character.armorClass}
</div>
)}
<Link to={inProgress ? `/characters/${character.id}/build` : `/characters/${character.id}`} className="btn">
@@ -488,6 +489,7 @@ export function CampaignHomePage() {
startingSpellCount: Number(fd.get('startingSpellCount')),
skipMotivationRoll: fd.get('skipMotivationRoll') === 'on',
showMonsterHp: fd.get('showMonsterHp') === 'on',
areaAttackAcPenalty: Number(fd.get('areaAttackAcPenalty')),
});
}}
>
@@ -513,6 +515,19 @@ export function CampaignHomePage() {
<input type="checkbox" name="showMonsterHp" defaultChecked={campaign.showMonsterHp} />
Players see the exact hit points of monsters and NPCs in encounters (untick for a health bar only)
</label>
<label className="flex-row flex-wrap gap-sm mt-sm">
Area attack AC penalty
<NumberInput
name="areaAttackAcPenalty"
min={0}
max={10}
keepValue
defaultValue={campaign.areaAttackAcPenalty}
style={{ width: 70 }}
title="An attack on several targets is harder to land: each target's AC counts as this much higher for every target beyond the first. 0 turns the penalty off."
/>
<span className="combatant-sub">added to each target's AC for every target beyond the first (0 for none)</span>
</label>
<button type="submit" className="btn btn-primary mt-md" disabled={updateCampaign.isPending}>
Save Settings
</button>
+117 -16
View File
@@ -17,6 +17,7 @@ import {
useRemoveCharacterSpell,
useGoldLedger,
useAdjustGold,
useExchangeCoins,
useCampaignCharacters,
useGive,
type Gift,
@@ -32,14 +33,30 @@ import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput';
import { HpBar } from '../components/HpBar';
import { DeathSaves } from '../components/DeathSaves';
import { QuickStatsFab } from '../components/QuickStatsDrawer';
import { InfoTip } from '../components/InfoTip';
import { usePagination, Pagination } from '../components/Paginated';
import { ABILITY_LABELS, CLASS_LABELS, SKILL_LABELS, SPECIES_LABELS, fmtMod, extractErrorMessage, spellLevelLabel } from '../lib/format';
import {
ABILITY_LABELS,
CLASS_LABELS,
COIN_FIELD,
COIN_LABELS,
COIN_SHORT,
COIN_VALUE,
CURRENCIES,
SKILL_LABELS,
SPECIES_LABELS,
fmtMod,
extractErrorMessage,
formatPurse,
purseWorthInGold,
spellLevelLabel,
} from '../lib/format';
import { speciesIcon } from '../lib/speciesIcons';
import { hpMax as previewHpMax, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
import { CLASS_HIT_DIE } from '../lib/classData';
import type { Ability, CharacterDetail } from '../api/types';
import type { Ability, CharacterDetail, Currency } from '../api/types';
// The DM's level picker for one held weapon or item, from the level the gear
// starts at up to the maximum. Each option shows what the dice become at that
@@ -89,20 +106,26 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError:
const others = characters?.filter((c) => c.id !== character.id && c.buildStage === 'COMPLETE') ?? [];
if (others.length === 0) return null;
// "kind:id" identifies the thing picked; gold has no id.
// "kind:id" identifies the thing picked; for coins the id is which coin.
const [kind, id] = what.split(':');
const coin = (kind === 'gold' ? id : 'GOLD') as Currency;
const heldWeapon = character.weapons.find((w) => w.weaponId === id);
const heldItem = character.inventoryItems.find((i) => i.itemId === id);
const gold = character.gold ?? 0;
const max = kind === 'gold' ? gold : kind === 'weapon' ? (heldWeapon?.quantity ?? 0) : kind === 'item' ? (heldItem?.quantity ?? 0) : 1;
// A gift of coins is paid from the whole purse, with change, so any coin
// the purse is worth at least one of can be given.
const held = (c: Currency) => character[COIN_FIELD[c]] ?? 0;
const purseInCopper = CURRENCIES.reduce((sum, c) => sum + held(c) * COIN_VALUE[c], 0);
const affordable = (c: Currency) => Math.floor(purseInCopper / COIN_VALUE[c]);
const coinsHeld = CURRENCIES.filter((c) => affordable(c) > 0);
const max = kind === 'gold' ? affordable(coin) : kind === 'weapon' ? (heldWeapon?.quantity ?? 0) : kind === 'item' ? (heldItem?.quantity ?? 0) : 1;
const count = Math.floor(Number(amount));
const countOk = kind === 'spell' || (count >= 1 && count <= max);
const nothingToGive = gold === 0 && character.weapons.length + character.inventoryItems.length + character.spells.length === 0;
const nothingToGive = coinsHeld.length === 0 && character.weapons.length + character.inventoryItems.length + character.spells.length === 0;
function handleGive() {
const gift: Gift | null =
kind === 'gold'
? { kind, amount: count }
? { kind, amount: count, currency: coin }
: kind === 'weapon' || kind === 'item'
? { kind, id, quantity: count }
: kind === 'spell'
@@ -145,7 +168,11 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError:
style={{ flex: 2, minWidth: 160 }}
>
<option value="">What to give…</option>
{gold > 0 && <option value="gold:">Gold ({gold} gp)</option>}
{coinsHeld.map((c) => (
<option key={c} value={`gold:${c}`}>
{COIN_LABELS[c]} ({held(c)} {COIN_SHORT[c]} held)
</option>
))}
{character.weapons.length > 0 && (
<optgroup label="Weapons">
{character.weapons.map((w) => (
@@ -178,8 +205,8 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError:
<NumberInput
min={1}
max={max}
aria-label={kind === 'gold' ? 'How much gold' : 'How many'}
title={kind === 'gold' ? 'How much gold' : `How many (up to ${max})`}
aria-label={kind === 'gold' ? 'How many coins' : 'How many'}
title={`How many (up to ${max})`}
value={amount}
onChange={(e) => setAmount(e.target.value)}
style={{ width: 90 }}
@@ -228,6 +255,7 @@ export function CharacterSheetPage() {
const patchSpell = usePatchCharacterSpell(characterId);
const removeSpell = useRemoveCharacterSpell(characterId);
const adjustGold = useAdjustGold(characterId);
const exchangeCoins = useExchangeCoins(characterId);
const createNote = useCreateNote();
const { data: campaigns } = useCampaigns();
const copyCharacter = useCopyCharacter(characterId);
@@ -237,6 +265,10 @@ export function CharacterSheetPage() {
const [pickedItemId, setPickedItemId] = useState('');
const [goldAmount, setGoldAmount] = useState('');
const [goldReason, setGoldReason] = useState('');
const [goldCurrency, setGoldCurrency] = useState<Currency>('GOLD');
const [exchangeAmount, setExchangeAmount] = useState('');
const [exchangeFrom, setExchangeFrom] = useState<Currency>('GOLD');
const [exchangeTo, setExchangeTo] = useState<Currency>('SILVER');
const [noteBody, setNoteBody] = useState('');
const [pickedWeaponId, setPickedWeaponId] = useState('');
const [pickedSpellId, setPickedSpellId] = useState('');
@@ -273,7 +305,9 @@ export function CharacterSheetPage() {
const amount = Number(hpDelta);
if (!amount) return;
const next = character!.currentHp + sign * amount;
patchCharacter.mutate({ currentHp: next });
setFormError(null);
// Healing is refused for a character who has died.
patchCharacter.mutate({ currentHp: next }, { onError: (err) => setFormError(extractErrorMessage(err)) });
setHpDelta('');
}
@@ -300,7 +334,7 @@ export function CharacterSheetPage() {
if (!(amount > 0)) return;
setFormError(null);
try {
await adjustGold.mutateAsync({ amount: sign * amount, reason: goldReason.trim() || undefined });
await adjustGold.mutateAsync({ amount: sign * amount, currency: goldCurrency, reason: goldReason.trim() || undefined });
setGoldAmount('');
setGoldReason('');
} catch (err) {
@@ -308,6 +342,18 @@ export function CharacterSheetPage() {
}
}
async function handleExchange() {
const amount = Math.floor(Number(exchangeAmount));
if (!(amount > 0) || exchangeFrom === exchangeTo) return;
setFormError(null);
try {
await exchangeCoins.mutateAsync({ from: exchangeFrom, to: exchangeTo, amount });
setExchangeAmount('');
} catch (err) {
setFormError(extractErrorMessage(err));
}
}
async function handleAddNote(e: FormEvent) {
e.preventDefault();
if (!noteBody.trim()) return;
@@ -415,6 +461,14 @@ export function CharacterSheetPage() {
</div>
<HpBar current={character.currentHp} max={sheet.hp.max} temp={character.tempHp} />
<DeathSaves
characterId={characterId}
name={character.name}
deathSaves={character.deathSaves}
canAct={canEdit}
isDM={isDM}
onError={(err) => setFormError(extractErrorMessage(err))}
/>
{canEdit && (
<div className="flex-row mt-sm flex-wrap">
@@ -770,19 +824,30 @@ export function CharacterSheetPage() {
<div className="card mt-md">
<div className="card-title">
<span>Money</span>
<span className="mono">{character.gold} gp</span>
<span className="mono">{formatPurse(character)}</span>
</div>
<p className="subtitle" style={{ marginTop: 0 }}>
1 gold = 10 silver = 100 copper. Worth {purseWorthInGold(character)} gold in all. Spending and giving make
change for you: if you are short of a coin, a bigger one is broken.
</p>
{canEdit && (
<div className="flex-row flex-wrap">
<NumberInput
min={1}
max={1000000}
placeholder="Amount"
aria-label="Gold amount"
aria-label="Coin amount"
value={goldAmount}
onChange={(e) => setGoldAmount(e.target.value)}
style={{ width: 100 }}
/>
<select aria-label="Coin" value={goldCurrency} onChange={(e) => setGoldCurrency(e.target.value as Currency)}>
{CURRENCIES.map((c) => (
<option key={c} value={c}>
{COIN_LABELS[c]}
</option>
))}
</select>
<input
placeholder="What for? (optional)"
aria-label="Reason"
@@ -799,19 +864,55 @@ export function CharacterSheetPage() {
</button>
</div>
)}
{canEdit && (
<div className="flex-row flex-wrap mt-sm">
<span className="subtitle">Exchange</span>
<NumberInput
min={1}
max={1000000}
placeholder="Amount"
aria-label="How many coins to exchange"
value={exchangeAmount}
onChange={(e) => setExchangeAmount(e.target.value)}
style={{ width: 100 }}
/>
<select aria-label="Exchange from" value={exchangeFrom} onChange={(e) => setExchangeFrom(e.target.value as Currency)}>
{CURRENCIES.map((c) => (
<option key={c} value={c}>
{COIN_LABELS[c]}
</option>
))}
</select>
<span className="subtitle">into</span>
<select aria-label="Exchange into" value={exchangeTo} onChange={(e) => setExchangeTo(e.target.value as Currency)}>
{CURRENCIES.map((c) => (
<option key={c} value={c}>
{COIN_LABELS[c]}
</option>
))}
</select>
<button className="btn" onClick={handleExchange} disabled={!exchangeAmount || exchangeFrom === exchangeTo || exchangeCoins.isPending}>
Exchange
</button>
</div>
)}
<div className="mt-sm">
{goldLedger?.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No transactions yet.</p>}
{ledgerPage.pageItems.map((t) => (
<div key={t.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<div>
<strong className="mono">{t.amount > 0 ? `+${t.amount}` : `−${Math.abs(t.amount)}`}</strong>
<strong className="mono">
{t.amount > 0 ? `+${t.amount}` : `−${Math.abs(t.amount)}`} {COIN_SHORT[t.currency ?? 'GOLD']}
</strong>
<span style={{ marginLeft: 8 }}>{t.reason || (t.amount > 0 ? 'Gained' : 'Spent')}</span>
<div style={{ fontSize: '0.75rem', color: 'var(--text-dim)' }}>
{t.createdBy ? `${t.createdBy.displayName} · ` : ''}
{new Date(t.createdAt).toLocaleString()}
</div>
</div>
<span className="mono" style={{ color: 'var(--text-muted)' }}>{t.balanceAfter} gp</span>
<span className="mono" style={{ color: 'var(--text-muted)' }}>
{t.balanceAfter} {COIN_SHORT[t.currency ?? 'GOLD']}
</span>
</div>
))}
</div>
+288 -36
View File
@@ -14,6 +14,7 @@ import {
useRemoveParticipant,
useAdvanceRound,
useNextTurn,
useSetTargets,
usePatchEncounter,
useSetEncounterArchived,
useDeleteEncounter,
@@ -27,11 +28,13 @@ import { useAuth } from '../auth/AuthContext';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput';
import { HpBar } from '../components/HpBar';
import { ABILITY_LABELS, CLASS_LABELS, SPECIES_LABELS, extractErrorMessage, fmtMod, spellLevelLabel } from '../lib/format';
import { DeathSaves } from '../components/DeathSaves';
import { ABILITY_LABELS, CLASS_LABELS, COIN_LABELS, CURRENCIES, SPECIES_LABELS, extractErrorMessage, fmtMod, spellLevelLabel } from '../lib/format';
import { abilityModifier, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
import type {
Ability,
CharacterSummary,
Currency,
Encounter,
EncounterMode,
EncounterParticipant,
@@ -60,6 +63,11 @@ const CONDITIONS = [
'Concentrating',
];
// An attack on more than one target is an area attack, and harder to land:
// every target's AC counts as `step` higher for each target beyond the first.
// The step is the campaign's "area attack AC penalty" setting.
const areaPenalty = (targetCount: number, step: number) => Math.max(0, targetCount - 1) * step;
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' };
@@ -94,20 +102,32 @@ function subline(p: EncounterParticipant): string {
function CombatantCard({
participant: p,
isDM,
mode,
isActive,
onPatch,
canRollInitiative,
targetedBy,
acPenalty,
onTarget,
onRemove,
onError,
}: {
participant: EncounterParticipant;
isDM: boolean;
mode: EncounterMode;
isActive: boolean;
onPatch: (data: Partial<EncounterParticipant>) => void;
// The DM for anyone; a player for their own character.
// The DM for anyone; a player for their own character. The same goes for
// that character's death saves.
canRollInitiative: boolean;
// Set while this combatant is a target: who is going for them, or '' when
// nobody is on turn.
targetedBy?: string;
// What an area attack adds to this target's AC; 0 when they are the only one.
acPenalty: number;
// Marks this combatant as a target, or unmarks them. Absent for someone who
// may not pick targets right now, and on the card of whoever is on turn.
onTarget?: () => void;
onRemove: () => void;
onError: (err: unknown) => void;
}) {
const [hpDelta, setHpDelta] = useState('');
// Details start open for the DM (who runs the scene from this screen) and
@@ -126,6 +146,9 @@ function CombatantCard({
const tracksHp = barOnly || maxHp > 0;
const pct = barOnly ? (p.hpPercent ?? 0) : tracksHp ? Math.max(0, Math.min(100, (currentHp / maxHp) * 100)) : 0;
const isDown = tracksHp && pct <= 0;
// A dying character still has something to do each turn, so its card stays lit.
const isDying = info?.deathSaves.state === 'DYING';
const isTargeted = targetedBy !== undefined;
// Players get a monster's name and health bar only; the stat block is the DM's.
const monsterStats = monster && monster.ac !== undefined ? monster : null;
const hasDetails = Boolean(info || monsterStats || npc?.dmNotes);
@@ -144,10 +167,16 @@ function CombatantCard({
}
return (
<div className={`card combatant combatant-${p.kind.toLowerCase()}${isActive ? ' is-active' : ''}${isDown ? ' is-down' : ''}`}>
<div className={`card combatant combatant-${p.kind.toLowerCase()}${isActive ? ' is-active' : ''}${isDown && !isDying ? ' is-down' : ''}${isTargeted ? ' is-targeted' : ''}`}>
<div className="flex-between flex-wrap">
<div>
{isActive && <div className="turn-marker">▶ On turn</div>}
{isTargeted && (
<div className="target-marker">
🎯 Targeted{targetedBy && ` by ${targetedBy}`}
{acPenalty > 0 && ` · area attack, AC +${acPenalty}`}
</div>
)}
<div className="flex-row flex-wrap">
<span className={`badge ${KIND_BADGES[p.kind]}`}>{KIND_LABELS[p.kind]}</span>
{p.character ? (
@@ -158,12 +187,22 @@ function CombatantCard({
<span className="combatant-name">{p.displayName}</span>
)}
{npc && <span className={`badge badge-${npc.disposition.toLowerCase()}`}>{npc.disposition}</span>}
{isDown && <span className="badge badge-hostile">Down</span>}
{isDown && !info && <span className="badge badge-hostile">Down</span>}
{onTarget && (
<button
className={`btn btn-target${isTargeted ? ' is-on' : ''}`}
aria-pressed={isTargeted}
title={isTargeted ? 'Stop targeting' : `Mark ${p.displayName} as a target. Marking more than one makes it an area attack.`}
onClick={onTarget}
>
🎯 {isTargeted ? 'Targeted' : 'Target'}
</button>
)}
</div>
<div className="combatant-sub">{subline(p)}</div>
</div>
{mode === 'BATTLE' &&
(canRollInitiative ? (
{/* Battles and quests both take initiative: who speaks or acts first matters in either. */}
{canRollInitiative ? (
<label className="flex-row gap-sm" title="Type what the d20 showed (1 to 20). The initiative bonus is added for you.">
Initiative roll
<NumberInput
@@ -186,7 +225,7 @@ function CombatantCard({
</label>
) : (
<StatChip label="Initiative">{p.initiativeRoll == null ? '—' : p.initiative}</StatChip>
))}
)}
</div>
<div className="stat-chips mt-sm">
@@ -244,6 +283,16 @@ function CombatantCard({
</button>
</div>
)}
{info && p.character && (
<DeathSaves
characterId={p.character.id}
name={p.displayName}
deathSaves={info.deathSaves}
canAct={canRollInitiative}
isDM={isDM}
onError={onError}
/>
)}
</>
)}
@@ -380,8 +429,178 @@ function CombatantCard({
);
}
// What a combatant hits with, one line per attack: a character's weapons
// (the equipped ones, when any are) and damaging spells, or the single attack
// of a monster or NPC.
function attackLines(p: EncounterParticipant): string[] {
const info = p.characterCombatInfo;
if (info) {
const equipped = info.weapons.filter((w) => w.equipped);
const weapons = (equipped.length ? equipped : info.weapons).map(
(w) => `${w.name}: ${fmtMod(w.attackBonus)} to hit · ${w.damageDice} ${fmtMod(w.damageBonus)} ${w.damageType}`
);
const spells = info.spells
.filter((s) => s.damageDice)
.map((s) => `${s.name}: ${s.damageDice}${info.spellcasting ? ` · ${fmtMod(info.spellcasting.attackBonus)} to hit / DC ${info.spellcasting.saveDC}` : ''}`);
return [...weapons, ...spells];
}
const stats = p.monster ?? p.npc;
if (!stats || (stats.attackBonus == null && !stats.damageDice)) return [];
return [[stats.attackBonus != null && `${fmtMod(stats.attackBonus)} to hit`, stats.damageDice].filter(Boolean).join(' · ')];
}
type AreaMode = 'SPLIT' | 'EACH';
// How `amount` lands on `count` targets: all of it on each, or shared out
// evenly, any remainder going one point each to the first targets picked
// (7 between three is 3, 2 and 2).
function shares(amount: number, count: number, mode: AreaMode): number[] {
if (mode === 'EACH') return Array.from({ length: count }, () => amount);
const base = Math.floor(amount / count);
return Array.from({ length: count }, (_, i) => base + (i < amount % count ? 1 : 0));
}
// The DM's shortcut once targets are marked: who is attacking whom, the
// numbers needed to call the hit, and a box to apply the damage without
// hunting for each target's card. With more than one target it is an area
// attack: each AC is shown with the penalty added, the DM ticks who was hit,
// and the amount is split between them or dealt to each in full.
function AttackBar({
attacker,
targets,
acStep,
onApply,
onRemove,
onClear,
}: {
attacker: EncounterParticipant | undefined;
targets: EncounterParticipant[];
acStep: number; // the campaign's area attack AC penalty
onApply: (changes: { participantId: string; currentHp: number }[]) => void;
onRemove: (participantId: string) => void;
onClear: () => void;
}) {
const [amount, setAmount] = useState('');
const [mode, setMode] = useState<AreaMode>('SPLIT');
const [missed, setMissed] = useState<string[]>([]);
const isArea = targets.length > 1;
const penalty = areaPenalty(targets.length, acStep);
const tracksHp = (t: EncounterParticipant) => (t.maxHp ?? 0) > 0;
const hit = targets.filter((t) => tracksHp(t) && !missed.includes(t.id));
const value = Number(amount) || 0;
const portions = shares(value, hit.length, isArea ? mode : 'EACH');
const attacks = attacker ? attackLines(attacker) : [];
function apply(sign: 1 | -1) {
if (!value || hit.length === 0) return;
onApply(hit.map((t, i) => ({ participantId: t.id, currentHp: (t.currentHp ?? 0) + sign * portions[i] })));
setAmount('');
setMissed([]);
}
return (
<div className="card attack-bar">
<div className="flex-between flex-wrap">
<div className="attack-bar-title">
{attacker && <>{attacker.displayName} → </>}🎯 {isArea ? `${targets.length} targets` : targets[0].displayName}
</div>
<button className="btn btn-ghost" onClick={onClear}>
✕ Clear {isArea ? 'targets' : 'target'}
</button>
</div>
{isArea && (
<p className="combatant-sub" style={{ margin: '0.25rem 0 0' }}>
Area attack{penalty > 0 && `: every target's AC counts as ${penalty} higher (+${acStep} for each target beyond the first)`}. Untick anyone the
roll missed.
</p>
)}
{targets.map((t) => {
const info = t.characterCombatInfo;
const ac = info?.armorClass ?? t.monster?.ac ?? t.npc?.ac;
const portion = portions[hit.indexOf(t)];
return (
<div key={t.id} className="attack-target">
{isArea && (
<label className="flex-row gap-sm">
<input
type="checkbox"
aria-label={`${t.displayName} was hit`}
disabled={!tracksHp(t)}
checked={hit.includes(t)}
onChange={(e) => setMissed(e.target.checked ? missed.filter((id) => id !== t.id) : [...missed, t.id])}
/>
<span className="attack-target-name">{t.displayName}</span>
</label>
)}
<div className="stat-chips">
{ac != null && (
<StatChip label={isArea ? 'AC to beat' : 'Target AC'}>
{ac + penalty}
{penalty > 0 && ` (${ac} +${penalty})`}
</StatChip>
)}
{tracksHp(t) ? (
<StatChip label={isArea ? 'HP' : 'Target HP'}>
{t.currentHp ?? 0}/{t.maxHp}
{info && info.tempHp > 0 && ` +${info.tempHp}`}
</StatChip>
) : (
<span className="combatant-sub">No hit points to track.</span>
)}
{isArea && value > 0 && portion !== undefined && <StatChip label="Share">{portion}</StatChip>}
</div>
{isArea && (
<button className="btn btn-ghost" aria-label={`Stop targeting ${t.displayName}`} onClick={() => onRemove(t.id)}>
✕
</button>
)}
</div>
);
})}
{attacks.map((line) => (
<div key={line} className="combatant-sub mt-sm">
⚔ {line}
</div>
))}
{targets.some(tracksHp) && (
<div className="flex-row flex-wrap gap-sm mt-sm">
{/* Remounted when the targets change, so the cursor lands here as soon as one is marked. */}
<NumberInput
key={targets.map((t) => t.id).join()}
autoFocus
min={1}
max={99999}
placeholder={isArea ? 'Total' : 'Amount'}
aria-label={isArea ? 'HP change for the targets hit' : `HP change for ${targets[0].displayName}`}
value={amount}
onChange={(e) => setAmount(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && apply(-1)}
style={{ width: 90 }}
/>
{isArea && (
<select aria-label="How the amount is shared" value={mode} onChange={(e) => setMode(e.target.value as AreaMode)} style={{ width: 'auto' }}>
<option value="SPLIT">Split between those hit</option>
<option value="EACH">Full amount to each</option>
</select>
)}
<button className="btn btn-danger" onClick={() => apply(-1)} disabled={!value || hit.length === 0}>
− Damage
</button>
<button className="btn" onClick={() => apply(1)} disabled={!value || hit.length === 0}>
+ Heal
</button>
</div>
)}
</div>
);
}
function rewardLabel(r: EncounterReward): string {
if (r.type === 'GOLD') return `${r.gold} gold`;
if (r.type === 'GOLD') return `${r.gold} ${COIN_LABELS[r.currency ?? 'GOLD'].toLowerCase()}`;
const entry = r.type === 'WEAPON' ? r.weapon : r.item;
return `${entry?.name ?? 'Removed from the pool'}${r.quantity > 1 ? ` ×${r.quantity}` : ''} · Lv ${r.level}`;
}
@@ -407,6 +626,7 @@ function RewardsPanel({
const [type, setType] = useState<NewReward['type']>('GOLD');
const [gold, setGold] = useState('');
const [currency, setCurrency] = useState<Currency>('GOLD');
const [poolId, setPoolId] = useState('');
const [quantity, setQuantity] = useState('1');
const [level, setLevel] = useState('');
@@ -426,7 +646,7 @@ function RewardsPanel({
if (type === 'GOLD') {
const amount = Math.floor(Number(gold));
if (!(amount > 0)) return;
reward = { type, gold: amount, ...shared };
reward = { type, gold: amount, currency, ...shared };
} else {
if (!picked) return;
const gear = { quantity: Math.max(1, Math.floor(Number(quantity)) || 1), level: level ? Number(level) : undefined, ...shared };
@@ -449,7 +669,7 @@ function RewardsPanel({
<div className="card-title">Rewards</div>
{isDM && rewards.length === 0 && (
<p className="combatant-sub" style={{ marginTop: 0 }}>
Line up gold, weapons and items here. Players only see a reward once you award it.
Line up coins, weapons and items here. Players only see a reward once you award it.
</p>
)}
{rewards.map((r) => (
@@ -519,20 +739,29 @@ function RewardsPanel({
setLevel('');
}}
>
<option value="GOLD">Gold</option>
<option value="GOLD">Coins</option>
<option value="WEAPON">Weapon</option>
<option value="ITEM">Item</option>
</select>
{type === 'GOLD' ? (
<NumberInput
min={1}
max={1000000}
placeholder="Amount"
aria-label="Gold amount"
value={gold}
onChange={(e) => setGold(e.target.value)}
style={{ flex: 1, minWidth: 90 }}
/>
<>
<NumberInput
min={1}
max={1000000}
placeholder="Amount"
aria-label="Coin amount"
value={gold}
onChange={(e) => setGold(e.target.value)}
style={{ flex: 1, minWidth: 90 }}
/>
<select aria-label="Coin" value={currency} onChange={(e) => setCurrency(e.target.value as Currency)}>
{CURRENCIES.map((c) => (
<option key={c} value={c}>
{COIN_LABELS[c]}
</option>
))}
</select>
</>
) : (
<>
<select
@@ -614,6 +843,7 @@ export function EncounterPage() {
const removeParticipant = useRemoveParticipant(encounterId);
const advanceRound = useAdvanceRound(encounterId);
const nextTurn = useNextTurn(encounterId);
const setTargets = useSetTargets(encounterId);
const patchEncounter = usePatchEncounter(encounterId);
const setArchived = useSetEncounterArchived(encounter?.campaignId ?? '');
const deleteEncounter = useDeleteEncounter(encounter?.campaignId ?? '');
@@ -630,6 +860,11 @@ export function EncounterPage() {
const isBattle = encounter.mode === 'BATTLE';
const participants = encounter.participants ?? []; // already in initiative order
const active = isBattle && encounter.status === 'ACTIVE' ? participants.find((p) => p.id === encounter.activeParticipantId) : undefined;
const targetIds = encounter.targetParticipantIds ?? [];
const targets = targetIds.map((id) => participants.find((p) => p.id === id)).filter((p): p is EncounterParticipant => p !== undefined);
// The DM marks targets whenever they like; a player while their own character is on turn.
const acStep = campaignData?.campaign.areaAttackAcPenalty ?? 0;
const canTarget = isDM || (active?.character != null && active.character.userId === user?.id);
const builtCharacters = characters?.filter((c) => c.buildStage === 'COMPLETE') ?? [];
const onError = (err: unknown) => setFormError(extractErrorMessage(err));
const fail = { onError };
@@ -655,15 +890,24 @@ export function EncounterPage() {
key={p.id}
participant={p}
isDM={isDM}
mode={encounter.mode}
isActive={p.id === active?.id}
onPatch={(data) => patchParticipant.mutate({ participantId: p.id, ...data }, fail)}
canRollInitiative={isDM || (p.character != null && p.character.userId === user?.id)}
targetedBy={targetIds.includes(p.id) ? (active?.displayName ?? '') : undefined}
acPenalty={areaPenalty(targets.length, acStep)}
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)
: undefined
}
onRemove={() => removeParticipant.mutate(p.id, fail)}
onError={onError}
/>
);
// A quest scene is grouped by who is there rather than by initiative.
// A quest scene is grouped by who is there; within each group the order is
// still initiative, highest first.
const groups: [string, EncounterParticipant[]][] = isBattle
? [['Initiative order', participants]]
: [
@@ -793,6 +1037,16 @@ export function EncounterPage() {
<div className="encounter-layout mt-md">
<div>
{isDM && targets.length > 0 && (
<AttackBar
attacker={active}
targets={targets}
acStep={acStep}
onApply={(changes) => Promise.all(changes.map((change) => patchParticipant.mutateAsync(change))).catch(onError)}
onRemove={(id) => setTargets.mutate(targetIds.filter((x) => x !== id), fail)}
onClear={() => setTargets.mutate([], fail)}
/>
)}
{participants.length === 0 && (
<div className="empty-state card">
{isDM
@@ -839,18 +1093,16 @@ export function EncounterPage() {
</select>
</div>
<div className="flex-row gap-sm mt-sm">
{isBattle && (
<NumberInput
min={1}
max={20}
placeholder="Initiative roll (d20)"
title="What the d20 showed, 1 to 20. Leave empty to roll later; the bonus is added for you."
aria-label="Initiative roll"
value={initiative}
onChange={(e) => setInitiative(e.target.value)}
style={{ flex: 1, minWidth: 0 }}
/>
)}
<NumberInput
min={1}
max={20}
placeholder="Initiative roll (d20)"
title="What the d20 showed, 1 to 20. Leave empty to roll later; the bonus is added for you."
aria-label="Initiative roll"
value={initiative}
onChange={(e) => setInitiative(e.target.value)}
style={{ flex: 1, minWidth: 0 }}
/>
<button className="btn btn-primary" onClick={handleAdd} disabled={!pickId || addParticipant.isPending}>
Add
</button>
+6 -4
View File
@@ -5,8 +5,9 @@ import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { usePagination, Pagination } from '../components/Paginated';
import { InfoTip } from '../components/InfoTip';
import { CLASS_LABELS, SPECIES_LABELS } from '../lib/format';
import { CLASS_LABELS, SPECIES_LABELS, formatPurse } from '../lib/format';
import { HpBar } from '../components/HpBar';
import { LifeBadge } from '../components/DeathSaves';
import { speciesIcon } from '../lib/speciesIcons';
// A DM's one-screen view of every character's stats at a glance; a player
@@ -56,7 +57,7 @@ export function PartyOverviewPage() {
<th>
AC <InfoTip term="AC" />
</th>
<th>Gold</th>
<th>Coins</th>
<th>Location</th>
</tr>
</thead>
@@ -81,7 +82,8 @@ export function PartyOverviewPage() {
<span className="mono">
{c.currentHp}/{c.maxHp}
{c.tempHp > 0 && ` +${c.tempHp}`}
</span>
</span>{' '}
<LifeBadge deathSaves={c.deathSaves} />
<HpBar current={c.currentHp} max={c.maxHp} temp={c.tempHp} height={6} />
</div>
) : (
@@ -89,7 +91,7 @@ export function PartyOverviewPage() {
)}
</td>
<td>{c.armorClass ?? '—'}</td>
<td className="mono">{c.gold ?? '—'}</td>
<td className="mono">{c.gold == null ? '—' : formatPurse(c)}</td>
<td>{c.currentLocationName ? `📍 ${c.currentLocationName}` : '—'}</td>
</tr>
);
+4 -4
View File
@@ -1,19 +1,19 @@
{
"name": "dnd-campaign-manager",
"version": "1.3.0",
"version": "1.7.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dnd-campaign-manager",
"version": "1.3.0",
"version": "1.7.2",
"workspaces": [
"backend",
"frontend"
]
},
"backend": {
"version": "1.3.0",
"version": "1.7.2",
"dependencies": {
"@prisma/client": "^5.20.0",
"bcrypt": "^5.1.1",
@@ -31,7 +31,7 @@
}
},
"frontend": {
"version": "1.3.0",
"version": "1.7.2",
"dependencies": {
"@tanstack/react-query": "^5.103.2",
"axios": "^1.20.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dnd-campaign-manager",
"version": "1.3.0",
"version": "1.7.2",
"private": true,
"description": "D&D 5e campaign manager — monorepo root",
"workspaces": [