V1.9.0: full monster stat blocks, and cash ($)

- "+ Add Monster" takes the whole stat block: challenge rating, speed, the
  six ability scores, special trait and notes, as well as name, AC, HP,
  attack and damage. Damage can name its type ("1d6+2 necrotic").
- Monsters can be edited: "Edit" on a row opens the same form filled in.
  Copies already in encounters show the new stat block.
- The monster list shows each full stat block, the library picker shows
  speed and ability scores too, and removing a monster asks first.
- Characters have a fourth balance, cash ($), for crossover worlds. It is
  gained, spent, given and awarded like a coin, but it has no worth in
  coins: it is never exchanged or used to make change.
- `npm test` in the backend runs again (the script pointed at a jest path
  that does not exist in the workspace).
- All packages at version 1.9.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 13:15:55 +02:00
co-authored by Claude Opus 5.5
parent f764878960
commit bdce3399ee
19 changed files with 425 additions and 123 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.8.1",
"version": "1.9.0",
"private": true,
"type": "commonjs",
"main": "src/index.js",
@@ -10,7 +10,7 @@
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js",
"test": "node --experimental-vm-modules node_modules/.bin/jest",
"test": "jest",
"docs:lists": "node scripts/generate-doc-lists.js",
"prisma:migrate": "prisma migrate dev",
"prisma:generate": "prisma generate",
@@ -0,0 +1,6 @@
-- AlterEnum: cash ($), a modern currency for crossover worlds. It has no fixed
-- worth in gold, so it is never exchanged or used to make change.
ALTER TYPE "Currency" ADD VALUE 'CASH';
-- AlterTable: cash is counted on its own, like each coin
ALTER TABLE "characters" ADD COLUMN "cash" INTEGER NOT NULL DEFAULT 0;
+6 -3
View File
@@ -118,12 +118,14 @@ enum RewardType {
ITEM
}
// The coins a character carries. Their worth is fixed in utils/grants.js:
// 1 gold = 10 silver = 100 copper.
// The money a character carries. The coins' worth is fixed in utils/grants.js:
// 1 gold = 10 silver = 100 copper. Cash ($) is modern money for crossover
// worlds; it has no worth in coins and is never exchanged for them.
enum Currency {
GOLD
SILVER
COPPER
CASH
}
enum NoteVisibility {
@@ -278,11 +280,12 @@ 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
// The purse: each coin (and cash) 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)
cash Int @default(0) // whole dollars
background String?
appearance String?
@@ -674,6 +674,39 @@ describe('money tracker', () => {
[15, 15, 'Starting gold', 'Player'],
]);
});
itIfDb('keeps cash apart from the coins', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('cash');
const create = (name) => request(app).post(`/campaigns/${campaign.id}/characters`).set(playerAuth).send({ name });
const characterId = (await create('Drifter')).body.character.id;
const money = (body) => request(app).post(`/characters/${characterId}/gold`).set(playerAuth).send(body);
await money({ amount: 5 });
const paid = await money({ amount: 40, currency: 'CASH', reason: 'Odd job' });
expect(paid.status).toBe(201);
expect(paid.body).toMatchObject({ gold: 5, cash: 40 });
expect(paid.body.transaction).toMatchObject({ currency: 'CASH', amount: 40, balanceAfter: 40 });
// Cash is paid in cash alone: gold doesn't cover it, and it doesn't cover gold.
const overspend = await money({ amount: -41, currency: 'CASH' });
expect(overspend.status).toBe(400);
expect(overspend.body.error).toContain('$40');
expect((await money({ amount: -6 })).status).toBe(400);
const spent = await money({ amount: -15, currency: 'CASH', reason: 'Diner' });
expect(spent.body).toMatchObject({ gold: 5, silver: 0, copper: 0, cash: 25 });
expect(spent.body.transactions).toHaveLength(1);
// It has no worth in coins, so it can't be exchanged, and it only moves through the ledger.
const exchange = (body) => request(app).post(`/characters/${characterId}/gold/exchange`).set(playerAuth).send(body);
expect((await exchange({ from: 'CASH', to: 'GOLD', amount: 10 })).status).toBe(400);
expect((await exchange({ from: 'GOLD', to: 'CASH', amount: 1 })).status).toBe(400);
expect((await request(app).patch(`/characters/${characterId}`).set(dmAuth).send({ cash: 999 })).status).toBe(400);
const sheet = (await request(app).get(`/characters/${characterId}`).set(playerAuth)).body.character;
expect(sheet.cash).toBe(25);
const listed = (await request(app).get(`/campaigns/${campaign.id}/characters`).set(dmAuth)).body.characters;
expect(listed.find((c) => c.id === characterId).cash).toBe(25);
});
});
describe('starting options', () => {
@@ -1418,6 +1451,35 @@ describe('default content library', () => {
expect((await request(app).get(`/campaigns/${campaign.id}/monsters`).set(dmAuth)).body.monsters).toHaveLength(1);
expect((await request(app).get(`/campaigns/${campaign.id}/monsters`).set(playerAuth)).status).toBe(403);
// Every library monster carries the whole stat block.
for (const m of listRes.body.monsters) {
expect(m).toMatchObject({
challengeRating: expect.any(String),
ac: expect.any(Number),
maxHp: expect.any(Number),
speed: expect.any(Number),
attackBonus: expect.any(Number),
damageDice: expect.any(String),
notes: expect.any(String),
});
for (const a of ['str', 'dex', 'con', 'int', 'wis', 'cha']) expect(m[`${a}Score`]).toEqual(expect.any(Number));
}
// A hand-made monster takes the whole stat block, and every field can be changed afterwards.
const block = {
name: 'Gloamling', challengeRating: '1/2', ac: 13, maxHp: 18, speed: 40, attackBonus: 4, damageDice: '1d6+2 necrotic',
strScore: 8, dexScore: 16, conScore: 12, intScore: 6, wisScore: 11, chaScore: 5,
specialTrait: 'Shadow Step', notes: 'Fly 30. Resists necrotic.',
};
const made = await request(app).post(`/campaigns/${campaign.id}/monsters`).set(dmAuth).send(block);
expect(made.status).toBe(201);
expect(made.body.monster).toMatchObject(block);
const patchMonster = (auth, body) => request(app).patch(`/campaigns/${campaign.id}/monsters/${made.body.monster.id}`).set(auth).send(body);
const edited = await patchMonster(dmAuth, { speed: 20, wisScore: 14, challengeRating: '1', specialTrait: null });
expect(edited.status).toBe(200);
expect(edited.body.monster).toMatchObject({ ...block, speed: 20, wisScore: 14, challengeRating: '1', specialTrait: null });
expect((await patchMonster(playerAuth, { ac: 1 })).status).toBe(403);
const playerAddRes = await request(app)
.post(`/campaigns/${campaign.id}/monsters/from-library`)
.set(playerAuth)
+3 -2
View File
@@ -201,6 +201,7 @@ function characterSummary(character) {
gold: character.gold,
silver: character.silver,
copper: character.copper,
cash: character.cash,
maxHp: character.buildStage === 'COMPLETE' ? sheet.hp.max : null,
armorClass: character.buildStage === 'COMPLETE' ? sheet.armorClass : null,
currentLocationId: character.currentLocationId,
@@ -225,7 +226,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, silver: null, copper: null, ...(knowsPlace ? {} : { currentLocationId: null, currentLocationName: null }) };
return { ...c, gold: null, silver: null, copper: null, cash: null, ...(knowsPlace ? {} : { currentLocationId: null, currentLocationName: null }) };
}),
});
} catch (err) {
@@ -525,7 +526,7 @@ const monsterSchema = z.object({
wisScore: z.number().int().min(1).max(30).optional(),
chaScore: z.number().int().min(1).max(30).optional(),
attackBonus: z.number().int().min(-20).max(50).optional(),
damageDice: z.string().max(20).optional().nullable(),
damageDice: z.string().max(60).optional().nullable(), // may name its type: "1d6+2 necrotic"
challengeRating: z.string().max(10).optional().nullable(),
specialTrait: z.string().max(500).optional().nullable(),
notes: z.string().max(2000).optional().nullable(),
+15 -8
View File
@@ -29,7 +29,7 @@ const {
} = require('../game/buildFlow');
const characterOptions = require('../game/data/characterOptions');
const { SKILLS } = require('../game/data/skills');
const { CURRENCIES, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, spendCoins, worthInGold, giveGear } = require('../utils/grants');
const { CURRENCIES, COINS, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, spendCoins, worthInGold, giveGear } = require('../utils/grants');
const { recordVisits } = require('../utils/locations');
const { absorbDamage, NO_DEATH_SAVES, lifeState, deathSaveInfo, settleDeathSaves, changeHp, rollDeathSave } = require('../utils/hp');
@@ -97,6 +97,7 @@ function serializeCharacter(character, isOwner, isDM = true) {
gold: restricted ? null : character.gold,
silver: restricted ? null : character.silver,
copper: restricted ? null : character.copper,
cash: restricted ? null : character.cash,
restricted,
buildStage: character.buildStage,
buildState: character.buildStage === 'COMPLETE' ? null : character.buildState,
@@ -173,8 +174,8 @@ function clamp(value, min, max) {
router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
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.' });
if (req.body && Object.values(COIN_COLUMN).some((coin) => req.body[coin] !== undefined)) {
return res.status(400).json({ error: 'Gold, silver, copper and cash changes go through the money tracker.' });
}
const parsed = patchCharacterSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
@@ -425,6 +426,7 @@ router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
gold: source.gold,
silver: source.silver,
copper: source.copper,
cash: source.cash,
background: source.background,
appearance: source.appearance,
// Same privacy boundary as serializeCharacter: a DM copying a
@@ -938,8 +940,11 @@ function coinName(currency) {
return currency.toLowerCase();
}
// For a payment the whole purse can't cover.
const PURSE_SELECT = { gold: true, silver: true, copper: true, cash: true };
// For a payment the whole purse can't cover. Cash stands apart from the coins.
function notEnoughMoney(character, amount, currency) {
if (currency === 'CASH') return `Not enough cash — ${character.name} has $${character.cash}, which doesn't cover $${amount}.`;
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)}.`;
}
@@ -951,7 +956,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, silver: req.character.silver, copper: req.character.copper, transactions });
res.json({ gold: req.character.gold, silver: req.character.silver, copper: req.character.copper, cash: req.character.cash, transactions });
} catch (err) {
next(err);
}
@@ -961,6 +966,7 @@ router.get('/:id/gold', requireCharacterAccess(), async (req, res, next) => {
// 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.
// Cash is only ever paid in cash.
router.post('/:id/gold', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
const parsed = z
@@ -986,7 +992,7 @@ router.post('/:id/gold', requireCharacterAccess({ write: true }), async (req, re
}
emitCharacterUpdated(req);
const purse = await prisma.character.findUnique({ where: { id: req.character.id }, select: { gold: true, silver: true, copper: true } });
const purse = await prisma.character.findUnique({ where: { id: req.character.id }, select: PURSE_SELECT });
res.status(201).json({ ...purse, transaction: transactions[0], transactions });
} catch (err) {
next(err);
@@ -996,11 +1002,12 @@ router.post('/:id/gold', requireCharacterAccess({ write: true }), async (req, re
// 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.
// a spend, this moves exactly the coins named: no change is made. Cash has no
// worth in coins, so it can't be exchanged.
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) })
.object({ from: z.enum(COINS), to: z.enum(COINS), 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() });
+18 -12
View File
@@ -6,10 +6,14 @@
// 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 COINS = ['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' };
// Everything a purse can hold: the coins, plus cash ($) — modern money for
// crossover worlds. Cash has no worth in coins, so it is never exchanged for
// them and never used to make change.
const CURRENCIES = [...COINS, 'CASH'];
// Each currency is its own column on the character.
const COIN_COLUMN = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper', CASH: 'cash' };
class InsufficientGoldError extends Error {}
@@ -43,7 +47,7 @@ function planPayment(purse, currency, amount) {
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;
if (COINS.reduce((sum, coin) => sum + purse[coin] * worth(coin), 0) < owed) return null;
const pay = (coin, count) => {
delta[coin] -= count;
@@ -51,13 +55,13 @@ function planPayment(purse, currency, amount) {
};
pay(currency, Math.min(purse[currency], amount));
const bigger = CURRENCIES.filter((coin) => worth(coin) > worth(currency)).reverse(); // nearest first
const bigger = COINS.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))) {
for (const small of COINS.filter((c) => worth(c) < worth(coin))) {
if (change <= 0) break;
const count = Math.floor(change / worth(small));
delta[small] += count;
@@ -66,7 +70,7 @@ function planPayment(purse, currency, amount) {
if (owed < 0) owed = 0;
}
for (const coin of CURRENCIES.filter((c) => worth(c) < worth(currency))) {
for (const coin of COINS.filter((c) => worth(c) < worth(currency))) {
if (owed <= 0) break;
pay(coin, Math.min(left(coin), Math.floor(owed / worth(coin))));
}
@@ -76,14 +80,16 @@ function planPayment(purse, currency, amount) {
// 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.
// InsufficientGoldError if the purse as a whole can't cover it. Cash is paid in
// cash alone: coins don't cover it, and it doesn't cover coins.
async function spendCoins(db, { characterId, amount, currency = 'GOLD', reason, userId, include }) {
if (!COINS.includes(currency)) return [await adjustGold(db, { characterId, amount: -amount, currency, 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 moved = COINS.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 = [];
@@ -103,9 +109,9 @@ async function spendCoins(db, { characterId, amount, currency = 'GOLD', reason,
return rows;
}
// A purse's whole worth as one figure in gold, for messages: 265 copper-worth -> 2.65.
// A purse's coins 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);
const copper = COINS.reduce((sum, coin) => sum + character[COIN_COLUMN[coin]] * COIN_VALUE[coin], 0);
return copper / COIN_VALUE.GOLD;
}
@@ -127,4 +133,4 @@ async function giveGear(db, kind, { characterId, entry, quantity = 1, level, equ
});
}
module.exports = { CURRENCIES, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, planPayment, spendCoins, worthInGold, giveGear };
module.exports = { CURRENCIES, COINS, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, planPayment, spendCoins, worthInGold, giveGear };
+7 -2
View File
@@ -581,7 +581,12 @@ behind it.
- The log is the only way a balance changes. Entries cannot be edited or
deleted; to correct a mistake, record the opposite entry.
- Starting gold (a campaign setting) is gold only. Encounter rewards and gifts
between characters can be in any coin.
between characters can be in any coin, or in cash.
- **Cash ($)** is a fourth balance for crossover worlds, counted in whole
dollars and shown as `$40`. It is gained, spent, given and awarded like a
coin and has its own log entries, but it has no worth in coins: it cannot
be exchanged, a cash spend is paid from cash alone, and cash is never
broken to cover a coin spend. It shows in the purse only when held.
### Giving to another character
@@ -779,7 +784,7 @@ calculations for them.
- Automatic levelling from XP
- Alignment, languages, tool proficiencies and backgrounds-as-rules
- Encumbrance and item weight
- Currencies other than gold, and item prices
- Item prices, cents, and an exchange rate between cash and coins
## 20. Everything the builder offers
+3 -3
View File
@@ -330,7 +330,7 @@ The DM lines up loot in the Rewards panel, on any encounter at any time.
| Reward | What the DM sets |
|---|---|
| Gold | An amount |
| Coins or cash | An amount, and which: gold, silver, copper or cash ($) |
| Weapon | A weapon from the pool, a level (not below its starting level), a quantity |
| Item | An item from the pool, a level, a quantity |
@@ -341,9 +341,9 @@ Each can carry a short note ("Goblin chief's purse").
| Button | What happens |
|---|---|
| Award (pick a character) | The whole reward goes to that character. It can be any finished character in the campaign, not only those in the encounter. |
| Split (gold only) | Shared evenly between the characters in the encounter. Any remainder goes one gold each, highest initiative first: 101 gold between two is 51 and 50. |
| Split (coins and cash only) | Shared evenly between the characters in the encounter. Any remainder goes one each, highest initiative first: 101 gold between two is 51 and 50. |
- **Gold** is added to the character's balance with a money-log entry reading
- **Coins or cash** are added to the character's balance with a money-log entry reading
"Reward: <encounter name>".
- A **weapon or item** is added to what the character holds, at the reward's
level, stacking with any they already have. If the character is below that
+18 -13
View File
@@ -36,10 +36,10 @@ This describes the code as it stands. The built-in list is
| Name | Up to 80 characters |
| AC | Armor Class |
| Max HP | Hit points each copy starts an encounter with |
| Speed | In feet. 30 unless set otherwise |
| Speed | Walking speed in feet. 30 unless set otherwise. Other speeds (fly, swim, climb) go in Notes |
| Ability scores | STR, DEX, CON, INT, WIS, CHA. 10 unless set otherwise |
| Attack bonus | Added to the monster's attack roll |
| Damage | Dice for its main attack, e.g. `1d6+2` |
| Damage | Dice for its main attack, e.g. `1d6+2`, with the damage type if you like: `1d6+2 necrotic` |
| Challenge rating | A rough danger rating, e.g. `1/4`, `2` |
| Special trait | A short note such as "Pack Tactics" |
| Notes | Anything else: what it attacks with, how it behaves |
@@ -52,21 +52,25 @@ from its scores for display only.
The list is the "Monsters" card on the campaign page (DM only).
- **Adding by hand:** "+ Add Monster" asks for name, AC, max HP, attack bonus
and damage. Speed is set to 30 and every ability score to 10; challenge
rating, trait and notes are left empty.
- **Adding by hand:** "+ Add Monster" opens the whole stat block: name,
challenge rating, AC, HP, speed, attack bonus, damage, the six ability
scores, special trait and notes. Only name, AC and HP must be typed; speed
starts at 30, attack bonus at 0 and each ability score at 10.
- **Adding from the bestiary:** "📚 From Library" copies a complete stat block.
- **Changing:** there is no edit form (the API accepts changes, but no screen
offers them). Remove and re-add, or add from the library.
- **Removing:** the ✕ button, immediately, with no confirmation. Copies
- **Changing:** "Edit" on a monster's row opens the same form filled in.
Saving changes the template, and copies already in encounters show the new
stat block straight away; their current HP is left alone.
- The list shows each monster's full stat block: the numbers in columns, and
its ability scores, trait and notes under its name.
- **Removing:** the ✕ button, after a confirmation. Copies
already in encounters stay there with their name and HP, but lose their
stat block.
## 4. The built-in bestiary
38 monsters, from harmless to dangerous. Each library row shows challenge
rating, AC, HP, attack and damage, plus its trait and notes, before you add
it. Adding copies the monster into your campaign; the copy is yours, and later
38 monsters, from harmless to dangerous, every one with a complete stat
block. Each library row shows challenge rating, AC, HP, speed, attack, damage
and the six ability scores, plus its trait and notes, before you add it. Adding copies the monster into your campaign; the copy is yours, and later
changes to the library do not affect it.
The full bestiary, weakest first. "Attack" is the bonus added to the
@@ -146,8 +150,9 @@ and its notes.
This is enforced by the server: stat blocks are never sent to a player's
browser.
Players do see a monster's exact HP numbers. If you would rather keep those
secret, that is not currently possible.
Players see a monster's exact HP numbers unless the campaign setting "Players
see the exact hit points of monsters and NPCs in encounters" is unticked.
Then they get the health bar only.
## 7. Permissions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.8.1",
"version": "1.9.0",
"type": "module",
"scripts": {
"dev": "vite",
+14
View File
@@ -537,6 +537,20 @@ export function useCreateMonster(campaignId: string) {
});
}
// Changes a monster's stat block. Encounters read it live, so copies already
// in a scene show the new numbers (their HP stays as it was).
export function useUpdateMonster(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, ...data }: { id: string } & Partial<Omit<Monster, 'id' | 'campaignId'>>) =>
(await apiClient.patch<{ monster: Monster }>(`/campaigns/${campaignId}/monsters/${id}`, data)).data.monster,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'monsters'] });
qc.invalidateQueries({ queryKey: ['encounter'] });
},
});
}
export function useDeleteMonster(campaignId: string) {
const qc = useQueryClient();
return useMutation({
+6 -2
View File
@@ -108,7 +108,9 @@ export interface CampaignMemberSummary {
}
// The coins a character carries. 1 gold = 10 silver = 100 copper.
export type Currency = 'GOLD' | 'SILVER' | 'COPPER';
export type Coin = 'GOLD' | 'SILVER' | 'COPPER';
// Coins, plus cash ($): modern money for crossover worlds, with no worth in coins.
export type Currency = Coin | 'CASH';
// Where a character stands at 0 HP. UP: on their feet. DYING: rolling death
// saves, three of either kind settles it. STABLE: at 0 HP but no longer
@@ -135,10 +137,11 @@ export interface CharacterSummary {
currentHp: number;
tempHp: number;
deathSaves: DeathSaves;
// The purse; all three are null for another player's character.
// The purse; all four are null for another player's character.
gold: number | null;
silver: number | null;
copper: number | null;
cash: number | null;
maxHp: number | null;
armorClass: number | null;
currentLocationId: string | null;
@@ -303,6 +306,7 @@ export interface CharacterDetail {
gold: number | null;
silver: number | null;
copper: number | null;
cash: 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;
+20 -11
View File
@@ -1,4 +1,4 @@
import type { Currency } from '../api/types';
import type { Coin, Currency } from '../api/types';
export const SPECIES_LABELS: Record<string, string> = {
HUMAN: 'Human',
@@ -73,25 +73,34 @@ export function hpBarClass(current: number, max: number): string {
}
// 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' };
export const COINS: Coin[] = ['GOLD', 'SILVER', 'COPPER'];
// Everything a purse can hold: the coins, plus cash ($) for crossover worlds.
// Cash has no worth in coins, so it is never exchanged or used to make change.
export const CURRENCIES: Currency[] = [...COINS, 'CASH'];
export const COIN_LABELS: Record<Currency, string> = { GOLD: 'Gold', SILVER: 'Silver', COPPER: 'Copper', CASH: 'Cash ($)' };
export const COIN_FIELD: Record<Currency, 'gold' | 'silver' | 'copper' | 'cash'> = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper', CASH: 'cash' };
// What one coin is worth, counted in copper.
export const COIN_VALUE: Record<Currency, number> = { GOLD: 100, SILVER: 10, COPPER: 1 };
export const COIN_VALUE: Record<Coin, number> = { GOLD: 100, SILVER: 10, COPPER: 1 };
const COIN_SHORT: Record<Coin, string> = { GOLD: 'gp', SILVER: 'sp', COPPER: 'cp' };
type Purse = { gold: number | null; silver: number | null; copper: number | null };
// "12 gp", "5 sp", "$40". With `long`, coins are spelled out: "12 gold".
export function formatMoney(amount: number, currency: Currency, long = false): string {
if (currency === 'CASH') return `$${amount}`;
return `${amount} ${long ? COIN_LABELS[currency].toLowerCase() : COIN_SHORT[currency]}`;
}
// "12 gp · 5 sp · 3 cp". Gold always shows; silver and copper only when held.
type Purse = { gold: number | null; silver: number | null; copper: number | null; cash: number | null };
// "12 gp · 5 sp · 3 cp · $40". Gold always shows; the rest 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]}`)
.map((c) => formatMoney(purse[COIN_FIELD[c]] ?? 0, c))
.join(' · ');
}
// The whole purse as one figure in gold, e.g. 12.65.
// The purse's coins as one figure in gold, e.g. 12.65. Cash is not counted.
export function purseWorthInGold(purse: Purse): number {
const copper = CURRENCIES.reduce((sum, c) => sum + (purse[COIN_FIELD[c]] ?? 0) * COIN_VALUE[c], 0);
const copper = COINS.reduce((sum, c) => sum + (purse[COIN_FIELD[c]] ?? 0) * COIN_VALUE[c], 0);
return copper / COIN_VALUE.GOLD;
}
+219 -42
View File
@@ -1,4 +1,4 @@
import { useState, type FormEvent } from 'react';
import { useState, type CSSProperties, type FormEvent, type ReactNode } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import {
useCampaign,
@@ -12,6 +12,7 @@ import {
useDeleteEncounter,
useCampaignMonsters,
useCreateMonster,
useUpdateMonster,
useDeleteMonster,
useCampaignWeapons,
useCreateWeapon,
@@ -43,10 +44,10 @@ import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput';
import { LifeBadge } from '../components/DeathSaves';
import { usePagination, Pagination } from '../components/Paginated';
import { SPELL_DAMAGE_GROUPS, SPELL_DAMAGE_SPECIALS, extractErrorMessage, spellLevelLabel, spellRequirementLabel } from '../lib/format';
import { SPELL_DAMAGE_GROUPS, SPELL_DAMAGE_SPECIALS, extractErrorMessage, fmtMod, spellLevelLabel, spellRequirementLabel } from '../lib/format';
import { speciesIcon } from '../lib/speciesIcons';
import { DAMAGE_SCALING_LABELS, MAX_GEAR_LEVEL } from '../lib/previewCalc';
import type { CharacterSummary, Encounter, EncounterMode, LibraryMonster, LibrarySpell, LibraryWeapon, NpcDisposition } from '../api/types';
import { ABILITY_ORDER, DAMAGE_SCALING_LABELS, MAX_GEAR_LEVEL } from '../lib/previewCalc';
import type { CharacterSummary, Encounter, EncounterMode, LibraryMonster, LibrarySpell, LibraryWeapon, Monster, NpcDisposition } from '../api/types';
// How the encounter list can be ordered. "custom" is the campaign's own order,
// which the DM arranges; the rest only change what this browser shows.
@@ -236,6 +237,142 @@ function SpellDamageSelect({
);
}
type MonsterInput = Omit<Monster, 'id' | 'campaignId'>;
type AbilityScores = Pick<Monster, 'strScore' | 'dexScore' | 'conScore' | 'intScore' | 'wisScore' | 'chaScore'>;
const scoreField = (ability: string) => `${ability.toLowerCase()}Score` as keyof AbilityScores;
// "STR 8 · DEX 14 · …", in stat-block order.
function abilityLine(scores: AbilityScores): string {
return ABILITY_ORDER.map((a) => `${a} ${scores[scoreField(a)]}`).join(' · ');
}
// The whole stat block, field for field as a printed one gives it. Adds a
// monster, or edits one when `monster` is passed (its values fill the form).
function MonsterForm({
monster,
saving,
onSave,
onCancel,
}: {
monster?: Monster;
saving: boolean;
// Rejects if the save failed, so the form keeps what was typed.
onSave: (data: MonsterInput) => Promise<void>;
onCancel?: () => void;
}) {
const field = (name: string, label: string, input: ReactNode, style?: CSSProperties) => (
<div className="field" style={{ marginBottom: 0, ...style }}>
<label htmlFor={`monster-${name}`}>{label}</label>
{input}
</div>
);
const number = (name: keyof MonsterInput, label: string, min: number, max: number, fallback?: number) =>
field(
name,
label,
<NumberInput
id={`monster-${name}`}
name={name}
min={min}
max={max}
defaultValue={(monster?.[name] as number | undefined) ?? fallback}
keepValue={fallback !== undefined}
required
/>
);
return (
<form
onSubmit={async (e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const fd = new FormData(form);
const text = (key: string) => String(fd.get(key) || '').trim() || null;
const num = (key: string) => Number(fd.get(key));
try {
await onSave({
name: String(fd.get('name')).trim(),
challengeRating: text('challengeRating'),
ac: num('ac'),
maxHp: num('maxHp'),
speed: num('speed'),
attackBonus: num('attackBonus'),
damageDice: text('damageDice'),
strScore: num('strScore'),
dexScore: num('dexScore'),
conScore: num('conScore'),
intScore: num('intScore'),
wisScore: num('wisScore'),
chaScore: num('chaScore'),
specialTrait: text('specialTrait'),
notes: text('notes'),
});
if (!monster) form.reset();
} catch {
// The page shows the error; what was typed stays.
}
}}
>
<div className="grid grid-cols-3">
{field('name', 'Name', <input id="monster-name" name="name" defaultValue={monster?.name} required maxLength={80} />)}
{field(
'challengeRating',
'CR (Challenge Rating)',
<input id="monster-challengeRating" name="challengeRating" defaultValue={monster?.challengeRating ?? ''} placeholder="e.g. 1/4 or 3" maxLength={10} />
)}
{number('ac', 'AC (Armor Class)', 0, 50)}
{number('maxHp', 'HP (Hit Points)', 1, 100000)}
{number('speed', 'Speed (feet)', 0, 1000, 30)}
{number('attackBonus', 'Attack bonus', -20, 50, 0)}
{field(
'damageDice',
'Damage',
<input id="monster-damageDice" name="damageDice" defaultValue={monster?.damageDice ?? ''} placeholder="e.g. 1d6+2 necrotic" maxLength={60} />
)}
</div>
<div className="grid mt-sm" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))' }}>
{ABILITY_ORDER.map((a) => number(scoreField(a), a, 1, 30, 10))}
</div>
{field(
'specialTrait',
'Special trait',
<input
id="monster-specialTrait"
name="specialTrait"
defaultValue={monster?.specialTrait ?? ''}
placeholder="The one thing that makes it different"
maxLength={500}
/>,
{ marginTop: '0.5rem' }
)}
{field(
'notes',
'Notes',
<textarea
id="monster-notes"
name="notes"
rows={3}
className="textarea-block"
defaultValue={monster?.notes ?? ''}
placeholder="Everything else: other speeds (fly 30, swim 40), extra attacks, spells, tactics, resistances, senses, what it drops"
maxLength={2000}
/>,
{ marginTop: '0.5rem' }
)}
<div className="flex-row mt-sm">
<button type="submit" className="btn btn-primary" disabled={saving}>
{monster ? 'Save Changes' : 'Add Monster'}
</button>
{onCancel && (
<button type="button" className="btn btn-ghost" onClick={onCancel}>
Cancel
</button>
)}
</div>
</form>
);
}
function LibraryPicker<T extends { key: string; name: string }>({
items,
onAdd,
@@ -304,6 +441,7 @@ export function CampaignHomePage() {
const setEncounterArchived = useSetEncounterArchived(campaignId);
const deleteEncounter = useDeleteEncounter(campaignId);
const createMonster = useCreateMonster(campaignId);
const updateMonster = useUpdateMonster(campaignId);
const deleteMonster = useDeleteMonster(campaignId);
const createWeapon = useCreateWeapon(campaignId);
const createSpell = useCreateSpell(campaignId);
@@ -341,6 +479,7 @@ export function CampaignHomePage() {
const [showDelete, setShowDelete] = useState(false);
const [deleteConfirmName, setDeleteConfirmName] = useState('');
const [showMonsterForm, setShowMonsterForm] = useState(false);
const [editingMonsterId, setEditingMonsterId] = useState<string | null>(null);
const [showWeaponForm, setShowWeaponForm] = useState(false);
const [showSpellForm, setShowSpellForm] = useState(false);
const [showItemForm, setShowItemForm] = useState(false);
@@ -353,6 +492,7 @@ export function CampaignHomePage() {
const itemsPage = usePagination(items, 8);
const npcsPage = usePagination(npcs, 8);
const monstersPage = usePagination(monsters, 8);
const editingMonster = monsters?.find((m) => m.id === editingMonsterId);
const liveEncounters = encounters?.filter((e) => !e.archivedAt) || [];
const archivedEncounters = encounters?.filter((e) => e.archivedAt) || [];
const shownEncounters = sortEncounters(liveEncounters, encounterSort);
@@ -1208,8 +1348,14 @@ export function CampaignHomePage() {
<button className="btn btn-ghost" onClick={() => setShowMonsterLibrary((s) => !s)}>
{showMonsterLibrary ? 'Close Library' : '📚 From Library'}
</button>
<button className="btn btn-ghost" onClick={() => setShowMonsterForm((s) => !s)}>
{showMonsterForm ? 'Close' : '+ Add Monster'}
<button
className="btn btn-ghost"
onClick={() => {
setShowMonsterForm(!(showMonsterForm || editingMonster));
setEditingMonsterId(null);
}}
>
{showMonsterForm || editingMonster ? 'Close' : '+ Add Monster'}
</button>
</div>
</div>
@@ -1219,62 +1365,93 @@ export function CampaignHomePage() {
adding={addMonsterFromLibrary.isPending}
onAdd={(key) => addMonsterFromLibrary.mutate(key)}
placeholder="Search the monster library…"
renderMeta={(m) => `CR ${m.challengeRating} · AC ${m.ac} · HP ${m.maxHp} · attack +${m.attackBonus}${m.damageDice ? ` for ${m.damageDice}` : ''}`}
renderMeta={(m) =>
`CR ${m.challengeRating ?? '—'} · AC ${m.ac} · HP ${m.maxHp} · speed ${m.speed} · attack ${fmtMod(m.attackBonus)}${m.damageDice ? ` for ${m.damageDice}` : ''} · ${abilityLine(m)}`
}
renderDetail={(m) => [m.specialTrait, m.notes].filter(Boolean).join(' — ') || null}
/>
)}
{showMonsterForm && (
<form
onSubmit={async (e) => {
e.preventDefault();
const fd = new FormData(e.target as HTMLFormElement);
await createMonster.mutateAsync({
name: String(fd.get('name')),
ac: Number(fd.get('ac')),
maxHp: Number(fd.get('maxHp')),
speed: 30,
strScore: 10,
dexScore: 10,
conScore: 10,
intScore: 10,
wisScore: 10,
chaScore: 10,
attackBonus: Number(fd.get('attackBonus') || 0),
damageDice: String(fd.get('damageDice') || '') || null,
challengeRating: null,
specialTrait: null,
notes: null,
});
(e.target as HTMLFormElement).reset();
}}
className="grid grid-cols-3"
>
<input name="name" placeholder="Name" required />
<NumberInput name="ac" min={0} max={50} placeholder="AC" required />
<NumberInput name="maxHp" min={1} max={100000} placeholder="Max HP" required />
<NumberInput name="attackBonus" min={-20} max={50} placeholder="Attack bonus" />
<input name="damageDice" placeholder="Damage dice" />
<button type="submit" className="btn btn-primary">Add</button>
</form>
{(showMonsterForm || editingMonster) && (
<div className="card mt-sm" style={{ background: 'var(--bg-inset)' }}>
{editingMonster && <div className="section-label">Editing {editingMonster.name}</div>}
<MonsterForm
key={editingMonster?.id ?? 'new'}
monster={editingMonster}
saving={createMonster.isPending || updateMonster.isPending}
onCancel={editingMonster ? () => setEditingMonsterId(null) : undefined}
onSave={async (stats) => {
setFormError(null);
try {
if (editingMonster) {
await updateMonster.mutateAsync({ id: editingMonster.id, ...stats });
setEditingMonsterId(null);
} else {
await createMonster.mutateAsync(stats);
}
} catch (err) {
setFormError(extractErrorMessage(err));
throw err;
}
}}
/>
</div>
)}
<div className="table-wrap mt-sm">
<table className="data-table">
<thead>
<tr>
<th>Name</th>
<th>CR</th>
<th>AC</th>
<th>HP</th>
<th>Speed</th>
<th>Attack</th>
<th>Damage</th>
<th />
<th />
</tr>
</thead>
<tbody>
{monstersPage.pageItems.map((m) => (
<tr key={m.id}>
<td>{m.name}</td>
<td>
<strong>{m.name}</strong>
<div className="mono" style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>{abilityLine(m)}</div>
{m.specialTrait && (
<div style={{ fontSize: '0.9rem' }}>
<strong>Trait:</strong> {m.specialTrait}
</div>
)}
{m.notes && <div style={{ color: 'var(--text-muted)', fontSize: '0.9rem', whiteSpace: 'pre-wrap' }}>{m.notes}</div>}
</td>
<td>{m.challengeRating || '—'}</td>
<td>{m.ac}</td>
<td>{m.maxHp}</td>
<td>{m.speed}</td>
<td>{fmtMod(m.attackBonus)}</td>
<td>{m.damageDice || '—'}</td>
<td>
<button className="btn btn-ghost" onClick={() => deleteMonster.mutate(m.id)}>✕</button>
<button
className="btn btn-ghost"
aria-label={`Edit ${m.name}`}
onClick={() => {
setEditingMonsterId(m.id);
setShowMonsterForm(false);
}}
>
Edit
</button>
</td>
<td>
<button
className="btn btn-ghost"
aria-label={`Remove ${m.name}`}
onClick={() =>
window.confirm(`Remove ${m.name}? Copies already in encounters stay, but lose their stat block.`) && deleteMonster.mutate(m.id)
}
>
✕
</button>
</td>
</tr>
))}
+18 -15
View File
@@ -40,15 +40,16 @@ import { usePagination, Pagination } from '../components/Paginated';
import {
ABILITY_LABELS,
CLASS_LABELS,
COINS,
COIN_FIELD,
COIN_LABELS,
COIN_SHORT,
COIN_VALUE,
CURRENCIES,
SKILL_LABELS,
SPECIES_LABELS,
fmtMod,
extractErrorMessage,
formatMoney,
formatPurse,
purseWorthInGold,
spellLevelLabel,
@@ -56,7 +57,7 @@ import {
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, Currency } from '../api/types';
import type { Ability, CharacterDetail, Coin, 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
@@ -112,10 +113,10 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError:
const heldWeapon = character.weapons.find((w) => w.weaponId === id);
const heldItem = character.inventoryItems.find((i) => i.itemId === id);
// 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.
// the purse is worth at least one of can be given. Cash is only paid in cash.
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 purseInCopper = COINS.reduce((sum, c) => sum + held(c) * COIN_VALUE[c], 0);
const affordable = (c: Currency) => (c === 'CASH' ? held(c) : 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));
@@ -170,7 +171,7 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError:
<option value="">What to give…</option>
{coinsHeld.map((c) => (
<option key={c} value={`gold:${c}`}>
{COIN_LABELS[c]} ({held(c)} {COIN_SHORT[c]} held)
{COIN_LABELS[c]} ({formatMoney(held(c), c)} held)
</option>
))}
{character.weapons.length > 0 && (
@@ -267,8 +268,8 @@ export function CharacterSheetPage() {
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 [exchangeFrom, setExchangeFrom] = useState<Coin>('GOLD');
const [exchangeTo, setExchangeTo] = useState<Coin>('SILVER');
const [noteBody, setNoteBody] = useState('');
const [pickedWeaponId, setPickedWeaponId] = useState('');
const [pickedSpellId, setPickedSpellId] = useState('');
@@ -828,7 +829,8 @@ export function CharacterSheetPage() {
</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.
change for you: if you are short of a coin, a bigger one is broken. Cash ($) is kept apart: it is never
exchanged for coins.
</p>
{canEdit && (
<div className="flex-row flex-wrap">
@@ -876,16 +878,16 @@ export function CharacterSheetPage() {
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) => (
<select aria-label="Exchange from" value={exchangeFrom} onChange={(e) => setExchangeFrom(e.target.value as Coin)}>
{COINS.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) => (
<select aria-label="Exchange into" value={exchangeTo} onChange={(e) => setExchangeTo(e.target.value as Coin)}>
{COINS.map((c) => (
<option key={c} value={c}>
{COIN_LABELS[c]}
</option>
@@ -902,7 +904,8 @@ export function CharacterSheetPage() {
<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)}`} {COIN_SHORT[t.currency ?? 'GOLD']}
{t.amount > 0 ? '+' : '−'}
{formatMoney(Math.abs(t.amount), 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)' }}>
@@ -911,7 +914,7 @@ export function CharacterSheetPage() {
</div>
</div>
<span className="mono" style={{ color: 'var(--text-muted)' }}>
{t.balanceAfter} {COIN_SHORT[t.currency ?? 'GOLD']}
{formatMoney(t.balanceAfter, t.currency ?? 'GOLD')}
</span>
</div>
))}
+2 -2
View File
@@ -30,7 +30,7 @@ import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput';
import { HpBar } from '../components/HpBar';
import { DeathSaves } from '../components/DeathSaves';
import { ABILITY_LABELS, CLASS_LABELS, COIN_LABELS, CURRENCIES, SPECIES_LABELS, extractErrorMessage, fmtMod, spellLevelLabel } from '../lib/format';
import { ABILITY_LABELS, CLASS_LABELS, COIN_LABELS, CURRENCIES, SPECIES_LABELS, extractErrorMessage, fmtMod, formatMoney, spellLevelLabel } from '../lib/format';
import { abilityModifier, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
import type {
Ability,
@@ -606,7 +606,7 @@ function AttackBar({
}
function rewardLabel(r: EncounterReward): string {
if (r.type === 'GOLD') return `${r.gold} ${COIN_LABELS[r.currency ?? 'GOLD'].toLowerCase()}`;
if (r.type === 'GOLD') return formatMoney(r.gold ?? 0, r.currency ?? 'GOLD', true);
const entry = r.type === 'WEAPON' ? r.weapon : r.item;
return `${entry?.name ?? 'Removed from the pool'}${r.quantity > 1 ? ` ×${r.quantity}` : ''} · Lv ${r.level}`;
}
+4 -4
View File
@@ -1,19 +1,19 @@
{
"name": "dnd-campaign-manager",
"version": "1.8.1",
"version": "1.9.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dnd-campaign-manager",
"version": "1.8.1",
"version": "1.9.0",
"workspaces": [
"backend",
"frontend"
]
},
"backend": {
"version": "1.8.1",
"version": "1.9.0",
"dependencies": {
"@prisma/client": "^5.20.0",
"bcrypt": "^5.1.1",
@@ -31,7 +31,7 @@
}
},
"frontend": {
"version": "1.8.1",
"version": "1.9.0",
"dependencies": {
"@tanstack/react-query": "^5.103.2",
"axios": "^1.20.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dnd-campaign-manager",
"version": "1.8.1",
"version": "1.9.0",
"private": true,
"description": "D&D 5e campaign manager — monorepo root",
"workspaces": [