Compare commits

...
2 Commits
Author SHA1 Message Date
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
joshuaandClaude Opus 5.5 e4456b0df2 V1.6.0: targets and an attack bar in encounters
- One combatant at a time can be marked as the target with the Target
  button on its card. The DM can mark anyone at any time; a player can
  while their own character is on turn in a battle.
- The target's card is outlined for everyone and names who is going for it.
- While a target is marked the DM gets an attack bar above the list: who is
  attacking whom, the target's AC and HP, the attacker's weapons, spells or
  monster attack, and a box to apply damage or healing straight away.
- The target is cleared when the turn or round moves on, when the encounter
  begins, ends or is reopened, or when the target is removed.
- All packages at version 1.6.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:53:18 +02:00
14 changed files with 452 additions and 11 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.5.0",
"version": "1.7.0",
"private": true,
"type": "commonjs",
"main": "src/index.js",
@@ -0,0 +1,2 @@
-- AlterTable: the combatant currently marked as the target
ALTER TABLE "encounters" ADD COLUMN "targetParticipantId" TEXT;
@@ -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";
+4
View File
@@ -535,6 +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, 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.
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
@@ -1912,6 +1912,48 @@ describe('initiative', () => {
});
});
describe('targets', () => {
itIfDb('are picked by the DM or the player on turn, shown to all, and go when the turn does', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('target');
const c = `/campaigns/${campaign.id}`;
const heroId = await buildCharacter(campaign.id, playerAuth, 'Archer');
const goblin = (await request(app).post(`${c}/monsters/from-library`).set(dmAuth).send({ key: 'goblin' })).body.monster;
const e = `/encounters/${(await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Ambush' })).body.encounter.id}`;
const add = async (body) => (await request(app).post(`${e}/participants`).set(dmAuth).send(body)).body.participant;
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, ...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.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 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)).toEqual([]);
expect((await target(playerAuth, second.id, first.id, second.id)).status).toBe(200);
expect(await seen(dmAuth)).toEqual([second.id, first.id]);
// Removing a target unmarks it and leaves the rest.
await request(app).delete(`${e}/participants/${second.id}`).set(dmAuth);
expect(await seen(dmAuth)).toEqual([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)).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.targetParticipantIds).toEqual([hero.id]);
expect((await target(dmAuth)).body.encounter.targetParticipantIds).toEqual([]);
});
});
describe('number limits', () => {
itIfDb('are enforced by the server, whatever a form sends', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('limits');
+52 -3
View File
@@ -262,6 +262,9 @@ router.patch('/:id', requireEncounterAccess, requireDM, async (req, res, next) =
data.activeParticipantId = inTurnOrder(participants)[0]?.id ?? 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);
} catch (err) {
@@ -430,10 +433,15 @@ router.delete('/:id/participants/:participantId', requireEncounterAccess, requir
const index = order.findIndex((p) => p.id === req.params.participantId);
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.
if (req.encounter.activeParticipantId === req.params.participantId) {
// Removing whoever is on turn passes the turn to the next combatant, and
// 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 } });
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);
@@ -442,6 +450,45 @@ router.delete('/:id/participants/:participantId', requireEncounterAccess, requir
}
});
// ---------------------------------------------------------------------------
// Targets — who the combatant on turn is going for
// ---------------------------------------------------------------------------
// The whole list, in the order picked; an empty one clears it.
const targetsSchema = z.object({ participantIds: z.array(z.string()).max(50) });
// 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 = targetsSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const participantIds = [...new Set(parsed.data.participantIds)];
if (!req.isDM) {
const { mode, status, activeParticipantId } = req.encounter;
const onTurn =
mode === 'BATTLE' && status === 'ACTIVE' && activeParticipantId
? await prisma.encounterParticipant.findFirst({
where: { id: activeParticipantId, encounterId: req.encounter.id, character: { userId: req.user.id } },
select: { id: true },
})
: null;
if (!onTurn) return res.status(403).json({ error: "You can only pick targets on your own character's turn" });
}
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);
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Turns and rounds
// ---------------------------------------------------------------------------
@@ -461,6 +508,7 @@ router.post('/:id/next-turn', requireEncounterAccess, requireDM, async (req, res
data: {
status: 'ACTIVE',
activeParticipantId: order[wrapped || index === -1 ? 0 : index + 1].id,
targetParticipantIds: [], // the next combatant picks their own
...(wrapped ? { currentRound: { increment: 1 } } : {}),
},
});
@@ -478,6 +526,7 @@ router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req,
data: {
currentRound: { increment: 1 },
status: 'ACTIVE',
targetParticipantIds: [],
// A new round starts back at the top of the initiative order.
...(req.encounter.mode === 'BATTLE' ? { activeParticipantId: inTurnOrder(participants)[0]?.id ?? null } : {}),
},
+53
View File
@@ -202,6 +202,58 @@ battles only.
combatant it returns to the top and the **round** goes up by one.
- **Next Round** skips straight to the top of the next round.
### Targets
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 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.
- A target's card is outlined in red for everyone and reads "Targeted by
<whoever is on turn>".
- 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 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; 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 +2 is fixed in the code
(`AREA_AC_STEP` in `frontend/src/pages/EncounterPage.tsx`).
## 8. Conditions
The DM adds a condition to any card from the "+ Condition" list, and removes
@@ -271,6 +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 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 |
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.5.0",
"version": "1.7.0",
"type": "module",
"scripts": {
"dev": "vite",
+10
View File
@@ -750,6 +750,16 @@ export function usePatchEncounter(encounterId: string) {
}
type EncounterStatusInput = 'PLANNING' | 'ACTIVE' | 'COMPLETE';
// 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 (participantIds: string[]) => (await apiClient.put(`/encounters/${encounterId}/targets`, { participantIds })).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
});
}
export function useNextTurn(encounterId: string) {
const qc = useQueryClient();
return useMutation({
+3
View File
@@ -447,6 +447,9 @@ export interface Encounter {
status: EncounterStatus;
currentRound: number;
activeParticipantId: 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.
+46
View File
@@ -805,6 +805,52 @@ table.data-table tbody tr:hover {
.combatant.is-down {
opacity: 0.6;
}
/* The marked target stands out even when it is down or not on turn. */
.combatant.is-targeted {
opacity: 1;
border-color: var(--danger-strong);
box-shadow: 0 0 0 1px var(--danger-strong), 0 0 22px rgba(216, 84, 84, 0.3), var(--shadow-deep);
}
.target-marker {
color: var(--danger-strong);
font-family: var(--font-display);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.btn-target {
padding: 0.15rem 0.6rem;
font-size: 0.8rem;
}
.btn-target.is-on {
border-color: var(--danger-strong);
color: var(--danger-strong);
background: var(--danger-soft);
}
/* The DM's attack bar stays in reach while scrolling the initiative order. */
.attack-bar {
position: sticky;
top: 4.5rem;
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;
color: var(--text);
}
.combatant-name {
font-family: var(--font-display);
font-size: 1.1rem;
+224 -1
View File
@@ -14,6 +14,7 @@ import {
useRemoveParticipant,
useAdvanceRound,
useNextTurn,
useSetTargets,
usePatchEncounter,
useSetEncounterArchived,
useDeleteEncounter,
@@ -62,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<EncounterMode, string> = { BATTLE: '⚔ Battle', QUEST: '📜 Quest' };
@@ -99,6 +105,9 @@ function CombatantCard({
isActive,
onPatch,
canRollInitiative,
targetedBy,
acPenalty,
onTarget,
onRemove,
onError,
}: {
@@ -109,6 +118,14 @@ 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 a target: who is going for them, or '' when
// nobody is on turn.
targetedBy?: string;
// 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;
}) {
@@ -131,6 +148,7 @@ function CombatantCard({
const isDown = tracksHp && pct <= 0;
// A dying character still has something to do each turn, so its card stays lit.
const isDying = info?.deathSaves.state === 'DYING';
const isTargeted = targetedBy !== undefined;
// Players get a monster's name and health bar only; the stat block is the DM's.
const monsterStats = monster && monster.ac !== undefined ? monster : null;
const hasDetails = Boolean(info || monsterStats || npc?.dmNotes);
@@ -149,10 +167,16 @@ function CombatantCard({
}
return (
<div className={`card combatant combatant-${p.kind.toLowerCase()}${isActive ? ' is-active' : ''}${isDown && !isDying ? ' is-down' : ''}`}>
<div className={`card combatant combatant-${p.kind.toLowerCase()}${isActive ? ' is-active' : ''}${isDown && !isDying ? ' is-down' : ''}${isTargeted ? ' is-targeted' : ''}`}>
<div className="flex-between flex-wrap">
<div>
{isActive && <div className="turn-marker">▶ On turn</div>}
{isTargeted && (
<div className="target-marker">
🎯 Targeted{targetedBy && ` by ${targetedBy}`}
{acPenalty > 0 && ` · area attack, AC +${acPenalty}`}
</div>
)}
<div className="flex-row flex-wrap">
<span className={`badge ${KIND_BADGES[p.kind]}`}>{KIND_LABELS[p.kind]}</span>
{p.character ? (
@@ -164,6 +188,16 @@ function CombatantCard({
)}
{npc && <span className={`badge badge-${npc.disposition.toLowerCase()}`}>{npc.disposition}</span>}
{isDown && !info && <span className="badge badge-hostile">Down</span>}
{onTarget && (
<button
className={`btn btn-target${isTargeted ? ' is-on' : ''}`}
aria-pressed={isTargeted}
title={isTargeted ? 'Stop targeting' : `Mark ${p.displayName} as a target. Marking more than one makes it an area attack.`}
onClick={onTarget}
>
🎯 {isTargeted ? 'Targeted' : 'Target'}
</button>
)}
</div>
<div className="combatant-sub">{subline(p)}</div>
</div>
@@ -395,6 +429,174 @@ function CombatantCard({
);
}
// What a combatant hits with, one line per attack: a character's weapons
// (the equipped ones, when any are) and damaging spells, or the single attack
// of a monster or NPC.
function attackLines(p: EncounterParticipant): string[] {
const info = p.characterCombatInfo;
if (info) {
const equipped = info.weapons.filter((w) => w.equipped);
const weapons = (equipped.length ? equipped : info.weapons).map(
(w) => `${w.name}: ${fmtMod(w.attackBonus)} to hit · ${w.damageDice} ${fmtMod(w.damageBonus)} ${w.damageType}`
);
const spells = info.spells
.filter((s) => s.damageDice)
.map((s) => `${s.name}: ${s.damageDice}${info.spellcasting ? ` · ${fmtMod(info.spellcasting.attackBonus)} to hit / DC ${info.spellcasting.saveDC}` : ''}`);
return [...weapons, ...spells];
}
const stats = p.monster ?? p.npc;
if (!stats || (stats.attackBonus == null && !stats.damageDice)) return [];
return [[stats.attackBonus != null && `${fmtMod(stats.attackBonus)} to hit`, stats.damageDice].filter(Boolean).join(' · ')];
}
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
// 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({
attacker,
targets,
onApply,
onRemove,
onClear,
}: {
attacker: EncounterParticipant | undefined;
targets: EncounterParticipant[];
onApply: (changes: { participantId: string; currentHp: number }[]) => void;
onRemove: (participantId: string) => void;
onClear: () => void;
}) {
const [amount, setAmount] = useState('');
const [mode, setMode] = useState<AreaMode>('SPLIT');
const [missed, setMissed] = useState<string[]>([]);
const isArea = targets.length > 1;
const penalty = areaPenalty(targets.length);
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 selfOnly = targets.length === 1 && targets[0].id === attacker?.id;
const attacks = attacker && !selfOnly ? attackLines(attacker) : [];
function apply(sign: 1 | -1) {
if (!value || hit.length === 0) return;
onApply(hit.map((t, i) => ({ participantId: t.id, currentHp: (t.currentHp ?? 0) + sign * portions[i] })));
setAmount('');
setMissed([]);
}
return (
<div className="card attack-bar">
<div className="flex-between flex-wrap">
<div className="attack-bar-title">
{attacker && !selfOnly && <>{attacker.displayName} → </>}🎯 {isArea ? `${targets.length} targets` : targets[0].displayName}
</div>
<button className="btn btn-ghost" onClick={onClear}>
✕ Clear {isArea ? 'targets' : 'target'}
</button>
</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.
</p>
)}
{targets.map((t) => {
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) => (
<div key={line} className="combatant-sub mt-sm">
⚔ {line}
</div>
))}
{targets.some(tracksHp) && (
<div className="flex-row flex-wrap gap-sm mt-sm">
{/* Remounted when the targets change, so the cursor lands here as soon as one is marked. */}
<NumberInput
key={targets.map((t) => t.id).join()}
autoFocus
min={1}
max={99999}
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 }}
/>
{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
</button>
<button className="btn" onClick={() => apply(1)} disabled={!value || hit.length === 0}>
+ Heal
</button>
</div>
)}
</div>
);
}
function rewardLabel(r: EncounterReward): string {
if (r.type === 'GOLD') return `${r.gold} ${COIN_LABELS[r.currency ?? 'GOLD'].toLowerCase()}`;
const entry = r.type === 'WEAPON' ? r.weapon : r.item;
@@ -639,6 +841,7 @@ export function EncounterPage() {
const removeParticipant = useRemoveParticipant(encounterId);
const advanceRound = useAdvanceRound(encounterId);
const nextTurn = useNextTurn(encounterId);
const setTargets = useSetTargets(encounterId);
const patchEncounter = usePatchEncounter(encounterId);
const setArchived = useSetEncounterArchived(encounter?.campaignId ?? '');
const deleteEncounter = useDeleteEncounter(encounter?.campaignId ?? '');
@@ -655,6 +858,10 @@ 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 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));
const fail = { onError };
@@ -683,6 +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={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}
/>
@@ -819,6 +1033,15 @@ export function EncounterPage() {
<div className="encounter-layout mt-md">
<div>
{isDM && targets.length > 0 && (
<AttackBar
attacker={active}
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 && (
<div className="empty-state card">
{isDM
+4 -4
View File
@@ -1,19 +1,19 @@
{
"name": "dnd-campaign-manager",
"version": "1.5.0",
"version": "1.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dnd-campaign-manager",
"version": "1.5.0",
"version": "1.7.0",
"workspaces": [
"backend",
"frontend"
]
},
"backend": {
"version": "1.5.0",
"version": "1.7.0",
"dependencies": {
"@prisma/client": "^5.20.0",
"bcrypt": "^5.1.1",
@@ -31,7 +31,7 @@
}
},
"frontend": {
"version": "1.5.0",
"version": "1.7.0",
"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.5.0",
"version": "1.7.0",
"private": true,
"description": "D&D 5e campaign manager — monorepo root",
"workspaces": [