From f8cee43ca2c9cdb809e09b597fdfc983eba80174 Mon Sep 17 00:00:00 2001 From: Joshua Oosthuizen Date: Tue, 6 Oct 2026 13:01:42 +0200 Subject: [PATCH] V1.7.0: area attacks on several targets - More than one combatant can be marked as a target; the Target button now adds to the targets instead of moving the mark. - Several targets make an area attack, which is harder to land: every target's AC counts as 2 higher for each target beyond the first. The attack bar shows the AC to beat, and each target's card names the penalty. - The DM unticks any target the roll missed, then applies one total: split evenly between those hit (remainder to the first marked), or the full amount to each. Each share is shown before it is applied. - The single target column from V1.6.0 becomes a list, carried over by the migration. - All packages at version 1.7.0. Co-Authored-By: Claude Opus 5.5 --- backend/package.json | 2 +- .../migration.sql | 9 + backend/prisma/schema.prisma | 5 +- backend/src/routes/api.integration.test.js | 32 +-- backend/src/routes/encounters.routes.js | 47 ++--- docs/ENCOUNTERS.md | 50 +++-- frontend/package.json | 2 +- frontend/src/api/queries.ts | 8 +- frontend/src/api/types.ts | 5 +- frontend/src/index.css | 11 ++ frontend/src/pages/EncounterPage.tsx | 183 +++++++++++++----- package-lock.json | 8 +- package.json | 2 +- 13 files changed, 250 insertions(+), 114 deletions(-) create mode 100644 backend/prisma/migrations/20261007170000_encounter_targets/migration.sql diff --git a/backend/package.json b/backend/package.json index a9e8126..91cb463 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "backend", - "version": "1.6.0", + "version": "1.7.0", "private": true, "type": "commonjs", "main": "src/index.js", diff --git a/backend/prisma/migrations/20261007170000_encounter_targets/migration.sql b/backend/prisma/migrations/20261007170000_encounter_targets/migration.sql new file mode 100644 index 0000000..2734719 --- /dev/null +++ b/backend/prisma/migrations/20261007170000_encounter_targets/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable: several combatants can be marked as targets at once (an area attack) +ALTER TABLE "encounters" ADD COLUMN "targetParticipantIds" TEXT[] DEFAULT ARRAY[]::TEXT[]; + +-- Carry over a single target marked before this. +UPDATE "encounters" +SET "targetParticipantIds" = ARRAY["targetParticipantId"] +WHERE "targetParticipantId" IS NOT NULL; + +ALTER TABLE "encounters" DROP COLUMN "targetParticipantId"; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index c5f8483..4d72ba3 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -535,9 +535,10 @@ model Encounter { currentRound Int @default(1) // Whose turn it is in a battle; null until the fight starts. activeParticipantId String? - // Who is being attacked (or healed) right now, shown to everyone. Picked by + // Who is being attacked (or healed) right now, shown to everyone, in the + // order they were picked. More than one makes it an area attack. Picked by // the DM or by the player on turn, and cleared when the turn passes. - targetParticipantId String? + targetParticipantIds String[] @default([]) description String? // the scene / objective, shown to everyone dmNotes String? // never sent to players // Where the scene takes place. While the encounter is in progress, the diff --git a/backend/src/routes/api.integration.test.js b/backend/src/routes/api.integration.test.js index 77d881e..159a687 100644 --- a/backend/src/routes/api.integration.test.js +++ b/backend/src/routes/api.integration.test.js @@ -1923,34 +1923,34 @@ describe('targets', () => { const hero = await add({ characterId: heroId, initiativeRoll: 20 }); const first = await add({ monsterId: goblin.id, initiativeRoll: 10 }); const second = await add({ monsterId: goblin.id, initiativeRoll: 1 }); - const target = (auth, participantId) => request(app).put(`${e}/target`).set(auth).send({ participantId }); - const seen = async (auth) => (await request(app).get(e).set(auth)).body.encounter.targetParticipantId; + const target = (auth, ...participantIds) => request(app).put(`${e}/targets`).set(auth).send({ participantIds }); + const seen = async (auth) => (await request(app).get(e).set(auth)).body.encounter.targetParticipantIds; // Before the battle only the DM can mark anyone. expect((await target(playerAuth, first.id)).status).toBe(403); - expect((await target(dmAuth, first.id)).body.encounter.targetParticipantId).toBe(first.id); - expect(await seen(playerAuth)).toBe(first.id); - expect((await target(dmAuth, 'nobody')).status).toBe(400); + expect((await target(dmAuth, first.id)).body.encounter.targetParticipantIds).toEqual([first.id]); + expect(await seen(playerAuth)).toEqual([first.id]); + expect((await target(dmAuth, first.id, 'nobody')).status).toBe(400); - // Beginning the battle clears it and puts the hero on turn: now the player can. + // Beginning the battle clears them and puts the hero on turn: now the + // player can, and several at once is an area attack, kept in the order picked. await request(app).patch(e).set(dmAuth).send({ status: 'ACTIVE' }); - expect(await seen(dmAuth)).toBeNull(); - expect((await target(playerAuth, second.id)).status).toBe(200); - expect(await seen(dmAuth)).toBe(second.id); + expect(await seen(dmAuth)).toEqual([]); + expect((await target(playerAuth, second.id, first.id, second.id)).status).toBe(200); + expect(await seen(dmAuth)).toEqual([second.id, first.id]); - // Removing the target clears it. + // Removing a target unmarks it and leaves the rest. await request(app).delete(`${e}/participants/${second.id}`).set(dmAuth); - expect(await seen(dmAuth)).toBeNull(); + expect(await seen(dmAuth)).toEqual([first.id]); - // The turn passing clears it, and the player is no longer on turn. - await target(playerAuth, first.id); + // The turn passing clears them, and the player is no longer on turn. await request(app).post(`${e}/next-turn`).set(dmAuth); - expect(await seen(dmAuth)).toBeNull(); + expect(await seen(dmAuth)).toEqual([]); expect((await target(playerAuth, first.id)).status).toBe(403); // The goblin's turn: the DM marks the hero, and can clear it again. - expect((await target(dmAuth, hero.id)).body.encounter.targetParticipantId).toBe(hero.id); - expect((await target(dmAuth, null)).body.encounter.targetParticipantId).toBeNull(); + 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 93f6632..3ee82d9 100644 --- a/backend/src/routes/encounters.routes.js +++ b/backend/src/routes/encounters.routes.js @@ -262,8 +262,8 @@ router.patch('/:id', requireEncounterAccess, requireDM, async (req, res, next) = data.activeParticipantId = inTurnOrder(participants)[0]?.id ?? null; } - // A target belongs to the moment; beginning, ending or reopening clears it. - if (data.status && data.status !== req.encounter.status) data.targetParticipantId = 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); @@ -434,13 +434,14 @@ router.delete('/:id/participants/:participantId', requireEncounterAccess, requir 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 - // the target goes with the turn โ€” or with the combatant who was it. - const { activeParticipantId, targetParticipantId } = req.encounter; + // 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, targetParticipantId: null } }); - } else if (targetParticipantId === req.params.participantId) { - await prisma.encounter.update({ where: { id: req.encounter.id }, data: { targetParticipantId: 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); @@ -450,19 +451,21 @@ router.delete('/:id/participants/:participantId', requireEncounterAccess, requir }); // --------------------------------------------------------------------------- -// Target โ€” who the combatant on turn is going for +// Targets โ€” who the combatant on turn is going for // --------------------------------------------------------------------------- -const targetSchema = z.object({ participantId: z.string().nullable() }); // null clears it +// The whole list, in the order picked; an empty one clears it. +const targetsSchema = z.object({ participantIds: z.array(z.string()).max(50) }); -// One combatant at a time can be marked as the target, for everyone to see; -// the DM's screen then offers to apply damage to it. The DM can mark anyone -// at any time. A player can while their own character is on turn in a battle. -router.put('/:id/target', requireEncounterAccess, async (req, res, next) => { +// 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. +router.put('/:id/targets', requireEncounterAccess, async (req, res, next) => { try { - const parsed = targetSchema.safeParse(req.body); + const parsed = targetsSchema.safeParse(req.body); if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); - const { participantId } = parsed.data; + const participantIds = [...new Set(parsed.data.participantIds)]; if (!req.isDM) { const { mode, status, activeParticipantId } = req.encounter; @@ -473,15 +476,13 @@ router.put('/:id/target', requireEncounterAccess, async (req, res, next) => { select: { id: true }, }) : null; - if (!onTurn) return res.status(403).json({ error: "You can only pick a target on your own character's turn" }); + if (!onTurn) return res.status(403).json({ error: "You can only pick targets on your own character's turn" }); } - if (participantId) { - const target = await prisma.encounterParticipant.findFirst({ where: { id: participantId, encounterId: req.encounter.id }, select: { id: true } }); - if (!target) return res.status(400).json({ error: 'Combatant not found in this encounter' }); - } + 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: { targetParticipantId: participantId } }); + await prisma.encounter.update({ where: { id: req.encounter.id }, data: { targetParticipantIds: participantIds } }); await respondWithEncounter(req, res); } catch (err) { next(err); @@ -507,7 +508,7 @@ router.post('/:id/next-turn', requireEncounterAccess, requireDM, async (req, res data: { status: 'ACTIVE', activeParticipantId: order[wrapped || index === -1 ? 0 : index + 1].id, - targetParticipantId: null, // the next combatant picks their own + targetParticipantIds: [], // the next combatant picks their own ...(wrapped ? { currentRound: { increment: 1 } } : {}), }, }); @@ -525,7 +526,7 @@ router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req, data: { currentRound: { increment: 1 }, status: 'ACTIVE', - targetParticipantId: null, + targetParticipantIds: [], // A new round starts back at the top of the initiative order. ...(req.encounter.mode === 'BATTLE' ? { activeParticipantId: inTurnOrder(participants)[0]?.id ?? null } : {}), }, diff --git a/docs/ENCOUNTERS.md b/docs/ENCOUNTERS.md index 087224e..3cde0fa 100644 --- a/docs/ENCOUNTERS.md +++ b/docs/ENCOUNTERS.md @@ -204,29 +204,55 @@ battles only. ### Targets -One combatant at a time can be marked as the **target**: whoever is being -attacked (or healed) right now. +Combatants can be marked as **targets**: whoever is being attacked (or +healed) right now. One target is an ordinary attack; more than one is an +**area attack**. - Click **๐ŸŽฏ Target** beside a combatant's name to mark them; click it again - to clear. Marking someone else moves the mark. + to unmark. Each one marked is added to the targets. - 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 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 ". -- While a target is marked the DM gets an **attack bar** above the list, which - stays in view while scrolling. It shows who is attacking whom, the target's +- While anyone is marked the DM gets an **attack bar** above the list, which + stays in view while scrolling. It shows who is attacking whom, each target's AC and HP, and the attacker's attacks: a character's equipped weapons (all of them if none are equipped) and damaging spells, or a monster's or NPC's attack and damage. The amount box is ready to type in; **Damage** (or - Enter) and **Heal** change the target's HP exactly as the buttons on its - card do. -- The target stays marked after damage, for a second attack. It is cleared + Enter) and **Heal** change HP exactly as the buttons on a card do. +- Targets stay marked after damage, for a second attack. They are cleared when the turn or round moves on, when the encounter begins, ends or is - reopened, or when the target is removed. + reopened; a target that is removed from the encounter is unmarked. + +**Area attacks** + +An attack on several targets is harder to land and its damage is shared. + +- **Harder to hit.** Every target's AC counts as **2 higher for each target + beyond the first**: + + | Targets | Added to each AC | + |---|---| + | 1 | 0 | + | 2 | +2 | + | 3 | +4 | + | 4 | +6 | + + The attack bar shows the AC to beat with the penalty already added + ("17 (15 +2)"), and each target's card says "area attack, AC +2". +- **Who was hit.** Each target has a tick box, ticked to begin with. Untick + anyone the roll missed; they take nothing. +- **Sharing the amount.** Type the total. "Split between those hit" (the + default) shares it evenly, any remainder going one point each to the first + targets marked: 7 between three is 3, 2 and 2. "Full amount to each" gives + every target hit the whole amount. Each target's share is shown before it + is applied. +- A target with no hit points to track is left out of the sharing. The app does not roll the attack or decide whether it hits: the DM compares -the roll with the AC shown and types the damage. +the roll with the AC shown and types the damage. The +2 is fixed in the code +(`AREA_AC_STEP` in `frontend/src/pages/EncounterPage.tsx`). ## 8. Conditions @@ -297,7 +323,7 @@ information cannot be dug out of the page. | Add or remove anyone | Yes | No | | Change HP or conditions | Yes | No | | Enter an initiative roll | Yes, for anyone | For their own character only | -| Mark a target | Yes, any time | On their own character's turn | +| Mark targets | Yes, any time | On their own character's turn | | Enter a death save, or stabilise | Yes, for anyone | For their own character only | | Set death-save tallies by hand, revive the dead | Yes | No | | Advance turns and rounds | Yes | No | diff --git a/frontend/package.json b/frontend/package.json index 5047251..c9a08b3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.6.0", + "version": "1.7.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/queries.ts b/frontend/src/api/queries.ts index 0f8a7bf..1a34583 100644 --- a/frontend/src/api/queries.ts +++ b/frontend/src/api/queries.ts @@ -750,12 +750,12 @@ export function usePatchEncounter(encounterId: string) { } type EncounterStatusInput = 'PLANNING' | 'ACTIVE' | 'COMPLETE'; -// Marks a combatant as the target (null clears it). The DM at any time; a -// player while their own character is on turn. -export function useSetTarget(encounterId: string) { +// Sets which combatants are marked as targets (an empty list clears them). +// The DM at any time; a player while their own character is on turn. +export function useSetTargets(encounterId: string) { const qc = useQueryClient(); return useMutation({ - mutationFn: async (participantId: string | null) => (await apiClient.put(`/encounters/${encounterId}/target`, { participantId })).data, + mutationFn: async (participantIds: string[]) => (await apiClient.put(`/encounters/${encounterId}/targets`, { participantIds })).data, onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }), }); } diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 4c99f33..3b105b5 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -447,8 +447,9 @@ export interface Encounter { status: EncounterStatus; currentRound: number; activeParticipantId: string | null; - // The combatant marked as the target right now, for everyone to see. - targetParticipantId: string | null; + // The combatants marked as targets right now, for everyone to see, in the + // order they were picked. More than one is an area attack. + targetParticipantIds: string[]; description: string | null; dmNotes?: string | null; // DM only // Where the scene is. A player is only told once their character has been there. diff --git a/frontend/src/index.css b/frontend/src/index.css index 60d757e..ea2e94c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -835,6 +835,17 @@ table.data-table tbody tr:hover { z-index: 20; border-color: var(--danger-strong); } +.attack-target { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-top: 0.5rem; +} +.attack-target-name { + font-family: var(--font-display); +} .attack-bar-title { font-family: var(--font-display); font-size: 1.1rem; diff --git a/frontend/src/pages/EncounterPage.tsx b/frontend/src/pages/EncounterPage.tsx index 820c3ab..a419b0a 100644 --- a/frontend/src/pages/EncounterPage.tsx +++ b/frontend/src/pages/EncounterPage.tsx @@ -14,7 +14,7 @@ import { useRemoveParticipant, useAdvanceRound, useNextTurn, - useSetTarget, + useSetTargets, usePatchEncounter, useSetEncounterArchived, useDeleteEncounter, @@ -63,6 +63,11 @@ const CONDITIONS = [ 'Concentrating', ]; +// An attack on more than one target is an area attack, and harder to land: +// every target's AC counts as this much higher for each target beyond the first. +const AREA_AC_STEP = 2; +const areaPenalty = (targetCount: number) => Math.max(0, targetCount - 1) * AREA_AC_STEP; + const KIND_LABELS = { CHARACTER: 'PC', MONSTER: 'Monster', NPC: 'NPC' } as const; const KIND_BADGES = { CHARACTER: 'badge-player', MONSTER: 'badge-hostile', NPC: 'badge-friendly' } as const; const MODE_LABELS: Record = { BATTLE: 'โš” Battle', QUEST: '๐Ÿ“œ Quest' }; @@ -101,6 +106,7 @@ function CombatantCard({ onPatch, canRollInitiative, targetedBy, + acPenalty, onTarget, onRemove, onError, @@ -112,11 +118,13 @@ function CombatantCard({ // The DM for anyone; a player for their own character. The same goes for // that character's death saves. canRollInitiative: boolean; - // Set while this combatant is the target: who is going for them, or '' when + // Set while this combatant is a target: who is going for them, or '' when // nobody is on turn. targetedBy?: string; - // Marks this combatant as the target, or clears it. Absent for someone who - // may not pick one right now. + // 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. onTarget?: () => void; onRemove: () => void; onError: (err: unknown) => void; @@ -163,7 +171,12 @@ function CombatantCard({
{isActive &&
โ–ถ On turn
} - {isTargeted &&
๐ŸŽฏ Targeted{targetedBy && ` by ${targetedBy}`}
} + {isTargeted && ( +
+ ๐ŸŽฏ Targeted{targetedBy && ` by ${targetedBy}`} + {acPenalty > 0 && ` ยท area attack, AC +${acPenalty}`} +
+ )}
{KIND_LABELS[p.kind]} {p.character ? ( @@ -179,7 +192,7 @@ function CombatantCard({
-
- {ac != null && {ac}} - {tracksHp && ( - - {currentHp}/{target.maxHp} - {info && info.tempHp > 0 && ` +${info.tempHp}`} - - )} -
+ {isArea && ( +

+ Area attack: every target's AC counts as {penalty} higher (+{AREA_AC_STEP} for each target beyond the first). Untick anyone the roll missed. +

+ )} + + {targets.map((t) => { + const info = t.characterCombatInfo; + const ac = info?.armorClass ?? t.monster?.ac ?? t.npc?.ac; + const portion = portions[hit.indexOf(t)]; + return ( +
+ {isArea && ( + + )} +
+ {ac != null && ( + + {ac + penalty} + {penalty > 0 && ` (${ac} +${penalty})`} + + )} + {tracksHp(t) ? ( + + {t.currentHp ?? 0}/{t.maxHp} + {info && info.tempHp > 0 && ` +${info.tempHp}`} + + ) : ( + No hit points to track. + )} + {isArea && value > 0 && portion !== undefined && {portion}} +
+ {isArea && ( + + )} +
+ ); + })} + {attacks.map((line) => (
โš” {line}
))} - {tracksHp ? ( + + {targets.some(tracksHp) && (
- {/* Remounted per target, so the cursor lands here as soon as one is marked. */} + {/* Remounted when the targets change, so the cursor lands here as soon as one is marked. */} t.id).join()} autoFocus min={1} max={99999} - placeholder="Amount" - aria-label={`HP change for ${target.displayName}`} + placeholder={isArea ? 'Total' : 'Amount'} + aria-label={isArea ? 'HP change for the targets hit' : `HP change for ${targets[0].displayName}`} value={amount} onChange={(e) => setAmount(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && apply(-1)} style={{ width: 90 }} /> - -
- ) : ( -

{target.displayName} has no hit points to track.

)}
); @@ -761,7 +841,7 @@ export function EncounterPage() { const removeParticipant = useRemoveParticipant(encounterId); const advanceRound = useAdvanceRound(encounterId); const nextTurn = useNextTurn(encounterId); - const setTarget = useSetTarget(encounterId); + const setTargets = useSetTargets(encounterId); const patchEncounter = usePatchEncounter(encounterId); const setArchived = useSetEncounterArchived(encounter?.campaignId ?? ''); const deleteEncounter = useDeleteEncounter(encounter?.campaignId ?? ''); @@ -778,8 +858,9 @@ export function EncounterPage() { const isBattle = encounter.mode === 'BATTLE'; const participants = encounter.participants ?? []; // already in initiative order const active = isBattle && encounter.status === 'ACTIVE' ? participants.find((p) => p.id === encounter.activeParticipantId) : undefined; - const target = participants.find((p) => p.id === encounter.targetParticipantId); - // The DM marks a target whenever they like; a player while their own character is on turn. + const targetIds = encounter.targetParticipantIds ?? []; + const targets = targetIds.map((id) => participants.find((p) => p.id === id)).filter((p): p is EncounterParticipant => p !== undefined); + // The DM marks targets whenever they like; a player while their own character is on turn. const canTarget = isDM || (active?.character != null && active.character.userId === user?.id); const builtCharacters = characters?.filter((c) => c.buildStage === 'COMPLETE') ?? []; const onError = (err: unknown) => setFormError(extractErrorMessage(err)); @@ -809,8 +890,13 @@ 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={p.id === target?.id ? (active && active.id !== p.id ? active.displayName : '') : undefined} - onTarget={canTarget ? () => setTarget.mutate(p.id === target?.id ? null : p.id, fail) : undefined} + targetedBy={targetIds.includes(p.id) ? (active && active.id !== p.id ? active.displayName : '') : undefined} + acPenalty={areaPenalty(targets.length)} + onTarget={ + canTarget + ? () => setTargets.mutate(targetIds.includes(p.id) ? targetIds.filter((id) => id !== p.id) : [...targetIds, p.id], fail) + : undefined + } onRemove={() => removeParticipant.mutate(p.id, fail)} onError={onError} /> @@ -947,12 +1033,13 @@ export function EncounterPage() {
- {isDM && target && ( + {isDM && targets.length > 0 && ( patchParticipant.mutate({ participantId: target.id, currentHp }, fail)} - onClear={() => setTarget.mutate(null, fail)} + targets={targets} + onApply={(changes) => Promise.all(changes.map((change) => patchParticipant.mutateAsync(change))).catch(onError)} + onRemove={(id) => setTargets.mutate(targetIds.filter((x) => x !== id), fail)} + onClear={() => setTargets.mutate([], fail)} /> )} {participants.length === 0 && ( diff --git a/package-lock.json b/package-lock.json index 6d492f5..402ef71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "dnd-campaign-manager", - "version": "1.6.0", + "version": "1.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dnd-campaign-manager", - "version": "1.6.0", + "version": "1.7.0", "workspaces": [ "backend", "frontend" ] }, "backend": { - "version": "1.6.0", + "version": "1.7.0", "dependencies": { "@prisma/client": "^5.20.0", "bcrypt": "^5.1.1", @@ -31,7 +31,7 @@ } }, "frontend": { - "version": "1.6.0", + "version": "1.7.0", "dependencies": { "@tanstack/react-query": "^5.103.2", "axios": "^1.20.0", diff --git a/package.json b/package.json index 48c0b35..81ac436 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dnd-campaign-manager", - "version": "1.6.0", + "version": "1.7.0", "private": true, "description": "D&D 5e campaign manager โ€” monorepo root", "workspaces": [