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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.11.0",
|
||||
"version": "1.11.1",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "src/index.js",
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user