darkwatch/docs/plans/2026-04-08-shadowdark-character-manager.md

3214 lines
75 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Shadowdark Character Sheet Manager — 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:** Build a real-time web app for managing Shadowdark RPG character sheets across a group of players.
**Architecture:** React SPA frontend communicating with a Node/Express + Socket.IO backend. SQLite for persistence. All mutations go through REST endpoints which save to DB then broadcast via Socket.IO to all clients in the same campaign room.
**Tech Stack:** React 18, Vite, TypeScript, Node.js, Express, Socket.IO, better-sqlite3, CSS Modules
---
## File Structure
```
shadowdark/
├── client/
│ ├── index.html
│ ├── package.json
│ ├── tsconfig.json
│ ├── vite.config.ts
│ └── src/
│ ├── main.tsx
│ ├── App.tsx
│ ├── App.module.css
│ ├── socket.ts # Socket.IO client singleton
│ ├── api.ts # REST API helper functions
│ ├── types.ts # Shared TypeScript types
│ ├── utils/
│ │ └── modifiers.ts # Ability score -> modifier lookup
│ ├── pages/
│ │ ├── CampaignList.tsx # Home page — list/create campaigns
│ │ ├── CampaignList.module.css
│ │ ├── CampaignView.tsx # Main campaign screen with character grid
│ │ └── CampaignView.module.css
│ └── components/
│ ├── CharacterCard.tsx # Compact card in the grid
│ ├── CharacterCard.module.css
│ ├── CharacterDetail.tsx # Expanded modal for full editing
│ ├── CharacterDetail.module.css
│ ├── StatBlock.tsx # 3x2 stat display with +/- buttons
│ ├── StatBlock.module.css
│ ├── GearList.tsx # Gear/inventory management
│ ├── GearList.module.css
│ ├── TalentList.tsx # Talents management
│ ├── TalentList.module.css
│ ├── HpBar.tsx # HP current/max with +/- buttons
│ └── HpBar.module.css
├── server/
│ ├── package.json
│ ├── tsconfig.json
│ ├── data/ # SQLite DB lives here (created at runtime)
│ └── src/
│ ├── index.ts # Express + Socket.IO server entry
│ ├── db.ts # SQLite setup, schema init, query helpers
│ ├── routes/
│ │ ├── campaigns.ts # CRUD routes for campaigns
│ │ └── characters.ts # CRUD routes for characters, stats, gear, talents
│ └── socket.ts # Socket.IO room management and broadcast logic
└── package.json # Root package.json with dev scripts
```
---
### Task 1: Project Scaffolding
**Files:**
- Create: `package.json` (root)
- Create: `server/package.json`
- Create: `server/tsconfig.json`
- Create: `client/package.json`
- Create: `client/tsconfig.json`
- Create: `client/vite.config.ts`
- Create: `client/index.html`
- [ ] **Step 1: Create root package.json**
```json
{
"name": "shadowdark",
"private": true,
"scripts": {
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
"dev:server": "cd server && npm run dev",
"dev:client": "cd client && npm run dev"
},
"devDependencies": {
"concurrently": "^9.1.2"
}
}
```
- [ ] **Step 2: Create server/package.json**
```json
{
"name": "shadowdark-server",
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"better-sqlite3": "^11.7.0",
"cors": "^2.8.5",
"express": "^4.21.2",
"socket.io": "^4.8.1"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}
```
- [ ] **Step 3: Create server/tsconfig.json**
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src"]
}
```
- [ ] **Step 4: Create client/package.json**
```json
{
"name": "shadowdark-client",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.1.1",
"socket.io-client": "^4.8.1"
},
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}
```
- [ ] **Step 5: Create client/tsconfig.json**
```json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src"]
}
```
- [ ] **Step 6: Create client/vite.config.ts**
```ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": "http://localhost:3000",
"/socket.io": {
target: "http://localhost:3000",
ws: true,
},
},
},
});
```
- [ ] **Step 7: Create client/index.html**
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shadowdark Character Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
```
- [ ] **Step 8: Install dependencies**
Run from project root:
```bash
cd /Users/aaron.wood/workspace/shadowdark && npm install
cd /Users/aaron.wood/workspace/shadowdark/server && npm install
cd /Users/aaron.wood/workspace/shadowdark/client && npm install
```
Expected: All three install successfully with no errors.
---
### Task 2: Database Layer
**Files:**
- Create: `server/src/db.ts`
- [ ] **Step 1: Create server/src/db.ts with schema initialization**
```ts
import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
const DATA_DIR = path.join(import.meta.dirname, "..", "data");
fs.mkdirSync(DATA_DIR, { recursive: true });
const db = new Database(path.join(DATA_DIR, "shadowdark.db"));
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
db.exec(`
CREATE TABLE IF NOT EXISTS campaigns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_by TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS characters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
campaign_id INTEGER NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
created_by TEXT DEFAULT '',
name TEXT NOT NULL,
class TEXT NOT NULL DEFAULT 'Fighter',
ancestry TEXT NOT NULL DEFAULT 'Human',
level INTEGER NOT NULL DEFAULT 1,
xp INTEGER NOT NULL DEFAULT 0,
hp_current INTEGER NOT NULL DEFAULT 0,
hp_max INTEGER NOT NULL DEFAULT 0,
ac INTEGER NOT NULL DEFAULT 10,
alignment TEXT NOT NULL DEFAULT 'Neutral',
title TEXT DEFAULT '',
notes TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS character_stats (
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
stat_name TEXT NOT NULL,
value INTEGER NOT NULL DEFAULT 10,
PRIMARY KEY (character_id, stat_name)
);
CREATE TABLE IF NOT EXISTS character_gear (
id INTEGER PRIMARY KEY AUTOINCREMENT,
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'gear',
slot_count INTEGER NOT NULL DEFAULT 1,
properties TEXT DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS character_talents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT DEFAULT '',
effect TEXT DEFAULT '{}'
);
`);
export default db;
```
- [ ] **Step 2: Verify the database initializes**
Run:
```bash
cd /Users/aaron.wood/workspace/shadowdark/server && npx tsx src/db.ts
```
Expected: No errors. File `server/data/shadowdark.db` is created.
---
### Task 3: Server Entry + Socket.IO Setup
**Files:**
- Create: `server/src/index.ts`
- Create: `server/src/socket.ts`
- [ ] **Step 1: Create server/src/socket.ts**
```ts
import { Server } from "socket.io";
export function setupSocket(io: Server) {
io.on("connection", (socket) => {
socket.on("join-campaign", (campaignId: string) => {
socket.join(`campaign:${campaignId}`);
});
socket.on("leave-campaign", (campaignId: string) => {
socket.leave(`campaign:${campaignId}`);
});
socket.on("disconnect", () => {
// Rooms are cleaned up automatically by Socket.IO
});
});
}
export function broadcastToCampaign(
io: Server,
campaignId: number,
event: string,
data: unknown
) {
io.to(`campaign:${campaignId}`).emit(event, data);
}
```
- [ ] **Step 2: Create server/src/index.ts**
```ts
import express from "express";
import cors from "cors";
import { createServer } from "http";
import { Server } from "socket.io";
import { setupSocket } from "./socket.js";
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: { origin: "*" },
});
app.use(cors());
app.use(express.json());
// Make io accessible to route handlers
app.set("io", io);
setupSocket(io);
const PORT = process.env.PORT || 3000;
httpServer.listen(PORT, () => {
console.log(`Shadowdark server running on http://localhost:${PORT}`);
});
export { io };
```
- [ ] **Step 3: Verify server starts**
Run:
```bash
cd /Users/aaron.wood/workspace/shadowdark/server && npx tsx src/index.ts
```
Expected: Console prints `Shadowdark server running on http://localhost:3000`. Kill with Ctrl+C.
---
### Task 4: Campaign API Routes
**Files:**
- Create: `server/src/routes/campaigns.ts`
- Modify: `server/src/index.ts` (register routes)
- [ ] **Step 1: Create server/src/routes/campaigns.ts**
```ts
import { Router } from "express";
import db from "../db.js";
const router = Router();
// GET /api/campaigns — list all campaigns
router.get("/", (_req, res) => {
const campaigns = db
.prepare("SELECT * FROM campaigns ORDER BY created_at DESC")
.all();
res.json(campaigns);
});
// POST /api/campaigns — create a campaign
router.post("/", (req, res) => {
const { name } = req.body;
if (!name || !name.trim()) {
res.status(400).json({ error: "Campaign name is required" });
return;
}
const result = db
.prepare("INSERT INTO campaigns (name) VALUES (?)")
.run(name.trim());
const campaign = db
.prepare("SELECT * FROM campaigns WHERE id = ?")
.get(result.lastInsertRowid);
res.status(201).json(campaign);
});
// GET /api/campaigns/:id — get a single campaign
router.get("/:id", (req, res) => {
const campaign = db
.prepare("SELECT * FROM campaigns WHERE id = ?")
.get(req.params.id);
if (!campaign) {
res.status(404).json({ error: "Campaign not found" });
return;
}
res.json(campaign);
});
// DELETE /api/campaigns/:id — delete a campaign (cascades to characters)
router.delete("/:id", (req, res) => {
const result = db
.prepare("DELETE FROM campaigns WHERE id = ?")
.run(req.params.id);
if (result.changes === 0) {
res.status(404).json({ error: "Campaign not found" });
return;
}
res.status(204).end();
});
export default router;
```
- [ ] **Step 2: Register campaign routes in server/src/index.ts**
Add these lines before the `httpServer.listen()` call:
```ts
import campaignRoutes from "./routes/campaigns.js";
app.use("/api/campaigns", campaignRoutes);
```
- [ ] **Step 3: Test campaign endpoints manually**
Run server:
```bash
cd /Users/aaron.wood/workspace/shadowdark/server && npx tsx src/index.ts &
```
Test:
```bash
# Create
curl -s -X POST http://localhost:3000/api/campaigns -H 'Content-Type: application/json' -d '{"name":"Test Campaign"}' | jq .
# List
curl -s http://localhost:3000/api/campaigns | jq .
# Get
curl -s http://localhost:3000/api/campaigns/1 | jq .
# Delete
curl -s -X DELETE http://localhost:3000/api/campaigns/1 -w "%{http_code}"
```
Expected: 201 with campaign object, 200 with array, 200 with object, 204.
Kill server after testing.
---
### Task 5: Character API Routes
**Files:**
- Create: `server/src/routes/characters.ts`
- Modify: `server/src/index.ts` (register routes)
- [ ] **Step 1: Create server/src/routes/characters.ts**
```ts
import { Router } from "express";
import type { Server } from "socket.io";
import db from "../db.js";
import { broadcastToCampaign } from "../socket.js";
const router = Router();
const DEFAULT_STATS = ["STR", "DEX", "CON", "INT", "WIS", "CHA"];
// GET /api/campaigns/:campaignId/characters — list characters in a campaign
router.get("/", (req, res) => {
const { campaignId } = req.params;
const characters = db
.prepare("SELECT * FROM characters WHERE campaign_id = ? ORDER BY name")
.all(campaignId) as Array<Record<string, unknown>>;
// Attach stats, gear, and talents to each character
const stmtStats = db.prepare(
"SELECT stat_name, value FROM character_stats WHERE character_id = ?"
);
const stmtGear = db.prepare(
"SELECT * FROM character_gear WHERE character_id = ?"
);
const stmtTalents = db.prepare(
"SELECT * FROM character_talents WHERE character_id = ?"
);
const enriched = characters.map((char) => ({
...char,
stats: stmtStats.all(char.id),
gear: stmtGear.all(char.id),
talents: stmtTalents.all(char.id),
}));
res.json(enriched);
});
// POST /api/campaigns/:campaignId/characters — create a character
router.post("/", (req, res) => {
const { campaignId } = req.params;
const { name, class: charClass, ancestry, hp_max } = req.body;
if (!name || !name.trim()) {
res.status(400).json({ error: "Character name is required" });
return;
}
const insertChar = db.prepare(`
INSERT INTO characters (campaign_id, name, class, ancestry, hp_current, hp_max)
VALUES (?, ?, ?, ?, ?, ?)
`);
const insertStat = db.prepare(`
INSERT INTO character_stats (character_id, stat_name, value) VALUES (?, ?, 10)
`);
const result = insertChar.run(
campaignId,
name.trim(),
charClass || "Fighter",
ancestry || "Human",
hp_max || 0,
hp_max || 0
);
const characterId = result.lastInsertRowid;
for (const stat of DEFAULT_STATS) {
insertStat.run(characterId, stat);
}
const character = db
.prepare("SELECT * FROM characters WHERE id = ?")
.get(characterId);
const stats = db
.prepare(
"SELECT stat_name, value FROM character_stats WHERE character_id = ?"
)
.all(characterId);
const enriched = {
...(character as Record<string, unknown>),
stats,
gear: [],
talents: [],
};
const io: Server = req.app.get("io");
broadcastToCampaign(io, Number(campaignId), "character:created", enriched);
res.status(201).json(enriched);
});
// PATCH /api/characters/:id — update character fields
router.patch("/:id", (req, res) => {
const { id } = req.params;
const allowedFields = [
"name",
"class",
"ancestry",
"level",
"xp",
"hp_current",
"hp_max",
"ac",
"alignment",
"title",
"notes",
];
const updates: string[] = [];
const values: unknown[] = [];
for (const field of allowedFields) {
if (req.body[field] !== undefined) {
updates.push(`${field} = ?`);
values.push(req.body[field]);
}
}
if (updates.length === 0) {
res.status(400).json({ error: "No valid fields to update" });
return;
}
values.push(id);
db.prepare(`UPDATE characters SET ${updates.join(", ")} WHERE id = ?`).run(
...values
);
const character = db
.prepare("SELECT * FROM characters WHERE id = ?")
.get(id) as Record<string, unknown>;
if (!character) {
res.status(404).json({ error: "Character not found" });
return;
}
const io: Server = req.app.get("io");
broadcastToCampaign(io, Number(character.campaign_id), "character:updated", {
id: Number(id),
...req.body,
});
res.json(character);
});
// DELETE /api/characters/:id — delete a character
router.delete("/:id", (req, res) => {
const character = db
.prepare("SELECT * FROM characters WHERE id = ?")
.get(req.params.id) as Record<string, unknown> | undefined;
if (!character) {
res.status(404).json({ error: "Character not found" });
return;
}
db.prepare("DELETE FROM characters WHERE id = ?").run(req.params.id);
const io: Server = req.app.get("io");
broadcastToCampaign(io, Number(character.campaign_id), "character:deleted", {
id: Number(req.params.id),
});
res.status(204).end();
});
// PATCH /api/characters/:id/stats/:statName — update a single stat
router.patch("/:id/stats/:statName", (req, res) => {
const { id, statName } = req.params;
const { value } = req.body;
const upper = statName.toUpperCase();
if (!DEFAULT_STATS.includes(upper)) {
res.status(400).json({ error: "Invalid stat name" });
return;
}
db.prepare(
"UPDATE character_stats SET value = ? WHERE character_id = ? AND stat_name = ?"
).run(value, id, upper);
const character = db
.prepare("SELECT campaign_id FROM characters WHERE id = ?")
.get(id) as Record<string, unknown>;
if (!character) {
res.status(404).json({ error: "Character not found" });
return;
}
const io: Server = req.app.get("io");
broadcastToCampaign(io, Number(character.campaign_id), "stat:updated", {
characterId: Number(id),
statName: upper,
value,
});
res.json({ characterId: Number(id), statName: upper, value });
});
// POST /api/characters/:id/gear — add gear
router.post("/:id/gear", (req, res) => {
const { id } = req.params;
const { name, type, slot_count, properties } = req.body;
if (!name || !name.trim()) {
res.status(400).json({ error: "Gear name is required" });
return;
}
const result = db
.prepare(
"INSERT INTO character_gear (character_id, name, type, slot_count, properties) VALUES (?, ?, ?, ?, ?)"
)
.run(
id,
name.trim(),
type || "gear",
slot_count ?? 1,
JSON.stringify(properties || {})
);
const gear = db
.prepare("SELECT * FROM character_gear WHERE id = ?")
.get(result.lastInsertRowid);
const character = db
.prepare("SELECT campaign_id FROM characters WHERE id = ?")
.get(id) as Record<string, unknown>;
const io: Server = req.app.get("io");
broadcastToCampaign(io, Number(character.campaign_id), "gear:added", {
characterId: Number(id),
gear,
});
res.status(201).json(gear);
});
// DELETE /api/characters/:id/gear/:gearId — remove gear
router.delete("/:id/gear/:gearId", (req, res) => {
const { id, gearId } = req.params;
const character = db
.prepare("SELECT campaign_id FROM characters WHERE id = ?")
.get(id) as Record<string, unknown>;
if (!character) {
res.status(404).json({ error: "Character not found" });
return;
}
const result = db
.prepare("DELETE FROM character_gear WHERE id = ? AND character_id = ?")
.run(gearId, id);
if (result.changes === 0) {
res.status(404).json({ error: "Gear not found" });
return;
}
const io: Server = req.app.get("io");
broadcastToCampaign(io, Number(character.campaign_id), "gear:removed", {
characterId: Number(id),
gearId: Number(gearId),
});
res.status(204).end();
});
// POST /api/characters/:id/talents — add talent
router.post("/:id/talents", (req, res) => {
const { id } = req.params;
const { name, description, effect } = req.body;
if (!name || !name.trim()) {
res.status(400).json({ error: "Talent name is required" });
return;
}
const result = db
.prepare(
"INSERT INTO character_talents (character_id, name, description, effect) VALUES (?, ?, ?, ?)"
)
.run(id, name.trim(), description || "", JSON.stringify(effect || {}));
const talent = db
.prepare("SELECT * FROM character_talents WHERE id = ?")
.get(result.lastInsertRowid);
const character = db
.prepare("SELECT campaign_id FROM characters WHERE id = ?")
.get(id) as Record<string, unknown>;
const io: Server = req.app.get("io");
broadcastToCampaign(io, Number(character.campaign_id), "talent:added", {
characterId: Number(id),
talent,
});
res.status(201).json(talent);
});
// DELETE /api/characters/:id/talents/:talentId — remove talent
router.delete("/:id/talents/:talentId", (req, res) => {
const { id, talentId } = req.params;
const character = db
.prepare("SELECT campaign_id FROM characters WHERE id = ?")
.get(id) as Record<string, unknown>;
if (!character) {
res.status(404).json({ error: "Character not found" });
return;
}
const result = db
.prepare("DELETE FROM character_talents WHERE id = ? AND character_id = ?")
.run(talentId, id);
if (result.changes === 0) {
res.status(404).json({ error: "Talent not found" });
return;
}
const io: Server = req.app.get("io");
broadcastToCampaign(io, Number(character.campaign_id), "talent:removed", {
characterId: Number(id),
talentId: Number(talentId),
});
res.status(204).end();
});
export default router;
```
- [ ] **Step 2: Register character routes in server/src/index.ts**
Add these lines after the campaign routes registration:
```ts
import characterRoutes from "./routes/characters.js";
app.use("/api/campaigns/:campaignId/characters", characterRoutes);
app.use("/api/characters", characterRoutes);
```
The dual mount gives us:
- `/api/campaigns/:campaignId/characters` — list/create characters in a campaign
- `/api/characters/:id` — update/delete a specific character, manage stats/gear/talents
- [ ] **Step 3: Test character endpoints manually**
Run server and test:
```bash
cd /Users/aaron.wood/workspace/shadowdark/server && npx tsx src/index.ts &
# Create campaign first
curl -s -X POST http://localhost:3000/api/campaigns -H 'Content-Type: application/json' -d '{"name":"Test"}' | jq .
# Create character
curl -s -X POST http://localhost:3000/api/campaigns/1/characters -H 'Content-Type: application/json' -d '{"name":"Kira","class":"Thief","ancestry":"Elf","hp_max":4}' | jq .
# Update stat
curl -s -X PATCH http://localhost:3000/api/characters/1/stats/STR -H 'Content-Type: application/json' -d '{"value":16}' | jq .
# Add gear
curl -s -X POST http://localhost:3000/api/characters/1/gear -H 'Content-Type: application/json' -d '{"name":"Longsword +1","type":"weapon","slot_count":1,"properties":{"damage":"1d8","melee":true,"bonus":1}}' | jq .
# Add talent
curl -s -X POST http://localhost:3000/api/characters/1/talents -H 'Content-Type: application/json' -d '{"name":"Backstab","description":"Advantage on attacks from behind","effect":{"advantage":"melee_stealth"}}' | jq .
# List characters
curl -s http://localhost:3000/api/campaigns/1/characters | jq .
```
Expected: All return appropriate status codes and JSON responses. Characters include stats, gear, and talents arrays.
Kill server after testing.
---
### Task 6: Client Foundation — Types, API, Socket, Modifier Utils
**Files:**
- Create: `client/src/types.ts`
- Create: `client/src/api.ts`
- Create: `client/src/socket.ts`
- Create: `client/src/utils/modifiers.ts`
- [ ] **Step 1: Create client/src/types.ts**
```ts
export interface Campaign {
id: number;
name: string;
created_by: string;
created_at: string;
}
export interface Stat {
stat_name: string;
value: number;
}
export interface Gear {
id: number;
character_id: number;
name: string;
type: "weapon" | "armor" | "gear" | "spell";
slot_count: number;
properties: Record<string, unknown>;
}
export interface Talent {
id: number;
character_id: number;
name: string;
description: string;
effect: Record<string, unknown>;
}
export interface Character {
id: number;
campaign_id: number;
created_by: string;
name: string;
class: string;
ancestry: string;
level: number;
xp: number;
hp_current: number;
hp_max: number;
ac: number;
alignment: string;
title: string;
notes: string;
stats: Stat[];
gear: Gear[];
talents: Talent[];
}
```
- [ ] **Step 2: Create client/src/utils/modifiers.ts**
```ts
const MODIFIER_TABLE: [number, number][] = [
[3, -4],
[5, -3],
[7, -2],
[9, -1],
[11, 0],
[13, 1],
[15, 2],
[17, 3],
[18, 4],
];
export function getModifier(score: number): number {
for (const [max, mod] of MODIFIER_TABLE) {
if (score <= max) return mod;
}
return 4;
}
export function formatModifier(mod: number): string {
return mod >= 0 ? `+${mod}` : `${mod}`;
}
```
- [ ] **Step 3: Create client/src/socket.ts**
```ts
import { io } from "socket.io-client";
const socket = io("/", {
autoConnect: true,
reconnection: true,
});
export default socket;
```
- [ ] **Step 4: Create client/src/api.ts**
```ts
const BASE = "/api";
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
headers: { "Content-Type": "application/json" },
...options,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
if (res.status === 204) return undefined as T;
return res.json();
}
// Campaigns
export const getCampaigns = () => request<Campaign[]>("/campaigns");
export const createCampaign = (name: string) =>
request<Campaign>("/campaigns", {
method: "POST",
body: JSON.stringify({ name }),
});
export const deleteCampaign = (id: number) =>
request<void>(`/campaigns/${id}`, { method: "DELETE" });
// Characters
export const getCharacters = (campaignId: number) =>
request<Character[]>(`/campaigns/${campaignId}/characters`);
export const createCharacter = (
campaignId: number,
data: { name: string; class?: string; ancestry?: string; hp_max?: number }
) =>
request<Character>(`/campaigns/${campaignId}/characters`, {
method: "POST",
body: JSON.stringify(data),
});
export const updateCharacter = (id: number, data: Partial<Character>) =>
request<Character>(`/characters/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
});
export const deleteCharacter = (id: number) =>
request<void>(`/characters/${id}`, { method: "DELETE" });
// Stats
export const updateStat = (
characterId: number,
statName: string,
value: number
) =>
request<{ characterId: number; statName: string; value: number }>(
`/characters/${characterId}/stats/${statName}`,
{ method: "PATCH", body: JSON.stringify({ value }) }
);
// Gear
export const addGear = (
characterId: number,
data: {
name: string;
type?: string;
slot_count?: number;
properties?: Record<string, unknown>;
}
) =>
request<Gear>(`/characters/${characterId}/gear`, {
method: "POST",
body: JSON.stringify(data),
});
export const removeGear = (characterId: number, gearId: number) =>
request<void>(`/characters/${characterId}/gear/${gearId}`, {
method: "DELETE",
});
// Talents
export const addTalent = (
characterId: number,
data: { name: string; description?: string; effect?: Record<string, unknown> }
) =>
request<Talent>(`/characters/${characterId}/talents`, {
method: "POST",
body: JSON.stringify(data),
});
export const removeTalent = (characterId: number, talentId: number) =>
request<void>(`/characters/${characterId}/talents/${talentId}`, {
method: "DELETE",
});
import type { Campaign, Character, Gear, Talent } from "./types";
```
- [ ] **Step 5: Verify client builds**
Run:
```bash
cd /Users/aaron.wood/workspace/shadowdark/client && npx tsc --noEmit
```
Expected: No type errors (may warn about missing main.tsx, that's fine — created next task).
---
### Task 7: App Shell — Routing and Layout
**Files:**
- Create: `client/src/main.tsx`
- Create: `client/src/App.tsx`
- Create: `client/src/App.module.css`
- [ ] **Step 1: Create client/src/main.tsx**
```tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);
```
- [ ] **Step 2: Create client/src/App.module.css**
```css
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
background: #1a1a2e;
color: #e0e0e0;
min-height: 100vh;
}
.app {
max-width: 1400px;
margin: 0 auto;
padding: 1rem;
}
.header {
text-align: center;
padding: 1rem 0;
border-bottom: 1px solid #333;
margin-bottom: 1.5rem;
}
.header h1 {
font-size: 1.8rem;
color: #c9a84c;
font-variant: small-caps;
letter-spacing: 0.05em;
}
```
- [ ] **Step 3: Create client/src/App.tsx**
```tsx
import { BrowserRouter, Routes, Route } from "react-router-dom";
import CampaignList from "./pages/CampaignList";
import CampaignView from "./pages/CampaignView";
import styles from "./App.module.css";
export default function App() {
return (
<BrowserRouter>
<div className={styles.app}>
<header className={styles.header}>
<h1>Shadowdark</h1>
</header>
<Routes>
<Route path="/" element={<CampaignList />} />
<Route path="/campaign/:id" element={<CampaignView />} />
</Routes>
</div>
</BrowserRouter>
);
}
```
- [ ] **Step 4: Verify the app compiles**
Run:
```bash
cd /Users/aaron.wood/workspace/shadowdark/client && npx vite build
```
Expected: Build succeeds (may warn about missing page components — they're in the next tasks).
---
### Task 8: Campaign List Page
**Files:**
- Create: `client/src/pages/CampaignList.tsx`
- Create: `client/src/pages/CampaignList.module.css`
- [ ] **Step 1: Create client/src/pages/CampaignList.module.css**
```css
.container {
max-width: 600px;
margin: 0 auto;
}
.campaignGrid {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.campaignCard {
display: flex;
align-items: center;
justify-content: space-between;
background: #16213e;
border: 1px solid #333;
border-radius: 8px;
padding: 1rem 1.25rem;
cursor: pointer;
transition: border-color 0.15s;
}
.campaignCard:hover {
border-color: #c9a84c;
}
.campaignName {
font-size: 1.1rem;
font-weight: 600;
}
.campaignDate {
font-size: 0.8rem;
color: #888;
}
.deleteBtn {
background: none;
border: none;
color: #666;
cursor: pointer;
font-size: 1.2rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.deleteBtn:hover {
color: #e74c3c;
background: rgba(231, 76, 60, 0.1);
}
.createForm {
display: flex;
gap: 0.5rem;
}
.createInput {
flex: 1;
padding: 0.6rem 1rem;
background: #16213e;
border: 1px solid #333;
border-radius: 8px;
color: #e0e0e0;
font-size: 1rem;
}
.createInput:focus {
outline: none;
border-color: #c9a84c;
}
.createBtn {
padding: 0.6rem 1.25rem;
background: #c9a84c;
color: #1a1a2e;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
font-size: 1rem;
}
.createBtn:hover {
background: #d4b65a;
}
.empty {
text-align: center;
color: #666;
padding: 3rem 0;
font-style: italic;
}
```
- [ ] **Step 2: Create client/src/pages/CampaignList.tsx**
```tsx
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getCampaigns, createCampaign, deleteCampaign } from "../api";
import type { Campaign } from "../types";
import styles from "./CampaignList.module.css";
export default function CampaignList() {
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [newName, setNewName] = useState("");
const navigate = useNavigate();
useEffect(() => {
getCampaigns().then(setCampaigns);
}, []);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!newName.trim()) return;
try {
const campaign = await createCampaign(newName.trim());
setCampaigns((prev) => [campaign, ...prev]);
setNewName("");
} catch (err) {
console.error("Failed to create campaign:", err);
}
}
async function handleDelete(e: React.MouseEvent, id: number) {
e.stopPropagation();
try {
await deleteCampaign(id);
setCampaigns((prev) => prev.filter((c) => c.id !== id));
} catch (err) {
console.error("Failed to delete campaign:", err);
}
}
return (
<div className={styles.container}>
<form className={styles.createForm} onSubmit={handleCreate}>
<input
className={styles.createInput}
type="text"
placeholder="New campaign name..."
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<button className={styles.createBtn} type="submit">
+ Create
</button>
</form>
<div className={styles.campaignGrid}>
{campaigns.length === 0 && (
<p className={styles.empty}>No campaigns yet. Create one above!</p>
)}
{campaigns.map((c) => (
<div
key={c.id}
className={styles.campaignCard}
onClick={() => navigate(`/campaign/${c.id}`)}
>
<div>
<div className={styles.campaignName}>{c.name}</div>
<div className={styles.campaignDate}>
{new Date(c.created_at).toLocaleDateString()}
</div>
</div>
<button
className={styles.deleteBtn}
onClick={(e) => handleDelete(e, c.id)}
title="Delete campaign"
>
</button>
</div>
))}
</div>
</div>
);
}
```
---
### Task 9: HpBar and StatBlock Components
**Files:**
- Create: `client/src/components/HpBar.tsx`
- Create: `client/src/components/HpBar.module.css`
- Create: `client/src/components/StatBlock.tsx`
- Create: `client/src/components/StatBlock.module.css`
- [ ] **Step 1: Create client/src/components/HpBar.module.css**
```css
.hpBar {
display: flex;
align-items: center;
gap: 0.5rem;
}
.label {
font-size: 0.75rem;
color: #888;
text-transform: uppercase;
font-weight: 600;
}
.values {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 1.1rem;
font-weight: 700;
}
.current {
color: #4caf50;
}
.current.hurt {
color: #ff9800;
}
.current.critical {
color: #e74c3c;
}
.slash {
color: #666;
}
.max {
color: #888;
}
.btn {
width: 24px;
height: 24px;
border-radius: 50%;
border: 1px solid #444;
background: #16213e;
color: #e0e0e0;
cursor: pointer;
font-size: 0.9rem;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.btn:hover {
border-color: #c9a84c;
color: #c9a84c;
}
```
- [ ] **Step 2: Create client/src/components/HpBar.tsx**
```tsx
import styles from "./HpBar.module.css";
interface HpBarProps {
current: number;
max: number;
onChange: (current: number) => void;
}
export default function HpBar({ current, max, onChange }: HpBarProps) {
const ratio = max > 0 ? current / max : 1;
const colorClass =
ratio <= 0.25 ? styles.critical : ratio <= 0.5 ? styles.hurt : "";
return (
<div className={styles.hpBar}>
<span className={styles.label}>HP</span>
<button className={styles.btn} onClick={() => onChange(current - 1)}>
</button>
<span className={styles.values}>
<span className={`${styles.current} ${colorClass}`}>{current}</span>
<span className={styles.slash}>/</span>
<span className={styles.max}>{max}</span>
</span>
<button className={styles.btn} onClick={() => onChange(current + 1)}>
+
</button>
</div>
);
}
```
- [ ] **Step 3: Create client/src/components/StatBlock.module.css**
```css
.statGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.4rem;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
background: #0f1a30;
border-radius: 6px;
padding: 0.3rem;
}
.statName {
font-size: 0.65rem;
color: #888;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.statRow {
display: flex;
align-items: center;
gap: 0.25rem;
}
.modifier {
font-size: 1rem;
font-weight: 700;
color: #c9a84c;
min-width: 2rem;
text-align: center;
}
.score {
font-size: 0.7rem;
color: #666;
}
.btn {
width: 20px;
height: 20px;
border-radius: 50%;
border: 1px solid #444;
background: #16213e;
color: #e0e0e0;
cursor: pointer;
font-size: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.btn:hover {
border-color: #c9a84c;
color: #c9a84c;
}
```
- [ ] **Step 4: Create client/src/components/StatBlock.tsx**
```tsx
import type { Stat } from "../types";
import { getModifier, formatModifier } from "../utils/modifiers";
import styles from "./StatBlock.module.css";
const STAT_ORDER = ["STR", "DEX", "CON", "INT", "WIS", "CHA"];
interface StatBlockProps {
stats: Stat[];
onStatChange: (statName: string, newValue: number) => void;
}
export default function StatBlock({ stats, onStatChange }: StatBlockProps) {
const statMap = new Map(stats.map((s) => [s.stat_name, s.value]));
return (
<div className={styles.statGrid}>
{STAT_ORDER.map((name) => {
const value = statMap.get(name) ?? 10;
const mod = getModifier(value);
return (
<div key={name} className={styles.stat}>
<span className={styles.statName}>{name}</span>
<div className={styles.statRow}>
<button
className={styles.btn}
onClick={() => onStatChange(name, value - 1)}
>
</button>
<span className={styles.modifier}>{formatModifier(mod)}</span>
<button
className={styles.btn}
onClick={() => onStatChange(name, value + 1)}
>
+
</button>
</div>
<span className={styles.score}>{value}</span>
</div>
);
})}
</div>
);
}
```
---
### Task 10: CharacterCard Component
**Files:**
- Create: `client/src/components/CharacterCard.tsx`
- Create: `client/src/components/CharacterCard.module.css`
- [ ] **Step 1: Create client/src/components/CharacterCard.module.css**
```css
.card {
background: #16213e;
border: 1px solid #333;
border-radius: 10px;
padding: 1rem;
cursor: pointer;
transition: border-color 0.15s, transform 0.1s;
}
.card:hover {
border-color: #c9a84c;
transform: translateY(-2px);
}
.cardHeader {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.5rem;
}
.name {
font-size: 1.1rem;
font-weight: 700;
color: #e0e0e0;
}
.level {
font-size: 0.8rem;
color: #888;
}
.meta {
font-size: 0.8rem;
color: #666;
margin-bottom: 0.75rem;
}
.hpAcRow {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.ac {
display: flex;
align-items: center;
gap: 0.3rem;
font-weight: 700;
}
.acLabel {
font-size: 0.75rem;
color: #888;
text-transform: uppercase;
font-weight: 600;
}
.acValue {
font-size: 1.1rem;
color: #5dade2;
}
.gearSummary {
font-size: 0.75rem;
color: #666;
text-align: right;
margin-top: 0.5rem;
}
```
- [ ] **Step 2: Create client/src/components/CharacterCard.tsx**
```tsx
import type { Character } from "../types";
import HpBar from "./HpBar";
import StatBlock from "./StatBlock";
import styles from "./CharacterCard.module.css";
interface CharacterCardProps {
character: Character;
onHpChange: (characterId: number, hp: number) => void;
onStatChange: (characterId: number, statName: string, value: number) => void;
onClick: (characterId: number) => void;
}
export default function CharacterCard({
character,
onHpChange,
onStatChange,
onClick,
}: CharacterCardProps) {
const totalSlots = character.gear.reduce((sum, g) => sum + g.slot_count, 0);
return (
<div className={styles.card} onClick={() => onClick(character.id)}>
<div className={styles.cardHeader}>
<span className={styles.name}>
{character.name}
{character.title ? ` ${character.title}` : ""}
</span>
<span className={styles.level}>Lvl {character.level}</span>
</div>
<div className={styles.meta}>
{character.ancestry} {character.class}
</div>
<div className={styles.hpAcRow} onClick={(e) => e.stopPropagation()}>
<HpBar
current={character.hp_current}
max={character.hp_max}
onChange={(hp) => onHpChange(character.id, hp)}
/>
<div className={styles.ac}>
<span className={styles.acLabel}>AC</span>
<span className={styles.acValue}>{character.ac}</span>
</div>
</div>
<div onClick={(e) => e.stopPropagation()}>
<StatBlock
stats={character.stats}
onStatChange={(statName, value) =>
onStatChange(character.id, statName, value)
}
/>
</div>
<div className={styles.gearSummary}>
{totalSlots} gear slot{totalSlots !== 1 ? "s" : ""} used
</div>
</div>
);
}
```
---
### Task 11: GearList and TalentList Components
**Files:**
- Create: `client/src/components/GearList.tsx`
- Create: `client/src/components/GearList.module.css`
- Create: `client/src/components/TalentList.tsx`
- Create: `client/src/components/TalentList.module.css`
- [ ] **Step 1: Create client/src/components/GearList.module.css**
```css
.section {
margin-bottom: 1rem;
}
.sectionHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.sectionTitle {
font-size: 0.9rem;
font-weight: 700;
color: #c9a84c;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.item {
display: flex;
justify-content: space-between;
align-items: center;
background: #0f1a30;
border-radius: 6px;
padding: 0.5rem 0.75rem;
}
.itemInfo {
flex: 1;
}
.itemName {
font-weight: 600;
font-size: 0.9rem;
}
.itemMeta {
font-size: 0.75rem;
color: #888;
}
.removeBtn {
background: none;
border: none;
color: #666;
cursor: pointer;
font-size: 1rem;
padding: 0.25rem;
}
.removeBtn:hover {
color: #e74c3c;
}
.addForm {
display: flex;
gap: 0.4rem;
margin-top: 0.5rem;
}
.addInput {
flex: 1;
padding: 0.4rem 0.6rem;
background: #0f1a30;
border: 1px solid #333;
border-radius: 6px;
color: #e0e0e0;
font-size: 0.85rem;
}
.addInput:focus {
outline: none;
border-color: #c9a84c;
}
.addSelect {
padding: 0.4rem 0.6rem;
background: #0f1a30;
border: 1px solid #333;
border-radius: 6px;
color: #e0e0e0;
font-size: 0.85rem;
}
.addBtn {
padding: 0.4rem 0.75rem;
background: #c9a84c;
color: #1a1a2e;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-size: 0.85rem;
}
.addBtn:hover {
background: #d4b65a;
}
.empty {
font-size: 0.8rem;
color: #555;
font-style: italic;
padding: 0.5rem 0;
}
```
- [ ] **Step 2: Create client/src/components/GearList.tsx**
```tsx
import { useState } from "react";
import type { Gear } from "../types";
import styles from "./GearList.module.css";
interface GearListProps {
gear: Gear[];
onAdd: (data: { name: string; type: string; slot_count: number }) => void;
onRemove: (gearId: number) => void;
}
export default function GearList({ gear, onAdd, onRemove }: GearListProps) {
const [name, setName] = useState("");
const [type, setType] = useState("gear");
function handleAdd(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) return;
onAdd({ name: name.trim(), type, slot_count: 1 });
setName("");
}
function formatGearMeta(item: Gear): string {
const parts = [item.type];
if (item.slot_count !== 1) parts.push(`${item.slot_count} slots`);
const props = item.properties as Record<string, unknown>;
if (props.damage) parts.push(String(props.damage));
if (props.bonus) parts.push(`+${props.bonus}`);
return parts.join(" · ");
}
return (
<div className={styles.section}>
<div className={styles.sectionHeader}>
<span className={styles.sectionTitle}>Gear & Inventory</span>
</div>
<div className={styles.list}>
{gear.length === 0 && <p className={styles.empty}>No gear yet</p>}
{gear.map((item) => (
<div key={item.id} className={styles.item}>
<div className={styles.itemInfo}>
<div className={styles.itemName}>{item.name}</div>
<div className={styles.itemMeta}>{formatGearMeta(item)}</div>
</div>
<button
className={styles.removeBtn}
onClick={() => onRemove(item.id)}
title="Remove"
>
</button>
</div>
))}
</div>
<form className={styles.addForm} onSubmit={handleAdd}>
<input
className={styles.addInput}
type="text"
placeholder="Item name..."
value={name}
onChange={(e) => setName(e.target.value)}
/>
<select
className={styles.addSelect}
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="weapon">Weapon</option>
<option value="armor">Armor</option>
<option value="gear">Gear</option>
<option value="spell">Spell</option>
</select>
<button className={styles.addBtn} type="submit">
+ Add
</button>
</form>
</div>
);
}
```
- [ ] **Step 3: Create client/src/components/TalentList.module.css**
```css
.section {
margin-bottom: 1rem;
}
.sectionHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.sectionTitle {
font-size: 0.9rem;
font-weight: 700;
color: #c9a84c;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.item {
display: flex;
justify-content: space-between;
align-items: flex-start;
background: #0f1a30;
border-radius: 6px;
padding: 0.5rem 0.75rem;
}
.itemInfo {
flex: 1;
}
.itemName {
font-weight: 600;
font-size: 0.9rem;
}
.itemDesc {
font-size: 0.75rem;
color: #888;
}
.removeBtn {
background: none;
border: none;
color: #666;
cursor: pointer;
font-size: 1rem;
padding: 0.25rem;
}
.removeBtn:hover {
color: #e74c3c;
}
.addForm {
display: flex;
gap: 0.4rem;
margin-top: 0.5rem;
}
.addInput {
flex: 1;
padding: 0.4rem 0.6rem;
background: #0f1a30;
border: 1px solid #333;
border-radius: 6px;
color: #e0e0e0;
font-size: 0.85rem;
}
.addInput:focus {
outline: none;
border-color: #c9a84c;
}
.addBtn {
padding: 0.4rem 0.75rem;
background: #c9a84c;
color: #1a1a2e;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-size: 0.85rem;
}
.addBtn:hover {
background: #d4b65a;
}
.empty {
font-size: 0.8rem;
color: #555;
font-style: italic;
padding: 0.5rem 0;
}
```
- [ ] **Step 4: Create client/src/components/TalentList.tsx**
```tsx
import { useState } from "react";
import type { Talent } from "../types";
import styles from "./TalentList.module.css";
interface TalentListProps {
talents: Talent[];
onAdd: (data: { name: string; description: string }) => void;
onRemove: (talentId: number) => void;
}
export default function TalentList({
talents,
onAdd,
onRemove,
}: TalentListProps) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
function handleAdd(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) return;
onAdd({ name: name.trim(), description: description.trim() });
setName("");
setDescription("");
}
return (
<div className={styles.section}>
<div className={styles.sectionHeader}>
<span className={styles.sectionTitle}>Talents</span>
</div>
<div className={styles.list}>
{talents.length === 0 && <p className={styles.empty}>No talents yet</p>}
{talents.map((t) => (
<div key={t.id} className={styles.item}>
<div className={styles.itemInfo}>
<div className={styles.itemName}>{t.name}</div>
{t.description && (
<div className={styles.itemDesc}>{t.description}</div>
)}
</div>
<button
className={styles.removeBtn}
onClick={() => onRemove(t.id)}
title="Remove"
>
</button>
</div>
))}
</div>
<form className={styles.addForm} onSubmit={handleAdd}>
<input
className={styles.addInput}
type="text"
placeholder="Talent name..."
value={name}
onChange={(e) => setName(e.target.value)}
/>
<input
className={styles.addInput}
type="text"
placeholder="Description (optional)..."
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<button className={styles.addBtn} type="submit">
+ Add
</button>
</form>
</div>
);
}
```
---
### Task 12: CharacterDetail Modal
**Files:**
- Create: `client/src/components/CharacterDetail.tsx`
- Create: `client/src/components/CharacterDetail.module.css`
- [ ] **Step 1: Create client/src/components/CharacterDetail.module.css**
```css
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 1rem;
}
.modal {
background: #1a1a2e;
border: 1px solid #333;
border-radius: 12px;
width: 100%;
max-width: 700px;
max-height: 90vh;
overflow-y: auto;
padding: 1.5rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.nameSection {
flex: 1;
}
.name {
font-size: 1.5rem;
font-weight: 700;
}
.meta {
color: #888;
font-size: 0.9rem;
margin-top: 0.25rem;
}
.closeBtn {
background: none;
border: none;
color: #888;
font-size: 1.5rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
}
.closeBtn:hover {
color: #e0e0e0;
}
.fieldGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.fieldLabel {
font-size: 0.75rem;
color: #888;
text-transform: uppercase;
font-weight: 600;
}
.fieldInput {
padding: 0.4rem 0.6rem;
background: #0f1a30;
border: 1px solid #333;
border-radius: 6px;
color: #e0e0e0;
font-size: 0.9rem;
}
.fieldInput:focus {
outline: none;
border-color: #c9a84c;
}
.fieldSelect {
padding: 0.4rem 0.6rem;
background: #0f1a30;
border: 1px solid #333;
border-radius: 6px;
color: #e0e0e0;
font-size: 0.9rem;
}
.statsSection {
margin-bottom: 1.25rem;
}
.statsSectionTitle {
font-size: 0.9rem;
font-weight: 700;
color: #c9a84c;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.notesField {
width: 100%;
min-height: 80px;
padding: 0.6rem;
background: #0f1a30;
border: 1px solid #333;
border-radius: 6px;
color: #e0e0e0;
font-size: 0.9rem;
font-family: inherit;
resize: vertical;
}
.notesField:focus {
outline: none;
border-color: #c9a84c;
}
.deleteSection {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #333;
}
.deleteBtn {
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid #e74c3c;
border-radius: 6px;
color: #e74c3c;
cursor: pointer;
font-size: 0.85rem;
}
.deleteBtn:hover {
background: rgba(231, 76, 60, 0.1);
}
```
- [ ] **Step 2: Create client/src/components/CharacterDetail.tsx**
```tsx
import { useState } from "react";
import type { Character } from "../types";
import StatBlock from "./StatBlock";
import GearList from "./GearList";
import TalentList from "./TalentList";
import styles from "./CharacterDetail.module.css";
const CLASSES = ["Fighter", "Priest", "Thief", "Wizard"];
const ANCESTRIES = ["Human", "Elf", "Dwarf", "Halfling", "Goblin", "Half-Orc"];
const ALIGNMENTS = ["Lawful", "Neutral", "Chaotic"];
interface CharacterDetailProps {
character: Character;
onUpdate: (id: number, data: Partial<Character>) => void;
onStatChange: (characterId: number, statName: string, value: number) => void;
onAddGear: (
characterId: number,
data: { name: string; type: string; slot_count: number }
) => void;
onRemoveGear: (characterId: number, gearId: number) => void;
onAddTalent: (
characterId: number,
data: { name: string; description: string }
) => void;
onRemoveTalent: (characterId: number, talentId: number) => void;
onDelete: (id: number) => void;
onClose: () => void;
}
export default function CharacterDetail({
character,
onUpdate,
onStatChange,
onAddGear,
onRemoveGear,
onAddTalent,
onRemoveTalent,
onDelete,
onClose,
}: CharacterDetailProps) {
const [confirmDelete, setConfirmDelete] = useState(false);
function handleFieldChange(field: string, value: string | number) {
onUpdate(character.id, { [field]: value });
}
return (
<div className={styles.overlay} onClick={onClose}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<div className={styles.header}>
<div className={styles.nameSection}>
<div className={styles.name}>{character.name}</div>
<div className={styles.meta}>
Level {character.level} {character.ancestry} {character.class}
</div>
</div>
<button className={styles.closeBtn} onClick={onClose}>
</button>
</div>
<div className={styles.fieldGrid}>
<div className={styles.field}>
<label className={styles.fieldLabel}>Name</label>
<input
className={styles.fieldInput}
value={character.name}
onChange={(e) => handleFieldChange("name", e.target.value)}
/>
</div>
<div className={styles.field}>
<label className={styles.fieldLabel}>Title</label>
<input
className={styles.fieldInput}
value={character.title}
placeholder="the Brave..."
onChange={(e) => handleFieldChange("title", e.target.value)}
/>
</div>
<div className={styles.field}>
<label className={styles.fieldLabel}>Class</label>
<select
className={styles.fieldSelect}
value={character.class}
onChange={(e) => handleFieldChange("class", e.target.value)}
>
{CLASSES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
<div className={styles.field}>
<label className={styles.fieldLabel}>Ancestry</label>
<select
className={styles.fieldSelect}
value={character.ancestry}
onChange={(e) => handleFieldChange("ancestry", e.target.value)}
>
{ANCESTRIES.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</div>
<div className={styles.field}>
<label className={styles.fieldLabel}>Level</label>
<input
className={styles.fieldInput}
type="number"
min={0}
value={character.level}
onChange={(e) =>
handleFieldChange("level", Number(e.target.value))
}
/>
</div>
<div className={styles.field}>
<label className={styles.fieldLabel}>XP</label>
<input
className={styles.fieldInput}
type="number"
min={0}
value={character.xp}
onChange={(e) => handleFieldChange("xp", Number(e.target.value))}
/>
</div>
<div className={styles.field}>
<label className={styles.fieldLabel}>HP Max</label>
<input
className={styles.fieldInput}
type="number"
min={0}
value={character.hp_max}
onChange={(e) =>
handleFieldChange("hp_max", Number(e.target.value))
}
/>
</div>
<div className={styles.field}>
<label className={styles.fieldLabel}>AC</label>
<input
className={styles.fieldInput}
type="number"
value={character.ac}
onChange={(e) => handleFieldChange("ac", Number(e.target.value))}
/>
</div>
<div className={styles.field}>
<label className={styles.fieldLabel}>Alignment</label>
<select
className={styles.fieldSelect}
value={character.alignment}
onChange={(e) => handleFieldChange("alignment", e.target.value)}
>
{ALIGNMENTS.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</div>
</div>
<div className={styles.statsSection}>
<div className={styles.statsSectionTitle}>Ability Scores</div>
<StatBlock
stats={character.stats}
onStatChange={(statName, value) =>
onStatChange(character.id, statName, value)
}
/>
</div>
<GearList
gear={character.gear}
onAdd={(data) => onAddGear(character.id, data)}
onRemove={(gearId) => onRemoveGear(character.id, gearId)}
/>
<TalentList
talents={character.talents}
onAdd={(data) => onAddTalent(character.id, data)}
onRemove={(talentId) => onRemoveTalent(character.id, talentId)}
/>
<div className={styles.field}>
<label className={styles.fieldLabel}>Notes</label>
<textarea
className={styles.notesField}
value={character.notes}
onChange={(e) => handleFieldChange("notes", e.target.value)}
placeholder="Freeform notes..."
/>
</div>
<div className={styles.deleteSection}>
{confirmDelete ? (
<div>
<span>Delete {character.name}? </span>
<button
className={styles.deleteBtn}
onClick={() => onDelete(character.id)}
>
Yes, delete
</button> <button
className={styles.deleteBtn}
onClick={() => setConfirmDelete(false)}
>
Cancel
</button>
</div>
) : (
<button
className={styles.deleteBtn}
onClick={() => setConfirmDelete(true)}
>
Delete Character
</button>
)}
</div>
</div>
</div>
);
}
```
---
### Task 13: CampaignView Page with Real-time Sync
**Files:**
- Create: `client/src/pages/CampaignView.tsx`
- Create: `client/src/pages/CampaignView.module.css`
- [ ] **Step 1: Create client/src/pages/CampaignView.module.css**
```css
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.backLink {
color: #888;
text-decoration: none;
font-size: 0.9rem;
}
.backLink:hover {
color: #c9a84c;
}
.campaignName {
font-size: 1.5rem;
font-weight: 700;
color: #c9a84c;
}
.addBtn {
padding: 0.5rem 1rem;
background: #c9a84c;
color: #1a1a2e;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
font-size: 0.9rem;
}
.addBtn:hover {
background: #d4b65a;
}
.grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
}
@media (max-width: 1100px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
.grid {
grid-template-columns: 1fr;
}
}
.empty {
text-align: center;
color: #666;
padding: 3rem 0;
font-style: italic;
grid-column: 1 / -1;
}
.createModal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 1rem;
}
.createForm {
background: #1a1a2e;
border: 1px solid #333;
border-radius: 12px;
padding: 1.5rem;
width: 100%;
max-width: 400px;
}
.createTitle {
font-size: 1.2rem;
font-weight: 700;
margin-bottom: 1rem;
color: #c9a84c;
}
.formField {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.75rem;
}
.formLabel {
font-size: 0.75rem;
color: #888;
text-transform: uppercase;
font-weight: 600;
}
.formInput {
padding: 0.5rem 0.75rem;
background: #0f1a30;
border: 1px solid #333;
border-radius: 6px;
color: #e0e0e0;
font-size: 0.9rem;
}
.formInput:focus {
outline: none;
border-color: #c9a84c;
}
.formSelect {
padding: 0.5rem 0.75rem;
background: #0f1a30;
border: 1px solid #333;
border-radius: 6px;
color: #e0e0e0;
font-size: 0.9rem;
}
.formActions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1rem;
}
.formBtn {
padding: 0.5rem 1rem;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-size: 0.9rem;
}
.formBtnPrimary {
background: #c9a84c;
color: #1a1a2e;
border: none;
}
.formBtnPrimary:hover {
background: #d4b65a;
}
.formBtnSecondary {
background: transparent;
color: #888;
border: 1px solid #333;
}
.formBtnSecondary:hover {
border-color: #888;
color: #e0e0e0;
}
```
- [ ] **Step 2: Create client/src/pages/CampaignView.tsx**
```tsx
import { useEffect, useState } from "react";
import { useParams, Link } from "react-router-dom";
import socket from "../socket";
import {
getCharacters,
createCharacter,
updateCharacter,
deleteCharacter,
updateStat,
addGear,
removeGear,
addTalent,
removeTalent,
} from "../api";
import type { Character, Gear, Talent } from "../types";
import CharacterCard from "../components/CharacterCard";
import CharacterDetail from "../components/CharacterDetail";
import styles from "./CampaignView.module.css";
const CLASSES = ["Fighter", "Priest", "Thief", "Wizard"];
const ANCESTRIES = ["Human", "Elf", "Dwarf", "Halfling", "Goblin", "Half-Orc"];
export default function CampaignView() {
const { id } = useParams<{ id: string }>();
const campaignId = Number(id);
const [characters, setCharacters] = useState<Character[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [newChar, setNewChar] = useState({
name: "",
class: "Fighter",
ancestry: "Human",
hp_max: 1,
});
// Fetch characters and join socket room
useEffect(() => {
getCharacters(campaignId).then(setCharacters);
socket.emit("join-campaign", String(campaignId));
return () => {
socket.emit("leave-campaign", String(campaignId));
};
}, [campaignId]);
// Socket event listeners
useEffect(() => {
function onCharacterCreated(char: Character) {
setCharacters((prev) => {
if (prev.some((c) => c.id === char.id)) return prev;
return [...prev, char];
});
}
function onCharacterUpdated(data: Partial<Character> & { id: number }) {
setCharacters((prev) =>
prev.map((c) => (c.id === data.id ? { ...c, ...data } : c))
);
}
function onCharacterDeleted({ id }: { id: number }) {
setCharacters((prev) => prev.filter((c) => c.id !== id));
setSelectedId((prev) => (prev === id ? null : prev));
}
function onStatUpdated({
characterId,
statName,
value,
}: {
characterId: number;
statName: string;
value: number;
}) {
setCharacters((prev) =>
prev.map((c) =>
c.id === characterId
? {
...c,
stats: c.stats.map((s) =>
s.stat_name === statName ? { ...s, value } : s
),
}
: c
)
);
}
function onGearAdded({
characterId,
gear,
}: {
characterId: number;
gear: Gear;
}) {
setCharacters((prev) =>
prev.map((c) =>
c.id === characterId
? { ...c, gear: [...c.gear.filter((g) => g.id !== gear.id), gear] }
: c
)
);
}
function onGearRemoved({
characterId,
gearId,
}: {
characterId: number;
gearId: number;
}) {
setCharacters((prev) =>
prev.map((c) =>
c.id === characterId
? { ...c, gear: c.gear.filter((g) => g.id !== gearId) }
: c
)
);
}
function onTalentAdded({
characterId,
talent,
}: {
characterId: number;
talent: Talent;
}) {
setCharacters((prev) =>
prev.map((c) =>
c.id === characterId
? {
...c,
talents: [
...c.talents.filter((t) => t.id !== talent.id),
talent,
],
}
: c
)
);
}
function onTalentRemoved({
characterId,
talentId,
}: {
characterId: number;
talentId: number;
}) {
setCharacters((prev) =>
prev.map((c) =>
c.id === characterId
? { ...c, talents: c.talents.filter((t) => t.id !== talentId) }
: c
)
);
}
socket.on("character:created", onCharacterCreated);
socket.on("character:updated", onCharacterUpdated);
socket.on("character:deleted", onCharacterDeleted);
socket.on("stat:updated", onStatUpdated);
socket.on("gear:added", onGearAdded);
socket.on("gear:removed", onGearRemoved);
socket.on("talent:added", onTalentAdded);
socket.on("talent:removed", onTalentRemoved);
return () => {
socket.off("character:created", onCharacterCreated);
socket.off("character:updated", onCharacterUpdated);
socket.off("character:deleted", onCharacterDeleted);
socket.off("stat:updated", onStatUpdated);
socket.off("gear:added", onGearAdded);
socket.off("gear:removed", onGearRemoved);
socket.off("talent:added", onTalentAdded);
socket.off("talent:removed", onTalentRemoved);
};
}, []);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!newChar.name.trim()) return;
try {
await createCharacter(campaignId, newChar);
setNewChar({ name: "", class: "Fighter", ancestry: "Human", hp_max: 1 });
setShowCreate(false);
} catch (err) {
console.error("Failed to create character:", err);
}
}
async function handleHpChange(characterId: number, hp: number) {
await updateCharacter(characterId, { hp_current: hp });
}
async function handleStatChange(
characterId: number,
statName: string,
value: number
) {
await updateStat(characterId, statName, value);
}
async function handleUpdate(characterId: number, data: Partial<Character>) {
await updateCharacter(characterId, data);
}
async function handleDelete(characterId: number) {
await deleteCharacter(characterId);
setSelectedId(null);
}
async function handleAddGear(
characterId: number,
data: { name: string; type: string; slot_count: number }
) {
await addGear(characterId, data);
}
async function handleRemoveGear(characterId: number, gearId: number) {
await removeGear(characterId, gearId);
}
async function handleAddTalent(
characterId: number,
data: { name: string; description: string }
) {
await addTalent(characterId, data);
}
async function handleRemoveTalent(characterId: number, talentId: number) {
await removeTalent(characterId, talentId);
}
const selectedCharacter = characters.find((c) => c.id === selectedId) ?? null;
return (
<div>
<div className={styles.header}>
<Link to="/" className={styles.backLink}>
Campaigns
</Link>
<span className={styles.campaignName}>Campaign</span>
<button className={styles.addBtn} onClick={() => setShowCreate(true)}>
+ Add Character
</button>
</div>
<div className={styles.grid}>
{characters.length === 0 && (
<p className={styles.empty}>
No characters yet. Add one to get started!
</p>
)}
{characters.map((char) => (
<CharacterCard
key={char.id}
character={char}
onHpChange={handleHpChange}
onStatChange={handleStatChange}
onClick={setSelectedId}
/>
))}
</div>
{selectedCharacter && (
<CharacterDetail
character={selectedCharacter}
onUpdate={handleUpdate}
onStatChange={handleStatChange}
onAddGear={handleAddGear}
onRemoveGear={handleRemoveGear}
onAddTalent={handleAddTalent}
onRemoveTalent={handleRemoveTalent}
onDelete={handleDelete}
onClose={() => setSelectedId(null)}
/>
)}
{showCreate && (
<div
className={styles.createModal}
onClick={() => setShowCreate(false)}
>
<form
className={styles.createForm}
onClick={(e) => e.stopPropagation()}
onSubmit={handleCreate}
>
<div className={styles.createTitle}>New Character</div>
<div className={styles.formField}>
<label className={styles.formLabel}>Name</label>
<input
className={styles.formInput}
type="text"
value={newChar.name}
onChange={(e) =>
setNewChar({ ...newChar, name: e.target.value })
}
autoFocus
/>
</div>
<div className={styles.formField}>
<label className={styles.formLabel}>Class</label>
<select
className={styles.formSelect}
value={newChar.class}
onChange={(e) =>
setNewChar({ ...newChar, class: e.target.value })
}
>
{CLASSES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
<div className={styles.formField}>
<label className={styles.formLabel}>Ancestry</label>
<select
className={styles.formSelect}
value={newChar.ancestry}
onChange={(e) =>
setNewChar({ ...newChar, ancestry: e.target.value })
}
>
{ANCESTRIES.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</div>
<div className={styles.formField}>
<label className={styles.formLabel}>Max HP</label>
<input
className={styles.formInput}
type="number"
min={1}
value={newChar.hp_max}
onChange={(e) =>
setNewChar({ ...newChar, hp_max: Number(e.target.value) })
}
/>
</div>
<div className={styles.formActions}>
<button
type="button"
className={`${styles.formBtn} ${styles.formBtnSecondary}`}
onClick={() => setShowCreate(false)}
>
Cancel
</button>
<button
type="submit"
className={`${styles.formBtn} ${styles.formBtnPrimary}`}
>
Create
</button>
</div>
</form>
</div>
)}
</div>
);
}
```
---
### Task 14: End-to-End Smoke Test
**Files:** None (testing only)
- [ ] **Step 1: Start the full stack**
Run:
```bash
cd /Users/aaron.wood/workspace/shadowdark && npm run dev
```
Expected: Both server (port 3000) and client (port 5173) start. Console shows both running.
- [ ] **Step 2: Test the full flow in browser**
Open `http://localhost:5173` in a browser.
1. Create a campaign — should appear in the list
2. Click into it — should see empty character grid
3. Click "+ Add Character" — fill in name, class, ancestry, HP
4. Character card appears in the grid with stats, HP bar, AC
5. Click +/- on HP — updates immediately
6. Click +/- on stats — modifier updates
7. Click card to open detail modal — edit name, add gear, add talent
8. Close modal — changes reflected on card
- [ ] **Step 3: Test real-time sync**
Open a second browser tab to the same campaign URL.
1. Change a stat in tab 1 — should update in tab 2 within a second
2. Add a character in tab 2 — should appear in tab 1
3. Delete a character in tab 1 — should disappear from tab 2
- [ ] **Step 4: Test responsive layout**
Resize the browser window:
- Wide (>1100px): 4 cards across
- Medium (600-1100px): 2 cards across
- Narrow (<600px): 1 card, stacked
---
### Task 15: Debounce Character Detail Field Updates
**Files:**
- Modify: `client/src/components/CharacterDetail.tsx`
Text fields in the detail modal (name, title, notes) currently fire an API call on every keystroke. Add a simple debounce so they only save after the user stops typing.
- [ ] **Step 1: Add a useDebounce helper inline in CharacterDetail.tsx**
Add this above the component function:
```tsx
function useDebouncedCallback<T extends (...args: unknown[]) => void>(
callback: T,
delay: number
): T {
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
return ((...args: unknown[]) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => callback(...args), delay);
}) as T;
}
```
Add `useRef, useEffect` to the react import.
- [ ] **Step 2: Use debounced callback for text fields**
Inside the `CharacterDetail` component, add:
```tsx
const debouncedUpdate = useDebouncedCallback(
(field: string, value: string | number) => {
onUpdate(character.id, { [field]: value });
},
400
);
```
Update `handleFieldChange` to use debounce for string fields and immediate for numbers:
```tsx
function handleFieldChange(field: string, value: string | number) {
// Optimistic local update handled by parent via socket
if (typeof value === "string") {
debouncedUpdate(field, value);
} else {
onUpdate(character.id, { [field]: value });
}
}
```
- [ ] **Step 3: Verify debounce works**
Open a character detail, type in the name field. Network tab should show a single PATCH request ~400ms after you stop typing, not one per keystroke.