Archived
- 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>
229 lines
8.2 KiB
JavaScript
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;
|