61 KiB
V2: Item Database + Derived Stats — 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: Add predefined item database, auto-calculated AC/attacks, manual override system, missing character fields, multi-column detail layout, and gear slot visualization.
Architecture: Server-side schema migrations add new columns and a game_items reference table seeded with Shadowdark core items. A new /api/game-items endpoint serves the item list. Client-side derives AC and attacks from character state (gear effects + stats + overrides). The CharacterDetail modal is redesigned as a multi-column layout. GearList gets a searchable predefined item picker.
Tech Stack: Same as v1 — React 18, Vite, TypeScript, Node/Express, Socket.IO, better-sqlite3, CSS Modules
File Structure — New and Modified Files
server/src/
├── db.ts # MODIFY: add game_items table, alter characters + character_gear
├── seed-items.ts # CREATE: static seed data for game_items
├── routes/
│ ├── characters.ts # MODIFY: add new fields to allowedFields, add effects to gear responses
│ └── game-items.ts # CREATE: GET /api/game-items endpoint
client/src/
├── types.ts # MODIFY: update Character, Gear interfaces; add GameItem, AttackLine
├── api.ts # MODIFY: add getGameItems(), add effects to addGear()
├── utils/
│ ├── modifiers.ts # (unchanged)
│ ├── derived-ac.ts # CREATE: calculateAC() from gear + stats + overrides
│ └── derived-attacks.ts # CREATE: generateAttacks() from weapons + stats + talents
├── components/
│ ├── CharacterCard.tsx # MODIFY: show XP threshold, derived AC
│ ├── CharacterCard.module.css # MODIFY: XP display
│ ├── CharacterDetail.tsx # REWRITE: multi-column layout with all new fields
│ ├── CharacterDetail.module.css # REWRITE: 3-column responsive grid
│ ├── GearList.tsx # REWRITE: predefined item picker, slot visualization, currency
│ ├── GearList.module.css # REWRITE: table layout, slot counter, currency row
│ ├── AttackBlock.tsx # CREATE: auto-generated attack lines
│ ├── AttackBlock.module.css # CREATE: attack styling with roll button space
│ ├── AcDisplay.tsx # CREATE: AC with override indicator
│ ├── AcDisplay.module.css # CREATE: override indicator styling
│ ├── ItemPicker.tsx # CREATE: searchable dropdown for predefined items
│ ├── ItemPicker.module.css # CREATE: dropdown styling
│ ├── CurrencyRow.tsx # CREATE: GP/SP/CP compact row
│ └── CurrencyRow.module.css # CREATE: currency styling
└── pages/
└── CampaignView.tsx # MODIFY: pass new props, handle overrides
Task 1: Schema Migration — game_items Table + Seed Data
Files:
-
Modify:
server/src/db.ts -
Create:
server/src/seed-items.ts -
Step 1: Create server/src/seed-items.ts with all Shadowdark core items
export interface SeedItem {
name: string;
type: "weapon" | "armor" | "gear";
slot_count: number;
effects: Record<string, unknown>;
properties: Record<string, unknown>;
}
export const SEED_ITEMS: SeedItem[] = [
// --- Weapons ---
{
name: "Bastard sword",
type: "weapon",
slot_count: 1,
effects: { damage: "1d8", melee: true, stat: "STR", versatile: "1d10" },
properties: { tags: ["versatile"] },
},
{
name: "Club",
type: "weapon",
slot_count: 1,
effects: { damage: "1d4", melee: true, stat: "STR" },
properties: {},
},
{
name: "Crossbow",
type: "weapon",
slot_count: 1,
effects: { damage: "1d6", ranged: true, stat: "DEX", range: "far" },
properties: { tags: ["loading"] },
},
{
name: "Dagger",
type: "weapon",
slot_count: 1,
effects: {
damage: "1d4",
melee: true,
stat: "STR",
finesse: true,
thrown: true,
range: "close",
},
properties: { tags: ["finesse", "thrown"] },
},
{
name: "Greataxe",
type: "weapon",
slot_count: 1,
effects: { damage: "1d10", melee: true, stat: "STR", two_handed: true },
properties: { tags: ["two-handed"] },
},
{
name: "Greatsword",
type: "weapon",
slot_count: 1,
effects: { damage: "2d6", melee: true, stat: "STR", two_handed: true },
properties: { tags: ["two-handed"] },
},
{
name: "Javelin",
type: "weapon",
slot_count: 1,
effects: {
damage: "1d4",
melee: true,
stat: "STR",
thrown: true,
range: "far",
},
properties: { tags: ["thrown"] },
},
{
name: "Longbow",
type: "weapon",
slot_count: 1,
effects: {
damage: "1d8",
ranged: true,
stat: "DEX",
range: "far",
two_handed: true,
},
properties: { tags: ["two-handed"] },
},
{
name: "Longsword",
type: "weapon",
slot_count: 1,
effects: { damage: "1d8", melee: true, stat: "STR" },
properties: {},
},
{
name: "Mace",
type: "weapon",
slot_count: 1,
effects: { damage: "1d6", melee: true, stat: "STR" },
properties: {},
},
{
name: "Shortbow",
type: "weapon",
slot_count: 1,
effects: {
damage: "1d4",
ranged: true,
stat: "DEX",
range: "far",
two_handed: true,
},
properties: { tags: ["two-handed"] },
},
{
name: "Shortsword",
type: "weapon",
slot_count: 1,
effects: { damage: "1d6", melee: true, stat: "STR" },
properties: {},
},
{
name: "Spear",
type: "weapon",
slot_count: 1,
effects: {
damage: "1d6",
melee: true,
stat: "STR",
thrown: true,
range: "close",
},
properties: { tags: ["thrown"] },
},
{
name: "Staff",
type: "weapon",
slot_count: 1,
effects: { damage: "1d4", melee: true, stat: "STR", two_handed: true },
properties: { tags: ["two-handed"] },
},
{
name: "Warhammer",
type: "weapon",
slot_count: 1,
effects: { damage: "1d10", melee: true, stat: "STR", two_handed: true },
properties: { tags: ["two-handed"] },
},
// --- Armor ---
{
name: "Leather armor",
type: "armor",
slot_count: 1,
effects: { ac_base: 11, ac_dex: true },
properties: {},
},
{
name: "Chainmail",
type: "armor",
slot_count: 1,
effects: { ac_base: 13, ac_dex: true },
properties: { note: "Disadvantage on stealth and swimming" },
},
{
name: "Plate mail",
type: "armor",
slot_count: 1,
effects: { ac_base: 15, ac_dex: false },
properties: { note: "Disadvantage on stealth, swimming, and climbing" },
},
{
name: "Shield",
type: "armor",
slot_count: 1,
effects: { ac_bonus: 2 },
properties: {},
},
{
name: "Mithral chainmail",
type: "armor",
slot_count: 1,
effects: { ac_base: 13, ac_dex: true },
properties: { note: "No disadvantage" },
},
// --- Gear ---
{
name: "Arrows/bolts (20)",
type: "gear",
slot_count: 1,
effects: {},
properties: {},
},
{
name: "Backpack",
type: "gear",
slot_count: 0,
effects: {},
properties: {},
},
{
name: "Caltrops",
type: "gear",
slot_count: 1,
effects: {},
properties: {},
},
{
name: "Climbing gear",
type: "gear",
slot_count: 1,
effects: {},
properties: {},
},
{ name: "Crowbar", type: "gear", slot_count: 1, effects: {}, properties: {} },
{
name: "Flask/bottle",
type: "gear",
slot_count: 1,
effects: {},
properties: {},
},
{
name: "Flint and steel",
type: "gear",
slot_count: 1,
effects: {},
properties: {},
},
{
name: "Grappling hook",
type: "gear",
slot_count: 1,
effects: {},
properties: {},
},
{
name: "Iron spikes (10)",
type: "gear",
slot_count: 1,
effects: {},
properties: {},
},
{ name: "Lantern", type: "gear", slot_count: 1, effects: {}, properties: {} },
{ name: "Mirror", type: "gear", slot_count: 1, effects: {}, properties: {} },
{
name: "Oil flask",
type: "gear",
slot_count: 1,
effects: {},
properties: {},
},
{ name: "Rations", type: "gear", slot_count: 1, effects: {}, properties: {} },
{
name: "Rope (60ft)",
type: "gear",
slot_count: 1,
effects: {},
properties: {},
},
{
name: "Thieves' tools",
type: "gear",
slot_count: 1,
effects: {},
properties: {},
},
{ name: "Torch", type: "gear", slot_count: 1, effects: {}, properties: {} },
];
- Step 2: Add game_items table and schema changes to server/src/db.ts
Add to the db.exec() call, after the existing CREATE TABLE statements:
CREATE TABLE IF NOT EXISTS game_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL,
slot_count INTEGER NOT NULL DEFAULT 1,
effects TEXT DEFAULT '{}',
properties TEXT DEFAULT '{}'
);
After the db.exec() block, add the migration for existing tables and the seed logic:
// --- Migrations for v2 ---
const v2Columns: Array<[string, string, string]> = [
["characters", "background", "TEXT DEFAULT ''"],
["characters", "deity", "TEXT DEFAULT ''"],
["characters", "languages", "TEXT DEFAULT ''"],
["characters", "gp", "INTEGER DEFAULT 0"],
["characters", "sp", "INTEGER DEFAULT 0"],
["characters", "cp", "INTEGER DEFAULT 0"],
["characters", "gear_slots_max", "INTEGER DEFAULT 10"],
["characters", "overrides", "TEXT DEFAULT '{}'"],
["character_gear", "game_item_id", "INTEGER"],
["character_gear", "effects", "TEXT DEFAULT '{}'"],
];
for (const [table, column, definition] of v2Columns) {
try {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
} catch {
// Column already exists
}
}
// Seed game_items if empty
import { SEED_ITEMS } from "./seed-items.js";
const count = (
db.prepare("SELECT COUNT(*) as c FROM game_items").get() as { c: number }
).c;
if (count === 0) {
const insert = db.prepare(
"INSERT INTO game_items (name, type, slot_count, effects, properties) VALUES (?, ?, ?, ?, ?)",
);
for (const item of SEED_ITEMS) {
insert.run(
item.name,
item.type,
item.slot_count,
JSON.stringify(item.effects),
JSON.stringify(item.properties),
);
}
}
- Step 3: Verify schema migration runs
rm -f /Users/aaron.wood/workspace/shadowdark/server/data/shadowdark.db
cd /Users/aaron.wood/workspace/shadowdark/server && npx tsx src/db.ts
Then verify tables exist and items are seeded:
cd /Users/aaron.wood/workspace/shadowdark/server && npx tsx -e "
import db from './src/db.js';
console.log('game_items:', db.prepare('SELECT COUNT(*) as c FROM game_items').get());
console.log('sample:', db.prepare('SELECT * FROM game_items WHERE type = \"armor\"').all());
const cols = db.prepare('PRAGMA table_info(characters)').all();
console.log('character cols:', cols.map((c: any) => c.name));
const gearCols = db.prepare('PRAGMA table_info(character_gear)').all();
console.log('gear cols:', gearCols.map((c: any) => c.name));
"
Expected: 36 game_items, 5 armor items with parsed effects, characters table has all new columns, character_gear has game_item_id and effects.
Task 2: Game Items API Endpoint
Files:
-
Create:
server/src/routes/game-items.ts -
Modify:
server/src/index.ts -
Step 1: Create server/src/routes/game-items.ts
import { Router } from "express";
import db from "../db.js";
const router = Router();
router.get("/", (_req, res) => {
const items = db
.prepare("SELECT * FROM game_items ORDER BY type, name")
.all() as Array<Record<string, unknown>>;
const parsed = items.map((item) => ({
...item,
effects: JSON.parse(item.effects as string),
properties: JSON.parse(item.properties as string),
}));
res.json(parsed);
});
export default router;
- Step 2: Register route in server/src/index.ts
Add after existing route registrations:
import gameItemRoutes from "./routes/game-items.js";
app.use("/api/game-items", gameItemRoutes);
- Step 3: Update character gear route to accept effects and game_item_id
In server/src/routes/characters.ts, update the POST /:id/gear handler. Change the destructuring and INSERT to include the new fields:
// POST /api/characters/:id/gear — add gear
router.post("/:id/gear", (req, res) => {
const { id } = req.params;
const { name, type, slot_count, properties, effects, game_item_id } =
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, effects, game_item_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.run(
id,
name.trim(),
type || "gear",
slot_count ?? 1,
JSON.stringify(properties || {}),
JSON.stringify(effects || {}),
game_item_id ?? null,
);
const gearRow = db
.prepare("SELECT * FROM character_gear WHERE id = ?")
.get(result.lastInsertRowid) as Record<string, unknown>;
const gear = {
...gearRow,
properties: parseJson(gearRow.properties),
effects: parseJson(gearRow.effects),
};
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);
});
- Step 4: Update parseGear to include effects
In server/src/routes/characters.ts, update the parseGear function:
function parseGear(rows: Array<Record<string, unknown>>) {
return rows.map((r) => ({
...r,
properties: parseJson(r.properties),
effects: parseJson(r.effects),
}));
}
- Step 5: Add new character fields to allowedFields in PATCH route
In server/src/routes/characters.ts, update the allowedFields array in the PATCH /:id handler:
const allowedFields = [
"name",
"class",
"ancestry",
"level",
"xp",
"hp_current",
"hp_max",
"ac",
"alignment",
"title",
"notes",
"background",
"deity",
"languages",
"gp",
"sp",
"cp",
"gear_slots_max",
"overrides",
];
- Step 6: Test the game-items endpoint
cd /Users/aaron.wood/workspace/shadowdark/server && npx tsx src/index.ts &
curl -s http://localhost:3000/api/game-items | python3 -c "import sys,json; items=json.load(sys.stdin); print(f'{len(items)} items'); print(json.dumps(items[0], indent=2))"
Expected: 36 items, first item has parsed effects and properties objects.
Kill server after testing.
Task 3: Client Types + Derived Stat Utilities
Files:
-
Modify:
client/src/types.ts -
Create:
client/src/utils/derived-ac.ts -
Create:
client/src/utils/derived-attacks.ts -
Step 1: Update client/src/types.ts
Replace entire file:
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>;
effects: Record<string, unknown>;
game_item_id: number | null;
}
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;
background: string;
deity: string;
languages: string;
gp: number;
sp: number;
cp: number;
gear_slots_max: number;
overrides: Record<string, unknown>;
stats: Stat[];
gear: Gear[];
talents: Talent[];
}
export interface GameItem {
id: number;
name: string;
type: "weapon" | "armor" | "gear";
slot_count: number;
effects: Record<string, unknown>;
properties: Record<string, unknown>;
}
export interface AttackLine {
name: string;
modifier: number;
modifierStr: string;
damage: string;
tags: string[];
isTalent: boolean;
description?: string;
}
- Step 2: Create client/src/utils/derived-ac.ts
import type { Character } from "../types";
import { getModifier } from "./modifiers";
export interface AcBreakdown {
calculated: number;
override: number | null;
effective: number;
source: string;
}
export function calculateAC(character: Character): AcBreakdown {
const dexMod = getModifier(
character.stats.find((s) => s.stat_name === "DEX")?.value ?? 10,
);
let base = 10 + dexMod;
let source = "Unarmored";
// Find equipped armor (not shields)
const armor = character.gear.find(
(g) => g.type === "armor" && g.effects.ac_base !== undefined,
);
if (armor) {
const acBase = armor.effects.ac_base as number;
const acDex = armor.effects.ac_dex as boolean;
base = acDex ? acBase + dexMod : acBase;
source = armor.name;
}
// Find shield
const shield = character.gear.find(
(g) => g.type === "armor" && g.effects.ac_bonus !== undefined,
);
if (shield) {
base += shield.effects.ac_bonus as number;
source += " + " + shield.name;
}
const override = (character.overrides?.ac as number | undefined) ?? null;
return {
calculated: base,
override: override,
effective: override ?? base,
source,
};
}
- Step 3: Create client/src/utils/derived-attacks.ts
import type { Character, AttackLine } from "../types";
import { getModifier, formatModifier } from "./modifiers";
export function generateAttacks(character: Character): AttackLine[] {
const strMod = getModifier(
character.stats.find((s) => s.stat_name === "STR")?.value ?? 10,
);
const dexMod = getModifier(
character.stats.find((s) => s.stat_name === "DEX")?.value ?? 10,
);
const attacks: AttackLine[] = [];
// Generate attack lines from weapons
for (const gear of character.gear) {
if (gear.type !== "weapon") continue;
const effects = gear.effects;
const damage = (effects.damage as string) || "1d4";
const tags: string[] = [];
let mod: number;
if (effects.finesse) {
mod = Math.max(strMod, dexMod);
tags.push("F");
} else if (effects.ranged) {
mod = dexMod;
} else {
mod = strMod;
}
// Add gear-specific bonus
const bonus = (effects.bonus as number) || 0;
mod += bonus;
if (effects.two_handed) tags.push("2H");
if (effects.thrown) tags.push("T");
attacks.push({
name: gear.name.toUpperCase(),
modifier: mod,
modifierStr: formatModifier(mod),
damage,
tags,
isTalent: false,
});
}
// Add attack-relevant talents
for (const talent of character.talents) {
const effect = talent.effect;
if (effect.attack || effect.damage_bonus || effect.advantage) {
attacks.push({
name: talent.name,
modifier: 0,
modifierStr: "",
damage: "",
tags: [],
isTalent: true,
description: talent.description,
});
}
}
return attacks;
}
- Step 4: Verify TypeScript compiles
cd /Users/aaron.wood/workspace/shadowdark/client && npx tsc --noEmit
Expected: Type errors in existing components that reference old Character interface (missing new fields). These will be fixed in subsequent tasks. For now, just verify the utility files themselves are correct — check with:
cd /Users/aaron.wood/workspace/shadowdark/client && npx tsc --noEmit src/utils/derived-ac.ts src/utils/derived-attacks.ts src/types.ts 2>&1 | head -5
Task 4: Client API + Update addGear to Include Effects
Files:
-
Modify:
client/src/api.ts -
Step 1: Add getGameItems and update addGear signature
Add to client/src/api.ts:
import type { Campaign, Character, Gear, Talent, GameItem } from "./types";
Add after the talents section:
// Game Items
export const getGameItems = () => request<GameItem[]>("/game-items");
Update the addGear function to accept effects and game_item_id:
export const addGear = (
characterId: number,
data: {
name: string;
type?: string;
slot_count?: number;
properties?: Record<string, unknown>;
effects?: Record<string, unknown>;
game_item_id?: number | null;
},
) =>
request<Gear>(`/characters/${characterId}/gear`, {
method: "POST",
body: JSON.stringify(data),
});
Task 5: AttackBlock + AcDisplay + CurrencyRow + ItemPicker Components
Files:
-
Create:
client/src/components/AttackBlock.tsx -
Create:
client/src/components/AttackBlock.module.css -
Create:
client/src/components/AcDisplay.tsx -
Create:
client/src/components/AcDisplay.module.css -
Create:
client/src/components/CurrencyRow.tsx -
Create:
client/src/components/CurrencyRow.module.css -
Create:
client/src/components/ItemPicker.tsx -
Create:
client/src/components/ItemPicker.module.css -
Step 1: Create AttackBlock.module.css
.section {
margin-top: 1rem;
}
.title {
font-size: 0.9rem;
font-weight: 700;
color: #c9a84c;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.list {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.line {
display: flex;
justify-content: space-between;
align-items: center;
background: #0f1a30;
border-radius: 6px;
padding: 0.35rem 0.6rem;
font-size: 0.85rem;
}
.weaponName {
font-weight: 700;
text-transform: uppercase;
color: #e0e0e0;
}
.stats {
color: #888;
}
.modifier {
color: #c9a84c;
font-weight: 600;
}
.damage {
color: #e0e0e0;
}
.tags {
font-size: 0.7rem;
color: #666;
margin-left: 0.3rem;
}
.talentLine {
font-style: italic;
color: #888;
font-size: 0.8rem;
padding: 0.25rem 0.6rem;
}
.rollSpace {
width: 2.5rem;
text-align: center;
color: #444;
font-size: 0.75rem;
}
.empty {
font-size: 0.8rem;
color: #555;
font-style: italic;
}
- Step 2: Create AttackBlock.tsx
import type { AttackLine } from "../types";
import styles from "./AttackBlock.module.css";
interface AttackBlockProps {
attacks: AttackLine[];
}
export default function AttackBlock({ attacks }: AttackBlockProps) {
const weapons = attacks.filter((a) => !a.isTalent);
const talents = attacks.filter((a) => a.isTalent);
return (
<div className={styles.section}>
<div className={styles.title}>Attacks</div>
<div className={styles.list}>
{weapons.length === 0 && talents.length === 0 && (
<span className={styles.empty}>No weapons equipped</span>
)}
{weapons.map((atk) => (
<div key={atk.name} className={styles.line}>
<span>
<span className={styles.weaponName}>{atk.name}</span>
{atk.tags.length > 0 && (
<span className={styles.tags}>({atk.tags.join(", ")})</span>
)}
</span>
<span className={styles.stats}>
<span className={styles.modifier}>{atk.modifierStr}</span>
{", "}
<span className={styles.damage}>{atk.damage}</span>
</span>
<span className={styles.rollSpace}></span>
</div>
))}
{talents.map((atk) => (
<div key={atk.name} className={styles.talentLine}>
<strong>{atk.name}:</strong> {atk.description}
</div>
))}
</div>
</div>
);
}
- Step 3: Create AcDisplay.module.css
.container {
display: flex;
align-items: center;
gap: 0.5rem;
}
.label {
font-size: 0.75rem;
color: #888;
text-transform: uppercase;
font-weight: 600;
}
.value {
font-size: 1.4rem;
font-weight: 700;
color: #5dade2;
cursor: pointer;
min-width: 2rem;
text-align: center;
}
.value.overridden {
color: #c9a84c;
}
.source {
font-size: 0.7rem;
color: #666;
}
.override {
display: flex;
align-items: center;
gap: 0.3rem;
}
.overrideIndicator {
font-size: 0.65rem;
color: #c9a84c;
cursor: pointer;
background: rgba(201, 168, 76, 0.15);
border: none;
border-radius: 3px;
padding: 0.1rem 0.3rem;
}
.overrideIndicator:hover {
background: rgba(201, 168, 76, 0.3);
}
.calculatedHint {
font-size: 0.65rem;
color: #555;
}
.editInput {
width: 3rem;
padding: 0.2rem 0.3rem;
background: #0f1a30;
border: 1px solid #c9a84c;
border-radius: 4px;
color: #e0e0e0;
font-size: 1.2rem;
font-weight: 700;
text-align: center;
}
- Step 4: Create AcDisplay.tsx
import { useState } from "react";
import type { AcBreakdown } from "../utils/derived-ac";
import styles from "./AcDisplay.module.css";
interface AcDisplayProps {
breakdown: AcBreakdown;
onOverride: (value: number | null) => void;
}
export default function AcDisplay({ breakdown, onOverride }: AcDisplayProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState("");
const isOverridden = breakdown.override !== null;
function startEdit() {
setEditValue(String(breakdown.effective));
setEditing(true);
}
function commitEdit() {
setEditing(false);
const num = parseInt(editValue, 10);
if (!isNaN(num) && num !== breakdown.calculated) {
onOverride(num);
} else if (num === breakdown.calculated) {
onOverride(null);
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter") commitEdit();
if (e.key === "Escape") setEditing(false);
}
return (
<div className={styles.container}>
<span className={styles.label}>AC</span>
{editing ? (
<input
className={styles.editInput}
type="number"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={handleKeyDown}
autoFocus
/>
) : (
<span
className={`${styles.value} ${isOverridden ? styles.overridden : ""}`}
onClick={startEdit}
title="Click to override"
>
{breakdown.effective}
</span>
)}
<div>
<div className={styles.source}>{breakdown.source}</div>
{isOverridden && (
<div className={styles.override}>
<span className={styles.calculatedHint}>
auto: {breakdown.calculated}
</span>
<button
className={styles.overrideIndicator}
onClick={() => onOverride(null)}
title="Revert to auto-calculated"
>
revert
</button>
</div>
)}
</div>
</div>
);
}
- Step 5: Create CurrencyRow.module.css
.row {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 0;
}
.coin {
display: flex;
align-items: center;
gap: 0.3rem;
}
.coinLabel {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.coinLabel.gp {
color: #c9a84c;
}
.coinLabel.sp {
color: #a0a0a0;
}
.coinLabel.cp {
color: #b87333;
}
.coinInput {
width: 3.5rem;
padding: 0.25rem 0.4rem;
background: #0f1a30;
border: 1px solid #333;
border-radius: 4px;
color: #e0e0e0;
font-size: 0.85rem;
text-align: center;
}
.coinInput:focus {
outline: none;
border-color: #c9a84c;
}
- Step 6: Create CurrencyRow.tsx
import styles from "./CurrencyRow.module.css";
interface CurrencyRowProps {
gp: number;
sp: number;
cp: number;
onChange: (field: "gp" | "sp" | "cp", value: number) => void;
}
export default function CurrencyRow({
gp,
sp,
cp,
onChange,
}: CurrencyRowProps) {
return (
<div className={styles.row}>
<div className={styles.coin}>
<span className={`${styles.coinLabel} ${styles.gp}`}>GP</span>
<input
className={styles.coinInput}
type="number"
min={0}
value={gp}
onChange={(e) => onChange("gp", Number(e.target.value))}
/>
</div>
<div className={styles.coin}>
<span className={`${styles.coinLabel} ${styles.sp}`}>SP</span>
<input
className={styles.coinInput}
type="number"
min={0}
value={sp}
onChange={(e) => onChange("sp", Number(e.target.value))}
/>
</div>
<div className={styles.coin}>
<span className={`${styles.coinLabel} ${styles.cp}`}>CP</span>
<input
className={styles.coinInput}
type="number"
min={0}
value={cp}
onChange={(e) => onChange("cp", Number(e.target.value))}
/>
</div>
</div>
);
}
- Step 7: Create ItemPicker.module.css
.container {
position: relative;
}
.searchInput {
width: 100%;
padding: 0.5rem 0.75rem;
background: #0f1a30;
border: 1px solid #333;
border-radius: 6px;
color: #e0e0e0;
font-size: 0.85rem;
}
.searchInput:focus {
outline: none;
border-color: #c9a84c;
}
.dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
max-height: 250px;
overflow-y: auto;
background: #16213e;
border: 1px solid #444;
border-radius: 6px;
margin-top: 0.25rem;
z-index: 50;
}
.group {
padding: 0.25rem 0;
}
.groupLabel {
font-size: 0.7rem;
font-weight: 700;
color: #c9a84c;
text-transform: uppercase;
padding: 0.25rem 0.75rem;
letter-spacing: 0.05em;
}
.item {
display: flex;
justify-content: space-between;
padding: 0.35rem 0.75rem;
cursor: pointer;
font-size: 0.85rem;
}
.item:hover {
background: rgba(201, 168, 76, 0.15);
}
.itemName {
color: #e0e0e0;
}
.itemMeta {
color: #666;
font-size: 0.75rem;
}
.customOption {
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: 0.85rem;
color: #888;
font-style: italic;
border-top: 1px solid #333;
}
.customOption:hover {
background: rgba(201, 168, 76, 0.15);
color: #c9a84c;
}
- Step 8: Create ItemPicker.tsx
import { useState, useEffect, useRef } from "react";
import { getGameItems } from "../api";
import type { GameItem } from "../types";
import styles from "./ItemPicker.module.css";
interface ItemPickerProps {
onSelect: (item: GameItem) => void;
onCustom: () => void;
onClose: () => void;
}
export default function ItemPicker({
onSelect,
onCustom,
onClose,
}: ItemPickerProps) {
const [items, setItems] = useState<GameItem[]>([]);
const [search, setSearch] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
getGameItems().then(setItems);
}, []);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
onClose();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
const filtered = search
? items.filter((i) => i.name.toLowerCase().includes(search.toLowerCase()))
: items;
const groups: Record<string, GameItem[]> = {};
for (const item of filtered) {
const key = item.type.charAt(0).toUpperCase() + item.type.slice(1) + "s";
if (!groups[key]) groups[key] = [];
groups[key].push(item);
}
return (
<div className={styles.container} ref={containerRef}>
<input
className={styles.searchInput}
type="text"
placeholder="Search items or type to filter..."
value={search}
onChange={(e) => setSearch(e.target.value)}
autoFocus
/>
<div className={styles.dropdown}>
{Object.entries(groups).map(([groupName, groupItems]) => (
<div key={groupName} className={styles.group}>
<div className={styles.groupLabel}>{groupName}</div>
{groupItems.map((item) => (
<div
key={item.id}
className={styles.item}
onClick={() => onSelect(item)}
>
<span className={styles.itemName}>{item.name}</span>
<span className={styles.itemMeta}>
{item.slot_count > 0 ? `${item.slot_count} slot` : "—"}
</span>
</div>
))}
</div>
))}
<div className={styles.customOption} onClick={onCustom}>
Custom item...
</div>
</div>
</div>
);
}
- Step 9: Verify TypeScript compiles
cd /Users/aaron.wood/workspace/shadowdark/client && npx tsc --noEmit
May still have errors in existing components (CharacterDetail, GearList, CampaignView) due to the new Character fields — those are fixed in subsequent tasks.
Task 6: Rewrite GearList with ItemPicker + Slot Visualization
Files:
-
Rewrite:
client/src/components/GearList.tsx -
Rewrite:
client/src/components/GearList.module.css -
Step 1: Rewrite GearList.module.css
.section {
margin-top: 0.75rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.title {
font-size: 0.9rem;
font-weight: 700;
color: #c9a84c;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.slotCounter {
font-size: 0.8rem;
font-weight: 600;
}
.slotCounter.normal {
color: #4caf50;
}
.slotCounter.warning {
color: #ff9800;
}
.slotCounter.over {
color: #e74c3c;
}
.table {
width: 100%;
border-collapse: collapse;
}
.tableHeader {
font-size: 0.7rem;
color: #666;
text-transform: uppercase;
font-weight: 600;
text-align: left;
padding: 0.25rem 0.5rem;
border-bottom: 1px solid #333;
}
.tableHeader.right {
text-align: right;
}
.tableHeader.center {
text-align: center;
}
.row {
border-bottom: 1px solid #222;
}
.row:hover {
background: rgba(201, 168, 76, 0.05);
}
.cell {
padding: 0.35rem 0.5rem;
font-size: 0.85rem;
vertical-align: middle;
}
.cell.center {
text-align: center;
}
.cell.right {
text-align: right;
}
.itemName {
font-weight: 600;
}
.typeBadge {
font-size: 0.65rem;
padding: 0.1rem 0.4rem;
border-radius: 3px;
text-transform: uppercase;
font-weight: 600;
}
.typeBadge.weapon {
background: rgba(231, 76, 60, 0.2);
color: #e74c3c;
}
.typeBadge.armor {
background: rgba(93, 173, 226, 0.2);
color: #5dade2;
}
.typeBadge.gear {
background: rgba(200, 200, 200, 0.15);
color: #888;
}
.typeBadge.spell {
background: rgba(155, 89, 182, 0.2);
color: #9b59b6;
}
.removeBtn {
background: none;
border: none;
color: #555;
cursor: pointer;
font-size: 0.9rem;
padding: 0.1rem 0.3rem;
}
.removeBtn:hover {
color: #e74c3c;
}
.addArea {
margin-top: 0.5rem;
}
.addBtn {
padding: 0.4rem 0.75rem;
background: #c9a84c;
color: #1a1a2e;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-size: 0.8rem;
}
.addBtn:hover {
background: #d4b65a;
}
.customForm {
display: flex;
gap: 0.4rem;
margin-top: 0.5rem;
}
.customInput {
flex: 1;
padding: 0.4rem 0.6rem;
background: #0f1a30;
border: 1px solid #333;
border-radius: 6px;
color: #e0e0e0;
font-size: 0.85rem;
}
.customInput:focus {
outline: none;
border-color: #c9a84c;
}
.customSelect {
padding: 0.4rem 0.6rem;
background: #0f1a30;
border: 1px solid #333;
border-radius: 6px;
color: #e0e0e0;
font-size: 0.85rem;
}
.empty {
font-size: 0.8rem;
color: #555;
font-style: italic;
padding: 0.5rem;
}
- Step 2: Rewrite GearList.tsx
import { useState } from "react";
import type { Gear, GameItem } from "../types";
import ItemPicker from "./ItemPicker";
import CurrencyRow from "./CurrencyRow";
import styles from "./GearList.module.css";
interface GearListProps {
gear: Gear[];
gp: number;
sp: number;
cp: number;
slotsUsed: number;
slotsMax: number;
onAddFromItem: (item: GameItem) => void;
onAddCustom: (data: {
name: string;
type: string;
slot_count: number;
}) => void;
onRemove: (gearId: number) => void;
onCurrencyChange: (field: "gp" | "sp" | "cp", value: number) => void;
}
export default function GearList({
gear,
gp,
sp,
cp,
slotsUsed,
slotsMax,
onAddFromItem,
onAddCustom,
onRemove,
onCurrencyChange,
}: GearListProps) {
const [showPicker, setShowPicker] = useState(false);
const [showCustom, setShowCustom] = useState(false);
const [customName, setCustomName] = useState("");
const [customType, setCustomType] = useState("gear");
function handleCustomAdd(e: React.FormEvent) {
e.preventDefault();
if (!customName.trim()) return;
onAddCustom({ name: customName.trim(), type: customType, slot_count: 1 });
setCustomName("");
setShowCustom(false);
}
function handleItemSelect(item: GameItem) {
onAddFromItem(item);
setShowPicker(false);
}
const slotClass =
slotsUsed >= slotsMax
? styles.over
: slotsUsed >= slotsMax - 2
? styles.warning
: styles.normal;
return (
<div className={styles.section}>
<div className={styles.header}>
<span className={styles.title}>Gear & Inventory</span>
<span className={`${styles.slotCounter} ${slotClass}`}>
Slots: {slotsUsed} / {slotsMax}
</span>
</div>
{gear.length === 0 ? (
<p className={styles.empty}>No gear yet</p>
) : (
<table className={styles.table}>
<thead>
<tr>
<th className={styles.tableHeader}>Item</th>
<th className={`${styles.tableHeader} ${styles.center}`}>Type</th>
<th className={`${styles.tableHeader} ${styles.center}`}>
Slots
</th>
<th className={`${styles.tableHeader} ${styles.right}`}></th>
</tr>
</thead>
<tbody>
{gear.map((item) => (
<tr key={item.id} className={styles.row}>
<td className={styles.cell}>
<span className={styles.itemName}>{item.name}</span>
</td>
<td className={`${styles.cell} ${styles.center}`}>
<span className={`${styles.typeBadge} ${styles[item.type]}`}>
{item.type}
</span>
</td>
<td className={`${styles.cell} ${styles.center}`}>
{item.slot_count > 0 ? item.slot_count : "—"}
</td>
<td className={`${styles.cell} ${styles.right}`}>
<button
className={styles.removeBtn}
onClick={() => onRemove(item.id)}
title="Remove"
>
✕
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
<CurrencyRow gp={gp} sp={sp} cp={cp} onChange={onCurrencyChange} />
<div className={styles.addArea}>
{showPicker ? (
<ItemPicker
onSelect={handleItemSelect}
onCustom={() => {
setShowPicker(false);
setShowCustom(true);
}}
onClose={() => setShowPicker(false)}
/>
) : showCustom ? (
<form className={styles.customForm} onSubmit={handleCustomAdd}>
<input
className={styles.customInput}
type="text"
placeholder="Item name..."
value={customName}
onChange={(e) => setCustomName(e.target.value)}
autoFocus
/>
<select
className={styles.customSelect}
value={customType}
onChange={(e) => setCustomType(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>
<button
className={styles.addBtn}
type="button"
onClick={() => setShowCustom(false)}
style={{ background: "#333", color: "#888" }}
>
Cancel
</button>
</form>
) : (
<button className={styles.addBtn} onClick={() => setShowPicker(true)}>
+ Add Gear
</button>
)}
</div>
</div>
);
}
Task 7: Rewrite CharacterDetail — Multi-Column Layout
Files:
-
Rewrite:
client/src/components/CharacterDetail.tsx -
Rewrite:
client/src/components/CharacterDetail.module.css -
Step 1: Rewrite CharacterDetail.module.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: 1100px;
max-height: 95vh;
overflow-y: auto;
padding: 1.5rem;
}
.topBar {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.nameBlock {
flex: 1;
}
.name {
font-size: 1.5rem;
font-weight: 700;
}
.subtitle {
color: #888;
font-size: 0.9rem;
margin-top: 0.2rem;
}
.closeBtn {
background: none;
border: none;
color: #888;
font-size: 1.5rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
}
.closeBtn:hover {
color: #e0e0e0;
}
.columns {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 1.5rem;
margin-bottom: 1.25rem;
}
@media (max-width: 1100px) {
.columns {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 768px) {
.columns {
grid-template-columns: 1fr;
}
}
.column {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.fieldLabel {
font-size: 0.7rem;
color: #666;
text-transform: uppercase;
font-weight: 600;
}
.fieldInput {
padding: 0.35rem 0.5rem;
background: #0f1a30;
border: 1px solid #333;
border-radius: 5px;
color: #e0e0e0;
font-size: 0.85rem;
}
.fieldInput:focus {
outline: none;
border-color: #c9a84c;
}
.fieldSelect {
padding: 0.35rem 0.5rem;
background: #0f1a30;
border: 1px solid #333;
border-radius: 5px;
color: #e0e0e0;
font-size: 0.85rem;
}
.fieldRow {
display: flex;
gap: 0.5rem;
}
.fieldRow > .field {
flex: 1;
}
.xpDisplay {
font-size: 0.85rem;
color: #888;
}
.xpCurrent {
color: #c9a84c;
font-weight: 600;
}
.sectionTitle {
font-size: 0.9rem;
font-weight: 700;
color: #c9a84c;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.3rem;
}
.notesField {
width: 100%;
min-height: 60px;
padding: 0.5rem;
background: #0f1a30;
border: 1px solid #333;
border-radius: 5px;
color: #e0e0e0;
font-size: 0.85rem;
font-family: inherit;
resize: vertical;
}
.notesField:focus {
outline: none;
border-color: #c9a84c;
}
.fullWidth {
margin-top: 0.5rem;
}
.deleteSection {
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid #333;
}
.deleteBtn {
padding: 0.4rem 0.75rem;
background: transparent;
border: 1px solid #e74c3c;
border-radius: 5px;
color: #e74c3c;
cursor: pointer;
font-size: 0.8rem;
}
.deleteBtn:hover {
background: rgba(231, 76, 60, 0.1);
}
- Step 2: Rewrite CharacterDetail.tsx
import { useState, useRef, useEffect } from "react";
import type { Character, GameItem } from "../types";
import { calculateAC } from "../utils/derived-ac";
import { generateAttacks } from "../utils/derived-attacks";
import StatBlock from "./StatBlock";
import AttackBlock from "./AttackBlock";
import AcDisplay from "./AcDisplay";
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;
onAddGearFromItem: (characterId: number, item: GameItem) => void;
onAddGearCustom: (
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,
onAddGearFromItem,
onAddGearCustom,
onRemoveGear,
onAddTalent,
onRemoveTalent,
onDelete,
onClose,
}: CharacterDetailProps) {
const [confirmDelete, setConfirmDelete] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
const acBreakdown = calculateAC(character);
const attacks = generateAttacks(character);
const slotsUsed = character.gear.reduce((sum, g) => sum + g.slot_count, 0);
const xpThreshold = character.level * 10;
function handleFieldChange(field: string, value: string | number) {
if (typeof value === "string") {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
onUpdate(character.id, { [field]: value });
}, 400);
} else {
onUpdate(character.id, { [field]: value });
}
}
function handleAcOverride(value: number | null) {
const overrides = { ...(character.overrides || {}) };
if (value === null) {
delete overrides.ac;
} else {
overrides.ac = value;
}
onUpdate(character.id, { overrides } as Partial<Character>);
}
return (
<div className={styles.overlay} onClick={onClose}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<div className={styles.topBar}>
<div className={styles.nameBlock}>
<div className={styles.name}>
{character.name}
{character.title ? ` ${character.title}` : ""}
</div>
<div className={styles.subtitle}>
Level {character.level} {character.ancestry} {character.class}
</div>
</div>
<button className={styles.closeBtn} onClick={onClose}>
✕
</button>
</div>
<div className={styles.columns}>
{/* LEFT COLUMN — Identity & Vitals */}
<div className={styles.column}>
<div className={styles.fieldRow}>
<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>
<div className={styles.fieldRow}>
<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>
<div className={styles.fieldRow}>
<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}>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.fieldRow}>
<div className={styles.field}>
<label className={styles.fieldLabel}>Background</label>
<input
className={styles.fieldInput}
value={character.background}
placeholder="Urchin..."
onChange={(e) =>
handleFieldChange("background", e.target.value)
}
/>
</div>
<div className={styles.field}>
<label className={styles.fieldLabel}>Deity</label>
<input
className={styles.fieldInput}
value={character.deity}
placeholder="None..."
onChange={(e) => handleFieldChange("deity", e.target.value)}
/>
</div>
</div>
<div onClick={(e) => e.stopPropagation()}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<AcDisplay
breakdown={acBreakdown}
onOverride={handleAcOverride}
/>
<div>
<span className={styles.fieldLabel}>HP </span>
<input
className={styles.fieldInput}
type="number"
style={{ width: "3rem" }}
value={character.hp_current}
onChange={(e) =>
handleFieldChange("hp_current", Number(e.target.value))
}
/>
<span style={{ color: "#666" }}> / </span>
<input
className={styles.fieldInput}
type="number"
style={{ width: "3rem" }}
value={character.hp_max}
onChange={(e) =>
handleFieldChange("hp_max", Number(e.target.value))
}
/>
</div>
</div>
</div>
<div className={styles.xpDisplay}>
XP: <span className={styles.xpCurrent}>{character.xp}</span> /{" "}
{xpThreshold}
<input
className={styles.fieldInput}
type="number"
min={0}
style={{ width: "3.5rem", marginLeft: "0.5rem" }}
value={character.xp}
onChange={(e) =>
handleFieldChange("xp", Number(e.target.value))
}
/>
</div>
</div>
{/* CENTER COLUMN — Combat & Stats */}
<div className={styles.column}>
<div className={styles.sectionTitle}>Ability Scores</div>
<div onClick={(e) => e.stopPropagation()}>
<StatBlock
stats={character.stats}
onStatChange={(statName, value) =>
onStatChange(character.id, statName, value)
}
/>
</div>
<AttackBlock attacks={attacks} />
</div>
{/* RIGHT COLUMN — Abilities & Notes */}
<div className={styles.column}>
<TalentList
talents={character.talents}
onAdd={(data) => onAddTalent(character.id, data)}
onRemove={(talentId) => onRemoveTalent(character.id, talentId)}
/>
<div className={styles.field}>
<label className={styles.fieldLabel}>Languages</label>
<input
className={styles.fieldInput}
value={character.languages}
placeholder="Common, Elvish..."
onChange={(e) => handleFieldChange("languages", e.target.value)}
/>
</div>
<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>
</div>
{/* FULL WIDTH — Gear */}
<div className={styles.fullWidth}>
<GearList
gear={character.gear}
gp={character.gp}
sp={character.sp}
cp={character.cp}
slotsUsed={slotsUsed}
slotsMax={character.gear_slots_max}
onAddFromItem={(item) => onAddGearFromItem(character.id, item)}
onAddCustom={(data) => onAddGearCustom(character.id, data)}
onRemove={(gearId) => onRemoveGear(character.id, gearId)}
onCurrencyChange={(field, value) =>
onUpdate(character.id, { [field]: value })
}
/>
</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 8: Update CampaignView + CharacterCard for New Props/Fields
Files:
-
Modify:
client/src/pages/CampaignView.tsx -
Modify:
client/src/components/CharacterCard.tsx -
Modify:
client/src/components/CharacterCard.module.css -
Step 1: Update CharacterCard to show XP threshold and derived AC
Add imports at top of CharacterCard.tsx:
import { calculateAC } from "../utils/derived-ac";
Update the card body — replace the AC display section and add XP:
In the hpAcRow div, replace the AC section:
<div className={styles.ac}>
<span className={styles.acLabel}>AC</span>
<span className={styles.acValue}>{calculateAC(character).effective}</span>
</div>
Add XP display after the gearSummary div:
<div className={styles.xp}>
XP: {character.xp} / {character.level * 10}
</div>
- Step 2: Add XP style to CharacterCard.module.css
.xp {
font-size: 0.75rem;
color: #888;
text-align: right;
}
- Step 3: Update CampaignView.tsx
The main changes:
- Replace the single
onAddGearprop withonAddGearFromItemandonAddGearCustom - Add
handleAddGearFromItemhandler that converts a GameItem to gear data - Pass
overridesfield to the character update handler
Replace the handleAddGear function and add handleAddGearFromItem:
async function handleAddGearFromItem(characterId: number, item: GameItem) {
await addGear(characterId, {
name: item.name,
type: item.type,
slot_count: item.slot_count,
properties: item.properties,
effects: item.effects,
game_item_id: item.id,
});
}
async function handleAddGearCustom(
characterId: number,
data: { name: string; type: string; slot_count: number },
) {
await addGear(characterId, data);
}
Add the GameItem import:
import type { Character, Gear, Talent, GameItem } from "../types";
Update the CharacterDetail component usage to pass the new props:
<CharacterDetail
character={selectedCharacter}
onUpdate={handleUpdate}
onStatChange={handleStatChange}
onAddGearFromItem={handleAddGearFromItem}
onAddGearCustom={handleAddGearCustom}
onRemoveGear={handleRemoveGear}
onAddTalent={handleAddTalent}
onRemoveTalent={handleRemoveTalent}
onDelete={handleDelete}
onClose={() => setSelectedId(null)}
/>
Remove the old handleAddGear function.
- Step 4: Ensure the character enrichment in CampaignView parses overrides
The server returns overrides as a JSON string. The character list endpoint already uses parseJson for gear, but the character:updated socket event may send overrides as a string. Add parsing in the onCharacterUpdated handler — or better, ensure the enrichment in the GET endpoint parses it.
In server/src/routes/characters.ts, update the enrichment in the GET handler to parse overrides:
const enriched = characters.map((char) => ({
...char,
overrides: parseJson(char.overrides),
stats: stmtStats.all(char.id),
gear: parseGear(stmtGear.all(char.id) as Array<Record<string, unknown>>),
talents: parseTalents(
stmtTalents.all(char.id) as Array<Record<string, unknown>>,
),
}));
Also in the POST create handler, return parsed overrides:
const enriched = {
...(character as Record<string, unknown>),
overrides: {},
stats,
gear: [],
talents: [],
};
- Step 5: Verify full TypeScript compilation
cd /Users/aaron.wood/workspace/shadowdark/client && npx tsc --noEmit
Expected: No errors.
Task 9: End-to-End Smoke Test
Files: None (testing only)
- Step 1: Reset DB and start full stack
rm -f /Users/aaron.wood/workspace/shadowdark/server/data/shadowdark.db
cd /Users/aaron.wood/workspace/shadowdark && npm run dev
- Step 2: Verify game items endpoint
curl -s http://localhost:3000/api/game-items | python3 -c "import sys,json; items=json.load(sys.stdin); print(f'{len(items)} items loaded')"
Expected: 36 items loaded.
- Step 3: Test full flow in browser
Open http://localhost:5173:
- Create a campaign
- Add a character
- Click the character card to open detail modal
- Verify 3-column layout on desktop
- Verify new fields: background, deity, languages are visible and editable
- Click "+ Add Gear" — item picker should appear with searchable list
- Select "Leather armor" — should appear in gear table
- Verify AC auto-updates (should show 11 + DEX modifier)
- Select "Shield" — AC should add +2
- Click AC value to override it, type a number, press Enter
- Verify "revert" button appears and shows auto-calculated value
- Add a weapon — verify attacks section populates
- Check currency row — GP/SP/CP inputs work
- Check gear slot counter colors at different fill levels
- Verify XP shows "0 / 10" format on card and detail
- Step 4: Test real-time sync
Open a second tab to same campaign:
- Add gear in tab 1 — appears in tab 2
- Change a stat in tab 2 — attacks update in tab 1
- Override AC in tab 1 — tab 2 sees the override
- Step 5: Test responsive layout
Resize browser:
- 1100px+: 3 columns, gear below
- 768-1100px: 2 columns
- <768px: single column stacked