From bdce3399ee1a31d3aac8c4dad86ce5cdba402c9d Mon Sep 17 00:00:00 2001 From: Joshua Oosthuizen Date: Wed, 7 Oct 2026 13:15:55 +0200 Subject: [PATCH] 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 --- backend/package.json | 4 +- .../migration.sql | 6 + backend/prisma/schema.prisma | 9 +- backend/src/routes/api.integration.test.js | 62 +++++ backend/src/routes/campaigns.routes.js | 5 +- backend/src/routes/characters.routes.js | 23 +- backend/src/utils/grants.js | 30 +- docs/CHARACTERS.md | 9 +- docs/ENCOUNTERS.md | 6 +- docs/MONSTERS.md | 31 ++- frontend/package.json | 2 +- frontend/src/api/queries.ts | 14 + frontend/src/api/types.ts | 8 +- frontend/src/lib/format.ts | 31 ++- frontend/src/pages/CampaignHomePage.tsx | 261 +++++++++++++++--- frontend/src/pages/CharacterSheetPage.tsx | 33 ++- frontend/src/pages/EncounterPage.tsx | 4 +- package-lock.json | 8 +- package.json | 2 +- 19 files changed, 425 insertions(+), 123 deletions(-) create mode 100644 backend/prisma/migrations/20261007230000_cash_currency/migration.sql diff --git a/backend/package.json b/backend/package.json index 715b005..454ea48 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/prisma/migrations/20261007230000_cash_currency/migration.sql b/backend/prisma/migrations/20261007230000_cash_currency/migration.sql new file mode 100644 index 0000000..2344f35 --- /dev/null +++ b/backend/prisma/migrations/20261007230000_cash_currency/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index dc9d6ec..b72dfaa 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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? diff --git a/backend/src/routes/api.integration.test.js b/backend/src/routes/api.integration.test.js index fe47cff..6838cb1 100644 --- a/backend/src/routes/api.integration.test.js +++ b/backend/src/routes/api.integration.test.js @@ -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) diff --git a/backend/src/routes/campaigns.routes.js b/backend/src/routes/campaigns.routes.js index f87f84e..eeac586 100644 --- a/backend/src/routes/campaigns.routes.js +++ b/backend/src/routes/campaigns.routes.js @@ -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(), diff --git a/backend/src/routes/characters.routes.js b/backend/src/routes/characters.routes.js index c61e7d9..abaa7d6 100644 --- a/backend/src/routes/characters.routes.js +++ b/backend/src/routes/characters.routes.js @@ -29,7 +29,7 @@ const { } = require('../game/buildFlow'); const characterOptions = require('../game/data/characterOptions'); const { SKILLS } = require('../game/data/skills'); -const { 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() }); diff --git a/backend/src/utils/grants.js b/backend/src/utils/grants.js index 23073e7..e2292b8 100644 --- a/backend/src/utils/grants.js +++ b/backend/src/utils/grants.js @@ -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 }; diff --git a/docs/CHARACTERS.md b/docs/CHARACTERS.md index 42e9bf7..bcae457 100644 --- a/docs/CHARACTERS.md +++ b/docs/CHARACTERS.md @@ -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 diff --git a/docs/ENCOUNTERS.md b/docs/ENCOUNTERS.md index fe94cf9..c90cdd3 100644 --- a/docs/ENCOUNTERS.md +++ b/docs/ENCOUNTERS.md @@ -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: ". - 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 diff --git a/docs/MONSTERS.md b/docs/MONSTERS.md index 3ed05d9..eb9c857 100644 --- a/docs/MONSTERS.md +++ b/docs/MONSTERS.md @@ -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 diff --git a/frontend/package.json b/frontend/package.json index def34a7..5485832 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.8.1", + "version": "1.9.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/queries.ts b/frontend/src/api/queries.ts index 695a78c..a3140a4 100644 --- a/frontend/src/api/queries.ts +++ b/frontend/src/api/queries.ts @@ -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>) => + (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({ diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index c3f25c6..4eed6fd 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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; diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 9c46f80..242ad80 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -1,4 +1,4 @@ -import type { Currency } from '../api/types'; +import type { Coin, Currency } from '../api/types'; export const SPECIES_LABELS: Record = { 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 = { GOLD: 'Gold', SILVER: 'Silver', COPPER: 'Copper' }; -export const COIN_SHORT: Record = { GOLD: 'gp', SILVER: 'sp', COPPER: 'cp' }; -export const COIN_FIELD: Record = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper' }; +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 = { GOLD: 'Gold', SILVER: 'Silver', COPPER: 'Copper', CASH: 'Cash ($)' }; +export const COIN_FIELD: Record = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper', CASH: 'cash' }; // What one coin is worth, counted in copper. -export const COIN_VALUE: Record = { GOLD: 100, SILVER: 10, COPPER: 1 }; +export const COIN_VALUE: Record = { GOLD: 100, SILVER: 10, COPPER: 1 }; +const COIN_SHORT: Record = { 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; } diff --git a/frontend/src/pages/CampaignHomePage.tsx b/frontend/src/pages/CampaignHomePage.tsx index 253b11b..53b24f8 100644 --- a/frontend/src/pages/CampaignHomePage.tsx +++ b/frontend/src/pages/CampaignHomePage.tsx @@ -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; +type AbilityScores = Pick; +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; + onCancel?: () => void; +}) { + const field = (name: string, label: string, input: ReactNode, style?: CSSProperties) => ( +
+ + {input} +
+ ); + const number = (name: keyof MonsterInput, label: string, min: number, max: number, fallback?: number) => + field( + name, + label, + + ); + + return ( +
{ + 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. + } + }} + > +
+ {field('name', 'Name', )} + {field( + 'challengeRating', + 'CR (Challenge Rating)', + + )} + {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', + + )} +
+
+ {ABILITY_ORDER.map((a) => number(scoreField(a), a, 1, 30, 10))} +
+ {field( + 'specialTrait', + 'Special trait', + , + { marginTop: '0.5rem' } + )} + {field( + 'notes', + 'Notes', +