docs: add death timer implementation plan

This commit is contained in:
Aaron Wood 2026-04-12 01:05:36 -04:00
parent 44b482e173
commit 4c10fe80ac

View file

@ -0,0 +1,960 @@
# 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`