diff --git a/docs/superpowers/plans/2026-04-11-character-creation.md b/docs/superpowers/plans/2026-04-11-character-creation.md new file mode 100644 index 0000000..f484d94 --- /dev/null +++ b/docs/superpowers/plans/2026-04-11-character-creation.md @@ -0,0 +1,1186 @@ +# Character Creation Wizard 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:** Replace the bare "New Character" inline form in CampaignView with a themed 4-step multi-step wizard that walks players through the full Shadowdark character creation process: name/class/ancestry → stat rolling → background/alignment/deity → review & create. + +**Architecture:** A standalone `CharacterWizard` modal component manages step state internally. A `character-creation.ts` util handles all dice math (3d6, HP by class, starting gold). A `backgrounds.ts` data file holds all 20 Shadowdark backgrounds. The server `POST /campaigns/:id/characters` endpoint is extended to accept full character data so we can create a complete character in one round-trip. + +**Tech Stack:** React + TypeScript + CSS Modules (matching existing patterns); existing `getModifier()` from `client/src/utils/modifiers.ts`; existing `getShadowdarkTitle()` from `client/src/utils/shadowdark-titles.ts`; existing `SelectDropdown` component; Express/MariaDB on server. + +--- + +### Task 1: Extend server character creation endpoint + +**Files:** +- Modify: `server/src/routes/characters.ts:93-151` + +The current endpoint accepts only `name`, `class`, `ancestry`, `hp_max`. We need it to also accept `alignment`, `background`, `deity`, `title`, and stat values so we can create a fully-formed character without a second PATCH call. + +- [ ] **Step 1: Write the failing test** (manual — run the server and test via curl after implementation) + +```bash +# After implementation, verify this works: +curl -s -X POST http://localhost:3001/api/campaigns/1/characters \ + -H "Content-Type: application/json" \ + -b "..." \ + -d '{ + "name": "Tharyn", + "class": "Fighter", + "ancestry": "Human", + "alignment": "Lawful", + "background": "Soldier", + "deity": "", + "title": "Squire", + "hp_max": 9, + "gp": 30, + "stats": {"STR":15,"DEX":10,"CON":12,"INT":8,"WIS":11,"CHA":9} + }' | jq '.alignment, .background, .title, .gp' +# Expected: "Lawful" "Soldier" "Squire" 30 +``` + +- [ ] **Step 2: Extend the POST handler to accept full character fields** + +In `server/src/routes/characters.ts`, replace the destructure at line 97 and the INSERT at line 106: + +```typescript +// Replace lines 97-120 with: +const { + name, + class: charClass, + ancestry, + hp_max, + alignment, + background, + deity, + title, + gp, + stats, +} = req.body; + +if (!name?.trim()) { + res.status(400).json({ error: "Character name is required" }); + return; +} + +const userId = req.user?.userId ?? null; + +const [result] = await db.execute( + `INSERT INTO characters + (campaign_id, user_id, name, class, ancestry, hp_current, hp_max, + alignment, background, deity, title, gp, color) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + campaignId, + userId, + name.trim(), + charClass ?? "Fighter", + ancestry ?? "Human", + hp_max ?? 1, + hp_max ?? 1, + alignment ?? "Neutral", + background ?? "", + deity ?? "", + title ?? "", + gp ?? 0, + generateCharacterColor(), + ] +); +const characterId = result.insertId; +``` + +- [ ] **Step 3: Use provided stat values instead of default 10s** + +Replace the `Promise.all` stat insert block (lines 123-130) with: + +```typescript +const statNames = ["STR", "DEX", "CON", "INT", "WIS", "CHA"]; +const providedStats: Record = stats && typeof stats === "object" ? stats : {}; + +await Promise.all( + statNames.map((stat) => + db.execute( + "INSERT INTO character_stats (character_id, stat_name, value) VALUES (?, ?, ?)", + [characterId, stat, providedStats[stat] ?? 10] + ) + ) +); +``` + +Then update the enriched object's stats line (currently `DEFAULT_STATS.map((s) => ({ stat_name: s, value: 10 }))`): + +```typescript +const enriched = { + ...charRows[0], + overrides: {}, + stats: statNames.map((s) => ({ stat_name: s, value: providedStats[s] ?? 10 })), + gear: [], + talents: [], +}; +``` + +- [ ] **Step 4: Run the server and test** + +```bash +cd /Users/aaron.wood/workspace/shadowdark/server && npm run dev +``` + +Confirm no TypeScript errors and the curl test from Step 1 returns expected values. + +- [ ] **Step 5: Commit** + +```bash +git -C /Users/aaron.wood/workspace/shadowdark add server/src/routes/characters.ts +git -C /Users/aaron.wood/workspace/shadowdark commit -m "feat: extend character creation endpoint to accept full character data" +``` + +--- + +### Task 2: Backgrounds data file + +**Files:** +- Create: `client/src/data/backgrounds.ts` + +20 official Shadowdark backgrounds from the Player Quickstart, each with a name and the skill it grants. + +- [ ] **Step 1: Create the file** + +```typescript +// client/src/data/backgrounds.ts +export interface Background { + name: string; + skill: string; +} + +export const BACKGROUNDS: Background[] = [ + { name: "Urchin", skill: "You are never lost in a city and can always find food and shelter in urban areas." }, + { name: "Wanted", skill: "You know how to move unseen and blend into crowds to avoid pursuit." }, + { name: "Cult Initiate", skill: "You know the rituals, symbols, and secrets of a dark cult." }, + { name: "Thieves' Guild", skill: "You can pick locks, pocket items, and recognize guild signs." }, + { name: "Banished", skill: "You can survive in the wilderness indefinitely and sense approaching weather." }, + { name: "Orphaned", skill: "You can beg, scrounge, and identify charity-givers in any settlement." }, + { name: "Wizard's Apprentice", skill: "You can read and identify magical writing and recognize common spell effects." }, + { name: "Jeweler", skill: "You can appraise gems, metals, and jewelry with accuracy." }, + { name: "Herbalist", skill: "You know which plants heal, harm, or alter the mind in any terrain." }, + { name: "Barbarian", skill: "You can track creatures and navigate by the stars in any wilderness." }, + { name: "Mercenary", skill: "You know the going rate for violence and can always find hired work." }, + { name: "Sailor", skill: "You can navigate by stars and read weather patterns at sea or in the open." }, + { name: "Acolyte", skill: "You know the prayers, calendar, and hierarchy of a major religion." }, + { name: "Soldier", skill: "You can read a battlefield and know the tactics of common military units." }, + { name: "Ranger", skill: "You can move silently through wilderness and identify animal tracks." }, + { name: "Scout", skill: "You can estimate distances, draw rough maps, and spot ambushes." }, + { name: "Minstrel", skill: "You can perform music, recite epics, and always find a crowd willing to listen." }, + { name: "Scholar", skill: "You can read most languages and recall obscure historical and arcane lore." }, + { name: "Noble", skill: "You know the etiquette, rumors, and politics of the upper class." }, + { name: "Chirurgeon", skill: "You can stabilize the dying and treat wounds to prevent infection." }, +]; + +export const BACKGROUND_NAMES = BACKGROUNDS.map((b) => b.name); +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +```bash +cd /Users/aaron.wood/workspace/shadowdark/client && npx tsc --noEmit +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git -C /Users/aaron.wood/workspace/shadowdark add client/src/data/backgrounds.ts +git -C /Users/aaron.wood/workspace/shadowdark commit -m "feat: add Shadowdark backgrounds data file" +``` + +--- + +### Task 3: Character creation utilities + +**Files:** +- Create: `client/src/utils/character-creation.ts` + +Pure math functions used by the wizard: roll 3d6, calculate HP by class, roll starting gold, calculate derived title. + +- [ ] **Step 1: Create the util file** + +```typescript +// client/src/utils/character-creation.ts +import { getModifier } from "./modifiers.js"; +import { getShadowdarkTitle } from "./shadowdark-titles.js"; + +const CLASS_HIT_DIE: Record = { + Fighter: 8, + Priest: 6, + Thief: 4, + Wizard: 4, +}; + +/** Roll 3d6 and return the sum (3–18). */ +export function roll3d6(): number { + return ( + Math.ceil(Math.random() * 6) + + Math.ceil(Math.random() * 6) + + Math.ceil(Math.random() * 6) + ); +} + +/** Roll a full set of 6 stats (STR/DEX/CON/INT/WIS/CHA). */ +export function rollAllStats(): Record { + return { + STR: roll3d6(), + DEX: roll3d6(), + CON: roll3d6(), + INT: roll3d6(), + WIS: roll3d6(), + CHA: roll3d6(), + }; +} + +/** + * Roll HP for level 1: roll the class hit die, add CON modifier. + * Minimum result is 1. + */ +export function rollStartingHp(charClass: string, conScore: number): number { + const die = CLASS_HIT_DIE[charClass] ?? 4; + const rolled = Math.ceil(Math.random() * die); + return Math.max(1, rolled + getModifier(conScore)); +} + +/** + * Roll starting gold: 2d6 × 5 gp. + */ +export function rollStartingGold(): number { + const d1 = Math.ceil(Math.random() * 6); + const d2 = Math.ceil(Math.random() * 6); + return (d1 + d2) * 5; +} + +/** + * Derive starting gear slots: 10 + STR modifier. + */ +export function startingGearSlots(strScore: number): number { + return 10 + getModifier(strScore); +} + +/** Convenience: get title for a level-1 character. */ +export function getStartingTitle(charClass: string, alignment: string): string { + return getShadowdarkTitle(charClass, alignment, 1) ?? ""; +} +``` + +- [ ] **Step 2: Verify TypeScript** + +```bash +cd /Users/aaron.wood/workspace/shadowdark/client && npx tsc --noEmit +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git -C /Users/aaron.wood/workspace/shadowdark add client/src/utils/character-creation.ts +git -C /Users/aaron.wood/workspace/shadowdark commit -m "feat: add character creation utility functions (roll3d6, rollHp, rollGold)" +``` + +--- + +### Task 4: Extend client API function + +**Files:** +- Modify: `client/src/api.ts:69-76` + +Extend `createCharacter` to accept the full set of fields the server now supports. + +- [ ] **Step 1: Update the createCharacter function signature** + +Replace the existing `createCharacter` export: + +```typescript +// Before: +export const createCharacter = ( + campaignId: number, + data: { name: string; class?: string; ancestry?: string; hp_max?: number }, +) => + request(`/campaigns/${campaignId}/characters`, { + method: "POST", + body: JSON.stringify(data), + }); +``` + +```typescript +// After: +export interface CreateCharacterData { + name: string; + class?: string; + ancestry?: string; + alignment?: string; + background?: string; + deity?: string; + title?: string; + hp_max?: number; + gp?: number; + stats?: Record; +} + +export const createCharacter = (campaignId: number, data: CreateCharacterData) => + request(`/campaigns/${campaignId}/characters`, { + method: "POST", + body: JSON.stringify(data), + }); +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +```bash +cd /Users/aaron.wood/workspace/shadowdark/client && npx tsc --noEmit +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git -C /Users/aaron.wood/workspace/shadowdark add client/src/api.ts +git -C /Users/aaron.wood/workspace/shadowdark commit -m "feat: extend createCharacter API to accept full character fields" +``` + +--- + +### Task 5: CharacterWizard component — step state & step 1 (Name / Class / Ancestry) + +**Files:** +- Create: `client/src/components/CharacterWizard.tsx` +- Create: `client/src/components/CharacterWizard.module.css` + +The wizard manages all step state. Step 1 collects name, class, and ancestry. It validates that name is non-empty before Next is enabled. + +- [ ] **Step 1: Create the CSS file** + +```css +/* client/src/components/CharacterWizard.module.css */ + +/* ── Backdrop ─────────────────────────────────────────── */ +.backdrop { + position: fixed; + inset: 0; + background: var(--bg-overlay); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; + padding: 1rem; +} + +/* ── Dialog ───────────────────────────────────────────── */ +.dialog { + background-color: var(--bg-modal); + background-image: var(--texture-surface), var(--texture-speckle); + background-size: 256px 256px, 128px 128px; + background-repeat: repeat, repeat; + border: 2px solid rgba(var(--gold-rgb), 0.3); + border-radius: 4px; + padding: 1.5rem; + width: 100%; + max-width: 480px; + box-shadow: + 0 8px 40px rgba(var(--shadow-rgb), 0.7), + 0 2px 8px rgba(var(--shadow-rgb), 0.5), + inset 0 1px 0 rgba(var(--gold-rgb), 0.1), + inset 0 0 60px rgba(var(--shadow-rgb), 0.2); +} + +/* ── Header ───────────────────────────────────────────── */ +.title { + font-family: "Cinzel", Georgia, serif; + font-size: 1.2rem; + font-weight: 700; + color: var(--gold); + letter-spacing: 0.05em; + text-shadow: 0 1px 2px rgba(var(--shadow-rgb), 0.3); + margin-bottom: 0.25rem; +} + +.stepLabel { + font-size: 0.75rem; + color: var(--text-tertiary); + font-family: "Cinzel", Georgia, serif; + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 1.25rem; +} + +/* ── Progress dots ────────────────────────────────────── */ +.dots { + display: flex; + gap: 0.4rem; + margin-bottom: 1.25rem; +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: rgba(var(--gold-rgb), 0.2); + border: 1px solid rgba(var(--gold-rgb), 0.3); + transition: background 0.2s; +} + +.dotActive { + background: var(--gold); + border-color: var(--gold); +} + +.dotDone { + background: rgba(var(--gold-rgb), 0.5); + border-color: rgba(var(--gold-rgb), 0.5); +} + +/* ── Form fields ──────────────────────────────────────── */ +.field { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-bottom: 0.75rem; +} + +.fieldLabel { + font-family: "Cinzel", Georgia, serif; + font-size: 0.75rem; + color: var(--text-secondary); + text-transform: uppercase; + font-weight: 600; + letter-spacing: 0.05em; +} + +.input { + padding: 0.5rem 0.75rem; + background: var(--bg-inset); + border: 1px solid rgba(var(--gold-rgb), 0.15); + border-radius: 4px; + color: var(--text-primary); + font-size: 0.9rem; + font-family: "Alegreya", Georgia, serif; +} + +.input:focus { + outline: none; + border-color: var(--gold); +} + +/* ── Stat rolling (step 2) ────────────────────────────── */ +.statGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.statRow { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.4rem 0.6rem; + background: var(--bg-inset); + border: 1px solid rgba(var(--gold-rgb), 0.12); + border-radius: 4px; +} + +.statName { + font-family: "Cinzel", Georgia, serif; + font-size: 0.7rem; + color: var(--text-secondary); + letter-spacing: 0.05em; + font-weight: 600; +} + +.statValue { + font-size: 1.1rem; + font-weight: 700; + color: var(--text-primary); + min-width: 2ch; + text-align: right; +} + +.statMod { + font-size: 0.75rem; + color: var(--text-tertiary); + min-width: 3ch; + text-align: right; +} + +.rerollBtn { + width: 100%; + padding: 0.5rem; + background: none; + border: 1px solid rgba(var(--gold-rgb), 0.25); + border-radius: 4px; + color: var(--text-secondary); + font-family: "Cinzel", Georgia, serif; + font-size: 0.8rem; + cursor: pointer; + letter-spacing: 0.05em; + transition: border-color 0.15s, color 0.15s; + margin-bottom: 0.75rem; +} + +.rerollBtn:hover { + border-color: rgba(var(--gold-rgb), 0.5); + color: var(--gold); +} + +/* ── Background list (step 3) ─────────────────────────── */ +.bgList { + display: flex; + flex-direction: column; + gap: 0.3rem; + max-height: 180px; + overflow-y: auto; + margin-bottom: 0.75rem; + scrollbar-width: thin; + scrollbar-color: rgba(var(--gold-rgb), 0.2) transparent; +} + +.bgItem { + padding: 0.4rem 0.6rem; + border-radius: 4px; + border: 1px solid transparent; + cursor: pointer; + font-size: 0.88rem; + font-family: "Alegreya", Georgia, serif; + transition: background 0.1s, border-color 0.1s; +} + +.bgItem:hover { + background: rgba(var(--gold-rgb), 0.06); +} + +.bgItemSelected { + background: rgba(var(--gold-rgb), 0.12); + border-color: rgba(var(--gold-rgb), 0.3); + color: var(--gold); +} + +.bgSkill { + font-size: 0.75rem; + color: var(--text-tertiary); + font-style: italic; + margin-top: 0.2rem; +} + +.randomBtn { + width: 100%; + padding: 0.35rem; + background: none; + border: 1px dashed rgba(var(--gold-rgb), 0.2); + border-radius: 4px; + color: var(--text-tertiary); + font-size: 0.8rem; + cursor: pointer; + font-family: "Cinzel", Georgia, serif; + letter-spacing: 0.05em; + transition: border-color 0.15s, color 0.15s; + margin-bottom: 0.75rem; +} + +.randomBtn:hover { + border-color: rgba(var(--gold-rgb), 0.4); + color: var(--text-secondary); +} + +/* ── Review (step 4) ──────────────────────────────────── */ +.reviewGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.35rem 1rem; + margin-bottom: 0.75rem; +} + +.reviewRow { + display: flex; + justify-content: space-between; + align-items: baseline; + border-bottom: 1px solid rgba(var(--gold-rgb), 0.08); + padding-bottom: 0.2rem; +} + +.reviewLabel { + font-family: "Cinzel", Georgia, serif; + font-size: 0.7rem; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.reviewValue { + font-size: 0.88rem; + color: var(--text-primary); + font-family: "Alegreya", Georgia, serif; +} + +.reviewSectionTitle { + font-family: "Cinzel", Georgia, serif; + font-size: 0.75rem; + color: var(--gold); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 0.4rem; + grid-column: 1 / -1; + border-bottom: 1px solid rgba(var(--gold-rgb), 0.15); + padding-bottom: 0.2rem; +} + +.reviewStats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.35rem; + margin-bottom: 0.75rem; +} + +.reviewStat { + text-align: center; + padding: 0.35rem; + background: var(--bg-inset); + border-radius: 4px; + border: 1px solid rgba(var(--gold-rgb), 0.1); +} + +.reviewStatName { + font-family: "Cinzel", Georgia, serif; + font-size: 0.65rem; + color: var(--text-tertiary); + letter-spacing: 0.05em; +} + +.reviewStatVal { + font-size: 1rem; + font-weight: 700; + color: var(--text-primary); +} + +.reviewStatMod { + font-size: 0.7rem; + color: var(--text-tertiary); +} + +/* ── Navigation buttons ───────────────────────────────── */ +.actions { + display: flex; + gap: 0.5rem; + justify-content: space-between; + margin-top: 1rem; +} + +.btnSecondary { + padding: 0.5rem 1rem; + border-radius: 4px; + font-weight: 600; + cursor: pointer; + font-size: 0.9rem; + background: none; + border: 1px solid rgba(var(--gold-rgb), 0.2); + color: var(--text-secondary); + font-family: "Cinzel", Georgia, serif; + transition: border-color 0.15s, color 0.15s; +} + +.btnSecondary:hover { + border-color: rgba(var(--gold-rgb), 0.4); + color: var(--text-primary); +} + +.btnPrimary { + padding: 0.5rem 1.25rem; + background: var(--btn-gold-bg); + color: var(--btn-active-text); + border: none; + border-radius: 4px; + font-family: "Cinzel", Georgia, serif; + font-weight: 600; + cursor: pointer; + font-size: 0.9rem; + box-shadow: + 0 2px 4px rgba(var(--shadow-rgb), 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.1); + text-shadow: 0 1px 1px rgba(var(--shadow-rgb), 0.2); +} + +.btnPrimary:hover { + background: linear-gradient(180deg, var(--gold-bright), var(--gold-hover) 40%, var(--gold)); +} + +.btnPrimary:disabled { + opacity: 0.4; + cursor: not-allowed; +} +``` + +- [ ] **Step 2: Create CharacterWizard.tsx with step 1** + +```tsx +// client/src/components/CharacterWizard.tsx +import { useState } from "react"; +import type { CreateCharacterData } from "../api.js"; +import SelectDropdown from "./SelectDropdown.js"; +import { BACKGROUNDS, type Background } from "../data/backgrounds.js"; +import { + rollAllStats, + rollStartingHp, + rollStartingGold, + startingGearSlots, + getStartingTitle, +} from "../utils/character-creation.js"; +import { getModifier, formatModifier } from "../utils/modifiers.js"; +import styles from "./CharacterWizard.module.css"; + +const CLASSES = ["Fighter", "Priest", "Thief", "Wizard"]; +const ANCESTRIES = ["Human", "Elf", "Dwarf", "Halfling", "Goblin", "Half-Orc"]; +const ALIGNMENTS = ["Lawful", "Neutral", "Chaotic"]; +const STAT_NAMES = ["STR", "DEX", "CON", "INT", "WIS", "CHA"]; + +const STEP_LABELS = [ + "Step 1 of 4 — Name & Origins", + "Step 2 of 4 — Ability Scores", + "Step 3 of 4 — Background & Beliefs", + "Step 4 of 4 — Review & Create", +]; + +interface Props { + campaignId: number; + onSubmit: (data: CreateCharacterData) => Promise; + onClose: () => void; +} + +export default function CharacterWizard({ onSubmit, onClose }: Props) { + const [step, setStep] = useState(0); + + // Step 1 state + const [name, setName] = useState(""); + const [charClass, setCharClass] = useState("Fighter"); + const [ancestry, setAncestry] = useState("Human"); + + // Step 2 state + const [stats, setStats] = useState>(rollAllStats); + + // Step 3 state + const [selectedBg, setSelectedBg] = useState(null); + const [alignment, setAlignment] = useState("Neutral"); + const [deity, setDeity] = useState(""); + + // Derived for review / submit + const conMod = getModifier(stats.CON); + const hp = rollStartingHp(charClass, stats.CON); + const gp = rollStartingGold(); + const gearSlots = startingGearSlots(stats.STR); + const title = getStartingTitle(charClass, alignment); + + // Keep hp/gp stable across re-renders by computing once at step transition + const [rolledHp, setRolledHp] = useState(0); + const [rolledGp, setRolledGp] = useState(0); + const [rolledGearSlots, setRolledGearSlots] = useState(10); + + function handleRerollStats() { + setStats(rollAllStats()); + } + + function advanceToStep(nextStep: number) { + if (nextStep === 3 && step === 2) { + // Roll HP and gold when entering review so they're stable + setRolledHp(rollStartingHp(charClass, stats.CON)); + setRolledGp(rollStartingGold()); + setRolledGearSlots(startingGearSlots(stats.STR)); + } + setStep(nextStep); + } + + async function handleCreate() { + await onSubmit({ + name: name.trim(), + class: charClass, + ancestry, + alignment, + background: selectedBg?.name ?? "", + deity: charClass === "Priest" ? deity : "", + title, + hp_max: rolledHp, + gp: rolledGp, + stats, + }); + } + + return ( +
+
e.stopPropagation()}> +
New Character
+
{STEP_LABELS[step]}
+ + {/* Progress dots */} +
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+ + {/* ── Step 1: Name / Class / Ancestry ── */} + {step === 0 && ( + <> +
+ + setName(e.target.value)} + autoFocus + placeholder="Tharyn the Bold..." + /> +
+
+ + +
+
+ + +
+ + )} + + {/* ── Step 2: Stat Rolling ── */} + {step === 1 && ( + <> +
+ {STAT_NAMES.map((s) => ( +
+ {s} + {stats[s]} + + {formatModifier(getModifier(stats[s]))} + +
+ ))} +
+ + + )} + + {/* ── Step 3: Background / Alignment / Deity ── */} + {step === 2 && ( + <> +
+ +
+
+ {BACKGROUNDS.map((bg) => ( +
setSelectedBg(bg)} + > +
{bg.name}
+ {selectedBg?.name === bg.name && ( +
{bg.skill}
+ )} +
+ ))} +
+ +
+ + +
+ {charClass === "Priest" && ( +
+ + setDeity(e.target.value)} + placeholder="Saint Terragnis..." + /> +
+ )} + + )} + + {/* ── Step 4: Review ── */} + {step === 3 && ( + <> +
+
Character
+ {[ + ["Name", name], + ["Class", charClass], + ["Ancestry", ancestry], + ["Alignment", alignment], + ["Title", title], + ["Background", selectedBg?.name ?? "—"], + ["HP", String(rolledHp)], + ["Starting Gold", `${rolledGp} gp`], + ["Gear Slots", String(rolledGearSlots)], + ...(charClass === "Priest" && deity ? [["Deity", deity]] : []), + ].map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+ +
+ Ability Scores +
+
+ {STAT_NAMES.map((s) => ( +
+
{s}
+
{stats[s]}
+
+ {formatModifier(getModifier(stats[s]))} +
+
+ ))} +
+ + )} + + {/* ── Navigation ── */} +
+ + {step < 3 ? ( + + ) : ( + + )} +
+
+
+ ); +} +``` + +- [ ] **Step 3: Verify TypeScript compiles** + +```bash +cd /Users/aaron.wood/workspace/shadowdark/client && npx tsc --noEmit +``` + +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git -C /Users/aaron.wood/workspace/shadowdark add client/src/components/CharacterWizard.tsx client/src/components/CharacterWizard.module.css +git -C /Users/aaron.wood/workspace/shadowdark commit -m "feat: add CharacterWizard multi-step character creation modal" +``` + +--- + +### Task 6: Wire CharacterWizard into CampaignView + +**Files:** +- Modify: `client/src/pages/CampaignView.tsx` + +Replace the inline `showCreate` / `newChar` form with `CharacterWizard`. Remove the `newChar` state, the inline form JSX block, and the `CLASSES`/`ANCESTRIES` constants from this file (they now live in the wizard). + +- [ ] **Step 1: Add the import** + +At the top of `CampaignView.tsx`, after the existing component imports, add: + +```typescript +import CharacterWizard from "../components/CharacterWizard.js"; +``` + +- [ ] **Step 2: Remove newChar state** + +Remove this block (around lines 45-50): + +```typescript +const [newChar, setNewChar] = useState({ + name: "", + class: "Fighter", + ancestry: "Human", + hp_max: 1, +}); +``` + +- [ ] **Step 3: Update handleCreate** + +Replace the existing `handleCreate` function (around lines 290-299): + +```typescript +// Before: +async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + if (!newChar.name.trim()) return; + try { + await createCharacter(campaignId, newChar); + setNewChar({ name: "", class: "Fighter", ancestry: "Human", hp_max: 1 }); + setShowCreate(false); + } catch (err) { + console.error("Failed to create character:", err); + } +} +``` + +```typescript +// After: +async function handleCreate(data: import("../api.js").CreateCharacterData) { + try { + await createCharacter(campaignId, data); + setShowCreate(false); + } catch (err) { + console.error("Failed to create character:", err); + } +} +``` + +- [ ] **Step 4: Replace inline form JSX with CharacterWizard** + +Find the inline form block in the JSX (around lines 440-508) — the entire `{showCreate && (
...
)}` block. Replace it with: + +```tsx +{showCreate && ( + setShowCreate(false)} + /> +)} +``` + +- [ ] **Step 5: Remove unused imports and constants** + +Remove `CLASSES` and `ANCESTRIES` from the top of `CampaignView.tsx` (they're now only used internally in `CharacterWizard`). Also remove the `SelectDropdown` import if it's no longer used elsewhere in this file (check — it may still be used in the create form, which is now gone). + +```typescript +// Remove these lines if no longer used: +const CLASSES = ["Fighter", "Priest", "Thief", "Wizard"]; +const ANCESTRIES = ["Human", "Elf", "Dwarf", "Halfling", "Goblin", "Half-Orc"]; +import SelectDropdown from "../components/SelectDropdown.js"; +``` + +- [ ] **Step 6: Verify TypeScript compiles** + +```bash +cd /Users/aaron.wood/workspace/shadowdark/client && npx tsc --noEmit +``` + +Expected: no errors. + +- [ ] **Step 7: Commit** + +```bash +git -C /Users/aaron.wood/workspace/shadowdark add client/src/pages/CampaignView.tsx +git -C /Users/aaron.wood/workspace/shadowdark commit -m "feat: replace inline create form with CharacterWizard multi-step modal" +``` + +--- + +### Task 7: Visual QA and CSS polish + +**Files:** +- Modify: `client/src/components/CharacterWizard.tsx` (minor fixes as needed) +- Modify: `client/src/components/CharacterWizard.module.css` (polish as needed) + +Open the app, click "+ Add Character", and walk through all 4 steps. Verify each step works as expected and looks good. + +- [ ] **Step 1: Start the dev server** + +```bash +cd /Users/aaron.wood/workspace/shadowdark/client && npm run dev +``` + +- [ ] **Step 2: Walk through each step manually** + +Check: +- Step 1: Name field auto-focuses. "Next" disabled with empty name. Class/ancestry dropdowns work. +- Step 2: Stats show correct values. Reroll All gives new numbers. Modifiers display correctly. +- Step 3: Background list scrolls smoothly. Clicking a background shows skill text. Random Background works. Alignment dropdown works. Deity field only shows for Priest. +- Step 4: All review values are correct. Title matches class/alignment. HP and gold are reasonable values. Stats block shows all 6 stats. +- "Create Character" closes modal and shows new character card in grid. + +- [ ] **Step 3: Fix any visual issues found** + +Common things to check: +- Background list scroll height feels right (adjust `max-height` in `.bgList`) +- Review section not cut off on smaller screens (adjust `max-width` on `.dialog` if needed) +- Dark/light mode both look correct + +- [ ] **Step 4: Commit any CSS fixes** + +```bash +git -C /Users/aaron.wood/workspace/shadowdark add client/src/components/CharacterWizard.tsx client/src/components/CharacterWizard.module.css +git -C /Users/aaron.wood/workspace/shadowdark commit -m "fix: CharacterWizard visual polish" +``` + +--- + +## Implementation Notes + +**HP is rolled at step transition:** `rolledHp` is set when advancing from step 3 → 4, so the value shown in review is what gets sent to the server. It won't re-roll if the user navigates back and forward. + +**Gold is also rolled at step transition:** Same pattern as HP — rolled once entering review, stable through multiple visits. + +**Gear slots are derived from STR:** `startingGearSlots()` returns `10 + STR modifier`. This is shown in review and sent as part of the create payload. The server currently uses `gear_slots_max` — ensure the PATCH call in the server endpoint saves this value. If the server's create endpoint doesn't yet support `gear_slots_max`, add it alongside the other extended fields in Task 1. + +**Deity field only shows for Priests:** Step 3 conditionally renders the deity input. For all other classes it's skipped (empty string sent to server). + +**Background is optional:** A player can advance from step 3 without selecting a background — `selectedBg` will be `null` and an empty string is sent to the server. + +**The `conMod` / `hp` / `gp` / `gearSlots` computed values at the top of the component are not used directly** — they're only used as a seed for the rolled values stored in state. This avoids re-rolling on every render.