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

2528 lines
61 KiB
Markdown

# 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<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:
```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<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:
```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<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:
```ts
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:
```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<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**
```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<GameItem[]>("/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<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**
```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 (
<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**
```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 (
<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**
```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 (
<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**
```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<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**
```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 (
<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**
```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<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`:
```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:
```tsx
<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:
```tsx
<div className={styles.xp}>
XP: {character.xp} / {character.level * 10}
</div>
```
- [ ] **Step 2: Add XP style to CharacterCard.module.css**
```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`:
```tsx
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:
```tsx
import type { Character, Gear, Talent, GameItem } from "../types";
```
Update the `CharacterDetail` component usage to pass the new props:
```tsx
<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:
```ts
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:
```ts
const enriched = {
...(character as Record<string, unknown>),
overrides: {},
stats,
gear: [],
talents: [],
};
```
- [ ] **Step 5: Verify full TypeScript compilation**
```bash
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**
```bash
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**
```bash
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