darkwatch/docs/plans/2026-04-08-v2-item-database-derived-stats.md

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:

  1. Replace the single onAddGear prop with onAddGearFromItem and onAddGearCustom
  2. Add handleAddGearFromItem handler that converts a GameItem to gear data
  3. Pass overrides field 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:

  1. Create a campaign
  2. Add a character
  3. Click the character card to open detail modal
  4. Verify 3-column layout on desktop
  5. Verify new fields: background, deity, languages are visible and editable
  6. Click "+ Add Gear" — item picker should appear with searchable list
  7. Select "Leather armor" — should appear in gear table
  8. Verify AC auto-updates (should show 11 + DEX modifier)
  9. Select "Shield" — AC should add +2
  10. Click AC value to override it, type a number, press Enter
  11. Verify "revert" button appears and shows auto-calculated value
  12. Add a weapon — verify attacks section populates
  13. Check currency row — GP/SP/CP inputs work
  14. Check gear slot counter colors at different fill levels
  15. Verify XP shows "0 / 10" format on card and detail
  • Step 4: Test real-time sync

Open a second tab to same campaign:

  1. Add gear in tab 1 — appears in tab 2
  2. Change a stat in tab 2 — attacks update in tab 1
  3. 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