- 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>
1122 lines
51 KiB
Markdown
1122 lines
51 KiB
Markdown
# 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<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:
|
|
```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<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):
|
|
|
|
```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<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):
|
|
```typescript
|
|
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**
|
|
|
|
```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<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:
|
|
```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<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**
|
|
|
|
```typescript
|
|
// 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**
|
|
|
|
```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<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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```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<Spell[]>([]);
|
|
const [showPicker, setShowPicker] = useState(false);
|
|
const [casting, setCasting] = useState<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
const spellClass = characterClass.toLowerCase() === "wizard" ? "wizard" : characterClass.toLowerCase() === "priest" ? "priest" : null;
|
|
if (spellClass) {
|
|
getSpells(spellClass).then(setAllSpells);
|
|
}
|
|
}, [characterClass]);
|
|
|
|
const isCaster = ["Wizard", "Priest"].includes(characterClass);
|
|
if (!isCaster) return null;
|
|
|
|
const knownIds = new Set(spells.map(s => s.spell_id));
|
|
const availableToAdd = allSpells.filter(s => !knownIds.has(s.id));
|
|
|
|
const byTier: Record<number, CharacterSpell[]> = {};
|
|
for (const s of spells) {
|
|
byTier[s.tier] = byTier[s.tier] ?? [];
|
|
byTier[s.tier].push(s);
|
|
}
|
|
|
|
async function handleCast(spell: CharacterSpell) {
|
|
setCasting(spell.spell_id);
|
|
try {
|
|
const result = await castSpell(characterId, spell.spell_id);
|
|
onSpellCast(result, spell.name);
|
|
// Update local exhausted state
|
|
onSpellsUpdated(spells.map(s => s.spell_id === spell.spell_id ? { ...s, exhausted: result.result === "success" || result.result === "crit_success" ? 0 : 1 } : s));
|
|
} catch (err) {
|
|
console.error("Cast failed", err);
|
|
} finally {
|
|
setCasting(null);
|
|
}
|
|
}
|
|
|
|
async function handleRemove(spellId: number) {
|
|
await removeCharacterSpell(characterId, spellId);
|
|
onSpellRemoved(spellId);
|
|
}
|
|
|
|
async function handleAdd(spell: Spell) {
|
|
const cs = await addCharacterSpell(characterId, spell.id);
|
|
onSpellAdded(cs);
|
|
setShowPicker(false);
|
|
}
|
|
|
|
return (
|
|
<div className={styles.container}>
|
|
<div className={styles.header}>
|
|
<span className={styles.title}>Spells</span>
|
|
{canEdit && mode === "edit" && (
|
|
<button className={styles.addBtn} onClick={() => setShowPicker(p => !p)}>
|
|
{showPicker ? "Cancel" : "+ Add Spell"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{showPicker && (
|
|
<div className={styles.picker}>
|
|
{availableToAdd.length === 0 && <p className={styles.empty}>All available spells known.</p>}
|
|
{availableToAdd.map(s => (
|
|
<button key={s.id} className={styles.pickerItem} onClick={() => handleAdd(s)}>
|
|
<span className={styles.tierBadge}>T{s.tier}</span>
|
|
<span className={styles.pickerName}>{s.name}</span>
|
|
{s.is_focus ? <span className={styles.focusBadge}>Focus</span> : null}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{spells.length === 0 && !showPicker && (
|
|
<p className={styles.empty}>No spells known. {canEdit && mode === "edit" ? "Add some above." : ""}</p>
|
|
)}
|
|
|
|
{Object.entries(byTier).sort(([a], [b]) => Number(a) - Number(b)).map(([tier, tierSpells]) => (
|
|
<div key={tier} className={styles.tierGroup}>
|
|
<div className={styles.tierLabel}>Tier {tier}</div>
|
|
{tierSpells.map(s => (
|
|
<div key={s.id} className={`${styles.spell} ${s.exhausted ? styles.exhausted : ""} ${s.focus_active ? styles.focusing : ""}`}>
|
|
<div className={styles.spellMain}>
|
|
<span className={styles.spellName}>{s.name}</span>
|
|
{s.focus_active && <span className={styles.focusActive}>● Focusing</span>}
|
|
{s.exhausted && <span className={styles.exhaustedLabel}>Exhausted</span>}
|
|
{s.locked_until && <span className={styles.lockedLabel}>Locked (penance)</span>}
|
|
</div>
|
|
<div className={styles.spellMeta}>{s.duration} · {s.range}</div>
|
|
<div className={styles.spellDesc}>{s.description}</div>
|
|
<div className={styles.spellActions}>
|
|
{canEdit && (
|
|
<button
|
|
className={styles.castBtn}
|
|
disabled={!!s.exhausted || !!s.locked_until || casting === s.spell_id}
|
|
onClick={() => handleCast(s)}
|
|
>
|
|
{casting === s.spell_id ? "Rolling..." : "Cast"}
|
|
</button>
|
|
)}
|
|
{canEdit && mode === "edit" && (
|
|
<button className={styles.removeBtn} onClick={() => handleRemove(s.spell_id)}>✕</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create SpellList.module.css**
|
|
|
|
Style following the existing dark parchment theme. Key states:
|
|
- `.exhausted` — 50% opacity, spell name has `text-decoration: line-through`
|
|
- `.focusing` — subtle gold border or glow
|
|
- `.focusActive` — gold dot + "Focusing" text in gold
|
|
- `.lockedLabel` — red/crimson text
|
|
- `.tierBadge` — small pill badge matching character accent color (use gold)
|
|
- Cast button: same style as existing action buttons
|
|
- Picker: dropdown list of available spells
|
|
|
|
```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<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:
|
|
```typescript
|
|
{["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**
|
|
|
|
```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<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.
|
|
|
|
```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 && (
|
|
<SpellCastResult
|
|
result={castResult.result}
|
|
spellName={castResult.spellName}
|
|
onClose={() => 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 && (
|
|
<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:
|
|
```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<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**
|
|
|
|
```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"
|
|
```
|