Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8462a6e31 | ||
|
|
754eb0ee68 | ||
|
|
f8cee43ca2 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "1.6.0",
|
"version": "1.7.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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)
|
skipMotivationRoll Boolean @default(false)
|
||||||
// Off: players see only a health bar for monsters and NPCs, not the numbers.
|
// Off: players see only a health bar for monsters and NPCs, not the numbers.
|
||||||
showMonsterHp Boolean @default(true)
|
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
|
ownerId String
|
||||||
// Set while the campaign is archived: kept in full, but moved off the main
|
// 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.
|
// list and closed to new members until the DM restores it.
|
||||||
@@ -535,9 +538,10 @@ model Encounter {
|
|||||||
currentRound Int @default(1)
|
currentRound Int @default(1)
|
||||||
// Whose turn it is in a battle; null until the fight starts.
|
// Whose turn it is in a battle; null until the fight starts.
|
||||||
activeParticipantId String?
|
activeParticipantId String?
|
||||||
// Who is being attacked (or healed) right now, shown to everyone. Picked by
|
// 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.
|
// the DM or by the player on turn, and cleared when the turn passes.
|
||||||
targetParticipantId String?
|
targetParticipantIds String[] @default([])
|
||||||
description String? // the scene / objective, shown to everyone
|
description String? // the scene / objective, shown to everyone
|
||||||
dmNotes String? // never sent to players
|
dmNotes String? // never sent to players
|
||||||
// Where the scene takes place. While the encounter is in progress, the
|
// Where the scene takes place. While the encounter is in progress, the
|
||||||
|
|||||||
@@ -1923,34 +1923,54 @@ describe('targets', () => {
|
|||||||
const hero = await add({ characterId: heroId, initiativeRoll: 20 });
|
const hero = await add({ characterId: heroId, initiativeRoll: 20 });
|
||||||
const first = await add({ monsterId: goblin.id, initiativeRoll: 10 });
|
const first = await add({ monsterId: goblin.id, initiativeRoll: 10 });
|
||||||
const second = await add({ monsterId: goblin.id, initiativeRoll: 1 });
|
const second = await add({ monsterId: goblin.id, initiativeRoll: 1 });
|
||||||
const target = (auth, participantId) => request(app).put(`${e}/target`).set(auth).send({ participantId });
|
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.targetParticipantId;
|
const seen = async (auth) => (await request(app).get(e).set(auth)).body.encounter.targetParticipantIds;
|
||||||
|
|
||||||
// Before the battle only the DM can mark anyone.
|
// Before the battle only the DM can mark anyone.
|
||||||
expect((await target(playerAuth, first.id)).status).toBe(403);
|
expect((await target(playerAuth, first.id)).status).toBe(403);
|
||||||
expect((await target(dmAuth, first.id)).body.encounter.targetParticipantId).toBe(first.id);
|
expect((await target(dmAuth, first.id)).body.encounter.targetParticipantIds).toEqual([first.id]);
|
||||||
expect(await seen(playerAuth)).toBe(first.id);
|
expect(await seen(playerAuth)).toEqual([first.id]);
|
||||||
expect((await target(dmAuth, 'nobody')).status).toBe(400);
|
expect((await target(dmAuth, first.id, 'nobody')).status).toBe(400);
|
||||||
|
|
||||||
// Beginning the battle clears it and puts the hero on turn: now the player can.
|
// 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' });
|
await request(app).patch(e).set(dmAuth).send({ status: 'ACTIVE' });
|
||||||
expect(await seen(dmAuth)).toBeNull();
|
expect(await seen(dmAuth)).toEqual([]);
|
||||||
expect((await target(playerAuth, second.id)).status).toBe(200);
|
expect((await target(playerAuth, second.id, first.id, second.id)).status).toBe(200);
|
||||||
expect(await seen(dmAuth)).toBe(second.id);
|
expect(await seen(dmAuth)).toEqual([second.id, first.id]);
|
||||||
|
|
||||||
// Removing the target clears it.
|
// Removing a target unmarks it and leaves the rest.
|
||||||
await request(app).delete(`${e}/participants/${second.id}`).set(dmAuth);
|
await request(app).delete(`${e}/participants/${second.id}`).set(dmAuth);
|
||||||
expect(await seen(dmAuth)).toBeNull();
|
expect(await seen(dmAuth)).toEqual([first.id]);
|
||||||
|
|
||||||
// The turn passing clears it, and the player is no longer on turn.
|
// The turn passing clears them, and the player is no longer on turn.
|
||||||
await target(playerAuth, first.id);
|
|
||||||
await request(app).post(`${e}/next-turn`).set(dmAuth);
|
await request(app).post(`${e}/next-turn`).set(dmAuth);
|
||||||
expect(await seen(dmAuth)).toBeNull();
|
expect(await seen(dmAuth)).toEqual([]);
|
||||||
expect((await target(playerAuth, first.id)).status).toBe(403);
|
expect((await target(playerAuth, first.id)).status).toBe(403);
|
||||||
|
|
||||||
|
// Whoever is on turn can't be a target, their own or as part of a group.
|
||||||
|
expect((await target(dmAuth, first.id)).status).toBe(400);
|
||||||
|
expect((await target(dmAuth, hero.id, first.id)).status).toBe(400);
|
||||||
|
|
||||||
// The goblin's turn: the DM marks the hero, and can clear it again.
|
// 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, hero.id)).body.encounter.targetParticipantIds).toEqual([hero.id]);
|
||||||
expect((await target(dmAuth, null)).body.encounter.targetParticipantId).toBeNull();
|
expect((await target(dmAuth)).body.encounter.targetParticipantIds).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ function campaignSummary(campaign, role) {
|
|||||||
startingSpellCount: campaign.startingSpellCount,
|
startingSpellCount: campaign.startingSpellCount,
|
||||||
skipMotivationRoll: campaign.skipMotivationRoll,
|
skipMotivationRoll: campaign.skipMotivationRoll,
|
||||||
showMonsterHp: campaign.showMonsterHp,
|
showMonsterHp: campaign.showMonsterHp,
|
||||||
|
areaAttackAcPenalty: campaign.areaAttackAcPenalty,
|
||||||
ownerId: campaign.ownerId,
|
ownerId: campaign.ownerId,
|
||||||
archivedAt: campaign.archivedAt,
|
archivedAt: campaign.archivedAt,
|
||||||
role,
|
role,
|
||||||
@@ -54,6 +55,7 @@ const createCampaignSchema = z.object({
|
|||||||
startingSpellCount: z.number().int().min(0).max(50).optional(),
|
startingSpellCount: z.number().int().min(0).max(50).optional(),
|
||||||
skipMotivationRoll: z.boolean().optional(),
|
skipMotivationRoll: z.boolean().optional(),
|
||||||
showMonsterHp: z.boolean().optional(),
|
showMonsterHp: z.boolean().optional(),
|
||||||
|
areaAttackAcPenalty: z.number().int().min(0).max(10).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /campaigns — create, creator becomes DM/owner
|
// POST /campaigns — create, creator becomes DM/owner
|
||||||
|
|||||||
@@ -262,8 +262,8 @@ router.patch('/:id', requireEncounterAccess, requireDM, async (req, res, next) =
|
|||||||
data.activeParticipantId = inTurnOrder(participants)[0]?.id ?? null;
|
data.activeParticipantId = inTurnOrder(participants)[0]?.id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A target belongs to the moment; beginning, ending or reopening clears it.
|
// Targets belong to the moment; beginning, ending or reopening clears them.
|
||||||
if (data.status && data.status !== req.encounter.status) data.targetParticipantId = null;
|
if (data.status && data.status !== req.encounter.status) data.targetParticipantIds = [];
|
||||||
|
|
||||||
await prisma.encounter.update({ where: { id: req.encounter.id }, data });
|
await prisma.encounter.update({ where: { id: req.encounter.id }, data });
|
||||||
await respondWithEncounter(req, res);
|
await respondWithEncounter(req, res);
|
||||||
@@ -434,13 +434,14 @@ router.delete('/:id/participants/:participantId', requireEncounterAccess, requir
|
|||||||
if (index === -1) return res.status(404).json({ error: 'Combatant not found in this encounter' });
|
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, and
|
// 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.
|
// their targets go with the turn. Removing a target just unmarks it.
|
||||||
const { activeParticipantId, targetParticipantId } = req.encounter;
|
const { activeParticipantId, targetParticipantIds } = req.encounter;
|
||||||
if (activeParticipantId === req.params.participantId) {
|
if (activeParticipantId === req.params.participantId) {
|
||||||
const next = order.length > 1 ? order[(index + 1) % order.length] : null;
|
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, targetParticipantId: null } });
|
await prisma.encounter.update({ where: { id: req.encounter.id }, data: { activeParticipantId: next ? next.id : null, targetParticipantIds: [] } });
|
||||||
} else if (targetParticipantId === req.params.participantId) {
|
} else if (targetParticipantIds.includes(req.params.participantId)) {
|
||||||
await prisma.encounter.update({ where: { id: req.encounter.id }, data: { targetParticipantId: null } });
|
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 prisma.encounterParticipant.delete({ where: { id: req.params.participantId } });
|
||||||
await respondWithEncounter(req, res);
|
await respondWithEncounter(req, res);
|
||||||
@@ -450,38 +451,45 @@ router.delete('/:id/participants/:participantId', requireEncounterAccess, requir
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Target — who the combatant on turn is going for
|
// Targets — who the combatant on turn is going for
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const targetSchema = z.object({ participantId: z.string().nullable() }); // null clears it
|
// The whole list, in the order picked; an empty one clears it.
|
||||||
|
const targetsSchema = z.object({ participantIds: z.array(z.string()).max(50) });
|
||||||
|
|
||||||
// One combatant at a time can be marked as the target, for everyone to see;
|
// Combatants can be marked as targets, for everyone to see; the DM's screen
|
||||||
// the DM's screen then offers to apply damage to it. The DM can mark anyone
|
// then offers to apply damage to them. One is an ordinary attack, more is an
|
||||||
// at any time. A player can while their own character is on turn in a battle.
|
// area attack. The DM can mark anyone at any time. A player can while their
|
||||||
router.put('/:id/target', requireEncounterAccess, async (req, res, next) => {
|
// own character is on turn in a battle. Nobody can mark the combatant who is
|
||||||
|
// on turn: you don't target yourself.
|
||||||
|
router.put('/:id/targets', requireEncounterAccess, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const parsed = targetSchema.safeParse(req.body);
|
const parsed = targetsSchema.safeParse(req.body);
|
||||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
const { participantId } = parsed.data;
|
const participantIds = [...new Set(parsed.data.participantIds)];
|
||||||
|
|
||||||
|
// Whoever is on turn, when a battle is under way.
|
||||||
|
const { mode, status, activeParticipantId } = req.encounter;
|
||||||
|
const onTurnId = mode === 'BATTLE' && status === 'ACTIVE' ? activeParticipantId : null;
|
||||||
|
|
||||||
if (!req.isDM) {
|
if (!req.isDM) {
|
||||||
const { mode, status, activeParticipantId } = req.encounter;
|
const ownTurn = onTurnId
|
||||||
const onTurn =
|
? await prisma.encounterParticipant.findFirst({
|
||||||
mode === 'BATTLE' && status === 'ACTIVE' && activeParticipantId
|
where: { id: onTurnId, encounterId: req.encounter.id, character: { userId: req.user.id } },
|
||||||
? await prisma.encounterParticipant.findFirst({
|
select: { id: true },
|
||||||
where: { id: activeParticipantId, encounterId: req.encounter.id, character: { userId: req.user.id } },
|
})
|
||||||
select: { id: true },
|
: null;
|
||||||
})
|
if (!ownTurn) return res.status(403).json({ error: "You can only pick targets on your own character's turn" });
|
||||||
: null;
|
|
||||||
if (!onTurn) return res.status(403).json({ error: "You can only pick a target on your own character's turn" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (participantId) {
|
if (onTurnId && participantIds.includes(onTurnId)) {
|
||||||
const target = await prisma.encounterParticipant.findFirst({ where: { id: participantId, encounterId: req.encounter.id }, select: { id: true } });
|
return res.status(400).json({ error: 'The combatant on turn cannot be their own target' });
|
||||||
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 } });
|
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);
|
await respondWithEncounter(req, res);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
@@ -507,7 +515,7 @@ router.post('/:id/next-turn', requireEncounterAccess, requireDM, async (req, res
|
|||||||
data: {
|
data: {
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
activeParticipantId: order[wrapped || index === -1 ? 0 : index + 1].id,
|
activeParticipantId: order[wrapped || index === -1 ? 0 : index + 1].id,
|
||||||
targetParticipantId: null, // the next combatant picks their own
|
targetParticipantIds: [], // the next combatant picks their own
|
||||||
...(wrapped ? { currentRound: { increment: 1 } } : {}),
|
...(wrapped ? { currentRound: { increment: 1 } } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -525,7 +533,7 @@ router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req,
|
|||||||
data: {
|
data: {
|
||||||
currentRound: { increment: 1 },
|
currentRound: { increment: 1 },
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
targetParticipantId: null,
|
targetParticipantIds: [],
|
||||||
// A new round starts back at the top of the initiative order.
|
// A new round starts back at the top of the initiative order.
|
||||||
...(req.encounter.mode === 'BATTLE' ? { activeParticipantId: inTurnOrder(participants)[0]?.id ?? null } : {}),
|
...(req.encounter.mode === 'BATTLE' ? { activeParticipantId: inTurnOrder(participants)[0]?.id ?? null } : {}),
|
||||||
},
|
},
|
||||||
|
|||||||
+41
-11
@@ -204,26 +204,56 @@ battles only.
|
|||||||
|
|
||||||
### Targets
|
### Targets
|
||||||
|
|
||||||
One combatant at a time can be marked as the **target**: whoever is being
|
Combatants can be marked as **targets**: whoever is being attacked (or
|
||||||
attacked (or healed) right now.
|
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
|
- Click **🎯 Target** beside a combatant's name to mark them; click it again
|
||||||
to clear. Marking someone else moves the mark.
|
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**
|
- 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
|
can while their own character is on turn in a battle, so they can show the
|
||||||
DM who they are going for.
|
DM who they are going for.
|
||||||
- The target's card is outlined in red for everyone and reads "Targeted by
|
- The combatant **on turn** cannot be marked: nobody targets themselves, and
|
||||||
|
their card has no Target button until the turn passes. To heal or hurt them
|
||||||
|
on their own turn, use the buttons on their card.
|
||||||
|
- A target's card is outlined in red for everyone and reads "Targeted by
|
||||||
<whoever is on turn>".
|
<whoever is on turn>".
|
||||||
- While a target is marked the DM gets an **attack bar** above the list, which
|
- 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, the target's
|
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
|
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
|
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
|
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
|
Enter) and **Heal** change HP exactly as the buttons on a card do.
|
||||||
card do.
|
- Targets stay marked after damage, for a second attack. They are cleared
|
||||||
- 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
|
when the turn or round moves on, when the encounter begins, ends or is
|
||||||
reopened, or when the target is removed.
|
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 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 |
|
||||||
|
|---|---|
|
||||||
|
| 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 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 roll with the AC shown and types the damage.
|
||||||
@@ -297,7 +327,7 @@ information cannot be dug out of the page.
|
|||||||
| Add or remove anyone | Yes | No |
|
| Add or remove anyone | Yes | No |
|
||||||
| Change HP or conditions | Yes | No |
|
| Change HP or conditions | Yes | No |
|
||||||
| Enter an initiative roll | Yes, for anyone | For their own character only |
|
| Enter an initiative roll | Yes, for anyone | For their own character only |
|
||||||
| Mark a target | Yes, any time | On their own character's turn |
|
| 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 |
|
| 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 |
|
| Set death-save tallies by hand, revive the dead | Yes | No |
|
||||||
| Advance turns and rounds | Yes | No |
|
| Advance turns and rounds | Yes | No |
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.6.0",
|
"version": "1.7.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -750,12 +750,12 @@ export function usePatchEncounter(encounterId: string) {
|
|||||||
}
|
}
|
||||||
type EncounterStatusInput = 'PLANNING' | 'ACTIVE' | 'COMPLETE';
|
type EncounterStatusInput = 'PLANNING' | 'ACTIVE' | 'COMPLETE';
|
||||||
|
|
||||||
// Marks a combatant as the target (null clears it). The DM at any time; a
|
// Sets which combatants are marked as targets (an empty list clears them).
|
||||||
// player while their own character is on turn.
|
// The DM at any time; a player while their own character is on turn.
|
||||||
export function useSetTarget(encounterId: string) {
|
export function useSetTargets(encounterId: string) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (participantId: string | null) => (await apiClient.put(`/encounters/${encounterId}/target`, { participantId })).data,
|
mutationFn: async (participantIds: string[]) => (await apiClient.put(`/encounters/${encounterId}/targets`, { participantIds })).data,
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ export interface Campaign {
|
|||||||
startingSpellCount: number;
|
startingSpellCount: number;
|
||||||
skipMotivationRoll: boolean;
|
skipMotivationRoll: boolean;
|
||||||
showMonsterHp: boolean; // off: players get a health bar for monsters and NPCs, no numbers
|
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;
|
ownerId: string;
|
||||||
archivedAt: string | null; // set while the campaign is archived
|
archivedAt: string | null; // set while the campaign is archived
|
||||||
role: 'DM' | 'PLAYER';
|
role: 'DM' | 'PLAYER';
|
||||||
@@ -447,8 +449,9 @@ export interface Encounter {
|
|||||||
status: EncounterStatus;
|
status: EncounterStatus;
|
||||||
currentRound: number;
|
currentRound: number;
|
||||||
activeParticipantId: string | null;
|
activeParticipantId: string | null;
|
||||||
// The combatant marked as the target right now, for everyone to see.
|
// The combatants marked as targets right now, for everyone to see, in the
|
||||||
targetParticipantId: string | null;
|
// order they were picked. More than one is an area attack.
|
||||||
|
targetParticipantIds: string[];
|
||||||
description: string | null;
|
description: string | null;
|
||||||
dmNotes?: string | null; // DM only
|
dmNotes?: string | null; // DM only
|
||||||
// Where the scene is. A player is only told once their character has been there.
|
// Where the scene is. A player is only told once their character has been there.
|
||||||
|
|||||||
@@ -835,6 +835,17 @@ table.data-table tbody tr:hover {
|
|||||||
z-index: 20;
|
z-index: 20;
|
||||||
border-color: var(--danger-strong);
|
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 {
|
.attack-bar-title {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
|
|||||||
@@ -489,6 +489,7 @@ export function CampaignHomePage() {
|
|||||||
startingSpellCount: Number(fd.get('startingSpellCount')),
|
startingSpellCount: Number(fd.get('startingSpellCount')),
|
||||||
skipMotivationRoll: fd.get('skipMotivationRoll') === 'on',
|
skipMotivationRoll: fd.get('skipMotivationRoll') === 'on',
|
||||||
showMonsterHp: fd.get('showMonsterHp') === '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} />
|
<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)
|
Players see the exact hit points of monsters and NPCs in encounters (untick for a health bar only)
|
||||||
</label>
|
</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}>
|
<button type="submit" className="btn btn-primary mt-md" disabled={updateCampaign.isPending}>
|
||||||
Save Settings
|
Save Settings
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
useRemoveParticipant,
|
useRemoveParticipant,
|
||||||
useAdvanceRound,
|
useAdvanceRound,
|
||||||
useNextTurn,
|
useNextTurn,
|
||||||
useSetTarget,
|
useSetTargets,
|
||||||
usePatchEncounter,
|
usePatchEncounter,
|
||||||
useSetEncounterArchived,
|
useSetEncounterArchived,
|
||||||
useDeleteEncounter,
|
useDeleteEncounter,
|
||||||
@@ -63,6 +63,11 @@ const CONDITIONS = [
|
|||||||
'Concentrating',
|
'Concentrating',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// An attack on more than one target is an area attack, and harder to land:
|
||||||
|
// 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_LABELS = { CHARACTER: 'PC', MONSTER: 'Monster', NPC: 'NPC' } as const;
|
||||||
const KIND_BADGES = { CHARACTER: 'badge-player', MONSTER: 'badge-hostile', NPC: 'badge-friendly' } 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' };
|
const MODE_LABELS: Record<EncounterMode, string> = { BATTLE: '⚔ Battle', QUEST: '📜 Quest' };
|
||||||
@@ -101,6 +106,7 @@ function CombatantCard({
|
|||||||
onPatch,
|
onPatch,
|
||||||
canRollInitiative,
|
canRollInitiative,
|
||||||
targetedBy,
|
targetedBy,
|
||||||
|
acPenalty,
|
||||||
onTarget,
|
onTarget,
|
||||||
onRemove,
|
onRemove,
|
||||||
onError,
|
onError,
|
||||||
@@ -112,11 +118,13 @@ function CombatantCard({
|
|||||||
// The DM for anyone; a player for their own character. The same goes for
|
// The DM for anyone; a player for their own character. The same goes for
|
||||||
// that character's death saves.
|
// that character's death saves.
|
||||||
canRollInitiative: boolean;
|
canRollInitiative: boolean;
|
||||||
// Set while this combatant is the target: who is going for them, or '' when
|
// Set while this combatant is a target: who is going for them, or '' when
|
||||||
// nobody is on turn.
|
// nobody is on turn.
|
||||||
targetedBy?: string;
|
targetedBy?: string;
|
||||||
// Marks this combatant as the target, or clears it. Absent for someone who
|
// What an area attack adds to this target's AC; 0 when they are the only one.
|
||||||
// may not pick one right now.
|
acPenalty: number;
|
||||||
|
// Marks this combatant as a target, or unmarks them. Absent for someone who
|
||||||
|
// may not pick targets right now, and on the card of whoever is on turn.
|
||||||
onTarget?: () => void;
|
onTarget?: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onError: (err: unknown) => void;
|
onError: (err: unknown) => void;
|
||||||
@@ -163,7 +171,12 @@ function CombatantCard({
|
|||||||
<div className="flex-between flex-wrap">
|
<div className="flex-between flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
{isActive && <div className="turn-marker">▶ On turn</div>}
|
{isActive && <div className="turn-marker">▶ On turn</div>}
|
||||||
{isTargeted && <div className="target-marker">🎯 Targeted{targetedBy && ` by ${targetedBy}`}</div>}
|
{isTargeted && (
|
||||||
|
<div className="target-marker">
|
||||||
|
🎯 Targeted{targetedBy && ` by ${targetedBy}`}
|
||||||
|
{acPenalty > 0 && ` · area attack, AC +${acPenalty}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex-row flex-wrap">
|
<div className="flex-row flex-wrap">
|
||||||
<span className={`badge ${KIND_BADGES[p.kind]}`}>{KIND_LABELS[p.kind]}</span>
|
<span className={`badge ${KIND_BADGES[p.kind]}`}>{KIND_LABELS[p.kind]}</span>
|
||||||
{p.character ? (
|
{p.character ? (
|
||||||
@@ -179,7 +192,7 @@ function CombatantCard({
|
|||||||
<button
|
<button
|
||||||
className={`btn btn-target${isTargeted ? ' is-on' : ''}`}
|
className={`btn btn-target${isTargeted ? ' is-on' : ''}`}
|
||||||
aria-pressed={isTargeted}
|
aria-pressed={isTargeted}
|
||||||
title={isTargeted ? 'Stop targeting' : `Mark ${p.displayName} as the target`}
|
title={isTargeted ? 'Stop targeting' : `Mark ${p.displayName} as a target. Marking more than one makes it an area attack.`}
|
||||||
onClick={onTarget}
|
onClick={onTarget}
|
||||||
>
|
>
|
||||||
🎯 {isTargeted ? 'Targeted' : 'Target'}
|
🎯 {isTargeted ? 'Targeted' : 'Target'}
|
||||||
@@ -436,82 +449,151 @@ function attackLines(p: EncounterParticipant): string[] {
|
|||||||
return [[stats.attackBonus != null && `${fmtMod(stats.attackBonus)} to hit`, stats.damageDice].filter(Boolean).join(' · ')];
|
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
|
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
|
// numbers needed to call the hit, and a box to apply the damage without
|
||||||
// hunting for the target's card.
|
// 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({
|
function AttackBar({
|
||||||
attacker,
|
attacker,
|
||||||
target,
|
targets,
|
||||||
|
acStep,
|
||||||
onApply,
|
onApply,
|
||||||
|
onRemove,
|
||||||
onClear,
|
onClear,
|
||||||
}: {
|
}: {
|
||||||
attacker: EncounterParticipant | undefined;
|
attacker: EncounterParticipant | undefined;
|
||||||
target: EncounterParticipant;
|
targets: EncounterParticipant[];
|
||||||
onApply: (currentHp: number) => void;
|
acStep: number; // the campaign's area attack AC penalty
|
||||||
|
onApply: (changes: { participantId: string; currentHp: number }[]) => void;
|
||||||
|
onRemove: (participantId: string) => void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [amount, setAmount] = useState('');
|
const [amount, setAmount] = useState('');
|
||||||
const info = target.characterCombatInfo;
|
const [mode, setMode] = useState<AreaMode>('SPLIT');
|
||||||
const ac = info?.armorClass ?? target.monster?.ac ?? target.npc?.ac;
|
const [missed, setMissed] = useState<string[]>([]);
|
||||||
const currentHp = target.currentHp ?? 0;
|
|
||||||
const tracksHp = (target.maxHp ?? 0) > 0;
|
const isArea = targets.length > 1;
|
||||||
const attacks = attacker && attacker.id !== target.id ? attackLines(attacker) : [];
|
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;
|
||||||
|
const portions = shares(value, hit.length, isArea ? mode : 'EACH');
|
||||||
|
const attacks = attacker ? attackLines(attacker) : [];
|
||||||
|
|
||||||
function apply(sign: 1 | -1) {
|
function apply(sign: 1 | -1) {
|
||||||
const value = Number(amount);
|
if (!value || hit.length === 0) return;
|
||||||
if (!value) return;
|
onApply(hit.map((t, i) => ({ participantId: t.id, currentHp: (t.currentHp ?? 0) + sign * portions[i] })));
|
||||||
onApply(currentHp + sign * value);
|
|
||||||
setAmount('');
|
setAmount('');
|
||||||
|
setMissed([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card attack-bar">
|
<div className="card attack-bar">
|
||||||
<div className="flex-between flex-wrap">
|
<div className="flex-between flex-wrap">
|
||||||
<div className="attack-bar-title">
|
<div className="attack-bar-title">
|
||||||
{attacker && attacker.id !== target.id && <>{attacker.displayName} → </>}🎯 {target.displayName}
|
{attacker && <>{attacker.displayName} → </>}🎯 {isArea ? `${targets.length} targets` : targets[0].displayName}
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-ghost" onClick={onClear}>
|
<button className="btn btn-ghost" onClick={onClear}>
|
||||||
✕ Clear target
|
✕ Clear {isArea ? 'targets' : 'target'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-chips mt-sm">
|
{isArea && (
|
||||||
{ac != null && <StatChip label="Target AC">{ac}</StatChip>}
|
<p className="combatant-sub" style={{ margin: '0.25rem 0 0' }}>
|
||||||
{tracksHp && (
|
Area attack{penalty > 0 && `: every target's AC counts as ${penalty} higher (+${acStep} for each target beyond the first)`}. Untick anyone the
|
||||||
<StatChip label="Target HP">
|
roll missed.
|
||||||
{currentHp}/{target.maxHp}
|
</p>
|
||||||
{info && info.tempHp > 0 && ` +${info.tempHp}`}
|
)}
|
||||||
</StatChip>
|
|
||||||
)}
|
{targets.map((t) => {
|
||||||
</div>
|
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) => (
|
{attacks.map((line) => (
|
||||||
<div key={line} className="combatant-sub mt-sm">
|
<div key={line} className="combatant-sub mt-sm">
|
||||||
⚔ {line}
|
⚔ {line}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{tracksHp ? (
|
|
||||||
|
{targets.some(tracksHp) && (
|
||||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||||
{/* Remounted per target, so the cursor lands here as soon as one is marked. */}
|
{/* Remounted when the targets change, so the cursor lands here as soon as one is marked. */}
|
||||||
<NumberInput
|
<NumberInput
|
||||||
key={target.id}
|
key={targets.map((t) => t.id).join()}
|
||||||
autoFocus
|
autoFocus
|
||||||
min={1}
|
min={1}
|
||||||
max={99999}
|
max={99999}
|
||||||
placeholder="Amount"
|
placeholder={isArea ? 'Total' : 'Amount'}
|
||||||
aria-label={`HP change for ${target.displayName}`}
|
aria-label={isArea ? 'HP change for the targets hit' : `HP change for ${targets[0].displayName}`}
|
||||||
value={amount}
|
value={amount}
|
||||||
onChange={(e) => setAmount(e.target.value)}
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && apply(-1)}
|
onKeyDown={(e) => e.key === 'Enter' && apply(-1)}
|
||||||
style={{ width: 90 }}
|
style={{ width: 90 }}
|
||||||
/>
|
/>
|
||||||
<button className="btn btn-danger" onClick={() => apply(-1)} disabled={!amount}>
|
{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
|
− Damage
|
||||||
</button>
|
</button>
|
||||||
<button className="btn" onClick={() => apply(1)} disabled={!amount}>
|
<button className="btn" onClick={() => apply(1)} disabled={!value || hit.length === 0}>
|
||||||
+ Heal
|
+ Heal
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<p className="combatant-sub">{target.displayName} has no hit points to track.</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -761,7 +843,7 @@ export function EncounterPage() {
|
|||||||
const removeParticipant = useRemoveParticipant(encounterId);
|
const removeParticipant = useRemoveParticipant(encounterId);
|
||||||
const advanceRound = useAdvanceRound(encounterId);
|
const advanceRound = useAdvanceRound(encounterId);
|
||||||
const nextTurn = useNextTurn(encounterId);
|
const nextTurn = useNextTurn(encounterId);
|
||||||
const setTarget = useSetTarget(encounterId);
|
const setTargets = useSetTargets(encounterId);
|
||||||
const patchEncounter = usePatchEncounter(encounterId);
|
const patchEncounter = usePatchEncounter(encounterId);
|
||||||
const setArchived = useSetEncounterArchived(encounter?.campaignId ?? '');
|
const setArchived = useSetEncounterArchived(encounter?.campaignId ?? '');
|
||||||
const deleteEncounter = useDeleteEncounter(encounter?.campaignId ?? '');
|
const deleteEncounter = useDeleteEncounter(encounter?.campaignId ?? '');
|
||||||
@@ -778,8 +860,10 @@ export function EncounterPage() {
|
|||||||
const isBattle = encounter.mode === 'BATTLE';
|
const isBattle = encounter.mode === 'BATTLE';
|
||||||
const participants = encounter.participants ?? []; // already in initiative order
|
const participants = encounter.participants ?? []; // already in initiative order
|
||||||
const active = isBattle && encounter.status === 'ACTIVE' ? participants.find((p) => p.id === encounter.activeParticipantId) : undefined;
|
const active = isBattle && encounter.status === 'ACTIVE' ? participants.find((p) => p.id === encounter.activeParticipantId) : undefined;
|
||||||
const target = participants.find((p) => p.id === encounter.targetParticipantId);
|
const targetIds = encounter.targetParticipantIds ?? [];
|
||||||
// The DM marks a target whenever they like; a player while their own character is on turn.
|
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 canTarget = isDM || (active?.character != null && active.character.userId === user?.id);
|
||||||
const builtCharacters = characters?.filter((c) => c.buildStage === 'COMPLETE') ?? [];
|
const builtCharacters = characters?.filter((c) => c.buildStage === 'COMPLETE') ?? [];
|
||||||
const onError = (err: unknown) => setFormError(extractErrorMessage(err));
|
const onError = (err: unknown) => setFormError(extractErrorMessage(err));
|
||||||
@@ -809,8 +893,14 @@ export function EncounterPage() {
|
|||||||
isActive={p.id === active?.id}
|
isActive={p.id === active?.id}
|
||||||
onPatch={(data) => patchParticipant.mutate({ participantId: p.id, ...data }, fail)}
|
onPatch={(data) => patchParticipant.mutate({ participantId: p.id, ...data }, fail)}
|
||||||
canRollInitiative={isDM || (p.character != null && p.character.userId === user?.id)}
|
canRollInitiative={isDM || (p.character != null && p.character.userId === user?.id)}
|
||||||
targetedBy={p.id === target?.id ? (active && active.id !== p.id ? active.displayName : '') : undefined}
|
targetedBy={targetIds.includes(p.id) ? (active?.displayName ?? '') : undefined}
|
||||||
onTarget={canTarget ? () => setTarget.mutate(p.id === target?.id ? null : p.id, fail) : undefined}
|
acPenalty={areaPenalty(targets.length, acStep)}
|
||||||
|
onTarget={
|
||||||
|
// Nobody targets themselves: the combatant on turn has no button.
|
||||||
|
canTarget && p.id !== active?.id
|
||||||
|
? () => setTargets.mutate(targetIds.includes(p.id) ? targetIds.filter((id) => id !== p.id) : [...targetIds, p.id], fail)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onRemove={() => removeParticipant.mutate(p.id, fail)}
|
onRemove={() => removeParticipant.mutate(p.id, fail)}
|
||||||
onError={onError}
|
onError={onError}
|
||||||
/>
|
/>
|
||||||
@@ -947,12 +1037,14 @@ export function EncounterPage() {
|
|||||||
|
|
||||||
<div className="encounter-layout mt-md">
|
<div className="encounter-layout mt-md">
|
||||||
<div>
|
<div>
|
||||||
{isDM && target && (
|
{isDM && targets.length > 0 && (
|
||||||
<AttackBar
|
<AttackBar
|
||||||
attacker={active}
|
attacker={active}
|
||||||
target={target}
|
targets={targets}
|
||||||
onApply={(currentHp) => patchParticipant.mutate({ participantId: target.id, currentHp }, fail)}
|
acStep={acStep}
|
||||||
onClear={() => setTarget.mutate(null, fail)}
|
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 && (
|
{participants.length === 0 && (
|
||||||
|
|||||||
Generated
+4
-4
@@ -1,19 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "dnd-campaign-manager",
|
"name": "dnd-campaign-manager",
|
||||||
"version": "1.6.0",
|
"version": "1.7.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "dnd-campaign-manager",
|
"name": "dnd-campaign-manager",
|
||||||
"version": "1.6.0",
|
"version": "1.7.2",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"backend",
|
"backend",
|
||||||
"frontend"
|
"frontend"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"backend": {
|
"backend": {
|
||||||
"version": "1.6.0",
|
"version": "1.7.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.20.0",
|
"@prisma/client": "^5.20.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"frontend": {
|
"frontend": {
|
||||||
"version": "1.6.0",
|
"version": "1.7.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.103.2",
|
"@tanstack/react-query": "^5.103.2",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.20.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dnd-campaign-manager",
|
"name": "dnd-campaign-manager",
|
||||||
"version": "1.6.0",
|
"version": "1.7.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "D&D 5e campaign manager — monorepo root",
|
"description": "D&D 5e campaign manager — monorepo root",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
Reference in New Issue
Block a user