From f8462a6e31f5ccbb93cfafa367e798d008b721d2 Mon Sep 17 00:00:00 2001 From: Joshua Oosthuizen Date: Tue, 6 Oct 2026 13:28:28 +0200 Subject: [PATCH] V1.7.2: the combatant on turn cannot be their own target - 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 --- backend/package.json | 2 +- backend/src/routes/api.integration.test.js | 4 ++++ backend/src/routes/encounters.routes.js | 27 ++++++++++++++-------- docs/ENCOUNTERS.md | 3 +++ frontend/package.json | 2 +- frontend/src/pages/EncounterPage.tsx | 12 +++++----- package-lock.json | 8 +++---- package.json | 2 +- 8 files changed, 37 insertions(+), 23 deletions(-) diff --git a/backend/package.json b/backend/package.json index b514faf..1b9d250 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "backend", - "version": "1.7.1", + "version": "1.7.2", "private": true, "type": "commonjs", "main": "src/index.js", diff --git a/backend/src/routes/api.integration.test.js b/backend/src/routes/api.integration.test.js index b6e3ec6..58d6401 100644 --- a/backend/src/routes/api.integration.test.js +++ b/backend/src/routes/api.integration.test.js @@ -1948,6 +1948,10 @@ describe('targets', () => { expect(await seen(dmAuth)).toEqual([]); expect((await target(playerAuth, first.id)).status).toBe(403); + // Whoever is on turn can't be a target, their own or as part of a group. + expect((await target(dmAuth, first.id)).status).toBe(400); + expect((await target(dmAuth, hero.id, first.id)).status).toBe(400); + // The goblin's turn: the DM marks the hero, and can clear it again. expect((await target(dmAuth, hero.id)).body.encounter.targetParticipantIds).toEqual([hero.id]); expect((await target(dmAuth)).body.encounter.targetParticipantIds).toEqual([]); diff --git a/backend/src/routes/encounters.routes.js b/backend/src/routes/encounters.routes.js index 3ee82d9..9df2c14 100644 --- a/backend/src/routes/encounters.routes.js +++ b/backend/src/routes/encounters.routes.js @@ -460,23 +460,30 @@ 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. +// 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 { mode, status, activeParticipantId } = req.encounter; - const onTurn = - mode === 'BATTLE' && status === 'ACTIVE' && activeParticipantId - ? await prisma.encounterParticipant.findFirst({ - where: { id: activeParticipantId, encounterId: req.encounter.id, character: { userId: req.user.id } }, - select: { id: true }, - }) - : null; - if (!onTurn) return res.status(403).json({ error: "You can only pick targets on your own character's turn" }); + 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 } }); diff --git a/docs/ENCOUNTERS.md b/docs/ENCOUNTERS.md index 852f64e..6beb949 100644 --- a/docs/ENCOUNTERS.md +++ b/docs/ENCOUNTERS.md @@ -213,6 +213,9 @@ healed) right now. One target is an ordinary attack; more than one is an - 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. +- 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. - A target's card is outlined in red for everyone and reads "Targeted by ". - While anyone is marked the DM gets an **attack bar** above the list, which diff --git a/frontend/package.json b/frontend/package.json index 9469f0e..fded661 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.7.1", + "version": "1.7.2", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/pages/EncounterPage.tsx b/frontend/src/pages/EncounterPage.tsx index 8671d40..26e3d94 100644 --- a/frontend/src/pages/EncounterPage.tsx +++ b/frontend/src/pages/EncounterPage.tsx @@ -124,7 +124,7 @@ function CombatantCard({ // What an area attack adds to this target's AC; 0 when they are the only one. acPenalty: number; // Marks this combatant as a target, or unmarks them. Absent for someone who - // may not pick targets right now. + // may not pick targets right now, and on the card of whoever is on turn. onTarget?: () => void; onRemove: () => void; onError: (err: unknown) => void; @@ -490,8 +490,7 @@ function AttackBar({ const hit = targets.filter((t) => tracksHp(t) && !missed.includes(t.id)); const value = Number(amount) || 0; const portions = shares(value, hit.length, isArea ? mode : 'EACH'); - const selfOnly = targets.length === 1 && targets[0].id === attacker?.id; - const attacks = attacker && !selfOnly ? attackLines(attacker) : []; + const attacks = attacker ? attackLines(attacker) : []; function apply(sign: 1 | -1) { if (!value || hit.length === 0) return; @@ -504,7 +503,7 @@ function AttackBar({
- {attacker && !selfOnly && <>{attacker.displayName} → }🎯 {isArea ? `${targets.length} targets` : targets[0].displayName} + {attacker && <>{attacker.displayName} → }🎯 {isArea ? `${targets.length} targets` : targets[0].displayName}