Compare commits

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:28:24 +02:00
22 changed files with 659 additions and 125 deletions
+6 -2
View File
@@ -80,8 +80,12 @@ cd backend
npm run seed:hollowcrown
```
On a server, apply new migrations with `npx prisma migrate deploy`. Change
your password from the account page (click your name in the top bar).
On a server, after pulling a new version, run `npm run deploy` in `backend`
and then restart the backend. It applies new migrations **and** regenerates
the Prisma client; without the second half the server keeps running with the
old data model and pages that use anything new fail to load (for example
"Could not load this encounter"). Change your password from the account page
(click your name in the top bar).
## Tests
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.3.0",
"version": "1.4.0",
"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';
+18 -3
View File
@@ -118,6 +118,14 @@ enum RewardType {
ITEM
}
// The coins a character carries. Their worth is fixed in utils/grants.js:
// 1 gold = 10 silver = 100 copper.
enum Currency {
GOLD
SILVER
COPPER
}
enum NoteVisibility {
DM_ONLY
SHARED
@@ -262,7 +270,11 @@ model Character {
// 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 +415,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())
@@ -544,7 +558,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)
+152
View File
@@ -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 });
@@ -1963,3 +1972,146 @@ describe('temporary hit points', () => {
expect([now.currentHp, now.tempHp]).toEqual([0, 0]);
});
});
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]);
});
});
+3 -1
View File
@@ -195,6 +195,8 @@ function characterSummary(character) {
currentHp: character.currentHp,
tempHp: character.tempHp,
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 +221,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) {
+84 -19
View File
@@ -29,7 +29,7 @@ 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');
@@ -94,6 +94,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 +172,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() });
@@ -355,6 +357,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 +374,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()] },
@@ -857,6 +864,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 +884,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 +892,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 +1103,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 +1130,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 +1167,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 -4
View File
@@ -4,7 +4,7 @@ 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');
@@ -493,7 +493,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 +570,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 +599,7 @@ router.post('/:id/rewards/:rewardId/award', requireEncounterAccess, requireDM, a
const remainder = reward.gold % recipients.length;
for (const [i, character] of recipients.entries()) {
const amount = share + (i < remainder ? 1 : 0);
if (amount > 0) await adjustGold(tx, { characterId: character.id, amount, reason, userId: req.user.id });
if (amount > 0) await adjustGold(tx, { characterId: character.id, amount, currency: reward.currency, reason, userId: req.user.id });
}
} else {
const kind = reward.type === 'WEAPON' ? 'weapon' : 'item';
+99 -9
View File
@@ -1,24 +1,114 @@
// The two ways a character's belongings change: gold through the ledger, and
// The two ways a character's belongings change: coins through the ledger, and
// gear from the campaign pools. Shared by the character sheet routes and by
// encounter rewards so both follow the same rules. Every function takes a
// Prisma client or transaction client as `db`.
// The coins, and what each is worth counted in copper: silver is 0.1 gold and
// copper is 0.01 gold, so 1 gold = 10 silver = 100 copper and 1 silver = 10
// copper. Whole numbers keep exchanges exact.
const CURRENCIES = ['GOLD', 'SILVER', 'COPPER'];
const COIN_VALUE = { GOLD: 100, SILVER: 10, COPPER: 1 };
// Each coin is its own column on the character.
const COIN_COLUMN = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper' };
class InsufficientGoldError extends Error {}
// amount is signed: positive = gained, negative = spent. Writes the ledger row
// and returns it; throws InsufficientGoldError if the balance would go below 0
// (inside a transaction, that rolls the whole thing back).
async function adjustGold(db, { characterId, amount, reason, userId, include }) {
// amount is signed: positive = gained, negative = spent, in `currency` coins.
// Writes the ledger row and returns it; throws InsufficientGoldError if that
// coin's balance would go below 0 (inside a transaction, that rolls the whole
// thing back). This moves exactly the coins named and nothing else; to pay a
// price out of whatever coins the purse holds, use spendCoins.
async function adjustGold(db, { characterId, amount, currency = 'GOLD', reason, userId, include }) {
const column = COIN_COLUMN[currency];
// The atomic increment is the balance check: two spends racing each other
// can't both pass a stale read.
const updated = await db.character.update({ where: { id: characterId }, data: { gold: { increment: amount } } });
if (updated.gold < 0) throw new InsufficientGoldError();
const updated = await db.character.update({ where: { id: characterId }, data: { [column]: { increment: amount } } });
if (updated[column] < 0) throw new InsufficientGoldError();
return db.goldTransaction.create({
data: { characterId, amount, balanceAfter: updated.gold, reason: reason || null, createdById: userId },
data: { characterId, currency, amount, balanceAfter: updated[column], reason: reason || null, createdById: userId },
include,
});
}
// Works out which coins leave (and come back as change) when a purse pays
// `amount` coins of `currency`. Returns the change to each balance, e.g.
// { GOLD: -1, SILVER: 7, COPPER: 0 } for 3 silver paid from a purse with only
// gold in it — or null if the whole purse isn't worth that much.
// 1. Coins of the kind asked for go first.
// 2. Any shortfall is met by breaking the nearest bigger coin; the change
// comes back in as few coins as possible.
// 3. Only if there are no bigger coins left are smaller ones counted out.
function planPayment(purse, currency, amount) {
const worth = (coin) => COIN_VALUE[coin];
const delta = { GOLD: 0, SILVER: 0, COPPER: 0 };
const left = (coin) => purse[coin] + delta[coin];
let owed = amount * worth(currency);
if (CURRENCIES.reduce((sum, coin) => sum + purse[coin] * worth(coin), 0) < owed) return null;
const pay = (coin, count) => {
delta[coin] -= count;
owed -= count * worth(coin);
};
pay(currency, Math.min(purse[currency], amount));
const bigger = CURRENCIES.filter((coin) => worth(coin) > worth(currency)).reverse(); // nearest first
for (const coin of bigger) {
if (owed <= 0) break;
pay(coin, Math.min(left(coin), Math.ceil(owed / worth(coin))));
// Overpaid: hand the difference back, biggest coins first.
let change = -owed;
for (const small of CURRENCIES.filter((c) => worth(c) < worth(coin))) {
if (change <= 0) break;
const count = Math.floor(change / worth(small));
delta[small] += count;
change -= count * worth(small);
}
if (owed < 0) owed = 0;
}
for (const coin of CURRENCIES.filter((c) => worth(c) < worth(currency))) {
if (owed <= 0) break;
pay(coin, Math.min(left(coin), Math.floor(owed / worth(coin))));
}
return delta;
}
// Pays `amount` coins of `currency` out of a character's purse, making change
// as needed (see planPayment). Every coin that moved gets its own ledger row;
// the rows are returned, the one in `currency` first when there is one. Throws
// InsufficientGoldError if the purse as a whole can't cover it.
async function spendCoins(db, { characterId, amount, currency = 'GOLD', reason, userId, include }) {
const character = await db.character.findUnique({ where: { id: characterId }, select: { gold: true, silver: true, copper: true } });
const purse = { GOLD: character.gold, SILVER: character.silver, COPPER: character.copper };
const delta = planPayment(purse, currency, amount);
if (!delta) throw new InsufficientGoldError();
const moved = CURRENCIES.filter((coin) => delta[coin] !== 0).sort((a, b) => (b === currency) - (a === currency));
const madeChange = moved.some((coin) => coin !== currency);
const label = reason || 'Spent';
const rows = [];
for (const coin of moved) {
rows.push(
await adjustGold(db, {
characterId,
amount: delta[coin],
currency: coin,
// Says what the payment was when the coins that moved aren't the ones asked for.
reason: madeChange ? `${label} (paid ${amount} ${currency.toLowerCase()}, with change)` : reason,
userId,
include,
})
);
}
return rows;
}
// A purse's whole worth as one figure in gold, for messages: 265 copper-worth -> 2.65.
function worthInGold(character) {
const copper = CURRENCIES.reduce((sum, coin) => sum + character[COIN_COLUMN[coin]] * COIN_VALUE[coin], 0);
return copper / COIN_VALUE.GOLD;
}
const HELD = {
weapon: { model: 'characterWeapon', key: 'weaponId' },
item: { model: 'inventoryItem', key: 'itemId' },
@@ -37,4 +127,4 @@ async function giveGear(db, kind, { characterId, entry, quantity = 1, level, equ
});
}
module.exports = { InsufficientGoldError, adjustGold, giveGear };
module.exports = { CURRENCIES, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, planPayment, spendCoins, worthInGold, giveGear };
+27 -9
View File
@@ -509,17 +509,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
+5 -3
View File
@@ -48,7 +48,7 @@ time from the top of the page.
| For | A fight | A scene built around people and an objective |
| Layout | Everyone in one list, in initiative order | Three groups: "People you meet", "The party", "Creatures" |
| Rounds and turns | Yes | No |
| Initiative | The d20 roll, typed in per combatant; the bonus is added | Not shown |
| Initiative | The d20 roll, typed in per combatant; the bonus is added | The same; each group lists highest first |
| Start button | Begin Battle | Begin Quest |
| End button | End Battle | Complete Quest |
@@ -156,11 +156,13 @@ The DM types an amount and clicks Damage or Heal on a card.
## 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.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.3.0",
"version": "1.4.0",
"type": "module",
"scripts": {
"dev": "vite",
+15 -4
View File
@@ -7,6 +7,7 @@ import type {
CampaignMemberSummary,
CharacterDetail,
CharacterSummary,
Currency,
DiceMode,
Encounter,
EncounterMode,
@@ -172,7 +173,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 +212,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 +399,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
});
}
@@ -730,7 +741,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 };
+13 -3
View File
@@ -105,6 +105,9 @@ export interface CampaignMemberSummary {
role: 'DM' | 'PLAYER';
}
// The coins a character carries. 1 gold = 10 silver = 100 copper.
export type Currency = 'GOLD' | 'SILVER' | 'COPPER';
export interface CharacterSummary {
id: string;
campaignId: string;
@@ -118,7 +121,10 @@ export interface CharacterSummary {
buildStage: BuildStage;
currentHp: number;
tempHp: number;
gold: number | null; // null for another player's character
// 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 +239,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;
@@ -278,6 +285,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 +346,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;
+39 -6
View File
@@ -1,4 +1,5 @@
import { useId, useState } from 'react';
import { useEffect, useId, useRef, useState, type CSSProperties } from 'react';
import { createPortal } from 'react-dom';
// Short explanations for the stat labels sprinkled across the sheet, quick
// stats, party table, and builder — "what is this, and how is it worked
@@ -38,8 +39,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 +78,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 +86,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 -4
View File
@@ -631,10 +631,9 @@ input[type='radio'] {
color: var(--accent-strong);
outline: none;
}
/* Placed by InfoTip.tsx against the viewport, so no scrolling container can clip it. */
.info-tip-bubble {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
position: fixed;
transform: translateX(-50%);
width: max-content;
max-width: 260px;
@@ -648,7 +647,7 @@ input[type='radio'] {
letter-spacing: normal;
color: var(--text);
box-shadow: var(--shadow-deep);
z-index: 100;
z-index: 200;
pointer-events: none;
}
+25
View File
@@ -1,3 +1,5 @@
import type { Currency } from '../api/types';
export const SPECIES_LABELS: Record<string, string> = {
HUMAN: 'Human',
HALF_ELF: 'Half-Elf',
@@ -70,6 +72,29 @@ export function hpBarClass(current: number, max: number): string {
return 'hp-bar-fill';
}
// Coins. Silver is worth 0.1 gold and copper 0.01 gold.
export const CURRENCIES: Currency[] = ['GOLD', 'SILVER', 'COPPER'];
export const COIN_LABELS: Record<Currency, string> = { GOLD: 'Gold', SILVER: 'Silver', COPPER: 'Copper' };
export const COIN_SHORT: Record<Currency, string> = { GOLD: 'gp', SILVER: 'sp', COPPER: 'cp' };
export const COIN_FIELD: Record<Currency, 'gold' | 'silver' | 'copper'> = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper' };
// What one coin is worth, counted in copper.
export const COIN_VALUE: Record<Currency, number> = { GOLD: 100, SILVER: 10, COPPER: 1 };
type Purse = { gold: number | null; silver: number | null; copper: number | null };
// "12 gp · 5 sp · 3 cp". Gold always shows; silver and copper only when held.
export function formatPurse(purse: Purse): string {
return CURRENCIES.filter((c) => c === 'GOLD' || (purse[COIN_FIELD[c]] ?? 0) > 0)
.map((c) => `${purse[COIN_FIELD[c]] ?? 0} ${COIN_SHORT[c]}`)
.join(' · ');
}
// The whole purse as one figure in gold, e.g. 12.65.
export function purseWorthInGold(purse: Purse): number {
const copper = CURRENCIES.reduce((sum, c) => sum + (purse[COIN_FIELD[c]] ?? 0) * COIN_VALUE[c], 0);
return copper / COIN_VALUE.GOLD;
}
export function extractErrorMessage(err: unknown): string {
const anyErr = err as { response?: { data?: { error?: unknown } } };
const apiError = anyErr?.response?.data?.error;
+105 -15
View File
@@ -17,6 +17,7 @@ import {
useRemoveCharacterSpell,
useGoldLedger,
useAdjustGold,
useExchangeCoins,
useCampaignCharacters,
useGive,
type Gift,
@@ -35,11 +36,26 @@ import { HpBar } from '../components/HpBar';
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 +105,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 +167,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 +204,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 +254,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 +264,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('');
@@ -300,7 +331,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 +339,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;
@@ -770,19 +813,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 +853,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>
+40 -33
View File
@@ -27,11 +27,12 @@ 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 { 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,
@@ -94,7 +95,6 @@ function subline(p: EncounterParticipant): string {
function CombatantCard({
participant: p,
isDM,
mode,
isActive,
onPatch,
canRollInitiative,
@@ -102,7 +102,6 @@ function CombatantCard({
}: {
participant: EncounterParticipant;
isDM: boolean;
mode: EncounterMode;
isActive: boolean;
onPatch: (data: Partial<EncounterParticipant>) => void;
// The DM for anyone; a player for their own character.
@@ -162,8 +161,8 @@ function CombatantCard({
</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 +185,7 @@ function CombatantCard({
</label>
) : (
<StatChip label="Initiative">{p.initiativeRoll == null ? '—' : p.initiative}</StatChip>
))}
)}
</div>
<div className="stat-chips mt-sm">
@@ -381,7 +380,7 @@ function CombatantCard({
}
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 +406,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 +426,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 +449,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 +519,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
@@ -655,7 +664,6 @@ 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)}
@@ -663,7 +671,8 @@ export function EncounterPage() {
/>
);
// 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]]
: [
@@ -839,18 +848,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>
+3 -3
View File
@@ -5,7 +5,7 @@ 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 { speciesIcon } from '../lib/speciesIcons';
@@ -56,7 +56,7 @@ export function PartyOverviewPage() {
<th>
AC <InfoTip term="AC" />
</th>
<th>Gold</th>
<th>Coins</th>
<th>Location</th>
</tr>
</thead>
@@ -89,7 +89,7 @@ export function PartyOverviewPage() {
)}
</td>
<td>{c.armorClass ?? '—'}</td>
<td className="mono">{c.gold ?? '—'}</td>
<td className="mono">{c.gold == null ? '—' : formatPurse(c)}</td>
<td>{c.currentLocationName ? `📍 ${c.currentLocationName}` : '—'}</td>
</tr>
);
+4 -4
View File
@@ -1,19 +1,19 @@
{
"name": "dnd-campaign-manager",
"version": "1.3.0",
"version": "1.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dnd-campaign-manager",
"version": "1.3.0",
"version": "1.4.0",
"workspaces": [
"backend",
"frontend"
]
},
"backend": {
"version": "1.3.0",
"version": "1.4.0",
"dependencies": {
"@prisma/client": "^5.20.0",
"bcrypt": "^5.1.1",
@@ -31,7 +31,7 @@
}
},
"frontend": {
"version": "1.3.0",
"version": "1.4.0",
"dependencies": {
"@tanstack/react-query": "^5.103.2",
"axios": "^1.20.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dnd-campaign-manager",
"version": "1.3.0",
"version": "1.4.0",
"private": true,
"description": "D&D 5e campaign manager — monorepo root",
"workspaces": [