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