Compare commits
81 Commits
c4a90c9982
...
0.7.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
473f1eaefe | ||
|
|
971e0ded49 | ||
|
|
36dcfc5076 | ||
|
|
127ed01064 | ||
|
|
179c3658ad | ||
|
|
01f2bb3ff1 | ||
|
|
930301de71 | ||
|
|
aa806d4fb9 | ||
|
|
61bc274715 | ||
|
|
1932e837fb | ||
|
|
cce87318fb | ||
|
|
3ef2370a34 | ||
|
|
c75d148d1e | ||
|
|
63e233bd8d | ||
|
|
8c62ec28f2 | ||
|
|
72195e90f6 | ||
|
|
6ac8e67970 | ||
|
|
a4797d5b15 | ||
|
|
d48e39ced4 | ||
|
|
b7406c4b54 | ||
|
|
07cdd4867a | ||
|
|
85acb5c185 | ||
|
|
f9ef64bb00 | ||
|
|
bd39808000 | ||
|
|
75778884bd | ||
|
|
72d4f30e60 | ||
|
|
96b37d4bdd | ||
|
|
76ca78c169 | ||
|
|
b0c27b8ab9 | ||
|
|
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 | ||
|
|
99d1ba1bcd | ||
|
|
f029c1a85b | ||
|
|
d5f7b6ee36 | ||
|
|
5b0bac880d | ||
|
|
c6349928eb | ||
|
|
24198c25f1 | ||
|
|
11c4c0237e | ||
|
|
fa078be2f9 | ||
|
|
04a4f18f98 | ||
|
|
0c0da9b90e | ||
|
|
e59fd83292 | ||
|
|
febe892e15 | ||
|
|
78c6591973 | ||
|
|
2793a66672 | ||
|
|
56bced8481 | ||
|
|
97d3918cef | ||
|
|
7d440677be | ||
|
|
a0d85a07e3 | ||
|
|
1c40bf7889 | ||
|
|
8185fde0e8 | ||
|
|
a9c280a6d6 |
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
|
- 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.)
|
- Skip if project is purely internal (build scripts, one-off tools, etc.)
|
||||||
|
|
||||||
3. **Agent context update**:
|
**Output**: data-model.md, /contracts/*, quickstart.md
|
||||||
- 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
|
|
||||||
|
|
||||||
## Key rules
|
## 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
.gitignore
vendored
1
.gitignore
vendored
@@ -11,3 +11,4 @@ Thumbs.db
|
|||||||
.idea/
|
.idea/
|
||||||
coverage/
|
coverage/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
docs/agents/plans/
|
||||||
|
|||||||
8
.jscpd.json
Normal file
8
.jscpd.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"threshold": 5,
|
||||||
|
"minLines": 5,
|
||||||
|
"minTokens": 50,
|
||||||
|
"pattern": ["**/*.ts", "**/*.tsx"],
|
||||||
|
"ignore": ["node_modules", "dist", "build", "coverage", ".specify", "specs"],
|
||||||
|
"reporters": ["console"]
|
||||||
|
}
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
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:
|
Modified sections:
|
||||||
- Development Workflow: added automated-checks merge gate
|
- Development Workflow: specs are living feature documents; full pipeline for new features only
|
||||||
Templates requiring updates:
|
Templates requiring updates: none
|
||||||
- .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
|
|
||||||
-->
|
-->
|
||||||
# Encounter Console Constitution
|
# Encounter Console Constitution
|
||||||
|
|
||||||
@@ -29,7 +25,7 @@ be injected at the boundary, never sourced inside the domain layer.
|
|||||||
|
|
||||||
### II. Layered Architecture
|
### 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:
|
dependency direction:
|
||||||
|
|
||||||
1. **Domain** — pure types, state transitions, validation rules.
|
1. **Domain** — pure types, state transitions, validation rules.
|
||||||
@@ -39,34 +35,21 @@ dependency direction:
|
|||||||
interfaces that Adapters implement. May import Domain only.
|
interfaces that Adapters implement. May import Domain only.
|
||||||
3. **Adapters** — I/O, persistence, UI rendering, external APIs.
|
3. **Adapters** — I/O, persistence, UI rendering, external APIs.
|
||||||
May import Application and Domain.
|
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.
|
A module in an inner layer MUST NOT import from an outer layer.
|
||||||
|
|
||||||
### III. Agent Boundary
|
### III. Clarification-First
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
Before making any non-trivial assumption during specification,
|
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
|
question to the user. "Non-trivial" means any decision that would
|
||||||
alter observable behavior, data model shape, or public API surface.
|
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
|
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.
|
choose among valid alternatives.
|
||||||
|
|
||||||
### V. Escalation Gates
|
### IV. Escalation Gates
|
||||||
|
|
||||||
Any feature, requirement, or scope change not present in the current
|
Any feature, requirement, or scope change not present in the current
|
||||||
spec MUST be rejected at implementation time until the spec is
|
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`.
|
3. Update the spec via `/speckit.specify` or `/speckit.clarify`.
|
||||||
4. Only then proceed with implementation.
|
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
|
Constraints in this constitution and in specs MUST use MVP baseline
|
||||||
language ("MVP baseline does not include X") rather than permanent
|
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;
|
amendment. The current MVP baseline is local-first and single-user;
|
||||||
this is a starting scope, not a permanent restriction.
|
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,
|
This constitution MUST NOT contain concrete gameplay mechanics,
|
||||||
rule-system specifics, or encounter resolution logic. Such details
|
rule-system specifics, or encounter resolution logic. Such details
|
||||||
@@ -96,9 +79,9 @@ architecture, and quality — not product behavior.
|
|||||||
## Scope Constraints
|
## Scope Constraints
|
||||||
|
|
||||||
- The Encounter Console's primary focus is initiative tracking and
|
- The Encounter Console's primary focus is initiative tracking and
|
||||||
encounter state management. Adjacent capabilities (e.g., richer
|
encounter state management. Adjacent capabilities (e.g., bestiary
|
||||||
game-engine features) are not in the MVP baseline but may be
|
integration, richer game-engine features) may be added via spec
|
||||||
added via spec updates in future iterations.
|
updates.
|
||||||
- Technology choices, UI framework, and storage mechanism are
|
- Technology choices, UI framework, and storage mechanism are
|
||||||
spec-level decisions, not constitutional mandates.
|
spec-level decisions, not constitutional mandates.
|
||||||
- Testing strategy (unit, integration, contract) is determined per
|
- 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
|
- No change may be merged unless all automated checks (tests and
|
||||||
static analysis as defined by the project) pass.
|
static analysis as defined by the project) pass.
|
||||||
- Every feature begins with a spec (`/speckit.specify`).
|
- Specs describe **features**, not individual changes. Each spec is
|
||||||
- Implementation follows the plan → tasks → implement pipeline.
|
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.
|
- Domain logic MUST be testable without mocks for external systems.
|
||||||
- Long-running or multi-step state transitions SHOULD be verifiable
|
- Long-running or multi-step state transitions SHOULD be verifiable
|
||||||
through reproducible event logs or snapshot-style tests.
|
through reproducible event logs or snapshot-style tests.
|
||||||
- Commits SHOULD be atomic and map to individual tasks where
|
- Commits SHOULD be atomic and map to individual tasks where
|
||||||
practical.
|
practical.
|
||||||
- Layer boundary compliance MUST be verified by automated import
|
- Layer boundary compliance MUST be verified by automated import
|
||||||
rules or architectural tests. Agent-assisted or manual review MAY
|
rules or architectural tests.
|
||||||
supplement but not replace automated checks.
|
- 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
|
## Governance
|
||||||
|
|
||||||
@@ -142,4 +140,4 @@ MUST comply with its principles.
|
|||||||
**Compliance review**: Every spec and plan MUST include a
|
**Compliance review**: Every spec and plan MUST include a
|
||||||
Constitution Check section validating adherence to all principles.
|
Constitution Check section validating adherence to all principles.
|
||||||
|
|
||||||
**Version**: 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
|
||||||
|
|||||||
@@ -122,6 +122,10 @@ fi
|
|||||||
# Build list of available documents
|
# Build list of available documents
|
||||||
docs=()
|
docs=()
|
||||||
|
|
||||||
|
# Include required docs that passed validation above
|
||||||
|
[[ -f "$FEATURE_SPEC" ]] && docs+=("spec.md")
|
||||||
|
[[ -f "$IMPL_PLAN" ]] && docs+=("plan.md")
|
||||||
|
|
||||||
# Always check these optional docs
|
# Always check these optional docs
|
||||||
[[ -f "$RESEARCH" ]] && docs+=("research.md")
|
[[ -f "$RESEARCH" ]] && docs+=("research.md")
|
||||||
[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md")
|
[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md")
|
||||||
|
|||||||
88
CLAUDE.md
88
CLAUDE.md
@@ -5,7 +5,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```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 knip # Unused code detection (Knip)
|
||||||
pnpm test # Run all tests (Vitest)
|
pnpm test # Run all tests (Vitest)
|
||||||
pnpm test:watch # Tests in watch mode
|
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.
|
- **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.
|
- **Application** — Orchestrates domain calls via port interfaces (`EncounterStore`, `BestiarySourceCache`). No business logic here.
|
||||||
- **Web** — React adapter. Implements ports using hooks/state.
|
- **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.
|
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
|
## Conventions
|
||||||
|
|
||||||
- **Biome 2.0** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically.
|
- **Biome 2.0** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically.
|
||||||
@@ -39,7 +68,48 @@ 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.
|
- **Branded types** for identity values (e.g., `CombatantId`). Prefer immutability/`readonly` where practical.
|
||||||
- **Domain events** are plain data objects with a `type` discriminant — no classes.
|
- **Domain events** are plain data objects with a `type` discriminant — no classes.
|
||||||
- **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks.
|
- **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios 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.
|
||||||
|
|
||||||
|
## Self-Review Checklist
|
||||||
|
|
||||||
|
Before finishing a change, consider:
|
||||||
|
- Is this the simplest approach that solves the current problem?
|
||||||
|
- Is there duplication that hurts readability? (But don't abstract prematurely.)
|
||||||
|
- Are errors handled correctly and communicated sensibly to the user?
|
||||||
|
- Does the UI follow modern patterns and feel intuitive to interact with?
|
||||||
|
|
||||||
|
## 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)
|
## Constitution (key principles)
|
||||||
|
|
||||||
@@ -49,13 +119,11 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work:
|
|||||||
2. **Layered Architecture** — Domain → Application → Adapters. Never skip layers or reverse dependencies.
|
2. **Layered Architecture** — Domain → Application → Adapters. Never skip layers or reverse dependencies.
|
||||||
3. **Clarification-First** — Ask before making non-trivial assumptions.
|
3. **Clarification-First** — Ask before making non-trivial assumptions.
|
||||||
4. **MVP Baseline** — Say "MVP baseline does not include X", never permanent bans.
|
4. **MVP Baseline** — Say "MVP baseline does not include X", never permanent bans.
|
||||||
5. **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
|
## Active Technologies
|
||||||
- TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite (003-remove-combatant)
|
- TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Vite 6, Tailwind CSS v4, Lucide Reac (005-player-characters)
|
||||||
- In-memory React state (local-first, single-user MVP) (003-remove-combatant)
|
- localStorage (new key `"initiative:player-characters"`) (005-player-characters)
|
||||||
- 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)
|
|
||||||
|
|
||||||
## Recent Changes
|
## 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
|
## Prerequisites
|
||||||
|
|
||||||
- Node.js 22
|
- Node.js 22+
|
||||||
- pnpm 10.6+
|
- pnpm 10.6+
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
@@ -14,9 +22,7 @@ pnpm install
|
|||||||
pnpm --filter web dev
|
pnpm --filter web dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open the URL printed in your terminal (typically `http://localhost:5173`).
|
Open `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.
|
|
||||||
|
|
||||||
## Scripts
|
## 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 dev` | Start the dev server |
|
||||||
| `pnpm --filter web build` | Production build |
|
| `pnpm --filter web build` | Production build |
|
||||||
| `pnpm test` | Run all tests |
|
| `pnpm test` | Run all tests (Vitest) |
|
||||||
| `pnpm check` | Full merge gate (format, lint, typecheck, test) |
|
| `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.
|
||||||
|
|
||||||
|
|||||||
@@ -11,13 +11,23 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@initiative/application": "workspace:*",
|
"@initiative/application": "workspace:*",
|
||||||
"@initiative/domain": "workspace:*",
|
"@initiative/domain": "workspace:*",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"idb": "^8.0.3",
|
||||||
|
"lucide-react": "^0.577.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0",
|
||||||
|
"tailwind-merge": "^3.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
|
"tailwindcss": "^4.2.1",
|
||||||
"vite": "^6.2.0"
|
"vite": "^6.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,163 +1,390 @@
|
|||||||
import type { CombatantId } from "@initiative/domain";
|
import {
|
||||||
import { type FormEvent, useCallback, useRef, useState } from "react";
|
rollAllInitiativeUseCase,
|
||||||
|
rollInitiativeUseCase,
|
||||||
|
} from "@initiative/application";
|
||||||
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type Creature,
|
||||||
|
type CreatureId,
|
||||||
|
isDomainError,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { ActionBar } from "./components/action-bar";
|
||||||
|
import { BulkImportToasts } from "./components/bulk-import-toasts";
|
||||||
|
import { CombatantRow } from "./components/combatant-row";
|
||||||
|
import {
|
||||||
|
PlayerCharacterSection,
|
||||||
|
type PlayerCharacterSectionHandle,
|
||||||
|
} from "./components/player-character-section";
|
||||||
|
import { StatBlockPanel } from "./components/stat-block-panel";
|
||||||
|
import { Toast } from "./components/toast";
|
||||||
|
import { TurnNavigation } from "./components/turn-navigation";
|
||||||
|
import { type SearchResult, useBestiary } from "./hooks/use-bestiary";
|
||||||
|
import { useBulkImport } from "./hooks/use-bulk-import";
|
||||||
import { useEncounter } from "./hooks/use-encounter";
|
import { useEncounter } from "./hooks/use-encounter";
|
||||||
|
import { usePlayerCharacters } from "./hooks/use-player-characters";
|
||||||
|
import { useSidePanelState } from "./hooks/use-side-panel-state";
|
||||||
|
|
||||||
function formatEvent(e: ReturnType<typeof useEncounter>["events"][number]) {
|
function rollDice(): number {
|
||||||
switch (e.type) {
|
return Math.floor(Math.random() * 20) + 1;
|
||||||
case "TurnAdvanced":
|
|
||||||
return `Turn: ${e.previousCombatantId} → ${e.newCombatantId} (round ${e.roundNumber})`;
|
|
||||||
case "RoundAdvanced":
|
|
||||||
return `Round advanced to ${e.newRoundNumber}`;
|
|
||||||
case "CombatantAdded":
|
|
||||||
return `Added combatant: ${e.name}`;
|
|
||||||
case "CombatantRemoved":
|
|
||||||
return `Removed combatant: ${e.name}`;
|
|
||||||
case "CombatantUpdated":
|
|
||||||
return `Renamed combatant: ${e.oldName} → ${e.newName}`;
|
|
||||||
case "InitiativeSet":
|
|
||||||
return `Initiative: ${e.combatantId} ${e.previousValue ?? "unset"} → ${e.newValue ?? "unset"}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditableName({
|
function useActionBarAnimation(combatantCount: number) {
|
||||||
name,
|
const wasEmptyRef = useRef(combatantCount === 0);
|
||||||
combatantId,
|
const [settling, setSettling] = useState(false);
|
||||||
isActive,
|
const [rising, setRising] = useState(false);
|
||||||
onRename,
|
const [topBarExiting, setTopBarExiting] = useState(false);
|
||||||
}: {
|
|
||||||
name: string;
|
|
||||||
combatantId: CombatantId;
|
|
||||||
isActive: boolean;
|
|
||||||
onRename: (id: CombatantId, newName: string) => void;
|
|
||||||
}) {
|
|
||||||
const [editing, setEditing] = useState(false);
|
|
||||||
const [draft, setDraft] = useState(name);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const commit = useCallback(() => {
|
useLayoutEffect(() => {
|
||||||
const trimmed = draft.trim();
|
const nowEmpty = combatantCount === 0;
|
||||||
if (trimmed !== "" && trimmed !== name) {
|
if (wasEmptyRef.current && !nowEmpty) {
|
||||||
onRename(combatantId, trimmed);
|
setSettling(true);
|
||||||
|
} else if (!wasEmptyRef.current && nowEmpty) {
|
||||||
|
setRising(true);
|
||||||
|
setTopBarExiting(true);
|
||||||
}
|
}
|
||||||
setEditing(false);
|
wasEmptyRef.current = nowEmpty;
|
||||||
}, [draft, name, combatantId, onRename]);
|
}, [combatantCount]);
|
||||||
|
|
||||||
const startEditing = useCallback(() => {
|
const empty = combatantCount === 0;
|
||||||
setDraft(name);
|
const risingClass = rising ? " animate-rise-to-center" : "";
|
||||||
setEditing(true);
|
const settlingClass = settling ? " animate-settle-to-bottom" : "";
|
||||||
requestAnimationFrame(() => inputRef.current?.select());
|
const topBarClass = settling
|
||||||
}, [name]);
|
? " animate-slide-down-in"
|
||||||
|
: topBarExiting
|
||||||
|
? " absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out"
|
||||||
|
: "";
|
||||||
|
const showTopBar = !empty || topBarExiting;
|
||||||
|
|
||||||
if (editing) {
|
return {
|
||||||
return (
|
risingClass,
|
||||||
<input
|
settlingClass,
|
||||||
ref={inputRef}
|
topBarClass,
|
||||||
type="text"
|
showTopBar,
|
||||||
value={draft}
|
onSettleEnd: () => setSettling(false),
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onRiseEnd: () => setRising(false),
|
||||||
onBlur={commit}
|
onTopBarExitEnd: () => setTopBarExiting(false),
|
||||||
onKeyDown={(e) => {
|
};
|
||||||
if (e.key === "Enter") commit();
|
|
||||||
if (e.key === "Escape") setEditing(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button type="button" onClick={startEditing}>
|
|
||||||
{isActive ? `▶ ${name}` : name}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const {
|
const {
|
||||||
encounter,
|
encounter,
|
||||||
events,
|
isEmpty,
|
||||||
|
hasCreatureCombatants,
|
||||||
|
canRollAllInitiative,
|
||||||
advanceTurn,
|
advanceTurn,
|
||||||
|
retreatTurn,
|
||||||
addCombatant,
|
addCombatant,
|
||||||
|
clearEncounter,
|
||||||
removeCombatant,
|
removeCombatant,
|
||||||
editCombatant,
|
editCombatant,
|
||||||
setInitiative,
|
setInitiative,
|
||||||
|
setHp,
|
||||||
|
adjustHp,
|
||||||
|
setAc,
|
||||||
|
toggleCondition,
|
||||||
|
toggleConcentration,
|
||||||
|
addFromBestiary,
|
||||||
|
addFromPlayerCharacter,
|
||||||
|
makeStore,
|
||||||
} = useEncounter();
|
} = useEncounter();
|
||||||
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
|
||||||
const [nameInput, setNameInput] = useState("");
|
|
||||||
|
|
||||||
const handleAdd = (e: FormEvent) => {
|
const {
|
||||||
e.preventDefault();
|
characters: playerCharacters,
|
||||||
if (nameInput.trim() === "") return;
|
createCharacter: createPlayerCharacter,
|
||||||
addCombatant(nameInput);
|
editCharacter: editPlayerCharacter,
|
||||||
setNameInput("");
|
deleteCharacter: deletePlayerCharacter,
|
||||||
};
|
} = usePlayerCharacters();
|
||||||
|
|
||||||
|
const {
|
||||||
|
search,
|
||||||
|
getCreature,
|
||||||
|
isLoaded,
|
||||||
|
isSourceCached,
|
||||||
|
fetchAndCacheSource,
|
||||||
|
uploadAndCacheSource,
|
||||||
|
refreshCache,
|
||||||
|
} = useBestiary();
|
||||||
|
|
||||||
|
const bulkImport = useBulkImport();
|
||||||
|
const sidePanel = useSidePanelState();
|
||||||
|
|
||||||
|
const [rollSkippedCount, setRollSkippedCount] = useState(0);
|
||||||
|
|
||||||
|
const selectedCreature: Creature | null = sidePanel.selectedCreatureId
|
||||||
|
? (getCreature(sidePanel.selectedCreatureId) ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const pinnedCreature: Creature | null = sidePanel.pinnedCreatureId
|
||||||
|
? (getCreature(sidePanel.pinnedCreatureId) ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const handleAddFromBestiary = useCallback(
|
||||||
|
(result: SearchResult) => {
|
||||||
|
addFromBestiary(result);
|
||||||
|
},
|
||||||
|
[addFromBestiary],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCombatantStatBlock = useCallback(
|
||||||
|
(creatureId: string) => {
|
||||||
|
sidePanel.showCreature(creatureId as CreatureId);
|
||||||
|
},
|
||||||
|
[sidePanel.showCreature],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRollInitiative = useCallback(
|
||||||
|
(id: CombatantId) => {
|
||||||
|
rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature);
|
||||||
|
},
|
||||||
|
[makeStore, getCreature],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRollAllInitiative = useCallback(() => {
|
||||||
|
const result = rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
||||||
|
if (!isDomainError(result) && result.skippedNoSource > 0) {
|
||||||
|
setRollSkippedCount(result.skippedNoSource);
|
||||||
|
}
|
||||||
|
}, [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;
|
||||||
|
sidePanel.showCreature(cId);
|
||||||
|
},
|
||||||
|
[sidePanel.showCreature],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStartBulkImport = useCallback(
|
||||||
|
(baseUrl: string) => {
|
||||||
|
bulkImport.startImport(
|
||||||
|
baseUrl,
|
||||||
|
fetchAndCacheSource,
|
||||||
|
isSourceCached,
|
||||||
|
refreshCache,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[bulkImport.startImport, fetchAndCacheSource, isSourceCached, refreshCache],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBulkImportDone = useCallback(() => {
|
||||||
|
sidePanel.dismissPanel();
|
||||||
|
bulkImport.reset();
|
||||||
|
}, [sidePanel.dismissPanel, bulkImport.reset]);
|
||||||
|
|
||||||
|
const actionBarInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
|
||||||
|
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
|
||||||
|
|
||||||
|
// Auto-scroll to the active combatant when the turn changes
|
||||||
|
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
activeRowRef.current?.scrollIntoView({
|
||||||
|
block: "nearest",
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}, [encounter.activeIndex]);
|
||||||
|
|
||||||
|
// Auto-show stat block for the active combatant when turn changes,
|
||||||
|
// but only when the viewport is wide enough to show it alongside the tracker.
|
||||||
|
// Only react to activeIndex changes — not combatant reordering (e.g. Roll All).
|
||||||
|
const prevActiveIndexRef = useRef(encounter.activeIndex);
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevActiveIndexRef.current === encounter.activeIndex) return;
|
||||||
|
prevActiveIndexRef.current = encounter.activeIndex;
|
||||||
|
if (!window.matchMedia("(min-width: 1024px)").matches) return;
|
||||||
|
const active = encounter.combatants[encounter.activeIndex];
|
||||||
|
if (!active?.creatureId || !isLoaded) return;
|
||||||
|
sidePanel.showCreature(active.creatureId as CreatureId);
|
||||||
|
}, [
|
||||||
|
encounter.activeIndex,
|
||||||
|
encounter.combatants,
|
||||||
|
isLoaded,
|
||||||
|
sidePanel.showCreature,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="flex h-screen flex-col">
|
||||||
<h1>Initiative Tracker</h1>
|
<div className="relative mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-4 min-h-0">
|
||||||
|
{actionBarAnim.showTopBar && (
|
||||||
|
<div
|
||||||
|
className={`shrink-0 pt-8${actionBarAnim.topBarClass}`}
|
||||||
|
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
|
||||||
|
>
|
||||||
|
<TurnNavigation
|
||||||
|
encounter={encounter}
|
||||||
|
onAdvanceTurn={advanceTurn}
|
||||||
|
onRetreatTurn={retreatTurn}
|
||||||
|
onClearEncounter={clearEncounter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeCombatant && (
|
{isEmpty ? (
|
||||||
<p>
|
/* Empty state — ActionBar centered */
|
||||||
Round {encounter.roundNumber} — Current: {activeCombatant.name}
|
<div className="flex flex-1 items-center justify-center min-h-0 pb-[15%] pt-8">
|
||||||
</p>
|
<div
|
||||||
)}
|
className={`w-full${actionBarAnim.risingClass}`}
|
||||||
|
onAnimationEnd={actionBarAnim.onRiseEnd}
|
||||||
<ul>
|
>
|
||||||
{encounter.combatants.map((c, i) => (
|
<ActionBar
|
||||||
<li key={c.id}>
|
onAddCombatant={addCombatant}
|
||||||
<EditableName
|
onAddFromBestiary={handleAddFromBestiary}
|
||||||
name={c.name}
|
bestiarySearch={search}
|
||||||
combatantId={c.id}
|
bestiaryLoaded={isLoaded}
|
||||||
isActive={i === encounter.activeIndex}
|
onViewStatBlock={handleViewStatBlock}
|
||||||
onRename={editCombatant}
|
onBulkImport={sidePanel.showBulkImport}
|
||||||
/>{" "}
|
bulkImportDisabled={bulkImport.state.status === "loading"}
|
||||||
<input
|
inputRef={actionBarInputRef}
|
||||||
type="number"
|
playerCharacters={playerCharacters}
|
||||||
value={c.initiative ?? ""}
|
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||||
placeholder="Init"
|
onManagePlayers={() =>
|
||||||
style={{ width: "4em" }}
|
playerCharacterRef.current?.openManagement()
|
||||||
onChange={(e) => {
|
|
||||||
const raw = e.target.value;
|
|
||||||
if (raw === "") {
|
|
||||||
setInitiative(c.id, undefined);
|
|
||||||
} else {
|
|
||||||
const n = Number.parseInt(raw, 10);
|
|
||||||
if (!Number.isNaN(n)) {
|
|
||||||
setInitiative(c.id, n);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}}
|
onRollAllInitiative={handleRollAllInitiative}
|
||||||
/>{" "}
|
showRollAllInitiative={hasCreatureCombatants}
|
||||||
<button type="button" onClick={() => removeCombatant(c.id)}>
|
rollAllInitiativeDisabled={!canRollAllInitiative}
|
||||||
Remove
|
onOpenSourceManager={sidePanel.showSourceManager}
|
||||||
</button>
|
autoFocus
|
||||||
</li>
|
/>
|
||||||
))}
|
</div>
|
||||||
</ul>
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Scrollable area — combatant list */}
|
||||||
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
|
<div className="flex flex-col px-2 py-2">
|
||||||
|
{encounter.combatants.map((c, i) => (
|
||||||
|
<CombatantRow
|
||||||
|
key={c.id}
|
||||||
|
ref={i === encounter.activeIndex ? activeRowRef : null}
|
||||||
|
combatant={c}
|
||||||
|
isActive={i === encounter.activeIndex}
|
||||||
|
onRename={editCombatant}
|
||||||
|
onSetInitiative={setInitiative}
|
||||||
|
onRemove={removeCombatant}
|
||||||
|
onSetHp={setHp}
|
||||||
|
onAdjustHp={adjustHp}
|
||||||
|
onSetAc={setAc}
|
||||||
|
onToggleCondition={toggleCondition}
|
||||||
|
onToggleConcentration={toggleConcentration}
|
||||||
|
onShowStatBlock={
|
||||||
|
c.creatureId
|
||||||
|
? () => handleCombatantStatBlock(c.creatureId as string)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onRollInitiative={
|
||||||
|
c.creatureId ? handleRollInitiative : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleAdd}>
|
{/* Action Bar — fixed at bottom */}
|
||||||
<input
|
<div
|
||||||
type="text"
|
className={`shrink-0 pb-8${actionBarAnim.settlingClass}`}
|
||||||
value={nameInput}
|
onAnimationEnd={actionBarAnim.onSettleEnd}
|
||||||
onChange={(e) => setNameInput(e.target.value)}
|
>
|
||||||
placeholder="Combatant name"
|
<ActionBar
|
||||||
|
onAddCombatant={addCombatant}
|
||||||
|
onAddFromBestiary={handleAddFromBestiary}
|
||||||
|
bestiarySearch={search}
|
||||||
|
bestiaryLoaded={isLoaded}
|
||||||
|
onViewStatBlock={handleViewStatBlock}
|
||||||
|
onBulkImport={sidePanel.showBulkImport}
|
||||||
|
bulkImportDisabled={bulkImport.state.status === "loading"}
|
||||||
|
inputRef={actionBarInputRef}
|
||||||
|
playerCharacters={playerCharacters}
|
||||||
|
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||||
|
onManagePlayers={() =>
|
||||||
|
playerCharacterRef.current?.openManagement()
|
||||||
|
}
|
||||||
|
onRollAllInitiative={handleRollAllInitiative}
|
||||||
|
showRollAllInitiative={hasCreatureCombatants}
|
||||||
|
rollAllInitiativeDisabled={!canRollAllInitiative}
|
||||||
|
onOpenSourceManager={sidePanel.showSourceManager}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pinned Stat Block Panel (left) */}
|
||||||
|
{sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
|
||||||
|
<StatBlockPanel
|
||||||
|
creatureId={sidePanel.pinnedCreatureId}
|
||||||
|
creature={pinnedCreature}
|
||||||
|
isSourceCached={isSourceCached}
|
||||||
|
fetchAndCacheSource={fetchAndCacheSource}
|
||||||
|
uploadAndCacheSource={uploadAndCacheSource}
|
||||||
|
refreshCache={refreshCache}
|
||||||
|
panelRole="pinned"
|
||||||
|
isCollapsed={false}
|
||||||
|
onToggleCollapse={() => {}}
|
||||||
|
onPin={() => {}}
|
||||||
|
onUnpin={sidePanel.unpin}
|
||||||
|
showPinButton={false}
|
||||||
|
side="left"
|
||||||
|
onDismiss={() => {}}
|
||||||
/>
|
/>
|
||||||
<button type="submit">Add Combatant</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<button type="button" onClick={advanceTurn}>
|
|
||||||
Next Turn
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{events.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h2>Events</h2>
|
|
||||||
<ul>
|
|
||||||
{events.map((e, i) => (
|
|
||||||
<li key={`${e.type}-${i}`}>{formatEvent(e)}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Browse Stat Block Panel (right) */}
|
||||||
|
<StatBlockPanel
|
||||||
|
creatureId={sidePanel.selectedCreatureId}
|
||||||
|
creature={selectedCreature}
|
||||||
|
isSourceCached={isSourceCached}
|
||||||
|
fetchAndCacheSource={fetchAndCacheSource}
|
||||||
|
uploadAndCacheSource={uploadAndCacheSource}
|
||||||
|
refreshCache={refreshCache}
|
||||||
|
panelRole="browse"
|
||||||
|
isCollapsed={sidePanel.isRightPanelCollapsed}
|
||||||
|
onToggleCollapse={sidePanel.toggleCollapse}
|
||||||
|
onPin={sidePanel.togglePin}
|
||||||
|
onUnpin={() => {}}
|
||||||
|
showPinButton={sidePanel.isWideDesktop && !!selectedCreature}
|
||||||
|
side="right"
|
||||||
|
onDismiss={sidePanel.dismissPanel}
|
||||||
|
bulkImportMode={sidePanel.bulkImportMode}
|
||||||
|
bulkImportState={bulkImport.state}
|
||||||
|
onStartBulkImport={handleStartBulkImport}
|
||||||
|
onBulkImportDone={handleBulkImportDone}
|
||||||
|
sourceManagerMode={sidePanel.sourceManagerMode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BulkImportToasts
|
||||||
|
state={bulkImport.state}
|
||||||
|
visible={!sidePanel.bulkImportMode || sidePanel.isRightPanelCollapsed}
|
||||||
|
onReset={bulkImport.reset}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{rollSkippedCount > 0 && (
|
||||||
|
<Toast
|
||||||
|
message={`${rollSkippedCount} skipped — bestiary source not loaded`}
|
||||||
|
onDismiss={() => setRollSkippedCount(0)}
|
||||||
|
autoDismissMs={4000}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PlayerCharacterSection
|
||||||
|
ref={playerCharacterRef}
|
||||||
|
characters={playerCharacters}
|
||||||
|
onCreateCharacter={createPlayerCharacter}
|
||||||
|
onEditCharacter={editPlayerCharacter}
|
||||||
|
onDeleteCharacter={deletePlayerCharacter}
|
||||||
|
/>
|
||||||
</div>
|
</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-collapse-pin.test.tsx
Normal file
263
apps/web/src/__tests__/stat-block-collapse-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";
|
||||||
|
isCollapsed?: boolean;
|
||||||
|
onToggleCollapse?: () => 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,
|
||||||
|
isCollapsed: false,
|
||||||
|
onToggleCollapse: 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 Collapse/Expand and Pin", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockMatchMedia(true); // desktop by default
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("US1: Collapse and Expand", () => {
|
||||||
|
it("shows collapse button instead of close button on desktop", () => {
|
||||||
|
renderPanel();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Collapse 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 collapsed tab with creature name when isCollapsed is true", () => {
|
||||||
|
renderPanel({ isCollapsed: true });
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Expand stat block panel" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onToggleCollapse when collapse button is clicked", () => {
|
||||||
|
const props = renderPanel();
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Collapse stat block panel" }),
|
||||||
|
);
|
||||||
|
expect(props.onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onToggleCollapse when collapsed tab is clicked", () => {
|
||||||
|
const props = renderPanel({ isCollapsed: true });
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Expand stat block panel" }),
|
||||||
|
);
|
||||||
|
expect(props.onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies translate-x class when collapsed (right side)", () => {
|
||||||
|
renderPanel({ isCollapsed: true, side: "right" });
|
||||||
|
const panel = screen
|
||||||
|
.getByRole("button", { name: "Expand stat block panel" })
|
||||||
|
.closest("div");
|
||||||
|
expect(panel?.className).toContain("translate-x-[calc(100%-40px)]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies translate-x-0 when expanded", () => {
|
||||||
|
renderPanel({ isCollapsed: false });
|
||||||
|
const foldBtn = screen.getByRole("button", {
|
||||||
|
name: "Collapse 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 collapse button instead of X close button on mobile drawer", () => {
|
||||||
|
renderPanel();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Collapse stat block panel" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// No X close icon button — only backdrop dismiss and collapse 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"
|
||||||
|
isCollapsed={false}
|
||||||
|
onToggleCollapse={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: "Collapse 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: Collapse independence with pinned panel", () => {
|
||||||
|
it("pinned panel has no collapse button", () => {
|
||||||
|
renderPanel({ panelRole: "pinned", side: "left" });
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: /collapse/i }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pinned panel is always expanded (no translate offset)", () => {
|
||||||
|
renderPanel({ panelRole: "pinned", side: "left", isCollapsed: 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
333
apps/web/src/adapters/__tests__/bestiary-adapter.test.ts
Normal file
333
apps/web/src/adapters/__tests__/bestiary-adapter.test.ts
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
normalizeBestiary,
|
||||||
|
setSourceDisplayNames,
|
||||||
|
} from "../bestiary-adapter.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
setSourceDisplayNames({ XMM: "MM 2024" });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeBestiary", () => {
|
||||||
|
it("normalizes a simple creature", () => {
|
||||||
|
const raw = {
|
||||||
|
monster: [
|
||||||
|
{
|
||||||
|
name: "Goblin Warrior",
|
||||||
|
source: "XMM",
|
||||||
|
size: ["S"],
|
||||||
|
type: { type: "fey", tags: ["goblinoid"] },
|
||||||
|
alignment: ["C", "N"],
|
||||||
|
ac: [15],
|
||||||
|
hp: { average: 10, formula: "3d6" },
|
||||||
|
speed: { walk: 30 },
|
||||||
|
str: 8,
|
||||||
|
dex: 15,
|
||||||
|
con: 10,
|
||||||
|
int: 10,
|
||||||
|
wis: 8,
|
||||||
|
cha: 8,
|
||||||
|
skill: { stealth: "+6" },
|
||||||
|
senses: ["Darkvision 60 ft."],
|
||||||
|
passive: 9,
|
||||||
|
languages: ["Common", "Goblin"],
|
||||||
|
cr: "1/4",
|
||||||
|
action: [
|
||||||
|
{
|
||||||
|
name: "Scimitar",
|
||||||
|
entries: [
|
||||||
|
"{@atkr m} {@hit 4}, reach 5 ft. {@h}5 ({@damage 1d6 + 2}) Slashing damage.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bonus: [
|
||||||
|
{
|
||||||
|
name: "Nimble Escape",
|
||||||
|
entries: [
|
||||||
|
"The goblin takes the {@action Disengage|XPHB} or {@action Hide|XPHB} action.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const creatures = normalizeBestiary(raw);
|
||||||
|
expect(creatures).toHaveLength(1);
|
||||||
|
|
||||||
|
const c = creatures[0];
|
||||||
|
expect(c.id).toBe("xmm:goblin-warrior");
|
||||||
|
expect(c.name).toBe("Goblin Warrior");
|
||||||
|
expect(c.source).toBe("XMM");
|
||||||
|
expect(c.sourceDisplayName).toBe("MM 2024");
|
||||||
|
expect(c.size).toBe("Small");
|
||||||
|
expect(c.type).toBe("Fey (Goblinoid)");
|
||||||
|
expect(c.alignment).toBe("Chaotic Neutral");
|
||||||
|
expect(c.ac).toBe(15);
|
||||||
|
expect(c.hp).toEqual({ average: 10, formula: "3d6" });
|
||||||
|
expect(c.speed).toBe("30 ft.");
|
||||||
|
expect(c.abilities.dex).toBe(15);
|
||||||
|
expect(c.cr).toBe("1/4");
|
||||||
|
expect(c.proficiencyBonus).toBe(2);
|
||||||
|
expect(c.passive).toBe(9);
|
||||||
|
expect(c.skills).toBe("Stealth +6");
|
||||||
|
expect(c.senses).toBe("Darkvision 60 ft.");
|
||||||
|
expect(c.languages).toBe("Common, Goblin");
|
||||||
|
expect(c.actions).toHaveLength(1);
|
||||||
|
expect(c.actions?.[0].text).toContain("Melee Attack Roll:");
|
||||||
|
expect(c.actions?.[0].text).not.toContain("{@");
|
||||||
|
expect(c.bonusActions).toHaveLength(1);
|
||||||
|
expect(c.bonusActions?.[0].text).toContain("Disengage");
|
||||||
|
expect(c.bonusActions?.[0].text).not.toContain("{@");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes a creature with legendary actions", () => {
|
||||||
|
const raw = {
|
||||||
|
monster: [
|
||||||
|
{
|
||||||
|
name: "Aboleth",
|
||||||
|
source: "XMM",
|
||||||
|
size: ["L"],
|
||||||
|
type: "aberration",
|
||||||
|
alignment: ["L", "E"],
|
||||||
|
ac: [17],
|
||||||
|
hp: { average: 135, formula: "18d10 + 36" },
|
||||||
|
speed: { walk: 10, swim: 40 },
|
||||||
|
str: 21,
|
||||||
|
dex: 9,
|
||||||
|
con: 15,
|
||||||
|
int: 18,
|
||||||
|
wis: 15,
|
||||||
|
cha: 18,
|
||||||
|
save: { con: "+6", int: "+8", wis: "+6" },
|
||||||
|
senses: ["Darkvision 120 ft."],
|
||||||
|
passive: 12,
|
||||||
|
languages: ["Deep Speech", "Telepathy 120 ft."],
|
||||||
|
cr: "10",
|
||||||
|
legendary: [
|
||||||
|
{
|
||||||
|
name: "Lash",
|
||||||
|
entries: ["The aboleth makes one Tentacle attack."],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const creatures = normalizeBestiary(raw);
|
||||||
|
const c = creatures[0];
|
||||||
|
expect(c.legendaryActions).toBeDefined();
|
||||||
|
expect(c.legendaryActions?.preamble).toContain("3 Legendary Actions");
|
||||||
|
expect(c.legendaryActions?.entries).toHaveLength(1);
|
||||||
|
expect(c.legendaryActions?.entries[0].name).toBe("Lash");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes a creature with spellcasting", () => {
|
||||||
|
const raw = {
|
||||||
|
monster: [
|
||||||
|
{
|
||||||
|
name: "Test Caster",
|
||||||
|
source: "XMM",
|
||||||
|
size: ["M"],
|
||||||
|
type: "humanoid",
|
||||||
|
ac: [12],
|
||||||
|
hp: { average: 40, formula: "9d8" },
|
||||||
|
speed: { walk: 30 },
|
||||||
|
str: 10,
|
||||||
|
dex: 14,
|
||||||
|
con: 10,
|
||||||
|
int: 17,
|
||||||
|
wis: 12,
|
||||||
|
cha: 11,
|
||||||
|
passive: 11,
|
||||||
|
cr: "6",
|
||||||
|
spellcasting: [
|
||||||
|
{
|
||||||
|
name: "Spellcasting",
|
||||||
|
headerEntries: [
|
||||||
|
"The caster casts spells using Intelligence (spell save {@dc 15}):",
|
||||||
|
],
|
||||||
|
will: ["{@spell Detect Magic|XPHB}", "{@spell Mage Hand|XPHB}"],
|
||||||
|
daily: {
|
||||||
|
"2e": ["{@spell Fireball|XPHB}"],
|
||||||
|
"1": ["{@spell Dimension Door|XPHB}"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const creatures = normalizeBestiary(raw);
|
||||||
|
const c = creatures[0];
|
||||||
|
expect(c.spellcasting).toHaveLength(1);
|
||||||
|
const sc = c.spellcasting?.[0];
|
||||||
|
expect(sc).toBeDefined();
|
||||||
|
expect(sc?.name).toBe("Spellcasting");
|
||||||
|
expect(sc?.headerText).toContain("DC 15");
|
||||||
|
expect(sc?.headerText).not.toContain("{@");
|
||||||
|
expect(sc?.atWill).toEqual(["Detect Magic", "Mage Hand"]);
|
||||||
|
expect(sc?.daily).toHaveLength(2);
|
||||||
|
expect(sc?.daily).toContainEqual({
|
||||||
|
uses: 2,
|
||||||
|
each: true,
|
||||||
|
spells: ["Fireball"],
|
||||||
|
});
|
||||||
|
expect(sc?.daily).toContainEqual({
|
||||||
|
uses: 1,
|
||||||
|
each: false,
|
||||||
|
spells: ["Dimension Door"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes a creature with object-type type field", () => {
|
||||||
|
const raw = {
|
||||||
|
monster: [
|
||||||
|
{
|
||||||
|
name: "Swarm of Bats",
|
||||||
|
source: "XMM",
|
||||||
|
size: ["L"],
|
||||||
|
type: { type: "beast", swarmSize: "T" },
|
||||||
|
ac: [12],
|
||||||
|
hp: { average: 11, formula: "2d10" },
|
||||||
|
speed: { walk: 5, fly: 30 },
|
||||||
|
str: 5,
|
||||||
|
dex: 15,
|
||||||
|
con: 10,
|
||||||
|
int: 2,
|
||||||
|
wis: 12,
|
||||||
|
cha: 4,
|
||||||
|
passive: 11,
|
||||||
|
resist: ["bludgeoning", "piercing", "slashing"],
|
||||||
|
conditionImmune: ["charmed", "frightened"],
|
||||||
|
cr: "1/4",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const creatures = normalizeBestiary(raw);
|
||||||
|
const c = creatures[0];
|
||||||
|
expect(c.type).toBe("Swarm of Tiny Beasts");
|
||||||
|
expect(c.resist).toBe("Bludgeoning, Piercing, Slashing");
|
||||||
|
expect(c.conditionImmune).toBe("Charmed, Frightened");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes a creature with conditional resistances", () => {
|
||||||
|
const raw = {
|
||||||
|
monster: [
|
||||||
|
{
|
||||||
|
name: "Half-Dragon",
|
||||||
|
source: "XMM",
|
||||||
|
size: ["M"],
|
||||||
|
type: "humanoid",
|
||||||
|
ac: [18],
|
||||||
|
hp: { average: 65, formula: "10d8 + 20" },
|
||||||
|
speed: { walk: 30 },
|
||||||
|
str: 16,
|
||||||
|
dex: 13,
|
||||||
|
con: 14,
|
||||||
|
int: 10,
|
||||||
|
wis: 11,
|
||||||
|
cha: 10,
|
||||||
|
passive: 10,
|
||||||
|
cr: "5",
|
||||||
|
resist: [
|
||||||
|
{
|
||||||
|
special: "Damage type chosen for the Draconic Origin trait",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const creatures = normalizeBestiary(raw);
|
||||||
|
const c = creatures[0];
|
||||||
|
expect(c.resist).toBe("Damage type chosen for the Draconic Origin trait");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes a creature with multiple sizes", () => {
|
||||||
|
const raw = {
|
||||||
|
monster: [
|
||||||
|
{
|
||||||
|
name: "Aberrant Cultist",
|
||||||
|
source: "XMM",
|
||||||
|
size: ["S", "M"],
|
||||||
|
type: "humanoid",
|
||||||
|
ac: [13],
|
||||||
|
hp: { average: 22, formula: "4d8 + 4" },
|
||||||
|
speed: { walk: 30 },
|
||||||
|
str: 11,
|
||||||
|
dex: 14,
|
||||||
|
con: 12,
|
||||||
|
int: 10,
|
||||||
|
wis: 13,
|
||||||
|
cha: 8,
|
||||||
|
passive: 11,
|
||||||
|
cr: "1/2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const creatures = normalizeBestiary(raw);
|
||||||
|
expect(creatures[0].size).toBe("Small or Medium");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes a creature with CR as object", () => {
|
||||||
|
const raw = {
|
||||||
|
monster: [
|
||||||
|
{
|
||||||
|
name: "Dragon",
|
||||||
|
source: "XMM",
|
||||||
|
size: ["H"],
|
||||||
|
type: "dragon",
|
||||||
|
ac: [19],
|
||||||
|
hp: { average: 256, formula: "19d12 + 133" },
|
||||||
|
speed: { walk: 40 },
|
||||||
|
str: 27,
|
||||||
|
dex: 10,
|
||||||
|
con: 25,
|
||||||
|
int: 16,
|
||||||
|
wis: 13,
|
||||||
|
cha: 23,
|
||||||
|
passive: 23,
|
||||||
|
cr: { cr: "17", xpLair: 20000 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const creatures = normalizeBestiary(raw);
|
||||||
|
expect(creatures[0].cr).toBe("17");
|
||||||
|
expect(creatures[0].proficiencyBonus).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles fly speed with hover condition", () => {
|
||||||
|
const raw = {
|
||||||
|
monster: [
|
||||||
|
{
|
||||||
|
name: "Air Elemental",
|
||||||
|
source: "XMM",
|
||||||
|
size: ["L"],
|
||||||
|
type: "elemental",
|
||||||
|
ac: [15],
|
||||||
|
hp: { average: 90, formula: "12d10 + 24" },
|
||||||
|
speed: {
|
||||||
|
walk: 10,
|
||||||
|
fly: { number: 90, condition: "(hover)" },
|
||||||
|
canHover: true,
|
||||||
|
},
|
||||||
|
str: 14,
|
||||||
|
dex: 20,
|
||||||
|
con: 14,
|
||||||
|
int: 6,
|
||||||
|
wis: 10,
|
||||||
|
cha: 6,
|
||||||
|
passive: 10,
|
||||||
|
cr: "5",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const creatures = normalizeBestiary(raw);
|
||||||
|
expect(creatures[0].speed).toBe("10 ft., fly 90 ft. (hover)");
|
||||||
|
});
|
||||||
|
});
|
||||||
138
apps/web/src/adapters/__tests__/strip-tags.test.ts
Normal file
138
apps/web/src/adapters/__tests__/strip-tags.test.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { stripTags } from "../strip-tags.js";
|
||||||
|
|
||||||
|
describe("stripTags", () => {
|
||||||
|
it("returns text unchanged when no tags present", () => {
|
||||||
|
expect(stripTags("Hello world")).toBe("Hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@spell Name|Source} to Name", () => {
|
||||||
|
expect(stripTags("{@spell Fireball|XPHB}")).toBe("Fireball");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@condition Name|Source} to Name", () => {
|
||||||
|
expect(stripTags("{@condition Frightened|XPHB}")).toBe("Frightened");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@damage dice} to dice", () => {
|
||||||
|
expect(stripTags("{@damage 2d10}")).toBe("2d10");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@dice value} to value", () => {
|
||||||
|
expect(stripTags("{@dice 5d10}")).toBe("5d10");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@dc N} to DC N", () => {
|
||||||
|
expect(stripTags("{@dc 15}")).toBe("DC 15");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@hit N} to +N", () => {
|
||||||
|
expect(stripTags("{@hit 5}")).toBe("+5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@h} to Hit: ", () => {
|
||||||
|
expect(stripTags("{@h}")).toBe("Hit: ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@hom} to Hit or Miss: ", () => {
|
||||||
|
expect(stripTags("{@hom}")).toBe("Hit or Miss: ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@atkr m} to Melee Attack Roll:", () => {
|
||||||
|
expect(stripTags("{@atkr m}")).toBe("Melee Attack Roll:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@atkr r} to Ranged Attack Roll:", () => {
|
||||||
|
expect(stripTags("{@atkr r}")).toBe("Ranged Attack Roll:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@atkr m,r} to Melee or Ranged Attack Roll:", () => {
|
||||||
|
expect(stripTags("{@atkr m,r}")).toBe("Melee or Ranged Attack Roll:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@recharge 5} to (Recharge 5-6)", () => {
|
||||||
|
expect(stripTags("{@recharge 5}")).toBe("(Recharge 5-6)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@recharge} to (Recharge 6)", () => {
|
||||||
|
expect(stripTags("{@recharge}")).toBe("(Recharge 6)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@actSave wis} to Wisdom saving throw", () => {
|
||||||
|
expect(stripTags("{@actSave wis}")).toBe("Wisdom saving throw");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@actSaveFail} to Failure:", () => {
|
||||||
|
expect(stripTags("{@actSaveFail}")).toBe("Failure:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@actSaveFail 2} to Failure by 2 or More:", () => {
|
||||||
|
expect(stripTags("{@actSaveFail 2}")).toBe("Failure by 2 or More:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@actSaveSuccess} to Success:", () => {
|
||||||
|
expect(stripTags("{@actSaveSuccess}")).toBe("Success:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@actTrigger} to Trigger:", () => {
|
||||||
|
expect(stripTags("{@actTrigger}")).toBe("Trigger:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@actResponse} to Response:", () => {
|
||||||
|
expect(stripTags("{@actResponse}")).toBe("Response:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@variantrule Name|Source|Display} to Display", () => {
|
||||||
|
expect(stripTags("{@variantrule Cone [Area of Effect]|XPHB|Cone}")).toBe(
|
||||||
|
"Cone",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@action Name|Source|Display} to Display", () => {
|
||||||
|
expect(stripTags("{@action Disengage|XPHB|Disengage}")).toBe("Disengage");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@skill Name|Source} to Name", () => {
|
||||||
|
expect(stripTags("{@skill Perception|XPHB}")).toBe("Perception");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@creature Name|Source} to Name", () => {
|
||||||
|
expect(stripTags("{@creature Goblin|XPHB}")).toBe("Goblin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@hazard Name|Source} to Name", () => {
|
||||||
|
expect(stripTags("{@hazard Lava|XPHB}")).toBe("Lava");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@status Name|Source} to Name", () => {
|
||||||
|
expect(stripTags("{@status Bloodied|XPHB}")).toBe("Bloodied");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles unknown tags by extracting first segment", () => {
|
||||||
|
expect(stripTags("{@unknown Something|else}")).toBe("Something");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple tags in the same string", () => {
|
||||||
|
expect(stripTags("{@hit 4}, reach 5 ft. {@h}5 ({@damage 1d6 + 2})")).toBe(
|
||||||
|
"+4, reach 5 ft. Hit: 5 (1d6 + 2)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles nested tags gracefully", () => {
|
||||||
|
expect(
|
||||||
|
stripTags("The spell {@spell Fireball|XPHB} deals {@damage 8d6}."),
|
||||||
|
).toBe("The spell Fireball deals 8d6.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles text with no tags", () => {
|
||||||
|
expect(stripTags("Just plain text.")).toBe("Just plain text.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@actSaveSuccessOrFail} to Success or Failure:", () => {
|
||||||
|
expect(stripTags("{@actSaveSuccessOrFail}")).toBe("Success or Failure:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@action Name|Source} to Name when no display text", () => {
|
||||||
|
expect(stripTags("{@action Disengage|XPHB}")).toBe("Disengage");
|
||||||
|
});
|
||||||
|
});
|
||||||
460
apps/web/src/adapters/bestiary-adapter.ts
Normal file
460
apps/web/src/adapters/bestiary-adapter.ts
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
import type {
|
||||||
|
Creature,
|
||||||
|
CreatureId,
|
||||||
|
DailySpells,
|
||||||
|
LegendaryBlock,
|
||||||
|
SpellcastingBlock,
|
||||||
|
TraitBlock,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { creatureId, proficiencyBonus } from "@initiative/domain";
|
||||||
|
import { stripTags } from "./strip-tags.js";
|
||||||
|
|
||||||
|
// --- Raw 5etools types (minimal, for parsing) ---
|
||||||
|
|
||||||
|
interface RawMonster {
|
||||||
|
name: string;
|
||||||
|
source: string;
|
||||||
|
size: string[];
|
||||||
|
type: string | { type: string; tags?: string[]; swarmSize?: string };
|
||||||
|
alignment?: string[];
|
||||||
|
ac: (number | { ac: number; from?: string[] } | { special: string })[];
|
||||||
|
hp: { average?: number; formula?: string; special?: string };
|
||||||
|
speed: Record<
|
||||||
|
string,
|
||||||
|
number | { number: number; condition?: string } | boolean
|
||||||
|
>;
|
||||||
|
str: number;
|
||||||
|
dex: number;
|
||||||
|
con: number;
|
||||||
|
int: number;
|
||||||
|
wis: number;
|
||||||
|
cha: number;
|
||||||
|
save?: Record<string, string>;
|
||||||
|
skill?: Record<string, string>;
|
||||||
|
senses?: string[];
|
||||||
|
passive: number;
|
||||||
|
resist?: (string | { special: string })[];
|
||||||
|
immune?: (string | { special: string })[];
|
||||||
|
vulnerable?: (string | { special: string })[];
|
||||||
|
conditionImmune?: string[];
|
||||||
|
languages?: string[];
|
||||||
|
cr?: string | { cr: string };
|
||||||
|
trait?: RawEntry[];
|
||||||
|
action?: RawEntry[];
|
||||||
|
bonus?: RawEntry[];
|
||||||
|
reaction?: RawEntry[];
|
||||||
|
legendary?: RawEntry[];
|
||||||
|
legendaryActions?: number;
|
||||||
|
legendaryActionsLair?: number;
|
||||||
|
legendaryHeader?: string[];
|
||||||
|
spellcasting?: RawSpellcasting[];
|
||||||
|
initiative?: { proficiency?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawEntry {
|
||||||
|
name: string;
|
||||||
|
entries: (string | RawEntryObject)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawEntryObject {
|
||||||
|
type: string;
|
||||||
|
items?: (
|
||||||
|
| string
|
||||||
|
| { type: string; name?: string; entries?: (string | RawEntryObject)[] }
|
||||||
|
)[];
|
||||||
|
style?: string;
|
||||||
|
name?: string;
|
||||||
|
entries?: (string | RawEntryObject)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawSpellcasting {
|
||||||
|
name: string;
|
||||||
|
headerEntries: string[];
|
||||||
|
will?: string[];
|
||||||
|
daily?: Record<string, string[]>;
|
||||||
|
rest?: Record<string, string[]>;
|
||||||
|
hidden?: string[];
|
||||||
|
ability?: string;
|
||||||
|
displayAs?: string;
|
||||||
|
legendary?: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Source mapping ---
|
||||||
|
|
||||||
|
let sourceDisplayNames: Record<string, string> = {};
|
||||||
|
|
||||||
|
export function setSourceDisplayNames(names: Record<string, string>): void {
|
||||||
|
sourceDisplayNames = names;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Size mapping ---
|
||||||
|
|
||||||
|
const SIZE_MAP: Record<string, string> = {
|
||||||
|
T: "Tiny",
|
||||||
|
S: "Small",
|
||||||
|
M: "Medium",
|
||||||
|
L: "Large",
|
||||||
|
H: "Huge",
|
||||||
|
G: "Gargantuan",
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Alignment mapping ---
|
||||||
|
|
||||||
|
const ALIGNMENT_MAP: Record<string, string> = {
|
||||||
|
L: "Lawful",
|
||||||
|
N: "Neutral",
|
||||||
|
C: "Chaotic",
|
||||||
|
G: "Good",
|
||||||
|
E: "Evil",
|
||||||
|
U: "Unaligned",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatAlignment(codes?: string[]): string {
|
||||||
|
if (!codes || codes.length === 0) return "Unaligned";
|
||||||
|
if (codes.length === 1 && codes[0] === "U") return "Unaligned";
|
||||||
|
if (codes.length === 1 && codes[0] === "N") return "Neutral";
|
||||||
|
return codes.map((c) => ALIGNMENT_MAP[c] ?? c).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function formatSize(sizes: string[]): string {
|
||||||
|
return sizes.map((s) => SIZE_MAP[s] ?? s).join(" or ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatType(
|
||||||
|
type:
|
||||||
|
| string
|
||||||
|
| {
|
||||||
|
type: string | { choose: string[] };
|
||||||
|
tags?: string[];
|
||||||
|
swarmSize?: string;
|
||||||
|
},
|
||||||
|
): string {
|
||||||
|
if (typeof type === "string") return capitalize(type);
|
||||||
|
|
||||||
|
const baseType =
|
||||||
|
typeof type.type === "string"
|
||||||
|
? capitalize(type.type)
|
||||||
|
: type.type.choose.map(capitalize).join(" or ");
|
||||||
|
|
||||||
|
let result = baseType;
|
||||||
|
if (type.tags && type.tags.length > 0) {
|
||||||
|
const tagStrs = type.tags
|
||||||
|
.filter((t): t is string => typeof t === "string")
|
||||||
|
.map(capitalize);
|
||||||
|
if (tagStrs.length > 0) {
|
||||||
|
result += ` (${tagStrs.join(", ")})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type.swarmSize) {
|
||||||
|
const swarmSizeLabel = SIZE_MAP[type.swarmSize] ?? type.swarmSize;
|
||||||
|
result = `Swarm of ${swarmSizeLabel} ${result}s`;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function capitalize(s: string): string {
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAc(ac: RawMonster["ac"]): {
|
||||||
|
value: number;
|
||||||
|
source?: string;
|
||||||
|
} {
|
||||||
|
const first = ac[0];
|
||||||
|
if (typeof first === "number") {
|
||||||
|
return { value: first };
|
||||||
|
}
|
||||||
|
if ("special" in first) {
|
||||||
|
// Variable AC (e.g. spell summons) — parse leading number if possible
|
||||||
|
const match = first.special.match(/^(\d+)/);
|
||||||
|
return {
|
||||||
|
value: match ? Number(match[1]) : 0,
|
||||||
|
source: first.special,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
value: first.ac,
|
||||||
|
source: first.from ? stripTags(first.from.join(", ")) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSpeed(speed: RawMonster["speed"]): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const [mode, value] of Object.entries(speed)) {
|
||||||
|
if (mode === "canHover") continue;
|
||||||
|
if (typeof value === "boolean") continue;
|
||||||
|
|
||||||
|
let numStr: string;
|
||||||
|
let condition = "";
|
||||||
|
if (typeof value === "number") {
|
||||||
|
numStr = `${value} ft.`;
|
||||||
|
} else {
|
||||||
|
numStr = `${value.number} ft.`;
|
||||||
|
if (value.condition) {
|
||||||
|
condition = ` ${value.condition}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "walk") {
|
||||||
|
parts.push(`${numStr}${condition}`);
|
||||||
|
} else {
|
||||||
|
parts.push(`${mode} ${numStr}${condition}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSaves(save?: Record<string, string>): string | undefined {
|
||||||
|
if (!save) return undefined;
|
||||||
|
return Object.entries(save)
|
||||||
|
.map(([key, val]) => `${key.toUpperCase()} ${val}`)
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSkills(skill?: Record<string, string>): string | undefined {
|
||||||
|
if (!skill) return undefined;
|
||||||
|
return Object.entries(skill)
|
||||||
|
.map(([key, val]) => `${capitalize(key)} ${val}`)
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDamageList(
|
||||||
|
items?: (string | Record<string, unknown>)[],
|
||||||
|
): string | undefined {
|
||||||
|
if (!items || items.length === 0) return undefined;
|
||||||
|
return items
|
||||||
|
.map((item) => {
|
||||||
|
if (typeof item === "string") return capitalize(stripTags(item));
|
||||||
|
if (typeof item.special === "string") return stripTags(item.special);
|
||||||
|
// Handle conditional entries like { vulnerable: [...], note: "..." }
|
||||||
|
const damageTypes = (Object.values(item).find((v) => Array.isArray(v)) ??
|
||||||
|
[]) as string[];
|
||||||
|
const note = typeof item.note === "string" ? ` ${item.note}` : "";
|
||||||
|
return damageTypes.map((d) => capitalize(stripTags(d))).join(", ") + note;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatConditionImmunities(
|
||||||
|
items?: (string | { conditionImmune?: string[]; note?: string })[],
|
||||||
|
): string | undefined {
|
||||||
|
if (!items || items.length === 0) return undefined;
|
||||||
|
return items
|
||||||
|
.flatMap((c) => {
|
||||||
|
if (typeof c === "string") return [capitalize(stripTags(c))];
|
||||||
|
if (c.conditionImmune) {
|
||||||
|
const conds = c.conditionImmune.map((ci) => capitalize(stripTags(ci)));
|
||||||
|
const note = c.note ? ` ${c.note}` : "";
|
||||||
|
return conds.map((ci) => `${ci}${note}`);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderListItem(item: string | RawEntryObject): string | undefined {
|
||||||
|
if (typeof item === "string") {
|
||||||
|
return `• ${stripTags(item)}`;
|
||||||
|
}
|
||||||
|
if (item.name && item.entries) {
|
||||||
|
return `• ${stripTags(item.name)}: ${renderEntries(item.entries)}`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEntryObject(entry: RawEntryObject, parts: string[]): void {
|
||||||
|
if (entry.type === "list") {
|
||||||
|
for (const item of entry.items ?? []) {
|
||||||
|
const rendered = renderListItem(item);
|
||||||
|
if (rendered) parts.push(rendered);
|
||||||
|
}
|
||||||
|
} else if (entry.type === "item" && entry.name && entry.entries) {
|
||||||
|
parts.push(`${stripTags(entry.name)}: ${renderEntries(entry.entries)}`);
|
||||||
|
} else if (entry.entries) {
|
||||||
|
parts.push(renderEntries(entry.entries));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEntries(entries: (string | RawEntryObject)[]): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (typeof entry === "string") {
|
||||||
|
parts.push(stripTags(entry));
|
||||||
|
} else {
|
||||||
|
renderEntryObject(entry, parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTraits(raw?: RawEntry[]): TraitBlock[] | undefined {
|
||||||
|
if (!raw || raw.length === 0) return undefined;
|
||||||
|
return raw.map((t) => ({
|
||||||
|
name: stripTags(t.name),
|
||||||
|
text: renderEntries(t.entries),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSpellcasting(
|
||||||
|
raw?: RawSpellcasting[],
|
||||||
|
): SpellcastingBlock[] | undefined {
|
||||||
|
if (!raw || raw.length === 0) return undefined;
|
||||||
|
|
||||||
|
return raw.map((sc) => {
|
||||||
|
const block: {
|
||||||
|
name: string;
|
||||||
|
headerText: string;
|
||||||
|
atWill?: string[];
|
||||||
|
daily?: DailySpells[];
|
||||||
|
restLong?: DailySpells[];
|
||||||
|
} = {
|
||||||
|
name: stripTags(sc.name),
|
||||||
|
headerText: sc.headerEntries.map((e) => stripTags(e)).join(" "),
|
||||||
|
};
|
||||||
|
|
||||||
|
const hidden = new Set(sc.hidden ?? []);
|
||||||
|
|
||||||
|
if (sc.will && !hidden.has("will")) {
|
||||||
|
block.atWill = sc.will.map((s) => stripTags(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sc.daily) {
|
||||||
|
block.daily = parseDailyMap(sc.daily);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sc.rest) {
|
||||||
|
block.restLong = parseDailyMap(sc.rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
return block;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDailyMap(map: Record<string, string[]>): DailySpells[] {
|
||||||
|
return Object.entries(map).map(([key, spells]) => {
|
||||||
|
const each = key.endsWith("e");
|
||||||
|
const uses = Number.parseInt(each ? key.slice(0, -1) : key, 10);
|
||||||
|
return {
|
||||||
|
uses,
|
||||||
|
each,
|
||||||
|
spells: spells.map((s) => stripTags(s)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLegendary(
|
||||||
|
raw?: RawEntry[],
|
||||||
|
monster?: RawMonster,
|
||||||
|
): LegendaryBlock | undefined {
|
||||||
|
if (!raw || raw.length === 0) return undefined;
|
||||||
|
|
||||||
|
const name = monster?.name ?? "creature";
|
||||||
|
const count = monster?.legendaryActions ?? 3;
|
||||||
|
const preamble = `${name} can take ${count} Legendary Actions, choosing from the options below. Only one Legendary Action can be used at a time and only at the end of another creature's turn. ${name} regains spent Legendary Actions at the start of its turn.`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
preamble,
|
||||||
|
entries: raw.map((e) => ({
|
||||||
|
name: stripTags(e.name),
|
||||||
|
text: renderEntries(e.entries),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCr(cr: string | { cr: string } | undefined): string {
|
||||||
|
if (cr === undefined) return "—";
|
||||||
|
return typeof cr === "string" ? cr : cr.cr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCreatureId(source: string, name: string): CreatureId {
|
||||||
|
const slug = name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/(^-|-$)/g, "");
|
||||||
|
return creatureId(`${source.toLowerCase()}:${slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes raw 5etools bestiary JSON into domain Creature[].
|
||||||
|
*/
|
||||||
|
export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
|
||||||
|
// Filter out _copy entries (reference another source's monster) and
|
||||||
|
// monsters missing required fields (ac, hp, size, type)
|
||||||
|
const monsters = raw.monster.filter((m) => {
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: raw JSON may have _copy field
|
||||||
|
if ((m as any)._copy) return false;
|
||||||
|
return (
|
||||||
|
Array.isArray(m.ac) &&
|
||||||
|
m.ac.length > 0 &&
|
||||||
|
m.hp !== undefined &&
|
||||||
|
Array.isArray(m.size) &&
|
||||||
|
m.size.length > 0 &&
|
||||||
|
m.type !== undefined
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const creatures: Creature[] = [];
|
||||||
|
for (const m of monsters) {
|
||||||
|
try {
|
||||||
|
creatures.push(normalizeMonster(m));
|
||||||
|
} catch {
|
||||||
|
// Skip monsters with unexpected data shapes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return creatures;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMonster(m: RawMonster): Creature {
|
||||||
|
const crStr = extractCr(m.cr);
|
||||||
|
const ac = extractAc(m.ac);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: makeCreatureId(m.source, m.name),
|
||||||
|
name: m.name,
|
||||||
|
source: m.source,
|
||||||
|
sourceDisplayName: sourceDisplayNames[m.source] ?? m.source,
|
||||||
|
size: formatSize(m.size),
|
||||||
|
type: formatType(m.type),
|
||||||
|
alignment: formatAlignment(m.alignment),
|
||||||
|
ac: ac.value,
|
||||||
|
acSource: ac.source,
|
||||||
|
hp: {
|
||||||
|
average: m.hp.average ?? 0,
|
||||||
|
formula: m.hp.formula ?? m.hp.special ?? "",
|
||||||
|
},
|
||||||
|
speed: formatSpeed(m.speed),
|
||||||
|
abilities: {
|
||||||
|
str: m.str,
|
||||||
|
dex: m.dex,
|
||||||
|
con: m.con,
|
||||||
|
int: m.int,
|
||||||
|
wis: m.wis,
|
||||||
|
cha: m.cha,
|
||||||
|
},
|
||||||
|
cr: crStr,
|
||||||
|
initiativeProficiency: m.initiative?.proficiency ?? 0,
|
||||||
|
proficiencyBonus: proficiencyBonus(crStr),
|
||||||
|
passive: m.passive,
|
||||||
|
savingThrows: formatSaves(m.save),
|
||||||
|
skills: formatSkills(m.skill),
|
||||||
|
resist: formatDamageList(m.resist),
|
||||||
|
immune: formatDamageList(m.immune),
|
||||||
|
vulnerable: formatDamageList(m.vulnerable),
|
||||||
|
conditionImmune: formatConditionImmunities(m.conditionImmune),
|
||||||
|
senses:
|
||||||
|
m.senses && m.senses.length > 0
|
||||||
|
? m.senses.map((s) => stripTags(s)).join(", ")
|
||||||
|
: undefined,
|
||||||
|
languages:
|
||||||
|
m.languages && m.languages.length > 0
|
||||||
|
? m.languages.join(", ")
|
||||||
|
: undefined,
|
||||||
|
traits: normalizeTraits(m.trait),
|
||||||
|
actions: normalizeTraits(m.action),
|
||||||
|
bonusActions: normalizeTraits(m.bonus),
|
||||||
|
reactions: normalizeTraits(m.reaction),
|
||||||
|
legendaryActions: normalizeLegendary(m.legendary, m),
|
||||||
|
spellcasting: normalizeSpellcasting(m.spellcasting),
|
||||||
|
};
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
100
apps/web/src/adapters/strip-tags.ts
Normal file
100
apps/web/src/adapters/strip-tags.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
const ABILITY_MAP: Record<string, string> = {
|
||||||
|
str: "Strength",
|
||||||
|
dex: "Dexterity",
|
||||||
|
con: "Constitution",
|
||||||
|
int: "Intelligence",
|
||||||
|
wis: "Wisdom",
|
||||||
|
cha: "Charisma",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ATKR_MAP: Record<string, string> = {
|
||||||
|
m: "Melee Attack Roll:",
|
||||||
|
r: "Ranged Attack Roll:",
|
||||||
|
"m,r": "Melee or Ranged Attack Roll:",
|
||||||
|
"r,m": "Melee or Ranged Attack Roll:",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips 5etools {@tag ...} markup from text, converting to plain readable text.
|
||||||
|
*
|
||||||
|
* Handles 15+ tag types per research.md R-002 tag resolution rules.
|
||||||
|
*/
|
||||||
|
export function stripTags(text: string): string {
|
||||||
|
if (typeof text !== "string") return String(text);
|
||||||
|
// Process special tags with specific output formats first
|
||||||
|
let result = text;
|
||||||
|
|
||||||
|
// {@h} → "Hit: "
|
||||||
|
result = result.replace(/\{@h\}/g, "Hit: ");
|
||||||
|
|
||||||
|
// {@hom} → "Hit or Miss: "
|
||||||
|
result = result.replace(/\{@hom\}/g, "Hit or Miss: ");
|
||||||
|
|
||||||
|
// {@actTrigger} → "Trigger:"
|
||||||
|
result = result.replace(/\{@actTrigger\}/g, "Trigger:");
|
||||||
|
|
||||||
|
// {@actResponse} → "Response:"
|
||||||
|
result = result.replace(/\{@actResponse\}/g, "Response:");
|
||||||
|
|
||||||
|
// {@actSaveSuccess} → "Success:"
|
||||||
|
result = result.replace(/\{@actSaveSuccess\}/g, "Success:");
|
||||||
|
|
||||||
|
// {@actSaveSuccessOrFail} → handled below as parameterized
|
||||||
|
|
||||||
|
// {@recharge 5} → "(Recharge 5-6)", {@recharge} → "(Recharge 6)"
|
||||||
|
result = result.replace(/\{@recharge\s+(\d)\}/g, "(Recharge $1-6)");
|
||||||
|
result = result.replace(/\{@recharge\}/g, "(Recharge 6)");
|
||||||
|
|
||||||
|
// {@dc N} → "DC N"
|
||||||
|
result = result.replace(/\{@dc\s+(\d+)\}/g, "DC $1");
|
||||||
|
|
||||||
|
// {@hit N} → "+N"
|
||||||
|
result = result.replace(/\{@hit\s+(\d+)\}/g, "+$1");
|
||||||
|
|
||||||
|
// {@atkr type} → mapped attack roll text
|
||||||
|
result = result.replace(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => {
|
||||||
|
return ATKR_MAP[type.trim()] ?? `Attack Roll:`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// {@actSave ability} → "Ability saving throw"
|
||||||
|
result = result.replace(/\{@actSave\s+([^}]+)\}/g, (_, ability: string) => {
|
||||||
|
const name = ABILITY_MAP[ability.trim().toLowerCase()];
|
||||||
|
return name ? `${name} saving throw` : `${ability} saving throw`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// {@actSaveFail} → "Failure:" or {@actSaveFail N} → "Failure by N or More:"
|
||||||
|
result = result.replace(
|
||||||
|
/\{@actSaveFail\s+(\d+)\}/g,
|
||||||
|
"Failure by $1 or More:",
|
||||||
|
);
|
||||||
|
result = result.replace(/\{@actSaveFail\}/g, "Failure:");
|
||||||
|
|
||||||
|
// {@actSaveSuccessOrFail} → keep as-is label
|
||||||
|
result = result.replace(/\{@actSaveSuccessOrFail\}/g, "Success or Failure:");
|
||||||
|
|
||||||
|
// {@actSaveFailBy N} → "Failure by N or More:"
|
||||||
|
result = result.replace(
|
||||||
|
/\{@actSaveFailBy\s+(\d+)\}/g,
|
||||||
|
"Failure by $1 or More:",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generic tags: {@tag Display|Source|...} → Display (first segment before |)
|
||||||
|
// Covers: spell, condition, damage, dice, variantrule, action, skill,
|
||||||
|
// creature, hazard, status, plus any unknown tags
|
||||||
|
result = result.replace(
|
||||||
|
/\{@(\w+)\s+([^}]+)\}/g,
|
||||||
|
(_, tag: string, content: string) => {
|
||||||
|
// For tags with Display|Source format, extract first segment
|
||||||
|
const segments = content.split("|");
|
||||||
|
|
||||||
|
// Some tags have a third segment as display text: {@variantrule Name|Source|Display}
|
||||||
|
if ((tag === "variantrule" || tag === "action") && segments.length >= 3) {
|
||||||
|
return segments[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments[0];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
199
apps/web/src/components/__tests__/turn-navigation.test.tsx
Normal file
199
apps/web/src/components/__tests__/turn-navigation.test.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
// @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()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
37
apps/web/src/components/ac-shield.tsx
Normal file
37
apps/web/src/components/ac-shield.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
interface AcShieldProps {
|
||||||
|
readonly value: number | undefined;
|
||||||
|
readonly onClick?: () => void;
|
||||||
|
readonly className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AcShield({ value, onClick, className }: AcShieldProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex items-center justify-center text-sm tabular-nums text-muted-foreground transition-colors hover:text-hover-neutral",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={{ width: 28, height: 32 }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 28 32"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="absolute inset-0 h-full w-full"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M14 1.5 L2.5 6.5 L2.5 15 Q2.5 25 14 30.5 Q25.5 25 25.5 15 L25.5 6.5 Z" />
|
||||||
|
</svg>
|
||||||
|
<span className="relative text-xs font-medium leading-none">
|
||||||
|
{value !== undefined ? value : "\u2014"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
599
apps/web/src/components/action-bar.tsx
Normal file
599
apps/web/src/components/action-bar.tsx
Normal file
@@ -0,0 +1,599 @@
|
|||||||
|
import type { PlayerCharacter, PlayerIcon } from "@initiative/domain";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Import,
|
||||||
|
Library,
|
||||||
|
Minus,
|
||||||
|
Plus,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
type FormEvent,
|
||||||
|
type RefObject,
|
||||||
|
useDeferredValue,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import type { SearchResult } from "../hooks/use-bestiary.js";
|
||||||
|
import { cn } from "../lib/utils.js";
|
||||||
|
import { D20Icon } from "./d20-icon.js";
|
||||||
|
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||||
|
import { Button } from "./ui/button.js";
|
||||||
|
import { Input } from "./ui/input.js";
|
||||||
|
import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
|
||||||
|
|
||||||
|
interface QueuedCreature {
|
||||||
|
result: SearchResult;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActionBarProps {
|
||||||
|
onAddCombatant: (
|
||||||
|
name: string,
|
||||||
|
opts?: { initiative?: number; ac?: number; maxHp?: number },
|
||||||
|
) => void;
|
||||||
|
onAddFromBestiary: (result: SearchResult) => void;
|
||||||
|
bestiarySearch: (query: string) => SearchResult[];
|
||||||
|
bestiaryLoaded: boolean;
|
||||||
|
onViewStatBlock?: (result: SearchResult) => void;
|
||||||
|
onBulkImport?: () => void;
|
||||||
|
bulkImportDisabled?: boolean;
|
||||||
|
inputRef?: RefObject<HTMLInputElement | null>;
|
||||||
|
playerCharacters?: readonly PlayerCharacter[];
|
||||||
|
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
||||||
|
onManagePlayers?: () => void;
|
||||||
|
onRollAllInitiative?: () => void;
|
||||||
|
showRollAllInitiative?: boolean;
|
||||||
|
rollAllInitiativeDisabled?: boolean;
|
||||||
|
onOpenSourceManager?: () => void;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function creatureKey(r: SearchResult): string {
|
||||||
|
return `${r.source}:${r.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddModeSuggestions({
|
||||||
|
nameInput,
|
||||||
|
suggestions,
|
||||||
|
pcMatches,
|
||||||
|
suggestionIndex,
|
||||||
|
queued,
|
||||||
|
onDismiss,
|
||||||
|
onClickSuggestion,
|
||||||
|
onSetSuggestionIndex,
|
||||||
|
onSetQueued,
|
||||||
|
onConfirmQueued,
|
||||||
|
onAddFromPlayerCharacter,
|
||||||
|
onClear,
|
||||||
|
}: {
|
||||||
|
nameInput: string;
|
||||||
|
suggestions: SearchResult[];
|
||||||
|
pcMatches: PlayerCharacter[];
|
||||||
|
suggestionIndex: number;
|
||||||
|
queued: QueuedCreature | null;
|
||||||
|
onDismiss: () => void;
|
||||||
|
onClear: () => void;
|
||||||
|
onClickSuggestion: (result: SearchResult) => void;
|
||||||
|
onSetSuggestionIndex: (i: number) => void;
|
||||||
|
onSetQueued: (q: QueuedCreature | null) => void;
|
||||||
|
onConfirmQueued: () => void;
|
||||||
|
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<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={onDismiss}
|
||||||
|
>
|
||||||
|
<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 = pc.icon
|
||||||
|
? PLAYER_ICON_MAP[pc.icon as PlayerIcon]
|
||||||
|
: undefined;
|
||||||
|
const pcColor = pc.color
|
||||||
|
? PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX]
|
||||||
|
: undefined;
|
||||||
|
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);
|
||||||
|
onClear();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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={() => onClickSuggestion(result)}
|
||||||
|
onMouseEnter={() => onSetSuggestionIndex(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) {
|
||||||
|
onSetQueued(null);
|
||||||
|
} else {
|
||||||
|
onSetQueued({
|
||||||
|
...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();
|
||||||
|
onSetQueued({
|
||||||
|
...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();
|
||||||
|
onConfirmQueued();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
result.sourceDisplayName
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOverflowItems(opts: {
|
||||||
|
onManagePlayers?: () => void;
|
||||||
|
onOpenSourceManager?: () => void;
|
||||||
|
bestiaryLoaded: boolean;
|
||||||
|
onBulkImport?: () => void;
|
||||||
|
bulkImportDisabled?: boolean;
|
||||||
|
}): OverflowMenuItem[] {
|
||||||
|
const items: OverflowMenuItem[] = [];
|
||||||
|
if (opts.onManagePlayers) {
|
||||||
|
items.push({
|
||||||
|
icon: <Users className="h-4 w-4" />,
|
||||||
|
label: "Player Characters",
|
||||||
|
onClick: opts.onManagePlayers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (opts.onOpenSourceManager) {
|
||||||
|
items.push({
|
||||||
|
icon: <Library className="h-4 w-4" />,
|
||||||
|
label: "Manage Sources",
|
||||||
|
onClick: opts.onOpenSourceManager,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (opts.bestiaryLoaded && opts.onBulkImport) {
|
||||||
|
items.push({
|
||||||
|
icon: <Import className="h-4 w-4" />,
|
||||||
|
label: "Import All Sources",
|
||||||
|
onClick: opts.onBulkImport,
|
||||||
|
disabled: opts.bulkImportDisabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionBar({
|
||||||
|
onAddCombatant,
|
||||||
|
onAddFromBestiary,
|
||||||
|
bestiarySearch,
|
||||||
|
bestiaryLoaded,
|
||||||
|
onViewStatBlock,
|
||||||
|
onBulkImport,
|
||||||
|
bulkImportDisabled,
|
||||||
|
inputRef,
|
||||||
|
playerCharacters,
|
||||||
|
onAddFromPlayerCharacter,
|
||||||
|
onManagePlayers,
|
||||||
|
onRollAllInitiative,
|
||||||
|
showRollAllInitiative,
|
||||||
|
rollAllInitiativeDisabled,
|
||||||
|
onOpenSourceManager,
|
||||||
|
autoFocus,
|
||||||
|
}: ActionBarProps) {
|
||||||
|
const [nameInput, setNameInput] = useState("");
|
||||||
|
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||||
|
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
||||||
|
const deferredSuggestions = useDeferredValue(suggestions);
|
||||||
|
const deferredPcMatches = useDeferredValue(pcMatches);
|
||||||
|
const [suggestionIndex, setSuggestionIndex] = useState(-1);
|
||||||
|
const [queued, setQueued] = useState<QueuedCreature | null>(null);
|
||||||
|
const [customInit, setCustomInit] = useState("");
|
||||||
|
const [customAc, setCustomAc] = useState("");
|
||||||
|
const [customMaxHp, setCustomMaxHp] = useState("");
|
||||||
|
const [browseMode, setBrowseMode] = useState(false);
|
||||||
|
|
||||||
|
const clearCustomFields = () => {
|
||||||
|
setCustomInit("");
|
||||||
|
setCustomAc("");
|
||||||
|
setCustomMaxHp("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearInput = () => {
|
||||||
|
setNameInput("");
|
||||||
|
setSuggestions([]);
|
||||||
|
setPcMatches([]);
|
||||||
|
setQueued(null);
|
||||||
|
setSuggestionIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissSuggestions = () => {
|
||||||
|
setSuggestions([]);
|
||||||
|
setPcMatches([]);
|
||||||
|
setQueued(null);
|
||||||
|
setSuggestionIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmQueued = () => {
|
||||||
|
if (!queued) return;
|
||||||
|
for (let i = 0; i < queued.count; i++) {
|
||||||
|
onAddFromBestiary(queued.result);
|
||||||
|
}
|
||||||
|
clearInput();
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseNum = (v: string): number | undefined => {
|
||||||
|
if (v.trim() === "") return undefined;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isNaN(n) ? undefined : n;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (browseMode) return;
|
||||||
|
if (queued) {
|
||||||
|
confirmQueued();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nameInput.trim() === "") return;
|
||||||
|
const opts: { initiative?: number; ac?: number; maxHp?: number } = {};
|
||||||
|
const init = parseNum(customInit);
|
||||||
|
const ac = parseNum(customAc);
|
||||||
|
const maxHp = parseNum(customMaxHp);
|
||||||
|
if (init !== undefined) opts.initiative = init;
|
||||||
|
if (ac !== undefined) opts.ac = ac;
|
||||||
|
if (maxHp !== undefined) opts.maxHp = maxHp;
|
||||||
|
onAddCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
|
||||||
|
setNameInput("");
|
||||||
|
setSuggestions([]);
|
||||||
|
setPcMatches([]);
|
||||||
|
clearCustomFields();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBrowseSearch = (value: string) => {
|
||||||
|
setSuggestions(value.length >= 2 ? bestiarySearch(value) : []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddSearch = (value: string) => {
|
||||||
|
let newSuggestions: SearchResult[] = [];
|
||||||
|
let newPcMatches: PlayerCharacter[] = [];
|
||||||
|
if (value.length >= 2) {
|
||||||
|
newSuggestions = bestiarySearch(value);
|
||||||
|
setSuggestions(newSuggestions);
|
||||||
|
if (playerCharacters && playerCharacters.length > 0) {
|
||||||
|
const lower = value.toLowerCase();
|
||||||
|
newPcMatches = playerCharacters.filter((pc) =>
|
||||||
|
pc.name.toLowerCase().includes(lower),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setPcMatches(newPcMatches);
|
||||||
|
} else {
|
||||||
|
setSuggestions([]);
|
||||||
|
setPcMatches([]);
|
||||||
|
}
|
||||||
|
if (newSuggestions.length > 0 || newPcMatches.length > 0) {
|
||||||
|
clearCustomFields();
|
||||||
|
}
|
||||||
|
if (queued) {
|
||||||
|
const qKey = creatureKey(queued.result);
|
||||||
|
const stillVisible = newSuggestions.some((s) => creatureKey(s) === qKey);
|
||||||
|
if (!stillVisible) {
|
||||||
|
setQueued(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNameChange = (value: string) => {
|
||||||
|
setNameInput(value);
|
||||||
|
setSuggestionIndex(-1);
|
||||||
|
if (browseMode) {
|
||||||
|
handleBrowseSearch(value);
|
||||||
|
} else {
|
||||||
|
handleAddSearch(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClickSuggestion = (result: SearchResult) => {
|
||||||
|
const key = creatureKey(result);
|
||||||
|
if (queued && creatureKey(queued.result) === key) {
|
||||||
|
setQueued({ ...queued, count: queued.count + 1 });
|
||||||
|
} else {
|
||||||
|
setQueued({ result, count: 1 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnter = () => {
|
||||||
|
if (queued) {
|
||||||
|
confirmQueued();
|
||||||
|
} else if (suggestionIndex >= 0) {
|
||||||
|
handleClickSuggestion(suggestions[suggestionIndex]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasSuggestions =
|
||||||
|
deferredSuggestions.length > 0 || deferredPcMatches.length > 0;
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (!hasSuggestions) return;
|
||||||
|
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleEnter();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
dismissSuggestions();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBrowseKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setBrowseMode(false);
|
||||||
|
clearInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (suggestions.length === 0) return;
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
||||||
|
} else if (e.key === "Enter" && suggestionIndex >= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
onViewStatBlock?.(suggestions[suggestionIndex]);
|
||||||
|
setBrowseMode(false);
|
||||||
|
clearInput();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBrowseSelect = (result: SearchResult) => {
|
||||||
|
onViewStatBlock?.(result);
|
||||||
|
setBrowseMode(false);
|
||||||
|
clearInput();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleBrowseMode = () => {
|
||||||
|
setBrowseMode((m) => !m);
|
||||||
|
clearInput();
|
||||||
|
clearCustomFields();
|
||||||
|
};
|
||||||
|
|
||||||
|
const overflowItems = buildOverflowItems({
|
||||||
|
onManagePlayers,
|
||||||
|
onOpenSourceManager,
|
||||||
|
bestiaryLoaded,
|
||||||
|
onBulkImport,
|
||||||
|
bulkImportDisabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
|
||||||
|
<form
|
||||||
|
onSubmit={handleAdd}
|
||||||
|
className="relative flex flex-1 items-center gap-2"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="relative max-w-xs">
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={nameInput}
|
||||||
|
onChange={(e) => handleNameChange(e.target.value)}
|
||||||
|
onKeyDown={browseMode ? handleBrowseKeyDown : handleKeyDown}
|
||||||
|
placeholder={
|
||||||
|
browseMode ? "Search stat blocks..." : "+ Add combatants"
|
||||||
|
}
|
||||||
|
className="pr-8"
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
/>
|
||||||
|
{bestiaryLoaded && onViewStatBlock && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
className={cn(
|
||||||
|
"absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
|
||||||
|
browseMode && "text-accent",
|
||||||
|
)}
|
||||||
|
onClick={toggleBrowseMode}
|
||||||
|
title={browseMode ? "Switch to add mode" : "Browse stat blocks"}
|
||||||
|
aria-label={
|
||||||
|
browseMode ? "Switch to add mode" : "Browse stat blocks"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{browseMode ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{browseMode && deferredSuggestions.length > 0 && (
|
||||||
|
<div className="absolute bottom-full z-50 mb-1 w-full rounded-md border border-border bg-card shadow-lg">
|
||||||
|
<ul className="max-h-48 overflow-y-auto py-1">
|
||||||
|
{deferredSuggestions.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 === suggestionIndex
|
||||||
|
? "bg-accent/20 text-foreground"
|
||||||
|
: "text-foreground hover:bg-hover-neutral-bg"
|
||||||
|
}`}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => handleBrowseSelect(result)}
|
||||||
|
onMouseEnter={() => setSuggestionIndex(i)}
|
||||||
|
>
|
||||||
|
<span>{result.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{result.sourceDisplayName}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!browseMode && hasSuggestions && (
|
||||||
|
<AddModeSuggestions
|
||||||
|
nameInput={nameInput}
|
||||||
|
suggestions={deferredSuggestions}
|
||||||
|
pcMatches={deferredPcMatches}
|
||||||
|
suggestionIndex={suggestionIndex}
|
||||||
|
queued={queued}
|
||||||
|
onDismiss={dismissSuggestions}
|
||||||
|
onClear={clearInput}
|
||||||
|
onClickSuggestion={handleClickSuggestion}
|
||||||
|
onSetSuggestionIndex={setSuggestionIndex}
|
||||||
|
onSetQueued={setQueued}
|
||||||
|
onConfirmQueued={confirmQueued}
|
||||||
|
onAddFromPlayerCharacter={onAddFromPlayerCharacter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={customInit}
|
||||||
|
onChange={(e) => setCustomInit(e.target.value)}
|
||||||
|
placeholder="Init"
|
||||||
|
className="w-16 text-center"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={customAc}
|
||||||
|
onChange={(e) => setCustomAc(e.target.value)}
|
||||||
|
placeholder="AC"
|
||||||
|
className="w-16 text-center"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={customMaxHp}
|
||||||
|
onChange={(e) => setCustomMaxHp(e.target.value)}
|
||||||
|
placeholder="MaxHP"
|
||||||
|
className="w-18 text-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||||
|
<Button type="submit">Add</Button>
|
||||||
|
)}
|
||||||
|
{showRollAllInitiative && onRollAllInitiative && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-muted-foreground hover:text-hover-action"
|
||||||
|
onClick={onRollAllInitiative}
|
||||||
|
disabled={rollAllInitiativeDisabled}
|
||||||
|
title="Roll all initiative"
|
||||||
|
aria-label="Roll all initiative"
|
||||||
|
>
|
||||||
|
<D20Icon className="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
apps/web/src/components/bulk-import-prompt.tsx
Normal file
106
apps/web/src/components/bulk-import-prompt.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
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 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 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">
|
||||||
|
Import All Sources
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Load stat block data for all {totalSources} sources at once.
|
||||||
|
</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 onClick={() => onStartImport(baseUrl)} disabled={isDisabled}>
|
||||||
|
Load All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
apps/web/src/components/bulk-import-toasts.tsx
Normal file
49
apps/web/src/components/bulk-import-toasts.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
||||||
|
import { Toast } from "./toast.js";
|
||||||
|
|
||||||
|
interface BulkImportToastsProps {
|
||||||
|
state: BulkImportState;
|
||||||
|
visible: boolean;
|
||||||
|
onReset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BulkImportToasts({
|
||||||
|
state,
|
||||||
|
visible,
|
||||||
|
onReset,
|
||||||
|
}: BulkImportToastsProps) {
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
if (state.status === "loading") {
|
||||||
|
return (
|
||||||
|
<Toast
|
||||||
|
message={`Loading sources... ${state.completed + state.failed}/${state.total}`}
|
||||||
|
progress={
|
||||||
|
state.total > 0 ? (state.completed + state.failed) / state.total : 0
|
||||||
|
}
|
||||||
|
onDismiss={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === "complete") {
|
||||||
|
return (
|
||||||
|
<Toast
|
||||||
|
message="All sources loaded"
|
||||||
|
onDismiss={onReset}
|
||||||
|
autoDismissMs={3000}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === "partial-failure") {
|
||||||
|
return (
|
||||||
|
<Toast
|
||||||
|
message={`Loaded ${state.completed}/${state.total} sources (${state.failed} failed)`}
|
||||||
|
onDismiss={onReset}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
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(value === color ? "" : 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
634
apps/web/src/components/combatant-row.tsx
Normal file
634
apps/web/src/components/combatant-row.tsx
Normal file
@@ -0,0 +1,634 @@
|
|||||||
|
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";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { AcShield } from "./ac-shield";
|
||||||
|
import { ConditionPicker } from "./condition-picker";
|
||||||
|
import { ConditionTags } from "./condition-tags";
|
||||||
|
import { D20Icon } from "./d20-icon";
|
||||||
|
import { HpAdjustPopover } from "./hp-adjust-popover";
|
||||||
|
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||||
|
import { ConfirmButton } from "./ui/confirm-button";
|
||||||
|
import { Input } from "./ui/input";
|
||||||
|
|
||||||
|
interface Combatant {
|
||||||
|
readonly id: CombatantId;
|
||||||
|
readonly name: string;
|
||||||
|
readonly initiative?: number;
|
||||||
|
readonly maxHp?: number;
|
||||||
|
readonly currentHp?: number;
|
||||||
|
readonly ac?: number;
|
||||||
|
readonly conditions?: readonly ConditionId[];
|
||||||
|
readonly isConcentrating?: boolean;
|
||||||
|
readonly color?: string;
|
||||||
|
readonly icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CombatantRowProps {
|
||||||
|
combatant: Combatant;
|
||||||
|
isActive: boolean;
|
||||||
|
onRename: (id: CombatantId, newName: string) => void;
|
||||||
|
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
|
||||||
|
onRemove: (id: CombatantId) => void;
|
||||||
|
onSetHp: (id: CombatantId, maxHp: number | undefined) => void;
|
||||||
|
onAdjustHp: (id: CombatantId, delta: number) => void;
|
||||||
|
onSetAc: (id: CombatantId, value: number | undefined) => void;
|
||||||
|
onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void;
|
||||||
|
onToggleConcentration: (id: CombatantId) => void;
|
||||||
|
onShowStatBlock?: () => void;
|
||||||
|
onRollInitiative?: (id: CombatantId) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditableName({
|
||||||
|
name,
|
||||||
|
combatantId,
|
||||||
|
onRename,
|
||||||
|
onShowStatBlock,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
combatantId: CombatantId;
|
||||||
|
onRename: (id: CombatantId, newName: string) => void;
|
||||||
|
onShowStatBlock?: () => void;
|
||||||
|
color?: string;
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState(name);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
const longPressTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
const longPressTriggeredRef = useRef(false);
|
||||||
|
|
||||||
|
const commit = useCallback(() => {
|
||||||
|
const trimmed = draft.trim();
|
||||||
|
if (trimmed !== "" && trimmed !== name) {
|
||||||
|
onRename(combatantId, trimmed);
|
||||||
|
}
|
||||||
|
setEditing(false);
|
||||||
|
}, [draft, name, combatantId, onRename]);
|
||||||
|
|
||||||
|
const startEditing = useCallback(() => {
|
||||||
|
setDraft(name);
|
||||||
|
setEditing(true);
|
||||||
|
requestAnimationFrame(() => inputRef.current?.select());
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearTimeout(clickTimerRef.current);
|
||||||
|
clearTimeout(longPressTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (longPressTriggeredRef.current) {
|
||||||
|
longPressTriggeredRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (clickTimerRef.current) {
|
||||||
|
clearTimeout(clickTimerRef.current);
|
||||||
|
clickTimerRef.current = undefined;
|
||||||
|
startEditing();
|
||||||
|
} else {
|
||||||
|
clickTimerRef.current = setTimeout(() => {
|
||||||
|
clickTimerRef.current = undefined;
|
||||||
|
onShowStatBlock?.();
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[startEditing, onShowStatBlock],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTouchStart = useCallback(() => {
|
||||||
|
longPressTriggeredRef.current = false;
|
||||||
|
longPressTimerRef.current = setTimeout(() => {
|
||||||
|
longPressTriggeredRef.current = true;
|
||||||
|
startEditing();
|
||||||
|
}, 500);
|
||||||
|
}, [startEditing]);
|
||||||
|
|
||||||
|
const cancelLongPress = useCallback(() => {
|
||||||
|
clearTimeout(longPressTimerRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={draft}
|
||||||
|
className="h-7 text-sm"
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") commit();
|
||||||
|
if (e.key === "Escape") setEditing(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchEnd={cancelLongPress}
|
||||||
|
onTouchCancel={cancelLongPress}
|
||||||
|
onTouchMove={cancelLongPress}
|
||||||
|
className="truncate text-left text-sm text-foreground cursor-text hover:text-hover-neutral transition-colors"
|
||||||
|
style={color ? { color } : undefined}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MaxHpDisplay({
|
||||||
|
maxHp,
|
||||||
|
onCommit,
|
||||||
|
}: {
|
||||||
|
maxHp: number | undefined;
|
||||||
|
onCommit: (value: number | undefined) => void;
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState(maxHp?.toString() ?? "");
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const commit = useCallback(() => {
|
||||||
|
if (draft === "") {
|
||||||
|
onCommit(undefined);
|
||||||
|
} else {
|
||||||
|
const n = Number.parseInt(draft, 10);
|
||||||
|
if (!Number.isNaN(n) && n >= 1) {
|
||||||
|
onCommit(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setEditing(false);
|
||||||
|
}, [draft, onCommit]);
|
||||||
|
|
||||||
|
const startEditing = useCallback(() => {
|
||||||
|
setDraft(maxHp?.toString() ?? "");
|
||||||
|
setEditing(true);
|
||||||
|
requestAnimationFrame(() => inputRef.current?.select());
|
||||||
|
}, [maxHp]);
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={draft}
|
||||||
|
placeholder="Max"
|
||||||
|
className="h-7 w-[7ch] text-center text-sm tabular-nums"
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") commit();
|
||||||
|
if (e.key === "Escape") setEditing(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={startEditing}
|
||||||
|
className="inline-block h-7 min-w-[3ch] text-center text-sm leading-7 tabular-nums text-muted-foreground transition-colors hover:text-hover-neutral"
|
||||||
|
>
|
||||||
|
{maxHp ?? "Max"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClickableHp({
|
||||||
|
currentHp,
|
||||||
|
maxHp,
|
||||||
|
onAdjust,
|
||||||
|
dimmed,
|
||||||
|
}: {
|
||||||
|
currentHp: number | undefined;
|
||||||
|
maxHp: number | undefined;
|
||||||
|
onAdjust: (delta: number) => void;
|
||||||
|
dimmed?: boolean;
|
||||||
|
}) {
|
||||||
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||||
|
const status = deriveHpStatus(currentHp, maxHp);
|
||||||
|
|
||||||
|
if (maxHp === undefined) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-block h-7 w-[4ch] text-center text-sm leading-7 tabular-nums text-muted-foreground",
|
||||||
|
dimmed && "opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
--
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPopoverOpen(true)}
|
||||||
|
className={cn(
|
||||||
|
"inline-block h-7 min-w-[3ch] text-center text-sm font-medium leading-7 tabular-nums transition-colors hover:text-hover-neutral",
|
||||||
|
status === "bloodied" && "text-amber-400",
|
||||||
|
status === "unconscious" && "text-red-400",
|
||||||
|
status === "healthy" && "text-foreground",
|
||||||
|
dimmed && "opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{currentHp}
|
||||||
|
</button>
|
||||||
|
{popoverOpen && (
|
||||||
|
<HpAdjustPopover
|
||||||
|
onAdjust={onAdjust}
|
||||||
|
onClose={() => setPopoverOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AcDisplay({
|
||||||
|
ac,
|
||||||
|
onCommit,
|
||||||
|
}: {
|
||||||
|
ac: number | undefined;
|
||||||
|
onCommit: (value: number | undefined) => void;
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState(ac?.toString() ?? "");
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const commit = useCallback(() => {
|
||||||
|
if (draft === "") {
|
||||||
|
onCommit(undefined);
|
||||||
|
} else {
|
||||||
|
const n = Number.parseInt(draft, 10);
|
||||||
|
if (!Number.isNaN(n) && n >= 0) {
|
||||||
|
onCommit(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setEditing(false);
|
||||||
|
}, [draft, onCommit]);
|
||||||
|
|
||||||
|
const startEditing = useCallback(() => {
|
||||||
|
setDraft(ac?.toString() ?? "");
|
||||||
|
setEditing(true);
|
||||||
|
requestAnimationFrame(() => inputRef.current?.select());
|
||||||
|
}, [ac]);
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={draft}
|
||||||
|
placeholder="AC"
|
||||||
|
className="h-7 w-[6ch] text-center text-sm tabular-nums"
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") commit();
|
||||||
|
if (e.key === "Escape") setEditing(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AcShield value={ac} onClick={startEditing} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InitiativeDisplay({
|
||||||
|
initiative,
|
||||||
|
combatantId,
|
||||||
|
dimmed,
|
||||||
|
onSetInitiative,
|
||||||
|
onRollInitiative,
|
||||||
|
}: {
|
||||||
|
initiative: number | undefined;
|
||||||
|
combatantId: CombatantId;
|
||||||
|
dimmed: boolean;
|
||||||
|
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
|
||||||
|
onRollInitiative?: (id: CombatantId) => void;
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState(initiative?.toString() ?? "");
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const commit = useCallback(() => {
|
||||||
|
if (draft === "") {
|
||||||
|
onSetInitiative(combatantId, undefined);
|
||||||
|
} else {
|
||||||
|
const n = Number.parseInt(draft, 10);
|
||||||
|
if (!Number.isNaN(n)) {
|
||||||
|
onSetInitiative(combatantId, n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setEditing(false);
|
||||||
|
}, [draft, combatantId, onSetInitiative]);
|
||||||
|
|
||||||
|
const startEditing = useCallback(() => {
|
||||||
|
setDraft(initiative?.toString() ?? "");
|
||||||
|
setEditing(true);
|
||||||
|
requestAnimationFrame(() => inputRef.current?.select());
|
||||||
|
}, [initiative]);
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={draft}
|
||||||
|
placeholder="--"
|
||||||
|
className={cn(
|
||||||
|
"h-7 w-[6ch] text-center text-sm tabular-nums",
|
||||||
|
dimmed && "opacity-50",
|
||||||
|
)}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") commit();
|
||||||
|
if (e.key === "Escape") setEditing(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty + bestiary creature → d20 roll button
|
||||||
|
if (initiative === undefined && onRollInitiative) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRollInitiative(combatantId)}
|
||||||
|
className={cn(
|
||||||
|
"flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral",
|
||||||
|
dimmed && "opacity-50",
|
||||||
|
)}
|
||||||
|
title="Roll initiative"
|
||||||
|
aria-label="Roll initiative"
|
||||||
|
>
|
||||||
|
<D20Icon className="h-7 w-7" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has value → bold number, click to edit
|
||||||
|
// Empty + manual → "--" placeholder, click to edit
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={startEditing}
|
||||||
|
className={cn(
|
||||||
|
"h-7 w-full text-center text-sm leading-7 tabular-nums transition-colors",
|
||||||
|
initiative !== undefined
|
||||||
|
? "font-medium text-foreground hover:text-hover-neutral"
|
||||||
|
: "text-muted-foreground hover:text-hover-neutral",
|
||||||
|
dimmed && "opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{initiative ?? "--"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowBorderClass(
|
||||||
|
isActive: boolean,
|
||||||
|
isConcentrating: boolean | undefined,
|
||||||
|
): string {
|
||||||
|
if (isActive) return "border-l-2 border-l-accent bg-accent/10";
|
||||||
|
if (isConcentrating) return "border-l-2 border-l-purple-400";
|
||||||
|
return "border-l-2 border-l-transparent";
|
||||||
|
}
|
||||||
|
|
||||||
|
function concentrationIconClass(
|
||||||
|
isConcentrating: boolean | undefined,
|
||||||
|
dimmed: boolean,
|
||||||
|
): string {
|
||||||
|
if (!isConcentrating)
|
||||||
|
return "opacity-0 group-hover:opacity-50 text-muted-foreground";
|
||||||
|
return dimmed ? "opacity-50 text-purple-400" : "opacity-100 text-purple-400";
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateOnKeyDown(
|
||||||
|
handler: () => void,
|
||||||
|
): (e: { key: string; preventDefault: () => void }) => void {
|
||||||
|
return (e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
handler();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CombatantRow({
|
||||||
|
ref,
|
||||||
|
combatant,
|
||||||
|
isActive,
|
||||||
|
onRename,
|
||||||
|
onSetInitiative,
|
||||||
|
onRemove,
|
||||||
|
onSetHp,
|
||||||
|
onAdjustHp,
|
||||||
|
onSetAc,
|
||||||
|
onToggleCondition,
|
||||||
|
onToggleConcentration,
|
||||||
|
onShowStatBlock,
|
||||||
|
onRollInitiative,
|
||||||
|
}: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) {
|
||||||
|
const { id, name, initiative, maxHp, currentHp } = combatant;
|
||||||
|
const status = deriveHpStatus(currentHp, maxHp);
|
||||||
|
const dimmed = status === "unconscious";
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
|
||||||
|
const prevHpRef = useRef(currentHp);
|
||||||
|
const [isPulsing, setIsPulsing] = useState(false);
|
||||||
|
const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const prevHp = prevHpRef.current;
|
||||||
|
prevHpRef.current = currentHp;
|
||||||
|
|
||||||
|
if (
|
||||||
|
prevHp !== undefined &&
|
||||||
|
currentHp !== undefined &&
|
||||||
|
currentHp < prevHp &&
|
||||||
|
combatant.isConcentrating
|
||||||
|
) {
|
||||||
|
setIsPulsing(true);
|
||||||
|
clearTimeout(pulseTimerRef.current);
|
||||||
|
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
|
||||||
|
}
|
||||||
|
}, [currentHp, combatant.isConcentrating]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!combatant.isConcentrating) {
|
||||||
|
clearTimeout(pulseTimerRef.current);
|
||||||
|
setIsPulsing(false);
|
||||||
|
}
|
||||||
|
}, [combatant.isConcentrating]);
|
||||||
|
|
||||||
|
const pcColor = combatant.color
|
||||||
|
? PLAYER_COLOR_HEX[combatant.color as keyof typeof PLAYER_COLOR_HEX]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
/* biome-ignore lint/a11y/noStaticElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role={onShowStatBlock ? "button" : undefined}
|
||||||
|
tabIndex={onShowStatBlock ? 0 : undefined}
|
||||||
|
className={cn(
|
||||||
|
"group rounded-md pr-3 transition-colors",
|
||||||
|
rowBorderClass(isActive, combatant.isConcentrating),
|
||||||
|
isPulsing && "animate-concentration-pulse",
|
||||||
|
onShowStatBlock && "cursor-pointer",
|
||||||
|
)}
|
||||||
|
onClick={onShowStatBlock}
|
||||||
|
onKeyDown={
|
||||||
|
onShowStatBlock ? activateOnKeyDown(onShowStatBlock) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-[2rem_3rem_1fr_auto_auto_2rem] items-center gap-3 py-2">
|
||||||
|
{/* Concentration */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleConcentration(id);
|
||||||
|
}}
|
||||||
|
title="Concentrating"
|
||||||
|
aria-label="Toggle concentration"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-center self-stretch -my-2 -ml-[2px] pl-[14px] transition-opacity hover:text-hover-neutral hover:opacity-100",
|
||||||
|
concentrationIconClass(combatant.isConcentrating, dimmed),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Brain size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Initiative */}
|
||||||
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<InitiativeDisplay
|
||||||
|
initiative={initiative}
|
||||||
|
combatantId={id}
|
||||||
|
dimmed={dimmed}
|
||||||
|
onSetInitiative={onSetInitiative}
|
||||||
|
onRollInitiative={onRollInitiative}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name + Conditions */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative flex flex-wrap items-center gap-1 min-w-0",
|
||||||
|
dimmed && "opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{combatant.icon &&
|
||||||
|
combatant.color &&
|
||||||
|
(() => {
|
||||||
|
const PcIcon = PLAYER_ICON_MAP[combatant.icon as PlayerIcon];
|
||||||
|
const pcColor =
|
||||||
|
PLAYER_COLOR_HEX[
|
||||||
|
combatant.color as keyof typeof PLAYER_COLOR_HEX
|
||||||
|
];
|
||||||
|
return PcIcon ? (
|
||||||
|
<PcIcon
|
||||||
|
size={14}
|
||||||
|
style={{ color: pcColor }}
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
<EditableName
|
||||||
|
name={name}
|
||||||
|
combatantId={id}
|
||||||
|
onRename={onRename}
|
||||||
|
onShowStatBlock={onShowStatBlock}
|
||||||
|
color={pcColor}
|
||||||
|
/>
|
||||||
|
<ConditionTags
|
||||||
|
conditions={combatant.conditions}
|
||||||
|
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
|
||||||
|
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
||||||
|
/>
|
||||||
|
{pickerOpen && (
|
||||||
|
<ConditionPicker
|
||||||
|
activeConditions={combatant.conditions}
|
||||||
|
onToggle={(conditionId) => onToggleCondition(id, conditionId)}
|
||||||
|
onClose={() => setPickerOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AC */}
|
||||||
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
||||||
|
<div
|
||||||
|
className={cn(dimmed && "opacity-50")}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* HP */}
|
||||||
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ClickableHp
|
||||||
|
currentHp={currentHp}
|
||||||
|
maxHp={maxHp}
|
||||||
|
onAdjust={(delta) => onAdjustHp(id, delta)}
|
||||||
|
dimmed={dimmed}
|
||||||
|
/>
|
||||||
|
{maxHp !== undefined && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-sm tabular-nums text-muted-foreground",
|
||||||
|
dimmed && "opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
/
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className={cn(dimmed && "opacity-50")}>
|
||||||
|
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => onSetHp(id, v)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<ConfirmButton
|
||||||
|
icon={<X size={16} />}
|
||||||
|
label="Remove combatant"
|
||||||
|
onConfirm={() => onRemove(id)}
|
||||||
|
className="h-7 w-7 text-muted-foreground opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto focus:opacity-100 focus:pointer-events-auto pointer-coarse:opacity-100 pointer-coarse:pointer-events-auto transition-opacity"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
apps/web/src/components/condition-picker.tsx
Normal file
134
apps/web/src/components/condition-picker.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
ArrowDown,
|
||||||
|
Ban,
|
||||||
|
BatteryLow,
|
||||||
|
Droplet,
|
||||||
|
EarOff,
|
||||||
|
EyeOff,
|
||||||
|
Gem,
|
||||||
|
Ghost,
|
||||||
|
Hand,
|
||||||
|
Heart,
|
||||||
|
Link,
|
||||||
|
Moon,
|
||||||
|
Siren,
|
||||||
|
Sparkles,
|
||||||
|
ZapOff,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
const ICON_MAP: Record<string, LucideIcon> = {
|
||||||
|
EyeOff,
|
||||||
|
Heart,
|
||||||
|
EarOff,
|
||||||
|
BatteryLow,
|
||||||
|
Siren,
|
||||||
|
Hand,
|
||||||
|
Ban,
|
||||||
|
Ghost,
|
||||||
|
ZapOff,
|
||||||
|
Gem,
|
||||||
|
Droplet,
|
||||||
|
ArrowDown,
|
||||||
|
Link,
|
||||||
|
Sparkles,
|
||||||
|
Moon,
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLOR_CLASSES: Record<string, string> = {
|
||||||
|
neutral: "text-muted-foreground",
|
||||||
|
pink: "text-pink-400",
|
||||||
|
amber: "text-amber-400",
|
||||||
|
orange: "text-orange-400",
|
||||||
|
gray: "text-gray-400",
|
||||||
|
violet: "text-violet-400",
|
||||||
|
yellow: "text-yellow-400",
|
||||||
|
slate: "text-slate-400",
|
||||||
|
green: "text-green-400",
|
||||||
|
indigo: "text-indigo-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ConditionPickerProps {
|
||||||
|
activeConditions: readonly ConditionId[] | undefined;
|
||||||
|
onToggle: (conditionId: ConditionId) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConditionPicker({
|
||||||
|
activeConditions,
|
||||||
|
onToggle,
|
||||||
|
onClose,
|
||||||
|
}: ConditionPickerProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [flipped, setFlipped] = useState(false);
|
||||||
|
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const spaceBelow = window.innerHeight - rect.top;
|
||||||
|
const spaceAbove = rect.bottom;
|
||||||
|
const shouldFlip =
|
||||||
|
rect.bottom > window.innerHeight && spaceAbove > spaceBelow;
|
||||||
|
setFlipped(shouldFlip);
|
||||||
|
const available = shouldFlip ? spaceAbove : spaceBelow;
|
||||||
|
if (rect.height > available) {
|
||||||
|
setMaxHeight(available - 16);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const active = new Set(activeConditions ?? []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"absolute left-0 z-10 w-fit overflow-y-auto rounded-md border border-border bg-background p-1 shadow-lg",
|
||||||
|
flipped ? "bottom-full mb-1" : "top-full mt-1",
|
||||||
|
)}
|
||||||
|
style={maxHeight ? { maxHeight } : undefined}
|
||||||
|
>
|
||||||
|
{CONDITION_DEFINITIONS.map((def) => {
|
||||||
|
const Icon = ICON_MAP[def.iconName];
|
||||||
|
if (!Icon) return null;
|
||||||
|
const isActive = active.has(def.id);
|
||||||
|
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={def.id}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-hover-neutral-bg",
|
||||||
|
isActive && "bg-card/50",
|
||||||
|
)}
|
||||||
|
onClick={() => onToggle(def.id)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size={14}
|
||||||
|
className={isActive ? colorClass : "text-muted-foreground"}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={isActive ? "text-foreground" : "text-muted-foreground"}
|
||||||
|
>
|
||||||
|
{def.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
apps/web/src/components/condition-tags.tsx
Normal file
102
apps/web/src/components/condition-tags.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
ArrowDown,
|
||||||
|
Ban,
|
||||||
|
BatteryLow,
|
||||||
|
Droplet,
|
||||||
|
EarOff,
|
||||||
|
EyeOff,
|
||||||
|
Gem,
|
||||||
|
Ghost,
|
||||||
|
Hand,
|
||||||
|
Heart,
|
||||||
|
Link,
|
||||||
|
Moon,
|
||||||
|
Plus,
|
||||||
|
Siren,
|
||||||
|
Sparkles,
|
||||||
|
ZapOff,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const ICON_MAP: Record<string, LucideIcon> = {
|
||||||
|
EyeOff,
|
||||||
|
Heart,
|
||||||
|
EarOff,
|
||||||
|
BatteryLow,
|
||||||
|
Siren,
|
||||||
|
Hand,
|
||||||
|
Ban,
|
||||||
|
Ghost,
|
||||||
|
ZapOff,
|
||||||
|
Gem,
|
||||||
|
Droplet,
|
||||||
|
ArrowDown,
|
||||||
|
Link,
|
||||||
|
Sparkles,
|
||||||
|
Moon,
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLOR_CLASSES: Record<string, string> = {
|
||||||
|
neutral: "text-muted-foreground",
|
||||||
|
pink: "text-pink-400",
|
||||||
|
amber: "text-amber-400",
|
||||||
|
orange: "text-orange-400",
|
||||||
|
gray: "text-gray-400",
|
||||||
|
violet: "text-violet-400",
|
||||||
|
yellow: "text-yellow-400",
|
||||||
|
slate: "text-slate-400",
|
||||||
|
green: "text-green-400",
|
||||||
|
indigo: "text-indigo-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ConditionTagsProps {
|
||||||
|
conditions: readonly ConditionId[] | undefined;
|
||||||
|
onRemove: (conditionId: ConditionId) => void;
|
||||||
|
onOpenPicker: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConditionTags({
|
||||||
|
conditions,
|
||||||
|
onRemove,
|
||||||
|
onOpenPicker,
|
||||||
|
}: ConditionTagsProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-0.5">
|
||||||
|
{conditions?.map((condId) => {
|
||||||
|
const def = CONDITION_DEFINITIONS.find((d) => d.id === condId);
|
||||||
|
if (!def) return null;
|
||||||
|
const Icon = ICON_MAP[def.iconName];
|
||||||
|
if (!Icon) return null;
|
||||||
|
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={condId}
|
||||||
|
type="button"
|
||||||
|
title={def.label}
|
||||||
|
aria-label={`Remove ${def.label}`}
|
||||||
|
className={`inline-flex items-center rounded p-0.5 hover:bg-hover-neutral-bg transition-colors ${colorClass}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove(condId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={14} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Add condition"
|
||||||
|
aria-label="Add condition"
|
||||||
|
className="inline-flex items-center rounded p-0.5 text-muted-foreground hover:text-hover-neutral hover:bg-hover-neutral-bg transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 pointer-coarse:opacity-100 transition-opacity"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpenPicker();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
apps/web/src/components/create-player-modal.tsx
Normal file
187
apps/web/src/components/create-player-modal.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
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 | undefined,
|
||||||
|
icon: string | undefined,
|
||||||
|
) => 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("");
|
||||||
|
setIcon("");
|
||||||
|
}
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
}, [open, playerCharacter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const handleSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (trimmed === "") {
|
||||||
|
setError("Name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const acNum = Number.parseInt(ac, 10);
|
||||||
|
if (Number.isNaN(acNum) || acNum < 0) {
|
||||||
|
setError("AC must be a non-negative number");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hpNum = Number.parseInt(maxHp, 10);
|
||||||
|
if (Number.isNaN(hpNum) || hpNum < 1) {
|
||||||
|
setError("Max HP must be at least 1");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSave(trimmed, acNum, hpNum, color || undefined, icon || undefined);
|
||||||
|
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
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
apps/web/src/components/d20-icon.tsx
Normal file
29
apps/web/src/components/d20-icon.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
interface D20IconProps {
|
||||||
|
readonly className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function D20Icon({ className }: D20IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
height="24"
|
||||||
|
width="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<polygon points="20.22 16.76 20.22 7.26 12.04 2.51 3.85 7.26 3.85 16.76 12.04 21.51 20.22 16.76" />
|
||||||
|
<line x1="7.29" y1="9.26" x2="3.85" y2="7.26" />
|
||||||
|
<line x1="20.22" y1="7.26" x2="16.79" y2="9.26" />
|
||||||
|
<line x1="12.04" y1="17.44" x2="12.04" y2="21.51" />
|
||||||
|
<polygon points="12.04 17.44 20.22 16.76 16.79 9.26 12.04 17.44" />
|
||||||
|
<polygon points="12.04 17.44 7.29 9.26 3.85 16.76 12.04 17.44" />
|
||||||
|
<polygon points="12.04 2.51 7.29 9.26 16.79 9.26 12.04 2.51" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
apps/web/src/components/hp-adjust-popover.tsx
Normal file
134
apps/web/src/components/hp-adjust-popover.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { Heart, Sword } from "lucide-react";
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { Input } from "./ui/input";
|
||||||
|
|
||||||
|
interface HpAdjustPopoverProps {
|
||||||
|
readonly onAdjust: (delta: number) => void;
|
||||||
|
readonly onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
||||||
|
const [inputValue, setInputValue] = useState("");
|
||||||
|
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const parent = el.parentElement;
|
||||||
|
if (!parent) return;
|
||||||
|
const trigger = parent.getBoundingClientRect();
|
||||||
|
const popover = el.getBoundingClientRect();
|
||||||
|
const vw = document.documentElement.clientWidth;
|
||||||
|
let left = trigger.left;
|
||||||
|
if (left + popover.width > vw) {
|
||||||
|
left = vw - popover.width - 8;
|
||||||
|
}
|
||||||
|
if (left < 8) {
|
||||||
|
left = 8;
|
||||||
|
}
|
||||||
|
setPos({ top: trigger.bottom + 4, left });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
requestAnimationFrame(() => inputRef.current?.focus());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const parsedValue =
|
||||||
|
inputValue === "" ? null : Number.parseInt(inputValue, 10);
|
||||||
|
const isValid =
|
||||||
|
parsedValue !== null && !Number.isNaN(parsedValue) && parsedValue > 0;
|
||||||
|
|
||||||
|
const applyDelta = useCallback(
|
||||||
|
(sign: -1 | 1) => {
|
||||||
|
if (inputValue === "") return;
|
||||||
|
const n = Number.parseInt(inputValue, 10);
|
||||||
|
if (Number.isNaN(n) || n <= 0) return;
|
||||||
|
onAdjust(sign * n);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
[inputValue, onAdjust, onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
if (e.shiftKey) {
|
||||||
|
applyDelta(1);
|
||||||
|
} else {
|
||||||
|
applyDelta(-1);
|
||||||
|
}
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[applyDelta, onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="fixed z-10 rounded-md border border-border bg-background p-2 shadow-lg"
|
||||||
|
style={
|
||||||
|
pos
|
||||||
|
? { top: pos.top, left: pos.left }
|
||||||
|
: { visibility: "hidden" as const }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={inputValue}
|
||||||
|
placeholder="HP"
|
||||||
|
className="h-7 w-[7ch] text-center text-sm tabular-nums"
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
if (v === "" || /^\d+$/.test(v)) {
|
||||||
|
setInputValue(v);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!isValid}
|
||||||
|
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-red-400 transition-colors hover:bg-red-950 hover:text-red-300 disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
onClick={() => applyDelta(-1)}
|
||||||
|
title="Apply damage"
|
||||||
|
aria-label="Apply damage"
|
||||||
|
>
|
||||||
|
<Sword size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!isValid}
|
||||||
|
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-emerald-400 transition-colors hover:bg-emerald-950 hover:text-emerald-300 disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
onClick={() => applyDelta(1)}
|
||||||
|
title="Apply healing"
|
||||||
|
aria-label="Apply healing"
|
||||||
|
>
|
||||||
|
<Heart size={14} />
|
||||||
|
</button>
|
||||||
|
</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(value === iconId ? "" : 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
apps/web/src/components/player-character-section.tsx
Normal file
91
apps/web/src/components/player-character-section.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||||
|
import { forwardRef, useImperativeHandle, useState } from "react";
|
||||||
|
import { CreatePlayerModal } from "./create-player-modal.js";
|
||||||
|
import { PlayerManagement } from "./player-management.js";
|
||||||
|
|
||||||
|
export interface PlayerCharacterSectionHandle {
|
||||||
|
openManagement: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlayerCharacterSectionProps {
|
||||||
|
characters: readonly PlayerCharacter[];
|
||||||
|
onCreateCharacter: (
|
||||||
|
name: string,
|
||||||
|
ac: number,
|
||||||
|
maxHp: number,
|
||||||
|
color: string | undefined,
|
||||||
|
icon: string | undefined,
|
||||||
|
) => void;
|
||||||
|
onEditCharacter: (
|
||||||
|
id: PlayerCharacterId,
|
||||||
|
fields: {
|
||||||
|
name?: string;
|
||||||
|
ac?: number;
|
||||||
|
maxHp?: number;
|
||||||
|
color?: string | null;
|
||||||
|
icon?: string | null;
|
||||||
|
},
|
||||||
|
) => void;
|
||||||
|
onDeleteCharacter: (id: PlayerCharacterId) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlayerCharacterSection = forwardRef<
|
||||||
|
PlayerCharacterSectionHandle,
|
||||||
|
PlayerCharacterSectionProps
|
||||||
|
>(function PlayerCharacterSection(
|
||||||
|
{ characters, onCreateCharacter, onEditCharacter, onDeleteCharacter },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const [managementOpen, setManagementOpen] = useState(false);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [editingPlayer, setEditingPlayer] = useState<
|
||||||
|
PlayerCharacter | undefined
|
||||||
|
>();
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
openManagement: () => setManagementOpen(true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CreatePlayerModal
|
||||||
|
open={createOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setCreateOpen(false);
|
||||||
|
setEditingPlayer(undefined);
|
||||||
|
setManagementOpen(true);
|
||||||
|
}}
|
||||||
|
onSave={(name, ac, maxHp, color, icon) => {
|
||||||
|
if (editingPlayer) {
|
||||||
|
onEditCharacter(editingPlayer.id, {
|
||||||
|
name,
|
||||||
|
ac,
|
||||||
|
maxHp,
|
||||||
|
color: color ?? null,
|
||||||
|
icon: icon ?? null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onCreateCharacter(name, ac, maxHp, color, icon);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
playerCharacter={editingPlayer}
|
||||||
|
/>
|
||||||
|
<PlayerManagement
|
||||||
|
open={managementOpen}
|
||||||
|
onClose={() => setManagementOpen(false)}
|
||||||
|
characters={characters}
|
||||||
|
onEdit={(pc) => {
|
||||||
|
setEditingPlayer(pc);
|
||||||
|
setCreateOpen(true);
|
||||||
|
setManagementOpen(false);
|
||||||
|
}}
|
||||||
|
onDelete={(id) => onDeleteCharacter(id)}
|
||||||
|
onCreate={() => {
|
||||||
|
setEditingPlayer(undefined);
|
||||||
|
setCreateOpen(true);
|
||||||
|
setManagementOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
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",
|
||||||
|
};
|
||||||
121
apps/web/src/components/player-management.tsx
Normal file
121
apps/web/src/components/player-management.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||||
|
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { ConfirmButton } from "./ui/confirm-button";
|
||||||
|
|
||||||
|
interface PlayerManagementProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
characters: readonly PlayerCharacter[];
|
||||||
|
onEdit: (pc: PlayerCharacter) => void;
|
||||||
|
onDelete: (id: PlayerCharacterId) => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlayerManagement({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
characters,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onCreate,
|
||||||
|
}: PlayerManagementProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||||
|
onMouseDown={onClose}
|
||||||
|
>
|
||||||
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
|
Player Characters
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<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}>
|
||||||
|
<Plus size={16} />
|
||||||
|
Create your first player character
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{characters.map((pc) => {
|
||||||
|
const Icon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
|
||||||
|
const color = pc.color ? PLAYER_COLOR_HEX[pc.color] : undefined;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={pc.id}
|
||||||
|
className="group flex items-center gap-3 rounded-md px-3 py-2 hover:bg-hover-neutral-bg"
|
||||||
|
>
|
||||||
|
{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
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => onEdit(pc)}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</Button>
|
||||||
|
<ConfirmButton
|
||||||
|
icon={<Trash2 size={14} />}
|
||||||
|
label="Delete player character"
|
||||||
|
onConfirm={() => onDelete(pc.id)}
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="mt-2 flex justify-end">
|
||||||
|
<Button onClick={onCreate} variant="ghost">
|
||||||
|
<Plus size={16} />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
apps/web/src/components/source-fetch-prompt.tsx
Normal file
126
apps/web/src/components/source-fetch-prompt.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
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 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
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
apps/web/src/components/source-manager.tsx
Normal file
97
apps/web/src/components/source-manager.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { Database, Trash2 } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useOptimistic, 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 [optimisticSources, applyOptimistic] = useOptimistic(
|
||||||
|
sources,
|
||||||
|
(
|
||||||
|
state,
|
||||||
|
action: { type: "remove"; sourceCode: string } | { type: "clear" },
|
||||||
|
) =>
|
||||||
|
action.type === "clear"
|
||||||
|
? []
|
||||||
|
: state.filter((s) => s.sourceCode !== action.sourceCode),
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadSources = useCallback(async () => {
|
||||||
|
const cached = await bestiaryCache.getCachedSources();
|
||||||
|
setSources(cached);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSources();
|
||||||
|
}, [loadSources]);
|
||||||
|
|
||||||
|
const handleClearSource = async (sourceCode: string) => {
|
||||||
|
applyOptimistic({ type: "remove", sourceCode });
|
||||||
|
await bestiaryCache.clearSource(sourceCode);
|
||||||
|
await loadSources();
|
||||||
|
onCacheCleared();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearAll = async () => {
|
||||||
|
applyOptimistic({ type: "clear" });
|
||||||
|
await bestiaryCache.clearAll();
|
||||||
|
await loadSources();
|
||||||
|
onCacheCleared();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (optimisticSources.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
|
||||||
|
variant="outline"
|
||||||
|
className="hover:text-hover-destructive hover:border-hover-destructive"
|
||||||
|
onClick={handleClearAll}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1 h-3 w-3" />
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{optimisticSources.map((source) => (
|
||||||
|
<li
|
||||||
|
key={source.sourceCode}
|
||||||
|
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-foreground">
|
||||||
|
{source.displayName}
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">
|
||||||
|
{source.creatureCount} creatures
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleClearSource(source.sourceCode)}
|
||||||
|
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-hover-destructive-bg hover:text-hover-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
361
apps/web/src/components/stat-block-panel.tsx
Normal file
361
apps/web/src/components/stat-block-panel.tsx
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
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 { SourceManager } from "./source-manager.js";
|
||||||
|
import { StatBlock } from "./stat-block.js";
|
||||||
|
import { Button } from "./ui/button.js";
|
||||||
|
|
||||||
|
interface StatBlockPanelProps {
|
||||||
|
creatureId: CreatureId | null;
|
||||||
|
creature: Creature | null;
|
||||||
|
isSourceCached: (sourceCode: string) => Promise<boolean>;
|
||||||
|
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
|
||||||
|
uploadAndCacheSource: (
|
||||||
|
sourceCode: string,
|
||||||
|
jsonData: unknown,
|
||||||
|
) => Promise<void>;
|
||||||
|
refreshCache: () => Promise<void>;
|
||||||
|
panelRole: "browse" | "pinned";
|
||||||
|
isCollapsed: boolean;
|
||||||
|
onToggleCollapse: () => void;
|
||||||
|
onPin: () => void;
|
||||||
|
onUnpin: () => void;
|
||||||
|
showPinButton: boolean;
|
||||||
|
side: "left" | "right";
|
||||||
|
onDismiss: () => void;
|
||||||
|
bulkImportMode?: boolean;
|
||||||
|
bulkImportState?: BulkImportState;
|
||||||
|
onStartBulkImport?: (baseUrl: string) => void;
|
||||||
|
onBulkImportDone?: () => void;
|
||||||
|
sourceManagerMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSourceCode(cId: CreatureId): string {
|
||||||
|
const colonIndex = cId.indexOf(":");
|
||||||
|
if (colonIndex === -1) return "";
|
||||||
|
return cId.slice(0, colonIndex).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsedTab({
|
||||||
|
creatureName,
|
||||||
|
side,
|
||||||
|
onToggleCollapse,
|
||||||
|
}: {
|
||||||
|
creatureName: string;
|
||||||
|
side: "left" | "right";
|
||||||
|
onToggleCollapse: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
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="Expand stat block panel"
|
||||||
|
>
|
||||||
|
<span className="writing-vertical-rl text-sm font-medium">
|
||||||
|
{creatureName}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelHeader({
|
||||||
|
panelRole,
|
||||||
|
showPinButton,
|
||||||
|
onToggleCollapse,
|
||||||
|
onPin,
|
||||||
|
onUnpin,
|
||||||
|
}: {
|
||||||
|
panelRole: "browse" | "pinned";
|
||||||
|
showPinButton: boolean;
|
||||||
|
onToggleCollapse: () => 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
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
aria-label="Collapse stat block panel"
|
||||||
|
>
|
||||||
|
<PanelRightClose className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{panelRole === "browse" && showPinButton && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onPin}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
aria-label="Pin creature"
|
||||||
|
>
|
||||||
|
<Pin className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{panelRole === "pinned" && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onUnpin}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
aria-label="Unpin creature"
|
||||||
|
>
|
||||||
|
<PinOff className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DesktopPanel({
|
||||||
|
isCollapsed,
|
||||||
|
side,
|
||||||
|
creatureName,
|
||||||
|
panelRole,
|
||||||
|
showPinButton,
|
||||||
|
onToggleCollapse,
|
||||||
|
onPin,
|
||||||
|
onUnpin,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
isCollapsed: boolean;
|
||||||
|
side: "left" | "right";
|
||||||
|
creatureName: string;
|
||||||
|
panelRole: "browse" | "pinned";
|
||||||
|
showPinButton: boolean;
|
||||||
|
onToggleCollapse: () => void;
|
||||||
|
onPin: () => void;
|
||||||
|
onUnpin: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const sideClasses = side === "left" ? "left-0 border-r" : "right-0 border-l";
|
||||||
|
const collapsedTranslate =
|
||||||
|
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} ${isCollapsed ? collapsedTranslate : "translate-x-0"}`}
|
||||||
|
>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<CollapsedTab
|
||||||
|
creatureName={creatureName}
|
||||||
|
side={side}
|
||||||
|
onToggleCollapse={onToggleCollapse}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PanelHeader
|
||||||
|
panelRole={panelRole}
|
||||||
|
showPinButton={showPinButton}
|
||||||
|
onToggleCollapse={onToggleCollapse}
|
||||||
|
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
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
aria-label="Collapse 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,
|
||||||
|
isCollapsed,
|
||||||
|
onToggleCollapse,
|
||||||
|
onPin,
|
||||||
|
onUnpin,
|
||||||
|
showPinButton,
|
||||||
|
side,
|
||||||
|
onDismiss,
|
||||||
|
bulkImportMode,
|
||||||
|
bulkImportState,
|
||||||
|
onStartBulkImport,
|
||||||
|
onBulkImportDone,
|
||||||
|
sourceManagerMode,
|
||||||
|
}: StatBlockPanelProps) {
|
||||||
|
const [isDesktop, setIsDesktop] = useState(
|
||||||
|
() => window.matchMedia("(min-width: 1024px)").matches,
|
||||||
|
);
|
||||||
|
const [needsFetch, setNeedsFetch] = useState(false);
|
||||||
|
const [checkingCache, setCheckingCache] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia("(min-width: 1024px)");
|
||||||
|
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
||||||
|
mq.addEventListener("change", handler);
|
||||||
|
return () => mq.removeEventListener("change", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!creatureId || creature) {
|
||||||
|
setNeedsFetch(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceCode = extractSourceCode(creatureId);
|
||||||
|
if (!sourceCode) {
|
||||||
|
setNeedsFetch(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCheckingCache(true);
|
||||||
|
isSourceCached(sourceCode).then((cached) => {
|
||||||
|
setNeedsFetch(!cached);
|
||||||
|
setCheckingCache(false);
|
||||||
|
});
|
||||||
|
}, [creatureId, creature, isSourceCached]);
|
||||||
|
|
||||||
|
if (!creatureId && !bulkImportMode && !sourceManagerMode) return null;
|
||||||
|
|
||||||
|
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
|
||||||
|
|
||||||
|
const handleSourceLoaded = async () => {
|
||||||
|
await refreshCache();
|
||||||
|
setNeedsFetch(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (sourceManagerMode) {
|
||||||
|
return <SourceManager onCacheCleared={refreshCache} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ??
|
||||||
|
(sourceManagerMode
|
||||||
|
? "Sources"
|
||||||
|
: bulkImportMode
|
||||||
|
? "Import All Sources"
|
||||||
|
: "Creature");
|
||||||
|
|
||||||
|
if (isDesktop) {
|
||||||
|
return (
|
||||||
|
<DesktopPanel
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
side={side}
|
||||||
|
creatureName={creatureName}
|
||||||
|
panelRole={panelRole}
|
||||||
|
showPinButton={showPinButton}
|
||||||
|
onToggleCollapse={onToggleCollapse}
|
||||||
|
onPin={onPin}
|
||||||
|
onUnpin={onUnpin}
|
||||||
|
>
|
||||||
|
{renderContent()}
|
||||||
|
</DesktopPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (panelRole === "pinned") return null;
|
||||||
|
|
||||||
|
return <MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>;
|
||||||
|
}
|
||||||
259
apps/web/src/components/stat-block.tsx
Normal file
259
apps/web/src/components/stat-block.tsx
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import {
|
||||||
|
type Creature,
|
||||||
|
calculateInitiative,
|
||||||
|
formatInitiativeModifier,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
|
||||||
|
interface StatBlockProps {
|
||||||
|
creature: Creature;
|
||||||
|
}
|
||||||
|
|
||||||
|
function abilityMod(score: number): string {
|
||||||
|
const mod = Math.floor((score - 10) / 2);
|
||||||
|
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PropertyLine({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string | undefined;
|
||||||
|
}) {
|
||||||
|
if (!value) return null;
|
||||||
|
return (
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-semibold">{label}</span> {value}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionDivider() {
|
||||||
|
return (
|
||||||
|
<div className="my-2 h-px bg-gradient-to-r from-amber-800/60 via-amber-700/40 to-transparent" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatBlock({ creature }: StatBlockProps) {
|
||||||
|
const abilities = [
|
||||||
|
{ label: "STR", score: creature.abilities.str },
|
||||||
|
{ label: "DEX", score: creature.abilities.dex },
|
||||||
|
{ label: "CON", score: creature.abilities.con },
|
||||||
|
{ label: "INT", score: creature.abilities.int },
|
||||||
|
{ label: "WIS", score: creature.abilities.wis },
|
||||||
|
{ label: "CHA", score: creature.abilities.cha },
|
||||||
|
];
|
||||||
|
|
||||||
|
const initiative = calculateInitiative({
|
||||||
|
dexScore: creature.abilities.dex,
|
||||||
|
cr: creature.cr,
|
||||||
|
initiativeProficiency: creature.initiativeProficiency,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1 text-foreground">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-amber-400">{creature.name}</h2>
|
||||||
|
<p className="text-sm italic text-muted-foreground">
|
||||||
|
{creature.size} {creature.type}, {creature.alignment}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{creature.sourceDisplayName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionDivider />
|
||||||
|
|
||||||
|
{/* Stats bar */}
|
||||||
|
<div className="space-y-0.5 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">Armor Class</span> {creature.ac}
|
||||||
|
{creature.acSource && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{" "}
|
||||||
|
({creature.acSource})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-3">
|
||||||
|
<span className="font-semibold">Initiative</span>{" "}
|
||||||
|
{formatInitiativeModifier(initiative.modifier)} (
|
||||||
|
{initiative.passive})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">Hit Points</span>{" "}
|
||||||
|
{creature.hp.average}{" "}
|
||||||
|
<span className="text-muted-foreground">({creature.hp.formula})</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">Speed</span> {creature.speed}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionDivider />
|
||||||
|
|
||||||
|
{/* Ability scores */}
|
||||||
|
<div className="grid grid-cols-6 gap-1 text-center text-sm">
|
||||||
|
{abilities.map(({ label, score }) => (
|
||||||
|
<div key={label}>
|
||||||
|
<div className="font-semibold">{label}</div>
|
||||||
|
<div>
|
||||||
|
{score}{" "}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
({abilityMod(score)})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionDivider />
|
||||||
|
|
||||||
|
{/* Properties */}
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<PropertyLine label="Saving Throws" value={creature.savingThrows} />
|
||||||
|
<PropertyLine label="Skills" value={creature.skills} />
|
||||||
|
<PropertyLine
|
||||||
|
label="Damage Vulnerabilities"
|
||||||
|
value={creature.vulnerable}
|
||||||
|
/>
|
||||||
|
<PropertyLine label="Damage Resistances" value={creature.resist} />
|
||||||
|
<PropertyLine label="Damage Immunities" value={creature.immune} />
|
||||||
|
<PropertyLine
|
||||||
|
label="Condition Immunities"
|
||||||
|
value={creature.conditionImmune}
|
||||||
|
/>
|
||||||
|
<PropertyLine label="Senses" value={creature.senses} />
|
||||||
|
<PropertyLine label="Languages" value={creature.languages} />
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-semibold">Challenge</span> {creature.cr}{" "}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
(Proficiency Bonus +{creature.proficiencyBonus})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Traits */}
|
||||||
|
{creature.traits && creature.traits.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionDivider />
|
||||||
|
<div className="space-y-2">
|
||||||
|
{creature.traits.map((t) => (
|
||||||
|
<div key={t.name} className="text-sm">
|
||||||
|
<span className="font-semibold italic">{t.name}.</span> {t.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Spellcasting */}
|
||||||
|
{creature.spellcasting && creature.spellcasting.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionDivider />
|
||||||
|
{creature.spellcasting.map((sc) => (
|
||||||
|
<div key={sc.name} className="space-y-1 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold italic">{sc.name}.</span>{" "}
|
||||||
|
{sc.headerText}
|
||||||
|
</div>
|
||||||
|
{sc.atWill && sc.atWill.length > 0 && (
|
||||||
|
<div className="pl-2">
|
||||||
|
<span className="font-semibold">At Will:</span>{" "}
|
||||||
|
{sc.atWill.join(", ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sc.daily?.map((d) => (
|
||||||
|
<div key={`${d.uses}${d.each ? "e" : ""}`} className="pl-2">
|
||||||
|
<span className="font-semibold">
|
||||||
|
{d.uses}/day
|
||||||
|
{d.each ? " each" : ""}:
|
||||||
|
</span>{" "}
|
||||||
|
{d.spells.join(", ")}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sc.restLong?.map((d) => (
|
||||||
|
<div
|
||||||
|
key={`rest-${d.uses}${d.each ? "e" : ""}`}
|
||||||
|
className="pl-2"
|
||||||
|
>
|
||||||
|
<span className="font-semibold">
|
||||||
|
{d.uses}/long rest
|
||||||
|
{d.each ? " each" : ""}:
|
||||||
|
</span>{" "}
|
||||||
|
{d.spells.join(", ")}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{creature.actions && creature.actions.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionDivider />
|
||||||
|
<h3 className="text-base font-bold text-amber-400">Actions</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{creature.actions.map((a) => (
|
||||||
|
<div key={a.name} className="text-sm">
|
||||||
|
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bonus Actions */}
|
||||||
|
{creature.bonusActions && creature.bonusActions.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionDivider />
|
||||||
|
<h3 className="text-base font-bold text-amber-400">Bonus Actions</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{creature.bonusActions.map((a) => (
|
||||||
|
<div key={a.name} className="text-sm">
|
||||||
|
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reactions */}
|
||||||
|
{creature.reactions && creature.reactions.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionDivider />
|
||||||
|
<h3 className="text-base font-bold text-amber-400">Reactions</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{creature.reactions.map((a) => (
|
||||||
|
<div key={a.name} className="text-sm">
|
||||||
|
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Legendary Actions */}
|
||||||
|
{creature.legendaryActions && (
|
||||||
|
<>
|
||||||
|
<SectionDivider />
|
||||||
|
<h3 className="text-base font-bold text-amber-400">
|
||||||
|
Legendary Actions
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm italic text-muted-foreground">
|
||||||
|
{creature.legendaryActions.preamble}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{creature.legendaryActions.entries.map((a) => (
|
||||||
|
<div key={a.name} className="text-sm">
|
||||||
|
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
apps/web/src/components/toast.tsx
Normal file
49
apps/web/src/components/toast.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { X } from "lucide-react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { Button } from "./ui/button.js";
|
||||||
|
|
||||||
|
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-4 z-50">
|
||||||
|
<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
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
68
apps/web/src/components/turn-navigation.tsx
Normal file
68
apps/web/src/components/turn-navigation.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import type { Encounter } from "@initiative/domain";
|
||||||
|
import { StepBack, StepForward, Trash2 } from "lucide-react";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { ConfirmButton } from "./ui/confirm-button";
|
||||||
|
|
||||||
|
interface TurnNavigationProps {
|
||||||
|
encounter: Encounter;
|
||||||
|
onAdvanceTurn: () => void;
|
||||||
|
onRetreatTurn: () => void;
|
||||||
|
onClearEncounter: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TurnNavigation({
|
||||||
|
encounter,
|
||||||
|
onAdvanceTurn,
|
||||||
|
onRetreatTurn,
|
||||||
|
onClearEncounter,
|
||||||
|
}: TurnNavigationProps) {
|
||||||
|
const hasCombatants = encounter.combatants.length > 0;
|
||||||
|
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
||||||
|
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={onRetreatTurn}
|
||||||
|
disabled={!hasCombatants || isAtStart}
|
||||||
|
title="Previous turn"
|
||||||
|
aria-label="Previous turn"
|
||||||
|
>
|
||||||
|
<StepBack className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1 flex items-center justify-center gap-2 text-sm">
|
||||||
|
<span className="rounded-full bg-muted text-foreground text-sm px-2 py-0.5 font-semibold shrink-0">
|
||||||
|
R{encounter.roundNumber}
|
||||||
|
</span>
|
||||||
|
{activeCombatant ? (
|
||||||
|
<span className="truncate font-medium">{activeCombatant.name}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">No combatants</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-3">
|
||||||
|
<ConfirmButton
|
||||||
|
icon={<Trash2 className="h-5 w-5" />}
|
||||||
|
label="Clear encounter"
|
||||||
|
onConfirm={onClearEncounter}
|
||||||
|
disabled={!hasCombatants}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={onAdvanceTurn}
|
||||||
|
disabled={!hasCombatants}
|
||||||
|
title="Next turn"
|
||||||
|
aria-label="Next turn"
|
||||||
|
>
|
||||||
|
<StepForward className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
apps/web/src/components/ui/button.tsx
Normal file
38
apps/web/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import type { ButtonHTMLAttributes } from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
outline:
|
||||||
|
"border border-border bg-transparent hover:bg-hover-neutral-bg hover:text-hover-neutral",
|
||||||
|
ghost: "hover:bg-hover-neutral-bg hover:text-hover-neutral",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-8 px-3 text-xs",
|
||||||
|
icon: "h-8 w-8",
|
||||||
|
"icon-sm": "h-6 w-6",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
|
||||||
|
VariantProps<typeof buttonVariants>;
|
||||||
|
|
||||||
|
export function Button({ className, variant, size, ...props }: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
apps/web/src/components/ui/confirm-button.tsx
Normal file
117
apps/web/src/components/ui/confirm-button.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
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 size?: "icon" | "icon-sm";
|
||||||
|
readonly className?: string;
|
||||||
|
readonly disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REVERT_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
|
export function ConfirmButton({
|
||||||
|
onConfirm,
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
size = "icon",
|
||||||
|
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={size}
|
||||||
|
className={cn(
|
||||||
|
className,
|
||||||
|
isConfirming
|
||||||
|
? "bg-destructive text-primary-foreground rounded-md animate-confirm-pulse hover:bg-destructive hover:text-primary-foreground"
|
||||||
|
: "hover:text-hover-destructive",
|
||||||
|
)}
|
||||||
|
onClick={handleClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={revert}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
|
||||||
|
title={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
|
||||||
|
>
|
||||||
|
{isConfirming ? <Check size={16} /> : icon}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
apps/web/src/components/ui/input.tsx
Normal file
19
apps/web/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { forwardRef, type InputHTMLAttributes } from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
type InputProps = InputHTMLAttributes<HTMLInputElement>;
|
||||||
|
|
||||||
|
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
72
apps/web/src/components/ui/overflow-menu.tsx
Normal file
72
apps/web/src/components/ui/overflow-menu.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { EllipsisVertical } from "lucide-react";
|
||||||
|
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||||
|
import { Button } from "./button";
|
||||||
|
|
||||||
|
export interface OverflowMenuItem {
|
||||||
|
readonly icon: ReactNode;
|
||||||
|
readonly label: string;
|
||||||
|
readonly onClick: () => void;
|
||||||
|
readonly disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverflowMenuProps {
|
||||||
|
readonly items: readonly OverflowMenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverflowMenu({ items }: OverflowMenuProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function handleMouseDown(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleMouseDown);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleMouseDown);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-muted-foreground hover:text-hover-neutral"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
aria-label="More actions"
|
||||||
|
title="More actions"
|
||||||
|
>
|
||||||
|
<EllipsisVertical className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute bottom-full right-0 z-50 mb-1 min-w-48 rounded-md border border-border bg-card py-1 shadow-lg">
|
||||||
|
{items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.label}
|
||||||
|
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 disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
disabled={item.disabled}
|
||||||
|
onClick={() => {
|
||||||
|
item.onClick();
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
apps/web/src/hooks/use-bestiary.ts
Normal file
134
apps/web/src/hooks/use-bestiary.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import type {
|
||||||
|
BestiaryIndexEntry,
|
||||||
|
Creature,
|
||||||
|
CreatureId,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
normalizeBestiary,
|
||||||
|
setSourceDisplayNames,
|
||||||
|
} from "../adapters/bestiary-adapter.js";
|
||||||
|
import * as bestiaryCache from "../adapters/bestiary-cache.js";
|
||||||
|
import {
|
||||||
|
getSourceDisplayName,
|
||||||
|
loadBestiaryIndex,
|
||||||
|
} from "../adapters/bestiary-index-adapter.js";
|
||||||
|
|
||||||
|
export interface SearchResult extends BestiaryIndexEntry {
|
||||||
|
readonly sourceDisplayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BestiaryHook {
|
||||||
|
search: (query: string) => SearchResult[];
|
||||||
|
getCreature: (id: CreatureId) => Creature | undefined;
|
||||||
|
isLoaded: boolean;
|
||||||
|
isSourceCached: (sourceCode: string) => Promise<boolean>;
|
||||||
|
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
|
||||||
|
uploadAndCacheSource: (
|
||||||
|
sourceCode: string,
|
||||||
|
jsonData: unknown,
|
||||||
|
) => Promise<void>;
|
||||||
|
refreshCache: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBestiary(): BestiaryHook {
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
const [creatureMap, setCreatureMap] = useState(
|
||||||
|
() => new Map<CreatureId, Creature>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const index = loadBestiaryIndex();
|
||||||
|
setSourceDisplayNames(index.sources as Record<string, string>);
|
||||||
|
if (index.creatures.length > 0) {
|
||||||
|
setIsLoaded(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
bestiaryCache.loadAllCachedCreatures().then((map) => {
|
||||||
|
setCreatureMap(map);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const search = useCallback((query: string): SearchResult[] => {
|
||||||
|
if (query.length < 2) return [];
|
||||||
|
const lower = query.toLowerCase();
|
||||||
|
const index = loadBestiaryIndex();
|
||||||
|
return index.creatures
|
||||||
|
.filter((c) => c.name.toLowerCase().includes(lower))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((c) => ({
|
||||||
|
...c,
|
||||||
|
sourceDisplayName: getSourceDisplayName(c.source),
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getCreature = useCallback(
|
||||||
|
(id: CreatureId): Creature | undefined => {
|
||||||
|
return creatureMap.get(id);
|
||||||
|
},
|
||||||
|
[creatureMap],
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
setCreatureMap((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const c of creatures) {
|
||||||
|
next.set(c.id, c);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
setCreatureMap((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const c of creatures) {
|
||||||
|
next.set(c.id, c);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshCache = useCallback(async (): Promise<void> => {
|
||||||
|
const map = await bestiaryCache.loadAllCachedCreatures();
|
||||||
|
setCreatureMap(map);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -1,43 +1,106 @@
|
|||||||
import type { EncounterStore } from "@initiative/application";
|
import type { EncounterStore } from "@initiative/application";
|
||||||
import {
|
import {
|
||||||
addCombatantUseCase,
|
addCombatantUseCase,
|
||||||
|
adjustHpUseCase,
|
||||||
advanceTurnUseCase,
|
advanceTurnUseCase,
|
||||||
|
clearEncounterUseCase,
|
||||||
editCombatantUseCase,
|
editCombatantUseCase,
|
||||||
removeCombatantUseCase,
|
removeCombatantUseCase,
|
||||||
|
retreatTurnUseCase,
|
||||||
|
setAcUseCase,
|
||||||
|
setHpUseCase,
|
||||||
setInitiativeUseCase,
|
setInitiativeUseCase,
|
||||||
|
toggleConcentrationUseCase,
|
||||||
|
toggleConditionUseCase,
|
||||||
} from "@initiative/application";
|
} from "@initiative/application";
|
||||||
import type { CombatantId, DomainEvent, Encounter } from "@initiative/domain";
|
import type {
|
||||||
|
BestiaryIndexEntry,
|
||||||
|
CombatantId,
|
||||||
|
ConditionId,
|
||||||
|
DomainEvent,
|
||||||
|
Encounter,
|
||||||
|
PlayerCharacter,
|
||||||
|
} from "@initiative/domain";
|
||||||
import {
|
import {
|
||||||
combatantId,
|
combatantId,
|
||||||
createEncounter,
|
|
||||||
isDomainError,
|
isDomainError,
|
||||||
|
creatureId as makeCreatureId,
|
||||||
|
resolveCreatureName,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
loadEncounter,
|
||||||
|
saveEncounter,
|
||||||
|
} from "../persistence/encounter-storage.js";
|
||||||
|
|
||||||
function createDemoEncounter(): Encounter {
|
const EMPTY_ENCOUNTER: Encounter = {
|
||||||
const result = createEncounter([
|
combatants: [],
|
||||||
{ id: combatantId("1"), name: "Aria" },
|
activeIndex: 0,
|
||||||
{ id: combatantId("2"), name: "Brak" },
|
roundNumber: 1,
|
||||||
{ id: combatantId("3"), name: "Cael" },
|
};
|
||||||
]);
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
function initializeEncounter(): Encounter {
|
||||||
throw new Error(`Failed to create demo encounter: ${result.message}`);
|
const stored = loadEncounter();
|
||||||
|
if (stored !== null) return stored;
|
||||||
|
return EMPTY_ENCOUNTER;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveNextId(encounter: Encounter): number {
|
||||||
|
let max = 0;
|
||||||
|
for (const c of encounter.combatants) {
|
||||||
|
const match = /^c-(\d+)$/.exec(c.id);
|
||||||
|
if (match) {
|
||||||
|
const n = Number.parseInt(match[1], 10);
|
||||||
|
if (n > max) max = n;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
interface CombatantOpts {
|
||||||
|
initiative?: number;
|
||||||
|
ac?: number;
|
||||||
|
maxHp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCombatantOpts(
|
||||||
|
makeStore: () => EncounterStore,
|
||||||
|
id: ReturnType<typeof combatantId>,
|
||||||
|
opts: CombatantOpts,
|
||||||
|
): DomainEvent[] {
|
||||||
|
const events: DomainEvent[] = [];
|
||||||
|
if (opts.maxHp !== undefined) {
|
||||||
|
const r = setHpUseCase(makeStore(), id, opts.maxHp);
|
||||||
|
if (!isDomainError(r)) events.push(...r);
|
||||||
|
}
|
||||||
|
if (opts.ac !== undefined) {
|
||||||
|
const r = setAcUseCase(makeStore(), id, opts.ac);
|
||||||
|
if (!isDomainError(r)) events.push(...r);
|
||||||
|
}
|
||||||
|
if (opts.initiative !== undefined) {
|
||||||
|
const r = setInitiativeUseCase(makeStore(), id, opts.initiative);
|
||||||
|
if (!isDomainError(r)) events.push(...r);
|
||||||
|
}
|
||||||
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useEncounter() {
|
export function useEncounter() {
|
||||||
const [encounter, setEncounter] = useState<Encounter>(createDemoEncounter);
|
const [encounter, setEncounter] = useState<Encounter>(initializeEncounter);
|
||||||
const [events, setEvents] = useState<DomainEvent[]>([]);
|
const [events, setEvents] = useState<DomainEvent[]>([]);
|
||||||
const encounterRef = useRef(encounter);
|
const encounterRef = useRef(encounter);
|
||||||
encounterRef.current = encounter;
|
encounterRef.current = encounter;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
saveEncounter(encounter);
|
||||||
|
}, [encounter]);
|
||||||
|
|
||||||
const makeStore = useCallback((): EncounterStore => {
|
const makeStore = useCallback((): EncounterStore => {
|
||||||
return {
|
return {
|
||||||
get: () => encounterRef.current,
|
get: () => encounterRef.current,
|
||||||
save: (e) => setEncounter(e),
|
save: (e) => {
|
||||||
|
encounterRef.current = e;
|
||||||
|
setEncounter(e);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -51,10 +114,20 @@ export function useEncounter() {
|
|||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
}, [makeStore]);
|
}, [makeStore]);
|
||||||
|
|
||||||
const nextId = useRef(0);
|
const retreatTurn = useCallback(() => {
|
||||||
|
const result = retreatTurnUseCase(makeStore());
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEvents((prev) => [...prev, ...result]);
|
||||||
|
}, [makeStore]);
|
||||||
|
|
||||||
|
const nextId = useRef(deriveNextId(encounter));
|
||||||
|
|
||||||
const addCombatant = useCallback(
|
const addCombatant = useCallback(
|
||||||
(name: string) => {
|
(name: string, opts?: CombatantOpts) => {
|
||||||
const id = combatantId(`c-${++nextId.current}`);
|
const id = combatantId(`c-${++nextId.current}`);
|
||||||
const result = addCombatantUseCase(makeStore(), id, name);
|
const result = addCombatantUseCase(makeStore(), id, name);
|
||||||
|
|
||||||
@@ -62,6 +135,13 @@ export function useEncounter() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (opts) {
|
||||||
|
const optEvents = applyCombatantOpts(makeStore, id, opts);
|
||||||
|
if (optEvents.length > 0) {
|
||||||
|
setEvents((prev) => [...prev, ...optEvents]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
},
|
},
|
||||||
[makeStore],
|
[makeStore],
|
||||||
@@ -106,13 +186,219 @@ export function useEncounter() {
|
|||||||
[makeStore],
|
[makeStore],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setHp = useCallback(
|
||||||
|
(id: CombatantId, maxHp: number | undefined) => {
|
||||||
|
const result = setHpUseCase(makeStore(), id, maxHp);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEvents((prev) => [...prev, ...result]);
|
||||||
|
},
|
||||||
|
[makeStore],
|
||||||
|
);
|
||||||
|
|
||||||
|
const adjustHp = useCallback(
|
||||||
|
(id: CombatantId, delta: number) => {
|
||||||
|
const result = adjustHpUseCase(makeStore(), id, delta);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEvents((prev) => [...prev, ...result]);
|
||||||
|
},
|
||||||
|
[makeStore],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setAc = useCallback(
|
||||||
|
(id: CombatantId, value: number | undefined) => {
|
||||||
|
const result = setAcUseCase(makeStore(), id, value);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEvents((prev) => [...prev, ...result]);
|
||||||
|
},
|
||||||
|
[makeStore],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleCondition = useCallback(
|
||||||
|
(id: CombatantId, conditionId: ConditionId) => {
|
||||||
|
const result = toggleConditionUseCase(makeStore(), id, conditionId);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEvents((prev) => [...prev, ...result]);
|
||||||
|
},
|
||||||
|
[makeStore],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleConcentration = useCallback(
|
||||||
|
(id: CombatantId) => {
|
||||||
|
const result = toggleConcentrationUseCase(makeStore(), id);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEvents((prev) => [...prev, ...result]);
|
||||||
|
},
|
||||||
|
[makeStore],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearEncounter = useCallback(() => {
|
||||||
|
const result = clearEncounterUseCase(makeStore());
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextId.current = 0;
|
||||||
|
setEvents((prev) => [...prev, ...result]);
|
||||||
|
}, [makeStore]);
|
||||||
|
|
||||||
|
const addFromBestiary = useCallback(
|
||||||
|
(entry: BestiaryIndexEntry) => {
|
||||||
|
const store = makeStore();
|
||||||
|
const existingNames = store.get().combatants.map((c) => c.name);
|
||||||
|
const { newName, renames } = resolveCreatureName(
|
||||||
|
entry.name,
|
||||||
|
existingNames,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply renames (e.g., "Goblin" → "Goblin 1")
|
||||||
|
for (const { from, to } of renames) {
|
||||||
|
const target = store.get().combatants.find((c) => c.name === from);
|
||||||
|
if (target) {
|
||||||
|
editCombatantUseCase(makeStore(), target.id, to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add combatant with resolved name
|
||||||
|
const id = combatantId(`c-${++nextId.current}`);
|
||||||
|
const addResult = addCombatantUseCase(makeStore(), id, newName);
|
||||||
|
if (isDomainError(addResult)) return;
|
||||||
|
|
||||||
|
// Set HP
|
||||||
|
const hpResult = setHpUseCase(makeStore(), id, entry.hp);
|
||||||
|
if (!isDomainError(hpResult)) {
|
||||||
|
setEvents((prev) => [...prev, ...hpResult]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set AC
|
||||||
|
if (entry.ac > 0) {
|
||||||
|
const acResult = setAcUseCase(makeStore(), id, entry.ac);
|
||||||
|
if (!isDomainError(acResult)) {
|
||||||
|
setEvents((prev) => [...prev, ...acResult]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive creatureId from source + name
|
||||||
|
const slug = entry.name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/(^-|-$)/g, "");
|
||||||
|
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
|
||||||
|
|
||||||
|
// Set creatureId on the combatant (use store.save to keep ref in sync for batch calls)
|
||||||
|
const currentEncounter = store.get();
|
||||||
|
store.save({
|
||||||
|
...currentEncounter,
|
||||||
|
combatants: currentEncounter.combatants.map((c) =>
|
||||||
|
c.id === id ? { ...c, creatureId: cId } : c,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
setEvents((prev) => [...prev, ...addResult]);
|
||||||
|
},
|
||||||
|
[makeStore, editCombatant],
|
||||||
|
);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
},
|
||||||
|
[makeStore, editCombatant],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isEmpty = encounter.combatants.length === 0;
|
||||||
|
const hasCreatureCombatants = encounter.combatants.some(
|
||||||
|
(c) => c.creatureId != null,
|
||||||
|
);
|
||||||
|
const canRollAllInitiative = encounter.combatants.some(
|
||||||
|
(c) => c.creatureId != null && c.initiative == null,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
encounter,
|
encounter,
|
||||||
events,
|
events,
|
||||||
|
isEmpty,
|
||||||
|
hasCreatureCombatants,
|
||||||
|
canRollAllInitiative,
|
||||||
advanceTurn,
|
advanceTurn,
|
||||||
|
retreatTurn,
|
||||||
addCombatant,
|
addCombatant,
|
||||||
|
clearEncounter,
|
||||||
removeCombatant,
|
removeCombatant,
|
||||||
editCombatant,
|
editCombatant,
|
||||||
setInitiative,
|
setInitiative,
|
||||||
|
setHp,
|
||||||
|
adjustHp,
|
||||||
|
setAc,
|
||||||
|
toggleCondition,
|
||||||
|
toggleConcentration,
|
||||||
|
addFromBestiary,
|
||||||
|
addFromPlayerCharacter,
|
||||||
|
makeStore,
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|||||||
108
apps/web/src/hooks/use-player-characters.ts
Normal file
108
apps/web/src/hooks/use-player-characters.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
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 | null;
|
||||||
|
readonly icon?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 | undefined,
|
||||||
|
icon: string | undefined,
|
||||||
|
) => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
101
apps/web/src/hooks/use-side-panel-state.ts
Normal file
101
apps/web/src/hooks/use-side-panel-state.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import type { CreatureId } from "@initiative/domain";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type PanelView =
|
||||||
|
| { mode: "closed" }
|
||||||
|
| { mode: "creature"; creatureId: CreatureId }
|
||||||
|
| { mode: "bulk-import" }
|
||||||
|
| { mode: "source-manager" };
|
||||||
|
|
||||||
|
interface SidePanelState {
|
||||||
|
panelView: PanelView;
|
||||||
|
selectedCreatureId: CreatureId | null;
|
||||||
|
bulkImportMode: boolean;
|
||||||
|
sourceManagerMode: boolean;
|
||||||
|
isRightPanelCollapsed: boolean;
|
||||||
|
pinnedCreatureId: CreatureId | null;
|
||||||
|
isWideDesktop: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidePanelActions {
|
||||||
|
showCreature: (creatureId: CreatureId) => void;
|
||||||
|
showBulkImport: () => void;
|
||||||
|
showSourceManager: () => void;
|
||||||
|
dismissPanel: () => void;
|
||||||
|
toggleCollapse: () => void;
|
||||||
|
togglePin: () => void;
|
||||||
|
unpin: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSidePanelState(): SidePanelState & SidePanelActions {
|
||||||
|
const [panelView, setPanelView] = useState<PanelView>({ mode: "closed" });
|
||||||
|
const [isRightPanelCollapsed, setIsRightPanelCollapsed] = useState(false);
|
||||||
|
const [pinnedCreatureId, setPinnedCreatureId] = useState<CreatureId | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [isWideDesktop, setIsWideDesktop] = useState(
|
||||||
|
() => window.matchMedia("(min-width: 1280px)").matches,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia("(min-width: 1280px)");
|
||||||
|
const handler = (e: MediaQueryListEvent) => setIsWideDesktop(e.matches);
|
||||||
|
mq.addEventListener("change", handler);
|
||||||
|
return () => mq.removeEventListener("change", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedCreatureId =
|
||||||
|
panelView.mode === "creature" ? panelView.creatureId : null;
|
||||||
|
|
||||||
|
const showCreature = useCallback((creatureId: CreatureId) => {
|
||||||
|
setPanelView({ mode: "creature", creatureId });
|
||||||
|
setIsRightPanelCollapsed(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showBulkImport = useCallback(() => {
|
||||||
|
setPanelView({ mode: "bulk-import" });
|
||||||
|
setIsRightPanelCollapsed(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showSourceManager = useCallback(() => {
|
||||||
|
setPanelView({ mode: "source-manager" });
|
||||||
|
setIsRightPanelCollapsed(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismissPanel = useCallback(() => {
|
||||||
|
setPanelView({ mode: "closed" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleCollapse = useCallback(() => {
|
||||||
|
setIsRightPanelCollapsed((f) => !f);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const togglePin = useCallback(() => {
|
||||||
|
if (selectedCreatureId) {
|
||||||
|
setPinnedCreatureId((prev) =>
|
||||||
|
prev === selectedCreatureId ? null : selectedCreatureId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [selectedCreatureId]);
|
||||||
|
|
||||||
|
const unpin = useCallback(() => {
|
||||||
|
setPinnedCreatureId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
panelView,
|
||||||
|
selectedCreatureId,
|
||||||
|
bulkImportMode: panelView.mode === "bulk-import",
|
||||||
|
sourceManagerMode: panelView.mode === "source-manager",
|
||||||
|
isRightPanelCollapsed,
|
||||||
|
pinnedCreatureId,
|
||||||
|
isWideDesktop,
|
||||||
|
showCreature,
|
||||||
|
showBulkImport,
|
||||||
|
showSourceManager,
|
||||||
|
dismissPanel,
|
||||||
|
toggleCollapse,
|
||||||
|
togglePin,
|
||||||
|
unpin,
|
||||||
|
};
|
||||||
|
}
|
||||||
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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
181
apps/web/src/index.css
Normal file
181
apps/web/src/index.css
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-background: #0f172a;
|
||||||
|
--color-foreground: #e2e8f0;
|
||||||
|
--color-muted: #64748b;
|
||||||
|
--color-muted-foreground: #94a3b8;
|
||||||
|
--color-card: #1e293b;
|
||||||
|
--color-card-foreground: #e2e8f0;
|
||||||
|
--color-border: #334155;
|
||||||
|
--color-input: #334155;
|
||||||
|
--color-primary: #3b82f6;
|
||||||
|
--color-primary-foreground: #ffffff;
|
||||||
|
--color-accent: #3b82f6;
|
||||||
|
--color-destructive: #ef4444;
|
||||||
|
--color-hover-neutral: var(--color-primary);
|
||||||
|
--color-hover-action: var(--color-primary);
|
||||||
|
--color-hover-destructive: var(--color-destructive);
|
||||||
|
--color-hover-neutral-bg: oklch(0.623 0.214 259 / 0.15);
|
||||||
|
--color-hover-action-bg: var(--color-muted);
|
||||||
|
--color-hover-destructive-bg: transparent;
|
||||||
|
--radius-sm: 0.25rem;
|
||||||
|
--radius-md: 0.375rem;
|
||||||
|
--radius-lg: 0.5rem;
|
||||||
|
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes concentration-shake {
|
||||||
|
0% {
|
||||||
|
translate: 0;
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
translate: -3px;
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
translate: 3px;
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
translate: -2px;
|
||||||
|
}
|
||||||
|
80% {
|
||||||
|
translate: 1px;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
translate: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes concentration-glow {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 4px 2px #c084fc;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in-right {
|
||||||
|
from {
|
||||||
|
translate: 100%;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
translate: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility animate-slide-in-right {
|
||||||
|
animation: slide-in-right 200ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes confirm-pulse {
|
||||||
|
0% {
|
||||||
|
scale: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
scale: 1.15;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
scale: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes settle-to-bottom {
|
||||||
|
from {
|
||||||
|
transform: translateY(-40vh);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility animate-settle-to-bottom {
|
||||||
|
animation: settle-to-bottom 700ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rise-to-center {
|
||||||
|
from {
|
||||||
|
transform: translateY(40vh);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility animate-rise-to-center {
|
||||||
|
animation: rise-to-center 700ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-down-in {
|
||||||
|
from {
|
||||||
|
transform: translateY(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility animate-slide-down-in {
|
||||||
|
animation: slide-down-in 700ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-up-out {
|
||||||
|
from {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility animate-slide-up-out {
|
||||||
|
animation: slide-up-out 700ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@custom-variant pointer-coarse (@media (pointer: coarse));
|
||||||
|
|
||||||
|
@utility animate-confirm-pulse {
|
||||||
|
animation: confirm-pulse 300ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility transition-slide-panel {
|
||||||
|
transition: translate 200ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility writing-vertical-rl {
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility animate-concentration-pulse {
|
||||||
|
animation:
|
||||||
|
concentration-shake 450ms ease-out,
|
||||||
|
concentration-glow 1200ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
scrollbar-color: var(--color-border) transparent;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--color-background);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
6
apps/web/src/lib/utils.ts
Normal file
6
apps/web/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
if (root) {
|
if (root) {
|
||||||
|
|||||||
296
apps/web/src/persistence/__tests__/encounter-storage.test.ts
Normal file
296
apps/web/src/persistence/__tests__/encounter-storage.test.ts
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import {
|
||||||
|
combatantId,
|
||||||
|
createEncounter,
|
||||||
|
isDomainError,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { loadEncounter, saveEncounter } from "../encounter-storage.js";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "initiative:encounter";
|
||||||
|
|
||||||
|
function makeEncounter() {
|
||||||
|
const result = createEncounter(
|
||||||
|
[
|
||||||
|
{ id: combatantId("1"), name: "Aria", initiative: 18 },
|
||||||
|
{ id: combatantId("c-2"), name: "Brak", initiative: 12 },
|
||||||
|
{ id: combatantId("3"), name: "Cael" },
|
||||||
|
],
|
||||||
|
1,
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error("Failed to create test encounter");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockLocalStorage() {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
getItem: (key: string) => store.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => store.set(key, value),
|
||||||
|
removeItem: (key: string) => store.delete(key),
|
||||||
|
clear: () => store.clear(),
|
||||||
|
get length() {
|
||||||
|
return store.size;
|
||||||
|
},
|
||||||
|
key: (_index: number) => null,
|
||||||
|
} as Storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(globalThis, "localStorage", {
|
||||||
|
value: createMockLocalStorage(),
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("saveEncounter", () => {
|
||||||
|
it("writes encounter to localStorage", () => {
|
||||||
|
const encounter = makeEncounter();
|
||||||
|
saveEncounter(encounter);
|
||||||
|
expect(localStorage.getItem(STORAGE_KEY)).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadEncounter", () => {
|
||||||
|
it("returns null when localStorage is empty", () => {
|
||||||
|
expect(loadEncounter()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trip save/load preserves encounter state", () => {
|
||||||
|
const encounter = makeEncounter();
|
||||||
|
saveEncounter(encounter);
|
||||||
|
const loaded = loadEncounter();
|
||||||
|
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
expect(loaded?.combatants).toHaveLength(3);
|
||||||
|
expect(loaded?.activeIndex).toBe(1);
|
||||||
|
expect(loaded?.roundNumber).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trip preserves combatant IDs, names, and initiative values", () => {
|
||||||
|
const encounter = makeEncounter();
|
||||||
|
saveEncounter(encounter);
|
||||||
|
const loaded = loadEncounter();
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
|
||||||
|
expect(loaded?.combatants[0].id).toBe("1");
|
||||||
|
expect(loaded?.combatants[0].name).toBe("Aria");
|
||||||
|
expect(loaded?.combatants[0].initiative).toBe(18);
|
||||||
|
|
||||||
|
expect(loaded?.combatants[1].id).toBe("c-2");
|
||||||
|
expect(loaded?.combatants[1].name).toBe("Brak");
|
||||||
|
expect(loaded?.combatants[1].initiative).toBe(12);
|
||||||
|
|
||||||
|
expect(loaded?.combatants[2].id).toBe("3");
|
||||||
|
expect(loaded?.combatants[2].name).toBe("Cael");
|
||||||
|
expect(loaded?.combatants[2].initiative).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for non-JSON strings", () => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, "not json at all");
|
||||||
|
expect(loadEncounter()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for JSON missing required fields", () => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({ foo: "bar" }));
|
||||||
|
expect(loadEncounter()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty encounter for cleared state (empty combatants)", () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify({ combatants: [], activeIndex: 0, roundNumber: 1 }),
|
||||||
|
);
|
||||||
|
const result = loadEncounter();
|
||||||
|
expect(result).toEqual({
|
||||||
|
combatants: [],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for out-of-bounds activeIndex", () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
combatants: [{ id: "1", name: "Aria" }],
|
||||||
|
activeIndex: 5,
|
||||||
|
roundNumber: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(loadEncounter()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// US3: Corrupt data scenarios
|
||||||
|
it("returns null for non-object JSON (string)", () => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify("hello"));
|
||||||
|
expect(loadEncounter()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for non-object JSON (number)", () => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(42));
|
||||||
|
expect(loadEncounter()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for non-object JSON (array)", () => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify([1, 2, 3]));
|
||||||
|
expect(loadEncounter()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for non-object JSON (null)", () => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, "null");
|
||||||
|
expect(loadEncounter()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when combatants is a string instead of array", () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
combatants: "not-array",
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(loadEncounter()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when activeIndex is a string instead of number", () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
combatants: [{ id: "1", name: "Aria" }],
|
||||||
|
activeIndex: "zero",
|
||||||
|
roundNumber: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(loadEncounter()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when combatant entry is missing id", () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
combatants: [{ name: "Aria" }],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(loadEncounter()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when combatant entry is missing name", () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
combatants: [{ id: "1" }],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(loadEncounter()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for negative roundNumber", () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
combatants: [{ id: "1", name: "Aria" }],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: -1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(loadEncounter()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty encounter for zero combatants (cleared state)", () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify({ combatants: [], activeIndex: 0, roundNumber: 1 }),
|
||||||
|
);
|
||||||
|
const result = loadEncounter();
|
||||||
|
expect(result).toEqual({
|
||||||
|
combatants: [],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trip preserves combatant AC value", () => {
|
||||||
|
const result = createEncounter(
|
||||||
|
[{ id: combatantId("1"), name: "Aria", ac: 18 }],
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error("unreachable");
|
||||||
|
saveEncounter(result);
|
||||||
|
const loaded = loadEncounter();
|
||||||
|
expect(loaded?.combatants[0].ac).toBe(18);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trip preserves combatant without AC", () => {
|
||||||
|
const result = createEncounter(
|
||||||
|
[{ id: combatantId("1"), name: "Aria" }],
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error("unreachable");
|
||||||
|
saveEncounter(result);
|
||||||
|
const loaded = loadEncounter();
|
||||||
|
expect(loaded?.combatants[0].ac).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discards invalid AC values during rehydration", () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
combatants: [
|
||||||
|
{ id: "1", name: "Neg", ac: -1 },
|
||||||
|
{ id: "2", name: "Float", ac: 3.5 },
|
||||||
|
{ id: "3", name: "Str", ac: "high" },
|
||||||
|
],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const loaded = loadEncounter();
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
expect(loaded?.combatants[0].ac).toBeUndefined();
|
||||||
|
expect(loaded?.combatants[1].ac).toBeUndefined();
|
||||||
|
expect(loaded?.combatants[2].ac).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves AC of 0 during rehydration", () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
combatants: [{ id: "1", name: "Aria", ac: 0 }],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const loaded = loadEncounter();
|
||||||
|
expect(loaded?.combatants[0].ac).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saving after modifications persists the latest state", () => {
|
||||||
|
const encounter = makeEncounter();
|
||||||
|
saveEncounter(encounter);
|
||||||
|
|
||||||
|
const modified = createEncounter(
|
||||||
|
[
|
||||||
|
{ id: combatantId("1"), name: "Aria", initiative: 18 },
|
||||||
|
{ id: combatantId("c-2"), name: "Brak", initiative: 12 },
|
||||||
|
],
|
||||||
|
0,
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
if (isDomainError(modified)) throw new Error("unreachable");
|
||||||
|
|
||||||
|
saveEncounter(modified);
|
||||||
|
const loaded = loadEncounter();
|
||||||
|
|
||||||
|
expect(loaded?.combatants).toHaveLength(2);
|
||||||
|
expect(loaded?.roundNumber).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
153
apps/web/src/persistence/encounter-storage.ts
Normal file
153
apps/web/src/persistence/encounter-storage.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import {
|
||||||
|
type ConditionId,
|
||||||
|
combatantId,
|
||||||
|
createEncounter,
|
||||||
|
creatureId,
|
||||||
|
type Encounter,
|
||||||
|
isDomainError,
|
||||||
|
playerCharacterId,
|
||||||
|
VALID_CONDITION_IDS,
|
||||||
|
VALID_PLAYER_COLORS,
|
||||||
|
VALID_PLAYER_ICONS,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "initiative:encounter";
|
||||||
|
|
||||||
|
export function saveEncounter(encounter: Encounter): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(encounter));
|
||||||
|
} catch {
|
||||||
|
// Silently swallow errors (quota exceeded, storage unavailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAc(value: unknown): number | undefined {
|
||||||
|
return typeof value === "number" && Number.isInteger(value) && value >= 0
|
||||||
|
? value
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateConditions(value: unknown): ConditionId[] | undefined {
|
||||||
|
if (!Array.isArray(value)) return undefined;
|
||||||
|
const valid = value.filter(
|
||||||
|
(v): v is ConditionId =>
|
||||||
|
typeof v === "string" && VALID_CONDITION_IDS.has(v),
|
||||||
|
);
|
||||||
|
return valid.length > 0 ? valid : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCreatureId(value: unknown) {
|
||||||
|
return typeof value === "string" && value.length > 0
|
||||||
|
? creatureId(value)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateHp(
|
||||||
|
rawMaxHp: unknown,
|
||||||
|
rawCurrentHp: unknown,
|
||||||
|
): { maxHp: number; currentHp: number } | undefined {
|
||||||
|
if (
|
||||||
|
typeof rawMaxHp !== "number" ||
|
||||||
|
!Number.isInteger(rawMaxHp) ||
|
||||||
|
rawMaxHp < 1
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const validCurrentHp =
|
||||||
|
typeof rawCurrentHp === "number" &&
|
||||||
|
Number.isInteger(rawCurrentHp) &&
|
||||||
|
rawCurrentHp >= 0 &&
|
||||||
|
rawCurrentHp <= rawMaxHp;
|
||||||
|
return {
|
||||||
|
maxHp: rawMaxHp,
|
||||||
|
currentHp: validCurrentHp ? rawCurrentHp : rawMaxHp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rehydrateCombatant(c: unknown) {
|
||||||
|
const entry = c as Record<string, unknown>;
|
||||||
|
const base = {
|
||||||
|
id: combatantId(entry.id as string),
|
||||||
|
name: entry.name as string,
|
||||||
|
initiative:
|
||||||
|
typeof entry.initiative === "number" ? entry.initiative : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const color =
|
||||||
|
typeof entry.color === "string" && VALID_PLAYER_COLORS.has(entry.color)
|
||||||
|
? entry.color
|
||||||
|
: undefined;
|
||||||
|
const icon =
|
||||||
|
typeof entry.icon === "string" && VALID_PLAYER_ICONS.has(entry.icon)
|
||||||
|
? entry.icon
|
||||||
|
: undefined;
|
||||||
|
const pcId =
|
||||||
|
typeof entry.playerCharacterId === "string" &&
|
||||||
|
entry.playerCharacterId.length > 0
|
||||||
|
? playerCharacterId(entry.playerCharacterId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const shared = {
|
||||||
|
...base,
|
||||||
|
ac: validateAc(entry.ac),
|
||||||
|
conditions: validateConditions(entry.conditions),
|
||||||
|
isConcentrating: entry.isConcentrating === true ? true : undefined,
|
||||||
|
creatureId: validateCreatureId(entry.creatureId),
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
playerCharacterId: pcId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const hp = validateHp(entry.maxHp, entry.currentHp);
|
||||||
|
return hp ? { ...shared, ...hp } : shared;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidCombatantEntry(c: unknown): boolean {
|
||||||
|
if (typeof c !== "object" || c === null || Array.isArray(c)) return false;
|
||||||
|
const entry = c as Record<string, unknown>;
|
||||||
|
return typeof entry.id === "string" && typeof entry.name === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadEncounter(): Encounter | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw === null) return null;
|
||||||
|
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
|
||||||
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const obj = parsed as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (!Array.isArray(obj.combatants)) return null;
|
||||||
|
if (typeof obj.activeIndex !== "number") return null;
|
||||||
|
if (typeof obj.roundNumber !== "number") return null;
|
||||||
|
|
||||||
|
const combatants = obj.combatants as unknown[];
|
||||||
|
|
||||||
|
// Handle empty encounter (cleared state) directly — createEncounter rejects empty arrays
|
||||||
|
if (combatants.length === 0) {
|
||||||
|
return {
|
||||||
|
combatants: [],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!combatants.every(isValidCombatantEntry)) return null;
|
||||||
|
|
||||||
|
const rehydrated = combatants.map(rehydrateCombatant);
|
||||||
|
|
||||||
|
const result = createEncounter(
|
||||||
|
rehydrated,
|
||||||
|
obj.activeIndex,
|
||||||
|
obj.roundNumber,
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) return null;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
77
apps/web/src/persistence/player-character-storage.ts
Normal file
77
apps/web/src/persistence/player-character-storage.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
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 isValidOptionalMember(
|
||||||
|
value: unknown,
|
||||||
|
valid: ReadonlySet<string>,
|
||||||
|
): boolean {
|
||||||
|
return value === undefined || (typeof value === "string" && valid.has(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
||||||
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
||||||
|
return null;
|
||||||
|
const entry = raw as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (typeof entry.id !== "string" || entry.id.length === 0) return null;
|
||||||
|
if (typeof entry.name !== "string" || entry.name.trim().length === 0)
|
||||||
|
return null;
|
||||||
|
if (
|
||||||
|
typeof entry.ac !== "number" ||
|
||||||
|
!Number.isInteger(entry.ac) ||
|
||||||
|
entry.ac < 0
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
if (
|
||||||
|
typeof entry.maxHp !== "number" ||
|
||||||
|
!Number.isInteger(entry.maxHp) ||
|
||||||
|
entry.maxHp < 1
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
if (!isValidOptionalMember(entry.color, VALID_PLAYER_COLORS)) return null;
|
||||||
|
if (!isValidOptionalMember(entry.icon, VALID_PLAYER_ICONS)) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: playerCharacterId(entry.id),
|
||||||
|
name: entry.name,
|
||||||
|
ac: entry.ac,
|
||||||
|
maxHp: entry.maxHp,
|
||||||
|
color: entry.color as PlayerCharacter["color"],
|
||||||
|
icon: entry.icon as PlayerCharacter["icon"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPlayerCharacters(): PlayerCharacter[] {
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [tailwindcss(), react()],
|
||||||
});
|
});
|
||||||
|
|||||||
14
biome.json
14
biome.json
@@ -6,7 +6,9 @@
|
|||||||
"!**/dist/**",
|
"!**/dist/**",
|
||||||
"!.claude/**",
|
"!.claude/**",
|
||||||
"!.specify/**",
|
"!.specify/**",
|
||||||
"!specs/**"
|
"!specs/**",
|
||||||
|
"!coverage/**",
|
||||||
|
"!.pnpm-store/**"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"assist": {
|
"assist": {
|
||||||
@@ -27,7 +29,15 @@
|
|||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true
|
"recommended": true,
|
||||||
|
"complexity": {
|
||||||
|
"noExcessiveCognitiveComplexity": {
|
||||||
|
"level": "error",
|
||||||
|
"options": {
|
||||||
|
"maxAllowedComplexity": 15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
d20.svg
Normal file
19
d20.svg
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
height="24"
|
||||||
|
width="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<polygon points="20.22 16.76 20.22 7.26 12.04 2.51 3.85 7.26 3.85 16.76 12.04 21.51 20.22 16.76"/>
|
||||||
|
<line x1="7.29" y1="9.26" x2="3.85" y2="7.26"/>
|
||||||
|
<line x1="20.22" y1="7.26" x2="16.79" y2="9.26"/>
|
||||||
|
<line x1="12.04" y1="17.44" x2="12.04" y2="21.51"/>
|
||||||
|
<polygon points="12.04 17.44 20.22 16.76 16.79 9.26 12.04 17.44"/>
|
||||||
|
<polygon points="12.04 17.44 7.29 9.26 3.85 16.76 12.04 17.44"/>
|
||||||
|
<polygon points="12.04 2.51 7.29 9.26 16.79 9.26 12.04 2.51"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 667 B |
36540
data/bestiary/index.json
Normal file
36540
data/bestiary/index.json
Normal file
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
176
docs/agents/research/2026-03-13-action-bars-and-buttons.md
Normal file
176
docs/agents/research/2026-03-13-action-bars-and-buttons.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
---
|
||||||
|
date: "2026-03-13T14:39:15.661886+00:00"
|
||||||
|
git_commit: 75778884bd1be7d135b2f5ea9b8a8e77a0149f7b
|
||||||
|
branch: main
|
||||||
|
topic: "Action Bars Setup — Top Bar and Bottom Bar Buttons"
|
||||||
|
tags: [research, codebase, action-bar, turn-navigation, layout, buttons]
|
||||||
|
status: complete
|
||||||
|
---
|
||||||
|
|
||||||
|
# Research: Action Bars Setup — Top Bar and Bottom Bar Buttons
|
||||||
|
|
||||||
|
## Research Question
|
||||||
|
|
||||||
|
How are the top and bottom action bars set up, what buttons do they contain, and how are their actions wired?
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The application has two primary bar components that frame the encounter tracker UI:
|
||||||
|
|
||||||
|
1. **Top bar** — `TurnNavigation` (`turn-navigation.tsx`) — turn controls, round/combatant display, and encounter-wide actions.
|
||||||
|
2. **Bottom bar** — `ActionBar` (`action-bar.tsx`) — combatant input, bestiary search, stat block browsing, bulk import, and player character management.
|
||||||
|
|
||||||
|
Both bars share the same visual container styling (`flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3`). They are laid out in `App.tsx` within a flex column, with a scrollable combatant list between them. When the encounter is empty, only the ActionBar is shown (centered in the viewport); the TurnNavigation appears with an animation when the first combatant is added.
|
||||||
|
|
||||||
|
## Detailed Findings
|
||||||
|
|
||||||
|
### Layout Structure (`App.tsx:243-344`)
|
||||||
|
|
||||||
|
The bars live inside a `max-w-2xl` centered column:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ TurnNavigation (pt-8, shrink-0) │ ← top bar, conditionally shown
|
||||||
|
├──────────────────────────────────┤
|
||||||
|
│ SourceManager (optional inline) │ ← toggled by Library button in top bar
|
||||||
|
├──────────────────────────────────┤
|
||||||
|
│ Combatant list (flex-1, │ ← scrollable
|
||||||
|
│ overflow-y-auto) │
|
||||||
|
├──────────────────────────────────┤
|
||||||
|
│ ActionBar (pb-8, shrink-0) │ ← bottom bar
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Empty state**: When `encounter.combatants.length === 0`, the top bar is hidden and the ActionBar is vertically centered in a `flex items-center justify-center` wrapper with `pb-[15%]` offset. It receives `autoFocus` in this state.
|
||||||
|
|
||||||
|
**Animation** (`useActionBarAnimation`, `App.tsx:30-66`): Manages transitions between empty and populated states:
|
||||||
|
- Empty → populated: ActionBar plays `animate-settle-to-bottom`, TurnNavigation plays `animate-slide-down-in`.
|
||||||
|
- Populated → empty: ActionBar plays `animate-rise-to-center`, TurnNavigation plays `animate-slide-up-out` (with `absolute` positioning during exit).
|
||||||
|
|
||||||
|
The `showTopBar` flag is `true` when either combatants exist or the top bar exit animation is still running.
|
||||||
|
|
||||||
|
### Top Bar — TurnNavigation (`turn-navigation.tsx`)
|
||||||
|
|
||||||
|
**Props interface** (`turn-navigation.tsx:7-14`):
|
||||||
|
- `encounter: Encounter` — full encounter state
|
||||||
|
- `onAdvanceTurn`, `onRetreatTurn` — turn navigation callbacks
|
||||||
|
- `onClearEncounter` — destructive clear with confirmation
|
||||||
|
- `onRollAllInitiative` — rolls initiative for all combatants
|
||||||
|
- `onOpenSourceManager` — toggles source manager panel
|
||||||
|
|
||||||
|
**Layout**: Left–Center–Right structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ◀ Prev ] | [ R1 Active Combatant Name ] | [ 🎲 📚 🗑 ] [ Next ▶ ]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Buttons (left to right)**:
|
||||||
|
|
||||||
|
| # | Icon | Component | Variant | Action | Disabled when |
|
||||||
|
|---|------|-----------|---------|--------|---------------|
|
||||||
|
| 1 | `StepBack` | `Button` | default | `onRetreatTurn` | No combatants OR at round 1 index 0 |
|
||||||
|
| 2 | `D20Icon` | `Button` | ghost | `onRollAllInitiative` | Never |
|
||||||
|
| 3 | `Library` | `Button` | ghost | `onOpenSourceManager` | Never |
|
||||||
|
| 4 | `Trash2` | `ConfirmButton` | — | `onClearEncounter` | No combatants |
|
||||||
|
| 5 | `StepForward` | `Button` | default | `onAdvanceTurn` | No combatants |
|
||||||
|
|
||||||
|
**Center section** (`turn-navigation.tsx:40-49`): Displays a round badge (`R{n}` in a `rounded-full bg-muted` span) and the active combatant's name (truncated). Falls back to "No combatants" in muted text.
|
||||||
|
|
||||||
|
**Button grouping**: Buttons 2-4 are grouped in a `gap-0` div (tight spacing), while button 5 (Next) is separated by the outer `gap-3`.
|
||||||
|
|
||||||
|
**Wiring in App.tsx** (`App.tsx:251-258`):
|
||||||
|
- `onAdvanceTurn` → `advanceTurn` from `useEncounter()`
|
||||||
|
- `onRetreatTurn` → `retreatTurn` from `useEncounter()`
|
||||||
|
- `onClearEncounter` → `clearEncounter` from `useEncounter()`
|
||||||
|
- `onRollAllInitiative` → `handleRollAllInitiative` → calls `rollAllInitiativeUseCase(makeStore(), rollDice, getCreature)`
|
||||||
|
- `onOpenSourceManager` → toggles `sourceManagerOpen` state
|
||||||
|
|
||||||
|
### Bottom Bar — ActionBar (`action-bar.tsx`)
|
||||||
|
|
||||||
|
**Props interface** (`action-bar.tsx:20-36`):
|
||||||
|
- `onAddCombatant` — adds custom combatant with optional init/AC/maxHP
|
||||||
|
- `onAddFromBestiary` — adds creature from search result
|
||||||
|
- `bestiarySearch` — search function returning `SearchResult[]`
|
||||||
|
- `bestiaryLoaded` — whether bestiary index is loaded
|
||||||
|
- `onViewStatBlock` — opens stat block panel for a creature
|
||||||
|
- `onBulkImport` — triggers bulk source import mode
|
||||||
|
- `bulkImportDisabled` — disables import button during loading
|
||||||
|
- `inputRef` — external ref to the name input
|
||||||
|
- `playerCharacters` — list of player characters for quick-add
|
||||||
|
- `onAddFromPlayerCharacter` — adds a player character to encounter
|
||||||
|
- `onManagePlayers` — opens player management modal
|
||||||
|
- `autoFocus` — auto-focuses input (used in empty state)
|
||||||
|
|
||||||
|
**Layout**: Form with input, contextual fields, submit button, and action icons:
|
||||||
|
|
||||||
|
```
|
||||||
|
[ + Add combatants... ] [ Init ] [ AC ] [ MaxHP ] [ Add ] [ 👥 👁 📥 ]
|
||||||
|
```
|
||||||
|
|
||||||
|
The Init/AC/MaxHP fields only appear when the input has 2+ characters and no bestiary suggestions are showing.
|
||||||
|
|
||||||
|
**Buttons (left to right)**:
|
||||||
|
|
||||||
|
| # | Icon | Component | Variant | Action | Condition |
|
||||||
|
|---|------|-----------|---------|--------|-----------|
|
||||||
|
| 1 | — | `Button` | sm | Form submit → `handleAdd` | Always shown |
|
||||||
|
| 2 | `Users` | `Button` | ghost | `onManagePlayers` | Only if `onManagePlayers` provided |
|
||||||
|
| 3 | `Eye` | `Button` | ghost | Toggle stat block viewer dropdown | Only if `bestiaryLoaded && onViewStatBlock` |
|
||||||
|
| 4 | `Import` | `Button` | ghost | `onBulkImport` | Only if `bestiaryLoaded && onBulkImport` |
|
||||||
|
|
||||||
|
**Button grouping**: Buttons 2-4 are grouped in a `gap-0` div, mirroring the top bar's icon button grouping.
|
||||||
|
|
||||||
|
**Suggestion dropdown** (`action-bar.tsx:267-410`): Opens above the input when 2+ chars are typed and results exist. Contains:
|
||||||
|
- A "Add as custom" escape row at the top (with `Esc` keyboard hint)
|
||||||
|
- **Players section**: Lists matching player characters with colored icons; clicking adds them directly via `onAddFromPlayerCharacter`
|
||||||
|
- **Bestiary section**: Lists search results; clicking queues a creature. Queued creatures show:
|
||||||
|
- `Minus` button — decrements count (removes queue at 0)
|
||||||
|
- Count badge — current queued count
|
||||||
|
- `Plus` button — increments count
|
||||||
|
- `Check` button — confirms and adds all queued copies
|
||||||
|
|
||||||
|
**Stat block viewer dropdown** (`action-bar.tsx:470-513`): A separate search dropdown anchored to the Eye button. Has its own input, search results, and keyboard navigation. Selecting a result calls `onViewStatBlock`.
|
||||||
|
|
||||||
|
**Keyboard handling** (`action-bar.tsx:168-186`):
|
||||||
|
- Arrow Up/Down — navigate suggestion list
|
||||||
|
- Enter — queue selected suggestion or confirm queued batch
|
||||||
|
- Escape — clear suggestions and queue
|
||||||
|
|
||||||
|
**Wiring in App.tsx** (`App.tsx:269-282` and `328-340`):
|
||||||
|
- `onAddCombatant` → `addCombatant` from `useEncounter()`
|
||||||
|
- `onAddFromBestiary` → `handleAddFromBestiary` → `addFromBestiary` from `useEncounter()`
|
||||||
|
- `bestiarySearch` → `search` from `useBestiary()`
|
||||||
|
- `onViewStatBlock` → `handleViewStatBlock` → constructs `CreatureId` and sets `selectedCreatureId`
|
||||||
|
- `onBulkImport` → `handleBulkImport` → sets `bulkImportMode` and clears selection
|
||||||
|
- `onAddFromPlayerCharacter` → `addFromPlayerCharacter` from `useEncounter()`
|
||||||
|
- `onManagePlayers` → opens `managementOpen` state (shows `PlayerManagement` modal)
|
||||||
|
|
||||||
|
### Shared UI Primitives
|
||||||
|
|
||||||
|
**`Button`** (`ui/button.tsx`): CVA-based component with variants (`default`, `outline`, `ghost`) and sizes (`default`, `sm`, `icon`). Both bars use `size="icon"` with `variant="ghost"` for their icon button clusters, and `size="icon"` with default variant for the primary navigation buttons (Prev/Next in top bar).
|
||||||
|
|
||||||
|
**`ConfirmButton`** (`ui/confirm-button.tsx`): Two-click destructive action button. First click shows a red pulsing confirmation state with a Check icon; second click fires `onConfirm`. Auto-reverts after 5 seconds. Supports Escape and click-outside cancellation. Used for Clear Encounter in the top bar.
|
||||||
|
|
||||||
|
### Hover Color Convention
|
||||||
|
|
||||||
|
Both bars use consistent hover color classes on their ghost icon buttons:
|
||||||
|
- `hover:text-hover-action` — used on the D20 (roll initiative) button, suggesting an action/accent color
|
||||||
|
- `hover:text-hover-neutral` — used on Library, Users, Eye, Import buttons, suggesting a neutral/informational color
|
||||||
|
|
||||||
|
## Code References
|
||||||
|
|
||||||
|
- `apps/web/src/components/turn-navigation.tsx` — Top bar component (93 lines)
|
||||||
|
- `apps/web/src/components/action-bar.tsx` — Bottom bar component (533 lines)
|
||||||
|
- `apps/web/src/App.tsx:30-66` — `useActionBarAnimation` hook for bar transitions
|
||||||
|
- `apps/web/src/App.tsx:243-344` — Layout structure with both bars
|
||||||
|
- `apps/web/src/components/ui/button.tsx` — Shared Button component
|
||||||
|
- `apps/web/src/components/ui/confirm-button.tsx` — Two-step confirmation button
|
||||||
|
- `apps/web/src/components/d20-icon.tsx` — Custom D20 dice SVG icon
|
||||||
|
|
||||||
|
## Architecture Documentation
|
||||||
|
|
||||||
|
The bars follow the app's adapter-layer convention: they are pure presentational React components that receive all behavior via callback props. No business logic lives in either bar — they delegate to handlers defined in `App.tsx`, which in turn call use-case functions from the application layer or manipulate local UI state.
|
||||||
|
|
||||||
|
Both bars are rendered twice in `App.tsx` (once in the empty-state branch, once in the populated branch) rather than being conditionally repositioned, which simplifies the animation logic.
|
||||||
|
|
||||||
|
The `ActionBar` is the more complex of the two, managing multiple pieces of local state (input value, suggestions, queued creatures, custom fields, stat block viewer) while `TurnNavigation` is fully stateless — all its data comes from the `encounter` prop.
|
||||||
188
docs/agents/research/2026-03-13-css-classes-buttons-hover.md
Normal file
188
docs/agents/research/2026-03-13-css-classes-buttons-hover.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
---
|
||||||
|
date: "2026-03-13T15:35:07.699570+00:00"
|
||||||
|
git_commit: bd398080008349b47726d0016f4b03587f453833
|
||||||
|
branch: main
|
||||||
|
topic: "CSS class usage, button categorization, and hover effects across all components"
|
||||||
|
tags: [research, codebase, css, tailwind, buttons, hover, ui]
|
||||||
|
status: complete
|
||||||
|
---
|
||||||
|
|
||||||
|
# Research: CSS Class Usage, Button Categorization, and Hover Effects
|
||||||
|
|
||||||
|
## Research Question
|
||||||
|
How are CSS classes used across all components? How are buttons categorized — are there primary and secondary buttons? What hover effects exist, and are they unified?
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The project uses **Tailwind CSS v4** with a custom dark theme defined in `index.css` via `@theme`. All class merging goes through a `cn()` utility (clsx + tailwind-merge). Buttons are built on a shared `Button` component using **class-variance-authority (CVA)** with three variants: **default** (primary), **outline**, and **ghost**. Hover effects are partially unified through semantic color tokens (`hover-neutral`, `hover-action`, `hover-destructive`) defined in the theme, but several components use **one-off hardcoded hover colors** that bypass the token system.
|
||||||
|
|
||||||
|
## Detailed Findings
|
||||||
|
|
||||||
|
### Theme System (`index.css`)
|
||||||
|
|
||||||
|
All colors are defined as CSS custom properties via Tailwind v4's `@theme` directive (`index.css:3-26`):
|
||||||
|
|
||||||
|
| Token | Value | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `--color-background` | `#0f172a` | Page background |
|
||||||
|
| `--color-foreground` | `#e2e8f0` | Default text |
|
||||||
|
| `--color-muted` | `#64748b` | Subdued elements |
|
||||||
|
| `--color-muted-foreground` | `#94a3b8` | Secondary text |
|
||||||
|
| `--color-card` | `#1e293b` | Card/panel surfaces |
|
||||||
|
| `--color-border` | `#334155` | Borders |
|
||||||
|
| `--color-primary` | `#3b82f6` | Primary actions (blue) |
|
||||||
|
| `--color-accent` | `#3b82f6` | Accent (same as primary) |
|
||||||
|
| `--color-destructive` | `#ef4444` | Destructive actions (red) |
|
||||||
|
|
||||||
|
**Hover tokens** (semantic layer for hover states):
|
||||||
|
|
||||||
|
| Token | Resolves to | Usage |
|
||||||
|
|---|---|---|
|
||||||
|
| `hover-neutral` | `primary` (blue) | Text color on neutral hover |
|
||||||
|
| `hover-action` | `primary` (blue) | Text color on action hover |
|
||||||
|
| `hover-destructive` | `destructive` (red) | Text color on destructive hover |
|
||||||
|
| `hover-neutral-bg` | `card` (slate) | Background on neutral hover |
|
||||||
|
| `hover-action-bg` | `muted` | Background on action hover |
|
||||||
|
| `hover-destructive-bg` | `transparent` | Background on destructive hover |
|
||||||
|
|
||||||
|
### Button Component (`components/ui/button.tsx`)
|
||||||
|
|
||||||
|
Uses CVA with three variants and three sizes:
|
||||||
|
|
||||||
|
**Variants:**
|
||||||
|
|
||||||
|
| Variant | Base styles | Hover |
|
||||||
|
|---|---|---|
|
||||||
|
| `default` (primary) | `bg-primary text-primary-foreground` | `hover:bg-primary/90` |
|
||||||
|
| `outline` | `border border-border bg-transparent` | `hover:bg-hover-neutral-bg hover:text-hover-neutral` |
|
||||||
|
| `ghost` | (no background/border) | `hover:bg-hover-neutral-bg hover:text-hover-neutral` |
|
||||||
|
|
||||||
|
**Sizes:**
|
||||||
|
|
||||||
|
| Size | Classes |
|
||||||
|
|---|---|
|
||||||
|
| `default` | `h-9 px-4 py-2` |
|
||||||
|
| `sm` | `h-8 px-3 text-xs` |
|
||||||
|
| `icon` | `h-8 w-8` |
|
||||||
|
|
||||||
|
All variants share: `rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50`.
|
||||||
|
|
||||||
|
There is **no "secondary" variant** — the outline variant is the closest equivalent.
|
||||||
|
|
||||||
|
### Composite Button Components
|
||||||
|
|
||||||
|
**ConfirmButton** (`components/ui/confirm-button.tsx`):
|
||||||
|
- Wraps `Button variant="ghost" size="icon"`
|
||||||
|
- Default state: `hover:text-hover-destructive` (uses token)
|
||||||
|
- Confirming state: `bg-destructive text-primary-foreground animate-confirm-pulse hover:bg-destructive hover:text-primary-foreground`
|
||||||
|
|
||||||
|
**OverflowMenu** (`components/ui/overflow-menu.tsx`):
|
||||||
|
- Trigger: `Button variant="ghost" size="icon"` with `text-muted-foreground hover:text-hover-neutral`
|
||||||
|
- Menu items: raw `<button>` elements with `hover:bg-muted/20` (**not using the token system**)
|
||||||
|
|
||||||
|
### Button Usage Across Components
|
||||||
|
|
||||||
|
| Component | Button type | Variant/Style |
|
||||||
|
|---|---|---|
|
||||||
|
| `action-bar.tsx:556` | `<Button type="submit">` | default (primary) — "Add" |
|
||||||
|
| `action-bar.tsx:561` | `<Button type="button">` | default (primary) — "Roll all" |
|
||||||
|
| `turn-navigation.tsx:25,54` | `<Button size="icon">` | default — prev/next turn |
|
||||||
|
| `turn-navigation.tsx:47` | `<ConfirmButton>` | ghost+destructive — clear encounter |
|
||||||
|
| `source-fetch-prompt.tsx:91` | `<Button size="sm">` | default — "Load" |
|
||||||
|
| `source-fetch-prompt.tsx:106` | `<Button size="sm" variant="outline">` | outline — "Upload file" |
|
||||||
|
| `bulk-import-prompt.tsx:31,45,106` | `<Button size="sm">` | default — "Done"/"Load All" |
|
||||||
|
| `source-manager.tsx:50` | `<Button size="sm" variant="outline">` | outline — "Clear all" |
|
||||||
|
| `hp-adjust-popover.tsx:112` | `<Button variant="ghost" size="icon">` | ghost + custom red — damage |
|
||||||
|
| `hp-adjust-popover.tsx:124` | `<Button variant="ghost" size="icon">` | ghost + custom green — heal |
|
||||||
|
| `player-management.tsx:67` | `<Button>` | default — "Create first player" |
|
||||||
|
| `player-management.tsx:113` | `<Button variant="ghost">` | ghost — "Add player" |
|
||||||
|
| `create-player-modal.tsx:177` | `<Button variant="ghost">` | ghost — "Cancel" |
|
||||||
|
| `create-player-modal.tsx:180` | `<Button type="submit">` | default — "Save"/"Create" |
|
||||||
|
| `combatant-row.tsx:625` | `<ConfirmButton>` | ghost+destructive — remove combatant |
|
||||||
|
|
||||||
|
**Raw `<button>` elements** (not using the Button component):
|
||||||
|
- `action-bar.tsx` — suggestion items, count increment/decrement, browse toggle, custom add (all inline-styled)
|
||||||
|
- `combatant-row.tsx` — editable name, HP display, AC, initiative, concentration toggle
|
||||||
|
- `stat-block-panel.tsx` — fold/close/pin/unpin buttons
|
||||||
|
- `condition-picker.tsx` — condition items
|
||||||
|
- `condition-tags.tsx` — condition tags, add condition button
|
||||||
|
- `toast.tsx` — dismiss button
|
||||||
|
- `player-management.tsx` — close modal, edit player
|
||||||
|
- `create-player-modal.tsx` — close modal
|
||||||
|
- `color-palette.tsx` — color swatches
|
||||||
|
- `icon-grid.tsx` — icon options
|
||||||
|
|
||||||
|
### Hover Effects Inventory
|
||||||
|
|
||||||
|
**Using semantic tokens (unified):**
|
||||||
|
|
||||||
|
| Hover class | Meaning | Used in |
|
||||||
|
|---|---|---|
|
||||||
|
| `hover:bg-hover-neutral-bg` | Neutral background highlight | button.tsx (outline/ghost), action-bar.tsx, condition-picker.tsx, condition-tags.tsx |
|
||||||
|
| `hover:text-hover-neutral` | Text turns primary blue | button.tsx (outline/ghost), action-bar.tsx, combatant-row.tsx, stat-block-panel.tsx, ac-shield.tsx, toast.tsx, overflow-menu.tsx, condition-tags.tsx |
|
||||||
|
| `hover:text-hover-action` | Action text (same as neutral) | action-bar.tsx (overflow trigger) |
|
||||||
|
| `hover:text-hover-destructive` | Destructive text turns red | confirm-button.tsx, source-manager.tsx |
|
||||||
|
| `hover:bg-hover-destructive-bg` | Destructive background (transparent) | source-manager.tsx |
|
||||||
|
|
||||||
|
**One-off / hardcoded hover colors (NOT using tokens):**
|
||||||
|
|
||||||
|
| Hover class | Used in | Context |
|
||||||
|
|---|---|---|
|
||||||
|
| `hover:bg-primary/90` | button.tsx (default variant) | Primary button darken |
|
||||||
|
| `hover:bg-accent/20` | action-bar.tsx | Suggestion highlight, custom add |
|
||||||
|
| `hover:bg-accent/40` | action-bar.tsx | Count +/- buttons, confirm queued |
|
||||||
|
| `hover:bg-muted/20` | overflow-menu.tsx | Menu item highlight |
|
||||||
|
| `hover:bg-red-950` | hp-adjust-popover.tsx | Damage button |
|
||||||
|
| `hover:text-red-300` | hp-adjust-popover.tsx | Damage button text |
|
||||||
|
| `hover:bg-emerald-950` | hp-adjust-popover.tsx | Heal button |
|
||||||
|
| `hover:text-emerald-300` | hp-adjust-popover.tsx | Heal button text |
|
||||||
|
| `hover:text-foreground` | player-management.tsx, create-player-modal.tsx, icon-grid.tsx | Close/edit buttons |
|
||||||
|
| `hover:bg-background/50` | player-management.tsx | Player row hover |
|
||||||
|
| `hover:bg-card` | icon-grid.tsx | Icon option hover |
|
||||||
|
| `hover:border-hover-destructive` | source-manager.tsx | Clear all button border |
|
||||||
|
| `hover:scale-110` | color-palette.tsx | Color swatch enlarge |
|
||||||
|
| `hover:bg-destructive` | confirm-button.tsx (confirming state) | Maintain red bg on hover |
|
||||||
|
| `hover:text-primary-foreground` | confirm-button.tsx (confirming state) | Maintain white text on hover |
|
||||||
|
|
||||||
|
### Hover unification assessment
|
||||||
|
|
||||||
|
The hover token system (`hover-neutral`, `hover-action`, `hover-destructive`) provides a consistent pattern for the most common interactions. The `Button` component's outline and ghost variants use these tokens, and many inline buttons in action-bar, combatant-row, stat-block-panel, and condition components also use them.
|
||||||
|
|
||||||
|
However, there are notable gaps:
|
||||||
|
1. **HP adjust popover** uses hardcoded red/green colors (`red-950`, `emerald-950`) instead of tokens
|
||||||
|
2. **Overflow menu items** use `hover:bg-muted/20` instead of `hover:bg-hover-neutral-bg`
|
||||||
|
3. **Player management modals** use `hover:text-foreground` and `hover:bg-background/50` instead of the semantic tokens
|
||||||
|
4. **Action-bar suggestion items** use `hover:bg-accent/20` and `hover:bg-accent/40` — accent-specific patterns not in the token system
|
||||||
|
5. **Icon grid** and **color palette** use their own hover patterns (`hover:bg-card`, `hover:scale-110`)
|
||||||
|
|
||||||
|
## Code References
|
||||||
|
|
||||||
|
- `apps/web/src/index.css:3-26` — Theme color definitions including hover tokens
|
||||||
|
- `apps/web/src/components/ui/button.tsx:1-38` — Button component with CVA variants
|
||||||
|
- `apps/web/src/components/ui/confirm-button.tsx:93-115` — ConfirmButton with destructive hover states
|
||||||
|
- `apps/web/src/components/ui/overflow-menu.tsx:38-72` — OverflowMenu with non-token hover
|
||||||
|
- `apps/web/src/components/hp-adjust-popover.tsx:117-129` — Hardcoded red/green hover colors
|
||||||
|
- `apps/web/src/components/action-bar.tsx:80-188` — Mixed token and accent-based hovers
|
||||||
|
- `apps/web/src/components/combatant-row.tsx:147-629` — Inline buttons with token hovers
|
||||||
|
- `apps/web/src/components/player-management.tsx:58-98` — Non-token hover patterns
|
||||||
|
- `apps/web/src/components/stat-block-panel.tsx:55-109` — Consistent token usage
|
||||||
|
- `apps/web/src/lib/utils.ts:1-5` — `cn()` utility (clsx + twMerge)
|
||||||
|
|
||||||
|
## Architecture Documentation
|
||||||
|
|
||||||
|
The styling architecture follows this pattern:
|
||||||
|
|
||||||
|
1. **Theme layer**: `index.css` defines all color tokens via `@theme`, including semantic hover tokens
|
||||||
|
2. **Component layer**: `Button` (CVA) provides the shared button abstraction with three variants
|
||||||
|
3. **Composite layer**: `ConfirmButton` and `OverflowMenu` wrap `Button` with additional behavior
|
||||||
|
4. **Usage layer**: Components use either `Button` component or raw `<button>` elements with inline Tailwind classes
|
||||||
|
|
||||||
|
The `cn()` utility from `lib/utils.ts` is used in 9+ components for conditional class merging.
|
||||||
|
|
||||||
|
Custom animations are defined in `index.css` via `@keyframes` + `@utility` pairs: slide-in-right, confirm-pulse, settle-to-bottom, rise-to-center, slide-down-in, slide-up-out, concentration-pulse.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. The `hover-action` and `hover-action-bg` tokens are defined but rarely used — `hover-action` appears only once in `action-bar.tsx:565`. Is this intentional or an incomplete migration?
|
||||||
|
2. The `accent` color (`#3b82f6`) is identical to `primary` — are they intended to diverge in the future, or is this redundancy?
|
||||||
|
3. Should the hardcoded HP adjust colors (red/emerald) be promoted to theme tokens (e.g., `hover-damage`, `hover-heal`)?
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
package.json
10
package.json
@@ -1,8 +1,15 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.6.0",
|
"packageManager": "pnpm@10.6.0",
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"undici": ">=7.24.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.0.0",
|
"@biomejs/biome": "2.0.0",
|
||||||
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
|
"jscpd": "^4.0.8",
|
||||||
"knip": "^5.85.0",
|
"knip": "^5.85.0",
|
||||||
"lefthook": "^1.11.0",
|
"lefthook": "^1.11.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
@@ -18,6 +25,7 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"knip": "knip",
|
"knip": "knip",
|
||||||
"check": "knip && biome check . && tsc --build && vitest run"
|
"jscpd": "jscpd",
|
||||||
|
"check": "pnpm audit --audit-level=high && knip && biome check . && tsc --build && vitest run && jscpd"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
packages/application/src/adjust-hp-use-case.ts
Normal file
24
packages/application/src/adjust-hp-use-case.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import {
|
||||||
|
adjustHp,
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
isDomainError,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
|
||||||
|
export function adjustHpUseCase(
|
||||||
|
store: EncounterStore,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
delta: number,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
const encounter = store.get();
|
||||||
|
const result = adjustHp(encounter, combatantId, delta);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.save(result.encounter);
|
||||||
|
return result.events;
|
||||||
|
}
|
||||||
21
packages/application/src/clear-encounter-use-case.ts
Normal file
21
packages/application/src/clear-encounter-use-case.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import {
|
||||||
|
clearEncounter,
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
isDomainError,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
|
||||||
|
export function clearEncounterUseCase(
|
||||||
|
store: EncounterStore,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
const encounter = store.get();
|
||||||
|
const result = clearEncounter(encounter);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.save(result.encounter);
|
||||||
|
return result.events;
|
||||||
|
}
|
||||||
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 | undefined,
|
||||||
|
icon: string | undefined,
|
||||||
|
): 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 | null;
|
||||||
|
readonly icon?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,25 @@
|
|||||||
export { addCombatantUseCase } from "./add-combatant-use-case.js";
|
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 { 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 { 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 { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
||||||
|
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
|
||||||
|
export {
|
||||||
|
type RollAllResult,
|
||||||
|
rollAllInitiativeUseCase,
|
||||||
|
} from "./roll-all-initiative-use-case.js";
|
||||||
|
export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
|
||||||
|
export { setAcUseCase } from "./set-ac-use-case.js";
|
||||||
|
export { setHpUseCase } from "./set-hp-use-case.js";
|
||||||
export { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
export { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
||||||
|
export { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js";
|
||||||
|
export { toggleConditionUseCase } from "./toggle-condition-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 {
|
export interface EncounterStore {
|
||||||
get(): Encounter;
|
get(): Encounter;
|
||||||
save(encounter: Encounter): void;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
21
packages/application/src/retreat-turn-use-case.ts
Normal file
21
packages/application/src/retreat-turn-use-case.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import {
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
isDomainError,
|
||||||
|
retreatTurn,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
|
||||||
|
export function retreatTurnUseCase(
|
||||||
|
store: EncounterStore,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
const encounter = store.get();
|
||||||
|
const result = retreatTurn(encounter);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.save(result.encounter);
|
||||||
|
return result.events;
|
||||||
|
}
|
||||||
60
packages/application/src/roll-all-initiative-use-case.ts
Normal file
60
packages/application/src/roll-all-initiative-use-case.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import {
|
||||||
|
type Creature,
|
||||||
|
type CreatureId,
|
||||||
|
calculateInitiative,
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
isDomainError,
|
||||||
|
rollInitiative,
|
||||||
|
setInitiative,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
|
||||||
|
export interface RollAllResult {
|
||||||
|
events: DomainEvent[];
|
||||||
|
skippedNoSource: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rollAllInitiativeUseCase(
|
||||||
|
store: EncounterStore,
|
||||||
|
rollDice: () => number,
|
||||||
|
getCreature: (id: CreatureId) => Creature | undefined,
|
||||||
|
): RollAllResult | DomainError {
|
||||||
|
let encounter = store.get();
|
||||||
|
const allEvents: DomainEvent[] = [];
|
||||||
|
let skippedNoSource = 0;
|
||||||
|
|
||||||
|
for (const combatant of encounter.combatants) {
|
||||||
|
if (!combatant.creatureId) continue;
|
||||||
|
if (combatant.initiative !== undefined) continue;
|
||||||
|
|
||||||
|
const creature = getCreature(combatant.creatureId);
|
||||||
|
if (!creature) {
|
||||||
|
skippedNoSource++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { modifier } = calculateInitiative({
|
||||||
|
dexScore: creature.abilities.dex,
|
||||||
|
cr: creature.cr,
|
||||||
|
initiativeProficiency: creature.initiativeProficiency,
|
||||||
|
});
|
||||||
|
const value = rollInitiative(rollDice(), modifier);
|
||||||
|
|
||||||
|
if (isDomainError(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = setInitiative(encounter, combatant.id, value);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
encounter = result.encounter;
|
||||||
|
allEvents.push(...result.events);
|
||||||
|
}
|
||||||
|
|
||||||
|
store.save(encounter);
|
||||||
|
return { events: allEvents, skippedNoSource };
|
||||||
|
}
|
||||||
67
packages/application/src/roll-initiative-use-case.ts
Normal file
67
packages/application/src/roll-initiative-use-case.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type Creature,
|
||||||
|
type CreatureId,
|
||||||
|
calculateInitiative,
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
isDomainError,
|
||||||
|
rollInitiative,
|
||||||
|
setInitiative,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
|
||||||
|
export function rollInitiativeUseCase(
|
||||||
|
store: EncounterStore,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
diceRoll: number,
|
||||||
|
getCreature: (id: CreatureId) => Creature | undefined,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
const encounter = store.get();
|
||||||
|
const combatant = encounter.combatants.find((c) => c.id === combatantId);
|
||||||
|
|
||||||
|
if (!combatant) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "combatant-not-found",
|
||||||
|
message: `No combatant found with ID "${combatantId}"`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!combatant.creatureId) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "no-creature-link",
|
||||||
|
message: `Combatant "${combatant.name}" has no linked creature`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const creature = getCreature(combatant.creatureId);
|
||||||
|
if (!creature) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "creature-not-found",
|
||||||
|
message: `Creature not found for ID "${combatant.creatureId}"`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { modifier } = calculateInitiative({
|
||||||
|
dexScore: creature.abilities.dex,
|
||||||
|
cr: creature.cr,
|
||||||
|
initiativeProficiency: creature.initiativeProficiency,
|
||||||
|
});
|
||||||
|
const value = rollInitiative(diceRoll, modifier);
|
||||||
|
|
||||||
|
if (isDomainError(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = setInitiative(encounter, combatantId, value);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.save(result.encounter);
|
||||||
|
return result.events;
|
||||||
|
}
|
||||||
24
packages/application/src/set-ac-use-case.ts
Normal file
24
packages/application/src/set-ac-use-case.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
isDomainError,
|
||||||
|
setAc,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
|
||||||
|
export function setAcUseCase(
|
||||||
|
store: EncounterStore,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
value: number | undefined,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
const encounter = store.get();
|
||||||
|
const result = setAc(encounter, combatantId, value);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.save(result.encounter);
|
||||||
|
return result.events;
|
||||||
|
}
|
||||||
24
packages/application/src/set-hp-use-case.ts
Normal file
24
packages/application/src/set-hp-use-case.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
isDomainError,
|
||||||
|
setHp,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
|
||||||
|
export function setHpUseCase(
|
||||||
|
store: EncounterStore,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
maxHp: number | undefined,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
const encounter = store.get();
|
||||||
|
const result = setHp(encounter, combatantId, maxHp);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.save(result.encounter);
|
||||||
|
return result.events;
|
||||||
|
}
|
||||||
23
packages/application/src/toggle-concentration-use-case.ts
Normal file
23
packages/application/src/toggle-concentration-use-case.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
isDomainError,
|
||||||
|
toggleConcentration,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
|
||||||
|
export function toggleConcentrationUseCase(
|
||||||
|
store: EncounterStore,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
const encounter = store.get();
|
||||||
|
const result = toggleConcentration(encounter, combatantId);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.save(result.encounter);
|
||||||
|
return result.events;
|
||||||
|
}
|
||||||
25
packages/application/src/toggle-condition-use-case.ts
Normal file
25
packages/application/src/toggle-condition-use-case.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type ConditionId,
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
isDomainError,
|
||||||
|
toggleCondition,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
|
||||||
|
export function toggleConditionUseCase(
|
||||||
|
store: EncounterStore,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
conditionId: ConditionId,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
const encounter = store.get();
|
||||||
|
const result = toggleCondition(encounter, combatantId, conditionId);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.save(result.encounter);
|
||||||
|
return result.events;
|
||||||
|
}
|
||||||
166
packages/domain/src/__tests__/adjust-hp.test.ts
Normal file
166
packages/domain/src/__tests__/adjust-hp.test.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { adjustHp } from "../adjust-hp.js";
|
||||||
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
|
|
||||||
|
function makeCombatant(
|
||||||
|
name: string,
|
||||||
|
opts?: { maxHp: number; currentHp: number },
|
||||||
|
): Combatant {
|
||||||
|
return {
|
||||||
|
id: combatantId(name),
|
||||||
|
name,
|
||||||
|
...(opts ? { maxHp: opts.maxHp, currentHp: opts.currentHp } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function enc(combatants: Combatant[]): Encounter {
|
||||||
|
return { combatants, activeIndex: 0, roundNumber: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function successResult(encounter: Encounter, id: string, delta: number) {
|
||||||
|
const result = adjustHp(encounter, combatantId(id), delta);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
throw new Error(`Expected success, got error: ${result.message}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("adjustHp", () => {
|
||||||
|
describe("acceptance scenarios", () => {
|
||||||
|
it("+1 increases currentHp by 1", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
|
||||||
|
const { encounter } = successResult(e, "A", 1);
|
||||||
|
expect(encounter.combatants[0].currentHp).toBe(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("-1 decreases currentHp by 1", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
|
||||||
|
const { encounter } = successResult(e, "A", -1);
|
||||||
|
expect(encounter.combatants[0].currentHp).toBe(14);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps at 0 — cannot go below zero", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 3 })]);
|
||||||
|
const { encounter } = successResult(e, "A", -10);
|
||||||
|
expect(encounter.combatants[0].currentHp).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps at maxHp — cannot exceed max", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 18 })]);
|
||||||
|
const { encounter } = successResult(e, "A", 10);
|
||||||
|
expect(encounter.combatants[0].currentHp).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("invariants", () => {
|
||||||
|
it("is pure — same input produces same output", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||||
|
const r1 = adjustHp(e, combatantId("A"), -5);
|
||||||
|
const r2 = adjustHp(e, combatantId("A"), -5);
|
||||||
|
expect(r1).toEqual(r2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate input encounter", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||||
|
const original = JSON.parse(JSON.stringify(e));
|
||||||
|
adjustHp(e, combatantId("A"), -3);
|
||||||
|
expect(e).toEqual(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits CurrentHpAdjusted event with delta", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
|
||||||
|
const { events } = successResult(e, "A", -5);
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "CurrentHpAdjusted",
|
||||||
|
combatantId: combatantId("A"),
|
||||||
|
previousHp: 15,
|
||||||
|
newHp: 10,
|
||||||
|
delta: -5,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves activeIndex and roundNumber", () => {
|
||||||
|
const e = {
|
||||||
|
combatants: [
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 10 }),
|
||||||
|
makeCombatant("B"),
|
||||||
|
],
|
||||||
|
activeIndex: 1,
|
||||||
|
roundNumber: 5,
|
||||||
|
};
|
||||||
|
const { encounter } = successResult(e, "A", -3);
|
||||||
|
expect(encounter.activeIndex).toBe(1);
|
||||||
|
expect(encounter.roundNumber).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("error cases", () => {
|
||||||
|
it("returns error for nonexistent combatant", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||||
|
const result = adjustHp(e, combatantId("Z"), -1);
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
expect(result.code).toBe("combatant-not-found");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error when combatant has no HP tracking", () => {
|
||||||
|
const e = enc([makeCombatant("A")]);
|
||||||
|
const result = adjustHp(e, combatantId("A"), -1);
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
expect(result.code).toBe("no-hp-tracking");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error for zero delta", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||||
|
const result = adjustHp(e, combatantId("A"), 0);
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
expect(result.code).toBe("zero-delta");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error for non-integer delta", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||||
|
const result = adjustHp(e, combatantId("A"), 1.5);
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
expect(result.code).toBe("invalid-delta");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("large negative delta beyond currentHp clamps to 0", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 100, currentHp: 50 })]);
|
||||||
|
const { encounter } = successResult(e, "A", -9999);
|
||||||
|
expect(encounter.combatants[0].currentHp).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("large positive delta beyond maxHp clamps to maxHp", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 100, currentHp: 50 })]);
|
||||||
|
const { encounter } = successResult(e, "A", 9999);
|
||||||
|
expect(encounter.combatants[0].currentHp).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not affect other combatants", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 15 }),
|
||||||
|
makeCombatant("B", { maxHp: 30, currentHp: 25 }),
|
||||||
|
]);
|
||||||
|
const { encounter } = successResult(e, "A", -5);
|
||||||
|
expect(encounter.combatants[1].currentHp).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adjusting from 0 upward works", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 0 })]);
|
||||||
|
const { encounter } = successResult(e, "A", 5);
|
||||||
|
expect(encounter.combatants[0].currentHp).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -169,9 +169,9 @@ describe("advanceTurn", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("invariants", () => {
|
describe("invariants", () => {
|
||||||
it("INV-1: createEncounter rejects empty combatant list", () => {
|
it("INV-1: createEncounter accepts empty combatant list", () => {
|
||||||
const result = createEncounter([]);
|
const result = createEncounter([]);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expect(isDomainError(result)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("INV-2: activeIndex always in bounds across all scenarios", () => {
|
it("INV-2: activeIndex always in bounds across all scenarios", () => {
|
||||||
|
|||||||
49
packages/domain/src/__tests__/auto-number.test.ts
Normal file
49
packages/domain/src/__tests__/auto-number.test.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveCreatureName } from "../auto-number.js";
|
||||||
|
|
||||||
|
describe("resolveCreatureName", () => {
|
||||||
|
it("returns name as-is when no conflict exists", () => {
|
||||||
|
const result = resolveCreatureName("Goblin", ["Orc", "Dragon"]);
|
||||||
|
expect(result).toEqual({ newName: "Goblin", renames: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns name as-is when existing list is empty", () => {
|
||||||
|
const result = resolveCreatureName("Goblin", []);
|
||||||
|
expect(result).toEqual({ newName: "Goblin", renames: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renames existing to 'Name 1' and new to 'Name 2' on first conflict", () => {
|
||||||
|
const result = resolveCreatureName("Goblin", ["Orc", "Goblin", "Dragon"]);
|
||||||
|
expect(result).toEqual({
|
||||||
|
newName: "Goblin 2",
|
||||||
|
renames: [{ from: "Goblin", to: "Goblin 1" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends next number when numbered variants already exist", () => {
|
||||||
|
const result = resolveCreatureName("Goblin", ["Goblin 1", "Goblin 2"]);
|
||||||
|
expect(result).toEqual({ newName: "Goblin 3", renames: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles mixed exact and numbered matches", () => {
|
||||||
|
const result = resolveCreatureName("Goblin", [
|
||||||
|
"Goblin",
|
||||||
|
"Goblin 1",
|
||||||
|
"Goblin 2",
|
||||||
|
]);
|
||||||
|
expect(result).toEqual({ newName: "Goblin 3", renames: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles names with special regex characters", () => {
|
||||||
|
const result = resolveCreatureName("Goblin (Boss)", ["Goblin (Boss)"]);
|
||||||
|
expect(result).toEqual({
|
||||||
|
newName: "Goblin (Boss) 2",
|
||||||
|
renames: [{ from: "Goblin (Boss)", to: "Goblin (Boss) 1" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not match partial name overlaps", () => {
|
||||||
|
const result = resolveCreatureName("Goblin", ["Goblin Boss"]);
|
||||||
|
expect(result).toEqual({ newName: "Goblin", renames: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
114
packages/domain/src/__tests__/clear-encounter.test.ts
Normal file
114
packages/domain/src/__tests__/clear-encounter.test.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { clearEncounter } from "../clear-encounter.js";
|
||||||
|
import type { DomainError, Encounter } from "../types.js";
|
||||||
|
import { combatantId, createEncounter, isDomainError } from "../types.js";
|
||||||
|
|
||||||
|
function makeEncounter(
|
||||||
|
count: number,
|
||||||
|
overrides?: Partial<Encounter>,
|
||||||
|
): Encounter {
|
||||||
|
const combatants = Array.from({ length: count }, (_, i) => ({
|
||||||
|
id: combatantId(`c-${i + 1}`),
|
||||||
|
name: `Combatant ${i + 1}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = createEncounter(combatants);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
throw new Error("Failed to create encounter in test helper");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...result, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("clearEncounter", () => {
|
||||||
|
it("clears encounter with multiple combatants at round 3 — returns empty encounter with roundNumber 1 and activeIndex 0", () => {
|
||||||
|
const encounter = makeEncounter(4, { roundNumber: 3, activeIndex: 2 });
|
||||||
|
|
||||||
|
const result = clearEncounter(encounter);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
const success = result as Exclude<typeof result, DomainError>;
|
||||||
|
expect(success.encounter.combatants).toEqual([]);
|
||||||
|
expect(success.encounter.roundNumber).toBe(1);
|
||||||
|
expect(success.encounter.activeIndex).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears encounter with a single combatant", () => {
|
||||||
|
const encounter = makeEncounter(1);
|
||||||
|
|
||||||
|
const result = clearEncounter(encounter);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
const success = result as Exclude<typeof result, DomainError>;
|
||||||
|
expect(success.encounter.combatants).toEqual([]);
|
||||||
|
expect(success.encounter.activeIndex).toBe(0);
|
||||||
|
expect(success.encounter.roundNumber).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears encounter with combatants that have HP/AC/conditions/concentration", () => {
|
||||||
|
const encounter: Encounter = {
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Fighter",
|
||||||
|
maxHp: 50,
|
||||||
|
currentHp: 30,
|
||||||
|
ac: 18,
|
||||||
|
conditions: ["blinded", "poisoned"],
|
||||||
|
isConcentrating: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Wizard",
|
||||||
|
maxHp: 25,
|
||||||
|
currentHp: 0,
|
||||||
|
ac: 12,
|
||||||
|
conditions: ["unconscious"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = clearEncounter(encounter);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
const success = result as Exclude<typeof result, DomainError>;
|
||||||
|
expect(success.encounter.combatants).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns DomainError with code 'encounter-already-empty' when encounter has no combatants", () => {
|
||||||
|
const encounter: Encounter = {
|
||||||
|
combatants: [],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = clearEncounter(encounter);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
const error = result as DomainError;
|
||||||
|
expect(error.code).toBe("encounter-already-empty");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits EncounterCleared event with correct combatantCount", () => {
|
||||||
|
const encounter = makeEncounter(3);
|
||||||
|
|
||||||
|
const result = clearEncounter(encounter);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
const success = result as Exclude<typeof result, DomainError>;
|
||||||
|
expect(success.events).toEqual([
|
||||||
|
{ type: "EncounterCleared", combatantCount: 3 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is deterministic — same input always produces same output", () => {
|
||||||
|
const encounter = makeEncounter(2, { roundNumber: 4, activeIndex: 1 });
|
||||||
|
|
||||||
|
const result1 = clearEncounter(encounter);
|
||||||
|
const result2 = clearEncounter(encounter);
|
||||||
|
|
||||||
|
expect(result1).toEqual(result2);
|
||||||
|
});
|
||||||
|
});
|
||||||
270
packages/domain/src/__tests__/create-player-character.test.ts
Normal file
270
packages/domain/src/__tests__/create-player-character.test.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
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("allows undefined color", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
undefined,
|
||||||
|
"sword",
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].color).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows undefined icon", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
"blue",
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].icon).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows both color and icon undefined", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].color).toBeUndefined();
|
||||||
|
expect(result.characters[0].icon).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user