2528 lines
61 KiB
Markdown
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
|