Compare commits
17 Commits
c295840b7b
...
0.9.23
| Author | SHA1 | Date | |
|---|---|---|---|
| 8dbff66ce1 | |||
| e62c49434c | |||
| 8f6eebc43b | |||
| 817cfddabc | |||
| 94e1806112 | |||
| 30e7ed4121 | |||
| 5540baf14c | |||
| 1ae9e12cff | |||
| 2c643cc98b | |||
| 228c1c667f | |||
| 300d4b1f73 | |||
| 43546aaa7b | |||
| 09da9a8dfc | |||
| b229a0dac7 | |||
| 08b5db81ad | |||
| a89fac5c23 | |||
| b6ee4c8c86 |
@@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
name: commit
|
||||||
|
description: Create a git commit with pre-commit hooks (bypasses sandbox restrictions).
|
||||||
|
disable-model-invocation: true
|
||||||
|
allowed-tools: Bash(git *), Bash(pnpm *)
|
||||||
|
---
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
Create a git commit for the current staged and/or unstaged changes.
|
||||||
|
|
||||||
|
### Step 1 — Assess changes
|
||||||
|
|
||||||
|
Run these in parallel:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log --oneline -5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2 — Draft commit message
|
||||||
|
|
||||||
|
- Summarize the nature of the changes (new feature, enhancement, bug fix, refactor, test, docs, etc.)
|
||||||
|
- Keep the first line concise (under 72 chars), use imperative mood
|
||||||
|
- Add a blank line and a short body if the "why" isn't obvious from the first line
|
||||||
|
- Match the style of recent commits in the log
|
||||||
|
- Do not commit files that likely contain secrets (.env, credentials, etc.)
|
||||||
|
|
||||||
|
### Step 3 — Stage and commit
|
||||||
|
|
||||||
|
Stage relevant files by name (avoid `git add -A` or `git add .`). Then commit.
|
||||||
|
|
||||||
|
**CRITICAL:** Always use `dangerouslyDisableSandbox: true` for the commit command. Lefthook pre-commit hooks spawn subprocesses (biome, oxlint, vitest, etc.) that require filesystem access beyond what the sandbox allows. They will always fail with "operation not permitted" in sandbox mode.
|
||||||
|
|
||||||
|
Append the co-author trailer:
|
||||||
|
|
||||||
|
```
|
||||||
|
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a HEREDOC for the commit message:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
<first line>
|
||||||
|
|
||||||
|
<optional body>
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4 — Verify
|
||||||
|
|
||||||
|
Run `git status` after the commit to confirm success.
|
||||||
|
|
||||||
|
### If the commit fails
|
||||||
|
|
||||||
|
If a pre-commit hook fails, fix the issue, re-stage, and create a **new** commit. Never amend unless explicitly asked — amending after a hook failure would modify the previous commit.
|
||||||
|
|
||||||
|
## User arguments
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ARGUMENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
If the user provided arguments, treat them as the commit message or guidance for what to commit.
|
||||||
@@ -12,4 +12,6 @@ Thumbs.db
|
|||||||
coverage/
|
coverage/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
docs/agents/plans/
|
docs/agents/plans/
|
||||||
|
docs/agents/research/
|
||||||
|
.agent-tests/
|
||||||
.rodney/
|
.rodney/
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
───────────────────
|
───────────────────
|
||||||
Version change: 3.0.0 → 3.1.0 (MINOR — new principle II-A: context-based state flow)
|
Version change: 3.1.0 → 3.2.0 (MINOR — artifact lifecycle guidance)
|
||||||
Modified sections:
|
Modified sections:
|
||||||
- Core Principles: added II-A. Context-Based State Flow (max 8 props, context over prop drilling)
|
- Development Workflow: added artifact lifecycle rules (spec.md living, plan/tasks bounded, tests authoritative)
|
||||||
Templates requiring updates: none
|
Templates requiring updates: none
|
||||||
-->
|
-->
|
||||||
# Encounter Console Constitution
|
# Encounter Console Constitution
|
||||||
@@ -113,6 +113,18 @@ architecture, and quality — not product behavior.
|
|||||||
(which creates a feature branch for the full speckit pipeline);
|
(which creates a feature branch for the full speckit pipeline);
|
||||||
changes to existing features update the existing spec via
|
changes to existing features update the existing spec via
|
||||||
`/integrate-issue`.
|
`/integrate-issue`.
|
||||||
|
- **Artifact lifecycles differ by type**:
|
||||||
|
- `spec.md` is a **living capability document** — it describes what
|
||||||
|
the feature does and is updated whenever the feature meaningfully
|
||||||
|
changes. It survives across multiple rounds of work.
|
||||||
|
- `plan.md` and `tasks.md` are **bounded work packages** — they
|
||||||
|
describe what to do for a specific increment of work. After
|
||||||
|
completion they become historical records. The next round of work
|
||||||
|
on the same feature gets a new plan, not an update to the old one.
|
||||||
|
- Tests are the **executable ground truth**. When a spec's
|
||||||
|
acceptance criteria and the tests disagree, the tests are
|
||||||
|
authoritative. Spec prose captures intent and context; tests
|
||||||
|
capture actual behavior.
|
||||||
- The full pipeline (spec → plan → tasks → implement) applies to new
|
- The full pipeline (spec → plan → tasks → implement) applies to new
|
||||||
features and significant additions. Bug fixes, tooling changes,
|
features and significant additions. Bug fixes, tooling changes,
|
||||||
and trivial UI adjustments do not require specs.
|
and trivial UI adjustments do not require specs.
|
||||||
@@ -156,4 +168,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**: 3.1.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-19
|
**Version**: 3.2.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-30
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
**Initiative** is a browser-based combat encounter tracker for tabletop RPGs (D&D 5.5e, Pathfinder 2e). It runs entirely client-side — no backend, no accounts — with localStorage and IndexedDB for persistence.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
@@ -66,16 +66,65 @@ docs/agents/ RPI skill artifacts (research reports, plans)
|
|||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- **Biome 2.4** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically.
|
- **Biome 2.4** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically.
|
||||||
- **oxlint** for type-aware linting that Biome can't do (unnecessary type assertions, deprecated APIs, `replaceAll` preference, `String.raw`). Configured in `.oxlintrc.json`.
|
- **oxlint** for type-aware linting that Biome can't do. Configured in `.oxlintrc.json`.
|
||||||
- **TypeScript strict mode** with `verbatimModuleSyntax`. Use `.js` extensions in relative imports when required by the repo's ESM settings (e.g., `./types.js`).
|
- **TypeScript strict mode** with `verbatimModuleSyntax`. Use `.js` extensions in relative imports.
|
||||||
- **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.
|
||||||
|
- **Reuse UI primitives** — before creating custom interactive elements (buttons, inputs, selects, dialogs), check `apps/web/src/components/ui/` for existing components with established variants and hover styles.
|
||||||
- **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 `__tests__/` directories adjacent to source. See the Testing section below for conventions on mocking, assertions, and per-layer approach.
|
||||||
- **Feature specs** live in `specs/NNN-feature-name/` with spec.md (and optionally plan.md, tasks.md for new work). Specs describe features, not individual changes. The project constitution is at `.specify/memory/constitution.md`.
|
|
||||||
- **Component props** — max 8 explicitly declared props per component interface (enforced by `scripts/check-component-props.mjs` using the TypeScript compiler API). Use React context for shared state; reserve props for per-instance config (data items, layout variants, refs).
|
|
||||||
- **Export format compatibility** — When changing `Encounter`, `Combatant`, `PlayerCharacter`, or `UndoRedoState` types, verify that previously exported JSON files (version 1) still import correctly. If not, bump the `ExportBundle` version and add migration logic in `validateImportBundle()`.
|
|
||||||
- **Quality gates** are enforced at pre-commit via Lefthook (parallel jobs). No gate may exist only as a CI step or manual process.
|
- **Quality gates** are enforced at pre-commit via Lefthook (parallel jobs). No gate may exist only as a CI step or manual process.
|
||||||
|
|
||||||
|
For component prop rules, export format compatibility, and ADRs, see [`docs/conventions.md`](docs/conventions.md).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Philosophy
|
||||||
|
|
||||||
|
Test **user-visible behavior**, not implementation details. A good test answers "does this feature work?" not "does this internal function get called?"
|
||||||
|
|
||||||
|
### Adapter Injection
|
||||||
|
|
||||||
|
Adapters (storage, cache, browser APIs) are provided via `AdapterContext`. Production wires real implementations; tests wire in-memory implementations. This means:
|
||||||
|
- No `vi.mock()` for adapter or persistence modules
|
||||||
|
- Tests control adapter behavior by configuring the in-memory implementation
|
||||||
|
- Type changes in adapter interfaces are caught at compile time
|
||||||
|
|
||||||
|
### Per-Layer Approach
|
||||||
|
|
||||||
|
| Layer | How to test |
|
||||||
|
|---|---|
|
||||||
|
| Domain (`packages/domain`) | Pure unit tests, no mocks, test invariants and acceptance scenarios |
|
||||||
|
| Application (`packages/application`) | Mock port interfaces only, use real domain logic |
|
||||||
|
| Hooks (context-wrapped) | Test via `renderHook` with `AllProviders` wrapping in-memory adapters |
|
||||||
|
| Hooks (component-specific) | Test through the component that uses them |
|
||||||
|
| Components | Render with `AllProviders`, use in-memory adapters, use `userEvent` for interactions |
|
||||||
|
|
||||||
|
### Test Data
|
||||||
|
|
||||||
|
Use factory functions from `apps/web/src/__tests__/factories/` to construct domain objects. Each factory provides sensible defaults overridden via `Partial<T>`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { buildEncounter } from "../../__tests__/factories/build-encounter.js";
|
||||||
|
import { buildCombatant } from "../../__tests__/factories/build-combatant.js";
|
||||||
|
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [buildCombatant({ name: "Goblin" })],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Add new factory files as needed (one per domain type). Don't inline test data construction — use factories so type changes are caught at compile time.
|
||||||
|
|
||||||
|
### Anti-Patterns
|
||||||
|
|
||||||
|
- **`vi.mock()` for adapters**: Use in-memory adapter implementations via `AdapterContext` instead
|
||||||
|
- **Mocking contexts**: Use `AllProviders` and drive state through real hooks instead of `vi.mock("../../contexts/...")`. Exception: context mocks are acceptable when the component under test requires specific state machine states that cannot be reached through adapter configuration alone — document the reason in a comment at the top of the test file.
|
||||||
|
- **Stubbing child components**: Render real children; stub only if the child has heavy I/O that can't be mocked at the adapter level
|
||||||
|
- **Asserting mock call counts**: Prefer asserting what the user sees (`screen.getByText(...)`) over `expect(mockFn).toHaveBeenCalledWith(...)`
|
||||||
|
- **Testing internal state**: Don't assert `result.current.suggestionIndex === 0`; assert the first suggestion is highlighted
|
||||||
|
- **Assertion-free tests**: Every `it()` block must contain at least one `expect()`. Tests that render without asserting inflate coverage without catching bugs.
|
||||||
|
|
||||||
## Self-Review Checklist
|
## Self-Review Checklist
|
||||||
|
|
||||||
Before finishing a change, consider:
|
Before finishing a change, consider:
|
||||||
@@ -86,21 +135,7 @@ Before finishing a change, consider:
|
|||||||
|
|
||||||
## Speckit Workflow
|
## Speckit Workflow
|
||||||
|
|
||||||
Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Specs are **living documents** that describe features, not individual changes.
|
Specs are **living documents** in `specs/NNN-feature-name/` that describe features, not individual changes. Use `/speckit.*` and RPI skills (`rpi-research`, `rpi-plan`, `rpi-implement`) to manage them — skill descriptions have full usage details.
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
**Research scope**: Research should include a scan for existing patterns similar to what the feature needs (e.g., shared UI primitives, duplicated validation logic, repeated state management patterns). Identify extraction and consolidation opportunities before implementation, not during.
|
|
||||||
|
|
||||||
### Choosing the right workflow by scope
|
|
||||||
|
|
||||||
| Scope | Workflow |
|
| Scope | Workflow |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -109,24 +144,8 @@ Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Spec
|
|||||||
| Larger addition to existing feature | `/integrate-issue` → `rpi-research` → `rpi-plan` → `rpi-implement` |
|
| 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` |
|
| 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.
|
**Research scope**: Always scan for existing patterns similar to what the feature needs. Identify extraction and consolidation opportunities before implementation, not during.
|
||||||
|
|
||||||
### Current feature specs
|
## Constitution
|
||||||
- `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
|
|
||||||
- `specs/006-undo-redo/` — undo/redo for encounter state mutations
|
|
||||||
- `specs/007-json-import-export/` — JSON import/export for full encounter state (encounter, undo/redo, player characters)
|
|
||||||
- `specs/008-encounter-difficulty/` — Live encounter difficulty indicator (5.5e XP budget system), optional PC level field
|
|
||||||
|
|
||||||
## Constitution (key principles)
|
Project principles governing all feature work are in [`.specify/memory/constitution.md`](.specify/memory/constitution.md). Key rules: deterministic domain core, strict layer boundaries, clarification before assumptions.
|
||||||
|
|
||||||
The constitution (`.specify/memory/constitution.md`) governs all feature work:
|
|
||||||
|
|
||||||
1. **Deterministic Domain Core** — Pure state transitions only; no I/O, randomness, or clocks in domain.
|
|
||||||
2. **Layered Architecture** — Domain → Application → Adapters. Never skip layers or reverse dependencies.
|
|
||||||
3. **Clarification-First** — Ask before making non-trivial assumptions.
|
|
||||||
4. **MVP Baseline** — Say "MVP baseline does not include X", never permanent bans.
|
|
||||||
5. **Spec-driven features** — Features are described in living specs; evolve existing specs via `/integrate-issue`, create new ones via `/speckit.specify`. Bug fixes and tooling changes do not require specs.
|
|
||||||
|
|||||||
@@ -29,6 +29,6 @@
|
|||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"jsdom": "^29.0.1",
|
"jsdom": "^29.0.1",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
"vite": "^8.0.1"
|
"vite": "^8.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
type AnyCreature,
|
||||||
|
type CreatureId,
|
||||||
|
EMPTY_UNDO_REDO_STATE,
|
||||||
|
type Encounter,
|
||||||
|
type PlayerCharacter,
|
||||||
|
type UndoRedoState,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { Adapters } from "../../contexts/adapter-context.js";
|
||||||
|
|
||||||
|
export function createTestAdapters(options?: {
|
||||||
|
encounter?: Encounter | null;
|
||||||
|
undoRedoState?: UndoRedoState;
|
||||||
|
playerCharacters?: PlayerCharacter[];
|
||||||
|
creatures?: Map<CreatureId, AnyCreature>;
|
||||||
|
sources?: Map<
|
||||||
|
string,
|
||||||
|
{ displayName: string; creatures: AnyCreature[]; cachedAt: number }
|
||||||
|
>;
|
||||||
|
}): Adapters {
|
||||||
|
let storedEncounter = options?.encounter ?? null;
|
||||||
|
let storedUndoRedo = options?.undoRedoState ?? EMPTY_UNDO_REDO_STATE;
|
||||||
|
let storedPCs = options?.playerCharacters ?? [];
|
||||||
|
const sourceStore =
|
||||||
|
options?.sources ??
|
||||||
|
new Map<
|
||||||
|
string,
|
||||||
|
{ displayName: string; creatures: AnyCreature[]; cachedAt: number }
|
||||||
|
>();
|
||||||
|
|
||||||
|
// Pre-populate sourceStore from creatures map if provided
|
||||||
|
if (options?.creatures && !options?.sources) {
|
||||||
|
// No-op: creatures are accessed directly from the map
|
||||||
|
}
|
||||||
|
|
||||||
|
const creatureMap = options?.creatures ?? new Map<CreatureId, AnyCreature>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
encounterPersistence: {
|
||||||
|
load: () => storedEncounter,
|
||||||
|
save: (e) => {
|
||||||
|
storedEncounter = e;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
undoRedoPersistence: {
|
||||||
|
load: () => storedUndoRedo,
|
||||||
|
save: (state) => {
|
||||||
|
storedUndoRedo = state;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
playerCharacterPersistence: {
|
||||||
|
load: () => [...storedPCs],
|
||||||
|
save: (pcs) => {
|
||||||
|
storedPCs = pcs;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bestiaryCache: {
|
||||||
|
cacheSource(system, sourceCode, displayName, creatures) {
|
||||||
|
const key = `${system}:${sourceCode}`;
|
||||||
|
sourceStore.set(key, {
|
||||||
|
displayName,
|
||||||
|
creatures,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
});
|
||||||
|
for (const c of creatures) {
|
||||||
|
creatureMap.set(c.id, c);
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
isSourceCached(system, sourceCode) {
|
||||||
|
return Promise.resolve(sourceStore.has(`${system}:${sourceCode}`));
|
||||||
|
},
|
||||||
|
getCachedSources(system) {
|
||||||
|
return Promise.resolve(
|
||||||
|
[...sourceStore.entries()]
|
||||||
|
.filter(([key]) => !system || key.startsWith(`${system}:`))
|
||||||
|
.map(([key, info]) => ({
|
||||||
|
sourceCode: key.includes(":")
|
||||||
|
? key.slice(key.indexOf(":") + 1)
|
||||||
|
: key,
|
||||||
|
displayName: info.displayName,
|
||||||
|
creatureCount: info.creatures.length,
|
||||||
|
cachedAt: info.cachedAt,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
clearSource(system, sourceCode) {
|
||||||
|
sourceStore.delete(`${system}:${sourceCode}`);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
clearAll() {
|
||||||
|
sourceStore.clear();
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
loadAllCachedCreatures() {
|
||||||
|
return Promise.resolve(new Map(creatureMap));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bestiaryIndex: {
|
||||||
|
loadIndex: () => ({ sources: {}, creatures: [] }),
|
||||||
|
getAllSourceCodes: () => [],
|
||||||
|
getDefaultFetchUrl: (sourceCode, baseUrl) => {
|
||||||
|
const filename = `bestiary-${sourceCode.toLowerCase()}.json`;
|
||||||
|
if (baseUrl !== undefined) {
|
||||||
|
const normalized = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||||
|
return `${normalized}${filename}`;
|
||||||
|
}
|
||||||
|
return `https://example.com/${filename}`;
|
||||||
|
},
|
||||||
|
getSourceDisplayName: (sourceCode) => sourceCode,
|
||||||
|
},
|
||||||
|
pf2eBestiaryIndex: {
|
||||||
|
loadIndex: () => ({ sources: {}, creatures: [] }),
|
||||||
|
getAllSourceCodes: () => [],
|
||||||
|
getDefaultFetchUrl: (sourceCode) =>
|
||||||
|
`https://example.com/creatures-${sourceCode.toLowerCase()}.json`,
|
||||||
|
getSourceDisplayName: (sourceCode) => sourceCode,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,34 +7,6 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|||||||
import { App } from "../App.js";
|
import { App } from "../App.js";
|
||||||
import { AllProviders } from "./test-providers.js";
|
import { AllProviders } from "./test-providers.js";
|
||||||
|
|
||||||
// Mock persistence — no localStorage interaction
|
|
||||||
vi.mock("../persistence/encounter-storage.js", () => ({
|
|
||||||
loadEncounter: () => null,
|
|
||||||
saveEncounter: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../persistence/player-character-storage.js", () => ({
|
|
||||||
loadPlayerCharacters: () => [],
|
|
||||||
savePlayerCharacters: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock bestiary — no IndexedDB or JSON index
|
|
||||||
vi.mock("../adapters/bestiary-cache.js", () => ({
|
|
||||||
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
|
||||||
isSourceCached: () => Promise.resolve(false),
|
|
||||||
cacheSource: () => Promise.resolve(),
|
|
||||||
getCachedSources: () => Promise.resolve([]),
|
|
||||||
clearSource: () => Promise.resolve(),
|
|
||||||
clearAll: () => Promise.resolve(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../adapters/bestiary-index-adapter.js", () => ({
|
|
||||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
|
||||||
getAllSourceCodes: () => [],
|
|
||||||
getDefaultFetchUrl: () => "",
|
|
||||||
getSourceDisplayName: (code: string) => code,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// DOM API stubs — jsdom doesn't implement these
|
// DOM API stubs — jsdom doesn't implement these
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
Object.defineProperty(globalThis, "matchMedia", {
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -209,6 +209,82 @@ describe("round-trip: export then import", () => {
|
|||||||
expect(imported.playerCharacters).toEqual(bundle.playerCharacters);
|
expect(imported.playerCharacters).toEqual(bundle.playerCharacters);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("round-trips a combatant with cr field", () => {
|
||||||
|
const encounterWithCr: Encounter = {
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Custom Thug",
|
||||||
|
cr: "2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
};
|
||||||
|
const emptyUndoRedo: UndoRedoState = {
|
||||||
|
undoStack: [],
|
||||||
|
redoStack: [],
|
||||||
|
};
|
||||||
|
const bundle = assembleExportBundle(encounterWithCr, emptyUndoRedo, []);
|
||||||
|
const serialized = JSON.parse(JSON.stringify(bundle));
|
||||||
|
const result = validateImportBundle(serialized);
|
||||||
|
|
||||||
|
expect(typeof result).toBe("object");
|
||||||
|
const imported = result as ExportBundle;
|
||||||
|
expect(imported.encounter.combatants[0].cr).toBe("2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips a combatant with side field", () => {
|
||||||
|
const encounterWithSide: Encounter = {
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Allied Guard",
|
||||||
|
cr: "2",
|
||||||
|
side: "party",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Goblin",
|
||||||
|
side: "enemy",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
};
|
||||||
|
const emptyUndoRedo: UndoRedoState = {
|
||||||
|
undoStack: [],
|
||||||
|
redoStack: [],
|
||||||
|
};
|
||||||
|
const bundle = assembleExportBundle(encounterWithSide, emptyUndoRedo, []);
|
||||||
|
const serialized = JSON.parse(JSON.stringify(bundle));
|
||||||
|
const result = validateImportBundle(serialized);
|
||||||
|
|
||||||
|
expect(typeof result).toBe("object");
|
||||||
|
const imported = result as ExportBundle;
|
||||||
|
expect(imported.encounter.combatants[0].side).toBe("party");
|
||||||
|
expect(imported.encounter.combatants[1].side).toBe("enemy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips a combatant without side field as undefined", () => {
|
||||||
|
const encounterNoSide: Encounter = {
|
||||||
|
combatants: [{ id: combatantId("c-1"), name: "Custom" }],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
};
|
||||||
|
const emptyUndoRedo: UndoRedoState = {
|
||||||
|
undoStack: [],
|
||||||
|
redoStack: [],
|
||||||
|
};
|
||||||
|
const bundle = assembleExportBundle(encounterNoSide, emptyUndoRedo, []);
|
||||||
|
const serialized = JSON.parse(JSON.stringify(bundle));
|
||||||
|
const result = validateImportBundle(serialized);
|
||||||
|
|
||||||
|
expect(typeof result).toBe("object");
|
||||||
|
const imported = result as ExportBundle;
|
||||||
|
expect(imported.encounter.combatants[0].side).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("round-trips an empty encounter", () => {
|
it("round-trips an empty encounter", () => {
|
||||||
const emptyEncounter: Encounter = {
|
const emptyEncounter: Encounter = {
|
||||||
combatants: [],
|
combatants: [],
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Combatant } from "@initiative/domain";
|
||||||
|
import { combatantId } from "@initiative/domain";
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
|
export function buildCombatant(overrides?: Partial<Combatant>): Combatant {
|
||||||
|
return {
|
||||||
|
id: combatantId(`c-${++counter}`),
|
||||||
|
name: "Combatant",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Creature } from "@initiative/domain";
|
||||||
|
import { creatureId } from "@initiative/domain";
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
|
export function buildCreature(overrides?: Partial<Creature>): Creature {
|
||||||
|
const id = ++counter;
|
||||||
|
return {
|
||||||
|
id: creatureId(`creature-${id}`),
|
||||||
|
name: `Creature ${id}`,
|
||||||
|
source: "srd",
|
||||||
|
sourceDisplayName: "SRD",
|
||||||
|
size: "Medium",
|
||||||
|
type: "humanoid",
|
||||||
|
alignment: "neutral",
|
||||||
|
ac: 13,
|
||||||
|
hp: { average: 7, formula: "2d6" },
|
||||||
|
speed: "30 ft.",
|
||||||
|
abilities: { str: 10, dex: 14, con: 10, int: 10, wis: 10, cha: 10 },
|
||||||
|
cr: "1/4",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
proficiencyBonus: 2,
|
||||||
|
passive: 10,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Encounter } from "@initiative/domain";
|
||||||
|
|
||||||
|
export function buildEncounter(overrides?: Partial<Encounter>): Encounter {
|
||||||
|
return {
|
||||||
|
combatants: [],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { buildCombatant } from "./build-combatant.js";
|
||||||
|
export { buildCreature } from "./build-creature.js";
|
||||||
|
export { buildEncounter } from "./build-encounter.js";
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* jsdom doesn't implement HTMLDialogElement.showModal/close.
|
||||||
|
* Call this in beforeAll() for tests that render <Dialog>.
|
||||||
|
*/
|
||||||
|
export function polyfillDialog(): void {
|
||||||
|
if (typeof HTMLDialogElement.prototype.showModal !== "function") {
|
||||||
|
HTMLDialogElement.prototype.showModal = function showModal() {
|
||||||
|
this.setAttribute("open", "");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (typeof HTMLDialogElement.prototype.close !== "function") {
|
||||||
|
HTMLDialogElement.prototype.close = function close() {
|
||||||
|
this.removeAttribute("open");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@ import type { Creature, CreatureId } from "@initiative/domain";
|
|||||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
// Mock the context modules
|
// Uses context mocks because StatBlockPanel requires fine-grained control over
|
||||||
|
// panel state (collapsed/expanded, pinned/unpinned, wide/narrow desktop) that
|
||||||
|
// would need extensive setup to drive through real providers.
|
||||||
vi.mock("../contexts/side-panel-context.js", () => ({
|
vi.mock("../contexts/side-panel-context.js", () => ({
|
||||||
useSidePanelContext: vi.fn(),
|
useSidePanelContext: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -14,14 +16,6 @@ vi.mock("../contexts/bestiary-context.js", () => ({
|
|||||||
useBestiaryContext: vi.fn(),
|
useBestiaryContext: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock adapters to avoid IndexedDB
|
|
||||||
vi.mock("../adapters/bestiary-index-adapter.js", () => ({
|
|
||||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
|
||||||
getAllSourceCodes: () => [],
|
|
||||||
getDefaultFetchUrl: () => "",
|
|
||||||
getSourceDisplayName: (code: string) => code,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { StatBlockPanel } from "../components/stat-block-panel.js";
|
import { StatBlockPanel } from "../components/stat-block-panel.js";
|
||||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import type { Adapters } from "../contexts/adapter-context.js";
|
||||||
|
import { AdapterProvider } from "../contexts/adapter-context.js";
|
||||||
import {
|
import {
|
||||||
BestiaryProvider,
|
BestiaryProvider,
|
||||||
BulkImportProvider,
|
BulkImportProvider,
|
||||||
@@ -9,23 +11,35 @@ import {
|
|||||||
SidePanelProvider,
|
SidePanelProvider,
|
||||||
ThemeProvider,
|
ThemeProvider,
|
||||||
} from "../contexts/index.js";
|
} from "../contexts/index.js";
|
||||||
|
import { createTestAdapters } from "./adapters/in-memory-adapters.js";
|
||||||
|
|
||||||
export function AllProviders({ children }: { children: ReactNode }) {
|
export function AllProviders({
|
||||||
|
adapters,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
adapters?: Adapters;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const resolved = adapters ?? createTestAdapters();
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<AdapterProvider adapters={resolved}>
|
||||||
<RulesEditionProvider>
|
<ThemeProvider>
|
||||||
<EncounterProvider>
|
<RulesEditionProvider>
|
||||||
<BestiaryProvider>
|
<EncounterProvider>
|
||||||
<PlayerCharactersProvider>
|
<BestiaryProvider>
|
||||||
<BulkImportProvider>
|
<PlayerCharactersProvider>
|
||||||
<SidePanelProvider>
|
<BulkImportProvider>
|
||||||
<InitiativeRollsProvider>{children}</InitiativeRollsProvider>
|
<SidePanelProvider>
|
||||||
</SidePanelProvider>
|
<InitiativeRollsProvider>
|
||||||
</BulkImportProvider>
|
{children}
|
||||||
</PlayerCharactersProvider>
|
</InitiativeRollsProvider>
|
||||||
</BestiaryProvider>
|
</SidePanelProvider>
|
||||||
</EncounterProvider>
|
</BulkImportProvider>
|
||||||
</RulesEditionProvider>
|
</PlayerCharactersProvider>
|
||||||
</ThemeProvider>
|
</BestiaryProvider>
|
||||||
|
</EncounterProvider>
|
||||||
|
</RulesEditionProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</AdapterProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
|
import type { TraitBlock } from "@initiative/domain";
|
||||||
import { beforeAll, describe, expect, it } from "vitest";
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
normalizeBestiary,
|
normalizeBestiary,
|
||||||
setSourceDisplayNames,
|
setSourceDisplayNames,
|
||||||
} from "../bestiary-adapter.js";
|
} from "../bestiary-adapter.js";
|
||||||
|
|
||||||
|
/** Flatten segments to a single string for simple text assertions. */
|
||||||
|
function flatText(trait: TraitBlock): string {
|
||||||
|
return trait.segments
|
||||||
|
.map((s) =>
|
||||||
|
s.type === "text"
|
||||||
|
? s.value
|
||||||
|
: s.items
|
||||||
|
.map((i) => (i.label ? `${i.label}. ${i.text}` : i.text))
|
||||||
|
.join(" "),
|
||||||
|
)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
setSourceDisplayNames({ XMM: "MM 2024" });
|
setSourceDisplayNames({ XMM: "MM 2024" });
|
||||||
});
|
});
|
||||||
@@ -74,11 +88,11 @@ describe("normalizeBestiary", () => {
|
|||||||
expect(c.senses).toBe("Darkvision 60 ft.");
|
expect(c.senses).toBe("Darkvision 60 ft.");
|
||||||
expect(c.languages).toBe("Common, Goblin");
|
expect(c.languages).toBe("Common, Goblin");
|
||||||
expect(c.actions).toHaveLength(1);
|
expect(c.actions).toHaveLength(1);
|
||||||
expect(c.actions?.[0].text).toContain("Melee Attack Roll:");
|
expect(flatText(c.actions![0])).toContain("Melee Attack Roll:");
|
||||||
expect(c.actions?.[0].text).not.toContain("{@");
|
expect(flatText(c.actions![0])).not.toContain("{@");
|
||||||
expect(c.bonusActions).toHaveLength(1);
|
expect(c.bonusActions).toHaveLength(1);
|
||||||
expect(c.bonusActions?.[0].text).toContain("Disengage");
|
expect(flatText(c.bonusActions![0])).toContain("Disengage");
|
||||||
expect(c.bonusActions?.[0].text).not.toContain("{@");
|
expect(flatText(c.bonusActions![0])).not.toContain("{@");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes a creature with legendary actions", () => {
|
it("normalizes a creature with legendary actions", () => {
|
||||||
@@ -333,9 +347,9 @@ describe("normalizeBestiary", () => {
|
|||||||
|
|
||||||
const creatures = normalizeBestiary(raw);
|
const creatures = normalizeBestiary(raw);
|
||||||
const bite = creatures[0].actions?.[0];
|
const bite = creatures[0].actions?.[0];
|
||||||
expect(bite?.text).toContain("Melee Weapon Attack:");
|
expect(flatText(bite!)).toContain("Melee Weapon Attack:");
|
||||||
expect(bite?.text).not.toContain("mw");
|
expect(flatText(bite!)).not.toContain("mw");
|
||||||
expect(bite?.text).not.toContain("{@");
|
expect(flatText(bite!)).not.toContain("{@");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles fly speed with hover condition", () => {
|
it("handles fly speed with hover condition", () => {
|
||||||
@@ -368,4 +382,129 @@ describe("normalizeBestiary", () => {
|
|||||||
const creatures = normalizeBestiary(raw);
|
const creatures = normalizeBestiary(raw);
|
||||||
expect(creatures[0].speed).toBe("10 ft., fly 90 ft. (hover)");
|
expect(creatures[0].speed).toBe("10 ft., fly 90 ft. (hover)");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders list items with singular entry field (e.g. Confusing Burble d4 effects)", () => {
|
||||||
|
const raw = {
|
||||||
|
monster: [
|
||||||
|
{
|
||||||
|
name: "Jabberwock",
|
||||||
|
source: "WBtW",
|
||||||
|
size: ["H"],
|
||||||
|
type: "dragon",
|
||||||
|
ac: [18],
|
||||||
|
hp: { average: 115, formula: "10d12 + 50" },
|
||||||
|
speed: { walk: 30 },
|
||||||
|
str: 22,
|
||||||
|
dex: 15,
|
||||||
|
con: 20,
|
||||||
|
int: 8,
|
||||||
|
wis: 14,
|
||||||
|
cha: 16,
|
||||||
|
passive: 12,
|
||||||
|
cr: "13",
|
||||||
|
trait: [
|
||||||
|
{
|
||||||
|
name: "Confusing Burble",
|
||||||
|
entries: [
|
||||||
|
"The jabberwock burbles unless {@condition incapacitated}. Roll a {@dice d4}:",
|
||||||
|
{
|
||||||
|
type: "list",
|
||||||
|
style: "list-hang-notitle",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
type: "item",
|
||||||
|
name: "1-2",
|
||||||
|
entry: "The creature does nothing.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "item",
|
||||||
|
name: "3",
|
||||||
|
entry:
|
||||||
|
"The creature uses all its movement to move in a random direction.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "item",
|
||||||
|
name: "4",
|
||||||
|
entry:
|
||||||
|
"The creature makes one melee attack against a random creature.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const creatures = normalizeBestiary(raw);
|
||||||
|
const trait = creatures[0].traits![0];
|
||||||
|
expect(trait.name).toBe("Confusing Burble");
|
||||||
|
expect(trait.segments).toHaveLength(2);
|
||||||
|
expect(trait.segments[0]).toEqual({
|
||||||
|
type: "text",
|
||||||
|
value: expect.stringContaining("d4"),
|
||||||
|
});
|
||||||
|
expect(trait.segments[1]).toEqual({
|
||||||
|
type: "list",
|
||||||
|
items: [
|
||||||
|
{ label: "1-2", text: "The creature does nothing." },
|
||||||
|
{
|
||||||
|
label: "3",
|
||||||
|
text: expect.stringContaining("random direction"),
|
||||||
|
},
|
||||||
|
{ label: "4", text: expect.stringContaining("melee attack") },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders table entries as structured list segments", () => {
|
||||||
|
const raw = {
|
||||||
|
monster: [
|
||||||
|
{
|
||||||
|
name: "Test Creature",
|
||||||
|
source: "XMM",
|
||||||
|
size: ["M"],
|
||||||
|
type: "humanoid",
|
||||||
|
ac: [12],
|
||||||
|
hp: { average: 40, formula: "9d8" },
|
||||||
|
speed: { walk: 30 },
|
||||||
|
str: 10,
|
||||||
|
dex: 10,
|
||||||
|
con: 10,
|
||||||
|
int: 10,
|
||||||
|
wis: 10,
|
||||||
|
cha: 10,
|
||||||
|
passive: 10,
|
||||||
|
cr: "1",
|
||||||
|
trait: [
|
||||||
|
{
|
||||||
|
name: "Random Effect",
|
||||||
|
entries: [
|
||||||
|
"Roll on the table:",
|
||||||
|
{
|
||||||
|
type: "table",
|
||||||
|
colLabels: ["d4", "Effect"],
|
||||||
|
rows: [
|
||||||
|
["1", "Nothing happens."],
|
||||||
|
["2", "Something happens."],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const creatures = normalizeBestiary(raw);
|
||||||
|
const trait = creatures[0].traits![0];
|
||||||
|
expect(trait.segments[1]).toEqual({
|
||||||
|
type: "list",
|
||||||
|
items: [
|
||||||
|
{ label: "1", text: "Nothing happens." },
|
||||||
|
{ label: "2", text: "Something happens." },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { Creature } from "@initiative/domain";
|
||||||
|
import { creatureId } from "@initiative/domain";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Mock idb to reject — simulates IndexedDB unavailable.
|
||||||
|
// This must be a separate file from bestiary-cache.test.ts because the
|
||||||
|
// module caches the db connection in a singleton; once openDB succeeds
|
||||||
|
// in one test, the fallback path is unreachable.
|
||||||
|
vi.mock("idb", () => ({
|
||||||
|
openDB: vi.fn().mockRejectedValue(new Error("IndexedDB unavailable")),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
cacheSource,
|
||||||
|
isSourceCached,
|
||||||
|
getCachedSources,
|
||||||
|
clearSource,
|
||||||
|
clearAll,
|
||||||
|
loadAllCachedCreatures,
|
||||||
|
} = await import("../bestiary-cache.js");
|
||||||
|
|
||||||
|
function makeCreature(id: string, name: string): Creature {
|
||||||
|
return {
|
||||||
|
id: creatureId(id),
|
||||||
|
name,
|
||||||
|
source: "MM",
|
||||||
|
sourceDisplayName: "Monster Manual",
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("bestiary-cache fallback (IndexedDB unavailable)", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await clearAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cacheSource falls back to in-memory store", async () => {
|
||||||
|
const creatures = [makeCreature("mm:goblin", "Goblin")];
|
||||||
|
await cacheSource("dnd", "MM", "Monster Manual", creatures);
|
||||||
|
|
||||||
|
expect(await isSourceCached("dnd", "MM")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isSourceCached returns false for uncached source", async () => {
|
||||||
|
expect(await isSourceCached("dnd", "XGE")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getCachedSources returns sources from in-memory store", async () => {
|
||||||
|
await cacheSource("dnd", "MM", "Monster Manual", [
|
||||||
|
makeCreature("mm:goblin", "Goblin"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sources = await getCachedSources();
|
||||||
|
expect(sources).toHaveLength(1);
|
||||||
|
expect(sources[0].sourceCode).toBe("MM");
|
||||||
|
expect(sources[0].creatureCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loadAllCachedCreatures assembles creatures from in-memory store", async () => {
|
||||||
|
const goblin = makeCreature("mm:goblin", "Goblin");
|
||||||
|
await cacheSource("dnd", "MM", "Monster Manual", [goblin]);
|
||||||
|
|
||||||
|
const map = await loadAllCachedCreatures();
|
||||||
|
expect(map.size).toBe(1);
|
||||||
|
expect(map.get(creatureId("mm:goblin"))?.name).toBe("Goblin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearSource removes a single source from in-memory store", async () => {
|
||||||
|
await cacheSource("dnd", "MM", "Monster Manual", []);
|
||||||
|
await cacheSource("dnd", "VGM", "Volo's Guide", []);
|
||||||
|
|
||||||
|
await clearSource("dnd", "MM");
|
||||||
|
|
||||||
|
expect(await isSourceCached("dnd", "MM")).toBe(false);
|
||||||
|
expect(await isSourceCached("dnd", "VGM")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearAll removes all data from in-memory store", async () => {
|
||||||
|
await cacheSource("dnd", "MM", "Monster Manual", []);
|
||||||
|
await clearAll();
|
||||||
|
|
||||||
|
const sources = await getCachedSources();
|
||||||
|
expect(sources).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import type { Creature } from "@initiative/domain";
|
||||||
|
import { creatureId } from "@initiative/domain";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Mock idb — the one legitimate use of vi.mock for a third-party I/O library.
|
||||||
|
// We can't use real IndexedDB in jsdom; this tests the cache logic through
|
||||||
|
// all public API methods with an in-memory backing store.
|
||||||
|
const fakeStore = new Map<string, unknown>();
|
||||||
|
|
||||||
|
vi.mock("idb", () => ({
|
||||||
|
openDB: vi.fn().mockResolvedValue({
|
||||||
|
put: vi.fn((_storeName: string, value: unknown) => {
|
||||||
|
const record = value as { sourceCode: string };
|
||||||
|
fakeStore.set(record.sourceCode, value);
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
get: vi.fn((_storeName: string, key: string) =>
|
||||||
|
Promise.resolve(fakeStore.get(key)),
|
||||||
|
),
|
||||||
|
getAll: vi.fn((_storeName: string) =>
|
||||||
|
Promise.resolve([...fakeStore.values()]),
|
||||||
|
),
|
||||||
|
delete: vi.fn((_storeName: string, key: string) => {
|
||||||
|
fakeStore.delete(key);
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
clear: vi.fn((_storeName: string) => {
|
||||||
|
fakeStore.clear();
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import after mocking
|
||||||
|
const {
|
||||||
|
cacheSource,
|
||||||
|
isSourceCached,
|
||||||
|
getCachedSources,
|
||||||
|
clearSource,
|
||||||
|
clearAll,
|
||||||
|
loadAllCachedCreatures,
|
||||||
|
} = await import("../bestiary-cache.js");
|
||||||
|
|
||||||
|
function makeCreature(id: string, name: string): Creature {
|
||||||
|
return {
|
||||||
|
id: creatureId(id),
|
||||||
|
name,
|
||||||
|
source: "MM",
|
||||||
|
sourceDisplayName: "Monster Manual",
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("bestiary-cache", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fakeStore.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cacheSource", () => {
|
||||||
|
it("stores creatures and metadata", async () => {
|
||||||
|
const creatures = [makeCreature("mm:goblin", "Goblin")];
|
||||||
|
await cacheSource("dnd", "MM", "Monster Manual", creatures);
|
||||||
|
|
||||||
|
expect(fakeStore.has("dnd:MM")).toBe(true);
|
||||||
|
const record = fakeStore.get("dnd:MM") as {
|
||||||
|
sourceCode: string;
|
||||||
|
displayName: string;
|
||||||
|
creatures: Creature[];
|
||||||
|
creatureCount: number;
|
||||||
|
cachedAt: number;
|
||||||
|
};
|
||||||
|
expect(record.sourceCode).toBe("dnd:MM");
|
||||||
|
expect(record.displayName).toBe("Monster Manual");
|
||||||
|
expect(record.creatures).toHaveLength(1);
|
||||||
|
expect(record.creatureCount).toBe(1);
|
||||||
|
expect(record.cachedAt).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isSourceCached", () => {
|
||||||
|
it("returns false for uncached source", async () => {
|
||||||
|
expect(await isSourceCached("dnd", "XGE")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true after caching", async () => {
|
||||||
|
await cacheSource("dnd", "MM", "Monster Manual", []);
|
||||||
|
expect(await isSourceCached("dnd", "MM")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getCachedSources", () => {
|
||||||
|
it("returns empty array when no sources cached", async () => {
|
||||||
|
const sources = await getCachedSources();
|
||||||
|
expect(sources).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns source info with creature counts", async () => {
|
||||||
|
await cacheSource("dnd", "MM", "Monster Manual", [
|
||||||
|
makeCreature("mm:goblin", "Goblin"),
|
||||||
|
makeCreature("mm:orc", "Orc"),
|
||||||
|
]);
|
||||||
|
await cacheSource("dnd", "VGM", "Volo's Guide", [
|
||||||
|
makeCreature("vgm:flind", "Flind"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sources = await getCachedSources();
|
||||||
|
expect(sources).toHaveLength(2);
|
||||||
|
|
||||||
|
const mm = sources.find((s) => s.sourceCode === "MM");
|
||||||
|
expect(mm).toBeDefined();
|
||||||
|
expect(mm?.displayName).toBe("Monster Manual");
|
||||||
|
expect(mm?.creatureCount).toBe(2);
|
||||||
|
|
||||||
|
const vgm = sources.find((s) => s.sourceCode === "VGM");
|
||||||
|
expect(vgm?.creatureCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadAllCachedCreatures", () => {
|
||||||
|
it("returns empty map when nothing cached", async () => {
|
||||||
|
const map = await loadAllCachedCreatures();
|
||||||
|
expect(map.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assembles creatures from all cached sources", async () => {
|
||||||
|
const goblin = makeCreature("mm:goblin", "Goblin");
|
||||||
|
const orc = makeCreature("mm:orc", "Orc");
|
||||||
|
const flind = makeCreature("vgm:flind", "Flind");
|
||||||
|
|
||||||
|
await cacheSource("dnd", "MM", "Monster Manual", [goblin, orc]);
|
||||||
|
await cacheSource("dnd", "VGM", "Volo's Guide", [flind]);
|
||||||
|
|
||||||
|
const map = await loadAllCachedCreatures();
|
||||||
|
expect(map.size).toBe(3);
|
||||||
|
expect(map.get(creatureId("mm:goblin"))?.name).toBe("Goblin");
|
||||||
|
expect(map.get(creatureId("mm:orc"))?.name).toBe("Orc");
|
||||||
|
expect(map.get(creatureId("vgm:flind"))?.name).toBe("Flind");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearSource", () => {
|
||||||
|
it("removes a single source", async () => {
|
||||||
|
await cacheSource("dnd", "MM", "Monster Manual", []);
|
||||||
|
await cacheSource("dnd", "VGM", "Volo's Guide", []);
|
||||||
|
|
||||||
|
await clearSource("dnd", "MM");
|
||||||
|
|
||||||
|
expect(await isSourceCached("dnd", "MM")).toBe(false);
|
||||||
|
expect(await isSourceCached("dnd", "VGM")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearAll", () => {
|
||||||
|
it("removes all cached data", async () => {
|
||||||
|
await cacheSource("dnd", "MM", "Monster Manual", []);
|
||||||
|
await cacheSource("dnd", "VGM", "Volo's Guide", []);
|
||||||
|
|
||||||
|
await clearAll();
|
||||||
|
|
||||||
|
const sources = await getCachedSources();
|
||||||
|
expect(sources).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
getAllSourceCodes,
|
||||||
|
getDefaultFetchUrl,
|
||||||
|
getSourceDisplayName,
|
||||||
|
loadBestiaryIndex,
|
||||||
|
} from "../bestiary-index-adapter.js";
|
||||||
|
|
||||||
|
describe("loadBestiaryIndex", () => {
|
||||||
|
it("returns an object with sources and creatures", () => {
|
||||||
|
const index = loadBestiaryIndex();
|
||||||
|
expect(index.sources).toBeDefined();
|
||||||
|
expect(index.creatures).toBeDefined();
|
||||||
|
expect(Array.isArray(index.creatures)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creatures have the expected shape", () => {
|
||||||
|
const index = loadBestiaryIndex();
|
||||||
|
expect(index.creatures.length).toBeGreaterThan(0);
|
||||||
|
const first = index.creatures[0];
|
||||||
|
expect(first).toHaveProperty("name");
|
||||||
|
expect(first).toHaveProperty("source");
|
||||||
|
expect(first).toHaveProperty("ac");
|
||||||
|
expect(first).toHaveProperty("hp");
|
||||||
|
expect(first).toHaveProperty("dex");
|
||||||
|
expect(first).toHaveProperty("cr");
|
||||||
|
expect(first).toHaveProperty("initiativeProficiency");
|
||||||
|
expect(first).toHaveProperty("size");
|
||||||
|
expect(first).toHaveProperty("type");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the same cached instance on subsequent calls", () => {
|
||||||
|
const a = loadBestiaryIndex();
|
||||||
|
const b = loadBestiaryIndex();
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sources is a record of source code to display name", () => {
|
||||||
|
const index = loadBestiaryIndex();
|
||||||
|
const entries = Object.entries(index.sources);
|
||||||
|
expect(entries.length).toBeGreaterThan(0);
|
||||||
|
for (const [code, name] of entries) {
|
||||||
|
expect(typeof code).toBe("string");
|
||||||
|
expect(typeof name).toBe("string");
|
||||||
|
expect(code.length).toBeGreaterThan(0);
|
||||||
|
expect(name.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAllSourceCodes", () => {
|
||||||
|
it("returns all keys from the index sources", () => {
|
||||||
|
const codes = getAllSourceCodes();
|
||||||
|
const index = loadBestiaryIndex();
|
||||||
|
expect(codes).toEqual(Object.keys(index.sources));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns only strings", () => {
|
||||||
|
for (const code of getAllSourceCodes()) {
|
||||||
|
expect(typeof code).toBe("string");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getDefaultFetchUrl", () => {
|
||||||
|
it("returns default GitHub URL when no baseUrl provided", () => {
|
||||||
|
const url = getDefaultFetchUrl("MM");
|
||||||
|
expect(url).toBe(
|
||||||
|
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/bestiary-mm.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("XMM");
|
||||||
|
expect(url).toContain("bestiary-xmm.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies filename override for Plane Shift sources", () => {
|
||||||
|
expect(getDefaultFetchUrl("PSA")).toContain("bestiary-ps-a.json");
|
||||||
|
expect(getDefaultFetchUrl("PSD")).toContain("bestiary-ps-d.json");
|
||||||
|
expect(getDefaultFetchUrl("PSK")).toContain("bestiary-ps-k.json");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSourceDisplayName", () => {
|
||||||
|
it("returns display name for a known source", () => {
|
||||||
|
const index = loadBestiaryIndex();
|
||||||
|
const [code, expectedName] = Object.entries(index.sources)[0];
|
||||||
|
expect(getSourceDisplayName(code)).toBe(expectedName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to source code for unknown source", () => {
|
||||||
|
expect(getSourceDisplayName("UNKNOWN_SOURCE_XYZ")).toBe(
|
||||||
|
"UNKNOWN_SOURCE_XYZ",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { normalizePf2eBestiary } from "../pf2e-bestiary-adapter.js";
|
||||||
|
|
||||||
|
function minimalCreature(defenses?: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
name: "Test Creature",
|
||||||
|
source: "TST",
|
||||||
|
defenses,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("normalizePf2eBestiary", () => {
|
||||||
|
describe("weaknesses formatting", () => {
|
||||||
|
it("formats weakness with numeric amount", () => {
|
||||||
|
const [creature] = normalizePf2eBestiary({
|
||||||
|
creature: [
|
||||||
|
minimalCreature({
|
||||||
|
weaknesses: [{ name: "fire", amount: 5 }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(creature.weaknesses).toBe("Fire 5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats weakness without amount (qualitative)", () => {
|
||||||
|
const [creature] = normalizePf2eBestiary({
|
||||||
|
creature: [
|
||||||
|
minimalCreature({
|
||||||
|
weaknesses: [{ name: "smoke susceptibility" }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(creature.weaknesses).toBe("Smoke susceptibility");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats weakness with note and amount", () => {
|
||||||
|
const [creature] = normalizePf2eBestiary({
|
||||||
|
creature: [
|
||||||
|
minimalCreature({
|
||||||
|
weaknesses: [
|
||||||
|
{ name: "cold iron", amount: 5, note: "except daggers" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(creature.weaknesses).toBe("Cold iron 5 (except daggers)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats weakness with note but no amount", () => {
|
||||||
|
const [creature] = normalizePf2eBestiary({
|
||||||
|
creature: [
|
||||||
|
minimalCreature({
|
||||||
|
weaknesses: [{ name: "smoke susceptibility", note: "see below" }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(creature.weaknesses).toBe("Smoke susceptibility (see below)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when no weaknesses", () => {
|
||||||
|
const [creature] = normalizePf2eBestiary({
|
||||||
|
creature: [minimalCreature({})],
|
||||||
|
});
|
||||||
|
expect(creature.weaknesses).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resistances formatting", () => {
|
||||||
|
it("formats resistance without amount", () => {
|
||||||
|
const [creature] = normalizePf2eBestiary({
|
||||||
|
creature: [
|
||||||
|
minimalCreature({
|
||||||
|
resistances: [{ name: "physical" }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(creature.resistances).toBe("Physical");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats resistance with amount", () => {
|
||||||
|
const [creature] = normalizePf2eBestiary({
|
||||||
|
creature: [
|
||||||
|
minimalCreature({
|
||||||
|
resistances: [{ name: "fire", amount: 10 }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(creature.resistances).toBe("Fire 10");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
getAllPf2eSourceCodes,
|
||||||
|
getDefaultPf2eFetchUrl,
|
||||||
|
getPf2eSourceDisplayName,
|
||||||
|
loadPf2eBestiaryIndex,
|
||||||
|
} from "../pf2e-bestiary-index-adapter.js";
|
||||||
|
|
||||||
|
describe("loadPf2eBestiaryIndex", () => {
|
||||||
|
it("returns an object with sources and creatures", () => {
|
||||||
|
const index = loadPf2eBestiaryIndex();
|
||||||
|
expect(index.sources).toBeDefined();
|
||||||
|
expect(index.creatures).toBeDefined();
|
||||||
|
expect(Array.isArray(index.creatures)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creatures have the expected PF2e shape", () => {
|
||||||
|
const index = loadPf2eBestiaryIndex();
|
||||||
|
expect(index.creatures.length).toBeGreaterThan(0);
|
||||||
|
const first = index.creatures[0];
|
||||||
|
expect(first).toHaveProperty("name");
|
||||||
|
expect(first).toHaveProperty("source");
|
||||||
|
expect(first).toHaveProperty("level");
|
||||||
|
expect(first).toHaveProperty("ac");
|
||||||
|
expect(first).toHaveProperty("hp");
|
||||||
|
expect(first).toHaveProperty("perception");
|
||||||
|
expect(first).toHaveProperty("size");
|
||||||
|
expect(first).toHaveProperty("type");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("contains a substantial number of creatures", () => {
|
||||||
|
const index = loadPf2eBestiaryIndex();
|
||||||
|
expect(index.creatures.length).toBeGreaterThan(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the same cached instance on subsequent calls", () => {
|
||||||
|
const a = loadPf2eBestiaryIndex();
|
||||||
|
const b = loadPf2eBestiaryIndex();
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAllPf2eSourceCodes", () => {
|
||||||
|
it("returns all keys from the index sources", () => {
|
||||||
|
const codes = getAllPf2eSourceCodes();
|
||||||
|
const index = loadPf2eBestiaryIndex();
|
||||||
|
expect(codes).toEqual(Object.keys(index.sources));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getDefaultPf2eFetchUrl", () => {
|
||||||
|
it("returns Pf2eTools GitHub URL with lowercase source code", () => {
|
||||||
|
const url = getDefaultPf2eFetchUrl("B1");
|
||||||
|
expect(url).toBe(
|
||||||
|
"https://raw.githubusercontent.com/Pf2eToolsOrg/Pf2eTools/dev/data/bestiary/creatures-b1.json",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getPf2eSourceDisplayName", () => {
|
||||||
|
it("returns display name for a known source", () => {
|
||||||
|
expect(getPf2eSourceDisplayName("B1")).toBe("Bestiary");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to source code for unknown source", () => {
|
||||||
|
expect(getPf2eSourceDisplayName("UNKNOWN")).toBe("UNKNOWN");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,8 @@ import type {
|
|||||||
LegendaryBlock,
|
LegendaryBlock,
|
||||||
SpellcastingBlock,
|
SpellcastingBlock,
|
||||||
TraitBlock,
|
TraitBlock,
|
||||||
|
TraitListItem,
|
||||||
|
TraitSegment,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { creatureId, proficiencyBonus } from "@initiative/domain";
|
import { creatureId, proficiencyBonus } from "@initiative/domain";
|
||||||
import { stripTags } from "./strip-tags.js";
|
import { stripTags } from "./strip-tags.js";
|
||||||
@@ -63,11 +65,18 @@ interface RawEntryObject {
|
|||||||
type: string;
|
type: string;
|
||||||
items?: (
|
items?: (
|
||||||
| string
|
| string
|
||||||
| { type: string; name?: string; entries?: (string | RawEntryObject)[] }
|
| {
|
||||||
|
type: string;
|
||||||
|
name?: string;
|
||||||
|
entry?: string;
|
||||||
|
entries?: (string | RawEntryObject)[];
|
||||||
|
}
|
||||||
)[];
|
)[];
|
||||||
style?: string;
|
style?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
entries?: (string | RawEntryObject)[];
|
entries?: (string | RawEntryObject)[];
|
||||||
|
colLabels?: string[];
|
||||||
|
rows?: (string | RawEntryObject)[][];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawSpellcasting {
|
interface RawSpellcasting {
|
||||||
@@ -257,23 +266,34 @@ function formatConditionImmunities(
|
|||||||
.join(", ");
|
.join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderListItem(item: string | RawEntryObject): string | undefined {
|
function toListItem(
|
||||||
|
item:
|
||||||
|
| string
|
||||||
|
| {
|
||||||
|
type: string;
|
||||||
|
name?: string;
|
||||||
|
entry?: string;
|
||||||
|
entries?: (string | RawEntryObject)[];
|
||||||
|
},
|
||||||
|
): TraitListItem | undefined {
|
||||||
if (typeof item === "string") {
|
if (typeof item === "string") {
|
||||||
return `• ${stripTags(item)}`;
|
return { text: stripTags(item) };
|
||||||
}
|
}
|
||||||
if (item.name && item.entries) {
|
if (item.name && item.entries) {
|
||||||
return `• ${stripTags(item.name)}: ${renderEntries(item.entries)}`;
|
return { label: stripTags(item.name), text: renderEntries(item.entries) };
|
||||||
|
}
|
||||||
|
if (item.name && item.entry) {
|
||||||
|
return { label: stripTags(item.name), text: stripTags(item.entry) };
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEntryObject(entry: RawEntryObject, parts: string[]): void {
|
function renderEntryObject(entry: RawEntryObject, parts: string[]): void {
|
||||||
if (entry.type === "list") {
|
if (entry.type === "list" || entry.type === "table") {
|
||||||
for (const item of entry.items ?? []) {
|
// Handled structurally in segmentizeEntries
|
||||||
const rendered = renderListItem(item);
|
return;
|
||||||
if (rendered) parts.push(rendered);
|
}
|
||||||
}
|
if (entry.type === "item" && entry.name && entry.entries) {
|
||||||
} else if (entry.type === "item" && entry.name && entry.entries) {
|
|
||||||
parts.push(`${stripTags(entry.name)}: ${renderEntries(entry.entries)}`);
|
parts.push(`${stripTags(entry.name)}: ${renderEntries(entry.entries)}`);
|
||||||
} else if (entry.entries) {
|
} else if (entry.entries) {
|
||||||
parts.push(renderEntries(entry.entries));
|
parts.push(renderEntries(entry.entries));
|
||||||
@@ -292,11 +312,67 @@ function renderEntries(entries: (string | RawEntryObject)[]): string {
|
|||||||
return parts.join(" ");
|
return parts.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tableRowToListItem(row: (string | RawEntryObject)[]): TraitListItem {
|
||||||
|
return {
|
||||||
|
label: typeof row[0] === "string" ? stripTags(row[0]) : undefined,
|
||||||
|
text: row
|
||||||
|
.slice(1)
|
||||||
|
.map((cell) =>
|
||||||
|
typeof cell === "string" ? stripTags(cell) : renderEntries([cell]),
|
||||||
|
)
|
||||||
|
.join(" "),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryToListSegment(entry: RawEntryObject): TraitSegment | undefined {
|
||||||
|
if (entry.type === "list") {
|
||||||
|
const items = (entry.items ?? [])
|
||||||
|
.map(toListItem)
|
||||||
|
.filter((i): i is TraitListItem => i !== undefined);
|
||||||
|
return items.length > 0 ? { type: "list", items } : undefined;
|
||||||
|
}
|
||||||
|
if (entry.type === "table" && entry.rows) {
|
||||||
|
const items = entry.rows.map(tableRowToListItem);
|
||||||
|
return items.length > 0 ? { type: "list", items } : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentizeEntries(
|
||||||
|
entries: (string | RawEntryObject)[],
|
||||||
|
): TraitSegment[] {
|
||||||
|
const segments: TraitSegment[] = [];
|
||||||
|
const textParts: string[] = [];
|
||||||
|
|
||||||
|
const flushText = () => {
|
||||||
|
if (textParts.length > 0) {
|
||||||
|
segments.push({ type: "text", value: textParts.join(" ") });
|
||||||
|
textParts.length = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (typeof entry === "string") {
|
||||||
|
textParts.push(stripTags(entry));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const listSeg = entryToListSegment(entry);
|
||||||
|
if (listSeg) {
|
||||||
|
flushText();
|
||||||
|
segments.push(listSeg);
|
||||||
|
} else {
|
||||||
|
renderEntryObject(entry, textParts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushText();
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeTraits(raw?: RawEntry[]): TraitBlock[] | undefined {
|
function normalizeTraits(raw?: RawEntry[]): TraitBlock[] | undefined {
|
||||||
if (!raw || raw.length === 0) return undefined;
|
if (!raw || raw.length === 0) return undefined;
|
||||||
return raw.map((t) => ({
|
return raw.map((t) => ({
|
||||||
name: stripTags(t.name),
|
name: stripTags(t.name),
|
||||||
text: renderEntries(t.entries),
|
segments: segmentizeEntries(t.entries),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,7 +437,7 @@ function normalizeLegendary(
|
|||||||
preamble,
|
preamble,
|
||||||
entries: raw.map((e) => ({
|
entries: raw.map((e) => ({
|
||||||
name: stripTags(e.name),
|
name: stripTags(e.name),
|
||||||
text: renderEntries(e.entries),
|
segments: segmentizeEntries(e.entries),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
import type { Creature, CreatureId } from "@initiative/domain";
|
import type { AnyCreature, CreatureId } from "@initiative/domain";
|
||||||
import { type IDBPDatabase, openDB } from "idb";
|
import { type IDBPDatabase, openDB } from "idb";
|
||||||
|
|
||||||
const DB_NAME = "initiative-bestiary";
|
const DB_NAME = "initiative-bestiary";
|
||||||
const STORE_NAME = "sources";
|
const STORE_NAME = "sources";
|
||||||
const DB_VERSION = 2;
|
const DB_VERSION = 4;
|
||||||
|
|
||||||
export interface CachedSourceInfo {
|
interface CachedSourceInfo {
|
||||||
readonly sourceCode: string;
|
readonly sourceCode: string;
|
||||||
readonly displayName: string;
|
readonly displayName: string;
|
||||||
readonly creatureCount: number;
|
readonly creatureCount: number;
|
||||||
readonly cachedAt: number;
|
readonly cachedAt: number;
|
||||||
|
readonly system?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CachedSourceRecord {
|
interface CachedSourceRecord {
|
||||||
sourceCode: string;
|
sourceCode: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
creatures: Creature[];
|
creatures: AnyCreature[];
|
||||||
cachedAt: number;
|
cachedAt: number;
|
||||||
creatureCount: number;
|
creatureCount: number;
|
||||||
|
system?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let db: IDBPDatabase | null = null;
|
let db: IDBPDatabase | null = null;
|
||||||
@@ -26,6 +28,10 @@ let dbFailed = false;
|
|||||||
// In-memory fallback when IndexedDB is unavailable
|
// In-memory fallback when IndexedDB is unavailable
|
||||||
const memoryStore = new Map<string, CachedSourceRecord>();
|
const memoryStore = new Map<string, CachedSourceRecord>();
|
||||||
|
|
||||||
|
function scopedKey(system: string, sourceCode: string): string {
|
||||||
|
return `${system}:${sourceCode}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function getDb(): Promise<IDBPDatabase | null> {
|
async function getDb(): Promise<IDBPDatabase | null> {
|
||||||
if (db) return db;
|
if (db) return db;
|
||||||
if (dbFailed) return null;
|
if (dbFailed) return null;
|
||||||
@@ -38,9 +44,12 @@ async function getDb(): Promise<IDBPDatabase | null> {
|
|||||||
keyPath: "sourceCode",
|
keyPath: "sourceCode",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (oldVersion < 2 && database.objectStoreNames.contains(STORE_NAME)) {
|
if (
|
||||||
// Clear cached creatures to pick up improved tag processing
|
oldVersion < DB_VERSION &&
|
||||||
transaction.objectStore(STORE_NAME).clear();
|
database.objectStoreNames.contains(STORE_NAME)
|
||||||
|
) {
|
||||||
|
// Clear cached creatures so they get re-normalized with latest rendering
|
||||||
|
void transaction.objectStore(STORE_NAME).clear();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -55,60 +64,77 @@ async function getDb(): Promise<IDBPDatabase | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function cacheSource(
|
export async function cacheSource(
|
||||||
|
system: string,
|
||||||
sourceCode: string,
|
sourceCode: string,
|
||||||
displayName: string,
|
displayName: string,
|
||||||
creatures: Creature[],
|
creatures: AnyCreature[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const key = scopedKey(system, sourceCode);
|
||||||
const record: CachedSourceRecord = {
|
const record: CachedSourceRecord = {
|
||||||
sourceCode,
|
sourceCode: key,
|
||||||
displayName,
|
displayName,
|
||||||
creatures,
|
creatures,
|
||||||
cachedAt: Date.now(),
|
cachedAt: Date.now(),
|
||||||
creatureCount: creatures.length,
|
creatureCount: creatures.length,
|
||||||
|
system,
|
||||||
};
|
};
|
||||||
|
|
||||||
const database = await getDb();
|
const database = await getDb();
|
||||||
if (database) {
|
if (database) {
|
||||||
await database.put(STORE_NAME, record);
|
await database.put(STORE_NAME, record);
|
||||||
} else {
|
} else {
|
||||||
memoryStore.set(sourceCode, record);
|
memoryStore.set(key, record);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function isSourceCached(sourceCode: string): Promise<boolean> {
|
export async function isSourceCached(
|
||||||
|
system: string,
|
||||||
|
sourceCode: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const key = scopedKey(system, sourceCode);
|
||||||
const database = await getDb();
|
const database = await getDb();
|
||||||
if (database) {
|
if (database) {
|
||||||
const record = await database.get(STORE_NAME, sourceCode);
|
const record = await database.get(STORE_NAME, key);
|
||||||
return record !== undefined;
|
return record !== undefined;
|
||||||
}
|
}
|
||||||
return memoryStore.has(sourceCode);
|
return memoryStore.has(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCachedSources(): Promise<CachedSourceInfo[]> {
|
export async function getCachedSources(
|
||||||
|
system?: string,
|
||||||
|
): Promise<CachedSourceInfo[]> {
|
||||||
const database = await getDb();
|
const database = await getDb();
|
||||||
|
let records: CachedSourceRecord[];
|
||||||
if (database) {
|
if (database) {
|
||||||
const all: CachedSourceRecord[] = await database.getAll(STORE_NAME);
|
records = await database.getAll(STORE_NAME);
|
||||||
return all.map((r) => ({
|
} else {
|
||||||
sourceCode: r.sourceCode,
|
records = [...memoryStore.values()];
|
||||||
displayName: r.displayName,
|
|
||||||
creatureCount: r.creatureCount,
|
|
||||||
cachedAt: r.cachedAt,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
return [...memoryStore.values()].map((r) => ({
|
|
||||||
sourceCode: r.sourceCode,
|
const filtered = system
|
||||||
|
? records.filter((r) => r.system === system)
|
||||||
|
: records;
|
||||||
|
return filtered.map((r) => ({
|
||||||
|
sourceCode: r.system
|
||||||
|
? r.sourceCode.slice(r.system.length + 1)
|
||||||
|
: r.sourceCode,
|
||||||
displayName: r.displayName,
|
displayName: r.displayName,
|
||||||
creatureCount: r.creatureCount,
|
creatureCount: r.creatureCount,
|
||||||
cachedAt: r.cachedAt,
|
cachedAt: r.cachedAt,
|
||||||
|
system: r.system,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearSource(sourceCode: string): Promise<void> {
|
export async function clearSource(
|
||||||
|
system: string,
|
||||||
|
sourceCode: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const key = scopedKey(system, sourceCode);
|
||||||
const database = await getDb();
|
const database = await getDb();
|
||||||
if (database) {
|
if (database) {
|
||||||
await database.delete(STORE_NAME, sourceCode);
|
await database.delete(STORE_NAME, key);
|
||||||
} else {
|
} else {
|
||||||
memoryStore.delete(sourceCode);
|
memoryStore.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,9 +148,9 @@ export async function clearAll(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadAllCachedCreatures(): Promise<
|
export async function loadAllCachedCreatures(): Promise<
|
||||||
Map<CreatureId, Creature>
|
Map<CreatureId, AnyCreature>
|
||||||
> {
|
> {
|
||||||
const map = new Map<CreatureId, Creature>();
|
const map = new Map<CreatureId, AnyCreature>();
|
||||||
const database = await getDb();
|
const database = await getDb();
|
||||||
|
|
||||||
let records: CachedSourceRecord[];
|
let records: CachedSourceRecord[];
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import type {
|
||||||
|
CreatureId,
|
||||||
|
Pf2eCreature,
|
||||||
|
TraitBlock,
|
||||||
|
TraitSegment,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { creatureId } from "@initiative/domain";
|
||||||
|
import { stripTags } from "./strip-tags.js";
|
||||||
|
|
||||||
|
// -- Raw Pf2eTools types (minimal, for parsing) --
|
||||||
|
|
||||||
|
interface RawPf2eCreature {
|
||||||
|
name: string;
|
||||||
|
source: string;
|
||||||
|
level?: number;
|
||||||
|
traits?: string[];
|
||||||
|
perception?: { std?: number };
|
||||||
|
senses?: { name?: string; type?: string }[];
|
||||||
|
languages?: { languages?: string[] };
|
||||||
|
skills?: Record<string, { std?: number }>;
|
||||||
|
abilityMods?: Record<string, number>;
|
||||||
|
items?: string[];
|
||||||
|
defenses?: RawDefenses;
|
||||||
|
speed?: Record<string, number | { number: number }>;
|
||||||
|
attacks?: RawAttack[];
|
||||||
|
abilities?: {
|
||||||
|
top?: RawAbility[];
|
||||||
|
mid?: RawAbility[];
|
||||||
|
bot?: RawAbility[];
|
||||||
|
};
|
||||||
|
_copy?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawDefenses {
|
||||||
|
ac?: Record<string, unknown>;
|
||||||
|
savingThrows?: {
|
||||||
|
fort?: { std?: number };
|
||||||
|
ref?: { std?: number };
|
||||||
|
will?: { std?: number };
|
||||||
|
};
|
||||||
|
hp?: { hp?: number }[];
|
||||||
|
immunities?: (string | { name: string })[];
|
||||||
|
resistances?: { amount?: number; name: string; note?: string }[];
|
||||||
|
weaknesses?: { amount?: number; name: string; note?: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawAbility {
|
||||||
|
name?: string;
|
||||||
|
entries?: RawEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawAttack {
|
||||||
|
range?: string;
|
||||||
|
name: string;
|
||||||
|
attack?: number;
|
||||||
|
traits?: string[];
|
||||||
|
damage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawEntry = string | RawEntryObject;
|
||||||
|
|
||||||
|
interface RawEntryObject {
|
||||||
|
type?: string;
|
||||||
|
items?: (string | { name?: string; entry?: string })[];
|
||||||
|
entries?: RawEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Module state --
|
||||||
|
|
||||||
|
let sourceDisplayNames: Record<string, string> = {};
|
||||||
|
|
||||||
|
export function setPf2eSourceDisplayNames(names: Record<string, string>): void {
|
||||||
|
sourceDisplayNames = names;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Helpers --
|
||||||
|
|
||||||
|
function capitalize(s: string): string {
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCreatureId(source: string, name: string): CreatureId {
|
||||||
|
const slug = name
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||||
|
.replaceAll(/(^-|-$)/g, "");
|
||||||
|
return creatureId(`${source.toLowerCase()}:${slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSpeed(
|
||||||
|
speed: Record<string, number | { number: number }> | undefined,
|
||||||
|
): string {
|
||||||
|
if (!speed) return "";
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const [mode, value] of Object.entries(speed)) {
|
||||||
|
if (typeof value === "number") {
|
||||||
|
parts.push(
|
||||||
|
mode === "walk" ? `${value} feet` : `${capitalize(mode)} ${value} feet`,
|
||||||
|
);
|
||||||
|
} else if (typeof value === "object" && "number" in value) {
|
||||||
|
parts.push(
|
||||||
|
mode === "walk"
|
||||||
|
? `${value.number} feet`
|
||||||
|
: `${capitalize(mode)} ${value.number} feet`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSkills(
|
||||||
|
skills: Record<string, { std?: number }> | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!skills) return undefined;
|
||||||
|
const parts = Object.entries(skills)
|
||||||
|
.map(([name, val]) => `${capitalize(name)} +${val.std ?? 0}`)
|
||||||
|
.sort();
|
||||||
|
return parts.length > 0 ? parts.join(", ") : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSenses(
|
||||||
|
senses: readonly { name?: string; type?: string }[] | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!senses || senses.length === 0) return undefined;
|
||||||
|
return senses
|
||||||
|
.map((s) => {
|
||||||
|
const label = s.name ?? s.type ?? "";
|
||||||
|
return label ? capitalize(label) : "";
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLanguages(
|
||||||
|
languages: { languages?: string[] } | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!languages?.languages || languages.languages.length === 0)
|
||||||
|
return undefined;
|
||||||
|
return languages.languages.map(capitalize).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatImmunities(
|
||||||
|
immunities: readonly (string | { name: string })[] | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!immunities || immunities.length === 0) return undefined;
|
||||||
|
return immunities
|
||||||
|
.map((i) => capitalize(typeof i === "string" ? i : i.name))
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatResistances(
|
||||||
|
resistances:
|
||||||
|
| readonly { amount?: number; name: string; note?: string }[]
|
||||||
|
| undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!resistances || resistances.length === 0) return undefined;
|
||||||
|
return resistances
|
||||||
|
.map((r) => {
|
||||||
|
const base =
|
||||||
|
r.amount == null
|
||||||
|
? capitalize(r.name)
|
||||||
|
: `${capitalize(r.name)} ${r.amount}`;
|
||||||
|
return r.note ? `${base} (${r.note})` : base;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWeaknesses(
|
||||||
|
weaknesses:
|
||||||
|
| readonly { amount?: number; name: string; note?: string }[]
|
||||||
|
| undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!weaknesses || weaknesses.length === 0) return undefined;
|
||||||
|
return weaknesses
|
||||||
|
.map((w) => {
|
||||||
|
const base =
|
||||||
|
w.amount == null
|
||||||
|
? capitalize(w.name)
|
||||||
|
: `${capitalize(w.name)} ${w.amount}`;
|
||||||
|
return w.note ? `${base} (${w.note})` : base;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Entry parsing --
|
||||||
|
|
||||||
|
function segmentizeEntries(entries: unknown): TraitSegment[] {
|
||||||
|
if (!Array.isArray(entries)) return [];
|
||||||
|
const segments: TraitSegment[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (typeof entry === "string") {
|
||||||
|
segments.push({ type: "text", value: stripTags(entry) });
|
||||||
|
} else if (typeof entry === "object" && entry !== null) {
|
||||||
|
const obj = entry as RawEntryObject;
|
||||||
|
if (obj.type === "list" && Array.isArray(obj.items)) {
|
||||||
|
segments.push({
|
||||||
|
type: "list",
|
||||||
|
items: obj.items.map((item) => {
|
||||||
|
if (typeof item === "string") {
|
||||||
|
return { text: stripTags(item) };
|
||||||
|
}
|
||||||
|
return { label: item.name, text: stripTags(item.entry ?? "") };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} else if (Array.isArray(obj.entries)) {
|
||||||
|
segments.push(...segmentizeEntries(obj.entries));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAffliction(a: Record<string, unknown>): TraitSegment[] {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (a.note) parts.push(stripTags(String(a.note)));
|
||||||
|
if (a.DC) parts.push(`DC ${a.DC}`);
|
||||||
|
if (a.savingThrow) parts.push(String(a.savingThrow));
|
||||||
|
const stages = a.stages as
|
||||||
|
| { stage: number; entry: string; duration: string }[]
|
||||||
|
| undefined;
|
||||||
|
if (stages) {
|
||||||
|
for (const s of stages) {
|
||||||
|
parts.push(`Stage ${s.stage}: ${stripTags(s.entry)} (${s.duration})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.length > 0 ? [{ type: "text", value: parts.join("; ") }] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAbilities(
|
||||||
|
abilities: readonly RawAbility[] | undefined,
|
||||||
|
): TraitBlock[] | undefined {
|
||||||
|
if (!abilities || abilities.length === 0) return undefined;
|
||||||
|
return abilities
|
||||||
|
.filter((a) => a.name)
|
||||||
|
.map((a) => {
|
||||||
|
const raw = a as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
name: stripTags(a.name as string),
|
||||||
|
segments: Array.isArray(a.entries)
|
||||||
|
? segmentizeEntries(a.entries)
|
||||||
|
: formatAffliction(raw),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAttacks(
|
||||||
|
attacks: readonly RawAttack[] | undefined,
|
||||||
|
): TraitBlock[] | undefined {
|
||||||
|
if (!attacks || attacks.length === 0) return undefined;
|
||||||
|
return attacks.map((a) => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (a.range) parts.push(a.range);
|
||||||
|
const attackMod = a.attack == null ? "" : ` +${a.attack}`;
|
||||||
|
const traits =
|
||||||
|
a.traits && a.traits.length > 0
|
||||||
|
? ` (${a.traits.map((t) => stripTags(t)).join(", ")})`
|
||||||
|
: "";
|
||||||
|
const damage = a.damage ? `, ${stripTags(a.damage)}` : "";
|
||||||
|
return {
|
||||||
|
name: capitalize(stripTags(a.name)),
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
value: `${parts.join(" ")}${attackMod}${traits}${damage}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Defenses extraction --
|
||||||
|
|
||||||
|
function extractDefenses(defenses: RawDefenses | undefined) {
|
||||||
|
const acRecord = defenses?.ac ?? {};
|
||||||
|
const acStd = (acRecord.std as number | undefined) ?? 0;
|
||||||
|
const acEntries = Object.entries(acRecord).filter(([k]) => k !== "std");
|
||||||
|
return {
|
||||||
|
ac: acStd,
|
||||||
|
acConditional:
|
||||||
|
acEntries.length > 0
|
||||||
|
? acEntries.map(([k, v]) => `${v} ${k}`).join(", ")
|
||||||
|
: undefined,
|
||||||
|
saveFort: defenses?.savingThrows?.fort?.std ?? 0,
|
||||||
|
saveRef: defenses?.savingThrows?.ref?.std ?? 0,
|
||||||
|
saveWill: defenses?.savingThrows?.will?.std ?? 0,
|
||||||
|
hp: defenses?.hp?.[0]?.hp ?? 0,
|
||||||
|
immunities: formatImmunities(defenses?.immunities),
|
||||||
|
resistances: formatResistances(defenses?.resistances),
|
||||||
|
weaknesses: formatWeaknesses(defenses?.weaknesses),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Main normalization --
|
||||||
|
|
||||||
|
function normalizeCreature(raw: RawPf2eCreature): Pf2eCreature {
|
||||||
|
const source = raw.source ?? "";
|
||||||
|
const defenses = extractDefenses(raw.defenses);
|
||||||
|
const mods = raw.abilityMods ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
system: "pf2e",
|
||||||
|
id: makeCreatureId(source, raw.name),
|
||||||
|
name: raw.name,
|
||||||
|
source,
|
||||||
|
sourceDisplayName: sourceDisplayNames[source] ?? source,
|
||||||
|
level: raw.level ?? 0,
|
||||||
|
traits: raw.traits ?? [],
|
||||||
|
perception: raw.perception?.std ?? 0,
|
||||||
|
senses: formatSenses(raw.senses),
|
||||||
|
languages: formatLanguages(raw.languages),
|
||||||
|
skills: formatSkills(raw.skills),
|
||||||
|
abilityMods: {
|
||||||
|
str: mods.str ?? 0,
|
||||||
|
dex: mods.dex ?? 0,
|
||||||
|
con: mods.con ?? 0,
|
||||||
|
int: mods.int ?? 0,
|
||||||
|
wis: mods.wis ?? 0,
|
||||||
|
cha: mods.cha ?? 0,
|
||||||
|
},
|
||||||
|
...defenses,
|
||||||
|
speed: formatSpeed(raw.speed),
|
||||||
|
attacks: normalizeAttacks(raw.attacks),
|
||||||
|
abilitiesTop: normalizeAbilities(raw.abilities?.top),
|
||||||
|
abilitiesMid: normalizeAbilities(raw.abilities?.mid),
|
||||||
|
abilitiesBot: normalizeAbilities(raw.abilities?.bot),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePf2eBestiary(raw: {
|
||||||
|
creature: unknown[];
|
||||||
|
}): Pf2eCreature[] {
|
||||||
|
return (raw.creature ?? [])
|
||||||
|
.filter((c: unknown) => {
|
||||||
|
const obj = c as { _copy?: unknown };
|
||||||
|
return !obj._copy;
|
||||||
|
})
|
||||||
|
.map((c) => normalizeCreature(c as RawPf2eCreature));
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type {
|
||||||
|
Pf2eBestiaryIndex,
|
||||||
|
Pf2eBestiaryIndexEntry,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
|
||||||
|
import rawIndex from "../../../../data/bestiary/pf2e-index.json";
|
||||||
|
|
||||||
|
interface CompactCreature {
|
||||||
|
readonly n: string;
|
||||||
|
readonly s: string;
|
||||||
|
readonly lv: number;
|
||||||
|
readonly ac: number;
|
||||||
|
readonly hp: number;
|
||||||
|
readonly pc: number;
|
||||||
|
readonly sz: string;
|
||||||
|
readonly tp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompactIndex {
|
||||||
|
readonly sources: Record<string, string>;
|
||||||
|
readonly creatures: readonly CompactCreature[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCreature(c: CompactCreature): Pf2eBestiaryIndexEntry {
|
||||||
|
return {
|
||||||
|
name: c.n,
|
||||||
|
source: c.s,
|
||||||
|
level: c.lv,
|
||||||
|
ac: c.ac,
|
||||||
|
hp: c.hp,
|
||||||
|
perception: c.pc,
|
||||||
|
size: c.sz,
|
||||||
|
type: c.tp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedIndex: Pf2eBestiaryIndex | undefined;
|
||||||
|
|
||||||
|
export function loadPf2eBestiaryIndex(): Pf2eBestiaryIndex {
|
||||||
|
if (cachedIndex) return cachedIndex;
|
||||||
|
|
||||||
|
const compact = rawIndex as unknown as CompactIndex;
|
||||||
|
cachedIndex = {
|
||||||
|
sources: compact.sources,
|
||||||
|
creatures: compact.creatures.map(mapCreature),
|
||||||
|
};
|
||||||
|
return cachedIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllPf2eSourceCodes(): string[] {
|
||||||
|
const index = loadPf2eBestiaryIndex();
|
||||||
|
return Object.keys(index.sources);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultPf2eFetchUrl(
|
||||||
|
sourceCode: string,
|
||||||
|
baseUrl?: string,
|
||||||
|
): string {
|
||||||
|
const filename = `creatures-${sourceCode.toLowerCase()}.json`;
|
||||||
|
if (baseUrl !== undefined) {
|
||||||
|
const normalized = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||||
|
return `${normalized}${filename}`;
|
||||||
|
}
|
||||||
|
return `https://raw.githubusercontent.com/Pf2eToolsOrg/Pf2eTools/dev/data/bestiary/${filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPf2eSourceDisplayName(sourceCode: string): string {
|
||||||
|
const index = loadPf2eBestiaryIndex();
|
||||||
|
return index.sources[sourceCode] ?? sourceCode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type {
|
||||||
|
AnyCreature,
|
||||||
|
BestiaryIndex,
|
||||||
|
CreatureId,
|
||||||
|
Encounter,
|
||||||
|
Pf2eBestiaryIndex,
|
||||||
|
PlayerCharacter,
|
||||||
|
UndoRedoState,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
|
||||||
|
export interface EncounterPersistence {
|
||||||
|
load(): Encounter | null;
|
||||||
|
save(encounter: Encounter): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UndoRedoPersistence {
|
||||||
|
load(): UndoRedoState;
|
||||||
|
save(state: UndoRedoState): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerCharacterPersistence {
|
||||||
|
load(): PlayerCharacter[];
|
||||||
|
save(characters: PlayerCharacter[]): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CachedSourceInfo {
|
||||||
|
readonly sourceCode: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly creatureCount: number;
|
||||||
|
readonly cachedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BestiaryCachePort {
|
||||||
|
cacheSource(
|
||||||
|
system: string,
|
||||||
|
sourceCode: string,
|
||||||
|
displayName: string,
|
||||||
|
creatures: AnyCreature[],
|
||||||
|
): Promise<void>;
|
||||||
|
isSourceCached(system: string, sourceCode: string): Promise<boolean>;
|
||||||
|
getCachedSources(system?: string): Promise<CachedSourceInfo[]>;
|
||||||
|
clearSource(system: string, sourceCode: string): Promise<void>;
|
||||||
|
clearAll(): Promise<void>;
|
||||||
|
loadAllCachedCreatures(): Promise<Map<CreatureId, AnyCreature>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BestiaryIndexPort {
|
||||||
|
loadIndex(): BestiaryIndex;
|
||||||
|
getAllSourceCodes(): string[];
|
||||||
|
getDefaultFetchUrl(sourceCode: string, baseUrl?: string): string;
|
||||||
|
getSourceDisplayName(sourceCode: string): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pf2eBestiaryIndexPort {
|
||||||
|
loadIndex(): Pf2eBestiaryIndex;
|
||||||
|
getAllSourceCodes(): string[];
|
||||||
|
getDefaultFetchUrl(sourceCode: string, baseUrl?: string): string;
|
||||||
|
getSourceDisplayName(sourceCode: string): string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { Adapters } from "../contexts/adapter-context.js";
|
||||||
|
import {
|
||||||
|
loadEncounter,
|
||||||
|
saveEncounter,
|
||||||
|
} from "../persistence/encounter-storage.js";
|
||||||
|
import {
|
||||||
|
loadPlayerCharacters,
|
||||||
|
savePlayerCharacters,
|
||||||
|
} from "../persistence/player-character-storage.js";
|
||||||
|
import {
|
||||||
|
loadUndoRedoStacks,
|
||||||
|
saveUndoRedoStacks,
|
||||||
|
} from "../persistence/undo-redo-storage.js";
|
||||||
|
import * as bestiaryCache from "./bestiary-cache.js";
|
||||||
|
import * as bestiaryIndex from "./bestiary-index-adapter.js";
|
||||||
|
import * as pf2eBestiaryIndex from "./pf2e-bestiary-index-adapter.js";
|
||||||
|
|
||||||
|
export const productionAdapters: Adapters = {
|
||||||
|
encounterPersistence: {
|
||||||
|
load: loadEncounter,
|
||||||
|
save: saveEncounter,
|
||||||
|
},
|
||||||
|
undoRedoPersistence: {
|
||||||
|
load: loadUndoRedoStacks,
|
||||||
|
save: saveUndoRedoStacks,
|
||||||
|
},
|
||||||
|
playerCharacterPersistence: {
|
||||||
|
load: loadPlayerCharacters,
|
||||||
|
save: savePlayerCharacters,
|
||||||
|
},
|
||||||
|
bestiaryCache: {
|
||||||
|
cacheSource: bestiaryCache.cacheSource,
|
||||||
|
isSourceCached: bestiaryCache.isSourceCached,
|
||||||
|
getCachedSources: bestiaryCache.getCachedSources,
|
||||||
|
clearSource: bestiaryCache.clearSource,
|
||||||
|
clearAll: bestiaryCache.clearAll,
|
||||||
|
loadAllCachedCreatures: bestiaryCache.loadAllCachedCreatures,
|
||||||
|
},
|
||||||
|
bestiaryIndex: {
|
||||||
|
loadIndex: bestiaryIndex.loadBestiaryIndex,
|
||||||
|
getAllSourceCodes: bestiaryIndex.getAllSourceCodes,
|
||||||
|
getDefaultFetchUrl: bestiaryIndex.getDefaultFetchUrl,
|
||||||
|
getSourceDisplayName: bestiaryIndex.getSourceDisplayName,
|
||||||
|
},
|
||||||
|
pf2eBestiaryIndex: {
|
||||||
|
loadIndex: pf2eBestiaryIndex.loadPf2eBestiaryIndex,
|
||||||
|
getAllSourceCodes: pf2eBestiaryIndex.getAllPf2eSourceCodes,
|
||||||
|
getDefaultFetchUrl: pf2eBestiaryIndex.getDefaultPf2eFetchUrl,
|
||||||
|
getSourceDisplayName: pf2eBestiaryIndex.getPf2eSourceDisplayName,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,40 +1,16 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { playerCharacterId } from "@initiative/domain";
|
||||||
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
|
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
import { ActionBar } from "../action-bar.js";
|
import { ActionBar } from "../action-bar.js";
|
||||||
|
|
||||||
// Mock persistence — no localStorage interaction
|
|
||||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
|
||||||
loadEncounter: () => null,
|
|
||||||
saveEncounter: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../persistence/player-character-storage.js", () => ({
|
|
||||||
loadPlayerCharacters: () => [],
|
|
||||||
savePlayerCharacters: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock bestiary — no IndexedDB or JSON index
|
|
||||||
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
|
||||||
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
|
||||||
isSourceCached: () => Promise.resolve(false),
|
|
||||||
cacheSource: () => Promise.resolve(),
|
|
||||||
getCachedSources: () => Promise.resolve([]),
|
|
||||||
clearSource: () => Promise.resolve(),
|
|
||||||
clearAll: () => Promise.resolve(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
|
||||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
|
||||||
getAllSourceCodes: () => [],
|
|
||||||
getDefaultFetchUrl: () => "",
|
|
||||||
getSourceDisplayName: (code: string) => code,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// DOM API stubs — jsdom doesn't implement these
|
// DOM API stubs — jsdom doesn't implement these
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
Object.defineProperty(globalThis, "matchMedia", {
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
@@ -50,16 +26,7 @@ beforeAll(() => {
|
|||||||
dispatchEvent: vi.fn(),
|
dispatchEvent: vi.fn(),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
HTMLDialogElement.prototype.showModal =
|
polyfillDialog();
|
||||||
HTMLDialogElement.prototype.showModal ||
|
|
||||||
function showModal(this: HTMLDialogElement) {
|
|
||||||
this.setAttribute("open", "");
|
|
||||||
};
|
|
||||||
HTMLDialogElement.prototype.close =
|
|
||||||
HTMLDialogElement.prototype.close ||
|
|
||||||
function close(this: HTMLDialogElement) {
|
|
||||||
this.removeAttribute("open");
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
@@ -68,121 +35,341 @@ function renderBar(props: Partial<Parameters<typeof ActionBar>[0]> = {}) {
|
|||||||
return render(<ActionBar {...props} />, { wrapper: AllProviders });
|
return render(<ActionBar {...props} />, { wrapper: AllProviders });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderBarWithBestiary(
|
||||||
|
props: Partial<Parameters<typeof ActionBar>[0]> = {},
|
||||||
|
) {
|
||||||
|
const adapters = createTestAdapters();
|
||||||
|
adapters.bestiaryIndex = {
|
||||||
|
...adapters.bestiaryIndex,
|
||||||
|
loadIndex: () => ({
|
||||||
|
sources: { MM: "Monster Manual" },
|
||||||
|
creatures: [
|
||||||
|
{
|
||||||
|
name: "Goblin",
|
||||||
|
source: "MM",
|
||||||
|
ac: 15,
|
||||||
|
hp: 7,
|
||||||
|
dex: 14,
|
||||||
|
cr: "1/4",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
size: "Small",
|
||||||
|
type: "humanoid",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Golem, Iron",
|
||||||
|
source: "MM",
|
||||||
|
ac: 20,
|
||||||
|
hp: 210,
|
||||||
|
dex: 9,
|
||||||
|
cr: "16",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
size: "Large",
|
||||||
|
type: "construct",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
getSourceDisplayName: (code: string) =>
|
||||||
|
code === "MM" ? "Monster Manual" : code,
|
||||||
|
};
|
||||||
|
return render(<ActionBar {...props} />, {
|
||||||
|
wrapper: ({ children }: { children: ReactNode }) => (
|
||||||
|
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBarWithPCs(
|
||||||
|
props: Partial<Parameters<typeof ActionBar>[0]> = {},
|
||||||
|
) {
|
||||||
|
const adapters = createTestAdapters({
|
||||||
|
playerCharacters: [
|
||||||
|
{
|
||||||
|
id: playerCharacterId("pc-1"),
|
||||||
|
name: "Gandalf",
|
||||||
|
ac: 15,
|
||||||
|
maxHp: 40,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
adapters.bestiaryIndex = {
|
||||||
|
...adapters.bestiaryIndex,
|
||||||
|
loadIndex: () => ({
|
||||||
|
sources: { MM: "Monster Manual" },
|
||||||
|
creatures: [
|
||||||
|
{
|
||||||
|
name: "Goblin",
|
||||||
|
source: "MM",
|
||||||
|
ac: 15,
|
||||||
|
hp: 7,
|
||||||
|
dex: 14,
|
||||||
|
cr: "1/4",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
size: "Small",
|
||||||
|
type: "humanoid",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
getSourceDisplayName: (code: string) =>
|
||||||
|
code === "MM" ? "Monster Manual" : code,
|
||||||
|
};
|
||||||
|
return render(<ActionBar {...props} />, {
|
||||||
|
wrapper: ({ children }: { children: ReactNode }) => (
|
||||||
|
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("ActionBar", () => {
|
describe("ActionBar", () => {
|
||||||
it("renders input with placeholder '+ Add combatants'", () => {
|
describe("basic rendering and custom add", () => {
|
||||||
renderBar();
|
it("renders input with placeholder '+ Add combatants'", () => {
|
||||||
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
|
renderBar();
|
||||||
|
expect(
|
||||||
|
screen.getByPlaceholderText("+ Add combatants"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submitting with a name adds a combatant", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBar();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "Goblin");
|
||||||
|
const addButton = screen.getByRole("button", { name: "Add" });
|
||||||
|
await user.click(addButton);
|
||||||
|
expect(input).toHaveValue("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submitting with empty name does nothing", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBar();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "{Enter}");
|
||||||
|
expect(input).toHaveValue("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows custom fields (Init, AC, MaxHP) when name >= 2 chars and no bestiary suggestions", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBar();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "Go");
|
||||||
|
expect(screen.getByPlaceholderText("Init")).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText("AC")).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText("MaxHP")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Add button when name >= 2 chars and no suggestions", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBar();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "Go");
|
||||||
|
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits custom stats with combatant", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBar();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "Fighter");
|
||||||
|
await user.type(screen.getByPlaceholderText("Init"), "15");
|
||||||
|
await user.type(screen.getByPlaceholderText("AC"), "18");
|
||||||
|
await user.type(screen.getByPlaceholderText("MaxHP"), "45");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Add" }));
|
||||||
|
expect(input).toHaveValue("");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("submitting with a name adds a combatant", async () => {
|
describe("bestiary suggestions and queuing", () => {
|
||||||
const user = userEvent.setup();
|
it("shows bestiary suggestions when typing a matching name", async () => {
|
||||||
renderBar();
|
const user = userEvent.setup();
|
||||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
renderBarWithBestiary();
|
||||||
await user.type(input, "Goblin");
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
// The Add button appears when name >= 2 chars and no suggestions
|
await user.type(input, "Go");
|
||||||
const addButton = screen.getByRole("button", { name: "Add" });
|
|
||||||
await user.click(addButton);
|
await waitFor(() => {
|
||||||
// Input is cleared after adding (context handles the state)
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
expect(input).toHaveValue("");
|
});
|
||||||
|
expect(screen.getByText("Golem, Iron")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking a suggestion queues it with count badge", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBarWithBestiary();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "Go");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click the Goblin suggestion
|
||||||
|
await user.click(screen.getByText("Goblin"));
|
||||||
|
|
||||||
|
// Should show count badge "1"
|
||||||
|
expect(screen.getByText("1")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking same suggestion again increments count", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBarWithBestiary();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "Go");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Goblin"));
|
||||||
|
await user.click(screen.getByText("Goblin"));
|
||||||
|
|
||||||
|
expect(screen.getByText("2")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("confirming queued creatures adds them to the encounter", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBarWithBestiary();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "Go");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Queue 1 Goblin
|
||||||
|
await user.click(screen.getByText("Goblin"));
|
||||||
|
|
||||||
|
// Press Enter to confirm the queued creature
|
||||||
|
await user.keyboard("{Enter}");
|
||||||
|
|
||||||
|
// Input should be cleared after confirming
|
||||||
|
expect(input).toHaveValue("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears queued when search text no longer matches", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBarWithBestiary();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "Go");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Goblin"));
|
||||||
|
expect(screen.getByText("1")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Change search to something with no matches
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, "xyz");
|
||||||
|
|
||||||
|
// Count badge should be gone
|
||||||
|
expect(screen.queryByText("1")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("submitting with empty name does nothing", async () => {
|
describe("player character matching", () => {
|
||||||
const user = userEvent.setup();
|
it("shows matching player characters in suggestions", async () => {
|
||||||
renderBar();
|
const user = userEvent.setup();
|
||||||
// Submit the form directly (Enter on empty input)
|
renderBarWithPCs();
|
||||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
await user.type(input, "{Enter}");
|
await user.type(input, "Gan");
|
||||||
// Input stays empty, no error
|
|
||||||
expect(input).toHaveValue("");
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Gandalf")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("Player")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows custom fields (Init, AC, MaxHP) when name >= 2 chars and no bestiary suggestions", async () => {
|
describe("browse mode", () => {
|
||||||
const user = userEvent.setup();
|
it("toggles browse mode via eye icon button", async () => {
|
||||||
renderBar();
|
const user = userEvent.setup();
|
||||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
renderBarWithBestiary();
|
||||||
await user.type(input, "Go");
|
|
||||||
expect(screen.getByPlaceholderText("Init")).toBeInTheDocument();
|
const browseButton = screen.getByRole("button", {
|
||||||
expect(screen.getByPlaceholderText("AC")).toBeInTheDocument();
|
name: "Browse stat blocks",
|
||||||
expect(screen.getByPlaceholderText("MaxHP")).toBeInTheDocument();
|
});
|
||||||
|
await user.click(browseButton);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByPlaceholderText("Search stat blocks..."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Switch to add mode" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("browse mode shows suggestions without add UI", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBarWithBestiary();
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Browse stat blocks" }),
|
||||||
|
);
|
||||||
|
const input = screen.getByPlaceholderText("Search stat blocks...");
|
||||||
|
await user.type(input, "Go");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
// No Add button in browse mode
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Add" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows Add button when name >= 2 chars and no suggestions", async () => {
|
describe("overflow menu", () => {
|
||||||
const user = userEvent.setup();
|
it("does not show roll all initiative button when no creature combatants", () => {
|
||||||
renderBar();
|
renderBar();
|
||||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
expect(
|
||||||
await user.type(input, "Go");
|
screen.queryByRole("button", { name: "Roll all initiative" }),
|
||||||
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not show roll all initiative button when no creature combatants", () => {
|
it("shows overflow menu items", () => {
|
||||||
renderBar();
|
renderBar({ onManagePlayers: vi.fn() });
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole("button", { name: "Roll all initiative" }),
|
screen.getByRole("button", { name: "More actions" }),
|
||||||
).not.toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows overflow menu items", () => {
|
it("opens export method dialog via overflow menu", async () => {
|
||||||
renderBar({ onManagePlayers: vi.fn() });
|
const user = userEvent.setup();
|
||||||
// The overflow menu should be present (it contains Player Characters etc.)
|
renderBar();
|
||||||
expect(
|
await user.click(screen.getByRole("button", { name: "More actions" }));
|
||||||
screen.getByRole("button", { name: "More actions" }),
|
const items = screen.getAllByText("Export Encounter");
|
||||||
).toBeInTheDocument();
|
await user.click(items[0]);
|
||||||
});
|
expect(
|
||||||
|
screen.getAllByText("Export Encounter").length,
|
||||||
|
).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("opens export method dialog via overflow menu", async () => {
|
it("opens import method dialog via overflow menu", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderBar();
|
renderBar();
|
||||||
await user.click(screen.getByRole("button", { name: "More actions" }));
|
await user.click(screen.getByRole("button", { name: "More actions" }));
|
||||||
// Click the menu item
|
const items = screen.getAllByText("Import Encounter");
|
||||||
const items = screen.getAllByText("Export Encounter");
|
await user.click(items[0]);
|
||||||
await user.click(items[0]);
|
expect(
|
||||||
// Dialog should now be open — it renders a second "Export Encounter" as heading
|
screen.getAllByText("Import Encounter").length,
|
||||||
expect(
|
).toBeGreaterThanOrEqual(1);
|
||||||
screen.getAllByText("Export Encounter").length,
|
});
|
||||||
).toBeGreaterThanOrEqual(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens import method dialog via overflow menu", async () => {
|
it("calls onManagePlayers from overflow menu", async () => {
|
||||||
const user = userEvent.setup();
|
const onManagePlayers = vi.fn();
|
||||||
renderBar();
|
const user = userEvent.setup();
|
||||||
await user.click(screen.getByRole("button", { name: "More actions" }));
|
renderBar({ onManagePlayers });
|
||||||
const items = screen.getAllByText("Import Encounter");
|
await user.click(screen.getByRole("button", { name: "More actions" }));
|
||||||
await user.click(items[0]);
|
await user.click(screen.getByText("Player Characters"));
|
||||||
expect(
|
expect(onManagePlayers).toHaveBeenCalledOnce();
|
||||||
screen.getAllByText("Import Encounter").length,
|
});
|
||||||
).toBeGreaterThanOrEqual(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls onManagePlayers from overflow menu", async () => {
|
it("calls onOpenSettings from overflow menu", async () => {
|
||||||
const onManagePlayers = vi.fn();
|
const onOpenSettings = vi.fn();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderBar({ onManagePlayers });
|
renderBar({ onOpenSettings });
|
||||||
await user.click(screen.getByRole("button", { name: "More actions" }));
|
await user.click(screen.getByRole("button", { name: "More actions" }));
|
||||||
await user.click(screen.getByText("Player Characters"));
|
await user.click(screen.getByText("Settings"));
|
||||||
expect(onManagePlayers).toHaveBeenCalledOnce();
|
expect(onOpenSettings).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onOpenSettings from overflow menu", async () => {
|
|
||||||
const onOpenSettings = vi.fn();
|
|
||||||
const user = userEvent.setup();
|
|
||||||
renderBar({ onOpenSettings });
|
|
||||||
await user.click(screen.getByRole("button", { name: "More actions" }));
|
|
||||||
await user.click(screen.getByText("Settings"));
|
|
||||||
expect(onOpenSettings).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("submits custom stats with combatant", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
renderBar();
|
|
||||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
|
||||||
await user.type(input, "Fighter");
|
|
||||||
const initInput = screen.getByPlaceholderText("Init");
|
|
||||||
const acInput = screen.getByPlaceholderText("AC");
|
|
||||||
const hpInput = screen.getByPlaceholderText("MaxHP");
|
|
||||||
await user.type(initInput, "15");
|
|
||||||
await user.type(acInput, "18");
|
|
||||||
await user.type(hpInput, "45");
|
|
||||||
await user.click(screen.getByRole("button", { name: "Add" }));
|
|
||||||
expect(input).toHaveValue("");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
|
import { AdapterProvider } from "../../contexts/adapter-context.js";
|
||||||
|
import { RulesEditionProvider } from "../../contexts/rules-edition-context.js";
|
||||||
|
import { BulkImportPrompt } from "../bulk-import-prompt.js";
|
||||||
|
|
||||||
|
const THREE_SOURCES_REGEX = /3 sources/;
|
||||||
|
const GITHUB_URL_REGEX = /raw\.githubusercontent/;
|
||||||
|
const LOADING_PROGRESS_REGEX = /Loading sources\.\.\. 4\/10/;
|
||||||
|
const SEVEN_OF_TEN_REGEX = /7\/10 sources/;
|
||||||
|
const THREE_FAILED_REGEX = /3 failed/;
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const mockFetchAndCacheSource = vi.fn();
|
||||||
|
const mockIsSourceCached = vi.fn().mockResolvedValue(false);
|
||||||
|
const mockRefreshCache = vi.fn();
|
||||||
|
const mockStartImport = vi.fn();
|
||||||
|
const mockReset = vi.fn();
|
||||||
|
const mockDismissPanel = vi.fn();
|
||||||
|
|
||||||
|
let mockImportState = {
|
||||||
|
status: "idle" as string,
|
||||||
|
total: 0,
|
||||||
|
completed: 0,
|
||||||
|
failed: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Uses context mocks because the bulk import state machine (idle → loading →
|
||||||
|
// complete → partial-failure) is impractical to drive through user interactions
|
||||||
|
// without real network calls. Consider migrating if adapter injection expands
|
||||||
|
// to cover these state transitions.
|
||||||
|
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||||
|
useBestiaryContext: () => ({
|
||||||
|
fetchAndCacheSource: mockFetchAndCacheSource,
|
||||||
|
isSourceCached: mockIsSourceCached,
|
||||||
|
refreshCache: mockRefreshCache,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../contexts/bulk-import-context.js", () => ({
|
||||||
|
useBulkImportContext: () => ({
|
||||||
|
state: mockImportState,
|
||||||
|
startImport: mockStartImport,
|
||||||
|
reset: mockReset,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../contexts/side-panel-context.js", () => ({
|
||||||
|
useSidePanelContext: () => ({
|
||||||
|
dismissPanel: mockDismissPanel,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createAdaptersWithSources() {
|
||||||
|
const adapters = createTestAdapters();
|
||||||
|
adapters.bestiaryIndex = {
|
||||||
|
...adapters.bestiaryIndex,
|
||||||
|
getAllSourceCodes: () => ["MM", "VGM", "XGE"],
|
||||||
|
};
|
||||||
|
return adapters;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWithAdapters() {
|
||||||
|
const adapters = createAdaptersWithSources();
|
||||||
|
return render(
|
||||||
|
<RulesEditionProvider>
|
||||||
|
<AdapterProvider adapters={adapters}>
|
||||||
|
<BulkImportPrompt />
|
||||||
|
</AdapterProvider>
|
||||||
|
</RulesEditionProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("BulkImportPrompt", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockImportState = { status: "idle", total: 0, completed: 0, failed: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
it("idle: shows base URL input, source count, Load All button", () => {
|
||||||
|
renderWithAdapters();
|
||||||
|
expect(screen.getByText(THREE_SOURCES_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue(GITHUB_URL_REGEX)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Load All" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("idle: clearing URL disables the button", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithAdapters();
|
||||||
|
|
||||||
|
const input = screen.getByDisplayValue(GITHUB_URL_REGEX);
|
||||||
|
await user.clear(input);
|
||||||
|
expect(screen.getByRole("button", { name: "Load All" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("idle: clicking Load All calls startImport with URL", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithAdapters();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Load All" }));
|
||||||
|
expect(mockStartImport).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("raw.githubusercontent"),
|
||||||
|
mockFetchAndCacheSource,
|
||||||
|
mockIsSourceCached,
|
||||||
|
mockRefreshCache,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loading: shows progress text and progress bar", () => {
|
||||||
|
mockImportState = {
|
||||||
|
status: "loading",
|
||||||
|
total: 10,
|
||||||
|
completed: 3,
|
||||||
|
failed: 1,
|
||||||
|
};
|
||||||
|
renderWithAdapters();
|
||||||
|
expect(screen.getByText(LOADING_PROGRESS_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("complete: shows success message and Done button", () => {
|
||||||
|
mockImportState = {
|
||||||
|
status: "complete",
|
||||||
|
total: 10,
|
||||||
|
completed: 10,
|
||||||
|
failed: 0,
|
||||||
|
};
|
||||||
|
renderWithAdapters();
|
||||||
|
expect(screen.getByText("All sources loaded")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("complete: Done calls dismissPanel and reset", async () => {
|
||||||
|
mockImportState = {
|
||||||
|
status: "complete",
|
||||||
|
total: 10,
|
||||||
|
completed: 10,
|
||||||
|
failed: 0,
|
||||||
|
};
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithAdapters();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Done" }));
|
||||||
|
expect(mockDismissPanel).toHaveBeenCalled();
|
||||||
|
expect(mockReset).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("partial-failure: shows loaded/failed counts", () => {
|
||||||
|
mockImportState = {
|
||||||
|
status: "partial-failure",
|
||||||
|
total: 10,
|
||||||
|
completed: 7,
|
||||||
|
failed: 3,
|
||||||
|
};
|
||||||
|
renderWithAdapters();
|
||||||
|
expect(screen.getByText(SEVEN_OF_TEN_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(THREE_FAILED_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { VALID_PLAYER_COLORS } from "@initiative/domain";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
import { ColorPalette } from "../color-palette.js";
|
||||||
|
|
||||||
|
describe("ColorPalette", () => {
|
||||||
|
it("renders a button for each valid color", () => {
|
||||||
|
render(<ColorPalette value="" onChange={() => {}} />);
|
||||||
|
const buttons = screen.getAllByRole("button");
|
||||||
|
expect(buttons).toHaveLength(VALID_PLAYER_COLORS.size);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("each button has an aria-label matching the color name", () => {
|
||||||
|
render(<ColorPalette value="" onChange={() => {}} />);
|
||||||
|
for (const color of VALID_PLAYER_COLORS) {
|
||||||
|
expect(screen.getByRole("button", { name: color })).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking a color calls onChange with that color", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<ColorPalette value="" onChange={onChange} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "blue" }));
|
||||||
|
expect(onChange).toHaveBeenCalledWith("blue");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking the selected color deselects it", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<ColorPalette value="red" onChange={onChange} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "red" }));
|
||||||
|
expect(onChange).toHaveBeenCalledWith("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selected color has ring styling", () => {
|
||||||
|
render(<ColorPalette value="green" onChange={() => {}} />);
|
||||||
|
const selected = screen.getByRole("button", { name: "green" });
|
||||||
|
expect(selected.className).toContain("ring-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("non-selected colors do not have ring styling", () => {
|
||||||
|
render(<ColorPalette value="green" onChange={() => {}} />);
|
||||||
|
const other = screen.getByRole("button", { name: "blue" });
|
||||||
|
expect(other.className).not.toContain("ring-2");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,34 +10,8 @@ import { CombatantRow } from "../combatant-row.js";
|
|||||||
import { PLAYER_COLOR_HEX } from "../player-icon-map.js";
|
import { PLAYER_COLOR_HEX } from "../player-icon-map.js";
|
||||||
|
|
||||||
const TEMP_HP_REGEX = /^\+\d/;
|
const TEMP_HP_REGEX = /^\+\d/;
|
||||||
|
const CURRENT_HP_7_REGEX = /Current HP: 7/;
|
||||||
// Mock persistence — no localStorage interaction
|
const CURRENT_HP_REGEX = /Current HP/;
|
||||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
|
||||||
loadEncounter: () => null,
|
|
||||||
saveEncounter: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../persistence/player-character-storage.js", () => ({
|
|
||||||
loadPlayerCharacters: () => [],
|
|
||||||
savePlayerCharacters: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock bestiary — no IndexedDB or JSON index
|
|
||||||
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
|
||||||
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
|
||||||
isSourceCached: () => Promise.resolve(false),
|
|
||||||
cacheSource: () => Promise.resolve(),
|
|
||||||
getCachedSources: () => Promise.resolve([]),
|
|
||||||
clearSource: () => Promise.resolve(),
|
|
||||||
clearAll: () => Promise.resolve(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
|
||||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
|
||||||
getAllSourceCodes: () => [],
|
|
||||||
getDefaultFetchUrl: () => "",
|
|
||||||
getSourceDisplayName: (code: string) => code,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// DOM API stubs
|
// DOM API stubs
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
@@ -257,6 +231,172 @@ describe("CombatantRow", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("inline name editing", () => {
|
||||||
|
it("click rename → type new name → blur commits rename", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderRow();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Rename" }));
|
||||||
|
const input = screen.getByDisplayValue("Goblin");
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, "Hobgoblin");
|
||||||
|
await user.tab(); // blur
|
||||||
|
// The input should be gone, name committed
|
||||||
|
expect(screen.queryByDisplayValue("Hobgoblin")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Escape cancels without renaming", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderRow();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Rename" }));
|
||||||
|
const input = screen.getByDisplayValue("Goblin");
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, "Changed");
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
// Should revert to showing the original name
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("inline AC editing", () => {
|
||||||
|
it("click AC → type value → Enter commits", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
ac: 13,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click the AC shield button
|
||||||
|
const acButton = screen.getByText("13").closest("button");
|
||||||
|
expect(acButton).not.toBeNull();
|
||||||
|
await user.click(acButton as HTMLElement);
|
||||||
|
const input = screen.getByDisplayValue("13");
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, "16");
|
||||||
|
await user.keyboard("{Enter}");
|
||||||
|
expect(screen.queryByDisplayValue("16")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("inline max HP editing", () => {
|
||||||
|
it("click max HP → type value → blur commits", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 10,
|
||||||
|
currentHp: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// The max HP button shows "10" as muted text
|
||||||
|
const maxHpButton = screen
|
||||||
|
.getAllByText("10")
|
||||||
|
.find(
|
||||||
|
(el) => el.closest("button") && el.className.includes("text-muted"),
|
||||||
|
);
|
||||||
|
expect(maxHpButton).toBeDefined();
|
||||||
|
const maxHpBtn = (maxHpButton as HTMLElement).closest("button");
|
||||||
|
expect(maxHpBtn).not.toBeNull();
|
||||||
|
await user.click(maxHpBtn as HTMLElement);
|
||||||
|
const input = screen.getByDisplayValue("10");
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, "25");
|
||||||
|
await user.tab();
|
||||||
|
expect(screen.queryByDisplayValue("25")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("inline initiative editing", () => {
|
||||||
|
it("click initiative → type value → Enter commits", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
initiative: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByText("15"));
|
||||||
|
const input = screen.getByDisplayValue("15");
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, "20");
|
||||||
|
await user.keyboard("{Enter}");
|
||||||
|
expect(screen.queryByDisplayValue("20")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearing initiative and pressing Enter commits the edit", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
initiative: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByText("15"));
|
||||||
|
const input = screen.getByDisplayValue("15");
|
||||||
|
await user.clear(input);
|
||||||
|
await user.keyboard("{Enter}");
|
||||||
|
// Input should be dismissed (editing mode exited)
|
||||||
|
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HP popover", () => {
|
||||||
|
it("clicking current HP opens the HP adjust popover", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 10,
|
||||||
|
currentHp: 7,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hpButton = screen.getByLabelText(CURRENT_HP_7_REGEX);
|
||||||
|
await user.click(hpButton);
|
||||||
|
// The popover should appear with damage/heal controls
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Apply damage" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Apply healing" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("HP section is absent when maxHp is undefined", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(screen.queryByLabelText(CURRENT_HP_REGEX)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("condition picker", () => {
|
||||||
|
it("clicking Add condition button opens the picker", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderRow();
|
||||||
|
const addButton = screen.getByRole("button", {
|
||||||
|
name: "Add condition",
|
||||||
|
});
|
||||||
|
await user.click(addButton);
|
||||||
|
// Condition picker should render with condition options
|
||||||
|
expect(screen.getByText("Blinded")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("temp HP display", () => {
|
describe("temp HP display", () => {
|
||||||
it("shows +N when combatant has temp HP", () => {
|
it("shows +N when combatant has temp HP", () => {
|
||||||
renderRow({
|
renderRow({
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
|
import {
|
||||||
|
type ConditionEntry,
|
||||||
|
type ConditionId,
|
||||||
|
getConditionsForEdition,
|
||||||
|
} from "@initiative/domain";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { createRef, type RefObject } from "react";
|
import { createRef, type RefObject } from "react";
|
||||||
@@ -13,12 +17,14 @@ afterEach(cleanup);
|
|||||||
|
|
||||||
function renderPicker(
|
function renderPicker(
|
||||||
overrides: Partial<{
|
overrides: Partial<{
|
||||||
activeConditions: readonly ConditionId[];
|
activeConditions: readonly ConditionEntry[];
|
||||||
onToggle: (conditionId: ConditionId) => void;
|
onToggle: (conditionId: ConditionId) => void;
|
||||||
|
onSetValue: (conditionId: ConditionId, value: number) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}> = {},
|
}> = {},
|
||||||
) {
|
) {
|
||||||
const onToggle = overrides.onToggle ?? vi.fn();
|
const onToggle = overrides.onToggle ?? vi.fn();
|
||||||
|
const onSetValue = overrides.onSetValue ?? vi.fn();
|
||||||
const onClose = overrides.onClose ?? vi.fn();
|
const onClose = overrides.onClose ?? vi.fn();
|
||||||
const anchorRef = createRef<HTMLElement>() as RefObject<HTMLElement>;
|
const anchorRef = createRef<HTMLElement>() as RefObject<HTMLElement>;
|
||||||
const anchor = document.createElement("div");
|
const anchor = document.createElement("div");
|
||||||
@@ -30,25 +36,27 @@ function renderPicker(
|
|||||||
anchorRef={anchorRef}
|
anchorRef={anchorRef}
|
||||||
activeConditions={overrides.activeConditions ?? []}
|
activeConditions={overrides.activeConditions ?? []}
|
||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
|
onSetValue={onSetValue}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
/>
|
/>
|
||||||
</RulesEditionProvider>,
|
</RulesEditionProvider>,
|
||||||
);
|
);
|
||||||
return { ...result, onToggle, onClose };
|
return { ...result, onToggle, onSetValue, onClose };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("ConditionPicker", () => {
|
describe("ConditionPicker", () => {
|
||||||
it("renders all condition definitions from domain", () => {
|
it("renders edition-specific conditions from domain", () => {
|
||||||
renderPicker();
|
renderPicker();
|
||||||
for (const def of CONDITION_DEFINITIONS) {
|
const editionConditions = getConditionsForEdition("5.5e");
|
||||||
|
for (const def of editionConditions) {
|
||||||
expect(screen.getByText(def.label)).toBeInTheDocument();
|
expect(screen.getByText(def.label)).toBeInTheDocument();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("active conditions are visually distinguished", () => {
|
it("active conditions are visually distinguished", () => {
|
||||||
renderPicker({ activeConditions: ["blinded"] });
|
renderPicker({ activeConditions: [{ id: "blinded" }] });
|
||||||
const blindedButton = screen.getByText("Blinded").closest("button");
|
const row = screen.getByText("Blinded").closest("div[class]");
|
||||||
expect(blindedButton?.className).toContain("bg-card/50");
|
expect(row?.className).toContain("bg-card/50");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clicking a condition calls onToggle with that condition's ID", async () => {
|
it("clicking a condition calls onToggle with that condition's ID", async () => {
|
||||||
@@ -65,7 +73,7 @@ describe("ConditionPicker", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("active condition labels use foreground color", () => {
|
it("active condition labels use foreground color", () => {
|
||||||
renderPicker({ activeConditions: ["charmed"] });
|
renderPicker({ activeConditions: [{ id: "charmed" }] });
|
||||||
const label = screen.getByText("Charmed");
|
const label = screen.getByText("Charmed");
|
||||||
expect(label.className).toContain("text-foreground");
|
expect(label.className).toContain("text-foreground");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,38 +1,36 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import type { ConditionId } from "@initiative/domain";
|
import type { ConditionEntry } from "@initiative/domain";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import { userEvent } from "@testing-library/user-event";
|
import { userEvent } from "@testing-library/user-event";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { RulesEditionProvider } from "../../contexts/rules-edition-context.js";
|
||||||
import { ConditionTags } from "../condition-tags.js";
|
import { ConditionTags } from "../condition-tags.js";
|
||||||
|
|
||||||
vi.mock("../../contexts/rules-edition-context.js", () => ({
|
|
||||||
useRulesEditionContext: () => ({ edition: "5.5e" }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function renderTags(props: Partial<Parameters<typeof ConditionTags>[0]> = {}) {
|
||||||
|
return render(
|
||||||
|
<RulesEditionProvider>
|
||||||
|
<ConditionTags
|
||||||
|
conditions={props.conditions}
|
||||||
|
onRemove={props.onRemove ?? (() => {})}
|
||||||
|
onDecrement={props.onDecrement ?? (() => {})}
|
||||||
|
onOpenPicker={props.onOpenPicker ?? (() => {})}
|
||||||
|
/>
|
||||||
|
</RulesEditionProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe("ConditionTags", () => {
|
describe("ConditionTags", () => {
|
||||||
it("renders nothing when conditions is undefined", () => {
|
it("renders nothing when conditions is undefined", () => {
|
||||||
const { container } = render(
|
const { container } = renderTags();
|
||||||
<ConditionTags
|
|
||||||
conditions={undefined}
|
|
||||||
onRemove={() => {}}
|
|
||||||
onOpenPicker={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
// Only the add button should be present
|
// Only the add button should be present
|
||||||
expect(container.querySelectorAll("button")).toHaveLength(1);
|
expect(container.querySelectorAll("button")).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a button per condition", () => {
|
it("renders a button per condition", () => {
|
||||||
const conditions: ConditionId[] = ["blinded", "prone"];
|
const conditions: ConditionEntry[] = [{ id: "blinded" }, { id: "prone" }];
|
||||||
render(
|
renderTags({ conditions });
|
||||||
<ConditionTags
|
|
||||||
conditions={conditions}
|
|
||||||
onRemove={() => {}}
|
|
||||||
onOpenPicker={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Remove Blinded" }),
|
screen.getByRole("button", { name: "Remove Blinded" }),
|
||||||
).toBeDefined();
|
).toBeDefined();
|
||||||
@@ -41,13 +39,10 @@ describe("ConditionTags", () => {
|
|||||||
|
|
||||||
it("calls onRemove with condition id when clicked", async () => {
|
it("calls onRemove with condition id when clicked", async () => {
|
||||||
const onRemove = vi.fn();
|
const onRemove = vi.fn();
|
||||||
render(
|
renderTags({
|
||||||
<ConditionTags
|
conditions: [{ id: "blinded" }] as ConditionEntry[],
|
||||||
conditions={["blinded"] as ConditionId[]}
|
onRemove,
|
||||||
onRemove={onRemove}
|
});
|
||||||
onOpenPicker={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await userEvent.click(
|
await userEvent.click(
|
||||||
screen.getByRole("button", { name: "Remove Blinded" }),
|
screen.getByRole("button", { name: "Remove Blinded" }),
|
||||||
@@ -58,13 +53,7 @@ describe("ConditionTags", () => {
|
|||||||
|
|
||||||
it("calls onOpenPicker when add button is clicked", async () => {
|
it("calls onOpenPicker when add button is clicked", async () => {
|
||||||
const onOpenPicker = vi.fn();
|
const onOpenPicker = vi.fn();
|
||||||
render(
|
renderTags({ conditions: [], onOpenPicker });
|
||||||
<ConditionTags
|
|
||||||
conditions={[]}
|
|
||||||
onRemove={() => {}}
|
|
||||||
onOpenPicker={onOpenPicker}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await userEvent.click(
|
await userEvent.click(
|
||||||
screen.getByRole("button", { name: "Add condition" }),
|
screen.getByRole("button", { name: "Add condition" }),
|
||||||
@@ -74,14 +63,41 @@ describe("ConditionTags", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders empty conditions array without errors", () => {
|
it("renders empty conditions array without errors", () => {
|
||||||
render(
|
renderTags({ conditions: [] });
|
||||||
<ConditionTags
|
|
||||||
conditions={[]}
|
|
||||||
onRemove={() => {}}
|
|
||||||
onOpenPicker={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
// Only add button
|
// Only add button
|
||||||
expect(screen.getByRole("button", { name: "Add condition" })).toBeDefined();
|
expect(screen.getByRole("button", { name: "Add condition" })).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("displays value badge for valued conditions", () => {
|
||||||
|
renderTags({ conditions: [{ id: "frightened", value: 3 }] });
|
||||||
|
expect(screen.getByText("3")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onDecrement for valued condition click", async () => {
|
||||||
|
const onDecrement = vi.fn();
|
||||||
|
renderTags({
|
||||||
|
conditions: [{ id: "frightened", value: 2 }],
|
||||||
|
onDecrement,
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Remove Frightened" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onDecrement).toHaveBeenCalledWith("frightened");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onRemove for non-valued condition click", async () => {
|
||||||
|
const onRemove = vi.fn();
|
||||||
|
renderTags({
|
||||||
|
conditions: [{ id: "blinded" }],
|
||||||
|
onRemove,
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Remove Blinded" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onRemove).toHaveBeenCalledWith("blinded");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,19 +4,11 @@ import { playerCharacterId } from "@initiative/domain";
|
|||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import { userEvent } from "@testing-library/user-event";
|
import { userEvent } from "@testing-library/user-event";
|
||||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||||
import { CreatePlayerModal } from "../create-player-modal.js";
|
import { CreatePlayerModal } from "../create-player-modal.js";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
HTMLDialogElement.prototype.showModal =
|
polyfillDialog();
|
||||||
HTMLDialogElement.prototype.showModal ||
|
|
||||||
function showModal(this: HTMLDialogElement) {
|
|
||||||
this.setAttribute("open", "");
|
|
||||||
};
|
|
||||||
HTMLDialogElement.prototype.close =
|
|
||||||
HTMLDialogElement.prototype.close ||
|
|
||||||
function close(this: HTMLDialogElement) {
|
|
||||||
this.removeAttribute("open");
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|||||||
@@ -2,19 +2,11 @@
|
|||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import { userEvent } from "@testing-library/user-event";
|
import { userEvent } from "@testing-library/user-event";
|
||||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||||
import { Dialog, DialogHeader } from "../ui/dialog.js";
|
import { Dialog, DialogHeader } from "../ui/dialog.js";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
HTMLDialogElement.prototype.showModal =
|
polyfillDialog();
|
||||||
HTMLDialogElement.prototype.showModal ||
|
|
||||||
function showModal(this: HTMLDialogElement) {
|
|
||||||
this.setAttribute("open", "");
|
|
||||||
};
|
|
||||||
HTMLDialogElement.prototype.close =
|
|
||||||
HTMLDialogElement.prototype.close ||
|
|
||||||
function close(this: HTMLDialogElement) {
|
|
||||||
this.removeAttribute("open");
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||||
|
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||||
|
import {
|
||||||
|
cleanup,
|
||||||
|
render,
|
||||||
|
renderHook,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
|
import {
|
||||||
|
buildCombatant,
|
||||||
|
buildCreature,
|
||||||
|
buildEncounter,
|
||||||
|
} from "../../__tests__/factories/index.js";
|
||||||
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
|
import { useRulesEdition } from "../../hooks/use-rules-edition.js";
|
||||||
|
import { DifficultyBreakdownPanel } from "../difficulty-breakdown-panel.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const pcId1 = playerCharacterId("pc-1");
|
||||||
|
const goblinCreature = buildCreature({
|
||||||
|
id: creatureId("srd:goblin"),
|
||||||
|
name: "Goblin",
|
||||||
|
cr: "1/4",
|
||||||
|
source: "srd",
|
||||||
|
sourceDisplayName: "SRD",
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderPanel(options: {
|
||||||
|
encounter: ReturnType<typeof buildEncounter>;
|
||||||
|
playerCharacters?: PlayerCharacter[];
|
||||||
|
creatures?: Map<CreatureId, Creature>;
|
||||||
|
onClose?: () => void;
|
||||||
|
}) {
|
||||||
|
const adapters = createTestAdapters({
|
||||||
|
encounter: options.encounter,
|
||||||
|
playerCharacters: options.playerCharacters ?? [],
|
||||||
|
creatures: options.creatures,
|
||||||
|
});
|
||||||
|
return render(
|
||||||
|
<AllProviders adapters={adapters}>
|
||||||
|
<DifficultyBreakdownPanel onClose={options.onClose ?? (() => {})} />
|
||||||
|
</AllProviders>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultEncounter() {
|
||||||
|
return buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: goblinCreature.id,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-3"),
|
||||||
|
name: "Custom Thug",
|
||||||
|
cr: "2",
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-4"),
|
||||||
|
name: "Bandit",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultPCs: PlayerCharacter[] = [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("DifficultyBreakdownPanel", () => {
|
||||||
|
it("renders party budget section", async () => {
|
||||||
|
renderPanel({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("Party Budget", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1 PC", { exact: false })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Low:", { exact: false })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders tier label", async () => {
|
||||||
|
renderPanel({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("Encounter Difficulty:", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows PC in party column with level", async () => {
|
||||||
|
renderPanel({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Hero")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Lv 5")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows monsters in enemy column", async () => {
|
||||||
|
renderPanel({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("CR 1/4").length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders explanation text", async () => {
|
||||||
|
renderPanel({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
"Allied NPC XP is subtracted from encounter difficulty",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Net Monster XP footer", async () => {
|
||||||
|
renderPanel({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Net Monster XP")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders custom combatant with CR picker in enemy column", async () => {
|
||||||
|
renderPanel({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const pickers = screen.getAllByLabelText("Challenge rating");
|
||||||
|
expect(pickers).toHaveLength(2);
|
||||||
|
expect(pickers[0]).toHaveValue("2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selecting a CR updates the visible XP value", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPanel({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getAllByLabelText("Challenge rating")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
const pickers = screen.getAllByLabelText("Challenge rating");
|
||||||
|
await user.selectOptions(pickers[1], "5");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("1,800")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("non-PC combatants show toggle button", async () => {
|
||||||
|
renderPanel({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Each non-PC enemy combatant has a toggle button
|
||||||
|
expect(
|
||||||
|
screen.getByLabelText("Move Goblin to party side"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByLabelText("Move Custom Thug to party side"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PC combatants do not show side toggle", async () => {
|
||||||
|
renderPanel({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Hero")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByLabelText("Move Hero to enemy side"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("side toggle moves combatant between sections", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPanel({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle goblin to party side
|
||||||
|
const toggleBtn = screen.getByLabelText("Move Goblin to party side");
|
||||||
|
await user.click(toggleBtn);
|
||||||
|
|
||||||
|
// After toggle, the aria-label should change to "Move Goblin to enemy side"
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByLabelText("Move Goblin to enemy side"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when breakdown data is insufficient", () => {
|
||||||
|
const { container } = renderPanel({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({ id: combatantId("c-1"), name: "Custom" }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.innerHTML).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 4 threshold columns for 2014 edition", async () => {
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||||
|
editionResult.current.setEdition("5e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
renderPanel({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Easy:", { exact: false })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Med:", { exact: false })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Hard:", { exact: false })).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Deadly:", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows multiplier and adjusted XP for 2014 edition", async () => {
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||||
|
editionResult.current.setEdition("5e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
renderPanel({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Monster XP")).toBeInTheDocument();
|
||||||
|
// 1 PC (<3) triggers party size adjustment
|
||||||
|
expect(screen.getByText("Adjusted for 1 PC")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Net Monster XP for 5.5e edition (no multiplier)", async () => {
|
||||||
|
renderPanel({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Net Monster XP")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClose when Escape is pressed", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderPanel({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
onClose,
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import type { DifficultyResult } from "@initiative/domain";
|
import type { DifficultyResult } from "@initiative/domain";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { DifficultyIndicator } from "../difficulty-indicator.js";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
DifficultyIndicator,
|
||||||
|
TIER_LABELS_5_5E,
|
||||||
|
TIER_LABELS_2014,
|
||||||
|
} from "../difficulty-indicator.js";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
@@ -10,50 +15,114 @@ function makeResult(tier: DifficultyResult["tier"]): DifficultyResult {
|
|||||||
return {
|
return {
|
||||||
tier,
|
tier,
|
||||||
totalMonsterXp: 100,
|
totalMonsterXp: 100,
|
||||||
partyBudget: { low: 50, moderate: 100, high: 200 },
|
thresholds: [
|
||||||
|
{ label: "Low", value: 50 },
|
||||||
|
{ label: "Moderate", value: 100 },
|
||||||
|
{ label: "High", value: 200 },
|
||||||
|
],
|
||||||
|
encounterMultiplier: undefined,
|
||||||
|
adjustedXp: undefined,
|
||||||
|
partySizeAdjusted: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("DifficultyIndicator", () => {
|
describe("DifficultyIndicator", () => {
|
||||||
it("renders 3 bars", () => {
|
it("renders 3 bars", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<DifficultyIndicator result={makeResult("moderate")} />,
|
<DifficultyIndicator result={makeResult(2)} labels={TIER_LABELS_5_5E} />,
|
||||||
);
|
);
|
||||||
const bars = container.querySelectorAll("[class*='rounded-sm']");
|
const bars = container.querySelectorAll("[class*='rounded-sm']");
|
||||||
expect(bars).toHaveLength(3);
|
expect(bars).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows 'Trivial encounter difficulty' label for trivial tier", () => {
|
it("shows 'Trivial encounter difficulty' for 5.5e tier 0", () => {
|
||||||
render(<DifficultyIndicator result={makeResult("trivial")} />);
|
render(
|
||||||
|
<DifficultyIndicator result={makeResult(0)} labels={TIER_LABELS_5_5E} />,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("img", {
|
screen.getByRole("img", { name: "Trivial encounter difficulty" }),
|
||||||
name: "Trivial encounter difficulty",
|
|
||||||
}),
|
|
||||||
).toBeDefined();
|
).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows 'Low encounter difficulty' label for low tier", () => {
|
it("shows 'Low encounter difficulty' for 5.5e tier 1", () => {
|
||||||
render(<DifficultyIndicator result={makeResult("low")} />);
|
render(
|
||||||
|
<DifficultyIndicator result={makeResult(1)} labels={TIER_LABELS_5_5E} />,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("img", { name: "Low encounter difficulty" }),
|
screen.getByRole("img", { name: "Low encounter difficulty" }),
|
||||||
).toBeDefined();
|
).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows 'Moderate encounter difficulty' label for moderate tier", () => {
|
it("shows 'Moderate encounter difficulty' for 5.5e tier 2", () => {
|
||||||
render(<DifficultyIndicator result={makeResult("moderate")} />);
|
render(
|
||||||
|
<DifficultyIndicator result={makeResult(2)} labels={TIER_LABELS_5_5E} />,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("img", {
|
screen.getByRole("img", { name: "Moderate encounter difficulty" }),
|
||||||
name: "Moderate encounter difficulty",
|
|
||||||
}),
|
|
||||||
).toBeDefined();
|
).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows 'High encounter difficulty' label for high tier", () => {
|
it("shows 'High encounter difficulty' for 5.5e tier 3", () => {
|
||||||
render(<DifficultyIndicator result={makeResult("high")} />);
|
render(
|
||||||
|
<DifficultyIndicator result={makeResult(3)} labels={TIER_LABELS_5_5E} />,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("img", {
|
screen.getByRole("img", { name: "High encounter difficulty" }),
|
||||||
name: "High encounter difficulty",
|
|
||||||
}),
|
|
||||||
).toBeDefined();
|
).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows 'Easy encounter difficulty' for 2014 tier 0", () => {
|
||||||
|
render(
|
||||||
|
<DifficultyIndicator result={makeResult(0)} labels={TIER_LABELS_2014} />,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("img", { name: "Easy encounter difficulty" }),
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'Deadly encounter difficulty' for 2014 tier 3", () => {
|
||||||
|
render(
|
||||||
|
<DifficultyIndicator result={makeResult(3)} labels={TIER_LABELS_2014} />,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("img", { name: "Deadly encounter difficulty" }),
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClick when clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const handleClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<DifficultyIndicator
|
||||||
|
result={makeResult(2)}
|
||||||
|
labels={TIER_LABELS_5_5E}
|
||||||
|
onClick={handleClick}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("img", { name: "Moderate encounter difficulty" }),
|
||||||
|
);
|
||||||
|
expect(handleClick).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders as div when onClick not provided", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<DifficultyIndicator result={makeResult(2)} labels={TIER_LABELS_5_5E} />,
|
||||||
|
);
|
||||||
|
const element = container.querySelector("[role='img']");
|
||||||
|
expect(element?.tagName).toBe("DIV");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders as button when onClick provided", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<DifficultyIndicator
|
||||||
|
result={makeResult(2)}
|
||||||
|
labels={TIER_LABELS_5_5E}
|
||||||
|
onClick={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const element = container.querySelector("[role='img']");
|
||||||
|
expect(element?.tagName).toBe("BUTTON");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||||
|
import { ExportMethodDialog } from "../export-method-dialog.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
polyfillDialog();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderDialog(open = true) {
|
||||||
|
const onDownload = vi.fn();
|
||||||
|
const onCopyToClipboard = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const result = render(
|
||||||
|
<ExportMethodDialog
|
||||||
|
open={open}
|
||||||
|
onDownload={onDownload}
|
||||||
|
onCopyToClipboard={onCopyToClipboard}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return { ...result, onDownload, onCopyToClipboard, onClose };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ExportMethodDialog", () => {
|
||||||
|
it("renders filename input and unchecked history checkbox", () => {
|
||||||
|
renderDialog();
|
||||||
|
expect(
|
||||||
|
screen.getByPlaceholderText("Filename (optional)"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
const checkbox = screen.getByRole("checkbox");
|
||||||
|
expect(checkbox).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("download button calls onDownload with defaults", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onDownload } = renderDialog();
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Download file"));
|
||||||
|
expect(onDownload).toHaveBeenCalledWith(false, "");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("download with filename and history checked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onDownload } = renderDialog();
|
||||||
|
|
||||||
|
await user.type(
|
||||||
|
screen.getByPlaceholderText("Filename (optional)"),
|
||||||
|
"my-encounter",
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("checkbox"));
|
||||||
|
await user.click(screen.getByText("Download file"));
|
||||||
|
expect(onDownload).toHaveBeenCalledWith(true, "my-encounter");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copy to clipboard calls onCopyToClipboard and shows Copied", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onCopyToClipboard } = renderDialog();
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Copy to clipboard"));
|
||||||
|
expect(onCopyToClipboard).toHaveBeenCalledWith(false);
|
||||||
|
expect(screen.getByText("Copied!")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Copied! reverts after 2 seconds", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Copy to clipboard"));
|
||||||
|
expect(screen.getByText("Copied!")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(screen.queryByText("Copied!")).not.toBeInTheDocument();
|
||||||
|
},
|
||||||
|
{ timeout: 3000 },
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Copy to clipboard")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||||
|
import { ImportMethodDialog } from "../import-method-dialog.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
polyfillDialog();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function renderDialog(open = true) {
|
||||||
|
const onSelectFile = vi.fn();
|
||||||
|
const onSubmitClipboard = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const result = render(
|
||||||
|
<ImportMethodDialog
|
||||||
|
open={open}
|
||||||
|
onSelectFile={onSelectFile}
|
||||||
|
onSubmitClipboard={onSubmitClipboard}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return { ...result, onSelectFile, onSubmitClipboard, onClose };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ImportMethodDialog", () => {
|
||||||
|
it("opens in pick mode with two method buttons", () => {
|
||||||
|
renderDialog();
|
||||||
|
expect(screen.getByText("From file")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Paste content")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("From file button calls onSelectFile and closes", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onSelectFile, onClose } = renderDialog();
|
||||||
|
|
||||||
|
await user.click(screen.getByText("From file"));
|
||||||
|
expect(onSelectFile).toHaveBeenCalled();
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Paste content button switches to paste mode", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Paste content"));
|
||||||
|
expect(
|
||||||
|
screen.getByPlaceholderText("Paste exported JSON here..."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Import" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("typing text enables Import button", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Paste content"));
|
||||||
|
const textarea = screen.getByPlaceholderText("Paste exported JSON here...");
|
||||||
|
await user.type(textarea, "test-data");
|
||||||
|
expect(screen.getByRole("button", { name: "Import" })).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Import calls onSubmitClipboard with text and closes", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onSubmitClipboard, onClose } = renderDialog();
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Paste content"));
|
||||||
|
await user.type(
|
||||||
|
screen.getByPlaceholderText("Paste exported JSON here..."),
|
||||||
|
"some-json-content",
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Import" }));
|
||||||
|
expect(onSubmitClipboard).toHaveBeenCalledWith("some-json-content");
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Back button returns to pick mode and clears text", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Paste content"));
|
||||||
|
await user.type(
|
||||||
|
screen.getByPlaceholderText("Paste exported JSON here..."),
|
||||||
|
"some text",
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("From file")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByPlaceholderText("Paste exported JSON here..."),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { createRef } from "react";
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||||
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
|
import {
|
||||||
|
PlayerCharacterSection,
|
||||||
|
type PlayerCharacterSectionHandle,
|
||||||
|
} from "../player-character-section.js";
|
||||||
|
|
||||||
|
const CREATE_FIRST_PC_REGEX = /create your first player character/i;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
polyfillDialog();
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function renderSection() {
|
||||||
|
const ref = createRef<PlayerCharacterSectionHandle>();
|
||||||
|
const result = render(<PlayerCharacterSection ref={ref} />, {
|
||||||
|
wrapper: AllProviders,
|
||||||
|
});
|
||||||
|
return { ...result, ref };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PlayerCharacterSection", () => {
|
||||||
|
it("openManagement ref handle opens the management dialog", async () => {
|
||||||
|
const { ref } = renderSection();
|
||||||
|
|
||||||
|
const handle = ref.current;
|
||||||
|
if (!handle) throw new Error("ref not set");
|
||||||
|
act(() => handle.openManagement());
|
||||||
|
|
||||||
|
// Management dialog should now be open with its title visible
|
||||||
|
await waitFor(() => {
|
||||||
|
const dialogs = document.querySelectorAll("dialog");
|
||||||
|
const managementDialog = Array.from(dialogs).find((d) =>
|
||||||
|
d.textContent?.includes("Player Characters"),
|
||||||
|
);
|
||||||
|
expect(managementDialog).toHaveAttribute("open");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creating a character from management opens create modal", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { ref } = renderSection();
|
||||||
|
|
||||||
|
const handle = ref.current;
|
||||||
|
if (!handle) throw new Error("ref not set");
|
||||||
|
act(() => handle.openManagement());
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: CREATE_FIRST_PC_REGEX,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create modal should now be visible
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByPlaceholderText("Character name")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saving a new character and returning to management", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { ref } = renderSection();
|
||||||
|
|
||||||
|
const handle = ref.current;
|
||||||
|
if (!handle) throw new Error("ref not set");
|
||||||
|
act(() => handle.openManagement());
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: CREATE_FIRST_PC_REGEX,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fill in the create form
|
||||||
|
await user.type(screen.getByPlaceholderText("Character name"), "Aria");
|
||||||
|
await user.type(screen.getByPlaceholderText("AC"), "16");
|
||||||
|
await user.type(screen.getByPlaceholderText("Max HP"), "30");
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||||
|
|
||||||
|
// Should return to management dialog showing the new character
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Aria")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { type PlayerCharacter, playerCharacterId } from "@initiative/domain";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const CREATE_FIRST_PC_REGEX = /create your first player character/i;
|
||||||
|
const LEVEL_REGEX = /^Lv /;
|
||||||
|
|
||||||
|
import { PlayerManagement } from "../player-management.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
polyfillDialog();
|
||||||
|
});
|
||||||
|
|
||||||
|
const PC_WARRIOR: PlayerCharacter = {
|
||||||
|
id: playerCharacterId("pc-1"),
|
||||||
|
name: "Thorin",
|
||||||
|
ac: 18,
|
||||||
|
maxHp: 45,
|
||||||
|
color: "red",
|
||||||
|
icon: "sword",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PC_WIZARD: PlayerCharacter = {
|
||||||
|
id: playerCharacterId("pc-2"),
|
||||||
|
name: "Gandalf",
|
||||||
|
ac: 12,
|
||||||
|
maxHp: 30,
|
||||||
|
color: "blue",
|
||||||
|
icon: "wand",
|
||||||
|
level: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderManagement(
|
||||||
|
overrides: Partial<Parameters<typeof PlayerManagement>[0]> = {},
|
||||||
|
) {
|
||||||
|
const props = {
|
||||||
|
open: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
characters: [] as readonly PlayerCharacter[],
|
||||||
|
onEdit: vi.fn(),
|
||||||
|
onDelete: vi.fn(),
|
||||||
|
onCreate: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
return { ...render(<PlayerManagement {...props} />), props };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PlayerManagement", () => {
|
||||||
|
it("shows empty state when no characters", () => {
|
||||||
|
renderManagement();
|
||||||
|
expect(screen.getByText("No player characters yet")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows create button in empty state that calls onCreate", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { props } = renderManagement();
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: CREATE_FIRST_PC_REGEX,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(props.onCreate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders each character with name, AC, HP", () => {
|
||||||
|
renderManagement({ characters: [PC_WARRIOR, PC_WIZARD] });
|
||||||
|
expect(screen.getByText("Thorin")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Gandalf")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("AC 18")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("HP 45")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("AC 12")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("HP 30")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows level when present, omits when undefined", () => {
|
||||||
|
renderManagement({ characters: [PC_WARRIOR, PC_WIZARD] });
|
||||||
|
expect(screen.getByText("Lv 10")).toBeInTheDocument();
|
||||||
|
// Thorin has no level — there should be only one "Lv" text
|
||||||
|
expect(screen.queryAllByText(LEVEL_REGEX)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("edit button calls onEdit with the character", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { props } = renderManagement({ characters: [PC_WARRIOR] });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||||
|
expect(props.onEdit).toHaveBeenCalledWith(PC_WARRIOR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delete button calls onDelete after confirmation", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { props } = renderManagement({ characters: [PC_WARRIOR] });
|
||||||
|
|
||||||
|
const deleteBtn = screen.getByRole("button", {
|
||||||
|
name: "Delete player character",
|
||||||
|
});
|
||||||
|
await user.click(deleteBtn);
|
||||||
|
const confirmBtn = screen.getByRole("button", {
|
||||||
|
name: "Confirm delete player character",
|
||||||
|
});
|
||||||
|
await user.click(confirmBtn);
|
||||||
|
expect(props.onDelete).toHaveBeenCalledWith(PC_WARRIOR.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("add button calls onCreate", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { props } = renderManagement({ characters: [PC_WARRIOR] });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Add" }));
|
||||||
|
expect(props.onCreate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||||
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
|
import { SettingsModal } from "../settings-modal.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
polyfillDialog();
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderModal(open = true) {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const result = render(<SettingsModal open={open} onClose={onClose} />, {
|
||||||
|
wrapper: AllProviders,
|
||||||
|
});
|
||||||
|
return { ...result, onClose };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SettingsModal", () => {
|
||||||
|
it("renders game system section with all three options", () => {
|
||||||
|
renderModal();
|
||||||
|
expect(screen.getByText("Game System")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "5e (2014)" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "5.5e (2024)" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Pathfinder 2e" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders theme toggle buttons", () => {
|
||||||
|
renderModal();
|
||||||
|
expect(screen.getByRole("button", { name: "System" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Light" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Dark" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking an edition button switches the active edition", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderModal();
|
||||||
|
const btn5e = screen.getByRole("button", { name: "5e (2014)" });
|
||||||
|
await user.click(btn5e);
|
||||||
|
// After clicking 5e, it should have the active style
|
||||||
|
expect(btn5e.className).toContain("bg-accent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking a theme button switches the active theme", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderModal();
|
||||||
|
const darkBtn = screen.getByRole("button", { name: "Dark" });
|
||||||
|
await user.click(darkBtn);
|
||||||
|
expect(darkBtn.className).toContain("bg-accent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("close button calls onClose", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onClose } = renderModal();
|
||||||
|
// DialogHeader renders an X button
|
||||||
|
const buttons = screen.getAllByRole("button");
|
||||||
|
const closeBtn = buttons.find((b) => b.querySelector(".h-4.w-4") !== null);
|
||||||
|
expect(closeBtn).toBeDefined();
|
||||||
|
await user.click(closeBtn as HTMLElement);
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
|
import { AdapterProvider } from "../../contexts/adapter-context.js";
|
||||||
|
import { RulesEditionProvider } from "../../contexts/rules-edition-context.js";
|
||||||
|
import { SourceFetchPrompt } from "../source-fetch-prompt.js";
|
||||||
|
|
||||||
|
const MONSTER_MANUAL_REGEX = /Monster Manual/;
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const mockFetchAndCacheSource = vi.fn();
|
||||||
|
const mockUploadAndCacheSource = vi.fn();
|
||||||
|
|
||||||
|
// Uses context mock because fetchAndCacheSource/uploadAndCacheSource involve
|
||||||
|
// real fetch() calls. The test controls success/failure to verify the
|
||||||
|
// component's loading and error UI, not the fetching logic itself.
|
||||||
|
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||||
|
useBestiaryContext: () => ({
|
||||||
|
fetchAndCacheSource: mockFetchAndCacheSource,
|
||||||
|
uploadAndCacheSource: mockUploadAndCacheSource,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderPrompt(sourceCode = "MM") {
|
||||||
|
const onSourceLoaded = vi.fn();
|
||||||
|
const adapters = createTestAdapters();
|
||||||
|
adapters.bestiaryIndex = {
|
||||||
|
...adapters.bestiaryIndex,
|
||||||
|
getDefaultFetchUrl: (code: string) =>
|
||||||
|
`https://example.com/bestiary/${code}.json`,
|
||||||
|
getSourceDisplayName: (code: string) =>
|
||||||
|
code === "MM" ? "Monster Manual" : code,
|
||||||
|
};
|
||||||
|
const result = render(
|
||||||
|
<RulesEditionProvider>
|
||||||
|
<AdapterProvider adapters={adapters}>
|
||||||
|
<SourceFetchPrompt
|
||||||
|
sourceCode={sourceCode}
|
||||||
|
onSourceLoaded={onSourceLoaded}
|
||||||
|
/>
|
||||||
|
</AdapterProvider>
|
||||||
|
</RulesEditionProvider>,
|
||||||
|
);
|
||||||
|
return { ...result, onSourceLoaded };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SourceFetchPrompt", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders source name, URL input, Load and Upload buttons", () => {
|
||||||
|
renderPrompt();
|
||||||
|
expect(screen.getByText(MONSTER_MANUAL_REGEX)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByDisplayValue("https://example.com/bestiary/MM.json"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Load")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Upload file")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Load calls fetchAndCacheSource and onSourceLoaded on success", async () => {
|
||||||
|
mockFetchAndCacheSource.mockResolvedValueOnce(undefined);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onSourceLoaded } = renderPrompt();
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Load"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetchAndCacheSource).toHaveBeenCalledWith(
|
||||||
|
"MM",
|
||||||
|
"https://example.com/bestiary/MM.json",
|
||||||
|
);
|
||||||
|
expect(onSourceLoaded).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetch error shows error message", async () => {
|
||||||
|
mockFetchAndCacheSource.mockRejectedValueOnce(new Error("Network error"));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPrompt();
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Load"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Network error")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upload file calls uploadAndCacheSource and onSourceLoaded", async () => {
|
||||||
|
mockUploadAndCacheSource.mockResolvedValueOnce(undefined);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onSourceLoaded } = renderPrompt();
|
||||||
|
|
||||||
|
const file = new File(['{"monster":[]}'], "bestiary-mm.json", {
|
||||||
|
type: "application/json",
|
||||||
|
});
|
||||||
|
const fileInput = document.querySelector(
|
||||||
|
'input[type="file"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
await user.upload(fileInput, file);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUploadAndCacheSource).toHaveBeenCalledWith("MM", {
|
||||||
|
monster: [],
|
||||||
|
});
|
||||||
|
expect(onSourceLoaded).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upload error shows error message", async () => {
|
||||||
|
mockUploadAndCacheSource.mockRejectedValueOnce(new Error("Invalid format"));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPrompt();
|
||||||
|
|
||||||
|
const file = new File(['{"bad": true}'], "bad.json", {
|
||||||
|
type: "application/json",
|
||||||
|
});
|
||||||
|
const fileInput = document.querySelector(
|
||||||
|
'input[type="file"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
await user.upload(fileInput, file);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Invalid format")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,60 +3,68 @@ import "@testing-library/jest-dom/vitest";
|
|||||||
|
|
||||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import type { ReactNode } from "react";
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
getCachedSources: vi.fn(),
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
clearSource: vi.fn(),
|
import type { CachedSourceInfo } from "../../adapters/ports.js";
|
||||||
clearAll: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the context module
|
|
||||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
|
||||||
useBestiaryContext: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import * as bestiaryCache from "../../adapters/bestiary-cache.js";
|
|
||||||
import { useBestiaryContext } from "../../contexts/bestiary-context.js";
|
|
||||||
import { SourceManager } from "../source-manager.js";
|
import { SourceManager } from "../source-manager.js";
|
||||||
|
|
||||||
const mockGetCachedSources = vi.mocked(bestiaryCache.getCachedSources);
|
beforeAll(() => {
|
||||||
const mockClearSource = vi.mocked(bestiaryCache.clearSource);
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
const mockClearAll = vi.mocked(bestiaryCache.clearAll);
|
writable: true,
|
||||||
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
afterEach(() => {
|
media: query,
|
||||||
cleanup();
|
onchange: null,
|
||||||
vi.clearAllMocks();
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function setupMockContext() {
|
afterEach(cleanup);
|
||||||
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
|
||||||
mockUseBestiaryContext.mockReturnValue({
|
function renderWithSources(sources: CachedSourceInfo[] = []) {
|
||||||
refreshCache,
|
const adapters = createTestAdapters();
|
||||||
search: vi.fn().mockReturnValue([]),
|
// Wire getCachedSources to return the provided sources initially,
|
||||||
getCreature: vi.fn(),
|
// then empty after clear operations
|
||||||
isLoaded: true,
|
let currentSources = [...sources];
|
||||||
isSourceCached: vi.fn().mockResolvedValue(false),
|
adapters.bestiaryCache = {
|
||||||
fetchAndCacheSource: vi.fn(),
|
...adapters.bestiaryCache,
|
||||||
uploadAndCacheSource: vi.fn(),
|
getCachedSources: () => Promise.resolve(currentSources),
|
||||||
} as ReturnType<typeof useBestiaryContext>);
|
clearSource(_system, sourceCode) {
|
||||||
return { refreshCache };
|
currentSources = currentSources.filter(
|
||||||
|
(s) => s.sourceCode !== sourceCode,
|
||||||
|
);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
clearAll() {
|
||||||
|
currentSources = [];
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<SourceManager />, {
|
||||||
|
wrapper: ({ children }: { children: ReactNode }) => (
|
||||||
|
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||||
|
),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("SourceManager", () => {
|
describe("SourceManager", () => {
|
||||||
it("shows 'No cached sources' empty state when no sources", async () => {
|
it("shows 'No cached sources' empty state when no sources", async () => {
|
||||||
setupMockContext();
|
void renderWithSources([]);
|
||||||
mockGetCachedSources.mockResolvedValue([]);
|
|
||||||
render(<SourceManager />);
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("No cached sources")).toBeInTheDocument();
|
expect(screen.getByText("No cached sources")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists cached sources with display name and creature count", async () => {
|
it("lists cached sources with display name and creature count", async () => {
|
||||||
setupMockContext();
|
void renderWithSources([
|
||||||
mockGetCachedSources.mockResolvedValue([
|
|
||||||
{
|
{
|
||||||
sourceCode: "mm",
|
sourceCode: "mm",
|
||||||
displayName: "Monster Manual",
|
displayName: "Monster Manual",
|
||||||
@@ -70,7 +78,6 @@ describe("SourceManager", () => {
|
|||||||
cachedAt: Date.now(),
|
cachedAt: Date.now(),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
render(<SourceManager />);
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -79,62 +86,45 @@ describe("SourceManager", () => {
|
|||||||
expect(screen.getByText("100 creatures")).toBeInTheDocument();
|
expect(screen.getByText("100 creatures")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Clear All button calls cache clear and refreshCache", async () => {
|
it("Clear All button removes all sources", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { refreshCache } = setupMockContext();
|
void renderWithSources([
|
||||||
mockGetCachedSources
|
{
|
||||||
.mockResolvedValueOnce([
|
sourceCode: "mm",
|
||||||
{
|
displayName: "Monster Manual",
|
||||||
sourceCode: "mm",
|
creatureCount: 300,
|
||||||
displayName: "Monster Manual",
|
cachedAt: Date.now(),
|
||||||
creatureCount: 300,
|
},
|
||||||
cachedAt: Date.now(),
|
]);
|
||||||
},
|
|
||||||
])
|
|
||||||
.mockResolvedValue([]);
|
|
||||||
mockClearAll.mockResolvedValue(undefined);
|
|
||||||
render(<SourceManager />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "Clear All" }));
|
await user.click(screen.getByRole("button", { name: "Clear All" }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockClearAll).toHaveBeenCalled();
|
expect(screen.getByText("No cached sources")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(refreshCache).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("individual source delete button calls clear for that source", async () => {
|
it("individual source delete button removes that source", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { refreshCache } = setupMockContext();
|
void renderWithSources([
|
||||||
mockGetCachedSources
|
{
|
||||||
.mockResolvedValueOnce([
|
sourceCode: "mm",
|
||||||
{
|
displayName: "Monster Manual",
|
||||||
sourceCode: "mm",
|
creatureCount: 300,
|
||||||
displayName: "Monster Manual",
|
cachedAt: Date.now(),
|
||||||
creatureCount: 300,
|
},
|
||||||
cachedAt: Date.now(),
|
{
|
||||||
},
|
sourceCode: "vgm",
|
||||||
{
|
displayName: "Volo's Guide",
|
||||||
sourceCode: "vgm",
|
creatureCount: 100,
|
||||||
displayName: "Volo's Guide",
|
cachedAt: Date.now(),
|
||||||
creatureCount: 100,
|
},
|
||||||
cachedAt: Date.now(),
|
]);
|
||||||
},
|
|
||||||
])
|
|
||||||
.mockResolvedValue([
|
|
||||||
{
|
|
||||||
sourceCode: "vgm",
|
|
||||||
displayName: "Volo's Guide",
|
|
||||||
creatureCount: 100,
|
|
||||||
cachedAt: Date.now(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
mockClearSource.mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
render(<SourceManager />);
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -142,9 +132,10 @@ describe("SourceManager", () => {
|
|||||||
await user.click(
|
await user.click(
|
||||||
screen.getByRole("button", { name: "Remove Monster Manual" }),
|
screen.getByRole("button", { name: "Remove Monster Manual" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockClearSource).toHaveBeenCalledWith("mm");
|
expect(screen.queryByText("Monster Manual")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(refreshCache).toHaveBeenCalled();
|
expect(screen.getByText("Volo's Guide")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import type { Creature } from "@initiative/domain";
|
||||||
|
import { creatureId } from "@initiative/domain";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { DndStatBlock as StatBlock } from "../dnd-stat-block.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const ARMOR_CLASS_REGEX = /Armor Class/;
|
||||||
|
const DEX_PLUS_4_REGEX = /Dex \+4/;
|
||||||
|
const CR_QUARTER_REGEX = /1\/4/;
|
||||||
|
const PROF_BONUS_2_REGEX = /Proficiency Bonus \+2/;
|
||||||
|
const NIMBLE_ESCAPE_REGEX = /Nimble Escape\./;
|
||||||
|
const SCIMITAR_REGEX = /Scimitar\./;
|
||||||
|
const DETECT_REGEX = /Detect\./;
|
||||||
|
const TAIL_ATTACK_REGEX = /Tail Attack\./;
|
||||||
|
const INNATE_SPELLCASTING_REGEX = /Innate Spellcasting\./;
|
||||||
|
const AT_WILL_REGEX = /At Will:/;
|
||||||
|
const DETECT_MAGIC_REGEX = /detect magic, suggestion/;
|
||||||
|
const DAILY_REGEX = /3\/day each:/;
|
||||||
|
const FIREBALL_REGEX = /fireball, wall of fire/;
|
||||||
|
const LONG_REST_REGEX = /1\/long rest:/;
|
||||||
|
const WISH_REGEX = /wish/;
|
||||||
|
|
||||||
|
const GOBLIN: Creature = {
|
||||||
|
id: creatureId("srd:goblin"),
|
||||||
|
name: "Goblin",
|
||||||
|
source: "MM",
|
||||||
|
sourceDisplayName: "Monster Manual",
|
||||||
|
size: "Small",
|
||||||
|
type: "humanoid",
|
||||||
|
alignment: "neutral evil",
|
||||||
|
ac: 15,
|
||||||
|
acSource: "leather armor, shield",
|
||||||
|
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,
|
||||||
|
savingThrows: "Dex +4",
|
||||||
|
skills: "Stealth +6",
|
||||||
|
senses: "darkvision 60 ft., passive Perception 9",
|
||||||
|
languages: "Common, Goblin",
|
||||||
|
traits: [
|
||||||
|
{
|
||||||
|
name: "Nimble Escape",
|
||||||
|
segments: [{ type: "text", value: "Disengage or Hide as bonus." }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
name: "Scimitar",
|
||||||
|
segments: [{ type: "text", value: "Melee: +4 to hit, 5 slashing." }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bonusActions: [
|
||||||
|
{
|
||||||
|
name: "Nimble",
|
||||||
|
segments: [{ type: "text", value: "Disengage or Hide." }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
reactions: [
|
||||||
|
{
|
||||||
|
name: "Redirect",
|
||||||
|
segments: [{ type: "text", value: "Redirect attack to ally." }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const DRAGON: Creature = {
|
||||||
|
id: creatureId("srd:dragon"),
|
||||||
|
name: "Ancient Red Dragon",
|
||||||
|
source: "MM",
|
||||||
|
sourceDisplayName: "Monster Manual",
|
||||||
|
size: "Gargantuan",
|
||||||
|
type: "dragon",
|
||||||
|
alignment: "chaotic evil",
|
||||||
|
ac: 22,
|
||||||
|
hp: { average: 546, formula: "28d20 + 252" },
|
||||||
|
speed: "40 ft., climb 40 ft., fly 80 ft.",
|
||||||
|
abilities: { str: 30, dex: 10, con: 29, int: 18, wis: 15, cha: 23 },
|
||||||
|
cr: "24",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
proficiencyBonus: 7,
|
||||||
|
passive: 26,
|
||||||
|
resist: "fire",
|
||||||
|
immune: "fire",
|
||||||
|
vulnerable: "cold",
|
||||||
|
conditionImmune: "frightened",
|
||||||
|
legendaryActions: {
|
||||||
|
preamble: "The dragon can take 3 legendary actions.",
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
name: "Detect",
|
||||||
|
segments: [
|
||||||
|
{ type: "text" as const, value: "Wisdom (Perception) check." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Tail Attack",
|
||||||
|
segments: [{ type: "text" as const, value: "Tail attack." }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
spellcasting: [
|
||||||
|
{
|
||||||
|
name: "Innate Spellcasting",
|
||||||
|
headerText: "The dragon's spellcasting ability is Charisma.",
|
||||||
|
atWill: ["detect magic", "suggestion"],
|
||||||
|
daily: [{ uses: 3, each: true, spells: ["fireball", "wall of fire"] }],
|
||||||
|
restLong: [{ uses: 1, each: false, spells: ["wish"] }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderStatBlock(creature: Creature) {
|
||||||
|
return render(<StatBlock creature={creature} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("StatBlock", () => {
|
||||||
|
describe("header", () => {
|
||||||
|
it("renders creature name", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Goblin" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders size, type, alignment", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(
|
||||||
|
screen.getByText("Small humanoid, neutral evil"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders source display name", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stats bar", () => {
|
||||||
|
it("renders AC with source", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(screen.getByText(ARMOR_CLASS_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("15")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("(leather armor, shield)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders AC without source when acSource is undefined", () => {
|
||||||
|
renderStatBlock(DRAGON);
|
||||||
|
expect(screen.getByText("22")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders HP average and formula", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(screen.getByText("7")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("(2d6)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders speed", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(screen.getByText("30 ft.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ability scores", () => {
|
||||||
|
it("renders all 6 ability labels", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
for (const label of ["STR", "DEX", "CON", "INT", "WIS", "CHA"]) {
|
||||||
|
expect(screen.getByText(label)).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders ability scores with modifier notation", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(screen.getByText("(+2)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("properties", () => {
|
||||||
|
it("renders saving throws when present", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(screen.getByText("Saving Throws")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(DEX_PLUS_4_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders skills when present", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(screen.getByText("Skills")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders damage resistances, immunities, vulnerabilities", () => {
|
||||||
|
renderStatBlock(DRAGON);
|
||||||
|
expect(screen.getByText("Damage Resistances")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Damage Immunities")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Damage Vulnerabilities")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Condition Immunities")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits properties when undefined", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(screen.queryByText("Damage Resistances")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Damage Immunities")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders CR and proficiency bonus", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(screen.getByText("Challenge")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(CR_QUARTER_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(PROF_BONUS_2_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("traits", () => {
|
||||||
|
it("renders trait entries", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(screen.getByText(NIMBLE_ESCAPE_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("actions / bonus actions / reactions", () => {
|
||||||
|
it("renders actions heading and entries", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Actions" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(SCIMITAR_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders bonus actions heading and entries", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Bonus Actions" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders reactions heading and entries", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Reactions" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("legendary actions", () => {
|
||||||
|
it("renders legendary actions with preamble", () => {
|
||||||
|
renderStatBlock(DRAGON);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Legendary Actions" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("The dragon can take 3 legendary actions."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(DETECT_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(TAIL_ATTACK_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits legendary actions when undefined", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("heading", { name: "Legendary Actions" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("spellcasting", () => {
|
||||||
|
it("renders spellcasting block with header", () => {
|
||||||
|
renderStatBlock(DRAGON);
|
||||||
|
expect(screen.getByText(INNATE_SPELLCASTING_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders at-will spells", () => {
|
||||||
|
renderStatBlock(DRAGON);
|
||||||
|
expect(screen.getByText(AT_WILL_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(DETECT_MAGIC_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders daily spells", () => {
|
||||||
|
renderStatBlock(DRAGON);
|
||||||
|
expect(screen.getByText(DAILY_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(FIREBALL_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders long rest spells", () => {
|
||||||
|
renderStatBlock(DRAGON);
|
||||||
|
expect(screen.getByText(LONG_REST_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(WISH_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits spellcasting when undefined", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(screen.queryByText(AT_WILL_REGEX)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,100 +1,68 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
import type { Encounter } from "@initiative/domain";
|
|
||||||
import { combatantId } from "@initiative/domain";
|
import { combatantId } from "@initiative/domain";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import type { ReactNode } from "react";
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
// Mock the context modules
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
vi.mock("../../contexts/encounter-context.js", () => ({
|
import {
|
||||||
useEncounterContext: vi.fn(),
|
buildCombatant,
|
||||||
}));
|
buildEncounter,
|
||||||
|
} from "../../__tests__/factories/index.js";
|
||||||
vi.mock("../../contexts/player-characters-context.js", () => ({
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
usePlayerCharactersContext: vi.fn().mockReturnValue({ characters: [] }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
|
||||||
useBestiaryContext: vi.fn().mockReturnValue({ getCreature: () => undefined }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { useEncounterContext } from "../../contexts/encounter-context.js";
|
|
||||||
import { TurnNavigation } from "../turn-navigation.js";
|
import { TurnNavigation } from "../turn-navigation.js";
|
||||||
|
|
||||||
const mockUseEncounterContext = vi.mocked(useEncounterContext);
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
afterEach(() => {
|
writable: true,
|
||||||
cleanup();
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
vi.clearAllMocks();
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function mockContext(overrides: Partial<Encounter> = {}) {
|
afterEach(cleanup);
|
||||||
const encounter: Encounter = {
|
|
||||||
combatants: [
|
|
||||||
{ id: combatantId("1"), name: "Goblin" },
|
|
||||||
{ id: combatantId("2"), name: "Conjurer" },
|
|
||||||
],
|
|
||||||
activeIndex: 0,
|
|
||||||
roundNumber: 1,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
|
|
||||||
const value = {
|
function renderNav(encounter = buildEncounter()) {
|
||||||
encounter,
|
const adapters = createTestAdapters({ encounter });
|
||||||
advanceTurn: vi.fn(),
|
return render(<TurnNavigation />, {
|
||||||
retreatTurn: vi.fn(),
|
wrapper: ({ children }: { children: ReactNode }) => (
|
||||||
clearEncounter: vi.fn(),
|
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||||
isEmpty: encounter.combatants.length === 0,
|
),
|
||||||
hasCreatureCombatants: false,
|
});
|
||||||
canRollAllInitiative: false,
|
|
||||||
addCombatant: vi.fn(),
|
|
||||||
removeCombatant: vi.fn(),
|
|
||||||
editCombatant: vi.fn(),
|
|
||||||
setInitiative: vi.fn(),
|
|
||||||
setHp: vi.fn(),
|
|
||||||
adjustHp: vi.fn(),
|
|
||||||
setTempHp: vi.fn(),
|
|
||||||
hasTempHp: false,
|
|
||||||
setAc: vi.fn(),
|
|
||||||
toggleCondition: vi.fn(),
|
|
||||||
toggleConcentration: vi.fn(),
|
|
||||||
addFromBestiary: vi.fn(),
|
|
||||||
addMultipleFromBestiary: vi.fn(),
|
|
||||||
addFromPlayerCharacter: vi.fn(),
|
|
||||||
makeStore: vi.fn(),
|
|
||||||
withUndo: vi.fn((action: () => unknown) => action()),
|
|
||||||
undo: vi.fn(),
|
|
||||||
redo: vi.fn(),
|
|
||||||
canUndo: false,
|
|
||||||
canRedo: false,
|
|
||||||
undoRedoState: { undoStack: [], redoStack: [] },
|
|
||||||
setEncounter: vi.fn(),
|
|
||||||
setUndoRedoState: vi.fn(),
|
|
||||||
events: [],
|
|
||||||
lastCreatureId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
mockUseEncounterContext.mockReturnValue(
|
|
||||||
value as ReturnType<typeof useEncounterContext>,
|
|
||||||
);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderNav(overrides: Partial<Encounter> = {}) {
|
|
||||||
mockContext(overrides);
|
|
||||||
return render(<TurnNavigation />);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("TurnNavigation", () => {
|
describe("TurnNavigation", () => {
|
||||||
describe("US1: Round badge and combatant name", () => {
|
describe("US1: Round badge and combatant name", () => {
|
||||||
it("renders the round badge with correct round number", () => {
|
it("renders the round badge with correct round number", () => {
|
||||||
renderNav({ roundNumber: 3 });
|
renderNav(
|
||||||
|
buildEncounter({
|
||||||
|
combatants: [buildCombatant({ name: "Goblin" })],
|
||||||
|
roundNumber: 3,
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(screen.getByText("R3")).toBeInTheDocument();
|
expect(screen.getByText("R3")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the combatant name separately from the round badge", () => {
|
it("renders the combatant name separately from the round badge", () => {
|
||||||
renderNav();
|
renderNav(
|
||||||
|
buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({ id: combatantId("c-1"), name: "Goblin" }),
|
||||||
|
buildCombatant({ id: combatantId("c-2"), name: "Conjurer" }),
|
||||||
|
],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const badge = screen.getByText("R1");
|
const badge = screen.getByText("R1");
|
||||||
const name = screen.getByText("Goblin");
|
const name = screen.getByText("Goblin");
|
||||||
expect(badge).toBeInTheDocument();
|
expect(badge).toBeInTheDocument();
|
||||||
@@ -104,41 +72,24 @@ describe("TurnNavigation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not render an em dash between round and name", () => {
|
it("does not render an em dash between round and name", () => {
|
||||||
const { container } = renderNav();
|
const { container } = renderNav(
|
||||||
|
buildEncounter({
|
||||||
|
combatants: [buildCombatant({ name: "Goblin" })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(container.textContent).not.toContain("\u2014");
|
expect(container.textContent).not.toContain("\u2014");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("round badge and combatant name are siblings in the center area", () => {
|
it("round badge is in the left zone and name is in the center zone", () => {
|
||||||
renderNav();
|
renderNav(
|
||||||
|
buildEncounter({
|
||||||
|
combatants: [buildCombatant({ name: "Goblin" })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
const badge = screen.getByText("R1");
|
const badge = screen.getByText("R1");
|
||||||
const name = screen.getByText("Goblin");
|
const name = screen.getByText("Goblin");
|
||||||
// badge text is inside inner span > outer span, name is a direct child
|
// Badge and name are in separate grid cells to prevent layout shifts
|
||||||
expect(badge.closest(".flex")).toBe(name.parentElement);
|
expect(badge.parentElement).not.toBe(name.parentElement);
|
||||||
});
|
|
||||||
|
|
||||||
it("updates the round badge when round changes", () => {
|
|
||||||
mockContext({ roundNumber: 2 });
|
|
||||||
const { rerender } = render(<TurnNavigation />);
|
|
||||||
expect(screen.getByText("R2")).toBeInTheDocument();
|
|
||||||
|
|
||||||
mockContext({ roundNumber: 3 });
|
|
||||||
rerender(<TurnNavigation />);
|
|
||||||
expect(screen.getByText("R3")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText("R2")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the next combatant name when turn advances", () => {
|
|
||||||
const combatants = [
|
|
||||||
{ id: combatantId("1"), name: "Goblin" },
|
|
||||||
{ id: combatantId("2"), name: "Conjurer" },
|
|
||||||
];
|
|
||||||
mockContext({ combatants, activeIndex: 0 });
|
|
||||||
const { rerender } = render(<TurnNavigation />);
|
|
||||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
|
||||||
|
|
||||||
mockContext({ combatants, activeIndex: 1 });
|
|
||||||
rerender(<TurnNavigation />);
|
|
||||||
expect(screen.getByText("Conjurer")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -146,17 +97,21 @@ describe("TurnNavigation", () => {
|
|||||||
it("applies truncation styles to long combatant names", () => {
|
it("applies truncation styles to long combatant names", () => {
|
||||||
const longName =
|
const longName =
|
||||||
"Ancient Red Dragon Wyrm of the Northern Wastes and Beyond";
|
"Ancient Red Dragon Wyrm of the Northern Wastes and Beyond";
|
||||||
renderNav({
|
renderNav(
|
||||||
combatants: [{ id: combatantId("1"), name: longName }],
|
buildEncounter({
|
||||||
});
|
combatants: [buildCombatant({ name: longName })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
const nameEl = screen.getByText(longName);
|
const nameEl = screen.getByText(longName);
|
||||||
expect(nameEl.className).toContain("truncate");
|
expect(nameEl.className).toContain("truncate");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders three-zone layout with a single-character name", () => {
|
it("renders three-zone layout with a single-character name", () => {
|
||||||
renderNav({
|
renderNav(
|
||||||
combatants: [{ id: combatantId("1"), name: "O" }],
|
buildEncounter({
|
||||||
});
|
combatants: [buildCombatant({ name: "O" })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(screen.getByText("R1")).toBeInTheDocument();
|
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||||
expect(screen.getByText("O")).toBeInTheDocument();
|
expect(screen.getByText("O")).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
@@ -169,9 +124,11 @@ describe("TurnNavigation", () => {
|
|||||||
|
|
||||||
it("keeps all action buttons accessible regardless of name length", () => {
|
it("keeps all action buttons accessible regardless of name length", () => {
|
||||||
const longName = "A".repeat(60);
|
const longName = "A".repeat(60);
|
||||||
renderNav({
|
renderNav(
|
||||||
combatants: [{ id: combatantId("1"), name: longName }],
|
buildEncounter({
|
||||||
});
|
combatants: [buildCombatant({ name: longName })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Previous turn" }),
|
screen.getByRole("button", { name: "Previous turn" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
@@ -182,29 +139,30 @@ describe("TurnNavigation", () => {
|
|||||||
|
|
||||||
it("renders a 40-character name without truncation class issues", () => {
|
it("renders a 40-character name without truncation class issues", () => {
|
||||||
const name40 = "A".repeat(40);
|
const name40 = "A".repeat(40);
|
||||||
renderNav({
|
renderNav(
|
||||||
combatants: [{ id: combatantId("1"), name: name40 }],
|
buildEncounter({
|
||||||
});
|
combatants: [buildCombatant({ name: name40 })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
const nameEl = screen.getByText(name40);
|
const nameEl = screen.getByText(name40);
|
||||||
expect(nameEl).toBeInTheDocument();
|
expect(nameEl).toBeInTheDocument();
|
||||||
// The truncate class is applied but CSS only visually truncates if content overflows
|
|
||||||
expect(nameEl.className).toContain("truncate");
|
expect(nameEl.className).toContain("truncate");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("US3: No combatants state", () => {
|
describe("US3: No combatants state", () => {
|
||||||
it("shows the round badge when there are no combatants", () => {
|
it("shows the round badge when there are no combatants", () => {
|
||||||
renderNav({ combatants: [], roundNumber: 1 });
|
renderNav(buildEncounter({ combatants: [], roundNumber: 1 }));
|
||||||
expect(screen.getByText("R1")).toBeInTheDocument();
|
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows 'No combatants' placeholder text", () => {
|
it("shows 'No combatants' placeholder text", () => {
|
||||||
renderNav({ combatants: [] });
|
renderNav(buildEncounter({ combatants: [] }));
|
||||||
expect(screen.getByText("No combatants")).toBeInTheDocument();
|
expect(screen.getByText("No combatants")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables navigation buttons when there are no combatants", () => {
|
it("disables navigation buttons when there are no combatants", () => {
|
||||||
renderNav({ combatants: [] });
|
renderNav(buildEncounter({ combatants: [] }));
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Previous turn" }),
|
screen.getByRole("button", { name: "Previous turn" }),
|
||||||
).toBeDisabled();
|
).toBeDisabled();
|
||||||
|
|||||||
@@ -12,27 +12,20 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import React, { type RefObject, useCallback, useRef, useState } from "react";
|
import React, { type RefObject, useCallback, useState } from "react";
|
||||||
import type { SearchResult } from "../contexts/bestiary-context.js";
|
import type { SearchResult } from "../contexts/bestiary-context.js";
|
||||||
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
||||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
|
||||||
import {
|
import {
|
||||||
creatureKey,
|
creatureKey,
|
||||||
type QueuedCreature,
|
type QueuedCreature,
|
||||||
type SuggestionActions,
|
type SuggestionActions,
|
||||||
useActionBarState,
|
useActionBarState,
|
||||||
} from "../hooks/use-action-bar-state.js";
|
} from "../hooks/use-action-bar-state.js";
|
||||||
|
import { useEncounterExportImport } from "../hooks/use-encounter-export-import.js";
|
||||||
import { useLongPress } from "../hooks/use-long-press.js";
|
import { useLongPress } from "../hooks/use-long-press.js";
|
||||||
import { cn } from "../lib/utils.js";
|
import { cn } from "../lib/utils.js";
|
||||||
import {
|
|
||||||
assembleExportBundle,
|
|
||||||
bundleToJson,
|
|
||||||
readImportFile,
|
|
||||||
triggerDownload,
|
|
||||||
validateImportBundle,
|
|
||||||
} from "../persistence/export-import.js";
|
|
||||||
import { D20Icon } from "./d20-icon.js";
|
import { D20Icon } from "./d20-icon.js";
|
||||||
import { ExportMethodDialog } from "./export-method-dialog.js";
|
import { ExportMethodDialog } from "./export-method-dialog.js";
|
||||||
import { ImportConfirmDialog } from "./import-confirm-prompt.js";
|
import { ImportConfirmDialog } from "./import-confirm-prompt.js";
|
||||||
@@ -439,116 +432,23 @@ export function ActionBar({
|
|||||||
} = useActionBarState();
|
} = useActionBarState();
|
||||||
|
|
||||||
const { state: bulkImportState } = useBulkImportContext();
|
const { state: bulkImportState } = useBulkImportContext();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
encounter,
|
importError,
|
||||||
undoRedoState,
|
showExportMethod,
|
||||||
isEmpty: encounterIsEmpty,
|
showImportMethod,
|
||||||
setEncounter,
|
showImportConfirm,
|
||||||
setUndoRedoState,
|
importFileRef,
|
||||||
} = useEncounterContext();
|
setImportError,
|
||||||
const { characters: playerCharacters, replacePlayerCharacters } =
|
setShowExportMethod,
|
||||||
usePlayerCharactersContext();
|
setShowImportMethod,
|
||||||
|
handleExportDownload,
|
||||||
const importFileRef = useRef<HTMLInputElement>(null);
|
handleExportClipboard,
|
||||||
const [importError, setImportError] = useState<string | null>(null);
|
handleImportFile,
|
||||||
const [showExportMethod, setShowExportMethod] = useState(false);
|
handleImportClipboard,
|
||||||
const [showImportMethod, setShowImportMethod] = useState(false);
|
handleImportConfirm,
|
||||||
const [showImportConfirm, setShowImportConfirm] = useState(false);
|
handleImportCancel,
|
||||||
const pendingBundleRef = useRef<
|
} = useEncounterExportImport();
|
||||||
import("@initiative/domain").ExportBundle | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
const handleExportDownload = useCallback(
|
|
||||||
(includeHistory: boolean, filename: string) => {
|
|
||||||
const bundle = assembleExportBundle(
|
|
||||||
encounter,
|
|
||||||
undoRedoState,
|
|
||||||
playerCharacters,
|
|
||||||
includeHistory,
|
|
||||||
);
|
|
||||||
triggerDownload(bundle, filename);
|
|
||||||
},
|
|
||||||
[encounter, undoRedoState, playerCharacters],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleExportClipboard = useCallback(
|
|
||||||
(includeHistory: boolean) => {
|
|
||||||
const bundle = assembleExportBundle(
|
|
||||||
encounter,
|
|
||||||
undoRedoState,
|
|
||||||
playerCharacters,
|
|
||||||
includeHistory,
|
|
||||||
);
|
|
||||||
void navigator.clipboard.writeText(bundleToJson(bundle));
|
|
||||||
},
|
|
||||||
[encounter, undoRedoState, playerCharacters],
|
|
||||||
);
|
|
||||||
|
|
||||||
const applyImport = useCallback(
|
|
||||||
(bundle: import("@initiative/domain").ExportBundle) => {
|
|
||||||
setEncounter(bundle.encounter);
|
|
||||||
setUndoRedoState({
|
|
||||||
undoStack: bundle.undoStack,
|
|
||||||
redoStack: bundle.redoStack,
|
|
||||||
});
|
|
||||||
replacePlayerCharacters([...bundle.playerCharacters]);
|
|
||||||
},
|
|
||||||
[setEncounter, setUndoRedoState, replacePlayerCharacters],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleValidatedBundle = useCallback(
|
|
||||||
(result: import("@initiative/domain").ExportBundle | string) => {
|
|
||||||
if (typeof result === "string") {
|
|
||||||
setImportError(result);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (encounterIsEmpty) {
|
|
||||||
applyImport(result);
|
|
||||||
} else {
|
|
||||||
pendingBundleRef.current = result;
|
|
||||||
setShowImportConfirm(true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[encounterIsEmpty, applyImport],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleImportFile = useCallback(
|
|
||||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
if (importFileRef.current) importFileRef.current.value = "";
|
|
||||||
|
|
||||||
setImportError(null);
|
|
||||||
handleValidatedBundle(await readImportFile(file));
|
|
||||||
},
|
|
||||||
[handleValidatedBundle],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleImportClipboard = useCallback(
|
|
||||||
(text: string) => {
|
|
||||||
setImportError(null);
|
|
||||||
try {
|
|
||||||
const parsed: unknown = JSON.parse(text);
|
|
||||||
handleValidatedBundle(validateImportBundle(parsed));
|
|
||||||
} catch {
|
|
||||||
setImportError("Invalid file format");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[handleValidatedBundle],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleImportConfirm = useCallback(() => {
|
|
||||||
if (pendingBundleRef.current) {
|
|
||||||
applyImport(pendingBundleRef.current);
|
|
||||||
pendingBundleRef.current = null;
|
|
||||||
}
|
|
||||||
setShowImportConfirm(false);
|
|
||||||
}, [applyImport]);
|
|
||||||
|
|
||||||
const handleImportCancel = useCallback(() => {
|
|
||||||
pendingBundleRef.current = null;
|
|
||||||
setShowImportConfirm(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const overflowItems = buildOverflowItems({
|
const overflowItems = buildOverflowItems({
|
||||||
onManagePlayers,
|
onManagePlayers,
|
||||||
|
|||||||
@@ -1,24 +1,32 @@
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useId, useState } from "react";
|
import { useId, useState } from "react";
|
||||||
import { getAllSourceCodes } from "../adapters/bestiary-index-adapter.js";
|
import { useAdapters } from "../contexts/adapter-context.js";
|
||||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
import { Input } from "./ui/input.js";
|
import { Input } from "./ui/input.js";
|
||||||
|
|
||||||
const DEFAULT_BASE_URL =
|
const DND_BASE_URL =
|
||||||
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
|
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
|
||||||
|
|
||||||
|
const PF2E_BASE_URL =
|
||||||
|
"https://raw.githubusercontent.com/Pf2eToolsOrg/Pf2eTools/dev/data/bestiary/";
|
||||||
|
|
||||||
export function BulkImportPrompt() {
|
export function BulkImportPrompt() {
|
||||||
|
const { bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
|
||||||
const { fetchAndCacheSource, isSourceCached, refreshCache } =
|
const { fetchAndCacheSource, isSourceCached, refreshCache } =
|
||||||
useBestiaryContext();
|
useBestiaryContext();
|
||||||
const { state: importState, startImport, reset } = useBulkImportContext();
|
const { state: importState, startImport, reset } = useBulkImportContext();
|
||||||
const { dismissPanel } = useSidePanelContext();
|
const { dismissPanel } = useSidePanelContext();
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
|
|
||||||
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
|
const indexPort = edition === "pf2e" ? pf2eBestiaryIndex : bestiaryIndex;
|
||||||
|
const defaultUrl = edition === "pf2e" ? PF2E_BASE_URL : DND_BASE_URL;
|
||||||
|
const [baseUrl, setBaseUrl] = useState(defaultUrl);
|
||||||
const baseUrlId = useId();
|
const baseUrlId = useId();
|
||||||
const totalSources = getAllSourceCodes().length;
|
const totalSources = indexPort.getAllSourceCodes().length;
|
||||||
|
|
||||||
const handleStart = (url: string) => {
|
const handleStart = (url: string) => {
|
||||||
startImport(url, fetchAndCacheSource, isSourceCached, refreshCache);
|
startImport(url, fetchAndCacheSource, isSourceCached, refreshCache);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
type CombatantId,
|
type CombatantId,
|
||||||
type ConditionId,
|
type ConditionEntry,
|
||||||
type CreatureId,
|
type CreatureId,
|
||||||
deriveHpStatus,
|
deriveHpStatus,
|
||||||
type PlayerIcon,
|
type PlayerIcon,
|
||||||
@@ -31,7 +31,7 @@ interface Combatant {
|
|||||||
readonly currentHp?: number;
|
readonly currentHp?: number;
|
||||||
readonly tempHp?: number;
|
readonly tempHp?: number;
|
||||||
readonly ac?: number;
|
readonly ac?: number;
|
||||||
readonly conditions?: readonly ConditionId[];
|
readonly conditions?: readonly ConditionEntry[];
|
||||||
readonly isConcentrating?: boolean;
|
readonly isConcentrating?: boolean;
|
||||||
readonly color?: string;
|
readonly color?: string;
|
||||||
readonly icon?: string;
|
readonly icon?: string;
|
||||||
@@ -430,7 +430,7 @@ function concentrationIconClass(
|
|||||||
dimmed: boolean,
|
dimmed: boolean,
|
||||||
): string {
|
): string {
|
||||||
if (!isConcentrating)
|
if (!isConcentrating)
|
||||||
return "opacity-0 group-hover:opacity-50 text-muted-foreground";
|
return "opacity-0 pointer-coarse:opacity-50 group-hover:opacity-50 text-muted-foreground";
|
||||||
return dimmed ? "opacity-50 text-purple-400" : "opacity-100 text-purple-400";
|
return dimmed ? "opacity-50 text-purple-400" : "opacity-100 text-purple-400";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,6 +448,8 @@ export function CombatantRow({
|
|||||||
setTempHp,
|
setTempHp,
|
||||||
setAc,
|
setAc,
|
||||||
toggleCondition,
|
toggleCondition,
|
||||||
|
setConditionValue,
|
||||||
|
decrementCondition,
|
||||||
toggleConcentration,
|
toggleConcentration,
|
||||||
} = useEncounterContext();
|
} = useEncounterContext();
|
||||||
const { selectedCreatureId, showCreature, toggleCollapse } =
|
const { selectedCreatureId, showCreature, toggleCollapse } =
|
||||||
@@ -585,6 +587,7 @@ export function CombatantRow({
|
|||||||
<ConditionTags
|
<ConditionTags
|
||||||
conditions={combatant.conditions}
|
conditions={combatant.conditions}
|
||||||
onRemove={(conditionId) => toggleCondition(id, conditionId)}
|
onRemove={(conditionId) => toggleCondition(id, conditionId)}
|
||||||
|
onDecrement={(conditionId) => decrementCondition(id, conditionId)}
|
||||||
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -593,6 +596,9 @@ export function CombatantRow({
|
|||||||
anchorRef={conditionAnchorRef}
|
anchorRef={conditionAnchorRef}
|
||||||
activeConditions={combatant.conditions}
|
activeConditions={combatant.conditions}
|
||||||
onToggle={(conditionId) => toggleCondition(id, conditionId)}
|
onToggle={(conditionId) => toggleCondition(id, conditionId)}
|
||||||
|
onSetValue={(conditionId, value) =>
|
||||||
|
setConditionValue(id, conditionId, value)
|
||||||
|
}
|
||||||
onClose={() => setPickerOpen(false)}
|
onClose={() => setPickerOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
|
type ConditionEntry,
|
||||||
type ConditionId,
|
type ConditionId,
|
||||||
getConditionDescription,
|
getConditionDescription,
|
||||||
getConditionsForEdition,
|
getConditionsForEdition,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
|
import { Check, Minus, Plus } from "lucide-react";
|
||||||
import { useLayoutEffect, useRef, useState } from "react";
|
import { useLayoutEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
@@ -16,8 +18,9 @@ import { Tooltip } from "./ui/tooltip.js";
|
|||||||
|
|
||||||
interface ConditionPickerProps {
|
interface ConditionPickerProps {
|
||||||
anchorRef: React.RefObject<HTMLElement | null>;
|
anchorRef: React.RefObject<HTMLElement | null>;
|
||||||
activeConditions: readonly ConditionId[] | undefined;
|
activeConditions: readonly ConditionEntry[] | undefined;
|
||||||
onToggle: (conditionId: ConditionId) => void;
|
onToggle: (conditionId: ConditionId) => void;
|
||||||
|
onSetValue: (conditionId: ConditionId, value: number) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +28,7 @@ export function ConditionPicker({
|
|||||||
anchorRef,
|
anchorRef,
|
||||||
activeConditions,
|
activeConditions,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
onSetValue,
|
||||||
onClose,
|
onClose,
|
||||||
}: Readonly<ConditionPickerProps>) {
|
}: Readonly<ConditionPickerProps>) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -34,6 +38,11 @@ export function ConditionPicker({
|
|||||||
maxHeight: number;
|
maxHeight: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState<{
|
||||||
|
id: ConditionId;
|
||||||
|
value: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const anchor = anchorRef.current;
|
const anchor = anchorRef.current;
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
@@ -59,7 +68,9 @@ export function ConditionPicker({
|
|||||||
|
|
||||||
const { edition } = useRulesEditionContext();
|
const { edition } = useRulesEditionContext();
|
||||||
const conditions = getConditionsForEdition(edition);
|
const conditions = getConditionsForEdition(edition);
|
||||||
const active = new Set(activeConditions ?? []);
|
const activeMap = new Map(
|
||||||
|
(activeConditions ?? []).map((e) => [e.id, e.value]),
|
||||||
|
);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
@@ -74,35 +85,112 @@ export function ConditionPicker({
|
|||||||
{conditions.map((def) => {
|
{conditions.map((def) => {
|
||||||
const Icon = CONDITION_ICON_MAP[def.iconName];
|
const Icon = CONDITION_ICON_MAP[def.iconName];
|
||||||
if (!Icon) return null;
|
if (!Icon) return null;
|
||||||
const isActive = active.has(def.id);
|
const isActive = activeMap.has(def.id);
|
||||||
|
const activeValue = activeMap.get(def.id);
|
||||||
|
const isEditing = editing?.id === def.id;
|
||||||
const colorClass =
|
const colorClass =
|
||||||
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (def.valued && edition === "pf2e") {
|
||||||
|
const current = activeMap.get(def.id);
|
||||||
|
setEditing({
|
||||||
|
id: def.id,
|
||||||
|
value: current ?? 1,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onToggle(def.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
key={def.id}
|
key={def.id}
|
||||||
content={getConditionDescription(def, edition)}
|
content={getConditionDescription(def, edition)}
|
||||||
className="block"
|
className="block"
|
||||||
>
|
>
|
||||||
<button
|
<div
|
||||||
type="button"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-hover-neutral-bg",
|
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors",
|
||||||
isActive && "bg-card/50",
|
(isActive || isEditing) && "bg-card/50",
|
||||||
)}
|
)}
|
||||||
onClick={() => onToggle(def.id)}
|
|
||||||
>
|
>
|
||||||
<Icon
|
<button
|
||||||
size={14}
|
type="button"
|
||||||
className={isActive ? colorClass : "text-muted-foreground"}
|
className="flex flex-1 items-center gap-2"
|
||||||
/>
|
onClick={handleClick}
|
||||||
<span
|
|
||||||
className={
|
|
||||||
isActive ? "text-foreground" : "text-muted-foreground"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{def.label}
|
<Icon
|
||||||
</span>
|
size={14}
|
||||||
</button>
|
className={
|
||||||
|
isActive || isEditing ? colorClass : "text-muted-foreground"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
isActive || isEditing
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{def.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{isActive && def.valued && edition === "pf2e" && !isEditing && (
|
||||||
|
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground text-xs">
|
||||||
|
{activeValue}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isEditing && (
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded p-0.5 text-foreground hover:bg-accent/40"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (editing.value > 1) {
|
||||||
|
setEditing({
|
||||||
|
...editing,
|
||||||
|
value: editing.value - 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-primary-foreground text-xs">
|
||||||
|
{editing.value}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded p-0.5 text-foreground hover:bg-accent/40"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditing({
|
||||||
|
...editing,
|
||||||
|
value: editing.value + 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();
|
||||||
|
onSetValue(editing.id, editing.value);
|
||||||
|
setEditing(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,42 +1,74 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
Anchor,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
Ban,
|
Ban,
|
||||||
BatteryLow,
|
BatteryLow,
|
||||||
|
BrainCog,
|
||||||
|
CircleHelp,
|
||||||
|
CloudFog,
|
||||||
|
Drama,
|
||||||
Droplet,
|
Droplet,
|
||||||
|
Droplets,
|
||||||
EarOff,
|
EarOff,
|
||||||
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
Footprints,
|
||||||
Gem,
|
Gem,
|
||||||
Ghost,
|
Ghost,
|
||||||
Hand,
|
Hand,
|
||||||
Heart,
|
Heart,
|
||||||
|
HeartCrack,
|
||||||
|
HeartPulse,
|
||||||
Link,
|
Link,
|
||||||
Moon,
|
Moon,
|
||||||
|
PersonStanding,
|
||||||
ShieldMinus,
|
ShieldMinus,
|
||||||
|
ShieldOff,
|
||||||
Siren,
|
Siren,
|
||||||
|
Skull,
|
||||||
Snail,
|
Snail,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Sun,
|
||||||
|
TrendingDown,
|
||||||
|
Zap,
|
||||||
ZapOff,
|
ZapOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
|
export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
|
||||||
EyeOff,
|
Anchor,
|
||||||
Heart,
|
|
||||||
EarOff,
|
|
||||||
BatteryLow,
|
|
||||||
Siren,
|
|
||||||
Hand,
|
|
||||||
Ban,
|
|
||||||
Ghost,
|
|
||||||
ZapOff,
|
|
||||||
Gem,
|
|
||||||
Droplet,
|
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
|
Ban,
|
||||||
|
BatteryLow,
|
||||||
|
BrainCog,
|
||||||
|
CircleHelp,
|
||||||
|
CloudFog,
|
||||||
|
Drama,
|
||||||
|
Droplet,
|
||||||
|
Droplets,
|
||||||
|
EarOff,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Footprints,
|
||||||
|
Gem,
|
||||||
|
Ghost,
|
||||||
|
Hand,
|
||||||
|
Heart,
|
||||||
|
HeartCrack,
|
||||||
|
HeartPulse,
|
||||||
Link,
|
Link,
|
||||||
|
Moon,
|
||||||
|
PersonStanding,
|
||||||
ShieldMinus,
|
ShieldMinus,
|
||||||
|
ShieldOff,
|
||||||
|
Siren,
|
||||||
|
Skull,
|
||||||
Snail,
|
Snail,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Moon,
|
Sun,
|
||||||
|
TrendingDown,
|
||||||
|
Zap,
|
||||||
|
ZapOff,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CONDITION_COLOR_CLASSES: Record<string, string> = {
|
export const CONDITION_COLOR_CLASSES: Record<string, string> = {
|
||||||
@@ -51,4 +83,5 @@ export const CONDITION_COLOR_CLASSES: Record<string, string> = {
|
|||||||
green: "text-green-400",
|
green: "text-green-400",
|
||||||
indigo: "text-indigo-400",
|
indigo: "text-indigo-400",
|
||||||
sky: "text-sky-400",
|
sky: "text-sky-400",
|
||||||
|
red: "text-red-400",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
CONDITION_DEFINITIONS,
|
CONDITION_DEFINITIONS,
|
||||||
|
type ConditionEntry,
|
||||||
type ConditionId,
|
type ConditionId,
|
||||||
getConditionDescription,
|
getConditionDescription,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
@@ -13,44 +14,57 @@ import {
|
|||||||
import { Tooltip } from "./ui/tooltip.js";
|
import { Tooltip } from "./ui/tooltip.js";
|
||||||
|
|
||||||
interface ConditionTagsProps {
|
interface ConditionTagsProps {
|
||||||
conditions: readonly ConditionId[] | undefined;
|
conditions: readonly ConditionEntry[] | undefined;
|
||||||
onRemove: (conditionId: ConditionId) => void;
|
onRemove: (conditionId: ConditionId) => void;
|
||||||
|
onDecrement: (conditionId: ConditionId) => void;
|
||||||
onOpenPicker: () => void;
|
onOpenPicker: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConditionTags({
|
export function ConditionTags({
|
||||||
conditions,
|
conditions,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onDecrement,
|
||||||
onOpenPicker,
|
onOpenPicker,
|
||||||
}: Readonly<ConditionTagsProps>) {
|
}: Readonly<ConditionTagsProps>) {
|
||||||
const { edition } = useRulesEditionContext();
|
const { edition } = useRulesEditionContext();
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-0.5">
|
<div className="flex flex-wrap items-center gap-0.5">
|
||||||
{conditions?.map((condId) => {
|
{conditions?.map((entry) => {
|
||||||
const def = CONDITION_DEFINITIONS.find((d) => d.id === condId);
|
const def = CONDITION_DEFINITIONS.find((d) => d.id === entry.id);
|
||||||
if (!def) return null;
|
if (!def) return null;
|
||||||
const Icon = CONDITION_ICON_MAP[def.iconName];
|
const Icon = CONDITION_ICON_MAP[def.iconName];
|
||||||
if (!Icon) return null;
|
if (!Icon) return null;
|
||||||
const colorClass =
|
const colorClass =
|
||||||
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||||
|
const tooltipLabel =
|
||||||
|
entry.value === undefined ? def.label : `${def.label} ${entry.value}`;
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
key={condId}
|
key={entry.id}
|
||||||
content={`${def.label}:\n${getConditionDescription(def, edition)}`}
|
content={`${tooltipLabel}:\n${getConditionDescription(def, edition)}`}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Remove ${def.label}`}
|
aria-label={`Remove ${def.label}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
|
"inline-flex items-center gap-0.5 rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
|
||||||
colorClass,
|
colorClass,
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onRemove(condId);
|
if (entry.value === undefined) {
|
||||||
|
onRemove(entry.id);
|
||||||
|
} else {
|
||||||
|
onDecrement(entry.id);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon size={14} />
|
<Icon size={14} />
|
||||||
|
{entry.value !== undefined && (
|
||||||
|
<span className="font-medium text-xs leading-none">
|
||||||
|
{entry.value}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { VALID_CR_VALUES } from "@initiative/domain";
|
||||||
|
|
||||||
|
const CR_LABELS: Record<string, string> = {
|
||||||
|
"0": "CR 0",
|
||||||
|
"1/8": "CR 1/8",
|
||||||
|
"1/4": "CR 1/4",
|
||||||
|
"1/2": "CR 1/2",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatCr(cr: string): string {
|
||||||
|
return CR_LABELS[cr] ?? `CR ${cr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CrPicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string | null;
|
||||||
|
onChange: (cr: string | undefined) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
className="rounded border border-border bg-card px-1.5 py-0.5 text-xs"
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={(e) => onChange(e.target.value || undefined)}
|
||||||
|
aria-label="Challenge rating"
|
||||||
|
>
|
||||||
|
<option value="">Assign</option>
|
||||||
|
{VALID_CR_VALUES.map((cr) => (
|
||||||
|
<option key={cr} value={cr}>
|
||||||
|
{formatCr(cr)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import type { DifficultyTier, RulesEdition } from "@initiative/domain";
|
||||||
|
import { ArrowLeftRight } from "lucide-react";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
|
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||||
|
import {
|
||||||
|
type BreakdownCombatant,
|
||||||
|
useDifficultyBreakdown,
|
||||||
|
} from "../hooks/use-difficulty-breakdown.js";
|
||||||
|
import { CrPicker } from "./cr-picker.js";
|
||||||
|
import { Button } from "./ui/button.js";
|
||||||
|
|
||||||
|
const TIER_LABEL_MAP: Partial<
|
||||||
|
Record<RulesEdition, Record<DifficultyTier, { label: string; color: string }>>
|
||||||
|
> = {
|
||||||
|
"5.5e": {
|
||||||
|
0: { label: "Trivial", color: "text-muted-foreground" },
|
||||||
|
1: { label: "Low", color: "text-green-500" },
|
||||||
|
2: { label: "Moderate", color: "text-yellow-500" },
|
||||||
|
3: { label: "High", color: "text-red-500" },
|
||||||
|
},
|
||||||
|
"5e": {
|
||||||
|
0: { label: "Easy", color: "text-muted-foreground" },
|
||||||
|
1: { label: "Medium", color: "text-green-500" },
|
||||||
|
2: { label: "Hard", color: "text-yellow-500" },
|
||||||
|
3: { label: "Deadly", color: "text-red-500" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Short labels for threshold display where horizontal space is limited. */
|
||||||
|
const SHORT_LABELS: Readonly<Record<string, string>> = {
|
||||||
|
Moderate: "Mod",
|
||||||
|
Medium: "Med",
|
||||||
|
};
|
||||||
|
|
||||||
|
function shortLabel(label: string): string {
|
||||||
|
return SHORT_LABELS[label] ?? label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatXp(xp: number): string {
|
||||||
|
return xp.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function PcRow({ entry }: { entry: BreakdownCombatant }) {
|
||||||
|
return (
|
||||||
|
<div className="col-span-4 grid grid-cols-subgrid items-center text-xs">
|
||||||
|
<span className="min-w-0 truncate" title={entry.combatant.name}>
|
||||||
|
{entry.combatant.name}
|
||||||
|
</span>
|
||||||
|
<span />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{entry.level === undefined ? "\u2014" : `Lv ${entry.level}`}
|
||||||
|
</span>
|
||||||
|
<span className="text-right tabular-nums">{"\u2014"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NpcRow({
|
||||||
|
entry,
|
||||||
|
onToggleSide,
|
||||||
|
}: {
|
||||||
|
entry: BreakdownCombatant;
|
||||||
|
onToggleSide: () => void;
|
||||||
|
}) {
|
||||||
|
const { setCr } = useEncounterContext();
|
||||||
|
const isParty = entry.side === "party";
|
||||||
|
const targetSide = isParty ? "enemy" : "party";
|
||||||
|
|
||||||
|
let xpDisplay: string;
|
||||||
|
if (entry.xp == null) {
|
||||||
|
xpDisplay = "\u2014";
|
||||||
|
} else if (isParty && entry.cr) {
|
||||||
|
xpDisplay = `\u2212${formatXp(entry.xp)}`;
|
||||||
|
} else {
|
||||||
|
xpDisplay = formatXp(entry.xp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="col-span-4 grid grid-cols-subgrid items-center text-xs">
|
||||||
|
<span className="min-w-0 truncate" title={entry.combatant.name}>
|
||||||
|
{entry.combatant.name}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onToggleSide}
|
||||||
|
aria-label={`Move ${entry.combatant.name} to ${targetSide} side`}
|
||||||
|
>
|
||||||
|
<ArrowLeftRight className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<span>
|
||||||
|
{entry.editable ? (
|
||||||
|
<CrPicker
|
||||||
|
value={entry.cr}
|
||||||
|
onChange={(cr) => setCr(entry.combatant.id, cr)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{entry.cr ? `CR ${entry.cr}` : "\u2014"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-right tabular-nums">{xpDisplay}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useClickOutside(ref, onClose);
|
||||||
|
const { setSide } = useEncounterContext();
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
|
|
||||||
|
const breakdown = useDifficultyBreakdown();
|
||||||
|
if (!breakdown) return null;
|
||||||
|
|
||||||
|
const tierLabels = TIER_LABEL_MAP[edition];
|
||||||
|
if (!tierLabels) return null;
|
||||||
|
const tierConfig = tierLabels[breakdown.tier];
|
||||||
|
|
||||||
|
const handleToggle = (entry: BreakdownCombatant) => {
|
||||||
|
const newSide = entry.side === "party" ? "enemy" : "party";
|
||||||
|
setSide(entry.combatant.id, newSide);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPC = (entry: BreakdownCombatant) =>
|
||||||
|
entry.combatant.playerCharacterId != null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="absolute top-full right-0 z-50 mt-1 w-80 rounded-lg border border-border bg-card p-3 shadow-lg max-sm:fixed max-sm:top-12 max-sm:right-3 max-sm:left-3 max-sm:w-auto"
|
||||||
|
>
|
||||||
|
<div className="mb-2 font-medium text-sm">
|
||||||
|
Encounter Difficulty:{" "}
|
||||||
|
<span className={tierConfig.color}>{tierConfig.label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-2 border-border border-t pt-2">
|
||||||
|
<div className="mb-1 text-muted-foreground text-xs">
|
||||||
|
Party Budget ({breakdown.pcCount}{" "}
|
||||||
|
{breakdown.pcCount === 1 ? "PC" : "PCs"})
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 text-xs">
|
||||||
|
{breakdown.thresholds.map((t) => (
|
||||||
|
<span key={t.label}>
|
||||||
|
{shortLabel(t.label)}: <strong>{formatXp(t.value)}</strong>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-border border-t pt-2 pb-2 text-muted-foreground text-xs italic">
|
||||||
|
Allied NPC XP is subtracted from encounter difficulty
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-border border-t pt-2">
|
||||||
|
<div className="mb-1 flex justify-between text-muted-foreground text-xs">
|
||||||
|
<span>Party</span>
|
||||||
|
<span>XP</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[1fr_auto_auto_3.5rem] gap-x-2 gap-y-1">
|
||||||
|
{breakdown.partyCombatants.map((entry) =>
|
||||||
|
isPC(entry) ? (
|
||||||
|
<PcRow key={entry.combatant.id} entry={entry} />
|
||||||
|
) : (
|
||||||
|
<NpcRow
|
||||||
|
key={entry.combatant.id}
|
||||||
|
entry={entry}
|
||||||
|
onToggleSide={() => handleToggle(entry)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 border-border border-t pt-2">
|
||||||
|
<div className="mb-1 flex justify-between text-muted-foreground text-xs">
|
||||||
|
<span>Enemy</span>
|
||||||
|
<span>XP</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[1fr_auto_auto_3.5rem] gap-x-2 gap-y-1">
|
||||||
|
{breakdown.enemyCombatants.map((entry) =>
|
||||||
|
isPC(entry) ? (
|
||||||
|
<PcRow key={entry.combatant.id} entry={entry} />
|
||||||
|
) : (
|
||||||
|
<NpcRow
|
||||||
|
key={entry.combatant.id}
|
||||||
|
entry={entry}
|
||||||
|
onToggleSide={() => handleToggle(entry)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{breakdown.encounterMultiplier !== undefined &&
|
||||||
|
breakdown.adjustedXp !== undefined ? (
|
||||||
|
<div className="mt-2 border-border border-t pt-2">
|
||||||
|
<div className="flex justify-between font-medium text-xs">
|
||||||
|
<span>Monster XP</span>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{formatXp(breakdown.totalMonsterXp)}{" "}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
×{breakdown.encounterMultiplier}
|
||||||
|
</span>{" "}
|
||||||
|
= {formatXp(breakdown.adjustedXp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{breakdown.partySizeAdjusted === true ? (
|
||||||
|
<div className="mt-0.5 text-muted-foreground text-xs italic">
|
||||||
|
Adjusted for {breakdown.pcCount}{" "}
|
||||||
|
{breakdown.pcCount === 1 ? "PC" : "PCs"}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 flex justify-between border-border border-t pt-2 font-medium text-xs">
|
||||||
|
<span>Net Monster XP</span>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{formatXp(breakdown.totalMonsterXp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,28 +1,58 @@
|
|||||||
import type { DifficultyResult, DifficultyTier } from "@initiative/domain";
|
import type { DifficultyResult, DifficultyTier } from "@initiative/domain";
|
||||||
import { cn } from "../lib/utils.js";
|
import { cn } from "../lib/utils.js";
|
||||||
|
|
||||||
const TIER_CONFIG: Record<
|
export const TIER_LABELS_5_5E: Record<DifficultyTier, string> = {
|
||||||
|
0: "Trivial",
|
||||||
|
1: "Low",
|
||||||
|
2: "Moderate",
|
||||||
|
3: "High",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TIER_LABELS_2014: Record<DifficultyTier, string> = {
|
||||||
|
0: "Easy",
|
||||||
|
1: "Medium",
|
||||||
|
2: "Hard",
|
||||||
|
3: "Deadly",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TIER_COLORS: Record<
|
||||||
DifficultyTier,
|
DifficultyTier,
|
||||||
{ filledBars: number; color: string; label: string }
|
{ filledBars: number; color: string }
|
||||||
> = {
|
> = {
|
||||||
trivial: { filledBars: 0, color: "", label: "Trivial" },
|
0: { filledBars: 0, color: "" },
|
||||||
low: { filledBars: 1, color: "bg-green-500", label: "Low" },
|
1: { filledBars: 1, color: "bg-green-500" },
|
||||||
moderate: { filledBars: 2, color: "bg-yellow-500", label: "Moderate" },
|
2: { filledBars: 2, color: "bg-yellow-500" },
|
||||||
high: { filledBars: 3, color: "bg-red-500", label: "High" },
|
3: { filledBars: 3, color: "bg-red-500" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const BAR_HEIGHTS = ["h-2", "h-3", "h-4"] as const;
|
const BAR_HEIGHTS = ["h-2", "h-3", "h-4"] as const;
|
||||||
|
|
||||||
export function DifficultyIndicator({ result }: { result: DifficultyResult }) {
|
export function DifficultyIndicator({
|
||||||
const config = TIER_CONFIG[result.tier];
|
result,
|
||||||
const tooltip = `${config.label} encounter difficulty`;
|
labels,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
result: DifficultyResult;
|
||||||
|
labels: Record<DifficultyTier, string>;
|
||||||
|
onClick?: () => void;
|
||||||
|
}) {
|
||||||
|
const config = TIER_COLORS[result.tier];
|
||||||
|
const label = labels[result.tier];
|
||||||
|
const tooltip = `${label} encounter difficulty`;
|
||||||
|
|
||||||
|
const Element = onClick ? "button" : "div";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Element
|
||||||
className="flex items-end gap-0.5"
|
className={cn(
|
||||||
|
"flex items-end gap-0.5",
|
||||||
|
onClick && "cursor-pointer rounded p-1 hover:bg-muted/50",
|
||||||
|
)}
|
||||||
title={tooltip}
|
title={tooltip}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={tooltip}
|
aria-label={tooltip}
|
||||||
|
onClick={onClick}
|
||||||
|
type={onClick ? "button" : undefined}
|
||||||
>
|
>
|
||||||
{BAR_HEIGHTS.map((height, i) => (
|
{BAR_HEIGHTS.map((height, i) => (
|
||||||
<div
|
<div
|
||||||
@@ -34,6 +64,6 @@ export function DifficultyIndicator({ result }: { result: DifficultyResult }) {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Element>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-52
@@ -1,10 +1,16 @@
|
|||||||
|
import type { Creature } from "@initiative/domain";
|
||||||
import {
|
import {
|
||||||
type Creature,
|
|
||||||
calculateInitiative,
|
calculateInitiative,
|
||||||
formatInitiativeModifier,
|
formatInitiativeModifier,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
|
import {
|
||||||
|
PropertyLine,
|
||||||
|
SectionDivider,
|
||||||
|
TraitEntry,
|
||||||
|
TraitSection,
|
||||||
|
} from "./stat-block-parts.js";
|
||||||
|
|
||||||
interface StatBlockProps {
|
interface DndStatBlockProps {
|
||||||
creature: Creature;
|
creature: Creature;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,53 +19,7 @@ function abilityMod(score: number): string {
|
|||||||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PropertyLine({
|
export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
||||||
label,
|
|
||||||
value,
|
|
||||||
}: Readonly<{
|
|
||||||
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-stat-divider-from via-stat-divider-via to-transparent" />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TraitSection({
|
|
||||||
entries,
|
|
||||||
heading,
|
|
||||||
}: Readonly<{
|
|
||||||
entries: readonly { name: string; text: string }[] | undefined;
|
|
||||||
heading?: string;
|
|
||||||
}>) {
|
|
||||||
if (!entries || entries.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SectionDivider />
|
|
||||||
{heading ? (
|
|
||||||
<h3 className="font-bold text-base text-stat-heading">{heading}</h3>
|
|
||||||
) : null}
|
|
||||||
<div className="space-y-2">
|
|
||||||
{entries.map((e) => (
|
|
||||||
<div key={e.name} className="text-sm">
|
|
||||||
<span className="font-semibold italic">{e.name}.</span> {e.text}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
|
||||||
const abilities = [
|
const abilities = [
|
||||||
{ label: "STR", score: creature.abilities.str },
|
{ label: "STR", score: creature.abilities.str },
|
||||||
{ label: "DEX", score: creature.abilities.dex },
|
{ label: "DEX", score: creature.abilities.dex },
|
||||||
@@ -219,9 +179,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
|||||||
</p>
|
</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{creature.legendaryActions.entries.map((a) => (
|
{creature.legendaryActions.entries.map((a) => (
|
||||||
<div key={a.name} className="text-sm">
|
<TraitEntry key={a.name} trait={a} />
|
||||||
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import type { Pf2eCreature } from "@initiative/domain";
|
||||||
|
import { formatInitiativeModifier } from "@initiative/domain";
|
||||||
|
import {
|
||||||
|
PropertyLine,
|
||||||
|
SectionDivider,
|
||||||
|
TraitSection,
|
||||||
|
} from "./stat-block-parts.js";
|
||||||
|
|
||||||
|
interface Pf2eStatBlockProps {
|
||||||
|
creature: Pf2eCreature;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALIGNMENTS = new Set([
|
||||||
|
"lg",
|
||||||
|
"ng",
|
||||||
|
"cg",
|
||||||
|
"ln",
|
||||||
|
"n",
|
||||||
|
"cn",
|
||||||
|
"le",
|
||||||
|
"ne",
|
||||||
|
"ce",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function capitalize(s: string): string {
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayTraits(traits: readonly string[]): string[] {
|
||||||
|
return traits.filter((t) => !ALIGNMENTS.has(t)).map(capitalize);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMod(mod: number): string {
|
||||||
|
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
||||||
|
const abilityEntries = [
|
||||||
|
{ label: "Str", mod: creature.abilityMods.str },
|
||||||
|
{ label: "Dex", mod: creature.abilityMods.dex },
|
||||||
|
{ label: "Con", mod: creature.abilityMods.con },
|
||||||
|
{ label: "Int", mod: creature.abilityMods.int },
|
||||||
|
{ label: "Wis", mod: creature.abilityMods.wis },
|
||||||
|
{ label: "Cha", mod: creature.abilityMods.cha },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1 text-foreground">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<h2 className="font-bold text-stat-heading text-xl">
|
||||||
|
{creature.name}
|
||||||
|
</h2>
|
||||||
|
<span className="shrink-0 font-semibold text-sm">
|
||||||
|
Level {creature.level}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{displayTraits(creature.traits).map((trait) => (
|
||||||
|
<span
|
||||||
|
key={trait}
|
||||||
|
className="rounded border border-border bg-card px-1.5 py-0.5 text-foreground text-xs"
|
||||||
|
>
|
||||||
|
{trait}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-muted-foreground text-xs">
|
||||||
|
{creature.sourceDisplayName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionDivider />
|
||||||
|
|
||||||
|
{/* Perception, Languages, Skills */}
|
||||||
|
<div className="space-y-0.5 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">Perception</span>{" "}
|
||||||
|
{formatInitiativeModifier(creature.perception)}
|
||||||
|
{creature.senses ? `; ${creature.senses}` : ""}
|
||||||
|
</div>
|
||||||
|
<PropertyLine label="Languages" value={creature.languages} />
|
||||||
|
<PropertyLine label="Skills" value={creature.skills} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ability Modifiers */}
|
||||||
|
<div className="grid grid-cols-6 gap-1 text-center text-sm">
|
||||||
|
{abilityEntries.map((a) => (
|
||||||
|
<div key={a.label}>
|
||||||
|
<div className="font-semibold text-muted-foreground text-xs">
|
||||||
|
{a.label}
|
||||||
|
</div>
|
||||||
|
<div>{formatMod(a.mod)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PropertyLine label="Items" value={creature.items} />
|
||||||
|
|
||||||
|
{/* Top abilities (before defenses) */}
|
||||||
|
<TraitSection entries={creature.abilitiesTop} />
|
||||||
|
|
||||||
|
<SectionDivider />
|
||||||
|
|
||||||
|
{/* Defenses */}
|
||||||
|
<div className="space-y-0.5 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">AC</span> {creature.ac}
|
||||||
|
{creature.acConditional ? ` (${creature.acConditional})` : ""};{" "}
|
||||||
|
<span className="font-semibold">Fort</span>{" "}
|
||||||
|
{formatMod(creature.saveFort)},{" "}
|
||||||
|
<span className="font-semibold">Ref</span>{" "}
|
||||||
|
{formatMod(creature.saveRef)},{" "}
|
||||||
|
<span className="font-semibold">Will</span>{" "}
|
||||||
|
{formatMod(creature.saveWill)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">HP</span> {creature.hp}
|
||||||
|
</div>
|
||||||
|
<PropertyLine label="Immunities" value={creature.immunities} />
|
||||||
|
<PropertyLine label="Resistances" value={creature.resistances} />
|
||||||
|
<PropertyLine label="Weaknesses" value={creature.weaknesses} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mid abilities (reactions, auras) */}
|
||||||
|
<TraitSection entries={creature.abilitiesMid} />
|
||||||
|
|
||||||
|
<SectionDivider />
|
||||||
|
|
||||||
|
{/* Speed */}
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-semibold">Speed</span> {creature.speed}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Attacks */}
|
||||||
|
<TraitSection entries={creature.attacks} />
|
||||||
|
|
||||||
|
{/* Bottom abilities (active abilities) */}
|
||||||
|
<TraitSection entries={creature.abilitiesBot} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ interface SettingsModalProps {
|
|||||||
const EDITION_OPTIONS: { value: RulesEdition; label: string }[] = [
|
const EDITION_OPTIONS: { value: RulesEdition; label: string }[] = [
|
||||||
{ value: "5e", label: "5e (2014)" },
|
{ value: "5e", label: "5e (2014)" },
|
||||||
{ value: "5.5e", label: "5.5e (2024)" },
|
{ value: "5.5e", label: "5.5e (2024)" },
|
||||||
|
{ value: "pf2e", label: "Pathfinder 2e" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const THEME_OPTIONS: {
|
const THEME_OPTIONS: {
|
||||||
@@ -36,7 +37,7 @@ export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
|||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<div>
|
<div>
|
||||||
<span className="mb-2 block font-medium text-muted-foreground text-sm">
|
<span className="mb-2 block font-medium text-muted-foreground text-sm">
|
||||||
Conditions
|
Game System
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{EDITION_OPTIONS.map((opt) => (
|
{EDITION_OPTIONS.map((opt) => (
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { Download, Loader2, Upload } from "lucide-react";
|
import { Download, Loader2, Upload } from "lucide-react";
|
||||||
import { useId, useRef, useState } from "react";
|
import { useId, useRef, useState } from "react";
|
||||||
import {
|
import { useAdapters } from "../contexts/adapter-context.js";
|
||||||
getDefaultFetchUrl,
|
|
||||||
getSourceDisplayName,
|
|
||||||
} from "../adapters/bestiary-index-adapter.js";
|
|
||||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
import { Input } from "./ui/input.js";
|
import { Input } from "./ui/input.js";
|
||||||
|
|
||||||
@@ -17,9 +15,14 @@ export function SourceFetchPrompt({
|
|||||||
sourceCode,
|
sourceCode,
|
||||||
onSourceLoaded,
|
onSourceLoaded,
|
||||||
}: Readonly<SourceFetchPromptProps>) {
|
}: Readonly<SourceFetchPromptProps>) {
|
||||||
|
const { bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
|
||||||
const { fetchAndCacheSource, uploadAndCacheSource } = useBestiaryContext();
|
const { fetchAndCacheSource, uploadAndCacheSource } = useBestiaryContext();
|
||||||
const sourceDisplayName = getSourceDisplayName(sourceCode);
|
const { edition } = useRulesEditionContext();
|
||||||
const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode));
|
const indexPort = edition === "pf2e" ? pf2eBestiaryIndex : bestiaryIndex;
|
||||||
|
const sourceDisplayName = indexPort.getSourceDisplayName(sourceCode);
|
||||||
|
const [url, setUrl] = useState(() =>
|
||||||
|
indexPort.getDefaultFetchUrl(sourceCode),
|
||||||
|
);
|
||||||
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
|
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
|
||||||
const [error, setError] = useState<string>("");
|
const [error, setError] = useState<string>("");
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|||||||
@@ -6,14 +6,18 @@ import {
|
|||||||
useOptimistic,
|
useOptimistic,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type { CachedSourceInfo } from "../adapters/bestiary-cache.js";
|
import type { CachedSourceInfo } from "../adapters/ports.js";
|
||||||
import * as bestiaryCache from "../adapters/bestiary-cache.js";
|
import { useAdapters } from "../contexts/adapter-context.js";
|
||||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
import { Input } from "./ui/input.js";
|
import { Input } from "./ui/input.js";
|
||||||
|
|
||||||
export function SourceManager() {
|
export function SourceManager() {
|
||||||
|
const { bestiaryCache } = useAdapters();
|
||||||
const { refreshCache } = useBestiaryContext();
|
const { refreshCache } = useBestiaryContext();
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
|
const system = edition === "pf2e" ? "pf2e" : "dnd";
|
||||||
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
|
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
|
||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
const [optimisticSources, applyOptimistic] = useOptimistic(
|
const [optimisticSources, applyOptimistic] = useOptimistic(
|
||||||
@@ -28,9 +32,9 @@ export function SourceManager() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const loadSources = useCallback(async () => {
|
const loadSources = useCallback(async () => {
|
||||||
const cached = await bestiaryCache.getCachedSources();
|
const cached = await bestiaryCache.getCachedSources(system);
|
||||||
setSources(cached);
|
setSources(cached);
|
||||||
}, []);
|
}, [bestiaryCache, system]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadSources();
|
void loadSources();
|
||||||
@@ -38,7 +42,7 @@ export function SourceManager() {
|
|||||||
|
|
||||||
const handleClearSource = async (sourceCode: string) => {
|
const handleClearSource = async (sourceCode: string) => {
|
||||||
applyOptimistic({ type: "remove", sourceCode });
|
applyOptimistic({ type: "remove", sourceCode });
|
||||||
await bestiaryCache.clearSource(sourceCode);
|
await bestiaryCache.clearSource(system, sourceCode);
|
||||||
await loadSources();
|
await loadSources();
|
||||||
void refreshCache();
|
void refreshCache();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { CreatureId } from "@initiative/domain";
|
import type { Creature, CreatureId } from "@initiative/domain";
|
||||||
import { PanelRightClose, Pin, PinOff } from "lucide-react";
|
import { PanelRightClose, Pin, PinOff } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -7,9 +7,10 @@ import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
|||||||
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
||||||
import { cn } from "../lib/utils.js";
|
import { cn } from "../lib/utils.js";
|
||||||
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
||||||
|
import { DndStatBlock } from "./dnd-stat-block.js";
|
||||||
|
import { Pf2eStatBlock } from "./pf2e-stat-block.js";
|
||||||
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
||||||
import { SourceManager } from "./source-manager.js";
|
import { SourceManager } from "./source-manager.js";
|
||||||
import { StatBlock } from "./stat-block.js";
|
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
|
|
||||||
interface StatBlockPanelProps {
|
interface StatBlockPanelProps {
|
||||||
@@ -307,7 +308,10 @@ export function StatBlockPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (creature) {
|
if (creature) {
|
||||||
return <StatBlock creature={creature} />;
|
if ("system" in creature && creature.system === "pf2e") {
|
||||||
|
return <Pf2eStatBlock creature={creature} />;
|
||||||
|
}
|
||||||
|
return <DndStatBlock creature={creature as Creature} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (needsFetch && sourceCode) {
|
if (needsFetch && sourceCode) {
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { TraitBlock, TraitSegment } from "@initiative/domain";
|
||||||
|
|
||||||
|
export function PropertyLine({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: Readonly<{
|
||||||
|
label: string;
|
||||||
|
value: string | undefined;
|
||||||
|
}>) {
|
||||||
|
if (!value) return null;
|
||||||
|
return (
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-semibold">{label}</span> {value}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionDivider() {
|
||||||
|
return (
|
||||||
|
<div className="my-2 h-px bg-gradient-to-r from-stat-divider-from via-stat-divider-via to-transparent" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentKey(seg: TraitSegment): string {
|
||||||
|
return seg.type === "text"
|
||||||
|
? seg.value.slice(0, 40)
|
||||||
|
: seg.items.map((i) => i.label ?? i.text.slice(0, 20)).join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
function TraitSegments({
|
||||||
|
segments,
|
||||||
|
}: Readonly<{ segments: readonly TraitSegment[] }>) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{segments.map((seg, i) => {
|
||||||
|
if (seg.type === "text") {
|
||||||
|
return (
|
||||||
|
<span key={segmentKey(seg)}>
|
||||||
|
{i === 0 ? ` ${seg.value}` : seg.value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={segmentKey(seg)} className="mt-1 space-y-0.5">
|
||||||
|
{seg.items.map((item) => (
|
||||||
|
<p key={item.label ?? item.text}>
|
||||||
|
{item.label != null && (
|
||||||
|
<span className="font-semibold">{item.label}. </span>
|
||||||
|
)}
|
||||||
|
{item.text}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TraitEntry({ trait }: Readonly<{ trait: TraitBlock }>) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-semibold italic">{trait.name}.</span>
|
||||||
|
<TraitSegments segments={trait.segments} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TraitSection({
|
||||||
|
entries,
|
||||||
|
heading,
|
||||||
|
}: Readonly<{
|
||||||
|
entries: readonly TraitBlock[] | undefined;
|
||||||
|
heading?: string;
|
||||||
|
}>) {
|
||||||
|
if (!entries || entries.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionDivider />
|
||||||
|
{heading ? (
|
||||||
|
<h3 className="font-bold text-base text-stat-heading">{heading}</h3>
|
||||||
|
) : null}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{entries.map((e) => (
|
||||||
|
<TraitEntry key={e.name} trait={e} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
import { Redo2, StepBack, StepForward, Trash2, Undo2 } from "lucide-react";
|
import { Redo2, StepBack, StepForward, Trash2, Undo2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
import { useDifficulty } from "../hooks/use-difficulty.js";
|
import { useDifficulty } from "../hooks/use-difficulty.js";
|
||||||
import { DifficultyIndicator } from "./difficulty-indicator.js";
|
import { DifficultyBreakdownPanel } from "./difficulty-breakdown-panel.js";
|
||||||
|
import {
|
||||||
|
DifficultyIndicator,
|
||||||
|
TIER_LABELS_5_5E,
|
||||||
|
TIER_LABELS_2014,
|
||||||
|
} from "./difficulty-indicator.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
import { ConfirmButton } from "./ui/confirm-button.js";
|
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||||
|
|
||||||
@@ -18,24 +25,27 @@ export function TurnNavigation() {
|
|||||||
} = useEncounterContext();
|
} = useEncounterContext();
|
||||||
|
|
||||||
const difficulty = useDifficulty();
|
const difficulty = useDifficulty();
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
|
const tierLabels = edition === "5e" ? TIER_LABELS_2014 : TIER_LABELS_5_5E;
|
||||||
|
const [showBreakdown, setShowBreakdown] = useState(false);
|
||||||
const hasCombatants = encounter.combatants.length > 0;
|
const hasCombatants = encounter.combatants.length > 0;
|
||||||
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
||||||
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card-glow flex items-center gap-3 border-border border-b bg-card px-4 py-3 sm:rounded-lg sm:border">
|
<div className="card-glow grid grid-cols-[1fr_minmax(0,auto)_1fr] items-center border-border border-b bg-card px-2 py-3 sm:rounded-lg sm:border sm:px-4">
|
||||||
<Button
|
{/* Left zone: navigation + history + round */}
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={retreatTurn}
|
|
||||||
disabled={!hasCombatants || isAtStart}
|
|
||||||
title="Previous turn"
|
|
||||||
aria-label="Previous turn"
|
|
||||||
>
|
|
||||||
<StepBack className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={retreatTurn}
|
||||||
|
disabled={!hasCombatants || isAtStart}
|
||||||
|
title="Previous turn"
|
||||||
|
aria-label="Previous turn"
|
||||||
|
>
|
||||||
|
<StepBack className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -56,23 +66,36 @@ export function TurnNavigation() {
|
|||||||
>
|
>
|
||||||
<Redo2 className="h-4 w-4" />
|
<Redo2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<span className="ml-1 rounded-md bg-muted px-2 py-0.5 font-semibold text-foreground text-sm tabular-nums">
|
||||||
|
R{encounter.roundNumber}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 items-center justify-center gap-2 text-sm">
|
{/* Center zone: active combatant name */}
|
||||||
<span className="shrink-0 rounded-md bg-muted px-2 py-0.5 font-semibold text-foreground text-sm">
|
<div className="min-w-0 px-2 text-center text-sm">
|
||||||
<span className="-mt-[3px] inline-block">
|
|
||||||
R{encounter.roundNumber}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
{activeCombatant ? (
|
{activeCombatant ? (
|
||||||
<span className="truncate font-medium">{activeCombatant.name}</span>
|
<span className="truncate font-medium">{activeCombatant.name}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">No combatants</span>
|
<span className="text-muted-foreground">No combatants</span>
|
||||||
)}
|
)}
|
||||||
{difficulty && <DifficultyIndicator result={difficulty} />}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-shrink-0 items-center gap-3">
|
{/* Right zone: difficulty + destructive + forward */}
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
{difficulty && (
|
||||||
|
<div className="relative mr-1">
|
||||||
|
<DifficultyIndicator
|
||||||
|
result={difficulty}
|
||||||
|
labels={tierLabels}
|
||||||
|
onClick={() => setShowBreakdown((prev) => !prev)}
|
||||||
|
/>
|
||||||
|
{showBreakdown ? (
|
||||||
|
<DifficultyBreakdownPanel
|
||||||
|
onClose={() => setShowBreakdown(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
icon={<Trash2 className="h-5 w-5" />}
|
icon={<Trash2 className="h-5 w-5" />}
|
||||||
label="Clear encounter"
|
label="Clear encounter"
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { createContext, type ReactNode, useContext } from "react";
|
||||||
|
import type {
|
||||||
|
BestiaryCachePort,
|
||||||
|
BestiaryIndexPort,
|
||||||
|
EncounterPersistence,
|
||||||
|
Pf2eBestiaryIndexPort,
|
||||||
|
PlayerCharacterPersistence,
|
||||||
|
UndoRedoPersistence,
|
||||||
|
} from "../adapters/ports.js";
|
||||||
|
|
||||||
|
export interface Adapters {
|
||||||
|
encounterPersistence: EncounterPersistence;
|
||||||
|
undoRedoPersistence: UndoRedoPersistence;
|
||||||
|
playerCharacterPersistence: PlayerCharacterPersistence;
|
||||||
|
bestiaryCache: BestiaryCachePort;
|
||||||
|
bestiaryIndex: BestiaryIndexPort;
|
||||||
|
pf2eBestiaryIndex: Pf2eBestiaryIndexPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdapterContext = createContext<Adapters | null>(null);
|
||||||
|
|
||||||
|
export function AdapterProvider({
|
||||||
|
adapters,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
adapters: Adapters;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AdapterContext.Provider value={adapters}>
|
||||||
|
{children}
|
||||||
|
</AdapterContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdapters(): Adapters {
|
||||||
|
const ctx = useContext(AdapterContext);
|
||||||
|
if (!ctx) throw new Error("useAdapters requires AdapterProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -1,8 +1,4 @@
|
|||||||
import type {
|
import type { ConditionId, PlayerCharacter } from "@initiative/domain";
|
||||||
BestiaryIndexEntry,
|
|
||||||
ConditionId,
|
|
||||||
PlayerCharacter,
|
|
||||||
} from "@initiative/domain";
|
|
||||||
import {
|
import {
|
||||||
combatantId,
|
combatantId,
|
||||||
createEncounter,
|
createEncounter,
|
||||||
@@ -10,19 +6,10 @@ import {
|
|||||||
isDomainError,
|
isDomainError,
|
||||||
playerCharacterId,
|
playerCharacterId,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { SearchResult } from "../use-bestiary.js";
|
||||||
import { type EncounterState, encounterReducer } from "../use-encounter.js";
|
import { type EncounterState, encounterReducer } from "../use-encounter.js";
|
||||||
|
|
||||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
|
||||||
loadEncounter: vi.fn().mockReturnValue(null),
|
|
||||||
saveEncounter: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../persistence/undo-redo-storage.js", () => ({
|
|
||||||
loadUndoRedoStacks: vi.fn().mockReturnValue(EMPTY_UNDO_REDO_STATE),
|
|
||||||
saveUndoRedoStacks: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
function emptyState(): EncounterState {
|
function emptyState(): EncounterState {
|
||||||
return {
|
return {
|
||||||
encounter: {
|
encounter: {
|
||||||
@@ -55,9 +42,11 @@ function stateWithHp(name: string, maxHp: number): EncounterState {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const BESTIARY_ENTRY: BestiaryIndexEntry = {
|
const BESTIARY_ENTRY: SearchResult = {
|
||||||
|
system: "dnd",
|
||||||
name: "Goblin",
|
name: "Goblin",
|
||||||
source: "MM",
|
source: "MM",
|
||||||
|
sourceDisplayName: "Monster Manual",
|
||||||
ac: 15,
|
ac: 15,
|
||||||
hp: 7,
|
hp: 7,
|
||||||
dex: 14,
|
dex: 14,
|
||||||
@@ -67,6 +56,19 @@ const BESTIARY_ENTRY: BestiaryIndexEntry = {
|
|||||||
type: "humanoid",
|
type: "humanoid",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PF2E_BESTIARY_ENTRY: SearchResult = {
|
||||||
|
system: "pf2e",
|
||||||
|
name: "Goblin Warrior",
|
||||||
|
source: "B1",
|
||||||
|
sourceDisplayName: "Bestiary",
|
||||||
|
level: -1,
|
||||||
|
ac: 16,
|
||||||
|
hp: 6,
|
||||||
|
perception: 5,
|
||||||
|
size: "small",
|
||||||
|
type: "humanoid",
|
||||||
|
};
|
||||||
|
|
||||||
describe("encounterReducer", () => {
|
describe("encounterReducer", () => {
|
||||||
describe("add-combatant", () => {
|
describe("add-combatant", () => {
|
||||||
it("adds a combatant and pushes undo", () => {
|
it("adds a combatant and pushes undo", () => {
|
||||||
@@ -246,7 +248,9 @@ describe("encounterReducer", () => {
|
|||||||
conditionId: "blinded" as ConditionId,
|
conditionId: "blinded" as ConditionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(next.encounter.combatants[0].conditions).toContain("blinded");
|
expect(next.encounter.combatants[0].conditions).toContainEqual({
|
||||||
|
id: "blinded",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("toggles concentration", () => {
|
it("toggles concentration", () => {
|
||||||
@@ -337,6 +341,19 @@ describe("encounterReducer", () => {
|
|||||||
expect(names).toContain("Goblin 1");
|
expect(names).toContain("Goblin 1");
|
||||||
expect(names).toContain("Goblin 2");
|
expect(names).toContain("Goblin 2");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("adds PF2e creature with HP, AC, and creatureId", () => {
|
||||||
|
const next = encounterReducer(emptyState(), {
|
||||||
|
type: "add-from-bestiary",
|
||||||
|
entry: PF2E_BESTIARY_ENTRY,
|
||||||
|
});
|
||||||
|
|
||||||
|
const c = next.encounter.combatants[0];
|
||||||
|
expect(c.name).toBe("Goblin Warrior");
|
||||||
|
expect(c.maxHp).toBe(6);
|
||||||
|
expect(c.ac).toBe(16);
|
||||||
|
expect(c.creatureId).toBe("b1:goblin-warrior");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("add-multiple-from-bestiary", () => {
|
describe("add-multiple-from-bestiary", () => {
|
||||||
|
|||||||
@@ -1,328 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
import type { PlayerCharacter } from "@initiative/domain";
|
|
||||||
import { playerCharacterId } from "@initiative/domain";
|
|
||||||
import { act, renderHook } from "@testing-library/react";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { SearchResult } from "../../contexts/bestiary-context.js";
|
|
||||||
import { useActionBarState } from "../use-action-bar-state.js";
|
|
||||||
|
|
||||||
const mockAddCombatant = vi.fn();
|
|
||||||
const mockAddFromBestiary = vi.fn();
|
|
||||||
const mockAddMultipleFromBestiary = vi.fn();
|
|
||||||
const mockAddFromPlayerCharacter = vi.fn();
|
|
||||||
const mockBestiarySearch = vi.fn<(q: string) => SearchResult[]>();
|
|
||||||
const mockShowCreature = vi.fn();
|
|
||||||
const mockShowBulkImport = vi.fn();
|
|
||||||
const mockShowSourceManager = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("../../contexts/encounter-context.js", () => ({
|
|
||||||
useEncounterContext: () => ({
|
|
||||||
addCombatant: mockAddCombatant,
|
|
||||||
addFromBestiary: mockAddFromBestiary,
|
|
||||||
addMultipleFromBestiary: mockAddMultipleFromBestiary,
|
|
||||||
addFromPlayerCharacter: mockAddFromPlayerCharacter,
|
|
||||||
lastCreatureId: null,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
|
||||||
useBestiaryContext: () => ({
|
|
||||||
search: mockBestiarySearch,
|
|
||||||
isLoaded: true,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../contexts/player-characters-context.js", () => ({
|
|
||||||
usePlayerCharactersContext: () => ({
|
|
||||||
characters: mockPlayerCharacters,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../contexts/side-panel-context.js", () => ({
|
|
||||||
useSidePanelContext: () => ({
|
|
||||||
showCreature: mockShowCreature,
|
|
||||||
showBulkImport: mockShowBulkImport,
|
|
||||||
showSourceManager: mockShowSourceManager,
|
|
||||||
panelView: { mode: "closed" },
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
let mockPlayerCharacters: PlayerCharacter[] = [];
|
|
||||||
|
|
||||||
const GOBLIN: SearchResult = {
|
|
||||||
name: "Goblin",
|
|
||||||
source: "MM",
|
|
||||||
sourceDisplayName: "Monster Manual",
|
|
||||||
ac: 15,
|
|
||||||
hp: 7,
|
|
||||||
dex: 14,
|
|
||||||
cr: "1/4",
|
|
||||||
initiativeProficiency: 0,
|
|
||||||
size: "Small",
|
|
||||||
type: "humanoid",
|
|
||||||
};
|
|
||||||
|
|
||||||
const ORC: SearchResult = {
|
|
||||||
name: "Orc",
|
|
||||||
source: "MM",
|
|
||||||
sourceDisplayName: "Monster Manual",
|
|
||||||
ac: 13,
|
|
||||||
hp: 15,
|
|
||||||
dex: 12,
|
|
||||||
cr: "1/2",
|
|
||||||
initiativeProficiency: 0,
|
|
||||||
size: "Medium",
|
|
||||||
type: "humanoid",
|
|
||||||
};
|
|
||||||
|
|
||||||
function renderActionBar() {
|
|
||||||
return renderHook(() => useActionBarState());
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("useActionBarState", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockBestiarySearch.mockReturnValue([]);
|
|
||||||
mockPlayerCharacters = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("search and suggestions", () => {
|
|
||||||
it("starts with empty state", () => {
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
expect(result.current.nameInput).toBe("");
|
|
||||||
expect(result.current.suggestions).toEqual([]);
|
|
||||||
expect(result.current.queued).toBeNull();
|
|
||||||
expect(result.current.browseMode).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("searches bestiary when input >= 2 chars", () => {
|
|
||||||
mockBestiarySearch.mockReturnValue([GOBLIN]);
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.handleNameChange("go"));
|
|
||||||
|
|
||||||
expect(mockBestiarySearch).toHaveBeenCalledWith("go");
|
|
||||||
expect(result.current.nameInput).toBe("go");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not search when input < 2 chars", () => {
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.handleNameChange("g"));
|
|
||||||
|
|
||||||
expect(mockBestiarySearch).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches player characters by name", () => {
|
|
||||||
mockPlayerCharacters = [
|
|
||||||
{
|
|
||||||
id: playerCharacterId("pc-1"),
|
|
||||||
name: "Gandalf",
|
|
||||||
ac: 15,
|
|
||||||
maxHp: 40,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
mockBestiarySearch.mockReturnValue([]);
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.handleNameChange("gan"));
|
|
||||||
|
|
||||||
expect(result.current.pcMatches).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("queued creatures", () => {
|
|
||||||
it("queues a creature on click", () => {
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
|
||||||
|
|
||||||
expect(result.current.queued).toEqual({
|
|
||||||
result: GOBLIN,
|
|
||||||
count: 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("increments count when same creature clicked again", () => {
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
|
||||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
|
||||||
|
|
||||||
expect(result.current.queued?.count).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resets queue when different creature clicked", () => {
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
|
||||||
act(() => result.current.suggestionActions.clickSuggestion(ORC));
|
|
||||||
|
|
||||||
expect(result.current.queued).toEqual({
|
|
||||||
result: ORC,
|
|
||||||
count: 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("confirmQueued calls addFromBestiary for count=1", () => {
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
|
||||||
act(() => result.current.suggestionActions.confirmQueued());
|
|
||||||
|
|
||||||
expect(mockAddFromBestiary).toHaveBeenCalledWith(GOBLIN);
|
|
||||||
expect(result.current.queued).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("confirmQueued calls addMultipleFromBestiary for count>1", () => {
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
|
||||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
|
||||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
|
||||||
act(() => result.current.suggestionActions.confirmQueued());
|
|
||||||
|
|
||||||
expect(mockAddMultipleFromBestiary).toHaveBeenCalledWith(GOBLIN, 3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears queued when search text changes and creature no longer visible", () => {
|
|
||||||
mockBestiarySearch.mockReturnValue([GOBLIN]);
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.handleNameChange("go"));
|
|
||||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
|
||||||
|
|
||||||
// Change search to something that won't match
|
|
||||||
mockBestiarySearch.mockReturnValue([]);
|
|
||||||
act(() => result.current.handleNameChange("xyz"));
|
|
||||||
|
|
||||||
expect(result.current.queued).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("form submission", () => {
|
|
||||||
it("adds custom combatant on submit", () => {
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.handleNameChange("Fighter"));
|
|
||||||
|
|
||||||
const event = {
|
|
||||||
preventDefault: vi.fn(),
|
|
||||||
} as unknown as React.SubmitEvent<HTMLFormElement>;
|
|
||||||
act(() => result.current.handleAdd(event));
|
|
||||||
|
|
||||||
expect(mockAddCombatant).toHaveBeenCalledWith("Fighter", undefined);
|
|
||||||
expect(result.current.nameInput).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not add when name is empty", () => {
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
const event = {
|
|
||||||
preventDefault: vi.fn(),
|
|
||||||
} as unknown as React.SubmitEvent<HTMLFormElement>;
|
|
||||||
act(() => result.current.handleAdd(event));
|
|
||||||
|
|
||||||
expect(mockAddCombatant).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes custom init/ac/maxHp when set", () => {
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.handleNameChange("Fighter"));
|
|
||||||
act(() => result.current.setCustomInit("15"));
|
|
||||||
act(() => result.current.setCustomAc("18"));
|
|
||||||
act(() => result.current.setCustomMaxHp("45"));
|
|
||||||
|
|
||||||
const event = {
|
|
||||||
preventDefault: vi.fn(),
|
|
||||||
} as unknown as React.SubmitEvent<HTMLFormElement>;
|
|
||||||
act(() => result.current.handleAdd(event));
|
|
||||||
|
|
||||||
expect(mockAddCombatant).toHaveBeenCalledWith("Fighter", {
|
|
||||||
initiative: 15,
|
|
||||||
ac: 18,
|
|
||||||
maxHp: 45,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not submit in browse mode", () => {
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.toggleBrowseMode());
|
|
||||||
act(() => result.current.handleNameChange("Fighter"));
|
|
||||||
|
|
||||||
const event = {
|
|
||||||
preventDefault: vi.fn(),
|
|
||||||
} as unknown as React.SubmitEvent<HTMLFormElement>;
|
|
||||||
act(() => result.current.handleAdd(event));
|
|
||||||
|
|
||||||
expect(mockAddCombatant).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("confirms queued on submit instead of adding by name", () => {
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
|
||||||
|
|
||||||
const event = {
|
|
||||||
preventDefault: vi.fn(),
|
|
||||||
} as unknown as React.SubmitEvent<HTMLFormElement>;
|
|
||||||
act(() => result.current.handleAdd(event));
|
|
||||||
|
|
||||||
expect(mockAddFromBestiary).toHaveBeenCalledWith(GOBLIN);
|
|
||||||
expect(mockAddCombatant).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("browse mode", () => {
|
|
||||||
it("toggles browse mode", () => {
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.toggleBrowseMode());
|
|
||||||
expect(result.current.browseMode).toBe(true);
|
|
||||||
|
|
||||||
act(() => result.current.toggleBrowseMode());
|
|
||||||
expect(result.current.browseMode).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handleBrowseSelect shows creature and exits browse mode", () => {
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.toggleBrowseMode());
|
|
||||||
act(() => result.current.handleBrowseSelect(GOBLIN));
|
|
||||||
|
|
||||||
expect(mockShowCreature).toHaveBeenCalledWith("mm:goblin");
|
|
||||||
expect(result.current.browseMode).toBe(false);
|
|
||||||
expect(result.current.nameInput).toBe("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("dismiss and clear", () => {
|
|
||||||
it("dismissSuggestions clears suggestions and queued", () => {
|
|
||||||
mockBestiarySearch.mockReturnValue([GOBLIN]);
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.handleNameChange("go"));
|
|
||||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
|
||||||
act(() => result.current.suggestionActions.dismiss());
|
|
||||||
|
|
||||||
expect(result.current.queued).toBeNull();
|
|
||||||
expect(result.current.suggestionIndex).toBe(-1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clear resets everything", () => {
|
|
||||||
mockBestiarySearch.mockReturnValue([GOBLIN]);
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.handleNameChange("go"));
|
|
||||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
|
||||||
act(() => result.current.suggestionActions.clear());
|
|
||||||
|
|
||||||
expect(result.current.nameInput).toBe("");
|
|
||||||
expect(result.current.queued).toBeNull();
|
|
||||||
expect(result.current.suggestionIndex).toBe(-1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
|
import { useBulkImport } from "../use-bulk-import.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const adapters = createTestAdapters();
|
||||||
|
adapters.bestiaryIndex = {
|
||||||
|
...adapters.bestiaryIndex,
|
||||||
|
getAllSourceCodes: () => ["MM", "VGM", "XGE"],
|
||||||
|
getDefaultFetchUrl: (code: string, baseUrl?: string) =>
|
||||||
|
`${baseUrl}${code}.json`,
|
||||||
|
};
|
||||||
|
|
||||||
|
function wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return <AllProviders adapters={adapters}>{children}</AllProviders>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flush microtasks so the internal async IIFE inside startImport settles. */
|
||||||
|
function flushMicrotasks(): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useBulkImport", () => {
|
||||||
|
it("starts in idle state with all counters at 0", () => {
|
||||||
|
const { result } = renderHook(() => useBulkImport(), { wrapper });
|
||||||
|
expect(result.current.state).toEqual({
|
||||||
|
status: "idle",
|
||||||
|
total: 0,
|
||||||
|
completed: 0,
|
||||||
|
failed: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reset returns to idle state", async () => {
|
||||||
|
const { result } = renderHook(() => useBulkImport(), { wrapper });
|
||||||
|
|
||||||
|
const isSourceCached = vi.fn().mockResolvedValue(true);
|
||||||
|
const fetchAndCacheSource = vi.fn();
|
||||||
|
const refreshCache = vi.fn();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.startImport(
|
||||||
|
"https://example.com/",
|
||||||
|
fetchAndCacheSource,
|
||||||
|
isSourceCached,
|
||||||
|
refreshCache,
|
||||||
|
);
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => result.current.reset());
|
||||||
|
expect(result.current.state.status).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("goes straight to complete when all sources are cached", async () => {
|
||||||
|
const { result } = renderHook(() => useBulkImport(), { wrapper });
|
||||||
|
|
||||||
|
const isSourceCached = vi.fn().mockResolvedValue(true);
|
||||||
|
const fetchAndCacheSource = vi.fn();
|
||||||
|
const refreshCache = vi.fn();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.startImport(
|
||||||
|
"https://example.com/",
|
||||||
|
fetchAndCacheSource,
|
||||||
|
isSourceCached,
|
||||||
|
refreshCache,
|
||||||
|
);
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.state.status).toBe("complete");
|
||||||
|
expect(result.current.state.completed).toBe(3);
|
||||||
|
expect(fetchAndCacheSource).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches uncached sources and completes", async () => {
|
||||||
|
const { result } = renderHook(() => useBulkImport(), { wrapper });
|
||||||
|
|
||||||
|
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||||
|
const fetchAndCacheSource = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.startImport(
|
||||||
|
"https://example.com/",
|
||||||
|
fetchAndCacheSource,
|
||||||
|
isSourceCached,
|
||||||
|
refreshCache,
|
||||||
|
);
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.state.status).toBe("complete");
|
||||||
|
expect(result.current.state.completed).toBe(3);
|
||||||
|
expect(result.current.state.failed).toBe(0);
|
||||||
|
expect(fetchAndCacheSource).toHaveBeenCalledTimes(3);
|
||||||
|
expect(refreshCache).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports partial-failure when some sources fail", async () => {
|
||||||
|
const { result } = renderHook(() => useBulkImport(), { wrapper });
|
||||||
|
|
||||||
|
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||||
|
const fetchAndCacheSource = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(undefined)
|
||||||
|
.mockRejectedValueOnce(new Error("fail"))
|
||||||
|
.mockResolvedValueOnce(undefined);
|
||||||
|
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.startImport(
|
||||||
|
"https://example.com/",
|
||||||
|
fetchAndCacheSource,
|
||||||
|
isSourceCached,
|
||||||
|
refreshCache,
|
||||||
|
);
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.state.status).toBe("partial-failure");
|
||||||
|
expect(result.current.state.completed).toBe(2);
|
||||||
|
expect(result.current.state.failed).toBe(1);
|
||||||
|
expect(refreshCache).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls refreshCache after all batches complete", async () => {
|
||||||
|
const { result } = renderHook(() => useBulkImport(), { wrapper });
|
||||||
|
|
||||||
|
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||||
|
const fetchAndCacheSource = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.startImport(
|
||||||
|
"https://example.com/",
|
||||||
|
fetchAndCacheSource,
|
||||||
|
isSourceCached,
|
||||||
|
refreshCache,
|
||||||
|
);
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refreshCache).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||||
|
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
|
import {
|
||||||
|
buildCombatant,
|
||||||
|
buildCreature,
|
||||||
|
buildEncounter,
|
||||||
|
} from "../../__tests__/factories/index.js";
|
||||||
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
|
import { useDifficultyBreakdown } from "../use-difficulty-breakdown.js";
|
||||||
|
import { useRulesEdition } from "../use-rules-edition.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const pcId1 = playerCharacterId("pc-1");
|
||||||
|
const goblinCreature = buildCreature({
|
||||||
|
id: creatureId("srd:goblin"),
|
||||||
|
name: "Goblin",
|
||||||
|
cr: "1/4",
|
||||||
|
source: "srd",
|
||||||
|
sourceDisplayName: "SRD",
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeWrapper(options: {
|
||||||
|
encounter: ReturnType<typeof buildEncounter>;
|
||||||
|
playerCharacters?: PlayerCharacter[];
|
||||||
|
creatures?: Map<CreatureId, Creature>;
|
||||||
|
}) {
|
||||||
|
const adapters = createTestAdapters({
|
||||||
|
encounter: options.encounter,
|
||||||
|
playerCharacters: options.playerCharacters ?? [],
|
||||||
|
creatures: options.creatures,
|
||||||
|
});
|
||||||
|
return ({ children }: { children: ReactNode }) => (
|
||||||
|
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useDifficultyBreakdown", () => {
|
||||||
|
it("returns null when no leveled PCs", () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: goblinCreature.id,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30 }],
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
expect(result.current).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when no monsters with CR", () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Custom",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
expect(result.current).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns per-combatant entries split by side", async () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: goblinCreature.id,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-3"),
|
||||||
|
name: "Custom Thug",
|
||||||
|
cr: "2",
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-4"),
|
||||||
|
name: "Bandit",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const breakdown = result.current;
|
||||||
|
expect(breakdown).not.toBeNull();
|
||||||
|
expect(breakdown?.pcCount).toBe(1);
|
||||||
|
// CR 1/4 = 50 + CR 2 = 450 -> total 500
|
||||||
|
expect(breakdown?.totalMonsterXp).toBe(500);
|
||||||
|
|
||||||
|
// PC in party column
|
||||||
|
expect(breakdown?.partyCombatants).toHaveLength(1);
|
||||||
|
expect(breakdown?.partyCombatants[0].combatant.name).toBe("Hero");
|
||||||
|
expect(breakdown?.partyCombatants[0].side).toBe("party");
|
||||||
|
expect(breakdown?.partyCombatants[0].level).toBe(5);
|
||||||
|
|
||||||
|
// Enemies: goblin, thug, bandit
|
||||||
|
expect(breakdown?.enemyCombatants).toHaveLength(3);
|
||||||
|
|
||||||
|
const goblin = breakdown?.enemyCombatants[0];
|
||||||
|
expect(goblin?.cr).toBe("1/4");
|
||||||
|
expect(goblin?.xp).toBe(50);
|
||||||
|
expect(goblin?.source).toBe("SRD");
|
||||||
|
expect(goblin?.editable).toBe(false);
|
||||||
|
expect(goblin?.side).toBe("enemy");
|
||||||
|
|
||||||
|
const thug = breakdown?.enemyCombatants[1];
|
||||||
|
expect(thug?.cr).toBe("2");
|
||||||
|
expect(thug?.xp).toBe(450);
|
||||||
|
expect(thug?.source).toBeNull();
|
||||||
|
expect(thug?.editable).toBe(true);
|
||||||
|
|
||||||
|
const bandit = breakdown?.enemyCombatants[2];
|
||||||
|
expect(bandit?.cr).toBeNull();
|
||||||
|
expect(bandit?.xp).toBeNull();
|
||||||
|
expect(bandit?.editable).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bestiary combatant with missing creature is non-editable with null CR", () => {
|
||||||
|
const missingCreatureId = creatureId("creature-missing");
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Ghost",
|
||||||
|
creatureId: missingCreatureId,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-3"),
|
||||||
|
name: "Thug",
|
||||||
|
cr: "1",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
const breakdown = result.current;
|
||||||
|
expect(breakdown).not.toBeNull();
|
||||||
|
const ghost = breakdown?.enemyCombatants[0];
|
||||||
|
expect(ghost?.cr).toBeNull();
|
||||||
|
expect(ghost?.xp).toBeNull();
|
||||||
|
expect(ghost?.editable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PC combatants appear in partyCombatants with level", async () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: goblinCreature.id,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current?.partyCombatants).toHaveLength(1);
|
||||||
|
expect(result.current?.partyCombatants[0].combatant.name).toBe("Hero");
|
||||||
|
expect(result.current?.partyCombatants[0].level).toBe(1);
|
||||||
|
expect(result.current?.partyCombatants[0].side).toBe("party");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("combatant with explicit side override is placed correctly", () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Allied Guard",
|
||||||
|
creatureId: goblinCreature.id,
|
||||||
|
side: "party",
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-3"),
|
||||||
|
name: "Thug",
|
||||||
|
cr: "1",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
const breakdown = result.current;
|
||||||
|
expect(breakdown).not.toBeNull();
|
||||||
|
// Allied Guard should be in party column
|
||||||
|
expect(breakdown?.partyCombatants).toHaveLength(2);
|
||||||
|
expect(breakdown?.partyCombatants[1].combatant.name).toBe("Allied Guard");
|
||||||
|
expect(breakdown?.partyCombatants[1].side).toBe("party");
|
||||||
|
// Thug in enemy column
|
||||||
|
expect(breakdown?.enemyCombatants).toHaveLength(1);
|
||||||
|
expect(breakdown?.enemyCombatants[0].combatant.name).toBe("Thug");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes encounterMultiplier and adjustedXp for 5e edition", async () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: goblinCreature.id,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-3"),
|
||||||
|
name: "Thug",
|
||||||
|
cr: "1",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
editionResult.current.setEdition("5e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const breakdown = result.current;
|
||||||
|
expect(breakdown).not.toBeNull();
|
||||||
|
// 2 enemy monsters, 1 PC (<3) → base x1.5, shift up → x2
|
||||||
|
expect(breakdown?.encounterMultiplier).toBe(2);
|
||||||
|
// CR 1/4 (50) + CR 1 (200) = 250, x2 = 500
|
||||||
|
expect(breakdown?.totalMonsterXp).toBe(250);
|
||||||
|
expect(breakdown?.adjustedXp).toBe(500);
|
||||||
|
expect(breakdown?.thresholds).toHaveLength(4);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||||
|
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
|
import {
|
||||||
|
buildCombatant,
|
||||||
|
buildCreature,
|
||||||
|
buildEncounter,
|
||||||
|
} from "../../__tests__/factories/index.js";
|
||||||
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
|
import { useDifficulty } from "../use-difficulty.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const pcId1 = playerCharacterId("pc-1");
|
||||||
|
const goblinCreature = buildCreature({
|
||||||
|
id: creatureId("srd:goblin"),
|
||||||
|
name: "Goblin",
|
||||||
|
cr: "1/4",
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeWrapper(options: {
|
||||||
|
encounter: ReturnType<typeof buildEncounter>;
|
||||||
|
playerCharacters?: PlayerCharacter[];
|
||||||
|
creatures?: Map<CreatureId, Creature>;
|
||||||
|
}) {
|
||||||
|
const adapters = createTestAdapters({
|
||||||
|
encounter: options.encounter,
|
||||||
|
playerCharacters: options.playerCharacters ?? [],
|
||||||
|
creatures: options.creatures,
|
||||||
|
});
|
||||||
|
return ({ children }: { children: ReactNode }) => (
|
||||||
|
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useDifficulty with custom combatant CRs", () => {
|
||||||
|
it("includes custom combatant with cr field in monster XP", () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Custom Thug",
|
||||||
|
cr: "2",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
|
||||||
|
expect(result.current).not.toBeNull();
|
||||||
|
expect(result.current?.totalMonsterXp).toBe(450);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses bestiary CR when combatant has both creatureId and cr", async () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: goblinCreature.id,
|
||||||
|
cr: "5",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current).not.toBeNull();
|
||||||
|
// Should use bestiary CR 1/4 (50 XP), not the manual cr "5" (1800 XP)
|
||||||
|
expect(result.current?.totalMonsterXp).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mixes bestiary and custom-with-CR combatants correctly", async () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: goblinCreature.id,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-3"),
|
||||||
|
name: "Custom",
|
||||||
|
cr: "1",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current).not.toBeNull();
|
||||||
|
// CR 1/4 = 50 XP, CR 1 = 200 XP → total 250
|
||||||
|
expect(result.current?.totalMonsterXp).toBe(250);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("custom combatant without CR is still excluded", () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Custom Monster",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
expect(result.current).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
import type {
|
|
||||||
Combatant,
|
|
||||||
CreatureId,
|
|
||||||
Encounter,
|
|
||||||
PlayerCharacter,
|
|
||||||
} from "@initiative/domain";
|
|
||||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
|
||||||
import { renderHook } from "@testing-library/react";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
vi.mock("../../contexts/encounter-context.js", () => ({
|
|
||||||
useEncounterContext: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../contexts/player-characters-context.js", () => ({
|
|
||||||
usePlayerCharactersContext: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
|
||||||
useBestiaryContext: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { useBestiaryContext } from "../../contexts/bestiary-context.js";
|
|
||||||
import { useEncounterContext } from "../../contexts/encounter-context.js";
|
|
||||||
import { usePlayerCharactersContext } from "../../contexts/player-characters-context.js";
|
|
||||||
import { useDifficulty } from "../use-difficulty.js";
|
|
||||||
|
|
||||||
const mockEncounterContext = vi.mocked(useEncounterContext);
|
|
||||||
const mockPlayerCharactersContext = vi.mocked(usePlayerCharactersContext);
|
|
||||||
const mockBestiaryContext = vi.mocked(useBestiaryContext);
|
|
||||||
|
|
||||||
const pcId1 = playerCharacterId("pc-1");
|
|
||||||
const pcId2 = playerCharacterId("pc-2");
|
|
||||||
const crId1 = creatureId("creature-1");
|
|
||||||
const _crId2 = creatureId("creature-2");
|
|
||||||
|
|
||||||
function setup(options: {
|
|
||||||
combatants: Combatant[];
|
|
||||||
characters: PlayerCharacter[];
|
|
||||||
creatures: Map<CreatureId, { cr: string }>;
|
|
||||||
}) {
|
|
||||||
const encounter = {
|
|
||||||
combatants: options.combatants,
|
|
||||||
activeIndex: 0,
|
|
||||||
roundNumber: 1,
|
|
||||||
} as Encounter;
|
|
||||||
|
|
||||||
mockEncounterContext.mockReturnValue({
|
|
||||||
encounter,
|
|
||||||
} as ReturnType<typeof useEncounterContext>);
|
|
||||||
|
|
||||||
mockPlayerCharactersContext.mockReturnValue({
|
|
||||||
characters: options.characters,
|
|
||||||
} as ReturnType<typeof usePlayerCharactersContext>);
|
|
||||||
|
|
||||||
mockBestiaryContext.mockReturnValue({
|
|
||||||
getCreature: (id: CreatureId) => options.creatures.get(id),
|
|
||||||
} as ReturnType<typeof useBestiaryContext>);
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("useDifficulty", () => {
|
|
||||||
it("returns difficulty result for leveled PCs and bestiary monsters", () => {
|
|
||||||
setup({
|
|
||||||
combatants: [
|
|
||||||
{ id: combatantId("c1"), name: "Hero", playerCharacterId: pcId1 },
|
|
||||||
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
|
|
||||||
],
|
|
||||||
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }],
|
|
||||||
creatures: new Map([[crId1, { cr: "1/4" }]]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDifficulty());
|
|
||||||
|
|
||||||
expect(result.current).not.toBeNull();
|
|
||||||
expect(result.current?.tier).toBe("low");
|
|
||||||
expect(result.current?.totalMonsterXp).toBe(50);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("returns null when data is insufficient (ED-2)", () => {
|
|
||||||
it("returns null when encounter has no combatants", () => {
|
|
||||||
setup({ combatants: [], characters: [], creatures: new Map() });
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDifficulty());
|
|
||||||
expect(result.current).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when only custom combatants (no creatureId)", () => {
|
|
||||||
setup({
|
|
||||||
combatants: [
|
|
||||||
{
|
|
||||||
id: combatantId("c1"),
|
|
||||||
name: "Custom",
|
|
||||||
playerCharacterId: pcId1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }],
|
|
||||||
creatures: new Map(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDifficulty());
|
|
||||||
expect(result.current).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when bestiary monsters present but no PC combatants", () => {
|
|
||||||
setup({
|
|
||||||
combatants: [
|
|
||||||
{ id: combatantId("c1"), name: "Goblin", creatureId: crId1 },
|
|
||||||
],
|
|
||||||
characters: [],
|
|
||||||
creatures: new Map([[crId1, { cr: "1" }]]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDifficulty());
|
|
||||||
expect(result.current).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when PC combatants have no level", () => {
|
|
||||||
setup({
|
|
||||||
combatants: [
|
|
||||||
{
|
|
||||||
id: combatantId("c1"),
|
|
||||||
name: "Hero",
|
|
||||||
playerCharacterId: pcId1,
|
|
||||||
},
|
|
||||||
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
|
|
||||||
],
|
|
||||||
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30 }],
|
|
||||||
creatures: new Map([[crId1, { cr: "1" }]]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDifficulty());
|
|
||||||
expect(result.current).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when PC combatant references unknown player character", () => {
|
|
||||||
setup({
|
|
||||||
combatants: [
|
|
||||||
{
|
|
||||||
id: combatantId("c1"),
|
|
||||||
name: "Hero",
|
|
||||||
playerCharacterId: pcId2,
|
|
||||||
},
|
|
||||||
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
|
|
||||||
],
|
|
||||||
characters: [{ id: pcId1, name: "Other", ac: 15, maxHp: 30, level: 5 }],
|
|
||||||
creatures: new Map([[crId1, { cr: "1" }]]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDifficulty());
|
|
||||||
expect(result.current).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles mixed combatants: only leveled PCs and bestiary monsters contribute", () => {
|
|
||||||
// Party: one leveled PC, one without level (excluded)
|
|
||||||
// Monsters: one bestiary creature, one custom (excluded)
|
|
||||||
setup({
|
|
||||||
combatants: [
|
|
||||||
{
|
|
||||||
id: combatantId("c1"),
|
|
||||||
name: "Leveled",
|
|
||||||
playerCharacterId: pcId1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: combatantId("c2"),
|
|
||||||
name: "No Level",
|
|
||||||
playerCharacterId: pcId2,
|
|
||||||
},
|
|
||||||
{ id: combatantId("c3"), name: "Goblin", creatureId: crId1 },
|
|
||||||
{ id: combatantId("c4"), name: "Custom Monster" },
|
|
||||||
],
|
|
||||||
characters: [
|
|
||||||
{ id: pcId1, name: "Leveled", ac: 15, maxHp: 30, level: 1 },
|
|
||||||
{ id: pcId2, name: "No Level", ac: 12, maxHp: 20 },
|
|
||||||
],
|
|
||||||
creatures: new Map([[crId1, { cr: "1" }]]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDifficulty());
|
|
||||||
|
|
||||||
expect(result.current).not.toBeNull();
|
|
||||||
// 1 level-1 PC: budget low=50, mod=75, high=100
|
|
||||||
// 1 CR 1 monster: 200 XP → high (200 >= 100)
|
|
||||||
expect(result.current?.tier).toBe("high");
|
|
||||||
expect(result.current?.totalMonsterXp).toBe(200);
|
|
||||||
expect(result.current?.partyBudget.low).toBe(50);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes duplicate PC combatants in budget", () => {
|
|
||||||
// Same PC added twice → counts twice
|
|
||||||
setup({
|
|
||||||
combatants: [
|
|
||||||
{
|
|
||||||
id: combatantId("c1"),
|
|
||||||
name: "Hero 1",
|
|
||||||
playerCharacterId: pcId1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: combatantId("c2"),
|
|
||||||
name: "Hero 2",
|
|
||||||
playerCharacterId: pcId1,
|
|
||||||
},
|
|
||||||
{ id: combatantId("c3"), name: "Goblin", creatureId: crId1 },
|
|
||||||
],
|
|
||||||
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }],
|
|
||||||
creatures: new Map([[crId1, { cr: "1/4" }]]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDifficulty());
|
|
||||||
|
|
||||||
expect(result.current).not.toBeNull();
|
|
||||||
// 2x level 1: budget low=100
|
|
||||||
expect(result.current?.partyBudget.low).toBe(100);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||||
|
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
|
import {
|
||||||
|
buildCombatant,
|
||||||
|
buildCreature,
|
||||||
|
buildEncounter,
|
||||||
|
} from "../../__tests__/factories/index.js";
|
||||||
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
|
import { useDifficulty } from "../use-difficulty.js";
|
||||||
|
import { useRulesEdition } from "../use-rules-edition.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const pcId1 = playerCharacterId("pc-1");
|
||||||
|
const pcId2 = playerCharacterId("pc-2");
|
||||||
|
const crId1 = creatureId("srd:goblin");
|
||||||
|
|
||||||
|
const goblinCreature = buildCreature({
|
||||||
|
id: crId1,
|
||||||
|
name: "Goblin",
|
||||||
|
cr: "1/4",
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeWrapper(options: {
|
||||||
|
encounter: ReturnType<typeof buildEncounter>;
|
||||||
|
playerCharacters?: PlayerCharacter[];
|
||||||
|
creatures?: Map<CreatureId, Creature>;
|
||||||
|
}) {
|
||||||
|
const adapters = createTestAdapters({
|
||||||
|
encounter: options.encounter,
|
||||||
|
playerCharacters: options.playerCharacters ?? [],
|
||||||
|
creatures: options.creatures,
|
||||||
|
});
|
||||||
|
return ({ children }: { children: ReactNode }) => (
|
||||||
|
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useDifficulty", () => {
|
||||||
|
it("returns difficulty result for leveled PCs and bestiary monsters", async () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c2"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: crId1,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[crId1, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current).not.toBeNull();
|
||||||
|
expect(result.current?.tier).toBe(1);
|
||||||
|
expect(result.current?.totalMonsterXp).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("returns null when data is insufficient (ED-2)", () => {
|
||||||
|
it("returns null when encounter has no combatants", () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({ combatants: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
expect(result.current).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when only custom combatants (no creatureId)", () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Custom",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
expect(result.current).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when bestiary monsters present but no PC combatants", () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: crId1,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
creatures: new Map([[crId1, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
expect(result.current).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when PC combatants have no level", () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c2"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: crId1,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30 }],
|
||||||
|
creatures: new Map([[crId1, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
expect(result.current).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when PC combatant references unknown player character", () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId2,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c2"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: crId1,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Other", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[crId1, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
expect(result.current).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles mixed combatants: only leveled PCs and CR-bearing monsters contribute", async () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Leveled",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c2"),
|
||||||
|
name: "No Level",
|
||||||
|
playerCharacterId: pcId2,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c3"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: crId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c4"),
|
||||||
|
name: "Custom Monster",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Leveled", ac: 15, maxHp: 30, level: 1 },
|
||||||
|
{ id: pcId2, name: "No Level", ac: 12, maxHp: 20 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[crId1, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current).not.toBeNull();
|
||||||
|
// 1 level-1 PC: budget low=50, mod=75, high=100
|
||||||
|
// CR 1/4 = 50 XP -> low (50 >= 50)
|
||||||
|
expect(result.current?.tier).toBe(1);
|
||||||
|
expect(result.current?.totalMonsterXp).toBe(50);
|
||||||
|
expect(result.current?.thresholds[0].value).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes duplicate PC combatants in budget", async () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Hero 1",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c2"),
|
||||||
|
name: "Hero 2",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c3"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: crId1,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[crId1, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current).not.toBeNull();
|
||||||
|
// 2x level 1: budget low=100
|
||||||
|
expect(result.current?.thresholds[0].value).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("combatant toggled to party side subtracts XP", async () => {
|
||||||
|
const bugbear = buildCreature({
|
||||||
|
id: creatureId("srd:bugbear"),
|
||||||
|
name: "Bugbear",
|
||||||
|
cr: "1",
|
||||||
|
});
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c2"),
|
||||||
|
name: "Allied Guard",
|
||||||
|
creatureId: bugbear.id,
|
||||||
|
side: "party",
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c3"),
|
||||||
|
name: "Thug",
|
||||||
|
cr: "1",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[bugbear.id, bugbear]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current).not.toBeNull();
|
||||||
|
// Thug CR 1 = 200 XP, Allied Guard CR 1 = 200 XP subtracted, net = 0
|
||||||
|
expect(result.current?.totalMonsterXp).toBe(0);
|
||||||
|
expect(result.current?.tier).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("default side resolution: PC -> party, non-PC -> enemy", async () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c2"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: crId1,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 3 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[crId1, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current).not.toBeNull();
|
||||||
|
// Level 3 budget: low=150, mod=225, high=400
|
||||||
|
// CR 1/4 = 50 XP -> trivial
|
||||||
|
expect(result.current?.thresholds[0].value).toBe(150);
|
||||||
|
expect(result.current?.totalMonsterXp).toBe(50);
|
||||||
|
expect(result.current?.tier).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 2014 difficulty when edition is 5e", async () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c2"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: crId1,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[crId1, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set edition via the hook's external store
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
editionResult.current.setEdition("5e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current).not.toBeNull();
|
||||||
|
// 2014: 4 thresholds with Easy/Medium/Hard/Deadly labels
|
||||||
|
expect(result.current?.thresholds).toHaveLength(4);
|
||||||
|
expect(result.current?.thresholds[0].label).toBe("Easy");
|
||||||
|
// CR 1/4 = 50 XP, 1 PC (<3) shifts x1 → x1.5, adjusted = 75
|
||||||
|
expect(result.current?.encounterMultiplier).toBe(1.5);
|
||||||
|
expect(result.current?.adjustedXp).toBe(75);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("custom combatant with CR on party side subtracts XP", async () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c2"),
|
||||||
|
name: "Ally",
|
||||||
|
cr: "2",
|
||||||
|
side: "party",
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c3"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: crId1,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[crId1, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current).not.toBeNull();
|
||||||
|
// CR 1/4 = 50 XP enemy, CR 2 = 450 XP ally subtracted, net = 0 (floored)
|
||||||
|
expect(result.current?.totalMonsterXp).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import type { ExportBundle } from "@initiative/domain";
|
||||||
|
import { combatantId, playerCharacterId } from "@initiative/domain";
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
|
import {
|
||||||
|
buildCombatant,
|
||||||
|
buildEncounter,
|
||||||
|
} from "../../__tests__/factories/index.js";
|
||||||
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
|
import { useEncounterContext } from "../../contexts/encounter-context.js";
|
||||||
|
import { useEncounterExportImport } from "../use-encounter-export-import.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return <AllProviders>{children}</AllProviders>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapperWithEncounter(encounter: ReturnType<typeof buildEncounter>) {
|
||||||
|
const adapters = createTestAdapters({ encounter });
|
||||||
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return <AllProviders adapters={adapters}>{children}</AllProviders>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_BUNDLE: ExportBundle = {
|
||||||
|
version: 1,
|
||||||
|
exportedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [buildCombatant({ id: combatantId("c-1"), name: "Imported" })],
|
||||||
|
}),
|
||||||
|
undoStack: [],
|
||||||
|
redoStack: [],
|
||||||
|
playerCharacters: [
|
||||||
|
{
|
||||||
|
id: playerCharacterId("pc-1"),
|
||||||
|
name: "Hero",
|
||||||
|
ac: 16,
|
||||||
|
maxHp: 30,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("useEncounterExportImport", () => {
|
||||||
|
describe("import via clipboard", () => {
|
||||||
|
it("imports valid JSON into empty encounter without error", () => {
|
||||||
|
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleImportClipboard(JSON.stringify(VALID_BUNDLE));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Import should succeed without error and not show confirm
|
||||||
|
expect(result.current.importError).toBeNull();
|
||||||
|
expect(result.current.showImportConfirm).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets error for invalid JSON", () => {
|
||||||
|
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleImportClipboard("not json{{{");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.importError).toBe("Invalid file format");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets error for valid JSON that fails validation", () => {
|
||||||
|
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleImportClipboard(JSON.stringify({ version: 999 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.importError).toBe("Invalid file format");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows confirm dialog when encounter is not empty", () => {
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({ id: combatantId("c-1"), name: "Existing" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||||
|
wrapper: wrapperWithEncounter(encounter),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleImportClipboard(JSON.stringify(VALID_BUNDLE));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.showImportConfirm).toBe(true);
|
||||||
|
expect(result.current.importError).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handleImportConfirm clears confirm dialog", () => {
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({ id: combatantId("c-1"), name: "Existing" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||||
|
wrapper: wrapperWithEncounter(encounter),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleImportClipboard(JSON.stringify(VALID_BUNDLE));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.showImportConfirm).toBe(true);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleImportConfirm();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.showImportConfirm).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handleImportCancel clears pending without applying", () => {
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({ id: combatantId("c-1"), name: "Existing" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => ({
|
||||||
|
exportImport: useEncounterExportImport(),
|
||||||
|
encounter: useEncounterContext(),
|
||||||
|
}),
|
||||||
|
{ wrapper: wrapperWithEncounter(encounter) },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.exportImport.handleImportClipboard(
|
||||||
|
JSON.stringify(VALID_BUNDLE),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.exportImport.handleImportCancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.exportImport.showImportConfirm).toBe(false);
|
||||||
|
expect(result.current.encounter.encounter.combatants[0].name).toBe(
|
||||||
|
"Existing",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("export", () => {
|
||||||
|
it("handleExportDownload calls triggerDownload", () => {
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({ id: combatantId("c-1"), name: "Fighter" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||||
|
wrapper: wrapperWithEncounter(encounter),
|
||||||
|
});
|
||||||
|
|
||||||
|
// triggerDownload creates a blob URL and clicks an anchor — just verify it doesn't throw
|
||||||
|
expect(() => {
|
||||||
|
act(() => {
|
||||||
|
result.current.handleExportDownload(false, "test-export.json");
|
||||||
|
});
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dialog state", () => {
|
||||||
|
it("toggles export method dialog", () => {
|
||||||
|
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.showExportMethod).toBe(false);
|
||||||
|
act(() => result.current.setShowExportMethod(true));
|
||||||
|
expect(result.current.showExportMethod).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles import method dialog", () => {
|
||||||
|
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.showImportMethod).toBe(false);
|
||||||
|
act(() => result.current.setShowImportMethod(true));
|
||||||
|
expect(result.current.showImportMethod).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears import error", () => {
|
||||||
|
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleImportClipboard("bad json");
|
||||||
|
});
|
||||||
|
expect(result.current.importError).toBe("Invalid file format");
|
||||||
|
|
||||||
|
act(() => result.current.setImportError(null));
|
||||||
|
expect(result.current.importError).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+66
-32
@@ -1,28 +1,37 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import type { BestiaryIndexEntry, PlayerCharacter } from "@initiative/domain";
|
import type { PlayerCharacter } from "@initiative/domain";
|
||||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||||
import { act, renderHook } from "@testing-library/react";
|
import { act, renderHook } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import type { ReactNode } from "react";
|
||||||
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
|
import type { SearchResult } from "../use-bestiary.js";
|
||||||
import { useEncounter } from "../use-encounter.js";
|
import { useEncounter } from "../use-encounter.js";
|
||||||
|
|
||||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
beforeAll(() => {
|
||||||
loadEncounter: vi.fn().mockReturnValue(null),
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
saveEncounter: vi.fn(),
|
writable: true,
|
||||||
}));
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const { loadEncounter: mockLoad, saveEncounter: mockSave } =
|
function wrapper({ children }: { children: ReactNode }) {
|
||||||
await vi.importMock<typeof import("../../persistence/encounter-storage.js")>(
|
return <AllProviders>{children}</AllProviders>;
|
||||||
"../../persistence/encounter-storage.js",
|
}
|
||||||
);
|
|
||||||
|
|
||||||
describe("useEncounter", () => {
|
describe("useEncounter", () => {
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockLoad.mockReturnValue(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("initializes with empty encounter when persistence returns null", () => {
|
it("initializes with empty encounter when persistence returns null", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
expect(result.current.encounter.combatants).toEqual([]);
|
expect(result.current.encounter.combatants).toEqual([]);
|
||||||
expect(result.current.encounter.activeIndex).toBe(0);
|
expect(result.current.encounter.activeIndex).toBe(0);
|
||||||
@@ -32,13 +41,33 @@ describe("useEncounter", () => {
|
|||||||
|
|
||||||
it("initializes from stored encounter", () => {
|
it("initializes from stored encounter", () => {
|
||||||
const stored = {
|
const stored = {
|
||||||
combatants: [{ id: combatantId("c-1"), name: "Goblin" }],
|
combatants: [
|
||||||
|
{
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Goblin",
|
||||||
|
initiative: undefined,
|
||||||
|
maxHp: undefined,
|
||||||
|
currentHp: undefined,
|
||||||
|
tempHp: undefined,
|
||||||
|
ac: undefined,
|
||||||
|
conditions: [],
|
||||||
|
concentrating: false,
|
||||||
|
creatureId: undefined,
|
||||||
|
playerCharacterId: undefined,
|
||||||
|
color: undefined,
|
||||||
|
icon: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
activeIndex: 0,
|
activeIndex: 0,
|
||||||
roundNumber: 2,
|
roundNumber: 2,
|
||||||
};
|
};
|
||||||
mockLoad.mockReturnValue(stored);
|
const adapters = createTestAdapters({ encounter: stored });
|
||||||
|
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), {
|
||||||
|
wrapper: ({ children }: { children: ReactNode }) => (
|
||||||
|
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
expect(result.current.encounter.combatants).toHaveLength(1);
|
expect(result.current.encounter.combatants).toHaveLength(1);
|
||||||
expect(result.current.encounter.roundNumber).toBe(2);
|
expect(result.current.encounter.roundNumber).toBe(2);
|
||||||
@@ -46,7 +75,7 @@ describe("useEncounter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("addCombatant adds a combatant with incremental IDs and persists", () => {
|
it("addCombatant adds a combatant with incremental IDs and persists", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
act(() => result.current.addCombatant("Goblin"));
|
act(() => result.current.addCombatant("Goblin"));
|
||||||
act(() => result.current.addCombatant("Orc"));
|
act(() => result.current.addCombatant("Orc"));
|
||||||
@@ -55,11 +84,10 @@ describe("useEncounter", () => {
|
|||||||
expect(result.current.encounter.combatants[0].name).toBe("Goblin");
|
expect(result.current.encounter.combatants[0].name).toBe("Goblin");
|
||||||
expect(result.current.encounter.combatants[1].name).toBe("Orc");
|
expect(result.current.encounter.combatants[1].name).toBe("Orc");
|
||||||
expect(result.current.isEmpty).toBe(false);
|
expect(result.current.isEmpty).toBe(false);
|
||||||
expect(mockSave).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removeCombatant removes a combatant and persists", () => {
|
it("removeCombatant removes a combatant and persists", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
act(() => result.current.addCombatant("Goblin"));
|
act(() => result.current.addCombatant("Goblin"));
|
||||||
const id = result.current.encounter.combatants[0].id;
|
const id = result.current.encounter.combatants[0].id;
|
||||||
@@ -71,7 +99,7 @@ describe("useEncounter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("advanceTurn and retreatTurn update encounter state", () => {
|
it("advanceTurn and retreatTurn update encounter state", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
act(() => result.current.addCombatant("Goblin"));
|
act(() => result.current.addCombatant("Goblin"));
|
||||||
act(() => result.current.addCombatant("Orc"));
|
act(() => result.current.addCombatant("Orc"));
|
||||||
@@ -86,7 +114,7 @@ describe("useEncounter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("clearEncounter resets to empty and resets ID counter", () => {
|
it("clearEncounter resets to empty and resets ID counter", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
act(() => result.current.addCombatant("Goblin"));
|
act(() => result.current.addCombatant("Goblin"));
|
||||||
act(() => result.current.clearEncounter());
|
act(() => result.current.clearEncounter());
|
||||||
@@ -100,7 +128,7 @@ describe("useEncounter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("addCombatant with opts applies initiative, ac, maxHp", () => {
|
it("addCombatant with opts applies initiative, ac, maxHp", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
act(() =>
|
act(() =>
|
||||||
result.current.addCombatant("Goblin", {
|
result.current.addCombatant("Goblin", {
|
||||||
@@ -118,16 +146,18 @@ describe("useEncounter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("derived flags: hasCreatureCombatants and canRollAllInitiative", () => {
|
it("derived flags: hasCreatureCombatants and canRollAllInitiative", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
// No creatures yet
|
// No creatures yet
|
||||||
expect(result.current.hasCreatureCombatants).toBe(false);
|
expect(result.current.hasCreatureCombatants).toBe(false);
|
||||||
expect(result.current.canRollAllInitiative).toBe(false);
|
expect(result.current.canRollAllInitiative).toBe(false);
|
||||||
|
|
||||||
// Add from bestiary to get a creature combatant
|
// Add from bestiary to get a creature combatant
|
||||||
const entry: BestiaryIndexEntry = {
|
const entry: SearchResult = {
|
||||||
|
system: "dnd",
|
||||||
name: "Goblin",
|
name: "Goblin",
|
||||||
source: "MM",
|
source: "MM",
|
||||||
|
sourceDisplayName: "Monster Manual",
|
||||||
ac: 15,
|
ac: 15,
|
||||||
hp: 7,
|
hp: 7,
|
||||||
dex: 14,
|
dex: 14,
|
||||||
@@ -146,11 +176,13 @@ describe("useEncounter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("addFromBestiary adds combatant with HP, AC, creatureId", () => {
|
it("addFromBestiary adds combatant with HP, AC, creatureId", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
const entry: BestiaryIndexEntry = {
|
const entry: SearchResult = {
|
||||||
|
system: "dnd",
|
||||||
name: "Goblin",
|
name: "Goblin",
|
||||||
source: "MM",
|
source: "MM",
|
||||||
|
sourceDisplayName: "Monster Manual",
|
||||||
ac: 15,
|
ac: 15,
|
||||||
hp: 7,
|
hp: 7,
|
||||||
dex: 14,
|
dex: 14,
|
||||||
@@ -173,11 +205,13 @@ describe("useEncounter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("addFromBestiary auto-numbers duplicate names", () => {
|
it("addFromBestiary auto-numbers duplicate names", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
const entry: BestiaryIndexEntry = {
|
const entry: SearchResult = {
|
||||||
|
system: "dnd",
|
||||||
name: "Goblin",
|
name: "Goblin",
|
||||||
source: "MM",
|
source: "MM",
|
||||||
|
sourceDisplayName: "Monster Manual",
|
||||||
ac: 15,
|
ac: 15,
|
||||||
hp: 7,
|
hp: 7,
|
||||||
dex: 14,
|
dex: 14,
|
||||||
@@ -200,7 +234,7 @@ describe("useEncounter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("addFromPlayerCharacter adds combatant with HP, AC, color, icon", () => {
|
it("addFromPlayerCharacter adds combatant with HP, AC, color, icon", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
const pc: PlayerCharacter = {
|
const pc: PlayerCharacter = {
|
||||||
id: playerCharacterId("pc-1"),
|
id: playerCharacterId("pc-1"),
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { type CreatureId, combatantId } from "@initiative/domain";
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useInitiativeRolls } from "../use-initiative-rolls.js";
|
||||||
|
|
||||||
|
const mockMakeStore = vi.fn(() => ({}));
|
||||||
|
const mockWithUndo = vi.fn((fn: () => unknown) => fn());
|
||||||
|
const mockGetCreature = vi.fn();
|
||||||
|
const mockShowCreature = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../contexts/encounter-context.js", () => ({
|
||||||
|
useEncounterContext: () => ({
|
||||||
|
encounter: {
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: "srd:goblin" as CreatureId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
},
|
||||||
|
makeStore: mockMakeStore,
|
||||||
|
withUndo: mockWithUndo,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||||
|
useBestiaryContext: () => ({
|
||||||
|
getCreature: mockGetCreature,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../contexts/side-panel-context.js", () => ({
|
||||||
|
useSidePanelContext: () => ({
|
||||||
|
showCreature: mockShowCreature,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockRollInitiativeUseCase = vi.fn();
|
||||||
|
const mockRollAllInitiativeUseCase = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@initiative/application", () => ({
|
||||||
|
rollInitiativeUseCase: (...args: unknown[]) =>
|
||||||
|
mockRollInitiativeUseCase(...args),
|
||||||
|
rollAllInitiativeUseCase: (...args: unknown[]) =>
|
||||||
|
mockRollAllInitiativeUseCase(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useInitiativeRolls", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handleRollInitiative calls rollInitiativeUseCase via withUndo", () => {
|
||||||
|
mockRollInitiativeUseCase.mockReturnValue({ initiative: 15 });
|
||||||
|
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
|
||||||
|
|
||||||
|
act(() => result.current.handleRollInitiative(combatantId("c1")));
|
||||||
|
|
||||||
|
expect(mockWithUndo).toHaveBeenCalled();
|
||||||
|
expect(mockRollInitiativeUseCase).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets rollSingleSkipped on domain error", () => {
|
||||||
|
mockRollInitiativeUseCase.mockReturnValue({
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "missing-source",
|
||||||
|
message: "no source",
|
||||||
|
});
|
||||||
|
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
|
||||||
|
|
||||||
|
act(() => result.current.handleRollInitiative(combatantId("c1")));
|
||||||
|
expect(result.current.rollSingleSkipped).toBe(true);
|
||||||
|
expect(mockShowCreature).toHaveBeenCalledWith("srd:goblin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dismissRollSingleSkipped resets the flag", () => {
|
||||||
|
mockRollInitiativeUseCase.mockReturnValue({
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "missing-source",
|
||||||
|
message: "no source",
|
||||||
|
});
|
||||||
|
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
|
||||||
|
|
||||||
|
act(() => result.current.handleRollInitiative(combatantId("c1")));
|
||||||
|
expect(result.current.rollSingleSkipped).toBe(true);
|
||||||
|
|
||||||
|
act(() => result.current.dismissRollSingleSkipped());
|
||||||
|
expect(result.current.rollSingleSkipped).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handleRollAllInitiative sets rollSkippedCount when sources missing", () => {
|
||||||
|
mockRollAllInitiativeUseCase.mockReturnValue({ skippedNoSource: 3 });
|
||||||
|
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
|
||||||
|
|
||||||
|
act(() => result.current.handleRollAllInitiative());
|
||||||
|
expect(result.current.rollSkippedCount).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dismissRollSkipped resets the count", () => {
|
||||||
|
mockRollAllInitiativeUseCase.mockReturnValue({ skippedNoSource: 2 });
|
||||||
|
const { result } = renderHook(() => useInitiativeRolls(), { wrapper });
|
||||||
|
|
||||||
|
act(() => result.current.handleRollAllInitiative());
|
||||||
|
act(() => result.current.dismissRollSkipped());
|
||||||
|
expect(result.current.rollSkippedCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useLongPress } from "../use-long-press.js";
|
||||||
|
|
||||||
|
function touchEvent(overrides?: Partial<React.TouchEvent>): React.TouchEvent {
|
||||||
|
return {
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
} as unknown as React.TouchEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useLongPress", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns onTouchStart, onTouchEnd, onTouchMove handlers", () => {
|
||||||
|
const { result } = renderHook(() => useLongPress(vi.fn()));
|
||||||
|
expect(result.current.onTouchStart).toBeInstanceOf(Function);
|
||||||
|
expect(result.current.onTouchEnd).toBeInstanceOf(Function);
|
||||||
|
expect(result.current.onTouchMove).toBeInstanceOf(Function);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fires onLongPress after 500ms hold", () => {
|
||||||
|
const onLongPress = vi.fn();
|
||||||
|
const { result } = renderHook(() => useLongPress(onLongPress));
|
||||||
|
|
||||||
|
const e = touchEvent();
|
||||||
|
act(() => result.current.onTouchStart(e));
|
||||||
|
expect(onLongPress).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
});
|
||||||
|
expect(onLongPress).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fire if released before 500ms", () => {
|
||||||
|
const onLongPress = vi.fn();
|
||||||
|
const { result } = renderHook(() => useLongPress(onLongPress));
|
||||||
|
|
||||||
|
act(() => result.current.onTouchStart(touchEvent()));
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
});
|
||||||
|
act(() => result.current.onTouchEnd(touchEvent()));
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onLongPress).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels on touch move", () => {
|
||||||
|
const onLongPress = vi.fn();
|
||||||
|
const { result } = renderHook(() => useLongPress(onLongPress));
|
||||||
|
|
||||||
|
act(() => result.current.onTouchStart(touchEvent()));
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(200);
|
||||||
|
});
|
||||||
|
act(() => result.current.onTouchMove());
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onLongPress).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("onTouchEnd calls preventDefault after long press fires", () => {
|
||||||
|
const onLongPress = vi.fn();
|
||||||
|
const { result } = renderHook(() => useLongPress(onLongPress));
|
||||||
|
|
||||||
|
act(() => result.current.onTouchStart(touchEvent()));
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
const preventDefaultSpy = vi.fn();
|
||||||
|
const endEvent = touchEvent({ preventDefault: preventDefaultSpy });
|
||||||
|
act(() => result.current.onTouchEnd(endEvent));
|
||||||
|
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("onTouchEnd does not preventDefault when long press did not fire", () => {
|
||||||
|
const onLongPress = vi.fn();
|
||||||
|
const { result } = renderHook(() => useLongPress(onLongPress));
|
||||||
|
|
||||||
|
act(() => result.current.onTouchStart(touchEvent()));
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
const preventDefaultSpy = vi.fn();
|
||||||
|
const endEvent = touchEvent({ preventDefault: preventDefaultSpy });
|
||||||
|
act(() => result.current.onTouchEnd(endEvent));
|
||||||
|
expect(preventDefaultSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
+32
-23
@@ -1,25 +1,33 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { playerCharacterId } from "@initiative/domain";
|
import { playerCharacterId } from "@initiative/domain";
|
||||||
import { act, renderHook } from "@testing-library/react";
|
import { act, renderHook } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import type { ReactNode } from "react";
|
||||||
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
import { usePlayerCharacters } from "../use-player-characters.js";
|
import { usePlayerCharacters } from "../use-player-characters.js";
|
||||||
|
|
||||||
vi.mock("../../persistence/player-character-storage.js", () => ({
|
beforeAll(() => {
|
||||||
loadPlayerCharacters: vi.fn().mockReturnValue([]),
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
savePlayerCharacters: vi.fn(),
|
writable: true,
|
||||||
}));
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const { loadPlayerCharacters: mockLoad, savePlayerCharacters: mockSave } =
|
function wrapper({ children }: { children: ReactNode }) {
|
||||||
await vi.importMock<
|
return <AllProviders>{children}</AllProviders>;
|
||||||
typeof import("../../persistence/player-character-storage.js")
|
}
|
||||||
>("../../persistence/player-character-storage.js");
|
|
||||||
|
|
||||||
describe("usePlayerCharacters", () => {
|
describe("usePlayerCharacters", () => {
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockLoad.mockReturnValue([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("initializes with characters from persistence", () => {
|
it("initializes with characters from persistence", () => {
|
||||||
const stored = [
|
const stored = [
|
||||||
{
|
{
|
||||||
@@ -31,15 +39,19 @@ describe("usePlayerCharacters", () => {
|
|||||||
icon: undefined,
|
icon: undefined,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
mockLoad.mockReturnValue(stored);
|
const adapters = createTestAdapters({ playerCharacters: stored });
|
||||||
|
|
||||||
const { result } = renderHook(() => usePlayerCharacters());
|
const { result } = renderHook(() => usePlayerCharacters(), {
|
||||||
|
wrapper: ({ children }: { children: ReactNode }) => (
|
||||||
|
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
expect(result.current.characters).toEqual(stored);
|
expect(result.current.characters).toEqual(stored);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("createCharacter adds a character and persists", () => {
|
it("createCharacter adds a character and persists", () => {
|
||||||
const { result } = renderHook(() => usePlayerCharacters());
|
const { result } = renderHook(() => usePlayerCharacters(), { wrapper });
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.createCharacter(
|
result.current.createCharacter(
|
||||||
@@ -56,11 +68,10 @@ describe("usePlayerCharacters", () => {
|
|||||||
expect(result.current.characters[0].name).toBe("Vex");
|
expect(result.current.characters[0].name).toBe("Vex");
|
||||||
expect(result.current.characters[0].ac).toBe(15);
|
expect(result.current.characters[0].ac).toBe(15);
|
||||||
expect(result.current.characters[0].maxHp).toBe(28);
|
expect(result.current.characters[0].maxHp).toBe(28);
|
||||||
expect(mockSave).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("createCharacter returns domain error for empty name", () => {
|
it("createCharacter returns domain error for empty name", () => {
|
||||||
const { result } = renderHook(() => usePlayerCharacters());
|
const { result } = renderHook(() => usePlayerCharacters(), { wrapper });
|
||||||
|
|
||||||
let error: unknown;
|
let error: unknown;
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -79,7 +90,7 @@ describe("usePlayerCharacters", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("editCharacter updates character and persists", () => {
|
it("editCharacter updates character and persists", () => {
|
||||||
const { result } = renderHook(() => usePlayerCharacters());
|
const { result } = renderHook(() => usePlayerCharacters(), { wrapper });
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.createCharacter(
|
result.current.createCharacter(
|
||||||
@@ -99,11 +110,10 @@ describe("usePlayerCharacters", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.characters[0].name).toBe("Vex'ahlia");
|
expect(result.current.characters[0].name).toBe("Vex'ahlia");
|
||||||
expect(mockSave).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deleteCharacter removes character and persists", () => {
|
it("deleteCharacter removes character and persists", () => {
|
||||||
const { result } = renderHook(() => usePlayerCharacters());
|
const { result } = renderHook(() => usePlayerCharacters(), { wrapper });
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.createCharacter(
|
result.current.createCharacter(
|
||||||
@@ -123,6 +133,5 @@ describe("usePlayerCharacters", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.characters).toHaveLength(0);
|
expect(result.current.characters).toHaveLength(0);
|
||||||
expect(mockSave).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { act, renderHook } from "@testing-library/react";
|
import { act, renderHook } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { useRulesEdition } from "../use-rules-edition.js";
|
import { useRulesEdition } from "../use-rules-edition.js";
|
||||||
|
|
||||||
const STORAGE_KEY = "initiative:rules-edition";
|
const STORAGE_KEY = "initiative:game-system";
|
||||||
|
const OLD_STORAGE_KEY = "initiative:rules-edition";
|
||||||
|
|
||||||
describe("useRulesEdition", () => {
|
describe("useRulesEdition", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -11,6 +12,7 @@ describe("useRulesEdition", () => {
|
|||||||
const { result } = renderHook(() => useRulesEdition());
|
const { result } = renderHook(() => useRulesEdition());
|
||||||
act(() => result.current.setEdition("5.5e"));
|
act(() => result.current.setEdition("5.5e"));
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
localStorage.removeItem(OLD_STORAGE_KEY);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults to 5.5e", () => {
|
it("defaults to 5.5e", () => {
|
||||||
@@ -42,4 +44,31 @@ describe("useRulesEdition", () => {
|
|||||||
|
|
||||||
expect(r2.current.edition).toBe("5e");
|
expect(r2.current.edition).toBe("5e");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts pf2e as a valid game system", () => {
|
||||||
|
const { result } = renderHook(() => useRulesEdition());
|
||||||
|
|
||||||
|
act(() => result.current.setEdition("pf2e"));
|
||||||
|
|
||||||
|
expect(result.current.edition).toBe("pf2e");
|
||||||
|
expect(localStorage.getItem(STORAGE_KEY)).toBe("pf2e");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("migrates from old storage key on fresh module load", async () => {
|
||||||
|
// Set up old key before re-importing the module
|
||||||
|
localStorage.setItem(OLD_STORAGE_KEY, "5e");
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
|
||||||
|
// Force a fresh module so loadEdition() re-runs at init time
|
||||||
|
vi.resetModules();
|
||||||
|
const { useRulesEdition: freshHook } = await import(
|
||||||
|
"../use-rules-edition.js"
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => freshHook());
|
||||||
|
|
||||||
|
expect(result.current.edition).toBe("5e");
|
||||||
|
expect(localStorage.getItem(STORAGE_KEY)).toBe("5e");
|
||||||
|
expect(localStorage.getItem(OLD_STORAGE_KEY)).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useSwipeToDismiss } from "../use-swipe-to-dismiss.js";
|
||||||
|
|
||||||
|
const PANEL_WIDTH = 300;
|
||||||
|
|
||||||
|
function makeTouchEvent(clientX: number, clientY = 0): React.TouchEvent {
|
||||||
|
return {
|
||||||
|
touches: [{ clientX, clientY }],
|
||||||
|
currentTarget: {
|
||||||
|
getBoundingClientRect: () => ({ width: PANEL_WIDTH }),
|
||||||
|
},
|
||||||
|
} as unknown as React.TouchEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useSwipeToDismiss", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(Date, "now").mockReturnValue(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts with offsetX 0 and isSwiping false", () => {
|
||||||
|
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
|
||||||
|
expect(result.current.offsetX).toBe(0);
|
||||||
|
expect(result.current.isSwiping).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("horizontal drag updates offsetX and sets isSwiping", () => {
|
||||||
|
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
|
||||||
|
|
||||||
|
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
|
||||||
|
act(() => result.current.handlers.onTouchMove(makeTouchEvent(50)));
|
||||||
|
|
||||||
|
expect(result.current.offsetX).toBe(50);
|
||||||
|
expect(result.current.isSwiping).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("vertical drag is ignored after direction lock", () => {
|
||||||
|
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
|
||||||
|
|
||||||
|
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0, 0)));
|
||||||
|
// Move vertically > 10px to lock vertical
|
||||||
|
act(() => result.current.handlers.onTouchMove(makeTouchEvent(0, 20)));
|
||||||
|
|
||||||
|
expect(result.current.offsetX).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("small movement does not lock direction", () => {
|
||||||
|
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
|
||||||
|
|
||||||
|
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
|
||||||
|
act(() => result.current.handlers.onTouchMove(makeTouchEvent(5)));
|
||||||
|
|
||||||
|
// No direction locked yet, no update
|
||||||
|
expect(result.current.offsetX).toBe(0);
|
||||||
|
expect(result.current.isSwiping).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leftward drag is clamped to 0", () => {
|
||||||
|
const { result } = renderHook(() => useSwipeToDismiss(vi.fn()));
|
||||||
|
|
||||||
|
act(() => result.current.handlers.onTouchStart(makeTouchEvent(100)));
|
||||||
|
act(() => result.current.handlers.onTouchMove(makeTouchEvent(50)));
|
||||||
|
|
||||||
|
expect(result.current.offsetX).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onDismiss when ratio exceeds threshold", () => {
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
const { result } = renderHook(() => useSwipeToDismiss(onDismiss));
|
||||||
|
|
||||||
|
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
|
||||||
|
// Move > 35% of panel width (300 * 0.35 = 105)
|
||||||
|
act(() => result.current.handlers.onTouchMove(makeTouchEvent(120)));
|
||||||
|
|
||||||
|
vi.spyOn(Date, "now").mockReturnValue(5000); // slow swipe
|
||||||
|
act(() => result.current.handlers.onTouchEnd());
|
||||||
|
|
||||||
|
expect(onDismiss).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onDismiss with fast velocity", () => {
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
const { result } = renderHook(() => useSwipeToDismiss(onDismiss));
|
||||||
|
|
||||||
|
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
|
||||||
|
// Small distance but fast
|
||||||
|
act(() => result.current.handlers.onTouchMove(makeTouchEvent(30)));
|
||||||
|
|
||||||
|
// Very fast: 30px in 0.1s = 300px/s, velocity = 300/300 = 1.0 > 0.5
|
||||||
|
vi.spyOn(Date, "now").mockReturnValue(100);
|
||||||
|
act(() => result.current.handlers.onTouchEnd());
|
||||||
|
|
||||||
|
expect(onDismiss).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not dismiss when below thresholds", () => {
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
const { result } = renderHook(() => useSwipeToDismiss(onDismiss));
|
||||||
|
|
||||||
|
act(() => result.current.handlers.onTouchStart(makeTouchEvent(0)));
|
||||||
|
// Small distance, slow speed
|
||||||
|
act(() => result.current.handlers.onTouchMove(makeTouchEvent(20)));
|
||||||
|
|
||||||
|
vi.spyOn(Date, "now").mockReturnValue(5000);
|
||||||
|
act(() => result.current.handlers.onTouchEnd());
|
||||||
|
|
||||||
|
expect(onDismiss).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.offsetX).toBe(0);
|
||||||
|
expect(result.current.isSwiping).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,26 +1,34 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AnyCreature,
|
||||||
BestiaryIndexEntry,
|
BestiaryIndexEntry,
|
||||||
Creature,
|
|
||||||
CreatureId,
|
CreatureId,
|
||||||
|
Pf2eBestiaryIndexEntry,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
normalizeBestiary,
|
normalizeBestiary,
|
||||||
setSourceDisplayNames,
|
setSourceDisplayNames,
|
||||||
} from "../adapters/bestiary-adapter.js";
|
} from "../adapters/bestiary-adapter.js";
|
||||||
import * as bestiaryCache from "../adapters/bestiary-cache.js";
|
|
||||||
import {
|
import {
|
||||||
getSourceDisplayName,
|
normalizePf2eBestiary,
|
||||||
loadBestiaryIndex,
|
setPf2eSourceDisplayNames,
|
||||||
} from "../adapters/bestiary-index-adapter.js";
|
} from "../adapters/pf2e-bestiary-adapter.js";
|
||||||
|
import { useAdapters } from "../contexts/adapter-context.js";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
|
|
||||||
export interface SearchResult extends BestiaryIndexEntry {
|
export type SearchResult =
|
||||||
readonly sourceDisplayName: string;
|
| (BestiaryIndexEntry & {
|
||||||
}
|
readonly system: "dnd";
|
||||||
|
readonly sourceDisplayName: string;
|
||||||
|
})
|
||||||
|
| (Pf2eBestiaryIndexEntry & {
|
||||||
|
readonly system: "pf2e";
|
||||||
|
readonly sourceDisplayName: string;
|
||||||
|
});
|
||||||
|
|
||||||
interface BestiaryHook {
|
interface BestiaryHook {
|
||||||
search: (query: string) => SearchResult[];
|
search: (query: string) => SearchResult[];
|
||||||
getCreature: (id: CreatureId) => Creature | undefined;
|
getCreature: (id: CreatureId) => AnyCreature | undefined;
|
||||||
isLoaded: boolean;
|
isLoaded: boolean;
|
||||||
isSourceCached: (sourceCode: string) => Promise<boolean>;
|
isSourceCached: (sourceCode: string) => Promise<boolean>;
|
||||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
|
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
|
||||||
@@ -32,49 +40,75 @@ interface BestiaryHook {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useBestiary(): BestiaryHook {
|
export function useBestiary(): BestiaryHook {
|
||||||
|
const { bestiaryCache, bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
const [creatureMap, setCreatureMap] = useState(
|
const [creatureMap, setCreatureMap] = useState(
|
||||||
() => new Map<CreatureId, Creature>(),
|
() => new Map<CreatureId, AnyCreature>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const index = loadBestiaryIndex();
|
const index = bestiaryIndex.loadIndex();
|
||||||
setSourceDisplayNames(index.sources as Record<string, string>);
|
setSourceDisplayNames(index.sources as Record<string, string>);
|
||||||
if (index.creatures.length > 0) {
|
|
||||||
|
const pf2eIndex = pf2eBestiaryIndex.loadIndex();
|
||||||
|
setPf2eSourceDisplayNames(pf2eIndex.sources as Record<string, string>);
|
||||||
|
|
||||||
|
if (index.creatures.length > 0 || pf2eIndex.creatures.length > 0) {
|
||||||
setIsLoaded(true);
|
setIsLoaded(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
void bestiaryCache.loadAllCachedCreatures().then((map) => {
|
void bestiaryCache.loadAllCachedCreatures().then((map) => {
|
||||||
setCreatureMap(map);
|
setCreatureMap(map);
|
||||||
});
|
});
|
||||||
}, []);
|
}, [bestiaryCache, bestiaryIndex, pf2eBestiaryIndex]);
|
||||||
|
|
||||||
const search = useCallback((query: string): SearchResult[] => {
|
const search = useCallback(
|
||||||
if (query.length < 2) return [];
|
(query: string): SearchResult[] => {
|
||||||
const lower = query.toLowerCase();
|
if (query.length < 2) return [];
|
||||||
const index = loadBestiaryIndex();
|
const lower = query.toLowerCase();
|
||||||
return index.creatures
|
|
||||||
.filter((c) => c.name.toLowerCase().includes(lower))
|
if (edition === "pf2e") {
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
const index = pf2eBestiaryIndex.loadIndex();
|
||||||
.slice(0, 10)
|
return index.creatures
|
||||||
.map((c) => ({
|
.filter((c) => c.name.toLowerCase().includes(lower))
|
||||||
...c,
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
sourceDisplayName: getSourceDisplayName(c.source),
|
.slice(0, 10)
|
||||||
}));
|
.map((c) => ({
|
||||||
}, []);
|
...c,
|
||||||
|
system: "pf2e" as const,
|
||||||
|
sourceDisplayName: pf2eBestiaryIndex.getSourceDisplayName(c.source),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = bestiaryIndex.loadIndex();
|
||||||
|
return index.creatures
|
||||||
|
.filter((c) => c.name.toLowerCase().includes(lower))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((c) => ({
|
||||||
|
...c,
|
||||||
|
system: "dnd" as const,
|
||||||
|
sourceDisplayName: bestiaryIndex.getSourceDisplayName(c.source),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[bestiaryIndex, pf2eBestiaryIndex, edition],
|
||||||
|
);
|
||||||
|
|
||||||
const getCreature = useCallback(
|
const getCreature = useCallback(
|
||||||
(id: CreatureId): Creature | undefined => {
|
(id: CreatureId): AnyCreature | undefined => {
|
||||||
return creatureMap.get(id);
|
return creatureMap.get(id);
|
||||||
},
|
},
|
||||||
[creatureMap],
|
[creatureMap],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const system = edition === "pf2e" ? "pf2e" : "dnd";
|
||||||
|
|
||||||
const isSourceCachedFn = useCallback(
|
const isSourceCachedFn = useCallback(
|
||||||
(sourceCode: string): Promise<boolean> => {
|
(sourceCode: string): Promise<boolean> => {
|
||||||
return bestiaryCache.isSourceCached(sourceCode);
|
return bestiaryCache.isSourceCached(system, sourceCode);
|
||||||
},
|
},
|
||||||
[],
|
[bestiaryCache, system],
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchAndCacheSource = useCallback(
|
const fetchAndCacheSource = useCallback(
|
||||||
@@ -86,9 +120,20 @@ export function useBestiary(): BestiaryHook {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
const creatures = normalizeBestiary(json);
|
const creatures =
|
||||||
const displayName = getSourceDisplayName(sourceCode);
|
edition === "pf2e"
|
||||||
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
? normalizePf2eBestiary(json)
|
||||||
|
: normalizeBestiary(json);
|
||||||
|
const displayName =
|
||||||
|
edition === "pf2e"
|
||||||
|
? pf2eBestiaryIndex.getSourceDisplayName(sourceCode)
|
||||||
|
: bestiaryIndex.getSourceDisplayName(sourceCode);
|
||||||
|
await bestiaryCache.cacheSource(
|
||||||
|
system,
|
||||||
|
sourceCode,
|
||||||
|
displayName,
|
||||||
|
creatures,
|
||||||
|
);
|
||||||
setCreatureMap((prev) => {
|
setCreatureMap((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
for (const c of creatures) {
|
for (const c of creatures) {
|
||||||
@@ -97,15 +142,27 @@ export function useBestiary(): BestiaryHook {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[],
|
[bestiaryCache, bestiaryIndex, pf2eBestiaryIndex, edition, system],
|
||||||
);
|
);
|
||||||
|
|
||||||
const uploadAndCacheSource = useCallback(
|
const uploadAndCacheSource = useCallback(
|
||||||
async (sourceCode: string, jsonData: unknown): Promise<void> => {
|
async (sourceCode: string, jsonData: unknown): Promise<void> => {
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: raw JSON shape varies
|
const creatures =
|
||||||
const creatures = normalizeBestiary(jsonData as any);
|
edition === "pf2e"
|
||||||
const displayName = getSourceDisplayName(sourceCode);
|
? normalizePf2eBestiary(jsonData as { creature: unknown[] })
|
||||||
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
: normalizeBestiary(
|
||||||
|
jsonData as Parameters<typeof normalizeBestiary>[0],
|
||||||
|
);
|
||||||
|
const displayName =
|
||||||
|
edition === "pf2e"
|
||||||
|
? pf2eBestiaryIndex.getSourceDisplayName(sourceCode)
|
||||||
|
: bestiaryIndex.getSourceDisplayName(sourceCode);
|
||||||
|
await bestiaryCache.cacheSource(
|
||||||
|
system,
|
||||||
|
sourceCode,
|
||||||
|
displayName,
|
||||||
|
creatures,
|
||||||
|
);
|
||||||
setCreatureMap((prev) => {
|
setCreatureMap((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
for (const c of creatures) {
|
for (const c of creatures) {
|
||||||
@@ -114,13 +171,13 @@ export function useBestiary(): BestiaryHook {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[],
|
[bestiaryCache, bestiaryIndex, pf2eBestiaryIndex, edition, system],
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshCache = useCallback(async (): Promise<void> => {
|
const refreshCache = useCallback(async (): Promise<void> => {
|
||||||
const map = await bestiaryCache.loadAllCachedCreatures();
|
const map = await bestiaryCache.loadAllCachedCreatures();
|
||||||
setCreatureMap(map);
|
setCreatureMap(map);
|
||||||
}, []);
|
}, [bestiaryCache]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
search,
|
search,
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import {
|
import { useAdapters } from "../contexts/adapter-context.js";
|
||||||
getAllSourceCodes,
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
getDefaultFetchUrl,
|
|
||||||
} from "../adapters/bestiary-index-adapter.js";
|
|
||||||
|
|
||||||
const BATCH_SIZE = 6;
|
const BATCH_SIZE = 6;
|
||||||
|
|
||||||
@@ -32,6 +30,9 @@ interface BulkImportHook {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useBulkImport(): BulkImportHook {
|
export function useBulkImport(): BulkImportHook {
|
||||||
|
const { bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
|
const indexPort = edition === "pf2e" ? pf2eBestiaryIndex : bestiaryIndex;
|
||||||
const [state, setState] = useState<BulkImportState>(IDLE_STATE);
|
const [state, setState] = useState<BulkImportState>(IDLE_STATE);
|
||||||
const countersRef = useRef({ completed: 0, failed: 0 });
|
const countersRef = useRef({ completed: 0, failed: 0 });
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ export function useBulkImport(): BulkImportHook {
|
|||||||
isSourceCached: (sourceCode: string) => Promise<boolean>,
|
isSourceCached: (sourceCode: string) => Promise<boolean>,
|
||||||
refreshCache: () => Promise<void>,
|
refreshCache: () => Promise<void>,
|
||||||
) => {
|
) => {
|
||||||
const allCodes = getAllSourceCodes();
|
const allCodes = indexPort.getAllSourceCodes();
|
||||||
const total = allCodes.length;
|
const total = allCodes.length;
|
||||||
|
|
||||||
countersRef.current = { completed: 0, failed: 0 };
|
countersRef.current = { completed: 0, failed: 0 };
|
||||||
@@ -83,7 +84,7 @@ export function useBulkImport(): BulkImportHook {
|
|||||||
chain.then(() =>
|
chain.then(() =>
|
||||||
Promise.allSettled(
|
Promise.allSettled(
|
||||||
batch.map(async ({ code }) => {
|
batch.map(async ({ code }) => {
|
||||||
const url = getDefaultFetchUrl(code, baseUrl);
|
const url = indexPort.getDefaultFetchUrl(code, baseUrl);
|
||||||
try {
|
try {
|
||||||
await fetchAndCacheSource(code, url);
|
await fetchAndCacheSource(code, url);
|
||||||
countersRef.current.completed++;
|
countersRef.current.completed++;
|
||||||
@@ -117,7 +118,7 @@ export function useBulkImport(): BulkImportHook {
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
[],
|
[indexPort],
|
||||||
);
|
);
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import type {
|
||||||
|
Combatant,
|
||||||
|
CreatureId,
|
||||||
|
DifficultyThreshold,
|
||||||
|
DifficultyTier,
|
||||||
|
PlayerCharacter,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { calculateEncounterDifficulty, crToXp } from "@initiative/domain";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
|
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
|
import { resolveSide } from "./use-difficulty.js";
|
||||||
|
|
||||||
|
export interface BreakdownCombatant {
|
||||||
|
readonly combatant: Combatant;
|
||||||
|
readonly cr: string | null;
|
||||||
|
readonly xp: number | null;
|
||||||
|
readonly source: string | null;
|
||||||
|
readonly editable: boolean;
|
||||||
|
readonly side: "party" | "enemy";
|
||||||
|
readonly level: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DifficultyBreakdown {
|
||||||
|
readonly tier: DifficultyTier;
|
||||||
|
readonly totalMonsterXp: number;
|
||||||
|
readonly thresholds: readonly DifficultyThreshold[];
|
||||||
|
readonly encounterMultiplier: number | undefined;
|
||||||
|
readonly adjustedXp: number | undefined;
|
||||||
|
readonly partySizeAdjusted: boolean | undefined;
|
||||||
|
readonly pcCount: number;
|
||||||
|
readonly partyCombatants: readonly BreakdownCombatant[];
|
||||||
|
readonly enemyCombatants: readonly BreakdownCombatant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDifficultyBreakdown(): DifficultyBreakdown | null {
|
||||||
|
const { encounter } = useEncounterContext();
|
||||||
|
const { characters } = usePlayerCharactersContext();
|
||||||
|
const { getCreature } = useBestiaryContext();
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const { partyCombatants, enemyCombatants, descriptors, pcCount } =
|
||||||
|
classifyCombatants(encounter.combatants, characters, getCreature);
|
||||||
|
|
||||||
|
const hasPartyLevel = descriptors.some(
|
||||||
|
(d) => d.side === "party" && d.level !== undefined,
|
||||||
|
);
|
||||||
|
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
||||||
|
|
||||||
|
if (!hasPartyLevel || !hasCr) return null;
|
||||||
|
|
||||||
|
const result = calculateEncounterDifficulty(descriptors, edition);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
pcCount,
|
||||||
|
partyCombatants,
|
||||||
|
enemyCombatants,
|
||||||
|
};
|
||||||
|
}, [encounter.combatants, characters, getCreature, edition]);
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreatureInfo = {
|
||||||
|
cr?: string;
|
||||||
|
source: string;
|
||||||
|
sourceDisplayName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildBreakdownEntry(
|
||||||
|
c: Combatant,
|
||||||
|
side: "party" | "enemy",
|
||||||
|
level: number | undefined,
|
||||||
|
creature: CreatureInfo | undefined,
|
||||||
|
): BreakdownCombatant {
|
||||||
|
if (c.playerCharacterId) {
|
||||||
|
return {
|
||||||
|
combatant: c,
|
||||||
|
cr: null,
|
||||||
|
xp: null,
|
||||||
|
source: null,
|
||||||
|
editable: false,
|
||||||
|
side,
|
||||||
|
level,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (creature) {
|
||||||
|
const cr = creature.cr ?? null;
|
||||||
|
return {
|
||||||
|
combatant: c,
|
||||||
|
cr,
|
||||||
|
xp: cr ? crToXp(cr) : null,
|
||||||
|
source: creature.sourceDisplayName ?? creature.source,
|
||||||
|
editable: false,
|
||||||
|
side,
|
||||||
|
level: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (c.cr) {
|
||||||
|
return {
|
||||||
|
combatant: c,
|
||||||
|
cr: c.cr,
|
||||||
|
xp: crToXp(c.cr),
|
||||||
|
source: null,
|
||||||
|
editable: true,
|
||||||
|
side,
|
||||||
|
level: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
combatant: c,
|
||||||
|
cr: null,
|
||||||
|
xp: null,
|
||||||
|
source: null,
|
||||||
|
editable: !c.creatureId,
|
||||||
|
side,
|
||||||
|
level: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLevel(
|
||||||
|
c: Combatant,
|
||||||
|
characters: readonly PlayerCharacter[],
|
||||||
|
): number | undefined {
|
||||||
|
if (!c.playerCharacterId) return undefined;
|
||||||
|
return characters.find((p) => p.id === c.playerCharacterId)?.level;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCr(
|
||||||
|
c: Combatant,
|
||||||
|
getCreature: (id: CreatureId) => CreatureInfo | undefined,
|
||||||
|
): { cr: string | null; creature: CreatureInfo | undefined } {
|
||||||
|
const creature = c.creatureId ? getCreature(c.creatureId) : undefined;
|
||||||
|
const cr = creature?.cr ?? c.cr ?? null;
|
||||||
|
return { cr, creature };
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyCombatants(
|
||||||
|
combatants: readonly Combatant[],
|
||||||
|
characters: readonly PlayerCharacter[],
|
||||||
|
getCreature: (id: CreatureId) => CreatureInfo | undefined,
|
||||||
|
) {
|
||||||
|
const partyCombatants: BreakdownCombatant[] = [];
|
||||||
|
const enemyCombatants: BreakdownCombatant[] = [];
|
||||||
|
const descriptors: {
|
||||||
|
level?: number;
|
||||||
|
cr?: string;
|
||||||
|
side: "party" | "enemy";
|
||||||
|
}[] = [];
|
||||||
|
let pcCount = 0;
|
||||||
|
|
||||||
|
for (const c of combatants) {
|
||||||
|
const side = resolveSide(c);
|
||||||
|
const level = resolveLevel(c, characters);
|
||||||
|
if (level !== undefined) pcCount++;
|
||||||
|
|
||||||
|
const { cr, creature } = resolveCr(c, getCreature);
|
||||||
|
|
||||||
|
if (level !== undefined || cr != null) {
|
||||||
|
descriptors.push({ level, cr: cr ?? undefined, side });
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = buildBreakdownEntry(c, side, level, creature);
|
||||||
|
const target = side === "party" ? partyCombatants : enemyCombatants;
|
||||||
|
target.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { partyCombatants, enemyCombatants, descriptors, pcCount };
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AnyCreature,
|
||||||
Combatant,
|
Combatant,
|
||||||
|
CombatantDescriptor,
|
||||||
CreatureId,
|
CreatureId,
|
||||||
DifficultyResult,
|
DifficultyResult,
|
||||||
PlayerCharacter,
|
PlayerCharacter,
|
||||||
@@ -9,46 +11,58 @@ import { useMemo } from "react";
|
|||||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
|
|
||||||
function derivePartyLevels(
|
export function resolveSide(c: Combatant): "party" | "enemy" {
|
||||||
combatants: readonly Combatant[],
|
if (c.side) return c.side;
|
||||||
characters: readonly PlayerCharacter[],
|
return c.playerCharacterId ? "party" : "enemy";
|
||||||
): number[] {
|
|
||||||
const levels: number[] = [];
|
|
||||||
for (const c of combatants) {
|
|
||||||
if (!c.playerCharacterId) continue;
|
|
||||||
const pc = characters.find((p) => p.id === c.playerCharacterId);
|
|
||||||
if (pc?.level !== undefined) levels.push(pc.level);
|
|
||||||
}
|
|
||||||
return levels;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deriveMonsterCrs(
|
function buildDescriptors(
|
||||||
combatants: readonly Combatant[],
|
combatants: readonly Combatant[],
|
||||||
getCreature: (id: CreatureId) => { cr: string } | undefined,
|
characters: readonly PlayerCharacter[],
|
||||||
): string[] {
|
getCreature: (id: CreatureId) => AnyCreature | undefined,
|
||||||
const crs: string[] = [];
|
): CombatantDescriptor[] {
|
||||||
|
const descriptors: CombatantDescriptor[] = [];
|
||||||
for (const c of combatants) {
|
for (const c of combatants) {
|
||||||
if (!c.creatureId) continue;
|
const side = resolveSide(c);
|
||||||
const creature = getCreature(c.creatureId);
|
const level = c.playerCharacterId
|
||||||
if (creature) crs.push(creature.cr);
|
? characters.find((p) => p.id === c.playerCharacterId)?.level
|
||||||
|
: undefined;
|
||||||
|
const creature = c.creatureId ? getCreature(c.creatureId) : undefined;
|
||||||
|
const creatureCr =
|
||||||
|
creature && !("system" in creature) ? creature.cr : undefined;
|
||||||
|
const cr = creatureCr ?? c.cr ?? undefined;
|
||||||
|
|
||||||
|
if (level !== undefined || cr !== undefined) {
|
||||||
|
descriptors.push({ level, cr, side });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return crs;
|
return descriptors;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDifficulty(): DifficultyResult | null {
|
export function useDifficulty(): DifficultyResult | null {
|
||||||
const { encounter } = useEncounterContext();
|
const { encounter } = useEncounterContext();
|
||||||
const { characters } = usePlayerCharactersContext();
|
const { characters } = usePlayerCharactersContext();
|
||||||
const { getCreature } = useBestiaryContext();
|
const { getCreature } = useBestiaryContext();
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const partyLevels = derivePartyLevels(encounter.combatants, characters);
|
if (edition === "pf2e") return null;
|
||||||
const monsterCrs = deriveMonsterCrs(encounter.combatants, getCreature);
|
|
||||||
|
|
||||||
if (partyLevels.length === 0 || monsterCrs.length === 0) {
|
const descriptors = buildDescriptors(
|
||||||
return null;
|
encounter.combatants,
|
||||||
}
|
characters,
|
||||||
|
getCreature,
|
||||||
|
);
|
||||||
|
|
||||||
return calculateEncounterDifficulty(partyLevels, monsterCrs);
|
const hasPartyLevel = descriptors.some(
|
||||||
}, [encounter.combatants, characters, getCreature]);
|
(d) => d.side === "party" && d.level !== undefined,
|
||||||
|
);
|
||||||
|
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
||||||
|
|
||||||
|
if (!hasPartyLevel || !hasCr) return null;
|
||||||
|
|
||||||
|
return calculateEncounterDifficulty(descriptors, edition);
|
||||||
|
}, [encounter.combatants, characters, getCreature, edition]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import type { ExportBundle } from "@initiative/domain";
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
|
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||||
|
import {
|
||||||
|
assembleExportBundle,
|
||||||
|
bundleToJson,
|
||||||
|
readImportFile,
|
||||||
|
triggerDownload,
|
||||||
|
validateImportBundle,
|
||||||
|
} from "../persistence/export-import.js";
|
||||||
|
|
||||||
|
export function useEncounterExportImport() {
|
||||||
|
const {
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
isEmpty: encounterIsEmpty,
|
||||||
|
setEncounter,
|
||||||
|
setUndoRedoState,
|
||||||
|
} = useEncounterContext();
|
||||||
|
const { characters: playerCharacters, replacePlayerCharacters } =
|
||||||
|
usePlayerCharactersContext();
|
||||||
|
|
||||||
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
|
const [showExportMethod, setShowExportMethod] = useState(false);
|
||||||
|
const [showImportMethod, setShowImportMethod] = useState(false);
|
||||||
|
const [showImportConfirm, setShowImportConfirm] = useState(false);
|
||||||
|
const pendingBundleRef = useRef<ExportBundle | null>(null);
|
||||||
|
const importFileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleExportDownload = useCallback(
|
||||||
|
(includeHistory: boolean, filename: string) => {
|
||||||
|
const bundle = assembleExportBundle(
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
playerCharacters,
|
||||||
|
includeHistory,
|
||||||
|
);
|
||||||
|
triggerDownload(bundle, filename);
|
||||||
|
},
|
||||||
|
[encounter, undoRedoState, playerCharacters],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleExportClipboard = useCallback(
|
||||||
|
(includeHistory: boolean) => {
|
||||||
|
const bundle = assembleExportBundle(
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
playerCharacters,
|
||||||
|
includeHistory,
|
||||||
|
);
|
||||||
|
void navigator.clipboard.writeText(bundleToJson(bundle));
|
||||||
|
},
|
||||||
|
[encounter, undoRedoState, playerCharacters],
|
||||||
|
);
|
||||||
|
|
||||||
|
const applyImport = useCallback(
|
||||||
|
(bundle: ExportBundle) => {
|
||||||
|
setEncounter(bundle.encounter);
|
||||||
|
setUndoRedoState({
|
||||||
|
undoStack: bundle.undoStack,
|
||||||
|
redoStack: bundle.redoStack,
|
||||||
|
});
|
||||||
|
replacePlayerCharacters([...bundle.playerCharacters]);
|
||||||
|
},
|
||||||
|
[setEncounter, setUndoRedoState, replacePlayerCharacters],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleValidatedBundle = useCallback(
|
||||||
|
(result: ExportBundle | string) => {
|
||||||
|
if (typeof result === "string") {
|
||||||
|
setImportError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (encounterIsEmpty) {
|
||||||
|
applyImport(result);
|
||||||
|
} else {
|
||||||
|
pendingBundleRef.current = result;
|
||||||
|
setShowImportConfirm(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[encounterIsEmpty, applyImport],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleImportFile = useCallback(
|
||||||
|
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (importFileRef.current) importFileRef.current.value = "";
|
||||||
|
|
||||||
|
setImportError(null);
|
||||||
|
handleValidatedBundle(await readImportFile(file));
|
||||||
|
},
|
||||||
|
[handleValidatedBundle],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleImportClipboard = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
setImportError(null);
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(text);
|
||||||
|
handleValidatedBundle(validateImportBundle(parsed));
|
||||||
|
} catch {
|
||||||
|
setImportError("Invalid file format");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleValidatedBundle],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleImportConfirm = useCallback(() => {
|
||||||
|
if (pendingBundleRef.current) {
|
||||||
|
applyImport(pendingBundleRef.current);
|
||||||
|
pendingBundleRef.current = null;
|
||||||
|
}
|
||||||
|
setShowImportConfirm(false);
|
||||||
|
}, [applyImport]);
|
||||||
|
|
||||||
|
const handleImportCancel = useCallback(() => {
|
||||||
|
pendingBundleRef.current = null;
|
||||||
|
setShowImportConfirm(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
importError,
|
||||||
|
showExportMethod,
|
||||||
|
showImportMethod,
|
||||||
|
showImportConfirm,
|
||||||
|
importFileRef,
|
||||||
|
setImportError,
|
||||||
|
setShowExportMethod,
|
||||||
|
setShowImportMethod,
|
||||||
|
handleExportDownload,
|
||||||
|
handleExportClipboard,
|
||||||
|
handleImportFile,
|
||||||
|
handleImportClipboard,
|
||||||
|
handleImportConfirm,
|
||||||
|
handleImportCancel,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
@@ -4,20 +4,23 @@ import {
|
|||||||
adjustHpUseCase,
|
adjustHpUseCase,
|
||||||
advanceTurnUseCase,
|
advanceTurnUseCase,
|
||||||
clearEncounterUseCase,
|
clearEncounterUseCase,
|
||||||
|
decrementConditionUseCase,
|
||||||
editCombatantUseCase,
|
editCombatantUseCase,
|
||||||
redoUseCase,
|
redoUseCase,
|
||||||
removeCombatantUseCase,
|
removeCombatantUseCase,
|
||||||
retreatTurnUseCase,
|
retreatTurnUseCase,
|
||||||
setAcUseCase,
|
setAcUseCase,
|
||||||
|
setConditionValueUseCase,
|
||||||
|
setCrUseCase,
|
||||||
setHpUseCase,
|
setHpUseCase,
|
||||||
setInitiativeUseCase,
|
setInitiativeUseCase,
|
||||||
|
setSideUseCase,
|
||||||
setTempHpUseCase,
|
setTempHpUseCase,
|
||||||
toggleConcentrationUseCase,
|
toggleConcentrationUseCase,
|
||||||
toggleConditionUseCase,
|
toggleConditionUseCase,
|
||||||
undoUseCase,
|
undoUseCase,
|
||||||
} from "@initiative/application";
|
} from "@initiative/application";
|
||||||
import type {
|
import type {
|
||||||
BestiaryIndexEntry,
|
|
||||||
CombatantId,
|
CombatantId,
|
||||||
CombatantInit,
|
CombatantInit,
|
||||||
ConditionId,
|
ConditionId,
|
||||||
@@ -37,14 +40,8 @@ import {
|
|||||||
resolveCreatureName,
|
resolveCreatureName,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { useCallback, useEffect, useReducer, useRef } from "react";
|
import { useCallback, useEffect, useReducer, useRef } from "react";
|
||||||
import {
|
import { useAdapters } from "../contexts/adapter-context.js";
|
||||||
loadEncounter,
|
import type { SearchResult } from "./use-bestiary.js";
|
||||||
saveEncounter,
|
|
||||||
} from "../persistence/encounter-storage.js";
|
|
||||||
import {
|
|
||||||
loadUndoRedoStacks,
|
|
||||||
saveUndoRedoStacks,
|
|
||||||
} from "../persistence/undo-redo-storage.js";
|
|
||||||
|
|
||||||
// -- Types --
|
// -- Types --
|
||||||
|
|
||||||
@@ -59,19 +56,32 @@ type EncounterAction =
|
|||||||
| { type: "adjust-hp"; id: CombatantId; delta: number }
|
| { type: "adjust-hp"; id: CombatantId; delta: number }
|
||||||
| { type: "set-temp-hp"; id: CombatantId; tempHp: number | undefined }
|
| { type: "set-temp-hp"; id: CombatantId; tempHp: number | undefined }
|
||||||
| { type: "set-ac"; id: CombatantId; value: number | undefined }
|
| { type: "set-ac"; id: CombatantId; value: number | undefined }
|
||||||
|
| { type: "set-cr"; id: CombatantId; value: string | undefined }
|
||||||
|
| { type: "set-side"; id: CombatantId; value: "party" | "enemy" }
|
||||||
| {
|
| {
|
||||||
type: "toggle-condition";
|
type: "toggle-condition";
|
||||||
id: CombatantId;
|
id: CombatantId;
|
||||||
conditionId: ConditionId;
|
conditionId: ConditionId;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: "set-condition-value";
|
||||||
|
id: CombatantId;
|
||||||
|
conditionId: ConditionId;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "decrement-condition";
|
||||||
|
id: CombatantId;
|
||||||
|
conditionId: ConditionId;
|
||||||
|
}
|
||||||
| { type: "toggle-concentration"; id: CombatantId }
|
| { type: "toggle-concentration"; id: CombatantId }
|
||||||
| { type: "clear-encounter" }
|
| { type: "clear-encounter" }
|
||||||
| { type: "undo" }
|
| { type: "undo" }
|
||||||
| { type: "redo" }
|
| { type: "redo" }
|
||||||
| { type: "add-from-bestiary"; entry: BestiaryIndexEntry }
|
| { type: "add-from-bestiary"; entry: SearchResult }
|
||||||
| {
|
| {
|
||||||
type: "add-multiple-from-bestiary";
|
type: "add-multiple-from-bestiary";
|
||||||
entry: BestiaryIndexEntry;
|
entry: SearchResult;
|
||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
| { type: "add-from-player-character"; pc: PlayerCharacter }
|
| { type: "add-from-player-character"; pc: PlayerCharacter }
|
||||||
@@ -111,11 +121,14 @@ function deriveNextId(encounter: Encounter): number {
|
|||||||
return max;
|
return max;
|
||||||
}
|
}
|
||||||
|
|
||||||
function initializeState(): EncounterState {
|
function initializeState(
|
||||||
const encounter = loadEncounter() ?? EMPTY_ENCOUNTER;
|
loadEncounterFn: () => Encounter | null,
|
||||||
|
loadUndoRedoFn: () => UndoRedoState,
|
||||||
|
): EncounterState {
|
||||||
|
const encounter = loadEncounterFn() ?? EMPTY_ENCOUNTER;
|
||||||
return {
|
return {
|
||||||
encounter,
|
encounter,
|
||||||
undoRedoState: loadUndoRedoStacks(),
|
undoRedoState: loadUndoRedoFn(),
|
||||||
events: [],
|
events: [],
|
||||||
nextId: deriveNextId(encounter),
|
nextId: deriveNextId(encounter),
|
||||||
lastCreatureId: null,
|
lastCreatureId: null,
|
||||||
@@ -156,7 +169,7 @@ function resolveAndRename(store: EncounterStore, name: string): string {
|
|||||||
|
|
||||||
function addOneFromBestiary(
|
function addOneFromBestiary(
|
||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
entry: BestiaryIndexEntry,
|
entry: SearchResult,
|
||||||
nextId: number,
|
nextId: number,
|
||||||
): {
|
): {
|
||||||
cId: CreatureId;
|
cId: CreatureId;
|
||||||
@@ -173,7 +186,7 @@ function addOneFromBestiary(
|
|||||||
|
|
||||||
const id = combatantId(`c-${nextId + 1}`);
|
const id = combatantId(`c-${nextId + 1}`);
|
||||||
const result = addCombatantUseCase(store, id, newName, {
|
const result = addCombatantUseCase(store, id, newName, {
|
||||||
maxHp: entry.hp,
|
maxHp: entry.hp > 0 ? entry.hp : undefined,
|
||||||
ac: entry.ac > 0 ? entry.ac : undefined,
|
ac: entry.ac > 0 ? entry.ac : undefined,
|
||||||
creatureId: cId,
|
creatureId: cId,
|
||||||
});
|
});
|
||||||
@@ -215,7 +228,7 @@ function handleUndoRedo(
|
|||||||
|
|
||||||
function handleAddFromBestiary(
|
function handleAddFromBestiary(
|
||||||
state: EncounterState,
|
state: EncounterState,
|
||||||
entry: BestiaryIndexEntry,
|
entry: SearchResult,
|
||||||
count: number,
|
count: number,
|
||||||
): EncounterState {
|
): EncounterState {
|
||||||
const { store, getEncounter } = makeStoreFromState(state);
|
const { store, getEncounter } = makeStoreFromState(state);
|
||||||
@@ -322,7 +335,11 @@ function dispatchEncounterAction(
|
|||||||
| { type: "adjust-hp" }
|
| { type: "adjust-hp" }
|
||||||
| { type: "set-temp-hp" }
|
| { type: "set-temp-hp" }
|
||||||
| { type: "set-ac" }
|
| { type: "set-ac" }
|
||||||
|
| { type: "set-cr" }
|
||||||
|
| { type: "set-side" }
|
||||||
| { type: "toggle-condition" }
|
| { type: "toggle-condition" }
|
||||||
|
| { type: "set-condition-value" }
|
||||||
|
| { type: "decrement-condition" }
|
||||||
| { type: "toggle-concentration" }
|
| { type: "toggle-concentration" }
|
||||||
>,
|
>,
|
||||||
): EncounterState {
|
): EncounterState {
|
||||||
@@ -362,9 +379,26 @@ function dispatchEncounterAction(
|
|||||||
case "set-ac":
|
case "set-ac":
|
||||||
result = setAcUseCase(store, action.id, action.value);
|
result = setAcUseCase(store, action.id, action.value);
|
||||||
break;
|
break;
|
||||||
|
case "set-cr":
|
||||||
|
result = setCrUseCase(store, action.id, action.value);
|
||||||
|
break;
|
||||||
|
case "set-side":
|
||||||
|
result = setSideUseCase(store, action.id, action.value);
|
||||||
|
break;
|
||||||
case "toggle-condition":
|
case "toggle-condition":
|
||||||
result = toggleConditionUseCase(store, action.id, action.conditionId);
|
result = toggleConditionUseCase(store, action.id, action.conditionId);
|
||||||
break;
|
break;
|
||||||
|
case "set-condition-value":
|
||||||
|
result = setConditionValueUseCase(
|
||||||
|
store,
|
||||||
|
action.id,
|
||||||
|
action.conditionId,
|
||||||
|
action.value,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "decrement-condition":
|
||||||
|
result = decrementConditionUseCase(store, action.id, action.conditionId);
|
||||||
|
break;
|
||||||
case "toggle-concentration":
|
case "toggle-concentration":
|
||||||
result = toggleConcentrationUseCase(store, action.id);
|
result = toggleConcentrationUseCase(store, action.id);
|
||||||
break;
|
break;
|
||||||
@@ -385,7 +419,10 @@ function dispatchEncounterAction(
|
|||||||
// -- Hook --
|
// -- Hook --
|
||||||
|
|
||||||
export function useEncounter() {
|
export function useEncounter() {
|
||||||
const [state, dispatch] = useReducer(encounterReducer, null, initializeState);
|
const { encounterPersistence, undoRedoPersistence } = useAdapters();
|
||||||
|
const [state, dispatch] = useReducer(encounterReducer, null, () =>
|
||||||
|
initializeState(encounterPersistence.load, undoRedoPersistence.load),
|
||||||
|
);
|
||||||
const { encounter, undoRedoState, events } = state;
|
const { encounter, undoRedoState, events } = state;
|
||||||
|
|
||||||
const encounterRef = useRef(encounter);
|
const encounterRef = useRef(encounter);
|
||||||
@@ -394,12 +431,12 @@ export function useEncounter() {
|
|||||||
undoRedoRef.current = undoRedoState;
|
undoRedoRef.current = undoRedoState;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
saveEncounter(encounter);
|
encounterPersistence.save(encounter);
|
||||||
}, [encounter]);
|
}, [encounter, encounterPersistence]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
saveUndoRedoStacks(undoRedoState);
|
undoRedoPersistence.save(undoRedoState);
|
||||||
}, [undoRedoState]);
|
}, [undoRedoState, undoRedoPersistence]);
|
||||||
|
|
||||||
// Escape hatches for useInitiativeRolls (needs raw port access)
|
// Escape hatches for useInitiativeRolls (needs raw port access)
|
||||||
const makeStore = useCallback((): EncounterStore => {
|
const makeStore = useCallback((): EncounterStore => {
|
||||||
@@ -496,11 +533,31 @@ export function useEncounter() {
|
|||||||
dispatch({ type: "set-ac", id, value }),
|
dispatch({ type: "set-ac", id, value }),
|
||||||
[],
|
[],
|
||||||
),
|
),
|
||||||
|
setCr: useCallback(
|
||||||
|
(id: CombatantId, value: string | undefined) =>
|
||||||
|
dispatch({ type: "set-cr", id, value }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
setSide: useCallback(
|
||||||
|
(id: CombatantId, value: "party" | "enemy") =>
|
||||||
|
dispatch({ type: "set-side", id, value }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
toggleCondition: useCallback(
|
toggleCondition: useCallback(
|
||||||
(id: CombatantId, conditionId: ConditionId) =>
|
(id: CombatantId, conditionId: ConditionId) =>
|
||||||
dispatch({ type: "toggle-condition", id, conditionId }),
|
dispatch({ type: "toggle-condition", id, conditionId }),
|
||||||
[],
|
[],
|
||||||
),
|
),
|
||||||
|
setConditionValue: useCallback(
|
||||||
|
(id: CombatantId, conditionId: ConditionId, value: number) =>
|
||||||
|
dispatch({ type: "set-condition-value", id, conditionId, value }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
decrementCondition: useCallback(
|
||||||
|
(id: CombatantId, conditionId: ConditionId) =>
|
||||||
|
dispatch({ type: "decrement-condition", id, conditionId }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
toggleConcentration: useCallback(
|
toggleConcentration: useCallback(
|
||||||
(id: CombatantId) => dispatch({ type: "toggle-concentration", id }),
|
(id: CombatantId) => dispatch({ type: "toggle-concentration", id }),
|
||||||
[],
|
[],
|
||||||
@@ -509,15 +566,12 @@ export function useEncounter() {
|
|||||||
() => dispatch({ type: "clear-encounter" }),
|
() => dispatch({ type: "clear-encounter" }),
|
||||||
[],
|
[],
|
||||||
),
|
),
|
||||||
addFromBestiary: useCallback(
|
addFromBestiary: useCallback((entry: SearchResult): CreatureId | null => {
|
||||||
(entry: BestiaryIndexEntry): CreatureId | null => {
|
dispatch({ type: "add-from-bestiary", entry });
|
||||||
dispatch({ type: "add-from-bestiary", entry });
|
return null;
|
||||||
return null;
|
}, []),
|
||||||
},
|
|
||||||
[],
|
|
||||||
),
|
|
||||||
addMultipleFromBestiary: useCallback(
|
addMultipleFromBestiary: useCallback(
|
||||||
(entry: BestiaryIndexEntry, count: number): CreatureId | null => {
|
(entry: SearchResult, count: number): CreatureId | null => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "add-multiple-from-bestiary",
|
type: "add-multiple-from-bestiary",
|
||||||
entry,
|
entry,
|
||||||
|
|||||||
@@ -7,14 +7,7 @@ import {
|
|||||||
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||||
import { isDomainError, playerCharacterId } from "@initiative/domain";
|
import { isDomainError, playerCharacterId } from "@initiative/domain";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import { useAdapters } from "../contexts/adapter-context.js";
|
||||||
loadPlayerCharacters,
|
|
||||||
savePlayerCharacters,
|
|
||||||
} from "../persistence/player-character-storage.js";
|
|
||||||
|
|
||||||
function initializeCharacters(): PlayerCharacter[] {
|
|
||||||
return loadPlayerCharacters();
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextPcId = 0;
|
let nextPcId = 0;
|
||||||
|
|
||||||
@@ -32,14 +25,16 @@ interface EditFields {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function usePlayerCharacters() {
|
export function usePlayerCharacters() {
|
||||||
const [characters, setCharacters] =
|
const { playerCharacterPersistence } = useAdapters();
|
||||||
useState<PlayerCharacter[]>(initializeCharacters);
|
const [characters, setCharacters] = useState<PlayerCharacter[]>(() =>
|
||||||
|
playerCharacterPersistence.load(),
|
||||||
|
);
|
||||||
const charactersRef = useRef(characters);
|
const charactersRef = useRef(characters);
|
||||||
charactersRef.current = characters;
|
charactersRef.current = characters;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
savePlayerCharacters(characters);
|
playerCharacterPersistence.save(characters);
|
||||||
}, [characters]);
|
}, [characters, playerCharacterPersistence]);
|
||||||
|
|
||||||
const makeStore = useCallback((): PlayerCharacterStore => {
|
const makeStore = useCallback((): PlayerCharacterStore => {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { RulesEdition } from "@initiative/domain";
|
import type { RulesEdition } from "@initiative/domain";
|
||||||
import { useCallback, useSyncExternalStore } from "react";
|
import { useCallback, useSyncExternalStore } from "react";
|
||||||
|
|
||||||
const STORAGE_KEY = "initiative:rules-edition";
|
const STORAGE_KEY = "initiative:game-system";
|
||||||
|
const OLD_STORAGE_KEY = "initiative:rules-edition";
|
||||||
|
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
let currentEdition: RulesEdition = loadEdition();
|
let currentEdition: RulesEdition = loadEdition();
|
||||||
@@ -9,7 +10,14 @@ let currentEdition: RulesEdition = loadEdition();
|
|||||||
function loadEdition(): RulesEdition {
|
function loadEdition(): RulesEdition {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
if (raw === "5e" || raw === "5.5e") return raw;
|
if (raw === "5e" || raw === "5.5e" || raw === "pf2e") return raw;
|
||||||
|
// Migrate from old key
|
||||||
|
const old = localStorage.getItem(OLD_STORAGE_KEY);
|
||||||
|
if (old === "5e" || old === "5.5e") {
|
||||||
|
localStorage.setItem(STORAGE_KEY, old);
|
||||||
|
localStorage.removeItem(OLD_STORAGE_KEY);
|
||||||
|
return old;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// storage unavailable
|
// storage unavailable
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-17
@@ -1,6 +1,8 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { App } from "./App.js";
|
import { App } from "./App.js";
|
||||||
|
import { productionAdapters } from "./adapters/production-adapters.js";
|
||||||
|
import { AdapterProvider } from "./contexts/adapter-context.js";
|
||||||
import {
|
import {
|
||||||
BestiaryProvider,
|
BestiaryProvider,
|
||||||
BulkImportProvider,
|
BulkImportProvider,
|
||||||
@@ -17,23 +19,25 @@ const root = document.getElementById("root");
|
|||||||
if (root) {
|
if (root) {
|
||||||
createRoot(root).render(
|
createRoot(root).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ThemeProvider>
|
<AdapterProvider adapters={productionAdapters}>
|
||||||
<RulesEditionProvider>
|
<ThemeProvider>
|
||||||
<EncounterProvider>
|
<RulesEditionProvider>
|
||||||
<BestiaryProvider>
|
<EncounterProvider>
|
||||||
<PlayerCharactersProvider>
|
<BestiaryProvider>
|
||||||
<BulkImportProvider>
|
<PlayerCharactersProvider>
|
||||||
<SidePanelProvider>
|
<BulkImportProvider>
|
||||||
<InitiativeRollsProvider>
|
<SidePanelProvider>
|
||||||
<App />
|
<InitiativeRollsProvider>
|
||||||
</InitiativeRollsProvider>
|
<App />
|
||||||
</SidePanelProvider>
|
</InitiativeRollsProvider>
|
||||||
</BulkImportProvider>
|
</SidePanelProvider>
|
||||||
</PlayerCharactersProvider>
|
</BulkImportProvider>
|
||||||
</BestiaryProvider>
|
</PlayerCharactersProvider>
|
||||||
</EncounterProvider>
|
</BestiaryProvider>
|
||||||
</RulesEditionProvider>
|
</EncounterProvider>
|
||||||
</ThemeProvider>
|
</RulesEditionProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</AdapterProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,67 @@ describe("loadEncounter", () => {
|
|||||||
expect(loadEncounter()).toBeNull();
|
expect(loadEncounter()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("round-trip preserves combatant cr field", () => {
|
||||||
|
const result = createEncounter(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Custom Thug",
|
||||||
|
cr: "2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error("unreachable");
|
||||||
|
saveEncounter(result);
|
||||||
|
const loaded = loadEncounter();
|
||||||
|
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
expect(loaded?.combatants[0].cr).toBe("2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trip preserves combatant side field", () => {
|
||||||
|
const result = createEncounter(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Allied Guard",
|
||||||
|
cr: "2",
|
||||||
|
side: "party",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Goblin",
|
||||||
|
side: "enemy",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error("unreachable");
|
||||||
|
saveEncounter(result);
|
||||||
|
const loaded = loadEncounter();
|
||||||
|
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
expect(loaded?.combatants[0].side).toBe("party");
|
||||||
|
expect(loaded?.combatants[1].side).toBe("enemy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trip preserves combatant without side field as undefined", () => {
|
||||||
|
const result = createEncounter(
|
||||||
|
[{ id: combatantId("c-1"), name: "Custom" }],
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error("unreachable");
|
||||||
|
saveEncounter(result);
|
||||||
|
const loaded = loadEncounter();
|
||||||
|
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
expect(loaded?.combatants[0].side).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("saving after modifications persists the latest state", () => {
|
it("saving after modifications persists the latest state", () => {
|
||||||
const encounter = makeEncounter();
|
const encounter = makeEncounter();
|
||||||
saveEncounter(encounter);
|
saveEncounter(encounter);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
|||||||
|
# Conventions (detailed)
|
||||||
|
|
||||||
|
These conventions supplement the overview in `CLAUDE.md`. Load this file when working in the relevant areas.
|
||||||
|
|
||||||
|
## Component Props
|
||||||
|
|
||||||
|
Max 8 explicitly declared props per component interface, enforced by `scripts/check-component-props.mjs` (uses the TypeScript compiler API). Run `pnpm check:props` to verify.
|
||||||
|
|
||||||
|
- Use React context for shared state
|
||||||
|
- Reserve props for per-instance config (data items, layout variants, refs)
|
||||||
|
|
||||||
|
## Export Format Compatibility
|
||||||
|
|
||||||
|
When changing `Encounter`, `Combatant`, `PlayerCharacter`, or `UndoRedoState` types, verify that previously exported JSON files (version 1) still import correctly. If not, bump the `ExportBundle` version and add migration logic in `validateImportBundle()`.
|
||||||
|
|
||||||
|
## Domain Patterns
|
||||||
|
|
||||||
|
- **Branded types** for identity values (e.g., `CombatantId`). Prefer immutability/`readonly` where practical. See [ADR-003](adr/003-branded-types-for-identity.md).
|
||||||
|
- **Domain events** are plain data objects with a `type` discriminant — no classes. See [ADR-002](adr/002-domain-events-as-plain-data.md).
|
||||||
|
- **Errors as values** (`DomainError`), never thrown. See [ADR-001](adr/001-errors-as-values.md).
|
||||||
+2
-2
@@ -24,6 +24,6 @@ pre-commit:
|
|||||||
- name: typecheck
|
- name: typecheck
|
||||||
run: pnpm exec tsc --build
|
run: pnpm exec tsc --build
|
||||||
- name: oxlint
|
- name: oxlint
|
||||||
run: pnpm oxlint
|
run: pnpm oxlint -- --deny warnings
|
||||||
- name: test
|
- name: test
|
||||||
run: pnpm test
|
run: pnpm vitest run --reporter=dot --coverage.reporter=text-summary
|
||||||
|
|||||||
+2
-2
@@ -31,10 +31,10 @@
|
|||||||
"knip": "knip",
|
"knip": "knip",
|
||||||
"jscpd": "jscpd",
|
"jscpd": "jscpd",
|
||||||
"jsinspect": "jsinspect -c .jsinspectrc apps/web/src packages/domain/src packages/application/src",
|
"jsinspect": "jsinspect -c .jsinspectrc apps/web/src packages/domain/src packages/application/src",
|
||||||
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
|
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware --deny warnings",
|
||||||
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
||||||
"check:classnames": "node scripts/check-cn-classnames.mjs",
|
"check:classnames": "node scripts/check-cn-classnames.mjs",
|
||||||
"check:props": "node scripts/check-component-props.mjs",
|
"check:props": "node scripts/check-component-props.mjs",
|
||||||
"check": "pnpm audit --audit-level=high && knip && biome check . && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && node scripts/check-component-props.mjs && jscpd && pnpm jsinspect && tsc --build && oxlint --tsconfig apps/web/tsconfig.json --type-aware && vitest run"
|
"check": "pnpm audit --audit-level=high && knip && biome check . && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && node scripts/check-component-props.mjs && jscpd && pnpm jsinspect && tsc --build && oxlint --tsconfig apps/web/tsconfig.json --type-aware --deny warnings && vitest run"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -292,9 +292,9 @@ describe("toggleConditionUseCase", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(false);
|
expect(isDomainError(result)).toBe(false);
|
||||||
expect(requireSaved(store.saved).combatants[0].conditions).toContain(
|
expect(requireSaved(store.saved).combatants[0].conditions).toContainEqual({
|
||||||
"blinded",
|
id: "blinded",
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns domain error for unknown combatant", () => {
|
it("returns domain error for unknown combatant", () => {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import {
|
||||||
|
type AnyCreature,
|
||||||
|
calculateInitiative,
|
||||||
|
calculatePf2eInitiative,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
|
||||||
|
export function creatureInitiativeModifier(creature: AnyCreature): number {
|
||||||
|
if ("system" in creature && creature.system === "pf2e") {
|
||||||
|
return calculatePf2eInitiative(creature.perception).modifier;
|
||||||
|
}
|
||||||
|
const c = creature as {
|
||||||
|
abilities: { dex: number };
|
||||||
|
cr: string;
|
||||||
|
initiativeProficiency: number;
|
||||||
|
};
|
||||||
|
return calculateInitiative({
|
||||||
|
dexScore: c.abilities.dex,
|
||||||
|
cr: c.cr,
|
||||||
|
initiativeProficiency: c.initiativeProficiency,
|
||||||
|
}).modifier;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type ConditionId,
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
decrementCondition,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
import { runEncounterAction } from "./run-encounter-action.js";
|
||||||
|
|
||||||
|
export function decrementConditionUseCase(
|
||||||
|
store: EncounterStore,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
conditionId: ConditionId,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
return runEncounterAction(store, (encounter) =>
|
||||||
|
decrementCondition(encounter, combatantId, conditionId),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ 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 { clearEncounterUseCase } from "./clear-encounter-use-case.js";
|
||||||
export { createPlayerCharacterUseCase } from "./create-player-character-use-case.js";
|
export { createPlayerCharacterUseCase } from "./create-player-character-use-case.js";
|
||||||
|
export { decrementConditionUseCase } from "./decrement-condition-use-case.js";
|
||||||
export { deletePlayerCharacterUseCase } from "./delete-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 { editPlayerCharacterUseCase } from "./edit-player-character-use-case.js";
|
export { editPlayerCharacterUseCase } from "./edit-player-character-use-case.js";
|
||||||
@@ -21,8 +22,11 @@ export {
|
|||||||
} from "./roll-all-initiative-use-case.js";
|
} from "./roll-all-initiative-use-case.js";
|
||||||
export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
|
export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
|
||||||
export { setAcUseCase } from "./set-ac-use-case.js";
|
export { setAcUseCase } from "./set-ac-use-case.js";
|
||||||
|
export { setConditionValueUseCase } from "./set-condition-value-use-case.js";
|
||||||
|
export { setCrUseCase } from "./set-cr-use-case.js";
|
||||||
export { setHpUseCase } from "./set-hp-use-case.js";
|
export { setHpUseCase } from "./set-hp-use-case.js";
|
||||||
export { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
export { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
||||||
|
export { setSideUseCase } from "./set-side-use-case.js";
|
||||||
export { setTempHpUseCase } from "./set-temp-hp-use-case.js";
|
export { setTempHpUseCase } from "./set-temp-hp-use-case.js";
|
||||||
export { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js";
|
export { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js";
|
||||||
export { toggleConditionUseCase } from "./toggle-condition-use-case.js";
|
export { toggleConditionUseCase } from "./toggle-condition-use-case.js";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
Creature,
|
AnyCreature,
|
||||||
CreatureId,
|
CreatureId,
|
||||||
Encounter,
|
Encounter,
|
||||||
PlayerCharacter,
|
PlayerCharacter,
|
||||||
@@ -12,7 +12,7 @@ export interface EncounterStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BestiarySourceCache {
|
export interface BestiarySourceCache {
|
||||||
getCreature(creatureId: CreatureId): Creature | undefined;
|
getCreature(creatureId: CreatureId): AnyCreature | undefined;
|
||||||
isSourceCached(sourceCode: string): boolean;
|
isSourceCached(sourceCode: string): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
type Creature,
|
type AnyCreature,
|
||||||
type CreatureId,
|
type CreatureId,
|
||||||
calculateInitiative,
|
|
||||||
type DomainError,
|
type DomainError,
|
||||||
type DomainEvent,
|
type DomainEvent,
|
||||||
isDomainError,
|
isDomainError,
|
||||||
@@ -10,6 +9,7 @@ import {
|
|||||||
selectRoll,
|
selectRoll,
|
||||||
setInitiative,
|
setInitiative,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
|
import { creatureInitiativeModifier } from "./creature-initiative-modifier.js";
|
||||||
import type { EncounterStore } from "./ports.js";
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
|
||||||
export interface RollAllResult {
|
export interface RollAllResult {
|
||||||
@@ -20,7 +20,7 @@ export interface RollAllResult {
|
|||||||
export function rollAllInitiativeUseCase(
|
export function rollAllInitiativeUseCase(
|
||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
rollDice: () => number,
|
rollDice: () => number,
|
||||||
getCreature: (id: CreatureId) => Creature | undefined,
|
getCreature: (id: CreatureId) => AnyCreature | undefined,
|
||||||
mode: RollMode = "normal",
|
mode: RollMode = "normal",
|
||||||
): RollAllResult | DomainError {
|
): RollAllResult | DomainError {
|
||||||
let encounter = store.get();
|
let encounter = store.get();
|
||||||
@@ -37,11 +37,7 @@ export function rollAllInitiativeUseCase(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { modifier } = calculateInitiative({
|
const modifier = creatureInitiativeModifier(creature);
|
||||||
dexScore: creature.abilities.dex,
|
|
||||||
cr: creature.cr,
|
|
||||||
initiativeProficiency: creature.initiativeProficiency,
|
|
||||||
});
|
|
||||||
const roll1 = rollDice();
|
const roll1 = rollDice();
|
||||||
const effectiveRoll =
|
const effectiveRoll =
|
||||||
mode === "normal" ? roll1 : selectRoll(roll1, rollDice(), mode);
|
mode === "normal" ? roll1 : selectRoll(roll1, rollDice(), mode);
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
|
type AnyCreature,
|
||||||
type CombatantId,
|
type CombatantId,
|
||||||
type Creature,
|
|
||||||
type CreatureId,
|
type CreatureId,
|
||||||
calculateInitiative,
|
|
||||||
type DomainError,
|
type DomainError,
|
||||||
type DomainEvent,
|
type DomainEvent,
|
||||||
isDomainError,
|
isDomainError,
|
||||||
@@ -11,13 +10,14 @@ import {
|
|||||||
selectRoll,
|
selectRoll,
|
||||||
setInitiative,
|
setInitiative,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
|
import { creatureInitiativeModifier } from "./creature-initiative-modifier.js";
|
||||||
import type { EncounterStore } from "./ports.js";
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
|
||||||
export function rollInitiativeUseCase(
|
export function rollInitiativeUseCase(
|
||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
combatantId: CombatantId,
|
combatantId: CombatantId,
|
||||||
diceRolls: readonly [number, ...number[]],
|
diceRolls: readonly [number, ...number[]],
|
||||||
getCreature: (id: CreatureId) => Creature | undefined,
|
getCreature: (id: CreatureId) => AnyCreature | undefined,
|
||||||
mode: RollMode = "normal",
|
mode: RollMode = "normal",
|
||||||
): DomainEvent[] | DomainError {
|
): DomainEvent[] | DomainError {
|
||||||
const encounter = store.get();
|
const encounter = store.get();
|
||||||
@@ -48,11 +48,7 @@ export function rollInitiativeUseCase(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { modifier } = calculateInitiative({
|
const modifier = creatureInitiativeModifier(creature);
|
||||||
dexScore: creature.abilities.dex,
|
|
||||||
cr: creature.cr,
|
|
||||||
initiativeProficiency: creature.initiativeProficiency,
|
|
||||||
});
|
|
||||||
const effectiveRoll =
|
const effectiveRoll =
|
||||||
mode === "normal"
|
mode === "normal"
|
||||||
? diceRolls[0]
|
? diceRolls[0]
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user