Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4362b7423e | ||
|
|
98a89185c1 |
@@ -80,8 +80,12 @@ cd backend
|
||||
npm run seed:hollowcrown
|
||||
```
|
||||
|
||||
On a server, apply new migrations with `npx prisma migrate deploy`. Change
|
||||
your password from the account page (click your name in the top bar).
|
||||
On a server, after pulling a new version, run `npm run deploy` in `backend`
|
||||
and then restart the backend. It applies new migrations **and** regenerates
|
||||
the Prisma client; without the second half the server keeps running with the
|
||||
old data model and pages that use anything new fail to load (for example
|
||||
"Could not load this encounter"). Change your password from the account page
|
||||
(click your name in the top bar).
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.3.0",
|
||||
"version": "1.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';
|
||||
@@ -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)
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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
@@ -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
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Generated
+4
-4
@@ -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
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user