Compare commits
31 Commits
99d1ba1bcd
...
0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
458c277e9f | ||
|
|
91703ddebc | ||
|
|
768e7a390f | ||
|
|
7feaf90eab | ||
|
|
b39e4923e1 | ||
|
|
369feb3cc8 | ||
|
|
51bdb799ae | ||
|
|
1baddad939 | ||
|
|
e701e4dd70 | ||
|
|
e2b0e7d5ee | ||
|
|
635e9c0705 | ||
|
|
582a42e62d | ||
|
|
fc43f440aa | ||
|
|
1cf30b3622 | ||
|
|
2ce0ff50b9 | ||
|
|
96a7b2d00e | ||
|
|
2d8e823eff | ||
|
|
613bb70065 | ||
|
|
b6e052f198 | ||
|
|
460c65bf49 | ||
|
|
95cb2edc23 | ||
|
|
55d322a727 | ||
|
|
0c903bc9a5 | ||
|
|
236c3bf64a | ||
|
|
0747d044f3 | ||
|
|
d101906776 | ||
|
|
69363d4f7d | ||
|
|
47da942b73 | ||
|
|
94d125d9c4 | ||
|
|
c323adc343 | ||
|
|
91120d7c82 |
143
.claude/commands/integrate-issue.md
Normal file
143
.claude/commands/integrate-issue.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
162
.claude/commands/sync-issue.md
Normal file
162
.claude/commands/sync-issue.md
Normal 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.
|
||||
163
.claude/commands/write-issue.md
Normal file
163
.claude/commands/write-issue.md
Normal 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.
|
||||
82
.claude/skills/rpi-implement/SKILL.md
Normal file
82
.claude/skills/rpi-implement/SKILL.md
Normal 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.
|
||||
349
.claude/skills/rpi-plan/SKILL.md
Normal file
349
.claude/skills/rpi-plan/SKILL.md
Normal 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
|
||||
37
.claude/skills/rpi-plan/scripts/metadata.py
Executable file
37
.claude/skills/rpi-plan/scripts/metadata.py
Executable 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()
|
||||
146
.claude/skills/rpi-research/SKILL.md
Normal file
146
.claude/skills/rpi-research/SKILL.md
Normal 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
|
||||
39
.claude/skills/rpi-research/scripts/metadata.py
Executable file
39
.claude/skills/rpi-research/scripts/metadata.py
Executable 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
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.pnpm-store
|
||||
dist
|
||||
coverage
|
||||
.git
|
||||
.claude
|
||||
.specify
|
||||
specs
|
||||
docs
|
||||
49
.gitea/workflows/ci.yml
Normal file
49
.gitea/workflows/ci.yml
Normal 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
|
||||
@@ -1,14 +1,10 @@
|
||||
<!--
|
||||
Sync Impact Report
|
||||
───────────────────
|
||||
Version change: 1.0.2 → 1.0.3 (PATCH — add merge-gate rule)
|
||||
Version change: 2.2.1 → 3.0.0 (MAJOR — specs describe features not changes, proportional workflow)
|
||||
Modified sections:
|
||||
- Development Workflow: added automated-checks merge gate
|
||||
Templates requiring updates:
|
||||
- .specify/templates/plan-template.md ✅ no update needed
|
||||
- .specify/templates/spec-template.md ✅ no update needed
|
||||
- .specify/templates/tasks-template.md ✅ no update needed
|
||||
Follow-up TODOs: none
|
||||
- Development Workflow: specs are living feature documents; full pipeline for new features only
|
||||
Templates requiring updates: none
|
||||
-->
|
||||
# Encounter Console Constitution
|
||||
|
||||
@@ -29,7 +25,7 @@ be injected at the boundary, never sourced inside the domain layer.
|
||||
|
||||
### II. Layered Architecture
|
||||
|
||||
The codebase MUST be organized into four layers with strict
|
||||
The codebase MUST be organized into three layers with strict
|
||||
dependency direction:
|
||||
|
||||
1. **Domain** — pure types, state transitions, validation rules.
|
||||
@@ -39,34 +35,21 @@ dependency direction:
|
||||
interfaces that Adapters implement. May import Domain only.
|
||||
3. **Adapters** — I/O, persistence, UI rendering, external APIs.
|
||||
May import Application and Domain.
|
||||
4. **Agent** — AI-assisted features (suggestions, analysis).
|
||||
May import Application and Domain as read-only consumers.
|
||||
|
||||
A module in an inner layer MUST NOT import from an outer layer.
|
||||
|
||||
### III. Agent Boundary
|
||||
|
||||
The agent layer MAY read domain events and current state. The agent
|
||||
MAY produce suggestions, annotations, or recommendations. The agent
|
||||
MUST NOT mutate domain state directly. All agent-originated changes
|
||||
MUST flow through the Application layer as explicit user-confirmed
|
||||
commands.
|
||||
|
||||
- Agent output MUST be clearly labeled as suggestions.
|
||||
- No silent or automatic application of agent recommendations.
|
||||
|
||||
### IV. Clarification-First
|
||||
### III. Clarification-First
|
||||
|
||||
Before making any non-trivial assumption during specification,
|
||||
planning, or implementation, the agent MUST surface a clarification
|
||||
planning, or implementation, Claude Code MUST surface a clarification
|
||||
question to the user. "Non-trivial" means any decision that would
|
||||
alter observable behavior, data model shape, or public API surface.
|
||||
The agent MUST also ask when multiple valid interpretations exist,
|
||||
Claude Code MUST also ask when multiple valid interpretations exist,
|
||||
when a choice would affect architectural layering, or when scope
|
||||
would expand beyond the current spec. The agent MUST NOT silently
|
||||
would expand beyond the current spec. Claude Code MUST NOT silently
|
||||
choose among valid alternatives.
|
||||
|
||||
### V. Escalation Gates
|
||||
### IV. Escalation Gates
|
||||
|
||||
Any feature, requirement, or scope change not present in the current
|
||||
spec MUST be rejected at implementation time until the spec is
|
||||
@@ -77,7 +60,7 @@ explicitly updated. The workflow is:
|
||||
3. Update the spec via `/speckit.specify` or `/speckit.clarify`.
|
||||
4. Only then proceed with implementation.
|
||||
|
||||
### VI. MVP Baseline Language
|
||||
### V. MVP Baseline Language
|
||||
|
||||
Constraints in this constitution and in specs MUST use MVP baseline
|
||||
language ("MVP baseline does not include X") rather than permanent
|
||||
@@ -86,7 +69,7 @@ add capabilities in future iterations without constitutional
|
||||
amendment. The current MVP baseline is local-first and single-user;
|
||||
this is a starting scope, not a permanent restriction.
|
||||
|
||||
### VII. No Gameplay Rules in Constitution
|
||||
### VI. No Gameplay Rules in Constitution
|
||||
|
||||
This constitution MUST NOT contain concrete gameplay mechanics,
|
||||
rule-system specifics, or encounter resolution logic. Such details
|
||||
@@ -96,9 +79,9 @@ architecture, and quality — not product behavior.
|
||||
## Scope Constraints
|
||||
|
||||
- The Encounter Console's primary focus is initiative tracking and
|
||||
encounter state management. Adjacent capabilities (e.g., richer
|
||||
game-engine features) are not in the MVP baseline but may be
|
||||
added via spec updates in future iterations.
|
||||
encounter state management. Adjacent capabilities (e.g., bestiary
|
||||
integration, richer game-engine features) may be added via spec
|
||||
updates.
|
||||
- Technology choices, UI framework, and storage mechanism are
|
||||
spec-level decisions, not constitutional mandates.
|
||||
- Testing strategy (unit, integration, contract) is determined per
|
||||
@@ -109,16 +92,31 @@ architecture, and quality — not product behavior.
|
||||
|
||||
- No change may be merged unless all automated checks (tests and
|
||||
static analysis as defined by the project) pass.
|
||||
- Every feature begins with a spec (`/speckit.specify`).
|
||||
- Implementation follows the plan → tasks → implement pipeline.
|
||||
- Specs describe **features**, not individual changes. Each spec is
|
||||
a living document. New features begin with `/speckit.specify`
|
||||
(which creates a feature branch for the full speckit pipeline);
|
||||
changes to existing features update the existing spec via
|
||||
`/integrate-issue`.
|
||||
- The full pipeline (spec → plan → tasks → implement) applies to new
|
||||
features and significant additions. Bug fixes, tooling changes,
|
||||
and trivial UI adjustments do not require specs.
|
||||
- Domain logic MUST be testable without mocks for external systems.
|
||||
- Long-running or multi-step state transitions SHOULD be verifiable
|
||||
through reproducible event logs or snapshot-style tests.
|
||||
- Commits SHOULD be atomic and map to individual tasks where
|
||||
practical.
|
||||
- Layer boundary compliance MUST be verified by automated import
|
||||
rules or architectural tests. Agent-assisted or manual review MAY
|
||||
supplement but not replace automated checks.
|
||||
rules or architectural tests.
|
||||
- All automated quality gates MUST run at the earliest feasible
|
||||
enforcement point (currently pre-commit via Lefthook). No gate
|
||||
may exist only as a CI step or manual process.
|
||||
- When a feature adds, removes, or changes user-facing capabilities
|
||||
described in README.md, the README MUST be updated in the same
|
||||
change. Features that materially alter what the product does or
|
||||
how it is set up SHOULD also be reflected in the README.
|
||||
- When a feature changes the tech stack, project structure, or
|
||||
architectural patterns documented in CLAUDE.md, the CLAUDE.md
|
||||
MUST be updated in the same change.
|
||||
|
||||
## Governance
|
||||
|
||||
@@ -142,4 +140,4 @@ MUST comply with its principles.
|
||||
**Compliance review**: Every spec and plan MUST include a
|
||||
Constitution Check section validating adherence to all principles.
|
||||
|
||||
**Version**: 1.0.3 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-03
|
||||
**Version**: 3.0.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-11
|
||||
|
||||
105
CLAUDE.md
105
CLAUDE.md
@@ -5,7 +5,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm check # Merge gate — must pass before every commit (knip + format + lint + typecheck + test)
|
||||
pnpm check # Merge gate — must pass before every commit (audit + knip + biome + typecheck + test/coverage + jscpd)
|
||||
pnpm knip # Unused code detection (Knip)
|
||||
pnpm test # Run all tests (Vitest)
|
||||
pnpm test:watch # Tests in watch mode
|
||||
@@ -27,11 +27,40 @@ apps/web (React 19 + Vite) → packages/application (use cases) → packages
|
||||
```
|
||||
|
||||
- **Domain** — Pure functions, no I/O, no framework imports. All state transitions are deterministic. Errors returned as values (`DomainError`), never thrown. Adapters may throw only for programmer errors.
|
||||
- **Application** — Orchestrates domain calls via port interfaces (e.g., `EncounterStore`). No business logic here.
|
||||
- **Web** — React adapter. Implements ports using hooks/state.
|
||||
- **Application** — Orchestrates domain calls via port interfaces (`EncounterStore`, `BestiarySourceCache`). No business logic here.
|
||||
- **Web** — React adapter. Implements ports using hooks/state. All UI components, routing, and user interaction live here.
|
||||
|
||||
Layer boundaries are enforced by `scripts/check-layer-boundaries.mjs`, which runs as a Vitest test. Domain and application must never import from React, Vite, or upper layers.
|
||||
|
||||
### Data & Storage
|
||||
|
||||
- **localStorage** — encounter persistence (adapter layer, JSON serialization)
|
||||
- **IndexedDB** — bestiary source cache (`apps/web/src/adapters/bestiary-cache.ts`, via `idb` wrapper)
|
||||
- **`data/bestiary/index.json`** — pre-built search index for creature lookup, generated by `scripts/generate-bestiary-index.mjs`
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
apps/web/ React app — components, hooks, adapters
|
||||
packages/domain/src/ Pure state transitions, types, validation
|
||||
packages/application/src/ Use cases, port interfaces
|
||||
data/bestiary/ Bestiary search index
|
||||
scripts/ Build tooling (layer checks, index generation)
|
||||
specs/NNN-feature-name/ Feature specs (spec.md, plan.md, tasks.md)
|
||||
.specify/ Speckit config (templates, scripts, constitution)
|
||||
docs/agents/ RPI skill artifacts (research reports, plans)
|
||||
.claude/skills/ Agent skills (rpi-research, rpi-plan, rpi-implement)
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- TypeScript 5.8 (strict mode, `verbatimModuleSyntax`)
|
||||
- React 19, Vite 6, Tailwind CSS v4
|
||||
- Lucide React (icons)
|
||||
- `idb` (IndexedDB wrapper for bestiary cache)
|
||||
- Biome 2.0 (formatting + linting), Knip (unused code), jscpd (copy-paste detection)
|
||||
- Vitest (testing, v8 coverage), Lefthook (pre-commit hooks)
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Biome 2.0** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically.
|
||||
@@ -39,7 +68,40 @@ Layer boundaries are enforced by `scripts/check-layer-boundaries.mjs`, which run
|
||||
- **Branded types** for identity values (e.g., `CombatantId`). Prefer immutability/`readonly` where practical.
|
||||
- **Domain events** are plain data objects with a `type` discriminant — no classes.
|
||||
- **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks.
|
||||
- **Feature specs** live in `specs/<feature>/` with spec.md, plan.md, tasks.md. The project constitution is at `.specify/memory/constitution.md`.
|
||||
- **Feature specs** live in `specs/NNN-feature-name/` with spec.md (and optionally plan.md, tasks.md for new work). Specs describe features, not individual changes. The project constitution is at `.specify/memory/constitution.md`.
|
||||
- **Quality gates** are enforced at pre-commit via Lefthook's `pnpm check` — the project's single earliest enforcement point. No gate may exist only as a CI step or manual process.
|
||||
|
||||
## Speckit Workflow
|
||||
|
||||
Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Specs are **living documents** that describe features, not individual changes.
|
||||
|
||||
### Issue-driven workflow
|
||||
- `/write-issue` — create a well-structured Gitea issue via interactive interview
|
||||
- `/integrate-issue <number>` — fetch an issue, route it to the right spec, and update the spec with the new/changed requirements. Then implement directly.
|
||||
- `/sync-issue <number>` — push acceptance criteria from the spec back to the Gitea issue
|
||||
|
||||
### RPI skills (Research → Plan → Implement)
|
||||
- `rpi-research` — deep codebase research producing a written report in `docs/agents/research/`
|
||||
- `rpi-plan` — interactive phased implementation plan in `docs/agents/plans/`
|
||||
- `rpi-implement` — execute a plan file phase by phase with automated + manual verification
|
||||
|
||||
### Choosing the right workflow by scope
|
||||
|
||||
| Scope | Workflow |
|
||||
|---|---|
|
||||
| Bug fix / CSS tweak | Just fix it, commit |
|
||||
| Small change to existing feature | `/integrate-issue` → implement → commit |
|
||||
| Larger addition to existing feature | `/integrate-issue` → `rpi-research` → `rpi-plan` → `rpi-implement` |
|
||||
| New feature | `/speckit.specify` → `/speckit.clarify` → `/speckit.plan` → `/speckit.tasks` → `/speckit.implement` |
|
||||
|
||||
Speckit manages **what** to build (specs as living documents). RPI manages **how** to build it (research, planning, execution). The full speckit pipeline is for new features. For changes to existing features, update the spec via `/integrate-issue`, then use RPI skills if the change is non-trivial.
|
||||
|
||||
### Current feature specs
|
||||
- `specs/001-combatant-management/` — CRUD, persistence, clear, batch add, confirm buttons
|
||||
- `specs/002-turn-tracking/` — rounds, turn order, advance/retreat, top bar
|
||||
- `specs/003-combatant-state/` — HP, AC, conditions, concentration, initiative
|
||||
- `specs/004-bestiary/` — search index, stat blocks, source management, panel UX
|
||||
- `specs/005-player-characters/` — persistent player character templates (CRUD), search & add to encounters, color/icon visual distinction, `PlayerCharacterStore` port
|
||||
|
||||
## Constitution (key principles)
|
||||
|
||||
@@ -49,38 +111,11 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work:
|
||||
2. **Layered Architecture** — Domain → Application → Adapters. Never skip layers or reverse dependencies.
|
||||
3. **Clarification-First** — Ask before making non-trivial assumptions.
|
||||
4. **MVP Baseline** — Say "MVP baseline does not include X", never permanent bans.
|
||||
5. **Every feature begins with a spec** — Spec → Plan → Tasks → Implementation.
|
||||
5. **Spec-driven features** — Features are described in living specs; evolve existing specs via `/integrate-issue`, create new ones via `/speckit.specify`. Bug fixes and tooling changes do not require specs.
|
||||
|
||||
## Active Technologies
|
||||
- TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite (003-remove-combatant)
|
||||
- In-memory React state (local-first, single-user MVP) (003-remove-combatant)
|
||||
- TypeScript 5.x (project), Go binary via npm (Lefthook) + `lefthook` (npm devDependency) (006-pre-commit-gate)
|
||||
- TypeScript 5.x (strict mode, verbatimModuleSyntax) + Knip v5 (new), Biome 2.0, Vitest, Vite 6, React 19 (007-add-knip)
|
||||
- TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Biome 2.0, existing domain/application packages (008-persist-encounter)
|
||||
- Browser localStorage (adapter layer only) (008-persist-encounter)
|
||||
- TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Biome 2.0, Vites (009-combatant-hp)
|
||||
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Tailwind CSS v4, shadcn/ui, Lucide React (icons) (010-ui-baseline)
|
||||
- N/A (no storage changes — localStorage persistence unchanged) (010-ui-baseline)
|
||||
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Tailwind CSS v4, shadcn/ui-style components, Lucide React (icons) (011-quick-hp-input)
|
||||
- N/A (no storage changes -- existing localStorage persistence handles HP via `adjustHpUseCase`) (011-quick-hp-input)
|
||||
- N/A (no storage changes -- existing localStorage persistence unchanged) (012-turn-navigation)
|
||||
- N/A (no storage changes -- purely derived state, existing localStorage persistence unchanged) (013-hp-status-indicators)
|
||||
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + jscpd (new dev dependency), Lefthook (existing), Biome 2.0 (existing), Knip (existing) (015-add-jscpd-gate)
|
||||
- N/A (no storage changes) (015-add-jscpd-gate)
|
||||
- Browser localStorage (existing adapter, transparent JSON serialization) (016-combatant-ac)
|
||||
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Tailwind CSS v4, Lucide React (icons) (017-combat-conditions)
|
||||
- N/A (no storage changes — existing localStorage persistence unchanged) (019-combatant-row-declutter)
|
||||
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Tailwind CSS v4, Lucide React (icons) (020-fix-zero-hp-opacity)
|
||||
- Browser localStorage (existing adapter, extended for creatureId) (021-bestiary-statblock)
|
||||
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Tailwind CSS v4 + React 19, Tailwind CSS v4, Vite 6 (022-fixed-layout-bars)
|
||||
- N/A (no storage changes -- purely presentational) (022-fixed-layout-bars)
|
||||
- Browser localStorage (existing adapter, updated to handle empty encounters) (023-clear-encounter)
|
||||
- N/A (no storage changes — purely presentational fix) (024-fix-hp-popover-overflow)
|
||||
- N/A (no storage changes — purely derived from existing bestiary data) (025-display-initiative)
|
||||
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Tailwind CSS v4, Lucide React (icons), Vite 6 (026-roll-initiative)
|
||||
- N/A (no storage changes — existing localStorage persistence handles initiative via `setInitiativeUseCase`) (026-roll-initiative)
|
||||
- N/A (no storage changes — purely presentational) (027-ui-polish)
|
||||
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Tailwind CSS v4, Vite 6 + Tailwind CSS v4 (CSS-native `@theme` theming), Lucide React (icons) (028-semantic-hover-tokens)
|
||||
- TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Vite 6, Tailwind CSS v4, Lucide Reac (005-player-characters)
|
||||
- localStorage (new key `"initiative:player-characters"`) (005-player-characters)
|
||||
|
||||
## Recent Changes
|
||||
- 003-remove-combatant: Added TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite
|
||||
- 005-player-characters: Added TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Vite 6, Tailwind CSS v4, Lucide Reac
|
||||
|
||||
19
Dockerfile
Normal file
19
Dockerfile
Normal 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
|
||||
44
README.md
44
README.md
@@ -1,10 +1,18 @@
|
||||
# Initiative Tracker
|
||||
# Encounter Console
|
||||
|
||||
A turn-based initiative tracker for tabletop RPG encounters. Click "Next Turn" to cycle through combatants and advance rounds.
|
||||
A local-first initiative tracker and encounter manager for tabletop RPGs (D&D 5e / 2024). Runs entirely in the browser — no server, no account, no data leaves your machine.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Initiative tracking** — add combatants (batch-add from bestiary, custom creatures with optional stats), roll initiative (manual or d20), cycle turns and rounds
|
||||
- **Encounter state** — HP, AC, conditions, concentration tracking with visual status indicators
|
||||
- **Bestiary integration** — import bestiary JSON sources, search creatures, and view full stat blocks
|
||||
- **Player characters** — create reusable player character templates with name, AC, HP, color, and icon; search and add them to encounters with pre-filled stats; manage (edit/delete) from a dedicated panel
|
||||
- **Persistent** — encounters survive page reloads via localStorage; bestiary data cached in IndexedDB; player characters stored independently
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 22
|
||||
- Node.js 22+
|
||||
- pnpm 10.6+
|
||||
|
||||
## Getting Started
|
||||
@@ -14,9 +22,7 @@ pnpm install
|
||||
pnpm --filter web dev
|
||||
```
|
||||
|
||||
Open the URL printed in your terminal (typically `http://localhost:5173`).
|
||||
|
||||
The app starts with a 3-combatant demo encounter (Aria, Brak, Cael). Click **Next Turn** to advance through the initiative order. When the last combatant finishes their turn, the round number increments and the cycle restarts.
|
||||
Open `http://localhost:5173`.
|
||||
|
||||
## Scripts
|
||||
|
||||
@@ -24,5 +30,27 @@ The app starts with a 3-combatant demo encounter (Aria, Brak, Cael). Click **Nex
|
||||
|---------|-------------|
|
||||
| `pnpm --filter web dev` | Start the dev server |
|
||||
| `pnpm --filter web build` | Production build |
|
||||
| `pnpm test` | Run all tests |
|
||||
| `pnpm check` | Full merge gate (format, lint, typecheck, test) |
|
||||
| `pnpm test` | Run all tests (Vitest) |
|
||||
| `pnpm check` | Full merge gate (knip, biome, typecheck, test, jscpd) |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/web/ React 19 + Vite — UI components, hooks, adapters
|
||||
packages/domain/ Pure functions — state transitions, types, validation
|
||||
packages/app/ Use cases — orchestrates domain via port interfaces
|
||||
data/bestiary/ Bestiary index for creature search
|
||||
scripts/ Build tooling (layer boundary checks, index generation)
|
||||
specs/ Feature specifications (spec → plan → tasks)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Strict layered architecture with enforced dependency direction:
|
||||
|
||||
```
|
||||
apps/web (adapters) → packages/application (use cases) → packages/domain (pure logic)
|
||||
```
|
||||
|
||||
Domain is pure — no I/O, no randomness, no framework imports. Layer boundaries are enforced by automated import checks that run as part of the test suite. See [CLAUDE.md](./CLAUDE.md) for full conventions.
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"@initiative/domain": "workspace:*",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -20,9 +21,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
|
||||
@@ -3,13 +3,20 @@ import {
|
||||
rollInitiativeUseCase,
|
||||
} from "@initiative/application";
|
||||
import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ActionBar } from "./components/action-bar";
|
||||
import { CombatantRow } from "./components/combatant-row";
|
||||
import { CreatePlayerModal } from "./components/create-player-modal";
|
||||
import { PlayerManagement } from "./components/player-management";
|
||||
import { SourceManager } from "./components/source-manager";
|
||||
import { StatBlockPanel } from "./components/stat-block-panel";
|
||||
import { Toast } from "./components/toast";
|
||||
import { TurnNavigation } from "./components/turn-navigation";
|
||||
import { useBestiary } from "./hooks/use-bestiary";
|
||||
import { type SearchResult, useBestiary } from "./hooks/use-bestiary";
|
||||
import { useBulkImport } from "./hooks/use-bulk-import";
|
||||
import { useEncounter } from "./hooks/use-encounter";
|
||||
import { usePlayerCharacters } from "./hooks/use-player-characters";
|
||||
|
||||
function rollDice(): number {
|
||||
return Math.floor(Math.random() * 20) + 1;
|
||||
@@ -31,47 +38,74 @@ export function App() {
|
||||
toggleCondition,
|
||||
toggleConcentration,
|
||||
addFromBestiary,
|
||||
addFromPlayerCharacter,
|
||||
makeStore,
|
||||
} = useEncounter();
|
||||
|
||||
const { search, getCreature, isLoaded } = useBestiary();
|
||||
const {
|
||||
characters: playerCharacters,
|
||||
createCharacter: createPlayerCharacter,
|
||||
editCharacter: editPlayerCharacter,
|
||||
deleteCharacter: deletePlayerCharacter,
|
||||
} = usePlayerCharacters();
|
||||
|
||||
const [selectedCreature, setSelectedCreature] = useState<Creature | null>(
|
||||
const [createPlayerOpen, setCreatePlayerOpen] = useState(false);
|
||||
const [managementOpen, setManagementOpen] = useState(false);
|
||||
const [editingPlayer, setEditingPlayer] = useState<
|
||||
(typeof playerCharacters)[number] | undefined
|
||||
>(undefined);
|
||||
|
||||
const {
|
||||
search,
|
||||
getCreature,
|
||||
isLoaded,
|
||||
isSourceCached,
|
||||
fetchAndCacheSource,
|
||||
uploadAndCacheSource,
|
||||
refreshCache,
|
||||
} = useBestiary();
|
||||
|
||||
const bulkImport = useBulkImport();
|
||||
|
||||
const [selectedCreatureId, setSelectedCreatureId] =
|
||||
useState<CreatureId | null>(null);
|
||||
const [bulkImportMode, setBulkImportMode] = useState(false);
|
||||
const [sourceManagerOpen, setSourceManagerOpen] = useState(false);
|
||||
const [isRightPanelFolded, setIsRightPanelFolded] = useState(false);
|
||||
const [pinnedCreatureId, setPinnedCreatureId] = useState<CreatureId | null>(
|
||||
null,
|
||||
);
|
||||
const [suggestions, setSuggestions] = useState<Creature[]>([]);
|
||||
const [isWideDesktop, setIsWideDesktop] = useState(
|
||||
() => window.matchMedia("(min-width: 1280px)").matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(min-width: 1280px)");
|
||||
const handler = (e: MediaQueryListEvent) => setIsWideDesktop(e.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
const selectedCreature: Creature | null = selectedCreatureId
|
||||
? (getCreature(selectedCreatureId) ?? null)
|
||||
: null;
|
||||
|
||||
const pinnedCreature: Creature | null = pinnedCreatureId
|
||||
? (getCreature(pinnedCreatureId) ?? null)
|
||||
: null;
|
||||
|
||||
const handleAddFromBestiary = useCallback(
|
||||
(creature: Creature) => {
|
||||
addFromBestiary(creature);
|
||||
setSelectedCreature(creature);
|
||||
(result: SearchResult) => {
|
||||
addFromBestiary(result);
|
||||
},
|
||||
[addFromBestiary],
|
||||
);
|
||||
|
||||
const handleShowStatBlock = useCallback((creature: Creature) => {
|
||||
setSelectedCreature(creature);
|
||||
const handleCombatantStatBlock = useCallback((creatureId: string) => {
|
||||
setSelectedCreatureId(creatureId as CreatureId);
|
||||
setIsRightPanelFolded(false);
|
||||
}, []);
|
||||
|
||||
const handleCombatantStatBlock = useCallback(
|
||||
(creatureId: string) => {
|
||||
const creature = getCreature(creatureId as CreatureId);
|
||||
if (creature) setSelectedCreature(creature);
|
||||
},
|
||||
[getCreature],
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(query: string) => {
|
||||
if (!isLoaded || query.length < 2) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
setSuggestions(search(query));
|
||||
},
|
||||
[isLoaded, search],
|
||||
);
|
||||
|
||||
const handleRollInitiative = useCallback(
|
||||
(id: CombatantId) => {
|
||||
rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature);
|
||||
@@ -83,6 +117,61 @@ export function App() {
|
||||
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
||||
}, [makeStore, getCreature]);
|
||||
|
||||
const handleViewStatBlock = useCallback((result: SearchResult) => {
|
||||
const slug = result.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
||||
setSelectedCreatureId(cId);
|
||||
setIsRightPanelFolded(false);
|
||||
}, []);
|
||||
|
||||
const handleBulkImport = useCallback(() => {
|
||||
setBulkImportMode(true);
|
||||
setSelectedCreatureId(null);
|
||||
}, []);
|
||||
|
||||
const handleStartBulkImport = useCallback(
|
||||
(baseUrl: string) => {
|
||||
bulkImport.startImport(
|
||||
baseUrl,
|
||||
fetchAndCacheSource,
|
||||
isSourceCached,
|
||||
refreshCache,
|
||||
);
|
||||
},
|
||||
[bulkImport.startImport, fetchAndCacheSource, isSourceCached, refreshCache],
|
||||
);
|
||||
|
||||
const handleBulkImportDone = useCallback(() => {
|
||||
setBulkImportMode(false);
|
||||
bulkImport.reset();
|
||||
}, [bulkImport.reset]);
|
||||
|
||||
const handleDismissBrowsePanel = useCallback(() => {
|
||||
setSelectedCreatureId(null);
|
||||
setBulkImportMode(false);
|
||||
}, []);
|
||||
|
||||
const handleToggleFold = useCallback(() => {
|
||||
setIsRightPanelFolded((f) => !f);
|
||||
}, []);
|
||||
|
||||
const handlePin = useCallback(() => {
|
||||
if (selectedCreatureId) {
|
||||
setPinnedCreatureId((prev) =>
|
||||
prev === selectedCreatureId ? null : selectedCreatureId,
|
||||
);
|
||||
}
|
||||
}, [selectedCreatureId]);
|
||||
|
||||
const handleUnpin = useCallback(() => {
|
||||
setPinnedCreatureId(null);
|
||||
}, []);
|
||||
|
||||
const actionBarInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Auto-scroll to the active combatant when the turn changes
|
||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
@@ -102,9 +191,8 @@ export function App() {
|
||||
if (!window.matchMedia("(min-width: 1024px)").matches) return;
|
||||
const active = encounter.combatants[encounter.activeIndex];
|
||||
if (!active?.creatureId || !isLoaded) return;
|
||||
const creature = getCreature(active.creatureId as CreatureId);
|
||||
if (creature) setSelectedCreature(creature);
|
||||
}, [encounter.activeIndex, encounter.combatants, getCreature, isLoaded]);
|
||||
setSelectedCreatureId(active.creatureId as CreatureId);
|
||||
}, [encounter.activeIndex, encounter.combatants, isLoaded]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
@@ -117,16 +205,29 @@ export function App() {
|
||||
onRetreatTurn={retreatTurn}
|
||||
onClearEncounter={clearEncounter}
|
||||
onRollAllInitiative={handleRollAllInitiative}
|
||||
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{sourceManagerOpen && (
|
||||
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
|
||||
<SourceManager onCacheCleared={refreshCache} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scrollable area — combatant list */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="flex flex-col pb-2">
|
||||
<div
|
||||
className={`flex flex-col px-2 py-2${encounter.combatants.length === 0 ? " h-full items-center justify-center" : ""}`}
|
||||
>
|
||||
{encounter.combatants.length === 0 ? (
|
||||
<p className="py-12 text-center text-sm text-muted-foreground">
|
||||
No combatants yet — add one to get started
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => actionBarInputRef.current?.focus()}
|
||||
className="animate-breathe cursor-pointer text-muted-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<Plus className="size-14" />
|
||||
</button>
|
||||
) : (
|
||||
encounter.combatants.map((c, i) => (
|
||||
<CombatantRow
|
||||
@@ -163,17 +264,123 @@ export function App() {
|
||||
onAddFromBestiary={handleAddFromBestiary}
|
||||
bestiarySearch={search}
|
||||
bestiaryLoaded={isLoaded}
|
||||
suggestions={suggestions}
|
||||
onSearchChange={handleSearchChange}
|
||||
onShowStatBlock={handleShowStatBlock}
|
||||
onViewStatBlock={handleViewStatBlock}
|
||||
onBulkImport={handleBulkImport}
|
||||
bulkImportDisabled={bulkImport.state.status === "loading"}
|
||||
inputRef={actionBarInputRef}
|
||||
playerCharacters={playerCharacters}
|
||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||
onManagePlayers={() => setManagementOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stat Block Panel */}
|
||||
{/* Pinned Stat Block Panel (left) */}
|
||||
{pinnedCreatureId && isWideDesktop && (
|
||||
<StatBlockPanel
|
||||
creatureId={pinnedCreatureId}
|
||||
creature={pinnedCreature}
|
||||
isSourceCached={isSourceCached}
|
||||
fetchAndCacheSource={fetchAndCacheSource}
|
||||
uploadAndCacheSource={uploadAndCacheSource}
|
||||
refreshCache={refreshCache}
|
||||
panelRole="pinned"
|
||||
isFolded={false}
|
||||
onToggleFold={() => {}}
|
||||
onPin={() => {}}
|
||||
onUnpin={handleUnpin}
|
||||
showPinButton={false}
|
||||
side="left"
|
||||
onDismiss={() => {}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Browse Stat Block Panel (right) */}
|
||||
<StatBlockPanel
|
||||
creatureId={selectedCreatureId}
|
||||
creature={selectedCreature}
|
||||
onClose={() => setSelectedCreature(null)}
|
||||
isSourceCached={isSourceCached}
|
||||
fetchAndCacheSource={fetchAndCacheSource}
|
||||
uploadAndCacheSource={uploadAndCacheSource}
|
||||
refreshCache={refreshCache}
|
||||
panelRole="browse"
|
||||
isFolded={isRightPanelFolded}
|
||||
onToggleFold={handleToggleFold}
|
||||
onPin={handlePin}
|
||||
onUnpin={() => {}}
|
||||
showPinButton={isWideDesktop && !!selectedCreature}
|
||||
side="right"
|
||||
onDismiss={handleDismissBrowsePanel}
|
||||
bulkImportMode={bulkImportMode}
|
||||
bulkImportState={bulkImport.state}
|
||||
onStartBulkImport={handleStartBulkImport}
|
||||
onBulkImportDone={handleBulkImportDone}
|
||||
/>
|
||||
|
||||
{/* Toast for bulk import progress when panel is closed */}
|
||||
{bulkImport.state.status === "loading" && !bulkImportMode && (
|
||||
<Toast
|
||||
message={`Loading sources... ${bulkImport.state.completed + bulkImport.state.failed}/${bulkImport.state.total}`}
|
||||
progress={
|
||||
bulkImport.state.total > 0
|
||||
? (bulkImport.state.completed + bulkImport.state.failed) /
|
||||
bulkImport.state.total
|
||||
: 0
|
||||
}
|
||||
onDismiss={() => {}}
|
||||
/>
|
||||
)}
|
||||
{bulkImport.state.status === "complete" && !bulkImportMode && (
|
||||
<Toast
|
||||
message="All sources loaded"
|
||||
onDismiss={bulkImport.reset}
|
||||
autoDismissMs={3000}
|
||||
/>
|
||||
)}
|
||||
{bulkImport.state.status === "partial-failure" && !bulkImportMode && (
|
||||
<Toast
|
||||
message={`Loaded ${bulkImport.state.completed}/${bulkImport.state.total} sources (${bulkImport.state.failed} failed)`}
|
||||
onDismiss={bulkImport.reset}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreatePlayerModal
|
||||
open={createPlayerOpen}
|
||||
onClose={() => {
|
||||
setCreatePlayerOpen(false);
|
||||
setEditingPlayer(undefined);
|
||||
}}
|
||||
onSave={(name, ac, maxHp, color, icon) => {
|
||||
if (editingPlayer) {
|
||||
editPlayerCharacter?.(editingPlayer.id, {
|
||||
name,
|
||||
ac,
|
||||
maxHp,
|
||||
color,
|
||||
icon,
|
||||
});
|
||||
} else {
|
||||
createPlayerCharacter(name, ac, maxHp, color, icon);
|
||||
}
|
||||
}}
|
||||
playerCharacter={editingPlayer}
|
||||
/>
|
||||
|
||||
<PlayerManagement
|
||||
open={managementOpen}
|
||||
onClose={() => setManagementOpen(false)}
|
||||
characters={playerCharacters}
|
||||
onEdit={(pc) => {
|
||||
setEditingPlayer(pc);
|
||||
setCreatePlayerOpen(true);
|
||||
setManagementOpen(false);
|
||||
}}
|
||||
onDelete={(id) => deletePlayerCharacter?.(id)}
|
||||
onCreate={() => {
|
||||
setEditingPlayer(undefined);
|
||||
setCreatePlayerOpen(true);
|
||||
setManagementOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
40
apps/web/src/__tests__/bestiary-index-helpers.test.ts
Normal file
40
apps/web/src/__tests__/bestiary-index-helpers.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
218
apps/web/src/__tests__/confirm-button.test.tsx
Normal file
218
apps/web/src/__tests__/confirm-button.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
263
apps/web/src/__tests__/stat-block-fold-pin.test.tsx
Normal file
263
apps/web/src/__tests__/stat-block-fold-pin.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
235
apps/web/src/__tests__/use-bulk-import.test.ts
Normal file
235
apps/web/src/__tests__/use-bulk-import.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeBestiary } from "../bestiary-adapter.js";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
normalizeBestiary,
|
||||
setSourceDisplayNames,
|
||||
} from "../bestiary-adapter.js";
|
||||
|
||||
beforeAll(() => {
|
||||
setSourceDisplayNames({ XMM: "MM 2024" });
|
||||
});
|
||||
|
||||
describe("normalizeBestiary", () => {
|
||||
it("normalizes a simple creature", () => {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { expect, it } from "vitest";
|
||||
import rawData from "../../../../../data/bestiary/xmm.json";
|
||||
import { normalizeBestiary } from "../bestiary-adapter.js";
|
||||
|
||||
it("normalizes all 503 monsters without error", () => {
|
||||
const creatures = normalizeBestiary(
|
||||
rawData as unknown as Parameters<typeof normalizeBestiary>[0],
|
||||
);
|
||||
expect(creatures.length).toBe(503);
|
||||
for (const c of creatures) {
|
||||
expect(c.name).toBeTruthy();
|
||||
expect(c.id).toBeTruthy();
|
||||
expect(c.ac).toBeGreaterThanOrEqual(0);
|
||||
expect(c.hp.average).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
@@ -17,8 +17,8 @@ interface RawMonster {
|
||||
size: string[];
|
||||
type: string | { type: string; tags?: string[]; swarmSize?: string };
|
||||
alignment?: string[];
|
||||
ac: (number | { ac: number; from?: string[] })[];
|
||||
hp: { average: number; formula: string };
|
||||
ac: (number | { ac: number; from?: string[] } | { special: string })[];
|
||||
hp: { average?: number; formula?: string; special?: string };
|
||||
speed: Record<
|
||||
string,
|
||||
number | { number: number; condition?: string } | boolean
|
||||
@@ -38,7 +38,7 @@ interface RawMonster {
|
||||
vulnerable?: (string | { special: string })[];
|
||||
conditionImmune?: string[];
|
||||
languages?: string[];
|
||||
cr: string | { cr: string };
|
||||
cr?: string | { cr: string };
|
||||
trait?: RawEntry[];
|
||||
action?: RawEntry[];
|
||||
bonus?: RawEntry[];
|
||||
@@ -81,9 +81,11 @@ interface RawSpellcasting {
|
||||
|
||||
// --- Source mapping ---
|
||||
|
||||
const SOURCE_DISPLAY_NAMES: Record<string, string> = {
|
||||
XMM: "MM 2024",
|
||||
};
|
||||
let sourceDisplayNames: Record<string, string> = {};
|
||||
|
||||
export function setSourceDisplayNames(names: Record<string, string>): void {
|
||||
sourceDisplayNames = names;
|
||||
}
|
||||
|
||||
// --- Size mapping ---
|
||||
|
||||
@@ -138,7 +140,12 @@ function formatType(
|
||||
|
||||
let result = baseType;
|
||||
if (type.tags && type.tags.length > 0) {
|
||||
result += ` (${type.tags.map(capitalize).join(", ")})`;
|
||||
const tagStrs = type.tags
|
||||
.filter((t): t is string => typeof t === "string")
|
||||
.map(capitalize);
|
||||
if (tagStrs.length > 0) {
|
||||
result += ` (${tagStrs.join(", ")})`;
|
||||
}
|
||||
}
|
||||
if (type.swarmSize) {
|
||||
const swarmSizeLabel = SIZE_MAP[type.swarmSize] ?? type.swarmSize;
|
||||
@@ -159,6 +166,14 @@ function extractAc(ac: RawMonster["ac"]): {
|
||||
if (typeof first === "number") {
|
||||
return { value: first };
|
||||
}
|
||||
if ("special" in first) {
|
||||
// Variable AC (e.g. spell summons) — parse leading number if possible
|
||||
const match = first.special.match(/^(\d+)/);
|
||||
return {
|
||||
value: match ? Number(match[1]) : 0,
|
||||
source: first.special,
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: first.ac,
|
||||
source: first.from ? stripTags(first.from.join(", ")) : undefined,
|
||||
@@ -239,25 +254,36 @@ function formatConditionImmunities(
|
||||
.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 if (entry.type === "list") {
|
||||
for (const item of entry.items ?? []) {
|
||||
if (typeof item === "string") {
|
||||
parts.push(`• ${stripTags(item)}`);
|
||||
} else if (item.name && item.entries) {
|
||||
parts.push(
|
||||
`• ${stripTags(item.name)}: ${renderEntries(item.entries)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} 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));
|
||||
} else {
|
||||
renderEntryObject(entry, parts);
|
||||
}
|
||||
}
|
||||
return parts.join(" ");
|
||||
@@ -337,7 +363,8 @@ function normalizeLegendary(
|
||||
};
|
||||
}
|
||||
|
||||
function extractCr(cr: string | { cr: string }): string {
|
||||
function extractCr(cr: string | { cr: string } | undefined): string {
|
||||
if (cr === undefined) return "—";
|
||||
return typeof cr === "string" ? cr : cr.cr;
|
||||
}
|
||||
|
||||
@@ -353,54 +380,81 @@ function makeCreatureId(source: string, name: string): CreatureId {
|
||||
* Normalizes raw 5etools bestiary JSON into domain Creature[].
|
||||
*/
|
||||
export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
|
||||
return raw.monster.map((m) => {
|
||||
const crStr = extractCr(m.cr);
|
||||
const ac = extractAc(m.ac);
|
||||
|
||||
return {
|
||||
id: makeCreatureId(m.source, m.name),
|
||||
name: m.name,
|
||||
source: m.source,
|
||||
sourceDisplayName: SOURCE_DISPLAY_NAMES[m.source] ?? m.source,
|
||||
size: formatSize(m.size),
|
||||
type: formatType(m.type),
|
||||
alignment: formatAlignment(m.alignment),
|
||||
ac: ac.value,
|
||||
acSource: ac.source,
|
||||
hp: { average: m.hp.average, formula: m.hp.formula },
|
||||
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),
|
||||
};
|
||||
// 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),
|
||||
};
|
||||
}
|
||||
|
||||
139
apps/web/src/adapters/bestiary-cache.ts
Normal file
139
apps/web/src/adapters/bestiary-cache.ts
Normal 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;
|
||||
}
|
||||
96
apps/web/src/adapters/bestiary-index-adapter.ts
Normal file
96
apps/web/src/adapters/bestiary-index-adapter.ts
Normal 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;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ const ATKR_MAP: Record<string, string> = {
|
||||
* Handles 15+ tag types per research.md R-002 tag resolution rules.
|
||||
*/
|
||||
export function stripTags(text: string): string {
|
||||
if (typeof text !== "string") return String(text);
|
||||
// Process special tags with specific output formats first
|
||||
let result = text;
|
||||
|
||||
|
||||
219
apps/web/src/components/__tests__/turn-navigation.test.tsx
Normal file
219
apps/web/src/components/__tests__/turn-navigation.test.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import type { Encounter } from "@initiative/domain";
|
||||
import { combatantId } from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TurnNavigation } from "../turn-navigation";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderNav(overrides: Partial<Encounter> = {}) {
|
||||
const encounter: Encounter = {
|
||||
combatants: [
|
||||
{ id: combatantId("1"), name: "Goblin" },
|
||||
{ id: combatantId("2"), name: "Conjurer" },
|
||||
],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return render(
|
||||
<TurnNavigation
|
||||
encounter={encounter}
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
onRollAllInitiative={vi.fn()}
|
||||
onOpenSourceManager={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("TurnNavigation", () => {
|
||||
describe("US1: Round badge and combatant name", () => {
|
||||
it("renders the round badge with correct round number", () => {
|
||||
renderNav({ roundNumber: 3 });
|
||||
expect(screen.getByText("R3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the combatant name separately from the round badge", () => {
|
||||
renderNav();
|
||||
const badge = screen.getByText("R1");
|
||||
const name = screen.getByText("Goblin");
|
||||
expect(badge).toBeInTheDocument();
|
||||
expect(name).toBeInTheDocument();
|
||||
expect(badge).not.toBe(name);
|
||||
expect(badge.closest("[class]")).not.toBe(name.closest("[class]"));
|
||||
});
|
||||
|
||||
it("does not render an em dash between round and name", () => {
|
||||
const { container } = renderNav();
|
||||
expect(container.textContent).not.toContain("—");
|
||||
});
|
||||
|
||||
it("round badge and combatant name are siblings in the center area", () => {
|
||||
renderNav();
|
||||
const badge = screen.getByText("R1");
|
||||
const name = screen.getByText("Goblin");
|
||||
expect(badge.parentElement).toBe(name.parentElement);
|
||||
});
|
||||
|
||||
it("updates the round badge when round changes", () => {
|
||||
const { rerender } = render(
|
||||
<TurnNavigation
|
||||
encounter={{
|
||||
combatants: [{ id: combatantId("1"), name: "Goblin" }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 2,
|
||||
}}
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
onRollAllInitiative={vi.fn()}
|
||||
onOpenSourceManager={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("R2")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<TurnNavigation
|
||||
encounter={{
|
||||
combatants: [{ id: combatantId("1"), name: "Goblin" }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 3,
|
||||
}}
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
onRollAllInitiative={vi.fn()}
|
||||
onOpenSourceManager={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("R3")).toBeInTheDocument();
|
||||
expect(screen.queryByText("R2")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the next combatant name when turn advances", () => {
|
||||
const { rerender } = render(
|
||||
<TurnNavigation
|
||||
encounter={{
|
||||
combatants: [
|
||||
{ id: combatantId("1"), name: "Goblin" },
|
||||
{ id: combatantId("2"), name: "Conjurer" },
|
||||
],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
}}
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
onRollAllInitiative={vi.fn()}
|
||||
onOpenSourceManager={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<TurnNavigation
|
||||
encounter={{
|
||||
combatants: [
|
||||
{ id: combatantId("1"), name: "Goblin" },
|
||||
{ id: combatantId("2"), name: "Conjurer" },
|
||||
],
|
||||
activeIndex: 1,
|
||||
roundNumber: 1,
|
||||
}}
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
onRollAllInitiative={vi.fn()}
|
||||
onOpenSourceManager={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Conjurer")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("US2: Layout robustness", () => {
|
||||
it("applies truncation styles to long combatant names", () => {
|
||||
const longName =
|
||||
"Ancient Red Dragon Wyrm of the Northern Wastes and Beyond";
|
||||
renderNav({
|
||||
combatants: [{ id: combatantId("1"), name: longName }],
|
||||
});
|
||||
const nameEl = screen.getByText(longName);
|
||||
expect(nameEl.className).toContain("truncate");
|
||||
});
|
||||
|
||||
it("renders three-zone layout with a single-character name", () => {
|
||||
renderNav({
|
||||
combatants: [{ id: combatantId("1"), name: "O" }],
|
||||
});
|
||||
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||
expect(screen.getByText("O")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Previous turn" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Next turn" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps all action buttons accessible regardless of name length", () => {
|
||||
const longName = "A".repeat(60);
|
||||
renderNav({
|
||||
combatants: [{ id: combatantId("1"), name: longName }],
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Previous turn" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Next turn" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "Roll all initiative",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "Manage cached sources",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a 40-character name without truncation class issues", () => {
|
||||
const name40 = "A".repeat(40);
|
||||
renderNav({
|
||||
combatants: [{ id: combatantId("1"), name: name40 }],
|
||||
});
|
||||
const nameEl = screen.getByText(name40);
|
||||
expect(nameEl).toBeInTheDocument();
|
||||
// The truncate class is applied but CSS only visually truncates if content overflows
|
||||
expect(nameEl.className).toContain("truncate");
|
||||
});
|
||||
});
|
||||
|
||||
describe("US3: No combatants state", () => {
|
||||
it("shows the round badge when there are no combatants", () => {
|
||||
renderNav({ combatants: [], roundNumber: 1 });
|
||||
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'No combatants' placeholder text", () => {
|
||||
renderNav({ combatants: [] });
|
||||
expect(screen.getByText("No combatants")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables navigation buttons when there are no combatants", () => {
|
||||
renderNav({ combatants: [] });
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Previous turn" }),
|
||||
).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Next turn" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,41 @@
|
||||
import type { Creature } from "@initiative/domain";
|
||||
import { Search } from "lucide-react";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { BestiarySearch } from "./bestiary-search.js";
|
||||
import type { PlayerCharacter, PlayerIcon } from "@initiative/domain";
|
||||
import { Check, Eye, Import, Minus, Plus, Users } from "lucide-react";
|
||||
import {
|
||||
type FormEvent,
|
||||
type RefObject,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { SearchResult } from "../hooks/use-bestiary.js";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
interface QueuedCreature {
|
||||
result: SearchResult;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface ActionBarProps {
|
||||
onAddCombatant: (name: string) => void;
|
||||
onAddFromBestiary: (creature: Creature) => void;
|
||||
bestiarySearch: (query: string) => Creature[];
|
||||
onAddCombatant: (
|
||||
name: string,
|
||||
opts?: { initiative?: number; ac?: number; maxHp?: number },
|
||||
) => void;
|
||||
onAddFromBestiary: (result: SearchResult) => void;
|
||||
bestiarySearch: (query: string) => SearchResult[];
|
||||
bestiaryLoaded: boolean;
|
||||
suggestions: Creature[];
|
||||
onSearchChange: (query: string) => void;
|
||||
onShowStatBlock?: (creature: Creature) => void;
|
||||
onViewStatBlock?: (result: SearchResult) => void;
|
||||
onBulkImport?: () => void;
|
||||
bulkImportDisabled?: boolean;
|
||||
inputRef?: RefObject<HTMLInputElement | null>;
|
||||
playerCharacters?: readonly PlayerCharacter[];
|
||||
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
||||
onManagePlayers?: () => void;
|
||||
}
|
||||
|
||||
function creatureKey(r: SearchResult): string {
|
||||
return `${r.source}:${r.name}`;
|
||||
}
|
||||
|
||||
export function ActionBar({
|
||||
@@ -20,45 +43,128 @@ export function ActionBar({
|
||||
onAddFromBestiary,
|
||||
bestiarySearch,
|
||||
bestiaryLoaded,
|
||||
suggestions,
|
||||
onSearchChange,
|
||||
onShowStatBlock,
|
||||
onViewStatBlock,
|
||||
onBulkImport,
|
||||
bulkImportDisabled,
|
||||
inputRef,
|
||||
playerCharacters,
|
||||
onAddFromPlayerCharacter,
|
||||
onManagePlayers,
|
||||
}: ActionBarProps) {
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
||||
const [suggestionIndex, setSuggestionIndex] = useState(-1);
|
||||
const [queued, setQueued] = useState<QueuedCreature | null>(null);
|
||||
const [customInit, setCustomInit] = useState("");
|
||||
const [customAc, setCustomAc] = useState("");
|
||||
const [customMaxHp, setCustomMaxHp] = useState("");
|
||||
|
||||
// Stat block viewer: separate dropdown
|
||||
const [viewerOpen, setViewerOpen] = useState(false);
|
||||
const [viewerQuery, setViewerQuery] = useState("");
|
||||
const [viewerResults, setViewerResults] = useState<SearchResult[]>([]);
|
||||
const [viewerIndex, setViewerIndex] = useState(-1);
|
||||
const viewerRef = useRef<HTMLDivElement>(null);
|
||||
const viewerInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const clearCustomFields = () => {
|
||||
setCustomInit("");
|
||||
setCustomAc("");
|
||||
setCustomMaxHp("");
|
||||
};
|
||||
|
||||
const confirmQueued = () => {
|
||||
if (!queued) return;
|
||||
for (let i = 0; i < queued.count; i++) {
|
||||
onAddFromBestiary(queued.result);
|
||||
}
|
||||
setQueued(null);
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
setSuggestionIndex(-1);
|
||||
};
|
||||
|
||||
const parseNum = (v: string): number | undefined => {
|
||||
if (v.trim() === "") return undefined;
|
||||
const n = Number(v);
|
||||
return Number.isNaN(n) ? undefined : n;
|
||||
};
|
||||
|
||||
const handleAdd = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (queued) {
|
||||
confirmQueued();
|
||||
return;
|
||||
}
|
||||
if (nameInput.trim() === "") return;
|
||||
onAddCombatant(nameInput);
|
||||
const opts: { initiative?: number; ac?: number; maxHp?: number } = {};
|
||||
const init = parseNum(customInit);
|
||||
const ac = parseNum(customAc);
|
||||
const maxHp = parseNum(customMaxHp);
|
||||
if (init !== undefined) opts.initiative = init;
|
||||
if (ac !== undefined) opts.ac = ac;
|
||||
if (maxHp !== undefined) opts.maxHp = maxHp;
|
||||
onAddCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
|
||||
setNameInput("");
|
||||
onSearchChange("");
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
clearCustomFields();
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setNameInput(value);
|
||||
setSuggestionIndex(-1);
|
||||
onSearchChange(value);
|
||||
let newSuggestions: SearchResult[] = [];
|
||||
let newPcMatches: PlayerCharacter[] = [];
|
||||
if (value.length >= 2) {
|
||||
newSuggestions = bestiarySearch(value);
|
||||
setSuggestions(newSuggestions);
|
||||
if (playerCharacters && playerCharacters.length > 0) {
|
||||
const lower = value.toLowerCase();
|
||||
newPcMatches = playerCharacters.filter((pc) =>
|
||||
pc.name.toLowerCase().includes(lower),
|
||||
);
|
||||
}
|
||||
setPcMatches(newPcMatches);
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
}
|
||||
if (newSuggestions.length > 0 || newPcMatches.length > 0) {
|
||||
clearCustomFields();
|
||||
}
|
||||
if (queued) {
|
||||
const qKey = creatureKey(queued.result);
|
||||
const stillVisible = newSuggestions.some((s) => creatureKey(s) === qKey);
|
||||
if (!stillVisible) {
|
||||
setQueued(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectCreature = (creature: Creature) => {
|
||||
onAddFromBestiary(creature);
|
||||
setSearchOpen(false);
|
||||
setNameInput("");
|
||||
onSearchChange("");
|
||||
onShowStatBlock?.(creature);
|
||||
const handleClickSuggestion = (result: SearchResult) => {
|
||||
const key = creatureKey(result);
|
||||
if (queued && creatureKey(queued.result) === key) {
|
||||
setQueued({ ...queued, count: queued.count + 1 });
|
||||
} else {
|
||||
setQueued({ result, count: 1 });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectSuggestion = (creature: Creature) => {
|
||||
onAddFromBestiary(creature);
|
||||
setNameInput("");
|
||||
onSearchChange("");
|
||||
onShowStatBlock?.(creature);
|
||||
const handleEnter = () => {
|
||||
if (queued) {
|
||||
confirmQueued();
|
||||
} else if (suggestionIndex >= 0) {
|
||||
handleClickSuggestion(suggestions[suggestionIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
const hasSuggestions = suggestions.length > 0 || pcMatches.length > 0;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (suggestions.length === 0) return;
|
||||
if (!hasSuggestions) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
@@ -66,78 +172,359 @@ export function ActionBar({
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
||||
} else if (e.key === "Enter" && suggestionIndex >= 0) {
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSelectSuggestion(suggestions[suggestionIndex]);
|
||||
handleEnter();
|
||||
} else if (e.key === "Escape") {
|
||||
setQueued(null);
|
||||
setSuggestionIndex(-1);
|
||||
onSearchChange("");
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Stat block viewer dropdown handlers
|
||||
const openViewer = () => {
|
||||
setViewerOpen(true);
|
||||
setViewerQuery("");
|
||||
setViewerResults([]);
|
||||
setViewerIndex(-1);
|
||||
requestAnimationFrame(() => viewerInputRef.current?.focus());
|
||||
};
|
||||
|
||||
const closeViewer = () => {
|
||||
setViewerOpen(false);
|
||||
setViewerQuery("");
|
||||
setViewerResults([]);
|
||||
setViewerIndex(-1);
|
||||
};
|
||||
|
||||
const handleViewerQueryChange = (value: string) => {
|
||||
setViewerQuery(value);
|
||||
setViewerIndex(-1);
|
||||
if (value.length >= 2) {
|
||||
setViewerResults(bestiarySearch(value));
|
||||
} else {
|
||||
setViewerResults([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewerSelect = (result: SearchResult) => {
|
||||
onViewStatBlock?.(result);
|
||||
closeViewer();
|
||||
};
|
||||
|
||||
const handleViewerKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
closeViewer();
|
||||
return;
|
||||
}
|
||||
if (viewerResults.length === 0) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setViewerIndex((i) => (i < viewerResults.length - 1 ? i + 1 : 0));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setViewerIndex((i) => (i > 0 ? i - 1 : viewerResults.length - 1));
|
||||
} else if (e.key === "Enter" && viewerIndex >= 0) {
|
||||
e.preventDefault();
|
||||
handleViewerSelect(viewerResults[viewerIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
// Close viewer on outside click
|
||||
useEffect(() => {
|
||||
if (!viewerOpen) return;
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (viewerRef.current && !viewerRef.current.contains(e.target as Node)) {
|
||||
closeViewer();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [viewerOpen]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
|
||||
{searchOpen ? (
|
||||
<BestiarySearch
|
||||
onSelectCreature={handleSelectCreature}
|
||||
onClose={() => setSearchOpen(false)}
|
||||
searchFn={bestiarySearch}
|
||||
/>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleAdd}
|
||||
className="relative flex flex-1 items-center gap-2"
|
||||
>
|
||||
<div className="relative flex-1">
|
||||
<form
|
||||
onSubmit={handleAdd}
|
||||
className="relative flex flex-1 items-center gap-2"
|
||||
>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={nameInput}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="+ Add combatants"
|
||||
className="max-w-xs"
|
||||
/>
|
||||
{hasSuggestions && (
|
||||
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-2 text-left text-sm text-accent hover:bg-accent/20"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
setQueued(null);
|
||||
setSuggestionIndex(-1);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<span className="flex-1">Add "{nameInput}" as custom</span>
|
||||
<kbd className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||
Esc
|
||||
</kbd>
|
||||
</button>
|
||||
<div className="max-h-48 overflow-y-auto py-1">
|
||||
{pcMatches.length > 0 && (
|
||||
<>
|
||||
<div className="px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||
Players
|
||||
</div>
|
||||
<ul>
|
||||
{pcMatches.map((pc) => {
|
||||
const PcIcon = PLAYER_ICON_MAP[pc.icon as PlayerIcon];
|
||||
const pcColor =
|
||||
PLAYER_COLOR_HEX[
|
||||
pc.color as keyof typeof PLAYER_COLOR_HEX
|
||||
];
|
||||
return (
|
||||
<li key={pc.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-foreground hover:bg-hover-neutral-bg"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
onAddFromPlayerCharacter?.(pc);
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
}}
|
||||
>
|
||||
{PcIcon && (
|
||||
<PcIcon size={14} style={{ color: pcColor }} />
|
||||
)}
|
||||
<span className="flex-1 truncate">{pc.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Player
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{suggestions.length > 0 && (
|
||||
<ul>
|
||||
{suggestions.map((result, i) => {
|
||||
const key = creatureKey(result);
|
||||
const isQueued =
|
||||
queued !== null && creatureKey(queued.result) === key;
|
||||
return (
|
||||
<li key={key}>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
{nameInput.length >= 2 && !hasSuggestions && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={nameInput}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Combatant name"
|
||||
className="max-w-xs"
|
||||
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"
|
||||
/>
|
||||
{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((creature, i) => (
|
||||
<li key={creature.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
|
||||
i === suggestionIndex
|
||||
? "bg-accent/20 text-foreground"
|
||||
: "text-foreground hover:bg-hover-neutral-bg"
|
||||
}`}
|
||||
onClick={() => handleSelectSuggestion(creature)}
|
||||
onMouseEnter={() => setSuggestionIndex(i)}
|
||||
>
|
||||
<span>{creature.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{creature.sourceDisplayName}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" size="sm">
|
||||
Add
|
||||
</Button>
|
||||
{bestiaryLoaded && (
|
||||
)}
|
||||
<Button type="submit" size="sm">
|
||||
Add
|
||||
</Button>
|
||||
<div className="flex items-center gap-0">
|
||||
{onManagePlayers && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className="text-muted-foreground hover:text-hover-neutral"
|
||||
onClick={onManagePlayers}
|
||||
title="Player characters"
|
||||
aria-label="Player characters"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
<Users className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
{bestiaryLoaded && onViewStatBlock && (
|
||||
<div ref={viewerRef} className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-hover-neutral"
|
||||
onClick={() => (viewerOpen ? closeViewer() : openViewer())}
|
||||
title="Browse stat blocks"
|
||||
aria-label="Browse stat blocks"
|
||||
>
|
||||
<Eye className="h-5 w-5" />
|
||||
</Button>
|
||||
{viewerOpen && (
|
||||
<div className="absolute bottom-full right-0 z-50 mb-1 w-64 rounded-md border border-border bg-card shadow-lg">
|
||||
<div className="p-2">
|
||||
<Input
|
||||
ref={viewerInputRef}
|
||||
type="text"
|
||||
value={viewerQuery}
|
||||
onChange={(e) => handleViewerQueryChange(e.target.value)}
|
||||
onKeyDown={handleViewerKeyDown}
|
||||
placeholder="Search stat blocks..."
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
{viewerResults.length > 0 && (
|
||||
<ul className="max-h-48 overflow-y-auto border-t border-border py-1">
|
||||
{viewerResults.map((result, i) => (
|
||||
<li key={creatureKey(result)}>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
|
||||
i === viewerIndex
|
||||
? "bg-accent/20 text-foreground"
|
||||
: "text-foreground hover:bg-hover-neutral-bg"
|
||||
}`}
|
||||
onClick={() => handleViewerSelect(result)}
|
||||
onMouseEnter={() => setViewerIndex(i)}
|
||||
>
|
||||
<span>{result.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{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="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-hover-neutral"
|
||||
onClick={onBulkImport}
|
||||
disabled={bulkImportDisabled}
|
||||
title="Bulk import"
|
||||
aria-label="Bulk import"
|
||||
>
|
||||
<Import className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import type { Creature } from "@initiative/domain";
|
||||
import { Search, X } from "lucide-react";
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
interface BestiarySearchProps {
|
||||
onSelectCreature: (creature: Creature) => void;
|
||||
onClose: () => void;
|
||||
searchFn: (query: string) => Creature[];
|
||||
}
|
||||
|
||||
export function BestiarySearch({
|
||||
onSelectCreature,
|
||||
onClose,
|
||||
searchFn,
|
||||
}: BestiarySearchProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [highlightIndex, setHighlightIndex] = useState(-1);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const results = query.length >= 2 ? searchFn(query) : [];
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setHighlightIndex(-1);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [onClose]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((i) => (i < results.length - 1 ? i + 1 : 0));
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((i) => (i > 0 ? i - 1 : results.length - 1));
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" && highlightIndex >= 0) {
|
||||
e.preventDefault();
|
||||
onSelectCreature(results[highlightIndex]);
|
||||
}
|
||||
},
|
||||
[results, highlightIndex, onClose, onSelectCreature],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full max-w-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search bestiary..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-hover-neutral"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{query.length >= 2 && (
|
||||
<div className="absolute bottom-full z-50 mb-1 w-full rounded-md border border-border bg-card shadow-lg">
|
||||
{results.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
No creatures found
|
||||
</div>
|
||||
) : (
|
||||
<ul className="max-h-60 overflow-y-auto py-1">
|
||||
{results.map((creature, i) => (
|
||||
<li key={creature.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
|
||||
i === highlightIndex
|
||||
? "bg-accent/20 text-foreground"
|
||||
: "text-foreground hover:bg-hover-neutral-bg"
|
||||
}`}
|
||||
onClick={() => onSelectCreature(creature)}
|
||||
onMouseEnter={() => setHighlightIndex(i)}
|
||||
>
|
||||
<span>{creature.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{creature.sourceDisplayName}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
apps/web/src/components/bulk-import-prompt.tsx
Normal file
115
apps/web/src/components/bulk-import-prompt.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
apps/web/src/components/color-palette.tsx
Normal file
36
apps/web/src/components/color-palette.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { VALID_PLAYER_COLORS } from "@initiative/domain";
|
||||
import { cn } from "../lib/utils";
|
||||
import { PLAYER_COLOR_HEX } from "./player-icon-map";
|
||||
|
||||
interface ColorPaletteProps {
|
||||
value: string;
|
||||
onChange: (color: string) => void;
|
||||
}
|
||||
|
||||
const COLORS = [...VALID_PLAYER_COLORS] as string[];
|
||||
|
||||
export function ColorPalette({ value, onChange }: ColorPaletteProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => onChange(color)}
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-full transition-all",
|
||||
value === color
|
||||
? "ring-2 ring-foreground ring-offset-2 ring-offset-background scale-110"
|
||||
: "hover:scale-110",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor:
|
||||
PLAYER_COLOR_HEX[color as keyof typeof PLAYER_COLOR_HEX],
|
||||
}}
|
||||
aria-label={color}
|
||||
title={color}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
type CombatantId,
|
||||
type ConditionId,
|
||||
deriveHpStatus,
|
||||
type PlayerIcon,
|
||||
} from "@initiative/domain";
|
||||
import { Brain, X } from "lucide-react";
|
||||
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
|
||||
@@ -11,7 +12,8 @@ import { ConditionPicker } from "./condition-picker";
|
||||
import { ConditionTags } from "./condition-tags";
|
||||
import { D20Icon } from "./d20-icon";
|
||||
import { HpAdjustPopover } from "./hp-adjust-popover";
|
||||
import { Button } from "./ui/button";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||
import { ConfirmButton } from "./ui/confirm-button";
|
||||
import { Input } from "./ui/input";
|
||||
|
||||
interface Combatant {
|
||||
@@ -23,6 +25,8 @@ interface Combatant {
|
||||
readonly ac?: number;
|
||||
readonly conditions?: readonly ConditionId[];
|
||||
readonly isConcentrating?: boolean;
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
}
|
||||
|
||||
interface CombatantRowProps {
|
||||
@@ -44,14 +48,19 @@ 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();
|
||||
@@ -67,6 +76,46 @@ function EditableName({
|
||||
requestAnimationFrame(() => inputRef.current?.select());
|
||||
}, [name]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimeout(clickTimerRef.current);
|
||||
clearTimeout(longPressTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (longPressTriggeredRef.current) {
|
||||
longPressTriggeredRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (clickTimerRef.current) {
|
||||
clearTimeout(clickTimerRef.current);
|
||||
clickTimerRef.current = undefined;
|
||||
startEditing();
|
||||
} else {
|
||||
clickTimerRef.current = setTimeout(() => {
|
||||
clickTimerRef.current = undefined;
|
||||
onShowStatBlock?.();
|
||||
}, 250);
|
||||
}
|
||||
},
|
||||
[startEditing, onShowStatBlock],
|
||||
);
|
||||
|
||||
const handleTouchStart = useCallback(() => {
|
||||
longPressTriggeredRef.current = false;
|
||||
longPressTimerRef.current = setTimeout(() => {
|
||||
longPressTriggeredRef.current = true;
|
||||
startEditing();
|
||||
}, 500);
|
||||
}, [startEditing]);
|
||||
|
||||
const cancelLongPress = useCallback(() => {
|
||||
clearTimeout(longPressTimerRef.current);
|
||||
}, []);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<Input
|
||||
@@ -85,16 +134,19 @@ function EditableName({
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startEditing();
|
||||
}}
|
||||
className="truncate text-left text-sm text-foreground hover:text-hover-neutral transition-colors"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -354,6 +406,35 @@ function InitiativeDisplay({
|
||||
);
|
||||
}
|
||||
|
||||
function rowBorderClass(
|
||||
isActive: boolean,
|
||||
isConcentrating: boolean | undefined,
|
||||
): string {
|
||||
if (isActive) return "border-l-2 border-l-accent bg-accent/10";
|
||||
if (isConcentrating) return "border-l-2 border-l-purple-400";
|
||||
return "border-l-2 border-l-transparent";
|
||||
}
|
||||
|
||||
function concentrationIconClass(
|
||||
isConcentrating: boolean | undefined,
|
||||
dimmed: boolean,
|
||||
): string {
|
||||
if (!isConcentrating)
|
||||
return "opacity-0 group-hover:opacity-50 text-muted-foreground";
|
||||
return dimmed ? "opacity-50 text-purple-400" : "opacity-100 text-purple-400";
|
||||
}
|
||||
|
||||
function activateOnKeyDown(
|
||||
handler: () => void,
|
||||
): (e: { key: string; preventDefault: () => void }) => void {
|
||||
return (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handler();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function CombatantRow({
|
||||
ref,
|
||||
combatant,
|
||||
@@ -401,22 +482,28 @@ export function CombatantRow({
|
||||
}
|
||||
}, [combatant.isConcentrating]);
|
||||
|
||||
const pcColor =
|
||||
combatant.color && !isActive && !combatant.isConcentrating
|
||||
? PLAYER_COLOR_HEX[combatant.color as keyof typeof PLAYER_COLOR_HEX]
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
/* biome-ignore lint/a11y/useKeyWithClickEvents: row click opens stat block */
|
||||
/* biome-ignore lint/a11y/noStaticElementInteractions: row click opens stat block */
|
||||
/* biome-ignore lint/a11y/noStaticElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
|
||||
<div
|
||||
ref={ref}
|
||||
role={onShowStatBlock ? "button" : undefined}
|
||||
tabIndex={onShowStatBlock ? 0 : undefined}
|
||||
className={cn(
|
||||
"group rounded-md pr-3 transition-colors",
|
||||
isActive
|
||||
? "border-l-2 border-l-accent bg-accent/10"
|
||||
: combatant.isConcentrating
|
||||
? "border-l-2 border-l-purple-400"
|
||||
: "border-l-2 border-l-transparent",
|
||||
rowBorderClass(isActive, combatant.isConcentrating),
|
||||
isPulsing && "animate-concentration-pulse",
|
||||
onShowStatBlock && "cursor-pointer",
|
||||
)}
|
||||
style={pcColor ? { borderLeftColor: pcColor } : undefined}
|
||||
onClick={onShowStatBlock}
|
||||
onKeyDown={
|
||||
onShowStatBlock ? activateOnKeyDown(onShowStatBlock) : undefined
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-[2rem_3rem_1fr_auto_auto_2rem] items-center gap-3 py-2">
|
||||
{/* Concentration */}
|
||||
@@ -430,20 +517,18 @@ export function CombatantRow({
|
||||
aria-label="Toggle concentration"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-center self-stretch -my-2 -ml-[2px] pl-[14px] transition-opacity hover:text-hover-neutral hover:opacity-100",
|
||||
combatant.isConcentrating
|
||||
? dimmed
|
||||
? "opacity-50 text-purple-400"
|
||||
: "opacity-100 text-purple-400"
|
||||
: "opacity-0 group-hover:opacity-50 text-muted-foreground",
|
||||
concentrationIconClass(combatant.isConcentrating, dimmed),
|
||||
)}
|
||||
>
|
||||
<Brain size={16} />
|
||||
</button>
|
||||
|
||||
{/* Initiative */}
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation wrapper */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper */}
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<InitiativeDisplay
|
||||
initiative={initiative}
|
||||
combatantId={id}
|
||||
@@ -460,9 +545,28 @@ export function CombatantRow({
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
<EditableName name={name} combatantId={id} onRename={onRename} />
|
||||
</span>
|
||||
{combatant.icon &&
|
||||
combatant.color &&
|
||||
(() => {
|
||||
const PcIcon = PLAYER_ICON_MAP[combatant.icon as PlayerIcon];
|
||||
const pcColor =
|
||||
PLAYER_COLOR_HEX[
|
||||
combatant.color as keyof typeof PLAYER_COLOR_HEX
|
||||
];
|
||||
return PcIcon ? (
|
||||
<PcIcon
|
||||
size={14}
|
||||
style={{ color: pcColor }}
|
||||
className="shrink-0"
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
<EditableName
|
||||
name={name}
|
||||
combatantId={id}
|
||||
onRename={onRename}
|
||||
onShowStatBlock={onShowStatBlock}
|
||||
/>
|
||||
<ConditionTags
|
||||
conditions={combatant.conditions}
|
||||
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
|
||||
@@ -478,21 +582,21 @@ export function CombatantRow({
|
||||
</div>
|
||||
|
||||
{/* AC */}
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation wrapper */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
||||
<div
|
||||
className={cn(dimmed && "opacity-50")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
|
||||
</div>
|
||||
|
||||
{/* HP */}
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation wrapper */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ClickableHp
|
||||
currentHp={currentHp}
|
||||
@@ -516,19 +620,12 @@ export function CombatantRow({
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-hover-destructive opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto focus:opacity-100 focus:pointer-events-auto transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(id);
|
||||
}}
|
||||
title="Remove combatant"
|
||||
aria-label="Remove combatant"
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
<ConfirmButton
|
||||
icon={<X size={16} />}
|
||||
label="Remove combatant"
|
||||
onConfirm={() => onRemove(id)}
|
||||
className="h-7 w-7 text-muted-foreground opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto focus:opacity-100 focus:pointer-events-auto pointer-coarse:opacity-100 pointer-coarse:pointer-events-auto transition-opacity"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -64,12 +64,21 @@ export function ConditionPicker({
|
||||
}: ConditionPickerProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [flipped, setFlipped] = useState(false);
|
||||
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
setFlipped(rect.bottom > window.innerHeight);
|
||||
const spaceBelow = window.innerHeight - rect.top;
|
||||
const spaceAbove = rect.bottom;
|
||||
const shouldFlip =
|
||||
rect.bottom > window.innerHeight && spaceAbove > spaceBelow;
|
||||
setFlipped(shouldFlip);
|
||||
const available = shouldFlip ? spaceAbove : spaceBelow;
|
||||
if (rect.height > available) {
|
||||
setMaxHeight(available - 16);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -88,9 +97,10 @@ export function ConditionPicker({
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute z-10 w-fit rounded-md border border-border bg-background p-1 shadow-lg",
|
||||
flipped ? "bottom-full mb-1" : "mt-1",
|
||||
"absolute left-0 z-10 w-fit overflow-y-auto rounded-md border border-border bg-background p-1 shadow-lg",
|
||||
flipped ? "bottom-full mb-1" : "top-full mt-1",
|
||||
)}
|
||||
style={maxHeight ? { maxHeight } : undefined}
|
||||
>
|
||||
{CONDITION_DEFINITIONS.map((def) => {
|
||||
const Icon = ICON_MAP[def.iconName];
|
||||
|
||||
@@ -89,7 +89,7 @@ export function ConditionTags({
|
||||
type="button"
|
||||
title="Add condition"
|
||||
aria-label="Add condition"
|
||||
className="inline-flex items-center rounded p-0.5 text-muted-foreground hover:text-hover-neutral hover:bg-hover-neutral-bg transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity"
|
||||
className="inline-flex items-center rounded p-0.5 text-muted-foreground hover:text-hover-neutral hover:bg-hover-neutral-bg transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 pointer-coarse:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenPicker();
|
||||
|
||||
177
apps/web/src/components/create-player-modal.tsx
Normal file
177
apps/web/src/components/create-player-modal.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import { X } from "lucide-react";
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { ColorPalette } from "./color-palette";
|
||||
import { IconGrid } from "./icon-grid";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
|
||||
interface CreatePlayerModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (
|
||||
name: string,
|
||||
ac: number,
|
||||
maxHp: number,
|
||||
color: string,
|
||||
icon: string,
|
||||
) => void;
|
||||
playerCharacter?: PlayerCharacter;
|
||||
}
|
||||
|
||||
export function CreatePlayerModal({
|
||||
open,
|
||||
onClose,
|
||||
onSave,
|
||||
playerCharacter,
|
||||
}: CreatePlayerModalProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [ac, setAc] = useState("10");
|
||||
const [maxHp, setMaxHp] = useState("10");
|
||||
const [color, setColor] = useState("blue");
|
||||
const [icon, setIcon] = useState("sword");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const isEdit = !!playerCharacter;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (playerCharacter) {
|
||||
setName(playerCharacter.name);
|
||||
setAc(String(playerCharacter.ac));
|
||||
setMaxHp(String(playerCharacter.maxHp));
|
||||
setColor(playerCharacter.color);
|
||||
setIcon(playerCharacter.icon);
|
||||
} else {
|
||||
setName("");
|
||||
setAc("10");
|
||||
setMaxHp("10");
|
||||
setColor("blue");
|
||||
setIcon("sword");
|
||||
}
|
||||
setError("");
|
||||
}
|
||||
}, [open, playerCharacter]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (trimmed === "") {
|
||||
setError("Name is required");
|
||||
return;
|
||||
}
|
||||
const acNum = Number.parseInt(ac, 10);
|
||||
if (Number.isNaN(acNum) || acNum < 0) {
|
||||
setError("AC must be a non-negative number");
|
||||
return;
|
||||
}
|
||||
const hpNum = Number.parseInt(maxHp, 10);
|
||||
if (Number.isNaN(hpNum) || hpNum < 1) {
|
||||
setError("Max HP must be at least 1");
|
||||
return;
|
||||
}
|
||||
onSave(trimmed, acNum, hpNum, color, icon);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onMouseDown={onClose}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
|
||||
<div
|
||||
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{isEdit ? "Edit Player" : "Create Player"}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div>
|
||||
<span className="mb-1 block text-sm text-muted-foreground">
|
||||
Name
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
placeholder="Character name"
|
||||
aria-label="Name"
|
||||
autoFocus
|
||||
/>
|
||||
{error && <p className="mt-1 text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<span className="mb-1 block text-sm text-muted-foreground">
|
||||
AC
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={ac}
|
||||
onChange={(e) => setAc(e.target.value)}
|
||||
placeholder="AC"
|
||||
aria-label="AC"
|
||||
className="text-center"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className="mb-1 block text-sm text-muted-foreground">
|
||||
Max HP
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={maxHp}
|
||||
onChange={(e) => setMaxHp(e.target.value)}
|
||||
placeholder="Max HP"
|
||||
aria-label="Max HP"
|
||||
className="text-center"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="mb-2 block text-sm text-muted-foreground">
|
||||
Color
|
||||
</span>
|
||||
<ColorPalette value={color} onChange={setColor} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="mb-2 block text-sm text-muted-foreground">
|
||||
Icon
|
||||
</span>
|
||||
<IconGrid value={icon} onChange={setIcon} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
apps/web/src/components/icon-grid.tsx
Normal file
38
apps/web/src/components/icon-grid.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { PlayerIcon } from "@initiative/domain";
|
||||
import { VALID_PLAYER_ICONS } from "@initiative/domain";
|
||||
import { cn } from "../lib/utils";
|
||||
import { PLAYER_ICON_MAP } from "./player-icon-map";
|
||||
|
||||
interface IconGridProps {
|
||||
value: string;
|
||||
onChange: (icon: string) => void;
|
||||
}
|
||||
|
||||
const ICONS = [...VALID_PLAYER_ICONS] as PlayerIcon[];
|
||||
|
||||
export function IconGrid({ value, onChange }: IconGridProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ICONS.map((iconId) => {
|
||||
const Icon = PLAYER_ICON_MAP[iconId];
|
||||
return (
|
||||
<button
|
||||
key={iconId}
|
||||
type="button"
|
||||
onClick={() => onChange(iconId)}
|
||||
className={cn(
|
||||
"flex h-9 w-9 items-center justify-center rounded-md transition-all",
|
||||
value === iconId
|
||||
? "bg-primary/20 ring-2 ring-primary text-foreground"
|
||||
: "text-muted-foreground hover:bg-card hover:text-foreground",
|
||||
)}
|
||||
aria-label={iconId}
|
||||
title={iconId}
|
||||
>
|
||||
<Icon size={20} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
apps/web/src/components/player-icon-map.ts
Normal file
50
apps/web/src/components/player-icon-map.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { PlayerColor, PlayerIcon } from "@initiative/domain";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Axe,
|
||||
Crosshair,
|
||||
Crown,
|
||||
Eye,
|
||||
Feather,
|
||||
Flame,
|
||||
Heart,
|
||||
Moon,
|
||||
Shield,
|
||||
Skull,
|
||||
Star,
|
||||
Sun,
|
||||
Sword,
|
||||
Wand,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
|
||||
export const PLAYER_ICON_MAP: Record<PlayerIcon, LucideIcon> = {
|
||||
sword: Sword,
|
||||
shield: Shield,
|
||||
skull: Skull,
|
||||
heart: Heart,
|
||||
wand: Wand,
|
||||
flame: Flame,
|
||||
crown: Crown,
|
||||
star: Star,
|
||||
moon: Moon,
|
||||
sun: Sun,
|
||||
axe: Axe,
|
||||
crosshair: Crosshair,
|
||||
eye: Eye,
|
||||
feather: Feather,
|
||||
zap: Zap,
|
||||
};
|
||||
|
||||
export const PLAYER_COLOR_HEX: Record<PlayerColor, string> = {
|
||||
red: "#ef4444",
|
||||
blue: "#3b82f6",
|
||||
green: "#22c55e",
|
||||
purple: "#a855f7",
|
||||
orange: "#f97316",
|
||||
pink: "#ec4899",
|
||||
cyan: "#06b6d4",
|
||||
yellow: "#eab308",
|
||||
emerald: "#10b981",
|
||||
indigo: "#6366f1",
|
||||
};
|
||||
113
apps/web/src/components/player-management.tsx
Normal file
113
apps/web/src/components/player-management.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import type {
|
||||
PlayerCharacter,
|
||||
PlayerCharacterId,
|
||||
PlayerIcon,
|
||||
} from "@initiative/domain";
|
||||
import { Pencil, Plus, X } from "lucide-react";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||
import { Button } from "./ui/button";
|
||||
import { ConfirmButton } from "./ui/confirm-button";
|
||||
|
||||
interface PlayerManagementProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
characters: readonly PlayerCharacter[];
|
||||
onEdit: (pc: PlayerCharacter) => void;
|
||||
onDelete: (id: PlayerCharacterId) => void;
|
||||
onCreate: () => void;
|
||||
}
|
||||
|
||||
export function PlayerManagement({
|
||||
open,
|
||||
onClose,
|
||||
characters,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onCreate,
|
||||
}: PlayerManagementProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onMouseDown={onClose}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
|
||||
<div
|
||||
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Player Characters
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{characters.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||
<p className="text-muted-foreground">No player characters yet</p>
|
||||
<Button onClick={onCreate} size="sm">
|
||||
<Plus size={16} />
|
||||
Create your first player character
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{characters.map((pc) => {
|
||||
const Icon = PLAYER_ICON_MAP[pc.icon as PlayerIcon];
|
||||
const color =
|
||||
PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX];
|
||||
return (
|
||||
<div
|
||||
key={pc.id}
|
||||
className="group flex items-center gap-3 rounded-md px-3 py-2 hover:bg-background/50"
|
||||
>
|
||||
{Icon && (
|
||||
<Icon size={18} style={{ color }} className="shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 truncate text-sm text-foreground">
|
||||
{pc.name}
|
||||
</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
AC {pc.ac}
|
||||
</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
HP {pc.maxHp}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(pc)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<ConfirmButton
|
||||
icon={<X size={14} />}
|
||||
label="Delete player character"
|
||||
onConfirm={() => onDelete(pc.id)}
|
||||
className="h-6 w-6 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button onClick={onCreate} size="sm" variant="ghost">
|
||||
<Plus size={16} />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
apps/web/src/components/source-fetch-prompt.tsx
Normal file
131
apps/web/src/components/source-fetch-prompt.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
apps/web/src/components/source-manager.tsx
Normal file
81
apps/web/src/components/source-manager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,242 @@
|
||||
import type { Creature } from "@initiative/domain";
|
||||
import { X } from "lucide-react";
|
||||
import type { Creature, CreatureId } from "@initiative/domain";
|
||||
import { PanelRightClose, Pin, PinOff } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
|
||||
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
||||
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
||||
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
||||
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
||||
import { StatBlock } from "./stat-block.js";
|
||||
|
||||
interface StatBlockPanelProps {
|
||||
creatureId: CreatureId | null;
|
||||
creature: Creature | null;
|
||||
onClose: () => void;
|
||||
isSourceCached: (sourceCode: string) => Promise<boolean>;
|
||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
|
||||
uploadAndCacheSource: (
|
||||
sourceCode: string,
|
||||
jsonData: unknown,
|
||||
) => Promise<void>;
|
||||
refreshCache: () => Promise<void>;
|
||||
panelRole: "browse" | "pinned";
|
||||
isFolded: boolean;
|
||||
onToggleFold: () => void;
|
||||
onPin: () => void;
|
||||
onUnpin: () => void;
|
||||
showPinButton: boolean;
|
||||
side: "left" | "right";
|
||||
onDismiss: () => void;
|
||||
bulkImportMode?: boolean;
|
||||
bulkImportState?: BulkImportState;
|
||||
onStartBulkImport?: (baseUrl: string) => void;
|
||||
onBulkImportDone?: () => void;
|
||||
}
|
||||
|
||||
export function StatBlockPanel({ creature, onClose }: StatBlockPanelProps) {
|
||||
function extractSourceCode(cId: CreatureId): string {
|
||||
const colonIndex = cId.indexOf(":");
|
||||
if (colonIndex === -1) return "";
|
||||
return cId.slice(0, colonIndex).toUpperCase();
|
||||
}
|
||||
|
||||
function FoldedTab({
|
||||
creatureName,
|
||||
side,
|
||||
onToggleFold,
|
||||
}: {
|
||||
creatureName: string;
|
||||
side: "left" | "right";
|
||||
onToggleFold: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleFold}
|
||||
className={`flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral ${
|
||||
side === "right" ? "self-start" : "self-end"
|
||||
}`}
|
||||
aria-label="Unfold stat block panel"
|
||||
>
|
||||
<span className="writing-vertical-rl text-sm font-medium">
|
||||
{creatureName}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelHeader({
|
||||
panelRole,
|
||||
showPinButton,
|
||||
onToggleFold,
|
||||
onPin,
|
||||
onUnpin,
|
||||
}: {
|
||||
panelRole: "browse" | "pinned";
|
||||
showPinButton: boolean;
|
||||
onToggleFold: () => void;
|
||||
onPin: () => void;
|
||||
onUnpin: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{panelRole === "browse" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleFold}
|
||||
className="text-muted-foreground hover:text-hover-neutral"
|
||||
aria-label="Fold stat block panel"
|
||||
>
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{panelRole === "browse" && showPinButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPin}
|
||||
className="text-muted-foreground hover:text-hover-neutral"
|
||||
aria-label="Pin creature"
|
||||
>
|
||||
<Pin className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{panelRole === "pinned" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUnpin}
|
||||
className="text-muted-foreground hover:text-hover-neutral"
|
||||
aria-label="Unpin creature"
|
||||
>
|
||||
<PinOff className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopPanel({
|
||||
isFolded,
|
||||
side,
|
||||
creatureName,
|
||||
panelRole,
|
||||
showPinButton,
|
||||
onToggleFold,
|
||||
onPin,
|
||||
onUnpin,
|
||||
children,
|
||||
}: {
|
||||
isFolded: boolean;
|
||||
side: "left" | "right";
|
||||
creatureName: string;
|
||||
panelRole: "browse" | "pinned";
|
||||
showPinButton: boolean;
|
||||
onToggleFold: () => void;
|
||||
onPin: () => void;
|
||||
onUnpin: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const sideClasses = side === "left" ? "left-0 border-r" : "right-0 border-l";
|
||||
const foldedTranslate =
|
||||
side === "right"
|
||||
? "translate-x-[calc(100%-40px)]"
|
||||
: "translate-x-[calc(-100%+40px)]";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel ${sideClasses} ${isFolded ? foldedTranslate : "translate-x-0"}`}
|
||||
>
|
||||
{isFolded ? (
|
||||
<FoldedTab
|
||||
creatureName={creatureName}
|
||||
side={side}
|
||||
onToggleFold={onToggleFold}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<PanelHeader
|
||||
panelRole={panelRole}
|
||||
showPinButton={showPinButton}
|
||||
onToggleFold={onToggleFold}
|
||||
onPin={onPin}
|
||||
onUnpin={onUnpin}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto p-4">{children}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileDrawer({
|
||||
onDismiss,
|
||||
children,
|
||||
}: {
|
||||
onDismiss: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { offsetX, isSwiping, handlers } = useSwipeToDismiss(onDismiss);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 bg-black/50 animate-in fade-in"
|
||||
onClick={onDismiss}
|
||||
aria-label="Close stat block"
|
||||
/>
|
||||
<div
|
||||
className={`absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-l border-border bg-card shadow-xl ${isSwiping ? "" : "animate-slide-in-right"}`}
|
||||
style={
|
||||
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
|
||||
}
|
||||
{...handlers}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="text-muted-foreground hover:text-hover-neutral"
|
||||
aria-label="Fold stat block panel"
|
||||
>
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatBlockPanel({
|
||||
creatureId,
|
||||
creature,
|
||||
isSourceCached,
|
||||
fetchAndCacheSource,
|
||||
uploadAndCacheSource,
|
||||
refreshCache,
|
||||
panelRole,
|
||||
isFolded,
|
||||
onToggleFold,
|
||||
onPin,
|
||||
onUnpin,
|
||||
showPinButton,
|
||||
side,
|
||||
onDismiss,
|
||||
bulkImportMode,
|
||||
bulkImportState,
|
||||
onStartBulkImport,
|
||||
onBulkImportDone,
|
||||
}: StatBlockPanelProps) {
|
||||
const [isDesktop, setIsDesktop] = useState(
|
||||
() => window.matchMedia("(min-width: 1024px)").matches,
|
||||
);
|
||||
const [needsFetch, setNeedsFetch] = useState(false);
|
||||
const [checkingCache, setCheckingCache] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(min-width: 1024px)");
|
||||
@@ -20,58 +245,100 @@ export function StatBlockPanel({ creature, onClose }: StatBlockPanelProps) {
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
if (!creature) return null;
|
||||
useEffect(() => {
|
||||
if (!creatureId || creature) {
|
||||
setNeedsFetch(false);
|
||||
return;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="fixed top-0 right-0 bottom-0 flex w-[400px] flex-col border-l border-border bg-card">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
||||
<span className="text-sm font-semibold text-muted-foreground">
|
||||
Stat Block
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-hover-neutral"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<StatBlock creature={creature} />
|
||||
</div>
|
||||
</div>
|
||||
<DesktopPanel
|
||||
isFolded={isFolded}
|
||||
side={side}
|
||||
creatureName={creatureName}
|
||||
panelRole={panelRole}
|
||||
showPinButton={showPinButton}
|
||||
onToggleFold={onToggleFold}
|
||||
onPin={onPin}
|
||||
onUnpin={onUnpin}
|
||||
>
|
||||
{renderContent()}
|
||||
</DesktopPanel>
|
||||
);
|
||||
}
|
||||
|
||||
// Mobile drawer
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
{/* Backdrop */}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 bg-black/50 animate-in fade-in"
|
||||
onClick={onClose}
|
||||
aria-label="Close stat block"
|
||||
/>
|
||||
{/* Drawer */}
|
||||
<div className="absolute top-0 right-0 bottom-0 w-[85%] max-w-md animate-slide-in-right border-l border-border bg-card shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
||||
<span className="text-sm font-semibold text-muted-foreground">
|
||||
Stat Block
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-hover-neutral"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
|
||||
<StatBlock creature={creature} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (panelRole === "pinned") return null;
|
||||
|
||||
return <MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>;
|
||||
}
|
||||
|
||||
47
apps/web/src/components/toast.tsx
Normal file
47
apps/web/src/components/toast.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Encounter } from "@initiative/domain";
|
||||
import { StepBack, StepForward, Trash2 } from "lucide-react";
|
||||
import { Library, StepBack, StepForward, Trash2 } from "lucide-react";
|
||||
import { D20Icon } from "./d20-icon";
|
||||
import { Button } from "./ui/button";
|
||||
import { ConfirmButton } from "./ui/confirm-button";
|
||||
|
||||
interface TurnNavigationProps {
|
||||
encounter: Encounter;
|
||||
@@ -9,6 +10,7 @@ interface TurnNavigationProps {
|
||||
onRetreatTurn: () => void;
|
||||
onClearEncounter: () => void;
|
||||
onRollAllInitiative: () => void;
|
||||
onOpenSourceManager: () => void;
|
||||
}
|
||||
|
||||
export function TurnNavigation({
|
||||
@@ -17,17 +19,16 @@ export function TurnNavigation({
|
||||
onRetreatTurn,
|
||||
onClearEncounter,
|
||||
onRollAllInitiative,
|
||||
onOpenSourceManager,
|
||||
}: TurnNavigationProps) {
|
||||
const hasCombatants = encounter.combatants.length > 0;
|
||||
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
||||
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-md border border-border bg-card px-4 py-3">
|
||||
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-foreground border-foreground hover:text-hover-action hover:border-hover-action hover:bg-transparent"
|
||||
onClick={onRetreatTurn}
|
||||
disabled={!hasCombatants || isAtStart}
|
||||
title="Previous turn"
|
||||
@@ -36,26 +37,23 @@ export function TurnNavigation({
|
||||
<StepBack className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm">
|
||||
<div className="min-w-0 flex-1 flex items-center justify-center gap-2 text-sm">
|
||||
<span className="rounded-full bg-muted text-foreground text-sm px-2 py-0.5 font-semibold shrink-0">
|
||||
R{encounter.roundNumber}
|
||||
</span>
|
||||
{activeCombatant ? (
|
||||
<>
|
||||
<span className="font-medium">Round {encounter.roundNumber}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
— {activeCombatant.name}
|
||||
</span>
|
||||
</>
|
||||
<span className="truncate font-medium">{activeCombatant.name}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">No combatants</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-shrink-0 items-center gap-3">
|
||||
<div className="flex items-center gap-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-hover-action"
|
||||
className="text-muted-foreground hover:text-hover-action"
|
||||
onClick={onRollAllInitiative}
|
||||
title="Roll all initiative"
|
||||
aria-label="Roll all initiative"
|
||||
@@ -65,17 +63,23 @@ export function TurnNavigation({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-hover-destructive"
|
||||
onClick={onClearEncounter}
|
||||
disabled={!hasCombatants}
|
||||
className="text-muted-foreground hover:text-hover-neutral"
|
||||
onClick={onOpenSourceManager}
|
||||
title="Manage cached sources"
|
||||
aria-label="Manage cached sources"
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
<Library className="h-5 w-5" />
|
||||
</Button>
|
||||
<ConfirmButton
|
||||
icon={<Trash2 className="h-5 w-5" />}
|
||||
label="Clear encounter"
|
||||
onConfirm={onClearEncounter}
|
||||
disabled={!hasCombatants}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-foreground border-foreground hover:text-hover-action hover:border-hover-action hover:bg-transparent"
|
||||
onClick={onAdvanceTurn}
|
||||
disabled={!hasCombatants}
|
||||
title="Next turn"
|
||||
|
||||
114
apps/web/src/components/ui/confirm-button.tsx
Normal file
114
apps/web/src/components/ui/confirm-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +1,126 @@
|
||||
import type { Creature, CreatureId } from "@initiative/domain";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { normalizeBestiary } from "../adapters/bestiary-adapter.js";
|
||||
import type {
|
||||
BestiaryIndexEntry,
|
||||
Creature,
|
||||
CreatureId,
|
||||
} from "@initiative/domain";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
normalizeBestiary,
|
||||
setSourceDisplayNames,
|
||||
} from "../adapters/bestiary-adapter.js";
|
||||
import * as bestiaryCache from "../adapters/bestiary-cache.js";
|
||||
import {
|
||||
getSourceDisplayName,
|
||||
loadBestiaryIndex,
|
||||
} from "../adapters/bestiary-index-adapter.js";
|
||||
|
||||
export interface SearchResult extends BestiaryIndexEntry {
|
||||
readonly sourceDisplayName: string;
|
||||
}
|
||||
|
||||
interface BestiaryHook {
|
||||
search: (query: string) => Creature[];
|
||||
search: (query: string) => SearchResult[];
|
||||
getCreature: (id: CreatureId) => Creature | undefined;
|
||||
allCreatures: Creature[];
|
||||
isLoaded: boolean;
|
||||
isSourceCached: (sourceCode: string) => Promise<boolean>;
|
||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
|
||||
uploadAndCacheSource: (
|
||||
sourceCode: string,
|
||||
jsonData: unknown,
|
||||
) => Promise<void>;
|
||||
refreshCache: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useBestiary(): BestiaryHook {
|
||||
const [creatures, setCreatures] = useState<Creature[]>([]);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const creatureMapRef = useRef<Map<string, Creature>>(new Map());
|
||||
const loadAttempted = useRef(false);
|
||||
const creatureMapRef = useRef<Map<CreatureId, Creature>>(new Map());
|
||||
const [, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (loadAttempted.current) return;
|
||||
loadAttempted.current = true;
|
||||
const index = loadBestiaryIndex();
|
||||
setSourceDisplayNames(index.sources as Record<string, string>);
|
||||
if (index.creatures.length > 0) {
|
||||
setIsLoaded(true);
|
||||
}
|
||||
|
||||
import("../../../../data/bestiary/xmm.json")
|
||||
// biome-ignore lint/suspicious/noExplicitAny: raw JSON shape varies per entry
|
||||
.then((mod: any) => {
|
||||
const raw = mod.default ?? mod;
|
||||
try {
|
||||
const normalized = normalizeBestiary(raw);
|
||||
const map = new Map<string, Creature>();
|
||||
for (const c of normalized) {
|
||||
map.set(c.id, c);
|
||||
}
|
||||
creatureMapRef.current = map;
|
||||
setCreatures(normalized);
|
||||
setIsLoaded(true);
|
||||
} catch {
|
||||
// Normalization failed — bestiary unavailable
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Import failed — bestiary unavailable
|
||||
});
|
||||
bestiaryCache.loadAllCachedCreatures().then((map) => {
|
||||
creatureMapRef.current = map;
|
||||
setTick((t) => t + 1);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const search = useMemo(() => {
|
||||
return (query: string): Creature[] => {
|
||||
if (query.length < 2) return [];
|
||||
const lower = query.toLowerCase();
|
||||
return creatures
|
||||
.filter((c) => c.name.toLowerCase().includes(lower))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.slice(0, 10);
|
||||
};
|
||||
}, [creatures]);
|
||||
|
||||
const getCreature = useMemo(() => {
|
||||
return (id: CreatureId): Creature | undefined => {
|
||||
return creatureMapRef.current.get(id);
|
||||
};
|
||||
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),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return { search, getCreature, allCreatures: creatures, isLoaded };
|
||||
const getCreature = useCallback((id: CreatureId): Creature | undefined => {
|
||||
return creatureMapRef.current.get(id);
|
||||
}, []);
|
||||
|
||||
const isSourceCachedFn = useCallback(
|
||||
(sourceCode: string): Promise<boolean> => {
|
||||
return bestiaryCache.isSourceCached(sourceCode);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const fetchAndCacheSource = useCallback(
|
||||
async (sourceCode: string, url: string): Promise<void> => {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
const json = await response.json();
|
||||
const creatures = normalizeBestiary(json);
|
||||
const displayName = getSourceDisplayName(sourceCode);
|
||||
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
||||
for (const c of creatures) {
|
||||
creatureMapRef.current.set(c.id, c);
|
||||
}
|
||||
setTick((t) => t + 1);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const uploadAndCacheSource = useCallback(
|
||||
async (sourceCode: string, jsonData: unknown): Promise<void> => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: raw JSON shape varies
|
||||
const creatures = normalizeBestiary(jsonData as any);
|
||||
const displayName = getSourceDisplayName(sourceCode);
|
||||
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
||||
for (const c of creatures) {
|
||||
creatureMapRef.current.set(c.id, c);
|
||||
}
|
||||
setTick((t) => t + 1);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const refreshCache = useCallback(async (): Promise<void> => {
|
||||
const map = await bestiaryCache.loadAllCachedCreatures();
|
||||
creatureMapRef.current = map;
|
||||
setTick((t) => t + 1);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
search,
|
||||
getCreature,
|
||||
isLoaded,
|
||||
isSourceCached: isSourceCachedFn,
|
||||
fetchAndCacheSource,
|
||||
uploadAndCacheSource,
|
||||
refreshCache,
|
||||
};
|
||||
}
|
||||
|
||||
120
apps/web/src/hooks/use-bulk-import.ts
Normal file
120
apps/web/src/hooks/use-bulk-import.ts
Normal 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 };
|
||||
}
|
||||
@@ -14,16 +14,17 @@ import {
|
||||
toggleConditionUseCase,
|
||||
} from "@initiative/application";
|
||||
import type {
|
||||
BestiaryIndexEntry,
|
||||
CombatantId,
|
||||
ConditionId,
|
||||
Creature,
|
||||
DomainEvent,
|
||||
Encounter,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
import {
|
||||
combatantId,
|
||||
createEncounter,
|
||||
isDomainError,
|
||||
creatureId as makeCreatureId,
|
||||
resolveCreatureName,
|
||||
} from "@initiative/domain";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
@@ -32,24 +33,16 @@ import {
|
||||
saveEncounter,
|
||||
} from "../persistence/encounter-storage.js";
|
||||
|
||||
function createDemoEncounter(): Encounter {
|
||||
const result = createEncounter([
|
||||
{ id: combatantId("1"), name: "Aria" },
|
||||
{ id: combatantId("2"), name: "Brak" },
|
||||
{ id: combatantId("3"), name: "Cael" },
|
||||
]);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
throw new Error(`Failed to create demo encounter: ${result.message}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
const EMPTY_ENCOUNTER: Encounter = {
|
||||
combatants: [],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
};
|
||||
|
||||
function initializeEncounter(): Encounter {
|
||||
const stored = loadEncounter();
|
||||
if (stored !== null) return stored;
|
||||
return createDemoEncounter();
|
||||
return EMPTY_ENCOUNTER;
|
||||
}
|
||||
|
||||
function deriveNextId(encounter: Encounter): number {
|
||||
@@ -64,6 +57,33 @@ function deriveNextId(encounter: Encounter): number {
|
||||
return max;
|
||||
}
|
||||
|
||||
interface CombatantOpts {
|
||||
initiative?: number;
|
||||
ac?: number;
|
||||
maxHp?: number;
|
||||
}
|
||||
|
||||
function applyCombatantOpts(
|
||||
makeStore: () => EncounterStore,
|
||||
id: ReturnType<typeof combatantId>,
|
||||
opts: CombatantOpts,
|
||||
): DomainEvent[] {
|
||||
const events: DomainEvent[] = [];
|
||||
if (opts.maxHp !== undefined) {
|
||||
const r = setHpUseCase(makeStore(), id, opts.maxHp);
|
||||
if (!isDomainError(r)) events.push(...r);
|
||||
}
|
||||
if (opts.ac !== undefined) {
|
||||
const r = setAcUseCase(makeStore(), id, opts.ac);
|
||||
if (!isDomainError(r)) events.push(...r);
|
||||
}
|
||||
if (opts.initiative !== undefined) {
|
||||
const r = setInitiativeUseCase(makeStore(), id, opts.initiative);
|
||||
if (!isDomainError(r)) events.push(...r);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
export function useEncounter() {
|
||||
const [encounter, setEncounter] = useState<Encounter>(initializeEncounter);
|
||||
const [events, setEvents] = useState<DomainEvent[]>([]);
|
||||
@@ -107,7 +127,7 @@ export function useEncounter() {
|
||||
const nextId = useRef(deriveNextId(encounter));
|
||||
|
||||
const addCombatant = useCallback(
|
||||
(name: string) => {
|
||||
(name: string, opts?: CombatantOpts) => {
|
||||
const id = combatantId(`c-${++nextId.current}`);
|
||||
const result = addCombatantUseCase(makeStore(), id, name);
|
||||
|
||||
@@ -115,6 +135,13 @@ export function useEncounter() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts) {
|
||||
const optEvents = applyCombatantOpts(makeStore, id, opts);
|
||||
if (optEvents.length > 0) {
|
||||
setEvents((prev) => [...prev, ...optEvents]);
|
||||
}
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
@@ -225,10 +252,6 @@ export function useEncounter() {
|
||||
);
|
||||
|
||||
const clearEncounter = useCallback(() => {
|
||||
if (!window.confirm("Clear the entire encounter? This cannot be undone.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = clearEncounterUseCase(makeStore());
|
||||
|
||||
if (isDomainError(result)) {
|
||||
@@ -240,11 +263,11 @@ export function useEncounter() {
|
||||
}, [makeStore]);
|
||||
|
||||
const addFromBestiary = useCallback(
|
||||
(creature: Creature) => {
|
||||
(entry: BestiaryIndexEntry) => {
|
||||
const store = makeStore();
|
||||
const existingNames = store.get().combatants.map((c) => c.name);
|
||||
const { newName, renames } = resolveCreatureName(
|
||||
creature.name,
|
||||
entry.name,
|
||||
existingNames,
|
||||
);
|
||||
|
||||
@@ -262,28 +285,86 @@ export function useEncounter() {
|
||||
if (isDomainError(addResult)) return;
|
||||
|
||||
// Set HP
|
||||
const hpResult = setHpUseCase(makeStore(), id, creature.hp.average);
|
||||
const hpResult = setHpUseCase(makeStore(), id, entry.hp);
|
||||
if (!isDomainError(hpResult)) {
|
||||
setEvents((prev) => [...prev, ...hpResult]);
|
||||
}
|
||||
|
||||
// Set AC
|
||||
if (creature.ac > 0) {
|
||||
const acResult = setAcUseCase(makeStore(), id, creature.ac);
|
||||
if (entry.ac > 0) {
|
||||
const acResult = setAcUseCase(makeStore(), id, entry.ac);
|
||||
if (!isDomainError(acResult)) {
|
||||
setEvents((prev) => [...prev, ...acResult]);
|
||||
}
|
||||
}
|
||||
|
||||
// Set creatureId on the combatant
|
||||
// Derive creatureId from source + name
|
||||
const slug = entry.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
|
||||
|
||||
// Set creatureId on the combatant (use store.save to keep ref in sync for batch calls)
|
||||
const currentEncounter = store.get();
|
||||
const updated = {
|
||||
store.save({
|
||||
...currentEncounter,
|
||||
combatants: currentEncounter.combatants.map((c) =>
|
||||
c.id === id ? { ...c, creatureId: creature.id } : c,
|
||||
c.id === id ? { ...c, creatureId: cId } : c,
|
||||
),
|
||||
};
|
||||
setEncounter(updated);
|
||||
});
|
||||
|
||||
setEvents((prev) => [...prev, ...addResult]);
|
||||
},
|
||||
[makeStore, editCombatant],
|
||||
);
|
||||
|
||||
const addFromPlayerCharacter = useCallback(
|
||||
(pc: PlayerCharacter) => {
|
||||
const store = makeStore();
|
||||
const existingNames = store.get().combatants.map((c) => c.name);
|
||||
const { newName, renames } = resolveCreatureName(pc.name, existingNames);
|
||||
|
||||
for (const { from, to } of renames) {
|
||||
const target = store.get().combatants.find((c) => c.name === from);
|
||||
if (target) {
|
||||
editCombatantUseCase(makeStore(), target.id, to);
|
||||
}
|
||||
}
|
||||
|
||||
const id = combatantId(`c-${++nextId.current}`);
|
||||
const addResult = addCombatantUseCase(makeStore(), id, newName);
|
||||
if (isDomainError(addResult)) return;
|
||||
|
||||
// Set HP
|
||||
const hpResult = setHpUseCase(makeStore(), id, pc.maxHp);
|
||||
if (!isDomainError(hpResult)) {
|
||||
setEvents((prev) => [...prev, ...hpResult]);
|
||||
}
|
||||
|
||||
// Set AC
|
||||
if (pc.ac > 0) {
|
||||
const acResult = setAcUseCase(makeStore(), id, pc.ac);
|
||||
if (!isDomainError(acResult)) {
|
||||
setEvents((prev) => [...prev, ...acResult]);
|
||||
}
|
||||
}
|
||||
|
||||
// Set color, icon, and playerCharacterId on the combatant
|
||||
const currentEncounter = store.get();
|
||||
store.save({
|
||||
...currentEncounter,
|
||||
combatants: currentEncounter.combatants.map((c) =>
|
||||
c.id === id
|
||||
? {
|
||||
...c,
|
||||
color: pc.color,
|
||||
icon: pc.icon,
|
||||
playerCharacterId: pc.id,
|
||||
}
|
||||
: c,
|
||||
),
|
||||
});
|
||||
|
||||
setEvents((prev) => [...prev, ...addResult]);
|
||||
},
|
||||
@@ -306,6 +387,7 @@ export function useEncounter() {
|
||||
toggleCondition,
|
||||
toggleConcentration,
|
||||
addFromBestiary,
|
||||
addFromPlayerCharacter,
|
||||
makeStore,
|
||||
} as const;
|
||||
}
|
||||
|
||||
102
apps/web/src/hooks/use-player-characters.ts
Normal file
102
apps/web/src/hooks/use-player-characters.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { PlayerCharacterStore } from "@initiative/application";
|
||||
import {
|
||||
createPlayerCharacterUseCase,
|
||||
deletePlayerCharacterUseCase,
|
||||
editPlayerCharacterUseCase,
|
||||
} from "@initiative/application";
|
||||
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||
import { isDomainError, playerCharacterId } from "@initiative/domain";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
loadPlayerCharacters,
|
||||
savePlayerCharacters,
|
||||
} from "../persistence/player-character-storage.js";
|
||||
|
||||
function initializeCharacters(): PlayerCharacter[] {
|
||||
return loadPlayerCharacters();
|
||||
}
|
||||
|
||||
let nextPcId = 0;
|
||||
|
||||
function generatePcId(): PlayerCharacterId {
|
||||
return playerCharacterId(`pc-${++nextPcId}`);
|
||||
}
|
||||
|
||||
interface EditFields {
|
||||
readonly name?: string;
|
||||
readonly ac?: number;
|
||||
readonly maxHp?: number;
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
}
|
||||
|
||||
export function usePlayerCharacters() {
|
||||
const [characters, setCharacters] =
|
||||
useState<PlayerCharacter[]>(initializeCharacters);
|
||||
const charactersRef = useRef(characters);
|
||||
charactersRef.current = characters;
|
||||
|
||||
useEffect(() => {
|
||||
savePlayerCharacters(characters);
|
||||
}, [characters]);
|
||||
|
||||
const makeStore = useCallback((): PlayerCharacterStore => {
|
||||
return {
|
||||
getAll: () => charactersRef.current,
|
||||
save: (updated) => {
|
||||
charactersRef.current = updated;
|
||||
setCharacters(updated);
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
const createCharacter = useCallback(
|
||||
(name: string, ac: number, maxHp: number, color: string, icon: string) => {
|
||||
const id = generatePcId();
|
||||
const result = createPlayerCharacterUseCase(
|
||||
makeStore(),
|
||||
id,
|
||||
name,
|
||||
ac,
|
||||
maxHp,
|
||||
color,
|
||||
icon,
|
||||
);
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[makeStore],
|
||||
);
|
||||
|
||||
const editCharacter = useCallback(
|
||||
(id: PlayerCharacterId, fields: EditFields) => {
|
||||
const result = editPlayerCharacterUseCase(makeStore(), id, fields);
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[makeStore],
|
||||
);
|
||||
|
||||
const deleteCharacter = useCallback(
|
||||
(id: PlayerCharacterId) => {
|
||||
const result = deletePlayerCharacterUseCase(makeStore(), id);
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[makeStore],
|
||||
);
|
||||
|
||||
return {
|
||||
characters,
|
||||
createCharacter,
|
||||
editCharacter,
|
||||
deleteCharacter,
|
||||
makeStore,
|
||||
} as const;
|
||||
}
|
||||
72
apps/web/src/hooks/use-swipe-to-dismiss.ts
Normal file
72
apps/web/src/hooks/use-swipe-to-dismiss.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
@@ -68,6 +68,48 @@
|
||||
animation: slide-in-right 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes confirm-pulse {
|
||||
0% {
|
||||
scale: 1;
|
||||
}
|
||||
50% {
|
||||
scale: 1.15;
|
||||
}
|
||||
100% {
|
||||
scale: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
scale: 0.9;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
scale: 1.1;
|
||||
}
|
||||
}
|
||||
|
||||
@utility animate-breathe {
|
||||
animation: breathe 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@custom-variant pointer-coarse (@media (pointer: coarse));
|
||||
|
||||
@utility animate-confirm-pulse {
|
||||
animation: confirm-pulse 300ms ease-out;
|
||||
}
|
||||
|
||||
@utility transition-slide-panel {
|
||||
transition: translate 200ms ease-out;
|
||||
}
|
||||
|
||||
@utility writing-vertical-rl {
|
||||
writing-mode: vertical-rl;
|
||||
}
|
||||
|
||||
@utility animate-concentration-pulse {
|
||||
animation:
|
||||
concentration-shake 450ms ease-out,
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import { playerCharacterId } from "@initiative/domain";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
loadPlayerCharacters,
|
||||
savePlayerCharacters,
|
||||
} from "../player-character-storage.js";
|
||||
|
||||
const STORAGE_KEY = "initiative:player-characters";
|
||||
|
||||
function createMockLocalStorage() {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => store.set(key, value),
|
||||
removeItem: (key: string) => store.delete(key),
|
||||
clear: () => store.clear(),
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
key: (_index: number) => null,
|
||||
store,
|
||||
};
|
||||
}
|
||||
|
||||
function makePC(overrides?: Partial<PlayerCharacter>): PlayerCharacter {
|
||||
return {
|
||||
id: playerCharacterId("pc-1"),
|
||||
name: "Aragorn",
|
||||
ac: 16,
|
||||
maxHp: 120,
|
||||
color: "green",
|
||||
icon: "sword",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("player-character-storage", () => {
|
||||
let mockStorage: ReturnType<typeof createMockLocalStorage>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockStorage = createMockLocalStorage();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: mockStorage,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe("round-trip save/load", () => {
|
||||
it("saves and loads a single character", () => {
|
||||
const pc = makePC();
|
||||
savePlayerCharacters([pc]);
|
||||
const loaded = loadPlayerCharacters();
|
||||
expect(loaded).toEqual([pc]);
|
||||
});
|
||||
|
||||
it("saves and loads multiple characters", () => {
|
||||
const pcs = [
|
||||
makePC({ id: playerCharacterId("pc-1"), name: "Aragorn" }),
|
||||
makePC({
|
||||
id: playerCharacterId("pc-2"),
|
||||
name: "Legolas",
|
||||
ac: 14,
|
||||
maxHp: 90,
|
||||
color: "blue",
|
||||
icon: "eye",
|
||||
}),
|
||||
];
|
||||
savePlayerCharacters(pcs);
|
||||
const loaded = loadPlayerCharacters();
|
||||
expect(loaded).toEqual(pcs);
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty storage", () => {
|
||||
it("returns empty array when no data exists", () => {
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("corrupt JSON", () => {
|
||||
it("returns empty array for invalid JSON", () => {
|
||||
mockStorage.setItem(STORAGE_KEY, "not-json{{{");
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for non-array JSON", () => {
|
||||
mockStorage.setItem(STORAGE_KEY, '{"foo": "bar"}');
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("per-character validation", () => {
|
||||
it("discards character with missing name", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{ id: "pc-1", ac: 10, maxHp: 50, color: "blue", icon: "sword" },
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards character with empty name", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "",
|
||||
ac: 10,
|
||||
maxHp: 50,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards character with invalid color", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Test",
|
||||
ac: 10,
|
||||
maxHp: 50,
|
||||
color: "neon",
|
||||
icon: "sword",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards character with invalid icon", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Test",
|
||||
ac: 10,
|
||||
maxHp: 50,
|
||||
color: "blue",
|
||||
icon: "banana",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards character with negative AC", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Test",
|
||||
ac: -1,
|
||||
maxHp: 50,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards character with maxHp of 0", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Test",
|
||||
ac: 10,
|
||||
maxHp: 0,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps valid characters and discards invalid ones", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Valid",
|
||||
ac: 10,
|
||||
maxHp: 50,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
{
|
||||
id: "pc-2",
|
||||
name: "",
|
||||
ac: 10,
|
||||
maxHp: 50,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const loaded = loadPlayerCharacters();
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0].name).toBe("Valid");
|
||||
});
|
||||
});
|
||||
|
||||
describe("storage errors", () => {
|
||||
it("save silently catches errors", () => {
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: {
|
||||
setItem: () => {
|
||||
throw new Error("QuotaExceeded");
|
||||
},
|
||||
getItem: () => null,
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
expect(() => savePlayerCharacters([makePC()])).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
creatureId,
|
||||
type Encounter,
|
||||
isDomainError,
|
||||
playerCharacterId,
|
||||
VALID_CONDITION_IDS,
|
||||
VALID_PLAYER_COLORS,
|
||||
VALID_PLAYER_ICONS,
|
||||
} from "@initiative/domain";
|
||||
|
||||
const STORAGE_KEY = "initiative:encounter";
|
||||
@@ -18,6 +21,93 @@ export function saveEncounter(encounter: Encounter): void {
|
||||
}
|
||||
}
|
||||
|
||||
function validateAc(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isInteger(value) && value >= 0
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function validateConditions(value: unknown): ConditionId[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const valid = value.filter(
|
||||
(v): v is ConditionId =>
|
||||
typeof v === "string" && VALID_CONDITION_IDS.has(v),
|
||||
);
|
||||
return valid.length > 0 ? valid : undefined;
|
||||
}
|
||||
|
||||
function validateCreatureId(value: unknown) {
|
||||
return typeof value === "string" && value.length > 0
|
||||
? creatureId(value)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function validateHp(
|
||||
rawMaxHp: unknown,
|
||||
rawCurrentHp: unknown,
|
||||
): { maxHp: number; currentHp: number } | undefined {
|
||||
if (
|
||||
typeof rawMaxHp !== "number" ||
|
||||
!Number.isInteger(rawMaxHp) ||
|
||||
rawMaxHp < 1
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const validCurrentHp =
|
||||
typeof rawCurrentHp === "number" &&
|
||||
Number.isInteger(rawCurrentHp) &&
|
||||
rawCurrentHp >= 0 &&
|
||||
rawCurrentHp <= rawMaxHp;
|
||||
return {
|
||||
maxHp: rawMaxHp,
|
||||
currentHp: validCurrentHp ? rawCurrentHp : rawMaxHp,
|
||||
};
|
||||
}
|
||||
|
||||
function rehydrateCombatant(c: unknown) {
|
||||
const entry = c as Record<string, unknown>;
|
||||
const base = {
|
||||
id: combatantId(entry.id as string),
|
||||
name: entry.name as string,
|
||||
initiative:
|
||||
typeof entry.initiative === "number" ? entry.initiative : undefined,
|
||||
};
|
||||
|
||||
const color =
|
||||
typeof entry.color === "string" && VALID_PLAYER_COLORS.has(entry.color)
|
||||
? entry.color
|
||||
: undefined;
|
||||
const icon =
|
||||
typeof entry.icon === "string" && VALID_PLAYER_ICONS.has(entry.icon)
|
||||
? entry.icon
|
||||
: undefined;
|
||||
const pcId =
|
||||
typeof entry.playerCharacterId === "string" &&
|
||||
entry.playerCharacterId.length > 0
|
||||
? playerCharacterId(entry.playerCharacterId)
|
||||
: undefined;
|
||||
|
||||
const shared = {
|
||||
...base,
|
||||
ac: validateAc(entry.ac),
|
||||
conditions: validateConditions(entry.conditions),
|
||||
isConcentrating: entry.isConcentrating === true ? true : undefined,
|
||||
creatureId: validateCreatureId(entry.creatureId),
|
||||
color,
|
||||
icon,
|
||||
playerCharacterId: pcId,
|
||||
};
|
||||
|
||||
const hp = validateHp(entry.maxHp, entry.currentHp);
|
||||
return hp ? { ...shared, ...hp } : shared;
|
||||
}
|
||||
|
||||
function isValidCombatantEntry(c: unknown): boolean {
|
||||
if (typeof c !== "object" || c === null || Array.isArray(c)) return false;
|
||||
const entry = c as Record<string, unknown>;
|
||||
return typeof entry.id === "string" && typeof entry.name === "string";
|
||||
}
|
||||
|
||||
export function loadEncounter(): Encounter | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
@@ -45,82 +135,9 @@ export function loadEncounter(): Encounter | null {
|
||||
};
|
||||
}
|
||||
|
||||
for (const c of combatants) {
|
||||
if (typeof c !== "object" || c === null || Array.isArray(c)) return null;
|
||||
const entry = c as Record<string, unknown>;
|
||||
if (typeof entry.id !== "string") return null;
|
||||
if (typeof entry.name !== "string") return null;
|
||||
}
|
||||
if (!combatants.every(isValidCombatantEntry)) return null;
|
||||
|
||||
const rehydrated = combatants.map((c) => {
|
||||
const entry = c as Record<string, unknown>;
|
||||
const base = {
|
||||
id: combatantId(entry.id as string),
|
||||
name: entry.name as string,
|
||||
initiative:
|
||||
typeof entry.initiative === "number" ? entry.initiative : undefined,
|
||||
};
|
||||
|
||||
// Validate AC field
|
||||
const ac = entry.ac;
|
||||
const validAc =
|
||||
typeof ac === "number" && Number.isInteger(ac) && ac >= 0
|
||||
? ac
|
||||
: undefined;
|
||||
|
||||
// Validate conditions field
|
||||
const rawConditions = entry.conditions;
|
||||
const validConditions: ConditionId[] | undefined = Array.isArray(
|
||||
rawConditions,
|
||||
)
|
||||
? (rawConditions.filter(
|
||||
(v): v is ConditionId =>
|
||||
typeof v === "string" && VALID_CONDITION_IDS.has(v),
|
||||
) as ConditionId[])
|
||||
: undefined;
|
||||
const conditions =
|
||||
validConditions && validConditions.length > 0
|
||||
? validConditions
|
||||
: undefined;
|
||||
|
||||
// Validate isConcentrating field
|
||||
const isConcentrating = entry.isConcentrating === true ? true : undefined;
|
||||
|
||||
// Validate creatureId field
|
||||
const rawCreatureId = entry.creatureId;
|
||||
const validCreatureId =
|
||||
typeof rawCreatureId === "string" && rawCreatureId.length > 0
|
||||
? creatureId(rawCreatureId)
|
||||
: undefined;
|
||||
|
||||
// Validate and attach HP fields if valid
|
||||
const maxHp = entry.maxHp;
|
||||
const currentHp = entry.currentHp;
|
||||
if (typeof maxHp === "number" && Number.isInteger(maxHp) && maxHp >= 1) {
|
||||
const validCurrentHp =
|
||||
typeof currentHp === "number" &&
|
||||
Number.isInteger(currentHp) &&
|
||||
currentHp >= 0 &&
|
||||
currentHp <= maxHp;
|
||||
return {
|
||||
...base,
|
||||
ac: validAc,
|
||||
conditions,
|
||||
isConcentrating,
|
||||
creatureId: validCreatureId,
|
||||
maxHp,
|
||||
currentHp: validCurrentHp ? currentHp : maxHp,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
ac: validAc,
|
||||
conditions,
|
||||
isConcentrating,
|
||||
creatureId: validCreatureId,
|
||||
};
|
||||
});
|
||||
const rehydrated = combatants.map(rehydrateCombatant);
|
||||
|
||||
const result = createEncounter(
|
||||
rehydrated,
|
||||
|
||||
72
apps/web/src/persistence/player-character-storage.ts
Normal file
72
apps/web/src/persistence/player-character-storage.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import {
|
||||
playerCharacterId,
|
||||
VALID_PLAYER_COLORS,
|
||||
VALID_PLAYER_ICONS,
|
||||
} from "@initiative/domain";
|
||||
|
||||
const STORAGE_KEY = "initiative:player-characters";
|
||||
|
||||
export function savePlayerCharacters(characters: PlayerCharacter[]): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(characters));
|
||||
} catch {
|
||||
// Silently swallow errors (quota exceeded, storage unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
||||
return null;
|
||||
const entry = raw as Record<string, unknown>;
|
||||
|
||||
if (typeof entry.id !== "string" || entry.id.length === 0) return null;
|
||||
if (typeof entry.name !== "string" || entry.name.trim().length === 0)
|
||||
return null;
|
||||
if (
|
||||
typeof entry.ac !== "number" ||
|
||||
!Number.isInteger(entry.ac) ||
|
||||
entry.ac < 0
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
typeof entry.maxHp !== "number" ||
|
||||
!Number.isInteger(entry.maxHp) ||
|
||||
entry.maxHp < 1
|
||||
)
|
||||
return null;
|
||||
if (typeof entry.color !== "string" || !VALID_PLAYER_COLORS.has(entry.color))
|
||||
return null;
|
||||
if (typeof entry.icon !== "string" || !VALID_PLAYER_ICONS.has(entry.icon))
|
||||
return null;
|
||||
|
||||
return {
|
||||
id: playerCharacterId(entry.id),
|
||||
name: entry.name,
|
||||
ac: entry.ac,
|
||||
maxHp: entry.maxHp,
|
||||
color: entry.color as PlayerCharacter["color"],
|
||||
icon: entry.icon as PlayerCharacter["icon"],
|
||||
};
|
||||
}
|
||||
|
||||
export function loadPlayerCharacters(): PlayerCharacter[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw === null) return [];
|
||||
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
|
||||
const characters: PlayerCharacter[] = [];
|
||||
for (const item of parsed) {
|
||||
const pc = rehydrateCharacter(item);
|
||||
if (pc !== null) {
|
||||
characters.push(pc);
|
||||
}
|
||||
}
|
||||
return characters;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
14
biome.json
14
biome.json
@@ -6,7 +6,9 @@
|
||||
"!**/dist/**",
|
||||
"!.claude/**",
|
||||
"!.specify/**",
|
||||
"!specs/**"
|
||||
"!specs/**",
|
||||
"!coverage/**",
|
||||
"!.pnpm-store/**"
|
||||
]
|
||||
},
|
||||
"assist": {
|
||||
@@ -27,7 +29,15 @@
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"complexity": {
|
||||
"noExcessiveCognitiveComplexity": {
|
||||
"level": "error",
|
||||
"options": {
|
||||
"maxAllowedComplexity": 15
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
36540
data/bestiary/index.json
Normal file
36540
data/bestiary/index.json
Normal file
File diff suppressed because it is too large
Load Diff
63266
data/bestiary/xmm.json
63266
data/bestiary/xmm.json
File diff suppressed because it is too large
Load Diff
4
docs/agents/.gitkeep
Normal file
4
docs/agents/.gitkeep
Normal file
@@ -0,0 +1,4 @@
|
||||
# Agent Artifacts
|
||||
|
||||
Research reports and implementation plans generated by RPI skills.
|
||||
|
||||
0
docs/agents/plans/.gitkeep
Normal file
0
docs/agents/plans/.gitkeep
Normal file
0
docs/agents/research/.gitkeep
Normal file
0
docs/agents/research/.gitkeep
Normal file
9
nginx.conf
Normal file
9
nginx.conf
Normal file
@@ -0,0 +1,9 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
"packageManager": "pnpm@10.6.0",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.0.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"jscpd": "^4.0.8",
|
||||
"knip": "^5.85.0",
|
||||
"lefthook": "^1.11.0",
|
||||
@@ -20,6 +21,6 @@
|
||||
"test:watch": "vitest",
|
||||
"knip": "knip",
|
||||
"jscpd": "jscpd",
|
||||
"check": "knip && biome check . && tsc --build && vitest run && jscpd"
|
||||
"check": "pnpm audit --audit-level=high && knip && biome check . && tsc --build && vitest run && jscpd"
|
||||
}
|
||||
}
|
||||
|
||||
36
packages/application/src/create-player-character-use-case.ts
Normal file
36
packages/application/src/create-player-character-use-case.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
createPlayerCharacter,
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
isDomainError,
|
||||
type PlayerCharacterId,
|
||||
} from "@initiative/domain";
|
||||
import type { PlayerCharacterStore } from "./ports.js";
|
||||
|
||||
export function createPlayerCharacterUseCase(
|
||||
store: PlayerCharacterStore,
|
||||
id: PlayerCharacterId,
|
||||
name: string,
|
||||
ac: number,
|
||||
maxHp: number,
|
||||
color: string,
|
||||
icon: string,
|
||||
): DomainEvent[] | DomainError {
|
||||
const characters = store.getAll();
|
||||
const result = createPlayerCharacter(
|
||||
characters,
|
||||
id,
|
||||
name,
|
||||
ac,
|
||||
maxHp,
|
||||
color,
|
||||
icon,
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
store.save([...result.characters]);
|
||||
return result.events;
|
||||
}
|
||||
23
packages/application/src/delete-player-character-use-case.ts
Normal file
23
packages/application/src/delete-player-character-use-case.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
deletePlayerCharacter,
|
||||
isDomainError,
|
||||
type PlayerCharacterId,
|
||||
} from "@initiative/domain";
|
||||
import type { PlayerCharacterStore } from "./ports.js";
|
||||
|
||||
export function deletePlayerCharacterUseCase(
|
||||
store: PlayerCharacterStore,
|
||||
id: PlayerCharacterId,
|
||||
): DomainEvent[] | DomainError {
|
||||
const characters = store.getAll();
|
||||
const result = deletePlayerCharacter(characters, id);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
store.save([...result.characters]);
|
||||
return result.events;
|
||||
}
|
||||
32
packages/application/src/edit-player-character-use-case.ts
Normal file
32
packages/application/src/edit-player-character-use-case.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
editPlayerCharacter,
|
||||
isDomainError,
|
||||
type PlayerCharacterId,
|
||||
} from "@initiative/domain";
|
||||
import type { PlayerCharacterStore } from "./ports.js";
|
||||
|
||||
interface EditFields {
|
||||
readonly name?: string;
|
||||
readonly ac?: number;
|
||||
readonly maxHp?: number;
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
}
|
||||
|
||||
export function editPlayerCharacterUseCase(
|
||||
store: PlayerCharacterStore,
|
||||
id: PlayerCharacterId,
|
||||
fields: EditFields,
|
||||
): DomainEvent[] | DomainError {
|
||||
const characters = store.getAll();
|
||||
const result = editPlayerCharacter(characters, id, fields);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
store.save([...result.characters]);
|
||||
return result.events;
|
||||
}
|
||||
@@ -2,8 +2,15 @@ export { addCombatantUseCase } from "./add-combatant-use-case.js";
|
||||
export { adjustHpUseCase } from "./adjust-hp-use-case.js";
|
||||
export { advanceTurnUseCase } from "./advance-turn-use-case.js";
|
||||
export { clearEncounterUseCase } from "./clear-encounter-use-case.js";
|
||||
export { createPlayerCharacterUseCase } from "./create-player-character-use-case.js";
|
||||
export { deletePlayerCharacterUseCase } from "./delete-player-character-use-case.js";
|
||||
export { editCombatantUseCase } from "./edit-combatant-use-case.js";
|
||||
export type { EncounterStore } from "./ports.js";
|
||||
export { editPlayerCharacterUseCase } from "./edit-player-character-use-case.js";
|
||||
export type {
|
||||
BestiarySourceCache,
|
||||
EncounterStore,
|
||||
PlayerCharacterStore,
|
||||
} from "./ports.js";
|
||||
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
||||
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
|
||||
export { rollAllInitiativeUseCase } from "./roll-all-initiative-use-case.js";
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import type { Encounter } from "@initiative/domain";
|
||||
import type {
|
||||
Creature,
|
||||
CreatureId,
|
||||
Encounter,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
|
||||
export interface EncounterStore {
|
||||
get(): Encounter;
|
||||
save(encounter: Encounter): void;
|
||||
}
|
||||
|
||||
export interface BestiarySourceCache {
|
||||
getCreature(creatureId: CreatureId): Creature | undefined;
|
||||
isSourceCached(sourceCode: string): boolean;
|
||||
}
|
||||
|
||||
export interface PlayerCharacterStore {
|
||||
getAll(): PlayerCharacter[];
|
||||
save(characters: PlayerCharacter[]): void;
|
||||
}
|
||||
|
||||
@@ -169,9 +169,9 @@ describe("advanceTurn", () => {
|
||||
});
|
||||
|
||||
describe("invariants", () => {
|
||||
it("INV-1: createEncounter rejects empty combatant list", () => {
|
||||
it("INV-1: createEncounter accepts empty combatant list", () => {
|
||||
const result = createEncounter([]);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
expect(isDomainError(result)).toBe(false);
|
||||
});
|
||||
|
||||
it("INV-2: activeIndex always in bounds across all scenarios", () => {
|
||||
|
||||
227
packages/domain/src/__tests__/create-player-character.test.ts
Normal file
227
packages/domain/src/__tests__/create-player-character.test.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createPlayerCharacter } from "../create-player-character.js";
|
||||
import type { PlayerCharacter } from "../player-character-types.js";
|
||||
import { playerCharacterId } from "../player-character-types.js";
|
||||
import { isDomainError } from "../types.js";
|
||||
|
||||
const id = playerCharacterId("pc-1");
|
||||
|
||||
function success(
|
||||
characters: readonly PlayerCharacter[],
|
||||
name: string,
|
||||
ac: number,
|
||||
maxHp: number,
|
||||
color = "blue",
|
||||
icon = "sword",
|
||||
) {
|
||||
const result = createPlayerCharacter(
|
||||
characters,
|
||||
id,
|
||||
name,
|
||||
ac,
|
||||
maxHp,
|
||||
color,
|
||||
icon,
|
||||
);
|
||||
if (isDomainError(result)) {
|
||||
throw new Error(`Expected success, got error: ${result.message}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("createPlayerCharacter", () => {
|
||||
it("creates a valid player character", () => {
|
||||
const { characters, events } = success(
|
||||
[],
|
||||
"Aragorn",
|
||||
16,
|
||||
120,
|
||||
"green",
|
||||
"shield",
|
||||
);
|
||||
|
||||
expect(characters).toHaveLength(1);
|
||||
expect(characters[0]).toEqual({
|
||||
id,
|
||||
name: "Aragorn",
|
||||
ac: 16,
|
||||
maxHp: 120,
|
||||
color: "green",
|
||||
icon: "shield",
|
||||
});
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "PlayerCharacterCreated",
|
||||
playerCharacterId: id,
|
||||
name: "Aragorn",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("trims whitespace from name", () => {
|
||||
const { characters } = success([], " Gandalf ", 12, 80);
|
||||
expect(characters[0].name).toBe("Gandalf");
|
||||
});
|
||||
|
||||
it("appends to existing characters", () => {
|
||||
const existing: PlayerCharacter = {
|
||||
id: playerCharacterId("pc-0"),
|
||||
name: "Legolas",
|
||||
ac: 14,
|
||||
maxHp: 90,
|
||||
color: "green",
|
||||
icon: "eye",
|
||||
};
|
||||
const { characters } = success([existing], "Gimli", 18, 100, "red", "axe");
|
||||
expect(characters).toHaveLength(2);
|
||||
expect(characters[0]).toEqual(existing);
|
||||
expect(characters[1].name).toBe("Gimli");
|
||||
});
|
||||
|
||||
it("rejects empty name", () => {
|
||||
const result = createPlayerCharacter([], id, "", 10, 50, "blue", "sword");
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-name");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects whitespace-only name", () => {
|
||||
const result = createPlayerCharacter(
|
||||
[],
|
||||
id,
|
||||
" ",
|
||||
10,
|
||||
50,
|
||||
"blue",
|
||||
"sword",
|
||||
);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-name");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects negative AC", () => {
|
||||
const result = createPlayerCharacter(
|
||||
[],
|
||||
id,
|
||||
"Test",
|
||||
-1,
|
||||
50,
|
||||
"blue",
|
||||
"sword",
|
||||
);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-ac");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-integer AC", () => {
|
||||
const result = createPlayerCharacter(
|
||||
[],
|
||||
id,
|
||||
"Test",
|
||||
10.5,
|
||||
50,
|
||||
"blue",
|
||||
"sword",
|
||||
);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-ac");
|
||||
}
|
||||
});
|
||||
|
||||
it("allows AC of 0", () => {
|
||||
const { characters } = success([], "Test", 0, 50);
|
||||
expect(characters[0].ac).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects maxHp of 0", () => {
|
||||
const result = createPlayerCharacter(
|
||||
[],
|
||||
id,
|
||||
"Test",
|
||||
10,
|
||||
0,
|
||||
"blue",
|
||||
"sword",
|
||||
);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-max-hp");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects negative maxHp", () => {
|
||||
const result = createPlayerCharacter(
|
||||
[],
|
||||
id,
|
||||
"Test",
|
||||
10,
|
||||
-5,
|
||||
"blue",
|
||||
"sword",
|
||||
);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-max-hp");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-integer maxHp", () => {
|
||||
const result = createPlayerCharacter(
|
||||
[],
|
||||
id,
|
||||
"Test",
|
||||
10,
|
||||
50.5,
|
||||
"blue",
|
||||
"sword",
|
||||
);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-max-hp");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid color", () => {
|
||||
const result = createPlayerCharacter(
|
||||
[],
|
||||
id,
|
||||
"Test",
|
||||
10,
|
||||
50,
|
||||
"neon",
|
||||
"sword",
|
||||
);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-color");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid icon", () => {
|
||||
const result = createPlayerCharacter(
|
||||
[],
|
||||
id,
|
||||
"Test",
|
||||
10,
|
||||
50,
|
||||
"blue",
|
||||
"banana",
|
||||
);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-icon");
|
||||
}
|
||||
});
|
||||
|
||||
it("emits exactly one event on success", () => {
|
||||
const { events } = success([], "Test", 10, 50);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].type).toBe("PlayerCharacterCreated");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deletePlayerCharacter } from "../delete-player-character.js";
|
||||
import type { PlayerCharacter } from "../player-character-types.js";
|
||||
import { playerCharacterId } from "../player-character-types.js";
|
||||
import { isDomainError } from "../types.js";
|
||||
|
||||
const id1 = playerCharacterId("pc-1");
|
||||
const id2 = playerCharacterId("pc-2");
|
||||
|
||||
function makePC(overrides?: Partial<PlayerCharacter>): PlayerCharacter {
|
||||
return {
|
||||
id: id1,
|
||||
name: "Aragorn",
|
||||
ac: 16,
|
||||
maxHp: 120,
|
||||
color: "green",
|
||||
icon: "sword",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deletePlayerCharacter", () => {
|
||||
it("deletes an existing character", () => {
|
||||
const result = deletePlayerCharacter([makePC()], id1);
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
expect(result.characters).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns error for not-found id", () => {
|
||||
const result = deletePlayerCharacter([makePC()], id2);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("player-character-not-found");
|
||||
}
|
||||
});
|
||||
|
||||
it("emits PlayerCharacterDeleted event", () => {
|
||||
const result = deletePlayerCharacter([makePC()], id1);
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
expect(result.events).toHaveLength(1);
|
||||
expect(result.events[0].type).toBe("PlayerCharacterDeleted");
|
||||
});
|
||||
|
||||
it("preserves other characters when deleting one", () => {
|
||||
const pc1 = makePC({ id: id1, name: "Aragorn" });
|
||||
const pc2 = makePC({ id: id2, name: "Legolas" });
|
||||
const result = deletePlayerCharacter([pc1, pc2], id1);
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
expect(result.characters).toHaveLength(1);
|
||||
expect(result.characters[0].name).toBe("Legolas");
|
||||
});
|
||||
|
||||
it("event includes deleted character name", () => {
|
||||
const result = deletePlayerCharacter([makePC()], id1);
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
const event = result.events[0];
|
||||
if (event.type !== "PlayerCharacterDeleted") throw new Error("wrong event");
|
||||
expect(event.name).toBe("Aragorn");
|
||||
});
|
||||
});
|
||||
117
packages/domain/src/__tests__/edit-player-character.test.ts
Normal file
117
packages/domain/src/__tests__/edit-player-character.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { editPlayerCharacter } from "../edit-player-character.js";
|
||||
import type { PlayerCharacter } from "../player-character-types.js";
|
||||
import { playerCharacterId } from "../player-character-types.js";
|
||||
import { isDomainError } from "../types.js";
|
||||
|
||||
const id = playerCharacterId("pc-1");
|
||||
|
||||
function makePC(overrides?: Partial<PlayerCharacter>): PlayerCharacter {
|
||||
return {
|
||||
id,
|
||||
name: "Aragorn",
|
||||
ac: 16,
|
||||
maxHp: 120,
|
||||
color: "green",
|
||||
icon: "sword",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("editPlayerCharacter", () => {
|
||||
it("edits name successfully", () => {
|
||||
const result = editPlayerCharacter([makePC()], id, { name: "Strider" });
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
expect(result.characters[0].name).toBe("Strider");
|
||||
expect(result.events[0].type).toBe("PlayerCharacterUpdated");
|
||||
});
|
||||
|
||||
it("edits multiple fields", () => {
|
||||
const result = editPlayerCharacter([makePC()], id, {
|
||||
name: "Strider",
|
||||
ac: 18,
|
||||
});
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
expect(result.characters[0].name).toBe("Strider");
|
||||
expect(result.characters[0].ac).toBe(18);
|
||||
});
|
||||
|
||||
it("returns error for not-found id", () => {
|
||||
const result = editPlayerCharacter(
|
||||
[makePC()],
|
||||
playerCharacterId("pc-999"),
|
||||
{ name: "Nope" },
|
||||
);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("player-character-not-found");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects empty name", () => {
|
||||
const result = editPlayerCharacter([makePC()], id, { name: "" });
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-name");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid AC", () => {
|
||||
const result = editPlayerCharacter([makePC()], id, { ac: -1 });
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-ac");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid maxHp", () => {
|
||||
const result = editPlayerCharacter([makePC()], id, { maxHp: 0 });
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-max-hp");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid color", () => {
|
||||
const result = editPlayerCharacter([makePC()], id, { color: "neon" });
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-color");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid icon", () => {
|
||||
const result = editPlayerCharacter([makePC()], id, { icon: "banana" });
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-icon");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns error when no fields changed", () => {
|
||||
const pc = makePC();
|
||||
const result = editPlayerCharacter([pc], id, {
|
||||
name: pc.name,
|
||||
ac: pc.ac,
|
||||
});
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("no-changes");
|
||||
}
|
||||
});
|
||||
|
||||
it("emits exactly one event on success", () => {
|
||||
const result = editPlayerCharacter([makePC()], id, { name: "Strider" });
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
expect(result.events).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("event includes old and new name", () => {
|
||||
const result = editPlayerCharacter([makePC()], id, { name: "Strider" });
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
const event = result.events[0];
|
||||
if (event.type !== "PlayerCharacterUpdated") throw new Error("wrong event");
|
||||
expect(event.oldName).toBe("Aragorn");
|
||||
expect(event.newName).toBe("Strider");
|
||||
});
|
||||
});
|
||||
87
packages/domain/src/create-player-character.ts
Normal file
87
packages/domain/src/create-player-character.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { DomainEvent } from "./events.js";
|
||||
import type {
|
||||
PlayerCharacter,
|
||||
PlayerCharacterId,
|
||||
} from "./player-character-types.js";
|
||||
import {
|
||||
VALID_PLAYER_COLORS,
|
||||
VALID_PLAYER_ICONS,
|
||||
} from "./player-character-types.js";
|
||||
import type { DomainError } from "./types.js";
|
||||
|
||||
export interface CreatePlayerCharacterSuccess {
|
||||
readonly characters: readonly PlayerCharacter[];
|
||||
readonly events: DomainEvent[];
|
||||
}
|
||||
|
||||
export function createPlayerCharacter(
|
||||
characters: readonly PlayerCharacter[],
|
||||
id: PlayerCharacterId,
|
||||
name: string,
|
||||
ac: number,
|
||||
maxHp: number,
|
||||
color: string,
|
||||
icon: string,
|
||||
): CreatePlayerCharacterSuccess | DomainError {
|
||||
const trimmed = name.trim();
|
||||
|
||||
if (trimmed === "") {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-name",
|
||||
message: "Player character name must not be empty",
|
||||
};
|
||||
}
|
||||
|
||||
if (!Number.isInteger(ac) || ac < 0) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-ac",
|
||||
message: "AC must be a non-negative integer",
|
||||
};
|
||||
}
|
||||
|
||||
if (!Number.isInteger(maxHp) || maxHp < 1) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-max-hp",
|
||||
message: "Max HP must be a positive integer",
|
||||
};
|
||||
}
|
||||
|
||||
if (!VALID_PLAYER_COLORS.has(color)) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-color",
|
||||
message: `Invalid color: ${color}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!VALID_PLAYER_ICONS.has(icon)) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-icon",
|
||||
message: `Invalid icon: ${icon}`,
|
||||
};
|
||||
}
|
||||
|
||||
const newCharacter: PlayerCharacter = {
|
||||
id,
|
||||
name: trimmed,
|
||||
ac,
|
||||
maxHp,
|
||||
color: color as PlayerCharacter["color"],
|
||||
icon: icon as PlayerCharacter["icon"],
|
||||
};
|
||||
|
||||
return {
|
||||
characters: [...characters, newCharacter],
|
||||
events: [
|
||||
{
|
||||
type: "PlayerCharacterCreated",
|
||||
playerCharacterId: id,
|
||||
name: trimmed,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -75,6 +75,23 @@ export interface Creature {
|
||||
readonly spellcasting?: readonly SpellcastingBlock[];
|
||||
}
|
||||
|
||||
export interface BestiaryIndexEntry {
|
||||
readonly name: string;
|
||||
readonly source: string;
|
||||
readonly ac: number;
|
||||
readonly hp: number;
|
||||
readonly dex: number;
|
||||
readonly cr: string;
|
||||
readonly initiativeProficiency: number;
|
||||
readonly size: string;
|
||||
readonly type: string;
|
||||
}
|
||||
|
||||
export interface BestiaryIndex {
|
||||
readonly sources: Readonly<Record<string, string>>;
|
||||
readonly creatures: readonly BestiaryIndexEntry[];
|
||||
}
|
||||
|
||||
/** Maps a CR string to the corresponding proficiency bonus. */
|
||||
export function proficiencyBonus(cr: string): number {
|
||||
const numericCr = cr.includes("/")
|
||||
|
||||
39
packages/domain/src/delete-player-character.ts
Normal file
39
packages/domain/src/delete-player-character.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { DomainEvent } from "./events.js";
|
||||
import type {
|
||||
PlayerCharacter,
|
||||
PlayerCharacterId,
|
||||
} from "./player-character-types.js";
|
||||
import type { DomainError } from "./types.js";
|
||||
|
||||
export interface DeletePlayerCharacterSuccess {
|
||||
readonly characters: readonly PlayerCharacter[];
|
||||
readonly events: DomainEvent[];
|
||||
}
|
||||
|
||||
export function deletePlayerCharacter(
|
||||
characters: readonly PlayerCharacter[],
|
||||
id: PlayerCharacterId,
|
||||
): DeletePlayerCharacterSuccess | DomainError {
|
||||
const index = characters.findIndex((c) => c.id === id);
|
||||
if (index === -1) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "player-character-not-found",
|
||||
message: `Player character not found: ${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
const removed = characters[index];
|
||||
const newList = characters.filter((_, i) => i !== index);
|
||||
|
||||
return {
|
||||
characters: newList,
|
||||
events: [
|
||||
{
|
||||
type: "PlayerCharacterDeleted",
|
||||
playerCharacterId: id,
|
||||
name: removed.name,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
137
packages/domain/src/edit-player-character.ts
Normal file
137
packages/domain/src/edit-player-character.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { DomainEvent } from "./events.js";
|
||||
import type {
|
||||
PlayerCharacter,
|
||||
PlayerCharacterId,
|
||||
} from "./player-character-types.js";
|
||||
import {
|
||||
VALID_PLAYER_COLORS,
|
||||
VALID_PLAYER_ICONS,
|
||||
} from "./player-character-types.js";
|
||||
import type { DomainError } from "./types.js";
|
||||
|
||||
export interface EditPlayerCharacterSuccess {
|
||||
readonly characters: readonly PlayerCharacter[];
|
||||
readonly events: DomainEvent[];
|
||||
}
|
||||
|
||||
interface EditFields {
|
||||
readonly name?: string;
|
||||
readonly ac?: number;
|
||||
readonly maxHp?: number;
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
}
|
||||
|
||||
function validateFields(fields: EditFields): DomainError | null {
|
||||
if (fields.name !== undefined && fields.name.trim() === "") {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-name",
|
||||
message: "Player character name must not be empty",
|
||||
};
|
||||
}
|
||||
if (
|
||||
fields.ac !== undefined &&
|
||||
(!Number.isInteger(fields.ac) || fields.ac < 0)
|
||||
) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-ac",
|
||||
message: "AC must be a non-negative integer",
|
||||
};
|
||||
}
|
||||
if (
|
||||
fields.maxHp !== undefined &&
|
||||
(!Number.isInteger(fields.maxHp) || fields.maxHp < 1)
|
||||
) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-max-hp",
|
||||
message: "Max HP must be a positive integer",
|
||||
};
|
||||
}
|
||||
if (fields.color !== undefined && !VALID_PLAYER_COLORS.has(fields.color)) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-color",
|
||||
message: `Invalid color: ${fields.color}`,
|
||||
};
|
||||
}
|
||||
if (fields.icon !== undefined && !VALID_PLAYER_ICONS.has(fields.icon)) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-icon",
|
||||
message: `Invalid icon: ${fields.icon}`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyFields(
|
||||
existing: PlayerCharacter,
|
||||
fields: EditFields,
|
||||
): PlayerCharacter {
|
||||
return {
|
||||
id: existing.id,
|
||||
name: fields.name !== undefined ? fields.name.trim() : existing.name,
|
||||
ac: fields.ac !== undefined ? fields.ac : existing.ac,
|
||||
maxHp: fields.maxHp !== undefined ? fields.maxHp : existing.maxHp,
|
||||
color:
|
||||
fields.color !== undefined
|
||||
? (fields.color as PlayerCharacter["color"])
|
||||
: existing.color,
|
||||
icon:
|
||||
fields.icon !== undefined
|
||||
? (fields.icon as PlayerCharacter["icon"])
|
||||
: existing.icon,
|
||||
};
|
||||
}
|
||||
|
||||
export function editPlayerCharacter(
|
||||
characters: readonly PlayerCharacter[],
|
||||
id: PlayerCharacterId,
|
||||
fields: EditFields,
|
||||
): EditPlayerCharacterSuccess | DomainError {
|
||||
const index = characters.findIndex((c) => c.id === id);
|
||||
if (index === -1) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "player-character-not-found",
|
||||
message: `Player character not found: ${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
const validationError = validateFields(fields);
|
||||
if (validationError) return validationError;
|
||||
|
||||
const existing = characters[index];
|
||||
const updated = applyFields(existing, fields);
|
||||
|
||||
if (
|
||||
updated.name === existing.name &&
|
||||
updated.ac === existing.ac &&
|
||||
updated.maxHp === existing.maxHp &&
|
||||
updated.color === existing.color &&
|
||||
updated.icon === existing.icon
|
||||
) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "no-changes",
|
||||
message: "No fields changed",
|
||||
};
|
||||
}
|
||||
|
||||
const newList = characters.map((c, i) => (i === index ? updated : c));
|
||||
|
||||
return {
|
||||
characters: newList,
|
||||
events: [
|
||||
{
|
||||
type: "PlayerCharacterUpdated",
|
||||
playerCharacterId: id,
|
||||
oldName: existing.name,
|
||||
newName: updated.name,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ConditionId } from "./conditions.js";
|
||||
import type { PlayerCharacterId } from "./player-character-types.js";
|
||||
import type { CombatantId } from "./types.js";
|
||||
|
||||
export interface TurnAdvanced {
|
||||
@@ -103,6 +104,25 @@ export interface EncounterCleared {
|
||||
readonly combatantCount: number;
|
||||
}
|
||||
|
||||
export interface PlayerCharacterCreated {
|
||||
readonly type: "PlayerCharacterCreated";
|
||||
readonly playerCharacterId: PlayerCharacterId;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
export interface PlayerCharacterUpdated {
|
||||
readonly type: "PlayerCharacterUpdated";
|
||||
readonly playerCharacterId: PlayerCharacterId;
|
||||
readonly oldName: string;
|
||||
readonly newName: string;
|
||||
}
|
||||
|
||||
export interface PlayerCharacterDeleted {
|
||||
readonly type: "PlayerCharacterDeleted";
|
||||
readonly playerCharacterId: PlayerCharacterId;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
export type DomainEvent =
|
||||
| TurnAdvanced
|
||||
| RoundAdvanced
|
||||
@@ -119,4 +139,7 @@ export type DomainEvent =
|
||||
| ConditionRemoved
|
||||
| ConcentrationStarted
|
||||
| ConcentrationEnded
|
||||
| EncounterCleared;
|
||||
| EncounterCleared
|
||||
| PlayerCharacterCreated
|
||||
| PlayerCharacterUpdated
|
||||
| PlayerCharacterDeleted;
|
||||
|
||||
@@ -13,6 +13,12 @@ export {
|
||||
VALID_CONDITION_IDS,
|
||||
} from "./conditions.js";
|
||||
export {
|
||||
type CreatePlayerCharacterSuccess,
|
||||
createPlayerCharacter,
|
||||
} from "./create-player-character.js";
|
||||
export {
|
||||
type BestiaryIndex,
|
||||
type BestiaryIndexEntry,
|
||||
type BestiarySource,
|
||||
type Creature,
|
||||
type CreatureId,
|
||||
@@ -23,10 +29,18 @@ export {
|
||||
type SpellcastingBlock,
|
||||
type TraitBlock,
|
||||
} from "./creature-types.js";
|
||||
export {
|
||||
type DeletePlayerCharacterSuccess,
|
||||
deletePlayerCharacter,
|
||||
} from "./delete-player-character.js";
|
||||
export {
|
||||
type EditCombatantSuccess,
|
||||
editCombatant,
|
||||
} from "./edit-combatant.js";
|
||||
export {
|
||||
type EditPlayerCharacterSuccess,
|
||||
editPlayerCharacter,
|
||||
} from "./edit-player-character.js";
|
||||
export type {
|
||||
AcSet,
|
||||
CombatantAdded,
|
||||
@@ -41,6 +55,9 @@ export type {
|
||||
EncounterCleared,
|
||||
InitiativeSet,
|
||||
MaxHpSet,
|
||||
PlayerCharacterCreated,
|
||||
PlayerCharacterDeleted,
|
||||
PlayerCharacterUpdated,
|
||||
RoundAdvanced,
|
||||
RoundRetreated,
|
||||
TurnAdvanced,
|
||||
@@ -52,6 +69,16 @@ export {
|
||||
formatInitiativeModifier,
|
||||
type InitiativeResult,
|
||||
} from "./initiative.js";
|
||||
export {
|
||||
type PlayerCharacter,
|
||||
type PlayerCharacterId,
|
||||
type PlayerCharacterList,
|
||||
type PlayerColor,
|
||||
type PlayerIcon,
|
||||
playerCharacterId,
|
||||
VALID_PLAYER_COLORS,
|
||||
VALID_PLAYER_ICONS,
|
||||
} from "./player-character-types.js";
|
||||
export {
|
||||
type RemoveCombatantSuccess,
|
||||
removeCombatant,
|
||||
|
||||
81
packages/domain/src/player-character-types.ts
Normal file
81
packages/domain/src/player-character-types.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/** Branded string type for player character identity. */
|
||||
export type PlayerCharacterId = string & {
|
||||
readonly __brand: "PlayerCharacterId";
|
||||
};
|
||||
|
||||
export function playerCharacterId(id: string): PlayerCharacterId {
|
||||
return id as PlayerCharacterId;
|
||||
}
|
||||
|
||||
export type PlayerColor =
|
||||
| "red"
|
||||
| "blue"
|
||||
| "green"
|
||||
| "purple"
|
||||
| "orange"
|
||||
| "pink"
|
||||
| "cyan"
|
||||
| "yellow"
|
||||
| "emerald"
|
||||
| "indigo";
|
||||
|
||||
export const VALID_PLAYER_COLORS: ReadonlySet<string> = new Set<PlayerColor>([
|
||||
"red",
|
||||
"blue",
|
||||
"green",
|
||||
"purple",
|
||||
"orange",
|
||||
"pink",
|
||||
"cyan",
|
||||
"yellow",
|
||||
"emerald",
|
||||
"indigo",
|
||||
]);
|
||||
|
||||
export type PlayerIcon =
|
||||
| "sword"
|
||||
| "shield"
|
||||
| "skull"
|
||||
| "heart"
|
||||
| "wand"
|
||||
| "flame"
|
||||
| "crown"
|
||||
| "star"
|
||||
| "moon"
|
||||
| "sun"
|
||||
| "axe"
|
||||
| "crosshair"
|
||||
| "eye"
|
||||
| "feather"
|
||||
| "zap";
|
||||
|
||||
export const VALID_PLAYER_ICONS: ReadonlySet<string> = new Set<PlayerIcon>([
|
||||
"sword",
|
||||
"shield",
|
||||
"skull",
|
||||
"heart",
|
||||
"wand",
|
||||
"flame",
|
||||
"crown",
|
||||
"star",
|
||||
"moon",
|
||||
"sun",
|
||||
"axe",
|
||||
"crosshair",
|
||||
"eye",
|
||||
"feather",
|
||||
"zap",
|
||||
]);
|
||||
|
||||
export interface PlayerCharacter {
|
||||
readonly id: PlayerCharacterId;
|
||||
readonly name: string;
|
||||
readonly ac: number;
|
||||
readonly maxHp: number;
|
||||
readonly color: PlayerColor;
|
||||
readonly icon: PlayerIcon;
|
||||
}
|
||||
|
||||
export interface PlayerCharacterList {
|
||||
readonly characters: readonly PlayerCharacter[];
|
||||
}
|
||||
@@ -62,8 +62,9 @@ export function setInitiative(
|
||||
const bHas = b.c.initiative !== undefined;
|
||||
|
||||
if (aHas && bHas) {
|
||||
// biome-ignore lint: both checked above
|
||||
const diff = b.c.initiative! - a.c.initiative!;
|
||||
const aInit = a.c.initiative as number;
|
||||
const bInit = b.c.initiative as number;
|
||||
const diff = bInit - aInit;
|
||||
return diff !== 0 ? diff : a.i - b.i;
|
||||
}
|
||||
if (aHas && !bHas) return -1;
|
||||
|
||||
@@ -7,6 +7,7 @@ export function combatantId(id: string): CombatantId {
|
||||
|
||||
import type { ConditionId } from "./conditions.js";
|
||||
import type { CreatureId } from "./creature-types.js";
|
||||
import type { PlayerCharacterId } from "./player-character-types.js";
|
||||
|
||||
export interface Combatant {
|
||||
readonly id: CombatantId;
|
||||
@@ -18,6 +19,9 @@ export interface Combatant {
|
||||
readonly conditions?: readonly ConditionId[];
|
||||
readonly isConcentrating?: boolean;
|
||||
readonly creatureId?: CreatureId;
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
readonly playerCharacterId?: PlayerCharacterId;
|
||||
}
|
||||
|
||||
export interface Encounter {
|
||||
@@ -38,8 +42,8 @@ function domainError(code: string, message: string): DomainError {
|
||||
|
||||
/**
|
||||
* Creates a valid Encounter, enforcing INV-1, INV-2, INV-3.
|
||||
* - INV-1: At least one combatant required.
|
||||
* - INV-2: activeIndex defaults to 0 (always in bounds).
|
||||
* - INV-1: An encounter MAY have zero combatants.
|
||||
* - INV-2: activeIndex defaults to 0 (always in bounds when combatants exist).
|
||||
* - INV-3: roundNumber defaults to 1 (positive integer).
|
||||
*/
|
||||
export function createEncounter(
|
||||
@@ -47,13 +51,10 @@ export function createEncounter(
|
||||
activeIndex = 0,
|
||||
roundNumber = 1,
|
||||
): Encounter | DomainError {
|
||||
if (combatants.length === 0) {
|
||||
return domainError(
|
||||
"invalid-encounter",
|
||||
"An encounter must have at least one combatant",
|
||||
);
|
||||
}
|
||||
if (activeIndex < 0 || activeIndex >= combatants.length) {
|
||||
if (
|
||||
combatants.length > 0 &&
|
||||
(activeIndex < 0 || activeIndex >= combatants.length)
|
||||
) {
|
||||
return domainError(
|
||||
"invalid-encounter",
|
||||
`activeIndex ${activeIndex} out of bounds for ${combatants.length} combatants`,
|
||||
|
||||
897
pnpm-lock.yaml
generated
897
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -47,40 +47,34 @@ function matchesForbidden(importPath, forbidden) {
|
||||
return importPath === forbidden || importPath.startsWith(`${forbidden}/`);
|
||||
}
|
||||
|
||||
/** @returns {{ file: string, line: number, importPath: string, forbidden: string }[]} */
|
||||
export function checkLayerBoundaries() {
|
||||
const IMPORT_RE =
|
||||
/(?:import|from)\s+["']([^"']+)["']|require\s*\(\s*["']([^"']+)["']\s*\)/;
|
||||
|
||||
/**
|
||||
* Check a single file for forbidden imports.
|
||||
* @param {string} file
|
||||
* @param {string[]} forbidden
|
||||
* @returns {{ file: string, line: number, importPath: string, forbidden: string }[]}
|
||||
*/
|
||||
function checkFile(file, forbidden) {
|
||||
/** @type {{ file: string, line: number, importPath: string, forbidden: string }[]} */
|
||||
const violations = [];
|
||||
const content = readFileSync(file, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
|
||||
for (const [srcDir, forbidden] of Object.entries(FORBIDDEN)) {
|
||||
const absDir = join(ROOT, srcDir);
|
||||
let files;
|
||||
try {
|
||||
files = collectTsFiles(absDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = lines[i].match(IMPORT_RE);
|
||||
if (!match) continue;
|
||||
|
||||
for (const file of files) {
|
||||
const content = readFileSync(file, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const match = line.match(
|
||||
/(?:import|from)\s+["']([^"']+)["']|require\s*\(\s*["']([^"']+)["']\s*\)/,
|
||||
);
|
||||
if (!match) continue;
|
||||
const importPath = match[1] || match[2];
|
||||
for (const f of forbidden) {
|
||||
if (matchesForbidden(importPath, f)) {
|
||||
violations.push({
|
||||
file: relative(ROOT, file),
|
||||
line: i + 1,
|
||||
importPath,
|
||||
forbidden: f,
|
||||
});
|
||||
}
|
||||
}
|
||||
const importPath = match[1] || match[2];
|
||||
for (const f of forbidden) {
|
||||
if (matchesForbidden(importPath, f)) {
|
||||
violations.push({
|
||||
file: relative(ROOT, file),
|
||||
line: i + 1,
|
||||
importPath,
|
||||
forbidden: f,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,6 +82,30 @@ export function checkLayerBoundaries() {
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all files in a layer directory for forbidden imports.
|
||||
* @param {string} srcDir
|
||||
* @param {string[]} forbidden
|
||||
* @returns {{ file: string, line: number, importPath: string, forbidden: string }[]}
|
||||
*/
|
||||
function checkLayer(srcDir, forbidden) {
|
||||
const absDir = join(ROOT, srcDir);
|
||||
let files;
|
||||
try {
|
||||
files = collectTsFiles(absDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return files.flatMap((file) => checkFile(file, forbidden));
|
||||
}
|
||||
|
||||
/** @returns {{ file: string, line: number, importPath: string, forbidden: string }[]} */
|
||||
export function checkLayerBoundaries() {
|
||||
return Object.entries(FORBIDDEN).flatMap(([srcDir, forbidden]) =>
|
||||
checkLayer(srcDir, forbidden),
|
||||
);
|
||||
}
|
||||
|
||||
// Run as CLI if invoked directly
|
||||
if (
|
||||
process.argv[1] &&
|
||||
|
||||
166
scripts/generate-bestiary-index.mjs
Normal file
166
scripts/generate-bestiary-index.mjs
Normal file
@@ -0,0 +1,166 @@
|
||||
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Usage: node scripts/generate-bestiary-index.mjs <path-to-5etools-src>
|
||||
//
|
||||
// Requires a local clone/checkout of https://github.com/5etools-mirror-3/5etools-src
|
||||
// with at least data/bestiary/, data/books.json, and data/adventures.json.
|
||||
//
|
||||
// Example:
|
||||
// git clone --depth 1 --sparse https://github.com/5etools-mirror-3/5etools-src.git /tmp/5etools
|
||||
// cd /tmp/5etools && git sparse-checkout set data/bestiary data
|
||||
// node scripts/generate-bestiary-index.mjs /tmp/5etools
|
||||
|
||||
const TOOLS_ROOT = process.argv[2];
|
||||
if (!TOOLS_ROOT) {
|
||||
console.error(
|
||||
"Usage: node scripts/generate-bestiary-index.mjs <5etools-src-path>",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const PROJECT_ROOT = join(import.meta.dirname, "..");
|
||||
const BESTIARY_DIR = join(TOOLS_ROOT, "data/bestiary");
|
||||
const BOOKS_PATH = join(TOOLS_ROOT, "data/books.json");
|
||||
const ADVENTURES_PATH = join(TOOLS_ROOT, "data/adventures.json");
|
||||
const OUTPUT_PATH = join(PROJECT_ROOT, "data/bestiary/index.json");
|
||||
|
||||
// --- Build source display name map from books.json + adventures.json ---
|
||||
|
||||
/** Populate map from a list of entries that have source/id + name. */
|
||||
function addEntriesToMap(map, entries) {
|
||||
for (const entry of entries) {
|
||||
if (!entry.name) continue;
|
||||
if (entry.source) {
|
||||
map[entry.source] = entry.name;
|
||||
}
|
||||
// Some entries use "id" instead of "source"
|
||||
if (entry.id && !map[entry.id]) {
|
||||
map[entry.id] = entry.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildSourceMap() {
|
||||
const map = {};
|
||||
|
||||
const books = JSON.parse(readFileSync(BOOKS_PATH, "utf-8"));
|
||||
addEntriesToMap(map, books.book ?? []);
|
||||
|
||||
const adventures = JSON.parse(readFileSync(ADVENTURES_PATH, "utf-8"));
|
||||
addEntriesToMap(map, adventures.adventure ?? []);
|
||||
|
||||
// Manual additions for sources missing from books.json / adventures.json
|
||||
const manual = {
|
||||
ESK: "Essentials Kit",
|
||||
MCV1SC: "Monstrous Compendium Volume 1: Spelljammer Creatures",
|
||||
MCV2DC: "Monstrous Compendium Volume 2: Dragonlance Creatures",
|
||||
MCV3MC: "Monstrous Compendium Volume 3: Minecraft Creatures",
|
||||
MCV4EC: "Monstrous Compendium Volume 4: Eldraine Creatures",
|
||||
MFF: "Mordenkainen's Fiendish Folio",
|
||||
MisMV1: "Misplaced Monsters: Volume 1",
|
||||
SADS: "Sapphire Anniversary Dice Set",
|
||||
TftYP: "Tales from the Yawning Portal",
|
||||
VD: "Vecna Dossier",
|
||||
};
|
||||
for (const [k, v] of Object.entries(manual)) {
|
||||
if (!map[k]) map[k] = v;
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
// --- Extract type string from raw type field ---
|
||||
|
||||
function extractType(type) {
|
||||
if (typeof type === "string") return type;
|
||||
if (typeof type?.type === "string") return type.type;
|
||||
if (typeof type?.type === "object" && Array.isArray(type.type.choose)) {
|
||||
return type.type.choose.join("/");
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
// --- Extract AC from raw ac field ---
|
||||
|
||||
function extractAc(ac) {
|
||||
if (!Array.isArray(ac) || ac.length === 0) return 0;
|
||||
const first = ac[0];
|
||||
if (typeof first === "number") return first;
|
||||
if (typeof first === "object" && typeof first.ac === "number")
|
||||
return first.ac;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --- Extract CR from raw cr field ---
|
||||
|
||||
function extractCr(cr) {
|
||||
if (typeof cr === "string") return cr;
|
||||
if (typeof cr === "object" && typeof cr.cr === "string") return cr.cr;
|
||||
return "0";
|
||||
}
|
||||
|
||||
// --- Main ---
|
||||
|
||||
const sourceMap = buildSourceMap();
|
||||
const files = readdirSync(BESTIARY_DIR).filter(
|
||||
(f) => f.startsWith("bestiary-") && f.endsWith(".json"),
|
||||
);
|
||||
|
||||
const creatures = [];
|
||||
const unmappedSources = new Set();
|
||||
|
||||
for (const file of files.sort()) {
|
||||
const raw = JSON.parse(readFileSync(join(BESTIARY_DIR, file), "utf-8"));
|
||||
const monsters = raw.monster ?? [];
|
||||
|
||||
for (const m of monsters) {
|
||||
// Skip creatures that are copies/references (no actual stats)
|
||||
if (m._copy || m.hp == null || m.ac == null) continue;
|
||||
|
||||
const source = m.source ?? "";
|
||||
if (source && !sourceMap[source]) {
|
||||
unmappedSources.add(source);
|
||||
}
|
||||
|
||||
creatures.push({
|
||||
n: m.name,
|
||||
s: source,
|
||||
ac: extractAc(m.ac),
|
||||
hp: m.hp.average ?? 0,
|
||||
dx: m.dex ?? 10,
|
||||
cr: extractCr(m.cr),
|
||||
ip: m.initiative?.proficiency ?? 0,
|
||||
sz: Array.isArray(m.size) ? m.size[0] : (m.size ?? "M"),
|
||||
tp: extractType(m.type),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by name then source for stable output
|
||||
creatures.sort((a, b) => a.n.localeCompare(b.n) || a.s.localeCompare(b.s));
|
||||
|
||||
// Filter sourceMap to only include sources that appear in the index
|
||||
const usedSources = new Set(creatures.map((c) => c.s));
|
||||
const filteredSourceMap = {};
|
||||
for (const [key, value] of Object.entries(sourceMap)) {
|
||||
if (usedSources.has(key)) {
|
||||
filteredSourceMap[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const output = {
|
||||
sources: filteredSourceMap,
|
||||
creatures,
|
||||
};
|
||||
|
||||
writeFileSync(OUTPUT_PATH, JSON.stringify(output));
|
||||
|
||||
// Stats
|
||||
const rawSize = Buffer.byteLength(JSON.stringify(output));
|
||||
console.log(`Sources: ${Object.keys(filteredSourceMap).length}`);
|
||||
console.log(`Creatures: ${creatures.length}`);
|
||||
console.log(`Output size: ${(rawSize / 1024).toFixed(1)} KB`);
|
||||
if (unmappedSources.size > 0) {
|
||||
console.log(`Unmapped sources: ${[...unmappedSources].sort().join(", ")}`);
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
# Implementation Plan: Advance Turn
|
||||
|
||||
**Branch**: `001-advance-turn` | **Date**: 2026-03-03 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/001-advance-turn/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Implement the AdvanceTurn domain operation as a pure function that
|
||||
transitions an Encounter to the next combatant, wrapping rounds and
|
||||
emitting TurnAdvanced / RoundAdvanced domain events. Stand up the
|
||||
pnpm monorepo skeleton, Biome tooling, and Vitest test harness so
|
||||
that all constitution merge-gate requirements are satisfied from the
|
||||
first commit.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Node**: 22 LTS (pinned via `.nvmrc`)
|
||||
**Language/Version**: TypeScript 5.8 (strict mode)
|
||||
**Primary Dependencies**: React 19 (pin to major; minor upgrades
|
||||
allowed), Vite 6.2
|
||||
**Storage**: In-memory only (MVP baseline)
|
||||
**Testing**: Vitest 3.0
|
||||
**Lint/Format**: Biome 2.0.0 (exact version, single tool — no
|
||||
Prettier, no ESLint)
|
||||
**Package Manager**: pnpm 10.6 (pinned via `packageManager` field
|
||||
in root `package.json`)
|
||||
**Target Platform**: Static web app (modern browsers)
|
||||
**Project Type**: Monorepo — library packages + web app
|
||||
**Performance Goals**: N/A (walking skeleton)
|
||||
**Constraints**: Domain package must have zero React/Vite imports
|
||||
**Scale/Scope**: Single feature, ~5 source files, ~1 test file
|
||||
|
||||
## Constitution Check
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| I. Deterministic Domain Core | PASS | `advanceTurn` is a pure function; no I/O, randomness, or clocks |
|
||||
| II. Layered Architecture | PASS | `packages/domain` → `packages/application` → `apps/web`; strict dependency direction enforced by automated import check |
|
||||
| III. Agent Boundary | N/A | Agent layer out of scope for this feature |
|
||||
| IV. Clarification-First | PASS | No ambiguous decisions remain; spec fully clarified |
|
||||
| V. Escalation Gates | PASS | Scope strictly limited to spec; out-of-scope items listed |
|
||||
| VI. MVP Baseline Language | PASS | No permanent bans; "MVP baseline does not include" used |
|
||||
| VII. No Gameplay Rules | PASS | No gameplay mechanics in plan or constitution |
|
||||
| Merge Gate | PASS | `pnpm check` script runs format, lint, typecheck, test |
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/001-advance-turn/
|
||||
├── spec.md
|
||||
└── plan.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
packages/
|
||||
├── domain/
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ └── src/
|
||||
│ ├── types.ts # CombatantId, Combatant, Encounter
|
||||
│ ├── events.ts # TurnAdvanced, RoundAdvanced, DomainEvent
|
||||
│ ├── advance-turn.ts # advanceTurn pure function
|
||||
│ └── index.ts # public barrel export
|
||||
├── application/
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ └── src/
|
||||
│ ├── ports.ts # EncounterStore port interface
|
||||
│ ├── advance-turn-use-case.ts
|
||||
│ └── index.ts
|
||||
apps/
|
||||
└── web/
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── vite.config.ts
|
||||
├── index.html
|
||||
└── src/
|
||||
├── main.tsx
|
||||
├── App.tsx
|
||||
└── hooks/
|
||||
└── use-encounter.ts
|
||||
|
||||
# Testing (co-located with domain package)
|
||||
packages/domain/
|
||||
└── src/
|
||||
└── __tests__/
|
||||
└── advance-turn.test.ts
|
||||
|
||||
# Root config
|
||||
├── .nvmrc # pins Node 22
|
||||
├── pnpm-workspace.yaml
|
||||
├── biome.json
|
||||
├── tsconfig.base.json
|
||||
└── package.json # packageManager field pins pnpm; root scripts
|
||||
```
|
||||
|
||||
**Structure Decision**: pnpm workspace monorepo with two packages
|
||||
(`domain`, `application`) and one app (`web`). Domain is
|
||||
framework-agnostic TypeScript. Application imports domain only.
|
||||
Web app (React + Vite) imports both.
|
||||
|
||||
## Tooling & Merge Gate
|
||||
|
||||
### Scripts (root package.json)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"scripts": {
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome format .",
|
||||
"lint": "biome lint .",
|
||||
"lint:fix": "biome lint --write .",
|
||||
"typecheck": "tsc --build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"check": "biome check . && tsc --build && vitest run"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`pnpm check` is the single merge gate: format + lint + typecheck +
|
||||
test. The layer boundary check runs as a Vitest test (see below),
|
||||
so it executes as part of `vitest run` — no separate script needed.
|
||||
|
||||
### Layer Boundary Enforcement
|
||||
|
||||
Biome does not natively support cross-package import restrictions.
|
||||
A lightweight `scripts/check-layer-boundaries.mjs` script will:
|
||||
|
||||
1. Scan `packages/domain/src/**/*.ts` — assert zero imports from
|
||||
`@initiative/application`, `apps/`, `react`, `vite`.
|
||||
2. Scan `packages/application/src/**/*.ts` — assert zero imports
|
||||
from `apps/`, `react`, `vite`.
|
||||
3. Exit non-zero on violation with a clear error message.
|
||||
|
||||
This script is invoked by a Vitest test
|
||||
(`packages/domain/src/__tests__/layer-boundaries.test.ts`) so it
|
||||
runs automatically as part of `vitest run` inside `pnpm check`.
|
||||
No separate `check:layer` script is needed — the layer boundary
|
||||
check is guaranteed to execute on every merge-gate run.
|
||||
|
||||
### Biome Configuration (biome.json)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
|
||||
"organizeImports": { "enabled": true },
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab",
|
||||
"lineWidth": 80
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### TypeScript Configuration
|
||||
|
||||
- `tsconfig.base.json` at root: strict mode, composite projects,
|
||||
path aliases (`@initiative/domain`, `@initiative/application`).
|
||||
- Each package extends `tsconfig.base.json` with its own
|
||||
`include`/`references`.
|
||||
- `apps/web/tsconfig.json` references both packages.
|
||||
|
||||
## Milestones
|
||||
|
||||
### Milestone 1: Tooling & Domain (walking skeleton)
|
||||
|
||||
Stand up monorepo, Biome, Vitest, TypeScript project references,
|
||||
and implement the complete AdvanceTurn domain logic with all tests.
|
||||
|
||||
**Exit criteria**: `pnpm check` passes. All 8 acceptance scenarios
|
||||
green. Layer boundary check green. No React or Vite dependencies in
|
||||
`packages/domain` or `packages/application`.
|
||||
|
||||
### Milestone 2: Application + Minimal Web Shell
|
||||
|
||||
Wire up the application use case and a minimal React UI that
|
||||
displays the encounter state and has a single "Next Turn" button.
|
||||
|
||||
**Exit criteria**: `pnpm check` passes. Clicking "Next Turn" in the
|
||||
browser advances the turn with correct round wrapping. The app
|
||||
builds with `vite build`.
|
||||
|
||||
## Task List
|
||||
|
||||
### Phase 1: Setup (Milestone 1)
|
||||
|
||||
- [X] **T001** Initialize pnpm workspace and root config
|
||||
- Create `pnpm-workspace.yaml` listing `packages/*` and `apps/*`
|
||||
- Create `.nvmrc` pinning Node 22
|
||||
- Create root `package.json` with `packageManager` field pinning
|
||||
pnpm 10.6 and scripts (check, test, lint, format, typecheck)
|
||||
- Create `biome.json` at root
|
||||
- Create `tsconfig.base.json` (strict, composite, path aliases)
|
||||
- **Acceptance**: `pnpm install` succeeds; `biome check .` runs
|
||||
without config errors
|
||||
|
||||
- [X] **T002** [P] Create `packages/domain` package skeleton
|
||||
- `package.json` (name: `@initiative/domain`, no dependencies)
|
||||
- `tsconfig.json` extending base, composite: true
|
||||
- Empty `src/index.ts`
|
||||
- **Acceptance**: `tsc --build packages/domain` succeeds
|
||||
|
||||
- [X] **T003** [P] Create `packages/application` package skeleton
|
||||
- `package.json` (name: `@initiative/application`,
|
||||
depends on `@initiative/domain`)
|
||||
- `tsconfig.json` extending base, references domain
|
||||
- Empty `src/index.ts`
|
||||
- **Acceptance**: `tsc --build packages/application` succeeds
|
||||
|
||||
- [X] **T004** [P] Create `apps/web` package skeleton
|
||||
- `package.json` with React, Vite, depends on both packages
|
||||
- `tsconfig.json` referencing both packages
|
||||
- `vite.config.ts` (minimal)
|
||||
- `index.html` + `src/main.tsx` + `src/App.tsx` (placeholder)
|
||||
- **Acceptance**: `pnpm --filter web dev` starts; `vite build`
|
||||
succeeds
|
||||
|
||||
- [X] **T005** Configure Vitest
|
||||
- Add `vitest` as root dev dependency
|
||||
- Create `vitest.config.ts` at root (workspace mode) or per
|
||||
package as needed
|
||||
- Verify `pnpm test` runs (0 tests, exits clean)
|
||||
- **Acceptance**: `pnpm test` exits 0
|
||||
|
||||
- [X] **T006** Create layer boundary check script
|
||||
- `scripts/check-layer-boundaries.mjs`: scans domain and
|
||||
application source for forbidden imports
|
||||
- `packages/domain/src/__tests__/layer-boundaries.test.ts`:
|
||||
wraps the script as a Vitest test
|
||||
- **Acceptance**: test passes on clean skeleton; fails if a
|
||||
forbidden import is manually added (verify, then remove)
|
||||
|
||||
### Phase 2: Domain Implementation (Milestone 1)
|
||||
|
||||
- [ ] **T007** Define domain types in `packages/domain/src/types.ts`
|
||||
- `CombatantId` (branded string or opaque type)
|
||||
- `Combatant` (carries a CombatantId)
|
||||
- `Encounter` (combatants array, activeIndex, roundNumber)
|
||||
- Factory function `createEncounter` that validates INV-1, INV-2,
|
||||
INV-3
|
||||
- **Acceptance**: types compile; `createEncounter([])` returns
|
||||
error; `createEncounter([a])` returns valid Encounter
|
||||
|
||||
- [ ] **T008** [P] Define domain events in
|
||||
`packages/domain/src/events.ts`
|
||||
- `TurnAdvanced { previousCombatantId, newCombatantId,
|
||||
roundNumber }`
|
||||
- `RoundAdvanced { newRoundNumber }`
|
||||
- `DomainEvent = TurnAdvanced | RoundAdvanced`
|
||||
- **Acceptance**: types compile; events are plain data (no
|
||||
classes with methods)
|
||||
|
||||
- [ ] **T009** Implement `advanceTurn` in
|
||||
`packages/domain/src/advance-turn.ts`
|
||||
- Signature: `(encounter: Encounter) =>
|
||||
{ encounter: Encounter; events: DomainEvent[] } | DomainError`
|
||||
- Implements FR-001 through FR-005
|
||||
- Returns error for empty combatant list (INV-1)
|
||||
- Emits TurnAdvanced on every call (INV-5)
|
||||
- Emits TurnAdvanced then RoundAdvanced on wrap (event order
|
||||
contract)
|
||||
- **Acceptance**: compiles; satisfies type contract
|
||||
|
||||
- [ ] **T010** Write tests for all 8 acceptance scenarios +
|
||||
invariants in
|
||||
`packages/domain/src/__tests__/advance-turn.test.ts`
|
||||
- Scenarios 1–8 from spec (Given/When/Then)
|
||||
- INV-1: empty encounter rejected
|
||||
- INV-2: activeIndex always in bounds (property check across
|
||||
scenarios)
|
||||
- INV-3: roundNumber never decreases
|
||||
- INV-4: determinism — same input produces same output (call
|
||||
twice, assert deep equal)
|
||||
- INV-5: every success emits at least TurnAdvanced
|
||||
- Event ordering: on wrap, events array is
|
||||
[TurnAdvanced, RoundAdvanced] in that order
|
||||
- **Acceptance**: `pnpm test` — all tests green; `pnpm check` —
|
||||
full pipeline green
|
||||
|
||||
- [ ] **T011** Export public API from `packages/domain/src/index.ts`
|
||||
- Re-export types, events, `advanceTurn`, `createEncounter`
|
||||
- **Acceptance**: consuming packages can
|
||||
`import { advanceTurn } from "@initiative/domain"`
|
||||
|
||||
**Milestone 1 checkpoint**: `pnpm check` passes (format + lint +
|
||||
typecheck + test + layer boundaries). All 8 scenarios + invariants
|
||||
green.
|
||||
|
||||
### Phase 3: Application + Web Shell (Milestone 2)
|
||||
|
||||
- [ ] **T012** Define port interface in
|
||||
`packages/application/src/ports.ts`
|
||||
- `EncounterStore` port: `get(): Encounter`, `save(e: Encounter)`
|
||||
- **Acceptance**: compiles; no imports from adapters or React
|
||||
|
||||
- [ ] **T013** Implement `AdvanceTurnUseCase` in
|
||||
`packages/application/src/advance-turn-use-case.ts`
|
||||
- Accepts `EncounterStore` port
|
||||
- Calls `advanceTurn` from domain, saves result, returns events
|
||||
- **Acceptance**: compiles; imports only from `@initiative/domain`
|
||||
and local ports
|
||||
|
||||
- [ ] **T014** Export public API from
|
||||
`packages/application/src/index.ts`
|
||||
- Re-export use case and port types
|
||||
- **Acceptance**: consuming app can import from
|
||||
`@initiative/application`
|
||||
|
||||
- [ ] **T015** Implement `useEncounter` hook in
|
||||
`apps/web/src/hooks/use-encounter.ts`
|
||||
- In-memory implementation of `EncounterStore` port (React state)
|
||||
- Exposes current encounter state + `advanceTurn` action
|
||||
- Initializes with a hardcoded 3-combatant encounter for demo
|
||||
- **Acceptance**: hook compiles; usable in a React component
|
||||
|
||||
- [ ] **T016** Wire up `App.tsx`
|
||||
- Display: current combatant name, round number, combatant list
|
||||
with active indicator
|
||||
- Single "Next Turn" button calling the use case
|
||||
- Display emitted events (optional, for demo clarity)
|
||||
- **Acceptance**: `vite build` succeeds; clicking "Next Turn"
|
||||
cycles through combatants and increments rounds correctly
|
||||
|
||||
**Milestone 2 checkpoint**: `pnpm check` passes. App runs in
|
||||
browser. Full round-trip from button click → domain pure function →
|
||||
UI update verified manually.
|
||||
|
||||
## Risks & Open Questions
|
||||
|
||||
| # | Item | Severity | Mitigation |
|
||||
|---|------|----------|------------|
|
||||
| 1 | pnpm workspace + TypeScript project references can have path resolution quirks with Vite | Low | Use `vite-tsconfig-paths` plugin if needed; test early in T004 |
|
||||
| 2 | Biome config format may change across versions | Low | Pinned to exact 2.0.0; `$schema` in config validates structure |
|
||||
| 3 | Layer boundary script is a lightweight grep — not a full architectural fitness function | Low | Sufficient for walking skeleton; can upgrade to a Biome plugin or `dependency-cruiser` later if needed |
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations. No complexity justifications needed.
|
||||
@@ -1,172 +0,0 @@
|
||||
# Feature Specification: Advance Turn
|
||||
|
||||
**Feature Branch**: `001-advance-turn`
|
||||
**Created**: 2026-03-03
|
||||
**Status**: Draft
|
||||
**Input**: Walking-skeleton domain feature — deterministic turn advancement
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Advance Turn (Priority: P1)
|
||||
|
||||
A game master running an encounter advances the turn to the next
|
||||
combatant in initiative order. When the last combatant in the round
|
||||
finishes, the round number increments and play wraps to the first
|
||||
combatant.
|
||||
|
||||
**Why this priority**: This is the irreducible core of an initiative
|
||||
tracker. Without turn advancement, no other feature has meaning.
|
||||
|
||||
**Independent Test**: Can be fully tested as a pure state transition
|
||||
with no I/O, persistence, or UI. Given an Encounter value and an
|
||||
AdvanceTurn action, assert the resulting Encounter value and emitted
|
||||
domain events.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an encounter with combatants [A, B, C], activeIndex 0,
|
||||
roundNumber 1,
|
||||
**When** AdvanceTurn,
|
||||
**Then** activeIndex is 1, roundNumber is 1,
|
||||
and a TurnAdvanced event is emitted with
|
||||
previousCombatantId A, newCombatantId B, roundNumber 1.
|
||||
|
||||
2. **Given** an encounter with combatants [A, B, C], activeIndex 1,
|
||||
roundNumber 1,
|
||||
**When** AdvanceTurn,
|
||||
**Then** activeIndex is 2, roundNumber is 1,
|
||||
and a TurnAdvanced event is emitted with
|
||||
previousCombatantId B, newCombatantId C, roundNumber 1.
|
||||
|
||||
3. **Given** an encounter with combatants [A, B, C], activeIndex 2,
|
||||
roundNumber 1,
|
||||
**When** AdvanceTurn,
|
||||
**Then** activeIndex is 0, roundNumber is 2,
|
||||
and events are emitted in order: TurnAdvanced
|
||||
(previousCombatantId C, newCombatantId A, roundNumber 2) then
|
||||
RoundAdvanced (newRoundNumber 2).
|
||||
|
||||
4. **Given** an encounter with combatants [A, B, C], activeIndex 2,
|
||||
roundNumber 5,
|
||||
**When** AdvanceTurn,
|
||||
**Then** activeIndex is 0, roundNumber is 6,
|
||||
and events are emitted in order: TurnAdvanced then RoundAdvanced
|
||||
(verifies round increment is not hardcoded to 2).
|
||||
|
||||
5. **Given** an encounter with a single combatant [A], activeIndex 0,
|
||||
roundNumber 1,
|
||||
**When** AdvanceTurn,
|
||||
**Then** activeIndex is 0, roundNumber is 2,
|
||||
and events are emitted in order: TurnAdvanced
|
||||
(previousCombatantId A, newCombatantId A, roundNumber 2) then
|
||||
RoundAdvanced (newRoundNumber 2).
|
||||
|
||||
6. **Given** an encounter with combatants [A, B], activeIndex 0,
|
||||
roundNumber 1,
|
||||
**When** AdvanceTurn is applied twice in sequence,
|
||||
**Then** after the first: activeIndex 1, roundNumber 1;
|
||||
after the second: activeIndex 0, roundNumber 2.
|
||||
|
||||
7. **Given** an encounter with an empty combatant list,
|
||||
**When** AdvanceTurn,
|
||||
**Then** the operation MUST fail with an invalid-encounter error.
|
||||
No events are emitted. State is unchanged.
|
||||
|
||||
8. **Given** an encounter with combatants [A, B, C], activeIndex 0,
|
||||
roundNumber 1,
|
||||
**When** AdvanceTurn is applied three times,
|
||||
**Then** the encounter completes a full round cycle:
|
||||
activeIndex returns to 0 and roundNumber is 2.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Empty combatant list: valid aggregate state, but AdvanceTurn MUST
|
||||
return a DomainError (no state change, no events).
|
||||
- Single combatant: every advance wraps and increments the round.
|
||||
- Large round numbers: no overflow or special-case behavior; round
|
||||
increments uniformly.
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-03-03
|
||||
|
||||
- Q: Should an encounter with zero combatants be a valid aggregate state? → A: Yes. Empty encounter is valid; AdvanceTurn returns DomainError.
|
||||
- Q: What is activeIndex when combatants list is empty? → A: activeIndex MUST be 0.
|
||||
- Q: Does this change any non-empty encounter behavior? → A: No. All existing acceptance scenarios and event contracts remain unchanged.
|
||||
|
||||
## Domain Model *(mandatory)*
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Combatant**: An identified participant in the encounter. For this
|
||||
feature, a combatant is an opaque identity (e.g., a name or id).
|
||||
The MVP baseline does not include HP, conditions, or stats.
|
||||
- **Encounter**: The aggregate root. Contains an ordered list of
|
||||
combatants (pre-sorted by initiative), an activeIndex pointing to
|
||||
the current combatant, and a roundNumber (positive integer,
|
||||
starting at 1).
|
||||
|
||||
### Domain Events
|
||||
|
||||
- **TurnAdvanced**: Emitted on every successful AdvanceTurn.
|
||||
Carries: previousCombatantId, newCombatantId, roundNumber.
|
||||
- **RoundAdvanced**: Emitted when activeIndex wraps past the last
|
||||
combatant. Carries: newRoundNumber.
|
||||
|
||||
When a round boundary is crossed, both TurnAdvanced and
|
||||
RoundAdvanced MUST be emitted in that order (TurnAdvanced first).
|
||||
This emission order is part of the observable domain contract and
|
||||
MUST be verified by tests.
|
||||
|
||||
### Invariants
|
||||
|
||||
- **INV-1**: An encounter MAY have zero combatants (an empty
|
||||
encounter is a valid aggregate state). AdvanceTurn on an empty
|
||||
encounter MUST return a DomainError with no state change and no
|
||||
events.
|
||||
- **INV-2**: If combatants.length > 0, activeIndex MUST satisfy
|
||||
0 <= activeIndex < combatants.length. If combatants.length == 0,
|
||||
activeIndex MUST be 0.
|
||||
- **INV-3**: roundNumber MUST be a positive integer (>= 1) and MUST
|
||||
only increase (never decrease or reset).
|
||||
- **INV-4**: AdvanceTurn MUST be a pure function of the current
|
||||
encounter state. Given identical input, output MUST be identical.
|
||||
- **INV-5**: Every successful AdvanceTurn MUST emit at least one
|
||||
domain event (TurnAdvanced). No silent state changes.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The domain MUST expose an AdvanceTurn operation that
|
||||
accepts an Encounter and returns the next Encounter state plus
|
||||
emitted domain events.
|
||||
- **FR-002**: AdvanceTurn MUST increment activeIndex by 1, wrapping
|
||||
to 0 when past the last combatant.
|
||||
- **FR-003**: When activeIndex wraps to 0, roundNumber MUST
|
||||
increment by 1.
|
||||
- **FR-004**: AdvanceTurn on an empty encounter MUST return an error
|
||||
without modifying state or emitting events.
|
||||
- **FR-005**: Domain events MUST be returned as values from the
|
||||
operation, not dispatched via side effects.
|
||||
|
||||
### Out of Scope (MVP baseline does not include)
|
||||
|
||||
- Initiative rolling or combatant ordering logic
|
||||
- Hit points, damage, conditions, or status effects
|
||||
- Adding or removing combatants mid-encounter
|
||||
- Persistence, serialization, or storage
|
||||
- UI, CLI, or any adapter layer
|
||||
- Agent behavior or suggestions
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: All 8 acceptance scenarios pass as deterministic,
|
||||
pure-function tests with no I/O dependencies.
|
||||
- **SC-002**: Invariants INV-1 through INV-5 are verified by tests.
|
||||
- **SC-003**: The domain module has zero imports from application,
|
||||
adapter, or agent layers (layer boundary compliance).
|
||||
@@ -1,128 +0,0 @@
|
||||
# Tasks: Advance Turn
|
||||
|
||||
**Input**: Design documents from `/specs/001-advance-turn/`
|
||||
**Prerequisites**: plan.md (required), spec.md (required)
|
||||
|
||||
**Organization**: Tasks follow the phased structure from plan.md. There is only one user story (US1 — Advance Turn, P1), so phases map directly to the plan's milestones.
|
||||
|
||||
## Format: `[ID] [P?] [Story?] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[US1]**: User Story 1 — Advance Turn
|
||||
- Exact file paths included in every task
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Milestone 1 — Tooling)
|
||||
|
||||
**Purpose**: Initialize pnpm monorepo, Biome, TypeScript, Vitest, and layer boundary enforcement
|
||||
|
||||
- [X] T001 Initialize pnpm workspace and root config — create `pnpm-workspace.yaml`, `.nvmrc` (Node 22), root `package.json` (with `packageManager` pinning pnpm 10.6 and scripts: check, test, lint, format, typecheck), `biome.json`, and `tsconfig.base.json` (strict, composite, path aliases)
|
||||
- [X] T002 [P] Create `packages/domain` package skeleton — `packages/domain/package.json` (`@initiative/domain`, no deps), `packages/domain/tsconfig.json` (extends base, composite), empty `packages/domain/src/index.ts`
|
||||
- [X] T003 [P] Create `packages/application` package skeleton — `packages/application/package.json` (`@initiative/application`, depends on `@initiative/domain`), `packages/application/tsconfig.json` (extends base, references domain), empty `packages/application/src/index.ts`
|
||||
- [X] T004 [P] Create `apps/web` package skeleton — `apps/web/package.json` (React 19, Vite 6.2, depends on both packages), `apps/web/tsconfig.json`, `apps/web/vite.config.ts`, `apps/web/index.html`, `apps/web/src/main.tsx`, `apps/web/src/App.tsx` (placeholder)
|
||||
- [X] T005 Configure Vitest — add `vitest` as root dev dependency, create `vitest.config.ts` at root (workspace mode or per-package), verify `pnpm test` exits 0
|
||||
- [X] T006 Create layer boundary check — `scripts/check-layer-boundaries.mjs` (scans domain/application for forbidden imports) and `packages/domain/src/__tests__/layer-boundaries.test.ts` (wraps script as Vitest test)
|
||||
|
||||
**Checkpoint**: `pnpm install` succeeds, `biome check .` runs, `tsc --build` compiles, `pnpm test` exits 0 with layer boundary test green.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Domain Implementation — User Story 1: Advance Turn (Priority: P1) (Milestone 1)
|
||||
|
||||
**Goal**: Implement the complete AdvanceTurn domain logic as a pure function with all 8 acceptance scenarios and invariant tests.
|
||||
|
||||
**Independent Test**: Pure state transition — given an Encounter value and AdvanceTurn action, assert resulting Encounter and emitted domain events. No I/O, persistence, or UI needed.
|
||||
|
||||
- [X] T007 [US1] Define domain types in `packages/domain/src/types.ts` — `CombatantId` (branded/opaque), `Combatant`, `Encounter` (combatants, activeIndex, roundNumber), factory `createEncounter` enforcing INV-1, INV-2, INV-3
|
||||
- [X] T008 [P] [US1] Define domain events in `packages/domain/src/events.ts` — `TurnAdvanced`, `RoundAdvanced`, `DomainEvent` union (plain data, no classes)
|
||||
- [X] T009 [US1] Implement `advanceTurn` in `packages/domain/src/advance-turn.ts` — pure function `(Encounter) => { encounter, events } | DomainError`, implements FR-001 through FR-005
|
||||
- [X] T010 [US1] Write tests for all 8 acceptance scenarios + invariants in `packages/domain/src/__tests__/advance-turn.test.ts` — scenarios 1–8, INV-1 through INV-5, event ordering on round wrap
|
||||
- [X] T011 [US1] Export public API from `packages/domain/src/index.ts` — re-export types, events, `advanceTurn`, `createEncounter`
|
||||
|
||||
**Checkpoint (Milestone 1)**: `pnpm check` passes (format + lint + typecheck + test + layer boundaries). All 8 scenarios + invariants green. No React/Vite imports in domain or application.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Application + Web Shell (Milestone 2)
|
||||
|
||||
**Goal**: Wire up the application use case and minimal React UI with a "Next Turn" button.
|
||||
|
||||
- [X] T012 Define port interface in `packages/application/src/ports.ts` — `EncounterStore` port: `get(): Encounter`, `save(e: Encounter)`
|
||||
- [X] T013 Implement `AdvanceTurnUseCase` in `packages/application/src/advance-turn-use-case.ts` — accepts `EncounterStore`, calls `advanceTurn`, saves result, returns events
|
||||
- [X] T014 Export public API from `packages/application/src/index.ts` — re-export use case and port types
|
||||
- [X] T015 Implement `useEncounter` hook in `apps/web/src/hooks/use-encounter.ts` — in-memory `EncounterStore` via React state, exposes encounter state + `advanceTurn` action, hardcoded 3-combatant demo
|
||||
- [X] T016 Wire up `apps/web/src/App.tsx` — display current combatant, round number, combatant list with active indicator, "Next Turn" button, emitted events
|
||||
|
||||
**Checkpoint (Milestone 2)**: `pnpm check` passes. `vite build` succeeds. Clicking "Next Turn" cycles combatants and increments rounds correctly.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Phase 1 (Setup)**: No dependencies — start immediately
|
||||
- **Phase 2 (Domain)**: Depends on Phase 1 completion
|
||||
- **Phase 3 (App + Web)**: Depends on Phase 2 completion (needs domain types and `advanceTurn`)
|
||||
|
||||
### Within Phase 1
|
||||
|
||||
- T001 must complete first (workspace and root config)
|
||||
- T002, T003, T004 can run in parallel [P] after T001
|
||||
- T005 depends on T001 (needs root package.json)
|
||||
- T006 depends on T002 and T005 (needs domain package + Vitest)
|
||||
|
||||
### Within Phase 2
|
||||
|
||||
- T007 must complete first (types needed by everything)
|
||||
- T008 can run in parallel [P] with T007 (events are independent types)
|
||||
- T009 depends on T007 and T008 (uses types and events)
|
||||
- T010 depends on T009 (tests the implementation)
|
||||
- T011 depends on T007, T008, T009 (exports all public API)
|
||||
|
||||
### Within Phase 3
|
||||
|
||||
- T012 first (port interface)
|
||||
- T013 depends on T012 (uses port)
|
||||
- T014 depends on T013 (exports use case)
|
||||
- T015 depends on T014 (uses application layer)
|
||||
- T016 depends on T015 (uses hook)
|
||||
|
||||
---
|
||||
|
||||
## Parallel Opportunities
|
||||
|
||||
```text
|
||||
# After T001 completes:
|
||||
T002, T003, T004 — all package skeletons in parallel
|
||||
|
||||
# After T007 starts:
|
||||
T008 — domain events can be written in parallel with types
|
||||
|
||||
# Independent stories: only one user story (US1), so parallelism is within-phase only
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (Milestone 1)
|
||||
|
||||
1. Complete Phase 1: Setup (T001–T006)
|
||||
2. Complete Phase 2: Domain (T007–T011)
|
||||
3. **STOP and VALIDATE**: `pnpm check` passes, all 8 scenarios green
|
||||
|
||||
### Full Feature (Milestone 2)
|
||||
|
||||
4. Complete Phase 3: App + Web Shell (T012–T016)
|
||||
5. **VALIDATE**: `pnpm check` passes, app runs in browser
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- All task IDs (T001–T016) match plan.md — no scope expansion
|
||||
- Single user story (US1: Advance Turn) — no cross-story dependencies
|
||||
- Tests (T010) are included as specified in plan.md and spec.md
|
||||
- Domain package must have zero React/Vite imports (enforced by T006)
|
||||
404
specs/001-combatant-management/spec.md
Normal file
404
specs/001-combatant-management/spec.md
Normal file
@@ -0,0 +1,404 @@
|
||||
# Feature Specification: Combatant Management
|
||||
|
||||
**Feature Branch**: `001-combatant-management`
|
||||
**Created**: 2026-03-03
|
||||
**Status**: Implemented
|
||||
|
||||
## Overview
|
||||
|
||||
Combatant Management covers the complete lifecycle of combatants within an encounter: adding (individually or in batch), editing, removing, clearing the entire encounter, persisting encounter state across page reloads, and the confirmation UX applied to all destructive actions.
|
||||
|
||||
---
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### Adding Combatants
|
||||
|
||||
**Story A1 — Add a single combatant (Priority: P1)**
|
||||
|
||||
A game master adds a new combatant to an existing encounter. The new combatant is appended to the end of the initiative order, allowing late-joining participants or newly discovered enemies to enter combat.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an empty encounter (no combatants, activeIndex 0, roundNumber 1), **When** AddCombatant with name "Gandalf", **Then** combatants is [Gandalf], activeIndex is 0, roundNumber is 1, and a CombatantAdded event is emitted with the new combatant's id, name "Gandalf", and position 0.
|
||||
|
||||
2. **Given** an encounter with combatants [A, B], activeIndex 0, roundNumber 1, **When** AddCombatant with name "C", **Then** combatants is [A, B, C], activeIndex is 0, roundNumber is 1, and a CombatantAdded event is emitted with position 2.
|
||||
|
||||
3. **Given** an encounter with combatants [A, B, C], activeIndex 2, roundNumber 3, **When** AddCombatant with name "D", **Then** combatants is [A, B, C, D], activeIndex is 2, roundNumber is 3, and a CombatantAdded event is emitted with position 3. The active combatant does not change.
|
||||
|
||||
4. **Given** an encounter with combatants [A], **When** AddCombatant is applied twice with names "B" then "C", **Then** combatants is [A, B, C] in that order. Each operation emits its own CombatantAdded event.
|
||||
|
||||
5. **Given** an encounter with combatants [A, B], **When** AddCombatant with an empty name "", **Then** the operation MUST fail with a validation error. No events are emitted. State is unchanged.
|
||||
|
||||
6. **Given** an encounter with combatants [A, B], **When** AddCombatant with a whitespace-only name " ", **Then** the operation MUST fail with a validation error. No events are emitted. State is unchanged.
|
||||
|
||||
---
|
||||
|
||||
> **Batch add and custom creature workflows** are defined in `specs/004-bestiary/spec.md` (Stories US-S2, US-S3). Those stories cover the bestiary search dropdown, count badge, batch confirm, and custom creature stat fields. This spec covers only the domain-level AddCombatant operation that those workflows invoke.
|
||||
|
||||
---
|
||||
|
||||
### Removing Combatants
|
||||
|
||||
**Story B1 — Remove a combatant from an active encounter (Priority: P1)**
|
||||
|
||||
A game master is running a combat encounter and a combatant is defeated or leaves. The GM removes that combatant. The combatant disappears from the initiative order and the turn continues correctly without disruption.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an encounter with combatants [A, B, C] and activeIndex 1 (B's turn), **When** the GM removes combatant C (index 2, after active), **Then** the encounter has [A, B], activeIndex remains 1, roundNumber unchanged, and a CombatantRemoved event is emitted.
|
||||
|
||||
2. **Given** an encounter with combatants [A, B, C] and activeIndex 2 (C's turn), **When** the GM removes combatant A (index 0, before active), **Then** the encounter has [B, C], activeIndex becomes 1 (still C's turn), roundNumber unchanged.
|
||||
|
||||
3. **Given** an encounter with combatants [A, B, C] and activeIndex 1 (B's turn), **When** the GM removes combatant B (the active combatant), **Then** the encounter has [A, C], activeIndex becomes 1 (C is now active), roundNumber unchanged.
|
||||
|
||||
4. **Given** an encounter with combatants [A, B, C] and activeIndex 2 (C's turn, last position), **When** the GM removes combatant C (active and last), **Then** the encounter has [A, B], activeIndex wraps to 0 (A is now active), roundNumber unchanged.
|
||||
|
||||
5. **Given** an encounter with combatants [A] and activeIndex 0, **When** the GM removes combatant A, **Then** the encounter has [], activeIndex is 0, roundNumber unchanged.
|
||||
|
||||
6. **Given** an encounter with combatants [A, B, C], **When** the GM attempts to remove a combatant with an ID that does not exist, **Then** a domain error is returned with code `"combatant-not-found"`, and the encounter is unchanged.
|
||||
|
||||
---
|
||||
|
||||
**Story B2 — Inline confirmation before removing (Priority: P1)**
|
||||
|
||||
A user clicking the remove (X) button on a combatant row is protected from accidental deletion by a two-step inline confirmation flow.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a combatant row is visible, **When** the user clicks the remove (X) button once, **Then** the button transitions to a confirm state showing a checkmark icon on a red/danger background with a scale pulse animation.
|
||||
|
||||
2. **Given** the remove button is in confirm state, **When** the user clicks it again, **Then** the combatant is removed from the encounter.
|
||||
|
||||
3. **Given** the remove button is in confirm state, **When** 5 seconds elapse without a second click, **Then** the button reverts to its original X icon and default styling.
|
||||
|
||||
4. **Given** the remove button is in confirm state, **When** the user clicks outside the button, **Then** the button reverts to its original state without removing the combatant.
|
||||
|
||||
5. **Given** the remove button is in confirm state, **When** the user presses Escape, **Then** the button reverts to its original state without removing the combatant.
|
||||
|
||||
6. **Given** a destructive button has keyboard focus, **When** the user presses Enter or Space, **Then** the button enters confirm state.
|
||||
|
||||
7. **Given** a destructive button is in confirm state with focus, **When** the user presses Enter or Space, **Then** the destructive action executes.
|
||||
|
||||
8. **Given** a destructive button is in confirm state with focus, **When** the user presses Escape, **Then** the button reverts to its original state.
|
||||
|
||||
9. **Given** a destructive button is in confirm state, **When** the button loses focus (e.g., Tab away), **Then** the button reverts to its original state.
|
||||
|
||||
---
|
||||
|
||||
### Editing Combatants
|
||||
|
||||
**Story C1 — Rename a combatant (Priority: P1)**
|
||||
|
||||
A user running an encounter realizes a combatant's name is misspelled or wants to change it. They select the combatant by its identity, provide a new name, and the system updates the combatant in-place while preserving turn order and round state.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an encounter with combatants [Alice, Bob], **When** the user updates Bob's name to "Robert", **Then** the encounter contains [Alice, Robert] and a `CombatantUpdated` event is emitted with the combatant's id, old name, and new name.
|
||||
|
||||
2. **Given** an encounter with combatants [Alice, Bob] where Bob is the active combatant, **When** the user updates Bob's name to "Robert", **Then** Bob remains the active combatant (active index unchanged) and the round number is preserved.
|
||||
|
||||
---
|
||||
|
||||
**Story C2 — Error feedback on invalid edit (Priority: P2)**
|
||||
|
||||
A user attempts to edit a combatant that no longer exists or provides an invalid name. The system returns a clear error without modifying the encounter.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an encounter with combatants [Alice, Bob], **When** the user attempts to update a combatant with a non-existent id, **Then** the system returns a "combatant not found" error and the encounter is unchanged.
|
||||
|
||||
2. **Given** an encounter with combatants [Alice, Bob], **When** the user attempts to update Alice's name to an empty string, **Then** the system returns an "invalid name" error and the encounter is unchanged.
|
||||
|
||||
3. **Given** an encounter with combatants [Alice, Bob], **When** the user attempts to update Alice's name to a whitespace-only string, **Then** the system returns an "invalid name" error and the encounter is unchanged.
|
||||
|
||||
---
|
||||
|
||||
**Story C3 — Rename trigger UX (Priority: P1)**
|
||||
|
||||
A user wants to rename a combatant. Single-clicking the name opens the stat block panel instead of entering edit mode. To rename, the user double-clicks the name or long-presses on touch devices. A `cursor-text` cursor on hover signals that the name is editable.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a combatant row is visible, **When** the user single-clicks the combatant name, **Then** the stat block panel opens or toggles — inline edit mode is NOT entered.
|
||||
|
||||
2. **Given** a combatant row is visible, **When** the user double-clicks the combatant name, **Then** inline edit mode is entered for that combatant's name.
|
||||
|
||||
3. **Given** a combatant row is visible on a pointer device, **When** the user hovers over the combatant name, **Then** the cursor changes to a text cursor (`cursor-text`) to signal editability.
|
||||
|
||||
4. **Given** a combatant row is visible on a touch device, **When** the user long-presses the combatant name, **Then** inline edit mode is entered for that combatant's name.
|
||||
|
||||
7. **Given** inline edit mode has been entered (via any trigger), **When** the user types a new name and presses Enter or blurs the field, **Then** the name is committed. **When** the user presses Escape, **Then** the edit is cancelled and the original name is restored.
|
||||
|
||||
---
|
||||
|
||||
### Clearing the Encounter
|
||||
|
||||
**Story D1 — Clear encounter to start fresh (Priority: P1)**
|
||||
|
||||
As a DM who has just finished a combat encounter, I want to clear the entire encounter with a single confirmed action so that I can quickly set up a new combat without manually removing each combatant one by one.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an encounter with multiple combatants at round 3, **When** the user activates the clear encounter action and confirms, **Then** all combatants are removed, the round number resets to 1, and the active turn index resets to 0.
|
||||
|
||||
2. **Given** an encounter with a single combatant, **When** the user activates the clear encounter action and confirms, **Then** the encounter is fully cleared.
|
||||
|
||||
3. **Given** an encounter has no combatants, **When** the user views the clear button, **Then** it is disabled and cannot be activated.
|
||||
|
||||
---
|
||||
|
||||
**Story D2 — Inline confirmation before clearing (Priority: P1)**
|
||||
|
||||
A user clicks the trash button to clear the entire encounter. Instead of a browser confirm dialog, the trash button itself transitions into a red confirm state with a checkmark icon and a scale pulse. A second click clears the encounter; otherwise the button reverts after 5 seconds or on dismiss.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an encounter has combatants, **When** the user clicks the clear encounter (trash) button once, **Then** the button transitions to a confirm state with a checkmark icon on a red/danger background with a scale pulse animation.
|
||||
|
||||
2. **Given** the trash button is in confirm state, **When** the user clicks it again, **Then** the entire encounter is cleared.
|
||||
|
||||
3. **Given** the trash button is in confirm state, **When** 5 seconds pass, the user clicks outside, or the user presses Escape, **Then** the button reverts to its original trash icon and default styling without clearing the encounter.
|
||||
|
||||
4. **Given** a confirmation prompt is displayed, **When** the user cancels, **Then** the encounter remains unchanged.
|
||||
|
||||
---
|
||||
|
||||
### Persistence
|
||||
|
||||
**Story E1 — Encounter survives page reload (Priority: P1)**
|
||||
|
||||
A user is managing a combat encounter. They accidentally refresh the page or their browser restarts. When the page reloads, the encounter is restored exactly as it was — same combatants, same active turn, same round number.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an encounter with combatants, active turn, and round number, **When** the user reloads the page, **Then** the encounter is restored with all state intact.
|
||||
|
||||
2. **Given** an encounter that has been modified (combatant added, removed, or renamed), **When** the user reloads the page, **Then** the latest state is reflected.
|
||||
|
||||
3. **Given** the user advances the turn multiple times, **When** the user reloads the page, **Then** the active turn and round number are preserved.
|
||||
|
||||
---
|
||||
|
||||
**Story E2 — Fresh start with no saved data (Priority: P2)**
|
||||
|
||||
A first-time user opens the application with no previously saved encounter. The application shows the default demo encounter so the user can immediately start exploring.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** no saved encounter exists in the browser, **When** the user opens the application, **Then** the default demo encounter is displayed.
|
||||
|
||||
2. **Given** saved encounter data has been manually cleared from the browser, **When** the user opens the application, **Then** the default demo encounter is displayed.
|
||||
|
||||
---
|
||||
|
||||
**Story E3 — Graceful handling of corrupt data (Priority: P3)**
|
||||
|
||||
Saved data may become invalid (e.g., manually edited in dev tools, schema changes between versions). The application handles this gracefully rather than crashing.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the saved encounter data is malformed or unparseable, **When** the user opens the application, **Then** the default demo encounter is displayed and the corrupt data is discarded.
|
||||
|
||||
2. **Given** the saved data is missing required fields, **When** the user opens the application, **Then** the default demo encounter is displayed.
|
||||
|
||||
---
|
||||
|
||||
## Domain Model
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Combatant**: An identified participant with a unique `CombatantId` (branded string), a required non-empty `name`, and optional `initiative`, `maxHp`, `currentHp`, `ac`, `conditions`, `isConcentrating`, and `creatureId` fields.
|
||||
- **Encounter**: The aggregate root. Contains an ordered `readonly` list of combatants, an `activeIndex` (zero-based integer), and a `roundNumber` (positive integer, starting at 1).
|
||||
> Queued Creature and Custom Creature Input entities are defined in `specs/004-bestiary/spec.md`.
|
||||
|
||||
### Domain Events
|
||||
|
||||
- **CombatantAdded**: Emitted on every successful AddCombatant. Carries: `combatantId`, `name`, `position` (zero-based index).
|
||||
- **CombatantRemoved**: Emitted on every successful RemoveCombatant. Carries: `combatantId`, `name`.
|
||||
- **CombatantUpdated**: Emitted on every successful EditCombatant. Carries: `combatantId`, `oldName`, `newName`.
|
||||
|
||||
### Invariants
|
||||
|
||||
- **INV-1**: An encounter MAY have zero combatants (after clearing or removing the last combatant).
|
||||
- **INV-2**: If `combatants.length > 0`, `activeIndex` MUST satisfy `0 <= activeIndex < combatants.length`. If `combatants.length == 0`, `activeIndex` MUST be 0.
|
||||
- **INV-3**: `roundNumber` MUST be a positive integer (>= 1) and MUST only increase during normal turn advancement. Clearing resets it to 1.
|
||||
- **INV-4**: `CombatantId` values MUST be unique within an encounter.
|
||||
- **INV-5**: All domain state transitions (add, remove, edit, clear) are pure functions; no I/O, randomness, or clocks.
|
||||
- **INV-6**: Every successful state transition emits exactly one corresponding domain event. No silent state changes.
|
||||
- **INV-7**: AddCombatant and RemoveCombatant MUST NOT change the `roundNumber`.
|
||||
- **INV-8**: EditCombatant MUST NOT change `activeIndex`, `roundNumber`, or the combatant's position in the list.
|
||||
|
||||
---
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
#### FR-001 — Add: Append combatant
|
||||
The domain MUST expose an AddCombatant operation that accepts an Encounter and a combatant name (plus a pre-generated `CombatantId`), and returns the updated Encounter plus emitted domain events. The new combatant MUST be appended to the end of the combatants list.
|
||||
|
||||
#### FR-002 — Add: Reject invalid names
|
||||
AddCombatant MUST reject empty or whitespace-only names by returning a `DomainError` without modifying state or emitting events. Name validation trims whitespace; a name that is empty after trimming is invalid.
|
||||
|
||||
#### FR-003 — Add: Preserve activeIndex and roundNumber
|
||||
AddCombatant MUST NOT alter the `activeIndex` or `roundNumber` of the encounter.
|
||||
|
||||
#### FR-004 — Add: Unique CombatantId
|
||||
AddCombatant MUST assign a unique `CombatantId` to the new combatant. Id generation is the caller's responsibility (application layer), keeping the domain function pure.
|
||||
|
||||
#### FR-005 — Add: Duplicate names allowed
|
||||
Duplicate combatant names are permitted. Combatants are distinguished solely by their unique `CombatantId`.
|
||||
|
||||
#### FR-006 — Add: UI form
|
||||
The UI MUST provide an add-combatant form accessible from the bottom bar. The search field MUST display action-oriented placeholder text (e.g., "Search creatures to add...").
|
||||
|
||||
> FR-007 through FR-013 (batch add and custom creature) are defined in `specs/004-bestiary/spec.md` (FR-007–FR-015).
|
||||
|
||||
#### FR-014 — Remove: Domain operation
|
||||
The domain MUST expose a RemoveCombatant operation that accepts an Encounter and a `CombatantId`, and returns the updated Encounter plus emitted domain events.
|
||||
|
||||
#### FR-015 — Remove: Error on unknown ID
|
||||
RemoveCombatant MUST return a domain error with code `"combatant-not-found"` when the given `CombatantId` does not match any combatant in the encounter.
|
||||
|
||||
#### FR-016 — Remove: activeIndex adjustment
|
||||
RemoveCombatant MUST adjust `activeIndex` according to these rules:
|
||||
- Removed combatant is **after** the active one: `activeIndex` unchanged.
|
||||
- Removed combatant is **before** the active one: `activeIndex` decrements by 1.
|
||||
- Removed combatant **is** the active one and is not last: `activeIndex` stays at the same integer value (the next combatant in line becomes active).
|
||||
- Removed combatant **is** the active one and **is last**: `activeIndex` wraps to 0.
|
||||
- Last remaining combatant is removed (encounter becomes empty): `activeIndex` is set to 0.
|
||||
|
||||
#### FR-017 — Remove: roundNumber preserved
|
||||
RemoveCombatant MUST preserve `roundNumber` unchanged.
|
||||
|
||||
#### FR-018 — Remove: UI control
|
||||
The UI MUST provide a remove control for each combatant row.
|
||||
|
||||
#### FR-019 — Remove: ConfirmButton
|
||||
The remove control MUST use the `ConfirmButton` two-step confirmation pattern (see FR-025 through FR-030). Silent no-op on domain error (combatant already gone).
|
||||
|
||||
#### FR-020 — Edit: Domain operation
|
||||
The domain MUST expose an EditCombatant operation that accepts an Encounter, a `CombatantId`, and a new name, and returns the updated Encounter plus emitted domain events.
|
||||
|
||||
#### FR-021 — Edit: Error on unknown ID
|
||||
EditCombatant MUST return a `"combatant-not-found"` error when the provided id does not match any combatant.
|
||||
|
||||
#### FR-022 — Edit: Reject invalid names
|
||||
EditCombatant MUST return an `"invalid-name"` error when the new name is empty or whitespace-only. The same trimming rules as AddCombatant apply.
|
||||
|
||||
#### FR-023 — Edit: Preserve position and counters
|
||||
EditCombatant MUST preserve the combatant's position in the list, `activeIndex`, and `roundNumber`. Setting a name to the same value it already has is treated as a valid update; a `CombatantUpdated` event is still emitted.
|
||||
|
||||
#### FR-024 — Edit: UI
|
||||
The UI MUST provide an inline name-edit mechanism for each combatant, activated by double-clicking the name or long-pressing on touch devices. The name MUST display a `cursor-text` cursor on hover to signal editability. Single-clicking the name MUST open/toggle the stat block panel, not enter edit mode. The updated name MUST be immediately visible after submission.
|
||||
|
||||
#### FR-025 — ConfirmButton: Reusable component
|
||||
The system MUST provide a reusable `ConfirmButton` component that wraps any icon button to add a two-step confirmation flow.
|
||||
|
||||
#### FR-026 — ConfirmButton: Confirm state on first activation
|
||||
On first activation (click, Enter, or Space), the button MUST transition to a confirm state displaying a checkmark icon on a red/danger background with a scale pulse animation.
|
||||
|
||||
#### FR-027 — ConfirmButton: Auto-revert after 5 seconds
|
||||
The button MUST automatically revert to its original state after 5 seconds if not confirmed.
|
||||
|
||||
#### FR-028 — ConfirmButton: Cancel on outside click, Escape, or focus loss
|
||||
Clicking outside the button, pressing Escape, or moving focus away MUST cancel the confirm state and revert the button.
|
||||
|
||||
#### FR-029 — ConfirmButton: Execute on second activation
|
||||
A second activation (click, Enter, or Space) while in confirm state MUST execute the destructive action.
|
||||
|
||||
#### FR-030 — ConfirmButton: Independent state per instance
|
||||
Each `ConfirmButton` instance MUST manage its confirm state independently of other instances.
|
||||
|
||||
#### FR-031 — Clear: Domain operation
|
||||
The domain MUST expose a ClearEncounter operation that removes all combatants, resets `roundNumber` to 1, and resets `activeIndex` to 0.
|
||||
|
||||
#### FR-032 — Clear: UI button with ConfirmButton
|
||||
The UI MUST provide a clear encounter button that uses the `ConfirmButton` pattern. The button MUST be disabled when the encounter has no combatants.
|
||||
|
||||
#### FR-033 — Clear: Cancellation leaves state unchanged
|
||||
Cancelling the confirmation (via timeout, outside click, Escape, or focus loss) MUST leave the encounter completely unchanged.
|
||||
|
||||
#### FR-034 — Clear: Cleared state persisted
|
||||
After clearing, the empty encounter state MUST be persisted so that a page refresh does not restore the previous encounter.
|
||||
|
||||
#### FR-035 — Persistence: Save on every change
|
||||
The system MUST save the full encounter state (combatants, `activeIndex`, `roundNumber`) to browser `localStorage` after every state change.
|
||||
|
||||
#### FR-036 — Persistence: Restore on load
|
||||
The system MUST restore the saved encounter state when the application loads, if valid saved data exists.
|
||||
|
||||
#### FR-037 — Persistence: Fallback to demo encounter
|
||||
The system MUST fall back to the default demo encounter when no saved data exists or saved data is invalid/corrupt.
|
||||
|
||||
#### FR-038 — Persistence: No crash on storage failure
|
||||
The system MUST NOT crash or show an error to the user when storage is unavailable or data is corrupt. When storage is unavailable, the application falls back to in-memory-only behavior.
|
||||
|
||||
#### FR-039 — Persistence: Preserve combatant identity across reloads
|
||||
The system MUST preserve combatant `CombatantId` values, names, and any other persisted fields across reloads, so that new combatants added after a reload do not collide with existing IDs.
|
||||
|
||||
#### FR-040 — Domain events as values
|
||||
All domain events MUST be returned as plain data values from operations, not dispatched via side effects.
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- **Empty name**: AddCombatant and EditCombatant return a `DomainError`; state and events are unchanged.
|
||||
- **Whitespace-only name**: Treated identically to empty name after trimming.
|
||||
- **Adding to an empty encounter**: The new combatant becomes the first and only participant; `activeIndex` remains 0.
|
||||
- **Adding during mid-round**: `activeIndex` is never shifted by an add operation.
|
||||
- **Duplicate combatant names**: Permitted. Combatants are distinguished by `CombatantId`.
|
||||
- **Removing the last combatant**: Encounter becomes empty; `activeIndex` is set to 0.
|
||||
- **Removing with unknown ID**: Returns `"combatant-not-found"` error; state unchanged. Removing the same ID twice: second call returns an error.
|
||||
- **Removing from empty encounter**: Covered by the unknown-ID error (no IDs exist).
|
||||
- **Editing a combatant to the same name**: Valid; `CombatantUpdated` event is still emitted.
|
||||
- **Editing a combatant in an empty encounter**: Returns `"combatant-not-found"` error.
|
||||
- **Clearing an already empty encounter**: The clear button is disabled; no operation is executed.
|
||||
- **Clearing and reloading**: The empty (cleared) state is persisted; the previous encounter is not restored.
|
||||
- **Storage quota exceeded**: Persistence silently fails; current in-memory session continues normally.
|
||||
- **Multiple browser tabs**: MVP baseline does not include cross-tab synchronization. Each tab operates independently; the last tab to save wins.
|
||||
> Batch add and custom creature edge cases are defined in `specs/004-bestiary/spec.md`.
|
||||
- **ConfirmButton: rapid triple-click**: First click enters confirm state; second executes the action; subsequent clicks are no-ops.
|
||||
- **ConfirmButton: component unmounts in confirm state**: The auto-revert timer MUST be cleaned up to prevent memory leaks or stale state updates.
|
||||
- **ConfirmButton: two instances in confirm state simultaneously**: Each manages its own state independently.
|
||||
- **ConfirmButton: combatant row re-renders while in confirm state**: Confirm state persists through re-renders as long as combatant identity is stable.
|
||||
- **Name single-click vs double-click**: A single click on the combatant name opens the stat block panel; only a completed double-click enters inline edit mode. The system must disambiguate between the two gestures.
|
||||
- **Touch edit affordance**: No hover-dependent affordance is shown on touch devices. Long-press is the touch equivalent for entering edit mode.
|
||||
- **Long-press threshold**: The long-press duration should follow platform conventions (typically ~500ms). A short tap must not trigger edit mode.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
- **SC-001**: All add-combatant acceptance scenarios pass as deterministic, pure-function tests with no I/O dependencies.
|
||||
- **SC-002**: Adding a combatant to an encounter preserves all existing combatants, their order, `activeIndex`, and `roundNumber` unchanged.
|
||||
- **SC-003**: All six remove-combatant acceptance scenarios pass as automated tests covering every `activeIndex` adjustment rule.
|
||||
- **SC-004**: The round number never changes as a result of a remove operation.
|
||||
- **SC-005**: Users can rename any combatant in the encounter in a single action without disrupting turn order, active combatant, or round number.
|
||||
- **SC-006**: Invalid edit attempts (missing combatant, empty or whitespace-only name) produce a domain error with no state change and no emitted events.
|
||||
- **SC-007**: All destructive actions (remove combatant, clear encounter) require exactly two deliberate user interactions to execute, eliminating single-click accidental mutations.
|
||||
- **SC-008**: The `ConfirmButton` confirm state auto-reverts reliably after 5 seconds. All confirmation flows are fully operable via keyboard alone.
|
||||
> SC-009 and SC-010 (batch add and custom creature success criteria) are defined in `specs/004-bestiary/spec.md`.
|
||||
- **SC-011**: Users can reload the page and see their encounter fully restored, with zero data loss.
|
||||
- **SC-012**: First-time users see the demo encounter immediately on first visit with no extra steps.
|
||||
- **SC-013**: 100% of corrupt or missing data scenarios result in a usable application (demo encounter displayed), never a crash or blank screen.
|
||||
- **SC-014**: After clearing, the encounter tracker displays an empty state with round and turn counters at their initial values, and this state persists across page refreshes.
|
||||
- **SC-015**: The domain module has zero imports from the application, adapter, or UI layers (layer boundary compliance verified by automated check).
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- `CombatantId` generation is the caller's responsibility (application layer), keeping domain functions pure and deterministic.
|
||||
- Name validation trims whitespace; a name that is empty after trimming is invalid.
|
||||
- No uniqueness constraint on combatant names — multiple combatants may share the same name.
|
||||
- Clearing results in an empty encounter state (no combatants, `roundNumber` 1, `activeIndex` 0). The user will then add new combatants using the existing add-combatant flow.
|
||||
- MVP baseline does not include undo/restore functionality after clearing or removing. Once confirmed, the action is final.
|
||||
- MVP baseline does not include encounter history or the ability to save/archive encounters before clearing.
|
||||
- A single `localStorage` key is sufficient for the MVP (one encounter at a time).
|
||||
- Cross-tab synchronization is not required for the MVP baseline.
|
||||
- The `ConfirmButton` 5-second timeout is a fixed value and is not configurable in the MVP baseline.
|
||||
- The `Check` icon from the Lucide icon library is used for the `ConfirmButton` confirm state.
|
||||
- The inline name-edit mechanism is activated by double-click or long-press (touch). A `cursor-text` cursor on hover signals editability. Single-clicking the name opens the stat block panel.
|
||||
@@ -1,35 +0,0 @@
|
||||
# Specification Quality Checklist: Add Combatant
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-03
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||
- Assumption documented: CombatantId is passed in rather than generated internally, keeping domain pure.
|
||||
@@ -1,77 +0,0 @@
|
||||
# Data Model: Add Combatant
|
||||
|
||||
**Feature**: 002-add-combatant
|
||||
**Date**: 2026-03-03
|
||||
|
||||
## Entities
|
||||
|
||||
### Combatant (existing, unchanged)
|
||||
|
||||
| Field | Type | Constraints |
|
||||
|-------|------|-------------|
|
||||
| id | CombatantId (branded string) | Unique, required |
|
||||
| name | string | Non-empty after trimming, required |
|
||||
|
||||
### Encounter (existing, unchanged)
|
||||
|
||||
| Field | Type | Constraints |
|
||||
|-------|------|-------------|
|
||||
| combatants | readonly Combatant[] | Ordered list, may be empty |
|
||||
| activeIndex | number | 0 <= activeIndex < combatants.length (or 0 if empty) |
|
||||
| roundNumber | number | Positive integer >= 1, only increases |
|
||||
|
||||
## Domain Events
|
||||
|
||||
### CombatantAdded (new)
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| type | "CombatantAdded" (literal) | Discriminant for the DomainEvent union |
|
||||
| combatantId | CombatantId | Id of the newly added combatant |
|
||||
| name | string | Name of the newly added combatant |
|
||||
| position | number | Zero-based index where the combatant was placed |
|
||||
|
||||
## State Transitions
|
||||
|
||||
### AddCombatant
|
||||
|
||||
**Input**: Encounter + CombatantId + name (string)
|
||||
|
||||
**Preconditions**:
|
||||
- Name must be non-empty after trimming
|
||||
|
||||
**Transition**:
|
||||
- New combatant `{ id, name: trimmedName }` appended to end of combatants list
|
||||
- activeIndex unchanged
|
||||
- roundNumber unchanged
|
||||
|
||||
**Postconditions**:
|
||||
- combatants.length increased by 1
|
||||
- New combatant is at index `combatants.length - 1`
|
||||
- All existing combatants preserve their order and index positions
|
||||
- INV-2 satisfied (activeIndex still valid for the now-larger list)
|
||||
|
||||
**Events emitted**: Exactly one `CombatantAdded`
|
||||
|
||||
**Error cases**:
|
||||
- Empty or whitespace-only name → DomainError `{ code: "invalid-name" }`
|
||||
|
||||
## Function Signatures
|
||||
|
||||
### Domain Layer
|
||||
|
||||
```
|
||||
addCombatant(encounter, id, name) → { encounter, events } | DomainError
|
||||
```
|
||||
|
||||
### Application Layer
|
||||
|
||||
```
|
||||
addCombatantUseCase(store, id, name) → DomainEvent[] | DomainError
|
||||
```
|
||||
|
||||
## Validation Rules
|
||||
|
||||
| Rule | Layer | Error Code |
|
||||
|------|-------|------------|
|
||||
| Name non-empty after trim | Domain | invalid-name |
|
||||
@@ -1,76 +0,0 @@
|
||||
# Implementation Plan: Add Combatant
|
||||
|
||||
**Branch**: `002-add-combatant` | **Date**: 2026-03-03 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/002-add-combatant/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Add a pure domain function `addCombatant` that appends a new combatant to the end of an encounter's combatant list without altering the active turn or round. The feature follows the same pattern as `advanceTurn`: a pure function returning updated state plus domain events, with an application-layer use case and a React adapter hook.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.x (strict mode, verbatimModuleSyntax)
|
||||
**Primary Dependencies**: None for domain; React 19 for web adapter
|
||||
**Storage**: In-memory (React state via hook)
|
||||
**Testing**: Vitest
|
||||
**Target Platform**: Browser (Vite dev server)
|
||||
**Project Type**: Monorepo (pnpm workspaces): domain library + application library + web app
|
||||
**Performance Goals**: N/A (pure synchronous function)
|
||||
**Constraints**: Domain must remain pure — no I/O, no randomness
|
||||
**Scale/Scope**: Single-user local app
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| I. Deterministic Domain Core | PASS | `addCombatant` is a pure function. CombatantId is passed in as input, not generated internally. |
|
||||
| II. Layered Architecture | PASS | Domain function in `packages/domain`, use case in `packages/application`, hook in `apps/web`. No reverse imports. |
|
||||
| III. Agent Boundary | PASS | No agent layer involvement in this feature. |
|
||||
| IV. Clarification-First | PASS | Spec has no NEEDS CLARIFICATION markers. Key assumption (id passed in) is documented. |
|
||||
| V. Escalation Gates | PASS | Implementation stays within spec scope. |
|
||||
| VI. MVP Baseline Language | PASS | Out-of-scope items use "MVP baseline does not include". |
|
||||
| VII. No Gameplay Rules | PASS | No gameplay mechanics in constitution. |
|
||||
|
||||
All gates pass. No violations to justify.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/002-add-combatant/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── quickstart.md # Phase 1 output
|
||||
└── tasks.md # Phase 2 output (via /speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
packages/domain/src/
|
||||
├── types.ts # Encounter, Combatant, CombatantId (existing)
|
||||
├── events.ts # DomainEvent union (add CombatantAdded)
|
||||
├── add-combatant.ts # NEW: addCombatant pure function
|
||||
├── advance-turn.ts # Existing (unchanged)
|
||||
├── index.ts # Re-exports (add new exports)
|
||||
└── __tests__/
|
||||
├── advance-turn.test.ts # Existing (unchanged)
|
||||
└── add-combatant.test.ts # NEW: acceptance + invariant tests
|
||||
|
||||
packages/application/src/
|
||||
├── ports.ts # EncounterStore (unchanged)
|
||||
├── add-combatant-use-case.ts # NEW: orchestrates addCombatant
|
||||
├── advance-turn-use-case.ts # Existing (unchanged)
|
||||
└── index.ts # Re-exports (add new exports)
|
||||
|
||||
apps/web/src/
|
||||
├── App.tsx # Update: add combatant input + button
|
||||
└── hooks/
|
||||
└── use-encounter.ts # Update: expose addCombatant callback
|
||||
```
|
||||
|
||||
**Structure Decision**: Follows the established monorepo layout. Each domain operation gets its own file (matching `advance-turn.ts` pattern). No new packages or directories needed beyond the existing structure.
|
||||
@@ -1,47 +0,0 @@
|
||||
# Quickstart: Add Combatant
|
||||
|
||||
**Feature**: 002-add-combatant
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm test:watch # Watch all tests
|
||||
pnpm vitest run packages/domain/src/__tests__/add-combatant.test.ts # Run feature tests
|
||||
pnpm --filter web dev # Dev server at localhost:5173
|
||||
```
|
||||
|
||||
## Merge Gate
|
||||
|
||||
```bash
|
||||
pnpm check # Must pass before commit (format + lint + typecheck + test)
|
||||
```
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Domain event** — Add `CombatantAdded` to `events.ts` and the `DomainEvent` union
|
||||
2. **Domain function** — Create `add-combatant.ts` with the pure `addCombatant` function
|
||||
3. **Domain exports** — Update `index.ts` to re-export new items
|
||||
4. **Domain tests** — Create `add-combatant.test.ts` with all 6 acceptance scenarios + invariant checks
|
||||
5. **Application use case** — Create `add-combatant-use-case.ts`
|
||||
6. **Application exports** — Update `index.ts` to re-export
|
||||
7. **Web hook** — Update `use-encounter.ts` to expose `addCombatant` callback
|
||||
8. **Web UI** — Update `App.tsx` with name input and add button
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Action | Purpose |
|
||||
|------|--------|---------|
|
||||
| `packages/domain/src/events.ts` | Edit | Add CombatantAdded event type |
|
||||
| `packages/domain/src/add-combatant.ts` | Create | Pure addCombatant function |
|
||||
| `packages/domain/src/index.ts` | Edit | Export new items |
|
||||
| `packages/domain/src/__tests__/add-combatant.test.ts` | Create | Acceptance + invariant tests |
|
||||
| `packages/application/src/add-combatant-use-case.ts` | Create | Use case orchestration |
|
||||
| `packages/application/src/index.ts` | Edit | Export new use case |
|
||||
| `apps/web/src/hooks/use-encounter.ts` | Edit | Add combatant hook callback |
|
||||
| `apps/web/src/App.tsx` | Edit | Name input + add button UI |
|
||||
@@ -1,40 +0,0 @@
|
||||
# Research: Add Combatant
|
||||
|
||||
**Feature**: 002-add-combatant
|
||||
**Date**: 2026-03-03
|
||||
|
||||
## Research Summary
|
||||
|
||||
No NEEDS CLARIFICATION items existed in the technical context. The feature is straightforward and follows established patterns. Research focused on confirming existing patterns and the one key design decision.
|
||||
|
||||
## Decision 1: CombatantId Generation Strategy
|
||||
|
||||
**Decision**: CombatantId is passed into the domain function as an argument, not generated internally.
|
||||
|
||||
**Rationale**: The domain layer must remain pure and deterministic (Constitution Principle I). Generating IDs internally would require either randomness (UUID) or side effects (counter with mutable state), both of which violate purity. By accepting the id as input, `addCombatant(encounter, id, name)` is a pure function: same inputs always produce the same output.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Generate UUID inside domain: Violates deterministic core principle. Tests would be non-deterministic.
|
||||
- Pass an id-generator function: Adds unnecessary complexity. The application layer can generate the id and pass it in.
|
||||
|
||||
**Who generates the id**: The application layer (use case) or adapter layer (hook) generates the CombatantId before calling the domain function. This matches how `createEncounter` already works — callers construct `Combatant` objects with pre-assigned ids.
|
||||
|
||||
## Decision 2: Function Signature Pattern
|
||||
|
||||
**Decision**: Follow the `advanceTurn` pattern — standalone pure function returning a success result or DomainError.
|
||||
|
||||
**Rationale**: Consistency with the existing codebase. `advanceTurn` returns `AdvanceTurnSuccess | DomainError`, so `addCombatant` will return `AddCombatantSuccess | DomainError` with the same shape: `{ encounter, events }`.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Method on an Encounter class: Project uses plain interfaces and free functions, not classes.
|
||||
- Mutating the encounter in place: Violates immutability convention (all fields are `readonly`).
|
||||
|
||||
## Decision 3: Name Validation Approach
|
||||
|
||||
**Decision**: Trim whitespace, then reject empty strings. The domain function validates the name.
|
||||
|
||||
**Rationale**: Name validation is a domain rule (what constitutes a valid combatant name), so it belongs in the domain layer. Trimming before checking prevents whitespace-only names from slipping through.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Validate in application layer: Would allow invalid data to reach domain if called from a different adapter. Domain should protect its own invariants.
|
||||
- Accept any string: Would allow empty-name combatants, violating spec FR-004.
|
||||
@@ -1,161 +0,0 @@
|
||||
# Feature Specification: Add Combatant
|
||||
|
||||
**Feature Branch**: `002-add-combatant`
|
||||
**Created**: 2026-03-03
|
||||
**Status**: Draft
|
||||
**Input**: User description: "let us add a spec for the option to add a combatant to the encounter. a new combatant is added to the end of the list."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Add Combatant to Encounter (Priority: P1)
|
||||
|
||||
A game master adds a new combatant to an existing encounter. The new
|
||||
combatant is appended to the end of the initiative order. This allows
|
||||
late-joining participants or newly discovered enemies to enter combat.
|
||||
|
||||
**Why this priority**: Adding combatants is the foundational mutation
|
||||
for populating an encounter. Without it, the encounter has no
|
||||
participants and no other feature (turn advancement, removal) is useful.
|
||||
|
||||
**Independent Test**: Can be fully tested as a pure state transition
|
||||
with no I/O, persistence, or UI. Given an Encounter value and an
|
||||
AddCombatant action with a name, assert the resulting Encounter value
|
||||
and emitted domain events.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an empty encounter (no combatants, activeIndex 0,
|
||||
roundNumber 1),
|
||||
**When** AddCombatant with name "Gandalf",
|
||||
**Then** combatants is [Gandalf], activeIndex is 0,
|
||||
roundNumber is 1,
|
||||
and a CombatantAdded event is emitted with the new combatant's
|
||||
id and name "Gandalf" and position 0.
|
||||
|
||||
2. **Given** an encounter with combatants [A, B], activeIndex 0,
|
||||
roundNumber 1,
|
||||
**When** AddCombatant with name "C",
|
||||
**Then** combatants is [A, B, C], activeIndex is 0,
|
||||
roundNumber is 1,
|
||||
and a CombatantAdded event is emitted with position 2.
|
||||
|
||||
3. **Given** an encounter with combatants [A, B, C], activeIndex 2,
|
||||
roundNumber 3,
|
||||
**When** AddCombatant with name "D",
|
||||
**Then** combatants is [A, B, C, D], activeIndex is 2,
|
||||
roundNumber is 3,
|
||||
and a CombatantAdded event is emitted with position 3.
|
||||
The active combatant does not change.
|
||||
|
||||
4. **Given** an encounter with combatants [A],
|
||||
**When** AddCombatant is applied twice with names "B" then "C",
|
||||
**Then** combatants is [A, B, C] in that order.
|
||||
Each operation emits its own CombatantAdded event.
|
||||
|
||||
5. **Given** an encounter with combatants [A, B],
|
||||
**When** AddCombatant with an empty name "",
|
||||
**Then** the operation MUST fail with a validation error.
|
||||
No events are emitted. State is unchanged.
|
||||
|
||||
6. **Given** an encounter with combatants [A, B],
|
||||
**When** AddCombatant with a whitespace-only name " ",
|
||||
**Then** the operation MUST fail with a validation error.
|
||||
No events are emitted. State is unchanged.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Empty name or whitespace-only name: AddCombatant MUST return a
|
||||
DomainError (no state change, no events).
|
||||
- Adding to an empty encounter: the new combatant becomes the first
|
||||
and only participant; activeIndex remains 0.
|
||||
- Adding during mid-round: the activeIndex must not shift; the
|
||||
currently active combatant stays active.
|
||||
- Duplicate names: allowed. Combatants are distinguished by their
|
||||
unique id, not by name.
|
||||
|
||||
## Domain Model *(mandatory)*
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Combatant**: An identified participant in the encounter with a
|
||||
unique CombatantId (branded string) and a name (non-empty string).
|
||||
- **Encounter**: The aggregate root. Contains an ordered list of
|
||||
combatants, an activeIndex pointing to the current combatant, and
|
||||
a roundNumber (positive integer, starting at 1).
|
||||
|
||||
### Domain Events
|
||||
|
||||
- **CombatantAdded**: Emitted on every successful AddCombatant.
|
||||
Carries: combatantId, name, position (zero-based index where the
|
||||
combatant was inserted).
|
||||
|
||||
### Invariants
|
||||
|
||||
- **INV-1** (preserved): An encounter MAY have zero combatants.
|
||||
- **INV-2** (preserved): If combatants.length > 0, activeIndex MUST
|
||||
satisfy 0 <= activeIndex < combatants.length. If
|
||||
combatants.length == 0, activeIndex MUST be 0.
|
||||
- **INV-3** (preserved): roundNumber MUST be a positive integer
|
||||
(>= 1) and MUST only increase.
|
||||
- **INV-4**: AddCombatant MUST be a pure function of the current
|
||||
encounter state and the input name. Given identical input, output
|
||||
MUST be identical (except for id generation — see Assumptions).
|
||||
- **INV-5**: Every successful AddCombatant MUST emit exactly one
|
||||
CombatantAdded event. No silent state changes.
|
||||
- **INV-6**: AddCombatant MUST NOT change the activeIndex or
|
||||
roundNumber of the encounter.
|
||||
- **INV-7**: The new combatant MUST be appended to the end of the
|
||||
combatants list (last position).
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The domain MUST expose an AddCombatant operation that
|
||||
accepts an Encounter and a combatant name, and returns the updated
|
||||
Encounter state plus emitted domain events.
|
||||
- **FR-002**: AddCombatant MUST append the new combatant to the end
|
||||
of the combatants list.
|
||||
- **FR-003**: AddCombatant MUST assign a unique CombatantId to the
|
||||
new combatant.
|
||||
- **FR-004**: AddCombatant MUST reject empty or whitespace-only names
|
||||
by returning a DomainError without modifying state or emitting
|
||||
events.
|
||||
- **FR-005**: AddCombatant MUST NOT alter the activeIndex or
|
||||
roundNumber of the encounter.
|
||||
- **FR-006**: Domain events MUST be returned as values from the
|
||||
operation, not dispatched via side effects.
|
||||
|
||||
### Out of Scope (MVP baseline does not include)
|
||||
|
||||
- Removing combatants from an encounter
|
||||
- Reordering combatants after adding
|
||||
- Initiative score or automatic sorting
|
||||
- Combatant attributes beyond name (HP, conditions, stats)
|
||||
- Maximum combatant count limits
|
||||
- Persistence, serialization, or storage
|
||||
- UI or any adapter layer
|
||||
|
||||
## Assumptions
|
||||
|
||||
- CombatantId generation is the caller's responsibility (passed in or
|
||||
generated by the application layer), keeping the domain function
|
||||
pure and deterministic. The domain function will accept a
|
||||
CombatantId as part of its input rather than generating one
|
||||
internally.
|
||||
- Name validation trims whitespace; a name that is empty after
|
||||
trimming is invalid.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: All 6 acceptance scenarios pass as deterministic,
|
||||
pure-function tests with no I/O dependencies.
|
||||
- **SC-002**: Invariants INV-1 through INV-7 are verified by tests.
|
||||
- **SC-003**: The domain module has zero imports from application,
|
||||
adapter, or agent layers (layer boundary compliance).
|
||||
- **SC-004**: Adding a combatant to an encounter preserves all
|
||||
existing combatants and their order unchanged.
|
||||
@@ -1,129 +0,0 @@
|
||||
# Tasks: Add Combatant
|
||||
|
||||
**Input**: Design documents from `/specs/002-add-combatant/`
|
||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, quickstart.md
|
||||
|
||||
**Tests**: Included — spec success criteria SC-001 and SC-002 require all acceptance scenarios and invariants to be verified by tests.
|
||||
|
||||
**Organization**: Single user story (P1). Tasks follow the established `advanceTurn` pattern across all three layers.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (e.g., US1)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundational (Domain Event)
|
||||
|
||||
**Purpose**: Add the CombatantAdded event type that all layers depend on
|
||||
|
||||
- [x] T001 Add CombatantAdded event interface and extend DomainEvent union in packages/domain/src/events.ts
|
||||
|
||||
**Checkpoint**: CombatantAdded event type available for import
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: User Story 1 - Add Combatant to Encounter (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: A game master can add a new combatant to an existing encounter. The combatant is appended to the end of the initiative list without changing the active turn or round.
|
||||
|
||||
**Independent Test**: Call `addCombatant` with an Encounter, a CombatantId, and a name. Assert the returned Encounter has the new combatant at the end, activeIndex and roundNumber unchanged, and a CombatantAdded event emitted.
|
||||
|
||||
### Domain Layer
|
||||
|
||||
- [x] T002 [US1] Create addCombatant pure function in packages/domain/src/add-combatant.ts
|
||||
- [x] T003 [US1] Export addCombatant and AddCombatantSuccess from packages/domain/src/index.ts
|
||||
|
||||
### Domain Tests
|
||||
|
||||
- [x] T004 [US1] Create acceptance tests (6 scenarios) and invariant tests (INV-1 through INV-7) in packages/domain/src/__tests__/add-combatant.test.ts
|
||||
|
||||
### Application Layer
|
||||
|
||||
- [x] T005 [P] [US1] Create addCombatantUseCase in packages/application/src/add-combatant-use-case.ts
|
||||
- [x] T006 [US1] Export addCombatantUseCase from packages/application/src/index.ts
|
||||
|
||||
### Web Adapter
|
||||
|
||||
- [x] T007 [US1] Add addCombatant callback to useEncounter hook in apps/web/src/hooks/use-encounter.ts
|
||||
- [x] T008 [US1] Add combatant name input and add button to apps/web/src/App.tsx
|
||||
|
||||
**Checkpoint**: All 6 acceptance scenarios pass. User can type a name and add a combatant via the UI. `pnpm check` passes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [x] T009 Run pnpm check (format + lint + typecheck + test) and fix any issues
|
||||
- [x] T010 Verify layer boundary compliance (domain has no outer-layer imports)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Foundational (Phase 1)**: No dependencies — start immediately
|
||||
- **User Story 1 (Phase 2)**: Depends on T001 (CombatantAdded event type)
|
||||
- **Polish (Phase 3)**: Depends on all Phase 2 tasks
|
||||
|
||||
### Within User Story 1
|
||||
|
||||
```
|
||||
T001 (event type)
|
||||
├── T002 (domain function) → T003 (domain exports) → T004 (domain tests)
|
||||
└── T005 (use case) ──────→ T006 (app exports) → T007 (hook) → T008 (UI)
|
||||
```
|
||||
|
||||
- T002 depends on T001 (needs CombatantAdded type)
|
||||
- T003 depends on T002 (exports the new function)
|
||||
- T004 depends on T003 (tests import from index)
|
||||
- T005 depends on T003 (use case imports domain function) — can run in parallel with T004
|
||||
- T006 depends on T005
|
||||
- T007 depends on T006
|
||||
- T008 depends on T007
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- T004 (domain tests) and T005 (use case) can run in parallel after T003
|
||||
- T009 and T010 can run in parallel
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: After T003
|
||||
|
||||
```
|
||||
# These two tasks touch different packages and can run in parallel:
|
||||
T004: "Acceptance + invariant tests in packages/domain/src/__tests__/add-combatant.test.ts"
|
||||
T005: "Use case in packages/application/src/add-combatant-use-case.ts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP (This Feature)
|
||||
|
||||
1. T001: Add event type (foundation)
|
||||
2. T002–T003: Domain function + exports
|
||||
3. T004 + T005 in parallel: Tests + use case
|
||||
4. T006–T008: Application exports → hook → UI
|
||||
5. T009–T010: Verify everything passes
|
||||
|
||||
### Validation
|
||||
|
||||
After T004: All 6 acceptance scenarios pass as pure-function tests
|
||||
After T008: UI allows adding combatants by name
|
||||
After T009: `pnpm check` passes clean (merge gate)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Follow the `advanceTurn` pattern for function signature, result type, and error handling
|
||||
- CombatantId is passed in as input (generated by caller), not created inside domain
|
||||
- Name is trimmed then validated; empty after trim returns DomainError with code "invalid-name"
|
||||
- Commit after each task or logical group
|
||||
- Total: 10 tasks (1 foundational + 7 US1 + 2 polish)
|
||||
319
specs/002-turn-tracking/spec.md
Normal file
319
specs/002-turn-tracking/spec.md
Normal file
@@ -0,0 +1,319 @@
|
||||
# Feature Specification: Turn Tracking
|
||||
|
||||
**Feature Branch**: `002-turn-tracking`
|
||||
**Created**: 2026-03-03
|
||||
**Status**: Implemented
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Turn Tracking covers all aspects of managing the flow of combat: advancing and retreating through combatants in initiative order, incrementing and decrementing the round counter at round boundaries, sorting combatants by initiative value, and presenting the top bar UI with navigation controls, round display, and active combatant display.
|
||||
|
||||
---
|
||||
|
||||
## Domain Model
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Combatant** — An identified participant in the encounter. Carries an optional integer `initiative` value that determines position in turn order.
|
||||
- **Encounter** — The aggregate root. Contains an ordered list of combatants (sorted by initiative descending, unset last), an `activeIndex` pointing to the current combatant, and a `roundNumber` (positive integer, starting at 1).
|
||||
|
||||
### Domain Events
|
||||
|
||||
- **TurnAdvanced** — Emitted on every successful AdvanceTurn. Carries: `previousCombatantId`, `newCombatantId`, `roundNumber`.
|
||||
- **RoundAdvanced** — Emitted when advancing crosses the end of the combatant list. Carries: `newRoundNumber`.
|
||||
- **TurnRetreated** — Emitted on every successful RetreatTurn. Carries: `previousCombatantId`, `newCombatantId`, `roundNumber`.
|
||||
- **RoundRetreated** — Emitted when retreating crosses a round boundary backward. Carries: `newRoundNumber`.
|
||||
- **InitiativeSet** — Emitted when a combatant's initiative value is set or changed.
|
||||
|
||||
When a round boundary is crossed, the corresponding turn event (TurnAdvanced or TurnRetreated) MUST be emitted first, followed by the round event (RoundAdvanced or RoundRetreated). This emission order is part of the observable domain contract.
|
||||
|
||||
### Invariants
|
||||
|
||||
- **INV-1**: An encounter MAY have zero combatants (empty encounter is valid aggregate state). AdvanceTurn and RetreatTurn on an empty encounter MUST return a DomainError with no state change and no events.
|
||||
- **INV-2**: If `combatants.length > 0`, `activeIndex` MUST satisfy `0 <= activeIndex < combatants.length`. If `combatants.length == 0`, `activeIndex` MUST be 0.
|
||||
- **INV-3**: `roundNumber` MUST be a positive integer (>= 1). It MUST only increase on AdvanceTurn and only decrease on RetreatTurn; it MUST never drop below 1.
|
||||
- **INV-4**: AdvanceTurn and RetreatTurn MUST be pure functions of the current encounter state. Given identical input, output MUST be identical.
|
||||
- **INV-5**: Every successful AdvanceTurn or RetreatTurn MUST emit at least one domain event (TurnAdvanced or TurnRetreated respectively). No silent state changes.
|
||||
- **INV-6**: The initiative sort MUST be stable — combatants with equal initiative (or multiple combatants with no initiative) retain their relative insertion order.
|
||||
- **INV-7**: The active combatant's identity MUST be preserved through any initiative-driven reorder — the active turn tracks the combatant by identity, not by index position.
|
||||
|
||||
---
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### Advancing Turns
|
||||
|
||||
#### Story A1 — Advance to the Next Combatant (Priority: P1)
|
||||
|
||||
As a game master running an encounter, I want to advance the turn to the next combatant in initiative order so that play moves forward through the encounter.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an encounter with combatants [A, B, C], activeIndex 0, roundNumber 1, **When** AdvanceTurn, **Then** activeIndex is 1, roundNumber is 1, and a TurnAdvanced event is emitted with previousCombatantId A, newCombatantId B, roundNumber 1.
|
||||
2. **Given** an encounter with combatants [A, B, C], activeIndex 1, roundNumber 1, **When** AdvanceTurn, **Then** activeIndex is 2, roundNumber is 1, and a TurnAdvanced event is emitted with previousCombatantId B, newCombatantId C, roundNumber 1.
|
||||
3. **Given** an encounter with combatants [A, B], activeIndex 0, roundNumber 1, **When** AdvanceTurn is applied twice in sequence, **Then** after the first: activeIndex 1, roundNumber 1; after the second: activeIndex 0, roundNumber 2.
|
||||
4. **Given** an encounter with combatants [A, B, C], activeIndex 0, roundNumber 1, **When** AdvanceTurn is applied three times, **Then** activeIndex returns to 0 and roundNumber is 2.
|
||||
|
||||
#### Story A2 — Round Increment on Wrap (Priority: P1)
|
||||
|
||||
As a game master, I want the round number to increment automatically when the last combatant's turn ends so that I always know which round of combat I am in.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an encounter with combatants [A, B, C], activeIndex 2, roundNumber 1, **When** AdvanceTurn, **Then** activeIndex is 0, roundNumber is 2, and events are emitted in order: TurnAdvanced (previousCombatantId C, newCombatantId A, roundNumber 2) then RoundAdvanced (newRoundNumber 2).
|
||||
2. **Given** an encounter with combatants [A, B, C], activeIndex 2, roundNumber 5, **When** AdvanceTurn, **Then** activeIndex is 0, roundNumber is 6, and events are emitted in order: TurnAdvanced then RoundAdvanced (verifies round increment is not hardcoded to 2).
|
||||
3. **Given** an encounter with a single combatant [A], activeIndex 0, roundNumber 1, **When** AdvanceTurn, **Then** activeIndex is 0, roundNumber is 2, and events are emitted in order: TurnAdvanced (previousCombatantId A, newCombatantId A, roundNumber 2) then RoundAdvanced (newRoundNumber 2).
|
||||
|
||||
#### Story A3 — AdvanceTurn on Empty Encounter (Priority: P1)
|
||||
|
||||
As a developer, I want AdvanceTurn to fail safely on an empty encounter so that no invalid state is ever produced.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an encounter with an empty combatant list, **When** AdvanceTurn, **Then** the operation MUST fail with an invalid-encounter error. No events are emitted. State is unchanged.
|
||||
|
||||
---
|
||||
|
||||
### Retreating Turns
|
||||
|
||||
#### Story R1 — Go Back to the Previous Turn (Priority: P1)
|
||||
|
||||
As a game master running a combat encounter, I want to go back to the previous combatant's turn so that I can correct mistakes (e.g., a forgotten action, an incorrectly skipped combatant) without restarting the encounter.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an encounter with combatants [A, B, C], activeIndex 1, roundNumber 1, **When** RetreatTurn, **Then** activeIndex is 0, roundNumber is 1, and a TurnRetreated event is emitted with previousCombatantId B, newCombatantId A, roundNumber 1.
|
||||
2. **Given** an encounter with a single combatant [A], activeIndex 0, roundNumber 3, **When** RetreatTurn, **Then** activeIndex is 0, roundNumber is 2, and events are emitted in order: TurnRetreated then RoundRetreated (newRoundNumber 2).
|
||||
|
||||
#### Story R2 — Round Decrement on Wrap Backward (Priority: P1)
|
||||
|
||||
As a game master, I want the round number to decrement when retreating past the first combatant so that the encounter state accurately reflects where I am in the timeline.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an encounter with combatants [A, B, C], activeIndex 0, roundNumber 2, **When** RetreatTurn, **Then** activeIndex is 2, roundNumber is 1, and events are emitted in order: TurnRetreated (previousCombatantId A, newCombatantId C, roundNumber 1) then RoundRetreated (newRoundNumber 1).
|
||||
|
||||
#### Story R3 — Retreat Blocked at Encounter Start (Priority: P1)
|
||||
|
||||
As a game master, I want the Previous Turn action to fail when I am at the very beginning of the encounter so that round 1 / first combatant is the earliest reachable state.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an encounter with combatants [A, B, C], activeIndex 0, roundNumber 1, **When** RetreatTurn, **Then** the operation MUST fail with an error. No events are emitted. State is unchanged.
|
||||
2. **Given** an encounter with a single combatant [A], activeIndex 0, roundNumber 1, **When** RetreatTurn, **Then** the operation MUST fail with an error. No events are emitted. State is unchanged.
|
||||
|
||||
#### Story R4 — RetreatTurn on Empty Encounter (Priority: P1)
|
||||
|
||||
As a developer, I want RetreatTurn to fail safely on an empty encounter so that no invalid state is ever produced.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an encounter with an empty combatant list, **When** RetreatTurn, **Then** the operation MUST fail with an invalid-encounter error. No events are emitted. State is unchanged.
|
||||
|
||||
---
|
||||
|
||||
### Round Tracking
|
||||
|
||||
#### Story RD1 — Round Number Display (Priority: P1)
|
||||
|
||||
As a game master, I want the current round number to always be visible at the top of the tracker so that I never lose track of which round of combat I am in.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an active encounter in Round 2, **When** the user views the top bar, **Then** the round badge shows "R2" (or equivalent compact format) as a visually distinct element.
|
||||
2. **Given** the user advances the turn and the round increments from 3 to 4, **Then** the round badge updates to the new round number immediately without layout shift.
|
||||
3. **Given** an encounter with no combatants, **When** viewing the top bar, **Then** the round badge still shows the current round number.
|
||||
|
||||
---
|
||||
|
||||
### Turn Order (Initiative Sorting)
|
||||
|
||||
#### Story TO1 — Automatic Ordering by Initiative (Priority: P1)
|
||||
|
||||
As a game master, I want the encounter to automatically sort combatants from highest to lowest initiative whenever initiative values are set or changed so that I do not have to manually reorder them.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** combatants A (initiative 20), B (initiative 5), C (initiative 15), **When** all initiatives are set, **Then** the combatant order is A (20), C (15), B (5).
|
||||
2. **Given** combatants in order A (20), C (15), B (5), **When** B's initiative is changed to 25, **Then** the order becomes B (25), A (20), C (15).
|
||||
3. **Given** combatants A (initiative 10) and B (initiative 10) with the same value, **Then** their relative order is preserved (stable sort — the combatant who was added or set first stays ahead).
|
||||
|
||||
#### Story TO2 — Combatants Without Initiative (Priority: P2)
|
||||
|
||||
As a game master, I want combatants who have not had their initiative set to appear at the end of the turn order so that the encounter remains usable while I am still entering initiative values.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** combatants A (initiative 15), B (no initiative), C (initiative 10), **Then** the order is A (15), C (10), B (no initiative).
|
||||
2. **Given** combatants A (no initiative) and B (no initiative), **Then** their relative order is preserved from when they were added.
|
||||
3. **Given** combatant A (no initiative), **When** initiative is set to 12, **Then** A moves to its correct sorted position among combatants that have initiative values.
|
||||
4. **Given** combatant A (initiative 15), **When** the user clears A's initiative, **Then** A moves to the end of the turn order.
|
||||
|
||||
#### Story TO3 — Active Turn Preserved During Reorder (Priority: P2)
|
||||
|
||||
As a game master mid-encounter, I want the active combatant's turn to be preserved when initiative changes cause a reorder so that I do not lose track of whose turn it is.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** it is combatant B's turn (activeIndex points to B), **When** combatant A's initiative is changed causing a reorder, **Then** the active turn still points to combatant B.
|
||||
2. **Given** it is combatant A's turn, **When** combatant A's own initiative is changed causing a reorder, **Then** the active turn still points to combatant A.
|
||||
|
||||
---
|
||||
|
||||
### Top Bar Display
|
||||
|
||||
#### Story TB1 — Scanning Round and Combatant at a Glance (Priority: P1)
|
||||
|
||||
As a game master running an encounter, I want the round number and current combatant displayed as distinct, visually separated elements so I can instantly identify both without parsing a combined string.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an active encounter in Round 2 with "Goblin" as the active combatant, **When** the user views the top bar, **Then** "R2" appears as a muted badge/pill near the left side and "Goblin" appears as a prominent centered label, with no dash or combined string.
|
||||
2. **Given** an active encounter in Round 1 at the first combatant, **When** the encounter starts, **Then** the round badge shows the round number and the center displays the first combatant's name as separate visual elements.
|
||||
3. **Given** the user advances the turn, **When** the round increments from 3 to 4, **Then** the round badge updates without layout shift.
|
||||
|
||||
#### Story TB2 — Fixed Top Bar (Priority: P1)
|
||||
|
||||
As a game master managing a large encounter with many combatants, I want the turn navigation bar pinned to the top of the screen so that I can always navigate turns without scrolling away from the controls.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an encounter with enough combatants to overflow the viewport, **When** the user scrolls through the combatant list, **Then** the turn navigation bar (Previous / round badge / combatant name / Next) remains fixed at the top of the encounter area and never scrolls out of view.
|
||||
2. **Given** any viewport width, **When** the encounter tracker is displayed, **Then** the top navigation bar remains fixed and the combatant list scrolls independently.
|
||||
|
||||
#### Story TB3 — Left-Center-Right Layout (Priority: P1)
|
||||
|
||||
As a game master, I want the top bar to follow a clear left-center-right structure so that controls are always in predictable positions regardless of combatant name length.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an encounter with a short combatant name like "Orc", **When** viewing the bar, **Then** the layout maintains the left-center-right structure with the name centered.
|
||||
2. **Given** an encounter with a long combatant name like "Ancient Red Dragon Wyrm of the Northern Wastes", **When** viewing the bar, **Then** the name truncates gracefully without pushing action buttons off-screen.
|
||||
3. **Given** a narrow viewport, **When** viewing the bar, **Then** all three zones (round badge, combatant name, action buttons) remain visible and accessible.
|
||||
|
||||
#### Story TB4 — Turn Navigation Controls Accessible and Correctly Disabled (Priority: P1)
|
||||
|
||||
As a game master, I want the Previous Turn and Next Turn buttons placed prominently in the fixed top bar, with the Previous button disabled when no retreat is possible, so that I can quickly navigate turns from any scroll position.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the encounter tracker is displayed, **When** the user looks at the screen, **Then** the Previous Turn and Next Turn buttons are visible in the fixed top bar, above the combatant list.
|
||||
2. **Given** the encounter is at round 1 with the first combatant active, **When** the user views the turn controls, **Then** the Previous Turn button is disabled (visually indicating it cannot be used).
|
||||
3. **Given** the encounter has no combatants, **When** the user views the tracker, **Then** both turn navigation buttons are disabled.
|
||||
4. **Given** the tracker has many combatants requiring scrolling, **When** the user scrolls down, **Then** the turn navigation controls remain accessible at the top (no scrolling needed to reach them).
|
||||
5. **Given** the Previous and Next buttons are displayed, **When** the user looks at the controls, **Then** the buttons are visually distinct with clear directional indicators (icons, labels, or both).
|
||||
|
||||
#### Story TB5 — No Combatants State (Priority: P2)
|
||||
|
||||
As a game master with an empty encounter, I want the top bar to handle the no-combatants state gracefully so that it does not appear broken.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an encounter with no combatants, **When** viewing the top bar, **Then** the round badge still shows the round number and the center area displays a placeholder message indicating no active combatant.
|
||||
|
||||
#### Story TB6 — Active Combatant Scrolled into View on Turn Change (Priority: P2)
|
||||
|
||||
As a game master, I want the active combatant's row to automatically scroll into view when the turn changes so that the active row is always visible after navigation.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the active combatant's row is scrolled off-screen, **When** the turn changes via Next or Previous, **Then** the combatant list automatically scrolls to bring the newly active combatant's row into view.
|
||||
|
||||
---
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
**Advancing Turns**
|
||||
|
||||
- **FR-001**: The domain MUST expose an AdvanceTurn operation that accepts an Encounter and returns the resulting Encounter state plus emitted domain events.
|
||||
- **FR-002**: AdvanceTurn MUST increment `activeIndex` by 1, wrapping to 0 when advancing past the last combatant.
|
||||
- **FR-003**: When `activeIndex` wraps to 0, `roundNumber` MUST increment by 1.
|
||||
- **FR-004**: AdvanceTurn on an empty encounter MUST return a DomainError without modifying state or emitting events.
|
||||
- **FR-005**: Domain events MUST be returned as values from AdvanceTurn, not dispatched via side effects.
|
||||
|
||||
**Retreating Turns**
|
||||
|
||||
- **FR-006**: The domain MUST expose a RetreatTurn operation that moves the active turn to the previous combatant in initiative order.
|
||||
- **FR-007**: RetreatTurn MUST decrement `activeIndex` by 1. When `activeIndex` would go below 0, it MUST wrap to the last combatant and decrement `roundNumber` by 1.
|
||||
- **FR-008**: RetreatTurn at round 1 with `activeIndex` 0 MUST fail with a DomainError. This is the earliest possible encounter state.
|
||||
- **FR-009**: RetreatTurn on an empty encounter MUST fail with a DomainError without modifying state or emitting events.
|
||||
- **FR-010**: RetreatTurn MUST emit a TurnRetreated event on success. When crossing a round boundary, a RoundRetreated event MUST also be emitted: TurnRetreated first, then RoundRetreated.
|
||||
- **FR-011**: RetreatTurn MUST be a pure function of the current encounter state. Given identical input, output MUST be identical.
|
||||
|
||||
**Turn Order (Initiative Sorting)**
|
||||
|
||||
- **FR-012**: The system MUST automatically reorder combatants from highest to lowest initiative whenever an initiative value is set, changed, or cleared.
|
||||
- **FR-013**: Combatants without an initiative value MUST be placed after all combatants that have initiative values.
|
||||
- **FR-014**: The sort MUST be stable: combatants with equal initiative (or multiple combatants without initiative) retain their relative order.
|
||||
- **FR-015**: The system MUST preserve the active combatant's turn identity when reordering occurs — the active turn tracks the combatant by identity, not by `activeIndex` position.
|
||||
- **FR-016**: Zero and negative integers MUST be accepted as valid initiative values.
|
||||
- **FR-017**: Non-integer initiative values MUST be rejected with an error.
|
||||
- **FR-018**: The system MUST emit an InitiativeSet domain event when a combatant's initiative is set or changed.
|
||||
|
||||
**Top Bar Display**
|
||||
|
||||
- **FR-019**: The top bar MUST remain fixed at the top of the encounter tracker area and MUST NOT scroll out of view.
|
||||
- **FR-020**: The top bar MUST follow a left-center-right layout: [prev button] [round badge] — [combatant name] — [action buttons] [next button].
|
||||
- **FR-021**: The round number MUST be displayed as a compact, visually muted badge or pill element (format: "R{n}", e.g., "R1", "R2") positioned to the left of the combatant name.
|
||||
- **FR-022**: The current combatant's name MUST be displayed as a prominent, centered label and MUST be the visual focal point of the bar.
|
||||
- **FR-023**: The round number and combatant name MUST be visually distinct elements — not joined by a dash or rendered as a single string.
|
||||
- **FR-024**: The combatant name MUST truncate with an ellipsis when it exceeds available space rather than causing layout overflow.
|
||||
- **FR-025**: When no combatants exist, the center area MUST display a placeholder message; the round badge MUST still show the current round number.
|
||||
- **FR-026**: The Previous Turn button MUST be disabled when the encounter is at its earliest state (round 1, first combatant active).
|
||||
- **FR-027**: Both turn navigation buttons MUST be disabled when the encounter has no combatants.
|
||||
- **FR-028**: The turn navigation buttons MUST have clear directional indicators (icons, labels, or both) so the user can distinguish Previous from Next at a glance.
|
||||
- **FR-029**: When the active turn changes via Next or Previous, the active combatant's row MUST automatically scroll into view if it is not currently visible in the scrollable list area.
|
||||
- **FR-030**: The combatant list MUST be the only scrollable region — positioned between the fixed top bar and the fixed bottom bar.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
- **SC-001**: All AdvanceTurn acceptance scenarios pass as deterministic, pure-function tests with no I/O dependencies.
|
||||
- **SC-002**: Invariants INV-1 through INV-7 are verified by tests.
|
||||
- **SC-003**: The domain module has zero imports from application, adapter, or UI layers (layer boundary compliance).
|
||||
- **SC-004**: A user can reverse a turn advancement using a single click on the Previous Turn button.
|
||||
- **SC-005**: The Previous Turn button is correctly disabled at the earliest encounter state (round 1, first combatant), preventing invalid operations 100% of the time.
|
||||
- **SC-006**: All RetreatTurn acceptance scenarios pass as deterministic, pure-function tests with no I/O dependencies.
|
||||
- **SC-007**: After setting or changing any initiative value, the encounter's combatant order immediately reflects the correct descending initiative sort.
|
||||
- **SC-008**: The active combatant's turn is never lost or shifted to a different combatant due to an initiative-driven reorder.
|
||||
- **SC-009**: Combatants without initiative are always displayed after combatants with initiative values.
|
||||
- **SC-010**: Users can identify the current round number and active combatant in under 1 second of looking at the top bar, without needing to parse a combined string.
|
||||
- **SC-011**: The top bar layout remains visually balanced and functional across viewport widths from 320px to 1920px.
|
||||
- **SC-012**: All existing top bar functionality (turn navigation, roll initiative, manage sources, clear encounter) remains fully operational.
|
||||
- **SC-013**: Combatant names up to 40 characters display without layout breakage; longer names truncate gracefully.
|
||||
- **SC-014**: With 20+ combatants in an encounter, the turn navigation bar remains visible at all scroll positions without any user action beyond normal scrolling.
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- **Empty combatant list**: Valid aggregate state. AdvanceTurn and RetreatTurn both return a DomainError (no state change, no events). The top bar shows the round badge and a placeholder for the combatant name.
|
||||
- **Single combatant, advancing**: Every advance wraps and increments the round. Both TurnAdvanced and RoundAdvanced are emitted.
|
||||
- **Single combatant, retreating at round 1**: RetreatTurn fails because there is no previous turn.
|
||||
- **Single combatant, retreating at round > 1**: RetreatTurn succeeds, decrementing the round; both TurnRetreated and RoundRetreated are emitted.
|
||||
- **Large round numbers**: No overflow or special-case behavior; round increments and decrements uniformly.
|
||||
- **Retreating at round 1, activeIndex 0**: The earliest possible state — RetreatTurn MUST fail. Round number can never drop below 1.
|
||||
- **All combatants have the same initiative**: Relative order is preserved (stable sort preserves insertion order).
|
||||
- **Initiative cleared mid-encounter**: The combatant moves to the end of the turn order. The active combatant identity is preserved.
|
||||
- **Initiative changed for the active combatant**: Reorder occurs; the active turn still points to that combatant at its new position.
|
||||
- **Initiative set to zero or a negative value**: Treated as a normal integer — sorted accordingly.
|
||||
- **Combatant name extremely long (50+ characters)**: Name truncates with an ellipsis; layout does not break.
|
||||
- **Very narrow viewport**: Round badge and navigation buttons remain visible; combatant name truncates.
|
||||
- **Very short viewport (e.g., 400px tall)**: Combatant list area is still scrollable, even if only a small portion is visible.
|
||||
- **Active combatant scrolled off-screen**: On turn change, the list auto-scrolls to bring the newly active combatant into view.
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Initiative values are integers (no decimals). There is no dice-rolling or randomization in the domain — the user provides the final value.
|
||||
- Tiebreaking for equal initiative values uses stable sort (preserves existing relative order). Secondary tiebreakers (e.g., Dexterity modifier) are not included in the MVP baseline.
|
||||
- RetreatTurn is the inverse of AdvanceTurn for position and round tracking only. It does not restore any other state (e.g., HP changes made during a combatant's turn are not undone). It is not a full undo/redo stack.
|
||||
- Keyboard shortcuts for Previous/Next Turn navigation are not included in the MVP baseline.
|
||||
- The round badge uses the compact format "R{number}" (e.g., "R1", "R2").
|
||||
- No new domain entities or persistence changes are required for the top bar display — it is a presentational layer over existing encounter state.
|
||||
492
specs/003-combatant-state/spec.md
Normal file
492
specs/003-combatant-state/spec.md
Normal file
@@ -0,0 +1,492 @@
|
||||
# Feature Specification: Combatant State
|
||||
|
||||
**Feature Branch**: `003-combatant-state`
|
||||
**Created**: 2026-03-03
|
||||
**Status**: Implemented
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Combatant State covers all per-combatant data tracked during an encounter: hit points, armor class, conditions, concentration, and initiative.
|
||||
|
||||
**Structure**: This spec is organized by topic area. Each topic section contains its own user scenarios, requirements, and edge cases. The Combatant Row Layout section covers cross-cutting UI concerns.
|
||||
|
||||
## Domain Model Reference
|
||||
|
||||
```ts
|
||||
interface Combatant {
|
||||
readonly id: CombatantId; // branded string
|
||||
readonly name: string;
|
||||
readonly initiative?: number; // integer, undefined = unset
|
||||
readonly maxHp?: number; // positive integer
|
||||
readonly currentHp?: number; // 0..maxHp
|
||||
readonly ac?: number; // non-negative integer
|
||||
readonly conditions?: readonly ConditionId[];
|
||||
readonly isConcentrating?: boolean;
|
||||
readonly creatureId?: CreatureId; // link to bestiary entry
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hit Points
|
||||
|
||||
### User Stories
|
||||
|
||||
**Story HP-1 — Set Max HP (P1)**
|
||||
As a game master, I want to assign a maximum HP value to a combatant so that I can track their health during the encounter.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** a combatant exists, **When** the user sets max HP to a positive integer, **Then** the combatant's max HP is stored and current HP defaults to that value.
|
||||
2. **Given** a combatant has max HP 20 and current HP 20, **When** the user lowers max HP to 15, **Then** current HP is clamped to 15.
|
||||
3. **Given** a combatant has max HP 20 and current HP 20 (full health), **When** the user increases max HP to 30, **Then** current HP increases to 30 (stays at full).
|
||||
4. **Given** a combatant has max HP 20 and current HP 12 (not full), **When** the user increases max HP to 30, **Then** current HP remains at 12.
|
||||
5. **Given** the max HP inline edit is active, **When** the user clears the field and confirms, **Then** max HP is unset and HP tracking is removed entirely.
|
||||
|
||||
**Story HP-2 — Apply HP Delta (P1)**
|
||||
As a game master in the heat of combat, I want to type a damage or healing number and immediately apply it to a combatant's HP so that I can keep up with fast-paced encounters without mental arithmetic.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** a combatant has 20/20 HP, **When** the user types 7 into the delta input and presses Enter, **Then** current HP decreases to 13.
|
||||
2. **Given** a combatant has 10/20 HP, **When** the user types 15 and presses Enter, **Then** current HP is clamped to 0.
|
||||
3. **Given** a combatant has 10/20 HP and types 5 then clicks the heal button, **Then** current HP increases to 15.
|
||||
4. **Given** a combatant has 18/20 HP and types 10 then clicks the heal button, **Then** current HP is clamped to 20.
|
||||
5. **Given** any confirmed delta, **Then** the input field clears automatically and is ready for the next entry.
|
||||
6. **Given** the user types 0 and presses Enter, **Then** the input is rejected and HP remains unchanged.
|
||||
7. **Given** the delta input is focused and the user presses Escape, **Then** the input clears without applying any change.
|
||||
|
||||
**Story HP-3 — Click-to-Adjust Popover (P1)**
|
||||
As a DM running combat, I want to click on a combatant's current HP value to open a small adjustment popover so that the combatant row is visually clean and I can still quickly apply damage or healing when needed.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** a combatant with max HP set, **When** viewing the row at rest, **Then** only the current HP number and max HP are visible — no delta input or action buttons.
|
||||
2. **Given** a combatant with max HP set, **When** the user clicks the current HP number, **Then** a small popover opens with an auto-focused numeric input and Damage/Heal buttons.
|
||||
3. **Given** the HP popover is open with a valid number, **When** the user presses Enter, **Then** damage is applied and the popover closes.
|
||||
4. **Given** the HP popover is open with a valid number, **When** the user presses Shift+Enter, **Then** healing is applied and the popover closes.
|
||||
5. **Given** the HP popover is open, **When** the user presses Escape, **Then** the popover closes without changes.
|
||||
6. **Given** the HP popover is open, **When** the user clicks outside, **Then** the popover closes without changes.
|
||||
7. **Given** a combatant with no max HP set, **When** viewing the row, **Then** the HP area shows only the max HP clickable placeholder — no current HP value.
|
||||
|
||||
**Story HP-4 — Direct HP Entry (P2)**
|
||||
As a game master, I want to type a specific absolute current HP value directly so I can apply large corrections in one action.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** a combatant has max HP 50, **When** the user types 35 into the current HP field, **Then** current HP is set to 35.
|
||||
2. **Given** a combatant has max HP 50, **When** the user types 60, **Then** current HP is clamped to 50.
|
||||
3. **Given** a combatant has max HP 50, **When** the user types -5, **Then** current HP is clamped to 0.
|
||||
|
||||
**Story HP-5 — HP Status Indicators (P1)**
|
||||
As a game master, I want to see at a glance which combatants are bloodied or unconscious so I can narrate the battle and make tactical decisions without mental math.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** a combatant has max HP 20 and current HP 10 (at half), **Then** no bloodied indicator is shown (10 is not less than 20/2 = 10).
|
||||
2. **Given** a combatant has max HP 20 and current HP 9 (below half), **Then** the bloodied indicator is visible (amber color treatment on HP value).
|
||||
3. **Given** a combatant has max HP 21 and current HP 10 (below 10.5), **Then** the bloodied indicator is shown.
|
||||
4. **Given** a combatant has max HP 20 and current HP 0, **Then** the unconscious/dead indicator is shown (red color; row visually muted).
|
||||
5. **Given** a combatant at 0 HP is healed above 0, **Then** the unconscious indicator is removed and the correct status is applied.
|
||||
6. **Given** a combatant has no max HP set, **Then** no status indicator is shown.
|
||||
7. **Given** a combatant at full HP 20/20, **When** 11 damage is dealt (-> 9/20), **Then** the indicator transitions to bloodied.
|
||||
8. **Given** a bloodied combatant 5/20, **When** 5 damage is dealt (-> 0/20), **Then** the indicator transitions to unconscious/dead.
|
||||
9. **Given** an unconscious combatant 0/20, **When** 15 HP is healed (-> 15/20), **Then** the indicator transitions directly to healthy (skips bloodied since 15 > 10).
|
||||
|
||||
**Story HP-6 — HP Persists Across Reloads (P2)**
|
||||
As a game master, I want HP values to survive page reloads so that I do not lose health tracking mid-session.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** a combatant has max HP 30 and current HP 18, **When** the page is reloaded, **Then** both values are restored exactly.
|
||||
|
||||
### Requirements
|
||||
|
||||
- **FR-001**: Each combatant MAY have an optional `maxHp` value (positive integer >= 1). HP tracking is optional per combatant.
|
||||
- **FR-002**: When `maxHp` is first set, `currentHp` MUST default to `maxHp`.
|
||||
- **FR-003**: `currentHp` MUST be clamped to [0, `maxHp`] at all times.
|
||||
- **FR-004**: The system MUST provide an inline HP delta input per combatant (hidden behind a click-to-open popover on the current HP value).
|
||||
- **FR-005**: The HP popover MUST contain a single auto-focused numeric input and Damage and Heal action buttons.
|
||||
- **FR-006**: Pressing Enter in the popover MUST apply the entered value as damage; Shift+Enter MUST apply it as healing; Escape MUST dismiss without change; clicking outside MUST dismiss without change.
|
||||
- **FR-007**: When a damage value is confirmed, the system MUST subtract the entered amount from `currentHp`, clamping to 0.
|
||||
- **FR-008**: When a healing value is confirmed, the system MUST add the entered amount to `currentHp`, clamping to `maxHp`.
|
||||
- **FR-009**: After any delta is applied, the input MUST clear automatically.
|
||||
- **FR-010**: The delta input MUST only accept positive integers. Zero, negative, and non-numeric values MUST be rejected.
|
||||
- **FR-011**: Direct editing of the absolute `currentHp` value MUST remain available alongside the delta input.
|
||||
- **FR-012**: `maxHp` MUST display as compact static text with click-to-edit. The value is committed on Enter or blur; Escape cancels. Intermediate editing (clearing the field to retype) MUST NOT affect `currentHp` until committed.
|
||||
- **FR-013**: When `maxHp` is reduced below `currentHp`, `currentHp` MUST be clamped to the new `maxHp`.
|
||||
- **FR-014**: When `maxHp` increases and the combatant was at full health, `currentHp` MUST increase to match the new `maxHp`.
|
||||
- **FR-015**: When `maxHp` increases and the combatant was NOT at full health, `currentHp` MUST remain unchanged (unless clamped by FR-013).
|
||||
- **FR-016**: `maxHp` MUST reject zero, negative, and non-integer values.
|
||||
- **FR-017**: HP values MUST persist across page reloads via the existing persistence mechanism.
|
||||
- **FR-018**: The HP status MUST be derived as a pure domain computation: `healthy` (currentHp >= maxHp / 2), `bloodied` (0 < currentHp < maxHp / 2), `unconscious` (currentHp <= 0). The status is not stored — computed on demand.
|
||||
- **FR-019**: The HP area MUST display the bloodied color treatment (amber) on the current HP value when status is `bloodied`.
|
||||
- **FR-020**: The HP area MUST display the unconscious color treatment (red) and the combatant row MUST appear visually muted when status is `unconscious`.
|
||||
- **FR-021**: Status indicators MUST NOT be shown when `maxHp` is not set.
|
||||
- **FR-022**: Visual status indicators MUST update within the same interaction frame as the HP change — no perceptible delay.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- `maxHp` of 1: at 1/1 the combatant is healthy; at 0/1 unconscious. No bloodied state is possible.
|
||||
- `maxHp` of 2: at 1/2 the combatant is healthy (1 is not strictly less than 1); at 0/2 unconscious.
|
||||
- When `maxHp` is cleared, `currentHp` is also cleared; the combatant returns to the no-HP state.
|
||||
- Entering a non-numeric value in any HP field is rejected; the previous value is preserved.
|
||||
- Entering a very large number (e.g., 99999) is applied normally; clamping prevents invalid state.
|
||||
- Submitting an empty delta input applies no change; the input remains ready.
|
||||
- When the user rapidly applies multiple deltas, each is applied sequentially; none are lost.
|
||||
- HP tracking is entirely absent for combatants with no `maxHp` set — no HP controls are shown.
|
||||
- There is no temporary HP in the MVP baseline.
|
||||
- There is no death/unconscious game mechanic triggered at 0 HP; the system displays the state only.
|
||||
- There is no damage type tracking, resistance/vulnerability calculation, or hit log in the MVP baseline.
|
||||
- There is no undo/redo for HP changes in the MVP baseline.
|
||||
|
||||
---
|
||||
|
||||
## Armor Class
|
||||
|
||||
### User Stories
|
||||
|
||||
**Story AC-1 — Set and Display AC (P1)**
|
||||
As a game master, I want to assign an Armor Class value to a combatant and see it displayed as a shield shape so I can reference it at a glance during combat.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** a combatant exists, **When** the user clicks the AC shield area and enters 17, **Then** the combatant's AC is stored and the shield shape displays "17".
|
||||
2. **Given** a combatant with AC 15, **When** viewing the row, **Then** the AC number is displayed inside a shield-shaped outline (not a separate icon + number).
|
||||
3. **Given** a combatant with no AC set, **When** viewing the row, **Then** the shield shape is shown in an empty/placeholder state.
|
||||
4. **Given** multiple combatants with different AC values, **When** viewing the encounter list, **Then** each displays its own correct AC.
|
||||
|
||||
**Story AC-2 — Edit AC (P2)**
|
||||
As a game master, I want to edit an existing combatant's AC inline so I can correct or update it without navigating away.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** a combatant with AC 15, **When** the user clicks the shield, **Then** an inline input appears pre-filled with 15 and selected.
|
||||
2. **Given** the inline AC edit is active, **When** the user types 18 and presses Enter, **Then** AC updates to 18 and the display returns to static mode.
|
||||
3. **Given** the inline AC edit is active, **When** the user blurs, **Then** AC is committed and the display returns to static mode.
|
||||
4. **Given** the inline AC edit is active, **When** the user presses Escape, **Then** the edit is cancelled and the original value is preserved.
|
||||
5. **Given** the inline AC edit is active, **When** the user clears the field and presses Enter, **Then** AC is unset and the shield shows an empty state.
|
||||
|
||||
### Requirements
|
||||
|
||||
- **FR-023**: Each combatant MAY have an optional `ac` value, a non-negative integer (>= 0).
|
||||
- **FR-024**: AC MUST be displayed inside a shield-shaped visual element. The separate shield icon is replaced by the shield shape itself.
|
||||
- **FR-025**: The shield shape MUST be shown in all cases (set, unset/empty state). No AC value means an empty-state shield, not hidden.
|
||||
- **FR-026**: Clicking the shield MUST open an inline edit input with the current value pre-filled and selected.
|
||||
- **FR-027**: The inline AC edit MUST commit on Enter or blur and cancel on Escape.
|
||||
- **FR-028**: Clearing the AC field and confirming MUST unset AC.
|
||||
- **FR-029**: AC MUST reject negative values. Zero is a valid AC.
|
||||
- **FR-030**: AC values MUST persist via the existing persistence mechanism.
|
||||
- **FR-031**: The AC shield MUST scale appropriately for single-digit, double-digit, and any valid AC values.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- AC 0 is valid and MUST be displayed.
|
||||
- Negative AC is not accepted; the input is rejected.
|
||||
- MVP baseline does not include AC-based calculations (to-hit comparisons, conditional formatting based on AC thresholds).
|
||||
|
||||
---
|
||||
|
||||
## Conditions & Concentration
|
||||
|
||||
### User Stories
|
||||
|
||||
**Story CC-1 — Add a Condition (P1)**
|
||||
As a DM running an encounter, I want to quickly apply a condition to a combatant so I can track status effects during combat.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** a combatant row is not hovered and has no conditions, **Then** no condition UI is visible.
|
||||
2. **Given** a combatant row is hovered, **When** no conditions are active, **Then** a "+" button appears inline after the creature name.
|
||||
3. **Given** the "+" button is visible, **When** the user clicks it, **Then** a compact condition picker opens showing all 15 conditions as icon + label pairs.
|
||||
4. **Given** the picker is open, **When** the user clicks a condition, **Then** it is toggled on and its icon appears inline after the creature name.
|
||||
5. **Given** the picker is open with active conditions already marked, **When** viewing the picker, **Then** active conditions are visually distinguished from inactive ones.
|
||||
|
||||
**Story CC-2 — Remove a Condition (P1)**
|
||||
As a DM, I want to remove a condition from a combatant when the effect ends so the tracker stays accurate.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** a combatant has active conditions, **When** the user clicks an active condition icon tag inline, **Then** the condition is removed and the icon disappears.
|
||||
2. **Given** the condition picker is open, **When** the user clicks an active condition, **Then** it is toggled off and removed from the row.
|
||||
3. **Given** a combatant with one condition and it is removed, **Then** only the hover-revealed "+" button remains.
|
||||
|
||||
**Story CC-3 — View Condition Name via Tooltip (P2)**
|
||||
As a DM, I want to hover over a condition icon to see its name so I can identify conditions without memorizing icons.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** a combatant has an active condition, **When** the user hovers over its icon, **Then** a tooltip shows the condition name (e.g., "Blinded").
|
||||
2. **Given** the user moves the cursor away from the icon, **Then** the tooltip disappears.
|
||||
|
||||
**Story CC-4 — Multiple Conditions (P2)**
|
||||
As a DM, I want to apply multiple conditions to a single combatant so I can track complex combat situations.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** a combatant with one condition, **When** another is added, **Then** both icons appear inline.
|
||||
2. **Given** a combatant with many conditions, **When** viewing the row, **Then** icons wrap within the name column without increasing row width; row height may increase.
|
||||
3. **Given** "poisoned" was applied first and "blinded" second, **When** viewing the row, **Then** "blinded" appears before "poisoned" (fixed definition order, not insertion order).
|
||||
|
||||
**Story CC-5 — Toggle Concentration (P1)**
|
||||
As a DM, I want to mark a combatant as concentrating on a spell by clicking a Brain icon in the row gutter so I can track spells requiring concentration.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** a combatant row is not hovered and concentration is inactive, **Then** the Brain icon is hidden.
|
||||
2. **Given** a combatant row is hovered and concentration is inactive, **Then** the Brain icon appears in a muted/faded style.
|
||||
3. **Given** the Brain icon is visible, **When** the user clicks it, **Then** concentration activates and the icon remains visible with an active style.
|
||||
4. **Given** concentration is active and the row is not hovered, **Then** the Brain icon remains visible.
|
||||
5. **Given** concentration is active, **When** the user clicks the Brain icon again, **Then** concentration deactivates and the icon hides (unless the row is still hovered).
|
||||
6. **Given** the Brain icon is visible, **When** the user hovers over it, **Then** a tooltip reading "Concentrating" appears.
|
||||
|
||||
**Story CC-6 — Visual Feedback for Concentration (P2)**
|
||||
As a DM, I want concentrating combatants to have a visible row accent so I can identify them at a glance without interacting.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** concentration is active, **When** viewing the encounter tracker, **Then** the combatant row shows a colored left border accent (`border-l-purple-400`).
|
||||
2. **Given** concentration is inactive, **Then** no concentration accent is shown.
|
||||
3. **Given** concentration is toggled off, **Then** the left border accent disappears immediately.
|
||||
|
||||
**Story CC-7 — Damage Pulse Alert (P3)**
|
||||
As a DM, I want a visual alert when a concentrating combatant takes damage so I remember to call for a concentration check.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** a combatant is concentrating, **When** the combatant takes damage (HP reduced), **Then** the Brain icon and row accent briefly pulse/flash for 700 ms.
|
||||
2. **Given** a combatant is concentrating, **When** the combatant is healed, **Then** no pulse/flash occurs.
|
||||
3. **Given** a combatant is NOT concentrating, **When** damage is taken, **Then** no pulse/flash occurs.
|
||||
4. **Given** a concentrating combatant takes damage, **When** the animation completes, **Then** the row returns to its normal concentration-active appearance.
|
||||
|
||||
### Requirements
|
||||
|
||||
- **FR-032**: The MVP MUST support the following 15 standard D&D 5e conditions: blinded, charmed, deafened, exhaustion, frightened, grappled, incapacitated, invisible, paralyzed, petrified, poisoned, prone, restrained, stunned, unconscious.
|
||||
- **FR-033**: Each condition MUST have a fixed icon and color mapping (Lucide icons; no emoji):
|
||||
|
||||
| Condition | Icon | Color |
|
||||
|---------------|------------|---------|
|
||||
| Blinded | EyeOff | neutral |
|
||||
| Charmed | Heart | pink |
|
||||
| Deafened | EarOff | neutral |
|
||||
| Exhaustion | BatteryLow | amber |
|
||||
| Frightened | Siren | orange |
|
||||
| Grappled | Hand | neutral |
|
||||
| Incapacitated | Ban | gray |
|
||||
| Invisible | Ghost | violet |
|
||||
| Paralyzed | ZapOff | yellow |
|
||||
| Petrified | Gem | slate |
|
||||
| Poisoned | Droplet | green |
|
||||
| Prone | ArrowDown | neutral |
|
||||
| Restrained | Link | neutral |
|
||||
| Stunned | Sparkles | yellow |
|
||||
| Unconscious | Moon | indigo |
|
||||
|
||||
- **FR-034**: Active condition icons MUST appear inline after the creature name within the same row, not on a separate line.
|
||||
- **FR-035**: Conditions MUST be displayed in the fixed definition order (blinded -> unconscious), regardless of application order.
|
||||
- **FR-036**: The "+" condition button MUST be hidden by default and appear only on row hover (or touch/focus on touch devices).
|
||||
- **FR-037**: Clicking "+" MUST open the compact condition picker showing all conditions as icon + label pairs.
|
||||
- **FR-038**: Clicking a condition in the picker MUST toggle it on or off.
|
||||
- **FR-039**: Clicking an active condition icon tag in the row MUST remove that condition.
|
||||
- **FR-040**: Hovering an active condition icon MUST show a tooltip with the condition name.
|
||||
- **FR-041**: Condition icons MUST NOT increase the row's width; row height MAY increase to accommodate wrapping.
|
||||
- **FR-042**: The condition picker MUST close when the user clicks outside of it.
|
||||
- **FR-043**: Conditions MUST persist as part of combatant state (surviving page reload).
|
||||
- **FR-044**: The condition data model MUST be extensible for future additions (e.g., mechanical effects, descriptions).
|
||||
- **FR-045**: `isConcentrating` MUST be stored as an optional boolean on the combatant, separate from the `conditions` array.
|
||||
- **FR-046**: Concentration MUST NOT appear in or interact with the condition tag system.
|
||||
- **FR-047**: The Brain icon toggle MUST be hidden at row rest and revealed on hover (same hover pattern as the "+" button).
|
||||
- **FR-048**: The Brain icon MUST remain visible whenever concentration is active, regardless of hover state.
|
||||
- **FR-049**: A tooltip reading "Concentrating" MUST appear when hovering the Brain icon.
|
||||
- **FR-050**: The active Brain icon MUST use `text-purple-400`; the inactive (hover-revealed) Brain icon MUST use a muted style (`text-muted-foreground opacity-50`).
|
||||
- **FR-051**: The concentration left border accent MUST use `border-l-purple-400`.
|
||||
- **FR-052**: The concentration toggle's clickable area MUST extend to fill the full gutter between the left border and the initiative column.
|
||||
- **FR-053**: When a concentrating combatant takes damage, the Brain icon and row accent MUST briefly pulse/flash for 700 ms.
|
||||
- **FR-054**: The pulse animation MUST NOT trigger on healing or when concentration is inactive.
|
||||
- **FR-055**: Concentration MUST persist across page reloads via existing storage.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- When all 15 conditions are applied, icons wrap within the row; row height increases but width does not.
|
||||
- When a combatant is removed, all its conditions and concentration state are discarded.
|
||||
- When the picker is open and the user clicks outside, it closes.
|
||||
- When a condition is toggled on then immediately off in the picker, it does not appear in the row.
|
||||
- When concentration is toggled during an active pulse animation, the animation cancels and the new state applies immediately.
|
||||
- Multiple combatants may concentrate simultaneously; concentration is independent per combatant.
|
||||
- Conditions have no mechanical effects in the MVP baseline (no auto-disadvantage, no automation).
|
||||
|
||||
---
|
||||
|
||||
## Initiative
|
||||
|
||||
### User Stories
|
||||
|
||||
**Story INI-1 — Set Initiative Value (P1)**
|
||||
As a game master running an encounter, I want to assign an initiative value to a combatant so that the encounter's turn order reflects each combatant's rolled initiative.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** a combatant with no initiative set, **When** the user sets initiative to 15, **Then** the combatant has initiative value 15.
|
||||
2. **Given** a combatant with initiative 15, **When** the user changes initiative to 8, **Then** the combatant has initiative value 8.
|
||||
3. **Given** a combatant with no initiative set, **When** the user attempts to set a non-integer value, **Then** the system rejects the input and initiative remains unset.
|
||||
4. **Given** a combatant with initiative 15, **When** the user clears initiative, **Then** the initiative is unset and the combatant moves to the end of the turn order.
|
||||
5. **Given** a combatant has an initiative value displayed as plain text, **When** the user clicks it, **Then** an inline editor opens to change or clear it.
|
||||
|
||||
**Story INI-2 — Roll Initiative for a Single Combatant (P1)**
|
||||
As a DM, I want to click a d20 icon next to a combatant's initiative slot to randomly roll initiative (1d20 + initiative modifier) so the result is immediately placed into the initiative field and the tracker re-sorts.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** a combatant linked to a bestiary creature (e.g., Aboleth, initiative modifier +7) with no initiative, **When** the user clicks the d20 icon, **Then** a random value in the range [8, 27] is stored as initiative and the list re-sorts descending.
|
||||
2. **Given** a combatant NOT linked to a bestiary creature, **When** viewing the row, **Then** the initiative slot shows "--" (clickable to type a value manually) — no d20 button.
|
||||
3. **Given** a combatant whose initiative modifier is negative (e.g., -2), **When** the d20 button is clicked, **Then** the result ranges from -1 to 18.
|
||||
4. **Given** a combatant already has an initiative value, **Then** the d20 button is replaced by the value as plain text; clicking it opens the inline editor.
|
||||
|
||||
**Story INI-3 — Roll Initiative for All Eligible Combatants (P2)**
|
||||
As a DM, I want a "Roll All Initiative" button in the top bar to roll for all bestiary combatants at once so I can set up the initiative order quickly at the start of combat.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** 3 bestiary combatants (no initiative) and 1 manual combatant, **When** the roll-all button is clicked, **Then** all 3 bestiary combatants receive rolled initiative values; the manual combatant is unchanged.
|
||||
2. **Given** bestiary combatants that already have initiative values, **When** the roll-all button is clicked, **Then** those combatants are skipped; only bestiary combatants without initiative are rolled.
|
||||
3. **Given** no bestiary combatants, **When** the roll-all button is clicked, **Then** no changes occur.
|
||||
|
||||
**Story INI-4 — Display Initiative Modifier in Stat Block (P1)**
|
||||
As a DM viewing a creature's stat block, I want to see the creature's initiative modifier and passive initiative so I can reference it when rolling.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** a creature with DEX 9, CR 10, initiative proficiency multiplier 2 (e.g., Aboleth), **When** the stat block is displayed, **Then** the initiative line shows "Initiative +7 (17)" (-1 + 2x4 = +7; passive = 17).
|
||||
2. **Given** a creature with proficiency multiplier 1, **Then** the initiative modifier includes 1x the proficiency bonus.
|
||||
3. **Given** a creature with no `initiative` field in bestiary data, **Then** only the DEX modifier is shown (e.g., DEX 14 -> "Initiative +2 (12)").
|
||||
4. **Given** a creature with a negative initiative modifier (e.g., DEX 8, no proficiency), **Then** the line uses a minus sign (e.g., "Initiative -1 (9)").
|
||||
5. **Given** a combatant without bestiary data, **Then** no initiative line is shown in the stat block.
|
||||
|
||||
**Story INI-5 — Combatants Without Initiative (P2)**
|
||||
As a game master, I want combatants without initiative set to appear at the end of the turn order so the encounter remains usable while I am still entering values.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** combatants A (initiative 15), B (no initiative), C (initiative 10), **Then** order is A, C, B.
|
||||
2. **Given** A (no initiative) and B (no initiative), **Then** their relative order is preserved from when they were added.
|
||||
3. **Given** combatant A (no initiative), **When** initiative is set to 12, **Then** A moves to its correct sorted position.
|
||||
|
||||
### Requirements
|
||||
|
||||
- **FR-056**: System MUST allow setting an integer initiative value for any combatant.
|
||||
- **FR-057**: System MUST allow changing an existing initiative value.
|
||||
- **FR-058**: System MUST allow clearing (unsetting) a combatant's initiative value.
|
||||
- **FR-059**: System MUST reject non-integer initiative values and return a domain error.
|
||||
- **FR-060**: System MUST accept zero and negative integers as valid initiative values.
|
||||
- **FR-061**: System MUST automatically reorder combatants highest-to-lowest initiative whenever a value is set, changed, or cleared.
|
||||
- **FR-062**: Combatants without initiative MUST be placed after all combatants with initiative values.
|
||||
- **FR-063**: System MUST use a stable sort so combatants with equal initiative (or multiple without initiative) retain their relative order.
|
||||
- **FR-064**: System MUST preserve the active combatant's turn through reorders — the active turn tracks combatant identity, not list position.
|
||||
- **FR-065**: System MUST emit a domain event when a combatant's initiative is set or changed.
|
||||
- **FR-066**: System MUST display a d20 icon button in the initiative slot for every combatant that has a linked bestiary creature and does not yet have an initiative value.
|
||||
- **FR-067**: System MUST NOT display the d20 button for combatants without a linked bestiary creature; they see a "--" placeholder that is clickable to type a value.
|
||||
- **FR-068**: When the d20 button is clicked, the system MUST generate a uniform random integer in [1, 20], add the creature's initiative modifier, and set the result as the initiative value.
|
||||
- **FR-069**: The initiative modifier MUST be calculated as: DEX modifier + (proficiency multiplier x proficiency bonus), where DEX modifier = floor((DEX - 10) / 2) and proficiency bonus is derived from challenge rating.
|
||||
- **FR-070**: The initiative proficiency multiplier MUST be read from `initiative.proficiency` in bestiary data (0 if absent, 1 for proficiency, 2 for expertise).
|
||||
- **FR-071**: System MUST provide a "Roll All Initiative" button in the top bar. Clicking it MUST roll for every bestiary-linked combatant that does not already have an initiative value; all others MUST be left unchanged.
|
||||
- **FR-072**: After any initiative roll (single or batch), the encounter list MUST re-sort descending per existing behavior.
|
||||
- **FR-073**: Once a combatant has an initiative value, the d20 button is replaced by the value as plain text using a click-to-edit pattern (consistent with AC and name editing).
|
||||
- **FR-074**: The stat block header MUST display initiative in the format "Initiative +X (Y)" where X is the modifier (with + or - sign) and Y = 10 + X.
|
||||
- **FR-075**: The initiative display in the stat block MUST be positioned adjacent to the AC value, matching Monster Manual 2024 layout.
|
||||
- **FR-076**: No initiative line MUST be shown in the stat block for combatants without bestiary data.
|
||||
- **FR-077**: The initiative display in the stat block is display-only. It MUST NOT modify encounter turn order or trigger rolling automatically.
|
||||
- **FR-078**: The d20 roll-initiative icon MUST be displayed larger than 20x20 px while remaining contained within the initiative column.
|
||||
- **FR-079**: The "Roll All Initiative" d20 in the top bar MUST be sized at 24 px; the clear-encounter (trash) icon MUST be sized at 20 px. Both are visually grouped together, separated from turn navigation controls by spacing.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Setting initiative to zero is valid and treated normally in sorting.
|
||||
- Negative initiative values are valid (some game systems use them).
|
||||
- When a combatant is added without initiative during an ongoing encounter, it appears at the end of the order.
|
||||
- When all combatants have the same initiative value, their insertion order is preserved.
|
||||
- When the active combatant's own initiative changes causing a reorder, the active turn still points to that combatant.
|
||||
- When another combatant's initiative changes causing a reorder, the active turn still points to the current active combatant.
|
||||
- When a combatant's initiative modifier produces a roll result of 0 or negative, the value is stored as-is.
|
||||
- When multiple combatants roll the same initiative, ties are resolved by preserving relative insertion order.
|
||||
- A minus sign is used for negative modifiers in the stat block display, not a hyphen.
|
||||
- When a creature has DEX producing a modifier of exactly 0 and no initiative proficiency, display "Initiative +0 (10)".
|
||||
- For manually-added combatants: no initiative modifier is available, so no d20 button and no stat block initiative line.
|
||||
- The passive initiative value shown in the stat block is reference-only; only the active modifier (+X) is used for rolling.
|
||||
- Random number generation for dice rolls uses standard browser randomness; cryptographic randomness is not required.
|
||||
|
||||
---
|
||||
|
||||
## Combatant Row Layout
|
||||
|
||||
### User Stories
|
||||
|
||||
**Story ROW-1 — Compact Resting State (P1)**
|
||||
As a DM, I want each combatant row to display a minimal, uncluttered view at rest so I can scan the encounter list quickly during play.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** a combatant row is not hovered, **Then** no delta input, action buttons, "+" condition button, remove button, or Brain icon are visible.
|
||||
2. **Given** a combatant has no conditions, **Then** the row occupies exactly one line height at rest.
|
||||
3. **Given** a combatant has conditions applied, **Then** condition icons appear inline after the creature name on the same row (not a separate line).
|
||||
|
||||
**Story ROW-2 — Hover Reveals Controls (P1)**
|
||||
As a DM, I want secondary controls to appear when I hover over a row so they are accessible without cluttering the resting view.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** any combatant row, **When** hovered, **Then** the "+" condition button appears inline after the name/conditions.
|
||||
2. **Given** any combatant row, **When** hovered, **Then** the remove (x) button becomes visible.
|
||||
3. **Given** any combatant row, **When** hovered and concentration is inactive, **Then** the Brain icon becomes visible.
|
||||
4. **Given** the remove button appears on hover, **Then** no layout shift occurs — space is reserved.
|
||||
|
||||
**Story ROW-3 — Row Click Opens Stat Block (P1)**
|
||||
As a DM, I want to click anywhere on a bestiary combatant row to open its stat block so I have a large click target and a cleaner row without a dedicated book icon.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** a combatant has a linked bestiary creature, **When** the user clicks the name text or empty row space, **Then** the stat block panel opens.
|
||||
2. **Given** the user clicks an interactive element (initiative, HP, AC, condition icon, "+", "x", concentration), **Then** the stat block does NOT open — the element's own action fires.
|
||||
3. **Given** a combatant does NOT have a linked creature, **When** the row is clicked, **Then** nothing happens.
|
||||
4. **Given** viewing any bestiary combatant row, **Then** no BookOpen icon is visible.
|
||||
5. **Given** a bestiary combatant row, **When** the user hovers over non-interactive areas, **Then** the cursor indicates clickability.
|
||||
6. **Given** the stat block is already open for a creature, **When** the same row is clicked, **Then** the panel closes (toggle behavior).
|
||||
|
||||
### Requirements
|
||||
|
||||
- **FR-080**: Condition icons MUST render inline after the creature name within the same row.
|
||||
- **FR-081**: The "+" condition button MUST be hidden at rest and appear on row hover (or touch/focus on touch devices).
|
||||
- **FR-082**: The remove (x) button MUST be hidden at rest and appear on row hover (or touch/focus on touch devices).
|
||||
- **FR-083**: Layout space for the remove button MUST be reserved so appearing/disappearing does not cause layout shifts.
|
||||
- **FR-084**: Clicking non-interactive areas of a bestiary combatant row MUST open the stat block panel.
|
||||
- **FR-085**: Clicking interactive elements (initiative, HP, AC, conditions, "+", "x", concentration) MUST NOT trigger the stat block — only the element's own action.
|
||||
- **FR-086**: The BookOpen icon MUST be removed from the combatant row.
|
||||
- **FR-087**: Bestiary combatant rows MUST show a pointer cursor on hover over non-interactive areas.
|
||||
- **FR-088**: All existing interactions (condition add/remove, HP adjustment, AC editing, initiative editing/rolling, concentration toggle, combatant removal) MUST continue to work.
|
||||
- **FR-089**: Browser scrollbars MUST be styled to match the dark UI theme (thin, dark-colored scrollbar thumbs).
|
||||
- **FR-090**: Turn navigation (Previous/Next) MUST use StepBack/StepForward icons in outline button style with foreground-colored borders. Utility actions (d20/trash) MUST use ghost button style to create clear visual hierarchy.
|
||||
- **FR-091**: Previous and Next turn buttons MUST be positioned at the far left and far right of the top bar respectively, with the round/combatant info centered between them.
|
||||
- **FR-092**: The "Initiative Tracker" heading MUST be removed to maximize vertical space for combatants.
|
||||
- **FR-093**: All interactive elements in the row MUST remain keyboard-accessible (focusable and operable via keyboard).
|
||||
- **FR-094**: On touch devices, hover-only controls ("+" button, "x" button) MUST remain accessible via tap or focus.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- When a combatant has so many conditions that they exceed the available inline space, they wrap within the name column; row height increases but width does not.
|
||||
- The condition picker dropdown positions relative to the "+" button, flipping vertically if near the viewport edge.
|
||||
- When the stat block panel is already open and the user clicks the same row again, the panel closes.
|
||||
- Clicking the initiative area starts editing; it does not open the stat block.
|
||||
- Tablet-width screens (>= 768 px / Tailwind `md`): popovers and inline edits MUST remain accessible and not overflow or clip.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
- **SC-001**: Users can set max HP and adjust current HP for any combatant in under 5 seconds per action.
|
||||
- **SC-002**: `currentHp` never exceeds `maxHp` or drops below 0, regardless of input method.
|
||||
- **SC-003**: HP values survive a full page reload without data loss.
|
||||
- **SC-004**: A user can apply damage to a combatant in 2 interactions or fewer (click HP -> type number -> Enter).
|
||||
- **SC-005**: A user can apply healing in 3 interactions or fewer (click HP -> type number -> click Heal).
|
||||
- **SC-006**: Users can identify bloodied combatants at a glance without reading HP numbers — 100% of combatants below half HP display a distinct amber treatment.
|
||||
- **SC-007**: Users can identify unconscious/dead combatants at a glance — 100% of combatants at 0 HP or below display a distinct red treatment that differs from the bloodied indicator.
|
||||
- **SC-008**: Visual status indicators update within the same interaction frame as the HP change.
|
||||
- **SC-009**: Users can set an AC value for any combatant within the existing edit workflow with no additional steps.
|
||||
- **SC-010**: AC is visible at a glance in the encounter list without expanding or hovering.
|
||||
- **SC-011**: A condition can be added to a combatant in 2 clicks or fewer (click "+", click condition).
|
||||
- **SC-012**: A condition can be removed in 1 click (click the active icon tag).
|
||||
- **SC-013**: All 15 D&D 5e conditions are available and visually distinguishable by icon and color.
|
||||
- **SC-014**: Condition state survives a full page reload without data loss.
|
||||
- **SC-015**: Users can toggle concentration on/off for any combatant in a single click.
|
||||
- **SC-016**: Concentrating combatants are visually distinguishable from non-concentrating combatants at a glance.
|
||||
- **SC-017**: When a concentrating combatant takes damage, the visual pulse alert draws attention within the same interaction flow.
|
||||
- **SC-018**: Concentration state survives a full page reload.
|
||||
- **SC-019**: Users can set initiative for any combatant in a single action.
|
||||
- **SC-020**: After any initiative change, the encounter list immediately reflects the correct descending sort.
|
||||
- **SC-021**: A single combatant's initiative can be rolled with one click (the d20 button).
|
||||
- **SC-022**: All eligible combatants' initiative can be rolled with one click (roll-all button).
|
||||
- **SC-023**: Manual combatants (no stat block) are never affected by roll actions.
|
||||
- **SC-024**: Every bestiary creature displays an initiative value in its stat block matching D&D Beyond / Monster Manual 2024.
|
||||
- **SC-025**: The initiative line is visible without scrolling in the stat block header.
|
||||
- **SC-026**: Each combatant row without conditions takes up exactly one line height at rest.
|
||||
- **SC-027**: The DM can open a stat block by clicking anywhere on the combatant name area without needing a dedicated icon.
|
||||
- **SC-028**: The AC number is visually identifiable as armor class through the shield shape alone.
|
||||
- **SC-029**: No layout shift occurs when hovering/unhovering rows.
|
||||
- **SC-030**: All HP, AC, initiative, condition, and concentration interactions remain fully operable using only a keyboard.
|
||||
@@ -1,34 +0,0 @@
|
||||
# Specification Quality Checklist: Remove Combatant
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-03
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||
@@ -1,69 +0,0 @@
|
||||
# Data Model: Remove Combatant
|
||||
|
||||
**Feature**: 003-remove-combatant
|
||||
**Date**: 2026-03-03
|
||||
|
||||
## Existing Entities (no changes)
|
||||
|
||||
### Encounter
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| combatants | readonly Combatant[] | Ordered list of participants |
|
||||
| activeIndex | number | Index of the combatant whose turn it is |
|
||||
| roundNumber | number | Current round (≥ 1, never changes on removal) |
|
||||
|
||||
### Combatant
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| id | CombatantId (branded string) | Unique identifier |
|
||||
| name | string | Display name |
|
||||
|
||||
## New Event Type
|
||||
|
||||
### CombatantRemoved
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| type | "CombatantRemoved" (literal) | Event discriminant |
|
||||
| combatantId | CombatantId | ID of the removed combatant |
|
||||
| name | string | Name of the removed combatant |
|
||||
|
||||
Added to the `DomainEvent` discriminated union alongside `TurnAdvanced`, `RoundAdvanced`, and `CombatantAdded`.
|
||||
|
||||
## New Domain Function
|
||||
|
||||
### removeCombatant
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| encounter | Encounter | Current encounter state |
|
||||
| id | CombatantId | ID of combatant to remove |
|
||||
|
||||
**Returns**: `RemoveCombatantSuccess | DomainError`
|
||||
|
||||
### RemoveCombatantSuccess
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| encounter | Encounter | Updated encounter after removal |
|
||||
| events | DomainEvent[] | Exactly one CombatantRemoved event |
|
||||
|
||||
### DomainError (existing, reused)
|
||||
|
||||
Returned with code `"combatant-not-found"` when ID does not match any combatant.
|
||||
|
||||
## State Transition Rules
|
||||
|
||||
### activeIndex Adjustment
|
||||
|
||||
Given removal of combatant at index `removedIdx` with current `activeIndex`:
|
||||
|
||||
| Condition | New activeIndex |
|
||||
|-----------|----------------|
|
||||
| removedIdx > activeIndex | activeIndex (unchanged) |
|
||||
| removedIdx < activeIndex | activeIndex - 1 |
|
||||
| removedIdx === activeIndex, not last in list | activeIndex (next slides in) |
|
||||
| removedIdx === activeIndex, last in list | 0 (wrap) |
|
||||
| Only combatant removed (list becomes empty) | 0 |
|
||||
@@ -1,71 +0,0 @@
|
||||
# Implementation Plan: Remove Combatant
|
||||
|
||||
**Branch**: `003-remove-combatant` | **Date**: 2026-03-03 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/003-remove-combatant/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Add a `removeCombatant` pure domain function that removes a combatant by ID from an Encounter, correctly adjusts `activeIndex` to preserve turn integrity, keeps `roundNumber` unchanged, and emits a `CombatantRemoved` event. Wire through an application-layer use case and expose via a minimal UI remove action per combatant.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.x (strict mode, verbatimModuleSyntax)
|
||||
**Primary Dependencies**: React 19, Vite
|
||||
**Storage**: In-memory React state (local-first, single-user MVP)
|
||||
**Testing**: Vitest
|
||||
**Target Platform**: Web (localhost:5173 dev, production build via Vite)
|
||||
**Project Type**: Web application (monorepo: packages/domain, packages/application, apps/web)
|
||||
**Performance Goals**: N/A (local-first, small data sets)
|
||||
**Constraints**: Domain must be pure (no I/O); layer boundaries enforced by automated script
|
||||
**Scale/Scope**: Single-user, single encounter at a time
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Evidence |
|
||||
|-----------|--------|----------|
|
||||
| I. Deterministic Domain Core | PASS | `removeCombatant` is a pure function: same input → same output, no I/O |
|
||||
| II. Layered Architecture | PASS | Domain function → use case → React hook/UI. No layer violations. |
|
||||
| III. Agent Boundary | N/A | No agent layer involved in this feature |
|
||||
| IV. Clarification-First | PASS | Spec fully specifies all activeIndex adjustment rules; no ambiguity |
|
||||
| V. Escalation Gates | PASS | All functionality is within spec scope |
|
||||
| VI. MVP Baseline Language | PASS | No permanent bans introduced |
|
||||
| VII. No Gameplay Rules | PASS | Removal is encounter management, not gameplay mechanics |
|
||||
|
||||
**Gate result**: PASS — no violations.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/003-remove-combatant/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
packages/domain/src/
|
||||
├── remove-combatant.ts # Pure domain function
|
||||
├── events.ts # Add CombatantRemoved to DomainEvent union
|
||||
├── types.ts # Existing types (no changes expected)
|
||||
├── index.ts # Re-export removeCombatant
|
||||
└── __tests__/
|
||||
└── remove-combatant.test.ts # Acceptance scenarios from spec
|
||||
|
||||
packages/application/src/
|
||||
├── remove-combatant-use-case.ts # Orchestrates store.get → domain → store.save
|
||||
└── index.ts # Re-export use case
|
||||
|
||||
apps/web/src/
|
||||
├── hooks/use-encounter.ts # Add removeCombatant callback
|
||||
└── App.tsx # Add remove button per combatant + event display
|
||||
```
|
||||
|
||||
**Structure Decision**: Follows the existing monorepo layered architecture (packages/domain → packages/application → apps/web) exactly mirroring the addCombatant feature's file layout.
|
||||
@@ -1,39 +0,0 @@
|
||||
# Quickstart: Remove Combatant
|
||||
|
||||
**Feature**: 003-remove-combatant
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+, pnpm
|
||||
- Repository cloned, `pnpm install` run
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
git checkout 003-remove-combatant
|
||||
pnpm test:watch # Run tests in watch mode during development
|
||||
pnpm --filter web dev # Dev server at localhost:5173
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
pnpm check # Must pass before commit (format + lint + typecheck + test)
|
||||
```
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Domain**: Add `CombatantRemoved` event type → implement `removeCombatant` pure function → tests
|
||||
2. **Application**: Add `removeCombatantUseCase` → re-export
|
||||
3. **Web**: Add `removeCombatant` to `useEncounter` hook → add remove button in `App.tsx`
|
||||
|
||||
## Key Files
|
||||
|
||||
| Layer | File | Purpose |
|
||||
|-------|------|---------|
|
||||
| Domain | `packages/domain/src/remove-combatant.ts` | Pure removal function |
|
||||
| Domain | `packages/domain/src/events.ts` | CombatantRemoved event type |
|
||||
| Domain | `packages/domain/src/__tests__/remove-combatant.test.ts` | Acceptance tests |
|
||||
| Application | `packages/application/src/remove-combatant-use-case.ts` | Use case orchestration |
|
||||
| Web | `apps/web/src/hooks/use-encounter.ts` | Hook integration |
|
||||
| Web | `apps/web/src/App.tsx` | UI remove button |
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user