# 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** ```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** ```bash 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** ```typescript 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 { const [existing] = await db.execute("SELECT COUNT(*) as c FROM spells"); if ((existing[0] as { c: number }).c > 0) return; for (const spell of SPELLS) { await db.execute( "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: ```typescript import { seedSpells } from "./seed-spells.js"; ``` Add at the start of `seedDevData()` before the users check: ```typescript await seedSpells(); ``` - [ ] **Step 3: Restart server, verify spells seeded** Server log should show: `Spells seeded: 34 spells` - [ ] **Step 4: Commit** ```bash 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** ```typescript 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(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): ```typescript // 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( `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( `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): ```typescript async function getCharacterCampaignId(id: number): Promise<{ campaign_id: number }> { const [rows] = await db.execute("SELECT campaign_id FROM characters WHERE id = ?", [id]); return rows[0] as { campaign_id: number }; } ``` - [ ] **Step 3: Mount spell routes in index.ts** ```typescript import spellRoutes from "./routes/spells.js"; // after existing routes: app.use("/api/spells", spellRoutes); ``` - [ ] **Step 4: Commit** ```bash 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** ```typescript // 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( "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( "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( "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: ```typescript 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 { 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("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("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("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( "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** ```typescript // POST /api/campaigns/:campaignId/rolls/:rollId/undo router.post("/:rollId/undo", requireAuth, async (req, res, next) => { try { const [rows] = await db.execute( "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; 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** ```bash 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** ```typescript 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 | 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** ```typescript export async function getSpells(spellClass?: string): Promise { const qs = spellClass ? `?class=${spellClass}` : ""; return request(`/api/spells${qs}`); } export async function getCharacterSpells(characterId: number): Promise { return request(`/api/characters/${characterId}/spells`); } export async function addCharacterSpell(characterId: number, spellId: number): Promise { return request(`/api/characters/${characterId}/spells`, { method: "POST", body: JSON.stringify({ spell_id: spellId }), }); } export async function removeCharacterSpell(characterId: number, spellId: number): Promise { return request(`/api/characters/${characterId}/spells/${spellId}`, { method: "DELETE" }); } export async function castSpell(characterId: number, spellId: number): Promise { return request(`/api/characters/${characterId}/spells/${spellId}/cast`, { method: "POST" }); } export async function restCharacter(characterId: number): Promise { return request(`/api/characters/${characterId}/rest`, { method: "POST" }); } export async function undoRoll(campaignId: number, rollId: number): Promise { return request(`/api/campaigns/${campaignId}/rolls/${rollId}/undo`, { method: "POST" }); } ``` - [ ] **Step 3: Commit** ```bash 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 ```typescript 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([]); const [showPicker, setShowPicker] = useState(false); const [casting, setCasting] = useState(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 = {}; 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 (
Spells {canEdit && mode === "edit" && ( )}
{showPicker && (
{availableToAdd.length === 0 &&

All available spells known.

} {availableToAdd.map(s => ( ))}
)} {spells.length === 0 && !showPicker && (

No spells known. {canEdit && mode === "edit" ? "Add some above." : ""}

)} {Object.entries(byTier).sort(([a], [b]) => Number(a) - Number(b)).map(([tier, tierSpells]) => (
Tier {tier}
{tierSpells.map(s => (
{s.name} {s.focus_active && ● Focusing} {s.exhausted && Exhausted} {s.locked_until && Locked (penance)}
{s.duration} · {s.range}
{s.description}
{canEdit && ( )} {canEdit && mode === "edit" && ( )}
))}
))}
); } ``` - [ ] **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 ```css .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: ```typescript const [spells, setSpells] = useState([]); 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: ```typescript {["Wizard", "Priest"].includes(character.class) && ( 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** ```bash 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 ```typescript 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 | null; return (
e.stopPropagation()}>
{spellName}
{result.roll} {result.modifier >= 0 ? "+" : ""}{result.modifier} = {result.total} vs DC {result.dc}
{label.text}
{result.result === "failure" && (

Spell exhausted until rest.

)} {result.result === "crit_success" && (

Double one numerical effect!

)} {result.result === "crit_fail" && mishap && (
⚠ Wizard Mishap

{String(mishap.description ?? "")}

{mishap.damage &&

Took {String(mishap.damage)} damage

}
)} {result.result === "crit_fail" && !mishap && (

Spell exhausted. Deity is displeased — penance required.

)}
); } ``` - [ ] **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. ```css .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** ```typescript {castResult && ( setCastResult(null)} /> )} ``` - [ ] **Step 4: Commit** ```bash 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: ```typescript {!roll.undone && roll.subtype === "spell_cast" && onUndo && ( )} {roll.undone && Reverted} ``` Style undo button as small, secondary, red-bordered. - [ ] **Step 2: Wire undo in RollLog/CampaignView** In CampaignView, add handler: ```typescript 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`: ```typescript 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` 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** ```bash 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: ```typescript 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** ```typescript 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** ```bash git add client/src/components/CharacterDetail.tsx client/src/pages/CampaignView.tsx git commit -m "feat: rest button clears exhausted spells and conditions" ```