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:
@@ -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([]);
|
||||||
|
|||||||
@@ -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 } });
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+4
-4
@@ -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
@@ -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": [
|
||||||
|
|||||||
Reference in New Issue
Block a user