V1.11.1: HP and money are the DM's to change

- A player can no longer heal or hurt their own character: the Damage,
  Heal and Temp HP controls on the sheet are the DM's, and the server
  refuses the change from anyone else. Players still roll their own
  death saves.
- A player can no longer add money to their own purse or write it off:
  recording a gain or a spend is the DM's, in any coin or in cash, on
  the sheet and on the server.
- Players still exchange gold, silver and copper on the sheet, give
  money to another character, and buy from or change money with an NPC.
- All packages at version 1.11.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 09:05:24 +02:00
co-authored by Claude Opus 5.5
parent 0124e3bc53
commit b82084276a
8 changed files with 85 additions and 66 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.11.0",
"version": "1.11.1",
"private": true,
"type": "commonjs",
"main": "src/index.js",
+40 -29
View File
@@ -311,11 +311,10 @@ describe('copy character to another campaign', () => {
await request(app).post(`/characters/${characterId}/weapons`).set(source.dmAuth).send({ weaponId: longsword.body.weapon.id, level: 2 });
await request(app).post(`/characters/${characterId}/spells`).set(source.dmAuth).send({ spellId: spell.body.spell.id });
await request(app).post(`/characters/${characterId}/inventory`).set(source.dmAuth).send({ itemId: rope.body.item.id, quantity: 2 });
await request(app).post(`/characters/${characterId}/gold`).set(playerAuth).send({ amount: 42, reason: 'Savings' });
await request(app).post(`/characters/${characterId}/gold`).set(source.dmAuth).send({ amount: 42, reason: 'Savings' });
await request(app).patch(`/characters/${characterId}`).set(source.dmAuth).send({ level: 3 });
const original = (
await request(app).patch(`/characters/${characterId}`).set(playerAuth).send({ currentHp: 1, privateNotes: 'secret' })
).body.character;
await request(app).patch(`/characters/${characterId}`).set(source.dmAuth).send({ currentHp: 1 });
const original = (await request(app).patch(`/characters/${characterId}`).set(playerAuth).send({ privateNotes: 'secret' })).body.character;
const copyRes = await request(app).post(`/characters/${characterId}/copy`).set(playerAuth).send({ targetCampaignId: target.campaign.id });
expect(copyRes.status).toBe(201);
@@ -402,6 +401,9 @@ describe('who can change what on a character', () => {
const location = await request(app).post(`/campaigns/${campaign.id}/locations`).set(dmAuth).send({ name: 'Keep' });
const dmOnly = [
// A player cannot heal or hurt their own character.
{ currentHp: 1 },
{ tempHp: 2 },
{ level: 20 },
{ xp: 9000 },
{ currentLocationId: location.body.location.id },
@@ -419,7 +421,8 @@ describe('who can change what on a character', () => {
expect((await request(app).patch(`/characters/${characterId}`).set(dmAuth).send({ gold: 999 })).status).toBe(400);
// The player can still edit the rest of their own sheet.
expect((await request(app).patch(`/characters/${characterId}`).set(playerAuth).send({ tempHp: 2 })).status).toBe(200);
expect((await request(app).patch(`/characters/${characterId}`).set(playerAuth).send({ privateNotes: 'Mine.' })).status).toBe(200);
expect((await request(app).patch(`/characters/${characterId}`).set(dmAuth).send({ currentHp: 1, tempHp: 2 })).status).toBe(200);
const dmRes = await request(app)
.patch(`/characters/${characterId}`)
@@ -581,12 +584,12 @@ describe('hit points', () => {
const patch = (auth, body) => request(app).patch(`/characters/${characterId}`).set(auth).send(body);
const level5 = (await patch(dmAuth, { level: 5 })).body.character;
const full = (await patch(playerAuth, { currentHp: 9999 })).body.character;
const full = (await patch(dmAuth, { currentHp: 9999 })).body.character;
expect(full.currentHp).toBe(level5.sheet.hp.max);
expect((await patch(playerAuth, { currentHp: -1 })).body.character.currentHp).toBe(0);
expect((await patch(dmAuth, { currentHp: -1 })).body.character.currentHp).toBe(0);
await patch(playerAuth, { currentHp: 9999 });
await patch(dmAuth, { currentHp: 9999 });
const level1 = (await patch(dmAuth, { level: 1 })).body.character;
expect(level1.sheet.hp.max).toBeLessThan(level5.sheet.hp.max);
expect(level1.currentHp).toBe(level1.sheet.hp.max);
@@ -609,7 +612,7 @@ describe('hit points', () => {
expect(await sheetHp()).toBe(maxHp - 3);
// Healing on the sheet shows in the tracker.
await request(app).patch(`/characters/${characterId}`).set(playerAuth).send({ currentHp: maxHp - 1 });
await request(app).patch(`/characters/${characterId}`).set(dmAuth).send({ currentHp: maxHp - 1 });
expect((await combatant()).currentHp).toBe(maxHp - 1);
// The tracker is clamped like the sheet, and can't give a character a different max.
@@ -645,14 +648,19 @@ describe('money tracker', () => {
expect(reward.status).toBe(201);
expect(reward.body.gold).toBe(65);
const spend = await gold(playerAuth, { amount: -20, reason: 'New boots' });
const spend = await gold(dmAuth, { amount: -20, reason: 'New boots' });
expect(spend.status).toBe(201);
expect(spend.body.gold).toBe(45);
const overspend = await gold(playerAuth, { amount: -46 });
// A player can neither add to their own money nor write it off, in any coin.
expect((await gold(playerAuth, { amount: 5, reason: 'Found it' })).status).toBe(403);
expect((await gold(playerAuth, { amount: 5, currency: 'SILVER' })).status).toBe(403);
expect((await gold(playerAuth, { amount: -5, reason: 'Lost it' })).status).toBe(403);
const overspend = await gold(dmAuth, { amount: -46 });
expect(overspend.status).toBe(400);
expect((await gold(playerAuth, { amount: 0 })).status).toBe(400);
expect((await gold(playerAuth, { amount: 1.5 })).status).toBe(400);
expect((await gold(dmAuth, { amount: 0 })).status).toBe(400);
expect((await gold(dmAuth, { amount: 1.5 })).status).toBe(400);
// Another player can neither write to the ledger nor read it.
const stranger = await request(app)
@@ -669,7 +677,7 @@ describe('money tracker', () => {
expect(ledger.status).toBe(200);
expect(ledger.body.gold).toBe(45);
expect(ledger.body.transactions.map((t) => [t.amount, t.balanceAfter, t.reason, t.createdBy.displayName])).toEqual([
[-20, 45, 'New boots', 'Player'],
[-20, 45, 'New boots', 'DM'],
[50, 65, 'Bounty on the wolf pack', 'DM'],
[15, 15, 'Starting gold', 'Player'],
]);
@@ -679,7 +687,8 @@ describe('money tracker', () => {
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);
// Only the DM records a gain or a spend.
const money = (body) => request(app).post(`/characters/${characterId}/gold`).set(dmAuth).send(body);
await money({ amount: 5 });
const paid = await money({ amount: 40, currency: 'CASH', reason: 'Odd job' });
@@ -2079,17 +2088,17 @@ describe('temporary hit points', () => {
expect((await sheet()).currentHp).toBe(max);
// On the sheet: 5 temp, 3 damage — all of it soaked.
await request(app).patch(ch).set(playerAuth).send({ tempHp: 5 });
let now = (await request(app).patch(ch).set(playerAuth).send({ currentHp: max - 3 })).body.character;
await request(app).patch(ch).set(dmAuth).send({ tempHp: 5 });
let now = (await request(app).patch(ch).set(dmAuth).send({ currentHp: max - 3 })).body.character;
expect([now.currentHp, now.tempHp]).toEqual([max, 2]);
// More damage than is left: the rest reaches real HP.
now = (await request(app).patch(ch).set(playerAuth).send({ currentHp: max - 3 })).body.character;
now = (await request(app).patch(ch).set(dmAuth).send({ currentHp: max - 3 })).body.character;
expect([now.currentHp, now.tempHp]).toEqual([max - 1, 0]);
// Healing doesn't touch temp HP, and setting temp HP doesn't touch HP.
await request(app).patch(ch).set(playerAuth).send({ tempHp: 4 });
now = (await request(app).patch(ch).set(playerAuth).send({ currentHp: max })).body.character;
await request(app).patch(ch).set(dmAuth).send({ tempHp: 4 });
now = (await request(app).patch(ch).set(dmAuth).send({ currentHp: max })).body.character;
expect([now.currentHp, now.tempHp]).toEqual([max, 4]);
// In an encounter the damage lands on the same sheet, the same way.
@@ -2101,7 +2110,7 @@ describe('temporary hit points', () => {
expect([now.currentHp, now.tempHp]).toEqual([max - 2, 0]);
// A blow far past zero still only takes what there is.
await request(app).patch(ch).set(playerAuth).send({ tempHp: 2 });
await request(app).patch(ch).set(dmAuth).send({ tempHp: 2 });
await request(app).patch(`${e}/participants/${row.id}`).set(dmAuth).send({ currentHp: -500 });
now = await sheet();
expect([now.currentHp, now.tempHp]).toEqual([0, 0]);
@@ -2121,7 +2130,7 @@ describe('death saves', () => {
// No saves while standing, and a save is a d20.
expect((await save(playerAuth, { action: 'roll', roll: 12 })).status).toBe(400);
await hp(playerAuth, 0);
await hp(dmAuth, 0);
expect((await sheet()).deathSaves).toEqual({ state: 'DYING', successes: 0, failures: 0 });
expect((await save(playerAuth, { action: 'roll', roll: 21 })).status).toBe(400);
@@ -2131,10 +2140,10 @@ describe('death saves', () => {
// Being hit while down is a failed save; healing clears the lot.
expect((await hp(dmAuth, -1)).body.character.deathSaves).toEqual({ state: 'DYING', successes: 1, failures: 2 });
expect((await hp(playerAuth, 3)).body.character.deathSaves).toEqual({ state: 'UP', successes: 0, failures: 0 });
expect((await hp(dmAuth, 3)).body.character.deathSaves).toEqual({ state: 'UP', successes: 0, failures: 0 });
// Three successes: stable, at 0 HP, with nothing left to roll.
await hp(playerAuth, 0);
await hp(dmAuth, 0);
for (const roll of [15, 11, 19]) await save(playerAuth, { action: 'roll', roll });
let now = await sheet();
expect([now.currentHp, now.deathSaves]).toEqual([0, { state: 'STABLE', successes: 0, failures: 0 }]);
@@ -2146,7 +2155,7 @@ describe('death saves', () => {
expect([now.currentHp, now.deathSaves]).toEqual([1, { state: 'UP', successes: 0, failures: 0 }]);
// Someone else can steady them without a roll.
await hp(playerAuth, 0);
await hp(dmAuth, 0);
expect((await save(playerAuth, { action: 'stabilize' })).body.character.deathSaves.state).toBe('STABLE');
// The tallies are the DM's to set by hand; a 1 on top of two failures kills.
@@ -2155,7 +2164,7 @@ describe('death saves', () => {
expect((await save(playerAuth, { action: 'roll', roll: 1 })).body.character.deathSaves.state).toBe('DEAD');
// The dead can't be healed or roll; only the DM brings them back.
expect((await hp(playerAuth, max)).status).toBe(400);
expect((await hp(dmAuth, max)).status).toBe(400);
expect((await save(playerAuth, { action: 'roll', roll: 20 })).status).toBe(400);
expect((await save(playerAuth, { action: 'revive' })).status).toBe(403);
now = (await save(dmAuth, { action: 'revive' })).body.character;
@@ -2173,7 +2182,7 @@ describe('death saves', () => {
const combatant = async () => (await request(app).get(e).set(playerAuth)).body.encounter.participants[0];
// Temp HP that soaks the whole blow keeps them on their feet.
await request(app).patch(ch).set(playerAuth).send({ tempHp: 2 });
await request(app).patch(ch).set(dmAuth).send({ tempHp: 2 });
await strike(max - 2);
expect((await combatant()).characterCombatInfo.deathSaves.state).toBe('UP');
@@ -2280,7 +2289,8 @@ describe('silver and copper', () => {
const { gold, silver, copper } = (await request(app).get(`/characters/${id}`).set(auth)).body.character;
return [gold, silver, copper];
};
const money = (body) => request(app).post(`${ch}/gold`).set(playerAuth).send(body);
// Only the DM records a gain or a spend.
const money = (body) => request(app).post(`${ch}/gold`).set(dmAuth).send(body);
const exchange = (body) => request(app).post(`${ch}/gold/exchange`).set(playerAuth).send(body);
expect(await purse()).toEqual([5, 0, 0]);
@@ -2359,7 +2369,8 @@ describe('paying with change', () => {
const { gold, silver, copper } = (await request(app).get(`/characters/${id}`).set(auth)).body.character;
return [gold, silver, copper];
};
const money = (body) => request(app).post(`${ch}/gold`).set(playerAuth).send(body);
// Only the DM records a gain or a spend.
const money = (body) => request(app).post(`${ch}/gold`).set(dmAuth).send(body);
const newest = async (count) =>
(await request(app).get(`${ch}/gold`).set(playerAuth)).body.transactions.slice(0, count).map((t) => [t.currency, t.amount, t.balanceAfter, t.reason]);
+9 -6
View File
@@ -164,11 +164,12 @@ const patchCharacterSchema = z.object({
// Progression and placement are the DM's call, and so is rewriting what the
// builder composed (special abilities, background, appearance): a player
// picks those from lists and cannot type their own. The owning player can
// picks those from lists and cannot type their own. So are HP and temp HP:
// a player cannot heal or hurt their own character. The owning player can
// edit the rest of their own sheet. Gold is not patchable at all — it only
// moves through the ledger (POST /:id/gold). The armor bonus is the DM's
// adjustment on top of what equipped gear gives, so it is the DM's too.
const DM_ONLY_PATCH_FIELDS = ['level', 'xp', 'currentLocationId', 'speedOverride', 'armorBonus', 'specialAbilities', 'background', 'appearance'];
const DM_ONLY_PATCH_FIELDS = ['currentHp', 'tempHp', 'level', 'xp', 'currentLocationId', 'speedOverride', 'armorBonus', 'specialAbilities', 'background', 'appearance'];
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
@@ -183,7 +184,7 @@ router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, n
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
if (!req.isDM && DM_ONLY_PATCH_FIELDS.some((field) => parsed.data[field] !== undefined)) {
return res.status(403).json({ error: 'Only the DM can change level, XP, speed, armor bonus, location, special abilities, background or appearance.' });
return res.status(403).json({ error: 'Only the DM can change HP, temp HP, level, XP, speed, armor bonus, location, special abilities, background or appearance.' });
}
// Damage is soaked by temp HP before it reaches real HP — unless this
@@ -1006,9 +1007,10 @@ router.get('/:id/gold', requireCharacterAccess(), async (req, res, next) => {
}
});
// amount is signed: positive = gained, negative = spent. The owning player
// and the DM can both record entries; each one is stamped with who made it.
// A spend is paid from the whole purse: if there aren't enough of the coin
// amount is signed: positive = gained, negative = spent. Only the DM records
// entries here — a player cannot add money to their own purse or write it
// off; theirs moves by giving, buying and exchanging. Each entry 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) => {
@@ -1022,6 +1024,7 @@ router.post('/:id/gold', requireCharacterAccess({ write: true }), async (req, re
.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { amount, currency, reason } = parsed.data;
if (!req.isDM) return res.status(403).json({ error: 'Only the DM can record a gain or a spend.' });
let transactions;
try {