darkwatch/docs/plans/2026-04-11-initiative-tracker.md
Aaron Wood 7c7bdf2ee5 chore: consolidate docs into flat structure and commit all plans
- 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>
2026-04-11 23:55:45 -04:00

56 KiB

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

-- 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
cd /Users/aaron.wood/workspace/shadowdark/server
npm run dev

Expected: console prints Migration applied: 003_combat_state.sql

  • Step 3: Verify column exists
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
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
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
cd /Users/aaron.wood/workspace/shadowdark/server
npx tsc --noEmit

Expected: no errors.

  • Step 3: Commit
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:

import { registerInitiativeHandlers } from "./routes/initiative.js";

Inside the io.on("connection", (socket) => { block, add after the existing socket.on("disconnect", ...):

    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:

    registerInitiativeHandlers(io, socket);

The final connection handler should look like:

  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
cd /Users/aaron.wood/workspace/shadowdark/server
npx tsc --noEmit

Expected: no errors.

  • Step 3: Restart server and verify it starts cleanly
# 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
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):

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
cd /Users/aaron.wood/workspace/shadowdark/client
npx tsc --noEmit

Expected: no errors.

  • Step 3: Commit
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
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):

      {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):

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
.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
cd /Users/aaron.wood/workspace/shadowdark/client
npx tsc --noEmit

Expected: no errors.

  • Step 4: Commit
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

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
.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
cd /Users/aaron.wood/workspace/shadowdark/client
npx tsc --noEmit

Expected: no errors.

  • Step 4: Commit
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):

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:

  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:

    socket.emit("join-campaign", String(campaignId));
    socket.emit("initiative:request-state", campaignId);

Add two socket event handlers inside the socket listeners useEffect, after onCharacterRested:

    function onInitiativeState(data: CombatState[]) {
      setCombats(data);
    }

    function onInitiativeUpdated(data: CombatState[]) {
      setCombats(data);
    }

Register them (inside same useEffect, after existing socket.on calls):

    socket.on("initiative:state", onInitiativeState);
    socket.on("initiative:updated", onInitiativeUpdated);

Unregister them (inside same useEffect return cleanup, after existing socket.off calls):

    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):

            {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:

        <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:

        {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:

/* ── 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
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
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