- 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>
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.tswith 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
- Open
http://localhost:5173in a browser, log in as DM (dm@darkwatch.test/password) - Open a campaign — header should show "⚔ Combat" button
- Click "⚔ Combat" — CombatStartModal should appear with all characters checked
- Add one enemy (e.g. "Goblin", HP 8), click "Roll Initiative ⚔"
- The modal closes; the initiative sidebar should appear on the left showing "Rolling Initiative…"
- Click "Roll for Enemies" d20 button — dice should animate, enemy roll appears
- Click "Begin Combat ▶" — sidebar switches to active phase showing Party and Enemies blocks
- Click "Next Turn ▶" — active side flips, round counter increments after second flip
- Open a second browser tab logged in as player (
player@darkwatch.test/password) — tracker should be visible but enemy HP should NOT appear - 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_stateJSON 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-statesends 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:
combatsis an array; UI showscombats[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