darkwatch/docs/plans/2026-04-11-spellcasting.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

51 KiB

Spellcasting System 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: Full Shadowdark spellcasting — spell database, per-character known spells, casting checks with success/failure/crit states, focus tracking, Wizard mishap auto-apply with undo, Priest penance tracking, rest to recover.

Architecture: New spells and character_spells tables. Casting is a server-side action that records the roll, applies effects, and broadcasts updates via socket. Mishap results are stored in roll_log metadata with an undo payload so any effect can be reversed. Focus state is tracked per-character-spell and shown on the DM card.

Tech Stack: React 18, TypeScript, Express, mysql2/promise, Socket.IO, CSS Modules


File Map

File Action
server/migrations/002_spells.sql Create — spells, character_spells, character_conditions tables; roll_log metadata column
server/src/seed-spells.ts Create — seeds all Tier 1-2 spells from Player Quickstart
server/src/seed-dev-data.ts Modify — import and call seedSpells()
server/src/routes/spells.ts Create — GET /api/spells, GET /api/spells/:class
server/src/routes/characters.ts Modify — add character spell endpoints and cast endpoint
server/src/routes/rolls.ts Modify — add POST /:rollId/undo endpoint
server/src/socket.ts Modify — emit spell/condition change events
client/src/types.ts Modify — add Spell, CharacterSpell, Condition types
client/src/api.ts Modify — add spell API functions
client/src/components/SpellList.tsx Create — known spells with cast button, exhausted state
client/src/components/SpellList.module.css Create
client/src/components/SpellCastResult.tsx Create — result modal (success/fail/crit/mishap)
client/src/components/SpellCastResult.module.css Create
client/src/components/CharacterCard.tsx Modify — focus spell indicator
client/src/components/CharacterDetail.tsx Modify — add Spells tab for caster classes
client/src/components/RollEntry.tsx Modify — undo button on mishap entries
client/src/pages/CampaignView.tsx Modify — handle spell socket events

Task 1: DB migration — spells, character_spells, conditions, roll_log metadata

Files:

  • Create: server/migrations/002_spells.sql

  • Step 1: Write migration SQL

CREATE TABLE IF NOT EXISTS spells (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  class ENUM('wizard', 'priest', 'both') NOT NULL,
  tier TINYINT NOT NULL,
  casting_stat ENUM('INT', 'WIS') NOT NULL,
  duration VARCHAR(100) NOT NULL DEFAULT 'Instant',
  range VARCHAR(100) NOT NULL DEFAULT 'Near',
  is_focus TINYINT NOT NULL DEFAULT 0,
  description TEXT NOT NULL DEFAULT '',
  UNIQUE KEY uq_spells_name_class (name, class)
);

CREATE TABLE IF NOT EXISTS character_spells (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  character_id INT UNSIGNED NOT NULL,
  spell_id INT UNSIGNED NOT NULL,
  exhausted TINYINT NOT NULL DEFAULT 0,
  locked_until DATETIME DEFAULT NULL,
  focus_active TINYINT NOT NULL DEFAULT 0,
  focus_started_at DATETIME DEFAULT NULL,
  UNIQUE KEY uq_char_spell (character_id, spell_id),
  FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE,
  FOREIGN KEY (spell_id) REFERENCES spells(id) ON DELETE CASCADE
);

CREATE TABLE IF NOT EXISTS character_conditions (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  character_id INT UNSIGNED NOT NULL,
  name VARCHAR(255) NOT NULL,
  description TEXT DEFAULT '',
  rounds_remaining INT DEFAULT NULL,
  expires_at DATETIME DEFAULT NULL,
  created_at DATETIME DEFAULT NOW(),
  FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE
);

ALTER TABLE roll_log
  ADD COLUMN subtype VARCHAR(50) DEFAULT NULL,
  ADD COLUMN metadata TEXT DEFAULT NULL,
  ADD COLUMN undone TINYINT NOT NULL DEFAULT 0;
  • Step 2: Verify migration runs

Start the server — it should apply migration 002 and log Migration applied: 002_spells.sql. No errors.

  • Step 3: Commit
git add server/migrations/002_spells.sql
git commit -m "feat: add spells, character_spells, conditions tables and roll_log metadata"

Task 2: Seed spell data (Tier 1-2 from Player Quickstart)

Files:

  • Create: server/src/seed-spells.ts

  • Modify: server/src/seed-dev-data.ts

  • Step 1: Write seed-spells.ts

import type { ResultSetHeader, RowDataPacket } from "mysql2";
import db from "./db.js";

const SPELLS = [
  // PRIEST TIER 1
  { name: "Cure Wounds", class: "priest", tier: 1, casting_stat: "WIS", duration: "Instant", range: "Close", is_focus: 0, description: "Touch restores HP. Roll 1d6 + half your level (round down); target regains that many HP." },
  { name: "Holy Weapon", class: "priest", tier: 1, casting_stat: "WIS", duration: "5 rounds", range: "Close", is_focus: 0, description: "One weapon touched becomes magical, gaining +1 to attack and damage." },
  { name: "Light", class: "both", tier: 1, casting_stat: "WIS", duration: "1 hour", range: "Close", is_focus: 0, description: "One object touched glows, illuminating to near distance." },
  { name: "Protection From Evil", class: "both", tier: 1, casting_stat: "WIS", duration: "5 rounds", range: "Self", is_focus: 0, description: "Chaotic beings have disadvantage on attacks and spellcasting against you. They cannot possess, compel, or beguile you." },
  { name: "Shield of Faith", class: "priest", tier: 1, casting_stat: "WIS", duration: "Focus", range: "Close", is_focus: 1, description: "Gain +2 bonus to AC for as long as you focus." },
  { name: "Turn Undead", class: "priest", tier: 1, casting_stat: "WIS", duration: "Instant", range: "Near", is_focus: 0, description: "Rebuke undead within near. They make a CHA check vs your spellcasting check. Fail by 10+ and level ≤ yours: destroyed. Fail: flees for 5 rounds." },
  // PRIEST TIER 2
  { name: "Augury", class: "priest", tier: 2, casting_stat: "WIS", duration: "Instant", range: "Self", is_focus: 0, description: "Ask the GM one question about a specific course of action. The GM says 'weal' (good outcome) or 'woe' (bad outcome)." },
  { name: "Bless", class: "priest", tier: 2, casting_stat: "WIS", duration: "Instant", range: "Close", is_focus: 0, description: "One creature touched gains a luck token." },
  { name: "Blind/Deafen", class: "priest", tier: 2, casting_stat: "WIS", duration: "Focus", range: "Near", is_focus: 1, description: "One creature loses one sense. It has disadvantage on tasks requiring that sense." },
  { name: "Cleansing Weapon", class: "priest", tier: 2, casting_stat: "WIS", duration: "5 rounds", range: "Close", is_focus: 0, description: "One weapon deals +1d4 damage (1d6 vs undead), wreathed in purifying flames." },
  { name: "Smite", class: "priest", tier: 2, casting_stat: "WIS", duration: "Instant", range: "Near", is_focus: 0, description: "Call down punishing flames on one creature, dealing 1d6 damage." },
  { name: "Zone of Truth", class: "priest", tier: 2, casting_stat: "WIS", duration: "Focus", range: "Near", is_focus: 1, description: "Compel one creature to speak only truth. It cannot utter deliberate lies while in range." },
  // WIZARD TIER 1
  { name: "Alarm", class: "wizard", tier: 1, casting_stat: "INT", duration: "1 day", range: "Close", is_focus: 0, description: "Touch one object (door, threshold). A bell sounds in your head if an undesignated creature touches or crosses the object." },
  { name: "Burning Hands", class: "wizard", tier: 1, casting_stat: "INT", duration: "Instant", range: "Close", is_focus: 0, description: "Spread fingers, unleash a circle of flame filling the close area. Creatures take 1d6 damage; unattended flammable objects ignite." },
  { name: "Charm Person", class: "wizard", tier: 1, casting_stat: "INT", duration: "1d8 days", range: "Near", is_focus: 0, description: "Beguile one humanoid of level 2 or less; it regards you as a friend. Ends if you or your allies hurt it. Target knows it was enchanted after." },
  { name: "Detect Magic", class: "wizard", tier: 1, casting_stat: "INT", duration: "Focus", range: "Near", is_focus: 1, description: "Sense presence of magic within near range. Focus 2 rounds: discern general properties. Full barriers block this." },
  { name: "Feather Fall", class: "wizard", tier: 1, casting_stat: "INT", duration: "Instant", range: "Self", is_focus: 0, description: "Cast when you fall. Slow your descent; land safely on your feet." },
  { name: "Floating Disk", class: "wizard", tier: 1, casting_stat: "INT", duration: "10 rounds", range: "Near", is_focus: 0, description: "Create a floating disk carrying up to 20 gear slots at waist height within near. Can't cross drops taller than a human." },
  { name: "Hold Portal", class: "wizard", tier: 1, casting_stat: "INT", duration: "10 rounds", range: "Near", is_focus: 0, description: "Magically hold a portal closed. A creature must pass a STR check vs your spellcasting check to open it. Knock ends this." },
  { name: "Mage Armor", class: "wizard", tier: 1, casting_stat: "INT", duration: "10 rounds", range: "Self", is_focus: 0, description: "Invisible layer of magical force. Your AC becomes 14 (18 on a critical spellcasting check)." },
  { name: "Magic Missile", class: "wizard", tier: 1, casting_stat: "INT", duration: "Instant", range: "Far", is_focus: 0, description: "You have advantage on your check to cast. A glowing bolt of force deals 1d4 damage to one target." },
  { name: "Sleep", class: "wizard", tier: 1, casting_stat: "INT", duration: "Instant", range: "Near", is_focus: 0, description: "Weave a lulling spell filling a near-sized cube. Living creatures level 2 or less fall into deep sleep. Vigorous shaking or injury wakes them." },
  // WIZARD TIER 2
  { name: "Acid Arrow", class: "wizard", tier: 2, casting_stat: "INT", duration: "Focus", range: "Far", is_focus: 1, description: "Conjure a corrosive bolt. One foe takes 1d6 damage per round while you focus." },
  { name: "Alter Self", class: "wizard", tier: 2, casting_stat: "INT", duration: "5 rounds", range: "Self", is_focus: 0, description: "Change your physical form, gaining one feature modifying existing anatomy (gills, bear claws, etc.). Cannot grow wings or new limbs." },
  { name: "Detect Thoughts", class: "wizard", tier: 2, casting_stat: "INT", duration: "Focus", range: "Near", is_focus: 1, description: "Learn one creature's immediate thoughts each round. Target makes WIS check vs your spellcasting; success means target notices you and spell ends." },
  { name: "Fixed Object", class: "wizard", tier: 2, casting_stat: "INT", duration: "5 rounds", range: "Close", is_focus: 0, description: "Object you touch (max 5 lbs) becomes immovable in location. Can support up to 5,000 lbs." },
  { name: "Hold Person", class: "wizard", tier: 2, casting_stat: "INT", duration: "Focus", range: "Near", is_focus: 1, description: "Magically paralyze one humanoid creature of level 4 or less." },
  { name: "Invisibility", class: "wizard", tier: 2, casting_stat: "INT", duration: "10 rounds", range: "Close", is_focus: 0, description: "Creature touched becomes invisible. Ends if the target attacks or casts a spell." },
  { name: "Knock", class: "wizard", tier: 2, casting_stat: "INT", duration: "Instant", range: "Near", is_focus: 0, description: "A door, window, gate, chest, or portal instantly opens. Defeats mundane locks. Creates a loud knock audible to all." },
  { name: "Levitate", class: "wizard", tier: 2, casting_stat: "INT", duration: "Focus", range: "Self", is_focus: 1, description: "Float near distance vertically per round. Push against solid objects to move horizontally." },
  { name: "Mirror Image", class: "wizard", tier: 2, casting_stat: "INT", duration: "5 rounds", range: "Self", is_focus: 0, description: "Create illusory duplicates equal to half your level (min 1). Each hit destroys one duplicate. Spell ends when all are gone." },
  { name: "Misty Step", class: "wizard", tier: 2, casting_stat: "INT", duration: "Instant", range: "Self", is_focus: 0, description: "In a puff of smoke, teleport near distance to an area you can see." },
  { name: "Silence", class: "wizard", tier: 2, casting_stat: "INT", duration: "Focus", range: "Far", is_focus: 1, description: "Mute sound in a near-sized cube within range. Creatures inside are deafened and no sounds can be heard from inside." },
  { name: "Web", class: "wizard", tier: 2, casting_stat: "INT", duration: "5 rounds", range: "Far", is_focus: 0, description: "Create a near-sized cube of sticky spider web. Creatures are stuck and can't move; must pass STR check vs your spellcasting to free themselves." },
] as const;

export async function seedSpells(): Promise<void> {
  const [existing] = await db.execute<RowDataPacket[]>("SELECT COUNT(*) as c FROM spells");
  if ((existing[0] as { c: number }).c > 0) return;

  for (const spell of SPELLS) {
    await db.execute<ResultSetHeader>(
      "INSERT INTO spells (name, class, tier, casting_stat, duration, range, is_focus, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
      [spell.name, spell.class, spell.tier, spell.casting_stat, spell.duration, spell.range, spell.is_focus, spell.description]
    );
  }
  console.log(`Spells seeded: ${SPELLS.length} spells`);
}
  • Step 2: Import and call in seed-dev-data.ts

Add to top of seed-dev-data.ts:

import { seedSpells } from "./seed-spells.js";

Add at the start of seedDevData() before the users check:

await seedSpells();
  • Step 3: Restart server, verify spells seeded

Server log should show: Spells seeded: 34 spells

  • Step 4: Commit
git add server/src/seed-spells.ts server/src/seed-dev-data.ts
git commit -m "feat: seed 34 Tier 1-2 spells from Shadowdark Player Quickstart"

Task 3: Server routes — spell catalog and character spell management

Files:

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

  • Modify: server/src/routes/characters.ts

  • Modify: server/src/index.ts

  • Step 1: Create server/src/routes/spells.ts

import { Router } from "express";
import type { RowDataPacket } from "mysql2";
import db from "../db.js";
import { requireAuth } from "../auth/middleware.js";

const router = Router();

// GET /api/spells — all spells, optionally filtered by class
router.get("/", requireAuth, async (req, res, next) => {
  try {
    const { class: spellClass } = req.query;
    let sql = "SELECT * FROM spells ORDER BY class, tier, name";
    const params: string[] = [];
    if (spellClass && typeof spellClass === "string") {
      sql = "SELECT * FROM spells WHERE class = ? OR class = 'both' ORDER BY tier, name";
      params.push(spellClass);
    }
    const [rows] = await db.execute<RowDataPacket[]>(sql, params);
    res.json(rows);
  } catch (err) {
    next(err);
  }
});

export default router;
  • Step 2: Add character spell endpoints to characters.ts

Add these routes to the existing characters router (after the existing PATCH /:id route):

// GET /api/characters/:id/spells — known spells with exhausted/focus state
router.get("/:id/spells", requireAuth, async (req, res, next) => {
  try {
    const [rows] = await db.execute<RowDataPacket[]>(
      `SELECT cs.id, cs.spell_id, cs.exhausted, cs.locked_until, cs.focus_active, cs.focus_started_at,
              s.name, s.class, s.tier, s.casting_stat, s.duration, s.range, s.is_focus, s.description
       FROM character_spells cs
       JOIN spells s ON s.id = cs.spell_id
       WHERE cs.character_id = ?
       ORDER BY s.tier, s.name`,
      [req.params.id]
    );
    res.json(rows);
  } catch (err) { next(err); }
});

// POST /api/characters/:id/spells — add a spell to known spells
router.post("/:id/spells", requireAuth, async (req, res, next) => {
  try {
    const { spell_id } = req.body;
    await db.execute(
      "INSERT IGNORE INTO character_spells (character_id, spell_id) VALUES (?, ?)",
      [req.params.id, spell_id]
    );
    const [rows] = await db.execute<RowDataPacket[]>(
      `SELECT cs.*, s.name, s.class, s.tier, s.casting_stat, s.duration, s.range, s.is_focus, s.description
       FROM character_spells cs JOIN spells s ON s.id = cs.spell_id
       WHERE cs.character_id = ? AND cs.spell_id = ?`,
      [req.params.id, spell_id]
    );
    const io = req.app.get("io");
    const char = await getCharacterCampaignId(Number(req.params.id));
    io.to(String(char.campaign_id)).emit("spell:added", { characterId: Number(req.params.id), spell: rows[0] });
    res.status(201).json(rows[0]);
  } catch (err) { next(err); }
});

// DELETE /api/characters/:id/spells/:spellId — remove from known spells
router.delete("/:id/spells/:spellId", requireAuth, async (req, res, next) => {
  try {
    await db.execute(
      "DELETE FROM character_spells WHERE character_id = ? AND spell_id = ?",
      [req.params.id, req.params.spellId]
    );
    const io = req.app.get("io");
    const char = await getCharacterCampaignId(Number(req.params.id));
    io.to(String(char.campaign_id)).emit("spell:removed", { characterId: Number(req.params.id), spellId: Number(req.params.spellId) });
    res.json({ ok: true });
  } catch (err) { next(err); }
});

// POST /api/characters/:id/rest — recover all spells (clear exhausted, focus)
router.post("/:id/rest", requireAuth, async (req, res, next) => {
  try {
    await db.execute(
      "UPDATE character_spells SET exhausted = 0, focus_active = 0, focus_started_at = NULL WHERE character_id = ? AND locked_until IS NULL",
      [req.params.id]
    );
    await db.execute(
      "DELETE FROM character_conditions WHERE character_id = ? AND (expires_at IS NULL OR expires_at > NOW())",
      [req.params.id]
    );
    const io = req.app.get("io");
    const char = await getCharacterCampaignId(Number(req.params.id));
    io.to(String(char.campaign_id)).emit("character:rested", { characterId: Number(req.params.id) });
    res.json({ ok: true });
  } catch (err) { next(err); }
});

Add helper function (before the router export):

async function getCharacterCampaignId(id: number): Promise<{ campaign_id: number }> {
  const [rows] = await db.execute<RowDataPacket[]>("SELECT campaign_id FROM characters WHERE id = ?", [id]);
  return rows[0] as { campaign_id: number };
}
  • Step 3: Mount spell routes in index.ts
import spellRoutes from "./routes/spells.js";
// after existing routes:
app.use("/api/spells", spellRoutes);
  • Step 4: Commit
git add server/src/routes/spells.ts server/src/routes/characters.ts server/src/index.ts
git commit -m "feat: spell catalog and character spell management routes"

Task 4: Spell cast endpoint with mishap and undo

Files:

  • Modify: server/src/routes/characters.ts (add cast endpoint)

  • Modify: server/src/routes/rolls.ts (add undo endpoint)

  • Step 1: Add cast endpoint to characters.ts

// POST /api/characters/:id/spells/:spellId/cast
router.post("/:id/spells/:spellId/cast", requireAuth, async (req, res, next) => {
  try {
    const characterId = Number(req.params.id);
    const spellId = Number(req.params.spellId);
    const { advantage = false, disadvantage = false } = req.body;

    // Get spell and character spell record
    const [spellRows] = await db.execute<RowDataPacket[]>(
      "SELECT s.*, cs.id as cs_id, cs.exhausted FROM spells s JOIN character_spells cs ON cs.spell_id = s.id WHERE s.id = ? AND cs.character_id = ?",
      [spellId, characterId]
    );
    if (spellRows.length === 0) return void res.status(404).json({ error: "Spell not known" });
    const spell = spellRows[0];
    if (spell.exhausted) return void res.status(400).json({ error: "Spell is exhausted" });

    // Get character stat for casting
    const [charRows] = await db.execute<RowDataPacket[]>(
      "SELECT c.id, c.campaign_id, c.name, c.class, c.color, cs2.value as stat_value FROM characters c JOIN character_stats cs2 ON cs2.character_id = c.id WHERE c.id = ? AND cs2.stat_name = ?",
      [characterId, spell.casting_stat]
    );
    if (charRows.length === 0) return void res.status(404).json({ error: "Character not found" });
    const character = charRows[0];
    const statMod = Math.floor((character.stat_value - 10) / 2);

    // Roll 1d20
    const roll = Math.floor(Math.random() * 20) + 1;
    const total = roll + statMod;
    const dc = 10 + spell.tier;
    const isCritSuccess = roll === 20;
    const isCritFail = roll === 1;
    const isSuccess = isCritSuccess || (!isCritFail && total >= dc);

    let result: "success" | "failure" | "crit_success" | "crit_fail" = "success";
    if (isCritSuccess) result = "crit_success";
    else if (isCritFail) result = "crit_fail";
    else if (!isSuccess) result = "failure";

    const changes: object[] = [];

    // On failure or crit_fail: exhaust the spell
    if (!isSuccess) {
      await db.execute("UPDATE character_spells SET exhausted = 1 WHERE id = ?", [spell.cs_id]);
      changes.push({ type: "spell_exhausted", characterSpellId: spell.cs_id });
    }

    // Wizard crit fail: mishap
    let mishapResult: object | null = null;
    if (isCritFail && character.class === "Wizard") {
      mishapResult = await applyWizardMishap(characterId, character.campaign_id, changes);
    }

    // Priest crit fail: lock spell for penance
    if (isCritFail && character.class === "Priest") {
      const penanceCost = [0, 5, 20, 40, 90, 150][spell.tier] ?? 5;
      await db.execute("UPDATE character_spells SET exhausted = 1 WHERE id = ?", [spell.cs_id]);
      changes.push({ type: "priest_penance", characterSpellId: spell.cs_id, penanceCost, tier: spell.tier });
    }

    // Record in roll log with undo payload
    const metadata = JSON.stringify({ spellId, spellName: spell.name, result, mishapResult, changes });
    const [logResult] = 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, nat20, metadata) VALUES (?, ?, ?, ?, 'custom', 'spell_cast', ?, '1d20', ?, ?, ?, ?, ?)",
      [character.campaign_id, characterId, character.name, character.color, `${spell.name} (Tier ${spell.tier})`, JSON.stringify([roll]), statMod, total, isCritSuccess ? 1 : 0, metadata]
    );

    const io = req.app.get("io");
    io.to(String(character.campaign_id)).emit("spell:cast", {
      rollId: logResult.insertId,
      characterId,
      spellId,
      spellName: spell.name,
      roll,
      modifier: statMod,
      total,
      dc,
      result,
      mishapResult,
      changes,
    });

    res.json({ rollId: logResult.insertId, roll, modifier: statMod, total, dc, result, mishapResult });
  } catch (err) { next(err); }
});

Add mishap helper function:

const MISHAP_TABLE = [
  null, // index 0 unused
  { id: 1, description: "Devastation! Roll twice, combine both effects." },
  { id: 2, description: "Explosion! Take 1d8 damage.", mechanic: "damage_1d8" },
  { id: 3, description: "Refraction! The spell targets you instead.", mechanic: "narrative" },
  { id: 4, description: "Your hand slipped! The spell hits a random ally.", mechanic: "narrative" },
  { id: 5, description: "Mind wound! You can't cast this spell again for a week.", mechanic: "lock_week" },
  { id: 6, description: "Discorporation! One random piece of gear disappears forever.", mechanic: "remove_gear" },
  { id: 7, description: "Spell worm! Lose a random spell each turn until you pass a DC 12 CON check.", mechanic: "condition", condition: "Spell Worm" },
  { id: 8, description: "Harmonic failure! Lose a random known spell until rest.", mechanic: "exhaust_random" },
  { id: 9, description: "Poof! All light suppressed within 30ft for 10 rounds.", mechanic: "condition", condition: "Light Suppressed" },
  { id: 10, description: "The horror! You scream uncontrollably for 3 rounds, drawing attention.", mechanic: "condition", condition: "Screaming" },
  { id: 11, description: "Energy surge! You glow bright purple for 10 rounds. Enemies have advantage on attacks.", mechanic: "condition", condition: "Glowing Purple" },
  { id: 12, description: "Unstable conduit! Disadvantage on casting same-tier spells for 10 rounds.", mechanic: "condition", condition: "Unstable Conduit" },
];

async function applyWizardMishap(characterId: number, campaignId: number, changes: object[]): Promise<object> {
  const roll = Math.floor(Math.random() * 12) + 1;
  const mishap = MISHAP_TABLE[roll];
  if (!mishap) return { roll, description: "Unknown mishap" };

  // Handle result 1 recursively (roll twice)
  if (roll === 1) {
    const r1 = await applyWizardMishap(characterId, campaignId, changes);
    const r2 = await applyWizardMishap(characterId, campaignId, changes);
    return { roll: 1, description: mishap.description, combined: [r1, r2] };
  }

  if (mishap.mechanic === "damage_1d8") {
    const dmg = Math.floor(Math.random() * 8) + 1;
    const [charRows] = await db.execute<RowDataPacket[]>("SELECT hp_current FROM characters WHERE id = ?", [characterId]);
    const prevHp = (charRows[0] as { hp_current: number }).hp_current;
    const newHp = Math.max(0, prevHp - dmg);
    await db.execute("UPDATE characters SET hp_current = ? WHERE id = ?", [newHp, characterId]);
    changes.push({ type: "hp_change", characterId, delta: -dmg, previous: prevHp });
    return { roll, description: `${mishap.description} (${dmg} damage)`, damage: dmg };
  }

  if (mishap.mechanic === "remove_gear") {
    const [gearRows] = await db.execute<RowDataPacket[]>("SELECT * FROM character_gear WHERE character_id = ? ORDER BY RAND() LIMIT 1", [characterId]);
    if (gearRows.length > 0) {
      const gear = gearRows[0];
      await db.execute("DELETE FROM character_gear WHERE id = ?", [gear.id]);
      changes.push({ type: "gear_removed", gear });
    }
    return { roll, description: mishap.description };
  }

  if (mishap.mechanic === "exhaust_random") {
    const [spellRows] = await db.execute<RowDataPacket[]>("SELECT id FROM character_spells WHERE character_id = ? AND exhausted = 0 ORDER BY RAND() LIMIT 1", [characterId]);
    if (spellRows.length > 0) {
      await db.execute("UPDATE character_spells SET exhausted = 1 WHERE id = ?", [spellRows[0].id]);
      changes.push({ type: "spell_exhausted", characterSpellId: spellRows[0].id });
    }
    return { roll, description: mishap.description };
  }

  if (mishap.mechanic === "lock_week") {
    // handled by caller (the crit fail already exhausted the spell)
    const lockedUntil = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 19).replace("T", " ");
    // Find the most recently exhausted spell for this character
    changes.push({ type: "spell_locked_week", characterId, lockedUntil });
    return { roll, description: mishap.description };
  }

  if (mishap.mechanic === "condition" && mishap.condition) {
    const [condResult] = await db.execute<import("mysql2").ResultSetHeader>(
      "INSERT INTO character_conditions (character_id, name, description) VALUES (?, ?, ?)",
      [characterId, mishap.condition, mishap.description]
    );
    changes.push({ type: "condition_added", conditionId: condResult.insertId, name: mishap.condition });
    return { roll, description: mishap.description };
  }

  // narrative only
  return { roll, description: mishap.description };
}
  • Step 2: Add undo endpoint to rolls.ts
// POST /api/campaigns/:campaignId/rolls/:rollId/undo
router.post("/:rollId/undo", requireAuth, async (req, res, next) => {
  try {
    const [rows] = await db.execute<RowDataPacket[]>(
      "SELECT * FROM roll_log WHERE id = ? AND campaign_id = ?",
      [req.params.rollId, req.params.campaignId]
    );
    if (rows.length === 0) return void res.status(404).json({ error: "Roll not found" });
    const entry = rows[0];
    if (entry.undone) return void res.status(400).json({ error: "Already undone" });
    if (!entry.metadata) return void res.status(400).json({ error: "Not undoable" });

    const meta = JSON.parse(entry.metadata as string);
    const changes: { type: string; [k: string]: unknown }[] = meta.changes ?? [];

    // Reverse changes in reverse order
    for (const change of [...changes].reverse()) {
      if (change.type === "hp_change") {
        await db.execute("UPDATE characters SET hp_current = ? WHERE id = ?", [change.previous, change.characterId]);
      } else if (change.type === "gear_removed") {
        const g = change.gear as Record<string, unknown>;
        await db.execute(
          "INSERT INTO character_gear (id, character_id, name, type, slot_count, properties, effects, game_item_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
          [g.id, g.character_id, g.name, g.type, g.slot_count, g.properties, g.effects, g.game_item_id]
        );
      } else if (change.type === "spell_exhausted") {
        await db.execute("UPDATE character_spells SET exhausted = 0 WHERE id = ?", [change.characterSpellId]);
      } else if (change.type === "condition_added") {
        await db.execute("DELETE FROM character_conditions WHERE id = ?", [change.conditionId]);
      }
    }

    await db.execute("UPDATE roll_log SET undone = 1 WHERE id = ?", [entry.id]);

    const io = req.app.get("io");
    io.to(req.params.campaignId).emit("roll:undone", { rollId: entry.id, campaignId: req.params.campaignId });

    res.json({ ok: true });
  } catch (err) { next(err); }
});
  • Step 3: Commit
git add server/src/routes/characters.ts server/src/routes/rolls.ts
git commit -m "feat: spell cast endpoint with mishap auto-apply and roll undo"

Task 5: Client types and API

Files:

  • Modify: client/src/types.ts

  • Modify: client/src/api.ts

  • Step 1: Add types to types.ts

export interface Spell {
  id: number;
  name: string;
  class: "wizard" | "priest" | "both";
  tier: number;
  casting_stat: "INT" | "WIS";
  duration: string;
  range: string;
  is_focus: number;
  description: string;
}

export interface CharacterSpell {
  id: number;
  spell_id: number;
  character_id?: number;
  exhausted: number;
  locked_until: string | null;
  focus_active: number;
  focus_started_at: string | null;
  // joined from spells:
  name: string;
  class: "wizard" | "priest" | "both";
  tier: number;
  casting_stat: "INT" | "WIS";
  duration: string;
  range: string;
  is_focus: number;
  description: string;
}

export interface SpellCastResult {
  rollId: number;
  roll: number;
  modifier: number;
  total: number;
  dc: number;
  result: "success" | "failure" | "crit_success" | "crit_fail";
  mishapResult: Record<string, unknown> | null;
}

export interface Condition {
  id: number;
  character_id: number;
  name: string;
  description: string;
  rounds_remaining: number | null;
  expires_at: string | null;
}
  • Step 2: Add API functions to api.ts
export async function getSpells(spellClass?: string): Promise<Spell[]> {
  const qs = spellClass ? `?class=${spellClass}` : "";
  return request<Spell[]>(`/api/spells${qs}`);
}

export async function getCharacterSpells(characterId: number): Promise<CharacterSpell[]> {
  return request<CharacterSpell[]>(`/api/characters/${characterId}/spells`);
}

export async function addCharacterSpell(characterId: number, spellId: number): Promise<CharacterSpell> {
  return request<CharacterSpell>(`/api/characters/${characterId}/spells`, {
    method: "POST",
    body: JSON.stringify({ spell_id: spellId }),
  });
}

export async function removeCharacterSpell(characterId: number, spellId: number): Promise<void> {
  return request<void>(`/api/characters/${characterId}/spells/${spellId}`, { method: "DELETE" });
}

export async function castSpell(characterId: number, spellId: number): Promise<SpellCastResult> {
  return request<SpellCastResult>(`/api/characters/${characterId}/spells/${spellId}/cast`, { method: "POST" });
}

export async function restCharacter(characterId: number): Promise<void> {
  return request<void>(`/api/characters/${characterId}/rest`, { method: "POST" });
}

export async function undoRoll(campaignId: number, rollId: number): Promise<void> {
  return request<void>(`/api/campaigns/${campaignId}/rolls/${rollId}/undo`, { method: "POST" });
}
  • Step 3: Commit
git add client/src/types.ts client/src/api.ts
git commit -m "feat: add Spell, CharacterSpell, Condition types and API functions"

Task 6: SpellList component

Files:

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

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

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

  • Step 1: Create SpellList.tsx

SpellList shows known spells grouped by tier. Each spell has:

  • Name + tier badge + duration + range
  • Cast button (disabled if exhausted or locked)
  • Exhausted indicator (greyed out with strikethrough on name)
  • Focus indicator if focus_active
  • Remove button in edit mode
  • Add spell button (opens picker filtered to class) in edit mode
import { useState, useEffect } from "react";
import type { CharacterSpell, Spell } from "../types";
import { getSpells, addCharacterSpell, removeCharacterSpell, castSpell } from "../api";
import styles from "./SpellList.module.css";

interface SpellListProps {
  characterId: number;
  characterClass: string;
  spells: CharacterSpell[];
  mode: "view" | "edit";
  canEdit: boolean;
  campaignId: number;
  onSpellAdded: (spell: CharacterSpell) => void;
  onSpellRemoved: (spellId: number) => void;
  onSpellCast: (result: import("../types").SpellCastResult, spellName: string) => void;
  onSpellsUpdated: (spells: CharacterSpell[]) => void;
}

export default function SpellList({ characterId, characterClass, spells, mode, canEdit, campaignId, onSpellAdded, onSpellRemoved, onSpellCast, onSpellsUpdated }: SpellListProps) {
  const [allSpells, setAllSpells] = useState<Spell[]>([]);
  const [showPicker, setShowPicker] = useState(false);
  const [casting, setCasting] = useState<number | null>(null);

  useEffect(() => {
    const spellClass = characterClass.toLowerCase() === "wizard" ? "wizard" : characterClass.toLowerCase() === "priest" ? "priest" : null;
    if (spellClass) {
      getSpells(spellClass).then(setAllSpells);
    }
  }, [characterClass]);

  const isCaster = ["Wizard", "Priest"].includes(characterClass);
  if (!isCaster) return null;

  const knownIds = new Set(spells.map(s => s.spell_id));
  const availableToAdd = allSpells.filter(s => !knownIds.has(s.id));

  const byTier: Record<number, CharacterSpell[]> = {};
  for (const s of spells) {
    byTier[s.tier] = byTier[s.tier] ?? [];
    byTier[s.tier].push(s);
  }

  async function handleCast(spell: CharacterSpell) {
    setCasting(spell.spell_id);
    try {
      const result = await castSpell(characterId, spell.spell_id);
      onSpellCast(result, spell.name);
      // Update local exhausted state
      onSpellsUpdated(spells.map(s => s.spell_id === spell.spell_id ? { ...s, exhausted: result.result === "success" || result.result === "crit_success" ? 0 : 1 } : s));
    } catch (err) {
      console.error("Cast failed", err);
    } finally {
      setCasting(null);
    }
  }

  async function handleRemove(spellId: number) {
    await removeCharacterSpell(characterId, spellId);
    onSpellRemoved(spellId);
  }

  async function handleAdd(spell: Spell) {
    const cs = await addCharacterSpell(characterId, spell.id);
    onSpellAdded(cs);
    setShowPicker(false);
  }

  return (
    <div className={styles.container}>
      <div className={styles.header}>
        <span className={styles.title}>Spells</span>
        {canEdit && mode === "edit" && (
          <button className={styles.addBtn} onClick={() => setShowPicker(p => !p)}>
            {showPicker ? "Cancel" : "+ Add Spell"}
          </button>
        )}
      </div>

      {showPicker && (
        <div className={styles.picker}>
          {availableToAdd.length === 0 && <p className={styles.empty}>All available spells known.</p>}
          {availableToAdd.map(s => (
            <button key={s.id} className={styles.pickerItem} onClick={() => handleAdd(s)}>
              <span className={styles.tierBadge}>T{s.tier}</span>
              <span className={styles.pickerName}>{s.name}</span>
              {s.is_focus ? <span className={styles.focusBadge}>Focus</span> : null}
            </button>
          ))}
        </div>
      )}

      {spells.length === 0 && !showPicker && (
        <p className={styles.empty}>No spells known. {canEdit && mode === "edit" ? "Add some above." : ""}</p>
      )}

      {Object.entries(byTier).sort(([a], [b]) => Number(a) - Number(b)).map(([tier, tierSpells]) => (
        <div key={tier} className={styles.tierGroup}>
          <div className={styles.tierLabel}>Tier {tier}</div>
          {tierSpells.map(s => (
            <div key={s.id} className={`${styles.spell} ${s.exhausted ? styles.exhausted : ""} ${s.focus_active ? styles.focusing : ""}`}>
              <div className={styles.spellMain}>
                <span className={styles.spellName}>{s.name}</span>
                {s.focus_active && <span className={styles.focusActive}> Focusing</span>}
                {s.exhausted && <span className={styles.exhaustedLabel}>Exhausted</span>}
                {s.locked_until && <span className={styles.lockedLabel}>Locked (penance)</span>}
              </div>
              <div className={styles.spellMeta}>{s.duration} · {s.range}</div>
              <div className={styles.spellDesc}>{s.description}</div>
              <div className={styles.spellActions}>
                {canEdit && (
                  <button
                    className={styles.castBtn}
                    disabled={!!s.exhausted || !!s.locked_until || casting === s.spell_id}
                    onClick={() => handleCast(s)}
                  >
                    {casting === s.spell_id ? "Rolling..." : "Cast"}
                  </button>
                )}
                {canEdit && mode === "edit" && (
                  <button className={styles.removeBtn} onClick={() => handleRemove(s.spell_id)}></button>
                )}
              </div>
            </div>
          ))}
        </div>
      ))}
    </div>
  );
}
  • Step 2: Create SpellList.module.css

Style following the existing dark parchment theme. Key states:

  • .exhausted — 50% opacity, spell name has text-decoration: line-through
  • .focusing — subtle gold border or glow
  • .focusActive — gold dot + "Focusing" text in gold
  • .lockedLabel — red/crimson text
  • .tierBadge — small pill badge matching character accent color (use gold)
  • Cast button: same style as existing action buttons
  • Picker: dropdown list of available spells
.container { display: flex; flex-direction: column; gap: 0.5rem; }
.header { display: flex; justify-content: space-between; align-items: center; }
.title { font-family: var(--font-display); color: var(--color-gold); font-size: 0.85rem; letter-spacing: 0.05em; text-transform: uppercase; }
.addBtn { font-size: 0.75rem; padding: 0.25rem 0.6rem; background: transparent; border: 1px solid var(--color-gold); color: var(--color-gold); border-radius: 3px; cursor: pointer; }
.addBtn:hover { background: rgba(201,170,113,0.1); }

.picker { display: flex; flex-direction: column; gap: 0.25rem; background: var(--color-bg-card); border: 1px solid var(--color-border); border-radius: 4px; padding: 0.5rem; max-height: 200px; overflow-y: auto; }
.pickerItem { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.5rem; background: transparent; border: none; color: var(--color-text); cursor: pointer; text-align: left; border-radius: 3px; }
.pickerItem:hover { background: rgba(201,170,113,0.1); }
.pickerName { flex: 1; font-size: 0.85rem; }
.focusBadge { font-size: 0.65rem; color: var(--color-gold); border: 1px solid var(--color-gold); border-radius: 2px; padding: 0 3px; }

.tierGroup { display: flex; flex-direction: column; gap: 0.25rem; }
.tierLabel { font-size: 0.7rem; color: var(--color-muted); text-transform: uppercase; letter-spacing: 0.08em; margin-top: 0.25rem; }

.spell { display: flex; flex-direction: column; gap: 0.2rem; padding: 0.5rem 0.6rem; background: var(--color-bg-card); border: 1px solid var(--color-border); border-radius: 4px; transition: opacity 0.2s; }
.exhausted { opacity: 0.5; }
.exhausted .spellName { text-decoration: line-through; }
.focusing { border-color: var(--color-gold); box-shadow: 0 0 6px rgba(201,170,113,0.3); }

.spellMain { display: flex; align-items: center; gap: 0.5rem; }
.spellName { font-size: 0.85rem; color: var(--color-text); font-weight: 500; }
.focusActive { font-size: 0.7rem; color: var(--color-gold); }
.exhaustedLabel { font-size: 0.7rem; color: var(--color-muted); }
.lockedLabel { font-size: 0.7rem; color: #c0392b; }

.spellMeta { font-size: 0.7rem; color: var(--color-muted); }
.spellDesc { font-size: 0.75rem; color: var(--color-text); opacity: 0.8; line-height: 1.4; }

.spellActions { display: flex; gap: 0.4rem; margin-top: 0.25rem; }
.castBtn { font-size: 0.75rem; padding: 0.2rem 0.6rem; background: var(--color-gold); color: #1a1008; border: none; border-radius: 3px; cursor: pointer; font-weight: 600; }
.castBtn:disabled { opacity: 0.4; cursor: not-allowed; }
.castBtn:hover:not(:disabled) { filter: brightness(1.1); }
.removeBtn { font-size: 0.7rem; padding: 0.2rem 0.4rem; background: transparent; border: 1px solid var(--color-border); color: var(--color-muted); border-radius: 3px; cursor: pointer; }
.removeBtn:hover { border-color: #c0392b; color: #c0392b; }

.empty { font-size: 0.8rem; color: var(--color-muted); }
.tierBadge { font-size: 0.65rem; color: var(--color-gold); background: rgba(201,170,113,0.15); border-radius: 2px; padding: 0 4px; }
  • Step 3: Add SpellList to CharacterDetail

Add a Spells section to CharacterDetail for Wizard and Priest classes. It should:

  • Load character spells on mount (getCharacterSpells(characterId))
  • Store them in local state
  • Show SpellList component above the gear/talent panels
  • Handle socket updates for spell state

In CharacterDetail, add:

const [spells, setSpells] = useState<CharacterSpell[]>([]);
const [castResult, setCastResult] = useState<{ result: SpellCastResult; spellName: string } | null>(null);

useEffect(() => {
  if (["Wizard", "Priest"].includes(character.class)) {
    getCharacterSpells(character.id).then(setSpells);
  }
}, [character.id, character.class]);

Show SpellList in JSX for caster classes:

{["Wizard", "Priest"].includes(character.class) && (
  <SpellList
    characterId={character.id}
    characterClass={character.class}
    spells={spells}
    mode={mode}
    canEdit={canEdit}
    campaignId={campaignId}
    onSpellAdded={(s) => setSpells(prev => [...prev, s])}
    onSpellRemoved={(id) => setSpells(prev => prev.filter(s => s.spell_id !== id))}
    onSpellCast={(result, name) => setCastResult({ result, spellName: name })}
    onSpellsUpdated={setSpells}
  />
)}
  • Step 4: Commit
git add client/src/components/SpellList.tsx client/src/components/SpellList.module.css client/src/components/CharacterDetail.tsx
git commit -m "feat: SpellList component with cast/add/remove and exhausted state"

Task 7: SpellCastResult modal (success/fail/crit/mishap display)

Files:

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

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

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

  • Step 1: Create SpellCastResult.tsx

Modal that appears after a cast roll, showing the result dramatically:

  • Roll value, modifier, total vs DC
  • Result label: SUCCESS / FAILURE / CRITICAL SUCCESS / CRITICAL FAILURE
  • For crit fail Wizard: mishap description in red with dramatic styling
  • Dismiss button
import styles from "./SpellCastResult.module.css";
import type { SpellCastResult as CastResult } from "../types";

interface Props {
  result: CastResult;
  spellName: string;
  onClose: () => void;
}

export default function SpellCastResult({ result, spellName, onClose }: Props) {
  const labels = {
    success: { text: "Success", cls: styles.success },
    failure: { text: "Failure", cls: styles.failure },
    crit_success: { text: "Critical Success!", cls: styles.critSuccess },
    crit_fail: { text: "Critical Failure!", cls: styles.critFail },
  };
  const label = labels[result.result];
  const mishap = result.mishapResult as Record<string, unknown> | null;

  return (
    <div className={styles.overlay} onClick={onClose}>
      <div className={styles.modal} onClick={e => e.stopPropagation()}>
        <div className={styles.spellName}>{spellName}</div>
        <div className={styles.roll}>
          {result.roll} {result.modifier >= 0 ? "+" : ""}{result.modifier} = {result.total}
          <span className={styles.dc}>vs DC {result.dc}</span>
        </div>
        <div className={`${styles.resultLabel} ${label.cls}`}>{label.text}</div>

        {result.result === "failure" && (
          <p className={styles.note}>Spell exhausted until rest.</p>
        )}
        {result.result === "crit_success" && (
          <p className={styles.note}>Double one numerical effect!</p>
        )}
        {result.result === "crit_fail" && mishap && (
          <div className={styles.mishap}>
            <div className={styles.mishapTitle}> Wizard Mishap</div>
            <p className={styles.mishapDesc}>{String(mishap.description ?? "")}</p>
            {mishap.damage && <p className={styles.mishapEffect}>Took {String(mishap.damage)} damage</p>}
          </div>
        )}
        {result.result === "crit_fail" && !mishap && (
          <p className={styles.note}>Spell exhausted. Deity is displeased  penance required.</p>
        )}

        <button className={styles.closeBtn} onClick={onClose}>Dismiss</button>
      </div>
    </div>
  );
}
  • Step 2: Create SpellCastResult.module.css

Dramatic dark overlay modal. SUCCESS is gold, FAILURE is muted, CRITICAL SUCCESS is bright gold with glow, CRITICAL FAILURE is crimson.

.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 10010; display: flex; align-items: center; justify-content: center; }
.modal { background: var(--color-bg-card); border: 2px solid var(--color-border); border-radius: 8px; padding: 2rem; min-width: 280px; max-width: 400px; text-align: center; display: flex; flex-direction: column; gap: 0.75rem; }
.spellName { font-family: var(--font-display); font-size: 1.2rem; color: var(--color-gold); }
.roll { font-size: 1.4rem; font-weight: bold; color: var(--color-text); }
.dc { font-size: 0.9rem; color: var(--color-muted); margin-left: 0.5rem; }
.resultLabel { font-family: var(--font-display); font-size: 1.5rem; letter-spacing: 0.05em; padding: 0.5rem; border-radius: 4px; }
.success { color: var(--color-gold); }
.failure { color: var(--color-muted); }
.critSuccess { color: var(--color-gold); text-shadow: 0 0 20px rgba(201,170,113,0.8); }
.critFail { color: #c0392b; text-shadow: 0 0 20px rgba(192,57,43,0.5); }
.note { font-size: 0.85rem; color: var(--color-muted); }
.mishap { background: rgba(192,57,43,0.1); border: 1px solid #c0392b; border-radius: 4px; padding: 0.75rem; }
.mishapTitle { font-family: var(--font-display); color: #c0392b; margin-bottom: 0.5rem; }
.mishapDesc { font-size: 0.85rem; color: var(--color-text); }
.mishapEffect { font-size: 0.85rem; color: #c0392b; font-weight: bold; margin-top: 0.25rem; }
.closeBtn { margin-top: 0.5rem; padding: 0.5rem 1.5rem; background: var(--color-gold); color: #1a1008; border: none; border-radius: 4px; cursor: pointer; font-weight: 600; font-family: var(--font-display); }
.closeBtn:hover { filter: brightness(1.1); }
  • Step 3: Wire modal into CharacterDetail
{castResult && (
  <SpellCastResult
    result={castResult.result}
    spellName={castResult.spellName}
    onClose={() => setCastResult(null)}
  />
)}
  • Step 4: Commit
git add client/src/components/SpellCastResult.tsx client/src/components/SpellCastResult.module.css client/src/components/CharacterDetail.tsx
git commit -m "feat: SpellCastResult modal for success/fail/crit/mishap display"

Task 8: Undo button on roll log + focus indicator on DM card

Files:

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

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

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

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

  • Step 1: Add undo button to RollEntry

Add onUndo?: () => void prop and undone?: boolean to RollEntry. Show undo button only when subtype === 'spell_cast' and !undone.

Update RollResult type in types.ts to add: subtype?: string; undone?: boolean;

In RollEntry:

{!roll.undone && roll.subtype === "spell_cast" && onUndo && (
  <button className={styles.undoBtn} onClick={onUndo}>Undo</button>
)}
{roll.undone && <span className={styles.reverted}>Reverted</span>}

Style undo button as small, secondary, red-bordered.

  • Step 2: Wire undo in RollLog/CampaignView

In CampaignView, add handler:

async function handleUndoRoll(rollId: number) {
  await undoRoll(campaignId, rollId);
  setRolls(prev => prev.map(r => r.id === rollId ? { ...r, undone: true } : r));
}

Pass to RollLog → RollEntry as onUndo={() => handleUndoRoll(roll.id)}.

Handle socket event roll:undone:

socket.on("roll:undone", ({ rollId }: { rollId: number }) => {
  setRolls(prev => prev.map(r => r.id === rollId ? { ...r, undone: true } : r));
});
  • Step 3: Focus spell indicator on CharacterCard

CharacterCard receives focusSpell?: string prop. Show it below the character name when set.

In CampaignView, track focus spells in a Map<characterId, spellName> and pass to CharacterCard.

Handle socket event spell:cast to update focus tracking when a focus spell succeeds.

Small gold indicator: ● Focusing: Shield of Faith

  • Step 4: Commit
git add client/src/components/RollEntry.tsx client/src/components/RollLog.tsx client/src/components/CharacterCard.tsx client/src/pages/CampaignView.tsx client/src/types.ts
git commit -m "feat: undo button on mishap roll log entries, focus spell on DM card"

Task 9: Rest button

Files:

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

  • Step 1: Add Rest button to CharacterDetail

Below the spell list, add a "Take Rest" button visible in edit mode for casters. On click:

async function handleRest() {
  await restCharacter(character.id);
  setSpells(prev => prev.map(s => ({ ...s, exhausted: 0, focus_active: 0, focus_started_at: null })));
}

Style as secondary action button.

  • Step 2: Handle character:rested socket event in CampaignView
socket.on("character:rested", ({ characterId }: { characterId: number }) => {
  // Refresh spells for that character if their detail is open
  if (selectedId === characterId) {
    getCharacterSpells(characterId).then(/* pass to CharacterDetail via state */);
  }
});
  • Step 3: Commit
git add client/src/components/CharacterDetail.tsx client/src/pages/CampaignView.tsx
git commit -m "feat: rest button clears exhausted spells and conditions"