Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8462a6e31 | ||
|
|
754eb0ee68 | ||
|
|
f8cee43ca2 | ||
|
|
e4456b0df2 | ||
|
|
ec48b65481 | ||
|
|
4362b7423e | ||
|
|
98a89185c1 |
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.3.0",
|
||||
"version": "1.7.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Generated
+4
-4
@@ -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
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user