Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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()
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,3 +12,4 @@ Thumbs.db
|
|||||||
coverage/
|
coverage/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
docs/agents/plans/
|
docs/agents/plans/
|
||||||
|
.rodney/
|
||||||
|
|||||||
13
CLAUDE.md
13
CLAUDE.md
@@ -73,6 +73,7 @@ docs/agents/ RPI skill artifacts (research reports, plans)
|
|||||||
- **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks.
|
- **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks.
|
||||||
- **Feature specs** live in `specs/NNN-feature-name/` with spec.md (and optionally plan.md, tasks.md for new work). Specs describe features, not individual changes. The project constitution is at `.specify/memory/constitution.md`.
|
- **Feature specs** live in `specs/NNN-feature-name/` with spec.md (and optionally plan.md, tasks.md for new work). Specs describe features, not individual changes. The project constitution is at `.specify/memory/constitution.md`.
|
||||||
- **Component props** — max 8 explicitly declared props per component interface (enforced by `scripts/check-component-props.mjs`). Use React context for shared state; reserve props for per-instance config (data items, layout variants, refs).
|
- **Component props** — max 8 explicitly declared props per component interface (enforced by `scripts/check-component-props.mjs`). Use React context for shared state; reserve props for per-instance config (data items, layout variants, refs).
|
||||||
|
- **Export format compatibility** — When changing `Encounter`, `Combatant`, `PlayerCharacter`, or `UndoRedoState` types, verify that previously exported JSON files (version 1) still import correctly. If not, bump the `ExportBundle` version and add migration logic in `validateImportBundle()`.
|
||||||
- **Quality gates** are enforced at pre-commit via Lefthook's `pnpm check` — the project's single earliest enforcement point. No gate may exist only as a CI step or manual process.
|
- **Quality gates** are enforced at pre-commit via Lefthook's `pnpm check` — the project's single earliest enforcement point. No gate may exist only as a CI step or manual process.
|
||||||
|
|
||||||
## Self-Review Checklist
|
## Self-Review Checklist
|
||||||
@@ -97,6 +98,8 @@ Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Spec
|
|||||||
- `rpi-plan` — interactive phased implementation plan in `docs/agents/plans/`
|
- `rpi-plan` — interactive phased implementation plan in `docs/agents/plans/`
|
||||||
- `rpi-implement` — execute a plan file phase by phase with automated + manual verification
|
- `rpi-implement` — execute a plan file phase by phase with automated + manual verification
|
||||||
|
|
||||||
|
**Research scope**: Research should include a scan for existing patterns similar to what the feature needs (e.g., shared UI primitives, duplicated validation logic, repeated state management patterns). Identify extraction and consolidation opportunities before implementation, not during.
|
||||||
|
|
||||||
### Choosing the right workflow by scope
|
### Choosing the right workflow by scope
|
||||||
|
|
||||||
| Scope | Workflow |
|
| Scope | Workflow |
|
||||||
@@ -114,6 +117,9 @@ Speckit manages **what** to build (specs as living documents). RPI manages **how
|
|||||||
- `specs/003-combatant-state/` — HP, AC, conditions, concentration, initiative
|
- `specs/003-combatant-state/` — HP, AC, conditions, concentration, initiative
|
||||||
- `specs/004-bestiary/` — search index, stat blocks, source management, panel UX
|
- `specs/004-bestiary/` — search index, stat blocks, source management, panel UX
|
||||||
- `specs/005-player-characters/` — persistent player character templates (CRUD), search & add to encounters, color/icon visual distinction, `PlayerCharacterStore` port
|
- `specs/005-player-characters/` — persistent player character templates (CRUD), search & add to encounters, color/icon visual distinction, `PlayerCharacterStore` port
|
||||||
|
- `specs/006-undo-redo/` — undo/redo for encounter state mutations
|
||||||
|
- `specs/007-json-import-export/` — JSON import/export for full encounter state (encounter, undo/redo, player characters)
|
||||||
|
- `specs/008-encounter-difficulty/` — Live encounter difficulty indicator (5.5e XP budget system), optional PC level field
|
||||||
|
|
||||||
## Constitution (key principles)
|
## Constitution (key principles)
|
||||||
|
|
||||||
@@ -124,10 +130,3 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work:
|
|||||||
3. **Clarification-First** — Ask before making non-trivial assumptions.
|
3. **Clarification-First** — Ask before making non-trivial assumptions.
|
||||||
4. **MVP Baseline** — Say "MVP baseline does not include X", never permanent bans.
|
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.
|
5. **Spec-driven features** — Features are described in living specs; evolve existing specs via `/integrate-issue`, create new ones via `/speckit.specify`. Bug fixes and tooling changes do not require specs.
|
||||||
|
|
||||||
## Active Technologies
|
|
||||||
- TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Vite 6, Tailwind CSS v4, Lucide Reac (005-player-characters)
|
|
||||||
- localStorage (new key `"initiative:player-characters"`) (005-player-characters)
|
|
||||||
|
|
||||||
## Recent Changes
|
|
||||||
- 005-player-characters: Added TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Vite 6, Tailwind CSS v4, Lucide Reac
|
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ A local-first initiative tracker and encounter manager for tabletop RPGs (D&D 5e
|
|||||||
- **Initiative tracking** — add combatants (batch-add from bestiary, custom creatures with optional stats), roll initiative (manual or d20), cycle turns and rounds
|
- **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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
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,
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ 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/;
|
||||||
|
|
||||||
// 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 +125,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 +195,106 @@ 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("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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,12 +54,23 @@ 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: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -92,7 +111,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,4 +1,8 @@
|
|||||||
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
|
import {
|
||||||
|
type ConditionId,
|
||||||
|
getConditionDescription,
|
||||||
|
getConditionsForEdition,
|
||||||
|
} from "@initiative/domain";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
@@ -13,12 +17,15 @@ import {
|
|||||||
Heart,
|
Heart,
|
||||||
Link,
|
Link,
|
||||||
Moon,
|
Moon,
|
||||||
|
ShieldMinus,
|
||||||
Siren,
|
Siren,
|
||||||
|
Snail,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
ZapOff,
|
ZapOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useLayoutEffect, useRef, useState } from "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 { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { Tooltip } from "./ui/tooltip.js";
|
import { Tooltip } from "./ui/tooltip.js";
|
||||||
|
|
||||||
@@ -36,6 +43,8 @@ const ICON_MAP: Record<string, LucideIcon> = {
|
|||||||
Droplet,
|
Droplet,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
Link,
|
Link,
|
||||||
|
ShieldMinus,
|
||||||
|
Snail,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Moon,
|
Moon,
|
||||||
};
|
};
|
||||||
@@ -51,6 +60,7 @@ const COLOR_CLASSES: Record<string, string> = {
|
|||||||
slate: "text-slate-400",
|
slate: "text-slate-400",
|
||||||
green: "text-green-400",
|
green: "text-green-400",
|
||||||
indigo: "text-indigo-400",
|
indigo: "text-indigo-400",
|
||||||
|
sky: "text-sky-400",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ConditionPickerProps {
|
interface ConditionPickerProps {
|
||||||
@@ -104,6 +114,8 @@ export function ConditionPicker({
|
|||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
|
const conditions = getConditionsForEdition(edition);
|
||||||
const active = new Set(activeConditions ?? []);
|
const active = new Set(activeConditions ?? []);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
@@ -116,13 +128,17 @@ 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 = 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 = 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(
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
|
import {
|
||||||
|
CONDITION_DEFINITIONS,
|
||||||
|
type ConditionId,
|
||||||
|
getConditionDescription,
|
||||||
|
} from "@initiative/domain";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
@@ -14,10 +18,13 @@ import {
|
|||||||
Link,
|
Link,
|
||||||
Moon,
|
Moon,
|
||||||
Plus,
|
Plus,
|
||||||
|
ShieldMinus,
|
||||||
Siren,
|
Siren,
|
||||||
|
Snail,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
ZapOff,
|
ZapOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
import { cn } from "../lib/utils.js";
|
import { cn } from "../lib/utils.js";
|
||||||
import { Tooltip } from "./ui/tooltip.js";
|
import { Tooltip } from "./ui/tooltip.js";
|
||||||
|
|
||||||
@@ -35,6 +42,8 @@ const ICON_MAP: Record<string, LucideIcon> = {
|
|||||||
Droplet,
|
Droplet,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
Link,
|
Link,
|
||||||
|
ShieldMinus,
|
||||||
|
Snail,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Moon,
|
Moon,
|
||||||
};
|
};
|
||||||
@@ -50,6 +59,7 @@ const COLOR_CLASSES: Record<string, string> = {
|
|||||||
slate: "text-slate-400",
|
slate: "text-slate-400",
|
||||||
green: "text-green-400",
|
green: "text-green-400",
|
||||||
indigo: "text-indigo-400",
|
indigo: "text-indigo-400",
|
||||||
|
sky: "text-sky-400",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ConditionTagsProps {
|
interface ConditionTagsProps {
|
||||||
@@ -63,6 +73,7 @@ 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) => {
|
||||||
@@ -72,7 +83,10 @@ export function ConditionTags({
|
|||||||
if (!Icon) return null;
|
if (!Icon) return null;
|
||||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
const colorClass = 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 +108,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
apps/web/src/components/export-method-dialog.tsx
Normal file
105
apps/web/src/components/export-method-dialog.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { Check, ClipboardCopy, Download, X } from "lucide-react";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { Button } from "./ui/button.js";
|
||||||
|
import { Dialog } from "./ui/dialog.js";
|
||||||
|
import { Input } from "./ui/input.js";
|
||||||
|
|
||||||
|
interface ExportMethodDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onDownload: (includeHistory: boolean, filename: string) => void;
|
||||||
|
onCopyToClipboard: (includeHistory: boolean) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportMethodDialog({
|
||||||
|
open,
|
||||||
|
onDownload,
|
||||||
|
onCopyToClipboard,
|
||||||
|
onClose,
|
||||||
|
}: Readonly<ExportMethodDialogProps>) {
|
||||||
|
const [includeHistory, setIncludeHistory] = useState(false);
|
||||||
|
const [filename, setFilename] = useState("");
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setIncludeHistory(false);
|
||||||
|
setFilename("");
|
||||||
|
setCopied(false);
|
||||||
|
onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={handleClose} className="w-80">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold text-lg">Export Encounter</h2>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={filename}
|
||||||
|
onChange={(e) => setFilename(e.target.value)}
|
||||||
|
placeholder="Filename (optional)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="mb-4 flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={includeHistory}
|
||||||
|
onChange={(e) => setIncludeHistory(e.target.checked)}
|
||||||
|
className="accent-accent"
|
||||||
|
/>
|
||||||
|
<span className="text-foreground">Include undo/redo history</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||||
|
onClick={() => {
|
||||||
|
onDownload(includeHistory, filename);
|
||||||
|
handleClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Download className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Download file</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
Save as a JSON file
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||||
|
onClick={() => {
|
||||||
|
onCopyToClipboard(includeHistory);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-5 w-5 text-green-400" />
|
||||||
|
) : (
|
||||||
|
<ClipboardCopy className="h-5 w-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{copied ? "Copied!" : "Copy to clipboard"}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
Copy JSON to your clipboard
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Heart, Sword } from "lucide-react";
|
import { Heart, ShieldPlus, Sword } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -12,10 +12,15 @@ 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);
|
||||||
@@ -101,7 +106,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 +135,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
apps/web/src/components/import-method-dialog.tsx
Normal file
125
apps/web/src/components/import-method-dialog.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { ClipboardPaste, FileUp, X } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Button } from "./ui/button.js";
|
||||||
|
import { Dialog } from "./ui/dialog.js";
|
||||||
|
|
||||||
|
interface ImportMethodDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onSelectFile: () => void;
|
||||||
|
onSubmitClipboard: (text: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportMethodDialog({
|
||||||
|
open,
|
||||||
|
onSelectFile,
|
||||||
|
onSubmitClipboard,
|
||||||
|
onClose,
|
||||||
|
}: Readonly<ImportMethodDialogProps>) {
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const [mode, setMode] = useState<"pick" | "paste">("pick");
|
||||||
|
const [pasteText, setPasteText] = useState("");
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setMode("pick");
|
||||||
|
setPasteText("");
|
||||||
|
onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setMode("pick");
|
||||||
|
setPasteText("");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === "paste") {
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={handleClose} className="w-80">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold text-lg">Import Encounter</h2>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{mode === "pick" && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||||
|
onClick={() => {
|
||||||
|
handleClose();
|
||||||
|
onSelectFile();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileUp className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">From file</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
Upload a JSON file
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||||
|
onClick={() => setMode("paste")}
|
||||||
|
>
|
||||||
|
<ClipboardPaste className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Paste content</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
Paste JSON content directly
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mode === "paste" && (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={pasteText}
|
||||||
|
onChange={(e) => setPasteText(e.target.value)}
|
||||||
|
placeholder="Paste exported JSON here..."
|
||||||
|
className="h-32 w-full resize-none rounded-md border border-border bg-background px-3 py-2 font-mono text-foreground text-xs placeholder:text-muted-foreground focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setMode("pick");
|
||||||
|
setPasteText("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={pasteText.trim().length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
const text = pasteText;
|
||||||
|
handleClose();
|
||||||
|
onSubmitClipboard(text);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
|||||||
setEditingPlayer(undefined);
|
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, X } 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 } from "./ui/dialog";
|
||||||
|
|
||||||
interface PlayerManagementProps {
|
interface PlayerManagementProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -22,41 +22,8 @@ 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}
|
|
||||||
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">
|
||||||
Player Characters
|
Player Characters
|
||||||
@@ -101,6 +68,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 +100,6 @@ export function PlayerManagement({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
100
apps/web/src/components/settings-modal.tsx
Normal file
100
apps/web/src/components/settings-modal.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import type { RulesEdition } from "@initiative/domain";
|
||||||
|
import { Monitor, Moon, Sun, X } 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 { Button } from "./ui/button.js";
|
||||||
|
import { Dialog } 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">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold text-foreground text-lg">Settings</h2>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
50
apps/web/src/components/ui/dialog.tsx
Normal file
50
apps/web/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { type ReactNode, useEffect, useRef } from "react";
|
||||||
|
import { cn } from "../../lib/utils.js";
|
||||||
|
|
||||||
|
interface DialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dialog({ open, onClose, className, children }: DialogProps) {
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
if (open && !dialog.open) dialog.showModal();
|
||||||
|
else if (!open && dialog.open) dialog.close();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
function handleCancel(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
function handleBackdropClick(e: MouseEvent) {
|
||||||
|
if (e.target === dialog) onClose();
|
||||||
|
}
|
||||||
|
dialog.addEventListener("cancel", handleCancel);
|
||||||
|
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||||
|
return () => {
|
||||||
|
dialog.removeEventListener("cancel", handleCancel);
|
||||||
|
dialog.removeEventListener("mousedown", handleBackdropClick);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={dialogRef}
|
||||||
|
className={cn(
|
||||||
|
"m-auto rounded-lg border border-border bg-card text-foreground shadow-xl backdrop:bg-black/50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="p-6">{children}</div>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
220
apps/web/src/hooks/__tests__/use-difficulty.test.ts
Normal file
220
apps/web/src/hooks/__tests__/use-difficulty.test.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import type {
|
||||||
|
Combatant,
|
||||||
|
CreatureId,
|
||||||
|
Encounter,
|
||||||
|
PlayerCharacter,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("../../contexts/encounter-context.js", () => ({
|
||||||
|
useEncounterContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../contexts/player-characters-context.js", () => ({
|
||||||
|
usePlayerCharactersContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||||
|
useBestiaryContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useBestiaryContext } from "../../contexts/bestiary-context.js";
|
||||||
|
import { useEncounterContext } from "../../contexts/encounter-context.js";
|
||||||
|
import { usePlayerCharactersContext } from "../../contexts/player-characters-context.js";
|
||||||
|
import { useDifficulty } from "../use-difficulty.js";
|
||||||
|
|
||||||
|
const mockEncounterContext = vi.mocked(useEncounterContext);
|
||||||
|
const mockPlayerCharactersContext = vi.mocked(usePlayerCharactersContext);
|
||||||
|
const mockBestiaryContext = vi.mocked(useBestiaryContext);
|
||||||
|
|
||||||
|
const pcId1 = playerCharacterId("pc-1");
|
||||||
|
const pcId2 = playerCharacterId("pc-2");
|
||||||
|
const crId1 = creatureId("creature-1");
|
||||||
|
const _crId2 = creatureId("creature-2");
|
||||||
|
|
||||||
|
function setup(options: {
|
||||||
|
combatants: Combatant[];
|
||||||
|
characters: PlayerCharacter[];
|
||||||
|
creatures: Map<CreatureId, { cr: string }>;
|
||||||
|
}) {
|
||||||
|
const encounter = {
|
||||||
|
combatants: options.combatants,
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
} as Encounter;
|
||||||
|
|
||||||
|
mockEncounterContext.mockReturnValue({
|
||||||
|
encounter,
|
||||||
|
} as ReturnType<typeof useEncounterContext>);
|
||||||
|
|
||||||
|
mockPlayerCharactersContext.mockReturnValue({
|
||||||
|
characters: options.characters,
|
||||||
|
} as ReturnType<typeof usePlayerCharactersContext>);
|
||||||
|
|
||||||
|
mockBestiaryContext.mockReturnValue({
|
||||||
|
getCreature: (id: CreatureId) => options.creatures.get(id),
|
||||||
|
} as ReturnType<typeof useBestiaryContext>);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useDifficulty", () => {
|
||||||
|
it("returns difficulty result for leveled PCs and bestiary monsters", () => {
|
||||||
|
setup({
|
||||||
|
combatants: [
|
||||||
|
{ id: combatantId("c1"), name: "Hero", playerCharacterId: pcId1 },
|
||||||
|
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
|
||||||
|
],
|
||||||
|
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }],
|
||||||
|
creatures: new Map([[crId1, { cr: "1/4" }]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty());
|
||||||
|
|
||||||
|
expect(result.current).not.toBeNull();
|
||||||
|
expect(result.current?.tier).toBe("low");
|
||||||
|
expect(result.current?.totalMonsterXp).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("returns null when data is insufficient (ED-2)", () => {
|
||||||
|
it("returns null when encounter has no combatants", () => {
|
||||||
|
setup({ combatants: [], characters: [], creatures: new Map() });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty());
|
||||||
|
expect(result.current).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when only custom combatants (no creatureId)", () => {
|
||||||
|
setup({
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Custom",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }],
|
||||||
|
creatures: new Map(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty());
|
||||||
|
expect(result.current).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when bestiary monsters present but no PC combatants", () => {
|
||||||
|
setup({
|
||||||
|
combatants: [
|
||||||
|
{ id: combatantId("c1"), name: "Goblin", creatureId: crId1 },
|
||||||
|
],
|
||||||
|
characters: [],
|
||||||
|
creatures: new Map([[crId1, { cr: "1" }]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty());
|
||||||
|
expect(result.current).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when PC combatants have no level", () => {
|
||||||
|
setup({
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
},
|
||||||
|
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
|
||||||
|
],
|
||||||
|
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30 }],
|
||||||
|
creatures: new Map([[crId1, { cr: "1" }]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty());
|
||||||
|
expect(result.current).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when PC combatant references unknown player character", () => {
|
||||||
|
setup({
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId2,
|
||||||
|
},
|
||||||
|
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
|
||||||
|
],
|
||||||
|
characters: [{ id: pcId1, name: "Other", ac: 15, maxHp: 30, level: 5 }],
|
||||||
|
creatures: new Map([[crId1, { cr: "1" }]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty());
|
||||||
|
expect(result.current).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles mixed combatants: only leveled PCs and bestiary monsters contribute", () => {
|
||||||
|
// Party: one leveled PC, one without level (excluded)
|
||||||
|
// Monsters: one bestiary creature, one custom (excluded)
|
||||||
|
setup({
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Leveled",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: combatantId("c2"),
|
||||||
|
name: "No Level",
|
||||||
|
playerCharacterId: pcId2,
|
||||||
|
},
|
||||||
|
{ id: combatantId("c3"), name: "Goblin", creatureId: crId1 },
|
||||||
|
{ id: combatantId("c4"), name: "Custom Monster" },
|
||||||
|
],
|
||||||
|
characters: [
|
||||||
|
{ id: pcId1, name: "Leveled", ac: 15, maxHp: 30, level: 1 },
|
||||||
|
{ id: pcId2, name: "No Level", ac: 12, maxHp: 20 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[crId1, { cr: "1" }]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty());
|
||||||
|
|
||||||
|
expect(result.current).not.toBeNull();
|
||||||
|
// 1 level-1 PC: budget low=50, mod=75, high=100
|
||||||
|
// 1 CR 1 monster: 200 XP → high (200 >= 100)
|
||||||
|
expect(result.current?.tier).toBe("high");
|
||||||
|
expect(result.current?.totalMonsterXp).toBe(200);
|
||||||
|
expect(result.current?.partyBudget.low).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes duplicate PC combatants in budget", () => {
|
||||||
|
// Same PC added twice → counts twice
|
||||||
|
setup({
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Hero 1",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: combatantId("c2"),
|
||||||
|
name: "Hero 2",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
},
|
||||||
|
{ id: combatantId("c3"), name: "Goblin", creatureId: crId1 },
|
||||||
|
],
|
||||||
|
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }],
|
||||||
|
creatures: new Map([[crId1, { cr: "1/4" }]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty());
|
||||||
|
|
||||||
|
expect(result.current).not.toBeNull();
|
||||||
|
// 2x level 1: budget low=100
|
||||||
|
expect(result.current?.partyBudget.low).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -42,7 +42,14 @@ describe("usePlayerCharacters", () => {
|
|||||||
const { result } = renderHook(() => usePlayerCharacters());
|
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;
|
||||||
|
|||||||
316
apps/web/src/hooks/use-action-bar-state.ts
Normal file
316
apps/web/src/hooks/use-action-bar-state.ts
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
import type { CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||||
|
import { useCallback, useDeferredValue, useMemo, useState } from "react";
|
||||||
|
import type { SearchResult } from "../contexts/bestiary-context.js";
|
||||||
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
|
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||||
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
|
|
||||||
|
export interface QueuedCreature {
|
||||||
|
result: SearchResult;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuggestionActions {
|
||||||
|
dismiss: () => void;
|
||||||
|
clear: () => void;
|
||||||
|
clickSuggestion: (result: SearchResult) => void;
|
||||||
|
setSuggestionIndex: (i: number) => void;
|
||||||
|
setQueued: (q: QueuedCreature | null) => void;
|
||||||
|
confirmQueued: () => void;
|
||||||
|
addFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function creatureKey(r: SearchResult): string {
|
||||||
|
return `${r.source}:${r.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActionBarState() {
|
||||||
|
const {
|
||||||
|
addCombatant,
|
||||||
|
addFromBestiary,
|
||||||
|
addMultipleFromBestiary,
|
||||||
|
addFromPlayerCharacter,
|
||||||
|
} = useEncounterContext();
|
||||||
|
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
|
||||||
|
useBestiaryContext();
|
||||||
|
const { characters: playerCharacters } = usePlayerCharactersContext();
|
||||||
|
const { showBulkImport, showSourceManager, showCreature, panelView } =
|
||||||
|
useSidePanelContext();
|
||||||
|
|
||||||
|
const [nameInput, setNameInput] = useState("");
|
||||||
|
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||||
|
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
||||||
|
const deferredSuggestions = useDeferredValue(suggestions);
|
||||||
|
const deferredPcMatches = useDeferredValue(pcMatches);
|
||||||
|
const [suggestionIndex, setSuggestionIndex] = useState(-1);
|
||||||
|
const [queued, setQueued] = useState<QueuedCreature | null>(null);
|
||||||
|
const [customInit, setCustomInit] = useState("");
|
||||||
|
const [customAc, setCustomAc] = useState("");
|
||||||
|
const [customMaxHp, setCustomMaxHp] = useState("");
|
||||||
|
const [browseMode, setBrowseMode] = useState(false);
|
||||||
|
|
||||||
|
const clearCustomFields = () => {
|
||||||
|
setCustomInit("");
|
||||||
|
setCustomAc("");
|
||||||
|
setCustomMaxHp("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearInput = useCallback(() => {
|
||||||
|
setNameInput("");
|
||||||
|
setSuggestions([]);
|
||||||
|
setPcMatches([]);
|
||||||
|
setQueued(null);
|
||||||
|
setSuggestionIndex(-1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismissSuggestions = useCallback(() => {
|
||||||
|
setSuggestions([]);
|
||||||
|
setPcMatches([]);
|
||||||
|
setQueued(null);
|
||||||
|
setSuggestionIndex(-1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddFromBestiary = useCallback(
|
||||||
|
(result: SearchResult) => {
|
||||||
|
const creatureId = addFromBestiary(result);
|
||||||
|
const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches;
|
||||||
|
if (creatureId && panelView.mode === "closed" && isDesktop) {
|
||||||
|
showCreature(creatureId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addFromBestiary, panelView.mode, showCreature],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleViewStatBlock = useCallback(
|
||||||
|
(result: SearchResult) => {
|
||||||
|
const slug = result.name
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||||
|
.replaceAll(/(^-|-$)/g, "");
|
||||||
|
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
||||||
|
showCreature(cId);
|
||||||
|
},
|
||||||
|
[showCreature],
|
||||||
|
);
|
||||||
|
|
||||||
|
const confirmQueued = useCallback(() => {
|
||||||
|
if (!queued) return;
|
||||||
|
if (queued.count === 1) {
|
||||||
|
handleAddFromBestiary(queued.result);
|
||||||
|
} else {
|
||||||
|
const creatureId = addMultipleFromBestiary(queued.result, queued.count);
|
||||||
|
const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches;
|
||||||
|
if (creatureId && panelView.mode === "closed" && isDesktop) {
|
||||||
|
showCreature(creatureId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clearInput();
|
||||||
|
}, [
|
||||||
|
queued,
|
||||||
|
handleAddFromBestiary,
|
||||||
|
addMultipleFromBestiary,
|
||||||
|
panelView.mode,
|
||||||
|
showCreature,
|
||||||
|
clearInput,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const parseNum = (v: string): number | undefined => {
|
||||||
|
if (v.trim() === "") return undefined;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isNaN(n) ? undefined : n;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (browseMode) return;
|
||||||
|
if (queued) {
|
||||||
|
confirmQueued();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nameInput.trim() === "") return;
|
||||||
|
const opts: { initiative?: number; ac?: number; maxHp?: number } = {};
|
||||||
|
const init = parseNum(customInit);
|
||||||
|
const ac = parseNum(customAc);
|
||||||
|
const maxHp = parseNum(customMaxHp);
|
||||||
|
if (init !== undefined) opts.initiative = init;
|
||||||
|
if (ac !== undefined) opts.ac = ac;
|
||||||
|
if (maxHp !== undefined) opts.maxHp = maxHp;
|
||||||
|
addCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
|
||||||
|
setNameInput("");
|
||||||
|
setSuggestions([]);
|
||||||
|
setPcMatches([]);
|
||||||
|
clearCustomFields();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBrowseSearch = (value: string) => {
|
||||||
|
setSuggestions(value.length >= 2 ? bestiarySearch(value) : []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddSearch = (value: string) => {
|
||||||
|
let newSuggestions: SearchResult[] = [];
|
||||||
|
let newPcMatches: PlayerCharacter[] = [];
|
||||||
|
if (value.length >= 2) {
|
||||||
|
newSuggestions = bestiarySearch(value);
|
||||||
|
setSuggestions(newSuggestions);
|
||||||
|
if (playerCharacters && playerCharacters.length > 0) {
|
||||||
|
const lower = value.toLowerCase();
|
||||||
|
newPcMatches = playerCharacters.filter((pc) =>
|
||||||
|
pc.name.toLowerCase().includes(lower),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setPcMatches(newPcMatches);
|
||||||
|
} else {
|
||||||
|
setSuggestions([]);
|
||||||
|
setPcMatches([]);
|
||||||
|
}
|
||||||
|
if (newSuggestions.length > 0 || newPcMatches.length > 0) {
|
||||||
|
clearCustomFields();
|
||||||
|
}
|
||||||
|
if (queued) {
|
||||||
|
const qKey = creatureKey(queued.result);
|
||||||
|
const stillVisible = newSuggestions.some((s) => creatureKey(s) === qKey);
|
||||||
|
if (!stillVisible) {
|
||||||
|
setQueued(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNameChange = (value: string) => {
|
||||||
|
setNameInput(value);
|
||||||
|
setSuggestionIndex(-1);
|
||||||
|
if (browseMode) {
|
||||||
|
handleBrowseSearch(value);
|
||||||
|
} else {
|
||||||
|
handleAddSearch(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClickSuggestion = useCallback((result: SearchResult) => {
|
||||||
|
const key = creatureKey(result);
|
||||||
|
setQueued((prev) => {
|
||||||
|
if (prev && creatureKey(prev.result) === key) {
|
||||||
|
return { ...prev, count: prev.count + 1 };
|
||||||
|
}
|
||||||
|
return { result, count: 1 };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEnter = () => {
|
||||||
|
if (queued) {
|
||||||
|
confirmQueued();
|
||||||
|
} else if (suggestionIndex >= 0) {
|
||||||
|
handleClickSuggestion(suggestions[suggestionIndex]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasSuggestions =
|
||||||
|
deferredSuggestions.length > 0 || deferredPcMatches.length > 0;
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (!hasSuggestions) return;
|
||||||
|
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleEnter();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
dismissSuggestions();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBrowseKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setBrowseMode(false);
|
||||||
|
clearInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (suggestions.length === 0) return;
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
||||||
|
} else if (e.key === "Enter" && suggestionIndex >= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleViewStatBlock(suggestions[suggestionIndex]);
|
||||||
|
setBrowseMode(false);
|
||||||
|
clearInput();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBrowseSelect = (result: SearchResult) => {
|
||||||
|
handleViewStatBlock(result);
|
||||||
|
setBrowseMode(false);
|
||||||
|
clearInput();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleBrowseMode = () => {
|
||||||
|
setBrowseMode((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
setSuggestionIndex(-1);
|
||||||
|
setQueued(null);
|
||||||
|
if (next) {
|
||||||
|
handleBrowseSearch(nameInput);
|
||||||
|
} else {
|
||||||
|
handleAddSearch(nameInput);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
clearCustomFields();
|
||||||
|
};
|
||||||
|
|
||||||
|
const suggestionActions: SuggestionActions = useMemo(
|
||||||
|
() => ({
|
||||||
|
dismiss: dismissSuggestions,
|
||||||
|
clear: clearInput,
|
||||||
|
clickSuggestion: handleClickSuggestion,
|
||||||
|
setSuggestionIndex,
|
||||||
|
setQueued,
|
||||||
|
confirmQueued,
|
||||||
|
addFromPlayerCharacter,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
dismissSuggestions,
|
||||||
|
clearInput,
|
||||||
|
handleClickSuggestion,
|
||||||
|
confirmQueued,
|
||||||
|
addFromPlayerCharacter,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
nameInput,
|
||||||
|
suggestions: deferredSuggestions,
|
||||||
|
pcMatches: deferredPcMatches,
|
||||||
|
suggestionIndex,
|
||||||
|
queued,
|
||||||
|
customInit,
|
||||||
|
customAc,
|
||||||
|
customMaxHp,
|
||||||
|
browseMode,
|
||||||
|
bestiaryLoaded,
|
||||||
|
hasSuggestions,
|
||||||
|
showBulkImport,
|
||||||
|
showSourceManager,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
suggestionActions,
|
||||||
|
handleNameChange,
|
||||||
|
handleKeyDown,
|
||||||
|
handleBrowseKeyDown,
|
||||||
|
handleAdd,
|
||||||
|
handleBrowseSelect,
|
||||||
|
toggleBrowseMode,
|
||||||
|
setCustomInit,
|
||||||
|
setCustomAc,
|
||||||
|
setCustomMaxHp,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|||||||
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,31 +1,38 @@
|
|||||||
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,
|
||||||
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, useRef, useState } from "react";
|
||||||
@@ -33,6 +40,10 @@ import {
|
|||||||
loadEncounter,
|
loadEncounter,
|
||||||
saveEncounter,
|
saveEncounter,
|
||||||
} from "../persistence/encounter-storage.js";
|
} from "../persistence/encounter-storage.js";
|
||||||
|
import {
|
||||||
|
loadUndoRedoStacks,
|
||||||
|
saveUndoRedoStacks,
|
||||||
|
} from "../persistence/undo-redo-storage.js";
|
||||||
|
|
||||||
const COMBATANT_ID_REGEX = /^c-(\d+)$/;
|
const COMBATANT_ID_REGEX = /^c-(\d+)$/;
|
||||||
|
|
||||||
@@ -60,43 +71,24 @@ function deriveNextId(encounter: Encounter): number {
|
|||||||
return max;
|
return max;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CombatantOpts {
|
|
||||||
initiative?: number;
|
|
||||||
ac?: number;
|
|
||||||
maxHp?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyCombatantOpts(
|
|
||||||
makeStore: () => EncounterStore,
|
|
||||||
id: ReturnType<typeof combatantId>,
|
|
||||||
opts: CombatantOpts,
|
|
||||||
): DomainEvent[] {
|
|
||||||
const events: DomainEvent[] = [];
|
|
||||||
if (opts.maxHp !== undefined) {
|
|
||||||
const r = setHpUseCase(makeStore(), id, opts.maxHp);
|
|
||||||
if (!isDomainError(r)) events.push(...r);
|
|
||||||
}
|
|
||||||
if (opts.ac !== undefined) {
|
|
||||||
const r = setAcUseCase(makeStore(), id, opts.ac);
|
|
||||||
if (!isDomainError(r)) events.push(...r);
|
|
||||||
}
|
|
||||||
if (opts.initiative !== undefined) {
|
|
||||||
const r = setInitiativeUseCase(makeStore(), id, opts.initiative);
|
|
||||||
if (!isDomainError(r)) events.push(...r);
|
|
||||||
}
|
|
||||||
return events;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useEncounter() {
|
export function useEncounter() {
|
||||||
const [encounter, setEncounter] = useState<Encounter>(initializeEncounter);
|
const [encounter, setEncounter] = useState<Encounter>(initializeEncounter);
|
||||||
const [events, setEvents] = useState<DomainEvent[]>([]);
|
const [events, setEvents] = useState<DomainEvent[]>([]);
|
||||||
|
const [undoRedoState, setUndoRedoState] =
|
||||||
|
useState<UndoRedoState>(loadUndoRedoStacks);
|
||||||
const encounterRef = useRef(encounter);
|
const encounterRef = useRef(encounter);
|
||||||
encounterRef.current = encounter;
|
encounterRef.current = encounter;
|
||||||
|
const undoRedoRef = useRef(undoRedoState);
|
||||||
|
undoRedoRef.current = undoRedoState;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
saveEncounter(encounter);
|
saveEncounter(encounter);
|
||||||
}, [encounter]);
|
}, [encounter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
saveUndoRedoStacks(undoRedoState);
|
||||||
|
}, [undoRedoState]);
|
||||||
|
|
||||||
const makeStore = useCallback((): EncounterStore => {
|
const makeStore = useCallback((): EncounterStore => {
|
||||||
return {
|
return {
|
||||||
get: () => encounterRef.current,
|
get: () => encounterRef.current,
|
||||||
@@ -107,52 +99,68 @@ export function useEncounter() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const makeUndoRedoStore = useCallback((): UndoRedoStore => {
|
||||||
|
return {
|
||||||
|
get: () => undoRedoRef.current,
|
||||||
|
save: (s) => {
|
||||||
|
undoRedoRef.current = s;
|
||||||
|
setUndoRedoState(s);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const withUndo = useCallback(<T>(action: () => T): T => {
|
||||||
|
const snapshot = encounterRef.current;
|
||||||
|
const result = action();
|
||||||
|
if (!isDomainError(result)) {
|
||||||
|
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||||
|
undoRedoRef.current = newState;
|
||||||
|
setUndoRedoState(newState);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const advanceTurn = useCallback(() => {
|
const advanceTurn = useCallback(() => {
|
||||||
const result = advanceTurnUseCase(makeStore());
|
const result = withUndo(() => advanceTurnUseCase(makeStore()));
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
}, [makeStore]);
|
}, [makeStore, withUndo]);
|
||||||
|
|
||||||
const retreatTurn = useCallback(() => {
|
const retreatTurn = useCallback(() => {
|
||||||
const result = retreatTurnUseCase(makeStore());
|
const result = withUndo(() => retreatTurnUseCase(makeStore()));
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
}, [makeStore]);
|
}, [makeStore, withUndo]);
|
||||||
|
|
||||||
const nextId = useRef(deriveNextId(encounter));
|
const nextId = useRef(deriveNextId(encounter));
|
||||||
|
|
||||||
const addCombatant = useCallback(
|
const addCombatant = useCallback(
|
||||||
(name: string, opts?: CombatantOpts) => {
|
(name: string, init?: CombatantInit) => {
|
||||||
const id = combatantId(`c-${++nextId.current}`);
|
const id = combatantId(`c-${++nextId.current}`);
|
||||||
const result = addCombatantUseCase(makeStore(), id, name);
|
const result = withUndo(() =>
|
||||||
|
addCombatantUseCase(makeStore(), id, name, init),
|
||||||
|
);
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts) {
|
|
||||||
const optEvents = applyCombatantOpts(makeStore, id, opts);
|
|
||||||
if (optEvents.length > 0) {
|
|
||||||
setEvents((prev) => [...prev, ...optEvents]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
},
|
},
|
||||||
[makeStore],
|
[makeStore, withUndo],
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeCombatant = useCallback(
|
const removeCombatant = useCallback(
|
||||||
(id: CombatantId) => {
|
(id: CombatantId) => {
|
||||||
const result = removeCombatantUseCase(makeStore(), id);
|
const result = withUndo(() => removeCombatantUseCase(makeStore(), id));
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
return;
|
return;
|
||||||
@@ -160,12 +168,14 @@ export function useEncounter() {
|
|||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
},
|
},
|
||||||
[makeStore],
|
[makeStore, withUndo],
|
||||||
);
|
);
|
||||||
|
|
||||||
const editCombatant = useCallback(
|
const editCombatant = useCallback(
|
||||||
(id: CombatantId, newName: string) => {
|
(id: CombatantId, newName: string) => {
|
||||||
const result = editCombatantUseCase(makeStore(), id, newName);
|
const result = withUndo(() =>
|
||||||
|
editCombatantUseCase(makeStore(), id, newName),
|
||||||
|
);
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
return;
|
return;
|
||||||
@@ -173,12 +183,14 @@ export function useEncounter() {
|
|||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
},
|
},
|
||||||
[makeStore],
|
[makeStore, withUndo],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setInitiative = useCallback(
|
const setInitiative = useCallback(
|
||||||
(id: CombatantId, value: number | undefined) => {
|
(id: CombatantId, value: number | undefined) => {
|
||||||
const result = setInitiativeUseCase(makeStore(), id, value);
|
const result = withUndo(() =>
|
||||||
|
setInitiativeUseCase(makeStore(), id, value),
|
||||||
|
);
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
return;
|
return;
|
||||||
@@ -186,12 +198,12 @@ export function useEncounter() {
|
|||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
},
|
},
|
||||||
[makeStore],
|
[makeStore, withUndo],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setHp = useCallback(
|
const setHp = useCallback(
|
||||||
(id: CombatantId, maxHp: number | undefined) => {
|
(id: CombatantId, maxHp: number | undefined) => {
|
||||||
const result = setHpUseCase(makeStore(), id, maxHp);
|
const result = withUndo(() => setHpUseCase(makeStore(), id, maxHp));
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
return;
|
return;
|
||||||
@@ -199,12 +211,12 @@ export function useEncounter() {
|
|||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
},
|
},
|
||||||
[makeStore],
|
[makeStore, withUndo],
|
||||||
);
|
);
|
||||||
|
|
||||||
const adjustHp = useCallback(
|
const adjustHp = useCallback(
|
||||||
(id: CombatantId, delta: number) => {
|
(id: CombatantId, delta: number) => {
|
||||||
const result = adjustHpUseCase(makeStore(), id, delta);
|
const result = withUndo(() => adjustHpUseCase(makeStore(), id, delta));
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
return;
|
return;
|
||||||
@@ -212,12 +224,25 @@ export function useEncounter() {
|
|||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
},
|
},
|
||||||
[makeStore],
|
[makeStore, withUndo],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setTempHp = useCallback(
|
||||||
|
(id: CombatantId, tempHp: number | undefined) => {
|
||||||
|
const result = withUndo(() => setTempHpUseCase(makeStore(), id, tempHp));
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEvents((prev) => [...prev, ...result]);
|
||||||
|
},
|
||||||
|
[makeStore, withUndo],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setAc = useCallback(
|
const setAc = useCallback(
|
||||||
(id: CombatantId, value: number | undefined) => {
|
(id: CombatantId, value: number | undefined) => {
|
||||||
const result = setAcUseCase(makeStore(), id, value);
|
const result = withUndo(() => setAcUseCase(makeStore(), id, value));
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
return;
|
return;
|
||||||
@@ -225,12 +250,14 @@ export function useEncounter() {
|
|||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
},
|
},
|
||||||
[makeStore],
|
[makeStore, withUndo],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleCondition = useCallback(
|
const toggleCondition = useCallback(
|
||||||
(id: CombatantId, conditionId: ConditionId) => {
|
(id: CombatantId, conditionId: ConditionId) => {
|
||||||
const result = toggleConditionUseCase(makeStore(), id, conditionId);
|
const result = withUndo(() =>
|
||||||
|
toggleConditionUseCase(makeStore(), id, conditionId),
|
||||||
|
);
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
return;
|
return;
|
||||||
@@ -238,12 +265,14 @@ export function useEncounter() {
|
|||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
},
|
},
|
||||||
[makeStore],
|
[makeStore, withUndo],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleConcentration = useCallback(
|
const toggleConcentration = useCallback(
|
||||||
(id: CombatantId) => {
|
(id: CombatantId) => {
|
||||||
const result = toggleConcentrationUseCase(makeStore(), id);
|
const result = withUndo(() =>
|
||||||
|
toggleConcentrationUseCase(makeStore(), id),
|
||||||
|
);
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
return;
|
return;
|
||||||
@@ -251,7 +280,7 @@ export function useEncounter() {
|
|||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
},
|
},
|
||||||
[makeStore],
|
[makeStore, withUndo],
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearEncounter = useCallback(() => {
|
const clearEncounter = useCallback(() => {
|
||||||
@@ -261,12 +290,18 @@ export function useEncounter() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cleared = clearHistory();
|
||||||
|
undoRedoRef.current = cleared;
|
||||||
|
setUndoRedoState(cleared);
|
||||||
|
|
||||||
nextId.current = 0;
|
nextId.current = 0;
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
}, [makeStore]);
|
}, [makeStore]);
|
||||||
|
|
||||||
const addFromBestiary = useCallback(
|
const addOneFromBestiary = useCallback(
|
||||||
(entry: BestiaryIndexEntry): CreatureId | null => {
|
(
|
||||||
|
entry: BestiaryIndexEntry,
|
||||||
|
): { cId: CreatureId; events: DomainEvent[] } | null => {
|
||||||
const store = makeStore();
|
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(
|
||||||
@@ -274,7 +309,6 @@ export function useEncounter() {
|
|||||||
existingNames,
|
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) {
|
||||||
@@ -282,50 +316,75 @@ export function useEncounter() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add combatant with resolved name
|
|
||||||
const id = combatantId(`c-${++nextId.current}`);
|
|
||||||
const addResult = addCombatantUseCase(makeStore(), id, newName);
|
|
||||||
if (isDomainError(addResult)) return null;
|
|
||||||
|
|
||||||
// Set HP
|
|
||||||
const hpResult = setHpUseCase(makeStore(), id, entry.hp);
|
|
||||||
if (!isDomainError(hpResult)) {
|
|
||||||
setEvents((prev) => [...prev, ...hpResult]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set AC
|
|
||||||
if (entry.ac > 0) {
|
|
||||||
const acResult = setAcUseCase(makeStore(), id, entry.ac);
|
|
||||||
if (!isDomainError(acResult)) {
|
|
||||||
setEvents((prev) => [...prev, ...acResult]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive creatureId from source + name
|
|
||||||
const slug = entry.name
|
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.current}`);
|
||||||
const currentEncounter = store.get();
|
const result = addCombatantUseCase(makeStore(), id, newName, {
|
||||||
store.save({
|
maxHp: entry.hp,
|
||||||
...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 };
|
||||||
},
|
},
|
||||||
[makeStore],
|
[makeStore],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const addFromBestiary = useCallback(
|
||||||
|
(entry: BestiaryIndexEntry): CreatureId | null => {
|
||||||
|
const snapshot = encounterRef.current;
|
||||||
|
const added = addOneFromBestiary(entry);
|
||||||
|
|
||||||
|
if (!added) {
|
||||||
|
makeStore().save(snapshot);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||||
|
undoRedoRef.current = newState;
|
||||||
|
setUndoRedoState(newState);
|
||||||
|
|
||||||
|
setEvents((prev) => [...prev, ...added.events]);
|
||||||
|
return added.cId;
|
||||||
|
},
|
||||||
|
[makeStore, addOneFromBestiary],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addMultipleFromBestiary = useCallback(
|
||||||
|
(entry: BestiaryIndexEntry, count: number): CreatureId | null => {
|
||||||
|
const snapshot = encounterRef.current;
|
||||||
|
const allEvents: DomainEvent[] = [];
|
||||||
|
let lastCId: CreatureId | null = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const added = addOneFromBestiary(entry);
|
||||||
|
if (!added) {
|
||||||
|
makeStore().save(snapshot);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
allEvents.push(...added.events);
|
||||||
|
lastCId = added.cId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||||
|
undoRedoRef.current = newState;
|
||||||
|
setUndoRedoState(newState);
|
||||||
|
|
||||||
|
setEvents((prev) => [...prev, ...allEvents]);
|
||||||
|
return lastCId;
|
||||||
|
},
|
||||||
|
[makeStore, addOneFromBestiary],
|
||||||
|
);
|
||||||
|
|
||||||
const addFromPlayerCharacter = useCallback(
|
const addFromPlayerCharacter = useCallback(
|
||||||
(pc: PlayerCharacter) => {
|
(pc: PlayerCharacter) => {
|
||||||
|
const snapshot = encounterRef.current;
|
||||||
const store = makeStore();
|
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(pc.name, existingNames);
|
const { newName, renames } = resolveCreatureName(pc.name, existingNames);
|
||||||
@@ -338,44 +397,43 @@ export function useEncounter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const id = combatantId(`c-${++nextId.current}`);
|
const id = combatantId(`c-${++nextId.current}`);
|
||||||
const addResult = addCombatantUseCase(makeStore(), id, newName);
|
const result = addCombatantUseCase(makeStore(), id, newName, {
|
||||||
if (isDomainError(addResult)) return;
|
maxHp: pc.maxHp,
|
||||||
|
ac: pc.ac > 0 ? pc.ac : undefined,
|
||||||
// Set HP
|
|
||||||
const hpResult = setHpUseCase(makeStore(), id, pc.maxHp);
|
|
||||||
if (!isDomainError(hpResult)) {
|
|
||||||
setEvents((prev) => [...prev, ...hpResult]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set AC
|
|
||||||
if (pc.ac > 0) {
|
|
||||||
const acResult = setAcUseCase(makeStore(), id, pc.ac);
|
|
||||||
if (!isDomainError(acResult)) {
|
|
||||||
setEvents((prev) => [...prev, ...acResult]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set color, icon, and playerCharacterId on the combatant
|
|
||||||
const currentEncounter = store.get();
|
|
||||||
store.save({
|
|
||||||
...currentEncounter,
|
|
||||||
combatants: currentEncounter.combatants.map((c) =>
|
|
||||||
c.id === id
|
|
||||||
? {
|
|
||||||
...c,
|
|
||||||
color: pc.color,
|
color: pc.color,
|
||||||
icon: pc.icon,
|
icon: pc.icon,
|
||||||
playerCharacterId: pc.id,
|
playerCharacterId: pc.id,
|
||||||
}
|
|
||||||
: c,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...addResult]);
|
if (isDomainError(result)) {
|
||||||
|
store.save(snapshot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||||
|
undoRedoRef.current = newState;
|
||||||
|
setUndoRedoState(newState);
|
||||||
|
|
||||||
|
setEvents((prev) => [...prev, ...result]);
|
||||||
},
|
},
|
||||||
[makeStore],
|
[makeStore],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const undoAction = useCallback(() => {
|
||||||
|
undoUseCase(makeStore(), makeUndoRedoStore());
|
||||||
|
}, [makeStore, makeUndoRedoStore]);
|
||||||
|
|
||||||
|
const redoAction = useCallback(() => {
|
||||||
|
redoUseCase(makeStore(), makeUndoRedoStore());
|
||||||
|
}, [makeStore, makeUndoRedoStore]);
|
||||||
|
|
||||||
|
const canUndo = undoRedoState.undoStack.length > 0;
|
||||||
|
const canRedo = undoRedoState.redoStack.length > 0;
|
||||||
|
|
||||||
|
const hasTempHp = encounter.combatants.some(
|
||||||
|
(c) => c.tempHp !== undefined && c.tempHp > 0,
|
||||||
|
);
|
||||||
|
|
||||||
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,10 +444,14 @@ export function useEncounter() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
encounter,
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
events,
|
events,
|
||||||
isEmpty,
|
isEmpty,
|
||||||
|
hasTempHp,
|
||||||
hasCreatureCombatants,
|
hasCreatureCombatants,
|
||||||
canRollAllInitiative,
|
canRollAllInitiative,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
advanceTurn,
|
advanceTurn,
|
||||||
retreatTurn,
|
retreatTurn,
|
||||||
addCombatant,
|
addCombatant,
|
||||||
@@ -399,11 +461,18 @@ export function useEncounter() {
|
|||||||
setInitiative,
|
setInitiative,
|
||||||
setHp,
|
setHp,
|
||||||
adjustHp,
|
adjustHp,
|
||||||
|
setTempHp,
|
||||||
setAc,
|
setAc,
|
||||||
toggleCondition,
|
toggleCondition,
|
||||||
toggleConcentration,
|
toggleConcentration,
|
||||||
addFromBestiary,
|
addFromBestiary,
|
||||||
|
addMultipleFromBestiary,
|
||||||
addFromPlayerCharacter,
|
addFromPlayerCharacter,
|
||||||
|
undo: undoAction,
|
||||||
|
redo: redoAction,
|
||||||
|
setEncounter,
|
||||||
|
setUndoRedoState,
|
||||||
makeStore,
|
makeStore,
|
||||||
|
withUndo,
|
||||||
} 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>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -186,6 +186,38 @@ describe("player-character-storage", () => {
|
|||||||
expect(loadPlayerCharacters()).toEqual([]);
|
expect(loadPlayerCharacters()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves level through save/load round-trip", () => {
|
||||||
|
const pc = makePC({ level: 5 });
|
||||||
|
savePlayerCharacters([pc]);
|
||||||
|
const loaded = loadPlayerCharacters();
|
||||||
|
expect(loaded[0].level).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves undefined level through save/load round-trip", () => {
|
||||||
|
const pc = makePC();
|
||||||
|
savePlayerCharacters([pc]);
|
||||||
|
const loaded = loadPlayerCharacters();
|
||||||
|
expect(loaded[0].level).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discards character with invalid level", () => {
|
||||||
|
mockStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
id: "pc-1",
|
||||||
|
name: "Test",
|
||||||
|
ac: 10,
|
||||||
|
maxHp: 50,
|
||||||
|
color: "blue",
|
||||||
|
icon: "sword",
|
||||||
|
level: 25,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(loadPlayerCharacters()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps valid characters and discards invalid ones", () => {
|
it("keeps valid characters and discards invalid ones", () => {
|
||||||
mockStorage.setItem(
|
mockStorage.setItem(
|
||||||
STORAGE_KEY,
|
STORAGE_KEY,
|
||||||
|
|||||||
@@ -108,13 +108,7 @@ function isValidCombatantEntry(c: unknown): boolean {
|
|||||||
return typeof entry.id === "string" && typeof entry.name === "string";
|
return typeof entry.id === "string" && typeof entry.name === "string";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadEncounter(): Encounter | null {
|
export function rehydrateEncounter(parsed: unknown): 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;
|
||||||
|
|
||||||
@@ -139,14 +133,19 @@ export function loadEncounter(): Encounter | null {
|
|||||||
|
|
||||||
const rehydrated = combatants.map(rehydrateCombatant);
|
const rehydrated = combatants.map(rehydrateCombatant);
|
||||||
|
|
||||||
const result = createEncounter(
|
const result = createEncounter(rehydrated, obj.activeIndex, obj.roundNumber);
|
||||||
rehydrated,
|
|
||||||
obj.activeIndex,
|
|
||||||
obj.roundNumber,
|
|
||||||
);
|
|
||||||
if (isDomainError(result)) return null;
|
if (isDomainError(result)) return null;
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadEncounter(): Encounter | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw === null) return null;
|
||||||
|
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
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 { rehydrateEncounter } from "./encounter-storage.js";
|
||||||
|
import { rehydrateCharacter } from "./player-character-storage.js";
|
||||||
|
|
||||||
|
function rehydrateStack(raw: unknown[]): Encounter[] {
|
||||||
|
const result: Encounter[] = [];
|
||||||
|
for (const entry of raw) {
|
||||||
|
const rehydrated = rehydrateEncounter(entry);
|
||||||
|
if (rehydrated !== null) {
|
||||||
|
result.push(rehydrated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rehydrateCharacters(raw: unknown[]): PlayerCharacter[] {
|
||||||
|
const result: PlayerCharacter[] = [];
|
||||||
|
for (const entry of raw) {
|
||||||
|
const rehydrated = rehydrateCharacter(entry);
|
||||||
|
if (rehydrated !== null) {
|
||||||
|
result.push(rehydrated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateImportBundle(data: unknown): ExportBundle | string {
|
||||||
|
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
||||||
|
return "Invalid file format";
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = data as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (typeof obj.version !== "number" || obj.version !== 1) {
|
||||||
|
return "Invalid file format";
|
||||||
|
}
|
||||||
|
if (typeof obj.exportedAt !== "string") {
|
||||||
|
return "Invalid file format";
|
||||||
|
}
|
||||||
|
if (!Array.isArray(obj.undoStack) || !Array.isArray(obj.redoStack)) {
|
||||||
|
return "Invalid file format";
|
||||||
|
}
|
||||||
|
if (!Array.isArray(obj.playerCharacters)) {
|
||||||
|
return "Invalid file format";
|
||||||
|
}
|
||||||
|
|
||||||
|
const encounter = rehydrateEncounter(obj.encounter);
|
||||||
|
if (encounter === null) {
|
||||||
|
return "Invalid encounter data";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
exportedAt: obj.exportedAt,
|
||||||
|
encounter,
|
||||||
|
undoStack: rehydrateStack(obj.undoStack),
|
||||||
|
redoStack: rehydrateStack(obj.redoStack),
|
||||||
|
playerCharacters: rehydrateCharacters(obj.playerCharacters),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assembleExportBundle(
|
||||||
|
encounter: Encounter,
|
||||||
|
undoRedoState: UndoRedoState,
|
||||||
|
playerCharacters: readonly PlayerCharacter[],
|
||||||
|
includeHistory = true,
|
||||||
|
): ExportBundle {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
encounter,
|
||||||
|
undoStack: includeHistory ? undoRedoState.undoStack : [],
|
||||||
|
redoStack: includeHistory ? undoRedoState.redoStack : [],
|
||||||
|
playerCharacters: [...playerCharacters],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bundleToJson(bundle: ExportBundle): string {
|
||||||
|
return JSON.stringify(bundle, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFilename(name?: string): string {
|
||||||
|
const base =
|
||||||
|
name?.trim() ||
|
||||||
|
`initiative-export-${new Date().toISOString().slice(0, 10)}`;
|
||||||
|
return base.endsWith(".json") ? base : `${base}.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triggerDownload(bundle: ExportBundle, name?: string): void {
|
||||||
|
const blob = new Blob([bundleToJson(bundle)], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
const filename = resolveFilename(name);
|
||||||
|
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = filename;
|
||||||
|
anchor.click();
|
||||||
|
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readImportFile(
|
||||||
|
file: File,
|
||||||
|
): Promise<ExportBundle | string> {
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const parsed: unknown = JSON.parse(text);
|
||||||
|
return validateImportBundle(parsed);
|
||||||
|
} catch {
|
||||||
|
return "Invalid file format";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ function isValidOptionalMember(
|
|||||||
return value === undefined || (typeof value === "string" && valid.has(value));
|
return value === undefined || (typeof value === "string" && valid.has(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
export function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
||||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
||||||
return null;
|
return null;
|
||||||
const entry = raw as Record<string, unknown>;
|
const entry = raw as Record<string, unknown>;
|
||||||
@@ -44,6 +44,14 @@ function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
|||||||
return null;
|
return null;
|
||||||
if (!isValidOptionalMember(entry.color, VALID_PLAYER_COLORS)) return null;
|
if (!isValidOptionalMember(entry.color, VALID_PLAYER_COLORS)) return null;
|
||||||
if (!isValidOptionalMember(entry.icon, VALID_PLAYER_ICONS)) return null;
|
if (!isValidOptionalMember(entry.icon, VALID_PLAYER_ICONS)) return null;
|
||||||
|
if (
|
||||||
|
entry.level !== undefined &&
|
||||||
|
(typeof entry.level !== "number" ||
|
||||||
|
!Number.isInteger(entry.level) ||
|
||||||
|
entry.level < 1 ||
|
||||||
|
entry.level > 20)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: playerCharacterId(entry.id),
|
id: playerCharacterId(entry.id),
|
||||||
@@ -52,6 +60,7 @@ function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
|||||||
maxHp: entry.maxHp,
|
maxHp: entry.maxHp,
|
||||||
color: entry.color as PlayerCharacter["color"],
|
color: entry.color as PlayerCharacter["color"],
|
||||||
icon: entry.icon as PlayerCharacter["icon"],
|
icon: entry.icon as PlayerCharacter["icon"],
|
||||||
|
level: entry.level,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
45
apps/web/src/persistence/undo-redo-storage.ts
Normal file
45
apps/web/src/persistence/undo-redo-storage.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import type { Encounter, UndoRedoState } from "@initiative/domain";
|
||||||
|
import { EMPTY_UNDO_REDO_STATE } from "@initiative/domain";
|
||||||
|
import { rehydrateEncounter } from "./encounter-storage.js";
|
||||||
|
|
||||||
|
const UNDO_KEY = "initiative:encounter:undo";
|
||||||
|
const REDO_KEY = "initiative:encounter:redo";
|
||||||
|
|
||||||
|
export function saveUndoRedoStacks(state: UndoRedoState): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(UNDO_KEY, JSON.stringify(state.undoStack));
|
||||||
|
localStorage.setItem(REDO_KEY, JSON.stringify(state.redoStack));
|
||||||
|
} catch {
|
||||||
|
// Silently swallow errors (quota exceeded, storage unavailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStack(key: string): readonly Encounter[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
if (raw === null) return [];
|
||||||
|
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
|
||||||
|
const valid: Encounter[] = [];
|
||||||
|
for (const entry of parsed) {
|
||||||
|
const rehydrated = rehydrateEncounter(entry);
|
||||||
|
if (rehydrated !== null) {
|
||||||
|
valid.push(rehydrated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return valid;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadUndoRedoStacks(): UndoRedoState {
|
||||||
|
const undoStack = loadStack(UNDO_KEY);
|
||||||
|
const redoStack = loadStack(REDO_KEY);
|
||||||
|
if (undoStack.length === 0 && redoStack.length === 0) {
|
||||||
|
return EMPTY_UNDO_REDO_STATE;
|
||||||
|
}
|
||||||
|
return { undoStack, redoStack };
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"includes": [
|
"includes": [
|
||||||
"**",
|
"**",
|
||||||
@@ -8,7 +8,9 @@
|
|||||||
"!.specify",
|
"!.specify",
|
||||||
"!specs",
|
"!specs",
|
||||||
"!coverage",
|
"!coverage",
|
||||||
"!.pnpm-store"
|
"!.pnpm-store",
|
||||||
|
"!.rodney",
|
||||||
|
"!.agent-tests"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"assist": {
|
"assist": {
|
||||||
|
|||||||
20
docs/adr/000-template.md
Normal file
20
docs/adr/000-template.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# ADR-NNN: [Title]
|
||||||
|
|
||||||
|
**Date**: YYYY-MM-DD
|
||||||
|
**Status**: accepted | superseded | deprecated
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
What is the problem or situation that motivates this decision?
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
What did we decide, and why?
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
What other approaches were evaluated?
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
What are the trade-offs — both positive and negative?
|
||||||
45
docs/adr/001-errors-as-values.md
Normal file
45
docs/adr/001-errors-as-values.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# ADR-001: Errors as Values, Not Exceptions
|
||||||
|
|
||||||
|
**Date**: 2026-03-25
|
||||||
|
**Status**: accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Domain functions need to communicate failure (invalid input, missing combatant, violated invariants). The standard JavaScript approach is to throw exceptions, but thrown exceptions are invisible to TypeScript's type system — nothing in a function's signature tells the caller that it can fail or what errors to expect.
|
||||||
|
|
||||||
|
This project's domain layer is designed to be pure and deterministic. Thrown exceptions break both properties: they alter control flow (a side effect) and make the function's output unpredictable from the caller's perspective.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
All domain functions return `SuccessType | DomainError` unions. `DomainError` is a plain data object with a `kind` discriminant, a machine-readable `code`, and a human-readable `message`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface DomainError {
|
||||||
|
readonly kind: "domain-error";
|
||||||
|
readonly code: string;
|
||||||
|
readonly message: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Callers check results with the `isDomainError()` type guard before accessing success data. Errors are never thrown in the domain layer (adapter-layer code may throw for programmer errors like missing providers).
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
**Thrown exceptions** — the JavaScript default. Simpler to write (`throw new Error(...)`) but error paths are invisible to the type system. The caller has no compile-time indication that a function can fail, and `catch` blocks lose type information about which errors are possible. Would also make domain functions impure.
|
||||||
|
|
||||||
|
**Result wrapper types** (e.g., `neverthrow`, `ts-results`) — formalizes the pattern with `.map()`, `.unwrap()`, `.match()` methods. More ergonomic for chaining operations, but adds a library dependency and a layer of indirection. The project's use cases are simple enough (call domain function, check error, save or return) that raw unions are sufficient.
|
||||||
|
|
||||||
|
**Validation libraries** (Zod, io-ts) — useful for input parsing but don't cover domain logic errors like "combatant not found" or "no previous turn". Would only address a subset of the problem.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:**
|
||||||
|
- Error handling is compiler-enforced. Forgetting to check for an error produces a type error when accessing success fields.
|
||||||
|
- Domain functions remain pure — they return data, never alter control flow.
|
||||||
|
- Error codes are stable, machine-readable identifiers that UI code can match on.
|
||||||
|
- Testing is straightforward: assert the return value, no try/catch in tests.
|
||||||
|
|
||||||
|
**Negative:**
|
||||||
|
- Every call site must check `isDomainError()` before proceeding. This is slightly more verbose than a try/catch that wraps multiple calls.
|
||||||
|
- Composing multiple fallible operations requires manual chaining (check error, then call next function). A Result wrapper would make this more ergonomic if the codebase grows significantly.
|
||||||
|
- Contributors familiar with JavaScript conventions may initially find the pattern unfamiliar.
|
||||||
46
docs/adr/002-domain-events-as-plain-data.md
Normal file
46
docs/adr/002-domain-events-as-plain-data.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# ADR-002: Domain Events as Plain Data Objects
|
||||||
|
|
||||||
|
**Date**: 2026-03-25
|
||||||
|
**Status**: accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Domain state transitions need to communicate what happened (not just the new state) so the UI layer can react — showing toasts, auto-scrolling, opening panels, etc. The project needs an event mechanism that stays consistent with the pure, deterministic domain core.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Domain events are plain data objects with a `type` string discriminant. They form a discriminated union (`DomainEvent`) of 18 event types. Events are returned alongside the new state from domain functions, not emitted through a pub/sub system:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Example event
|
||||||
|
{ type: "TurnAdvanced", previousCombatantId: "abc", newCombatantId: "def", roundNumber: 2 }
|
||||||
|
|
||||||
|
// Domain function returns both state and events
|
||||||
|
function advanceTurn(encounter: Encounter): { encounter: Encounter; events: DomainEvent[] } | DomainError
|
||||||
|
```
|
||||||
|
|
||||||
|
Events are consumed ephemerally by the UI layer and are not persisted.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
**Class-based events** (e.g., `class TurnAdvanced extends DomainEvent { ... }`) — common in OOP-style domain-driven design. Adds inheritance hierarchies, constructors, and `instanceof` checks. No benefit here: TypeScript's discriminated union narrowing (`switch (event.type)`) provides the same exhaustiveness checking without classes. Classes also can't be serialized/deserialized without custom logic.
|
||||||
|
|
||||||
|
**Event emitter / pub-sub** (Node `EventEmitter`, custom bus, RxJS) — events are broadcast and listeners subscribe. Decouples producers from consumers, but introduces implicit coupling (who's listening?), ordering concerns, and makes the domain impure (emitting is a side effect). Harder to test — you'd need to set up listeners and collect results instead of just asserting on a return value.
|
||||||
|
|
||||||
|
**Observable streams** (RxJS) — powerful for async event processing and composition. Massive overkill for this use case: events are synchronous, produced one batch at a time, and consumed immediately. Would add a significant dependency and conceptual overhead.
|
||||||
|
|
||||||
|
**No events** (just compare old and new state) — the UI could diff states to determine what changed. Works for simple cases, but can't express intent (did HP drop because of damage or because max HP was lowered?) and gets unwieldy as the state model grows.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:**
|
||||||
|
- Events are serializable (JSON-compatible). If the project ever adds undo/redo or event logging, no changes to the event format are needed.
|
||||||
|
- TypeScript's `switch (event.type)` provides exhaustiveness checking — the compiler warns if a new event type is added but not handled.
|
||||||
|
- No framework coupling. Events are just data; any consumer (React, a test, a CLI) can process them identically.
|
||||||
|
- Domain functions remain pure — events are returned, not emitted.
|
||||||
|
- Testing is trivial: assert that `result.events` contains the expected objects.
|
||||||
|
|
||||||
|
**Negative:**
|
||||||
|
- Events are currently consumed and discarded. There is no event log, replay, or undo capability. The architecture supports it, but it's not built.
|
||||||
|
- Adding a new event type requires updating the `DomainEvent` union, which touches a central file. This is intentional (forces explicit acknowledgment) but adds friction.
|
||||||
|
- No built-in mechanism for event handlers to communicate back (e.g., "veto this event"). Events are informational, not transactional.
|
||||||
53
docs/adr/003-branded-types-for-identity.md
Normal file
53
docs/adr/003-branded-types-for-identity.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# ADR-003: Branded Types for Identity Safety
|
||||||
|
|
||||||
|
**Date**: 2026-03-25
|
||||||
|
**Status**: accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The domain model has multiple entity types with string-based identifiers: combatants, creatures, and player characters. All IDs are strings at runtime (UUIDs or slug-based), making it easy to accidentally pass one ID type where another is expected. Such bugs are silent — the code compiles, runs, and only fails at runtime when a lookup returns `undefined` or mutates the wrong entity.
|
||||||
|
|
||||||
|
TypeScript's structural type system treats all `string` values as interchangeable, so a plain `string` type alias provides no protection.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Identity types use TypeScript branded types — a `string` intersected with a phantom `readonly __brand` property that exists only at the type level:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type CombatantId = string & { readonly __brand: "CombatantId" };
|
||||||
|
type CreatureId = string & { readonly __brand: "CreatureId" };
|
||||||
|
type PlayerCharacterId = string & { readonly __brand: "PlayerCharacterId" };
|
||||||
|
```
|
||||||
|
|
||||||
|
Each type has a factory function that casts a plain string into the branded type:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function combatantId(id: string): CombatantId {
|
||||||
|
return id as CombatantId;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `__brand` property is never assigned at runtime — it's a compile-time-only construct. The cast in the factory is the single point where the type system is "convinced" that the string carries the brand.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
**Plain `string` type aliases** (`type CombatantId = string`) — provides documentation value but zero type safety. TypeScript treats the alias as fully interchangeable with `string` and with all other string aliases. This is what most TypeScript codebases do, accepting the risk of ID confusion.
|
||||||
|
|
||||||
|
**Opaque types via unique symbols** (`declare const brand: unique symbol; type CombatantId = string & { [brand]: void }`) — stricter than the `__brand` approach because the symbol is truly unique and unexportable. Slightly more boilerplate and harder to read. The simpler `__brand` string approach provides sufficient safety for this codebase's scale.
|
||||||
|
|
||||||
|
**Wrapper classes** (`class CombatantId { constructor(public readonly value: string) {} }`) — provides nominal typing naturally, but introduces runtime overhead (object allocation, `.value` access everywhere), breaks JSON serialization, and doesn't play well with the project's preference for plain data over classes.
|
||||||
|
|
||||||
|
**Runtime validation** (check ID format at every function boundary) — catches errors at runtime but not at compile time. Adds overhead and doesn't prevent the bug from being written in the first place.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:**
|
||||||
|
- Passing a `CreatureId` where a `CombatantId` is expected produces a compile-time error — the bug is caught before the code runs.
|
||||||
|
- Zero runtime cost. The brand is erased during compilation; at runtime, IDs are plain strings.
|
||||||
|
- JSON serialization works naturally — no custom serializers needed for persistence or network transport.
|
||||||
|
- Factory functions (`combatantId()`, `creatureId()`) serve as explicit construction points, making it clear where IDs originate.
|
||||||
|
|
||||||
|
**Negative:**
|
||||||
|
- The `as CombatantId` cast in factory functions is an escape hatch from the type system. If misused (casting arbitrary strings elsewhere), the safety guarantee is undermined. In practice, casts are confined to factory functions and adapter-layer deserialization.
|
||||||
|
- The `__brand` property appears in IDE autocomplete and hover tooltips, which can be confusing for developers unfamiliar with the pattern.
|
||||||
|
- Branded types are a community convention, not a TypeScript language feature. There is no official syntax or standard library support.
|
||||||
42
docs/adr/004-on-demand-bestiary-loading.md
Normal file
42
docs/adr/004-on-demand-bestiary-loading.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# ADR-004: On-Demand Bestiary Loading via Compact Index and IndexedDB Cache
|
||||||
|
|
||||||
|
**Date**: 2026-03-25
|
||||||
|
**Status**: accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The application integrates a D&D creature bestiary containing 3,300+ creatures from the 5etools dataset. The full bestiary data (stat blocks, traits, actions, spellcasting) is several megabytes of JSON. Bundling it directly into the application would create two problems: a large initial download for every user, and the distribution of copyrighted game content as part of the application bundle.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The bestiary is split into two tiers:
|
||||||
|
|
||||||
|
1. **Compact search index** (`data/bestiary/index.json`, ~350KB) — shipped with the application bundle. Contains only the fields needed for search and display in the autocomplete dropdown: name, source, AC, HP, DEX, CR, initiative proficiency, size, and type. Field names are abbreviated (`n`, `s`, `ac`, `hp`, `dx`, `cr`, `ip`, `sz`, `tp`) to minimize file size. Generated offline by `scripts/generate-bestiary-index.mjs` from a local clone of the 5etools repository.
|
||||||
|
|
||||||
|
2. **On-demand source data** — full creature stat blocks are fetched per-source when a user first needs them (e.g., when viewing a stat block or adding a creature with HP/AC pre-fill). Fetched data is cached in IndexedDB (`initiative-bestiary` database) via the `idb` library, with an in-memory Map fallback when IndexedDB is unavailable. Users can also upload source files directly or bulk-import all sources.
|
||||||
|
|
||||||
|
The application never bundles or redistributes the full creature data. Users fetch it themselves from their own configured source URLs.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
**Bundle all bestiary data** — simplest approach, used during early development. Eliminated because it would distribute copyrighted content in the application bundle and inflate the initial download by several megabytes. Most users only need a fraction of the available sources.
|
||||||
|
|
||||||
|
**Server-side API** — a backend service could serve creature data on demand. This would keep the client lightweight and solve the bundle size concern, but the copyright issue remains — we would still be distributing copyrighted content, just from a server instead of a bundle. It also contradicts the project's local-first, single-user, no-backend architecture and would require hosting infrastructure and a network dependency for basic functionality.
|
||||||
|
|
||||||
|
**Service Worker with lazy caching** — fetch and cache bestiary data transparently via a Service Worker. More complex to implement and debug than explicit IndexedDB caching. The explicit approach gives users visibility and control over which sources are cached (via the source manager UI).
|
||||||
|
|
||||||
|
**localStorage for caching** — simpler API than IndexedDB, but localStorage has a ~5MB limit per origin, which is insufficient for multiple bestiary sources. IndexedDB has no practical storage limit.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:**
|
||||||
|
- The application does not distribute copyrighted game content. Users fetch data from their own sources.
|
||||||
|
- Initial bundle stays small (~350KB for the search index). The full bestiary data is only downloaded when needed and then cached locally.
|
||||||
|
- Offline capability: once sources are cached in IndexedDB, creature data is available without network access.
|
||||||
|
- Users have explicit control over cached sources (import, clear, manage via UI).
|
||||||
|
|
||||||
|
**Negative:**
|
||||||
|
- First-time use requires fetching source data before full stat blocks are available. The bulk import feature mitigates this but requires an initial download.
|
||||||
|
- The search index must be regenerated manually when the upstream 5etools dataset changes. In practice this is infrequent (new D&D source books release a few times per year), so a manual process triggered by a new book release is sufficient at this scale.
|
||||||
|
- Two separate data representations (compact index vs full source) must be kept conceptually in sync. A creature that appears in the index but whose source hasn't been fetched will show limited information until the source is cached.
|
||||||
|
- IndexedDB adds adapter complexity (async API, database versioning, migration handling) compared to the synchronous localStorage used for encounter persistence.
|
||||||
58
docs/adr/005-all-quality-gates-at-pre-commit.md
Normal file
58
docs/adr/005-all-quality-gates-at-pre-commit.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# ADR-005: All Quality Gates at Pre-Commit
|
||||||
|
|
||||||
|
**Date**: 2026-03-25
|
||||||
|
**Status**: accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This project is developed primarily through agentic coding — AI coding agents generate and modify code under human supervision. Agents are highly productive but can drift from established conventions, introduce subtle style inconsistencies, or produce code that compiles but doesn't meet the project's quality standards.
|
||||||
|
|
||||||
|
The conventional approach in most software projects is to keep pre-commit hooks lightweight (formatting, maybe linting) and defer heavier checks (tests, type checking, coverage, copy-paste detection) to CI pipelines. This optimizes for developer speed at commit time.
|
||||||
|
|
||||||
|
However, when working with AI agents, the dynamics are different. Agents iterate quickly and can fix issues immediately — but only if they receive feedback immediately. A failing CI pipeline minutes later breaks the feedback loop: the agent's context has moved on, and the human must re-engage to address the failure.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
All quality gates run at pre-commit via Lefthook, as a single sequential `pnpm check` command. No gate may exist only as a CI step or as a manual process. The full gate sequence is:
|
||||||
|
|
||||||
|
1. `pnpm audit --audit-level=high` — security vulnerability scan
|
||||||
|
2. `knip` — unused code detection
|
||||||
|
3. `biome check .` — linting and formatting (50+ rules)
|
||||||
|
4. `oxlint --tsconfig ... --type-aware` — type-aware linting
|
||||||
|
5. `check-lint-ignores.mjs` — caps biome-ignore directives
|
||||||
|
6. `check-cn-classnames.mjs` — bans template-literal classNames
|
||||||
|
7. `check-component-props.mjs` — max 8 props per component
|
||||||
|
8. `tsc --build` — TypeScript type checking
|
||||||
|
9. `vitest run` — tests with per-path coverage thresholds
|
||||||
|
10. `jscpd` — copy-paste detection
|
||||||
|
|
||||||
|
Layer boundary enforcement runs as a Vitest test within step 9.
|
||||||
|
|
||||||
|
This takes ~8 seconds on the current codebase. Every commit is guaranteed to pass all checks.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
**Lightweight pre-commit, full checks in CI** — the industry default. Pre-commit runs only formatting and basic linting; tests, type checking, and coverage run in a CI pipeline. This is faster at commit time but creates a delayed feedback loop. For agentic coding workflows, this delay is costly: the agent produces a commit, moves on, and the CI failure arrives minutes later when context has shifted. The human must re-engage the agent with the failure context, losing the tight iteration loop.
|
||||||
|
|
||||||
|
**No pre-commit hooks, CI only** — maximum commit speed, all enforcement in CI. Risks accumulating multiple broken commits before issues surface. Particularly problematic with agents that commit frequently.
|
||||||
|
|
||||||
|
**Selective pre-commit (fast checks only)** — run formatting, linting, and type checking at pre-commit; defer tests and coverage to CI as a compromise. Still breaks the feedback loop for test failures and coverage regressions, which are the checks most likely to catch agent-introduced bugs.
|
||||||
|
|
||||||
|
**Per-change hooks (e.g., Claude Code hooks)** — run checks after every file edit or tool call, not just at commit time. This is an even tighter feedback loop than pre-commit: the agent learns about a violation seconds after introducing it, before more code is written on top of it. Claude Code supports hooks that trigger on events like `PostToolUse`, which could run linting or type checking after every file write.
|
||||||
|
|
||||||
|
However, running the full gate after every edit breaks test-driven workflows: writing a test before its implementation, or updating implementation before updating tests, produces intermediate states that legitimately fail type checking or tests. Scoping hooks to only fast, non-breaking checks (formatting, linting) would avoid this, but splits the gate into two tiers — adding complexity for unclear benefit when pre-commit already catches everything within ~8 seconds.
|
||||||
|
|
||||||
|
Pre-commit is the current sweet spot: tight enough that agents get feedback in the same context window, but not so tight that it interferes with red-green-refactor or incremental editing. Per-change hooks remain a future option if the codebase grows to a point where pre-commit becomes too slow.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:**
|
||||||
|
- Early backpressure in short feedback loops. Agents receive immediate, comprehensive feedback on every commit attempt. If a check fails, the agent can fix it in the same context window, maintaining continuity.
|
||||||
|
- Every commit on `main` is guaranteed to pass all quality gates. There is no state where "it compiled but the tests are broken" or "formatting drifted."
|
||||||
|
- No CI/local divergence. The same checks run everywhere, eliminating "works on my machine" or "CI caught something pre-commit didn't."
|
||||||
|
- Enforces discipline incrementally: each commit is small, clean, and complete rather than "I'll fix the tests later."
|
||||||
|
|
||||||
|
**Negative:**
|
||||||
|
- ~8 seconds per commit attempt. This is acceptable for the current codebase size but will grow with the test suite. If it exceeds ~15 seconds, selective pre-commit with CI for the rest may become necessary.
|
||||||
|
- Developers (or agents) cannot make quick "WIP" or "checkpoint" commits without passing all gates. This is intentional — every commit should be a valid state — but it prevents some workflows like committing broken code to switch branches.
|
||||||
|
- The sequential chain means a failure in step 1 (audit) prevents discovering failures in step 9 (tests). In practice, this rarely matters because failures are fixed immediately and the chain is re-run.
|
||||||
256
docs/agents/research/2026-03-24-rules-edition-settings-modal.md
Normal file
256
docs/agents/research/2026-03-24-rules-edition-settings-modal.md
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
---
|
||||||
|
date: "2026-03-24T10:22:04.341906+00:00"
|
||||||
|
git_commit: cfd4aef724487a681e425cedfa08f3e89255f91a
|
||||||
|
branch: main
|
||||||
|
topic: "Rules edition setting for condition tooltips + settings modal"
|
||||||
|
tags: [research, codebase, conditions, settings, theme, modal, issue-12]
|
||||||
|
status: complete
|
||||||
|
---
|
||||||
|
|
||||||
|
# Research: Rules Edition Setting for Condition Tooltips + Settings Modal
|
||||||
|
|
||||||
|
## Research Question
|
||||||
|
|
||||||
|
Map the codebase for implementing issue #12: a rules edition setting (5e 2014 / 5.5e 2024) that controls condition tooltip descriptions, delivered via a new settings modal that also absorbs the existing theme toggle. Target spec: `specs/003-combatant-state/spec.md` (stories CC-3, CC-8, FR-095–FR-102).
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The implementation touches five areas: (1) the domain condition definitions, (2) the tooltip rendering in two web components, (3) the kebab overflow menu in the action bar, (4) the theme system (hook + context), and (5) a new settings modal following existing `<dialog>` patterns. The localStorage persistence pattern is well-established with a consistent `"initiative:<key>"` convention. The context provider tree in `main.tsx` is the integration point for a new settings context.
|
||||||
|
|
||||||
|
## Detailed Findings
|
||||||
|
|
||||||
|
### 1. Condition Definitions and Tooltip Data Flow
|
||||||
|
|
||||||
|
**Domain layer** — `packages/domain/src/conditions.ts`
|
||||||
|
|
||||||
|
The `ConditionDefinition` interface (line 18) carries a single `description: string` field. The `CONDITION_DEFINITIONS` array (line 26) holds all 15 conditions as `readonly` objects with `id`, `label`, `description`, `iconName`, and `color`. This is the single source of truth for condition data.
|
||||||
|
|
||||||
|
Exported types: `ConditionId` (union of 15 string literals), `ConditionDefinition`, `CONDITION_DEFINITIONS`, `VALID_CONDITION_IDS`.
|
||||||
|
|
||||||
|
**Web layer — condition-tags.tsx** (`apps/web/src/components/condition-tags.tsx`)
|
||||||
|
|
||||||
|
- Line 69: Looks up definition via `CONDITION_DEFINITIONS.find((d) => d.id === condId)`
|
||||||
|
- Line 75: Passes tooltip content as `` `${def.label}: ${def.description}` `` — combines label + description into a single string
|
||||||
|
- This is the tooltip shown when hovering active condition icons in the combatant row
|
||||||
|
|
||||||
|
**Web layer — condition-picker.tsx** (`apps/web/src/components/condition-picker.tsx`)
|
||||||
|
|
||||||
|
- Line 119: Iterates `CONDITION_DEFINITIONS.map(...)` directly
|
||||||
|
- Line 125: Passes `content={def.description}` to Tooltip — description only, no label prefix
|
||||||
|
- This is the tooltip shown when hovering conditions in the dropdown picker
|
||||||
|
|
||||||
|
**Key observation:** Both components read `def.description` directly from the imported domain constant. To make descriptions edition-aware, either (a) the domain type needs dual descriptions and consumers select by edition, or (b) a higher-level hook resolves the correct description before passing to components.
|
||||||
|
|
||||||
|
### 2. Tooltip Component
|
||||||
|
|
||||||
|
**File:** `apps/web/src/components/ui/tooltip.tsx`
|
||||||
|
|
||||||
|
- Props: `content: string`, `children: ReactNode`, optional `className`
|
||||||
|
- Positioning: Uses `getBoundingClientRect()` to place tooltip 4px above the trigger element, centered horizontally
|
||||||
|
- Rendered via `createPortal` to `document.body` at z-index 60
|
||||||
|
- Max width: `max-w-64` (256px / 16rem) with `text-xs leading-snug`
|
||||||
|
- Text wraps naturally within the max-width constraint — no explicit truncation
|
||||||
|
- The tooltip accepts only `string` content, not ReactNode
|
||||||
|
|
||||||
|
The current descriptions are short (1-2 sentences). The 5e (2014) exhaustion description will be longer (6-level table as text), which may benefit from the existing 256px wrapping. No changes to the tooltip component itself should be needed.
|
||||||
|
|
||||||
|
### 3. Kebab Menu (Overflow Menu)
|
||||||
|
|
||||||
|
**OverflowMenu component** — `apps/web/src/components/ui/overflow-menu.tsx`
|
||||||
|
|
||||||
|
- Generic menu component accepting `items: readonly OverflowMenuItem[]`
|
||||||
|
- Each item has: `icon: ReactNode`, `label: string`, `onClick: () => void`, optional `disabled` and `keepOpen`
|
||||||
|
- Opens upward (`bottom-full`) from the kebab button, right-aligned
|
||||||
|
- Close on click-outside (mousedown) and Escape key
|
||||||
|
|
||||||
|
**ActionBar integration** — `apps/web/src/components/action-bar.tsx`
|
||||||
|
|
||||||
|
- `buildOverflowItems()` function (line 231) constructs the menu items array
|
||||||
|
- Current items in order:
|
||||||
|
1. **Player Characters** (Users icon) — calls `opts.onManagePlayers`
|
||||||
|
2. **Manage Sources** (Library icon) — calls `opts.onOpenSourceManager`
|
||||||
|
3. **Import All Sources** (Import icon) — conditional on bestiary loaded
|
||||||
|
4. **Theme cycle** (Monitor/Sun/Moon icon) — calls `opts.onCycleTheme`, uses `keepOpen: true`
|
||||||
|
- Theme constants at lines 219-229: `THEME_ICONS` and `THEME_LABELS` maps
|
||||||
|
- Line 293: `useThemeContext()` provides `preference` and `cycleTheme`
|
||||||
|
- Line 529-537: Overflow items built with all options passed in
|
||||||
|
|
||||||
|
**To add a "Settings" item:** Add a new entry to `buildOverflowItems()` and remove the theme cycle entry. The new item would call a callback to open the settings modal.
|
||||||
|
|
||||||
|
### 4. Theme System
|
||||||
|
|
||||||
|
**Hook** — `apps/web/src/hooks/use-theme.ts`
|
||||||
|
|
||||||
|
- Module-level state: `currentPreference` initialized from localStorage on import (line 9)
|
||||||
|
- `ThemePreference` type: `"system" | "light" | "dark"`
|
||||||
|
- `ResolvedTheme` type: `"light" | "dark"`
|
||||||
|
- Storage key: `"initiative:theme"` (line 6)
|
||||||
|
- `loadPreference()` — reads localStorage, defaults to `"system"` (lines 11-19)
|
||||||
|
- `savePreference()` — writes to localStorage, silent on error (lines 21-27)
|
||||||
|
- `resolve()` — resolves "system" via `matchMedia("(prefers-color-scheme: light)")` (lines 29-38)
|
||||||
|
- `applyTheme()` — sets `document.documentElement.dataset.theme` (lines 40-42)
|
||||||
|
- Uses `useSyncExternalStore` for React integration (line 77)
|
||||||
|
- Exposes: `preference`, `resolved`, `setPreference`, `cycleTheme`
|
||||||
|
- OS preference change listener updates theme when preference is "system" (lines 54-63)
|
||||||
|
|
||||||
|
**Context** — `apps/web/src/contexts/theme-context.tsx`
|
||||||
|
|
||||||
|
- Simple wrapper: `ThemeProvider` calls `useTheme()` and provides via React context
|
||||||
|
- `useThemeContext()` hook for consumers (line 15)
|
||||||
|
|
||||||
|
**For settings modal:** The theme system already exposes `setPreference(pref)` which is exactly what the settings modal needs — direct selection instead of cycling.
|
||||||
|
|
||||||
|
### 5. localStorage Persistence Patterns
|
||||||
|
|
||||||
|
All storage follows a consistent pattern:
|
||||||
|
|
||||||
|
| Key | Content | Format |
|
||||||
|
|-----|---------|--------|
|
||||||
|
| `initiative:encounter` | Full encounter state | JSON object |
|
||||||
|
| `initiative:player-characters` | Player character array | JSON array |
|
||||||
|
| `initiative:theme` | Theme preference | Plain string |
|
||||||
|
|
||||||
|
**Common patterns:**
|
||||||
|
- Read: `try { localStorage.getItem(key) } catch { return default }`
|
||||||
|
- Write: `try { localStorage.setItem(key, value) } catch { /* silent */ }`
|
||||||
|
- Validation on read: type-check, range-check, reject invalid, return fallback
|
||||||
|
- Bootstrap: `useState(initializeFunction)` where initializer loads from storage
|
||||||
|
- Persistence: `useEffect([data], () => saveToStorage(data))`
|
||||||
|
|
||||||
|
**For rules edition:** Key would be `"initiative:rules-edition"`. Value would be a plain string (`"5e"` or `"5.5e"`), matching the theme pattern (simple string, not JSON). Default: `"5.5e"`.
|
||||||
|
|
||||||
|
### 6. Modal Patterns
|
||||||
|
|
||||||
|
Two modal implementations exist, both using HTML `<dialog>`:
|
||||||
|
|
||||||
|
**PlayerManagement** (`apps/web/src/components/player-management.tsx`)
|
||||||
|
- Controlled by `open` prop
|
||||||
|
- `useEffect` calls `dialog.showModal()` / `dialog.close()` based on `open`
|
||||||
|
- Cancel event (Escape) prevented and routed to `onClose`
|
||||||
|
- Backdrop click (mousedown on dialog element itself) routes to `onClose`
|
||||||
|
- Styling: `card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50`
|
||||||
|
- Header: title + X close button (ghost variant, muted foreground)
|
||||||
|
|
||||||
|
**CreatePlayerModal** (`apps/web/src/components/create-player-modal.tsx`)
|
||||||
|
- Same `<dialog>` pattern with identical open/close/cancel/backdrop handling
|
||||||
|
- Has form submission with validation and error display
|
||||||
|
- Same styling as PlayerManagement
|
||||||
|
|
||||||
|
**Shared dialog pattern (extract from both):**
|
||||||
|
```tsx
|
||||||
|
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;
|
||||||
|
const handleCancel = (e: Event) => { e.preventDefault(); onClose(); };
|
||||||
|
const handleBackdropClick = (e: MouseEvent) => { if (e.target === dialog) onClose(); };
|
||||||
|
dialog.addEventListener("cancel", handleCancel);
|
||||||
|
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||||
|
return () => { /* cleanup */ };
|
||||||
|
}, [onClose]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Context Provider Tree
|
||||||
|
|
||||||
|
**File:** `apps/web/src/main.tsx`
|
||||||
|
|
||||||
|
Provider nesting order (outermost first):
|
||||||
|
1. `ThemeProvider`
|
||||||
|
2. `EncounterProvider`
|
||||||
|
3. `BestiaryProvider`
|
||||||
|
4. `PlayerCharactersProvider`
|
||||||
|
5. `BulkImportProvider`
|
||||||
|
6. `SidePanelProvider`
|
||||||
|
7. `InitiativeRollsProvider`
|
||||||
|
|
||||||
|
A new `SettingsProvider` (or `RulesEditionProvider`) would slot in early — before any component that reads condition descriptions. Since `ThemeProvider` is already the outermost, and the settings modal manages both theme and rules edition, one option is a `SettingsProvider` that wraps or replaces `ThemeProvider`.
|
||||||
|
|
||||||
|
### 8. 5e vs 5.5e Condition Text Differences
|
||||||
|
|
||||||
|
Based on research, here are the conditions with meaningful mechanical differences between editions. Conditions not listed are functionally identical across editions.
|
||||||
|
|
||||||
|
**Major changes:**
|
||||||
|
|
||||||
|
| Condition | 5e (2014) | 5.5e (2024) — current text |
|
||||||
|
|---|---|---|
|
||||||
|
| **Exhaustion** | 6 escalating levels: L1 disadvantage on ability checks, L2 speed halved, L3 disadvantage on attacks/saves, L4 HP max halved, L5 speed 0, L6 death | −level from d20 tests and spell save DCs. Speed reduced by 5 ft. × level. Death at 10 levels. (current) |
|
||||||
|
| **Grappled** | Speed 0. Ends if grappler incapacitated or moved out of reach. | Speed 0, can't benefit from speed bonuses. Ends if grappler incapacitated or moved out of reach. (current — but 2024 also adds disadvantage on attacks vs non-grappler) |
|
||||||
|
| **Invisible** | Can't be seen without magic/special sense. Heavily obscured. Advantage on attacks; disadvantage on attacks against. | 2024 broadened: can be gained from Hide action; grants Surprise (advantage on initiative), Concealed (unaffected by sight effects), attacks advantage/disadvantage. (current text is closer to 2014) |
|
||||||
|
| **Stunned** | Incapacitated. Can't move. Speak falteringly. Auto-fail Str/Dex saves. Attacks against have advantage. | 2024: same but can still move (controversial). (current text says "Can't move" — matches 2014) |
|
||||||
|
|
||||||
|
**Moderate changes:**
|
||||||
|
|
||||||
|
| Condition | 5e (2014) | 5.5e (2024) |
|
||||||
|
|---|---|---|
|
||||||
|
| **Incapacitated** | Can't take actions or reactions. | Can't take actions, bonus actions, or reactions. Speed 0. Auto-fail Str/Dex saves. Attacks against have advantage. Concentration broken. (current is partial 2024) |
|
||||||
|
| **Petrified** | Unaware of surroundings. | Aware of surroundings (2024 change). Current text doesn't mention awareness. |
|
||||||
|
| **Poisoned** | Disadvantage on attacks and ability checks. | Same, but 2024 consolidates disease into poisoned. |
|
||||||
|
|
||||||
|
**Minor/identical:**
|
||||||
|
|
||||||
|
Blinded, Charmed ("harmful" → "damaging"), Deafened, Frightened, Paralyzed, Prone, Restrained, Unconscious — functionally identical between editions.
|
||||||
|
|
||||||
|
**Note on current descriptions:** The existing `conditions.ts` descriptions are a mix — exhaustion is clearly 2024, but stunned says "Can't move" which matches 2014. A full audit of each description against both editions will be needed during implementation to ensure accuracy.
|
||||||
|
|
||||||
|
## Code References
|
||||||
|
|
||||||
|
- `packages/domain/src/conditions.ts:18-24` — `ConditionDefinition` interface (single `description` field)
|
||||||
|
- `packages/domain/src/conditions.ts:26-145` — `CONDITION_DEFINITIONS` array with current (mixed edition) descriptions
|
||||||
|
- `apps/web/src/components/condition-tags.tsx:75` — Tooltip with `${def.label}: ${def.description}`
|
||||||
|
- `apps/web/src/components/condition-picker.tsx:125` — Tooltip with `def.description`
|
||||||
|
- `apps/web/src/components/ui/tooltip.tsx:1-55` — Tooltip component (string content, 256px max-width)
|
||||||
|
- `apps/web/src/components/ui/overflow-menu.tsx:1-73` — Generic overflow menu
|
||||||
|
- `apps/web/src/components/action-bar.tsx:231-274` — `buildOverflowItems()` (current menu items)
|
||||||
|
- `apps/web/src/components/action-bar.tsx:293` — `useThemeContext()` usage in ActionBar
|
||||||
|
- `apps/web/src/hooks/use-theme.ts:1-98` — Theme hook with localStorage, `useSyncExternalStore`, cycle/set
|
||||||
|
- `apps/web/src/contexts/theme-context.tsx:1-19` — Theme context provider
|
||||||
|
- `apps/web/src/main.tsx:17-35` — Provider nesting order
|
||||||
|
- `apps/web/src/components/player-management.tsx:55-131` — `<dialog>` modal pattern (reference for settings modal)
|
||||||
|
- `apps/web/src/components/create-player-modal.tsx:106-191` — Form-based `<dialog>` modal pattern
|
||||||
|
- `apps/web/src/persistence/encounter-storage.ts` — localStorage persistence pattern (read/write/validate)
|
||||||
|
- `apps/web/src/persistence/player-character-storage.ts` — localStorage persistence pattern
|
||||||
|
|
||||||
|
## Architecture Documentation
|
||||||
|
|
||||||
|
### Data Flow: Condition Description → Tooltip
|
||||||
|
|
||||||
|
```
|
||||||
|
Domain: CONDITION_DEFINITIONS[].description (single string)
|
||||||
|
↓ imported by
|
||||||
|
Web: condition-tags.tsx → Tooltip content={`${label}: ${description}`}
|
||||||
|
Web: condition-picker.tsx → Tooltip content={description}
|
||||||
|
↓ rendered by
|
||||||
|
UI: tooltip.tsx → createPortal → fixed-position div (max-w-64)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Settings/Preference Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
localStorage → use-theme.ts (useSyncExternalStore) → theme-context.tsx → consumers
|
||||||
|
localStorage → encounter-storage.ts → use-encounter.ts (useState) → encounter-context.tsx
|
||||||
|
localStorage → player-character-storage.ts → use-player-characters.ts (useState) → pc-context.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modal Triggering Pattern
|
||||||
|
|
||||||
|
```
|
||||||
|
ActionBar overflow menu item click
|
||||||
|
→ callback prop (e.g., onManagePlayers)
|
||||||
|
→ App.tsx calls imperative handle (e.g., playerCharacterRef.current.openManagement())
|
||||||
|
→ Section component sets open state
|
||||||
|
→ <dialog>.showModal() via useEffect
|
||||||
|
```
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Current description accuracy:** The existing descriptions are a mix of 2014 and 2024 text (e.g., exhaustion is 2024, stunned "Can't move" is 2014). Both sets of descriptions need careful authoring against official sources during implementation.
|
||||||
|
2. **Domain type change:** Should `ConditionDefinition` carry `description5e` and `description55e` fields, or should description resolution happen at the application/web layer? The domain-level approach is simpler and keeps the data co-located with condition definitions.
|
||||||
|
3. **Settings context scope:** Should a new `SettingsProvider` manage both rules edition and theme, or should rules edition be its own context? The theme system already has its own well-structured hook/context; combining them may add unnecessary coupling.
|
||||||
19
package.json
19
package.json
@@ -1,21 +1,22 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.6.0",
|
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"undici": ">=7.24.0"
|
"undici": ">=7.24.0",
|
||||||
|
"picomatch": ">=4.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.7",
|
"@biomejs/biome": "2.4.8",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^4.1.0",
|
||||||
"jscpd": "^4.0.8",
|
"jscpd": "^4.0.8",
|
||||||
"knip": "^5.85.0",
|
"knip": "^5.88.1",
|
||||||
"lefthook": "^1.11.0",
|
"lefthook": "^2.1.4",
|
||||||
"oxlint": "^1.55.0",
|
"oxlint": "^1.56.0",
|
||||||
"oxlint-tsgolint": "^0.16.0",
|
"oxlint-tsgolint": "^0.17.1",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^4.1.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "lefthook install",
|
"prepare": "lefthook install",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
addCombatant,
|
addCombatant,
|
||||||
type CombatantId,
|
type CombatantId,
|
||||||
|
type CombatantInit,
|
||||||
type DomainError,
|
type DomainError,
|
||||||
type DomainEvent,
|
type DomainEvent,
|
||||||
isDomainError,
|
isDomainError,
|
||||||
@@ -11,9 +12,10 @@ export function addCombatantUseCase(
|
|||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
id: CombatantId,
|
id: CombatantId,
|
||||||
name: string,
|
name: string,
|
||||||
|
init?: CombatantInit,
|
||||||
): DomainEvent[] | DomainError {
|
): DomainEvent[] | DomainError {
|
||||||
const encounter = store.get();
|
const encounter = store.get();
|
||||||
const result = addCombatant(encounter, id, name);
|
const result = addCombatant(encounter, id, name, init);
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export function createPlayerCharacterUseCase(
|
|||||||
maxHp: number,
|
maxHp: number,
|
||||||
color: string | undefined,
|
color: string | undefined,
|
||||||
icon: string | undefined,
|
icon: string | undefined,
|
||||||
|
level?: number,
|
||||||
): DomainEvent[] | DomainError {
|
): DomainEvent[] | DomainError {
|
||||||
const characters = store.getAll();
|
const characters = store.getAll();
|
||||||
const result = createPlayerCharacter(
|
const result = createPlayerCharacter(
|
||||||
@@ -25,6 +26,7 @@ export function createPlayerCharacterUseCase(
|
|||||||
maxHp,
|
maxHp,
|
||||||
color,
|
color,
|
||||||
icon,
|
icon,
|
||||||
|
level,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
|
|||||||
@@ -13,6 +13,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 editPlayerCharacterUseCase(
|
export function editPlayerCharacterUseCase(
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ export type {
|
|||||||
BestiarySourceCache,
|
BestiarySourceCache,
|
||||||
EncounterStore,
|
EncounterStore,
|
||||||
PlayerCharacterStore,
|
PlayerCharacterStore,
|
||||||
|
UndoRedoStore,
|
||||||
} from "./ports.js";
|
} from "./ports.js";
|
||||||
|
export { redoUseCase } from "./redo-use-case.js";
|
||||||
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
||||||
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
|
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
|
||||||
export {
|
export {
|
||||||
@@ -21,5 +23,7 @@ export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
|
|||||||
export { setAcUseCase } from "./set-ac-use-case.js";
|
export { setAcUseCase } from "./set-ac-use-case.js";
|
||||||
export { setHpUseCase } from "./set-hp-use-case.js";
|
export { setHpUseCase } from "./set-hp-use-case.js";
|
||||||
export { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
export { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
||||||
|
export { setTempHpUseCase } from "./set-temp-hp-use-case.js";
|
||||||
export { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js";
|
export { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js";
|
||||||
export { toggleConditionUseCase } from "./toggle-condition-use-case.js";
|
export { toggleConditionUseCase } from "./toggle-condition-use-case.js";
|
||||||
|
export { undoUseCase } from "./undo-use-case.js";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
CreatureId,
|
CreatureId,
|
||||||
Encounter,
|
Encounter,
|
||||||
PlayerCharacter,
|
PlayerCharacter,
|
||||||
|
UndoRedoState,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
|
|
||||||
export interface EncounterStore {
|
export interface EncounterStore {
|
||||||
@@ -19,3 +20,8 @@ export interface PlayerCharacterStore {
|
|||||||
getAll(): PlayerCharacter[];
|
getAll(): PlayerCharacter[];
|
||||||
save(characters: PlayerCharacter[]): void;
|
save(characters: PlayerCharacter[]): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UndoRedoStore {
|
||||||
|
get(): UndoRedoState;
|
||||||
|
save(state: UndoRedoState): void;
|
||||||
|
}
|
||||||
|
|||||||
24
packages/application/src/redo-use-case.ts
Normal file
24
packages/application/src/redo-use-case.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import {
|
||||||
|
type DomainError,
|
||||||
|
type Encounter,
|
||||||
|
isDomainError,
|
||||||
|
redo,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore, UndoRedoStore } from "./ports.js";
|
||||||
|
|
||||||
|
export function redoUseCase(
|
||||||
|
encounterStore: EncounterStore,
|
||||||
|
undoRedoStore: UndoRedoStore,
|
||||||
|
): Encounter | DomainError {
|
||||||
|
const current = encounterStore.get();
|
||||||
|
const state = undoRedoStore.get();
|
||||||
|
const result = redo(state, current);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
encounterStore.save(result.encounter);
|
||||||
|
undoRedoStore.save(result.state);
|
||||||
|
return result.encounter;
|
||||||
|
}
|
||||||
24
packages/application/src/set-temp-hp-use-case.ts
Normal file
24
packages/application/src/set-temp-hp-use-case.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
isDomainError,
|
||||||
|
setTempHp,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
|
||||||
|
export function setTempHpUseCase(
|
||||||
|
store: EncounterStore,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
tempHp: number | undefined,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
const encounter = store.get();
|
||||||
|
const result = setTempHp(encounter, combatantId, tempHp);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.save(result.encounter);
|
||||||
|
return result.events;
|
||||||
|
}
|
||||||
24
packages/application/src/undo-use-case.ts
Normal file
24
packages/application/src/undo-use-case.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import {
|
||||||
|
type DomainError,
|
||||||
|
type Encounter,
|
||||||
|
isDomainError,
|
||||||
|
undo,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore, UndoRedoStore } from "./ports.js";
|
||||||
|
|
||||||
|
export function undoUseCase(
|
||||||
|
encounterStore: EncounterStore,
|
||||||
|
undoRedoStore: UndoRedoStore,
|
||||||
|
): Encounter | DomainError {
|
||||||
|
const current = encounterStore.get();
|
||||||
|
const state = undoRedoStore.get();
|
||||||
|
const result = undo(state, current);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
encounterStore.save(result.encounter);
|
||||||
|
undoRedoStore.save(result.state);
|
||||||
|
return result.encounter;
|
||||||
|
}
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { addCombatant } from "../add-combatant.js";
|
import { addCombatant, type CombatantInit } from "../add-combatant.js";
|
||||||
|
import { creatureId } from "../creature-types.js";
|
||||||
|
import { playerCharacterId } from "../player-character-types.js";
|
||||||
import type { Combatant, Encounter } from "../types.js";
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
import { combatantId, isDomainError } from "../types.js";
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
import { expectDomainError } from "./test-helpers.js";
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
function makeCombatant(name: string): Combatant {
|
function makeCombatant(
|
||||||
return { id: combatantId(name), name };
|
name: string,
|
||||||
|
overrides?: Partial<Combatant>,
|
||||||
|
): Combatant {
|
||||||
|
return { id: combatantId(name), name, ...overrides };
|
||||||
}
|
}
|
||||||
|
|
||||||
const A = makeCombatant("A");
|
const A = makeCombatant("A");
|
||||||
@@ -22,8 +27,13 @@ function enc(
|
|||||||
return { combatants, activeIndex, roundNumber };
|
return { combatants, activeIndex, roundNumber };
|
||||||
}
|
}
|
||||||
|
|
||||||
function successResult(encounter: Encounter, id: string, name: string) {
|
function successResult(
|
||||||
const result = addCombatant(encounter, combatantId(id), name);
|
encounter: Encounter,
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
init?: CombatantInit,
|
||||||
|
) {
|
||||||
|
const result = addCombatant(encounter, combatantId(id), name, init);
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
throw new Error(`Expected success, got error: ${result.message}`);
|
throw new Error(`Expected success, got error: ${result.message}`);
|
||||||
}
|
}
|
||||||
@@ -190,4 +200,152 @@ describe("addCombatant", () => {
|
|||||||
expect(encounter.combatants[1]).toEqual(B);
|
expect(encounter.combatants[1]).toEqual(B);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("with CombatantInit", () => {
|
||||||
|
it("creates combatant with maxHp and currentHp set to maxHp", () => {
|
||||||
|
const e = enc([]);
|
||||||
|
const { encounter } = successResult(e, "orc", "Orc", {
|
||||||
|
maxHp: 15,
|
||||||
|
});
|
||||||
|
const c = encounter.combatants[0];
|
||||||
|
expect(c.maxHp).toBe(15);
|
||||||
|
expect(c.currentHp).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates combatant with ac", () => {
|
||||||
|
const e = enc([]);
|
||||||
|
const { encounter } = successResult(e, "orc", "Orc", {
|
||||||
|
ac: 13,
|
||||||
|
});
|
||||||
|
expect(encounter.combatants[0].ac).toBe(13);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates combatant with initiative and sorts into position", () => {
|
||||||
|
const hi = makeCombatant("Hi", { initiative: 20 });
|
||||||
|
const lo = makeCombatant("Lo", { initiative: 10 });
|
||||||
|
const e = enc([hi, lo]);
|
||||||
|
|
||||||
|
const { encounter } = successResult(e, "mid", "Mid", {
|
||||||
|
initiative: 15,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(encounter.combatants.map((c) => c.name)).toEqual([
|
||||||
|
"Hi",
|
||||||
|
"Mid",
|
||||||
|
"Lo",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid maxHp (non-integer)", () => {
|
||||||
|
const e = enc([]);
|
||||||
|
const result = addCombatant(e, combatantId("x"), "X", {
|
||||||
|
maxHp: 1.5,
|
||||||
|
});
|
||||||
|
expectDomainError(result, "invalid-max-hp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid maxHp (zero)", () => {
|
||||||
|
const e = enc([]);
|
||||||
|
const result = addCombatant(e, combatantId("x"), "X", {
|
||||||
|
maxHp: 0,
|
||||||
|
});
|
||||||
|
expectDomainError(result, "invalid-max-hp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid ac (negative)", () => {
|
||||||
|
const e = enc([]);
|
||||||
|
const result = addCombatant(e, combatantId("x"), "X", {
|
||||||
|
ac: -1,
|
||||||
|
});
|
||||||
|
expectDomainError(result, "invalid-ac");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid initiative (non-integer)", () => {
|
||||||
|
const e = enc([]);
|
||||||
|
const result = addCombatant(e, combatantId("x"), "X", {
|
||||||
|
initiative: 3.5,
|
||||||
|
});
|
||||||
|
expectDomainError(result, "invalid-initiative");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates combatant with creatureId", () => {
|
||||||
|
const e = enc([]);
|
||||||
|
const cId = creatureId("srd:goblin");
|
||||||
|
const { encounter } = successResult(e, "gob", "Goblin", {
|
||||||
|
creatureId: cId,
|
||||||
|
});
|
||||||
|
expect(encounter.combatants[0].creatureId).toBe(cId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates combatant with color and icon", () => {
|
||||||
|
const e = enc([]);
|
||||||
|
const { encounter } = successResult(e, "pc", "Aria", {
|
||||||
|
color: "blue",
|
||||||
|
icon: "sword",
|
||||||
|
});
|
||||||
|
const c = encounter.combatants[0];
|
||||||
|
expect(c.color).toBe("blue");
|
||||||
|
expect(c.icon).toBe("sword");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates combatant with playerCharacterId", () => {
|
||||||
|
const e = enc([]);
|
||||||
|
const pcId = playerCharacterId("pc-1");
|
||||||
|
const { encounter } = successResult(e, "pc", "Aria", {
|
||||||
|
playerCharacterId: pcId,
|
||||||
|
});
|
||||||
|
expect(encounter.combatants[0].playerCharacterId).toBe(pcId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates combatant with all init fields", () => {
|
||||||
|
const e = enc([]);
|
||||||
|
const cId = creatureId("srd:orc");
|
||||||
|
const pcId = playerCharacterId("pc-1");
|
||||||
|
const { encounter } = successResult(e, "orc", "Orc", {
|
||||||
|
maxHp: 15,
|
||||||
|
ac: 13,
|
||||||
|
initiative: 12,
|
||||||
|
creatureId: cId,
|
||||||
|
color: "red",
|
||||||
|
icon: "axe",
|
||||||
|
playerCharacterId: pcId,
|
||||||
|
});
|
||||||
|
const c = encounter.combatants[0];
|
||||||
|
expect(c.maxHp).toBe(15);
|
||||||
|
expect(c.currentHp).toBe(15);
|
||||||
|
expect(c.ac).toBe(13);
|
||||||
|
expect(c.initiative).toBe(12);
|
||||||
|
expect(c.creatureId).toBe(cId);
|
||||||
|
expect(c.color).toBe("red");
|
||||||
|
expect(c.icon).toBe("axe");
|
||||||
|
expect(c.playerCharacterId).toBe(pcId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CombatantAdded event includes init", () => {
|
||||||
|
const e = enc([]);
|
||||||
|
const { events } = successResult(e, "orc", "Orc", {
|
||||||
|
maxHp: 15,
|
||||||
|
ac: 13,
|
||||||
|
});
|
||||||
|
expect(events[0]).toMatchObject({
|
||||||
|
type: "CombatantAdded",
|
||||||
|
init: { maxHp: 15, ac: 13 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves activeIndex through initiative sort", () => {
|
||||||
|
const hi = makeCombatant("Hi", { initiative: 20 });
|
||||||
|
const lo = makeCombatant("Lo", { initiative: 10 });
|
||||||
|
// Lo is active (index 1)
|
||||||
|
const e = enc([hi, lo], 1);
|
||||||
|
|
||||||
|
const { encounter } = successResult(e, "mid", "Mid", {
|
||||||
|
initiative: 15,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lo should still be active after sort
|
||||||
|
const loIdx = encounter.combatants.findIndex((c) => c.name === "Lo");
|
||||||
|
expect(encounter.activeIndex).toBe(loIdx);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,12 +6,18 @@ import { expectDomainError } from "./test-helpers.js";
|
|||||||
|
|
||||||
function makeCombatant(
|
function makeCombatant(
|
||||||
name: string,
|
name: string,
|
||||||
opts?: { maxHp: number; currentHp: number },
|
opts?: { maxHp: number; currentHp: number; tempHp?: number },
|
||||||
): Combatant {
|
): Combatant {
|
||||||
return {
|
return {
|
||||||
id: combatantId(name),
|
id: combatantId(name),
|
||||||
name,
|
name,
|
||||||
...(opts ? { maxHp: opts.maxHp, currentHp: opts.currentHp } : {}),
|
...(opts
|
||||||
|
? {
|
||||||
|
maxHp: opts.maxHp,
|
||||||
|
currentHp: opts.currentHp,
|
||||||
|
tempHp: opts.tempHp,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,4 +158,96 @@ describe("adjustHp", () => {
|
|||||||
expect(encounter.combatants[0].currentHp).toBe(5);
|
expect(encounter.combatants[0].currentHp).toBe(5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("temporary HP absorption", () => {
|
||||||
|
it("damage fully absorbed by temp HP — currentHp unchanged", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 8 }),
|
||||||
|
]);
|
||||||
|
const { encounter } = successResult(e, "A", -5);
|
||||||
|
expect(encounter.combatants[0].currentHp).toBe(15);
|
||||||
|
expect(encounter.combatants[0].tempHp).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("damage partially absorbed by temp HP — overflow reduces currentHp", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 3 }),
|
||||||
|
]);
|
||||||
|
const { encounter } = successResult(e, "A", -10);
|
||||||
|
expect(encounter.combatants[0].tempHp).toBeUndefined();
|
||||||
|
expect(encounter.combatants[0].currentHp).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("damage exceeding both temp HP and currentHp — both reach minimum", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 5, tempHp: 3 }),
|
||||||
|
]);
|
||||||
|
const { encounter } = successResult(e, "A", -50);
|
||||||
|
expect(encounter.combatants[0].tempHp).toBeUndefined();
|
||||||
|
expect(encounter.combatants[0].currentHp).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("healing does not restore temp HP", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 3 }),
|
||||||
|
]);
|
||||||
|
const { encounter } = successResult(e, "A", 5);
|
||||||
|
expect(encounter.combatants[0].currentHp).toBe(15);
|
||||||
|
expect(encounter.combatants[0].tempHp).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("temp HP cleared to undefined when fully depleted", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 5 }),
|
||||||
|
]);
|
||||||
|
const { encounter } = successResult(e, "A", -5);
|
||||||
|
expect(encounter.combatants[0].tempHp).toBeUndefined();
|
||||||
|
expect(encounter.combatants[0].currentHp).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits only TempHpSet when damage fully absorbed", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 8 }),
|
||||||
|
]);
|
||||||
|
const { events } = successResult(e, "A", -3);
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "TempHpSet",
|
||||||
|
combatantId: combatantId("A"),
|
||||||
|
previousTempHp: 8,
|
||||||
|
newTempHp: 5,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits both TempHpSet and CurrentHpAdjusted when damage overflows", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 3 }),
|
||||||
|
]);
|
||||||
|
const { events } = successResult(e, "A", -10);
|
||||||
|
expect(events).toHaveLength(2);
|
||||||
|
expect(events[0]).toEqual({
|
||||||
|
type: "TempHpSet",
|
||||||
|
combatantId: combatantId("A"),
|
||||||
|
previousTempHp: 3,
|
||||||
|
newTempHp: undefined,
|
||||||
|
});
|
||||||
|
expect(events[1]).toEqual({
|
||||||
|
type: "CurrentHpAdjusted",
|
||||||
|
combatantId: combatantId("A"),
|
||||||
|
previousHp: 15,
|
||||||
|
newHp: 8,
|
||||||
|
delta: -10,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("damage with no temp HP works as before", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
|
||||||
|
const { encounter, events } = successResult(e, "A", -5);
|
||||||
|
expect(encounter.combatants[0].currentHp).toBe(10);
|
||||||
|
expect(encounter.combatants[0].tempHp).toBeUndefined();
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].type).toBe("CurrentHpAdjusted");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
82
packages/domain/src/__tests__/conditions.test.ts
Normal file
82
packages/domain/src/__tests__/conditions.test.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
CONDITION_DEFINITIONS,
|
||||||
|
getConditionDescription,
|
||||||
|
getConditionsForEdition,
|
||||||
|
} from "../conditions.js";
|
||||||
|
|
||||||
|
function findCondition(id: string) {
|
||||||
|
const def = CONDITION_DEFINITIONS.find((d) => d.id === id);
|
||||||
|
if (!def) throw new Error(`Condition ${id} not found`);
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("getConditionDescription", () => {
|
||||||
|
it("returns 5.5e description by default", () => {
|
||||||
|
const exhaustion = findCondition("exhaustion");
|
||||||
|
expect(getConditionDescription(exhaustion, "5.5e")).toBe(
|
||||||
|
exhaustion.description,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 5e description when edition is 5e", () => {
|
||||||
|
const exhaustion = findCondition("exhaustion");
|
||||||
|
expect(getConditionDescription(exhaustion, "5e")).toBe(
|
||||||
|
exhaustion.description5e,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("universal conditions have both descriptions", () => {
|
||||||
|
const universal = CONDITION_DEFINITIONS.filter(
|
||||||
|
(d) => d.edition === undefined,
|
||||||
|
);
|
||||||
|
expect(universal.length).toBeGreaterThan(0);
|
||||||
|
for (const def of universal) {
|
||||||
|
expect(def.description).toBeTruthy();
|
||||||
|
expect(def.description5e).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("edition-specific conditions have their edition description", () => {
|
||||||
|
const sapped = findCondition("sapped");
|
||||||
|
expect(sapped.description).toBeTruthy();
|
||||||
|
expect(sapped.edition).toBe("5.5e");
|
||||||
|
|
||||||
|
const slowed = findCondition("slowed");
|
||||||
|
expect(slowed.description).toBeTruthy();
|
||||||
|
expect(slowed.edition).toBe("5.5e");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("conditions with identical rules share the same text", () => {
|
||||||
|
const blinded = findCondition("blinded");
|
||||||
|
expect(blinded.description).toBe(blinded.description5e);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("conditions with different rules have different text", () => {
|
||||||
|
const exhaustion = findCondition("exhaustion");
|
||||||
|
expect(exhaustion.description).not.toBe(exhaustion.description5e);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getConditionsForEdition", () => {
|
||||||
|
it("includes sapped and slowed for 5.5e", () => {
|
||||||
|
const conditions = getConditionsForEdition("5.5e");
|
||||||
|
const ids = conditions.map((d) => d.id);
|
||||||
|
expect(ids).toContain("sapped");
|
||||||
|
expect(ids).toContain("slowed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes sapped and slowed for 5e", () => {
|
||||||
|
const conditions = getConditionsForEdition("5e");
|
||||||
|
const ids = conditions.map((d) => d.id);
|
||||||
|
expect(ids).not.toContain("sapped");
|
||||||
|
expect(ids).not.toContain("slowed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes universal conditions for both editions", () => {
|
||||||
|
const ids5e = getConditionsForEdition("5e").map((d) => d.id);
|
||||||
|
const ids55e = getConditionsForEdition("5.5e").map((d) => d.id);
|
||||||
|
expect(ids5e).toContain("blinded");
|
||||||
|
expect(ids55e).toContain("blinded");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,6 +23,7 @@ function success(
|
|||||||
maxHp,
|
maxHp,
|
||||||
color,
|
color,
|
||||||
icon,
|
icon,
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
throw new Error(`Expected success, got error: ${result.message}`);
|
throw new Error(`Expected success, got error: ${result.message}`);
|
||||||
@@ -241,4 +242,76 @@ describe("createPlayerCharacter", () => {
|
|||||||
expect(events).toHaveLength(1);
|
expect(events).toHaveLength(1);
|
||||||
expect(events[0].type).toBe("PlayerCharacterCreated");
|
expect(events[0].type).toBe("PlayerCharacterCreated");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("creates a player character with a valid level", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
"blue",
|
||||||
|
"sword",
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].level).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a player character without a level", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
"blue",
|
||||||
|
"sword",
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].level).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects level below 1", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
"blue",
|
||||||
|
"sword",
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
expectDomainError(result, "invalid-level");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects level above 20", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
"blue",
|
||||||
|
"sword",
|
||||||
|
21,
|
||||||
|
);
|
||||||
|
expectDomainError(result, "invalid-level");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-integer level", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
"blue",
|
||||||
|
"sword",
|
||||||
|
3.5,
|
||||||
|
);
|
||||||
|
expectDomainError(result, "invalid-level");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -110,4 +110,33 @@ describe("editPlayerCharacter", () => {
|
|||||||
expect(event.oldName).toBe("Aragorn");
|
expect(event.oldName).toBe("Aragorn");
|
||||||
expect(event.newName).toBe("Strider");
|
expect(event.newName).toBe("Strider");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sets level on a player character", () => {
|
||||||
|
const result = editPlayerCharacter([makePC()], id, { level: 5 });
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].level).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears level when set to null", () => {
|
||||||
|
const result = editPlayerCharacter([makePC({ level: 5 })], id, {
|
||||||
|
level: null,
|
||||||
|
});
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].level).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid level", () => {
|
||||||
|
const result = editPlayerCharacter([makePC()], id, { level: 0 });
|
||||||
|
expectDomainError(result, "invalid-level");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects level above 20", () => {
|
||||||
|
const result = editPlayerCharacter([makePC()], id, { level: 21 });
|
||||||
|
expectDomainError(result, "invalid-level");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-integer level", () => {
|
||||||
|
const result = editPlayerCharacter([makePC()], id, { level: 3.5 });
|
||||||
|
expectDomainError(result, "invalid-level");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
133
packages/domain/src/__tests__/encounter-difficulty.test.ts
Normal file
133
packages/domain/src/__tests__/encounter-difficulty.test.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
calculateEncounterDifficulty,
|
||||||
|
crToXp,
|
||||||
|
} from "../encounter-difficulty.js";
|
||||||
|
|
||||||
|
describe("crToXp", () => {
|
||||||
|
it("returns 0 for CR 0", () => {
|
||||||
|
expect(crToXp("0")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 25 for CR 1/8", () => {
|
||||||
|
expect(crToXp("1/8")).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 50 for CR 1/4", () => {
|
||||||
|
expect(crToXp("1/4")).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 100 for CR 1/2", () => {
|
||||||
|
expect(crToXp("1/2")).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 for CR 1", () => {
|
||||||
|
expect(crToXp("1")).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 155000 for CR 30", () => {
|
||||||
|
expect(crToXp("30")).toBe(155000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 for unknown CR", () => {
|
||||||
|
expect(crToXp("99")).toBe(0);
|
||||||
|
expect(crToXp("")).toBe(0);
|
||||||
|
expect(crToXp("abc")).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("calculateEncounterDifficulty", () => {
|
||||||
|
it("returns trivial when monster XP is below Low threshold", () => {
|
||||||
|
// 4x level 1: Low = 200, Moderate = 300, High = 400
|
||||||
|
// 1x CR 0 = 0 XP → trivial
|
||||||
|
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["0"]);
|
||||||
|
expect(result.tier).toBe("trivial");
|
||||||
|
expect(result.totalMonsterXp).toBe(0);
|
||||||
|
expect(result.partyBudget).toEqual({
|
||||||
|
low: 200,
|
||||||
|
moderate: 300,
|
||||||
|
high: 400,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns low for 4x level 1 vs Bugbear (CR 1)", () => {
|
||||||
|
// DMG example: 4x level 1 PCs vs 1 Bugbear (CR 1, 200 XP)
|
||||||
|
// Low = 200, Moderate = 300 → 200 >= 200 but < 300 → Low
|
||||||
|
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["1"]);
|
||||||
|
expect(result.tier).toBe("low");
|
||||||
|
expect(result.totalMonsterXp).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns moderate for 5x level 3 vs 1125 XP", () => {
|
||||||
|
// 5x level 3: Low = 750, Moderate = 1125, High = 2000
|
||||||
|
// 1125 XP >= 1125 Moderate but < 2000 High → Moderate
|
||||||
|
// Using CR 3 (700) + CR 2 (450) = 1150 XP ≈ 1125 threshold
|
||||||
|
// Let's use exact: 5 * 225 = 1125 moderate budget
|
||||||
|
// Need monsters that sum exactly to 1125: CR 3 (700) + CR 2 (450) = 1150
|
||||||
|
const result = calculateEncounterDifficulty([3, 3, 3, 3, 3], ["3", "2"]);
|
||||||
|
expect(result.tier).toBe("moderate");
|
||||||
|
expect(result.totalMonsterXp).toBe(1150);
|
||||||
|
expect(result.partyBudget.moderate).toBe(1125);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns high when XP meets High threshold", () => {
|
||||||
|
// 4x level 1: High = 400
|
||||||
|
// 2x CR 1 = 400 XP → High
|
||||||
|
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["1", "1"]);
|
||||||
|
expect(result.tier).toBe("high");
|
||||||
|
expect(result.totalMonsterXp).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps at high when XP far exceeds threshold", () => {
|
||||||
|
// 4x level 1: High = 400
|
||||||
|
// CR 30 = 155000 XP → still High (no tier above)
|
||||||
|
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["30"]);
|
||||||
|
expect(result.tier).toBe("high");
|
||||||
|
expect(result.totalMonsterXp).toBe(155000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles mixed party levels", () => {
|
||||||
|
// 3x level 3 + 1x level 2
|
||||||
|
// level 3: low=150, mod=225, high=400 (x3 = 450, 675, 1200)
|
||||||
|
// level 2: low=100, mod=150, high=200 (x1 = 100, 150, 200)
|
||||||
|
// Total: low=550, mod=825, high=1400
|
||||||
|
const result = calculateEncounterDifficulty([3, 3, 3, 2], ["3"]);
|
||||||
|
expect(result.partyBudget).toEqual({
|
||||||
|
low: 550,
|
||||||
|
moderate: 825,
|
||||||
|
high: 1400,
|
||||||
|
});
|
||||||
|
expect(result.totalMonsterXp).toBe(700);
|
||||||
|
expect(result.tier).toBe("low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns trivial with empty monster array", () => {
|
||||||
|
const result = calculateEncounterDifficulty([5, 5], []);
|
||||||
|
expect(result.tier).toBe("trivial");
|
||||||
|
expect(result.totalMonsterXp).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns high with empty party array (zero budget thresholds)", () => {
|
||||||
|
// Domain function treats empty party as zero budgets — any XP exceeds all thresholds.
|
||||||
|
// The useDifficulty hook guards this path by returning null when no leveled PCs exist.
|
||||||
|
const result = calculateEncounterDifficulty([], ["1"]);
|
||||||
|
expect(result.tier).toBe("high");
|
||||||
|
expect(result.totalMonsterXp).toBe(200);
|
||||||
|
expect(result.partyBudget).toEqual({ low: 0, moderate: 0, high: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles fractional CRs", () => {
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[1, 1, 1, 1],
|
||||||
|
["1/8", "1/4", "1/2"],
|
||||||
|
);
|
||||||
|
expect(result.totalMonsterXp).toBe(175); // 25 + 50 + 100
|
||||||
|
expect(result.tier).toBe("trivial"); // 175 < 200 Low
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores unknown CRs (0 XP)", () => {
|
||||||
|
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["unknown"]);
|
||||||
|
expect(result.totalMonsterXp).toBe(0);
|
||||||
|
expect(result.tier).toBe("trivial");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -69,6 +69,34 @@ describe("setHp", () => {
|
|||||||
expect(encounter.combatants[0].maxHp).toBeUndefined();
|
expect(encounter.combatants[0].maxHp).toBeUndefined();
|
||||||
expect(encounter.combatants[0].currentHp).toBeUndefined();
|
expect(encounter.combatants[0].currentHp).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clears tempHp when maxHp is cleared", () => {
|
||||||
|
const e = enc([
|
||||||
|
{
|
||||||
|
id: combatantId("A"),
|
||||||
|
name: "A",
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 15,
|
||||||
|
tempHp: 5,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { encounter } = successResult(e, "A", undefined);
|
||||||
|
expect(encounter.combatants[0].tempHp).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves tempHp when maxHp is updated", () => {
|
||||||
|
const e = enc([
|
||||||
|
{
|
||||||
|
id: combatantId("A"),
|
||||||
|
name: "A",
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 15,
|
||||||
|
tempHp: 5,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { encounter } = successResult(e, "A", 25);
|
||||||
|
expect(encounter.combatants[0].tempHp).toBe(5);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("invariants", () => {
|
describe("invariants", () => {
|
||||||
|
|||||||
182
packages/domain/src/__tests__/set-temp-hp.test.ts
Normal file
182
packages/domain/src/__tests__/set-temp-hp.test.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { setTempHp } from "../set-temp-hp.js";
|
||||||
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
|
function makeCombatant(
|
||||||
|
name: string,
|
||||||
|
opts?: { maxHp: number; currentHp: number; tempHp?: number },
|
||||||
|
): Combatant {
|
||||||
|
return {
|
||||||
|
id: combatantId(name),
|
||||||
|
name,
|
||||||
|
...(opts
|
||||||
|
? {
|
||||||
|
maxHp: opts.maxHp,
|
||||||
|
currentHp: opts.currentHp,
|
||||||
|
tempHp: opts.tempHp,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function enc(combatants: Combatant[]): Encounter {
|
||||||
|
return { combatants, activeIndex: 0, roundNumber: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function successResult(
|
||||||
|
encounter: Encounter,
|
||||||
|
id: string,
|
||||||
|
tempHp: number | undefined,
|
||||||
|
) {
|
||||||
|
const result = setTempHp(encounter, combatantId(id), tempHp);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
throw new Error(`Expected success, got error: ${result.message}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("setTempHp", () => {
|
||||||
|
describe("acceptance scenarios", () => {
|
||||||
|
it("sets temp HP on a combatant with HP tracking enabled", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
|
||||||
|
const { encounter } = successResult(e, "A", 8);
|
||||||
|
expect(encounter.combatants[0].tempHp).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps higher value when existing temp HP is greater", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 5 }),
|
||||||
|
]);
|
||||||
|
const { encounter } = successResult(e, "A", 3);
|
||||||
|
expect(encounter.combatants[0].tempHp).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces when new value is higher", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 3 }),
|
||||||
|
]);
|
||||||
|
const { encounter } = successResult(e, "A", 7);
|
||||||
|
expect(encounter.combatants[0].tempHp).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears temp HP when set to undefined", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 5 }),
|
||||||
|
]);
|
||||||
|
const { encounter } = successResult(e, "A", undefined);
|
||||||
|
expect(encounter.combatants[0].tempHp).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("invariants", () => {
|
||||||
|
it("is pure — same input produces same output", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
|
||||||
|
const r1 = setTempHp(e, combatantId("A"), 5);
|
||||||
|
const r2 = setTempHp(e, combatantId("A"), 5);
|
||||||
|
expect(r1).toEqual(r2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate input encounter", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
|
||||||
|
const original = JSON.parse(JSON.stringify(e));
|
||||||
|
setTempHp(e, combatantId("A"), 5);
|
||||||
|
expect(e).toEqual(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits TempHpSet event with correct shape", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 3 }),
|
||||||
|
]);
|
||||||
|
const { events } = successResult(e, "A", 7);
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "TempHpSet",
|
||||||
|
combatantId: combatantId("A"),
|
||||||
|
previousTempHp: 3,
|
||||||
|
newTempHp: 7,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves activeIndex and roundNumber", () => {
|
||||||
|
const e = {
|
||||||
|
combatants: [
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 10 }),
|
||||||
|
makeCombatant("B"),
|
||||||
|
],
|
||||||
|
activeIndex: 1,
|
||||||
|
roundNumber: 5,
|
||||||
|
};
|
||||||
|
const { encounter } = successResult(e, "A", 5);
|
||||||
|
expect(encounter.activeIndex).toBe(1);
|
||||||
|
expect(encounter.roundNumber).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("error cases", () => {
|
||||||
|
it("returns error for nonexistent combatant", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||||
|
const result = setTempHp(e, combatantId("Z"), 5);
|
||||||
|
expectDomainError(result, "combatant-not-found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error when HP tracking is not enabled", () => {
|
||||||
|
const e = enc([makeCombatant("A")]);
|
||||||
|
const result = setTempHp(e, combatantId("A"), 5);
|
||||||
|
expectDomainError(result, "no-hp-tracking");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects temp HP of 0", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||||
|
const result = setTempHp(e, combatantId("A"), 0);
|
||||||
|
expectDomainError(result, "invalid-temp-hp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects negative temp HP", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||||
|
const result = setTempHp(e, combatantId("A"), -3);
|
||||||
|
expectDomainError(result, "invalid-temp-hp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-integer temp HP", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||||
|
const result = setTempHp(e, combatantId("A"), 2.5);
|
||||||
|
expectDomainError(result, "invalid-temp-hp");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("does not affect other combatants", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 15 }),
|
||||||
|
makeCombatant("B", { maxHp: 30, currentHp: 25, tempHp: 4 }),
|
||||||
|
]);
|
||||||
|
const { encounter } = successResult(e, "A", 5);
|
||||||
|
expect(encounter.combatants[1].tempHp).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not affect currentHp or maxHp", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
|
||||||
|
const { encounter } = successResult(e, "A", 8);
|
||||||
|
expect(encounter.combatants[0].maxHp).toBe(20);
|
||||||
|
expect(encounter.combatants[0].currentHp).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("event reflects no change when existing value equals new value", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 5 }),
|
||||||
|
]);
|
||||||
|
const { events } = successResult(e, "A", 5);
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "TempHpSet",
|
||||||
|
combatantId: combatantId("A"),
|
||||||
|
previousTempHp: 5,
|
||||||
|
newTempHp: 5,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
124
packages/domain/src/__tests__/undo-redo.test.ts
Normal file
124
packages/domain/src/__tests__/undo-redo.test.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Encounter } from "../types.js";
|
||||||
|
import { isDomainError } from "../types.js";
|
||||||
|
import {
|
||||||
|
clearHistory,
|
||||||
|
EMPTY_UNDO_REDO_STATE,
|
||||||
|
pushUndo,
|
||||||
|
redo,
|
||||||
|
undo,
|
||||||
|
} from "../undo-redo.js";
|
||||||
|
|
||||||
|
function enc(roundNumber = 1, activeIndex = 0): Encounter {
|
||||||
|
return { combatants: [], activeIndex, roundNumber };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("pushUndo", () => {
|
||||||
|
it("adds a snapshot to the undo stack", () => {
|
||||||
|
const result = pushUndo(EMPTY_UNDO_REDO_STATE, enc(1));
|
||||||
|
expect(result.undoStack).toHaveLength(1);
|
||||||
|
expect(result.undoStack[0]).toEqual(enc(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the redo stack", () => {
|
||||||
|
const state = {
|
||||||
|
undoStack: [enc(1)],
|
||||||
|
redoStack: [enc(2)],
|
||||||
|
};
|
||||||
|
const result = pushUndo(state, enc(3));
|
||||||
|
expect(result.redoStack).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps the undo stack at 50, dropping the oldest", () => {
|
||||||
|
const undoStack = Array.from({ length: 50 }, (_, i) => enc(i + 1));
|
||||||
|
const state = { undoStack, redoStack: [] };
|
||||||
|
const result = pushUndo(state, enc(51));
|
||||||
|
expect(result.undoStack).toHaveLength(50);
|
||||||
|
expect(result.undoStack[0]).toEqual(enc(2));
|
||||||
|
expect(result.undoStack[49]).toEqual(enc(51));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("undo", () => {
|
||||||
|
it("pops from undo stack and pushes current to redo stack", () => {
|
||||||
|
const state = { undoStack: [enc(1)], redoStack: [] };
|
||||||
|
const result = undo(state, enc(2));
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
if (isDomainError(result)) return;
|
||||||
|
expect(result.encounter).toEqual(enc(1));
|
||||||
|
expect(result.state.undoStack).toHaveLength(0);
|
||||||
|
expect(result.state.redoStack).toEqual([enc(2)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error when undo stack is empty", () => {
|
||||||
|
const result = undo(EMPTY_UNDO_REDO_STATE, enc(1));
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
if (!isDomainError(result)) return;
|
||||||
|
expect(result.code).toBe("nothing-to-undo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pops the most recent entry (last in stack)", () => {
|
||||||
|
const state = { undoStack: [enc(1), enc(2), enc(3)], redoStack: [] };
|
||||||
|
const result = undo(state, enc(4));
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
if (isDomainError(result)) return;
|
||||||
|
expect(result.encounter).toEqual(enc(3));
|
||||||
|
expect(result.state.undoStack).toEqual([enc(1), enc(2)]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("redo", () => {
|
||||||
|
it("pops from redo stack and pushes current to undo stack", () => {
|
||||||
|
const state = { undoStack: [], redoStack: [enc(1)] };
|
||||||
|
const result = redo(state, enc(2));
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
if (isDomainError(result)) return;
|
||||||
|
expect(result.encounter).toEqual(enc(1));
|
||||||
|
expect(result.state.redoStack).toHaveLength(0);
|
||||||
|
expect(result.state.undoStack).toEqual([enc(2)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error when redo stack is empty", () => {
|
||||||
|
const result = redo(EMPTY_UNDO_REDO_STATE, enc(1));
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
if (!isDomainError(result)) return;
|
||||||
|
expect(result.code).toBe("nothing-to-redo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pops the most recent entry (last in stack)", () => {
|
||||||
|
const state = { undoStack: [], redoStack: [enc(1), enc(2), enc(3)] };
|
||||||
|
const result = redo(state, enc(4));
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
if (isDomainError(result)) return;
|
||||||
|
expect(result.encounter).toEqual(enc(3));
|
||||||
|
expect(result.state.redoStack).toEqual([enc(1), enc(2)]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("undo-then-redo roundtrip", () => {
|
||||||
|
it("returns the exact same encounter after undo then redo", () => {
|
||||||
|
const original = enc(5);
|
||||||
|
const current = enc(6);
|
||||||
|
const afterPush = pushUndo(EMPTY_UNDO_REDO_STATE, original);
|
||||||
|
|
||||||
|
const undoResult = undo(afterPush, current);
|
||||||
|
expect(isDomainError(undoResult)).toBe(false);
|
||||||
|
if (isDomainError(undoResult)) return;
|
||||||
|
|
||||||
|
expect(undoResult.encounter).toEqual(original);
|
||||||
|
|
||||||
|
const redoResult = redo(undoResult.state, undoResult.encounter);
|
||||||
|
expect(isDomainError(redoResult)).toBe(false);
|
||||||
|
if (isDomainError(redoResult)) return;
|
||||||
|
|
||||||
|
expect(redoResult.encounter).toEqual(current);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearHistory", () => {
|
||||||
|
it("empties both stacks", () => {
|
||||||
|
const result = clearHistory();
|
||||||
|
expect(result.undoStack).toHaveLength(0);
|
||||||
|
expect(result.redoStack).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,24 +1,94 @@
|
|||||||
|
import type { CreatureId } from "./creature-types.js";
|
||||||
import type { DomainEvent } from "./events.js";
|
import type { DomainEvent } from "./events.js";
|
||||||
import type { CombatantId, DomainError, Encounter } from "./types.js";
|
import { sortByInitiative } from "./initiative-sort.js";
|
||||||
|
import type { PlayerCharacterId } from "./player-character-types.js";
|
||||||
|
import type {
|
||||||
|
Combatant,
|
||||||
|
CombatantId,
|
||||||
|
DomainError,
|
||||||
|
Encounter,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export interface CombatantInit {
|
||||||
|
readonly maxHp?: number;
|
||||||
|
readonly ac?: number;
|
||||||
|
readonly initiative?: number;
|
||||||
|
readonly creatureId?: CreatureId;
|
||||||
|
readonly color?: string;
|
||||||
|
readonly icon?: string;
|
||||||
|
readonly playerCharacterId?: PlayerCharacterId;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AddCombatantSuccess {
|
export interface AddCombatantSuccess {
|
||||||
readonly encounter: Encounter;
|
readonly encounter: Encounter;
|
||||||
readonly events: DomainEvent[];
|
readonly events: DomainEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateInit(init: CombatantInit): DomainError | undefined {
|
||||||
|
if (
|
||||||
|
init.maxHp !== undefined &&
|
||||||
|
(!Number.isInteger(init.maxHp) || init.maxHp < 1)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "invalid-max-hp",
|
||||||
|
message: `Max HP must be a positive integer, got ${init.maxHp}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (init.ac !== undefined && (!Number.isInteger(init.ac) || init.ac < 0)) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "invalid-ac",
|
||||||
|
message: `AC must be a non-negative integer, got ${init.ac}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (init.initiative !== undefined && !Number.isInteger(init.initiative)) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "invalid-initiative",
|
||||||
|
message: `Initiative must be an integer, got ${init.initiative}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCombatant(
|
||||||
|
id: CombatantId,
|
||||||
|
name: string,
|
||||||
|
init?: CombatantInit,
|
||||||
|
): Combatant {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
...(init?.maxHp !== undefined && {
|
||||||
|
maxHp: init.maxHp,
|
||||||
|
currentHp: init.maxHp,
|
||||||
|
}),
|
||||||
|
...(init?.ac !== undefined && { ac: init.ac }),
|
||||||
|
...(init?.initiative !== undefined && { initiative: init.initiative }),
|
||||||
|
...(init?.creatureId !== undefined && { creatureId: init.creatureId }),
|
||||||
|
...(init?.color !== undefined && { color: init.color }),
|
||||||
|
...(init?.icon !== undefined && { icon: init.icon }),
|
||||||
|
...(init?.playerCharacterId !== undefined && {
|
||||||
|
playerCharacterId: init.playerCharacterId,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure function that adds a combatant to the end of an encounter's list.
|
* Pure function that adds a combatant to the end of an encounter's list.
|
||||||
*
|
*
|
||||||
* FR-001: Accepts an Encounter, CombatantId, and name; returns next state + events.
|
* FR-001: Accepts an Encounter, CombatantId, and name; returns next state + events.
|
||||||
* FR-002: Appends new combatant to end of combatants list.
|
* FR-002: Appends new combatant to end of combatants list.
|
||||||
* FR-004: Rejects empty/whitespace-only names with DomainError.
|
* FR-004: Rejects empty/whitespace-only names with DomainError.
|
||||||
* FR-005: Does not alter activeIndex or roundNumber.
|
* FR-005: Does not alter activeIndex or roundNumber (unless initiative triggers sort).
|
||||||
* FR-006: Events returned as values, not dispatched via side effects.
|
* FR-006: Events returned as values, not dispatched via side effects.
|
||||||
*/
|
*/
|
||||||
export function addCombatant(
|
export function addCombatant(
|
||||||
encounter: Encounter,
|
encounter: Encounter,
|
||||||
id: CombatantId,
|
id: CombatantId,
|
||||||
name: string,
|
name: string,
|
||||||
|
init?: CombatantInit,
|
||||||
): AddCombatantSuccess | DomainError {
|
): AddCombatantSuccess | DomainError {
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
|
|
||||||
@@ -30,12 +100,35 @@ export function addCombatant(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const position = encounter.combatants.length;
|
if (init) {
|
||||||
|
const error = validateInit(init);
|
||||||
|
if (error) return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCombatant = buildCombatant(id, trimmed, init);
|
||||||
|
let combatants: readonly Combatant[] = [
|
||||||
|
...encounter.combatants,
|
||||||
|
newCombatant,
|
||||||
|
];
|
||||||
|
let activeIndex = encounter.activeIndex;
|
||||||
|
|
||||||
|
if (init?.initiative !== undefined) {
|
||||||
|
const activeCombatantId =
|
||||||
|
encounter.combatants.length > 0
|
||||||
|
? encounter.combatants[encounter.activeIndex].id
|
||||||
|
: id;
|
||||||
|
|
||||||
|
const result = sortByInitiative(combatants, activeCombatantId);
|
||||||
|
combatants = result.sorted;
|
||||||
|
activeIndex = result.activeIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = combatants.findIndex((c) => c.id === id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
encounter: {
|
encounter: {
|
||||||
combatants: [...encounter.combatants, { id, name: trimmed }],
|
combatants,
|
||||||
activeIndex: encounter.activeIndex,
|
activeIndex,
|
||||||
roundNumber: encounter.roundNumber,
|
roundNumber: encounter.roundNumber,
|
||||||
},
|
},
|
||||||
events: [
|
events: [
|
||||||
@@ -44,6 +137,7 @@ export function addCombatant(
|
|||||||
combatantId: id,
|
combatantId: id,
|
||||||
name: trimmed,
|
name: trimmed,
|
||||||
position,
|
position,
|
||||||
|
init,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -54,24 +54,52 @@ export function adjustHp(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const previousHp = target.currentHp;
|
const previousHp = target.currentHp;
|
||||||
const newHp = Math.max(0, Math.min(target.maxHp, previousHp + delta));
|
const previousTempHp = target.tempHp ?? 0;
|
||||||
|
let newTempHp = previousTempHp;
|
||||||
|
let effectiveDelta = delta;
|
||||||
|
|
||||||
return {
|
if (delta < 0 && previousTempHp > 0) {
|
||||||
encounter: {
|
const absorbed = Math.min(previousTempHp, Math.abs(delta));
|
||||||
combatants: encounter.combatants.map((c) =>
|
newTempHp = previousTempHp - absorbed;
|
||||||
c.id === combatantId ? { ...c, currentHp: newHp } : c,
|
effectiveDelta = delta + absorbed;
|
||||||
),
|
}
|
||||||
activeIndex: encounter.activeIndex,
|
|
||||||
roundNumber: encounter.roundNumber,
|
const newHp = Math.max(
|
||||||
},
|
0,
|
||||||
events: [
|
Math.min(target.maxHp, previousHp + effectiveDelta),
|
||||||
{
|
);
|
||||||
|
|
||||||
|
const events: DomainEvent[] = [];
|
||||||
|
|
||||||
|
if (newTempHp !== previousTempHp) {
|
||||||
|
events.push({
|
||||||
|
type: "TempHpSet",
|
||||||
|
combatantId,
|
||||||
|
previousTempHp: previousTempHp || undefined,
|
||||||
|
newTempHp: newTempHp || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newHp !== previousHp) {
|
||||||
|
events.push({
|
||||||
type: "CurrentHpAdjusted",
|
type: "CurrentHpAdjusted",
|
||||||
combatantId,
|
combatantId,
|
||||||
previousHp,
|
previousHp,
|
||||||
newHp,
|
newHp,
|
||||||
delta,
|
delta,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
encounter: {
|
||||||
|
combatants: encounter.combatants.map((c) =>
|
||||||
|
c.id === combatantId
|
||||||
|
? { ...c, currentHp: newHp, tempHp: newTempHp || undefined }
|
||||||
|
: c,
|
||||||
|
),
|
||||||
|
activeIndex: encounter.activeIndex,
|
||||||
|
roundNumber: encounter.roundNumber,
|
||||||
},
|
},
|
||||||
],
|
events,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,15 +12,29 @@ export type ConditionId =
|
|||||||
| "poisoned"
|
| "poisoned"
|
||||||
| "prone"
|
| "prone"
|
||||||
| "restrained"
|
| "restrained"
|
||||||
|
| "sapped"
|
||||||
|
| "slowed"
|
||||||
| "stunned"
|
| "stunned"
|
||||||
| "unconscious";
|
| "unconscious";
|
||||||
|
|
||||||
|
export type RulesEdition = "5e" | "5.5e";
|
||||||
|
|
||||||
export interface ConditionDefinition {
|
export interface ConditionDefinition {
|
||||||
readonly id: ConditionId;
|
readonly id: ConditionId;
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
readonly description: string;
|
readonly description: string;
|
||||||
|
readonly description5e: string;
|
||||||
readonly iconName: string;
|
readonly iconName: string;
|
||||||
readonly color: string;
|
readonly color: string;
|
||||||
|
/** When set, the condition only appears in this edition's picker. */
|
||||||
|
readonly edition?: RulesEdition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConditionDescription(
|
||||||
|
def: ConditionDefinition,
|
||||||
|
edition: RulesEdition,
|
||||||
|
): string {
|
||||||
|
return edition === "5e" ? def.description5e : def.description;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||||
@@ -29,6 +43,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
label: "Blinded",
|
label: "Blinded",
|
||||||
description:
|
description:
|
||||||
"Can't see. Auto-fail sight checks. Attacks have Disadvantage; attacks against have Advantage.",
|
"Can't see. Auto-fail sight checks. Attacks have Disadvantage; attacks against have Advantage.",
|
||||||
|
description5e:
|
||||||
|
"Can't see. Auto-fail sight checks. Attacks have Disadvantage; attacks against have Advantage.",
|
||||||
iconName: "EyeOff",
|
iconName: "EyeOff",
|
||||||
color: "neutral",
|
color: "neutral",
|
||||||
},
|
},
|
||||||
@@ -37,6 +53,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
label: "Charmed",
|
label: "Charmed",
|
||||||
description:
|
description:
|
||||||
"Can't attack or target the charmer with harmful abilities. Charmer has Advantage on social checks.",
|
"Can't attack or target the charmer with harmful abilities. Charmer has Advantage on social checks.",
|
||||||
|
description5e:
|
||||||
|
"Can't attack or target the charmer with harmful abilities. Charmer has Advantage on social checks.",
|
||||||
iconName: "Heart",
|
iconName: "Heart",
|
||||||
color: "pink",
|
color: "pink",
|
||||||
},
|
},
|
||||||
@@ -44,6 +62,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
id: "deafened",
|
id: "deafened",
|
||||||
label: "Deafened",
|
label: "Deafened",
|
||||||
description: "Can't hear. Auto-fail hearing checks.",
|
description: "Can't hear. Auto-fail hearing checks.",
|
||||||
|
description5e: "Can't hear. Auto-fail hearing checks.",
|
||||||
iconName: "EarOff",
|
iconName: "EarOff",
|
||||||
color: "neutral",
|
color: "neutral",
|
||||||
},
|
},
|
||||||
@@ -51,7 +70,9 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
id: "exhaustion",
|
id: "exhaustion",
|
||||||
label: "Exhaustion",
|
label: "Exhaustion",
|
||||||
description:
|
description:
|
||||||
"Subtract exhaustion level from D20 Tests and Spell save DCs. Speed reduced by 5 ft. \u00D7 level. Removed by long rest (1 level) or death at 10 levels.",
|
"D20 Tests reduced by 2 \u00D7 exhaustion level.\nSpeed reduced by 5 ft. \u00D7 level.\nLong rest removes 1 level.\nDeath at 6 levels.",
|
||||||
|
description5e:
|
||||||
|
"L1: Disadvantage on ability checks\nL2: Speed halved\nL3: Disadvantage on attacks and saves\nL4: HP max halved\nL5: Speed 0\nL6: Death\nLong rest removes 1 level.",
|
||||||
iconName: "BatteryLow",
|
iconName: "BatteryLow",
|
||||||
color: "amber",
|
color: "amber",
|
||||||
},
|
},
|
||||||
@@ -60,6 +81,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
label: "Frightened",
|
label: "Frightened",
|
||||||
description:
|
description:
|
||||||
"Disadvantage on ability checks and attacks while source of fear is in line of sight. Can't willingly move closer to the source.",
|
"Disadvantage on ability checks and attacks while source of fear is in line of sight. Can't willingly move closer to the source.",
|
||||||
|
description5e:
|
||||||
|
"Disadvantage on ability checks and attacks while source of fear is in line of sight. Can't willingly move closer to the source.",
|
||||||
iconName: "Siren",
|
iconName: "Siren",
|
||||||
color: "orange",
|
color: "orange",
|
||||||
},
|
},
|
||||||
@@ -67,7 +90,9 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
id: "grappled",
|
id: "grappled",
|
||||||
label: "Grappled",
|
label: "Grappled",
|
||||||
description:
|
description:
|
||||||
"Speed is 0 and can't benefit from bonuses to speed. Ends if grappler is Incapacitated or moved out of reach.",
|
"Speed 0. Disadvantage on attacks against targets other than the grappler. Grappler can drag you (extra movement cost). Ends if grappler is Incapacitated or you leave their reach.",
|
||||||
|
description5e:
|
||||||
|
"Speed 0. Ends if grappler is Incapacitated or moved out of reach.",
|
||||||
iconName: "Hand",
|
iconName: "Hand",
|
||||||
color: "neutral",
|
color: "neutral",
|
||||||
},
|
},
|
||||||
@@ -75,7 +100,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
id: "incapacitated",
|
id: "incapacitated",
|
||||||
label: "Incapacitated",
|
label: "Incapacitated",
|
||||||
description:
|
description:
|
||||||
"Can't take Actions, Bonus Actions, or Reactions. Concentration is broken.",
|
"Can't take Actions, Bonus Actions, or Reactions. Can't speak. Concentration is broken. Disadvantage on Initiative.",
|
||||||
|
description5e: "Can't take Actions or Reactions.",
|
||||||
iconName: "Ban",
|
iconName: "Ban",
|
||||||
color: "gray",
|
color: "gray",
|
||||||
},
|
},
|
||||||
@@ -83,6 +109,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
id: "invisible",
|
id: "invisible",
|
||||||
label: "Invisible",
|
label: "Invisible",
|
||||||
description:
|
description:
|
||||||
|
"Can't be seen. Advantage on Initiative. Not affected by effects requiring sight (unless caster sees you). Attacks have Advantage; attacks against have Disadvantage.",
|
||||||
|
description5e:
|
||||||
"Impossible to see without magic or special sense. Heavily Obscured. Attacks have Advantage; attacks against have Disadvantage.",
|
"Impossible to see without magic or special sense. Heavily Obscured. Attacks have Advantage; attacks against have Disadvantage.",
|
||||||
iconName: "Ghost",
|
iconName: "Ghost",
|
||||||
color: "violet",
|
color: "violet",
|
||||||
@@ -92,6 +120,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
label: "Paralyzed",
|
label: "Paralyzed",
|
||||||
description:
|
description:
|
||||||
"Incapacitated. Can't move or speak. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
"Incapacitated. Can't move or speak. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
||||||
|
description5e:
|
||||||
|
"Incapacitated. Can't move or speak. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
||||||
iconName: "ZapOff",
|
iconName: "ZapOff",
|
||||||
color: "yellow",
|
color: "yellow",
|
||||||
},
|
},
|
||||||
@@ -100,6 +130,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
label: "Petrified",
|
label: "Petrified",
|
||||||
description:
|
description:
|
||||||
"Turned to stone. Weight \u00D710. Incapacitated. Can't move or speak. Attacks against have Advantage. Auto-fail Str/Dex saves. Resistant to all damage. Immune to poison and disease.",
|
"Turned to stone. Weight \u00D710. Incapacitated. Can't move or speak. Attacks against have Advantage. Auto-fail Str/Dex saves. Resistant to all damage. Immune to poison and disease.",
|
||||||
|
description5e:
|
||||||
|
"Turned to stone. Weight \u00D710. Incapacitated. Can't move or speak. Attacks against have Advantage. Auto-fail Str/Dex saves. Resistant to all damage. Immune to poison and disease.",
|
||||||
iconName: "Gem",
|
iconName: "Gem",
|
||||||
color: "slate",
|
color: "slate",
|
||||||
},
|
},
|
||||||
@@ -107,6 +139,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
id: "poisoned",
|
id: "poisoned",
|
||||||
label: "Poisoned",
|
label: "Poisoned",
|
||||||
description: "Disadvantage on attack rolls and ability checks.",
|
description: "Disadvantage on attack rolls and ability checks.",
|
||||||
|
description5e: "Disadvantage on attack rolls and ability checks.",
|
||||||
iconName: "Droplet",
|
iconName: "Droplet",
|
||||||
color: "green",
|
color: "green",
|
||||||
},
|
},
|
||||||
@@ -115,6 +148,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
label: "Prone",
|
label: "Prone",
|
||||||
description:
|
description:
|
||||||
"Can only crawl (costs extra movement). Disadvantage on attacks. Attacks within 5 ft. have Advantage; ranged attacks have Disadvantage. Standing up costs half movement.",
|
"Can only crawl (costs extra movement). Disadvantage on attacks. Attacks within 5 ft. have Advantage; ranged attacks have Disadvantage. Standing up costs half movement.",
|
||||||
|
description5e:
|
||||||
|
"Can only crawl (costs extra movement). Disadvantage on attacks. Attacks within 5 ft. have Advantage; ranged attacks have Disadvantage. Standing up costs half movement.",
|
||||||
iconName: "ArrowDown",
|
iconName: "ArrowDown",
|
||||||
color: "neutral",
|
color: "neutral",
|
||||||
},
|
},
|
||||||
@@ -123,13 +158,37 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
label: "Restrained",
|
label: "Restrained",
|
||||||
description:
|
description:
|
||||||
"Speed is 0. Attacks have Disadvantage. Attacks against have Advantage. Disadvantage on Dex saves.",
|
"Speed is 0. Attacks have Disadvantage. Attacks against have Advantage. Disadvantage on Dex saves.",
|
||||||
|
description5e:
|
||||||
|
"Speed is 0. Attacks have Disadvantage. Attacks against have Advantage. Disadvantage on Dex saves.",
|
||||||
iconName: "Link",
|
iconName: "Link",
|
||||||
color: "neutral",
|
color: "neutral",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "sapped",
|
||||||
|
label: "Sapped",
|
||||||
|
description:
|
||||||
|
"Disadvantage on next attack roll before the start of your next turn. (Weapon Mastery: Sap)",
|
||||||
|
description5e: "",
|
||||||
|
iconName: "ShieldMinus",
|
||||||
|
color: "amber",
|
||||||
|
edition: "5.5e",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "slowed",
|
||||||
|
label: "Slowed",
|
||||||
|
description:
|
||||||
|
"Speed reduced by 10 ft. until the start of your next turn. (Weapon Mastery: Slow)",
|
||||||
|
description5e: "",
|
||||||
|
iconName: "Snail",
|
||||||
|
color: "sky",
|
||||||
|
edition: "5.5e",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "stunned",
|
id: "stunned",
|
||||||
label: "Stunned",
|
label: "Stunned",
|
||||||
description:
|
description:
|
||||||
|
"Incapacitated (can't act or speak). Auto-fail Str/Dex saves. Attacks against have Advantage.",
|
||||||
|
description5e:
|
||||||
"Incapacitated. Can't move. Can speak only falteringly. Auto-fail Str/Dex saves. Attacks against have Advantage.",
|
"Incapacitated. Can't move. Can speak only falteringly. Auto-fail Str/Dex saves. Attacks against have Advantage.",
|
||||||
iconName: "Sparkles",
|
iconName: "Sparkles",
|
||||||
color: "yellow",
|
color: "yellow",
|
||||||
@@ -138,7 +197,9 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
id: "unconscious",
|
id: "unconscious",
|
||||||
label: "Unconscious",
|
label: "Unconscious",
|
||||||
description:
|
description:
|
||||||
"Incapacitated. Can't move or speak. Unaware of surroundings. Drops held items, falls Prone. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
"Incapacitated. Speed 0. Can't move or speak. Unaware of surroundings. Drops held items, falls Prone. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
||||||
|
description5e:
|
||||||
|
"Incapacitated. Speed 0. Can't move or speak. Unaware of surroundings. Drops held items, falls Prone. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
||||||
iconName: "Moon",
|
iconName: "Moon",
|
||||||
color: "indigo",
|
color: "indigo",
|
||||||
},
|
},
|
||||||
@@ -147,3 +208,11 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
export const VALID_CONDITION_IDS: ReadonlySet<string> = new Set(
|
export const VALID_CONDITION_IDS: ReadonlySet<string> = new Set(
|
||||||
CONDITION_DEFINITIONS.map((d) => d.id),
|
CONDITION_DEFINITIONS.map((d) => d.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export function getConditionsForEdition(
|
||||||
|
edition: RulesEdition,
|
||||||
|
): readonly ConditionDefinition[] {
|
||||||
|
return CONDITION_DEFINITIONS.filter(
|
||||||
|
(d) => d.edition === undefined || d.edition === edition,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export function createPlayerCharacter(
|
|||||||
maxHp: number,
|
maxHp: number,
|
||||||
color: string | undefined,
|
color: string | undefined,
|
||||||
icon: string | undefined,
|
icon: string | undefined,
|
||||||
|
level?: number,
|
||||||
): CreatePlayerCharacterSuccess | DomainError {
|
): CreatePlayerCharacterSuccess | DomainError {
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
|
|
||||||
@@ -65,6 +66,17 @@ export function createPlayerCharacter(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
level !== undefined &&
|
||||||
|
(!Number.isInteger(level) || level < 1 || level > 20)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "invalid-level",
|
||||||
|
message: "Level must be an integer between 1 and 20",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const newCharacter: PlayerCharacter = {
|
const newCharacter: PlayerCharacter = {
|
||||||
id,
|
id,
|
||||||
name: trimmed,
|
name: trimmed,
|
||||||
@@ -72,6 +84,7 @@ export function createPlayerCharacter(
|
|||||||
maxHp,
|
maxHp,
|
||||||
color: color as PlayerCharacter["color"],
|
color: color as PlayerCharacter["color"],
|
||||||
icon: icon as PlayerCharacter["icon"],
|
icon: icon as PlayerCharacter["icon"],
|
||||||
|
level,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -20,6 +20,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateFields(fields: EditFields): DomainError | null {
|
function validateFields(fields: EditFields): DomainError | null {
|
||||||
@@ -72,6 +73,17 @@ function validateFields(fields: EditFields): DomainError | null {
|
|||||||
message: `Invalid icon: ${fields.icon}`,
|
message: `Invalid icon: ${fields.icon}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
fields.level !== undefined &&
|
||||||
|
fields.level !== null &&
|
||||||
|
(!Number.isInteger(fields.level) || fields.level < 1 || fields.level > 20)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "invalid-level",
|
||||||
|
message: "Level must be an integer between 1 and 20",
|
||||||
|
};
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +104,8 @@ function applyFields(
|
|||||||
fields.icon === undefined
|
fields.icon === undefined
|
||||||
? existing.icon
|
? existing.icon
|
||||||
: ((fields.icon as PlayerCharacter["icon"]) ?? undefined),
|
: ((fields.icon as PlayerCharacter["icon"]) ?? undefined),
|
||||||
|
level:
|
||||||
|
fields.level === undefined ? existing.level : (fields.level ?? undefined),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +134,8 @@ export function editPlayerCharacter(
|
|||||||
updated.ac === existing.ac &&
|
updated.ac === existing.ac &&
|
||||||
updated.maxHp === existing.maxHp &&
|
updated.maxHp === existing.maxHp &&
|
||||||
updated.color === existing.color &&
|
updated.color === existing.color &&
|
||||||
updated.icon === existing.icon
|
updated.icon === existing.icon &&
|
||||||
|
updated.level === existing.level
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
kind: "domain-error",
|
kind: "domain-error",
|
||||||
|
|||||||
126
packages/domain/src/encounter-difficulty.ts
Normal file
126
packages/domain/src/encounter-difficulty.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
export type DifficultyTier = "trivial" | "low" | "moderate" | "high";
|
||||||
|
|
||||||
|
export interface DifficultyResult {
|
||||||
|
readonly tier: DifficultyTier;
|
||||||
|
readonly totalMonsterXp: number;
|
||||||
|
readonly partyBudget: {
|
||||||
|
readonly low: number;
|
||||||
|
readonly moderate: number;
|
||||||
|
readonly high: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps challenge rating strings to XP values (standard 5e). */
|
||||||
|
const CR_TO_XP: Readonly<Record<string, number>> = {
|
||||||
|
"0": 0,
|
||||||
|
"1/8": 25,
|
||||||
|
"1/4": 50,
|
||||||
|
"1/2": 100,
|
||||||
|
"1": 200,
|
||||||
|
"2": 450,
|
||||||
|
"3": 700,
|
||||||
|
"4": 1100,
|
||||||
|
"5": 1800,
|
||||||
|
"6": 2300,
|
||||||
|
"7": 2900,
|
||||||
|
"8": 3900,
|
||||||
|
"9": 5000,
|
||||||
|
"10": 5900,
|
||||||
|
"11": 7200,
|
||||||
|
"12": 8400,
|
||||||
|
"13": 10000,
|
||||||
|
"14": 11500,
|
||||||
|
"15": 13000,
|
||||||
|
"16": 15000,
|
||||||
|
"17": 18000,
|
||||||
|
"18": 20000,
|
||||||
|
"19": 22000,
|
||||||
|
"20": 25000,
|
||||||
|
"21": 33000,
|
||||||
|
"22": 41000,
|
||||||
|
"23": 50000,
|
||||||
|
"24": 62000,
|
||||||
|
"25": 75000,
|
||||||
|
"26": 90000,
|
||||||
|
"27": 105000,
|
||||||
|
"28": 120000,
|
||||||
|
"29": 135000,
|
||||||
|
"30": 155000,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Maps character level (1-20) to XP budget thresholds (2024 5.5e DMG). */
|
||||||
|
const XP_BUDGET_PER_CHARACTER: Readonly<
|
||||||
|
Record<number, { low: number; moderate: number; high: number }>
|
||||||
|
> = {
|
||||||
|
1: { low: 50, moderate: 75, high: 100 },
|
||||||
|
2: { low: 100, moderate: 150, high: 200 },
|
||||||
|
3: { low: 150, moderate: 225, high: 400 },
|
||||||
|
4: { low: 250, moderate: 375, high: 500 },
|
||||||
|
5: { low: 500, moderate: 750, high: 1100 },
|
||||||
|
6: { low: 600, moderate: 1000, high: 1400 },
|
||||||
|
7: { low: 750, moderate: 1300, high: 1700 },
|
||||||
|
8: { low: 1000, moderate: 1700, high: 2100 },
|
||||||
|
9: { low: 1300, moderate: 2000, high: 2600 },
|
||||||
|
10: { low: 1600, moderate: 2300, high: 3100 },
|
||||||
|
11: { low: 1900, moderate: 2900, high: 4100 },
|
||||||
|
12: { low: 2200, moderate: 3700, high: 4700 },
|
||||||
|
13: { low: 2600, moderate: 4200, high: 5400 },
|
||||||
|
14: { low: 2900, moderate: 4900, high: 6200 },
|
||||||
|
15: { low: 3300, moderate: 5400, high: 7800 },
|
||||||
|
16: { low: 3800, moderate: 6100, high: 9800 },
|
||||||
|
17: { low: 4500, moderate: 7200, high: 11700 },
|
||||||
|
18: { low: 5000, moderate: 8700, high: 14200 },
|
||||||
|
19: { low: 5500, moderate: 10700, high: 17200 },
|
||||||
|
20: { low: 6400, moderate: 13200, high: 22000 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Returns the XP value for a given CR string. Returns 0 for unknown CRs. */
|
||||||
|
export function crToXp(cr: string): number {
|
||||||
|
return CR_TO_XP[cr] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates encounter difficulty from party levels and monster CRs.
|
||||||
|
* Both arrays should be pre-filtered (only PCs with levels, only bestiary-linked monsters).
|
||||||
|
*/
|
||||||
|
export function calculateEncounterDifficulty(
|
||||||
|
partyLevels: readonly number[],
|
||||||
|
monsterCrs: readonly string[],
|
||||||
|
): DifficultyResult {
|
||||||
|
let budgetLow = 0;
|
||||||
|
let budgetModerate = 0;
|
||||||
|
let budgetHigh = 0;
|
||||||
|
|
||||||
|
for (const level of partyLevels) {
|
||||||
|
const budget = XP_BUDGET_PER_CHARACTER[level];
|
||||||
|
if (budget) {
|
||||||
|
budgetLow += budget.low;
|
||||||
|
budgetModerate += budget.moderate;
|
||||||
|
budgetHigh += budget.high;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalMonsterXp = 0;
|
||||||
|
for (const cr of monsterCrs) {
|
||||||
|
totalMonsterXp += crToXp(cr);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tier: DifficultyTier = "trivial";
|
||||||
|
if (totalMonsterXp >= budgetHigh) {
|
||||||
|
tier = "high";
|
||||||
|
} else if (totalMonsterXp >= budgetModerate) {
|
||||||
|
tier = "moderate";
|
||||||
|
} else if (totalMonsterXp >= budgetLow) {
|
||||||
|
tier = "low";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tier,
|
||||||
|
totalMonsterXp,
|
||||||
|
partyBudget: {
|
||||||
|
low: budgetLow,
|
||||||
|
moderate: budgetModerate,
|
||||||
|
high: budgetHigh,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ConditionId } from "./conditions.js";
|
import type { ConditionId } from "./conditions.js";
|
||||||
|
import type { CreatureId } from "./creature-types.js";
|
||||||
import type { PlayerCharacterId } from "./player-character-types.js";
|
import type { PlayerCharacterId } from "./player-character-types.js";
|
||||||
import type { CombatantId } from "./types.js";
|
import type { CombatantId } from "./types.js";
|
||||||
|
|
||||||
@@ -19,6 +20,15 @@ export interface CombatantAdded {
|
|||||||
readonly combatantId: CombatantId;
|
readonly combatantId: CombatantId;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly position: number;
|
readonly position: number;
|
||||||
|
readonly init?: {
|
||||||
|
readonly maxHp?: number;
|
||||||
|
readonly ac?: number;
|
||||||
|
readonly initiative?: number;
|
||||||
|
readonly creatureId?: CreatureId;
|
||||||
|
readonly color?: string;
|
||||||
|
readonly icon?: string;
|
||||||
|
readonly playerCharacterId?: PlayerCharacterId;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CombatantRemoved {
|
export interface CombatantRemoved {
|
||||||
@@ -58,6 +68,13 @@ export interface CurrentHpAdjusted {
|
|||||||
readonly delta: number;
|
readonly delta: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TempHpSet {
|
||||||
|
readonly type: "TempHpSet";
|
||||||
|
readonly combatantId: CombatantId;
|
||||||
|
readonly previousTempHp: number | undefined;
|
||||||
|
readonly newTempHp: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TurnRetreated {
|
export interface TurnRetreated {
|
||||||
readonly type: "TurnRetreated";
|
readonly type: "TurnRetreated";
|
||||||
readonly previousCombatantId: CombatantId;
|
readonly previousCombatantId: CombatantId;
|
||||||
@@ -132,6 +149,7 @@ export type DomainEvent =
|
|||||||
| InitiativeSet
|
| InitiativeSet
|
||||||
| MaxHpSet
|
| MaxHpSet
|
||||||
| CurrentHpAdjusted
|
| CurrentHpAdjusted
|
||||||
|
| TempHpSet
|
||||||
| TurnRetreated
|
| TurnRetreated
|
||||||
| RoundRetreated
|
| RoundRetreated
|
||||||
| AcSet
|
| AcSet
|
||||||
|
|||||||
11
packages/domain/src/export-bundle.ts
Normal file
11
packages/domain/src/export-bundle.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { PlayerCharacter } from "./player-character-types.js";
|
||||||
|
import type { Encounter } from "./types.js";
|
||||||
|
|
||||||
|
export interface ExportBundle {
|
||||||
|
readonly version: number;
|
||||||
|
readonly exportedAt: string;
|
||||||
|
readonly encounter: Encounter;
|
||||||
|
readonly undoStack: readonly Encounter[];
|
||||||
|
readonly redoStack: readonly Encounter[];
|
||||||
|
readonly playerCharacters: readonly PlayerCharacter[];
|
||||||
|
}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
export { type AddCombatantSuccess, addCombatant } from "./add-combatant.js";
|
export {
|
||||||
|
type AddCombatantSuccess,
|
||||||
|
addCombatant,
|
||||||
|
type CombatantInit,
|
||||||
|
} from "./add-combatant.js";
|
||||||
export { type AdjustHpSuccess, adjustHp } from "./adjust-hp.js";
|
export { type AdjustHpSuccess, adjustHp } from "./adjust-hp.js";
|
||||||
export { advanceTurn } from "./advance-turn.js";
|
export { advanceTurn } from "./advance-turn.js";
|
||||||
export { resolveCreatureName } from "./auto-number.js";
|
export { resolveCreatureName } from "./auto-number.js";
|
||||||
@@ -10,6 +14,9 @@ export {
|
|||||||
CONDITION_DEFINITIONS,
|
CONDITION_DEFINITIONS,
|
||||||
type ConditionDefinition,
|
type ConditionDefinition,
|
||||||
type ConditionId,
|
type ConditionId,
|
||||||
|
getConditionDescription,
|
||||||
|
getConditionsForEdition,
|
||||||
|
type RulesEdition,
|
||||||
VALID_CONDITION_IDS,
|
VALID_CONDITION_IDS,
|
||||||
} from "./conditions.js";
|
} from "./conditions.js";
|
||||||
export {
|
export {
|
||||||
@@ -41,6 +48,12 @@ export {
|
|||||||
type EditPlayerCharacterSuccess,
|
type EditPlayerCharacterSuccess,
|
||||||
editPlayerCharacter,
|
editPlayerCharacter,
|
||||||
} from "./edit-player-character.js";
|
} from "./edit-player-character.js";
|
||||||
|
export {
|
||||||
|
calculateEncounterDifficulty,
|
||||||
|
crToXp,
|
||||||
|
type DifficultyResult,
|
||||||
|
type DifficultyTier,
|
||||||
|
} from "./encounter-difficulty.js";
|
||||||
export type {
|
export type {
|
||||||
AcSet,
|
AcSet,
|
||||||
CombatantAdded,
|
CombatantAdded,
|
||||||
@@ -60,9 +73,11 @@ export type {
|
|||||||
PlayerCharacterUpdated,
|
PlayerCharacterUpdated,
|
||||||
RoundAdvanced,
|
RoundAdvanced,
|
||||||
RoundRetreated,
|
RoundRetreated,
|
||||||
|
TempHpSet,
|
||||||
TurnAdvanced,
|
TurnAdvanced,
|
||||||
TurnRetreated,
|
TurnRetreated,
|
||||||
} from "./events.js";
|
} from "./events.js";
|
||||||
|
export type { ExportBundle } from "./export-bundle.js";
|
||||||
export { deriveHpStatus, type HpStatus } from "./hp-status.js";
|
export { deriveHpStatus, type HpStatus } from "./hp-status.js";
|
||||||
export {
|
export {
|
||||||
calculateInitiative,
|
calculateInitiative,
|
||||||
@@ -95,6 +110,7 @@ export {
|
|||||||
type SetInitiativeSuccess,
|
type SetInitiativeSuccess,
|
||||||
setInitiative,
|
setInitiative,
|
||||||
} from "./set-initiative.js";
|
} from "./set-initiative.js";
|
||||||
|
export { type SetTempHpSuccess, setTempHp } from "./set-temp-hp.js";
|
||||||
export {
|
export {
|
||||||
type ToggleConcentrationSuccess,
|
type ToggleConcentrationSuccess,
|
||||||
toggleConcentration,
|
toggleConcentration,
|
||||||
@@ -112,3 +128,11 @@ export {
|
|||||||
type Encounter,
|
type Encounter,
|
||||||
isDomainError,
|
isDomainError,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
export {
|
||||||
|
clearHistory,
|
||||||
|
EMPTY_UNDO_REDO_STATE,
|
||||||
|
pushUndo,
|
||||||
|
redo,
|
||||||
|
type UndoRedoState,
|
||||||
|
undo,
|
||||||
|
} from "./undo-redo.js";
|
||||||
|
|||||||
35
packages/domain/src/initiative-sort.ts
Normal file
35
packages/domain/src/initiative-sort.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Combatant, CombatantId } from "./types.js";
|
||||||
|
|
||||||
|
interface Indexed {
|
||||||
|
readonly c: Combatant;
|
||||||
|
readonly i: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareByInitiative(a: Indexed, b: Indexed): number {
|
||||||
|
const aHas = a.c.initiative !== undefined;
|
||||||
|
const bHas = b.c.initiative !== undefined;
|
||||||
|
if (aHas && bHas) {
|
||||||
|
const diff = b.c.initiative - a.c.initiative;
|
||||||
|
return diff === 0 ? a.i - b.i : diff;
|
||||||
|
}
|
||||||
|
if (aHas && !bHas) return -1;
|
||||||
|
if (!aHas && bHas) return 1;
|
||||||
|
return a.i - b.i;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable-sorts combatants by initiative (descending), preserving relative
|
||||||
|
* order for ties and for combatants without initiative. Returns the sorted
|
||||||
|
* list and the new activeIndex that tracks the given active combatant
|
||||||
|
* through the reorder.
|
||||||
|
*/
|
||||||
|
export function sortByInitiative(
|
||||||
|
combatants: readonly Combatant[],
|
||||||
|
activeCombatantId: CombatantId,
|
||||||
|
): { sorted: readonly Combatant[]; activeIndex: number } {
|
||||||
|
const indexed = combatants.map((c, i) => ({ c, i }));
|
||||||
|
indexed.sort(compareByInitiative);
|
||||||
|
const sorted = indexed.map(({ c }) => c);
|
||||||
|
const idx = sorted.findIndex((c) => c.id === activeCombatantId);
|
||||||
|
return { sorted, activeIndex: idx === -1 ? 0 : idx };
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user