57 Commits

Author SHA1 Message Date
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
Lukas
99d1ba1bcd Implement the 028-semantic-hover-tokens feature that unifies hover colors across all interactive UI components via six CSS custom property tokens (three text, three background) defined in the Tailwind v4 theme, replacing hardcoded hover classes in 9 component files plus the shared Button primitive with semantic token references so all hover colors can be globally reconfigured from one place
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 19:50:22 +01:00
Lukas
f029c1a85b Implement the 027-ui-polish feature that adds six combatant row improvements (inline conditions, row-click stat block, hover-only remove button, AC shield shape, expanded concentration click target, larger d20 icon) plus top bar redesign with icon-only StepBack/StepForward navigation buttons, dark-themed scrollbars, and multiple UX fixes including stat block panel stability during initiative rolls and mobile touch safety for hidden buttons
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 19:00:49 +01:00
Lukas
d5f7b6ee36 Implement the 026-roll-initiative feature that adds d20 roll buttons for bestiary combatants' initiative using a click-to-edit pattern (d20 icon when empty, plain text when set), plus a Roll All button in the top bar that batch-rolls for all unrolled bestiary combatants, with randomness confined to the adapter layer and the domain receiving pre-resolved dice values
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:29:09 +01:00
Lukas
5b0bac880d Implement the 025-display-initiative feature that adds initiative modifier and passive initiative display to creature stat blocks, calculated as DEX modifier + (proficiency multiplier × proficiency bonus) from bestiary data, shown in MM 2024 format on the AC line
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:27:46 +01:00
Lukas
c6349928eb Implement the 024-fix-hp-popover-overflow feature that switches the HP adjustment popover from absolute to fixed positioning with viewport-aware clamping so it stays fully visible and causes no horizontal scrollbar, even when the HP display is near the right edge of the viewport
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:50:39 +01:00
Lukas
24198c25f1 Implement the 023-clear-encounter feature that adds a clear encounter button with confirmation dialog to remove all combatants and reset round/turn counters, with the cleared state persisting across page refreshes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 13:43:42 +01:00
Lukas
11c4c0237e Implement the 022-fixed-layout-bars feature that pins turn navigation to the top and add-creature bar to the bottom of the encounter tracker with only the combatant list scrolling between them, and auto-scrolls to the active combatant on turn change
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:36:59 +01:00
Lukas
fa078be2f9 Implement the 021-bestiary-statblock feature that adds a searchable D&D 2024 Monster Manual creature library with inline autocomplete suggestions, full stat block display in a fixed side panel, auto-numbering of duplicate creature names, HP/AC pre-fill from bestiary data, and automatic stat block display on turn change for wide viewports
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:01:07 +01:00
Lukas
04a4f18f98 Implement the 020-fix-zero-hp-opacity feature that replaces container-level opacity dimming with element-level opacity on individual leaf elements so that HP popover and condition picker render at full opacity for unconscious combatants
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:43:27 +01:00
Lukas
0c0da9b90e Implement the 019-combatant-row-declutter feature that replaces always-visible HP controls and AC/MaxHP inputs with compact click-to-edit and click-to-adjust patterns in the encounter tracker
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:07:04 +01:00
Lukas
e59fd83292 Implement the 018-combatant-concentration feature that adds a per-combatant concentration toggle with Brain icon, purple border accent, and damage pulse animation in the encounter tracker
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:41:59 +01:00
Lukas
febe892e15 Implement the 017-combat-conditions feature that adds D&D 5e status conditions to combatants with icon tags, color coding, and a compact toggle picker in the encounter tracker
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:29:39 +01:00
Lukas
78c6591973 Implement the 016-combatant-ac feature that adds an optional Armor Class field to combatants with shield icon display and inline editing in the encounter tracker
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:41:56 +01:00
Lukas
2793a66672 Implement the 015-add-jscpd-gate feature that adds copy-paste detection to the quality gate using jscpd with a 5% duplication threshold integrated into pnpm check
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 00:13:32 +01:00
Lukas
56bced8481 Implement the 014-inline-hp-delta feature that replaces the damage/heal mode toggle with explicit action buttons and Enter-to-damage keyboard shortcut
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:52:03 +01:00
Lukas
97d3918cef Implement the 013-hp-status-indicators feature that adds visual HP status indicators to combatant rows with a pure domain function deriving bloodied/unconscious states
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:29:24 +01:00
Lukas
7d440677be Implement the 012-turn-navigation feature that adds a RetreatTurn domain operation and relocates turn controls to a navigation bar at the top of the encounter tracker
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:11:11 +01:00
Lukas
a0d85a07e3 Implement the 011-quick-hp-input feature that adds an inline damage/heal numeric input per combatant row with mode toggle, keyboard workflow, and visual distinction
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:43:26 +01:00
Lukas
1c40bf7889 Implement the 010-ui-baseline feature that establishes a modern UI using Tailwind CSS v4 and shadcn/ui-style components for the encounter screen
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 18:36:39 +01:00
Lukas
8185fde0e8 Implement the 009-combatant-hp feature that adds optional max HP and current HP tracking per combatant with +/- controls, direct entry, and persistence
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:18:03 +01:00
Lukas
a9c280a6d6 Implement the 008-persist-encounter feature that saves encounter state to localStorage so it survives page reloads
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:24:00 +01:00
Lukas
c4a90c9982 Implement the 007-add-knip feature that adds Knip unused code detection to the quality gate and as a standalone command
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:24:27 +01:00
Lukas
0bbd6f27f9 Implement the 006-pre-commit-gate feature that enforces a pre-commit quality gate using Lefthook to run pnpm check before every commit
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:44:44 +01:00
Lukas
fea2bfe39d Implement the 005-set-initiative feature that adds initiative values to combatants with automatic descending sort and active turn preservation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 17:26:41 +01:00
Lukas
a9df826fef Implement the 004-edit-combatant feature that adds the possibility to change a combatants name 2026-03-04 10:05:13 +01:00
Lukas
aed234de7b Implement the 003-remove-combatant feature that adds the possibility to remove a combatant from an encounter 2026-03-03 23:46:47 +01:00
Lukas
9d7b174867 Merge branch '002-add-combatant' 2026-03-03 23:15:05 +01:00
Lukas
0de68100c8 Implement the 002-add-combatant feature that adds the possibility to add new combatants to an encounter 2026-03-03 23:12:20 +01:00
Lukas
187f98fc52 Relax INV-1/INV-2 in 001-advance-turn spec to allow empty encounters
Prepares for 002-add-combatant by treating an empty combatant list as
a valid aggregate state (activeIndex must be 0). AdvanceTurn behavior
on non-empty encounters is unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 18:06:13 +01:00
Lukas
2f7b4b82c1 Add CLAUDE.md with project commands, architecture, and conventions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 13:18:01 +01:00
Lukas
4c2e0a47e6 T012–T016: Phase 3 application + web shell (use case, ports, React hook, UI, README)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 13:11:33 +01:00
130 changed files with 52576 additions and 736 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

8
.jscpd.json Normal file
View File

@@ -0,0 +1,8 @@
{
"threshold": 5,
"minLines": 5,
"minTokens": 50,
"pattern": ["**/*.ts", "**/*.tsx"],
"ignore": ["node_modules", "dist", "build", "coverage", ".specify", "specs"],
"reporters": ["console"]
}

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

View File

@@ -122,6 +122,10 @@ fi
# Build list of available documents
docs=()
# Include required docs that passed validation above
[[ -f "$FEATURE_SPEC" ]] && docs+=("spec.md")
[[ -f "$IMPL_PLAN" ]] && docs+=("plan.md")
# Always check these optional docs
[[ -f "$RESEARCH" ]] && docs+=("research.md")
[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md")

113
CLAUDE.md Normal file
View File

@@ -0,0 +1,113 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
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
pnpm typecheck # tsc --build (project references)
pnpm lint # Biome lint
pnpm format # Biome format (writes)
pnpm --filter web dev # Vite dev server (localhost:5173)
pnpm --filter web build # Production build
```
Run a single test file: `pnpm vitest run packages/domain/src/__tests__/advance-turn.test.ts`
## Architecture
Strict layered architecture with ports/adapters and enforced dependency direction:
```
apps/web (React 19 + Vite) → packages/application (use cases) → packages/domain (pure logic)
```
- **Domain** — Pure functions, no I/O, no framework imports. All state transitions are deterministic. Errors returned as values (`DomainError`), never thrown. Adapters may throw only for programmer errors.
- **Application** — Orchestrates domain calls via port interfaces (`EncounterStore`, `BestiarySourceCache`). No business logic here.
- **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.
- **TypeScript strict mode** with `verbatimModuleSyntax`. Use `.js` extensions in relative imports when required by the repo's ESM settings (e.g., `./types.js`).
- **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/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
## Constitution (key principles)
The constitution (`.specify/memory/constitution.md`) governs all feature work:
1. **Deterministic Domain Core** — Pure state transitions only; no I/O, randomness, or clocks in domain.
2. **Layered Architecture** — Domain → Application → Adapters. Never skip layers or reverse dependencies.
3. **Clarification-First** — Ask before making non-trivial assumptions.
4. **MVP Baseline** — Say "MVP baseline does not include X", never permanent bans.
5. **Spec-driven features** — Features are described in living specs; evolve existing specs via `/integrate-issue`, create new ones via `/speckit.specify`. Bug fixes and tooling changes do not require specs.

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

55
README.md Normal file
View File

@@ -0,0 +1,55 @@
# Encounter Console
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
- **Persistent** — encounters survive page reloads via localStorage; bestiary data cached in IndexedDB
## Prerequisites
- Node.js 22+
- pnpm 10.6+
## Getting Started
```sh
pnpm install
pnpm --filter web dev
```
Open `http://localhost:5173`.
## Scripts
| Command | Description |
|---------|-------------|
| `pnpm --filter web dev` | Start the dev server |
| `pnpm --filter web build` | Production build |
| `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

@@ -11,13 +11,23 @@
"dependencies": {
"@initiative/application": "workspace:*",
"@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"
"react-dom": "^19.0.0",
"tailwind-merge": "^3.5.0"
},
"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

@@ -1,3 +1,326 @@
export function App() {
return <div>Initiative Tracker</div>;
import {
rollAllInitiativeUseCase,
rollInitiativeUseCase,
} from "@initiative/application";
import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActionBar } from "./components/action-bar";
import { CombatantRow } from "./components/combatant-row";
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 { type SearchResult, useBestiary } from "./hooks/use-bestiary";
import { useBulkImport } from "./hooks/use-bulk-import";
import { useEncounter } from "./hooks/use-encounter";
function rollDice(): number {
return Math.floor(Math.random() * 20) + 1;
}
export function App() {
const {
encounter,
advanceTurn,
retreatTurn,
addCombatant,
clearEncounter,
removeCombatant,
editCombatant,
setInitiative,
setHp,
adjustHp,
setAc,
toggleCondition,
toggleConcentration,
addFromBestiary,
makeStore,
} = useEncounter();
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 [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(
(result: SearchResult) => {
addFromBestiary(result);
// Derive the creature ID so stat block panel can try to show it
const slug = result.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
setSelectedCreatureId(
`${result.source.toLowerCase()}:${slug}` as CreatureId,
);
},
[addFromBestiary],
);
const handleCombatantStatBlock = useCallback((creatureId: string) => {
setSelectedCreatureId(creatureId as CreatureId);
setIsRightPanelFolded(false);
}, []);
const handleRollInitiative = useCallback(
(id: CombatantId) => {
rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature);
},
[makeStore, getCreature],
);
const handleRollAllInitiative = useCallback(() => {
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);
}, []);
// Auto-scroll to the active combatant when the turn changes
const activeRowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
activeRowRef.current?.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}, [encounter.activeIndex]);
// Auto-show stat block for the active combatant when turn changes,
// but only when the viewport is wide enough to show it alongside the tracker.
// Only react to activeIndex changes — not combatant reordering (e.g. Roll All).
const prevActiveIndexRef = useRef(encounter.activeIndex);
useEffect(() => {
if (prevActiveIndexRef.current === encounter.activeIndex) return;
prevActiveIndexRef.current = encounter.activeIndex;
if (!window.matchMedia("(min-width: 1024px)").matches) return;
const active = encounter.combatants[encounter.activeIndex];
if (!active?.creatureId || !isLoaded) return;
setSelectedCreatureId(active.creatureId as CreatureId);
}, [encounter.activeIndex, encounter.combatants, isLoaded]);
return (
<div className="flex h-screen flex-col">
<div className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-4 min-h-0">
{/* Turn Navigation — fixed at top */}
<div className="shrink-0 pt-8">
<TurnNavigation
encounter={encounter}
onAdvanceTurn={advanceTurn}
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 px-2 py-2">
{encounter.combatants.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground">
No combatants yet add one to get started
</p>
) : (
encounter.combatants.map((c, i) => (
<CombatantRow
key={c.id}
ref={i === encounter.activeIndex ? activeRowRef : null}
combatant={c}
isActive={i === encounter.activeIndex}
onRename={editCombatant}
onSetInitiative={setInitiative}
onRemove={removeCombatant}
onSetHp={setHp}
onAdjustHp={adjustHp}
onSetAc={setAc}
onToggleCondition={toggleCondition}
onToggleConcentration={toggleConcentration}
onShowStatBlock={
c.creatureId
? () => handleCombatantStatBlock(c.creatureId as string)
: undefined
}
onRollInitiative={
c.creatureId ? handleRollInitiative : undefined
}
/>
))
)}
</div>
</div>
{/* Action Bar — fixed at bottom */}
<div className="shrink-0 pb-8">
<ActionBar
onAddCombatant={addCombatant}
onAddFromBestiary={handleAddFromBestiary}
bestiarySearch={search}
bestiaryLoaded={isLoaded}
onViewStatBlock={handleViewStatBlock}
onBulkImport={handleBulkImport}
bulkImportDisabled={bulkImport.state.status === "loading"}
/>
</div>
</div>
{/* 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}
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}
/>
)}
</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

@@ -0,0 +1,333 @@
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", () => {
const raw = {
monster: [
{
name: "Goblin Warrior",
source: "XMM",
size: ["S"],
type: { type: "fey", tags: ["goblinoid"] },
alignment: ["C", "N"],
ac: [15],
hp: { average: 10, formula: "3d6" },
speed: { walk: 30 },
str: 8,
dex: 15,
con: 10,
int: 10,
wis: 8,
cha: 8,
skill: { stealth: "+6" },
senses: ["Darkvision 60 ft."],
passive: 9,
languages: ["Common", "Goblin"],
cr: "1/4",
action: [
{
name: "Scimitar",
entries: [
"{@atkr m} {@hit 4}, reach 5 ft. {@h}5 ({@damage 1d6 + 2}) Slashing damage.",
],
},
],
bonus: [
{
name: "Nimble Escape",
entries: [
"The goblin takes the {@action Disengage|XPHB} or {@action Hide|XPHB} action.",
],
},
],
},
],
};
const creatures = normalizeBestiary(raw);
expect(creatures).toHaveLength(1);
const c = creatures[0];
expect(c.id).toBe("xmm:goblin-warrior");
expect(c.name).toBe("Goblin Warrior");
expect(c.source).toBe("XMM");
expect(c.sourceDisplayName).toBe("MM 2024");
expect(c.size).toBe("Small");
expect(c.type).toBe("Fey (Goblinoid)");
expect(c.alignment).toBe("Chaotic Neutral");
expect(c.ac).toBe(15);
expect(c.hp).toEqual({ average: 10, formula: "3d6" });
expect(c.speed).toBe("30 ft.");
expect(c.abilities.dex).toBe(15);
expect(c.cr).toBe("1/4");
expect(c.proficiencyBonus).toBe(2);
expect(c.passive).toBe(9);
expect(c.skills).toBe("Stealth +6");
expect(c.senses).toBe("Darkvision 60 ft.");
expect(c.languages).toBe("Common, Goblin");
expect(c.actions).toHaveLength(1);
expect(c.actions?.[0].text).toContain("Melee Attack Roll:");
expect(c.actions?.[0].text).not.toContain("{@");
expect(c.bonusActions).toHaveLength(1);
expect(c.bonusActions?.[0].text).toContain("Disengage");
expect(c.bonusActions?.[0].text).not.toContain("{@");
});
it("normalizes a creature with legendary actions", () => {
const raw = {
monster: [
{
name: "Aboleth",
source: "XMM",
size: ["L"],
type: "aberration",
alignment: ["L", "E"],
ac: [17],
hp: { average: 135, formula: "18d10 + 36" },
speed: { walk: 10, swim: 40 },
str: 21,
dex: 9,
con: 15,
int: 18,
wis: 15,
cha: 18,
save: { con: "+6", int: "+8", wis: "+6" },
senses: ["Darkvision 120 ft."],
passive: 12,
languages: ["Deep Speech", "Telepathy 120 ft."],
cr: "10",
legendary: [
{
name: "Lash",
entries: ["The aboleth makes one Tentacle attack."],
},
],
},
],
};
const creatures = normalizeBestiary(raw);
const c = creatures[0];
expect(c.legendaryActions).toBeDefined();
expect(c.legendaryActions?.preamble).toContain("3 Legendary Actions");
expect(c.legendaryActions?.entries).toHaveLength(1);
expect(c.legendaryActions?.entries[0].name).toBe("Lash");
});
it("normalizes a creature with spellcasting", () => {
const raw = {
monster: [
{
name: "Test Caster",
source: "XMM",
size: ["M"],
type: "humanoid",
ac: [12],
hp: { average: 40, formula: "9d8" },
speed: { walk: 30 },
str: 10,
dex: 14,
con: 10,
int: 17,
wis: 12,
cha: 11,
passive: 11,
cr: "6",
spellcasting: [
{
name: "Spellcasting",
headerEntries: [
"The caster casts spells using Intelligence (spell save {@dc 15}):",
],
will: ["{@spell Detect Magic|XPHB}", "{@spell Mage Hand|XPHB}"],
daily: {
"2e": ["{@spell Fireball|XPHB}"],
"1": ["{@spell Dimension Door|XPHB}"],
},
},
],
},
],
};
const creatures = normalizeBestiary(raw);
const c = creatures[0];
expect(c.spellcasting).toHaveLength(1);
const sc = c.spellcasting?.[0];
expect(sc).toBeDefined();
expect(sc?.name).toBe("Spellcasting");
expect(sc?.headerText).toContain("DC 15");
expect(sc?.headerText).not.toContain("{@");
expect(sc?.atWill).toEqual(["Detect Magic", "Mage Hand"]);
expect(sc?.daily).toHaveLength(2);
expect(sc?.daily).toContainEqual({
uses: 2,
each: true,
spells: ["Fireball"],
});
expect(sc?.daily).toContainEqual({
uses: 1,
each: false,
spells: ["Dimension Door"],
});
});
it("normalizes a creature with object-type type field", () => {
const raw = {
monster: [
{
name: "Swarm of Bats",
source: "XMM",
size: ["L"],
type: { type: "beast", swarmSize: "T" },
ac: [12],
hp: { average: 11, formula: "2d10" },
speed: { walk: 5, fly: 30 },
str: 5,
dex: 15,
con: 10,
int: 2,
wis: 12,
cha: 4,
passive: 11,
resist: ["bludgeoning", "piercing", "slashing"],
conditionImmune: ["charmed", "frightened"],
cr: "1/4",
},
],
};
const creatures = normalizeBestiary(raw);
const c = creatures[0];
expect(c.type).toBe("Swarm of Tiny Beasts");
expect(c.resist).toBe("Bludgeoning, Piercing, Slashing");
expect(c.conditionImmune).toBe("Charmed, Frightened");
});
it("normalizes a creature with conditional resistances", () => {
const raw = {
monster: [
{
name: "Half-Dragon",
source: "XMM",
size: ["M"],
type: "humanoid",
ac: [18],
hp: { average: 65, formula: "10d8 + 20" },
speed: { walk: 30 },
str: 16,
dex: 13,
con: 14,
int: 10,
wis: 11,
cha: 10,
passive: 10,
cr: "5",
resist: [
{
special: "Damage type chosen for the Draconic Origin trait",
},
],
},
],
};
const creatures = normalizeBestiary(raw);
const c = creatures[0];
expect(c.resist).toBe("Damage type chosen for the Draconic Origin trait");
});
it("normalizes a creature with multiple sizes", () => {
const raw = {
monster: [
{
name: "Aberrant Cultist",
source: "XMM",
size: ["S", "M"],
type: "humanoid",
ac: [13],
hp: { average: 22, formula: "4d8 + 4" },
speed: { walk: 30 },
str: 11,
dex: 14,
con: 12,
int: 10,
wis: 13,
cha: 8,
passive: 11,
cr: "1/2",
},
],
};
const creatures = normalizeBestiary(raw);
expect(creatures[0].size).toBe("Small or Medium");
});
it("normalizes a creature with CR as object", () => {
const raw = {
monster: [
{
name: "Dragon",
source: "XMM",
size: ["H"],
type: "dragon",
ac: [19],
hp: { average: 256, formula: "19d12 + 133" },
speed: { walk: 40 },
str: 27,
dex: 10,
con: 25,
int: 16,
wis: 13,
cha: 23,
passive: 23,
cr: { cr: "17", xpLair: 20000 },
},
],
};
const creatures = normalizeBestiary(raw);
expect(creatures[0].cr).toBe("17");
expect(creatures[0].proficiencyBonus).toBe(6);
});
it("handles fly speed with hover condition", () => {
const raw = {
monster: [
{
name: "Air Elemental",
source: "XMM",
size: ["L"],
type: "elemental",
ac: [15],
hp: { average: 90, formula: "12d10 + 24" },
speed: {
walk: 10,
fly: { number: 90, condition: "(hover)" },
canHover: true,
},
str: 14,
dex: 20,
con: 14,
int: 6,
wis: 10,
cha: 6,
passive: 10,
cr: "5",
},
],
};
const creatures = normalizeBestiary(raw);
expect(creatures[0].speed).toBe("10 ft., fly 90 ft. (hover)");
});
});

View File

@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import { stripTags } from "../strip-tags.js";
describe("stripTags", () => {
it("returns text unchanged when no tags present", () => {
expect(stripTags("Hello world")).toBe("Hello world");
});
it("strips {@spell Name|Source} to Name", () => {
expect(stripTags("{@spell Fireball|XPHB}")).toBe("Fireball");
});
it("strips {@condition Name|Source} to Name", () => {
expect(stripTags("{@condition Frightened|XPHB}")).toBe("Frightened");
});
it("strips {@damage dice} to dice", () => {
expect(stripTags("{@damage 2d10}")).toBe("2d10");
});
it("strips {@dice value} to value", () => {
expect(stripTags("{@dice 5d10}")).toBe("5d10");
});
it("strips {@dc N} to DC N", () => {
expect(stripTags("{@dc 15}")).toBe("DC 15");
});
it("strips {@hit N} to +N", () => {
expect(stripTags("{@hit 5}")).toBe("+5");
});
it("strips {@h} to Hit: ", () => {
expect(stripTags("{@h}")).toBe("Hit: ");
});
it("strips {@hom} to Hit or Miss: ", () => {
expect(stripTags("{@hom}")).toBe("Hit or Miss: ");
});
it("strips {@atkr m} to Melee Attack Roll:", () => {
expect(stripTags("{@atkr m}")).toBe("Melee Attack Roll:");
});
it("strips {@atkr r} to Ranged Attack Roll:", () => {
expect(stripTags("{@atkr r}")).toBe("Ranged Attack Roll:");
});
it("strips {@atkr m,r} to Melee or Ranged Attack Roll:", () => {
expect(stripTags("{@atkr m,r}")).toBe("Melee or Ranged Attack Roll:");
});
it("strips {@recharge 5} to (Recharge 5-6)", () => {
expect(stripTags("{@recharge 5}")).toBe("(Recharge 5-6)");
});
it("strips {@recharge} to (Recharge 6)", () => {
expect(stripTags("{@recharge}")).toBe("(Recharge 6)");
});
it("strips {@actSave wis} to Wisdom saving throw", () => {
expect(stripTags("{@actSave wis}")).toBe("Wisdom saving throw");
});
it("strips {@actSaveFail} to Failure:", () => {
expect(stripTags("{@actSaveFail}")).toBe("Failure:");
});
it("strips {@actSaveFail 2} to Failure by 2 or More:", () => {
expect(stripTags("{@actSaveFail 2}")).toBe("Failure by 2 or More:");
});
it("strips {@actSaveSuccess} to Success:", () => {
expect(stripTags("{@actSaveSuccess}")).toBe("Success:");
});
it("strips {@actTrigger} to Trigger:", () => {
expect(stripTags("{@actTrigger}")).toBe("Trigger:");
});
it("strips {@actResponse} to Response:", () => {
expect(stripTags("{@actResponse}")).toBe("Response:");
});
it("strips {@variantrule Name|Source|Display} to Display", () => {
expect(stripTags("{@variantrule Cone [Area of Effect]|XPHB|Cone}")).toBe(
"Cone",
);
});
it("strips {@action Name|Source|Display} to Display", () => {
expect(stripTags("{@action Disengage|XPHB|Disengage}")).toBe("Disengage");
});
it("strips {@skill Name|Source} to Name", () => {
expect(stripTags("{@skill Perception|XPHB}")).toBe("Perception");
});
it("strips {@creature Name|Source} to Name", () => {
expect(stripTags("{@creature Goblin|XPHB}")).toBe("Goblin");
});
it("strips {@hazard Name|Source} to Name", () => {
expect(stripTags("{@hazard Lava|XPHB}")).toBe("Lava");
});
it("strips {@status Name|Source} to Name", () => {
expect(stripTags("{@status Bloodied|XPHB}")).toBe("Bloodied");
});
it("handles unknown tags by extracting first segment", () => {
expect(stripTags("{@unknown Something|else}")).toBe("Something");
});
it("handles multiple tags in the same string", () => {
expect(stripTags("{@hit 4}, reach 5 ft. {@h}5 ({@damage 1d6 + 2})")).toBe(
"+4, reach 5 ft. Hit: 5 (1d6 + 2)",
);
});
it("handles nested tags gracefully", () => {
expect(
stripTags("The spell {@spell Fireball|XPHB} deals {@damage 8d6}."),
).toBe("The spell Fireball deals 8d6.");
});
it("handles text with no tags", () => {
expect(stripTags("Just plain text.")).toBe("Just plain text.");
});
it("strips {@actSaveSuccessOrFail} to Success or Failure:", () => {
expect(stripTags("{@actSaveSuccessOrFail}")).toBe("Success or Failure:");
});
it("strips {@action Name|Source} to Name when no display text", () => {
expect(stripTags("{@action Disengage|XPHB}")).toBe("Disengage");
});
});

View File

@@ -0,0 +1,460 @@
import type {
Creature,
CreatureId,
DailySpells,
LegendaryBlock,
SpellcastingBlock,
TraitBlock,
} from "@initiative/domain";
import { creatureId, proficiencyBonus } from "@initiative/domain";
import { stripTags } from "./strip-tags.js";
// --- Raw 5etools types (minimal, for parsing) ---
interface RawMonster {
name: string;
source: string;
size: string[];
type: string | { type: string; tags?: string[]; swarmSize?: string };
alignment?: 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
>;
str: number;
dex: number;
con: number;
int: number;
wis: number;
cha: number;
save?: Record<string, string>;
skill?: Record<string, string>;
senses?: string[];
passive: number;
resist?: (string | { special: string })[];
immune?: (string | { special: string })[];
vulnerable?: (string | { special: string })[];
conditionImmune?: string[];
languages?: string[];
cr?: string | { cr: string };
trait?: RawEntry[];
action?: RawEntry[];
bonus?: RawEntry[];
reaction?: RawEntry[];
legendary?: RawEntry[];
legendaryActions?: number;
legendaryActionsLair?: number;
legendaryHeader?: string[];
spellcasting?: RawSpellcasting[];
initiative?: { proficiency?: number };
}
interface RawEntry {
name: string;
entries: (string | RawEntryObject)[];
}
interface RawEntryObject {
type: string;
items?: (
| string
| { type: string; name?: string; entries?: (string | RawEntryObject)[] }
)[];
style?: string;
name?: string;
entries?: (string | RawEntryObject)[];
}
interface RawSpellcasting {
name: string;
headerEntries: string[];
will?: string[];
daily?: Record<string, string[]>;
rest?: Record<string, string[]>;
hidden?: string[];
ability?: string;
displayAs?: string;
legendary?: Record<string, string[]>;
}
// --- Source mapping ---
let sourceDisplayNames: Record<string, string> = {};
export function setSourceDisplayNames(names: Record<string, string>): void {
sourceDisplayNames = names;
}
// --- Size mapping ---
const SIZE_MAP: Record<string, string> = {
T: "Tiny",
S: "Small",
M: "Medium",
L: "Large",
H: "Huge",
G: "Gargantuan",
};
// --- Alignment mapping ---
const ALIGNMENT_MAP: Record<string, string> = {
L: "Lawful",
N: "Neutral",
C: "Chaotic",
G: "Good",
E: "Evil",
U: "Unaligned",
};
function formatAlignment(codes?: string[]): string {
if (!codes || codes.length === 0) return "Unaligned";
if (codes.length === 1 && codes[0] === "U") return "Unaligned";
if (codes.length === 1 && codes[0] === "N") return "Neutral";
return codes.map((c) => ALIGNMENT_MAP[c] ?? c).join(" ");
}
// --- Helpers ---
function formatSize(sizes: string[]): string {
return sizes.map((s) => SIZE_MAP[s] ?? s).join(" or ");
}
function formatType(
type:
| string
| {
type: string | { choose: string[] };
tags?: string[];
swarmSize?: string;
},
): string {
if (typeof type === "string") return capitalize(type);
const baseType =
typeof type.type === "string"
? capitalize(type.type)
: type.type.choose.map(capitalize).join(" or ");
let result = baseType;
if (type.tags && type.tags.length > 0) {
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;
result = `Swarm of ${swarmSizeLabel} ${result}s`;
}
return result;
}
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
function extractAc(ac: RawMonster["ac"]): {
value: number;
source?: string;
} {
const first = ac[0];
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,
};
}
function formatSpeed(speed: RawMonster["speed"]): string {
const parts: string[] = [];
for (const [mode, value] of Object.entries(speed)) {
if (mode === "canHover") continue;
if (typeof value === "boolean") continue;
let numStr: string;
let condition = "";
if (typeof value === "number") {
numStr = `${value} ft.`;
} else {
numStr = `${value.number} ft.`;
if (value.condition) {
condition = ` ${value.condition}`;
}
}
if (mode === "walk") {
parts.push(`${numStr}${condition}`);
} else {
parts.push(`${mode} ${numStr}${condition}`);
}
}
return parts.join(", ");
}
function formatSaves(save?: Record<string, string>): string | undefined {
if (!save) return undefined;
return Object.entries(save)
.map(([key, val]) => `${key.toUpperCase()} ${val}`)
.join(", ");
}
function formatSkills(skill?: Record<string, string>): string | undefined {
if (!skill) return undefined;
return Object.entries(skill)
.map(([key, val]) => `${capitalize(key)} ${val}`)
.join(", ");
}
function formatDamageList(
items?: (string | Record<string, unknown>)[],
): string | undefined {
if (!items || items.length === 0) return undefined;
return items
.map((item) => {
if (typeof item === "string") return capitalize(stripTags(item));
if (typeof item.special === "string") return stripTags(item.special);
// Handle conditional entries like { vulnerable: [...], note: "..." }
const damageTypes = (Object.values(item).find((v) => Array.isArray(v)) ??
[]) as string[];
const note = typeof item.note === "string" ? ` ${item.note}` : "";
return damageTypes.map((d) => capitalize(stripTags(d))).join(", ") + note;
})
.join(", ");
}
function formatConditionImmunities(
items?: (string | { conditionImmune?: string[]; note?: string })[],
): string | undefined {
if (!items || items.length === 0) return undefined;
return items
.flatMap((c) => {
if (typeof c === "string") return [capitalize(stripTags(c))];
if (c.conditionImmune) {
const conds = c.conditionImmune.map((ci) => capitalize(stripTags(ci)));
const note = c.note ? ` ${c.note}` : "";
return conds.map((ci) => `${ci}${note}`);
}
return [];
})
.join(", ");
}
function renderListItem(item: string | RawEntryObject): string | undefined {
if (typeof item === "string") {
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(" ");
}
function normalizeTraits(raw?: RawEntry[]): TraitBlock[] | undefined {
if (!raw || raw.length === 0) return undefined;
return raw.map((t) => ({
name: stripTags(t.name),
text: renderEntries(t.entries),
}));
}
function normalizeSpellcasting(
raw?: RawSpellcasting[],
): SpellcastingBlock[] | undefined {
if (!raw || raw.length === 0) return undefined;
return raw.map((sc) => {
const block: {
name: string;
headerText: string;
atWill?: string[];
daily?: DailySpells[];
restLong?: DailySpells[];
} = {
name: stripTags(sc.name),
headerText: sc.headerEntries.map((e) => stripTags(e)).join(" "),
};
const hidden = new Set(sc.hidden ?? []);
if (sc.will && !hidden.has("will")) {
block.atWill = sc.will.map((s) => stripTags(s));
}
if (sc.daily) {
block.daily = parseDailyMap(sc.daily);
}
if (sc.rest) {
block.restLong = parseDailyMap(sc.rest);
}
return block;
});
}
function parseDailyMap(map: Record<string, string[]>): DailySpells[] {
return Object.entries(map).map(([key, spells]) => {
const each = key.endsWith("e");
const uses = Number.parseInt(each ? key.slice(0, -1) : key, 10);
return {
uses,
each,
spells: spells.map((s) => stripTags(s)),
};
});
}
function normalizeLegendary(
raw?: RawEntry[],
monster?: RawMonster,
): LegendaryBlock | undefined {
if (!raw || raw.length === 0) return undefined;
const name = monster?.name ?? "creature";
const count = monster?.legendaryActions ?? 3;
const preamble = `${name} can take ${count} Legendary Actions, choosing from the options below. Only one Legendary Action can be used at a time and only at the end of another creature's turn. ${name} regains spent Legendary Actions at the start of its turn.`;
return {
preamble,
entries: raw.map((e) => ({
name: stripTags(e.name),
text: renderEntries(e.entries),
})),
};
}
function extractCr(cr: string | { cr: string } | undefined): string {
if (cr === undefined) return "—";
return typeof cr === "string" ? cr : cr.cr;
}
function makeCreatureId(source: string, name: string): CreatureId {
const slug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
return creatureId(`${source.toLowerCase()}:${slug}`);
}
/**
* Normalizes raw 5etools bestiary JSON into domain Creature[].
*/
export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
// 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);
return {
id: makeCreatureId(m.source, m.name),
name: m.name,
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 ?? 0,
formula: m.hp.formula ?? m.hp.special ?? "",
},
speed: formatSpeed(m.speed),
abilities: {
str: m.str,
dex: m.dex,
con: m.con,
int: m.int,
wis: m.wis,
cha: m.cha,
},
cr: crStr,
initiativeProficiency: m.initiative?.proficiency ?? 0,
proficiencyBonus: proficiencyBonus(crStr),
passive: m.passive,
savingThrows: formatSaves(m.save),
skills: formatSkills(m.skill),
resist: formatDamageList(m.resist),
immune: formatDamageList(m.immune),
vulnerable: formatDamageList(m.vulnerable),
conditionImmune: formatConditionImmunities(m.conditionImmune),
senses:
m.senses && m.senses.length > 0
? m.senses.map((s) => stripTags(s)).join(", ")
: undefined,
languages:
m.languages && m.languages.length > 0
? m.languages.join(", ")
: undefined,
traits: normalizeTraits(m.trait),
actions: normalizeTraits(m.action),
bonusActions: normalizeTraits(m.bonus),
reactions: normalizeTraits(m.reaction),
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

@@ -0,0 +1,100 @@
const ABILITY_MAP: Record<string, string> = {
str: "Strength",
dex: "Dexterity",
con: "Constitution",
int: "Intelligence",
wis: "Wisdom",
cha: "Charisma",
};
const ATKR_MAP: Record<string, string> = {
m: "Melee Attack Roll:",
r: "Ranged Attack Roll:",
"m,r": "Melee or Ranged Attack Roll:",
"r,m": "Melee or Ranged Attack Roll:",
};
/**
* Strips 5etools {@tag ...} markup from text, converting to plain readable text.
*
* 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;
// {@h} → "Hit: "
result = result.replace(/\{@h\}/g, "Hit: ");
// {@hom} → "Hit or Miss: "
result = result.replace(/\{@hom\}/g, "Hit or Miss: ");
// {@actTrigger} → "Trigger:"
result = result.replace(/\{@actTrigger\}/g, "Trigger:");
// {@actResponse} → "Response:"
result = result.replace(/\{@actResponse\}/g, "Response:");
// {@actSaveSuccess} → "Success:"
result = result.replace(/\{@actSaveSuccess\}/g, "Success:");
// {@actSaveSuccessOrFail} → handled below as parameterized
// {@recharge 5} → "(Recharge 5-6)", {@recharge} → "(Recharge 6)"
result = result.replace(/\{@recharge\s+(\d)\}/g, "(Recharge $1-6)");
result = result.replace(/\{@recharge\}/g, "(Recharge 6)");
// {@dc N} → "DC N"
result = result.replace(/\{@dc\s+(\d+)\}/g, "DC $1");
// {@hit N} → "+N"
result = result.replace(/\{@hit\s+(\d+)\}/g, "+$1");
// {@atkr type} → mapped attack roll text
result = result.replace(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => {
return ATKR_MAP[type.trim()] ?? `Attack Roll:`;
});
// {@actSave ability} → "Ability saving throw"
result = result.replace(/\{@actSave\s+([^}]+)\}/g, (_, ability: string) => {
const name = ABILITY_MAP[ability.trim().toLowerCase()];
return name ? `${name} saving throw` : `${ability} saving throw`;
});
// {@actSaveFail} → "Failure:" or {@actSaveFail N} → "Failure by N or More:"
result = result.replace(
/\{@actSaveFail\s+(\d+)\}/g,
"Failure by $1 or More:",
);
result = result.replace(/\{@actSaveFail\}/g, "Failure:");
// {@actSaveSuccessOrFail} → keep as-is label
result = result.replace(/\{@actSaveSuccessOrFail\}/g, "Success or Failure:");
// {@actSaveFailBy N} → "Failure by N or More:"
result = result.replace(
/\{@actSaveFailBy\s+(\d+)\}/g,
"Failure by $1 or More:",
);
// Generic tags: {@tag Display|Source|...} → Display (first segment before |)
// Covers: spell, condition, damage, dice, variantrule, action, skill,
// creature, hazard, status, plus any unknown tags
result = result.replace(
/\{@(\w+)\s+([^}]+)\}/g,
(_, tag: string, content: string) => {
// For tags with Display|Source format, extract first segment
const segments = content.split("|");
// Some tags have a third segment as display text: {@variantrule Name|Source|Display}
if ((tag === "variantrule" || tag === "action") && segments.length >= 3) {
return segments[2];
}
return segments[0];
},
);
return result;
}

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 in separate DOM elements", () => {
renderNav();
const badge = screen.getByText("R1");
const name = screen.getByText("Goblin");
expect(badge.parentElement).not.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

@@ -0,0 +1,37 @@
import { cn } from "../lib/utils";
interface AcShieldProps {
readonly value: number | undefined;
readonly onClick?: () => void;
readonly className?: string;
}
export function AcShield({ value, onClick, className }: AcShieldProps) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"relative inline-flex items-center justify-center text-sm tabular-nums text-muted-foreground transition-colors hover:text-hover-neutral",
className,
)}
style={{ width: 28, height: 32 }}
>
<svg
viewBox="0 0 28 32"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
className="absolute inset-0 h-full w-full"
aria-hidden="true"
>
<path d="M14 1.5 L2.5 6.5 L2.5 15 Q2.5 25 14 30.5 Q25.5 25 25.5 15 L25.5 6.5 Z" />
</svg>
<span className="relative text-xs font-medium leading-none">
{value !== undefined ? value : "\u2014"}
</span>
</button>
);
}

View File

@@ -0,0 +1,417 @@
import { Check, Eye, Import, Minus, Plus } from "lucide-react";
import { type FormEvent, useEffect, useRef, useState } from "react";
import type { SearchResult } from "../hooks/use-bestiary.js";
import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
interface QueuedCreature {
result: SearchResult;
count: number;
}
interface ActionBarProps {
onAddCombatant: (
name: string,
opts?: { initiative?: number; ac?: number; maxHp?: number },
) => void;
onAddFromBestiary: (result: SearchResult) => void;
bestiarySearch: (query: string) => SearchResult[];
bestiaryLoaded: boolean;
onViewStatBlock?: (result: SearchResult) => void;
onBulkImport?: () => void;
bulkImportDisabled?: boolean;
}
function creatureKey(r: SearchResult): string {
return `${r.source}:${r.name}`;
}
export function ActionBar({
onAddCombatant,
onAddFromBestiary,
bestiarySearch,
bestiaryLoaded,
onViewStatBlock,
onBulkImport,
bulkImportDisabled,
}: ActionBarProps) {
const [nameInput, setNameInput] = useState("");
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
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([]);
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;
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("");
setSuggestions([]);
clearCustomFields();
};
const handleNameChange = (value: string) => {
setNameInput(value);
setSuggestionIndex(-1);
let newSuggestions: SearchResult[] = [];
if (value.length >= 2) {
newSuggestions = bestiarySearch(value);
setSuggestions(newSuggestions);
} else {
setSuggestions([]);
}
if (newSuggestions.length > 0) {
clearCustomFields();
}
if (queued) {
const qKey = creatureKey(queued.result);
const stillVisible = newSuggestions.some((s) => creatureKey(s) === qKey);
if (!stillVisible) {
setQueued(null);
}
}
};
const handleClickSuggestion = (result: SearchResult) => {
const key = creatureKey(result);
if (queued && creatureKey(queued.result) === key) {
setQueued({ ...queued, count: queued.count + 1 });
} else {
setQueued({ result, count: 1 });
}
};
const handleEnter = () => {
if (queued) {
confirmQueued();
} else if (suggestionIndex >= 0) {
handleClickSuggestion(suggestions[suggestionIndex]);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (suggestions.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
} else if (e.key === "Enter") {
e.preventDefault();
handleEnter();
} else if (e.key === "Escape") {
setQueued(null);
setSuggestionIndex(-1);
setSuggestions([]);
}
};
// 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">
<form
onSubmit={handleAdd}
className="relative flex flex-1 items-center gap-2"
>
<div className="relative flex-1">
<Input
type="text"
value={nameInput}
onChange={(e) => handleNameChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="+ Add combatants"
className="max-w-xs"
/>
{suggestions.length > 0 && (
<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((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 ${
isQueued
? "bg-accent/30 text-foreground"
: i === suggestionIndex
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg"
}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleClickSuggestion(result)}
onMouseEnter={() => setSuggestionIndex(i)}
>
<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>
{nameInput.length >= 2 && suggestions.length === 0 && (
<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>
{bestiaryLoaded && onViewStatBlock && (
<div ref={viewerRef} className="relative">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => (viewerOpen ? closeViewer() : openViewer())}
>
<Eye className="h-4 w-4" />
</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">
{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>
)}
</div>
)}
{bestiaryLoaded && onBulkImport && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={onBulkImport}
disabled={bulkImportDisabled}
>
<Import className="h-4 w-4" />
</Button>
)}
</form>
</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,606 @@
import {
type CombatantId,
type ConditionId,
deriveHpStatus,
} from "@initiative/domain";
import { Brain, X } from "lucide-react";
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
import { cn } from "../lib/utils";
import { AcShield } from "./ac-shield";
import { ConditionPicker } from "./condition-picker";
import { ConditionTags } from "./condition-tags";
import { D20Icon } from "./d20-icon";
import { HpAdjustPopover } from "./hp-adjust-popover";
import { ConfirmButton } from "./ui/confirm-button";
import { Input } from "./ui/input";
interface Combatant {
readonly id: CombatantId;
readonly name: string;
readonly initiative?: number;
readonly maxHp?: number;
readonly currentHp?: number;
readonly ac?: number;
readonly conditions?: readonly ConditionId[];
readonly isConcentrating?: boolean;
}
interface CombatantRowProps {
combatant: Combatant;
isActive: boolean;
onRename: (id: CombatantId, newName: string) => void;
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
onRemove: (id: CombatantId) => void;
onSetHp: (id: CombatantId, maxHp: number | undefined) => void;
onAdjustHp: (id: CombatantId, delta: number) => void;
onSetAc: (id: CombatantId, value: number | undefined) => void;
onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void;
onToggleConcentration: (id: CombatantId) => void;
onShowStatBlock?: () => void;
onRollInitiative?: (id: CombatantId) => void;
}
function EditableName({
name,
combatantId,
onRename,
onShowStatBlock,
}: {
name: string;
combatantId: CombatantId;
onRename: (id: CombatantId, newName: string) => void;
onShowStatBlock?: () => void;
}) {
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();
if (trimmed !== "" && trimmed !== name) {
onRename(combatantId, trimmed);
}
setEditing(false);
}, [draft, name, combatantId, onRename]);
const startEditing = useCallback(() => {
setDraft(name);
setEditing(true);
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
ref={inputRef}
type="text"
value={draft}
className="h-7 text-sm"
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") setEditing(false);
}}
/>
);
}
return (
<>
<button
type="button"
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"
>
{name}
</button>
</>
);
}
function MaxHpDisplay({
maxHp,
onCommit,
}: {
maxHp: number | undefined;
onCommit: (value: number | undefined) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(maxHp?.toString() ?? "");
const inputRef = useRef<HTMLInputElement>(null);
const commit = useCallback(() => {
if (draft === "") {
onCommit(undefined);
} else {
const n = Number.parseInt(draft, 10);
if (!Number.isNaN(n) && n >= 1) {
onCommit(n);
}
}
setEditing(false);
}, [draft, onCommit]);
const startEditing = useCallback(() => {
setDraft(maxHp?.toString() ?? "");
setEditing(true);
requestAnimationFrame(() => inputRef.current?.select());
}, [maxHp]);
if (editing) {
return (
<Input
ref={inputRef}
type="text"
inputMode="numeric"
value={draft}
placeholder="Max"
className="h-7 w-[7ch] text-center text-sm tabular-nums"
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") setEditing(false);
}}
/>
);
}
return (
<button
type="button"
onClick={startEditing}
className="inline-block h-7 min-w-[3ch] text-center text-sm leading-7 tabular-nums text-muted-foreground transition-colors hover:text-hover-neutral"
>
{maxHp ?? "Max"}
</button>
);
}
function ClickableHp({
currentHp,
maxHp,
onAdjust,
dimmed,
}: {
currentHp: number | undefined;
maxHp: number | undefined;
onAdjust: (delta: number) => void;
dimmed?: boolean;
}) {
const [popoverOpen, setPopoverOpen] = useState(false);
const status = deriveHpStatus(currentHp, maxHp);
if (maxHp === undefined) {
return (
<span
className={cn(
"inline-block h-7 w-[4ch] text-center text-sm leading-7 tabular-nums text-muted-foreground",
dimmed && "opacity-50",
)}
>
--
</span>
);
}
return (
<div className="relative">
<button
type="button"
onClick={() => setPopoverOpen(true)}
className={cn(
"inline-block h-7 min-w-[3ch] text-center text-sm font-medium leading-7 tabular-nums transition-colors hover:text-hover-neutral",
status === "bloodied" && "text-amber-400",
status === "unconscious" && "text-red-400",
status === "healthy" && "text-foreground",
dimmed && "opacity-50",
)}
>
{currentHp}
</button>
{popoverOpen && (
<HpAdjustPopover
onAdjust={onAdjust}
onClose={() => setPopoverOpen(false)}
/>
)}
</div>
);
}
function AcDisplay({
ac,
onCommit,
}: {
ac: number | undefined;
onCommit: (value: number | undefined) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(ac?.toString() ?? "");
const inputRef = useRef<HTMLInputElement>(null);
const commit = useCallback(() => {
if (draft === "") {
onCommit(undefined);
} else {
const n = Number.parseInt(draft, 10);
if (!Number.isNaN(n) && n >= 0) {
onCommit(n);
}
}
setEditing(false);
}, [draft, onCommit]);
const startEditing = useCallback(() => {
setDraft(ac?.toString() ?? "");
setEditing(true);
requestAnimationFrame(() => inputRef.current?.select());
}, [ac]);
if (editing) {
return (
<Input
ref={inputRef}
type="text"
inputMode="numeric"
value={draft}
placeholder="AC"
className="h-7 w-[6ch] text-center text-sm tabular-nums"
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") setEditing(false);
}}
/>
);
}
return <AcShield value={ac} onClick={startEditing} />;
}
function InitiativeDisplay({
initiative,
combatantId,
dimmed,
onSetInitiative,
onRollInitiative,
}: {
initiative: number | undefined;
combatantId: CombatantId;
dimmed: boolean;
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
onRollInitiative?: (id: CombatantId) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(initiative?.toString() ?? "");
const inputRef = useRef<HTMLInputElement>(null);
const commit = useCallback(() => {
if (draft === "") {
onSetInitiative(combatantId, undefined);
} else {
const n = Number.parseInt(draft, 10);
if (!Number.isNaN(n)) {
onSetInitiative(combatantId, n);
}
}
setEditing(false);
}, [draft, combatantId, onSetInitiative]);
const startEditing = useCallback(() => {
setDraft(initiative?.toString() ?? "");
setEditing(true);
requestAnimationFrame(() => inputRef.current?.select());
}, [initiative]);
if (editing) {
return (
<Input
ref={inputRef}
type="text"
inputMode="numeric"
value={draft}
placeholder="--"
className={cn(
"h-7 w-[6ch] text-center text-sm tabular-nums",
dimmed && "opacity-50",
)}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") setEditing(false);
}}
/>
);
}
// Empty + bestiary creature → d20 roll button
if (initiative === undefined && onRollInitiative) {
return (
<button
type="button"
onClick={() => onRollInitiative(combatantId)}
className={cn(
"flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral",
dimmed && "opacity-50",
)}
title="Roll initiative"
aria-label="Roll initiative"
>
<D20Icon className="h-7 w-7" />
</button>
);
}
// Has value → bold number, click to edit
// Empty + manual → "--" placeholder, click to edit
return (
<button
type="button"
onClick={startEditing}
className={cn(
"h-7 w-full text-center text-sm leading-7 tabular-nums transition-colors",
initiative !== undefined
? "font-medium text-foreground hover:text-hover-neutral"
: "text-muted-foreground hover:text-hover-neutral",
dimmed && "opacity-50",
)}
>
{initiative ?? "--"}
</button>
);
}
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,
isActive,
onRename,
onSetInitiative,
onRemove,
onSetHp,
onAdjustHp,
onSetAc,
onToggleCondition,
onToggleConcentration,
onShowStatBlock,
onRollInitiative,
}: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) {
const { id, name, initiative, maxHp, currentHp } = combatant;
const status = deriveHpStatus(currentHp, maxHp);
const dimmed = status === "unconscious";
const [pickerOpen, setPickerOpen] = useState(false);
const prevHpRef = useRef(currentHp);
const [isPulsing, setIsPulsing] = useState(false);
const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
const prevHp = prevHpRef.current;
prevHpRef.current = currentHp;
if (
prevHp !== undefined &&
currentHp !== undefined &&
currentHp < prevHp &&
combatant.isConcentrating
) {
setIsPulsing(true);
clearTimeout(pulseTimerRef.current);
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
}
}, [currentHp, combatant.isConcentrating]);
useEffect(() => {
if (!combatant.isConcentrating) {
clearTimeout(pulseTimerRef.current);
setIsPulsing(false);
}
}, [combatant.isConcentrating]);
return (
/* 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",
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 */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleConcentration(id);
}}
title="Concentrating"
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",
concentrationIconClass(combatant.isConcentrating, dimmed),
)}
>
<Brain size={16} />
</button>
{/* Initiative */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
<div
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<InitiativeDisplay
initiative={initiative}
combatantId={id}
dimmed={dimmed}
onSetInitiative={onSetInitiative}
onRollInitiative={onRollInitiative}
/>
</div>
{/* Name + Conditions */}
<div
className={cn(
"relative flex flex-wrap items-center gap-1 min-w-0",
dimmed && "opacity-50",
)}
>
<EditableName
name={name}
combatantId={id}
onRename={onRename}
onShowStatBlock={onShowStatBlock}
/>
<ConditionTags
conditions={combatant.conditions}
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
onOpenPicker={() => setPickerOpen((prev) => !prev)}
/>
{pickerOpen && (
<ConditionPicker
activeConditions={combatant.conditions}
onToggle={(conditionId) => onToggleCondition(id, conditionId)}
onClose={() => setPickerOpen(false)}
/>
)}
</div>
{/* AC */}
{/* 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/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
<div
className="flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<ClickableHp
currentHp={currentHp}
maxHp={maxHp}
onAdjust={(delta) => onAdjustHp(id, delta)}
dimmed={dimmed}
/>
{maxHp !== undefined && (
<span
className={cn(
"text-sm tabular-nums text-muted-foreground",
dimmed && "opacity-50",
)}
>
/
</span>
)}
<div className={cn(dimmed && "opacity-50")}>
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => onSetHp(id, v)} />
</div>
</div>
{/* Actions */}
<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

@@ -0,0 +1,134 @@
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
import type { LucideIcon } from "lucide-react";
import {
ArrowDown,
Ban,
BatteryLow,
Droplet,
EarOff,
EyeOff,
Gem,
Ghost,
Hand,
Heart,
Link,
Moon,
Siren,
Sparkles,
ZapOff,
} from "lucide-react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { cn } from "../lib/utils";
const ICON_MAP: Record<string, LucideIcon> = {
EyeOff,
Heart,
EarOff,
BatteryLow,
Siren,
Hand,
Ban,
Ghost,
ZapOff,
Gem,
Droplet,
ArrowDown,
Link,
Sparkles,
Moon,
};
const COLOR_CLASSES: Record<string, string> = {
neutral: "text-muted-foreground",
pink: "text-pink-400",
amber: "text-amber-400",
orange: "text-orange-400",
gray: "text-gray-400",
violet: "text-violet-400",
yellow: "text-yellow-400",
slate: "text-slate-400",
green: "text-green-400",
indigo: "text-indigo-400",
};
interface ConditionPickerProps {
activeConditions: readonly ConditionId[] | undefined;
onToggle: (conditionId: ConditionId) => void;
onClose: () => void;
}
export function ConditionPicker({
activeConditions,
onToggle,
onClose,
}: 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();
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(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
const active = new Set(activeConditions ?? []);
return (
<div
ref={ref}
className={cn(
"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];
if (!Icon) return null;
const isActive = active.has(def.id);
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
return (
<button
key={def.id}
type="button"
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-hover-neutral-bg",
isActive && "bg-card/50",
)}
onClick={() => onToggle(def.id)}
>
<Icon
size={14}
className={isActive ? colorClass : "text-muted-foreground"}
/>
<span
className={isActive ? "text-foreground" : "text-muted-foreground"}
>
{def.label}
</span>
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,102 @@
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
import type { LucideIcon } from "lucide-react";
import {
ArrowDown,
Ban,
BatteryLow,
Droplet,
EarOff,
EyeOff,
Gem,
Ghost,
Hand,
Heart,
Link,
Moon,
Plus,
Siren,
Sparkles,
ZapOff,
} from "lucide-react";
const ICON_MAP: Record<string, LucideIcon> = {
EyeOff,
Heart,
EarOff,
BatteryLow,
Siren,
Hand,
Ban,
Ghost,
ZapOff,
Gem,
Droplet,
ArrowDown,
Link,
Sparkles,
Moon,
};
const COLOR_CLASSES: Record<string, string> = {
neutral: "text-muted-foreground",
pink: "text-pink-400",
amber: "text-amber-400",
orange: "text-orange-400",
gray: "text-gray-400",
violet: "text-violet-400",
yellow: "text-yellow-400",
slate: "text-slate-400",
green: "text-green-400",
indigo: "text-indigo-400",
};
interface ConditionTagsProps {
conditions: readonly ConditionId[] | undefined;
onRemove: (conditionId: ConditionId) => void;
onOpenPicker: () => void;
}
export function ConditionTags({
conditions,
onRemove,
onOpenPicker,
}: ConditionTagsProps) {
return (
<div className="flex flex-wrap items-center gap-0.5">
{conditions?.map((condId) => {
const def = CONDITION_DEFINITIONS.find((d) => d.id === condId);
if (!def) return null;
const Icon = ICON_MAP[def.iconName];
if (!Icon) return null;
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
return (
<button
key={condId}
type="button"
title={def.label}
aria-label={`Remove ${def.label}`}
className={`inline-flex items-center rounded p-0.5 hover:bg-hover-neutral-bg transition-colors ${colorClass}`}
onClick={(e) => {
e.stopPropagation();
onRemove(condId);
}}
>
<Icon size={14} />
</button>
);
})}
<button
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 pointer-coarse:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
onOpenPicker();
}}
>
<Plus size={14} />
</button>
</div>
);
}

View File

@@ -0,0 +1,29 @@
interface D20IconProps {
readonly className?: string;
}
export function D20Icon({ className }: D20IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
width="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<polygon points="20.22 16.76 20.22 7.26 12.04 2.51 3.85 7.26 3.85 16.76 12.04 21.51 20.22 16.76" />
<line x1="7.29" y1="9.26" x2="3.85" y2="7.26" />
<line x1="20.22" y1="7.26" x2="16.79" y2="9.26" />
<line x1="12.04" y1="17.44" x2="12.04" y2="21.51" />
<polygon points="12.04 17.44 20.22 16.76 16.79 9.26 12.04 17.44" />
<polygon points="12.04 17.44 7.29 9.26 3.85 16.76 12.04 17.44" />
<polygon points="12.04 2.51 7.29 9.26 16.79 9.26 12.04 2.51" />
</svg>
);
}

View File

@@ -0,0 +1,139 @@
import { Heart, Sword } from "lucide-react";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
interface HpAdjustPopoverProps {
readonly onAdjust: (delta: number) => void;
readonly onClose: () => void;
}
export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
const [inputValue, setInputValue] = useState("");
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const ref = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const parent = el.parentElement;
if (!parent) return;
const trigger = parent.getBoundingClientRect();
const popover = el.getBoundingClientRect();
const vw = document.documentElement.clientWidth;
let left = trigger.left;
if (left + popover.width > vw) {
left = vw - popover.width - 8;
}
if (left < 8) {
left = 8;
}
setPos({ top: trigger.bottom + 4, left });
}, []);
useEffect(() => {
requestAnimationFrame(() => inputRef.current?.focus());
}, []);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
const parsedValue =
inputValue === "" ? null : Number.parseInt(inputValue, 10);
const isValid =
parsedValue !== null && !Number.isNaN(parsedValue) && parsedValue > 0;
const applyDelta = useCallback(
(sign: -1 | 1) => {
if (inputValue === "") return;
const n = Number.parseInt(inputValue, 10);
if (Number.isNaN(n) || n <= 0) return;
onAdjust(sign * n);
onClose();
},
[inputValue, onAdjust, onClose],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
if (e.shiftKey) {
applyDelta(1);
} else {
applyDelta(-1);
}
} else if (e.key === "Escape") {
onClose();
}
},
[applyDelta, onClose],
);
return (
<div
ref={ref}
className="fixed z-10 rounded-md border border-border bg-background p-2 shadow-lg"
style={
pos
? { top: pos.top, left: pos.left }
: { visibility: "hidden" as const }
}
>
<div className="flex items-center gap-1">
<Input
ref={inputRef}
type="text"
inputMode="numeric"
value={inputValue}
placeholder="HP"
className="h-7 w-[7ch] text-center text-sm tabular-nums"
onChange={(e) => {
const v = e.target.value;
if (v === "" || /^\d+$/.test(v)) {
setInputValue(v);
}
}}
onKeyDown={handleKeyDown}
/>
<Button
type="button"
variant="ghost"
size="icon"
disabled={!isValid}
className="h-7 w-7 shrink-0 text-red-400 hover:bg-red-950 hover:text-red-300"
onClick={() => applyDelta(-1)}
title="Apply damage"
aria-label="Apply damage"
>
<Sword size={14} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
disabled={!isValid}
className="h-7 w-7 shrink-0 text-emerald-400 hover:bg-emerald-950 hover:text-emerald-300"
onClick={() => applyDelta(1)}
title="Apply healing"
aria-label="Apply healing"
>
<Heart size={14} />
</Button>
</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,81 @@
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" 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="text-muted-foreground hover:text-hover-danger"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,344 @@
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;
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;
}
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)");
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
useEffect(() => {
if (!creatureId || creature) {
setNeedsFetch(false);
return;
}
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 (
<BulkImportPrompt
importState={bulkImportState}
onStartImport={onStartBulkImport}
onDone={onBulkImportDone}
/>
);
}
if (checkingCache) {
return (
<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}
/>
);
}
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,259 @@
import {
type Creature,
calculateInitiative,
formatInitiativeModifier,
} from "@initiative/domain";
interface StatBlockProps {
creature: Creature;
}
function abilityMod(score: number): string {
const mod = Math.floor((score - 10) / 2);
return mod >= 0 ? `+${mod}` : `${mod}`;
}
function PropertyLine({
label,
value,
}: {
label: string;
value: string | undefined;
}) {
if (!value) return null;
return (
<div className="text-sm">
<span className="font-semibold">{label}</span> {value}
</div>
);
}
function SectionDivider() {
return (
<div className="my-2 h-px bg-gradient-to-r from-amber-800/60 via-amber-700/40 to-transparent" />
);
}
export function StatBlock({ creature }: StatBlockProps) {
const abilities = [
{ label: "STR", score: creature.abilities.str },
{ label: "DEX", score: creature.abilities.dex },
{ label: "CON", score: creature.abilities.con },
{ label: "INT", score: creature.abilities.int },
{ label: "WIS", score: creature.abilities.wis },
{ label: "CHA", score: creature.abilities.cha },
];
const initiative = calculateInitiative({
dexScore: creature.abilities.dex,
cr: creature.cr,
initiativeProficiency: creature.initiativeProficiency,
});
return (
<div className="space-y-1 text-foreground">
{/* Header */}
<div>
<h2 className="text-xl font-bold text-amber-400">{creature.name}</h2>
<p className="text-sm italic text-muted-foreground">
{creature.size} {creature.type}, {creature.alignment}
</p>
<p className="text-xs text-muted-foreground">
{creature.sourceDisplayName}
</p>
</div>
<SectionDivider />
{/* Stats bar */}
<div className="space-y-0.5 text-sm">
<div>
<span className="font-semibold">Armor Class</span> {creature.ac}
{creature.acSource && (
<span className="text-muted-foreground">
{" "}
({creature.acSource})
</span>
)}
<span className="ml-3">
<span className="font-semibold">Initiative</span>{" "}
{formatInitiativeModifier(initiative.modifier)} (
{initiative.passive})
</span>
</div>
<div>
<span className="font-semibold">Hit Points</span>{" "}
{creature.hp.average}{" "}
<span className="text-muted-foreground">({creature.hp.formula})</span>
</div>
<div>
<span className="font-semibold">Speed</span> {creature.speed}
</div>
</div>
<SectionDivider />
{/* Ability scores */}
<div className="grid grid-cols-6 gap-1 text-center text-sm">
{abilities.map(({ label, score }) => (
<div key={label}>
<div className="font-semibold">{label}</div>
<div>
{score}{" "}
<span className="text-muted-foreground">
({abilityMod(score)})
</span>
</div>
</div>
))}
</div>
<SectionDivider />
{/* Properties */}
<div className="space-y-0.5">
<PropertyLine label="Saving Throws" value={creature.savingThrows} />
<PropertyLine label="Skills" value={creature.skills} />
<PropertyLine
label="Damage Vulnerabilities"
value={creature.vulnerable}
/>
<PropertyLine label="Damage Resistances" value={creature.resist} />
<PropertyLine label="Damage Immunities" value={creature.immune} />
<PropertyLine
label="Condition Immunities"
value={creature.conditionImmune}
/>
<PropertyLine label="Senses" value={creature.senses} />
<PropertyLine label="Languages" value={creature.languages} />
<div className="text-sm">
<span className="font-semibold">Challenge</span> {creature.cr}{" "}
<span className="text-muted-foreground">
(Proficiency Bonus +{creature.proficiencyBonus})
</span>
</div>
</div>
{/* Traits */}
{creature.traits && creature.traits.length > 0 && (
<>
<SectionDivider />
<div className="space-y-2">
{creature.traits.map((t) => (
<div key={t.name} className="text-sm">
<span className="font-semibold italic">{t.name}.</span> {t.text}
</div>
))}
</div>
</>
)}
{/* Spellcasting */}
{creature.spellcasting && creature.spellcasting.length > 0 && (
<>
<SectionDivider />
{creature.spellcasting.map((sc) => (
<div key={sc.name} className="space-y-1 text-sm">
<div>
<span className="font-semibold italic">{sc.name}.</span>{" "}
{sc.headerText}
</div>
{sc.atWill && sc.atWill.length > 0 && (
<div className="pl-2">
<span className="font-semibold">At Will:</span>{" "}
{sc.atWill.join(", ")}
</div>
)}
{sc.daily?.map((d) => (
<div key={`${d.uses}${d.each ? "e" : ""}`} className="pl-2">
<span className="font-semibold">
{d.uses}/day
{d.each ? " each" : ""}:
</span>{" "}
{d.spells.join(", ")}
</div>
))}
{sc.restLong?.map((d) => (
<div
key={`rest-${d.uses}${d.each ? "e" : ""}`}
className="pl-2"
>
<span className="font-semibold">
{d.uses}/long rest
{d.each ? " each" : ""}:
</span>{" "}
{d.spells.join(", ")}
</div>
))}
</div>
))}
</>
)}
{/* Actions */}
{creature.actions && creature.actions.length > 0 && (
<>
<SectionDivider />
<h3 className="text-base font-bold text-amber-400">Actions</h3>
<div className="space-y-2">
{creature.actions.map((a) => (
<div key={a.name} className="text-sm">
<span className="font-semibold italic">{a.name}.</span> {a.text}
</div>
))}
</div>
</>
)}
{/* Bonus Actions */}
{creature.bonusActions && creature.bonusActions.length > 0 && (
<>
<SectionDivider />
<h3 className="text-base font-bold text-amber-400">Bonus Actions</h3>
<div className="space-y-2">
{creature.bonusActions.map((a) => (
<div key={a.name} className="text-sm">
<span className="font-semibold italic">{a.name}.</span> {a.text}
</div>
))}
</div>
</>
)}
{/* Reactions */}
{creature.reactions && creature.reactions.length > 0 && (
<>
<SectionDivider />
<h3 className="text-base font-bold text-amber-400">Reactions</h3>
<div className="space-y-2">
{creature.reactions.map((a) => (
<div key={a.name} className="text-sm">
<span className="font-semibold italic">{a.name}.</span> {a.text}
</div>
))}
</div>
</>
)}
{/* Legendary Actions */}
{creature.legendaryActions && (
<>
<SectionDivider />
<h3 className="text-base font-bold text-amber-400">
Legendary Actions
</h3>
<p className="text-sm italic text-muted-foreground">
{creature.legendaryActions.preamble}
</p>
<div className="space-y-2">
{creature.legendaryActions.entries.map((a) => (
<div key={a.name} className="text-sm">
<span className="font-semibold italic">{a.name}.</span> {a.text}
</div>
))}
</div>
</>
)}
</div>
);
}

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

@@ -0,0 +1,101 @@
import type { Encounter } from "@initiative/domain";
import { Settings, 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;
onAdvanceTurn: () => void;
onRetreatTurn: () => void;
onClearEncounter: () => void;
onRollAllInitiative: () => void;
onOpenSourceManager: () => void;
}
export function TurnNavigation({
encounter,
onAdvanceTurn,
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 gap-3 rounded-md border border-border bg-card px-4 py-3">
<div className="flex flex-shrink-0 items-center gap-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"
aria-label="Previous turn"
>
<StepBack className="h-5 w-5" />
</Button>
<span className="rounded-full bg-muted text-foreground text-sm px-2 py-0.5 font-semibold">
R{encounter.roundNumber}
</span>
</div>
<div className="min-w-0 flex-1 text-center text-sm">
{activeCombatant ? (
<span className="truncate block font-medium">
{activeCombatant.name}
</span>
) : (
<span className="text-muted-foreground">No combatants</span>
)}
</div>
<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"
onClick={onRollAllInitiative}
title="Roll all initiative"
aria-label="Roll all initiative"
>
<D20Icon className="h-6 w-6" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-hover-neutral"
onClick={onOpenSourceManager}
title="Manage cached sources"
aria-label="Manage cached sources"
>
<Settings className="h-5 w-5" />
</Button>
<ConfirmButton
icon={<Trash2 className="h-5 w-5" />}
label="Clear encounter"
onConfirm={onClearEncounter}
disabled={!hasCombatants}
className="h-8 w-8 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"
aria-label="Next turn"
>
<StepForward className="h-5 w-5" />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { cva, type VariantProps } from "class-variance-authority";
import type { ButtonHTMLAttributes } from "react";
import { cn } from "../../lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
outline:
"border border-border bg-transparent hover:bg-hover-neutral-bg hover:text-hover-neutral",
ghost: "hover:bg-hover-neutral-bg hover:text-hover-neutral",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 px-3 text-xs",
icon: "h-8 w-8",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants>;
export function Button({ className, variant, size, ...props }: ButtonProps) {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}

View File

@@ -0,0 +1,114 @@
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",
)}
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

@@ -0,0 +1,19 @@
import { forwardRef, type InputHTMLAttributes } from "react";
import { cn } from "../../lib/utils";
type InputProps = InputHTMLAttributes<HTMLInputElement>;
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, ...props }, ref) => {
return (
<input
ref={ref}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
);
},
);

View File

@@ -0,0 +1,126 @@
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) => SearchResult[];
getCreature: (id: CreatureId) => Creature | undefined;
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 [isLoaded, setIsLoaded] = useState(false);
const creatureMapRef = useRef<Map<CreatureId, Creature>>(new Map());
const [, setTick] = useState(0);
useEffect(() => {
const index = loadBestiaryIndex();
setSourceDisplayNames(index.sources as Record<string, string>);
if (index.creatures.length > 0) {
setIsLoaded(true);
}
bestiaryCache.loadAllCachedCreatures().then((map) => {
creatureMapRef.current = map;
setTick((t) => t + 1);
});
}, []);
const search = useCallback((query: string): SearchResult[] => {
if (query.length < 2) return [];
const lower = query.toLowerCase();
const index = loadBestiaryIndex();
return index.creatures
.filter((c) => c.name.toLowerCase().includes(lower))
.sort((a, b) => a.name.localeCompare(b.name))
.slice(0, 10)
.map((c) => ({
...c,
sourceDisplayName: getSourceDisplayName(c.source),
}));
}, []);
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

@@ -0,0 +1,348 @@
import type { EncounterStore } from "@initiative/application";
import {
addCombatantUseCase,
adjustHpUseCase,
advanceTurnUseCase,
clearEncounterUseCase,
editCombatantUseCase,
removeCombatantUseCase,
retreatTurnUseCase,
setAcUseCase,
setHpUseCase,
setInitiativeUseCase,
toggleConcentrationUseCase,
toggleConditionUseCase,
} from "@initiative/application";
import type {
BestiaryIndexEntry,
CombatantId,
ConditionId,
DomainEvent,
Encounter,
} from "@initiative/domain";
import {
combatantId,
createEncounter,
isDomainError,
creatureId as makeCreatureId,
resolveCreatureName,
} from "@initiative/domain";
import { useCallback, useEffect, useRef, useState } from "react";
import {
loadEncounter,
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;
}
function initializeEncounter(): Encounter {
const stored = loadEncounter();
if (stored !== null) return stored;
return createDemoEncounter();
}
function deriveNextId(encounter: Encounter): number {
let max = 0;
for (const c of encounter.combatants) {
const match = /^c-(\d+)$/.exec(c.id);
if (match) {
const n = Number.parseInt(match[1], 10);
if (n > max) max = n;
}
}
return max;
}
interface CombatantOpts {
initiative?: number;
ac?: number;
maxHp?: number;
}
function applyCombatantOpts(
makeStore: () => EncounterStore,
id: ReturnType<typeof combatantId>,
opts: CombatantOpts,
): DomainEvent[] {
const events: DomainEvent[] = [];
if (opts.maxHp !== undefined) {
const r = setHpUseCase(makeStore(), id, opts.maxHp);
if (!isDomainError(r)) events.push(...r);
}
if (opts.ac !== undefined) {
const r = setAcUseCase(makeStore(), id, opts.ac);
if (!isDomainError(r)) events.push(...r);
}
if (opts.initiative !== undefined) {
const r = setInitiativeUseCase(makeStore(), id, opts.initiative);
if (!isDomainError(r)) events.push(...r);
}
return events;
}
export function useEncounter() {
const [encounter, setEncounter] = useState<Encounter>(initializeEncounter);
const [events, setEvents] = useState<DomainEvent[]>([]);
const encounterRef = useRef(encounter);
encounterRef.current = encounter;
useEffect(() => {
saveEncounter(encounter);
}, [encounter]);
const makeStore = useCallback((): EncounterStore => {
return {
get: () => encounterRef.current,
save: (e) => {
encounterRef.current = e;
setEncounter(e);
},
};
}, []);
const advanceTurn = useCallback(() => {
const result = advanceTurnUseCase(makeStore());
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
}, [makeStore]);
const retreatTurn = useCallback(() => {
const result = retreatTurnUseCase(makeStore());
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
}, [makeStore]);
const nextId = useRef(deriveNextId(encounter));
const addCombatant = useCallback(
(name: string, opts?: CombatantOpts) => {
const id = combatantId(`c-${++nextId.current}`);
const result = addCombatantUseCase(makeStore(), id, name);
if (isDomainError(result)) {
return;
}
if (opts) {
const optEvents = applyCombatantOpts(makeStore, id, opts);
if (optEvents.length > 0) {
setEvents((prev) => [...prev, ...optEvents]);
}
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
);
const removeCombatant = useCallback(
(id: CombatantId) => {
const result = removeCombatantUseCase(makeStore(), id);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
);
const editCombatant = useCallback(
(id: CombatantId, newName: string) => {
const result = editCombatantUseCase(makeStore(), id, newName);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
);
const setInitiative = useCallback(
(id: CombatantId, value: number | undefined) => {
const result = setInitiativeUseCase(makeStore(), id, value);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
);
const setHp = useCallback(
(id: CombatantId, maxHp: number | undefined) => {
const result = setHpUseCase(makeStore(), id, maxHp);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
);
const adjustHp = useCallback(
(id: CombatantId, delta: number) => {
const result = adjustHpUseCase(makeStore(), id, delta);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
);
const setAc = useCallback(
(id: CombatantId, value: number | undefined) => {
const result = setAcUseCase(makeStore(), id, value);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
);
const toggleCondition = useCallback(
(id: CombatantId, conditionId: ConditionId) => {
const result = toggleConditionUseCase(makeStore(), id, conditionId);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
);
const toggleConcentration = useCallback(
(id: CombatantId) => {
const result = toggleConcentrationUseCase(makeStore(), id);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
);
const clearEncounter = useCallback(() => {
const result = clearEncounterUseCase(makeStore());
if (isDomainError(result)) {
return;
}
nextId.current = 0;
setEvents((prev) => [...prev, ...result]);
}, [makeStore]);
const addFromBestiary = useCallback(
(entry: BestiaryIndexEntry) => {
const store = makeStore();
const existingNames = store.get().combatants.map((c) => c.name);
const { newName, renames } = resolveCreatureName(
entry.name,
existingNames,
);
// Apply renames (e.g., "Goblin" → "Goblin 1")
for (const { from, to } of renames) {
const target = store.get().combatants.find((c) => c.name === from);
if (target) {
editCombatantUseCase(makeStore(), target.id, to);
}
}
// Add combatant with resolved name
const id = combatantId(`c-${++nextId.current}`);
const addResult = addCombatantUseCase(makeStore(), id, newName);
if (isDomainError(addResult)) return;
// Set HP
const hpResult = setHpUseCase(makeStore(), id, entry.hp);
if (!isDomainError(hpResult)) {
setEvents((prev) => [...prev, ...hpResult]);
}
// Set AC
if (entry.ac > 0) {
const acResult = setAcUseCase(makeStore(), id, entry.ac);
if (!isDomainError(acResult)) {
setEvents((prev) => [...prev, ...acResult]);
}
}
// Derive creatureId from source + name
const slug = entry.name
.toLowerCase()
.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();
store.save({
...currentEncounter,
combatants: currentEncounter.combatants.map((c) =>
c.id === id ? { ...c, creatureId: cId } : c,
),
});
setEvents((prev) => [...prev, ...addResult]);
},
[makeStore, editCombatant],
);
return {
encounter,
events,
advanceTurn,
retreatTurn,
addCombatant,
clearEncounter,
removeCombatant,
editCombatant,
setInitiative,
setHp,
adjustHp,
setAc,
toggleCondition,
toggleConcentration,
addFromBestiary,
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 },
};
}

112
apps/web/src/index.css Normal file
View File

@@ -0,0 +1,112 @@
@import "tailwindcss";
@theme {
--color-background: #0f172a;
--color-foreground: #e2e8f0;
--color-muted: #64748b;
--color-muted-foreground: #94a3b8;
--color-card: #1e293b;
--color-card-foreground: #e2e8f0;
--color-border: #334155;
--color-input: #334155;
--color-primary: #3b82f6;
--color-primary-foreground: #ffffff;
--color-accent: #3b82f6;
--color-destructive: #ef4444;
--color-hover-neutral: var(--color-primary);
--color-hover-action: var(--color-primary);
--color-hover-destructive: var(--color-destructive);
--color-hover-neutral-bg: var(--color-card);
--color-hover-action-bg: var(--color-muted);
--color-hover-destructive-bg: transparent;
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
}
@keyframes concentration-shake {
0% {
translate: 0;
}
20% {
translate: -3px;
}
40% {
translate: 3px;
}
60% {
translate: -2px;
}
80% {
translate: 1px;
}
100% {
translate: 0;
}
}
@keyframes concentration-glow {
0% {
box-shadow: 0 0 4px 2px #c084fc;
}
100% {
box-shadow: 0 0 0 0 transparent;
}
}
@keyframes slide-in-right {
from {
translate: 100%;
}
to {
translate: 0;
}
}
@utility animate-slide-in-right {
animation: slide-in-right 200ms ease-out;
}
@keyframes confirm-pulse {
0% {
scale: 1;
}
50% {
scale: 1.15;
}
100% {
scale: 1;
}
}
@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,
concentration-glow 1200ms ease-out;
}
* {
scrollbar-color: var(--color-border) transparent;
scrollbar-width: thin;
}
body {
background-color: var(--color-background);
color: var(--color-foreground);
font-family: var(--font-sans);
}

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -1,6 +1,7 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./index.css";
const root = document.getElementById("root");
if (root) {

View File

@@ -0,0 +1,296 @@
import {
combatantId,
createEncounter,
isDomainError,
} from "@initiative/domain";
import { beforeEach, describe, expect, it } from "vitest";
import { loadEncounter, saveEncounter } from "../encounter-storage.js";
const STORAGE_KEY = "initiative:encounter";
function makeEncounter() {
const result = createEncounter(
[
{ id: combatantId("1"), name: "Aria", initiative: 18 },
{ id: combatantId("c-2"), name: "Brak", initiative: 12 },
{ id: combatantId("3"), name: "Cael" },
],
1,
3,
);
if (isDomainError(result)) throw new Error("Failed to create test encounter");
return result;
}
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,
} as Storage;
}
beforeEach(() => {
Object.defineProperty(globalThis, "localStorage", {
value: createMockLocalStorage(),
writable: true,
configurable: true,
});
});
describe("saveEncounter", () => {
it("writes encounter to localStorage", () => {
const encounter = makeEncounter();
saveEncounter(encounter);
expect(localStorage.getItem(STORAGE_KEY)).not.toBeNull();
});
});
describe("loadEncounter", () => {
it("returns null when localStorage is empty", () => {
expect(loadEncounter()).toBeNull();
});
it("round-trip save/load preserves encounter state", () => {
const encounter = makeEncounter();
saveEncounter(encounter);
const loaded = loadEncounter();
expect(loaded).not.toBeNull();
expect(loaded?.combatants).toHaveLength(3);
expect(loaded?.activeIndex).toBe(1);
expect(loaded?.roundNumber).toBe(3);
});
it("round-trip preserves combatant IDs, names, and initiative values", () => {
const encounter = makeEncounter();
saveEncounter(encounter);
const loaded = loadEncounter();
expect(loaded).not.toBeNull();
expect(loaded?.combatants[0].id).toBe("1");
expect(loaded?.combatants[0].name).toBe("Aria");
expect(loaded?.combatants[0].initiative).toBe(18);
expect(loaded?.combatants[1].id).toBe("c-2");
expect(loaded?.combatants[1].name).toBe("Brak");
expect(loaded?.combatants[1].initiative).toBe(12);
expect(loaded?.combatants[2].id).toBe("3");
expect(loaded?.combatants[2].name).toBe("Cael");
expect(loaded?.combatants[2].initiative).toBeUndefined();
});
it("returns null for non-JSON strings", () => {
localStorage.setItem(STORAGE_KEY, "not json at all");
expect(loadEncounter()).toBeNull();
});
it("returns null for JSON missing required fields", () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ foo: "bar" }));
expect(loadEncounter()).toBeNull();
});
it("returns empty encounter for cleared state (empty combatants)", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ combatants: [], activeIndex: 0, roundNumber: 1 }),
);
const result = loadEncounter();
expect(result).toEqual({
combatants: [],
activeIndex: 0,
roundNumber: 1,
});
});
it("returns null for out-of-bounds activeIndex", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
combatants: [{ id: "1", name: "Aria" }],
activeIndex: 5,
roundNumber: 1,
}),
);
expect(loadEncounter()).toBeNull();
});
// US3: Corrupt data scenarios
it("returns null for non-object JSON (string)", () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify("hello"));
expect(loadEncounter()).toBeNull();
});
it("returns null for non-object JSON (number)", () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(42));
expect(loadEncounter()).toBeNull();
});
it("returns null for non-object JSON (array)", () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify([1, 2, 3]));
expect(loadEncounter()).toBeNull();
});
it("returns null for non-object JSON (null)", () => {
localStorage.setItem(STORAGE_KEY, "null");
expect(loadEncounter()).toBeNull();
});
it("returns null when combatants is a string instead of array", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
combatants: "not-array",
activeIndex: 0,
roundNumber: 1,
}),
);
expect(loadEncounter()).toBeNull();
});
it("returns null when activeIndex is a string instead of number", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
combatants: [{ id: "1", name: "Aria" }],
activeIndex: "zero",
roundNumber: 1,
}),
);
expect(loadEncounter()).toBeNull();
});
it("returns null when combatant entry is missing id", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
combatants: [{ name: "Aria" }],
activeIndex: 0,
roundNumber: 1,
}),
);
expect(loadEncounter()).toBeNull();
});
it("returns null when combatant entry is missing name", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
combatants: [{ id: "1" }],
activeIndex: 0,
roundNumber: 1,
}),
);
expect(loadEncounter()).toBeNull();
});
it("returns null for negative roundNumber", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
combatants: [{ id: "1", name: "Aria" }],
activeIndex: 0,
roundNumber: -1,
}),
);
expect(loadEncounter()).toBeNull();
});
it("returns empty encounter for zero combatants (cleared state)", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ combatants: [], activeIndex: 0, roundNumber: 1 }),
);
const result = loadEncounter();
expect(result).toEqual({
combatants: [],
activeIndex: 0,
roundNumber: 1,
});
});
it("round-trip preserves combatant AC value", () => {
const result = createEncounter(
[{ id: combatantId("1"), name: "Aria", ac: 18 }],
0,
1,
);
if (isDomainError(result)) throw new Error("unreachable");
saveEncounter(result);
const loaded = loadEncounter();
expect(loaded?.combatants[0].ac).toBe(18);
});
it("round-trip preserves combatant without AC", () => {
const result = createEncounter(
[{ id: combatantId("1"), name: "Aria" }],
0,
1,
);
if (isDomainError(result)) throw new Error("unreachable");
saveEncounter(result);
const loaded = loadEncounter();
expect(loaded?.combatants[0].ac).toBeUndefined();
});
it("discards invalid AC values during rehydration", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
combatants: [
{ id: "1", name: "Neg", ac: -1 },
{ id: "2", name: "Float", ac: 3.5 },
{ id: "3", name: "Str", ac: "high" },
],
activeIndex: 0,
roundNumber: 1,
}),
);
const loaded = loadEncounter();
expect(loaded).not.toBeNull();
expect(loaded?.combatants[0].ac).toBeUndefined();
expect(loaded?.combatants[1].ac).toBeUndefined();
expect(loaded?.combatants[2].ac).toBeUndefined();
});
it("preserves AC of 0 during rehydration", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
combatants: [{ id: "1", name: "Aria", ac: 0 }],
activeIndex: 0,
roundNumber: 1,
}),
);
const loaded = loadEncounter();
expect(loaded?.combatants[0].ac).toBe(0);
});
it("saving after modifications persists the latest state", () => {
const encounter = makeEncounter();
saveEncounter(encounter);
const modified = createEncounter(
[
{ id: combatantId("1"), name: "Aria", initiative: 18 },
{ id: combatantId("c-2"), name: "Brak", initiative: 12 },
],
0,
5,
);
if (isDomainError(modified)) throw new Error("unreachable");
saveEncounter(modified);
const loaded = loadEncounter();
expect(loaded?.combatants).toHaveLength(2);
expect(loaded?.roundNumber).toBe(5);
});
});

View File

@@ -0,0 +1,133 @@
import {
type ConditionId,
combatantId,
createEncounter,
creatureId,
type Encounter,
isDomainError,
VALID_CONDITION_IDS,
} from "@initiative/domain";
const STORAGE_KEY = "initiative:encounter";
export function saveEncounter(encounter: Encounter): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(encounter));
} catch {
// Silently swallow errors (quota exceeded, storage unavailable)
}
}
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 shared = {
...base,
ac: validateAc(entry.ac),
conditions: validateConditions(entry.conditions),
isConcentrating: entry.isConcentrating === true ? true : undefined,
creatureId: validateCreatureId(entry.creatureId),
};
const hp = validateHp(entry.maxHp, entry.currentHp);
return hp ? { ...shared, ...hp } : shared;
}
function isValidCombatantEntry(c: unknown): boolean {
if (typeof c !== "object" || c === null || Array.isArray(c)) return false;
const entry = c as Record<string, unknown>;
return typeof entry.id === "string" && typeof entry.name === "string";
}
export function loadEncounter(): Encounter | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) return null;
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
return null;
const obj = parsed as Record<string, unknown>;
if (!Array.isArray(obj.combatants)) return null;
if (typeof obj.activeIndex !== "number") return null;
if (typeof obj.roundNumber !== "number") return null;
const combatants = obj.combatants as unknown[];
// Handle empty encounter (cleared state) directly — createEncounter rejects empty arrays
if (combatants.length === 0) {
return {
combatants: [],
activeIndex: 0,
roundNumber: 1,
};
}
if (!combatants.every(isValidCombatantEntry)) return null;
const rehydrated = combatants.map(rehydrateCombatant);
const result = createEncounter(
rehydrated,
obj.activeIndex,
obj.roundNumber,
);
if (isDomainError(result)) return null;
return result;
} catch {
return null;
}
}

View File

@@ -1,6 +1,7 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
plugins: [tailwindcss(), react()],
});

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
}
}
}
}
}
}

19
d20.svg Normal file
View File

@@ -0,0 +1,19 @@
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
width="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1"
stroke-linecap="round"
stroke-linejoin="round"
>
<polygon points="20.22 16.76 20.22 7.26 12.04 2.51 3.85 7.26 3.85 16.76 12.04 21.51 20.22 16.76"/>
<line x1="7.29" y1="9.26" x2="3.85" y2="7.26"/>
<line x1="20.22" y1="7.26" x2="16.79" y2="9.26"/>
<line x1="12.04" y1="17.44" x2="12.04" y2="21.51"/>
<polygon points="12.04 17.44 20.22 16.76 16.79 9.26 12.04 17.44"/>
<polygon points="12.04 17.44 7.29 9.26 3.85 16.76 12.04 17.44"/>
<polygon points="12.04 2.51 7.29 9.26 16.79 9.26 12.04 2.51"/>
</svg>

After

Width:  |  Height:  |  Size: 667 B

36540
data/bestiary/index.json Normal file

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

10
knip.json Normal file
View File

@@ -0,0 +1,10 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"workspaces": {
".": {
"entry": ["scripts/*.mjs"]
},
"packages/*": {},
"apps/*": {}
}
}

4
lefthook.yml Normal file
View File

@@ -0,0 +1,4 @@
pre-commit:
jobs:
- name: check
run: pnpm check

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,10 +3,15 @@
"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",
"typescript": "^5.8.0",
"vitest": "^3.0.0"
},
"scripts": {
"prepare": "lefthook install",
"format": "biome format --write .",
"format:check": "biome format .",
"lint": "biome lint .",
@@ -14,6 +19,8 @@
"typecheck": "tsc --build",
"test": "vitest run",
"test:watch": "vitest",
"check": "biome check . && tsc --build && vitest run"
"knip": "knip",
"jscpd": "jscpd",
"check": "pnpm audit --audit-level=high && knip && biome check . && tsc --build && vitest run && jscpd"
}
}

View File

@@ -0,0 +1,24 @@
import {
addCombatant,
type CombatantId,
type DomainError,
type DomainEvent,
isDomainError,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
export function addCombatantUseCase(
store: EncounterStore,
id: CombatantId,
name: string,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = addCombatant(encounter, id, name);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
}

View File

@@ -0,0 +1,24 @@
import {
adjustHp,
type CombatantId,
type DomainError,
type DomainEvent,
isDomainError,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
export function adjustHpUseCase(
store: EncounterStore,
combatantId: CombatantId,
delta: number,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = adjustHp(encounter, combatantId, delta);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
}

View File

@@ -0,0 +1,21 @@
import {
advanceTurn,
type DomainError,
type DomainEvent,
isDomainError,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
export function advanceTurnUseCase(
store: EncounterStore,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = advanceTurn(encounter);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
}

View File

@@ -0,0 +1,21 @@
import {
clearEncounter,
type DomainError,
type DomainEvent,
isDomainError,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
export function clearEncounterUseCase(
store: EncounterStore,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = clearEncounter(encounter);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
}

View File

@@ -0,0 +1,24 @@
import {
type CombatantId,
type DomainError,
type DomainEvent,
editCombatant,
isDomainError,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
export function editCombatantUseCase(
store: EncounterStore,
id: CombatantId,
newName: string,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = editCombatant(encounter, id, newName);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
}

View File

@@ -1 +1,15 @@
export {};
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 { editCombatantUseCase } from "./edit-combatant-use-case.js";
export type { BestiarySourceCache, EncounterStore } 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";
export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
export { setAcUseCase } from "./set-ac-use-case.js";
export { setHpUseCase } from "./set-hp-use-case.js";
export { setInitiativeUseCase } from "./set-initiative-use-case.js";
export { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js";
export { toggleConditionUseCase } from "./toggle-condition-use-case.js";

View File

@@ -0,0 +1,11 @@
import type { Creature, CreatureId, Encounter } from "@initiative/domain";
export interface EncounterStore {
get(): Encounter;
save(encounter: Encounter): void;
}
export interface BestiarySourceCache {
getCreature(creatureId: CreatureId): Creature | undefined;
isSourceCached(sourceCode: string): boolean;
}

View File

@@ -0,0 +1,23 @@
import {
type CombatantId,
type DomainError,
type DomainEvent,
isDomainError,
removeCombatant,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
export function removeCombatantUseCase(
store: EncounterStore,
id: CombatantId,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = removeCombatant(encounter, id);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
}

View File

@@ -0,0 +1,21 @@
import {
type DomainError,
type DomainEvent,
isDomainError,
retreatTurn,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
export function retreatTurnUseCase(
store: EncounterStore,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = retreatTurn(encounter);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
}

View File

@@ -0,0 +1,51 @@
import {
type Creature,
type CreatureId,
calculateInitiative,
type DomainError,
type DomainEvent,
isDomainError,
rollInitiative,
setInitiative,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
export function rollAllInitiativeUseCase(
store: EncounterStore,
rollDice: () => number,
getCreature: (id: CreatureId) => Creature | undefined,
): DomainEvent[] | DomainError {
let encounter = store.get();
const allEvents: DomainEvent[] = [];
for (const combatant of encounter.combatants) {
if (!combatant.creatureId) continue;
if (combatant.initiative !== undefined) continue;
const creature = getCreature(combatant.creatureId);
if (!creature) continue;
const { modifier } = calculateInitiative({
dexScore: creature.abilities.dex,
cr: creature.cr,
initiativeProficiency: creature.initiativeProficiency,
});
const value = rollInitiative(rollDice(), modifier);
if (isDomainError(value)) {
return value;
}
const result = setInitiative(encounter, combatant.id, value);
if (isDomainError(result)) {
return result;
}
encounter = result.encounter;
allEvents.push(...result.events);
}
store.save(encounter);
return allEvents;
}

View File

@@ -0,0 +1,67 @@
import {
type CombatantId,
type Creature,
type CreatureId,
calculateInitiative,
type DomainError,
type DomainEvent,
isDomainError,
rollInitiative,
setInitiative,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
export function rollInitiativeUseCase(
store: EncounterStore,
combatantId: CombatantId,
diceRoll: number,
getCreature: (id: CreatureId) => Creature | undefined,
): DomainEvent[] | DomainError {
const encounter = store.get();
const combatant = encounter.combatants.find((c) => c.id === combatantId);
if (!combatant) {
return {
kind: "domain-error",
code: "combatant-not-found",
message: `No combatant found with ID "${combatantId}"`,
};
}
if (!combatant.creatureId) {
return {
kind: "domain-error",
code: "no-creature-link",
message: `Combatant "${combatant.name}" has no linked creature`,
};
}
const creature = getCreature(combatant.creatureId);
if (!creature) {
return {
kind: "domain-error",
code: "creature-not-found",
message: `Creature not found for ID "${combatant.creatureId}"`,
};
}
const { modifier } = calculateInitiative({
dexScore: creature.abilities.dex,
cr: creature.cr,
initiativeProficiency: creature.initiativeProficiency,
});
const value = rollInitiative(diceRoll, modifier);
if (isDomainError(value)) {
return value;
}
const result = setInitiative(encounter, combatantId, value);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
}

View File

@@ -0,0 +1,24 @@
import {
type CombatantId,
type DomainError,
type DomainEvent,
isDomainError,
setAc,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
export function setAcUseCase(
store: EncounterStore,
combatantId: CombatantId,
value: number | undefined,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = setAc(encounter, combatantId, value);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
}

View File

@@ -0,0 +1,24 @@
import {
type CombatantId,
type DomainError,
type DomainEvent,
isDomainError,
setHp,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
export function setHpUseCase(
store: EncounterStore,
combatantId: CombatantId,
maxHp: number | undefined,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = setHp(encounter, combatantId, maxHp);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
}

View File

@@ -0,0 +1,24 @@
import {
type CombatantId,
type DomainError,
type DomainEvent,
isDomainError,
setInitiative,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
export function setInitiativeUseCase(
store: EncounterStore,
combatantId: CombatantId,
value: number | undefined,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = setInitiative(encounter, combatantId, value);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
}

View File

@@ -0,0 +1,23 @@
import {
type CombatantId,
type DomainError,
type DomainEvent,
isDomainError,
toggleConcentration,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
export function toggleConcentrationUseCase(
store: EncounterStore,
combatantId: CombatantId,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = toggleConcentration(encounter, combatantId);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
}

View File

@@ -0,0 +1,25 @@
import {
type CombatantId,
type ConditionId,
type DomainError,
type DomainEvent,
isDomainError,
toggleCondition,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
export function toggleConditionUseCase(
store: EncounterStore,
combatantId: CombatantId,
conditionId: ConditionId,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = toggleCondition(encounter, combatantId, conditionId);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
}

View File

@@ -0,0 +1,200 @@
import { describe, expect, it } from "vitest";
import { addCombatant } from "../add-combatant.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
// --- Helpers ---
function makeCombatant(name: string): Combatant {
return { id: combatantId(name), name };
}
const A = makeCombatant("A");
const B = makeCombatant("B");
const C = makeCombatant("C");
function enc(
combatants: Combatant[],
activeIndex = 0,
roundNumber = 1,
): Encounter {
return { combatants, activeIndex, roundNumber };
}
function successResult(encounter: Encounter, id: string, name: string) {
const result = addCombatant(encounter, combatantId(id), name);
if (isDomainError(result)) {
throw new Error(`Expected success, got error: ${result.message}`);
}
return result;
}
// --- Acceptance Scenarios ---
describe("addCombatant", () => {
describe("acceptance scenarios", () => {
it("scenario 1: add to empty encounter", () => {
const e = enc([], 0, 1);
const { encounter, events } = successResult(e, "gandalf", "Gandalf");
expect(encounter.combatants).toEqual([
{ id: combatantId("gandalf"), name: "Gandalf" },
]);
expect(encounter.activeIndex).toBe(0);
expect(encounter.roundNumber).toBe(1);
expect(events).toEqual([
{
type: "CombatantAdded",
combatantId: combatantId("gandalf"),
name: "Gandalf",
position: 0,
},
]);
});
it("scenario 2: add to encounter with [A, B]", () => {
const e = enc([A, B], 0, 1);
const { encounter, events } = successResult(e, "C", "C");
expect(encounter.combatants).toEqual([
A,
B,
{ id: combatantId("C"), name: "C" },
]);
expect(encounter.activeIndex).toBe(0);
expect(encounter.roundNumber).toBe(1);
expect(events).toEqual([
{
type: "CombatantAdded",
combatantId: combatantId("C"),
name: "C",
position: 2,
},
]);
});
it("scenario 3: add during mid-round does not change active combatant", () => {
const e = enc([A, B, C], 2, 3);
const { encounter, events } = successResult(e, "D", "D");
expect(encounter.combatants).toHaveLength(4);
expect(encounter.combatants[3]).toEqual({
id: combatantId("D"),
name: "D",
});
expect(encounter.activeIndex).toBe(2);
expect(encounter.roundNumber).toBe(3);
expect(events).toEqual([
{
type: "CombatantAdded",
combatantId: combatantId("D"),
name: "D",
position: 3,
},
]);
});
it("scenario 4: two sequential adds preserve order", () => {
const e = enc([A]);
const first = successResult(e, "B", "B");
const second = successResult(first.encounter, "C", "C");
expect(second.encounter.combatants).toEqual([
A,
{ id: combatantId("B"), name: "B" },
{ id: combatantId("C"), name: "C" },
]);
expect(first.events).toHaveLength(1);
expect(second.events).toHaveLength(1);
});
it("scenario 5: empty name returns error", () => {
const e = enc([A, B]);
const result = addCombatant(e, combatantId("x"), "");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
});
it("scenario 6: whitespace-only name returns error", () => {
const e = enc([A, B]);
const result = addCombatant(e, combatantId("x"), " ");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
});
});
describe("invariants", () => {
it("INV-1: encounter may have zero combatants (adding to empty is valid)", () => {
const e = enc([]);
const result = addCombatant(e, combatantId("a"), "A");
expect(isDomainError(result)).toBe(false);
});
it("INV-2: activeIndex remains valid after adding", () => {
const scenarios: Encounter[] = [
enc([], 0, 1),
enc([A], 0, 1),
enc([A, B, C], 2, 3),
];
for (const e of scenarios) {
const result = successResult(e, "new", "New");
const { combatants, activeIndex } = result.encounter;
if (combatants.length > 0) {
expect(activeIndex).toBeGreaterThanOrEqual(0);
expect(activeIndex).toBeLessThan(combatants.length);
} else {
expect(activeIndex).toBe(0);
}
}
});
it("INV-3: roundNumber is preserved (never decreases)", () => {
const e = enc([A, B], 1, 5);
const { encounter } = successResult(e, "C", "C");
expect(encounter.roundNumber).toBe(5);
});
it("INV-4: determinism — same input produces same output", () => {
const e = enc([A, B], 1, 3);
const result1 = addCombatant(e, combatantId("x"), "X");
const result2 = addCombatant(e, combatantId("x"), "X");
expect(result1).toEqual(result2);
});
it("INV-5: every success emits exactly one CombatantAdded event", () => {
const scenarios: Encounter[] = [enc([]), enc([A]), enc([A, B, C], 2, 5)];
for (const e of scenarios) {
const result = successResult(e, "z", "Z");
expect(result.events).toHaveLength(1);
expect(result.events[0].type).toBe("CombatantAdded");
}
});
it("INV-6: addCombatant does not change activeIndex or roundNumber", () => {
const e = enc([A, B, C], 2, 7);
const { encounter } = successResult(e, "D", "D");
expect(encounter.activeIndex).toBe(2);
expect(encounter.roundNumber).toBe(7);
});
it("INV-7: new combatant is always appended at the end", () => {
const e = enc([A, B]);
const { encounter } = successResult(e, "C", "C");
expect(encounter.combatants[encounter.combatants.length - 1]).toEqual({
id: combatantId("C"),
name: "C",
});
// Existing combatants preserve order
expect(encounter.combatants[0]).toEqual(A);
expect(encounter.combatants[1]).toEqual(B);
});
});
});

View File

@@ -0,0 +1,166 @@
import { describe, expect, it } from "vitest";
import { adjustHp } from "../adjust-hp.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
function makeCombatant(
name: string,
opts?: { maxHp: number; currentHp: number },
): Combatant {
return {
id: combatantId(name),
name,
...(opts ? { maxHp: opts.maxHp, currentHp: opts.currentHp } : {}),
};
}
function enc(combatants: Combatant[]): Encounter {
return { combatants, activeIndex: 0, roundNumber: 1 };
}
function successResult(encounter: Encounter, id: string, delta: number) {
const result = adjustHp(encounter, combatantId(id), delta);
if (isDomainError(result)) {
throw new Error(`Expected success, got error: ${result.message}`);
}
return result;
}
describe("adjustHp", () => {
describe("acceptance scenarios", () => {
it("+1 increases currentHp by 1", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
const { encounter } = successResult(e, "A", 1);
expect(encounter.combatants[0].currentHp).toBe(16);
});
it("-1 decreases currentHp by 1", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
const { encounter } = successResult(e, "A", -1);
expect(encounter.combatants[0].currentHp).toBe(14);
});
it("clamps at 0 — cannot go below zero", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 3 })]);
const { encounter } = successResult(e, "A", -10);
expect(encounter.combatants[0].currentHp).toBe(0);
});
it("clamps at maxHp — cannot exceed max", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 18 })]);
const { encounter } = successResult(e, "A", 10);
expect(encounter.combatants[0].currentHp).toBe(20);
});
});
describe("invariants", () => {
it("is pure — same input produces same output", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const r1 = adjustHp(e, combatantId("A"), -5);
const r2 = adjustHp(e, combatantId("A"), -5);
expect(r1).toEqual(r2);
});
it("does not mutate input encounter", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const original = JSON.parse(JSON.stringify(e));
adjustHp(e, combatantId("A"), -3);
expect(e).toEqual(original);
});
it("emits CurrentHpAdjusted event with delta", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
const { events } = successResult(e, "A", -5);
expect(events).toEqual([
{
type: "CurrentHpAdjusted",
combatantId: combatantId("A"),
previousHp: 15,
newHp: 10,
delta: -5,
},
]);
});
it("preserves activeIndex and roundNumber", () => {
const e = {
combatants: [
makeCombatant("A", { maxHp: 20, currentHp: 10 }),
makeCombatant("B"),
],
activeIndex: 1,
roundNumber: 5,
};
const { encounter } = successResult(e, "A", -3);
expect(encounter.activeIndex).toBe(1);
expect(encounter.roundNumber).toBe(5);
});
});
describe("error cases", () => {
it("returns error for nonexistent combatant", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const result = adjustHp(e, combatantId("Z"), -1);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
});
it("returns error when combatant has no HP tracking", () => {
const e = enc([makeCombatant("A")]);
const result = adjustHp(e, combatantId("A"), -1);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("no-hp-tracking");
}
});
it("returns error for zero delta", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const result = adjustHp(e, combatantId("A"), 0);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("zero-delta");
}
});
it("returns error for non-integer delta", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const result = adjustHp(e, combatantId("A"), 1.5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-delta");
}
});
});
describe("edge cases", () => {
it("large negative delta beyond currentHp clamps to 0", () => {
const e = enc([makeCombatant("A", { maxHp: 100, currentHp: 50 })]);
const { encounter } = successResult(e, "A", -9999);
expect(encounter.combatants[0].currentHp).toBe(0);
});
it("large positive delta beyond maxHp clamps to maxHp", () => {
const e = enc([makeCombatant("A", { maxHp: 100, currentHp: 50 })]);
const { encounter } = successResult(e, "A", 9999);
expect(encounter.combatants[0].currentHp).toBe(100);
});
it("does not affect other combatants", () => {
const e = enc([
makeCombatant("A", { maxHp: 20, currentHp: 15 }),
makeCombatant("B", { maxHp: 30, currentHp: 25 }),
]);
const { encounter } = successResult(e, "A", -5);
expect(encounter.combatants[1].currentHp).toBe(25);
});
it("adjusting from 0 upward works", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 0 })]);
const { encounter } = successResult(e, "A", 5);
expect(encounter.combatants[0].currentHp).toBe(5);
});
});
});

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { resolveCreatureName } from "../auto-number.js";
describe("resolveCreatureName", () => {
it("returns name as-is when no conflict exists", () => {
const result = resolveCreatureName("Goblin", ["Orc", "Dragon"]);
expect(result).toEqual({ newName: "Goblin", renames: [] });
});
it("returns name as-is when existing list is empty", () => {
const result = resolveCreatureName("Goblin", []);
expect(result).toEqual({ newName: "Goblin", renames: [] });
});
it("renames existing to 'Name 1' and new to 'Name 2' on first conflict", () => {
const result = resolveCreatureName("Goblin", ["Orc", "Goblin", "Dragon"]);
expect(result).toEqual({
newName: "Goblin 2",
renames: [{ from: "Goblin", to: "Goblin 1" }],
});
});
it("appends next number when numbered variants already exist", () => {
const result = resolveCreatureName("Goblin", ["Goblin 1", "Goblin 2"]);
expect(result).toEqual({ newName: "Goblin 3", renames: [] });
});
it("handles mixed exact and numbered matches", () => {
const result = resolveCreatureName("Goblin", [
"Goblin",
"Goblin 1",
"Goblin 2",
]);
expect(result).toEqual({ newName: "Goblin 3", renames: [] });
});
it("handles names with special regex characters", () => {
const result = resolveCreatureName("Goblin (Boss)", ["Goblin (Boss)"]);
expect(result).toEqual({
newName: "Goblin (Boss) 2",
renames: [{ from: "Goblin (Boss)", to: "Goblin (Boss) 1" }],
});
});
it("does not match partial name overlaps", () => {
const result = resolveCreatureName("Goblin", ["Goblin Boss"]);
expect(result).toEqual({ newName: "Goblin", renames: [] });
});
});

View File

@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import { clearEncounter } from "../clear-encounter.js";
import type { DomainError, Encounter } from "../types.js";
import { combatantId, createEncounter, isDomainError } from "../types.js";
function makeEncounter(
count: number,
overrides?: Partial<Encounter>,
): Encounter {
const combatants = Array.from({ length: count }, (_, i) => ({
id: combatantId(`c-${i + 1}`),
name: `Combatant ${i + 1}`,
}));
const result = createEncounter(combatants);
if (isDomainError(result)) {
throw new Error("Failed to create encounter in test helper");
}
return { ...result, ...overrides };
}
describe("clearEncounter", () => {
it("clears encounter with multiple combatants at round 3 — returns empty encounter with roundNumber 1 and activeIndex 0", () => {
const encounter = makeEncounter(4, { roundNumber: 3, activeIndex: 2 });
const result = clearEncounter(encounter);
expect(isDomainError(result)).toBe(false);
const success = result as Exclude<typeof result, DomainError>;
expect(success.encounter.combatants).toEqual([]);
expect(success.encounter.roundNumber).toBe(1);
expect(success.encounter.activeIndex).toBe(0);
});
it("clears encounter with a single combatant", () => {
const encounter = makeEncounter(1);
const result = clearEncounter(encounter);
expect(isDomainError(result)).toBe(false);
const success = result as Exclude<typeof result, DomainError>;
expect(success.encounter.combatants).toEqual([]);
expect(success.encounter.activeIndex).toBe(0);
expect(success.encounter.roundNumber).toBe(1);
});
it("clears encounter with combatants that have HP/AC/conditions/concentration", () => {
const encounter: Encounter = {
combatants: [
{
id: combatantId("c-1"),
name: "Fighter",
maxHp: 50,
currentHp: 30,
ac: 18,
conditions: ["blinded", "poisoned"],
isConcentrating: true,
},
{
id: combatantId("c-2"),
name: "Wizard",
maxHp: 25,
currentHp: 0,
ac: 12,
conditions: ["unconscious"],
},
],
activeIndex: 0,
roundNumber: 5,
};
const result = clearEncounter(encounter);
expect(isDomainError(result)).toBe(false);
const success = result as Exclude<typeof result, DomainError>;
expect(success.encounter.combatants).toEqual([]);
});
it("returns DomainError with code 'encounter-already-empty' when encounter has no combatants", () => {
const encounter: Encounter = {
combatants: [],
activeIndex: 0,
roundNumber: 1,
};
const result = clearEncounter(encounter);
expect(isDomainError(result)).toBe(true);
const error = result as DomainError;
expect(error.code).toBe("encounter-already-empty");
});
it("emits EncounterCleared event with correct combatantCount", () => {
const encounter = makeEncounter(3);
const result = clearEncounter(encounter);
expect(isDomainError(result)).toBe(false);
const success = result as Exclude<typeof result, DomainError>;
expect(success.events).toEqual([
{ type: "EncounterCleared", combatantCount: 3 },
]);
});
it("is deterministic — same input always produces same output", () => {
const encounter = makeEncounter(2, { roundNumber: 4, activeIndex: 1 });
const result1 = clearEncounter(encounter);
const result2 = clearEncounter(encounter);
expect(result1).toEqual(result2);
});
});

View File

@@ -0,0 +1,163 @@
import { describe, expect, it } from "vitest";
import { editCombatant } from "../edit-combatant.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
// --- Helpers ---
function makeCombatant(name: string): Combatant {
return { id: combatantId(name), name };
}
const Alice = makeCombatant("Alice");
const Bob = makeCombatant("Bob");
function enc(
combatants: Combatant[],
activeIndex = 0,
roundNumber = 1,
): Encounter {
return { combatants, activeIndex, roundNumber };
}
function successResult(encounter: Encounter, id: string, newName: string) {
const result = editCombatant(encounter, combatantId(id), newName);
if (isDomainError(result)) {
throw new Error(`Expected success, got error: ${result.message}`);
}
return result;
}
// --- Acceptance Scenarios (T004) ---
describe("editCombatant", () => {
describe("acceptance scenarios", () => {
it("scenario 1: rename succeeds with correct event containing combatantId, oldName, newName", () => {
const e = enc([Alice, Bob]);
const { encounter, events } = successResult(e, "Bob", "Robert");
expect(encounter.combatants[1]).toEqual({
id: combatantId("Bob"),
name: "Robert",
});
expect(events).toEqual([
{
type: "CombatantUpdated",
combatantId: combatantId("Bob"),
oldName: "Bob",
newName: "Robert",
},
]);
});
it("scenario 2: activeIndex and roundNumber preserved when renaming the active combatant", () => {
const e = enc([Alice, Bob], 1, 3);
const { encounter } = successResult(e, "Bob", "Robert");
expect(encounter.activeIndex).toBe(1);
expect(encounter.roundNumber).toBe(3);
expect(encounter.combatants[1].name).toBe("Robert");
});
it("scenario 3: combatant list order preserved", () => {
const Cael = makeCombatant("Cael");
const e = enc([Alice, Bob, Cael]);
const { encounter } = successResult(e, "Bob", "Robert");
expect(encounter.combatants.map((c) => c.name)).toEqual([
"Alice",
"Robert",
"Cael",
]);
});
it("scenario 4: renaming to same name still emits event", () => {
const e = enc([Alice, Bob]);
const { encounter, events } = successResult(e, "Bob", "Bob");
expect(encounter.combatants[1].name).toBe("Bob");
expect(events).toHaveLength(1);
expect(events[0]).toEqual({
type: "CombatantUpdated",
combatantId: combatantId("Bob"),
oldName: "Bob",
newName: "Bob",
});
});
});
// --- Invariant Tests (T005) ---
describe("invariants", () => {
it("INV-1: determinism — same inputs produce same outputs", () => {
const e = enc([Alice, Bob], 1, 3);
const result1 = editCombatant(e, combatantId("Alice"), "Aria");
const result2 = editCombatant(e, combatantId("Alice"), "Aria");
expect(result1).toEqual(result2);
});
it("INV-2: exactly one event emitted on success", () => {
const e = enc([Alice, Bob]);
const { events } = successResult(e, "Alice", "Aria");
expect(events).toHaveLength(1);
expect(events[0].type).toBe("CombatantUpdated");
});
it("INV-3: original encounter is not mutated", () => {
const e = enc([Alice, Bob], 0, 1);
const originalCombatants = [...e.combatants];
const originalActiveIndex = e.activeIndex;
const originalRoundNumber = e.roundNumber;
successResult(e, "Alice", "Aria");
expect(e.combatants).toEqual(originalCombatants);
expect(e.activeIndex).toBe(originalActiveIndex);
expect(e.roundNumber).toBe(originalRoundNumber);
});
});
// --- Error Scenarios (T011) ---
describe("error scenarios", () => {
it("non-existent id returns combatant-not-found error", () => {
const e = enc([Alice, Bob]);
const result = editCombatant(e, combatantId("nonexistent"), "NewName");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
});
it("empty name returns invalid-name error", () => {
const e = enc([Alice, Bob]);
const result = editCombatant(e, combatantId("Alice"), "");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
});
it("whitespace-only name returns invalid-name error", () => {
const e = enc([Alice, Bob]);
const result = editCombatant(e, combatantId("Alice"), " ");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
});
it("empty encounter returns combatant-not-found for any id", () => {
const e = enc([]);
const result = editCombatant(e, combatantId("any"), "Name");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
});
});
});

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { deriveHpStatus } from "../hp-status.js";
describe("deriveHpStatus", () => {
it("returns healthy when currentHp >= maxHp / 2", () => {
expect(deriveHpStatus(10, 20)).toBe("healthy");
expect(deriveHpStatus(15, 20)).toBe("healthy");
expect(deriveHpStatus(20, 20)).toBe("healthy");
});
it("returns bloodied when 0 < currentHp < maxHp / 2", () => {
expect(deriveHpStatus(9, 20)).toBe("bloodied");
expect(deriveHpStatus(1, 20)).toBe("bloodied");
expect(deriveHpStatus(5, 20)).toBe("bloodied");
});
it("returns unconscious when currentHp <= 0", () => {
expect(deriveHpStatus(0, 20)).toBe("unconscious");
});
it("returns unconscious with negative HP", () => {
expect(deriveHpStatus(-5, 20)).toBe("unconscious");
});
it("returns undefined when maxHp is undefined", () => {
expect(deriveHpStatus(10, undefined)).toBeUndefined();
});
it("returns undefined when currentHp is undefined", () => {
expect(deriveHpStatus(undefined, 20)).toBeUndefined();
});
it("returns undefined when both are undefined", () => {
expect(deriveHpStatus(undefined, undefined)).toBeUndefined();
});
it("handles maxHp=1 (no bloodied state possible)", () => {
expect(deriveHpStatus(1, 1)).toBe("healthy");
expect(deriveHpStatus(0, 1)).toBe("unconscious");
});
it("handles maxHp=2 (1/2 is healthy, not bloodied)", () => {
expect(deriveHpStatus(1, 2)).toBe("healthy");
expect(deriveHpStatus(0, 2)).toBe("unconscious");
});
it("handles odd maxHp=21 (10 is bloodied since 10 < 10.5)", () => {
expect(deriveHpStatus(10, 21)).toBe("bloodied");
expect(deriveHpStatus(11, 21)).toBe("healthy");
});
it("returns healthy when currentHp exceeds maxHp", () => {
expect(deriveHpStatus(25, 20)).toBe("healthy");
});
});

View File

@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import {
calculateInitiative,
formatInitiativeModifier,
} from "../initiative.js";
describe("calculateInitiative", () => {
it("returns positive modifier for creature with expertise (Aboleth: DEX 9, CR 10, proficiency 2)", () => {
const result = calculateInitiative({
dexScore: 9,
cr: "10",
initiativeProficiency: 2,
});
// DEX mod = floor((9-10)/2) = -1, PB for CR 10 = 4, -1 + 2*4 = +7
expect(result.modifier).toBe(7);
expect(result.passive).toBe(17);
});
it("returns negative modifier for low DEX with no proficiency", () => {
const result = calculateInitiative({
dexScore: 8,
cr: "1",
initiativeProficiency: 0,
});
// DEX mod = floor((8-10)/2) = -1, 0 * PB = 0, -1 + 0 = -1
expect(result.modifier).toBe(-1);
expect(result.passive).toBe(9);
});
it("returns zero modifier for DEX 10 with no proficiency", () => {
const result = calculateInitiative({
dexScore: 10,
cr: "1",
initiativeProficiency: 0,
});
expect(result.modifier).toBe(0);
expect(result.passive).toBe(10);
});
it("adds single proficiency bonus (multiplier 1)", () => {
const result = calculateInitiative({
dexScore: 14,
cr: "5",
initiativeProficiency: 1,
});
// DEX mod = +2, PB for CR 5 = 3, 2 + 1*3 = 5
expect(result.modifier).toBe(5);
expect(result.passive).toBe(15);
});
it("adds double proficiency bonus (multiplier 2 / expertise)", () => {
const result = calculateInitiative({
dexScore: 14,
cr: "5",
initiativeProficiency: 2,
});
// DEX mod = +2, PB for CR 5 = 3, 2 + 2*3 = 8
expect(result.modifier).toBe(8);
expect(result.passive).toBe(18);
});
it("handles no proficiency (multiplier 0) — reduces to raw DEX modifier", () => {
const result = calculateInitiative({
dexScore: 14,
cr: "5",
initiativeProficiency: 0,
});
// DEX mod = +2, 0 * PB = 0, 2 + 0 = 2
expect(result.modifier).toBe(2);
expect(result.passive).toBe(12);
});
it("handles negative result even with proficiency (very low DEX)", () => {
const result = calculateInitiative({
dexScore: 3,
cr: "0",
initiativeProficiency: 1,
});
// DEX mod = floor((3-10)/2) = -4, PB for CR 0 = 2, -4 + 1*2 = -2
expect(result.modifier).toBe(-2);
expect(result.passive).toBe(8);
});
it("handles fractional CR values", () => {
const result = calculateInitiative({
dexScore: 12,
cr: "1/4",
initiativeProficiency: 1,
});
// DEX mod = +1, PB for CR 1/4 = 2, 1 + 1*2 = 3
expect(result.modifier).toBe(3);
expect(result.passive).toBe(13);
});
});
describe("formatInitiativeModifier", () => {
it("formats positive modifier with plus sign", () => {
expect(formatInitiativeModifier(7)).toBe("+7");
});
it("formats negative modifier with U+2212 minus sign", () => {
expect(formatInitiativeModifier(-1)).toBe("\u22121");
});
it("formats zero modifier with plus sign", () => {
expect(formatInitiativeModifier(0)).toBe("+0");
});
});

View File

@@ -0,0 +1,142 @@
import { describe, expect, it } from "vitest";
import { removeCombatant } from "../remove-combatant.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
// --- Helpers ---
function makeCombatant(name: string): Combatant {
return { id: combatantId(name), name };
}
const A = makeCombatant("A");
const B = makeCombatant("B");
const C = makeCombatant("C");
const D = makeCombatant("D");
function enc(
combatants: Combatant[],
activeIndex = 0,
roundNumber = 1,
): Encounter {
return { combatants, activeIndex, roundNumber };
}
function successResult(encounter: Encounter, id: string) {
const result = removeCombatant(encounter, combatantId(id));
if (isDomainError(result)) {
throw new Error(`Expected success, got error: ${result.message}`);
}
return result;
}
// --- Acceptance Scenarios ---
describe("removeCombatant", () => {
describe("acceptance scenarios", () => {
it("AS-1: remove combatant after active — activeIndex unchanged", () => {
// [A*, B, C] remove C → [A*, B], activeIndex stays 0
const e = enc([A, B, C], 0, 2);
const { encounter, events } = successResult(e, "C");
expect(encounter.combatants).toEqual([A, B]);
expect(encounter.activeIndex).toBe(0);
expect(encounter.roundNumber).toBe(2);
expect(events).toEqual([
{
type: "CombatantRemoved",
combatantId: combatantId("C"),
name: "C",
},
]);
});
it("AS-2: remove combatant before active — activeIndex decrements", () => {
// [A, B, C*] remove A → [B, C*], activeIndex 2→1
const e = enc([A, B, C], 2, 3);
const { encounter } = successResult(e, "A");
expect(encounter.combatants).toEqual([B, C]);
expect(encounter.activeIndex).toBe(1);
expect(encounter.roundNumber).toBe(3);
});
it("AS-3: remove active combatant mid-list — next slides in", () => {
// [A, B*, C, D] remove B → [A, C*, D], activeIndex stays 1
const e = enc([A, B, C, D], 1, 1);
const { encounter } = successResult(e, "B");
expect(encounter.combatants).toEqual([A, C, D]);
expect(encounter.activeIndex).toBe(1);
});
it("AS-4: remove active combatant at end — wraps to 0", () => {
// [A, B, C*] remove C → [A, B], activeIndex wraps to 0
const e = enc([A, B, C], 2, 1);
const { encounter } = successResult(e, "C");
expect(encounter.combatants).toEqual([A, B]);
expect(encounter.activeIndex).toBe(0);
});
it("AS-5: remove only combatant — empty list, activeIndex 0", () => {
const e = enc([A], 0, 5);
const { encounter } = successResult(e, "A");
expect(encounter.combatants).toEqual([]);
expect(encounter.activeIndex).toBe(0);
expect(encounter.roundNumber).toBe(5);
});
it("AS-6: ID not found — returns DomainError", () => {
const e = enc([A, B], 0, 1);
const result = removeCombatant(e, combatantId("nonexistent"));
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
});
});
describe("invariants", () => {
it("event shape includes combatantId and name", () => {
const e = enc([A, B], 0, 1);
const { events } = successResult(e, "B");
expect(events).toHaveLength(1);
expect(events[0]).toEqual({
type: "CombatantRemoved",
combatantId: combatantId("B"),
name: "B",
});
});
it("roundNumber never changes on removal", () => {
const e = enc([A, B, C], 1, 7);
const { encounter } = successResult(e, "A");
expect(encounter.roundNumber).toBe(7);
});
it("determinism — same input produces same output", () => {
const e = enc([A, B, C], 1, 3);
const result1 = removeCombatant(e, combatantId("B"));
const result2 = removeCombatant(e, combatantId("B"));
expect(result1).toEqual(result2);
});
it("every success emits exactly one CombatantRemoved event", () => {
const scenarios: [Encounter, string][] = [
[enc([A]), "A"],
[enc([A, B], 1), "A"],
[enc([A, B, C], 2, 5), "C"],
];
for (const [e, id] of scenarios) {
const { events } = successResult(e, id);
expect(events).toHaveLength(1);
expect(events[0].type).toBe("CombatantRemoved");
}
});
});
});

View File

@@ -0,0 +1,184 @@
import { describe, expect, it } from "vitest";
import type { DomainEvent } from "../events.js";
import { retreatTurn } from "../retreat-turn.js";
import {
type Combatant,
combatantId,
createEncounter,
type Encounter,
isDomainError,
} from "../types.js";
// --- Helpers ---
function makeCombatant(name: string): Combatant {
return { id: combatantId(name), name };
}
const A = makeCombatant("A");
const B = makeCombatant("B");
const C = makeCombatant("C");
function encounter(
combatants: Combatant[],
activeIndex: number,
roundNumber: number,
): Encounter {
const result = createEncounter(combatants, activeIndex, roundNumber);
if (isDomainError(result)) {
throw new Error(`Test setup failed: ${result.message}`);
}
return result;
}
function successResult(enc: Encounter) {
const result = retreatTurn(enc);
if (isDomainError(result)) {
throw new Error(`Expected success, got error: ${result.message}`);
}
return result;
}
// --- Acceptance Scenarios ---
describe("retreatTurn", () => {
describe("acceptance scenarios", () => {
it("scenario 1: mid-round retreat — retreats from second to first combatant", () => {
const enc = encounter([A, B, C], 1, 1);
const { encounter: next, events } = successResult(enc);
expect(next.activeIndex).toBe(0);
expect(next.roundNumber).toBe(1);
expect(events).toEqual([
{
type: "TurnRetreated",
previousCombatantId: combatantId("B"),
newCombatantId: combatantId("A"),
roundNumber: 1,
},
]);
});
it("scenario 2: round-boundary retreat — wraps from first combatant to last, decrements round", () => {
const enc = encounter([A, B, C], 0, 2);
const { encounter: next, events } = successResult(enc);
expect(next.activeIndex).toBe(2);
expect(next.roundNumber).toBe(1);
expect(events).toEqual([
{
type: "TurnRetreated",
previousCombatantId: combatantId("A"),
newCombatantId: combatantId("C"),
roundNumber: 1,
},
{
type: "RoundRetreated",
newRoundNumber: 1,
},
]);
});
it("scenario 3: start-of-encounter error — cannot retreat at round 1 index 0", () => {
const enc = encounter([A, B, C], 0, 1);
const result = retreatTurn(enc);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("no-previous-turn");
}
});
it("scenario 4: single-combatant retreat — wraps to same combatant, decrements round", () => {
const enc = encounter([A], 0, 2);
const { encounter: next, events } = successResult(enc);
expect(next.activeIndex).toBe(0);
expect(next.roundNumber).toBe(1);
expect(events).toEqual([
{
type: "TurnRetreated",
previousCombatantId: combatantId("A"),
newCombatantId: combatantId("A"),
roundNumber: 1,
},
{
type: "RoundRetreated",
newRoundNumber: 1,
},
]);
});
it("scenario 5: empty-encounter error", () => {
const enc: Encounter = {
combatants: [],
activeIndex: 0,
roundNumber: 1,
};
const result = retreatTurn(enc);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-encounter");
}
});
});
describe("invariants", () => {
it("determinism — same input produces same output", () => {
const enc = encounter([A, B, C], 1, 3);
const result1 = retreatTurn(enc);
const result2 = retreatTurn(enc);
expect(result1).toEqual(result2);
});
it("activeIndex always in bounds after retreat", () => {
const combatants = [A, B, C];
// Start at round 4 so we can retreat many times
let enc = encounter(combatants, 2, 4);
for (let i = 0; i < 9; i++) {
const result = successResult(enc);
expect(result.encounter.activeIndex).toBeGreaterThanOrEqual(0);
expect(result.encounter.activeIndex).toBeLessThan(combatants.length);
enc = result.encounter;
}
});
it("roundNumber never goes below 1", () => {
let enc = encounter([A, B, C], 2, 2);
// Retreat through rounds — should stop at round 1 index 0
while (!(enc.roundNumber === 1 && enc.activeIndex === 0)) {
const result = successResult(enc);
expect(result.encounter.roundNumber).toBeGreaterThanOrEqual(1);
enc = result.encounter;
}
});
it("every success emits at least TurnRetreated", () => {
const scenarios: Encounter[] = [
encounter([A, B, C], 1, 1),
encounter([A, B, C], 0, 2),
encounter([A], 0, 2),
];
for (const enc of scenarios) {
const result = successResult(enc);
const hasTurnRetreated = result.events.some(
(e: DomainEvent) => e.type === "TurnRetreated",
);
expect(hasTurnRetreated).toBe(true);
}
});
it("event ordering: on wrap, events are [TurnRetreated, RoundRetreated]", () => {
const enc = encounter([A, B, C], 0, 2);
const { events } = successResult(enc);
expect(events).toHaveLength(2);
expect(events[0].type).toBe("TurnRetreated");
expect(events[1].type).toBe("RoundRetreated");
});
});
});

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { rollInitiative } from "../roll-initiative.js";
import { isDomainError } from "../types.js";
describe("rollInitiative", () => {
describe("valid rolls", () => {
it("normal roll: 15 + modifier 7 = 22", () => {
expect(rollInitiative(15, 7)).toBe(22);
});
it("boundary: roll 1 + modifier 0 = 1", () => {
expect(rollInitiative(1, 0)).toBe(1);
});
it("boundary: roll 20 + modifier 0 = 20", () => {
expect(rollInitiative(20, 0)).toBe(20);
});
it("negative modifier: roll 1 + (3) = 2", () => {
expect(rollInitiative(1, -3)).toBe(-2);
});
it("zero modifier: roll 10 + 0 = 10", () => {
expect(rollInitiative(10, 0)).toBe(10);
});
it("large positive modifier: roll 20 + 12 = 32", () => {
expect(rollInitiative(20, 12)).toBe(32);
});
});
describe("invalid dice rolls", () => {
it("rejects 0", () => {
const result = rollInitiative(0, 5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-dice-roll");
}
});
it("rejects 21", () => {
const result = rollInitiative(21, 5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-dice-roll");
}
});
it("rejects non-integer (3.5)", () => {
const result = rollInitiative(3.5, 0);
expect(isDomainError(result)).toBe(true);
});
it("rejects negative dice roll", () => {
const result = rollInitiative(-1, 0);
expect(isDomainError(result)).toBe(true);
});
it("rejects NaN", () => {
const result = rollInitiative(Number.NaN, 0);
expect(isDomainError(result)).toBe(true);
});
});
describe("determinism", () => {
it("same input produces same output", () => {
expect(rollInitiative(10, 5)).toBe(rollInitiative(10, 5));
});
});
});

View File

@@ -0,0 +1,143 @@
import { describe, expect, it } from "vitest";
import { setAc } from "../set-ac.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
function makeCombatant(name: string, ac?: number): Combatant {
return ac === undefined
? { id: combatantId(name), name }
: { id: combatantId(name), name, ac };
}
function enc(
combatants: Combatant[],
activeIndex = 0,
roundNumber = 1,
): Encounter {
return { combatants, activeIndex, roundNumber };
}
function successResult(
encounter: Encounter,
id: string,
value: number | undefined,
) {
const result = setAc(encounter, combatantId(id), value);
if (isDomainError(result)) {
throw new Error(`Expected success, got error: ${result.message}`);
}
return result;
}
describe("setAc", () => {
it("sets AC to a valid value", () => {
const e = enc([makeCombatant("A"), makeCombatant("B")]);
const { encounter, events } = successResult(e, "A", 15);
expect(encounter.combatants[0].ac).toBe(15);
expect(events).toEqual([
{
type: "AcSet",
combatantId: combatantId("A"),
previousAc: undefined,
newAc: 15,
},
]);
});
it("sets AC to 0", () => {
const e = enc([makeCombatant("A")]);
const { encounter } = successResult(e, "A", 0);
expect(encounter.combatants[0].ac).toBe(0);
});
it("clears AC with undefined", () => {
const e = enc([makeCombatant("A", 15)]);
const { encounter, events } = successResult(e, "A", undefined);
expect(encounter.combatants[0].ac).toBeUndefined();
expect(events[0]).toMatchObject({
previousAc: 15,
newAc: undefined,
});
});
it("returns error for nonexistent combatant", () => {
const e = enc([makeCombatant("A")]);
const result = setAc(e, combatantId("nonexistent"), 10);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
});
it("returns error for negative AC", () => {
const e = enc([makeCombatant("A")]);
const result = setAc(e, combatantId("A"), -1);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
});
it("returns error for non-integer AC", () => {
const e = enc([makeCombatant("A")]);
const result = setAc(e, combatantId("A"), 3.5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
});
it("returns error for NaN", () => {
const e = enc([makeCombatant("A")]);
const result = setAc(e, combatantId("A"), Number.NaN);
expect(isDomainError(result)).toBe(true);
});
it("preserves other fields when setting AC", () => {
const combatant: Combatant = {
id: combatantId("A"),
name: "Aria",
initiative: 15,
maxHp: 20,
currentHp: 18,
};
const e = enc([combatant]);
const { encounter } = successResult(e, "A", 16);
const updated = encounter.combatants[0];
expect(updated.ac).toBe(16);
expect(updated.name).toBe("Aria");
expect(updated.initiative).toBe(15);
expect(updated.maxHp).toBe(20);
expect(updated.currentHp).toBe(18);
});
it("does not reorder combatants", () => {
const e = enc([makeCombatant("A"), makeCombatant("B")]);
const { encounter } = successResult(e, "B", 18);
expect(encounter.combatants[0].id).toBe(combatantId("A"));
expect(encounter.combatants[1].id).toBe(combatantId("B"));
});
it("preserves activeIndex and roundNumber", () => {
const e = enc([makeCombatant("A"), makeCombatant("B")], 1, 5);
const { encounter } = successResult(e, "A", 14);
expect(encounter.activeIndex).toBe(1);
expect(encounter.roundNumber).toBe(5);
});
it("does not mutate input encounter", () => {
const e = enc([makeCombatant("A")]);
const original = JSON.parse(JSON.stringify(e));
setAc(e, combatantId("A"), 10);
expect(e).toEqual(original);
});
});

View File

@@ -0,0 +1,197 @@
import { describe, expect, it } from "vitest";
import { setHp } from "../set-hp.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
function makeCombatant(
name: string,
opts?: { maxHp?: number; currentHp?: number },
): Combatant {
return {
id: combatantId(name),
name,
...(opts?.maxHp !== undefined
? { maxHp: opts.maxHp, currentHp: opts.currentHp ?? opts.maxHp }
: {}),
};
}
function enc(combatants: Combatant[]): Encounter {
return { combatants, activeIndex: 0, roundNumber: 1 };
}
function successResult(
encounter: Encounter,
id: string,
maxHp: number | undefined,
) {
const result = setHp(encounter, combatantId(id), maxHp);
if (isDomainError(result)) {
throw new Error(`Expected success, got error: ${result.message}`);
}
return result;
}
describe("setHp", () => {
describe("acceptance scenarios", () => {
it("sets maxHp on a combatant with no HP — currentHp defaults to maxHp", () => {
const e = enc([makeCombatant("A")]);
const { encounter } = successResult(e, "A", 20);
expect(encounter.combatants[0].maxHp).toBe(20);
expect(encounter.combatants[0].currentHp).toBe(20);
});
it("increases maxHp while at full health — currentHp stays synced", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 20 })]);
const { encounter } = successResult(e, "A", 30);
expect(encounter.combatants[0].maxHp).toBe(30);
expect(encounter.combatants[0].currentHp).toBe(30);
});
it("increases maxHp while not at full health — currentHp unchanged", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 12 })]);
const { encounter } = successResult(e, "A", 30);
expect(encounter.combatants[0].maxHp).toBe(30);
expect(encounter.combatants[0].currentHp).toBe(12);
});
it("reduces maxHp below currentHp — clamps currentHp", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 18 })]);
const { encounter } = successResult(e, "A", 10);
expect(encounter.combatants[0].maxHp).toBe(10);
expect(encounter.combatants[0].currentHp).toBe(10);
});
it("clears maxHp — both maxHp and currentHp become undefined", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
const { encounter } = successResult(e, "A", undefined);
expect(encounter.combatants[0].maxHp).toBeUndefined();
expect(encounter.combatants[0].currentHp).toBeUndefined();
});
});
describe("invariants", () => {
it("is pure — same input produces same output", () => {
const e = enc([makeCombatant("A")]);
const r1 = setHp(e, combatantId("A"), 10);
const r2 = setHp(e, combatantId("A"), 10);
expect(r1).toEqual(r2);
});
it("does not mutate input encounter", () => {
const e = enc([makeCombatant("A")]);
const original = JSON.parse(JSON.stringify(e));
setHp(e, combatantId("A"), 10);
expect(e).toEqual(original);
});
it("emits MaxHpSet event with correct shape", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 18 })]);
const { events } = successResult(e, "A", 10);
expect(events).toEqual([
{
type: "MaxHpSet",
combatantId: combatantId("A"),
previousMaxHp: 20,
newMaxHp: 10,
previousCurrentHp: 18,
newCurrentHp: 10,
},
]);
});
it("preserves activeIndex and roundNumber", () => {
const e = {
combatants: [makeCombatant("A"), makeCombatant("B")],
activeIndex: 1,
roundNumber: 3,
};
const { encounter } = successResult(e, "A", 10);
expect(encounter.activeIndex).toBe(1);
expect(encounter.roundNumber).toBe(3);
});
});
describe("error cases", () => {
it("returns error for nonexistent combatant", () => {
const e = enc([makeCombatant("A")]);
const result = setHp(e, combatantId("Z"), 10);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
});
it("rejects maxHp of 0", () => {
const e = enc([makeCombatant("A")]);
const result = setHp(e, combatantId("A"), 0);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
});
it("rejects negative maxHp", () => {
const e = enc([makeCombatant("A")]);
const result = setHp(e, combatantId("A"), -5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
});
it("rejects non-integer maxHp", () => {
const e = enc([makeCombatant("A")]);
const result = setHp(e, combatantId("A"), 3.5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
});
});
describe("edge cases", () => {
it("maxHp=1 is valid", () => {
const e = enc([makeCombatant("A")]);
const { encounter } = successResult(e, "A", 1);
expect(encounter.combatants[0].maxHp).toBe(1);
expect(encounter.combatants[0].currentHp).toBe(1);
});
it("setting same maxHp does not change currentHp", () => {
const e = enc([makeCombatant("A", { maxHp: 10, currentHp: 7 })]);
const { encounter } = successResult(e, "A", 10);
expect(encounter.combatants[0].currentHp).toBe(7);
});
it("clear then re-set loses currentHp — UI must commit on blur", () => {
// Simulates: user clears max HP field then retypes a new value
// If the domain sees clear→set as two calls, currentHp resets.
// This is why the UI commits max HP only on blur, not per-keystroke.
const e = enc([makeCombatant("A", { maxHp: 22, currentHp: 12 })]);
const cleared = successResult(e, "A", undefined);
expect(cleared.encounter.combatants[0].currentHp).toBeUndefined();
const retyped = successResult(cleared.encounter, "A", 122);
// currentHp resets to 122 (first-set path) — original 12 is lost
expect(retyped.encounter.combatants[0].currentHp).toBe(122);
});
it("single committed change preserves currentHp", () => {
// The blur-commit approach: domain only sees 22→122, not 22→undefined→122
const e = enc([makeCombatant("A", { maxHp: 22, currentHp: 12 })]);
const { encounter } = successResult(e, "A", 122);
expect(encounter.combatants[0].maxHp).toBe(122);
expect(encounter.combatants[0].currentHp).toBe(12);
});
it("does not affect other combatants", () => {
const e = enc([
makeCombatant("A"),
makeCombatant("B", { maxHp: 30, currentHp: 25 }),
]);
const { encounter } = successResult(e, "A", 10);
expect(encounter.combatants[1].maxHp).toBe(30);
expect(encounter.combatants[1].currentHp).toBe(25);
});
});
});

View File

@@ -0,0 +1,314 @@
import { describe, expect, it } from "vitest";
import { setInitiative } from "../set-initiative.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
// --- Helpers ---
function makeCombatant(name: string, initiative?: number): Combatant {
return initiative === undefined
? { id: combatantId(name), name }
: { id: combatantId(name), name, initiative };
}
const A = makeCombatant("A");
const B = makeCombatant("B");
const C = makeCombatant("C");
function enc(
combatants: Combatant[],
activeIndex = 0,
roundNumber = 1,
): Encounter {
return { combatants, activeIndex, roundNumber };
}
function successResult(
encounter: Encounter,
id: string,
value: number | undefined,
) {
const result = setInitiative(encounter, combatantId(id), value);
if (isDomainError(result)) {
throw new Error(`Expected success, got error: ${result.message}`);
}
return result;
}
function names(encounter: Encounter): string[] {
return encounter.combatants.map((c) => c.name);
}
// --- US1: Set Initiative ---
describe("setInitiative", () => {
describe("US1: set initiative value", () => {
it("AS-1: set initiative on combatant with no initiative", () => {
const e = enc([A, B], 0);
const { encounter, events } = successResult(e, "A", 15);
expect(encounter.combatants[0].initiative).toBe(15);
expect(events).toEqual([
{
type: "InitiativeSet",
combatantId: combatantId("A"),
previousValue: undefined,
newValue: 15,
},
]);
});
it("AS-2: change existing initiative value", () => {
const e = enc([makeCombatant("A", 15), B], 0);
const { encounter, events } = successResult(e, "A", 8);
const a = encounter.combatants.find((c) => c.id === combatantId("A"));
expect(a?.initiative).toBe(8);
expect(events[0]).toMatchObject({
previousValue: 15,
newValue: 8,
});
});
it("AS-3: reject non-integer initiative value", () => {
const e = enc([A, B], 0);
const result = setInitiative(e, combatantId("A"), 3.5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-initiative");
}
});
it("AS-3b: reject NaN", () => {
const e = enc([A, B], 0);
const result = setInitiative(e, combatantId("A"), Number.NaN);
expect(isDomainError(result)).toBe(true);
});
it("AS-3c: reject Infinity", () => {
const e = enc([A, B], 0);
const result = setInitiative(
e,
combatantId("A"),
Number.POSITIVE_INFINITY,
);
expect(isDomainError(result)).toBe(true);
});
it("AS-4: clear initiative moves combatant to end", () => {
const e = enc([makeCombatant("A", 15), makeCombatant("B", 10)], 0);
const { encounter } = successResult(e, "A", undefined);
const a = encounter.combatants.find((c) => c.id === combatantId("A"));
expect(a?.initiative).toBeUndefined();
// A should be after B now
expect(names(encounter)).toEqual(["B", "A"]);
});
it("returns error for nonexistent combatant", () => {
const e = enc([A, B], 0);
const result = setInitiative(e, combatantId("nonexistent"), 10);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
});
});
// --- US2: Automatic Ordering ---
describe("US2: automatic ordering by initiative", () => {
it("AS-1: orders combatants descending by initiative", () => {
// Start with A(20), B(5), C(15) → should be A(20), C(15), B(5)
const e = enc([
makeCombatant("A", 20),
makeCombatant("B", 5),
makeCombatant("C", 15),
]);
// Set C's initiative to trigger reorder (no-op change to force sort)
const { encounter } = successResult(e, "C", 15);
expect(names(encounter)).toEqual(["A", "C", "B"]);
});
it("AS-2: changing initiative reorders correctly", () => {
const e = enc([
makeCombatant("A", 20),
makeCombatant("C", 15),
makeCombatant("B", 5),
]);
const { encounter } = successResult(e, "B", 25);
expect(names(encounter)).toEqual(["B", "A", "C"]);
});
it("AS-3: stable sort for equal initiative values", () => {
const e = enc([makeCombatant("A", 10), makeCombatant("B", 10)]);
// Set A's initiative to same value to confirm stable sort
const { encounter } = successResult(e, "A", 10);
expect(names(encounter)).toEqual(["A", "B"]);
});
});
// --- US3: Combatants Without Initiative ---
describe("US3: combatants without initiative", () => {
it("AS-1: unset combatants appear after those with initiative", () => {
const e = enc([
makeCombatant("A", 15),
B, // no initiative
makeCombatant("C", 10),
]);
const { encounter } = successResult(e, "A", 15);
expect(names(encounter)).toEqual(["A", "C", "B"]);
});
it("AS-2: multiple unset combatants preserve relative order", () => {
const e = enc([A, B]); // both no initiative
const { encounter } = successResult(e, "A", undefined);
expect(names(encounter)).toEqual(["A", "B"]);
});
it("AS-3: setting initiative moves combatant to correct position", () => {
const e = enc([
makeCombatant("A", 20),
B, // no initiative
makeCombatant("C", 10),
]);
const { encounter } = successResult(e, "B", 12);
expect(names(encounter)).toEqual(["A", "B", "C"]);
});
});
// --- US4: Active Turn Preservation ---
describe("US4: active turn preservation during reorder", () => {
it("AS-1: reorder preserves active turn on different combatant", () => {
// B is active (index 1), change A's initiative
const e = enc(
[makeCombatant("A", 10), makeCombatant("B", 15), makeCombatant("C", 5)],
1,
);
// Change A's initiative to 20, causing reorder
const { encounter } = successResult(e, "A", 20);
// New order: A(20), B(15), C(5)
expect(names(encounter)).toEqual(["A", "B", "C"]);
// B should still be active
expect(encounter.combatants[encounter.activeIndex].id).toBe(
combatantId("B"),
);
});
it("AS-2: active combatant's own initiative change preserves turn", () => {
const e = enc(
[makeCombatant("A", 20), makeCombatant("B", 15), makeCombatant("C", 5)],
0, // A is active
);
// Change A's initiative to 1, causing it to move to the end
const { encounter } = successResult(e, "A", 1);
// New order: B(15), C(5), A(1)
expect(names(encounter)).toEqual(["B", "C", "A"]);
// A should still be active
expect(encounter.combatants[encounter.activeIndex].id).toBe(
combatantId("A"),
);
});
});
// --- Invariants ---
describe("invariants", () => {
it("determinism — same input produces same output", () => {
const e = enc([A, B, C], 1, 3);
const result1 = setInitiative(e, combatantId("A"), 10);
const result2 = setInitiative(e, combatantId("A"), 10);
expect(result1).toEqual(result2);
});
it("immutability — input encounter is not mutated", () => {
const e = enc([A, B], 0, 2);
const original = JSON.parse(JSON.stringify(e));
setInitiative(e, combatantId("A"), 10);
expect(e).toEqual(original);
});
it("event shape includes all required fields", () => {
const e = enc([makeCombatant("A", 5), B], 0);
const { events } = successResult(e, "A", 10);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({
type: "InitiativeSet",
combatantId: combatantId("A"),
previousValue: 5,
newValue: 10,
});
});
it("roundNumber is never changed", () => {
const e = enc([A, B], 0, 7);
const { encounter } = successResult(e, "A", 10);
expect(encounter.roundNumber).toBe(7);
});
it("every success emits exactly one InitiativeSet event", () => {
const scenarios: [Encounter, string, number | undefined][] = [
[enc([A]), "A", 10],
[enc([A, B], 1), "A", 5],
[enc([makeCombatant("A", 10)]), "A", undefined],
];
for (const [e, id, value] of scenarios) {
const { events } = successResult(e, id, value);
expect(events).toHaveLength(1);
expect(events[0].type).toBe("InitiativeSet");
}
});
});
// --- Edge Cases ---
describe("edge cases", () => {
it("zero is a valid initiative value", () => {
const e = enc([A, B], 0);
const { encounter } = successResult(e, "A", 0);
const a = encounter.combatants.find((c) => c.id === combatantId("A"));
expect(a?.initiative).toBe(0);
});
it("negative initiative is valid", () => {
const e = enc([A, B], 0);
const { encounter } = successResult(e, "A", -5);
const a = encounter.combatants.find((c) => c.id === combatantId("A"));
expect(a?.initiative).toBe(-5);
});
it("negative sorts below positive", () => {
const e = enc([makeCombatant("A", -3), makeCombatant("B", 10)]);
const { encounter } = successResult(e, "A", -3);
expect(names(encounter)).toEqual(["B", "A"]);
});
it("all combatants with same initiative preserve order", () => {
const e = enc([
makeCombatant("A", 10),
makeCombatant("B", 10),
makeCombatant("C", 10),
]);
const { encounter } = successResult(e, "B", 10);
expect(names(encounter)).toEqual(["A", "B", "C"]);
});
it("clearing initiative on last combatant with initiative", () => {
const e = enc([makeCombatant("A", 10), B], 0);
const { encounter } = successResult(e, "A", undefined);
// Both unset now, preserve relative order
expect(names(encounter)).toEqual(["A", "B"]);
});
it("undefined value skips integer validation", () => {
const e = enc([A], 0);
const result = setInitiative(e, combatantId("A"), undefined);
expect(isDomainError(result)).toBe(false);
});
});
});

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { toggleConcentration } from "../toggle-concentration.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
function makeCombatant(name: string, isConcentrating?: boolean): Combatant {
return isConcentrating
? { id: combatantId(name), name, isConcentrating }
: { id: combatantId(name), name };
}
function enc(combatants: Combatant[]): Encounter {
return { combatants, activeIndex: 0, roundNumber: 1 };
}
function success(encounter: Encounter, id: string) {
const result = toggleConcentration(encounter, combatantId(id));
if (isDomainError(result)) {
throw new Error(`Expected success, got error: ${result.message}`);
}
return result;
}
describe("toggleConcentration", () => {
it("toggles concentration on when falsy", () => {
const e = enc([makeCombatant("A")]);
const { encounter, events } = success(e, "A");
expect(encounter.combatants[0].isConcentrating).toBe(true);
expect(events).toEqual([
{ type: "ConcentrationStarted", combatantId: combatantId("A") },
]);
});
it("toggles concentration off when true", () => {
const e = enc([makeCombatant("A", true)]);
const { encounter, events } = success(e, "A");
expect(encounter.combatants[0].isConcentrating).toBeUndefined();
expect(events).toEqual([
{ type: "ConcentrationEnded", combatantId: combatantId("A") },
]);
});
it("returns error for nonexistent combatant", () => {
const e = enc([makeCombatant("A")]);
const result = toggleConcentration(e, combatantId("missing"));
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
});
it("does not mutate input encounter", () => {
const e = enc([makeCombatant("A")]);
const original = JSON.parse(JSON.stringify(e));
toggleConcentration(e, combatantId("A"));
expect(e).toEqual(original);
});
it("does not affect other combatants", () => {
const e = enc([makeCombatant("A"), makeCombatant("B", true)]);
const { encounter } = success(e, "A");
expect(encounter.combatants[0].isConcentrating).toBe(true);
expect(encounter.combatants[1].isConcentrating).toBe(true);
});
});

View File

@@ -0,0 +1,120 @@
import { describe, expect, it } from "vitest";
import type { ConditionId } from "../conditions.js";
import { CONDITION_DEFINITIONS } from "../conditions.js";
import { toggleCondition } from "../toggle-condition.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
function makeCombatant(
name: string,
conditions?: readonly ConditionId[],
): Combatant {
return conditions
? { id: combatantId(name), name, conditions }
: { id: combatantId(name), name };
}
function enc(combatants: Combatant[]): Encounter {
return { combatants, activeIndex: 0, roundNumber: 1 };
}
function success(encounter: Encounter, id: string, condition: ConditionId) {
const result = toggleCondition(encounter, combatantId(id), condition);
if (isDomainError(result)) {
throw new Error(`Expected success, got error: ${result.message}`);
}
return result;
}
describe("toggleCondition", () => {
it("adds a condition when not present", () => {
const e = enc([makeCombatant("A")]);
const { encounter, events } = success(e, "A", "blinded");
expect(encounter.combatants[0].conditions).toEqual(["blinded"]);
expect(events).toEqual([
{
type: "ConditionAdded",
combatantId: combatantId("A"),
condition: "blinded",
},
]);
});
it("removes a condition when already present", () => {
const e = enc([makeCombatant("A", ["blinded"])]);
const { encounter, events } = success(e, "A", "blinded");
expect(encounter.combatants[0].conditions).toBeUndefined();
expect(events).toEqual([
{
type: "ConditionRemoved",
combatantId: combatantId("A"),
condition: "blinded",
},
]);
});
it("maintains definition order when adding conditions", () => {
const e = enc([makeCombatant("A", ["poisoned"])]);
const { encounter } = success(e, "A", "blinded");
expect(encounter.combatants[0].conditions).toEqual(["blinded", "poisoned"]);
});
it("prevents duplicate conditions", () => {
const e = enc([makeCombatant("A", ["blinded"])]);
// Toggling blinded again removes it, not duplicates
const { encounter } = success(e, "A", "blinded");
expect(encounter.combatants[0].conditions).toBeUndefined();
});
it("rejects unknown condition", () => {
const e = enc([makeCombatant("A")]);
const result = toggleCondition(
e,
combatantId("A"),
"flying" as ConditionId,
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("unknown-condition");
}
});
it("returns error for nonexistent combatant", () => {
const e = enc([makeCombatant("A")]);
const result = toggleCondition(e, combatantId("missing"), "blinded");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
});
it("does not mutate input encounter", () => {
const e = enc([makeCombatant("A")]);
const original = JSON.parse(JSON.stringify(e));
toggleCondition(e, combatantId("A"), "blinded");
expect(e).toEqual(original);
});
it("normalizes empty array to undefined on removal", () => {
const e = enc([makeCombatant("A", ["charmed"])]);
const { encounter } = success(e, "A", "charmed");
expect(encounter.combatants[0].conditions).toBeUndefined();
});
it("preserves order across all conditions", () => {
const order = CONDITION_DEFINITIONS.map((d) => d.id);
// Add in reverse order
let e = enc([makeCombatant("A")]);
for (const cond of [...order].reverse()) {
const result = success(e, "A", cond);
e = result.encounter;
}
expect(e.combatants[0].conditions).toEqual(order);
});
});

View File

@@ -0,0 +1,50 @@
import type { DomainEvent } from "./events.js";
import type { CombatantId, DomainError, Encounter } from "./types.js";
export interface AddCombatantSuccess {
readonly encounter: Encounter;
readonly events: DomainEvent[];
}
/**
* Pure function that adds a combatant to the end of an encounter's list.
*
* FR-001: Accepts an Encounter, CombatantId, and name; returns next state + events.
* FR-002: Appends new combatant to end of combatants list.
* FR-004: Rejects empty/whitespace-only names with DomainError.
* FR-005: Does not alter activeIndex or roundNumber.
* FR-006: Events returned as values, not dispatched via side effects.
*/
export function addCombatant(
encounter: Encounter,
id: CombatantId,
name: string,
): AddCombatantSuccess | DomainError {
const trimmed = name.trim();
if (trimmed === "") {
return {
kind: "domain-error",
code: "invalid-name",
message: "Combatant name must not be empty",
};
}
const position = encounter.combatants.length;
return {
encounter: {
combatants: [...encounter.combatants, { id, name: trimmed }],
activeIndex: encounter.activeIndex,
roundNumber: encounter.roundNumber,
},
events: [
{
type: "CombatantAdded",
combatantId: id,
name: trimmed,
position,
},
],
};
}

View File

@@ -0,0 +1,77 @@
import type { DomainEvent } from "./events.js";
import type { CombatantId, DomainError, Encounter } from "./types.js";
export interface AdjustHpSuccess {
readonly encounter: Encounter;
readonly events: DomainEvent[];
}
/**
* Pure function that adjusts a combatant's current HP by a delta.
*
* The result is clamped to [0, maxHp]. Requires the combatant to have
* HP tracking enabled (maxHp must be set).
*/
export function adjustHp(
encounter: Encounter,
combatantId: CombatantId,
delta: number,
): AdjustHpSuccess | DomainError {
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
if (targetIdx === -1) {
return {
kind: "domain-error",
code: "combatant-not-found",
message: `No combatant found with ID "${combatantId}"`,
};
}
const target = encounter.combatants[targetIdx];
if (target.maxHp === undefined || target.currentHp === undefined) {
return {
kind: "domain-error",
code: "no-hp-tracking",
message: `Combatant "${combatantId}" does not have HP tracking enabled`,
};
}
if (delta === 0) {
return {
kind: "domain-error",
code: "zero-delta",
message: "Delta must not be zero",
};
}
if (!Number.isInteger(delta)) {
return {
kind: "domain-error",
code: "invalid-delta",
message: `Delta must be an integer, got ${delta}`,
};
}
const previousHp = target.currentHp;
const newHp = Math.max(0, Math.min(target.maxHp, previousHp + delta));
return {
encounter: {
combatants: encounter.combatants.map((c) =>
c.id === combatantId ? { ...c, currentHp: newHp } : c,
),
activeIndex: encounter.activeIndex,
roundNumber: encounter.roundNumber,
},
events: [
{
type: "CurrentHpAdjusted",
combatantId,
previousHp,
newHp,
delta,
},
],
};
}

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