From 7c7bdf2ee53e347507b3f52ce8f8a0e5f2f03183 Mon Sep 17 00:00:00 2001 From: Aaron Wood Date: Sat, 11 Apr 2026 23:55:45 -0400 Subject: [PATCH] chore: consolidate docs into flat structure and commit all plans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CLAUDE.md | 8 + docs/HANDBOOK.md | 9 +- docs/ROADMAP.md | 1 + .../plans/2026-04-10-darkwatch-auth-db.md | 0 .../plans/2026-04-10-particle-effects.md | 0 .../plans/2026-04-11-character-creation.md | 0 docs/plans/2026-04-11-handbook-changelog.md | 661 ++++++ docs/plans/2026-04-11-initiative-tracker.md | 2089 +++++++++++++++++ docs/plans/2026-04-11-spellcasting.md | 1122 +++++++++ docs/plans/2026-04-12-brochure.md | 1176 ++++++++++ .../2026-04-10-darkwatch-auth-db-design.md | 0 .../2026-04-10-particle-effects-design.md | 0 .../2026-04-11-handbook-changelog-design.md | 0 .../2026-04-11-initiative-tracker-design.md | 0 .../specs/2026-04-12-brochure-design.md | 0 15 files changed, 5061 insertions(+), 5 deletions(-) rename docs/{superpowers => }/plans/2026-04-10-darkwatch-auth-db.md (100%) rename docs/{superpowers => }/plans/2026-04-10-particle-effects.md (100%) rename docs/{superpowers => }/plans/2026-04-11-character-creation.md (100%) create mode 100644 docs/plans/2026-04-11-handbook-changelog.md create mode 100644 docs/plans/2026-04-11-initiative-tracker.md create mode 100644 docs/plans/2026-04-11-spellcasting.md create mode 100644 docs/plans/2026-04-12-brochure.md rename docs/{superpowers => }/specs/2026-04-10-darkwatch-auth-db-design.md (100%) rename docs/{superpowers => }/specs/2026-04-10-particle-effects-design.md (100%) rename docs/{superpowers => }/specs/2026-04-11-handbook-changelog-design.md (100%) rename docs/{superpowers => }/specs/2026-04-11-initiative-tracker-design.md (100%) rename docs/{superpowers => }/specs/2026-04-12-brochure-design.md (100%) diff --git a/CLAUDE.md b/CLAUDE.md index a5e8532..910317c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,14 @@ This is **Darkwatch**, a real-time session manager for the Shadowdark TTRPG. - All imports use `.js` extensions (ES modules) - Dev seed accounts: `dm@darkwatch.test` / `player@darkwatch.test` (password: `password`) +## Docs Layout + +Specs and plans live flat in `docs/`: +- **Specs** → `docs/specs/YYYY-MM-DD--design.md` +- **Plans** → `docs/plans/YYYY-MM-DD-.md` + +These override the superpowers skill defaults (`docs/superpowers/...`). + ## Project Skills Project-local skills live in `.claude/skills/`. Invoke them by asking Claude to "use the [skill name] skill" or by reading the skill file directly. diff --git a/docs/HANDBOOK.md b/docs/HANDBOOK.md index 2008709..0ee0bd2 100644 --- a/docs/HANDBOOK.md +++ b/docs/HANDBOOK.md @@ -242,9 +242,8 @@ shadowdark/ ├── docs/ │ ├── ROADMAP.md # Feature backlog and ideas │ ├── HANDBOOK.md # This file -│ └── superpowers/ -│ ├── specs/ # Design specs (brainstorming output) -│ └── plans/ # Implementation plans +│ ├── specs/ # Design specs (brainstorming output) +│ └── plans/ # Implementation plans ├── .claude/ │ └── skills/ # Project-local Claude skills ├── CHANGELOG.md # Version history @@ -310,9 +309,9 @@ We use a structured approach: **brainstorm → spec → plan → implement**. > Skills are Claude Code slash commands defined in `.claude/skills/` — invoke them inside a Claude Code session. -2. **Spec** saved to `docs/superpowers/specs/YYYY-MM-DD-feature-design.md` +2. **Spec** saved to `docs/specs/YYYY-MM-DD-feature-design.md` 3. **Plan** (`/writing-plans` skill) — detailed implementation plan with code in every step -4. **Plan** saved to `docs/superpowers/plans/YYYY-MM-DD-feature.md` +4. **Plan** saved to `docs/plans/YYYY-MM-DD-feature.md` 5. **Implement** — subagent-driven development, one task at a time with spec + quality reviews All specs and plans are committed to the repo. Check `docs/superpowers/` to understand why things were built the way they were. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9ac49ba..41b10db 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -21,6 +21,7 @@ A living document tracking planned features, improvements, and ideas. Items move ## Planned ### Gameplay Tools +- [ ] **Per-enemy dice rolls in initiative tracker** — DM can trigger attack/damage rolls directly from each enemy entry in the tracker sidebar; useful when enemies act individually rather than as a named group - [ ] **Party loot / shared inventory** — campaign-level shared pool, DM-managed - [ ] **Creature gallery / NPC system** — DM-managed library of enemy stat blocks (name, HP, AC, etc.); quick-add to initiative tracker; reusable across sessions diff --git a/docs/superpowers/plans/2026-04-10-darkwatch-auth-db.md b/docs/plans/2026-04-10-darkwatch-auth-db.md similarity index 100% rename from docs/superpowers/plans/2026-04-10-darkwatch-auth-db.md rename to docs/plans/2026-04-10-darkwatch-auth-db.md diff --git a/docs/superpowers/plans/2026-04-10-particle-effects.md b/docs/plans/2026-04-10-particle-effects.md similarity index 100% rename from docs/superpowers/plans/2026-04-10-particle-effects.md rename to docs/plans/2026-04-10-particle-effects.md diff --git a/docs/superpowers/plans/2026-04-11-character-creation.md b/docs/plans/2026-04-11-character-creation.md similarity index 100% rename from docs/superpowers/plans/2026-04-11-character-creation.md rename to docs/plans/2026-04-11-character-creation.md diff --git a/docs/plans/2026-04-11-handbook-changelog.md b/docs/plans/2026-04-11-handbook-changelog.md new file mode 100644 index 0000000..64ea711 --- /dev/null +++ b/docs/plans/2026-04-11-handbook-changelog.md @@ -0,0 +1,661 @@ +# Handbook & Changelog 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:** Create CHANGELOG.md and docs/HANDBOOK.md capturing the current state of Darkwatch, plus two project-local Claude skills for maintaining them. + +**Architecture:** Four markdown files — two are project docs (CHANGELOG.md at repo root, docs/HANDBOOK.md for collaborators), two are skill files (.claude/skills/) that instruct Claude how to update those docs when features change. Skills are committed to the repo so all collaborators get them. + +**Tech Stack:** Markdown · Keep a Changelog format · Semver + +--- + +## File Map + +| Action | Path | Purpose | +|---|---|---| +| Create | `CHANGELOG.md` | Version history, Keep a Changelog format, v0.1.0 initial entry | +| Create | `docs/HANDBOOK.md` | Full project reference: product overview + technical guide | +| Create | `.claude/skills/update-changelog.md` | Skill: update CHANGELOG.md when features ship | +| Create | `.claude/skills/update-handbook.md` | Skill: update HANDBOOK.md when features change | +| Modify | `CLAUDE.md` | Document available project skills | +| Modify | `.gitignore` | Ensure `.claude/settings.local.json` is ignored, skills are not | + +--- + +## Task 1: CHANGELOG.md + +**Files:** +- Create: `CHANGELOG.md` + +- [ ] **Step 1: Create `CHANGELOG.md` at repo root** + +```markdown +# Changelog + +All notable changes to Darkwatch are documented here. + +Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) +Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) + +--- + +## [Unreleased] + +## [0.1.0] - 2026-04-11 + +### Added + +**Auth & Access** +- Email/password authentication with JWT httpOnly cookies +- Register, login, and logout flows +- Campaign membership — DM and player roles +- Invite link system for joining campaigns +- Per-campaign role enforcement — API middleware, socket auth, and frontend gating + +**Campaign Management** +- Campaign CRUD with real-time sync via Socket.IO +- DM and player roles with distinct permissions per campaign + +**Characters** +- Full character CRUD — stats (STR, DEX, CON, INT, WIS, CHA), gear, talents, HP, XP, currency +- Auto-calculated AC and derived attacks +- Talent effects — stat bonuses, AC, attack/damage, gear slots, HP scaling +- Click-to-edit stats with view/edit mode split +- Per-character color picker +- DiceBear avatars with style picker +- Character title auto-derived from class + alignment + level +- Character creation wizard — 4-step modal: name/class/ancestry → 3d6 stat rolling → background/alignment/deity → review (HP, gold, gear slots, title all auto-derived) + +**Spellcasting** +- 34 Tier 1–2 spells (Wizard and Priest) +- 1d20 cast checks with exhaustion on failure +- Wizard mishap table (d12, auto-applied with undo) +- Priest penance mechanic +- Rest to recover spells +- Spell focus indicator on DM character cards + +**Dice Rolling** +- 3D animated dice with predetermined outcomes +- Character-colored dice +- Delayed roll log +- Advantage/disadvantage support +- Long-press for advantage/disadvantage on mobile + +**Torch Timer & Luck Tokens** +- 60-minute torch countdown, synced across all clients +- Luck token toggle per character + +**Atmosphere Effects** +- Fog overlay with intensity slider — DM-only, synced via socket +- Fire effect (Three.js particles) +- Rain and embers (tsParticles) + +**Initiative Tracker** +- Team-based Shadowdark initiative: party rolls d20 individually (system takes highest) vs DM's single d20 for enemies +- Rolling phase: each player rolls for their character, DM rolls for enemies +- Active phase: turn order display, round counter, side highlighting +- Enemy HP tracking — visible to DM only, hidden from players +- Add/remove enemies mid-combat +- Real-time sync via Socket.IO +- Combat state persisted to database (survives server restarts) + +**DM Tools** +- Compact DM character cards — 3-up grid showing HP, AC, luck, torch, and stat modifiers +- Atmosphere control panel (DM-only) + +**UI & Platform** +- Medieval visual theme — Cinzel + Alegreya fonts, parchment texture, gold filigree, dark color scheme +- Mobile layout — full-screen character sheet, roll log drawer +- Custom SelectDropdown — consistent cross-platform styling +- MariaDB 11 via Docker (replaced SQLite) +``` + +- [ ] **Step 2: Verify the file looks correct** + +```bash +cat CHANGELOG.md | head -20 +``` + +Expected: header lines and `## [0.1.0] - 2026-04-11` visible. + +- [ ] **Step 3: Commit** + +```bash +git add CHANGELOG.md +git commit -m "docs: add CHANGELOG.md at v0.1.0" +``` + +--- + +## Task 2: docs/HANDBOOK.md + +**Files:** +- Create: `docs/HANDBOOK.md` + +This is the largest task. The handbook has two parts: a product overview (for anyone) and a technical guide (for developers). Write the complete file in one step. + +- [ ] **Step 1: Create `docs/HANDBOOK.md`** + +```markdown +# Darkwatch Handbook + +**Last updated:** 2026-04-11 + +A reference for everyone working on or with Darkwatch — whether you're a DM discovering the tool, a developer joining the project, or a collaborator adding features. + +## Table of Contents + +- [Part 1: Product Overview](#part-1-product-overview) + - [What is Darkwatch?](#what-is-darkwatch) + - [Who is it for?](#who-is-it-for) + - [How a session works](#how-a-session-works) + - [Features](#features) + - [What's coming next](#whats-coming-next) + - [Known limitations](#known-limitations) +- [Part 2: Technical Guide](#part-2-technical-guide) + - [Stack](#stack) + - [Running locally](#running-locally) + - [Repo structure](#repo-structure) + - [Key patterns](#key-patterns) + - [Development workflow](#development-workflow) + +--- + +# Part 1: Product Overview + +## What is Darkwatch? + +Darkwatch is a real-time digital session companion for [Shadowdark RPG](https://www.thearcanelibrary.com/pages/shadowdark). It runs in a web browser — the DM opens a campaign and players join via invite link. Everything stays in sync automatically: HP changes, dice rolls, spell slots, atmosphere effects, and combat all update live for everyone at the table. + +It's not a virtual tabletop (no maps or tokens). It's the digital layer that sits alongside your physical table: character sheets, dice, a torch timer, initiative tracking, and the right amount of atmosphere. + +## Who is it for? + +- **Dungeon Masters** running Shadowdark campaigns who want a real-time view of the whole party without paper shuffling +- **Players** who want a clean digital character sheet that stays synced without everyone shouting HP updates across the table +- **Groups** playing in-person, online, or hybrid — it works in any browser + +## How a session works + +1. The DM creates a campaign and gets an invite link +2. Players click the link and join with their account (or create one) +3. The DM sees all characters in a compact card grid; players see their own full character sheet +4. Dice rolls, HP changes, spell casts, and atmosphere effects all sync in real time +5. When combat starts, the DM opens the initiative tracker — everyone rolls, the tracker resolves who goes first, and the DM advances turns + +Sessions are stateful: pausing mid-combat and resuming later works because combat state is saved to the database. + +## Features + +### Characters + +Each character has the full Shadowdark stat block: STR, DEX, CON, INT, WIS, CHA, HP, AC, XP, gold, gear slots, and currency. Stats are click-to-edit inline. AC and attacks are auto-calculated from stats and talents. + +**Character creation wizard** walks through four steps: name/class/ancestry → roll 3d6 for each stat → background/alignment/deity → review. HP, starting gold, gear slots, and character title are all derived automatically at the end. + +**Talents** can be added with name and effect (e.g. "+2 STR", "+1 gear slot"). They apply to the relevant derived values immediately. + +**Avatars** use DiceBear with a style picker. Each character also has a color used for their dice and name display. + +### Dice Rolling + +Dice roll in 3D with animation. Results are predetermined on the server (not client-side) so they're trustworthy. Dice match the character's color. Results appear in a roll log that all players can see. + +Supported: d4, d6, d8, d10, d12, d20, d100. Advantage and disadvantage are supported (roll two dice, take higher/lower). Long-press the roll button on mobile to trigger advantage or disadvantage. + +### Spellcasting + +34 Tier 1 and Tier 2 spells for Wizard and Priest classes. Casting rolls a d20 against a difficulty; failure causes exhaustion (one spell slot lost). The spell list shows current/max slots and highlights exhausted spells. + +**Wizard mishaps:** On a critical fail, a d12 mishap table is rolled automatically. The effect is applied and can be undone if the DM made an error. + +**Priest penance:** Failed casts trigger the Shadowdark penance mechanic. Rest recovers all spell slots. + +### Torch Timer + +A 60-minute countdown synced across all clients. The DM controls start/stop/reset. When the torch goes out, everyone knows. + +### Luck Tokens + +Each character has a luck token toggle. Players and the DM can both see and toggle luck token state. + +### Atmosphere Effects + +The DM can layer atmosphere effects that all players see in real time: + +- **Fog** — a dark gradient overlay with adjustable intensity +- **Fire** — Three.js particle simulation, full screen +- **Rain / Embers** — tsParticles overlay + +Atmosphere is DM-only to control; all players see the active effects. + +### Initiative Tracker + +Shadowdark uses team-based initiative — the whole party acts together, then all enemies act together. Darkwatch implements this directly: + +**Rolling phase:** Each player clicks "Roll Initiative" for their character. The server takes the highest individual roll as the party's result. The DM rolls once for all enemies. Either side can roll in any order. + +**Active phase:** Whichever side rolled higher acts first (ties go to the party). The DM clicks "Next Turn" to flip sides. The round counter increments each time the turn passes back to the party. + +**Enemy management (DM only):** Enemies are added at combat start with a name and max HP. HP is tracked inline and visible only to the DM — players see enemy names but no HP values. Enemies can be added or removed mid-combat. + +Combat state persists to the database. If the server restarts mid-fight, the tracker reappears when players rejoin. + +### DM View + +The DM sees all characters in a compact 3-up card grid showing: HP (current/max), AC, luck token state, torch timer, and all stat modifiers at a glance. The full character detail is one click away. + +## What's coming next + +Features currently planned or in progress: + +- **Talent roll UI** — roll 2d6 on the class talent table when leveling up; currently talents are added manually +- **Death timer** — when a character is dying: 1d4 + CON rounds to live, d20 each turn to rise at 1 HP +- **Conditions / status effects** — poisoned, stunned, dying, etc. shown on character cards +- **Per-enemy dice rolls** — DM triggers attack/damage rolls from individual enemies in the tracker +- **Party loot / shared inventory** — campaign-level shared item pool, DM-managed +- **Creature gallery / NPC system** — saved enemy stat blocks, quick-add to initiative tracker + +Longer-term ideas: multi-system support (Cairn, Knave, Cyberpunk RED), metrics and analytics, API documentation, and a public marketing site. + +## Known limitations + +- **Shadowdark only (for now)** — rules, stat blocks, and spell lists are Shadowdark-specific. Multi-system support is planned but not started. +- **No map or token support** — Darkwatch is a companion tool, not a full VTT. +- **Single combat displayed** — the initiative tracker shows one combat at a time. The backend supports multiple simultaneous combats (rare edge case) but the UI only shows the first. +- **No offline mode** — everything requires a server connection. There's no local-only or offline fallback. +- **No profile image uploads** — avatars use DiceBear. Custom image uploads are not yet supported. + +--- + +# Part 2: Technical Guide + +## Stack + +| Layer | Technology | +|---|---| +| Client | React 18 + Vite + TypeScript + CSS Modules | +| Server | Express + Socket.IO + TypeScript (tsx runner) | +| Database | MariaDB 11 (Docker container `darkwatch-maria`, port 3307) | +| DB client | mysql2/promise | +| Real-time | Socket.IO (rooms per campaign: `campaign:{id}`) | +| Auth | JWT in httpOnly cookies | +| 3D dice | @react-three/fiber + cannon-es (Ammo.js physics) | +| Particles | tsParticles (rain/embers), Three.js (fire) | +| Avatars | DiceBear | + +## Running locally + +**Prerequisites:** Node 18+, Docker + +**1. Start the database** + +```bash +docker start darkwatch-maria +``` + +If the container doesn't exist yet, it was created with: +```bash +docker run -d --name darkwatch-maria \ + -e MYSQL_DATABASE=darkwatch \ + -e MYSQL_USER=darkwatch \ + -e MYSQL_PASSWORD=darkwatch \ + -e MYSQL_ROOT_PASSWORD=root \ + -p 3307:3306 mariadb:11 +``` + +**2. Set up environment** + +Copy `server/.env.example` to `server/.env` (or ensure `server/.env` exists with DB credentials pointing to port 3307). + +**3. Start the server** + +```bash +cd server +npm install +npm run dev +``` + +The server runs migrations automatically on startup, then listens on `http://localhost:3000`. + +**4. Start the client** + +```bash +cd client +npm install +npm run dev -- --host +``` + +The client runs on `http://localhost:5173` (or the next available port). The `--host` flag exposes it on the local network (useful for testing on a phone via ngrok). + +**Dev seed accounts:** +- DM: `dm@darkwatch.test` / `password` +- Player: `player@darkwatch.test` / `password` + +## Repo structure + +``` +shadowdark/ +├── client/ # React + Vite frontend +│ ├── src/ +│ │ ├── components/ # Reusable UI components +│ │ ├── pages/ # Route-level pages (CampaignView, Login, etc.) +│ │ ├── context/ # React context (AuthContext) +│ │ ├── lib/ # Utility libraries +│ │ ├── types.ts # Shared TypeScript types +│ │ └── socket.ts # Socket.IO singleton +│ └── public/ # Static assets (dice themes, etc.) +├── server/ +│ ├── src/ +│ │ ├── routes/ # Express REST routes + socket handler files +│ │ ├── index.ts # Express app setup +│ │ ├── socket.ts # Socket.IO setup, connection handler +│ │ ├── db.ts # MariaDB pool +│ │ ├── dice.ts # Server-side dice rolling +│ │ ├── auth.ts # JWT helpers +│ │ └── migrate.ts # Migration runner +│ └── migrations/ # Numbered SQL migration files (001_, 002_, ...) +├── docs/ +│ ├── ROADMAP.md # Feature backlog and ideas +│ ├── HANDBOOK.md # This file +│ └── superpowers/ +│ ├── specs/ # Design specs (brainstorming output) +│ └── plans/ # Implementation plans +├── .claude/ +│ └── skills/ # Project-local Claude skills +├── CHANGELOG.md # Version history +└── CLAUDE.md # Claude Code instructions for this project +``` + +## Key patterns + +### Socket rooms + +Every campaign has a Socket.IO room named `campaign:{id}`. Clients join on page load: + +```typescript +socket.emit("join-campaign", String(campaignId)); +``` + +Server handlers broadcast to the room: +```typescript +io.to(`campaign:${campaignId}`).emit("event-name", data); +``` + +The initiative tracker uses a role-aware pattern: `socket.to(room)` sends to all except the emitting socket; `socket.emit()` sends back to the emitter. This lets the DM receive full data while players receive HP-stripped data in one operation. + +### CSS Modules and variables + +All components use CSS Modules (`.module.css` files co-located with the component). Global CSS variables are defined in `client/src/theme.css`: + +Key variables: `--gold`, `--gold-rgb`, `--bg-modal`, `--bg-input`, `--text-primary`, `--text-secondary`, `--text-tertiary`, `--btn-active-text`, `--danger`, `--danger-rgb`, `--font-display`. + +Always use CSS variables for colors — never hardcode hex values in component CSS. + +### Database migrations + +Migrations live in `server/migrations/` as numbered SQL files: `001_initial_schema.sql`, `002_spells.sql`, `003_combat_state.sql`, etc. The migration runner (`server/src/migrate.ts`) tracks applied migrations in a `_migrations` table and runs new ones on server startup. + +To add a migration: create the next numbered file. It runs automatically on the next server restart. + +### Auth and roles + +Auth uses JWT stored in httpOnly cookies. The token contains `{ userId }`. On socket connection, the auth middleware reads the cookie and populates `socket.data.user`. + +Campaign roles (DM or player) are stored in the `campaign_members` table. Role enforcement happens: +- REST routes: middleware checks `campaign_members` before handling requests +- Socket handlers: `checkDM()` queries `campaign_members` for DM-only events +- Frontend: `role === "dm"` gates DM-only UI elements + +### All imports use `.js` extensions + +The project uses ES modules. Even when importing `.tsx` or `.ts` files, use `.js` in the import path: + +```typescript +import InitiativeTracker from "../components/InitiativeTracker.js"; +import type { CombatState } from "../types.js"; +``` + +Vite and tsx resolve `.js` to the TypeScript source at build/dev time. + +## Development workflow + +We use a structured approach: **brainstorm → spec → plan → implement**. + +1. **Brainstorm** (`/brainstorm` skill) — collaborative design session; produces a spec doc +2. **Spec** saved to `docs/superpowers/specs/YYYY-MM-DD-feature-design.md` +3. **Plan** (`/writing-plans` skill) — detailed implementation plan with code in every step +4. **Plan** saved to `docs/superpowers/plans/YYYY-MM-DD-feature.md` +5. **Implement** — subagent-driven development, one task at a time with spec + quality reviews + +All specs and plans are committed to the repo. Check `docs/superpowers/` to understand why things were built the way they were. + +**Project-local Claude skills** live in `.claude/skills/`. See `CLAUDE.md` for the list of available skills and how to invoke them. +``` + +- [ ] **Step 2: Verify the file was created** + +```bash +wc -l docs/HANDBOOK.md +``` + +Expected: over 200 lines. + +- [ ] **Step 3: Commit** + +```bash +git add docs/HANDBOOK.md +git commit -m "docs: add HANDBOOK.md with product overview and technical guide" +``` + +--- + +## Task 3: update-changelog skill + +**Files:** +- Create: `.claude/skills/update-changelog.md` + +- [ ] **Step 1: Create `.claude/skills/` directory and the skill file** + +```bash +mkdir -p .claude/skills +``` + +Then create `.claude/skills/update-changelog.md`: + +```markdown +# update-changelog + +Use this skill when a feature has shipped, a bug has been fixed, or a feature has been removed. It updates CHANGELOG.md with a new versioned entry. + +## Process + +1. Read `CHANGELOG.md` — find the current latest version number (top `## [x.y.z]` entry) + +2. Check recent git history for context on what changed: + ```bash + git log --oneline -20 + ``` + +3. Read `docs/ROADMAP.md` — check what moved to Completed since the last changelog entry + +4. Determine the version bump: + - **Patch** (0.1.x → 0.1.x+1): bug fixes, minor UI polish, no new user-facing features + - **Minor** (0.x.0 → 0.x+1.0): new features or meaningful enhancements + - **Major** (x.0.0 → x+1.0.0): breaking changes or significant redesigns (rare) + +5. Write a new entry **above** the previous version, using today's date and the bumped version: + + ```markdown + ## [0.2.0] - YYYY-MM-DD + + ### Added + - Feature name — brief description of what it does for users + + ### Changed + - What changed and why + + ### Fixed + - Bug description — what was wrong and what's right now + + ### Removed + - What was removed and why + ``` + + Only include sections that apply. Skip empty sections. + +6. Update `docs/HANDBOOK.md` if the change affects feature descriptions or known limitations (use the `update-handbook` skill or do it inline). + +7. Commit: + ```bash + git add CHANGELOG.md + git commit -m "chore: changelog v0.x.y" + ``` + +## Versioning rules + +- Write entries for users, not developers ("Added initiative tracker" not "Added registerInitiativeHandlers to socket.ts") +- One entry per version bump — don't create multiple entries for the same version +- The `[Unreleased]` section at the top is for changes not yet assigned a version; move them down when cutting a release +- Keep descriptions brief but specific — "Fixed HP leak exposing enemy stats to players" beats "Fixed bug" +``` + +- [ ] **Step 2: Verify the skill file exists** + +```bash +ls .claude/skills/ +``` + +Expected: `update-changelog.md` + +- [ ] **Step 3: Commit** + +```bash +git add .claude/skills/update-changelog.md +git commit -m "feat: add update-changelog project skill" +``` + +--- + +## Task 4: update-handbook skill + +**Files:** +- Create: `.claude/skills/update-handbook.md` + +- [ ] **Step 1: Create `.claude/skills/update-handbook.md`** + +```markdown +# update-handbook + +Use this skill when a feature has been added, changed, or removed and the handbook needs updating. It keeps `docs/HANDBOOK.md` accurate. + +## Process + +1. Read `docs/HANDBOOK.md` in full. + +2. Identify what changed. Ask the user, or review recent git commits and CHANGELOG.md: + ```bash + git log --oneline -10 + cat CHANGELOG.md | head -40 + ``` + +3. Determine which sections need updating: + - New feature added → add a subsection under **Features** in Part 1; update **What's coming next** to remove it from planned + - Feature removed → remove or note it; update **Known limitations** if relevant + - Feature changed → update the relevant Features subsection + - New dev pattern introduced → update **Key patterns** in Part 2 + - Stack change → update the **Stack** table in Part 2 + - New workflow step → update **Development workflow** in Part 2 + +4. Make only the necessary edits. Don't rewrite sections that aren't affected. + +5. Update the `Last updated` date at the top of the file to today's date. + +6. Commit: + ```bash + git add docs/HANDBOOK.md + git commit -m "docs: update handbook for [feature name]" + ``` + +## Tone guidelines + +**Part 1 (Product Overview):** Write for someone who plays TTRPGs. Explain what something does from the user's perspective, not how it's implemented. "Players roll a d20 for their character; the system takes the highest" not "party_roll = Math.max(Object.values(party_rolls))". + +**Part 2 (Technical Guide):** Write for a developer. Be specific — include exact file paths, commands, and patterns. Don't leave anything as "see the code." +``` + +- [ ] **Step 2: Commit** + +```bash +git add .claude/skills/update-handbook.md +git commit -m "feat: add update-handbook project skill" +``` + +--- + +## Task 5: Register skills in CLAUDE.md and update .gitignore + +**Files:** +- Modify: `CLAUDE.md` +- Modify: `.gitignore` + +- [ ] **Step 1: Read the current CLAUDE.md** + +```bash +cat CLAUDE.md +``` + +- [ ] **Step 2: Add a Project Skills section to CLAUDE.md** + +Append the following section to the end of `CLAUDE.md`: + +```markdown + +## Project Skills + +Project-local skills live in `.claude/skills/`. Invoke them by asking Claude to "use the [skill name] skill" or by reading the skill file directly. + +| Skill | File | When to use | +|---|---|---| +| `update-changelog` | `.claude/skills/update-changelog.md` | After a feature ships, bug is fixed, or feature is removed | +| `update-handbook` | `.claude/skills/update-handbook.md` | After a feature is added, changed, or removed | +``` + +- [ ] **Step 3: Add `.claude/settings.local.json` to .gitignore** + +Append to `.gitignore`: + +``` +.claude/settings.local.json +``` + +- [ ] **Step 4: Verify .gitignore still tracks the skills** + +```bash +git check-ignore -v .claude/skills/update-changelog.md +``` + +Expected: no output (file is NOT ignored — it should be committed). + +- [ ] **Step 5: Commit** + +```bash +git add CLAUDE.md .gitignore +git commit -m "docs: register project skills in CLAUDE.md, gitignore local settings" +``` + +--- + +## Self-Review Checklist + +After all tasks complete, verify against the spec: + +- [ ] `CHANGELOG.md` at repo root, Keep a Changelog format, v0.1.0 entry covers all shipped features — Task 1 +- [ ] `docs/HANDBOOK.md` has both Part 1 (product overview) and Part 2 (technical guide) — Task 2 +- [ ] Part 1 covers: what Darkwatch is, who it's for, how a session works, all features, what's coming, known limitations — Task 2 +- [ ] Part 2 covers: stack, running locally, repo structure, key patterns, dev workflow — Task 2 +- [ ] `.claude/skills/update-changelog.md` exists and includes: git log check, version bump rules, entry format, commit step — Task 3 +- [ ] `.claude/skills/update-handbook.md` exists and includes: section identification, tone guidelines, commit step — Task 4 +- [ ] `CLAUDE.md` lists both skills with when-to-use guidance — Task 5 +- [ ] `.claude/settings.local.json` is gitignored; skill files are not — Task 5 diff --git a/docs/plans/2026-04-11-initiative-tracker.md b/docs/plans/2026-04-11-initiative-tracker.md new file mode 100644 index 0000000..9717506 --- /dev/null +++ b/docs/plans/2026-04-11-initiative-tracker.md @@ -0,0 +1,2089 @@ +# Initiative Tracker 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 a real-time, DM-managed combat initiative tracker that persists between sessions, supports Shadowdark's team-based initiative (party vs enemies), and hides enemy HP from players. + +**Architecture:** Combat state lives in a JSON array column on the `campaigns` table. A new `server/src/routes/initiative.ts` registers socket handlers for all `initiative:*` events; every mutation persists to DB then broadcasts to the campaign room — full data to the DM socket, HP-stripped data to players. A new `InitiativeTracker` sidebar component renders inside `CampaignView` when any combat is active, and a `CombatStartModal` handles the DM's setup flow. + +**Tech Stack:** MariaDB JSON column · Socket.IO (existing room pattern) · React + CSS Modules · TypeScript · No new npm packages required + +--- + +## File Map + +| Action | Path | Purpose | +|---|---|---| +| Create | `server/migrations/003_combat_state.sql` | Add JSON column to campaigns | +| Create | `server/src/routes/initiative.ts` | All initiative:* socket handlers | +| Modify | `server/src/socket.ts` | Register initiative handlers + send state on join | +| Modify | `client/src/types.ts` | Add CombatState, CombatEnemy interfaces | +| Create | `client/src/components/InitiativeTracker.tsx` | Sidebar component (rolling + active phases) | +| Create | `client/src/components/InitiativeTracker.module.css` | Sidebar styles | +| Create | `client/src/components/CombatStartModal.tsx` | DM setup modal | +| Create | `client/src/components/CombatStartModal.module.css` | Modal styles | +| Modify | `client/src/pages/CampaignView.tsx` | State, socket listeners, layout, ⚔ button | +| Modify | `client/src/pages/CampaignView.module.css` | Combat layout styles | + +--- + +## Task 1: DB Migration + +**Files:** +- Create: `server/migrations/003_combat_state.sql` + +- [ ] **Step 1: Create the migration file** + +```sql +-- server/migrations/003_combat_state.sql +ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS combat_state JSON NULL DEFAULT NULL; +``` + +- [ ] **Step 2: Restart the server to apply migration** + +```bash +cd /Users/aaron.wood/workspace/shadowdark/server +npm run dev +``` + +Expected: console prints `Migration applied: 003_combat_state.sql` + +- [ ] **Step 3: Verify column exists** + +```bash +docker exec darkwatch-maria mariadb -u darkwatch -pdarkwatch darkwatch \ + -e "DESCRIBE campaigns;" +``` + +Expected: `combat_state` column of type `longtext` (MariaDB stores JSON as longtext) with `NULL` default. + +- [ ] **Step 4: Commit** + +```bash +git add server/migrations/003_combat_state.sql +git commit -m "feat: add combat_state JSON column to campaigns" +``` + +--- + +## Task 2: Server — initiative socket handlers + +**Files:** +- Create: `server/src/routes/initiative.ts` + +This file exports `registerInitiativeHandlers(io, socket)` and two helpers (`loadCombats`, `stripHp`) used by socket.ts in Task 3. + +- [ ] **Step 1: Create `server/src/routes/initiative.ts` with helpers and all handlers** + +```typescript +import type { Server, Socket } from "socket.io"; +import type { RowDataPacket } from "mysql2"; +import { randomUUID } from "crypto"; +import db from "../db.js"; +import { rollDice } from "../dice.js"; + +// ── Types ────────────────────────────────────────────────────────────────── + +export interface CombatEnemy { + id: string; + name: string; + hp_current: number; + hp_max: number; +} + +export interface CombatState { + id: string; + label?: string; + mode: "team"; + round: number; + phase: "rolling" | "active"; + current_side: "party" | "enemies"; + party_roll: number | null; + enemy_roll: number | null; + party_rolls: Record; + character_ids: number[]; + enemies: CombatEnemy[]; +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +export async function loadCombats(campaignId: number): Promise { + const [rows] = await db.execute( + "SELECT combat_state FROM campaigns WHERE id = ?", + [campaignId] + ); + if (rows.length === 0 || rows[0].combat_state === null) return []; + const raw = rows[0].combat_state; + return typeof raw === "string" ? JSON.parse(raw) : raw; +} + +async function saveCombats(campaignId: number, combats: CombatState[]): Promise { + await db.execute( + "UPDATE campaigns SET combat_state = ? WHERE id = ?", + [combats.length > 0 ? JSON.stringify(combats) : null, campaignId] + ); +} + +// Returns combats with hp_current and hp_max removed from every enemy. +export function stripHp(combats: CombatState[]): Omit[][] { + return combats.map((c) => ({ + ...c, + enemies: c.enemies.map(({ id, name }) => ({ id, name })), + })) as unknown as any; +} + +// Broadcast full state to the emitting (DM) socket, HP-stripped to everyone else. +function broadcast( + io: Server, + socket: Socket, + campaignId: number, + combats: CombatState[] +): void { + socket.to(`campaign:${campaignId}`).emit("initiative:updated", stripHp(combats)); + socket.emit("initiative:updated", combats); +} + +async function checkDM(socket: Socket, campaignId: number): Promise { + const userId = socket.data.user?.userId; + const [rows] = await db.execute( + "SELECT role FROM campaign_members WHERE campaign_id = ? AND user_id = ?", + [campaignId, userId] + ); + return rows.length > 0 && rows[0].role === "dm"; +} + +async function insertRollLog( + campaignId: number, + characterId: number | null, + characterName: string, + characterColor: string, + label: string, + rollValue: number, + rolls: number[] +): Promise { + const [ins] = await db.execute( + `INSERT INTO roll_log + (campaign_id, character_id, character_name, character_color, + type, subtype, label, dice_expression, rolls, modifier, total, + advantage, disadvantage, nat20) + VALUES (?, ?, ?, ?, 'custom', 'initiative', ?, '1d20', ?, 0, ?, 0, 0, ?)`, + [ + campaignId, characterId, characterName, characterColor, + label, JSON.stringify(rolls), rollValue, rollValue === 20 ? 1 : 0, + ] + ); + const [saved] = await db.execute( + "SELECT * FROM roll_log WHERE id = ?", + [ins.insertId] + ); + return saved[0]; +} + +// ── Handler registration ─────────────────────────────────────────────────── + +export function registerInitiativeHandlers(io: Server, socket: Socket): void { + // Send state to requesting client (called right after join-campaign). + socket.on("initiative:request-state", async (campaignId: number) => { + const userId = socket.data.user?.userId; + const [rows] = await db.execute( + "SELECT role FROM campaign_members WHERE campaign_id = ? AND user_id = ?", + [campaignId, userId] + ); + const dm = rows.length > 0 && rows[0].role === "dm"; + const combats = await loadCombats(campaignId); + socket.emit("initiative:state", dm ? combats : stripHp(combats)); + }); + + // DM: open rolling phase with enemies configured. + socket.on("initiative:start", async (data: { + campaignId: number; + label?: string; + character_ids: number[]; + enemies: Array<{ name: string; hp_max: number }>; + }) => { + if (!await checkDM(socket, data.campaignId)) return; + + const combats = await loadCombats(data.campaignId); + const newCombat: CombatState = { + id: randomUUID(), + label: data.label, + mode: "team", + round: 1, + phase: "rolling", + current_side: "party", + party_roll: null, + enemy_roll: null, + party_rolls: {}, + character_ids: data.character_ids, + enemies: data.enemies.map((e) => ({ + id: randomUUID(), + name: e.name, + hp_current: e.hp_max, + hp_max: e.hp_max, + })), + }; + const updated = [...combats, newCombat]; + await saveCombats(data.campaignId, updated); + broadcast(io, socket, data.campaignId, updated); + }); + + // Any campaign member: roll d20 for their character's initiative. + socket.on("initiative:roll", async (data: { + campaignId: number; + combatId: string; + characterId: number; + characterName: string; + characterColor: string; + }) => { + const userId = socket.data.user?.userId; + const dm = await checkDM(socket, data.campaignId); + if (!dm) { + const [charRows] = await db.execute( + "SELECT user_id FROM characters WHERE id = ?", + [data.characterId] + ); + if (charRows.length === 0 || charRows[0].user_id !== userId) return; + } + + const result = rollDice("1d20", {}); + const rollValue = result.total; + + const saved = await insertRollLog( + data.campaignId, data.characterId, data.characterName, data.characterColor, + "Initiative", rollValue, result.rolls + ); + io.to(`campaign:${data.campaignId}`).emit("roll:result", { + ...saved, + rolls: result.rolls, + advantage: false, + disadvantage: false, + nat20: rollValue === 20, + }); + + const combats = await loadCombats(data.campaignId); + const updated = combats.map((c) => { + if (c.id !== data.combatId) return c; + const newRolls = { ...c.party_rolls, [data.characterId]: rollValue }; + return { + ...c, + party_rolls: newRolls, + party_roll: Math.max(...Object.values(newRolls)), + }; + }); + await saveCombats(data.campaignId, updated); + broadcast(io, socket, data.campaignId, updated); + }); + + // DM: roll d20 for enemies. + socket.on("initiative:enemy-roll", async (data: { + campaignId: number; + combatId: string; + }) => { + if (!await checkDM(socket, data.campaignId)) return; + + const result = rollDice("1d20", {}); + const rollValue = result.total; + + const saved = await insertRollLog( + data.campaignId, null, "DM", "#888888", + "Enemy Initiative", rollValue, result.rolls + ); + io.to(`campaign:${data.campaignId}`).emit("roll:result", { + ...saved, + rolls: result.rolls, + advantage: false, + disadvantage: false, + nat20: rollValue === 20, + }); + + const combats = await loadCombats(data.campaignId); + const updated = combats.map((c) => + c.id !== data.combatId ? c : { ...c, enemy_roll: rollValue } + ); + await saveCombats(data.campaignId, updated); + broadcast(io, socket, data.campaignId, updated); + }); + + // DM: end rolling phase, set current_side based on results. Ties go to party. + socket.on("initiative:begin", async (data: { + campaignId: number; + combatId: string; + }) => { + if (!await checkDM(socket, data.campaignId)) return; + + const combats = await loadCombats(data.campaignId); + const updated = combats.map((c) => { + if (c.id !== data.combatId) return c; + const partyWins = (c.party_roll ?? 0) >= (c.enemy_roll ?? 0); + return { + ...c, + phase: "active" as const, + current_side: (partyWins ? "party" : "enemies") as "party" | "enemies", + }; + }); + await saveCombats(data.campaignId, updated); + broadcast(io, socket, data.campaignId, updated); + }); + + // DM: flip active side; increment round when side flips back to party. + socket.on("initiative:next", async (data: { + campaignId: number; + combatId: string; + }) => { + if (!await checkDM(socket, data.campaignId)) return; + + const combats = await loadCombats(data.campaignId); + const updated = combats.map((c) => { + if (c.id !== data.combatId) return c; + const nextSide: "party" | "enemies" = + c.current_side === "party" ? "enemies" : "party"; + return { + ...c, + current_side: nextSide, + round: nextSide === "party" ? c.round + 1 : c.round, + }; + }); + await saveCombats(data.campaignId, updated); + broadcast(io, socket, data.campaignId, updated); + }); + + // DM: edit an enemy's name or HP. + socket.on("initiative:update-enemy", async (data: { + campaignId: number; + combatId: string; + enemyId: string; + name?: string; + hp_current?: number; + hp_max?: number; + }) => { + if (!await checkDM(socket, data.campaignId)) return; + + const combats = await loadCombats(data.campaignId); + const updated = combats.map((c) => { + if (c.id !== data.combatId) return c; + return { + ...c, + enemies: c.enemies.map((e) => + e.id !== data.enemyId ? e : { + ...e, + ...(data.name !== undefined ? { name: data.name } : {}), + ...(data.hp_current !== undefined ? { hp_current: data.hp_current } : {}), + ...(data.hp_max !== undefined ? { hp_max: data.hp_max } : {}), + } + ), + }; + }); + await saveCombats(data.campaignId, updated); + broadcast(io, socket, data.campaignId, updated); + }); + + // DM: add a new enemy mid-combat. + socket.on("initiative:add-enemy", async (data: { + campaignId: number; + combatId: string; + name: string; + hp_max: number; + }) => { + if (!await checkDM(socket, data.campaignId)) return; + + const combats = await loadCombats(data.campaignId); + const updated = combats.map((c) => + c.id !== data.combatId ? c : { + ...c, + enemies: [...c.enemies, { + id: randomUUID(), + name: data.name, + hp_current: data.hp_max, + hp_max: data.hp_max, + }], + } + ); + await saveCombats(data.campaignId, updated); + broadcast(io, socket, data.campaignId, updated); + }); + + // DM: remove an enemy (died, fled). + socket.on("initiative:remove-enemy", async (data: { + campaignId: number; + combatId: string; + enemyId: string; + }) => { + if (!await checkDM(socket, data.campaignId)) return; + + const combats = await loadCombats(data.campaignId); + const updated = combats.map((c) => + c.id !== data.combatId ? c : { + ...c, + enemies: c.enemies.filter((e) => e.id !== data.enemyId), + } + ); + await saveCombats(data.campaignId, updated); + broadcast(io, socket, data.campaignId, updated); + }); + + // DM: end combat, remove from array (saves NULL when last combat ends). + socket.on("initiative:end", async (data: { + campaignId: number; + combatId: string; + }) => { + if (!await checkDM(socket, data.campaignId)) return; + + const combats = await loadCombats(data.campaignId); + const updated = combats.filter((c) => c.id !== data.combatId); + await saveCombats(data.campaignId, updated); + const toSend = updated.length > 0 ? updated : []; + socket.to(`campaign:${data.campaignId}`).emit("initiative:updated", toSend); + socket.emit("initiative:updated", toSend); + }); +} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +```bash +cd /Users/aaron.wood/workspace/shadowdark/server +npx tsc --noEmit +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add server/src/routes/initiative.ts +git commit -m "feat: add initiative socket handlers" +``` + +--- + +## Task 3: Server — wire initiative handlers into socket.ts + +**Files:** +- Modify: `server/src/socket.ts` + +- [ ] **Step 1: Add import and handler registration to socket.ts** + +At the top of `server/src/socket.ts`, add the import after existing imports: + +```typescript +import { registerInitiativeHandlers } from "./routes/initiative.js"; +``` + +Inside the `io.on("connection", (socket) => {` block, add after the existing `socket.on("disconnect", ...)`: + +```typescript + registerInitiativeHandlers(io, socket); + + socket.on("initiative:request-state", async (campaignId: number) => { + // Handled inside registerInitiativeHandlers — no duplicate needed. + // (initiative:request-state is registered there) + }); +``` + +Wait — `initiative:request-state` is already registered inside `registerInitiativeHandlers`. No duplicate needed. Just add the one line: + +Replace the `setupSocket` function body to add the registration. The new full `setupSocket` function (only the relevant additions — leave everything else unchanged): + +After the `socket.on("disconnect", ...)` block, add: + +```typescript + registerInitiativeHandlers(io, socket); +``` + +The final connection handler should look like: + +```typescript + io.on("connection", (socket) => { + socket.on("join-campaign", (campaignId: string) => { + socket.join(`campaign:${campaignId}`); + }); + + socket.on("leave-campaign", (campaignId: string) => { + socket.leave(`campaign:${campaignId}`); + }); + + socket.on("roll:request", async (data: { ... }) => { + // ... existing code unchanged ... + }); + + socket.on("atmosphere:update", async (data: AtmosphereUpdateData) => { + // ... existing code unchanged ... + }); + + socket.on("disconnect", () => { + // Rooms are cleaned up automatically by Socket.IO + }); + + registerInitiativeHandlers(io, socket); // ← ADD THIS LINE + }); +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +```bash +cd /Users/aaron.wood/workspace/shadowdark/server +npx tsc --noEmit +``` + +Expected: no errors. + +- [ ] **Step 3: Restart server and verify it starts cleanly** + +```bash +# Kill existing server (port 3000) and restart +pkill -f "ts-node\|tsx\|node.*server" 2>/dev/null; sleep 1 +cd /Users/aaron.wood/workspace/shadowdark/server && npm run dev & +sleep 3 && curl -s http://localhost:3000/api/auth/me | head -c 50 +``` + +Expected: server starts without errors, curl returns a JSON response. + +- [ ] **Step 4: Commit** + +```bash +git add server/src/socket.ts +git commit -m "feat: register initiative handlers in socket setup" +``` + +--- + +## Task 4: Client — add CombatState types + +**Files:** +- Modify: `client/src/types.ts` + +- [ ] **Step 1: Add CombatEnemy and CombatState interfaces to `client/src/types.ts`** + +Append to the end of the file (after the `Condition` interface): + +```typescript +export interface CombatEnemy { + id: string; + name: string; + hp_current?: number; // present only for DM; stripped before broadcast to players + hp_max?: number; // present only for DM; stripped before broadcast to players +} + +export interface CombatState { + id: string; + label?: string; + mode: "team"; + round: number; + phase: "rolling" | "active"; + current_side: "party" | "enemies"; + party_roll: number | null; + enemy_roll: number | null; + party_rolls: Record; // characterId → roll value + character_ids: number[]; + enemies: CombatEnemy[]; +} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +```bash +cd /Users/aaron.wood/workspace/shadowdark/client +npx tsc --noEmit +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add client/src/types.ts +git commit -m "feat: add CombatState and CombatEnemy types" +``` + +--- + +## Task 5: Client — InitiativeTracker component + +**Files:** +- Create: `client/src/components/InitiativeTracker.tsx` +- Create: `client/src/components/InitiativeTracker.module.css` + +The component renders two phases internally. It emits socket events directly (same pattern as DiceTray/AtmospherePanel). + +- [ ] **Step 1: Create `client/src/components/InitiativeTracker.tsx`** + +```typescript +import { useState } from "react"; +import socket from "../socket.js"; +import type { CombatState, Character } from "../types.js"; +import styles from "./InitiativeTracker.module.css"; + +interface InitiativeTrackerProps { + combat: CombatState; + characters: Character[]; + isDM: boolean; + currentUserId: number | null; + campaignId: number; +} + +export default function InitiativeTracker({ + combat, + characters, + isDM, + currentUserId, + campaignId, +}: InitiativeTrackerProps) { + const [addEnemyName, setAddEnemyName] = useState(""); + const [addEnemyHp, setAddEnemyHp] = useState(""); + const [showAddEnemy, setShowAddEnemy] = useState(false); + + const partyChars = characters.filter((c) => combat.character_ids.includes(c.id)); + + // Find the current user's character(s) in this combat. + const myCharIds = partyChars + .filter((c) => c.user_id === currentUserId) + .map((c) => c.id); + + function emitRoll(characterId: number) { + const char = characters.find((c) => c.id === characterId); + if (!char) return; + socket.emit("initiative:roll", { + campaignId, + combatId: combat.id, + characterId, + characterName: char.name, + characterColor: char.color, + }); + } + + function emitEnemyRoll() { + socket.emit("initiative:enemy-roll", { campaignId, combatId: combat.id }); + } + + function emitBegin() { + socket.emit("initiative:begin", { campaignId, combatId: combat.id }); + } + + function emitNext() { + socket.emit("initiative:next", { campaignId, combatId: combat.id }); + } + + function emitEnd() { + socket.emit("initiative:end", { campaignId, combatId: combat.id }); + } + + function emitUpdateEnemyHp(enemyId: string, hp_current: number) { + socket.emit("initiative:update-enemy", { + campaignId, + combatId: combat.id, + enemyId, + hp_current, + }); + } + + function emitRemoveEnemy(enemyId: string) { + socket.emit("initiative:remove-enemy", { campaignId, combatId: combat.id, enemyId }); + } + + function emitAddEnemy() { + const hp = parseInt(addEnemyHp, 10); + if (!addEnemyName.trim() || isNaN(hp) || hp < 1) return; + socket.emit("initiative:add-enemy", { + campaignId, + combatId: combat.id, + name: addEnemyName.trim(), + hp_max: hp, + }); + setAddEnemyName(""); + setAddEnemyHp(""); + setShowAddEnemy(false); + } + + return ( +
+
+ + {combat.label ? combat.label : "Initiative"} + + Round {combat.round} +
+ + {combat.phase === "rolling" ? ( + + ) : ( + + )} +
+ ); +} + +// ── Rolling phase ──────────────────────────────────────────────────────── + +interface RollingPhaseProps { + combat: CombatState; + partyChars: Character[]; + myCharIds: number[]; + isDM: boolean; + onRoll: (charId: number) => void; + onEnemyRoll: () => void; + onBegin: () => void; + onEnd: () => void; +} + +function RollingPhase({ + combat, + partyChars, + myCharIds, + isDM, + onRoll, + onEnemyRoll, + onBegin, + onEnd, +}: RollingPhaseProps) { + return ( + <> +
Rolling Initiative…
+ +
+
Party
+ {partyChars.map((c) => { + const rolled = combat.party_rolls[c.id]; + const canRoll = myCharIds.includes(c.id) && rolled === undefined; + return ( +
+ + {c.name} + {rolled !== undefined ? ( + {rolled} + ) : canRoll ? ( + + ) : ( + + )} +
+ ); + })} +
+ +
+
Enemies
+
+ Monsters + {combat.enemy_roll !== null ? ( + {combat.enemy_roll} + ) : isDM ? ( + + ) : ( + + )} +
+
+ + {isDM && ( +
+ + +
+ )} + + ); +} + +// ── Active phase ───────────────────────────────────────────────────────── + +interface ActivePhaseProps { + combat: CombatState; + partyChars: Character[]; + isDM: boolean; + showAddEnemy: boolean; + addEnemyName: string; + addEnemyHp: string; + onSetShowAddEnemy: (v: boolean) => void; + onSetAddEnemyName: (v: string) => void; + onSetAddEnemyHp: (v: string) => void; + onUpdateEnemyHp: (enemyId: string, hp: number) => void; + onRemoveEnemy: (enemyId: string) => void; + onAddEnemy: () => void; + onNext: () => void; + onEnd: () => void; +} + +function ActivePhase({ + combat, + partyChars, + isDM, + showAddEnemy, + addEnemyName, + addEnemyHp, + onSetShowAddEnemy, + onSetAddEnemyName, + onSetAddEnemyHp, + onUpdateEnemyHp, + onRemoveEnemy, + onAddEnemy, +}: ActivePhaseProps & { onNext: () => void; onEnd: () => void }) { + const partyActive = combat.current_side === "party"; + + return ( + <> + {/* Party block */} +
+
+ Party + {combat.party_roll !== null && ( + ({combat.party_roll} vs {combat.enemy_roll ?? "?"}) + )} +
+ {partyChars.map((c) => ( +
+ + + {c.name} + +
+ ))} +
+ + {/* Enemies block */} +
+
Enemies
+ {combat.enemies.map((e) => ( +
+ + + {e.name} + + {isDM && e.hp_current !== undefined && e.hp_max !== undefined && ( + + + onUpdateEnemyHp(e.id, Number(ev.target.value)) + } + /> + /{e.hp_max} + + )} + {isDM && ( + + )} +
+ ))} + + {isDM && showAddEnemy && ( +
+ onSetAddEnemyName(e.target.value)} + /> + onSetAddEnemyHp(e.target.value)} + /> + + +
+ )} + {isDM && !showAddEnemy && ( + + )} +
+ + {isDM && ( +
+ + +
+ )} + + ); +} +``` + +Wait — the `ActivePhase` footer has a bug passing `onNext`/`onEnd` through the destructured `arguments` trick. Fix that — pass them properly. Here is the corrected `ActivePhase` component footer only (replace the footer section): + +```typescript + {isDM && ( +
+ + +
+ )} +``` + +The full corrected `ActivePhase` component (replace the entire `ActivePhase` function with this): + +```typescript +function ActivePhase({ + combat, + partyChars, + isDM, + showAddEnemy, + addEnemyName, + addEnemyHp, + onSetShowAddEnemy, + onSetAddEnemyName, + onSetAddEnemyHp, + onUpdateEnemyHp, + onRemoveEnemy, + onAddEnemy, + onNext, + onEnd, +}: ActivePhaseProps) { + const partyActive = combat.current_side === "party"; + + return ( + <> +
+
+ Party + {combat.party_roll !== null && ( + + {" "}({combat.party_roll} vs {combat.enemy_roll ?? "?"}) + + )} +
+ {partyChars.map((c) => ( +
+ + + {c.name} + +
+ ))} +
+ +
+
Enemies
+ {combat.enemies.map((e) => ( +
+ + + {e.name} + + {isDM && e.hp_current !== undefined && e.hp_max !== undefined && ( + + onUpdateEnemyHp(e.id, Number(ev.target.value))} + /> + /{e.hp_max} + + )} + {isDM && ( + + )} +
+ ))} + + {isDM && showAddEnemy && ( +
+ onSetAddEnemyName(e.target.value)} + /> + onSetAddEnemyHp(e.target.value)} + /> + + +
+ )} + {isDM && !showAddEnemy && ( + + )} +
+ + {isDM && ( +
+ + +
+ )} + + ); +} +``` + +**Note for implementer:** Write the full file using the corrected `ActivePhase` (the one immediately above), not the first draft with the `arguments` trick. + +- [ ] **Step 2: Create `client/src/components/InitiativeTracker.module.css`** + +```css +.tracker { + display: flex; + flex-direction: column; + height: 100%; + padding: 0.65rem 0.5rem; + gap: 0; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 0.5rem; + margin-bottom: 0.4rem; + border-bottom: 1px solid rgba(var(--gold-rgb), 0.2); +} + +.title { + font-family: var(--font-display, "Cinzel", Georgia, serif); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--gold); +} + +.round { + font-size: 0.6rem; + color: var(--text-tertiary); +} + +.phaseLabel { + font-size: 0.65rem; + color: var(--text-tertiary); + font-style: italic; + margin-bottom: 0.5rem; +} + +/* ── Section blocks ── */ + +.section { + padding: 0.4rem 0.35rem; + border-radius: 3px; + border: 1px solid transparent; + margin-bottom: 0.4rem; + transition: border-color 0.15s, background 0.15s; +} + +.activeSection { + border-color: rgba(var(--gold-rgb), 0.35); + background: rgba(var(--gold-rgb), 0.04); + box-shadow: inset 2px 0 0 var(--gold); +} + +.sectionLabel { + font-family: var(--font-display, "Cinzel", Georgia, serif); + font-size: 0.6rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-secondary); + margin-bottom: 0.35rem; +} + +.rollSummary { + font-weight: 400; + font-family: inherit; + color: var(--text-tertiary); + font-size: 0.58rem; +} + +/* ── Combatant rows ── */ + +.rollRow, +.combatantRow { + display: flex; + align-items: center; + gap: 0.35rem; + padding: 0.2rem 0; + min-height: 1.6rem; +} + +.dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.enemyDot { + width: 6px; + height: 6px; + border-radius: 50%; + background: rgba(var(--gold-rgb), 0.3); + flex-shrink: 0; +} + +.rollName { + font-size: 0.68rem; + color: var(--text-secondary); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.activeName { + font-size: 0.68rem; + color: var(--gold); + font-weight: 700; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rollValue { + font-family: var(--font-display, "Cinzel", Georgia, serif); + font-size: 0.8rem; + font-weight: 700; + color: var(--gold); + min-width: 20px; + text-align: right; + flex-shrink: 0; +} + +.rollPending { + font-size: 0.7rem; + color: var(--text-tertiary); + min-width: 20px; + text-align: right; + flex-shrink: 0; +} + +.rollBtn { + font-family: var(--font-display, "Cinzel", Georgia, serif); + font-size: 0.58rem; + font-weight: 700; + letter-spacing: 0.04em; + padding: 0.2rem 0.4rem; + background: rgba(var(--gold-rgb), 0.85); + border: none; + border-radius: 3px; + color: #1a1408; + cursor: pointer; + flex-shrink: 0; + white-space: nowrap; +} + +.rollBtn:hover { + filter: brightness(1.1); +} + +/* ── Enemy HP ── */ + +.enemyHp { + display: flex; + align-items: center; + gap: 0.1rem; + flex-shrink: 0; +} + +.hpInput { + width: 32px; + font-size: 0.65rem; + font-weight: 700; + color: var(--hp, #e05a5a); + background: var(--bg-input, rgba(0,0,0,0.3)); + border: 1px solid rgba(var(--gold-rgb), 0.15); + border-radius: 2px; + padding: 0.1rem 0.2rem; + text-align: center; +} + +.hpInput:focus { + outline: none; + border-color: rgba(var(--gold-rgb), 0.4); +} + +/* Remove arrows on number inputs */ +.hpInput::-webkit-inner-spin-button, +.hpInput::-webkit-outer-spin-button { + -webkit-appearance: none; +} + +.hpMax { + font-size: 0.58rem; + color: var(--text-tertiary); +} + +.removeBtn { + font-size: 0.6rem; + padding: 0.1rem 0.25rem; + background: none; + border: 1px solid transparent; + border-radius: 2px; + color: var(--text-tertiary); + cursor: pointer; + flex-shrink: 0; + transition: all 0.12s; +} + +.removeBtn:hover { + color: var(--danger, #c0392b); + border-color: rgba(192, 57, 43, 0.3); +} + +/* ── Add enemy form ── */ + +.addEnemyBtn { + font-family: var(--font-display, "Cinzel", Georgia, serif); + font-size: 0.6rem; + font-weight: 600; + padding: 0.2rem 0.4rem; + background: transparent; + border: 1px dashed rgba(var(--gold-rgb), 0.25); + border-radius: 3px; + color: var(--text-tertiary); + cursor: pointer; + width: 100%; + margin-top: 0.25rem; + transition: all 0.12s; +} + +.addEnemyBtn:hover { + border-color: rgba(var(--gold-rgb), 0.45); + color: var(--gold); +} + +.addEnemyForm { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-top: 0.3rem; +} + +.addEnemyInput { + font-size: 0.65rem; + background: var(--bg-input, rgba(0,0,0,0.3)); + border: 1px solid rgba(var(--gold-rgb), 0.2); + border-radius: 3px; + padding: 0.25rem 0.4rem; + color: var(--text-primary); + width: 100%; +} + +.addEnemyInput:focus { + outline: none; + border-color: rgba(var(--gold-rgb), 0.45); +} + +/* ── Footer buttons ── */ + +.footer { + margin-top: auto; + padding-top: 0.5rem; + border-top: 1px solid rgba(var(--gold-rgb), 0.12); + display: flex; + gap: 0.3rem; +} + +.primaryBtn { + font-family: var(--font-display, "Cinzel", Georgia, serif); + font-size: 0.6rem; + font-weight: 700; + letter-spacing: 0.04em; + padding: 0.3rem 0.5rem; + background: rgba(var(--gold-rgb), 0.85); + border: none; + border-radius: 3px; + color: #1a1408; + cursor: pointer; + flex: 1; + transition: filter 0.12s; +} + +.primaryBtn:hover:not(:disabled) { + filter: brightness(1.1); +} + +.primaryBtn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.outlineBtn { + font-family: var(--font-display, "Cinzel", Georgia, serif); + font-size: 0.6rem; + font-weight: 600; + letter-spacing: 0.04em; + padding: 0.3rem 0.5rem; + background: transparent; + border: 1px solid rgba(var(--gold-rgb), 0.3); + border-radius: 3px; + color: var(--text-secondary); + cursor: pointer; + flex: 1; + transition: all 0.12s; +} + +.outlineBtn:hover { + border-color: rgba(var(--gold-rgb), 0.5); + color: var(--gold); +} +``` + +- [ ] **Step 3: Verify TypeScript compiles** + +```bash +cd /Users/aaron.wood/workspace/shadowdark/client +npx tsc --noEmit +``` + +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add client/src/components/InitiativeTracker.tsx \ + client/src/components/InitiativeTracker.module.css +git commit -m "feat: add InitiativeTracker sidebar component" +``` + +--- + +## Task 6: Client — CombatStartModal + +**Files:** +- Create: `client/src/components/CombatStartModal.tsx` +- Create: `client/src/components/CombatStartModal.module.css` + +- [ ] **Step 1: Create `client/src/components/CombatStartModal.tsx`** + +```typescript +import { useState } from "react"; +import socket from "../socket.js"; +import type { Character } from "../types.js"; +import styles from "./CombatStartModal.module.css"; + +interface CombatStartModalProps { + characters: Character[]; + campaignId: number; + onClose: () => void; +} + +interface EnemyEntry { + key: number; + name: string; + hp_max: string; +} + +let enemyKey = 0; + +export default function CombatStartModal({ + characters, + campaignId, + onClose, +}: CombatStartModalProps) { + const [label, setLabel] = useState(""); + const [selectedCharIds, setSelectedCharIds] = useState>( + new Set(characters.map((c) => c.id)) + ); + const [enemies, setEnemies] = useState([ + { key: enemyKey++, name: "", hp_max: "" }, + ]); + + function toggleChar(id: number) { + setSelectedCharIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + } + + function updateEnemy(key: number, field: "name" | "hp_max", value: string) { + setEnemies((prev) => + prev.map((e) => (e.key === key ? { ...e, [field]: value } : e)) + ); + } + + function addEnemyRow() { + setEnemies((prev) => [...prev, { key: enemyKey++, name: "", hp_max: "" }]); + } + + function removeEnemyRow(key: number) { + setEnemies((prev) => prev.filter((e) => e.key !== key)); + } + + function handleStart() { + const validEnemies = enemies + .filter((e) => e.name.trim() && parseInt(e.hp_max, 10) > 0) + .map((e) => ({ name: e.name.trim(), hp_max: parseInt(e.hp_max, 10) })); + + socket.emit("initiative:start", { + campaignId, + label: label.trim() || undefined, + character_ids: Array.from(selectedCharIds), + enemies: validEnemies, + }); + onClose(); + } + + const canStart = selectedCharIds.size > 0; + + return ( +
+
e.stopPropagation()}> +
+ Start Combat + +
+ + {/* Optional label */} +
+ + setLabel(e.target.value)} + /> +
+ + {/* Character selection */} +
+ +
+ {characters.map((c) => ( + + ))} +
+
+ + {/* Enemy entries */} +
+ + {enemies.map((e) => ( +
+ updateEnemy(e.key, "name", ev.target.value)} + /> + updateEnemy(e.key, "hp_max", ev.target.value)} + /> + +
+ ))} + +
+ +
+ + +
+
+
+ ); +} +``` + +- [ ] **Step 2: Create `client/src/components/CombatStartModal.module.css`** + +```css +.backdrop { + position: absolute; + inset: 0; + background: var(--bg-overlay, rgba(0,0,0,0.6)); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; + padding: 1rem; +} + +.modal { + background: var(--bg-modal, #1a1814); + background-image: var(--texture-surface), var(--texture-speckle); + background-size: 256px 256px, 128px 128px; + border: 2px solid rgba(var(--gold-rgb), 0.3); + border-radius: 4px; + width: 100%; + max-width: 480px; + max-height: 85vh; + overflow-y: auto; + padding: 1.25rem; + box-shadow: 0 8px 40px rgba(0,0,0,0.7); +} + +.modalHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.25rem; +} + +.modalTitle { + font-family: var(--font-display, "Cinzel", Georgia, serif); + font-size: 1rem; + font-weight: 700; + color: var(--gold); + letter-spacing: 0.05em; +} + +.closeBtn { + background: none; + border: none; + color: var(--text-secondary); + font-size: 1.2rem; + cursor: pointer; + padding: 0.1rem 0.3rem; +} + +.closeBtn:hover { + color: var(--text-primary); +} + +.field { + margin-bottom: 1rem; +} + +.fieldLabel { + display: block; + font-family: var(--font-display, "Cinzel", Georgia, serif); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-secondary); + margin-bottom: 0.4rem; +} + +.input { + width: 100%; + font-size: 0.82rem; + color: var(--text-primary); + background: var(--bg-input, rgba(0,0,0,0.3)); + border: 1px solid rgba(var(--gold-rgb), 0.2); + border-radius: 3px; + padding: 0.4rem 0.6rem; + font-family: inherit; +} + +.input:focus { + outline: none; + border-color: rgba(var(--gold-rgb), 0.5); +} + +.charList { + display: flex; + flex-direction: column; + gap: 0.3rem; + background: var(--bg-inset, rgba(0,0,0,0.2)); + border: 1px solid rgba(var(--gold-rgb), 0.12); + border-radius: 3px; + padding: 0.5rem 0.6rem; +} + +.charRow { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-size: 0.82rem; + color: var(--text-primary); +} + +.charRow input[type="checkbox"] { + accent-color: var(--gold); +} + +.charDot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.charName { + flex: 1; +} + +.enemyRow { + display: flex; + gap: 0.4rem; + align-items: center; + margin-bottom: 0.35rem; +} + +.enemyRow .input { + flex: 1; +} + +.hpInput { + max-width: 80px; + flex: 0 0 80px !important; +} + +.removeRowBtn { + font-size: 0.7rem; + padding: 0.25rem 0.4rem; + background: none; + border: 1px solid rgba(var(--gold-rgb), 0.15); + border-radius: 3px; + color: var(--text-tertiary); + cursor: pointer; + flex-shrink: 0; + transition: all 0.12s; +} + +.removeRowBtn:hover:not(:disabled) { + color: var(--danger, #c0392b); + border-color: rgba(192,57,43,0.3); +} + +.removeRowBtn:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.addRowBtn { + font-family: var(--font-display, "Cinzel", Georgia, serif); + font-size: 0.68rem; + font-weight: 600; + padding: 0.3rem 0.6rem; + background: transparent; + border: 1px dashed rgba(var(--gold-rgb), 0.3); + border-radius: 3px; + color: var(--text-secondary); + cursor: pointer; + width: 100%; + margin-top: 0.15rem; + transition: all 0.12s; +} + +.addRowBtn:hover { + border-color: rgba(var(--gold-rgb), 0.5); + color: var(--gold); +} + +.actions { + display: flex; + gap: 0.5rem; + margin-top: 1.25rem; + padding-top: 1rem; + border-top: 1px solid rgba(var(--gold-rgb), 0.15); +} + +.startBtn { + font-family: var(--font-display, "Cinzel", Georgia, serif); + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.05em; + padding: 0.5rem 1rem; + background: var(--btn-gold-bg, rgba(var(--gold-rgb), 0.85)); + border: none; + border-radius: 4px; + color: #1a1408; + cursor: pointer; + flex: 1; + transition: filter 0.12s; +} + +.startBtn:hover:not(:disabled) { + filter: brightness(1.1); +} + +.startBtn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.cancelBtn { + font-family: var(--font-display, "Cinzel", Georgia, serif); + font-size: 0.82rem; + font-weight: 600; + padding: 0.5rem 1rem; + background: transparent; + border: 1px solid rgba(var(--gold-rgb), 0.3); + border-radius: 4px; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.12s; +} + +.cancelBtn:hover { + border-color: rgba(var(--gold-rgb), 0.5); + color: var(--gold); +} +``` + +- [ ] **Step 3: Verify TypeScript compiles** + +```bash +cd /Users/aaron.wood/workspace/shadowdark/client +npx tsc --noEmit +``` + +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add client/src/components/CombatStartModal.tsx \ + client/src/components/CombatStartModal.module.css +git commit -m "feat: add CombatStartModal for DM combat setup" +``` + +--- + +## Task 7: Client — CampaignView integration + +**Files:** +- Modify: `client/src/pages/CampaignView.tsx` +- Modify: `client/src/pages/CampaignView.module.css` + +- [ ] **Step 1: Add combats state and socket wiring to `CampaignView.tsx`** + +Add imports at the top (after existing imports): + +```typescript +import type { CombatState } from "../types.js"; +import InitiativeTracker from "../components/InitiativeTracker.js"; +import CombatStartModal from "../components/CombatStartModal.js"; +``` + +Add state inside the `CampaignView` function, after `focusSpells` state: + +```typescript + const [combats, setCombats] = useState([]); + const [showCombatStart, setShowCombatStart] = useState(false); +``` + +Add to the main `useEffect` (after `socket.emit("join-campaign", ...)`) — emit the request for initiative state right after joining: + +```typescript + socket.emit("join-campaign", String(campaignId)); + socket.emit("initiative:request-state", campaignId); +``` + +Add two socket event handlers inside the socket listeners `useEffect`, after `onCharacterRested`: + +```typescript + function onInitiativeState(data: CombatState[]) { + setCombats(data); + } + + function onInitiativeUpdated(data: CombatState[]) { + setCombats(data); + } +``` + +Register them (inside same `useEffect`, after existing `socket.on` calls): + +```typescript + socket.on("initiative:state", onInitiativeState); + socket.on("initiative:updated", onInitiativeUpdated); +``` + +Unregister them (inside same `useEffect` return cleanup, after existing `socket.off` calls): + +```typescript + socket.off("initiative:state", onInitiativeState); + socket.off("initiative:updated", onInitiativeUpdated); +``` + +- [ ] **Step 2: Add the "⚔ Combat" header button and layout restructure to `CampaignView.tsx`** + +In the JSX, add the "⚔ Combat" button inside `.headerBtns`, after the Atmosphere panel and before Invite (DM-only): + +```tsx + {role === "dm" && ( + + )} +``` + +Replace the `
` section with a content wrapper that adds the sidebar when combat is active: + +```tsx +
0 ? styles.withCombat : ""}`}> + {combats.length > 0 && ( +
+ +
+ )} +
+ {characters.length === 0 && ( +

+ No characters yet. Add one to get started! +

+ )} + {characters.map((char) => ( + + ))} +
+
+``` + +Add the `CombatStartModal` just before the closing `
` of `.main`: + +```tsx + {showCombatStart && ( + setShowCombatStart(false)} + /> + )} +``` + +- [ ] **Step 3: Add combat layout styles to `CampaignView.module.css`** + +Append to the end of the file: + +```css +/* ── Combat layout ── */ + +.content { + display: block; +} + +.content.withCombat { + display: flex; + gap: 0; + align-items: flex-start; +} + +.combatSidebar { + width: 190px; + flex-shrink: 0; + border-right: 1px solid rgba(var(--gold-rgb), 0.15); + padding-right: 0.5rem; + margin-right: 0.75rem; + min-height: 300px; + position: sticky; + top: 0; +} + +.content.withCombat .grid { + flex: 1; + min-width: 0; +} + +.addBtnActive { + background: rgba(var(--gold-rgb), 0.15) !important; + border: 1px solid rgba(var(--gold-rgb), 0.5) !important; + color: var(--gold) !important; +} + +@media (max-width: 768px) { + .content.withCombat { + flex-direction: column; + } + + .combatSidebar { + width: 100%; + border-right: none; + border-bottom: 1px solid rgba(var(--gold-rgb), 0.15); + padding-right: 0; + margin-right: 0; + padding-bottom: 0.5rem; + margin-bottom: 0.75rem; + position: static; + } +} +``` + +- [ ] **Step 4: Verify TypeScript compiles** + +```bash +cd /Users/aaron.wood/workspace/shadowdark/client +npx tsc --noEmit +``` + +Expected: no errors. + +- [ ] **Step 5: Smoke test the full flow** + +1. Open `http://localhost:5173` in a browser, log in as DM (`dm@darkwatch.test` / `password`) +2. Open a campaign — header should show "⚔ Combat" button +3. Click "⚔ Combat" — CombatStartModal should appear with all characters checked +4. Add one enemy (e.g. "Goblin", HP 8), click "Roll Initiative ⚔" +5. The modal closes; the initiative sidebar should appear on the left showing "Rolling Initiative…" +6. Click "Roll for Enemies" d20 button — dice should animate, enemy roll appears +7. Click "Begin Combat ▶" — sidebar switches to active phase showing Party and Enemies blocks +8. Click "Next Turn ▶" — active side flips, round counter increments after second flip +9. Open a second browser tab logged in as player (`player@darkwatch.test` / `password`) — tracker should be visible but enemy HP should NOT appear +10. Click "End" in DM tab — sidebar disappears for both tabs + +- [ ] **Step 6: Commit** + +```bash +git add client/src/pages/CampaignView.tsx \ + client/src/pages/CampaignView.module.css +git commit -m "feat: integrate initiative tracker into campaign view" +``` + +--- + +## Self-Review Checklist + +After all tasks complete, verify against the spec: + +- [ ] `campaigns.combat_state` JSON column exists — Task 1 +- [ ] State persists: restart server mid-combat, reload client, tracker re-appears — Task 2 + 7 +- [ ] Players see enemy names but not HP — Task 2 (`stripHp`) + Task 5 (conditional render) +- [ ] `initiative:request-state` sends on join — Task 3 + 7 +- [ ] Rolling phase: players roll their own characters — Task 5 (`myCharIds`) +- [ ] Rolling phase: ties go to party (`>=`) — Task 2 (`initiative:begin`) +- [ ] Active phase: "Next Turn" flips sides, increments round on party start — Task 2 (`initiative:next`) +- [ ] Enemy HP editable inline by DM — Task 5 +- [ ] Add/remove enemy mid-combat — Task 5 + 6 +- [ ] Multiple combats: `combats` is an array; UI shows `combats[0]` — Task 7 (YAGNI: single-combat UI) +- [ ] Mobile: sidebar stacks above grid — Task 7 CSS +- [ ] `mode: "team"` field present on CombatState for future individual-mode support — Task 2 + 4 diff --git a/docs/plans/2026-04-11-spellcasting.md b/docs/plans/2026-04-11-spellcasting.md new file mode 100644 index 0000000..3d3c132 --- /dev/null +++ b/docs/plans/2026-04-11-spellcasting.md @@ -0,0 +1,1122 @@ +# Spellcasting System 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:** Full Shadowdark spellcasting — spell database, per-character known spells, casting checks with success/failure/crit states, focus tracking, Wizard mishap auto-apply with undo, Priest penance tracking, rest to recover. + +**Architecture:** New `spells` and `character_spells` tables. Casting is a server-side action that records the roll, applies effects, and broadcasts updates via socket. Mishap results are stored in roll_log metadata with an undo payload so any effect can be reversed. Focus state is tracked per-character-spell and shown on the DM card. + +**Tech Stack:** React 18, TypeScript, Express, mysql2/promise, Socket.IO, CSS Modules + +--- + +## File Map + +| File | Action | +|------|--------| +| `server/migrations/002_spells.sql` | Create — spells, character_spells, character_conditions tables; roll_log metadata column | +| `server/src/seed-spells.ts` | Create — seeds all Tier 1-2 spells from Player Quickstart | +| `server/src/seed-dev-data.ts` | Modify — import and call seedSpells() | +| `server/src/routes/spells.ts` | Create — GET /api/spells, GET /api/spells/:class | +| `server/src/routes/characters.ts` | Modify — add character spell endpoints and cast endpoint | +| `server/src/routes/rolls.ts` | Modify — add POST /:rollId/undo endpoint | +| `server/src/socket.ts` | Modify — emit spell/condition change events | +| `client/src/types.ts` | Modify — add Spell, CharacterSpell, Condition types | +| `client/src/api.ts` | Modify — add spell API functions | +| `client/src/components/SpellList.tsx` | Create — known spells with cast button, exhausted state | +| `client/src/components/SpellList.module.css` | Create | +| `client/src/components/SpellCastResult.tsx` | Create — result modal (success/fail/crit/mishap) | +| `client/src/components/SpellCastResult.module.css` | Create | +| `client/src/components/CharacterCard.tsx` | Modify — focus spell indicator | +| `client/src/components/CharacterDetail.tsx` | Modify — add Spells tab for caster classes | +| `client/src/components/RollEntry.tsx` | Modify — undo button on mishap entries | +| `client/src/pages/CampaignView.tsx` | Modify — handle spell socket events | + +--- + +### Task 1: DB migration — spells, character_spells, conditions, roll_log metadata + +**Files:** +- Create: `server/migrations/002_spells.sql` + +- [ ] **Step 1: Write migration SQL** + +```sql +CREATE TABLE IF NOT EXISTS spells ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + class ENUM('wizard', 'priest', 'both') NOT NULL, + tier TINYINT NOT NULL, + casting_stat ENUM('INT', 'WIS') NOT NULL, + duration VARCHAR(100) NOT NULL DEFAULT 'Instant', + range VARCHAR(100) NOT NULL DEFAULT 'Near', + is_focus TINYINT NOT NULL DEFAULT 0, + description TEXT NOT NULL DEFAULT '', + UNIQUE KEY uq_spells_name_class (name, class) +); + +CREATE TABLE IF NOT EXISTS character_spells ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + character_id INT UNSIGNED NOT NULL, + spell_id INT UNSIGNED NOT NULL, + exhausted TINYINT NOT NULL DEFAULT 0, + locked_until DATETIME DEFAULT NULL, + focus_active TINYINT NOT NULL DEFAULT 0, + focus_started_at DATETIME DEFAULT NULL, + UNIQUE KEY uq_char_spell (character_id, spell_id), + FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE, + FOREIGN KEY (spell_id) REFERENCES spells(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS character_conditions ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + character_id INT UNSIGNED NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT DEFAULT '', + rounds_remaining INT DEFAULT NULL, + expires_at DATETIME DEFAULT NULL, + created_at DATETIME DEFAULT NOW(), + FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE +); + +ALTER TABLE roll_log + ADD COLUMN subtype VARCHAR(50) DEFAULT NULL, + ADD COLUMN metadata TEXT DEFAULT NULL, + ADD COLUMN undone TINYINT NOT NULL DEFAULT 0; +``` + +- [ ] **Step 2: Verify migration runs** + +Start the server — it should apply migration 002 and log `Migration applied: 002_spells.sql`. No errors. + +- [ ] **Step 3: Commit** + +```bash +git add server/migrations/002_spells.sql +git commit -m "feat: add spells, character_spells, conditions tables and roll_log metadata" +``` + +--- + +### Task 2: Seed spell data (Tier 1-2 from Player Quickstart) + +**Files:** +- Create: `server/src/seed-spells.ts` +- Modify: `server/src/seed-dev-data.ts` + +- [ ] **Step 1: Write seed-spells.ts** + +```typescript +import type { ResultSetHeader, RowDataPacket } from "mysql2"; +import db from "./db.js"; + +const SPELLS = [ + // PRIEST TIER 1 + { name: "Cure Wounds", class: "priest", tier: 1, casting_stat: "WIS", duration: "Instant", range: "Close", is_focus: 0, description: "Touch restores HP. Roll 1d6 + half your level (round down); target regains that many HP." }, + { name: "Holy Weapon", class: "priest", tier: 1, casting_stat: "WIS", duration: "5 rounds", range: "Close", is_focus: 0, description: "One weapon touched becomes magical, gaining +1 to attack and damage." }, + { name: "Light", class: "both", tier: 1, casting_stat: "WIS", duration: "1 hour", range: "Close", is_focus: 0, description: "One object touched glows, illuminating to near distance." }, + { name: "Protection From Evil", class: "both", tier: 1, casting_stat: "WIS", duration: "5 rounds", range: "Self", is_focus: 0, description: "Chaotic beings have disadvantage on attacks and spellcasting against you. They cannot possess, compel, or beguile you." }, + { name: "Shield of Faith", class: "priest", tier: 1, casting_stat: "WIS", duration: "Focus", range: "Close", is_focus: 1, description: "Gain +2 bonus to AC for as long as you focus." }, + { name: "Turn Undead", class: "priest", tier: 1, casting_stat: "WIS", duration: "Instant", range: "Near", is_focus: 0, description: "Rebuke undead within near. They make a CHA check vs your spellcasting check. Fail by 10+ and level ≤ yours: destroyed. Fail: flees for 5 rounds." }, + // PRIEST TIER 2 + { name: "Augury", class: "priest", tier: 2, casting_stat: "WIS", duration: "Instant", range: "Self", is_focus: 0, description: "Ask the GM one question about a specific course of action. The GM says 'weal' (good outcome) or 'woe' (bad outcome)." }, + { name: "Bless", class: "priest", tier: 2, casting_stat: "WIS", duration: "Instant", range: "Close", is_focus: 0, description: "One creature touched gains a luck token." }, + { name: "Blind/Deafen", class: "priest", tier: 2, casting_stat: "WIS", duration: "Focus", range: "Near", is_focus: 1, description: "One creature loses one sense. It has disadvantage on tasks requiring that sense." }, + { name: "Cleansing Weapon", class: "priest", tier: 2, casting_stat: "WIS", duration: "5 rounds", range: "Close", is_focus: 0, description: "One weapon deals +1d4 damage (1d6 vs undead), wreathed in purifying flames." }, + { name: "Smite", class: "priest", tier: 2, casting_stat: "WIS", duration: "Instant", range: "Near", is_focus: 0, description: "Call down punishing flames on one creature, dealing 1d6 damage." }, + { name: "Zone of Truth", class: "priest", tier: 2, casting_stat: "WIS", duration: "Focus", range: "Near", is_focus: 1, description: "Compel one creature to speak only truth. It cannot utter deliberate lies while in range." }, + // WIZARD TIER 1 + { name: "Alarm", class: "wizard", tier: 1, casting_stat: "INT", duration: "1 day", range: "Close", is_focus: 0, description: "Touch one object (door, threshold). A bell sounds in your head if an undesignated creature touches or crosses the object." }, + { name: "Burning Hands", class: "wizard", tier: 1, casting_stat: "INT", duration: "Instant", range: "Close", is_focus: 0, description: "Spread fingers, unleash a circle of flame filling the close area. Creatures take 1d6 damage; unattended flammable objects ignite." }, + { name: "Charm Person", class: "wizard", tier: 1, casting_stat: "INT", duration: "1d8 days", range: "Near", is_focus: 0, description: "Beguile one humanoid of level 2 or less; it regards you as a friend. Ends if you or your allies hurt it. Target knows it was enchanted after." }, + { name: "Detect Magic", class: "wizard", tier: 1, casting_stat: "INT", duration: "Focus", range: "Near", is_focus: 1, description: "Sense presence of magic within near range. Focus 2 rounds: discern general properties. Full barriers block this." }, + { name: "Feather Fall", class: "wizard", tier: 1, casting_stat: "INT", duration: "Instant", range: "Self", is_focus: 0, description: "Cast when you fall. Slow your descent; land safely on your feet." }, + { name: "Floating Disk", class: "wizard", tier: 1, casting_stat: "INT", duration: "10 rounds", range: "Near", is_focus: 0, description: "Create a floating disk carrying up to 20 gear slots at waist height within near. Can't cross drops taller than a human." }, + { name: "Hold Portal", class: "wizard", tier: 1, casting_stat: "INT", duration: "10 rounds", range: "Near", is_focus: 0, description: "Magically hold a portal closed. A creature must pass a STR check vs your spellcasting check to open it. Knock ends this." }, + { name: "Mage Armor", class: "wizard", tier: 1, casting_stat: "INT", duration: "10 rounds", range: "Self", is_focus: 0, description: "Invisible layer of magical force. Your AC becomes 14 (18 on a critical spellcasting check)." }, + { name: "Magic Missile", class: "wizard", tier: 1, casting_stat: "INT", duration: "Instant", range: "Far", is_focus: 0, description: "You have advantage on your check to cast. A glowing bolt of force deals 1d4 damage to one target." }, + { name: "Sleep", class: "wizard", tier: 1, casting_stat: "INT", duration: "Instant", range: "Near", is_focus: 0, description: "Weave a lulling spell filling a near-sized cube. Living creatures level 2 or less fall into deep sleep. Vigorous shaking or injury wakes them." }, + // WIZARD TIER 2 + { name: "Acid Arrow", class: "wizard", tier: 2, casting_stat: "INT", duration: "Focus", range: "Far", is_focus: 1, description: "Conjure a corrosive bolt. One foe takes 1d6 damage per round while you focus." }, + { name: "Alter Self", class: "wizard", tier: 2, casting_stat: "INT", duration: "5 rounds", range: "Self", is_focus: 0, description: "Change your physical form, gaining one feature modifying existing anatomy (gills, bear claws, etc.). Cannot grow wings or new limbs." }, + { name: "Detect Thoughts", class: "wizard", tier: 2, casting_stat: "INT", duration: "Focus", range: "Near", is_focus: 1, description: "Learn one creature's immediate thoughts each round. Target makes WIS check vs your spellcasting; success means target notices you and spell ends." }, + { name: "Fixed Object", class: "wizard", tier: 2, casting_stat: "INT", duration: "5 rounds", range: "Close", is_focus: 0, description: "Object you touch (max 5 lbs) becomes immovable in location. Can support up to 5,000 lbs." }, + { name: "Hold Person", class: "wizard", tier: 2, casting_stat: "INT", duration: "Focus", range: "Near", is_focus: 1, description: "Magically paralyze one humanoid creature of level 4 or less." }, + { name: "Invisibility", class: "wizard", tier: 2, casting_stat: "INT", duration: "10 rounds", range: "Close", is_focus: 0, description: "Creature touched becomes invisible. Ends if the target attacks or casts a spell." }, + { name: "Knock", class: "wizard", tier: 2, casting_stat: "INT", duration: "Instant", range: "Near", is_focus: 0, description: "A door, window, gate, chest, or portal instantly opens. Defeats mundane locks. Creates a loud knock audible to all." }, + { name: "Levitate", class: "wizard", tier: 2, casting_stat: "INT", duration: "Focus", range: "Self", is_focus: 1, description: "Float near distance vertically per round. Push against solid objects to move horizontally." }, + { name: "Mirror Image", class: "wizard", tier: 2, casting_stat: "INT", duration: "5 rounds", range: "Self", is_focus: 0, description: "Create illusory duplicates equal to half your level (min 1). Each hit destroys one duplicate. Spell ends when all are gone." }, + { name: "Misty Step", class: "wizard", tier: 2, casting_stat: "INT", duration: "Instant", range: "Self", is_focus: 0, description: "In a puff of smoke, teleport near distance to an area you can see." }, + { name: "Silence", class: "wizard", tier: 2, casting_stat: "INT", duration: "Focus", range: "Far", is_focus: 1, description: "Mute sound in a near-sized cube within range. Creatures inside are deafened and no sounds can be heard from inside." }, + { name: "Web", class: "wizard", tier: 2, casting_stat: "INT", duration: "5 rounds", range: "Far", is_focus: 0, description: "Create a near-sized cube of sticky spider web. Creatures are stuck and can't move; must pass STR check vs your spellcasting to free themselves." }, +] as const; + +export async function seedSpells(): Promise { + const [existing] = await db.execute("SELECT COUNT(*) as c FROM spells"); + if ((existing[0] as { c: number }).c > 0) return; + + for (const spell of SPELLS) { + await db.execute( + "INSERT INTO spells (name, class, tier, casting_stat, duration, range, is_focus, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [spell.name, spell.class, spell.tier, spell.casting_stat, spell.duration, spell.range, spell.is_focus, spell.description] + ); + } + console.log(`Spells seeded: ${SPELLS.length} spells`); +} +``` + +- [ ] **Step 2: Import and call in seed-dev-data.ts** + +Add to top of seed-dev-data.ts: +```typescript +import { seedSpells } from "./seed-spells.js"; +``` + +Add at the start of `seedDevData()` before the users check: +```typescript +await seedSpells(); +``` + +- [ ] **Step 3: Restart server, verify spells seeded** + +Server log should show: `Spells seeded: 34 spells` + +- [ ] **Step 4: Commit** + +```bash +git add server/src/seed-spells.ts server/src/seed-dev-data.ts +git commit -m "feat: seed 34 Tier 1-2 spells from Shadowdark Player Quickstart" +``` + +--- + +### Task 3: Server routes — spell catalog and character spell management + +**Files:** +- Create: `server/src/routes/spells.ts` +- Modify: `server/src/routes/characters.ts` +- Modify: `server/src/index.ts` + +- [ ] **Step 1: Create server/src/routes/spells.ts** + +```typescript +import { Router } from "express"; +import type { RowDataPacket } from "mysql2"; +import db from "../db.js"; +import { requireAuth } from "../auth/middleware.js"; + +const router = Router(); + +// GET /api/spells — all spells, optionally filtered by class +router.get("/", requireAuth, async (req, res, next) => { + try { + const { class: spellClass } = req.query; + let sql = "SELECT * FROM spells ORDER BY class, tier, name"; + const params: string[] = []; + if (spellClass && typeof spellClass === "string") { + sql = "SELECT * FROM spells WHERE class = ? OR class = 'both' ORDER BY tier, name"; + params.push(spellClass); + } + const [rows] = await db.execute(sql, params); + res.json(rows); + } catch (err) { + next(err); + } +}); + +export default router; +``` + +- [ ] **Step 2: Add character spell endpoints to characters.ts** + +Add these routes to the existing characters router (after the existing PATCH /:id route): + +```typescript +// GET /api/characters/:id/spells — known spells with exhausted/focus state +router.get("/:id/spells", requireAuth, async (req, res, next) => { + try { + const [rows] = await db.execute( + `SELECT cs.id, cs.spell_id, cs.exhausted, cs.locked_until, cs.focus_active, cs.focus_started_at, + s.name, s.class, s.tier, s.casting_stat, s.duration, s.range, s.is_focus, s.description + FROM character_spells cs + JOIN spells s ON s.id = cs.spell_id + WHERE cs.character_id = ? + ORDER BY s.tier, s.name`, + [req.params.id] + ); + res.json(rows); + } catch (err) { next(err); } +}); + +// POST /api/characters/:id/spells — add a spell to known spells +router.post("/:id/spells", requireAuth, async (req, res, next) => { + try { + const { spell_id } = req.body; + await db.execute( + "INSERT IGNORE INTO character_spells (character_id, spell_id) VALUES (?, ?)", + [req.params.id, spell_id] + ); + const [rows] = await db.execute( + `SELECT cs.*, s.name, s.class, s.tier, s.casting_stat, s.duration, s.range, s.is_focus, s.description + FROM character_spells cs JOIN spells s ON s.id = cs.spell_id + WHERE cs.character_id = ? AND cs.spell_id = ?`, + [req.params.id, spell_id] + ); + const io = req.app.get("io"); + const char = await getCharacterCampaignId(Number(req.params.id)); + io.to(String(char.campaign_id)).emit("spell:added", { characterId: Number(req.params.id), spell: rows[0] }); + res.status(201).json(rows[0]); + } catch (err) { next(err); } +}); + +// DELETE /api/characters/:id/spells/:spellId — remove from known spells +router.delete("/:id/spells/:spellId", requireAuth, async (req, res, next) => { + try { + await db.execute( + "DELETE FROM character_spells WHERE character_id = ? AND spell_id = ?", + [req.params.id, req.params.spellId] + ); + const io = req.app.get("io"); + const char = await getCharacterCampaignId(Number(req.params.id)); + io.to(String(char.campaign_id)).emit("spell:removed", { characterId: Number(req.params.id), spellId: Number(req.params.spellId) }); + res.json({ ok: true }); + } catch (err) { next(err); } +}); + +// POST /api/characters/:id/rest — recover all spells (clear exhausted, focus) +router.post("/:id/rest", requireAuth, async (req, res, next) => { + try { + await db.execute( + "UPDATE character_spells SET exhausted = 0, focus_active = 0, focus_started_at = NULL WHERE character_id = ? AND locked_until IS NULL", + [req.params.id] + ); + await db.execute( + "DELETE FROM character_conditions WHERE character_id = ? AND (expires_at IS NULL OR expires_at > NOW())", + [req.params.id] + ); + const io = req.app.get("io"); + const char = await getCharacterCampaignId(Number(req.params.id)); + io.to(String(char.campaign_id)).emit("character:rested", { characterId: Number(req.params.id) }); + res.json({ ok: true }); + } catch (err) { next(err); } +}); +``` + +Add helper function (before the router export): +```typescript +async function getCharacterCampaignId(id: number): Promise<{ campaign_id: number }> { + const [rows] = await db.execute("SELECT campaign_id FROM characters WHERE id = ?", [id]); + return rows[0] as { campaign_id: number }; +} +``` + +- [ ] **Step 3: Mount spell routes in index.ts** + +```typescript +import spellRoutes from "./routes/spells.js"; +// after existing routes: +app.use("/api/spells", spellRoutes); +``` + +- [ ] **Step 4: Commit** + +```bash +git add server/src/routes/spells.ts server/src/routes/characters.ts server/src/index.ts +git commit -m "feat: spell catalog and character spell management routes" +``` + +--- + +### Task 4: Spell cast endpoint with mishap and undo + +**Files:** +- Modify: `server/src/routes/characters.ts` (add cast endpoint) +- Modify: `server/src/routes/rolls.ts` (add undo endpoint) + +- [ ] **Step 1: Add cast endpoint to characters.ts** + +```typescript +// POST /api/characters/:id/spells/:spellId/cast +router.post("/:id/spells/:spellId/cast", requireAuth, async (req, res, next) => { + try { + const characterId = Number(req.params.id); + const spellId = Number(req.params.spellId); + const { advantage = false, disadvantage = false } = req.body; + + // Get spell and character spell record + const [spellRows] = await db.execute( + "SELECT s.*, cs.id as cs_id, cs.exhausted FROM spells s JOIN character_spells cs ON cs.spell_id = s.id WHERE s.id = ? AND cs.character_id = ?", + [spellId, characterId] + ); + if (spellRows.length === 0) return void res.status(404).json({ error: "Spell not known" }); + const spell = spellRows[0]; + if (spell.exhausted) return void res.status(400).json({ error: "Spell is exhausted" }); + + // Get character stat for casting + const [charRows] = await db.execute( + "SELECT c.id, c.campaign_id, c.name, c.class, c.color, cs2.value as stat_value FROM characters c JOIN character_stats cs2 ON cs2.character_id = c.id WHERE c.id = ? AND cs2.stat_name = ?", + [characterId, spell.casting_stat] + ); + if (charRows.length === 0) return void res.status(404).json({ error: "Character not found" }); + const character = charRows[0]; + const statMod = Math.floor((character.stat_value - 10) / 2); + + // Roll 1d20 + const roll = Math.floor(Math.random() * 20) + 1; + const total = roll + statMod; + const dc = 10 + spell.tier; + const isCritSuccess = roll === 20; + const isCritFail = roll === 1; + const isSuccess = isCritSuccess || (!isCritFail && total >= dc); + + let result: "success" | "failure" | "crit_success" | "crit_fail" = "success"; + if (isCritSuccess) result = "crit_success"; + else if (isCritFail) result = "crit_fail"; + else if (!isSuccess) result = "failure"; + + const changes: object[] = []; + + // On failure or crit_fail: exhaust the spell + if (!isSuccess) { + await db.execute("UPDATE character_spells SET exhausted = 1 WHERE id = ?", [spell.cs_id]); + changes.push({ type: "spell_exhausted", characterSpellId: spell.cs_id }); + } + + // Wizard crit fail: mishap + let mishapResult: object | null = null; + if (isCritFail && character.class === "Wizard") { + mishapResult = await applyWizardMishap(characterId, character.campaign_id, changes); + } + + // Priest crit fail: lock spell for penance + if (isCritFail && character.class === "Priest") { + const penanceCost = [0, 5, 20, 40, 90, 150][spell.tier] ?? 5; + await db.execute("UPDATE character_spells SET exhausted = 1 WHERE id = ?", [spell.cs_id]); + changes.push({ type: "priest_penance", characterSpellId: spell.cs_id, penanceCost, tier: spell.tier }); + } + + // Record in roll log with undo payload + const metadata = JSON.stringify({ spellId, spellName: spell.name, result, mishapResult, changes }); + const [logResult] = await db.execute( + "INSERT INTO roll_log (campaign_id, character_id, character_name, character_color, type, subtype, label, dice_expression, rolls, modifier, total, nat20, metadata) VALUES (?, ?, ?, ?, 'custom', 'spell_cast', ?, '1d20', ?, ?, ?, ?, ?)", + [character.campaign_id, characterId, character.name, character.color, `${spell.name} (Tier ${spell.tier})`, JSON.stringify([roll]), statMod, total, isCritSuccess ? 1 : 0, metadata] + ); + + const io = req.app.get("io"); + io.to(String(character.campaign_id)).emit("spell:cast", { + rollId: logResult.insertId, + characterId, + spellId, + spellName: spell.name, + roll, + modifier: statMod, + total, + dc, + result, + mishapResult, + changes, + }); + + res.json({ rollId: logResult.insertId, roll, modifier: statMod, total, dc, result, mishapResult }); + } catch (err) { next(err); } +}); +``` + +Add mishap helper function: +```typescript +const MISHAP_TABLE = [ + null, // index 0 unused + { id: 1, description: "Devastation! Roll twice, combine both effects." }, + { id: 2, description: "Explosion! Take 1d8 damage.", mechanic: "damage_1d8" }, + { id: 3, description: "Refraction! The spell targets you instead.", mechanic: "narrative" }, + { id: 4, description: "Your hand slipped! The spell hits a random ally.", mechanic: "narrative" }, + { id: 5, description: "Mind wound! You can't cast this spell again for a week.", mechanic: "lock_week" }, + { id: 6, description: "Discorporation! One random piece of gear disappears forever.", mechanic: "remove_gear" }, + { id: 7, description: "Spell worm! Lose a random spell each turn until you pass a DC 12 CON check.", mechanic: "condition", condition: "Spell Worm" }, + { id: 8, description: "Harmonic failure! Lose a random known spell until rest.", mechanic: "exhaust_random" }, + { id: 9, description: "Poof! All light suppressed within 30ft for 10 rounds.", mechanic: "condition", condition: "Light Suppressed" }, + { id: 10, description: "The horror! You scream uncontrollably for 3 rounds, drawing attention.", mechanic: "condition", condition: "Screaming" }, + { id: 11, description: "Energy surge! You glow bright purple for 10 rounds. Enemies have advantage on attacks.", mechanic: "condition", condition: "Glowing Purple" }, + { id: 12, description: "Unstable conduit! Disadvantage on casting same-tier spells for 10 rounds.", mechanic: "condition", condition: "Unstable Conduit" }, +]; + +async function applyWizardMishap(characterId: number, campaignId: number, changes: object[]): Promise { + const roll = Math.floor(Math.random() * 12) + 1; + const mishap = MISHAP_TABLE[roll]; + if (!mishap) return { roll, description: "Unknown mishap" }; + + // Handle result 1 recursively (roll twice) + if (roll === 1) { + const r1 = await applyWizardMishap(characterId, campaignId, changes); + const r2 = await applyWizardMishap(characterId, campaignId, changes); + return { roll: 1, description: mishap.description, combined: [r1, r2] }; + } + + if (mishap.mechanic === "damage_1d8") { + const dmg = Math.floor(Math.random() * 8) + 1; + const [charRows] = await db.execute("SELECT hp_current FROM characters WHERE id = ?", [characterId]); + const prevHp = (charRows[0] as { hp_current: number }).hp_current; + const newHp = Math.max(0, prevHp - dmg); + await db.execute("UPDATE characters SET hp_current = ? WHERE id = ?", [newHp, characterId]); + changes.push({ type: "hp_change", characterId, delta: -dmg, previous: prevHp }); + return { roll, description: `${mishap.description} (${dmg} damage)`, damage: dmg }; + } + + if (mishap.mechanic === "remove_gear") { + const [gearRows] = await db.execute("SELECT * FROM character_gear WHERE character_id = ? ORDER BY RAND() LIMIT 1", [characterId]); + if (gearRows.length > 0) { + const gear = gearRows[0]; + await db.execute("DELETE FROM character_gear WHERE id = ?", [gear.id]); + changes.push({ type: "gear_removed", gear }); + } + return { roll, description: mishap.description }; + } + + if (mishap.mechanic === "exhaust_random") { + const [spellRows] = await db.execute("SELECT id FROM character_spells WHERE character_id = ? AND exhausted = 0 ORDER BY RAND() LIMIT 1", [characterId]); + if (spellRows.length > 0) { + await db.execute("UPDATE character_spells SET exhausted = 1 WHERE id = ?", [spellRows[0].id]); + changes.push({ type: "spell_exhausted", characterSpellId: spellRows[0].id }); + } + return { roll, description: mishap.description }; + } + + if (mishap.mechanic === "lock_week") { + // handled by caller (the crit fail already exhausted the spell) + const lockedUntil = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 19).replace("T", " "); + // Find the most recently exhausted spell for this character + changes.push({ type: "spell_locked_week", characterId, lockedUntil }); + return { roll, description: mishap.description }; + } + + if (mishap.mechanic === "condition" && mishap.condition) { + const [condResult] = await db.execute( + "INSERT INTO character_conditions (character_id, name, description) VALUES (?, ?, ?)", + [characterId, mishap.condition, mishap.description] + ); + changes.push({ type: "condition_added", conditionId: condResult.insertId, name: mishap.condition }); + return { roll, description: mishap.description }; + } + + // narrative only + return { roll, description: mishap.description }; +} +``` + +- [ ] **Step 2: Add undo endpoint to rolls.ts** + +```typescript +// POST /api/campaigns/:campaignId/rolls/:rollId/undo +router.post("/:rollId/undo", requireAuth, async (req, res, next) => { + try { + const [rows] = await db.execute( + "SELECT * FROM roll_log WHERE id = ? AND campaign_id = ?", + [req.params.rollId, req.params.campaignId] + ); + if (rows.length === 0) return void res.status(404).json({ error: "Roll not found" }); + const entry = rows[0]; + if (entry.undone) return void res.status(400).json({ error: "Already undone" }); + if (!entry.metadata) return void res.status(400).json({ error: "Not undoable" }); + + const meta = JSON.parse(entry.metadata as string); + const changes: { type: string; [k: string]: unknown }[] = meta.changes ?? []; + + // Reverse changes in reverse order + for (const change of [...changes].reverse()) { + if (change.type === "hp_change") { + await db.execute("UPDATE characters SET hp_current = ? WHERE id = ?", [change.previous, change.characterId]); + } else if (change.type === "gear_removed") { + const g = change.gear as Record; + await db.execute( + "INSERT INTO character_gear (id, character_id, name, type, slot_count, properties, effects, game_item_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [g.id, g.character_id, g.name, g.type, g.slot_count, g.properties, g.effects, g.game_item_id] + ); + } else if (change.type === "spell_exhausted") { + await db.execute("UPDATE character_spells SET exhausted = 0 WHERE id = ?", [change.characterSpellId]); + } else if (change.type === "condition_added") { + await db.execute("DELETE FROM character_conditions WHERE id = ?", [change.conditionId]); + } + } + + await db.execute("UPDATE roll_log SET undone = 1 WHERE id = ?", [entry.id]); + + const io = req.app.get("io"); + io.to(req.params.campaignId).emit("roll:undone", { rollId: entry.id, campaignId: req.params.campaignId }); + + res.json({ ok: true }); + } catch (err) { next(err); } +}); +``` + +- [ ] **Step 3: Commit** + +```bash +git add server/src/routes/characters.ts server/src/routes/rolls.ts +git commit -m "feat: spell cast endpoint with mishap auto-apply and roll undo" +``` + +--- + +### Task 5: Client types and API + +**Files:** +- Modify: `client/src/types.ts` +- Modify: `client/src/api.ts` + +- [ ] **Step 1: Add types to types.ts** + +```typescript +export interface Spell { + id: number; + name: string; + class: "wizard" | "priest" | "both"; + tier: number; + casting_stat: "INT" | "WIS"; + duration: string; + range: string; + is_focus: number; + description: string; +} + +export interface CharacterSpell { + id: number; + spell_id: number; + character_id?: number; + exhausted: number; + locked_until: string | null; + focus_active: number; + focus_started_at: string | null; + // joined from spells: + name: string; + class: "wizard" | "priest" | "both"; + tier: number; + casting_stat: "INT" | "WIS"; + duration: string; + range: string; + is_focus: number; + description: string; +} + +export interface SpellCastResult { + rollId: number; + roll: number; + modifier: number; + total: number; + dc: number; + result: "success" | "failure" | "crit_success" | "crit_fail"; + mishapResult: Record | null; +} + +export interface Condition { + id: number; + character_id: number; + name: string; + description: string; + rounds_remaining: number | null; + expires_at: string | null; +} +``` + +- [ ] **Step 2: Add API functions to api.ts** + +```typescript +export async function getSpells(spellClass?: string): Promise { + const qs = spellClass ? `?class=${spellClass}` : ""; + return request(`/api/spells${qs}`); +} + +export async function getCharacterSpells(characterId: number): Promise { + return request(`/api/characters/${characterId}/spells`); +} + +export async function addCharacterSpell(characterId: number, spellId: number): Promise { + return request(`/api/characters/${characterId}/spells`, { + method: "POST", + body: JSON.stringify({ spell_id: spellId }), + }); +} + +export async function removeCharacterSpell(characterId: number, spellId: number): Promise { + return request(`/api/characters/${characterId}/spells/${spellId}`, { method: "DELETE" }); +} + +export async function castSpell(characterId: number, spellId: number): Promise { + return request(`/api/characters/${characterId}/spells/${spellId}/cast`, { method: "POST" }); +} + +export async function restCharacter(characterId: number): Promise { + return request(`/api/characters/${characterId}/rest`, { method: "POST" }); +} + +export async function undoRoll(campaignId: number, rollId: number): Promise { + return request(`/api/campaigns/${campaignId}/rolls/${rollId}/undo`, { method: "POST" }); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add client/src/types.ts client/src/api.ts +git commit -m "feat: add Spell, CharacterSpell, Condition types and API functions" +``` + +--- + +### Task 6: SpellList component + +**Files:** +- Create: `client/src/components/SpellList.tsx` +- Create: `client/src/components/SpellList.module.css` +- Modify: `client/src/components/CharacterDetail.tsx` + +- [ ] **Step 1: Create SpellList.tsx** + +SpellList shows known spells grouped by tier. Each spell has: +- Name + tier badge + duration + range +- Cast button (disabled if exhausted or locked) +- Exhausted indicator (greyed out with strikethrough on name) +- Focus indicator if focus_active +- Remove button in edit mode +- Add spell button (opens picker filtered to class) in edit mode + +```typescript +import { useState, useEffect } from "react"; +import type { CharacterSpell, Spell } from "../types"; +import { getSpells, addCharacterSpell, removeCharacterSpell, castSpell } from "../api"; +import styles from "./SpellList.module.css"; + +interface SpellListProps { + characterId: number; + characterClass: string; + spells: CharacterSpell[]; + mode: "view" | "edit"; + canEdit: boolean; + campaignId: number; + onSpellAdded: (spell: CharacterSpell) => void; + onSpellRemoved: (spellId: number) => void; + onSpellCast: (result: import("../types").SpellCastResult, spellName: string) => void; + onSpellsUpdated: (spells: CharacterSpell[]) => void; +} + +export default function SpellList({ characterId, characterClass, spells, mode, canEdit, campaignId, onSpellAdded, onSpellRemoved, onSpellCast, onSpellsUpdated }: SpellListProps) { + const [allSpells, setAllSpells] = useState([]); + const [showPicker, setShowPicker] = useState(false); + const [casting, setCasting] = useState(null); + + useEffect(() => { + const spellClass = characterClass.toLowerCase() === "wizard" ? "wizard" : characterClass.toLowerCase() === "priest" ? "priest" : null; + if (spellClass) { + getSpells(spellClass).then(setAllSpells); + } + }, [characterClass]); + + const isCaster = ["Wizard", "Priest"].includes(characterClass); + if (!isCaster) return null; + + const knownIds = new Set(spells.map(s => s.spell_id)); + const availableToAdd = allSpells.filter(s => !knownIds.has(s.id)); + + const byTier: Record = {}; + for (const s of spells) { + byTier[s.tier] = byTier[s.tier] ?? []; + byTier[s.tier].push(s); + } + + async function handleCast(spell: CharacterSpell) { + setCasting(spell.spell_id); + try { + const result = await castSpell(characterId, spell.spell_id); + onSpellCast(result, spell.name); + // Update local exhausted state + onSpellsUpdated(spells.map(s => s.spell_id === spell.spell_id ? { ...s, exhausted: result.result === "success" || result.result === "crit_success" ? 0 : 1 } : s)); + } catch (err) { + console.error("Cast failed", err); + } finally { + setCasting(null); + } + } + + async function handleRemove(spellId: number) { + await removeCharacterSpell(characterId, spellId); + onSpellRemoved(spellId); + } + + async function handleAdd(spell: Spell) { + const cs = await addCharacterSpell(characterId, spell.id); + onSpellAdded(cs); + setShowPicker(false); + } + + return ( +
+
+ Spells + {canEdit && mode === "edit" && ( + + )} +
+ + {showPicker && ( +
+ {availableToAdd.length === 0 &&

All available spells known.

} + {availableToAdd.map(s => ( + + ))} +
+ )} + + {spells.length === 0 && !showPicker && ( +

No spells known. {canEdit && mode === "edit" ? "Add some above." : ""}

+ )} + + {Object.entries(byTier).sort(([a], [b]) => Number(a) - Number(b)).map(([tier, tierSpells]) => ( +
+
Tier {tier}
+ {tierSpells.map(s => ( +
+
+ {s.name} + {s.focus_active && ● Focusing} + {s.exhausted && Exhausted} + {s.locked_until && Locked (penance)} +
+
{s.duration} · {s.range}
+
{s.description}
+
+ {canEdit && ( + + )} + {canEdit && mode === "edit" && ( + + )} +
+
+ ))} +
+ ))} +
+ ); +} +``` + +- [ ] **Step 2: Create SpellList.module.css** + +Style following the existing dark parchment theme. Key states: +- `.exhausted` — 50% opacity, spell name has `text-decoration: line-through` +- `.focusing` — subtle gold border or glow +- `.focusActive` — gold dot + "Focusing" text in gold +- `.lockedLabel` — red/crimson text +- `.tierBadge` — small pill badge matching character accent color (use gold) +- Cast button: same style as existing action buttons +- Picker: dropdown list of available spells + +```css +.container { display: flex; flex-direction: column; gap: 0.5rem; } +.header { display: flex; justify-content: space-between; align-items: center; } +.title { font-family: var(--font-display); color: var(--color-gold); font-size: 0.85rem; letter-spacing: 0.05em; text-transform: uppercase; } +.addBtn { font-size: 0.75rem; padding: 0.25rem 0.6rem; background: transparent; border: 1px solid var(--color-gold); color: var(--color-gold); border-radius: 3px; cursor: pointer; } +.addBtn:hover { background: rgba(201,170,113,0.1); } + +.picker { display: flex; flex-direction: column; gap: 0.25rem; background: var(--color-bg-card); border: 1px solid var(--color-border); border-radius: 4px; padding: 0.5rem; max-height: 200px; overflow-y: auto; } +.pickerItem { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.5rem; background: transparent; border: none; color: var(--color-text); cursor: pointer; text-align: left; border-radius: 3px; } +.pickerItem:hover { background: rgba(201,170,113,0.1); } +.pickerName { flex: 1; font-size: 0.85rem; } +.focusBadge { font-size: 0.65rem; color: var(--color-gold); border: 1px solid var(--color-gold); border-radius: 2px; padding: 0 3px; } + +.tierGroup { display: flex; flex-direction: column; gap: 0.25rem; } +.tierLabel { font-size: 0.7rem; color: var(--color-muted); text-transform: uppercase; letter-spacing: 0.08em; margin-top: 0.25rem; } + +.spell { display: flex; flex-direction: column; gap: 0.2rem; padding: 0.5rem 0.6rem; background: var(--color-bg-card); border: 1px solid var(--color-border); border-radius: 4px; transition: opacity 0.2s; } +.exhausted { opacity: 0.5; } +.exhausted .spellName { text-decoration: line-through; } +.focusing { border-color: var(--color-gold); box-shadow: 0 0 6px rgba(201,170,113,0.3); } + +.spellMain { display: flex; align-items: center; gap: 0.5rem; } +.spellName { font-size: 0.85rem; color: var(--color-text); font-weight: 500; } +.focusActive { font-size: 0.7rem; color: var(--color-gold); } +.exhaustedLabel { font-size: 0.7rem; color: var(--color-muted); } +.lockedLabel { font-size: 0.7rem; color: #c0392b; } + +.spellMeta { font-size: 0.7rem; color: var(--color-muted); } +.spellDesc { font-size: 0.75rem; color: var(--color-text); opacity: 0.8; line-height: 1.4; } + +.spellActions { display: flex; gap: 0.4rem; margin-top: 0.25rem; } +.castBtn { font-size: 0.75rem; padding: 0.2rem 0.6rem; background: var(--color-gold); color: #1a1008; border: none; border-radius: 3px; cursor: pointer; font-weight: 600; } +.castBtn:disabled { opacity: 0.4; cursor: not-allowed; } +.castBtn:hover:not(:disabled) { filter: brightness(1.1); } +.removeBtn { font-size: 0.7rem; padding: 0.2rem 0.4rem; background: transparent; border: 1px solid var(--color-border); color: var(--color-muted); border-radius: 3px; cursor: pointer; } +.removeBtn:hover { border-color: #c0392b; color: #c0392b; } + +.empty { font-size: 0.8rem; color: var(--color-muted); } +.tierBadge { font-size: 0.65rem; color: var(--color-gold); background: rgba(201,170,113,0.15); border-radius: 2px; padding: 0 4px; } +``` + +- [ ] **Step 3: Add SpellList to CharacterDetail** + +Add a Spells section to CharacterDetail for Wizard and Priest classes. It should: +- Load character spells on mount (`getCharacterSpells(characterId)`) +- Store them in local state +- Show SpellList component above the gear/talent panels +- Handle socket updates for spell state + +In CharacterDetail, add: +```typescript +const [spells, setSpells] = useState([]); +const [castResult, setCastResult] = useState<{ result: SpellCastResult; spellName: string } | null>(null); + +useEffect(() => { + if (["Wizard", "Priest"].includes(character.class)) { + getCharacterSpells(character.id).then(setSpells); + } +}, [character.id, character.class]); +``` + +Show SpellList in JSX for caster classes: +```typescript +{["Wizard", "Priest"].includes(character.class) && ( + setSpells(prev => [...prev, s])} + onSpellRemoved={(id) => setSpells(prev => prev.filter(s => s.spell_id !== id))} + onSpellCast={(result, name) => setCastResult({ result, spellName: name })} + onSpellsUpdated={setSpells} + /> +)} +``` + +- [ ] **Step 4: Commit** + +```bash +git add client/src/components/SpellList.tsx client/src/components/SpellList.module.css client/src/components/CharacterDetail.tsx +git commit -m "feat: SpellList component with cast/add/remove and exhausted state" +``` + +--- + +### Task 7: SpellCastResult modal (success/fail/crit/mishap display) + +**Files:** +- Create: `client/src/components/SpellCastResult.tsx` +- Create: `client/src/components/SpellCastResult.module.css` +- Modify: `client/src/components/CharacterDetail.tsx` + +- [ ] **Step 1: Create SpellCastResult.tsx** + +Modal that appears after a cast roll, showing the result dramatically: +- Roll value, modifier, total vs DC +- Result label: SUCCESS / FAILURE / CRITICAL SUCCESS / CRITICAL FAILURE +- For crit fail Wizard: mishap description in red with dramatic styling +- Dismiss button + +```typescript +import styles from "./SpellCastResult.module.css"; +import type { SpellCastResult as CastResult } from "../types"; + +interface Props { + result: CastResult; + spellName: string; + onClose: () => void; +} + +export default function SpellCastResult({ result, spellName, onClose }: Props) { + const labels = { + success: { text: "Success", cls: styles.success }, + failure: { text: "Failure", cls: styles.failure }, + crit_success: { text: "Critical Success!", cls: styles.critSuccess }, + crit_fail: { text: "Critical Failure!", cls: styles.critFail }, + }; + const label = labels[result.result]; + const mishap = result.mishapResult as Record | null; + + return ( +
+
e.stopPropagation()}> +
{spellName}
+
+ {result.roll} {result.modifier >= 0 ? "+" : ""}{result.modifier} = {result.total} + vs DC {result.dc} +
+
{label.text}
+ + {result.result === "failure" && ( +

Spell exhausted until rest.

+ )} + {result.result === "crit_success" && ( +

Double one numerical effect!

+ )} + {result.result === "crit_fail" && mishap && ( +
+
⚠ Wizard Mishap
+

{String(mishap.description ?? "")}

+ {mishap.damage &&

Took {String(mishap.damage)} damage

} +
+ )} + {result.result === "crit_fail" && !mishap && ( +

Spell exhausted. Deity is displeased — penance required.

+ )} + + +
+
+ ); +} +``` + +- [ ] **Step 2: Create SpellCastResult.module.css** + +Dramatic dark overlay modal. SUCCESS is gold, FAILURE is muted, CRITICAL SUCCESS is bright gold with glow, CRITICAL FAILURE is crimson. + +```css +.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 10010; display: flex; align-items: center; justify-content: center; } +.modal { background: var(--color-bg-card); border: 2px solid var(--color-border); border-radius: 8px; padding: 2rem; min-width: 280px; max-width: 400px; text-align: center; display: flex; flex-direction: column; gap: 0.75rem; } +.spellName { font-family: var(--font-display); font-size: 1.2rem; color: var(--color-gold); } +.roll { font-size: 1.4rem; font-weight: bold; color: var(--color-text); } +.dc { font-size: 0.9rem; color: var(--color-muted); margin-left: 0.5rem; } +.resultLabel { font-family: var(--font-display); font-size: 1.5rem; letter-spacing: 0.05em; padding: 0.5rem; border-radius: 4px; } +.success { color: var(--color-gold); } +.failure { color: var(--color-muted); } +.critSuccess { color: var(--color-gold); text-shadow: 0 0 20px rgba(201,170,113,0.8); } +.critFail { color: #c0392b; text-shadow: 0 0 20px rgba(192,57,43,0.5); } +.note { font-size: 0.85rem; color: var(--color-muted); } +.mishap { background: rgba(192,57,43,0.1); border: 1px solid #c0392b; border-radius: 4px; padding: 0.75rem; } +.mishapTitle { font-family: var(--font-display); color: #c0392b; margin-bottom: 0.5rem; } +.mishapDesc { font-size: 0.85rem; color: var(--color-text); } +.mishapEffect { font-size: 0.85rem; color: #c0392b; font-weight: bold; margin-top: 0.25rem; } +.closeBtn { margin-top: 0.5rem; padding: 0.5rem 1.5rem; background: var(--color-gold); color: #1a1008; border: none; border-radius: 4px; cursor: pointer; font-weight: 600; font-family: var(--font-display); } +.closeBtn:hover { filter: brightness(1.1); } +``` + +- [ ] **Step 3: Wire modal into CharacterDetail** + +```typescript +{castResult && ( + setCastResult(null)} + /> +)} +``` + +- [ ] **Step 4: Commit** + +```bash +git add client/src/components/SpellCastResult.tsx client/src/components/SpellCastResult.module.css client/src/components/CharacterDetail.tsx +git commit -m "feat: SpellCastResult modal for success/fail/crit/mishap display" +``` + +--- + +### Task 8: Undo button on roll log + focus indicator on DM card + +**Files:** +- Modify: `client/src/components/RollEntry.tsx` +- Modify: `client/src/components/RollLog.tsx` +- Modify: `client/src/components/CharacterCard.tsx` +- Modify: `client/src/pages/CampaignView.tsx` + +- [ ] **Step 1: Add undo button to RollEntry** + +Add `onUndo?: () => void` prop and `undone?: boolean` to RollEntry. Show undo button only when `subtype === 'spell_cast'` and `!undone`. + +Update `RollResult` type in types.ts to add: `subtype?: string; undone?: boolean;` + +In RollEntry: +```typescript +{!roll.undone && roll.subtype === "spell_cast" && onUndo && ( + +)} +{roll.undone && Reverted} +``` + +Style undo button as small, secondary, red-bordered. + +- [ ] **Step 2: Wire undo in RollLog/CampaignView** + +In CampaignView, add handler: +```typescript +async function handleUndoRoll(rollId: number) { + await undoRoll(campaignId, rollId); + setRolls(prev => prev.map(r => r.id === rollId ? { ...r, undone: true } : r)); +} +``` + +Pass to RollLog → RollEntry as `onUndo={() => handleUndoRoll(roll.id)}`. + +Handle socket event `roll:undone`: +```typescript +socket.on("roll:undone", ({ rollId }: { rollId: number }) => { + setRolls(prev => prev.map(r => r.id === rollId ? { ...r, undone: true } : r)); +}); +``` + +- [ ] **Step 3: Focus spell indicator on CharacterCard** + +CharacterCard receives `focusSpell?: string` prop. Show it below the character name when set. + +In CampaignView, track focus spells in a `Map` and pass to CharacterCard. + +Handle socket event `spell:cast` to update focus tracking when a focus spell succeeds. + +Small gold indicator: `● Focusing: Shield of Faith` + +- [ ] **Step 4: Commit** + +```bash +git add client/src/components/RollEntry.tsx client/src/components/RollLog.tsx client/src/components/CharacterCard.tsx client/src/pages/CampaignView.tsx client/src/types.ts +git commit -m "feat: undo button on mishap roll log entries, focus spell on DM card" +``` + +--- + +### Task 9: Rest button + +**Files:** +- Modify: `client/src/components/CharacterDetail.tsx` + +- [ ] **Step 1: Add Rest button to CharacterDetail** + +Below the spell list, add a "Take Rest" button visible in edit mode for casters. On click: +```typescript +async function handleRest() { + await restCharacter(character.id); + setSpells(prev => prev.map(s => ({ ...s, exhausted: 0, focus_active: 0, focus_started_at: null }))); +} +``` + +Style as secondary action button. + +- [ ] **Step 2: Handle character:rested socket event in CampaignView** + +```typescript +socket.on("character:rested", ({ characterId }: { characterId: number }) => { + // Refresh spells for that character if their detail is open + if (selectedId === characterId) { + getCharacterSpells(characterId).then(/* pass to CharacterDetail via state */); + } +}); +``` + +- [ ] **Step 3: Commit** + +```bash +git add client/src/components/CharacterDetail.tsx client/src/pages/CampaignView.tsx +git commit -m "feat: rest button clears exhausted spells and conditions" +``` diff --git a/docs/plans/2026-04-12-brochure.md b/docs/plans/2026-04-12-brochure.md new file mode 100644 index 0000000..52dcf15 --- /dev/null +++ b/docs/plans/2026-04-12-brochure.md @@ -0,0 +1,1176 @@ +# The Brochure — 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:** Build a single-page showcase site (`site/`) for Darkwatch with a dark hero section, parchment feature rows with Playwright-generated screenshots, a system support strip, and a publisher contact section. + +**Architecture:** Plain HTML/CSS/JS with no build step or framework. A Playwright Node script (`site/screenshots.js`) logs into the running dev app and captures feature screenshots automatically. A `update-brochure` Claude skill keeps the site current as features change. + +**Tech Stack:** HTML · CSS · Vanilla JS · Playwright (Node, screenshot script only) + +--- + +## File Map + +| Action | Path | Purpose | +|---|---|---| +| Create | `site/index.html` | The page — all sections | +| Create | `site/style.css` | All styles — CSS variables, fonts, layout | +| Create | `site/main.js` | Smooth scroll + hero parallax | +| Create | `site/assets/screenshots/.gitkeep` | Ensures directory exists in git | +| Create | `site/package.json` | Playwright dependency for screenshot script | +| Create | `site/screenshots.js` | Playwright script — captures all feature screenshots | +| Create | `.claude/skills/update-brochure.md` | Maintenance skill | +| Modify | `CLAUDE.md` | Register update-brochure skill | +| Modify | `.gitignore` | Ignore `site/node_modules/` | + +--- + +## Task 1: CSS foundation + +**Files:** +- Create: `site/style.css` +- Create: `site/assets/screenshots/.gitkeep` + +- [ ] **Step 1: Create `site/assets/screenshots/.gitkeep`** + +```bash +mkdir -p site/assets/screenshots +touch site/assets/screenshots/.gitkeep +``` + +- [ ] **Step 2: Create `site/style.css`** + +```css +@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Alegreya:ital,wght@0,400;0,500;1,400&display=swap'); + +/* ── Variables ─────────────────────────────────────────── */ +:root { + --gold: #c9a84c; + --gold-dark: #8b6914; + --gold-dim: rgba(201, 168, 76, 0.12); + --gold-border: rgba(201, 168, 76, 0.3); + --bg-dark: #0d0a05; + --bg-dark-2: #1a1408; + --bg-parchment: #f5f0e8; + --bg-parchment-alt: #ede8d5; + --border-dark: #2a2010; + --border-parchment: #d4c9a8; + --border-light: #e0d5b5; + --text-gold: #c9a84c; + --text-parchment: #e8dcc8; + --text-dark: #2a1f0a; + --text-muted: #6a5a3a; + --text-label: #8b6914; + --font-display: 'Cinzel', serif; + --font-body: 'Alegreya', serif; +} + +/* ── Reset ──────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html { scroll-behavior: smooth; } +body { font-family: var(--font-body); background: var(--bg-dark); color: var(--text-parchment); } +img { display: block; max-width: 100%; height: auto; border-radius: 6px; } +a { text-decoration: none; } + +/* ── Hero ───────────────────────────────────────────────── */ +.hero { + min-height: 100vh; + background: var(--bg-dark); + border-bottom: 2px solid var(--border-dark); + display: flex; + align-items: center; + justify-content: center; + text-align: center; + padding: 80px 24px; + position: relative; + overflow: hidden; +} + +.hero::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(ellipse 80% 60% at 50% 40%, rgba(201,168,76,0.07) 0%, transparent 70%); + pointer-events: none; +} + +.hero-content { position: relative; max-width: 680px; } + +.wordmark { + font-family: var(--font-display); + font-size: 11px; + letter-spacing: 4px; + text-transform: uppercase; + color: var(--gold); + margin-bottom: 28px; + opacity: 0.9; +} + +.hero-headline { + font-family: var(--font-display); + font-size: clamp(2.2rem, 5vw, 3.6rem); + font-weight: 700; + color: var(--text-parchment); + line-height: 1.2; + margin-bottom: 20px; + text-shadow: 0 0 40px rgba(201,168,76,0.15); +} + +.hero-subhead { + font-family: var(--font-body); + font-size: 1.15rem; + color: #9a8a6a; + line-height: 1.65; + margin-bottom: 40px; + max-width: 520px; + margin-left: auto; + margin-right: auto; +} + +.hero-actions { + display: flex; + align-items: center; + justify-content: center; + gap: 28px; + flex-wrap: wrap; +} + +.btn-primary { + font-family: var(--font-display); + font-size: 13px; + letter-spacing: 2px; + text-transform: uppercase; + background: var(--gold); + color: var(--bg-dark); + padding: 14px 32px; + border-radius: 3px; + font-weight: 700; + transition: background 0.2s, transform 0.1s; +} +.btn-primary:hover { background: #e0bc60; transform: translateY(-1px); } + +.scroll-hint { + font-family: var(--font-body); + font-size: 0.95rem; + color: var(--text-muted); + transition: color 0.2s; +} +.scroll-hint:hover { color: var(--gold); } + +/* ── Features ───────────────────────────────────────────── */ +.features { background: var(--bg-parchment); } + +.features-intro { + padding: 64px 24px 40px; + text-align: center; + border-bottom: 1px solid var(--border-parchment); +} + +.section-label { + font-family: var(--font-display); + font-size: 10px; + letter-spacing: 3px; + text-transform: uppercase; + color: var(--text-label); + margin-bottom: 10px; +} + +.features-intro h2 { + font-family: var(--font-display); + font-size: clamp(1.4rem, 3vw, 2rem); + color: var(--text-dark); + font-weight: 600; +} + +.feature-row { + display: grid; + grid-template-columns: 1fr 1fr; + align-items: center; + gap: 0; + border-bottom: 1px solid var(--border-light); + background: var(--bg-parchment); +} + +.feature-row:nth-child(even) { background: var(--bg-parchment-alt); } + +.feature-image { + padding: 48px 40px; + display: flex; + align-items: center; + justify-content: center; +} + +.feature-image img { + box-shadow: 0 8px 32px rgba(42,31,10,0.18); + border: 1px solid var(--border-parchment); +} + +.feature-text { + padding: 48px 56px 48px 24px; +} + +.feature-row.reverse .feature-image { order: 2; } +.feature-row.reverse .feature-text { order: 1; padding: 48px 24px 48px 56px; } + +.feature-label { + font-family: var(--font-display); + font-size: 10px; + letter-spacing: 2px; + text-transform: uppercase; + color: var(--text-label); + margin-bottom: 10px; +} + +.feature-heading { + font-family: var(--font-display); + font-size: 1.5rem; + font-weight: 600; + color: var(--text-dark); + margin-bottom: 14px; + line-height: 1.3; +} + +.feature-desc { + font-family: var(--font-body); + font-size: 1.05rem; + color: #4a3a1a; + line-height: 1.7; +} + +/* Torch & Luck callout cards */ +.callout-row { + padding: 56px 40px; + background: var(--bg-parchment-alt); + border-bottom: 1px solid var(--border-light); + display: flex; + gap: 32px; + justify-content: center; +} + +.callout-card { + background: var(--bg-parchment); + border: 1px solid var(--border-parchment); + border-radius: 6px; + padding: 32px 36px; + max-width: 320px; + text-align: center; +} + +.callout-icon { font-size: 2rem; margin-bottom: 12px; } + +.callout-card h3 { + font-family: var(--font-display); + font-size: 1rem; + font-weight: 600; + color: var(--text-dark); + margin-bottom: 8px; +} + +.callout-card p { + font-family: var(--font-body); + font-size: 0.95rem; + color: #4a3a1a; + line-height: 1.6; +} + +/* ── System Support ─────────────────────────────────────── */ +.system-support { + background: var(--bg-dark-2); + border-top: 2px solid var(--border-dark); + border-bottom: 2px solid var(--border-dark); + padding: 48px 24px; + text-align: center; +} + +.system-support .section-label { color: var(--gold); opacity: 0.7; } + +.system-support h2 { + font-family: var(--font-display); + font-size: 1.4rem; + color: var(--text-parchment); + font-weight: 600; + margin-bottom: 10px; +} + +.system-support p { + font-family: var(--font-body); + color: var(--text-muted); + font-size: 1rem; +} + +/* ── Publisher ──────────────────────────────────────────── */ +.publisher { + background: var(--bg-parchment); + border-bottom: 1px solid var(--border-parchment); + padding: 80px 24px; + text-align: center; +} + +.publisher .section-label { margin-bottom: 14px; } + +.publisher h2 { + font-family: var(--font-display); + font-size: clamp(1.4rem, 3vw, 2rem); + color: var(--text-dark); + font-weight: 600; + margin-bottom: 20px; +} + +.publisher p { + font-family: var(--font-body); + font-size: 1.1rem; + color: #4a3a1a; + line-height: 1.75; + max-width: 520px; + margin: 0 auto 32px; +} + +.btn-secondary { + font-family: var(--font-display); + font-size: 12px; + letter-spacing: 2px; + text-transform: uppercase; + background: var(--bg-dark); + color: var(--gold); + padding: 13px 30px; + border-radius: 3px; + font-weight: 600; + border: 1px solid var(--border-dark); + transition: background 0.2s; + display: inline-block; +} +.btn-secondary:hover { background: var(--bg-dark-2); } + +/* ── Footer ─────────────────────────────────────────────── */ +.footer { + background: var(--bg-dark); + border-top: 1px solid var(--border-dark); + padding: 28px 40px; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 12px; +} + +.footer-left { + font-family: var(--font-display); + font-size: 11px; + letter-spacing: 1px; + color: #4a3a1a; +} + +.footer-right { display: flex; gap: 20px; } + +.footer-right a { + font-family: var(--font-display); + font-size: 11px; + letter-spacing: 1px; + color: #4a3a1a; + transition: color 0.2s; +} +.footer-right a:hover { color: var(--gold); } + +/* ── Responsive ─────────────────────────────────────────── */ +@media (max-width: 768px) { + .feature-row, + .feature-row.reverse { + grid-template-columns: 1fr; + } + .feature-row.reverse .feature-image { order: 0; } + .feature-row.reverse .feature-text { order: 0; } + .feature-image { padding: 32px 24px 0; } + .feature-text, + .feature-row.reverse .feature-text { padding: 24px 24px 40px; } + .callout-row { flex-direction: column; align-items: center; } + .footer { justify-content: center; text-align: center; } +} +``` + +- [ ] **Step 3: Verify fonts load** + +```bash +open site/index.html # will 404 — we'll create it in Task 2. Just confirm style.css has no syntax errors: +node -e "require('fs').readFileSync('site/style.css', 'utf8'); console.log('OK')" +``` + +Expected: `OK` + +- [ ] **Step 4: Commit** + +```bash +git add site/style.css site/assets/screenshots/.gitkeep +git commit -m "feat: add brochure CSS foundation and screenshot directory" +``` + +--- + +## Task 2: Hero section + +**Files:** +- Create: `site/index.html` + +- [ ] **Step 1: Create `site/index.html` with skeleton + hero** + +```html + + + + + + Darkwatch — Real-Time Shadowdark Companion + + + + + + +
+
+
◈ Darkwatch
+

Your whole party.
One screen. No paper.

+

+ A real-time session companion for Shadowdark RPG. Characters, dice, spells, + and atmosphere — synced live across every device at the table. +

+ +
+
+ + + + + + +``` + +- [ ] **Step 2: Open in browser and verify hero** + +```bash +open site/index.html +``` + +Expected: dark background, gold wordmark, large serif headline, parchment-colored subhead, gold CTA button. Fonts may not load from `file://` — if Cinzel/Alegreya don't appear, use a local server: + +```bash +npx serve site -l 4000 +# then open http://localhost:4000 +``` + +- [ ] **Step 3: Commit** + +```bash +git add site/index.html +git commit -m "feat: add brochure hero section" +``` + +--- + +## Task 3: Feature rows + +**Files:** +- Modify: `site/index.html` + +- [ ] **Step 1: Add features intro and all 7 feature rows** + +Replace `` with: + +```html + +
+ +
+ +

Built for Shadowdark. Designed for the whole party.

+
+ + +
+
+ Darkwatch character sheet showing stats, gear, and talents +
+
+
Characters
+

Every stat, always in sync.

+

+ Full Shadowdark character sheets — STR, DEX, CON, INT, WIS, CHA, HP, AC, XP, + gear, and talents. Click any value to edit it. Changes sync to the DM and every + player at the table in real time. +

+
+
+ + +
+
+ Character creation wizard showing 3d6 stat rolling +
+
+
Character Creation
+

Roll to begin.

+

+ A guided four-step wizard: name, class, and ancestry — then roll 3d6 for each + stat and watch them land. Background, alignment, and deity last. HP, gear slots, + gold, and your title are all derived automatically. +

+
+
+ + +
+
+ 3D dice rolling with character-colored dice and shared roll log +
+
+
Dice Rolling
+

3D dice. Shared results. No hiding rolls.

+

+ Dice roll in 3D, in your character's color. Results are determined server-side — + no one can fudge them. Every roll appears in a shared log that the whole table + sees at the same time. +

+
+
+ + +
+
+ Spellcasting panel with spell slots and exhaustion tracking +
+
+
Spellcasting
+

Cast, fail, and suffer — together.

+

+ 34 Tier 1–2 spells for Wizard and Priest. Every cast rolls a d20 — fail and + lose a spell slot. Wizards risk the mishap table. Priests do penance. Rest to + recover. The DM sees your spell focus state at a glance. +

+
+
+ + +
+
+ Atmospheric fog effect covering the campaign view +
+
+
Atmosphere
+

Set the scene.

+

+ The DM controls atmosphere effects that every player sees in real time: + creeping fog, crackling fire, rain, or drifting embers. One click and the + whole table feels the dungeon shift. +

+
+
+ + +
+
+ Initiative tracker showing party vs enemies with round counter +
+
+
Initiative Tracker
+

Shadowdark initiative, handled.

+

+ Everyone rolls. The tracker takes the party's highest result against the DM's + single enemy roll. Whoever wins goes first — then the DM clicks Next Turn and + the round counter climbs. Enemy HP is visible to the DM only. +

+
+
+ + +
+
+ DM view showing compact character cards for the whole party +
+
+
DM View
+

The whole party, at a glance.

+

+ The DM sees every character in a compact grid: HP, AC, luck token, torch timer, + and all six stat modifiers — without opening a single sheet. Drill in when you + need to, stay out when you don't. +

+
+
+ + +
+
+
🕯
+

Torch Timer

+

A real 60-minute countdown, synced across every device. The DM starts it when + the torch is lit. Everyone watches it burn.

+
+
+
+

Luck Tokens

+

Each character has a luck token the DM and player can both see and toggle. + No more forgetting who spent their luck last session.

+
+
+ +
+ + +``` + +- [ ] **Step 2: Open in browser and verify feature rows** + +```bash +npx serve site -l 4000 +# open http://localhost:4000 +``` + +Expected: seven alternating rows with broken image icons (screenshots don't exist yet) and text descriptions. Parchment background. Callout cards at the bottom. Layout alternates left/right correctly. + +- [ ] **Step 3: Commit** + +```bash +git add site/index.html +git commit -m "feat: add brochure feature rows" +``` + +--- + +## Task 4: Lower sections — system support, publisher, footer + +**Files:** +- Modify: `site/index.html` + +- [ ] **Step 1: Replace the `` comment with the remaining sections** + +Replace `` with: + +```html + +
+ +

Built for Shadowdark RPG. More systems coming.

+

Currently: Shadowdark RPG  ·  Next: based on demand

+
+ + +
+ +

"We're not here to sell your rules."

+

+ Darkwatch is a session companion — a torch timer, a dice roller, an initiative + tracker. Your players still need the book. We just want to make the table more + fun. If you'd like to talk, we'd love to. +

+ Get in touch +
+ + +
+ + +
+``` + +- [ ] **Step 2: Open in browser and verify complete page** + +```bash +npx serve site -l 4000 +# open http://localhost:4000 +``` + +Scroll through the full page. Expected: +- Dark hero → parchment features → dark system strip → parchment publisher → dark footer +- Publisher section has the "Get in touch" button +- Footer has Discord and GitHub links (both currently `PLACEHOLDER`) + +- [ ] **Step 3: Commit** + +```bash +git add site/index.html +git commit -m "feat: add brochure system support, publisher, and footer sections" +``` + +--- + +## Task 5: main.js — smooth scroll and hero parallax + +**Files:** +- Create: `site/main.js` + +- [ ] **Step 1: Create `site/main.js`** + +```javascript +// Smooth scroll for anchor links (CSS scroll-behavior covers most cases, +// this handles the ↓ scroll-hint click on browsers that need it) +document.querySelectorAll('a[href^="#"]').forEach(anchor => { + anchor.addEventListener('click', e => { + const target = document.querySelector(anchor.getAttribute('href')); + if (target) { + e.preventDefault(); + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }); +}); + +// Subtle parallax on hero background glow +const hero = document.querySelector('.hero'); +if (hero) { + window.addEventListener('scroll', () => { + const scrolled = window.scrollY; + const heroHeight = hero.offsetHeight; + if (scrolled < heroHeight) { + // Move the pseudo-element glow upward slightly as user scrolls + hero.style.setProperty('--parallax-offset', `${scrolled * 0.3}px`); + } + }, { passive: true }); +} +``` + +- [ ] **Step 2: Wire the parallax offset into CSS** + +In `site/style.css`, update the `.hero::before` rule to use the variable: + +Find: +```css +.hero::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(ellipse 80% 60% at 50% 40%, rgba(201,168,76,0.07) 0%, transparent 70%); + pointer-events: none; +} +``` + +Replace with: +```css +.hero::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(ellipse 80% 60% at 50% calc(40% - var(--parallax-offset, 0px)), rgba(201,168,76,0.07) 0%, transparent 70%); + pointer-events: none; +} +``` + +- [ ] **Step 3: Verify in browser** + +```bash +npx serve site -l 4000 +# open http://localhost:4000 +``` + +Scroll slowly down from the hero. Expected: the gold glow in the hero drifts upward subtly as you scroll. Effect is gentle — if it's too strong, reduce `0.3` multiplier in `main.js`. + +- [ ] **Step 4: Commit** + +```bash +git add site/main.js site/style.css +git commit -m "feat: add smooth scroll and hero parallax" +``` + +--- + +## Task 6: Playwright screenshot script + +**Files:** +- Create: `site/package.json` +- Create: `site/screenshots.js` +- Modify: `.gitignore` (add `site/node_modules/`) + +- [ ] **Step 1: Add `site/node_modules/` to .gitignore** + +Append to `.gitignore`: +``` +site/node_modules/ +``` + +- [ ] **Step 2: Create `site/package.json`** + +```json +{ + "name": "darkwatch-brochure-screenshots", + "type": "module", + "scripts": { + "screenshots": "node screenshots.js", + "screenshots:only": "node screenshots.js --only" + }, + "devDependencies": { + "playwright": "^1.43.0" + } +} +``` + +- [ ] **Step 3: Install Playwright and its Chromium browser** + +```bash +cd site +npm install +npx playwright install chromium +cd .. +``` + +Expected: `node_modules/` created in `site/`, Chromium downloaded. + +- [ ] **Step 4: Create `site/screenshots.js`** + +```javascript +import { chromium } from 'playwright'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { mkdirSync } from 'fs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SHOT_DIR = resolve(__dirname, 'assets', 'screenshots'); +const APP = 'http://localhost:5173'; +const DM_EMAIL = 'dm@darkwatch.test'; +const DM_PASS = 'password'; +const ONLY = process.argv.includes('--only') + ? process.argv[process.argv.indexOf('--only') + 1] + : null; + +mkdirSync(SHOT_DIR, { recursive: true }); + +// ── helpers ──────────────────────────────────────────────────────────────── + +async function shot(page, name, fn) { + if (ONLY && name !== ONLY) return; + console.log(` capturing ${name}...`); + await fn(page); + await page.screenshot({ + path: resolve(SHOT_DIR, `${name}.png`), + fullPage: false, + }); + console.log(` ✓ ${name}.png`); +} + +async function login(page) { + await page.goto(`${APP}/login`); + await page.fill('input[type="email"]', DM_EMAIL); + await page.fill('input[type="password"]', DM_PASS); + await page.click('button[type="submit"]'); + await page.waitForURL(`${APP}/`); +} + +async function openCampaign(page) { + // Seeded campaign is named "Tomb of the Serpent King" + await page.click('text=Tomb of the Serpent King'); + await page.waitForURL(/\/campaign\/\d+/); + await page.waitForTimeout(1500); // socket connect + data load +} + +// ── captures ─────────────────────────────────────────────────────────────── + +async function captureCharacterSheet(page) { + // DM view — click the first character card to open full sheet + // Selector: first .characterCard element in the card grid + // Adjust class name if different in the actual DOM + const card = page.locator('[class*="characterCard"]').first(); + await card.click(); + await page.waitForTimeout(500); +} + +async function captureCharacterCreation(page) { + // Open character creation wizard and advance to step 2 (stat rolling) + await page.click('button:has-text("New Character")'); + await page.waitForTimeout(400); + // Fill required fields on step 1 so Next is enabled + const nameInput = page.locator('input[placeholder*="name" i]').first(); + await nameInput.fill('Screenshot Hero'); + // Advance to step 2 + await page.click('button:has-text("Next")'); + await page.waitForTimeout(400); +} + +async function captureDiceRoll(page) { + // Roll a d20 — find the d20 button in the dice panel + await page.click('button:has-text("d20")'); + await page.waitForTimeout(2000); // wait for 3D animation to settle +} + +async function captureSpellcasting(page) { + // Open the Spells tab/panel for the first Wizard or Priest character + // The spell panel may be a tab within the character sheet + await page.click('text=Spells'); + await page.waitForTimeout(400); +} + +async function captureAtmosphere(page) { + // Open atmosphere panel and enable fog + await page.click('button[title*="tmosphere" i], button:has-text("Atmosphere")'); + await page.waitForTimeout(300); + await page.click('button:has-text("Fog")'); + await page.waitForTimeout(600); // wait for fog to render +} + +async function captureInitiativeActive(page) { + // Click the Combat button to open the start combat modal + await page.click('button:has-text("Combat")'); + await page.waitForTimeout(300); + // Start combat with whatever defaults are present + await page.click('button:has-text("Start Combat")'); + await page.waitForTimeout(500); + // Roll initiative for enemies (DM roll) + const rollBtn = page.locator('button:has-text("Roll")').first(); + await rollBtn.click(); + await page.waitForTimeout(400); +} + +async function captureDmCards(page) { + // The DM compact card grid is the default campaign view. + // Navigate back to campaign root to ensure no modals are open. + const url = page.url(); + await page.goto(url); + await page.waitForTimeout(1500); // let socket reconnect +} + +// ── main ─────────────────────────────────────────────────────────────────── + +async function run() { + console.log('Starting Darkwatch screenshot capture...'); + console.log(`App: ${APP}`); + if (ONLY) console.log(`Only: ${ONLY}`); + console.log(''); + + const browser = await chromium.launch(); + const context = await browser.newContext({ + viewport: { width: 1280, height: 800 }, + }); + const page = await context.newPage(); + + await login(page); + console.log('✓ Logged in as DM'); + + await openCampaign(page); + console.log('✓ Campaign open'); + console.log(''); + + await shot(page, 'dm-cards', captureDmCards); + await shot(page, 'character-sheet', captureCharacterSheet); + await shot(page, 'character-creation',captureCharacterCreation); + await shot(page, 'dice-roll', captureDiceRoll); + await shot(page, 'spellcasting', captureSpellcasting); + await shot(page, 'atmosphere', captureAtmosphere); + await shot(page, 'initiative-active', captureInitiativeActive); + + await browser.close(); + console.log(''); + console.log('Done! Screenshots saved to site/assets/screenshots/'); +} + +run().catch(err => { + console.error(err); + process.exit(1); +}); +``` + +- [ ] **Step 5: Run the script against the running dev server** + +Ensure the dev server is running first (`npm run dev` in `server/` and `client/`). Then: + +```bash +cd site +node screenshots.js +``` + +Expected output: +``` +Starting Darkwatch screenshot capture... +App: http://localhost:5173 + +✓ Logged in as DM +✓ Campaign open + + capturing dm-cards... + ✓ dm-cards.png + capturing character-sheet... + ... +Done! Screenshots saved to site/assets/screenshots/ +``` + +**If a capture fails:** The most likely cause is a selector mismatch. Open Playwright in headed mode to debug: + +```javascript +// Temporarily change launch() to: +const browser = await chromium.launch({ headless: false, slowMo: 500 }); +``` + +Use browser DevTools to find the correct selector, update the relevant `capture*` function, then switch back to headless. + +- [ ] **Step 6: Verify screenshots look good** + +```bash +open site/assets/screenshots/ +``` + +Open each PNG and verify: +- `dm-cards.png` — compact DM character card grid, 2+ cards visible +- `character-sheet.png` — full character stats visible +- `character-creation.png` — stat rolling step, 3d6 dice area visible +- `dice-roll.png` — dice visible (mid-roll or result), roll log visible +- `spellcasting.png` — spell list with slot indicators +- `atmosphere.png` — fog or fire effect clearly visible over the UI +- `initiative-active.png` — tracker showing two sides, round counter + +If any screenshot is blank, too dark, or shows the wrong view, debug with headed mode (Step 5 note) and re-run `node screenshots.js --only `. + +- [ ] **Step 7: Commit** + +```bash +git add site/package.json site/screenshots.js .gitignore site/assets/screenshots/ +git commit -m "feat: add Playwright screenshot script for brochure" +``` + +Note: `site/node_modules/` is gitignored. The PNG screenshots are committed so the site renders without running the script. + +--- + +## Task 7: update-brochure skill + CLAUDE.md + +**Files:** +- Create: `.claude/skills/update-brochure.md` +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Create `.claude/skills/update-brochure.md`** + +```markdown +# update-brochure + +Use this skill when a Darkwatch feature is added, changed, or removed, and the brochure needs updating. It keeps `site/index.html` and `site/screenshots.js` in sync with the app. + +## Scope + +- `site/index.html` — add, update, or remove feature rows +- `site/screenshots.js` — add, update, or remove capture blocks +- System support strip in `site/index.html` — update when a new RPG system is added + +**Not in scope:** Hero copy, publisher section copy, footer links. Those are editorial and manual. + +## Process: Feature added + +1. Add a new `.feature-row` block to `site/index.html` after the last existing feature row (before the `.callout-row`). Follow the alternating pattern: + - Rows 1, 3, 5, 7 (odd positions): screenshot on the left, text on the right (no `reverse` class) + - Rows 2, 4, 6 (even positions): `class="feature-row reverse"` (screenshot right, text left) + - Count existing rows to determine whether to add `reverse` or not + + Template for a new row: + ```html +
+
+ Description of screenshot +
+
+
Short Label
+

Punchy headline.

+

+ Two sentences. Write for players and DMs, not developers. +

+
+
+ ``` + +2. Add a capture block to `site/screenshots.js`: + - Write a `captureFeatureName(page)` async function that navigates to the right UI state + - Add `await shot(page, 'feature-name', captureFeatureName);` to the `run()` function, in the same order as the HTML row + +3. Run the screenshot script for the new capture: + ```bash + cd site && node screenshots.js --only feature-name + ``` + +4. Commit all three files: + ```bash + git add site/index.html site/screenshots.js site/assets/screenshots/feature-name.png + git commit -m "docs: add [feature name] to brochure" + ``` + +## Process: Feature removed + +1. Delete the feature's `.feature-row` block from `site/index.html` +2. Recheck alternating pattern — renumber remaining rows if needed +3. Delete the `capture*` function and `shot()` call from `site/screenshots.js` +4. Delete the PNG from `site/assets/screenshots/` +5. Commit + +## Process: Feature changed (copy update only) + +1. Update the heading and description in the relevant `.feature-row` in `site/index.html` +2. Re-run the screenshot if the UI changed visually: + ```bash + cd site && node screenshots.js --only feature-name + ``` +3. Commit + +## Process: New RPG system added + +1. Update the system support strip in `site/index.html`: + ```html + +

Currently: Shadowdark RPG  ·  Next: based on demand

+ + +

Currently: Shadowdark RPG  ·  Cairn  ·  More based on demand

+ ``` +2. Commit + +## Tone guidelines + +Write feature descriptions for players and DMs, not developers. + +- ✓ "Everyone rolls. The tracker takes the party's highest result." +- ✗ "The server computes max(party_rolls) and compares to the enemy roll." + +Keep headings punchy and short. Keep descriptions to two sentences max. +``` + +- [ ] **Step 2: Read current CLAUDE.md to find the Project Skills table** + +```bash +grep -n "update-changelog\|update-handbook\|Project Skills" CLAUDE.md +``` + +- [ ] **Step 3: Add update-brochure to the Project Skills table in CLAUDE.md** + +Find the existing table: +```markdown +| `update-changelog` | `.claude/skills/update-changelog.md` | After a feature ships, bug is fixed, or feature is removed | +| `update-handbook` | `.claude/skills/update-handbook.md` | After a feature is added, changed, or removed | +``` + +Add a row: +```markdown +| `update-changelog` | `.claude/skills/update-changelog.md` | After a feature ships, bug is fixed, or feature is removed | +| `update-handbook` | `.claude/skills/update-handbook.md` | After a feature is added, changed, or removed | +| `update-brochure` | `.claude/skills/update-brochure.md` | After a feature is added, changed, or removed — keeps the site current | +``` + +- [ ] **Step 4: Commit** + +```bash +git add .claude/skills/update-brochure.md CLAUDE.md +git commit -m "feat: add update-brochure skill and register in CLAUDE.md" +``` + +--- + +## Self-Review Checklist + +After all tasks complete, verify against the spec: + +- [ ] `site/index.html` has all 5 sections: hero, features (7 rows + callout cards), system support, publisher, footer +- [ ] Feature rows alternate left/right correctly (1,3,5,7 image-left; 2,4,6 image-right) +- [ ] All 7 screenshot `img` tags reference `assets/screenshots/*.png` +- [ ] Hero CTA links to Discord (placeholder is acceptable pre-launch) +- [ ] Publisher section uses `mailto:email@email.com` placeholder +- [ ] `site/screenshots.js` has a named capture function for each of the 7 PNGs +- [ ] `--only` flag works: `node screenshots.js --only dm-cards` captures only that one +- [ ] `.claude/skills/update-brochure.md` covers add, remove, change, and new-system processes +- [ ] `CLAUDE.md` lists `update-brochure` in the Project Skills table +- [ ] `site/node_modules/` is gitignored +- [ ] Screenshots are committed to `site/assets/screenshots/` diff --git a/docs/superpowers/specs/2026-04-10-darkwatch-auth-db-design.md b/docs/specs/2026-04-10-darkwatch-auth-db-design.md similarity index 100% rename from docs/superpowers/specs/2026-04-10-darkwatch-auth-db-design.md rename to docs/specs/2026-04-10-darkwatch-auth-db-design.md diff --git a/docs/superpowers/specs/2026-04-10-particle-effects-design.md b/docs/specs/2026-04-10-particle-effects-design.md similarity index 100% rename from docs/superpowers/specs/2026-04-10-particle-effects-design.md rename to docs/specs/2026-04-10-particle-effects-design.md diff --git a/docs/superpowers/specs/2026-04-11-handbook-changelog-design.md b/docs/specs/2026-04-11-handbook-changelog-design.md similarity index 100% rename from docs/superpowers/specs/2026-04-11-handbook-changelog-design.md rename to docs/specs/2026-04-11-handbook-changelog-design.md diff --git a/docs/superpowers/specs/2026-04-11-initiative-tracker-design.md b/docs/specs/2026-04-11-initiative-tracker-design.md similarity index 100% rename from docs/superpowers/specs/2026-04-11-initiative-tracker-design.md rename to docs/specs/2026-04-11-initiative-tracker-design.md diff --git a/docs/superpowers/specs/2026-04-12-brochure-design.md b/docs/specs/2026-04-12-brochure-design.md similarity index 100% rename from docs/superpowers/specs/2026-04-12-brochure-design.md rename to docs/specs/2026-04-12-brochure-design.md