Compare commits

...
3 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 f8462a6e31 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>
2026-10-06 13:28:28 +02:00
joshuaandClaude Opus 5.5 754eb0ee68 V1.7.1: area attack AC penalty is a campaign setting
- The amount an area attack adds to each target's AC per extra target is
  now set per campaign, from 0 (no penalty) to 10. It starts at 2, so
  existing campaigns behave as before.
- The DM changes it in the campaign settings; the attack bar and the
  targets' cards use it.
- All packages at version 1.7.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:11:46 +02:00
joshuaandClaude Opus 5.5 f8cee43ca2 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 <noreply@anthropic.com>
2026-10-06 13:01:42 +02:00
16 changed files with 315 additions and 120 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "backend", "name": "backend",
"version": "1.6.0", "version": "1.7.2",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"main": "src/index.js", "main": "src/index.js",
@@ -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";
@@ -0,0 +1,2 @@
-- AlterTable: how much an area attack adds to each AC per extra target
ALTER TABLE "campaigns" ADD COLUMN "areaAttackAcPenalty" INTEGER NOT NULL DEFAULT 2;
+6 -2
View File
@@ -168,6 +168,9 @@ model Campaign {
skipMotivationRoll Boolean @default(false) skipMotivationRoll Boolean @default(false)
// Off: players see only a health bar for monsters and NPCs, not the numbers. // Off: players see only a health bar for monsters and NPCs, not the numbers.
showMonsterHp Boolean @default(true) showMonsterHp Boolean @default(true)
// An attack on several targets is harder to land: each target's AC counts
// as this much higher for every target beyond the first. 0 turns it off.
areaAttackAcPenalty Int @default(2)
ownerId String ownerId String
// Set while the campaign is archived: kept in full, but moved off the main // Set while the campaign is archived: kept in full, but moved off the main
// list and closed to new members until the DM restores it. // list and closed to new members until the DM restores it.
@@ -535,9 +538,10 @@ model Encounter {
currentRound Int @default(1) currentRound Int @default(1)
// Whose turn it is in a battle; null until the fight starts. // Whose turn it is in a battle; null until the fight starts.
activeParticipantId String? 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. // 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 description String? // the scene / objective, shown to everyone
dmNotes String? // never sent to players dmNotes String? // never sent to players
// Where the scene takes place. While the encounter is in progress, the // Where the scene takes place. While the encounter is in progress, the
+36 -16
View File
@@ -1923,34 +1923,54 @@ describe('targets', () => {
const hero = await add({ characterId: heroId, initiativeRoll: 20 }); const hero = await add({ characterId: heroId, initiativeRoll: 20 });
const first = await add({ monsterId: goblin.id, initiativeRoll: 10 }); const first = await add({ monsterId: goblin.id, initiativeRoll: 10 });
const second = await add({ monsterId: goblin.id, initiativeRoll: 1 }); const second = await add({ monsterId: goblin.id, initiativeRoll: 1 });
const target = (auth, participantId) => request(app).put(`${e}/target`).set(auth).send({ participantId }); 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.targetParticipantId; const seen = async (auth) => (await request(app).get(e).set(auth)).body.encounter.targetParticipantIds;
// Before the battle only the DM can mark anyone. // Before the battle only the DM can mark anyone.
expect((await target(playerAuth, first.id)).status).toBe(403); expect((await target(playerAuth, first.id)).status).toBe(403);
expect((await target(dmAuth, first.id)).body.encounter.targetParticipantId).toBe(first.id); expect((await target(dmAuth, first.id)).body.encounter.targetParticipantIds).toEqual([first.id]);
expect(await seen(playerAuth)).toBe(first.id); expect(await seen(playerAuth)).toEqual([first.id]);
expect((await target(dmAuth, 'nobody')).status).toBe(400); 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' }); await request(app).patch(e).set(dmAuth).send({ status: 'ACTIVE' });
expect(await seen(dmAuth)).toBeNull(); expect(await seen(dmAuth)).toEqual([]);
expect((await target(playerAuth, second.id)).status).toBe(200); expect((await target(playerAuth, second.id, first.id, second.id)).status).toBe(200);
expect(await seen(dmAuth)).toBe(second.id); 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); 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. // The turn passing clears them, and the player is no longer on turn.
await target(playerAuth, first.id);
await request(app).post(`${e}/next-turn`).set(dmAuth); 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); 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.targetParticipantId).toBe(hero.id); expect((await target(dmAuth, hero.id)).body.encounter.targetParticipantIds).toEqual([hero.id]);
expect((await target(dmAuth, null)).body.encounter.targetParticipantId).toBeNull(); expect((await target(dmAuth)).body.encounter.targetParticipantIds).toEqual([]);
});
});
describe('area attack AC penalty', () => {
itIfDb('is a campaign setting the DM changes, 2 unless told otherwise', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('areapenalty');
const c = `/campaigns/${campaign.id}`;
expect(campaign.areaAttackAcPenalty).toBe(2);
expect((await request(app).patch(c).set(playerAuth).send({ areaAttackAcPenalty: 0 })).status).toBe(403);
expect((await request(app).patch(c).set(dmAuth).send({ areaAttackAcPenalty: 11 })).status).toBe(400);
expect((await request(app).patch(c).set(dmAuth).send({ areaAttackAcPenalty: -1 })).status).toBe(400);
expect((await request(app).patch(c).set(dmAuth).send({ areaAttackAcPenalty: 0 })).body.campaign.areaAttackAcPenalty).toBe(0);
// Players are told it too: their cards show the penalty on a marked target.
expect((await request(app).get(c).set(playerAuth)).body.campaign.areaAttackAcPenalty).toBe(0);
}); });
}); });
+2
View File
@@ -25,6 +25,7 @@ function campaignSummary(campaign, role) {
startingSpellCount: campaign.startingSpellCount, startingSpellCount: campaign.startingSpellCount,
skipMotivationRoll: campaign.skipMotivationRoll, skipMotivationRoll: campaign.skipMotivationRoll,
showMonsterHp: campaign.showMonsterHp, showMonsterHp: campaign.showMonsterHp,
areaAttackAcPenalty: campaign.areaAttackAcPenalty,
ownerId: campaign.ownerId, ownerId: campaign.ownerId,
archivedAt: campaign.archivedAt, archivedAt: campaign.archivedAt,
role, role,
@@ -54,6 +55,7 @@ const createCampaignSchema = z.object({
startingSpellCount: z.number().int().min(0).max(50).optional(), startingSpellCount: z.number().int().min(0).max(50).optional(),
skipMotivationRoll: z.boolean().optional(), skipMotivationRoll: z.boolean().optional(),
showMonsterHp: z.boolean().optional(), showMonsterHp: z.boolean().optional(),
areaAttackAcPenalty: z.number().int().min(0).max(10).optional(),
}); });
// POST /campaigns — create, creator becomes DM/owner // POST /campaigns — create, creator becomes DM/owner
+38 -30
View File
@@ -262,8 +262,8 @@ router.patch('/:id', requireEncounterAccess, requireDM, async (req, res, next) =
data.activeParticipantId = inTurnOrder(participants)[0]?.id ?? null; data.activeParticipantId = inTurnOrder(participants)[0]?.id ?? null;
} }
// A target belongs to the moment; beginning, ending or reopening clears it. // Targets belong to the moment; beginning, ending or reopening clears them.
if (data.status && data.status !== req.encounter.status) data.targetParticipantId = null; if (data.status && data.status !== req.encounter.status) data.targetParticipantIds = [];
await prisma.encounter.update({ where: { id: req.encounter.id }, data }); await prisma.encounter.update({ where: { id: req.encounter.id }, data });
await respondWithEncounter(req, res); 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' }); 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 // 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. // their targets go with the turn. Removing a target just unmarks it.
const { activeParticipantId, targetParticipantId } = req.encounter; const { activeParticipantId, targetParticipantIds } = req.encounter;
if (activeParticipantId === req.params.participantId) { if (activeParticipantId === req.params.participantId) {
const next = order.length > 1 ? order[(index + 1) % order.length] : null; 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 } }); await prisma.encounter.update({ where: { id: req.encounter.id }, data: { activeParticipantId: next ? next.id : null, targetParticipantIds: [] } });
} else if (targetParticipantId === req.params.participantId) { } else if (targetParticipantIds.includes(req.params.participantId)) {
await prisma.encounter.update({ where: { id: req.encounter.id }, data: { targetParticipantId: null } }); 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 prisma.encounterParticipant.delete({ where: { id: req.params.participantId } });
await respondWithEncounter(req, res); await respondWithEncounter(req, res);
@@ -450,38 +451,45 @@ 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; // Combatants can be marked as targets, for everyone to see; the DM's screen
// the DM's screen then offers to apply damage to it. The DM can mark anyone // then offers to apply damage to them. One is an ordinary attack, more is an
// at any time. A player can while their own character is on turn in a battle. // area attack. The DM can mark anyone at any time. A player can while their
router.put('/:id/target', requireEncounterAccess, async (req, res, next) => { // 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 { 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() }); if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { participantId } = parsed.data; 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 a target on your own character's turn" });
} }
if (participantId) { if (onTurnId && participantIds.includes(onTurnId)) {
const target = await prisma.encounterParticipant.findFirst({ where: { id: participantId, encounterId: req.encounter.id }, select: { id: true } }); return res.status(400).json({ error: 'The combatant on turn cannot be their own target' });
if (!target) return res.status(400).json({ error: 'Combatant not found in this encounter' });
} }
await prisma.encounter.update({ where: { id: req.encounter.id }, data: { targetParticipantId: participantId } }); 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: { targetParticipantIds: participantIds } });
await respondWithEncounter(req, res); await respondWithEncounter(req, res);
} catch (err) { } catch (err) {
next(err); next(err);
@@ -507,7 +515,7 @@ router.post('/:id/next-turn', requireEncounterAccess, requireDM, async (req, res
data: { data: {
status: 'ACTIVE', status: 'ACTIVE',
activeParticipantId: order[wrapped || index === -1 ? 0 : index + 1].id, 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 } } : {}), ...(wrapped ? { currentRound: { increment: 1 } } : {}),
}, },
}); });
@@ -525,7 +533,7 @@ router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req,
data: { data: {
currentRound: { increment: 1 }, currentRound: { increment: 1 },
status: 'ACTIVE', status: 'ACTIVE',
targetParticipantId: null, targetParticipantIds: [],
// A new round starts back at the top of the initiative order. // A new round starts back at the top of the initiative order.
...(req.encounter.mode === 'BATTLE' ? { activeParticipantId: inTurnOrder(participants)[0]?.id ?? null } : {}), ...(req.encounter.mode === 'BATTLE' ? { activeParticipantId: inTurnOrder(participants)[0]?.id ?? null } : {}),
}, },
+41 -11
View File
@@ -204,26 +204,56 @@ battles only.
### Targets ### Targets
One combatant at a time can be marked as the **target**: whoever is being Combatants can be marked as **targets**: whoever is being attacked (or
attacked (or healed) right now. 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 - 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** - 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 target's card is outlined in red for everyone and reads "Targeted by - 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>". <whoever is on turn>".
- While a target 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
stays in view while scrolling. It shows who is attacking whom, the target's 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 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 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 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 Enter) and **Heal** change HP exactly as the buttons on a card do.
card do. - Targets stay marked after damage, for a second attack. They are cleared
- The target stays marked after damage, for a second attack. It is cleared
when the turn or round moves on, when the encounter begins, ends or is 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 higher for each target
beyond the first, by the campaign's **area attack AC penalty**. The DM sets
it in the campaign settings, from 0 (no penalty) to 10; it starts at 2, which
gives:
| 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 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.
@@ -297,7 +327,7 @@ information cannot be dug out of the page.
| Add or remove anyone | Yes | No | | Add or remove anyone | Yes | No |
| Change HP or conditions | Yes | No | | Change HP or conditions | Yes | No |
| Enter an initiative roll | Yes, for anyone | For their own character only | | 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 | | 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 | | Set death-save tallies by hand, revive the dead | Yes | No |
| Advance turns and rounds | Yes | No | | Advance turns and rounds | Yes | No |
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "1.6.0", "version": "1.7.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+4 -4
View File
@@ -750,12 +750,12 @@ export function usePatchEncounter(encounterId: string) {
} }
type EncounterStatusInput = 'PLANNING' | 'ACTIVE' | 'COMPLETE'; type EncounterStatusInput = 'PLANNING' | 'ACTIVE' | 'COMPLETE';
// Marks a combatant as the target (null clears it). The DM at any time; a // Sets which combatants are marked as targets (an empty list clears them).
// player while their own character is on turn. // The DM at any time; a player while their own character is on turn.
export function useSetTarget(encounterId: string) { export function useSetTargets(encounterId: string) {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ 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] }), onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
}); });
} }
+5 -2
View File
@@ -93,6 +93,8 @@ export interface Campaign {
startingSpellCount: number; startingSpellCount: number;
skipMotivationRoll: boolean; skipMotivationRoll: boolean;
showMonsterHp: boolean; // off: players get a health bar for monsters and NPCs, no numbers showMonsterHp: boolean; // off: players get a health bar for monsters and NPCs, no numbers
// Added to each target's AC for every target beyond the first in an area attack; 0 is off.
areaAttackAcPenalty: number;
ownerId: string; ownerId: string;
archivedAt: string | null; // set while the campaign is archived archivedAt: string | null; // set while the campaign is archived
role: 'DM' | 'PLAYER'; role: 'DM' | 'PLAYER';
@@ -447,8 +449,9 @@ export interface Encounter {
status: EncounterStatus; status: EncounterStatus;
currentRound: number; currentRound: number;
activeParticipantId: string | null; activeParticipantId: string | null;
// The combatant marked as the target right now, for everyone to see. // The combatants marked as targets right now, for everyone to see, in the
targetParticipantId: string | null; // order they were picked. More than one is an area attack.
targetParticipantIds: string[];
description: string | null; description: string | null;
dmNotes?: string | null; // DM only dmNotes?: string | null; // DM only
// Where the scene is. A player is only told once their character has been there. // Where the scene is. A player is only told once their character has been there.
+11
View File
@@ -835,6 +835,17 @@ table.data-table tbody tr:hover {
z-index: 20; z-index: 20;
border-color: var(--danger-strong); 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 { .attack-bar-title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 1.1rem; font-size: 1.1rem;
+14
View File
@@ -489,6 +489,7 @@ export function CampaignHomePage() {
startingSpellCount: Number(fd.get('startingSpellCount')), startingSpellCount: Number(fd.get('startingSpellCount')),
skipMotivationRoll: fd.get('skipMotivationRoll') === 'on', skipMotivationRoll: fd.get('skipMotivationRoll') === 'on',
showMonsterHp: fd.get('showMonsterHp') === 'on', showMonsterHp: fd.get('showMonsterHp') === 'on',
areaAttackAcPenalty: Number(fd.get('areaAttackAcPenalty')),
}); });
}} }}
> >
@@ -514,6 +515,19 @@ export function CampaignHomePage() {
<input type="checkbox" name="showMonsterHp" defaultChecked={campaign.showMonsterHp} /> <input type="checkbox" name="showMonsterHp" defaultChecked={campaign.showMonsterHp} />
Players see the exact hit points of monsters and NPCs in encounters (untick for a health bar only) Players see the exact hit points of monsters and NPCs in encounters (untick for a health bar only)
</label> </label>
<label className="flex-row flex-wrap gap-sm mt-sm">
Area attack AC penalty
<NumberInput
name="areaAttackAcPenalty"
min={0}
max={10}
keepValue
defaultValue={campaign.areaAttackAcPenalty}
style={{ width: 70 }}
title="An attack on several targets is harder to land: each target's AC counts as this much higher for every target beyond the first. 0 turns the penalty off."
/>
<span className="combatant-sub">added to each target's AC for every target beyond the first (0 for none)</span>
</label>
<button type="submit" className="btn btn-primary mt-md" disabled={updateCampaign.isPending}> <button type="submit" className="btn btn-primary mt-md" disabled={updateCampaign.isPending}>
Save Settings Save Settings
</button> </button>
+140 -48
View File
@@ -14,7 +14,7 @@ import {
useRemoveParticipant, useRemoveParticipant,
useAdvanceRound, useAdvanceRound,
useNextTurn, useNextTurn,
useSetTarget, useSetTargets,
usePatchEncounter, usePatchEncounter,
useSetEncounterArchived, useSetEncounterArchived,
useDeleteEncounter, useDeleteEncounter,
@@ -63,6 +63,11 @@ const CONDITIONS = [
'Concentrating', 'Concentrating',
]; ];
// An attack on more than one target is an area attack, and harder to land:
// every target's AC counts as `step` higher for each target beyond the first.
// The step is the campaign's "area attack AC penalty" setting.
const areaPenalty = (targetCount: number, step: number) => Math.max(0, targetCount - 1) * step;
const KIND_LABELS = { CHARACTER: 'PC', MONSTER: 'Monster', NPC: 'NPC' } as const; 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 KIND_BADGES = { CHARACTER: 'badge-player', MONSTER: 'badge-hostile', NPC: 'badge-friendly' } as const;
const MODE_LABELS: Record<EncounterMode, string> = { BATTLE: '⚔ Battle', QUEST: '📜 Quest' }; const MODE_LABELS: Record<EncounterMode, string> = { BATTLE: '⚔ Battle', QUEST: '📜 Quest' };
@@ -101,6 +106,7 @@ function CombatantCard({
onPatch, onPatch,
canRollInitiative, canRollInitiative,
targetedBy, targetedBy,
acPenalty,
onTarget, onTarget,
onRemove, onRemove,
onError, onError,
@@ -112,11 +118,13 @@ function CombatantCard({
// The DM for anyone; a player for their own character. The same goes for // The DM for anyone; a player for their own character. The same goes for
// that character's death saves. // that character's death saves.
canRollInitiative: boolean; 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. // nobody is on turn.
targetedBy?: string; targetedBy?: string;
// Marks this combatant as the target, or clears it. Absent for someone who // What an area attack adds to this target's AC; 0 when they are the only one.
// may not pick one right now. acPenalty: number;
// Marks this combatant as a target, or unmarks them. Absent for someone who
// 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;
@@ -163,7 +171,12 @@ function CombatantCard({
<div className="flex-between flex-wrap"> <div className="flex-between flex-wrap">
<div> <div>
{isActive && <div className="turn-marker">▶ On turn</div>} {isActive && <div className="turn-marker">▶ On turn</div>}
{isTargeted && <div className="target-marker">🎯 Targeted{targetedBy && ` by ${targetedBy}`}</div>} {isTargeted && (
<div className="target-marker">
🎯 Targeted{targetedBy && ` by ${targetedBy}`}
{acPenalty > 0 && ` · area attack, AC +${acPenalty}`}
</div>
)}
<div className="flex-row flex-wrap"> <div className="flex-row flex-wrap">
<span className={`badge ${KIND_BADGES[p.kind]}`}>{KIND_LABELS[p.kind]}</span> <span className={`badge ${KIND_BADGES[p.kind]}`}>{KIND_LABELS[p.kind]}</span>
{p.character ? ( {p.character ? (
@@ -179,7 +192,7 @@ function CombatantCard({
<button <button
className={`btn btn-target${isTargeted ? ' is-on' : ''}`} className={`btn btn-target${isTargeted ? ' is-on' : ''}`}
aria-pressed={isTargeted} aria-pressed={isTargeted}
title={isTargeted ? 'Stop targeting' : `Mark ${p.displayName} as the target`} title={isTargeted ? 'Stop targeting' : `Mark ${p.displayName} as a target. Marking more than one makes it an area attack.`}
onClick={onTarget} onClick={onTarget}
> >
🎯 {isTargeted ? 'Targeted' : 'Target'} 🎯 {isTargeted ? 'Targeted' : 'Target'}
@@ -436,82 +449,151 @@ function attackLines(p: EncounterParticipant): string[] {
return [[stats.attackBonus != null && `${fmtMod(stats.attackBonus)} to hit`, stats.damageDice].filter(Boolean).join(' · ')]; return [[stats.attackBonus != null && `${fmtMod(stats.attackBonus)} to hit`, stats.damageDice].filter(Boolean).join(' · ')];
} }
// The DM's shortcut once a target is marked: who is attacking whom, the type AreaMode = 'SPLIT' | 'EACH';
// How `amount` lands on `count` targets: all of it on each, or shared out
// evenly, any remainder going one point each to the first targets picked
// (7 between three is 3, 2 and 2).
function shares(amount: number, count: number, mode: AreaMode): number[] {
if (mode === 'EACH') return Array.from({ length: count }, () => amount);
const base = Math.floor(amount / count);
return Array.from({ length: count }, (_, i) => base + (i < amount % count ? 1 : 0));
}
// The DM's shortcut once targets are marked: who is attacking whom, the
// numbers needed to call the hit, and a box to apply the damage without // numbers needed to call the hit, and a box to apply the damage without
// hunting for the target's card. // hunting for each target's card. With more than one target it is an area
// attack: each AC is shown with the penalty added, the DM ticks who was hit,
// and the amount is split between them or dealt to each in full.
function AttackBar({ function AttackBar({
attacker, attacker,
target, targets,
acStep,
onApply, onApply,
onRemove,
onClear, onClear,
}: { }: {
attacker: EncounterParticipant | undefined; attacker: EncounterParticipant | undefined;
target: EncounterParticipant; targets: EncounterParticipant[];
onApply: (currentHp: number) => void; acStep: number; // the campaign's area attack AC penalty
onApply: (changes: { participantId: string; currentHp: number }[]) => void;
onRemove: (participantId: string) => void;
onClear: () => void; onClear: () => void;
}) { }) {
const [amount, setAmount] = useState(''); const [amount, setAmount] = useState('');
const info = target.characterCombatInfo; const [mode, setMode] = useState<AreaMode>('SPLIT');
const ac = info?.armorClass ?? target.monster?.ac ?? target.npc?.ac; const [missed, setMissed] = useState<string[]>([]);
const currentHp = target.currentHp ?? 0;
const tracksHp = (target.maxHp ?? 0) > 0; const isArea = targets.length > 1;
const attacks = attacker && attacker.id !== target.id ? attackLines(attacker) : []; const penalty = areaPenalty(targets.length, acStep);
const tracksHp = (t: EncounterParticipant) => (t.maxHp ?? 0) > 0;
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 attacks = attacker ? attackLines(attacker) : [];
function apply(sign: 1 | -1) { function apply(sign: 1 | -1) {
const value = Number(amount); if (!value || hit.length === 0) return;
if (!value) return; onApply(hit.map((t, i) => ({ participantId: t.id, currentHp: (t.currentHp ?? 0) + sign * portions[i] })));
onApply(currentHp + sign * value);
setAmount(''); setAmount('');
setMissed([]);
} }
return ( return (
<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 && attacker.id !== target.id && <>{attacker.displayName} → </>}🎯 {target.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 target ✕ Clear {isArea ? 'targets' : 'target'}
</button> </button>
</div> </div>
<div className="stat-chips mt-sm"> {isArea && (
{ac != null && <StatChip label="Target AC">{ac}</StatChip>} <p className="combatant-sub" style={{ margin: '0.25rem 0 0' }}>
{tracksHp && ( Area attack{penalty > 0 && `: every target's AC counts as ${penalty} higher (+${acStep} for each target beyond the first)`}. Untick anyone the
<StatChip label="Target HP"> roll missed.
{currentHp}/{target.maxHp} </p>
{info && info.tempHp > 0 && ` +${info.tempHp}`} )}
</StatChip>
)} {targets.map((t) => {
</div> const info = t.characterCombatInfo;
const ac = info?.armorClass ?? t.monster?.ac ?? t.npc?.ac;
const portion = portions[hit.indexOf(t)];
return (
<div key={t.id} className="attack-target">
{isArea && (
<label className="flex-row gap-sm">
<input
type="checkbox"
aria-label={`${t.displayName} was hit`}
disabled={!tracksHp(t)}
checked={hit.includes(t)}
onChange={(e) => setMissed(e.target.checked ? missed.filter((id) => id !== t.id) : [...missed, t.id])}
/>
<span className="attack-target-name">{t.displayName}</span>
</label>
)}
<div className="stat-chips">
{ac != null && (
<StatChip label={isArea ? 'AC to beat' : 'Target AC'}>
{ac + penalty}
{penalty > 0 && ` (${ac} +${penalty})`}
</StatChip>
)}
{tracksHp(t) ? (
<StatChip label={isArea ? 'HP' : 'Target HP'}>
{t.currentHp ?? 0}/{t.maxHp}
{info && info.tempHp > 0 && ` +${info.tempHp}`}
</StatChip>
) : (
<span className="combatant-sub">No hit points to track.</span>
)}
{isArea && value > 0 && portion !== undefined && <StatChip label="Share">{portion}</StatChip>}
</div>
{isArea && (
<button className="btn btn-ghost" aria-label={`Stop targeting ${t.displayName}`} onClick={() => onRemove(t.id)}>
✕
</button>
)}
</div>
);
})}
{attacks.map((line) => ( {attacks.map((line) => (
<div key={line} className="combatant-sub mt-sm"> <div key={line} className="combatant-sub mt-sm">
⚔ {line} ⚔ {line}
</div> </div>
))} ))}
{tracksHp ? (
{targets.some(tracksHp) && (
<div className="flex-row flex-wrap gap-sm mt-sm"> <div className="flex-row flex-wrap gap-sm mt-sm">
{/* 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. */}
<NumberInput <NumberInput
key={target.id} key={targets.map((t) => t.id).join()}
autoFocus autoFocus
min={1} min={1}
max={99999} max={99999}
placeholder="Amount" placeholder={isArea ? 'Total' : 'Amount'}
aria-label={`HP change for ${target.displayName}`} aria-label={isArea ? 'HP change for the targets hit' : `HP change for ${targets[0].displayName}`}
value={amount} value={amount}
onChange={(e) => setAmount(e.target.value)} onChange={(e) => setAmount(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && apply(-1)} onKeyDown={(e) => e.key === 'Enter' && apply(-1)}
style={{ width: 90 }} style={{ width: 90 }}
/> />
<button className="btn btn-danger" onClick={() => apply(-1)} disabled={!amount}> {isArea && (
<select aria-label="How the amount is shared" value={mode} onChange={(e) => setMode(e.target.value as AreaMode)} style={{ width: 'auto' }}>
<option value="SPLIT">Split between those hit</option>
<option value="EACH">Full amount to each</option>
</select>
)}
<button className="btn btn-danger" onClick={() => apply(-1)} disabled={!value || hit.length === 0}>
− Damage − Damage
</button> </button>
<button className="btn" onClick={() => apply(1)} disabled={!amount}> <button className="btn" onClick={() => apply(1)} disabled={!value || hit.length === 0}>
+ Heal + Heal
</button> </button>
</div> </div>
) : (
<p className="combatant-sub">{target.displayName} has no hit points to track.</p>
)} )}
</div> </div>
); );
@@ -761,7 +843,7 @@ export function EncounterPage() {
const removeParticipant = useRemoveParticipant(encounterId); const removeParticipant = useRemoveParticipant(encounterId);
const advanceRound = useAdvanceRound(encounterId); const advanceRound = useAdvanceRound(encounterId);
const nextTurn = useNextTurn(encounterId); const nextTurn = useNextTurn(encounterId);
const setTarget = useSetTarget(encounterId); const setTargets = useSetTargets(encounterId);
const patchEncounter = usePatchEncounter(encounterId); const patchEncounter = usePatchEncounter(encounterId);
const setArchived = useSetEncounterArchived(encounter?.campaignId ?? ''); const setArchived = useSetEncounterArchived(encounter?.campaignId ?? '');
const deleteEncounter = useDeleteEncounter(encounter?.campaignId ?? ''); const deleteEncounter = useDeleteEncounter(encounter?.campaignId ?? '');
@@ -778,8 +860,10 @@ export function EncounterPage() {
const isBattle = encounter.mode === 'BATTLE'; const isBattle = encounter.mode === 'BATTLE';
const participants = encounter.participants ?? []; // already in initiative order const participants = encounter.participants ?? []; // already in initiative order
const active = isBattle && encounter.status === 'ACTIVE' ? participants.find((p) => p.id === encounter.activeParticipantId) : undefined; const active = isBattle && encounter.status === 'ACTIVE' ? participants.find((p) => p.id === encounter.activeParticipantId) : undefined;
const target = participants.find((p) => p.id === encounter.targetParticipantId); const targetIds = encounter.targetParticipantIds ?? [];
// The DM marks a target whenever they like; a player while their own character is on turn. 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 acStep = campaignData?.campaign.areaAttackAcPenalty ?? 0;
const canTarget = isDM || (active?.character != null && active.character.userId === user?.id); const canTarget = isDM || (active?.character != null && active.character.userId === user?.id);
const builtCharacters = characters?.filter((c) => c.buildStage === 'COMPLETE') ?? []; const builtCharacters = characters?.filter((c) => c.buildStage === 'COMPLETE') ?? [];
const onError = (err: unknown) => setFormError(extractErrorMessage(err)); const onError = (err: unknown) => setFormError(extractErrorMessage(err));
@@ -809,8 +893,14 @@ 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={p.id === target?.id ? (active && active.id !== p.id ? active.displayName : '') : undefined} targetedBy={targetIds.includes(p.id) ? (active?.displayName ?? '') : undefined}
onTarget={canTarget ? () => setTarget.mutate(p.id === target?.id ? null : p.id, fail) : undefined} acPenalty={areaPenalty(targets.length, acStep)}
onTarget={
// 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
}
onRemove={() => removeParticipant.mutate(p.id, fail)} onRemove={() => removeParticipant.mutate(p.id, fail)}
onError={onError} onError={onError}
/> />
@@ -947,12 +1037,14 @@ export function EncounterPage() {
<div className="encounter-layout mt-md"> <div className="encounter-layout mt-md">
<div> <div>
{isDM && target && ( {isDM && targets.length > 0 && (
<AttackBar <AttackBar
attacker={active} attacker={active}
target={target} targets={targets}
onApply={(currentHp) => patchParticipant.mutate({ participantId: target.id, currentHp }, fail)} acStep={acStep}
onClear={() => setTarget.mutate(null, fail)} 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 && ( {participants.length === 0 && (
+4 -4
View File
@@ -1,19 +1,19 @@
{ {
"name": "dnd-campaign-manager", "name": "dnd-campaign-manager",
"version": "1.6.0", "version": "1.7.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dnd-campaign-manager", "name": "dnd-campaign-manager",
"version": "1.6.0", "version": "1.7.2",
"workspaces": [ "workspaces": [
"backend", "backend",
"frontend" "frontend"
] ]
}, },
"backend": { "backend": {
"version": "1.6.0", "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.6.0", "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.6.0", "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": [