- 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>
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 hastext-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"