diff --git a/backend/package.json b/backend/package.json index 84941c9..5c82dca 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "backend", - "version": "1.3.1", + "version": "1.4.0", "private": true, "type": "commonjs", "main": "src/index.js", diff --git a/backend/prisma/migrations/20261007090000_silver_and_copper/migration.sql b/backend/prisma/migrations/20261007090000_silver_and_copper/migration.sql new file mode 100644 index 0000000..0b21999 --- /dev/null +++ b/backend/prisma/migrations/20261007090000_silver_and_copper/migration.sql @@ -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'; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index f0aea89..028252e 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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) diff --git a/backend/src/routes/api.integration.test.js b/backend/src/routes/api.integration.test.js index 1926630..1fdfe47 100644 --- a/backend/src/routes/api.integration.test.js +++ b/backend/src/routes/api.integration.test.js @@ -1972,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]); + }); +}); diff --git a/backend/src/routes/campaigns.routes.js b/backend/src/routes/campaigns.routes.js index 0c27d0c..bdaf1cd 100644 --- a/backend/src/routes/campaigns.routes.js +++ b/backend/src/routes/campaigns.routes.js @@ -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) { diff --git a/backend/src/routes/characters.routes.js b/backend/src/routes/characters.routes.js index 550d1df..68a8bda 100644 --- a/backend/src/routes/characters.routes.js +++ b/backend/src/routes/characters.routes.js @@ -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; diff --git a/backend/src/routes/encounters.routes.js b/backend/src/routes/encounters.routes.js index df554ef..b1d43b6 100644 --- a/backend/src/routes/encounters.routes.js +++ b/backend/src/routes/encounters.routes.js @@ -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'; diff --git a/backend/src/utils/grants.js b/backend/src/utils/grants.js index cf00f0d..23073e7 100644 --- a/backend/src/utils/grants.js +++ b/backend/src/utils/grants.js @@ -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 }; diff --git a/docs/CHARACTERS.md b/docs/CHARACTERS.md index 72431f9..89a99ce 100644 --- a/docs/CHARACTERS.md +++ b/docs/CHARACTERS.md @@ -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 diff --git a/frontend/package.json b/frontend/package.json index 9977f7b..25a7172 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.3.1", + "version": "1.4.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/queries.ts b/frontend/src/api/queries.ts index 539fb56..b9eec2a 100644 --- a/frontend/src/api/queries.ts +++ b/frontend/src/api/queries.ts @@ -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 }; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index bd0cc69..6af7f01 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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; diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 10fc70e..9c46f80 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -1,3 +1,5 @@ +import type { Currency } from '../api/types'; + export const SPECIES_LABELS: Record = { 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 = { GOLD: 'Gold', SILVER: 'Silver', COPPER: 'Copper' }; +export const COIN_SHORT: Record = { GOLD: 'gp', SILVER: 'sp', COPPER: 'cp' }; +export const COIN_FIELD: Record = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper' }; +// What one coin is worth, counted in copper. +export const COIN_VALUE: Record = { 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; diff --git a/frontend/src/pages/CharacterSheetPage.tsx b/frontend/src/pages/CharacterSheetPage.tsx index 2a7a25b..fae8ef3 100644 --- a/frontend/src/pages/CharacterSheetPage.tsx +++ b/frontend/src/pages/CharacterSheetPage.tsx @@ -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 }} > - {gold > 0 && } + {coinsHeld.map((c) => ( + + ))} {character.weapons.length > 0 && ( {character.weapons.map((w) => ( @@ -178,8 +204,8 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError: 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('GOLD'); + const [exchangeAmount, setExchangeAmount] = useState(''); + const [exchangeFrom, setExchangeFrom] = useState('GOLD'); + const [exchangeTo, setExchangeTo] = useState('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() {
Money - {character.gold} gp + {formatPurse(character)}
+

+ 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. +

{canEdit && (
setGoldAmount(e.target.value)} style={{ width: 100 }} /> +
)} + {canEdit && ( +
+ Exchange + setExchangeAmount(e.target.value)} + style={{ width: 100 }} + /> + + into + + +
+ )}
{goldLedger?.length === 0 &&

No transactions yet.

} {ledgerPage.pageItems.map((t) => (
- {t.amount > 0 ? `+${t.amount}` : `−${Math.abs(t.amount)}`} + + {t.amount > 0 ? `+${t.amount}` : `−${Math.abs(t.amount)}`} {COIN_SHORT[t.currency ?? 'GOLD']} + {t.reason || (t.amount > 0 ? 'Gained' : 'Spent')}
{t.createdBy ? `${t.createdBy.displayName} · ` : ''} {new Date(t.createdAt).toLocaleString()}
- {t.balanceAfter} gp + + {t.balanceAfter} {COIN_SHORT[t.currency ?? 'GOLD']} +
))}
diff --git a/frontend/src/pages/EncounterPage.tsx b/frontend/src/pages/EncounterPage.tsx index 2376ea4..f43e2ac 100644 --- a/frontend/src/pages/EncounterPage.tsx +++ b/frontend/src/pages/EncounterPage.tsx @@ -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, @@ -379,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}`; } @@ -405,6 +406,7 @@ function RewardsPanel({ const [type, setType] = useState('GOLD'); const [gold, setGold] = useState(''); + const [currency, setCurrency] = useState('GOLD'); const [poolId, setPoolId] = useState(''); const [quantity, setQuantity] = useState('1'); const [level, setLevel] = useState(''); @@ -424,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 }; @@ -447,7 +449,7 @@ function RewardsPanel({
Rewards
{isDM && rewards.length === 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.

)} {rewards.map((r) => ( @@ -517,20 +519,29 @@ function RewardsPanel({ setLevel(''); }} > - + {type === 'GOLD' ? ( - setGold(e.target.value)} - style={{ flex: 1, minWidth: 90 }} - /> + <> + setGold(e.target.value)} + style={{ flex: 1, minWidth: 90 }} + /> + + ) : ( <>