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 0d448cde9c
8 changed files with 37 additions and 23 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.7.1",
"version": "1.7.2",
"private": true,
"type": "commonjs",
"main": "src/index.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([]);
+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
// 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 } });
+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**
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
<whoever is on turn>".
- While anyone is marked the DM gets an **attack bar** above the list, which
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.7.1",
"version": "1.7.2",
"type": "module",
"scripts": {
"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.
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({
<div className="card attack-bar">
<div className="flex-between flex-wrap">
<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>
<button className="btn btn-ghost" onClick={onClear}>
✕ Clear {isArea ? 'targets' : 'target'}
@@ -894,10 +893,11 @@ export function EncounterPage() {
isActive={p.id === active?.id}
onPatch={(data) => patchParticipant.mutate({ participantId: p.id, ...data }, fail)}
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)}
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)
: undefined
}
+4 -4
View File
@@ -1,19 +1,19 @@
{
"name": "dnd-campaign-manager",
"version": "1.7.1",
"version": "1.7.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dnd-campaign-manager",
"version": "1.7.1",
"version": "1.7.2",
"workspaces": [
"backend",
"frontend"
]
},
"backend": {
"version": "1.7.1",
"version": "1.7.2",
"dependencies": {
"@prisma/client": "^5.20.0",
"bcrypt": "^5.1.1",
@@ -31,7 +31,7 @@
}
},
"frontend": {
"version": "1.7.1",
"version": "1.7.2",
"dependencies": {
"@tanstack/react-query": "^5.103.2",
"axios": "^1.20.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dnd-campaign-manager",
"version": "1.7.1",
"version": "1.7.2",
"private": true,
"description": "D&D 5e campaign manager — monorepo root",
"workspaces": [