Compare commits

..
4 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 f764878960 V1.8.1: combatants who have had their turn move to "Next Round"
- In a battle in progress, whoever is on turn is at the top of the list
  with those still to act beneath them. Each combatant whose turn has
  passed drops to a "Next Round" section at the bottom, and a new round
  puts everyone back.
- All packages at version 1.8.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:49:44 +02:00
joshuaandClaude Opus 5.5 6b466d497e V1.8.0: the fallen are hidden, and allies can revive the dead
- A dead character, or a monster or NPC at 0 HP, is out of the fight: the
  server no longer sends it to players, target marks included, and it has
  no turn. Healing or reviving brings it straight back.
- The DM's screen leaves the fallen out too until "Show dead" is ticked,
  which lists them in a group of their own, marked hidden.
- Players see dead characters by name in a Fallen box. The player whose
  character is on turn in a battle gets "Try revive" there if that
  character is on their feet and knows a spell that revives, at a level
  they can cast. The dead come back at 1 HP.
- Spells have a "Revives" tick in the campaign's spell list. Revivify and
  Raise Dead have it, in the library and in existing campaigns.
- A dead character cannot be added to an encounter until revived.
- All packages at version 1.8.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:25:46 +02:00
joshuaandClaude Opus 5.5 f8462a6e31 V1.7.2: the combatant on turn cannot be their own target
- In a battle under way, whoever is on turn has no Target button on their
  card, and the server refuses a target list that includes them.
- All packages at version 1.7.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:28:28 +02:00
joshuaandClaude Opus 5.5 754eb0ee68 V1.7.1: area attack AC penalty is a campaign setting
- The amount an area attack adds to each target's AC per extra target is
  now set per campaign, from 0 (no penalty) to 10. It starts at 2, so
  existing campaigns behave as before.
- The DM changes it in the campaign settings; the attack bar and the
  targets' cards use it.
- All packages at version 1.7.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:11:46 +02:00
20 changed files with 495 additions and 84 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.7.0",
"version": "1.8.1",
"private": true,
"type": "commonjs",
"main": "src/index.js",
@@ -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');
+6
View File
@@ -168,6 +168,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.
@@ -455,6 +458,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[]
+2 -2
View File
@@ -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'] },
];
+98 -3
View File
@@ -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);
@@ -1839,7 +1839,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 +1950,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 +2119,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', () => {
+3
View File
@@ -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
@@ -403,6 +405,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) => {
+136 -32
View File
@@ -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);
+5 -2
View File
@@ -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
+77 -14
View File
@@ -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
@@ -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
+4 -3
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.7.0",
"version": "1.8.1",
"type": "module",
"scripts": {
"dev": "vite",
+14
View File
@@ -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;
@@ -760,6 +761,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({
+12 -1
View File
@@ -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';
@@ -213,6 +215,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 {
@@ -400,6 +403,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 +435,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 +465,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 +505,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[];
}
+2 -2
View File
@@ -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 && (
+5
View File
@@ -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;
+28
View File
@@ -489,6 +489,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 +515,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 +1129,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 +1150,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 +1164,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 +1180,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 })}
+89 -18
View File
@@ -15,6 +15,7 @@ import {
useAdvanceRound,
useNextTurn,
useSetTargets,
useReviveParticipant,
usePatchEncounter,
useSetEncounterArchived,
useDeleteEncounter,
@@ -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>
)}
@@ -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.'
+4 -4
View File
@@ -1,19 +1,19 @@
{
"name": "dnd-campaign-manager",
"version": "1.7.0",
"version": "1.8.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dnd-campaign-manager",
"version": "1.7.0",
"version": "1.8.1",
"workspaces": [
"backend",
"frontend"
]
},
"backend": {
"version": "1.7.0",
"version": "1.8.1",
"dependencies": {
"@prisma/client": "^5.20.0",
"bcrypt": "^5.1.1",
@@ -31,7 +31,7 @@
}
},
"frontend": {
"version": "1.7.0",
"version": "1.8.1",
"dependencies": {
"@tanstack/react-query": "^5.103.2",
"axios": "^1.20.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dnd-campaign-manager",
"version": "1.7.0",
"version": "1.8.1",
"private": true,
"description": "D&D 5e campaign manager — monorepo root",
"workspaces": [