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>
This commit is contained in:
2026-10-06 14:25:46 +02:00
co-authored by Claude Opus 5.5
parent f8462a6e31
commit 6b466d497e
19 changed files with 392 additions and 57 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.7.2",
"version": "1.8.0",
"private": true,
"type": "commonjs",
"main": "src/index.js",
@@ -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');
+3
View File
@@ -458,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'] },
];
+78 -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 });
});
});
@@ -2117,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', () => {
+1
View File
@@ -405,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) => {
+119 -22
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
// ---------------------------------------------------------------------------
@@ -486,8 +580,10 @@ router.put('/:id/targets', requireEncounterAccess, async (req, res, next) => {
return res.status(400).json({ error: 'The combatant on turn cannot be their own target' });
}
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' });
// 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);
@@ -500,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 } } : {}),
},
@@ -527,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: {
@@ -535,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);