Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4456b0df2 |
@@ -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;
|
||||
@@ -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');
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+4
-4
@@ -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
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user