Compare commits

...
1 Commits
Author SHA1 Message Date
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
13 changed files with 316 additions and 11 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.5.0",
"version": "1.6.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;
+3
View File
@@ -535,6 +535,9 @@ 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. Picked by
// the DM or by the player on turn, and cleared when the turn passes.
targetParticipantId String?
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, participantId) => request(app).put(`${e}/target`).set(auth).send({ participantId });
const seen = async (auth) => (await request(app).get(e).set(auth)).body.encounter.targetParticipantId;
// 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.targetParticipantId).toBe(first.id);
expect(await seen(playerAuth)).toBe(first.id);
expect((await target(dmAuth, 'nobody')).status).toBe(400);
// Beginning the battle clears it and puts the hero on turn: now the player can.
await request(app).patch(e).set(dmAuth).send({ status: 'ACTIVE' });
expect(await seen(dmAuth)).toBeNull();
expect((await target(playerAuth, second.id)).status).toBe(200);
expect(await seen(dmAuth)).toBe(second.id);
// Removing the target clears it.
await request(app).delete(`${e}/participants/${second.id}`).set(dmAuth);
expect(await seen(dmAuth)).toBeNull();
// The turn passing clears it, and the player is no longer on turn.
await target(playerAuth, first.id);
await request(app).post(`${e}/next-turn`).set(dmAuth);
expect(await seen(dmAuth)).toBeNull();
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.targetParticipantId).toBe(hero.id);
expect((await target(dmAuth, null)).body.encounter.targetParticipantId).toBeNull();
});
});
describe('number limits', () => {
itIfDb('are enforced by the server, whatever a form sends', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('limits');
+51 -3
View File
@@ -262,6 +262,9 @@ router.patch('/:id', requireEncounterAccess, requireDM, async (req, res, next) =
data.activeParticipantId = inTurnOrder(participants)[0]?.id ?? null;
}
// A target belongs to the moment; beginning, ending or reopening clears it.
if (data.status && data.status !== req.encounter.status) data.targetParticipantId = null;
await prisma.encounter.update({ where: { id: req.encounter.id }, data });
await respondWithEncounter(req, res);
} catch (err) {
@@ -430,10 +433,14 @@ 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
// the target goes with the turn — or with the combatant who was it.
const { activeParticipantId, targetParticipantId } = 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, targetParticipantId: null } });
} else if (targetParticipantId === req.params.participantId) {
await prisma.encounter.update({ where: { id: req.encounter.id }, data: { targetParticipantId: null } });
}
await prisma.encounterParticipant.delete({ where: { id: req.params.participantId } });
await respondWithEncounter(req, res);
@@ -442,6 +449,45 @@ router.delete('/:id/participants/:participantId', requireEncounterAccess, requir
}
});
// ---------------------------------------------------------------------------
// Target — who the combatant on turn is going for
// ---------------------------------------------------------------------------
const targetSchema = z.object({ participantId: z.string().nullable() }); // null clears it
// One combatant at a time can be marked as the target, for everyone to see;
// the DM's screen then offers to apply damage to it. The DM can mark anyone
// at any time. A player can while their own character is on turn in a battle.
router.put('/:id/target', requireEncounterAccess, async (req, res, next) => {
try {
const parsed = targetSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { participantId } = parsed.data;
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 a target on your own character's turn" });
}
if (participantId) {
const target = await prisma.encounterParticipant.findFirst({ where: { id: participantId, encounterId: req.encounter.id }, select: { id: true } });
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 } });
await respondWithEncounter(req, res);
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Turns and rounds
// ---------------------------------------------------------------------------
@@ -461,6 +507,7 @@ router.post('/:id/next-turn', requireEncounterAccess, requireDM, async (req, res
data: {
status: 'ACTIVE',
activeParticipantId: order[wrapped || index === -1 ? 0 : index + 1].id,
targetParticipantId: null, // the next combatant picks their own
...(wrapped ? { currentRound: { increment: 1 } } : {}),
},
});
@@ -478,6 +525,7 @@ router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req,
data: {
currentRound: { increment: 1 },
status: 'ACTIVE',
targetParticipantId: null,
// A new round starts back at the top of the initiative order.
...(req.encounter.mode === 'BATTLE' ? { activeParticipantId: inTurnOrder(participants)[0]?.id ?? null } : {}),
},
+27
View File
@@ -202,6 +202,32 @@ 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
One combatant at a time can be marked as the **target**: whoever is being
attacked (or healed) right now.
- Click **🎯 Target** beside a combatant's name to mark them; click it again
to clear. Marking someone else moves the mark.
- 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.
- The target's card is outlined in red for everyone and reads "Targeted by
<whoever is on turn>".
- While a target 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
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 the target's HP exactly as the buttons on its
card do.
- 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
reopened, or when the target is removed.
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.
## 8. Conditions
The DM adds a condition to any card from the "+ Condition" list, and removes
@@ -271,6 +297,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 a target | 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.6.0",
"type": "module",
"scripts": {
"dev": "vite",
+10
View File
@@ -750,6 +750,16 @@ export function usePatchEncounter(encounterId: string) {
}
type EncounterStatusInput = 'PLANNING' | 'ACTIVE' | 'COMPLETE';
// Marks a combatant as the target (null clears it). The DM at any time; a
// player while their own character is on turn.
export function useSetTarget(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (participantId: string | null) => (await apiClient.put(`/encounters/${encounterId}/target`, { participantId })).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
});
}
export function useNextTurn(encounterId: string) {
const qc = useQueryClient();
return useMutation({
+2
View File
@@ -447,6 +447,8 @@ export interface Encounter {
status: EncounterStatus;
currentRound: number;
activeParticipantId: string | null;
// The combatant marked as the target right now, for everyone to see.
targetParticipantId: string | null;
description: string | null;
dmNotes?: string | null; // DM only
// Where the scene is. A player is only told once their character has been there.
+35
View File
@@ -805,6 +805,41 @@ 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-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;
+137 -1
View File
@@ -14,6 +14,7 @@ import {
useRemoveParticipant,
useAdvanceRound,
useNextTurn,
useSetTarget,
usePatchEncounter,
useSetEncounterArchived,
useDeleteEncounter,
@@ -99,6 +100,8 @@ function CombatantCard({
isActive,
onPatch,
canRollInitiative,
targetedBy,
onTarget,
onRemove,
onError,
}: {
@@ -109,6 +112,12 @@ 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 the target: who is going for them, or '' when
// nobody is on turn.
targetedBy?: string;
// Marks this combatant as the target, or clears it. Absent for someone who
// may not pick one right now.
onTarget?: () => void;
onRemove: () => void;
onError: (err: unknown) => void;
}) {
@@ -131,6 +140,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 +159,11 @@ 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}`}</div>}
<div className="flex-row flex-wrap">
<span className={`badge ${KIND_BADGES[p.kind]}`}>{KIND_LABELS[p.kind]}</span>
{p.character ? (
@@ -164,6 +175,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 the target`}
onClick={onTarget}
>
🎯 {isTargeted ? 'Targeted' : 'Target'}
</button>
)}
</div>
<div className="combatant-sub">{subline(p)}</div>
</div>
@@ -395,6 +416,107 @@ 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(' · ')];
}
// The DM's shortcut once a target is marked: who is attacking whom, the
// numbers needed to call the hit, and a box to apply the damage without
// hunting for the target's card.
function AttackBar({
attacker,
target,
onApply,
onClear,
}: {
attacker: EncounterParticipant | undefined;
target: EncounterParticipant;
onApply: (currentHp: number) => void;
onClear: () => void;
}) {
const [amount, setAmount] = useState('');
const info = target.characterCombatInfo;
const ac = info?.armorClass ?? target.monster?.ac ?? target.npc?.ac;
const currentHp = target.currentHp ?? 0;
const tracksHp = (target.maxHp ?? 0) > 0;
const attacks = attacker && attacker.id !== target.id ? attackLines(attacker) : [];
function apply(sign: 1 | -1) {
const value = Number(amount);
if (!value) return;
onApply(currentHp + sign * value);
setAmount('');
}
return (
<div className="card attack-bar">
<div className="flex-between flex-wrap">
<div className="attack-bar-title">
{attacker && attacker.id !== target.id && <>{attacker.displayName} → </>}🎯 {target.displayName}
</div>
<button className="btn btn-ghost" onClick={onClear}>
✕ Clear target
</button>
</div>
<div className="stat-chips mt-sm">
{ac != null && <StatChip label="Target AC">{ac}</StatChip>}
{tracksHp && (
<StatChip label="Target HP">
{currentHp}/{target.maxHp}
{info && info.tempHp > 0 && ` +${info.tempHp}`}
</StatChip>
)}
</div>
{attacks.map((line) => (
<div key={line} className="combatant-sub mt-sm">
⚔ {line}
</div>
))}
{tracksHp ? (
<div className="flex-row flex-wrap gap-sm mt-sm">
{/* Remounted per target, so the cursor lands here as soon as one is marked. */}
<NumberInput
key={target.id}
autoFocus
min={1}
max={99999}
placeholder="Amount"
aria-label={`HP change for ${target.displayName}`}
value={amount}
onChange={(e) => setAmount(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && apply(-1)}
style={{ width: 90 }}
/>
<button className="btn btn-danger" onClick={() => apply(-1)} disabled={!amount}>
− Damage
</button>
<button className="btn" onClick={() => apply(1)} disabled={!amount}>
+ Heal
</button>
</div>
) : (
<p className="combatant-sub">{target.displayName} has no hit points to track.</p>
)}
</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 +761,7 @@ export function EncounterPage() {
const removeParticipant = useRemoveParticipant(encounterId);
const advanceRound = useAdvanceRound(encounterId);
const nextTurn = useNextTurn(encounterId);
const setTarget = useSetTarget(encounterId);
const patchEncounter = usePatchEncounter(encounterId);
const setArchived = useSetEncounterArchived(encounter?.campaignId ?? '');
const deleteEncounter = useDeleteEncounter(encounter?.campaignId ?? '');
@@ -655,6 +778,9 @@ 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 target = participants.find((p) => p.id === encounter.targetParticipantId);
// The DM marks a target 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 +809,8 @@ 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={p.id === target?.id ? (active && active.id !== p.id ? active.displayName : '') : undefined}
onTarget={canTarget ? () => setTarget.mutate(p.id === target?.id ? null : p.id, fail) : undefined}
onRemove={() => removeParticipant.mutate(p.id, fail)}
onError={onError}
/>
@@ -819,6 +947,14 @@ export function EncounterPage() {
<div className="encounter-layout mt-md">
<div>
{isDM && target && (
<AttackBar
attacker={active}
target={target}
onApply={(currentHp) => patchParticipant.mutate({ participantId: target.id, currentHp }, fail)}
onClear={() => setTarget.mutate(null, 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.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dnd-campaign-manager",
"version": "1.5.0",
"version": "1.6.0",
"workspaces": [
"backend",
"frontend"
]
},
"backend": {
"version": "1.5.0",
"version": "1.6.0",
"dependencies": {
"@prisma/client": "^5.20.0",
"bcrypt": "^5.1.1",
@@ -31,7 +31,7 @@
}
},
"frontend": {
"version": "1.5.0",
"version": "1.6.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.6.0",
"private": true,
"description": "D&D 5e campaign manager — monorepo root",
"workspaces": [