darkwatch/docs/plans/2026-04-12-death-timer.md
2026-04-12 01:05:36 -04:00

960 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Death Timer Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement Shadowdark's dying mechanic — HP→0 starts a dying countdown (1d4+CON rounds), party turns tick the timer, DM can roll d20 each turn for recovery (18+ = stand at 1 HP), timer expiry marks permanent death with a DM-only Revive button.
**Architecture:** Dying state lives in `character_conditions` (existing table) as a "Dying" row with `rounds_remaining`. Permanent death is a new `is_dead BOOLEAN` column on `characters` (migration 004). Server drives all state: HP PATCH triggers dying start/clear, `initiative:next` ticks the timer, `death:recovery-roll` handles recovery rolls. `character:updated` broadcasts include a `conditions` array so clients always have current state.
**Tech Stack:** Express REST (PATCH `/characters/:id`), Socket.IO (`death:recovery-roll`), MariaDB, React + CSS Modules
---
## File Map
| File | Change |
|---|---|
| `server/migrations/004_death_timer.sql` | New — adds `is_dead` column |
| `server/src/routes/characters.ts` | `enrichCharacters` includes conditions; PATCH adds conditions to response, dying logic, `is_dead` in allowedFields |
| `server/src/routes/initiative.ts` | `initiative:next` ticks Dying timers when flipping to party |
| `server/src/socket.ts` | New `death:recovery-roll` handler |
| `client/src/types.ts` | Add `is_dead` and `conditions` to `Character` |
| `client/src/components/CharacterCard.tsx` | Dying border + 💀N countdown; dead state; Revive button (DM only) |
| `client/src/components/CharacterCard.module.css` | Dying pulse animation; dead mute; dying label; revive button styles |
| `client/src/components/InitiativeTracker.tsx` | Dying label + Roll Recovery button in ActivePhase |
---
## Task 1: DB Migration — Add is_dead column
**Files:**
- Create: `server/migrations/004_death_timer.sql`
- [ ] **Step 1: Write the migration**
Create `server/migrations/004_death_timer.sql`:
```sql
ALTER TABLE characters ADD COLUMN is_dead BOOLEAN NOT NULL DEFAULT FALSE;
```
- [ ] **Step 2: Apply the migration**
Restart the server (migrations run automatically on startup):
```bash
cd server && npm run dev
```
Expected: server logs `Running migration: 004_death_timer.sql` then `Migrations complete.`
- [ ] **Step 3: Verify column exists**
```bash
docker exec -it darkwatch-maria mariadb -u darkwatch -pdarkwatch darkwatch \
-e "DESCRIBE characters;" | grep is_dead
```
Expected output contains: `is_dead | tinyint(1) | NO | | 0 |`
- [ ] **Step 4: Commit**
```bash
git add server/migrations/004_death_timer.sql
git commit -m "feat: add is_dead column to characters (death timer migration 004)"
```
---
## Task 2: Enrich characters with conditions + update client types
**Files:**
- Modify: `server/src/routes/characters.ts`
- Modify: `client/src/types.ts`
The `character:updated` socket event currently sends a flat character row (no stats/gear/talents). We extend it to also include `conditions` so the client always has up-to-date dying state. The existing merge pattern `{ ...c, ...data }` in `CampaignView.tsx:131` handles partial updates safely.
- [ ] **Step 1: Add conditions to enrichCharacters in characters.ts**
`enrichCharacters` (lines 5175 of `server/src/routes/characters.ts`) — add a conditions fetch and include it in the return value:
```typescript
async function enrichCharacters(characters: RowDataPacket[]) {
return Promise.all(
characters.map(async (char) => {
const [stats] = await db.execute<RowDataPacket[]>(
"SELECT stat_name, value FROM character_stats WHERE character_id = ?",
[char.id]
);
const [gear] = await db.execute<RowDataPacket[]>(
"SELECT * FROM character_gear WHERE character_id = ?",
[char.id]
);
const [talents] = await db.execute<RowDataPacket[]>(
"SELECT * FROM character_talents WHERE character_id = ?",
[char.id]
);
const [conditions] = await db.execute<RowDataPacket[]>(
"SELECT * FROM character_conditions WHERE character_id = ?",
[char.id]
);
return {
...char,
overrides: parseJson(char.overrides),
stats,
gear: parseGear(gear),
talents: parseTalents(talents),
conditions,
};
})
);
}
```
- [ ] **Step 2: Add conditions to the PATCH handler response**
The PATCH handler (lines 215227 of `server/src/routes/characters.ts`) fetches `SELECT * FROM characters` then builds an enriched object. Replace that block to also fetch conditions:
```typescript
const [rows] = await db.execute<RowDataPacket[]>(
"SELECT * FROM characters WHERE id = ?",
[id]
);
const [conditions] = await db.execute<RowDataPacket[]>(
"SELECT * FROM character_conditions WHERE character_id = ?",
[id]
);
const enriched = {
...rows[0],
overrides: parseJson(rows[0].overrides),
conditions,
};
const io: Server = req.app.get("io");
broadcastToCampaign(io, Number(rows[0].campaign_id), "character:updated", enriched);
res.json(enriched);
```
- [ ] **Step 3: Update client types**
In `client/src/types.ts`, add `is_dead` and `conditions` to the `Character` interface (after `torch_lit_at`):
```typescript
export interface Character {
id: number;
campaign_id: number;
user_id?: number | null;
created_by: string;
name: string;
class: string;
ancestry: string;
level: number;
xp: number;
hp_current: number;
hp_max: number;
ac: number;
alignment: string;
title: string;
notes: string;
background: string;
deity: string;
languages: string;
gp: number;
sp: number;
cp: number;
gear_slots_max: number;
overrides: Record<string, unknown>;
color: string;
luck_token: number;
torch_lit_at: string | null;
is_dead: boolean;
stats: Stat[];
gear: Gear[];
talents: Talent[];
conditions: Condition[];
}
```
- [ ] **Step 4: Verify TypeScript compiles**
```bash
cd client && npx tsc --noEmit 2>&1 | head -30
```
Expected: no new errors. (Pre-existing errors unrelated to this feature are OK.)
```bash
cd server && npx tsc --noEmit 2>&1 | head -30
```
Expected: no new errors.
- [ ] **Step 5: Commit**
```bash
git add server/src/routes/characters.ts client/src/types.ts
git commit -m "feat: include conditions in character responses; add is_dead + conditions to Character type"
```
---
## Task 3: Auto-start/clear dying on HP change
**Files:**
- Modify: `server/src/routes/characters.ts`
When the HP PATCH writes `hp_current <= 0` and the character isn't already dying or dead, insert a Dying condition (1d4 + CON modifier rounds, minimum 1). When HP goes above 0, delete any Dying condition.
- [ ] **Step 1: Add rollDice import**
At the top of `server/src/routes/characters.ts`, add:
```typescript
import { rollDice } from "../dice.js";
```
- [ ] **Step 2: Add is_dead to allowedFields in PATCH handler**
In the `allowedFields` array (around line 185), add `"is_dead"`:
```typescript
const allowedFields = [
"name", "class", "ancestry", "level", "xp", "hp_current", "hp_max",
"ac", "alignment", "title", "notes", "background", "deity", "languages",
"gp", "sp", "cp", "gear_slots_max", "overrides", "color", "luck_token",
"torch_lit_at", "is_dead",
];
```
This allows the Revive button (Task 6) to send `{ is_dead: false, hp_current: 1 }` in a single PATCH.
- [ ] **Step 3: Insert dying state management block**
After the `if (updateResult.affectedRows === 0)` check (around line 213) and before the `SELECT * FROM characters` fetch, insert:
```typescript
// Auto-start or clear Dying condition based on HP change
if (req.body.hp_current !== undefined) {
const newHp = Number(req.body.hp_current);
if (newHp <= 0) {
// Check if already dying or permanently dead
const [dyingRows] = await db.execute<RowDataPacket[]>(
"SELECT id FROM character_conditions WHERE character_id = ? AND name = 'Dying'",
[id]
);
const [deadRows] = await db.execute<RowDataPacket[]>(
"SELECT is_dead FROM characters WHERE id = ?",
[id]
);
const isAlreadyDying = dyingRows.length > 0;
const isAlreadyDead = Boolean(deadRows[0]?.is_dead);
if (!isAlreadyDying && !isAlreadyDead) {
// Roll 1d4, add CON modifier, clamp to minimum 1
const d4 = rollDice("1d4");
const [statRows] = await db.execute<RowDataPacket[]>(
"SELECT value FROM character_stats WHERE character_id = ? AND stat_name = 'CON'",
[id]
);
const conValue = (statRows[0]?.value as number) ?? 10;
const conMod = Math.floor((conValue - 10) / 2);
const roundsRemaining = Math.max(1, d4.total + conMod);
await db.execute(
"INSERT INTO character_conditions (character_id, name, description, rounds_remaining) VALUES (?, 'Dying', '', ?)",
[id, roundsRemaining]
);
}
} else {
// HP above 0: remove any Dying condition (character was healed)
await db.execute(
"DELETE FROM character_conditions WHERE character_id = ? AND name = 'Dying'",
[id]
);
}
}
```
- [ ] **Step 4: Verify TypeScript compiles**
```bash
cd server && npx tsc --noEmit 2>&1 | head -20
```
Expected: no errors.
- [ ] **Step 5: Smoke-test manually**
With server running: use the DM login, open a campaign, set a character's HP to 0 via the HP bar. Reload the page and verify a Dying condition with rounds_remaining exists in the DB:
```bash
docker exec -it darkwatch-maria mariadb -u darkwatch -pdarkwatch darkwatch \
-e "SELECT * FROM character_conditions WHERE name = 'Dying';"
```
Set HP back to 1 — verify the row is gone.
- [ ] **Step 6: Commit**
```bash
git add server/src/routes/characters.ts
git commit -m "feat: auto-start Dying condition when HP hits 0, clear when HP recovers"
```
---
## Task 4: Tick death timer on party turn
**Files:**
- Modify: `server/src/routes/initiative.ts`
When `initiative:next` flips the side to "party", decrement all Dying conditions for characters in the campaign. Conditions at 0 mark the character dead.
- [ ] **Step 1: Add tickDeathTimers helper**
Add this function above `registerInitiativeHandlers` in `server/src/routes/initiative.ts`:
```typescript
async function tickDeathTimers(io: Server, campaignId: number): Promise<void> {
// Find all living characters in this campaign with a Dying condition
const [charRows] = await db.execute<RowDataPacket[]>(
"SELECT id FROM characters WHERE campaign_id = ? AND is_dead = FALSE",
[campaignId]
);
if (charRows.length === 0) return;
const charIds = (charRows as RowDataPacket[]).map((r) => r.id as number);
const placeholders = charIds.map(() => "?").join(", ");
const [dyingRows] = await db.execute<RowDataPacket[]>(
`SELECT * FROM character_conditions WHERE name = 'Dying' AND character_id IN (${placeholders})`,
charIds
);
if (dyingRows.length === 0) return;
for (const condition of dyingRows) {
const newRounds = (condition.rounds_remaining as number) - 1;
if (newRounds <= 0) {
// Timer expired — remove Dying condition and mark permanently dead
await db.execute("DELETE FROM character_conditions WHERE id = ?", [condition.id]);
await db.execute("UPDATE characters SET is_dead = TRUE WHERE id = ?", [condition.character_id]);
} else {
await db.execute(
"UPDATE character_conditions SET rounds_remaining = ? WHERE id = ?",
[newRounds, condition.id]
);
}
// Broadcast updated character to the campaign room
const [charRow] = await db.execute<RowDataPacket[]>(
"SELECT * FROM characters WHERE id = ?",
[condition.character_id]
);
const [updatedConditions] = await db.execute<RowDataPacket[]>(
"SELECT * FROM character_conditions WHERE character_id = ?",
[condition.character_id]
);
io.to(`campaign:${campaignId}`).emit("character:updated", {
...charRow[0],
conditions: updatedConditions,
});
}
}
```
- [ ] **Step 2: Call tickDeathTimers in initiative:next handler**
In the `initiative:next` handler (around line 292), after `broadcast(io, socket, data.campaignId, updated, true);` and before the `} catch`, add:
```typescript
// Tick death timers when the party's turn begins
const flippedCombat = updated.find((c) => c.id === data.combatId);
if (flippedCombat && flippedCombat.current_side === "party") {
await tickDeathTimers(io, data.campaignId);
}
```
- [ ] **Step 3: Verify TypeScript compiles**
```bash
cd server && npx tsc --noEmit 2>&1 | head -20
```
Expected: no errors.
- [ ] **Step 4: Smoke-test manually**
Set a character's HP to 0 (gains Dying condition). Start combat with that character. Advance turns until the enemy side ends and the party side begins. Check that `rounds_remaining` decremented in the DB:
```bash
docker exec -it darkwatch-maria mariadb -u darkwatch -pdarkwatch darkwatch \
-e "SELECT * FROM character_conditions WHERE name = 'Dying';"
```
- [ ] **Step 5: Commit**
```bash
git add server/src/routes/initiative.ts
git commit -m "feat: tick Dying timer on party turn; mark is_dead when timer expires"
```
---
## Task 5: death:recovery-roll socket handler
**Files:**
- Modify: `server/src/socket.ts`
New socket event emitted by the client when the DM clicks Roll Recovery. Verifies DM role, verifies character is Dying, rolls d20, logs it as a "Death Save" roll, and on 18+ heals to 1 HP and clears the Dying condition.
- [ ] **Step 1: Add handler in socket.ts**
Inside `io.on("connection", ...)`, add after the `atmosphere:update` handler (before `socket.on("disconnect", ...)`):
```typescript
socket.on("death:recovery-roll", async (data: {
campaignId: number;
characterId: number;
}) => {
const userId = socket.data.user?.userId;
// Verify caller is DM
const [memberRows] = await db.execute<RowDataPacket[]>(
"SELECT role FROM campaign_members WHERE campaign_id = ? AND user_id = ?",
[data.campaignId, userId]
);
if (memberRows.length === 0 || memberRows[0].role !== "dm") return;
// Verify character has a Dying condition
const [dyingRows] = await db.execute<RowDataPacket[]>(
"SELECT id FROM character_conditions WHERE character_id = ? AND name = 'Dying'",
[data.characterId]
);
if (dyingRows.length === 0) return;
// Get character info for roll log
const [charRows] = await db.execute<RowDataPacket[]>(
"SELECT name, color, campaign_id FROM characters WHERE id = ?",
[data.characterId]
);
if (charRows.length === 0) return;
const char = charRows[0];
// Roll d20 server-side
const result = rollDice("1d20");
const roll = result.total;
const nat20 = roll === 20;
const success = roll >= 18;
// Log to roll_log with "Death Save" label (success gets suffix)
const label = success ? "Death Save \u2014 stands at 1 HP!" : "Death Save";
const [insertResult] = await db.execute<import("mysql2").ResultSetHeader>(
`INSERT INTO roll_log
(campaign_id, character_id, character_name, character_color, type, label,
dice_expression, rolls, modifier, total, advantage, disadvantage, nat20)
VALUES (?, ?, ?, ?, 'custom', ?, '1d20', ?, 0, ?, 0, 0, ?)`,
[
data.campaignId,
data.characterId,
char.name,
char.color,
label,
JSON.stringify(result.rolls),
roll,
nat20 ? 1 : 0,
]
);
const [savedRows] = await db.execute<RowDataPacket[]>(
"SELECT * FROM roll_log WHERE id = ?",
[insertResult.insertId]
);
io.to(`campaign:${data.campaignId}`).emit("roll:result", {
...savedRows[0],
rolls: result.rolls,
advantage: false,
disadvantage: false,
nat20,
});
// On 18+: heal to 1 HP and clear Dying condition
if (success) {
await db.execute("UPDATE characters SET hp_current = 1 WHERE id = ?", [data.characterId]);
await db.execute(
"DELETE FROM character_conditions WHERE character_id = ? AND name = 'Dying'",
[data.characterId]
);
const [updatedChar] = await db.execute<RowDataPacket[]>(
"SELECT * FROM characters WHERE id = ?",
[data.characterId]
);
const [updatedConditions] = await db.execute<RowDataPacket[]>(
"SELECT * FROM character_conditions WHERE character_id = ?",
[data.characterId]
);
io.to(`campaign:${data.campaignId}`).emit("character:updated", {
...updatedChar[0],
conditions: updatedConditions,
});
}
});
```
- [ ] **Step 2: Verify TypeScript compiles**
```bash
cd server && npx tsc --noEmit 2>&1 | head -20
```
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add server/src/socket.ts
git commit -m "feat: death:recovery-roll socket handler — d20 save, 18+ stands at 1 HP"
```
---
## Task 6: CharacterCard dying/dead UI
**Files:**
- Modify: `client/src/components/CharacterCard.tsx`
- Modify: `client/src/components/CharacterCard.module.css`
Dying state: pulsing red border + 💀N countdown in vitals row.
Dead state: muted/greyed card, skull prefix on name, HP bar non-functional.
Revive button: DM-only, appears when `is_dead === true`, sends PATCH `{ is_dead: false, hp_current: 1 }`.
- [ ] **Step 1: Add CSS classes**
Add to the end of `client/src/components/CharacterCard.module.css`:
```css
.dying {
border: 2px solid var(--danger) !important;
animation: dyingPulse 1.5s ease-in-out infinite;
}
@keyframes dyingPulse {
0%, 100% { box-shadow: 0 0 6px rgba(var(--danger-rgb), 0.4); }
50% { box-shadow: 0 0 20px rgba(var(--danger-rgb), 0.85); }
}
.dead {
opacity: 0.45;
filter: grayscale(0.75);
}
.dyingLabel {
font-size: 0.8rem;
color: var(--danger);
font-weight: 700;
flex-shrink: 0;
white-space: nowrap;
}
.reviveBtn {
display: block;
width: 100%;
margin-top: 0.5rem;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
background: transparent;
border: 1px solid var(--gold);
color: var(--gold);
border-radius: 3px;
cursor: pointer;
font-family: "Cinzel", Georgia, serif;
letter-spacing: 0.04em;
}
.reviveBtn:hover {
background: rgba(var(--gold-rgb), 0.12);
}
```
- [ ] **Step 2: Update CharacterCard component**
Replace the entire contents of `client/src/components/CharacterCard.tsx`:
```typescript
import type { Character } from "../types.js";
import HpBar from "./HpBar.js";
import TorchTimer from "./TorchTimer.js";
import styles from "./CharacterCard.module.css";
import { calculateAC } from "../utils/derived-ac.js";
import { getTalentHpBonus } from "../utils/talent-effects.js";
import { getEffectiveStat } from "../utils/talent-effects.js";
import { getModifier, formatModifier } from "../utils/modifiers.js";
function getAvatarUrl(character: Character): string {
const style = (character.overrides?.avatar_style as string) || "micah";
const seed = encodeURIComponent(character.name || "hero");
return `https://api.dicebear.com/9.x/${style}/svg?seed=${seed}`;
}
const STATS = ["STR", "DEX", "CON", "INT", "WIS", "CHA"];
interface CharacterCardProps {
character: Character;
onHpChange: (characterId: number, hp: number) => void;
onUpdate: (characterId: number, data: Partial<Character>) => void;
onClick: (characterId: number) => void;
canEdit?: boolean;
focusSpell?: string;
isDM?: boolean;
}
export default function CharacterCard({
character,
onHpChange,
onUpdate,
onClick,
canEdit = true,
focusSpell,
isDM = false,
}: CharacterCardProps) {
const dyingCondition = character.conditions?.find((c) => c.name === "Dying");
const isDying = !!dyingCondition;
const isDead = !!character.is_dead;
const cardClass = [
styles.card,
isDying ? styles.dying : "",
isDead ? styles.dead : "",
]
.filter(Boolean)
.join(" ");
// When dying/dead the left-color border is replaced by the dying/dead CSS
const cardStyle = isDying || isDead
? {}
: { borderLeftColor: character.color, borderLeftWidth: "3px" };
return (
<div className={cardClass} onClick={() => onClick(character.id)} style={cardStyle}>
<div className={styles.cardHeader}>
<img className={styles.avatar} src={getAvatarUrl(character)} alt="" />
<div className={styles.nameRow}>
<span className={styles.name}>
{isDead ? "\u{1F480} " : ""}{character.name}
{character.title ? ` ${character.title}` : ""}
</span>
<span className={styles.level}>Lvl {character.level}</span>
</div>
</div>
<div className={styles.meta}>
{character.ancestry} {character.class}
</div>
{focusSpell && (
<div className={styles.focusIndicator}>
&#9679; Focusing: {focusSpell}
</div>
)}
<div className={styles.vitalsRow} onClick={(e) => e.stopPropagation()}>
<HpBar
current={character.hp_current}
max={character.hp_max + getTalentHpBonus(character)}
onChange={isDead ? () => {} : (hp) => onHpChange(character.id, hp)}
/>
{isDying && dyingCondition && (
<span className={styles.dyingLabel} title="Dying">
{"\u{1F480}"} {dyingCondition.rounds_remaining}
</span>
)}
<div className={styles.ac}>
<span className={styles.acLabel}>AC</span>
<span className={styles.acValue}>{calculateAC(character).effective}</span>
</div>
<span
className={styles.luck}
title={character.luck_token ? "Luck available" : "Luck spent"}
>
{character.luck_token ? "\u2605" : "\u2606"}
</span>
<TorchTimer
torchLitAt={character.torch_lit_at}
onToggle={() => {
const isLit = character.torch_lit_at !== null;
onUpdate(character.id, {
torch_lit_at: isLit ? null : new Date().toISOString(),
} as Partial<Character>);
}}
/>
</div>
{isDead && isDM && (
<button
className={styles.reviveBtn}
onClick={(e) => {
e.stopPropagation();
onUpdate(character.id, { is_dead: false, hp_current: 1 } as Partial<Character>);
}}
>
Revive
</button>
)}
<div className={styles.modRow}>
{STATS.map((stat) => {
const value = getEffectiveStat(character, stat);
const mod = getModifier(value);
return (
<span key={stat} className={styles.mod}>
<span className={styles.modLabel}>{stat}</span>
<span className={styles.modValue}>{formatModifier(mod)}</span>
</span>
);
})}
</div>
<div className={styles.xp}>
XP {character.xp} / {character.level * 10}
</div>
</div>
);
}
```
Note: The original imports use no `.js` extension on the type import (`from "../types"`) and no extension on some component imports. The existing codebase uses `.js` for some and none for others in the same file — keep whatever the original had for the imports that existed before, and use `.js` on any new ones. The existing file used no extensions — keep that pattern.
Actually, looking at the original file:
```typescript
import type { Character } from "../types";
import HpBar from "./HpBar";
import TorchTimer from "./TorchTimer";
import styles from "./CharacterCard.module.css";
import { calculateAC } from "../utils/derived-ac";
...
```
Use the same no-extension pattern to match existing code in this file:
```typescript
import type { Character } from "../types";
import HpBar from "./HpBar";
import TorchTimer from "./TorchTimer";
import styles from "./CharacterCard.module.css";
import { calculateAC } from "../utils/derived-ac";
import { getTalentHpBonus } from "../utils/talent-effects";
import { getEffectiveStat } from "../utils/talent-effects";
import { getModifier, formatModifier } from "../utils/modifiers";
```
Use those exact import paths (no `.js`) in the replacement.
- [ ] **Step 3: Verify CharacterCard callers still compile**
Search for all places that render `<CharacterCard` to confirm the new optional `isDM` prop doesn't break anything:
```bash
grep -rn "CharacterCard" /Users/aaron.wood/workspace/shadowdark/client/src/
```
All existing call sites are compatible — `isDM` is optional and defaults to `false`.
Now find where the DM view renders cards and pass `isDM`:
```bash
grep -rn "onHpChange\|CharacterCard" /Users/aaron.wood/workspace/shadowdark/client/src/pages/
```
In `CampaignView.tsx` (or wherever the DM renders character cards), add `isDM={role === "dm"}` or `isDM={isDM}` to the `<CharacterCard>` calls.
- [ ] **Step 4: Verify TypeScript compiles**
```bash
cd client && npx tsc --noEmit 2>&1 | head -30
```
Expected: no errors.
- [ ] **Step 5: Commit**
```bash
git add client/src/components/CharacterCard.tsx client/src/components/CharacterCard.module.css
git commit -m "feat: CharacterCard dying pulse border + countdown; dead state; DM Revive button"
```
---
## Task 7: InitiativeTracker Roll Recovery button
**Files:**
- Modify: `client/src/components/InitiativeTracker.tsx`
In the active combat view, for each party combatant with a Dying condition, show a "💀 Dying (N rounds)" label and a DM-only "Roll Recovery" button that emits `death:recovery-roll`.
- [ ] **Step 1: Add emitRecoveryRoll in InitiativeTracker**
In `InitiativeTracker` (the main component, around line 50), add:
```typescript
function emitRecoveryRoll(characterId: number) {
socket.emit("death:recovery-roll", { campaignId, characterId });
}
```
- [ ] **Step 2: Pass onRecoveryRoll to ActivePhase**
Update the `<ActivePhase>` call (around line 107) to pass the new callback:
```tsx
<ActivePhase
combat={combat}
partyChars={partyChars}
isDM={isDM}
showAddEnemy={showAddEnemy}
addEnemyName={addEnemyName}
addEnemyHp={addEnemyHp}
onSetShowAddEnemy={setShowAddEnemy}
onSetAddEnemyName={setAddEnemyName}
onSetAddEnemyHp={setAddEnemyHp}
onUpdateEnemyHp={emitUpdateEnemyHp}
onRemoveEnemy={emitRemoveEnemy}
onAddEnemy={emitAddEnemy}
onNext={emitNext}
onEnd={emitEnd}
onRecoveryRoll={emitRecoveryRoll}
/>
```
- [ ] **Step 3: Update ActivePhaseProps interface**
In the `ActivePhaseProps` interface (around line 220), add:
```typescript
onRecoveryRoll: (characterId: number) => void;
```
- [ ] **Step 4: Update ActivePhase destructuring**
In the `ActivePhase` function signature, add `onRecoveryRoll` to the destructuring.
- [ ] **Step 5: Add dying indicator and Roll Recovery button in party list**
In `ActivePhase`, replace the party combatant render (the `partyChars.map` block around line 266) with:
```tsx
{partyChars.map((c) => {
const dyingCondition = c.conditions?.find((cond) => cond.name === "Dying");
return (
<div key={c.id} className={styles.combatantRow}>
<span className={styles.dot} style={{ background: c.color }} />
<span className={partyActive ? styles.activeName : styles.rollName}>
{c.is_dead ? "\u{1F480} " : ""}{c.name}
</span>
{dyingCondition && (
<span className={styles.dyingTag}>
{"\u{1F480}"} Dying ({dyingCondition.rounds_remaining}r)
</span>
)}
{isDM && dyingCondition && !c.is_dead && (
<button
className={styles.recoveryBtn}
onClick={() => onRecoveryRoll(c.id)}
>
Roll Recovery
</button>
)}
</div>
);
})}
```
- [ ] **Step 6: Add CSS for dying tag and recovery button**
Open `client/src/components/InitiativeTracker.module.css` and add:
```css
.dyingTag {
font-size: 0.72rem;
color: var(--danger);
font-weight: 600;
margin-left: auto;
white-space: nowrap;
}
.recoveryBtn {
font-size: 0.7rem;
padding: 0.15rem 0.4rem;
background: transparent;
border: 1px solid var(--danger);
color: var(--danger);
border-radius: 3px;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.recoveryBtn:hover {
background: rgba(var(--danger-rgb), 0.12);
}
```
- [ ] **Step 7: Verify TypeScript compiles**
```bash
cd client && npx tsc --noEmit 2>&1 | head -30
```
Expected: no errors.
- [ ] **Step 8: End-to-end smoke test**
1. Start server and client
2. Log in as DM, open a campaign with a character in active combat
3. Set character HP to 0 → card gains red pulsing border with 💀N
4. Advance turn (enemy → party) → rounds_remaining decrements
5. Click Roll Recovery in initiative tracker → roll appears in roll log
6. On a 18+ roll → character HP returns to 1, dying border disappears
7. Let timer expire → character marked dead → card greyed with skull, HP non-functional
8. Click Revive → character returns to 1 HP, dead state clears
- [ ] **Step 9: Commit**
```bash
git add client/src/components/InitiativeTracker.tsx client/src/components/InitiativeTracker.module.css
git commit -m "feat: dying label and Roll Recovery button in InitiativeTracker active phase"
```
---
## Self-Review Checklist
**Spec coverage:**
- ✅ HP→0 auto-inserts Dying condition (Task 3)
- ✅ HP>0 clears Dying condition (Task 3)
- ✅ 1d4 + CON modifier, minimum 1 (Task 3)
-`is_dead` column (Task 1)
- ✅ Party turn ticks the timer (Task 4)
- ✅ Timer expiry sets is_dead + deletes condition (Task 4)
-`death:recovery-roll` event, DM only (Task 5)
- ✅ d20 server-side, roll:result with "Death Save" label (Task 5)
- ✅ 18+ heals to 1 HP, clears Dying (Task 5)
- ✅ Pulsing red border + 💀N on dying card (Task 6)
- ✅ Grey/muted dead card, HP non-functional (Task 6)
- ✅ Revive button (DM only, Task 6)
- ✅ Dying label + Roll Recovery button in InitiativeTracker (Task 7)
- ✅ Roll log "Death Save" with success suffix (Task 5)
- ✅ Already dying: no new condition (Task 3 — checks `isAlreadyDying`)
- ✅ Negative CON mod: clamped to minimum 1 (Task 3 — `Math.max(1, ...)`)
- ✅ Combat ends while dying: timer just stops ticking (tick only happens in initiative:next)
- ✅ Multiple dying chars same round: loop handles all in tickDeathTimers (Task 4)
**Scope check:** All changes are narrowly scoped to the death timer feature. No unrelated refactors.
**Type consistency:**
- `conditions: Condition[]` added to `Character` in `types.ts`
- `is_dead: boolean` added to `Character` in `types.ts`
- Both are included in `character:updated` broadcasts from all code paths
- `Condition.rounds_remaining` is `number | null` in types — initiative handler casts to `number` after confirming `dyingRows.length > 0`