- Move docs/superpowers/{plans,specs}/ → docs/{plans,specs}/
- Add 4 previously untracked implementation plans to git
- Update CLAUDE.md with docs path overrides for superpowers skills
- Update HANDBOOK.md repo structure and workflow paths
- Add per-enemy dice rolls to ROADMAP planned section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1186 lines
36 KiB
Markdown
1186 lines
36 KiB
Markdown
# 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<ResultSetHeader>(
|
||
`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<string, number> = 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<string, number> = {
|
||
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<string, number> {
|
||
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<Character>(`/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<string, number>;
|
||
}
|
||
|
||
export const createCharacter = (campaignId: number, data: CreateCharacterData) =>
|
||
request<Character>(`/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<void>;
|
||
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<Record<string, number>>(rollAllStats);
|
||
|
||
// Step 3 state
|
||
const [selectedBg, setSelectedBg] = useState<Background | null>(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 (
|
||
<div className={styles.backdrop} onClick={onClose}>
|
||
<div className={styles.dialog} onClick={(e) => e.stopPropagation()}>
|
||
<div className={styles.title}>New Character</div>
|
||
<div className={styles.stepLabel}>{STEP_LABELS[step]}</div>
|
||
|
||
{/* Progress dots */}
|
||
<div className={styles.dots}>
|
||
{[0, 1, 2, 3].map((i) => (
|
||
<div
|
||
key={i}
|
||
className={[
|
||
styles.dot,
|
||
i === step ? styles.dotActive : "",
|
||
i < step ? styles.dotDone : "",
|
||
]
|
||
.filter(Boolean)
|
||
.join(" ")}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{/* ── Step 1: Name / Class / Ancestry ── */}
|
||
{step === 0 && (
|
||
<>
|
||
<div className={styles.field}>
|
||
<label className={styles.fieldLabel}>Name</label>
|
||
<input
|
||
className={styles.input}
|
||
type="text"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
autoFocus
|
||
placeholder="Tharyn the Bold..."
|
||
/>
|
||
</div>
|
||
<div className={styles.field}>
|
||
<label className={styles.fieldLabel}>Class</label>
|
||
<SelectDropdown
|
||
value={charClass}
|
||
options={CLASSES}
|
||
onChange={setCharClass}
|
||
/>
|
||
</div>
|
||
<div className={styles.field}>
|
||
<label className={styles.fieldLabel}>Ancestry</label>
|
||
<SelectDropdown
|
||
value={ancestry}
|
||
options={ANCESTRIES}
|
||
onChange={setAncestry}
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* ── Step 2: Stat Rolling ── */}
|
||
{step === 1 && (
|
||
<>
|
||
<div className={styles.statGrid}>
|
||
{STAT_NAMES.map((s) => (
|
||
<div key={s} className={styles.statRow}>
|
||
<span className={styles.statName}>{s}</span>
|
||
<span className={styles.statValue}>{stats[s]}</span>
|
||
<span className={styles.statMod}>
|
||
{formatModifier(getModifier(stats[s]))}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<button className={styles.rerollBtn} onClick={handleRerollStats}>
|
||
Reroll All
|
||
</button>
|
||
</>
|
||
)}
|
||
|
||
{/* ── Step 3: Background / Alignment / Deity ── */}
|
||
{step === 2 && (
|
||
<>
|
||
<div className={styles.field}>
|
||
<label className={styles.fieldLabel}>Background</label>
|
||
</div>
|
||
<div className={styles.bgList}>
|
||
{BACKGROUNDS.map((bg) => (
|
||
<div
|
||
key={bg.name}
|
||
className={[
|
||
styles.bgItem,
|
||
selectedBg?.name === bg.name ? styles.bgItemSelected : "",
|
||
]
|
||
.filter(Boolean)
|
||
.join(" ")}
|
||
onClick={() => setSelectedBg(bg)}
|
||
>
|
||
<div>{bg.name}</div>
|
||
{selectedBg?.name === bg.name && (
|
||
<div className={styles.bgSkill}>{bg.skill}</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
<button
|
||
className={styles.randomBtn}
|
||
onClick={() =>
|
||
setSelectedBg(
|
||
BACKGROUNDS[Math.floor(Math.random() * BACKGROUNDS.length)]
|
||
)
|
||
}
|
||
>
|
||
Random Background
|
||
</button>
|
||
<div className={styles.field}>
|
||
<label className={styles.fieldLabel}>Alignment</label>
|
||
<SelectDropdown
|
||
value={alignment}
|
||
options={ALIGNMENTS}
|
||
onChange={setAlignment}
|
||
/>
|
||
</div>
|
||
{charClass === "Priest" && (
|
||
<div className={styles.field}>
|
||
<label className={styles.fieldLabel}>Deity</label>
|
||
<input
|
||
className={styles.input}
|
||
type="text"
|
||
value={deity}
|
||
onChange={(e) => setDeity(e.target.value)}
|
||
placeholder="Saint Terragnis..."
|
||
/>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* ── Step 4: Review ── */}
|
||
{step === 3 && (
|
||
<>
|
||
<div className={styles.reviewGrid}>
|
||
<div className={styles.reviewSectionTitle}>Character</div>
|
||
{[
|
||
["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]) => (
|
||
<div key={label} className={styles.reviewRow}>
|
||
<span className={styles.reviewLabel}>{label}</span>
|
||
<span className={styles.reviewValue}>{value}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div
|
||
className={styles.reviewSectionTitle}
|
||
style={{ fontFamily: "Cinzel, Georgia, serif", fontSize: "0.75rem", color: "var(--gold)", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: "0.4rem", borderBottom: "1px solid rgba(var(--gold-rgb), 0.15)", paddingBottom: "0.2rem" }}
|
||
>
|
||
Ability Scores
|
||
</div>
|
||
<div className={styles.reviewStats}>
|
||
{STAT_NAMES.map((s) => (
|
||
<div key={s} className={styles.reviewStat}>
|
||
<div className={styles.reviewStatName}>{s}</div>
|
||
<div className={styles.reviewStatVal}>{stats[s]}</div>
|
||
<div className={styles.reviewStatMod}>
|
||
{formatModifier(getModifier(stats[s]))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* ── Navigation ── */}
|
||
<div className={styles.actions}>
|
||
<button
|
||
className={styles.btnSecondary}
|
||
onClick={step === 0 ? onClose : () => setStep(step - 1)}
|
||
>
|
||
{step === 0 ? "Cancel" : "Back"}
|
||
</button>
|
||
{step < 3 ? (
|
||
<button
|
||
className={styles.btnPrimary}
|
||
disabled={step === 0 && !name.trim()}
|
||
onClick={() => advanceToStep(step + 1)}
|
||
>
|
||
Next
|
||
</button>
|
||
) : (
|
||
<button className={styles.btnPrimary} onClick={handleCreate}>
|
||
Create Character
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **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 && (<div className={styles.createModal}>...</div>)}` block. Replace it with:
|
||
|
||
```tsx
|
||
{showCreate && (
|
||
<CharacterWizard
|
||
campaignId={campaignId}
|
||
onSubmit={handleCreate}
|
||
onClose={() => 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.
|