Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdce3399ee | ||
|
|
f764878960 | ||
|
|
6b466d497e | ||
|
|
f8462a6e31 | ||
|
|
754eb0ee68 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.7.0",
|
||||
"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,2 @@
|
||||
-- AlterTable: how much an area attack adds to each AC per extra target
|
||||
ALTER TABLE "campaigns" ADD COLUMN "areaAttackAcPenalty" INTEGER NOT NULL DEFAULT 2;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable: spells that bring back the dead
|
||||
ALTER TABLE "spells" ADD COLUMN "revives" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- The library's two revival spells, wherever a campaign already has them.
|
||||
UPDATE "spells" SET "revives" = true WHERE "name" IN ('Revivify', 'Raise Dead');
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
@@ -168,6 +170,9 @@ model Campaign {
|
||||
skipMotivationRoll Boolean @default(false)
|
||||
// Off: players see only a health bar for monsters and NPCs, not the numbers.
|
||||
showMonsterHp Boolean @default(true)
|
||||
// An attack on several targets is harder to land: each target's AC counts
|
||||
// as this much higher for every target beyond the first. 0 turns it off.
|
||||
areaAttackAcPenalty Int @default(2)
|
||||
ownerId String
|
||||
// Set while the campaign is archived: kept in full, but moved off the main
|
||||
// list and closed to new members until the DM restores it.
|
||||
@@ -275,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?
|
||||
@@ -455,6 +461,9 @@ model Spell {
|
||||
duration String?
|
||||
description String?
|
||||
isStartingOption Boolean @default(false)
|
||||
// Brings back the dead: a character who can cast it may revive a dead ally
|
||||
// on their turn in a battle (encounters.routes.js).
|
||||
revives Boolean @default(false)
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
known CharacterSpell[]
|
||||
|
||||
@@ -115,7 +115,7 @@ const SPELL_LIBRARY = [
|
||||
{ key: 'nondetection', name: 'Nondetection', level: 3, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: '8 hours', description: 'Hide a target from divination magic.', classes: ['BARD', 'RANGER', 'WIZARD'] },
|
||||
{ key: 'protection-from-energy', name: 'Protection from Energy', level: 3, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 1 hour', description: 'Resistance to one damage type of your choice.', classes: ['CLERIC', 'DRUID', 'RANGER', 'SORCERER', 'WIZARD'] },
|
||||
{ key: 'remove-curse', name: 'Remove Curse', level: 3, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'Lift a curse from a creature or object.', classes: ['CLERIC', 'PALADIN', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'revivify', name: 'Revivify', level: 3, school: 'Necromancy', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'A creature that died within the last minute returns to life with 1 hit point.', classes: ['CLERIC', 'PALADIN'] },
|
||||
{ key: 'revivify', name: 'Revivify', level: 3, school: 'Necromancy', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'A creature that died within the last minute returns to life with 1 hit point.', revives: true, classes: ['CLERIC', 'PALADIN'] },
|
||||
{ key: 'sleet-storm', name: 'Sleet Storm', level: 3, school: 'Conjuration', castingTime: '1 action', range: '150 feet', duration: 'Concentration, up to 1 minute', description: 'Freezing rain falls in a 40-foot-radius cylinder, icing the ground.', classes: ['DRUID', 'SORCERER', 'WIZARD'] },
|
||||
{ key: 'slow', name: 'Slow', level: 3, school: 'Transmutation', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 1 minute', description: 'Up to six creatures are hindered, halving their speed and worse.', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'spirit-guardians', name: 'Spirit Guardians', level: 3, school: 'Conjuration', castingTime: '1 action', range: 'Self (15-foot radius)', duration: 'Concentration, up to 10 minutes', description: 'Protective spirits swirl around you, damaging enemies that enter.', damageDice: '3d8', classes: ['CLERIC'] },
|
||||
@@ -150,7 +150,7 @@ const SPELL_LIBRARY = [
|
||||
{ key: 'insect-plague', name: 'Insect Plague', level: 5, school: 'Conjuration', castingTime: '1 action', range: '300 feet', duration: 'Concentration, up to 10 minutes', description: 'A swarm of biting, stinging insects fills a 20-foot-radius sphere.', damageDice: '4d10', classes: ['CLERIC', 'DRUID', 'SORCERER'] },
|
||||
{ key: 'legend-lore', name: 'Legend Lore', level: 5, school: 'Divination', castingTime: '10 minutes', range: 'Self', duration: 'Instantaneous', description: 'Learn what you know, or have heard, about a name, place, or object.', classes: ['BARD', 'CLERIC', 'WIZARD'] },
|
||||
{ key: 'mass-cure-wounds', name: 'Mass Cure Wounds', level: 5, school: 'Evocation', castingTime: '1 action', range: '60 feet', duration: 'Instantaneous', description: 'Up to six creatures in a 30-foot-radius sphere regain hit points.', classes: ['BARD', 'CLERIC', 'DRUID'] },
|
||||
{ key: 'raise-dead', name: 'Raise Dead', level: 5, school: 'Necromancy', castingTime: '1 hour', range: 'Touch', duration: 'Instantaneous', description: 'Return a dead creature to life, as long as its body is mostly intact.', classes: ['BARD', 'CLERIC', 'PALADIN'] },
|
||||
{ key: 'raise-dead', name: 'Raise Dead', level: 5, school: 'Necromancy', castingTime: '1 hour', range: 'Touch', duration: 'Instantaneous', description: 'Return a dead creature to life, as long as its body is mostly intact.', revives: true, classes: ['BARD', 'CLERIC', 'PALADIN'] },
|
||||
{ key: 'wall-of-force', name: 'Wall of Force', level: 5, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 10 minutes', description: 'An invisible wall of force springs into existence, impenetrable to physical force.', classes: ['WIZARD'] },
|
||||
{ key: 'wall-of-stone', name: 'Wall of Stone', level: 5, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 10 minutes', description: 'A nonmagical wall of solid stone springs into existence.', classes: ['DRUID', 'SORCERER', 'WIZARD'] },
|
||||
];
|
||||
|
||||
@@ -613,7 +613,7 @@ describe('hit points', () => {
|
||||
expect((await combatant()).currentHp).toBe(maxHp - 1);
|
||||
|
||||
// The tracker is clamped like the sheet, and can't give a character a different max.
|
||||
await request(app).patch(`/encounters/${encounterId}/participants/${participantId}`).set(dmAuth).send({ currentHp: -20, maxHp: 500 });
|
||||
await request(app).patch(`/encounters/${encounterId}/participants/${participantId}`).set(dmAuth).send({ currentHp: -1, maxHp: 500 });
|
||||
expect(await sheetHp()).toBe(0);
|
||||
expect((await combatant()).maxHp).toBe(maxHp);
|
||||
|
||||
@@ -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)
|
||||
@@ -1839,7 +1901,9 @@ describe('what a player may see', () => {
|
||||
expect(dm.Ogre.hpPercent).toBeUndefined();
|
||||
|
||||
await request(app).patch(`${e}/participants/${ogreRow.id}`).set(dmAuth).send({ currentHp: 0 });
|
||||
expect((await view(playerAuth)).Ogre.hpPercent).toBe(0);
|
||||
// At 0 HP it is out of the fight, and out of the players' sight.
|
||||
expect((await view(playerAuth)).Ogre).toBeUndefined();
|
||||
expect((await view(dmAuth)).Ogre).toMatchObject({ currentHp: 0, hidden: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1948,12 +2012,32 @@ describe('targets', () => {
|
||||
expect(await seen(dmAuth)).toEqual([]);
|
||||
expect((await target(playerAuth, first.id)).status).toBe(403);
|
||||
|
||||
// Whoever is on turn can't be a target, their own or as part of a group.
|
||||
expect((await target(dmAuth, first.id)).status).toBe(400);
|
||||
expect((await target(dmAuth, hero.id, first.id)).status).toBe(400);
|
||||
|
||||
// The goblin's turn: the DM marks the hero, and can clear it again.
|
||||
expect((await target(dmAuth, hero.id)).body.encounter.targetParticipantIds).toEqual([hero.id]);
|
||||
expect((await target(dmAuth)).body.encounter.targetParticipantIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('area attack AC penalty', () => {
|
||||
itIfDb('is a campaign setting the DM changes, 2 unless told otherwise', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('areapenalty');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
expect(campaign.areaAttackAcPenalty).toBe(2);
|
||||
|
||||
expect((await request(app).patch(c).set(playerAuth).send({ areaAttackAcPenalty: 0 })).status).toBe(403);
|
||||
expect((await request(app).patch(c).set(dmAuth).send({ areaAttackAcPenalty: 11 })).status).toBe(400);
|
||||
expect((await request(app).patch(c).set(dmAuth).send({ areaAttackAcPenalty: -1 })).status).toBe(400);
|
||||
expect((await request(app).patch(c).set(dmAuth).send({ areaAttackAcPenalty: 0 })).body.campaign.areaAttackAcPenalty).toBe(0);
|
||||
|
||||
// Players are told it too: their cards show the penalty on a marked target.
|
||||
expect((await request(app).get(c).set(playerAuth)).body.campaign.areaAttackAcPenalty).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('number limits', () => {
|
||||
itIfDb('are enforced by the server, whatever a form sends', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('limits');
|
||||
@@ -2097,9 +2181,82 @@ describe('death saves', () => {
|
||||
// Healed, then hit for max HP more than they have left.
|
||||
await strike(max);
|
||||
await strike(-max);
|
||||
expect((await combatant()).characterCombatInfo.deathSaves.state).toBe('DEAD');
|
||||
const dmView = (await request(app).get(e).set(dmAuth)).body.encounter.participants[0];
|
||||
expect([dmView.hidden, dmView.characterCombatInfo.deathSaves.state]).toEqual([true, 'DEAD']);
|
||||
expect((await strike(5)).status).toBe(400);
|
||||
});
|
||||
|
||||
itIfDb('hide the fallen from players, skip their turns, and let an ally with a reviving spell bring a character back', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('fallen');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const healerId = await buildCharacter(campaign.id, playerAuth, 'Healer');
|
||||
const doomedId = await buildCharacter(campaign.id, playerAuth, 'Doomed');
|
||||
const goblin = (await request(app).post(`${c}/monsters/from-library`).set(dmAuth).send({ key: 'goblin' })).body.monster;
|
||||
const e = `/encounters/${(await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Crypt' })).body.encounter.id}`;
|
||||
const add = async (body) => (await request(app).post(`${e}/participants`).set(dmAuth).send(body)).body.participant.id;
|
||||
const healer = await add({ characterId: healerId, initiativeRoll: 20 });
|
||||
const doomed = await add({ characterId: doomedId, initiativeRoll: 10 });
|
||||
const gob = await add({ monsterId: goblin.id, initiativeRoll: 1 });
|
||||
const setHp = (id, currentHp) => request(app).patch(`${e}/participants/${id}`).set(dmAuth).send({ currentHp });
|
||||
const view = async (auth) => (await request(app).get(e).set(auth)).body.encounter;
|
||||
const ids = (encounter) => encounter.participants.map((p) => p.id);
|
||||
const revive = (auth, id) => request(app).post(`${e}/participants/${id}/revive`).set(auth);
|
||||
const nextTurn = async () => (await request(app).post(`${e}/next-turn`).set(dmAuth)).body.encounter;
|
||||
|
||||
await request(app).patch(e).set(dmAuth).send({ status: 'ACTIVE' }); // the healer is on turn
|
||||
await request(app).put(`${e}/targets`).set(dmAuth).send({ participantIds: [gob] });
|
||||
|
||||
// A monster at 0 HP is gone from the player's list, target mark and all; the DM keeps it.
|
||||
await setHp(gob, 0);
|
||||
let seen = await view(playerAuth);
|
||||
expect([ids(seen), seen.targetParticipantIds, seen.fallen]).toEqual([[healer, doomed], [], []]);
|
||||
seen = await view(dmAuth);
|
||||
expect([ids(seen), seen.targetParticipantIds]).toEqual([[healer, doomed, gob], [gob]]);
|
||||
expect(seen.participants.map((p) => p.hidden)).toEqual([false, false, true]);
|
||||
expect((await request(app).put(`${e}/targets`).set(playerAuth).send({ participantIds: [gob] })).status).toBe(400);
|
||||
|
||||
// A dying character stays in view; a dead one is hidden, and named among the fallen.
|
||||
await setHp(doomed, 0);
|
||||
expect(ids(await view(playerAuth))).toEqual([healer, doomed]);
|
||||
await request(app).post(`/characters/${doomedId}/death-saves`).set(dmAuth).send({ action: 'set', successes: 0, failures: 3 });
|
||||
seen = await view(playerAuth);
|
||||
expect([ids(seen), seen.fallen]).toEqual([[healer], [{ participantId: doomed, characterId: doomedId, name: 'Doomed' }]]);
|
||||
|
||||
// The dead join no other scene until they are revived.
|
||||
const elsewhere = `/encounters/${(await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Elsewhere' })).body.encounter.id}`;
|
||||
const join = () => request(app).post(`${elsewhere}/participants`).set(dmAuth).send({ characterId: doomedId });
|
||||
expect((await join()).status).toBe(400);
|
||||
|
||||
// The fallen have no turn: with only the healer standing, the round rolls straight over.
|
||||
seen = await nextTurn();
|
||||
expect([seen.activeParticipantId, seen.currentRound]).toEqual([healer, 2]);
|
||||
|
||||
// Reviving takes a spell that brings back the dead, at a level the caster can use.
|
||||
expect(seen.participants[0].characterCombatInfo.canRevive).toBe(false);
|
||||
expect((await revive(playerAuth, doomed)).status).toBe(400);
|
||||
const revivify = (await request(app).post(`${c}/spells/from-library`).set(dmAuth).send({ key: 'revivify' })).body.spell;
|
||||
expect(revivify.revives).toBe(true);
|
||||
await request(app).post(`/characters/${healerId}/spells`).set(dmAuth).send({ spellId: revivify.id });
|
||||
expect((await revive(playerAuth, doomed)).status).toBe(400); // level 1 can't cast a level 3 spell
|
||||
await request(app).patch(`/characters/${healerId}`).set(dmAuth).send({ level: 3 });
|
||||
expect((await view(playerAuth)).participants[0].characterCombatInfo.canRevive).toBe(true);
|
||||
expect((await revive(playerAuth, gob)).status).toBe(400); // monsters are healed, not revived
|
||||
|
||||
const revived = await revive(playerAuth, doomed);
|
||||
expect([revived.status, revived.body.revivedWith]).toEqual([200, 'Revivify']);
|
||||
expect([ids(revived.body.encounter), revived.body.encounter.fallen]).toEqual([[healer, doomed], []]);
|
||||
const back = (await request(app).get(`/characters/${doomedId}`).set(playerAuth)).body.character;
|
||||
expect([back.currentHp, back.deathSaves]).toEqual([1, { state: 'UP', successes: 0, failures: 0 }]);
|
||||
expect((await revive(playerAuth, doomed)).status).toBe(400); // not dead any more
|
||||
expect((await join()).status).toBe(201);
|
||||
|
||||
// Only on the reviver's own turn, and healing a monster brings it back into view.
|
||||
expect((await nextTurn()).activeParticipantId).toBe(doomed);
|
||||
await setHp(gob, 3);
|
||||
expect(ids(await view(playerAuth))).toEqual([healer, doomed, gob]);
|
||||
expect((await nextTurn()).activeParticipantId).toBe(gob);
|
||||
expect((await revive(playerAuth, doomed)).status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('silver and copper', () => {
|
||||
|
||||
@@ -25,6 +25,7 @@ function campaignSummary(campaign, role) {
|
||||
startingSpellCount: campaign.startingSpellCount,
|
||||
skipMotivationRoll: campaign.skipMotivationRoll,
|
||||
showMonsterHp: campaign.showMonsterHp,
|
||||
areaAttackAcPenalty: campaign.areaAttackAcPenalty,
|
||||
ownerId: campaign.ownerId,
|
||||
archivedAt: campaign.archivedAt,
|
||||
role,
|
||||
@@ -54,6 +55,7 @@ const createCampaignSchema = z.object({
|
||||
startingSpellCount: z.number().int().min(0).max(50).optional(),
|
||||
skipMotivationRoll: z.boolean().optional(),
|
||||
showMonsterHp: z.boolean().optional(),
|
||||
areaAttackAcPenalty: z.number().int().min(0).max(10).optional(),
|
||||
});
|
||||
|
||||
// POST /campaigns — create, creator becomes DM/owner
|
||||
@@ -199,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,
|
||||
@@ -223,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) {
|
||||
@@ -403,6 +406,7 @@ const spellSchema = z.object({
|
||||
description: z.string().max(4000).optional().nullable(),
|
||||
damageDice: z.string().max(20).optional().nullable(),
|
||||
isStartingOption: z.boolean().optional(),
|
||||
revives: z.boolean().optional(), // brings back the dead
|
||||
});
|
||||
|
||||
router.post('/:id/spells', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
@@ -522,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(),
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -6,7 +6,7 @@ const { loadCampaignMembership } = require('../middleware/campaignAccess');
|
||||
const { computeCharacterSheet, computeWeaponStats, canUseGear, abilityModifier, MAX_GEAR_LEVEL } = require('../game/calculations');
|
||||
const { CURRENCIES, adjustGold, giveGear } = require('../utils/grants');
|
||||
const { moveCharacters, visitedLocationIds } = require('../utils/locations');
|
||||
const { lifeState, deathSaveInfo, changeHp } = require('../utils/hp');
|
||||
const { NO_DEATH_SAVES, lifeState, deathSaveInfo, changeHp } = require('../utils/hp');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -66,6 +66,43 @@ function initiativeBonus({ character, monster }) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Out of the fight: a dead character, or a monster or NPC brought to 0 HP.
|
||||
// The fallen are hidden from the players and skipped in the turn order; the
|
||||
// DM still sees them. It is worked out from HP each time, never stored, so
|
||||
// healing a monster or reviving a character brings them straight back. Takes
|
||||
// a participant with its character loaded.
|
||||
function isFallen(p) {
|
||||
if (p.character) return lifeState(p.character) === 'DEAD';
|
||||
return p.maxHp > 0 && p.currentHp <= 0;
|
||||
}
|
||||
|
||||
// The first combatant still standing at or after `from` in the order, going
|
||||
// round to the top when the end is reached. If nobody is standing, whoever is
|
||||
// at `from`.
|
||||
function standingFrom(order, from) {
|
||||
for (let step = 0; step < order.length; step += 1) {
|
||||
const p = order[(from + step) % order.length];
|
||||
if (!isFallen(p)) return p;
|
||||
}
|
||||
return order[from % order.length] ?? null;
|
||||
}
|
||||
|
||||
// An encounter's combatants in turn order, with what isFallen needs.
|
||||
async function loadTurnOrder(encounterId) {
|
||||
const participants = await prisma.encounterParticipant.findMany({
|
||||
where: { encounterId },
|
||||
include: { character: { select: { currentHp: true, deathSaveFailures: true, stable: true } } },
|
||||
});
|
||||
return inTurnOrder(participants);
|
||||
}
|
||||
|
||||
// The spell a character would raise the dead with: one flagged as reviving
|
||||
// in the campaign's spell list, at a level they can cast. Takes a character
|
||||
// with its spells loaded.
|
||||
function revivalSpell(character) {
|
||||
return character.spells.map((cs) => cs.spell).find((spell) => spell.revives && canUseGear(character.level, spell.level));
|
||||
}
|
||||
|
||||
const initiativeRollSchema = z.number().int().min(1, 'An initiative roll is a d20: 1 to 20').max(20, 'An initiative roll is a d20: 1 to 20');
|
||||
|
||||
// What every campaign member sees about a character in an encounter: the
|
||||
@@ -104,6 +141,7 @@ function characterCombatInfo(character) {
|
||||
maxHp: sheet.hp.max,
|
||||
tempHp: character.tempHp,
|
||||
deathSaves: deathSaveInfo(character),
|
||||
canRevive: Boolean(revivalSpell(character)),
|
||||
speed: sheet.speed,
|
||||
initiativeBonus: sheet.initiative,
|
||||
passivePerception: sheet.passivePerception,
|
||||
@@ -119,7 +157,9 @@ function characterCombatInfo(character) {
|
||||
|
||||
// The DM gets everything. Players get the scene as their characters see it:
|
||||
// no DM notes, no monster stat blocks, no NPC secrets or stats, and only the
|
||||
// rewards that have actually been handed out.
|
||||
// rewards that have actually been handed out. The fallen (isFallen) are left
|
||||
// out of a player's list altogether; of those, only the dead characters are
|
||||
// named, in `fallen`, so an ally can try to revive them.
|
||||
//
|
||||
// `viewerId` is the user the payload is for. A player is told where a scene
|
||||
// is once one of their characters has been there, and — when the campaign
|
||||
@@ -151,12 +191,17 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
|
||||
if (!encounter) return encounter;
|
||||
|
||||
const hideHp = !isDM && !encounter.campaign.showMonsterHp;
|
||||
const participants = inTurnOrder(encounter.participants).map((p) => {
|
||||
const inOrder = inTurnOrder(encounter.participants);
|
||||
const hiddenIds = new Set(inOrder.filter(isFallen).map((p) => p.id));
|
||||
const fallen = inOrder
|
||||
.filter((p) => p.character && hiddenIds.has(p.id))
|
||||
.map((p) => ({ participantId: p.id, characterId: p.character.id, name: p.displayName }));
|
||||
const participants = inOrder.filter((p) => isDM || !hiddenIds.has(p.id)).map((p) => {
|
||||
const { character, monster, npc, ...row } = p;
|
||||
// The bonus is part of a monster's stat block, so only the DM gets it for
|
||||
// those; a character's is on their sheet for all to see.
|
||||
const bonus = isDM || character ? { initiativeBonus: initiativeBonus(p) } : {};
|
||||
const out = { ...row, ...bonus, kind: npc || p.npcId ? 'NPC' : p.isMonster ? 'MONSTER' : 'CHARACTER' };
|
||||
const out = { ...row, ...bonus, kind: npc || p.npcId ? 'NPC' : p.isMonster ? 'MONSTER' : 'CHARACTER', hidden: hiddenIds.has(p.id) };
|
||||
if (character) {
|
||||
// A character combatant's HP is the character's own HP, not a
|
||||
// per-encounter copy, so the tracker and the sheet can never disagree.
|
||||
@@ -187,7 +232,10 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
|
||||
locationId: knowsPlace ? scene.locationId : null,
|
||||
location: knowsPlace ? location : null,
|
||||
...(isDM ? { dmNotes } : {}),
|
||||
// A player is not told that someone they cannot see is being targeted.
|
||||
targetParticipantIds: isDM ? scene.targetParticipantIds : scene.targetParticipantIds.filter((id) => !hiddenIds.has(id)),
|
||||
participants,
|
||||
fallen,
|
||||
rewards: isDM ? rewards : rewards.filter((r) => r.awardedAt),
|
||||
};
|
||||
}
|
||||
@@ -255,11 +303,10 @@ router.patch('/:id', requireEncounterAccess, requireDM, async (req, res, next) =
|
||||
if (!location) return res.status(400).json({ error: 'That location is not part of this campaign.' });
|
||||
}
|
||||
|
||||
// Starting a battle puts the first combatant in the initiative order on turn.
|
||||
// Starting a battle puts the first combatant still standing on turn.
|
||||
const mode = data.mode ?? req.encounter.mode;
|
||||
if (data.status === 'ACTIVE' && mode === 'BATTLE' && !req.encounter.activeParticipantId) {
|
||||
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id } });
|
||||
data.activeParticipantId = inTurnOrder(participants)[0]?.id ?? null;
|
||||
data.activeParticipantId = standingFrom(await loadTurnOrder(req.encounter.id), 0)?.id ?? null;
|
||||
}
|
||||
|
||||
// Targets belong to the moment; beginning, ending or reopening clears them.
|
||||
@@ -317,6 +364,10 @@ router.post('/:id/participants', requireEncounterAccess, requireDM, async (req,
|
||||
if (!character || character.campaignId !== req.encounter.campaignId) {
|
||||
return res.status(400).json({ error: 'That character is not part of this campaign.' });
|
||||
}
|
||||
// The dead stay where they fell: they join no new scene until revived.
|
||||
if (lifeState(character) === 'DEAD') {
|
||||
return res.status(400).json({ error: `${character.name} is dead and cannot join an encounter. Revive them first.` });
|
||||
}
|
||||
currentHp = character.currentHp;
|
||||
maxHp = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies).hp.max;
|
||||
resolvedName = displayName || character.name;
|
||||
@@ -428,16 +479,16 @@ router.patch('/:id/participants/:participantId', requireEncounterAccess, async (
|
||||
|
||||
router.delete('/:id/participants/:participantId', requireEncounterAccess, requireDM, async (req, res, next) => {
|
||||
try {
|
||||
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id } });
|
||||
const order = inTurnOrder(participants);
|
||||
const order = await loadTurnOrder(req.encounter.id);
|
||||
const index = order.findIndex((p) => p.id === req.params.participantId);
|
||||
if (index === -1) return res.status(404).json({ error: 'Combatant not found in this encounter' });
|
||||
|
||||
// Removing whoever is on turn passes the turn to the next combatant, and
|
||||
// their targets go with the turn. Removing a target just unmarks it.
|
||||
// Removing whoever is on turn passes the turn to the next combatant still
|
||||
// standing, and their targets go with the turn. Removing a target just
|
||||
// unmarks it.
|
||||
const { activeParticipantId, targetParticipantIds } = req.encounter;
|
||||
if (activeParticipantId === req.params.participantId) {
|
||||
const next = order.length > 1 ? order[(index + 1) % order.length] : null;
|
||||
const next = standingFrom([...order.slice(index + 1), ...order.slice(0, index)], 0);
|
||||
await prisma.encounter.update({ where: { id: req.encounter.id }, data: { activeParticipantId: next ? next.id : null, targetParticipantIds: [] } });
|
||||
} else if (targetParticipantIds.includes(req.params.participantId)) {
|
||||
const left = targetParticipantIds.filter((id) => id !== req.params.participantId);
|
||||
@@ -450,6 +501,49 @@ router.delete('/:id/participants/:participantId', requireEncounterAccess, requir
|
||||
}
|
||||
});
|
||||
|
||||
// A dead character can be brought back in the middle of a battle by an ally:
|
||||
// the character on turn, if they are on their feet and can cast a spell that
|
||||
// raises the dead (revivalSpell). The dead come back at 1 HP, and with that
|
||||
// are back in everyone's list. A player tries it for their own character; the
|
||||
// DM for whoever is on turn. (The DM can also revive anyone outright, through
|
||||
// the character's death saves.)
|
||||
router.post('/:id/participants/:participantId/revive', requireEncounterAccess, async (req, res, next) => {
|
||||
try {
|
||||
const { mode, status, activeParticipantId } = req.encounter;
|
||||
const onTurnId = mode === 'BATTLE' && status === 'ACTIVE' ? activeParticipantId : null;
|
||||
const onTurn = onTurnId
|
||||
? await prisma.encounterParticipant.findFirst({
|
||||
where: { id: onTurnId, encounterId: req.encounter.id },
|
||||
include: { character: { include: { spells: { include: { spell: true } } } } },
|
||||
})
|
||||
: null;
|
||||
const reviver = onTurn?.character;
|
||||
if (!req.isDM && (!reviver || reviver.userId !== req.user.id)) {
|
||||
return res.status(403).json({ error: "You can only try to revive someone on your own character's turn" });
|
||||
}
|
||||
if (!reviver) return res.status(400).json({ error: 'Only a character on turn in a battle can try to revive someone.' });
|
||||
|
||||
const target = await prisma.encounterParticipant.findUnique({ where: { id: req.params.participantId }, include: { character: true } });
|
||||
if (!target || target.encounterId !== req.encounter.id) {
|
||||
return res.status(404).json({ error: 'Combatant not found in this encounter' });
|
||||
}
|
||||
if (!target.character || lifeState(target.character) !== 'DEAD') {
|
||||
return res.status(400).json({ error: `${target.displayName} is not dead.` });
|
||||
}
|
||||
if (lifeState(reviver) !== 'UP') return res.status(400).json({ error: `${reviver.name} is in no state to revive anyone.` });
|
||||
|
||||
const spell = revivalSpell(reviver);
|
||||
if (!spell) return res.status(400).json({ error: `${reviver.name} has no spell that can bring back the dead.` });
|
||||
|
||||
await prisma.character.update({ where: { id: target.character.id }, data: { currentHp: 1, ...NO_DEATH_SAVES } });
|
||||
await prisma.encounterParticipant.update({ where: { id: target.id }, data: { currentHp: 1 } });
|
||||
emitCharacterUpdated(req, target.character.id);
|
||||
await respondWithEncounter(req, res, 200, { revivedWith: spell.name });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Targets — who the combatant on turn is going for
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -460,27 +554,36 @@ const targetsSchema = z.object({ participantIds: z.array(z.string()).max(50) });
|
||||
// Combatants can be marked as targets, for everyone to see; the DM's screen
|
||||
// then offers to apply damage to them. One is an ordinary attack, more is an
|
||||
// area attack. The DM can mark anyone at any time. A player can while their
|
||||
// own character is on turn in a battle.
|
||||
// own character is on turn in a battle. Nobody can mark the combatant who is
|
||||
// on turn: you don't target yourself.
|
||||
router.put('/:id/targets', requireEncounterAccess, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = targetsSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const participantIds = [...new Set(parsed.data.participantIds)];
|
||||
|
||||
// Whoever is on turn, when a battle is under way.
|
||||
const { mode, status, activeParticipantId } = req.encounter;
|
||||
const onTurnId = mode === 'BATTLE' && status === 'ACTIVE' ? activeParticipantId : null;
|
||||
|
||||
if (!req.isDM) {
|
||||
const { mode, status, activeParticipantId } = req.encounter;
|
||||
const onTurn =
|
||||
mode === 'BATTLE' && status === 'ACTIVE' && activeParticipantId
|
||||
? await prisma.encounterParticipant.findFirst({
|
||||
where: { id: activeParticipantId, encounterId: req.encounter.id, character: { userId: req.user.id } },
|
||||
select: { id: true },
|
||||
})
|
||||
: null;
|
||||
if (!onTurn) return res.status(403).json({ error: "You can only pick targets on your own character's turn" });
|
||||
const ownTurn = onTurnId
|
||||
? await prisma.encounterParticipant.findFirst({
|
||||
where: { id: onTurnId, encounterId: req.encounter.id, character: { userId: req.user.id } },
|
||||
select: { id: true },
|
||||
})
|
||||
: null;
|
||||
if (!ownTurn) return res.status(403).json({ error: "You can only pick targets on your own character's turn" });
|
||||
}
|
||||
|
||||
const found = await prisma.encounterParticipant.count({ where: { id: { in: participantIds }, encounterId: req.encounter.id } });
|
||||
if (found !== participantIds.length) return res.status(400).json({ error: 'Combatant not found in this encounter' });
|
||||
if (onTurnId && participantIds.includes(onTurnId)) {
|
||||
return res.status(400).json({ error: 'The combatant on turn cannot be their own target' });
|
||||
}
|
||||
|
||||
// A player can only mark who they can see: to them the fallen are not there.
|
||||
const order = await loadTurnOrder(req.encounter.id);
|
||||
const markable = new Set(order.filter((p) => req.isDM || !isFallen(p)).map((p) => p.id));
|
||||
if (!participantIds.every((id) => markable.has(id))) return res.status(400).json({ error: 'Combatant not found in this encounter' });
|
||||
|
||||
await prisma.encounter.update({ where: { id: req.encounter.id }, data: { targetParticipantIds: participantIds } });
|
||||
await respondWithEncounter(req, res);
|
||||
@@ -493,21 +596,22 @@ router.put('/:id/targets', requireEncounterAccess, async (req, res, next) => {
|
||||
// Turns and rounds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Passes the turn down the initiative order; going past the last combatant
|
||||
// starts the next round.
|
||||
// Passes the turn down the initiative order to the next combatant still
|
||||
// standing (the fallen have no turn); going past the last combatant starts
|
||||
// the next round.
|
||||
router.post('/:id/next-turn', requireEncounterAccess, requireDM, async (req, res, next) => {
|
||||
try {
|
||||
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id } });
|
||||
const order = inTurnOrder(participants);
|
||||
const order = await loadTurnOrder(req.encounter.id);
|
||||
if (order.length === 0) return res.status(400).json({ error: 'Add combatants before taking turns.' });
|
||||
|
||||
const index = order.findIndex((p) => p.id === req.encounter.activeParticipantId);
|
||||
const wrapped = index === order.length - 1;
|
||||
const upNext = standingFrom(order, index + 1);
|
||||
const wrapped = index !== -1 && order.indexOf(upNext) <= index;
|
||||
await prisma.encounter.update({
|
||||
where: { id: req.encounter.id },
|
||||
data: {
|
||||
status: 'ACTIVE',
|
||||
activeParticipantId: order[wrapped || index === -1 ? 0 : index + 1].id,
|
||||
activeParticipantId: upNext.id,
|
||||
targetParticipantIds: [], // the next combatant picks their own
|
||||
...(wrapped ? { currentRound: { increment: 1 } } : {}),
|
||||
},
|
||||
@@ -520,7 +624,7 @@ router.post('/:id/next-turn', requireEncounterAccess, requireDM, async (req, res
|
||||
|
||||
router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req, res, next) => {
|
||||
try {
|
||||
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id } });
|
||||
const order = await loadTurnOrder(req.encounter.id);
|
||||
const updated = await prisma.encounter.update({
|
||||
where: { id: req.encounter.id },
|
||||
data: {
|
||||
@@ -528,7 +632,7 @@ router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req,
|
||||
status: 'ACTIVE',
|
||||
targetParticipantIds: [],
|
||||
// A new round starts back at the top of the initiative order.
|
||||
...(req.encounter.mode === 'BATTLE' ? { activeParticipantId: inTurnOrder(participants)[0]?.id ?? null } : {}),
|
||||
...(req.encounter.mode === 'BATTLE' ? { activeParticipantId: standingFrom(order, 0)?.id ?? null } : {}),
|
||||
},
|
||||
});
|
||||
await syncCharacterLocations(req);
|
||||
|
||||
+18
-12
@@ -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 };
|
||||
|
||||
+12
-4
@@ -334,7 +334,7 @@ HP bar, on their encounter card, in the quick stats and in the party lists.
|
||||
|---|---|---|
|
||||
| Dying | Rolling death saves | Three successes, three failures, healing, or being stabilised |
|
||||
| Stable | At 0 HP, no longer rolling | Healing wakes them; damage makes them Dying again |
|
||||
| Dead | Three failures, or one blow too big to survive | Only the DM's **Revive at 1 HP** |
|
||||
| Dead | Three failures, or one blow too big to survive | The DM's **Revive at 1 HP**, or an ally's reviving spell in a battle |
|
||||
|
||||
**Rolling.** While Dying, type what the d20 showed and click **Death save**.
|
||||
The app does not roll it.
|
||||
@@ -359,7 +359,10 @@ The app does not roll it.
|
||||
- **Healing** from 0, by any amount, puts the character back up and clears
|
||||
the tallies and Stable.
|
||||
- **Dead** characters cannot be healed: Heal is refused with a message. The
|
||||
DM clicks **Revive at 1 HP**, then heals as normal.
|
||||
DM clicks **Revive at 1 HP**, then heals as normal. In a battle, an ally
|
||||
with a reviving spell can do the same on their own turn, and until then the
|
||||
dead are hidden from the players' encounter screen: see
|
||||
[ENCOUNTERS.md](ENCOUNTERS.md), section 6.
|
||||
- The **DM** can click a pip to set either tally by hand, to fix a mistake.
|
||||
|
||||
Dropping to 0 always starts from empty tallies. None of this changes any
|
||||
@@ -578,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
|
||||
|
||||
@@ -776,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
|
||||
|
||||
|
||||
+80
-17
@@ -46,7 +46,7 @@ time from the top of the page.
|
||||
| | Battle | NPC quest |
|
||||
|---|---|---|
|
||||
| For | A fight | A scene built around people and an objective |
|
||||
| Layout | Everyone in one list, in initiative order | Three groups: "People you meet", "The party", "Creatures" |
|
||||
| Layout | Everyone in one list, in initiative order; once in progress, those who have had their turn move to "Next Round" at the bottom | Three groups: "People you meet", "The party", "Creatures" |
|
||||
| Rounds and turns | Yes | No |
|
||||
| Initiative | The d20 roll, typed in per combatant; the bonus is added | The same; each group lists highest first |
|
||||
| Start button | Begin Battle | Begin Quest |
|
||||
@@ -111,7 +111,7 @@ The DM adds people from the "Add to the scene" panel.
|
||||
|
||||
| Kind | Comes from | Can be added more than once |
|
||||
|---|---|---|
|
||||
| Character | The campaign's finished characters | No |
|
||||
| Character | The campaign's finished characters, except dead ones | No |
|
||||
| Monster | The campaign's monster list ([MONSTERS.md](MONSTERS.md)) | Yes |
|
||||
| NPC | The campaign's NPC list ([NPCS.md](NPCS.md)) | No |
|
||||
|
||||
@@ -131,6 +131,10 @@ Details start open for the DM and closed for players. Weapons and spells
|
||||
above a character's level are left out, because the character cannot use
|
||||
them.
|
||||
|
||||
A **dead character cannot be added** to any encounter: they are left out of
|
||||
the list to pick from and the server refuses them, until they are revived. A
|
||||
character who dies stays in the encounters they were already in.
|
||||
|
||||
The DM removes someone with "Remove" on their card. Removing whoever is on
|
||||
turn passes the turn to the next combatant.
|
||||
|
||||
@@ -149,8 +153,7 @@ The DM types an amount and clicks Damage or Heal on a card.
|
||||
monsters and NPCs it depends on the campaign setting "Players see the exact
|
||||
hit points of monsters and NPCs": on (the default) shows the numbers, off
|
||||
shows only the health bar. The DM always sees the numbers.
|
||||
- At 0 HP a monster or NPC is marked **Down** and dimmed. It keeps its place
|
||||
in the order; nothing is removed automatically.
|
||||
- At 0 HP a monster or NPC is **out of the fight**: see "The fallen" below.
|
||||
- A **character** at 0 HP is **Dying** instead, and its card grows a death-save
|
||||
box: see below.
|
||||
- A character's temporary HP is shown beside its HP but is not used up
|
||||
@@ -173,12 +176,54 @@ the card is the same one as on the sheet, and both change the same character.
|
||||
tallies.
|
||||
- **Dead** after three failures, or when one blow leaves as much as the
|
||||
character's max HP still to take after reaching 0. Healing is refused; the
|
||||
DM clicks **Revive at 1 HP**.
|
||||
DM clicks **Revive at 1 HP**, or an ally revives them (below).
|
||||
- The DM can click any pip to set a tally by hand.
|
||||
|
||||
Nothing rolls or advances by itself: the app does not prompt for a save when
|
||||
the turn comes round, and a dying, stable or dead character keeps its place
|
||||
in the order.
|
||||
Nothing rolls by itself: the app does not prompt for a save when the turn
|
||||
comes round. A dying or stable character keeps its place in the order and
|
||||
still gets its turn.
|
||||
|
||||
### The fallen
|
||||
|
||||
A combatant is **out of the fight** when it is:
|
||||
|
||||
- a **monster or NPC at 0 HP** (one with hit points to track), or
|
||||
- a **dead character**. Dying and stable characters are not: they stay in
|
||||
view.
|
||||
|
||||
What that does:
|
||||
|
||||
- **Players no longer see it.** Its card is gone from their screen, along
|
||||
with any target mark on it; the server does not send it to them at all.
|
||||
- **The DM's screen leaves it out as well**, until the DM ticks **Show dead**
|
||||
above the list (the box shows how many there are, and only appears when
|
||||
there are any). Ticked, they are listed in a group of their own at the
|
||||
bottom, "Out of the fight, hidden from players", each with a "Hidden from
|
||||
players" badge, and everything on the card still works. The tick is not
|
||||
remembered: it is off again when the page is reopened.
|
||||
- **It has no turn.** Next Turn, Next Round and Begin Battle pass over it.
|
||||
- It stays in the encounter: nothing is removed, and gold is still split with
|
||||
a dead character.
|
||||
|
||||
It is worked out from hit points each time, not stored, so it undoes itself:
|
||||
heal a monster or NPC above 0, or revive a character, and they are back on
|
||||
everyone's screen and back in the turn order.
|
||||
|
||||
**Reviving a dead character.** Players see dead characters by name only, in a
|
||||
**☠ Fallen** box above the list. There are two ways back:
|
||||
|
||||
- The **DM** ticks Show dead and clicks **Revive at 1 HP** on the dead
|
||||
character's card (or does it on the sheet), at any time.
|
||||
- An **ally** tries it: in a battle in progress, the player whose character is
|
||||
**on turn** gets a **Try revive** button beside each name in the Fallen box,
|
||||
if that character is capable. Capable means on their feet (above 0 HP) and
|
||||
knowing a spell that is ticked **Revives** in the campaign's spell list
|
||||
([SPELLS.md](SPELLS.md)), at a level they can cast. The library's Revivify
|
||||
(level 3) and Raise Dead (level 5) are ticked already.
|
||||
|
||||
Either way the character comes back at 1 HP with the death saves cleared. The
|
||||
app does not use up the spell, check how long ago they died, or end the
|
||||
reviver's turn.
|
||||
|
||||
## 7. Initiative, turns and rounds
|
||||
|
||||
@@ -201,6 +246,14 @@ battles only.
|
||||
- **Next Turn** passes the turn to the next in the order. After the last
|
||||
combatant it returns to the top and the **round** goes up by one.
|
||||
- **Next Round** skips straight to the top of the next round.
|
||||
- Anyone out of the fight (section 6, "The fallen") is passed over.
|
||||
- **The list follows the turn.** While a battle is in progress, whoever is on
|
||||
turn is at the top of "Initiative order" with those still to act this round
|
||||
beneath them. Each combatant whose turn has passed moves down to a **Next
|
||||
Round** section at the bottom, still in initiative order. When the round
|
||||
rolls over everyone is back in "Initiative order". Everyone sees the same
|
||||
arrangement. Before the battle begins, and once it is complete, it is one
|
||||
plain list.
|
||||
|
||||
### Targets
|
||||
|
||||
@@ -212,7 +265,11 @@ healed) right now. One target is an ordinary attack; more than one is an
|
||||
to unmark. Each one marked is added to the targets.
|
||||
- The **DM** can mark anyone at any time, in a battle or a quest. A **player**
|
||||
can while their own character is on turn in a battle, so they can show the
|
||||
DM who they are going for.
|
||||
DM who they are going for. A player cannot mark someone who is out of the
|
||||
fight, and is not shown a mark the DM leaves on one.
|
||||
- The combatant **on turn** cannot be marked: nobody targets themselves, and
|
||||
their card has no Target button until the turn passes. To heal or hurt them
|
||||
on their own turn, use the buttons on their card.
|
||||
- A target's card is outlined in red for everyone and reads "Targeted by
|
||||
<whoever is on turn>".
|
||||
- While anyone is marked the DM gets an **attack bar** above the list, which
|
||||
@@ -229,8 +286,10 @@ healed) right now. One target is an ordinary attack; more than one is an
|
||||
|
||||
An attack on several targets is harder to land and its damage is shared.
|
||||
|
||||
- **Harder to hit.** Every target's AC counts as **2 higher for each target
|
||||
beyond the first**:
|
||||
- **Harder to hit.** Every target's AC counts as higher for each target
|
||||
beyond the first, by the campaign's **area attack AC penalty**. The DM sets
|
||||
it in the campaign settings, from 0 (no penalty) to 10; it starts at 2, which
|
||||
gives:
|
||||
|
||||
| Targets | Added to each AC |
|
||||
|---|---|
|
||||
@@ -251,8 +310,7 @@ An attack on several targets is harder to land and its damage is shared.
|
||||
- A target with no hit points to track is left out of the sharing.
|
||||
|
||||
The app does not roll the attack or decide whether it hits: the DM compares
|
||||
the roll with the AC shown and types the damage. The +2 is fixed in the code
|
||||
(`AREA_AC_STEP` in `frontend/src/pages/EncounterPage.tsx`).
|
||||
the roll with the AC shown and types the damage.
|
||||
|
||||
## 8. Conditions
|
||||
|
||||
@@ -272,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 |
|
||||
|
||||
@@ -283,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
|
||||
@@ -305,6 +363,8 @@ it was and who received it.
|
||||
| Characters | Full card | Full card |
|
||||
| Monsters | Full stat block | Name, HP, conditions |
|
||||
| NPCs | Everything | Name, role, attitude, description, HP bar |
|
||||
| Monsters and NPCs at 0 HP | With "Show dead" ticked, marked hidden | Nothing |
|
||||
| Dead characters | Full card with "Show dead" ticked, marked hidden | The name only, in the Fallen box |
|
||||
| Rewards | All | Only awarded ones |
|
||||
| Controls (add, damage, turns, rewards) | Yes | None |
|
||||
|
||||
@@ -325,7 +385,9 @@ information cannot be dug out of the page.
|
||||
| Enter an initiative roll | Yes, for anyone | For their own character only |
|
||||
| Mark targets | Yes, any time | On their own character's turn |
|
||||
| Enter a death save, or stabilise | Yes, for anyone | For their own character only |
|
||||
| Set death-save tallies by hand, revive the dead | Yes | No |
|
||||
| Set death-save tallies by hand | Yes | No |
|
||||
| Revive a dead character outright | Yes | No |
|
||||
| Try to revive a dead character | Yes, for the character on turn | On their own character's turn, if it has a reviving spell |
|
||||
| Advance turns and rounds | Yes | No |
|
||||
| Add, remove or award rewards | Yes | No |
|
||||
|
||||
@@ -338,6 +400,7 @@ People outside the campaign cannot open its encounters.
|
||||
- Applying an attack's damage automatically
|
||||
- Death saves for monsters and NPCs, and prompting for a save on a dying
|
||||
character's turn
|
||||
- A roll, a spell slot or a time limit for reviving the dead
|
||||
- Conditions with durations or mechanical effects
|
||||
- Maps, positions, distances and areas of effect
|
||||
- Experience points and encounter difficulty
|
||||
|
||||
+18
-13
@@ -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
|
||||
|
||||
|
||||
+4
-3
@@ -46,15 +46,16 @@ The list is the "Spell List" card on the campaign page (DM only).
|
||||
| What it does | One sentence. Shown wherever the spell is shown |
|
||||
| Damage | Picked from a list, or "No damage". Fixed; it does not grow |
|
||||
| Starting option | Whether a brand-new character may pick it in the builder (section 8) |
|
||||
| Revives | Whether it brings back the dead. A character who can cast one may revive a dead ally on their turn in a battle ([ENCOUNTERS.md](ENCOUNTERS.md), section 6). Ticked already on the library's Revivify and Raise Dead |
|
||||
| Casting time, range, duration | Stored for library spells but not shown on any screen |
|
||||
|
||||
A cantrip is a spell of level 0. The app always calls it a **cantrip**, never
|
||||
"level 0" or "level 1".
|
||||
|
||||
- **Adding:** by hand with the "+ Add Spell" form (name, level, school,
|
||||
damage and what it does), or from the library.
|
||||
- **Changing:** damage (the drop-down in the table) and the starting-option
|
||||
tick box. Name, level, school and description cannot be changed on screen
|
||||
damage, what it does and the two tick boxes), or from the library.
|
||||
- **Changing:** damage (the drop-down in the table) and the Revives and
|
||||
starting-option tick boxes. Name, level, school and description cannot be changed on screen
|
||||
once created (the API accepts it); remove and re-add instead.
|
||||
- **Damage choices:** the drop-down offers "No damage", then 1 to 6, 8 or 10
|
||||
dice of d4, d6, d8, d10 or d12 (1d4 up to 10d12), plus the three unusual
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.7.0",
|
||||
"version": "1.9.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -463,6 +463,7 @@ export function usePatchPoolEntry(campaignId: string, pool: Pool) {
|
||||
isStartingOption?: boolean;
|
||||
damageDice?: string | null;
|
||||
damageScaling?: number;
|
||||
revives?: boolean;
|
||||
minLevel?: number;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
@@ -536,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({
|
||||
@@ -760,6 +775,19 @@ export function useSetTargets(encounterId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// The character on turn trying to bring a dead ally back; the server decides
|
||||
// whether they have the means.
|
||||
export function useReviveParticipant(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (participantId: string) => (await apiClient.post(`/encounters/${encounterId}/participants/${participantId}/revive`)).data,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
|
||||
qc.invalidateQueries({ queryKey: ['character'] }); // the revived character's sheet
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useNextTurn(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -93,6 +93,8 @@ export interface Campaign {
|
||||
startingSpellCount: number;
|
||||
skipMotivationRoll: boolean;
|
||||
showMonsterHp: boolean; // off: players get a health bar for monsters and NPCs, no numbers
|
||||
// Added to each target's AC for every target beyond the first in an area attack; 0 is off.
|
||||
areaAttackAcPenalty: number;
|
||||
ownerId: string;
|
||||
archivedAt: string | null; // set while the campaign is archived
|
||||
role: 'DM' | 'PLAYER';
|
||||
@@ -106,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
|
||||
@@ -133,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;
|
||||
@@ -213,6 +218,7 @@ export interface Spell {
|
||||
description: string | null;
|
||||
damageDice: string | null;
|
||||
isStartingOption: boolean;
|
||||
revives: boolean; // brings back the dead: lets its caster revive a dead ally in a battle
|
||||
}
|
||||
|
||||
export interface CharacterSpell {
|
||||
@@ -300,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;
|
||||
@@ -400,6 +407,7 @@ export interface CharacterCombatInfo {
|
||||
armorClass: number;
|
||||
tempHp: number;
|
||||
deathSaves: DeathSaves;
|
||||
canRevive: boolean; // has a spell that brings back the dead, at a level they can cast
|
||||
speed: number;
|
||||
initiativeBonus: number;
|
||||
passivePerception: number;
|
||||
@@ -431,6 +439,9 @@ export interface EncounterParticipant {
|
||||
conditions: string[];
|
||||
isMonster: boolean;
|
||||
kind: 'CHARACTER' | 'MONSTER' | 'NPC';
|
||||
// Out of the fight (a dead character, or a monster or NPC at 0 HP) and so
|
||||
// hidden from the players. Only the DM is ever sent one of these.
|
||||
hidden: boolean;
|
||||
npcId: string | null;
|
||||
character?: { id: string; name: string; userId: string | null } | null;
|
||||
// The DM gets the whole stat block; players only the id and name.
|
||||
@@ -458,7 +469,10 @@ export interface Encounter {
|
||||
archivedAt: string | null; // archived encounters only reach the DM's list
|
||||
sortOrder: number; // place in the campaign's own order, lowest first
|
||||
createdAt: string;
|
||||
participants?: EncounterParticipant[];
|
||||
participants?: EncounterParticipant[]; // a player's list leaves out the fallen
|
||||
// The dead characters in the scene. Players do not get their cards, only
|
||||
// this, so that an ally can try to revive them.
|
||||
fallen?: { participantId: string; characterId: string; name: string }[];
|
||||
rewards?: EncounterReward[]; // players only receive the awarded ones
|
||||
}
|
||||
|
||||
@@ -495,6 +509,7 @@ export interface LibrarySpell {
|
||||
duration: string | null;
|
||||
description: string | null;
|
||||
damageDice?: string; // default damage; absent for spells that deal none
|
||||
revives?: boolean; // brings back the dead
|
||||
classes: CharacterClass[];
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ function Pips({ kind, count, onSet }: { kind: 'success' | 'failure'; count: numb
|
||||
|
||||
// What happens to a character at 0 HP: the death-save tallies, and the ways
|
||||
// out of them. `canAct` is the character's own player or the DM; setting the
|
||||
// tallies by hand and raising the dead are the DM's alone.
|
||||
// tallies by hand and raising the dead from here are the DM's alone.
|
||||
export function DeathSaves({
|
||||
characterId,
|
||||
name,
|
||||
@@ -78,7 +78,7 @@ export function DeathSaves({
|
||||
</>
|
||||
)}
|
||||
{state === 'STABLE' && <span className="combatant-sub">Out cold at 0 HP, but no longer rolling. Any healing wakes them; damage starts the saves again.</span>}
|
||||
{state === 'DEAD' && <span className="combatant-sub">{isDM ? 'Healing does nothing now.' : 'Only the DM can bring them back.'}</span>}
|
||||
{state === 'DEAD' && <span className="combatant-sub">{isDM ? 'Healing does nothing now.' : 'The DM can bring them back, or an ally with a reviving spell on their turn in a battle.'}</span>}
|
||||
</div>
|
||||
|
||||
{state === 'DYING' && canAct && (
|
||||
|
||||
@@ -828,6 +828,11 @@ table.data-table tbody tr:hover {
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
/* What players see of the dead: names, and a way to try to bring them back. */
|
||||
.fallen-bar {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
/* The DM's attack bar stays in reach while scrolling the initiative order. */
|
||||
.attack-bar {
|
||||
position: sticky;
|
||||
|
||||
+20
-11
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -489,6 +629,7 @@ export function CampaignHomePage() {
|
||||
startingSpellCount: Number(fd.get('startingSpellCount')),
|
||||
skipMotivationRoll: fd.get('skipMotivationRoll') === 'on',
|
||||
showMonsterHp: fd.get('showMonsterHp') === 'on',
|
||||
areaAttackAcPenalty: Number(fd.get('areaAttackAcPenalty')),
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -514,6 +655,19 @@ export function CampaignHomePage() {
|
||||
<input type="checkbox" name="showMonsterHp" defaultChecked={campaign.showMonsterHp} />
|
||||
Players see the exact hit points of monsters and NPCs in encounters (untick for a health bar only)
|
||||
</label>
|
||||
<label className="flex-row flex-wrap gap-sm mt-sm">
|
||||
Area attack AC penalty
|
||||
<NumberInput
|
||||
name="areaAttackAcPenalty"
|
||||
min={0}
|
||||
max={10}
|
||||
keepValue
|
||||
defaultValue={campaign.areaAttackAcPenalty}
|
||||
style={{ width: 70 }}
|
||||
title="An attack on several targets is harder to land: each target's AC counts as this much higher for every target beyond the first. 0 turns the penalty off."
|
||||
/>
|
||||
<span className="combatant-sub">added to each target's AC for every target beyond the first (0 for none)</span>
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary mt-md" disabled={updateCampaign.isPending}>
|
||||
Save Settings
|
||||
</button>
|
||||
@@ -1115,6 +1269,7 @@ export function CampaignHomePage() {
|
||||
description: String(fd.get('description') || '').trim() || null,
|
||||
damageDice: String(fd.get('damageDice') || '') || null,
|
||||
isStartingOption: fd.get('isStartingOption') === 'on',
|
||||
revives: fd.get('revives') === 'on',
|
||||
});
|
||||
(e.target as HTMLFormElement).reset();
|
||||
}}
|
||||
@@ -1135,6 +1290,10 @@ export function CampaignHomePage() {
|
||||
<input type="checkbox" name="isStartingOption" />
|
||||
Starting option
|
||||
</label>
|
||||
<label className="checkbox-row" title="A character who can cast it may revive a dead ally on their turn in a battle">
|
||||
<input type="checkbox" name="revives" />
|
||||
Revives the dead
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary">Add</button>
|
||||
</form>
|
||||
)}
|
||||
@@ -1145,6 +1304,7 @@ export function CampaignHomePage() {
|
||||
<th>Spell</th>
|
||||
<th>Level</th>
|
||||
<th>Damage</th>
|
||||
<th title="A character who can cast it may revive a dead ally on their turn in a battle">Revives</th>
|
||||
<th>Starting</th>
|
||||
<th />
|
||||
</tr>
|
||||
@@ -1160,6 +1320,14 @@ export function CampaignHomePage() {
|
||||
<td>
|
||||
<SpellDamageSelect spellName={s.name} value={s.damageDice} onChange={(damageDice) => patchSpell.mutate({ id: s.id, damageDice })} />
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`${s.name} revives the dead`}
|
||||
checked={s.revives}
|
||||
onChange={(e) => patchSpell.mutate({ id: s.id, revives: e.target.checked })}
|
||||
/>
|
||||
</td>
|
||||
<PoolRowControls
|
||||
entry={s}
|
||||
onToggleStarting={(isStartingOption) => patchSpell.mutate({ id: s.id, isStartingOption })}
|
||||
@@ -1180,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>
|
||||
@@ -1191,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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
useAdvanceRound,
|
||||
useNextTurn,
|
||||
useSetTargets,
|
||||
useReviveParticipant,
|
||||
usePatchEncounter,
|
||||
useSetEncounterArchived,
|
||||
useDeleteEncounter,
|
||||
@@ -29,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,
|
||||
@@ -64,9 +65,9 @@ const CONDITIONS = [
|
||||
];
|
||||
|
||||
// An attack on more than one target is an area attack, and harder to land:
|
||||
// every target's AC counts as this much higher for each target beyond the first.
|
||||
const AREA_AC_STEP = 2;
|
||||
const areaPenalty = (targetCount: number) => Math.max(0, targetCount - 1) * AREA_AC_STEP;
|
||||
// every target's AC counts as `step` higher for each target beyond the first.
|
||||
// The step is the campaign's "area attack AC penalty" setting.
|
||||
const areaPenalty = (targetCount: number, step: number) => Math.max(0, targetCount - 1) * step;
|
||||
|
||||
const KIND_LABELS = { CHARACTER: 'PC', MONSTER: 'Monster', NPC: 'NPC' } as const;
|
||||
const KIND_BADGES = { CHARACTER: 'badge-player', MONSTER: 'badge-hostile', NPC: 'badge-friendly' } as const;
|
||||
@@ -124,7 +125,7 @@ function CombatantCard({
|
||||
// What an area attack adds to this target's AC; 0 when they are the only one.
|
||||
acPenalty: number;
|
||||
// Marks this combatant as a target, or unmarks them. Absent for someone who
|
||||
// may not pick targets right now.
|
||||
// may not pick targets right now, and on the card of whoever is on turn.
|
||||
onTarget?: () => void;
|
||||
onRemove: () => void;
|
||||
onError: (err: unknown) => void;
|
||||
@@ -188,6 +189,11 @@ function CombatantCard({
|
||||
)}
|
||||
{npc && <span className={`badge badge-${npc.disposition.toLowerCase()}`}>{npc.disposition}</span>}
|
||||
{isDown && !info && <span className="badge badge-hostile">Down</span>}
|
||||
{p.hidden && (
|
||||
<span className="badge" title="Out of the fight. Players no longer see this card, and it has no turn.">
|
||||
Hidden from players
|
||||
</span>
|
||||
)}
|
||||
{onTarget && (
|
||||
<button
|
||||
className={`btn btn-target${isTargeted ? ' is-on' : ''}`}
|
||||
@@ -468,12 +474,14 @@ function shares(amount: number, count: number, mode: AreaMode): number[] {
|
||||
function AttackBar({
|
||||
attacker,
|
||||
targets,
|
||||
acStep,
|
||||
onApply,
|
||||
onRemove,
|
||||
onClear,
|
||||
}: {
|
||||
attacker: EncounterParticipant | undefined;
|
||||
targets: EncounterParticipant[];
|
||||
acStep: number; // the campaign's area attack AC penalty
|
||||
onApply: (changes: { participantId: string; currentHp: number }[]) => void;
|
||||
onRemove: (participantId: string) => void;
|
||||
onClear: () => void;
|
||||
@@ -483,13 +491,12 @@ function AttackBar({
|
||||
const [missed, setMissed] = useState<string[]>([]);
|
||||
|
||||
const isArea = targets.length > 1;
|
||||
const penalty = areaPenalty(targets.length);
|
||||
const penalty = areaPenalty(targets.length, acStep);
|
||||
const tracksHp = (t: EncounterParticipant) => (t.maxHp ?? 0) > 0;
|
||||
const hit = targets.filter((t) => tracksHp(t) && !missed.includes(t.id));
|
||||
const value = Number(amount) || 0;
|
||||
const portions = shares(value, hit.length, isArea ? mode : 'EACH');
|
||||
const selfOnly = targets.length === 1 && targets[0].id === attacker?.id;
|
||||
const attacks = attacker && !selfOnly ? attackLines(attacker) : [];
|
||||
const attacks = attacker ? attackLines(attacker) : [];
|
||||
|
||||
function apply(sign: 1 | -1) {
|
||||
if (!value || hit.length === 0) return;
|
||||
@@ -502,7 +509,7 @@ function AttackBar({
|
||||
<div className="card attack-bar">
|
||||
<div className="flex-between flex-wrap">
|
||||
<div className="attack-bar-title">
|
||||
{attacker && !selfOnly && <>{attacker.displayName} → </>}🎯 {isArea ? `${targets.length} targets` : targets[0].displayName}
|
||||
{attacker && <>{attacker.displayName} → </>}🎯 {isArea ? `${targets.length} targets` : targets[0].displayName}
|
||||
</div>
|
||||
<button className="btn btn-ghost" onClick={onClear}>
|
||||
✕ Clear {isArea ? 'targets' : 'target'}
|
||||
@@ -510,7 +517,8 @@ function AttackBar({
|
||||
</div>
|
||||
{isArea && (
|
||||
<p className="combatant-sub" style={{ margin: '0.25rem 0 0' }}>
|
||||
Area attack: every target's AC counts as {penalty} higher (+{AREA_AC_STEP} for each target beyond the first). Untick anyone the roll missed.
|
||||
Area attack{penalty > 0 && `: every target's AC counts as ${penalty} higher (+${acStep} for each target beyond the first)`}. Untick anyone the
|
||||
roll missed.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -598,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}`;
|
||||
}
|
||||
@@ -842,6 +850,7 @@ export function EncounterPage() {
|
||||
const advanceRound = useAdvanceRound(encounterId);
|
||||
const nextTurn = useNextTurn(encounterId);
|
||||
const setTargets = useSetTargets(encounterId);
|
||||
const revive = useReviveParticipant(encounterId);
|
||||
const patchEncounter = usePatchEncounter(encounterId);
|
||||
const setArchived = useSetEncounterArchived(encounter?.campaignId ?? '');
|
||||
const deleteEncounter = useDeleteEncounter(encounter?.campaignId ?? '');
|
||||
@@ -851,24 +860,39 @@ export function EncounterPage() {
|
||||
const [pickId, setPickId] = useState('');
|
||||
const [initiative, setInitiative] = useState('');
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
// The DM's own screen leaves the fallen out too, until asked for.
|
||||
const [showDead, setShowDead] = useState(false);
|
||||
|
||||
if (isLoading) return <Spinner label="Setting the scene…" />;
|
||||
if (error || !encounter) return <ErrorBanner message="Could not load this encounter." />;
|
||||
|
||||
const isBattle = encounter.mode === 'BATTLE';
|
||||
const participants = encounter.participants ?? []; // already in initiative order
|
||||
// The fallen: dead characters, and monsters and NPCs at 0 HP. Only the DM is
|
||||
// sent their cards, and they sit apart from those still in the fight.
|
||||
const standing = participants.filter((p) => !p.hidden);
|
||||
const outOfTheFight = participants.filter((p) => p.hidden);
|
||||
const fallen = encounter.fallen ?? [];
|
||||
const active = isBattle && encounter.status === 'ACTIVE' ? participants.find((p) => p.id === encounter.activeParticipantId) : undefined;
|
||||
const targetIds = encounter.targetParticipantIds ?? [];
|
||||
const targets = targetIds.map((id) => participants.find((p) => p.id === id)).filter((p): p is EncounterParticipant => p !== undefined);
|
||||
// The DM marks targets whenever they like; a player while their own character is on turn.
|
||||
const acStep = campaignData?.campaign.areaAttackAcPenalty ?? 0;
|
||||
const canTarget = isDM || (active?.character != null && active.character.userId === user?.id);
|
||||
// A player whose own character is on turn, on their feet and able to raise the dead.
|
||||
const canTryRevive =
|
||||
active?.character != null &&
|
||||
active.character.userId === user?.id &&
|
||||
active.characterCombatInfo?.deathSaves.state === 'UP' &&
|
||||
active.characterCombatInfo.canRevive;
|
||||
const builtCharacters = characters?.filter((c) => c.buildStage === 'COMPLETE') ?? [];
|
||||
const onError = (err: unknown) => setFormError(extractErrorMessage(err));
|
||||
const fail = { onError };
|
||||
|
||||
const pickOptions: { id: string; name: string }[] =
|
||||
pickType === 'character'
|
||||
? builtCharacters.filter((c) => !participants.some((p) => p.characterId === c.id))
|
||||
? // The dead join no new scene until they are revived.
|
||||
builtCharacters.filter((c) => c.deathSaves.state !== 'DEAD' && !participants.some((p) => p.characterId === c.id))
|
||||
: pickType === 'monster'
|
||||
? (monsters ?? [])
|
||||
: (npcs ?? []).filter((n) => !participants.some((p) => p.npcId === n.id));
|
||||
@@ -890,10 +914,11 @@ export function EncounterPage() {
|
||||
isActive={p.id === active?.id}
|
||||
onPatch={(data) => patchParticipant.mutate({ participantId: p.id, ...data }, fail)}
|
||||
canRollInitiative={isDM || (p.character != null && p.character.userId === user?.id)}
|
||||
targetedBy={targetIds.includes(p.id) ? (active && active.id !== p.id ? active.displayName : '') : undefined}
|
||||
acPenalty={areaPenalty(targets.length)}
|
||||
targetedBy={targetIds.includes(p.id) ? (active?.displayName ?? '') : undefined}
|
||||
acPenalty={areaPenalty(targets.length, acStep)}
|
||||
onTarget={
|
||||
canTarget
|
||||
// Nobody targets themselves: the combatant on turn has no button.
|
||||
canTarget && p.id !== active?.id
|
||||
? () => setTargets.mutate(targetIds.includes(p.id) ? targetIds.filter((id) => id !== p.id) : [...targetIds, p.id], fail)
|
||||
: undefined
|
||||
}
|
||||
@@ -902,15 +927,26 @@ export function EncounterPage() {
|
||||
/>
|
||||
);
|
||||
|
||||
// In a battle under way the turn passes down the initiative order, so
|
||||
// everyone above whoever is on turn has had their go this round. They drop
|
||||
// to "Next Round" at the bottom, which leaves the combatant on turn at the
|
||||
// top with those still to act beneath them. A new round puts everyone back.
|
||||
const turnIndex = active ? participants.indexOf(active) : -1;
|
||||
const hasActed = (p: EncounterParticipant) => participants.indexOf(p) < turnIndex;
|
||||
|
||||
// A quest scene is grouped by who is there; within each group the order is
|
||||
// still initiative, highest first.
|
||||
const groups: [string, EncounterParticipant[]][] = isBattle
|
||||
? [['Initiative order', participants]]
|
||||
? [
|
||||
['Initiative order', standing.filter((p) => !hasActed(p))],
|
||||
['Next Round', standing.filter(hasActed)],
|
||||
]
|
||||
: [
|
||||
['People you meet', participants.filter((p) => p.kind === 'NPC')],
|
||||
['The party', participants.filter((p) => p.kind === 'CHARACTER')],
|
||||
['Creatures', participants.filter((p) => p.kind === 'MONSTER')],
|
||||
['People you meet', standing.filter((p) => p.kind === 'NPC')],
|
||||
['The party', standing.filter((p) => p.kind === 'CHARACTER')],
|
||||
['Creatures', standing.filter((p) => p.kind === 'MONSTER')],
|
||||
];
|
||||
if (showDead) groups.push(['Out of the fight, hidden from players', outOfTheFight]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -1037,12 +1073,47 @@ export function EncounterPage() {
|
||||
<AttackBar
|
||||
attacker={active}
|
||||
targets={targets}
|
||||
acStep={acStep}
|
||||
onApply={(changes) => Promise.all(changes.map((change) => patchParticipant.mutateAsync(change))).catch(onError)}
|
||||
onRemove={(id) => setTargets.mutate(targetIds.filter((x) => x !== id), fail)}
|
||||
onClear={() => setTargets.mutate([], fail)}
|
||||
/>
|
||||
)}
|
||||
{participants.length === 0 && (
|
||||
{!isDM && fallen.length > 0 && (
|
||||
<div className="card fallen-bar">
|
||||
<div className="card-title">☠ Fallen</div>
|
||||
{fallen.map((f) => (
|
||||
<div key={f.participantId} className="flex-between flex-wrap">
|
||||
<span className="combatant-name">{f.name}</span>
|
||||
{canTryRevive && (
|
||||
<button
|
||||
className="btn"
|
||||
title={`${active?.displayName} tries to bring ${f.name} back, at 1 HP.`}
|
||||
disabled={revive.isPending}
|
||||
onClick={() => {
|
||||
setFormError(null);
|
||||
revive.mutate(f.participantId, fail);
|
||||
}}
|
||||
>
|
||||
Try revive
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<p className="combatant-sub" style={{ margin: '0.4rem 0 0' }}>
|
||||
{canTryRevive
|
||||
? `${active?.displayName} has a spell that can bring back the dead.`
|
||||
: 'A character with a spell that brings back the dead can try to revive them on their own turn.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isDM && outOfTheFight.length > 0 && (
|
||||
<label className="checkbox-row mt-md" title="Dead characters, and monsters and NPCs at 0 HP. Players never see them.">
|
||||
<input type="checkbox" checked={showDead} onChange={(e) => setShowDead(e.target.checked)} />
|
||||
Show dead ({outOfTheFight.length})
|
||||
</label>
|
||||
)}
|
||||
{participants.length === 0 && fallen.length === 0 && (
|
||||
<div className="empty-state card">
|
||||
{isDM
|
||||
? 'Nobody here yet. Add characters, monsters or NPCs from the panel alongside.'
|
||||
|
||||
Generated
+4
-4
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.7.0",
|
||||
"version": "1.9.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.7.0",
|
||||
"version": "1.9.0",
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend"
|
||||
]
|
||||
},
|
||||
"backend": {
|
||||
"version": "1.7.0",
|
||||
"version": "1.9.0",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.20.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
@@ -31,7 +31,7 @@
|
||||
}
|
||||
},
|
||||
"frontend": {
|
||||
"version": "1.7.0",
|
||||
"version": "1.9.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"axios": "^1.20.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.7.0",
|
||||
"version": "1.9.0",
|
||||
"private": true,
|
||||
"description": "D&D 5e campaign manager — monorepo root",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user