From 754eb0ee68aa53d6231dc1a5894da327f327b4bb Mon Sep 17 00:00:00 2001 From: Joshua Oosthuizen Date: Tue, 6 Oct 2026 13:11:46 +0200 Subject: [PATCH] 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 --- backend/package.json | 2 +- .../migration.sql | 2 ++ backend/prisma/schema.prisma | 3 +++ backend/src/routes/api.integration.test.js | 16 ++++++++++++++++ backend/src/routes/campaigns.routes.js | 2 ++ docs/ENCOUNTERS.md | 9 +++++---- frontend/package.json | 2 +- frontend/src/api/types.ts | 2 ++ frontend/src/pages/CampaignHomePage.tsx | 14 ++++++++++++++ frontend/src/pages/EncounterPage.tsx | 17 +++++++++++------ package-lock.json | 8 ++++---- package.json | 2 +- 12 files changed, 62 insertions(+), 17 deletions(-) create mode 100644 backend/prisma/migrations/20261007190000_area_attack_ac_penalty/migration.sql diff --git a/backend/package.json b/backend/package.json index 91cb463..b514faf 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "backend", - "version": "1.7.0", + "version": "1.7.1", "private": true, "type": "commonjs", "main": "src/index.js", diff --git a/backend/prisma/migrations/20261007190000_area_attack_ac_penalty/migration.sql b/backend/prisma/migrations/20261007190000_area_attack_ac_penalty/migration.sql new file mode 100644 index 0000000..0ab9be0 --- /dev/null +++ b/backend/prisma/migrations/20261007190000_area_attack_ac_penalty/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 4d72ba3..b4924f2 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -168,6 +168,9 @@ model Campaign { skipMotivationRoll Boolean @default(false) // Off: players see only a health bar for monsters and NPCs, not the numbers. 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 // 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. diff --git a/backend/src/routes/api.integration.test.js b/backend/src/routes/api.integration.test.js index 159a687..b6e3ec6 100644 --- a/backend/src/routes/api.integration.test.js +++ b/backend/src/routes/api.integration.test.js @@ -1954,6 +1954,22 @@ describe('targets', () => { }); }); +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); + }); +}); + describe('number limits', () => { itIfDb('are enforced by the server, whatever a form sends', async () => { const { dmAuth, playerAuth, campaign } = await setupCampaign('limits'); diff --git a/backend/src/routes/campaigns.routes.js b/backend/src/routes/campaigns.routes.js index 634bcab..1b3cc5e 100644 --- a/backend/src/routes/campaigns.routes.js +++ b/backend/src/routes/campaigns.routes.js @@ -25,6 +25,7 @@ function campaignSummary(campaign, role) { startingSpellCount: campaign.startingSpellCount, skipMotivationRoll: campaign.skipMotivationRoll, showMonsterHp: campaign.showMonsterHp, + areaAttackAcPenalty: campaign.areaAttackAcPenalty, ownerId: campaign.ownerId, archivedAt: campaign.archivedAt, role, @@ -54,6 +55,7 @@ const createCampaignSchema = z.object({ startingSpellCount: z.number().int().min(0).max(50).optional(), skipMotivationRoll: z.boolean().optional(), showMonsterHp: z.boolean().optional(), + areaAttackAcPenalty: z.number().int().min(0).max(10).optional(), }); // POST /campaigns — create, creator becomes DM/owner diff --git a/docs/ENCOUNTERS.md b/docs/ENCOUNTERS.md index 3cde0fa..852f64e 100644 --- a/docs/ENCOUNTERS.md +++ b/docs/ENCOUNTERS.md @@ -229,8 +229,10 @@ healed) right now. One target is an ordinary attack; more than one is an 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**: +- **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 | |---|---| @@ -251,8 +253,7 @@ An attack on several targets is harder to land and its damage is shared. - 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 +2 is fixed in the code -(`AREA_AC_STEP` in `frontend/src/pages/EncounterPage.tsx`). +the roll with the AC shown and types the damage. ## 8. Conditions diff --git a/frontend/package.json b/frontend/package.json index c9a08b3..9469f0e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.7.0", + "version": "1.7.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 3b105b5..baa1985 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -93,6 +93,8 @@ export interface Campaign { startingSpellCount: number; skipMotivationRoll: boolean; 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; archivedAt: string | null; // set while the campaign is archived role: 'DM' | 'PLAYER'; diff --git a/frontend/src/pages/CampaignHomePage.tsx b/frontend/src/pages/CampaignHomePage.tsx index 3cde823..ce15fde 100644 --- a/frontend/src/pages/CampaignHomePage.tsx +++ b/frontend/src/pages/CampaignHomePage.tsx @@ -489,6 +489,7 @@ export function CampaignHomePage() { startingSpellCount: Number(fd.get('startingSpellCount')), skipMotivationRoll: fd.get('skipMotivationRoll') === 'on', showMonsterHp: fd.get('showMonsterHp') === 'on', + areaAttackAcPenalty: Number(fd.get('areaAttackAcPenalty')), }); }} > @@ -514,6 +515,19 @@ export function CampaignHomePage() { Players see the exact hit points of monsters and NPCs in encounters (untick for a health bar only) + diff --git a/frontend/src/pages/EncounterPage.tsx b/frontend/src/pages/EncounterPage.tsx index a419b0a..8671d40 100644 --- a/frontend/src/pages/EncounterPage.tsx +++ b/frontend/src/pages/EncounterPage.tsx @@ -64,9 +64,9 @@ const CONDITIONS = [ ]; // 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; +// 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_BADGES = { CHARACTER: 'badge-player', MONSTER: 'badge-hostile', NPC: 'badge-friendly' } as const; @@ -468,12 +468,14 @@ function shares(amount: number, count: number, mode: AreaMode): number[] { function AttackBar({ attacker, targets, + acStep, onApply, onRemove, onClear, }: { attacker: EncounterParticipant | undefined; targets: EncounterParticipant[]; + acStep: number; // the campaign's area attack AC penalty onApply: (changes: { participantId: string; currentHp: number }[]) => void; onRemove: (participantId: string) => void; onClear: () => void; @@ -483,7 +485,7 @@ function AttackBar({ const [missed, setMissed] = useState([]); const isArea = targets.length > 1; - const penalty = areaPenalty(targets.length); + 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; @@ -510,7 +512,8 @@ function AttackBar({ {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. + Area attack{penalty > 0 && `: every target's AC counts as ${penalty} higher (+${acStep} for each target beyond the first)`}. Untick anyone the + roll missed.

)} @@ -861,6 +864,7 @@ export function EncounterPage() { 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 acStep = campaignData?.campaign.areaAttackAcPenalty ?? 0; 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)); @@ -891,7 +895,7 @@ export function EncounterPage() { 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} - acPenalty={areaPenalty(targets.length)} + acPenalty={areaPenalty(targets.length, acStep)} onTarget={ canTarget ? () => setTargets.mutate(targetIds.includes(p.id) ? targetIds.filter((id) => id !== p.id) : [...targetIds, p.id], fail) @@ -1037,6 +1041,7 @@ export function EncounterPage() { Promise.all(changes.map((change) => patchParticipant.mutateAsync(change))).catch(onError)} onRemove={(id) => setTargets.mutate(targetIds.filter((x) => x !== id), fail)} onClear={() => setTargets.mutate([], fail)} diff --git a/package-lock.json b/package-lock.json index 402ef71..00c15e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "dnd-campaign-manager", - "version": "1.7.0", + "version": "1.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dnd-campaign-manager", - "version": "1.7.0", + "version": "1.7.1", "workspaces": [ "backend", "frontend" ] }, "backend": { - "version": "1.7.0", + "version": "1.7.1", "dependencies": { "@prisma/client": "^5.20.0", "bcrypt": "^5.1.1", @@ -31,7 +31,7 @@ } }, "frontend": { - "version": "1.7.0", + "version": "1.7.1", "dependencies": { "@tanstack/react-query": "^5.103.2", "axios": "^1.20.0", diff --git a/package.json b/package.json index 81ac436..e9894ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dnd-campaign-manager", - "version": "1.7.0", + "version": "1.7.1", "private": true, "description": "D&D 5e campaign manager — monorepo root", "workspaces": [