docs: add death timer implementation plan
This commit is contained in:
parent
44b482e173
commit
4c10fe80ac
1 changed files with 960 additions and 0 deletions
960
docs/plans/2026-04-12-death-timer.md
Normal file
960
docs/plans/2026-04-12-death-timer.md
Normal 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 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<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 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<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}>
|
||||||
|
● 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`
|
||||||
Loading…
Add table
Reference in a new issue