From 6b466d497e560d03c01108c27afd1e73af84d0a4 Mon Sep 17 00:00:00 2001 From: Joshua Oosthuizen Date: Tue, 6 Oct 2026 14:25:46 +0200 Subject: [PATCH] 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 --- backend/package.json | 2 +- .../migration.sql | 5 + backend/prisma/schema.prisma | 3 + backend/src/game/data/spellsLibrary.js | 4 +- backend/src/routes/api.integration.test.js | 81 +++++++++- backend/src/routes/campaigns.routes.js | 1 + backend/src/routes/encounters.routes.js | 141 +++++++++++++++--- docs/CHARACTERS.md | 7 +- docs/ENCOUNTERS.md | 70 +++++++-- docs/SPELLS.md | 7 +- frontend/package.json | 2 +- frontend/src/api/queries.ts | 14 ++ frontend/src/api/types.ts | 11 +- frontend/src/components/DeathSaves.tsx | 4 +- frontend/src/index.css | 5 + frontend/src/pages/CampaignHomePage.tsx | 14 ++ frontend/src/pages/EncounterPage.tsx | 68 ++++++++- package-lock.json | 8 +- package.json | 2 +- 19 files changed, 392 insertions(+), 57 deletions(-) create mode 100644 backend/prisma/migrations/20261007210000_spell_revives/migration.sql diff --git a/backend/package.json b/backend/package.json index 1b9d250..2089263 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "backend", - "version": "1.7.2", + "version": "1.8.0", "private": true, "type": "commonjs", "main": "src/index.js", diff --git a/backend/prisma/migrations/20261007210000_spell_revives/migration.sql b/backend/prisma/migrations/20261007210000_spell_revives/migration.sql new file mode 100644 index 0000000..001697b --- /dev/null +++ b/backend/prisma/migrations/20261007210000_spell_revives/migration.sql @@ -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'); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b4924f2..dc9d6ec 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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[] diff --git a/backend/src/game/data/spellsLibrary.js b/backend/src/game/data/spellsLibrary.js index 83fd7d8..f4fe1cc 100644 --- a/backend/src/game/data/spellsLibrary.js +++ b/backend/src/game/data/spellsLibrary.js @@ -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'] }, ]; diff --git a/backend/src/routes/api.integration.test.js b/backend/src/routes/api.integration.test.js index 58d6401..fe47cff 100644 --- a/backend/src/routes/api.integration.test.js +++ b/backend/src/routes/api.integration.test.js @@ -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', () => { diff --git a/backend/src/routes/campaigns.routes.js b/backend/src/routes/campaigns.routes.js index 1b3cc5e..f87f84e 100644 --- a/backend/src/routes/campaigns.routes.js +++ b/backend/src/routes/campaigns.routes.js @@ -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) => { diff --git a/backend/src/routes/encounters.routes.js b/backend/src/routes/encounters.routes.js index 9df2c14..f6531ff 100644 --- a/backend/src/routes/encounters.routes.js +++ b/backend/src/routes/encounters.routes.js @@ -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); diff --git a/docs/CHARACTERS.md b/docs/CHARACTERS.md index fecb744..42e9bf7 100644 --- a/docs/CHARACTERS.md +++ b/docs/CHARACTERS.md @@ -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 diff --git a/docs/ENCOUNTERS.md b/docs/ENCOUNTERS.md index 6beb949..8bfebfe 100644 --- a/docs/ENCOUNTERS.md +++ b/docs/ENCOUNTERS.md @@ -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,7 @@ 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. ### Targets @@ -212,7 +258,8 @@ 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. @@ -309,6 +356,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 | @@ -329,7 +378,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 | @@ -342,6 +393,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 diff --git a/docs/SPELLS.md b/docs/SPELLS.md index 914b1b3..68a961d 100644 --- a/docs/SPELLS.md +++ b/docs/SPELLS.md @@ -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 diff --git a/frontend/package.json b/frontend/package.json index fded661..a1df1e1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.7.2", + "version": "1.8.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/queries.ts b/frontend/src/api/queries.ts index 1a34583..695a78c 100644 --- a/frontend/src/api/queries.ts +++ b/frontend/src/api/queries.ts @@ -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({ diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index baa1985..c3f25c6 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -215,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 { @@ -402,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; @@ -433,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. @@ -460,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 } @@ -497,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[]; } diff --git a/frontend/src/components/DeathSaves.tsx b/frontend/src/components/DeathSaves.tsx index a0b550e..c13b14a 100644 --- a/frontend/src/components/DeathSaves.tsx +++ b/frontend/src/components/DeathSaves.tsx @@ -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' && Out cold at 0 HP, but no longer rolling. Any healing wakes them; damage starts the saves again.} - {state === 'DEAD' && {isDM ? 'Healing does nothing now.' : 'Only the DM can bring them back.'}} + {state === 'DEAD' && {isDM ? 'Healing does nothing now.' : 'The DM can bring them back, or an ally with a reviving spell on their turn in a battle.'}} {state === 'DYING' && canAct && ( diff --git a/frontend/src/index.css b/frontend/src/index.css index ea2e94c..23d49f3 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; diff --git a/frontend/src/pages/CampaignHomePage.tsx b/frontend/src/pages/CampaignHomePage.tsx index ce15fde..253b11b 100644 --- a/frontend/src/pages/CampaignHomePage.tsx +++ b/frontend/src/pages/CampaignHomePage.tsx @@ -1129,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(); }} @@ -1149,6 +1150,10 @@ export function CampaignHomePage() { Starting option + )} @@ -1159,6 +1164,7 @@ export function CampaignHomePage() { Spell Level Damage + Revives Starting @@ -1174,6 +1180,14 @@ export function CampaignHomePage() { patchSpell.mutate({ id: s.id, damageDice })} /> + + patchSpell.mutate({ id: s.id, revives: e.target.checked })} + /> + patchSpell.mutate({ id: s.id, isStartingOption })} diff --git a/frontend/src/pages/EncounterPage.tsx b/frontend/src/pages/EncounterPage.tsx index 26e3d94..c29132a 100644 --- a/frontend/src/pages/EncounterPage.tsx +++ b/frontend/src/pages/EncounterPage.tsx @@ -15,6 +15,7 @@ import { useAdvanceRound, useNextTurn, useSetTargets, + useReviveParticipant, usePatchEncounter, useSetEncounterArchived, useDeleteEncounter, @@ -188,6 +189,11 @@ function CombatantCard({ )} {npc && {npc.disposition}} {isDown && !info && Down} + {p.hidden && ( + + Hidden from players + + )} {onTarget && ( + )} + + ))} +

+ {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.'} +

+ + )} + {isDM && outOfTheFight.length > 0 && ( + + )} + {participants.length === 0 && fallen.length === 0 && (
{isDM ? 'Nobody here yet. Add characters, monsters or NPCs from the panel alongside.' diff --git a/package-lock.json b/package-lock.json index 309f532..eec9cf2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "dnd-campaign-manager", - "version": "1.7.2", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dnd-campaign-manager", - "version": "1.7.2", + "version": "1.8.0", "workspaces": [ "backend", "frontend" ] }, "backend": { - "version": "1.7.2", + "version": "1.8.0", "dependencies": { "@prisma/client": "^5.20.0", "bcrypt": "^5.1.1", @@ -31,7 +31,7 @@ } }, "frontend": { - "version": "1.7.2", + "version": "1.8.0", "dependencies": { "@tanstack/react-query": "^5.103.2", "axios": "^1.20.0", diff --git a/package.json b/package.json index c9c2b35..3b4a69d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dnd-campaign-manager", - "version": "1.7.2", + "version": "1.8.0", "private": true, "description": "D&D 5e campaign manager — monorepo root", "workspaces": [