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 };