34 Commits

Author SHA1 Message Date
Lukas
96b37d4bdd Color player character names instead of left border
All checks were successful
CI / check (push) Successful in 45s
CI / build-image (push) Successful in 18s
Player characters now show their chosen color on their name text
rather than as a left border glow. Left border is reserved for
active row (accent) and concentration (purple).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:52:09 +01:00
Lukas
76ca78c169 Improve player modals: Escape to close, trash icon for delete
Both player management and create/edit modals now close on Escape.
Delete player character button uses Trash2 icon instead of X to
distinguish permanent deletion from dismissal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:51:58 +01:00
Lukas
b0c27b8ab9 Add red hover effect to destructive buttons
ConfirmButton now shows hover:text-hover-destructive in its default
state. Source manager delete buttons and Clear All get matching
destructive hover styling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:51:34 +01:00
Lukas
458c277e9f Polish UI: consistent icon buttons, tooltips, modal backdrop close, and top bar layout
All checks were successful
CI / check (push) Successful in 45s
CI / build-image (push) Successful in 17s
- Standardize icon button sizing (size="icon") and color (text-muted-foreground) across top and bottom bars
- Group bottom bar icon buttons with gap-0 to match top bar style
- Add missing tooltips/aria-labels for stat block viewer, bulk import buttons
- Replace Settings icon with Library for source manager
- Make step forward/back buttons use primary (solid) variant
- Move round badge next to combatant name in center of top bar
- Close player create/edit and management modals on backdrop click

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:47:47 +01:00
Lukas
91703ddebc Add player character management feature
All checks were successful
CI / check (push) Successful in 45s
CI / build-image (push) Successful in 18s
Persistent player character templates (name, AC, HP, color, icon) with
full CRUD, bestiary-style search to add PCs to encounters with pre-filled
stats, and color/icon visual distinction in combatant rows. Also stops
the stat block panel from auto-opening when adding a creature.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:11:08 +01:00
Lukas
768e7a390f Improve empty encounter UX with interactive add button
All checks were successful
CI / check (push) Successful in 44s
CI / build-image (push) Successful in 22s
Replace the static "No combatants yet" text with a centered, breathing
"+" icon that focuses the action bar input on click, guiding users to
add their first combatant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:56:56 +01:00
Lukas
7feaf90eab Add "custom creature" option to bestiary suggestions dropdown
When typing a name that partially matches bestiary entries, users
couldn't access the custom creature fields (Init/AC/MaxHP). Now a
prominent option at the top of the dropdown lets users dismiss
suggestions and add a custom creature instead, with an Esc hint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:45:39 +01:00
Lukas
b39e4923e1 Remove demo combatants and allow empty encounters
All checks were successful
CI / check (push) Successful in 45s
CI / build-image (push) Successful in 28s
Empty encounters are now valid (INV-1 updated). New sessions start
with zero combatants instead of pre-populated Aria/Brak/Cael.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:24:26 +01:00
Lukas
369feb3cc8 Add deploy step to CI workflow to auto-restart container on tag push
All checks were successful
CI / check (push) Successful in 43s
CI / build-image (push) Successful in 17s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:19:34 +01:00
Lukas
51bdb799ae Skip lifecycle scripts in Docker build to avoid missing git
All checks were successful
CI / check (push) Successful in 47s
CI / build-image (push) Successful in 29s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:06:49 +01:00
Lukas
1baddad939 Remove pnpm cache from setup-node to fix Gitea Actions timeout
Some checks failed
CI / check (push) Successful in 47s
CI / build-image (push) Failing after 18s
2026-03-12 09:52:16 +01:00
Lukas
e701e4dd70 Run build-image job on host to access Docker CLI
Some checks failed
CI / check (push) Failing after 14m13s
CI / build-image (push) Has been cancelled
The node:22 container doesn't have Docker installed. Running
on the host label executes directly on the VPS where Docker
is available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:18:51 +01:00
Lukas
e2b0e7d5ee Exclude .pnpm-store from Biome and add .dockerignore
Some checks failed
CI / check (push) Successful in 10m21s
CI / build-image (push) Failing after 4s
The CI runner's pnpm store lands inside the workspace, causing
Biome to lint/format hundreds of store index JSON files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:46:38 +01:00
Lukas
635e9c0705 Add Dockerfile, nginx config, and Gitea Actions CI workflow
Some checks failed
CI / check (push) Failing after 6m20s
CI / build-image (push) Has been skipped
Multi-stage Docker build produces an Nginx container serving the
static SPA. The CI workflow runs pnpm check on every push and
builds/pushes a Docker image on semver tags.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:32:12 +01:00
Lukas
582a42e62d Change add creature placeholder to "+ Add combatants"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:50:09 +01:00
Lukas
fc43f440aa Toggle pin off when clicking pin on already-pinned creature
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:49:00 +01:00
Lukas
1cf30b3622 Add swipe-to-dismiss gesture for mobile stat block drawer
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:48:48 +01:00
Lukas
2ce0ff50b9 Always show add-condition and delete buttons on touch devices
Use a pointer-coarse custom variant to bypass hover-dependent
visibility on devices without a precise pointer (phones, tablets).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:37:04 +01:00
Lukas
96a7b2d00e Remove pencil icon, use cursor-text to signal editable name
The hover-revealed pencil icon caused layout shift on rows with
conditions. Modern UIs (Figma, Notion, Linear) rely on double-click
without a visible edit icon. Replace with cursor-text on hover.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:32:56 +01:00
Lukas
2d8e823eff Replace single-click rename with double-click, pencil icon, and long-press (#6)
Single-clicking a combatant name now opens the stat block panel instead of
entering edit mode. Renaming is triggered by double-click, a hover pencil
icon, or long-press on touch. Also fixes condition picker positioning when
near viewport edges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:22:21 +01:00
Lukas
613bb70065 Consolidate 36 per-change specs into 4 feature-level specs and align workflow
Replace granular change-level specs (001–036) with living feature specs:
- 001-combatant-management (CRUD, persistence, clear, confirm buttons)
- 002-turn-tracking (rounds, turn order, advance/retreat, top bar)
- 003-combatant-state (HP, AC, conditions, concentration, initiative)
- 004-bestiary (search, stat blocks, source management, panel UX)

Workflow changes:
- Add /integrate-issue command (replaces /issue-to-spec) for routing
  issues to existing specs or handing off to /speckit.specify
- Update /sync-issue to list specs instead of requiring feature branch
- Update /write-issue to reference /integrate-issue
- Add RPI skills (research, plan, implement) to .claude/skills/
- Create docs/agents/ for RPI artifacts (research reports, plans)
- Remove update-agent-context.sh call from /speckit.plan
- Update CLAUDE.md with proportional scope-based workflow table
- Bump constitution to 3.0.0 (specs describe features, not changes)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:33:27 +01:00
Lukas
b6e052f198 Overhaul bottom bar: batch add, custom fields, stat block viewer
Unify the action bar into a single search input with inline bestiary
dropdown. Clicking a dropdown entry queues it with +/- count controls
and a confirm button; Enter or confirm adds N copies to combat.

When no bestiary match exists, optional Init/AC/MaxHP fields appear
for custom creatures. The eye icon opens a separate search dropdown
to preview stat blocks without leaving the add flow.

Fix batch-add bug where only the last creature got a creatureId by
using store.save() instead of setEncounter() in addFromBestiary.
Prevent dropdown buttons from stealing input focus so Enter confirms
the queued batch.

Remove the now-redundant BestiarySearch component.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:27:06 +01:00
Lukas
460c65bf49 Implement stat block panel fold/unfold and pin-to-second-panel
Replace the close button and heading with fold/unfold controls that
collapse the panel to a slim right-edge tab showing the creature name
vertically, and add a pin button (xl+ viewports with creature loaded)
that opens the creature in a second left-side panel for simultaneous
reference. Fold state is respected on turn change. 19 acceptance tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:18:15 +01:00
Lukas
95cb2edc23 Redesign top bar with dedicated round badge and centered combatant name
Replace the combined "Round N — Name" string with a three-zone flex layout:
left (prev button + R{n} pill badge), center (prominent combatant name with
truncation), right (action buttons + next button). Adds 13 unit tests
covering all user stories including layout robustness and empty state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 12:39:57 +01:00
Lukas
55d322a727 Fix concentration glow clipping at container edges
Add padding to the inner combatant list container so the box-shadow glow
from the concentration-damage animation renders fully on all sides,
preventing clipping by the scrollable parent's implicit overflow-x.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 11:54:22 +01:00
Lukas
0c903bc9a5 Fix ConfirmButton Enter/Space keydown bubbling to parent row handler
The button's onClick stopped mouse event propagation, but keyboard
Enter/Space fired a separate keydown event that bubbled to the
combatant row's onKeyDown, opening the stat block side panel instead
of arming/confirming the button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:04:27 +01:00
Lukas
236c3bf64a Add /issue-to-spec and /sync-issue Claude Code skills
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:00:47 +01:00
Lukas
0747d044f3 Implement the 032-inline-confirm-buttons feature that replaces single-click destructive actions with a reusable ConfirmButton component providing inline two-step confirmation (click to arm, click to execute), applied to the remove combatant and clear encounter buttons, with CSS scale pulse animation, 5-second auto-revert, click-outside/Escape/blur dismissal, full keyboard accessibility, and 13 unit tests via @testing-library/react
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 09:51:21 +01:00
Lukas
d101906776 Add /write-issue skill for interactive Gitea issue creation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 01:21:22 +01:00
Lukas
69363d4f7d Implement the 031-quality-gates-hygiene feature that strengthens automated quality gates by adding pnpm audit to the check script, v8 coverage thresholds with per-directory auto-ratchet (domain 96%, adapters 71%, persistence 87%), Biome cognitive complexity enforcement (max 15), and keyboard accessibility for the combatant row, while cleaning up all blanket biome-ignore comments, refactoring 5 overly complex functions into smaller helpers, and codifying the early-enforcement principle in the constitution and CLAUDE.md
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 00:52:29 +01:00
Lukas
47da942b73 Clean up project documentation and constitution for post-MVP maturity
- CLAUDE.md: replace 32-line per-feature tech changelog with consolidated
  tech stack, add project structure and data/storage sections, remove stale
  Recent Changes
- README.md: rewrite for current feature set, fix bestiary description
  (import-based, not bundled), remove placeholder license section
- Constitution v2.2.0: remove unused Agent Boundary principle (MAJOR),
  add README and CLAUDE.md sync rules to Development Workflow (MINOR)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:48:50 +01:00
Lukas
94d125d9c4 Implement the 030-bulk-import-sources feature that adds a one-click bulk import button to load all bestiary sources at once, with real-time progress feedback in the side panel and a toast notification when the panel is closed, plus completion/failure reporting with auto-dismiss on success and persistent display on partial failure, while also hardening the bestiary normalizer to handle variable stat blocks (spell summons with special AC/HP/CR) and skip malformed monster entries gracefully
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:29:34 +01:00
Lukas
c323adc343 Add spec, plan, and tasks for 030-bulk-import-sources feature
Defines the "Bulk Import All Sources" feature for the on-demand bestiary
system: one-click loading of all ~104 bestiary sources with concurrent
fetching, progress feedback, toast notifications, and completion reporting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:46:24 +01:00
Lukas
91120d7c82 Implement the 029-on-demand-bestiary feature that replaces the bundled XMM bestiary JSON with a compact search index (~350KB) and on-demand source loading, where users explicitly provide a URL or upload a JSON file to fetch full stat block data per source, which is then normalized and cached in IndexedDB (with in-memory fallback) so creature stat blocks load instantly on subsequent visits while keeping the app bundle small and never auto-fetching copyrighted content
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:46:13 +01:00
288 changed files with 46924 additions and 78779 deletions

View File

@@ -0,0 +1,143 @@
---
description: Fetch a Gitea issue, identify the affected feature spec(s), and integrate the issue's requirements into the spec. For new features, hands off to /speckit.specify.
---
## User Input
```text
$ARGUMENTS
```
You **MUST** provide an issue number as the argument (e.g. `/integrate-issue 6`). If `$ARGUMENTS` is empty or not a valid number, ask the user for the issue number.
## Prerequisites
1. Verify the `GITEA_TOKEN_ISSUES` environment variable is set:
```bash
test -n "$GITEA_TOKEN_ISSUES" && echo "TOKEN_OK" || echo "TOKEN_MISSING"
```
If missing, tell the user to set it:
```
export GITEA_TOKEN_ISSUES="your-gitea-personal-access-token"
```
Then abort.
2. Parse the git remote to extract the Gitea API base URL, owner, and repo:
```bash
git config --get remote.origin.url
```
Expected format: `ssh://git@<host>:<port>/<owner>/<repo>.git` or `https://<host>/<owner>/<repo>.git`
Extract:
- `GITEA_HOST` — the hostname
- `OWNER` — the repo owner/org
- `REPO` — the repo name (strip `.git` suffix)
- `API_BASE``https://<GITEA_HOST>/api/v1`
## Execution
### Step 1 — Fetch the issue
```bash
curl -sf -H "Authorization: token $GITEA_TOKEN_ISSUES" "$API_BASE/repos/$OWNER/$REPO/issues/<NUMBER>"
```
Extract from the JSON response:
- `title` — the issue title
- `body` — the issue body (markdown)
- `labels` — array of label names (if any)
If the API call fails or returns no issue, abort with a clear error.
### Step 2 — Fetch issue comments (if any)
```bash
curl -sf -H "Authorization: token $GITEA_TOKEN_ISSUES" "$API_BASE/repos/$OWNER/$REPO/issues/<NUMBER>/comments"
```
If comments exist, include them as additional context (they may contain clarifications or requirements discussed after the issue was created).
### Step 3 — Route: new feature or existing feature?
List the existing feature specs by reading the `specs/` directory:
```bash
ls -d specs/*/
```
Present the issue summary and existing specs to the user. Ask:
**"Does this issue belong to an existing feature, or is it a new feature?"**
Present options:
- Each existing spec as a numbered option (show spec name and one-line description from CLAUDE.md or the spec's overview)
- A "New feature" option
If the user selects **New feature**, compose the feature description from the issue content (title + body + comments) and hand off to `/speckit.specify`. Stop here.
If the user selects an **existing spec**, continue to Step 4.
### Step 4 — Read the affected spec
Load the selected spec file. Identify the sections that the issue's requirements affect:
- Which user stories need updating?
- Which requirements (FR-NNN) need adding or modifying?
- Which acceptance scenarios change?
- Are new edge cases introduced?
Present your analysis to the user:
- **Stories affected**: list the story IDs/titles that need changes
- **New stories needed**: if the issue introduces behavior not covered by any existing story
- **Requirements to add/modify**: list specific FR numbers or new ones needed
Ask the user to confirm or adjust the scope.
### Step 5 — Draft spec changes
For each affected section, draft the specific changes:
- **Modified stories**: show the before/after for acceptance scenarios
- **New stories**: write them in the spec's format (matching the existing story naming convention — e.g., `**Story HP-7**` for combatant-state, `**Story A4**` for combatant-management)
- **New/modified requirements**: write them with the next available FR number
- **New edge cases**: add to the relevant edge cases section
For per-topic specs (003-combatant-state, 004-bestiary), place changes in the correct topic section.
### Step 6 — Preview and confirm
Show the user a complete preview of all changes:
- Which file(s) will be modified
- The exact additions/modifications (as diffs or before/after blocks)
Ask for confirmation before writing.
### Step 7 — Write changes
On confirmation:
- Write the updated spec file(s)
- Report what was changed (sections touched, stories added/modified, requirements added)
### Step 8 — Suggest next steps
Report completion and suggest next steps based on scope:
- **Straightforward change** (1-2 stories, clear acceptance scenarios): "Implement the changes and commit"
- **Larger change** (multiple stories, cross-cutting concerns): "Use `rpi-research` to investigate the affected code, then `rpi-plan` to create a phased implementation plan, then `rpi-implement` to execute it"
- **Complex or ambiguous change**: "Run `/speckit.clarify` to resolve remaining ambiguities before implementing"
- Only if the spec adds substantive new criteria not already captured in the issue: "Run `/sync-issue <number>` to update the Gitea issue with the new acceptance criteria". Skip this if the spec merely reformulates what the issue already says into Given/When/Then format.
## Behavior Rules
- Never modify the issue on Gitea — this is a read-only operation on the issue side.
- Always preview before writing spec changes — never write without user confirmation.
- Include comment authors in the context so requirements can be attributed.
- If the issue body is empty, warn the user but still proceed with just the title.
- Strip HTML tags from the body/comments if present (Gitea sometimes includes rendered HTML).
- Use `curl` for all API calls — do not rely on `gh` CLI.
- Match the existing spec's naming conventions for stories, requirements, and structure.
- When adding to per-topic specs (003, 004), place content in the correct topic section — do not create new top-level sections unless the change introduces an entirely new topic area.
- Increment FR/SC numbers from the highest existing number in the spec.

View File

@@ -75,14 +75,7 @@ You **MUST** consider the user input before proceeding (if not empty).
- Examples: public APIs for libraries, command schemas for CLI tools, endpoints for web services, grammars for parsers, UI contracts for applications
- Skip if project is purely internal (build scripts, one-off tools, etc.)
3. **Agent context update**:
- Run `.specify/scripts/bash/update-agent-context.sh claude`
- These scripts detect which AI agent is in use
- Update the appropriate agent-specific context file
- Add only new technology from current plan
- Preserve manual additions between markers
**Output**: data-model.md, /contracts/*, quickstart.md, agent-specific file
**Output**: data-model.md, /contracts/*, quickstart.md
## Key rules

View File

@@ -0,0 +1,162 @@
---
description: Update a Gitea issue with business-level acceptance criteria extracted from the feature spec's user stories.
---
## User Input
```text
$ARGUMENTS
```
You **MUST** provide an issue number as the argument (e.g. `/sync-issue 42`). If `$ARGUMENTS` is empty or not a valid number, ask the user for the issue number.
## Prerequisites
1. Verify the `GITEA_TOKEN_ISSUES` environment variable is set:
```bash
test -n "$GITEA_TOKEN_ISSUES" && echo "TOKEN_OK" || echo "TOKEN_MISSING"
```
If missing, tell the user to set it:
```
export GITEA_TOKEN_ISSUES="your-gitea-personal-access-token"
```
Then abort.
2. Parse the git remote to extract the Gitea API base URL, owner, and repo:
```bash
git config --get remote.origin.url
```
Expected format: `ssh://git@<host>:<port>/<owner>/<repo>.git` or `https://<host>/<owner>/<repo>.git`
Extract:
- `GITEA_HOST` — the hostname
- `OWNER` — the repo owner/org
- `REPO` — the repo name (strip `.git` suffix)
- `API_BASE``https://<GITEA_HOST>/api/v1`
3. Locate the spec file. List the available feature specs:
```bash
ls specs/*/spec.md
```
Present the specs to the user and ask which one contains the acceptance criteria for this issue. If only one spec exists, use it automatically.
## Execution
### Step 1 — Read the spec
Load the spec file at `FEATURE_SPEC`. Extract user stories and acceptance scenarios using these patterns:
**Flat specs** (001-combatant-management, 002-turn-tracking):
- Look for the `## User Scenarios & Testing` section
- Each `### ... Story ...` or `**Story ...** ` block
- The **Acceptance Scenarios** numbered list within each story (Given/When/Then format)
- The **Edge Cases** section(s)
**Per-topic specs** (003-combatant-state, 004-bestiary):
- Stories are nested inside topic sections (e.g., `## Hit Points` > `### User Stories` > `**Story HP-1 — ...**`)
- Scan ALL `##` sections for `**Story ...` or `**US-...` patterns
- Extract acceptance scenarios from each story regardless of nesting depth
- Collect edge cases from each topic section's `### Edge Cases` subsection
### Step 2 — Condense into business-level acceptance criteria
For each user story, extract the acceptance scenarios and rewrite them as concise, business-level checkbox items. Group by user story title.
**Transformation rules:**
- Strip Given/When/Then syntax — write as plain outcomes
- Remove implementation details (API names, database references, component names, file paths, config values, tool names)
- Focus on what the user **can do** or **can see**
- Keep each item to one line
- Preserve the grouping by user story for readability
**Example transformation:**
Input (from spec):
```
**Given** no sources are cached, **When** the user clicks the import button in the top bar,
**Then** the stat block side panel opens showing a descriptive explanation, an editable
pre-filled base URL, and a "Load All" button.
```
Output (for issue):
```
- [ ] Clicking the import button opens a panel with a description, editable URL, and "Load All" button
```
Also include edge cases as a separate group if they describe user-facing behavior.
### Step 3 — Fetch the existing issue
```bash
curl -sf -H "Authorization: token $GITEA_TOKEN_ISSUES" "$API_BASE/repos/$OWNER/$REPO/issues/<NUMBER>"
```
Extract the current `body` from the response.
### Step 4 — Update the issue body
Merge the acceptance criteria into the existing issue body:
- If the body already has an `## Acceptance Criteria` section, **replace** its contents (everything between `## Acceptance Criteria` and the next `##` heading or end of body).
- If the body does not have an `## Acceptance Criteria` section, insert it after the `## Summary` section (or at the end if no Summary exists).
Preserve all other sections of the issue body unchanged.
The acceptance criteria section should look like:
```markdown
## Acceptance Criteria
### <User Story 1 Title>
- [ ] <criterion from acceptance scenario>
- [ ] <criterion from acceptance scenario>
### <User Story 2 Title>
- [ ] <criterion from acceptance scenario>
### Edge Cases
- [ ] <edge case behavior>
```
### Step 5 — Preview and confirm
Show the user:
- The full updated issue body
- A diff summary of what changed (sections added/replaced)
Ask for confirmation before updating.
### Step 6 — Push the update
On confirmation:
```bash
curl -sf -X PATCH \
-H "Authorization: token $GITEA_TOKEN_ISSUES" \
-H "Content-Type: application/json" \
"$API_BASE/repos/$OWNER/$REPO/issues/<NUMBER>" \
-d @- <<'PAYLOAD'
{
"body": "<updated body>"
}
PAYLOAD
```
Report success with the issue URL.
## Behavior Rules
- Never modify the issue title, labels, milestone, or assignees — only the body.
- Always preview before updating — never push without user confirmation.
- If the spec has no user stories or acceptance scenarios, abort with a clear message suggesting the user run `/speckit.specify` first.
- Acceptance criteria must be business-level. If you find yourself writing implementation details, rewrite at a higher level of abstraction.
- Do NOT sync when the issue's existing acceptance criteria already capture the same requirements as the spec. The spec's Given/When/Then format is not needed in the issue — if the only difference is formatting, skip the sync and tell the user the criteria already align.
- Use `curl` for all API calls — do not rely on `gh` CLI.
- Always use HEREDOC for the JSON payload to handle special characters in the body.
- Escape double quotes and newlines properly in the JSON body.

View File

@@ -0,0 +1,163 @@
---
description: Interactive interview to create a well-structured Gitea issue with business-level acceptance criteria.
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Prerequisites
1. Verify the `GITEA_TOKEN_ISSUES` environment variable is set:
```bash
test -n "$GITEA_TOKEN_ISSUES" && echo "TOKEN_OK" || echo "TOKEN_MISSING"
```
If missing, tell the user to set it:
```
export GITEA_TOKEN_ISSUES="your-gitea-personal-access-token"
```
Then abort.
2. Parse the git remote to extract the Gitea base URL, owner, and repo:
```bash
git config --get remote.origin.url
```
Expected format: `ssh://git@<host>:<port>/<owner>/<repo>.git` or `https://<host>/<owner>/<repo>.git`
Extract:
- `GITEA_HOST` — the hostname (e.g. `git.bahamut.nitrix.one`)
- `GITEA_PORT` — the port if present in SSH URL (for API, always use HTTPS on default port)
- `OWNER` — the repo owner/org
- `REPO` — the repo name (strip `.git` suffix)
- `API_BASE``https://<GITEA_HOST>/api/v1`
Verify the remote is reachable:
```bash
curl -sf -H "Authorization: token $GITEA_TOKEN_ISSUES" "$API_BASE/repos/$OWNER/$REPO" | head -c 200
```
If this fails, abort with an error explaining the API is unreachable.
## Interview Flow
### Step 1 — What's the feature?
If `$ARGUMENTS` is not empty, use it as the initial feature description and skip asking.
Otherwise, ask the user: **"What feature or change do you want to build?"**
Accept a free-form description (1-3 sentences is fine).
### Step 2 — Contextual business questions
Analyze the feature description and identify what's ambiguous or underspecified **for this specific feature**. Generate 1-3 targeted questions that would materially improve the issue.
Rules:
- Questions MUST be derived from the specific feature description, not from a generic checklist.
- Only ask questions whose answers change the scope, behavior, or acceptance criteria.
- If the feature is straightforward and well-described, skip this step entirely.
- Present one question at a time using the AskUserQuestion tool.
- For each question, provide 2-4 concrete options plus the ability to give a custom answer.
- After each answer, decide if you need another question or have enough clarity.
Examples of good contextual questions:
- For a "color overhaul" feature: "Are you targeting specific components or a global palette change?"
- For a "bulk import" feature: "Should the user see progress during import or just a final result?"
- For an "initiative tiebreaker" feature: "Should ties be broken by DEX score, manual choice, or random roll?"
Examples of bad questions (never ask these):
- Generic: "Who is the target user?"
- Obvious: "Should errors be handled?"
- Implementation: "What database should we use?"
### Step 3 — Acceptance criteria
Based on the description and clarifications, draft 3-8 business-level acceptance criteria. These should describe **what the user can do or see**, not how the system implements it.
Present the draft to the user and ask if they want to add, remove, or change any.
Good: "User can load all sources with a single click"
Bad: "System fires concurrent fetch requests to IndexedDB"
### Step 4 — Labels and milestone (optional)
Fetch available labels and milestones from the Gitea API:
```bash
curl -sf -H "Authorization: token $GITEA_TOKEN_ISSUES" "$API_BASE/repos/$OWNER/$REPO/labels"
curl -sf -H "Authorization: token $GITEA_TOKEN_ISSUES" "$API_BASE/repos/$OWNER/$REPO/milestones"
```
If labels/milestones exist, present them to the user and ask which (if any) to apply. If none exist or the API returns empty, skip this step.
### Step 5 — Branch linking
Check if we're on a feature branch:
```bash
git branch --show-current
```
If the current branch is not `main` (and looks like a feature branch, e.g. `NNN-feature-name`), offer to include it in the issue body as a linked branch.
### Step 6 — Preview and create
Compose the issue body using this template:
```markdown
## Summary
<2-3 sentence description synthesized from the interview>
## Acceptance Criteria
- [ ] <criterion 1>
- [ ] <criterion 2>
- [ ] <criterion 3>
...
## Branch
`<branch-name>` *(if linked in step 5)*
```
Show the full preview (title + body) to the user and ask for confirmation.
On confirmation, create the issue:
```bash
curl -sf -X POST \
-H "Authorization: token $GITEA_TOKEN_ISSUES" \
-H "Content-Type: application/json" \
"$API_BASE/repos/$OWNER/$REPO/issues" \
-d @- <<'PAYLOAD'
{
"title": "<issue title>",
"body": "<issue body>",
"labels": [<label IDs if selected>],
"milestone": <milestone ID if selected or null>
}
PAYLOAD
```
Parse the response and report:
- Issue number and URL (`https://<GITEA_HOST>/<OWNER>/<REPO>/issues/<number>`)
- Suggest next step: `/integrate-issue <number>` to integrate into a feature spec
## Behavior Rules
- Keep the title short — under 70 characters.
- Acceptance criteria must be business-level, not implementation details.
- Never create an issue without user confirmation of the preview.
- If any API call fails, show the error and suggest the user check their token permissions.
- Use `curl` for all API calls — do not rely on `gh` CLI.
- Always use HEREDOC for the JSON payload to handle special characters in the body.
- Escape double quotes and newlines properly in the JSON body.

View File

@@ -0,0 +1,82 @@
---
name: rpi-implement
description: Execute approved implementation plans phase by phase with automated and manual verification. Use when the user explicitly says "implement the plan", "execute the plan", or "start implementing" and has a plan file ready. Do not use for ad-hoc coding tasks without a plan.
---
# Implement Plan
You are tasked with implementing an approved technical plan. These plans contain phases with specific changes and success criteria.
## Getting Started
If the user provided a plan path, proceed directly. If no plan path was provided, check `docs/agents/plans/` for recent plans. If none found, ask the user for a path.
When you have a plan:
- Read the plan completely and check for any existing checkmarks (`- [x]`)
- Read all files mentioned in the plan
- **Read files fully** - never use limit/offset parameters, you need complete context
- Think deeply about how the pieces fit together
- If you have a todo list, use it to track your progress
- Start implementing if you understand what needs to be done
## Implementation Philosophy
Plans are carefully designed, but reality can be messy. Your job is to:
- Follow the plan's intent while adapting to what you find
- Implement each phase fully before moving to the next
- Verify your work makes sense in the broader codebase context
- Keep plan checkboxes current: `[-]` before starting an item, `[x]` right after it passes verification. Never batch updates.
When things don't match the plan exactly, think about why and communicate clearly. The plan is your guide, but your judgment matters too.
If you encounter a mismatch:
- STOP and think deeply about why the plan can't be followed
- Present the issue clearly:
```
Issue in Phase [N]:
Expected: [what the plan says]
Found: [actual situation]
Why this matters: [explanation]
How should I proceed?
```
## Verification Approach
After implementing a phase:
- Run the success criteria checks listed in the plan (test commands, linters, type checkers, etc.)
- Fix any issues before proceeding
- **Check if manual verification is needed**: Look at the plan's success criteria for the current phase.
- If the phase has **manual verification steps**, pause and inform the human:
```
Phase [N] Complete - Ready for Manual Verification
Automated verification passed:
- [List automated checks that passed]
Please perform the manual verification steps listed in the plan:
- [List manual verification items from the plan]
Let me know when manual testing is complete so I can proceed to Phase [N+1].
```
- If the phase has **only automated verification** (no manual steps), continue directly to the next phase without pausing. Just note in passing that the phase is complete and automated checks passed.
Do not check off items in the manual testing steps until confirmed by the user.
## If You Get Stuck
When something isn't working as expected:
- First, make sure you've read and understood all the relevant code
- Consider if the codebase has evolved since the plan was written
- Present the mismatch clearly and ask for guidance
Use sub-agents sparingly - mainly for targeted debugging or exploring unfamiliar territory.
## Resuming Work
If the plan has existing checkmarks:
- Trust that completed work is done
- Pick up from the first unchecked item
- Verify previous work only if something seems off
Remember: You're implementing a solution, not just checking boxes. Keep the end goal in mind and maintain forward momentum.

View File

@@ -0,0 +1,349 @@
---
name: rpi-plan
description: Create detailed, phased implementation plans through interactive research and iteration. Use when the user explicitly asks to "create a plan", "plan the implementation", or "design an approach" for a feature, refactor, or bug fix. Do not use for quick questions or simple tasks.
---
# Implementation Plan
You are tasked with creating detailed implementation plans through an interactive, iterative process. You should be skeptical, thorough, and work collaboratively with the user to produce high-quality technical specifications.
## Initial Setup
If the user already provided a task description, file path, or topic alongside this command, proceed directly to step 1 below. Only if no context was given, respond with:
```
I'll help you create a detailed implementation plan. Let me start by understanding what we're building.
Please provide:
1. A description of what you want to build or change
2. Any relevant context, constraints, or specific requirements
3. Pointers to related files or previous research
I'll analyze this information and work with you to create a comprehensive plan.
```
Then wait for the user's input.
## Process Steps
### Step 1: Context Gathering & Initial Analysis
1. **Read all mentioned files immediately and FULLY**:
- Any files the user referenced (docs, research, code)
- **IMPORTANT**: Use the Read tool WITHOUT limit/offset parameters to read entire files
- **CRITICAL**: DO NOT spawn sub-tasks before reading these files yourself in the main context
- **NEVER** read files partially - if a file is mentioned, read it completely
2. **Determine if research already exists**:
- If the user provided a research document (e.g. from `docs/agents/research/`), **trust it as the source of truth**. Do NOT re-research topics that the document already covers. Use its findings, file references, and architecture analysis directly as the basis for planning.
- **NEVER repeat or re-do research that has already been provided.** The plan phase is about turning existing research into actionable implementation steps, not about gathering information that's already available.
- If NO research document was provided, proceed with targeted research as described below.
3. **Read the most relevant files directly into your main context**:
- Based on the research document and/or user input, identify the most relevant source files
- **Read these files yourself using the Read tool** — do NOT delegate this to sub-agents. You need these files in your own context to write an accurate plan.
- Focus on files that will be modified or that define interfaces/patterns you need to follow
4. **Only spawn sub-agents for genuinely missing information**:
- Do NOT spawn sub-agents to re-discover what the research document already covers
- Only use sub-agents if there are specific gaps: e.g. the research doesn't cover test conventions, a specific API surface, or a file that was added after the research was written
- Each sub-agent should have a narrow, specific question to answer — not broad exploration
5. **Analyze and verify understanding**:
- Cross-reference the requirements with actual code (and research document if provided)
- Identify any discrepancies or misunderstandings
- Note assumptions that need verification
- Determine true scope based on codebase reality
6. **Present informed understanding and focused questions**:
```
Based on the task and my research of the codebase, I understand we need to [accurate summary].
I've found that:
- [Current implementation detail with file:line reference]
- [Relevant pattern or constraint discovered]
- [Potential complexity or edge case identified]
Questions that my research couldn't answer:
- [Specific technical question that requires human judgment]
- [Business logic clarification]
- [Design preference that affects implementation]
```
Only ask questions that you genuinely cannot answer through code investigation.
### Step 2: Targeted Research & Discovery
After getting initial clarifications:
1. **If the user corrects any misunderstanding**:
- DO NOT just accept the correction
- Read the specific files/directories they mention directly into your context
- Only proceed once you've verified the facts yourself
2. If you have a todo list, use it to track exploration progress
3. **Fill in gaps — do NOT redo existing research**:
- If a research document was provided, identify only the specific gaps that need filling
- Read additional files directly when possible — only spawn sub-agents for searches where you don't know the file paths
- **Ask yourself before any research action: "Is this already covered by the provided research?"** If yes, skip it and use what's there.
4. **Present findings and design options**:
```
Based on my research, here's what I found:
**Current State:**
- [Key discovery about existing code]
- [Pattern or convention to follow]
**Design Options:**
1. [Option A] - [pros/cons]
2. [Option B] - [pros/cons]
**Open Questions:**
- [Technical uncertainty]
- [Design decision needed]
Which approach aligns best with your vision?
```
### Step 3: Plan Structure Development
Once aligned on approach:
1. **Create initial plan outline**:
```
Here's my proposed plan structure:
## Overview
[1-2 sentence summary]
## Implementation Phases:
1. [Phase name] - [what it accomplishes]
2. [Phase name] - [what it accomplishes]
3. [Phase name] - [what it accomplishes]
Does this phasing make sense? Should I adjust the order or granularity?
```
2. **Get feedback on structure** before writing details
### Step 4: Detailed Plan Writing
After structure approval:
1. **Gather metadata**:
- Run `python <skill_directory>/scripts/metadata.py` to get date, commit, branch, and repository info
- Determine the output filename: `docs/agents/plans/YYYY-MM-DD-description.md`
- YYYY-MM-DD is today's date
- description is a brief kebab-case description
- Example: `2025-01-08-improve-error-handling.md`
- The output folder (`docs/agents/plans/`) can be overridden by instructions in the project's `AGENTS.md` or `CLAUDE.md`
2. **Write the plan** to `docs/agents/plans/YYYY-MM-DD-description.md`
- Ensure the `docs/agents/plans/` directory exists (create if needed)
- **Every actionable item must have a checkbox** (`- [ ]`) so progress can be tracked during implementation. This includes each change in "Changes Required" and each verification step in "Success Criteria".
- Use the template structure below:
````markdown
---
date: [ISO date/time from metadata]
git_commit: [Current commit hash from metadata]
branch: [Current branch name from metadata]
topic: "[Feature/Task Name]"
tags: [plan, relevant-component-names]
status: draft
---
# [Feature/Task Name] Implementation Plan
## Overview
[Brief description of what we're implementing and why]
## Current State Analysis
[What exists now, what's missing, key constraints discovered]
## Desired End State
[A specification of the desired end state after this plan is complete, and how to verify it]
### UI Mockups (if applicable)
[If the changes involve user-facing interfaces (CLI output, web UI, terminal UI, etc.), include ASCII mockups
that visually illustrate the intended result. This helps the reader quickly grasp the change.]
### Key Discoveries:
- [Important finding with file:line reference]
- [Pattern to follow]
- [Constraint to work within]
## What We're NOT Doing
[Explicitly list out-of-scope items to prevent scope creep]
## Implementation Approach
[High-level strategy and reasoning]
## Phase 1: [Descriptive Name]
### Overview
[What this phase accomplishes]
### Changes Required:
#### [ ] 1. [Component/File Group]
**File**: `path/to/file.ext`
**Changes**: [Summary of changes]
```[language]
// Specific code to add/modify
```
### Success Criteria:
#### Automated Verification:
- [ ] Tests pass: `[test command]`
- [ ] Type checking passes: `[typecheck command]`
- [ ] Linting passes: `[lint command]`
#### Manual Verification:
- [ ] Feature works as expected when tested
- [ ] Edge case handling verified
- [ ] No regressions in related features
**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human before proceeding to the next phase.
---
## Phase 2: [Descriptive Name]
[Similar structure with both automated and manual success criteria...]
---
## Testing Strategy
### Unit Tests:
- [What to test]
- [Key edge cases]
### Integration Tests:
- [End-to-end scenarios]
### Manual Testing Steps:
1. [Specific step to verify feature]
2. [Another verification step]
## Performance Considerations
[Any performance implications or optimizations needed]
## Migration Notes
[If applicable, how to handle existing data/systems]
## References
- [Related research or documentation]
- [Similar implementation: file:line]
````
### Step 5: Review & Iterate
1. **Present the draft plan location**:
```
I've created the initial implementation plan at:
`docs/agents/plans/YYYY-MM-DD-description.md`
Please review it and let me know:
- Are the phases properly scoped?
- Are the success criteria specific enough?
- Any technical details that need adjustment?
- Missing edge cases or considerations?
```
2. **Iterate based on feedback** - be ready to:
- Add missing phases
- Adjust technical approach
- Clarify success criteria (both automated and manual)
- Add/remove scope items
3. **Continue refining** until the user is satisfied
## Important Guidelines
1. **Be Skeptical**:
- Question vague requirements
- Identify potential issues early
- Ask "why" and "what about"
- Don't assume - verify with code
2. **Be Interactive**:
- Don't write the full plan in one shot
- Get buy-in at each major step
- Allow course corrections
- Work collaboratively
3. **Be Thorough But Not Redundant**:
- Read all context files COMPLETELY before planning
- Use provided research as-is — do not re-investigate what's already documented
- Read key source files directly into your context rather than delegating to sub-agents
- Only spawn sub-agents for narrow, specific questions that aren't answered by existing research
- Include specific file paths and line numbers
- Write measurable success criteria with clear automated vs manual distinction
4. **Be Visual**:
- If the change involves any user-facing interface (web UI, CLI output, terminal UI, forms, dashboards, etc.), include ASCII mockups in the plan
- Mockups make the intended result immediately understandable and help catch misunderstandings early
- Show both the current state and the proposed state when the change modifies an existing UI
- Keep mockups simple but accurate enough to convey layout, key elements, and interactions
5. **Be Practical**:
- Focus on incremental, testable changes
- Consider migration and rollback
- Think about edge cases
- Include "what we're NOT doing"
6. **No Open Questions in Final Plan**:
- If you encounter open questions during planning, STOP
- Research or ask for clarification immediately
- Do NOT write the plan with unresolved questions
- The implementation plan must be complete and actionable
- Every decision must be made before finalizing the plan
## Success Criteria Guidelines
**Always separate success criteria into two categories:**
1. **Automated Verification** (can be run by agents):
- Commands that can be run: test suites, linters, type checkers
- Specific files that should exist
- Code compilation/type checking
2. **Manual Verification** (requires human testing):
- UI/UX functionality
- Performance under real conditions
- Edge cases that are hard to automate
- User acceptance criteria
## Common Patterns
### For Database Changes:
- Start with schema/migration
- Add store methods
- Update business logic
- Expose via API
- Update clients
### For New Features:
- Research existing patterns first
- Start with data model
- Build backend logic
- Add API endpoints
- Implement UI last
### For Refactoring:
- Document current behavior
- Plan incremental changes
- Maintain backwards compatibility
- Include migration strategy

View File

@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Get git metadata for plan documents."""
import json
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
def run(cmd: list[str]) -> str:
result = subprocess.run(cmd, capture_output=True, text=True)
return result.stdout.strip() if result.returncode == 0 else ""
def get_repo_name() -> str:
remote = run(["git", "remote", "get-url", "origin"])
if remote:
name = remote.rstrip("/").rsplit("/", 1)[-1]
return name.removesuffix(".git")
root = run(["git", "rev-parse", "--show-toplevel"])
return Path(root).name if root else Path.cwd().name
def main() -> None:
metadata = {
"date": datetime.now(timezone.utc).isoformat(),
"commit": run(["git", "rev-parse", "HEAD"]),
"branch": run(["git", "branch", "--show-current"]),
"repository": get_repo_name(),
}
json.dump(metadata, sys.stdout, indent=2)
print()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,146 @@
---
name: rpi-research
description: Conduct deep codebase research and produce a written report. Use when the user explicitly requests research like "start a research for", "deeply investigate", or "fully understand how X works". Do not use for quick questions or simple code lookups.
---
# Research Codebase
You are tasked with conducting comprehensive research across the codebase to answer user questions by spawning parallel sub-agents and synthesizing their findings.
## CRITICAL: YOUR ONLY JOB IS TO DOCUMENT AND EXPLAIN THE CODEBASE AS IT EXISTS TODAY
- DO NOT suggest improvements or changes unless the user explicitly asks for them
- DO NOT perform root cause analysis unless the user explicitly asks for them
- DO NOT propose future enhancements unless the user explicitly asks for them
- DO NOT critique the implementation or identify problems
- DO NOT recommend refactoring, optimization, or architectural changes
- ONLY describe what exists, where it exists, how it works, and how components interact
- You are creating a technical map/documentation of the existing system
## Initial Setup
If the user already provided a research question or topic alongside this command, proceed directly to step 1 below. Only if no query was given, respond with:
```
I'm ready to research the codebase. Please provide your research question or area of interest, and I'll analyze it thoroughly by exploring relevant components and connections.
```
Then wait for the user's research query.
## Steps to follow after receiving the research query:
1. **Read any directly mentioned files first:**
- If the user mentions specific files or docs, read them FULLY first
- **IMPORTANT**: Use the Read tool WITHOUT limit/offset parameters to read entire files
- **CRITICAL**: Read these files yourself in the main context before spawning any sub-tasks
- This ensures you have full context before decomposing the research
2. **Analyze and decompose the research question:**
- Break down the user's query into composable research areas
- Take time to think deeply about the underlying patterns, connections, and architectural implications the user might be seeking
- Identify specific components, patterns, or concepts to investigate
- If you have a todo list, use it to track progress
- Consider which directories, files, or architectural patterns are relevant
3. **Spawn parallel sub-agents to identify relevant files and map the landscape:**
- Create multiple Task agents to search for files and identify what's relevant
- Each sub-agent should focus on locating files and reporting back paths and brief summaries — NOT on deeply analyzing code
- The key is to use these agents for discovery:
- Search for files related to each research area
- Identify entry points, key types, and important functions
- Report back file paths, line numbers, and short descriptions of what each file contains
- Run multiple agents in parallel when they're searching for different things
- Remind agents they are documenting, not evaluating or improving
- **If the user explicitly asks for web research**, spawn additional agents with WebSearch/WebFetch tools and instruct them to return links with their findings
4. **Read the most relevant files yourself in the main context:**
- After sub-agents report back, identify the most important files for answering the research question
- **Read these files yourself using the Read tool** — you need them in your own context to write an accurate, detailed research document
- Do NOT rely solely on sub-agent summaries for the core findings — sub-agent summaries may miss nuances, connections, or important details
- Prioritize files that are central to the research question; skip peripheral files that sub-agents already summarized adequately
- This is the step where you build deep understanding — the previous step was just finding what to read
5. **Synthesize findings into a complete picture:**
- Combine your own reading with sub-agent discoveries
- Connect findings across different components
- Include specific file paths and line numbers for reference
- Highlight patterns, connections, and architectural decisions
- Answer the user's specific questions with concrete evidence
6. **Gather metadata for the research document:**
- Run `python <skill_directory>/scripts/metadata.py` to get date, commit, branch, and repository info
- Determine the output filename: `docs/agents/research/YYYY-MM-DD-description.md`
- YYYY-MM-DD is today's date
- description is a brief kebab-case description of the research topic
- Example: `2025-01-08-authentication-flow.md`
- The output folder (`docs/agents/research/`) can be overridden by instructions in the project's `AGENTS.md` or `CLAUDE.md`
7. **Generate research document:**
- Use the metadata gathered in step 5
- Ensure the `docs/agents/research/` directory exists (create if needed)
- Structure the document with YAML frontmatter followed by content:
```markdown
---
date: [ISO date/time from metadata]
git_commit: [Current commit hash from metadata]
branch: [Current branch name from metadata]
topic: "[User's Question/Topic]"
tags: [research, codebase, relevant-component-names]
status: complete
---
# Research: [User's Question/Topic]
## Research Question
[Original user query]
## Summary
[High-level documentation of what was found, answering the user's question by describing what exists]
## Detailed Findings
### [Component/Area 1]
- Description of what exists (file.ext:line)
- How it connects to other components
- Current implementation details (without evaluation)
### [Component/Area 2]
...
## Code References
- `path/to/file.py:123` - Description of what's there
- `another/file.ts:45-67` - Description of the code block
## Architecture Documentation
[Current patterns, conventions, and design implementations found in the codebase]
## Open Questions
[Any areas that need further investigation]
```
8. **Present findings to the user:**
- Present a concise summary of findings
- Include key file references for easy navigation
- Ask if they have follow-up questions or need clarification
9. **Handle follow-up questions:**
- If the user has follow-up questions, append to the same research document
- Add a new section: `## Follow-up Research [timestamp]`
- Spawn new sub-agents as needed for additional investigation
- Continue updating the document
## Important notes:
- Use parallel sub-agents for file discovery and landscape mapping, but **read the most important files yourself** in the main context
- Sub-agents are scouts that find relevant files — the main agent must read key files to build deep understanding
- Do NOT rely solely on sub-agent summaries for your core findings; they may miss nuances and connections
- Focus on finding concrete file paths and line numbers for developer reference
- Research documents should be self-contained with all necessary context
- Each sub-agent prompt should be specific and focused on locating files and reporting back paths
- Document cross-component connections and how systems interact
- **CRITICAL**: You and all sub-agents are documentarians, not evaluators
- **REMEMBER**: Document what IS, not what SHOULD BE
- **NO RECOMMENDATIONS**: Only describe the current state of the codebase
- **File reading**: Always read mentioned files FULLY (no limit/offset) before spawning sub-tasks
- **Critical ordering**: Follow the numbered steps exactly
- ALWAYS read mentioned files first before spawning sub-tasks (step 1)
- ALWAYS read key files yourself after sub-agents report back (step 4)
- ALWAYS wait for your own reading to complete before synthesizing (step 5)
- ALWAYS gather metadata before writing the document (step 6 before step 7)
- NEVER write the research document with placeholder values

View File

@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""Get git metadata for research documents."""
import json
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
def run(cmd: list[str]) -> str:
result = subprocess.run(cmd, capture_output=True, text=True)
return result.stdout.strip() if result.returncode == 0 else ""
def get_repo_name() -> str:
remote = run(["git", "remote", "get-url", "origin"])
if remote:
# Handle both HTTPS and SSH URLs
name = remote.rstrip("/").rsplit("/", 1)[-1]
return name.removesuffix(".git")
# Fall back to directory name
root = run(["git", "rev-parse", "--show-toplevel"])
return Path(root).name if root else Path.cwd().name
def main() -> None:
metadata = {
"date": datetime.now(timezone.utc).isoformat(),
"commit": run(["git", "rev-parse", "HEAD"]),
"branch": run(["git", "branch", "--show-current"]),
"repository": get_repo_name(),
}
json.dump(metadata, sys.stdout, indent=2)
print()
if __name__ == "__main__":
main()

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
.pnpm-store
dist
coverage
.git
.claude
.specify
specs
docs

49
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,49 @@
name: CI
on:
push:
branches: [main]
tags: ["*"]
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: pnpm install --frozen-lockfile
- run: pnpm check
build-image:
if: startsWith(github.ref, 'refs/tags/')
needs: check
runs-on: host
steps:
- uses: actions/checkout@v4
- name: Log in to Gitea registry
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.bahamut.nitrix.one -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
- name: Build and push
run: |
IMAGE=git.bahamut.nitrix.one/dostulata/initiative
TAG=${GITHUB_REF#refs/tags/}
docker build -t $IMAGE:$TAG -t $IMAGE:latest .
docker push $IMAGE:$TAG
docker push $IMAGE:latest
- name: Deploy
run: |
IMAGE=git.bahamut.nitrix.one/dostulata/initiative
TAG=${GITHUB_REF#refs/tags/}
docker stop initiative || true
docker rm initiative || true
docker run -d --name initiative --restart unless-stopped -p 8080:80 $IMAGE:$TAG

View File

@@ -1,14 +1,10 @@
<!--
Sync Impact Report
───────────────────
Version change: 1.0.21.0.3 (PATCH — add merge-gate rule)
Version change: 2.2.13.0.0 (MAJOR — specs describe features not changes, proportional workflow)
Modified sections:
- Development Workflow: added automated-checks merge gate
Templates requiring updates:
- .specify/templates/plan-template.md ✅ no update needed
- .specify/templates/spec-template.md ✅ no update needed
- .specify/templates/tasks-template.md ✅ no update needed
Follow-up TODOs: none
- Development Workflow: specs are living feature documents; full pipeline for new features only
Templates requiring updates: none
-->
# Encounter Console Constitution
@@ -29,7 +25,7 @@ be injected at the boundary, never sourced inside the domain layer.
### II. Layered Architecture
The codebase MUST be organized into four layers with strict
The codebase MUST be organized into three layers with strict
dependency direction:
1. **Domain** — pure types, state transitions, validation rules.
@@ -39,34 +35,21 @@ dependency direction:
interfaces that Adapters implement. May import Domain only.
3. **Adapters** — I/O, persistence, UI rendering, external APIs.
May import Application and Domain.
4. **Agent** — AI-assisted features (suggestions, analysis).
May import Application and Domain as read-only consumers.
A module in an inner layer MUST NOT import from an outer layer.
### III. Agent Boundary
The agent layer MAY read domain events and current state. The agent
MAY produce suggestions, annotations, or recommendations. The agent
MUST NOT mutate domain state directly. All agent-originated changes
MUST flow through the Application layer as explicit user-confirmed
commands.
- Agent output MUST be clearly labeled as suggestions.
- No silent or automatic application of agent recommendations.
### IV. Clarification-First
### III. Clarification-First
Before making any non-trivial assumption during specification,
planning, or implementation, the agent MUST surface a clarification
planning, or implementation, Claude Code MUST surface a clarification
question to the user. "Non-trivial" means any decision that would
alter observable behavior, data model shape, or public API surface.
The agent MUST also ask when multiple valid interpretations exist,
Claude Code MUST also ask when multiple valid interpretations exist,
when a choice would affect architectural layering, or when scope
would expand beyond the current spec. The agent MUST NOT silently
would expand beyond the current spec. Claude Code MUST NOT silently
choose among valid alternatives.
### V. Escalation Gates
### IV. Escalation Gates
Any feature, requirement, or scope change not present in the current
spec MUST be rejected at implementation time until the spec is
@@ -77,7 +60,7 @@ explicitly updated. The workflow is:
3. Update the spec via `/speckit.specify` or `/speckit.clarify`.
4. Only then proceed with implementation.
### VI. MVP Baseline Language
### V. MVP Baseline Language
Constraints in this constitution and in specs MUST use MVP baseline
language ("MVP baseline does not include X") rather than permanent
@@ -86,7 +69,7 @@ add capabilities in future iterations without constitutional
amendment. The current MVP baseline is local-first and single-user;
this is a starting scope, not a permanent restriction.
### VII. No Gameplay Rules in Constitution
### VI. No Gameplay Rules in Constitution
This constitution MUST NOT contain concrete gameplay mechanics,
rule-system specifics, or encounter resolution logic. Such details
@@ -96,9 +79,9 @@ architecture, and quality — not product behavior.
## Scope Constraints
- The Encounter Console's primary focus is initiative tracking and
encounter state management. Adjacent capabilities (e.g., richer
game-engine features) are not in the MVP baseline but may be
added via spec updates in future iterations.
encounter state management. Adjacent capabilities (e.g., bestiary
integration, richer game-engine features) may be added via spec
updates.
- Technology choices, UI framework, and storage mechanism are
spec-level decisions, not constitutional mandates.
- Testing strategy (unit, integration, contract) is determined per
@@ -109,16 +92,31 @@ architecture, and quality — not product behavior.
- No change may be merged unless all automated checks (tests and
static analysis as defined by the project) pass.
- Every feature begins with a spec (`/speckit.specify`).
- Implementation follows the plan → tasks → implement pipeline.
- Specs describe **features**, not individual changes. Each spec is
a living document. New features begin with `/speckit.specify`
(which creates a feature branch for the full speckit pipeline);
changes to existing features update the existing spec via
`/integrate-issue`.
- The full pipeline (spec → plan → tasks → implement) applies to new
features and significant additions. Bug fixes, tooling changes,
and trivial UI adjustments do not require specs.
- Domain logic MUST be testable without mocks for external systems.
- Long-running or multi-step state transitions SHOULD be verifiable
through reproducible event logs or snapshot-style tests.
- Commits SHOULD be atomic and map to individual tasks where
practical.
- Layer boundary compliance MUST be verified by automated import
rules or architectural tests. Agent-assisted or manual review MAY
supplement but not replace automated checks.
rules or architectural tests.
- All automated quality gates MUST run at the earliest feasible
enforcement point (currently pre-commit via Lefthook). No gate
may exist only as a CI step or manual process.
- When a feature adds, removes, or changes user-facing capabilities
described in README.md, the README MUST be updated in the same
change. Features that materially alter what the product does or
how it is set up SHOULD also be reflected in the README.
- When a feature changes the tech stack, project structure, or
architectural patterns documented in CLAUDE.md, the CLAUDE.md
MUST be updated in the same change.
## Governance
@@ -142,4 +140,4 @@ MUST comply with its principles.
**Compliance review**: Every spec and plan MUST include a
Constitution Check section validating adherence to all principles.
**Version**: 1.0.3 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-03
**Version**: 3.0.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-11

105
CLAUDE.md
View File

@@ -5,7 +5,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Commands
```bash
pnpm check # Merge gate — must pass before every commit (knip + format + lint + typecheck + test)
pnpm check # Merge gate — must pass before every commit (audit + knip + biome + typecheck + test/coverage + jscpd)
pnpm knip # Unused code detection (Knip)
pnpm test # Run all tests (Vitest)
pnpm test:watch # Tests in watch mode
@@ -27,11 +27,40 @@ apps/web (React 19 + Vite) → packages/application (use cases) → packages
```
- **Domain** — Pure functions, no I/O, no framework imports. All state transitions are deterministic. Errors returned as values (`DomainError`), never thrown. Adapters may throw only for programmer errors.
- **Application** — Orchestrates domain calls via port interfaces (e.g., `EncounterStore`). No business logic here.
- **Web** — React adapter. Implements ports using hooks/state.
- **Application** — Orchestrates domain calls via port interfaces (`EncounterStore`, `BestiarySourceCache`). No business logic here.
- **Web** — React adapter. Implements ports using hooks/state. All UI components, routing, and user interaction live here.
Layer boundaries are enforced by `scripts/check-layer-boundaries.mjs`, which runs as a Vitest test. Domain and application must never import from React, Vite, or upper layers.
### Data & Storage
- **localStorage** — encounter persistence (adapter layer, JSON serialization)
- **IndexedDB** — bestiary source cache (`apps/web/src/adapters/bestiary-cache.ts`, via `idb` wrapper)
- **`data/bestiary/index.json`** — pre-built search index for creature lookup, generated by `scripts/generate-bestiary-index.mjs`
### Project Structure
```
apps/web/ React app — components, hooks, adapters
packages/domain/src/ Pure state transitions, types, validation
packages/application/src/ Use cases, port interfaces
data/bestiary/ Bestiary search index
scripts/ Build tooling (layer checks, index generation)
specs/NNN-feature-name/ Feature specs (spec.md, plan.md, tasks.md)
.specify/ Speckit config (templates, scripts, constitution)
docs/agents/ RPI skill artifacts (research reports, plans)
.claude/skills/ Agent skills (rpi-research, rpi-plan, rpi-implement)
```
## Tech Stack
- TypeScript 5.8 (strict mode, `verbatimModuleSyntax`)
- React 19, Vite 6, Tailwind CSS v4
- Lucide React (icons)
- `idb` (IndexedDB wrapper for bestiary cache)
- Biome 2.0 (formatting + linting), Knip (unused code), jscpd (copy-paste detection)
- Vitest (testing, v8 coverage), Lefthook (pre-commit hooks)
## Conventions
- **Biome 2.0** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically.
@@ -39,7 +68,40 @@ Layer boundaries are enforced by `scripts/check-layer-boundaries.mjs`, which run
- **Branded types** for identity values (e.g., `CombatantId`). Prefer immutability/`readonly` where practical.
- **Domain events** are plain data objects with a `type` discriminant — no classes.
- **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/<feature>/` with spec.md, plan.md, tasks.md. 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`.
- **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.
## Speckit Workflow
Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Specs are **living documents** that describe features, not individual changes.
### Issue-driven workflow
- `/write-issue` — create a well-structured Gitea issue via interactive interview
- `/integrate-issue <number>` — fetch an issue, route it to the right spec, and update the spec with the new/changed requirements. Then implement directly.
- `/sync-issue <number>` — push acceptance criteria from the spec back to the Gitea issue
### RPI skills (Research → Plan → Implement)
- `rpi-research` — deep codebase research producing a written report in `docs/agents/research/`
- `rpi-plan` — interactive phased implementation plan in `docs/agents/plans/`
- `rpi-implement` — execute a plan file phase by phase with automated + manual verification
### Choosing the right workflow by scope
| Scope | Workflow |
|---|---|
| Bug fix / CSS tweak | Just fix it, commit |
| Small change to existing feature | `/integrate-issue` → implement → commit |
| Larger addition to existing feature | `/integrate-issue``rpi-research``rpi-plan``rpi-implement` |
| New feature | `/speckit.specify``/speckit.clarify``/speckit.plan``/speckit.tasks``/speckit.implement` |
Speckit manages **what** to build (specs as living documents). RPI manages **how** to build it (research, planning, execution). The full speckit pipeline is for new features. For changes to existing features, update the spec via `/integrate-issue`, then use RPI skills if the change is non-trivial.
### Current feature specs
- `specs/001-combatant-management/` — CRUD, persistence, clear, batch add, confirm buttons
- `specs/002-turn-tracking/` — rounds, turn order, advance/retreat, top bar
- `specs/003-combatant-state/` — HP, AC, conditions, concentration, initiative
- `specs/004-bestiary/` — search index, stat blocks, source management, panel UX
- `specs/005-player-characters/` — persistent player character templates (CRUD), search & add to encounters, color/icon visual distinction, `PlayerCharacterStore` port
## Constitution (key principles)
@@ -49,38 +111,11 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work:
2. **Layered Architecture** — Domain → Application → Adapters. Never skip layers or reverse dependencies.
3. **Clarification-First** — Ask before making non-trivial assumptions.
4. **MVP Baseline** — Say "MVP baseline does not include X", never permanent bans.
5. **Every feature begins with a spec** — Spec → Plan → Tasks → Implementation.
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.x (strict mode, verbatimModuleSyntax) + React 19, Vite (003-remove-combatant)
- In-memory React state (local-first, single-user MVP) (003-remove-combatant)
- TypeScript 5.x (project), Go binary via npm (Lefthook) + `lefthook` (npm devDependency) (006-pre-commit-gate)
- TypeScript 5.x (strict mode, verbatimModuleSyntax) + Knip v5 (new), Biome 2.0, Vitest, Vite 6, React 19 (007-add-knip)
- TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Biome 2.0, existing domain/application packages (008-persist-encounter)
- Browser localStorage (adapter layer only) (008-persist-encounter)
- TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Biome 2.0, Vites (009-combatant-hp)
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Tailwind CSS v4, shadcn/ui, Lucide React (icons) (010-ui-baseline)
- N/A (no storage changes — localStorage persistence unchanged) (010-ui-baseline)
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Tailwind CSS v4, shadcn/ui-style components, Lucide React (icons) (011-quick-hp-input)
- N/A (no storage changes -- existing localStorage persistence handles HP via `adjustHpUseCase`) (011-quick-hp-input)
- N/A (no storage changes -- existing localStorage persistence unchanged) (012-turn-navigation)
- N/A (no storage changes -- purely derived state, existing localStorage persistence unchanged) (013-hp-status-indicators)
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + jscpd (new dev dependency), Lefthook (existing), Biome 2.0 (existing), Knip (existing) (015-add-jscpd-gate)
- N/A (no storage changes) (015-add-jscpd-gate)
- Browser localStorage (existing adapter, transparent JSON serialization) (016-combatant-ac)
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Tailwind CSS v4, Lucide React (icons) (017-combat-conditions)
- N/A (no storage changes — existing localStorage persistence unchanged) (019-combatant-row-declutter)
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Tailwind CSS v4, Lucide React (icons) (020-fix-zero-hp-opacity)
- Browser localStorage (existing adapter, extended for creatureId) (021-bestiary-statblock)
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Tailwind CSS v4 + React 19, Tailwind CSS v4, Vite 6 (022-fixed-layout-bars)
- N/A (no storage changes -- purely presentational) (022-fixed-layout-bars)
- Browser localStorage (existing adapter, updated to handle empty encounters) (023-clear-encounter)
- N/A (no storage changes — purely presentational fix) (024-fix-hp-popover-overflow)
- N/A (no storage changes — purely derived from existing bestiary data) (025-display-initiative)
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Tailwind CSS v4, Lucide React (icons), Vite 6 (026-roll-initiative)
- N/A (no storage changes — existing localStorage persistence handles initiative via `setInitiativeUseCase`) (026-roll-initiative)
- N/A (no storage changes — purely presentational) (027-ui-polish)
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Tailwind CSS v4, Vite 6 + Tailwind CSS v4 (CSS-native `@theme` theming), Lucide React (icons) (028-semantic-hover-tokens)
- 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
- 003-remove-combatant: Added TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite
- 005-player-characters: Added TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Vite 6, Tailwind CSS v4, Lucide Reac

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM node:22-slim AS build
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/domain/package.json packages/domain/
COPY packages/application/package.json packages/application/
COPY apps/web/package.json apps/web/
RUN pnpm install --frozen-lockfile --ignore-scripts
COPY . .
RUN pnpm --filter web build
FROM nginx:alpine
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

View File

@@ -1,10 +1,18 @@
# Initiative Tracker
# Encounter Console
A turn-based initiative tracker for tabletop RPG encounters. Click "Next Turn" to cycle through combatants and advance rounds.
A local-first initiative tracker and encounter manager for tabletop RPGs (D&D 5e / 2024). Runs entirely in the browser — no server, no account, no data leaves your machine.
## What it does
- **Initiative tracking** — add combatants (batch-add from bestiary, custom creatures with optional stats), roll initiative (manual or d20), cycle turns and rounds
- **Encounter state** — HP, AC, conditions, concentration tracking with visual status indicators
- **Bestiary integration** — import bestiary JSON sources, search creatures, and view full stat blocks
- **Player characters** — create reusable player character templates with name, AC, HP, color, and icon; search and add them to encounters with pre-filled stats; manage (edit/delete) from a dedicated panel
- **Persistent** — encounters survive page reloads via localStorage; bestiary data cached in IndexedDB; player characters stored independently
## Prerequisites
- Node.js 22
- Node.js 22+
- pnpm 10.6+
## Getting Started
@@ -14,9 +22,7 @@ pnpm install
pnpm --filter web dev
```
Open the URL printed in your terminal (typically `http://localhost:5173`).
The app starts with a 3-combatant demo encounter (Aria, Brak, Cael). Click **Next Turn** to advance through the initiative order. When the last combatant finishes their turn, the round number increments and the cycle restarts.
Open `http://localhost:5173`.
## Scripts
@@ -24,5 +30,27 @@ The app starts with a 3-combatant demo encounter (Aria, Brak, Cael). Click **Nex
|---------|-------------|
| `pnpm --filter web dev` | Start the dev server |
| `pnpm --filter web build` | Production build |
| `pnpm test` | Run all tests |
| `pnpm check` | Full merge gate (format, lint, typecheck, test) |
| `pnpm test` | Run all tests (Vitest) |
| `pnpm check` | Full merge gate (knip, biome, typecheck, test, jscpd) |
## Project Structure
```
apps/web/ React 19 + Vite — UI components, hooks, adapters
packages/domain/ Pure functions — state transitions, types, validation
packages/app/ Use cases — orchestrates domain via port interfaces
data/bestiary/ Bestiary index for creature search
scripts/ Build tooling (layer boundary checks, index generation)
specs/ Feature specifications (spec → plan → tasks)
```
## Architecture
Strict layered architecture with enforced dependency direction:
```
apps/web (adapters) → packages/application (use cases) → packages/domain (pure logic)
```
Domain is pure — no I/O, no randomness, no framework imports. Layer boundaries are enforced by automated import checks that run as part of the test suite. See [CLAUDE.md](./CLAUDE.md) for full conventions.

View File

@@ -13,6 +13,7 @@
"@initiative/domain": "workspace:*",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"idb": "^8.0.3",
"lucide-react": "^0.577.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -20,9 +21,12 @@
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"jsdom": "^28.1.0",
"tailwindcss": "^4.2.1",
"vite": "^6.2.0"
}

View File

@@ -3,13 +3,20 @@ import {
rollInitiativeUseCase,
} from "@initiative/application";
import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
import { Plus } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActionBar } from "./components/action-bar";
import { CombatantRow } from "./components/combatant-row";
import { CreatePlayerModal } from "./components/create-player-modal";
import { PlayerManagement } from "./components/player-management";
import { SourceManager } from "./components/source-manager";
import { StatBlockPanel } from "./components/stat-block-panel";
import { Toast } from "./components/toast";
import { TurnNavigation } from "./components/turn-navigation";
import { useBestiary } from "./hooks/use-bestiary";
import { type SearchResult, useBestiary } from "./hooks/use-bestiary";
import { useBulkImport } from "./hooks/use-bulk-import";
import { useEncounter } from "./hooks/use-encounter";
import { usePlayerCharacters } from "./hooks/use-player-characters";
function rollDice(): number {
return Math.floor(Math.random() * 20) + 1;
@@ -31,47 +38,74 @@ export function App() {
toggleCondition,
toggleConcentration,
addFromBestiary,
addFromPlayerCharacter,
makeStore,
} = useEncounter();
const { search, getCreature, isLoaded } = useBestiary();
const {
characters: playerCharacters,
createCharacter: createPlayerCharacter,
editCharacter: editPlayerCharacter,
deleteCharacter: deletePlayerCharacter,
} = usePlayerCharacters();
const [selectedCreature, setSelectedCreature] = useState<Creature | null>(
const [createPlayerOpen, setCreatePlayerOpen] = useState(false);
const [managementOpen, setManagementOpen] = useState(false);
const [editingPlayer, setEditingPlayer] = useState<
(typeof playerCharacters)[number] | undefined
>(undefined);
const {
search,
getCreature,
isLoaded,
isSourceCached,
fetchAndCacheSource,
uploadAndCacheSource,
refreshCache,
} = useBestiary();
const bulkImport = useBulkImport();
const [selectedCreatureId, setSelectedCreatureId] =
useState<CreatureId | null>(null);
const [bulkImportMode, setBulkImportMode] = useState(false);
const [sourceManagerOpen, setSourceManagerOpen] = useState(false);
const [isRightPanelFolded, setIsRightPanelFolded] = useState(false);
const [pinnedCreatureId, setPinnedCreatureId] = useState<CreatureId | null>(
null,
);
const [suggestions, setSuggestions] = useState<Creature[]>([]);
const [isWideDesktop, setIsWideDesktop] = useState(
() => window.matchMedia("(min-width: 1280px)").matches,
);
useEffect(() => {
const mq = window.matchMedia("(min-width: 1280px)");
const handler = (e: MediaQueryListEvent) => setIsWideDesktop(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
const selectedCreature: Creature | null = selectedCreatureId
? (getCreature(selectedCreatureId) ?? null)
: null;
const pinnedCreature: Creature | null = pinnedCreatureId
? (getCreature(pinnedCreatureId) ?? null)
: null;
const handleAddFromBestiary = useCallback(
(creature: Creature) => {
addFromBestiary(creature);
setSelectedCreature(creature);
(result: SearchResult) => {
addFromBestiary(result);
},
[addFromBestiary],
);
const handleShowStatBlock = useCallback((creature: Creature) => {
setSelectedCreature(creature);
const handleCombatantStatBlock = useCallback((creatureId: string) => {
setSelectedCreatureId(creatureId as CreatureId);
setIsRightPanelFolded(false);
}, []);
const handleCombatantStatBlock = useCallback(
(creatureId: string) => {
const creature = getCreature(creatureId as CreatureId);
if (creature) setSelectedCreature(creature);
},
[getCreature],
);
const handleSearchChange = useCallback(
(query: string) => {
if (!isLoaded || query.length < 2) {
setSuggestions([]);
return;
}
setSuggestions(search(query));
},
[isLoaded, search],
);
const handleRollInitiative = useCallback(
(id: CombatantId) => {
rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature);
@@ -83,6 +117,61 @@ export function App() {
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
}, [makeStore, getCreature]);
const handleViewStatBlock = useCallback((result: SearchResult) => {
const slug = result.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
setSelectedCreatureId(cId);
setIsRightPanelFolded(false);
}, []);
const handleBulkImport = useCallback(() => {
setBulkImportMode(true);
setSelectedCreatureId(null);
}, []);
const handleStartBulkImport = useCallback(
(baseUrl: string) => {
bulkImport.startImport(
baseUrl,
fetchAndCacheSource,
isSourceCached,
refreshCache,
);
},
[bulkImport.startImport, fetchAndCacheSource, isSourceCached, refreshCache],
);
const handleBulkImportDone = useCallback(() => {
setBulkImportMode(false);
bulkImport.reset();
}, [bulkImport.reset]);
const handleDismissBrowsePanel = useCallback(() => {
setSelectedCreatureId(null);
setBulkImportMode(false);
}, []);
const handleToggleFold = useCallback(() => {
setIsRightPanelFolded((f) => !f);
}, []);
const handlePin = useCallback(() => {
if (selectedCreatureId) {
setPinnedCreatureId((prev) =>
prev === selectedCreatureId ? null : selectedCreatureId,
);
}
}, [selectedCreatureId]);
const handleUnpin = useCallback(() => {
setPinnedCreatureId(null);
}, []);
const actionBarInputRef = useRef<HTMLInputElement>(null);
// Auto-scroll to the active combatant when the turn changes
const activeRowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -102,9 +191,8 @@ export function App() {
if (!window.matchMedia("(min-width: 1024px)").matches) return;
const active = encounter.combatants[encounter.activeIndex];
if (!active?.creatureId || !isLoaded) return;
const creature = getCreature(active.creatureId as CreatureId);
if (creature) setSelectedCreature(creature);
}, [encounter.activeIndex, encounter.combatants, getCreature, isLoaded]);
setSelectedCreatureId(active.creatureId as CreatureId);
}, [encounter.activeIndex, encounter.combatants, isLoaded]);
return (
<div className="flex h-screen flex-col">
@@ -117,16 +205,29 @@ export function App() {
onRetreatTurn={retreatTurn}
onClearEncounter={clearEncounter}
onRollAllInitiative={handleRollAllInitiative}
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
/>
</div>
{sourceManagerOpen && (
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
<SourceManager onCacheCleared={refreshCache} />
</div>
)}
{/* Scrollable area — combatant list */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="flex flex-col pb-2">
<div
className={`flex flex-col px-2 py-2${encounter.combatants.length === 0 ? " h-full items-center justify-center" : ""}`}
>
{encounter.combatants.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground">
No combatants yet add one to get started
</p>
<button
type="button"
onClick={() => actionBarInputRef.current?.focus()}
className="animate-breathe cursor-pointer text-muted-foreground transition-colors hover:text-primary"
>
<Plus className="size-14" />
</button>
) : (
encounter.combatants.map((c, i) => (
<CombatantRow
@@ -163,17 +264,123 @@ export function App() {
onAddFromBestiary={handleAddFromBestiary}
bestiarySearch={search}
bestiaryLoaded={isLoaded}
suggestions={suggestions}
onSearchChange={handleSearchChange}
onShowStatBlock={handleShowStatBlock}
onViewStatBlock={handleViewStatBlock}
onBulkImport={handleBulkImport}
bulkImportDisabled={bulkImport.state.status === "loading"}
inputRef={actionBarInputRef}
playerCharacters={playerCharacters}
onAddFromPlayerCharacter={addFromPlayerCharacter}
onManagePlayers={() => setManagementOpen(true)}
/>
</div>
</div>
{/* Stat Block Panel */}
{/* Pinned Stat Block Panel (left) */}
{pinnedCreatureId && isWideDesktop && (
<StatBlockPanel
creatureId={pinnedCreatureId}
creature={pinnedCreature}
isSourceCached={isSourceCached}
fetchAndCacheSource={fetchAndCacheSource}
uploadAndCacheSource={uploadAndCacheSource}
refreshCache={refreshCache}
panelRole="pinned"
isFolded={false}
onToggleFold={() => {}}
onPin={() => {}}
onUnpin={handleUnpin}
showPinButton={false}
side="left"
onDismiss={() => {}}
/>
)}
{/* Browse Stat Block Panel (right) */}
<StatBlockPanel
creatureId={selectedCreatureId}
creature={selectedCreature}
onClose={() => setSelectedCreature(null)}
isSourceCached={isSourceCached}
fetchAndCacheSource={fetchAndCacheSource}
uploadAndCacheSource={uploadAndCacheSource}
refreshCache={refreshCache}
panelRole="browse"
isFolded={isRightPanelFolded}
onToggleFold={handleToggleFold}
onPin={handlePin}
onUnpin={() => {}}
showPinButton={isWideDesktop && !!selectedCreature}
side="right"
onDismiss={handleDismissBrowsePanel}
bulkImportMode={bulkImportMode}
bulkImportState={bulkImport.state}
onStartBulkImport={handleStartBulkImport}
onBulkImportDone={handleBulkImportDone}
/>
{/* Toast for bulk import progress when panel is closed */}
{bulkImport.state.status === "loading" && !bulkImportMode && (
<Toast
message={`Loading sources... ${bulkImport.state.completed + bulkImport.state.failed}/${bulkImport.state.total}`}
progress={
bulkImport.state.total > 0
? (bulkImport.state.completed + bulkImport.state.failed) /
bulkImport.state.total
: 0
}
onDismiss={() => {}}
/>
)}
{bulkImport.state.status === "complete" && !bulkImportMode && (
<Toast
message="All sources loaded"
onDismiss={bulkImport.reset}
autoDismissMs={3000}
/>
)}
{bulkImport.state.status === "partial-failure" && !bulkImportMode && (
<Toast
message={`Loaded ${bulkImport.state.completed}/${bulkImport.state.total} sources (${bulkImport.state.failed} failed)`}
onDismiss={bulkImport.reset}
/>
)}
<CreatePlayerModal
open={createPlayerOpen}
onClose={() => {
setCreatePlayerOpen(false);
setEditingPlayer(undefined);
}}
onSave={(name, ac, maxHp, color, icon) => {
if (editingPlayer) {
editPlayerCharacter?.(editingPlayer.id, {
name,
ac,
maxHp,
color,
icon,
});
} else {
createPlayerCharacter(name, ac, maxHp, color, icon);
}
}}
playerCharacter={editingPlayer}
/>
<PlayerManagement
open={managementOpen}
onClose={() => setManagementOpen(false)}
characters={playerCharacters}
onEdit={(pc) => {
setEditingPlayer(pc);
setCreatePlayerOpen(true);
setManagementOpen(false);
}}
onDelete={(id) => deletePlayerCharacter?.(id)}
onCreate={() => {
setEditingPlayer(undefined);
setCreatePlayerOpen(true);
setManagementOpen(false);
}}
/>
</div>
);

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
getAllSourceCodes,
getDefaultFetchUrl,
} from "../adapters/bestiary-index-adapter.js";
describe("getAllSourceCodes", () => {
it("returns all keys from the index sources object", () => {
const codes = getAllSourceCodes();
expect(codes.length).toBeGreaterThan(0);
expect(Array.isArray(codes)).toBe(true);
for (const code of codes) {
expect(typeof code).toBe("string");
}
});
});
describe("getDefaultFetchUrl", () => {
it("returns the default URL when no baseUrl is provided", () => {
const url = getDefaultFetchUrl("XMM");
expect(url).toBe(
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/bestiary-xmm.json",
);
});
it("constructs URL from baseUrl with trailing slash", () => {
const url = getDefaultFetchUrl("PHB", "https://example.com/data/");
expect(url).toBe("https://example.com/data/bestiary-phb.json");
});
it("normalizes baseUrl without trailing slash", () => {
const url = getDefaultFetchUrl("PHB", "https://example.com/data");
expect(url).toBe("https://example.com/data/bestiary-phb.json");
});
it("lowercases the source code in the filename", () => {
const url = getDefaultFetchUrl("MM", "https://example.com/");
expect(url).toBe("https://example.com/bestiary-mm.json");
});
});

View File

@@ -0,0 +1,218 @@
// @vitest-environment jsdom
import {
act,
cleanup,
fireEvent,
render,
screen,
} from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ConfirmButton } from "../components/ui/confirm-button";
function XIcon() {
return <span data-testid="x-icon">X</span>;
}
function renderButton(
props: Partial<Parameters<typeof ConfirmButton>[0]> = {},
) {
const onConfirm = props.onConfirm ?? vi.fn();
render(
<ConfirmButton
icon={<XIcon />}
label="Remove combatant"
onConfirm={onConfirm}
{...props}
/>,
);
return { onConfirm };
}
describe("ConfirmButton", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
cleanup();
});
it("renders the default icon in idle state", () => {
renderButton();
expect(screen.getByTestId("x-icon")).toBeTruthy();
expect(screen.getByRole("button")).toHaveAttribute(
"aria-label",
"Remove combatant",
);
});
it("transitions to confirm state with Check icon on first click", () => {
renderButton();
fireEvent.click(screen.getByRole("button"));
expect(screen.queryByTestId("x-icon")).toBeNull();
expect(screen.getByRole("button")).toHaveAttribute(
"aria-label",
"Confirm remove combatant",
);
expect(screen.getByRole("button").className).toContain("bg-destructive");
});
it("calls onConfirm on second click in confirm state", () => {
const { onConfirm } = renderButton();
const button = screen.getByRole("button");
fireEvent.click(button);
fireEvent.click(button);
expect(onConfirm).toHaveBeenCalledOnce();
});
it("auto-reverts after 5 seconds", () => {
renderButton();
fireEvent.click(screen.getByRole("button"));
expect(screen.queryByTestId("x-icon")).toBeNull();
act(() => {
vi.advanceTimersByTime(5000);
});
expect(screen.getByTestId("x-icon")).toBeTruthy();
});
it("reverts on Escape key", () => {
renderButton();
fireEvent.click(screen.getByRole("button"));
expect(screen.queryByTestId("x-icon")).toBeNull();
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.getByTestId("x-icon")).toBeTruthy();
});
it("reverts on click outside", () => {
renderButton();
fireEvent.click(screen.getByRole("button"));
expect(screen.queryByTestId("x-icon")).toBeNull();
fireEvent.mouseDown(document.body);
expect(screen.getByTestId("x-icon")).toBeTruthy();
});
it("does not enter confirm state when disabled", () => {
renderButton({ disabled: true });
fireEvent.click(screen.getByRole("button"));
expect(screen.getByTestId("x-icon")).toBeTruthy();
});
it("cleans up timer on unmount", () => {
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
renderButton();
fireEvent.click(screen.getByRole("button"));
cleanup();
expect(clearTimeoutSpy).toHaveBeenCalled();
clearTimeoutSpy.mockRestore();
});
it("manages independent instances separately", () => {
const onConfirm1 = vi.fn();
const onConfirm2 = vi.fn();
render(
<>
<ConfirmButton
icon={<span data-testid="icon-1">1</span>}
label="Remove first"
onConfirm={onConfirm1}
/>
<ConfirmButton
icon={<span data-testid="icon-2">2</span>}
label="Remove second"
onConfirm={onConfirm2}
/>
</>,
);
const buttons = screen.getAllByRole("button");
fireEvent.click(buttons[0]);
// First is confirming, second is still idle
expect(screen.queryByTestId("icon-1")).toBeNull();
expect(screen.getByTestId("icon-2")).toBeTruthy();
});
// T008: Keyboard-specific tests
it("Enter key triggers confirm state", () => {
renderButton();
const button = screen.getByRole("button");
// Native button handles Enter via click event
fireEvent.click(button);
expect(screen.queryByTestId("x-icon")).toBeNull();
expect(button).toHaveAttribute("aria-label", "Confirm remove combatant");
});
it("Enter in confirm state calls onConfirm", () => {
const { onConfirm } = renderButton();
const button = screen.getByRole("button");
fireEvent.click(button); // enter confirm state
fireEvent.click(button); // confirm
expect(onConfirm).toHaveBeenCalledOnce();
});
it("Escape in confirm state reverts", () => {
renderButton();
fireEvent.click(screen.getByRole("button"));
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.getByTestId("x-icon")).toBeTruthy();
expect(screen.getByRole("button")).toHaveAttribute(
"aria-label",
"Remove combatant",
);
});
it("blur event reverts confirm state", () => {
renderButton();
const button = screen.getByRole("button");
fireEvent.click(button);
expect(screen.queryByTestId("x-icon")).toBeNull();
fireEvent.blur(button);
expect(screen.getByTestId("x-icon")).toBeTruthy();
});
it("Enter/Space keydown stops propagation to prevent parent handlers", () => {
const parentHandler = vi.fn();
render(
// biome-ignore lint/a11y/noStaticElementInteractions: test wrapper
<div onKeyDown={parentHandler}>
<ConfirmButton
icon={<XIcon />}
label="Remove combatant"
onConfirm={vi.fn()}
/>
</div>,
);
const button = screen.getByRole("button");
fireEvent.keyDown(button, { key: "Enter" });
fireEvent.keyDown(button, { key: " " });
expect(parentHandler).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,263 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import type { Creature, CreatureId } from "@initiative/domain";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StatBlockPanel } from "../components/stat-block-panel";
const CREATURE_ID = "srd:goblin" as CreatureId;
const CREATURE: Creature = {
id: CREATURE_ID,
name: "Goblin",
source: "SRD",
sourceDisplayName: "SRD",
size: "Small",
type: "humanoid",
alignment: "neutral evil",
ac: 15,
hp: { average: 7, formula: "2d6" },
speed: "30 ft.",
abilities: { str: 8, dex: 14, con: 10, int: 10, wis: 8, cha: 8 },
cr: "1/4",
initiativeProficiency: 0,
proficiencyBonus: 2,
passive: 9,
};
function mockMatchMedia(matches: boolean) {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
interface PanelProps {
creatureId?: CreatureId | null;
creature?: Creature | null;
panelRole?: "browse" | "pinned";
isFolded?: boolean;
onToggleFold?: () => void;
onPin?: () => void;
onUnpin?: () => void;
showPinButton?: boolean;
side?: "left" | "right";
onDismiss?: () => void;
bulkImportMode?: boolean;
}
function renderPanel(overrides: PanelProps = {}) {
const props = {
creatureId: CREATURE_ID,
creature: CREATURE,
isSourceCached: vi.fn().mockResolvedValue(true),
fetchAndCacheSource: vi.fn(),
uploadAndCacheSource: vi.fn(),
refreshCache: vi.fn(),
panelRole: "browse" as const,
isFolded: false,
onToggleFold: vi.fn(),
onPin: vi.fn(),
onUnpin: vi.fn(),
showPinButton: false,
side: "right" as const,
onDismiss: vi.fn(),
...overrides,
};
render(<StatBlockPanel {...props} />);
return props;
}
describe("Stat Block Panel Fold/Unfold and Pin", () => {
beforeEach(() => {
mockMatchMedia(true); // desktop by default
});
afterEach(cleanup);
describe("US1: Fold and Unfold", () => {
it("shows fold button instead of close button on desktop", () => {
renderPanel();
expect(
screen.getByRole("button", { name: "Fold stat block panel" }),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /close/i }),
).not.toBeInTheDocument();
});
it("does not show 'Stat Block' heading", () => {
renderPanel();
expect(screen.queryByText("Stat Block")).not.toBeInTheDocument();
});
it("renders folded tab with creature name when isFolded is true", () => {
renderPanel({ isFolded: true });
expect(screen.getByText("Goblin")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Unfold stat block panel" }),
).toBeInTheDocument();
});
it("calls onToggleFold when fold button is clicked", () => {
const props = renderPanel();
fireEvent.click(
screen.getByRole("button", { name: "Fold stat block panel" }),
);
expect(props.onToggleFold).toHaveBeenCalledTimes(1);
});
it("calls onToggleFold when folded tab is clicked", () => {
const props = renderPanel({ isFolded: true });
fireEvent.click(
screen.getByRole("button", { name: "Unfold stat block panel" }),
);
expect(props.onToggleFold).toHaveBeenCalledTimes(1);
});
it("applies translate-x class when folded (right side)", () => {
renderPanel({ isFolded: true, side: "right" });
const panel = screen
.getByRole("button", { name: "Unfold stat block panel" })
.closest("div");
expect(panel?.className).toContain("translate-x-[calc(100%-40px)]");
});
it("applies translate-x-0 when expanded", () => {
renderPanel({ isFolded: false });
const foldBtn = screen.getByRole("button", {
name: "Fold stat block panel",
});
const panel = foldBtn.closest("div.fixed") as HTMLElement;
expect(panel?.className).toContain("translate-x-0");
});
});
describe("US1: Mobile behavior", () => {
beforeEach(() => {
mockMatchMedia(false); // mobile
});
it("shows fold button instead of X close button on mobile drawer", () => {
renderPanel();
expect(
screen.getByRole("button", { name: "Fold stat block panel" }),
).toBeInTheDocument();
// No X close icon button — only backdrop dismiss and fold toggle
const buttons = screen.getAllByRole("button");
const buttonLabels = buttons.map((b) => b.getAttribute("aria-label"));
expect(buttonLabels).not.toContain("Close");
});
it("calls onDismiss when backdrop is clicked on mobile", () => {
const props = renderPanel();
fireEvent.click(screen.getByRole("button", { name: "Close stat block" }));
expect(props.onDismiss).toHaveBeenCalledTimes(1);
});
it("does not render pinned panel on mobile", () => {
const { container } = render(
<StatBlockPanel
creatureId={CREATURE_ID}
creature={CREATURE}
isSourceCached={vi.fn().mockResolvedValue(true)}
fetchAndCacheSource={vi.fn()}
uploadAndCacheSource={vi.fn()}
refreshCache={vi.fn()}
panelRole="pinned"
isFolded={false}
onToggleFold={vi.fn()}
onPin={vi.fn()}
onUnpin={vi.fn()}
showPinButton={false}
side="left"
onDismiss={vi.fn()}
/>,
);
expect(container.innerHTML).toBe("");
});
});
describe("US2: Pin and Unpin", () => {
it("shows pin button when showPinButton is true on desktop", () => {
renderPanel({ showPinButton: true });
expect(
screen.getByRole("button", { name: "Pin creature" }),
).toBeInTheDocument();
});
it("hides pin button when showPinButton is false", () => {
renderPanel({ showPinButton: false });
expect(
screen.queryByRole("button", { name: "Pin creature" }),
).not.toBeInTheDocument();
});
it("calls onPin when pin button is clicked", () => {
const props = renderPanel({ showPinButton: true });
fireEvent.click(screen.getByRole("button", { name: "Pin creature" }));
expect(props.onPin).toHaveBeenCalledTimes(1);
});
it("shows unpin button for pinned role", () => {
renderPanel({ panelRole: "pinned", side: "left" });
expect(
screen.getByRole("button", { name: "Unpin creature" }),
).toBeInTheDocument();
});
it("calls onUnpin when unpin button is clicked", () => {
const props = renderPanel({ panelRole: "pinned", side: "left" });
fireEvent.click(screen.getByRole("button", { name: "Unpin creature" }));
expect(props.onUnpin).toHaveBeenCalledTimes(1);
});
it("positions pinned panel on the left side", () => {
renderPanel({ panelRole: "pinned", side: "left" });
const unpinBtn = screen.getByRole("button", {
name: "Unpin creature",
});
const panel = unpinBtn.closest("div.fixed") as HTMLElement;
expect(panel?.className).toContain("left-0");
expect(panel?.className).toContain("border-r");
});
it("positions browse panel on the right side", () => {
renderPanel({ panelRole: "browse", side: "right" });
const foldBtn = screen.getByRole("button", {
name: "Fold stat block panel",
});
const panel = foldBtn.closest("div.fixed") as HTMLElement;
expect(panel?.className).toContain("right-0");
expect(panel?.className).toContain("border-l");
});
});
describe("US3: Fold independence with pinned panel", () => {
it("pinned panel has no fold button", () => {
renderPanel({ panelRole: "pinned", side: "left" });
expect(
screen.queryByRole("button", { name: /fold/i }),
).not.toBeInTheDocument();
});
it("pinned panel is always expanded (no translate offset)", () => {
renderPanel({ panelRole: "pinned", side: "left", isFolded: false });
const unpinBtn = screen.getByRole("button", {
name: "Unpin creature",
});
const panel = unpinBtn.closest("div.fixed") as HTMLElement;
expect(panel?.className).toContain("translate-x-0");
});
});
});

View File

@@ -0,0 +1,235 @@
import { describe, expect, it, vi } from "vitest";
import * as indexAdapter from "../adapters/bestiary-index-adapter.js";
// We test the bulk import logic by extracting and exercising the async flow.
// Since useBulkImport is a thin React wrapper around async logic,
// we test the core behavior via a direct simulation.
vi.mock("../adapters/bestiary-index-adapter.js", async () => {
const actual = await vi.importActual<
typeof import("../adapters/bestiary-index-adapter.js")
>("../adapters/bestiary-index-adapter.js");
return {
...actual,
getAllSourceCodes: vi.fn(),
};
});
const mockGetAllSourceCodes = vi.mocked(indexAdapter.getAllSourceCodes);
interface BulkImportState {
status: "idle" | "loading" | "complete" | "partial-failure";
total: number;
completed: number;
failed: number;
}
/** Simulate the core bulk import logic extracted from the hook */
async function runBulkImport(
baseUrl: string,
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>,
isSourceCached: (sourceCode: string) => Promise<boolean>,
refreshCache: () => Promise<void>,
): Promise<BulkImportState> {
const allCodes = indexAdapter.getAllSourceCodes();
const total = allCodes.length;
const cacheChecks = await Promise.all(
allCodes.map(async (code) => ({
code,
cached: await isSourceCached(code),
})),
);
const alreadyCached = cacheChecks.filter((c) => c.cached).length;
const uncached = cacheChecks.filter((c) => !c.cached);
if (uncached.length === 0) {
return { status: "complete", total, completed: total, failed: 0 };
}
let completed = alreadyCached;
let failed = 0;
await Promise.allSettled(
uncached.map(async ({ code }) => {
const url = indexAdapter.getDefaultFetchUrl(code, baseUrl);
try {
await fetchAndCacheSource(code, url);
completed++;
} catch {
failed++;
}
}),
);
await refreshCache();
return {
status: failed > 0 ? "partial-failure" : "complete",
total,
completed,
failed,
};
}
describe("bulk import logic", () => {
it("skips already-cached sources and counts them into completed", async () => {
mockGetAllSourceCodes.mockReturnValue(["SRC1", "SRC2", "SRC3"]);
const fetchAndCache = vi.fn().mockResolvedValue(undefined);
const isSourceCached = vi
.fn()
.mockImplementation((code: string) =>
Promise.resolve(code === "SRC1" || code === "SRC3"),
);
const refreshCache = vi.fn().mockResolvedValue(undefined);
const result = await runBulkImport(
"https://example.com/",
fetchAndCache,
isSourceCached,
refreshCache,
);
expect(fetchAndCache).toHaveBeenCalledTimes(1);
expect(fetchAndCache).toHaveBeenCalledWith(
"SRC2",
"https://example.com/bestiary-src2.json",
);
expect(result.completed).toBe(3);
expect(result.status).toBe("complete");
});
it("increments completed on successful fetch", async () => {
mockGetAllSourceCodes.mockReturnValue(["SRC1"]);
const fetchAndCache = vi.fn().mockResolvedValue(undefined);
const isSourceCached = vi.fn().mockResolvedValue(false);
const refreshCache = vi.fn().mockResolvedValue(undefined);
const result = await runBulkImport(
"https://example.com/",
fetchAndCache,
isSourceCached,
refreshCache,
);
expect(result.completed).toBe(1);
expect(result.failed).toBe(0);
expect(result.status).toBe("complete");
});
it("increments failed on rejected fetch", async () => {
mockGetAllSourceCodes.mockReturnValue(["SRC1"]);
const fetchAndCache = vi.fn().mockRejectedValue(new Error("fail"));
const isSourceCached = vi.fn().mockResolvedValue(false);
const refreshCache = vi.fn().mockResolvedValue(undefined);
const result = await runBulkImport(
"https://example.com/",
fetchAndCache,
isSourceCached,
refreshCache,
);
expect(result.failed).toBe(1);
expect(result.completed).toBe(0);
expect(result.status).toBe("partial-failure");
});
it("transitions to complete when all succeed", async () => {
mockGetAllSourceCodes.mockReturnValue(["A", "B"]);
const fetchAndCache = vi.fn().mockResolvedValue(undefined);
const isSourceCached = vi.fn().mockResolvedValue(false);
const refreshCache = vi.fn().mockResolvedValue(undefined);
const result = await runBulkImport(
"https://example.com/",
fetchAndCache,
isSourceCached,
refreshCache,
);
expect(result.status).toBe("complete");
expect(result.completed).toBe(2);
expect(result.failed).toBe(0);
});
it("transitions to partial-failure when any fetch fails", async () => {
mockGetAllSourceCodes.mockReturnValue(["A", "B"]);
const fetchAndCache = vi
.fn()
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error("fail"));
const isSourceCached = vi.fn().mockResolvedValue(false);
const refreshCache = vi.fn().mockResolvedValue(undefined);
const result = await runBulkImport(
"https://example.com/",
fetchAndCache,
isSourceCached,
refreshCache,
);
expect(result.status).toBe("partial-failure");
expect(result.failed).toBe(1);
});
it("immediately transitions to complete when all sources are cached", async () => {
mockGetAllSourceCodes.mockReturnValue(["A", "B", "C"]);
const fetchAndCache = vi.fn();
const isSourceCached = vi.fn().mockResolvedValue(true);
const refreshCache = vi.fn().mockResolvedValue(undefined);
const result = await runBulkImport(
"https://example.com/",
fetchAndCache,
isSourceCached,
refreshCache,
);
expect(result.status).toBe("complete");
expect(result.total).toBe(3);
expect(result.completed).toBe(3);
expect(fetchAndCache).not.toHaveBeenCalled();
});
it("calls refreshCache exactly once when all settle", async () => {
mockGetAllSourceCodes.mockReturnValue(["A", "B"]);
const fetchAndCache = vi.fn().mockResolvedValue(undefined);
const isSourceCached = vi.fn().mockResolvedValue(false);
const refreshCache = vi.fn().mockResolvedValue(undefined);
await runBulkImport(
"https://example.com/",
fetchAndCache,
isSourceCached,
refreshCache,
);
expect(refreshCache).toHaveBeenCalledTimes(1);
});
it("does not call refreshCache when all sources are already cached", async () => {
mockGetAllSourceCodes.mockReturnValue(["A"]);
const fetchAndCache = vi.fn();
const isSourceCached = vi.fn().mockResolvedValue(true);
const refreshCache = vi.fn().mockResolvedValue(undefined);
await runBulkImport(
"https://example.com/",
fetchAndCache,
isSourceCached,
refreshCache,
);
expect(refreshCache).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
import { normalizeBestiary } from "../bestiary-adapter.js";
import { beforeAll, describe, expect, it } from "vitest";
import {
normalizeBestiary,
setSourceDisplayNames,
} from "../bestiary-adapter.js";
beforeAll(() => {
setSourceDisplayNames({ XMM: "MM 2024" });
});
describe("normalizeBestiary", () => {
it("normalizes a simple creature", () => {

View File

@@ -1,16 +0,0 @@
import { expect, it } from "vitest";
import rawData from "../../../../../data/bestiary/xmm.json";
import { normalizeBestiary } from "../bestiary-adapter.js";
it("normalizes all 503 monsters without error", () => {
const creatures = normalizeBestiary(
rawData as unknown as Parameters<typeof normalizeBestiary>[0],
);
expect(creatures.length).toBe(503);
for (const c of creatures) {
expect(c.name).toBeTruthy();
expect(c.id).toBeTruthy();
expect(c.ac).toBeGreaterThanOrEqual(0);
expect(c.hp.average).toBeGreaterThan(0);
}
});

View File

@@ -17,8 +17,8 @@ interface RawMonster {
size: string[];
type: string | { type: string; tags?: string[]; swarmSize?: string };
alignment?: string[];
ac: (number | { ac: number; from?: string[] })[];
hp: { average: number; formula: string };
ac: (number | { ac: number; from?: string[] } | { special: string })[];
hp: { average?: number; formula?: string; special?: string };
speed: Record<
string,
number | { number: number; condition?: string } | boolean
@@ -38,7 +38,7 @@ interface RawMonster {
vulnerable?: (string | { special: string })[];
conditionImmune?: string[];
languages?: string[];
cr: string | { cr: string };
cr?: string | { cr: string };
trait?: RawEntry[];
action?: RawEntry[];
bonus?: RawEntry[];
@@ -81,9 +81,11 @@ interface RawSpellcasting {
// --- Source mapping ---
const SOURCE_DISPLAY_NAMES: Record<string, string> = {
XMM: "MM 2024",
};
let sourceDisplayNames: Record<string, string> = {};
export function setSourceDisplayNames(names: Record<string, string>): void {
sourceDisplayNames = names;
}
// --- Size mapping ---
@@ -138,7 +140,12 @@ function formatType(
let result = baseType;
if (type.tags && type.tags.length > 0) {
result += ` (${type.tags.map(capitalize).join(", ")})`;
const tagStrs = type.tags
.filter((t): t is string => typeof t === "string")
.map(capitalize);
if (tagStrs.length > 0) {
result += ` (${tagStrs.join(", ")})`;
}
}
if (type.swarmSize) {
const swarmSizeLabel = SIZE_MAP[type.swarmSize] ?? type.swarmSize;
@@ -159,6 +166,14 @@ function extractAc(ac: RawMonster["ac"]): {
if (typeof first === "number") {
return { value: first };
}
if ("special" in first) {
// Variable AC (e.g. spell summons) — parse leading number if possible
const match = first.special.match(/^(\d+)/);
return {
value: match ? Number(match[1]) : 0,
source: first.special,
};
}
return {
value: first.ac,
source: first.from ? stripTags(first.from.join(", ")) : undefined,
@@ -239,26 +254,37 @@ function formatConditionImmunities(
.join(", ");
}
function renderEntries(entries: (string | RawEntryObject)[]): string {
const parts: string[] = [];
for (const entry of entries) {
if (typeof entry === "string") {
parts.push(stripTags(entry));
} else if (entry.type === "list") {
for (const item of entry.items ?? []) {
function renderListItem(item: string | RawEntryObject): string | undefined {
if (typeof item === "string") {
parts.push(`${stripTags(item)}`);
} else if (item.name && item.entries) {
parts.push(
`${stripTags(item.name)}: ${renderEntries(item.entries)}`,
);
return `${stripTags(item)}`;
}
if (item.name && item.entries) {
return `${stripTags(item.name)}: ${renderEntries(item.entries)}`;
}
return undefined;
}
function renderEntryObject(entry: RawEntryObject, parts: string[]): void {
if (entry.type === "list") {
for (const item of entry.items ?? []) {
const rendered = renderListItem(item);
if (rendered) parts.push(rendered);
}
} else if (entry.type === "item" && entry.name && entry.entries) {
parts.push(`${stripTags(entry.name)}: ${renderEntries(entry.entries)}`);
} else if (entry.entries) {
parts.push(renderEntries(entry.entries));
}
}
function renderEntries(entries: (string | RawEntryObject)[]): string {
const parts: string[] = [];
for (const entry of entries) {
if (typeof entry === "string") {
parts.push(stripTags(entry));
} else {
renderEntryObject(entry, parts);
}
}
return parts.join(" ");
}
@@ -337,7 +363,8 @@ function normalizeLegendary(
};
}
function extractCr(cr: string | { cr: string }): string {
function extractCr(cr: string | { cr: string } | undefined): string {
if (cr === undefined) return "—";
return typeof cr === "string" ? cr : cr.cr;
}
@@ -353,7 +380,32 @@ function makeCreatureId(source: string, name: string): CreatureId {
* Normalizes raw 5etools bestiary JSON into domain Creature[].
*/
export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
return raw.monster.map((m) => {
// Filter out _copy entries (reference another source's monster) and
// monsters missing required fields (ac, hp, size, type)
const monsters = raw.monster.filter((m) => {
// biome-ignore lint/suspicious/noExplicitAny: raw JSON may have _copy field
if ((m as any)._copy) return false;
return (
Array.isArray(m.ac) &&
m.ac.length > 0 &&
m.hp !== undefined &&
Array.isArray(m.size) &&
m.size.length > 0 &&
m.type !== undefined
);
});
const creatures: Creature[] = [];
for (const m of monsters) {
try {
creatures.push(normalizeMonster(m));
} catch {
// Skip monsters with unexpected data shapes
}
}
return creatures;
}
function normalizeMonster(m: RawMonster): Creature {
const crStr = extractCr(m.cr);
const ac = extractAc(m.ac);
@@ -361,13 +413,16 @@ export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
id: makeCreatureId(m.source, m.name),
name: m.name,
source: m.source,
sourceDisplayName: SOURCE_DISPLAY_NAMES[m.source] ?? m.source,
sourceDisplayName: sourceDisplayNames[m.source] ?? m.source,
size: formatSize(m.size),
type: formatType(m.type),
alignment: formatAlignment(m.alignment),
ac: ac.value,
acSource: ac.source,
hp: { average: m.hp.average, formula: m.hp.formula },
hp: {
average: m.hp.average ?? 0,
formula: m.hp.formula ?? m.hp.special ?? "",
},
speed: formatSpeed(m.speed),
abilities: {
str: m.str,
@@ -402,5 +457,4 @@ export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
legendaryActions: normalizeLegendary(m.legendary, m),
spellcasting: normalizeSpellcasting(m.spellcasting),
};
});
}

View File

@@ -0,0 +1,139 @@
import type { Creature, CreatureId } from "@initiative/domain";
import { type IDBPDatabase, openDB } from "idb";
const DB_NAME = "initiative-bestiary";
const STORE_NAME = "sources";
const DB_VERSION = 1;
export interface CachedSourceInfo {
readonly sourceCode: string;
readonly displayName: string;
readonly creatureCount: number;
readonly cachedAt: number;
}
interface CachedSourceRecord {
sourceCode: string;
displayName: string;
creatures: Creature[];
cachedAt: number;
creatureCount: number;
}
let db: IDBPDatabase | null = null;
let dbFailed = false;
// In-memory fallback when IndexedDB is unavailable
const memoryStore = new Map<string, CachedSourceRecord>();
async function getDb(): Promise<IDBPDatabase | null> {
if (db) return db;
if (dbFailed) return null;
try {
db = await openDB(DB_NAME, DB_VERSION, {
upgrade(database) {
if (!database.objectStoreNames.contains(STORE_NAME)) {
database.createObjectStore(STORE_NAME, {
keyPath: "sourceCode",
});
}
},
});
return db;
} catch {
dbFailed = true;
console.warn(
"IndexedDB unavailable — bestiary cache will not persist across sessions.",
);
return null;
}
}
export async function cacheSource(
sourceCode: string,
displayName: string,
creatures: Creature[],
): Promise<void> {
const record: CachedSourceRecord = {
sourceCode,
displayName,
creatures,
cachedAt: Date.now(),
creatureCount: creatures.length,
};
const database = await getDb();
if (database) {
await database.put(STORE_NAME, record);
} else {
memoryStore.set(sourceCode, record);
}
}
export async function isSourceCached(sourceCode: string): Promise<boolean> {
const database = await getDb();
if (database) {
const record = await database.get(STORE_NAME, sourceCode);
return record !== undefined;
}
return memoryStore.has(sourceCode);
}
export async function getCachedSources(): Promise<CachedSourceInfo[]> {
const database = await getDb();
if (database) {
const all: CachedSourceRecord[] = await database.getAll(STORE_NAME);
return all.map((r) => ({
sourceCode: r.sourceCode,
displayName: r.displayName,
creatureCount: r.creatureCount,
cachedAt: r.cachedAt,
}));
}
return [...memoryStore.values()].map((r) => ({
sourceCode: r.sourceCode,
displayName: r.displayName,
creatureCount: r.creatureCount,
cachedAt: r.cachedAt,
}));
}
export async function clearSource(sourceCode: string): Promise<void> {
const database = await getDb();
if (database) {
await database.delete(STORE_NAME, sourceCode);
} else {
memoryStore.delete(sourceCode);
}
}
export async function clearAll(): Promise<void> {
const database = await getDb();
if (database) {
await database.clear(STORE_NAME);
} else {
memoryStore.clear();
}
}
export async function loadAllCachedCreatures(): Promise<
Map<CreatureId, Creature>
> {
const map = new Map<CreatureId, Creature>();
const database = await getDb();
let records: CachedSourceRecord[];
if (database) {
records = await database.getAll(STORE_NAME);
} else {
records = [...memoryStore.values()];
}
for (const record of records) {
for (const creature of record.creatures) {
map.set(creature.id, creature);
}
}
return map;
}

View File

@@ -0,0 +1,96 @@
import type { BestiaryIndex, BestiaryIndexEntry } from "@initiative/domain";
import rawIndex from "../../../../data/bestiary/index.json";
interface CompactCreature {
readonly n: string;
readonly s: string;
readonly ac: number;
readonly hp: number;
readonly dx: number;
readonly cr: string;
readonly ip: number;
readonly sz: string;
readonly tp: string;
}
interface CompactIndex {
readonly sources: Record<string, string>;
readonly creatures: readonly CompactCreature[];
}
function mapCreature(c: CompactCreature): BestiaryIndexEntry {
return {
name: c.n,
source: c.s,
ac: c.ac,
hp: c.hp,
dex: c.dx,
cr: c.cr,
initiativeProficiency: c.ip,
size: c.sz,
type: c.tp,
};
}
// Source codes whose filename on the remote differs from a simple lowercase.
// Plane Shift sources use a hyphen: PSA -> ps-a, etc.
const FILENAME_OVERRIDES: Record<string, string> = {
PSA: "ps-a",
PSD: "ps-d",
PSI: "ps-i",
PSK: "ps-k",
PSX: "ps-x",
PSZ: "ps-z",
};
// Source codes with no corresponding remote bestiary file.
// Excluded from the index entirely so creatures aren't searchable
// without a fetchable source.
const EXCLUDED_SOURCES = new Set<string>([]);
let cachedIndex: BestiaryIndex | undefined;
export function loadBestiaryIndex(): BestiaryIndex {
if (cachedIndex) return cachedIndex;
const compact = rawIndex as unknown as CompactIndex;
const sources = Object.fromEntries(
Object.entries(compact.sources).filter(
([code]) => !EXCLUDED_SOURCES.has(code),
),
);
cachedIndex = {
sources,
creatures: compact.creatures
.filter((c) => !EXCLUDED_SOURCES.has(c.s))
.map(mapCreature),
};
return cachedIndex;
}
export function getAllSourceCodes(): string[] {
const index = loadBestiaryIndex();
return Object.keys(index.sources).filter((c) => !EXCLUDED_SOURCES.has(c));
}
function sourceCodeToFilename(sourceCode: string): string {
return FILENAME_OVERRIDES[sourceCode] ?? sourceCode.toLowerCase();
}
export function getDefaultFetchUrl(
sourceCode: string,
baseUrl?: string,
): string {
const filename = `bestiary-${sourceCodeToFilename(sourceCode)}.json`;
if (baseUrl !== undefined) {
const normalized = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
return `${normalized}${filename}`;
}
return `https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/${filename}`;
}
export function getSourceDisplayName(sourceCode: string): string {
const index = loadBestiaryIndex();
return index.sources[sourceCode] ?? sourceCode;
}

View File

@@ -20,6 +20,7 @@ const ATKR_MAP: Record<string, string> = {
* Handles 15+ tag types per research.md R-002 tag resolution rules.
*/
export function stripTags(text: string): string {
if (typeof text !== "string") return String(text);
// Process special tags with specific output formats first
let result = text;

View File

@@ -0,0 +1,219 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import type { Encounter } from "@initiative/domain";
import { combatantId } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TurnNavigation } from "../turn-navigation";
afterEach(cleanup);
function renderNav(overrides: Partial<Encounter> = {}) {
const encounter: Encounter = {
combatants: [
{ id: combatantId("1"), name: "Goblin" },
{ id: combatantId("2"), name: "Conjurer" },
],
activeIndex: 0,
roundNumber: 1,
...overrides,
};
return render(
<TurnNavigation
encounter={encounter}
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>,
);
}
describe("TurnNavigation", () => {
describe("US1: Round badge and combatant name", () => {
it("renders the round badge with correct round number", () => {
renderNav({ roundNumber: 3 });
expect(screen.getByText("R3")).toBeInTheDocument();
});
it("renders the combatant name separately from the round badge", () => {
renderNav();
const badge = screen.getByText("R1");
const name = screen.getByText("Goblin");
expect(badge).toBeInTheDocument();
expect(name).toBeInTheDocument();
expect(badge).not.toBe(name);
expect(badge.closest("[class]")).not.toBe(name.closest("[class]"));
});
it("does not render an em dash between round and name", () => {
const { container } = renderNav();
expect(container.textContent).not.toContain("—");
});
it("round badge and combatant name are siblings in the center area", () => {
renderNav();
const badge = screen.getByText("R1");
const name = screen.getByText("Goblin");
expect(badge.parentElement).toBe(name.parentElement);
});
it("updates the round badge when round changes", () => {
const { rerender } = render(
<TurnNavigation
encounter={{
combatants: [{ id: combatantId("1"), name: "Goblin" }],
activeIndex: 0,
roundNumber: 2,
}}
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>,
);
expect(screen.getByText("R2")).toBeInTheDocument();
rerender(
<TurnNavigation
encounter={{
combatants: [{ id: combatantId("1"), name: "Goblin" }],
activeIndex: 0,
roundNumber: 3,
}}
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>,
);
expect(screen.getByText("R3")).toBeInTheDocument();
expect(screen.queryByText("R2")).not.toBeInTheDocument();
});
it("renders the next combatant name when turn advances", () => {
const { rerender } = render(
<TurnNavigation
encounter={{
combatants: [
{ id: combatantId("1"), name: "Goblin" },
{ id: combatantId("2"), name: "Conjurer" },
],
activeIndex: 0,
roundNumber: 1,
}}
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>,
);
expect(screen.getByText("Goblin")).toBeInTheDocument();
rerender(
<TurnNavigation
encounter={{
combatants: [
{ id: combatantId("1"), name: "Goblin" },
{ id: combatantId("2"), name: "Conjurer" },
],
activeIndex: 1,
roundNumber: 1,
}}
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
onRollAllInitiative={vi.fn()}
onOpenSourceManager={vi.fn()}
/>,
);
expect(screen.getByText("Conjurer")).toBeInTheDocument();
});
});
describe("US2: Layout robustness", () => {
it("applies truncation styles to long combatant names", () => {
const longName =
"Ancient Red Dragon Wyrm of the Northern Wastes and Beyond";
renderNav({
combatants: [{ id: combatantId("1"), name: longName }],
});
const nameEl = screen.getByText(longName);
expect(nameEl.className).toContain("truncate");
});
it("renders three-zone layout with a single-character name", () => {
renderNav({
combatants: [{ id: combatantId("1"), name: "O" }],
});
expect(screen.getByText("R1")).toBeInTheDocument();
expect(screen.getByText("O")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Previous turn" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Next turn" }),
).toBeInTheDocument();
});
it("keeps all action buttons accessible regardless of name length", () => {
const longName = "A".repeat(60);
renderNav({
combatants: [{ id: combatantId("1"), name: longName }],
});
expect(
screen.getByRole("button", { name: "Previous turn" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Next turn" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", {
name: "Roll all initiative",
}),
).toBeInTheDocument();
expect(
screen.getByRole("button", {
name: "Manage cached sources",
}),
).toBeInTheDocument();
});
it("renders a 40-character name without truncation class issues", () => {
const name40 = "A".repeat(40);
renderNav({
combatants: [{ id: combatantId("1"), name: name40 }],
});
const nameEl = screen.getByText(name40);
expect(nameEl).toBeInTheDocument();
// The truncate class is applied but CSS only visually truncates if content overflows
expect(nameEl.className).toContain("truncate");
});
});
describe("US3: No combatants state", () => {
it("shows the round badge when there are no combatants", () => {
renderNav({ combatants: [], roundNumber: 1 });
expect(screen.getByText("R1")).toBeInTheDocument();
});
it("shows 'No combatants' placeholder text", () => {
renderNav({ combatants: [] });
expect(screen.getByText("No combatants")).toBeInTheDocument();
});
it("disables navigation buttons when there are no combatants", () => {
renderNav({ combatants: [] });
expect(
screen.getByRole("button", { name: "Previous turn" }),
).toBeDisabled();
expect(screen.getByRole("button", { name: "Next turn" })).toBeDisabled();
});
});
});

View File

@@ -1,18 +1,41 @@
import type { Creature } from "@initiative/domain";
import { Search } from "lucide-react";
import { type FormEvent, useState } from "react";
import { BestiarySearch } from "./bestiary-search.js";
import type { PlayerCharacter, PlayerIcon } from "@initiative/domain";
import { Check, Eye, Import, Minus, Plus, Users } from "lucide-react";
import {
type FormEvent,
type RefObject,
useEffect,
useRef,
useState,
} from "react";
import type { SearchResult } from "../hooks/use-bestiary.js";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
interface QueuedCreature {
result: SearchResult;
count: number;
}
interface ActionBarProps {
onAddCombatant: (name: string) => void;
onAddFromBestiary: (creature: Creature) => void;
bestiarySearch: (query: string) => Creature[];
onAddCombatant: (
name: string,
opts?: { initiative?: number; ac?: number; maxHp?: number },
) => void;
onAddFromBestiary: (result: SearchResult) => void;
bestiarySearch: (query: string) => SearchResult[];
bestiaryLoaded: boolean;
suggestions: Creature[];
onSearchChange: (query: string) => void;
onShowStatBlock?: (creature: Creature) => void;
onViewStatBlock?: (result: SearchResult) => void;
onBulkImport?: () => void;
bulkImportDisabled?: boolean;
inputRef?: RefObject<HTMLInputElement | null>;
playerCharacters?: readonly PlayerCharacter[];
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
onManagePlayers?: () => void;
}
function creatureKey(r: SearchResult): string {
return `${r.source}:${r.name}`;
}
export function ActionBar({
@@ -20,45 +43,128 @@ export function ActionBar({
onAddFromBestiary,
bestiarySearch,
bestiaryLoaded,
suggestions,
onSearchChange,
onShowStatBlock,
onViewStatBlock,
onBulkImport,
bulkImportDisabled,
inputRef,
playerCharacters,
onAddFromPlayerCharacter,
onManagePlayers,
}: ActionBarProps) {
const [nameInput, setNameInput] = useState("");
const [searchOpen, setSearchOpen] = useState(false);
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
const [suggestionIndex, setSuggestionIndex] = useState(-1);
const [queued, setQueued] = useState<QueuedCreature | null>(null);
const [customInit, setCustomInit] = useState("");
const [customAc, setCustomAc] = useState("");
const [customMaxHp, setCustomMaxHp] = useState("");
// Stat block viewer: separate dropdown
const [viewerOpen, setViewerOpen] = useState(false);
const [viewerQuery, setViewerQuery] = useState("");
const [viewerResults, setViewerResults] = useState<SearchResult[]>([]);
const [viewerIndex, setViewerIndex] = useState(-1);
const viewerRef = useRef<HTMLDivElement>(null);
const viewerInputRef = useRef<HTMLInputElement>(null);
const clearCustomFields = () => {
setCustomInit("");
setCustomAc("");
setCustomMaxHp("");
};
const confirmQueued = () => {
if (!queued) return;
for (let i = 0; i < queued.count; i++) {
onAddFromBestiary(queued.result);
}
setQueued(null);
setNameInput("");
setSuggestions([]);
setPcMatches([]);
setSuggestionIndex(-1);
};
const parseNum = (v: string): number | undefined => {
if (v.trim() === "") return undefined;
const n = Number(v);
return Number.isNaN(n) ? undefined : n;
};
const handleAdd = (e: FormEvent) => {
e.preventDefault();
if (queued) {
confirmQueued();
return;
}
if (nameInput.trim() === "") return;
onAddCombatant(nameInput);
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;
onAddCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
setNameInput("");
onSearchChange("");
setSuggestions([]);
setPcMatches([]);
clearCustomFields();
};
const handleNameChange = (value: string) => {
setNameInput(value);
setSuggestionIndex(-1);
onSearchChange(value);
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 handleSelectCreature = (creature: Creature) => {
onAddFromBestiary(creature);
setSearchOpen(false);
setNameInput("");
onSearchChange("");
onShowStatBlock?.(creature);
const handleClickSuggestion = (result: SearchResult) => {
const key = creatureKey(result);
if (queued && creatureKey(queued.result) === key) {
setQueued({ ...queued, count: queued.count + 1 });
} else {
setQueued({ result, count: 1 });
}
};
const handleSelectSuggestion = (creature: Creature) => {
onAddFromBestiary(creature);
setNameInput("");
onSearchChange("");
onShowStatBlock?.(creature);
const handleEnter = () => {
if (queued) {
confirmQueued();
} else if (suggestionIndex >= 0) {
handleClickSuggestion(suggestions[suggestionIndex]);
}
};
const hasSuggestions = suggestions.length > 0 || pcMatches.length > 0;
const handleKeyDown = (e: React.KeyboardEvent) => {
if (suggestions.length === 0) return;
if (!hasSuggestions) return;
if (e.key === "ArrowDown") {
e.preventDefault();
@@ -66,78 +172,359 @@ export function ActionBar({
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
} else if (e.key === "Enter" && suggestionIndex >= 0) {
} else if (e.key === "Enter") {
e.preventDefault();
handleSelectSuggestion(suggestions[suggestionIndex]);
handleEnter();
} else if (e.key === "Escape") {
setQueued(null);
setSuggestionIndex(-1);
onSearchChange("");
setSuggestions([]);
setPcMatches([]);
}
};
// Stat block viewer dropdown handlers
const openViewer = () => {
setViewerOpen(true);
setViewerQuery("");
setViewerResults([]);
setViewerIndex(-1);
requestAnimationFrame(() => viewerInputRef.current?.focus());
};
const closeViewer = () => {
setViewerOpen(false);
setViewerQuery("");
setViewerResults([]);
setViewerIndex(-1);
};
const handleViewerQueryChange = (value: string) => {
setViewerQuery(value);
setViewerIndex(-1);
if (value.length >= 2) {
setViewerResults(bestiarySearch(value));
} else {
setViewerResults([]);
}
};
const handleViewerSelect = (result: SearchResult) => {
onViewStatBlock?.(result);
closeViewer();
};
const handleViewerKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
closeViewer();
return;
}
if (viewerResults.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setViewerIndex((i) => (i < viewerResults.length - 1 ? i + 1 : 0));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setViewerIndex((i) => (i > 0 ? i - 1 : viewerResults.length - 1));
} else if (e.key === "Enter" && viewerIndex >= 0) {
e.preventDefault();
handleViewerSelect(viewerResults[viewerIndex]);
}
};
// Close viewer on outside click
useEffect(() => {
if (!viewerOpen) return;
function handleClickOutside(e: MouseEvent) {
if (viewerRef.current && !viewerRef.current.contains(e.target as Node)) {
closeViewer();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [viewerOpen]);
return (
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
{searchOpen ? (
<BestiarySearch
onSelectCreature={handleSelectCreature}
onClose={() => setSearchOpen(false)}
searchFn={bestiarySearch}
/>
) : (
<form
onSubmit={handleAdd}
className="relative flex flex-1 items-center gap-2"
>
<div className="relative flex-1">
<Input
ref={inputRef}
type="text"
value={nameInput}
onChange={(e) => handleNameChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Combatant name"
placeholder="+ Add combatants"
className="max-w-xs"
/>
{suggestions.length > 0 && (
{hasSuggestions && (
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
<ul className="max-h-48 overflow-y-auto py-1">
{suggestions.map((creature, i) => (
<li key={creature.id}>
<button
type="button"
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-2 text-left text-sm text-accent hover:bg-accent/20"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setSuggestions([]);
setPcMatches([]);
setQueued(null);
setSuggestionIndex(-1);
}}
>
<Plus className="h-3.5 w-3.5" />
<span className="flex-1">Add "{nameInput}" as custom</span>
<kbd className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground">
Esc
</kbd>
</button>
<div className="max-h-48 overflow-y-auto py-1">
{pcMatches.length > 0 && (
<>
<div className="px-3 py-1 text-xs font-medium text-muted-foreground">
Players
</div>
<ul>
{pcMatches.map((pc) => {
const PcIcon = PLAYER_ICON_MAP[pc.icon as PlayerIcon];
const pcColor =
PLAYER_COLOR_HEX[
pc.color as keyof typeof PLAYER_COLOR_HEX
];
return (
<li key={pc.id}>
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-foreground hover:bg-hover-neutral-bg"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onAddFromPlayerCharacter?.(pc);
setNameInput("");
setSuggestions([]);
setPcMatches([]);
}}
>
{PcIcon && (
<PcIcon size={14} style={{ color: pcColor }} />
)}
<span className="flex-1 truncate">{pc.name}</span>
<span className="text-xs text-muted-foreground">
Player
</span>
</button>
</li>
);
})}
</ul>
</>
)}
{suggestions.length > 0 && (
<ul>
{suggestions.map((result, i) => {
const key = creatureKey(result);
const isQueued =
queued !== null && creatureKey(queued.result) === key;
return (
<li key={key}>
<button
type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
i === suggestionIndex
isQueued
? "bg-accent/30 text-foreground"
: i === suggestionIndex
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg"
}`}
onClick={() => handleSelectSuggestion(creature)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleClickSuggestion(result)}
onMouseEnter={() => setSuggestionIndex(i)}
>
<span>{creature.name}</span>
<span>{result.name}</span>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
{isQueued ? (
<>
<button
type="button"
className="rounded p-0.5 text-foreground hover:bg-accent/40"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
if (queued.count <= 1) {
setQueued(null);
} else {
setQueued({
...queued,
count: queued.count - 1,
});
}
}}
>
<Minus className="h-3 w-3" />
</button>
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-foreground">
{queued.count}
</span>
<button
type="button"
className="rounded p-0.5 text-foreground hover:bg-accent/40"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
setQueued({
...queued,
count: queued.count + 1,
});
}}
>
<Plus className="h-3 w-3" />
</button>
<button
type="button"
className="ml-0.5 rounded p-0.5 text-foreground hover:bg-accent/40"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
confirmQueued();
}}
>
<Check className="h-3.5 w-3.5" />
</button>
</>
) : (
result.sourceDisplayName
)}
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
</div>
)}
</div>
{nameInput.length >= 2 && !hasSuggestions && (
<div className="flex items-center gap-2">
<Input
type="text"
inputMode="numeric"
value={customInit}
onChange={(e) => setCustomInit(e.target.value)}
placeholder="Init"
className="w-16 text-center"
/>
<Input
type="text"
inputMode="numeric"
value={customAc}
onChange={(e) => setCustomAc(e.target.value)}
placeholder="AC"
className="w-16 text-center"
/>
<Input
type="text"
inputMode="numeric"
value={customMaxHp}
onChange={(e) => setCustomMaxHp(e.target.value)}
placeholder="MaxHP"
className="w-18 text-center"
/>
</div>
)}
<Button type="submit" size="sm">
Add
</Button>
<div className="flex items-center gap-0">
{onManagePlayers && (
<Button
type="button"
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-hover-neutral"
onClick={onManagePlayers}
title="Player characters"
aria-label="Player characters"
>
<Users className="h-5 w-5" />
</Button>
)}
{bestiaryLoaded && onViewStatBlock && (
<div ref={viewerRef} className="relative">
<Button
type="button"
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-hover-neutral"
onClick={() => (viewerOpen ? closeViewer() : openViewer())}
title="Browse stat blocks"
aria-label="Browse stat blocks"
>
<Eye className="h-5 w-5" />
</Button>
{viewerOpen && (
<div className="absolute bottom-full right-0 z-50 mb-1 w-64 rounded-md border border-border bg-card shadow-lg">
<div className="p-2">
<Input
ref={viewerInputRef}
type="text"
value={viewerQuery}
onChange={(e) => handleViewerQueryChange(e.target.value)}
onKeyDown={handleViewerKeyDown}
placeholder="Search stat blocks..."
className="w-full"
/>
</div>
{viewerResults.length > 0 && (
<ul className="max-h-48 overflow-y-auto border-t border-border py-1">
{viewerResults.map((result, i) => (
<li key={creatureKey(result)}>
<button
type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
i === viewerIndex
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg"
}`}
onClick={() => handleViewerSelect(result)}
onMouseEnter={() => setViewerIndex(i)}
>
<span>{result.name}</span>
<span className="text-xs text-muted-foreground">
{creature.sourceDisplayName}
{result.sourceDisplayName}
</span>
</button>
</li>
))}
</ul>
)}
{viewerQuery.length >= 2 && viewerResults.length === 0 && (
<div className="border-t border-border px-3 py-2 text-sm text-muted-foreground">
No creatures found
</div>
)}
</div>
<Button type="submit" size="sm">
Add
</Button>
{bestiaryLoaded && (
)}
</div>
)}
{bestiaryLoaded && onBulkImport && (
<Button
type="button"
size="sm"
size="icon"
variant="ghost"
onClick={() => setSearchOpen(true)}
className="text-muted-foreground hover:text-hover-neutral"
onClick={onBulkImport}
disabled={bulkImportDisabled}
title="Bulk import"
aria-label="Bulk import"
>
<Search className="h-4 w-4" />
<Import className="h-5 w-5" />
</Button>
)}
</div>
</form>
)}
</div>
);
}

View File

@@ -1,129 +0,0 @@
import type { Creature } from "@initiative/domain";
import { Search, X } from "lucide-react";
import {
type KeyboardEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { Input } from "./ui/input.js";
interface BestiarySearchProps {
onSelectCreature: (creature: Creature) => void;
onClose: () => void;
searchFn: (query: string) => Creature[];
}
export function BestiarySearch({
onSelectCreature,
onClose,
searchFn,
}: BestiarySearchProps) {
const [query, setQuery] = useState("");
const [highlightIndex, setHighlightIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const results = query.length >= 2 ? searchFn(query) : [];
useEffect(() => {
inputRef.current?.focus();
}, []);
useEffect(() => {
setHighlightIndex(-1);
}, [query]);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
onClose();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlightIndex((i) => (i < results.length - 1 ? i + 1 : 0));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setHighlightIndex((i) => (i > 0 ? i - 1 : results.length - 1));
return;
}
if (e.key === "Enter" && highlightIndex >= 0) {
e.preventDefault();
onSelectCreature(results[highlightIndex]);
}
},
[results, highlightIndex, onClose, onSelectCreature],
);
return (
<div ref={containerRef} className="relative w-full max-w-sm">
<div className="flex items-center gap-2">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search bestiary..."
className="flex-1"
/>
<button
type="button"
onClick={onClose}
className="text-muted-foreground hover:text-hover-neutral"
>
<X className="h-4 w-4" />
</button>
</div>
{query.length >= 2 && (
<div className="absolute bottom-full z-50 mb-1 w-full rounded-md border border-border bg-card shadow-lg">
{results.length === 0 ? (
<div className="px-3 py-2 text-sm text-muted-foreground">
No creatures found
</div>
) : (
<ul className="max-h-60 overflow-y-auto py-1">
{results.map((creature, i) => (
<li key={creature.id}>
<button
type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
i === highlightIndex
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg"
}`}
onClick={() => onSelectCreature(creature)}
onMouseEnter={() => setHighlightIndex(i)}
>
<span>{creature.name}</span>
<span className="text-xs text-muted-foreground">
{creature.sourceDisplayName}
</span>
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,115 @@
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { getAllSourceCodes } from "../adapters/bestiary-index-adapter.js";
import type { BulkImportState } from "../hooks/use-bulk-import.js";
import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
const DEFAULT_BASE_URL =
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
interface BulkImportPromptProps {
importState: BulkImportState;
onStartImport: (baseUrl: string) => void;
onDone: () => void;
}
export function BulkImportPrompt({
importState,
onStartImport,
onDone,
}: BulkImportPromptProps) {
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
const totalSources = getAllSourceCodes().length;
if (importState.status === "complete") {
return (
<div className="flex flex-col gap-4">
<div className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-sm text-green-400">
All sources loaded
</div>
<Button size="sm" onClick={onDone}>
Done
</Button>
</div>
);
}
if (importState.status === "partial-failure") {
return (
<div className="flex flex-col gap-4">
<div className="rounded-md border border-yellow-500/50 bg-yellow-500/10 px-3 py-2 text-sm text-yellow-400">
Loaded {importState.completed}/{importState.total} sources (
{importState.failed} failed)
</div>
<Button size="sm" onClick={onDone}>
Done
</Button>
</div>
);
}
if (importState.status === "loading") {
const processed = importState.completed + importState.failed;
const pct =
importState.total > 0
? Math.round((processed / importState.total) * 100)
: 0;
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading sources... {processed}/{importState.total}
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
// idle state
const isDisabled = !baseUrl.trim() || importState.status !== "idle";
return (
<div className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold text-foreground">
Bulk Import Sources
</h3>
<p className="mt-1 text-xs text-muted-foreground">
Load stat block data for all {totalSources} sources at once. This will
download approximately 12.5 MB of data.
</p>
</div>
<div className="flex flex-col gap-2">
<label
htmlFor="bulk-base-url"
className="text-xs text-muted-foreground"
>
Base URL
</label>
<Input
id="bulk-base-url"
type="url"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
className="text-xs"
/>
</div>
<Button
size="sm"
onClick={() => onStartImport(baseUrl)}
disabled={isDisabled}
>
Load All
</Button>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { VALID_PLAYER_COLORS } from "@initiative/domain";
import { cn } from "../lib/utils";
import { PLAYER_COLOR_HEX } from "./player-icon-map";
interface ColorPaletteProps {
value: string;
onChange: (color: string) => void;
}
const COLORS = [...VALID_PLAYER_COLORS] as string[];
export function ColorPalette({ value, onChange }: ColorPaletteProps) {
return (
<div className="flex flex-wrap gap-2">
{COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => onChange(color)}
className={cn(
"h-8 w-8 rounded-full transition-all",
value === color
? "ring-2 ring-foreground ring-offset-2 ring-offset-background scale-110"
: "hover:scale-110",
)}
style={{
backgroundColor:
PLAYER_COLOR_HEX[color as keyof typeof PLAYER_COLOR_HEX],
}}
aria-label={color}
title={color}
/>
))}
</div>
);
}

View File

@@ -2,6 +2,7 @@ import {
type CombatantId,
type ConditionId,
deriveHpStatus,
type PlayerIcon,
} from "@initiative/domain";
import { Brain, X } from "lucide-react";
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
@@ -11,7 +12,8 @@ import { ConditionPicker } from "./condition-picker";
import { ConditionTags } from "./condition-tags";
import { D20Icon } from "./d20-icon";
import { HpAdjustPopover } from "./hp-adjust-popover";
import { Button } from "./ui/button";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
import { ConfirmButton } from "./ui/confirm-button";
import { Input } from "./ui/input";
interface Combatant {
@@ -23,6 +25,8 @@ interface Combatant {
readonly ac?: number;
readonly conditions?: readonly ConditionId[];
readonly isConcentrating?: boolean;
readonly color?: string;
readonly icon?: string;
}
interface CombatantRowProps {
@@ -44,14 +48,21 @@ function EditableName({
name,
combatantId,
onRename,
onShowStatBlock,
color,
}: {
name: string;
combatantId: CombatantId;
onRename: (id: CombatantId, newName: string) => void;
onShowStatBlock?: () => void;
color?: string;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(name);
const inputRef = useRef<HTMLInputElement>(null);
const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const longPressTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const longPressTriggeredRef = useRef(false);
const commit = useCallback(() => {
const trimmed = draft.trim();
@@ -67,6 +78,46 @@ function EditableName({
requestAnimationFrame(() => inputRef.current?.select());
}, [name]);
useEffect(() => {
return () => {
clearTimeout(clickTimerRef.current);
clearTimeout(longPressTimerRef.current);
};
}, []);
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (longPressTriggeredRef.current) {
longPressTriggeredRef.current = false;
return;
}
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
clickTimerRef.current = undefined;
startEditing();
} else {
clickTimerRef.current = setTimeout(() => {
clickTimerRef.current = undefined;
onShowStatBlock?.();
}, 250);
}
},
[startEditing, onShowStatBlock],
);
const handleTouchStart = useCallback(() => {
longPressTriggeredRef.current = false;
longPressTimerRef.current = setTimeout(() => {
longPressTriggeredRef.current = true;
startEditing();
}, 500);
}, [startEditing]);
const cancelLongPress = useCallback(() => {
clearTimeout(longPressTimerRef.current);
}, []);
if (editing) {
return (
<Input
@@ -85,16 +136,20 @@ function EditableName({
}
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
startEditing();
}}
className="truncate text-left text-sm text-foreground hover:text-hover-neutral transition-colors"
onClick={handleClick}
onTouchStart={handleTouchStart}
onTouchEnd={cancelLongPress}
onTouchCancel={cancelLongPress}
onTouchMove={cancelLongPress}
className="truncate text-left text-sm text-foreground cursor-text hover:text-hover-neutral transition-colors"
style={color ? { color } : undefined}
>
{name}
</button>
</>
);
}
@@ -354,6 +409,35 @@ function InitiativeDisplay({
);
}
function rowBorderClass(
isActive: boolean,
isConcentrating: boolean | undefined,
): string {
if (isActive) return "border-l-2 border-l-accent bg-accent/10";
if (isConcentrating) return "border-l-2 border-l-purple-400";
return "border-l-2 border-l-transparent";
}
function concentrationIconClass(
isConcentrating: boolean | undefined,
dimmed: boolean,
): string {
if (!isConcentrating)
return "opacity-0 group-hover:opacity-50 text-muted-foreground";
return dimmed ? "opacity-50 text-purple-400" : "opacity-100 text-purple-400";
}
function activateOnKeyDown(
handler: () => void,
): (e: { key: string; preventDefault: () => void }) => void {
return (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handler();
}
};
}
export function CombatantRow({
ref,
combatant,
@@ -401,22 +485,26 @@ export function CombatantRow({
}
}, [combatant.isConcentrating]);
const pcColor = combatant.color
? PLAYER_COLOR_HEX[combatant.color as keyof typeof PLAYER_COLOR_HEX]
: undefined;
return (
/* biome-ignore lint/a11y/useKeyWithClickEvents: row click opens stat block */
/* biome-ignore lint/a11y/noStaticElementInteractions: row click opens stat block */
/* biome-ignore lint/a11y/noStaticElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
<div
ref={ref}
role={onShowStatBlock ? "button" : undefined}
tabIndex={onShowStatBlock ? 0 : undefined}
className={cn(
"group rounded-md pr-3 transition-colors",
isActive
? "border-l-2 border-l-accent bg-accent/10"
: combatant.isConcentrating
? "border-l-2 border-l-purple-400"
: "border-l-2 border-l-transparent",
rowBorderClass(isActive, combatant.isConcentrating),
isPulsing && "animate-concentration-pulse",
onShowStatBlock && "cursor-pointer",
)}
onClick={onShowStatBlock}
onKeyDown={
onShowStatBlock ? activateOnKeyDown(onShowStatBlock) : undefined
}
>
<div className="grid grid-cols-[2rem_3rem_1fr_auto_auto_2rem] items-center gap-3 py-2">
{/* Concentration */}
@@ -430,20 +518,18 @@ export function CombatantRow({
aria-label="Toggle concentration"
className={cn(
"flex w-full items-center justify-center self-stretch -my-2 -ml-[2px] pl-[14px] transition-opacity hover:text-hover-neutral hover:opacity-100",
combatant.isConcentrating
? dimmed
? "opacity-50 text-purple-400"
: "opacity-100 text-purple-400"
: "opacity-0 group-hover:opacity-50 text-muted-foreground",
concentrationIconClass(combatant.isConcentrating, dimmed),
)}
>
<Brain size={16} />
</button>
{/* Initiative */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation wrapper */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper */}
<div onClick={(e) => e.stopPropagation()}>
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
<div
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<InitiativeDisplay
initiative={initiative}
combatantId={id}
@@ -460,9 +546,29 @@ export function CombatantRow({
dimmed && "opacity-50",
)}
>
<span className="min-w-0 truncate">
<EditableName name={name} combatantId={id} onRename={onRename} />
</span>
{combatant.icon &&
combatant.color &&
(() => {
const PcIcon = PLAYER_ICON_MAP[combatant.icon as PlayerIcon];
const pcColor =
PLAYER_COLOR_HEX[
combatant.color as keyof typeof PLAYER_COLOR_HEX
];
return PcIcon ? (
<PcIcon
size={14}
style={{ color: pcColor }}
className="shrink-0"
/>
) : null;
})()}
<EditableName
name={name}
combatantId={id}
onRename={onRename}
onShowStatBlock={onShowStatBlock}
color={pcColor}
/>
<ConditionTags
conditions={combatant.conditions}
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
@@ -478,21 +584,21 @@ export function CombatantRow({
</div>
{/* AC */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation wrapper */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
<div
className={cn(dimmed && "opacity-50")}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
</div>
{/* HP */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation wrapper */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
<div
className="flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<ClickableHp
currentHp={currentHp}
@@ -516,19 +622,12 @@ export function CombatantRow({
</div>
{/* Actions */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-hover-destructive opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto focus:opacity-100 focus:pointer-events-auto transition-opacity"
onClick={(e) => {
e.stopPropagation();
onRemove(id);
}}
title="Remove combatant"
aria-label="Remove combatant"
>
<X size={16} />
</Button>
<ConfirmButton
icon={<X size={16} />}
label="Remove combatant"
onConfirm={() => onRemove(id)}
className="h-7 w-7 text-muted-foreground opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto focus:opacity-100 focus:pointer-events-auto pointer-coarse:opacity-100 pointer-coarse:pointer-events-auto transition-opacity"
/>
</div>
</div>
);

View File

@@ -64,12 +64,21 @@ export function ConditionPicker({
}: ConditionPickerProps) {
const ref = useRef<HTMLDivElement>(null);
const [flipped, setFlipped] = useState(false);
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
setFlipped(rect.bottom > window.innerHeight);
const spaceBelow = window.innerHeight - rect.top;
const spaceAbove = rect.bottom;
const shouldFlip =
rect.bottom > window.innerHeight && spaceAbove > spaceBelow;
setFlipped(shouldFlip);
const available = shouldFlip ? spaceAbove : spaceBelow;
if (rect.height > available) {
setMaxHeight(available - 16);
}
}, []);
useEffect(() => {
@@ -88,9 +97,10 @@ export function ConditionPicker({
<div
ref={ref}
className={cn(
"absolute z-10 w-fit rounded-md border border-border bg-background p-1 shadow-lg",
flipped ? "bottom-full mb-1" : "mt-1",
"absolute left-0 z-10 w-fit overflow-y-auto rounded-md border border-border bg-background p-1 shadow-lg",
flipped ? "bottom-full mb-1" : "top-full mt-1",
)}
style={maxHeight ? { maxHeight } : undefined}
>
{CONDITION_DEFINITIONS.map((def) => {
const Icon = ICON_MAP[def.iconName];

View File

@@ -89,7 +89,7 @@ export function ConditionTags({
type="button"
title="Add condition"
aria-label="Add condition"
className="inline-flex items-center rounded p-0.5 text-muted-foreground hover:text-hover-neutral hover:bg-hover-neutral-bg transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity"
className="inline-flex items-center rounded p-0.5 text-muted-foreground hover:text-hover-neutral hover:bg-hover-neutral-bg transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 pointer-coarse:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
onOpenPicker();

View File

@@ -0,0 +1,186 @@
import type { PlayerCharacter } from "@initiative/domain";
import { X } from "lucide-react";
import { type FormEvent, useEffect, useState } from "react";
import { ColorPalette } from "./color-palette";
import { IconGrid } from "./icon-grid";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
interface CreatePlayerModalProps {
open: boolean;
onClose: () => void;
onSave: (
name: string,
ac: number,
maxHp: number,
color: string,
icon: string,
) => void;
playerCharacter?: PlayerCharacter;
}
export function CreatePlayerModal({
open,
onClose,
onSave,
playerCharacter,
}: CreatePlayerModalProps) {
const [name, setName] = useState("");
const [ac, setAc] = useState("10");
const [maxHp, setMaxHp] = useState("10");
const [color, setColor] = useState("blue");
const [icon, setIcon] = useState("sword");
const [error, setError] = useState("");
const isEdit = !!playerCharacter;
useEffect(() => {
if (open) {
if (playerCharacter) {
setName(playerCharacter.name);
setAc(String(playerCharacter.ac));
setMaxHp(String(playerCharacter.maxHp));
setColor(playerCharacter.color);
setIcon(playerCharacter.icon);
} else {
setName("");
setAc("10");
setMaxHp("10");
setColor("blue");
setIcon("sword");
}
setError("");
}
}, [open, playerCharacter]);
useEffect(() => {
if (!open) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, onClose]);
if (!open) return null;
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (trimmed === "") {
setError("Name is required");
return;
}
const acNum = Number.parseInt(ac, 10);
if (Number.isNaN(acNum) || acNum < 0) {
setError("AC must be a non-negative number");
return;
}
const hpNum = Number.parseInt(maxHp, 10);
if (Number.isNaN(hpNum) || hpNum < 1) {
setError("Max HP must be at least 1");
return;
}
onSave(trimmed, acNum, hpNum, color, icon);
onClose();
};
return (
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onMouseDown={onClose}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
<div
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">
{isEdit ? "Edit Player" : "Create Player"}
</h2>
<button
type="button"
onClick={onClose}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X size={20} />
</button>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<span className="mb-1 block text-sm text-muted-foreground">
Name
</span>
<Input
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
setError("");
}}
placeholder="Character name"
aria-label="Name"
autoFocus
/>
{error && <p className="mt-1 text-sm text-destructive">{error}</p>}
</div>
<div className="flex gap-3">
<div className="flex-1">
<span className="mb-1 block text-sm text-muted-foreground">
AC
</span>
<Input
type="text"
inputMode="numeric"
value={ac}
onChange={(e) => setAc(e.target.value)}
placeholder="AC"
aria-label="AC"
className="text-center"
/>
</div>
<div className="flex-1">
<span className="mb-1 block text-sm text-muted-foreground">
Max HP
</span>
<Input
type="text"
inputMode="numeric"
value={maxHp}
onChange={(e) => setMaxHp(e.target.value)}
placeholder="Max HP"
aria-label="Max HP"
className="text-center"
/>
</div>
</div>
<div>
<span className="mb-2 block text-sm text-muted-foreground">
Color
</span>
<ColorPalette value={color} onChange={setColor} />
</div>
<div>
<span className="mb-2 block text-sm text-muted-foreground">
Icon
</span>
<IconGrid value={icon} onChange={setIcon} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import type { PlayerIcon } from "@initiative/domain";
import { VALID_PLAYER_ICONS } from "@initiative/domain";
import { cn } from "../lib/utils";
import { PLAYER_ICON_MAP } from "./player-icon-map";
interface IconGridProps {
value: string;
onChange: (icon: string) => void;
}
const ICONS = [...VALID_PLAYER_ICONS] as PlayerIcon[];
export function IconGrid({ value, onChange }: IconGridProps) {
return (
<div className="flex flex-wrap gap-2">
{ICONS.map((iconId) => {
const Icon = PLAYER_ICON_MAP[iconId];
return (
<button
key={iconId}
type="button"
onClick={() => onChange(iconId)}
className={cn(
"flex h-9 w-9 items-center justify-center rounded-md transition-all",
value === iconId
? "bg-primary/20 ring-2 ring-primary text-foreground"
: "text-muted-foreground hover:bg-card hover:text-foreground",
)}
aria-label={iconId}
title={iconId}
>
<Icon size={20} />
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,50 @@
import type { PlayerColor, PlayerIcon } from "@initiative/domain";
import type { LucideIcon } from "lucide-react";
import {
Axe,
Crosshair,
Crown,
Eye,
Feather,
Flame,
Heart,
Moon,
Shield,
Skull,
Star,
Sun,
Sword,
Wand,
Zap,
} from "lucide-react";
export const PLAYER_ICON_MAP: Record<PlayerIcon, LucideIcon> = {
sword: Sword,
shield: Shield,
skull: Skull,
heart: Heart,
wand: Wand,
flame: Flame,
crown: Crown,
star: Star,
moon: Moon,
sun: Sun,
axe: Axe,
crosshair: Crosshair,
eye: Eye,
feather: Feather,
zap: Zap,
};
export const PLAYER_COLOR_HEX: Record<PlayerColor, string> = {
red: "#ef4444",
blue: "#3b82f6",
green: "#22c55e",
purple: "#a855f7",
orange: "#f97316",
pink: "#ec4899",
cyan: "#06b6d4",
yellow: "#eab308",
emerald: "#10b981",
indigo: "#6366f1",
};

View File

@@ -0,0 +1,123 @@
import type {
PlayerCharacter,
PlayerCharacterId,
PlayerIcon,
} from "@initiative/domain";
import { Pencil, Plus, Trash2, X } from "lucide-react";
import { useEffect } from "react";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
import { Button } from "./ui/button";
import { ConfirmButton } from "./ui/confirm-button";
interface PlayerManagementProps {
open: boolean;
onClose: () => void;
characters: readonly PlayerCharacter[];
onEdit: (pc: PlayerCharacter) => void;
onDelete: (id: PlayerCharacterId) => void;
onCreate: () => void;
}
export function PlayerManagement({
open,
onClose,
characters,
onEdit,
onDelete,
onCreate,
}: PlayerManagementProps) {
useEffect(() => {
if (!open) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, onClose]);
if (!open) return null;
return (
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onMouseDown={onClose}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
<div
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">
Player Characters
</h2>
<button
type="button"
onClick={onClose}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X size={20} />
</button>
</div>
{characters.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-8 text-center">
<p className="text-muted-foreground">No player characters yet</p>
<Button onClick={onCreate} size="sm">
<Plus size={16} />
Create your first player character
</Button>
</div>
) : (
<div className="flex flex-col gap-1">
{characters.map((pc) => {
const Icon = PLAYER_ICON_MAP[pc.icon as PlayerIcon];
const color =
PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX];
return (
<div
key={pc.id}
className="group flex items-center gap-3 rounded-md px-3 py-2 hover:bg-background/50"
>
{Icon && (
<Icon size={18} style={{ color }} className="shrink-0" />
)}
<span className="flex-1 truncate text-sm text-foreground">
{pc.name}
</span>
<span className="text-xs tabular-nums text-muted-foreground">
AC {pc.ac}
</span>
<span className="text-xs tabular-nums text-muted-foreground">
HP {pc.maxHp}
</span>
<button
type="button"
onClick={() => onEdit(pc)}
className="text-muted-foreground hover:text-foreground transition-colors"
title="Edit"
>
<Pencil size={14} />
</button>
<ConfirmButton
icon={<Trash2 size={14} />}
label="Delete player character"
onConfirm={() => onDelete(pc.id)}
className="h-6 w-6 text-muted-foreground"
/>
</div>
);
})}
<div className="mt-2 flex justify-end">
<Button onClick={onCreate} size="sm" variant="ghost">
<Plus size={16} />
Add
</Button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,131 @@
import { Download, Loader2, Upload } from "lucide-react";
import { useRef, useState } from "react";
import { getDefaultFetchUrl } from "../adapters/bestiary-index-adapter.js";
import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
interface SourceFetchPromptProps {
sourceCode: string;
sourceDisplayName: string;
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
onSourceLoaded: () => void;
onUploadSource: (sourceCode: string, jsonData: unknown) => Promise<void>;
}
export function SourceFetchPrompt({
sourceCode,
sourceDisplayName,
fetchAndCacheSource,
onSourceLoaded,
onUploadSource,
}: SourceFetchPromptProps) {
const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode));
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
const [error, setError] = useState<string>("");
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFetch = async () => {
setStatus("fetching");
setError("");
try {
await fetchAndCacheSource(sourceCode, url);
onSourceLoaded();
} catch (e) {
setStatus("error");
setError(e instanceof Error ? e.message : "Failed to fetch source data");
}
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setStatus("fetching");
setError("");
try {
const text = await file.text();
const json = JSON.parse(text);
await onUploadSource(sourceCode, json);
onSourceLoaded();
} catch (err) {
setStatus("error");
setError(
err instanceof Error ? err.message : "Failed to process uploaded file",
);
}
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
return (
<div className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold text-foreground">
Load {sourceDisplayName}
</h3>
<p className="mt-1 text-xs text-muted-foreground">
Stat block data for this source needs to be loaded. Enter a URL or
upload a JSON file.
</p>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="source-url" className="text-xs text-muted-foreground">
Source URL
</label>
<Input
id="source-url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
disabled={status === "fetching"}
className="text-xs"
/>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={handleFetch}
disabled={status === "fetching" || !url}
>
{status === "fetching" ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<Download className="mr-1 h-3 w-3" />
)}
{status === "fetching" ? "Loading..." : "Load"}
</Button>
<span className="text-xs text-muted-foreground">or</span>
<Button
size="sm"
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={status === "fetching"}
>
<Upload className="mr-1 h-3 w-3" />
Upload file
</Button>
<input
ref={fileInputRef}
type="file"
accept=".json"
className="hidden"
onChange={handleFileUpload}
/>
</div>
{status === "error" && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{error}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { Database, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import type { CachedSourceInfo } from "../adapters/bestiary-cache.js";
import * as bestiaryCache from "../adapters/bestiary-cache.js";
import { Button } from "./ui/button.js";
interface SourceManagerProps {
onCacheCleared: () => void;
}
export function SourceManager({ onCacheCleared }: SourceManagerProps) {
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
const loadSources = useCallback(async () => {
const cached = await bestiaryCache.getCachedSources();
setSources(cached);
}, []);
useEffect(() => {
loadSources();
}, [loadSources]);
const handleClearSource = async (sourceCode: string) => {
await bestiaryCache.clearSource(sourceCode);
await loadSources();
onCacheCleared();
};
const handleClearAll = async () => {
await bestiaryCache.clearAll();
await loadSources();
onCacheCleared();
};
if (sources.length === 0) {
return (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<Database className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No cached sources</p>
</div>
);
}
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-foreground">
Cached Sources
</span>
<Button
size="sm"
variant="outline"
className="hover:text-hover-destructive hover:border-hover-destructive"
onClick={handleClearAll}
>
<Trash2 className="mr-1 h-3 w-3" />
Clear All
</Button>
</div>
<ul className="flex flex-col gap-1">
{sources.map((source) => (
<li
key={source.sourceCode}
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
>
<div>
<span className="text-sm text-foreground">
{source.displayName}
</span>
<span className="ml-2 text-xs text-muted-foreground">
{source.creatureCount} creatures
</span>
</div>
<button
type="button"
onClick={() => handleClearSource(source.sourceCode)}
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-hover-destructive-bg hover:text-hover-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
</div>
);
}

View File

@@ -1,17 +1,242 @@
import type { Creature } from "@initiative/domain";
import { X } from "lucide-react";
import type { Creature, CreatureId } from "@initiative/domain";
import { PanelRightClose, Pin, PinOff } from "lucide-react";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
import type { BulkImportState } from "../hooks/use-bulk-import.js";
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
import { BulkImportPrompt } from "./bulk-import-prompt.js";
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
import { StatBlock } from "./stat-block.js";
interface StatBlockPanelProps {
creatureId: CreatureId | null;
creature: Creature | null;
onClose: () => void;
isSourceCached: (sourceCode: string) => Promise<boolean>;
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
uploadAndCacheSource: (
sourceCode: string,
jsonData: unknown,
) => Promise<void>;
refreshCache: () => Promise<void>;
panelRole: "browse" | "pinned";
isFolded: boolean;
onToggleFold: () => void;
onPin: () => void;
onUnpin: () => void;
showPinButton: boolean;
side: "left" | "right";
onDismiss: () => void;
bulkImportMode?: boolean;
bulkImportState?: BulkImportState;
onStartBulkImport?: (baseUrl: string) => void;
onBulkImportDone?: () => void;
}
export function StatBlockPanel({ creature, onClose }: StatBlockPanelProps) {
function extractSourceCode(cId: CreatureId): string {
const colonIndex = cId.indexOf(":");
if (colonIndex === -1) return "";
return cId.slice(0, colonIndex).toUpperCase();
}
function FoldedTab({
creatureName,
side,
onToggleFold,
}: {
creatureName: string;
side: "left" | "right";
onToggleFold: () => void;
}) {
return (
<button
type="button"
onClick={onToggleFold}
className={`flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral ${
side === "right" ? "self-start" : "self-end"
}`}
aria-label="Unfold stat block panel"
>
<span className="writing-vertical-rl text-sm font-medium">
{creatureName}
</span>
</button>
);
}
function PanelHeader({
panelRole,
showPinButton,
onToggleFold,
onPin,
onUnpin,
}: {
panelRole: "browse" | "pinned";
showPinButton: boolean;
onToggleFold: () => void;
onPin: () => void;
onUnpin: () => void;
}) {
return (
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<div className="flex items-center gap-1">
{panelRole === "browse" && (
<button
type="button"
onClick={onToggleFold}
className="text-muted-foreground hover:text-hover-neutral"
aria-label="Fold stat block panel"
>
<PanelRightClose className="h-4 w-4" />
</button>
)}
</div>
<div className="flex items-center gap-1">
{panelRole === "browse" && showPinButton && (
<button
type="button"
onClick={onPin}
className="text-muted-foreground hover:text-hover-neutral"
aria-label="Pin creature"
>
<Pin className="h-4 w-4" />
</button>
)}
{panelRole === "pinned" && (
<button
type="button"
onClick={onUnpin}
className="text-muted-foreground hover:text-hover-neutral"
aria-label="Unpin creature"
>
<PinOff className="h-4 w-4" />
</button>
)}
</div>
</div>
);
}
function DesktopPanel({
isFolded,
side,
creatureName,
panelRole,
showPinButton,
onToggleFold,
onPin,
onUnpin,
children,
}: {
isFolded: boolean;
side: "left" | "right";
creatureName: string;
panelRole: "browse" | "pinned";
showPinButton: boolean;
onToggleFold: () => void;
onPin: () => void;
onUnpin: () => void;
children: ReactNode;
}) {
const sideClasses = side === "left" ? "left-0 border-r" : "right-0 border-l";
const foldedTranslate =
side === "right"
? "translate-x-[calc(100%-40px)]"
: "translate-x-[calc(-100%+40px)]";
return (
<div
className={`fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel ${sideClasses} ${isFolded ? foldedTranslate : "translate-x-0"}`}
>
{isFolded ? (
<FoldedTab
creatureName={creatureName}
side={side}
onToggleFold={onToggleFold}
/>
) : (
<>
<PanelHeader
panelRole={panelRole}
showPinButton={showPinButton}
onToggleFold={onToggleFold}
onPin={onPin}
onUnpin={onUnpin}
/>
<div className="flex-1 overflow-y-auto p-4">{children}</div>
</>
)}
</div>
);
}
function MobileDrawer({
onDismiss,
children,
}: {
onDismiss: () => void;
children: ReactNode;
}) {
const { offsetX, isSwiping, handlers } = useSwipeToDismiss(onDismiss);
return (
<div className="fixed inset-0 z-50">
<button
type="button"
className="absolute inset-0 bg-black/50 animate-in fade-in"
onClick={onDismiss}
aria-label="Close stat block"
/>
<div
className={`absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-l border-border bg-card shadow-xl ${isSwiping ? "" : "animate-slide-in-right"}`}
style={
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
}
{...handlers}
>
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<button
type="button"
onClick={onDismiss}
className="text-muted-foreground hover:text-hover-neutral"
aria-label="Fold stat block panel"
>
<PanelRightClose className="h-4 w-4" />
</button>
</div>
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
{children}
</div>
</div>
</div>
);
}
export function StatBlockPanel({
creatureId,
creature,
isSourceCached,
fetchAndCacheSource,
uploadAndCacheSource,
refreshCache,
panelRole,
isFolded,
onToggleFold,
onPin,
onUnpin,
showPinButton,
side,
onDismiss,
bulkImportMode,
bulkImportState,
onStartBulkImport,
onBulkImportDone,
}: StatBlockPanelProps) {
const [isDesktop, setIsDesktop] = useState(
() => window.matchMedia("(min-width: 1024px)").matches,
);
const [needsFetch, setNeedsFetch] = useState(false);
const [checkingCache, setCheckingCache] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(min-width: 1024px)");
@@ -20,58 +245,100 @@ export function StatBlockPanel({ creature, onClose }: StatBlockPanelProps) {
return () => mq.removeEventListener("change", handler);
}, []);
if (!creature) return null;
useEffect(() => {
if (!creatureId || creature) {
setNeedsFetch(false);
return;
}
if (isDesktop) {
const sourceCode = extractSourceCode(creatureId);
if (!sourceCode) {
setNeedsFetch(false);
return;
}
setCheckingCache(true);
isSourceCached(sourceCode).then((cached) => {
setNeedsFetch(!cached);
setCheckingCache(false);
});
}, [creatureId, creature, isSourceCached]);
if (!creatureId && !bulkImportMode) return null;
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
const handleSourceLoaded = async () => {
await refreshCache();
setNeedsFetch(false);
};
const renderContent = () => {
if (
bulkImportMode &&
bulkImportState &&
onStartBulkImport &&
onBulkImportDone
) {
return (
<div className="fixed top-0 right-0 bottom-0 flex w-[400px] flex-col border-l border-border bg-card">
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<span className="text-sm font-semibold text-muted-foreground">
Stat Block
</span>
<button
type="button"
onClick={onClose}
className="text-muted-foreground hover:text-hover-neutral"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<StatBlock creature={creature} />
</div>
</div>
<BulkImportPrompt
importState={bulkImportState}
onStartImport={onStartBulkImport}
onDone={onBulkImportDone}
/>
);
}
// Mobile drawer
if (checkingCache) {
return (
<div className="fixed inset-0 z-50">
{/* Backdrop */}
<button
type="button"
className="absolute inset-0 bg-black/50 animate-in fade-in"
onClick={onClose}
aria-label="Close stat block"
<div className="p-4 text-sm text-muted-foreground">Loading...</div>
);
}
if (creature) {
return <StatBlock creature={creature} />;
}
if (needsFetch && sourceCode) {
return (
<SourceFetchPrompt
sourceCode={sourceCode}
sourceDisplayName={getSourceDisplayName(sourceCode)}
fetchAndCacheSource={fetchAndCacheSource}
onSourceLoaded={handleSourceLoaded}
onUploadSource={uploadAndCacheSource}
/>
{/* Drawer */}
<div className="absolute top-0 right-0 bottom-0 w-[85%] max-w-md animate-slide-in-right border-l border-border bg-card shadow-xl">
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<span className="text-sm font-semibold text-muted-foreground">
Stat Block
</span>
<button
type="button"
onClick={onClose}
className="text-muted-foreground hover:text-hover-neutral"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
<StatBlock creature={creature} />
</div>
</div>
);
}
return (
<div className="p-4 text-sm text-muted-foreground">
No stat block available
</div>
);
};
const creatureName =
creature?.name ?? (bulkImportMode ? "Bulk Import" : "Creature");
if (isDesktop) {
return (
<DesktopPanel
isFolded={isFolded}
side={side}
creatureName={creatureName}
panelRole={panelRole}
showPinButton={showPinButton}
onToggleFold={onToggleFold}
onPin={onPin}
onUnpin={onUnpin}
>
{renderContent()}
</DesktopPanel>
);
}
if (panelRole === "pinned") return null;
return <MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>;
}

View File

@@ -0,0 +1,47 @@
import { X } from "lucide-react";
import { useEffect } from "react";
import { createPortal } from "react-dom";
interface ToastProps {
message: string;
progress?: number;
onDismiss: () => void;
autoDismissMs?: number;
}
export function Toast({
message,
progress,
onDismiss,
autoDismissMs,
}: ToastProps) {
useEffect(() => {
if (autoDismissMs === undefined) return;
const timer = setTimeout(onDismiss, autoDismissMs);
return () => clearTimeout(timer);
}, [autoDismissMs, onDismiss]);
return createPortal(
<div className="fixed bottom-4 left-1/2 z-50 -translate-x-1/2">
<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-lg">
<span className="text-sm text-foreground">{message}</span>
{progress !== undefined && (
<div className="h-2 w-24 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${Math.round(progress * 100)}%` }}
/>
</div>
)}
<button
type="button"
onClick={onDismiss}
className="text-muted-foreground hover:text-hover-neutral"
>
<X className="h-3 w-3" />
</button>
</div>
</div>,
document.body,
);
}

View File

@@ -1,7 +1,8 @@
import type { Encounter } from "@initiative/domain";
import { StepBack, StepForward, Trash2 } from "lucide-react";
import { Library, StepBack, StepForward, Trash2 } from "lucide-react";
import { D20Icon } from "./d20-icon";
import { Button } from "./ui/button";
import { ConfirmButton } from "./ui/confirm-button";
interface TurnNavigationProps {
encounter: Encounter;
@@ -9,6 +10,7 @@ interface TurnNavigationProps {
onRetreatTurn: () => void;
onClearEncounter: () => void;
onRollAllInitiative: () => void;
onOpenSourceManager: () => void;
}
export function TurnNavigation({
@@ -17,17 +19,16 @@ export function TurnNavigation({
onRetreatTurn,
onClearEncounter,
onRollAllInitiative,
onOpenSourceManager,
}: TurnNavigationProps) {
const hasCombatants = encounter.combatants.length > 0;
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
const activeCombatant = encounter.combatants[encounter.activeIndex];
return (
<div className="flex items-center justify-between rounded-md border border-border bg-card px-4 py-3">
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
<Button
variant="outline"
size="icon"
className="h-8 w-8 text-foreground border-foreground hover:text-hover-action hover:border-hover-action hover:bg-transparent"
onClick={onRetreatTurn}
disabled={!hasCombatants || isAtStart}
title="Previous turn"
@@ -36,26 +37,23 @@ export function TurnNavigation({
<StepBack className="h-5 w-5" />
</Button>
<div className="text-center text-sm">
{activeCombatant ? (
<>
<span className="font-medium">Round {encounter.roundNumber}</span>
<span className="text-muted-foreground">
{" "}
{activeCombatant.name}
<div className="min-w-0 flex-1 flex items-center justify-center gap-2 text-sm">
<span className="rounded-full bg-muted text-foreground text-sm px-2 py-0.5 font-semibold shrink-0">
R{encounter.roundNumber}
</span>
</>
{activeCombatant ? (
<span className="truncate font-medium">{activeCombatant.name}</span>
) : (
<span className="text-muted-foreground">No combatants</span>
)}
</div>
<div className="flex items-center gap-3">
<div className="flex flex-shrink-0 items-center gap-3">
<div className="flex items-center gap-0">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-hover-action"
className="text-muted-foreground hover:text-hover-action"
onClick={onRollAllInitiative}
title="Roll all initiative"
aria-label="Roll all initiative"
@@ -65,17 +63,23 @@ export function TurnNavigation({
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-hover-destructive"
onClick={onClearEncounter}
disabled={!hasCombatants}
className="text-muted-foreground hover:text-hover-neutral"
onClick={onOpenSourceManager}
title="Manage cached sources"
aria-label="Manage cached sources"
>
<Trash2 className="h-5 w-5" />
<Library className="h-5 w-5" />
</Button>
<ConfirmButton
icon={<Trash2 className="h-5 w-5" />}
label="Clear encounter"
onConfirm={onClearEncounter}
disabled={!hasCombatants}
className="text-muted-foreground"
/>
</div>
<Button
variant="outline"
size="icon"
className="h-8 w-8 text-foreground border-foreground hover:text-hover-action hover:border-hover-action hover:bg-transparent"
onClick={onAdvanceTurn}
disabled={!hasCombatants}
title="Next turn"

View File

@@ -0,0 +1,115 @@
import { Check } from "lucide-react";
import {
type ReactElement,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { cn } from "../../lib/utils";
import { Button } from "./button";
interface ConfirmButtonProps {
readonly onConfirm: () => void;
readonly icon: ReactElement;
readonly label: string;
readonly className?: string;
readonly disabled?: boolean;
}
const REVERT_TIMEOUT_MS = 5_000;
export function ConfirmButton({
onConfirm,
icon,
label,
className,
disabled,
}: ConfirmButtonProps) {
const [isConfirming, setIsConfirming] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const wrapperRef = useRef<HTMLDivElement>(null);
const revert = useCallback(() => {
setIsConfirming(false);
clearTimeout(timerRef.current);
}, []);
// Cleanup timer on unmount
useEffect(() => {
return () => clearTimeout(timerRef.current);
}, []);
// Click-outside listener when confirming
useEffect(() => {
if (!isConfirming) return;
function handleMouseDown(e: MouseEvent) {
if (
wrapperRef.current &&
!wrapperRef.current.contains(e.target as Node)
) {
revert();
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
revert();
}
}
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isConfirming, revert]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
}
}, []);
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (disabled) return;
if (isConfirming) {
revert();
onConfirm();
} else {
setIsConfirming(true);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(revert, REVERT_TIMEOUT_MS);
}
},
[isConfirming, disabled, onConfirm, revert],
);
return (
<div ref={wrapperRef} className="inline-flex">
<Button
variant="ghost"
size="icon"
className={cn(
className,
isConfirming
? "bg-destructive text-primary-foreground rounded-md animate-confirm-pulse hover:bg-destructive hover:text-primary-foreground"
: "hover:text-hover-destructive",
)}
onClick={handleClick}
onKeyDown={handleKeyDown}
onBlur={revert}
disabled={disabled}
aria-label={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
title={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
>
{isConfirming ? <Check size={16} /> : icon}
</Button>
</div>
);
}

View File

@@ -1,62 +1,126 @@
import type { Creature, CreatureId } from "@initiative/domain";
import { useEffect, useMemo, useRef, useState } from "react";
import { normalizeBestiary } from "../adapters/bestiary-adapter.js";
import type {
BestiaryIndexEntry,
Creature,
CreatureId,
} from "@initiative/domain";
import { useCallback, useEffect, useRef, useState } from "react";
import {
normalizeBestiary,
setSourceDisplayNames,
} from "../adapters/bestiary-adapter.js";
import * as bestiaryCache from "../adapters/bestiary-cache.js";
import {
getSourceDisplayName,
loadBestiaryIndex,
} from "../adapters/bestiary-index-adapter.js";
export interface SearchResult extends BestiaryIndexEntry {
readonly sourceDisplayName: string;
}
interface BestiaryHook {
search: (query: string) => Creature[];
search: (query: string) => SearchResult[];
getCreature: (id: CreatureId) => Creature | undefined;
allCreatures: Creature[];
isLoaded: boolean;
isSourceCached: (sourceCode: string) => Promise<boolean>;
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
uploadAndCacheSource: (
sourceCode: string,
jsonData: unknown,
) => Promise<void>;
refreshCache: () => Promise<void>;
}
export function useBestiary(): BestiaryHook {
const [creatures, setCreatures] = useState<Creature[]>([]);
const [isLoaded, setIsLoaded] = useState(false);
const creatureMapRef = useRef<Map<string, Creature>>(new Map());
const loadAttempted = useRef(false);
const creatureMapRef = useRef<Map<CreatureId, Creature>>(new Map());
const [, setTick] = useState(0);
useEffect(() => {
if (loadAttempted.current) return;
loadAttempted.current = true;
import("../../../../data/bestiary/xmm.json")
// biome-ignore lint/suspicious/noExplicitAny: raw JSON shape varies per entry
.then((mod: any) => {
const raw = mod.default ?? mod;
try {
const normalized = normalizeBestiary(raw);
const map = new Map<string, Creature>();
for (const c of normalized) {
map.set(c.id, c);
}
creatureMapRef.current = map;
setCreatures(normalized);
const index = loadBestiaryIndex();
setSourceDisplayNames(index.sources as Record<string, string>);
if (index.creatures.length > 0) {
setIsLoaded(true);
} catch {
// Normalization failed — bestiary unavailable
}
})
.catch(() => {
// Import failed — bestiary unavailable
bestiaryCache.loadAllCachedCreatures().then((map) => {
creatureMapRef.current = map;
setTick((t) => t + 1);
});
}, []);
const search = useMemo(() => {
return (query: string): Creature[] => {
const search = useCallback((query: string): SearchResult[] => {
if (query.length < 2) return [];
const lower = query.toLowerCase();
return creatures
const index = loadBestiaryIndex();
return index.creatures
.filter((c) => c.name.toLowerCase().includes(lower))
.sort((a, b) => a.name.localeCompare(b.name))
.slice(0, 10);
};
}, [creatures]);
const getCreature = useMemo(() => {
return (id: CreatureId): Creature | undefined => {
return creatureMapRef.current.get(id);
};
.slice(0, 10)
.map((c) => ({
...c,
sourceDisplayName: getSourceDisplayName(c.source),
}));
}, []);
return { search, getCreature, allCreatures: creatures, isLoaded };
const getCreature = useCallback((id: CreatureId): Creature | undefined => {
return creatureMapRef.current.get(id);
}, []);
const isSourceCachedFn = useCallback(
(sourceCode: string): Promise<boolean> => {
return bestiaryCache.isSourceCached(sourceCode);
},
[],
);
const fetchAndCacheSource = useCallback(
async (sourceCode: string, url: string): Promise<void> => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to fetch: ${response.status} ${response.statusText}`,
);
}
const json = await response.json();
const creatures = normalizeBestiary(json);
const displayName = getSourceDisplayName(sourceCode);
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
for (const c of creatures) {
creatureMapRef.current.set(c.id, c);
}
setTick((t) => t + 1);
},
[],
);
const uploadAndCacheSource = useCallback(
async (sourceCode: string, jsonData: unknown): Promise<void> => {
// biome-ignore lint/suspicious/noExplicitAny: raw JSON shape varies
const creatures = normalizeBestiary(jsonData as any);
const displayName = getSourceDisplayName(sourceCode);
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
for (const c of creatures) {
creatureMapRef.current.set(c.id, c);
}
setTick((t) => t + 1);
},
[],
);
const refreshCache = useCallback(async (): Promise<void> => {
const map = await bestiaryCache.loadAllCachedCreatures();
creatureMapRef.current = map;
setTick((t) => t + 1);
}, []);
return {
search,
getCreature,
isLoaded,
isSourceCached: isSourceCachedFn,
fetchAndCacheSource,
uploadAndCacheSource,
refreshCache,
};
}

View File

@@ -0,0 +1,120 @@
import { useCallback, useRef, useState } from "react";
import {
getAllSourceCodes,
getDefaultFetchUrl,
} from "../adapters/bestiary-index-adapter.js";
const BATCH_SIZE = 6;
export interface BulkImportState {
readonly status: "idle" | "loading" | "complete" | "partial-failure";
readonly total: number;
readonly completed: number;
readonly failed: number;
}
const IDLE_STATE: BulkImportState = {
status: "idle",
total: 0,
completed: 0,
failed: 0,
};
interface BulkImportHook {
state: BulkImportState;
startImport: (
baseUrl: string,
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>,
isSourceCached: (sourceCode: string) => Promise<boolean>,
refreshCache: () => Promise<void>,
) => void;
reset: () => void;
}
export function useBulkImport(): BulkImportHook {
const [state, setState] = useState<BulkImportState>(IDLE_STATE);
const countersRef = useRef({ completed: 0, failed: 0 });
const startImport = useCallback(
(
baseUrl: string,
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>,
isSourceCached: (sourceCode: string) => Promise<boolean>,
refreshCache: () => Promise<void>,
) => {
const allCodes = getAllSourceCodes();
const total = allCodes.length;
countersRef.current = { completed: 0, failed: 0 };
setState({ status: "loading", total, completed: 0, failed: 0 });
(async () => {
const cacheChecks = await Promise.all(
allCodes.map(async (code) => ({
code,
cached: await isSourceCached(code),
})),
);
const alreadyCached = cacheChecks.filter((c) => c.cached).length;
const uncached = cacheChecks.filter((c) => !c.cached);
countersRef.current.completed = alreadyCached;
if (uncached.length === 0) {
setState({
status: "complete",
total,
completed: total,
failed: 0,
});
return;
}
setState((s) => ({ ...s, completed: alreadyCached }));
for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
const batch = uncached.slice(i, i + BATCH_SIZE);
await Promise.allSettled(
batch.map(async ({ code }) => {
const url = getDefaultFetchUrl(code, baseUrl);
try {
await fetchAndCacheSource(code, url);
countersRef.current.completed++;
} catch (err) {
countersRef.current.failed++;
console.warn(
`[bulk-import] FAILED ${code} (${url}):`,
err instanceof Error ? err.message : err,
);
}
setState({
status: "loading",
total,
completed: countersRef.current.completed,
failed: countersRef.current.failed,
});
}),
);
}
await refreshCache();
const { completed, failed } = countersRef.current;
setState({
status: failed > 0 ? "partial-failure" : "complete",
total,
completed,
failed,
});
})();
},
[],
);
const reset = useCallback(() => {
setState(IDLE_STATE);
}, []);
return { state, startImport, reset };
}

View File

@@ -14,16 +14,17 @@ import {
toggleConditionUseCase,
} from "@initiative/application";
import type {
BestiaryIndexEntry,
CombatantId,
ConditionId,
Creature,
DomainEvent,
Encounter,
PlayerCharacter,
} from "@initiative/domain";
import {
combatantId,
createEncounter,
isDomainError,
creatureId as makeCreatureId,
resolveCreatureName,
} from "@initiative/domain";
import { useCallback, useEffect, useRef, useState } from "react";
@@ -32,24 +33,16 @@ import {
saveEncounter,
} from "../persistence/encounter-storage.js";
function createDemoEncounter(): Encounter {
const result = createEncounter([
{ id: combatantId("1"), name: "Aria" },
{ id: combatantId("2"), name: "Brak" },
{ id: combatantId("3"), name: "Cael" },
]);
if (isDomainError(result)) {
throw new Error(`Failed to create demo encounter: ${result.message}`);
}
return result;
}
const EMPTY_ENCOUNTER: Encounter = {
combatants: [],
activeIndex: 0,
roundNumber: 1,
};
function initializeEncounter(): Encounter {
const stored = loadEncounter();
if (stored !== null) return stored;
return createDemoEncounter();
return EMPTY_ENCOUNTER;
}
function deriveNextId(encounter: Encounter): number {
@@ -64,6 +57,33 @@ function deriveNextId(encounter: Encounter): number {
return max;
}
interface CombatantOpts {
initiative?: number;
ac?: number;
maxHp?: number;
}
function applyCombatantOpts(
makeStore: () => EncounterStore,
id: ReturnType<typeof combatantId>,
opts: CombatantOpts,
): DomainEvent[] {
const events: DomainEvent[] = [];
if (opts.maxHp !== undefined) {
const r = setHpUseCase(makeStore(), id, opts.maxHp);
if (!isDomainError(r)) events.push(...r);
}
if (opts.ac !== undefined) {
const r = setAcUseCase(makeStore(), id, opts.ac);
if (!isDomainError(r)) events.push(...r);
}
if (opts.initiative !== undefined) {
const r = setInitiativeUseCase(makeStore(), id, opts.initiative);
if (!isDomainError(r)) events.push(...r);
}
return events;
}
export function useEncounter() {
const [encounter, setEncounter] = useState<Encounter>(initializeEncounter);
const [events, setEvents] = useState<DomainEvent[]>([]);
@@ -107,7 +127,7 @@ export function useEncounter() {
const nextId = useRef(deriveNextId(encounter));
const addCombatant = useCallback(
(name: string) => {
(name: string, opts?: CombatantOpts) => {
const id = combatantId(`c-${++nextId.current}`);
const result = addCombatantUseCase(makeStore(), id, name);
@@ -115,6 +135,13 @@ export function useEncounter() {
return;
}
if (opts) {
const optEvents = applyCombatantOpts(makeStore, id, opts);
if (optEvents.length > 0) {
setEvents((prev) => [...prev, ...optEvents]);
}
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
@@ -225,10 +252,6 @@ export function useEncounter() {
);
const clearEncounter = useCallback(() => {
if (!window.confirm("Clear the entire encounter? This cannot be undone.")) {
return;
}
const result = clearEncounterUseCase(makeStore());
if (isDomainError(result)) {
@@ -240,11 +263,11 @@ export function useEncounter() {
}, [makeStore]);
const addFromBestiary = useCallback(
(creature: Creature) => {
(entry: BestiaryIndexEntry) => {
const store = makeStore();
const existingNames = store.get().combatants.map((c) => c.name);
const { newName, renames } = resolveCreatureName(
creature.name,
entry.name,
existingNames,
);
@@ -262,28 +285,86 @@ export function useEncounter() {
if (isDomainError(addResult)) return;
// Set HP
const hpResult = setHpUseCase(makeStore(), id, creature.hp.average);
const hpResult = setHpUseCase(makeStore(), id, entry.hp);
if (!isDomainError(hpResult)) {
setEvents((prev) => [...prev, ...hpResult]);
}
// Set AC
if (creature.ac > 0) {
const acResult = setAcUseCase(makeStore(), id, creature.ac);
if (entry.ac > 0) {
const acResult = setAcUseCase(makeStore(), id, entry.ac);
if (!isDomainError(acResult)) {
setEvents((prev) => [...prev, ...acResult]);
}
}
// Set creatureId on the combatant
// Derive creatureId from source + name
const slug = entry.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
// Set creatureId on the combatant (use store.save to keep ref in sync for batch calls)
const currentEncounter = store.get();
const updated = {
store.save({
...currentEncounter,
combatants: currentEncounter.combatants.map((c) =>
c.id === id ? { ...c, creatureId: creature.id } : c,
c.id === id ? { ...c, creatureId: cId } : c,
),
};
setEncounter(updated);
});
setEvents((prev) => [...prev, ...addResult]);
},
[makeStore, editCombatant],
);
const addFromPlayerCharacter = useCallback(
(pc: PlayerCharacter) => {
const store = makeStore();
const existingNames = store.get().combatants.map((c) => c.name);
const { newName, renames } = resolveCreatureName(pc.name, existingNames);
for (const { from, to } of renames) {
const target = store.get().combatants.find((c) => c.name === from);
if (target) {
editCombatantUseCase(makeStore(), target.id, to);
}
}
const id = combatantId(`c-${++nextId.current}`);
const addResult = addCombatantUseCase(makeStore(), id, newName);
if (isDomainError(addResult)) return;
// Set HP
const hpResult = setHpUseCase(makeStore(), id, pc.maxHp);
if (!isDomainError(hpResult)) {
setEvents((prev) => [...prev, ...hpResult]);
}
// Set AC
if (pc.ac > 0) {
const acResult = setAcUseCase(makeStore(), id, pc.ac);
if (!isDomainError(acResult)) {
setEvents((prev) => [...prev, ...acResult]);
}
}
// Set color, icon, and playerCharacterId on the combatant
const currentEncounter = store.get();
store.save({
...currentEncounter,
combatants: currentEncounter.combatants.map((c) =>
c.id === id
? {
...c,
color: pc.color,
icon: pc.icon,
playerCharacterId: pc.id,
}
: c,
),
});
setEvents((prev) => [...prev, ...addResult]);
},
@@ -306,6 +387,7 @@ export function useEncounter() {
toggleCondition,
toggleConcentration,
addFromBestiary,
addFromPlayerCharacter,
makeStore,
} as const;
}

View File

@@ -0,0 +1,102 @@
import type { PlayerCharacterStore } from "@initiative/application";
import {
createPlayerCharacterUseCase,
deletePlayerCharacterUseCase,
editPlayerCharacterUseCase,
} from "@initiative/application";
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
import { isDomainError, playerCharacterId } from "@initiative/domain";
import { useCallback, useEffect, useRef, useState } from "react";
import {
loadPlayerCharacters,
savePlayerCharacters,
} from "../persistence/player-character-storage.js";
function initializeCharacters(): PlayerCharacter[] {
return loadPlayerCharacters();
}
let nextPcId = 0;
function generatePcId(): PlayerCharacterId {
return playerCharacterId(`pc-${++nextPcId}`);
}
interface EditFields {
readonly name?: string;
readonly ac?: number;
readonly maxHp?: number;
readonly color?: string;
readonly icon?: string;
}
export function usePlayerCharacters() {
const [characters, setCharacters] =
useState<PlayerCharacter[]>(initializeCharacters);
const charactersRef = useRef(characters);
charactersRef.current = characters;
useEffect(() => {
savePlayerCharacters(characters);
}, [characters]);
const makeStore = useCallback((): PlayerCharacterStore => {
return {
getAll: () => charactersRef.current,
save: (updated) => {
charactersRef.current = updated;
setCharacters(updated);
},
};
}, []);
const createCharacter = useCallback(
(name: string, ac: number, maxHp: number, color: string, icon: string) => {
const id = generatePcId();
const result = createPlayerCharacterUseCase(
makeStore(),
id,
name,
ac,
maxHp,
color,
icon,
);
if (isDomainError(result)) {
return result;
}
return undefined;
},
[makeStore],
);
const editCharacter = useCallback(
(id: PlayerCharacterId, fields: EditFields) => {
const result = editPlayerCharacterUseCase(makeStore(), id, fields);
if (isDomainError(result)) {
return result;
}
return undefined;
},
[makeStore],
);
const deleteCharacter = useCallback(
(id: PlayerCharacterId) => {
const result = deletePlayerCharacterUseCase(makeStore(), id);
if (isDomainError(result)) {
return result;
}
return undefined;
},
[makeStore],
);
return {
characters,
createCharacter,
editCharacter,
deleteCharacter,
makeStore,
} as const;
}

View File

@@ -0,0 +1,72 @@
import { useCallback, useRef, useState } from "react";
const DISMISS_THRESHOLD = 0.35;
const VELOCITY_THRESHOLD = 0.5;
interface SwipeState {
offsetX: number;
isSwiping: boolean;
}
export function useSwipeToDismiss(onDismiss: () => void) {
const [swipe, setSwipe] = useState<SwipeState>({
offsetX: 0,
isSwiping: false,
});
const startX = useRef(0);
const startY = useRef(0);
const startTime = useRef(0);
const panelWidth = useRef(0);
const directionLocked = useRef<"horizontal" | "vertical" | null>(null);
const onTouchStart = useCallback((e: React.TouchEvent) => {
const touch = e.touches[0];
startX.current = touch.clientX;
startY.current = touch.clientY;
startTime.current = Date.now();
directionLocked.current = null;
const el = e.currentTarget as HTMLElement;
panelWidth.current = el.getBoundingClientRect().width;
}, []);
const onTouchMove = useCallback((e: React.TouchEvent) => {
const touch = e.touches[0];
const dx = touch.clientX - startX.current;
const dy = touch.clientY - startY.current;
if (!directionLocked.current) {
if (Math.abs(dx) < 10 && Math.abs(dy) < 10) return;
directionLocked.current =
Math.abs(dx) > Math.abs(dy) ? "horizontal" : "vertical";
}
if (directionLocked.current === "vertical") return;
const clampedX = Math.max(0, dx);
setSwipe({ offsetX: clampedX, isSwiping: true });
}, []);
const onTouchEnd = useCallback(() => {
if (directionLocked.current !== "horizontal") {
setSwipe({ offsetX: 0, isSwiping: false });
return;
}
const elapsed = (Date.now() - startTime.current) / 1000;
const velocity = swipe.offsetX / elapsed / panelWidth.current;
const ratio =
panelWidth.current > 0 ? swipe.offsetX / panelWidth.current : 0;
if (ratio > DISMISS_THRESHOLD || velocity > VELOCITY_THRESHOLD) {
onDismiss();
}
setSwipe({ offsetX: 0, isSwiping: false });
}, [swipe.offsetX, onDismiss]);
return {
offsetX: swipe.offsetX,
isSwiping: swipe.isSwiping,
handlers: { onTouchStart, onTouchMove, onTouchEnd },
};
}

View File

@@ -68,6 +68,48 @@
animation: slide-in-right 200ms ease-out;
}
@keyframes confirm-pulse {
0% {
scale: 1;
}
50% {
scale: 1.15;
}
100% {
scale: 1;
}
}
@keyframes breathe {
0%,
100% {
opacity: 0.4;
scale: 0.9;
}
50% {
opacity: 1;
scale: 1.1;
}
}
@utility animate-breathe {
animation: breathe 3s ease-in-out infinite;
}
@custom-variant pointer-coarse (@media (pointer: coarse));
@utility animate-confirm-pulse {
animation: confirm-pulse 300ms ease-out;
}
@utility transition-slide-panel {
transition: translate 200ms ease-out;
}
@utility writing-vertical-rl {
writing-mode: vertical-rl;
}
@utility animate-concentration-pulse {
animation:
concentration-shake 450ms ease-out,

View File

@@ -0,0 +1,231 @@
import type { PlayerCharacter } from "@initiative/domain";
import { playerCharacterId } from "@initiative/domain";
import { beforeEach, describe, expect, it } from "vitest";
import {
loadPlayerCharacters,
savePlayerCharacters,
} from "../player-character-storage.js";
const STORAGE_KEY = "initiative:player-characters";
function createMockLocalStorage() {
const store = new Map<string, string>();
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, value),
removeItem: (key: string) => store.delete(key),
clear: () => store.clear(),
get length() {
return store.size;
},
key: (_index: number) => null,
store,
};
}
function makePC(overrides?: Partial<PlayerCharacter>): PlayerCharacter {
return {
id: playerCharacterId("pc-1"),
name: "Aragorn",
ac: 16,
maxHp: 120,
color: "green",
icon: "sword",
...overrides,
};
}
describe("player-character-storage", () => {
let mockStorage: ReturnType<typeof createMockLocalStorage>;
beforeEach(() => {
mockStorage = createMockLocalStorage();
Object.defineProperty(globalThis, "localStorage", {
value: mockStorage,
writable: true,
});
});
describe("round-trip save/load", () => {
it("saves and loads a single character", () => {
const pc = makePC();
savePlayerCharacters([pc]);
const loaded = loadPlayerCharacters();
expect(loaded).toEqual([pc]);
});
it("saves and loads multiple characters", () => {
const pcs = [
makePC({ id: playerCharacterId("pc-1"), name: "Aragorn" }),
makePC({
id: playerCharacterId("pc-2"),
name: "Legolas",
ac: 14,
maxHp: 90,
color: "blue",
icon: "eye",
}),
];
savePlayerCharacters(pcs);
const loaded = loadPlayerCharacters();
expect(loaded).toEqual(pcs);
});
});
describe("empty storage", () => {
it("returns empty array when no data exists", () => {
expect(loadPlayerCharacters()).toEqual([]);
});
});
describe("corrupt JSON", () => {
it("returns empty array for invalid JSON", () => {
mockStorage.setItem(STORAGE_KEY, "not-json{{{");
expect(loadPlayerCharacters()).toEqual([]);
});
it("returns empty array for non-array JSON", () => {
mockStorage.setItem(STORAGE_KEY, '{"foo": "bar"}');
expect(loadPlayerCharacters()).toEqual([]);
});
});
describe("per-character validation", () => {
it("discards character with missing name", () => {
mockStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{ id: "pc-1", ac: 10, maxHp: 50, color: "blue", icon: "sword" },
]),
);
expect(loadPlayerCharacters()).toEqual([]);
});
it("discards character with empty name", () => {
mockStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{
id: "pc-1",
name: "",
ac: 10,
maxHp: 50,
color: "blue",
icon: "sword",
},
]),
);
expect(loadPlayerCharacters()).toEqual([]);
});
it("discards character with invalid color", () => {
mockStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{
id: "pc-1",
name: "Test",
ac: 10,
maxHp: 50,
color: "neon",
icon: "sword",
},
]),
);
expect(loadPlayerCharacters()).toEqual([]);
});
it("discards character with invalid icon", () => {
mockStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{
id: "pc-1",
name: "Test",
ac: 10,
maxHp: 50,
color: "blue",
icon: "banana",
},
]),
);
expect(loadPlayerCharacters()).toEqual([]);
});
it("discards character with negative AC", () => {
mockStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{
id: "pc-1",
name: "Test",
ac: -1,
maxHp: 50,
color: "blue",
icon: "sword",
},
]),
);
expect(loadPlayerCharacters()).toEqual([]);
});
it("discards character with maxHp of 0", () => {
mockStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{
id: "pc-1",
name: "Test",
ac: 10,
maxHp: 0,
color: "blue",
icon: "sword",
},
]),
);
expect(loadPlayerCharacters()).toEqual([]);
});
it("keeps valid characters and discards invalid ones", () => {
mockStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{
id: "pc-1",
name: "Valid",
ac: 10,
maxHp: 50,
color: "blue",
icon: "sword",
},
{
id: "pc-2",
name: "",
ac: 10,
maxHp: 50,
color: "blue",
icon: "sword",
},
]),
);
const loaded = loadPlayerCharacters();
expect(loaded).toHaveLength(1);
expect(loaded[0].name).toBe("Valid");
});
});
describe("storage errors", () => {
it("save silently catches errors", () => {
Object.defineProperty(globalThis, "localStorage", {
value: {
setItem: () => {
throw new Error("QuotaExceeded");
},
getItem: () => null,
},
writable: true,
});
expect(() => savePlayerCharacters([makePC()])).not.toThrow();
});
});
});

View File

@@ -5,7 +5,10 @@ import {
creatureId,
type Encounter,
isDomainError,
playerCharacterId,
VALID_CONDITION_IDS,
VALID_PLAYER_COLORS,
VALID_PLAYER_ICONS,
} from "@initiative/domain";
const STORAGE_KEY = "initiative:encounter";
@@ -18,6 +21,93 @@ export function saveEncounter(encounter: Encounter): void {
}
}
function validateAc(value: unknown): number | undefined {
return typeof value === "number" && Number.isInteger(value) && value >= 0
? value
: undefined;
}
function validateConditions(value: unknown): ConditionId[] | undefined {
if (!Array.isArray(value)) return undefined;
const valid = value.filter(
(v): v is ConditionId =>
typeof v === "string" && VALID_CONDITION_IDS.has(v),
);
return valid.length > 0 ? valid : undefined;
}
function validateCreatureId(value: unknown) {
return typeof value === "string" && value.length > 0
? creatureId(value)
: undefined;
}
function validateHp(
rawMaxHp: unknown,
rawCurrentHp: unknown,
): { maxHp: number; currentHp: number } | undefined {
if (
typeof rawMaxHp !== "number" ||
!Number.isInteger(rawMaxHp) ||
rawMaxHp < 1
) {
return undefined;
}
const validCurrentHp =
typeof rawCurrentHp === "number" &&
Number.isInteger(rawCurrentHp) &&
rawCurrentHp >= 0 &&
rawCurrentHp <= rawMaxHp;
return {
maxHp: rawMaxHp,
currentHp: validCurrentHp ? rawCurrentHp : rawMaxHp,
};
}
function rehydrateCombatant(c: unknown) {
const entry = c as Record<string, unknown>;
const base = {
id: combatantId(entry.id as string),
name: entry.name as string,
initiative:
typeof entry.initiative === "number" ? entry.initiative : undefined,
};
const color =
typeof entry.color === "string" && VALID_PLAYER_COLORS.has(entry.color)
? entry.color
: undefined;
const icon =
typeof entry.icon === "string" && VALID_PLAYER_ICONS.has(entry.icon)
? entry.icon
: undefined;
const pcId =
typeof entry.playerCharacterId === "string" &&
entry.playerCharacterId.length > 0
? playerCharacterId(entry.playerCharacterId)
: undefined;
const shared = {
...base,
ac: validateAc(entry.ac),
conditions: validateConditions(entry.conditions),
isConcentrating: entry.isConcentrating === true ? true : undefined,
creatureId: validateCreatureId(entry.creatureId),
color,
icon,
playerCharacterId: pcId,
};
const hp = validateHp(entry.maxHp, entry.currentHp);
return hp ? { ...shared, ...hp } : shared;
}
function isValidCombatantEntry(c: unknown): boolean {
if (typeof c !== "object" || c === null || Array.isArray(c)) return false;
const entry = c as Record<string, unknown>;
return typeof entry.id === "string" && typeof entry.name === "string";
}
export function loadEncounter(): Encounter | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
@@ -45,82 +135,9 @@ export function loadEncounter(): Encounter | null {
};
}
for (const c of combatants) {
if (typeof c !== "object" || c === null || Array.isArray(c)) return null;
const entry = c as Record<string, unknown>;
if (typeof entry.id !== "string") return null;
if (typeof entry.name !== "string") return null;
}
if (!combatants.every(isValidCombatantEntry)) return null;
const rehydrated = combatants.map((c) => {
const entry = c as Record<string, unknown>;
const base = {
id: combatantId(entry.id as string),
name: entry.name as string,
initiative:
typeof entry.initiative === "number" ? entry.initiative : undefined,
};
// Validate AC field
const ac = entry.ac;
const validAc =
typeof ac === "number" && Number.isInteger(ac) && ac >= 0
? ac
: undefined;
// Validate conditions field
const rawConditions = entry.conditions;
const validConditions: ConditionId[] | undefined = Array.isArray(
rawConditions,
)
? (rawConditions.filter(
(v): v is ConditionId =>
typeof v === "string" && VALID_CONDITION_IDS.has(v),
) as ConditionId[])
: undefined;
const conditions =
validConditions && validConditions.length > 0
? validConditions
: undefined;
// Validate isConcentrating field
const isConcentrating = entry.isConcentrating === true ? true : undefined;
// Validate creatureId field
const rawCreatureId = entry.creatureId;
const validCreatureId =
typeof rawCreatureId === "string" && rawCreatureId.length > 0
? creatureId(rawCreatureId)
: undefined;
// Validate and attach HP fields if valid
const maxHp = entry.maxHp;
const currentHp = entry.currentHp;
if (typeof maxHp === "number" && Number.isInteger(maxHp) && maxHp >= 1) {
const validCurrentHp =
typeof currentHp === "number" &&
Number.isInteger(currentHp) &&
currentHp >= 0 &&
currentHp <= maxHp;
return {
...base,
ac: validAc,
conditions,
isConcentrating,
creatureId: validCreatureId,
maxHp,
currentHp: validCurrentHp ? currentHp : maxHp,
};
}
return {
...base,
ac: validAc,
conditions,
isConcentrating,
creatureId: validCreatureId,
};
});
const rehydrated = combatants.map(rehydrateCombatant);
const result = createEncounter(
rehydrated,

View File

@@ -0,0 +1,72 @@
import type { PlayerCharacter } from "@initiative/domain";
import {
playerCharacterId,
VALID_PLAYER_COLORS,
VALID_PLAYER_ICONS,
} from "@initiative/domain";
const STORAGE_KEY = "initiative:player-characters";
export function savePlayerCharacters(characters: PlayerCharacter[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(characters));
} catch {
// Silently swallow errors (quota exceeded, storage unavailable)
}
}
function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
return null;
const entry = raw as Record<string, unknown>;
if (typeof entry.id !== "string" || entry.id.length === 0) return null;
if (typeof entry.name !== "string" || entry.name.trim().length === 0)
return null;
if (
typeof entry.ac !== "number" ||
!Number.isInteger(entry.ac) ||
entry.ac < 0
)
return null;
if (
typeof entry.maxHp !== "number" ||
!Number.isInteger(entry.maxHp) ||
entry.maxHp < 1
)
return null;
if (typeof entry.color !== "string" || !VALID_PLAYER_COLORS.has(entry.color))
return null;
if (typeof entry.icon !== "string" || !VALID_PLAYER_ICONS.has(entry.icon))
return null;
return {
id: playerCharacterId(entry.id),
name: entry.name,
ac: entry.ac,
maxHp: entry.maxHp,
color: entry.color as PlayerCharacter["color"],
icon: entry.icon as PlayerCharacter["icon"],
};
}
export function loadPlayerCharacters(): PlayerCharacter[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) return [];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
const characters: PlayerCharacter[] = [];
for (const item of parsed) {
const pc = rehydrateCharacter(item);
if (pc !== null) {
characters.push(pc);
}
}
return characters;
} catch {
return [];
}
}

View File

@@ -6,7 +6,9 @@
"!**/dist/**",
"!.claude/**",
"!.specify/**",
"!specs/**"
"!specs/**",
"!coverage/**",
"!.pnpm-store/**"
]
},
"assist": {
@@ -27,7 +29,15 @@
"linter": {
"enabled": true,
"rules": {
"recommended": true
"recommended": true,
"complexity": {
"noExcessiveCognitiveComplexity": {
"level": "error",
"options": {
"maxAllowedComplexity": 15
}
}
}
}
}
}

36540
data/bestiary/index.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

4
docs/agents/.gitkeep Normal file
View File

@@ -0,0 +1,4 @@
# Agent Artifacts
Research reports and implementation plans generated by RPI skills.

View File

View File

9
nginx.conf Normal file
View File

@@ -0,0 +1,9 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -3,6 +3,7 @@
"packageManager": "pnpm@10.6.0",
"devDependencies": {
"@biomejs/biome": "2.0.0",
"@vitest/coverage-v8": "^3.2.4",
"jscpd": "^4.0.8",
"knip": "^5.85.0",
"lefthook": "^1.11.0",
@@ -20,6 +21,6 @@
"test:watch": "vitest",
"knip": "knip",
"jscpd": "jscpd",
"check": "knip && biome check . && tsc --build && vitest run && jscpd"
"check": "pnpm audit --audit-level=high && knip && biome check . && tsc --build && vitest run && jscpd"
}
}

View File

@@ -0,0 +1,36 @@
import {
createPlayerCharacter,
type DomainError,
type DomainEvent,
isDomainError,
type PlayerCharacterId,
} from "@initiative/domain";
import type { PlayerCharacterStore } from "./ports.js";
export function createPlayerCharacterUseCase(
store: PlayerCharacterStore,
id: PlayerCharacterId,
name: string,
ac: number,
maxHp: number,
color: string,
icon: string,
): DomainEvent[] | DomainError {
const characters = store.getAll();
const result = createPlayerCharacter(
characters,
id,
name,
ac,
maxHp,
color,
icon,
);
if (isDomainError(result)) {
return result;
}
store.save([...result.characters]);
return result.events;
}

View File

@@ -0,0 +1,23 @@
import {
type DomainError,
type DomainEvent,
deletePlayerCharacter,
isDomainError,
type PlayerCharacterId,
} from "@initiative/domain";
import type { PlayerCharacterStore } from "./ports.js";
export function deletePlayerCharacterUseCase(
store: PlayerCharacterStore,
id: PlayerCharacterId,
): DomainEvent[] | DomainError {
const characters = store.getAll();
const result = deletePlayerCharacter(characters, id);
if (isDomainError(result)) {
return result;
}
store.save([...result.characters]);
return result.events;
}

View File

@@ -0,0 +1,32 @@
import {
type DomainError,
type DomainEvent,
editPlayerCharacter,
isDomainError,
type PlayerCharacterId,
} from "@initiative/domain";
import type { PlayerCharacterStore } from "./ports.js";
interface EditFields {
readonly name?: string;
readonly ac?: number;
readonly maxHp?: number;
readonly color?: string;
readonly icon?: string;
}
export function editPlayerCharacterUseCase(
store: PlayerCharacterStore,
id: PlayerCharacterId,
fields: EditFields,
): DomainEvent[] | DomainError {
const characters = store.getAll();
const result = editPlayerCharacter(characters, id, fields);
if (isDomainError(result)) {
return result;
}
store.save([...result.characters]);
return result.events;
}

View File

@@ -2,8 +2,15 @@ export { addCombatantUseCase } from "./add-combatant-use-case.js";
export { adjustHpUseCase } from "./adjust-hp-use-case.js";
export { advanceTurnUseCase } from "./advance-turn-use-case.js";
export { clearEncounterUseCase } from "./clear-encounter-use-case.js";
export { createPlayerCharacterUseCase } from "./create-player-character-use-case.js";
export { deletePlayerCharacterUseCase } from "./delete-player-character-use-case.js";
export { editCombatantUseCase } from "./edit-combatant-use-case.js";
export type { EncounterStore } from "./ports.js";
export { editPlayerCharacterUseCase } from "./edit-player-character-use-case.js";
export type {
BestiarySourceCache,
EncounterStore,
PlayerCharacterStore,
} from "./ports.js";
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
export { rollAllInitiativeUseCase } from "./roll-all-initiative-use-case.js";

View File

@@ -1,6 +1,21 @@
import type { Encounter } from "@initiative/domain";
import type {
Creature,
CreatureId,
Encounter,
PlayerCharacter,
} from "@initiative/domain";
export interface EncounterStore {
get(): Encounter;
save(encounter: Encounter): void;
}
export interface BestiarySourceCache {
getCreature(creatureId: CreatureId): Creature | undefined;
isSourceCached(sourceCode: string): boolean;
}
export interface PlayerCharacterStore {
getAll(): PlayerCharacter[];
save(characters: PlayerCharacter[]): void;
}

View File

@@ -169,9 +169,9 @@ describe("advanceTurn", () => {
});
describe("invariants", () => {
it("INV-1: createEncounter rejects empty combatant list", () => {
it("INV-1: createEncounter accepts empty combatant list", () => {
const result = createEncounter([]);
expect(isDomainError(result)).toBe(true);
expect(isDomainError(result)).toBe(false);
});
it("INV-2: activeIndex always in bounds across all scenarios", () => {

View File

@@ -0,0 +1,227 @@
import { describe, expect, it } from "vitest";
import { createPlayerCharacter } from "../create-player-character.js";
import type { PlayerCharacter } from "../player-character-types.js";
import { playerCharacterId } from "../player-character-types.js";
import { isDomainError } from "../types.js";
const id = playerCharacterId("pc-1");
function success(
characters: readonly PlayerCharacter[],
name: string,
ac: number,
maxHp: number,
color = "blue",
icon = "sword",
) {
const result = createPlayerCharacter(
characters,
id,
name,
ac,
maxHp,
color,
icon,
);
if (isDomainError(result)) {
throw new Error(`Expected success, got error: ${result.message}`);
}
return result;
}
describe("createPlayerCharacter", () => {
it("creates a valid player character", () => {
const { characters, events } = success(
[],
"Aragorn",
16,
120,
"green",
"shield",
);
expect(characters).toHaveLength(1);
expect(characters[0]).toEqual({
id,
name: "Aragorn",
ac: 16,
maxHp: 120,
color: "green",
icon: "shield",
});
expect(events).toEqual([
{
type: "PlayerCharacterCreated",
playerCharacterId: id,
name: "Aragorn",
},
]);
});
it("trims whitespace from name", () => {
const { characters } = success([], " Gandalf ", 12, 80);
expect(characters[0].name).toBe("Gandalf");
});
it("appends to existing characters", () => {
const existing: PlayerCharacter = {
id: playerCharacterId("pc-0"),
name: "Legolas",
ac: 14,
maxHp: 90,
color: "green",
icon: "eye",
};
const { characters } = success([existing], "Gimli", 18, 100, "red", "axe");
expect(characters).toHaveLength(2);
expect(characters[0]).toEqual(existing);
expect(characters[1].name).toBe("Gimli");
});
it("rejects empty name", () => {
const result = createPlayerCharacter([], id, "", 10, 50, "blue", "sword");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
});
it("rejects whitespace-only name", () => {
const result = createPlayerCharacter(
[],
id,
" ",
10,
50,
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
});
it("rejects negative AC", () => {
const result = createPlayerCharacter(
[],
id,
"Test",
-1,
50,
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
});
it("rejects non-integer AC", () => {
const result = createPlayerCharacter(
[],
id,
"Test",
10.5,
50,
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
});
it("allows AC of 0", () => {
const { characters } = success([], "Test", 0, 50);
expect(characters[0].ac).toBe(0);
});
it("rejects maxHp of 0", () => {
const result = createPlayerCharacter(
[],
id,
"Test",
10,
0,
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
});
it("rejects negative maxHp", () => {
const result = createPlayerCharacter(
[],
id,
"Test",
10,
-5,
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
});
it("rejects non-integer maxHp", () => {
const result = createPlayerCharacter(
[],
id,
"Test",
10,
50.5,
"blue",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
});
it("rejects invalid color", () => {
const result = createPlayerCharacter(
[],
id,
"Test",
10,
50,
"neon",
"sword",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-color");
}
});
it("rejects invalid icon", () => {
const result = createPlayerCharacter(
[],
id,
"Test",
10,
50,
"blue",
"banana",
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-icon");
}
});
it("emits exactly one event on success", () => {
const { events } = success([], "Test", 10, 50);
expect(events).toHaveLength(1);
expect(events[0].type).toBe("PlayerCharacterCreated");
});
});

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { deletePlayerCharacter } from "../delete-player-character.js";
import type { PlayerCharacter } from "../player-character-types.js";
import { playerCharacterId } from "../player-character-types.js";
import { isDomainError } from "../types.js";
const id1 = playerCharacterId("pc-1");
const id2 = playerCharacterId("pc-2");
function makePC(overrides?: Partial<PlayerCharacter>): PlayerCharacter {
return {
id: id1,
name: "Aragorn",
ac: 16,
maxHp: 120,
color: "green",
icon: "sword",
...overrides,
};
}
describe("deletePlayerCharacter", () => {
it("deletes an existing character", () => {
const result = deletePlayerCharacter([makePC()], id1);
if (isDomainError(result)) throw new Error(result.message);
expect(result.characters).toHaveLength(0);
});
it("returns error for not-found id", () => {
const result = deletePlayerCharacter([makePC()], id2);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("player-character-not-found");
}
});
it("emits PlayerCharacterDeleted event", () => {
const result = deletePlayerCharacter([makePC()], id1);
if (isDomainError(result)) throw new Error(result.message);
expect(result.events).toHaveLength(1);
expect(result.events[0].type).toBe("PlayerCharacterDeleted");
});
it("preserves other characters when deleting one", () => {
const pc1 = makePC({ id: id1, name: "Aragorn" });
const pc2 = makePC({ id: id2, name: "Legolas" });
const result = deletePlayerCharacter([pc1, pc2], id1);
if (isDomainError(result)) throw new Error(result.message);
expect(result.characters).toHaveLength(1);
expect(result.characters[0].name).toBe("Legolas");
});
it("event includes deleted character name", () => {
const result = deletePlayerCharacter([makePC()], id1);
if (isDomainError(result)) throw new Error(result.message);
const event = result.events[0];
if (event.type !== "PlayerCharacterDeleted") throw new Error("wrong event");
expect(event.name).toBe("Aragorn");
});
});

View File

@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { editPlayerCharacter } from "../edit-player-character.js";
import type { PlayerCharacter } from "../player-character-types.js";
import { playerCharacterId } from "../player-character-types.js";
import { isDomainError } from "../types.js";
const id = playerCharacterId("pc-1");
function makePC(overrides?: Partial<PlayerCharacter>): PlayerCharacter {
return {
id,
name: "Aragorn",
ac: 16,
maxHp: 120,
color: "green",
icon: "sword",
...overrides,
};
}
describe("editPlayerCharacter", () => {
it("edits name successfully", () => {
const result = editPlayerCharacter([makePC()], id, { name: "Strider" });
if (isDomainError(result)) throw new Error(result.message);
expect(result.characters[0].name).toBe("Strider");
expect(result.events[0].type).toBe("PlayerCharacterUpdated");
});
it("edits multiple fields", () => {
const result = editPlayerCharacter([makePC()], id, {
name: "Strider",
ac: 18,
});
if (isDomainError(result)) throw new Error(result.message);
expect(result.characters[0].name).toBe("Strider");
expect(result.characters[0].ac).toBe(18);
});
it("returns error for not-found id", () => {
const result = editPlayerCharacter(
[makePC()],
playerCharacterId("pc-999"),
{ name: "Nope" },
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("player-character-not-found");
}
});
it("rejects empty name", () => {
const result = editPlayerCharacter([makePC()], id, { name: "" });
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
});
it("rejects invalid AC", () => {
const result = editPlayerCharacter([makePC()], id, { ac: -1 });
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
});
it("rejects invalid maxHp", () => {
const result = editPlayerCharacter([makePC()], id, { maxHp: 0 });
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
});
it("rejects invalid color", () => {
const result = editPlayerCharacter([makePC()], id, { color: "neon" });
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-color");
}
});
it("rejects invalid icon", () => {
const result = editPlayerCharacter([makePC()], id, { icon: "banana" });
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-icon");
}
});
it("returns error when no fields changed", () => {
const pc = makePC();
const result = editPlayerCharacter([pc], id, {
name: pc.name,
ac: pc.ac,
});
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("no-changes");
}
});
it("emits exactly one event on success", () => {
const result = editPlayerCharacter([makePC()], id, { name: "Strider" });
if (isDomainError(result)) throw new Error(result.message);
expect(result.events).toHaveLength(1);
});
it("event includes old and new name", () => {
const result = editPlayerCharacter([makePC()], id, { name: "Strider" });
if (isDomainError(result)) throw new Error(result.message);
const event = result.events[0];
if (event.type !== "PlayerCharacterUpdated") throw new Error("wrong event");
expect(event.oldName).toBe("Aragorn");
expect(event.newName).toBe("Strider");
});
});

View File

@@ -0,0 +1,87 @@
import type { DomainEvent } from "./events.js";
import type {
PlayerCharacter,
PlayerCharacterId,
} from "./player-character-types.js";
import {
VALID_PLAYER_COLORS,
VALID_PLAYER_ICONS,
} from "./player-character-types.js";
import type { DomainError } from "./types.js";
export interface CreatePlayerCharacterSuccess {
readonly characters: readonly PlayerCharacter[];
readonly events: DomainEvent[];
}
export function createPlayerCharacter(
characters: readonly PlayerCharacter[],
id: PlayerCharacterId,
name: string,
ac: number,
maxHp: number,
color: string,
icon: string,
): CreatePlayerCharacterSuccess | DomainError {
const trimmed = name.trim();
if (trimmed === "") {
return {
kind: "domain-error",
code: "invalid-name",
message: "Player character name must not be empty",
};
}
if (!Number.isInteger(ac) || ac < 0) {
return {
kind: "domain-error",
code: "invalid-ac",
message: "AC must be a non-negative integer",
};
}
if (!Number.isInteger(maxHp) || maxHp < 1) {
return {
kind: "domain-error",
code: "invalid-max-hp",
message: "Max HP must be a positive integer",
};
}
if (!VALID_PLAYER_COLORS.has(color)) {
return {
kind: "domain-error",
code: "invalid-color",
message: `Invalid color: ${color}`,
};
}
if (!VALID_PLAYER_ICONS.has(icon)) {
return {
kind: "domain-error",
code: "invalid-icon",
message: `Invalid icon: ${icon}`,
};
}
const newCharacter: PlayerCharacter = {
id,
name: trimmed,
ac,
maxHp,
color: color as PlayerCharacter["color"],
icon: icon as PlayerCharacter["icon"],
};
return {
characters: [...characters, newCharacter],
events: [
{
type: "PlayerCharacterCreated",
playerCharacterId: id,
name: trimmed,
},
],
};
}

View File

@@ -75,6 +75,23 @@ export interface Creature {
readonly spellcasting?: readonly SpellcastingBlock[];
}
export interface BestiaryIndexEntry {
readonly name: string;
readonly source: string;
readonly ac: number;
readonly hp: number;
readonly dex: number;
readonly cr: string;
readonly initiativeProficiency: number;
readonly size: string;
readonly type: string;
}
export interface BestiaryIndex {
readonly sources: Readonly<Record<string, string>>;
readonly creatures: readonly BestiaryIndexEntry[];
}
/** Maps a CR string to the corresponding proficiency bonus. */
export function proficiencyBonus(cr: string): number {
const numericCr = cr.includes("/")

View File

@@ -0,0 +1,39 @@
import type { DomainEvent } from "./events.js";
import type {
PlayerCharacter,
PlayerCharacterId,
} from "./player-character-types.js";
import type { DomainError } from "./types.js";
export interface DeletePlayerCharacterSuccess {
readonly characters: readonly PlayerCharacter[];
readonly events: DomainEvent[];
}
export function deletePlayerCharacter(
characters: readonly PlayerCharacter[],
id: PlayerCharacterId,
): DeletePlayerCharacterSuccess | DomainError {
const index = characters.findIndex((c) => c.id === id);
if (index === -1) {
return {
kind: "domain-error",
code: "player-character-not-found",
message: `Player character not found: ${id}`,
};
}
const removed = characters[index];
const newList = characters.filter((_, i) => i !== index);
return {
characters: newList,
events: [
{
type: "PlayerCharacterDeleted",
playerCharacterId: id,
name: removed.name,
},
],
};
}

View File

@@ -0,0 +1,137 @@
import type { DomainEvent } from "./events.js";
import type {
PlayerCharacter,
PlayerCharacterId,
} from "./player-character-types.js";
import {
VALID_PLAYER_COLORS,
VALID_PLAYER_ICONS,
} from "./player-character-types.js";
import type { DomainError } from "./types.js";
export interface EditPlayerCharacterSuccess {
readonly characters: readonly PlayerCharacter[];
readonly events: DomainEvent[];
}
interface EditFields {
readonly name?: string;
readonly ac?: number;
readonly maxHp?: number;
readonly color?: string;
readonly icon?: string;
}
function validateFields(fields: EditFields): DomainError | null {
if (fields.name !== undefined && fields.name.trim() === "") {
return {
kind: "domain-error",
code: "invalid-name",
message: "Player character name must not be empty",
};
}
if (
fields.ac !== undefined &&
(!Number.isInteger(fields.ac) || fields.ac < 0)
) {
return {
kind: "domain-error",
code: "invalid-ac",
message: "AC must be a non-negative integer",
};
}
if (
fields.maxHp !== undefined &&
(!Number.isInteger(fields.maxHp) || fields.maxHp < 1)
) {
return {
kind: "domain-error",
code: "invalid-max-hp",
message: "Max HP must be a positive integer",
};
}
if (fields.color !== undefined && !VALID_PLAYER_COLORS.has(fields.color)) {
return {
kind: "domain-error",
code: "invalid-color",
message: `Invalid color: ${fields.color}`,
};
}
if (fields.icon !== undefined && !VALID_PLAYER_ICONS.has(fields.icon)) {
return {
kind: "domain-error",
code: "invalid-icon",
message: `Invalid icon: ${fields.icon}`,
};
}
return null;
}
function applyFields(
existing: PlayerCharacter,
fields: EditFields,
): PlayerCharacter {
return {
id: existing.id,
name: fields.name !== undefined ? fields.name.trim() : existing.name,
ac: fields.ac !== undefined ? fields.ac : existing.ac,
maxHp: fields.maxHp !== undefined ? fields.maxHp : existing.maxHp,
color:
fields.color !== undefined
? (fields.color as PlayerCharacter["color"])
: existing.color,
icon:
fields.icon !== undefined
? (fields.icon as PlayerCharacter["icon"])
: existing.icon,
};
}
export function editPlayerCharacter(
characters: readonly PlayerCharacter[],
id: PlayerCharacterId,
fields: EditFields,
): EditPlayerCharacterSuccess | DomainError {
const index = characters.findIndex((c) => c.id === id);
if (index === -1) {
return {
kind: "domain-error",
code: "player-character-not-found",
message: `Player character not found: ${id}`,
};
}
const validationError = validateFields(fields);
if (validationError) return validationError;
const existing = characters[index];
const updated = applyFields(existing, fields);
if (
updated.name === existing.name &&
updated.ac === existing.ac &&
updated.maxHp === existing.maxHp &&
updated.color === existing.color &&
updated.icon === existing.icon
) {
return {
kind: "domain-error",
code: "no-changes",
message: "No fields changed",
};
}
const newList = characters.map((c, i) => (i === index ? updated : c));
return {
characters: newList,
events: [
{
type: "PlayerCharacterUpdated",
playerCharacterId: id,
oldName: existing.name,
newName: updated.name,
},
],
};
}

View File

@@ -1,4 +1,5 @@
import type { ConditionId } from "./conditions.js";
import type { PlayerCharacterId } from "./player-character-types.js";
import type { CombatantId } from "./types.js";
export interface TurnAdvanced {
@@ -103,6 +104,25 @@ export interface EncounterCleared {
readonly combatantCount: number;
}
export interface PlayerCharacterCreated {
readonly type: "PlayerCharacterCreated";
readonly playerCharacterId: PlayerCharacterId;
readonly name: string;
}
export interface PlayerCharacterUpdated {
readonly type: "PlayerCharacterUpdated";
readonly playerCharacterId: PlayerCharacterId;
readonly oldName: string;
readonly newName: string;
}
export interface PlayerCharacterDeleted {
readonly type: "PlayerCharacterDeleted";
readonly playerCharacterId: PlayerCharacterId;
readonly name: string;
}
export type DomainEvent =
| TurnAdvanced
| RoundAdvanced
@@ -119,4 +139,7 @@ export type DomainEvent =
| ConditionRemoved
| ConcentrationStarted
| ConcentrationEnded
| EncounterCleared;
| EncounterCleared
| PlayerCharacterCreated
| PlayerCharacterUpdated
| PlayerCharacterDeleted;

View File

@@ -13,6 +13,12 @@ export {
VALID_CONDITION_IDS,
} from "./conditions.js";
export {
type CreatePlayerCharacterSuccess,
createPlayerCharacter,
} from "./create-player-character.js";
export {
type BestiaryIndex,
type BestiaryIndexEntry,
type BestiarySource,
type Creature,
type CreatureId,
@@ -23,10 +29,18 @@ export {
type SpellcastingBlock,
type TraitBlock,
} from "./creature-types.js";
export {
type DeletePlayerCharacterSuccess,
deletePlayerCharacter,
} from "./delete-player-character.js";
export {
type EditCombatantSuccess,
editCombatant,
} from "./edit-combatant.js";
export {
type EditPlayerCharacterSuccess,
editPlayerCharacter,
} from "./edit-player-character.js";
export type {
AcSet,
CombatantAdded,
@@ -41,6 +55,9 @@ export type {
EncounterCleared,
InitiativeSet,
MaxHpSet,
PlayerCharacterCreated,
PlayerCharacterDeleted,
PlayerCharacterUpdated,
RoundAdvanced,
RoundRetreated,
TurnAdvanced,
@@ -52,6 +69,16 @@ export {
formatInitiativeModifier,
type InitiativeResult,
} from "./initiative.js";
export {
type PlayerCharacter,
type PlayerCharacterId,
type PlayerCharacterList,
type PlayerColor,
type PlayerIcon,
playerCharacterId,
VALID_PLAYER_COLORS,
VALID_PLAYER_ICONS,
} from "./player-character-types.js";
export {
type RemoveCombatantSuccess,
removeCombatant,

View File

@@ -0,0 +1,81 @@
/** Branded string type for player character identity. */
export type PlayerCharacterId = string & {
readonly __brand: "PlayerCharacterId";
};
export function playerCharacterId(id: string): PlayerCharacterId {
return id as PlayerCharacterId;
}
export type PlayerColor =
| "red"
| "blue"
| "green"
| "purple"
| "orange"
| "pink"
| "cyan"
| "yellow"
| "emerald"
| "indigo";
export const VALID_PLAYER_COLORS: ReadonlySet<string> = new Set<PlayerColor>([
"red",
"blue",
"green",
"purple",
"orange",
"pink",
"cyan",
"yellow",
"emerald",
"indigo",
]);
export type PlayerIcon =
| "sword"
| "shield"
| "skull"
| "heart"
| "wand"
| "flame"
| "crown"
| "star"
| "moon"
| "sun"
| "axe"
| "crosshair"
| "eye"
| "feather"
| "zap";
export const VALID_PLAYER_ICONS: ReadonlySet<string> = new Set<PlayerIcon>([
"sword",
"shield",
"skull",
"heart",
"wand",
"flame",
"crown",
"star",
"moon",
"sun",
"axe",
"crosshair",
"eye",
"feather",
"zap",
]);
export interface PlayerCharacter {
readonly id: PlayerCharacterId;
readonly name: string;
readonly ac: number;
readonly maxHp: number;
readonly color: PlayerColor;
readonly icon: PlayerIcon;
}
export interface PlayerCharacterList {
readonly characters: readonly PlayerCharacter[];
}

View File

@@ -62,8 +62,9 @@ export function setInitiative(
const bHas = b.c.initiative !== undefined;
if (aHas && bHas) {
// biome-ignore lint: both checked above
const diff = b.c.initiative! - a.c.initiative!;
const aInit = a.c.initiative as number;
const bInit = b.c.initiative as number;
const diff = bInit - aInit;
return diff !== 0 ? diff : a.i - b.i;
}
if (aHas && !bHas) return -1;

View File

@@ -7,6 +7,7 @@ export function combatantId(id: string): CombatantId {
import type { ConditionId } from "./conditions.js";
import type { CreatureId } from "./creature-types.js";
import type { PlayerCharacterId } from "./player-character-types.js";
export interface Combatant {
readonly id: CombatantId;
@@ -18,6 +19,9 @@ export interface Combatant {
readonly conditions?: readonly ConditionId[];
readonly isConcentrating?: boolean;
readonly creatureId?: CreatureId;
readonly color?: string;
readonly icon?: string;
readonly playerCharacterId?: PlayerCharacterId;
}
export interface Encounter {
@@ -38,8 +42,8 @@ function domainError(code: string, message: string): DomainError {
/**
* Creates a valid Encounter, enforcing INV-1, INV-2, INV-3.
* - INV-1: At least one combatant required.
* - INV-2: activeIndex defaults to 0 (always in bounds).
* - INV-1: An encounter MAY have zero combatants.
* - INV-2: activeIndex defaults to 0 (always in bounds when combatants exist).
* - INV-3: roundNumber defaults to 1 (positive integer).
*/
export function createEncounter(
@@ -47,13 +51,10 @@ export function createEncounter(
activeIndex = 0,
roundNumber = 1,
): Encounter | DomainError {
if (combatants.length === 0) {
return domainError(
"invalid-encounter",
"An encounter must have at least one combatant",
);
}
if (activeIndex < 0 || activeIndex >= combatants.length) {
if (
combatants.length > 0 &&
(activeIndex < 0 || activeIndex >= combatants.length)
) {
return domainError(
"invalid-encounter",
`activeIndex ${activeIndex} out of bounds for ${combatants.length} combatants`,

897
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -47,29 +47,25 @@ function matchesForbidden(importPath, forbidden) {
return importPath === forbidden || importPath.startsWith(`${forbidden}/`);
}
/** @returns {{ file: string, line: number, importPath: string, forbidden: string }[]} */
export function checkLayerBoundaries() {
const IMPORT_RE =
/(?:import|from)\s+["']([^"']+)["']|require\s*\(\s*["']([^"']+)["']\s*\)/;
/**
* Check a single file for forbidden imports.
* @param {string} file
* @param {string[]} forbidden
* @returns {{ file: string, line: number, importPath: string, forbidden: string }[]}
*/
function checkFile(file, forbidden) {
/** @type {{ file: string, line: number, importPath: string, forbidden: string }[]} */
const violations = [];
for (const [srcDir, forbidden] of Object.entries(FORBIDDEN)) {
const absDir = join(ROOT, srcDir);
let files;
try {
files = collectTsFiles(absDir);
} catch {
continue;
}
for (const file of files) {
const content = readFileSync(file, "utf-8");
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const match = line.match(
/(?:import|from)\s+["']([^"']+)["']|require\s*\(\s*["']([^"']+)["']\s*\)/,
);
const match = lines[i].match(IMPORT_RE);
if (!match) continue;
const importPath = match[1] || match[2];
for (const f of forbidden) {
if (matchesForbidden(importPath, f)) {
@@ -82,12 +78,34 @@ export function checkLayerBoundaries() {
}
}
}
}
}
return violations;
}
/**
* Check all files in a layer directory for forbidden imports.
* @param {string} srcDir
* @param {string[]} forbidden
* @returns {{ file: string, line: number, importPath: string, forbidden: string }[]}
*/
function checkLayer(srcDir, forbidden) {
const absDir = join(ROOT, srcDir);
let files;
try {
files = collectTsFiles(absDir);
} catch {
return [];
}
return files.flatMap((file) => checkFile(file, forbidden));
}
/** @returns {{ file: string, line: number, importPath: string, forbidden: string }[]} */
export function checkLayerBoundaries() {
return Object.entries(FORBIDDEN).flatMap(([srcDir, forbidden]) =>
checkLayer(srcDir, forbidden),
);
}
// Run as CLI if invoked directly
if (
process.argv[1] &&

View File

@@ -0,0 +1,166 @@
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
// Usage: node scripts/generate-bestiary-index.mjs <path-to-5etools-src>
//
// Requires a local clone/checkout of https://github.com/5etools-mirror-3/5etools-src
// with at least data/bestiary/, data/books.json, and data/adventures.json.
//
// Example:
// git clone --depth 1 --sparse https://github.com/5etools-mirror-3/5etools-src.git /tmp/5etools
// cd /tmp/5etools && git sparse-checkout set data/bestiary data
// node scripts/generate-bestiary-index.mjs /tmp/5etools
const TOOLS_ROOT = process.argv[2];
if (!TOOLS_ROOT) {
console.error(
"Usage: node scripts/generate-bestiary-index.mjs <5etools-src-path>",
);
process.exit(1);
}
const PROJECT_ROOT = join(import.meta.dirname, "..");
const BESTIARY_DIR = join(TOOLS_ROOT, "data/bestiary");
const BOOKS_PATH = join(TOOLS_ROOT, "data/books.json");
const ADVENTURES_PATH = join(TOOLS_ROOT, "data/adventures.json");
const OUTPUT_PATH = join(PROJECT_ROOT, "data/bestiary/index.json");
// --- Build source display name map from books.json + adventures.json ---
/** Populate map from a list of entries that have source/id + name. */
function addEntriesToMap(map, entries) {
for (const entry of entries) {
if (!entry.name) continue;
if (entry.source) {
map[entry.source] = entry.name;
}
// Some entries use "id" instead of "source"
if (entry.id && !map[entry.id]) {
map[entry.id] = entry.name;
}
}
}
function buildSourceMap() {
const map = {};
const books = JSON.parse(readFileSync(BOOKS_PATH, "utf-8"));
addEntriesToMap(map, books.book ?? []);
const adventures = JSON.parse(readFileSync(ADVENTURES_PATH, "utf-8"));
addEntriesToMap(map, adventures.adventure ?? []);
// Manual additions for sources missing from books.json / adventures.json
const manual = {
ESK: "Essentials Kit",
MCV1SC: "Monstrous Compendium Volume 1: Spelljammer Creatures",
MCV2DC: "Monstrous Compendium Volume 2: Dragonlance Creatures",
MCV3MC: "Monstrous Compendium Volume 3: Minecraft Creatures",
MCV4EC: "Monstrous Compendium Volume 4: Eldraine Creatures",
MFF: "Mordenkainen's Fiendish Folio",
MisMV1: "Misplaced Monsters: Volume 1",
SADS: "Sapphire Anniversary Dice Set",
TftYP: "Tales from the Yawning Portal",
VD: "Vecna Dossier",
};
for (const [k, v] of Object.entries(manual)) {
if (!map[k]) map[k] = v;
}
return map;
}
// --- Extract type string from raw type field ---
function extractType(type) {
if (typeof type === "string") return type;
if (typeof type?.type === "string") return type.type;
if (typeof type?.type === "object" && Array.isArray(type.type.choose)) {
return type.type.choose.join("/");
}
return "unknown";
}
// --- Extract AC from raw ac field ---
function extractAc(ac) {
if (!Array.isArray(ac) || ac.length === 0) return 0;
const first = ac[0];
if (typeof first === "number") return first;
if (typeof first === "object" && typeof first.ac === "number")
return first.ac;
return 0;
}
// --- Extract CR from raw cr field ---
function extractCr(cr) {
if (typeof cr === "string") return cr;
if (typeof cr === "object" && typeof cr.cr === "string") return cr.cr;
return "0";
}
// --- Main ---
const sourceMap = buildSourceMap();
const files = readdirSync(BESTIARY_DIR).filter(
(f) => f.startsWith("bestiary-") && f.endsWith(".json"),
);
const creatures = [];
const unmappedSources = new Set();
for (const file of files.sort()) {
const raw = JSON.parse(readFileSync(join(BESTIARY_DIR, file), "utf-8"));
const monsters = raw.monster ?? [];
for (const m of monsters) {
// Skip creatures that are copies/references (no actual stats)
if (m._copy || m.hp == null || m.ac == null) continue;
const source = m.source ?? "";
if (source && !sourceMap[source]) {
unmappedSources.add(source);
}
creatures.push({
n: m.name,
s: source,
ac: extractAc(m.ac),
hp: m.hp.average ?? 0,
dx: m.dex ?? 10,
cr: extractCr(m.cr),
ip: m.initiative?.proficiency ?? 0,
sz: Array.isArray(m.size) ? m.size[0] : (m.size ?? "M"),
tp: extractType(m.type),
});
}
}
// Sort by name then source for stable output
creatures.sort((a, b) => a.n.localeCompare(b.n) || a.s.localeCompare(b.s));
// Filter sourceMap to only include sources that appear in the index
const usedSources = new Set(creatures.map((c) => c.s));
const filteredSourceMap = {};
for (const [key, value] of Object.entries(sourceMap)) {
if (usedSources.has(key)) {
filteredSourceMap[key] = value;
}
}
const output = {
sources: filteredSourceMap,
creatures,
};
writeFileSync(OUTPUT_PATH, JSON.stringify(output));
// Stats
const rawSize = Buffer.byteLength(JSON.stringify(output));
console.log(`Sources: ${Object.keys(filteredSourceMap).length}`);
console.log(`Creatures: ${creatures.length}`);
console.log(`Output size: ${(rawSize / 1024).toFixed(1)} KB`);
if (unmappedSources.size > 0) {
console.log(`Unmapped sources: ${[...unmappedSources].sort().join(", ")}`);
}

View File

@@ -1,349 +0,0 @@
# Implementation Plan: Advance Turn
**Branch**: `001-advance-turn` | **Date**: 2026-03-03 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/001-advance-turn/spec.md`
## Summary
Implement the AdvanceTurn domain operation as a pure function that
transitions an Encounter to the next combatant, wrapping rounds and
emitting TurnAdvanced / RoundAdvanced domain events. Stand up the
pnpm monorepo skeleton, Biome tooling, and Vitest test harness so
that all constitution merge-gate requirements are satisfied from the
first commit.
## Technical Context
**Node**: 22 LTS (pinned via `.nvmrc`)
**Language/Version**: TypeScript 5.8 (strict mode)
**Primary Dependencies**: React 19 (pin to major; minor upgrades
allowed), Vite 6.2
**Storage**: In-memory only (MVP baseline)
**Testing**: Vitest 3.0
**Lint/Format**: Biome 2.0.0 (exact version, single tool — no
Prettier, no ESLint)
**Package Manager**: pnpm 10.6 (pinned via `packageManager` field
in root `package.json`)
**Target Platform**: Static web app (modern browsers)
**Project Type**: Monorepo — library packages + web app
**Performance Goals**: N/A (walking skeleton)
**Constraints**: Domain package must have zero React/Vite imports
**Scale/Scope**: Single feature, ~5 source files, ~1 test file
## Constitution Check
| Principle | Status | Notes |
|-----------|--------|-------|
| I. Deterministic Domain Core | PASS | `advanceTurn` is a pure function; no I/O, randomness, or clocks |
| II. Layered Architecture | PASS | `packages/domain``packages/application``apps/web`; strict dependency direction enforced by automated import check |
| III. Agent Boundary | N/A | Agent layer out of scope for this feature |
| IV. Clarification-First | PASS | No ambiguous decisions remain; spec fully clarified |
| V. Escalation Gates | PASS | Scope strictly limited to spec; out-of-scope items listed |
| VI. MVP Baseline Language | PASS | No permanent bans; "MVP baseline does not include" used |
| VII. No Gameplay Rules | PASS | No gameplay mechanics in plan or constitution |
| Merge Gate | PASS | `pnpm check` script runs format, lint, typecheck, test |
## Project Structure
### Documentation (this feature)
```text
specs/001-advance-turn/
├── spec.md
└── plan.md
```
### Source Code (repository root)
```text
packages/
├── domain/
│ ├── package.json
│ ├── tsconfig.json
│ └── src/
│ ├── types.ts # CombatantId, Combatant, Encounter
│ ├── events.ts # TurnAdvanced, RoundAdvanced, DomainEvent
│ ├── advance-turn.ts # advanceTurn pure function
│ └── index.ts # public barrel export
├── application/
│ ├── package.json
│ ├── tsconfig.json
│ └── src/
│ ├── ports.ts # EncounterStore port interface
│ ├── advance-turn-use-case.ts
│ └── index.ts
apps/
└── web/
├── package.json
├── tsconfig.json
├── vite.config.ts
├── index.html
└── src/
├── main.tsx
├── App.tsx
└── hooks/
└── use-encounter.ts
# Testing (co-located with domain package)
packages/domain/
└── src/
└── __tests__/
└── advance-turn.test.ts
# Root config
├── .nvmrc # pins Node 22
├── pnpm-workspace.yaml
├── biome.json
├── tsconfig.base.json
└── package.json # packageManager field pins pnpm; root scripts
```
**Structure Decision**: pnpm workspace monorepo with two packages
(`domain`, `application`) and one app (`web`). Domain is
framework-agnostic TypeScript. Application imports domain only.
Web app (React + Vite) imports both.
## Tooling & Merge Gate
### Scripts (root package.json)
```jsonc
{
"scripts": {
"format": "biome format --write .",
"format:check": "biome format .",
"lint": "biome lint .",
"lint:fix": "biome lint --write .",
"typecheck": "tsc --build",
"test": "vitest run",
"test:watch": "vitest",
"check": "biome check . && tsc --build && vitest run"
}
}
```
`pnpm check` is the single merge gate: format + lint + typecheck +
test. The layer boundary check runs as a Vitest test (see below),
so it executes as part of `vitest run` — no separate script needed.
### Layer Boundary Enforcement
Biome does not natively support cross-package import restrictions.
A lightweight `scripts/check-layer-boundaries.mjs` script will:
1. Scan `packages/domain/src/**/*.ts` — assert zero imports from
`@initiative/application`, `apps/`, `react`, `vite`.
2. Scan `packages/application/src/**/*.ts` — assert zero imports
from `apps/`, `react`, `vite`.
3. Exit non-zero on violation with a clear error message.
This script is invoked by a Vitest test
(`packages/domain/src/__tests__/layer-boundaries.test.ts`) so it
runs automatically as part of `vitest run` inside `pnpm check`.
No separate `check:layer` script is needed — the layer boundary
check is guaranteed to execute on every merge-gate run.
### Biome Configuration (biome.json)
```jsonc
{
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"organizeImports": { "enabled": true },
"formatter": {
"enabled": true,
"indentStyle": "tab",
"lineWidth": 80
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
}
}
```
### TypeScript Configuration
- `tsconfig.base.json` at root: strict mode, composite projects,
path aliases (`@initiative/domain`, `@initiative/application`).
- Each package extends `tsconfig.base.json` with its own
`include`/`references`.
- `apps/web/tsconfig.json` references both packages.
## Milestones
### Milestone 1: Tooling & Domain (walking skeleton)
Stand up monorepo, Biome, Vitest, TypeScript project references,
and implement the complete AdvanceTurn domain logic with all tests.
**Exit criteria**: `pnpm check` passes. All 8 acceptance scenarios
green. Layer boundary check green. No React or Vite dependencies in
`packages/domain` or `packages/application`.
### Milestone 2: Application + Minimal Web Shell
Wire up the application use case and a minimal React UI that
displays the encounter state and has a single "Next Turn" button.
**Exit criteria**: `pnpm check` passes. Clicking "Next Turn" in the
browser advances the turn with correct round wrapping. The app
builds with `vite build`.
## Task List
### Phase 1: Setup (Milestone 1)
- [X] **T001** Initialize pnpm workspace and root config
- Create `pnpm-workspace.yaml` listing `packages/*` and `apps/*`
- Create `.nvmrc` pinning Node 22
- Create root `package.json` with `packageManager` field pinning
pnpm 10.6 and scripts (check, test, lint, format, typecheck)
- Create `biome.json` at root
- Create `tsconfig.base.json` (strict, composite, path aliases)
- **Acceptance**: `pnpm install` succeeds; `biome check .` runs
without config errors
- [X] **T002** [P] Create `packages/domain` package skeleton
- `package.json` (name: `@initiative/domain`, no dependencies)
- `tsconfig.json` extending base, composite: true
- Empty `src/index.ts`
- **Acceptance**: `tsc --build packages/domain` succeeds
- [X] **T003** [P] Create `packages/application` package skeleton
- `package.json` (name: `@initiative/application`,
depends on `@initiative/domain`)
- `tsconfig.json` extending base, references domain
- Empty `src/index.ts`
- **Acceptance**: `tsc --build packages/application` succeeds
- [X] **T004** [P] Create `apps/web` package skeleton
- `package.json` with React, Vite, depends on both packages
- `tsconfig.json` referencing both packages
- `vite.config.ts` (minimal)
- `index.html` + `src/main.tsx` + `src/App.tsx` (placeholder)
- **Acceptance**: `pnpm --filter web dev` starts; `vite build`
succeeds
- [X] **T005** Configure Vitest
- Add `vitest` as root dev dependency
- Create `vitest.config.ts` at root (workspace mode) or per
package as needed
- Verify `pnpm test` runs (0 tests, exits clean)
- **Acceptance**: `pnpm test` exits 0
- [X] **T006** Create layer boundary check script
- `scripts/check-layer-boundaries.mjs`: scans domain and
application source for forbidden imports
- `packages/domain/src/__tests__/layer-boundaries.test.ts`:
wraps the script as a Vitest test
- **Acceptance**: test passes on clean skeleton; fails if a
forbidden import is manually added (verify, then remove)
### Phase 2: Domain Implementation (Milestone 1)
- [ ] **T007** Define domain types in `packages/domain/src/types.ts`
- `CombatantId` (branded string or opaque type)
- `Combatant` (carries a CombatantId)
- `Encounter` (combatants array, activeIndex, roundNumber)
- Factory function `createEncounter` that validates INV-1, INV-2,
INV-3
- **Acceptance**: types compile; `createEncounter([])` returns
error; `createEncounter([a])` returns valid Encounter
- [ ] **T008** [P] Define domain events in
`packages/domain/src/events.ts`
- `TurnAdvanced { previousCombatantId, newCombatantId,
roundNumber }`
- `RoundAdvanced { newRoundNumber }`
- `DomainEvent = TurnAdvanced | RoundAdvanced`
- **Acceptance**: types compile; events are plain data (no
classes with methods)
- [ ] **T009** Implement `advanceTurn` in
`packages/domain/src/advance-turn.ts`
- Signature: `(encounter: Encounter) =>
{ encounter: Encounter; events: DomainEvent[] } | DomainError`
- Implements FR-001 through FR-005
- Returns error for empty combatant list (INV-1)
- Emits TurnAdvanced on every call (INV-5)
- Emits TurnAdvanced then RoundAdvanced on wrap (event order
contract)
- **Acceptance**: compiles; satisfies type contract
- [ ] **T010** Write tests for all 8 acceptance scenarios +
invariants in
`packages/domain/src/__tests__/advance-turn.test.ts`
- Scenarios 18 from spec (Given/When/Then)
- INV-1: empty encounter rejected
- INV-2: activeIndex always in bounds (property check across
scenarios)
- INV-3: roundNumber never decreases
- INV-4: determinism — same input produces same output (call
twice, assert deep equal)
- INV-5: every success emits at least TurnAdvanced
- Event ordering: on wrap, events array is
[TurnAdvanced, RoundAdvanced] in that order
- **Acceptance**: `pnpm test` — all tests green; `pnpm check` —
full pipeline green
- [ ] **T011** Export public API from `packages/domain/src/index.ts`
- Re-export types, events, `advanceTurn`, `createEncounter`
- **Acceptance**: consuming packages can
`import { advanceTurn } from "@initiative/domain"`
**Milestone 1 checkpoint**: `pnpm check` passes (format + lint +
typecheck + test + layer boundaries). All 8 scenarios + invariants
green.
### Phase 3: Application + Web Shell (Milestone 2)
- [ ] **T012** Define port interface in
`packages/application/src/ports.ts`
- `EncounterStore` port: `get(): Encounter`, `save(e: Encounter)`
- **Acceptance**: compiles; no imports from adapters or React
- [ ] **T013** Implement `AdvanceTurnUseCase` in
`packages/application/src/advance-turn-use-case.ts`
- Accepts `EncounterStore` port
- Calls `advanceTurn` from domain, saves result, returns events
- **Acceptance**: compiles; imports only from `@initiative/domain`
and local ports
- [ ] **T014** Export public API from
`packages/application/src/index.ts`
- Re-export use case and port types
- **Acceptance**: consuming app can import from
`@initiative/application`
- [ ] **T015** Implement `useEncounter` hook in
`apps/web/src/hooks/use-encounter.ts`
- In-memory implementation of `EncounterStore` port (React state)
- Exposes current encounter state + `advanceTurn` action
- Initializes with a hardcoded 3-combatant encounter for demo
- **Acceptance**: hook compiles; usable in a React component
- [ ] **T016** Wire up `App.tsx`
- Display: current combatant name, round number, combatant list
with active indicator
- Single "Next Turn" button calling the use case
- Display emitted events (optional, for demo clarity)
- **Acceptance**: `vite build` succeeds; clicking "Next Turn"
cycles through combatants and increments rounds correctly
**Milestone 2 checkpoint**: `pnpm check` passes. App runs in
browser. Full round-trip from button click → domain pure function →
UI update verified manually.
## Risks & Open Questions
| # | Item | Severity | Mitigation |
|---|------|----------|------------|
| 1 | pnpm workspace + TypeScript project references can have path resolution quirks with Vite | Low | Use `vite-tsconfig-paths` plugin if needed; test early in T004 |
| 2 | Biome config format may change across versions | Low | Pinned to exact 2.0.0; `$schema` in config validates structure |
| 3 | Layer boundary script is a lightweight grep — not a full architectural fitness function | Low | Sufficient for walking skeleton; can upgrade to a Biome plugin or `dependency-cruiser` later if needed |
## Complexity Tracking
No constitution violations. No complexity justifications needed.

View File

@@ -1,172 +0,0 @@
# Feature Specification: Advance Turn
**Feature Branch**: `001-advance-turn`
**Created**: 2026-03-03
**Status**: Draft
**Input**: Walking-skeleton domain feature — deterministic turn advancement
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Advance Turn (Priority: P1)
A game master running an encounter advances the turn to the next
combatant in initiative order. When the last combatant in the round
finishes, the round number increments and play wraps to the first
combatant.
**Why this priority**: This is the irreducible core of an initiative
tracker. Without turn advancement, no other feature has meaning.
**Independent Test**: Can be fully tested as a pure state transition
with no I/O, persistence, or UI. Given an Encounter value and an
AdvanceTurn action, assert the resulting Encounter value and emitted
domain events.
**Acceptance Scenarios**:
1. **Given** an encounter with combatants [A, B, C], activeIndex 0,
roundNumber 1,
**When** AdvanceTurn,
**Then** activeIndex is 1, roundNumber is 1,
and a TurnAdvanced event is emitted with
previousCombatantId A, newCombatantId B, roundNumber 1.
2. **Given** an encounter with combatants [A, B, C], activeIndex 1,
roundNumber 1,
**When** AdvanceTurn,
**Then** activeIndex is 2, roundNumber is 1,
and a TurnAdvanced event is emitted with
previousCombatantId B, newCombatantId C, roundNumber 1.
3. **Given** an encounter with combatants [A, B, C], activeIndex 2,
roundNumber 1,
**When** AdvanceTurn,
**Then** activeIndex is 0, roundNumber is 2,
and events are emitted in order: TurnAdvanced
(previousCombatantId C, newCombatantId A, roundNumber 2) then
RoundAdvanced (newRoundNumber 2).
4. **Given** an encounter with combatants [A, B, C], activeIndex 2,
roundNumber 5,
**When** AdvanceTurn,
**Then** activeIndex is 0, roundNumber is 6,
and events are emitted in order: TurnAdvanced then RoundAdvanced
(verifies round increment is not hardcoded to 2).
5. **Given** an encounter with a single combatant [A], activeIndex 0,
roundNumber 1,
**When** AdvanceTurn,
**Then** activeIndex is 0, roundNumber is 2,
and events are emitted in order: TurnAdvanced
(previousCombatantId A, newCombatantId A, roundNumber 2) then
RoundAdvanced (newRoundNumber 2).
6. **Given** an encounter with combatants [A, B], activeIndex 0,
roundNumber 1,
**When** AdvanceTurn is applied twice in sequence,
**Then** after the first: activeIndex 1, roundNumber 1;
after the second: activeIndex 0, roundNumber 2.
7. **Given** an encounter with an empty combatant list,
**When** AdvanceTurn,
**Then** the operation MUST fail with an invalid-encounter error.
No events are emitted. State is unchanged.
8. **Given** an encounter with combatants [A, B, C], activeIndex 0,
roundNumber 1,
**When** AdvanceTurn is applied three times,
**Then** the encounter completes a full round cycle:
activeIndex returns to 0 and roundNumber is 2.
---
### Edge Cases
- Empty combatant list: valid aggregate state, but AdvanceTurn MUST
return a DomainError (no state change, no events).
- Single combatant: every advance wraps and increments the round.
- Large round numbers: no overflow or special-case behavior; round
increments uniformly.
## Clarifications
### Session 2026-03-03
- Q: Should an encounter with zero combatants be a valid aggregate state? → A: Yes. Empty encounter is valid; AdvanceTurn returns DomainError.
- Q: What is activeIndex when combatants list is empty? → A: activeIndex MUST be 0.
- Q: Does this change any non-empty encounter behavior? → A: No. All existing acceptance scenarios and event contracts remain unchanged.
## Domain Model *(mandatory)*
### Key Entities
- **Combatant**: An identified participant in the encounter. For this
feature, a combatant is an opaque identity (e.g., a name or id).
The MVP baseline does not include HP, conditions, or stats.
- **Encounter**: The aggregate root. Contains an ordered list of
combatants (pre-sorted by initiative), an activeIndex pointing to
the current combatant, and a roundNumber (positive integer,
starting at 1).
### Domain Events
- **TurnAdvanced**: Emitted on every successful AdvanceTurn.
Carries: previousCombatantId, newCombatantId, roundNumber.
- **RoundAdvanced**: Emitted when activeIndex wraps past the last
combatant. Carries: newRoundNumber.
When a round boundary is crossed, both TurnAdvanced and
RoundAdvanced MUST be emitted in that order (TurnAdvanced first).
This emission order is part of the observable domain contract and
MUST be verified by tests.
### Invariants
- **INV-1**: An encounter MAY have zero combatants (an empty
encounter is a valid aggregate state). AdvanceTurn on an empty
encounter MUST return a DomainError with no state change and no
events.
- **INV-2**: If combatants.length > 0, activeIndex MUST satisfy
0 <= activeIndex < combatants.length. If combatants.length == 0,
activeIndex MUST be 0.
- **INV-3**: roundNumber MUST be a positive integer (>= 1) and MUST
only increase (never decrease or reset).
- **INV-4**: AdvanceTurn MUST be a pure function of the current
encounter state. Given identical input, output MUST be identical.
- **INV-5**: Every successful AdvanceTurn MUST emit at least one
domain event (TurnAdvanced). No silent state changes.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The domain MUST expose an AdvanceTurn operation that
accepts an Encounter and returns the next Encounter state plus
emitted domain events.
- **FR-002**: AdvanceTurn MUST increment activeIndex by 1, wrapping
to 0 when past the last combatant.
- **FR-003**: When activeIndex wraps to 0, roundNumber MUST
increment by 1.
- **FR-004**: AdvanceTurn on an empty encounter MUST return an error
without modifying state or emitting events.
- **FR-005**: Domain events MUST be returned as values from the
operation, not dispatched via side effects.
### Out of Scope (MVP baseline does not include)
- Initiative rolling or combatant ordering logic
- Hit points, damage, conditions, or status effects
- Adding or removing combatants mid-encounter
- Persistence, serialization, or storage
- UI, CLI, or any adapter layer
- Agent behavior or suggestions
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: All 8 acceptance scenarios pass as deterministic,
pure-function tests with no I/O dependencies.
- **SC-002**: Invariants INV-1 through INV-5 are verified by tests.
- **SC-003**: The domain module has zero imports from application,
adapter, or agent layers (layer boundary compliance).

View File

@@ -1,128 +0,0 @@
# Tasks: Advance Turn
**Input**: Design documents from `/specs/001-advance-turn/`
**Prerequisites**: plan.md (required), spec.md (required)
**Organization**: Tasks follow the phased structure from plan.md. There is only one user story (US1 — Advance Turn, P1), so phases map directly to the plan's milestones.
## Format: `[ID] [P?] [Story?] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[US1]**: User Story 1 — Advance Turn
- Exact file paths included in every task
---
## Phase 1: Setup (Milestone 1 — Tooling)
**Purpose**: Initialize pnpm monorepo, Biome, TypeScript, Vitest, and layer boundary enforcement
- [X] T001 Initialize pnpm workspace and root config — create `pnpm-workspace.yaml`, `.nvmrc` (Node 22), root `package.json` (with `packageManager` pinning pnpm 10.6 and scripts: check, test, lint, format, typecheck), `biome.json`, and `tsconfig.base.json` (strict, composite, path aliases)
- [X] T002 [P] Create `packages/domain` package skeleton — `packages/domain/package.json` (`@initiative/domain`, no deps), `packages/domain/tsconfig.json` (extends base, composite), empty `packages/domain/src/index.ts`
- [X] T003 [P] Create `packages/application` package skeleton — `packages/application/package.json` (`@initiative/application`, depends on `@initiative/domain`), `packages/application/tsconfig.json` (extends base, references domain), empty `packages/application/src/index.ts`
- [X] T004 [P] Create `apps/web` package skeleton — `apps/web/package.json` (React 19, Vite 6.2, depends on both packages), `apps/web/tsconfig.json`, `apps/web/vite.config.ts`, `apps/web/index.html`, `apps/web/src/main.tsx`, `apps/web/src/App.tsx` (placeholder)
- [X] T005 Configure Vitest — add `vitest` as root dev dependency, create `vitest.config.ts` at root (workspace mode or per-package), verify `pnpm test` exits 0
- [X] T006 Create layer boundary check — `scripts/check-layer-boundaries.mjs` (scans domain/application for forbidden imports) and `packages/domain/src/__tests__/layer-boundaries.test.ts` (wraps script as Vitest test)
**Checkpoint**: `pnpm install` succeeds, `biome check .` runs, `tsc --build` compiles, `pnpm test` exits 0 with layer boundary test green.
---
## Phase 2: Domain Implementation — User Story 1: Advance Turn (Priority: P1) (Milestone 1)
**Goal**: Implement the complete AdvanceTurn domain logic as a pure function with all 8 acceptance scenarios and invariant tests.
**Independent Test**: Pure state transition — given an Encounter value and AdvanceTurn action, assert resulting Encounter and emitted domain events. No I/O, persistence, or UI needed.
- [X] T007 [US1] Define domain types in `packages/domain/src/types.ts``CombatantId` (branded/opaque), `Combatant`, `Encounter` (combatants, activeIndex, roundNumber), factory `createEncounter` enforcing INV-1, INV-2, INV-3
- [X] T008 [P] [US1] Define domain events in `packages/domain/src/events.ts``TurnAdvanced`, `RoundAdvanced`, `DomainEvent` union (plain data, no classes)
- [X] T009 [US1] Implement `advanceTurn` in `packages/domain/src/advance-turn.ts` — pure function `(Encounter) => { encounter, events } | DomainError`, implements FR-001 through FR-005
- [X] T010 [US1] Write tests for all 8 acceptance scenarios + invariants in `packages/domain/src/__tests__/advance-turn.test.ts` — scenarios 18, INV-1 through INV-5, event ordering on round wrap
- [X] T011 [US1] Export public API from `packages/domain/src/index.ts` — re-export types, events, `advanceTurn`, `createEncounter`
**Checkpoint (Milestone 1)**: `pnpm check` passes (format + lint + typecheck + test + layer boundaries). All 8 scenarios + invariants green. No React/Vite imports in domain or application.
---
## Phase 3: Application + Web Shell (Milestone 2)
**Goal**: Wire up the application use case and minimal React UI with a "Next Turn" button.
- [X] T012 Define port interface in `packages/application/src/ports.ts``EncounterStore` port: `get(): Encounter`, `save(e: Encounter)`
- [X] T013 Implement `AdvanceTurnUseCase` in `packages/application/src/advance-turn-use-case.ts` — accepts `EncounterStore`, calls `advanceTurn`, saves result, returns events
- [X] T014 Export public API from `packages/application/src/index.ts` — re-export use case and port types
- [X] T015 Implement `useEncounter` hook in `apps/web/src/hooks/use-encounter.ts` — in-memory `EncounterStore` via React state, exposes encounter state + `advanceTurn` action, hardcoded 3-combatant demo
- [X] T016 Wire up `apps/web/src/App.tsx` — display current combatant, round number, combatant list with active indicator, "Next Turn" button, emitted events
**Checkpoint (Milestone 2)**: `pnpm check` passes. `vite build` succeeds. Clicking "Next Turn" cycles combatants and increments rounds correctly.
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 1 (Setup)**: No dependencies — start immediately
- **Phase 2 (Domain)**: Depends on Phase 1 completion
- **Phase 3 (App + Web)**: Depends on Phase 2 completion (needs domain types and `advanceTurn`)
### Within Phase 1
- T001 must complete first (workspace and root config)
- T002, T003, T004 can run in parallel [P] after T001
- T005 depends on T001 (needs root package.json)
- T006 depends on T002 and T005 (needs domain package + Vitest)
### Within Phase 2
- T007 must complete first (types needed by everything)
- T008 can run in parallel [P] with T007 (events are independent types)
- T009 depends on T007 and T008 (uses types and events)
- T010 depends on T009 (tests the implementation)
- T011 depends on T007, T008, T009 (exports all public API)
### Within Phase 3
- T012 first (port interface)
- T013 depends on T012 (uses port)
- T014 depends on T013 (exports use case)
- T015 depends on T014 (uses application layer)
- T016 depends on T015 (uses hook)
---
## Parallel Opportunities
```text
# After T001 completes:
T002, T003, T004 — all package skeletons in parallel
# After T007 starts:
T008 — domain events can be written in parallel with types
# Independent stories: only one user story (US1), so parallelism is within-phase only
```
---
## Implementation Strategy
### MVP First (Milestone 1)
1. Complete Phase 1: Setup (T001T006)
2. Complete Phase 2: Domain (T007T011)
3. **STOP and VALIDATE**: `pnpm check` passes, all 8 scenarios green
### Full Feature (Milestone 2)
4. Complete Phase 3: App + Web Shell (T012T016)
5. **VALIDATE**: `pnpm check` passes, app runs in browser
---
## Notes
- All task IDs (T001T016) match plan.md — no scope expansion
- Single user story (US1: Advance Turn) — no cross-story dependencies
- Tests (T010) are included as specified in plan.md and spec.md
- Domain package must have zero React/Vite imports (enforced by T006)

View File

@@ -0,0 +1,404 @@
# Feature Specification: Combatant Management
**Feature Branch**: `001-combatant-management`
**Created**: 2026-03-03
**Status**: Implemented
## Overview
Combatant Management covers the complete lifecycle of combatants within an encounter: adding (individually or in batch), editing, removing, clearing the entire encounter, persisting encounter state across page reloads, and the confirmation UX applied to all destructive actions.
---
## User Scenarios & Testing *(mandatory)*
### Adding Combatants
**Story A1 — Add a single combatant (Priority: P1)**
A game master adds a new combatant to an existing encounter. The new combatant is appended to the end of the initiative order, allowing late-joining participants or newly discovered enemies to enter combat.
**Acceptance Scenarios**:
1. **Given** an empty encounter (no combatants, activeIndex 0, roundNumber 1), **When** AddCombatant with name "Gandalf", **Then** combatants is [Gandalf], activeIndex is 0, roundNumber is 1, and a CombatantAdded event is emitted with the new combatant's id, name "Gandalf", and position 0.
2. **Given** an encounter with combatants [A, B], activeIndex 0, roundNumber 1, **When** AddCombatant with name "C", **Then** combatants is [A, B, C], activeIndex is 0, roundNumber is 1, and a CombatantAdded event is emitted with position 2.
3. **Given** an encounter with combatants [A, B, C], activeIndex 2, roundNumber 3, **When** AddCombatant with name "D", **Then** combatants is [A, B, C, D], activeIndex is 2, roundNumber is 3, and a CombatantAdded event is emitted with position 3. The active combatant does not change.
4. **Given** an encounter with combatants [A], **When** AddCombatant is applied twice with names "B" then "C", **Then** combatants is [A, B, C] in that order. Each operation emits its own CombatantAdded event.
5. **Given** an encounter with combatants [A, B], **When** AddCombatant with an empty name "", **Then** the operation MUST fail with a validation error. No events are emitted. State is unchanged.
6. **Given** an encounter with combatants [A, B], **When** AddCombatant with a whitespace-only name " ", **Then** the operation MUST fail with a validation error. No events are emitted. State is unchanged.
---
> **Batch add and custom creature workflows** are defined in `specs/004-bestiary/spec.md` (Stories US-S2, US-S3). Those stories cover the bestiary search dropdown, count badge, batch confirm, and custom creature stat fields. This spec covers only the domain-level AddCombatant operation that those workflows invoke.
---
### Removing Combatants
**Story B1 — Remove a combatant from an active encounter (Priority: P1)**
A game master is running a combat encounter and a combatant is defeated or leaves. The GM removes that combatant. The combatant disappears from the initiative order and the turn continues correctly without disruption.
**Acceptance Scenarios**:
1. **Given** an encounter with combatants [A, B, C] and activeIndex 1 (B's turn), **When** the GM removes combatant C (index 2, after active), **Then** the encounter has [A, B], activeIndex remains 1, roundNumber unchanged, and a CombatantRemoved event is emitted.
2. **Given** an encounter with combatants [A, B, C] and activeIndex 2 (C's turn), **When** the GM removes combatant A (index 0, before active), **Then** the encounter has [B, C], activeIndex becomes 1 (still C's turn), roundNumber unchanged.
3. **Given** an encounter with combatants [A, B, C] and activeIndex 1 (B's turn), **When** the GM removes combatant B (the active combatant), **Then** the encounter has [A, C], activeIndex becomes 1 (C is now active), roundNumber unchanged.
4. **Given** an encounter with combatants [A, B, C] and activeIndex 2 (C's turn, last position), **When** the GM removes combatant C (active and last), **Then** the encounter has [A, B], activeIndex wraps to 0 (A is now active), roundNumber unchanged.
5. **Given** an encounter with combatants [A] and activeIndex 0, **When** the GM removes combatant A, **Then** the encounter has [], activeIndex is 0, roundNumber unchanged.
6. **Given** an encounter with combatants [A, B, C], **When** the GM attempts to remove a combatant with an ID that does not exist, **Then** a domain error is returned with code `"combatant-not-found"`, and the encounter is unchanged.
---
**Story B2 — Inline confirmation before removing (Priority: P1)**
A user clicking the remove (X) button on a combatant row is protected from accidental deletion by a two-step inline confirmation flow.
**Acceptance Scenarios**:
1. **Given** a combatant row is visible, **When** the user clicks the remove (X) button once, **Then** the button transitions to a confirm state showing a checkmark icon on a red/danger background with a scale pulse animation.
2. **Given** the remove button is in confirm state, **When** the user clicks it again, **Then** the combatant is removed from the encounter.
3. **Given** the remove button is in confirm state, **When** 5 seconds elapse without a second click, **Then** the button reverts to its original X icon and default styling.
4. **Given** the remove button is in confirm state, **When** the user clicks outside the button, **Then** the button reverts to its original state without removing the combatant.
5. **Given** the remove button is in confirm state, **When** the user presses Escape, **Then** the button reverts to its original state without removing the combatant.
6. **Given** a destructive button has keyboard focus, **When** the user presses Enter or Space, **Then** the button enters confirm state.
7. **Given** a destructive button is in confirm state with focus, **When** the user presses Enter or Space, **Then** the destructive action executes.
8. **Given** a destructive button is in confirm state with focus, **When** the user presses Escape, **Then** the button reverts to its original state.
9. **Given** a destructive button is in confirm state, **When** the button loses focus (e.g., Tab away), **Then** the button reverts to its original state.
---
### Editing Combatants
**Story C1 — Rename a combatant (Priority: P1)**
A user running an encounter realizes a combatant's name is misspelled or wants to change it. They select the combatant by its identity, provide a new name, and the system updates the combatant in-place while preserving turn order and round state.
**Acceptance Scenarios**:
1. **Given** an encounter with combatants [Alice, Bob], **When** the user updates Bob's name to "Robert", **Then** the encounter contains [Alice, Robert] and a `CombatantUpdated` event is emitted with the combatant's id, old name, and new name.
2. **Given** an encounter with combatants [Alice, Bob] where Bob is the active combatant, **When** the user updates Bob's name to "Robert", **Then** Bob remains the active combatant (active index unchanged) and the round number is preserved.
---
**Story C2 — Error feedback on invalid edit (Priority: P2)**
A user attempts to edit a combatant that no longer exists or provides an invalid name. The system returns a clear error without modifying the encounter.
**Acceptance Scenarios**:
1. **Given** an encounter with combatants [Alice, Bob], **When** the user attempts to update a combatant with a non-existent id, **Then** the system returns a "combatant not found" error and the encounter is unchanged.
2. **Given** an encounter with combatants [Alice, Bob], **When** the user attempts to update Alice's name to an empty string, **Then** the system returns an "invalid name" error and the encounter is unchanged.
3. **Given** an encounter with combatants [Alice, Bob], **When** the user attempts to update Alice's name to a whitespace-only string, **Then** the system returns an "invalid name" error and the encounter is unchanged.
---
**Story C3 — Rename trigger UX (Priority: P1)**
A user wants to rename a combatant. Single-clicking the name opens the stat block panel instead of entering edit mode. To rename, the user double-clicks the name or long-presses on touch devices. A `cursor-text` cursor on hover signals that the name is editable.
**Acceptance Scenarios**:
1. **Given** a combatant row is visible, **When** the user single-clicks the combatant name, **Then** the stat block panel opens or toggles — inline edit mode is NOT entered.
2. **Given** a combatant row is visible, **When** the user double-clicks the combatant name, **Then** inline edit mode is entered for that combatant's name.
3. **Given** a combatant row is visible on a pointer device, **When** the user hovers over the combatant name, **Then** the cursor changes to a text cursor (`cursor-text`) to signal editability.
4. **Given** a combatant row is visible on a touch device, **When** the user long-presses the combatant name, **Then** inline edit mode is entered for that combatant's name.
7. **Given** inline edit mode has been entered (via any trigger), **When** the user types a new name and presses Enter or blurs the field, **Then** the name is committed. **When** the user presses Escape, **Then** the edit is cancelled and the original name is restored.
---
### Clearing the Encounter
**Story D1 — Clear encounter to start fresh (Priority: P1)**
As a DM who has just finished a combat encounter, I want to clear the entire encounter with a single confirmed action so that I can quickly set up a new combat without manually removing each combatant one by one.
**Acceptance Scenarios**:
1. **Given** an encounter with multiple combatants at round 3, **When** the user activates the clear encounter action and confirms, **Then** all combatants are removed, the round number resets to 1, and the active turn index resets to 0.
2. **Given** an encounter with a single combatant, **When** the user activates the clear encounter action and confirms, **Then** the encounter is fully cleared.
3. **Given** an encounter has no combatants, **When** the user views the clear button, **Then** it is disabled and cannot be activated.
---
**Story D2 — Inline confirmation before clearing (Priority: P1)**
A user clicks the trash button to clear the entire encounter. Instead of a browser confirm dialog, the trash button itself transitions into a red confirm state with a checkmark icon and a scale pulse. A second click clears the encounter; otherwise the button reverts after 5 seconds or on dismiss.
**Acceptance Scenarios**:
1. **Given** an encounter has combatants, **When** the user clicks the clear encounter (trash) button once, **Then** the button transitions to a confirm state with a checkmark icon on a red/danger background with a scale pulse animation.
2. **Given** the trash button is in confirm state, **When** the user clicks it again, **Then** the entire encounter is cleared.
3. **Given** the trash button is in confirm state, **When** 5 seconds pass, the user clicks outside, or the user presses Escape, **Then** the button reverts to its original trash icon and default styling without clearing the encounter.
4. **Given** a confirmation prompt is displayed, **When** the user cancels, **Then** the encounter remains unchanged.
---
### Persistence
**Story E1 — Encounter survives page reload (Priority: P1)**
A user is managing a combat encounter. They accidentally refresh the page or their browser restarts. When the page reloads, the encounter is restored exactly as it was — same combatants, same active turn, same round number.
**Acceptance Scenarios**:
1. **Given** an encounter with combatants, active turn, and round number, **When** the user reloads the page, **Then** the encounter is restored with all state intact.
2. **Given** an encounter that has been modified (combatant added, removed, or renamed), **When** the user reloads the page, **Then** the latest state is reflected.
3. **Given** the user advances the turn multiple times, **When** the user reloads the page, **Then** the active turn and round number are preserved.
---
**Story E2 — Fresh start with no saved data (Priority: P2)**
A first-time user opens the application with no previously saved encounter. The application shows the default demo encounter so the user can immediately start exploring.
**Acceptance Scenarios**:
1. **Given** no saved encounter exists in the browser, **When** the user opens the application, **Then** the default demo encounter is displayed.
2. **Given** saved encounter data has been manually cleared from the browser, **When** the user opens the application, **Then** the default demo encounter is displayed.
---
**Story E3 — Graceful handling of corrupt data (Priority: P3)**
Saved data may become invalid (e.g., manually edited in dev tools, schema changes between versions). The application handles this gracefully rather than crashing.
**Acceptance Scenarios**:
1. **Given** the saved encounter data is malformed or unparseable, **When** the user opens the application, **Then** the default demo encounter is displayed and the corrupt data is discarded.
2. **Given** the saved data is missing required fields, **When** the user opens the application, **Then** the default demo encounter is displayed.
---
## Domain Model
### Key Entities
- **Combatant**: An identified participant with a unique `CombatantId` (branded string), a required non-empty `name`, and optional `initiative`, `maxHp`, `currentHp`, `ac`, `conditions`, `isConcentrating`, and `creatureId` fields.
- **Encounter**: The aggregate root. Contains an ordered `readonly` list of combatants, an `activeIndex` (zero-based integer), and a `roundNumber` (positive integer, starting at 1).
> Queued Creature and Custom Creature Input entities are defined in `specs/004-bestiary/spec.md`.
### Domain Events
- **CombatantAdded**: Emitted on every successful AddCombatant. Carries: `combatantId`, `name`, `position` (zero-based index).
- **CombatantRemoved**: Emitted on every successful RemoveCombatant. Carries: `combatantId`, `name`.
- **CombatantUpdated**: Emitted on every successful EditCombatant. Carries: `combatantId`, `oldName`, `newName`.
### Invariants
- **INV-1**: An encounter MAY have zero combatants (after clearing or removing the last combatant).
- **INV-2**: If `combatants.length > 0`, `activeIndex` MUST satisfy `0 <= activeIndex < combatants.length`. If `combatants.length == 0`, `activeIndex` MUST be 0.
- **INV-3**: `roundNumber` MUST be a positive integer (>= 1) and MUST only increase during normal turn advancement. Clearing resets it to 1.
- **INV-4**: `CombatantId` values MUST be unique within an encounter.
- **INV-5**: All domain state transitions (add, remove, edit, clear) are pure functions; no I/O, randomness, or clocks.
- **INV-6**: Every successful state transition emits exactly one corresponding domain event. No silent state changes.
- **INV-7**: AddCombatant and RemoveCombatant MUST NOT change the `roundNumber`.
- **INV-8**: EditCombatant MUST NOT change `activeIndex`, `roundNumber`, or the combatant's position in the list.
---
## Requirements *(mandatory)*
### Functional Requirements
#### FR-001 — Add: Append combatant
The domain MUST expose an AddCombatant operation that accepts an Encounter and a combatant name (plus a pre-generated `CombatantId`), and returns the updated Encounter plus emitted domain events. The new combatant MUST be appended to the end of the combatants list.
#### FR-002 — Add: Reject invalid names
AddCombatant MUST reject empty or whitespace-only names by returning a `DomainError` without modifying state or emitting events. Name validation trims whitespace; a name that is empty after trimming is invalid.
#### FR-003 — Add: Preserve activeIndex and roundNumber
AddCombatant MUST NOT alter the `activeIndex` or `roundNumber` of the encounter.
#### FR-004 — Add: Unique CombatantId
AddCombatant MUST assign a unique `CombatantId` to the new combatant. Id generation is the caller's responsibility (application layer), keeping the domain function pure.
#### FR-005 — Add: Duplicate names allowed
Duplicate combatant names are permitted. Combatants are distinguished solely by their unique `CombatantId`.
#### FR-006 — Add: UI form
The UI MUST provide an add-combatant form accessible from the bottom bar. The search field MUST display action-oriented placeholder text (e.g., "Search creatures to add...").
> FR-007 through FR-013 (batch add and custom creature) are defined in `specs/004-bestiary/spec.md` (FR-007FR-015).
#### FR-014 — Remove: Domain operation
The domain MUST expose a RemoveCombatant operation that accepts an Encounter and a `CombatantId`, and returns the updated Encounter plus emitted domain events.
#### FR-015 — Remove: Error on unknown ID
RemoveCombatant MUST return a domain error with code `"combatant-not-found"` when the given `CombatantId` does not match any combatant in the encounter.
#### FR-016 — Remove: activeIndex adjustment
RemoveCombatant MUST adjust `activeIndex` according to these rules:
- Removed combatant is **after** the active one: `activeIndex` unchanged.
- Removed combatant is **before** the active one: `activeIndex` decrements by 1.
- Removed combatant **is** the active one and is not last: `activeIndex` stays at the same integer value (the next combatant in line becomes active).
- Removed combatant **is** the active one and **is last**: `activeIndex` wraps to 0.
- Last remaining combatant is removed (encounter becomes empty): `activeIndex` is set to 0.
#### FR-017 — Remove: roundNumber preserved
RemoveCombatant MUST preserve `roundNumber` unchanged.
#### FR-018 — Remove: UI control
The UI MUST provide a remove control for each combatant row.
#### FR-019 — Remove: ConfirmButton
The remove control MUST use the `ConfirmButton` two-step confirmation pattern (see FR-025 through FR-030). Silent no-op on domain error (combatant already gone).
#### FR-020 — Edit: Domain operation
The domain MUST expose an EditCombatant operation that accepts an Encounter, a `CombatantId`, and a new name, and returns the updated Encounter plus emitted domain events.
#### FR-021 — Edit: Error on unknown ID
EditCombatant MUST return a `"combatant-not-found"` error when the provided id does not match any combatant.
#### FR-022 — Edit: Reject invalid names
EditCombatant MUST return an `"invalid-name"` error when the new name is empty or whitespace-only. The same trimming rules as AddCombatant apply.
#### FR-023 — Edit: Preserve position and counters
EditCombatant MUST preserve the combatant's position in the list, `activeIndex`, and `roundNumber`. Setting a name to the same value it already has is treated as a valid update; a `CombatantUpdated` event is still emitted.
#### FR-024 — Edit: UI
The UI MUST provide an inline name-edit mechanism for each combatant, activated by double-clicking the name or long-pressing on touch devices. The name MUST display a `cursor-text` cursor on hover to signal editability. Single-clicking the name MUST open/toggle the stat block panel, not enter edit mode. The updated name MUST be immediately visible after submission.
#### FR-025 — ConfirmButton: Reusable component
The system MUST provide a reusable `ConfirmButton` component that wraps any icon button to add a two-step confirmation flow.
#### FR-026 — ConfirmButton: Confirm state on first activation
On first activation (click, Enter, or Space), the button MUST transition to a confirm state displaying a checkmark icon on a red/danger background with a scale pulse animation.
#### FR-027 — ConfirmButton: Auto-revert after 5 seconds
The button MUST automatically revert to its original state after 5 seconds if not confirmed.
#### FR-028 — ConfirmButton: Cancel on outside click, Escape, or focus loss
Clicking outside the button, pressing Escape, or moving focus away MUST cancel the confirm state and revert the button.
#### FR-029 — ConfirmButton: Execute on second activation
A second activation (click, Enter, or Space) while in confirm state MUST execute the destructive action.
#### FR-030 — ConfirmButton: Independent state per instance
Each `ConfirmButton` instance MUST manage its confirm state independently of other instances.
#### FR-031 — Clear: Domain operation
The domain MUST expose a ClearEncounter operation that removes all combatants, resets `roundNumber` to 1, and resets `activeIndex` to 0.
#### FR-032 — Clear: UI button with ConfirmButton
The UI MUST provide a clear encounter button that uses the `ConfirmButton` pattern. The button MUST be disabled when the encounter has no combatants.
#### FR-033 — Clear: Cancellation leaves state unchanged
Cancelling the confirmation (via timeout, outside click, Escape, or focus loss) MUST leave the encounter completely unchanged.
#### FR-034 — Clear: Cleared state persisted
After clearing, the empty encounter state MUST be persisted so that a page refresh does not restore the previous encounter.
#### FR-035 — Persistence: Save on every change
The system MUST save the full encounter state (combatants, `activeIndex`, `roundNumber`) to browser `localStorage` after every state change.
#### FR-036 — Persistence: Restore on load
The system MUST restore the saved encounter state when the application loads, if valid saved data exists.
#### FR-037 — Persistence: Fallback to demo encounter
The system MUST fall back to the default demo encounter when no saved data exists or saved data is invalid/corrupt.
#### FR-038 — Persistence: No crash on storage failure
The system MUST NOT crash or show an error to the user when storage is unavailable or data is corrupt. When storage is unavailable, the application falls back to in-memory-only behavior.
#### FR-039 — Persistence: Preserve combatant identity across reloads
The system MUST preserve combatant `CombatantId` values, names, and any other persisted fields across reloads, so that new combatants added after a reload do not collide with existing IDs.
#### FR-040 — Domain events as values
All domain events MUST be returned as plain data values from operations, not dispatched via side effects.
---
## Edge Cases
- **Empty name**: AddCombatant and EditCombatant return a `DomainError`; state and events are unchanged.
- **Whitespace-only name**: Treated identically to empty name after trimming.
- **Adding to an empty encounter**: The new combatant becomes the first and only participant; `activeIndex` remains 0.
- **Adding during mid-round**: `activeIndex` is never shifted by an add operation.
- **Duplicate combatant names**: Permitted. Combatants are distinguished by `CombatantId`.
- **Removing the last combatant**: Encounter becomes empty; `activeIndex` is set to 0.
- **Removing with unknown ID**: Returns `"combatant-not-found"` error; state unchanged. Removing the same ID twice: second call returns an error.
- **Removing from empty encounter**: Covered by the unknown-ID error (no IDs exist).
- **Editing a combatant to the same name**: Valid; `CombatantUpdated` event is still emitted.
- **Editing a combatant in an empty encounter**: Returns `"combatant-not-found"` error.
- **Clearing an already empty encounter**: The clear button is disabled; no operation is executed.
- **Clearing and reloading**: The empty (cleared) state is persisted; the previous encounter is not restored.
- **Storage quota exceeded**: Persistence silently fails; current in-memory session continues normally.
- **Multiple browser tabs**: MVP baseline does not include cross-tab synchronization. Each tab operates independently; the last tab to save wins.
> Batch add and custom creature edge cases are defined in `specs/004-bestiary/spec.md`.
- **ConfirmButton: rapid triple-click**: First click enters confirm state; second executes the action; subsequent clicks are no-ops.
- **ConfirmButton: component unmounts in confirm state**: The auto-revert timer MUST be cleaned up to prevent memory leaks or stale state updates.
- **ConfirmButton: two instances in confirm state simultaneously**: Each manages its own state independently.
- **ConfirmButton: combatant row re-renders while in confirm state**: Confirm state persists through re-renders as long as combatant identity is stable.
- **Name single-click vs double-click**: A single click on the combatant name opens the stat block panel; only a completed double-click enters inline edit mode. The system must disambiguate between the two gestures.
- **Touch edit affordance**: No hover-dependent affordance is shown on touch devices. Long-press is the touch equivalent for entering edit mode.
- **Long-press threshold**: The long-press duration should follow platform conventions (typically ~500ms). A short tap must not trigger edit mode.
---
## Success Criteria *(mandatory)*
- **SC-001**: All add-combatant acceptance scenarios pass as deterministic, pure-function tests with no I/O dependencies.
- **SC-002**: Adding a combatant to an encounter preserves all existing combatants, their order, `activeIndex`, and `roundNumber` unchanged.
- **SC-003**: All six remove-combatant acceptance scenarios pass as automated tests covering every `activeIndex` adjustment rule.
- **SC-004**: The round number never changes as a result of a remove operation.
- **SC-005**: Users can rename any combatant in the encounter in a single action without disrupting turn order, active combatant, or round number.
- **SC-006**: Invalid edit attempts (missing combatant, empty or whitespace-only name) produce a domain error with no state change and no emitted events.
- **SC-007**: All destructive actions (remove combatant, clear encounter) require exactly two deliberate user interactions to execute, eliminating single-click accidental mutations.
- **SC-008**: The `ConfirmButton` confirm state auto-reverts reliably after 5 seconds. All confirmation flows are fully operable via keyboard alone.
> SC-009 and SC-010 (batch add and custom creature success criteria) are defined in `specs/004-bestiary/spec.md`.
- **SC-011**: Users can reload the page and see their encounter fully restored, with zero data loss.
- **SC-012**: First-time users see the demo encounter immediately on first visit with no extra steps.
- **SC-013**: 100% of corrupt or missing data scenarios result in a usable application (demo encounter displayed), never a crash or blank screen.
- **SC-014**: After clearing, the encounter tracker displays an empty state with round and turn counters at their initial values, and this state persists across page refreshes.
- **SC-015**: The domain module has zero imports from the application, adapter, or UI layers (layer boundary compliance verified by automated check).
---
## Assumptions
- `CombatantId` generation is the caller's responsibility (application layer), keeping domain functions pure and deterministic.
- Name validation trims whitespace; a name that is empty after trimming is invalid.
- No uniqueness constraint on combatant names — multiple combatants may share the same name.
- Clearing results in an empty encounter state (no combatants, `roundNumber` 1, `activeIndex` 0). The user will then add new combatants using the existing add-combatant flow.
- MVP baseline does not include undo/restore functionality after clearing or removing. Once confirmed, the action is final.
- MVP baseline does not include encounter history or the ability to save/archive encounters before clearing.
- A single `localStorage` key is sufficient for the MVP (one encounter at a time).
- Cross-tab synchronization is not required for the MVP baseline.
- The `ConfirmButton` 5-second timeout is a fixed value and is not configurable in the MVP baseline.
- The `Check` icon from the Lucide icon library is used for the `ConfirmButton` confirm state.
- The inline name-edit mechanism is activated by double-click or long-press (touch). A `cursor-text` cursor on hover signals editability. Single-clicking the name opens the stat block panel.

View File

@@ -1,35 +0,0 @@
# Specification Quality Checklist: Add Combatant
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-03
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
- Assumption documented: CombatantId is passed in rather than generated internally, keeping domain pure.

View File

@@ -1,77 +0,0 @@
# Data Model: Add Combatant
**Feature**: 002-add-combatant
**Date**: 2026-03-03
## Entities
### Combatant (existing, unchanged)
| Field | Type | Constraints |
|-------|------|-------------|
| id | CombatantId (branded string) | Unique, required |
| name | string | Non-empty after trimming, required |
### Encounter (existing, unchanged)
| Field | Type | Constraints |
|-------|------|-------------|
| combatants | readonly Combatant[] | Ordered list, may be empty |
| activeIndex | number | 0 <= activeIndex < combatants.length (or 0 if empty) |
| roundNumber | number | Positive integer >= 1, only increases |
## Domain Events
### CombatantAdded (new)
| Field | Type | Description |
|-------|------|-------------|
| type | "CombatantAdded" (literal) | Discriminant for the DomainEvent union |
| combatantId | CombatantId | Id of the newly added combatant |
| name | string | Name of the newly added combatant |
| position | number | Zero-based index where the combatant was placed |
## State Transitions
### AddCombatant
**Input**: Encounter + CombatantId + name (string)
**Preconditions**:
- Name must be non-empty after trimming
**Transition**:
- New combatant `{ id, name: trimmedName }` appended to end of combatants list
- activeIndex unchanged
- roundNumber unchanged
**Postconditions**:
- combatants.length increased by 1
- New combatant is at index `combatants.length - 1`
- All existing combatants preserve their order and index positions
- INV-2 satisfied (activeIndex still valid for the now-larger list)
**Events emitted**: Exactly one `CombatantAdded`
**Error cases**:
- Empty or whitespace-only name → DomainError `{ code: "invalid-name" }`
## Function Signatures
### Domain Layer
```
addCombatant(encounter, id, name) → { encounter, events } | DomainError
```
### Application Layer
```
addCombatantUseCase(store, id, name) → DomainEvent[] | DomainError
```
## Validation Rules
| Rule | Layer | Error Code |
|------|-------|------------|
| Name non-empty after trim | Domain | invalid-name |

View File

@@ -1,76 +0,0 @@
# Implementation Plan: Add Combatant
**Branch**: `002-add-combatant` | **Date**: 2026-03-03 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/002-add-combatant/spec.md`
## Summary
Add a pure domain function `addCombatant` that appends a new combatant to the end of an encounter's combatant list without altering the active turn or round. The feature follows the same pattern as `advanceTurn`: a pure function returning updated state plus domain events, with an application-layer use case and a React adapter hook.
## Technical Context
**Language/Version**: TypeScript 5.x (strict mode, verbatimModuleSyntax)
**Primary Dependencies**: None for domain; React 19 for web adapter
**Storage**: In-memory (React state via hook)
**Testing**: Vitest
**Target Platform**: Browser (Vite dev server)
**Project Type**: Monorepo (pnpm workspaces): domain library + application library + web app
**Performance Goals**: N/A (pure synchronous function)
**Constraints**: Domain must remain pure — no I/O, no randomness
**Scale/Scope**: Single-user local app
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Status | Notes |
|-----------|--------|-------|
| I. Deterministic Domain Core | PASS | `addCombatant` is a pure function. CombatantId is passed in as input, not generated internally. |
| II. Layered Architecture | PASS | Domain function in `packages/domain`, use case in `packages/application`, hook in `apps/web`. No reverse imports. |
| III. Agent Boundary | PASS | No agent layer involvement in this feature. |
| IV. Clarification-First | PASS | Spec has no NEEDS CLARIFICATION markers. Key assumption (id passed in) is documented. |
| V. Escalation Gates | PASS | Implementation stays within spec scope. |
| VI. MVP Baseline Language | PASS | Out-of-scope items use "MVP baseline does not include". |
| VII. No Gameplay Rules | PASS | No gameplay mechanics in constitution. |
All gates pass. No violations to justify.
## Project Structure
### Documentation (this feature)
```text
specs/002-add-combatant/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
└── tasks.md # Phase 2 output (via /speckit.tasks)
```
### Source Code (repository root)
```text
packages/domain/src/
├── types.ts # Encounter, Combatant, CombatantId (existing)
├── events.ts # DomainEvent union (add CombatantAdded)
├── add-combatant.ts # NEW: addCombatant pure function
├── advance-turn.ts # Existing (unchanged)
├── index.ts # Re-exports (add new exports)
└── __tests__/
├── advance-turn.test.ts # Existing (unchanged)
└── add-combatant.test.ts # NEW: acceptance + invariant tests
packages/application/src/
├── ports.ts # EncounterStore (unchanged)
├── add-combatant-use-case.ts # NEW: orchestrates addCombatant
├── advance-turn-use-case.ts # Existing (unchanged)
└── index.ts # Re-exports (add new exports)
apps/web/src/
├── App.tsx # Update: add combatant input + button
└── hooks/
└── use-encounter.ts # Update: expose addCombatant callback
```
**Structure Decision**: Follows the established monorepo layout. Each domain operation gets its own file (matching `advance-turn.ts` pattern). No new packages or directories needed beyond the existing structure.

View File

@@ -1,47 +0,0 @@
# Quickstart: Add Combatant
**Feature**: 002-add-combatant
## Prerequisites
```bash
pnpm install
```
## Development
```bash
pnpm test:watch # Watch all tests
pnpm vitest run packages/domain/src/__tests__/add-combatant.test.ts # Run feature tests
pnpm --filter web dev # Dev server at localhost:5173
```
## Merge Gate
```bash
pnpm check # Must pass before commit (format + lint + typecheck + test)
```
## Implementation Order
1. **Domain event** — Add `CombatantAdded` to `events.ts` and the `DomainEvent` union
2. **Domain function** — Create `add-combatant.ts` with the pure `addCombatant` function
3. **Domain exports** — Update `index.ts` to re-export new items
4. **Domain tests** — Create `add-combatant.test.ts` with all 6 acceptance scenarios + invariant checks
5. **Application use case** — Create `add-combatant-use-case.ts`
6. **Application exports** — Update `index.ts` to re-export
7. **Web hook** — Update `use-encounter.ts` to expose `addCombatant` callback
8. **Web UI** — Update `App.tsx` with name input and add button
## Key Files
| File | Action | Purpose |
|------|--------|---------|
| `packages/domain/src/events.ts` | Edit | Add CombatantAdded event type |
| `packages/domain/src/add-combatant.ts` | Create | Pure addCombatant function |
| `packages/domain/src/index.ts` | Edit | Export new items |
| `packages/domain/src/__tests__/add-combatant.test.ts` | Create | Acceptance + invariant tests |
| `packages/application/src/add-combatant-use-case.ts` | Create | Use case orchestration |
| `packages/application/src/index.ts` | Edit | Export new use case |
| `apps/web/src/hooks/use-encounter.ts` | Edit | Add combatant hook callback |
| `apps/web/src/App.tsx` | Edit | Name input + add button UI |

View File

@@ -1,40 +0,0 @@
# Research: Add Combatant
**Feature**: 002-add-combatant
**Date**: 2026-03-03
## Research Summary
No NEEDS CLARIFICATION items existed in the technical context. The feature is straightforward and follows established patterns. Research focused on confirming existing patterns and the one key design decision.
## Decision 1: CombatantId Generation Strategy
**Decision**: CombatantId is passed into the domain function as an argument, not generated internally.
**Rationale**: The domain layer must remain pure and deterministic (Constitution Principle I). Generating IDs internally would require either randomness (UUID) or side effects (counter with mutable state), both of which violate purity. By accepting the id as input, `addCombatant(encounter, id, name)` is a pure function: same inputs always produce the same output.
**Alternatives considered**:
- Generate UUID inside domain: Violates deterministic core principle. Tests would be non-deterministic.
- Pass an id-generator function: Adds unnecessary complexity. The application layer can generate the id and pass it in.
**Who generates the id**: The application layer (use case) or adapter layer (hook) generates the CombatantId before calling the domain function. This matches how `createEncounter` already works — callers construct `Combatant` objects with pre-assigned ids.
## Decision 2: Function Signature Pattern
**Decision**: Follow the `advanceTurn` pattern — standalone pure function returning a success result or DomainError.
**Rationale**: Consistency with the existing codebase. `advanceTurn` returns `AdvanceTurnSuccess | DomainError`, so `addCombatant` will return `AddCombatantSuccess | DomainError` with the same shape: `{ encounter, events }`.
**Alternatives considered**:
- Method on an Encounter class: Project uses plain interfaces and free functions, not classes.
- Mutating the encounter in place: Violates immutability convention (all fields are `readonly`).
## Decision 3: Name Validation Approach
**Decision**: Trim whitespace, then reject empty strings. The domain function validates the name.
**Rationale**: Name validation is a domain rule (what constitutes a valid combatant name), so it belongs in the domain layer. Trimming before checking prevents whitespace-only names from slipping through.
**Alternatives considered**:
- Validate in application layer: Would allow invalid data to reach domain if called from a different adapter. Domain should protect its own invariants.
- Accept any string: Would allow empty-name combatants, violating spec FR-004.

View File

@@ -1,161 +0,0 @@
# Feature Specification: Add Combatant
**Feature Branch**: `002-add-combatant`
**Created**: 2026-03-03
**Status**: Draft
**Input**: User description: "let us add a spec for the option to add a combatant to the encounter. a new combatant is added to the end of the list."
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Add Combatant to Encounter (Priority: P1)
A game master adds a new combatant to an existing encounter. The new
combatant is appended to the end of the initiative order. This allows
late-joining participants or newly discovered enemies to enter combat.
**Why this priority**: Adding combatants is the foundational mutation
for populating an encounter. Without it, the encounter has no
participants and no other feature (turn advancement, removal) is useful.
**Independent Test**: Can be fully tested as a pure state transition
with no I/O, persistence, or UI. Given an Encounter value and an
AddCombatant action with a name, assert the resulting Encounter value
and emitted domain events.
**Acceptance Scenarios**:
1. **Given** an empty encounter (no combatants, activeIndex 0,
roundNumber 1),
**When** AddCombatant with name "Gandalf",
**Then** combatants is [Gandalf], activeIndex is 0,
roundNumber is 1,
and a CombatantAdded event is emitted with the new combatant's
id and name "Gandalf" and position 0.
2. **Given** an encounter with combatants [A, B], activeIndex 0,
roundNumber 1,
**When** AddCombatant with name "C",
**Then** combatants is [A, B, C], activeIndex is 0,
roundNumber is 1,
and a CombatantAdded event is emitted with position 2.
3. **Given** an encounter with combatants [A, B, C], activeIndex 2,
roundNumber 3,
**When** AddCombatant with name "D",
**Then** combatants is [A, B, C, D], activeIndex is 2,
roundNumber is 3,
and a CombatantAdded event is emitted with position 3.
The active combatant does not change.
4. **Given** an encounter with combatants [A],
**When** AddCombatant is applied twice with names "B" then "C",
**Then** combatants is [A, B, C] in that order.
Each operation emits its own CombatantAdded event.
5. **Given** an encounter with combatants [A, B],
**When** AddCombatant with an empty name "",
**Then** the operation MUST fail with a validation error.
No events are emitted. State is unchanged.
6. **Given** an encounter with combatants [A, B],
**When** AddCombatant with a whitespace-only name " ",
**Then** the operation MUST fail with a validation error.
No events are emitted. State is unchanged.
---
### Edge Cases
- Empty name or whitespace-only name: AddCombatant MUST return a
DomainError (no state change, no events).
- Adding to an empty encounter: the new combatant becomes the first
and only participant; activeIndex remains 0.
- Adding during mid-round: the activeIndex must not shift; the
currently active combatant stays active.
- Duplicate names: allowed. Combatants are distinguished by their
unique id, not by name.
## Domain Model *(mandatory)*
### Key Entities
- **Combatant**: An identified participant in the encounter with a
unique CombatantId (branded string) and a name (non-empty string).
- **Encounter**: The aggregate root. Contains an ordered list of
combatants, an activeIndex pointing to the current combatant, and
a roundNumber (positive integer, starting at 1).
### Domain Events
- **CombatantAdded**: Emitted on every successful AddCombatant.
Carries: combatantId, name, position (zero-based index where the
combatant was inserted).
### Invariants
- **INV-1** (preserved): An encounter MAY have zero combatants.
- **INV-2** (preserved): If combatants.length > 0, activeIndex MUST
satisfy 0 <= activeIndex < combatants.length. If
combatants.length == 0, activeIndex MUST be 0.
- **INV-3** (preserved): roundNumber MUST be a positive integer
(>= 1) and MUST only increase.
- **INV-4**: AddCombatant MUST be a pure function of the current
encounter state and the input name. Given identical input, output
MUST be identical (except for id generation — see Assumptions).
- **INV-5**: Every successful AddCombatant MUST emit exactly one
CombatantAdded event. No silent state changes.
- **INV-6**: AddCombatant MUST NOT change the activeIndex or
roundNumber of the encounter.
- **INV-7**: The new combatant MUST be appended to the end of the
combatants list (last position).
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The domain MUST expose an AddCombatant operation that
accepts an Encounter and a combatant name, and returns the updated
Encounter state plus emitted domain events.
- **FR-002**: AddCombatant MUST append the new combatant to the end
of the combatants list.
- **FR-003**: AddCombatant MUST assign a unique CombatantId to the
new combatant.
- **FR-004**: AddCombatant MUST reject empty or whitespace-only names
by returning a DomainError without modifying state or emitting
events.
- **FR-005**: AddCombatant MUST NOT alter the activeIndex or
roundNumber of the encounter.
- **FR-006**: Domain events MUST be returned as values from the
operation, not dispatched via side effects.
### Out of Scope (MVP baseline does not include)
- Removing combatants from an encounter
- Reordering combatants after adding
- Initiative score or automatic sorting
- Combatant attributes beyond name (HP, conditions, stats)
- Maximum combatant count limits
- Persistence, serialization, or storage
- UI or any adapter layer
## Assumptions
- CombatantId generation is the caller's responsibility (passed in or
generated by the application layer), keeping the domain function
pure and deterministic. The domain function will accept a
CombatantId as part of its input rather than generating one
internally.
- Name validation trims whitespace; a name that is empty after
trimming is invalid.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: All 6 acceptance scenarios pass as deterministic,
pure-function tests with no I/O dependencies.
- **SC-002**: Invariants INV-1 through INV-7 are verified by tests.
- **SC-003**: The domain module has zero imports from application,
adapter, or agent layers (layer boundary compliance).
- **SC-004**: Adding a combatant to an encounter preserves all
existing combatants and their order unchanged.

View File

@@ -1,129 +0,0 @@
# Tasks: Add Combatant
**Input**: Design documents from `/specs/002-add-combatant/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, quickstart.md
**Tests**: Included — spec success criteria SC-001 and SC-002 require all acceptance scenarios and invariants to be verified by tests.
**Organization**: Single user story (P1). Tasks follow the established `advanceTurn` pattern across all three layers.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1)
- Include exact file paths in descriptions
---
## Phase 1: Foundational (Domain Event)
**Purpose**: Add the CombatantAdded event type that all layers depend on
- [x] T001 Add CombatantAdded event interface and extend DomainEvent union in packages/domain/src/events.ts
**Checkpoint**: CombatantAdded event type available for import
---
## Phase 2: User Story 1 - Add Combatant to Encounter (Priority: P1) 🎯 MVP
**Goal**: A game master can add a new combatant to an existing encounter. The combatant is appended to the end of the initiative list without changing the active turn or round.
**Independent Test**: Call `addCombatant` with an Encounter, a CombatantId, and a name. Assert the returned Encounter has the new combatant at the end, activeIndex and roundNumber unchanged, and a CombatantAdded event emitted.
### Domain Layer
- [x] T002 [US1] Create addCombatant pure function in packages/domain/src/add-combatant.ts
- [x] T003 [US1] Export addCombatant and AddCombatantSuccess from packages/domain/src/index.ts
### Domain Tests
- [x] T004 [US1] Create acceptance tests (6 scenarios) and invariant tests (INV-1 through INV-7) in packages/domain/src/__tests__/add-combatant.test.ts
### Application Layer
- [x] T005 [P] [US1] Create addCombatantUseCase in packages/application/src/add-combatant-use-case.ts
- [x] T006 [US1] Export addCombatantUseCase from packages/application/src/index.ts
### Web Adapter
- [x] T007 [US1] Add addCombatant callback to useEncounter hook in apps/web/src/hooks/use-encounter.ts
- [x] T008 [US1] Add combatant name input and add button to apps/web/src/App.tsx
**Checkpoint**: All 6 acceptance scenarios pass. User can type a name and add a combatant via the UI. `pnpm check` passes.
---
## Phase 3: Polish & Cross-Cutting Concerns
- [x] T009 Run pnpm check (format + lint + typecheck + test) and fix any issues
- [x] T010 Verify layer boundary compliance (domain has no outer-layer imports)
---
## Dependencies & Execution Order
### Phase Dependencies
- **Foundational (Phase 1)**: No dependencies — start immediately
- **User Story 1 (Phase 2)**: Depends on T001 (CombatantAdded event type)
- **Polish (Phase 3)**: Depends on all Phase 2 tasks
### Within User Story 1
```
T001 (event type)
├── T002 (domain function) → T003 (domain exports) → T004 (domain tests)
└── T005 (use case) ──────→ T006 (app exports) → T007 (hook) → T008 (UI)
```
- T002 depends on T001 (needs CombatantAdded type)
- T003 depends on T002 (exports the new function)
- T004 depends on T003 (tests import from index)
- T005 depends on T003 (use case imports domain function) — can run in parallel with T004
- T006 depends on T005
- T007 depends on T006
- T008 depends on T007
### Parallel Opportunities
- T004 (domain tests) and T005 (use case) can run in parallel after T003
- T009 and T010 can run in parallel
---
## Parallel Example: After T003
```
# These two tasks touch different packages and can run in parallel:
T004: "Acceptance + invariant tests in packages/domain/src/__tests__/add-combatant.test.ts"
T005: "Use case in packages/application/src/add-combatant-use-case.ts"
```
---
## Implementation Strategy
### MVP (This Feature)
1. T001: Add event type (foundation)
2. T002T003: Domain function + exports
3. T004 + T005 in parallel: Tests + use case
4. T006T008: Application exports → hook → UI
5. T009T010: Verify everything passes
### Validation
After T004: All 6 acceptance scenarios pass as pure-function tests
After T008: UI allows adding combatants by name
After T009: `pnpm check` passes clean (merge gate)
---
## Notes
- Follow the `advanceTurn` pattern for function signature, result type, and error handling
- CombatantId is passed in as input (generated by caller), not created inside domain
- Name is trimmed then validated; empty after trim returns DomainError with code "invalid-name"
- Commit after each task or logical group
- Total: 10 tasks (1 foundational + 7 US1 + 2 polish)

View File

@@ -0,0 +1,319 @@
# Feature Specification: Turn Tracking
**Feature Branch**: `002-turn-tracking`
**Created**: 2026-03-03
**Status**: Implemented
---
## Overview
Turn Tracking covers all aspects of managing the flow of combat: advancing and retreating through combatants in initiative order, incrementing and decrementing the round counter at round boundaries, sorting combatants by initiative value, and presenting the top bar UI with navigation controls, round display, and active combatant display.
---
## Domain Model
### Key Entities
- **Combatant** — An identified participant in the encounter. Carries an optional integer `initiative` value that determines position in turn order.
- **Encounter** — The aggregate root. Contains an ordered list of combatants (sorted by initiative descending, unset last), an `activeIndex` pointing to the current combatant, and a `roundNumber` (positive integer, starting at 1).
### Domain Events
- **TurnAdvanced** — Emitted on every successful AdvanceTurn. Carries: `previousCombatantId`, `newCombatantId`, `roundNumber`.
- **RoundAdvanced** — Emitted when advancing crosses the end of the combatant list. Carries: `newRoundNumber`.
- **TurnRetreated** — Emitted on every successful RetreatTurn. Carries: `previousCombatantId`, `newCombatantId`, `roundNumber`.
- **RoundRetreated** — Emitted when retreating crosses a round boundary backward. Carries: `newRoundNumber`.
- **InitiativeSet** — Emitted when a combatant's initiative value is set or changed.
When a round boundary is crossed, the corresponding turn event (TurnAdvanced or TurnRetreated) MUST be emitted first, followed by the round event (RoundAdvanced or RoundRetreated). This emission order is part of the observable domain contract.
### Invariants
- **INV-1**: An encounter MAY have zero combatants (empty encounter is valid aggregate state). AdvanceTurn and RetreatTurn on an empty encounter MUST return a DomainError with no state change and no events.
- **INV-2**: If `combatants.length > 0`, `activeIndex` MUST satisfy `0 <= activeIndex < combatants.length`. If `combatants.length == 0`, `activeIndex` MUST be 0.
- **INV-3**: `roundNumber` MUST be a positive integer (>= 1). It MUST only increase on AdvanceTurn and only decrease on RetreatTurn; it MUST never drop below 1.
- **INV-4**: AdvanceTurn and RetreatTurn MUST be pure functions of the current encounter state. Given identical input, output MUST be identical.
- **INV-5**: Every successful AdvanceTurn or RetreatTurn MUST emit at least one domain event (TurnAdvanced or TurnRetreated respectively). No silent state changes.
- **INV-6**: The initiative sort MUST be stable — combatants with equal initiative (or multiple combatants with no initiative) retain their relative insertion order.
- **INV-7**: The active combatant's identity MUST be preserved through any initiative-driven reorder — the active turn tracks the combatant by identity, not by index position.
---
## User Scenarios & Testing *(mandatory)*
### Advancing Turns
#### Story A1 — Advance to the Next Combatant (Priority: P1)
As a game master running an encounter, I want to advance the turn to the next combatant in initiative order so that play moves forward through the encounter.
**Acceptance Scenarios**:
1. **Given** an encounter with combatants [A, B, C], activeIndex 0, roundNumber 1, **When** AdvanceTurn, **Then** activeIndex is 1, roundNumber is 1, and a TurnAdvanced event is emitted with previousCombatantId A, newCombatantId B, roundNumber 1.
2. **Given** an encounter with combatants [A, B, C], activeIndex 1, roundNumber 1, **When** AdvanceTurn, **Then** activeIndex is 2, roundNumber is 1, and a TurnAdvanced event is emitted with previousCombatantId B, newCombatantId C, roundNumber 1.
3. **Given** an encounter with combatants [A, B], activeIndex 0, roundNumber 1, **When** AdvanceTurn is applied twice in sequence, **Then** after the first: activeIndex 1, roundNumber 1; after the second: activeIndex 0, roundNumber 2.
4. **Given** an encounter with combatants [A, B, C], activeIndex 0, roundNumber 1, **When** AdvanceTurn is applied three times, **Then** activeIndex returns to 0 and roundNumber is 2.
#### Story A2 — Round Increment on Wrap (Priority: P1)
As a game master, I want the round number to increment automatically when the last combatant's turn ends so that I always know which round of combat I am in.
**Acceptance Scenarios**:
1. **Given** an encounter with combatants [A, B, C], activeIndex 2, roundNumber 1, **When** AdvanceTurn, **Then** activeIndex is 0, roundNumber is 2, and events are emitted in order: TurnAdvanced (previousCombatantId C, newCombatantId A, roundNumber 2) then RoundAdvanced (newRoundNumber 2).
2. **Given** an encounter with combatants [A, B, C], activeIndex 2, roundNumber 5, **When** AdvanceTurn, **Then** activeIndex is 0, roundNumber is 6, and events are emitted in order: TurnAdvanced then RoundAdvanced (verifies round increment is not hardcoded to 2).
3. **Given** an encounter with a single combatant [A], activeIndex 0, roundNumber 1, **When** AdvanceTurn, **Then** activeIndex is 0, roundNumber is 2, and events are emitted in order: TurnAdvanced (previousCombatantId A, newCombatantId A, roundNumber 2) then RoundAdvanced (newRoundNumber 2).
#### Story A3 — AdvanceTurn on Empty Encounter (Priority: P1)
As a developer, I want AdvanceTurn to fail safely on an empty encounter so that no invalid state is ever produced.
**Acceptance Scenarios**:
1. **Given** an encounter with an empty combatant list, **When** AdvanceTurn, **Then** the operation MUST fail with an invalid-encounter error. No events are emitted. State is unchanged.
---
### Retreating Turns
#### Story R1 — Go Back to the Previous Turn (Priority: P1)
As a game master running a combat encounter, I want to go back to the previous combatant's turn so that I can correct mistakes (e.g., a forgotten action, an incorrectly skipped combatant) without restarting the encounter.
**Acceptance Scenarios**:
1. **Given** an encounter with combatants [A, B, C], activeIndex 1, roundNumber 1, **When** RetreatTurn, **Then** activeIndex is 0, roundNumber is 1, and a TurnRetreated event is emitted with previousCombatantId B, newCombatantId A, roundNumber 1.
2. **Given** an encounter with a single combatant [A], activeIndex 0, roundNumber 3, **When** RetreatTurn, **Then** activeIndex is 0, roundNumber is 2, and events are emitted in order: TurnRetreated then RoundRetreated (newRoundNumber 2).
#### Story R2 — Round Decrement on Wrap Backward (Priority: P1)
As a game master, I want the round number to decrement when retreating past the first combatant so that the encounter state accurately reflects where I am in the timeline.
**Acceptance Scenarios**:
1. **Given** an encounter with combatants [A, B, C], activeIndex 0, roundNumber 2, **When** RetreatTurn, **Then** activeIndex is 2, roundNumber is 1, and events are emitted in order: TurnRetreated (previousCombatantId A, newCombatantId C, roundNumber 1) then RoundRetreated (newRoundNumber 1).
#### Story R3 — Retreat Blocked at Encounter Start (Priority: P1)
As a game master, I want the Previous Turn action to fail when I am at the very beginning of the encounter so that round 1 / first combatant is the earliest reachable state.
**Acceptance Scenarios**:
1. **Given** an encounter with combatants [A, B, C], activeIndex 0, roundNumber 1, **When** RetreatTurn, **Then** the operation MUST fail with an error. No events are emitted. State is unchanged.
2. **Given** an encounter with a single combatant [A], activeIndex 0, roundNumber 1, **When** RetreatTurn, **Then** the operation MUST fail with an error. No events are emitted. State is unchanged.
#### Story R4 — RetreatTurn on Empty Encounter (Priority: P1)
As a developer, I want RetreatTurn to fail safely on an empty encounter so that no invalid state is ever produced.
**Acceptance Scenarios**:
1. **Given** an encounter with an empty combatant list, **When** RetreatTurn, **Then** the operation MUST fail with an invalid-encounter error. No events are emitted. State is unchanged.
---
### Round Tracking
#### Story RD1 — Round Number Display (Priority: P1)
As a game master, I want the current round number to always be visible at the top of the tracker so that I never lose track of which round of combat I am in.
**Acceptance Scenarios**:
1. **Given** an active encounter in Round 2, **When** the user views the top bar, **Then** the round badge shows "R2" (or equivalent compact format) as a visually distinct element.
2. **Given** the user advances the turn and the round increments from 3 to 4, **Then** the round badge updates to the new round number immediately without layout shift.
3. **Given** an encounter with no combatants, **When** viewing the top bar, **Then** the round badge still shows the current round number.
---
### Turn Order (Initiative Sorting)
#### Story TO1 — Automatic Ordering by Initiative (Priority: P1)
As a game master, I want the encounter to automatically sort combatants from highest to lowest initiative whenever initiative values are set or changed so that I do not have to manually reorder them.
**Acceptance Scenarios**:
1. **Given** combatants A (initiative 20), B (initiative 5), C (initiative 15), **When** all initiatives are set, **Then** the combatant order is A (20), C (15), B (5).
2. **Given** combatants in order A (20), C (15), B (5), **When** B's initiative is changed to 25, **Then** the order becomes B (25), A (20), C (15).
3. **Given** combatants A (initiative 10) and B (initiative 10) with the same value, **Then** their relative order is preserved (stable sort — the combatant who was added or set first stays ahead).
#### Story TO2 — Combatants Without Initiative (Priority: P2)
As a game master, I want combatants who have not had their initiative set to appear at the end of the turn order so that the encounter remains usable while I am still entering initiative values.
**Acceptance Scenarios**:
1. **Given** combatants A (initiative 15), B (no initiative), C (initiative 10), **Then** the order is A (15), C (10), B (no initiative).
2. **Given** combatants A (no initiative) and B (no initiative), **Then** their relative order is preserved from when they were added.
3. **Given** combatant A (no initiative), **When** initiative is set to 12, **Then** A moves to its correct sorted position among combatants that have initiative values.
4. **Given** combatant A (initiative 15), **When** the user clears A's initiative, **Then** A moves to the end of the turn order.
#### Story TO3 — Active Turn Preserved During Reorder (Priority: P2)
As a game master mid-encounter, I want the active combatant's turn to be preserved when initiative changes cause a reorder so that I do not lose track of whose turn it is.
**Acceptance Scenarios**:
1. **Given** it is combatant B's turn (activeIndex points to B), **When** combatant A's initiative is changed causing a reorder, **Then** the active turn still points to combatant B.
2. **Given** it is combatant A's turn, **When** combatant A's own initiative is changed causing a reorder, **Then** the active turn still points to combatant A.
---
### Top Bar Display
#### Story TB1 — Scanning Round and Combatant at a Glance (Priority: P1)
As a game master running an encounter, I want the round number and current combatant displayed as distinct, visually separated elements so I can instantly identify both without parsing a combined string.
**Acceptance Scenarios**:
1. **Given** an active encounter in Round 2 with "Goblin" as the active combatant, **When** the user views the top bar, **Then** "R2" appears as a muted badge/pill near the left side and "Goblin" appears as a prominent centered label, with no dash or combined string.
2. **Given** an active encounter in Round 1 at the first combatant, **When** the encounter starts, **Then** the round badge shows the round number and the center displays the first combatant's name as separate visual elements.
3. **Given** the user advances the turn, **When** the round increments from 3 to 4, **Then** the round badge updates without layout shift.
#### Story TB2 — Fixed Top Bar (Priority: P1)
As a game master managing a large encounter with many combatants, I want the turn navigation bar pinned to the top of the screen so that I can always navigate turns without scrolling away from the controls.
**Acceptance Scenarios**:
1. **Given** an encounter with enough combatants to overflow the viewport, **When** the user scrolls through the combatant list, **Then** the turn navigation bar (Previous / round badge / combatant name / Next) remains fixed at the top of the encounter area and never scrolls out of view.
2. **Given** any viewport width, **When** the encounter tracker is displayed, **Then** the top navigation bar remains fixed and the combatant list scrolls independently.
#### Story TB3 — Left-Center-Right Layout (Priority: P1)
As a game master, I want the top bar to follow a clear left-center-right structure so that controls are always in predictable positions regardless of combatant name length.
**Acceptance Scenarios**:
1. **Given** an encounter with a short combatant name like "Orc", **When** viewing the bar, **Then** the layout maintains the left-center-right structure with the name centered.
2. **Given** an encounter with a long combatant name like "Ancient Red Dragon Wyrm of the Northern Wastes", **When** viewing the bar, **Then** the name truncates gracefully without pushing action buttons off-screen.
3. **Given** a narrow viewport, **When** viewing the bar, **Then** all three zones (round badge, combatant name, action buttons) remain visible and accessible.
#### Story TB4 — Turn Navigation Controls Accessible and Correctly Disabled (Priority: P1)
As a game master, I want the Previous Turn and Next Turn buttons placed prominently in the fixed top bar, with the Previous button disabled when no retreat is possible, so that I can quickly navigate turns from any scroll position.
**Acceptance Scenarios**:
1. **Given** the encounter tracker is displayed, **When** the user looks at the screen, **Then** the Previous Turn and Next Turn buttons are visible in the fixed top bar, above the combatant list.
2. **Given** the encounter is at round 1 with the first combatant active, **When** the user views the turn controls, **Then** the Previous Turn button is disabled (visually indicating it cannot be used).
3. **Given** the encounter has no combatants, **When** the user views the tracker, **Then** both turn navigation buttons are disabled.
4. **Given** the tracker has many combatants requiring scrolling, **When** the user scrolls down, **Then** the turn navigation controls remain accessible at the top (no scrolling needed to reach them).
5. **Given** the Previous and Next buttons are displayed, **When** the user looks at the controls, **Then** the buttons are visually distinct with clear directional indicators (icons, labels, or both).
#### Story TB5 — No Combatants State (Priority: P2)
As a game master with an empty encounter, I want the top bar to handle the no-combatants state gracefully so that it does not appear broken.
**Acceptance Scenarios**:
1. **Given** an encounter with no combatants, **When** viewing the top bar, **Then** the round badge still shows the round number and the center area displays a placeholder message indicating no active combatant.
#### Story TB6 — Active Combatant Scrolled into View on Turn Change (Priority: P2)
As a game master, I want the active combatant's row to automatically scroll into view when the turn changes so that the active row is always visible after navigation.
**Acceptance Scenarios**:
1. **Given** the active combatant's row is scrolled off-screen, **When** the turn changes via Next or Previous, **Then** the combatant list automatically scrolls to bring the newly active combatant's row into view.
---
## Requirements *(mandatory)*
### Functional Requirements
**Advancing Turns**
- **FR-001**: The domain MUST expose an AdvanceTurn operation that accepts an Encounter and returns the resulting Encounter state plus emitted domain events.
- **FR-002**: AdvanceTurn MUST increment `activeIndex` by 1, wrapping to 0 when advancing past the last combatant.
- **FR-003**: When `activeIndex` wraps to 0, `roundNumber` MUST increment by 1.
- **FR-004**: AdvanceTurn on an empty encounter MUST return a DomainError without modifying state or emitting events.
- **FR-005**: Domain events MUST be returned as values from AdvanceTurn, not dispatched via side effects.
**Retreating Turns**
- **FR-006**: The domain MUST expose a RetreatTurn operation that moves the active turn to the previous combatant in initiative order.
- **FR-007**: RetreatTurn MUST decrement `activeIndex` by 1. When `activeIndex` would go below 0, it MUST wrap to the last combatant and decrement `roundNumber` by 1.
- **FR-008**: RetreatTurn at round 1 with `activeIndex` 0 MUST fail with a DomainError. This is the earliest possible encounter state.
- **FR-009**: RetreatTurn on an empty encounter MUST fail with a DomainError without modifying state or emitting events.
- **FR-010**: RetreatTurn MUST emit a TurnRetreated event on success. When crossing a round boundary, a RoundRetreated event MUST also be emitted: TurnRetreated first, then RoundRetreated.
- **FR-011**: RetreatTurn MUST be a pure function of the current encounter state. Given identical input, output MUST be identical.
**Turn Order (Initiative Sorting)**
- **FR-012**: The system MUST automatically reorder combatants from highest to lowest initiative whenever an initiative value is set, changed, or cleared.
- **FR-013**: Combatants without an initiative value MUST be placed after all combatants that have initiative values.
- **FR-014**: The sort MUST be stable: combatants with equal initiative (or multiple combatants without initiative) retain their relative order.
- **FR-015**: The system MUST preserve the active combatant's turn identity when reordering occurs — the active turn tracks the combatant by identity, not by `activeIndex` position.
- **FR-016**: Zero and negative integers MUST be accepted as valid initiative values.
- **FR-017**: Non-integer initiative values MUST be rejected with an error.
- **FR-018**: The system MUST emit an InitiativeSet domain event when a combatant's initiative is set or changed.
**Top Bar Display**
- **FR-019**: The top bar MUST remain fixed at the top of the encounter tracker area and MUST NOT scroll out of view.
- **FR-020**: The top bar MUST follow a left-center-right layout: [prev button] [round badge] — [combatant name] — [action buttons] [next button].
- **FR-021**: The round number MUST be displayed as a compact, visually muted badge or pill element (format: "R{n}", e.g., "R1", "R2") positioned to the left of the combatant name.
- **FR-022**: The current combatant's name MUST be displayed as a prominent, centered label and MUST be the visual focal point of the bar.
- **FR-023**: The round number and combatant name MUST be visually distinct elements — not joined by a dash or rendered as a single string.
- **FR-024**: The combatant name MUST truncate with an ellipsis when it exceeds available space rather than causing layout overflow.
- **FR-025**: When no combatants exist, the center area MUST display a placeholder message; the round badge MUST still show the current round number.
- **FR-026**: The Previous Turn button MUST be disabled when the encounter is at its earliest state (round 1, first combatant active).
- **FR-027**: Both turn navigation buttons MUST be disabled when the encounter has no combatants.
- **FR-028**: The turn navigation buttons MUST have clear directional indicators (icons, labels, or both) so the user can distinguish Previous from Next at a glance.
- **FR-029**: When the active turn changes via Next or Previous, the active combatant's row MUST automatically scroll into view if it is not currently visible in the scrollable list area.
- **FR-030**: The combatant list MUST be the only scrollable region — positioned between the fixed top bar and the fixed bottom bar.
---
## Success Criteria *(mandatory)*
- **SC-001**: All AdvanceTurn acceptance scenarios pass as deterministic, pure-function tests with no I/O dependencies.
- **SC-002**: Invariants INV-1 through INV-7 are verified by tests.
- **SC-003**: The domain module has zero imports from application, adapter, or UI layers (layer boundary compliance).
- **SC-004**: A user can reverse a turn advancement using a single click on the Previous Turn button.
- **SC-005**: The Previous Turn button is correctly disabled at the earliest encounter state (round 1, first combatant), preventing invalid operations 100% of the time.
- **SC-006**: All RetreatTurn acceptance scenarios pass as deterministic, pure-function tests with no I/O dependencies.
- **SC-007**: After setting or changing any initiative value, the encounter's combatant order immediately reflects the correct descending initiative sort.
- **SC-008**: The active combatant's turn is never lost or shifted to a different combatant due to an initiative-driven reorder.
- **SC-009**: Combatants without initiative are always displayed after combatants with initiative values.
- **SC-010**: Users can identify the current round number and active combatant in under 1 second of looking at the top bar, without needing to parse a combined string.
- **SC-011**: The top bar layout remains visually balanced and functional across viewport widths from 320px to 1920px.
- **SC-012**: All existing top bar functionality (turn navigation, roll initiative, manage sources, clear encounter) remains fully operational.
- **SC-013**: Combatant names up to 40 characters display without layout breakage; longer names truncate gracefully.
- **SC-014**: With 20+ combatants in an encounter, the turn navigation bar remains visible at all scroll positions without any user action beyond normal scrolling.
---
## Edge Cases
- **Empty combatant list**: Valid aggregate state. AdvanceTurn and RetreatTurn both return a DomainError (no state change, no events). The top bar shows the round badge and a placeholder for the combatant name.
- **Single combatant, advancing**: Every advance wraps and increments the round. Both TurnAdvanced and RoundAdvanced are emitted.
- **Single combatant, retreating at round 1**: RetreatTurn fails because there is no previous turn.
- **Single combatant, retreating at round > 1**: RetreatTurn succeeds, decrementing the round; both TurnRetreated and RoundRetreated are emitted.
- **Large round numbers**: No overflow or special-case behavior; round increments and decrements uniformly.
- **Retreating at round 1, activeIndex 0**: The earliest possible state — RetreatTurn MUST fail. Round number can never drop below 1.
- **All combatants have the same initiative**: Relative order is preserved (stable sort preserves insertion order).
- **Initiative cleared mid-encounter**: The combatant moves to the end of the turn order. The active combatant identity is preserved.
- **Initiative changed for the active combatant**: Reorder occurs; the active turn still points to that combatant at its new position.
- **Initiative set to zero or a negative value**: Treated as a normal integer — sorted accordingly.
- **Combatant name extremely long (50+ characters)**: Name truncates with an ellipsis; layout does not break.
- **Very narrow viewport**: Round badge and navigation buttons remain visible; combatant name truncates.
- **Very short viewport (e.g., 400px tall)**: Combatant list area is still scrollable, even if only a small portion is visible.
- **Active combatant scrolled off-screen**: On turn change, the list auto-scrolls to bring the newly active combatant into view.
---
## Assumptions
- Initiative values are integers (no decimals). There is no dice-rolling or randomization in the domain — the user provides the final value.
- Tiebreaking for equal initiative values uses stable sort (preserves existing relative order). Secondary tiebreakers (e.g., Dexterity modifier) are not included in the MVP baseline.
- RetreatTurn is the inverse of AdvanceTurn for position and round tracking only. It does not restore any other state (e.g., HP changes made during a combatant's turn are not undone). It is not a full undo/redo stack.
- Keyboard shortcuts for Previous/Next Turn navigation are not included in the MVP baseline.
- The round badge uses the compact format "R{number}" (e.g., "R1", "R2").
- No new domain entities or persistence changes are required for the top bar display — it is a presentational layer over existing encounter state.

View File

@@ -0,0 +1,492 @@
# Feature Specification: Combatant State
**Feature Branch**: `003-combatant-state`
**Created**: 2026-03-03
**Status**: Implemented
---
## Overview
Combatant State covers all per-combatant data tracked during an encounter: hit points, armor class, conditions, concentration, and initiative.
**Structure**: This spec is organized by topic area. Each topic section contains its own user scenarios, requirements, and edge cases. The Combatant Row Layout section covers cross-cutting UI concerns.
## Domain Model Reference
```ts
interface Combatant {
readonly id: CombatantId; // branded string
readonly name: string;
readonly initiative?: number; // integer, undefined = unset
readonly maxHp?: number; // positive integer
readonly currentHp?: number; // 0..maxHp
readonly ac?: number; // non-negative integer
readonly conditions?: readonly ConditionId[];
readonly isConcentrating?: boolean;
readonly creatureId?: CreatureId; // link to bestiary entry
}
```
---
## Hit Points
### User Stories
**Story HP-1 — Set Max HP (P1)**
As a game master, I want to assign a maximum HP value to a combatant so that I can track their health during the encounter.
Acceptance scenarios:
1. **Given** a combatant exists, **When** the user sets max HP to a positive integer, **Then** the combatant's max HP is stored and current HP defaults to that value.
2. **Given** a combatant has max HP 20 and current HP 20, **When** the user lowers max HP to 15, **Then** current HP is clamped to 15.
3. **Given** a combatant has max HP 20 and current HP 20 (full health), **When** the user increases max HP to 30, **Then** current HP increases to 30 (stays at full).
4. **Given** a combatant has max HP 20 and current HP 12 (not full), **When** the user increases max HP to 30, **Then** current HP remains at 12.
5. **Given** the max HP inline edit is active, **When** the user clears the field and confirms, **Then** max HP is unset and HP tracking is removed entirely.
**Story HP-2 — Apply HP Delta (P1)**
As a game master in the heat of combat, I want to type a damage or healing number and immediately apply it to a combatant's HP so that I can keep up with fast-paced encounters without mental arithmetic.
Acceptance scenarios:
1. **Given** a combatant has 20/20 HP, **When** the user types 7 into the delta input and presses Enter, **Then** current HP decreases to 13.
2. **Given** a combatant has 10/20 HP, **When** the user types 15 and presses Enter, **Then** current HP is clamped to 0.
3. **Given** a combatant has 10/20 HP and types 5 then clicks the heal button, **Then** current HP increases to 15.
4. **Given** a combatant has 18/20 HP and types 10 then clicks the heal button, **Then** current HP is clamped to 20.
5. **Given** any confirmed delta, **Then** the input field clears automatically and is ready for the next entry.
6. **Given** the user types 0 and presses Enter, **Then** the input is rejected and HP remains unchanged.
7. **Given** the delta input is focused and the user presses Escape, **Then** the input clears without applying any change.
**Story HP-3 — Click-to-Adjust Popover (P1)**
As a DM running combat, I want to click on a combatant's current HP value to open a small adjustment popover so that the combatant row is visually clean and I can still quickly apply damage or healing when needed.
Acceptance scenarios:
1. **Given** a combatant with max HP set, **When** viewing the row at rest, **Then** only the current HP number and max HP are visible — no delta input or action buttons.
2. **Given** a combatant with max HP set, **When** the user clicks the current HP number, **Then** a small popover opens with an auto-focused numeric input and Damage/Heal buttons.
3. **Given** the HP popover is open with a valid number, **When** the user presses Enter, **Then** damage is applied and the popover closes.
4. **Given** the HP popover is open with a valid number, **When** the user presses Shift+Enter, **Then** healing is applied and the popover closes.
5. **Given** the HP popover is open, **When** the user presses Escape, **Then** the popover closes without changes.
6. **Given** the HP popover is open, **When** the user clicks outside, **Then** the popover closes without changes.
7. **Given** a combatant with no max HP set, **When** viewing the row, **Then** the HP area shows only the max HP clickable placeholder — no current HP value.
**Story HP-4 — Direct HP Entry (P2)**
As a game master, I want to type a specific absolute current HP value directly so I can apply large corrections in one action.
Acceptance scenarios:
1. **Given** a combatant has max HP 50, **When** the user types 35 into the current HP field, **Then** current HP is set to 35.
2. **Given** a combatant has max HP 50, **When** the user types 60, **Then** current HP is clamped to 50.
3. **Given** a combatant has max HP 50, **When** the user types -5, **Then** current HP is clamped to 0.
**Story HP-5 — HP Status Indicators (P1)**
As a game master, I want to see at a glance which combatants are bloodied or unconscious so I can narrate the battle and make tactical decisions without mental math.
Acceptance scenarios:
1. **Given** a combatant has max HP 20 and current HP 10 (at half), **Then** no bloodied indicator is shown (10 is not less than 20/2 = 10).
2. **Given** a combatant has max HP 20 and current HP 9 (below half), **Then** the bloodied indicator is visible (amber color treatment on HP value).
3. **Given** a combatant has max HP 21 and current HP 10 (below 10.5), **Then** the bloodied indicator is shown.
4. **Given** a combatant has max HP 20 and current HP 0, **Then** the unconscious/dead indicator is shown (red color; row visually muted).
5. **Given** a combatant at 0 HP is healed above 0, **Then** the unconscious indicator is removed and the correct status is applied.
6. **Given** a combatant has no max HP set, **Then** no status indicator is shown.
7. **Given** a combatant at full HP 20/20, **When** 11 damage is dealt (-> 9/20), **Then** the indicator transitions to bloodied.
8. **Given** a bloodied combatant 5/20, **When** 5 damage is dealt (-> 0/20), **Then** the indicator transitions to unconscious/dead.
9. **Given** an unconscious combatant 0/20, **When** 15 HP is healed (-> 15/20), **Then** the indicator transitions directly to healthy (skips bloodied since 15 > 10).
**Story HP-6 — HP Persists Across Reloads (P2)**
As a game master, I want HP values to survive page reloads so that I do not lose health tracking mid-session.
Acceptance scenarios:
1. **Given** a combatant has max HP 30 and current HP 18, **When** the page is reloaded, **Then** both values are restored exactly.
### Requirements
- **FR-001**: Each combatant MAY have an optional `maxHp` value (positive integer >= 1). HP tracking is optional per combatant.
- **FR-002**: When `maxHp` is first set, `currentHp` MUST default to `maxHp`.
- **FR-003**: `currentHp` MUST be clamped to [0, `maxHp`] at all times.
- **FR-004**: The system MUST provide an inline HP delta input per combatant (hidden behind a click-to-open popover on the current HP value).
- **FR-005**: The HP popover MUST contain a single auto-focused numeric input and Damage and Heal action buttons.
- **FR-006**: Pressing Enter in the popover MUST apply the entered value as damage; Shift+Enter MUST apply it as healing; Escape MUST dismiss without change; clicking outside MUST dismiss without change.
- **FR-007**: When a damage value is confirmed, the system MUST subtract the entered amount from `currentHp`, clamping to 0.
- **FR-008**: When a healing value is confirmed, the system MUST add the entered amount to `currentHp`, clamping to `maxHp`.
- **FR-009**: After any delta is applied, the input MUST clear automatically.
- **FR-010**: The delta input MUST only accept positive integers. Zero, negative, and non-numeric values MUST be rejected.
- **FR-011**: Direct editing of the absolute `currentHp` value MUST remain available alongside the delta input.
- **FR-012**: `maxHp` MUST display as compact static text with click-to-edit. The value is committed on Enter or blur; Escape cancels. Intermediate editing (clearing the field to retype) MUST NOT affect `currentHp` until committed.
- **FR-013**: When `maxHp` is reduced below `currentHp`, `currentHp` MUST be clamped to the new `maxHp`.
- **FR-014**: When `maxHp` increases and the combatant was at full health, `currentHp` MUST increase to match the new `maxHp`.
- **FR-015**: When `maxHp` increases and the combatant was NOT at full health, `currentHp` MUST remain unchanged (unless clamped by FR-013).
- **FR-016**: `maxHp` MUST reject zero, negative, and non-integer values.
- **FR-017**: HP values MUST persist across page reloads via the existing persistence mechanism.
- **FR-018**: The HP status MUST be derived as a pure domain computation: `healthy` (currentHp >= maxHp / 2), `bloodied` (0 < currentHp < maxHp / 2), `unconscious` (currentHp <= 0). The status is not stored — computed on demand.
- **FR-019**: The HP area MUST display the bloodied color treatment (amber) on the current HP value when status is `bloodied`.
- **FR-020**: The HP area MUST display the unconscious color treatment (red) and the combatant row MUST appear visually muted when status is `unconscious`.
- **FR-021**: Status indicators MUST NOT be shown when `maxHp` is not set.
- **FR-022**: Visual status indicators MUST update within the same interaction frame as the HP change — no perceptible delay.
### Edge Cases
- `maxHp` of 1: at 1/1 the combatant is healthy; at 0/1 unconscious. No bloodied state is possible.
- `maxHp` of 2: at 1/2 the combatant is healthy (1 is not strictly less than 1); at 0/2 unconscious.
- When `maxHp` is cleared, `currentHp` is also cleared; the combatant returns to the no-HP state.
- Entering a non-numeric value in any HP field is rejected; the previous value is preserved.
- Entering a very large number (e.g., 99999) is applied normally; clamping prevents invalid state.
- Submitting an empty delta input applies no change; the input remains ready.
- When the user rapidly applies multiple deltas, each is applied sequentially; none are lost.
- HP tracking is entirely absent for combatants with no `maxHp` set — no HP controls are shown.
- There is no temporary HP in the MVP baseline.
- There is no death/unconscious game mechanic triggered at 0 HP; the system displays the state only.
- There is no damage type tracking, resistance/vulnerability calculation, or hit log in the MVP baseline.
- There is no undo/redo for HP changes in the MVP baseline.
---
## Armor Class
### User Stories
**Story AC-1 — Set and Display AC (P1)**
As a game master, I want to assign an Armor Class value to a combatant and see it displayed as a shield shape so I can reference it at a glance during combat.
Acceptance scenarios:
1. **Given** a combatant exists, **When** the user clicks the AC shield area and enters 17, **Then** the combatant's AC is stored and the shield shape displays "17".
2. **Given** a combatant with AC 15, **When** viewing the row, **Then** the AC number is displayed inside a shield-shaped outline (not a separate icon + number).
3. **Given** a combatant with no AC set, **When** viewing the row, **Then** the shield shape is shown in an empty/placeholder state.
4. **Given** multiple combatants with different AC values, **When** viewing the encounter list, **Then** each displays its own correct AC.
**Story AC-2 — Edit AC (P2)**
As a game master, I want to edit an existing combatant's AC inline so I can correct or update it without navigating away.
Acceptance scenarios:
1. **Given** a combatant with AC 15, **When** the user clicks the shield, **Then** an inline input appears pre-filled with 15 and selected.
2. **Given** the inline AC edit is active, **When** the user types 18 and presses Enter, **Then** AC updates to 18 and the display returns to static mode.
3. **Given** the inline AC edit is active, **When** the user blurs, **Then** AC is committed and the display returns to static mode.
4. **Given** the inline AC edit is active, **When** the user presses Escape, **Then** the edit is cancelled and the original value is preserved.
5. **Given** the inline AC edit is active, **When** the user clears the field and presses Enter, **Then** AC is unset and the shield shows an empty state.
### Requirements
- **FR-023**: Each combatant MAY have an optional `ac` value, a non-negative integer (>= 0).
- **FR-024**: AC MUST be displayed inside a shield-shaped visual element. The separate shield icon is replaced by the shield shape itself.
- **FR-025**: The shield shape MUST be shown in all cases (set, unset/empty state). No AC value means an empty-state shield, not hidden.
- **FR-026**: Clicking the shield MUST open an inline edit input with the current value pre-filled and selected.
- **FR-027**: The inline AC edit MUST commit on Enter or blur and cancel on Escape.
- **FR-028**: Clearing the AC field and confirming MUST unset AC.
- **FR-029**: AC MUST reject negative values. Zero is a valid AC.
- **FR-030**: AC values MUST persist via the existing persistence mechanism.
- **FR-031**: The AC shield MUST scale appropriately for single-digit, double-digit, and any valid AC values.
### Edge Cases
- AC 0 is valid and MUST be displayed.
- Negative AC is not accepted; the input is rejected.
- MVP baseline does not include AC-based calculations (to-hit comparisons, conditional formatting based on AC thresholds).
---
## Conditions & Concentration
### User Stories
**Story CC-1 — Add a Condition (P1)**
As a DM running an encounter, I want to quickly apply a condition to a combatant so I can track status effects during combat.
Acceptance scenarios:
1. **Given** a combatant row is not hovered and has no conditions, **Then** no condition UI is visible.
2. **Given** a combatant row is hovered, **When** no conditions are active, **Then** a "+" button appears inline after the creature name.
3. **Given** the "+" button is visible, **When** the user clicks it, **Then** a compact condition picker opens showing all 15 conditions as icon + label pairs.
4. **Given** the picker is open, **When** the user clicks a condition, **Then** it is toggled on and its icon appears inline after the creature name.
5. **Given** the picker is open with active conditions already marked, **When** viewing the picker, **Then** active conditions are visually distinguished from inactive ones.
**Story CC-2 — Remove a Condition (P1)**
As a DM, I want to remove a condition from a combatant when the effect ends so the tracker stays accurate.
Acceptance scenarios:
1. **Given** a combatant has active conditions, **When** the user clicks an active condition icon tag inline, **Then** the condition is removed and the icon disappears.
2. **Given** the condition picker is open, **When** the user clicks an active condition, **Then** it is toggled off and removed from the row.
3. **Given** a combatant with one condition and it is removed, **Then** only the hover-revealed "+" button remains.
**Story CC-3 — View Condition Name via Tooltip (P2)**
As a DM, I want to hover over a condition icon to see its name so I can identify conditions without memorizing icons.
Acceptance scenarios:
1. **Given** a combatant has an active condition, **When** the user hovers over its icon, **Then** a tooltip shows the condition name (e.g., "Blinded").
2. **Given** the user moves the cursor away from the icon, **Then** the tooltip disappears.
**Story CC-4 — Multiple Conditions (P2)**
As a DM, I want to apply multiple conditions to a single combatant so I can track complex combat situations.
Acceptance scenarios:
1. **Given** a combatant with one condition, **When** another is added, **Then** both icons appear inline.
2. **Given** a combatant with many conditions, **When** viewing the row, **Then** icons wrap within the name column without increasing row width; row height may increase.
3. **Given** "poisoned" was applied first and "blinded" second, **When** viewing the row, **Then** "blinded" appears before "poisoned" (fixed definition order, not insertion order).
**Story CC-5 — Toggle Concentration (P1)**
As a DM, I want to mark a combatant as concentrating on a spell by clicking a Brain icon in the row gutter so I can track spells requiring concentration.
Acceptance scenarios:
1. **Given** a combatant row is not hovered and concentration is inactive, **Then** the Brain icon is hidden.
2. **Given** a combatant row is hovered and concentration is inactive, **Then** the Brain icon appears in a muted/faded style.
3. **Given** the Brain icon is visible, **When** the user clicks it, **Then** concentration activates and the icon remains visible with an active style.
4. **Given** concentration is active and the row is not hovered, **Then** the Brain icon remains visible.
5. **Given** concentration is active, **When** the user clicks the Brain icon again, **Then** concentration deactivates and the icon hides (unless the row is still hovered).
6. **Given** the Brain icon is visible, **When** the user hovers over it, **Then** a tooltip reading "Concentrating" appears.
**Story CC-6 — Visual Feedback for Concentration (P2)**
As a DM, I want concentrating combatants to have a visible row accent so I can identify them at a glance without interacting.
Acceptance scenarios:
1. **Given** concentration is active, **When** viewing the encounter tracker, **Then** the combatant row shows a colored left border accent (`border-l-purple-400`).
2. **Given** concentration is inactive, **Then** no concentration accent is shown.
3. **Given** concentration is toggled off, **Then** the left border accent disappears immediately.
**Story CC-7 — Damage Pulse Alert (P3)**
As a DM, I want a visual alert when a concentrating combatant takes damage so I remember to call for a concentration check.
Acceptance scenarios:
1. **Given** a combatant is concentrating, **When** the combatant takes damage (HP reduced), **Then** the Brain icon and row accent briefly pulse/flash for 700 ms.
2. **Given** a combatant is concentrating, **When** the combatant is healed, **Then** no pulse/flash occurs.
3. **Given** a combatant is NOT concentrating, **When** damage is taken, **Then** no pulse/flash occurs.
4. **Given** a concentrating combatant takes damage, **When** the animation completes, **Then** the row returns to its normal concentration-active appearance.
### Requirements
- **FR-032**: The MVP MUST support the following 15 standard D&D 5e conditions: blinded, charmed, deafened, exhaustion, frightened, grappled, incapacitated, invisible, paralyzed, petrified, poisoned, prone, restrained, stunned, unconscious.
- **FR-033**: Each condition MUST have a fixed icon and color mapping (Lucide icons; no emoji):
| Condition | Icon | Color |
|---------------|------------|---------|
| Blinded | EyeOff | neutral |
| Charmed | Heart | pink |
| Deafened | EarOff | neutral |
| Exhaustion | BatteryLow | amber |
| Frightened | Siren | orange |
| Grappled | Hand | neutral |
| Incapacitated | Ban | gray |
| Invisible | Ghost | violet |
| Paralyzed | ZapOff | yellow |
| Petrified | Gem | slate |
| Poisoned | Droplet | green |
| Prone | ArrowDown | neutral |
| Restrained | Link | neutral |
| Stunned | Sparkles | yellow |
| Unconscious | Moon | indigo |
- **FR-034**: Active condition icons MUST appear inline after the creature name within the same row, not on a separate line.
- **FR-035**: Conditions MUST be displayed in the fixed definition order (blinded -> unconscious), regardless of application order.
- **FR-036**: The "+" condition button MUST be hidden by default and appear only on row hover (or touch/focus on touch devices).
- **FR-037**: Clicking "+" MUST open the compact condition picker showing all conditions as icon + label pairs.
- **FR-038**: Clicking a condition in the picker MUST toggle it on or off.
- **FR-039**: Clicking an active condition icon tag in the row MUST remove that condition.
- **FR-040**: Hovering an active condition icon MUST show a tooltip with the condition name.
- **FR-041**: Condition icons MUST NOT increase the row's width; row height MAY increase to accommodate wrapping.
- **FR-042**: The condition picker MUST close when the user clicks outside of it.
- **FR-043**: Conditions MUST persist as part of combatant state (surviving page reload).
- **FR-044**: The condition data model MUST be extensible for future additions (e.g., mechanical effects, descriptions).
- **FR-045**: `isConcentrating` MUST be stored as an optional boolean on the combatant, separate from the `conditions` array.
- **FR-046**: Concentration MUST NOT appear in or interact with the condition tag system.
- **FR-047**: The Brain icon toggle MUST be hidden at row rest and revealed on hover (same hover pattern as the "+" button).
- **FR-048**: The Brain icon MUST remain visible whenever concentration is active, regardless of hover state.
- **FR-049**: A tooltip reading "Concentrating" MUST appear when hovering the Brain icon.
- **FR-050**: The active Brain icon MUST use `text-purple-400`; the inactive (hover-revealed) Brain icon MUST use a muted style (`text-muted-foreground opacity-50`).
- **FR-051**: The concentration left border accent MUST use `border-l-purple-400`.
- **FR-052**: The concentration toggle's clickable area MUST extend to fill the full gutter between the left border and the initiative column.
- **FR-053**: When a concentrating combatant takes damage, the Brain icon and row accent MUST briefly pulse/flash for 700 ms.
- **FR-054**: The pulse animation MUST NOT trigger on healing or when concentration is inactive.
- **FR-055**: Concentration MUST persist across page reloads via existing storage.
### Edge Cases
- When all 15 conditions are applied, icons wrap within the row; row height increases but width does not.
- When a combatant is removed, all its conditions and concentration state are discarded.
- When the picker is open and the user clicks outside, it closes.
- When a condition is toggled on then immediately off in the picker, it does not appear in the row.
- When concentration is toggled during an active pulse animation, the animation cancels and the new state applies immediately.
- Multiple combatants may concentrate simultaneously; concentration is independent per combatant.
- Conditions have no mechanical effects in the MVP baseline (no auto-disadvantage, no automation).
---
## Initiative
### User Stories
**Story INI-1 — Set Initiative Value (P1)**
As a game master running an encounter, I want to assign an initiative value to a combatant so that the encounter's turn order reflects each combatant's rolled initiative.
Acceptance scenarios:
1. **Given** a combatant with no initiative set, **When** the user sets initiative to 15, **Then** the combatant has initiative value 15.
2. **Given** a combatant with initiative 15, **When** the user changes initiative to 8, **Then** the combatant has initiative value 8.
3. **Given** a combatant with no initiative set, **When** the user attempts to set a non-integer value, **Then** the system rejects the input and initiative remains unset.
4. **Given** a combatant with initiative 15, **When** the user clears initiative, **Then** the initiative is unset and the combatant moves to the end of the turn order.
5. **Given** a combatant has an initiative value displayed as plain text, **When** the user clicks it, **Then** an inline editor opens to change or clear it.
**Story INI-2 — Roll Initiative for a Single Combatant (P1)**
As a DM, I want to click a d20 icon next to a combatant's initiative slot to randomly roll initiative (1d20 + initiative modifier) so the result is immediately placed into the initiative field and the tracker re-sorts.
Acceptance scenarios:
1. **Given** a combatant linked to a bestiary creature (e.g., Aboleth, initiative modifier +7) with no initiative, **When** the user clicks the d20 icon, **Then** a random value in the range [8, 27] is stored as initiative and the list re-sorts descending.
2. **Given** a combatant NOT linked to a bestiary creature, **When** viewing the row, **Then** the initiative slot shows "--" (clickable to type a value manually) — no d20 button.
3. **Given** a combatant whose initiative modifier is negative (e.g., -2), **When** the d20 button is clicked, **Then** the result ranges from -1 to 18.
4. **Given** a combatant already has an initiative value, **Then** the d20 button is replaced by the value as plain text; clicking it opens the inline editor.
**Story INI-3 — Roll Initiative for All Eligible Combatants (P2)**
As a DM, I want a "Roll All Initiative" button in the top bar to roll for all bestiary combatants at once so I can set up the initiative order quickly at the start of combat.
Acceptance scenarios:
1. **Given** 3 bestiary combatants (no initiative) and 1 manual combatant, **When** the roll-all button is clicked, **Then** all 3 bestiary combatants receive rolled initiative values; the manual combatant is unchanged.
2. **Given** bestiary combatants that already have initiative values, **When** the roll-all button is clicked, **Then** those combatants are skipped; only bestiary combatants without initiative are rolled.
3. **Given** no bestiary combatants, **When** the roll-all button is clicked, **Then** no changes occur.
**Story INI-4 — Display Initiative Modifier in Stat Block (P1)**
As a DM viewing a creature's stat block, I want to see the creature's initiative modifier and passive initiative so I can reference it when rolling.
Acceptance scenarios:
1. **Given** a creature with DEX 9, CR 10, initiative proficiency multiplier 2 (e.g., Aboleth), **When** the stat block is displayed, **Then** the initiative line shows "Initiative +7 (17)" (-1 + 2x4 = +7; passive = 17).
2. **Given** a creature with proficiency multiplier 1, **Then** the initiative modifier includes 1x the proficiency bonus.
3. **Given** a creature with no `initiative` field in bestiary data, **Then** only the DEX modifier is shown (e.g., DEX 14 -> "Initiative +2 (12)").
4. **Given** a creature with a negative initiative modifier (e.g., DEX 8, no proficiency), **Then** the line uses a minus sign (e.g., "Initiative -1 (9)").
5. **Given** a combatant without bestiary data, **Then** no initiative line is shown in the stat block.
**Story INI-5 — Combatants Without Initiative (P2)**
As a game master, I want combatants without initiative set to appear at the end of the turn order so the encounter remains usable while I am still entering values.
Acceptance scenarios:
1. **Given** combatants A (initiative 15), B (no initiative), C (initiative 10), **Then** order is A, C, B.
2. **Given** A (no initiative) and B (no initiative), **Then** their relative order is preserved from when they were added.
3. **Given** combatant A (no initiative), **When** initiative is set to 12, **Then** A moves to its correct sorted position.
### Requirements
- **FR-056**: System MUST allow setting an integer initiative value for any combatant.
- **FR-057**: System MUST allow changing an existing initiative value.
- **FR-058**: System MUST allow clearing (unsetting) a combatant's initiative value.
- **FR-059**: System MUST reject non-integer initiative values and return a domain error.
- **FR-060**: System MUST accept zero and negative integers as valid initiative values.
- **FR-061**: System MUST automatically reorder combatants highest-to-lowest initiative whenever a value is set, changed, or cleared.
- **FR-062**: Combatants without initiative MUST be placed after all combatants with initiative values.
- **FR-063**: System MUST use a stable sort so combatants with equal initiative (or multiple without initiative) retain their relative order.
- **FR-064**: System MUST preserve the active combatant's turn through reorders — the active turn tracks combatant identity, not list position.
- **FR-065**: System MUST emit a domain event when a combatant's initiative is set or changed.
- **FR-066**: System MUST display a d20 icon button in the initiative slot for every combatant that has a linked bestiary creature and does not yet have an initiative value.
- **FR-067**: System MUST NOT display the d20 button for combatants without a linked bestiary creature; they see a "--" placeholder that is clickable to type a value.
- **FR-068**: When the d20 button is clicked, the system MUST generate a uniform random integer in [1, 20], add the creature's initiative modifier, and set the result as the initiative value.
- **FR-069**: The initiative modifier MUST be calculated as: DEX modifier + (proficiency multiplier x proficiency bonus), where DEX modifier = floor((DEX - 10) / 2) and proficiency bonus is derived from challenge rating.
- **FR-070**: The initiative proficiency multiplier MUST be read from `initiative.proficiency` in bestiary data (0 if absent, 1 for proficiency, 2 for expertise).
- **FR-071**: System MUST provide a "Roll All Initiative" button in the top bar. Clicking it MUST roll for every bestiary-linked combatant that does not already have an initiative value; all others MUST be left unchanged.
- **FR-072**: After any initiative roll (single or batch), the encounter list MUST re-sort descending per existing behavior.
- **FR-073**: Once a combatant has an initiative value, the d20 button is replaced by the value as plain text using a click-to-edit pattern (consistent with AC and name editing).
- **FR-074**: The stat block header MUST display initiative in the format "Initiative +X (Y)" where X is the modifier (with + or - sign) and Y = 10 + X.
- **FR-075**: The initiative display in the stat block MUST be positioned adjacent to the AC value, matching Monster Manual 2024 layout.
- **FR-076**: No initiative line MUST be shown in the stat block for combatants without bestiary data.
- **FR-077**: The initiative display in the stat block is display-only. It MUST NOT modify encounter turn order or trigger rolling automatically.
- **FR-078**: The d20 roll-initiative icon MUST be displayed larger than 20x20 px while remaining contained within the initiative column.
- **FR-079**: The "Roll All Initiative" d20 in the top bar MUST be sized at 24 px; the clear-encounter (trash) icon MUST be sized at 20 px. Both are visually grouped together, separated from turn navigation controls by spacing.
### Edge Cases
- Setting initiative to zero is valid and treated normally in sorting.
- Negative initiative values are valid (some game systems use them).
- When a combatant is added without initiative during an ongoing encounter, it appears at the end of the order.
- When all combatants have the same initiative value, their insertion order is preserved.
- When the active combatant's own initiative changes causing a reorder, the active turn still points to that combatant.
- When another combatant's initiative changes causing a reorder, the active turn still points to the current active combatant.
- When a combatant's initiative modifier produces a roll result of 0 or negative, the value is stored as-is.
- When multiple combatants roll the same initiative, ties are resolved by preserving relative insertion order.
- A minus sign is used for negative modifiers in the stat block display, not a hyphen.
- When a creature has DEX producing a modifier of exactly 0 and no initiative proficiency, display "Initiative +0 (10)".
- For manually-added combatants: no initiative modifier is available, so no d20 button and no stat block initiative line.
- The passive initiative value shown in the stat block is reference-only; only the active modifier (+X) is used for rolling.
- Random number generation for dice rolls uses standard browser randomness; cryptographic randomness is not required.
---
## Combatant Row Layout
### User Stories
**Story ROW-1 — Compact Resting State (P1)**
As a DM, I want each combatant row to display a minimal, uncluttered view at rest so I can scan the encounter list quickly during play.
Acceptance scenarios:
1. **Given** a combatant row is not hovered, **Then** no delta input, action buttons, "+" condition button, remove button, or Brain icon are visible.
2. **Given** a combatant has no conditions, **Then** the row occupies exactly one line height at rest.
3. **Given** a combatant has conditions applied, **Then** condition icons appear inline after the creature name on the same row (not a separate line).
**Story ROW-2 — Hover Reveals Controls (P1)**
As a DM, I want secondary controls to appear when I hover over a row so they are accessible without cluttering the resting view.
Acceptance scenarios:
1. **Given** any combatant row, **When** hovered, **Then** the "+" condition button appears inline after the name/conditions.
2. **Given** any combatant row, **When** hovered, **Then** the remove (x) button becomes visible.
3. **Given** any combatant row, **When** hovered and concentration is inactive, **Then** the Brain icon becomes visible.
4. **Given** the remove button appears on hover, **Then** no layout shift occurs — space is reserved.
**Story ROW-3 — Row Click Opens Stat Block (P1)**
As a DM, I want to click anywhere on a bestiary combatant row to open its stat block so I have a large click target and a cleaner row without a dedicated book icon.
Acceptance scenarios:
1. **Given** a combatant has a linked bestiary creature, **When** the user clicks the name text or empty row space, **Then** the stat block panel opens.
2. **Given** the user clicks an interactive element (initiative, HP, AC, condition icon, "+", "x", concentration), **Then** the stat block does NOT open — the element's own action fires.
3. **Given** a combatant does NOT have a linked creature, **When** the row is clicked, **Then** nothing happens.
4. **Given** viewing any bestiary combatant row, **Then** no BookOpen icon is visible.
5. **Given** a bestiary combatant row, **When** the user hovers over non-interactive areas, **Then** the cursor indicates clickability.
6. **Given** the stat block is already open for a creature, **When** the same row is clicked, **Then** the panel closes (toggle behavior).
### Requirements
- **FR-080**: Condition icons MUST render inline after the creature name within the same row.
- **FR-081**: The "+" condition button MUST be hidden at rest and appear on row hover (or touch/focus on touch devices).
- **FR-082**: The remove (x) button MUST be hidden at rest and appear on row hover (or touch/focus on touch devices).
- **FR-083**: Layout space for the remove button MUST be reserved so appearing/disappearing does not cause layout shifts.
- **FR-084**: Clicking non-interactive areas of a bestiary combatant row MUST open the stat block panel.
- **FR-085**: Clicking interactive elements (initiative, HP, AC, conditions, "+", "x", concentration) MUST NOT trigger the stat block — only the element's own action.
- **FR-086**: The BookOpen icon MUST be removed from the combatant row.
- **FR-087**: Bestiary combatant rows MUST show a pointer cursor on hover over non-interactive areas.
- **FR-088**: All existing interactions (condition add/remove, HP adjustment, AC editing, initiative editing/rolling, concentration toggle, combatant removal) MUST continue to work.
- **FR-089**: Browser scrollbars MUST be styled to match the dark UI theme (thin, dark-colored scrollbar thumbs).
- **FR-090**: Turn navigation (Previous/Next) MUST use StepBack/StepForward icons in outline button style with foreground-colored borders. Utility actions (d20/trash) MUST use ghost button style to create clear visual hierarchy.
- **FR-091**: Previous and Next turn buttons MUST be positioned at the far left and far right of the top bar respectively, with the round/combatant info centered between them.
- **FR-092**: The "Initiative Tracker" heading MUST be removed to maximize vertical space for combatants.
- **FR-093**: All interactive elements in the row MUST remain keyboard-accessible (focusable and operable via keyboard).
- **FR-094**: On touch devices, hover-only controls ("+" button, "x" button) MUST remain accessible via tap or focus.
### Edge Cases
- When a combatant has so many conditions that they exceed the available inline space, they wrap within the name column; row height increases but width does not.
- The condition picker dropdown positions relative to the "+" button, flipping vertically if near the viewport edge.
- When the stat block panel is already open and the user clicks the same row again, the panel closes.
- Clicking the initiative area starts editing; it does not open the stat block.
- Tablet-width screens (>= 768 px / Tailwind `md`): popovers and inline edits MUST remain accessible and not overflow or clip.
---
## Success Criteria *(mandatory)*
- **SC-001**: Users can set max HP and adjust current HP for any combatant in under 5 seconds per action.
- **SC-002**: `currentHp` never exceeds `maxHp` or drops below 0, regardless of input method.
- **SC-003**: HP values survive a full page reload without data loss.
- **SC-004**: A user can apply damage to a combatant in 2 interactions or fewer (click HP -> type number -> Enter).
- **SC-005**: A user can apply healing in 3 interactions or fewer (click HP -> type number -> click Heal).
- **SC-006**: Users can identify bloodied combatants at a glance without reading HP numbers — 100% of combatants below half HP display a distinct amber treatment.
- **SC-007**: Users can identify unconscious/dead combatants at a glance — 100% of combatants at 0 HP or below display a distinct red treatment that differs from the bloodied indicator.
- **SC-008**: Visual status indicators update within the same interaction frame as the HP change.
- **SC-009**: Users can set an AC value for any combatant within the existing edit workflow with no additional steps.
- **SC-010**: AC is visible at a glance in the encounter list without expanding or hovering.
- **SC-011**: A condition can be added to a combatant in 2 clicks or fewer (click "+", click condition).
- **SC-012**: A condition can be removed in 1 click (click the active icon tag).
- **SC-013**: All 15 D&D 5e conditions are available and visually distinguishable by icon and color.
- **SC-014**: Condition state survives a full page reload without data loss.
- **SC-015**: Users can toggle concentration on/off for any combatant in a single click.
- **SC-016**: Concentrating combatants are visually distinguishable from non-concentrating combatants at a glance.
- **SC-017**: When a concentrating combatant takes damage, the visual pulse alert draws attention within the same interaction flow.
- **SC-018**: Concentration state survives a full page reload.
- **SC-019**: Users can set initiative for any combatant in a single action.
- **SC-020**: After any initiative change, the encounter list immediately reflects the correct descending sort.
- **SC-021**: A single combatant's initiative can be rolled with one click (the d20 button).
- **SC-022**: All eligible combatants' initiative can be rolled with one click (roll-all button).
- **SC-023**: Manual combatants (no stat block) are never affected by roll actions.
- **SC-024**: Every bestiary creature displays an initiative value in its stat block matching D&D Beyond / Monster Manual 2024.
- **SC-025**: The initiative line is visible without scrolling in the stat block header.
- **SC-026**: Each combatant row without conditions takes up exactly one line height at rest.
- **SC-027**: The DM can open a stat block by clicking anywhere on the combatant name area without needing a dedicated icon.
- **SC-028**: The AC number is visually identifiable as armor class through the shield shape alone.
- **SC-029**: No layout shift occurs when hovering/unhovering rows.
- **SC-030**: All HP, AC, initiative, condition, and concentration interactions remain fully operable using only a keyboard.

View File

@@ -1,34 +0,0 @@
# Specification Quality Checklist: Remove Combatant
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-03
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.

View File

@@ -1,69 +0,0 @@
# Data Model: Remove Combatant
**Feature**: 003-remove-combatant
**Date**: 2026-03-03
## Existing Entities (no changes)
### Encounter
| Field | Type | Description |
|-------|------|-------------|
| combatants | readonly Combatant[] | Ordered list of participants |
| activeIndex | number | Index of the combatant whose turn it is |
| roundNumber | number | Current round (≥ 1, never changes on removal) |
### Combatant
| Field | Type | Description |
|-------|------|-------------|
| id | CombatantId (branded string) | Unique identifier |
| name | string | Display name |
## New Event Type
### CombatantRemoved
| Field | Type | Description |
|-------|------|-------------|
| type | "CombatantRemoved" (literal) | Event discriminant |
| combatantId | CombatantId | ID of the removed combatant |
| name | string | Name of the removed combatant |
Added to the `DomainEvent` discriminated union alongside `TurnAdvanced`, `RoundAdvanced`, and `CombatantAdded`.
## New Domain Function
### removeCombatant
| Parameter | Type | Description |
|-----------|------|-------------|
| encounter | Encounter | Current encounter state |
| id | CombatantId | ID of combatant to remove |
**Returns**: `RemoveCombatantSuccess | DomainError`
### RemoveCombatantSuccess
| Field | Type | Description |
|-------|------|-------------|
| encounter | Encounter | Updated encounter after removal |
| events | DomainEvent[] | Exactly one CombatantRemoved event |
### DomainError (existing, reused)
Returned with code `"combatant-not-found"` when ID does not match any combatant.
## State Transition Rules
### activeIndex Adjustment
Given removal of combatant at index `removedIdx` with current `activeIndex`:
| Condition | New activeIndex |
|-----------|----------------|
| removedIdx > activeIndex | activeIndex (unchanged) |
| removedIdx < activeIndex | activeIndex - 1 |
| removedIdx === activeIndex, not last in list | activeIndex (next slides in) |
| removedIdx === activeIndex, last in list | 0 (wrap) |
| Only combatant removed (list becomes empty) | 0 |

View File

@@ -1,71 +0,0 @@
# Implementation Plan: Remove Combatant
**Branch**: `003-remove-combatant` | **Date**: 2026-03-03 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/003-remove-combatant/spec.md`
## Summary
Add a `removeCombatant` pure domain function that removes a combatant by ID from an Encounter, correctly adjusts `activeIndex` to preserve turn integrity, keeps `roundNumber` unchanged, and emits a `CombatantRemoved` event. Wire through an application-layer use case and expose via a minimal UI remove action per combatant.
## Technical Context
**Language/Version**: TypeScript 5.x (strict mode, verbatimModuleSyntax)
**Primary Dependencies**: React 19, Vite
**Storage**: In-memory React state (local-first, single-user MVP)
**Testing**: Vitest
**Target Platform**: Web (localhost:5173 dev, production build via Vite)
**Project Type**: Web application (monorepo: packages/domain, packages/application, apps/web)
**Performance Goals**: N/A (local-first, small data sets)
**Constraints**: Domain must be pure (no I/O); layer boundaries enforced by automated script
**Scale/Scope**: Single-user, single encounter at a time
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Status | Evidence |
|-----------|--------|----------|
| I. Deterministic Domain Core | PASS | `removeCombatant` is a pure function: same input → same output, no I/O |
| II. Layered Architecture | PASS | Domain function → use case → React hook/UI. No layer violations. |
| III. Agent Boundary | N/A | No agent layer involved in this feature |
| IV. Clarification-First | PASS | Spec fully specifies all activeIndex adjustment rules; no ambiguity |
| V. Escalation Gates | PASS | All functionality is within spec scope |
| VI. MVP Baseline Language | PASS | No permanent bans introduced |
| VII. No Gameplay Rules | PASS | Removal is encounter management, not gameplay mechanics |
**Gate result**: PASS — no violations.
## Project Structure
### Documentation (this feature)
```text
specs/003-remove-combatant/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
└── tasks.md
```
### Source Code (repository root)
```text
packages/domain/src/
├── remove-combatant.ts # Pure domain function
├── events.ts # Add CombatantRemoved to DomainEvent union
├── types.ts # Existing types (no changes expected)
├── index.ts # Re-export removeCombatant
└── __tests__/
└── remove-combatant.test.ts # Acceptance scenarios from spec
packages/application/src/
├── remove-combatant-use-case.ts # Orchestrates store.get → domain → store.save
└── index.ts # Re-export use case
apps/web/src/
├── hooks/use-encounter.ts # Add removeCombatant callback
└── App.tsx # Add remove button per combatant + event display
```
**Structure Decision**: Follows the existing monorepo layered architecture (packages/domain → packages/application → apps/web) exactly mirroring the addCombatant feature's file layout.

View File

@@ -1,39 +0,0 @@
# Quickstart: Remove Combatant
**Feature**: 003-remove-combatant
## Prerequisites
- Node.js 18+, pnpm
- Repository cloned, `pnpm install` run
## Development
```bash
git checkout 003-remove-combatant
pnpm test:watch # Run tests in watch mode during development
pnpm --filter web dev # Dev server at localhost:5173
```
## Verification
```bash
pnpm check # Must pass before commit (format + lint + typecheck + test)
```
## Implementation Order
1. **Domain**: Add `CombatantRemoved` event type → implement `removeCombatant` pure function → tests
2. **Application**: Add `removeCombatantUseCase` → re-export
3. **Web**: Add `removeCombatant` to `useEncounter` hook → add remove button in `App.tsx`
## Key Files
| Layer | File | Purpose |
|-------|------|---------|
| Domain | `packages/domain/src/remove-combatant.ts` | Pure removal function |
| Domain | `packages/domain/src/events.ts` | CombatantRemoved event type |
| Domain | `packages/domain/src/__tests__/remove-combatant.test.ts` | Acceptance tests |
| Application | `packages/application/src/remove-combatant-use-case.ts` | Use case orchestration |
| Web | `apps/web/src/hooks/use-encounter.ts` | Hook integration |
| Web | `apps/web/src/App.tsx` | UI remove button |

Some files were not shown because too many files have changed in this diff Show More