# 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; character_ids: number[]; enemies: CombatEnemy[]; } // ── Helpers ──────────────────────────────────────────────────────────────── export async function loadCombats(campaignId: number): Promise { const [rows] = await db.execute( "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 { 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[][] { 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 { const userId = socket.data.user?.userId; const [rows] = await db.execute( "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 { const [ins] = await db.execute( `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( "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( "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( "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; // 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 (
{combat.label ? combat.label : "Initiative"} Round {combat.round}
{combat.phase === "rolling" ? ( ) : ( )}
); } // ── 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 ( <>
Rolling Initiative…
Party
{partyChars.map((c) => { const rolled = combat.party_rolls[c.id]; const canRoll = myCharIds.includes(c.id) && rolled === undefined; return (
{c.name} {rolled !== undefined ? ( {rolled} ) : canRoll ? ( ) : ( )}
); })}
Enemies
Monsters {combat.enemy_roll !== null ? ( {combat.enemy_roll} ) : isDM ? ( ) : ( )}
{isDM && (
)} ); } // ── 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 */}
Party {combat.party_roll !== null && ( ({combat.party_roll} vs {combat.enemy_roll ?? "?"}) )}
{partyChars.map((c) => (
{c.name}
))}
{/* Enemies block */}
Enemies
{combat.enemies.map((e) => (
{e.name} {isDM && e.hp_current !== undefined && e.hp_max !== undefined && ( onUpdateEnemyHp(e.id, Number(ev.target.value)) } /> /{e.hp_max} )} {isDM && ( )}
))} {isDM && showAddEnemy && (
onSetAddEnemyName(e.target.value)} /> onSetAddEnemyHp(e.target.value)} />
)} {isDM && !showAddEnemy && ( )}
{isDM && (
)} ); } ``` 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 && (
)} ``` 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 ( <>
Party {combat.party_roll !== null && ( {" "}({combat.party_roll} vs {combat.enemy_roll ?? "?"}) )}
{partyChars.map((c) => (
{c.name}
))}
Enemies
{combat.enemies.map((e) => (
{e.name} {isDM && e.hp_current !== undefined && e.hp_max !== undefined && ( onUpdateEnemyHp(e.id, Number(ev.target.value))} /> /{e.hp_max} )} {isDM && ( )}
))} {isDM && showAddEnemy && (
onSetAddEnemyName(e.target.value)} /> onSetAddEnemyHp(e.target.value)} />
)} {isDM && !showAddEnemy && ( )}
{isDM && (
)} ); } ``` **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>( new Set(characters.map((c) => c.id)) ); const [enemies, setEnemies] = useState([ { 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 (
e.stopPropagation()}>
Start Combat
{/* Optional label */}
setLabel(e.target.value)} />
{/* Character selection */}
{characters.map((c) => ( ))}
{/* Enemy entries */}
{enemies.map((e) => (
updateEnemy(e.key, "name", ev.target.value)} /> updateEnemy(e.key, "hp_max", ev.target.value)} />
))}
); } ``` - [ ] **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([]); 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" && ( )} ``` Replace the `
` section with a content wrapper that adds the sidebar when combat is active: ```tsx
0 ? styles.withCombat : ""}`}> {combats.length > 0 && (
)}
{characters.length === 0 && (

No characters yet. Add one to get started!

)} {characters.map((char) => ( ))}
``` Add the `CombatStartModal` just before the closing `
` of `.main`: ```tsx {showCombatStart && ( 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