# 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** ```ts export interface SeedItem { name: string; type: "weapon" | "armor" | "gear"; slot_count: number; effects: Record; properties: Record; } 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: ```sql 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: ```ts // --- 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** ```bash 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: ```bash 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** ```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>; 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: ```ts 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: ```ts // 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; 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; 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: ```ts function parseGear(rows: Array>) { 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: ```ts 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** ```bash 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: ```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; effects: Record; game_item_id: number | null; } export interface Talent { id: number; character_id: number; name: string; description: string; effect: Record; } 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; stats: Stat[]; gear: Gear[]; talents: Talent[]; } export interface GameItem { id: number; name: string; type: "weapon" | "armor" | "gear"; slot_count: number; effects: Record; properties: Record; } 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** ```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** ```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** ```bash 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: ```bash 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`: ```ts import type { Campaign, Character, Gear, Talent, GameItem } from "./types"; ``` Add after the talents section: ```ts // Game Items export const getGameItems = () => request("/game-items"); ``` Update the `addGear` function to accept effects and game_item_id: ```ts export const addGear = ( characterId: number, data: { name: string; type?: string; slot_count?: number; properties?: Record; effects?: Record; game_item_id?: number | null; }, ) => request(`/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** ```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** ```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 (
Attacks
{weapons.length === 0 && talents.length === 0 && ( No weapons equipped )} {weapons.map((atk) => (
{atk.name} {atk.tags.length > 0 && ( ({atk.tags.join(", ")}) )} {atk.modifierStr} {", "} {atk.damage}
))} {talents.map((atk) => (
{atk.name}: {atk.description}
))}
); } ``` - [ ] **Step 3: Create AcDisplay.module.css** ```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** ```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 (
AC {editing ? ( setEditValue(e.target.value)} onBlur={commitEdit} onKeyDown={handleKeyDown} autoFocus /> ) : ( {breakdown.effective} )}
{breakdown.source}
{isOverridden && (
auto: {breakdown.calculated}
)}
); } ``` - [ ] **Step 5: Create CurrencyRow.module.css** ```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** ```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 (
GP onChange("gp", Number(e.target.value))} />
SP onChange("sp", Number(e.target.value))} />
CP onChange("cp", Number(e.target.value))} />
); } ``` - [ ] **Step 7: Create ItemPicker.module.css** ```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** ```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([]); const [search, setSearch] = useState(""); const containerRef = useRef(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 = {}; 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 (
setSearch(e.target.value)} autoFocus />
{Object.entries(groups).map(([groupName, groupItems]) => (
{groupName}
{groupItems.map((item) => (
onSelect(item)} > {item.name} {item.slot_count > 0 ? `${item.slot_count} slot` : "—"}
))}
))}
Custom item...
); } ``` - [ ] **Step 9: Verify TypeScript compiles** ```bash 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** ```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** ```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 (
Gear & Inventory Slots: {slotsUsed} / {slotsMax}
{gear.length === 0 ? (

No gear yet

) : ( {gear.map((item) => ( ))}
Item Type Slots
{item.name} {item.type} {item.slot_count > 0 ? item.slot_count : "—"}
)}
{showPicker ? ( { setShowPicker(false); setShowCustom(true); }} onClose={() => setShowPicker(false)} /> ) : showCustom ? (
setCustomName(e.target.value)} autoFocus />
) : ( )}
); } ``` --- ### 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** ```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** ```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) => 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>(); 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); } return (
e.stopPropagation()}>
{character.name} {character.title ? ` ${character.title}` : ""}
Level {character.level} {character.ancestry} {character.class}
{/* LEFT COLUMN — Identity & Vitals */}
handleFieldChange("name", e.target.value)} />
handleFieldChange("title", e.target.value)} />
handleFieldChange("level", Number(e.target.value)) } />
handleFieldChange("background", e.target.value) } />
handleFieldChange("deity", e.target.value)} />
e.stopPropagation()}>
HP handleFieldChange("hp_current", Number(e.target.value)) } /> / handleFieldChange("hp_max", Number(e.target.value)) } />
XP: {character.xp} /{" "} {xpThreshold} handleFieldChange("xp", Number(e.target.value)) } />
{/* CENTER COLUMN — Combat & Stats */}
Ability Scores
e.stopPropagation()}> onStatChange(character.id, statName, value) } />
{/* RIGHT COLUMN — Abilities & Notes */}
onAddTalent(character.id, data)} onRemove={(talentId) => onRemoveTalent(character.id, talentId)} />
handleFieldChange("languages", e.target.value)} />