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 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 13:28:28 +02:00
co-authored by Claude Opus 5.5
parent 754eb0ee68
commit f8462a6e31
8 changed files with 37 additions and 23 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "backend", "name": "backend",
"version": "1.7.1", "version": "1.7.2",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"main": "src/index.js", "main": "src/index.js",
@@ -1948,6 +1948,10 @@ describe('targets', () => {
expect(await seen(dmAuth)).toEqual([]); expect(await seen(dmAuth)).toEqual([]);
expect((await target(playerAuth, first.id)).status).toBe(403); 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. // 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, hero.id)).body.encounter.targetParticipantIds).toEqual([hero.id]);
expect((await target(dmAuth)).body.encounter.targetParticipantIds).toEqual([]); expect((await target(dmAuth)).body.encounter.targetParticipantIds).toEqual([]);
+17 -10
View File
@@ -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 // 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 // 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 // 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) => { router.put('/:id/targets', requireEncounterAccess, async (req, res, next) => {
try { try {
const parsed = targetsSchema.safeParse(req.body); const parsed = targetsSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const participantIds = [...new Set(parsed.data.participantIds)]; 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) { if (!req.isDM) {
const { mode, status, activeParticipantId } = req.encounter; const ownTurn = onTurnId
const onTurn = ? await prisma.encounterParticipant.findFirst({
mode === 'BATTLE' && status === 'ACTIVE' && activeParticipantId where: { id: onTurnId, encounterId: req.encounter.id, character: { userId: req.user.id } },
? await prisma.encounterParticipant.findFirst({ select: { id: true },
where: { id: activeParticipantId, 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" });
: null; }
if (!onTurn) 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 } }); const found = await prisma.encounterParticipant.count({ where: { id: { in: participantIds }, encounterId: req.encounter.id } });
+3
View File
@@ -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** - 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 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.
- 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 - A target's card is outlined in red for everyone and reads "Targeted by
<whoever is on turn>". <whoever is on turn>".
- While anyone is marked the DM gets an **attack bar** above the list, which - While anyone is marked the DM gets an **attack bar** above the list, which
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "1.7.1", "version": "1.7.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+6 -6
View File
@@ -124,7 +124,7 @@ function CombatantCard({
// What an area attack adds to this target's AC; 0 when they are the only one. // What an area attack adds to this target's AC; 0 when they are the only one.
acPenalty: number; acPenalty: number;
// Marks this combatant as a target, or unmarks them. Absent for someone who // 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; onTarget?: () => void;
onRemove: () => void; onRemove: () => void;
onError: (err: unknown) => void; onError: (err: unknown) => void;
@@ -490,8 +490,7 @@ function AttackBar({
const hit = targets.filter((t) => tracksHp(t) && !missed.includes(t.id)); const hit = targets.filter((t) => tracksHp(t) && !missed.includes(t.id));
const value = Number(amount) || 0; const value = Number(amount) || 0;
const portions = shares(value, hit.length, isArea ? mode : 'EACH'); const portions = shares(value, hit.length, isArea ? mode : 'EACH');
const selfOnly = targets.length === 1 && targets[0].id === attacker?.id; const attacks = attacker ? attackLines(attacker) : [];
const attacks = attacker && !selfOnly ? attackLines(attacker) : [];
function apply(sign: 1 | -1) { function apply(sign: 1 | -1) {
if (!value || hit.length === 0) return; if (!value || hit.length === 0) return;
@@ -504,7 +503,7 @@ function AttackBar({
<div className="card attack-bar"> <div className="card attack-bar">
<div className="flex-between flex-wrap"> <div className="flex-between flex-wrap">
<div className="attack-bar-title"> <div className="attack-bar-title">
{attacker && !selfOnly && <>{attacker.displayName} → </>}🎯 {isArea ? `${targets.length} targets` : targets[0].displayName} {attacker && <>{attacker.displayName} → </>}🎯 {isArea ? `${targets.length} targets` : targets[0].displayName}
</div> </div>
<button className="btn btn-ghost" onClick={onClear}> <button className="btn btn-ghost" onClick={onClear}>
✕ Clear {isArea ? 'targets' : 'target'} ✕ Clear {isArea ? 'targets' : 'target'}
@@ -894,10 +893,11 @@ export function EncounterPage() {
isActive={p.id === active?.id} isActive={p.id === active?.id}
onPatch={(data) => patchParticipant.mutate({ participantId: p.id, ...data }, fail)} onPatch={(data) => patchParticipant.mutate({ participantId: p.id, ...data }, fail)}
canRollInitiative={isDM || (p.character != null && p.character.userId === user?.id)} canRollInitiative={isDM || (p.character != null && p.character.userId === user?.id)}
targetedBy={targetIds.includes(p.id) ? (active && active.id !== p.id ? active.displayName : '') : undefined} targetedBy={targetIds.includes(p.id) ? (active?.displayName ?? '') : undefined}
acPenalty={areaPenalty(targets.length, acStep)} acPenalty={areaPenalty(targets.length, acStep)}
onTarget={ onTarget={
canTarget // Nobody targets themselves: the combatant on turn has no button.
canTarget && p.id !== active?.id
? () => setTargets.mutate(targetIds.includes(p.id) ? targetIds.filter((id) => id !== p.id) : [...targetIds, p.id], fail) ? () => setTargets.mutate(targetIds.includes(p.id) ? targetIds.filter((id) => id !== p.id) : [...targetIds, p.id], fail)
: undefined : undefined
} }
+4 -4
View File
@@ -1,19 +1,19 @@
{ {
"name": "dnd-campaign-manager", "name": "dnd-campaign-manager",
"version": "1.7.1", "version": "1.7.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dnd-campaign-manager", "name": "dnd-campaign-manager",
"version": "1.7.1", "version": "1.7.2",
"workspaces": [ "workspaces": [
"backend", "backend",
"frontend" "frontend"
] ]
}, },
"backend": { "backend": {
"version": "1.7.1", "version": "1.7.2",
"dependencies": { "dependencies": {
"@prisma/client": "^5.20.0", "@prisma/client": "^5.20.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
@@ -31,7 +31,7 @@
} }
}, },
"frontend": { "frontend": {
"version": "1.7.1", "version": "1.7.2",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.103.2", "@tanstack/react-query": "^5.103.2",
"axios": "^1.20.0", "axios": "^1.20.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dnd-campaign-manager", "name": "dnd-campaign-manager",
"version": "1.7.1", "version": "1.7.2",
"private": true, "private": true,
"description": "D&D 5e campaign manager — monorepo root", "description": "D&D 5e campaign manager — monorepo root",
"workspaces": [ "workspaces": [