V1.5.0: death saves for characters at 0 HP
- A character at 0 HP is Dying, Stable or Dead. The tallies live on the character, so the sheet and the encounter card always agree. - Death saves are typed in as the d20 showed: 10 or more succeeds, less fails, a 1 fails twice, a 20 puts the character back up at 1 HP. Three successes make them stable; three failures kill. - Damage taken at 0 HP is a failed save and ends stable. A blow that leaves as much as max HP still to take after reaching 0 kills outright. - Healing from 0 clears the tallies. The dead cannot be healed; the DM revives them at 1 HP. - A player rolls and stabilises for their own character; the DM for anyone, and only the DM sets tallies by hand or revives. - The party overview, campaign list and quick stats show the state. - All packages at version 1.5.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -163,6 +163,28 @@ export function usePatchCharacter(characterId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// What can be done for a character at 0 HP: enter a death save (what the d20
|
||||
// showed), steady them, or — the DM only — set the tallies by hand or bring
|
||||
// back the dead.
|
||||
export type DeathSaveAction =
|
||||
| { action: 'roll'; roll: number }
|
||||
| { action: 'stabilize' }
|
||||
| { action: 'set'; successes: number; failures: number }
|
||||
| { action: 'revive' };
|
||||
|
||||
export function useDeathSave(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: DeathSaveAction) =>
|
||||
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/death-saves`, data)).data.character,
|
||||
onSuccess: (character) => {
|
||||
invalidateCharacter(qc, characterId); // the sheet and its quick stats
|
||||
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows the sheet's HP
|
||||
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCharacter(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -108,6 +108,17 @@ export interface CampaignMemberSummary {
|
||||
// The coins a character carries. 1 gold = 10 silver = 100 copper.
|
||||
export type Currency = 'GOLD' | 'SILVER' | 'COPPER';
|
||||
|
||||
// Where a character stands at 0 HP. UP: on their feet. DYING: rolling death
|
||||
// saves, three of either kind settles it. STABLE: at 0 HP but no longer
|
||||
// rolling. DEAD: three failures, or one blow too big to survive.
|
||||
export type LifeState = 'UP' | 'DYING' | 'STABLE' | 'DEAD';
|
||||
|
||||
export interface DeathSaves {
|
||||
state: LifeState;
|
||||
successes: number;
|
||||
failures: number;
|
||||
}
|
||||
|
||||
export interface CharacterSummary {
|
||||
id: string;
|
||||
campaignId: string;
|
||||
@@ -121,6 +132,7 @@ export interface CharacterSummary {
|
||||
buildStage: BuildStage;
|
||||
currentHp: number;
|
||||
tempHp: number;
|
||||
deathSaves: DeathSaves;
|
||||
// The purse; all three are null for another player's character.
|
||||
gold: number | null;
|
||||
silver: number | null;
|
||||
@@ -274,6 +286,7 @@ export interface CharacterDetail {
|
||||
rolledScores: Record<Ability, number>;
|
||||
currentHp: number;
|
||||
tempHp: number;
|
||||
deathSaves: DeathSaves;
|
||||
armorBonus: number;
|
||||
specialAbilities: string | null;
|
||||
background: string | null;
|
||||
@@ -386,6 +399,7 @@ export interface CharacterCombatInfo {
|
||||
characterClass: CharacterClass | null;
|
||||
armorClass: number;
|
||||
tempHp: number;
|
||||
deathSaves: DeathSaves;
|
||||
speed: number;
|
||||
initiativeBonus: number;
|
||||
passivePerception: number;
|
||||
@@ -524,6 +538,7 @@ export interface QuickStats {
|
||||
currentHp: number;
|
||||
maxHp: number;
|
||||
tempHp: number;
|
||||
deathSaves: DeathSaves;
|
||||
armorClass: number;
|
||||
initiative: number;
|
||||
speed: number;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useState } from 'react';
|
||||
import { useDeathSave } from '../api/queries';
|
||||
import { InfoTip } from './InfoTip';
|
||||
import { NumberInput } from './NumberInput';
|
||||
import type { DeathSaves as DeathSavesInfo } from '../api/types';
|
||||
|
||||
const STATE_LABELS = { DYING: 'Dying', STABLE: 'Stable', DEAD: '☠ Dead' } as const;
|
||||
|
||||
// Nothing for a character on their feet; otherwise where they stand at 0 HP.
|
||||
export function LifeBadge({ deathSaves }: { deathSaves: DeathSavesInfo }) {
|
||||
if (deathSaves.state === 'UP') return null;
|
||||
return <span className={`badge ${deathSaves.state === 'STABLE' ? '' : 'badge-hostile'}`}>{STATE_LABELS[deathSaves.state]}</span>;
|
||||
}
|
||||
|
||||
// Three pips for one tally. For the DM each is a button: clicking a pip sets
|
||||
// the tally to it, and clicking the last filled one takes it back off.
|
||||
function Pips({ kind, count, onSet }: { kind: 'success' | 'failure'; count: number; onSet?: (count: number) => void }) {
|
||||
const noun = kind === 'success' ? 'successes' : 'failures';
|
||||
return (
|
||||
<span className="death-pips" role="group" aria-label={`${count} of 3 ${noun}`}>
|
||||
{[1, 2, 3].map((n) => {
|
||||
const className = `death-pip is-${kind}${n <= count ? ' is-filled' : ''}`;
|
||||
return onSet ? (
|
||||
<button key={n} type="button" className={className} aria-label={`Set ${noun} to ${n === count ? n - 1 : n}`} onClick={() => onSet(n === count ? n - 1 : n)} />
|
||||
) : (
|
||||
<span key={n} className={className} />
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// What happens to a character at 0 HP: the death-save tallies, and the ways
|
||||
// out of them. `canAct` is the character's own player or the DM; setting the
|
||||
// tallies by hand and raising the dead are the DM's alone.
|
||||
export function DeathSaves({
|
||||
characterId,
|
||||
name,
|
||||
deathSaves,
|
||||
canAct,
|
||||
isDM,
|
||||
onError,
|
||||
}: {
|
||||
characterId: string;
|
||||
name: string;
|
||||
deathSaves: DeathSavesInfo;
|
||||
canAct: boolean;
|
||||
isDM: boolean;
|
||||
onError?: (err: unknown) => void;
|
||||
}) {
|
||||
const deathSave = useDeathSave(characterId);
|
||||
const [roll, setRoll] = useState('');
|
||||
const { state, successes, failures } = deathSaves;
|
||||
if (state === 'UP') return null;
|
||||
|
||||
const send = (action: Parameters<typeof deathSave.mutate>[0]) => deathSave.mutate(action, { onError });
|
||||
|
||||
function handleRoll() {
|
||||
const value = Number(roll);
|
||||
if (!roll || !Number.isInteger(value)) return;
|
||||
send({ action: 'roll', roll: value });
|
||||
setRoll('');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`death-saves is-${state.toLowerCase()}`}>
|
||||
<div className="flex-row flex-wrap">
|
||||
<LifeBadge deathSaves={deathSaves} />
|
||||
{state === 'DYING' && (
|
||||
<>
|
||||
<span className="death-tally">
|
||||
Saved <Pips kind="success" count={successes} onSet={isDM ? (n) => send({ action: 'set', successes: n, failures }) : undefined} />
|
||||
</span>
|
||||
<span className="death-tally">
|
||||
Failed <Pips kind="failure" count={failures} onSet={isDM ? (n) => send({ action: 'set', successes, failures: n }) : undefined} />
|
||||
</span>
|
||||
<InfoTip term="DEATH_SAVES" />
|
||||
</>
|
||||
)}
|
||||
{state === 'STABLE' && <span className="combatant-sub">Out cold at 0 HP, but no longer rolling. Any healing wakes them; damage starts the saves again.</span>}
|
||||
{state === 'DEAD' && <span className="combatant-sub">{isDM ? 'Healing does nothing now.' : 'Only the DM can bring them back.'}</span>}
|
||||
</div>
|
||||
|
||||
{state === 'DYING' && canAct && (
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={20}
|
||||
placeholder="d20"
|
||||
aria-label={`Death save roll for ${name}`}
|
||||
title="Type what the d20 showed (1 to 20)."
|
||||
value={roll}
|
||||
onChange={(e) => setRoll(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleRoll()}
|
||||
style={{ width: 64 }}
|
||||
/>
|
||||
<button className="btn" onClick={handleRoll} disabled={!roll || deathSave.isPending}>
|
||||
Death save
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
title="Someone steadied them: a Medicine check, a healer's kit or a spell. They stay at 0 HP but stop rolling."
|
||||
onClick={() => send({ action: 'stabilize' })}
|
||||
disabled={deathSave.isPending}
|
||||
>
|
||||
Stabilise
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'DEAD' && isDM && (
|
||||
<button className="btn mt-sm" onClick={() => send({ action: 'revive' })} disabled={deathSave.isPending}>
|
||||
Revive at 1 HP
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export const STAT_HELP: Record<string, string> = {
|
||||
AC: 'Armor Class — how hard you are to hit. 10 + your Dexterity modifier + any armor bonus.',
|
||||
HP: 'Hit Points — damage you can take before falling unconscious. Max HP is set by your class hit die, Constitution modifier, and level.',
|
||||
TEMP_HP: 'Temporary Hit Points — an extra buffer that absorbs damage first and disappears when it runs out (it does not stack with more temp HP).',
|
||||
DEATH_SAVES: 'Death saves — at 0 HP you roll a d20 at the start of each of your turns. 10 or more is a success, less is a failure; a 1 counts as two failures and a 20 puts you back up with 1 HP. Three successes and you are stable; three failures and you die. Being hit while down is a failure (two if it was a critical hit). Any healing ends it.',
|
||||
INITIATIVE: 'Initiative — added to a d20 roll at the start of a fight to decide turn order. Equal to your Dexterity modifier.',
|
||||
PROFICIENCY_BONUS: 'Proficiency Bonus — added to attacks, saves, and skills you are trained in. Grows with level (+2 at level 1, up to +6 at level 20).',
|
||||
PASSIVE_PERCEPTION: 'Passive Perception — your always-on alertness score, used instead of rolling when you are not actively searching. 10 + your Perception skill bonus.',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuickStats } from '../api/queries';
|
||||
import { Spinner, ErrorBanner } from './Spinner';
|
||||
import { fmtMod } from '../lib/format';
|
||||
import { HpBar } from './HpBar';
|
||||
import { DeathSaves } from './DeathSaves';
|
||||
|
||||
export function QuickStatsFab({ characterId }: { characterId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -52,6 +53,8 @@ export function QuickStatsDrawer({ characterId, onClose }: { characterId: string
|
||||
</div>
|
||||
</div>
|
||||
<HpBar current={data.currentHp} max={data.maxHp} temp={data.tempHp} />
|
||||
{/* Read-only here; the saves are rolled on the sheet or in the encounter. */}
|
||||
<DeathSaves characterId={characterId} name={data.name} deathSaves={data.deathSaves} canAct={false} isDM={false} />
|
||||
|
||||
{data.spellcasting && (
|
||||
<div className="card mt-md">
|
||||
|
||||
@@ -869,6 +869,55 @@ table.data-table tbody tr:hover {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* At 0 HP: the death-save tallies and what can be done about them. */
|
||||
.death-saves {
|
||||
margin-top: 0.6rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: 6px;
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
.death-saves.is-stable {
|
||||
border-color: var(--border);
|
||||
background: var(--bg-inset);
|
||||
}
|
||||
.death-tally {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.death-pips {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.death-pip {
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--text-dim);
|
||||
background: transparent;
|
||||
}
|
||||
button.death-pip {
|
||||
cursor: pointer;
|
||||
}
|
||||
.death-pip.is-success {
|
||||
border-color: var(--success-strong);
|
||||
}
|
||||
.death-pip.is-failure {
|
||||
border-color: var(--danger-strong);
|
||||
}
|
||||
.death-pip.is-success.is-filled {
|
||||
background: var(--success-strong);
|
||||
}
|
||||
.death-pip.is-failure.is-filled {
|
||||
background: var(--danger-strong);
|
||||
}
|
||||
|
||||
.combatant-details {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
|
||||
@@ -41,6 +41,7 @@ import { useAuth } from '../auth/AuthContext';
|
||||
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { NumberInput } from '../components/NumberInput';
|
||||
import { LifeBadge } from '../components/DeathSaves';
|
||||
import { usePagination, Pagination } from '../components/Paginated';
|
||||
import { SPELL_DAMAGE_GROUPS, SPELL_DAMAGE_SPECIALS, extractErrorMessage, spellLevelLabel, spellRequirementLabel } from '../lib/format';
|
||||
import { speciesIcon } from '../lib/speciesIcons';
|
||||
@@ -102,7 +103,7 @@ function CharacterRow({ character, isDM, onDelete }: { character: CharacterSumma
|
||||
<div className="flex-row">
|
||||
{!inProgress && (
|
||||
<div style={{ textAlign: 'right', fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
||||
HP {character.currentHp}/{character.maxHp} · AC {character.armorClass}
|
||||
<LifeBadge deathSaves={character.deathSaves} /> HP {character.currentHp}/{character.maxHp} · AC {character.armorClass}
|
||||
</div>
|
||||
)}
|
||||
<Link to={inProgress ? `/characters/${character.id}/build` : `/characters/${character.id}`} className="btn">
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { NumberInput } from '../components/NumberInput';
|
||||
import { HpBar } from '../components/HpBar';
|
||||
import { DeathSaves } from '../components/DeathSaves';
|
||||
import { QuickStatsFab } from '../components/QuickStatsDrawer';
|
||||
import { InfoTip } from '../components/InfoTip';
|
||||
import { usePagination, Pagination } from '../components/Paginated';
|
||||
@@ -304,7 +305,9 @@ export function CharacterSheetPage() {
|
||||
const amount = Number(hpDelta);
|
||||
if (!amount) return;
|
||||
const next = character!.currentHp + sign * amount;
|
||||
patchCharacter.mutate({ currentHp: next });
|
||||
setFormError(null);
|
||||
// Healing is refused for a character who has died.
|
||||
patchCharacter.mutate({ currentHp: next }, { onError: (err) => setFormError(extractErrorMessage(err)) });
|
||||
setHpDelta('');
|
||||
}
|
||||
|
||||
@@ -458,6 +461,14 @@ export function CharacterSheetPage() {
|
||||
</div>
|
||||
|
||||
<HpBar current={character.currentHp} max={sheet.hp.max} temp={character.tempHp} />
|
||||
<DeathSaves
|
||||
characterId={characterId}
|
||||
name={character.name}
|
||||
deathSaves={character.deathSaves}
|
||||
canAct={canEdit}
|
||||
isDM={isDM}
|
||||
onError={(err) => setFormError(extractErrorMessage(err))}
|
||||
/>
|
||||
|
||||
{canEdit && (
|
||||
<div className="flex-row mt-sm flex-wrap">
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useAuth } from '../auth/AuthContext';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { NumberInput } from '../components/NumberInput';
|
||||
import { HpBar } from '../components/HpBar';
|
||||
import { DeathSaves } from '../components/DeathSaves';
|
||||
import { ABILITY_LABELS, CLASS_LABELS, COIN_LABELS, CURRENCIES, SPECIES_LABELS, extractErrorMessage, fmtMod, spellLevelLabel } from '../lib/format';
|
||||
import { abilityModifier, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
|
||||
import type {
|
||||
@@ -99,14 +100,17 @@ function CombatantCard({
|
||||
onPatch,
|
||||
canRollInitiative,
|
||||
onRemove,
|
||||
onError,
|
||||
}: {
|
||||
participant: EncounterParticipant;
|
||||
isDM: boolean;
|
||||
isActive: boolean;
|
||||
onPatch: (data: Partial<EncounterParticipant>) => void;
|
||||
// The DM for anyone; a player for their own character.
|
||||
// The DM for anyone; a player for their own character. The same goes for
|
||||
// that character's death saves.
|
||||
canRollInitiative: boolean;
|
||||
onRemove: () => void;
|
||||
onError: (err: unknown) => void;
|
||||
}) {
|
||||
const [hpDelta, setHpDelta] = useState('');
|
||||
// Details start open for the DM (who runs the scene from this screen) and
|
||||
@@ -125,6 +129,8 @@ function CombatantCard({
|
||||
const tracksHp = barOnly || maxHp > 0;
|
||||
const pct = barOnly ? (p.hpPercent ?? 0) : tracksHp ? Math.max(0, Math.min(100, (currentHp / maxHp) * 100)) : 0;
|
||||
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';
|
||||
// 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);
|
||||
@@ -143,7 +149,7 @@ function CombatantCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`card combatant combatant-${p.kind.toLowerCase()}${isActive ? ' is-active' : ''}${isDown ? ' is-down' : ''}`}>
|
||||
<div className={`card combatant combatant-${p.kind.toLowerCase()}${isActive ? ' is-active' : ''}${isDown && !isDying ? ' is-down' : ''}`}>
|
||||
<div className="flex-between flex-wrap">
|
||||
<div>
|
||||
{isActive && <div className="turn-marker">▶ On turn</div>}
|
||||
@@ -157,7 +163,7 @@ function CombatantCard({
|
||||
<span className="combatant-name">{p.displayName}</span>
|
||||
)}
|
||||
{npc && <span className={`badge badge-${npc.disposition.toLowerCase()}`}>{npc.disposition}</span>}
|
||||
{isDown && <span className="badge badge-hostile">Down</span>}
|
||||
{isDown && !info && <span className="badge badge-hostile">Down</span>}
|
||||
</div>
|
||||
<div className="combatant-sub">{subline(p)}</div>
|
||||
</div>
|
||||
@@ -243,6 +249,16 @@ function CombatantCard({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{info && p.character && (
|
||||
<DeathSaves
|
||||
characterId={p.character.id}
|
||||
name={p.displayName}
|
||||
deathSaves={info.deathSaves}
|
||||
canAct={canRollInitiative}
|
||||
isDM={isDM}
|
||||
onError={onError}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -668,6 +684,7 @@ export function EncounterPage() {
|
||||
onPatch={(data) => patchParticipant.mutate({ participantId: p.id, ...data }, fail)}
|
||||
canRollInitiative={isDM || (p.character != null && p.character.userId === user?.id)}
|
||||
onRemove={() => removeParticipant.mutate(p.id, fail)}
|
||||
onError={onError}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { usePagination, Pagination } from '../components/Paginated';
|
||||
import { InfoTip } from '../components/InfoTip';
|
||||
import { CLASS_LABELS, SPECIES_LABELS, formatPurse } from '../lib/format';
|
||||
import { HpBar } from '../components/HpBar';
|
||||
import { LifeBadge } from '../components/DeathSaves';
|
||||
import { speciesIcon } from '../lib/speciesIcons';
|
||||
|
||||
// A DM's one-screen view of every character's stats at a glance; a player
|
||||
@@ -81,7 +82,8 @@ export function PartyOverviewPage() {
|
||||
<span className="mono">
|
||||
{c.currentHp}/{c.maxHp}
|
||||
{c.tempHp > 0 && ` +${c.tempHp}`}
|
||||
</span>
|
||||
</span>{' '}
|
||||
<LifeBadge deathSaves={c.deathSaves} />
|
||||
<HpBar current={c.currentHp} max={c.maxHp} temp={c.tempHp} height={6} />
|
||||
</div>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user