diff --git a/docs/plans/2026-04-12-death-timer.md b/docs/plans/2026-04-12-death-timer.md new file mode 100644 index 0000000..2f5e7a7 --- /dev/null +++ b/docs/plans/2026-04-12-death-timer.md @@ -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 51–75 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( + "SELECT stat_name, value FROM character_stats WHERE character_id = ?", + [char.id] + ); + const [gear] = await db.execute( + "SELECT * FROM character_gear WHERE character_id = ?", + [char.id] + ); + const [talents] = await db.execute( + "SELECT * FROM character_talents WHERE character_id = ?", + [char.id] + ); + const [conditions] = await db.execute( + "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 215–227 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( + "SELECT * FROM characters WHERE id = ?", + [id] +); +const [conditions] = await db.execute( + "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; + 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( + "SELECT id FROM character_conditions WHERE character_id = ? AND name = 'Dying'", + [id] + ); + const [deadRows] = await db.execute( + "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( + "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 { + // Find all living characters in this campaign with a Dying condition + const [charRows] = await db.execute( + "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( + `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( + "SELECT * FROM characters WHERE id = ?", + [condition.character_id] + ); + const [updatedConditions] = await db.execute( + "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( + "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( + "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( + "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( + `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( + "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( + "SELECT * FROM characters WHERE id = ?", + [data.characterId] + ); + const [updatedConditions] = await db.execute( + "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) => 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 ( +
onClick(character.id)} style={cardStyle}> +
+ +
+ + {isDead ? "\u{1F480} " : ""}{character.name} + {character.title ? ` ${character.title}` : ""} + + Lvl {character.level} +
+
+ +
+ {character.ancestry} {character.class} +
+ + {focusSpell && ( +
+ ● Focusing: {focusSpell} +
+ )} + +
e.stopPropagation()}> + {} : (hp) => onHpChange(character.id, hp)} + /> + {isDying && dyingCondition && ( + + {"\u{1F480}"} {dyingCondition.rounds_remaining} + + )} +
+ AC + {calculateAC(character).effective} +
+ + {character.luck_token ? "\u2605" : "\u2606"} + + { + const isLit = character.torch_lit_at !== null; + onUpdate(character.id, { + torch_lit_at: isLit ? null : new Date().toISOString(), + } as Partial); + }} + /> +
+ + {isDead && isDM && ( + + )} + +
+ {STATS.map((stat) => { + const value = getEffectiveStat(character, stat); + const mod = getModifier(value); + return ( + + {stat} + {formatModifier(mod)} + + ); + })} +
+ +
+ XP {character.xp} / {character.level * 10} +
+
+ ); +} +``` + +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 `` 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 `` call (around line 107) to pass the new callback: + +```tsx + +``` + +- [ ] **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 ( +
+ + + {c.is_dead ? "\u{1F480} " : ""}{c.name} + + {dyingCondition && ( + + {"\u{1F480}"} Dying ({dyingCondition.rounds_remaining}r) + + )} + {isDM && dyingCondition && !c.is_dead && ( + + )} +
+ ); +})} +``` + +- [ ] **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`