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>
This commit is contained in:
2026-10-06 13:11:46 +02:00
co-authored by Claude Opus 5.5
parent f8cee43ca2
commit 754eb0ee68
12 changed files with 62 additions and 17 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.7.0",
"version": "1.7.1",
"private": true,
"type": "commonjs",
"main": "src/index.js",
@@ -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;
+3
View File
@@ -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.
@@ -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');
+2
View File
@@ -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
+5 -4
View File
@@ -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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.7.0",
"version": "1.7.1",
"type": "module",
"scripts": {
"dev": "vite",
+2
View File
@@ -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';
+14
View File
@@ -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() {
<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)
</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}>
Save Settings
</button>
+11 -6
View File
@@ -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<string[]>([]);
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({
</div>
{isArea && (
<p className="combatant-sub" style={{ margin: '0.25rem 0 0' }}>
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.
</p>
)}
@@ -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() {
<AttackBar
attacker={active}
targets={targets}
acStep={acStep}
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)}
+4 -4
View File
@@ -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",
+1 -1
View File
@@ -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": [