- In a battle under way, whoever is on turn has no Target button on their card, and the server refuses a target list that includes them. - All packages at version 1.7.2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
684 lines
30 KiB
JavaScript
684 lines
30 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, 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 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' });
|
|
|
|
// A player only gets into a scene one of their own characters is in. To
|
|
// anyone else it doesn't exist — the name alone can be a spoiler.
|
|
const isDM = membership.role === 'DM';
|
|
if (!isDM) {
|
|
const present = await prisma.encounterParticipant.findFirst({
|
|
where: { encounterId: encounter.id, character: { userId: req.user.id } },
|
|
select: { id: true },
|
|
});
|
|
if (!present) return res.status(404).json({ error: 'Encounter not found' });
|
|
}
|
|
|
|
req.encounter = encounter;
|
|
req.membership = membership;
|
|
req.isDM = isDM;
|
|
return next();
|
|
} catch (err) {
|
|
return next(err);
|
|
}
|
|
}
|
|
|
|
function requireDM(req, res, next) {
|
|
if (!req.isDM) return res.status(403).json({ error: 'DM only' });
|
|
return next();
|
|
}
|
|
|
|
function clamp(value, min, max) {
|
|
return Math.min(Math.max(value, min), max);
|
|
}
|
|
|
|
// Highest initiative first; ties keep the order combatants were added in.
|
|
// Anyone who has not rolled yet waits at the bottom.
|
|
function inTurnOrder(participants) {
|
|
const rolled = (p) => (p.initiativeRoll == null ? 0 : 1);
|
|
return [...participants].sort((a, b) => rolled(b) - rolled(a) || b.initiative - a.initiative || (a.id < b.id ? -1 : 1));
|
|
}
|
|
|
|
// What is added to a combatant's d20 for initiative: the sheet's initiative
|
|
// bonus for a character, the Dexterity modifier for a monster, and nothing for
|
|
// an NPC (they have no ability scores). Takes a participant with its
|
|
// character (and proficiencies) and monster loaded.
|
|
function initiativeBonus({ character, monster }) {
|
|
if (character) return computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies).initiative;
|
|
if (monster) return abilityModifier(monster.dexScore);
|
|
return 0;
|
|
}
|
|
|
|
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
|
|
// sheet's headline numbers plus the weapons and spells it can actually use —
|
|
// so the party can plan around each other mid-fight, not only the DM. Uses the
|
|
// same calculation-engine functions as the sheet, never re-deriving formulas.
|
|
function characterCombatInfo(character) {
|
|
const sheet = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
|
|
// Gear and spells above the character's level are held but can't be used,
|
|
// so they stay out of the fight.
|
|
const weapons = character.weapons
|
|
.filter((cw) => canUseGear(character.level, cw.level))
|
|
.map((cw) => ({
|
|
characterWeaponId: cw.id,
|
|
name: cw.weapon.name,
|
|
level: cw.level,
|
|
equipped: cw.equipped,
|
|
quantity: cw.quantity,
|
|
...computeWeaponStats(cw.weapon, sheet.abilities, sheet.proficiencyBonus, true, cw.level),
|
|
}));
|
|
const spells = character.spells
|
|
.filter((cs) => canUseGear(character.level, cs.spell.level))
|
|
.map((cs) => ({
|
|
characterSpellId: cs.id,
|
|
name: cs.spell.name,
|
|
level: cs.spell.level,
|
|
description: cs.spell.description,
|
|
damageDice: cs.spell.damageDice,
|
|
prepared: cs.prepared,
|
|
}));
|
|
return {
|
|
level: character.level,
|
|
species: character.species,
|
|
characterClass: character.characterClass,
|
|
armorClass: sheet.armorClass,
|
|
maxHp: sheet.hp.max,
|
|
tempHp: character.tempHp,
|
|
deathSaves: deathSaveInfo(character),
|
|
speed: sheet.speed,
|
|
initiativeBonus: sheet.initiative,
|
|
passivePerception: sheet.passivePerception,
|
|
proficiencyBonus: sheet.proficiencyBonus,
|
|
abilities: sheet.abilities,
|
|
saves: sheet.saves,
|
|
specialAbilities: character.specialAbilities,
|
|
weapons,
|
|
spells,
|
|
spellcasting: sheet.spellcasting,
|
|
};
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// `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
|
|
// hides monster hit points — gets a percentage for the health bar in place of
|
|
// the numbers for anyone who is not a character.
|
|
async function serializeEncounter(encounterId, isDM, viewerId) {
|
|
const encounter = await prisma.encounter.findUnique({
|
|
where: { id: encounterId },
|
|
include: {
|
|
campaign: { select: { showMonsterHp: true } },
|
|
location: { select: { id: true, name: true, description: true } },
|
|
participants: {
|
|
include: {
|
|
character: {
|
|
include: {
|
|
skillProficiencies: true,
|
|
saveProficiencies: true,
|
|
weapons: { include: { weapon: true } },
|
|
spells: { include: { spell: true } },
|
|
},
|
|
},
|
|
monster: true,
|
|
npc: true,
|
|
},
|
|
},
|
|
rewards: { include: { weapon: true, item: true }, orderBy: { createdAt: 'asc' } },
|
|
},
|
|
});
|
|
if (!encounter) return encounter;
|
|
|
|
const hideHp = !isDM && !encounter.campaign.showMonsterHp;
|
|
const participants = inTurnOrder(encounter.participants).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' };
|
|
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.
|
|
const { maxHp, ...combatInfo } = characterCombatInfo(character);
|
|
out.currentHp = character.currentHp;
|
|
out.maxHp = maxHp;
|
|
out.character = { id: character.id, name: character.name, userId: character.userId };
|
|
out.characterCombatInfo = combatInfo;
|
|
}
|
|
if (monster) out.monster = isDM ? monster : { id: monster.id, name: monster.name };
|
|
if (npc) {
|
|
const { dmNotes, ac, maxHp, attackBonus, damageDice, campaignId, createdAt, ...publicNpc } = npc;
|
|
out.npc = isDM ? { ...publicNpc, dmNotes, ac, maxHp, attackBonus, damageDice } : publicNpc;
|
|
}
|
|
if (hideHp && !character) {
|
|
// Rounded up, so anything still standing shows a sliver of bar.
|
|
out.hpPercent = p.maxHp > 0 ? Math.ceil((clamp(p.currentHp, 0, p.maxHp) / p.maxHp) * 100) : null;
|
|
out.currentHp = null;
|
|
out.maxHp = null;
|
|
}
|
|
return out;
|
|
});
|
|
|
|
const { dmNotes, rewards, campaign, location, ...scene } = encounter;
|
|
const knowsPlace = isDM || (location && (await visitedLocationIds(prisma, viewerId, encounter.campaignId)).has(location.id));
|
|
return {
|
|
...scene,
|
|
locationId: knowsPlace ? scene.locationId : null,
|
|
location: knowsPlace ? location : null,
|
|
...(isDM ? { dmNotes } : {}),
|
|
participants,
|
|
rewards: isDM ? rewards : rewards.filter((r) => r.awardedAt),
|
|
};
|
|
}
|
|
|
|
// Every open tab refetches its own view of the encounter (the payload differs
|
|
// between the DM and players, so it is never pushed over the shared room).
|
|
function emitEncounterUpdated(req) {
|
|
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('encounter:participant-updated', { encounterId: req.encounter.id });
|
|
}
|
|
|
|
function emitCharacterUpdated(req, characterId) {
|
|
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('character:updated', { characterId });
|
|
}
|
|
|
|
// While an encounter with a location is in progress, the characters in it
|
|
// are at that location: starting it, moving it, or adding someone to it puts
|
|
// them there, and that goes on their record of places visited.
|
|
async function syncCharacterLocations(req) {
|
|
const encounter = await prisma.encounter.findUnique({
|
|
where: { id: req.encounter.id },
|
|
include: { participants: { where: { characterId: { not: null } }, include: { character: { select: { id: true, currentLocationId: true } } } } },
|
|
});
|
|
if (!encounter || encounter.status !== 'ACTIVE' || !encounter.locationId) return;
|
|
|
|
const elsewhere = encounter.participants.map((p) => p.character).filter((c) => c.currentLocationId !== encounter.locationId);
|
|
await moveCharacters(prisma, elsewhere.map((c) => c.id), encounter.locationId);
|
|
for (const character of elsewhere) emitCharacterUpdated(req, character.id);
|
|
}
|
|
|
|
async function respondWithEncounter(req, res, status = 200, extra = {}) {
|
|
await syncCharacterLocations(req);
|
|
const encounter = await serializeEncounter(req.encounter.id, req.isDM, req.user.id);
|
|
emitEncounterUpdated(req);
|
|
res.status(status).json({ ...extra, encounter });
|
|
}
|
|
|
|
router.get('/:id', requireEncounterAccess, async (req, res, next) => {
|
|
try {
|
|
res.json({ encounter: await serializeEncounter(req.encounter.id, req.isDM, req.user.id) });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
const patchEncounterSchema = z.object({
|
|
status: z.enum(['PLANNING', 'ACTIVE', 'COMPLETE']).optional(),
|
|
mode: z.enum(['BATTLE', 'QUEST']).optional(),
|
|
name: z.string().min(1).max(120).optional(),
|
|
description: z.string().max(4000).nullable().optional(),
|
|
dmNotes: z.string().max(4000).nullable().optional(),
|
|
locationId: z.string().nullable().optional(),
|
|
archived: z.boolean().optional(), // put away (true) or bring back (false)
|
|
});
|
|
|
|
router.patch('/:id', requireEncounterAccess, requireDM, async (req, res, next) => {
|
|
try {
|
|
const parsed = patchEncounterSchema.safeParse(req.body);
|
|
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
|
const { archived, ...data } = parsed.data;
|
|
if (archived === true && !req.encounter.archivedAt) data.archivedAt = new Date();
|
|
if (archived === false) data.archivedAt = null;
|
|
|
|
if (data.locationId) {
|
|
const location = await prisma.location.findFirst({ where: { id: data.locationId, campaignId: req.encounter.campaignId } });
|
|
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.
|
|
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;
|
|
}
|
|
|
|
// Targets belong to the moment; beginning, ending or reopening clears them.
|
|
if (data.status && data.status !== req.encounter.status) data.targetParticipantIds = [];
|
|
|
|
await prisma.encounter.update({ where: { id: req.encounter.id }, data });
|
|
await respondWithEncounter(req, res);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// Deleting removes the scene, its combatant rows and its reward list. What
|
|
// was already handed out stays with the characters, as does their HP.
|
|
router.delete('/:id', requireEncounterAccess, requireDM, async (req, res, next) => {
|
|
try {
|
|
await prisma.encounter.delete({ where: { id: req.encounter.id } });
|
|
emitEncounterUpdated(req);
|
|
res.status(204).end();
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Combatants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const participantSchema = z
|
|
.object({
|
|
characterId: z.string().optional(),
|
|
monsterId: z.string().optional(),
|
|
npcId: z.string().optional(),
|
|
displayName: z.string().min(1).max(80).optional(),
|
|
initiativeRoll: initiativeRollSchema.optional(),
|
|
})
|
|
.refine((data) => [data.characterId, data.monsterId, data.npcId].filter(Boolean).length === 1, {
|
|
message: 'Provide exactly one of characterId, monsterId or npcId',
|
|
});
|
|
|
|
router.post('/:id/participants', requireEncounterAccess, requireDM, async (req, res, next) => {
|
|
try {
|
|
const parsed = participantSchema.safeParse(req.body);
|
|
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
|
const { characterId, monsterId, npcId, displayName, initiativeRoll } = parsed.data;
|
|
|
|
let currentHp, maxHp, resolvedName;
|
|
let bonus = 0;
|
|
|
|
if (characterId) {
|
|
const character = await prisma.character.findUnique({
|
|
where: { id: characterId },
|
|
include: { skillProficiencies: true, saveProficiencies: true },
|
|
});
|
|
if (!character || character.campaignId !== req.encounter.campaignId) {
|
|
return res.status(400).json({ error: 'That character is not part of this campaign.' });
|
|
}
|
|
currentHp = character.currentHp;
|
|
maxHp = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies).hp.max;
|
|
resolvedName = displayName || character.name;
|
|
bonus = initiativeBonus({ character });
|
|
} else if (monsterId) {
|
|
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;
|
|
bonus = initiativeBonus({ monster });
|
|
} else {
|
|
const npc = await prisma.npc.findUnique({ where: { id: npcId } });
|
|
if (!npc || npc.campaignId !== req.encounter.campaignId) {
|
|
return res.status(400).json({ error: 'That NPC is not part of this campaign.' });
|
|
}
|
|
// An NPC with no combat stats takes part with no hit points to track.
|
|
currentHp = npc.maxHp ?? 0;
|
|
maxHp = npc.maxHp ?? 0;
|
|
resolvedName = displayName || npc.name;
|
|
}
|
|
|
|
const participant = await prisma.encounterParticipant.create({
|
|
data: {
|
|
encounterId: req.encounter.id,
|
|
characterId: characterId || null,
|
|
monsterId: monsterId || null,
|
|
npcId: npcId || null,
|
|
displayName: resolvedName,
|
|
initiativeRoll: initiativeRoll ?? null,
|
|
initiative: initiativeRoll == null ? 0 : initiativeRoll + bonus,
|
|
currentHp,
|
|
maxHp,
|
|
isMonster: Boolean(monsterId),
|
|
},
|
|
});
|
|
|
|
await respondWithEncounter(req, res, 201, { participant });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
const updateParticipantSchema = z.object({
|
|
currentHp: z.number().int().optional(),
|
|
maxHp: z.number().int().min(1).max(100000).optional(),
|
|
initiativeRoll: initiativeRollSchema.nullable().optional(), // null clears the roll
|
|
conditions: z.array(z.string().trim().min(1).max(40)).max(20).optional(),
|
|
displayName: z.string().min(1).max(80).optional(),
|
|
});
|
|
|
|
// The DM runs every combatant. A player may do one thing here: enter the
|
|
// initiative roll for their own character.
|
|
router.patch('/:id/participants/:participantId', requireEncounterAccess, async (req, res, next) => {
|
|
try {
|
|
const parsed = updateParticipantSchema.safeParse(req.body);
|
|
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
|
|
|
const existing = await prisma.encounterParticipant.findUnique({
|
|
where: { id: req.params.participantId },
|
|
include: { character: { include: { skillProficiencies: true, saveProficiencies: true } }, monster: true },
|
|
});
|
|
if (!existing || existing.encounterId !== req.encounter.id) {
|
|
return res.status(404).json({ error: 'Combatant not found in this encounter' });
|
|
}
|
|
if (!req.isDM) {
|
|
const onlyTheRoll = Object.keys(parsed.data).every((key) => key === 'initiativeRoll');
|
|
const ownCharacter = existing.character && existing.character.userId === req.user.id;
|
|
if (!onlyTheRoll || !ownCharacter) {
|
|
return res.status(403).json({ error: 'You can only enter the initiative roll for your own character' });
|
|
}
|
|
}
|
|
|
|
const data = { ...parsed.data };
|
|
// The roll is what was on the die; the bonus is added here.
|
|
if (data.initiativeRoll !== undefined) {
|
|
data.initiative = data.initiativeRoll == null ? 0 : data.initiativeRoll + initiativeBonus(existing);
|
|
}
|
|
if (existing.character) {
|
|
// Max HP comes from the sheet, and damage/healing lands on the character.
|
|
const { character } = existing;
|
|
const maxHp = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies).hp.max;
|
|
data.maxHp = maxHp;
|
|
if (data.currentHp !== undefined) {
|
|
if (lifeState(character) === 'DEAD' && data.currentHp > 0) {
|
|
return res.status(400).json({ error: `${character.name} is dead. Revive them before healing.` });
|
|
}
|
|
// Temp HP soaks damage first; what is left lands on the character,
|
|
// and at 0 HP it moves their death saves too.
|
|
const after = changeHp(character, data.currentHp, maxHp);
|
|
data.currentHp = after.currentHp;
|
|
await prisma.character.update({ where: { id: character.id }, data: after });
|
|
emitCharacterUpdated(req, character.id);
|
|
}
|
|
} else if (data.currentHp !== undefined) {
|
|
data.currentHp = clamp(data.currentHp, 0, data.maxHp ?? existing.maxHp);
|
|
} else if (data.maxHp !== undefined) {
|
|
data.currentHp = Math.min(existing.currentHp, data.maxHp);
|
|
}
|
|
|
|
const participant = await prisma.encounterParticipant.update({ where: { id: existing.id }, data });
|
|
await respondWithEncounter(req, res, 200, { participant });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
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 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.
|
|
const { activeParticipantId, targetParticipantIds } = req.encounter;
|
|
if (activeParticipantId === req.params.participantId) {
|
|
const next = order.length > 1 ? order[(index + 1) % order.length] : null;
|
|
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);
|
|
await prisma.encounter.update({ where: { id: req.encounter.id }, data: { targetParticipantIds: left } });
|
|
}
|
|
await prisma.encounterParticipant.delete({ where: { id: req.params.participantId } });
|
|
await respondWithEncounter(req, res);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Targets — who the combatant on turn is going for
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// The whole list, in the order picked; an empty one clears it.
|
|
const targetsSchema = z.object({ participantIds: z.array(z.string()).max(50) });
|
|
|
|
// Combatants can be marked as targets, for everyone to see; the DM's screen
|
|
// then offers to apply damage to them. One is an ordinary attack, more is an
|
|
// area attack. The DM can mark anyone at any time. A player can while their
|
|
// own character is on turn in a battle. Nobody can mark the combatant who is
|
|
// on turn: you don't target yourself.
|
|
router.put('/:id/targets', requireEncounterAccess, async (req, res, next) => {
|
|
try {
|
|
const parsed = targetsSchema.safeParse(req.body);
|
|
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
|
const participantIds = [...new Set(parsed.data.participantIds)];
|
|
|
|
// Whoever is on turn, when a battle is under way.
|
|
const { mode, status, activeParticipantId } = req.encounter;
|
|
const onTurnId = mode === 'BATTLE' && status === 'ACTIVE' ? activeParticipantId : null;
|
|
|
|
if (!req.isDM) {
|
|
const ownTurn = onTurnId
|
|
? await prisma.encounterParticipant.findFirst({
|
|
where: { id: onTurnId, encounterId: req.encounter.id, character: { userId: req.user.id } },
|
|
select: { id: true },
|
|
})
|
|
: null;
|
|
if (!ownTurn) return res.status(403).json({ error: "You can only pick targets on your own character's turn" });
|
|
}
|
|
|
|
if (onTurnId && participantIds.includes(onTurnId)) {
|
|
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' });
|
|
|
|
await prisma.encounter.update({ where: { id: req.encounter.id }, data: { targetParticipantIds: participantIds } });
|
|
await respondWithEncounter(req, res);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Turns and rounds
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Passes the turn down the initiative order; 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);
|
|
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;
|
|
await prisma.encounter.update({
|
|
where: { id: req.encounter.id },
|
|
data: {
|
|
status: 'ACTIVE',
|
|
activeParticipantId: order[wrapped || index === -1 ? 0 : index + 1].id,
|
|
targetParticipantIds: [], // the next combatant picks their own
|
|
...(wrapped ? { currentRound: { increment: 1 } } : {}),
|
|
},
|
|
});
|
|
await respondWithEncounter(req, res);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req, res, next) => {
|
|
try {
|
|
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id } });
|
|
const updated = await prisma.encounter.update({
|
|
where: { id: req.encounter.id },
|
|
data: {
|
|
currentRound: { increment: 1 },
|
|
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 } : {}),
|
|
},
|
|
});
|
|
await syncCharacterLocations(req);
|
|
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('encounter:round-advanced', {
|
|
encounterId: updated.id,
|
|
currentRound: updated.currentRound,
|
|
});
|
|
res.json({ encounter: await serializeEncounter(updated.id, req.isDM, req.user.id) });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rewards — loot the DM lines up, then hands to a character
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const rewardSchema = z.discriminatedUnion('type', [
|
|
z.object({ type: z.literal('GOLD'), gold: z.number().int().min(1).max(1000000), currency: z.enum(CURRENCIES).default('GOLD'), note: z.string().trim().max(200).optional().nullable() }),
|
|
z.object({
|
|
type: z.literal('WEAPON'),
|
|
weaponId: z.string(),
|
|
quantity: z.number().int().min(1).max(999).default(1),
|
|
level: z.number().int().min(1).max(MAX_GEAR_LEVEL).optional(),
|
|
note: z.string().trim().max(200).optional().nullable(),
|
|
}),
|
|
z.object({
|
|
type: z.literal('ITEM'),
|
|
itemId: z.string(),
|
|
quantity: z.number().int().min(1).max(999).default(1),
|
|
level: z.number().int().min(1).max(MAX_GEAR_LEVEL).optional(),
|
|
note: z.string().trim().max(200).optional().nullable(),
|
|
}),
|
|
]);
|
|
|
|
router.post('/:id/rewards', requireEncounterAccess, requireDM, async (req, res, next) => {
|
|
try {
|
|
const parsed = rewardSchema.safeParse(req.body);
|
|
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
|
const data = { ...parsed.data, note: parsed.data.note || null };
|
|
|
|
if (data.type !== 'GOLD') {
|
|
const pool = data.type === 'WEAPON' ? 'weapon' : 'item';
|
|
const entry = await prisma[pool].findUnique({ where: { id: data[`${pool}Id`] } });
|
|
if (!entry || entry.campaignId !== req.encounter.campaignId) {
|
|
return res.status(400).json({ error: `That ${pool} is not part of this campaign.` });
|
|
}
|
|
data.level = data.level ?? entry.minLevel;
|
|
if (data.level < entry.minLevel) {
|
|
return res.status(400).json({ error: `${entry.name} starts at level ${entry.minLevel} and can't go below it.` });
|
|
}
|
|
}
|
|
|
|
const reward = await prisma.encounterReward.create({ data: { ...data, encounterId: req.encounter.id } });
|
|
await respondWithEncounter(req, res, 201, { reward });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// Removing a reward only takes it off the list — anything already handed out
|
|
// stays with the character.
|
|
router.delete('/:id/rewards/:rewardId', requireEncounterAccess, requireDM, async (req, res, next) => {
|
|
try {
|
|
const { count } = await prisma.encounterReward.deleteMany({
|
|
where: { id: req.params.rewardId, encounterId: req.encounter.id },
|
|
});
|
|
if (count === 0) return res.status(404).json({ error: 'Reward not found in this encounter' });
|
|
await respondWithEncounter(req, res);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
const awardSchema = z.union([z.object({ characterId: z.string() }), z.object({ split: z.literal(true) })]);
|
|
|
|
// Hands a reward over, once. `characterId` gives all of it to one character;
|
|
// `split` (gold only) shares it evenly between the characters in the
|
|
// encounter, any remainder going one gold each down the initiative order.
|
|
router.post('/:id/rewards/:rewardId/award', requireEncounterAccess, requireDM, async (req, res, next) => {
|
|
try {
|
|
const parsed = awardSchema.safeParse(req.body);
|
|
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
|
|
|
const reward = await prisma.encounterReward.findUnique({
|
|
where: { id: req.params.rewardId },
|
|
include: { weapon: true, item: true },
|
|
});
|
|
if (!reward || reward.encounterId !== req.encounter.id) {
|
|
return res.status(404).json({ error: 'Reward not found in this encounter' });
|
|
}
|
|
if (reward.awardedAt) return res.status(400).json({ error: `Already awarded to ${reward.awardedTo}.` });
|
|
|
|
let recipients;
|
|
if (parsed.data.split) {
|
|
if (reward.type !== 'GOLD') return res.status(400).json({ error: 'Only coins can be split.' });
|
|
const participants = await prisma.encounterParticipant.findMany({
|
|
where: { encounterId: req.encounter.id, characterId: { not: null } },
|
|
include: { character: true },
|
|
});
|
|
recipients = inTurnOrder(participants).map((p) => p.character);
|
|
if (recipients.length === 0) return res.status(400).json({ error: 'There are no characters in this encounter to split it between.' });
|
|
} else {
|
|
const character = await prisma.character.findUnique({ where: { id: parsed.data.characterId } });
|
|
if (!character || character.campaignId !== req.encounter.campaignId) {
|
|
return res.status(400).json({ error: 'That character is not part of this campaign.' });
|
|
}
|
|
recipients = [character];
|
|
}
|
|
|
|
const reason = `Reward: ${req.encounter.name}`;
|
|
await prisma.$transaction(async (tx) => {
|
|
// Claim the reward first so two clicks can't hand it out twice.
|
|
const claimed = await tx.encounterReward.updateMany({
|
|
where: { id: reward.id, awardedAt: null },
|
|
data: { awardedAt: new Date(), awardedTo: recipients.map((c) => c.name).join(', ') },
|
|
});
|
|
if (claimed.count === 0) return;
|
|
|
|
if (reward.type === 'GOLD') {
|
|
const share = Math.floor(reward.gold / recipients.length);
|
|
const remainder = reward.gold % recipients.length;
|
|
for (const [i, character] of recipients.entries()) {
|
|
const amount = share + (i < remainder ? 1 : 0);
|
|
if (amount > 0) await adjustGold(tx, { characterId: character.id, amount, currency: reward.currency, reason, userId: req.user.id });
|
|
}
|
|
} else {
|
|
const kind = reward.type === 'WEAPON' ? 'weapon' : 'item';
|
|
await giveGear(tx, kind, {
|
|
characterId: recipients[0].id,
|
|
entry: reward[kind],
|
|
quantity: reward.quantity,
|
|
level: reward.level,
|
|
});
|
|
}
|
|
});
|
|
|
|
for (const character of recipients) emitCharacterUpdated(req, character.id);
|
|
await respondWithEncounter(req, res);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|