- Move docs/superpowers/{plans,specs}/ → docs/{plans,specs}/
- Add 4 previously untracked implementation plans to git
- Update CLAUDE.md with docs path overrides for superpowers skills
- Update HANDBOOK.md repo structure and workflow paths
- Add per-enemy dice rolls to ROADMAP planned section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2089 lines
56 KiB
Markdown
2089 lines
56 KiB
Markdown
# Initiative Tracker 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:** Add a real-time, DM-managed combat initiative tracker that persists between sessions, supports Shadowdark's team-based initiative (party vs enemies), and hides enemy HP from players.
|
|
|
|
**Architecture:** Combat state lives in a JSON array column on the `campaigns` table. A new `server/src/routes/initiative.ts` registers socket handlers for all `initiative:*` events; every mutation persists to DB then broadcasts to the campaign room — full data to the DM socket, HP-stripped data to players. A new `InitiativeTracker` sidebar component renders inside `CampaignView` when any combat is active, and a `CombatStartModal` handles the DM's setup flow.
|
|
|
|
**Tech Stack:** MariaDB JSON column · Socket.IO (existing room pattern) · React + CSS Modules · TypeScript · No new npm packages required
|
|
|
|
---
|
|
|
|
## File Map
|
|
|
|
| Action | Path | Purpose |
|
|
|---|---|---|
|
|
| Create | `server/migrations/003_combat_state.sql` | Add JSON column to campaigns |
|
|
| Create | `server/src/routes/initiative.ts` | All initiative:* socket handlers |
|
|
| Modify | `server/src/socket.ts` | Register initiative handlers + send state on join |
|
|
| Modify | `client/src/types.ts` | Add CombatState, CombatEnemy interfaces |
|
|
| Create | `client/src/components/InitiativeTracker.tsx` | Sidebar component (rolling + active phases) |
|
|
| Create | `client/src/components/InitiativeTracker.module.css` | Sidebar styles |
|
|
| Create | `client/src/components/CombatStartModal.tsx` | DM setup modal |
|
|
| Create | `client/src/components/CombatStartModal.module.css` | Modal styles |
|
|
| Modify | `client/src/pages/CampaignView.tsx` | State, socket listeners, layout, ⚔ button |
|
|
| Modify | `client/src/pages/CampaignView.module.css` | Combat layout styles |
|
|
|
|
---
|
|
|
|
## Task 1: DB Migration
|
|
|
|
**Files:**
|
|
- Create: `server/migrations/003_combat_state.sql`
|
|
|
|
- [ ] **Step 1: Create the migration file**
|
|
|
|
```sql
|
|
-- server/migrations/003_combat_state.sql
|
|
ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS combat_state JSON NULL DEFAULT NULL;
|
|
```
|
|
|
|
- [ ] **Step 2: Restart the server to apply migration**
|
|
|
|
```bash
|
|
cd /Users/aaron.wood/workspace/shadowdark/server
|
|
npm run dev
|
|
```
|
|
|
|
Expected: console prints `Migration applied: 003_combat_state.sql`
|
|
|
|
- [ ] **Step 3: Verify column exists**
|
|
|
|
```bash
|
|
docker exec darkwatch-maria mariadb -u darkwatch -pdarkwatch darkwatch \
|
|
-e "DESCRIBE campaigns;"
|
|
```
|
|
|
|
Expected: `combat_state` column of type `longtext` (MariaDB stores JSON as longtext) with `NULL` default.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add server/migrations/003_combat_state.sql
|
|
git commit -m "feat: add combat_state JSON column to campaigns"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Server — initiative socket handlers
|
|
|
|
**Files:**
|
|
- Create: `server/src/routes/initiative.ts`
|
|
|
|
This file exports `registerInitiativeHandlers(io, socket)` and two helpers (`loadCombats`, `stripHp`) used by socket.ts in Task 3.
|
|
|
|
- [ ] **Step 1: Create `server/src/routes/initiative.ts` with helpers and all handlers**
|
|
|
|
```typescript
|
|
import type { Server, Socket } from "socket.io";
|
|
import type { RowDataPacket } from "mysql2";
|
|
import { randomUUID } from "crypto";
|
|
import db from "../db.js";
|
|
import { rollDice } from "../dice.js";
|
|
|
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
|
|
export interface CombatEnemy {
|
|
id: string;
|
|
name: string;
|
|
hp_current: number;
|
|
hp_max: number;
|
|
}
|
|
|
|
export interface CombatState {
|
|
id: string;
|
|
label?: string;
|
|
mode: "team";
|
|
round: number;
|
|
phase: "rolling" | "active";
|
|
current_side: "party" | "enemies";
|
|
party_roll: number | null;
|
|
enemy_roll: number | null;
|
|
party_rolls: Record<number, number>;
|
|
character_ids: number[];
|
|
enemies: CombatEnemy[];
|
|
}
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
export async function loadCombats(campaignId: number): Promise<CombatState[]> {
|
|
const [rows] = await db.execute<RowDataPacket[]>(
|
|
"SELECT combat_state FROM campaigns WHERE id = ?",
|
|
[campaignId]
|
|
);
|
|
if (rows.length === 0 || rows[0].combat_state === null) return [];
|
|
const raw = rows[0].combat_state;
|
|
return typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
}
|
|
|
|
async function saveCombats(campaignId: number, combats: CombatState[]): Promise<void> {
|
|
await db.execute(
|
|
"UPDATE campaigns SET combat_state = ? WHERE id = ?",
|
|
[combats.length > 0 ? JSON.stringify(combats) : null, campaignId]
|
|
);
|
|
}
|
|
|
|
// Returns combats with hp_current and hp_max removed from every enemy.
|
|
export function stripHp(combats: CombatState[]): Omit<CombatEnemy, "hp_current" | "hp_max">[][] {
|
|
return combats.map((c) => ({
|
|
...c,
|
|
enemies: c.enemies.map(({ id, name }) => ({ id, name })),
|
|
})) as unknown as any;
|
|
}
|
|
|
|
// Broadcast full state to the emitting (DM) socket, HP-stripped to everyone else.
|
|
function broadcast(
|
|
io: Server,
|
|
socket: Socket,
|
|
campaignId: number,
|
|
combats: CombatState[]
|
|
): void {
|
|
socket.to(`campaign:${campaignId}`).emit("initiative:updated", stripHp(combats));
|
|
socket.emit("initiative:updated", combats);
|
|
}
|
|
|
|
async function checkDM(socket: Socket, campaignId: number): Promise<boolean> {
|
|
const userId = socket.data.user?.userId;
|
|
const [rows] = await db.execute<RowDataPacket[]>(
|
|
"SELECT role FROM campaign_members WHERE campaign_id = ? AND user_id = ?",
|
|
[campaignId, userId]
|
|
);
|
|
return rows.length > 0 && rows[0].role === "dm";
|
|
}
|
|
|
|
async function insertRollLog(
|
|
campaignId: number,
|
|
characterId: number | null,
|
|
characterName: string,
|
|
characterColor: string,
|
|
label: string,
|
|
rollValue: number,
|
|
rolls: number[]
|
|
): Promise<RowDataPacket> {
|
|
const [ins] = await db.execute<import("mysql2").ResultSetHeader>(
|
|
`INSERT INTO roll_log
|
|
(campaign_id, character_id, character_name, character_color,
|
|
type, subtype, label, dice_expression, rolls, modifier, total,
|
|
advantage, disadvantage, nat20)
|
|
VALUES (?, ?, ?, ?, 'custom', 'initiative', ?, '1d20', ?, 0, ?, 0, 0, ?)`,
|
|
[
|
|
campaignId, characterId, characterName, characterColor,
|
|
label, JSON.stringify(rolls), rollValue, rollValue === 20 ? 1 : 0,
|
|
]
|
|
);
|
|
const [saved] = await db.execute<RowDataPacket[]>(
|
|
"SELECT * FROM roll_log WHERE id = ?",
|
|
[ins.insertId]
|
|
);
|
|
return saved[0];
|
|
}
|
|
|
|
// ── Handler registration ───────────────────────────────────────────────────
|
|
|
|
export function registerInitiativeHandlers(io: Server, socket: Socket): void {
|
|
// Send state to requesting client (called right after join-campaign).
|
|
socket.on("initiative:request-state", async (campaignId: number) => {
|
|
const userId = socket.data.user?.userId;
|
|
const [rows] = await db.execute<RowDataPacket[]>(
|
|
"SELECT role FROM campaign_members WHERE campaign_id = ? AND user_id = ?",
|
|
[campaignId, userId]
|
|
);
|
|
const dm = rows.length > 0 && rows[0].role === "dm";
|
|
const combats = await loadCombats(campaignId);
|
|
socket.emit("initiative:state", dm ? combats : stripHp(combats));
|
|
});
|
|
|
|
// DM: open rolling phase with enemies configured.
|
|
socket.on("initiative:start", async (data: {
|
|
campaignId: number;
|
|
label?: string;
|
|
character_ids: number[];
|
|
enemies: Array<{ name: string; hp_max: number }>;
|
|
}) => {
|
|
if (!await checkDM(socket, data.campaignId)) return;
|
|
|
|
const combats = await loadCombats(data.campaignId);
|
|
const newCombat: CombatState = {
|
|
id: randomUUID(),
|
|
label: data.label,
|
|
mode: "team",
|
|
round: 1,
|
|
phase: "rolling",
|
|
current_side: "party",
|
|
party_roll: null,
|
|
enemy_roll: null,
|
|
party_rolls: {},
|
|
character_ids: data.character_ids,
|
|
enemies: data.enemies.map((e) => ({
|
|
id: randomUUID(),
|
|
name: e.name,
|
|
hp_current: e.hp_max,
|
|
hp_max: e.hp_max,
|
|
})),
|
|
};
|
|
const updated = [...combats, newCombat];
|
|
await saveCombats(data.campaignId, updated);
|
|
broadcast(io, socket, data.campaignId, updated);
|
|
});
|
|
|
|
// Any campaign member: roll d20 for their character's initiative.
|
|
socket.on("initiative:roll", async (data: {
|
|
campaignId: number;
|
|
combatId: string;
|
|
characterId: number;
|
|
characterName: string;
|
|
characterColor: string;
|
|
}) => {
|
|
const userId = socket.data.user?.userId;
|
|
const dm = await checkDM(socket, data.campaignId);
|
|
if (!dm) {
|
|
const [charRows] = await db.execute<RowDataPacket[]>(
|
|
"SELECT user_id FROM characters WHERE id = ?",
|
|
[data.characterId]
|
|
);
|
|
if (charRows.length === 0 || charRows[0].user_id !== userId) return;
|
|
}
|
|
|
|
const result = rollDice("1d20", {});
|
|
const rollValue = result.total;
|
|
|
|
const saved = await insertRollLog(
|
|
data.campaignId, data.characterId, data.characterName, data.characterColor,
|
|
"Initiative", rollValue, result.rolls
|
|
);
|
|
io.to(`campaign:${data.campaignId}`).emit("roll:result", {
|
|
...saved,
|
|
rolls: result.rolls,
|
|
advantage: false,
|
|
disadvantage: false,
|
|
nat20: rollValue === 20,
|
|
});
|
|
|
|
const combats = await loadCombats(data.campaignId);
|
|
const updated = combats.map((c) => {
|
|
if (c.id !== data.combatId) return c;
|
|
const newRolls = { ...c.party_rolls, [data.characterId]: rollValue };
|
|
return {
|
|
...c,
|
|
party_rolls: newRolls,
|
|
party_roll: Math.max(...Object.values(newRolls)),
|
|
};
|
|
});
|
|
await saveCombats(data.campaignId, updated);
|
|
broadcast(io, socket, data.campaignId, updated);
|
|
});
|
|
|
|
// DM: roll d20 for enemies.
|
|
socket.on("initiative:enemy-roll", async (data: {
|
|
campaignId: number;
|
|
combatId: string;
|
|
}) => {
|
|
if (!await checkDM(socket, data.campaignId)) return;
|
|
|
|
const result = rollDice("1d20", {});
|
|
const rollValue = result.total;
|
|
|
|
const saved = await insertRollLog(
|
|
data.campaignId, null, "DM", "#888888",
|
|
"Enemy Initiative", rollValue, result.rolls
|
|
);
|
|
io.to(`campaign:${data.campaignId}`).emit("roll:result", {
|
|
...saved,
|
|
rolls: result.rolls,
|
|
advantage: false,
|
|
disadvantage: false,
|
|
nat20: rollValue === 20,
|
|
});
|
|
|
|
const combats = await loadCombats(data.campaignId);
|
|
const updated = combats.map((c) =>
|
|
c.id !== data.combatId ? c : { ...c, enemy_roll: rollValue }
|
|
);
|
|
await saveCombats(data.campaignId, updated);
|
|
broadcast(io, socket, data.campaignId, updated);
|
|
});
|
|
|
|
// DM: end rolling phase, set current_side based on results. Ties go to party.
|
|
socket.on("initiative:begin", async (data: {
|
|
campaignId: number;
|
|
combatId: string;
|
|
}) => {
|
|
if (!await checkDM(socket, data.campaignId)) return;
|
|
|
|
const combats = await loadCombats(data.campaignId);
|
|
const updated = combats.map((c) => {
|
|
if (c.id !== data.combatId) return c;
|
|
const partyWins = (c.party_roll ?? 0) >= (c.enemy_roll ?? 0);
|
|
return {
|
|
...c,
|
|
phase: "active" as const,
|
|
current_side: (partyWins ? "party" : "enemies") as "party" | "enemies",
|
|
};
|
|
});
|
|
await saveCombats(data.campaignId, updated);
|
|
broadcast(io, socket, data.campaignId, updated);
|
|
});
|
|
|
|
// DM: flip active side; increment round when side flips back to party.
|
|
socket.on("initiative:next", async (data: {
|
|
campaignId: number;
|
|
combatId: string;
|
|
}) => {
|
|
if (!await checkDM(socket, data.campaignId)) return;
|
|
|
|
const combats = await loadCombats(data.campaignId);
|
|
const updated = combats.map((c) => {
|
|
if (c.id !== data.combatId) return c;
|
|
const nextSide: "party" | "enemies" =
|
|
c.current_side === "party" ? "enemies" : "party";
|
|
return {
|
|
...c,
|
|
current_side: nextSide,
|
|
round: nextSide === "party" ? c.round + 1 : c.round,
|
|
};
|
|
});
|
|
await saveCombats(data.campaignId, updated);
|
|
broadcast(io, socket, data.campaignId, updated);
|
|
});
|
|
|
|
// DM: edit an enemy's name or HP.
|
|
socket.on("initiative:update-enemy", async (data: {
|
|
campaignId: number;
|
|
combatId: string;
|
|
enemyId: string;
|
|
name?: string;
|
|
hp_current?: number;
|
|
hp_max?: number;
|
|
}) => {
|
|
if (!await checkDM(socket, data.campaignId)) return;
|
|
|
|
const combats = await loadCombats(data.campaignId);
|
|
const updated = combats.map((c) => {
|
|
if (c.id !== data.combatId) return c;
|
|
return {
|
|
...c,
|
|
enemies: c.enemies.map((e) =>
|
|
e.id !== data.enemyId ? e : {
|
|
...e,
|
|
...(data.name !== undefined ? { name: data.name } : {}),
|
|
...(data.hp_current !== undefined ? { hp_current: data.hp_current } : {}),
|
|
...(data.hp_max !== undefined ? { hp_max: data.hp_max } : {}),
|
|
}
|
|
),
|
|
};
|
|
});
|
|
await saveCombats(data.campaignId, updated);
|
|
broadcast(io, socket, data.campaignId, updated);
|
|
});
|
|
|
|
// DM: add a new enemy mid-combat.
|
|
socket.on("initiative:add-enemy", async (data: {
|
|
campaignId: number;
|
|
combatId: string;
|
|
name: string;
|
|
hp_max: number;
|
|
}) => {
|
|
if (!await checkDM(socket, data.campaignId)) return;
|
|
|
|
const combats = await loadCombats(data.campaignId);
|
|
const updated = combats.map((c) =>
|
|
c.id !== data.combatId ? c : {
|
|
...c,
|
|
enemies: [...c.enemies, {
|
|
id: randomUUID(),
|
|
name: data.name,
|
|
hp_current: data.hp_max,
|
|
hp_max: data.hp_max,
|
|
}],
|
|
}
|
|
);
|
|
await saveCombats(data.campaignId, updated);
|
|
broadcast(io, socket, data.campaignId, updated);
|
|
});
|
|
|
|
// DM: remove an enemy (died, fled).
|
|
socket.on("initiative:remove-enemy", async (data: {
|
|
campaignId: number;
|
|
combatId: string;
|
|
enemyId: string;
|
|
}) => {
|
|
if (!await checkDM(socket, data.campaignId)) return;
|
|
|
|
const combats = await loadCombats(data.campaignId);
|
|
const updated = combats.map((c) =>
|
|
c.id !== data.combatId ? c : {
|
|
...c,
|
|
enemies: c.enemies.filter((e) => e.id !== data.enemyId),
|
|
}
|
|
);
|
|
await saveCombats(data.campaignId, updated);
|
|
broadcast(io, socket, data.campaignId, updated);
|
|
});
|
|
|
|
// DM: end combat, remove from array (saves NULL when last combat ends).
|
|
socket.on("initiative:end", async (data: {
|
|
campaignId: number;
|
|
combatId: string;
|
|
}) => {
|
|
if (!await checkDM(socket, data.campaignId)) return;
|
|
|
|
const combats = await loadCombats(data.campaignId);
|
|
const updated = combats.filter((c) => c.id !== data.combatId);
|
|
await saveCombats(data.campaignId, updated);
|
|
const toSend = updated.length > 0 ? updated : [];
|
|
socket.to(`campaign:${data.campaignId}`).emit("initiative:updated", toSend);
|
|
socket.emit("initiative:updated", toSend);
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify TypeScript compiles**
|
|
|
|
```bash
|
|
cd /Users/aaron.wood/workspace/shadowdark/server
|
|
npx tsc --noEmit
|
|
```
|
|
|
|
Expected: no errors.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add server/src/routes/initiative.ts
|
|
git commit -m "feat: add initiative socket handlers"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Server — wire initiative handlers into socket.ts
|
|
|
|
**Files:**
|
|
- Modify: `server/src/socket.ts`
|
|
|
|
- [ ] **Step 1: Add import and handler registration to socket.ts**
|
|
|
|
At the top of `server/src/socket.ts`, add the import after existing imports:
|
|
|
|
```typescript
|
|
import { registerInitiativeHandlers } from "./routes/initiative.js";
|
|
```
|
|
|
|
Inside the `io.on("connection", (socket) => {` block, add after the existing `socket.on("disconnect", ...)`:
|
|
|
|
```typescript
|
|
registerInitiativeHandlers(io, socket);
|
|
|
|
socket.on("initiative:request-state", async (campaignId: number) => {
|
|
// Handled inside registerInitiativeHandlers — no duplicate needed.
|
|
// (initiative:request-state is registered there)
|
|
});
|
|
```
|
|
|
|
Wait — `initiative:request-state` is already registered inside `registerInitiativeHandlers`. No duplicate needed. Just add the one line:
|
|
|
|
Replace the `setupSocket` function body to add the registration. The new full `setupSocket` function (only the relevant additions — leave everything else unchanged):
|
|
|
|
After the `socket.on("disconnect", ...)` block, add:
|
|
|
|
```typescript
|
|
registerInitiativeHandlers(io, socket);
|
|
```
|
|
|
|
The final connection handler should look like:
|
|
|
|
```typescript
|
|
io.on("connection", (socket) => {
|
|
socket.on("join-campaign", (campaignId: string) => {
|
|
socket.join(`campaign:${campaignId}`);
|
|
});
|
|
|
|
socket.on("leave-campaign", (campaignId: string) => {
|
|
socket.leave(`campaign:${campaignId}`);
|
|
});
|
|
|
|
socket.on("roll:request", async (data: { ... }) => {
|
|
// ... existing code unchanged ...
|
|
});
|
|
|
|
socket.on("atmosphere:update", async (data: AtmosphereUpdateData) => {
|
|
// ... existing code unchanged ...
|
|
});
|
|
|
|
socket.on("disconnect", () => {
|
|
// Rooms are cleaned up automatically by Socket.IO
|
|
});
|
|
|
|
registerInitiativeHandlers(io, socket); // ← ADD THIS LINE
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Verify TypeScript compiles**
|
|
|
|
```bash
|
|
cd /Users/aaron.wood/workspace/shadowdark/server
|
|
npx tsc --noEmit
|
|
```
|
|
|
|
Expected: no errors.
|
|
|
|
- [ ] **Step 3: Restart server and verify it starts cleanly**
|
|
|
|
```bash
|
|
# Kill existing server (port 3000) and restart
|
|
pkill -f "ts-node\|tsx\|node.*server" 2>/dev/null; sleep 1
|
|
cd /Users/aaron.wood/workspace/shadowdark/server && npm run dev &
|
|
sleep 3 && curl -s http://localhost:3000/api/auth/me | head -c 50
|
|
```
|
|
|
|
Expected: server starts without errors, curl returns a JSON response.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add server/src/socket.ts
|
|
git commit -m "feat: register initiative handlers in socket setup"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Client — add CombatState types
|
|
|
|
**Files:**
|
|
- Modify: `client/src/types.ts`
|
|
|
|
- [ ] **Step 1: Add CombatEnemy and CombatState interfaces to `client/src/types.ts`**
|
|
|
|
Append to the end of the file (after the `Condition` interface):
|
|
|
|
```typescript
|
|
export interface CombatEnemy {
|
|
id: string;
|
|
name: string;
|
|
hp_current?: number; // present only for DM; stripped before broadcast to players
|
|
hp_max?: number; // present only for DM; stripped before broadcast to players
|
|
}
|
|
|
|
export interface CombatState {
|
|
id: string;
|
|
label?: string;
|
|
mode: "team";
|
|
round: number;
|
|
phase: "rolling" | "active";
|
|
current_side: "party" | "enemies";
|
|
party_roll: number | null;
|
|
enemy_roll: number | null;
|
|
party_rolls: Record<number, number>; // characterId → roll value
|
|
character_ids: number[];
|
|
enemies: CombatEnemy[];
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify TypeScript compiles**
|
|
|
|
```bash
|
|
cd /Users/aaron.wood/workspace/shadowdark/client
|
|
npx tsc --noEmit
|
|
```
|
|
|
|
Expected: no errors.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add client/src/types.ts
|
|
git commit -m "feat: add CombatState and CombatEnemy types"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Client — InitiativeTracker component
|
|
|
|
**Files:**
|
|
- Create: `client/src/components/InitiativeTracker.tsx`
|
|
- Create: `client/src/components/InitiativeTracker.module.css`
|
|
|
|
The component renders two phases internally. It emits socket events directly (same pattern as DiceTray/AtmospherePanel).
|
|
|
|
- [ ] **Step 1: Create `client/src/components/InitiativeTracker.tsx`**
|
|
|
|
```typescript
|
|
import { useState } from "react";
|
|
import socket from "../socket.js";
|
|
import type { CombatState, Character } from "../types.js";
|
|
import styles from "./InitiativeTracker.module.css";
|
|
|
|
interface InitiativeTrackerProps {
|
|
combat: CombatState;
|
|
characters: Character[];
|
|
isDM: boolean;
|
|
currentUserId: number | null;
|
|
campaignId: number;
|
|
}
|
|
|
|
export default function InitiativeTracker({
|
|
combat,
|
|
characters,
|
|
isDM,
|
|
currentUserId,
|
|
campaignId,
|
|
}: InitiativeTrackerProps) {
|
|
const [addEnemyName, setAddEnemyName] = useState("");
|
|
const [addEnemyHp, setAddEnemyHp] = useState("");
|
|
const [showAddEnemy, setShowAddEnemy] = useState(false);
|
|
|
|
const partyChars = characters.filter((c) => combat.character_ids.includes(c.id));
|
|
|
|
// Find the current user's character(s) in this combat.
|
|
const myCharIds = partyChars
|
|
.filter((c) => c.user_id === currentUserId)
|
|
.map((c) => c.id);
|
|
|
|
function emitRoll(characterId: number) {
|
|
const char = characters.find((c) => c.id === characterId);
|
|
if (!char) return;
|
|
socket.emit("initiative:roll", {
|
|
campaignId,
|
|
combatId: combat.id,
|
|
characterId,
|
|
characterName: char.name,
|
|
characterColor: char.color,
|
|
});
|
|
}
|
|
|
|
function emitEnemyRoll() {
|
|
socket.emit("initiative:enemy-roll", { campaignId, combatId: combat.id });
|
|
}
|
|
|
|
function emitBegin() {
|
|
socket.emit("initiative:begin", { campaignId, combatId: combat.id });
|
|
}
|
|
|
|
function emitNext() {
|
|
socket.emit("initiative:next", { campaignId, combatId: combat.id });
|
|
}
|
|
|
|
function emitEnd() {
|
|
socket.emit("initiative:end", { campaignId, combatId: combat.id });
|
|
}
|
|
|
|
function emitUpdateEnemyHp(enemyId: string, hp_current: number) {
|
|
socket.emit("initiative:update-enemy", {
|
|
campaignId,
|
|
combatId: combat.id,
|
|
enemyId,
|
|
hp_current,
|
|
});
|
|
}
|
|
|
|
function emitRemoveEnemy(enemyId: string) {
|
|
socket.emit("initiative:remove-enemy", { campaignId, combatId: combat.id, enemyId });
|
|
}
|
|
|
|
function emitAddEnemy() {
|
|
const hp = parseInt(addEnemyHp, 10);
|
|
if (!addEnemyName.trim() || isNaN(hp) || hp < 1) return;
|
|
socket.emit("initiative:add-enemy", {
|
|
campaignId,
|
|
combatId: combat.id,
|
|
name: addEnemyName.trim(),
|
|
hp_max: hp,
|
|
});
|
|
setAddEnemyName("");
|
|
setAddEnemyHp("");
|
|
setShowAddEnemy(false);
|
|
}
|
|
|
|
return (
|
|
<div className={styles.tracker}>
|
|
<div className={styles.header}>
|
|
<span className={styles.title}>
|
|
{combat.label ? combat.label : "Initiative"}
|
|
</span>
|
|
<span className={styles.round}>Round {combat.round}</span>
|
|
</div>
|
|
|
|
{combat.phase === "rolling" ? (
|
|
<RollingPhase
|
|
combat={combat}
|
|
partyChars={partyChars}
|
|
myCharIds={myCharIds}
|
|
isDM={isDM}
|
|
onRoll={emitRoll}
|
|
onEnemyRoll={emitEnemyRoll}
|
|
onBegin={emitBegin}
|
|
onEnd={emitEnd}
|
|
/>
|
|
) : (
|
|
<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}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Rolling phase ────────────────────────────────────────────────────────
|
|
|
|
interface RollingPhaseProps {
|
|
combat: CombatState;
|
|
partyChars: Character[];
|
|
myCharIds: number[];
|
|
isDM: boolean;
|
|
onRoll: (charId: number) => void;
|
|
onEnemyRoll: () => void;
|
|
onBegin: () => void;
|
|
onEnd: () => void;
|
|
}
|
|
|
|
function RollingPhase({
|
|
combat,
|
|
partyChars,
|
|
myCharIds,
|
|
isDM,
|
|
onRoll,
|
|
onEnemyRoll,
|
|
onBegin,
|
|
onEnd,
|
|
}: RollingPhaseProps) {
|
|
return (
|
|
<>
|
|
<div className={styles.phaseLabel}>Rolling Initiative…</div>
|
|
|
|
<div className={styles.section}>
|
|
<div className={styles.sectionLabel}>Party</div>
|
|
{partyChars.map((c) => {
|
|
const rolled = combat.party_rolls[c.id];
|
|
const canRoll = myCharIds.includes(c.id) && rolled === undefined;
|
|
return (
|
|
<div key={c.id} className={styles.rollRow}>
|
|
<span
|
|
className={styles.dot}
|
|
style={{ background: c.color }}
|
|
/>
|
|
<span className={styles.rollName}>{c.name}</span>
|
|
{rolled !== undefined ? (
|
|
<span className={styles.rollValue}>{rolled}</span>
|
|
) : canRoll ? (
|
|
<button
|
|
className={styles.rollBtn}
|
|
onClick={() => onRoll(c.id)}
|
|
>
|
|
Roll d20
|
|
</button>
|
|
) : (
|
|
<span className={styles.rollPending}>—</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className={styles.section}>
|
|
<div className={styles.sectionLabel}>Enemies</div>
|
|
<div className={styles.rollRow}>
|
|
<span className={styles.rollName}>Monsters</span>
|
|
{combat.enemy_roll !== null ? (
|
|
<span className={styles.rollValue}>{combat.enemy_roll}</span>
|
|
) : isDM ? (
|
|
<button className={styles.rollBtn} onClick={onEnemyRoll}>
|
|
Roll d20
|
|
</button>
|
|
) : (
|
|
<span className={styles.rollPending}>—</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{isDM && (
|
|
<div className={styles.footer}>
|
|
<button
|
|
className={styles.primaryBtn}
|
|
disabled={combat.enemy_roll === null}
|
|
onClick={onBegin}
|
|
>
|
|
Begin Combat ▶
|
|
</button>
|
|
<button className={styles.outlineBtn} onClick={onEnd}>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ── Active phase ─────────────────────────────────────────────────────────
|
|
|
|
interface ActivePhaseProps {
|
|
combat: CombatState;
|
|
partyChars: Character[];
|
|
isDM: boolean;
|
|
showAddEnemy: boolean;
|
|
addEnemyName: string;
|
|
addEnemyHp: string;
|
|
onSetShowAddEnemy: (v: boolean) => void;
|
|
onSetAddEnemyName: (v: string) => void;
|
|
onSetAddEnemyHp: (v: string) => void;
|
|
onUpdateEnemyHp: (enemyId: string, hp: number) => void;
|
|
onRemoveEnemy: (enemyId: string) => void;
|
|
onAddEnemy: () => void;
|
|
onNext: () => void;
|
|
onEnd: () => void;
|
|
}
|
|
|
|
function ActivePhase({
|
|
combat,
|
|
partyChars,
|
|
isDM,
|
|
showAddEnemy,
|
|
addEnemyName,
|
|
addEnemyHp,
|
|
onSetShowAddEnemy,
|
|
onSetAddEnemyName,
|
|
onSetAddEnemyHp,
|
|
onUpdateEnemyHp,
|
|
onRemoveEnemy,
|
|
onAddEnemy,
|
|
}: ActivePhaseProps & { onNext: () => void; onEnd: () => void }) {
|
|
const partyActive = combat.current_side === "party";
|
|
|
|
return (
|
|
<>
|
|
{/* Party block */}
|
|
<div className={`${styles.section} ${partyActive ? styles.activeSection : ""}`}>
|
|
<div className={styles.sectionLabel}>
|
|
Party
|
|
{combat.party_roll !== null && (
|
|
<span className={styles.rollSummary}> ({combat.party_roll} vs {combat.enemy_roll ?? "?"})</span>
|
|
)}
|
|
</div>
|
|
{partyChars.map((c) => (
|
|
<div key={c.id} className={styles.combatantRow}>
|
|
<span className={styles.dot} style={{ background: c.color }} />
|
|
<span className={partyActive ? styles.activeName : styles.rollName}>
|
|
{c.name}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Enemies block */}
|
|
<div className={`${styles.section} ${!partyActive ? styles.activeSection : ""}`}>
|
|
<div className={styles.sectionLabel}>Enemies</div>
|
|
{combat.enemies.map((e) => (
|
|
<div key={e.id} className={styles.combatantRow}>
|
|
<span className={styles.enemyDot} />
|
|
<span className={!partyActive ? styles.activeName : styles.rollName}>
|
|
{e.name}
|
|
</span>
|
|
{isDM && e.hp_current !== undefined && e.hp_max !== undefined && (
|
|
<span className={styles.enemyHp}>
|
|
<input
|
|
type="number"
|
|
className={styles.hpInput}
|
|
value={e.hp_current}
|
|
min={0}
|
|
onChange={(ev) =>
|
|
onUpdateEnemyHp(e.id, Number(ev.target.value))
|
|
}
|
|
/>
|
|
<span className={styles.hpMax}>/{e.hp_max}</span>
|
|
</span>
|
|
)}
|
|
{isDM && (
|
|
<button
|
|
className={styles.removeBtn}
|
|
onClick={() => onRemoveEnemy(e.id)}
|
|
title="Remove enemy"
|
|
>
|
|
✕
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{isDM && showAddEnemy && (
|
|
<div className={styles.addEnemyForm}>
|
|
<input
|
|
className={styles.addEnemyInput}
|
|
placeholder="Name"
|
|
value={addEnemyName}
|
|
onChange={(e) => onSetAddEnemyName(e.target.value)}
|
|
/>
|
|
<input
|
|
className={styles.addEnemyInput}
|
|
placeholder="HP"
|
|
type="number"
|
|
min={1}
|
|
value={addEnemyHp}
|
|
onChange={(e) => onSetAddEnemyHp(e.target.value)}
|
|
/>
|
|
<button className={styles.primaryBtn} onClick={onAddEnemy}>Add</button>
|
|
<button className={styles.outlineBtn} onClick={() => onSetShowAddEnemy(false)}>
|
|
✕
|
|
</button>
|
|
</div>
|
|
)}
|
|
{isDM && !showAddEnemy && (
|
|
<button
|
|
className={styles.addEnemyBtn}
|
|
onClick={() => onSetShowAddEnemy(true)}
|
|
>
|
|
+ Add Enemy
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{isDM && (
|
|
<div className={styles.footer}>
|
|
<button className={styles.primaryBtn} onClick={() => {
|
|
const { onNext } = arguments[0] as never;
|
|
void onNext;
|
|
}}>
|
|
Next Turn ▶
|
|
</button>
|
|
<button className={styles.outlineBtn} onClick={() => {
|
|
const { onEnd } = arguments[0] as never;
|
|
void onEnd;
|
|
}}>
|
|
End
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
```
|
|
|
|
Wait — the `ActivePhase` footer has a bug passing `onNext`/`onEnd` through the destructured `arguments` trick. Fix that — pass them properly. Here is the corrected `ActivePhase` component footer only (replace the footer section):
|
|
|
|
```typescript
|
|
{isDM && (
|
|
<div className={styles.footer}>
|
|
<button className={styles.primaryBtn} onClick={onNext}>
|
|
Next Turn ▶
|
|
</button>
|
|
<button className={styles.outlineBtn} onClick={onEnd}>
|
|
End
|
|
</button>
|
|
</div>
|
|
)}
|
|
```
|
|
|
|
The full corrected `ActivePhase` component (replace the entire `ActivePhase` function with this):
|
|
|
|
```typescript
|
|
function ActivePhase({
|
|
combat,
|
|
partyChars,
|
|
isDM,
|
|
showAddEnemy,
|
|
addEnemyName,
|
|
addEnemyHp,
|
|
onSetShowAddEnemy,
|
|
onSetAddEnemyName,
|
|
onSetAddEnemyHp,
|
|
onUpdateEnemyHp,
|
|
onRemoveEnemy,
|
|
onAddEnemy,
|
|
onNext,
|
|
onEnd,
|
|
}: ActivePhaseProps) {
|
|
const partyActive = combat.current_side === "party";
|
|
|
|
return (
|
|
<>
|
|
<div className={`${styles.section} ${partyActive ? styles.activeSection : ""}`}>
|
|
<div className={styles.sectionLabel}>
|
|
Party
|
|
{combat.party_roll !== null && (
|
|
<span className={styles.rollSummary}>
|
|
{" "}({combat.party_roll} vs {combat.enemy_roll ?? "?"})
|
|
</span>
|
|
)}
|
|
</div>
|
|
{partyChars.map((c) => (
|
|
<div key={c.id} className={styles.combatantRow}>
|
|
<span className={styles.dot} style={{ background: c.color }} />
|
|
<span className={partyActive ? styles.activeName : styles.rollName}>
|
|
{c.name}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className={`${styles.section} ${!partyActive ? styles.activeSection : ""}`}>
|
|
<div className={styles.sectionLabel}>Enemies</div>
|
|
{combat.enemies.map((e) => (
|
|
<div key={e.id} className={styles.combatantRow}>
|
|
<span className={styles.enemyDot} />
|
|
<span className={!partyActive ? styles.activeName : styles.rollName}>
|
|
{e.name}
|
|
</span>
|
|
{isDM && e.hp_current !== undefined && e.hp_max !== undefined && (
|
|
<span className={styles.enemyHp}>
|
|
<input
|
|
type="number"
|
|
className={styles.hpInput}
|
|
value={e.hp_current}
|
|
min={0}
|
|
onChange={(ev) => onUpdateEnemyHp(e.id, Number(ev.target.value))}
|
|
/>
|
|
<span className={styles.hpMax}>/{e.hp_max}</span>
|
|
</span>
|
|
)}
|
|
{isDM && (
|
|
<button
|
|
className={styles.removeBtn}
|
|
onClick={() => onRemoveEnemy(e.id)}
|
|
title="Remove"
|
|
>
|
|
✕
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{isDM && showAddEnemy && (
|
|
<div className={styles.addEnemyForm}>
|
|
<input
|
|
className={styles.addEnemyInput}
|
|
placeholder="Name"
|
|
value={addEnemyName}
|
|
onChange={(e) => onSetAddEnemyName(e.target.value)}
|
|
/>
|
|
<input
|
|
className={styles.addEnemyInput}
|
|
placeholder="HP"
|
|
type="number"
|
|
min={1}
|
|
value={addEnemyHp}
|
|
onChange={(e) => onSetAddEnemyHp(e.target.value)}
|
|
/>
|
|
<button className={styles.primaryBtn} onClick={onAddEnemy}>
|
|
Add
|
|
</button>
|
|
<button
|
|
className={styles.outlineBtn}
|
|
onClick={() => onSetShowAddEnemy(false)}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
)}
|
|
{isDM && !showAddEnemy && (
|
|
<button
|
|
className={styles.addEnemyBtn}
|
|
onClick={() => onSetShowAddEnemy(true)}
|
|
>
|
|
+ Add Enemy
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{isDM && (
|
|
<div className={styles.footer}>
|
|
<button className={styles.primaryBtn} onClick={onNext}>
|
|
Next Turn ▶
|
|
</button>
|
|
<button className={styles.outlineBtn} onClick={onEnd}>
|
|
End
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Note for implementer:** Write the full file using the corrected `ActivePhase` (the one immediately above), not the first draft with the `arguments` trick.
|
|
|
|
- [ ] **Step 2: Create `client/src/components/InitiativeTracker.module.css`**
|
|
|
|
```css
|
|
.tracker {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
padding: 0.65rem 0.5rem;
|
|
gap: 0;
|
|
}
|
|
|
|
.header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding-bottom: 0.5rem;
|
|
margin-bottom: 0.4rem;
|
|
border-bottom: 1px solid rgba(var(--gold-rgb), 0.2);
|
|
}
|
|
|
|
.title {
|
|
font-family: var(--font-display, "Cinzel", Georgia, serif);
|
|
font-size: 0.68rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.1em;
|
|
text-transform: uppercase;
|
|
color: var(--gold);
|
|
}
|
|
|
|
.round {
|
|
font-size: 0.6rem;
|
|
color: var(--text-tertiary);
|
|
}
|
|
|
|
.phaseLabel {
|
|
font-size: 0.65rem;
|
|
color: var(--text-tertiary);
|
|
font-style: italic;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
/* ── Section blocks ── */
|
|
|
|
.section {
|
|
padding: 0.4rem 0.35rem;
|
|
border-radius: 3px;
|
|
border: 1px solid transparent;
|
|
margin-bottom: 0.4rem;
|
|
transition: border-color 0.15s, background 0.15s;
|
|
}
|
|
|
|
.activeSection {
|
|
border-color: rgba(var(--gold-rgb), 0.35);
|
|
background: rgba(var(--gold-rgb), 0.04);
|
|
box-shadow: inset 2px 0 0 var(--gold);
|
|
}
|
|
|
|
.sectionLabel {
|
|
font-family: var(--font-display, "Cinzel", Georgia, serif);
|
|
font-size: 0.6rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
color: var(--text-secondary);
|
|
margin-bottom: 0.35rem;
|
|
}
|
|
|
|
.rollSummary {
|
|
font-weight: 400;
|
|
font-family: inherit;
|
|
color: var(--text-tertiary);
|
|
font-size: 0.58rem;
|
|
}
|
|
|
|
/* ── Combatant rows ── */
|
|
|
|
.rollRow,
|
|
.combatantRow {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
padding: 0.2rem 0;
|
|
min-height: 1.6rem;
|
|
}
|
|
|
|
.dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.enemyDot {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background: rgba(var(--gold-rgb), 0.3);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.rollName {
|
|
font-size: 0.68rem;
|
|
color: var(--text-secondary);
|
|
flex: 1;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.activeName {
|
|
font-size: 0.68rem;
|
|
color: var(--gold);
|
|
font-weight: 700;
|
|
flex: 1;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.rollValue {
|
|
font-family: var(--font-display, "Cinzel", Georgia, serif);
|
|
font-size: 0.8rem;
|
|
font-weight: 700;
|
|
color: var(--gold);
|
|
min-width: 20px;
|
|
text-align: right;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.rollPending {
|
|
font-size: 0.7rem;
|
|
color: var(--text-tertiary);
|
|
min-width: 20px;
|
|
text-align: right;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.rollBtn {
|
|
font-family: var(--font-display, "Cinzel", Georgia, serif);
|
|
font-size: 0.58rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.04em;
|
|
padding: 0.2rem 0.4rem;
|
|
background: rgba(var(--gold-rgb), 0.85);
|
|
border: none;
|
|
border-radius: 3px;
|
|
color: #1a1408;
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.rollBtn:hover {
|
|
filter: brightness(1.1);
|
|
}
|
|
|
|
/* ── Enemy HP ── */
|
|
|
|
.enemyHp {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.1rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.hpInput {
|
|
width: 32px;
|
|
font-size: 0.65rem;
|
|
font-weight: 700;
|
|
color: var(--hp, #e05a5a);
|
|
background: var(--bg-input, rgba(0,0,0,0.3));
|
|
border: 1px solid rgba(var(--gold-rgb), 0.15);
|
|
border-radius: 2px;
|
|
padding: 0.1rem 0.2rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.hpInput:focus {
|
|
outline: none;
|
|
border-color: rgba(var(--gold-rgb), 0.4);
|
|
}
|
|
|
|
/* Remove arrows on number inputs */
|
|
.hpInput::-webkit-inner-spin-button,
|
|
.hpInput::-webkit-outer-spin-button {
|
|
-webkit-appearance: none;
|
|
}
|
|
|
|
.hpMax {
|
|
font-size: 0.58rem;
|
|
color: var(--text-tertiary);
|
|
}
|
|
|
|
.removeBtn {
|
|
font-size: 0.6rem;
|
|
padding: 0.1rem 0.25rem;
|
|
background: none;
|
|
border: 1px solid transparent;
|
|
border-radius: 2px;
|
|
color: var(--text-tertiary);
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
transition: all 0.12s;
|
|
}
|
|
|
|
.removeBtn:hover {
|
|
color: var(--danger, #c0392b);
|
|
border-color: rgba(192, 57, 43, 0.3);
|
|
}
|
|
|
|
/* ── Add enemy form ── */
|
|
|
|
.addEnemyBtn {
|
|
font-family: var(--font-display, "Cinzel", Georgia, serif);
|
|
font-size: 0.6rem;
|
|
font-weight: 600;
|
|
padding: 0.2rem 0.4rem;
|
|
background: transparent;
|
|
border: 1px dashed rgba(var(--gold-rgb), 0.25);
|
|
border-radius: 3px;
|
|
color: var(--text-tertiary);
|
|
cursor: pointer;
|
|
width: 100%;
|
|
margin-top: 0.25rem;
|
|
transition: all 0.12s;
|
|
}
|
|
|
|
.addEnemyBtn:hover {
|
|
border-color: rgba(var(--gold-rgb), 0.45);
|
|
color: var(--gold);
|
|
}
|
|
|
|
.addEnemyForm {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
margin-top: 0.3rem;
|
|
}
|
|
|
|
.addEnemyInput {
|
|
font-size: 0.65rem;
|
|
background: var(--bg-input, rgba(0,0,0,0.3));
|
|
border: 1px solid rgba(var(--gold-rgb), 0.2);
|
|
border-radius: 3px;
|
|
padding: 0.25rem 0.4rem;
|
|
color: var(--text-primary);
|
|
width: 100%;
|
|
}
|
|
|
|
.addEnemyInput:focus {
|
|
outline: none;
|
|
border-color: rgba(var(--gold-rgb), 0.45);
|
|
}
|
|
|
|
/* ── Footer buttons ── */
|
|
|
|
.footer {
|
|
margin-top: auto;
|
|
padding-top: 0.5rem;
|
|
border-top: 1px solid rgba(var(--gold-rgb), 0.12);
|
|
display: flex;
|
|
gap: 0.3rem;
|
|
}
|
|
|
|
.primaryBtn {
|
|
font-family: var(--font-display, "Cinzel", Georgia, serif);
|
|
font-size: 0.6rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.04em;
|
|
padding: 0.3rem 0.5rem;
|
|
background: rgba(var(--gold-rgb), 0.85);
|
|
border: none;
|
|
border-radius: 3px;
|
|
color: #1a1408;
|
|
cursor: pointer;
|
|
flex: 1;
|
|
transition: filter 0.12s;
|
|
}
|
|
|
|
.primaryBtn:hover:not(:disabled) {
|
|
filter: brightness(1.1);
|
|
}
|
|
|
|
.primaryBtn:disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.outlineBtn {
|
|
font-family: var(--font-display, "Cinzel", Georgia, serif);
|
|
font-size: 0.6rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.04em;
|
|
padding: 0.3rem 0.5rem;
|
|
background: transparent;
|
|
border: 1px solid rgba(var(--gold-rgb), 0.3);
|
|
border-radius: 3px;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
flex: 1;
|
|
transition: all 0.12s;
|
|
}
|
|
|
|
.outlineBtn:hover {
|
|
border-color: rgba(var(--gold-rgb), 0.5);
|
|
color: var(--gold);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Verify TypeScript compiles**
|
|
|
|
```bash
|
|
cd /Users/aaron.wood/workspace/shadowdark/client
|
|
npx tsc --noEmit
|
|
```
|
|
|
|
Expected: no errors.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add client/src/components/InitiativeTracker.tsx \
|
|
client/src/components/InitiativeTracker.module.css
|
|
git commit -m "feat: add InitiativeTracker sidebar component"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Client — CombatStartModal
|
|
|
|
**Files:**
|
|
- Create: `client/src/components/CombatStartModal.tsx`
|
|
- Create: `client/src/components/CombatStartModal.module.css`
|
|
|
|
- [ ] **Step 1: Create `client/src/components/CombatStartModal.tsx`**
|
|
|
|
```typescript
|
|
import { useState } from "react";
|
|
import socket from "../socket.js";
|
|
import type { Character } from "../types.js";
|
|
import styles from "./CombatStartModal.module.css";
|
|
|
|
interface CombatStartModalProps {
|
|
characters: Character[];
|
|
campaignId: number;
|
|
onClose: () => void;
|
|
}
|
|
|
|
interface EnemyEntry {
|
|
key: number;
|
|
name: string;
|
|
hp_max: string;
|
|
}
|
|
|
|
let enemyKey = 0;
|
|
|
|
export default function CombatStartModal({
|
|
characters,
|
|
campaignId,
|
|
onClose,
|
|
}: CombatStartModalProps) {
|
|
const [label, setLabel] = useState("");
|
|
const [selectedCharIds, setSelectedCharIds] = useState<Set<number>>(
|
|
new Set(characters.map((c) => c.id))
|
|
);
|
|
const [enemies, setEnemies] = useState<EnemyEntry[]>([
|
|
{ key: enemyKey++, name: "", hp_max: "" },
|
|
]);
|
|
|
|
function toggleChar(id: number) {
|
|
setSelectedCharIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) {
|
|
next.delete(id);
|
|
} else {
|
|
next.add(id);
|
|
}
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function updateEnemy(key: number, field: "name" | "hp_max", value: string) {
|
|
setEnemies((prev) =>
|
|
prev.map((e) => (e.key === key ? { ...e, [field]: value } : e))
|
|
);
|
|
}
|
|
|
|
function addEnemyRow() {
|
|
setEnemies((prev) => [...prev, { key: enemyKey++, name: "", hp_max: "" }]);
|
|
}
|
|
|
|
function removeEnemyRow(key: number) {
|
|
setEnemies((prev) => prev.filter((e) => e.key !== key));
|
|
}
|
|
|
|
function handleStart() {
|
|
const validEnemies = enemies
|
|
.filter((e) => e.name.trim() && parseInt(e.hp_max, 10) > 0)
|
|
.map((e) => ({ name: e.name.trim(), hp_max: parseInt(e.hp_max, 10) }));
|
|
|
|
socket.emit("initiative:start", {
|
|
campaignId,
|
|
label: label.trim() || undefined,
|
|
character_ids: Array.from(selectedCharIds),
|
|
enemies: validEnemies,
|
|
});
|
|
onClose();
|
|
}
|
|
|
|
const canStart = selectedCharIds.size > 0;
|
|
|
|
return (
|
|
<div className={styles.backdrop} onClick={onClose}>
|
|
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
|
|
<div className={styles.modalHeader}>
|
|
<span className={styles.modalTitle}>Start Combat</span>
|
|
<button className={styles.closeBtn} onClick={onClose}>✕</button>
|
|
</div>
|
|
|
|
{/* Optional label */}
|
|
<div className={styles.field}>
|
|
<label className={styles.fieldLabel}>Label (optional)</label>
|
|
<input
|
|
className={styles.input}
|
|
placeholder="e.g. Throne Room"
|
|
value={label}
|
|
onChange={(e) => setLabel(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Character selection */}
|
|
<div className={styles.field}>
|
|
<label className={styles.fieldLabel}>Party Members</label>
|
|
<div className={styles.charList}>
|
|
{characters.map((c) => (
|
|
<label key={c.id} className={styles.charRow}>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedCharIds.has(c.id)}
|
|
onChange={() => toggleChar(c.id)}
|
|
/>
|
|
<span
|
|
className={styles.charDot}
|
|
style={{ background: c.color }}
|
|
/>
|
|
<span className={styles.charName}>{c.name}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Enemy entries */}
|
|
<div className={styles.field}>
|
|
<label className={styles.fieldLabel}>Enemies</label>
|
|
{enemies.map((e) => (
|
|
<div key={e.key} className={styles.enemyRow}>
|
|
<input
|
|
className={styles.input}
|
|
placeholder="Name (e.g. Goblin x3)"
|
|
value={e.name}
|
|
onChange={(ev) => updateEnemy(e.key, "name", ev.target.value)}
|
|
/>
|
|
<input
|
|
className={`${styles.input} ${styles.hpInput}`}
|
|
placeholder="Max HP"
|
|
type="number"
|
|
min={1}
|
|
value={e.hp_max}
|
|
onChange={(ev) => updateEnemy(e.key, "hp_max", ev.target.value)}
|
|
/>
|
|
<button
|
|
className={styles.removeRowBtn}
|
|
onClick={() => removeEnemyRow(e.key)}
|
|
disabled={enemies.length === 1}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
))}
|
|
<button className={styles.addRowBtn} onClick={addEnemyRow}>
|
|
+ Add Another Enemy
|
|
</button>
|
|
</div>
|
|
|
|
<div className={styles.actions}>
|
|
<button
|
|
className={styles.startBtn}
|
|
onClick={handleStart}
|
|
disabled={!canStart}
|
|
>
|
|
Roll Initiative ⚔
|
|
</button>
|
|
<button className={styles.cancelBtn} onClick={onClose}>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create `client/src/components/CombatStartModal.module.css`**
|
|
|
|
```css
|
|
.backdrop {
|
|
position: absolute;
|
|
inset: 0;
|
|
background: var(--bg-overlay, rgba(0,0,0,0.6));
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 200;
|
|
padding: 1rem;
|
|
}
|
|
|
|
.modal {
|
|
background: var(--bg-modal, #1a1814);
|
|
background-image: var(--texture-surface), var(--texture-speckle);
|
|
background-size: 256px 256px, 128px 128px;
|
|
border: 2px solid rgba(var(--gold-rgb), 0.3);
|
|
border-radius: 4px;
|
|
width: 100%;
|
|
max-width: 480px;
|
|
max-height: 85vh;
|
|
overflow-y: auto;
|
|
padding: 1.25rem;
|
|
box-shadow: 0 8px 40px rgba(0,0,0,0.7);
|
|
}
|
|
|
|
.modalHeader {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 1.25rem;
|
|
}
|
|
|
|
.modalTitle {
|
|
font-family: var(--font-display, "Cinzel", Georgia, serif);
|
|
font-size: 1rem;
|
|
font-weight: 700;
|
|
color: var(--gold);
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.closeBtn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-secondary);
|
|
font-size: 1.2rem;
|
|
cursor: pointer;
|
|
padding: 0.1rem 0.3rem;
|
|
}
|
|
|
|
.closeBtn:hover {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.field {
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.fieldLabel {
|
|
display: block;
|
|
font-family: var(--font-display, "Cinzel", Georgia, serif);
|
|
font-size: 0.68rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
color: var(--text-secondary);
|
|
margin-bottom: 0.4rem;
|
|
}
|
|
|
|
.input {
|
|
width: 100%;
|
|
font-size: 0.82rem;
|
|
color: var(--text-primary);
|
|
background: var(--bg-input, rgba(0,0,0,0.3));
|
|
border: 1px solid rgba(var(--gold-rgb), 0.2);
|
|
border-radius: 3px;
|
|
padding: 0.4rem 0.6rem;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.input:focus {
|
|
outline: none;
|
|
border-color: rgba(var(--gold-rgb), 0.5);
|
|
}
|
|
|
|
.charList {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.3rem;
|
|
background: var(--bg-inset, rgba(0,0,0,0.2));
|
|
border: 1px solid rgba(var(--gold-rgb), 0.12);
|
|
border-radius: 3px;
|
|
padding: 0.5rem 0.6rem;
|
|
}
|
|
|
|
.charRow {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
cursor: pointer;
|
|
font-size: 0.82rem;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.charRow input[type="checkbox"] {
|
|
accent-color: var(--gold);
|
|
}
|
|
|
|
.charDot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.charName {
|
|
flex: 1;
|
|
}
|
|
|
|
.enemyRow {
|
|
display: flex;
|
|
gap: 0.4rem;
|
|
align-items: center;
|
|
margin-bottom: 0.35rem;
|
|
}
|
|
|
|
.enemyRow .input {
|
|
flex: 1;
|
|
}
|
|
|
|
.hpInput {
|
|
max-width: 80px;
|
|
flex: 0 0 80px !important;
|
|
}
|
|
|
|
.removeRowBtn {
|
|
font-size: 0.7rem;
|
|
padding: 0.25rem 0.4rem;
|
|
background: none;
|
|
border: 1px solid rgba(var(--gold-rgb), 0.15);
|
|
border-radius: 3px;
|
|
color: var(--text-tertiary);
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
transition: all 0.12s;
|
|
}
|
|
|
|
.removeRowBtn:hover:not(:disabled) {
|
|
color: var(--danger, #c0392b);
|
|
border-color: rgba(192,57,43,0.3);
|
|
}
|
|
|
|
.removeRowBtn:disabled {
|
|
opacity: 0.3;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.addRowBtn {
|
|
font-family: var(--font-display, "Cinzel", Georgia, serif);
|
|
font-size: 0.68rem;
|
|
font-weight: 600;
|
|
padding: 0.3rem 0.6rem;
|
|
background: transparent;
|
|
border: 1px dashed rgba(var(--gold-rgb), 0.3);
|
|
border-radius: 3px;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
width: 100%;
|
|
margin-top: 0.15rem;
|
|
transition: all 0.12s;
|
|
}
|
|
|
|
.addRowBtn:hover {
|
|
border-color: rgba(var(--gold-rgb), 0.5);
|
|
color: var(--gold);
|
|
}
|
|
|
|
.actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-top: 1.25rem;
|
|
padding-top: 1rem;
|
|
border-top: 1px solid rgba(var(--gold-rgb), 0.15);
|
|
}
|
|
|
|
.startBtn {
|
|
font-family: var(--font-display, "Cinzel", Georgia, serif);
|
|
font-size: 0.82rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.05em;
|
|
padding: 0.5rem 1rem;
|
|
background: var(--btn-gold-bg, rgba(var(--gold-rgb), 0.85));
|
|
border: none;
|
|
border-radius: 4px;
|
|
color: #1a1408;
|
|
cursor: pointer;
|
|
flex: 1;
|
|
transition: filter 0.12s;
|
|
}
|
|
|
|
.startBtn:hover:not(:disabled) {
|
|
filter: brightness(1.1);
|
|
}
|
|
|
|
.startBtn:disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.cancelBtn {
|
|
font-family: var(--font-display, "Cinzel", Georgia, serif);
|
|
font-size: 0.82rem;
|
|
font-weight: 600;
|
|
padding: 0.5rem 1rem;
|
|
background: transparent;
|
|
border: 1px solid rgba(var(--gold-rgb), 0.3);
|
|
border-radius: 4px;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
transition: all 0.12s;
|
|
}
|
|
|
|
.cancelBtn:hover {
|
|
border-color: rgba(var(--gold-rgb), 0.5);
|
|
color: var(--gold);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Verify TypeScript compiles**
|
|
|
|
```bash
|
|
cd /Users/aaron.wood/workspace/shadowdark/client
|
|
npx tsc --noEmit
|
|
```
|
|
|
|
Expected: no errors.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add client/src/components/CombatStartModal.tsx \
|
|
client/src/components/CombatStartModal.module.css
|
|
git commit -m "feat: add CombatStartModal for DM combat setup"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Client — CampaignView integration
|
|
|
|
**Files:**
|
|
- Modify: `client/src/pages/CampaignView.tsx`
|
|
- Modify: `client/src/pages/CampaignView.module.css`
|
|
|
|
- [ ] **Step 1: Add combats state and socket wiring to `CampaignView.tsx`**
|
|
|
|
Add imports at the top (after existing imports):
|
|
|
|
```typescript
|
|
import type { CombatState } from "../types.js";
|
|
import InitiativeTracker from "../components/InitiativeTracker.js";
|
|
import CombatStartModal from "../components/CombatStartModal.js";
|
|
```
|
|
|
|
Add state inside the `CampaignView` function, after `focusSpells` state:
|
|
|
|
```typescript
|
|
const [combats, setCombats] = useState<CombatState[]>([]);
|
|
const [showCombatStart, setShowCombatStart] = useState(false);
|
|
```
|
|
|
|
Add to the main `useEffect` (after `socket.emit("join-campaign", ...)`) — emit the request for initiative state right after joining:
|
|
|
|
```typescript
|
|
socket.emit("join-campaign", String(campaignId));
|
|
socket.emit("initiative:request-state", campaignId);
|
|
```
|
|
|
|
Add two socket event handlers inside the socket listeners `useEffect`, after `onCharacterRested`:
|
|
|
|
```typescript
|
|
function onInitiativeState(data: CombatState[]) {
|
|
setCombats(data);
|
|
}
|
|
|
|
function onInitiativeUpdated(data: CombatState[]) {
|
|
setCombats(data);
|
|
}
|
|
```
|
|
|
|
Register them (inside same `useEffect`, after existing `socket.on` calls):
|
|
|
|
```typescript
|
|
socket.on("initiative:state", onInitiativeState);
|
|
socket.on("initiative:updated", onInitiativeUpdated);
|
|
```
|
|
|
|
Unregister them (inside same `useEffect` return cleanup, after existing `socket.off` calls):
|
|
|
|
```typescript
|
|
socket.off("initiative:state", onInitiativeState);
|
|
socket.off("initiative:updated", onInitiativeUpdated);
|
|
```
|
|
|
|
- [ ] **Step 2: Add the "⚔ Combat" header button and layout restructure to `CampaignView.tsx`**
|
|
|
|
In the JSX, add the "⚔ Combat" button inside `.headerBtns`, after the Atmosphere panel and before Invite (DM-only):
|
|
|
|
```tsx
|
|
{role === "dm" && (
|
|
<button
|
|
className={`${styles.addBtn} ${combats.length > 0 ? styles.addBtnActive : ""}`}
|
|
onClick={() => setShowCombatStart(true)}
|
|
>
|
|
⚔ Combat
|
|
</button>
|
|
)}
|
|
```
|
|
|
|
Replace the `<div className={styles.grid}>` section with a content wrapper that adds the sidebar when combat is active:
|
|
|
|
```tsx
|
|
<div className={`${styles.content} ${combats.length > 0 ? styles.withCombat : ""}`}>
|
|
{combats.length > 0 && (
|
|
<div className={styles.combatSidebar}>
|
|
<InitiativeTracker
|
|
combat={combats[0]}
|
|
characters={characters}
|
|
isDM={role === "dm"}
|
|
currentUserId={user?.userId ?? null}
|
|
campaignId={campaignId}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className={styles.grid}>
|
|
{characters.length === 0 && (
|
|
<p className={styles.empty}>
|
|
No characters yet. Add one to get started!
|
|
</p>
|
|
)}
|
|
{characters.map((char) => (
|
|
<CharacterCard
|
|
key={char.id}
|
|
character={char}
|
|
onHpChange={handleHpChange}
|
|
onUpdate={handleUpdate}
|
|
onClick={setSelectedId}
|
|
canEdit={role === "dm" || char.user_id === user?.userId}
|
|
focusSpell={focusSpells.get(char.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
Add the `CombatStartModal` just before the closing `</div>` of `.main`:
|
|
|
|
```tsx
|
|
{showCombatStart && (
|
|
<CombatStartModal
|
|
characters={characters}
|
|
campaignId={campaignId}
|
|
onClose={() => setShowCombatStart(false)}
|
|
/>
|
|
)}
|
|
```
|
|
|
|
- [ ] **Step 3: Add combat layout styles to `CampaignView.module.css`**
|
|
|
|
Append to the end of the file:
|
|
|
|
```css
|
|
/* ── Combat layout ── */
|
|
|
|
.content {
|
|
display: block;
|
|
}
|
|
|
|
.content.withCombat {
|
|
display: flex;
|
|
gap: 0;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.combatSidebar {
|
|
width: 190px;
|
|
flex-shrink: 0;
|
|
border-right: 1px solid rgba(var(--gold-rgb), 0.15);
|
|
padding-right: 0.5rem;
|
|
margin-right: 0.75rem;
|
|
min-height: 300px;
|
|
position: sticky;
|
|
top: 0;
|
|
}
|
|
|
|
.content.withCombat .grid {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.addBtnActive {
|
|
background: rgba(var(--gold-rgb), 0.15) !important;
|
|
border: 1px solid rgba(var(--gold-rgb), 0.5) !important;
|
|
color: var(--gold) !important;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.content.withCombat {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.combatSidebar {
|
|
width: 100%;
|
|
border-right: none;
|
|
border-bottom: 1px solid rgba(var(--gold-rgb), 0.15);
|
|
padding-right: 0;
|
|
margin-right: 0;
|
|
padding-bottom: 0.5rem;
|
|
margin-bottom: 0.75rem;
|
|
position: static;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Verify TypeScript compiles**
|
|
|
|
```bash
|
|
cd /Users/aaron.wood/workspace/shadowdark/client
|
|
npx tsc --noEmit
|
|
```
|
|
|
|
Expected: no errors.
|
|
|
|
- [ ] **Step 5: Smoke test the full flow**
|
|
|
|
1. Open `http://localhost:5173` in a browser, log in as DM (`dm@darkwatch.test` / `password`)
|
|
2. Open a campaign — header should show "⚔ Combat" button
|
|
3. Click "⚔ Combat" — CombatStartModal should appear with all characters checked
|
|
4. Add one enemy (e.g. "Goblin", HP 8), click "Roll Initiative ⚔"
|
|
5. The modal closes; the initiative sidebar should appear on the left showing "Rolling Initiative…"
|
|
6. Click "Roll for Enemies" d20 button — dice should animate, enemy roll appears
|
|
7. Click "Begin Combat ▶" — sidebar switches to active phase showing Party and Enemies blocks
|
|
8. Click "Next Turn ▶" — active side flips, round counter increments after second flip
|
|
9. Open a second browser tab logged in as player (`player@darkwatch.test` / `password`) — tracker should be visible but enemy HP should NOT appear
|
|
10. Click "End" in DM tab — sidebar disappears for both tabs
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add client/src/pages/CampaignView.tsx \
|
|
client/src/pages/CampaignView.module.css
|
|
git commit -m "feat: integrate initiative tracker into campaign view"
|
|
```
|
|
|
|
---
|
|
|
|
## Self-Review Checklist
|
|
|
|
After all tasks complete, verify against the spec:
|
|
|
|
- [ ] `campaigns.combat_state` JSON column exists — Task 1
|
|
- [ ] State persists: restart server mid-combat, reload client, tracker re-appears — Task 2 + 7
|
|
- [ ] Players see enemy names but not HP — Task 2 (`stripHp`) + Task 5 (conditional render)
|
|
- [ ] `initiative:request-state` sends on join — Task 3 + 7
|
|
- [ ] Rolling phase: players roll their own characters — Task 5 (`myCharIds`)
|
|
- [ ] Rolling phase: ties go to party (`>=`) — Task 2 (`initiative:begin`)
|
|
- [ ] Active phase: "Next Turn" flips sides, increments round on party start — Task 2 (`initiative:next`)
|
|
- [ ] Enemy HP editable inline by DM — Task 5
|
|
- [ ] Add/remove enemy mid-combat — Task 5 + 6
|
|
- [ ] Multiple combats: `combats` is an array; UI shows `combats[0]` — Task 7 (YAGNI: single-combat UI)
|
|
- [ ] Mobile: sidebar stacks above grid — Task 7 CSS
|
|
- [ ] `mode: "team"` field present on CombatState for future individual-mode support — Task 2 + 4
|