Combat: GM concentration flag on combatants so the save prompt actually fires

The post-damage concentration save was keyed only off Character.concentration, which is
set just when a seated player casts via their own panel — so a GM running a monster/NPC
(or any unseated PC) never got the reminder, defeating the feature. Combatants now carry
an optional concentrating flag with a one-click toggle in the row; noteConcentrationCheck
keys off it (falling back to the linked Character). Verified in-app: toggle + 10 damage →
'roll a DC 10 Constitution save or lose concentration'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 19:40:16 +02:00
parent b491d57949
commit b0309c245b
2 changed files with 18 additions and 2 deletions
+16 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { import {
ArrowDown, ArrowDown,
ArrowUp, ArrowUp,
BrainCircuit,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Dices, Dices,
@@ -81,11 +82,14 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
*/ */
const noteConcentrationCheck = (combatant: Combatant, damage: number) => { const noteConcentrationCheck = (combatant: Combatant, damage: number) => {
if (damage <= 0) return; if (damage <= 0) return;
// The GM-set combatant flag works for any creature (monster/NPC/PC); fall back to
// the linked Character's concentration (set when a seated player casts a spell).
const ch = pcCharacter(combatant); const ch = pcCharacter(combatant);
if (!ch?.concentration) return; const spellName = combatant.concentrating || ch?.concentration?.spellName;
if (!spellName) return;
const dc = concentrationDC(damage); const dc = concentrationDC(damage);
void encountersRepo.mutate(encounter.id, (e) => void encountersRepo.mutate(encounter.id, (e) =>
logEvent(e, `${ch.name}: roll a DC ${dc} Constitution save or lose concentration on ${ch.concentration!.spellName}.`)); logEvent(e, `${combatant.name}: roll a DC ${dc} Constitution save or lose concentration on ${spellName}.`));
}; };
/** Advance the turn; remind (don't roll) when a downed PC's turn begins. */ /** Advance the turn; remind (don't roll) when a downed PC's turn begins. */
@@ -481,6 +485,16 @@ function CombatantRow({
</Badge> </Badge>
)} )}
<span className="smallcaps" style={{ fontSize: 9 }}>{c.kind}</span> <span className="smallcaps" style={{ fontSize: 9 }}>{c.kind}</span>
<button
type="button"
onClick={() => onChange({ concentrating: c.concentrating ? null : 'a spell' })}
className={cn('rounded p-0.5', c.concentrating ? 'text-accent' : 'text-faint hover:text-muted')}
title={c.concentrating ? `Concentrating on ${c.concentrating} — click to clear` : 'Mark as concentrating (prompts a Con save when damaged)'}
aria-label={c.concentrating ? `${c.name} is concentrating; clear` : `Mark ${c.name} as concentrating`}
aria-pressed={!!c.concentrating}
>
<BrainCircuit size={13} aria-hidden />
</button>
{dead && ( {dead && (
<Badge tone="ember"> <Badge tone="ember">
<Skull size={11} aria-hidden /> DOWN <Skull size={11} aria-hidden /> DOWN
+2
View File
@@ -34,6 +34,8 @@ export const combatantSchema = z.object({
ac: int, ac: int,
hp: hpSchema, hp: hpSchema,
conditions: z.array(conditionSchema).default([]), conditions: z.array(conditionSchema).default([]),
/** Spell this combatant is concentrating on (GM-set). Drives the post-damage save prompt. */
concentrating: z.string().max(120).nullable().optional(),
notes: z.string().max(2000).default(''), notes: z.string().max(2000).default(''),
/** damage resistances/immunities snapshotted from the bestiary, if known */ /** damage resistances/immunities snapshotted from the bestiary, if known */
damageDefenses: damageDefensesSchema.optional(), damageDefenses: damageDefensesSchema.optional(),