darkwatch/docs/plans/2026-04-09-dice-rolling.md

28 KiB

Dice Rolling — 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 complete dice rolling system with server-side roll engine, shared real-time roll log panel, character sheet roll buttons, and advantage/disadvantage support.

Architecture: Server-side dice parser and roller handles all roll logic. Rolls flow through Socket.IO: client emits roll:request, server generates results, saves to roll_log table, broadcasts roll:result to the campaign room. A collapsible side panel on the campaign view shows the shared roll history. Roll buttons appear on stat rows and attack lines in view mode.

Tech Stack: Same as existing — React 18, TypeScript, CSS Modules, Node/Express, Socket.IO, better-sqlite3


File Structure

server/src/
├── dice.ts                    # CREATE: dice expression parser + roller
├── routes/rolls.ts            # CREATE: GET /api/campaigns/:id/rolls
├── db.ts                      # MODIFY: add roll_log table
├── socket.ts                  # MODIFY: handle roll:request events
├── index.ts                   # MODIFY: register rolls route

client/src/
├── types.ts                   # MODIFY: add RollResult type
├── api.ts                     # MODIFY: add getRolls()
├── components/
│   ├── RollLog.tsx            # CREATE: collapsible side panel
│   ├── RollLog.module.css     # CREATE: panel styling
│   ├── RollEntry.tsx          # CREATE: individual roll result card
│   ├── RollEntry.module.css   # CREATE: card styling + animation
│   ├── DiceButton.tsx         # CREATE: small reusable dice button
│   ├── DiceButton.module.css  # CREATE: button styling
│   ├── StatsPanel.tsx         # MODIFY: add DiceButton to stat rows
│   └── AttackBlock.tsx        # MODIFY: add DiceButton to attack lines
├── pages/
│   ├── CampaignView.tsx       # MODIFY: add RollLog panel, socket events
│   └── CampaignView.module.css # MODIFY: layout with side panel

Task 1: Dice Parser + Roll Engine (Server)

Files:

  • Create: server/src/dice.ts

  • Step 1: Create server/src/dice.ts

export interface ParsedDice {
  count: number;
  sides: number;
  modifier: number;
  raw: string;
}

export interface RollResult {
  rolls: number[];
  modifier: number;
  total: number;
  expression: string;
  error?: string;
}

export function parseDice(expression: string): ParsedDice | null {
  const cleaned = expression.trim().toLowerCase().replace(/\s/g, "");
  const match = cleaned.match(/^(\d*)d(\d+)([+-]\d+)?$/);
  if (!match) return null;

  const count = match[1] ? parseInt(match[1], 10) : 1;
  const sides = parseInt(match[2], 10);
  const modifier = match[3] ? parseInt(match[3], 10) : 0;

  if (count < 1 || count > 100 || sides < 1 || sides > 100) return null;

  return { count, sides, modifier, raw: expression.trim() };
}

export function rollDice(
  expression: string,
  options?: { advantage?: boolean; disadvantage?: boolean },
): RollResult {
  const parsed = parseDice(expression);
  if (!parsed) {
    return {
      rolls: [],
      modifier: 0,
      total: 0,
      expression,
      error: `Couldn't parse: ${expression}`,
    };
  }

  const { count, sides, modifier } = parsed;

  // Advantage/disadvantage: roll the die twice, pick higher/lower
  if (
    (options?.advantage || options?.disadvantage) &&
    count === 1 &&
    sides === 20
  ) {
    const roll1 = Math.floor(Math.random() * sides) + 1;
    const roll2 = Math.floor(Math.random() * sides) + 1;
    const chosen = options.advantage
      ? Math.max(roll1, roll2)
      : Math.min(roll1, roll2);
    return {
      rolls: [roll1, roll2],
      modifier,
      total: chosen + modifier,
      expression,
    };
  }

  // Normal roll
  const rolls: number[] = [];
  for (let i = 0; i < count; i++) {
    rolls.push(Math.floor(Math.random() * sides) + 1);
  }
  const sum = rolls.reduce((a, b) => a + b, 0);

  return {
    rolls,
    modifier,
    total: sum + modifier,
    expression,
  };
}
  • Step 2: Verify dice module loads
cd /Users/aaron.wood/workspace/shadowdark/server && npx tsx -e "
import { parseDice, rollDice } from './src/dice.js';
console.log('parse 2d6+3:', parseDice('2d6+3'));
console.log('parse d20:', parseDice('d20'));
console.log('parse invalid:', parseDice('abc'));
console.log('roll 1d20:', rollDice('1d20'));
console.log('roll 2d6+1:', rollDice('2d6+1'));
console.log('roll adv:', rollDice('1d20', { advantage: true }));
console.log('roll disadv:', rollDice('1d20', { disadvantage: true }));
"

Expected: Parsed objects for valid expressions, null for invalid, roll results with arrays.


Task 2: Roll Log Table + Rolls API Endpoint

Files:

  • Modify: server/src/db.ts

  • Create: server/src/routes/rolls.ts

  • Modify: server/src/index.ts

  • Step 1: Add roll_log table to server/src/db.ts

Read the file first. Add inside the db.exec() block, after the game_items table:

    CREATE TABLE IF NOT EXISTS roll_log (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        campaign_id INTEGER NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
        character_id INTEGER,
        character_name TEXT NOT NULL DEFAULT 'Roll',
        type TEXT NOT NULL DEFAULT 'custom',
        label TEXT NOT NULL,
        dice_expression TEXT NOT NULL,
        rolls TEXT NOT NULL DEFAULT '[]',
        modifier INTEGER NOT NULL DEFAULT 0,
        total INTEGER NOT NULL DEFAULT 0,
        advantage INTEGER NOT NULL DEFAULT 0,
        disadvantage INTEGER NOT NULL DEFAULT 0,
        created_at TEXT DEFAULT (datetime('now'))
    );
  • Step 2: Create server/src/routes/rolls.ts
import { Router } from "express";
import db from "../db.js";

const router = Router({ mergeParams: true });

// GET /api/campaigns/:campaignId/rolls — last 50 rolls
router.get("/", (req, res) => {
  const { campaignId } = req.params;
  const rolls = db
    .prepare(
      "SELECT * FROM roll_log WHERE campaign_id = ? ORDER BY created_at DESC LIMIT 50",
    )
    .all(campaignId) as Array<Record<string, unknown>>;

  const parsed = rolls.map((r) => ({
    ...r,
    rolls: JSON.parse(r.rolls as string),
    advantage: r.advantage === 1,
    disadvantage: r.disadvantage === 1,
  }));

  res.json(parsed);
});

export default router;
  • Step 3: Register rolls route in server/src/index.ts

Read the file first. Add after existing route registrations:

import rollRoutes from "./routes/rolls.js";

app.use("/api/campaigns/:campaignId/rolls", rollRoutes);
  • Step 4: Verify
rm -f /Users/aaron.wood/workspace/shadowdark/server/data/shadowdark.db
cd /Users/aaron.wood/workspace/shadowdark/server && npx tsx -e "
import db from './src/db.js';
const cols = db.prepare('PRAGMA table_info(roll_log)').all();
console.log('roll_log cols:', cols.map((c: any) => c.name).join(', '));
"

Expected: All roll_log columns listed.


Task 3: Socket.IO Roll Handler

Files:

  • Modify: server/src/socket.ts

  • Step 1: Update server/src/socket.ts to handle roll requests

Read the file first. Replace the entire file:

import { Server } from "socket.io";
import db from "./db.js";
import { rollDice } from "./dice.js";

export function setupSocket(io: Server) {
  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",
      (data: {
        campaignId: number;
        characterId?: number;
        characterName?: string;
        type: string;
        dice: string;
        label: string;
        modifier?: number;
        advantage?: boolean;
        disadvantage?: boolean;
      }) => {
        const result = rollDice(data.dice, {
          advantage: data.advantage,
          disadvantage: data.disadvantage,
        });

        if (result.error) {
          socket.emit("roll:error", { error: result.error });
          return;
        }

        const row = db
          .prepare(
            `
                INSERT INTO roll_log (campaign_id, character_id, character_name, type, label, dice_expression, rolls, modifier, total, advantage, disadvantage)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            `,
          )
          .run(
            data.campaignId,
            data.characterId ?? null,
            data.characterName || "Roll",
            data.type || "custom",
            data.label,
            data.dice,
            JSON.stringify(result.rolls),
            result.modifier,
            result.total,
            data.advantage ? 1 : 0,
            data.disadvantage ? 1 : 0,
          );

        const saved = db
          .prepare("SELECT * FROM roll_log WHERE id = ?")
          .get(row.lastInsertRowid) as Record<string, unknown>;

        const broadcast = {
          ...saved,
          rolls: result.rolls,
          advantage: data.advantage || false,
          disadvantage: data.disadvantage || false,
        };

        io.to(`campaign:${data.campaignId}`).emit("roll:result", broadcast);
      },
    );

    socket.on("disconnect", () => {
      // Rooms are cleaned up automatically by Socket.IO
    });
  });
}

export function broadcastToCampaign(
  io: Server,
  campaignId: number,
  event: string,
  data: unknown,
) {
  io.to(`campaign:${campaignId}`).emit(event, data);
}
  • Step 2: Verify server starts and roll endpoint works
cd /Users/aaron.wood/workspace/shadowdark/server && npx tsx src/index.ts &
sleep 2
curl -s http://localhost:3000/api/campaigns/1/rolls | python3 -m json.tool

Expected: Empty array [] (no rolls yet). Kill server after.


Task 4: Client Types + API

Files:

  • Modify: client/src/types.ts

  • Modify: client/src/api.ts

  • Step 1: Add RollResult type to client/src/types.ts

Read the file first. Add after AttackLine:

export interface RollResult {
  id: number;
  campaign_id: number;
  character_id: number | null;
  character_name: string;
  type: "attack" | "ability-check" | "custom";
  label: string;
  dice_expression: string;
  rolls: number[];
  modifier: number;
  total: number;
  advantage: boolean;
  disadvantage: boolean;
  created_at: string;
}
  • Step 2: Add getRolls to client/src/api.ts

Read the file first. Add the import and function:

import type {
  Campaign,
  Character,
  Gear,
  Talent,
  GameItem,
  GameTalent,
  RollResult,
} from "./types";
// Rolls
export const getRolls = (campaignId: number) =>
  request<RollResult[]>(`/campaigns/${campaignId}/rolls`);

Task 5: DiceButton + RollEntry Components

Files:

  • Create: client/src/components/DiceButton.tsx

  • Create: client/src/components/DiceButton.module.css

  • Create: client/src/components/RollEntry.tsx

  • Create: client/src/components/RollEntry.module.css

  • Step 1: Create DiceButton.module.css

.btn {
  width: 24px;
  height: 24px;
  border-radius: 4px;
  border: 1px solid #444;
  background: #16213e;
  color: #888;
  cursor: pointer;
  font-size: 0.75rem;
  display: flex;
  align-items: center;
  justify-content: center;
  transition:
    border-color 0.15s,
    color 0.15s,
    background 0.15s;
}

.btn:hover {
  border-color: #c9a84c;
  color: #c9a84c;
  background: rgba(201, 168, 76, 0.1);
}

.btn:active {
  background: rgba(201, 168, 76, 0.25);
}
  • Step 2: Create DiceButton.tsx
import socket from "../socket";
import styles from "./DiceButton.module.css";

interface DiceButtonProps {
  campaignId: number;
  characterId?: number;
  characterName?: string;
  type: "attack" | "ability-check" | "custom";
  dice: string;
  label: string;
  damageDice?: string;
  damageLabel?: string;
}

export default function DiceButton({
  campaignId,
  characterId,
  characterName,
  type,
  dice,
  label,
  damageDice,
  damageLabel,
}: DiceButtonProps) {
  function handleClick(e: React.MouseEvent) {
    const advantage = e.shiftKey;
    const disadvantage = e.ctrlKey || e.metaKey;

    socket.emit("roll:request", {
      campaignId,
      characterId,
      characterName,
      type,
      dice,
      label,
      advantage: advantage && !disadvantage,
      disadvantage: disadvantage && !advantage,
    });

    // If this is an attack with a damage die, roll damage too
    if (damageDice && damageLabel) {
      setTimeout(() => {
        socket.emit("roll:request", {
          campaignId,
          characterId,
          characterName,
          type: "attack",
          dice: damageDice,
          label: damageLabel,
        });
      }, 100);
    }
  }

  return (
    <button
      className={styles.btn}
      onClick={handleClick}
      title={`Roll ${dice} (Shift: advantage, Ctrl: disadvantage)`}
    >
      🎲
    </button>
  );
}
  • Step 3: Create RollEntry.module.css
.card {
  background: #0f1a30;
  border: 1px solid #2a2a4a;
  border-radius: 6px;
  padding: 0.5rem 0.6rem;
  animation: slideIn 0.3s ease-out;
}

@keyframes slideIn {
  from {
    opacity: 0;
    transform: translateY(-10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.card.fresh {
  border-color: #c9a84c;
  animation:
    slideIn 0.3s ease-out,
    glow 1s ease-out;
}

@keyframes glow {
  0% {
    box-shadow: 0 0 8px rgba(201, 168, 76, 0.4);
  }
  100% {
    box-shadow: none;
  }
}

.topLine {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 0.2rem;
}

.charName {
  font-weight: 700;
  font-size: 0.8rem;
  color: #c9a84c;
}

.timestamp {
  font-size: 0.65rem;
  color: #555;
}

.label {
  font-size: 0.75rem;
  color: #888;
  margin-bottom: 0.2rem;
}

.breakdown {
  font-size: 0.75rem;
  color: #666;
  margin-bottom: 0.15rem;
}

.dieResult {
  color: #e0e0e0;
  font-weight: 600;
}

.dieChosen {
  color: #c9a84c;
  font-weight: 700;
}

.dieDiscarded {
  color: #555;
  text-decoration: line-through;
}

.modLine {
  font-size: 0.7rem;
  color: #666;
}

.total {
  font-size: 1.3rem;
  font-weight: 700;
  color: #e0e0e0;
  text-align: center;
  margin-top: 0.2rem;
}

.advantage {
  color: #4caf50;
  font-size: 0.65rem;
  font-weight: 600;
  text-transform: uppercase;
}

.disadvantage {
  color: #e74c3c;
  font-size: 0.65rem;
  font-weight: 600;
  text-transform: uppercase;
}

.error {
  color: #e74c3c;
  font-size: 0.8rem;
  font-style: italic;
}
  • Step 4: Create RollEntry.tsx
import type { RollResult } from "../types";
import styles from "./RollEntry.module.css";

interface RollEntryProps {
  roll: RollResult;
  fresh?: boolean;
}

function timeAgo(dateStr: string): string {
  const now = Date.now();
  const then = new Date(dateStr + "Z").getTime();
  const seconds = Math.floor((now - then) / 1000);
  if (seconds < 10) return "just now";
  if (seconds < 60) return `${seconds}s ago`;
  const minutes = Math.floor(seconds / 60);
  if (minutes < 60) return `${minutes}m ago`;
  const hours = Math.floor(minutes / 60);
  return `${hours}h ago`;
}

function formatBreakdown(roll: RollResult): string {
  const { rolls, advantage, disadvantage, dice_expression } = roll;

  if ((advantage || disadvantage) && rolls.length === 2) {
    const chosen = advantage ? Math.max(...rolls) : Math.min(...rolls);
    const parts = rolls.map((r) => (r === chosen ? `**${r}**` : `~~${r}~~`));
    return `d20: [${parts.join(", ")}] → ${chosen}`;
  }

  return `${dice_expression}: [${rolls.join(", ")}]`;
}

export default function RollEntry({ roll, fresh }: RollEntryProps) {
  const { rolls, advantage, disadvantage, dice_expression } = roll;

  const isAdvantage = advantage && rolls.length === 2;
  const isDisadvantage = disadvantage && rolls.length === 2;
  const chosen = isAdvantage
    ? Math.max(...rolls)
    : isDisadvantage
      ? Math.min(...rolls)
      : null;

  return (
    <div className={`${styles.card} ${fresh ? styles.fresh : ""}`}>
      <div className={styles.topLine}>
        <span className={styles.charName}>{roll.character_name}</span>
        <span className={styles.timestamp}>{timeAgo(roll.created_at)}</span>
      </div>
      <div className={styles.label}>
        {roll.label}
        {isAdvantage && <span className={styles.advantage}> ADV</span>}
        {isDisadvantage && <span className={styles.disadvantage}> DIS</span>}
      </div>
      <div className={styles.breakdown}>
        {dice_expression}: [
        {rolls.map((r, i) => (
          <span key={i}>
            {i > 0 && ", "}
            <span
              className={
                chosen === null
                  ? styles.dieResult
                  : r === chosen
                    ? styles.dieChosen
                    : styles.dieDiscarded
              }
            >
              {r}
            </span>
          </span>
        ))}
        ]{chosen !== null && ` → ${chosen}`}
      </div>
      {roll.modifier !== 0 && (
        <div className={styles.modLine}>
          {roll.modifier > 0 ? "+" : ""}
          {roll.modifier}
        </div>
      )}
      <div className={styles.total}>{roll.total}</div>
    </div>
  );
}

Task 6: RollLog Side Panel

Files:

  • Create: client/src/components/RollLog.tsx

  • Create: client/src/components/RollLog.module.css

  • Step 1: Create RollLog.module.css

.panel {
  width: 300px;
  height: 100%;
  background: #1a1a2e;
  border-left: 1px solid #333;
  display: flex;
  flex-direction: column;
  transition: width 0.2s;
}

.panel.collapsed {
  width: 40px;
  cursor: pointer;
}

.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0.5rem 0.75rem;
  border-bottom: 1px solid #333;
}

.title {
  font-size: 0.85rem;
  font-weight: 700;
  color: #c9a84c;
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

.collapseBtn {
  background: none;
  border: none;
  color: #888;
  cursor: pointer;
  font-size: 1rem;
  padding: 0.2rem;
}

.collapseBtn:hover {
  color: #c9a84c;
}

.collapsedContent {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding-top: 0.5rem;
  gap: 0.5rem;
}

.collapsedIcon {
  font-size: 1.2rem;
  cursor: pointer;
}

.collapsedLast {
  writing-mode: vertical-rl;
  font-size: 0.7rem;
  color: #888;
  max-height: 100px;
  overflow: hidden;
}

.inputArea {
  padding: 0.5rem 0.75rem;
  border-bottom: 1px solid #333;
}

.input {
  width: 100%;
  padding: 0.4rem 0.6rem;
  background: #0f1a30;
  border: 1px solid #333;
  border-radius: 6px;
  color: #e0e0e0;
  font-size: 0.85rem;
}

.input:focus {
  outline: none;
  border-color: #c9a84c;
}

.hint {
  font-size: 0.6rem;
  color: #555;
  margin-top: 0.25rem;
  text-align: center;
}

.entries {
  flex: 1;
  overflow-y: auto;
  padding: 0.5rem;
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  scrollbar-width: thin;
  scrollbar-color: #333 transparent;
}

.entries::-webkit-scrollbar {
  width: 4px;
}

.entries::-webkit-scrollbar-thumb {
  background: #333;
  border-radius: 2px;
}

.empty {
  text-align: center;
  color: #555;
  font-size: 0.8rem;
  font-style: italic;
  padding: 2rem 0;
}
  • Step 2: Create RollLog.tsx
import { useState } from "react";
import type { RollResult } from "../types";
import socket from "../socket";
import RollEntry from "./RollEntry";
import styles from "./RollLog.module.css";

interface RollLogProps {
  campaignId: number;
  rolls: RollResult[];
  freshIds: Set<number>;
}

export default function RollLog({ campaignId, rolls, freshIds }: RollLogProps) {
  const [collapsed, setCollapsed] = useState(false);
  const [input, setInput] = useState("");

  function handleRoll(e: React.FormEvent) {
    e.preventDefault();
    if (!input.trim()) return;
    socket.emit("roll:request", {
      campaignId,
      type: "custom",
      dice: input.trim(),
      label: input.trim(),
    });
    setInput("");
  }

  if (collapsed) {
    return (
      <div
        className={`${styles.panel} ${styles.collapsed}`}
        onClick={() => setCollapsed(false)}
      >
        <div className={styles.collapsedContent}>
          <span className={styles.collapsedIcon}>🎲</span>
          {rolls.length > 0 && (
            <span className={styles.collapsedLast}>{rolls[0].total}</span>
          )}
        </div>
      </div>
    );
  }

  return (
    <div className={styles.panel}>
      <div className={styles.header}>
        <span className={styles.title}>Roll Log</span>
        <button
          className={styles.collapseBtn}
          onClick={() => setCollapsed(true)}
        >
          
        </button>
      </div>
      <div className={styles.inputArea}>
        <form onSubmit={handleRoll}>
          <input
            className={styles.input}
            value={input}
            onChange={(e) => setInput(e.target.value)}
            placeholder="Roll dice... (e.g. 2d6+1)"
          />
        </form>
        <div className={styles.hint}>Shift: advantage · Ctrl: disadvantage</div>
      </div>
      <div className={styles.entries}>
        {rolls.length === 0 && <p className={styles.empty}>No rolls yet</p>}
        {rolls.map((roll) => (
          <RollEntry key={roll.id} roll={roll} fresh={freshIds.has(roll.id)} />
        ))}
      </div>
    </div>
  );
}

Task 7: Add DiceButton to StatsPanel and AttackBlock

Files:

  • Modify: client/src/components/StatsPanel.tsx

  • Modify: client/src/components/StatBlock.tsx

  • Modify: client/src/components/StatBlock.module.css

  • Modify: client/src/components/AttackBlock.tsx

  • Step 1: Add campaignId prop to StatsPanel and pass to StatBlock

Read StatsPanel.tsx. Add campaignId to the props interface:

interface StatsPanelProps {
  character: Character;
  mode: "view" | "edit";
  campaignId: number;
  onStatChange: (characterId: number, statName: string, value: number) => void;
}

Pass campaignId and character info to StatBlock:

<StatBlock
  stats={character.stats}
  onStatChange={(statName, value) =>
    onStatChange(character.id, statName, value)
  }
  mode={mode}
  campaignId={campaignId}
  characterId={character.id}
  characterName={character.name}
/>

Also pass campaignId and character to AttackBlock:

<AttackBlock
  attacks={attacks}
  campaignId={campaignId}
  characterId={character.id}
  characterName={character.name}
  mode={mode}
/>
  • Step 2: Update StatBlock to show DiceButton in view mode

Read StatBlock.tsx. Add campaignId, characterId, characterName to props:

interface StatBlockProps {
  stats: Stat[];
  onStatChange: (statName: string, newValue: number) => void;
  mode?: "view" | "edit";
  campaignId?: number;
  characterId?: number;
  characterName?: string;
}

Import DiceButton and getModifier/formatModifier. In view mode, after the modifier span, render a DiceButton:

{
  mode === "view" && campaignId && (
    <DiceButton
      campaignId={campaignId}
      characterId={characterId}
      characterName={characterName}
      type="ability-check"
      dice={`1d20${mod >= 0 ? "+" + mod : String(mod)}`}
      label={`${name} check`}
    />
  );
}

Import at top:

import DiceButton from "./DiceButton";

Remove the .rollSpace CSS class usage since DiceButton replaces it.

  • Step 3: Update AttackBlock to show DiceButton in view mode

Read AttackBlock.tsx. Add campaignId, characterId, characterName, mode to props:

interface AttackBlockProps {
  attacks: AttackLine[];
  campaignId?: number;
  characterId?: number;
  characterName?: string;
  mode?: "view" | "edit";
}

Import DiceButton. Replace the rollSpace span in each weapon line with a DiceButton:

{
  mode === "view" && campaignId ? (
    <DiceButton
      campaignId={campaignId}
      characterId={characterId}
      characterName={characterName}
      type="attack"
      dice={`1d20${atk.modifier >= 0 ? "+" + atk.modifier : String(atk.modifier)}`}
      label={`${atk.name} attack`}
      damageDice={atk.damage}
      damageLabel={`${atk.name} damage`}
    />
  ) : (
    <span className={styles.rollSpace}></span>
  );
}

Task 8: Wire RollLog into CampaignView

Files:

  • Modify: client/src/pages/CampaignView.tsx

  • Modify: client/src/pages/CampaignView.module.css

  • Modify: client/src/components/CharacterSheet.tsx

  • Step 1: Update CampaignView layout for side panel

Read CampaignView.module.css. Wrap the existing content in a flex container with the roll log on the right. Add:

.layout {
  display: flex;
  height: calc(100vh - 100px);
}

.main {
  flex: 1;
  overflow-y: auto;
  padding-right: 0.5rem;
}
  • Step 2: Update CampaignView.tsx

Read the file. Add these changes:

a) Import RollLog and getRolls:

import RollLog from "../components/RollLog";
import { getRolls } from "../api";
import type { Character, Gear, Talent, GameItem, RollResult } from "../types";

b) Add state for rolls and fresh IDs:

const [rolls, setRolls] = useState<RollResult[]>([]);
const [freshIds, setFreshIds] = useState<Set<number>>(new Set());

c) In the useEffect that fetches characters and joins the socket room, also fetch rolls:

getRolls(campaignId).then(setRolls);

d) Add socket listener for roll:result in the socket useEffect:

function onRollResult(roll: RollResult) {
  setRolls((prev) => [roll, ...prev].slice(0, 50));
  setFreshIds((prev) => new Set(prev).add(roll.id));
  setTimeout(() => {
    setFreshIds((prev) => {
      const next = new Set(prev);
      next.delete(roll.id);
      return next;
    });
  }, 2000);
}

socket.on("roll:result", onRollResult);

And in the cleanup:

socket.off("roll:result", onRollResult);

e) Wrap the JSX in the layout flex container and add RollLog:

return (
  <div className={styles.layout}>
    <div className={styles.main}>{/* existing header, grid, modals */}</div>
    <RollLog campaignId={campaignId} rolls={rolls} freshIds={freshIds} />
  </div>
);

f) Pass campaignId to CharacterDetail so it can reach StatsPanel/AttackBlock. Add campaignId prop to CharacterDetail, CharacterSheet:

In CharacterDetail.tsx, add campaignId: number to props and pass through to CharacterSheet. In CharacterSheet.tsx, add campaignId: number to props and pass to StatsPanel:

<StatsPanel
  character={character}
  mode={mode}
  campaignId={campaignId}
  onStatChange={onStatChange}
/>
  • Step 3: Verify TypeScript compiles
cd /Users/aaron.wood/workspace/shadowdark/client && npx tsc --noEmit

Expected: No errors.


Task 9: End-to-End Smoke Test

Files: None (testing only)

  • Step 1: Reset DB and start full stack
rm -f /Users/aaron.wood/workspace/shadowdark/server/data/shadowdark.db
lsof -ti:3000 | xargs kill 2>/dev/null; lsof -ti:5173 | xargs kill 2>/dev/null
cd /Users/aaron.wood/workspace/shadowdark && npm run dev
  • Step 2: Test in browser

Open http://localhost:5173, go into a campaign:

  1. Roll log panel visible on the right side
  2. Type "1d20" in the general roller input, press Enter — roll result appears in the log
  3. Type "2d6+3" — result shows two dice and modifier
  4. Collapse the panel — should show thin strip with last result
  5. Expand — full panel returns
  • Step 3: Test character sheet roll buttons
  1. Open a character sheet (view mode)
  2. Click 🎲 next to a stat — roll appears in log with character name and "STR check" etc.
  3. Shift+click — roll shows ADV with two d20s
  4. Ctrl+click — roll shows DIS with two d20s
  5. Click 🎲 on an attack line — two rolls appear: attack d20 then damage die
  • Step 4: Test real-time sync

Open second tab:

  1. Roll in tab 1 — appears in tab 2's roll log
  2. Roll in tab 2 — appears in tab 1's roll log
  3. Both see the pop-in animation
  • Step 5: Test persistence
  1. Make several rolls
  2. Refresh the page
  3. Navigate back to the campaign
  4. Roll log should show previous rolls (loaded from DB)