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:
@@ -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;
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.7.0",
|
||||
"version": "1.7.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Generated
+4
-4
@@ -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
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user