This repository has been archived on 2026-10-05. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
dnd-manager/backend/src/routes/encounters.routes.js
T
joshuaandClaude Opus 5.5 94067f1268 V1.0.1: Aldenfell campaign seed, content library, party overview
- Seed now resets the database and seeds the Aldenfell campaign from the
  campaign book: admin DM user, default weapons, locations, bestiary,
  pre-built encounters, and DM/shared notes
- Default weapon/spell/monster library, character profile fields,
  locations and dice mode (new migration)
- Party overview page, pagination, info tips, build preview calc
- Remove stale demo-login hint; notes preserve line breaks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 10:24:48 +02:00

229 lines
8.2 KiB
JavaScript

const express = require('express');
const { z } = require('zod');
const prisma = require('../db');
const { requireAuth } = require('../middleware/auth');
const { loadCampaignMembership } = require('../middleware/campaignAccess');
const { computeCharacterSheet, computeWeaponStats } = require('../game/calculations');
const router = express.Router();
router.use(requireAuth);
async function requireEncounterAccess(req, res, next) {
try {
const encounter = await prisma.encounter.findUnique({ where: { id: req.params.id } });
if (!encounter) return res.status(404).json({ error: 'Encounter not found' });
const membership = await loadCampaignMembership(req.user.id, encounter.campaignId);
if (!membership) return res.status(403).json({ error: 'Not a member of this campaign' });
req.encounter = encounter;
req.membership = membership;
req.isDM = membership.role === 'DM';
return next();
} catch (err) {
return next(err);
}
}
// Every campaign member looking at an encounter can see a character
// combatant's equipped weapons and known spells (attack/damage bonus, save
// DC) — not just HP/AC — so the party can plan around each other mid-fight,
// not only the DM. Uses the same calculation-engine functions the
// quick-stats panel already uses, never re-deriving the formulas.
function characterCombatInfo(character) {
const sheet = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
const weapons = character.weapons.map((cw) => ({
characterWeaponId: cw.id,
name: cw.weapon.name,
equipped: cw.equipped,
quantity: cw.quantity,
...computeWeaponStats(cw.weapon, sheet.abilities, sheet.proficiencyBonus, true),
}));
const spells = character.spells.map((cs) => ({
characterSpellId: cs.id,
name: cs.spell.name,
level: cs.spell.level,
prepared: cs.prepared,
}));
return { armorClass: sheet.armorClass, weapons, spells, spellcasting: sheet.spellcasting };
}
async function serializeEncounter(encounterId) {
const encounter = await prisma.encounter.findUnique({
where: { id: encounterId },
include: {
participants: {
include: {
character: {
include: {
skillProficiencies: true,
saveProficiencies: true,
weapons: { include: { weapon: true } },
spells: { include: { spell: true } },
},
},
monster: { select: { id: true, name: true } },
},
orderBy: { initiative: 'desc' },
},
},
});
if (!encounter) return encounter;
encounter.participants = encounter.participants.map((p) => {
if (!p.character) return p;
return {
...p,
character: { id: p.character.id, name: p.character.name },
characterCombatInfo: characterCombatInfo(p.character),
};
});
return encounter;
}
router.get('/:id', requireEncounterAccess, async (req, res, next) => {
try {
const encounter = await serializeEncounter(req.encounter.id);
res.json({ encounter });
} catch (err) {
next(err);
}
});
const participantSchema = z
.object({
characterId: z.string().optional(),
monsterId: z.string().optional(),
displayName: z.string().min(1).max(80).optional(),
initiative: z.number().int().default(0),
})
.refine((data) => Boolean(data.characterId) !== Boolean(data.monsterId), {
message: 'Provide exactly one of characterId or monsterId',
});
router.post('/:id/participants', requireEncounterAccess, async (req, res, next) => {
try {
if (!req.isDM) return res.status(403).json({ error: 'DM only' });
const parsed = participantSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { characterId, monsterId, displayName, initiative } = parsed.data;
let currentHp, maxHp, resolvedName, isMonster;
if (characterId) {
const character = await prisma.character.findUnique({ where: { id: characterId } });
if (!character || character.campaignId !== req.encounter.campaignId) {
return res.status(400).json({ error: 'That character is not part of this campaign.' });
}
const full = await prisma.character.findUnique({
where: { id: characterId },
include: { skillProficiencies: true, saveProficiencies: true },
});
const sheet = computeCharacterSheet(full, full.skillProficiencies, full.saveProficiencies);
currentHp = character.currentHp;
maxHp = sheet.hp.max;
resolvedName = displayName || character.name;
isMonster = false;
} else {
const monster = await prisma.monster.findUnique({ where: { id: monsterId } });
if (!monster || monster.campaignId !== req.encounter.campaignId) {
return res.status(400).json({ error: 'That monster is not part of this campaign.' });
}
currentHp = monster.maxHp;
maxHp = monster.maxHp;
resolvedName = displayName || monster.name;
isMonster = true;
}
const participant = await prisma.encounterParticipant.create({
data: {
encounterId: req.encounter.id,
characterId: characterId || null,
monsterId: monsterId || null,
displayName: resolvedName,
initiative,
currentHp,
maxHp,
isMonster,
},
});
const encounter = await serializeEncounter(req.encounter.id);
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('encounter:participant-updated', { encounter });
res.status(201).json({ participant, encounter });
} catch (err) {
next(err);
}
});
const updateParticipantSchema = z.object({
currentHp: z.number().int().optional(),
maxHp: z.number().int().min(1).optional(),
initiative: z.number().int().optional(),
conditions: z.array(z.string()).optional(),
displayName: z.string().min(1).max(80).optional(),
});
router.patch('/:id/participants/:participantId', requireEncounterAccess, async (req, res, next) => {
try {
if (!req.isDM) return res.status(403).json({ error: 'DM only' });
const parsed = updateParticipantSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const participant = await prisma.encounterParticipant.update({
where: { id: req.params.participantId },
data: parsed.data,
});
const encounter = await serializeEncounter(req.encounter.id);
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('encounter:participant-updated', { encounter });
res.json({ participant, encounter });
} catch (err) {
next(err);
}
});
router.delete('/:id/participants/:participantId', requireEncounterAccess, async (req, res, next) => {
try {
if (!req.isDM) return res.status(403).json({ error: 'DM only' });
await prisma.encounterParticipant.delete({ where: { id: req.params.participantId } });
const encounter = await serializeEncounter(req.encounter.id);
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('encounter:participant-updated', { encounter });
res.json({ encounter });
} catch (err) {
next(err);
}
});
router.post('/:id/advance-round', requireEncounterAccess, async (req, res, next) => {
try {
if (!req.isDM) return res.status(403).json({ error: 'DM only' });
const updated = await prisma.encounter.update({
where: { id: req.encounter.id },
data: { currentRound: { increment: 1 }, status: 'ACTIVE' },
});
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('encounter:round-advanced', {
encounterId: updated.id,
currentRound: updated.currentRound,
});
res.json({ encounter: updated });
} catch (err) {
next(err);
}
});
router.patch('/:id', requireEncounterAccess, async (req, res, next) => {
try {
if (!req.isDM) return res.status(403).json({ error: 'DM only' });
const parsed = z.object({ status: z.enum(['PLANNING', 'ACTIVE', 'COMPLETE']).optional(), name: z.string().min(1).max(120).optional() }).safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const updated = await prisma.encounter.update({ where: { id: req.encounter.id }, data: parsed.data });
res.json({ encounter: updated });
} catch (err) {
next(err);
}
});
module.exports = router;