Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef76b9c90b | ||
|
|
36122b500b | ||
|
|
f4355a8675 | ||
|
|
209df13c32 | ||
|
|
4969ed069b | ||
|
|
fba83bebd6 | ||
|
|
f6766b729d | ||
|
|
f10c67a5ba | ||
|
|
9437272fe0 | ||
|
|
541e04b732 | ||
|
|
e9fd896934 | ||
|
|
29cdd19cab | ||
|
|
17cc6ed72c | ||
|
|
9d81c8ad27 | ||
|
|
7199b9d2d9 | ||
|
|
158bcf1468 | ||
|
|
fab9301b20 | ||
|
|
d653cfe489 | ||
|
|
228a2603e8 |
206
.claude/skills/browser-interactive-testing/SKILL.md
Normal file
206
.claude/skills/browser-interactive-testing/SKILL.md
Normal file
@@ -0,0 +1,206 @@
|
||||
---
|
||||
name: browser-interactive-testing
|
||||
description: >
|
||||
This skill should be used when the user asks to "test a web page",
|
||||
"take a screenshot of a site", "automate browser interaction",
|
||||
"create a test report", "verify a page works", or mentions
|
||||
rodney, showboat, headless Chrome testing, or browser automation.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Browser Interactive Testing
|
||||
|
||||
Test web pages interactively using **rodney** (headless Chrome automation) and document results with **showboat** (executable demo reports).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Ensure `uv` is installed. If missing, instruct the user to run:
|
||||
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
```
|
||||
|
||||
Do NOT install rodney or showboat globally. Run them via `uvx`:
|
||||
|
||||
```bash
|
||||
uvx rodney <command>
|
||||
uvx showboat <command>
|
||||
```
|
||||
|
||||
## Rodney Quick Reference
|
||||
|
||||
### Start a browser session
|
||||
|
||||
```bash
|
||||
uvx rodney start # Launch headless Chrome
|
||||
uvx rodney start --show # Launch visible browser (for debugging)
|
||||
uvx rodney connect host:port # Connect to existing Chrome with remote debugging
|
||||
```
|
||||
|
||||
Use `--local` on all commands to scope the session to the current directory.
|
||||
|
||||
### Navigate and inspect
|
||||
|
||||
```bash
|
||||
uvx rodney open "https://example.com"
|
||||
uvx rodney waitload
|
||||
uvx rodney title
|
||||
uvx rodney url
|
||||
uvx rodney text "h1"
|
||||
uvx rodney html "#content"
|
||||
```
|
||||
|
||||
### Interact with elements
|
||||
|
||||
```bash
|
||||
uvx rodney click "#submit-btn"
|
||||
uvx rodney input "#email" "user@example.com"
|
||||
uvx rodney select "#country" "US"
|
||||
uvx rodney js "document.querySelector('#app').dataset.ready"
|
||||
```
|
||||
|
||||
### Assert and verify
|
||||
|
||||
```bash
|
||||
uvx rodney assert "document.title" "My App" -m "Title must match"
|
||||
uvx rodney exists ".error-banner"
|
||||
uvx rodney visible "#loading-spinner"
|
||||
uvx rodney count ".list-item"
|
||||
```
|
||||
|
||||
Exit code `0` = pass, `1` = fail, `2` = error.
|
||||
|
||||
### Screenshots and cleanup
|
||||
|
||||
```bash
|
||||
uvx rodney screenshot -w 1280 -h 720 page.png
|
||||
uvx rodney screenshot-el "#chart" chart.png
|
||||
uvx rodney stop
|
||||
```
|
||||
|
||||
Run `uvx rodney --help` for the full command list, including tab management, navigation, waiting, accessibility tree inspection, and PDF export.
|
||||
|
||||
## Showboat Quick Reference
|
||||
|
||||
```bash
|
||||
uvx showboat init report.md "Test Report Title"
|
||||
uvx showboat note report.md "Description of what we are testing."
|
||||
uvx showboat exec report.md bash "uvx rodney title --local"
|
||||
uvx showboat image report.md ''
|
||||
uvx showboat pop report.md # Remove last entry (fix mistakes)
|
||||
uvx showboat verify report.md # Re-run all code blocks and diff
|
||||
uvx showboat extract report.md # Print commands that recreate the document
|
||||
```
|
||||
|
||||
Run `uvx showboat --help` for details on `--workdir`, `--output`, `--filename`, and stdin piping.
|
||||
|
||||
## Output Directory
|
||||
|
||||
Save all reports under `.agent-tests/` in the project root:
|
||||
|
||||
```
|
||||
.agent-tests/
|
||||
└── YYYY-MM-DD-<slug>/
|
||||
├── report.md
|
||||
└── screenshots/
|
||||
```
|
||||
|
||||
Derive the slug from the test subject (e.g., `login-flow`, `homepage-layout`). Keep it lowercase, hyphen-separated, max ~30 chars. If a directory with the same date and slug already exists, append a numeric suffix (e.g., `tetris-game-2`) or choose a more specific slug (e.g., `tetris-controls` instead of reusing `tetris-game`).
|
||||
|
||||
### Setup Script
|
||||
|
||||
Run the bundled `scripts/setup.py` to create the directory, init the report, start the browser, and capture `DIR` in one step. Replace `<SKILL_DIR>` with the actual path to the directory containing this skill's files:
|
||||
|
||||
```bash
|
||||
DIR=$(python3 <SKILL_DIR>/scripts/setup.py "<slug>" "<Report Title>")
|
||||
```
|
||||
|
||||
This single command:
|
||||
1. Creates `.agent-tests/YYYY-MM-DD-<slug>/screenshots/`
|
||||
2. Adds `.rodney/` to `.gitignore` (if `.gitignore` exists)
|
||||
3. Runs `showboat init` for the report
|
||||
4. Starts a browser (connects to existing, launches system Chrome/Chromium, or falls back to rodney's built-in launcher)
|
||||
5. Prints the directory path to stdout (all status messages go to stderr)
|
||||
|
||||
After setup, `$DIR` is ready for use with all subsequent commands.
|
||||
|
||||
**Important:** The `--local` flag stores session data in `.rodney/` relative to the current working directory. Do NOT `cd` to a different directory during the session, or rodney will lose the connection. Use absolute paths for file arguments instead.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Setup** — Run the setup script to create the dir, init the report, start the browser, and set `$DIR`
|
||||
2. **Describe the test** — `uvx showboat note "$DIR/report.md" "Testing [subject] for [goals]."` so the report has context up front
|
||||
3. **Open page** — `uvx showboat exec "$DIR/report.md" bash "uvx rodney open --local 'URL' && uvx rodney waitload --local"`
|
||||
4. **Add a note** before each test group — Use a heading followed by a short explanation of what the tests in this section verify and why it matters. Use unique section titles; avoid duplicating headings within the same report.
|
||||
```bash
|
||||
uvx showboat note "$DIR/report.md" "## Keyboard Controls"
|
||||
uvx showboat note "$DIR/report.md" "Verify arrow keys move and rotate the active piece, and that soft/hard drop work correctly."
|
||||
```
|
||||
5. **Run assertions** — Before each assertion, add a short `showboat note` explaining what it checks. Then wrap the `rodney assert` / `rodney js` call in `showboat exec`:
|
||||
```bash
|
||||
uvx showboat note "$DIR/report.md" "The left arrow key should move the piece one cell to the left."
|
||||
uvx showboat exec "$DIR/report.md" bash "uvx rodney assert --local '...' '...' -m 'Piece moved left'"
|
||||
```
|
||||
6. **Capture screenshots** — Take the screenshot with `rodney screenshot`, then embed with `showboat image`. **Important:** `showboat image` resolves image paths relative to the current working directory, NOT relative to the report file. Always use absolute paths (`$DIR/screenshots/...`) in the markdown image reference to avoid "image file not found" errors:
|
||||
```bash
|
||||
uvx rodney screenshot --local -w 1280 -h 720 "$DIR/screenshots/01-initial-load.png"
|
||||
uvx showboat image "$DIR/report.md" ""
|
||||
```
|
||||
Number screenshots sequentially (`01-`, `02-`, ...) and use descriptive filenames.
|
||||
7. **Pop on failure** — If a command fails, run `showboat pop` then retry
|
||||
8. **Stop browser** — `uvx rodney stop --local`
|
||||
9. **Write summary** — Add a final `showboat note` with a summary section listing all pass/fail results and any bugs found. Every report must end with a summary.
|
||||
10. **Verify report** — `uvx showboat verify "$DIR/report.md"`
|
||||
|
||||
### Best Practices
|
||||
|
||||
- Use `uvx rodney waitload` or `uvx rodney wait <selector>` before interacting with page content.
|
||||
- Run `uvx showboat pop` immediately after a failed `exec` to keep the report clean.
|
||||
- Prefer `rodney assert` for checks — clear exit codes and self-documenting output.
|
||||
- Use `rodney js` only for complex checks or state manipulation that `assert` cannot express.
|
||||
- Take screenshots at key stages (initial load, after interaction, error states) for visual evidence.
|
||||
- Add a `showboat note` before each logical group of tests with a heading and a short explanation of what the section tests. Use unique heading titles — duplicate headings make the report confusing.
|
||||
- Always end reports with a summary `showboat note` listing pass/fail results and any bugs found. This is required, not optional.
|
||||
|
||||
## Quoting Rules for `rodney js`
|
||||
|
||||
`rodney js` evaluates a single JS **expression** (not statements). Nested shell quoting with `showboat exec` causes most errors. Follow these rules strictly:
|
||||
|
||||
1. **Wrap multi-statement JS in an IIFE** — bare `const`, `let`, `for` fail at top level:
|
||||
```bash
|
||||
# WRONG
|
||||
uvx rodney js --local 'const x = 1; x + 2'
|
||||
# CORRECT
|
||||
uvx rodney js --local '(function(){ var x = 1; return x + 2; })()'
|
||||
```
|
||||
|
||||
2. **Use `var` instead of `const`/`let`** inside IIFEs to avoid strict-mode eval scoping issues.
|
||||
|
||||
3. **Direct `rodney js` calls** — use single quotes for the outer shell, double quotes inside JS:
|
||||
```bash
|
||||
uvx rodney js --local '(function(){ var el = document.querySelector("#app"); return el.textContent; })()'
|
||||
```
|
||||
|
||||
4. **Inside `showboat exec`** — use a heredoc with a **quoted delimiter** (`<<'JSEOF'`) to prevent all shell expansion (`$`, backticks, etc.):
|
||||
```bash
|
||||
uvx showboat exec "$DIR/report.md" bash "$(cat <<'JSEOF'
|
||||
uvx rodney js --local '
|
||||
(function(){
|
||||
var x = score;
|
||||
hardDrop();
|
||||
return "before:" + x + ",after:" + score;
|
||||
})()
|
||||
'
|
||||
JSEOF
|
||||
)"
|
||||
```
|
||||
For simple one-liners, single quotes inside the double-quoted bash arg also work:
|
||||
```bash
|
||||
uvx showboat exec "$DIR/report.md" bash "uvx rodney js --local '(function(){ return String(score); })()'"
|
||||
```
|
||||
|
||||
5. **Avoid without heredoc**: backticks, `$` signs, unescaped double quotes. The heredoc pattern avoids all of these.
|
||||
|
||||
6. **Prefer `rodney assert` over `rodney js`** when possible — separate arguments avoid quoting entirely.
|
||||
|
||||
7. **Pop after syntax errors** — always `showboat pop` before retrying to keep the report clean.
|
||||
160
.claude/skills/browser-interactive-testing/scripts/setup.py
Normal file
160
.claude/skills/browser-interactive-testing/scripts/setup.py
Normal file
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Set up a browser-interactive-testing session.
|
||||
|
||||
Creates the output directory, inits the showboat report, starts a browser,
|
||||
and prints the DIR path. Automatically detects whether rodney can launch
|
||||
its own Chromium or falls back to a system-installed browser.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
REMOTE_DEBUG_PORT = 9222
|
||||
|
||||
|
||||
def find_system_browser():
|
||||
"""Return the path to a system Chrome/Chromium binary, or None."""
|
||||
for name in ["chromium", "chromium-browser", "google-chrome", "google-chrome-stable"]:
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def port_listening(port):
|
||||
"""Check if something is already listening on the given port."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.settimeout(1)
|
||||
return s.connect_ex(("localhost", port)) == 0
|
||||
|
||||
|
||||
def try_connect(port):
|
||||
"""Try to connect rodney to a browser on the given port. Returns True on success."""
|
||||
result = subprocess.run(
|
||||
["uvx", "rodney", "connect", "--local", f"localhost:{port}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print(f"Connected to existing browser on port {port}", file=sys.stderr)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def launch_system_browser(browser_path):
|
||||
"""Launch a system browser with remote debugging and wait for it to be ready."""
|
||||
subprocess.Popen(
|
||||
[
|
||||
browser_path,
|
||||
"--headless",
|
||||
"--disable-gpu",
|
||||
f"--remote-debugging-port={REMOTE_DEBUG_PORT}",
|
||||
"--no-sandbox",
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
# Wait for the browser to start listening
|
||||
for _ in range(20):
|
||||
if port_listening(REMOTE_DEBUG_PORT):
|
||||
return True
|
||||
time.sleep(0.25)
|
||||
return False
|
||||
|
||||
|
||||
def start_browser():
|
||||
"""Start a headless browser and connect rodney to it.
|
||||
|
||||
Strategy order (fastest path first):
|
||||
1. Connect to an already-running browser on the debug port.
|
||||
2. Launch a system Chrome/Chromium (avoids rodney's Chromium download,
|
||||
which fails on some architectures like Linux ARM64).
|
||||
3. Let rodney launch its own browser as a last resort.
|
||||
"""
|
||||
# Strategy 1: connect to an already-running browser
|
||||
if port_listening(REMOTE_DEBUG_PORT) and try_connect(REMOTE_DEBUG_PORT):
|
||||
return
|
||||
|
||||
# Strategy 2: launch a system browser (most reliable on Linux)
|
||||
browser = find_system_browser()
|
||||
if browser:
|
||||
print(f"Launching system browser: {browser}", file=sys.stderr)
|
||||
if launch_system_browser(browser):
|
||||
if try_connect(REMOTE_DEBUG_PORT):
|
||||
return
|
||||
print("WARNING: system browser started but rodney could not connect", file=sys.stderr)
|
||||
else:
|
||||
print("WARNING: system browser did not start in time", file=sys.stderr)
|
||||
|
||||
# Strategy 3: let rodney try its built-in launcher
|
||||
result = subprocess.run(
|
||||
["uvx", "rodney", "start", "--local"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print("Browser started via rodney", file=sys.stderr)
|
||||
return
|
||||
|
||||
print(
|
||||
"ERROR: Could not start a browser. Tried:\n"
|
||||
f" - Connecting to localhost:{REMOTE_DEBUG_PORT} (no browser found)\n"
|
||||
f" - System browser: {browser or 'not found'}\n"
|
||||
" - rodney start (failed)\n"
|
||||
"Install chromium or google-chrome and try again.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def ensure_gitignore_entry(entry):
|
||||
"""Add entry to .gitignore if the file exists and the entry is missing."""
|
||||
gitignore = ".gitignore"
|
||||
if not os.path.isfile(gitignore):
|
||||
return
|
||||
with open(gitignore, "r") as f:
|
||||
content = f.read()
|
||||
# Check if the entry (with or without trailing slash/newline variations) is already present
|
||||
lines = content.splitlines()
|
||||
if any(line.strip() == entry or line.strip() == entry.rstrip("/") for line in lines):
|
||||
return
|
||||
# Append the entry
|
||||
with open(gitignore, "a") as f:
|
||||
if content and not content.endswith("\n"):
|
||||
f.write("\n")
|
||||
f.write(f"{entry}\n")
|
||||
print(f"Added '{entry}' to .gitignore", file=sys.stderr)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print(f"Usage: {sys.argv[0]} <slug> <report-title>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
slug = sys.argv[1]
|
||||
title = sys.argv[2]
|
||||
|
||||
# Create output directory
|
||||
d = f".agent-tests/{datetime.date.today()}-{slug}"
|
||||
os.makedirs(f"{d}/screenshots", exist_ok=True)
|
||||
|
||||
# Ensure .rodney/ is in .gitignore (rodney stores session files there)
|
||||
ensure_gitignore_entry(".rodney/")
|
||||
|
||||
# Init showboat report
|
||||
subprocess.run(["uvx", "showboat", "init", f"{d}/report.md", title], check=True)
|
||||
|
||||
# Start browser
|
||||
start_browser()
|
||||
|
||||
# Print the directory path (only real stdout, everything else goes to stderr)
|
||||
print(d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,3 +12,4 @@ Thumbs.db
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
docs/agents/plans/
|
||||
.rodney/
|
||||
|
||||
13
CLAUDE.md
13
CLAUDE.md
@@ -73,6 +73,7 @@ docs/agents/ RPI skill artifacts (research reports, plans)
|
||||
- **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks.
|
||||
- **Feature specs** live in `specs/NNN-feature-name/` with spec.md (and optionally plan.md, tasks.md for new work). Specs describe features, not individual changes. The project constitution is at `.specify/memory/constitution.md`.
|
||||
- **Component props** — max 8 explicitly declared props per component interface (enforced by `scripts/check-component-props.mjs`). Use React context for shared state; reserve props for per-instance config (data items, layout variants, refs).
|
||||
- **Export format compatibility** — When changing `Encounter`, `Combatant`, `PlayerCharacter`, or `UndoRedoState` types, verify that previously exported JSON files (version 1) still import correctly. If not, bump the `ExportBundle` version and add migration logic in `validateImportBundle()`.
|
||||
- **Quality gates** are enforced at pre-commit via Lefthook's `pnpm check` — the project's single earliest enforcement point. No gate may exist only as a CI step or manual process.
|
||||
|
||||
## Self-Review Checklist
|
||||
@@ -97,6 +98,8 @@ Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Spec
|
||||
- `rpi-plan` — interactive phased implementation plan in `docs/agents/plans/`
|
||||
- `rpi-implement` — execute a plan file phase by phase with automated + manual verification
|
||||
|
||||
**Research scope**: Research should include a scan for existing patterns similar to what the feature needs (e.g., shared UI primitives, duplicated validation logic, repeated state management patterns). Identify extraction and consolidation opportunities before implementation, not during.
|
||||
|
||||
### Choosing the right workflow by scope
|
||||
|
||||
| Scope | Workflow |
|
||||
@@ -114,6 +117,9 @@ Speckit manages **what** to build (specs as living documents). RPI manages **how
|
||||
- `specs/003-combatant-state/` — HP, AC, conditions, concentration, initiative
|
||||
- `specs/004-bestiary/` — search index, stat blocks, source management, panel UX
|
||||
- `specs/005-player-characters/` — persistent player character templates (CRUD), search & add to encounters, color/icon visual distinction, `PlayerCharacterStore` port
|
||||
- `specs/006-undo-redo/` — undo/redo for encounter state mutations
|
||||
- `specs/007-json-import-export/` — JSON import/export for full encounter state (encounter, undo/redo, player characters)
|
||||
- `specs/008-encounter-difficulty/` — Live encounter difficulty indicator (5.5e XP budget system), optional PC level field
|
||||
|
||||
## Constitution (key principles)
|
||||
|
||||
@@ -124,10 +130,3 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work:
|
||||
3. **Clarification-First** — Ask before making non-trivial assumptions.
|
||||
4. **MVP Baseline** — Say "MVP baseline does not include X", never permanent bans.
|
||||
5. **Spec-driven features** — Features are described in living specs; evolve existing specs via `/integrate-issue`, create new ones via `/speckit.specify`. Bug fixes and tooling changes do not require specs.
|
||||
|
||||
## Active Technologies
|
||||
- TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Vite 6, Tailwind CSS v4, Lucide Reac (005-player-characters)
|
||||
- localStorage (new key `"initiative:player-characters"`) (005-player-characters)
|
||||
|
||||
## Recent Changes
|
||||
- 005-player-characters: Added TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Vite 6, Tailwind CSS v4, Lucide Reac
|
||||
|
||||
@@ -7,7 +7,10 @@ A local-first initiative tracker and encounter manager for tabletop RPGs (D&D 5e
|
||||
- **Initiative tracking** — add combatants (batch-add from bestiary, custom creatures with optional stats), roll initiative (manual or d20), cycle turns and rounds
|
||||
- **Encounter state** — HP, AC, conditions, concentration tracking with visual status indicators
|
||||
- **Bestiary integration** — import bestiary JSON sources, search creatures, and view full stat blocks
|
||||
- **Player characters** — create reusable player character templates with name, AC, HP, color, and icon; search and add them to encounters with pre-filled stats; manage (edit/delete) from a dedicated panel
|
||||
- **Player characters** — create reusable player character templates with name, AC, HP, level, color, and icon; search and add them to encounters with pre-filled stats; manage (edit/delete) from a dedicated panel
|
||||
- **Encounter difficulty** — live 3-bar indicator in the top bar showing encounter difficulty (Trivial/Low/Moderate/High) based on the 2024 5.5e XP budget system; automatically derived from PC levels and bestiary creature CRs
|
||||
- **Undo/redo** — reverse any encounter action with Undo/Redo buttons or keyboard shortcuts (Ctrl+Z / Ctrl+Shift+Z, Cmd on Mac); history persists across page reloads
|
||||
- **Import/export** — export the full encounter state (combatants, undo/redo history, player characters) as a JSON file or copy to clipboard; import from file upload or pasted JSON with validation and confirmation
|
||||
- **Persistent** — encounters survive page reloads via localStorage; bestiary data cached in IndexedDB; player characters stored independently
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -30,6 +30,13 @@ export function App() {
|
||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
|
||||
|
||||
// Close the side panel when the encounter becomes empty
|
||||
useEffect(() => {
|
||||
if (isEmpty) {
|
||||
sidePanel.dismissPanel();
|
||||
}
|
||||
}, [isEmpty, sidePanel.dismissPanel]);
|
||||
|
||||
// Auto-scroll to active combatant when turn changes
|
||||
const activeIndex = encounter.activeIndex;
|
||||
useEffect(() => {
|
||||
|
||||
233
apps/web/src/__tests__/export-import.test.ts
Normal file
233
apps/web/src/__tests__/export-import.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
combatantId,
|
||||
type Encounter,
|
||||
type ExportBundle,
|
||||
type PlayerCharacter,
|
||||
playerCharacterId,
|
||||
type UndoRedoState,
|
||||
} from "@initiative/domain";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assembleExportBundle,
|
||||
bundleToJson,
|
||||
resolveFilename,
|
||||
validateImportBundle,
|
||||
} from "../persistence/export-import.js";
|
||||
|
||||
const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
|
||||
const DEFAULT_FILENAME_RE = /^initiative-export-\d{4}-\d{2}-\d{2}\.json$/;
|
||||
|
||||
const encounter: Encounter = {
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c-1"),
|
||||
name: "Goblin",
|
||||
initiative: 15,
|
||||
maxHp: 7,
|
||||
currentHp: 7,
|
||||
ac: 15,
|
||||
},
|
||||
{
|
||||
id: combatantId("c-2"),
|
||||
name: "Aria",
|
||||
initiative: 18,
|
||||
maxHp: 45,
|
||||
currentHp: 40,
|
||||
ac: 16,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
playerCharacterId: playerCharacterId("pc-1"),
|
||||
},
|
||||
],
|
||||
activeIndex: 0,
|
||||
roundNumber: 2,
|
||||
};
|
||||
|
||||
const undoRedoState: UndoRedoState = {
|
||||
undoStack: [
|
||||
{
|
||||
combatants: [{ id: combatantId("c-1"), name: "Goblin", initiative: 15 }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
},
|
||||
],
|
||||
redoStack: [],
|
||||
};
|
||||
|
||||
const playerCharacters: PlayerCharacter[] = [
|
||||
{
|
||||
id: playerCharacterId("pc-1"),
|
||||
name: "Aria",
|
||||
ac: 16,
|
||||
maxHp: 45,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
];
|
||||
|
||||
describe("assembleExportBundle", () => {
|
||||
it("returns a bundle with version 1", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
expect(bundle.version).toBe(1);
|
||||
});
|
||||
|
||||
it("includes an ISO timestamp", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
expect(bundle.exportedAt).toMatch(ISO_TIMESTAMP_RE);
|
||||
});
|
||||
|
||||
it("includes the encounter", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
expect(bundle.encounter).toEqual(encounter);
|
||||
});
|
||||
|
||||
it("includes undo and redo stacks", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
expect(bundle.undoStack).toEqual(undoRedoState.undoStack);
|
||||
expect(bundle.redoStack).toEqual(undoRedoState.redoStack);
|
||||
});
|
||||
|
||||
it("includes player characters", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
expect(bundle.playerCharacters).toEqual(playerCharacters);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assembleExportBundle with includeHistory", () => {
|
||||
it("excludes undo/redo stacks when includeHistory is false", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
false,
|
||||
);
|
||||
expect(bundle.undoStack).toHaveLength(0);
|
||||
expect(bundle.redoStack).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("includes undo/redo stacks when includeHistory is true", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
true,
|
||||
);
|
||||
expect(bundle.undoStack).toEqual(undoRedoState.undoStack);
|
||||
expect(bundle.redoStack).toEqual(undoRedoState.redoStack);
|
||||
});
|
||||
|
||||
it("includes undo/redo stacks by default", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
expect(bundle.undoStack).toEqual(undoRedoState.undoStack);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundleToJson", () => {
|
||||
it("produces valid JSON that round-trips through validateImportBundle", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
const json = bundleToJson(bundle);
|
||||
const parsed: unknown = JSON.parse(json);
|
||||
const result = validateImportBundle(parsed);
|
||||
expect(typeof result).toBe("object");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveFilename", () => {
|
||||
it("uses date-based default when no name provided", () => {
|
||||
const result = resolveFilename();
|
||||
expect(result).toMatch(DEFAULT_FILENAME_RE);
|
||||
});
|
||||
|
||||
it("uses date-based default for empty string", () => {
|
||||
const result = resolveFilename("");
|
||||
expect(result).toMatch(DEFAULT_FILENAME_RE);
|
||||
});
|
||||
|
||||
it("uses date-based default for whitespace-only string", () => {
|
||||
const result = resolveFilename(" ");
|
||||
expect(result).toMatch(DEFAULT_FILENAME_RE);
|
||||
});
|
||||
|
||||
it("appends .json to a custom name", () => {
|
||||
expect(resolveFilename("my-encounter")).toBe("my-encounter.json");
|
||||
});
|
||||
|
||||
it("does not double-append .json", () => {
|
||||
expect(resolveFilename("my-encounter.json")).toBe("my-encounter.json");
|
||||
});
|
||||
|
||||
it("trims whitespace from custom name", () => {
|
||||
expect(resolveFilename(" my-encounter ")).toBe("my-encounter.json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("round-trip: export then import", () => {
|
||||
it("produces identical state after round-trip", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
|
||||
const serialized = JSON.parse(JSON.stringify(bundle));
|
||||
const result = validateImportBundle(serialized);
|
||||
|
||||
expect(typeof result).toBe("object");
|
||||
const imported = result as ExportBundle;
|
||||
expect(imported.version).toBe(bundle.version);
|
||||
expect(imported.encounter).toEqual(bundle.encounter);
|
||||
expect(imported.undoStack).toEqual(bundle.undoStack);
|
||||
expect(imported.redoStack).toEqual(bundle.redoStack);
|
||||
expect(imported.playerCharacters).toEqual(bundle.playerCharacters);
|
||||
});
|
||||
|
||||
it("round-trips an empty encounter", () => {
|
||||
const emptyEncounter: Encounter = {
|
||||
combatants: [],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
};
|
||||
const emptyUndoRedo: UndoRedoState = {
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
const bundle = assembleExportBundle(emptyEncounter, emptyUndoRedo, []);
|
||||
const serialized = JSON.parse(JSON.stringify(bundle));
|
||||
const result = validateImportBundle(serialized);
|
||||
|
||||
expect(typeof result).toBe("object");
|
||||
const imported = result as ExportBundle;
|
||||
expect(imported.encounter.combatants).toHaveLength(0);
|
||||
expect(imported.undoStack).toHaveLength(0);
|
||||
expect(imported.redoStack).toHaveLength(0);
|
||||
expect(imported.playerCharacters).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
249
apps/web/src/__tests__/validate-import-bundle.test.ts
Normal file
249
apps/web/src/__tests__/validate-import-bundle.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import type { ExportBundle } from "@initiative/domain";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateImportBundle } from "../persistence/export-import.js";
|
||||
|
||||
function validBundle(): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
exportedAt: "2026-03-27T12:00:00.000Z",
|
||||
encounter: {
|
||||
combatants: [{ id: "c-1", name: "Goblin", initiative: 15 }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
},
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
playerCharacters: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("validateImportBundle", () => {
|
||||
it("accepts a valid bundle", () => {
|
||||
const result = validateImportBundle(validBundle());
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.version).toBe(1);
|
||||
expect(bundle.encounter.combatants).toHaveLength(1);
|
||||
expect(bundle.encounter.combatants[0].name).toBe("Goblin");
|
||||
});
|
||||
|
||||
it("accepts a valid bundle with empty encounter", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
encounter: { combatants: [], activeIndex: 0, roundNumber: 1 },
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.encounter.combatants).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("accepts a bundle with undo/redo stacks", () => {
|
||||
const enc = {
|
||||
combatants: [{ id: "c-1", name: "Orc" }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
};
|
||||
const input = {
|
||||
...validBundle(),
|
||||
undoStack: [enc],
|
||||
redoStack: [enc],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.undoStack).toHaveLength(1);
|
||||
expect(bundle.redoStack).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("accepts a bundle with player characters", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
playerCharacters: [
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Aria",
|
||||
ac: 16,
|
||||
maxHp: 45,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.playerCharacters).toHaveLength(1);
|
||||
expect(bundle.playerCharacters[0].name).toBe("Aria");
|
||||
});
|
||||
|
||||
it("rejects non-object input", () => {
|
||||
expect(validateImportBundle(null)).toBe("Invalid file format");
|
||||
expect(validateImportBundle(42)).toBe("Invalid file format");
|
||||
expect(validateImportBundle("string")).toBe("Invalid file format");
|
||||
expect(validateImportBundle([])).toBe("Invalid file format");
|
||||
expect(validateImportBundle(undefined)).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects missing version field", () => {
|
||||
const input = validBundle();
|
||||
delete input.version;
|
||||
expect(validateImportBundle(input)).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects version 0 or negative", () => {
|
||||
expect(validateImportBundle({ ...validBundle(), version: 0 })).toBe(
|
||||
"Invalid file format",
|
||||
);
|
||||
expect(validateImportBundle({ ...validBundle(), version: -1 })).toBe(
|
||||
"Invalid file format",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown version", () => {
|
||||
expect(validateImportBundle({ ...validBundle(), version: 99 })).toBe(
|
||||
"Invalid file format",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects missing encounter field", () => {
|
||||
const input = validBundle();
|
||||
delete input.encounter;
|
||||
expect(validateImportBundle(input)).toBe("Invalid encounter data");
|
||||
});
|
||||
|
||||
it("rejects invalid encounter data", () => {
|
||||
expect(
|
||||
validateImportBundle({ ...validBundle(), encounter: "not an object" }),
|
||||
).toBe("Invalid encounter data");
|
||||
});
|
||||
|
||||
it("rejects missing undoStack", () => {
|
||||
const input = validBundle();
|
||||
delete input.undoStack;
|
||||
expect(validateImportBundle(input)).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects missing redoStack", () => {
|
||||
const input = validBundle();
|
||||
delete input.redoStack;
|
||||
expect(validateImportBundle(input)).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects missing playerCharacters", () => {
|
||||
const input = validBundle();
|
||||
delete input.playerCharacters;
|
||||
expect(validateImportBundle(input)).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects non-string exportedAt", () => {
|
||||
expect(validateImportBundle({ ...validBundle(), exportedAt: 12345 })).toBe(
|
||||
"Invalid file format",
|
||||
);
|
||||
});
|
||||
|
||||
it("drops invalid entries from undo stack", () => {
|
||||
const valid = {
|
||||
combatants: [{ id: "c-1", name: "Orc" }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
};
|
||||
const input = {
|
||||
...validBundle(),
|
||||
undoStack: [valid, "invalid", { bad: true }, valid],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.undoStack).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("drops invalid player characters", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
playerCharacters: [
|
||||
{ id: "pc-1", name: "Valid", ac: 10, maxHp: 20 },
|
||||
{ id: "", name: "Bad ID" },
|
||||
"not an object",
|
||||
{ id: "pc-3", name: "Also Valid", ac: 15, maxHp: 30 },
|
||||
],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.playerCharacters).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("rejects JSON array instead of object", () => {
|
||||
expect(validateImportBundle([1, 2, 3])).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects encounter that fails rehydration (missing combatant fields)", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
encounter: {
|
||||
combatants: [{ noId: true }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
},
|
||||
};
|
||||
expect(validateImportBundle(input)).toBe("Invalid encounter data");
|
||||
});
|
||||
|
||||
it("strips invalid color/icon from player characters but keeps the character", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
playerCharacters: [
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Test",
|
||||
ac: 10,
|
||||
maxHp: 20,
|
||||
color: "neon-pink",
|
||||
icon: "bazooka",
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
// rehydrateCharacter rejects characters with invalid color/icon members
|
||||
// that are not in the valid sets, so this character is dropped
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.playerCharacters).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps player characters with valid optional color and icon", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
playerCharacters: [
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Aria",
|
||||
ac: 16,
|
||||
maxHp: 45,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.playerCharacters).toHaveLength(1);
|
||||
expect(bundle.playerCharacters[0].color).toBe("blue");
|
||||
expect(bundle.playerCharacters[0].icon).toBe("sword");
|
||||
});
|
||||
|
||||
it("ignores unknown extra fields on the bundle", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
unknownField: "should be ignored",
|
||||
anotherExtra: 42,
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.version).toBe(1);
|
||||
expect("unknownField" in bundle).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -6,11 +6,19 @@ import { combatantId } from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock the context module
|
||||
// Mock the context modules
|
||||
vi.mock("../../contexts/encounter-context.js", () => ({
|
||||
useEncounterContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/player-characters-context.js", () => ({
|
||||
usePlayerCharactersContext: vi.fn().mockReturnValue({ characters: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||
useBestiaryContext: vi.fn().mockReturnValue({ getCreature: () => undefined }),
|
||||
}));
|
||||
|
||||
import { useEncounterContext } from "../../contexts/encounter-context.js";
|
||||
import { TurnNavigation } from "../turn-navigation.js";
|
||||
|
||||
@@ -52,8 +60,17 @@ function mockContext(overrides: Partial<Encounter> = {}) {
|
||||
toggleCondition: vi.fn(),
|
||||
toggleConcentration: vi.fn(),
|
||||
addFromBestiary: vi.fn(),
|
||||
addMultipleFromBestiary: vi.fn(),
|
||||
addFromPlayerCharacter: vi.fn(),
|
||||
makeStore: vi.fn(),
|
||||
withUndo: vi.fn((action: () => unknown) => action()),
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
undoRedoState: { undoStack: [], redoStack: [] },
|
||||
setEncounter: vi.fn(),
|
||||
setUndoRedoState: vi.fn(),
|
||||
events: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import {
|
||||
Check,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Import,
|
||||
@@ -8,35 +9,41 @@ import {
|
||||
Minus,
|
||||
Plus,
|
||||
Settings,
|
||||
Upload,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import React, {
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useState,
|
||||
} from "react";
|
||||
import React, { type RefObject, useCallback, useRef, useState } from "react";
|
||||
import type { SearchResult } from "../contexts/bestiary-context.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
import {
|
||||
creatureKey,
|
||||
type QueuedCreature,
|
||||
type SuggestionActions,
|
||||
useActionBarState,
|
||||
} from "../hooks/use-action-bar-state.js";
|
||||
import { useLongPress } from "../hooks/use-long-press.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import {
|
||||
assembleExportBundle,
|
||||
bundleToJson,
|
||||
readImportFile,
|
||||
triggerDownload,
|
||||
validateImportBundle,
|
||||
} from "../persistence/export-import.js";
|
||||
import { D20Icon } from "./d20-icon.js";
|
||||
import { ExportMethodDialog } from "./export-method-dialog.js";
|
||||
import { ImportConfirmDialog } from "./import-confirm-prompt.js";
|
||||
import { ImportMethodDialog } from "./import-method-dialog.js";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
|
||||
import { RollModeMenu } from "./roll-mode-menu.js";
|
||||
import { Toast } from "./toast.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
|
||||
|
||||
interface QueuedCreature {
|
||||
result: SearchResult;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface ActionBarProps {
|
||||
inputRef?: RefObject<HTMLInputElement | null>;
|
||||
autoFocus?: boolean;
|
||||
@@ -44,8 +51,13 @@ interface ActionBarProps {
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
function creatureKey(r: SearchResult): string {
|
||||
return `${r.source}:${r.name}`;
|
||||
interface AddModeSuggestionsProps {
|
||||
nameInput: string;
|
||||
suggestions: SearchResult[];
|
||||
pcMatches: PlayerCharacter[];
|
||||
suggestionIndex: number;
|
||||
queued: QueuedCreature | null;
|
||||
actions: SuggestionActions;
|
||||
}
|
||||
|
||||
function AddModeSuggestions({
|
||||
@@ -54,34 +66,15 @@ function AddModeSuggestions({
|
||||
pcMatches,
|
||||
suggestionIndex,
|
||||
queued,
|
||||
onDismiss,
|
||||
onClickSuggestion,
|
||||
onSetSuggestionIndex,
|
||||
onSetQueued,
|
||||
onConfirmQueued,
|
||||
onAddFromPlayerCharacter,
|
||||
onClear,
|
||||
}: Readonly<{
|
||||
nameInput: string;
|
||||
suggestions: SearchResult[];
|
||||
pcMatches: PlayerCharacter[];
|
||||
suggestionIndex: number;
|
||||
queued: QueuedCreature | null;
|
||||
onDismiss: () => void;
|
||||
onClear: () => void;
|
||||
onClickSuggestion: (result: SearchResult) => void;
|
||||
onSetSuggestionIndex: (i: number) => void;
|
||||
onSetQueued: (q: QueuedCreature | null) => void;
|
||||
onConfirmQueued: () => void;
|
||||
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
||||
}>) {
|
||||
actions,
|
||||
}: Readonly<AddModeSuggestionsProps>) {
|
||||
return (
|
||||
<div className="card-glow absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-lg border border-border bg-card">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1.5 border-border border-b px-3 py-2 text-left text-accent text-sm hover:bg-accent/20"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={onDismiss}
|
||||
onClick={actions.dismiss}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<span className="flex-1">Add "{nameInput}" as custom</span>
|
||||
@@ -108,8 +101,8 @@ function AddModeSuggestions({
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
onAddFromPlayerCharacter?.(pc);
|
||||
onClear();
|
||||
actions.addFromPlayerCharacter?.(pc);
|
||||
actions.clear();
|
||||
}}
|
||||
>
|
||||
{!!PcIcon && (
|
||||
@@ -145,8 +138,8 @@ function AddModeSuggestions({
|
||||
"hover:bg-hover-neutral-bg",
|
||||
)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onClickSuggestion(result)}
|
||||
onMouseEnter={() => onSetSuggestionIndex(i)}
|
||||
onClick={() => actions.clickSuggestion(result)}
|
||||
onMouseEnter={() => actions.setSuggestionIndex(i)}
|
||||
>
|
||||
<span>{result.name}</span>
|
||||
<span className="flex items-center gap-1 text-muted-foreground text-xs">
|
||||
@@ -159,9 +152,9 @@ function AddModeSuggestions({
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (queued.count <= 1) {
|
||||
onSetQueued(null);
|
||||
actions.setQueued(null);
|
||||
} else {
|
||||
onSetQueued({
|
||||
actions.setQueued({
|
||||
...queued,
|
||||
count: queued.count - 1,
|
||||
});
|
||||
@@ -179,7 +172,7 @@ function AddModeSuggestions({
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSetQueued({
|
||||
actions.setQueued({
|
||||
...queued,
|
||||
count: queued.count + 1,
|
||||
});
|
||||
@@ -193,7 +186,7 @@ function AddModeSuggestions({
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConfirmQueued();
|
||||
actions.confirmQueued();
|
||||
}}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
@@ -214,12 +207,160 @@ function AddModeSuggestions({
|
||||
);
|
||||
}
|
||||
|
||||
interface BrowseSuggestionsProps {
|
||||
suggestions: SearchResult[];
|
||||
suggestionIndex: number;
|
||||
onSelect: (result: SearchResult) => void;
|
||||
onHover: (index: number) => void;
|
||||
}
|
||||
|
||||
function BrowseSuggestions({
|
||||
suggestions,
|
||||
suggestionIndex,
|
||||
onSelect,
|
||||
onHover,
|
||||
}: Readonly<BrowseSuggestionsProps>) {
|
||||
if (suggestions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="card-glow absolute bottom-full z-50 mb-1 w-full rounded-lg border border-border bg-card">
|
||||
<ul className="max-h-48 overflow-y-auto py-1">
|
||||
{suggestions.map((result, i) => (
|
||||
<li key={creatureKey(result)}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between px-3 py-1.5 text-left text-sm",
|
||||
i === suggestionIndex
|
||||
? "bg-accent/20 text-foreground"
|
||||
: "text-foreground hover:bg-hover-neutral-bg",
|
||||
)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onSelect(result)}
|
||||
onMouseEnter={() => onHover(i)}
|
||||
>
|
||||
<span>{result.name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{result.sourceDisplayName}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CustomStatFieldsProps {
|
||||
customInit: string;
|
||||
customAc: string;
|
||||
customMaxHp: string;
|
||||
onInitChange: (v: string) => void;
|
||||
onAcChange: (v: string) => void;
|
||||
onMaxHpChange: (v: string) => void;
|
||||
}
|
||||
|
||||
function CustomStatFields({
|
||||
customInit,
|
||||
customAc,
|
||||
customMaxHp,
|
||||
onInitChange,
|
||||
onAcChange,
|
||||
onMaxHpChange,
|
||||
}: Readonly<CustomStatFieldsProps>) {
|
||||
return (
|
||||
<div className="hidden items-center gap-2 sm:flex">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={customInit}
|
||||
onChange={(e) => onInitChange(e.target.value)}
|
||||
placeholder="Init"
|
||||
className="w-16 text-center"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={customAc}
|
||||
onChange={(e) => onAcChange(e.target.value)}
|
||||
placeholder="AC"
|
||||
className="w-16 text-center"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={customMaxHp}
|
||||
onChange={(e) => onMaxHpChange(e.target.value)}
|
||||
placeholder="MaxHP"
|
||||
className="w-18 text-center"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RollAllButton() {
|
||||
const { hasCreatureCombatants, canRollAllInitiative } = useEncounterContext();
|
||||
const { handleRollAllInitiative } = useInitiativeRollsContext();
|
||||
|
||||
const [menuPos, setMenuPos] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
const openMenu = useCallback((x: number, y: number) => {
|
||||
setMenuPos({ x, y });
|
||||
}, []);
|
||||
|
||||
const longPress = useLongPress(
|
||||
useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
if (touch) openMenu(touch.clientX, touch.clientY);
|
||||
},
|
||||
[openMenu],
|
||||
),
|
||||
);
|
||||
|
||||
if (!hasCreatureCombatants) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-hover-action"
|
||||
onClick={() => handleRollAllInitiative()}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openMenu(e.clientX, e.clientY);
|
||||
}}
|
||||
{...longPress}
|
||||
disabled={!canRollAllInitiative}
|
||||
title="Roll all initiative"
|
||||
aria-label="Roll all initiative"
|
||||
>
|
||||
<D20Icon className="h-6 w-6" />
|
||||
</Button>
|
||||
{!!menuPos && (
|
||||
<RollModeMenu
|
||||
position={menuPos}
|
||||
onSelect={(mode) => handleRollAllInitiative(mode)}
|
||||
onClose={() => setMenuPos(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function buildOverflowItems(opts: {
|
||||
onManagePlayers?: () => void;
|
||||
onOpenSourceManager?: () => void;
|
||||
bestiaryLoaded: boolean;
|
||||
onBulkImport?: () => void;
|
||||
bulkImportDisabled?: boolean;
|
||||
onExportEncounter: () => void;
|
||||
onImportEncounter: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
}): OverflowMenuItem[] {
|
||||
const items: OverflowMenuItem[] = [];
|
||||
@@ -245,6 +386,16 @@ function buildOverflowItems(opts: {
|
||||
disabled: opts.bulkImportDisabled,
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
icon: <Download className="h-4 w-4" />,
|
||||
label: "Export Encounter",
|
||||
onClick: opts.onExportEncounter,
|
||||
});
|
||||
items.push({
|
||||
icon: <Upload className="h-4 w-4" />,
|
||||
label: "Import Encounter",
|
||||
onClick: opts.onImportEncounter,
|
||||
});
|
||||
if (opts.onOpenSettings) {
|
||||
items.push({
|
||||
icon: <Settings className="h-4 w-4" />,
|
||||
@@ -262,259 +413,151 @@ export function ActionBar({
|
||||
onOpenSettings,
|
||||
}: Readonly<ActionBarProps>) {
|
||||
const {
|
||||
addCombatant,
|
||||
addFromBestiary,
|
||||
addFromPlayerCharacter,
|
||||
hasCreatureCombatants,
|
||||
canRollAllInitiative,
|
||||
} = useEncounterContext();
|
||||
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
|
||||
useBestiaryContext();
|
||||
const { characters: playerCharacters } = usePlayerCharactersContext();
|
||||
const { showBulkImport, showSourceManager, showCreature, panelView } =
|
||||
useSidePanelContext();
|
||||
const { handleRollAllInitiative } = useInitiativeRollsContext();
|
||||
nameInput,
|
||||
suggestions,
|
||||
pcMatches,
|
||||
suggestionIndex,
|
||||
queued,
|
||||
customInit,
|
||||
customAc,
|
||||
customMaxHp,
|
||||
browseMode,
|
||||
bestiaryLoaded,
|
||||
hasSuggestions,
|
||||
showBulkImport,
|
||||
showSourceManager,
|
||||
suggestionActions,
|
||||
handleNameChange,
|
||||
handleKeyDown,
|
||||
handleBrowseKeyDown,
|
||||
handleAdd,
|
||||
handleBrowseSelect,
|
||||
toggleBrowseMode,
|
||||
setCustomInit,
|
||||
setCustomAc,
|
||||
setCustomMaxHp,
|
||||
} = useActionBarState();
|
||||
|
||||
const { state: bulkImportState } = useBulkImportContext();
|
||||
const {
|
||||
encounter,
|
||||
undoRedoState,
|
||||
isEmpty: encounterIsEmpty,
|
||||
setEncounter,
|
||||
setUndoRedoState,
|
||||
} = useEncounterContext();
|
||||
const { characters: playerCharacters, replacePlayerCharacters } =
|
||||
usePlayerCharactersContext();
|
||||
|
||||
const handleAddFromBestiary = useCallback(
|
||||
(result: SearchResult) => {
|
||||
const creatureId = addFromBestiary(result);
|
||||
const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches;
|
||||
if (creatureId && panelView.mode === "closed" && isDesktop) {
|
||||
showCreature(creatureId);
|
||||
}
|
||||
const importFileRef = useRef<HTMLInputElement>(null);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [showExportMethod, setShowExportMethod] = useState(false);
|
||||
const [showImportMethod, setShowImportMethod] = useState(false);
|
||||
const [showImportConfirm, setShowImportConfirm] = useState(false);
|
||||
const pendingBundleRef = useRef<
|
||||
import("@initiative/domain").ExportBundle | null
|
||||
>(null);
|
||||
|
||||
const handleExportDownload = useCallback(
|
||||
(includeHistory: boolean, filename: string) => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
includeHistory,
|
||||
);
|
||||
triggerDownload(bundle, filename);
|
||||
},
|
||||
[addFromBestiary, panelView.mode, showCreature],
|
||||
[encounter, undoRedoState, playerCharacters],
|
||||
);
|
||||
|
||||
const handleViewStatBlock = useCallback(
|
||||
(result: SearchResult) => {
|
||||
const slug = result.name
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||
.replaceAll(/(^-|-$)/g, "");
|
||||
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
||||
showCreature(cId);
|
||||
const handleExportClipboard = useCallback(
|
||||
(includeHistory: boolean) => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
includeHistory,
|
||||
);
|
||||
void navigator.clipboard.writeText(bundleToJson(bundle));
|
||||
},
|
||||
[showCreature],
|
||||
[encounter, undoRedoState, playerCharacters],
|
||||
);
|
||||
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
||||
const deferredSuggestions = useDeferredValue(suggestions);
|
||||
const deferredPcMatches = useDeferredValue(pcMatches);
|
||||
const [suggestionIndex, setSuggestionIndex] = useState(-1);
|
||||
const [queued, setQueued] = useState<QueuedCreature | null>(null);
|
||||
const [customInit, setCustomInit] = useState("");
|
||||
const [customAc, setCustomAc] = useState("");
|
||||
const [customMaxHp, setCustomMaxHp] = useState("");
|
||||
const [browseMode, setBrowseMode] = useState(false);
|
||||
const applyImport = useCallback(
|
||||
(bundle: import("@initiative/domain").ExportBundle) => {
|
||||
setEncounter(bundle.encounter);
|
||||
setUndoRedoState({
|
||||
undoStack: bundle.undoStack,
|
||||
redoStack: bundle.redoStack,
|
||||
});
|
||||
replacePlayerCharacters([...bundle.playerCharacters]);
|
||||
},
|
||||
[setEncounter, setUndoRedoState, replacePlayerCharacters],
|
||||
);
|
||||
|
||||
const clearCustomFields = () => {
|
||||
setCustomInit("");
|
||||
setCustomAc("");
|
||||
setCustomMaxHp("");
|
||||
};
|
||||
|
||||
const clearInput = () => {
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
setQueued(null);
|
||||
setSuggestionIndex(-1);
|
||||
};
|
||||
|
||||
const dismissSuggestions = () => {
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
setQueued(null);
|
||||
setSuggestionIndex(-1);
|
||||
};
|
||||
|
||||
const confirmQueued = () => {
|
||||
if (!queued) return;
|
||||
for (let i = 0; i < queued.count; i++) {
|
||||
handleAddFromBestiary(queued.result);
|
||||
}
|
||||
clearInput();
|
||||
};
|
||||
|
||||
const parseNum = (v: string): number | undefined => {
|
||||
if (v.trim() === "") return undefined;
|
||||
const n = Number(v);
|
||||
return Number.isNaN(n) ? undefined : n;
|
||||
};
|
||||
|
||||
const handleAdd = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (browseMode) return;
|
||||
if (queued) {
|
||||
confirmQueued();
|
||||
return;
|
||||
}
|
||||
if (nameInput.trim() === "") return;
|
||||
const opts: { initiative?: number; ac?: number; maxHp?: number } = {};
|
||||
const init = parseNum(customInit);
|
||||
const ac = parseNum(customAc);
|
||||
const maxHp = parseNum(customMaxHp);
|
||||
if (init !== undefined) opts.initiative = init;
|
||||
if (ac !== undefined) opts.ac = ac;
|
||||
if (maxHp !== undefined) opts.maxHp = maxHp;
|
||||
addCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
clearCustomFields();
|
||||
};
|
||||
|
||||
const handleBrowseSearch = (value: string) => {
|
||||
setSuggestions(value.length >= 2 ? bestiarySearch(value) : []);
|
||||
};
|
||||
|
||||
const handleAddSearch = (value: string) => {
|
||||
let newSuggestions: SearchResult[] = [];
|
||||
let newPcMatches: PlayerCharacter[] = [];
|
||||
if (value.length >= 2) {
|
||||
newSuggestions = bestiarySearch(value);
|
||||
setSuggestions(newSuggestions);
|
||||
if (playerCharacters && playerCharacters.length > 0) {
|
||||
const lower = value.toLowerCase();
|
||||
newPcMatches = playerCharacters.filter((pc) =>
|
||||
pc.name.toLowerCase().includes(lower),
|
||||
);
|
||||
const handleValidatedBundle = useCallback(
|
||||
(result: import("@initiative/domain").ExportBundle | string) => {
|
||||
if (typeof result === "string") {
|
||||
setImportError(result);
|
||||
return;
|
||||
}
|
||||
setPcMatches(newPcMatches);
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
}
|
||||
if (newSuggestions.length > 0 || newPcMatches.length > 0) {
|
||||
clearCustomFields();
|
||||
}
|
||||
if (queued) {
|
||||
const qKey = creatureKey(queued.result);
|
||||
const stillVisible = newSuggestions.some((s) => creatureKey(s) === qKey);
|
||||
if (!stillVisible) {
|
||||
setQueued(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setNameInput(value);
|
||||
setSuggestionIndex(-1);
|
||||
if (browseMode) {
|
||||
handleBrowseSearch(value);
|
||||
} else {
|
||||
handleAddSearch(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickSuggestion = (result: SearchResult) => {
|
||||
const key = creatureKey(result);
|
||||
if (queued && creatureKey(queued.result) === key) {
|
||||
setQueued({ ...queued, count: queued.count + 1 });
|
||||
} else {
|
||||
setQueued({ result, count: 1 });
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnter = () => {
|
||||
if (queued) {
|
||||
confirmQueued();
|
||||
} else if (suggestionIndex >= 0) {
|
||||
handleClickSuggestion(suggestions[suggestionIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
const hasSuggestions =
|
||||
deferredSuggestions.length > 0 || deferredPcMatches.length > 0;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!hasSuggestions) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleEnter();
|
||||
} else if (e.key === "Escape") {
|
||||
dismissSuggestions();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowseKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
return;
|
||||
}
|
||||
if (suggestions.length === 0) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
||||
} else if (e.key === "Enter" && suggestionIndex >= 0) {
|
||||
e.preventDefault();
|
||||
handleViewStatBlock(suggestions[suggestionIndex]);
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowseSelect = (result: SearchResult) => {
|
||||
handleViewStatBlock(result);
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
};
|
||||
|
||||
const toggleBrowseMode = () => {
|
||||
setBrowseMode((prev) => {
|
||||
const next = !prev;
|
||||
setSuggestionIndex(-1);
|
||||
setQueued(null);
|
||||
if (next) {
|
||||
handleBrowseSearch(nameInput);
|
||||
if (encounterIsEmpty) {
|
||||
applyImport(result);
|
||||
} else {
|
||||
handleAddSearch(nameInput);
|
||||
pendingBundleRef.current = result;
|
||||
setShowImportConfirm(true);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
clearCustomFields();
|
||||
};
|
||||
|
||||
const [rollAllMenuPos, setRollAllMenuPos] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
const openRollAllMenu = useCallback((x: number, y: number) => {
|
||||
setRollAllMenuPos({ x, y });
|
||||
}, []);
|
||||
|
||||
const rollAllLongPress = useLongPress(
|
||||
useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
if (touch) openRollAllMenu(touch.clientX, touch.clientY);
|
||||
},
|
||||
[openRollAllMenu],
|
||||
),
|
||||
},
|
||||
[encounterIsEmpty, applyImport],
|
||||
);
|
||||
|
||||
const handleImportFile = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (importFileRef.current) importFileRef.current.value = "";
|
||||
|
||||
setImportError(null);
|
||||
handleValidatedBundle(await readImportFile(file));
|
||||
},
|
||||
[handleValidatedBundle],
|
||||
);
|
||||
|
||||
const handleImportClipboard = useCallback(
|
||||
(text: string) => {
|
||||
setImportError(null);
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
handleValidatedBundle(validateImportBundle(parsed));
|
||||
} catch {
|
||||
setImportError("Invalid file format");
|
||||
}
|
||||
},
|
||||
[handleValidatedBundle],
|
||||
);
|
||||
|
||||
const handleImportConfirm = useCallback(() => {
|
||||
if (pendingBundleRef.current) {
|
||||
applyImport(pendingBundleRef.current);
|
||||
pendingBundleRef.current = null;
|
||||
}
|
||||
setShowImportConfirm(false);
|
||||
}, [applyImport]);
|
||||
|
||||
const handleImportCancel = useCallback(() => {
|
||||
pendingBundleRef.current = null;
|
||||
setShowImportConfirm(false);
|
||||
}, []);
|
||||
|
||||
const overflowItems = buildOverflowItems({
|
||||
onManagePlayers,
|
||||
onOpenSourceManager: showSourceManager,
|
||||
bestiaryLoaded,
|
||||
onBulkImport: showBulkImport,
|
||||
bulkImportDisabled: bulkImportState.status === "loading",
|
||||
onExportEncounter: () => setShowExportMethod(true),
|
||||
onImportEncounter: () => setShowImportMethod(true),
|
||||
onOpenSettings,
|
||||
});
|
||||
|
||||
@@ -560,112 +603,73 @@ export function ActionBar({
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{browseMode && deferredSuggestions.length > 0 && (
|
||||
<div className="card-glow absolute bottom-full z-50 mb-1 w-full rounded-lg border border-border bg-card">
|
||||
<ul className="max-h-48 overflow-y-auto py-1">
|
||||
{deferredSuggestions.map((result, i) => (
|
||||
<li key={creatureKey(result)}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between px-3 py-1.5 text-left text-sm",
|
||||
i === suggestionIndex
|
||||
? "bg-accent/20 text-foreground"
|
||||
: "text-foreground hover:bg-hover-neutral-bg",
|
||||
)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => handleBrowseSelect(result)}
|
||||
onMouseEnter={() => setSuggestionIndex(i)}
|
||||
>
|
||||
<span>{result.name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{result.sourceDisplayName}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{!!browseMode && (
|
||||
<BrowseSuggestions
|
||||
suggestions={suggestions}
|
||||
suggestionIndex={suggestionIndex}
|
||||
onSelect={handleBrowseSelect}
|
||||
onHover={suggestionActions.setSuggestionIndex}
|
||||
/>
|
||||
)}
|
||||
{!browseMode && hasSuggestions && (
|
||||
<AddModeSuggestions
|
||||
nameInput={nameInput}
|
||||
suggestions={deferredSuggestions}
|
||||
pcMatches={deferredPcMatches}
|
||||
suggestions={suggestions}
|
||||
pcMatches={pcMatches}
|
||||
suggestionIndex={suggestionIndex}
|
||||
queued={queued}
|
||||
onDismiss={dismissSuggestions}
|
||||
onClear={clearInput}
|
||||
onClickSuggestion={handleClickSuggestion}
|
||||
onSetSuggestionIndex={setSuggestionIndex}
|
||||
onSetQueued={setQueued}
|
||||
onConfirmQueued={confirmQueued}
|
||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||
actions={suggestionActions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||
<div className="hidden items-center gap-2 sm:flex">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={customInit}
|
||||
onChange={(e) => setCustomInit(e.target.value)}
|
||||
placeholder="Init"
|
||||
className="w-16 text-center"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={customAc}
|
||||
onChange={(e) => setCustomAc(e.target.value)}
|
||||
placeholder="AC"
|
||||
className="w-16 text-center"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={customMaxHp}
|
||||
onChange={(e) => setCustomMaxHp(e.target.value)}
|
||||
placeholder="MaxHP"
|
||||
className="w-18 text-center"
|
||||
/>
|
||||
</div>
|
||||
<CustomStatFields
|
||||
customInit={customInit}
|
||||
customAc={customAc}
|
||||
customMaxHp={customMaxHp}
|
||||
onInitChange={setCustomInit}
|
||||
onAcChange={setCustomAc}
|
||||
onMaxHpChange={setCustomMaxHp}
|
||||
/>
|
||||
)}
|
||||
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||
<Button type="submit">Add</Button>
|
||||
)}
|
||||
{!!hasCreatureCombatants && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-hover-action"
|
||||
onClick={() => handleRollAllInitiative()}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openRollAllMenu(e.clientX, e.clientY);
|
||||
}}
|
||||
{...rollAllLongPress}
|
||||
disabled={!canRollAllInitiative}
|
||||
title="Roll all initiative"
|
||||
aria-label="Roll all initiative"
|
||||
>
|
||||
<D20Icon className="h-6 w-6" />
|
||||
</Button>
|
||||
{!!rollAllMenuPos && (
|
||||
<RollModeMenu
|
||||
position={rollAllMenuPos}
|
||||
onSelect={(mode) => handleRollAllInitiative(mode)}
|
||||
onClose={() => setRollAllMenuPos(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<RollAllButton />
|
||||
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
|
||||
</form>
|
||||
<input
|
||||
ref={importFileRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
onChange={handleImportFile}
|
||||
/>
|
||||
{!!importError && (
|
||||
<Toast
|
||||
message={importError}
|
||||
onDismiss={() => setImportError(null)}
|
||||
autoDismissMs={5000}
|
||||
/>
|
||||
)}
|
||||
<ExportMethodDialog
|
||||
open={showExportMethod}
|
||||
onDownload={handleExportDownload}
|
||||
onCopyToClipboard={handleExportClipboard}
|
||||
onClose={() => setShowExportMethod(false)}
|
||||
/>
|
||||
<ImportMethodDialog
|
||||
open={showImportMethod}
|
||||
onSelectFile={() => importFileRef.current?.click()}
|
||||
onSubmitClipboard={handleImportClipboard}
|
||||
onClose={() => setShowImportMethod(false)}
|
||||
/>
|
||||
<ImportConfirmDialog
|
||||
open={showImportConfirm}
|
||||
onConfirm={handleImportConfirm}
|
||||
onCancel={handleImportCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
CONDITION_DEFINITIONS,
|
||||
type ConditionId,
|
||||
getConditionDescription,
|
||||
getConditionsForEdition,
|
||||
} from "@initiative/domain";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
@@ -17,7 +17,9 @@ import {
|
||||
Heart,
|
||||
Link,
|
||||
Moon,
|
||||
ShieldMinus,
|
||||
Siren,
|
||||
Snail,
|
||||
Sparkles,
|
||||
ZapOff,
|
||||
} from "lucide-react";
|
||||
@@ -41,6 +43,8 @@ const ICON_MAP: Record<string, LucideIcon> = {
|
||||
Droplet,
|
||||
ArrowDown,
|
||||
Link,
|
||||
ShieldMinus,
|
||||
Snail,
|
||||
Sparkles,
|
||||
Moon,
|
||||
};
|
||||
@@ -56,6 +60,7 @@ const COLOR_CLASSES: Record<string, string> = {
|
||||
slate: "text-slate-400",
|
||||
green: "text-green-400",
|
||||
indigo: "text-indigo-400",
|
||||
sky: "text-sky-400",
|
||||
};
|
||||
|
||||
interface ConditionPickerProps {
|
||||
@@ -110,6 +115,7 @@ export function ConditionPicker({
|
||||
}, [onClose]);
|
||||
|
||||
const { edition } = useRulesEditionContext();
|
||||
const conditions = getConditionsForEdition(edition);
|
||||
const active = new Set(activeConditions ?? []);
|
||||
|
||||
return createPortal(
|
||||
@@ -122,7 +128,7 @@ export function ConditionPicker({
|
||||
: { visibility: "hidden" as const }
|
||||
}
|
||||
>
|
||||
{CONDITION_DEFINITIONS.map((def) => {
|
||||
{conditions.map((def) => {
|
||||
const Icon = ICON_MAP[def.iconName];
|
||||
if (!Icon) return null;
|
||||
const isActive = active.has(def.id);
|
||||
|
||||
@@ -18,7 +18,9 @@ import {
|
||||
Link,
|
||||
Moon,
|
||||
Plus,
|
||||
ShieldMinus,
|
||||
Siren,
|
||||
Snail,
|
||||
Sparkles,
|
||||
ZapOff,
|
||||
} from "lucide-react";
|
||||
@@ -40,6 +42,8 @@ const ICON_MAP: Record<string, LucideIcon> = {
|
||||
Droplet,
|
||||
ArrowDown,
|
||||
Link,
|
||||
ShieldMinus,
|
||||
Snail,
|
||||
Sparkles,
|
||||
Moon,
|
||||
};
|
||||
@@ -55,6 +59,7 @@ const COLOR_CLASSES: Record<string, string> = {
|
||||
slate: "text-slate-400",
|
||||
green: "text-green-400",
|
||||
indigo: "text-indigo-400",
|
||||
sky: "text-sky-400",
|
||||
};
|
||||
|
||||
interface ConditionTagsProps {
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ColorPalette } from "./color-palette";
|
||||
import { IconGrid } from "./icon-grid";
|
||||
import { Button } from "./ui/button";
|
||||
import { Dialog } from "./ui/dialog";
|
||||
import { Input } from "./ui/input";
|
||||
|
||||
function parseLevel(value: string): number | undefined | "invalid" {
|
||||
if (value.trim() === "") return undefined;
|
||||
const n = Number.parseInt(value, 10);
|
||||
if (Number.isNaN(n) || n < 1 || n > 20) return "invalid";
|
||||
return n;
|
||||
}
|
||||
|
||||
interface CreatePlayerModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -15,6 +23,7 @@ interface CreatePlayerModalProps {
|
||||
maxHp: number,
|
||||
color: string | undefined,
|
||||
icon: string | undefined,
|
||||
level: number | undefined,
|
||||
) => void;
|
||||
playerCharacter?: PlayerCharacter;
|
||||
}
|
||||
@@ -25,12 +34,12 @@ export function CreatePlayerModal({
|
||||
onSave,
|
||||
playerCharacter,
|
||||
}: Readonly<CreatePlayerModalProps>) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [ac, setAc] = useState("10");
|
||||
const [maxHp, setMaxHp] = useState("10");
|
||||
const [color, setColor] = useState("blue");
|
||||
const [icon, setIcon] = useState("sword");
|
||||
const [level, setLevel] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const isEdit = !!playerCharacter;
|
||||
@@ -43,45 +52,23 @@ export function CreatePlayerModal({
|
||||
setMaxHp(String(playerCharacter.maxHp));
|
||||
setColor(playerCharacter.color ?? "");
|
||||
setIcon(playerCharacter.icon ?? "");
|
||||
setLevel(
|
||||
playerCharacter.level === undefined
|
||||
? ""
|
||||
: String(playerCharacter.level),
|
||||
);
|
||||
} else {
|
||||
setName("");
|
||||
setAc("10");
|
||||
setMaxHp("10");
|
||||
setColor("");
|
||||
setIcon("");
|
||||
setLevel("");
|
||||
}
|
||||
setError("");
|
||||
}
|
||||
}, [open, playerCharacter]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) {
|
||||
dialog.showModal();
|
||||
} else if (!open && dialog.open) {
|
||||
dialog.close();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
function handleCancel(e: Event) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === dialog) onClose();
|
||||
}
|
||||
dialog.addEventListener("cancel", handleCancel);
|
||||
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", handleCancel);
|
||||
dialog.removeEventListener("mousedown", handleBackdropClick);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
@@ -99,15 +86,24 @@ export function CreatePlayerModal({
|
||||
setError("Max HP must be at least 1");
|
||||
return;
|
||||
}
|
||||
onSave(trimmed, acNum, hpNum, color || undefined, icon || undefined);
|
||||
const levelNum = parseLevel(level);
|
||||
if (levelNum === "invalid") {
|
||||
setError("Level must be between 1 and 20");
|
||||
return;
|
||||
}
|
||||
onSave(
|
||||
trimmed,
|
||||
acNum,
|
||||
hpNum,
|
||||
color || undefined,
|
||||
icon || undefined,
|
||||
levelNum,
|
||||
);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className="card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
|
||||
>
|
||||
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-foreground text-lg">
|
||||
{isEdit ? "Edit Player" : "Create Player"}
|
||||
@@ -166,6 +162,20 @@ export function CreatePlayerModal({
|
||||
className="text-center"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className="mb-1 block text-muted-foreground text-sm">
|
||||
Level
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={level}
|
||||
onChange={(e) => setLevel(e.target.value)}
|
||||
placeholder="1-20"
|
||||
aria-label="Level"
|
||||
className="text-center"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -187,6 +197,6 @@ export function CreatePlayerModal({
|
||||
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
39
apps/web/src/components/difficulty-indicator.tsx
Normal file
39
apps/web/src/components/difficulty-indicator.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { DifficultyResult, DifficultyTier } from "@initiative/domain";
|
||||
import { cn } from "../lib/utils.js";
|
||||
|
||||
const TIER_CONFIG: Record<
|
||||
DifficultyTier,
|
||||
{ filledBars: number; color: string; label: string }
|
||||
> = {
|
||||
trivial: { filledBars: 0, color: "", label: "Trivial" },
|
||||
low: { filledBars: 1, color: "bg-green-500", label: "Low" },
|
||||
moderate: { filledBars: 2, color: "bg-yellow-500", label: "Moderate" },
|
||||
high: { filledBars: 3, color: "bg-red-500", label: "High" },
|
||||
};
|
||||
|
||||
const BAR_HEIGHTS = ["h-2", "h-3", "h-4"] as const;
|
||||
|
||||
export function DifficultyIndicator({ result }: { result: DifficultyResult }) {
|
||||
const config = TIER_CONFIG[result.tier];
|
||||
const tooltip = `${config.label} encounter difficulty`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-end gap-0.5"
|
||||
title={tooltip}
|
||||
role="img"
|
||||
aria-label={tooltip}
|
||||
>
|
||||
{BAR_HEIGHTS.map((height, i) => (
|
||||
<div
|
||||
key={height}
|
||||
className={cn(
|
||||
"w-1 rounded-sm",
|
||||
height,
|
||||
i < config.filledBars ? config.color : "bg-muted",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
apps/web/src/components/export-method-dialog.tsx
Normal file
105
apps/web/src/components/export-method-dialog.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Check, ClipboardCopy, Download, X } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Dialog } from "./ui/dialog.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
interface ExportMethodDialogProps {
|
||||
open: boolean;
|
||||
onDownload: (includeHistory: boolean, filename: string) => void;
|
||||
onCopyToClipboard: (includeHistory: boolean) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ExportMethodDialog({
|
||||
open,
|
||||
onDownload,
|
||||
onCopyToClipboard,
|
||||
onClose,
|
||||
}: Readonly<ExportMethodDialogProps>) {
|
||||
const [includeHistory, setIncludeHistory] = useState(false);
|
||||
const [filename, setFilename] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIncludeHistory(false);
|
||||
setFilename("");
|
||||
setCopied(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} className="w-80">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-lg">Export Encounter</h2>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={handleClose}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<Input
|
||||
type="text"
|
||||
value={filename}
|
||||
onChange={(e) => setFilename(e.target.value)}
|
||||
placeholder="Filename (optional)"
|
||||
/>
|
||||
</div>
|
||||
<label className="mb-4 flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeHistory}
|
||||
onChange={(e) => setIncludeHistory(e.target.checked)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
<span className="text-foreground">Include undo/redo history</span>
|
||||
</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||
onClick={() => {
|
||||
onDownload(includeHistory, filename);
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
<Download className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">Download file</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Save as a JSON file
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||
onClick={() => {
|
||||
onCopyToClipboard(includeHistory);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-5 w-5 text-green-400" />
|
||||
) : (
|
||||
<ClipboardCopy className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{copied ? "Copied!" : "Copy to clipboard"}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Copy JSON to your clipboard
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
32
apps/web/src/components/import-confirm-prompt.tsx
Normal file
32
apps/web/src/components/import-confirm-prompt.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Dialog } from "./ui/dialog.js";
|
||||
|
||||
interface ImportConfirmDialogProps {
|
||||
open: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ImportConfirmDialog({
|
||||
open,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: Readonly<ImportConfirmDialogProps>) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onCancel}>
|
||||
<h2 className="mb-2 font-semibold text-lg">Replace current encounter?</h2>
|
||||
<p className="mb-4 text-muted-foreground text-sm">
|
||||
Importing will replace your current encounter, undo/redo history, and
|
||||
player characters. This cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={onConfirm}>
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
125
apps/web/src/components/import-method-dialog.tsx
Normal file
125
apps/web/src/components/import-method-dialog.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { ClipboardPaste, FileUp, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Dialog } from "./ui/dialog.js";
|
||||
|
||||
interface ImportMethodDialogProps {
|
||||
open: boolean;
|
||||
onSelectFile: () => void;
|
||||
onSubmitClipboard: (text: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ImportMethodDialog({
|
||||
open,
|
||||
onSelectFile,
|
||||
onSubmitClipboard,
|
||||
onClose,
|
||||
}: Readonly<ImportMethodDialogProps>) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [mode, setMode] = useState<"pick" | "paste">("pick");
|
||||
const [pasteText, setPasteText] = useState("");
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setMode("pick");
|
||||
setPasteText("");
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setMode("pick");
|
||||
setPasteText("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "paste") {
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} className="w-80">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-lg">Import Encounter</h2>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={handleClose}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{mode === "pick" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||
onClick={() => {
|
||||
handleClose();
|
||||
onSelectFile();
|
||||
}}
|
||||
>
|
||||
<FileUp className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">From file</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Upload a JSON file
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||
onClick={() => setMode("paste")}
|
||||
>
|
||||
<ClipboardPaste className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">Paste content</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Paste JSON content directly
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{mode === "paste" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={pasteText}
|
||||
onChange={(e) => setPasteText(e.target.value)}
|
||||
placeholder="Paste exported JSON here..."
|
||||
className="h-32 w-full resize-none rounded-md border border-border bg-background px-3 py-2 font-mono text-foreground text-xs placeholder:text-muted-foreground focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setMode("pick");
|
||||
setPasteText("");
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={pasteText.trim().length === 0}
|
||||
onClick={() => {
|
||||
const text = pasteText;
|
||||
handleClose();
|
||||
onSubmitClipboard(text);
|
||||
}}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
||||
setEditingPlayer(undefined);
|
||||
setManagementOpen(true);
|
||||
}}
|
||||
onSave={(name, ac, maxHp, color, icon) => {
|
||||
onSave={(name, ac, maxHp, color, icon, level) => {
|
||||
if (editingPlayer) {
|
||||
editCharacter(editingPlayer.id, {
|
||||
name,
|
||||
@@ -43,9 +43,10 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
||||
maxHp,
|
||||
color: color ?? null,
|
||||
icon: icon ?? null,
|
||||
level: level ?? null,
|
||||
});
|
||||
} else {
|
||||
createCharacter(name, ac, maxHp, color, icon);
|
||||
createCharacter(name, ac, maxHp, color, icon, level);
|
||||
}
|
||||
}}
|
||||
playerCharacter={editingPlayer}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||
import { Button } from "./ui/button";
|
||||
import { ConfirmButton } from "./ui/confirm-button";
|
||||
import { Dialog } from "./ui/dialog";
|
||||
|
||||
interface PlayerManagementProps {
|
||||
open: boolean;
|
||||
@@ -22,41 +22,8 @@ export function PlayerManagement({
|
||||
onDelete,
|
||||
onCreate,
|
||||
}: Readonly<PlayerManagementProps>) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) {
|
||||
dialog.showModal();
|
||||
} else if (!open && dialog.open) {
|
||||
dialog.close();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
function handleCancel(e: Event) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === dialog) onClose();
|
||||
}
|
||||
dialog.addEventListener("cancel", handleCancel);
|
||||
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", handleCancel);
|
||||
dialog.removeEventListener("mousedown", handleBackdropClick);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className="card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
|
||||
>
|
||||
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-foreground text-lg">
|
||||
Player Characters
|
||||
@@ -101,6 +68,11 @@ export function PlayerManagement({
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
HP {pc.maxHp}
|
||||
</span>
|
||||
{pc.level !== undefined && (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
Lv {pc.level}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
@@ -128,6 +100,6 @@ export function PlayerManagement({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</dialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { RulesEdition } from "@initiative/domain";
|
||||
import { Monitor, Moon, Sun, X } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { useThemeContext } from "../contexts/theme-context.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Dialog } from "./ui/dialog.js";
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean;
|
||||
@@ -27,40 +27,11 @@ const THEME_OPTIONS: {
|
||||
];
|
||||
|
||||
export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const { edition, setEdition } = useRulesEditionContext();
|
||||
const { preference, setPreference } = useThemeContext();
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) dialog.showModal();
|
||||
else if (!open && dialog.open) dialog.close();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
function handleCancel(e: Event) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === dialog) onClose();
|
||||
}
|
||||
dialog.addEventListener("cancel", handleCancel);
|
||||
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", handleCancel);
|
||||
dialog.removeEventListener("mousedown", handleBackdropClick);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className="card-glow m-auto w-full max-w-sm rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
|
||||
>
|
||||
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-sm">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-foreground text-lg">Settings</h2>
|
||||
<Button
|
||||
@@ -124,6 +95,6 @@ export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { StepBack, StepForward, Trash2 } from "lucide-react";
|
||||
import { Redo2, StepBack, StepForward, Trash2, Undo2 } from "lucide-react";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useDifficulty } from "../hooks/use-difficulty.js";
|
||||
import { DifficultyIndicator } from "./difficulty-indicator.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||
|
||||
export function TurnNavigation() {
|
||||
const { encounter, advanceTurn, retreatTurn, clearEncounter } =
|
||||
useEncounterContext();
|
||||
const {
|
||||
encounter,
|
||||
advanceTurn,
|
||||
retreatTurn,
|
||||
clearEncounter,
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
} = useEncounterContext();
|
||||
|
||||
const difficulty = useDifficulty();
|
||||
const hasCombatants = encounter.combatants.length > 0;
|
||||
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
||||
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
||||
@@ -24,6 +35,29 @@ export function TurnNavigation() {
|
||||
<StepBack className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={undo}
|
||||
disabled={!canUndo}
|
||||
title="Undo"
|
||||
aria-label="Undo"
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={redo}
|
||||
disabled={!canRedo}
|
||||
title="Redo"
|
||||
aria-label="Redo"
|
||||
>
|
||||
<Redo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center gap-2 text-sm">
|
||||
<span className="shrink-0 rounded-md bg-muted px-2 py-0.5 font-semibold text-foreground text-sm">
|
||||
<span className="-mt-[3px] inline-block">
|
||||
@@ -35,6 +69,7 @@ export function TurnNavigation() {
|
||||
) : (
|
||||
<span className="text-muted-foreground">No combatants</span>
|
||||
)}
|
||||
{difficulty && <DifficultyIndicator result={difficulty} />}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-3">
|
||||
|
||||
50
apps/web/src/components/ui/dialog.tsx
Normal file
50
apps/web/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
|
||||
interface DialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Dialog({ open, onClose, className, children }: DialogProps) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) dialog.showModal();
|
||||
else if (!open && dialog.open) dialog.close();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
function handleCancel(e: Event) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === dialog) onClose();
|
||||
}
|
||||
dialog.addEventListener("cancel", handleCancel);
|
||||
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", handleCancel);
|
||||
dialog.removeEventListener("mousedown", handleBackdropClick);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className={cn(
|
||||
"m-auto rounded-lg border border-border bg-card text-foreground shadow-xl backdrop:bg-black/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="p-6">{children}</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { useEncounter } from "../hooks/use-encounter.js";
|
||||
import { useUndoRedoShortcuts } from "../hooks/use-undo-redo-shortcuts.js";
|
||||
|
||||
type EncounterContextValue = ReturnType<typeof useEncounter>;
|
||||
|
||||
@@ -7,6 +8,7 @@ const EncounterContext = createContext<EncounterContextValue | null>(null);
|
||||
|
||||
export function EncounterProvider({ children }: { children: ReactNode }) {
|
||||
const value = useEncounter();
|
||||
useUndoRedoShortcuts(value.undo, value.redo, value.canUndo, value.canRedo);
|
||||
return (
|
||||
<EncounterContext.Provider value={value}>
|
||||
{children}
|
||||
|
||||
220
apps/web/src/hooks/__tests__/use-difficulty.test.ts
Normal file
220
apps/web/src/hooks/__tests__/use-difficulty.test.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
// @vitest-environment jsdom
|
||||
import type {
|
||||
Combatant,
|
||||
CreatureId,
|
||||
Encounter,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../contexts/encounter-context.js", () => ({
|
||||
useEncounterContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/player-characters-context.js", () => ({
|
||||
usePlayerCharactersContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||
useBestiaryContext: vi.fn(),
|
||||
}));
|
||||
|
||||
import { useBestiaryContext } from "../../contexts/bestiary-context.js";
|
||||
import { useEncounterContext } from "../../contexts/encounter-context.js";
|
||||
import { usePlayerCharactersContext } from "../../contexts/player-characters-context.js";
|
||||
import { useDifficulty } from "../use-difficulty.js";
|
||||
|
||||
const mockEncounterContext = vi.mocked(useEncounterContext);
|
||||
const mockPlayerCharactersContext = vi.mocked(usePlayerCharactersContext);
|
||||
const mockBestiaryContext = vi.mocked(useBestiaryContext);
|
||||
|
||||
const pcId1 = playerCharacterId("pc-1");
|
||||
const pcId2 = playerCharacterId("pc-2");
|
||||
const crId1 = creatureId("creature-1");
|
||||
const _crId2 = creatureId("creature-2");
|
||||
|
||||
function setup(options: {
|
||||
combatants: Combatant[];
|
||||
characters: PlayerCharacter[];
|
||||
creatures: Map<CreatureId, { cr: string }>;
|
||||
}) {
|
||||
const encounter = {
|
||||
combatants: options.combatants,
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
} as Encounter;
|
||||
|
||||
mockEncounterContext.mockReturnValue({
|
||||
encounter,
|
||||
} as ReturnType<typeof useEncounterContext>);
|
||||
|
||||
mockPlayerCharactersContext.mockReturnValue({
|
||||
characters: options.characters,
|
||||
} as ReturnType<typeof usePlayerCharactersContext>);
|
||||
|
||||
mockBestiaryContext.mockReturnValue({
|
||||
getCreature: (id: CreatureId) => options.creatures.get(id),
|
||||
} as ReturnType<typeof useBestiaryContext>);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("useDifficulty", () => {
|
||||
it("returns difficulty result for leveled PCs and bestiary monsters", () => {
|
||||
setup({
|
||||
combatants: [
|
||||
{ id: combatantId("c1"), name: "Hero", playerCharacterId: pcId1 },
|
||||
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
|
||||
],
|
||||
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }],
|
||||
creatures: new Map([[crId1, { cr: "1/4" }]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
|
||||
expect(result.current).not.toBeNull();
|
||||
expect(result.current?.tier).toBe("low");
|
||||
expect(result.current?.totalMonsterXp).toBe(50);
|
||||
});
|
||||
|
||||
describe("returns null when data is insufficient (ED-2)", () => {
|
||||
it("returns null when encounter has no combatants", () => {
|
||||
setup({ combatants: [], characters: [], creatures: new Map() });
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when only custom combatants (no creatureId)", () => {
|
||||
setup({
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c1"),
|
||||
name: "Custom",
|
||||
playerCharacterId: pcId1,
|
||||
},
|
||||
],
|
||||
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }],
|
||||
creatures: new Map(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when bestiary monsters present but no PC combatants", () => {
|
||||
setup({
|
||||
combatants: [
|
||||
{ id: combatantId("c1"), name: "Goblin", creatureId: crId1 },
|
||||
],
|
||||
characters: [],
|
||||
creatures: new Map([[crId1, { cr: "1" }]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when PC combatants have no level", () => {
|
||||
setup({
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
},
|
||||
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
|
||||
],
|
||||
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30 }],
|
||||
creatures: new Map([[crId1, { cr: "1" }]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when PC combatant references unknown player character", () => {
|
||||
setup({
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId2,
|
||||
},
|
||||
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
|
||||
],
|
||||
characters: [{ id: pcId1, name: "Other", ac: 15, maxHp: 30, level: 5 }],
|
||||
creatures: new Map([[crId1, { cr: "1" }]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("handles mixed combatants: only leveled PCs and bestiary monsters contribute", () => {
|
||||
// Party: one leveled PC, one without level (excluded)
|
||||
// Monsters: one bestiary creature, one custom (excluded)
|
||||
setup({
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c1"),
|
||||
name: "Leveled",
|
||||
playerCharacterId: pcId1,
|
||||
},
|
||||
{
|
||||
id: combatantId("c2"),
|
||||
name: "No Level",
|
||||
playerCharacterId: pcId2,
|
||||
},
|
||||
{ id: combatantId("c3"), name: "Goblin", creatureId: crId1 },
|
||||
{ id: combatantId("c4"), name: "Custom Monster" },
|
||||
],
|
||||
characters: [
|
||||
{ id: pcId1, name: "Leveled", ac: 15, maxHp: 30, level: 1 },
|
||||
{ id: pcId2, name: "No Level", ac: 12, maxHp: 20 },
|
||||
],
|
||||
creatures: new Map([[crId1, { cr: "1" }]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
|
||||
expect(result.current).not.toBeNull();
|
||||
// 1 level-1 PC: budget low=50, mod=75, high=100
|
||||
// 1 CR 1 monster: 200 XP → high (200 >= 100)
|
||||
expect(result.current?.tier).toBe("high");
|
||||
expect(result.current?.totalMonsterXp).toBe(200);
|
||||
expect(result.current?.partyBudget.low).toBe(50);
|
||||
});
|
||||
|
||||
it("includes duplicate PC combatants in budget", () => {
|
||||
// Same PC added twice → counts twice
|
||||
setup({
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c1"),
|
||||
name: "Hero 1",
|
||||
playerCharacterId: pcId1,
|
||||
},
|
||||
{
|
||||
id: combatantId("c2"),
|
||||
name: "Hero 2",
|
||||
playerCharacterId: pcId1,
|
||||
},
|
||||
{ id: combatantId("c3"), name: "Goblin", creatureId: crId1 },
|
||||
],
|
||||
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }],
|
||||
creatures: new Map([[crId1, { cr: "1/4" }]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
|
||||
expect(result.current).not.toBeNull();
|
||||
// 2x level 1: budget low=100
|
||||
expect(result.current?.partyBudget.low).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,14 @@ describe("usePlayerCharacters", () => {
|
||||
const { result } = renderHook(() => usePlayerCharacters());
|
||||
|
||||
act(() => {
|
||||
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
|
||||
result.current.createCharacter(
|
||||
"Vex",
|
||||
15,
|
||||
28,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.characters).toHaveLength(1);
|
||||
@@ -57,7 +64,14 @@ describe("usePlayerCharacters", () => {
|
||||
|
||||
let error: unknown;
|
||||
act(() => {
|
||||
error = result.current.createCharacter("", 15, 28, undefined, undefined);
|
||||
error = result.current.createCharacter(
|
||||
"",
|
||||
15,
|
||||
28,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
expect(error).toMatchObject({ kind: "domain-error" });
|
||||
@@ -68,7 +82,14 @@ describe("usePlayerCharacters", () => {
|
||||
const { result } = renderHook(() => usePlayerCharacters());
|
||||
|
||||
act(() => {
|
||||
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
|
||||
result.current.createCharacter(
|
||||
"Vex",
|
||||
15,
|
||||
28,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
const id = result.current.characters[0].id;
|
||||
@@ -85,7 +106,14 @@ describe("usePlayerCharacters", () => {
|
||||
const { result } = renderHook(() => usePlayerCharacters());
|
||||
|
||||
act(() => {
|
||||
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
|
||||
result.current.createCharacter(
|
||||
"Vex",
|
||||
15,
|
||||
28,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
const id = result.current.characters[0].id;
|
||||
|
||||
316
apps/web/src/hooks/use-action-bar-state.ts
Normal file
316
apps/web/src/hooks/use-action-bar-state.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import type { CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||
import { useCallback, useDeferredValue, useMemo, useState } from "react";
|
||||
import type { SearchResult } from "../contexts/bestiary-context.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
|
||||
export interface QueuedCreature {
|
||||
result: SearchResult;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SuggestionActions {
|
||||
dismiss: () => void;
|
||||
clear: () => void;
|
||||
clickSuggestion: (result: SearchResult) => void;
|
||||
setSuggestionIndex: (i: number) => void;
|
||||
setQueued: (q: QueuedCreature | null) => void;
|
||||
confirmQueued: () => void;
|
||||
addFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
||||
}
|
||||
|
||||
export function creatureKey(r: SearchResult): string {
|
||||
return `${r.source}:${r.name}`;
|
||||
}
|
||||
|
||||
export function useActionBarState() {
|
||||
const {
|
||||
addCombatant,
|
||||
addFromBestiary,
|
||||
addMultipleFromBestiary,
|
||||
addFromPlayerCharacter,
|
||||
} = useEncounterContext();
|
||||
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
|
||||
useBestiaryContext();
|
||||
const { characters: playerCharacters } = usePlayerCharactersContext();
|
||||
const { showBulkImport, showSourceManager, showCreature, panelView } =
|
||||
useSidePanelContext();
|
||||
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
||||
const deferredSuggestions = useDeferredValue(suggestions);
|
||||
const deferredPcMatches = useDeferredValue(pcMatches);
|
||||
const [suggestionIndex, setSuggestionIndex] = useState(-1);
|
||||
const [queued, setQueued] = useState<QueuedCreature | null>(null);
|
||||
const [customInit, setCustomInit] = useState("");
|
||||
const [customAc, setCustomAc] = useState("");
|
||||
const [customMaxHp, setCustomMaxHp] = useState("");
|
||||
const [browseMode, setBrowseMode] = useState(false);
|
||||
|
||||
const clearCustomFields = () => {
|
||||
setCustomInit("");
|
||||
setCustomAc("");
|
||||
setCustomMaxHp("");
|
||||
};
|
||||
|
||||
const clearInput = useCallback(() => {
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
setQueued(null);
|
||||
setSuggestionIndex(-1);
|
||||
}, []);
|
||||
|
||||
const dismissSuggestions = useCallback(() => {
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
setQueued(null);
|
||||
setSuggestionIndex(-1);
|
||||
}, []);
|
||||
|
||||
const handleAddFromBestiary = useCallback(
|
||||
(result: SearchResult) => {
|
||||
const creatureId = addFromBestiary(result);
|
||||
const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches;
|
||||
if (creatureId && panelView.mode === "closed" && isDesktop) {
|
||||
showCreature(creatureId);
|
||||
}
|
||||
},
|
||||
[addFromBestiary, panelView.mode, showCreature],
|
||||
);
|
||||
|
||||
const handleViewStatBlock = useCallback(
|
||||
(result: SearchResult) => {
|
||||
const slug = result.name
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||
.replaceAll(/(^-|-$)/g, "");
|
||||
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
||||
showCreature(cId);
|
||||
},
|
||||
[showCreature],
|
||||
);
|
||||
|
||||
const confirmQueued = useCallback(() => {
|
||||
if (!queued) return;
|
||||
if (queued.count === 1) {
|
||||
handleAddFromBestiary(queued.result);
|
||||
} else {
|
||||
const creatureId = addMultipleFromBestiary(queued.result, queued.count);
|
||||
const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches;
|
||||
if (creatureId && panelView.mode === "closed" && isDesktop) {
|
||||
showCreature(creatureId);
|
||||
}
|
||||
}
|
||||
clearInput();
|
||||
}, [
|
||||
queued,
|
||||
handleAddFromBestiary,
|
||||
addMultipleFromBestiary,
|
||||
panelView.mode,
|
||||
showCreature,
|
||||
clearInput,
|
||||
]);
|
||||
|
||||
const parseNum = (v: string): number | undefined => {
|
||||
if (v.trim() === "") return undefined;
|
||||
const n = Number(v);
|
||||
return Number.isNaN(n) ? undefined : n;
|
||||
};
|
||||
|
||||
const handleAdd = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (browseMode) return;
|
||||
if (queued) {
|
||||
confirmQueued();
|
||||
return;
|
||||
}
|
||||
if (nameInput.trim() === "") return;
|
||||
const opts: { initiative?: number; ac?: number; maxHp?: number } = {};
|
||||
const init = parseNum(customInit);
|
||||
const ac = parseNum(customAc);
|
||||
const maxHp = parseNum(customMaxHp);
|
||||
if (init !== undefined) opts.initiative = init;
|
||||
if (ac !== undefined) opts.ac = ac;
|
||||
if (maxHp !== undefined) opts.maxHp = maxHp;
|
||||
addCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
clearCustomFields();
|
||||
};
|
||||
|
||||
const handleBrowseSearch = (value: string) => {
|
||||
setSuggestions(value.length >= 2 ? bestiarySearch(value) : []);
|
||||
};
|
||||
|
||||
const handleAddSearch = (value: string) => {
|
||||
let newSuggestions: SearchResult[] = [];
|
||||
let newPcMatches: PlayerCharacter[] = [];
|
||||
if (value.length >= 2) {
|
||||
newSuggestions = bestiarySearch(value);
|
||||
setSuggestions(newSuggestions);
|
||||
if (playerCharacters && playerCharacters.length > 0) {
|
||||
const lower = value.toLowerCase();
|
||||
newPcMatches = playerCharacters.filter((pc) =>
|
||||
pc.name.toLowerCase().includes(lower),
|
||||
);
|
||||
}
|
||||
setPcMatches(newPcMatches);
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
}
|
||||
if (newSuggestions.length > 0 || newPcMatches.length > 0) {
|
||||
clearCustomFields();
|
||||
}
|
||||
if (queued) {
|
||||
const qKey = creatureKey(queued.result);
|
||||
const stillVisible = newSuggestions.some((s) => creatureKey(s) === qKey);
|
||||
if (!stillVisible) {
|
||||
setQueued(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setNameInput(value);
|
||||
setSuggestionIndex(-1);
|
||||
if (browseMode) {
|
||||
handleBrowseSearch(value);
|
||||
} else {
|
||||
handleAddSearch(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickSuggestion = useCallback((result: SearchResult) => {
|
||||
const key = creatureKey(result);
|
||||
setQueued((prev) => {
|
||||
if (prev && creatureKey(prev.result) === key) {
|
||||
return { ...prev, count: prev.count + 1 };
|
||||
}
|
||||
return { result, count: 1 };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleEnter = () => {
|
||||
if (queued) {
|
||||
confirmQueued();
|
||||
} else if (suggestionIndex >= 0) {
|
||||
handleClickSuggestion(suggestions[suggestionIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
const hasSuggestions =
|
||||
deferredSuggestions.length > 0 || deferredPcMatches.length > 0;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!hasSuggestions) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleEnter();
|
||||
} else if (e.key === "Escape") {
|
||||
dismissSuggestions();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowseKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
return;
|
||||
}
|
||||
if (suggestions.length === 0) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
||||
} else if (e.key === "Enter" && suggestionIndex >= 0) {
|
||||
e.preventDefault();
|
||||
handleViewStatBlock(suggestions[suggestionIndex]);
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowseSelect = (result: SearchResult) => {
|
||||
handleViewStatBlock(result);
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
};
|
||||
|
||||
const toggleBrowseMode = () => {
|
||||
setBrowseMode((prev) => {
|
||||
const next = !prev;
|
||||
setSuggestionIndex(-1);
|
||||
setQueued(null);
|
||||
if (next) {
|
||||
handleBrowseSearch(nameInput);
|
||||
} else {
|
||||
handleAddSearch(nameInput);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
clearCustomFields();
|
||||
};
|
||||
|
||||
const suggestionActions: SuggestionActions = useMemo(
|
||||
() => ({
|
||||
dismiss: dismissSuggestions,
|
||||
clear: clearInput,
|
||||
clickSuggestion: handleClickSuggestion,
|
||||
setSuggestionIndex,
|
||||
setQueued,
|
||||
confirmQueued,
|
||||
addFromPlayerCharacter,
|
||||
}),
|
||||
[
|
||||
dismissSuggestions,
|
||||
clearInput,
|
||||
handleClickSuggestion,
|
||||
confirmQueued,
|
||||
addFromPlayerCharacter,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
// State
|
||||
nameInput,
|
||||
suggestions: deferredSuggestions,
|
||||
pcMatches: deferredPcMatches,
|
||||
suggestionIndex,
|
||||
queued,
|
||||
customInit,
|
||||
customAc,
|
||||
customMaxHp,
|
||||
browseMode,
|
||||
bestiaryLoaded,
|
||||
hasSuggestions,
|
||||
showBulkImport,
|
||||
showSourceManager,
|
||||
|
||||
// Actions
|
||||
suggestionActions,
|
||||
handleNameChange,
|
||||
handleKeyDown,
|
||||
handleBrowseKeyDown,
|
||||
handleAdd,
|
||||
handleBrowseSelect,
|
||||
toggleBrowseMode,
|
||||
setCustomInit,
|
||||
setCustomAc,
|
||||
setCustomMaxHp,
|
||||
} as const;
|
||||
}
|
||||
54
apps/web/src/hooks/use-difficulty.ts
Normal file
54
apps/web/src/hooks/use-difficulty.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type {
|
||||
Combatant,
|
||||
CreatureId,
|
||||
DifficultyResult,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
import { calculateEncounterDifficulty } from "@initiative/domain";
|
||||
import { useMemo } from "react";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||
|
||||
function derivePartyLevels(
|
||||
combatants: readonly Combatant[],
|
||||
characters: readonly PlayerCharacter[],
|
||||
): number[] {
|
||||
const levels: number[] = [];
|
||||
for (const c of combatants) {
|
||||
if (!c.playerCharacterId) continue;
|
||||
const pc = characters.find((p) => p.id === c.playerCharacterId);
|
||||
if (pc?.level !== undefined) levels.push(pc.level);
|
||||
}
|
||||
return levels;
|
||||
}
|
||||
|
||||
function deriveMonsterCrs(
|
||||
combatants: readonly Combatant[],
|
||||
getCreature: (id: CreatureId) => { cr: string } | undefined,
|
||||
): string[] {
|
||||
const crs: string[] = [];
|
||||
for (const c of combatants) {
|
||||
if (!c.creatureId) continue;
|
||||
const creature = getCreature(c.creatureId);
|
||||
if (creature) crs.push(creature.cr);
|
||||
}
|
||||
return crs;
|
||||
}
|
||||
|
||||
export function useDifficulty(): DifficultyResult | null {
|
||||
const { encounter } = useEncounterContext();
|
||||
const { characters } = usePlayerCharactersContext();
|
||||
const { getCreature } = useBestiaryContext();
|
||||
|
||||
return useMemo(() => {
|
||||
const partyLevels = derivePartyLevels(encounter.combatants, characters);
|
||||
const monsterCrs = deriveMonsterCrs(encounter.combatants, getCreature);
|
||||
|
||||
if (partyLevels.length === 0 || monsterCrs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return calculateEncounterDifficulty(partyLevels, monsterCrs);
|
||||
}, [encounter.combatants, characters, getCreature]);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { EncounterStore } from "@initiative/application";
|
||||
import type { EncounterStore, UndoRedoStore } from "@initiative/application";
|
||||
import {
|
||||
addCombatantUseCase,
|
||||
adjustHpUseCase,
|
||||
advanceTurnUseCase,
|
||||
clearEncounterUseCase,
|
||||
editCombatantUseCase,
|
||||
redoUseCase,
|
||||
removeCombatantUseCase,
|
||||
retreatTurnUseCase,
|
||||
setAcUseCase,
|
||||
@@ -13,20 +14,25 @@ import {
|
||||
setTempHpUseCase,
|
||||
toggleConcentrationUseCase,
|
||||
toggleConditionUseCase,
|
||||
undoUseCase,
|
||||
} from "@initiative/application";
|
||||
import type {
|
||||
BestiaryIndexEntry,
|
||||
CombatantId,
|
||||
CombatantInit,
|
||||
ConditionId,
|
||||
CreatureId,
|
||||
DomainEvent,
|
||||
Encounter,
|
||||
PlayerCharacter,
|
||||
UndoRedoState,
|
||||
} from "@initiative/domain";
|
||||
import {
|
||||
clearHistory,
|
||||
combatantId,
|
||||
isDomainError,
|
||||
creatureId as makeCreatureId,
|
||||
pushUndo,
|
||||
resolveCreatureName,
|
||||
} from "@initiative/domain";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
@@ -34,6 +40,10 @@ import {
|
||||
loadEncounter,
|
||||
saveEncounter,
|
||||
} from "../persistence/encounter-storage.js";
|
||||
import {
|
||||
loadUndoRedoStacks,
|
||||
saveUndoRedoStacks,
|
||||
} from "../persistence/undo-redo-storage.js";
|
||||
|
||||
const COMBATANT_ID_REGEX = /^c-(\d+)$/;
|
||||
|
||||
@@ -61,43 +71,24 @@ function deriveNextId(encounter: Encounter): number {
|
||||
return max;
|
||||
}
|
||||
|
||||
interface CombatantOpts {
|
||||
initiative?: number;
|
||||
ac?: number;
|
||||
maxHp?: number;
|
||||
}
|
||||
|
||||
function applyCombatantOpts(
|
||||
makeStore: () => EncounterStore,
|
||||
id: ReturnType<typeof combatantId>,
|
||||
opts: CombatantOpts,
|
||||
): DomainEvent[] {
|
||||
const events: DomainEvent[] = [];
|
||||
if (opts.maxHp !== undefined) {
|
||||
const r = setHpUseCase(makeStore(), id, opts.maxHp);
|
||||
if (!isDomainError(r)) events.push(...r);
|
||||
}
|
||||
if (opts.ac !== undefined) {
|
||||
const r = setAcUseCase(makeStore(), id, opts.ac);
|
||||
if (!isDomainError(r)) events.push(...r);
|
||||
}
|
||||
if (opts.initiative !== undefined) {
|
||||
const r = setInitiativeUseCase(makeStore(), id, opts.initiative);
|
||||
if (!isDomainError(r)) events.push(...r);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
export function useEncounter() {
|
||||
const [encounter, setEncounter] = useState<Encounter>(initializeEncounter);
|
||||
const [events, setEvents] = useState<DomainEvent[]>([]);
|
||||
const [undoRedoState, setUndoRedoState] =
|
||||
useState<UndoRedoState>(loadUndoRedoStacks);
|
||||
const encounterRef = useRef(encounter);
|
||||
encounterRef.current = encounter;
|
||||
const undoRedoRef = useRef(undoRedoState);
|
||||
undoRedoRef.current = undoRedoState;
|
||||
|
||||
useEffect(() => {
|
||||
saveEncounter(encounter);
|
||||
}, [encounter]);
|
||||
|
||||
useEffect(() => {
|
||||
saveUndoRedoStacks(undoRedoState);
|
||||
}, [undoRedoState]);
|
||||
|
||||
const makeStore = useCallback((): EncounterStore => {
|
||||
return {
|
||||
get: () => encounterRef.current,
|
||||
@@ -108,52 +99,68 @@ export function useEncounter() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const makeUndoRedoStore = useCallback((): UndoRedoStore => {
|
||||
return {
|
||||
get: () => undoRedoRef.current,
|
||||
save: (s) => {
|
||||
undoRedoRef.current = s;
|
||||
setUndoRedoState(s);
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
const withUndo = useCallback(<T>(action: () => T): T => {
|
||||
const snapshot = encounterRef.current;
|
||||
const result = action();
|
||||
if (!isDomainError(result)) {
|
||||
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||
undoRedoRef.current = newState;
|
||||
setUndoRedoState(newState);
|
||||
}
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
const advanceTurn = useCallback(() => {
|
||||
const result = advanceTurnUseCase(makeStore());
|
||||
const result = withUndo(() => advanceTurnUseCase(makeStore()));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
}, [makeStore]);
|
||||
}, [makeStore, withUndo]);
|
||||
|
||||
const retreatTurn = useCallback(() => {
|
||||
const result = retreatTurnUseCase(makeStore());
|
||||
const result = withUndo(() => retreatTurnUseCase(makeStore()));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
}, [makeStore]);
|
||||
}, [makeStore, withUndo]);
|
||||
|
||||
const nextId = useRef(deriveNextId(encounter));
|
||||
|
||||
const addCombatant = useCallback(
|
||||
(name: string, opts?: CombatantOpts) => {
|
||||
(name: string, init?: CombatantInit) => {
|
||||
const id = combatantId(`c-${++nextId.current}`);
|
||||
const result = addCombatantUseCase(makeStore(), id, name);
|
||||
const result = withUndo(() =>
|
||||
addCombatantUseCase(makeStore(), id, name, init),
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts) {
|
||||
const optEvents = applyCombatantOpts(makeStore, id, opts);
|
||||
if (optEvents.length > 0) {
|
||||
setEvents((prev) => [...prev, ...optEvents]);
|
||||
}
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const removeCombatant = useCallback(
|
||||
(id: CombatantId) => {
|
||||
const result = removeCombatantUseCase(makeStore(), id);
|
||||
const result = withUndo(() => removeCombatantUseCase(makeStore(), id));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
@@ -161,12 +168,14 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const editCombatant = useCallback(
|
||||
(id: CombatantId, newName: string) => {
|
||||
const result = editCombatantUseCase(makeStore(), id, newName);
|
||||
const result = withUndo(() =>
|
||||
editCombatantUseCase(makeStore(), id, newName),
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
@@ -174,12 +183,14 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const setInitiative = useCallback(
|
||||
(id: CombatantId, value: number | undefined) => {
|
||||
const result = setInitiativeUseCase(makeStore(), id, value);
|
||||
const result = withUndo(() =>
|
||||
setInitiativeUseCase(makeStore(), id, value),
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
@@ -187,12 +198,12 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const setHp = useCallback(
|
||||
(id: CombatantId, maxHp: number | undefined) => {
|
||||
const result = setHpUseCase(makeStore(), id, maxHp);
|
||||
const result = withUndo(() => setHpUseCase(makeStore(), id, maxHp));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
@@ -200,12 +211,12 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const adjustHp = useCallback(
|
||||
(id: CombatantId, delta: number) => {
|
||||
const result = adjustHpUseCase(makeStore(), id, delta);
|
||||
const result = withUndo(() => adjustHpUseCase(makeStore(), id, delta));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
@@ -213,12 +224,12 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const setTempHp = useCallback(
|
||||
(id: CombatantId, tempHp: number | undefined) => {
|
||||
const result = setTempHpUseCase(makeStore(), id, tempHp);
|
||||
const result = withUndo(() => setTempHpUseCase(makeStore(), id, tempHp));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
@@ -226,12 +237,12 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const setAc = useCallback(
|
||||
(id: CombatantId, value: number | undefined) => {
|
||||
const result = setAcUseCase(makeStore(), id, value);
|
||||
const result = withUndo(() => setAcUseCase(makeStore(), id, value));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
@@ -239,12 +250,14 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const toggleCondition = useCallback(
|
||||
(id: CombatantId, conditionId: ConditionId) => {
|
||||
const result = toggleConditionUseCase(makeStore(), id, conditionId);
|
||||
const result = withUndo(() =>
|
||||
toggleConditionUseCase(makeStore(), id, conditionId),
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
@@ -252,12 +265,14 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const toggleConcentration = useCallback(
|
||||
(id: CombatantId) => {
|
||||
const result = toggleConcentrationUseCase(makeStore(), id);
|
||||
const result = withUndo(() =>
|
||||
toggleConcentrationUseCase(makeStore(), id),
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
@@ -265,7 +280,7 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const clearEncounter = useCallback(() => {
|
||||
@@ -275,12 +290,18 @@ export function useEncounter() {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleared = clearHistory();
|
||||
undoRedoRef.current = cleared;
|
||||
setUndoRedoState(cleared);
|
||||
|
||||
nextId.current = 0;
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
}, [makeStore]);
|
||||
|
||||
const addFromBestiary = useCallback(
|
||||
(entry: BestiaryIndexEntry): CreatureId | null => {
|
||||
const addOneFromBestiary = useCallback(
|
||||
(
|
||||
entry: BestiaryIndexEntry,
|
||||
): { cId: CreatureId; events: DomainEvent[] } | null => {
|
||||
const store = makeStore();
|
||||
const existingNames = store.get().combatants.map((c) => c.name);
|
||||
const { newName, renames } = resolveCreatureName(
|
||||
@@ -288,7 +309,6 @@ export function useEncounter() {
|
||||
existingNames,
|
||||
);
|
||||
|
||||
// Apply renames (e.g., "Goblin" → "Goblin 1")
|
||||
for (const { from, to } of renames) {
|
||||
const target = store.get().combatants.find((c) => c.name === from);
|
||||
if (target) {
|
||||
@@ -296,50 +316,75 @@ export function useEncounter() {
|
||||
}
|
||||
}
|
||||
|
||||
// Add combatant with resolved name
|
||||
const id = combatantId(`c-${++nextId.current}`);
|
||||
const addResult = addCombatantUseCase(makeStore(), id, newName);
|
||||
if (isDomainError(addResult)) return null;
|
||||
|
||||
// Set HP
|
||||
const hpResult = setHpUseCase(makeStore(), id, entry.hp);
|
||||
if (!isDomainError(hpResult)) {
|
||||
setEvents((prev) => [...prev, ...hpResult]);
|
||||
}
|
||||
|
||||
// Set AC
|
||||
if (entry.ac > 0) {
|
||||
const acResult = setAcUseCase(makeStore(), id, entry.ac);
|
||||
if (!isDomainError(acResult)) {
|
||||
setEvents((prev) => [...prev, ...acResult]);
|
||||
}
|
||||
}
|
||||
|
||||
// Derive creatureId from source + name
|
||||
const slug = entry.name
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||
.replaceAll(/(^-|-$)/g, "");
|
||||
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
|
||||
|
||||
// Set creatureId on the combatant (use store.save to keep ref in sync for batch calls)
|
||||
const currentEncounter = store.get();
|
||||
store.save({
|
||||
...currentEncounter,
|
||||
combatants: currentEncounter.combatants.map((c) =>
|
||||
c.id === id ? { ...c, creatureId: cId } : c,
|
||||
),
|
||||
const id = combatantId(`c-${++nextId.current}`);
|
||||
const result = addCombatantUseCase(makeStore(), id, newName, {
|
||||
maxHp: entry.hp,
|
||||
ac: entry.ac > 0 ? entry.ac : undefined,
|
||||
creatureId: cId,
|
||||
});
|
||||
|
||||
setEvents((prev) => [...prev, ...addResult]);
|
||||
if (isDomainError(result)) return null;
|
||||
|
||||
return cId;
|
||||
return { cId, events: result };
|
||||
},
|
||||
[makeStore],
|
||||
);
|
||||
|
||||
const addFromBestiary = useCallback(
|
||||
(entry: BestiaryIndexEntry): CreatureId | null => {
|
||||
const snapshot = encounterRef.current;
|
||||
const added = addOneFromBestiary(entry);
|
||||
|
||||
if (!added) {
|
||||
makeStore().save(snapshot);
|
||||
return null;
|
||||
}
|
||||
|
||||
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||
undoRedoRef.current = newState;
|
||||
setUndoRedoState(newState);
|
||||
|
||||
setEvents((prev) => [...prev, ...added.events]);
|
||||
return added.cId;
|
||||
},
|
||||
[makeStore, addOneFromBestiary],
|
||||
);
|
||||
|
||||
const addMultipleFromBestiary = useCallback(
|
||||
(entry: BestiaryIndexEntry, count: number): CreatureId | null => {
|
||||
const snapshot = encounterRef.current;
|
||||
const allEvents: DomainEvent[] = [];
|
||||
let lastCId: CreatureId | null = null;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const added = addOneFromBestiary(entry);
|
||||
if (!added) {
|
||||
makeStore().save(snapshot);
|
||||
return null;
|
||||
}
|
||||
allEvents.push(...added.events);
|
||||
lastCId = added.cId;
|
||||
}
|
||||
|
||||
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||
undoRedoRef.current = newState;
|
||||
setUndoRedoState(newState);
|
||||
|
||||
setEvents((prev) => [...prev, ...allEvents]);
|
||||
return lastCId;
|
||||
},
|
||||
[makeStore, addOneFromBestiary],
|
||||
);
|
||||
|
||||
const addFromPlayerCharacter = useCallback(
|
||||
(pc: PlayerCharacter) => {
|
||||
const snapshot = encounterRef.current;
|
||||
const store = makeStore();
|
||||
const existingNames = store.get().combatants.map((c) => c.name);
|
||||
const { newName, renames } = resolveCreatureName(pc.name, existingNames);
|
||||
@@ -352,44 +397,39 @@ export function useEncounter() {
|
||||
}
|
||||
|
||||
const id = combatantId(`c-${++nextId.current}`);
|
||||
const addResult = addCombatantUseCase(makeStore(), id, newName);
|
||||
if (isDomainError(addResult)) return;
|
||||
|
||||
// Set HP
|
||||
const hpResult = setHpUseCase(makeStore(), id, pc.maxHp);
|
||||
if (!isDomainError(hpResult)) {
|
||||
setEvents((prev) => [...prev, ...hpResult]);
|
||||
}
|
||||
|
||||
// Set AC
|
||||
if (pc.ac > 0) {
|
||||
const acResult = setAcUseCase(makeStore(), id, pc.ac);
|
||||
if (!isDomainError(acResult)) {
|
||||
setEvents((prev) => [...prev, ...acResult]);
|
||||
}
|
||||
}
|
||||
|
||||
// Set color, icon, and playerCharacterId on the combatant
|
||||
const currentEncounter = store.get();
|
||||
store.save({
|
||||
...currentEncounter,
|
||||
combatants: currentEncounter.combatants.map((c) =>
|
||||
c.id === id
|
||||
? {
|
||||
...c,
|
||||
color: pc.color,
|
||||
icon: pc.icon,
|
||||
playerCharacterId: pc.id,
|
||||
}
|
||||
: c,
|
||||
),
|
||||
const result = addCombatantUseCase(makeStore(), id, newName, {
|
||||
maxHp: pc.maxHp,
|
||||
ac: pc.ac > 0 ? pc.ac : undefined,
|
||||
color: pc.color,
|
||||
icon: pc.icon,
|
||||
playerCharacterId: pc.id,
|
||||
});
|
||||
|
||||
setEvents((prev) => [...prev, ...addResult]);
|
||||
if (isDomainError(result)) {
|
||||
store.save(snapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||
undoRedoRef.current = newState;
|
||||
setUndoRedoState(newState);
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
);
|
||||
|
||||
const undoAction = useCallback(() => {
|
||||
undoUseCase(makeStore(), makeUndoRedoStore());
|
||||
}, [makeStore, makeUndoRedoStore]);
|
||||
|
||||
const redoAction = useCallback(() => {
|
||||
redoUseCase(makeStore(), makeUndoRedoStore());
|
||||
}, [makeStore, makeUndoRedoStore]);
|
||||
|
||||
const canUndo = undoRedoState.undoStack.length > 0;
|
||||
const canRedo = undoRedoState.redoStack.length > 0;
|
||||
|
||||
const hasTempHp = encounter.combatants.some(
|
||||
(c) => c.tempHp !== undefined && c.tempHp > 0,
|
||||
);
|
||||
@@ -404,11 +444,14 @@ export function useEncounter() {
|
||||
|
||||
return {
|
||||
encounter,
|
||||
undoRedoState,
|
||||
events,
|
||||
isEmpty,
|
||||
hasTempHp,
|
||||
hasCreatureCombatants,
|
||||
canRollAllInitiative,
|
||||
canUndo,
|
||||
canRedo,
|
||||
advanceTurn,
|
||||
retreatTurn,
|
||||
addCombatant,
|
||||
@@ -423,7 +466,13 @@ export function useEncounter() {
|
||||
toggleCondition,
|
||||
toggleConcentration,
|
||||
addFromBestiary,
|
||||
addMultipleFromBestiary,
|
||||
addFromPlayerCharacter,
|
||||
undo: undoAction,
|
||||
redo: redoAction,
|
||||
setEncounter,
|
||||
setUndoRedoState,
|
||||
makeStore,
|
||||
withUndo,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ function rollDice(): number {
|
||||
}
|
||||
|
||||
export function useInitiativeRolls() {
|
||||
const { encounter, makeStore } = useEncounterContext();
|
||||
const { encounter, makeStore, withUndo } = useEncounterContext();
|
||||
const { getCreature } = useBestiaryContext();
|
||||
const { showCreature } = useSidePanelContext();
|
||||
|
||||
@@ -28,12 +28,8 @@ export function useInitiativeRolls() {
|
||||
(id: CombatantId, mode: RollMode = "normal") => {
|
||||
const diceRolls: [number, ...number[]] =
|
||||
mode === "normal" ? [rollDice()] : [rollDice(), rollDice()];
|
||||
const result = rollInitiativeUseCase(
|
||||
makeStore(),
|
||||
id,
|
||||
diceRolls,
|
||||
getCreature,
|
||||
mode,
|
||||
const result = withUndo(() =>
|
||||
rollInitiativeUseCase(makeStore(), id, diceRolls, getCreature, mode),
|
||||
);
|
||||
if (isDomainError(result)) {
|
||||
setRollSingleSkipped(true);
|
||||
@@ -43,22 +39,19 @@ export function useInitiativeRolls() {
|
||||
}
|
||||
}
|
||||
},
|
||||
[makeStore, getCreature, encounter.combatants, showCreature],
|
||||
[makeStore, getCreature, withUndo, encounter.combatants, showCreature],
|
||||
);
|
||||
|
||||
const handleRollAllInitiative = useCallback(
|
||||
(mode: RollMode = "normal") => {
|
||||
const result = rollAllInitiativeUseCase(
|
||||
makeStore(),
|
||||
rollDice,
|
||||
getCreature,
|
||||
mode,
|
||||
const result = withUndo(() =>
|
||||
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature, mode),
|
||||
);
|
||||
if (!isDomainError(result) && result.skippedNoSource > 0) {
|
||||
setRollSkippedCount(result.skippedNoSource);
|
||||
}
|
||||
},
|
||||
[makeStore, getCreature],
|
||||
[makeStore, getCreature, withUndo],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -28,6 +28,7 @@ interface EditFields {
|
||||
readonly maxHp?: number;
|
||||
readonly color?: string | null;
|
||||
readonly icon?: string | null;
|
||||
readonly level?: number | null;
|
||||
}
|
||||
|
||||
export function usePlayerCharacters() {
|
||||
@@ -57,6 +58,7 @@ export function usePlayerCharacters() {
|
||||
maxHp: number,
|
||||
color: string | undefined,
|
||||
icon: string | undefined,
|
||||
level: number | undefined,
|
||||
) => {
|
||||
const id = generatePcId();
|
||||
const result = createPlayerCharacterUseCase(
|
||||
@@ -67,6 +69,7 @@ export function usePlayerCharacters() {
|
||||
maxHp,
|
||||
color,
|
||||
icon,
|
||||
level,
|
||||
);
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
@@ -103,6 +106,7 @@ export function usePlayerCharacters() {
|
||||
createCharacter,
|
||||
editCharacter,
|
||||
deleteCharacter,
|
||||
replacePlayerCharacters: setCharacters,
|
||||
makeStore,
|
||||
} as const;
|
||||
}
|
||||
|
||||
42
apps/web/src/hooks/use-undo-redo-shortcuts.ts
Normal file
42
apps/web/src/hooks/use-undo-redo-shortcuts.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
const SUPPRESSED_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]);
|
||||
|
||||
function isTextInputFocused(): boolean {
|
||||
const active = document.activeElement;
|
||||
if (!active) return false;
|
||||
if (SUPPRESSED_TAGS.has(active.tagName)) return true;
|
||||
return active instanceof HTMLElement && active.isContentEditable;
|
||||
}
|
||||
|
||||
function isUndoShortcut(e: KeyboardEvent): boolean {
|
||||
return (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z" && !e.shiftKey;
|
||||
}
|
||||
|
||||
function isRedoShortcut(e: KeyboardEvent): boolean {
|
||||
return (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z" && e.shiftKey;
|
||||
}
|
||||
|
||||
export function useUndoRedoShortcuts(
|
||||
undo: () => void,
|
||||
redo: () => void,
|
||||
canUndo: boolean,
|
||||
canRedo: boolean,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (isTextInputFocused()) return;
|
||||
|
||||
if (isUndoShortcut(e) && canUndo) {
|
||||
e.preventDefault();
|
||||
undo();
|
||||
} else if (isRedoShortcut(e) && canRedo) {
|
||||
e.preventDefault();
|
||||
redo();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [undo, redo, canUndo, canRedo]);
|
||||
}
|
||||
@@ -186,6 +186,38 @@ describe("player-character-storage", () => {
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves level through save/load round-trip", () => {
|
||||
const pc = makePC({ level: 5 });
|
||||
savePlayerCharacters([pc]);
|
||||
const loaded = loadPlayerCharacters();
|
||||
expect(loaded[0].level).toBe(5);
|
||||
});
|
||||
|
||||
it("preserves undefined level through save/load round-trip", () => {
|
||||
const pc = makePC();
|
||||
savePlayerCharacters([pc]);
|
||||
const loaded = loadPlayerCharacters();
|
||||
expect(loaded[0].level).toBeUndefined();
|
||||
});
|
||||
|
||||
it("discards character with invalid level", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Test",
|
||||
ac: 10,
|
||||
maxHp: 50,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
level: 25,
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps valid characters and discards invalid ones", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
|
||||
@@ -108,45 +108,44 @@ function isValidCombatantEntry(c: unknown): boolean {
|
||||
return typeof entry.id === "string" && typeof entry.name === "string";
|
||||
}
|
||||
|
||||
export function rehydrateEncounter(parsed: unknown): Encounter | null {
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
||||
return null;
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
|
||||
if (!Array.isArray(obj.combatants)) return null;
|
||||
if (typeof obj.activeIndex !== "number") return null;
|
||||
if (typeof obj.roundNumber !== "number") return null;
|
||||
|
||||
const combatants = obj.combatants as unknown[];
|
||||
|
||||
// Handle empty encounter (cleared state) directly — createEncounter rejects empty arrays
|
||||
if (combatants.length === 0) {
|
||||
return {
|
||||
combatants: [],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (!combatants.every(isValidCombatantEntry)) return null;
|
||||
|
||||
const rehydrated = combatants.map(rehydrateCombatant);
|
||||
|
||||
const result = createEncounter(rehydrated, obj.activeIndex, obj.roundNumber);
|
||||
if (isDomainError(result)) return null;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function loadEncounter(): Encounter | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw === null) return null;
|
||||
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
||||
return null;
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
|
||||
if (!Array.isArray(obj.combatants)) return null;
|
||||
if (typeof obj.activeIndex !== "number") return null;
|
||||
if (typeof obj.roundNumber !== "number") return null;
|
||||
|
||||
const combatants = obj.combatants as unknown[];
|
||||
|
||||
// Handle empty encounter (cleared state) directly — createEncounter rejects empty arrays
|
||||
if (combatants.length === 0) {
|
||||
return {
|
||||
combatants: [],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (!combatants.every(isValidCombatantEntry)) return null;
|
||||
|
||||
const rehydrated = combatants.map(rehydrateCombatant);
|
||||
|
||||
const result = createEncounter(
|
||||
rehydrated,
|
||||
obj.activeIndex,
|
||||
obj.roundNumber,
|
||||
);
|
||||
if (isDomainError(result)) return null;
|
||||
|
||||
return result;
|
||||
return rehydrateEncounter(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
118
apps/web/src/persistence/export-import.ts
Normal file
118
apps/web/src/persistence/export-import.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type {
|
||||
Encounter,
|
||||
ExportBundle,
|
||||
PlayerCharacter,
|
||||
UndoRedoState,
|
||||
} from "@initiative/domain";
|
||||
import { rehydrateEncounter } from "./encounter-storage.js";
|
||||
import { rehydrateCharacter } from "./player-character-storage.js";
|
||||
|
||||
function rehydrateStack(raw: unknown[]): Encounter[] {
|
||||
const result: Encounter[] = [];
|
||||
for (const entry of raw) {
|
||||
const rehydrated = rehydrateEncounter(entry);
|
||||
if (rehydrated !== null) {
|
||||
result.push(rehydrated);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function rehydrateCharacters(raw: unknown[]): PlayerCharacter[] {
|
||||
const result: PlayerCharacter[] = [];
|
||||
for (const entry of raw) {
|
||||
const rehydrated = rehydrateCharacter(entry);
|
||||
if (rehydrated !== null) {
|
||||
result.push(rehydrated);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function validateImportBundle(data: unknown): ExportBundle | string {
|
||||
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
||||
return "Invalid file format";
|
||||
}
|
||||
|
||||
const obj = data as Record<string, unknown>;
|
||||
|
||||
if (typeof obj.version !== "number" || obj.version !== 1) {
|
||||
return "Invalid file format";
|
||||
}
|
||||
if (typeof obj.exportedAt !== "string") {
|
||||
return "Invalid file format";
|
||||
}
|
||||
if (!Array.isArray(obj.undoStack) || !Array.isArray(obj.redoStack)) {
|
||||
return "Invalid file format";
|
||||
}
|
||||
if (!Array.isArray(obj.playerCharacters)) {
|
||||
return "Invalid file format";
|
||||
}
|
||||
|
||||
const encounter = rehydrateEncounter(obj.encounter);
|
||||
if (encounter === null) {
|
||||
return "Invalid encounter data";
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
exportedAt: obj.exportedAt,
|
||||
encounter,
|
||||
undoStack: rehydrateStack(obj.undoStack),
|
||||
redoStack: rehydrateStack(obj.redoStack),
|
||||
playerCharacters: rehydrateCharacters(obj.playerCharacters),
|
||||
};
|
||||
}
|
||||
|
||||
export function assembleExportBundle(
|
||||
encounter: Encounter,
|
||||
undoRedoState: UndoRedoState,
|
||||
playerCharacters: readonly PlayerCharacter[],
|
||||
includeHistory = true,
|
||||
): ExportBundle {
|
||||
return {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
encounter,
|
||||
undoStack: includeHistory ? undoRedoState.undoStack : [],
|
||||
redoStack: includeHistory ? undoRedoState.redoStack : [],
|
||||
playerCharacters: [...playerCharacters],
|
||||
};
|
||||
}
|
||||
|
||||
export function bundleToJson(bundle: ExportBundle): string {
|
||||
return JSON.stringify(bundle, null, 2);
|
||||
}
|
||||
|
||||
export function resolveFilename(name?: string): string {
|
||||
const base =
|
||||
name?.trim() ||
|
||||
`initiative-export-${new Date().toISOString().slice(0, 10)}`;
|
||||
return base.endsWith(".json") ? base : `${base}.json`;
|
||||
}
|
||||
|
||||
export function triggerDownload(bundle: ExportBundle, name?: string): void {
|
||||
const blob = new Blob([bundleToJson(bundle)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const filename = resolveFilename(name);
|
||||
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export async function readImportFile(
|
||||
file: File,
|
||||
): Promise<ExportBundle | string> {
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
return validateImportBundle(parsed);
|
||||
} catch {
|
||||
return "Invalid file format";
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ function isValidOptionalMember(
|
||||
return value === undefined || (typeof value === "string" && valid.has(value));
|
||||
}
|
||||
|
||||
function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
||||
export function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
||||
return null;
|
||||
const entry = raw as Record<string, unknown>;
|
||||
@@ -44,6 +44,14 @@ function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
||||
return null;
|
||||
if (!isValidOptionalMember(entry.color, VALID_PLAYER_COLORS)) return null;
|
||||
if (!isValidOptionalMember(entry.icon, VALID_PLAYER_ICONS)) return null;
|
||||
if (
|
||||
entry.level !== undefined &&
|
||||
(typeof entry.level !== "number" ||
|
||||
!Number.isInteger(entry.level) ||
|
||||
entry.level < 1 ||
|
||||
entry.level > 20)
|
||||
)
|
||||
return null;
|
||||
|
||||
return {
|
||||
id: playerCharacterId(entry.id),
|
||||
@@ -52,6 +60,7 @@ function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
||||
maxHp: entry.maxHp,
|
||||
color: entry.color as PlayerCharacter["color"],
|
||||
icon: entry.icon as PlayerCharacter["icon"],
|
||||
level: entry.level,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
45
apps/web/src/persistence/undo-redo-storage.ts
Normal file
45
apps/web/src/persistence/undo-redo-storage.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { Encounter, UndoRedoState } from "@initiative/domain";
|
||||
import { EMPTY_UNDO_REDO_STATE } from "@initiative/domain";
|
||||
import { rehydrateEncounter } from "./encounter-storage.js";
|
||||
|
||||
const UNDO_KEY = "initiative:encounter:undo";
|
||||
const REDO_KEY = "initiative:encounter:redo";
|
||||
|
||||
export function saveUndoRedoStacks(state: UndoRedoState): void {
|
||||
try {
|
||||
localStorage.setItem(UNDO_KEY, JSON.stringify(state.undoStack));
|
||||
localStorage.setItem(REDO_KEY, JSON.stringify(state.redoStack));
|
||||
} catch {
|
||||
// Silently swallow errors (quota exceeded, storage unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
function loadStack(key: string): readonly Encounter[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw === null) return [];
|
||||
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
|
||||
const valid: Encounter[] = [];
|
||||
for (const entry of parsed) {
|
||||
const rehydrated = rehydrateEncounter(entry);
|
||||
if (rehydrated !== null) {
|
||||
valid.push(rehydrated);
|
||||
}
|
||||
}
|
||||
return valid;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function loadUndoRedoStacks(): UndoRedoState {
|
||||
const undoStack = loadStack(UNDO_KEY);
|
||||
const redoStack = loadStack(REDO_KEY);
|
||||
if (undoStack.length === 0 && redoStack.length === 0) {
|
||||
return EMPTY_UNDO_REDO_STATE;
|
||||
}
|
||||
return { undoStack, redoStack };
|
||||
}
|
||||
@@ -8,7 +8,9 @@
|
||||
"!.specify",
|
||||
"!specs",
|
||||
"!coverage",
|
||||
"!.pnpm-store"
|
||||
"!.pnpm-store",
|
||||
"!.rodney",
|
||||
"!.agent-tests"
|
||||
]
|
||||
},
|
||||
"assist": {
|
||||
|
||||
20
docs/adr/000-template.md
Normal file
20
docs/adr/000-template.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# ADR-NNN: [Title]
|
||||
|
||||
**Date**: YYYY-MM-DD
|
||||
**Status**: accepted | superseded | deprecated
|
||||
|
||||
## Context
|
||||
|
||||
What is the problem or situation that motivates this decision?
|
||||
|
||||
## Decision
|
||||
|
||||
What did we decide, and why?
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
What other approaches were evaluated?
|
||||
|
||||
## Consequences
|
||||
|
||||
What are the trade-offs — both positive and negative?
|
||||
45
docs/adr/001-errors-as-values.md
Normal file
45
docs/adr/001-errors-as-values.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# ADR-001: Errors as Values, Not Exceptions
|
||||
|
||||
**Date**: 2026-03-25
|
||||
**Status**: accepted
|
||||
|
||||
## Context
|
||||
|
||||
Domain functions need to communicate failure (invalid input, missing combatant, violated invariants). The standard JavaScript approach is to throw exceptions, but thrown exceptions are invisible to TypeScript's type system — nothing in a function's signature tells the caller that it can fail or what errors to expect.
|
||||
|
||||
This project's domain layer is designed to be pure and deterministic. Thrown exceptions break both properties: they alter control flow (a side effect) and make the function's output unpredictable from the caller's perspective.
|
||||
|
||||
## Decision
|
||||
|
||||
All domain functions return `SuccessType | DomainError` unions. `DomainError` is a plain data object with a `kind` discriminant, a machine-readable `code`, and a human-readable `message`:
|
||||
|
||||
```typescript
|
||||
interface DomainError {
|
||||
readonly kind: "domain-error";
|
||||
readonly code: string;
|
||||
readonly message: string;
|
||||
}
|
||||
```
|
||||
|
||||
Callers check results with the `isDomainError()` type guard before accessing success data. Errors are never thrown in the domain layer (adapter-layer code may throw for programmer errors like missing providers).
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
**Thrown exceptions** — the JavaScript default. Simpler to write (`throw new Error(...)`) but error paths are invisible to the type system. The caller has no compile-time indication that a function can fail, and `catch` blocks lose type information about which errors are possible. Would also make domain functions impure.
|
||||
|
||||
**Result wrapper types** (e.g., `neverthrow`, `ts-results`) — formalizes the pattern with `.map()`, `.unwrap()`, `.match()` methods. More ergonomic for chaining operations, but adds a library dependency and a layer of indirection. The project's use cases are simple enough (call domain function, check error, save or return) that raw unions are sufficient.
|
||||
|
||||
**Validation libraries** (Zod, io-ts) — useful for input parsing but don't cover domain logic errors like "combatant not found" or "no previous turn". Would only address a subset of the problem.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- Error handling is compiler-enforced. Forgetting to check for an error produces a type error when accessing success fields.
|
||||
- Domain functions remain pure — they return data, never alter control flow.
|
||||
- Error codes are stable, machine-readable identifiers that UI code can match on.
|
||||
- Testing is straightforward: assert the return value, no try/catch in tests.
|
||||
|
||||
**Negative:**
|
||||
- Every call site must check `isDomainError()` before proceeding. This is slightly more verbose than a try/catch that wraps multiple calls.
|
||||
- Composing multiple fallible operations requires manual chaining (check error, then call next function). A Result wrapper would make this more ergonomic if the codebase grows significantly.
|
||||
- Contributors familiar with JavaScript conventions may initially find the pattern unfamiliar.
|
||||
46
docs/adr/002-domain-events-as-plain-data.md
Normal file
46
docs/adr/002-domain-events-as-plain-data.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# ADR-002: Domain Events as Plain Data Objects
|
||||
|
||||
**Date**: 2026-03-25
|
||||
**Status**: accepted
|
||||
|
||||
## Context
|
||||
|
||||
Domain state transitions need to communicate what happened (not just the new state) so the UI layer can react — showing toasts, auto-scrolling, opening panels, etc. The project needs an event mechanism that stays consistent with the pure, deterministic domain core.
|
||||
|
||||
## Decision
|
||||
|
||||
Domain events are plain data objects with a `type` string discriminant. They form a discriminated union (`DomainEvent`) of 18 event types. Events are returned alongside the new state from domain functions, not emitted through a pub/sub system:
|
||||
|
||||
```typescript
|
||||
// Example event
|
||||
{ type: "TurnAdvanced", previousCombatantId: "abc", newCombatantId: "def", roundNumber: 2 }
|
||||
|
||||
// Domain function returns both state and events
|
||||
function advanceTurn(encounter: Encounter): { encounter: Encounter; events: DomainEvent[] } | DomainError
|
||||
```
|
||||
|
||||
Events are consumed ephemerally by the UI layer and are not persisted.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
**Class-based events** (e.g., `class TurnAdvanced extends DomainEvent { ... }`) — common in OOP-style domain-driven design. Adds inheritance hierarchies, constructors, and `instanceof` checks. No benefit here: TypeScript's discriminated union narrowing (`switch (event.type)`) provides the same exhaustiveness checking without classes. Classes also can't be serialized/deserialized without custom logic.
|
||||
|
||||
**Event emitter / pub-sub** (Node `EventEmitter`, custom bus, RxJS) — events are broadcast and listeners subscribe. Decouples producers from consumers, but introduces implicit coupling (who's listening?), ordering concerns, and makes the domain impure (emitting is a side effect). Harder to test — you'd need to set up listeners and collect results instead of just asserting on a return value.
|
||||
|
||||
**Observable streams** (RxJS) — powerful for async event processing and composition. Massive overkill for this use case: events are synchronous, produced one batch at a time, and consumed immediately. Would add a significant dependency and conceptual overhead.
|
||||
|
||||
**No events** (just compare old and new state) — the UI could diff states to determine what changed. Works for simple cases, but can't express intent (did HP drop because of damage or because max HP was lowered?) and gets unwieldy as the state model grows.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- Events are serializable (JSON-compatible). If the project ever adds undo/redo or event logging, no changes to the event format are needed.
|
||||
- TypeScript's `switch (event.type)` provides exhaustiveness checking — the compiler warns if a new event type is added but not handled.
|
||||
- No framework coupling. Events are just data; any consumer (React, a test, a CLI) can process them identically.
|
||||
- Domain functions remain pure — events are returned, not emitted.
|
||||
- Testing is trivial: assert that `result.events` contains the expected objects.
|
||||
|
||||
**Negative:**
|
||||
- Events are currently consumed and discarded. There is no event log, replay, or undo capability. The architecture supports it, but it's not built.
|
||||
- Adding a new event type requires updating the `DomainEvent` union, which touches a central file. This is intentional (forces explicit acknowledgment) but adds friction.
|
||||
- No built-in mechanism for event handlers to communicate back (e.g., "veto this event"). Events are informational, not transactional.
|
||||
53
docs/adr/003-branded-types-for-identity.md
Normal file
53
docs/adr/003-branded-types-for-identity.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# ADR-003: Branded Types for Identity Safety
|
||||
|
||||
**Date**: 2026-03-25
|
||||
**Status**: accepted
|
||||
|
||||
## Context
|
||||
|
||||
The domain model has multiple entity types with string-based identifiers: combatants, creatures, and player characters. All IDs are strings at runtime (UUIDs or slug-based), making it easy to accidentally pass one ID type where another is expected. Such bugs are silent — the code compiles, runs, and only fails at runtime when a lookup returns `undefined` or mutates the wrong entity.
|
||||
|
||||
TypeScript's structural type system treats all `string` values as interchangeable, so a plain `string` type alias provides no protection.
|
||||
|
||||
## Decision
|
||||
|
||||
Identity types use TypeScript branded types — a `string` intersected with a phantom `readonly __brand` property that exists only at the type level:
|
||||
|
||||
```typescript
|
||||
type CombatantId = string & { readonly __brand: "CombatantId" };
|
||||
type CreatureId = string & { readonly __brand: "CreatureId" };
|
||||
type PlayerCharacterId = string & { readonly __brand: "PlayerCharacterId" };
|
||||
```
|
||||
|
||||
Each type has a factory function that casts a plain string into the branded type:
|
||||
|
||||
```typescript
|
||||
function combatantId(id: string): CombatantId {
|
||||
return id as CombatantId;
|
||||
}
|
||||
```
|
||||
|
||||
The `__brand` property is never assigned at runtime — it's a compile-time-only construct. The cast in the factory is the single point where the type system is "convinced" that the string carries the brand.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
**Plain `string` type aliases** (`type CombatantId = string`) — provides documentation value but zero type safety. TypeScript treats the alias as fully interchangeable with `string` and with all other string aliases. This is what most TypeScript codebases do, accepting the risk of ID confusion.
|
||||
|
||||
**Opaque types via unique symbols** (`declare const brand: unique symbol; type CombatantId = string & { [brand]: void }`) — stricter than the `__brand` approach because the symbol is truly unique and unexportable. Slightly more boilerplate and harder to read. The simpler `__brand` string approach provides sufficient safety for this codebase's scale.
|
||||
|
||||
**Wrapper classes** (`class CombatantId { constructor(public readonly value: string) {} }`) — provides nominal typing naturally, but introduces runtime overhead (object allocation, `.value` access everywhere), breaks JSON serialization, and doesn't play well with the project's preference for plain data over classes.
|
||||
|
||||
**Runtime validation** (check ID format at every function boundary) — catches errors at runtime but not at compile time. Adds overhead and doesn't prevent the bug from being written in the first place.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- Passing a `CreatureId` where a `CombatantId` is expected produces a compile-time error — the bug is caught before the code runs.
|
||||
- Zero runtime cost. The brand is erased during compilation; at runtime, IDs are plain strings.
|
||||
- JSON serialization works naturally — no custom serializers needed for persistence or network transport.
|
||||
- Factory functions (`combatantId()`, `creatureId()`) serve as explicit construction points, making it clear where IDs originate.
|
||||
|
||||
**Negative:**
|
||||
- The `as CombatantId` cast in factory functions is an escape hatch from the type system. If misused (casting arbitrary strings elsewhere), the safety guarantee is undermined. In practice, casts are confined to factory functions and adapter-layer deserialization.
|
||||
- The `__brand` property appears in IDE autocomplete and hover tooltips, which can be confusing for developers unfamiliar with the pattern.
|
||||
- Branded types are a community convention, not a TypeScript language feature. There is no official syntax or standard library support.
|
||||
42
docs/adr/004-on-demand-bestiary-loading.md
Normal file
42
docs/adr/004-on-demand-bestiary-loading.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# ADR-004: On-Demand Bestiary Loading via Compact Index and IndexedDB Cache
|
||||
|
||||
**Date**: 2026-03-25
|
||||
**Status**: accepted
|
||||
|
||||
## Context
|
||||
|
||||
The application integrates a D&D creature bestiary containing 3,300+ creatures from the 5etools dataset. The full bestiary data (stat blocks, traits, actions, spellcasting) is several megabytes of JSON. Bundling it directly into the application would create two problems: a large initial download for every user, and the distribution of copyrighted game content as part of the application bundle.
|
||||
|
||||
## Decision
|
||||
|
||||
The bestiary is split into two tiers:
|
||||
|
||||
1. **Compact search index** (`data/bestiary/index.json`, ~350KB) — shipped with the application bundle. Contains only the fields needed for search and display in the autocomplete dropdown: name, source, AC, HP, DEX, CR, initiative proficiency, size, and type. Field names are abbreviated (`n`, `s`, `ac`, `hp`, `dx`, `cr`, `ip`, `sz`, `tp`) to minimize file size. Generated offline by `scripts/generate-bestiary-index.mjs` from a local clone of the 5etools repository.
|
||||
|
||||
2. **On-demand source data** — full creature stat blocks are fetched per-source when a user first needs them (e.g., when viewing a stat block or adding a creature with HP/AC pre-fill). Fetched data is cached in IndexedDB (`initiative-bestiary` database) via the `idb` library, with an in-memory Map fallback when IndexedDB is unavailable. Users can also upload source files directly or bulk-import all sources.
|
||||
|
||||
The application never bundles or redistributes the full creature data. Users fetch it themselves from their own configured source URLs.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
**Bundle all bestiary data** — simplest approach, used during early development. Eliminated because it would distribute copyrighted content in the application bundle and inflate the initial download by several megabytes. Most users only need a fraction of the available sources.
|
||||
|
||||
**Server-side API** — a backend service could serve creature data on demand. This would keep the client lightweight and solve the bundle size concern, but the copyright issue remains — we would still be distributing copyrighted content, just from a server instead of a bundle. It also contradicts the project's local-first, single-user, no-backend architecture and would require hosting infrastructure and a network dependency for basic functionality.
|
||||
|
||||
**Service Worker with lazy caching** — fetch and cache bestiary data transparently via a Service Worker. More complex to implement and debug than explicit IndexedDB caching. The explicit approach gives users visibility and control over which sources are cached (via the source manager UI).
|
||||
|
||||
**localStorage for caching** — simpler API than IndexedDB, but localStorage has a ~5MB limit per origin, which is insufficient for multiple bestiary sources. IndexedDB has no practical storage limit.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- The application does not distribute copyrighted game content. Users fetch data from their own sources.
|
||||
- Initial bundle stays small (~350KB for the search index). The full bestiary data is only downloaded when needed and then cached locally.
|
||||
- Offline capability: once sources are cached in IndexedDB, creature data is available without network access.
|
||||
- Users have explicit control over cached sources (import, clear, manage via UI).
|
||||
|
||||
**Negative:**
|
||||
- First-time use requires fetching source data before full stat blocks are available. The bulk import feature mitigates this but requires an initial download.
|
||||
- The search index must be regenerated manually when the upstream 5etools dataset changes. In practice this is infrequent (new D&D source books release a few times per year), so a manual process triggered by a new book release is sufficient at this scale.
|
||||
- Two separate data representations (compact index vs full source) must be kept conceptually in sync. A creature that appears in the index but whose source hasn't been fetched will show limited information until the source is cached.
|
||||
- IndexedDB adds adapter complexity (async API, database versioning, migration handling) compared to the synchronous localStorage used for encounter persistence.
|
||||
58
docs/adr/005-all-quality-gates-at-pre-commit.md
Normal file
58
docs/adr/005-all-quality-gates-at-pre-commit.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# ADR-005: All Quality Gates at Pre-Commit
|
||||
|
||||
**Date**: 2026-03-25
|
||||
**Status**: accepted
|
||||
|
||||
## Context
|
||||
|
||||
This project is developed primarily through agentic coding — AI coding agents generate and modify code under human supervision. Agents are highly productive but can drift from established conventions, introduce subtle style inconsistencies, or produce code that compiles but doesn't meet the project's quality standards.
|
||||
|
||||
The conventional approach in most software projects is to keep pre-commit hooks lightweight (formatting, maybe linting) and defer heavier checks (tests, type checking, coverage, copy-paste detection) to CI pipelines. This optimizes for developer speed at commit time.
|
||||
|
||||
However, when working with AI agents, the dynamics are different. Agents iterate quickly and can fix issues immediately — but only if they receive feedback immediately. A failing CI pipeline minutes later breaks the feedback loop: the agent's context has moved on, and the human must re-engage to address the failure.
|
||||
|
||||
## Decision
|
||||
|
||||
All quality gates run at pre-commit via Lefthook, as a single sequential `pnpm check` command. No gate may exist only as a CI step or as a manual process. The full gate sequence is:
|
||||
|
||||
1. `pnpm audit --audit-level=high` — security vulnerability scan
|
||||
2. `knip` — unused code detection
|
||||
3. `biome check .` — linting and formatting (50+ rules)
|
||||
4. `oxlint --tsconfig ... --type-aware` — type-aware linting
|
||||
5. `check-lint-ignores.mjs` — caps biome-ignore directives
|
||||
6. `check-cn-classnames.mjs` — bans template-literal classNames
|
||||
7. `check-component-props.mjs` — max 8 props per component
|
||||
8. `tsc --build` — TypeScript type checking
|
||||
9. `vitest run` — tests with per-path coverage thresholds
|
||||
10. `jscpd` — copy-paste detection
|
||||
|
||||
Layer boundary enforcement runs as a Vitest test within step 9.
|
||||
|
||||
This takes ~8 seconds on the current codebase. Every commit is guaranteed to pass all checks.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
**Lightweight pre-commit, full checks in CI** — the industry default. Pre-commit runs only formatting and basic linting; tests, type checking, and coverage run in a CI pipeline. This is faster at commit time but creates a delayed feedback loop. For agentic coding workflows, this delay is costly: the agent produces a commit, moves on, and the CI failure arrives minutes later when context has shifted. The human must re-engage the agent with the failure context, losing the tight iteration loop.
|
||||
|
||||
**No pre-commit hooks, CI only** — maximum commit speed, all enforcement in CI. Risks accumulating multiple broken commits before issues surface. Particularly problematic with agents that commit frequently.
|
||||
|
||||
**Selective pre-commit (fast checks only)** — run formatting, linting, and type checking at pre-commit; defer tests and coverage to CI as a compromise. Still breaks the feedback loop for test failures and coverage regressions, which are the checks most likely to catch agent-introduced bugs.
|
||||
|
||||
**Per-change hooks (e.g., Claude Code hooks)** — run checks after every file edit or tool call, not just at commit time. This is an even tighter feedback loop than pre-commit: the agent learns about a violation seconds after introducing it, before more code is written on top of it. Claude Code supports hooks that trigger on events like `PostToolUse`, which could run linting or type checking after every file write.
|
||||
|
||||
However, running the full gate after every edit breaks test-driven workflows: writing a test before its implementation, or updating implementation before updating tests, produces intermediate states that legitimately fail type checking or tests. Scoping hooks to only fast, non-breaking checks (formatting, linting) would avoid this, but splits the gate into two tiers — adding complexity for unclear benefit when pre-commit already catches everything within ~8 seconds.
|
||||
|
||||
Pre-commit is the current sweet spot: tight enough that agents get feedback in the same context window, but not so tight that it interferes with red-green-refactor or incremental editing. Per-change hooks remain a future option if the codebase grows to a point where pre-commit becomes too slow.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- Early backpressure in short feedback loops. Agents receive immediate, comprehensive feedback on every commit attempt. If a check fails, the agent can fix it in the same context window, maintaining continuity.
|
||||
- Every commit on `main` is guaranteed to pass all quality gates. There is no state where "it compiled but the tests are broken" or "formatting drifted."
|
||||
- No CI/local divergence. The same checks run everywhere, eliminating "works on my machine" or "CI caught something pre-commit didn't."
|
||||
- Enforces discipline incrementally: each commit is small, clean, and complete rather than "I'll fix the tests later."
|
||||
|
||||
**Negative:**
|
||||
- ~8 seconds per commit attempt. This is acceptable for the current codebase size but will grow with the test suite. If it exceeds ~15 seconds, selective pre-commit with CI for the rest may become necessary.
|
||||
- Developers (or agents) cannot make quick "WIP" or "checkpoint" commits without passing all gates. This is intentional — every commit should be a valid state — but it prevents some workflows like committing broken code to switch branches.
|
||||
- The sequential chain means a failure in step 1 (audit) prevents discovering failures in step 9 (tests). In practice, this rarely matters because failures are fixed immediately and the chain is re-run.
|
||||
@@ -3,7 +3,8 @@
|
||||
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"undici": ">=7.24.0"
|
||||
"undici": ">=7.24.0",
|
||||
"picomatch": ">=4.0.4"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
addCombatant,
|
||||
type CombatantId,
|
||||
type CombatantInit,
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
isDomainError,
|
||||
@@ -11,9 +12,10 @@ export function addCombatantUseCase(
|
||||
store: EncounterStore,
|
||||
id: CombatantId,
|
||||
name: string,
|
||||
init?: CombatantInit,
|
||||
): DomainEvent[] | DomainError {
|
||||
const encounter = store.get();
|
||||
const result = addCombatant(encounter, id, name);
|
||||
const result = addCombatant(encounter, id, name, init);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
|
||||
@@ -15,6 +15,7 @@ export function createPlayerCharacterUseCase(
|
||||
maxHp: number,
|
||||
color: string | undefined,
|
||||
icon: string | undefined,
|
||||
level?: number,
|
||||
): DomainEvent[] | DomainError {
|
||||
const characters = store.getAll();
|
||||
const result = createPlayerCharacter(
|
||||
@@ -25,6 +26,7 @@ export function createPlayerCharacterUseCase(
|
||||
maxHp,
|
||||
color,
|
||||
icon,
|
||||
level,
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
|
||||
@@ -13,6 +13,7 @@ interface EditFields {
|
||||
readonly maxHp?: number;
|
||||
readonly color?: string | null;
|
||||
readonly icon?: string | null;
|
||||
readonly level?: number | null;
|
||||
}
|
||||
|
||||
export function editPlayerCharacterUseCase(
|
||||
|
||||
@@ -10,7 +10,9 @@ export type {
|
||||
BestiarySourceCache,
|
||||
EncounterStore,
|
||||
PlayerCharacterStore,
|
||||
UndoRedoStore,
|
||||
} from "./ports.js";
|
||||
export { redoUseCase } from "./redo-use-case.js";
|
||||
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
||||
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
|
||||
export {
|
||||
@@ -24,3 +26,4 @@ export { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
||||
export { setTempHpUseCase } from "./set-temp-hp-use-case.js";
|
||||
export { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js";
|
||||
export { toggleConditionUseCase } from "./toggle-condition-use-case.js";
|
||||
export { undoUseCase } from "./undo-use-case.js";
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
CreatureId,
|
||||
Encounter,
|
||||
PlayerCharacter,
|
||||
UndoRedoState,
|
||||
} from "@initiative/domain";
|
||||
|
||||
export interface EncounterStore {
|
||||
@@ -19,3 +20,8 @@ export interface PlayerCharacterStore {
|
||||
getAll(): PlayerCharacter[];
|
||||
save(characters: PlayerCharacter[]): void;
|
||||
}
|
||||
|
||||
export interface UndoRedoStore {
|
||||
get(): UndoRedoState;
|
||||
save(state: UndoRedoState): void;
|
||||
}
|
||||
|
||||
24
packages/application/src/redo-use-case.ts
Normal file
24
packages/application/src/redo-use-case.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
type DomainError,
|
||||
type Encounter,
|
||||
isDomainError,
|
||||
redo,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore, UndoRedoStore } from "./ports.js";
|
||||
|
||||
export function redoUseCase(
|
||||
encounterStore: EncounterStore,
|
||||
undoRedoStore: UndoRedoStore,
|
||||
): Encounter | DomainError {
|
||||
const current = encounterStore.get();
|
||||
const state = undoRedoStore.get();
|
||||
const result = redo(state, current);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
encounterStore.save(result.encounter);
|
||||
undoRedoStore.save(result.state);
|
||||
return result.encounter;
|
||||
}
|
||||
24
packages/application/src/undo-use-case.ts
Normal file
24
packages/application/src/undo-use-case.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
type DomainError,
|
||||
type Encounter,
|
||||
isDomainError,
|
||||
undo,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore, UndoRedoStore } from "./ports.js";
|
||||
|
||||
export function undoUseCase(
|
||||
encounterStore: EncounterStore,
|
||||
undoRedoStore: UndoRedoStore,
|
||||
): Encounter | DomainError {
|
||||
const current = encounterStore.get();
|
||||
const state = undoRedoStore.get();
|
||||
const result = undo(state, current);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
encounterStore.save(result.encounter);
|
||||
undoRedoStore.save(result.state);
|
||||
return result.encounter;
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { addCombatant } from "../add-combatant.js";
|
||||
import { addCombatant, type CombatantInit } from "../add-combatant.js";
|
||||
import { creatureId } from "../creature-types.js";
|
||||
import { playerCharacterId } from "../player-character-types.js";
|
||||
import type { Combatant, Encounter } from "../types.js";
|
||||
import { combatantId, isDomainError } from "../types.js";
|
||||
import { expectDomainError } from "./test-helpers.js";
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function makeCombatant(name: string): Combatant {
|
||||
return { id: combatantId(name), name };
|
||||
function makeCombatant(
|
||||
name: string,
|
||||
overrides?: Partial<Combatant>,
|
||||
): Combatant {
|
||||
return { id: combatantId(name), name, ...overrides };
|
||||
}
|
||||
|
||||
const A = makeCombatant("A");
|
||||
@@ -22,8 +27,13 @@ function enc(
|
||||
return { combatants, activeIndex, roundNumber };
|
||||
}
|
||||
|
||||
function successResult(encounter: Encounter, id: string, name: string) {
|
||||
const result = addCombatant(encounter, combatantId(id), name);
|
||||
function successResult(
|
||||
encounter: Encounter,
|
||||
id: string,
|
||||
name: string,
|
||||
init?: CombatantInit,
|
||||
) {
|
||||
const result = addCombatant(encounter, combatantId(id), name, init);
|
||||
if (isDomainError(result)) {
|
||||
throw new Error(`Expected success, got error: ${result.message}`);
|
||||
}
|
||||
@@ -190,4 +200,152 @@ describe("addCombatant", () => {
|
||||
expect(encounter.combatants[1]).toEqual(B);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with CombatantInit", () => {
|
||||
it("creates combatant with maxHp and currentHp set to maxHp", () => {
|
||||
const e = enc([]);
|
||||
const { encounter } = successResult(e, "orc", "Orc", {
|
||||
maxHp: 15,
|
||||
});
|
||||
const c = encounter.combatants[0];
|
||||
expect(c.maxHp).toBe(15);
|
||||
expect(c.currentHp).toBe(15);
|
||||
});
|
||||
|
||||
it("creates combatant with ac", () => {
|
||||
const e = enc([]);
|
||||
const { encounter } = successResult(e, "orc", "Orc", {
|
||||
ac: 13,
|
||||
});
|
||||
expect(encounter.combatants[0].ac).toBe(13);
|
||||
});
|
||||
|
||||
it("creates combatant with initiative and sorts into position", () => {
|
||||
const hi = makeCombatant("Hi", { initiative: 20 });
|
||||
const lo = makeCombatant("Lo", { initiative: 10 });
|
||||
const e = enc([hi, lo]);
|
||||
|
||||
const { encounter } = successResult(e, "mid", "Mid", {
|
||||
initiative: 15,
|
||||
});
|
||||
|
||||
expect(encounter.combatants.map((c) => c.name)).toEqual([
|
||||
"Hi",
|
||||
"Mid",
|
||||
"Lo",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects invalid maxHp (non-integer)", () => {
|
||||
const e = enc([]);
|
||||
const result = addCombatant(e, combatantId("x"), "X", {
|
||||
maxHp: 1.5,
|
||||
});
|
||||
expectDomainError(result, "invalid-max-hp");
|
||||
});
|
||||
|
||||
it("rejects invalid maxHp (zero)", () => {
|
||||
const e = enc([]);
|
||||
const result = addCombatant(e, combatantId("x"), "X", {
|
||||
maxHp: 0,
|
||||
});
|
||||
expectDomainError(result, "invalid-max-hp");
|
||||
});
|
||||
|
||||
it("rejects invalid ac (negative)", () => {
|
||||
const e = enc([]);
|
||||
const result = addCombatant(e, combatantId("x"), "X", {
|
||||
ac: -1,
|
||||
});
|
||||
expectDomainError(result, "invalid-ac");
|
||||
});
|
||||
|
||||
it("rejects invalid initiative (non-integer)", () => {
|
||||
const e = enc([]);
|
||||
const result = addCombatant(e, combatantId("x"), "X", {
|
||||
initiative: 3.5,
|
||||
});
|
||||
expectDomainError(result, "invalid-initiative");
|
||||
});
|
||||
|
||||
it("creates combatant with creatureId", () => {
|
||||
const e = enc([]);
|
||||
const cId = creatureId("srd:goblin");
|
||||
const { encounter } = successResult(e, "gob", "Goblin", {
|
||||
creatureId: cId,
|
||||
});
|
||||
expect(encounter.combatants[0].creatureId).toBe(cId);
|
||||
});
|
||||
|
||||
it("creates combatant with color and icon", () => {
|
||||
const e = enc([]);
|
||||
const { encounter } = successResult(e, "pc", "Aria", {
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
});
|
||||
const c = encounter.combatants[0];
|
||||
expect(c.color).toBe("blue");
|
||||
expect(c.icon).toBe("sword");
|
||||
});
|
||||
|
||||
it("creates combatant with playerCharacterId", () => {
|
||||
const e = enc([]);
|
||||
const pcId = playerCharacterId("pc-1");
|
||||
const { encounter } = successResult(e, "pc", "Aria", {
|
||||
playerCharacterId: pcId,
|
||||
});
|
||||
expect(encounter.combatants[0].playerCharacterId).toBe(pcId);
|
||||
});
|
||||
|
||||
it("creates combatant with all init fields", () => {
|
||||
const e = enc([]);
|
||||
const cId = creatureId("srd:orc");
|
||||
const pcId = playerCharacterId("pc-1");
|
||||
const { encounter } = successResult(e, "orc", "Orc", {
|
||||
maxHp: 15,
|
||||
ac: 13,
|
||||
initiative: 12,
|
||||
creatureId: cId,
|
||||
color: "red",
|
||||
icon: "axe",
|
||||
playerCharacterId: pcId,
|
||||
});
|
||||
const c = encounter.combatants[0];
|
||||
expect(c.maxHp).toBe(15);
|
||||
expect(c.currentHp).toBe(15);
|
||||
expect(c.ac).toBe(13);
|
||||
expect(c.initiative).toBe(12);
|
||||
expect(c.creatureId).toBe(cId);
|
||||
expect(c.color).toBe("red");
|
||||
expect(c.icon).toBe("axe");
|
||||
expect(c.playerCharacterId).toBe(pcId);
|
||||
});
|
||||
|
||||
it("CombatantAdded event includes init", () => {
|
||||
const e = enc([]);
|
||||
const { events } = successResult(e, "orc", "Orc", {
|
||||
maxHp: 15,
|
||||
ac: 13,
|
||||
});
|
||||
expect(events[0]).toMatchObject({
|
||||
type: "CombatantAdded",
|
||||
init: { maxHp: 15, ac: 13 },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves activeIndex through initiative sort", () => {
|
||||
const hi = makeCombatant("Hi", { initiative: 20 });
|
||||
const lo = makeCombatant("Lo", { initiative: 10 });
|
||||
// Lo is active (index 1)
|
||||
const e = enc([hi, lo], 1);
|
||||
|
||||
const { encounter } = successResult(e, "mid", "Mid", {
|
||||
initiative: 15,
|
||||
});
|
||||
|
||||
// Lo should still be active after sort
|
||||
const loIdx = encounter.combatants.findIndex((c) => c.name === "Lo");
|
||||
expect(encounter.activeIndex).toBe(loIdx);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CONDITION_DEFINITIONS,
|
||||
getConditionDescription,
|
||||
getConditionsForEdition,
|
||||
} from "../conditions.js";
|
||||
|
||||
function findCondition(id: string) {
|
||||
@@ -25,13 +26,27 @@ describe("getConditionDescription", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("every condition has both descriptions", () => {
|
||||
for (const def of CONDITION_DEFINITIONS) {
|
||||
it("universal conditions have both descriptions", () => {
|
||||
const universal = CONDITION_DEFINITIONS.filter(
|
||||
(d) => d.edition === undefined,
|
||||
);
|
||||
expect(universal.length).toBeGreaterThan(0);
|
||||
for (const def of universal) {
|
||||
expect(def.description).toBeTruthy();
|
||||
expect(def.description5e).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("edition-specific conditions have their edition description", () => {
|
||||
const sapped = findCondition("sapped");
|
||||
expect(sapped.description).toBeTruthy();
|
||||
expect(sapped.edition).toBe("5.5e");
|
||||
|
||||
const slowed = findCondition("slowed");
|
||||
expect(slowed.description).toBeTruthy();
|
||||
expect(slowed.edition).toBe("5.5e");
|
||||
});
|
||||
|
||||
it("conditions with identical rules share the same text", () => {
|
||||
const blinded = findCondition("blinded");
|
||||
expect(blinded.description).toBe(blinded.description5e);
|
||||
@@ -42,3 +57,26 @@ describe("getConditionDescription", () => {
|
||||
expect(exhaustion.description).not.toBe(exhaustion.description5e);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getConditionsForEdition", () => {
|
||||
it("includes sapped and slowed for 5.5e", () => {
|
||||
const conditions = getConditionsForEdition("5.5e");
|
||||
const ids = conditions.map((d) => d.id);
|
||||
expect(ids).toContain("sapped");
|
||||
expect(ids).toContain("slowed");
|
||||
});
|
||||
|
||||
it("excludes sapped and slowed for 5e", () => {
|
||||
const conditions = getConditionsForEdition("5e");
|
||||
const ids = conditions.map((d) => d.id);
|
||||
expect(ids).not.toContain("sapped");
|
||||
expect(ids).not.toContain("slowed");
|
||||
});
|
||||
|
||||
it("includes universal conditions for both editions", () => {
|
||||
const ids5e = getConditionsForEdition("5e").map((d) => d.id);
|
||||
const ids55e = getConditionsForEdition("5.5e").map((d) => d.id);
|
||||
expect(ids5e).toContain("blinded");
|
||||
expect(ids55e).toContain("blinded");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ function success(
|
||||
maxHp,
|
||||
color,
|
||||
icon,
|
||||
undefined,
|
||||
);
|
||||
if (isDomainError(result)) {
|
||||
throw new Error(`Expected success, got error: ${result.message}`);
|
||||
@@ -241,4 +242,76 @@ describe("createPlayerCharacter", () => {
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].type).toBe("PlayerCharacterCreated");
|
||||
});
|
||||
|
||||
it("creates a player character with a valid level", () => {
|
||||
const result = createPlayerCharacter(
|
||||
[],
|
||||
id,
|
||||
"Test",
|
||||
10,
|
||||
50,
|
||||
"blue",
|
||||
"sword",
|
||||
5,
|
||||
);
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
expect(result.characters[0].level).toBe(5);
|
||||
});
|
||||
|
||||
it("creates a player character without a level", () => {
|
||||
const result = createPlayerCharacter(
|
||||
[],
|
||||
id,
|
||||
"Test",
|
||||
10,
|
||||
50,
|
||||
"blue",
|
||||
"sword",
|
||||
undefined,
|
||||
);
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
expect(result.characters[0].level).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects level below 1", () => {
|
||||
const result = createPlayerCharacter(
|
||||
[],
|
||||
id,
|
||||
"Test",
|
||||
10,
|
||||
50,
|
||||
"blue",
|
||||
"sword",
|
||||
0,
|
||||
);
|
||||
expectDomainError(result, "invalid-level");
|
||||
});
|
||||
|
||||
it("rejects level above 20", () => {
|
||||
const result = createPlayerCharacter(
|
||||
[],
|
||||
id,
|
||||
"Test",
|
||||
10,
|
||||
50,
|
||||
"blue",
|
||||
"sword",
|
||||
21,
|
||||
);
|
||||
expectDomainError(result, "invalid-level");
|
||||
});
|
||||
|
||||
it("rejects non-integer level", () => {
|
||||
const result = createPlayerCharacter(
|
||||
[],
|
||||
id,
|
||||
"Test",
|
||||
10,
|
||||
50,
|
||||
"blue",
|
||||
"sword",
|
||||
3.5,
|
||||
);
|
||||
expectDomainError(result, "invalid-level");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,4 +110,33 @@ describe("editPlayerCharacter", () => {
|
||||
expect(event.oldName).toBe("Aragorn");
|
||||
expect(event.newName).toBe("Strider");
|
||||
});
|
||||
|
||||
it("sets level on a player character", () => {
|
||||
const result = editPlayerCharacter([makePC()], id, { level: 5 });
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
expect(result.characters[0].level).toBe(5);
|
||||
});
|
||||
|
||||
it("clears level when set to null", () => {
|
||||
const result = editPlayerCharacter([makePC({ level: 5 })], id, {
|
||||
level: null,
|
||||
});
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
expect(result.characters[0].level).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects invalid level", () => {
|
||||
const result = editPlayerCharacter([makePC()], id, { level: 0 });
|
||||
expectDomainError(result, "invalid-level");
|
||||
});
|
||||
|
||||
it("rejects level above 20", () => {
|
||||
const result = editPlayerCharacter([makePC()], id, { level: 21 });
|
||||
expectDomainError(result, "invalid-level");
|
||||
});
|
||||
|
||||
it("rejects non-integer level", () => {
|
||||
const result = editPlayerCharacter([makePC()], id, { level: 3.5 });
|
||||
expectDomainError(result, "invalid-level");
|
||||
});
|
||||
});
|
||||
|
||||
133
packages/domain/src/__tests__/encounter-difficulty.test.ts
Normal file
133
packages/domain/src/__tests__/encounter-difficulty.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
calculateEncounterDifficulty,
|
||||
crToXp,
|
||||
} from "../encounter-difficulty.js";
|
||||
|
||||
describe("crToXp", () => {
|
||||
it("returns 0 for CR 0", () => {
|
||||
expect(crToXp("0")).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 25 for CR 1/8", () => {
|
||||
expect(crToXp("1/8")).toBe(25);
|
||||
});
|
||||
|
||||
it("returns 50 for CR 1/4", () => {
|
||||
expect(crToXp("1/4")).toBe(50);
|
||||
});
|
||||
|
||||
it("returns 100 for CR 1/2", () => {
|
||||
expect(crToXp("1/2")).toBe(100);
|
||||
});
|
||||
|
||||
it("returns 200 for CR 1", () => {
|
||||
expect(crToXp("1")).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 155000 for CR 30", () => {
|
||||
expect(crToXp("30")).toBe(155000);
|
||||
});
|
||||
|
||||
it("returns 0 for unknown CR", () => {
|
||||
expect(crToXp("99")).toBe(0);
|
||||
expect(crToXp("")).toBe(0);
|
||||
expect(crToXp("abc")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateEncounterDifficulty", () => {
|
||||
it("returns trivial when monster XP is below Low threshold", () => {
|
||||
// 4x level 1: Low = 200, Moderate = 300, High = 400
|
||||
// 1x CR 0 = 0 XP → trivial
|
||||
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["0"]);
|
||||
expect(result.tier).toBe("trivial");
|
||||
expect(result.totalMonsterXp).toBe(0);
|
||||
expect(result.partyBudget).toEqual({
|
||||
low: 200,
|
||||
moderate: 300,
|
||||
high: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns low for 4x level 1 vs Bugbear (CR 1)", () => {
|
||||
// DMG example: 4x level 1 PCs vs 1 Bugbear (CR 1, 200 XP)
|
||||
// Low = 200, Moderate = 300 → 200 >= 200 but < 300 → Low
|
||||
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["1"]);
|
||||
expect(result.tier).toBe("low");
|
||||
expect(result.totalMonsterXp).toBe(200);
|
||||
});
|
||||
|
||||
it("returns moderate for 5x level 3 vs 1125 XP", () => {
|
||||
// 5x level 3: Low = 750, Moderate = 1125, High = 2000
|
||||
// 1125 XP >= 1125 Moderate but < 2000 High → Moderate
|
||||
// Using CR 3 (700) + CR 2 (450) = 1150 XP ≈ 1125 threshold
|
||||
// Let's use exact: 5 * 225 = 1125 moderate budget
|
||||
// Need monsters that sum exactly to 1125: CR 3 (700) + CR 2 (450) = 1150
|
||||
const result = calculateEncounterDifficulty([3, 3, 3, 3, 3], ["3", "2"]);
|
||||
expect(result.tier).toBe("moderate");
|
||||
expect(result.totalMonsterXp).toBe(1150);
|
||||
expect(result.partyBudget.moderate).toBe(1125);
|
||||
});
|
||||
|
||||
it("returns high when XP meets High threshold", () => {
|
||||
// 4x level 1: High = 400
|
||||
// 2x CR 1 = 400 XP → High
|
||||
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["1", "1"]);
|
||||
expect(result.tier).toBe("high");
|
||||
expect(result.totalMonsterXp).toBe(400);
|
||||
});
|
||||
|
||||
it("caps at high when XP far exceeds threshold", () => {
|
||||
// 4x level 1: High = 400
|
||||
// CR 30 = 155000 XP → still High (no tier above)
|
||||
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["30"]);
|
||||
expect(result.tier).toBe("high");
|
||||
expect(result.totalMonsterXp).toBe(155000);
|
||||
});
|
||||
|
||||
it("handles mixed party levels", () => {
|
||||
// 3x level 3 + 1x level 2
|
||||
// level 3: low=150, mod=225, high=400 (x3 = 450, 675, 1200)
|
||||
// level 2: low=100, mod=150, high=200 (x1 = 100, 150, 200)
|
||||
// Total: low=550, mod=825, high=1400
|
||||
const result = calculateEncounterDifficulty([3, 3, 3, 2], ["3"]);
|
||||
expect(result.partyBudget).toEqual({
|
||||
low: 550,
|
||||
moderate: 825,
|
||||
high: 1400,
|
||||
});
|
||||
expect(result.totalMonsterXp).toBe(700);
|
||||
expect(result.tier).toBe("low");
|
||||
});
|
||||
|
||||
it("returns trivial with empty monster array", () => {
|
||||
const result = calculateEncounterDifficulty([5, 5], []);
|
||||
expect(result.tier).toBe("trivial");
|
||||
expect(result.totalMonsterXp).toBe(0);
|
||||
});
|
||||
|
||||
it("returns high with empty party array (zero budget thresholds)", () => {
|
||||
// Domain function treats empty party as zero budgets — any XP exceeds all thresholds.
|
||||
// The useDifficulty hook guards this path by returning null when no leveled PCs exist.
|
||||
const result = calculateEncounterDifficulty([], ["1"]);
|
||||
expect(result.tier).toBe("high");
|
||||
expect(result.totalMonsterXp).toBe(200);
|
||||
expect(result.partyBudget).toEqual({ low: 0, moderate: 0, high: 0 });
|
||||
});
|
||||
|
||||
it("handles fractional CRs", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[1, 1, 1, 1],
|
||||
["1/8", "1/4", "1/2"],
|
||||
);
|
||||
expect(result.totalMonsterXp).toBe(175); // 25 + 50 + 100
|
||||
expect(result.tier).toBe("trivial"); // 175 < 200 Low
|
||||
});
|
||||
|
||||
it("ignores unknown CRs (0 XP)", () => {
|
||||
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["unknown"]);
|
||||
expect(result.totalMonsterXp).toBe(0);
|
||||
expect(result.tier).toBe("trivial");
|
||||
});
|
||||
});
|
||||
124
packages/domain/src/__tests__/undo-redo.test.ts
Normal file
124
packages/domain/src/__tests__/undo-redo.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Encounter } from "../types.js";
|
||||
import { isDomainError } from "../types.js";
|
||||
import {
|
||||
clearHistory,
|
||||
EMPTY_UNDO_REDO_STATE,
|
||||
pushUndo,
|
||||
redo,
|
||||
undo,
|
||||
} from "../undo-redo.js";
|
||||
|
||||
function enc(roundNumber = 1, activeIndex = 0): Encounter {
|
||||
return { combatants: [], activeIndex, roundNumber };
|
||||
}
|
||||
|
||||
describe("pushUndo", () => {
|
||||
it("adds a snapshot to the undo stack", () => {
|
||||
const result = pushUndo(EMPTY_UNDO_REDO_STATE, enc(1));
|
||||
expect(result.undoStack).toHaveLength(1);
|
||||
expect(result.undoStack[0]).toEqual(enc(1));
|
||||
});
|
||||
|
||||
it("clears the redo stack", () => {
|
||||
const state = {
|
||||
undoStack: [enc(1)],
|
||||
redoStack: [enc(2)],
|
||||
};
|
||||
const result = pushUndo(state, enc(3));
|
||||
expect(result.redoStack).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("caps the undo stack at 50, dropping the oldest", () => {
|
||||
const undoStack = Array.from({ length: 50 }, (_, i) => enc(i + 1));
|
||||
const state = { undoStack, redoStack: [] };
|
||||
const result = pushUndo(state, enc(51));
|
||||
expect(result.undoStack).toHaveLength(50);
|
||||
expect(result.undoStack[0]).toEqual(enc(2));
|
||||
expect(result.undoStack[49]).toEqual(enc(51));
|
||||
});
|
||||
});
|
||||
|
||||
describe("undo", () => {
|
||||
it("pops from undo stack and pushes current to redo stack", () => {
|
||||
const state = { undoStack: [enc(1)], redoStack: [] };
|
||||
const result = undo(state, enc(2));
|
||||
expect(isDomainError(result)).toBe(false);
|
||||
if (isDomainError(result)) return;
|
||||
expect(result.encounter).toEqual(enc(1));
|
||||
expect(result.state.undoStack).toHaveLength(0);
|
||||
expect(result.state.redoStack).toEqual([enc(2)]);
|
||||
});
|
||||
|
||||
it("returns domain error when undo stack is empty", () => {
|
||||
const result = undo(EMPTY_UNDO_REDO_STATE, enc(1));
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (!isDomainError(result)) return;
|
||||
expect(result.code).toBe("nothing-to-undo");
|
||||
});
|
||||
|
||||
it("pops the most recent entry (last in stack)", () => {
|
||||
const state = { undoStack: [enc(1), enc(2), enc(3)], redoStack: [] };
|
||||
const result = undo(state, enc(4));
|
||||
expect(isDomainError(result)).toBe(false);
|
||||
if (isDomainError(result)) return;
|
||||
expect(result.encounter).toEqual(enc(3));
|
||||
expect(result.state.undoStack).toEqual([enc(1), enc(2)]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("redo", () => {
|
||||
it("pops from redo stack and pushes current to undo stack", () => {
|
||||
const state = { undoStack: [], redoStack: [enc(1)] };
|
||||
const result = redo(state, enc(2));
|
||||
expect(isDomainError(result)).toBe(false);
|
||||
if (isDomainError(result)) return;
|
||||
expect(result.encounter).toEqual(enc(1));
|
||||
expect(result.state.redoStack).toHaveLength(0);
|
||||
expect(result.state.undoStack).toEqual([enc(2)]);
|
||||
});
|
||||
|
||||
it("returns domain error when redo stack is empty", () => {
|
||||
const result = redo(EMPTY_UNDO_REDO_STATE, enc(1));
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (!isDomainError(result)) return;
|
||||
expect(result.code).toBe("nothing-to-redo");
|
||||
});
|
||||
|
||||
it("pops the most recent entry (last in stack)", () => {
|
||||
const state = { undoStack: [], redoStack: [enc(1), enc(2), enc(3)] };
|
||||
const result = redo(state, enc(4));
|
||||
expect(isDomainError(result)).toBe(false);
|
||||
if (isDomainError(result)) return;
|
||||
expect(result.encounter).toEqual(enc(3));
|
||||
expect(result.state.redoStack).toEqual([enc(1), enc(2)]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("undo-then-redo roundtrip", () => {
|
||||
it("returns the exact same encounter after undo then redo", () => {
|
||||
const original = enc(5);
|
||||
const current = enc(6);
|
||||
const afterPush = pushUndo(EMPTY_UNDO_REDO_STATE, original);
|
||||
|
||||
const undoResult = undo(afterPush, current);
|
||||
expect(isDomainError(undoResult)).toBe(false);
|
||||
if (isDomainError(undoResult)) return;
|
||||
|
||||
expect(undoResult.encounter).toEqual(original);
|
||||
|
||||
const redoResult = redo(undoResult.state, undoResult.encounter);
|
||||
expect(isDomainError(redoResult)).toBe(false);
|
||||
if (isDomainError(redoResult)) return;
|
||||
|
||||
expect(redoResult.encounter).toEqual(current);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearHistory", () => {
|
||||
it("empties both stacks", () => {
|
||||
const result = clearHistory();
|
||||
expect(result.undoStack).toHaveLength(0);
|
||||
expect(result.redoStack).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,94 @@
|
||||
import type { CreatureId } from "./creature-types.js";
|
||||
import type { DomainEvent } from "./events.js";
|
||||
import type { CombatantId, DomainError, Encounter } from "./types.js";
|
||||
import { sortByInitiative } from "./initiative-sort.js";
|
||||
import type { PlayerCharacterId } from "./player-character-types.js";
|
||||
import type {
|
||||
Combatant,
|
||||
CombatantId,
|
||||
DomainError,
|
||||
Encounter,
|
||||
} from "./types.js";
|
||||
|
||||
export interface CombatantInit {
|
||||
readonly maxHp?: number;
|
||||
readonly ac?: number;
|
||||
readonly initiative?: number;
|
||||
readonly creatureId?: CreatureId;
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
readonly playerCharacterId?: PlayerCharacterId;
|
||||
}
|
||||
|
||||
export interface AddCombatantSuccess {
|
||||
readonly encounter: Encounter;
|
||||
readonly events: DomainEvent[];
|
||||
}
|
||||
|
||||
function validateInit(init: CombatantInit): DomainError | undefined {
|
||||
if (
|
||||
init.maxHp !== undefined &&
|
||||
(!Number.isInteger(init.maxHp) || init.maxHp < 1)
|
||||
) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-max-hp",
|
||||
message: `Max HP must be a positive integer, got ${init.maxHp}`,
|
||||
};
|
||||
}
|
||||
if (init.ac !== undefined && (!Number.isInteger(init.ac) || init.ac < 0)) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-ac",
|
||||
message: `AC must be a non-negative integer, got ${init.ac}`,
|
||||
};
|
||||
}
|
||||
if (init.initiative !== undefined && !Number.isInteger(init.initiative)) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-initiative",
|
||||
message: `Initiative must be an integer, got ${init.initiative}`,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildCombatant(
|
||||
id: CombatantId,
|
||||
name: string,
|
||||
init?: CombatantInit,
|
||||
): Combatant {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
...(init?.maxHp !== undefined && {
|
||||
maxHp: init.maxHp,
|
||||
currentHp: init.maxHp,
|
||||
}),
|
||||
...(init?.ac !== undefined && { ac: init.ac }),
|
||||
...(init?.initiative !== undefined && { initiative: init.initiative }),
|
||||
...(init?.creatureId !== undefined && { creatureId: init.creatureId }),
|
||||
...(init?.color !== undefined && { color: init.color }),
|
||||
...(init?.icon !== undefined && { icon: init.icon }),
|
||||
...(init?.playerCharacterId !== undefined && {
|
||||
playerCharacterId: init.playerCharacterId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function that adds a combatant to the end of an encounter's list.
|
||||
*
|
||||
* FR-001: Accepts an Encounter, CombatantId, and name; returns next state + events.
|
||||
* FR-002: Appends new combatant to end of combatants list.
|
||||
* FR-004: Rejects empty/whitespace-only names with DomainError.
|
||||
* FR-005: Does not alter activeIndex or roundNumber.
|
||||
* FR-005: Does not alter activeIndex or roundNumber (unless initiative triggers sort).
|
||||
* FR-006: Events returned as values, not dispatched via side effects.
|
||||
*/
|
||||
export function addCombatant(
|
||||
encounter: Encounter,
|
||||
id: CombatantId,
|
||||
name: string,
|
||||
init?: CombatantInit,
|
||||
): AddCombatantSuccess | DomainError {
|
||||
const trimmed = name.trim();
|
||||
|
||||
@@ -30,12 +100,35 @@ export function addCombatant(
|
||||
};
|
||||
}
|
||||
|
||||
const position = encounter.combatants.length;
|
||||
if (init) {
|
||||
const error = validateInit(init);
|
||||
if (error) return error;
|
||||
}
|
||||
|
||||
const newCombatant = buildCombatant(id, trimmed, init);
|
||||
let combatants: readonly Combatant[] = [
|
||||
...encounter.combatants,
|
||||
newCombatant,
|
||||
];
|
||||
let activeIndex = encounter.activeIndex;
|
||||
|
||||
if (init?.initiative !== undefined) {
|
||||
const activeCombatantId =
|
||||
encounter.combatants.length > 0
|
||||
? encounter.combatants[encounter.activeIndex].id
|
||||
: id;
|
||||
|
||||
const result = sortByInitiative(combatants, activeCombatantId);
|
||||
combatants = result.sorted;
|
||||
activeIndex = result.activeIndex;
|
||||
}
|
||||
|
||||
const position = combatants.findIndex((c) => c.id === id);
|
||||
|
||||
return {
|
||||
encounter: {
|
||||
combatants: [...encounter.combatants, { id, name: trimmed }],
|
||||
activeIndex: encounter.activeIndex,
|
||||
combatants,
|
||||
activeIndex,
|
||||
roundNumber: encounter.roundNumber,
|
||||
},
|
||||
events: [
|
||||
@@ -44,6 +137,7 @@ export function addCombatant(
|
||||
combatantId: id,
|
||||
name: trimmed,
|
||||
position,
|
||||
init,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -12,6 +12,8 @@ export type ConditionId =
|
||||
| "poisoned"
|
||||
| "prone"
|
||||
| "restrained"
|
||||
| "sapped"
|
||||
| "slowed"
|
||||
| "stunned"
|
||||
| "unconscious";
|
||||
|
||||
@@ -24,6 +26,8 @@ export interface ConditionDefinition {
|
||||
readonly description5e: string;
|
||||
readonly iconName: string;
|
||||
readonly color: string;
|
||||
/** When set, the condition only appears in this edition's picker. */
|
||||
readonly edition?: RulesEdition;
|
||||
}
|
||||
|
||||
export function getConditionDescription(
|
||||
@@ -159,6 +163,26 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||
iconName: "Link",
|
||||
color: "neutral",
|
||||
},
|
||||
{
|
||||
id: "sapped",
|
||||
label: "Sapped",
|
||||
description:
|
||||
"Disadvantage on next attack roll before the start of your next turn. (Weapon Mastery: Sap)",
|
||||
description5e: "",
|
||||
iconName: "ShieldMinus",
|
||||
color: "amber",
|
||||
edition: "5.5e",
|
||||
},
|
||||
{
|
||||
id: "slowed",
|
||||
label: "Slowed",
|
||||
description:
|
||||
"Speed reduced by 10 ft. until the start of your next turn. (Weapon Mastery: Slow)",
|
||||
description5e: "",
|
||||
iconName: "Snail",
|
||||
color: "sky",
|
||||
edition: "5.5e",
|
||||
},
|
||||
{
|
||||
id: "stunned",
|
||||
label: "Stunned",
|
||||
@@ -184,3 +208,11 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||
export const VALID_CONDITION_IDS: ReadonlySet<string> = new Set(
|
||||
CONDITION_DEFINITIONS.map((d) => d.id),
|
||||
);
|
||||
|
||||
export function getConditionsForEdition(
|
||||
edition: RulesEdition,
|
||||
): readonly ConditionDefinition[] {
|
||||
return CONDITION_DEFINITIONS.filter(
|
||||
(d) => d.edition === undefined || d.edition === edition,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export function createPlayerCharacter(
|
||||
maxHp: number,
|
||||
color: string | undefined,
|
||||
icon: string | undefined,
|
||||
level?: number,
|
||||
): CreatePlayerCharacterSuccess | DomainError {
|
||||
const trimmed = name.trim();
|
||||
|
||||
@@ -65,6 +66,17 @@ export function createPlayerCharacter(
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
level !== undefined &&
|
||||
(!Number.isInteger(level) || level < 1 || level > 20)
|
||||
) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-level",
|
||||
message: "Level must be an integer between 1 and 20",
|
||||
};
|
||||
}
|
||||
|
||||
const newCharacter: PlayerCharacter = {
|
||||
id,
|
||||
name: trimmed,
|
||||
@@ -72,6 +84,7 @@ export function createPlayerCharacter(
|
||||
maxHp,
|
||||
color: color as PlayerCharacter["color"],
|
||||
icon: icon as PlayerCharacter["icon"],
|
||||
level,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -20,6 +20,7 @@ interface EditFields {
|
||||
readonly maxHp?: number;
|
||||
readonly color?: string | null;
|
||||
readonly icon?: string | null;
|
||||
readonly level?: number | null;
|
||||
}
|
||||
|
||||
function validateFields(fields: EditFields): DomainError | null {
|
||||
@@ -72,6 +73,17 @@ function validateFields(fields: EditFields): DomainError | null {
|
||||
message: `Invalid icon: ${fields.icon}`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
fields.level !== undefined &&
|
||||
fields.level !== null &&
|
||||
(!Number.isInteger(fields.level) || fields.level < 1 || fields.level > 20)
|
||||
) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-level",
|
||||
message: "Level must be an integer between 1 and 20",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -92,6 +104,8 @@ function applyFields(
|
||||
fields.icon === undefined
|
||||
? existing.icon
|
||||
: ((fields.icon as PlayerCharacter["icon"]) ?? undefined),
|
||||
level:
|
||||
fields.level === undefined ? existing.level : (fields.level ?? undefined),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,7 +134,8 @@ export function editPlayerCharacter(
|
||||
updated.ac === existing.ac &&
|
||||
updated.maxHp === existing.maxHp &&
|
||||
updated.color === existing.color &&
|
||||
updated.icon === existing.icon
|
||||
updated.icon === existing.icon &&
|
||||
updated.level === existing.level
|
||||
) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
|
||||
126
packages/domain/src/encounter-difficulty.ts
Normal file
126
packages/domain/src/encounter-difficulty.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
export type DifficultyTier = "trivial" | "low" | "moderate" | "high";
|
||||
|
||||
export interface DifficultyResult {
|
||||
readonly tier: DifficultyTier;
|
||||
readonly totalMonsterXp: number;
|
||||
readonly partyBudget: {
|
||||
readonly low: number;
|
||||
readonly moderate: number;
|
||||
readonly high: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Maps challenge rating strings to XP values (standard 5e). */
|
||||
const CR_TO_XP: Readonly<Record<string, number>> = {
|
||||
"0": 0,
|
||||
"1/8": 25,
|
||||
"1/4": 50,
|
||||
"1/2": 100,
|
||||
"1": 200,
|
||||
"2": 450,
|
||||
"3": 700,
|
||||
"4": 1100,
|
||||
"5": 1800,
|
||||
"6": 2300,
|
||||
"7": 2900,
|
||||
"8": 3900,
|
||||
"9": 5000,
|
||||
"10": 5900,
|
||||
"11": 7200,
|
||||
"12": 8400,
|
||||
"13": 10000,
|
||||
"14": 11500,
|
||||
"15": 13000,
|
||||
"16": 15000,
|
||||
"17": 18000,
|
||||
"18": 20000,
|
||||
"19": 22000,
|
||||
"20": 25000,
|
||||
"21": 33000,
|
||||
"22": 41000,
|
||||
"23": 50000,
|
||||
"24": 62000,
|
||||
"25": 75000,
|
||||
"26": 90000,
|
||||
"27": 105000,
|
||||
"28": 120000,
|
||||
"29": 135000,
|
||||
"30": 155000,
|
||||
};
|
||||
|
||||
/** Maps character level (1-20) to XP budget thresholds (2024 5.5e DMG). */
|
||||
const XP_BUDGET_PER_CHARACTER: Readonly<
|
||||
Record<number, { low: number; moderate: number; high: number }>
|
||||
> = {
|
||||
1: { low: 50, moderate: 75, high: 100 },
|
||||
2: { low: 100, moderate: 150, high: 200 },
|
||||
3: { low: 150, moderate: 225, high: 400 },
|
||||
4: { low: 250, moderate: 375, high: 500 },
|
||||
5: { low: 500, moderate: 750, high: 1100 },
|
||||
6: { low: 600, moderate: 1000, high: 1400 },
|
||||
7: { low: 750, moderate: 1300, high: 1700 },
|
||||
8: { low: 1000, moderate: 1700, high: 2100 },
|
||||
9: { low: 1300, moderate: 2000, high: 2600 },
|
||||
10: { low: 1600, moderate: 2300, high: 3100 },
|
||||
11: { low: 1900, moderate: 2900, high: 4100 },
|
||||
12: { low: 2200, moderate: 3700, high: 4700 },
|
||||
13: { low: 2600, moderate: 4200, high: 5400 },
|
||||
14: { low: 2900, moderate: 4900, high: 6200 },
|
||||
15: { low: 3300, moderate: 5400, high: 7800 },
|
||||
16: { low: 3800, moderate: 6100, high: 9800 },
|
||||
17: { low: 4500, moderate: 7200, high: 11700 },
|
||||
18: { low: 5000, moderate: 8700, high: 14200 },
|
||||
19: { low: 5500, moderate: 10700, high: 17200 },
|
||||
20: { low: 6400, moderate: 13200, high: 22000 },
|
||||
};
|
||||
|
||||
/** Returns the XP value for a given CR string. Returns 0 for unknown CRs. */
|
||||
export function crToXp(cr: string): number {
|
||||
return CR_TO_XP[cr] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates encounter difficulty from party levels and monster CRs.
|
||||
* Both arrays should be pre-filtered (only PCs with levels, only bestiary-linked monsters).
|
||||
*/
|
||||
export function calculateEncounterDifficulty(
|
||||
partyLevels: readonly number[],
|
||||
monsterCrs: readonly string[],
|
||||
): DifficultyResult {
|
||||
let budgetLow = 0;
|
||||
let budgetModerate = 0;
|
||||
let budgetHigh = 0;
|
||||
|
||||
for (const level of partyLevels) {
|
||||
const budget = XP_BUDGET_PER_CHARACTER[level];
|
||||
if (budget) {
|
||||
budgetLow += budget.low;
|
||||
budgetModerate += budget.moderate;
|
||||
budgetHigh += budget.high;
|
||||
}
|
||||
}
|
||||
|
||||
let totalMonsterXp = 0;
|
||||
for (const cr of monsterCrs) {
|
||||
totalMonsterXp += crToXp(cr);
|
||||
}
|
||||
|
||||
let tier: DifficultyTier = "trivial";
|
||||
if (totalMonsterXp >= budgetHigh) {
|
||||
tier = "high";
|
||||
} else if (totalMonsterXp >= budgetModerate) {
|
||||
tier = "moderate";
|
||||
} else if (totalMonsterXp >= budgetLow) {
|
||||
tier = "low";
|
||||
}
|
||||
|
||||
return {
|
||||
tier,
|
||||
totalMonsterXp,
|
||||
partyBudget: {
|
||||
low: budgetLow,
|
||||
moderate: budgetModerate,
|
||||
high: budgetHigh,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ConditionId } from "./conditions.js";
|
||||
import type { CreatureId } from "./creature-types.js";
|
||||
import type { PlayerCharacterId } from "./player-character-types.js";
|
||||
import type { CombatantId } from "./types.js";
|
||||
|
||||
@@ -19,6 +20,15 @@ export interface CombatantAdded {
|
||||
readonly combatantId: CombatantId;
|
||||
readonly name: string;
|
||||
readonly position: number;
|
||||
readonly init?: {
|
||||
readonly maxHp?: number;
|
||||
readonly ac?: number;
|
||||
readonly initiative?: number;
|
||||
readonly creatureId?: CreatureId;
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
readonly playerCharacterId?: PlayerCharacterId;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CombatantRemoved {
|
||||
|
||||
11
packages/domain/src/export-bundle.ts
Normal file
11
packages/domain/src/export-bundle.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { PlayerCharacter } from "./player-character-types.js";
|
||||
import type { Encounter } from "./types.js";
|
||||
|
||||
export interface ExportBundle {
|
||||
readonly version: number;
|
||||
readonly exportedAt: string;
|
||||
readonly encounter: Encounter;
|
||||
readonly undoStack: readonly Encounter[];
|
||||
readonly redoStack: readonly Encounter[];
|
||||
readonly playerCharacters: readonly PlayerCharacter[];
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
export { type AddCombatantSuccess, addCombatant } from "./add-combatant.js";
|
||||
export {
|
||||
type AddCombatantSuccess,
|
||||
addCombatant,
|
||||
type CombatantInit,
|
||||
} from "./add-combatant.js";
|
||||
export { type AdjustHpSuccess, adjustHp } from "./adjust-hp.js";
|
||||
export { advanceTurn } from "./advance-turn.js";
|
||||
export { resolveCreatureName } from "./auto-number.js";
|
||||
@@ -11,6 +15,7 @@ export {
|
||||
type ConditionDefinition,
|
||||
type ConditionId,
|
||||
getConditionDescription,
|
||||
getConditionsForEdition,
|
||||
type RulesEdition,
|
||||
VALID_CONDITION_IDS,
|
||||
} from "./conditions.js";
|
||||
@@ -43,6 +48,12 @@ export {
|
||||
type EditPlayerCharacterSuccess,
|
||||
editPlayerCharacter,
|
||||
} from "./edit-player-character.js";
|
||||
export {
|
||||
calculateEncounterDifficulty,
|
||||
crToXp,
|
||||
type DifficultyResult,
|
||||
type DifficultyTier,
|
||||
} from "./encounter-difficulty.js";
|
||||
export type {
|
||||
AcSet,
|
||||
CombatantAdded,
|
||||
@@ -66,6 +77,7 @@ export type {
|
||||
TurnAdvanced,
|
||||
TurnRetreated,
|
||||
} from "./events.js";
|
||||
export type { ExportBundle } from "./export-bundle.js";
|
||||
export { deriveHpStatus, type HpStatus } from "./hp-status.js";
|
||||
export {
|
||||
calculateInitiative,
|
||||
@@ -116,3 +128,11 @@ export {
|
||||
type Encounter,
|
||||
isDomainError,
|
||||
} from "./types.js";
|
||||
export {
|
||||
clearHistory,
|
||||
EMPTY_UNDO_REDO_STATE,
|
||||
pushUndo,
|
||||
redo,
|
||||
type UndoRedoState,
|
||||
undo,
|
||||
} from "./undo-redo.js";
|
||||
|
||||
35
packages/domain/src/initiative-sort.ts
Normal file
35
packages/domain/src/initiative-sort.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Combatant, CombatantId } from "./types.js";
|
||||
|
||||
interface Indexed {
|
||||
readonly c: Combatant;
|
||||
readonly i: number;
|
||||
}
|
||||
|
||||
function compareByInitiative(a: Indexed, b: Indexed): number {
|
||||
const aHas = a.c.initiative !== undefined;
|
||||
const bHas = b.c.initiative !== undefined;
|
||||
if (aHas && bHas) {
|
||||
const diff = b.c.initiative - a.c.initiative;
|
||||
return diff === 0 ? a.i - b.i : diff;
|
||||
}
|
||||
if (aHas && !bHas) return -1;
|
||||
if (!aHas && bHas) return 1;
|
||||
return a.i - b.i;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable-sorts combatants by initiative (descending), preserving relative
|
||||
* order for ties and for combatants without initiative. Returns the sorted
|
||||
* list and the new activeIndex that tracks the given active combatant
|
||||
* through the reorder.
|
||||
*/
|
||||
export function sortByInitiative(
|
||||
combatants: readonly Combatant[],
|
||||
activeCombatantId: CombatantId,
|
||||
): { sorted: readonly Combatant[]; activeIndex: number } {
|
||||
const indexed = combatants.map((c, i) => ({ c, i }));
|
||||
indexed.sort(compareByInitiative);
|
||||
const sorted = indexed.map(({ c }) => c);
|
||||
const idx = sorted.findIndex((c) => c.id === activeCombatantId);
|
||||
return { sorted, activeIndex: idx === -1 ? 0 : idx };
|
||||
}
|
||||
@@ -74,6 +74,7 @@ export interface PlayerCharacter {
|
||||
readonly maxHp: number;
|
||||
readonly color?: PlayerColor;
|
||||
readonly icon?: PlayerIcon;
|
||||
readonly level?: number;
|
||||
}
|
||||
|
||||
export interface PlayerCharacterList {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DomainEvent } from "./events.js";
|
||||
import { sortByInitiative } from "./initiative-sort.js";
|
||||
import type { CombatantId, DomainError, Encounter } from "./types.js";
|
||||
|
||||
export interface SetInitiativeSuccess {
|
||||
@@ -44,45 +45,21 @@ export function setInitiative(
|
||||
const target = encounter.combatants[targetIdx];
|
||||
const previousValue = target.initiative;
|
||||
|
||||
// Record active combatant's id before reorder
|
||||
const activeCombatantId =
|
||||
encounter.combatants.length > 0
|
||||
? encounter.combatants[encounter.activeIndex].id
|
||||
: undefined;
|
||||
|
||||
// Create new combatants array with updated initiative
|
||||
const updated = encounter.combatants.map((c) =>
|
||||
c.id === combatantId ? { ...c, initiative: value } : c,
|
||||
);
|
||||
|
||||
// Stable sort: initiative descending, undefined last
|
||||
const indexed = updated.map((c, i) => ({ c, i }));
|
||||
indexed.sort((a, b) => {
|
||||
const aHas = a.c.initiative !== undefined;
|
||||
const bHas = b.c.initiative !== undefined;
|
||||
// Record active combatant's id before reorder
|
||||
const activeCombatantId =
|
||||
encounter.combatants.length > 0
|
||||
? encounter.combatants[encounter.activeIndex].id
|
||||
: combatantId;
|
||||
|
||||
if (aHas && bHas) {
|
||||
const aInit = a.c.initiative as number;
|
||||
const bInit = b.c.initiative as number;
|
||||
const diff = bInit - aInit;
|
||||
return diff === 0 ? a.i - b.i : diff;
|
||||
}
|
||||
if (aHas && !bHas) return -1;
|
||||
if (!aHas && bHas) return 1;
|
||||
// Both undefined — preserve relative order
|
||||
return a.i - b.i;
|
||||
});
|
||||
|
||||
const sorted = indexed.map(({ c }) => c);
|
||||
|
||||
// Find active combatant's new index
|
||||
let newActiveIndex = encounter.activeIndex;
|
||||
if (activeCombatantId !== undefined) {
|
||||
const idx = sorted.findIndex((c) => c.id === activeCombatantId);
|
||||
if (idx !== -1) {
|
||||
newActiveIndex = idx;
|
||||
}
|
||||
}
|
||||
const { sorted, activeIndex: newActiveIndex } = sortByInitiative(
|
||||
updated,
|
||||
activeCombatantId,
|
||||
);
|
||||
|
||||
return {
|
||||
encounter: {
|
||||
|
||||
70
packages/domain/src/undo-redo.ts
Normal file
70
packages/domain/src/undo-redo.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { DomainError, Encounter } from "./types.js";
|
||||
|
||||
export interface UndoRedoState {
|
||||
readonly undoStack: readonly Encounter[];
|
||||
readonly redoStack: readonly Encounter[];
|
||||
}
|
||||
|
||||
const MAX_UNDO_STACK = 50;
|
||||
|
||||
export const EMPTY_UNDO_REDO_STATE: UndoRedoState = {
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
|
||||
export function pushUndo(
|
||||
state: UndoRedoState,
|
||||
snapshot: Encounter,
|
||||
): UndoRedoState {
|
||||
const newStack = [...state.undoStack, snapshot];
|
||||
if (newStack.length > MAX_UNDO_STACK) {
|
||||
newStack.shift();
|
||||
}
|
||||
return { undoStack: newStack, redoStack: [] };
|
||||
}
|
||||
|
||||
export function undo(
|
||||
state: UndoRedoState,
|
||||
currentEncounter: Encounter,
|
||||
): { state: UndoRedoState; encounter: Encounter } | DomainError {
|
||||
if (state.undoStack.length === 0) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "nothing-to-undo",
|
||||
message: "Nothing to undo",
|
||||
};
|
||||
}
|
||||
const restored = state.undoStack.at(-1) as Encounter;
|
||||
return {
|
||||
state: {
|
||||
undoStack: state.undoStack.slice(0, -1),
|
||||
redoStack: [...state.redoStack, currentEncounter],
|
||||
},
|
||||
encounter: restored,
|
||||
};
|
||||
}
|
||||
|
||||
export function redo(
|
||||
state: UndoRedoState,
|
||||
currentEncounter: Encounter,
|
||||
): { state: UndoRedoState; encounter: Encounter } | DomainError {
|
||||
if (state.redoStack.length === 0) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "nothing-to-redo",
|
||||
message: "Nothing to redo",
|
||||
};
|
||||
}
|
||||
const restored = state.redoStack.at(-1) as Encounter;
|
||||
return {
|
||||
state: {
|
||||
undoStack: [...state.undoStack, currentEncounter],
|
||||
redoStack: state.redoStack.slice(0, -1),
|
||||
},
|
||||
encounter: restored,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearHistory(): UndoRedoState {
|
||||
return EMPTY_UNDO_REDO_STATE;
|
||||
}
|
||||
31
pnpm-lock.yaml
generated
31
pnpm-lock.yaml
generated
@@ -6,6 +6,7 @@ settings:
|
||||
|
||||
overrides:
|
||||
undici: '>=7.24.0'
|
||||
picomatch: '>=4.0.4'
|
||||
|
||||
importers:
|
||||
|
||||
@@ -1082,7 +1083,7 @@ packages:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
peerDependencies:
|
||||
picomatch: ^3 || ^4
|
||||
picomatch: '>=4.0.4'
|
||||
peerDependenciesMeta:
|
||||
picomatch:
|
||||
optional: true
|
||||
@@ -1507,12 +1508,8 @@ packages:
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
picomatch@2.3.1:
|
||||
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
picomatch@4.0.3:
|
||||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||
picomatch@4.0.4:
|
||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
postcss@8.5.8:
|
||||
@@ -2663,9 +2660,9 @@ snapshots:
|
||||
dependencies:
|
||||
walk-up-path: 4.0.0
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.3):
|
||||
fdir@6.5.0(picomatch@4.0.4):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
picomatch: 4.0.4
|
||||
|
||||
fill-range@7.1.1:
|
||||
dependencies:
|
||||
@@ -2865,7 +2862,7 @@ snapshots:
|
||||
minimist: 1.2.8
|
||||
oxc-resolver: 11.19.1
|
||||
picocolors: 1.1.1
|
||||
picomatch: 4.0.3
|
||||
picomatch: 4.0.4
|
||||
smol-toml: 1.6.0
|
||||
strip-json-comments: 5.0.3
|
||||
typescript: 5.9.3
|
||||
@@ -3002,7 +2999,7 @@ snapshots:
|
||||
micromatch@4.0.8:
|
||||
dependencies:
|
||||
braces: 3.0.3
|
||||
picomatch: 2.3.1
|
||||
picomatch: 4.0.4
|
||||
|
||||
mimic-fn@2.1.0: {}
|
||||
|
||||
@@ -3100,9 +3097,7 @@ snapshots:
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@2.3.1: {}
|
||||
|
||||
picomatch@4.0.3: {}
|
||||
picomatch@4.0.4: {}
|
||||
|
||||
postcss@8.5.8:
|
||||
dependencies:
|
||||
@@ -3315,8 +3310,8 @@ snapshots:
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
|
||||
tinyrainbow@3.1.0: {}
|
||||
|
||||
@@ -3356,7 +3351,7 @@ snapshots:
|
||||
vite@8.0.1(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
picomatch: 4.0.3
|
||||
picomatch: 4.0.4
|
||||
postcss: 8.5.8
|
||||
rolldown: 1.0.0-rc.10
|
||||
tinyglobby: 0.2.15
|
||||
@@ -3380,7 +3375,7 @@ snapshots:
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.1
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.3
|
||||
picomatch: 4.0.4
|
||||
std-env: 4.0.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 1.0.4
|
||||
|
||||
@@ -226,7 +226,7 @@ Deleting a player character MUST NOT remove or modify any combatants currently i
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **PlayerCharacter**: A persistent, reusable character template with a unique `PlayerCharacterId` (branded string), required `name`, `ac` (number), `maxHp` (number), `color` (string from predefined set), and `icon` (string identifier from preset icon set).
|
||||
- **PlayerCharacter**: A persistent, reusable character template with a unique `PlayerCharacterId` (branded string), required `name`, `ac` (number), `maxHp` (number), `color` (string from predefined set), `icon` (string identifier from preset icon set), and optional `level` (integer 1-20, added by spec 008 for encounter difficulty calculation).
|
||||
- **PlayerCharacterStore** (port): Interface for loading, saving, and deleting player characters. Implemented as a browser storage adapter.
|
||||
|
||||
---
|
||||
|
||||
35
specs/006-undo-redo/checklists/requirements.md
Normal file
35
specs/006-undo-redo/checklists/requirements.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Undo/Redo
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-26
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass. The spec references "memento pattern" and "localStorage" — these are architectural intent from the issue itself, not implementation leakage. The spec describes *what* (snapshot-based history, persisted to local storage) not *how* (no code structure, no framework APIs).
|
||||
- The "Assumptions" section documents the localStorage sizing assumption and the dependency on #15 being resolved.
|
||||
83
specs/006-undo-redo/data-model.md
Normal file
83
specs/006-undo-redo/data-model.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Data Model: Undo/Redo
|
||||
|
||||
**Feature**: 006-undo-redo
|
||||
**Date**: 2026-03-26
|
||||
|
||||
## Entities
|
||||
|
||||
### UndoRedoState
|
||||
|
||||
Represents the complete undo/redo history for an encounter session.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| undoStack | Encounter[] | Ordered list of encounter snapshots, most recent last. Max 50 entries. |
|
||||
| redoStack | Encounter[] | Ordered list of encounter snapshots accumulated by undo operations. Cleared on any new action. |
|
||||
|
||||
### Encounter (existing, unchanged)
|
||||
|
||||
Each stack entry is a full `Encounter` snapshot as defined in `packages/domain/src/types.ts`. No schema changes to the encounter type.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| combatants | Combatant[] | Ordered list of combatants |
|
||||
| activeIndex | number | Index of the active combatant |
|
||||
| roundNumber | number | Current round number |
|
||||
|
||||
## State Transitions
|
||||
|
||||
### pushUndo(state, snapshot) -> UndoRedoState
|
||||
|
||||
Push a snapshot onto the undo stack. If the stack exceeds 50 entries, drop the oldest (index 0). Clear the redo stack.
|
||||
|
||||
**Precondition**: snapshot is a valid Encounter
|
||||
**Postcondition**: undoStack length <= 50, redoStack is empty
|
||||
|
||||
### undo(state, currentEncounter) -> { state: UndoRedoState, encounter: Encounter } | DomainError
|
||||
|
||||
Pop the most recent snapshot from the undo stack. Push the current encounter onto the redo stack. Return the popped snapshot as the new current encounter.
|
||||
|
||||
**Precondition**: undoStack is non-empty
|
||||
**Postcondition**: undoStack length decremented by 1, redoStack length incremented by 1
|
||||
**Error**: "nothing-to-undo" if undoStack is empty
|
||||
|
||||
### redo(state, currentEncounter) -> { state: UndoRedoState, encounter: Encounter } | DomainError
|
||||
|
||||
Pop the most recent snapshot from the redo stack. Push the current encounter onto the undo stack. Return the popped snapshot as the new current encounter.
|
||||
|
||||
**Precondition**: redoStack is non-empty
|
||||
**Postcondition**: redoStack length decremented by 1, undoStack length incremented by 1
|
||||
**Error**: "nothing-to-redo" if redoStack is empty
|
||||
|
||||
### clearHistory() -> UndoRedoState
|
||||
|
||||
Reset both stacks to empty. Used when the encounter is cleared.
|
||||
|
||||
**Postcondition**: undoStack and redoStack are both empty
|
||||
|
||||
## Persistence
|
||||
|
||||
### Storage Keys
|
||||
|
||||
| Key | Content | Format |
|
||||
|-----|---------|--------|
|
||||
| `initiative:encounter:undo` | Undo stack | JSON array of serialized Encounter objects |
|
||||
| `initiative:encounter:redo` | Redo stack | JSON array of serialized Encounter objects |
|
||||
|
||||
### Serialization
|
||||
|
||||
Stacks are serialized as JSON arrays of `Encounter` objects, identical to the existing encounter serialization format. On load, each entry is validated using the same rehydration logic as `loadEncounter()`.
|
||||
|
||||
### Failure Modes
|
||||
|
||||
- **localStorage quota exceeded**: Stacks continue in-memory; persistence is best-effort. Silently swallow write errors (matching existing encounter persistence pattern).
|
||||
- **Corrupt data on load**: Start with empty stacks. Log no error (matching existing pattern).
|
||||
- **Schema mismatch after upgrade**: Invalid entries are dropped during rehydration; stacks may be shorter than persisted but never contain invalid data.
|
||||
|
||||
## Invariants
|
||||
|
||||
1. `undoStack.length <= 50` at all times
|
||||
2. `redoStack` is empty after any non-undo/redo action
|
||||
3. `undoStack.length + redoStack.length` represents the total history depth (not capped as a whole — redo can grow up to 50 if all actions are undone)
|
||||
4. Each stack entry is a valid, complete `Encounter` snapshot
|
||||
5. Undo followed by redo returns the encounter to the exact same state
|
||||
71
specs/006-undo-redo/plan.md
Normal file
71
specs/006-undo-redo/plan.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Implementation Plan: Undo/Redo
|
||||
|
||||
**Branch**: `006-undo-redo` | **Date**: 2026-03-26 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `/specs/006-undo-redo/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Add undo/redo capability for all encounter state changes using the memento pattern. Before each state transition, the current `Encounter` is pushed to an undo stack (capped at 50 entries). Undo restores the previous snapshot and pushes the current state to a redo stack; any new action clears the redo stack. Stacks are persisted to localStorage. Triggered via UI buttons (disabled when empty) and keyboard shortcuts (Ctrl+Z / Ctrl+Shift+Z, Cmd on Mac), suppressed during text input focus.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.8 (strict mode, `verbatimModuleSyntax`)
|
||||
**Primary Dependencies**: React 19, Vite 6, Tailwind CSS v4, Lucide React (icons)
|
||||
**Storage**: localStorage (existing key `"initiative:encounter"`, new keys for undo/redo stacks)
|
||||
**Testing**: Vitest (v8 coverage)
|
||||
**Target Platform**: Web browser (desktop + mobile)
|
||||
**Project Type**: Web application (monorepo: `apps/web` + `packages/domain` + `packages/application`)
|
||||
**Performance Goals**: Undo/redo operations complete in < 1 second (per SC-001)
|
||||
**Constraints**: Undo stack capped at 50 snapshots; localStorage quota is best-effort
|
||||
**Scale/Scope**: Encounters have tens of combatants; 50 snapshots of ~2-5 KB each = ~100-250 KB
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| I. Deterministic Domain Core | PASS | Stack management (push, pop, cap) is pure logic with no I/O. Domain functions remain unchanged. |
|
||||
| II. Layered Architecture | PASS | Stack operations are pure functions in domain. Persistence is in the adapter layer (localStorage). Hook orchestrates via application pattern. |
|
||||
| II-A. Context-Based State Flow | PASS | Undo/redo state exposed via existing EncounterContext. No new props needed on components beyond the context consumer. |
|
||||
| III. Clarification-First | PASS | No ambiguities remain; issue #16 and spec fully define behavior. |
|
||||
| IV. Escalation Gates | PASS | All requirements come from the spec; no scope expansion. |
|
||||
| V. MVP Baseline Language | PASS | No permanent bans introduced. |
|
||||
| VI. No Gameplay Rules | PASS | Undo/redo is infrastructure, not gameplay. |
|
||||
|
||||
**Result**: All gates pass. No violations to justify.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/006-undo-redo/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── quickstart.md # Phase 1 output
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
packages/domain/src/
|
||||
├── undo-redo.ts # Pure stack operations (push, pop, cap, clear)
|
||||
├── __tests__/undo-redo.test.ts # Unit tests for stack operations
|
||||
|
||||
packages/application/src/
|
||||
├── undo-use-case.ts # Orchestrates undo via EncounterStore + UndoRedoStore
|
||||
├── redo-use-case.ts # Orchestrates redo via EncounterStore + UndoRedoStore
|
||||
├── ports.ts # Extended with UndoRedoStore port interface
|
||||
|
||||
apps/web/src/
|
||||
├── hooks/use-encounter.ts # Modified: wrap actions with snapshot capture, expose undo/redo
|
||||
├── persistence/undo-redo-storage.ts # localStorage save/load for undo/redo stacks
|
||||
├── contexts/encounter-context.tsx # Modified: expose undo/redo + stack emptiness flags
|
||||
├── components/turn-navigation.tsx # Modified: add undo/redo buttons (inboard of turn step buttons)
|
||||
├── hooks/use-undo-redo-shortcuts.ts # Keyboard shortcut handler (Ctrl+Z, Ctrl+Shift+Z)
|
||||
```
|
||||
|
||||
**Structure Decision**: Follows existing layered architecture. Pure stack operations in domain, use cases in application, persistence and UI in web adapter. No new packages or structural changes needed.
|
||||
53
specs/006-undo-redo/quickstart.md
Normal file
53
specs/006-undo-redo/quickstart.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Quickstart: Undo/Redo
|
||||
|
||||
**Feature**: 006-undo-redo
|
||||
**Date**: 2026-03-26
|
||||
|
||||
## Overview
|
||||
|
||||
This feature adds undo/redo to all encounter state changes using the memento (snapshot) pattern. Each action captures the pre-action encounter state onto an undo stack. Undo restores the previous state; redo re-applies an undone state.
|
||||
|
||||
## Implementation Layers
|
||||
|
||||
### Domain (`packages/domain/src/undo-redo.ts`)
|
||||
|
||||
Pure functions for stack management:
|
||||
- `pushUndo(state, snapshot)` — push snapshot, cap at 50, clear redo
|
||||
- `undo(state, currentEncounter)` — pop undo, push current to redo
|
||||
- `redo(state, currentEncounter)` — pop redo, push current to undo
|
||||
- `clearHistory()` — reset both stacks
|
||||
|
||||
All functions take and return immutable data. No I/O.
|
||||
|
||||
### Application (`packages/application/src/`)
|
||||
|
||||
Use cases that orchestrate domain calls via store ports:
|
||||
- `undoUseCase(encounterStore, undoRedoStore)` — execute undo
|
||||
- `redoUseCase(encounterStore, undoRedoStore)` — execute redo
|
||||
|
||||
New port interface `UndoRedoStore` in `ports.ts`:
|
||||
- `get(): UndoRedoState`
|
||||
- `save(state: UndoRedoState): void`
|
||||
|
||||
### Web Adapter (`apps/web/src/`)
|
||||
|
||||
**Hook (`use-encounter.ts`)**: Wraps every action callback to capture pre-action snapshot. Exposes `undo()`, `redo()`, `canUndo`, `canRedo`.
|
||||
|
||||
**Persistence (`persistence/undo-redo-storage.ts`)**: Save/load undo/redo stacks to localStorage keys `"initiative:encounter:undo"` and `"initiative:encounter:redo"`.
|
||||
|
||||
**UI (`components/turn-navigation.tsx`)**: Undo/Redo buttons in the top bar, inboard of the turn step buttons, disabled when stack is empty.
|
||||
|
||||
**Keyboard (`hooks/use-undo-redo-shortcuts.ts`)**: Global keydown listener for Ctrl+Z / Ctrl+Shift+Z (Cmd on Mac). Suppressed when text input has focus.
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **Memento over Command**: Full encounter snapshots, not inverse events. Simpler at encounter scale (~2-5 KB per snapshot).
|
||||
2. **Capture in hook, not domain**: Snapshot capture happens in the adapter layer. Domain and application layers are unaware of undo/redo.
|
||||
3. **React state for stacks**: Enables reactive button disabled states without manual re-render triggers.
|
||||
4. **Clear is not undoable**: Both stacks reset on encounter clear (per spec).
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Domain tests**: Pure function tests for stack operations (push, pop, cap, clear, undo/redo roundtrip).
|
||||
- **Application tests**: Use case tests with mock stores.
|
||||
- **Integration**: Spec acceptance scenarios mapped to test cases (undo restores state, redo reapplies, new action clears redo, keyboard suppression during input focus).
|
||||
82
specs/006-undo-redo/research.md
Normal file
82
specs/006-undo-redo/research.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Research: Undo/Redo for Encounter Actions
|
||||
|
||||
**Feature**: 006-undo-redo
|
||||
**Date**: 2026-03-26
|
||||
|
||||
## Decision 1: Undo/Redo Strategy — Memento (Snapshots) vs Command (Events)
|
||||
|
||||
**Decision**: Memento pattern — store full `Encounter` snapshots.
|
||||
|
||||
**Rationale**:
|
||||
- The `Encounter` type is small (tens of combatants, ~2-5 KB serialized). Storing 50 snapshots costs ~100-250 KB in memory and localStorage — negligible.
|
||||
- The codebase already serializes/deserializes full encounters for localStorage persistence. Reuse is straightforward.
|
||||
- All domain state transitions are pure functions returning new `Encounter` objects. Each action naturally produces a "before" snapshot (the current state) and an "after" snapshot (the result).
|
||||
- The command/event approach would require inverse operations for every domain function, including compound operations like initiative re-sorting. This complexity is not justified at encounter scale.
|
||||
- The existing event system captures `previousValue`/`newValue` pairs but lacks full combatant snapshots for structural changes (add/remove with initiative reordering). Extending events would be more work than snapshots for no practical benefit.
|
||||
|
||||
**Alternatives considered**:
|
||||
- **Command pattern (inverse events)**: More memory-efficient per entry but significantly more complex. Requires implementing and testing inverse operations for all 18+ domain transitions. Rejected because the complexity outweighs the memory savings at encounter scale.
|
||||
- **Hybrid (events for simple, snapshots for structural)**: Rejected because mixed strategies increase implementation and debugging complexity.
|
||||
|
||||
## Decision 2: Stack Storage Location
|
||||
|
||||
**Decision**: Store undo/redo stacks in React state within `useEncounter`, persisted to localStorage via dedicated keys.
|
||||
|
||||
**Rationale**:
|
||||
- Matches the existing persistence pattern: encounter state lives in React state and is synced to localStorage via `useEffect`.
|
||||
- Using `useState` (not `useRef`) ensures React re-renders when stack emptiness changes, keeping button disabled states reactive.
|
||||
- Dedicated localStorage keys (`"initiative:encounter:undo"`, `"initiative:encounter:redo"`) avoid coupling stack persistence with encounter persistence.
|
||||
|
||||
**Alternatives considered**:
|
||||
- **useRef for stacks**: Would avoid re-renders on every push/pop, but then button disabled states wouldn't update reactively. Would need manual `forceUpdate` or separate boolean state — more complex for no clear benefit.
|
||||
- **Single localStorage key with encounter**: Rejected because it couples concerns and makes the encounter storage format backward-incompatible.
|
||||
|
||||
## Decision 3: Snapshot Capture Point
|
||||
|
||||
**Decision**: Capture the pre-action encounter snapshot inside each action callback in `useEncounter`, before calling the use case.
|
||||
|
||||
**Rationale**:
|
||||
- Each action callback in `useEncounter` already calls `makeStore()` which accesses the current encounter via `encounterRef.current`. The snapshot is naturally available at this point.
|
||||
- Capturing at the hook level (not the use case level) keeps the domain and application layers unchanged — undo/redo is purely an adapter concern.
|
||||
- Failed actions (domain errors) should NOT push to the undo stack, so the capture must happen conditionally after confirming the action succeeded.
|
||||
|
||||
**Alternatives considered**:
|
||||
- **Capture inside use cases**: Would require changing the application layer API to return the pre-action state. Violates layered architecture — use cases shouldn't know about undo.
|
||||
- **Capture via store wrapper**: Could intercept `store.save()` to capture the previous state. Elegant but makes the flow harder to follow and debug. Rejected in favor of explicit capture.
|
||||
|
||||
## Decision 4: Keyboard Shortcut Suppression Strategy
|
||||
|
||||
**Decision**: Check `document.activeElement` tag name and `contentEditable` attribute. Suppress encounter undo/redo when focus is on `INPUT`, `TEXTAREA`, or `contentEditable` elements.
|
||||
|
||||
**Rationale**:
|
||||
- Simple and reliable. The app uses standard HTML form elements for text input.
|
||||
- No `contentEditable` elements currently exist, but checking for them is defensive and low-cost.
|
||||
- The check happens in the `keydown` event handler before dispatching to undo/redo.
|
||||
|
||||
**Alternatives considered**:
|
||||
- **Capture phase with stopPropagation**: Overly complex for this use case.
|
||||
- **Custom focus tracking via context**: Would require every input to register/unregister. Too invasive.
|
||||
|
||||
## Decision 5: UI Placement for Undo/Redo Buttons
|
||||
|
||||
**Decision**: Place undo/redo buttons in the `TurnNavigation` component (top bar), inboard of the turn step buttons. Turn navigation (Previous/Next Turn) stays as the outermost buttons; Undo/Redo sits between Previous Turn and the center info area.
|
||||
|
||||
**Rationale**:
|
||||
- `TurnNavigation` is the primary command bar for encounter-level actions (advance/retreat turn, clear encounter). Undo/redo are encounter-level actions.
|
||||
- Placing them in the top bar keeps them always visible when the encounter is active.
|
||||
- Turn navigation stays outermost because it's the most frequently used control during live combat. Undo/redo is secondary.
|
||||
|
||||
**Alternatives considered**:
|
||||
- **ActionBar (bottom bar)**: Already crowded with combatant management controls (search, add, roll initiative). Undo/redo would be buried.
|
||||
- **Floating buttons**: Unconventional for this app's design language.
|
||||
|
||||
## Decision 6: Clear Encounter and Undo Stack
|
||||
|
||||
**Decision**: Clearing the encounter resets both undo and redo stacks. Clear is not undoable.
|
||||
|
||||
**Rationale**:
|
||||
- Per spec (FR-010 and edge case). Clear is an intentionally destructive "reset" action. Making it undoable would create confusion about what "fresh start" means.
|
||||
- The existing clear encounter flow already has a confirmation dialog, providing sufficient protection against accidents.
|
||||
|
||||
**Alternatives considered**:
|
||||
- **Make clear undoable**: Would require keeping the pre-clear state in a special recovery slot. Adds complexity for a scenario already guarded by confirmation. Rejected per spec.
|
||||
123
specs/006-undo-redo/spec.md
Normal file
123
specs/006-undo-redo/spec.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Feature Specification: Undo/Redo
|
||||
|
||||
**Feature Branch**: `006-undo-redo`
|
||||
**Created**: 2026-03-26
|
||||
**Status**: Draft
|
||||
**Input**: Gitea issue #16 — Undo/redo for encounter actions
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Undo a Mistake (Priority: P1)
|
||||
|
||||
A DM accidentally removes the wrong combatant, changes HP incorrectly, or advances the turn too early. They press an undo button (or keyboard shortcut) and the encounter returns to exactly the state it was in before that action.
|
||||
|
||||
**Why this priority**: Mistakes during live combat are stressful and time-sensitive. Undo is the core value proposition — without it, the DM must manually reconstruct state, which disrupts the game.
|
||||
|
||||
**Independent Test**: Can be fully tested by performing any encounter action, pressing undo, and verifying the encounter matches its pre-action state.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an encounter with 3 combatants, **When** the user removes a combatant and clicks Undo, **Then** the removed combatant reappears in the same position with all its stats intact.
|
||||
2. **Given** a combatant with 30/45 HP, **When** the user adjusts HP to 20/45 and presses Undo, **Then** the combatant's HP returns to 30/45.
|
||||
3. **Given** an encounter on round 3 turn 2, **When** the user advances the turn and presses Undo, **Then** the encounter returns to round 3 turn 2.
|
||||
4. **Given** the undo stack is empty, **When** the user looks at the Undo button, **Then** it appears disabled and cannot be activated.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Redo an Undone Action (Priority: P2)
|
||||
|
||||
A DM presses undo but then realizes the original action was correct. They press redo to restore the undone change rather than re-entering it manually.
|
||||
|
||||
**Why this priority**: Redo complements undo — without it, undoing too far forces manual re-entry. Lower priority than undo because redo is used less frequently.
|
||||
|
||||
**Independent Test**: Can be tested by performing an action, undoing it, then redoing it, and verifying the state matches the post-action state.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the user has undone an HP adjustment, **When** they click Redo, **Then** the HP adjustment is reapplied exactly.
|
||||
2. **Given** the user has undone two actions, **When** they click Redo twice, **Then** both actions are reapplied in order.
|
||||
3. **Given** the user has undone an action and then performs a new action, **When** they look at the Redo button, **Then** it is disabled (the redo stack was cleared by the new action).
|
||||
4. **Given** the redo stack is empty, **When** the user looks at the Redo button, **Then** it appears disabled and cannot be activated.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Keyboard Shortcuts (Priority: P3)
|
||||
|
||||
A DM who prefers keyboard interaction can undo with Ctrl+Z (Cmd+Z on Mac) and redo with Ctrl+Shift+Z (Cmd+Shift+Z on Mac) without reaching for buttons.
|
||||
|
||||
**Why this priority**: Keyboard shortcuts are a convenience layer. The feature is fully usable via buttons alone, so shortcuts are an enhancement.
|
||||
|
||||
**Independent Test**: Can be tested by pressing the keyboard shortcut and verifying the same behavior as the button.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the undo stack has entries, **When** the user presses Ctrl+Z (Cmd+Z on Mac), **Then** the most recent action is undone.
|
||||
2. **Given** the redo stack has entries, **When** the user presses Ctrl+Shift+Z (Cmd+Shift+Z on Mac), **Then** the most recent undo is redone.
|
||||
3. **Given** an input field or textarea has focus, **When** the user presses Ctrl+Z, **Then** the browser's native text undo fires instead of encounter undo.
|
||||
4. **Given** no input has focus and the undo stack is empty, **When** the user presses Ctrl+Z, **Then** nothing happens.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Undo History Survives Refresh (Priority: P4)
|
||||
|
||||
A DM refreshes the page (or the browser restores the tab) and can still undo/redo recent actions, so history is not lost to accidental navigation.
|
||||
|
||||
**Why this priority**: Persistence is important for reliability but is secondary to the core undo/redo mechanics working correctly in-session.
|
||||
|
||||
**Independent Test**: Can be tested by performing actions, refreshing the page, and verifying the undo/redo buttons reflect the pre-refresh stack state.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the user has performed 5 actions, **When** they refresh the page, **Then** the undo stack contains 5 entries and undo works correctly.
|
||||
2. **Given** the user has undone 2 of 5 actions, **When** they refresh the page, **Then** the undo stack has 3 entries and the redo stack has 2 entries.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when the undo stack reaches 50 entries? The oldest entry is dropped silently; the user can still undo the 50 most recent actions.
|
||||
- What happens when the user clears the encounter? Both undo and redo stacks are reset to empty; there is no way to undo a clear.
|
||||
- What happens when the user performs a new action after undoing? The redo stack is cleared entirely; the new action becomes the latest history entry.
|
||||
- What happens if localStorage is full and the stacks cannot be persisted? The stacks continue to work in-memory for the current session; persistence is best-effort.
|
||||
- What happens if persisted stack data is corrupt or invalid on load? The stacks start empty; the encounter itself loads normally from its own storage.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST capture a snapshot of the current encounter state before each state transition and push it onto the undo stack.
|
||||
- **FR-002**: System MUST cap the undo stack at 50 entries, dropping the oldest entry when the cap is exceeded.
|
||||
- **FR-003**: When the user triggers undo, system MUST restore the encounter to the most recent snapshot from the undo stack and push the current state onto the redo stack.
|
||||
- **FR-004**: When the user triggers redo, system MUST restore the encounter to the most recent snapshot from the redo stack and push the current state onto the undo stack.
|
||||
- **FR-005**: When the user performs any new encounter action (not undo/redo), system MUST clear the redo stack.
|
||||
- **FR-006**: System MUST persist the undo and redo stacks to localStorage and restore them on page load.
|
||||
- **FR-007**: System MUST display Undo and Redo buttons in the UI that are disabled when their respective stacks are empty.
|
||||
- **FR-008**: System MUST support Ctrl+Z / Cmd+Z for undo and Ctrl+Shift+Z / Cmd+Shift+Z for redo.
|
||||
- **FR-009**: System MUST suppress encounter undo/redo keyboard shortcuts when an input, textarea, or other text-editable element has focus, allowing native browser text editing behavior.
|
||||
- **FR-010**: When the encounter is cleared, system MUST reset both undo and redo stacks to empty.
|
||||
- **FR-011**: Undo/redo MUST operate on the full encounter snapshot (memento pattern), not on individual field changes.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Undo Stack**: An ordered collection of encounter snapshots (most recent last), capped at 50 entries. Each entry is a complete encounter state captured before a state transition.
|
||||
- **Redo Stack**: An ordered collection of encounter snapshots accumulated by undo operations. Cleared when any new (non-undo/redo) action occurs.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Users can reverse any single encounter action within 1 second via button or keyboard shortcut.
|
||||
- **SC-002**: Users can undo and redo up to 50 sequential actions without data loss or state corruption.
|
||||
- **SC-003**: Undo/redo history is preserved across page refresh with no user intervention.
|
||||
- **SC-004**: Keyboard shortcuts do not interfere with native text editing in input fields.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The encounter data structure is small enough (tens of combatants) that storing 50 full snapshots in memory and localStorage is practical.
|
||||
- The dependency on #15 (atomic addCombatant) is resolved, so each user action maps to exactly one snapshot.
|
||||
- Player character template state is managed separately and is not part of the undo/redo scope — only encounter state is tracked.
|
||||
- "Clear encounter" is an intentionally destructive action that should not be undoable, matching user expectations for a "reset" operation.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **#15 (Atomic addCombatant)**: Required so compound operations (add from bestiary, add from player character) produce a single state transition and thus a single undo entry.
|
||||
159
specs/006-undo-redo/tasks.md
Normal file
159
specs/006-undo-redo/tasks.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# Tasks: Undo/Redo
|
||||
|
||||
**Input**: Design documents from `/specs/006-undo-redo/`
|
||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, quickstart.md
|
||||
|
||||
**Tests**: Domain tests included (pure function testing is standard for this project per CLAUDE.md).
|
||||
|
||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundational (Domain + Application Layer)
|
||||
|
||||
**Purpose**: Pure domain logic and application ports that all user stories depend on
|
||||
|
||||
**CRITICAL**: No user story work can begin until this phase is complete
|
||||
|
||||
- [x] T001 Define `UndoRedoState` type and pure stack functions (`pushUndo`, `undo`, `redo`, `clearHistory`) in `packages/domain/src/undo-redo.ts`. All functions take and return immutable data per data-model.md state transitions. Cap undo stack at 50 entries (drop oldest). Return `DomainError` for empty-stack operations.
|
||||
- [x] T002 Add unit tests for all stack functions in `packages/domain/src/__tests__/undo-redo.test.ts`. Cover: push adds to stack, cap at 50 drops oldest, push clears redo stack, undo pops and moves to redo, redo pops and moves to undo, undo on empty returns error, redo on empty returns error, clearHistory empties both, undo-then-redo roundtrip returns exact same encounter. Application use case tests are not needed separately — the use cases are thin orchestration and their logic is fully covered by domain tests + integration via the hook.
|
||||
- [x] T003 [P] Add `UndoRedoStore` port interface (`get(): UndoRedoState`, `save(state: UndoRedoState): void`) to `packages/application/src/ports.ts`.
|
||||
- [x] T004 [P] Implement `undoUseCase(encounterStore, undoRedoStore)` in `packages/application/src/undo-use-case.ts`. Calls domain `undo()` with current encounter, saves resulting encounter to encounterStore and resulting state to undoRedoStore.
|
||||
- [x] T005 [P] Implement `redoUseCase(encounterStore, undoRedoStore)` in `packages/application/src/redo-use-case.ts`. Same pattern as undo but calls domain `redo()`.
|
||||
|
||||
**Checkpoint**: Domain logic and use cases complete. All pure functions tested. Ready for adapter layer.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: User Story 1 - Undo a Mistake (Priority: P1) MVP
|
||||
|
||||
**Goal**: User can undo any encounter action via a button in the top bar.
|
||||
|
||||
**Independent Test**: Perform any encounter action (add combatant, adjust HP, advance turn), click Undo, verify encounter returns to pre-action state. Undo button is disabled when stack is empty.
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T006 [US1] Add undo/redo state management to `apps/web/src/hooks/use-encounter.ts`: add `UndoRedoState` to hook state (initialized empty), create `makeUndoRedoStore()` factory (same pattern as `makeStore()`), create a `withUndo` wrapper function that captures the pre-action encounter snapshot and calls `pushUndo` on the undo/redo state after a successful action. Wrap all existing action callbacks with `withUndo`.
|
||||
- [x] T007 [US1] Add `undo` callback to `apps/web/src/hooks/use-encounter.ts` that calls `undoUseCase` and updates both encounter and undo/redo state. Expose `canUndo: boolean` (derived from undo stack length > 0).
|
||||
- [x] T008 [US1] Update `apps/web/src/contexts/encounter-context.tsx` to expose `undo`, `canUndo` from the encounter hook return type.
|
||||
- [x] T009 [US1] Add Undo button to `apps/web/src/components/turn-navigation.tsx`, placed inboard of (to the right of) the Previous Turn button. Use `Undo2` icon from Lucide React. Button is disabled when `canUndo` is false. Calls `undo()` from encounter context on click.
|
||||
|
||||
**Checkpoint**: Undo works for all encounter actions via button. Redo not yet available.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 2 - Redo an Undone Action (Priority: P2)
|
||||
|
||||
**Goal**: User can redo an undone action via a button. New actions clear the redo stack.
|
||||
|
||||
**Independent Test**: Perform an action, undo it, click Redo, verify state matches post-action. Then perform a new action and verify Redo button becomes disabled.
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T010 [US2] Add `redo` callback to `apps/web/src/hooks/use-encounter.ts` that calls `redoUseCase` and updates both encounter and undo/redo state. Expose `canRedo: boolean` (derived from redo stack length > 0). Verify that `pushUndo` (called by `withUndo` wrapper from T006) already clears the redo stack per domain logic — no additional work needed for FR-005.
|
||||
- [x] T011 [US2] Update `apps/web/src/contexts/encounter-context.tsx` to expose `redo`, `canRedo` from the encounter hook return type.
|
||||
- [x] T012 [US2] Add Redo button to `apps/web/src/components/turn-navigation.tsx`, placed next to the Undo button (both inboard of turn step buttons). Use `Redo2` icon from Lucide React. Button is disabled when `canRedo` is false. Calls `redo()` from encounter context on click.
|
||||
|
||||
**Checkpoint**: Full undo/redo via buttons. Keyboard shortcuts and persistence not yet available.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 3 - Keyboard Shortcuts (Priority: P3)
|
||||
|
||||
**Goal**: Ctrl+Z / Cmd+Z triggers undo; Ctrl+Shift+Z / Cmd+Shift+Z triggers redo. Suppressed when text input has focus.
|
||||
|
||||
**Independent Test**: Press Ctrl+Z with no input focused — encounter undoes. Focus an input field, press Ctrl+Z — browser native text undo fires. Press Ctrl+Shift+Z — encounter redoes.
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T013 [US3] Create `apps/web/src/hooks/use-undo-redo-shortcuts.ts`. Register a `keydown` event listener on `document`. Detect Ctrl+Z / Cmd+Z (undo) and Ctrl+Shift+Z / Cmd+Shift+Z (redo). Before dispatching, check `document.activeElement` — suppress if tag is `INPUT`, `TEXTAREA`, `SELECT`, or element has `contentEditable`. Call `preventDefault()` only when handling the shortcut. Accept `undo`, `redo`, `canUndo`, `canRedo` as parameters.
|
||||
- [x] T014 [US3] Wire `useUndoRedoShortcuts` into the encounter provider layer. Call the hook from inside `EncounterProvider` in `apps/web/src/contexts/encounter-context.tsx` (or from `App.tsx` if context structure makes that cleaner), passing undo/redo callbacks and flags from the encounter hook.
|
||||
|
||||
**Checkpoint**: Full undo/redo via buttons and keyboard shortcuts.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 4 - Undo History Survives Refresh (Priority: P4)
|
||||
|
||||
**Goal**: Undo/redo stacks persist to localStorage and restore on page load.
|
||||
|
||||
**Independent Test**: Perform 5 actions, refresh the page, verify undo button is enabled and clicking it 5 times restores each previous state.
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [x] T015 [P] [US4] Create `apps/web/src/persistence/undo-redo-storage.ts`. Implement `saveUndoRedoStacks(undoStack, redoStack)` and `loadUndoRedoStacks()`. Use localStorage keys `"initiative:encounter:undo"` and `"initiative:encounter:redo"`. Reuse existing encounter rehydration/validation logic from `encounter-storage.ts` for each stack entry. Silently swallow write errors (quota exceeded). Return empty stacks on corrupt/invalid data.
|
||||
- [x] T016 [US4] Integrate persistence into `apps/web/src/hooks/use-encounter.ts`: initialize undo/redo state from `loadUndoRedoStacks()` on mount. Add a `useEffect` that calls `saveUndoRedoStacks()` whenever undo/redo state changes (same pattern as existing encounter persistence).
|
||||
|
||||
**Checkpoint**: Full feature complete — undo/redo via buttons, keyboard shortcuts, persisted across refresh.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Edge cases and quality gates
|
||||
|
||||
- [x] T017 Ensure clear encounter resets undo/redo stacks in `apps/web/src/hooks/use-encounter.ts`. In the `clearEncounter` callback, call `clearHistory()` on the undo/redo state after clearing the encounter. Verify this also clears persisted stacks via the useEffect.
|
||||
- [x] T018 Run `pnpm check` and fix any lint, type, coverage, or unused-code issues. Ensure layer boundary check passes (domain must not import from web/application, application must not import from web).
|
||||
- [x] T019 Update README.md to document undo/redo capability (buttons + keyboard shortcuts). Per constitution, user-facing feature changes MUST be reflected in README.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Foundational (Phase 1)**: No dependencies — can start immediately
|
||||
- **US1 Undo (Phase 2)**: Depends on Foundational completion
|
||||
- **US2 Redo (Phase 3)**: Depends on US1 (undo must exist before redo makes sense)
|
||||
- **US3 Shortcuts (Phase 4)**: Depends on US2 (needs both undo and redo callbacks)
|
||||
- **US4 Persistence (Phase 5)**: Depends on US1 (needs undo/redo state to exist). Can run in parallel with US2/US3 if needed.
|
||||
- **Polish (Phase 6)**: Depends on all user stories being complete
|
||||
|
||||
### Within Foundational Phase
|
||||
|
||||
- T001 (domain functions) must complete before T002 (tests)
|
||||
- T003 (port) must complete before T004/T005 (use cases import `UndoRedoStore` from ports.ts)
|
||||
- T004 (undo use case) and T005 (redo use case) can run in parallel after T001 + T003
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- T004, T005 can run in parallel (different files, depend on T001 + T003)
|
||||
- T015 (persistence storage) can run in parallel with any Phase 3-4 work (different file)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Foundational (domain + application)
|
||||
2. Complete Phase 2: User Story 1 (undo via button)
|
||||
3. **STOP and VALIDATE**: Test undo independently with all encounter actions
|
||||
4. Demo if ready — undo alone delivers significant value
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Foundational → Domain logic tested, ready for integration
|
||||
2. Add US1 (Undo) → Button works → Validate independently
|
||||
3. Add US2 (Redo) → Both buttons work → Validate independently
|
||||
4. Add US3 (Shortcuts) → Keyboard works → Validate independently
|
||||
5. Add US4 (Persistence) → Refresh-safe → Validate independently
|
||||
6. Polish → Quality gates pass → Ready to merge
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks = different files, no dependencies
|
||||
- [Story] label maps task to specific user story for traceability
|
||||
- US2 (Redo) depends on US1 (Undo) — redo is meaningless without undo
|
||||
- US3 (Shortcuts) and US4 (Persistence) are independent of each other but both need US1
|
||||
- The `withUndo` wrapper in T006 is the key integration point — it captures snapshots for ALL existing actions in one place
|
||||
- Domain tests (T002) validate all invariants from data-model.md
|
||||
- Commit after each phase checkpoint
|
||||
34
specs/007-json-import-export/checklists/requirements.md
Normal file
34
specs/007-json-import-export/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: JSON Import/Export
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-27
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||
102
specs/007-json-import-export/data-model.md
Normal file
102
specs/007-json-import-export/data-model.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Data Model: JSON Import/Export
|
||||
|
||||
**Feature**: 007-json-import-export
|
||||
**Date**: 2026-03-27
|
||||
|
||||
## Entities
|
||||
|
||||
### ExportBundle
|
||||
|
||||
The top-level structure written to and read from `.json` files. Contains all exportable application state.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|--------------------|----------------------|----------|--------------------------------------------------|
|
||||
| version | number | Yes | Format version (starts at 1) |
|
||||
| exportedAt | string (ISO 8601) | Yes | Timestamp of when the export was created |
|
||||
| encounter | Encounter | Yes | Current encounter state (combatants + turn info) |
|
||||
| undoStack | Encounter[] | Yes | Undo history (encounter snapshots, max 50) |
|
||||
| redoStack | Encounter[] | Yes | Redo history (encounter snapshots) |
|
||||
| playerCharacters | PlayerCharacter[] | Yes | Player character templates |
|
||||
|
||||
### Encounter (existing)
|
||||
|
||||
Defined in `packages/domain/src/types.ts`. No changes needed — exported as-is via JSON serialization.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|--------------|--------------|----------|--------------------------------------|
|
||||
| combatants | Combatant[] | Yes | Ordered list of combatants |
|
||||
| activeIndex | number | Yes | Index of current turn (0-based) |
|
||||
| roundNumber | number | Yes | Current round (starts at 1) |
|
||||
|
||||
### Combatant (existing)
|
||||
|
||||
Defined in `packages/domain/src/types.ts`. No changes needed.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|--------------------|-------------------|----------|------------------------------------|
|
||||
| id | CombatantId | Yes | Unique identifier ("c-N") |
|
||||
| name | string | Yes | Display name |
|
||||
| initiative | number | No | Initiative roll result |
|
||||
| maxHp | number | No | Maximum hit points (>= 1) |
|
||||
| currentHp | number | No | Current HP (0 to maxHp) |
|
||||
| tempHp | number | No | Temporary hit points |
|
||||
| ac | number | No | Armor class (>= 0) |
|
||||
| conditions | ConditionId[] | No | Active status conditions |
|
||||
| isConcentrating | boolean | No | Concentration flag |
|
||||
| creatureId | CreatureId | No | Link to bestiary creature |
|
||||
| color | string | No | Visual color (from player char) |
|
||||
| icon | string | No | Visual icon (from player char) |
|
||||
| playerCharacterId | PlayerCharacterId | No | Link to player character template |
|
||||
|
||||
### PlayerCharacter (existing)
|
||||
|
||||
Defined in `packages/domain/src/player-character-types.ts`. No changes needed.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|--------|-------------------|----------|-------------------------------------------|
|
||||
| id | PlayerCharacterId | Yes | Unique identifier ("pc-N") |
|
||||
| name | string | Yes | Character name |
|
||||
| ac | number | Yes | Armor class (>= 0) |
|
||||
| maxHp | number | Yes | Maximum hit points (>= 1) |
|
||||
| color | PlayerColor | No | Visual color (10 options) |
|
||||
| icon | PlayerIcon | No | Visual icon (15 options) |
|
||||
|
||||
## Validation Rules
|
||||
|
||||
### Import Validation
|
||||
|
||||
1. **Top-level structure**: Must be a JSON object with `version`, `encounter`, `undoStack`, `redoStack`, and `playerCharacters` fields.
|
||||
2. **Version check**: `version` must be a number. Unknown versions are rejected.
|
||||
3. **Encounter validation**: Delegated to existing `rehydrateEncounter()` — validates combatant structure, HP ranges, condition IDs, player colors/icons.
|
||||
4. **Undo/redo stack validation**: Each entry in both stacks is validated via `rehydrateEncounter()`. Invalid entries are silently dropped.
|
||||
5. **Player character validation**: Delegated to existing player character rehydration — validates types, ranges, color/icon enums.
|
||||
6. **Graceful degradation**: Invalid optional fields on combatants/characters are stripped (not rejected). Only structurally malformed data (missing required fields, wrong types) causes full rejection.
|
||||
|
||||
## State Transitions
|
||||
|
||||
### Export Flow
|
||||
|
||||
```
|
||||
User triggers export
|
||||
→ Read encounter from EncounterContext
|
||||
→ Read undoRedoState from EncounterContext
|
||||
→ Read playerCharacters from PlayerCharactersContext
|
||||
→ Assemble ExportBundle { version: 1, exportedAt, encounter, undoStack, redoStack, playerCharacters }
|
||||
→ Serialize to JSON
|
||||
→ Trigger browser file download
|
||||
```
|
||||
|
||||
### Import Flow
|
||||
|
||||
```
|
||||
User selects file
|
||||
→ Read file as text
|
||||
→ Parse JSON (reject on parse failure)
|
||||
→ Validate top-level structure (reject on missing fields)
|
||||
→ Validate encounter via rehydrateEncounter() (reject on null)
|
||||
→ Validate undo/redo stacks via rehydrateEncounter() per entry (filter invalid)
|
||||
→ Validate player characters via rehydration (filter invalid)
|
||||
→ If current encounter is non-empty: show confirmation dialog
|
||||
→ On confirm: replace encounter, undo/redo, and player characters in state
|
||||
→ State changes trigger existing useEffect auto-saves to localStorage
|
||||
```
|
||||
111
specs/007-json-import-export/plan.md
Normal file
111
specs/007-json-import-export/plan.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Implementation Plan: JSON Import/Export
|
||||
|
||||
**Branch**: `007-json-import-export` | **Date**: 2026-03-27 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `/specs/007-json-import-export/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Add JSON import/export for the full application state (encounter, undo/redo history, player characters). Export creates a downloadable `.json` file; import reads a file, validates it using existing rehydration functions, and replaces the current state after user confirmation. UI is integrated via the action bar overflow menu.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.8 (strict mode, `verbatimModuleSyntax`)
|
||||
**Primary Dependencies**: React 19, Vite 6, Tailwind CSS v4, Lucide React
|
||||
**Storage**: localStorage (encounter, undo/redo, player characters)
|
||||
**Testing**: Vitest (v8 coverage)
|
||||
**Target Platform**: Browser (desktop + mobile)
|
||||
**Project Type**: Web application (SPA)
|
||||
**Performance Goals**: Export/import completes in under 1 second for typical encounters
|
||||
**Constraints**: No server-side component; browser-only file operations
|
||||
**Scale/Scope**: Encounters with up to ~50 combatants, undo stacks of up to 50 snapshots
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| I. Deterministic Domain Core | PASS | No new domain logic needed. ExportBundle type is pure data. Validation reuses existing pure functions. |
|
||||
| II. Layered Architecture | PASS | Type in domain, validation in adapter layer (co-located with existing rehydration functions), file I/O and UI in adapter layer. No reverse dependencies. |
|
||||
| II-A. Context-Based State Flow | PASS | Import reads/writes state via existing contexts. No new props beyond per-instance config. |
|
||||
| III. Clarification-First | PASS | All decisions documented in research.md. No ambiguities remain. |
|
||||
| IV. Escalation Gates | PASS | Feature scope matches spec exactly. No out-of-scope additions. |
|
||||
| V. MVP Baseline Language | PASS | Format versioning and selective import noted as "not included in MVP baseline." |
|
||||
| VI. No Gameplay Rules | PASS | No gameplay mechanics involved. |
|
||||
|
||||
**Post-Phase 1 re-check**: All gates still pass. The ExportBundle type is a pure data structure in the domain layer. The validation logic lives in the adapter layer alongside existing rehydration functions (it depends on adapter-layer `rehydrateEncounter` and `rehydrateCharacter`). File I/O and UI live in the adapter layer.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/007-json-import-export/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── quickstart.md # Phase 1 output
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
packages/domain/src/
|
||||
├── export-bundle.ts # ExportBundle type definition
|
||||
|
||||
apps/web/src/
|
||||
├── persistence/
|
||||
│ └── export-import.ts # Export assembly + import validation + file handling
|
||||
├── hooks/
|
||||
│ └── use-encounter.ts # Existing — needs import/export state setters exposed
|
||||
├── components/
|
||||
│ ├── action-bar.tsx # Existing — add overflow menu items
|
||||
│ └── import-confirm-prompt.tsx # Confirmation dialog for import
|
||||
```
|
||||
|
||||
**Structure Decision**: Follows existing project structure. New files are minimal — one domain type, one persistence module (validation + export assembly + file handling), one UI component. Most work integrates into existing files.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: ExportBundle Type + Validation Use Case
|
||||
|
||||
**Goal**: Define the export format and validation logic with full test coverage.
|
||||
|
||||
1. Create `ExportBundle` type in `packages/domain/src/export-bundle.ts`
|
||||
2. Export from domain package index
|
||||
3. Create `validateImportBundle()` in `apps/web/src/persistence/export-import.ts` — accepts `unknown`, returns validated `ExportBundle | DomainError`
|
||||
4. Import `rehydrateEncounter()` from `./encounter-storage.ts` for encounter and undo/redo stack validation
|
||||
5. Export `rehydrateCharacter()` from `player-character-storage.ts` and import it for player character validation
|
||||
6. Write tests covering: valid bundles, missing fields, invalid encounter, invalid player characters, empty stacks, unknown version
|
||||
|
||||
### Phase 2: Export Functionality
|
||||
|
||||
**Goal**: Users can download the current state as a `.json` file.
|
||||
|
||||
1. Create `export-import.ts` in `apps/web/src/persistence/`
|
||||
2. Implement `assembleExportBundle(encounter, undoRedoState, playerCharacters)` — pure function returning `ExportBundle`
|
||||
3. Implement `triggerDownload(bundle: ExportBundle)` — creates JSON blob, generates filename with date, triggers browser download
|
||||
4. Add "Export Encounter" item to action bar overflow menu
|
||||
5. Wire button to read from contexts and call export functions
|
||||
|
||||
### Phase 3: Import Functionality + Confirmation
|
||||
|
||||
**Goal**: Users can import a `.json` file with confirmation and error handling.
|
||||
|
||||
1. Add "Import Encounter" item to action bar overflow menu
|
||||
2. Implement file picker trigger (hidden `<input type="file">`)
|
||||
3. On file selected: read text, parse JSON, validate via use case
|
||||
4. If validation fails: show error toast
|
||||
5. If encounter is non-empty: show confirmation dialog
|
||||
6. On confirm (or if encounter is empty): replace encounter, undo/redo, and player characters via context setters
|
||||
7. Write integration test: export → import round-trip produces identical state
|
||||
|
||||
## Notes
|
||||
|
||||
- Import `rehydrateEncounter()` from `apps/web/src/persistence/encounter-storage.ts` for encounter validation — do not duplicate
|
||||
- Export `rehydrateCharacter()` from `apps/web/src/persistence/player-character-storage.ts` so it can be imported for player character validation
|
||||
- Follow existing file picker pattern from `apps/web/src/components/source-fetch-prompt.tsx`
|
||||
- Follow existing overflow menu pattern in `apps/web/src/components/action-bar.tsx`
|
||||
- Follow existing `<dialog>` pattern from `apps/web/src/components/settings-modal.tsx`
|
||||
- Commit after each phase checkpoint
|
||||
52
specs/007-json-import-export/quickstart.md
Normal file
52
specs/007-json-import-export/quickstart.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Quickstart: JSON Import/Export
|
||||
|
||||
**Feature**: 007-json-import-export
|
||||
**Date**: 2026-03-27
|
||||
|
||||
## Overview
|
||||
|
||||
Export and import the full application state (encounter, undo/redo history, player characters) as a JSON file. Enables backup/restore and sharing encounters between DMs.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **ExportBundle**: A JSON object containing `version`, `exportedAt`, `encounter`, `undoStack`, `redoStack`, and `playerCharacters`. This is the file format.
|
||||
- **Full replacement**: Import replaces all existing state — encounter, undo/redo, and player characters. It's not a merge.
|
||||
- **Validation reuse**: Import validation uses the same `rehydrateEncounter()` and player character validation functions that localStorage loading uses.
|
||||
|
||||
## Implementation Layers
|
||||
|
||||
### Domain Layer
|
||||
|
||||
No new domain functions are needed. The existing types (`Encounter`, `PlayerCharacter`, `UndoRedoState`) and validation functions are reused as-is.
|
||||
|
||||
A new `ExportBundle` type is defined in the domain layer as a pure data structure.
|
||||
|
||||
### Application Layer
|
||||
|
||||
A new use case for import validation and bundle assembly:
|
||||
- `validateImportBundle(data: unknown)` — validates and rehydrates an export bundle, returning the validated bundle or an error.
|
||||
|
||||
Export assembly is straightforward enough to live in the adapter layer (it's just reading and packaging existing state).
|
||||
|
||||
### Adapter Layer (Web)
|
||||
|
||||
- **Export**: Read state from contexts, assemble bundle, trigger browser download via `URL.createObjectURL()` + anchor element.
|
||||
- **Import**: File picker input, parse JSON, delegate to application-layer validation, show confirmation dialog if encounter is non-empty, replace state via context setters.
|
||||
- **UI**: Two new overflow menu items in the action bar — "Export Encounter" and "Import Encounter".
|
||||
|
||||
## File Locations
|
||||
|
||||
| Artifact | Path |
|
||||
|----------|------|
|
||||
| ExportBundle type | `packages/domain/src/types.ts` or new file |
|
||||
| Import validation use case | `packages/application/src/` |
|
||||
| Export/import adapter functions | `apps/web/src/persistence/` |
|
||||
| UI integration | `apps/web/src/components/action-bar.tsx` |
|
||||
| Confirmation dialog | `apps/web/src/components/` (new or reuse existing confirm pattern) |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Domain**: No new domain tests needed (existing types unchanged).
|
||||
- **Application**: Test `validateImportBundle()` with valid bundles, invalid bundles, missing fields, wrong types, and edge cases (empty encounter, empty stacks).
|
||||
- **Adapter**: Test export bundle assembly and import file handling.
|
||||
- **Integration**: Round-trip test — export then import should produce identical state.
|
||||
79
specs/007-json-import-export/research.md
Normal file
79
specs/007-json-import-export/research.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Research: JSON Import/Export
|
||||
|
||||
**Feature**: 007-json-import-export
|
||||
**Date**: 2026-03-27
|
||||
|
||||
## Decision 1: Export Bundle Contents
|
||||
|
||||
**Decision**: Export includes encounter, undo/redo stacks, and player characters. Excludes bestiary cache, theme, and rules edition.
|
||||
|
||||
**Rationale**: The spec explicitly includes undo/redo history and player characters. Theme and rules edition are user preferences that should not transfer between DMs. Bestiary cache is large and can be rebuilt from sources.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Include theme/edition settings — rejected because these are personal preferences, not encounter data.
|
||||
- Exclude undo/redo — rejected because the spec explicitly requires it and it enables full session restore.
|
||||
- Include bestiary cache — rejected because it's large, device-specific, and reconstructable from source URLs.
|
||||
|
||||
## Decision 2: Import Strategy — Full Replacement vs Merge
|
||||
|
||||
**Decision**: Full state replacement. Import replaces encounter, undo/redo, and player characters entirely.
|
||||
|
||||
**Rationale**: The spec states "Import replaces all existing state." Merging would require conflict resolution (duplicate IDs, name collisions) which adds significant complexity for unclear benefit.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Merge player characters — rejected because ID conflicts between different sessions would be complex to resolve and the spec doesn't call for it.
|
||||
- Selective import (pick which parts to load) — rejected as out of MVP scope.
|
||||
|
||||
## Decision 3: Validation Approach
|
||||
|
||||
**Decision**: Reuse existing `rehydrateEncounter()` and player character validation from the persistence layer. These functions already handle all field validation, type checking, and graceful degradation for invalid fields.
|
||||
|
||||
**Rationale**: The spec explicitly states "validated using the same rules as localStorage loading." The existing `rehydrateEncounter()` function already validates every combatant field, filters invalid conditions, clamps HP values, and rejects structurally malformed data. Reusing it ensures consistency and avoids duplication.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Write separate import validation — rejected because it would duplicate existing validation logic and risk divergence.
|
||||
- Stricter validation (reject files with any invalid field) — rejected because the existing approach gracefully degrades (strips invalid optional fields) which is more user-friendly.
|
||||
|
||||
## Decision 4: File Download Mechanism
|
||||
|
||||
**Decision**: Use `URL.createObjectURL()` with an anchor element's `download` attribute for triggering the file download.
|
||||
|
||||
**Rationale**: This is the standard browser-native approach that works across all modern browsers without popup blockers interfering. No server-side component needed.
|
||||
|
||||
**Alternatives considered**:
|
||||
- `window.open()` with data URI — rejected because popup blockers can interfere.
|
||||
- FileSaver.js library — rejected because the native approach is sufficient and avoids an additional dependency.
|
||||
|
||||
## Decision 5: File Upload Mechanism
|
||||
|
||||
**Decision**: Use an `<input type="file" accept=".json">` element, consistent with the existing pattern in `source-fetch-prompt.tsx` which uses `file.text()` + `JSON.parse()`.
|
||||
|
||||
**Rationale**: The codebase already has this pattern for bestiary source uploads. Reusing the same approach keeps the UX consistent.
|
||||
|
||||
## Decision 6: UI Placement
|
||||
|
||||
**Decision**: Place export and import actions in the action bar's overflow menu, alongside existing items like "Players", "Manage Sources", and "Settings".
|
||||
|
||||
**Rationale**: The overflow menu already groups secondary actions. Import/export are infrequent operations that don't need primary button placement. The action bar's `buildOverflowItems()` function makes this straightforward to add.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Settings modal — rejected because import/export are actions, not settings.
|
||||
- Dedicated toolbar buttons — rejected because import/export are infrequent and would clutter the primary UI.
|
||||
|
||||
## Decision 7: Export File Naming
|
||||
|
||||
**Decision**: Use a filename pattern like `initiative-export-YYYY-MM-DD.json` with the current date.
|
||||
|
||||
**Rationale**: The date provides context for when the export was created. Including "initiative" in the name makes the file's purpose clear when browsing a downloads folder.
|
||||
|
||||
## Decision 8: State Restoration After Import
|
||||
|
||||
**Decision**: Import must update both React state and localStorage in one operation. The encounter hook's `setEncounter()` triggers a `useEffect` that auto-saves to localStorage, and `setUndoRedoState()` similarly auto-saves. For player characters, the same auto-save pattern applies.
|
||||
|
||||
**Rationale**: Following the existing state flow ensures consistency. Setting React state triggers the existing persistence effects, so no manual localStorage writes are needed for the import path.
|
||||
|
||||
## Decision 9: Export Format Versioning
|
||||
|
||||
**Decision**: Include a `version` field in the export format (e.g., `1`) but do not implement migration logic in MVP.
|
||||
|
||||
**Rationale**: The spec's assumptions state "Future format versioning is not included in MVP baseline." Including the version field costs nothing and enables future migration logic without breaking existing exports.
|
||||
106
specs/007-json-import-export/spec.md
Normal file
106
specs/007-json-import-export/spec.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Feature Specification: JSON Import/Export
|
||||
|
||||
**Feature Branch**: `007-json-import-export`
|
||||
**Created**: 2026-03-27
|
||||
**Status**: Draft
|
||||
**Input**: Gitea issue #17 — JSON import/export for full encounter state
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### Story IE-1 — Export encounter to file (Priority: P1)
|
||||
|
||||
A DM has set up an encounter (combatants, HP, initiative, conditions) and wants to save or share it. They click an export button, choose whether to include undo/redo history, and either download a `.json` file or copy the JSON to their clipboard.
|
||||
|
||||
**Why this priority**: Export is the foundation — without it, import has nothing to work with. It also delivers standalone value as a backup mechanism.
|
||||
|
||||
**Independent Test**: Can be fully tested by creating an encounter, exporting (via download or clipboard), and verifying the output contains all encounter data and player character templates.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the user clicks the export action, **Then** a dialog appears with an option to include undo/redo history (off by default) and two export methods: download file and copy to clipboard.
|
||||
2. **Given** an encounter with combatants (some with HP, AC, conditions, initiative), **When** the user exports, **Then** the output contains the encounter state, player character templates, and optionally undo/redo stacks.
|
||||
3. **Given** an empty encounter with no combatants, **When** the user exports, **Then** the output contains the empty encounter state and any existing player character templates.
|
||||
4. **Given** an encounter with player character combatants (color, icon, linked template), **When** the user exports, **Then** the exported data preserves all player character visual properties and template links.
|
||||
5. **Given** the user chooses "Copy to clipboard", **When** the export completes, **Then** the JSON is copied and a visual confirmation is shown.
|
||||
|
||||
---
|
||||
|
||||
### Story IE-2 — Import encounter from file (Priority: P1)
|
||||
|
||||
A DM receives a `.json` file from another DM (or from their own earlier export) and wants to load it. They click an import button, choose an import method (file upload or clipboard paste), and the application replaces the current state with the imported data.
|
||||
|
||||
**Why this priority**: Import completes the core value proposition — without it, export is just a read-only backup. Both are needed for the feature to be useful.
|
||||
|
||||
**Independent Test**: Can be tested by importing a valid `.json` file (or pasting valid JSON from the clipboard) and verifying the encounter, undo/redo history, and player characters are replaced with the imported data.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the user clicks the import action, **Then** a dialog appears offering two import methods: file upload and clipboard paste.
|
||||
2. **Given** the current encounter is empty, **When** the user imports valid encounter data (via file or clipboard), **Then** the application loads the imported encounter, undo/redo history, and player characters without any confirmation prompt.
|
||||
3. **Given** the current encounter has combatants, **When** the user imports valid encounter data, **Then** a confirmation dialog appears warning that the current encounter will be replaced.
|
||||
4. **Given** the confirmation dialog is shown, **When** the user confirms, **Then** the current encounter, undo/redo history, and player characters are replaced with the imported data.
|
||||
5. **Given** the confirmation dialog is shown, **When** the user cancels, **Then** the current state remains unchanged.
|
||||
|
||||
---
|
||||
|
||||
### Story IE-3 — Reject invalid import files (Priority: P2)
|
||||
|
||||
A DM accidentally selects a wrong file (a non-JSON file, a corrupted export, or a JSON file with the wrong structure). The application rejects it and shows a clear error message without losing the current state.
|
||||
|
||||
**Why this priority**: Error handling is essential for a good user experience but secondary to the core import/export flow.
|
||||
|
||||
**Independent Test**: Can be tested by attempting to import various invalid files and verifying appropriate error messages appear while the current state is preserved.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the user selects a non-JSON file (e.g., an image), **When** the import attempts to parse it, **Then** a user-facing error message is shown and the current state is unchanged.
|
||||
2. **Given** the user selects a JSON file with missing required fields, **When** the import validates it, **Then** a user-facing error message is shown and the current state is unchanged.
|
||||
3. **Given** the user selects a JSON file with a valid top-level structure but individual combatants with invalid fields (e.g., negative HP, unknown condition IDs), **When** the import validates it, **Then** invalid fields on otherwise valid combatants are dropped/defaulted (same as localStorage rehydration), but if the top-level structure is malformed (missing `encounter` key, wrong types), the file is rejected with an error message.
|
||||
4. **Given** the user chooses clipboard import but the clipboard is empty or contains non-JSON text, **Then** a user-facing error message is shown and the current state is unchanged.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when the exported file is from a newer version of the application with unknown fields? The import ignores unknown fields and loads what it can validate.
|
||||
- What happens when the user imports a file with player characters that conflict with existing player character IDs? The imported player characters replace the existing ones entirely (full state replacement, not merge).
|
||||
- What happens when the undo/redo stacks in the imported file are empty or missing? The system loads with empty undo/redo stacks (same as a fresh session).
|
||||
- What happens when the browser blocks the file download (e.g., popup blocker)? The export uses a direct download mechanism (anchor element with download attribute) that is not subject to popup blocking.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST provide an export action accessible from the UI that lets the user download the application state as a `.json` file or copy it to the clipboard.
|
||||
- **FR-002**: The exported data MUST contain the current encounter (combatants and turn/round state) and player character templates. Undo/redo stacks MUST be includable via a user option (default: excluded).
|
||||
- **FR-003**: The exported file MUST use a human-readable default filename that includes the date. The user MAY optionally specify a custom filename; the `.json` extension is appended automatically if not provided.
|
||||
- **FR-004**: The system MUST provide an import action accessible from the UI that lets the user choose between uploading a `.json` file or pasting from the clipboard.
|
||||
- **FR-005**: On import, the system MUST replace the current encounter, undo/redo history, and player characters with the imported data.
|
||||
- **FR-006**: The system MUST show a confirmation dialog before importing if the current encounter is non-empty (has at least one combatant).
|
||||
- **FR-007**: The system MUST validate imported data using the same rules applied when loading from localStorage — invalid fields are cleaned or dropped, structurally malformed files are rejected entirely.
|
||||
- **FR-008**: The system MUST show a user-facing error message when an imported file is rejected as invalid.
|
||||
- **FR-009**: A failed or cancelled import MUST NOT alter the current application state.
|
||||
- **FR-010**: Export and import actions MUST be accessible from the same location in the UI.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Export Bundle**: A single JSON structure containing the encounter snapshot, undo stack, redo stack, and player character list. Represents the full application state at the time of export.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Users can export the current state to a downloadable file in one click.
|
||||
- **SC-002**: Users can import a previously exported file and see the full encounter restored, including combatant stats, turn tracking, and player characters.
|
||||
- **SC-003**: Importing an invalid file shows an error message within 1 second without affecting the current state.
|
||||
- **SC-004**: A round-trip (export then import) produces an encounter identical to the original.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The export format does not need to be backwards-compatible across application versions at this stage. Future format versioning is not included in MVP baseline.
|
||||
- Export/import covers the three main state stores: encounter, undo/redo, and player characters. Bestiary cache and user settings (theme, rules edition) are excluded.
|
||||
- The import is a full state replacement, not a merge. There is no selective import of individual pieces.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Undo/redo system (spec 006) must be implemented so that undo/redo stacks can be included in the export.
|
||||
142
specs/007-json-import-export/tasks.md
Normal file
142
specs/007-json-import-export/tasks.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Tasks: JSON Import/Export
|
||||
|
||||
**Input**: Design documents from `/specs/007-json-import-export/`
|
||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, quickstart.md
|
||||
|
||||
**Tests**: Domain tests included (pure function testing is standard for this project per CLAUDE.md).
|
||||
|
||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundational (ExportBundle Type + Validation)
|
||||
|
||||
**Purpose**: Define the export format and validation logic that all stories depend on.
|
||||
|
||||
**⚠️ CRITICAL**: Export and import both depend on the ExportBundle type and validation.
|
||||
|
||||
- [x] T001 [P] Create `ExportBundle` type in `packages/domain/src/export-bundle.ts` with fields: `version` (number), `exportedAt` (string), `encounter` (Encounter), `undoStack` (Encounter[]), `redoStack` (Encounter[]), `playerCharacters` (PlayerCharacter[]). Export from `packages/domain/src/index.ts`.
|
||||
- [x] T002 [P] Create `validateImportBundle()` in `apps/web/src/persistence/export-import.ts` — accepts `unknown`, validates top-level structure (version, encounter, undoStack, redoStack, playerCharacters), delegates encounter validation to `rehydrateEncounter()` (imported from `./encounter-storage.ts`) and player character validation to `rehydrateCharacter()` (exported from `./player-character-storage.ts`). Returns validated `ExportBundle` or `DomainError`.
|
||||
- [x] T003 Write tests for `validateImportBundle()` in `apps/web/src/__tests__/validate-import-bundle.test.ts` — valid bundle, missing fields, invalid encounter, invalid player characters, empty stacks, unknown version, non-object input, invalid JSON types for each field.
|
||||
|
||||
**Checkpoint**: ExportBundle type and validation are tested and ready for use by export and import stories.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: User Story 1 — Export Encounter to File (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Users can download the current state as a `.json` file in one click.
|
||||
|
||||
**Independent Test**: Create an encounter with combatants, HP, conditions, and player characters. Click export. Verify the downloaded file contains all state and is valid JSON matching the ExportBundle schema.
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T004 [P] [US1] Create `assembleExportBundle()` function in `apps/web/src/persistence/export-import.ts` — takes encounter, undoRedoState, and playerCharacters, returns an `ExportBundle` with version 1 and current ISO timestamp.
|
||||
- [x] T005 [P] [US1] Create `triggerDownload(bundle: ExportBundle)` function in `apps/web/src/persistence/export-import.ts` — serializes bundle to JSON, creates a Blob, generates filename `initiative-export-YYYY-MM-DD.json`, triggers download via anchor element with `download` attribute.
|
||||
- [x] T006 [US1] Add "Export Encounter" item to the overflow menu in `apps/web/src/components/action-bar.tsx` — wire it to read encounter, undoRedoState, and playerCharacters from contexts, call `assembleExportBundle()`, then `triggerDownload()`. Use a `Download` icon from Lucide.
|
||||
- [x] T007 [US1] Write test for `assembleExportBundle()` in `apps/web/src/__tests__/export-import.test.ts` — verify output shape, version field, timestamp format, and that encounter/stacks/characters are included.
|
||||
|
||||
**Checkpoint**: Export is fully functional. Users can download state as JSON.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 2 — Import Encounter from File (Priority: P1)
|
||||
|
||||
**Goal**: Users can import a `.json` file and replace the current state.
|
||||
|
||||
**Independent Test**: Export a file, clear the encounter, import the file. Verify the encounter is restored with all combatants, HP, conditions, undo/redo history, and player characters.
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T008 [US2] Expose `setEncounter` and `setUndoRedoState` from `useEncounter` hook via `EncounterContext` in `apps/web/src/hooks/use-encounter.ts` and `apps/web/src/contexts/encounter-context.tsx` — these are needed for import to replace state directly (bypassing individual use cases). Also expose a `replacePlayerCharacters` setter from `usePlayerCharacters` hook via `PlayerCharactersContext` in `apps/web/src/hooks/use-player-characters.ts` and `apps/web/src/contexts/player-characters-context.tsx`.
|
||||
- [x] T009 [US2] Create `readImportFile(file: File)` function in `apps/web/src/persistence/export-import.ts` — reads file as text, parses JSON, calls `validateImportUseCase()`, returns validated `ExportBundle` or error string.
|
||||
- [x] T010 [US2] Create `ImportConfirmPrompt` component in `apps/web/src/components/import-confirm-prompt.tsx` — confirmation dialog (using native `<dialog>` element consistent with existing patterns) warning that the current encounter will be replaced. Props: `open`, `onConfirm`, `onCancel`.
|
||||
- [x] T011 [US2] Add "Import Encounter" item to the overflow menu in `apps/web/src/components/action-bar.tsx` — renders a hidden `<input type="file" accept=".json">`, triggers it on menu item click. On file selected: validate via `readImportFile()`, show error toast on failure, show `ImportConfirmPrompt` if encounter is non-empty, replace state on confirm (or directly if encounter is empty). Use an `Upload` icon from Lucide.
|
||||
- [x] T012 [US2] Write round-trip test in `apps/web/src/__tests__/export-import.test.ts` — assemble an export bundle, validate it via the import use case, verify the result matches the original state.
|
||||
|
||||
**Checkpoint**: Import is fully functional. Users can load exported files and restore state.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 3 — Reject Invalid Import Files (Priority: P2)
|
||||
|
||||
**Goal**: Invalid files are rejected with clear error messages while preserving current state.
|
||||
|
||||
**Independent Test**: Attempt to import various invalid files (non-JSON, wrong structure, malformed combatants). Verify error messages appear and current state is unchanged.
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T013 [US3] Add user-facing error toast for import failures in `apps/web/src/components/action-bar.tsx` — use the existing toast/alert pattern in the app. Show specific messages: "Invalid file format" for non-JSON, "Invalid encounter data" for validation failures.
|
||||
- [x] T014 [US3] Write validation edge case tests in `apps/web/src/__tests__/validate-import-bundle.test.ts` — non-JSON text file content, JSON array instead of object, missing version field, version 0 or negative, encounter that fails rehydration, undo stack with mix of valid and invalid entries (valid ones kept, invalid dropped), player characters with invalid color/icon (stripped but character kept). Include a state-preservation test: set up an encounter, attempt import of an invalid file, verify encounter is unchanged after error (FR-009).
|
||||
|
||||
**Checkpoint**: All three stories are complete. Invalid files are handled gracefully.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final cleanup and documentation.
|
||||
|
||||
- [x] T015 Update CLAUDE.md spec listing to describe the feature in `CLAUDE.md`
|
||||
- [x] T016 N/A — no project-level README.md exists
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Foundational (Phase 1)**: No dependencies — can start immediately
|
||||
- **US1 Export (Phase 2)**: Depends on Phase 1 (needs ExportBundle type)
|
||||
- **US2 Import (Phase 3)**: Depends on Phase 1 (needs validation use case) and Phase 2 (needs export for round-trip testing)
|
||||
- **US3 Error Handling (Phase 4)**: Depends on Phase 3 (builds on import flow)
|
||||
- **Polish (Phase 5)**: Depends on all stories being complete
|
||||
|
||||
### Within Each Phase
|
||||
|
||||
- Tasks marked [P] can run in parallel
|
||||
- T001 and T002 are parallel (different files)
|
||||
- T004 and T005 are parallel (different functions, same file but independent)
|
||||
- T008 must complete before T011 (setters must exist before import wiring)
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- Phase 1: T001 and T002 can run in parallel (type definition + validation use case)
|
||||
- Phase 2: T004 and T005 can run in parallel (assemble + download functions)
|
||||
- Phase 2: T007 can run in parallel with T006 (test + UI wiring)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (Export Only)
|
||||
|
||||
1. Complete Phase 1: ExportBundle type + validation
|
||||
2. Complete Phase 2: Export functionality
|
||||
3. **STOP and VALIDATE**: User can download encounter state as JSON
|
||||
4. Continue to Phase 3: Import functionality
|
||||
5. Continue to Phase 4: Error handling polish
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Phase 1 → Foundation ready
|
||||
2. Phase 2 → Export works → Delivers backup value
|
||||
3. Phase 3 → Import works → Delivers full round-trip + sharing value
|
||||
4. Phase 4 → Error handling → Production-ready robustness
|
||||
5. Phase 5 → Documentation updated
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Reuse `rehydrateEncounter()` from `apps/web/src/persistence/encounter-storage.ts` for all encounter validation — do not duplicate
|
||||
- Follow existing file picker pattern from `apps/web/src/components/source-fetch-prompt.tsx`
|
||||
- Follow existing overflow menu pattern in `apps/web/src/components/action-bar.tsx`
|
||||
- Follow existing `<dialog>` pattern from `apps/web/src/components/settings-modal.tsx`
|
||||
- Commit after each phase checkpoint
|
||||
36
specs/008-encounter-difficulty/checklists/requirements.md
Normal file
36
specs/008-encounter-difficulty/checklists/requirements.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Specification Quality Checklist: Encounter Difficulty Indicator
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-27
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||
- The spec references `creatureId` and `playerCharacterId` field names — these are domain entity attributes, not implementation details.
|
||||
- Cross-dependency with spec 005 (PlayerCharacter) is documented in FR-011 and Assumptions.
|
||||
145
specs/008-encounter-difficulty/data-model.md
Normal file
145
specs/008-encounter-difficulty/data-model.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# Data Model: Encounter Difficulty Indicator
|
||||
|
||||
**Date**: 2026-03-27 | **Feature**: 008-encounter-difficulty
|
||||
|
||||
## Entities
|
||||
|
||||
### PlayerCharacter (modified)
|
||||
|
||||
Existing entity from spec 005. Adding one optional field.
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| id | PlayerCharacterId | yes | Existing — branded string |
|
||||
| name | string | yes | Existing |
|
||||
| ac | number | yes | Existing |
|
||||
| maxHp | number | yes | Existing |
|
||||
| color | PlayerColor | no | Existing |
|
||||
| icon | PlayerIcon | no | Existing |
|
||||
| **level** | **number** | **no** | **NEW — integer 1-20. Used for XP budget calculation. PCs without level are excluded from difficulty calc.** |
|
||||
|
||||
**Validation rules for `level`**:
|
||||
- If provided, must be an integer
|
||||
- If provided, must be >= 1 and <= 20
|
||||
- If omitted/undefined, PC is excluded from difficulty budget
|
||||
|
||||
### DifficultyTier (new)
|
||||
|
||||
Enumeration of encounter difficulty categories.
|
||||
|
||||
| Value | Display Label | Visual |
|
||||
|-------|---------------|--------|
|
||||
| `"trivial"` | Trivial | 3 empty bars |
|
||||
| `"low"` | Low | 1 green bar |
|
||||
| `"moderate"` | Moderate | 2 yellow bars |
|
||||
| `"high"` | High | 3 red bars |
|
||||
|
||||
### DifficultyResult (new)
|
||||
|
||||
Output of the difficulty calculation. Pure data object.
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| tier | DifficultyTier | The determined difficulty category |
|
||||
| totalMonsterXp | number | Sum of XP for all bestiary-linked combatants |
|
||||
| partyBudget | { low: number; moderate: number; high: number } | XP thresholds for the party |
|
||||
|
||||
### XP Budget per Character (static lookup)
|
||||
|
||||
Maps character level to XP thresholds. Data from 2024 5.5e DMG.
|
||||
|
||||
| Level | Low | Moderate | High |
|
||||
|-------|-----|----------|------|
|
||||
| 1 | 50 | 75 | 100 |
|
||||
| 2 | 100 | 150 | 200 |
|
||||
| 3 | 150 | 225 | 400 |
|
||||
| 4 | 250 | 375 | 500 |
|
||||
| 5 | 500 | 750 | 1,100 |
|
||||
| 6 | 600 | 1,000 | 1,400 |
|
||||
| 7 | 750 | 1,300 | 1,700 |
|
||||
| 8 | 1,000 | 1,700 | 2,100 |
|
||||
| 9 | 1,300 | 2,000 | 2,600 |
|
||||
| 10 | 1,600 | 2,300 | 3,100 |
|
||||
| 11 | 1,900 | 2,900 | 4,100 |
|
||||
| 12 | 2,200 | 3,700 | 4,700 |
|
||||
| 13 | 2,600 | 4,200 | 5,400 |
|
||||
| 14 | 2,900 | 4,900 | 6,200 |
|
||||
| 15 | 3,300 | 5,400 | 7,800 |
|
||||
| 16 | 3,800 | 6,100 | 9,800 |
|
||||
| 17 | 4,500 | 7,200 | 11,700 |
|
||||
| 18 | 5,000 | 8,700 | 14,200 |
|
||||
| 19 | 5,500 | 10,700 | 17,200 |
|
||||
| 20 | 6,400 | 13,200 | 22,000 |
|
||||
|
||||
### CR-to-XP (static lookup)
|
||||
|
||||
Maps challenge rating strings to XP values. Standard 5e values.
|
||||
|
||||
| CR | XP |
|
||||
|----|-----|
|
||||
| 0 | 0 |
|
||||
| 1/8 | 25 |
|
||||
| 1/4 | 50 |
|
||||
| 1/2 | 100 |
|
||||
| 1 | 200 |
|
||||
| 2 | 450 |
|
||||
| 3 | 700 |
|
||||
| 4 | 1,100 |
|
||||
| 5 | 1,800 |
|
||||
| 6 | 2,300 |
|
||||
| 7 | 2,900 |
|
||||
| 8 | 3,900 |
|
||||
| 9 | 5,000 |
|
||||
| 10 | 5,900 |
|
||||
| 11 | 7,200 |
|
||||
| 12 | 8,400 |
|
||||
| 13 | 10,000 |
|
||||
| 14 | 11,500 |
|
||||
| 15 | 13,000 |
|
||||
| 16 | 15,000 |
|
||||
| 17 | 18,000 |
|
||||
| 18 | 20,000 |
|
||||
| 19 | 22,000 |
|
||||
| 20 | 25,000 |
|
||||
| 21 | 33,000 |
|
||||
| 22 | 41,000 |
|
||||
| 23 | 50,000 |
|
||||
| 24 | 62,000 |
|
||||
| 25 | 75,000 |
|
||||
| 26 | 90,000 |
|
||||
| 27 | 105,000 |
|
||||
| 28 | 120,000 |
|
||||
| 29 | 135,000 |
|
||||
| 30 | 155,000 |
|
||||
|
||||
## Relationships
|
||||
|
||||
```
|
||||
PlayerCharacter (has optional level)
|
||||
│
|
||||
▼ linked via playerCharacterId
|
||||
Combatant (in Encounter)
|
||||
│
|
||||
▼ linked via creatureId
|
||||
Creature (has cr string)
|
||||
│
|
||||
▼ lookup via CR_TO_XP table
|
||||
XP value (number)
|
||||
|
||||
Party levels ──► XP_BUDGET_TABLE ──► { low, moderate, high } thresholds
|
||||
Monster XP total ──► compare against thresholds ──► DifficultyTier
|
||||
```
|
||||
|
||||
## State Transitions
|
||||
|
||||
The difficulty calculation is stateless — it's a pure derivation from current encounter state. No state machine or transitions to model.
|
||||
|
||||
**Input derivation** (at adapter layer):
|
||||
1. For each combatant with `playerCharacterId` → look up `PlayerCharacter.level` → collect non-undefined levels
|
||||
2. For each combatant with `creatureId` → look up `Creature.cr` → collect CR strings
|
||||
3. Pass `(levels[], crs[])` to domain function
|
||||
|
||||
**Pure calculation** (domain layer):
|
||||
1. Sum XP budget per level → `partyBudget.{low, moderate, high}`
|
||||
2. Convert each CR to XP → sum → `totalMonsterXp`
|
||||
3. Compare `totalMonsterXp` against thresholds → `DifficultyTier`
|
||||
81
specs/008-encounter-difficulty/plan.md
Normal file
81
specs/008-encounter-difficulty/plan.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Implementation Plan: Encounter Difficulty Indicator
|
||||
|
||||
**Branch**: `008-encounter-difficulty` | **Date**: 2026-03-27 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `/specs/008-encounter-difficulty/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Add a live 3-bar encounter difficulty indicator to the top bar, based on the 2024 5.5e XP budget system. The domain layer gains pure lookup tables (CR-to-XP, XP Budget per Character) and a difficulty calculation function. The `PlayerCharacter` type gains an optional `level` field (1-20). The UI renders a compact bar indicator that derives difficulty from encounter combatants, player character levels, and bestiary creature CRs.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.8 (strict mode, `verbatimModuleSyntax`)
|
||||
**Primary Dependencies**: React 19, Vite 6, Tailwind CSS v4, Lucide React (icons)
|
||||
**Storage**: localStorage (encounter + player characters), IndexedDB (bestiary cache)
|
||||
**Testing**: Vitest (v8 coverage)
|
||||
**Target Platform**: Web (mobile-first responsive, desktop side panels)
|
||||
**Project Type**: Web application (monorepo: domain → application → web adapter)
|
||||
**Performance Goals**: Indicator updates within the same render cycle as combatant changes
|
||||
**Constraints**: Offline-capable, local-first, single-user. Max 8 props per component.
|
||||
**Scale/Scope**: Single-page app, ~15 components, 3-layer architecture
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| I. Deterministic Domain Core | PASS | Difficulty calculation is a pure function: `(partyLevels, monsterCRs) → DifficultyResult`. No I/O, no randomness, no clocks. Lookup tables are static data. |
|
||||
| II. Layered Architecture | PASS | New domain module (`encounter-difficulty.ts`) has zero imports from application/adapter. UI component composes data from existing contexts. |
|
||||
| II-A. Context-Based State Flow | PASS | Difficulty indicator reads from existing contexts (encounter, player characters, bestiary). No new props beyond what's needed for the component itself. |
|
||||
| III. Clarification-First | PASS | All design decisions resolved in spec: optional level, 3 tiers, 5.5e rules only, no multipliers, hidden when insufficient data. |
|
||||
| IV. Escalation Gates | PASS | Feature scoped to spec. MVP exclusions documented (no custom CR, no 2014 rules, no XP numbers in UI). |
|
||||
| V. MVP Baseline Language | PASS | Exclusions use "MVP baseline does not include" language. |
|
||||
| VI. No Gameplay Rules in Constitution | PASS | XP tables and difficulty rules live in the feature spec, not the constitution. |
|
||||
|
||||
No violations. Complexity Tracking section not needed.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/008-encounter-difficulty/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── quickstart.md # Phase 1 output
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
packages/domain/src/
|
||||
├── encounter-difficulty.ts # NEW: CR-to-XP, XP budget tables, difficulty calc
|
||||
├── player-character-types.ts # MODIFY: add optional level field
|
||||
├── create-player-character.ts # MODIFY: add level validation
|
||||
├── edit-player-character.ts # MODIFY: add level validation + apply
|
||||
├── __tests__/
|
||||
│ ├── encounter-difficulty.test.ts # NEW: unit tests for difficulty calc
|
||||
│ ├── create-player-character.test.ts # MODIFY: add level tests
|
||||
│ └── edit-player-character.test.ts # MODIFY: add level tests
|
||||
└── index.ts # MODIFY: export new functions/types
|
||||
|
||||
packages/application/src/
|
||||
├── create-player-character-use-case.ts # MODIFY: pass level through
|
||||
└── edit-player-character-use-case.ts # MODIFY: pass level through
|
||||
|
||||
apps/web/src/
|
||||
├── components/
|
||||
│ ├── difficulty-indicator.tsx # NEW: 3-bar indicator component
|
||||
│ ├── turn-navigation.tsx # MODIFY: add indicator to top bar
|
||||
│ ├── create-player-modal.tsx # MODIFY: add level field
|
||||
│ └── player-character-manager.tsx # MODIFY: show level, pass to edit
|
||||
├── hooks/
|
||||
│ └── use-difficulty.ts # NEW: hook composing contexts → difficulty result
|
||||
└── contexts/
|
||||
└── player-characters-context.tsx # MODIFY: pass level to create/edit
|
||||
```
|
||||
|
||||
**Structure Decision**: Follows existing layered architecture. New domain module for difficulty calculation. New UI component + hook at adapter layer. No new contexts needed — the difficulty hook composes existing contexts.
|
||||
67
specs/008-encounter-difficulty/quickstart.md
Normal file
67
specs/008-encounter-difficulty/quickstart.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Quickstart: Encounter Difficulty Indicator
|
||||
|
||||
**Date**: 2026-03-27 | **Feature**: 008-encounter-difficulty
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Phase 1: Domain — Level field + validation
|
||||
|
||||
1. Add `level?: number` to `PlayerCharacter` in `player-character-types.ts`
|
||||
2. Add level validation to `createPlayerCharacter()` — validate if provided: integer, 1-20
|
||||
3. Add level validation to `editPlayerCharacter()` — same rules in `validateFields()`, apply in `applyFields()`
|
||||
4. Add tests for level validation in existing test files
|
||||
5. Export updated types from `index.ts`
|
||||
|
||||
### Phase 2: Domain — Difficulty calculation
|
||||
|
||||
1. Create `encounter-difficulty.ts` with:
|
||||
- `CR_TO_XP` lookup (Record<string, number>)
|
||||
- `XP_BUDGET_PER_CHARACTER` lookup (Record<number, { low, moderate, high }>)
|
||||
- `crToXp(cr: string): number` — returns 0 for unknown CRs
|
||||
- `calculateEncounterDifficulty(partyLevels: number[], monsterCrs: string[]): DifficultyResult`
|
||||
- `DifficultyTier` type and `DifficultyResult` type
|
||||
2. Add comprehensive unit tests covering:
|
||||
- All CR string formats (0, 1/8, 1/4, 1/2, integers)
|
||||
- All difficulty tiers including trivial
|
||||
- DMG example encounters (from issue comments)
|
||||
- Edge cases: empty arrays, unknown CRs, mixed levels
|
||||
3. Export from `index.ts`
|
||||
|
||||
### Phase 3: Application — Pass level through use cases
|
||||
|
||||
1. Update `CreatePlayerCharacterUseCase` to accept and pass `level`
|
||||
2. Update `EditPlayerCharacterUseCase` to accept and pass `level`
|
||||
|
||||
### Phase 4: Web — Level field in PC forms
|
||||
|
||||
1. Update player characters context to pass `level` in create/edit calls
|
||||
2. Add level input field to create player modal (optional number, 1-20)
|
||||
3. Add level display + edit in player character manager
|
||||
4. Test: create PC with level, edit level, verify persistence
|
||||
|
||||
### Phase 5: Web — Difficulty indicator
|
||||
|
||||
1. Create `useDifficulty()` hook:
|
||||
- Consume encounter context, player characters context, bestiary hook
|
||||
- Map combatants → party levels + monster CRs
|
||||
- Call domain `calculateEncounterDifficulty()`
|
||||
- Return `DifficultyResult | null` (null when insufficient data)
|
||||
2. Create `DifficultyIndicator` component:
|
||||
- Render 3 bars with conditional fill colors
|
||||
- Add `title` attribute for tooltip
|
||||
- Hidden when hook returns null
|
||||
3. Add indicator to `TurnNavigation` component, right of active combatant name
|
||||
4. Test: manual verification with various encounter compositions
|
||||
|
||||
## Key Patterns to Follow
|
||||
|
||||
- **Domain purity**: `calculateEncounterDifficulty` takes `number[]` and `string[]`, not domain types
|
||||
- **Validation pattern**: Follow `color`/`icon` optional field pattern in create/edit
|
||||
- **Hook composition**: `useDifficulty` composes multiple contexts like `useInitiativeRolls`
|
||||
- **Component size**: DifficultyIndicator should be <8 props (likely 0-1, just the result)
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Domain tests** (unit): Exhaustive coverage of `calculateEncounterDifficulty` and `crToXp` with table-driven tests. Cover all 34 CR values, all 20 levels, and the DMG example encounters.
|
||||
- **Domain tests** (level validation): Test create/edit with valid levels, invalid levels, and undefined level.
|
||||
- **Integration**: Verify indicator appears/hides correctly through component rendering (if existing test patterns support this).
|
||||
87
specs/008-encounter-difficulty/research.md
Normal file
87
specs/008-encounter-difficulty/research.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Research: Encounter Difficulty Indicator
|
||||
|
||||
**Date**: 2026-03-27 | **Feature**: 008-encounter-difficulty
|
||||
|
||||
## Research Questions & Findings
|
||||
|
||||
### 1. Where does the CR-to-XP mapping come from?
|
||||
|
||||
**Decision**: Create a static lookup table in the domain layer as a `Record<string, number>`.
|
||||
|
||||
**Rationale**: No CR-to-XP mapping exists in the codebase. The bestiary index stores CR as a string but does not include XP. The standard 5e CR-to-XP table is fixed (published rules), so a static lookup is the simplest approach. The existing `proficiencyBonus(cr)` function in `creature-types.ts` demonstrates the pattern: a pure function that maps a CR string to a derived value.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Adding XP to the bestiary index: Would require rebuilding the index generation script and inflating the index size. Unnecessary since XP is deterministically derived from CR.
|
||||
- Computing XP from CR via formula: No clean formula exists for the 5e table — it's an irregular curve. A lookup table is more reliable and readable.
|
||||
|
||||
### 2. How does the difficulty component access creature CR at runtime?
|
||||
|
||||
**Decision**: The difficulty hook (`useDifficulty`) will consume `useBestiary()` to access the creature map, then look up CR for each combatant with a `creatureId`.
|
||||
|
||||
**Rationale**: The `useBestiary()` hook already provides `getCreature(id): Creature | undefined`, which returns the full Creature including `cr`. This is the established pattern — `CombatantRow` and `StatBlockPanel` already use it. No new adapter or port is needed.
|
||||
|
||||
**Data flow**:
|
||||
```
|
||||
useDifficulty hook
|
||||
├── useEncounterContext() → encounter.combatants[]
|
||||
├── usePlayerCharactersContext() → characters[] (for level lookup)
|
||||
└── useBestiary() → getCreature(creatureId) → creature.cr
|
||||
└── domain: crToXp(cr) → xp
|
||||
└── domain: calculateDifficulty({ partyLevels, monsterXp }) → DifficultyResult
|
||||
```
|
||||
|
||||
**Alternatives considered**:
|
||||
- Passing CR through the application layer port: Would add a `BestiarySourceCache` dependency to the difficulty calculation, breaking domain purity. Better to resolve CRs to XP values in the hook (adapter layer) and pass pure data to the domain function.
|
||||
|
||||
### 3. How should the domain difficulty function be structured?
|
||||
|
||||
**Decision**: A single pure function that takes resolved inputs (party levels as `number[]`, monster XP values as `number[]`) and returns a `DifficultyResult`.
|
||||
|
||||
**Rationale**: Keeping the domain function agnostic to how levels and XP are obtained preserves purity and testability. The function doesn't need to know about `PlayerCharacter`, `Combatant`, or `Creature` types — just numbers. This follows the pattern of `advanceTurn(encounter)` and `proficiencyBonus(cr)`: pure inputs → pure outputs.
|
||||
|
||||
**Function signature** (domain):
|
||||
```
|
||||
calculateEncounterDifficulty(partyLevels: number[], monsterCrs: string[]): DifficultyResult
|
||||
```
|
||||
|
||||
Takes party levels (already filtered to only PCs with levels) and monster CR strings (already filtered to only bestiary-linked combatants). Returns the tier, total XP, and budget thresholds.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Taking full `Combatant[]` + `PlayerCharacter[]`: Would couple the difficulty module to combatant/PC types unnecessarily.
|
||||
- Separate functions for budget and XP: The calculation is simple enough for one function. Internal helpers can split the logic without exposing multiple public functions.
|
||||
|
||||
### 4. How does `level` integrate with the PlayerCharacter CRUD flow?
|
||||
|
||||
**Decision**: Add `level?: number` to `PlayerCharacter` interface. Validation in `createPlayerCharacter` and `editPlayerCharacter` domain functions. Passed through use cases and context like existing fields.
|
||||
|
||||
**Rationale**: The existing pattern for optional fields (e.g., `color`, `icon`) is well-established:
|
||||
- Domain type: optional field on interface
|
||||
- Create function: validate if provided, skip if undefined
|
||||
- Edit function: validate in `validateFields()`, apply in `applyFields()`
|
||||
- Use case: pass through from adapter
|
||||
- Context: expose in create/edit methods
|
||||
- UI: optional input field in modal
|
||||
|
||||
Following this exact pattern for `level` minimizes risk and code churn.
|
||||
|
||||
### 5. Does adding `level` to PlayerCharacter affect export compatibility?
|
||||
|
||||
**Decision**: No version bump needed. The field is optional, so existing exports (version 1) import correctly — `level` will be `undefined` for old data.
|
||||
|
||||
**Rationale**: The `ExportBundle` includes `playerCharacters: readonly PlayerCharacter[]`. Adding an optional field to `PlayerCharacter` is backward-compatible: old exports simply lack the field, which TypeScript treats as `undefined`. The `validateImportBundle()` function doesn't validate individual PlayerCharacter fields beyond basic structure checks.
|
||||
|
||||
### 6. Should the difficulty calculation live in a new domain module or extend an existing one?
|
||||
|
||||
**Decision**: New module `encounter-difficulty.ts` in `packages/domain/src/`.
|
||||
|
||||
**Rationale**: The difficulty calculation is a self-contained concern with its own lookup tables and types. It doesn't naturally belong in `creature-types.ts` (which is about creature data structures) or `types.ts` (which is about encounter/combatant structure). A dedicated module keeps concerns separated and makes the feature easy to find, test, and potentially remove.
|
||||
|
||||
### 7. How should the 3-bar indicator be rendered?
|
||||
|
||||
**Decision**: Simple `div` elements with Tailwind CSS classes for color and fill state. No external icon or SVG needed.
|
||||
|
||||
**Rationale**: The bars are simple rectangles with conditional fill colors (green/yellow/red). Tailwind's `bg-*` utilities handle this trivially. The existing codebase uses Tailwind for all styling with no CSS-in-JS or external style libraries. A native HTML tooltip (`title` attribute) handles the hover tooltip requirement.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Lucide icons: No suitable "signal bars" icon exists. Custom SVG would be overkill for 3 rectangles.
|
||||
- CSS custom properties for colors: Unnecessary abstraction for 3 fixed states.
|
||||
205
specs/008-encounter-difficulty/spec.md
Normal file
205
specs/008-encounter-difficulty/spec.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# Feature Specification: Encounter Difficulty Indicator
|
||||
|
||||
**Feature Branch**: `008-encounter-difficulty`
|
||||
**Created**: 2026-03-27
|
||||
**Status**: Draft
|
||||
**Input**: Gitea issue #18 — "Encounter difficulty indicator (5.5e XP budget)"
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### Difficulty Calculation
|
||||
|
||||
**Story ED-1 — See encounter difficulty at a glance (Priority: P1)**
|
||||
|
||||
A game master is building an encounter by adding monsters and player characters. As they add bestiary-linked creatures alongside PC combatants that have levels assigned, a compact 3-bar difficulty indicator appears in the top bar next to the active combatant name. The bars fill and change color to reflect the current difficulty tier: one green bar for Low, two yellow bars for Moderate, three red bars for High. Hovering over the indicator shows a tooltip with the difficulty label (e.g., "Moderate encounter difficulty").
|
||||
|
||||
**Why this priority**: This is the entire feature — without the indicator there is nothing to show.
|
||||
|
||||
**Independent Test**: Can be fully tested by adding PC combatants (with levels) and bestiary-linked monsters to an encounter and verifying the indicator appears with the correct difficulty tier.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an encounter with at least one PC combatant whose player character has a level and at least one bestiary-linked combatant, **When** the total monster XP is below the Low threshold, **Then** the indicator shows three empty bars (trivial difficulty).
|
||||
|
||||
2. **Given** an encounter where total monster XP meets or exceeds the Low threshold but is below Moderate, **When** the indicator renders, **Then** it shows one filled green bar and the tooltip reads "Low encounter difficulty".
|
||||
|
||||
3. **Given** an encounter where total monster XP meets or exceeds the Moderate threshold but is below High, **When** the indicator renders, **Then** it shows two filled yellow bars and the tooltip reads "Moderate encounter difficulty".
|
||||
|
||||
4. **Given** an encounter where total monster XP meets or exceeds the High threshold, **When** the indicator renders, **Then** it shows three filled red bars and the tooltip reads "High encounter difficulty".
|
||||
|
||||
5. **Given** an encounter where total monster XP exceeds the High threshold by a large margin, **When** the indicator renders, **Then** it still shows three filled red bars (High is the cap — there is no "above High" tier).
|
||||
|
||||
6. **Given** the difficulty indicator is visible, **When** a bestiary-linked combatant is added or removed, **Then** the indicator updates immediately to reflect the new difficulty tier.
|
||||
|
||||
7. **Given** the difficulty indicator is visible, **When** a PC combatant is added or removed, **Then** the indicator updates immediately to reflect the new party budget.
|
||||
|
||||
---
|
||||
|
||||
### Indicator Visibility
|
||||
|
||||
**Story ED-2 — Indicator hidden when data is insufficient (Priority: P1)**
|
||||
|
||||
The difficulty indicator only appears when meaningful calculation is possible. If the encounter lacks PC combatants with levels or lacks bestiary-linked monsters, the indicator is hidden entirely rather than showing a confusing empty or zero state.
|
||||
|
||||
**Why this priority**: Showing an indicator when it can't calculate anything is worse than showing nothing — it would confuse users who don't use bestiary creatures or don't assign levels.
|
||||
|
||||
**Independent Test**: Can be tested by creating encounters with various combatant combinations and verifying the indicator appears or hides correctly.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an encounter with only custom combatants (no `creatureId`), **When** the top bar renders, **Then** no difficulty indicator is shown.
|
||||
|
||||
2. **Given** an encounter with bestiary-linked monsters but no PC combatants, **When** the top bar renders, **Then** no difficulty indicator is shown.
|
||||
|
||||
3. **Given** an encounter with PC combatants whose player characters have no level assigned, **When** the top bar renders, **Then** no difficulty indicator is shown (even if bestiary-linked monsters are present).
|
||||
|
||||
4. **Given** an encounter with one leveled PC combatant and one bestiary-linked monster, **When** the last leveled PC is removed, **Then** the indicator disappears.
|
||||
|
||||
5. **Given** an encounter with one leveled PC combatant and one bestiary-linked monster, **When** the last bestiary-linked monster is removed (only custom combatants remain), **Then** the indicator disappears.
|
||||
|
||||
---
|
||||
|
||||
### Player Character Level
|
||||
|
||||
**Story ED-3 — Assign a level to a player character (Priority: P1)**
|
||||
|
||||
The game master can set an optional level (1-20) when creating or editing a player character. This level is used to determine the party's XP budget for the difficulty calculation. Player characters without a level are silently excluded from the budget.
|
||||
|
||||
**Why this priority**: Without levels on PCs, the XP budget cannot be calculated and the indicator cannot function.
|
||||
|
||||
**Independent Test**: Can be tested by creating a player character with a level, adding it to an encounter with a bestiary creature, and verifying the difficulty indicator appears.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the create player character form is open, **When** the user sets level to 5, **Then** the player character is created with level 5.
|
||||
|
||||
2. **Given** the create player character form is open, **When** the user leaves the level field empty, **Then** the player character is created without a level.
|
||||
|
||||
3. **Given** a player character with no level, **When** the user edits the player character and sets level to 10, **Then** the level is saved and used in future difficulty calculations.
|
||||
|
||||
4. **Given** the level field is shown, **When** the user enters a value outside 1-20 (e.g., 0, 21, -1), **Then** a validation error is shown and the value is not accepted.
|
||||
|
||||
5. **Given** a player character with level 5 exists, **When** the page is reloaded, **Then** the level is restored as part of the player character data.
|
||||
|
||||
---
|
||||
|
||||
### XP Budget Calculation
|
||||
|
||||
**Story ED-4 — Correct XP budget from 5.5e rules (Priority: P1)**
|
||||
|
||||
The difficulty calculation uses the 2024 5.5e XP Budget per Character table and a standard CR-to-XP mapping. The party's XP budget is the sum of per-character budgets for each PC combatant that has a level. The total monster XP is the sum of XP values for each bestiary-linked combatant's CR. The difficulty tier is determined by comparing total monster XP against the Low, Moderate, and High budget thresholds.
|
||||
|
||||
**Why this priority**: Incorrect calculation would make the feature misleading — the math must match the published rules.
|
||||
|
||||
**Independent Test**: Can be tested with pure domain function unit tests using known party/monster combinations from the 2024 DMG examples.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a party of four level 1 PCs (Low budget: 50 each = 200 total), **When** facing a single Bugbear (CR 1, 200 XP), **Then** the difficulty is Low (200 XP meets the Low threshold of 200 but is below Moderate at 300).
|
||||
|
||||
2. **Given** a party of five level 3 PCs (Moderate budget: 225 each = 1,125 total), **When** facing monsters totaling 1,125 XP, **Then** the difficulty is Moderate.
|
||||
|
||||
3. **Given** a party with PCs at different levels (e.g., three level 3 and one level 2), **When** the budget is calculated, **Then** each PC's budget is looked up individually by level and summed (not averaged).
|
||||
|
||||
4. **Given** an encounter with both bestiary-linked and custom combatants, **When** the XP total is calculated, **Then** only bestiary-linked combatants contribute XP (custom combatants are excluded).
|
||||
|
||||
5. **Given** a PC combatant whose player character has no level, **When** the budget is calculated, **Then** that PC is excluded from the budget (as if they are not in the party).
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- **All bars empty (trivial)**: When total monster XP is greater than 0 but below the Low threshold, the indicator shows three empty bars. This communicates "we can calculate, but it's trivial."
|
||||
- **Zero monster XP**: If all combatants with `creatureId` have CR 0 (0 XP), the indicator shows three empty bars (trivial).
|
||||
- **Mixed party levels**: PCs at different levels each contribute their own budget — the system handles heterogeneous parties correctly.
|
||||
- **Duplicate PC combatants**: If the same player character is added to the encounter multiple times, each copy contributes to the party budget independently (each counts as a party member).
|
||||
- **CR fractions**: Bestiary creatures can have fractional CRs (e.g., "1/4", "1/2"). The CR-to-XP lookup must handle these string formats.
|
||||
- **Custom combatants silently excluded**: Custom combatants without `creatureId` do not appear in the XP total and are not flagged as warnings or errors.
|
||||
- **PCs without level silently excluded**: PC combatants whose player character has no level do not contribute to the budget and are not flagged.
|
||||
- **Indicator with empty encounter**: When the encounter has no combatants, the indicator is hidden (the top bar may not even render per existing behavior).
|
||||
- **Level field on existing player characters**: Existing player characters created before this feature will have no level. They are treated as "no level assigned" — no migration or default is needed.
|
||||
|
||||
---
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
#### FR-001 — XP Budget per Character table
|
||||
The system MUST contain the 2024 5.5e XP Budget per Character lookup table mapping character levels 1-20 to Low, Moderate, and High XP thresholds.
|
||||
|
||||
#### FR-002 — CR-to-XP lookup table
|
||||
The system MUST contain a CR-to-XP lookup table mapping all standard 5e challenge ratings (0, 1/8, 1/4, 1/2, 1-30) to their XP values.
|
||||
|
||||
#### FR-003 — Party XP budget calculation
|
||||
The system MUST calculate the party's XP budget by summing the per-character budget for each PC combatant whose player character has a level assigned. PCs without a level are excluded from the sum.
|
||||
|
||||
#### FR-004 — Monster XP total calculation
|
||||
The system MUST calculate the total monster XP by summing the XP value (derived from CR) for each combatant that has a `creatureId`. Combatants without `creatureId` are excluded.
|
||||
|
||||
#### FR-005 — Difficulty tier determination
|
||||
The system MUST determine the encounter difficulty tier by comparing total monster XP against the party's Low, Moderate, and High thresholds. The tier is the highest threshold that the total XP meets or exceeds. If below Low, the encounter is trivial (no tier label).
|
||||
|
||||
#### FR-006 — Difficulty indicator in top bar
|
||||
The system MUST display a 3-bar difficulty indicator in the top bar, positioned to the right of the active combatant name.
|
||||
|
||||
#### FR-007 — Bar visual states
|
||||
The indicator MUST display: three empty bars for trivial, one green filled bar for Low, two yellow filled bars for Moderate, three red filled bars for High.
|
||||
|
||||
#### FR-008 — Tooltip on hover
|
||||
The indicator MUST show a tooltip on hover displaying the difficulty label (e.g., "Moderate encounter difficulty"). For the trivial state, the tooltip MUST read "Trivial encounter difficulty".
|
||||
|
||||
#### FR-009 — Live updates
|
||||
The indicator MUST update immediately when combatants are added to or removed from the encounter.
|
||||
|
||||
#### FR-010 — Hidden when data insufficient
|
||||
The indicator MUST be hidden when the encounter has no PC combatants with levels OR no bestiary-linked combatants.
|
||||
|
||||
#### FR-011 — Optional level field on PlayerCharacter
|
||||
The `PlayerCharacter` entity MUST support an optional `level` field accepting integer values 1-20.
|
||||
|
||||
#### FR-012 — Level in create/edit forms
|
||||
The player character create and edit forms MUST include an optional level field with validation constraining values to the 1-20 range.
|
||||
|
||||
#### FR-013 — Level persistence
|
||||
The player character level MUST be persisted and restored across sessions, consistent with existing player character persistence behavior.
|
||||
|
||||
#### FR-014 — High is the cap
|
||||
When total monster XP exceeds the High threshold, the indicator MUST display the High state (three red bars). There is no tier above High.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **XP Budget Table**: A lookup mapping character level (1-20) to three XP thresholds (Low, Moderate, High), sourced from the 2024 5.5e DMG.
|
||||
- **CR-to-XP Table**: A lookup mapping challenge rating strings ("0", "1/8", "1/4", "1/2", "1"-"30") to XP integer values.
|
||||
- **DifficultyTier**: An enumeration of difficulty categories: Trivial, Low, Moderate, High.
|
||||
- **DifficultyResult**: The output of the calculation containing the tier, total monster XP, and per-tier budget thresholds.
|
||||
- **PlayerCharacter.level**: An optional integer (1-20) added to the existing `PlayerCharacter` entity defined in spec 005.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: The difficulty indicator correctly reflects the 2024 5.5e XP budget rules for all party level and monster CR combinations in the published tables.
|
||||
- **SC-002**: The indicator updates within the same render cycle as combatant additions/removals — no perceptible delay.
|
||||
- **SC-003**: Users can identify the encounter difficulty tier at a glance from the top bar without opening any modal or menu.
|
||||
- **SC-004**: The indicator is completely hidden when the encounter lacks sufficient data for calculation, avoiding user confusion.
|
||||
- **SC-005**: The difficulty calculation is a pure domain function with no I/O, consistent with the project's deterministic domain core.
|
||||
- **SC-006**: The domain module for difficulty calculation has zero imports from application, adapter, or UI layers.
|
||||
- **SC-007**: The optional level field integrates seamlessly into the existing player character create/edit workflow without disrupting existing functionality.
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The 2024 5.5e XP Budget per Character table and CR-to-XP table are static data that do not change at runtime.
|
||||
- The CR-to-XP mapping uses the standard 5e values (0 XP for CR 0, 25 XP for CR 1/8, 50 XP for CR 1/4, 100 XP for CR 1/2, 200 XP for CR 1, up to 155,000 XP for CR 30).
|
||||
- Monster XP is derived solely from CR — no encounter multipliers are applied (the 5.5e system dropped the 2014 multiplier mechanic).
|
||||
- The `level` field is added to the existing `PlayerCharacter` type from spec 005. No new entity or storage mechanism is needed.
|
||||
- Existing player characters without a level are treated as "no level assigned" with no migration.
|
||||
- The difficulty indicator occupies minimal horizontal space in the top bar and does not interfere with the combatant name truncation or other controls.
|
||||
- MVP baseline does not include CR assignment for custom (non-bestiary) combatants.
|
||||
- MVP baseline does not include the 2014 DMG encounter multiplier mechanic or the four-tier (Easy/Medium/Hard/Deadly) system.
|
||||
- MVP baseline does not include showing XP totals or budget numbers in the indicator — only the visual bars and tooltip label.
|
||||
- MVP baseline does not include per-combatant level overrides — level is always derived from the player character template.
|
||||
171
specs/008-encounter-difficulty/tasks.md
Normal file
171
specs/008-encounter-difficulty/tasks.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# Tasks: Encounter Difficulty Indicator
|
||||
|
||||
**Input**: Design documents from `/specs/008-encounter-difficulty/`
|
||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, quickstart.md
|
||||
|
||||
**Organization**: Tasks are grouped by user story (ED-1 through ED-4) to enable independent implementation and testing of each story.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (e.g., ED-1, ED-3, ED-4)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundational (Level field on PlayerCharacter)
|
||||
|
||||
**Purpose**: Add optional `level` field to `PlayerCharacter` — required by all user stories since the difficulty calculation depends on party levels.
|
||||
|
||||
**⚠️ CRITICAL**: The difficulty indicator cannot function without PC levels. This must complete first.
|
||||
|
||||
- [x] T001 Add `level?: number` to `PlayerCharacter` interface in `packages/domain/src/player-character-types.ts`
|
||||
- [x] T002 [P] Add level validation to `createPlayerCharacter()` in `packages/domain/src/create-player-character.ts` — validate if provided: integer, 1-20, error code `"invalid-level"`
|
||||
- [x] T003 [P] Add level validation to `validateFields()` and apply in `applyFields()` in `packages/domain/src/edit-player-character.ts`
|
||||
- [x] T004 [P] Add level tests to `packages/domain/src/__tests__/create-player-character.test.ts` — valid level, no level, out-of-range, non-integer
|
||||
- [x] T005 [P] Add level tests to `packages/domain/src/__tests__/edit-player-character.test.ts` — set level, clear level, invalid level
|
||||
- [x] T006 Update `CreatePlayerCharacterUseCase` to accept and pass `level` in `packages/application/src/create-player-character-use-case.ts`
|
||||
- [x] T007 [P] Update `EditPlayerCharacterUseCase` to accept and pass `level` in `packages/application/src/edit-player-character-use-case.ts`
|
||||
- [x] T008 Update player characters context to pass `level` in create/edit calls in `apps/web/src/contexts/player-characters-context.tsx`
|
||||
- [x] T009 Add level input field to create player modal in `apps/web/src/components/create-player-modal.tsx` — optional number input, 1-20 range
|
||||
- [x] T010 Add level display and edit support in player character manager in `apps/web/src/components/player-character-manager.tsx`
|
||||
- [x] T011 Export updated `PlayerCharacter` type from `packages/domain/src/index.ts` (verify re-export includes level)
|
||||
|
||||
**Checkpoint**: Player characters can be created/edited with an optional level. Existing PCs without level continue to work. All quality gates pass.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: User Story 4 — XP Budget Calculation (Priority: P1) 🎯 MVP Core
|
||||
|
||||
**Goal**: Implement the pure domain difficulty calculation with CR-to-XP and XP Budget tables.
|
||||
|
||||
**Independent Test**: Verified with unit tests using known party/monster combinations from the 2024 DMG examples.
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [x] T012 Create `packages/domain/src/encounter-difficulty.ts` with `DifficultyTier` type (`"trivial" | "low" | "moderate" | "high"`), `DifficultyResult` interface, `CR_TO_XP` lookup table (Record mapping all CRs 0 through 30 including fractions to XP values), and `XP_BUDGET_PER_CHARACTER` lookup table (Record mapping levels 1-20 to `{ low, moderate, high }`)
|
||||
- [x] T013 Implement `crToXp(cr: string): number` in `packages/domain/src/encounter-difficulty.ts` — returns XP for given CR string, 0 for unknown CRs
|
||||
- [x] T014 Implement `calculateEncounterDifficulty(partyLevels: number[], monsterCrs: string[]): DifficultyResult` in `packages/domain/src/encounter-difficulty.ts` — sums party budget per level, sums monster XP per CR, determines tier by comparing total XP against thresholds
|
||||
- [x] T015 Export `DifficultyTier`, `DifficultyResult`, `crToXp`, `calculateEncounterDifficulty` from `packages/domain/src/index.ts` (same file as T011 — merge into one edit)
|
||||
- [x] T016 Create `packages/domain/src/__tests__/encounter-difficulty.test.ts` with tests for: all CR string formats (0, 1/8, 1/4, 1/2, integers 1-30), unknown CR returns 0, all difficulty tiers (trivial/low/moderate/high), DMG example encounters (4x level 1 vs Bugbear = Low, 5x level 3 vs 1125 XP = Moderate), mixed party levels, empty arrays, High as cap (XP far exceeding High threshold still returns "high")
|
||||
|
||||
**Checkpoint**: Domain difficulty calculation is complete, tested, and exported. All quality gates pass.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — See Encounter Difficulty at a Glance (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Display the 3-bar difficulty indicator in the top bar that updates live as combatants change.
|
||||
|
||||
**Independent Test**: Add PC combatants with levels and bestiary-linked monsters — indicator appears with correct tier. Add/remove combatants — indicator updates.
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T017 Create `apps/web/src/hooks/use-difficulty.ts` — hook that consumes `useEncounterContext()`, `usePlayerCharactersContext()`, and `useBestiary()` to derive party levels and monster CRs from current encounter, calls `calculateEncounterDifficulty()`, returns `DifficultyResult | null` (null when insufficient data)
|
||||
- [x] T018 Create `apps/web/src/components/difficulty-indicator.tsx` — renders 3 bars as small `div` elements with Tailwind classes: empty bars for trivial (all `bg-muted`), 1 green bar for low (`bg-green-500`), 2 yellow bars for moderate (`bg-yellow-500`), 3 red bars for high (`bg-red-500`). Add `title` attribute for tooltip (e.g., "Moderate encounter difficulty"). Accept `DifficultyResult` as prop.
|
||||
- [x] T019 Add `DifficultyIndicator` to `TurnNavigation` in `apps/web/src/components/turn-navigation.tsx` — position to the right of the active combatant name inside the center flex section. Use `useDifficulty()` hook; render indicator only when result is non-null.
|
||||
- [x] T019a Add tests for `useDifficulty` hook in `apps/web/src/hooks/__tests__/use-difficulty.test.ts` — verify correct `DifficultyResult` for known combatant/PC/creature combinations, returns null when data is insufficient, and updates when combatants change. Include tooltip text assertions for `DifficultyIndicator`.
|
||||
|
||||
**Checkpoint**: Difficulty indicator appears in top bar for encounters with leveled PCs + bestiary monsters. Updates live. All quality gates pass.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Indicator Hidden When Data Insufficient (Priority: P1)
|
||||
|
||||
**Goal**: Indicator is completely hidden when the encounter lacks PC combatants with levels or bestiary-linked monsters.
|
||||
|
||||
**Independent Test**: Create encounters with only custom combatants, only monsters (no PCs), only PCs without levels — indicator should not appear in any case.
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T020 Review and verify `useDifficulty()` null-return paths implemented in T017 cover all edge cases: no combatants with `playerCharacterId` that have a level, no combatants with `creatureId`, all PCs without levels, all custom combatants, empty encounter. Fix any missing cases.
|
||||
- [x] T021 Verify `TurnNavigation` in `apps/web/src/components/turn-navigation.tsx` renders nothing for the indicator when `useDifficulty()` returns null — confirm conditional rendering is correct.
|
||||
|
||||
**Checkpoint**: Indicator hides correctly for all insufficient-data scenarios. No visual artifacts when hidden.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final validation and documentation updates.
|
||||
|
||||
- [x] T022 Run `pnpm check` to verify all quality gates pass (audit, knip, biome, oxlint, typecheck, test/coverage, jscpd)
|
||||
- [x] T023 Verify export compatibility — create a player character with level, export encounter JSON, re-import, confirm level is preserved. Verify old exports (without level) still import correctly. If the added `level` field causes old imports to fail, bump `ExportBundle` version and add migration logic in `validateImportBundle()` per CLAUDE.md convention.
|
||||
- [x] T024 Update `specs/005-player-characters/spec.md` to note that `PlayerCharacter` now supports an optional `level` field (added by spec 008)
|
||||
- [x] T025 Update `CLAUDE.md` to add spec 008 to the current feature specs list
|
||||
- [x] T026 Update `README.md` if encounter difficulty is a user-facing feature worth documenting
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Phase 1 (Foundational)**: No dependencies — can start immediately
|
||||
- **Phase 2 (XP Calculation)**: T012-T014 depend on T001 (level type). T016 tests can be written in parallel with T012-T014.
|
||||
- **Phase 3 (Indicator UI)**: Depends on Phase 1 (level in forms) and Phase 2 (calculation function)
|
||||
- **Phase 4 (Visibility)**: Depends on Phase 3 (indicator exists to hide)
|
||||
- **Phase 5 (Polish)**: Depends on all previous phases
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **ED-4 (Calculation)**: Depends on `level` field existing on `PlayerCharacter` (Phase 1, T001)
|
||||
- **ED-1 (Indicator)**: Depends on ED-4 (calculation) + Phase 1 (level in UI)
|
||||
- **ED-2 (Visibility)**: Depends on ED-1 (indicator rendering) — primarily a verification task
|
||||
- **ED-3 (Level field)**: Implemented in Phase 1 as foundational — all stories depend on it
|
||||
|
||||
### Within Each Phase
|
||||
|
||||
- Tasks marked [P] can run in parallel
|
||||
- Domain tasks before application tasks before web tasks
|
||||
- Type definitions before functions using those types
|
||||
- Implementation before tests (unless TDD requested)
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
**Phase 1 parallel group**:
|
||||
```
|
||||
T002 (create validation) ‖ T003 (edit validation) ‖ T004 (create tests) ‖ T005 (edit tests)
|
||||
T006 (create use case) ‖ T007 (edit use case)
|
||||
```
|
||||
|
||||
**Phase 2 parallel group**:
|
||||
```
|
||||
T012 (tables + types) → T013 (crToXp) ‖ T014 (calculateDifficulty) → T016 (tests)
|
||||
```
|
||||
|
||||
**Phase 3 parallel group**:
|
||||
```
|
||||
T017 (hook) ‖ T018 (component) → T019 (integration into top bar)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (Phase 1 + Phase 2 + Phase 3)
|
||||
|
||||
1. Complete Phase 1: Level field on PlayerCharacter
|
||||
2. Complete Phase 2: Domain difficulty calculation + tests
|
||||
3. Complete Phase 3: Indicator in top bar
|
||||
4. **STOP and VALIDATE**: Indicator shows correct difficulty for encounters with leveled PCs and bestiary monsters
|
||||
5. Demo: add a party of level 3 PCs, add some goblins from bestiary, see bars change
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Phase 1 → Level field works in PC forms → Can assign levels immediately
|
||||
2. Phase 2 → Calculation is correct → Domain tests prove it
|
||||
3. Phase 3 → Indicator visible → Feature is usable
|
||||
4. Phase 4 → Edge cases verified → Feature is robust
|
||||
5. Phase 5 → Docs updated → Feature is complete
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks = different files, no dependencies
|
||||
- [Story] label maps task to specific user story for traceability
|
||||
- Story ED-3 (level field) is implemented in Phase 1 as it's foundational to all other stories
|
||||
- The `useDifficulty` hook is the key integration point — it bridges three contexts into one domain call
|
||||
- No new contexts or ports needed — existing patterns handle everything
|
||||
- Commit after each phase checkpoint
|
||||
Reference in New Issue
Block a user