Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
228c1c667f | ||
|
|
300d4b1f73 | ||
|
|
43546aaa7b | ||
|
|
09da9a8dfc | ||
|
|
b229a0dac7 | ||
|
|
08b5db81ad | ||
|
|
a89fac5c23 | ||
|
|
b6ee4c8c86 | ||
|
|
c295840b7b | ||
|
|
d13641152f | ||
|
|
110f4726ae | ||
|
|
2bc22369ce | ||
|
|
2971d32f45 | ||
|
|
a97044ec3e | ||
|
|
a77db0eeee | ||
|
|
d8c8a0c44d | ||
|
|
80dd68752e | ||
|
|
896fd427ed | ||
|
|
01b1bba6d6 | ||
|
|
b7a97c3d88 | ||
|
|
1de00e3d8e | ||
|
|
f4fb69dbc7 | ||
|
|
ef76b9c90b | ||
|
|
36122b500b | ||
|
|
f4355a8675 | ||
|
|
209df13c32 | ||
|
|
4969ed069b | ||
|
|
fba83bebd6 | ||
|
|
f6766b729d | ||
|
|
f10c67a5ba | ||
|
|
9437272fe0 | ||
|
|
541e04b732 | ||
|
|
e9fd896934 | ||
|
|
29cdd19cab | ||
|
|
17cc6ed72c | ||
|
|
9d81c8ad27 | ||
|
|
7199b9d2d9 | ||
|
|
158bcf1468 | ||
|
|
fab9301b20 | ||
|
|
d653cfe489 | ||
|
|
228a2603e8 | ||
|
|
27ff8ba1ad | ||
|
|
4cfcefe6c3 | ||
|
|
8baccf3cd3 | ||
|
|
a9ca31e9bc | ||
|
|
64a1f0b8db | ||
|
|
5e5812bcaa | ||
|
|
9e09c8ae2a | ||
|
|
4d0ec0c7b2 | ||
|
|
fe62f2eb2f | ||
|
|
7092677273 | ||
|
|
e1a06c9d59 | ||
|
|
4043612ccf | ||
|
|
cfd4aef724 | ||
|
|
968cc7239b | ||
|
|
d9562f850c | ||
|
|
ec9f2e7877 | ||
|
|
c4079c384b | ||
|
|
a4285fc415 | ||
|
|
9c0e3398f1 | ||
|
|
9cdf004c15 | ||
|
|
8bf69fd47d | ||
|
|
7b83e3c3ea | ||
|
|
c3c2cad798 | ||
|
|
3f6140303d | ||
|
|
fd30278474 | ||
|
|
278c06221f | ||
|
|
722e8cc627 | ||
|
|
64741956dd |
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()
|
||||||
75
.claude/skills/commit/SKILL.md
Normal file
75
.claude/skills/commit/SKILL.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
name: commit
|
||||||
|
description: Create a git commit with pre-commit hooks (bypasses sandbox restrictions).
|
||||||
|
disable-model-invocation: true
|
||||||
|
allowed-tools: Bash(git *), Bash(pnpm *)
|
||||||
|
---
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
Create a git commit for the current staged and/or unstaged changes.
|
||||||
|
|
||||||
|
### Step 1 — Assess changes
|
||||||
|
|
||||||
|
Run these in parallel:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log --oneline -5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2 — Draft commit message
|
||||||
|
|
||||||
|
- Summarize the nature of the changes (new feature, enhancement, bug fix, refactor, test, docs, etc.)
|
||||||
|
- Keep the first line concise (under 72 chars), use imperative mood
|
||||||
|
- Add a blank line and a short body if the "why" isn't obvious from the first line
|
||||||
|
- Match the style of recent commits in the log
|
||||||
|
- Do not commit files that likely contain secrets (.env, credentials, etc.)
|
||||||
|
|
||||||
|
### Step 3 — Stage and commit
|
||||||
|
|
||||||
|
Stage relevant files by name (avoid `git add -A` or `git add .`). Then commit.
|
||||||
|
|
||||||
|
**CRITICAL:** Always use `dangerouslyDisableSandbox: true` for the commit command. Lefthook pre-commit hooks spawn subprocesses (biome, oxlint, vitest, etc.) that require filesystem access beyond what the sandbox allows. They will always fail with "operation not permitted" in sandbox mode.
|
||||||
|
|
||||||
|
Append the co-author trailer:
|
||||||
|
|
||||||
|
```
|
||||||
|
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a HEREDOC for the commit message:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
<first line>
|
||||||
|
|
||||||
|
<optional body>
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4 — Verify
|
||||||
|
|
||||||
|
Run `git status` after the commit to confirm success.
|
||||||
|
|
||||||
|
### If the commit fails
|
||||||
|
|
||||||
|
If a pre-commit hook fails, fix the issue, re-stage, and create a **new** commit. Never amend unless explicitly asked — amending after a hook failure would modify the previous commit.
|
||||||
|
|
||||||
|
## User arguments
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ARGUMENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
If the user provided arguments, treat them as the commit message or guidance for what to commit.
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -12,3 +12,6 @@ Thumbs.db
|
|||||||
coverage/
|
coverage/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
docs/agents/plans/
|
docs/agents/plans/
|
||||||
|
docs/agents/research/
|
||||||
|
.agent-tests/
|
||||||
|
.rodney/
|
||||||
|
|||||||
9
.jsinspectrc
Normal file
9
.jsinspectrc
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"threshold": 50,
|
||||||
|
"minInstances": 3,
|
||||||
|
"identifiers": false,
|
||||||
|
"literals": false,
|
||||||
|
"ignore": "dist|__tests__|node_modules",
|
||||||
|
"reporter": "default",
|
||||||
|
"truncate": 100
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
───────────────────
|
───────────────────
|
||||||
Version change: 3.0.0 → 3.1.0 (MINOR — new principle II-A: context-based state flow)
|
Version change: 3.1.0 → 3.2.0 (MINOR — artifact lifecycle guidance)
|
||||||
Modified sections:
|
Modified sections:
|
||||||
- Core Principles: added II-A. Context-Based State Flow (max 8 props, context over prop drilling)
|
- Development Workflow: added artifact lifecycle rules (spec.md living, plan/tasks bounded, tests authoritative)
|
||||||
Templates requiring updates: none
|
Templates requiring updates: none
|
||||||
-->
|
-->
|
||||||
# Encounter Console Constitution
|
# Encounter Console Constitution
|
||||||
@@ -113,6 +113,18 @@ architecture, and quality — not product behavior.
|
|||||||
(which creates a feature branch for the full speckit pipeline);
|
(which creates a feature branch for the full speckit pipeline);
|
||||||
changes to existing features update the existing spec via
|
changes to existing features update the existing spec via
|
||||||
`/integrate-issue`.
|
`/integrate-issue`.
|
||||||
|
- **Artifact lifecycles differ by type**:
|
||||||
|
- `spec.md` is a **living capability document** — it describes what
|
||||||
|
the feature does and is updated whenever the feature meaningfully
|
||||||
|
changes. It survives across multiple rounds of work.
|
||||||
|
- `plan.md` and `tasks.md` are **bounded work packages** — they
|
||||||
|
describe what to do for a specific increment of work. After
|
||||||
|
completion they become historical records. The next round of work
|
||||||
|
on the same feature gets a new plan, not an update to the old one.
|
||||||
|
- Tests are the **executable ground truth**. When a spec's
|
||||||
|
acceptance criteria and the tests disagree, the tests are
|
||||||
|
authoritative. Spec prose captures intent and context; tests
|
||||||
|
capture actual behavior.
|
||||||
- The full pipeline (spec → plan → tasks → implement) applies to new
|
- The full pipeline (spec → plan → tasks → implement) applies to new
|
||||||
features and significant additions. Bug fixes, tooling changes,
|
features and significant additions. Bug fixes, tooling changes,
|
||||||
and trivial UI adjustments do not require specs.
|
and trivial UI adjustments do not require specs.
|
||||||
@@ -156,4 +168,4 @@ MUST comply with its principles.
|
|||||||
**Compliance review**: Every spec and plan MUST include a
|
**Compliance review**: Every spec and plan MUST include a
|
||||||
Constitution Check section validating adherence to all principles.
|
Constitution Check section validating adherence to all principles.
|
||||||
|
|
||||||
**Version**: 3.1.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-19
|
**Version**: 3.2.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-30
|
||||||
|
|||||||
60
CLAUDE.md
60
CLAUDE.md
@@ -1,11 +1,11 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
**Initiative** is a browser-based combat encounter tracker for tabletop RPGs (D&D 5.5e, Pathfinder 2e). It runs entirely client-side — no backend, no accounts — with localStorage and IndexedDB for persistence.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm check # Merge gate — must pass before every commit (audit + knip + biome + oxlint + typecheck + test/coverage + jscpd)
|
pnpm check # Merge gate — must pass before every commit (audit + knip + biome + oxlint + typecheck + test/coverage + jscpd + jsinspect)
|
||||||
pnpm oxlint # Type-aware linting (oxlint — complements Biome)
|
pnpm oxlint # Type-aware linting (oxlint — complements Biome)
|
||||||
pnpm knip # Unused code detection (Knip)
|
pnpm knip # Unused code detection (Knip)
|
||||||
pnpm test # Run all tests (Vitest)
|
pnpm test # Run all tests (Vitest)
|
||||||
@@ -30,7 +30,7 @@ apps/web (React 19 + Vite) → packages/application (use cases) → packages
|
|||||||
|
|
||||||
- **Domain** — Pure functions, no I/O, no framework imports. All state transitions are deterministic. Errors returned as values (`DomainError`), never thrown. Adapters may throw only for programmer errors.
|
- **Domain** — Pure functions, no I/O, no framework imports. All state transitions are deterministic. Errors returned as values (`DomainError`), never thrown. Adapters may throw only for programmer errors.
|
||||||
- **Application** — Orchestrates domain calls via port interfaces (`EncounterStore`, `BestiarySourceCache`). No business logic here.
|
- **Application** — Orchestrates domain calls via port interfaces (`EncounterStore`, `BestiarySourceCache`). No business logic here.
|
||||||
- **Web** — React adapter. Implements ports using hooks/state. All UI components, routing, and user interaction live here.
|
- **Web** — React adapter. Implements ports using hooks/state. All UI components and user interaction live here.
|
||||||
|
|
||||||
Layer boundaries are enforced by `scripts/check-layer-boundaries.mjs`, which runs as a Vitest test. Domain and application must never import from React, Vite, or upper layers.
|
Layer boundaries are enforced by `scripts/check-layer-boundaries.mjs`, which runs as a Vitest test. Domain and application must never import from React, Vite, or upper layers.
|
||||||
|
|
||||||
@@ -60,20 +60,20 @@ docs/agents/ RPI skill artifacts (research reports, plans)
|
|||||||
- React 19, Vite 6, Tailwind CSS v4
|
- React 19, Vite 6, Tailwind CSS v4
|
||||||
- Lucide React (icons)
|
- Lucide React (icons)
|
||||||
- `idb` (IndexedDB wrapper for bestiary cache)
|
- `idb` (IndexedDB wrapper for bestiary cache)
|
||||||
- Biome 2.4 (formatting + linting), oxlint (type-aware linting), Knip (unused code), jscpd (copy-paste detection)
|
- Biome 2.4 (formatting + linting), oxlint (type-aware linting), Knip (unused code), jscpd (copy-paste detection), jsinspect-plus (structural duplication)
|
||||||
- Vitest (testing, v8 coverage), Lefthook (pre-commit hooks)
|
- Vitest (testing, v8 coverage), Lefthook (pre-commit hooks)
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- **Biome 2.4** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically.
|
- **Biome 2.4** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically.
|
||||||
- **oxlint** for type-aware linting that Biome can't do (unnecessary type assertions, deprecated APIs, `replaceAll` preference, `String.raw`). Configured in `.oxlintrc.json`.
|
- **oxlint** for type-aware linting that Biome can't do. Configured in `.oxlintrc.json`.
|
||||||
- **TypeScript strict mode** with `verbatimModuleSyntax`. Use `.js` extensions in relative imports when required by the repo's ESM settings (e.g., `./types.js`).
|
- **TypeScript strict mode** with `verbatimModuleSyntax`. Use `.js` extensions in relative imports.
|
||||||
- **Branded types** for identity values (e.g., `CombatantId`). Prefer immutability/`readonly` where practical.
|
- **Branded types** for identity values (e.g., `CombatantId`). Prefer immutability/`readonly` where practical.
|
||||||
- **Domain events** are plain data objects with a `type` discriminant — no classes.
|
- **Domain events** are plain data objects with a `type` discriminant — no classes.
|
||||||
- **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks.
|
- **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios 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`.
|
- **Quality gates** are enforced at pre-commit via Lefthook (parallel jobs). No gate may exist only as a CI step or manual process.
|
||||||
- **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).
|
|
||||||
- **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.
|
For component prop rules, export format compatibility, and ADRs, see [`docs/conventions.md`](docs/conventions.md).
|
||||||
|
|
||||||
## Self-Review Checklist
|
## Self-Review Checklist
|
||||||
|
|
||||||
@@ -85,19 +85,7 @@ Before finishing a change, consider:
|
|||||||
|
|
||||||
## Speckit Workflow
|
## Speckit Workflow
|
||||||
|
|
||||||
Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Specs are **living documents** that describe features, not individual changes.
|
Specs are **living documents** in `specs/NNN-feature-name/` that describe features, not individual changes. Use `/speckit.*` and RPI skills (`rpi-research`, `rpi-plan`, `rpi-implement`) to manage them — skill descriptions have full usage details.
|
||||||
|
|
||||||
### Issue-driven workflow
|
|
||||||
- `/write-issue` — create a well-structured Gitea issue via interactive interview
|
|
||||||
- `/integrate-issue <number>` — fetch an issue, route it to the right spec, and update the spec with the new/changed requirements. Then implement directly.
|
|
||||||
- `/sync-issue <number>` — push acceptance criteria from the spec back to the Gitea issue
|
|
||||||
|
|
||||||
### RPI skills (Research → Plan → Implement)
|
|
||||||
- `rpi-research` — deep codebase research producing a written report in `docs/agents/research/`
|
|
||||||
- `rpi-plan` — interactive phased implementation plan in `docs/agents/plans/`
|
|
||||||
- `rpi-implement` — execute a plan file phase by phase with automated + manual verification
|
|
||||||
|
|
||||||
### Choosing the right workflow by scope
|
|
||||||
|
|
||||||
| Scope | Workflow |
|
| Scope | Workflow |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -106,28 +94,8 @@ Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Spec
|
|||||||
| Larger addition to existing feature | `/integrate-issue` → `rpi-research` → `rpi-plan` → `rpi-implement` |
|
| Larger addition to existing feature | `/integrate-issue` → `rpi-research` → `rpi-plan` → `rpi-implement` |
|
||||||
| New feature | `/speckit.specify` → `/speckit.clarify` → `/speckit.plan` → `/speckit.tasks` → `/speckit.implement` |
|
| New feature | `/speckit.specify` → `/speckit.clarify` → `/speckit.plan` → `/speckit.tasks` → `/speckit.implement` |
|
||||||
|
|
||||||
Speckit manages **what** to build (specs as living documents). RPI manages **how** to build it (research, planning, execution). The full speckit pipeline is for new features. For changes to existing features, update the spec via `/integrate-issue`, then use RPI skills if the change is non-trivial.
|
**Research scope**: Always scan for existing patterns similar to what the feature needs. Identify extraction and consolidation opportunities before implementation, not during.
|
||||||
|
|
||||||
### Current feature specs
|
## Constitution
|
||||||
- `specs/001-combatant-management/` — CRUD, persistence, clear, batch add, confirm buttons
|
|
||||||
- `specs/002-turn-tracking/` — rounds, turn order, advance/retreat, top bar
|
|
||||||
- `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
|
|
||||||
|
|
||||||
## Constitution (key principles)
|
Project principles governing all feature work are in [`.specify/memory/constitution.md`](.specify/memory/constitution.md). Key rules: deterministic domain core, strict layer boundaries, clarification before assumptions.
|
||||||
|
|
||||||
The constitution (`.specify/memory/constitution.md`) governs all feature work:
|
|
||||||
|
|
||||||
1. **Deterministic Domain Core** — Pure state transitions only; no I/O, randomness, or clocks in domain.
|
|
||||||
2. **Layered Architecture** — Domain → Application → Adapters. Never skip layers or reverse dependencies.
|
|
||||||
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
|
|
||||||
|
|||||||
83
README.md
83
README.md
@@ -1,4 +1,4 @@
|
|||||||
# Encounter Console
|
# Initiative
|
||||||
|
|
||||||
A local-first initiative tracker and encounter manager for tabletop RPGs (D&D 5e / 2024). Runs entirely in the browser — no server, no account, no data leaves your machine.
|
A local-first initiative tracker and encounter manager for tabletop RPGs (D&D 5e / 2024). Runs entirely in the browser — no server, no account, no data leaves your machine.
|
||||||
|
|
||||||
@@ -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
|
- **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
|
- **Encounter state** — HP, AC, conditions, concentration tracking with visual status indicators
|
||||||
- **Bestiary integration** — import bestiary JSON sources, search creatures, and view full stat blocks
|
- **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
|
- **Persistent** — encounters survive page reloads via localStorage; bestiary data cached in IndexedDB; player characters stored independently
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
@@ -31,16 +34,42 @@ Open `http://localhost:5173`.
|
|||||||
| `pnpm --filter web dev` | Start the dev server |
|
| `pnpm --filter web dev` | Start the dev server |
|
||||||
| `pnpm --filter web build` | Production build |
|
| `pnpm --filter web build` | Production build |
|
||||||
| `pnpm test` | Run all tests (Vitest) |
|
| `pnpm test` | Run all tests (Vitest) |
|
||||||
| `pnpm check` | Full merge gate (knip, biome, typecheck, test, jscpd) |
|
| `pnpm test:watch` | Tests in watch mode |
|
||||||
|
| `pnpm vitest run path/to/test.ts` | Run a single test file |
|
||||||
|
| `pnpm typecheck` | TypeScript type checking |
|
||||||
|
| `pnpm lint` | Biome lint |
|
||||||
|
| `pnpm format` | Biome format (writes changes) |
|
||||||
|
| `pnpm check` | Full merge gate (see below) |
|
||||||
|
|
||||||
|
### Merge gate (`pnpm check`)
|
||||||
|
|
||||||
|
All of these run at pre-commit via Lefthook (in parallel where possible):
|
||||||
|
|
||||||
|
- `pnpm audit` — security audit
|
||||||
|
- `knip` — unused code detection
|
||||||
|
- `biome check` — formatting + linting
|
||||||
|
- `oxlint` — type-aware linting (complements Biome)
|
||||||
|
- Custom scripts — lint-ignore caps, className enforcement, component prop limits
|
||||||
|
- `tsc --build` — TypeScript strict mode
|
||||||
|
- `vitest run` — tests with per-path coverage thresholds
|
||||||
|
- `jscpd` + `jsinspect` — copy-paste and structural duplication detection
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- TypeScript 5.8 (strict mode), React 19, Vite 6
|
||||||
|
- Tailwind CSS v4 (dark/light theme)
|
||||||
|
- Biome 2.4 (formatting + linting), oxlint (type-aware linting)
|
||||||
|
- Vitest (testing, v8 coverage), Lefthook (pre-commit hooks)
|
||||||
|
- Knip (unused code), jscpd + jsinspect (duplication detection)
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
apps/web/ React 19 + Vite — UI components, hooks, adapters
|
apps/web/ React 19 + Vite — UI components, hooks, adapters
|
||||||
packages/domain/ Pure functions — state transitions, types, validation
|
packages/domain/ Pure functions — state transitions, types, validation
|
||||||
packages/app/ Use cases — orchestrates domain via port interfaces
|
packages/application/ Use cases — orchestrates domain via port interfaces
|
||||||
data/bestiary/ Bestiary index for creature search
|
data/bestiary/ Pre-built bestiary search index (~10k creatures)
|
||||||
scripts/ Build tooling (layer boundary checks, index generation)
|
scripts/ Build tooling (layer checks, index generation)
|
||||||
specs/ Feature specifications (spec → plan → tasks)
|
specs/ Feature specifications (spec → plan → tasks)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -52,5 +81,45 @@ Strict layered architecture with enforced dependency direction:
|
|||||||
apps/web (adapters) → packages/application (use cases) → packages/domain (pure logic)
|
apps/web (adapters) → packages/application (use cases) → packages/domain (pure logic)
|
||||||
```
|
```
|
||||||
|
|
||||||
Domain is pure — no I/O, no randomness, no framework imports. Layer boundaries are enforced by automated import checks that run as part of the test suite. See [CLAUDE.md](./CLAUDE.md) for full conventions.
|
- **Domain** — pure functions, no I/O, no randomness, no framework imports. Errors returned as values (`DomainError`), never thrown.
|
||||||
|
- **Application** — orchestrates domain calls via port interfaces (`EncounterStore`, `PlayerCharacterStore`, etc.). No business logic.
|
||||||
|
- **Web** — React adapter. Implements ports using hooks/state. All UI components, persistence, and external data access live here.
|
||||||
|
|
||||||
|
Layer boundaries are enforced by automated import checks that run as part of the test suite.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
### Workflow
|
||||||
|
|
||||||
|
Development is spec-driven. Feature specs live in `specs/NNN-feature-name/` and are managed through Claude Code skills (see [CLAUDE.md](./CLAUDE.md) for full details).
|
||||||
|
|
||||||
|
| Scope | What to do |
|
||||||
|
|-------|-----------|
|
||||||
|
| Bug fix / CSS tweak | Fix it, run `pnpm check`, commit. Optionally use `/browser-interactive-testing` for visual verification. |
|
||||||
|
| Change to existing feature | Update the feature spec, then implement |
|
||||||
|
| Larger change to existing feature | Update the spec → `/rpi-research` → `/rpi-plan` → `/rpi-implement` |
|
||||||
|
| New feature | `/speckit.specify` → `/speckit.clarify` → `/speckit.plan` → `/speckit.tasks` → `/speckit.implement` |
|
||||||
|
|
||||||
|
Use `/write-issue` to create well-structured Gitea issues, and `/integrate-issue` to pull an existing issue's requirements into the relevant feature spec.
|
||||||
|
|
||||||
|
### Before committing
|
||||||
|
|
||||||
|
Run `pnpm check` — Lefthook runs this automatically at pre-commit, but running it manually first saves time. All checks must pass.
|
||||||
|
|
||||||
|
### Conventions
|
||||||
|
|
||||||
|
- **Biome** for formatting and linting — tab indentation, 80-char lines
|
||||||
|
- **TypeScript strict mode** with `verbatimModuleSyntax` (type-only imports must use `import type`)
|
||||||
|
- **Max 8 props** per component interface — use React context for shared state
|
||||||
|
- **Tests** in `__tests__/` directories — test pure functions directly, use `renderHook` for hooks
|
||||||
|
|
||||||
|
See [CLAUDE.md](./CLAUDE.md) for the full conventions and project constitution.
|
||||||
|
|
||||||
|
## Bestiary Index
|
||||||
|
|
||||||
|
The bestiary search index (`data/bestiary/index.json`) is pre-built and checked into the repo. To regenerate it (e.g., after a new source book release):
|
||||||
|
|
||||||
|
1. Clone [5etools-mirror-3/5etools-src](https://github.com/5etools-mirror-3/5etools-src) locally
|
||||||
|
2. Run `node scripts/generate-bestiary-index.mjs /path/to/5etools-src`
|
||||||
|
|
||||||
|
The script extracts creature names, stats, and source info into a compact search index.
|
||||||
|
|||||||
@@ -2,7 +2,16 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#0e1a2e" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<meta property="og:title" content="Initiative Tracker" />
|
||||||
|
<meta property="og:description" content="D&D combat initiative tracker" />
|
||||||
|
<meta property="og:image" content="https://initiative.dostulata.rocks/icon-512.png" />
|
||||||
|
<meta property="og:url" content="https://initiative.dostulata.rocks/" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
<title>Initiative Tracker</title>
|
<title>Initiative Tracker</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -20,15 +20,15 @@
|
|||||||
"tailwind-merge": "^3.5.0"
|
"tailwind-merge": "^3.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^29.0.1",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.2",
|
||||||
"vite": "^6.2.0"
|
"vite": "^8.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
apps/web/public/apple-touch-icon.png
Normal file
BIN
apps/web/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
21
apps/web/public/favicon.svg
Normal file
21
apps/web/public/favicon.svg
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="f" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#60a5fa"/>
|
||||||
|
<stop offset="100%" stop-color="#2563eb"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g transform="translate(16 15) scale(1.55)" fill="none" stroke="url(#f)" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polygon points="8.18 4.76 8.18 -4.74 -0.01 -9.49 -8.19 -4.74 -8.19 4.76 -0.01 9.51 8.18 4.76" fill="url(#f)" fill-opacity="0.15"/>
|
||||||
|
<polygon points="-0.01 -9.49 -4.75 -2.74 4.75 -2.74 -0.01 -9.49" fill="url(#f)" fill-opacity="0.25"/>
|
||||||
|
<polygon points="-0.01 5.44 8.18 4.76 4.75 -2.74 -0.01 5.44" fill="url(#f)" fill-opacity="0.2"/>
|
||||||
|
<polygon points="-0.01 5.44 -4.75 -2.74 -8.19 4.76 -0.01 5.44" fill="url(#f)" fill-opacity="0.1"/>
|
||||||
|
<line x1="-4.75" y1="-2.74" x2="-8.19" y2="-4.74"/>
|
||||||
|
<line x1="8.18" y1="-4.74" x2="4.75" y2="-2.74"/>
|
||||||
|
<line x1="-0.01" y1="5.44" x2="-0.01" y2="9.51"/>
|
||||||
|
<polygon points="8.18 4.76 8.18 -4.74 -0.01 -9.49 -8.19 -4.74 -8.19 4.76 -0.01 9.51 8.18 4.76"/>
|
||||||
|
<polygon points="-0.01 -9.49 -4.75 -2.74 4.75 -2.74 -0.01 -9.49"/>
|
||||||
|
<polygon points="-0.01 5.44 8.18 4.76 4.75 -2.74 -0.01 5.44"/>
|
||||||
|
<polygon points="-0.01 5.44 -4.75 -2.74 -8.19 4.76 -0.01 5.44"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
apps/web/public/icon-192.png
Normal file
BIN
apps/web/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
apps/web/public/icon-512.png
Normal file
BIN
apps/web/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
31
apps/web/public/icon.svg
Normal file
31
apps/web/public/icon.svg
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg" cx="50%" cy="40%" r="70%">
|
||||||
|
<stop offset="0%" stop-color="#1a2e4a"/>
|
||||||
|
<stop offset="100%" stop-color="#0e1a2e"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="d20fill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#60a5fa"/>
|
||||||
|
<stop offset="100%" stop-color="#2563eb"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="d20stroke" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#93c5fd"/>
|
||||||
|
<stop offset="100%" stop-color="#3b82f6"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="96" fill="url(#bg)"/>
|
||||||
|
<g transform="translate(256 256) scale(8.5)" fill="none" stroke="url(#d20stroke)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polygon points="8.18 4.76 8.18 -4.74 -0.01 -9.49 -8.19 -4.74 -8.19 4.76 -0.01 9.51 8.18 4.76" fill="url(#d20fill)" fill-opacity="0.15"/>
|
||||||
|
<polygon points="-0.01 -9.49 -4.75 -2.74 4.75 -2.74 -0.01 -9.49" fill="url(#d20fill)" fill-opacity="0.25"/>
|
||||||
|
<polygon points="-0.01 5.44 8.18 4.76 4.75 -2.74 -0.01 5.44" fill="url(#d20fill)" fill-opacity="0.2"/>
|
||||||
|
<polygon points="-0.01 5.44 -4.75 -2.74 -8.19 4.76 -0.01 5.44" fill="url(#d20fill)" fill-opacity="0.1"/>
|
||||||
|
<line x1="-4.75" y1="-2.74" x2="-8.19" y2="-4.74"/>
|
||||||
|
<line x1="8.18" y1="-4.74" x2="4.75" y2="-2.74"/>
|
||||||
|
<line x1="-0.01" y1="5.44" x2="-0.01" y2="9.51"/>
|
||||||
|
<polygon points="8.18 4.76 8.18 -4.74 -0.01 -9.49 -8.19 -4.74 -8.19 4.76 -0.01 9.51 8.18 4.76"/>
|
||||||
|
<polygon points="-0.01 -9.49 -4.75 -2.74 4.75 -2.74 -0.01 -9.49"/>
|
||||||
|
<polygon points="-0.01 5.44 8.18 4.76 4.75 -2.74 -0.01 5.44"/>
|
||||||
|
<polygon points="-0.01 5.44 -4.75 -2.74 -8.19 4.76 -0.01 5.44"/>
|
||||||
|
</g>
|
||||||
|
<text x="256" y="278" text-anchor="middle" dominant-baseline="central" font-family="Inter, system-ui, sans-serif" font-weight="700" font-size="52" fill="#93c5fd" letter-spacing="1">20</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
21
apps/web/public/manifest.json
Normal file
21
apps/web/public/manifest.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "Initiative Tracker",
|
||||||
|
"short_name": "Initiative",
|
||||||
|
"description": "D&D combat initiative tracker",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0e1a2e",
|
||||||
|
"theme_color": "#0e1a2e",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ActionBar } from "./components/action-bar.js";
|
import { ActionBar } from "./components/action-bar.js";
|
||||||
import { BulkImportToasts } from "./components/bulk-import-toasts.js";
|
import { BulkImportToasts } from "./components/bulk-import-toasts.js";
|
||||||
import { CombatantRow } from "./components/combatant-row.js";
|
import { CombatantRow } from "./components/combatant-row.js";
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
PlayerCharacterSection,
|
PlayerCharacterSection,
|
||||||
type PlayerCharacterSectionHandle,
|
type PlayerCharacterSectionHandle,
|
||||||
} from "./components/player-character-section.js";
|
} from "./components/player-character-section.js";
|
||||||
|
import { SettingsModal } from "./components/settings-modal.js";
|
||||||
import { StatBlockPanel } from "./components/stat-block-panel.js";
|
import { StatBlockPanel } from "./components/stat-block-panel.js";
|
||||||
import { Toast } from "./components/toast.js";
|
import { Toast } from "./components/toast.js";
|
||||||
import { TurnNavigation } from "./components/turn-navigation.js";
|
import { TurnNavigation } from "./components/turn-navigation.js";
|
||||||
@@ -23,11 +24,19 @@ export function App() {
|
|||||||
|
|
||||||
useAutoStatBlock();
|
useAutoStatBlock();
|
||||||
|
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
|
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
|
||||||
const actionBarInputRef = useRef<HTMLInputElement>(null);
|
const actionBarInputRef = useRef<HTMLInputElement>(null);
|
||||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||||
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
|
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
|
// Auto-scroll to active combatant when turn changes
|
||||||
const activeIndex = encounter.activeIndex;
|
const activeIndex = encounter.activeIndex;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -41,10 +50,13 @@ export function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh flex-col">
|
<div className="flex h-dvh flex-col">
|
||||||
<div className="relative mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col gap-3 px-4">
|
<div className="relative mx-auto flex min-h-0 w-full flex-1 flex-col gap-3 sm:max-w-2xl sm:px-4">
|
||||||
{!!actionBarAnim.showTopBar && (
|
{!!actionBarAnim.showTopBar && (
|
||||||
<div
|
<div
|
||||||
className={cn("shrink-0 pt-8", actionBarAnim.topBarClass)}
|
className={cn(
|
||||||
|
"shrink-0 pt-[env(safe-area-inset-top)] sm:pt-[max(env(safe-area-inset-top),2rem)]",
|
||||||
|
actionBarAnim.topBarClass,
|
||||||
|
)}
|
||||||
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
|
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
|
||||||
>
|
>
|
||||||
<TurnNavigation />
|
<TurnNavigation />
|
||||||
@@ -62,6 +74,7 @@ export function App() {
|
|||||||
onManagePlayers={() =>
|
onManagePlayers={() =>
|
||||||
playerCharacterRef.current?.openManagement()
|
playerCharacterRef.current?.openManagement()
|
||||||
}
|
}
|
||||||
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,7 +95,10 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn("shrink-0 pb-8", actionBarAnim.settlingClass)}
|
className={cn(
|
||||||
|
"shrink-0 pb-[env(safe-area-inset-bottom)] sm:pb-[max(env(safe-area-inset-bottom),2rem)]",
|
||||||
|
actionBarAnim.settlingClass,
|
||||||
|
)}
|
||||||
onAnimationEnd={actionBarAnim.onSettleEnd}
|
onAnimationEnd={actionBarAnim.onSettleEnd}
|
||||||
>
|
>
|
||||||
<ActionBar
|
<ActionBar
|
||||||
@@ -90,6 +106,7 @@ export function App() {
|
|||||||
onManagePlayers={() =>
|
onManagePlayers={() =>
|
||||||
playerCharacterRef.current?.openManagement()
|
playerCharacterRef.current?.openManagement()
|
||||||
}
|
}
|
||||||
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -120,6 +137,10 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<SettingsModal
|
||||||
|
open={settingsOpen}
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
/>
|
||||||
<PlayerCharacterSection ref={playerCharacterRef} />
|
<PlayerCharacterSection ref={playerCharacterRef} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -143,7 +143,6 @@ describe("App integration", () => {
|
|||||||
await addCombatant(user, "Ogre", { maxHp: "59" });
|
await addCombatant(user, "Ogre", { maxHp: "59" });
|
||||||
|
|
||||||
// Verify HP displays — currentHp and maxHp both show "59"
|
// Verify HP displays — currentHp and maxHp both show "59"
|
||||||
expect(screen.getByText("/")).toBeInTheDocument();
|
|
||||||
const hpButton = screen.getByRole("button", {
|
const hpButton = screen.getByRole("button", {
|
||||||
name: "Current HP: 59 (healthy)",
|
name: "Current HP: 59 (healthy)",
|
||||||
});
|
});
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
16
apps/web/src/__tests__/polyfill-dialog.ts
Normal file
16
apps/web/src/__tests__/polyfill-dialog.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* jsdom doesn't implement HTMLDialogElement.showModal/close.
|
||||||
|
* Call this in beforeAll() for tests that render <Dialog>.
|
||||||
|
*/
|
||||||
|
export function polyfillDialog(): void {
|
||||||
|
if (typeof HTMLDialogElement.prototype.showModal !== "function") {
|
||||||
|
HTMLDialogElement.prototype.showModal = function showModal() {
|
||||||
|
this.setAttribute("open", "");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (typeof HTMLDialogElement.prototype.close !== "function") {
|
||||||
|
HTMLDialogElement.prototype.close = function close() {
|
||||||
|
this.removeAttribute("open");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
EncounterProvider,
|
EncounterProvider,
|
||||||
InitiativeRollsProvider,
|
InitiativeRollsProvider,
|
||||||
PlayerCharactersProvider,
|
PlayerCharactersProvider,
|
||||||
|
RulesEditionProvider,
|
||||||
SidePanelProvider,
|
SidePanelProvider,
|
||||||
ThemeProvider,
|
ThemeProvider,
|
||||||
} from "../contexts/index.js";
|
} from "../contexts/index.js";
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
export function AllProviders({ children }: { children: ReactNode }) {
|
export function AllProviders({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
|
<RulesEditionProvider>
|
||||||
<EncounterProvider>
|
<EncounterProvider>
|
||||||
<BestiaryProvider>
|
<BestiaryProvider>
|
||||||
<PlayerCharactersProvider>
|
<PlayerCharactersProvider>
|
||||||
@@ -23,6 +25,7 @@ export function AllProviders({ children }: { children: ReactNode }) {
|
|||||||
</PlayerCharactersProvider>
|
</PlayerCharactersProvider>
|
||||||
</BestiaryProvider>
|
</BestiaryProvider>
|
||||||
</EncounterProvider>
|
</EncounterProvider>
|
||||||
|
</RulesEditionProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -300,6 +300,44 @@ describe("normalizeBestiary", () => {
|
|||||||
expect(creatures[0].proficiencyBonus).toBe(6);
|
expect(creatures[0].proficiencyBonus).toBe(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes pre-2024 {@atk mw} tags to full attack type text", () => {
|
||||||
|
const raw = {
|
||||||
|
monster: [
|
||||||
|
{
|
||||||
|
name: "Adult Black Dragon",
|
||||||
|
source: "MM",
|
||||||
|
size: ["H"],
|
||||||
|
type: "dragon",
|
||||||
|
ac: [19],
|
||||||
|
hp: { average: 195, formula: "17d12 + 85" },
|
||||||
|
speed: { walk: 40, fly: 80, swim: 40 },
|
||||||
|
str: 23,
|
||||||
|
dex: 14,
|
||||||
|
con: 21,
|
||||||
|
int: 14,
|
||||||
|
wis: 13,
|
||||||
|
cha: 17,
|
||||||
|
passive: 21,
|
||||||
|
cr: "14",
|
||||||
|
action: [
|
||||||
|
{
|
||||||
|
name: "Bite",
|
||||||
|
entries: [
|
||||||
|
"{@atk mw} {@hit 11} to hit, reach 10 ft., one target. {@h}17 ({@damage 2d10 + 6}) piercing damage plus 4 ({@damage 1d8}) acid damage.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const creatures = normalizeBestiary(raw);
|
||||||
|
const bite = creatures[0].actions?.[0];
|
||||||
|
expect(bite?.text).toContain("Melee Weapon Attack:");
|
||||||
|
expect(bite?.text).not.toContain("mw");
|
||||||
|
expect(bite?.text).not.toContain("{@");
|
||||||
|
});
|
||||||
|
|
||||||
it("handles fly speed with hover condition", () => {
|
it("handles fly speed with hover condition", () => {
|
||||||
const raw = {
|
const raw = {
|
||||||
monster: [
|
monster: [
|
||||||
|
|||||||
@@ -50,6 +50,26 @@ describe("stripTags", () => {
|
|||||||
expect(stripTags("{@atkr m,r}")).toBe("Melee or Ranged Attack Roll:");
|
expect(stripTags("{@atkr m,r}")).toBe("Melee or Ranged Attack Roll:");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("strips {@atk mw} to Melee Weapon Attack:", () => {
|
||||||
|
expect(stripTags("{@atk mw}")).toBe("Melee Weapon Attack:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@atk rw} to Ranged Weapon Attack:", () => {
|
||||||
|
expect(stripTags("{@atk rw}")).toBe("Ranged Weapon Attack:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@atk ms} to Melee Spell Attack:", () => {
|
||||||
|
expect(stripTags("{@atk ms}")).toBe("Melee Spell Attack:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@atk rs} to Ranged Spell Attack:", () => {
|
||||||
|
expect(stripTags("{@atk rs}")).toBe("Ranged Spell Attack:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@atk mw,rw} to Melee or Ranged Weapon Attack:", () => {
|
||||||
|
expect(stripTags("{@atk mw,rw}")).toBe("Melee or Ranged Weapon Attack:");
|
||||||
|
});
|
||||||
|
|
||||||
it("strips {@recharge 5} to (Recharge 5-6)", () => {
|
it("strips {@recharge 5} to (Recharge 5-6)", () => {
|
||||||
expect(stripTags("{@recharge 5}")).toBe("(Recharge 5-6)");
|
expect(stripTags("{@recharge 5}")).toBe("(Recharge 5-6)");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { type IDBPDatabase, openDB } from "idb";
|
|||||||
|
|
||||||
const DB_NAME = "initiative-bestiary";
|
const DB_NAME = "initiative-bestiary";
|
||||||
const STORE_NAME = "sources";
|
const STORE_NAME = "sources";
|
||||||
const DB_VERSION = 1;
|
const DB_VERSION = 2;
|
||||||
|
|
||||||
export interface CachedSourceInfo {
|
export interface CachedSourceInfo {
|
||||||
readonly sourceCode: string;
|
readonly sourceCode: string;
|
||||||
@@ -32,12 +32,16 @@ async function getDb(): Promise<IDBPDatabase | null> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
db = await openDB(DB_NAME, DB_VERSION, {
|
db = await openDB(DB_NAME, DB_VERSION, {
|
||||||
upgrade(database) {
|
upgrade(database, oldVersion, _newVersion, transaction) {
|
||||||
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
if (oldVersion < 1) {
|
||||||
database.createObjectStore(STORE_NAME, {
|
database.createObjectStore(STORE_NAME, {
|
||||||
keyPath: "sourceCode",
|
keyPath: "sourceCode",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (oldVersion < 2 && database.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
// Clear cached creatures to pick up improved tag processing
|
||||||
|
void transaction.objectStore(STORE_NAME).clear();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return db;
|
return db;
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ const ATKR_MAP: Record<string, string> = {
|
|||||||
"r,m": "Melee or Ranged Attack Roll:",
|
"r,m": "Melee or Ranged Attack Roll:",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ATK_MAP: Record<string, string> = {
|
||||||
|
mw: "Melee Weapon Attack:",
|
||||||
|
rw: "Ranged Weapon Attack:",
|
||||||
|
ms: "Melee Spell Attack:",
|
||||||
|
rs: "Ranged Spell Attack:",
|
||||||
|
"mw,rw": "Melee or Ranged Weapon Attack:",
|
||||||
|
"rw,mw": "Melee or Ranged Weapon Attack:",
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strips 5etools {@tag ...} markup from text, converting to plain readable text.
|
* Strips 5etools {@tag ...} markup from text, converting to plain readable text.
|
||||||
*
|
*
|
||||||
@@ -51,11 +60,16 @@ export function stripTags(text: string): string {
|
|||||||
// {@hit N} → "+N"
|
// {@hit N} → "+N"
|
||||||
result = result.replaceAll(/\{@hit\s+(\d+)\}/g, "+$1");
|
result = result.replaceAll(/\{@hit\s+(\d+)\}/g, "+$1");
|
||||||
|
|
||||||
// {@atkr type} → mapped attack roll text
|
// {@atkr type} → mapped attack roll text (2024 rules)
|
||||||
result = result.replaceAll(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => {
|
result = result.replaceAll(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => {
|
||||||
return ATKR_MAP[type.trim()] ?? "Attack Roll:";
|
return ATKR_MAP[type.trim()] ?? "Attack Roll:";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// {@atk type} → mapped attack type text (pre-2024 data)
|
||||||
|
result = result.replaceAll(/\{@atk\s+([^}]+)\}/g, (_, type: string) => {
|
||||||
|
return ATK_MAP[type.trim()] ?? "Attack:";
|
||||||
|
});
|
||||||
|
|
||||||
// {@actSave ability} → "Ability saving throw"
|
// {@actSave ability} → "Ability saving throw"
|
||||||
result = result.replaceAll(
|
result = result.replaceAll(
|
||||||
/\{@actSave\s+([^}]+)\}/g,
|
/\{@actSave\s+([^}]+)\}/g,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import "@testing-library/jest-dom/vitest";
|
|||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
import { ActionBar } from "../action-bar.js";
|
import { ActionBar } from "../action-bar.js";
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ beforeAll(() => {
|
|||||||
dispatchEvent: vi.fn(),
|
dispatchEvent: vi.fn(),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
polyfillDialog();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
@@ -118,4 +120,61 @@ describe("ActionBar", () => {
|
|||||||
screen.getByRole("button", { name: "More actions" }),
|
screen.getByRole("button", { name: "More actions" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens export method dialog via overflow menu", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBar();
|
||||||
|
await user.click(screen.getByRole("button", { name: "More actions" }));
|
||||||
|
// Click the menu item
|
||||||
|
const items = screen.getAllByText("Export Encounter");
|
||||||
|
await user.click(items[0]);
|
||||||
|
// Dialog should now be open — it renders a second "Export Encounter" as heading
|
||||||
|
expect(
|
||||||
|
screen.getAllByText("Export Encounter").length,
|
||||||
|
).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens import method dialog via overflow menu", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBar();
|
||||||
|
await user.click(screen.getByRole("button", { name: "More actions" }));
|
||||||
|
const items = screen.getAllByText("Import Encounter");
|
||||||
|
await user.click(items[0]);
|
||||||
|
expect(
|
||||||
|
screen.getAllByText("Import Encounter").length,
|
||||||
|
).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onManagePlayers from overflow menu", async () => {
|
||||||
|
const onManagePlayers = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBar({ onManagePlayers });
|
||||||
|
await user.click(screen.getByRole("button", { name: "More actions" }));
|
||||||
|
await user.click(screen.getByText("Player Characters"));
|
||||||
|
expect(onManagePlayers).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onOpenSettings from overflow menu", async () => {
|
||||||
|
const onOpenSettings = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBar({ onOpenSettings });
|
||||||
|
await user.click(screen.getByRole("button", { name: "More actions" }));
|
||||||
|
await user.click(screen.getByText("Settings"));
|
||||||
|
expect(onOpenSettings).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits custom stats with combatant", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBar();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "Fighter");
|
||||||
|
const initInput = screen.getByPlaceholderText("Init");
|
||||||
|
const acInput = screen.getByPlaceholderText("AC");
|
||||||
|
const hpInput = screen.getByPlaceholderText("MaxHP");
|
||||||
|
await user.type(initInput, "15");
|
||||||
|
await user.type(acInput, "18");
|
||||||
|
await user.type(hpInput, "45");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Add" }));
|
||||||
|
expect(input).toHaveValue("");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
146
apps/web/src/components/__tests__/bulk-import-prompt.test.tsx
Normal file
146
apps/web/src/components/__tests__/bulk-import-prompt.test.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { BulkImportPrompt } from "../bulk-import-prompt.js";
|
||||||
|
|
||||||
|
const THREE_SOURCES_REGEX = /3 sources/;
|
||||||
|
const GITHUB_URL_REGEX = /raw\.githubusercontent/;
|
||||||
|
const LOADING_PROGRESS_REGEX = /Loading sources\.\.\. 4\/10/;
|
||||||
|
const SEVEN_OF_TEN_REGEX = /7\/10 sources/;
|
||||||
|
const THREE_FAILED_REGEX = /3 failed/;
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const mockFetchAndCacheSource = vi.fn();
|
||||||
|
const mockIsSourceCached = vi.fn().mockResolvedValue(false);
|
||||||
|
const mockRefreshCache = vi.fn();
|
||||||
|
const mockStartImport = vi.fn();
|
||||||
|
const mockReset = vi.fn();
|
||||||
|
const mockDismissPanel = vi.fn();
|
||||||
|
|
||||||
|
let mockImportState = {
|
||||||
|
status: "idle" as string,
|
||||||
|
total: 0,
|
||||||
|
completed: 0,
|
||||||
|
failed: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||||
|
useBestiaryContext: () => ({
|
||||||
|
fetchAndCacheSource: mockFetchAndCacheSource,
|
||||||
|
isSourceCached: mockIsSourceCached,
|
||||||
|
refreshCache: mockRefreshCache,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../contexts/bulk-import-context.js", () => ({
|
||||||
|
useBulkImportContext: () => ({
|
||||||
|
state: mockImportState,
|
||||||
|
startImport: mockStartImport,
|
||||||
|
reset: mockReset,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../contexts/side-panel-context.js", () => ({
|
||||||
|
useSidePanelContext: () => ({
|
||||||
|
dismissPanel: mockDismissPanel,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
||||||
|
getAllSourceCodes: () => ["MM", "VGM", "XGE"],
|
||||||
|
getDefaultFetchUrl: () => "",
|
||||||
|
getSourceDisplayName: (code: string) => code,
|
||||||
|
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("BulkImportPrompt", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockImportState = { status: "idle", total: 0, completed: 0, failed: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
it("idle: shows base URL input, source count, Load All button", () => {
|
||||||
|
render(<BulkImportPrompt />);
|
||||||
|
expect(screen.getByText(THREE_SOURCES_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue(GITHUB_URL_REGEX)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Load All" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("idle: clearing URL disables the button", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<BulkImportPrompt />);
|
||||||
|
|
||||||
|
const input = screen.getByDisplayValue(GITHUB_URL_REGEX);
|
||||||
|
await user.clear(input);
|
||||||
|
expect(screen.getByRole("button", { name: "Load All" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("idle: clicking Load All calls startImport with URL", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<BulkImportPrompt />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Load All" }));
|
||||||
|
expect(mockStartImport).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("raw.githubusercontent"),
|
||||||
|
mockFetchAndCacheSource,
|
||||||
|
mockIsSourceCached,
|
||||||
|
mockRefreshCache,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loading: shows progress text and progress bar", () => {
|
||||||
|
mockImportState = {
|
||||||
|
status: "loading",
|
||||||
|
total: 10,
|
||||||
|
completed: 3,
|
||||||
|
failed: 1,
|
||||||
|
};
|
||||||
|
render(<BulkImportPrompt />);
|
||||||
|
expect(screen.getByText(LOADING_PROGRESS_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("complete: shows success message and Done button", () => {
|
||||||
|
mockImportState = {
|
||||||
|
status: "complete",
|
||||||
|
total: 10,
|
||||||
|
completed: 10,
|
||||||
|
failed: 0,
|
||||||
|
};
|
||||||
|
render(<BulkImportPrompt />);
|
||||||
|
expect(screen.getByText("All sources loaded")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("complete: Done calls dismissPanel and reset", async () => {
|
||||||
|
mockImportState = {
|
||||||
|
status: "complete",
|
||||||
|
total: 10,
|
||||||
|
completed: 10,
|
||||||
|
failed: 0,
|
||||||
|
};
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<BulkImportPrompt />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Done" }));
|
||||||
|
expect(mockDismissPanel).toHaveBeenCalled();
|
||||||
|
expect(mockReset).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("partial-failure: shows loaded/failed counts", () => {
|
||||||
|
mockImportState = {
|
||||||
|
status: "partial-failure",
|
||||||
|
total: 10,
|
||||||
|
completed: 7,
|
||||||
|
failed: 3,
|
||||||
|
};
|
||||||
|
render(<BulkImportPrompt />);
|
||||||
|
expect(screen.getByText(SEVEN_OF_TEN_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(THREE_FAILED_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
56
apps/web/src/components/__tests__/color-palette.test.tsx
Normal file
56
apps/web/src/components/__tests__/color-palette.test.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { VALID_PLAYER_COLORS } from "@initiative/domain";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
import { ColorPalette } from "../color-palette.js";
|
||||||
|
|
||||||
|
describe("ColorPalette", () => {
|
||||||
|
it("renders a button for each valid color", () => {
|
||||||
|
render(<ColorPalette value="" onChange={() => {}} />);
|
||||||
|
const buttons = screen.getAllByRole("button");
|
||||||
|
expect(buttons).toHaveLength(VALID_PLAYER_COLORS.size);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("each button has an aria-label matching the color name", () => {
|
||||||
|
render(<ColorPalette value="" onChange={() => {}} />);
|
||||||
|
for (const color of VALID_PLAYER_COLORS) {
|
||||||
|
expect(screen.getByRole("button", { name: color })).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking a color calls onChange with that color", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<ColorPalette value="" onChange={onChange} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "blue" }));
|
||||||
|
expect(onChange).toHaveBeenCalledWith("blue");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking the selected color deselects it", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<ColorPalette value="red" onChange={onChange} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "red" }));
|
||||||
|
expect(onChange).toHaveBeenCalledWith("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selected color has ring styling", () => {
|
||||||
|
render(<ColorPalette value="green" onChange={() => {}} />);
|
||||||
|
const selected = screen.getByRole("button", { name: "green" });
|
||||||
|
expect(selected.className).toContain("ring-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("non-selected colors do not have ring styling", () => {
|
||||||
|
render(<ColorPalette value="green" onChange={() => {}} />);
|
||||||
|
const other = screen.getByRole("button", { name: "blue" });
|
||||||
|
expect(other.className).not.toContain("ring-2");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,10 @@ import { AllProviders } from "../../__tests__/test-providers.js";
|
|||||||
import { CombatantRow } from "../combatant-row.js";
|
import { CombatantRow } from "../combatant-row.js";
|
||||||
import { PLAYER_COLOR_HEX } from "../player-icon-map.js";
|
import { PLAYER_COLOR_HEX } from "../player-icon-map.js";
|
||||||
|
|
||||||
|
const TEMP_HP_REGEX = /^\+\d/;
|
||||||
|
const CURRENT_HP_7_REGEX = /Current HP: 7/;
|
||||||
|
const CURRENT_HP_REGEX = /Current HP/;
|
||||||
|
|
||||||
// Mock persistence — no localStorage interaction
|
// Mock persistence — no localStorage interaction
|
||||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
vi.mock("../../persistence/encounter-storage.js", () => ({
|
||||||
loadEncounter: () => null,
|
loadEncounter: () => null,
|
||||||
@@ -123,14 +127,14 @@ describe("CombatantRow", () => {
|
|||||||
expect(nameContainer).not.toBeNull();
|
expect(nameContainer).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows '--' for current HP when no maxHp is set", () => {
|
it("shows 'Max' placeholder when no maxHp is set", () => {
|
||||||
renderRow({
|
renderRow({
|
||||||
combatant: {
|
combatant: {
|
||||||
id: combatantId("1"),
|
id: combatantId("1"),
|
||||||
name: "Goblin",
|
name: "Goblin",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(screen.getByLabelText("No HP set")).toBeInTheDocument();
|
expect(screen.getByText("Max")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows concentration icon when isConcentrating is true", () => {
|
it("shows concentration icon when isConcentrating is true", () => {
|
||||||
@@ -193,4 +197,272 @@ describe("CombatantRow", () => {
|
|||||||
screen.getByRole("button", { name: "Roll initiative" }),
|
screen.getByRole("button", { name: "Roll initiative" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("concentration pulse", () => {
|
||||||
|
it("pulses when currentHp drops on a concentrating combatant", () => {
|
||||||
|
const combatant = {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 15,
|
||||||
|
isConcentrating: true,
|
||||||
|
};
|
||||||
|
const { rerender, container } = renderRow({ combatant });
|
||||||
|
rerender(
|
||||||
|
<CombatantRow
|
||||||
|
combatant={{ ...combatant, currentHp: 10 }}
|
||||||
|
isActive={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const row = container.firstElementChild;
|
||||||
|
expect(row?.className).toContain("animate-concentration-pulse");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not pulse when not concentrating", () => {
|
||||||
|
const combatant = {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 15,
|
||||||
|
isConcentrating: false,
|
||||||
|
};
|
||||||
|
const { rerender, container } = renderRow({ combatant });
|
||||||
|
rerender(
|
||||||
|
<CombatantRow
|
||||||
|
combatant={{ ...combatant, currentHp: 10 }}
|
||||||
|
isActive={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const row = container.firstElementChild;
|
||||||
|
expect(row?.className).not.toContain("animate-concentration-pulse");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pulses when temp HP absorbs all damage on a concentrating combatant", () => {
|
||||||
|
const combatant = {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 15,
|
||||||
|
tempHp: 8,
|
||||||
|
isConcentrating: true,
|
||||||
|
};
|
||||||
|
const { rerender, container } = renderRow({ combatant });
|
||||||
|
// Temp HP absorbs all damage, currentHp unchanged
|
||||||
|
rerender(
|
||||||
|
<CombatantRow
|
||||||
|
combatant={{ ...combatant, tempHp: 3 }}
|
||||||
|
isActive={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const row = container.firstElementChild;
|
||||||
|
expect(row?.className).toContain("animate-concentration-pulse");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("inline name editing", () => {
|
||||||
|
it("click rename → type new name → blur commits rename", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderRow();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Rename" }));
|
||||||
|
const input = screen.getByDisplayValue("Goblin");
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, "Hobgoblin");
|
||||||
|
await user.tab(); // blur
|
||||||
|
// The input should be gone, name committed
|
||||||
|
expect(screen.queryByDisplayValue("Hobgoblin")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Escape cancels without renaming", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderRow();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Rename" }));
|
||||||
|
const input = screen.getByDisplayValue("Goblin");
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, "Changed");
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
// Should revert to showing the original name
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("inline AC editing", () => {
|
||||||
|
it("click AC → type value → Enter commits", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
ac: 13,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click the AC shield button
|
||||||
|
const acButton = screen.getByText("13").closest("button");
|
||||||
|
expect(acButton).not.toBeNull();
|
||||||
|
await user.click(acButton as HTMLElement);
|
||||||
|
const input = screen.getByDisplayValue("13");
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, "16");
|
||||||
|
await user.keyboard("{Enter}");
|
||||||
|
expect(screen.queryByDisplayValue("16")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("inline max HP editing", () => {
|
||||||
|
it("click max HP → type value → blur commits", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 10,
|
||||||
|
currentHp: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// The max HP button shows "10" as muted text
|
||||||
|
const maxHpButton = screen
|
||||||
|
.getAllByText("10")
|
||||||
|
.find(
|
||||||
|
(el) => el.closest("button") && el.className.includes("text-muted"),
|
||||||
|
);
|
||||||
|
expect(maxHpButton).toBeDefined();
|
||||||
|
const maxHpBtn = (maxHpButton as HTMLElement).closest("button");
|
||||||
|
expect(maxHpBtn).not.toBeNull();
|
||||||
|
await user.click(maxHpBtn as HTMLElement);
|
||||||
|
const input = screen.getByDisplayValue("10");
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, "25");
|
||||||
|
await user.tab();
|
||||||
|
expect(screen.queryByDisplayValue("25")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("inline initiative editing", () => {
|
||||||
|
it("click initiative → type value → Enter commits", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
initiative: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByText("15"));
|
||||||
|
const input = screen.getByDisplayValue("15");
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, "20");
|
||||||
|
await user.keyboard("{Enter}");
|
||||||
|
expect(screen.queryByDisplayValue("20")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearing initiative and pressing Enter commits the edit", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
initiative: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByText("15"));
|
||||||
|
const input = screen.getByDisplayValue("15");
|
||||||
|
await user.clear(input);
|
||||||
|
await user.keyboard("{Enter}");
|
||||||
|
// Input should be dismissed (editing mode exited)
|
||||||
|
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HP popover", () => {
|
||||||
|
it("clicking current HP opens the HP adjust popover", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 10,
|
||||||
|
currentHp: 7,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hpButton = screen.getByLabelText(CURRENT_HP_7_REGEX);
|
||||||
|
await user.click(hpButton);
|
||||||
|
// The popover should appear with damage/heal controls
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Apply damage" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Apply healing" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("HP section is absent when maxHp is undefined", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(screen.queryByLabelText(CURRENT_HP_REGEX)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("condition picker", () => {
|
||||||
|
it("clicking Add condition button opens the picker", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderRow();
|
||||||
|
const addButton = screen.getByRole("button", {
|
||||||
|
name: "Add condition",
|
||||||
|
});
|
||||||
|
await user.click(addButton);
|
||||||
|
// Condition picker should render with condition options
|
||||||
|
expect(screen.getByText("Blinded")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("temp HP display", () => {
|
||||||
|
it("shows +N when combatant has temp HP", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 15,
|
||||||
|
tempHp: 5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(screen.getByText("+5")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show +N when combatant has no temp HP", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(screen.queryByText(TEMP_HP_REGEX)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("temp HP display uses cyan color", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 15,
|
||||||
|
tempHp: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const tempHpEl = screen.getByText("+8");
|
||||||
|
expect(tempHpEl.className).toContain("text-cyan-400");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { cleanup, render, screen } from "@testing-library/react";
|
|||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { createRef, type RefObject } from "react";
|
import { createRef, type RefObject } from "react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { RulesEditionProvider } from "../../contexts/index.js";
|
||||||
import { ConditionPicker } from "../condition-picker";
|
import { ConditionPicker } from "../condition-picker";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
@@ -24,12 +25,14 @@ function renderPicker(
|
|||||||
document.body.appendChild(anchor);
|
document.body.appendChild(anchor);
|
||||||
(anchorRef as { current: HTMLElement }).current = anchor;
|
(anchorRef as { current: HTMLElement }).current = anchor;
|
||||||
const result = render(
|
const result = render(
|
||||||
|
<RulesEditionProvider>
|
||||||
<ConditionPicker
|
<ConditionPicker
|
||||||
anchorRef={anchorRef}
|
anchorRef={anchorRef}
|
||||||
activeConditions={overrides.activeConditions ?? []}
|
activeConditions={overrides.activeConditions ?? []}
|
||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
/>,
|
/>
|
||||||
|
</RulesEditionProvider>,
|
||||||
);
|
);
|
||||||
return { ...result, onToggle, onClose };
|
return { ...result, onToggle, onClose };
|
||||||
}
|
}
|
||||||
|
|||||||
87
apps/web/src/components/__tests__/condition-tags.test.tsx
Normal file
87
apps/web/src/components/__tests__/condition-tags.test.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import type { ConditionId } from "@initiative/domain";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { userEvent } from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ConditionTags } from "../condition-tags.js";
|
||||||
|
|
||||||
|
vi.mock("../../contexts/rules-edition-context.js", () => ({
|
||||||
|
useRulesEditionContext: () => ({ edition: "5.5e" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("ConditionTags", () => {
|
||||||
|
it("renders nothing when conditions is undefined", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ConditionTags
|
||||||
|
conditions={undefined}
|
||||||
|
onRemove={() => {}}
|
||||||
|
onOpenPicker={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// Only the add button should be present
|
||||||
|
expect(container.querySelectorAll("button")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a button per condition", () => {
|
||||||
|
const conditions: ConditionId[] = ["blinded", "prone"];
|
||||||
|
render(
|
||||||
|
<ConditionTags
|
||||||
|
conditions={conditions}
|
||||||
|
onRemove={() => {}}
|
||||||
|
onOpenPicker={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Remove Blinded" }),
|
||||||
|
).toBeDefined();
|
||||||
|
expect(screen.getByRole("button", { name: "Remove Prone" })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onRemove with condition id when clicked", async () => {
|
||||||
|
const onRemove = vi.fn();
|
||||||
|
render(
|
||||||
|
<ConditionTags
|
||||||
|
conditions={["blinded"] as ConditionId[]}
|
||||||
|
onRemove={onRemove}
|
||||||
|
onOpenPicker={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Remove Blinded" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onRemove).toHaveBeenCalledWith("blinded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onOpenPicker when add button is clicked", async () => {
|
||||||
|
const onOpenPicker = vi.fn();
|
||||||
|
render(
|
||||||
|
<ConditionTags
|
||||||
|
conditions={[]}
|
||||||
|
onRemove={() => {}}
|
||||||
|
onOpenPicker={onOpenPicker}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Add condition" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onOpenPicker).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders empty conditions array without errors", () => {
|
||||||
|
render(
|
||||||
|
<ConditionTags
|
||||||
|
conditions={[]}
|
||||||
|
onRemove={() => {}}
|
||||||
|
onOpenPicker={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// Only add button
|
||||||
|
expect(screen.getByRole("button", { name: "Add condition" })).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
165
apps/web/src/components/__tests__/create-player-modal.test.tsx
Normal file
165
apps/web/src/components/__tests__/create-player-modal.test.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import type { PlayerCharacter } from "@initiative/domain";
|
||||||
|
import { playerCharacterId } from "@initiative/domain";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { userEvent } from "@testing-library/user-event";
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||||
|
import { CreatePlayerModal } from "../create-player-modal.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
polyfillDialog();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function renderModal(
|
||||||
|
overrides: Partial<Parameters<typeof CreatePlayerModal>[0]> = {},
|
||||||
|
) {
|
||||||
|
const defaults = {
|
||||||
|
open: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onSave: vi.fn(),
|
||||||
|
};
|
||||||
|
const props = { ...defaults, ...overrides };
|
||||||
|
return { ...render(<CreatePlayerModal {...props} />), ...props };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CreatePlayerModal", () => {
|
||||||
|
it("renders create form with defaults", () => {
|
||||||
|
renderModal();
|
||||||
|
expect(screen.getByText("Create Player")).toBeDefined();
|
||||||
|
expect(screen.getByLabelText("Name")).toBeDefined();
|
||||||
|
expect(screen.getByLabelText("AC")).toBeDefined();
|
||||||
|
expect(screen.getByLabelText("Max HP")).toBeDefined();
|
||||||
|
expect(screen.getByLabelText("Level")).toBeDefined();
|
||||||
|
expect(screen.getByRole("button", { name: "Create" })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders edit form when playerCharacter is provided", () => {
|
||||||
|
const pc: PlayerCharacter = {
|
||||||
|
id: playerCharacterId("pc-1"),
|
||||||
|
name: "Gandalf",
|
||||||
|
ac: 15,
|
||||||
|
maxHp: 40,
|
||||||
|
color: "blue",
|
||||||
|
icon: "wand",
|
||||||
|
level: 10,
|
||||||
|
};
|
||||||
|
renderModal({ playerCharacter: pc });
|
||||||
|
expect(screen.getByText("Edit Player")).toBeDefined();
|
||||||
|
expect(screen.getByLabelText("Name")).toHaveProperty("value", "Gandalf");
|
||||||
|
expect(screen.getByLabelText("AC")).toHaveProperty("value", "15");
|
||||||
|
expect(screen.getByLabelText("Max HP")).toHaveProperty("value", "40");
|
||||||
|
expect(screen.getByLabelText("Level")).toHaveProperty("value", "10");
|
||||||
|
expect(screen.getByRole("button", { name: "Save" })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onSave with valid data", async () => {
|
||||||
|
const { onSave, onClose } = renderModal();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText("Name"), "Aria");
|
||||||
|
await user.clear(screen.getByLabelText("AC"));
|
||||||
|
await user.type(screen.getByLabelText("AC"), "16");
|
||||||
|
await user.clear(screen.getByLabelText("Max HP"));
|
||||||
|
await user.type(screen.getByLabelText("Max HP"), "30");
|
||||||
|
await user.type(screen.getByLabelText("Level"), "5");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
"Aria",
|
||||||
|
16,
|
||||||
|
30,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error for empty name", async () => {
|
||||||
|
const { onSave } = renderModal();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("Name is required")).toBeDefined();
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error for invalid AC", async () => {
|
||||||
|
const { onSave } = renderModal();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText("Name"), "Test");
|
||||||
|
await user.clear(screen.getByLabelText("AC"));
|
||||||
|
await user.type(screen.getByLabelText("AC"), "abc");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("AC must be a non-negative number")).toBeDefined();
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error for invalid Max HP", async () => {
|
||||||
|
const { onSave } = renderModal();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText("Name"), "Test");
|
||||||
|
await user.clear(screen.getByLabelText("Max HP"));
|
||||||
|
await user.type(screen.getByLabelText("Max HP"), "0");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("Max HP must be at least 1")).toBeDefined();
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error for invalid level", async () => {
|
||||||
|
const { onSave } = renderModal();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText("Name"), "Test");
|
||||||
|
await user.type(screen.getByLabelText("Level"), "25");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("Level must be between 1 and 20")).toBeDefined();
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears error when name is edited", async () => {
|
||||||
|
renderModal();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||||
|
expect(screen.getByText("Name is required")).toBeDefined();
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText("Name"), "A");
|
||||||
|
expect(screen.queryByText("Name is required")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClose when cancel is clicked", async () => {
|
||||||
|
const { onClose } = renderModal();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits level when field is empty", async () => {
|
||||||
|
const { onSave } = renderModal();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText("Name"), "Aria");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
"Aria",
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
63
apps/web/src/components/__tests__/dialog.test.tsx
Normal file
63
apps/web/src/components/__tests__/dialog.test.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { userEvent } from "@testing-library/user-event";
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||||
|
import { Dialog, DialogHeader } from "../ui/dialog.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
polyfillDialog();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("Dialog", () => {
|
||||||
|
it("opens when open=true", () => {
|
||||||
|
render(
|
||||||
|
<Dialog open={true} onClose={() => {}}>
|
||||||
|
Content
|
||||||
|
</Dialog>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Content")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes when open changes from true to false", () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<Dialog open={true} onClose={() => {}}>
|
||||||
|
Content
|
||||||
|
</Dialog>,
|
||||||
|
);
|
||||||
|
const dialog = document.querySelector("dialog");
|
||||||
|
expect(dialog?.hasAttribute("open")).toBe(true);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<Dialog open={false} onClose={() => {}}>
|
||||||
|
Content
|
||||||
|
</Dialog>,
|
||||||
|
);
|
||||||
|
expect(dialog?.hasAttribute("open")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClose on cancel event", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<Dialog open={true} onClose={onClose}>
|
||||||
|
Content
|
||||||
|
</Dialog>,
|
||||||
|
);
|
||||||
|
const dialog = document.querySelector("dialog");
|
||||||
|
dialog?.dispatchEvent(new Event("cancel"));
|
||||||
|
expect(onClose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DialogHeader", () => {
|
||||||
|
it("renders title and close button", async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<DialogHeader title="Test Title" onClose={onClose} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Test Title")).toBeDefined();
|
||||||
|
await userEvent.click(screen.getByRole("button"));
|
||||||
|
expect(onClose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import type { DifficultyResult } from "@initiative/domain";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { DifficultyIndicator } from "../difficulty-indicator.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function makeResult(tier: DifficultyResult["tier"]): DifficultyResult {
|
||||||
|
return {
|
||||||
|
tier,
|
||||||
|
totalMonsterXp: 100,
|
||||||
|
partyBudget: { low: 50, moderate: 100, high: 200 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DifficultyIndicator", () => {
|
||||||
|
it("renders 3 bars", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<DifficultyIndicator result={makeResult("moderate")} />,
|
||||||
|
);
|
||||||
|
const bars = container.querySelectorAll("[class*='rounded-sm']");
|
||||||
|
expect(bars).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'Trivial encounter difficulty' label for trivial tier", () => {
|
||||||
|
render(<DifficultyIndicator result={makeResult("trivial")} />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("img", {
|
||||||
|
name: "Trivial encounter difficulty",
|
||||||
|
}),
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'Low encounter difficulty' label for low tier", () => {
|
||||||
|
render(<DifficultyIndicator result={makeResult("low")} />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("img", { name: "Low encounter difficulty" }),
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'Moderate encounter difficulty' label for moderate tier", () => {
|
||||||
|
render(<DifficultyIndicator result={makeResult("moderate")} />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("img", {
|
||||||
|
name: "Moderate encounter difficulty",
|
||||||
|
}),
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'High encounter difficulty' label for high tier", () => {
|
||||||
|
render(<DifficultyIndicator result={makeResult("high")} />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("img", {
|
||||||
|
name: "High encounter difficulty",
|
||||||
|
}),
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||||
|
import { ExportMethodDialog } from "../export-method-dialog.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
polyfillDialog();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderDialog(open = true) {
|
||||||
|
const onDownload = vi.fn();
|
||||||
|
const onCopyToClipboard = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const result = render(
|
||||||
|
<ExportMethodDialog
|
||||||
|
open={open}
|
||||||
|
onDownload={onDownload}
|
||||||
|
onCopyToClipboard={onCopyToClipboard}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return { ...result, onDownload, onCopyToClipboard, onClose };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ExportMethodDialog", () => {
|
||||||
|
it("renders filename input and unchecked history checkbox", () => {
|
||||||
|
renderDialog();
|
||||||
|
expect(
|
||||||
|
screen.getByPlaceholderText("Filename (optional)"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
const checkbox = screen.getByRole("checkbox");
|
||||||
|
expect(checkbox).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("download button calls onDownload with defaults", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onDownload } = renderDialog();
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Download file"));
|
||||||
|
expect(onDownload).toHaveBeenCalledWith(false, "");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("download with filename and history checked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onDownload } = renderDialog();
|
||||||
|
|
||||||
|
await user.type(
|
||||||
|
screen.getByPlaceholderText("Filename (optional)"),
|
||||||
|
"my-encounter",
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("checkbox"));
|
||||||
|
await user.click(screen.getByText("Download file"));
|
||||||
|
expect(onDownload).toHaveBeenCalledWith(true, "my-encounter");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copy to clipboard calls onCopyToClipboard and shows Copied", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onCopyToClipboard } = renderDialog();
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Copy to clipboard"));
|
||||||
|
expect(onCopyToClipboard).toHaveBeenCalledWith(false);
|
||||||
|
expect(screen.getByText("Copied!")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Copied! reverts after 2 seconds", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Copy to clipboard"));
|
||||||
|
expect(screen.getByText("Copied!")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(screen.queryByText("Copied!")).not.toBeInTheDocument();
|
||||||
|
},
|
||||||
|
{ timeout: 3000 },
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Copy to clipboard")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,15 +11,21 @@ afterEach(cleanup);
|
|||||||
function renderPopover(
|
function renderPopover(
|
||||||
overrides: Partial<{
|
overrides: Partial<{
|
||||||
onAdjust: (delta: number) => void;
|
onAdjust: (delta: number) => void;
|
||||||
|
onSetTempHp: (value: number) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}> = {},
|
}> = {},
|
||||||
) {
|
) {
|
||||||
const onAdjust = overrides.onAdjust ?? vi.fn();
|
const onAdjust = overrides.onAdjust ?? vi.fn();
|
||||||
|
const onSetTempHp = overrides.onSetTempHp ?? vi.fn();
|
||||||
const onClose = overrides.onClose ?? vi.fn();
|
const onClose = overrides.onClose ?? vi.fn();
|
||||||
const result = render(
|
const result = render(
|
||||||
<HpAdjustPopover onAdjust={onAdjust} onClose={onClose} />,
|
<HpAdjustPopover
|
||||||
|
onAdjust={onAdjust}
|
||||||
|
onSetTempHp={onSetTempHp}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
return { ...result, onAdjust, onClose };
|
return { ...result, onAdjust, onSetTempHp, onClose };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("HpAdjustPopover", () => {
|
describe("HpAdjustPopover", () => {
|
||||||
@@ -112,4 +118,31 @@ describe("HpAdjustPopover", () => {
|
|||||||
await user.type(input, "12abc34");
|
await user.type(input, "12abc34");
|
||||||
expect(input).toHaveValue("1234");
|
expect(input).toHaveValue("1234");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("temp HP", () => {
|
||||||
|
it("shield button calls onSetTempHp with entered value and closes", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onSetTempHp, onClose } = renderPopover();
|
||||||
|
await user.type(screen.getByPlaceholderText("HP"), "8");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Set temp HP" }));
|
||||||
|
expect(onSetTempHp).toHaveBeenCalledWith(8);
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shield button is disabled when input is empty", () => {
|
||||||
|
renderPopover();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Set temp HP" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shield button is disabled when input is '0'", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPopover();
|
||||||
|
await user.type(screen.getByPlaceholderText("HP"), "0");
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Set temp HP" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||||
|
import { ImportMethodDialog } from "../import-method-dialog.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
polyfillDialog();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function renderDialog(open = true) {
|
||||||
|
const onSelectFile = vi.fn();
|
||||||
|
const onSubmitClipboard = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const result = render(
|
||||||
|
<ImportMethodDialog
|
||||||
|
open={open}
|
||||||
|
onSelectFile={onSelectFile}
|
||||||
|
onSubmitClipboard={onSubmitClipboard}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return { ...result, onSelectFile, onSubmitClipboard, onClose };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ImportMethodDialog", () => {
|
||||||
|
it("opens in pick mode with two method buttons", () => {
|
||||||
|
renderDialog();
|
||||||
|
expect(screen.getByText("From file")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Paste content")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("From file button calls onSelectFile and closes", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onSelectFile, onClose } = renderDialog();
|
||||||
|
|
||||||
|
await user.click(screen.getByText("From file"));
|
||||||
|
expect(onSelectFile).toHaveBeenCalled();
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Paste content button switches to paste mode", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Paste content"));
|
||||||
|
expect(
|
||||||
|
screen.getByPlaceholderText("Paste exported JSON here..."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Import" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("typing text enables Import button", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Paste content"));
|
||||||
|
const textarea = screen.getByPlaceholderText("Paste exported JSON here...");
|
||||||
|
await user.type(textarea, "test-data");
|
||||||
|
expect(screen.getByRole("button", { name: "Import" })).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Import calls onSubmitClipboard with text and closes", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onSubmitClipboard, onClose } = renderDialog();
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Paste content"));
|
||||||
|
await user.type(
|
||||||
|
screen.getByPlaceholderText("Paste exported JSON here..."),
|
||||||
|
"some-json-content",
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Import" }));
|
||||||
|
expect(onSubmitClipboard).toHaveBeenCalledWith("some-json-content");
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Back button returns to pick mode and clears text", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Paste content"));
|
||||||
|
await user.type(
|
||||||
|
screen.getByPlaceholderText("Paste exported JSON here..."),
|
||||||
|
"some text",
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("From file")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByPlaceholderText("Paste exported JSON here..."),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
89
apps/web/src/components/__tests__/overflow-menu.test.tsx
Normal file
89
apps/web/src/components/__tests__/overflow-menu.test.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { userEvent } from "@testing-library/user-event";
|
||||||
|
import { Circle } from "lucide-react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { OverflowMenu } from "../ui/overflow-menu.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ icon: <Circle />, label: "Action A", onClick: vi.fn() },
|
||||||
|
{ icon: <Circle />, label: "Action B", onClick: vi.fn() },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("OverflowMenu", () => {
|
||||||
|
it("renders toggle button", () => {
|
||||||
|
render(<OverflowMenu items={items} />);
|
||||||
|
expect(screen.getByRole("button", { name: "More actions" })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show menu items when closed", () => {
|
||||||
|
render(<OverflowMenu items={items} />);
|
||||||
|
expect(screen.queryByText("Action A")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows menu items when toggled open", async () => {
|
||||||
|
render(<OverflowMenu items={items} />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "More actions" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("Action A")).toBeDefined();
|
||||||
|
expect(screen.getByText("Action B")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes menu after clicking an item", async () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<OverflowMenu items={[{ icon: <Circle />, label: "Do it", onClick }]} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "More actions" }));
|
||||||
|
await userEvent.click(screen.getByText("Do it"));
|
||||||
|
|
||||||
|
expect(onClick).toHaveBeenCalledOnce();
|
||||||
|
expect(screen.queryByText("Do it")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps menu open when keepOpen is true", async () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<OverflowMenu
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
icon: <Circle />,
|
||||||
|
label: "Stay",
|
||||||
|
onClick,
|
||||||
|
keepOpen: true,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "More actions" }));
|
||||||
|
await userEvent.click(screen.getByText("Stay"));
|
||||||
|
|
||||||
|
expect(onClick).toHaveBeenCalledOnce();
|
||||||
|
expect(screen.getByText("Stay")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables items when disabled is true", async () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<OverflowMenu
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
icon: <Circle />,
|
||||||
|
label: "Nope",
|
||||||
|
onClick,
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "More actions" }));
|
||||||
|
const item = screen.getByText("Nope");
|
||||||
|
expect(item.closest("button")?.hasAttribute("disabled")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { createRef } from "react";
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||||
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
|
import {
|
||||||
|
PlayerCharacterSection,
|
||||||
|
type PlayerCharacterSectionHandle,
|
||||||
|
} from "../player-character-section.js";
|
||||||
|
|
||||||
|
const CREATE_FIRST_PC_REGEX = /create your first player character/i;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
polyfillDialog();
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
vi.mock("../../persistence/encounter-storage.js", () => ({
|
||||||
|
loadEncounter: () => null,
|
||||||
|
saveEncounter: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../persistence/player-character-storage.js", () => ({
|
||||||
|
loadPlayerCharacters: () => [],
|
||||||
|
savePlayerCharacters: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
||||||
|
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
||||||
|
isSourceCached: () => Promise.resolve(false),
|
||||||
|
cacheSource: () => Promise.resolve(),
|
||||||
|
getCachedSources: () => Promise.resolve([]),
|
||||||
|
clearSource: () => Promise.resolve(),
|
||||||
|
clearAll: () => Promise.resolve(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
||||||
|
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||||
|
getAllSourceCodes: () => [],
|
||||||
|
getDefaultFetchUrl: () => "",
|
||||||
|
getSourceDisplayName: (code: string) => code,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderSection() {
|
||||||
|
const ref = createRef<PlayerCharacterSectionHandle>();
|
||||||
|
const result = render(<PlayerCharacterSection ref={ref} />, {
|
||||||
|
wrapper: AllProviders,
|
||||||
|
});
|
||||||
|
return { ...result, ref };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PlayerCharacterSection", () => {
|
||||||
|
it("openManagement ref handle opens the management dialog", async () => {
|
||||||
|
const { ref } = renderSection();
|
||||||
|
|
||||||
|
const handle = ref.current;
|
||||||
|
if (!handle) throw new Error("ref not set");
|
||||||
|
act(() => handle.openManagement());
|
||||||
|
|
||||||
|
// Management dialog should now be open with its title visible
|
||||||
|
await waitFor(() => {
|
||||||
|
const dialogs = document.querySelectorAll("dialog");
|
||||||
|
const managementDialog = Array.from(dialogs).find((d) =>
|
||||||
|
d.textContent?.includes("Player Characters"),
|
||||||
|
);
|
||||||
|
expect(managementDialog).toHaveAttribute("open");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creating a character from management opens create modal", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { ref } = renderSection();
|
||||||
|
|
||||||
|
const handle = ref.current;
|
||||||
|
if (!handle) throw new Error("ref not set");
|
||||||
|
act(() => handle.openManagement());
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: CREATE_FIRST_PC_REGEX,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create modal should now be visible
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByPlaceholderText("Character name")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saving a new character and returning to management", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { ref } = renderSection();
|
||||||
|
|
||||||
|
const handle = ref.current;
|
||||||
|
if (!handle) throw new Error("ref not set");
|
||||||
|
act(() => handle.openManagement());
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: CREATE_FIRST_PC_REGEX,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fill in the create form
|
||||||
|
await user.type(screen.getByPlaceholderText("Character name"), "Aria");
|
||||||
|
await user.type(screen.getByPlaceholderText("AC"), "16");
|
||||||
|
await user.type(screen.getByPlaceholderText("Max HP"), "30");
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||||
|
|
||||||
|
// Should return to management dialog showing the new character
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Aria")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
120
apps/web/src/components/__tests__/player-management.test.tsx
Normal file
120
apps/web/src/components/__tests__/player-management.test.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { type PlayerCharacter, playerCharacterId } from "@initiative/domain";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const CREATE_FIRST_PC_REGEX = /create your first player character/i;
|
||||||
|
const LEVEL_REGEX = /^Lv /;
|
||||||
|
|
||||||
|
import { PlayerManagement } from "../player-management.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
polyfillDialog();
|
||||||
|
});
|
||||||
|
|
||||||
|
const PC_WARRIOR: PlayerCharacter = {
|
||||||
|
id: playerCharacterId("pc-1"),
|
||||||
|
name: "Thorin",
|
||||||
|
ac: 18,
|
||||||
|
maxHp: 45,
|
||||||
|
color: "red",
|
||||||
|
icon: "sword",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PC_WIZARD: PlayerCharacter = {
|
||||||
|
id: playerCharacterId("pc-2"),
|
||||||
|
name: "Gandalf",
|
||||||
|
ac: 12,
|
||||||
|
maxHp: 30,
|
||||||
|
color: "blue",
|
||||||
|
icon: "wand",
|
||||||
|
level: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderManagement(
|
||||||
|
overrides: Partial<Parameters<typeof PlayerManagement>[0]> = {},
|
||||||
|
) {
|
||||||
|
const props = {
|
||||||
|
open: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
characters: [] as readonly PlayerCharacter[],
|
||||||
|
onEdit: vi.fn(),
|
||||||
|
onDelete: vi.fn(),
|
||||||
|
onCreate: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
return { ...render(<PlayerManagement {...props} />), props };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PlayerManagement", () => {
|
||||||
|
it("shows empty state when no characters", () => {
|
||||||
|
renderManagement();
|
||||||
|
expect(screen.getByText("No player characters yet")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows create button in empty state that calls onCreate", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { props } = renderManagement();
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: CREATE_FIRST_PC_REGEX,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(props.onCreate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders each character with name, AC, HP", () => {
|
||||||
|
renderManagement({ characters: [PC_WARRIOR, PC_WIZARD] });
|
||||||
|
expect(screen.getByText("Thorin")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Gandalf")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("AC 18")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("HP 45")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("AC 12")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("HP 30")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows level when present, omits when undefined", () => {
|
||||||
|
renderManagement({ characters: [PC_WARRIOR, PC_WIZARD] });
|
||||||
|
expect(screen.getByText("Lv 10")).toBeInTheDocument();
|
||||||
|
// Thorin has no level — there should be only one "Lv" text
|
||||||
|
expect(screen.queryAllByText(LEVEL_REGEX)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("edit button calls onEdit with the character", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { props } = renderManagement({ characters: [PC_WARRIOR] });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||||
|
expect(props.onEdit).toHaveBeenCalledWith(PC_WARRIOR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delete button calls onDelete after confirmation", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { props } = renderManagement({ characters: [PC_WARRIOR] });
|
||||||
|
|
||||||
|
const deleteBtn = screen.getByRole("button", {
|
||||||
|
name: "Delete player character",
|
||||||
|
});
|
||||||
|
await user.click(deleteBtn);
|
||||||
|
const confirmBtn = screen.getByRole("button", {
|
||||||
|
name: "Confirm delete player character",
|
||||||
|
});
|
||||||
|
await user.click(confirmBtn);
|
||||||
|
expect(props.onDelete).toHaveBeenCalledWith(PC_WARRIOR.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("add button calls onCreate", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { props } = renderManagement({ characters: [PC_WARRIOR] });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Add" }));
|
||||||
|
expect(props.onCreate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
55
apps/web/src/components/__tests__/roll-mode-menu.test.tsx
Normal file
55
apps/web/src/components/__tests__/roll-mode-menu.test.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { userEvent } from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { RollModeMenu } from "../roll-mode-menu.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("RollModeMenu", () => {
|
||||||
|
it("renders advantage and disadvantage buttons", () => {
|
||||||
|
render(
|
||||||
|
<RollModeMenu
|
||||||
|
position={{ x: 100, y: 100 }}
|
||||||
|
onSelect={() => {}}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Advantage")).toBeDefined();
|
||||||
|
expect(screen.getByText("Disadvantage")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onSelect with 'advantage' and onClose when clicked", async () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<RollModeMenu
|
||||||
|
position={{ x: 100, y: 100 }}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText("Advantage"));
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledWith("advantage");
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onSelect with 'disadvantage' and onClose when clicked", async () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<RollModeMenu
|
||||||
|
position={{ x: 100, y: 100 }}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText("Disadvantage"));
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledWith("disadvantage");
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
110
apps/web/src/components/__tests__/settings-modal.test.tsx
Normal file
110
apps/web/src/components/__tests__/settings-modal.test.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||||
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
|
import { SettingsModal } from "../settings-modal.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
polyfillDialog();
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../../persistence/encounter-storage.js", () => ({
|
||||||
|
loadEncounter: () => null,
|
||||||
|
saveEncounter: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../persistence/player-character-storage.js", () => ({
|
||||||
|
loadPlayerCharacters: () => [],
|
||||||
|
savePlayerCharacters: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
||||||
|
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
||||||
|
isSourceCached: () => Promise.resolve(false),
|
||||||
|
cacheSource: () => Promise.resolve(),
|
||||||
|
getCachedSources: () => Promise.resolve([]),
|
||||||
|
clearSource: () => Promise.resolve(),
|
||||||
|
clearAll: () => Promise.resolve(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
||||||
|
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||||
|
getAllSourceCodes: () => [],
|
||||||
|
getDefaultFetchUrl: () => "",
|
||||||
|
getSourceDisplayName: (code: string) => code,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderModal(open = true) {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const result = render(<SettingsModal open={open} onClose={onClose} />, {
|
||||||
|
wrapper: AllProviders,
|
||||||
|
});
|
||||||
|
return { ...result, onClose };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SettingsModal", () => {
|
||||||
|
it("renders edition toggle buttons", () => {
|
||||||
|
renderModal();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "5e (2014)" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "5.5e (2024)" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders theme toggle buttons", () => {
|
||||||
|
renderModal();
|
||||||
|
expect(screen.getByRole("button", { name: "System" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Light" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Dark" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking an edition button switches the active edition", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderModal();
|
||||||
|
const btn5e = screen.getByRole("button", { name: "5e (2014)" });
|
||||||
|
await user.click(btn5e);
|
||||||
|
// After clicking 5e, it should have the active style
|
||||||
|
expect(btn5e.className).toContain("bg-accent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking a theme button switches the active theme", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderModal();
|
||||||
|
const darkBtn = screen.getByRole("button", { name: "Dark" });
|
||||||
|
await user.click(darkBtn);
|
||||||
|
expect(darkBtn.className).toContain("bg-accent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("close button calls onClose", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onClose } = renderModal();
|
||||||
|
// DialogHeader renders an X button
|
||||||
|
const buttons = screen.getAllByRole("button");
|
||||||
|
const closeBtn = buttons.find((b) => b.querySelector(".h-4.w-4") !== null);
|
||||||
|
expect(closeBtn).toBeDefined();
|
||||||
|
await user.click(closeBtn as HTMLElement);
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
124
apps/web/src/components/__tests__/source-fetch-prompt.test.tsx
Normal file
124
apps/web/src/components/__tests__/source-fetch-prompt.test.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { SourceFetchPrompt } from "../source-fetch-prompt.js";
|
||||||
|
|
||||||
|
const MONSTER_MANUAL_REGEX = /Monster Manual/;
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const mockFetchAndCacheSource = vi.fn();
|
||||||
|
const mockUploadAndCacheSource = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||||
|
useBestiaryContext: () => ({
|
||||||
|
fetchAndCacheSource: mockFetchAndCacheSource,
|
||||||
|
uploadAndCacheSource: mockUploadAndCacheSource,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
||||||
|
getDefaultFetchUrl: (code: string) =>
|
||||||
|
`https://example.com/bestiary/${code}.json`,
|
||||||
|
getSourceDisplayName: (code: string) =>
|
||||||
|
code === "MM" ? "Monster Manual" : code,
|
||||||
|
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||||
|
getAllSourceCodes: () => [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderPrompt(sourceCode = "MM") {
|
||||||
|
const onSourceLoaded = vi.fn();
|
||||||
|
const result = render(
|
||||||
|
<SourceFetchPrompt
|
||||||
|
sourceCode={sourceCode}
|
||||||
|
onSourceLoaded={onSourceLoaded}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return { ...result, onSourceLoaded };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SourceFetchPrompt", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders source name, URL input, Load and Upload buttons", () => {
|
||||||
|
renderPrompt();
|
||||||
|
expect(screen.getByText(MONSTER_MANUAL_REGEX)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByDisplayValue("https://example.com/bestiary/MM.json"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Load")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Upload file")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Load calls fetchAndCacheSource and onSourceLoaded on success", async () => {
|
||||||
|
mockFetchAndCacheSource.mockResolvedValueOnce(undefined);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onSourceLoaded } = renderPrompt();
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Load"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetchAndCacheSource).toHaveBeenCalledWith(
|
||||||
|
"MM",
|
||||||
|
"https://example.com/bestiary/MM.json",
|
||||||
|
);
|
||||||
|
expect(onSourceLoaded).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetch error shows error message", async () => {
|
||||||
|
mockFetchAndCacheSource.mockRejectedValueOnce(new Error("Network error"));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPrompt();
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Load"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Network error")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upload file calls uploadAndCacheSource and onSourceLoaded", async () => {
|
||||||
|
mockUploadAndCacheSource.mockResolvedValueOnce(undefined);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onSourceLoaded } = renderPrompt();
|
||||||
|
|
||||||
|
const file = new File(['{"monster":[]}'], "bestiary-mm.json", {
|
||||||
|
type: "application/json",
|
||||||
|
});
|
||||||
|
const fileInput = document.querySelector(
|
||||||
|
'input[type="file"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
await user.upload(fileInput, file);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUploadAndCacheSource).toHaveBeenCalledWith("MM", {
|
||||||
|
monster: [],
|
||||||
|
});
|
||||||
|
expect(onSourceLoaded).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upload error shows error message", async () => {
|
||||||
|
mockUploadAndCacheSource.mockRejectedValueOnce(new Error("Invalid format"));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPrompt();
|
||||||
|
|
||||||
|
const file = new File(['{"bad": true}'], "bad.json", {
|
||||||
|
type: "application/json",
|
||||||
|
});
|
||||||
|
const fileInput = document.querySelector(
|
||||||
|
'input[type="file"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
await user.upload(fileInput, file);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Invalid format")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
273
apps/web/src/components/__tests__/stat-block.test.tsx
Normal file
273
apps/web/src/components/__tests__/stat-block.test.tsx
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import type { Creature } from "@initiative/domain";
|
||||||
|
import { creatureId } from "@initiative/domain";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { StatBlock } from "../stat-block.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const ARMOR_CLASS_REGEX = /Armor Class/;
|
||||||
|
const DEX_PLUS_4_REGEX = /Dex \+4/;
|
||||||
|
const CR_QUARTER_REGEX = /1\/4/;
|
||||||
|
const PROF_BONUS_2_REGEX = /Proficiency Bonus \+2/;
|
||||||
|
const NIMBLE_ESCAPE_REGEX = /Nimble Escape\./;
|
||||||
|
const SCIMITAR_REGEX = /Scimitar\./;
|
||||||
|
const DETECT_REGEX = /Detect\./;
|
||||||
|
const TAIL_ATTACK_REGEX = /Tail Attack\./;
|
||||||
|
const INNATE_SPELLCASTING_REGEX = /Innate Spellcasting\./;
|
||||||
|
const AT_WILL_REGEX = /At Will:/;
|
||||||
|
const DETECT_MAGIC_REGEX = /detect magic, suggestion/;
|
||||||
|
const DAILY_REGEX = /3\/day each:/;
|
||||||
|
const FIREBALL_REGEX = /fireball, wall of fire/;
|
||||||
|
const LONG_REST_REGEX = /1\/long rest:/;
|
||||||
|
const WISH_REGEX = /wish/;
|
||||||
|
|
||||||
|
const GOBLIN: Creature = {
|
||||||
|
id: creatureId("srd:goblin"),
|
||||||
|
name: "Goblin",
|
||||||
|
source: "MM",
|
||||||
|
sourceDisplayName: "Monster Manual",
|
||||||
|
size: "Small",
|
||||||
|
type: "humanoid",
|
||||||
|
alignment: "neutral evil",
|
||||||
|
ac: 15,
|
||||||
|
acSource: "leather armor, shield",
|
||||||
|
hp: { average: 7, formula: "2d6" },
|
||||||
|
speed: "30 ft.",
|
||||||
|
abilities: { str: 8, dex: 14, con: 10, int: 10, wis: 8, cha: 8 },
|
||||||
|
cr: "1/4",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
proficiencyBonus: 2,
|
||||||
|
passive: 9,
|
||||||
|
savingThrows: "Dex +4",
|
||||||
|
skills: "Stealth +6",
|
||||||
|
senses: "darkvision 60 ft., passive Perception 9",
|
||||||
|
languages: "Common, Goblin",
|
||||||
|
traits: [{ name: "Nimble Escape", text: "Disengage or Hide as bonus." }],
|
||||||
|
actions: [{ name: "Scimitar", text: "Melee: +4 to hit, 5 slashing." }],
|
||||||
|
bonusActions: [{ name: "Nimble", text: "Disengage or Hide." }],
|
||||||
|
reactions: [{ name: "Redirect", text: "Redirect attack to ally." }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const DRAGON: Creature = {
|
||||||
|
id: creatureId("srd:dragon"),
|
||||||
|
name: "Ancient Red Dragon",
|
||||||
|
source: "MM",
|
||||||
|
sourceDisplayName: "Monster Manual",
|
||||||
|
size: "Gargantuan",
|
||||||
|
type: "dragon",
|
||||||
|
alignment: "chaotic evil",
|
||||||
|
ac: 22,
|
||||||
|
hp: { average: 546, formula: "28d20 + 252" },
|
||||||
|
speed: "40 ft., climb 40 ft., fly 80 ft.",
|
||||||
|
abilities: { str: 30, dex: 10, con: 29, int: 18, wis: 15, cha: 23 },
|
||||||
|
cr: "24",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
proficiencyBonus: 7,
|
||||||
|
passive: 26,
|
||||||
|
resist: "fire",
|
||||||
|
immune: "fire",
|
||||||
|
vulnerable: "cold",
|
||||||
|
conditionImmune: "frightened",
|
||||||
|
legendaryActions: {
|
||||||
|
preamble: "The dragon can take 3 legendary actions.",
|
||||||
|
entries: [
|
||||||
|
{ name: "Detect", text: "Wisdom (Perception) check." },
|
||||||
|
{ name: "Tail Attack", text: "Tail attack." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
spellcasting: [
|
||||||
|
{
|
||||||
|
name: "Innate Spellcasting",
|
||||||
|
headerText: "The dragon's spellcasting ability is Charisma.",
|
||||||
|
atWill: ["detect magic", "suggestion"],
|
||||||
|
daily: [{ uses: 3, each: true, spells: ["fireball", "wall of fire"] }],
|
||||||
|
restLong: [{ uses: 1, each: false, spells: ["wish"] }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderStatBlock(creature: Creature) {
|
||||||
|
return render(<StatBlock creature={creature} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("StatBlock", () => {
|
||||||
|
describe("header", () => {
|
||||||
|
it("renders creature name", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Goblin" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders size, type, alignment", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(
|
||||||
|
screen.getByText("Small humanoid, neutral evil"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders source display name", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stats bar", () => {
|
||||||
|
it("renders AC with source", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(screen.getByText(ARMOR_CLASS_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("15")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("(leather armor, shield)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders AC without source when acSource is undefined", () => {
|
||||||
|
renderStatBlock(DRAGON);
|
||||||
|
expect(screen.getByText("22")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders HP average and formula", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(screen.getByText("7")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("(2d6)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders speed", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(screen.getByText("30 ft.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ability scores", () => {
|
||||||
|
it("renders all 6 ability labels", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
for (const label of ["STR", "DEX", "CON", "INT", "WIS", "CHA"]) {
|
||||||
|
expect(screen.getByText(label)).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders ability scores with modifier notation", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(screen.getByText("(+2)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("properties", () => {
|
||||||
|
it("renders saving throws when present", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(screen.getByText("Saving Throws")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(DEX_PLUS_4_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders skills when present", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(screen.getByText("Skills")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders damage resistances, immunities, vulnerabilities", () => {
|
||||||
|
renderStatBlock(DRAGON);
|
||||||
|
expect(screen.getByText("Damage Resistances")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Damage Immunities")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Damage Vulnerabilities")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Condition Immunities")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits properties when undefined", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(screen.queryByText("Damage Resistances")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Damage Immunities")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders CR and proficiency bonus", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(screen.getByText("Challenge")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(CR_QUARTER_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(PROF_BONUS_2_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("traits", () => {
|
||||||
|
it("renders trait entries", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(screen.getByText(NIMBLE_ESCAPE_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("actions / bonus actions / reactions", () => {
|
||||||
|
it("renders actions heading and entries", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Actions" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(SCIMITAR_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders bonus actions heading and entries", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Bonus Actions" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders reactions heading and entries", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Reactions" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("legendary actions", () => {
|
||||||
|
it("renders legendary actions with preamble", () => {
|
||||||
|
renderStatBlock(DRAGON);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Legendary Actions" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("The dragon can take 3 legendary actions."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(DETECT_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(TAIL_ATTACK_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits legendary actions when undefined", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("heading", { name: "Legendary Actions" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("spellcasting", () => {
|
||||||
|
it("renders spellcasting block with header", () => {
|
||||||
|
renderStatBlock(DRAGON);
|
||||||
|
expect(screen.getByText(INNATE_SPELLCASTING_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders at-will spells", () => {
|
||||||
|
renderStatBlock(DRAGON);
|
||||||
|
expect(screen.getByText(AT_WILL_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(DETECT_MAGIC_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders daily spells", () => {
|
||||||
|
renderStatBlock(DRAGON);
|
||||||
|
expect(screen.getByText(DAILY_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(FIREBALL_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders long rest spells", () => {
|
||||||
|
renderStatBlock(DRAGON);
|
||||||
|
expect(screen.getByText(LONG_REST_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(WISH_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits spellcasting when undefined", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(screen.queryByText(AT_WILL_REGEX)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
68
apps/web/src/components/__tests__/toast.test.tsx
Normal file
68
apps/web/src/components/__tests__/toast.test.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { userEvent } from "@testing-library/user-event";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { Toast } from "../toast.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("Toast", () => {
|
||||||
|
it("renders message text", () => {
|
||||||
|
render(<Toast message="Hello" onDismiss={() => {}} />);
|
||||||
|
expect(screen.getByText("Hello")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders progress bar when progress is provided", () => {
|
||||||
|
render(<Toast message="Loading" progress={0.5} onDismiss={() => {}} />);
|
||||||
|
const bar = document.body.querySelector("[style*='width']") as HTMLElement;
|
||||||
|
expect(bar).not.toBeNull();
|
||||||
|
expect(bar.style.width).toBe("50%");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render progress bar when progress is omitted", () => {
|
||||||
|
render(<Toast message="Done" onDismiss={() => {}} />);
|
||||||
|
const bar = document.body.querySelector("[style*='width']");
|
||||||
|
expect(bar).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onDismiss when close button is clicked", async () => {
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
render(<Toast message="Hi" onDismiss={onDismiss} />);
|
||||||
|
|
||||||
|
const toast = screen.getByText("Hi").closest("div");
|
||||||
|
const button = toast?.querySelector("button");
|
||||||
|
expect(button).not.toBeNull();
|
||||||
|
await userEvent.click(button as HTMLElement);
|
||||||
|
|
||||||
|
expect(onDismiss).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("auto-dismiss", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-dismisses after specified timeout", () => {
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
render(
|
||||||
|
<Toast message="Auto" onDismiss={onDismiss} autoDismissMs={3000} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onDismiss).not.toHaveBeenCalled();
|
||||||
|
vi.advanceTimersByTime(3000);
|
||||||
|
expect(onDismiss).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not auto-dismiss when autoDismissMs is omitted", () => {
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
render(<Toast message="Stay" onDismiss={onDismiss} />);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(10000);
|
||||||
|
expect(onDismiss).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
42
apps/web/src/components/__tests__/tooltip.test.tsx
Normal file
42
apps/web/src/components/__tests__/tooltip.test.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { Tooltip } from "../ui/tooltip.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("Tooltip", () => {
|
||||||
|
it("renders children", () => {
|
||||||
|
render(
|
||||||
|
<Tooltip content="Hint">
|
||||||
|
<button type="button">Hover me</button>
|
||||||
|
</Tooltip>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Hover me")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show tooltip initially", () => {
|
||||||
|
render(
|
||||||
|
<Tooltip content="Hint">
|
||||||
|
<span>Target</span>
|
||||||
|
</Tooltip>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows tooltip on pointer enter and hides on pointer leave", () => {
|
||||||
|
render(
|
||||||
|
<Tooltip content="Hint text">
|
||||||
|
<span>Target</span>
|
||||||
|
</Tooltip>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const wrapper = screen.getByText("Target").closest("span");
|
||||||
|
fireEvent.pointerEnter(wrapper as HTMLElement);
|
||||||
|
expect(screen.getByRole("tooltip")).toBeDefined();
|
||||||
|
expect(screen.getByText("Hint text")).toBeDefined();
|
||||||
|
|
||||||
|
fireEvent.pointerLeave(wrapper as HTMLElement);
|
||||||
|
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,11 +6,19 @@ import { combatantId } from "@initiative/domain";
|
|||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
// Mock the context module
|
// Mock the context modules
|
||||||
vi.mock("../../contexts/encounter-context.js", () => ({
|
vi.mock("../../contexts/encounter-context.js", () => ({
|
||||||
useEncounterContext: vi.fn(),
|
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 { useEncounterContext } from "../../contexts/encounter-context.js";
|
||||||
import { TurnNavigation } from "../turn-navigation.js";
|
import { TurnNavigation } from "../turn-navigation.js";
|
||||||
|
|
||||||
@@ -46,13 +54,25 @@ function mockContext(overrides: Partial<Encounter> = {}) {
|
|||||||
setInitiative: vi.fn(),
|
setInitiative: vi.fn(),
|
||||||
setHp: vi.fn(),
|
setHp: vi.fn(),
|
||||||
adjustHp: vi.fn(),
|
adjustHp: vi.fn(),
|
||||||
|
setTempHp: vi.fn(),
|
||||||
|
hasTempHp: false,
|
||||||
setAc: vi.fn(),
|
setAc: vi.fn(),
|
||||||
toggleCondition: vi.fn(),
|
toggleCondition: vi.fn(),
|
||||||
toggleConcentration: vi.fn(),
|
toggleConcentration: vi.fn(),
|
||||||
addFromBestiary: vi.fn(),
|
addFromBestiary: vi.fn(),
|
||||||
|
addMultipleFromBestiary: vi.fn(),
|
||||||
addFromPlayerCharacter: vi.fn(),
|
addFromPlayerCharacter: vi.fn(),
|
||||||
makeStore: 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: [],
|
events: [],
|
||||||
|
lastCreatureId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
mockUseEncounterContext.mockReturnValue(
|
mockUseEncounterContext.mockReturnValue(
|
||||||
@@ -92,7 +112,8 @@ describe("TurnNavigation", () => {
|
|||||||
renderNav();
|
renderNav();
|
||||||
const badge = screen.getByText("R1");
|
const badge = screen.getByText("R1");
|
||||||
const name = screen.getByText("Goblin");
|
const name = screen.getByText("Goblin");
|
||||||
expect(badge.parentElement).toBe(name.parentElement);
|
// badge text is inside inner span > outer span, name is a direct child
|
||||||
|
expect(badge.closest(".flex")).toBe(name.parentElement);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates the round badge when round changes", () => {
|
it("updates the round badge when round changes", () => {
|
||||||
|
|||||||
@@ -19,17 +19,15 @@ export function AcShield({ value, onClick, className }: AcShieldProps) {
|
|||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 28 32"
|
viewBox="0 0 28 32"
|
||||||
fill="none"
|
fill="var(--color-border)"
|
||||||
stroke="currentColor"
|
fillOpacity={0.5}
|
||||||
strokeWidth={1.5}
|
stroke="none"
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className="absolute inset-0 h-full w-full"
|
className="absolute inset-0 h-full w-full"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path d="M14 1.5 L2.5 6.5 L2.5 15 Q2.5 25 14 30.5 Q25.5 25 25.5 15 L25.5 6.5 Z" />
|
<path d="M14 1.5 L2.5 6.5 L2.5 15 Q2.5 25 14 30.5 Q25.5 25 25.5 15 L25.5 6.5 Z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="relative font-medium text-xs leading-none">
|
<span className="relative -mt-0.5 font-medium text-xs leading-none">
|
||||||
{value == null ? "\u2014" : String(value)}
|
{value == null ? "\u2014" : String(value)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,53 +1,63 @@
|
|||||||
import type { CreatureId, PlayerCharacter } from "@initiative/domain";
|
import type { PlayerCharacter } from "@initiative/domain";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Import,
|
Import,
|
||||||
Library,
|
Library,
|
||||||
Minus,
|
Minus,
|
||||||
Monitor,
|
|
||||||
Moon,
|
|
||||||
Plus,
|
Plus,
|
||||||
Sun,
|
Settings,
|
||||||
|
Upload,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import React, {
|
import React, { type RefObject, useCallback, useRef, useState } from "react";
|
||||||
type RefObject,
|
|
||||||
useCallback,
|
|
||||||
useDeferredValue,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import type { SearchResult } from "../contexts/bestiary-context.js";
|
import type { SearchResult } from "../contexts/bestiary-context.js";
|
||||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
|
||||||
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
||||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
import {
|
||||||
import { useThemeContext } from "../contexts/theme-context.js";
|
creatureKey,
|
||||||
|
type QueuedCreature,
|
||||||
|
type SuggestionActions,
|
||||||
|
useActionBarState,
|
||||||
|
} from "../hooks/use-action-bar-state.js";
|
||||||
import { useLongPress } from "../hooks/use-long-press.js";
|
import { useLongPress } from "../hooks/use-long-press.js";
|
||||||
import { cn } from "../lib/utils.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 { 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 { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
|
||||||
import { RollModeMenu } from "./roll-mode-menu.js";
|
import { RollModeMenu } from "./roll-mode-menu.js";
|
||||||
|
import { Toast } from "./toast.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
import { Input } from "./ui/input.js";
|
import { Input } from "./ui/input.js";
|
||||||
import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
|
import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
|
||||||
|
|
||||||
interface QueuedCreature {
|
|
||||||
result: SearchResult;
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActionBarProps {
|
interface ActionBarProps {
|
||||||
inputRef?: RefObject<HTMLInputElement | null>;
|
inputRef?: RefObject<HTMLInputElement | null>;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
onManagePlayers?: () => void;
|
onManagePlayers?: () => void;
|
||||||
|
onOpenSettings?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function creatureKey(r: SearchResult): string {
|
interface AddModeSuggestionsProps {
|
||||||
return `${r.source}:${r.name}`;
|
nameInput: string;
|
||||||
|
suggestions: SearchResult[];
|
||||||
|
pcMatches: PlayerCharacter[];
|
||||||
|
suggestionIndex: number;
|
||||||
|
queued: QueuedCreature | null;
|
||||||
|
actions: SuggestionActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AddModeSuggestions({
|
function AddModeSuggestions({
|
||||||
@@ -56,34 +66,15 @@ function AddModeSuggestions({
|
|||||||
pcMatches,
|
pcMatches,
|
||||||
suggestionIndex,
|
suggestionIndex,
|
||||||
queued,
|
queued,
|
||||||
onDismiss,
|
actions,
|
||||||
onClickSuggestion,
|
}: Readonly<AddModeSuggestionsProps>) {
|
||||||
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;
|
|
||||||
}>) {
|
|
||||||
return (
|
return (
|
||||||
<div className="card-glow absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-lg border border-border bg-card">
|
<div className="card-glow absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-lg border border-border bg-card">
|
||||||
<button
|
<button
|
||||||
type="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"
|
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()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={onDismiss}
|
onClick={actions.dismiss}
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
<span className="flex-1">Add "{nameInput}" as custom</span>
|
<span className="flex-1">Add "{nameInput}" as custom</span>
|
||||||
@@ -110,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"
|
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()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onAddFromPlayerCharacter?.(pc);
|
actions.addFromPlayerCharacter?.(pc);
|
||||||
onClear();
|
actions.clear();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!!PcIcon && (
|
{!!PcIcon && (
|
||||||
@@ -147,8 +138,8 @@ function AddModeSuggestions({
|
|||||||
"hover:bg-hover-neutral-bg",
|
"hover:bg-hover-neutral-bg",
|
||||||
)}
|
)}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => onClickSuggestion(result)}
|
onClick={() => actions.clickSuggestion(result)}
|
||||||
onMouseEnter={() => onSetSuggestionIndex(i)}
|
onMouseEnter={() => actions.setSuggestionIndex(i)}
|
||||||
>
|
>
|
||||||
<span>{result.name}</span>
|
<span>{result.name}</span>
|
||||||
<span className="flex items-center gap-1 text-muted-foreground text-xs">
|
<span className="flex items-center gap-1 text-muted-foreground text-xs">
|
||||||
@@ -161,9 +152,9 @@ function AddModeSuggestions({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (queued.count <= 1) {
|
if (queued.count <= 1) {
|
||||||
onSetQueued(null);
|
actions.setQueued(null);
|
||||||
} else {
|
} else {
|
||||||
onSetQueued({
|
actions.setQueued({
|
||||||
...queued,
|
...queued,
|
||||||
count: queued.count - 1,
|
count: queued.count - 1,
|
||||||
});
|
});
|
||||||
@@ -181,7 +172,7 @@ function AddModeSuggestions({
|
|||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onSetQueued({
|
actions.setQueued({
|
||||||
...queued,
|
...queued,
|
||||||
count: queued.count + 1,
|
count: queued.count + 1,
|
||||||
});
|
});
|
||||||
@@ -195,7 +186,7 @@ function AddModeSuggestions({
|
|||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onConfirmQueued();
|
actions.confirmQueued();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Check className="h-3.5 w-3.5" />
|
<Check className="h-3.5 w-3.5" />
|
||||||
@@ -216,17 +207,151 @@ function AddModeSuggestions({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const THEME_ICONS = {
|
interface BrowseSuggestionsProps {
|
||||||
system: Monitor,
|
suggestions: SearchResult[];
|
||||||
light: Sun,
|
suggestionIndex: number;
|
||||||
dark: Moon,
|
onSelect: (result: SearchResult) => void;
|
||||||
} as const;
|
onHover: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const THEME_LABELS = {
|
function BrowseSuggestions({
|
||||||
system: "Theme: System",
|
suggestions,
|
||||||
light: "Theme: Light",
|
suggestionIndex,
|
||||||
dark: "Theme: Dark",
|
onSelect,
|
||||||
} as const;
|
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: {
|
function buildOverflowItems(opts: {
|
||||||
onManagePlayers?: () => void;
|
onManagePlayers?: () => void;
|
||||||
@@ -234,8 +359,9 @@ function buildOverflowItems(opts: {
|
|||||||
bestiaryLoaded: boolean;
|
bestiaryLoaded: boolean;
|
||||||
onBulkImport?: () => void;
|
onBulkImport?: () => void;
|
||||||
bulkImportDisabled?: boolean;
|
bulkImportDisabled?: boolean;
|
||||||
themePreference?: "system" | "light" | "dark";
|
onExportEncounter: () => void;
|
||||||
onCycleTheme?: () => void;
|
onImportEncounter: () => void;
|
||||||
|
onOpenSettings?: () => void;
|
||||||
}): OverflowMenuItem[] {
|
}): OverflowMenuItem[] {
|
||||||
const items: OverflowMenuItem[] = [];
|
const items: OverflowMenuItem[] = [];
|
||||||
if (opts.onManagePlayers) {
|
if (opts.onManagePlayers) {
|
||||||
@@ -260,14 +386,21 @@ function buildOverflowItems(opts: {
|
|||||||
disabled: opts.bulkImportDisabled,
|
disabled: opts.bulkImportDisabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (opts.onCycleTheme) {
|
|
||||||
const pref = opts.themePreference ?? "system";
|
|
||||||
const ThemeIcon = THEME_ICONS[pref];
|
|
||||||
items.push({
|
items.push({
|
||||||
icon: <ThemeIcon className="h-4 w-4" />,
|
icon: <Download className="h-4 w-4" />,
|
||||||
label: THEME_LABELS[pref],
|
label: "Export Encounter",
|
||||||
onClick: opts.onCycleTheme,
|
onClick: opts.onExportEncounter,
|
||||||
keepOpen: true,
|
});
|
||||||
|
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" />,
|
||||||
|
label: "Settings",
|
||||||
|
onClick: opts.onOpenSettings,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
@@ -277,261 +410,162 @@ export function ActionBar({
|
|||||||
inputRef,
|
inputRef,
|
||||||
autoFocus,
|
autoFocus,
|
||||||
onManagePlayers,
|
onManagePlayers,
|
||||||
|
onOpenSettings,
|
||||||
}: Readonly<ActionBarProps>) {
|
}: Readonly<ActionBarProps>) {
|
||||||
const {
|
const {
|
||||||
addCombatant,
|
nameInput,
|
||||||
addFromBestiary,
|
suggestions,
|
||||||
addFromPlayerCharacter,
|
pcMatches,
|
||||||
hasCreatureCombatants,
|
suggestionIndex,
|
||||||
canRollAllInitiative,
|
queued,
|
||||||
} = useEncounterContext();
|
customInit,
|
||||||
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
|
customAc,
|
||||||
useBestiaryContext();
|
customMaxHp,
|
||||||
const { characters: playerCharacters } = usePlayerCharactersContext();
|
browseMode,
|
||||||
const { showBulkImport, showSourceManager, showCreature, panelView } =
|
bestiaryLoaded,
|
||||||
useSidePanelContext();
|
hasSuggestions,
|
||||||
const { preference: themePreference, cycleTheme } = useThemeContext();
|
showBulkImport,
|
||||||
const { handleRollAllInitiative } = useInitiativeRollsContext();
|
showSourceManager,
|
||||||
|
suggestionActions,
|
||||||
|
handleNameChange,
|
||||||
|
handleKeyDown,
|
||||||
|
handleBrowseKeyDown,
|
||||||
|
handleAdd,
|
||||||
|
handleBrowseSelect,
|
||||||
|
toggleBrowseMode,
|
||||||
|
setCustomInit,
|
||||||
|
setCustomAc,
|
||||||
|
setCustomMaxHp,
|
||||||
|
} = useActionBarState();
|
||||||
|
|
||||||
const { state: bulkImportState } = useBulkImportContext();
|
const { state: bulkImportState } = useBulkImportContext();
|
||||||
|
const {
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
isEmpty: encounterIsEmpty,
|
||||||
|
setEncounter,
|
||||||
|
setUndoRedoState,
|
||||||
|
} = useEncounterContext();
|
||||||
|
const { characters: playerCharacters, replacePlayerCharacters } =
|
||||||
|
usePlayerCharactersContext();
|
||||||
|
|
||||||
const handleAddFromBestiary = useCallback(
|
const importFileRef = useRef<HTMLInputElement>(null);
|
||||||
(result: SearchResult) => {
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
const creatureId = addFromBestiary(result);
|
const [showExportMethod, setShowExportMethod] = useState(false);
|
||||||
if (creatureId && panelView.mode === "closed") {
|
const [showImportMethod, setShowImportMethod] = useState(false);
|
||||||
showCreature(creatureId);
|
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(
|
const handleExportClipboard = useCallback(
|
||||||
(result: SearchResult) => {
|
(includeHistory: boolean) => {
|
||||||
const slug = result.name
|
const bundle = assembleExportBundle(
|
||||||
.toLowerCase()
|
encounter,
|
||||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
undoRedoState,
|
||||||
.replaceAll(/(^-|-$)/g, "");
|
playerCharacters,
|
||||||
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
includeHistory,
|
||||||
showCreature(cId);
|
);
|
||||||
|
void navigator.clipboard.writeText(bundleToJson(bundle));
|
||||||
},
|
},
|
||||||
[showCreature],
|
[encounter, undoRedoState, playerCharacters],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [nameInput, setNameInput] = useState("");
|
const applyImport = useCallback(
|
||||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
(bundle: import("@initiative/domain").ExportBundle) => {
|
||||||
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
setEncounter(bundle.encounter);
|
||||||
const deferredSuggestions = useDeferredValue(suggestions);
|
setUndoRedoState({
|
||||||
const deferredPcMatches = useDeferredValue(pcMatches);
|
undoStack: bundle.undoStack,
|
||||||
const [suggestionIndex, setSuggestionIndex] = useState(-1);
|
redoStack: bundle.redoStack,
|
||||||
const [queued, setQueued] = useState<QueuedCreature | null>(null);
|
});
|
||||||
const [customInit, setCustomInit] = useState("");
|
replacePlayerCharacters([...bundle.playerCharacters]);
|
||||||
const [customAc, setCustomAc] = useState("");
|
},
|
||||||
const [customMaxHp, setCustomMaxHp] = useState("");
|
[setEncounter, setUndoRedoState, replacePlayerCharacters],
|
||||||
const [browseMode, setBrowseMode] = useState(false);
|
);
|
||||||
|
|
||||||
const clearCustomFields = () => {
|
const handleValidatedBundle = useCallback(
|
||||||
setCustomInit("");
|
(result: import("@initiative/domain").ExportBundle | string) => {
|
||||||
setCustomAc("");
|
if (typeof result === "string") {
|
||||||
setCustomMaxHp("");
|
setImportError(result);
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
if (nameInput.trim() === "") return;
|
if (encounterIsEmpty) {
|
||||||
const opts: { initiative?: number; ac?: number; maxHp?: number } = {};
|
applyImport(result);
|
||||||
const init = parseNum(customInit);
|
} else {
|
||||||
const ac = parseNum(customAc);
|
pendingBundleRef.current = result;
|
||||||
const maxHp = parseNum(customMaxHp);
|
setShowImportConfirm(true);
|
||||||
if (init !== undefined) opts.initiative = init;
|
}
|
||||||
if (ac !== undefined) opts.ac = ac;
|
},
|
||||||
if (maxHp !== undefined) opts.maxHp = maxHp;
|
[encounterIsEmpty, applyImport],
|
||||||
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) => {
|
const handleImportFile = useCallback(
|
||||||
setNameInput(value);
|
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setSuggestionIndex(-1);
|
const file = e.target.files?.[0];
|
||||||
if (browseMode) {
|
if (!file) return;
|
||||||
handleBrowseSearch(value);
|
if (importFileRef.current) importFileRef.current.value = "";
|
||||||
} else {
|
|
||||||
handleAddSearch(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 handleClickSuggestion = (result: SearchResult) => {
|
const handleImportConfirm = useCallback(() => {
|
||||||
const key = creatureKey(result);
|
if (pendingBundleRef.current) {
|
||||||
if (queued && creatureKey(queued.result) === key) {
|
applyImport(pendingBundleRef.current);
|
||||||
setQueued({ ...queued, count: queued.count + 1 });
|
pendingBundleRef.current = null;
|
||||||
} else {
|
|
||||||
setQueued({ result, count: 1 });
|
|
||||||
}
|
}
|
||||||
};
|
setShowImportConfirm(false);
|
||||||
|
}, [applyImport]);
|
||||||
|
|
||||||
const handleEnter = () => {
|
const handleImportCancel = useCallback(() => {
|
||||||
if (queued) {
|
pendingBundleRef.current = null;
|
||||||
confirmQueued();
|
setShowImportConfirm(false);
|
||||||
} 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((m) => !m);
|
|
||||||
clearInput();
|
|
||||||
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],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const overflowItems = buildOverflowItems({
|
const overflowItems = buildOverflowItems({
|
||||||
onManagePlayers,
|
onManagePlayers,
|
||||||
onOpenSourceManager: showSourceManager,
|
onOpenSourceManager: showSourceManager,
|
||||||
bestiaryLoaded,
|
bestiaryLoaded,
|
||||||
onBulkImport: showBulkImport,
|
onBulkImport: showBulkImport,
|
||||||
bulkImportDisabled: bulkImportState.status === "loading",
|
bulkImportDisabled: bulkImportState.status === "loading",
|
||||||
themePreference,
|
onExportEncounter: () => setShowExportMethod(true),
|
||||||
onCycleTheme: cycleTheme,
|
onImportEncounter: () => setShowImportMethod(true),
|
||||||
|
onOpenSettings,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
|
<div className="card-glow flex items-center gap-3 border-border border-t bg-card px-4 py-3 sm:rounded-lg sm:border">
|
||||||
<form
|
<form
|
||||||
onSubmit={handleAdd}
|
onSubmit={handleAdd}
|
||||||
className="relative flex flex-1 items-center gap-2"
|
className="relative flex flex-1 flex-wrap items-center gap-3 sm:flex-nowrap"
|
||||||
>
|
>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="relative max-w-xs">
|
<div className="relative max-w-xs">
|
||||||
@@ -555,6 +589,7 @@ export function ActionBar({
|
|||||||
"absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
|
"absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
|
||||||
browseMode && "text-accent",
|
browseMode && "text-accent",
|
||||||
)}
|
)}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={toggleBrowseMode}
|
onClick={toggleBrowseMode}
|
||||||
title={browseMode ? "Switch to add mode" : "Browse stat blocks"}
|
title={browseMode ? "Switch to add mode" : "Browse stat blocks"}
|
||||||
aria-label={
|
aria-label={
|
||||||
@@ -568,112 +603,73 @@ export function ActionBar({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{browseMode && deferredSuggestions.length > 0 && (
|
{!!browseMode && (
|
||||||
<div className="card-glow absolute bottom-full z-50 mb-1 w-full rounded-lg border border-border bg-card">
|
<BrowseSuggestions
|
||||||
<ul className="max-h-48 overflow-y-auto py-1">
|
suggestions={suggestions}
|
||||||
{deferredSuggestions.map((result, i) => (
|
suggestionIndex={suggestionIndex}
|
||||||
<li key={creatureKey(result)}>
|
onSelect={handleBrowseSelect}
|
||||||
<button
|
onHover={suggestionActions.setSuggestionIndex}
|
||||||
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 && hasSuggestions && (
|
{!browseMode && hasSuggestions && (
|
||||||
<AddModeSuggestions
|
<AddModeSuggestions
|
||||||
nameInput={nameInput}
|
nameInput={nameInput}
|
||||||
suggestions={deferredSuggestions}
|
suggestions={suggestions}
|
||||||
pcMatches={deferredPcMatches}
|
pcMatches={pcMatches}
|
||||||
suggestionIndex={suggestionIndex}
|
suggestionIndex={suggestionIndex}
|
||||||
queued={queued}
|
queued={queued}
|
||||||
onDismiss={dismissSuggestions}
|
actions={suggestionActions}
|
||||||
onClear={clearInput}
|
|
||||||
onClickSuggestion={handleClickSuggestion}
|
|
||||||
onSetSuggestionIndex={setSuggestionIndex}
|
|
||||||
onSetQueued={setQueued}
|
|
||||||
onConfirmQueued={confirmQueued}
|
|
||||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||||
<div className="flex items-center gap-2">
|
<CustomStatFields
|
||||||
<Input
|
customInit={customInit}
|
||||||
type="text"
|
customAc={customAc}
|
||||||
inputMode="numeric"
|
customMaxHp={customMaxHp}
|
||||||
value={customInit}
|
onInitChange={setCustomInit}
|
||||||
onChange={(e) => setCustomInit(e.target.value)}
|
onAcChange={setCustomAc}
|
||||||
placeholder="Init"
|
onMaxHpChange={setCustomMaxHp}
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||||
<Button type="submit">Add</Button>
|
<Button type="submit">Add</Button>
|
||||||
)}
|
)}
|
||||||
{!!hasCreatureCombatants && (
|
<RollAllButton />
|
||||||
<>
|
|
||||||
<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)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
|
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
|
||||||
</form>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ interface Combatant {
|
|||||||
readonly initiative?: number;
|
readonly initiative?: number;
|
||||||
readonly maxHp?: number;
|
readonly maxHp?: number;
|
||||||
readonly currentHp?: number;
|
readonly currentHp?: number;
|
||||||
|
readonly tempHp?: number;
|
||||||
readonly ac?: number;
|
readonly ac?: number;
|
||||||
readonly conditions?: readonly ConditionId[];
|
readonly conditions?: readonly ConditionId[];
|
||||||
readonly isConcentrating?: boolean;
|
readonly isConcentrating?: boolean;
|
||||||
@@ -111,7 +112,7 @@ function EditableName({
|
|||||||
onClick={startEditing}
|
onClick={startEditing}
|
||||||
title="Rename"
|
title="Rename"
|
||||||
aria-label="Rename"
|
aria-label="Rename"
|
||||||
className="inline-flex shrink-0 items-center rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-colors transition-opacity hover:bg-hover-neutral-bg hover:text-hover-neutral focus:opacity-100 group-hover:opacity-100"
|
className="inline-flex pointer-coarse:w-auto w-0 shrink-0 items-center overflow-hidden pointer-coarse:overflow-visible rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-all duration-150 hover:bg-hover-neutral-bg hover:text-hover-neutral focus:w-auto focus:overflow-visible focus:opacity-100 group-hover:w-auto group-hover:overflow-visible group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -156,7 +157,7 @@ function MaxHpDisplay({
|
|||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={draft}
|
value={draft}
|
||||||
placeholder="Max"
|
placeholder="Max"
|
||||||
className="h-7 w-[7ch] text-center text-sm tabular-nums"
|
className="h-7 w-[7ch] text-center tabular-nums"
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onBlur={commit}
|
onBlur={commit}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -171,7 +172,12 @@ function MaxHpDisplay({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={startEditing}
|
onClick={startEditing}
|
||||||
className="inline-block h-7 min-w-[3ch] text-center text-muted-foreground text-sm tabular-nums leading-7 transition-colors hover:text-hover-neutral"
|
className={cn(
|
||||||
|
"inline-block h-7 min-w-[3ch] text-center leading-7 transition-colors hover:text-hover-neutral",
|
||||||
|
maxHp === undefined
|
||||||
|
? "text-muted-foreground text-sm"
|
||||||
|
: "text-muted-foreground text-xs",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{maxHp ?? "Max"}
|
{maxHp ?? "Max"}
|
||||||
</button>
|
</button>
|
||||||
@@ -181,51 +187,47 @@ function MaxHpDisplay({
|
|||||||
function ClickableHp({
|
function ClickableHp({
|
||||||
currentHp,
|
currentHp,
|
||||||
maxHp,
|
maxHp,
|
||||||
|
tempHp,
|
||||||
onAdjust,
|
onAdjust,
|
||||||
dimmed,
|
onSetTempHp,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
currentHp: number | undefined;
|
currentHp: number | undefined;
|
||||||
maxHp: number | undefined;
|
maxHp: number | undefined;
|
||||||
|
tempHp: number | undefined;
|
||||||
onAdjust: (delta: number) => void;
|
onAdjust: (delta: number) => void;
|
||||||
dimmed?: boolean;
|
onSetTempHp: (value: number) => void;
|
||||||
}>) {
|
}>) {
|
||||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||||
const status = deriveHpStatus(currentHp, maxHp);
|
const status = deriveHpStatus(currentHp, maxHp);
|
||||||
|
|
||||||
if (maxHp === undefined) {
|
if (maxHp === undefined) {
|
||||||
return (
|
return null;
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-block h-7 w-[4ch] text-center text-muted-foreground text-sm tabular-nums leading-7",
|
|
||||||
dimmed && "opacity-50",
|
|
||||||
)}
|
|
||||||
role="status"
|
|
||||||
aria-label="No HP set"
|
|
||||||
>
|
|
||||||
--
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative flex items-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPopoverOpen(true)}
|
onClick={() => setPopoverOpen(true)}
|
||||||
aria-label={`Current HP: ${currentHp} (${status})`}
|
aria-label={`Current HP: ${currentHp}${tempHp ? ` (+${tempHp} temp)` : ""} (${status})`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-block h-7 min-w-[3ch] text-center font-medium text-sm tabular-nums leading-7 transition-colors hover:text-hover-neutral",
|
"inline-block h-7 min-w-[3ch] text-center font-medium text-sm leading-7 transition-colors hover:text-hover-neutral",
|
||||||
status === "bloodied" && "text-amber-400",
|
status === "bloodied" && "text-amber-400",
|
||||||
status === "unconscious" && "text-red-400",
|
status === "unconscious" && "text-red-400",
|
||||||
status === "healthy" && "text-foreground",
|
status === "healthy" && "text-foreground",
|
||||||
dimmed && "opacity-50",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{currentHp}
|
{currentHp}
|
||||||
</button>
|
</button>
|
||||||
|
{!!tempHp && (
|
||||||
|
<span className="font-medium text-cyan-400 text-sm leading-7">
|
||||||
|
+{tempHp}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{!!popoverOpen && (
|
{!!popoverOpen && (
|
||||||
<HpAdjustPopover
|
<HpAdjustPopover
|
||||||
onAdjust={onAdjust}
|
onAdjust={onAdjust}
|
||||||
|
onSetTempHp={onSetTempHp}
|
||||||
onClose={() => setPopoverOpen(false)}
|
onClose={() => setPopoverOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -270,7 +272,7 @@ function AcDisplay({
|
|||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={draft}
|
value={draft}
|
||||||
placeholder="AC"
|
placeholder="AC"
|
||||||
className="h-7 w-[6ch] text-center text-sm tabular-nums"
|
className="h-7 w-[6ch] text-center tabular-nums"
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onBlur={commit}
|
onBlur={commit}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -346,7 +348,7 @@ function InitiativeDisplay({
|
|||||||
value={draft}
|
value={draft}
|
||||||
placeholder="--"
|
placeholder="--"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-7 w-[6ch] text-center text-sm tabular-nums",
|
"h-7 w-full text-center tabular-nums",
|
||||||
dimmed && "opacity-50",
|
dimmed && "opacity-50",
|
||||||
)}
|
)}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
@@ -443,6 +445,7 @@ export function CombatantRow({
|
|||||||
removeCombatant,
|
removeCombatant,
|
||||||
setHp,
|
setHp,
|
||||||
adjustHp,
|
adjustHp,
|
||||||
|
setTempHp,
|
||||||
setAc,
|
setAc,
|
||||||
toggleCondition,
|
toggleCondition,
|
||||||
toggleConcentration,
|
toggleConcentration,
|
||||||
@@ -475,24 +478,27 @@ export function CombatantRow({
|
|||||||
const conditionAnchorRef = useRef<HTMLDivElement>(null);
|
const conditionAnchorRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const prevHpRef = useRef(currentHp);
|
const prevHpRef = useRef(currentHp);
|
||||||
|
const prevTempHpRef = useRef(combatant.tempHp);
|
||||||
const [isPulsing, setIsPulsing] = useState(false);
|
const [isPulsing, setIsPulsing] = useState(false);
|
||||||
const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prevHp = prevHpRef.current;
|
const prevHp = prevHpRef.current;
|
||||||
|
const prevTempHp = prevTempHpRef.current;
|
||||||
prevHpRef.current = currentHp;
|
prevHpRef.current = currentHp;
|
||||||
|
prevTempHpRef.current = combatant.tempHp;
|
||||||
|
|
||||||
if (
|
const realHpDropped =
|
||||||
prevHp !== undefined &&
|
prevHp !== undefined && currentHp !== undefined && currentHp < prevHp;
|
||||||
currentHp !== undefined &&
|
const tempHpDropped =
|
||||||
currentHp < prevHp &&
|
prevTempHp !== undefined && (combatant.tempHp ?? 0) < prevTempHp;
|
||||||
combatant.isConcentrating
|
|
||||||
) {
|
if ((realHpDropped || tempHpDropped) && combatant.isConcentrating) {
|
||||||
setIsPulsing(true);
|
setIsPulsing(true);
|
||||||
clearTimeout(pulseTimerRef.current);
|
clearTimeout(pulseTimerRef.current);
|
||||||
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
|
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
|
||||||
}
|
}
|
||||||
}, [currentHp, combatant.isConcentrating]);
|
}, [currentHp, combatant.tempHp, combatant.isConcentrating]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!combatant.isConcentrating) {
|
if (!combatant.isConcentrating) {
|
||||||
@@ -514,7 +520,7 @@ export function CombatantRow({
|
|||||||
isPulsing && "animate-concentration-pulse",
|
isPulsing && "animate-concentration-pulse",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-[2rem_3rem_1fr_auto_auto_2rem] items-center gap-3 py-2">
|
<div className="grid grid-cols-[2rem_3rem_auto_1fr_auto_2rem] items-center gap-1.5 py-3 sm:grid-cols-[2rem_3.5rem_auto_1fr_auto_2rem] sm:gap-3 sm:py-2">
|
||||||
{/* Concentration */}
|
{/* Concentration */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -530,6 +536,7 @@ export function CombatantRow({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Initiative */}
|
{/* Initiative */}
|
||||||
|
<div className="rounded-md bg-muted/30 px-1">
|
||||||
<InitiativeDisplay
|
<InitiativeDisplay
|
||||||
initiative={initiative}
|
initiative={initiative}
|
||||||
combatantId={id}
|
combatantId={id}
|
||||||
@@ -537,6 +544,12 @@ export function CombatantRow({
|
|||||||
onSetInitiative={setInitiative}
|
onSetInitiative={setInitiative}
|
||||||
onRollInitiative={onRollInitiative}
|
onRollInitiative={onRollInitiative}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AC */}
|
||||||
|
<div className={cn(dimmed && "opacity-50")}>
|
||||||
|
<AcDisplay ac={combatant.ac} onCommit={(v) => setAc(id, v)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Name + Conditions */}
|
{/* Name + Conditions */}
|
||||||
<div
|
<div
|
||||||
@@ -585,33 +598,28 @@ export function CombatantRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AC */}
|
|
||||||
<div className={cn(dimmed && "opacity-50")}>
|
|
||||||
<AcDisplay ac={combatant.ac} onCommit={(v) => setAc(id, v)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* HP */}
|
{/* HP */}
|
||||||
<div className="flex items-center gap-1">
|
<div
|
||||||
<ClickableHp
|
|
||||||
currentHp={currentHp}
|
|
||||||
maxHp={maxHp}
|
|
||||||
onAdjust={(delta) => adjustHp(id, delta)}
|
|
||||||
dimmed={dimmed}
|
|
||||||
/>
|
|
||||||
{maxHp !== undefined && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-muted-foreground text-sm tabular-nums",
|
"flex items-center rounded-md tabular-nums",
|
||||||
|
maxHp === undefined
|
||||||
|
? ""
|
||||||
|
: "gap-0.5 border border-border/50 bg-muted/30 px-1.5",
|
||||||
dimmed && "opacity-50",
|
dimmed && "opacity-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
/
|
<ClickableHp
|
||||||
</span>
|
currentHp={currentHp}
|
||||||
|
maxHp={maxHp}
|
||||||
|
tempHp={combatant.tempHp}
|
||||||
|
onAdjust={(delta) => adjustHp(id, delta)}
|
||||||
|
onSetTempHp={(value) => setTempHp(id, value)}
|
||||||
|
/>
|
||||||
|
{maxHp !== undefined && (
|
||||||
|
<span className="text-muted-foreground/50 text-xs">/</span>
|
||||||
)}
|
)}
|
||||||
<div className={cn(dimmed && "opacity-50")}>
|
|
||||||
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => setHp(id, v)} />
|
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => setHp(id, v)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
|
|||||||
@@ -1,58 +1,19 @@
|
|||||||
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
|
|
||||||
import type { LucideIcon } from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
type ConditionId,
|
||||||
Ban,
|
getConditionDescription,
|
||||||
BatteryLow,
|
getConditionsForEdition,
|
||||||
Droplet,
|
} from "@initiative/domain";
|
||||||
EarOff,
|
import { useLayoutEffect, useRef, useState } from "react";
|
||||||
EyeOff,
|
|
||||||
Gem,
|
|
||||||
Ghost,
|
|
||||||
Hand,
|
|
||||||
Heart,
|
|
||||||
Link,
|
|
||||||
Moon,
|
|
||||||
Siren,
|
|
||||||
Sparkles,
|
|
||||||
ZapOff,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
|
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import {
|
||||||
|
CONDITION_COLOR_CLASSES,
|
||||||
|
CONDITION_ICON_MAP,
|
||||||
|
} from "./condition-styles.js";
|
||||||
import { Tooltip } from "./ui/tooltip.js";
|
import { Tooltip } from "./ui/tooltip.js";
|
||||||
|
|
||||||
const ICON_MAP: Record<string, LucideIcon> = {
|
|
||||||
EyeOff,
|
|
||||||
Heart,
|
|
||||||
EarOff,
|
|
||||||
BatteryLow,
|
|
||||||
Siren,
|
|
||||||
Hand,
|
|
||||||
Ban,
|
|
||||||
Ghost,
|
|
||||||
ZapOff,
|
|
||||||
Gem,
|
|
||||||
Droplet,
|
|
||||||
ArrowDown,
|
|
||||||
Link,
|
|
||||||
Sparkles,
|
|
||||||
Moon,
|
|
||||||
};
|
|
||||||
|
|
||||||
const COLOR_CLASSES: Record<string, string> = {
|
|
||||||
neutral: "text-muted-foreground",
|
|
||||||
pink: "text-pink-400",
|
|
||||||
amber: "text-amber-400",
|
|
||||||
orange: "text-orange-400",
|
|
||||||
gray: "text-gray-400",
|
|
||||||
violet: "text-violet-400",
|
|
||||||
yellow: "text-yellow-400",
|
|
||||||
slate: "text-slate-400",
|
|
||||||
green: "text-green-400",
|
|
||||||
indigo: "text-indigo-400",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ConditionPickerProps {
|
interface ConditionPickerProps {
|
||||||
anchorRef: React.RefObject<HTMLElement | null>;
|
anchorRef: React.RefObject<HTMLElement | null>;
|
||||||
activeConditions: readonly ConditionId[] | undefined;
|
activeConditions: readonly ConditionId[] | undefined;
|
||||||
@@ -94,16 +55,10 @@ export function ConditionPicker({
|
|||||||
setPos({ top, left: anchorRect.left, maxHeight });
|
setPos({ top, left: anchorRect.left, maxHeight });
|
||||||
}, [anchorRef]);
|
}, [anchorRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useClickOutside(ref, onClose);
|
||||||
function handleClickOutside(e: MouseEvent) {
|
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
|
const conditions = getConditionsForEdition(edition);
|
||||||
const active = new Set(activeConditions ?? []);
|
const active = new Set(activeConditions ?? []);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
@@ -116,13 +71,18 @@ export function ConditionPicker({
|
|||||||
: { visibility: "hidden" as const }
|
: { visibility: "hidden" as const }
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{CONDITION_DEFINITIONS.map((def) => {
|
{conditions.map((def) => {
|
||||||
const Icon = ICON_MAP[def.iconName];
|
const Icon = CONDITION_ICON_MAP[def.iconName];
|
||||||
if (!Icon) return null;
|
if (!Icon) return null;
|
||||||
const isActive = active.has(def.id);
|
const isActive = active.has(def.id);
|
||||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
const colorClass =
|
||||||
|
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||||
return (
|
return (
|
||||||
<Tooltip key={def.id} content={def.description} className="block">
|
<Tooltip
|
||||||
|
key={def.id}
|
||||||
|
content={getConditionDescription(def, edition)}
|
||||||
|
className="block"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
54
apps/web/src/components/condition-styles.ts
Normal file
54
apps/web/src/components/condition-styles.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
ArrowDown,
|
||||||
|
Ban,
|
||||||
|
BatteryLow,
|
||||||
|
Droplet,
|
||||||
|
EarOff,
|
||||||
|
EyeOff,
|
||||||
|
Gem,
|
||||||
|
Ghost,
|
||||||
|
Hand,
|
||||||
|
Heart,
|
||||||
|
Link,
|
||||||
|
Moon,
|
||||||
|
ShieldMinus,
|
||||||
|
Siren,
|
||||||
|
Snail,
|
||||||
|
Sparkles,
|
||||||
|
ZapOff,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
|
||||||
|
EyeOff,
|
||||||
|
Heart,
|
||||||
|
EarOff,
|
||||||
|
BatteryLow,
|
||||||
|
Siren,
|
||||||
|
Hand,
|
||||||
|
Ban,
|
||||||
|
Ghost,
|
||||||
|
ZapOff,
|
||||||
|
Gem,
|
||||||
|
Droplet,
|
||||||
|
ArrowDown,
|
||||||
|
Link,
|
||||||
|
ShieldMinus,
|
||||||
|
Snail,
|
||||||
|
Sparkles,
|
||||||
|
Moon,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CONDITION_COLOR_CLASSES: Record<string, string> = {
|
||||||
|
neutral: "text-muted-foreground",
|
||||||
|
pink: "text-pink-400",
|
||||||
|
amber: "text-amber-400",
|
||||||
|
orange: "text-orange-400",
|
||||||
|
gray: "text-gray-400",
|
||||||
|
violet: "text-violet-400",
|
||||||
|
yellow: "text-yellow-400",
|
||||||
|
slate: "text-slate-400",
|
||||||
|
green: "text-green-400",
|
||||||
|
indigo: "text-indigo-400",
|
||||||
|
sky: "text-sky-400",
|
||||||
|
};
|
||||||
@@ -1,57 +1,17 @@
|
|||||||
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
|
|
||||||
import type { LucideIcon } from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
CONDITION_DEFINITIONS,
|
||||||
Ban,
|
type ConditionId,
|
||||||
BatteryLow,
|
getConditionDescription,
|
||||||
Droplet,
|
} from "@initiative/domain";
|
||||||
EarOff,
|
import { Plus } from "lucide-react";
|
||||||
EyeOff,
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
Gem,
|
|
||||||
Ghost,
|
|
||||||
Hand,
|
|
||||||
Heart,
|
|
||||||
Link,
|
|
||||||
Moon,
|
|
||||||
Plus,
|
|
||||||
Siren,
|
|
||||||
Sparkles,
|
|
||||||
ZapOff,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { cn } from "../lib/utils.js";
|
import { cn } from "../lib/utils.js";
|
||||||
|
import {
|
||||||
|
CONDITION_COLOR_CLASSES,
|
||||||
|
CONDITION_ICON_MAP,
|
||||||
|
} from "./condition-styles.js";
|
||||||
import { Tooltip } from "./ui/tooltip.js";
|
import { Tooltip } from "./ui/tooltip.js";
|
||||||
|
|
||||||
const ICON_MAP: Record<string, LucideIcon> = {
|
|
||||||
EyeOff,
|
|
||||||
Heart,
|
|
||||||
EarOff,
|
|
||||||
BatteryLow,
|
|
||||||
Siren,
|
|
||||||
Hand,
|
|
||||||
Ban,
|
|
||||||
Ghost,
|
|
||||||
ZapOff,
|
|
||||||
Gem,
|
|
||||||
Droplet,
|
|
||||||
ArrowDown,
|
|
||||||
Link,
|
|
||||||
Sparkles,
|
|
||||||
Moon,
|
|
||||||
};
|
|
||||||
|
|
||||||
const COLOR_CLASSES: Record<string, string> = {
|
|
||||||
neutral: "text-muted-foreground",
|
|
||||||
pink: "text-pink-400",
|
|
||||||
amber: "text-amber-400",
|
|
||||||
orange: "text-orange-400",
|
|
||||||
gray: "text-gray-400",
|
|
||||||
violet: "text-violet-400",
|
|
||||||
yellow: "text-yellow-400",
|
|
||||||
slate: "text-slate-400",
|
|
||||||
green: "text-green-400",
|
|
||||||
indigo: "text-indigo-400",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ConditionTagsProps {
|
interface ConditionTagsProps {
|
||||||
conditions: readonly ConditionId[] | undefined;
|
conditions: readonly ConditionId[] | undefined;
|
||||||
onRemove: (conditionId: ConditionId) => void;
|
onRemove: (conditionId: ConditionId) => void;
|
||||||
@@ -63,16 +23,21 @@ export function ConditionTags({
|
|||||||
onRemove,
|
onRemove,
|
||||||
onOpenPicker,
|
onOpenPicker,
|
||||||
}: Readonly<ConditionTagsProps>) {
|
}: Readonly<ConditionTagsProps>) {
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-0.5">
|
<div className="flex flex-wrap items-center gap-0.5">
|
||||||
{conditions?.map((condId) => {
|
{conditions?.map((condId) => {
|
||||||
const def = CONDITION_DEFINITIONS.find((d) => d.id === condId);
|
const def = CONDITION_DEFINITIONS.find((d) => d.id === condId);
|
||||||
if (!def) return null;
|
if (!def) return null;
|
||||||
const Icon = ICON_MAP[def.iconName];
|
const Icon = CONDITION_ICON_MAP[def.iconName];
|
||||||
if (!Icon) return null;
|
if (!Icon) return null;
|
||||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
const colorClass =
|
||||||
|
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||||
return (
|
return (
|
||||||
<Tooltip key={condId} content={`${def.label}: ${def.description}`}>
|
<Tooltip
|
||||||
|
key={condId}
|
||||||
|
content={`${def.label}:\n${getConditionDescription(def, edition)}`}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Remove ${def.label}`}
|
aria-label={`Remove ${def.label}`}
|
||||||
@@ -94,7 +59,7 @@ export function ConditionTags({
|
|||||||
type="button"
|
type="button"
|
||||||
title="Add condition"
|
title="Add condition"
|
||||||
aria-label="Add condition"
|
aria-label="Add condition"
|
||||||
className="inline-flex items-center rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-colors transition-opacity hover:bg-hover-neutral-bg hover:text-hover-neutral focus:opacity-100 group-hover:opacity-100"
|
className="inline-flex pointer-coarse:w-auto w-0 items-center overflow-hidden pointer-coarse:overflow-visible rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-all duration-150 hover:bg-hover-neutral-bg hover:text-hover-neutral focus:w-auto focus:overflow-visible focus:opacity-100 group-hover:w-auto group-hover:overflow-visible group-hover:opacity-100"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onOpenPicker();
|
onOpenPicker();
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import type { PlayerCharacter } from "@initiative/domain";
|
import type { PlayerCharacter } from "@initiative/domain";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ColorPalette } from "./color-palette";
|
import { ColorPalette } from "./color-palette";
|
||||||
import { IconGrid } from "./icon-grid";
|
import { IconGrid } from "./icon-grid";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
|
import { Dialog } from "./ui/dialog";
|
||||||
import { Input } from "./ui/input";
|
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 {
|
interface CreatePlayerModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -15,6 +23,7 @@ interface CreatePlayerModalProps {
|
|||||||
maxHp: number,
|
maxHp: number,
|
||||||
color: string | undefined,
|
color: string | undefined,
|
||||||
icon: string | undefined,
|
icon: string | undefined,
|
||||||
|
level: number | undefined,
|
||||||
) => void;
|
) => void;
|
||||||
playerCharacter?: PlayerCharacter;
|
playerCharacter?: PlayerCharacter;
|
||||||
}
|
}
|
||||||
@@ -25,12 +34,12 @@ export function CreatePlayerModal({
|
|||||||
onSave,
|
onSave,
|
||||||
playerCharacter,
|
playerCharacter,
|
||||||
}: Readonly<CreatePlayerModalProps>) {
|
}: Readonly<CreatePlayerModalProps>) {
|
||||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [ac, setAc] = useState("10");
|
const [ac, setAc] = useState("10");
|
||||||
const [maxHp, setMaxHp] = useState("10");
|
const [maxHp, setMaxHp] = useState("10");
|
||||||
const [color, setColor] = useState("blue");
|
const [color, setColor] = useState("blue");
|
||||||
const [icon, setIcon] = useState("sword");
|
const [icon, setIcon] = useState("sword");
|
||||||
|
const [level, setLevel] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
const isEdit = !!playerCharacter;
|
const isEdit = !!playerCharacter;
|
||||||
@@ -43,45 +52,23 @@ export function CreatePlayerModal({
|
|||||||
setMaxHp(String(playerCharacter.maxHp));
|
setMaxHp(String(playerCharacter.maxHp));
|
||||||
setColor(playerCharacter.color ?? "");
|
setColor(playerCharacter.color ?? "");
|
||||||
setIcon(playerCharacter.icon ?? "");
|
setIcon(playerCharacter.icon ?? "");
|
||||||
|
setLevel(
|
||||||
|
playerCharacter.level === undefined
|
||||||
|
? ""
|
||||||
|
: String(playerCharacter.level),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setName("");
|
setName("");
|
||||||
setAc("10");
|
setAc("10");
|
||||||
setMaxHp("10");
|
setMaxHp("10");
|
||||||
setColor("");
|
setColor("");
|
||||||
setIcon("");
|
setIcon("");
|
||||||
|
setLevel("");
|
||||||
}
|
}
|
||||||
setError("");
|
setError("");
|
||||||
}
|
}
|
||||||
}, [open, playerCharacter]);
|
}, [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>) => {
|
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
@@ -99,15 +86,24 @@ export function CreatePlayerModal({
|
|||||||
setError("Max HP must be at least 1");
|
setError("Max HP must be at least 1");
|
||||||
return;
|
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();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<dialog
|
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="font-semibold text-foreground text-lg">
|
<h2 className="font-semibold text-foreground text-lg">
|
||||||
{isEdit ? "Edit Player" : "Create Player"}
|
{isEdit ? "Edit Player" : "Create Player"}
|
||||||
@@ -166,6 +162,20 @@ export function CreatePlayerModal({
|
|||||||
className="text-center"
|
className="text-center"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -187,6 +197,6 @@ export function CreatePlayerModal({
|
|||||||
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
|
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
apps/web/src/components/export-method-dialog.tsx
Normal file
93
apps/web/src/components/export-method-dialog.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { Check, ClipboardCopy, Download } from "lucide-react";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { Dialog, DialogHeader } 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">
|
||||||
|
<DialogHeader title="Export Encounter" onClose={handleClose} />
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Heart, Sword } from "lucide-react";
|
import { Heart, ShieldPlus, Sword } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -6,16 +6,22 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||||
import { Input } from "./ui/input";
|
import { Input } from "./ui/input";
|
||||||
|
|
||||||
const DIGITS_ONLY_REGEX = /^\d+$/;
|
const DIGITS_ONLY_REGEX = /^\d+$/;
|
||||||
|
|
||||||
interface HpAdjustPopoverProps {
|
interface HpAdjustPopoverProps {
|
||||||
readonly onAdjust: (delta: number) => void;
|
readonly onAdjust: (delta: number) => void;
|
||||||
|
readonly onSetTempHp: (value: number) => void;
|
||||||
readonly onClose: () => void;
|
readonly onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
export function HpAdjustPopover({
|
||||||
|
onAdjust,
|
||||||
|
onSetTempHp,
|
||||||
|
onClose,
|
||||||
|
}: HpAdjustPopoverProps) {
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -43,15 +49,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
|||||||
requestAnimationFrame(() => inputRef.current?.focus());
|
requestAnimationFrame(() => inputRef.current?.focus());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useClickOutside(ref, onClose);
|
||||||
function handleClickOutside(e: MouseEvent) {
|
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const parsedValue =
|
const parsedValue =
|
||||||
inputValue === "" ? null : Number.parseInt(inputValue, 10);
|
inputValue === "" ? null : Number.parseInt(inputValue, 10);
|
||||||
@@ -101,7 +99,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
|||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
placeholder="HP"
|
placeholder="HP"
|
||||||
className="h-7 w-[7ch] text-center text-sm tabular-nums"
|
className="h-7 w-[7ch] text-center tabular-nums"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const v = e.target.value;
|
const v = e.target.value;
|
||||||
if (v === "" || DIGITS_ONLY_REGEX.test(v)) {
|
if (v === "" || DIGITS_ONLY_REGEX.test(v)) {
|
||||||
@@ -130,6 +128,21 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
|||||||
>
|
>
|
||||||
<Heart size={14} />
|
<Heart size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!isValid}
|
||||||
|
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-cyan-400 transition-colors hover:bg-cyan-400/10 hover:text-cyan-300 disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
onClick={() => {
|
||||||
|
if (isValid && parsedValue) {
|
||||||
|
onSetTempHp(parsedValue);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Set temp HP"
|
||||||
|
aria-label="Set temp HP"
|
||||||
|
>
|
||||||
|
<ShieldPlus size={14} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
apps/web/src/components/import-method-dialog.tsx
Normal file
114
apps/web/src/components/import-method-dialog.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { ClipboardPaste, FileUp } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Button } from "./ui/button.js";
|
||||||
|
import { Dialog, DialogHeader } 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">
|
||||||
|
<DialogHeader title="Import Encounter" onClose={handleClose} />
|
||||||
|
{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);
|
setEditingPlayer(undefined);
|
||||||
setManagementOpen(true);
|
setManagementOpen(true);
|
||||||
}}
|
}}
|
||||||
onSave={(name, ac, maxHp, color, icon) => {
|
onSave={(name, ac, maxHp, color, icon, level) => {
|
||||||
if (editingPlayer) {
|
if (editingPlayer) {
|
||||||
editCharacter(editingPlayer.id, {
|
editCharacter(editingPlayer.id, {
|
||||||
name,
|
name,
|
||||||
@@ -43,9 +43,10 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
|||||||
maxHp,
|
maxHp,
|
||||||
color: color ?? null,
|
color: color ?? null,
|
||||||
icon: icon ?? null,
|
icon: icon ?? null,
|
||||||
|
level: level ?? null,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
createCharacter(name, ac, maxHp, color, icon);
|
createCharacter(name, ac, maxHp, color, icon, level);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
playerCharacter={editingPlayer}
|
playerCharacter={editingPlayer}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||||
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { ConfirmButton } from "./ui/confirm-button";
|
import { ConfirmButton } from "./ui/confirm-button";
|
||||||
|
import { Dialog, DialogHeader } from "./ui/dialog";
|
||||||
|
|
||||||
interface PlayerManagementProps {
|
interface PlayerManagementProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -22,54 +22,9 @@ export function PlayerManagement({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onCreate,
|
onCreate,
|
||||||
}: Readonly<PlayerManagementProps>) {
|
}: 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 (
|
return (
|
||||||
<dialog
|
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
|
||||||
ref={dialogRef}
|
<DialogHeader title="Player Characters" onClose={onClose} />
|
||||||
className="card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
|
|
||||||
>
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
|
||||||
<h2 className="font-semibold text-foreground text-lg">
|
|
||||||
Player Characters
|
|
||||||
</h2>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
>
|
|
||||||
<X size={20} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{characters.length === 0 ? (
|
{characters.length === 0 ? (
|
||||||
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||||
@@ -101,6 +56,11 @@ export function PlayerManagement({
|
|||||||
<span className="text-muted-foreground text-xs tabular-nums">
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
HP {pc.maxHp}
|
HP {pc.maxHp}
|
||||||
</span>
|
</span>
|
||||||
|
{pc.level !== undefined && (
|
||||||
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
|
Lv {pc.level}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
@@ -128,6 +88,6 @@ export function PlayerManagement({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { RollMode } from "@initiative/domain";
|
import type { RollMode } from "@initiative/domain";
|
||||||
import { ChevronsDown, ChevronsUp } from "lucide-react";
|
import { ChevronsDown, ChevronsUp } from "lucide-react";
|
||||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
import { useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||||
|
|
||||||
interface RollModeMenuProps {
|
interface RollModeMenuProps {
|
||||||
readonly position: { x: number; y: number };
|
readonly position: { x: number; y: number };
|
||||||
@@ -34,22 +35,7 @@ export function RollModeMenu({
|
|||||||
setPos({ top, left });
|
setPos({ top, left });
|
||||||
}, [position.x, position.y]);
|
}, [position.x, position.y]);
|
||||||
|
|
||||||
useEffect(() => {
|
useClickOutside(ref, onClose);
|
||||||
function handleMouseDown(e: MouseEvent) {
|
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handleMouseDown);
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousedown", handleMouseDown);
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
89
apps/web/src/components/settings-modal.tsx
Normal file
89
apps/web/src/components/settings-modal.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import type { RulesEdition } from "@initiative/domain";
|
||||||
|
import { Monitor, Moon, Sun } from "lucide-react";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
|
import { useThemeContext } from "../contexts/theme-context.js";
|
||||||
|
import { cn } from "../lib/utils.js";
|
||||||
|
import { Dialog, DialogHeader } from "./ui/dialog.js";
|
||||||
|
|
||||||
|
interface SettingsModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EDITION_OPTIONS: { value: RulesEdition; label: string }[] = [
|
||||||
|
{ value: "5e", label: "5e (2014)" },
|
||||||
|
{ value: "5.5e", label: "5.5e (2024)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const THEME_OPTIONS: {
|
||||||
|
value: "system" | "light" | "dark";
|
||||||
|
label: string;
|
||||||
|
icon: typeof Sun;
|
||||||
|
}[] = [
|
||||||
|
{ value: "system", label: "System", icon: Monitor },
|
||||||
|
{ value: "light", label: "Light", icon: Sun },
|
||||||
|
{ value: "dark", label: "Dark", icon: Moon },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
||||||
|
const { edition, setEdition } = useRulesEditionContext();
|
||||||
|
const { preference, setPreference } = useThemeContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-sm">
|
||||||
|
<DialogHeader title="Settings" onClose={onClose} />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div>
|
||||||
|
<span className="mb-2 block font-medium text-muted-foreground text-sm">
|
||||||
|
Conditions
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{EDITION_OPTIONS.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 rounded-md px-3 py-1.5 text-sm transition-colors",
|
||||||
|
edition === opt.value
|
||||||
|
? "bg-accent text-primary-foreground"
|
||||||
|
: "bg-card text-muted-foreground hover:bg-hover-neutral-bg hover:text-foreground",
|
||||||
|
)}
|
||||||
|
onClick={() => setEdition(opt.value)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="mb-2 block font-medium text-muted-foreground text-sm">
|
||||||
|
Theme
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{THEME_OPTIONS.map((opt) => {
|
||||||
|
const Icon = opt.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm transition-colors",
|
||||||
|
preference === opt.value
|
||||||
|
? "bg-accent text-primary-foreground"
|
||||||
|
: "bg-card text-muted-foreground hover:bg-hover-neutral-bg hover:text-foreground",
|
||||||
|
)}
|
||||||
|
onClick={() => setPreference(opt.value)}
|
||||||
|
>
|
||||||
|
<Icon size={14} />
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,6 +34,31 @@ function SectionDivider() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TraitSection({
|
||||||
|
entries,
|
||||||
|
heading,
|
||||||
|
}: Readonly<{
|
||||||
|
entries: readonly { name: string; text: string }[] | undefined;
|
||||||
|
heading?: string;
|
||||||
|
}>) {
|
||||||
|
if (!entries || entries.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionDivider />
|
||||||
|
{heading ? (
|
||||||
|
<h3 className="font-bold text-base text-stat-heading">{heading}</h3>
|
||||||
|
) : null}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{entries.map((e) => (
|
||||||
|
<div key={e.name} className="text-sm">
|
||||||
|
<span className="font-semibold italic">{e.name}.</span> {e.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||||
const abilities = [
|
const abilities = [
|
||||||
{ label: "STR", score: creature.abilities.str },
|
{ label: "STR", score: creature.abilities.str },
|
||||||
@@ -134,19 +159,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Traits */}
|
<TraitSection entries={creature.traits} />
|
||||||
{creature.traits && creature.traits.length > 0 && (
|
|
||||||
<>
|
|
||||||
<SectionDivider />
|
|
||||||
<div className="space-y-2">
|
|
||||||
{creature.traits.map((t) => (
|
|
||||||
<div key={t.name} className="text-sm">
|
|
||||||
<span className="font-semibold italic">{t.name}.</span> {t.text}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Spellcasting */}
|
{/* Spellcasting */}
|
||||||
{creature.spellcasting && creature.spellcasting.length > 0 && (
|
{creature.spellcasting && creature.spellcasting.length > 0 && (
|
||||||
@@ -190,52 +203,9 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
<TraitSection entries={creature.actions} heading="Actions" />
|
||||||
{creature.actions && creature.actions.length > 0 && (
|
<TraitSection entries={creature.bonusActions} heading="Bonus Actions" />
|
||||||
<>
|
<TraitSection entries={creature.reactions} heading="Reactions" />
|
||||||
<SectionDivider />
|
|
||||||
<h3 className="font-bold text-base text-stat-heading">Actions</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{creature.actions.map((a) => (
|
|
||||||
<div key={a.name} className="text-sm">
|
|
||||||
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Bonus Actions */}
|
|
||||||
{creature.bonusActions && creature.bonusActions.length > 0 && (
|
|
||||||
<>
|
|
||||||
<SectionDivider />
|
|
||||||
<h3 className="font-bold text-base text-stat-heading">
|
|
||||||
Bonus Actions
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{creature.bonusActions.map((a) => (
|
|
||||||
<div key={a.name} className="text-sm">
|
|
||||||
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Reactions */}
|
|
||||||
{creature.reactions && creature.reactions.length > 0 && (
|
|
||||||
<>
|
|
||||||
<SectionDivider />
|
|
||||||
<h3 className="font-bold text-base text-stat-heading">Reactions</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{creature.reactions.map((a) => (
|
|
||||||
<div key={a.name} className="text-sm">
|
|
||||||
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Legendary Actions */}
|
{/* Legendary Actions */}
|
||||||
{!!creature.legendaryActions && (
|
{!!creature.legendaryActions && (
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
import { StepBack, StepForward, Trash2 } from "lucide-react";
|
import { Redo2, StepBack, StepForward, Trash2, Undo2 } from "lucide-react";
|
||||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
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 { Button } from "./ui/button.js";
|
||||||
import { ConfirmButton } from "./ui/confirm-button.js";
|
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||||
|
|
||||||
export function TurnNavigation() {
|
export function TurnNavigation() {
|
||||||
const { encounter, advanceTurn, retreatTurn, clearEncounter } =
|
const {
|
||||||
useEncounterContext();
|
encounter,
|
||||||
|
advanceTurn,
|
||||||
|
retreatTurn,
|
||||||
|
clearEncounter,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
} = useEncounterContext();
|
||||||
|
|
||||||
|
const difficulty = useDifficulty();
|
||||||
const hasCombatants = encounter.combatants.length > 0;
|
const hasCombatants = encounter.combatants.length > 0;
|
||||||
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
||||||
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
|
<div className="card-glow flex items-center gap-3 border-border border-b bg-card px-4 py-3 sm:rounded-lg sm:border">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -24,15 +35,41 @@ export function TurnNavigation() {
|
|||||||
<StepBack className="h-5 w-5" />
|
<StepBack className="h-5 w-5" />
|
||||||
</Button>
|
</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">
|
<div className="flex min-w-0 flex-1 items-center justify-center gap-2 text-sm">
|
||||||
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 font-semibold text-foreground 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">
|
||||||
R{encounter.roundNumber}
|
R{encounter.roundNumber}
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
{activeCombatant ? (
|
{activeCombatant ? (
|
||||||
<span className="truncate font-medium">{activeCombatant.name}</span>
|
<span className="truncate font-medium">{activeCombatant.name}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">No combatants</span>
|
<span className="text-muted-foreground">No combatants</span>
|
||||||
)}
|
)}
|
||||||
|
{difficulty && <DifficultyIndicator result={difficulty} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-shrink-0 items-center gap-3">
|
<div className="flex flex-shrink-0 items-center gap-3">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useClickOutside } from "../../hooks/use-click-outside.js";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
import { Button } from "./button";
|
import { Button } from "./button";
|
||||||
|
|
||||||
@@ -42,32 +43,7 @@ export function ConfirmButton({
|
|||||||
return () => clearTimeout(timerRef.current);
|
return () => clearTimeout(timerRef.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Click-outside listener when confirming
|
useClickOutside(wrapperRef, revert, isConfirming);
|
||||||
useEffect(() => {
|
|
||||||
if (!isConfirming) return;
|
|
||||||
|
|
||||||
function handleMouseDown(e: MouseEvent) {
|
|
||||||
if (
|
|
||||||
wrapperRef.current &&
|
|
||||||
!wrapperRef.current.contains(e.target as Node)
|
|
||||||
) {
|
|
||||||
revert();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEscapeKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
revert();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("mousedown", handleMouseDown);
|
|
||||||
document.addEventListener("keydown", handleEscapeKey);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousedown", handleMouseDown);
|
|
||||||
document.removeEventListener("keydown", handleEscapeKey);
|
|
||||||
};
|
|
||||||
}, [isConfirming, revert]);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
|||||||
71
apps/web/src/components/ui/dialog.tsx
Normal file
71
apps/web/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { X } from "lucide-react";
|
||||||
|
import { type ReactNode, useEffect, useRef } from "react";
|
||||||
|
import { cn } from "../../lib/utils.js";
|
||||||
|
import { Button } from "./button.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogHeader({
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
}: Readonly<{ title: string; onClose: () => void }>) {
|
||||||
|
return (
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold text-foreground text-lg">{title}</h2>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ export const Input = ({
|
|||||||
<input
|
<input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-foreground text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50 sm:text-sm",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { EllipsisVertical } from "lucide-react";
|
import { EllipsisVertical } from "lucide-react";
|
||||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
import { type ReactNode, useRef, useState } from "react";
|
||||||
|
import { useClickOutside } from "../../hooks/use-click-outside.js";
|
||||||
import { Button } from "./button";
|
import { Button } from "./button";
|
||||||
|
|
||||||
export interface OverflowMenuItem {
|
export interface OverflowMenuItem {
|
||||||
@@ -18,23 +19,7 @@ export function OverflowMenu({ items }: OverflowMenuProps) {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useClickOutside(ref, () => setOpen(false), open);
|
||||||
if (!open) return;
|
|
||||||
function handleMouseDown(e: MouseEvent) {
|
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") setOpen(false);
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handleMouseDown);
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousedown", handleMouseDown);
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="relative">
|
<div ref={ref} className="relative">
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function Tooltip({
|
|||||||
createPortal(
|
createPortal(
|
||||||
<div
|
<div
|
||||||
role="tooltip"
|
role="tooltip"
|
||||||
className="pointer-events-none fixed z-[60] max-w-64 -translate-x-1/2 -translate-y-full rounded-md border border-border bg-background px-2.5 py-1.5 text-foreground text-xs leading-snug shadow-lg"
|
className="pointer-events-none fixed z-[60] max-w-64 -translate-x-1/2 -translate-y-full whitespace-pre-line rounded-md border border-border bg-background px-2.5 py-1.5 text-foreground text-xs leading-snug shadow-lg"
|
||||||
style={{ top: pos.top, left: pos.left }}
|
style={{ top: pos.top, left: pos.left }}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createContext, type ReactNode, useContext } from "react";
|
import { createContext, type ReactNode, useContext } from "react";
|
||||||
import { useEncounter } from "../hooks/use-encounter.js";
|
import { useEncounter } from "../hooks/use-encounter.js";
|
||||||
|
import { useUndoRedoShortcuts } from "../hooks/use-undo-redo-shortcuts.js";
|
||||||
|
|
||||||
type EncounterContextValue = ReturnType<typeof useEncounter>;
|
type EncounterContextValue = ReturnType<typeof useEncounter>;
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@ const EncounterContext = createContext<EncounterContextValue | null>(null);
|
|||||||
|
|
||||||
export function EncounterProvider({ children }: { children: ReactNode }) {
|
export function EncounterProvider({ children }: { children: ReactNode }) {
|
||||||
const value = useEncounter();
|
const value = useEncounter();
|
||||||
|
useUndoRedoShortcuts(value.undo, value.redo, value.canUndo, value.canRedo);
|
||||||
return (
|
return (
|
||||||
<EncounterContext.Provider value={value}>
|
<EncounterContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -3,5 +3,6 @@ export { BulkImportProvider } from "./bulk-import-context.js";
|
|||||||
export { EncounterProvider } from "./encounter-context.js";
|
export { EncounterProvider } from "./encounter-context.js";
|
||||||
export { InitiativeRollsProvider } from "./initiative-rolls-context.js";
|
export { InitiativeRollsProvider } from "./initiative-rolls-context.js";
|
||||||
export { PlayerCharactersProvider } from "./player-characters-context.js";
|
export { PlayerCharactersProvider } from "./player-characters-context.js";
|
||||||
|
export { RulesEditionProvider } from "./rules-edition-context.js";
|
||||||
export { SidePanelProvider } from "./side-panel-context.js";
|
export { SidePanelProvider } from "./side-panel-context.js";
|
||||||
export { ThemeProvider } from "./theme-context.js";
|
export { ThemeProvider } from "./theme-context.js";
|
||||||
|
|||||||
24
apps/web/src/contexts/rules-edition-context.tsx
Normal file
24
apps/web/src/contexts/rules-edition-context.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { createContext, type ReactNode, useContext } from "react";
|
||||||
|
import { useRulesEdition } from "../hooks/use-rules-edition.js";
|
||||||
|
|
||||||
|
type RulesEditionContextValue = ReturnType<typeof useRulesEdition>;
|
||||||
|
|
||||||
|
const RulesEditionContext = createContext<RulesEditionContextValue | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
export function RulesEditionProvider({ children }: { children: ReactNode }) {
|
||||||
|
const value = useRulesEdition();
|
||||||
|
return (
|
||||||
|
<RulesEditionContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</RulesEditionContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRulesEditionContext(): RulesEditionContextValue {
|
||||||
|
const ctx = useContext(RulesEditionContext);
|
||||||
|
if (!ctx)
|
||||||
|
throw new Error("useRulesEditionContext requires RulesEditionProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
416
apps/web/src/hooks/__tests__/encounter-reducer.test.ts
Normal file
416
apps/web/src/hooks/__tests__/encounter-reducer.test.ts
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
import type {
|
||||||
|
BestiaryIndexEntry,
|
||||||
|
ConditionId,
|
||||||
|
PlayerCharacter,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import {
|
||||||
|
combatantId,
|
||||||
|
createEncounter,
|
||||||
|
EMPTY_UNDO_REDO_STATE,
|
||||||
|
isDomainError,
|
||||||
|
playerCharacterId,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { type EncounterState, encounterReducer } from "../use-encounter.js";
|
||||||
|
|
||||||
|
vi.mock("../../persistence/encounter-storage.js", () => ({
|
||||||
|
loadEncounter: vi.fn().mockReturnValue(null),
|
||||||
|
saveEncounter: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../persistence/undo-redo-storage.js", () => ({
|
||||||
|
loadUndoRedoStacks: vi.fn().mockReturnValue(EMPTY_UNDO_REDO_STATE),
|
||||||
|
saveUndoRedoStacks: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function emptyState(): EncounterState {
|
||||||
|
return {
|
||||||
|
encounter: {
|
||||||
|
combatants: [],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
},
|
||||||
|
undoRedoState: EMPTY_UNDO_REDO_STATE,
|
||||||
|
events: [],
|
||||||
|
nextId: 0,
|
||||||
|
lastCreatureId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateWith(...names: string[]): EncounterState {
|
||||||
|
let state = emptyState();
|
||||||
|
for (const name of names) {
|
||||||
|
state = encounterReducer(state, { type: "add-combatant", name });
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateWithHp(name: string, maxHp: number): EncounterState {
|
||||||
|
const state = stateWith(name);
|
||||||
|
const id = state.encounter.combatants[0].id;
|
||||||
|
return encounterReducer(state, {
|
||||||
|
type: "set-hp",
|
||||||
|
id,
|
||||||
|
maxHp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const BESTIARY_ENTRY: BestiaryIndexEntry = {
|
||||||
|
name: "Goblin",
|
||||||
|
source: "MM",
|
||||||
|
ac: 15,
|
||||||
|
hp: 7,
|
||||||
|
dex: 14,
|
||||||
|
cr: "1/4",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
size: "Small",
|
||||||
|
type: "humanoid",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("encounterReducer", () => {
|
||||||
|
describe("add-combatant", () => {
|
||||||
|
it("adds a combatant and pushes undo", () => {
|
||||||
|
const next = encounterReducer(emptyState(), {
|
||||||
|
type: "add-combatant",
|
||||||
|
name: "Goblin",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants).toHaveLength(1);
|
||||||
|
expect(next.encounter.combatants[0].name).toBe("Goblin");
|
||||||
|
expect(next.undoRedoState.undoStack).toHaveLength(1);
|
||||||
|
expect(next.nextId).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies optional init values", () => {
|
||||||
|
const next = encounterReducer(emptyState(), {
|
||||||
|
type: "add-combatant",
|
||||||
|
name: "Goblin",
|
||||||
|
init: { initiative: 15, ac: 13, maxHp: 7 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const c = next.encounter.combatants[0];
|
||||||
|
expect(c.initiative).toBe(15);
|
||||||
|
expect(c.ac).toBe(13);
|
||||||
|
expect(c.maxHp).toBe(7);
|
||||||
|
expect(c.currentHp).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increments IDs", () => {
|
||||||
|
const s1 = encounterReducer(emptyState(), {
|
||||||
|
type: "add-combatant",
|
||||||
|
name: "A",
|
||||||
|
});
|
||||||
|
const s2 = encounterReducer(s1, {
|
||||||
|
type: "add-combatant",
|
||||||
|
name: "B",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(s2.encounter.combatants[0].id).toBe("c-1");
|
||||||
|
expect(s2.encounter.combatants[1].id).toBe("c-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns unchanged state for invalid name", () => {
|
||||||
|
const state = emptyState();
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "add-combatant",
|
||||||
|
name: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next).toBe(state);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("remove-combatant", () => {
|
||||||
|
it("removes combatant and pushes undo", () => {
|
||||||
|
const state = stateWith("Goblin");
|
||||||
|
const id = state.encounter.combatants[0].id;
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "remove-combatant",
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants).toHaveLength(0);
|
||||||
|
expect(next.undoRedoState.undoStack).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edit-combatant", () => {
|
||||||
|
it("renames combatant", () => {
|
||||||
|
const state = stateWith("Goblin");
|
||||||
|
const id = state.encounter.combatants[0].id;
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "edit-combatant",
|
||||||
|
id,
|
||||||
|
newName: "Hobgoblin",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants[0].name).toBe("Hobgoblin");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("advance-turn / retreat-turn", () => {
|
||||||
|
it("advances and retreats turn", () => {
|
||||||
|
const state = stateWith("A", "B");
|
||||||
|
const advanced = encounterReducer(state, {
|
||||||
|
type: "advance-turn",
|
||||||
|
});
|
||||||
|
expect(advanced.encounter.activeIndex).toBe(1);
|
||||||
|
|
||||||
|
const retreated = encounterReducer(advanced, {
|
||||||
|
type: "retreat-turn",
|
||||||
|
});
|
||||||
|
expect(retreated.encounter.activeIndex).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns unchanged state on empty encounter", () => {
|
||||||
|
const state = emptyState();
|
||||||
|
const next = encounterReducer(state, { type: "advance-turn" });
|
||||||
|
expect(next).toBe(state);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("set-hp / adjust-hp / set-temp-hp", () => {
|
||||||
|
it("sets max HP", () => {
|
||||||
|
const state = stateWith("Goblin");
|
||||||
|
const id = state.encounter.combatants[0].id;
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "set-hp",
|
||||||
|
id,
|
||||||
|
maxHp: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants[0].maxHp).toBe(20);
|
||||||
|
expect(next.encounter.combatants[0].currentHp).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adjusts HP", () => {
|
||||||
|
const state = stateWithHp("Goblin", 20);
|
||||||
|
const id = state.encounter.combatants[0].id;
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "adjust-hp",
|
||||||
|
id,
|
||||||
|
delta: -5,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants[0].currentHp).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets temp HP", () => {
|
||||||
|
const state = stateWithHp("Goblin", 20);
|
||||||
|
const id = state.encounter.combatants[0].id;
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "set-temp-hp",
|
||||||
|
id,
|
||||||
|
tempHp: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants[0].tempHp).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("set-ac", () => {
|
||||||
|
it("sets AC", () => {
|
||||||
|
const state = stateWith("Goblin");
|
||||||
|
const id = state.encounter.combatants[0].id;
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "set-ac",
|
||||||
|
id,
|
||||||
|
value: 15,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants[0].ac).toBe(15);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("set-initiative", () => {
|
||||||
|
it("sets initiative", () => {
|
||||||
|
const state = stateWith("Goblin");
|
||||||
|
const id = state.encounter.combatants[0].id;
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "set-initiative",
|
||||||
|
id,
|
||||||
|
value: 18,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants[0].initiative).toBe(18);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toggle-condition / toggle-concentration", () => {
|
||||||
|
it("toggles condition", () => {
|
||||||
|
const state = stateWith("Goblin");
|
||||||
|
const id = state.encounter.combatants[0].id;
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "toggle-condition",
|
||||||
|
id,
|
||||||
|
conditionId: "blinded" as ConditionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants[0].conditions).toContain("blinded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles concentration", () => {
|
||||||
|
const state = stateWith("Wizard");
|
||||||
|
const id = state.encounter.combatants[0].id;
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "toggle-concentration",
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants[0].isConcentrating).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clear-encounter", () => {
|
||||||
|
it("clears combatants, resets history and nextId", () => {
|
||||||
|
const state = stateWith("A", "B");
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "clear-encounter",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants).toHaveLength(0);
|
||||||
|
expect(next.undoRedoState.undoStack).toHaveLength(0);
|
||||||
|
expect(next.undoRedoState.redoStack).toHaveLength(0);
|
||||||
|
expect(next.nextId).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("undo / redo", () => {
|
||||||
|
it("undo restores previous state", () => {
|
||||||
|
const state = stateWith("Goblin");
|
||||||
|
const next = encounterReducer(state, { type: "undo" });
|
||||||
|
|
||||||
|
expect(next.encounter.combatants).toHaveLength(0);
|
||||||
|
expect(next.undoRedoState.undoStack).toHaveLength(0);
|
||||||
|
expect(next.undoRedoState.redoStack).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redo restores undone state", () => {
|
||||||
|
const state = stateWith("Goblin");
|
||||||
|
const undone = encounterReducer(state, { type: "undo" });
|
||||||
|
const redone = encounterReducer(undone, { type: "redo" });
|
||||||
|
|
||||||
|
expect(redone.encounter.combatants).toHaveLength(1);
|
||||||
|
expect(redone.encounter.combatants[0].name).toBe("Goblin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("undo returns unchanged state when stack is empty", () => {
|
||||||
|
const state = emptyState();
|
||||||
|
const next = encounterReducer(state, { type: "undo" });
|
||||||
|
expect(next).toBe(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redo returns unchanged state when stack is empty", () => {
|
||||||
|
const state = emptyState();
|
||||||
|
const next = encounterReducer(state, { type: "redo" });
|
||||||
|
expect(next).toBe(state);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("add-from-bestiary", () => {
|
||||||
|
it("adds creature with HP, AC, and creatureId", () => {
|
||||||
|
const next = encounterReducer(emptyState(), {
|
||||||
|
type: "add-from-bestiary",
|
||||||
|
entry: BESTIARY_ENTRY,
|
||||||
|
});
|
||||||
|
|
||||||
|
const c = next.encounter.combatants[0];
|
||||||
|
expect(c.name).toBe("Goblin");
|
||||||
|
expect(c.maxHp).toBe(7);
|
||||||
|
expect(c.ac).toBe(15);
|
||||||
|
expect(c.creatureId).toBe("mm:goblin");
|
||||||
|
expect(next.lastCreatureId).toBe("mm:goblin");
|
||||||
|
expect(next.undoRedoState.undoStack).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-numbers duplicate names", () => {
|
||||||
|
const s1 = encounterReducer(emptyState(), {
|
||||||
|
type: "add-from-bestiary",
|
||||||
|
entry: BESTIARY_ENTRY,
|
||||||
|
});
|
||||||
|
const s2 = encounterReducer(s1, {
|
||||||
|
type: "add-from-bestiary",
|
||||||
|
entry: BESTIARY_ENTRY,
|
||||||
|
});
|
||||||
|
|
||||||
|
const names = s2.encounter.combatants.map((c) => c.name);
|
||||||
|
expect(names).toContain("Goblin 1");
|
||||||
|
expect(names).toContain("Goblin 2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("add-multiple-from-bestiary", () => {
|
||||||
|
it("adds multiple creatures in one action", () => {
|
||||||
|
const next = encounterReducer(emptyState(), {
|
||||||
|
type: "add-multiple-from-bestiary",
|
||||||
|
entry: BESTIARY_ENTRY,
|
||||||
|
count: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants).toHaveLength(3);
|
||||||
|
expect(next.undoRedoState.undoStack).toHaveLength(1);
|
||||||
|
expect(next.lastCreatureId).toBe("mm:goblin");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("add-from-player-character", () => {
|
||||||
|
it("adds combatant with PC attributes", () => {
|
||||||
|
const pc: PlayerCharacter = {
|
||||||
|
id: playerCharacterId("pc-1"),
|
||||||
|
name: "Aria",
|
||||||
|
ac: 16,
|
||||||
|
maxHp: 30,
|
||||||
|
color: "blue",
|
||||||
|
icon: "sword",
|
||||||
|
};
|
||||||
|
const next = encounterReducer(emptyState(), {
|
||||||
|
type: "add-from-player-character",
|
||||||
|
pc,
|
||||||
|
});
|
||||||
|
|
||||||
|
const c = next.encounter.combatants[0];
|
||||||
|
expect(c.name).toBe("Aria");
|
||||||
|
expect(c.maxHp).toBe(30);
|
||||||
|
expect(c.ac).toBe(16);
|
||||||
|
expect(c.color).toBe("blue");
|
||||||
|
expect(c.icon).toBe("sword");
|
||||||
|
expect(c.playerCharacterId).toBe("pc-1");
|
||||||
|
expect(next.lastCreatureId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("import", () => {
|
||||||
|
it("replaces encounter and undo/redo state", () => {
|
||||||
|
const state = stateWith("A", "B");
|
||||||
|
const enc = createEncounter([
|
||||||
|
{ id: combatantId("c-5"), name: "Imported" },
|
||||||
|
]);
|
||||||
|
if (isDomainError(enc)) throw new Error("Setup failed");
|
||||||
|
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "import",
|
||||||
|
encounter: enc,
|
||||||
|
undoRedoState: EMPTY_UNDO_REDO_STATE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants).toHaveLength(1);
|
||||||
|
expect(next.encounter.combatants[0].name).toBe("Imported");
|
||||||
|
expect(next.nextId).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("events accumulation", () => {
|
||||||
|
it("accumulates events across actions", () => {
|
||||||
|
const s1 = encounterReducer(emptyState(), {
|
||||||
|
type: "add-combatant",
|
||||||
|
name: "A",
|
||||||
|
});
|
||||||
|
const s2 = encounterReducer(s1, {
|
||||||
|
type: "add-combatant",
|
||||||
|
name: "B",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(s2.events.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
328
apps/web/src/hooks/__tests__/use-action-bar-state.test.ts
Normal file
328
apps/web/src/hooks/__tests__/use-action-bar-state.test.ts
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import type { PlayerCharacter } from "@initiative/domain";
|
||||||
|
import { playerCharacterId } from "@initiative/domain";
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { SearchResult } from "../../contexts/bestiary-context.js";
|
||||||
|
import { useActionBarState } from "../use-action-bar-state.js";
|
||||||
|
|
||||||
|
const mockAddCombatant = vi.fn();
|
||||||
|
const mockAddFromBestiary = vi.fn();
|
||||||
|
const mockAddMultipleFromBestiary = vi.fn();
|
||||||
|
const mockAddFromPlayerCharacter = vi.fn();
|
||||||
|
const mockBestiarySearch = vi.fn<(q: string) => SearchResult[]>();
|
||||||
|
const mockShowCreature = vi.fn();
|
||||||
|
const mockShowBulkImport = vi.fn();
|
||||||
|
const mockShowSourceManager = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../contexts/encounter-context.js", () => ({
|
||||||
|
useEncounterContext: () => ({
|
||||||
|
addCombatant: mockAddCombatant,
|
||||||
|
addFromBestiary: mockAddFromBestiary,
|
||||||
|
addMultipleFromBestiary: mockAddMultipleFromBestiary,
|
||||||
|
addFromPlayerCharacter: mockAddFromPlayerCharacter,
|
||||||
|
lastCreatureId: null,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||||
|
useBestiaryContext: () => ({
|
||||||
|
search: mockBestiarySearch,
|
||||||
|
isLoaded: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../contexts/player-characters-context.js", () => ({
|
||||||
|
usePlayerCharactersContext: () => ({
|
||||||
|
characters: mockPlayerCharacters,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../contexts/side-panel-context.js", () => ({
|
||||||
|
useSidePanelContext: () => ({
|
||||||
|
showCreature: mockShowCreature,
|
||||||
|
showBulkImport: mockShowBulkImport,
|
||||||
|
showSourceManager: mockShowSourceManager,
|
||||||
|
panelView: { mode: "closed" },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let mockPlayerCharacters: PlayerCharacter[] = [];
|
||||||
|
|
||||||
|
const GOBLIN: SearchResult = {
|
||||||
|
name: "Goblin",
|
||||||
|
source: "MM",
|
||||||
|
sourceDisplayName: "Monster Manual",
|
||||||
|
ac: 15,
|
||||||
|
hp: 7,
|
||||||
|
dex: 14,
|
||||||
|
cr: "1/4",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
size: "Small",
|
||||||
|
type: "humanoid",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ORC: SearchResult = {
|
||||||
|
name: "Orc",
|
||||||
|
source: "MM",
|
||||||
|
sourceDisplayName: "Monster Manual",
|
||||||
|
ac: 13,
|
||||||
|
hp: 15,
|
||||||
|
dex: 12,
|
||||||
|
cr: "1/2",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
size: "Medium",
|
||||||
|
type: "humanoid",
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderActionBar() {
|
||||||
|
return renderHook(() => useActionBarState());
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useActionBarState", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockBestiarySearch.mockReturnValue([]);
|
||||||
|
mockPlayerCharacters = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("search and suggestions", () => {
|
||||||
|
it("starts with empty state", () => {
|
||||||
|
const { result } = renderActionBar();
|
||||||
|
|
||||||
|
expect(result.current.nameInput).toBe("");
|
||||||
|
expect(result.current.suggestions).toEqual([]);
|
||||||
|
expect(result.current.queued).toBeNull();
|
||||||
|
expect(result.current.browseMode).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("searches bestiary when input >= 2 chars", () => {
|
||||||
|
mockBestiarySearch.mockReturnValue([GOBLIN]);
|
||||||
|
const { result } = renderActionBar();
|
||||||
|
|
||||||
|
act(() => result.current.handleNameChange("go"));
|
||||||
|
|
||||||
|
expect(mockBestiarySearch).toHaveBeenCalledWith("go");
|
||||||
|
expect(result.current.nameInput).toBe("go");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not search when input < 2 chars", () => {
|
||||||
|
const { result } = renderActionBar();
|
||||||
|
|
||||||
|
act(() => result.current.handleNameChange("g"));
|
||||||
|
|
||||||
|
expect(mockBestiarySearch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches player characters by name", () => {
|
||||||
|
mockPlayerCharacters = [
|
||||||
|
{
|
||||||
|
id: playerCharacterId("pc-1"),
|
||||||
|
name: "Gandalf",
|
||||||
|
ac: 15,
|
||||||
|
maxHp: 40,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockBestiarySearch.mockReturnValue([]);
|
||||||
|
const { result } = renderActionBar();
|
||||||
|
|
||||||
|
act(() => result.current.handleNameChange("gan"));
|
||||||
|
|
||||||
|
expect(result.current.pcMatches).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("queued creatures", () => {
|
||||||
|
it("queues a creature on click", () => {
|
||||||
|
const { result } = renderActionBar();
|
||||||
|
|
||||||
|
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||||
|
|
||||||
|
expect(result.current.queued).toEqual({
|
||||||
|
result: GOBLIN,
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increments count when same creature clicked again", () => {
|
||||||
|
const { result } = renderActionBar();
|
||||||
|
|
||||||
|
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||||
|
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||||
|
|
||||||
|
expect(result.current.queued?.count).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets queue when different creature clicked", () => {
|
||||||
|
const { result } = renderActionBar();
|
||||||
|
|
||||||
|
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||||
|
act(() => result.current.suggestionActions.clickSuggestion(ORC));
|
||||||
|
|
||||||
|
expect(result.current.queued).toEqual({
|
||||||
|
result: ORC,
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("confirmQueued calls addFromBestiary for count=1", () => {
|
||||||
|
const { result } = renderActionBar();
|
||||||
|
|
||||||
|
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||||
|
act(() => result.current.suggestionActions.confirmQueued());
|
||||||
|
|
||||||
|
expect(mockAddFromBestiary).toHaveBeenCalledWith(GOBLIN);
|
||||||
|
expect(result.current.queued).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("confirmQueued calls addMultipleFromBestiary for count>1", () => {
|
||||||
|
const { result } = renderActionBar();
|
||||||
|
|
||||||
|
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||||
|
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||||
|
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||||
|
act(() => result.current.suggestionActions.confirmQueued());
|
||||||
|
|
||||||
|
expect(mockAddMultipleFromBestiary).toHaveBeenCalledWith(GOBLIN, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears queued when search text changes and creature no longer visible", () => {
|
||||||
|
mockBestiarySearch.mockReturnValue([GOBLIN]);
|
||||||
|
const { result } = renderActionBar();
|
||||||
|
|
||||||
|
act(() => result.current.handleNameChange("go"));
|
||||||
|
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||||
|
|
||||||
|
// Change search to something that won't match
|
||||||
|
mockBestiarySearch.mockReturnValue([]);
|
||||||
|
act(() => result.current.handleNameChange("xyz"));
|
||||||
|
|
||||||
|
expect(result.current.queued).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("form submission", () => {
|
||||||
|
it("adds custom combatant on submit", () => {
|
||||||
|
const { result } = renderActionBar();
|
||||||
|
|
||||||
|
act(() => result.current.handleNameChange("Fighter"));
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
} as unknown as React.SubmitEvent<HTMLFormElement>;
|
||||||
|
act(() => result.current.handleAdd(event));
|
||||||
|
|
||||||
|
expect(mockAddCombatant).toHaveBeenCalledWith("Fighter", undefined);
|
||||||
|
expect(result.current.nameInput).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not add when name is empty", () => {
|
||||||
|
const { result } = renderActionBar();
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
} as unknown as React.SubmitEvent<HTMLFormElement>;
|
||||||
|
act(() => result.current.handleAdd(event));
|
||||||
|
|
||||||
|
expect(mockAddCombatant).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes custom init/ac/maxHp when set", () => {
|
||||||
|
const { result } = renderActionBar();
|
||||||
|
|
||||||
|
act(() => result.current.handleNameChange("Fighter"));
|
||||||
|
act(() => result.current.setCustomInit("15"));
|
||||||
|
act(() => result.current.setCustomAc("18"));
|
||||||
|
act(() => result.current.setCustomMaxHp("45"));
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
} as unknown as React.SubmitEvent<HTMLFormElement>;
|
||||||
|
act(() => result.current.handleAdd(event));
|
||||||
|
|
||||||
|
expect(mockAddCombatant).toHaveBeenCalledWith("Fighter", {
|
||||||
|
initiative: 15,
|
||||||
|
ac: 18,
|
||||||
|
maxHp: 45,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not submit in browse mode", () => {
|
||||||
|
const { result } = renderActionBar();
|
||||||
|
|
||||||
|
act(() => result.current.toggleBrowseMode());
|
||||||
|
act(() => result.current.handleNameChange("Fighter"));
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
} as unknown as React.SubmitEvent<HTMLFormElement>;
|
||||||
|
act(() => result.current.handleAdd(event));
|
||||||
|
|
||||||
|
expect(mockAddCombatant).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("confirms queued on submit instead of adding by name", () => {
|
||||||
|
const { result } = renderActionBar();
|
||||||
|
|
||||||
|
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
} as unknown as React.SubmitEvent<HTMLFormElement>;
|
||||||
|
act(() => result.current.handleAdd(event));
|
||||||
|
|
||||||
|
expect(mockAddFromBestiary).toHaveBeenCalledWith(GOBLIN);
|
||||||
|
expect(mockAddCombatant).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("browse mode", () => {
|
||||||
|
it("toggles browse mode", () => {
|
||||||
|
const { result } = renderActionBar();
|
||||||
|
|
||||||
|
act(() => result.current.toggleBrowseMode());
|
||||||
|
expect(result.current.browseMode).toBe(true);
|
||||||
|
|
||||||
|
act(() => result.current.toggleBrowseMode());
|
||||||
|
expect(result.current.browseMode).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handleBrowseSelect shows creature and exits browse mode", () => {
|
||||||
|
const { result } = renderActionBar();
|
||||||
|
|
||||||
|
act(() => result.current.toggleBrowseMode());
|
||||||
|
act(() => result.current.handleBrowseSelect(GOBLIN));
|
||||||
|
|
||||||
|
expect(mockShowCreature).toHaveBeenCalledWith("mm:goblin");
|
||||||
|
expect(result.current.browseMode).toBe(false);
|
||||||
|
expect(result.current.nameInput).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dismiss and clear", () => {
|
||||||
|
it("dismissSuggestions clears suggestions and queued", () => {
|
||||||
|
mockBestiarySearch.mockReturnValue([GOBLIN]);
|
||||||
|
const { result } = renderActionBar();
|
||||||
|
|
||||||
|
act(() => result.current.handleNameChange("go"));
|
||||||
|
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||||
|
act(() => result.current.suggestionActions.dismiss());
|
||||||
|
|
||||||
|
expect(result.current.queued).toBeNull();
|
||||||
|
expect(result.current.suggestionIndex).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clear resets everything", () => {
|
||||||
|
mockBestiarySearch.mockReturnValue([GOBLIN]);
|
||||||
|
const { result } = renderActionBar();
|
||||||
|
|
||||||
|
act(() => result.current.handleNameChange("go"));
|
||||||
|
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||||
|
act(() => result.current.suggestionActions.clear());
|
||||||
|
|
||||||
|
expect(result.current.nameInput).toBe("");
|
||||||
|
expect(result.current.queued).toBeNull();
|
||||||
|
expect(result.current.suggestionIndex).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
145
apps/web/src/hooks/__tests__/use-bulk-import.test.ts
Normal file
145
apps/web/src/hooks/__tests__/use-bulk-import.test.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { useBulkImport } from "../use-bulk-import.js";
|
||||||
|
|
||||||
|
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
||||||
|
getAllSourceCodes: () => ["MM", "VGM", "XGE"],
|
||||||
|
getDefaultFetchUrl: (code: string, baseUrl: string) =>
|
||||||
|
`${baseUrl}${code}.json`,
|
||||||
|
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||||
|
getSourceDisplayName: (code: string) => code,
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** Flush microtasks so the internal async IIFE inside startImport settles. */
|
||||||
|
function flushMicrotasks(): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useBulkImport", () => {
|
||||||
|
it("starts in idle state with all counters at 0", () => {
|
||||||
|
const { result } = renderHook(() => useBulkImport());
|
||||||
|
expect(result.current.state).toEqual({
|
||||||
|
status: "idle",
|
||||||
|
total: 0,
|
||||||
|
completed: 0,
|
||||||
|
failed: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reset returns to idle state", async () => {
|
||||||
|
const { result } = renderHook(() => useBulkImport());
|
||||||
|
|
||||||
|
const isSourceCached = vi.fn().mockResolvedValue(true);
|
||||||
|
const fetchAndCacheSource = vi.fn();
|
||||||
|
const refreshCache = vi.fn();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.startImport(
|
||||||
|
"https://example.com/",
|
||||||
|
fetchAndCacheSource,
|
||||||
|
isSourceCached,
|
||||||
|
refreshCache,
|
||||||
|
);
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => result.current.reset());
|
||||||
|
expect(result.current.state.status).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("goes straight to complete when all sources are cached", async () => {
|
||||||
|
const { result } = renderHook(() => useBulkImport());
|
||||||
|
|
||||||
|
const isSourceCached = vi.fn().mockResolvedValue(true);
|
||||||
|
const fetchAndCacheSource = vi.fn();
|
||||||
|
const refreshCache = vi.fn();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.startImport(
|
||||||
|
"https://example.com/",
|
||||||
|
fetchAndCacheSource,
|
||||||
|
isSourceCached,
|
||||||
|
refreshCache,
|
||||||
|
);
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.state.status).toBe("complete");
|
||||||
|
expect(result.current.state.completed).toBe(3);
|
||||||
|
expect(fetchAndCacheSource).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches uncached sources and completes", async () => {
|
||||||
|
const { result } = renderHook(() => useBulkImport());
|
||||||
|
|
||||||
|
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||||
|
const fetchAndCacheSource = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.startImport(
|
||||||
|
"https://example.com/",
|
||||||
|
fetchAndCacheSource,
|
||||||
|
isSourceCached,
|
||||||
|
refreshCache,
|
||||||
|
);
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.state.status).toBe("complete");
|
||||||
|
expect(result.current.state.completed).toBe(3);
|
||||||
|
expect(result.current.state.failed).toBe(0);
|
||||||
|
expect(fetchAndCacheSource).toHaveBeenCalledTimes(3);
|
||||||
|
expect(refreshCache).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports partial-failure when some sources fail", async () => {
|
||||||
|
const { result } = renderHook(() => useBulkImport());
|
||||||
|
|
||||||
|
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||||
|
const fetchAndCacheSource = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(undefined)
|
||||||
|
.mockRejectedValueOnce(new Error("fail"))
|
||||||
|
.mockResolvedValueOnce(undefined);
|
||||||
|
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.startImport(
|
||||||
|
"https://example.com/",
|
||||||
|
fetchAndCacheSource,
|
||||||
|
isSourceCached,
|
||||||
|
refreshCache,
|
||||||
|
);
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.state.status).toBe("partial-failure");
|
||||||
|
expect(result.current.state.completed).toBe(2);
|
||||||
|
expect(result.current.state.failed).toBe(1);
|
||||||
|
expect(refreshCache).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls refreshCache after all batches complete", async () => {
|
||||||
|
const { result } = renderHook(() => useBulkImport());
|
||||||
|
|
||||||
|
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||||
|
const fetchAndCacheSource = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.startImport(
|
||||||
|
"https://example.com/",
|
||||||
|
fetchAndCacheSource,
|
||||||
|
isSourceCached,
|
||||||
|
refreshCache,
|
||||||
|
);
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refreshCache).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
118
apps/web/src/hooks/__tests__/use-initiative-rolls.test.ts
Normal file
118
apps/web/src/hooks/__tests__/use-initiative-rolls.test.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { type CreatureId, combatantId } from "@initiative/domain";
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useInitiativeRolls } from "../use-initiative-rolls.js";
|
||||||
|
|
||||||
|
const mockMakeStore = vi.fn(() => ({}));
|
||||||
|
const mockWithUndo = vi.fn((fn: () => unknown) => fn());
|
||||||
|
const mockGetCreature = vi.fn();
|
||||||
|
const mockShowCreature = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../contexts/encounter-context.js", () => ({
|
||||||
|
useEncounterContext: () => ({
|
||||||
|
encounter: {
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: "srd:goblin" as CreatureId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
},
|
||||||
|
makeStore: mockMakeStore,
|
||||||
|
withUndo: mockWithUndo,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||||
|
useBestiaryContext: () => ({
|
||||||
|
getCreature: mockGetCreature,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../contexts/side-panel-context.js", () => ({
|
||||||
|
useSidePanelContext: () => ({
|
||||||
|
showCreature: mockShowCreature,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockRollInitiativeUseCase = vi.fn();
|
||||||
|
const mockRollAllInitiativeUseCase = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@initiative/application", () => ({
|
||||||
|
rollInitiativeUseCase: (...args: unknown[]) =>
|
||||||
|
mockRollInitiativeUseCase(...args),
|
||||||
|
rollAllInitiativeUseCase: (...args: unknown[]) =>
|
||||||
|
mockRollAllInitiativeUseCase(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useInitiativeRolls", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handleRollInitiative calls rollInitiativeUseCase via withUndo", () => {
|
||||||
|
mockRollInitiativeUseCase.mockReturnValue({ initiative: 15 });
|
||||||
|
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
|
||||||
|
|
||||||
|
act(() => result.current.handleRollInitiative(combatantId("c1")));
|
||||||
|
|
||||||
|
expect(mockWithUndo).toHaveBeenCalled();
|
||||||
|
expect(mockRollInitiativeUseCase).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets rollSingleSkipped on domain error", () => {
|
||||||
|
mockRollInitiativeUseCase.mockReturnValue({
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "missing-source",
|
||||||
|
message: "no source",
|
||||||
|
});
|
||||||
|
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
|
||||||
|
|
||||||
|
act(() => result.current.handleRollInitiative(combatantId("c1")));
|
||||||
|
expect(result.current.rollSingleSkipped).toBe(true);
|
||||||
|
expect(mockShowCreature).toHaveBeenCalledWith("srd:goblin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dismissRollSingleSkipped resets the flag", () => {
|
||||||
|
mockRollInitiativeUseCase.mockReturnValue({
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "missing-source",
|
||||||
|
message: "no source",
|
||||||
|
});
|
||||||
|
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
|
||||||
|
|
||||||
|
act(() => result.current.handleRollInitiative(combatantId("c1")));
|
||||||
|
expect(result.current.rollSingleSkipped).toBe(true);
|
||||||
|
|
||||||
|
act(() => result.current.dismissRollSingleSkipped());
|
||||||
|
expect(result.current.rollSingleSkipped).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handleRollAllInitiative sets rollSkippedCount when sources missing", () => {
|
||||||
|
mockRollAllInitiativeUseCase.mockReturnValue({ skippedNoSource: 3 });
|
||||||
|
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
|
||||||
|
|
||||||
|
act(() => result.current.handleRollAllInitiative());
|
||||||
|
expect(result.current.rollSkippedCount).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dismissRollSkipped resets the count", () => {
|
||||||
|
mockRollAllInitiativeUseCase.mockReturnValue({ skippedNoSource: 2 });
|
||||||
|
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
|
||||||
|
|
||||||
|
act(() => result.current.handleRollAllInitiative());
|
||||||
|
act(() => result.current.dismissRollSkipped());
|
||||||
|
expect(result.current.rollSkippedCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
104
apps/web/src/hooks/__tests__/use-long-press.test.ts
Normal file
104
apps/web/src/hooks/__tests__/use-long-press.test.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useLongPress } from "../use-long-press.js";
|
||||||
|
|
||||||
|
function touchEvent(overrides?: Partial<React.TouchEvent>): React.TouchEvent {
|
||||||
|
return {
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
} as unknown as React.TouchEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useLongPress", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns onTouchStart, onTouchEnd, onTouchMove handlers", () => {
|
||||||
|
const { result } = renderHook(() => useLongPress(vi.fn()));
|
||||||
|
expect(result.current.onTouchStart).toBeInstanceOf(Function);
|
||||||
|
expect(result.current.onTouchEnd).toBeInstanceOf(Function);
|
||||||
|
expect(result.current.onTouchMove).toBeInstanceOf(Function);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fires onLongPress after 500ms hold", () => {
|
||||||
|
const onLongPress = vi.fn();
|
||||||
|
const { result } = renderHook(() => useLongPress(onLongPress));
|
||||||
|
|
||||||
|
const e = touchEvent();
|
||||||
|
act(() => result.current.onTouchStart(e));
|
||||||
|
expect(onLongPress).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
});
|
||||||
|
expect(onLongPress).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fire if released before 500ms", () => {
|
||||||
|
const onLongPress = vi.fn();
|
||||||
|
const { result } = renderHook(() => useLongPress(onLongPress));
|
||||||
|
|
||||||
|
act(() => result.current.onTouchStart(touchEvent()));
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
});
|
||||||
|
act(() => result.current.onTouchEnd(touchEvent()));
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onLongPress).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels on touch move", () => {
|
||||||
|
const onLongPress = vi.fn();
|
||||||
|
const { result } = renderHook(() => useLongPress(onLongPress));
|
||||||
|
|
||||||
|
act(() => result.current.onTouchStart(touchEvent()));
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(200);
|
||||||
|
});
|
||||||
|
act(() => result.current.onTouchMove());
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onLongPress).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("onTouchEnd calls preventDefault after long press fires", () => {
|
||||||
|
const onLongPress = vi.fn();
|
||||||
|
const { result } = renderHook(() => useLongPress(onLongPress));
|
||||||
|
|
||||||
|
act(() => result.current.onTouchStart(touchEvent()));
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
const preventDefaultSpy = vi.fn();
|
||||||
|
const endEvent = touchEvent({ preventDefault: preventDefaultSpy });
|
||||||
|
act(() => result.current.onTouchEnd(endEvent));
|
||||||
|
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("onTouchEnd does not preventDefault when long press did not fire", () => {
|
||||||
|
const onLongPress = vi.fn();
|
||||||
|
const { result } = renderHook(() => useLongPress(onLongPress));
|
||||||
|
|
||||||
|
act(() => result.current.onTouchStart(touchEvent()));
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
const preventDefaultSpy = vi.fn();
|
||||||
|
const endEvent = touchEvent({ preventDefault: preventDefaultSpy });
|
||||||
|
act(() => result.current.onTouchEnd(endEvent));
|
||||||
|
expect(preventDefaultSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -42,7 +42,14 @@ describe("usePlayerCharacters", () => {
|
|||||||
const { result } = renderHook(() => usePlayerCharacters());
|
const { result } = renderHook(() => usePlayerCharacters());
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
|
result.current.createCharacter(
|
||||||
|
"Vex",
|
||||||
|
15,
|
||||||
|
28,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.characters).toHaveLength(1);
|
expect(result.current.characters).toHaveLength(1);
|
||||||
@@ -57,7 +64,14 @@ describe("usePlayerCharacters", () => {
|
|||||||
|
|
||||||
let error: unknown;
|
let error: unknown;
|
||||||
act(() => {
|
act(() => {
|
||||||
error = result.current.createCharacter("", 15, 28, undefined, undefined);
|
error = result.current.createCharacter(
|
||||||
|
"",
|
||||||
|
15,
|
||||||
|
28,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(error).toMatchObject({ kind: "domain-error" });
|
expect(error).toMatchObject({ kind: "domain-error" });
|
||||||
@@ -68,7 +82,14 @@ describe("usePlayerCharacters", () => {
|
|||||||
const { result } = renderHook(() => usePlayerCharacters());
|
const { result } = renderHook(() => usePlayerCharacters());
|
||||||
|
|
||||||
act(() => {
|
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;
|
const id = result.current.characters[0].id;
|
||||||
@@ -85,7 +106,14 @@ describe("usePlayerCharacters", () => {
|
|||||||
const { result } = renderHook(() => usePlayerCharacters());
|
const { result } = renderHook(() => usePlayerCharacters());
|
||||||
|
|
||||||
act(() => {
|
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;
|
const id = result.current.characters[0].id;
|
||||||
|
|||||||
45
apps/web/src/hooks/__tests__/use-rules-edition.test.ts
Normal file
45
apps/web/src/hooks/__tests__/use-rules-edition.test.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { useRulesEdition } from "../use-rules-edition.js";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "initiative:rules-edition";
|
||||||
|
|
||||||
|
describe("useRulesEdition", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
// Reset to default
|
||||||
|
const { result } = renderHook(() => useRulesEdition());
|
||||||
|
act(() => result.current.setEdition("5.5e"));
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to 5.5e", () => {
|
||||||
|
const { result } = renderHook(() => useRulesEdition());
|
||||||
|
expect(result.current.edition).toBe("5.5e");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setEdition updates value", () => {
|
||||||
|
const { result } = renderHook(() => useRulesEdition());
|
||||||
|
|
||||||
|
act(() => result.current.setEdition("5e"));
|
||||||
|
|
||||||
|
expect(result.current.edition).toBe("5e");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setEdition persists to localStorage", () => {
|
||||||
|
const { result } = renderHook(() => useRulesEdition());
|
||||||
|
|
||||||
|
act(() => result.current.setEdition("5e"));
|
||||||
|
|
||||||
|
expect(localStorage.getItem(STORAGE_KEY)).toBe("5e");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("multiple hooks stay in sync", () => {
|
||||||
|
const { result: r1 } = renderHook(() => useRulesEdition());
|
||||||
|
const { result: r2 } = renderHook(() => useRulesEdition());
|
||||||
|
|
||||||
|
act(() => r1.current.setEdition("5e"));
|
||||||
|
|
||||||
|
expect(r2.current.edition).toBe("5e");
|
||||||
|
});
|
||||||
|
});
|
||||||
116
apps/web/src/hooks/__tests__/use-swipe-to-dismiss.test.ts
Normal file
116
apps/web/src/hooks/__tests__/use-swipe-to-dismiss.test.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useSwipeToDismiss } from "../use-swipe-to-dismiss.js";
|
||||||
|
|
||||||
|
const PANEL_WIDTH = 300;
|
||||||
|
|
||||||
|
function makeTouchEvent(clientX: number, clientY = 0): React.TouchEvent {
|
||||||
|
return {
|
||||||
|
touches: [{ clientX, clientY }],
|
||||||
|
currentTarget: {
|
||||||
|
getBoundingClientRect: () => ({ width: PANEL_WIDTH }),
|
||||||
|
},
|
||||||
|
} as unknown as React.TouchEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useSwipeToDismiss", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(Date, "now").mockReturnValue(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts with offsetX 0 and isSwiping false", () => {
|
||||||
|
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
|
||||||
|
expect(result.current.offsetX).toBe(0);
|
||||||
|
expect(result.current.isSwiping).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("horizontal drag updates offsetX and sets isSwiping", () => {
|
||||||
|
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
|
||||||
|
|
||||||
|
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
|
||||||
|
act(() => result.current.handlers.onTouchMove(makeTouchEvent(50)));
|
||||||
|
|
||||||
|
expect(result.current.offsetX).toBe(50);
|
||||||
|
expect(result.current.isSwiping).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("vertical drag is ignored after direction lock", () => {
|
||||||
|
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
|
||||||
|
|
||||||
|
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0, 0)));
|
||||||
|
// Move vertically > 10px to lock vertical
|
||||||
|
act(() => result.current.handlers.onTouchMove(makeTouchEvent(0, 20)));
|
||||||
|
|
||||||
|
expect(result.current.offsetX).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("small movement does not lock direction", () => {
|
||||||
|
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
|
||||||
|
|
||||||
|
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
|
||||||
|
act(() => result.current.handlers.onTouchMove(makeTouchEvent(5)));
|
||||||
|
|
||||||
|
// No direction locked yet, no update
|
||||||
|
expect(result.current.offsetX).toBe(0);
|
||||||
|
expect(result.current.isSwiping).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leftward drag is clamped to 0", () => {
|
||||||
|
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
|
||||||
|
|
||||||
|
act(() => result.current.handlers.onTouchStart(makeTouchEvent(100)));
|
||||||
|
act(() => result.current.handlers.onTouchMove(makeTouchEvent(50)));
|
||||||
|
|
||||||
|
expect(result.current.offsetX).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onDismiss when ratio exceeds threshold", () => {
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
const { result } = renderHook(() => useSwipeToDismiss(onDismiss));
|
||||||
|
|
||||||
|
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
|
||||||
|
// Move > 35% of panel width (300 * 0.35 = 105)
|
||||||
|
act(() => result.current.handlers.onTouchMove(makeTouchEvent(120)));
|
||||||
|
|
||||||
|
vi.spyOn(Date, "now").mockReturnValue(5000); // slow swipe
|
||||||
|
act(() => result.current.handlers.onTouchEnd());
|
||||||
|
|
||||||
|
expect(onDismiss).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onDismiss with fast velocity", () => {
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
const { result } = renderHook(() => useSwipeToDismiss(onDismiss));
|
||||||
|
|
||||||
|
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
|
||||||
|
// Small distance but fast
|
||||||
|
act(() => result.current.handlers.onTouchMove(makeTouchEvent(30)));
|
||||||
|
|
||||||
|
// Very fast: 30px in 0.1s = 300px/s, velocity = 300/300 = 1.0 > 0.5
|
||||||
|
vi.spyOn(Date, "now").mockReturnValue(100);
|
||||||
|
act(() => result.current.handlers.onTouchEnd());
|
||||||
|
|
||||||
|
expect(onDismiss).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not dismiss when below thresholds", () => {
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
const { result } = renderHook(() => useSwipeToDismiss(onDismiss));
|
||||||
|
|
||||||
|
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
|
||||||
|
// Small distance, slow speed
|
||||||
|
act(() => result.current.handlers.onTouchMove(makeTouchEvent(20)));
|
||||||
|
|
||||||
|
vi.spyOn(Date, "now").mockReturnValue(5000);
|
||||||
|
act(() => result.current.handlers.onTouchEnd());
|
||||||
|
|
||||||
|
expect(onDismiss).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.offsetX).toBe(0);
|
||||||
|
expect(result.current.isSwiping).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
63
apps/web/src/hooks/__tests__/use-theme.test.ts
Normal file
63
apps/web/src/hooks/__tests__/use-theme.test.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { useTheme } from "../use-theme.js";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "initiative:theme";
|
||||||
|
|
||||||
|
describe("useTheme", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
// Reset to default
|
||||||
|
const { result } = renderHook(() => useTheme());
|
||||||
|
act(() => result.current.setPreference("system"));
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to system preference", () => {
|
||||||
|
const { result } = renderHook(() => useTheme());
|
||||||
|
expect(result.current.preference).toBe("system");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setPreference updates to light", () => {
|
||||||
|
const { result } = renderHook(() => useTheme());
|
||||||
|
|
||||||
|
act(() => result.current.setPreference("light"));
|
||||||
|
|
||||||
|
expect(result.current.preference).toBe("light");
|
||||||
|
expect(result.current.resolved).toBe("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setPreference updates to dark", () => {
|
||||||
|
const { result } = renderHook(() => useTheme());
|
||||||
|
|
||||||
|
act(() => result.current.setPreference("dark"));
|
||||||
|
|
||||||
|
expect(result.current.preference).toBe("dark");
|
||||||
|
expect(result.current.resolved).toBe("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists preference to localStorage", () => {
|
||||||
|
const { result } = renderHook(() => useTheme());
|
||||||
|
|
||||||
|
act(() => result.current.setPreference("light"));
|
||||||
|
|
||||||
|
expect(localStorage.getItem(STORAGE_KEY)).toBe("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies theme to document element", () => {
|
||||||
|
const { result } = renderHook(() => useTheme());
|
||||||
|
|
||||||
|
act(() => result.current.setPreference("light"));
|
||||||
|
|
||||||
|
expect(document.documentElement.dataset.theme).toBe("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("multiple hooks stay in sync", () => {
|
||||||
|
const { result: r1 } = renderHook(() => useTheme());
|
||||||
|
const { result: r2 } = renderHook(() => useTheme());
|
||||||
|
|
||||||
|
act(() => r1.current.setPreference("dark"));
|
||||||
|
|
||||||
|
expect(r2.current.preference).toBe("dark");
|
||||||
|
});
|
||||||
|
});
|
||||||
323
apps/web/src/hooks/use-action-bar-state.ts
Normal file
323
apps/web/src/hooks/use-action-bar-state.ts
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import type { CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useDeferredValue,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
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,
|
||||||
|
lastCreatureId,
|
||||||
|
} = useEncounterContext();
|
||||||
|
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
|
||||||
|
useBestiaryContext();
|
||||||
|
const { characters: playerCharacters } = usePlayerCharactersContext();
|
||||||
|
const { showBulkImport, showSourceManager, showCreature, panelView } =
|
||||||
|
useSidePanelContext();
|
||||||
|
|
||||||
|
// Auto-show stat block when a bestiary creature is added on desktop
|
||||||
|
const prevCreatureIdRef = useRef(lastCreatureId);
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
lastCreatureId &&
|
||||||
|
lastCreatureId !== prevCreatureIdRef.current &&
|
||||||
|
panelView.mode === "closed" &&
|
||||||
|
globalThis.matchMedia("(min-width: 1024px)").matches
|
||||||
|
) {
|
||||||
|
showCreature(lastCreatureId);
|
||||||
|
}
|
||||||
|
prevCreatureIdRef.current = lastCreatureId;
|
||||||
|
}, [lastCreatureId, panelView.mode, showCreature]);
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
addFromBestiary(result);
|
||||||
|
},
|
||||||
|
[addFromBestiary],
|
||||||
|
);
|
||||||
|
|
||||||
|
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 {
|
||||||
|
addMultipleFromBestiary(queued.result, queued.count);
|
||||||
|
}
|
||||||
|
clearInput();
|
||||||
|
}, [queued, handleAddFromBestiary, addMultipleFromBestiary, 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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
|
|
||||||
@@ -8,10 +8,20 @@ export function useAutoStatBlock(): void {
|
|||||||
|
|
||||||
const activeCreatureId =
|
const activeCreatureId =
|
||||||
encounter.combatants[encounter.activeIndex]?.creatureId;
|
encounter.combatants[encounter.activeIndex]?.creatureId;
|
||||||
|
const prevActiveIndexRef = useRef(encounter.activeIndex);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeCreatureId && panelView.mode === "creature") {
|
const prevIndex = prevActiveIndexRef.current;
|
||||||
|
prevActiveIndexRef.current = encounter.activeIndex;
|
||||||
|
|
||||||
|
// Only auto-update when the active turn changes (advance/retreat),
|
||||||
|
// not when the panel mode changes (user clicking a different creature).
|
||||||
|
if (
|
||||||
|
encounter.activeIndex !== prevIndex &&
|
||||||
|
activeCreatureId &&
|
||||||
|
panelView.mode === "creature"
|
||||||
|
) {
|
||||||
updateCreature(activeCreatureId);
|
updateCreature(activeCreatureId);
|
||||||
}
|
}
|
||||||
}, [activeCreatureId, panelView.mode, updateCreature]);
|
}, [encounter.activeIndex, activeCreatureId, panelView.mode, updateCreature]);
|
||||||
}
|
}
|
||||||
|
|||||||
27
apps/web/src/hooks/use-click-outside.ts
Normal file
27
apps/web/src/hooks/use-click-outside.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { RefObject } from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export function useClickOutside(
|
||||||
|
ref: RefObject<HTMLElement | null>,
|
||||||
|
onClose: () => void,
|
||||||
|
active = true,
|
||||||
|
): void {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
|
||||||
|
function handleMouseDown(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleMouseDown);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleMouseDown);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [ref, onClose, active]);
|
||||||
|
}
|
||||||
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,38 +1,95 @@
|
|||||||
import type { EncounterStore } from "@initiative/application";
|
import type { EncounterStore, UndoRedoStore } from "@initiative/application";
|
||||||
import {
|
import {
|
||||||
addCombatantUseCase,
|
addCombatantUseCase,
|
||||||
adjustHpUseCase,
|
adjustHpUseCase,
|
||||||
advanceTurnUseCase,
|
advanceTurnUseCase,
|
||||||
clearEncounterUseCase,
|
clearEncounterUseCase,
|
||||||
editCombatantUseCase,
|
editCombatantUseCase,
|
||||||
|
redoUseCase,
|
||||||
removeCombatantUseCase,
|
removeCombatantUseCase,
|
||||||
retreatTurnUseCase,
|
retreatTurnUseCase,
|
||||||
setAcUseCase,
|
setAcUseCase,
|
||||||
setHpUseCase,
|
setHpUseCase,
|
||||||
setInitiativeUseCase,
|
setInitiativeUseCase,
|
||||||
|
setTempHpUseCase,
|
||||||
toggleConcentrationUseCase,
|
toggleConcentrationUseCase,
|
||||||
toggleConditionUseCase,
|
toggleConditionUseCase,
|
||||||
|
undoUseCase,
|
||||||
} from "@initiative/application";
|
} from "@initiative/application";
|
||||||
import type {
|
import type {
|
||||||
BestiaryIndexEntry,
|
BestiaryIndexEntry,
|
||||||
CombatantId,
|
CombatantId,
|
||||||
|
CombatantInit,
|
||||||
ConditionId,
|
ConditionId,
|
||||||
CreatureId,
|
CreatureId,
|
||||||
|
DomainError,
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
Encounter,
|
Encounter,
|
||||||
PlayerCharacter,
|
PlayerCharacter,
|
||||||
|
UndoRedoState,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import {
|
import {
|
||||||
|
clearHistory,
|
||||||
combatantId,
|
combatantId,
|
||||||
isDomainError,
|
isDomainError,
|
||||||
creatureId as makeCreatureId,
|
creatureId as makeCreatureId,
|
||||||
|
pushUndo,
|
||||||
resolveCreatureName,
|
resolveCreatureName,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useReducer, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
loadEncounter,
|
loadEncounter,
|
||||||
saveEncounter,
|
saveEncounter,
|
||||||
} from "../persistence/encounter-storage.js";
|
} from "../persistence/encounter-storage.js";
|
||||||
|
import {
|
||||||
|
loadUndoRedoStacks,
|
||||||
|
saveUndoRedoStacks,
|
||||||
|
} from "../persistence/undo-redo-storage.js";
|
||||||
|
|
||||||
|
// -- Types --
|
||||||
|
|
||||||
|
type EncounterAction =
|
||||||
|
| { type: "advance-turn" }
|
||||||
|
| { type: "retreat-turn" }
|
||||||
|
| { type: "add-combatant"; name: string; init?: CombatantInit }
|
||||||
|
| { type: "remove-combatant"; id: CombatantId }
|
||||||
|
| { type: "edit-combatant"; id: CombatantId; newName: string }
|
||||||
|
| { type: "set-initiative"; id: CombatantId; value: number | undefined }
|
||||||
|
| { type: "set-hp"; id: CombatantId; maxHp: number | undefined }
|
||||||
|
| { type: "adjust-hp"; id: CombatantId; delta: number }
|
||||||
|
| { type: "set-temp-hp"; id: CombatantId; tempHp: number | undefined }
|
||||||
|
| { type: "set-ac"; id: CombatantId; value: number | undefined }
|
||||||
|
| {
|
||||||
|
type: "toggle-condition";
|
||||||
|
id: CombatantId;
|
||||||
|
conditionId: ConditionId;
|
||||||
|
}
|
||||||
|
| { type: "toggle-concentration"; id: CombatantId }
|
||||||
|
| { type: "clear-encounter" }
|
||||||
|
| { type: "undo" }
|
||||||
|
| { type: "redo" }
|
||||||
|
| { type: "add-from-bestiary"; entry: BestiaryIndexEntry }
|
||||||
|
| {
|
||||||
|
type: "add-multiple-from-bestiary";
|
||||||
|
entry: BestiaryIndexEntry;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
| { type: "add-from-player-character"; pc: PlayerCharacter }
|
||||||
|
| {
|
||||||
|
type: "import";
|
||||||
|
encounter: Encounter;
|
||||||
|
undoRedoState: UndoRedoState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface EncounterState {
|
||||||
|
readonly encounter: Encounter;
|
||||||
|
readonly undoRedoState: UndoRedoState;
|
||||||
|
readonly events: readonly DomainEvent[];
|
||||||
|
readonly nextId: number;
|
||||||
|
readonly lastCreatureId: CreatureId | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Initialization --
|
||||||
|
|
||||||
const COMBATANT_ID_REGEX = /^c-(\d+)$/;
|
const COMBATANT_ID_REGEX = /^c-(\d+)$/;
|
||||||
|
|
||||||
@@ -42,12 +99,6 @@ const EMPTY_ENCOUNTER: Encounter = {
|
|||||||
roundNumber: 1,
|
roundNumber: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
function initializeEncounter(): Encounter {
|
|
||||||
const stored = loadEncounter();
|
|
||||||
if (stored !== null) return stored;
|
|
||||||
return EMPTY_ENCOUNTER;
|
|
||||||
}
|
|
||||||
|
|
||||||
function deriveNextId(encounter: Encounter): number {
|
function deriveNextId(encounter: Encounter): number {
|
||||||
let max = 0;
|
let max = 0;
|
||||||
for (const c of encounter.combatants) {
|
for (const c of encounter.combatants) {
|
||||||
@@ -60,322 +111,332 @@ function deriveNextId(encounter: Encounter): number {
|
|||||||
return max;
|
return max;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CombatantOpts {
|
function initializeState(): EncounterState {
|
||||||
initiative?: number;
|
const encounter = loadEncounter() ?? EMPTY_ENCOUNTER;
|
||||||
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 encounterRef = useRef(encounter);
|
|
||||||
encounterRef.current = encounter;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
saveEncounter(encounter);
|
|
||||||
}, [encounter]);
|
|
||||||
|
|
||||||
const makeStore = useCallback((): EncounterStore => {
|
|
||||||
return {
|
return {
|
||||||
get: () => encounterRef.current,
|
encounter,
|
||||||
save: (e) => {
|
undoRedoState: loadUndoRedoStacks(),
|
||||||
encounterRef.current = e;
|
events: [],
|
||||||
setEncounter(e);
|
nextId: deriveNextId(encounter),
|
||||||
},
|
lastCreatureId: null,
|
||||||
};
|
};
|
||||||
}, []);
|
}
|
||||||
|
|
||||||
const advanceTurn = useCallback(() => {
|
// -- Helpers --
|
||||||
const result = advanceTurnUseCase(makeStore());
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
function makeStoreFromState(state: EncounterState): {
|
||||||
return;
|
store: EncounterStore;
|
||||||
}
|
getEncounter: () => Encounter;
|
||||||
|
} {
|
||||||
setEvents((prev) => [...prev, ...result]);
|
let current = state.encounter;
|
||||||
}, [makeStore]);
|
return {
|
||||||
|
store: {
|
||||||
const retreatTurn = useCallback(() => {
|
get: () => current,
|
||||||
const result = retreatTurnUseCase(makeStore());
|
save: (e) => {
|
||||||
|
current = e;
|
||||||
if (isDomainError(result)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
|
||||||
}, [makeStore]);
|
|
||||||
|
|
||||||
const nextId = useRef(deriveNextId(encounter));
|
|
||||||
|
|
||||||
const addCombatant = useCallback(
|
|
||||||
(name: string, opts?: CombatantOpts) => {
|
|
||||||
const id = combatantId(`c-${++nextId.current}`);
|
|
||||||
const result = addCombatantUseCase(makeStore(), id, name);
|
|
||||||
|
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeCombatant = useCallback(
|
|
||||||
(id: CombatantId) => {
|
|
||||||
const result = removeCombatantUseCase(makeStore(), id);
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
|
||||||
},
|
},
|
||||||
[makeStore],
|
getEncounter: () => current,
|
||||||
);
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const editCombatant = useCallback(
|
function resolveAndRename(store: EncounterStore, name: string): string {
|
||||||
(id: CombatantId, newName: string) => {
|
|
||||||
const result = editCombatantUseCase(makeStore(), id, newName);
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
|
||||||
},
|
|
||||||
[makeStore],
|
|
||||||
);
|
|
||||||
|
|
||||||
const setInitiative = useCallback(
|
|
||||||
(id: CombatantId, value: number | undefined) => {
|
|
||||||
const result = setInitiativeUseCase(makeStore(), id, value);
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
|
||||||
},
|
|
||||||
[makeStore],
|
|
||||||
);
|
|
||||||
|
|
||||||
const setHp = useCallback(
|
|
||||||
(id: CombatantId, maxHp: number | undefined) => {
|
|
||||||
const result = setHpUseCase(makeStore(), id, maxHp);
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
|
||||||
},
|
|
||||||
[makeStore],
|
|
||||||
);
|
|
||||||
|
|
||||||
const adjustHp = useCallback(
|
|
||||||
(id: CombatantId, delta: number) => {
|
|
||||||
const result = adjustHpUseCase(makeStore(), id, delta);
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
|
||||||
},
|
|
||||||
[makeStore],
|
|
||||||
);
|
|
||||||
|
|
||||||
const setAc = useCallback(
|
|
||||||
(id: CombatantId, value: number | undefined) => {
|
|
||||||
const result = setAcUseCase(makeStore(), id, value);
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
|
||||||
},
|
|
||||||
[makeStore],
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleCondition = useCallback(
|
|
||||||
(id: CombatantId, conditionId: ConditionId) => {
|
|
||||||
const result = toggleConditionUseCase(makeStore(), id, conditionId);
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
|
||||||
},
|
|
||||||
[makeStore],
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleConcentration = useCallback(
|
|
||||||
(id: CombatantId) => {
|
|
||||||
const result = toggleConcentrationUseCase(makeStore(), id);
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
|
||||||
},
|
|
||||||
[makeStore],
|
|
||||||
);
|
|
||||||
|
|
||||||
const clearEncounter = useCallback(() => {
|
|
||||||
const result = clearEncounterUseCase(makeStore());
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
nextId.current = 0;
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
|
||||||
}, [makeStore]);
|
|
||||||
|
|
||||||
const addFromBestiary = useCallback(
|
|
||||||
(entry: BestiaryIndexEntry): CreatureId | null => {
|
|
||||||
const store = makeStore();
|
|
||||||
const existingNames = store.get().combatants.map((c) => c.name);
|
const existingNames = store.get().combatants.map((c) => c.name);
|
||||||
const { newName, renames } = resolveCreatureName(
|
const { newName, renames } = resolveCreatureName(name, existingNames);
|
||||||
entry.name,
|
|
||||||
existingNames,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Apply renames (e.g., "Goblin" → "Goblin 1")
|
|
||||||
for (const { from, to } of renames) {
|
for (const { from, to } of renames) {
|
||||||
const target = store.get().combatants.find((c) => c.name === from);
|
const target = store.get().combatants.find((c) => c.name === from);
|
||||||
if (target) {
|
if (target) {
|
||||||
editCombatantUseCase(makeStore(), target.id, to);
|
editCombatantUseCase(store, target.id, to);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add combatant with resolved name
|
return newName;
|
||||||
const id = combatantId(`c-${++nextId.current}`);
|
}
|
||||||
const addResult = addCombatantUseCase(makeStore(), id, newName);
|
|
||||||
if (isDomainError(addResult)) return null;
|
|
||||||
|
|
||||||
// Set HP
|
function addOneFromBestiary(
|
||||||
const hpResult = setHpUseCase(makeStore(), id, entry.hp);
|
store: EncounterStore,
|
||||||
if (!isDomainError(hpResult)) {
|
entry: BestiaryIndexEntry,
|
||||||
setEvents((prev) => [...prev, ...hpResult]);
|
nextId: number,
|
||||||
}
|
): {
|
||||||
|
cId: CreatureId;
|
||||||
|
events: DomainEvent[];
|
||||||
|
nextId: number;
|
||||||
|
} | null {
|
||||||
|
const newName = resolveAndRename(store, entry.name);
|
||||||
|
|
||||||
// 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
|
const slug = entry.name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||||
.replaceAll(/(^-|-$)/g, "");
|
.replaceAll(/(^-|-$)/g, "");
|
||||||
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
|
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
|
||||||
|
|
||||||
// Set creatureId on the combatant (use store.save to keep ref in sync for batch calls)
|
const id = combatantId(`c-${nextId + 1}`);
|
||||||
const currentEncounter = store.get();
|
const result = addCombatantUseCase(store, id, newName, {
|
||||||
store.save({
|
maxHp: entry.hp > 0 ? entry.hp : undefined,
|
||||||
...currentEncounter,
|
ac: entry.ac > 0 ? entry.ac : undefined,
|
||||||
combatants: currentEncounter.combatants.map((c) =>
|
creatureId: cId,
|
||||||
c.id === id ? { ...c, creatureId: cId } : c,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...addResult]);
|
if (isDomainError(result)) return null;
|
||||||
|
|
||||||
return cId;
|
return { cId, events: result, nextId: nextId + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Reducer case handlers --
|
||||||
|
|
||||||
|
function handleUndoRedo(
|
||||||
|
state: EncounterState,
|
||||||
|
direction: "undo" | "redo",
|
||||||
|
): EncounterState {
|
||||||
|
const { store, getEncounter } = makeStoreFromState(state);
|
||||||
|
const undoRedoStore: UndoRedoStore = {
|
||||||
|
get: () => state.undoRedoState,
|
||||||
|
save: () => {},
|
||||||
|
};
|
||||||
|
const applyFn = direction === "undo" ? undoUseCase : redoUseCase;
|
||||||
|
const result = applyFn(store, undoRedoStore);
|
||||||
|
if (isDomainError(result)) return state;
|
||||||
|
|
||||||
|
const isUndo = direction === "undo";
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
encounter: getEncounter(),
|
||||||
|
undoRedoState: {
|
||||||
|
undoStack: isUndo
|
||||||
|
? state.undoRedoState.undoStack.slice(0, -1)
|
||||||
|
: [...state.undoRedoState.undoStack, state.encounter],
|
||||||
|
redoStack: isUndo
|
||||||
|
? [...state.undoRedoState.redoStack, state.encounter]
|
||||||
|
: state.undoRedoState.redoStack.slice(0, -1),
|
||||||
},
|
},
|
||||||
[makeStore],
|
};
|
||||||
);
|
}
|
||||||
|
|
||||||
const addFromPlayerCharacter = useCallback(
|
function handleAddFromBestiary(
|
||||||
(pc: PlayerCharacter) => {
|
state: EncounterState,
|
||||||
const store = makeStore();
|
entry: BestiaryIndexEntry,
|
||||||
const existingNames = store.get().combatants.map((c) => c.name);
|
count: number,
|
||||||
const { newName, renames } = resolveCreatureName(pc.name, existingNames);
|
): EncounterState {
|
||||||
|
const { store, getEncounter } = makeStoreFromState(state);
|
||||||
|
const allEvents: DomainEvent[] = [];
|
||||||
|
let nextId = state.nextId;
|
||||||
|
let lastCId: CreatureId | null = null;
|
||||||
|
|
||||||
for (const { from, to } of renames) {
|
for (let i = 0; i < count; i++) {
|
||||||
const target = store.get().combatants.find((c) => c.name === from);
|
const added = addOneFromBestiary(store, entry, nextId);
|
||||||
if (target) {
|
if (!added) return state;
|
||||||
editCombatantUseCase(makeStore(), target.id, to);
|
allEvents.push(...added.events);
|
||||||
}
|
nextId = added.nextId;
|
||||||
|
lastCId = added.cId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = combatantId(`c-${++nextId.current}`);
|
return {
|
||||||
const addResult = addCombatantUseCase(makeStore(), id, newName);
|
...state,
|
||||||
if (isDomainError(addResult)) return;
|
encounter: getEncounter(),
|
||||||
|
undoRedoState: pushUndo(state.undoRedoState, state.encounter),
|
||||||
|
events: [...state.events, ...allEvents],
|
||||||
|
nextId,
|
||||||
|
lastCreatureId: lastCId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Set HP
|
function handleAddFromPlayerCharacter(
|
||||||
const hpResult = setHpUseCase(makeStore(), id, pc.maxHp);
|
state: EncounterState,
|
||||||
if (!isDomainError(hpResult)) {
|
pc: PlayerCharacter,
|
||||||
setEvents((prev) => [...prev, ...hpResult]);
|
): EncounterState {
|
||||||
}
|
const { store, getEncounter } = makeStoreFromState(state);
|
||||||
|
const newName = resolveAndRename(store, pc.name);
|
||||||
// Set AC
|
const id = combatantId(`c-${state.nextId + 1}`);
|
||||||
if (pc.ac > 0) {
|
const result = addCombatantUseCase(store, id, newName, {
|
||||||
const acResult = setAcUseCase(makeStore(), id, pc.ac);
|
maxHp: pc.maxHp,
|
||||||
if (!isDomainError(acResult)) {
|
ac: pc.ac > 0 ? pc.ac : undefined,
|
||||||
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,
|
color: pc.color,
|
||||||
icon: pc.icon,
|
icon: pc.icon,
|
||||||
playerCharacterId: pc.id,
|
playerCharacterId: pc.id,
|
||||||
}
|
|
||||||
: c,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
if (isDomainError(result)) return state;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
encounter: getEncounter(),
|
||||||
|
undoRedoState: pushUndo(state.undoRedoState, state.encounter),
|
||||||
|
events: [...state.events, ...result],
|
||||||
|
nextId: state.nextId + 1,
|
||||||
|
lastCreatureId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...addResult]);
|
// -- Reducer --
|
||||||
|
|
||||||
|
export function encounterReducer(
|
||||||
|
state: EncounterState,
|
||||||
|
action: EncounterAction,
|
||||||
|
): EncounterState {
|
||||||
|
switch (action.type) {
|
||||||
|
case "import":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
encounter: action.encounter,
|
||||||
|
undoRedoState: action.undoRedoState,
|
||||||
|
nextId: deriveNextId(action.encounter),
|
||||||
|
lastCreatureId: null,
|
||||||
|
};
|
||||||
|
case "undo":
|
||||||
|
case "redo":
|
||||||
|
return handleUndoRedo(state, action.type);
|
||||||
|
case "clear-encounter": {
|
||||||
|
const { store, getEncounter } = makeStoreFromState(state);
|
||||||
|
const result = clearEncounterUseCase(store);
|
||||||
|
if (isDomainError(result)) return state;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
encounter: getEncounter(),
|
||||||
|
undoRedoState: clearHistory(),
|
||||||
|
events: [...state.events, ...result],
|
||||||
|
nextId: 0,
|
||||||
|
lastCreatureId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "add-from-bestiary":
|
||||||
|
return handleAddFromBestiary(state, action.entry, 1);
|
||||||
|
case "add-multiple-from-bestiary":
|
||||||
|
return handleAddFromBestiary(state, action.entry, action.count);
|
||||||
|
case "add-from-player-character":
|
||||||
|
return handleAddFromPlayerCharacter(state, action.pc);
|
||||||
|
default:
|
||||||
|
return dispatchEncounterAction(state, action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchEncounterAction(
|
||||||
|
state: EncounterState,
|
||||||
|
action: Extract<
|
||||||
|
EncounterAction,
|
||||||
|
| { type: "advance-turn" }
|
||||||
|
| { type: "retreat-turn" }
|
||||||
|
| { type: "add-combatant" }
|
||||||
|
| { type: "remove-combatant" }
|
||||||
|
| { type: "edit-combatant" }
|
||||||
|
| { type: "set-initiative" }
|
||||||
|
| { type: "set-hp" }
|
||||||
|
| { type: "adjust-hp" }
|
||||||
|
| { type: "set-temp-hp" }
|
||||||
|
| { type: "set-ac" }
|
||||||
|
| { type: "toggle-condition" }
|
||||||
|
| { type: "toggle-concentration" }
|
||||||
|
>,
|
||||||
|
): EncounterState {
|
||||||
|
const { store, getEncounter } = makeStoreFromState(state);
|
||||||
|
let result: DomainEvent[] | DomainError;
|
||||||
|
|
||||||
|
switch (action.type) {
|
||||||
|
case "advance-turn":
|
||||||
|
result = advanceTurnUseCase(store);
|
||||||
|
break;
|
||||||
|
case "retreat-turn":
|
||||||
|
result = retreatTurnUseCase(store);
|
||||||
|
break;
|
||||||
|
case "add-combatant": {
|
||||||
|
const id = combatantId(`c-${state.nextId + 1}`);
|
||||||
|
result = addCombatantUseCase(store, id, action.name, action.init);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "remove-combatant":
|
||||||
|
result = removeCombatantUseCase(store, action.id);
|
||||||
|
break;
|
||||||
|
case "edit-combatant":
|
||||||
|
result = editCombatantUseCase(store, action.id, action.newName);
|
||||||
|
break;
|
||||||
|
case "set-initiative":
|
||||||
|
result = setInitiativeUseCase(store, action.id, action.value);
|
||||||
|
break;
|
||||||
|
case "set-hp":
|
||||||
|
result = setHpUseCase(store, action.id, action.maxHp);
|
||||||
|
break;
|
||||||
|
case "adjust-hp":
|
||||||
|
result = adjustHpUseCase(store, action.id, action.delta);
|
||||||
|
break;
|
||||||
|
case "set-temp-hp":
|
||||||
|
result = setTempHpUseCase(store, action.id, action.tempHp);
|
||||||
|
break;
|
||||||
|
case "set-ac":
|
||||||
|
result = setAcUseCase(store, action.id, action.value);
|
||||||
|
break;
|
||||||
|
case "toggle-condition":
|
||||||
|
result = toggleConditionUseCase(store, action.id, action.conditionId);
|
||||||
|
break;
|
||||||
|
case "toggle-concentration":
|
||||||
|
result = toggleConcentrationUseCase(store, action.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDomainError(result)) return state;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
encounter: getEncounter(),
|
||||||
|
undoRedoState: pushUndo(state.undoRedoState, state.encounter),
|
||||||
|
events: [...state.events, ...result],
|
||||||
|
nextId: action.type === "add-combatant" ? state.nextId + 1 : state.nextId,
|
||||||
|
lastCreatureId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Hook --
|
||||||
|
|
||||||
|
export function useEncounter() {
|
||||||
|
const [state, dispatch] = useReducer(encounterReducer, null, initializeState);
|
||||||
|
const { encounter, undoRedoState, events } = state;
|
||||||
|
|
||||||
|
const encounterRef = useRef(encounter);
|
||||||
|
encounterRef.current = encounter;
|
||||||
|
const undoRedoRef = useRef(undoRedoState);
|
||||||
|
undoRedoRef.current = undoRedoState;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
saveEncounter(encounter);
|
||||||
|
}, [encounter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
saveUndoRedoStacks(undoRedoState);
|
||||||
|
}, [undoRedoState]);
|
||||||
|
|
||||||
|
// Escape hatches for useInitiativeRolls (needs raw port access)
|
||||||
|
const makeStore = useCallback((): EncounterStore => {
|
||||||
|
return {
|
||||||
|
get: () => encounterRef.current,
|
||||||
|
save: (e) => {
|
||||||
|
encounterRef.current = e;
|
||||||
|
dispatch({
|
||||||
|
type: "import",
|
||||||
|
encounter: e,
|
||||||
|
undoRedoState: undoRedoRef.current,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[makeStore],
|
};
|
||||||
);
|
}, []);
|
||||||
|
|
||||||
|
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;
|
||||||
|
dispatch({
|
||||||
|
type: "import",
|
||||||
|
encounter: encounterRef.current,
|
||||||
|
undoRedoState: newState,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Derived state
|
||||||
|
const canUndo = undoRedoState.undoStack.length > 0;
|
||||||
|
const canRedo = undoRedoState.redoStack.length > 0;
|
||||||
|
const hasTempHp = encounter.combatants.some(
|
||||||
|
(c) => c.tempHp !== undefined && c.tempHp > 0,
|
||||||
|
);
|
||||||
const isEmpty = encounter.combatants.length === 0;
|
const isEmpty = encounter.combatants.length === 0;
|
||||||
const hasCreatureCombatants = encounter.combatants.some(
|
const hasCreatureCombatants = encounter.combatants.some(
|
||||||
(c) => c.creatureId != null,
|
(c) => c.creatureId != null,
|
||||||
@@ -386,24 +447,113 @@ export function useEncounter() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
encounter,
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
events,
|
events,
|
||||||
isEmpty,
|
isEmpty,
|
||||||
|
hasTempHp,
|
||||||
hasCreatureCombatants,
|
hasCreatureCombatants,
|
||||||
canRollAllInitiative,
|
canRollAllInitiative,
|
||||||
advanceTurn,
|
canUndo,
|
||||||
retreatTurn,
|
canRedo,
|
||||||
addCombatant,
|
advanceTurn: useCallback(() => dispatch({ type: "advance-turn" }), []),
|
||||||
clearEncounter,
|
retreatTurn: useCallback(() => dispatch({ type: "retreat-turn" }), []),
|
||||||
removeCombatant,
|
addCombatant: useCallback(
|
||||||
editCombatant,
|
(name: string, init?: CombatantInit) =>
|
||||||
setInitiative,
|
dispatch({ type: "add-combatant", name, init }),
|
||||||
setHp,
|
[],
|
||||||
adjustHp,
|
),
|
||||||
setAc,
|
removeCombatant: useCallback(
|
||||||
toggleCondition,
|
(id: CombatantId) => dispatch({ type: "remove-combatant", id }),
|
||||||
toggleConcentration,
|
[],
|
||||||
addFromBestiary,
|
),
|
||||||
addFromPlayerCharacter,
|
editCombatant: useCallback(
|
||||||
|
(id: CombatantId, newName: string) =>
|
||||||
|
dispatch({ type: "edit-combatant", id, newName }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
setInitiative: useCallback(
|
||||||
|
(id: CombatantId, value: number | undefined) =>
|
||||||
|
dispatch({ type: "set-initiative", id, value }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
setHp: useCallback(
|
||||||
|
(id: CombatantId, maxHp: number | undefined) =>
|
||||||
|
dispatch({ type: "set-hp", id, maxHp }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
adjustHp: useCallback(
|
||||||
|
(id: CombatantId, delta: number) =>
|
||||||
|
dispatch({ type: "adjust-hp", id, delta }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
setTempHp: useCallback(
|
||||||
|
(id: CombatantId, tempHp: number | undefined) =>
|
||||||
|
dispatch({ type: "set-temp-hp", id, tempHp }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
setAc: useCallback(
|
||||||
|
(id: CombatantId, value: number | undefined) =>
|
||||||
|
dispatch({ type: "set-ac", id, value }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
toggleCondition: useCallback(
|
||||||
|
(id: CombatantId, conditionId: ConditionId) =>
|
||||||
|
dispatch({ type: "toggle-condition", id, conditionId }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
toggleConcentration: useCallback(
|
||||||
|
(id: CombatantId) => dispatch({ type: "toggle-concentration", id }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
clearEncounter: useCallback(
|
||||||
|
() => dispatch({ type: "clear-encounter" }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
addFromBestiary: useCallback(
|
||||||
|
(entry: BestiaryIndexEntry): CreatureId | null => {
|
||||||
|
dispatch({ type: "add-from-bestiary", entry });
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
addMultipleFromBestiary: useCallback(
|
||||||
|
(entry: BestiaryIndexEntry, count: number): CreatureId | null => {
|
||||||
|
dispatch({
|
||||||
|
type: "add-multiple-from-bestiary",
|
||||||
|
entry,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
addFromPlayerCharacter: useCallback(
|
||||||
|
(pc: PlayerCharacter) =>
|
||||||
|
dispatch({ type: "add-from-player-character", pc }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
undo: useCallback(() => dispatch({ type: "undo" }), []),
|
||||||
|
redo: useCallback(() => dispatch({ type: "redo" }), []),
|
||||||
|
setEncounter: useCallback(
|
||||||
|
(enc: Encounter) =>
|
||||||
|
dispatch({
|
||||||
|
type: "import",
|
||||||
|
encounter: enc,
|
||||||
|
undoRedoState: undoRedoRef.current,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
setUndoRedoState: useCallback(
|
||||||
|
(urs: UndoRedoState) =>
|
||||||
|
dispatch({
|
||||||
|
type: "import",
|
||||||
|
encounter: encounterRef.current,
|
||||||
|
undoRedoState: urs,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
),
|
||||||
makeStore,
|
makeStore,
|
||||||
|
withUndo,
|
||||||
|
lastCreatureId: state.lastCreatureId,
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function rollDice(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useInitiativeRolls() {
|
export function useInitiativeRolls() {
|
||||||
const { encounter, makeStore } = useEncounterContext();
|
const { encounter, makeStore, withUndo } = useEncounterContext();
|
||||||
const { getCreature } = useBestiaryContext();
|
const { getCreature } = useBestiaryContext();
|
||||||
const { showCreature } = useSidePanelContext();
|
const { showCreature } = useSidePanelContext();
|
||||||
|
|
||||||
@@ -28,12 +28,8 @@ export function useInitiativeRolls() {
|
|||||||
(id: CombatantId, mode: RollMode = "normal") => {
|
(id: CombatantId, mode: RollMode = "normal") => {
|
||||||
const diceRolls: [number, ...number[]] =
|
const diceRolls: [number, ...number[]] =
|
||||||
mode === "normal" ? [rollDice()] : [rollDice(), rollDice()];
|
mode === "normal" ? [rollDice()] : [rollDice(), rollDice()];
|
||||||
const result = rollInitiativeUseCase(
|
const result = withUndo(() =>
|
||||||
makeStore(),
|
rollInitiativeUseCase(makeStore(), id, diceRolls, getCreature, mode),
|
||||||
id,
|
|
||||||
diceRolls,
|
|
||||||
getCreature,
|
|
||||||
mode,
|
|
||||||
);
|
);
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
setRollSingleSkipped(true);
|
setRollSingleSkipped(true);
|
||||||
@@ -43,22 +39,19 @@ export function useInitiativeRolls() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[makeStore, getCreature, encounter.combatants, showCreature],
|
[makeStore, getCreature, withUndo, encounter.combatants, showCreature],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRollAllInitiative = useCallback(
|
const handleRollAllInitiative = useCallback(
|
||||||
(mode: RollMode = "normal") => {
|
(mode: RollMode = "normal") => {
|
||||||
const result = rollAllInitiativeUseCase(
|
const result = withUndo(() =>
|
||||||
makeStore(),
|
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature, mode),
|
||||||
rollDice,
|
|
||||||
getCreature,
|
|
||||||
mode,
|
|
||||||
);
|
);
|
||||||
if (!isDomainError(result) && result.skippedNoSource > 0) {
|
if (!isDomainError(result) && result.skippedNoSource > 0) {
|
||||||
setRollSkippedCount(result.skippedNoSource);
|
setRollSkippedCount(result.skippedNoSource);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[makeStore, getCreature],
|
[makeStore, getCreature, withUndo],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ interface EditFields {
|
|||||||
readonly maxHp?: number;
|
readonly maxHp?: number;
|
||||||
readonly color?: string | null;
|
readonly color?: string | null;
|
||||||
readonly icon?: string | null;
|
readonly icon?: string | null;
|
||||||
|
readonly level?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePlayerCharacters() {
|
export function usePlayerCharacters() {
|
||||||
@@ -57,6 +58,7 @@ export function usePlayerCharacters() {
|
|||||||
maxHp: number,
|
maxHp: number,
|
||||||
color: string | undefined,
|
color: string | undefined,
|
||||||
icon: string | undefined,
|
icon: string | undefined,
|
||||||
|
level: number | undefined,
|
||||||
) => {
|
) => {
|
||||||
const id = generatePcId();
|
const id = generatePcId();
|
||||||
const result = createPlayerCharacterUseCase(
|
const result = createPlayerCharacterUseCase(
|
||||||
@@ -67,6 +69,7 @@ export function usePlayerCharacters() {
|
|||||||
maxHp,
|
maxHp,
|
||||||
color,
|
color,
|
||||||
icon,
|
icon,
|
||||||
|
level,
|
||||||
);
|
);
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
return result;
|
return result;
|
||||||
@@ -103,6 +106,7 @@ export function usePlayerCharacters() {
|
|||||||
createCharacter,
|
createCharacter,
|
||||||
editCharacter,
|
editCharacter,
|
||||||
deleteCharacter,
|
deleteCharacter,
|
||||||
|
replacePlayerCharacters: setCharacters,
|
||||||
makeStore,
|
makeStore,
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|||||||
52
apps/web/src/hooks/use-rules-edition.ts
Normal file
52
apps/web/src/hooks/use-rules-edition.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { RulesEdition } from "@initiative/domain";
|
||||||
|
import { useCallback, useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "initiative:rules-edition";
|
||||||
|
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
let currentEdition: RulesEdition = loadEdition();
|
||||||
|
|
||||||
|
function loadEdition(): RulesEdition {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw === "5e" || raw === "5.5e") return raw;
|
||||||
|
} catch {
|
||||||
|
// storage unavailable
|
||||||
|
}
|
||||||
|
return "5.5e";
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveEdition(edition: RulesEdition): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, edition);
|
||||||
|
} catch {
|
||||||
|
// quota exceeded or storage unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyAll(): void {
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(callback: () => void): () => void {
|
||||||
|
listeners.add(callback);
|
||||||
|
return () => listeners.delete(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshot(): RulesEdition {
|
||||||
|
return currentEdition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRulesEdition() {
|
||||||
|
const edition = useSyncExternalStore(subscribe, getSnapshot);
|
||||||
|
|
||||||
|
const setEdition = useCallback((next: RulesEdition) => {
|
||||||
|
currentEdition = next;
|
||||||
|
saveEdition(next);
|
||||||
|
notifyAll();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { edition, setEdition } as const;
|
||||||
|
}
|
||||||
@@ -39,6 +39,9 @@ function resolve(pref: ThemePreference): ResolvedTheme {
|
|||||||
|
|
||||||
function applyTheme(resolved: ResolvedTheme): void {
|
function applyTheme(resolved: ResolvedTheme): void {
|
||||||
document.documentElement.dataset.theme = resolved;
|
document.documentElement.dataset.theme = resolved;
|
||||||
|
document
|
||||||
|
.querySelector('meta[name="theme-color"]')
|
||||||
|
?.setAttribute("content", resolved === "light" ? "#eeecea" : "#0e1a2e");
|
||||||
}
|
}
|
||||||
|
|
||||||
function notifyAll(): void {
|
function notifyAll(): void {
|
||||||
@@ -71,8 +74,6 @@ function getSnapshot(): ThemePreference {
|
|||||||
return currentPreference;
|
return currentPreference;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CYCLE: ThemePreference[] = ["system", "light", "dark"];
|
|
||||||
|
|
||||||
export function useTheme() {
|
export function useTheme() {
|
||||||
const preference = useSyncExternalStore(subscribe, getSnapshot);
|
const preference = useSyncExternalStore(subscribe, getSnapshot);
|
||||||
const resolved = resolve(preference);
|
const resolved = resolve(preference);
|
||||||
@@ -88,11 +89,5 @@ export function useTheme() {
|
|||||||
notifyAll();
|
notifyAll();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const cycleTheme = useCallback(() => {
|
return { preference, resolved, setPreference } as const;
|
||||||
const idx = CYCLE.indexOf(currentPreference);
|
|
||||||
const next = CYCLE[(idx + 1) % CYCLE.length];
|
|
||||||
setPreference(next);
|
|
||||||
}, [setPreference]);
|
|
||||||
|
|
||||||
return { preference, resolved, setPreference, cycleTheme } 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]);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
EncounterProvider,
|
EncounterProvider,
|
||||||
InitiativeRollsProvider,
|
InitiativeRollsProvider,
|
||||||
PlayerCharactersProvider,
|
PlayerCharactersProvider,
|
||||||
|
RulesEditionProvider,
|
||||||
SidePanelProvider,
|
SidePanelProvider,
|
||||||
ThemeProvider,
|
ThemeProvider,
|
||||||
} from "./contexts/index.js";
|
} from "./contexts/index.js";
|
||||||
@@ -17,6 +18,7 @@ if (root) {
|
|||||||
createRoot(root).render(
|
createRoot(root).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
|
<RulesEditionProvider>
|
||||||
<EncounterProvider>
|
<EncounterProvider>
|
||||||
<BestiaryProvider>
|
<BestiaryProvider>
|
||||||
<PlayerCharactersProvider>
|
<PlayerCharactersProvider>
|
||||||
@@ -30,6 +32,7 @@ if (root) {
|
|||||||
</PlayerCharactersProvider>
|
</PlayerCharactersProvider>
|
||||||
</BestiaryProvider>
|
</BestiaryProvider>
|
||||||
</EncounterProvider>
|
</EncounterProvider>
|
||||||
|
</RulesEditionProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -122,64 +122,7 @@ describe("loadEncounter", () => {
|
|||||||
expect(loadEncounter()).toBeNull();
|
expect(loadEncounter()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
// US3: Corrupt data scenarios
|
it("returns null when combatant has invalid required fields", () => {
|
||||||
it("returns null for non-object JSON (string)", () => {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify("hello"));
|
|
||||||
expect(loadEncounter()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for non-object JSON (number)", () => {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(42));
|
|
||||||
expect(loadEncounter()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for non-object JSON (array)", () => {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([1, 2, 3]));
|
|
||||||
expect(loadEncounter()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for non-object JSON (null)", () => {
|
|
||||||
localStorage.setItem(STORAGE_KEY, "null");
|
|
||||||
expect(loadEncounter()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when combatants is a string instead of array", () => {
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify({
|
|
||||||
combatants: "not-array",
|
|
||||||
activeIndex: 0,
|
|
||||||
roundNumber: 1,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(loadEncounter()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when activeIndex is a string instead of number", () => {
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify({
|
|
||||||
combatants: [{ id: "1", name: "Aria" }],
|
|
||||||
activeIndex: "zero",
|
|
||||||
roundNumber: 1,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(loadEncounter()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when combatant entry is missing id", () => {
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify({
|
|
||||||
combatants: [{ name: "Aria" }],
|
|
||||||
activeIndex: 0,
|
|
||||||
roundNumber: 1,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(loadEncounter()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when combatant entry is missing name", () => {
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
STORAGE_KEY,
|
STORAGE_KEY,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -191,88 +134,6 @@ describe("loadEncounter", () => {
|
|||||||
expect(loadEncounter()).toBeNull();
|
expect(loadEncounter()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for negative roundNumber", () => {
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify({
|
|
||||||
combatants: [{ id: "1", name: "Aria" }],
|
|
||||||
activeIndex: 0,
|
|
||||||
roundNumber: -1,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(loadEncounter()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty encounter for zero combatants (cleared state)", () => {
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify({ combatants: [], activeIndex: 0, roundNumber: 1 }),
|
|
||||||
);
|
|
||||||
const result = loadEncounter();
|
|
||||||
expect(result).toEqual({
|
|
||||||
combatants: [],
|
|
||||||
activeIndex: 0,
|
|
||||||
roundNumber: 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("round-trip preserves combatant AC value", () => {
|
|
||||||
const result = createEncounter(
|
|
||||||
[{ id: combatantId("1"), name: "Aria", ac: 18 }],
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
if (isDomainError(result)) throw new Error("unreachable");
|
|
||||||
saveEncounter(result);
|
|
||||||
const loaded = loadEncounter();
|
|
||||||
expect(loaded?.combatants[0].ac).toBe(18);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("round-trip preserves combatant without AC", () => {
|
|
||||||
const result = createEncounter(
|
|
||||||
[{ id: combatantId("1"), name: "Aria" }],
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
if (isDomainError(result)) throw new Error("unreachable");
|
|
||||||
saveEncounter(result);
|
|
||||||
const loaded = loadEncounter();
|
|
||||||
expect(loaded?.combatants[0].ac).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("discards invalid AC values during rehydration", () => {
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify({
|
|
||||||
combatants: [
|
|
||||||
{ id: "1", name: "Neg", ac: -1 },
|
|
||||||
{ id: "2", name: "Float", ac: 3.5 },
|
|
||||||
{ id: "3", name: "Str", ac: "high" },
|
|
||||||
],
|
|
||||||
activeIndex: 0,
|
|
||||||
roundNumber: 1,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const loaded = loadEncounter();
|
|
||||||
expect(loaded).not.toBeNull();
|
|
||||||
expect(loaded?.combatants[0].ac).toBeUndefined();
|
|
||||||
expect(loaded?.combatants[1].ac).toBeUndefined();
|
|
||||||
expect(loaded?.combatants[2].ac).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves AC of 0 during rehydration", () => {
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify({
|
|
||||||
combatants: [{ id: "1", name: "Aria", ac: 0 }],
|
|
||||||
activeIndex: 0,
|
|
||||||
roundNumber: 1,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const loaded = loadEncounter();
|
|
||||||
expect(loaded?.combatants[0].ac).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("saving after modifications persists the latest state", () => {
|
it("saving after modifications persists the latest state", () => {
|
||||||
const encounter = makeEncounter();
|
const encounter = makeEncounter();
|
||||||
saveEncounter(encounter);
|
saveEncounter(encounter);
|
||||||
|
|||||||
@@ -90,102 +90,7 @@ describe("player-character-storage", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("per-character validation", () => {
|
describe("delegation to domain rehydration", () => {
|
||||||
it("discards character with missing name", () => {
|
|
||||||
mockStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify([
|
|
||||||
{ id: "pc-1", ac: 10, maxHp: 50, color: "blue", icon: "sword" },
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
expect(loadPlayerCharacters()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("discards character with empty name", () => {
|
|
||||||
mockStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify([
|
|
||||||
{
|
|
||||||
id: "pc-1",
|
|
||||||
name: "",
|
|
||||||
ac: 10,
|
|
||||||
maxHp: 50,
|
|
||||||
color: "blue",
|
|
||||||
icon: "sword",
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
expect(loadPlayerCharacters()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("discards character with invalid color", () => {
|
|
||||||
mockStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify([
|
|
||||||
{
|
|
||||||
id: "pc-1",
|
|
||||||
name: "Test",
|
|
||||||
ac: 10,
|
|
||||||
maxHp: 50,
|
|
||||||
color: "neon",
|
|
||||||
icon: "sword",
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
expect(loadPlayerCharacters()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("discards character with invalid icon", () => {
|
|
||||||
mockStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify([
|
|
||||||
{
|
|
||||||
id: "pc-1",
|
|
||||||
name: "Test",
|
|
||||||
ac: 10,
|
|
||||||
maxHp: 50,
|
|
||||||
color: "blue",
|
|
||||||
icon: "banana",
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
expect(loadPlayerCharacters()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("discards character with negative AC", () => {
|
|
||||||
mockStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify([
|
|
||||||
{
|
|
||||||
id: "pc-1",
|
|
||||||
name: "Test",
|
|
||||||
ac: -1,
|
|
||||||
maxHp: 50,
|
|
||||||
color: "blue",
|
|
||||||
icon: "sword",
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
expect(loadPlayerCharacters()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("discards character with maxHp of 0", () => {
|
|
||||||
mockStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify([
|
|
||||||
{
|
|
||||||
id: "pc-1",
|
|
||||||
name: "Test",
|
|
||||||
ac: 10,
|
|
||||||
maxHp: 0,
|
|
||||||
color: "blue",
|
|
||||||
icon: "sword",
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
expect(loadPlayerCharacters()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps valid characters and discards invalid ones", () => {
|
it("keeps valid characters and discards invalid ones", () => {
|
||||||
mockStorage.setItem(
|
mockStorage.setItem(
|
||||||
STORAGE_KEY,
|
STORAGE_KEY,
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
type ConditionId,
|
type Combatant,
|
||||||
combatantId,
|
|
||||||
createEncounter,
|
createEncounter,
|
||||||
creatureId,
|
|
||||||
type Encounter,
|
type Encounter,
|
||||||
isDomainError,
|
isDomainError,
|
||||||
playerCharacterId,
|
rehydrateCombatant,
|
||||||
VALID_CONDITION_IDS,
|
|
||||||
VALID_PLAYER_COLORS,
|
|
||||||
VALID_PLAYER_ICONS,
|
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
|
|
||||||
const STORAGE_KEY = "initiative:encounter";
|
const STORAGE_KEY = "initiative:encounter";
|
||||||
@@ -21,100 +16,7 @@ export function saveEncounter(encounter: Encounter): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateAc(value: unknown): number | undefined {
|
export function rehydrateEncounter(parsed: unknown): Encounter | null {
|
||||||
return typeof value === "number" && Number.isInteger(value) && value >= 0
|
|
||||||
? value
|
|
||||||
: undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateConditions(value: unknown): ConditionId[] | undefined {
|
|
||||||
if (!Array.isArray(value)) return undefined;
|
|
||||||
const valid = value.filter(
|
|
||||||
(v): v is ConditionId =>
|
|
||||||
typeof v === "string" && VALID_CONDITION_IDS.has(v),
|
|
||||||
);
|
|
||||||
return valid.length > 0 ? valid : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateCreatureId(value: unknown) {
|
|
||||||
return typeof value === "string" && value.length > 0
|
|
||||||
? creatureId(value)
|
|
||||||
: undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateHp(
|
|
||||||
rawMaxHp: unknown,
|
|
||||||
rawCurrentHp: unknown,
|
|
||||||
): { maxHp: number; currentHp: number } | undefined {
|
|
||||||
if (
|
|
||||||
typeof rawMaxHp !== "number" ||
|
|
||||||
!Number.isInteger(rawMaxHp) ||
|
|
||||||
rawMaxHp < 1
|
|
||||||
) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const validCurrentHp =
|
|
||||||
typeof rawCurrentHp === "number" &&
|
|
||||||
Number.isInteger(rawCurrentHp) &&
|
|
||||||
rawCurrentHp >= 0 &&
|
|
||||||
rawCurrentHp <= rawMaxHp;
|
|
||||||
return {
|
|
||||||
maxHp: rawMaxHp,
|
|
||||||
currentHp: validCurrentHp ? rawCurrentHp : rawMaxHp,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function rehydrateCombatant(c: unknown) {
|
|
||||||
const entry = c as Record<string, unknown>;
|
|
||||||
const base = {
|
|
||||||
id: combatantId(entry.id as string),
|
|
||||||
name: entry.name as string,
|
|
||||||
initiative:
|
|
||||||
typeof entry.initiative === "number" ? entry.initiative : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const color =
|
|
||||||
typeof entry.color === "string" && VALID_PLAYER_COLORS.has(entry.color)
|
|
||||||
? entry.color
|
|
||||||
: undefined;
|
|
||||||
const icon =
|
|
||||||
typeof entry.icon === "string" && VALID_PLAYER_ICONS.has(entry.icon)
|
|
||||||
? entry.icon
|
|
||||||
: undefined;
|
|
||||||
const pcId =
|
|
||||||
typeof entry.playerCharacterId === "string" &&
|
|
||||||
entry.playerCharacterId.length > 0
|
|
||||||
? playerCharacterId(entry.playerCharacterId)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const shared = {
|
|
||||||
...base,
|
|
||||||
ac: validateAc(entry.ac),
|
|
||||||
conditions: validateConditions(entry.conditions),
|
|
||||||
isConcentrating: entry.isConcentrating === true ? true : undefined,
|
|
||||||
creatureId: validateCreatureId(entry.creatureId),
|
|
||||||
color,
|
|
||||||
icon,
|
|
||||||
playerCharacterId: pcId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const hp = validateHp(entry.maxHp, entry.currentHp);
|
|
||||||
return hp ? { ...shared, ...hp } : shared;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidCombatantEntry(c: unknown): boolean {
|
|
||||||
if (typeof c !== "object" || c === null || Array.isArray(c)) return false;
|
|
||||||
const entry = c as Record<string, unknown>;
|
|
||||||
return typeof entry.id === "string" && typeof entry.name === "string";
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
@@ -135,18 +37,30 @@ export function loadEncounter(): Encounter | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!combatants.every(isValidCombatantEntry)) return null;
|
const rehydrated: Combatant[] = [];
|
||||||
|
for (const c of combatants) {
|
||||||
|
const result = rehydrateCombatant(c);
|
||||||
|
if (result === null) return null;
|
||||||
|
rehydrated.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
const rehydrated = combatants.map(rehydrateCombatant);
|
const encounter = createEncounter(
|
||||||
|
|
||||||
const result = createEncounter(
|
|
||||||
rehydrated,
|
rehydrated,
|
||||||
obj.activeIndex,
|
obj.activeIndex,
|
||||||
obj.roundNumber,
|
obj.roundNumber,
|
||||||
);
|
);
|
||||||
if (isDomainError(result)) return null;
|
if (isDomainError(encounter)) return null;
|
||||||
|
|
||||||
return result;
|
return encounter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadEncounter(): Encounter | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw === null) return null;
|
||||||
|
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
return rehydrateEncounter(parsed);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
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 { rehydratePlayerCharacter } from "@initiative/domain";
|
||||||
|
import { rehydrateEncounter } from "./encounter-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 = rehydratePlayerCharacter(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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
import type { PlayerCharacter } from "@initiative/domain";
|
import type { PlayerCharacter } from "@initiative/domain";
|
||||||
import {
|
import { rehydratePlayerCharacter } from "@initiative/domain";
|
||||||
playerCharacterId,
|
|
||||||
VALID_PLAYER_COLORS,
|
|
||||||
VALID_PLAYER_ICONS,
|
|
||||||
} from "@initiative/domain";
|
|
||||||
|
|
||||||
const STORAGE_KEY = "initiative:player-characters";
|
const STORAGE_KEY = "initiative:player-characters";
|
||||||
|
|
||||||
@@ -15,46 +11,6 @@ export function savePlayerCharacters(characters: PlayerCharacter[]): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidOptionalMember(
|
|
||||||
value: unknown,
|
|
||||||
valid: ReadonlySet<string>,
|
|
||||||
): boolean {
|
|
||||||
return value === undefined || (typeof value === "string" && valid.has(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
|
||||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
||||||
return null;
|
|
||||||
const entry = raw as Record<string, unknown>;
|
|
||||||
|
|
||||||
if (typeof entry.id !== "string" || entry.id.length === 0) return null;
|
|
||||||
if (typeof entry.name !== "string" || entry.name.trim().length === 0)
|
|
||||||
return null;
|
|
||||||
if (
|
|
||||||
typeof entry.ac !== "number" ||
|
|
||||||
!Number.isInteger(entry.ac) ||
|
|
||||||
entry.ac < 0
|
|
||||||
)
|
|
||||||
return null;
|
|
||||||
if (
|
|
||||||
typeof entry.maxHp !== "number" ||
|
|
||||||
!Number.isInteger(entry.maxHp) ||
|
|
||||||
entry.maxHp < 1
|
|
||||||
)
|
|
||||||
return null;
|
|
||||||
if (!isValidOptionalMember(entry.color, VALID_PLAYER_COLORS)) return null;
|
|
||||||
if (!isValidOptionalMember(entry.icon, VALID_PLAYER_ICONS)) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: playerCharacterId(entry.id),
|
|
||||||
name: entry.name,
|
|
||||||
ac: entry.ac,
|
|
||||||
maxHp: entry.maxHp,
|
|
||||||
color: entry.color as PlayerCharacter["color"],
|
|
||||||
icon: entry.icon as PlayerCharacter["icon"],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadPlayerCharacters(): PlayerCharacter[] {
|
export function loadPlayerCharacters(): PlayerCharacter[] {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
@@ -65,7 +21,7 @@ export function loadPlayerCharacters(): PlayerCharacter[] {
|
|||||||
|
|
||||||
const characters: PlayerCharacter[] = [];
|
const characters: PlayerCharacter[] = [];
|
||||||
for (const item of parsed) {
|
for (const item of parsed) {
|
||||||
const pc = rehydrateCharacter(item);
|
const pc = rehydratePlayerCharacter(item);
|
||||||
if (pc !== null) {
|
if (pc !== null) {
|
||||||
characters.push(pc);
|
characters.push(pc);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user