Compare commits
24 Commits
0.9.11
..
c295840b7b
| Author | SHA1 | Date | |
|---|---|---|---|
| c295840b7b | |||
| d13641152f | |||
| 110f4726ae | |||
| 2bc22369ce | |||
| 2971d32f45 | |||
| a97044ec3e | |||
| a77db0eeee | |||
| d8c8a0c44d | |||
| 80dd68752e | |||
| 896fd427ed | |||
| 01b1bba6d6 | |||
| b7a97c3d88 | |||
| 1de00e3d8e | |||
| f4fb69dbc7 | |||
| ef76b9c90b | |||
| 36122b500b | |||
| f4355a8675 | |||
| 209df13c32 | |||
| 4969ed069b | |||
| fba83bebd6 | |||
| f6766b729d | |||
| f10c67a5ba | |||
| 9437272fe0 | |||
| 541e04b732 |
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"threshold": 50,
|
||||||
|
"minInstances": 3,
|
||||||
|
"identifiers": false,
|
||||||
|
"literals": false,
|
||||||
|
"ignore": "dist|__tests__|node_modules",
|
||||||
|
"reporter": "default",
|
||||||
|
"truncate": 100
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm check # Merge gate — must pass before every commit (audit + knip + biome + oxlint + typecheck + test/coverage + jscpd)
|
pnpm check # Merge gate — must pass before every commit (audit + knip + biome + oxlint + typecheck + test/coverage + jscpd + jsinspect)
|
||||||
pnpm oxlint # Type-aware linting (oxlint — complements Biome)
|
pnpm oxlint # Type-aware linting (oxlint — complements Biome)
|
||||||
pnpm knip # Unused code detection (Knip)
|
pnpm knip # Unused code detection (Knip)
|
||||||
pnpm test # Run all tests (Vitest)
|
pnpm test # Run all tests (Vitest)
|
||||||
@@ -30,7 +30,7 @@ apps/web (React 19 + Vite) → packages/application (use cases) → packages
|
|||||||
|
|
||||||
- **Domain** — Pure functions, no I/O, no framework imports. All state transitions are deterministic. Errors returned as values (`DomainError`), never thrown. Adapters may throw only for programmer errors.
|
- **Domain** — Pure functions, no I/O, no framework imports. All state transitions are deterministic. Errors returned as values (`DomainError`), never thrown. Adapters may throw only for programmer errors.
|
||||||
- **Application** — Orchestrates domain calls via port interfaces (`EncounterStore`, `BestiarySourceCache`). No business logic here.
|
- **Application** — Orchestrates domain calls via port interfaces (`EncounterStore`, `BestiarySourceCache`). No business logic here.
|
||||||
- **Web** — React adapter. Implements ports using hooks/state. All UI components, routing, and user interaction live here.
|
- **Web** — React adapter. Implements ports using hooks/state. All UI components and user interaction live here.
|
||||||
|
|
||||||
Layer boundaries are enforced by `scripts/check-layer-boundaries.mjs`, which runs as a Vitest test. Domain and application must never import from React, Vite, or upper layers.
|
Layer boundaries are enforced by `scripts/check-layer-boundaries.mjs`, which runs as a Vitest test. Domain and application must never import from React, Vite, or upper layers.
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ docs/agents/ RPI skill artifacts (research reports, plans)
|
|||||||
- React 19, Vite 6, Tailwind CSS v4
|
- React 19, Vite 6, Tailwind CSS v4
|
||||||
- Lucide React (icons)
|
- Lucide React (icons)
|
||||||
- `idb` (IndexedDB wrapper for bestiary cache)
|
- `idb` (IndexedDB wrapper for bestiary cache)
|
||||||
- Biome 2.4 (formatting + linting), oxlint (type-aware linting), Knip (unused code), jscpd (copy-paste detection)
|
- Biome 2.4 (formatting + linting), oxlint (type-aware linting), Knip (unused code), jscpd (copy-paste detection), jsinspect-plus (structural duplication)
|
||||||
- Vitest (testing, v8 coverage), Lefthook (pre-commit hooks)
|
- Vitest (testing, v8 coverage), Lefthook (pre-commit hooks)
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
@@ -72,8 +72,9 @@ docs/agents/ RPI skill artifacts (research reports, plans)
|
|||||||
- **Domain events** are plain data objects with a `type` discriminant — no classes.
|
- **Domain events** are plain data objects with a `type` discriminant — no classes.
|
||||||
- **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks.
|
- **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks.
|
||||||
- **Feature specs** live in `specs/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`.
|
- **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`). Use React context for shared state; reserve props for per-instance config (data items, layout variants, refs).
|
- **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).
|
||||||
- **Quality gates** are enforced at pre-commit via Lefthook's `pnpm check` — the project's single earliest enforcement point. No gate may exist only as a CI step or manual process.
|
- **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.
|
||||||
|
|
||||||
## Self-Review Checklist
|
## Self-Review Checklist
|
||||||
|
|
||||||
@@ -97,6 +98,8 @@ Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Spec
|
|||||||
- `rpi-plan` — interactive phased implementation plan in `docs/agents/plans/`
|
- `rpi-plan` — interactive phased implementation plan in `docs/agents/plans/`
|
||||||
- `rpi-implement` — execute a plan file phase by phase with automated + manual verification
|
- `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
|
### Choosing the right workflow by scope
|
||||||
|
|
||||||
| Scope | Workflow |
|
| Scope | Workflow |
|
||||||
@@ -114,6 +117,9 @@ Speckit manages **what** to build (specs as living documents). RPI manages **how
|
|||||||
- `specs/003-combatant-state/` — HP, AC, conditions, concentration, initiative
|
- `specs/003-combatant-state/` — HP, AC, conditions, concentration, initiative
|
||||||
- `specs/004-bestiary/` — search index, stat blocks, source management, panel UX
|
- `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/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)
|
## Constitution (key principles)
|
||||||
|
|
||||||
@@ -124,4 +130,3 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work:
|
|||||||
3. **Clarification-First** — Ask before making non-trivial assumptions.
|
3. **Clarification-First** — Ask before making non-trivial assumptions.
|
||||||
4. **MVP Baseline** — Say "MVP baseline does not include X", never permanent bans.
|
4. **MVP Baseline** — Say "MVP baseline does not include X", never permanent bans.
|
||||||
5. **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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Encounter Console
|
# Initiative
|
||||||
|
|
||||||
A local-first initiative tracker and encounter manager for tabletop RPGs (D&D 5e / 2024). Runs entirely in the browser — no server, no account, no data leaves your machine.
|
A local-first initiative tracker and encounter manager for tabletop RPGs (D&D 5e / 2024). Runs entirely in the browser — no server, no account, no data leaves your machine.
|
||||||
|
|
||||||
@@ -7,8 +7,10 @@ A local-first initiative tracker and encounter manager for tabletop RPGs (D&D 5e
|
|||||||
- **Initiative tracking** — add combatants (batch-add from bestiary, custom creatures with optional stats), roll initiative (manual or d20), cycle turns and rounds
|
- **Initiative tracking** — add combatants (batch-add from bestiary, custom creatures with optional stats), roll initiative (manual or d20), cycle turns and rounds
|
||||||
- **Encounter state** — HP, AC, conditions, concentration tracking with visual status indicators
|
- **Encounter state** — HP, AC, conditions, concentration tracking with visual status indicators
|
||||||
- **Bestiary integration** — import bestiary JSON sources, search creatures, and view full stat blocks
|
- **Bestiary integration** — import bestiary JSON sources, search creatures, and view full stat blocks
|
||||||
- **Player characters** — create reusable player character templates with name, AC, HP, color, and icon; search and add them to encounters with pre-filled stats; manage (edit/delete) from a dedicated panel
|
- **Player characters** — create reusable player character templates with name, AC, HP, level, color, and icon; search and add them to encounters with pre-filled stats; manage (edit/delete) from a dedicated panel
|
||||||
|
- **Encounter difficulty** — live 3-bar indicator in the top bar showing encounter difficulty (Trivial/Low/Moderate/High) based on the 2024 5.5e XP budget system; automatically derived from PC levels and bestiary creature CRs
|
||||||
- **Undo/redo** — reverse any encounter action with Undo/Redo buttons or keyboard shortcuts (Ctrl+Z / Ctrl+Shift+Z, Cmd on Mac); history persists across page reloads
|
- **Undo/redo** — reverse any encounter action with Undo/Redo buttons or keyboard shortcuts (Ctrl+Z / Ctrl+Shift+Z, Cmd on Mac); history persists across page reloads
|
||||||
|
- **Import/export** — export the full encounter state (combatants, undo/redo history, player characters) as a JSON file or copy to clipboard; import from file upload or pasted JSON with validation and confirmation
|
||||||
- **Persistent** — encounters survive page reloads via localStorage; bestiary data cached in IndexedDB; player characters stored independently
|
- **Persistent** — encounters survive page reloads via localStorage; bestiary data cached in IndexedDB; player characters stored independently
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
@@ -32,17 +34,43 @@ Open `http://localhost:5173`.
|
|||||||
| `pnpm --filter web dev` | Start the dev server |
|
| `pnpm --filter web dev` | Start the dev server |
|
||||||
| `pnpm --filter web build` | Production build |
|
| `pnpm --filter web build` | Production build |
|
||||||
| `pnpm test` | Run all tests (Vitest) |
|
| `pnpm test` | Run all tests (Vitest) |
|
||||||
| `pnpm check` | Full merge gate (knip, biome, typecheck, test, jscpd) |
|
| `pnpm test:watch` | Tests in watch mode |
|
||||||
|
| `pnpm vitest run path/to/test.ts` | Run a single test file |
|
||||||
|
| `pnpm typecheck` | TypeScript type checking |
|
||||||
|
| `pnpm lint` | Biome lint |
|
||||||
|
| `pnpm format` | Biome format (writes changes) |
|
||||||
|
| `pnpm check` | Full merge gate (see below) |
|
||||||
|
|
||||||
|
### Merge gate (`pnpm check`)
|
||||||
|
|
||||||
|
All of these run at pre-commit via Lefthook (in parallel where possible):
|
||||||
|
|
||||||
|
- `pnpm audit` — security audit
|
||||||
|
- `knip` — unused code detection
|
||||||
|
- `biome check` — formatting + linting
|
||||||
|
- `oxlint` — type-aware linting (complements Biome)
|
||||||
|
- Custom scripts — lint-ignore caps, className enforcement, component prop limits
|
||||||
|
- `tsc --build` — TypeScript strict mode
|
||||||
|
- `vitest run` — tests with per-path coverage thresholds
|
||||||
|
- `jscpd` + `jsinspect` — copy-paste and structural duplication detection
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- TypeScript 5.8 (strict mode), React 19, Vite 6
|
||||||
|
- Tailwind CSS v4 (dark/light theme)
|
||||||
|
- Biome 2.4 (formatting + linting), oxlint (type-aware linting)
|
||||||
|
- Vitest (testing, v8 coverage), Lefthook (pre-commit hooks)
|
||||||
|
- Knip (unused code), jscpd + jsinspect (duplication detection)
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
apps/web/ React 19 + Vite — UI components, hooks, adapters
|
apps/web/ React 19 + Vite — UI components, hooks, adapters
|
||||||
packages/domain/ Pure functions — state transitions, types, validation
|
packages/domain/ Pure functions — state transitions, types, validation
|
||||||
packages/app/ Use cases — orchestrates domain via port interfaces
|
packages/application/ Use cases — orchestrates domain via port interfaces
|
||||||
data/bestiary/ Bestiary index for creature search
|
data/bestiary/ Pre-built bestiary search index (~10k creatures)
|
||||||
scripts/ Build tooling (layer boundary checks, index generation)
|
scripts/ Build tooling (layer checks, index generation)
|
||||||
specs/ Feature specifications (spec → plan → tasks)
|
specs/ Feature specifications (spec → plan → tasks)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -53,5 +81,45 @@ Strict layered architecture with enforced dependency direction:
|
|||||||
apps/web (adapters) → packages/application (use cases) → packages/domain (pure logic)
|
apps/web (adapters) → packages/application (use cases) → packages/domain (pure logic)
|
||||||
```
|
```
|
||||||
|
|
||||||
Domain is pure — no I/O, no randomness, no framework imports. Layer boundaries are enforced by automated import checks that run as part of the test suite. See [CLAUDE.md](./CLAUDE.md) for full conventions.
|
- **Domain** — pure functions, no I/O, no randomness, no framework imports. Errors returned as values (`DomainError`), never thrown.
|
||||||
|
- **Application** — orchestrates domain calls via port interfaces (`EncounterStore`, `PlayerCharacterStore`, etc.). No business logic.
|
||||||
|
- **Web** — React adapter. Implements ports using hooks/state. All UI components, persistence, and external data access live here.
|
||||||
|
|
||||||
|
Layer boundaries are enforced by automated import checks that run as part of the test suite.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
### Workflow
|
||||||
|
|
||||||
|
Development is spec-driven. Feature specs live in `specs/NNN-feature-name/` and are managed through Claude Code skills (see [CLAUDE.md](./CLAUDE.md) for full details).
|
||||||
|
|
||||||
|
| Scope | What to do |
|
||||||
|
|-------|-----------|
|
||||||
|
| Bug fix / CSS tweak | Fix it, run `pnpm check`, commit. Optionally use `/browser-interactive-testing` for visual verification. |
|
||||||
|
| Change to existing feature | Update the feature spec, then implement |
|
||||||
|
| Larger change to existing feature | Update the spec → `/rpi-research` → `/rpi-plan` → `/rpi-implement` |
|
||||||
|
| New feature | `/speckit.specify` → `/speckit.clarify` → `/speckit.plan` → `/speckit.tasks` → `/speckit.implement` |
|
||||||
|
|
||||||
|
Use `/write-issue` to create well-structured Gitea issues, and `/integrate-issue` to pull an existing issue's requirements into the relevant feature spec.
|
||||||
|
|
||||||
|
### Before committing
|
||||||
|
|
||||||
|
Run `pnpm check` — Lefthook runs this automatically at pre-commit, but running it manually first saves time. All checks must pass.
|
||||||
|
|
||||||
|
### Conventions
|
||||||
|
|
||||||
|
- **Biome** for formatting and linting — tab indentation, 80-char lines
|
||||||
|
- **TypeScript strict mode** with `verbatimModuleSyntax` (type-only imports must use `import type`)
|
||||||
|
- **Max 8 props** per component interface — use React context for shared state
|
||||||
|
- **Tests** in `__tests__/` directories — test pure functions directly, use `renderHook` for hooks
|
||||||
|
|
||||||
|
See [CLAUDE.md](./CLAUDE.md) for the full conventions and project constitution.
|
||||||
|
|
||||||
|
## Bestiary Index
|
||||||
|
|
||||||
|
The bestiary search index (`data/bestiary/index.json`) is pre-built and checked into the repo. To regenerate it (e.g., after a new source book release):
|
||||||
|
|
||||||
|
1. Clone [5etools-mirror-3/5etools-src](https://github.com/5etools-mirror-3/5etools-src) locally
|
||||||
|
2. Run `node scripts/generate-bestiary-index.mjs /path/to/5etools-src`
|
||||||
|
|
||||||
|
The script extracts creature names, stats, and source info into a compact search index.
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ export function App() {
|
|||||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||||
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
|
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
|
||||||
|
|
||||||
|
// Close the side panel when the encounter becomes empty
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEmpty) {
|
||||||
|
sidePanel.dismissPanel();
|
||||||
|
}
|
||||||
|
}, [isEmpty, sidePanel.dismissPanel]);
|
||||||
|
|
||||||
// Auto-scroll to active combatant when turn changes
|
// Auto-scroll to active combatant when turn changes
|
||||||
const activeIndex = encounter.activeIndex;
|
const activeIndex = encounter.activeIndex;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import {
|
||||||
|
combatantId,
|
||||||
|
type Encounter,
|
||||||
|
type ExportBundle,
|
||||||
|
type PlayerCharacter,
|
||||||
|
playerCharacterId,
|
||||||
|
type UndoRedoState,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
assembleExportBundle,
|
||||||
|
bundleToJson,
|
||||||
|
resolveFilename,
|
||||||
|
validateImportBundle,
|
||||||
|
} from "../persistence/export-import.js";
|
||||||
|
|
||||||
|
const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
|
||||||
|
const DEFAULT_FILENAME_RE = /^initiative-export-\d{4}-\d{2}-\d{2}\.json$/;
|
||||||
|
|
||||||
|
const encounter: Encounter = {
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Goblin",
|
||||||
|
initiative: 15,
|
||||||
|
maxHp: 7,
|
||||||
|
currentHp: 7,
|
||||||
|
ac: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Aria",
|
||||||
|
initiative: 18,
|
||||||
|
maxHp: 45,
|
||||||
|
currentHp: 40,
|
||||||
|
ac: 16,
|
||||||
|
color: "blue",
|
||||||
|
icon: "sword",
|
||||||
|
playerCharacterId: playerCharacterId("pc-1"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const undoRedoState: UndoRedoState = {
|
||||||
|
undoStack: [
|
||||||
|
{
|
||||||
|
combatants: [{ id: combatantId("c-1"), name: "Goblin", initiative: 15 }],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
redoStack: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const playerCharacters: PlayerCharacter[] = [
|
||||||
|
{
|
||||||
|
id: playerCharacterId("pc-1"),
|
||||||
|
name: "Aria",
|
||||||
|
ac: 16,
|
||||||
|
maxHp: 45,
|
||||||
|
color: "blue",
|
||||||
|
icon: "sword",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("assembleExportBundle", () => {
|
||||||
|
it("returns a bundle with version 1", () => {
|
||||||
|
const bundle = assembleExportBundle(
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
playerCharacters,
|
||||||
|
);
|
||||||
|
expect(bundle.version).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes an ISO timestamp", () => {
|
||||||
|
const bundle = assembleExportBundle(
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
playerCharacters,
|
||||||
|
);
|
||||||
|
expect(bundle.exportedAt).toMatch(ISO_TIMESTAMP_RE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes the encounter", () => {
|
||||||
|
const bundle = assembleExportBundle(
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
playerCharacters,
|
||||||
|
);
|
||||||
|
expect(bundle.encounter).toEqual(encounter);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes undo and redo stacks", () => {
|
||||||
|
const bundle = assembleExportBundle(
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
playerCharacters,
|
||||||
|
);
|
||||||
|
expect(bundle.undoStack).toEqual(undoRedoState.undoStack);
|
||||||
|
expect(bundle.redoStack).toEqual(undoRedoState.redoStack);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes player characters", () => {
|
||||||
|
const bundle = assembleExportBundle(
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
playerCharacters,
|
||||||
|
);
|
||||||
|
expect(bundle.playerCharacters).toEqual(playerCharacters);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("assembleExportBundle with includeHistory", () => {
|
||||||
|
it("excludes undo/redo stacks when includeHistory is false", () => {
|
||||||
|
const bundle = assembleExportBundle(
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
playerCharacters,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(bundle.undoStack).toHaveLength(0);
|
||||||
|
expect(bundle.redoStack).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes undo/redo stacks when includeHistory is true", () => {
|
||||||
|
const bundle = assembleExportBundle(
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
playerCharacters,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(bundle.undoStack).toEqual(undoRedoState.undoStack);
|
||||||
|
expect(bundle.redoStack).toEqual(undoRedoState.redoStack);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes undo/redo stacks by default", () => {
|
||||||
|
const bundle = assembleExportBundle(
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
playerCharacters,
|
||||||
|
);
|
||||||
|
expect(bundle.undoStack).toEqual(undoRedoState.undoStack);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bundleToJson", () => {
|
||||||
|
it("produces valid JSON that round-trips through validateImportBundle", () => {
|
||||||
|
const bundle = assembleExportBundle(
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
playerCharacters,
|
||||||
|
);
|
||||||
|
const json = bundleToJson(bundle);
|
||||||
|
const parsed: unknown = JSON.parse(json);
|
||||||
|
const result = validateImportBundle(parsed);
|
||||||
|
expect(typeof result).toBe("object");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveFilename", () => {
|
||||||
|
it("uses date-based default when no name provided", () => {
|
||||||
|
const result = resolveFilename();
|
||||||
|
expect(result).toMatch(DEFAULT_FILENAME_RE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses date-based default for empty string", () => {
|
||||||
|
const result = resolveFilename("");
|
||||||
|
expect(result).toMatch(DEFAULT_FILENAME_RE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses date-based default for whitespace-only string", () => {
|
||||||
|
const result = resolveFilename(" ");
|
||||||
|
expect(result).toMatch(DEFAULT_FILENAME_RE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends .json to a custom name", () => {
|
||||||
|
expect(resolveFilename("my-encounter")).toBe("my-encounter.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not double-append .json", () => {
|
||||||
|
expect(resolveFilename("my-encounter.json")).toBe("my-encounter.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims whitespace from custom name", () => {
|
||||||
|
expect(resolveFilename(" my-encounter ")).toBe("my-encounter.json");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("round-trip: export then import", () => {
|
||||||
|
it("produces identical state after round-trip", () => {
|
||||||
|
const bundle = assembleExportBundle(
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
playerCharacters,
|
||||||
|
);
|
||||||
|
|
||||||
|
const serialized = JSON.parse(JSON.stringify(bundle));
|
||||||
|
const result = validateImportBundle(serialized);
|
||||||
|
|
||||||
|
expect(typeof result).toBe("object");
|
||||||
|
const imported = result as ExportBundle;
|
||||||
|
expect(imported.version).toBe(bundle.version);
|
||||||
|
expect(imported.encounter).toEqual(bundle.encounter);
|
||||||
|
expect(imported.undoStack).toEqual(bundle.undoStack);
|
||||||
|
expect(imported.redoStack).toEqual(bundle.redoStack);
|
||||||
|
expect(imported.playerCharacters).toEqual(bundle.playerCharacters);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips an empty encounter", () => {
|
||||||
|
const emptyEncounter: Encounter = {
|
||||||
|
combatants: [],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
};
|
||||||
|
const emptyUndoRedo: UndoRedoState = {
|
||||||
|
undoStack: [],
|
||||||
|
redoStack: [],
|
||||||
|
};
|
||||||
|
const bundle = assembleExportBundle(emptyEncounter, 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).toHaveLength(0);
|
||||||
|
expect(imported.undoStack).toHaveLength(0);
|
||||||
|
expect(imported.redoStack).toHaveLength(0);
|
||||||
|
expect(imported.playerCharacters).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import type { ExportBundle } from "@initiative/domain";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { validateImportBundle } from "../persistence/export-import.js";
|
||||||
|
|
||||||
|
function validBundle(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
exportedAt: "2026-03-27T12:00:00.000Z",
|
||||||
|
encounter: {
|
||||||
|
combatants: [{ id: "c-1", name: "Goblin", initiative: 15 }],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
},
|
||||||
|
undoStack: [],
|
||||||
|
redoStack: [],
|
||||||
|
playerCharacters: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("validateImportBundle", () => {
|
||||||
|
it("accepts a valid bundle", () => {
|
||||||
|
const result = validateImportBundle(validBundle());
|
||||||
|
expect(typeof result).toBe("object");
|
||||||
|
const bundle = result as ExportBundle;
|
||||||
|
expect(bundle.version).toBe(1);
|
||||||
|
expect(bundle.encounter.combatants).toHaveLength(1);
|
||||||
|
expect(bundle.encounter.combatants[0].name).toBe("Goblin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a valid bundle with empty encounter", () => {
|
||||||
|
const input = {
|
||||||
|
...validBundle(),
|
||||||
|
encounter: { combatants: [], activeIndex: 0, roundNumber: 1 },
|
||||||
|
};
|
||||||
|
const result = validateImportBundle(input);
|
||||||
|
expect(typeof result).toBe("object");
|
||||||
|
const bundle = result as ExportBundle;
|
||||||
|
expect(bundle.encounter.combatants).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a bundle with undo/redo stacks", () => {
|
||||||
|
const enc = {
|
||||||
|
combatants: [{ id: "c-1", name: "Orc" }],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
};
|
||||||
|
const input = {
|
||||||
|
...validBundle(),
|
||||||
|
undoStack: [enc],
|
||||||
|
redoStack: [enc],
|
||||||
|
};
|
||||||
|
const result = validateImportBundle(input);
|
||||||
|
expect(typeof result).toBe("object");
|
||||||
|
const bundle = result as ExportBundle;
|
||||||
|
expect(bundle.undoStack).toHaveLength(1);
|
||||||
|
expect(bundle.redoStack).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a bundle with player characters", () => {
|
||||||
|
const input = {
|
||||||
|
...validBundle(),
|
||||||
|
playerCharacters: [
|
||||||
|
{
|
||||||
|
id: "pc-1",
|
||||||
|
name: "Aria",
|
||||||
|
ac: 16,
|
||||||
|
maxHp: 45,
|
||||||
|
color: "blue",
|
||||||
|
icon: "sword",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const result = validateImportBundle(input);
|
||||||
|
expect(typeof result).toBe("object");
|
||||||
|
const bundle = result as ExportBundle;
|
||||||
|
expect(bundle.playerCharacters).toHaveLength(1);
|
||||||
|
expect(bundle.playerCharacters[0].name).toBe("Aria");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-object input", () => {
|
||||||
|
expect(validateImportBundle(null)).toBe("Invalid file format");
|
||||||
|
expect(validateImportBundle(42)).toBe("Invalid file format");
|
||||||
|
expect(validateImportBundle("string")).toBe("Invalid file format");
|
||||||
|
expect(validateImportBundle([])).toBe("Invalid file format");
|
||||||
|
expect(validateImportBundle(undefined)).toBe("Invalid file format");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing version field", () => {
|
||||||
|
const input = validBundle();
|
||||||
|
delete input.version;
|
||||||
|
expect(validateImportBundle(input)).toBe("Invalid file format");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects version 0 or negative", () => {
|
||||||
|
expect(validateImportBundle({ ...validBundle(), version: 0 })).toBe(
|
||||||
|
"Invalid file format",
|
||||||
|
);
|
||||||
|
expect(validateImportBundle({ ...validBundle(), version: -1 })).toBe(
|
||||||
|
"Invalid file format",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown version", () => {
|
||||||
|
expect(validateImportBundle({ ...validBundle(), version: 99 })).toBe(
|
||||||
|
"Invalid file format",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing encounter field", () => {
|
||||||
|
const input = validBundle();
|
||||||
|
delete input.encounter;
|
||||||
|
expect(validateImportBundle(input)).toBe("Invalid encounter data");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid encounter data", () => {
|
||||||
|
expect(
|
||||||
|
validateImportBundle({ ...validBundle(), encounter: "not an object" }),
|
||||||
|
).toBe("Invalid encounter data");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing undoStack", () => {
|
||||||
|
const input = validBundle();
|
||||||
|
delete input.undoStack;
|
||||||
|
expect(validateImportBundle(input)).toBe("Invalid file format");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing redoStack", () => {
|
||||||
|
const input = validBundle();
|
||||||
|
delete input.redoStack;
|
||||||
|
expect(validateImportBundle(input)).toBe("Invalid file format");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing playerCharacters", () => {
|
||||||
|
const input = validBundle();
|
||||||
|
delete input.playerCharacters;
|
||||||
|
expect(validateImportBundle(input)).toBe("Invalid file format");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-string exportedAt", () => {
|
||||||
|
expect(validateImportBundle({ ...validBundle(), exportedAt: 12345 })).toBe(
|
||||||
|
"Invalid file format",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops invalid entries from undo stack", () => {
|
||||||
|
const valid = {
|
||||||
|
combatants: [{ id: "c-1", name: "Orc" }],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
};
|
||||||
|
const input = {
|
||||||
|
...validBundle(),
|
||||||
|
undoStack: [valid, "invalid", { bad: true }, valid],
|
||||||
|
};
|
||||||
|
const result = validateImportBundle(input);
|
||||||
|
expect(typeof result).toBe("object");
|
||||||
|
const bundle = result as ExportBundle;
|
||||||
|
expect(bundle.undoStack).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops invalid player characters", () => {
|
||||||
|
const input = {
|
||||||
|
...validBundle(),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: "pc-1", name: "Valid", ac: 10, maxHp: 20 },
|
||||||
|
{ id: "", name: "Bad ID" },
|
||||||
|
"not an object",
|
||||||
|
{ id: "pc-3", name: "Also Valid", ac: 15, maxHp: 30 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const result = validateImportBundle(input);
|
||||||
|
expect(typeof result).toBe("object");
|
||||||
|
const bundle = result as ExportBundle;
|
||||||
|
expect(bundle.playerCharacters).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects JSON array instead of object", () => {
|
||||||
|
expect(validateImportBundle([1, 2, 3])).toBe("Invalid file format");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects encounter that fails rehydration (missing combatant fields)", () => {
|
||||||
|
const input = {
|
||||||
|
...validBundle(),
|
||||||
|
encounter: {
|
||||||
|
combatants: [{ noId: true }],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(validateImportBundle(input)).toBe("Invalid encounter data");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips invalid color/icon from player characters but keeps the character", () => {
|
||||||
|
const input = {
|
||||||
|
...validBundle(),
|
||||||
|
playerCharacters: [
|
||||||
|
{
|
||||||
|
id: "pc-1",
|
||||||
|
name: "Test",
|
||||||
|
ac: 10,
|
||||||
|
maxHp: 20,
|
||||||
|
color: "neon-pink",
|
||||||
|
icon: "bazooka",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const result = validateImportBundle(input);
|
||||||
|
// rehydrateCharacter rejects characters with invalid color/icon members
|
||||||
|
// that are not in the valid sets, so this character is dropped
|
||||||
|
expect(typeof result).toBe("object");
|
||||||
|
const bundle = result as ExportBundle;
|
||||||
|
expect(bundle.playerCharacters).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps player characters with valid optional color and icon", () => {
|
||||||
|
const input = {
|
||||||
|
...validBundle(),
|
||||||
|
playerCharacters: [
|
||||||
|
{
|
||||||
|
id: "pc-1",
|
||||||
|
name: "Aria",
|
||||||
|
ac: 16,
|
||||||
|
maxHp: 45,
|
||||||
|
color: "blue",
|
||||||
|
icon: "sword",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const result = validateImportBundle(input);
|
||||||
|
expect(typeof result).toBe("object");
|
||||||
|
const bundle = result as ExportBundle;
|
||||||
|
expect(bundle.playerCharacters).toHaveLength(1);
|
||||||
|
expect(bundle.playerCharacters[0].color).toBe("blue");
|
||||||
|
expect(bundle.playerCharacters[0].icon).toBe("sword");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores unknown extra fields on the bundle", () => {
|
||||||
|
const input = {
|
||||||
|
...validBundle(),
|
||||||
|
unknownField: "should be ignored",
|
||||||
|
anotherExtra: 42,
|
||||||
|
};
|
||||||
|
const result = validateImportBundle(input);
|
||||||
|
expect(typeof result).toBe("object");
|
||||||
|
const bundle = result as ExportBundle;
|
||||||
|
expect(bundle.version).toBe(1);
|
||||||
|
expect("unknownField" in bundle).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -50,6 +50,16 @@ beforeAll(() => {
|
|||||||
dispatchEvent: vi.fn(),
|
dispatchEvent: vi.fn(),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
HTMLDialogElement.prototype.showModal =
|
||||||
|
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);
|
||||||
@@ -118,4 +128,61 @@ describe("ActionBar", () => {
|
|||||||
screen.getByRole("button", { name: "More actions" }),
|
screen.getByRole("button", { name: "More actions" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens export method dialog via overflow menu", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBar();
|
||||||
|
await user.click(screen.getByRole("button", { name: "More actions" }));
|
||||||
|
// Click the menu item
|
||||||
|
const items = screen.getAllByText("Export Encounter");
|
||||||
|
await user.click(items[0]);
|
||||||
|
// Dialog should now be open — it renders a second "Export Encounter" as heading
|
||||||
|
expect(
|
||||||
|
screen.getAllByText("Export Encounter").length,
|
||||||
|
).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens import method dialog via overflow menu", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBar();
|
||||||
|
await user.click(screen.getByRole("button", { name: "More actions" }));
|
||||||
|
const items = screen.getAllByText("Import Encounter");
|
||||||
|
await user.click(items[0]);
|
||||||
|
expect(
|
||||||
|
screen.getAllByText("Import Encounter").length,
|
||||||
|
).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onManagePlayers from overflow menu", async () => {
|
||||||
|
const onManagePlayers = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBar({ onManagePlayers });
|
||||||
|
await user.click(screen.getByRole("button", { name: "More actions" }));
|
||||||
|
await user.click(screen.getByText("Player Characters"));
|
||||||
|
expect(onManagePlayers).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,87 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import type { ConditionId } 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";
|
||||||
|
import { ConditionTags } from "../condition-tags.js";
|
||||||
|
|
||||||
|
vi.mock("../../contexts/rules-edition-context.js", () => ({
|
||||||
|
useRulesEditionContext: () => ({ edition: "5.5e" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("ConditionTags", () => {
|
||||||
|
it("renders nothing when conditions is undefined", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ConditionTags
|
||||||
|
conditions={undefined}
|
||||||
|
onRemove={() => {}}
|
||||||
|
onOpenPicker={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// Only the add button should be present
|
||||||
|
expect(container.querySelectorAll("button")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a button per condition", () => {
|
||||||
|
const conditions: ConditionId[] = ["blinded", "prone"];
|
||||||
|
render(
|
||||||
|
<ConditionTags
|
||||||
|
conditions={conditions}
|
||||||
|
onRemove={() => {}}
|
||||||
|
onOpenPicker={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Remove Blinded" }),
|
||||||
|
).toBeDefined();
|
||||||
|
expect(screen.getByRole("button", { name: "Remove Prone" })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onRemove with condition id when clicked", async () => {
|
||||||
|
const onRemove = vi.fn();
|
||||||
|
render(
|
||||||
|
<ConditionTags
|
||||||
|
conditions={["blinded"] as ConditionId[]}
|
||||||
|
onRemove={onRemove}
|
||||||
|
onOpenPicker={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Remove Blinded" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onRemove).toHaveBeenCalledWith("blinded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onOpenPicker when add button is clicked", async () => {
|
||||||
|
const onOpenPicker = vi.fn();
|
||||||
|
render(
|
||||||
|
<ConditionTags
|
||||||
|
conditions={[]}
|
||||||
|
onRemove={() => {}}
|
||||||
|
onOpenPicker={onOpenPicker}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Add condition" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onOpenPicker).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders empty conditions array without errors", () => {
|
||||||
|
render(
|
||||||
|
<ConditionTags
|
||||||
|
conditions={[]}
|
||||||
|
onRemove={() => {}}
|
||||||
|
onOpenPicker={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// Only add button
|
||||||
|
expect(screen.getByRole("button", { name: "Add condition" })).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import type { PlayerCharacter } from "@initiative/domain";
|
||||||
|
import { 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 { CreatePlayerModal } from "../create-player-modal.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
HTMLDialogElement.prototype.showModal =
|
||||||
|
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);
|
||||||
|
|
||||||
|
function renderModal(
|
||||||
|
overrides: Partial<Parameters<typeof CreatePlayerModal>[0]> = {},
|
||||||
|
) {
|
||||||
|
const defaults = {
|
||||||
|
open: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onSave: vi.fn(),
|
||||||
|
};
|
||||||
|
const props = { ...defaults, ...overrides };
|
||||||
|
return { ...render(<CreatePlayerModal {...props} />), ...props };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CreatePlayerModal", () => {
|
||||||
|
it("renders create form with defaults", () => {
|
||||||
|
renderModal();
|
||||||
|
expect(screen.getByText("Create Player")).toBeDefined();
|
||||||
|
expect(screen.getByLabelText("Name")).toBeDefined();
|
||||||
|
expect(screen.getByLabelText("AC")).toBeDefined();
|
||||||
|
expect(screen.getByLabelText("Max HP")).toBeDefined();
|
||||||
|
expect(screen.getByLabelText("Level")).toBeDefined();
|
||||||
|
expect(screen.getByRole("button", { name: "Create" })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders edit form when playerCharacter is provided", () => {
|
||||||
|
const pc: PlayerCharacter = {
|
||||||
|
id: playerCharacterId("pc-1"),
|
||||||
|
name: "Gandalf",
|
||||||
|
ac: 15,
|
||||||
|
maxHp: 40,
|
||||||
|
color: "blue",
|
||||||
|
icon: "wand",
|
||||||
|
level: 10,
|
||||||
|
};
|
||||||
|
renderModal({ playerCharacter: pc });
|
||||||
|
expect(screen.getByText("Edit Player")).toBeDefined();
|
||||||
|
expect(screen.getByLabelText("Name")).toHaveProperty("value", "Gandalf");
|
||||||
|
expect(screen.getByLabelText("AC")).toHaveProperty("value", "15");
|
||||||
|
expect(screen.getByLabelText("Max HP")).toHaveProperty("value", "40");
|
||||||
|
expect(screen.getByLabelText("Level")).toHaveProperty("value", "10");
|
||||||
|
expect(screen.getByRole("button", { name: "Save" })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onSave with valid data", async () => {
|
||||||
|
const { onSave, onClose } = renderModal();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText("Name"), "Aria");
|
||||||
|
await user.clear(screen.getByLabelText("AC"));
|
||||||
|
await user.type(screen.getByLabelText("AC"), "16");
|
||||||
|
await user.clear(screen.getByLabelText("Max HP"));
|
||||||
|
await user.type(screen.getByLabelText("Max HP"), "30");
|
||||||
|
await user.type(screen.getByLabelText("Level"), "5");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
"Aria",
|
||||||
|
16,
|
||||||
|
30,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error for empty name", async () => {
|
||||||
|
const { onSave } = renderModal();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("Name is required")).toBeDefined();
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error for invalid AC", async () => {
|
||||||
|
const { onSave } = renderModal();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText("Name"), "Test");
|
||||||
|
await user.clear(screen.getByLabelText("AC"));
|
||||||
|
await user.type(screen.getByLabelText("AC"), "abc");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("AC must be a non-negative number")).toBeDefined();
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error for invalid Max HP", async () => {
|
||||||
|
const { onSave } = renderModal();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText("Name"), "Test");
|
||||||
|
await user.clear(screen.getByLabelText("Max HP"));
|
||||||
|
await user.type(screen.getByLabelText("Max HP"), "0");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("Max HP must be at least 1")).toBeDefined();
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error for invalid level", async () => {
|
||||||
|
const { onSave } = renderModal();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText("Name"), "Test");
|
||||||
|
await user.type(screen.getByLabelText("Level"), "25");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("Level must be between 1 and 20")).toBeDefined();
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears error when name is edited", async () => {
|
||||||
|
renderModal();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||||
|
expect(screen.getByText("Name is required")).toBeDefined();
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText("Name"), "A");
|
||||||
|
expect(screen.queryByText("Name is required")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClose when cancel is clicked", async () => {
|
||||||
|
const { onClose } = renderModal();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits level when field is empty", async () => {
|
||||||
|
const { onSave } = renderModal();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText("Name"), "Aria");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
"Aria",
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
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 { Dialog, DialogHeader } from "../ui/dialog.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
HTMLDialogElement.prototype.showModal =
|
||||||
|
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);
|
||||||
|
|
||||||
|
describe("Dialog", () => {
|
||||||
|
it("opens when open=true", () => {
|
||||||
|
render(
|
||||||
|
<Dialog open={true} onClose={() => {}}>
|
||||||
|
Content
|
||||||
|
</Dialog>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Content")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes when open changes from true to false", () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<Dialog open={true} onClose={() => {}}>
|
||||||
|
Content
|
||||||
|
</Dialog>,
|
||||||
|
);
|
||||||
|
const dialog = document.querySelector("dialog");
|
||||||
|
expect(dialog?.hasAttribute("open")).toBe(true);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<Dialog open={false} onClose={() => {}}>
|
||||||
|
Content
|
||||||
|
</Dialog>,
|
||||||
|
);
|
||||||
|
expect(dialog?.hasAttribute("open")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClose on cancel event", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<Dialog open={true} onClose={onClose}>
|
||||||
|
Content
|
||||||
|
</Dialog>,
|
||||||
|
);
|
||||||
|
const dialog = document.querySelector("dialog");
|
||||||
|
dialog?.dispatchEvent(new Event("cancel"));
|
||||||
|
expect(onClose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DialogHeader", () => {
|
||||||
|
it("renders title and close button", async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<DialogHeader title="Test Title" onClose={onClose} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Test Title")).toBeDefined();
|
||||||
|
await userEvent.click(screen.getByRole("button"));
|
||||||
|
expect(onClose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import type { DifficultyResult } from "@initiative/domain";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { DifficultyIndicator } from "../difficulty-indicator.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function makeResult(tier: DifficultyResult["tier"]): DifficultyResult {
|
||||||
|
return {
|
||||||
|
tier,
|
||||||
|
totalMonsterXp: 100,
|
||||||
|
partyBudget: { low: 50, moderate: 100, high: 200 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DifficultyIndicator", () => {
|
||||||
|
it("renders 3 bars", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<DifficultyIndicator result={makeResult("moderate")} />,
|
||||||
|
);
|
||||||
|
const bars = container.querySelectorAll("[class*='rounded-sm']");
|
||||||
|
expect(bars).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'Trivial encounter difficulty' label for trivial tier", () => {
|
||||||
|
render(<DifficultyIndicator result={makeResult("trivial")} />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("img", {
|
||||||
|
name: "Trivial encounter difficulty",
|
||||||
|
}),
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'Low encounter difficulty' label for low tier", () => {
|
||||||
|
render(<DifficultyIndicator result={makeResult("low")} />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("img", { name: "Low encounter difficulty" }),
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'Moderate encounter difficulty' label for moderate tier", () => {
|
||||||
|
render(<DifficultyIndicator result={makeResult("moderate")} />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("img", {
|
||||||
|
name: "Moderate encounter difficulty",
|
||||||
|
}),
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'High encounter difficulty' label for high tier", () => {
|
||||||
|
render(<DifficultyIndicator result={makeResult("high")} />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("img", {
|
||||||
|
name: "High encounter difficulty",
|
||||||
|
}),
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { userEvent } from "@testing-library/user-event";
|
||||||
|
import { Circle } from "lucide-react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { OverflowMenu } from "../ui/overflow-menu.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ icon: <Circle />, label: "Action A", onClick: vi.fn() },
|
||||||
|
{ icon: <Circle />, label: "Action B", onClick: vi.fn() },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("OverflowMenu", () => {
|
||||||
|
it("renders toggle button", () => {
|
||||||
|
render(<OverflowMenu items={items} />);
|
||||||
|
expect(screen.getByRole("button", { name: "More actions" })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show menu items when closed", () => {
|
||||||
|
render(<OverflowMenu items={items} />);
|
||||||
|
expect(screen.queryByText("Action A")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows menu items when toggled open", async () => {
|
||||||
|
render(<OverflowMenu items={items} />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "More actions" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("Action A")).toBeDefined();
|
||||||
|
expect(screen.getByText("Action B")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes menu after clicking an item", async () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<OverflowMenu items={[{ icon: <Circle />, label: "Do it", onClick }]} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "More actions" }));
|
||||||
|
await userEvent.click(screen.getByText("Do it"));
|
||||||
|
|
||||||
|
expect(onClick).toHaveBeenCalledOnce();
|
||||||
|
expect(screen.queryByText("Do it")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps menu open when keepOpen is true", async () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<OverflowMenu
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
icon: <Circle />,
|
||||||
|
label: "Stay",
|
||||||
|
onClick,
|
||||||
|
keepOpen: true,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "More actions" }));
|
||||||
|
await userEvent.click(screen.getByText("Stay"));
|
||||||
|
|
||||||
|
expect(onClick).toHaveBeenCalledOnce();
|
||||||
|
expect(screen.getByText("Stay")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables items when disabled is true", async () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<OverflowMenu
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
icon: <Circle />,
|
||||||
|
label: "Nope",
|
||||||
|
onClick,
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "More actions" }));
|
||||||
|
const item = screen.getByText("Nope");
|
||||||
|
expect(item.closest("button")?.hasAttribute("disabled")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { userEvent } from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { RollModeMenu } from "../roll-mode-menu.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("RollModeMenu", () => {
|
||||||
|
it("renders advantage and disadvantage buttons", () => {
|
||||||
|
render(
|
||||||
|
<RollModeMenu
|
||||||
|
position={{ x: 100, y: 100 }}
|
||||||
|
onSelect={() => {}}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Advantage")).toBeDefined();
|
||||||
|
expect(screen.getByText("Disadvantage")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onSelect with 'advantage' and onClose when clicked", async () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<RollModeMenu
|
||||||
|
position={{ x: 100, y: 100 }}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText("Advantage"));
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledWith("advantage");
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onSelect with 'disadvantage' and onClose when clicked", async () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<RollModeMenu
|
||||||
|
position={{ x: 100, y: 100 }}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText("Disadvantage"));
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledWith("disadvantage");
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { userEvent } from "@testing-library/user-event";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { Toast } from "../toast.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("Toast", () => {
|
||||||
|
it("renders message text", () => {
|
||||||
|
render(<Toast message="Hello" onDismiss={() => {}} />);
|
||||||
|
expect(screen.getByText("Hello")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders progress bar when progress is provided", () => {
|
||||||
|
render(<Toast message="Loading" progress={0.5} onDismiss={() => {}} />);
|
||||||
|
const bar = document.body.querySelector("[style*='width']") as HTMLElement;
|
||||||
|
expect(bar).not.toBeNull();
|
||||||
|
expect(bar.style.width).toBe("50%");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render progress bar when progress is omitted", () => {
|
||||||
|
render(<Toast message="Done" onDismiss={() => {}} />);
|
||||||
|
const bar = document.body.querySelector("[style*='width']");
|
||||||
|
expect(bar).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onDismiss when close button is clicked", async () => {
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
render(<Toast message="Hi" onDismiss={onDismiss} />);
|
||||||
|
|
||||||
|
const toast = screen.getByText("Hi").closest("div");
|
||||||
|
const button = toast?.querySelector("button");
|
||||||
|
expect(button).not.toBeNull();
|
||||||
|
await userEvent.click(button as HTMLElement);
|
||||||
|
|
||||||
|
expect(onDismiss).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("auto-dismiss", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-dismisses after specified timeout", () => {
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
render(
|
||||||
|
<Toast message="Auto" onDismiss={onDismiss} autoDismissMs={3000} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onDismiss).not.toHaveBeenCalled();
|
||||||
|
vi.advanceTimersByTime(3000);
|
||||||
|
expect(onDismiss).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not auto-dismiss when autoDismissMs is omitted", () => {
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
render(<Toast message="Stay" onDismiss={onDismiss} />);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(10000);
|
||||||
|
expect(onDismiss).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { Tooltip } from "../ui/tooltip.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("Tooltip", () => {
|
||||||
|
it("renders children", () => {
|
||||||
|
render(
|
||||||
|
<Tooltip content="Hint">
|
||||||
|
<button type="button">Hover me</button>
|
||||||
|
</Tooltip>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Hover me")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show tooltip initially", () => {
|
||||||
|
render(
|
||||||
|
<Tooltip content="Hint">
|
||||||
|
<span>Target</span>
|
||||||
|
</Tooltip>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows tooltip on pointer enter and hides on pointer leave", () => {
|
||||||
|
render(
|
||||||
|
<Tooltip content="Hint text">
|
||||||
|
<span>Target</span>
|
||||||
|
</Tooltip>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const wrapper = screen.getByText("Target").closest("span");
|
||||||
|
fireEvent.pointerEnter(wrapper as HTMLElement);
|
||||||
|
expect(screen.getByRole("tooltip")).toBeDefined();
|
||||||
|
expect(screen.getByText("Hint text")).toBeDefined();
|
||||||
|
|
||||||
|
fireEvent.pointerLeave(wrapper as HTMLElement);
|
||||||
|
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,11 +6,19 @@ 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 { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
// Mock the context module
|
// Mock the context modules
|
||||||
vi.mock("../../contexts/encounter-context.js", () => ({
|
vi.mock("../../contexts/encounter-context.js", () => ({
|
||||||
useEncounterContext: vi.fn(),
|
useEncounterContext: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../contexts/player-characters-context.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 { useEncounterContext } from "../../contexts/encounter-context.js";
|
||||||
import { TurnNavigation } from "../turn-navigation.js";
|
import { TurnNavigation } from "../turn-navigation.js";
|
||||||
|
|
||||||
@@ -52,13 +60,19 @@ function mockContext(overrides: Partial<Encounter> = {}) {
|
|||||||
toggleCondition: vi.fn(),
|
toggleCondition: vi.fn(),
|
||||||
toggleConcentration: vi.fn(),
|
toggleConcentration: vi.fn(),
|
||||||
addFromBestiary: vi.fn(),
|
addFromBestiary: vi.fn(),
|
||||||
|
addMultipleFromBestiary: vi.fn(),
|
||||||
addFromPlayerCharacter: vi.fn(),
|
addFromPlayerCharacter: vi.fn(),
|
||||||
makeStore: vi.fn(),
|
makeStore: vi.fn(),
|
||||||
|
withUndo: vi.fn((action: () => unknown) => action()),
|
||||||
undo: vi.fn(),
|
undo: vi.fn(),
|
||||||
redo: vi.fn(),
|
redo: vi.fn(),
|
||||||
canUndo: false,
|
canUndo: false,
|
||||||
canRedo: false,
|
canRedo: false,
|
||||||
|
undoRedoState: { undoStack: [], redoStack: [] },
|
||||||
|
setEncounter: vi.fn(),
|
||||||
|
setUndoRedoState: vi.fn(),
|
||||||
events: [],
|
events: [],
|
||||||
|
lastCreatureId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
mockUseEncounterContext.mockReturnValue(
|
mockUseEncounterContext.mockReturnValue(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { PlayerCharacter } from "@initiative/domain";
|
import type { PlayerCharacter } from "@initiative/domain";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Import,
|
Import,
|
||||||
@@ -8,13 +9,15 @@ import {
|
|||||||
Minus,
|
Minus,
|
||||||
Plus,
|
Plus,
|
||||||
Settings,
|
Settings,
|
||||||
|
Upload,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import React, { type RefObject, useCallback, useState } from "react";
|
import React, { type RefObject, useCallback, useRef, 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,
|
||||||
@@ -23,9 +26,20 @@ import {
|
|||||||
} from "../hooks/use-action-bar-state.js";
|
} from "../hooks/use-action-bar-state.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 { ImportConfirmDialog } from "./import-confirm-prompt.js";
|
||||||
|
import { ImportMethodDialog } from "./import-method-dialog.js";
|
||||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
|
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
|
||||||
import { RollModeMenu } from "./roll-mode-menu.js";
|
import { RollModeMenu } from "./roll-mode-menu.js";
|
||||||
|
import { Toast } from "./toast.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";
|
||||||
import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
|
import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
|
||||||
@@ -345,6 +359,8 @@ function buildOverflowItems(opts: {
|
|||||||
bestiaryLoaded: boolean;
|
bestiaryLoaded: boolean;
|
||||||
onBulkImport?: () => void;
|
onBulkImport?: () => void;
|
||||||
bulkImportDisabled?: boolean;
|
bulkImportDisabled?: boolean;
|
||||||
|
onExportEncounter: () => void;
|
||||||
|
onImportEncounter: () => void;
|
||||||
onOpenSettings?: () => void;
|
onOpenSettings?: () => void;
|
||||||
}): OverflowMenuItem[] {
|
}): OverflowMenuItem[] {
|
||||||
const items: OverflowMenuItem[] = [];
|
const items: OverflowMenuItem[] = [];
|
||||||
@@ -370,6 +386,16 @@ function buildOverflowItems(opts: {
|
|||||||
disabled: opts.bulkImportDisabled,
|
disabled: opts.bulkImportDisabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
items.push({
|
||||||
|
icon: <Download className="h-4 w-4" />,
|
||||||
|
label: "Export Encounter",
|
||||||
|
onClick: opts.onExportEncounter,
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
icon: <Upload className="h-4 w-4" />,
|
||||||
|
label: "Import Encounter",
|
||||||
|
onClick: opts.onImportEncounter,
|
||||||
|
});
|
||||||
if (opts.onOpenSettings) {
|
if (opts.onOpenSettings) {
|
||||||
items.push({
|
items.push({
|
||||||
icon: <Settings className="h-4 w-4" />,
|
icon: <Settings className="h-4 w-4" />,
|
||||||
@@ -413,6 +439,116 @@ export function ActionBar({
|
|||||||
} = useActionBarState();
|
} = useActionBarState();
|
||||||
|
|
||||||
const { state: bulkImportState } = useBulkImportContext();
|
const { state: bulkImportState } = useBulkImportContext();
|
||||||
|
const {
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
isEmpty: encounterIsEmpty,
|
||||||
|
setEncounter,
|
||||||
|
setUndoRedoState,
|
||||||
|
} = useEncounterContext();
|
||||||
|
const { characters: playerCharacters, replacePlayerCharacters } =
|
||||||
|
usePlayerCharactersContext();
|
||||||
|
|
||||||
|
const importFileRef = useRef<HTMLInputElement>(null);
|
||||||
|
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<
|
||||||
|
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,
|
||||||
@@ -420,6 +556,8 @@ export function ActionBar({
|
|||||||
bestiaryLoaded,
|
bestiaryLoaded,
|
||||||
onBulkImport: showBulkImport,
|
onBulkImport: showBulkImport,
|
||||||
bulkImportDisabled: bulkImportState.status === "loading",
|
bulkImportDisabled: bulkImportState.status === "loading",
|
||||||
|
onExportEncounter: () => setShowExportMethod(true),
|
||||||
|
onImportEncounter: () => setShowImportMethod(true),
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -501,6 +639,37 @@ export function ActionBar({
|
|||||||
<RollAllButton />
|
<RollAllButton />
|
||||||
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
|
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
|
||||||
</form>
|
</form>
|
||||||
|
<input
|
||||||
|
ref={importFileRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleImportFile}
|
||||||
|
/>
|
||||||
|
{!!importError && (
|
||||||
|
<Toast
|
||||||
|
message={importError}
|
||||||
|
onDismiss={() => setImportError(null)}
|
||||||
|
autoDismissMs={5000}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ExportMethodDialog
|
||||||
|
open={showExportMethod}
|
||||||
|
onDownload={handleExportDownload}
|
||||||
|
onCopyToClipboard={handleExportClipboard}
|
||||||
|
onClose={() => setShowExportMethod(false)}
|
||||||
|
/>
|
||||||
|
<ImportMethodDialog
|
||||||
|
open={showImportMethod}
|
||||||
|
onSelectFile={() => importFileRef.current?.click()}
|
||||||
|
onSubmitClipboard={handleImportClipboard}
|
||||||
|
onClose={() => setShowImportMethod(false)}
|
||||||
|
/>
|
||||||
|
<ImportConfirmDialog
|
||||||
|
open={showImportConfirm}
|
||||||
|
onConfirm={handleImportConfirm}
|
||||||
|
onCancel={handleImportCancel}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,66 +3,17 @@ import {
|
|||||||
getConditionDescription,
|
getConditionDescription,
|
||||||
getConditionsForEdition,
|
getConditionsForEdition,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import { useLayoutEffect, useRef, useState } from "react";
|
||||||
import {
|
|
||||||
ArrowDown,
|
|
||||||
Ban,
|
|
||||||
BatteryLow,
|
|
||||||
Droplet,
|
|
||||||
EarOff,
|
|
||||||
EyeOff,
|
|
||||||
Gem,
|
|
||||||
Ghost,
|
|
||||||
Hand,
|
|
||||||
Heart,
|
|
||||||
Link,
|
|
||||||
Moon,
|
|
||||||
ShieldMinus,
|
|
||||||
Siren,
|
|
||||||
Snail,
|
|
||||||
Sparkles,
|
|
||||||
ZapOff,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEffect, 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";
|
||||||
|
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import {
|
||||||
|
CONDITION_COLOR_CLASSES,
|
||||||
|
CONDITION_ICON_MAP,
|
||||||
|
} from "./condition-styles.js";
|
||||||
import { Tooltip } from "./ui/tooltip.js";
|
import { Tooltip } from "./ui/tooltip.js";
|
||||||
|
|
||||||
const ICON_MAP: Record<string, LucideIcon> = {
|
|
||||||
EyeOff,
|
|
||||||
Heart,
|
|
||||||
EarOff,
|
|
||||||
BatteryLow,
|
|
||||||
Siren,
|
|
||||||
Hand,
|
|
||||||
Ban,
|
|
||||||
Ghost,
|
|
||||||
ZapOff,
|
|
||||||
Gem,
|
|
||||||
Droplet,
|
|
||||||
ArrowDown,
|
|
||||||
Link,
|
|
||||||
ShieldMinus,
|
|
||||||
Snail,
|
|
||||||
Sparkles,
|
|
||||||
Moon,
|
|
||||||
};
|
|
||||||
|
|
||||||
const COLOR_CLASSES: Record<string, string> = {
|
|
||||||
neutral: "text-muted-foreground",
|
|
||||||
pink: "text-pink-400",
|
|
||||||
amber: "text-amber-400",
|
|
||||||
orange: "text-orange-400",
|
|
||||||
gray: "text-gray-400",
|
|
||||||
violet: "text-violet-400",
|
|
||||||
yellow: "text-yellow-400",
|
|
||||||
slate: "text-slate-400",
|
|
||||||
green: "text-green-400",
|
|
||||||
indigo: "text-indigo-400",
|
|
||||||
sky: "text-sky-400",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ConditionPickerProps {
|
interface ConditionPickerProps {
|
||||||
anchorRef: React.RefObject<HTMLElement | null>;
|
anchorRef: React.RefObject<HTMLElement | null>;
|
||||||
activeConditions: readonly ConditionId[] | undefined;
|
activeConditions: readonly ConditionId[] | undefined;
|
||||||
@@ -104,15 +55,7 @@ export function ConditionPicker({
|
|||||||
setPos({ top, left: anchorRect.left, maxHeight });
|
setPos({ top, left: anchorRect.left, maxHeight });
|
||||||
}, [anchorRef]);
|
}, [anchorRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useClickOutside(ref, onClose);
|
||||||
function handleClickOutside(e: MouseEvent) {
|
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const { edition } = useRulesEditionContext();
|
const { edition } = useRulesEditionContext();
|
||||||
const conditions = getConditionsForEdition(edition);
|
const conditions = getConditionsForEdition(edition);
|
||||||
@@ -129,10 +72,11 @@ export function ConditionPicker({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{conditions.map((def) => {
|
{conditions.map((def) => {
|
||||||
const Icon = 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 = active.has(def.id);
|
||||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
const colorClass =
|
||||||
|
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
key={def.id}
|
key={def.id}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
ArrowDown,
|
||||||
|
Ban,
|
||||||
|
BatteryLow,
|
||||||
|
Droplet,
|
||||||
|
EarOff,
|
||||||
|
EyeOff,
|
||||||
|
Gem,
|
||||||
|
Ghost,
|
||||||
|
Hand,
|
||||||
|
Heart,
|
||||||
|
Link,
|
||||||
|
Moon,
|
||||||
|
ShieldMinus,
|
||||||
|
Siren,
|
||||||
|
Snail,
|
||||||
|
Sparkles,
|
||||||
|
ZapOff,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
|
||||||
|
EyeOff,
|
||||||
|
Heart,
|
||||||
|
EarOff,
|
||||||
|
BatteryLow,
|
||||||
|
Siren,
|
||||||
|
Hand,
|
||||||
|
Ban,
|
||||||
|
Ghost,
|
||||||
|
ZapOff,
|
||||||
|
Gem,
|
||||||
|
Droplet,
|
||||||
|
ArrowDown,
|
||||||
|
Link,
|
||||||
|
ShieldMinus,
|
||||||
|
Snail,
|
||||||
|
Sparkles,
|
||||||
|
Moon,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CONDITION_COLOR_CLASSES: Record<string, string> = {
|
||||||
|
neutral: "text-muted-foreground",
|
||||||
|
pink: "text-pink-400",
|
||||||
|
amber: "text-amber-400",
|
||||||
|
orange: "text-orange-400",
|
||||||
|
gray: "text-gray-400",
|
||||||
|
violet: "text-violet-400",
|
||||||
|
yellow: "text-yellow-400",
|
||||||
|
slate: "text-slate-400",
|
||||||
|
green: "text-green-400",
|
||||||
|
indigo: "text-indigo-400",
|
||||||
|
sky: "text-sky-400",
|
||||||
|
};
|
||||||
@@ -3,65 +3,15 @@ import {
|
|||||||
type ConditionId,
|
type ConditionId,
|
||||||
getConditionDescription,
|
getConditionDescription,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import {
|
|
||||||
ArrowDown,
|
|
||||||
Ban,
|
|
||||||
BatteryLow,
|
|
||||||
Droplet,
|
|
||||||
EarOff,
|
|
||||||
EyeOff,
|
|
||||||
Gem,
|
|
||||||
Ghost,
|
|
||||||
Hand,
|
|
||||||
Heart,
|
|
||||||
Link,
|
|
||||||
Moon,
|
|
||||||
Plus,
|
|
||||||
ShieldMinus,
|
|
||||||
Siren,
|
|
||||||
Snail,
|
|
||||||
Sparkles,
|
|
||||||
ZapOff,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
import { cn } from "../lib/utils.js";
|
import { cn } from "../lib/utils.js";
|
||||||
|
import {
|
||||||
|
CONDITION_COLOR_CLASSES,
|
||||||
|
CONDITION_ICON_MAP,
|
||||||
|
} from "./condition-styles.js";
|
||||||
import { Tooltip } from "./ui/tooltip.js";
|
import { Tooltip } from "./ui/tooltip.js";
|
||||||
|
|
||||||
const ICON_MAP: Record<string, LucideIcon> = {
|
|
||||||
EyeOff,
|
|
||||||
Heart,
|
|
||||||
EarOff,
|
|
||||||
BatteryLow,
|
|
||||||
Siren,
|
|
||||||
Hand,
|
|
||||||
Ban,
|
|
||||||
Ghost,
|
|
||||||
ZapOff,
|
|
||||||
Gem,
|
|
||||||
Droplet,
|
|
||||||
ArrowDown,
|
|
||||||
Link,
|
|
||||||
ShieldMinus,
|
|
||||||
Snail,
|
|
||||||
Sparkles,
|
|
||||||
Moon,
|
|
||||||
};
|
|
||||||
|
|
||||||
const COLOR_CLASSES: Record<string, string> = {
|
|
||||||
neutral: "text-muted-foreground",
|
|
||||||
pink: "text-pink-400",
|
|
||||||
amber: "text-amber-400",
|
|
||||||
orange: "text-orange-400",
|
|
||||||
gray: "text-gray-400",
|
|
||||||
violet: "text-violet-400",
|
|
||||||
yellow: "text-yellow-400",
|
|
||||||
slate: "text-slate-400",
|
|
||||||
green: "text-green-400",
|
|
||||||
indigo: "text-indigo-400",
|
|
||||||
sky: "text-sky-400",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ConditionTagsProps {
|
interface ConditionTagsProps {
|
||||||
conditions: readonly ConditionId[] | undefined;
|
conditions: readonly ConditionId[] | undefined;
|
||||||
onRemove: (conditionId: ConditionId) => void;
|
onRemove: (conditionId: ConditionId) => void;
|
||||||
@@ -79,9 +29,10 @@ export function ConditionTags({
|
|||||||
{conditions?.map((condId) => {
|
{conditions?.map((condId) => {
|
||||||
const def = CONDITION_DEFINITIONS.find((d) => d.id === condId);
|
const def = CONDITION_DEFINITIONS.find((d) => d.id === condId);
|
||||||
if (!def) return null;
|
if (!def) return null;
|
||||||
const Icon = ICON_MAP[def.iconName];
|
const Icon = CONDITION_ICON_MAP[def.iconName];
|
||||||
if (!Icon) return null;
|
if (!Icon) return null;
|
||||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
const colorClass =
|
||||||
|
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
key={condId}
|
key={condId}
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import type { PlayerCharacter } from "@initiative/domain";
|
import type { PlayerCharacter } from "@initiative/domain";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ColorPalette } from "./color-palette";
|
import { ColorPalette } from "./color-palette";
|
||||||
import { IconGrid } from "./icon-grid";
|
import { IconGrid } from "./icon-grid";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
|
import { Dialog } from "./ui/dialog";
|
||||||
import { Input } from "./ui/input";
|
import { Input } from "./ui/input";
|
||||||
|
|
||||||
|
function parseLevel(value: string): number | undefined | "invalid" {
|
||||||
|
if (value.trim() === "") return undefined;
|
||||||
|
const n = Number.parseInt(value, 10);
|
||||||
|
if (Number.isNaN(n) || n < 1 || n > 20) return "invalid";
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
interface CreatePlayerModalProps {
|
interface CreatePlayerModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -15,6 +23,7 @@ interface CreatePlayerModalProps {
|
|||||||
maxHp: number,
|
maxHp: number,
|
||||||
color: string | undefined,
|
color: string | undefined,
|
||||||
icon: string | undefined,
|
icon: string | undefined,
|
||||||
|
level: number | undefined,
|
||||||
) => void;
|
) => void;
|
||||||
playerCharacter?: PlayerCharacter;
|
playerCharacter?: PlayerCharacter;
|
||||||
}
|
}
|
||||||
@@ -25,12 +34,12 @@ export function CreatePlayerModal({
|
|||||||
onSave,
|
onSave,
|
||||||
playerCharacter,
|
playerCharacter,
|
||||||
}: Readonly<CreatePlayerModalProps>) {
|
}: Readonly<CreatePlayerModalProps>) {
|
||||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [ac, setAc] = useState("10");
|
const [ac, setAc] = useState("10");
|
||||||
const [maxHp, setMaxHp] = useState("10");
|
const [maxHp, setMaxHp] = useState("10");
|
||||||
const [color, setColor] = useState("blue");
|
const [color, setColor] = useState("blue");
|
||||||
const [icon, setIcon] = useState("sword");
|
const [icon, setIcon] = useState("sword");
|
||||||
|
const [level, setLevel] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
const isEdit = !!playerCharacter;
|
const isEdit = !!playerCharacter;
|
||||||
@@ -43,45 +52,23 @@ export function CreatePlayerModal({
|
|||||||
setMaxHp(String(playerCharacter.maxHp));
|
setMaxHp(String(playerCharacter.maxHp));
|
||||||
setColor(playerCharacter.color ?? "");
|
setColor(playerCharacter.color ?? "");
|
||||||
setIcon(playerCharacter.icon ?? "");
|
setIcon(playerCharacter.icon ?? "");
|
||||||
|
setLevel(
|
||||||
|
playerCharacter.level === undefined
|
||||||
|
? ""
|
||||||
|
: String(playerCharacter.level),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setName("");
|
setName("");
|
||||||
setAc("10");
|
setAc("10");
|
||||||
setMaxHp("10");
|
setMaxHp("10");
|
||||||
setColor("");
|
setColor("");
|
||||||
setIcon("");
|
setIcon("");
|
||||||
|
setLevel("");
|
||||||
}
|
}
|
||||||
setError("");
|
setError("");
|
||||||
}
|
}
|
||||||
}, [open, playerCharacter]);
|
}, [open, playerCharacter]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const dialog = dialogRef.current;
|
|
||||||
if (!dialog) return;
|
|
||||||
if (open && !dialog.open) {
|
|
||||||
dialog.showModal();
|
|
||||||
} else if (!open && dialog.open) {
|
|
||||||
dialog.close();
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const dialog = dialogRef.current;
|
|
||||||
if (!dialog) return;
|
|
||||||
function handleCancel(e: Event) {
|
|
||||||
e.preventDefault();
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
function handleBackdropClick(e: MouseEvent) {
|
|
||||||
if (e.target === dialog) onClose();
|
|
||||||
}
|
|
||||||
dialog.addEventListener("cancel", handleCancel);
|
|
||||||
dialog.addEventListener("mousedown", handleBackdropClick);
|
|
||||||
return () => {
|
|
||||||
dialog.removeEventListener("cancel", handleCancel);
|
|
||||||
dialog.removeEventListener("mousedown", handleBackdropClick);
|
|
||||||
};
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
@@ -99,15 +86,24 @@ export function CreatePlayerModal({
|
|||||||
setError("Max HP must be at least 1");
|
setError("Max HP must be at least 1");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSave(trimmed, acNum, hpNum, color || undefined, icon || undefined);
|
const levelNum = parseLevel(level);
|
||||||
|
if (levelNum === "invalid") {
|
||||||
|
setError("Level must be between 1 and 20");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSave(
|
||||||
|
trimmed,
|
||||||
|
acNum,
|
||||||
|
hpNum,
|
||||||
|
color || undefined,
|
||||||
|
icon || undefined,
|
||||||
|
levelNum,
|
||||||
|
);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<dialog
|
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
|
||||||
ref={dialogRef}
|
|
||||||
className="card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
|
|
||||||
>
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="font-semibold text-foreground text-lg">
|
<h2 className="font-semibold text-foreground text-lg">
|
||||||
{isEdit ? "Edit Player" : "Create Player"}
|
{isEdit ? "Edit Player" : "Create Player"}
|
||||||
@@ -166,6 +162,20 @@ export function CreatePlayerModal({
|
|||||||
className="text-center"
|
className="text-center"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="mb-1 block text-muted-foreground text-sm">
|
||||||
|
Level
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={level}
|
||||||
|
onChange={(e) => setLevel(e.target.value)}
|
||||||
|
placeholder="1-20"
|
||||||
|
aria-label="Level"
|
||||||
|
className="text-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -187,6 +197,6 @@ export function CreatePlayerModal({
|
|||||||
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
|
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { DifficultyResult, DifficultyTier } from "@initiative/domain";
|
||||||
|
import { cn } from "../lib/utils.js";
|
||||||
|
|
||||||
|
const TIER_CONFIG: Record<
|
||||||
|
DifficultyTier,
|
||||||
|
{ filledBars: number; color: string; label: string }
|
||||||
|
> = {
|
||||||
|
trivial: { filledBars: 0, color: "", label: "Trivial" },
|
||||||
|
low: { filledBars: 1, color: "bg-green-500", label: "Low" },
|
||||||
|
moderate: { filledBars: 2, color: "bg-yellow-500", label: "Moderate" },
|
||||||
|
high: { filledBars: 3, color: "bg-red-500", label: "High" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const BAR_HEIGHTS = ["h-2", "h-3", "h-4"] as const;
|
||||||
|
|
||||||
|
export function DifficultyIndicator({ result }: { result: DifficultyResult }) {
|
||||||
|
const config = TIER_CONFIG[result.tier];
|
||||||
|
const tooltip = `${config.label} encounter difficulty`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-end gap-0.5"
|
||||||
|
title={tooltip}
|
||||||
|
role="img"
|
||||||
|
aria-label={tooltip}
|
||||||
|
>
|
||||||
|
{BAR_HEIGHTS.map((height, i) => (
|
||||||
|
<div
|
||||||
|
key={height}
|
||||||
|
className={cn(
|
||||||
|
"w-1 rounded-sm",
|
||||||
|
height,
|
||||||
|
i < config.filledBars ? config.color : "bg-muted",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { Check, ClipboardCopy, Download } from "lucide-react";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { Dialog, DialogHeader } from "./ui/dialog.js";
|
||||||
|
import { Input } from "./ui/input.js";
|
||||||
|
|
||||||
|
interface ExportMethodDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onDownload: (includeHistory: boolean, filename: string) => void;
|
||||||
|
onCopyToClipboard: (includeHistory: boolean) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportMethodDialog({
|
||||||
|
open,
|
||||||
|
onDownload,
|
||||||
|
onCopyToClipboard,
|
||||||
|
onClose,
|
||||||
|
}: Readonly<ExportMethodDialogProps>) {
|
||||||
|
const [includeHistory, setIncludeHistory] = useState(false);
|
||||||
|
const [filename, setFilename] = useState("");
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setIncludeHistory(false);
|
||||||
|
setFilename("");
|
||||||
|
setCopied(false);
|
||||||
|
onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={handleClose} className="w-80">
|
||||||
|
<DialogHeader title="Export Encounter" onClose={handleClose} />
|
||||||
|
<div className="mb-3">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={filename}
|
||||||
|
onChange={(e) => setFilename(e.target.value)}
|
||||||
|
placeholder="Filename (optional)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="mb-4 flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={includeHistory}
|
||||||
|
onChange={(e) => setIncludeHistory(e.target.checked)}
|
||||||
|
className="accent-accent"
|
||||||
|
/>
|
||||||
|
<span className="text-foreground">Include undo/redo history</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||||
|
onClick={() => {
|
||||||
|
onDownload(includeHistory, filename);
|
||||||
|
handleClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Download className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Download file</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
Save as a JSON file
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||||
|
onClick={() => {
|
||||||
|
onCopyToClipboard(includeHistory);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-5 w-5 text-green-400" />
|
||||||
|
) : (
|
||||||
|
<ClipboardCopy className="h-5 w-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{copied ? "Copied!" : "Copy to clipboard"}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
Copy JSON to your clipboard
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||||
import { Input } from "./ui/input";
|
import { Input } from "./ui/input";
|
||||||
|
|
||||||
const DIGITS_ONLY_REGEX = /^\d+$/;
|
const DIGITS_ONLY_REGEX = /^\d+$/;
|
||||||
@@ -48,15 +49,7 @@ export function HpAdjustPopover({
|
|||||||
requestAnimationFrame(() => inputRef.current?.focus());
|
requestAnimationFrame(() => inputRef.current?.focus());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useClickOutside(ref, onClose);
|
||||||
function handleClickOutside(e: MouseEvent) {
|
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const parsedValue =
|
const parsedValue =
|
||||||
inputValue === "" ? null : Number.parseInt(inputValue, 10);
|
inputValue === "" ? null : Number.parseInt(inputValue, 10);
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Button } from "./ui/button.js";
|
||||||
|
import { Dialog } from "./ui/dialog.js";
|
||||||
|
|
||||||
|
interface ImportConfirmDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportConfirmDialog({
|
||||||
|
open,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: Readonly<ImportConfirmDialogProps>) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onCancel}>
|
||||||
|
<h2 className="mb-2 font-semibold text-lg">Replace current encounter?</h2>
|
||||||
|
<p className="mb-4 text-muted-foreground text-sm">
|
||||||
|
Importing will replace your current encounter, undo/redo history, and
|
||||||
|
player characters. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="ghost" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={onConfirm}>
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { ClipboardPaste, FileUp } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Button } from "./ui/button.js";
|
||||||
|
import { Dialog, DialogHeader } from "./ui/dialog.js";
|
||||||
|
|
||||||
|
interface ImportMethodDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onSelectFile: () => void;
|
||||||
|
onSubmitClipboard: (text: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportMethodDialog({
|
||||||
|
open,
|
||||||
|
onSelectFile,
|
||||||
|
onSubmitClipboard,
|
||||||
|
onClose,
|
||||||
|
}: Readonly<ImportMethodDialogProps>) {
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const [mode, setMode] = useState<"pick" | "paste">("pick");
|
||||||
|
const [pasteText, setPasteText] = useState("");
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setMode("pick");
|
||||||
|
setPasteText("");
|
||||||
|
onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setMode("pick");
|
||||||
|
setPasteText("");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === "paste") {
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={handleClose} className="w-80">
|
||||||
|
<DialogHeader title="Import Encounter" onClose={handleClose} />
|
||||||
|
{mode === "pick" && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||||
|
onClick={() => {
|
||||||
|
handleClose();
|
||||||
|
onSelectFile();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileUp className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">From file</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
Upload a JSON file
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||||
|
onClick={() => setMode("paste")}
|
||||||
|
>
|
||||||
|
<ClipboardPaste className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Paste content</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
Paste JSON content directly
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mode === "paste" && (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={pasteText}
|
||||||
|
onChange={(e) => setPasteText(e.target.value)}
|
||||||
|
placeholder="Paste exported JSON here..."
|
||||||
|
className="h-32 w-full resize-none rounded-md border border-border bg-background px-3 py-2 font-mono text-foreground text-xs placeholder:text-muted-foreground focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setMode("pick");
|
||||||
|
setPasteText("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={pasteText.trim().length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
const text = pasteText;
|
||||||
|
handleClose();
|
||||||
|
onSubmitClipboard(text);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
|||||||
setEditingPlayer(undefined);
|
setEditingPlayer(undefined);
|
||||||
setManagementOpen(true);
|
setManagementOpen(true);
|
||||||
}}
|
}}
|
||||||
onSave={(name, ac, maxHp, color, icon) => {
|
onSave={(name, ac, maxHp, color, icon, level) => {
|
||||||
if (editingPlayer) {
|
if (editingPlayer) {
|
||||||
editCharacter(editingPlayer.id, {
|
editCharacter(editingPlayer.id, {
|
||||||
name,
|
name,
|
||||||
@@ -43,9 +43,10 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
|||||||
maxHp,
|
maxHp,
|
||||||
color: color ?? null,
|
color: color ?? null,
|
||||||
icon: icon ?? null,
|
icon: icon ?? null,
|
||||||
|
level: level ?? null,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
createCharacter(name, ac, maxHp, color, icon);
|
createCharacter(name, ac, maxHp, color, icon, level);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
playerCharacter={editingPlayer}
|
playerCharacter={editingPlayer}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||||
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { ConfirmButton } from "./ui/confirm-button";
|
import { ConfirmButton } from "./ui/confirm-button";
|
||||||
|
import { Dialog, DialogHeader } from "./ui/dialog";
|
||||||
|
|
||||||
interface PlayerManagementProps {
|
interface PlayerManagementProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -22,54 +22,9 @@ export function PlayerManagement({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onCreate,
|
onCreate,
|
||||||
}: Readonly<PlayerManagementProps>) {
|
}: Readonly<PlayerManagementProps>) {
|
||||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const dialog = dialogRef.current;
|
|
||||||
if (!dialog) return;
|
|
||||||
if (open && !dialog.open) {
|
|
||||||
dialog.showModal();
|
|
||||||
} else if (!open && dialog.open) {
|
|
||||||
dialog.close();
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const dialog = dialogRef.current;
|
|
||||||
if (!dialog) return;
|
|
||||||
function handleCancel(e: Event) {
|
|
||||||
e.preventDefault();
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
function handleBackdropClick(e: MouseEvent) {
|
|
||||||
if (e.target === dialog) onClose();
|
|
||||||
}
|
|
||||||
dialog.addEventListener("cancel", handleCancel);
|
|
||||||
dialog.addEventListener("mousedown", handleBackdropClick);
|
|
||||||
return () => {
|
|
||||||
dialog.removeEventListener("cancel", handleCancel);
|
|
||||||
dialog.removeEventListener("mousedown", handleBackdropClick);
|
|
||||||
};
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<dialog
|
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
|
||||||
ref={dialogRef}
|
<DialogHeader title="Player Characters" onClose={onClose} />
|
||||||
className="card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
|
|
||||||
>
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
|
||||||
<h2 className="font-semibold text-foreground text-lg">
|
|
||||||
Player Characters
|
|
||||||
</h2>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
>
|
|
||||||
<X size={20} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{characters.length === 0 ? (
|
{characters.length === 0 ? (
|
||||||
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||||
@@ -101,6 +56,11 @@ export function PlayerManagement({
|
|||||||
<span className="text-muted-foreground text-xs tabular-nums">
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
HP {pc.maxHp}
|
HP {pc.maxHp}
|
||||||
</span>
|
</span>
|
||||||
|
{pc.level !== undefined && (
|
||||||
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
|
Lv {pc.level}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
@@ -128,6 +88,6 @@ export function PlayerManagement({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { RollMode } from "@initiative/domain";
|
import type { RollMode } from "@initiative/domain";
|
||||||
import { ChevronsDown, ChevronsUp } from "lucide-react";
|
import { ChevronsDown, ChevronsUp } from "lucide-react";
|
||||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
import { useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||||
|
|
||||||
interface RollModeMenuProps {
|
interface RollModeMenuProps {
|
||||||
readonly position: { x: number; y: number };
|
readonly position: { x: number; y: number };
|
||||||
@@ -34,22 +35,7 @@ export function RollModeMenu({
|
|||||||
setPos({ top, left });
|
setPos({ top, left });
|
||||||
}, [position.x, position.y]);
|
}, [position.x, position.y]);
|
||||||
|
|
||||||
useEffect(() => {
|
useClickOutside(ref, onClose);
|
||||||
function handleMouseDown(e: MouseEvent) {
|
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handleMouseDown);
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousedown", handleMouseDown);
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import type { RulesEdition } from "@initiative/domain";
|
import type { RulesEdition } from "@initiative/domain";
|
||||||
import { Monitor, Moon, Sun, X } from "lucide-react";
|
import { Monitor, Moon, Sun } from "lucide-react";
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
import { useThemeContext } from "../contexts/theme-context.js";
|
import { useThemeContext } from "../contexts/theme-context.js";
|
||||||
import { cn } from "../lib/utils.js";
|
import { cn } from "../lib/utils.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Dialog, DialogHeader } from "./ui/dialog.js";
|
||||||
|
|
||||||
interface SettingsModalProps {
|
interface SettingsModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -27,51 +26,12 @@ const THEME_OPTIONS: {
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
||||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
||||||
const { edition, setEdition } = useRulesEditionContext();
|
const { edition, setEdition } = useRulesEditionContext();
|
||||||
const { preference, setPreference } = useThemeContext();
|
const { preference, setPreference } = useThemeContext();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const dialog = dialogRef.current;
|
|
||||||
if (!dialog) return;
|
|
||||||
if (open && !dialog.open) dialog.showModal();
|
|
||||||
else if (!open && dialog.open) dialog.close();
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const dialog = dialogRef.current;
|
|
||||||
if (!dialog) return;
|
|
||||||
function handleCancel(e: Event) {
|
|
||||||
e.preventDefault();
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
function handleBackdropClick(e: MouseEvent) {
|
|
||||||
if (e.target === dialog) onClose();
|
|
||||||
}
|
|
||||||
dialog.addEventListener("cancel", handleCancel);
|
|
||||||
dialog.addEventListener("mousedown", handleBackdropClick);
|
|
||||||
return () => {
|
|
||||||
dialog.removeEventListener("cancel", handleCancel);
|
|
||||||
dialog.removeEventListener("mousedown", handleBackdropClick);
|
|
||||||
};
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<dialog
|
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-sm">
|
||||||
ref={dialogRef}
|
<DialogHeader title="Settings" onClose={onClose} />
|
||||||
className="card-glow m-auto w-full max-w-sm rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
|
|
||||||
>
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
|
||||||
<h2 className="font-semibold text-foreground text-lg">Settings</h2>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
>
|
|
||||||
<X size={20} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<div>
|
<div>
|
||||||
@@ -124,6 +84,6 @@ export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,31 @@ function SectionDivider() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>) {
|
export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||||
const abilities = [
|
const abilities = [
|
||||||
{ label: "STR", score: creature.abilities.str },
|
{ label: "STR", score: creature.abilities.str },
|
||||||
@@ -134,19 +159,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Traits */}
|
<TraitSection entries={creature.traits} />
|
||||||
{creature.traits && creature.traits.length > 0 && (
|
|
||||||
<>
|
|
||||||
<SectionDivider />
|
|
||||||
<div className="space-y-2">
|
|
||||||
{creature.traits.map((t) => (
|
|
||||||
<div key={t.name} className="text-sm">
|
|
||||||
<span className="font-semibold italic">{t.name}.</span> {t.text}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Spellcasting */}
|
{/* Spellcasting */}
|
||||||
{creature.spellcasting && creature.spellcasting.length > 0 && (
|
{creature.spellcasting && creature.spellcasting.length > 0 && (
|
||||||
@@ -190,52 +203,9 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
<TraitSection entries={creature.actions} heading="Actions" />
|
||||||
{creature.actions && creature.actions.length > 0 && (
|
<TraitSection entries={creature.bonusActions} heading="Bonus Actions" />
|
||||||
<>
|
<TraitSection entries={creature.reactions} heading="Reactions" />
|
||||||
<SectionDivider />
|
|
||||||
<h3 className="font-bold text-base text-stat-heading">Actions</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{creature.actions.map((a) => (
|
|
||||||
<div key={a.name} className="text-sm">
|
|
||||||
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Bonus Actions */}
|
|
||||||
{creature.bonusActions && creature.bonusActions.length > 0 && (
|
|
||||||
<>
|
|
||||||
<SectionDivider />
|
|
||||||
<h3 className="font-bold text-base text-stat-heading">
|
|
||||||
Bonus Actions
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{creature.bonusActions.map((a) => (
|
|
||||||
<div key={a.name} className="text-sm">
|
|
||||||
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Reactions */}
|
|
||||||
{creature.reactions && creature.reactions.length > 0 && (
|
|
||||||
<>
|
|
||||||
<SectionDivider />
|
|
||||||
<h3 className="font-bold text-base text-stat-heading">Reactions</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{creature.reactions.map((a) => (
|
|
||||||
<div key={a.name} className="text-sm">
|
|
||||||
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Legendary Actions */}
|
{/* Legendary Actions */}
|
||||||
{!!creature.legendaryActions && (
|
{!!creature.legendaryActions && (
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Redo2, StepBack, StepForward, Trash2, Undo2 } from "lucide-react";
|
import { Redo2, StepBack, StepForward, Trash2, Undo2 } from "lucide-react";
|
||||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
|
import { useDifficulty } from "../hooks/use-difficulty.js";
|
||||||
|
import { DifficultyIndicator } 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";
|
||||||
|
|
||||||
@@ -15,6 +17,7 @@ export function TurnNavigation() {
|
|||||||
canRedo,
|
canRedo,
|
||||||
} = useEncounterContext();
|
} = useEncounterContext();
|
||||||
|
|
||||||
|
const difficulty = useDifficulty();
|
||||||
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];
|
||||||
@@ -66,6 +69,7 @@ export function TurnNavigation() {
|
|||||||
) : (
|
) : (
|
||||||
<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">
|
<div className="flex flex-shrink-0 items-center gap-3">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useClickOutside } from "../../hooks/use-click-outside.js";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
import { Button } from "./button";
|
import { Button } from "./button";
|
||||||
|
|
||||||
@@ -42,32 +43,7 @@ export function ConfirmButton({
|
|||||||
return () => clearTimeout(timerRef.current);
|
return () => clearTimeout(timerRef.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Click-outside listener when confirming
|
useClickOutside(wrapperRef, revert, isConfirming);
|
||||||
useEffect(() => {
|
|
||||||
if (!isConfirming) return;
|
|
||||||
|
|
||||||
function handleMouseDown(e: MouseEvent) {
|
|
||||||
if (
|
|
||||||
wrapperRef.current &&
|
|
||||||
!wrapperRef.current.contains(e.target as Node)
|
|
||||||
) {
|
|
||||||
revert();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEscapeKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
revert();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("mousedown", handleMouseDown);
|
|
||||||
document.addEventListener("keydown", handleEscapeKey);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousedown", handleMouseDown);
|
|
||||||
document.removeEventListener("keydown", handleEscapeKey);
|
|
||||||
};
|
|
||||||
}, [isConfirming, revert]);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { X } from "lucide-react";
|
||||||
|
import { type ReactNode, useEffect, useRef } from "react";
|
||||||
|
import { cn } from "../../lib/utils.js";
|
||||||
|
import { Button } from "./button.js";
|
||||||
|
|
||||||
|
interface DialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dialog({ open, onClose, className, children }: DialogProps) {
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
if (open && !dialog.open) dialog.showModal();
|
||||||
|
else if (!open && dialog.open) dialog.close();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
function handleCancel(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
function handleBackdropClick(e: MouseEvent) {
|
||||||
|
if (e.target === dialog) onClose();
|
||||||
|
}
|
||||||
|
dialog.addEventListener("cancel", handleCancel);
|
||||||
|
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||||
|
return () => {
|
||||||
|
dialog.removeEventListener("cancel", handleCancel);
|
||||||
|
dialog.removeEventListener("mousedown", handleBackdropClick);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={dialogRef}
|
||||||
|
className={cn(
|
||||||
|
"m-auto rounded-lg border border-border bg-card text-foreground shadow-xl backdrop:bg-black/50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="p-6">{children}</div>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogHeader({
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
}: Readonly<{ title: string; onClose: () => void }>) {
|
||||||
|
return (
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold text-foreground text-lg">{title}</h2>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { EllipsisVertical } from "lucide-react";
|
import { EllipsisVertical } from "lucide-react";
|
||||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
import { type ReactNode, useRef, useState } from "react";
|
||||||
|
import { useClickOutside } from "../../hooks/use-click-outside.js";
|
||||||
import { Button } from "./button";
|
import { Button } from "./button";
|
||||||
|
|
||||||
export interface OverflowMenuItem {
|
export interface OverflowMenuItem {
|
||||||
@@ -18,23 +19,7 @@ export function OverflowMenu({ items }: OverflowMenuProps) {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useClickOutside(ref, () => setOpen(false), open);
|
||||||
if (!open) return;
|
|
||||||
function handleMouseDown(e: MouseEvent) {
|
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") setOpen(false);
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handleMouseDown);
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousedown", handleMouseDown);
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="relative">
|
<div ref={ref} className="relative">
|
||||||
|
|||||||
@@ -0,0 +1,416 @@
|
|||||||
|
import type {
|
||||||
|
BestiaryIndexEntry,
|
||||||
|
ConditionId,
|
||||||
|
PlayerCharacter,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import {
|
||||||
|
combatantId,
|
||||||
|
createEncounter,
|
||||||
|
EMPTY_UNDO_REDO_STATE,
|
||||||
|
isDomainError,
|
||||||
|
playerCharacterId,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
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 {
|
||||||
|
return {
|
||||||
|
encounter: {
|
||||||
|
combatants: [],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
},
|
||||||
|
undoRedoState: EMPTY_UNDO_REDO_STATE,
|
||||||
|
events: [],
|
||||||
|
nextId: 0,
|
||||||
|
lastCreatureId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateWith(...names: string[]): EncounterState {
|
||||||
|
let state = emptyState();
|
||||||
|
for (const name of names) {
|
||||||
|
state = encounterReducer(state, { type: "add-combatant", name });
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateWithHp(name: string, maxHp: number): EncounterState {
|
||||||
|
const state = stateWith(name);
|
||||||
|
const id = state.encounter.combatants[0].id;
|
||||||
|
return encounterReducer(state, {
|
||||||
|
type: "set-hp",
|
||||||
|
id,
|
||||||
|
maxHp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const BESTIARY_ENTRY: BestiaryIndexEntry = {
|
||||||
|
name: "Goblin",
|
||||||
|
source: "MM",
|
||||||
|
ac: 15,
|
||||||
|
hp: 7,
|
||||||
|
dex: 14,
|
||||||
|
cr: "1/4",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
size: "Small",
|
||||||
|
type: "humanoid",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("encounterReducer", () => {
|
||||||
|
describe("add-combatant", () => {
|
||||||
|
it("adds a combatant and pushes undo", () => {
|
||||||
|
const next = encounterReducer(emptyState(), {
|
||||||
|
type: "add-combatant",
|
||||||
|
name: "Goblin",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants).toHaveLength(1);
|
||||||
|
expect(next.encounter.combatants[0].name).toBe("Goblin");
|
||||||
|
expect(next.undoRedoState.undoStack).toHaveLength(1);
|
||||||
|
expect(next.nextId).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies optional init values", () => {
|
||||||
|
const next = encounterReducer(emptyState(), {
|
||||||
|
type: "add-combatant",
|
||||||
|
name: "Goblin",
|
||||||
|
init: { initiative: 15, ac: 13, maxHp: 7 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const c = next.encounter.combatants[0];
|
||||||
|
expect(c.initiative).toBe(15);
|
||||||
|
expect(c.ac).toBe(13);
|
||||||
|
expect(c.maxHp).toBe(7);
|
||||||
|
expect(c.currentHp).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increments IDs", () => {
|
||||||
|
const s1 = encounterReducer(emptyState(), {
|
||||||
|
type: "add-combatant",
|
||||||
|
name: "A",
|
||||||
|
});
|
||||||
|
const s2 = encounterReducer(s1, {
|
||||||
|
type: "add-combatant",
|
||||||
|
name: "B",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(s2.encounter.combatants[0].id).toBe("c-1");
|
||||||
|
expect(s2.encounter.combatants[1].id).toBe("c-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns unchanged state for invalid name", () => {
|
||||||
|
const state = emptyState();
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "add-combatant",
|
||||||
|
name: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next).toBe(state);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("remove-combatant", () => {
|
||||||
|
it("removes combatant and pushes undo", () => {
|
||||||
|
const state = stateWith("Goblin");
|
||||||
|
const id = state.encounter.combatants[0].id;
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "remove-combatant",
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants).toHaveLength(0);
|
||||||
|
expect(next.undoRedoState.undoStack).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edit-combatant", () => {
|
||||||
|
it("renames combatant", () => {
|
||||||
|
const state = stateWith("Goblin");
|
||||||
|
const id = state.encounter.combatants[0].id;
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "edit-combatant",
|
||||||
|
id,
|
||||||
|
newName: "Hobgoblin",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants[0].name).toBe("Hobgoblin");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("advance-turn / retreat-turn", () => {
|
||||||
|
it("advances and retreats turn", () => {
|
||||||
|
const state = stateWith("A", "B");
|
||||||
|
const advanced = encounterReducer(state, {
|
||||||
|
type: "advance-turn",
|
||||||
|
});
|
||||||
|
expect(advanced.encounter.activeIndex).toBe(1);
|
||||||
|
|
||||||
|
const retreated = encounterReducer(advanced, {
|
||||||
|
type: "retreat-turn",
|
||||||
|
});
|
||||||
|
expect(retreated.encounter.activeIndex).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns unchanged state on empty encounter", () => {
|
||||||
|
const state = emptyState();
|
||||||
|
const next = encounterReducer(state, { type: "advance-turn" });
|
||||||
|
expect(next).toBe(state);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("set-hp / adjust-hp / set-temp-hp", () => {
|
||||||
|
it("sets max HP", () => {
|
||||||
|
const state = stateWith("Goblin");
|
||||||
|
const id = state.encounter.combatants[0].id;
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "set-hp",
|
||||||
|
id,
|
||||||
|
maxHp: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants[0].maxHp).toBe(20);
|
||||||
|
expect(next.encounter.combatants[0].currentHp).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adjusts HP", () => {
|
||||||
|
const state = stateWithHp("Goblin", 20);
|
||||||
|
const id = state.encounter.combatants[0].id;
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "adjust-hp",
|
||||||
|
id,
|
||||||
|
delta: -5,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants[0].currentHp).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets temp HP", () => {
|
||||||
|
const state = stateWithHp("Goblin", 20);
|
||||||
|
const id = state.encounter.combatants[0].id;
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "set-temp-hp",
|
||||||
|
id,
|
||||||
|
tempHp: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants[0].tempHp).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("set-ac", () => {
|
||||||
|
it("sets AC", () => {
|
||||||
|
const state = stateWith("Goblin");
|
||||||
|
const id = state.encounter.combatants[0].id;
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "set-ac",
|
||||||
|
id,
|
||||||
|
value: 15,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants[0].ac).toBe(15);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("set-initiative", () => {
|
||||||
|
it("sets initiative", () => {
|
||||||
|
const state = stateWith("Goblin");
|
||||||
|
const id = state.encounter.combatants[0].id;
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "set-initiative",
|
||||||
|
id,
|
||||||
|
value: 18,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants[0].initiative).toBe(18);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toggle-condition / toggle-concentration", () => {
|
||||||
|
it("toggles condition", () => {
|
||||||
|
const state = stateWith("Goblin");
|
||||||
|
const id = state.encounter.combatants[0].id;
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "toggle-condition",
|
||||||
|
id,
|
||||||
|
conditionId: "blinded" as ConditionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants[0].conditions).toContain("blinded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles concentration", () => {
|
||||||
|
const state = stateWith("Wizard");
|
||||||
|
const id = state.encounter.combatants[0].id;
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "toggle-concentration",
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants[0].isConcentrating).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clear-encounter", () => {
|
||||||
|
it("clears combatants, resets history and nextId", () => {
|
||||||
|
const state = stateWith("A", "B");
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "clear-encounter",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants).toHaveLength(0);
|
||||||
|
expect(next.undoRedoState.undoStack).toHaveLength(0);
|
||||||
|
expect(next.undoRedoState.redoStack).toHaveLength(0);
|
||||||
|
expect(next.nextId).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("undo / redo", () => {
|
||||||
|
it("undo restores previous state", () => {
|
||||||
|
const state = stateWith("Goblin");
|
||||||
|
const next = encounterReducer(state, { type: "undo" });
|
||||||
|
|
||||||
|
expect(next.encounter.combatants).toHaveLength(0);
|
||||||
|
expect(next.undoRedoState.undoStack).toHaveLength(0);
|
||||||
|
expect(next.undoRedoState.redoStack).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redo restores undone state", () => {
|
||||||
|
const state = stateWith("Goblin");
|
||||||
|
const undone = encounterReducer(state, { type: "undo" });
|
||||||
|
const redone = encounterReducer(undone, { type: "redo" });
|
||||||
|
|
||||||
|
expect(redone.encounter.combatants).toHaveLength(1);
|
||||||
|
expect(redone.encounter.combatants[0].name).toBe("Goblin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("undo returns unchanged state when stack is empty", () => {
|
||||||
|
const state = emptyState();
|
||||||
|
const next = encounterReducer(state, { type: "undo" });
|
||||||
|
expect(next).toBe(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redo returns unchanged state when stack is empty", () => {
|
||||||
|
const state = emptyState();
|
||||||
|
const next = encounterReducer(state, { type: "redo" });
|
||||||
|
expect(next).toBe(state);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("add-from-bestiary", () => {
|
||||||
|
it("adds creature with HP, AC, and creatureId", () => {
|
||||||
|
const next = encounterReducer(emptyState(), {
|
||||||
|
type: "add-from-bestiary",
|
||||||
|
entry: BESTIARY_ENTRY,
|
||||||
|
});
|
||||||
|
|
||||||
|
const c = next.encounter.combatants[0];
|
||||||
|
expect(c.name).toBe("Goblin");
|
||||||
|
expect(c.maxHp).toBe(7);
|
||||||
|
expect(c.ac).toBe(15);
|
||||||
|
expect(c.creatureId).toBe("mm:goblin");
|
||||||
|
expect(next.lastCreatureId).toBe("mm:goblin");
|
||||||
|
expect(next.undoRedoState.undoStack).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-numbers duplicate names", () => {
|
||||||
|
const s1 = encounterReducer(emptyState(), {
|
||||||
|
type: "add-from-bestiary",
|
||||||
|
entry: BESTIARY_ENTRY,
|
||||||
|
});
|
||||||
|
const s2 = encounterReducer(s1, {
|
||||||
|
type: "add-from-bestiary",
|
||||||
|
entry: BESTIARY_ENTRY,
|
||||||
|
});
|
||||||
|
|
||||||
|
const names = s2.encounter.combatants.map((c) => c.name);
|
||||||
|
expect(names).toContain("Goblin 1");
|
||||||
|
expect(names).toContain("Goblin 2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("add-multiple-from-bestiary", () => {
|
||||||
|
it("adds multiple creatures in one action", () => {
|
||||||
|
const next = encounterReducer(emptyState(), {
|
||||||
|
type: "add-multiple-from-bestiary",
|
||||||
|
entry: BESTIARY_ENTRY,
|
||||||
|
count: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants).toHaveLength(3);
|
||||||
|
expect(next.undoRedoState.undoStack).toHaveLength(1);
|
||||||
|
expect(next.lastCreatureId).toBe("mm:goblin");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("add-from-player-character", () => {
|
||||||
|
it("adds combatant with PC attributes", () => {
|
||||||
|
const pc: PlayerCharacter = {
|
||||||
|
id: playerCharacterId("pc-1"),
|
||||||
|
name: "Aria",
|
||||||
|
ac: 16,
|
||||||
|
maxHp: 30,
|
||||||
|
color: "blue",
|
||||||
|
icon: "sword",
|
||||||
|
};
|
||||||
|
const next = encounterReducer(emptyState(), {
|
||||||
|
type: "add-from-player-character",
|
||||||
|
pc,
|
||||||
|
});
|
||||||
|
|
||||||
|
const c = next.encounter.combatants[0];
|
||||||
|
expect(c.name).toBe("Aria");
|
||||||
|
expect(c.maxHp).toBe(30);
|
||||||
|
expect(c.ac).toBe(16);
|
||||||
|
expect(c.color).toBe("blue");
|
||||||
|
expect(c.icon).toBe("sword");
|
||||||
|
expect(c.playerCharacterId).toBe("pc-1");
|
||||||
|
expect(next.lastCreatureId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("import", () => {
|
||||||
|
it("replaces encounter and undo/redo state", () => {
|
||||||
|
const state = stateWith("A", "B");
|
||||||
|
const enc = createEncounter([
|
||||||
|
{ id: combatantId("c-5"), name: "Imported" },
|
||||||
|
]);
|
||||||
|
if (isDomainError(enc)) throw new Error("Setup failed");
|
||||||
|
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "import",
|
||||||
|
encounter: enc,
|
||||||
|
undoRedoState: EMPTY_UNDO_REDO_STATE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants).toHaveLength(1);
|
||||||
|
expect(next.encounter.combatants[0].name).toBe("Imported");
|
||||||
|
expect(next.nextId).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("events accumulation", () => {
|
||||||
|
it("accumulates events across actions", () => {
|
||||||
|
const s1 = encounterReducer(emptyState(), {
|
||||||
|
type: "add-combatant",
|
||||||
|
name: "A",
|
||||||
|
});
|
||||||
|
const s2 = encounterReducer(s1, {
|
||||||
|
type: "add-combatant",
|
||||||
|
name: "B",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(s2.events.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
// @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,220 @@
|
|||||||
|
// @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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -42,7 +42,14 @@ describe("usePlayerCharacters", () => {
|
|||||||
const { result } = renderHook(() => usePlayerCharacters());
|
const { result } = renderHook(() => usePlayerCharacters());
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
|
result.current.createCharacter(
|
||||||
|
"Vex",
|
||||||
|
15,
|
||||||
|
28,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.characters).toHaveLength(1);
|
expect(result.current.characters).toHaveLength(1);
|
||||||
@@ -57,7 +64,14 @@ describe("usePlayerCharacters", () => {
|
|||||||
|
|
||||||
let error: unknown;
|
let error: unknown;
|
||||||
act(() => {
|
act(() => {
|
||||||
error = result.current.createCharacter("", 15, 28, undefined, undefined);
|
error = result.current.createCharacter(
|
||||||
|
"",
|
||||||
|
15,
|
||||||
|
28,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(error).toMatchObject({ kind: "domain-error" });
|
expect(error).toMatchObject({ kind: "domain-error" });
|
||||||
@@ -68,7 +82,14 @@ describe("usePlayerCharacters", () => {
|
|||||||
const { result } = renderHook(() => usePlayerCharacters());
|
const { result } = renderHook(() => usePlayerCharacters());
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
|
result.current.createCharacter(
|
||||||
|
"Vex",
|
||||||
|
15,
|
||||||
|
28,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const id = result.current.characters[0].id;
|
const id = result.current.characters[0].id;
|
||||||
@@ -85,7 +106,14 @@ describe("usePlayerCharacters", () => {
|
|||||||
const { result } = renderHook(() => usePlayerCharacters());
|
const { result } = renderHook(() => usePlayerCharacters());
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
|
result.current.createCharacter(
|
||||||
|
"Vex",
|
||||||
|
15,
|
||||||
|
28,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const id = result.current.characters[0].id;
|
const id = result.current.characters[0].id;
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { useRulesEdition } from "../use-rules-edition.js";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "initiative:rules-edition";
|
||||||
|
|
||||||
|
describe("useRulesEdition", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
// Reset to default
|
||||||
|
const { result } = renderHook(() => useRulesEdition());
|
||||||
|
act(() => result.current.setEdition("5.5e"));
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to 5.5e", () => {
|
||||||
|
const { result } = renderHook(() => useRulesEdition());
|
||||||
|
expect(result.current.edition).toBe("5.5e");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setEdition updates value", () => {
|
||||||
|
const { result } = renderHook(() => useRulesEdition());
|
||||||
|
|
||||||
|
act(() => result.current.setEdition("5e"));
|
||||||
|
|
||||||
|
expect(result.current.edition).toBe("5e");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setEdition persists to localStorage", () => {
|
||||||
|
const { result } = renderHook(() => useRulesEdition());
|
||||||
|
|
||||||
|
act(() => result.current.setEdition("5e"));
|
||||||
|
|
||||||
|
expect(localStorage.getItem(STORAGE_KEY)).toBe("5e");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("multiple hooks stay in sync", () => {
|
||||||
|
const { result: r1 } = renderHook(() => useRulesEdition());
|
||||||
|
const { result: r2 } = renderHook(() => useRulesEdition());
|
||||||
|
|
||||||
|
act(() => r1.current.setEdition("5e"));
|
||||||
|
|
||||||
|
expect(r2.current.edition).toBe("5e");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { useTheme } from "../use-theme.js";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "initiative:theme";
|
||||||
|
|
||||||
|
describe("useTheme", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
// Reset to default
|
||||||
|
const { result } = renderHook(() => useTheme());
|
||||||
|
act(() => result.current.setPreference("system"));
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to system preference", () => {
|
||||||
|
const { result } = renderHook(() => useTheme());
|
||||||
|
expect(result.current.preference).toBe("system");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setPreference updates to light", () => {
|
||||||
|
const { result } = renderHook(() => useTheme());
|
||||||
|
|
||||||
|
act(() => result.current.setPreference("light"));
|
||||||
|
|
||||||
|
expect(result.current.preference).toBe("light");
|
||||||
|
expect(result.current.resolved).toBe("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setPreference updates to dark", () => {
|
||||||
|
const { result } = renderHook(() => useTheme());
|
||||||
|
|
||||||
|
act(() => result.current.setPreference("dark"));
|
||||||
|
|
||||||
|
expect(result.current.preference).toBe("dark");
|
||||||
|
expect(result.current.resolved).toBe("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists preference to localStorage", () => {
|
||||||
|
const { result } = renderHook(() => useTheme());
|
||||||
|
|
||||||
|
act(() => result.current.setPreference("light"));
|
||||||
|
|
||||||
|
expect(localStorage.getItem(STORAGE_KEY)).toBe("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies theme to document element", () => {
|
||||||
|
const { result } = renderHook(() => useTheme());
|
||||||
|
|
||||||
|
act(() => result.current.setPreference("light"));
|
||||||
|
|
||||||
|
expect(document.documentElement.dataset.theme).toBe("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("multiple hooks stay in sync", () => {
|
||||||
|
const { result: r1 } = renderHook(() => useTheme());
|
||||||
|
const { result: r2 } = renderHook(() => useTheme());
|
||||||
|
|
||||||
|
act(() => r1.current.setPreference("dark"));
|
||||||
|
|
||||||
|
expect(r2.current.preference).toBe("dark");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import type { CreatureId, PlayerCharacter } from "@initiative/domain";
|
import type { CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||||
import { useCallback, useDeferredValue, useMemo, useState } from "react";
|
import {
|
||||||
|
useCallback,
|
||||||
|
useDeferredValue,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import type { SearchResult } from "../contexts/bestiary-context.js";
|
import type { SearchResult } from "../contexts/bestiary-context.js";
|
||||||
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";
|
||||||
@@ -26,14 +33,33 @@ export function creatureKey(r: SearchResult): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useActionBarState() {
|
export function useActionBarState() {
|
||||||
const { addCombatant, addFromBestiary, addFromPlayerCharacter } =
|
const {
|
||||||
useEncounterContext();
|
addCombatant,
|
||||||
|
addFromBestiary,
|
||||||
|
addMultipleFromBestiary,
|
||||||
|
addFromPlayerCharacter,
|
||||||
|
lastCreatureId,
|
||||||
|
} = useEncounterContext();
|
||||||
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
|
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
|
||||||
useBestiaryContext();
|
useBestiaryContext();
|
||||||
const { characters: playerCharacters } = usePlayerCharactersContext();
|
const { characters: playerCharacters } = usePlayerCharactersContext();
|
||||||
const { showBulkImport, showSourceManager, showCreature, panelView } =
|
const { showBulkImport, showSourceManager, showCreature, panelView } =
|
||||||
useSidePanelContext();
|
useSidePanelContext();
|
||||||
|
|
||||||
|
// Auto-show stat block when a bestiary creature is added on desktop
|
||||||
|
const prevCreatureIdRef = useRef(lastCreatureId);
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
lastCreatureId &&
|
||||||
|
lastCreatureId !== prevCreatureIdRef.current &&
|
||||||
|
panelView.mode === "closed" &&
|
||||||
|
globalThis.matchMedia("(min-width: 1024px)").matches
|
||||||
|
) {
|
||||||
|
showCreature(lastCreatureId);
|
||||||
|
}
|
||||||
|
prevCreatureIdRef.current = lastCreatureId;
|
||||||
|
}, [lastCreatureId, panelView.mode, showCreature]);
|
||||||
|
|
||||||
const [nameInput, setNameInput] = useState("");
|
const [nameInput, setNameInput] = useState("");
|
||||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||||
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
||||||
@@ -69,13 +95,9 @@ export function useActionBarState() {
|
|||||||
|
|
||||||
const handleAddFromBestiary = useCallback(
|
const handleAddFromBestiary = useCallback(
|
||||||
(result: SearchResult) => {
|
(result: SearchResult) => {
|
||||||
const creatureId = addFromBestiary(result);
|
addFromBestiary(result);
|
||||||
const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches;
|
|
||||||
if (creatureId && panelView.mode === "closed" && isDesktop) {
|
|
||||||
showCreature(creatureId);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[addFromBestiary, panelView.mode, showCreature],
|
[addFromBestiary],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleViewStatBlock = useCallback(
|
const handleViewStatBlock = useCallback(
|
||||||
@@ -92,11 +114,13 @@ export function useActionBarState() {
|
|||||||
|
|
||||||
const confirmQueued = useCallback(() => {
|
const confirmQueued = useCallback(() => {
|
||||||
if (!queued) return;
|
if (!queued) return;
|
||||||
for (let i = 0; i < queued.count; i++) {
|
if (queued.count === 1) {
|
||||||
handleAddFromBestiary(queued.result);
|
handleAddFromBestiary(queued.result);
|
||||||
|
} else {
|
||||||
|
addMultipleFromBestiary(queued.result, queued.count);
|
||||||
}
|
}
|
||||||
clearInput();
|
clearInput();
|
||||||
}, [queued, handleAddFromBestiary, clearInput]);
|
}, [queued, handleAddFromBestiary, addMultipleFromBestiary, clearInput]);
|
||||||
|
|
||||||
const parseNum = (v: string): number | undefined => {
|
const parseNum = (v: string): number | undefined => {
|
||||||
if (v.trim() === "") return undefined;
|
if (v.trim() === "") return undefined;
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { RefObject } from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export function useClickOutside(
|
||||||
|
ref: RefObject<HTMLElement | null>,
|
||||||
|
onClose: () => void,
|
||||||
|
active = true,
|
||||||
|
): void {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
|
||||||
|
function handleMouseDown(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleMouseDown);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleMouseDown);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [ref, onClose, active]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type {
|
||||||
|
Combatant,
|
||||||
|
CreatureId,
|
||||||
|
DifficultyResult,
|
||||||
|
PlayerCharacter,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { calculateEncounterDifficulty } 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";
|
||||||
|
|
||||||
|
function derivePartyLevels(
|
||||||
|
combatants: readonly Combatant[],
|
||||||
|
characters: readonly PlayerCharacter[],
|
||||||
|
): 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(
|
||||||
|
combatants: readonly Combatant[],
|
||||||
|
getCreature: (id: CreatureId) => { cr: string } | undefined,
|
||||||
|
): string[] {
|
||||||
|
const crs: string[] = [];
|
||||||
|
for (const c of combatants) {
|
||||||
|
if (!c.creatureId) continue;
|
||||||
|
const creature = getCreature(c.creatureId);
|
||||||
|
if (creature) crs.push(creature.cr);
|
||||||
|
}
|
||||||
|
return crs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDifficulty(): DifficultyResult | null {
|
||||||
|
const { encounter } = useEncounterContext();
|
||||||
|
const { characters } = usePlayerCharactersContext();
|
||||||
|
const { getCreature } = useBestiaryContext();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const partyLevels = derivePartyLevels(encounter.combatants, characters);
|
||||||
|
const monsterCrs = deriveMonsterCrs(encounter.combatants, getCreature);
|
||||||
|
|
||||||
|
if (partyLevels.length === 0 || monsterCrs.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return calculateEncounterDifficulty(partyLevels, monsterCrs);
|
||||||
|
}, [encounter.combatants, characters, getCreature]);
|
||||||
|
}
|
||||||
+435
-310
@@ -22,6 +22,7 @@ import type {
|
|||||||
CombatantInit,
|
CombatantInit,
|
||||||
ConditionId,
|
ConditionId,
|
||||||
CreatureId,
|
CreatureId,
|
||||||
|
DomainError,
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
Encounter,
|
Encounter,
|
||||||
PlayerCharacter,
|
PlayerCharacter,
|
||||||
@@ -35,7 +36,7 @@ import {
|
|||||||
pushUndo,
|
pushUndo,
|
||||||
resolveCreatureName,
|
resolveCreatureName,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useReducer, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
loadEncounter,
|
loadEncounter,
|
||||||
saveEncounter,
|
saveEncounter,
|
||||||
@@ -45,6 +46,51 @@ import {
|
|||||||
saveUndoRedoStacks,
|
saveUndoRedoStacks,
|
||||||
} from "../persistence/undo-redo-storage.js";
|
} from "../persistence/undo-redo-storage.js";
|
||||||
|
|
||||||
|
// -- Types --
|
||||||
|
|
||||||
|
type EncounterAction =
|
||||||
|
| { type: "advance-turn" }
|
||||||
|
| { type: "retreat-turn" }
|
||||||
|
| { type: "add-combatant"; name: string; init?: CombatantInit }
|
||||||
|
| { type: "remove-combatant"; id: CombatantId }
|
||||||
|
| { type: "edit-combatant"; id: CombatantId; newName: string }
|
||||||
|
| { type: "set-initiative"; id: CombatantId; value: number | undefined }
|
||||||
|
| { type: "set-hp"; id: CombatantId; maxHp: number | undefined }
|
||||||
|
| { type: "adjust-hp"; id: CombatantId; delta: number }
|
||||||
|
| { type: "set-temp-hp"; id: CombatantId; tempHp: number | undefined }
|
||||||
|
| { type: "set-ac"; id: CombatantId; value: number | undefined }
|
||||||
|
| {
|
||||||
|
type: "toggle-condition";
|
||||||
|
id: CombatantId;
|
||||||
|
conditionId: ConditionId;
|
||||||
|
}
|
||||||
|
| { type: "toggle-concentration"; id: CombatantId }
|
||||||
|
| { type: "clear-encounter" }
|
||||||
|
| { type: "undo" }
|
||||||
|
| { type: "redo" }
|
||||||
|
| { type: "add-from-bestiary"; entry: BestiaryIndexEntry }
|
||||||
|
| {
|
||||||
|
type: "add-multiple-from-bestiary";
|
||||||
|
entry: BestiaryIndexEntry;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
| { type: "add-from-player-character"; pc: PlayerCharacter }
|
||||||
|
| {
|
||||||
|
type: "import";
|
||||||
|
encounter: Encounter;
|
||||||
|
undoRedoState: UndoRedoState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface EncounterState {
|
||||||
|
readonly encounter: Encounter;
|
||||||
|
readonly undoRedoState: UndoRedoState;
|
||||||
|
readonly events: readonly DomainEvent[];
|
||||||
|
readonly nextId: number;
|
||||||
|
readonly lastCreatureId: CreatureId | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Initialization --
|
||||||
|
|
||||||
const COMBATANT_ID_REGEX = /^c-(\d+)$/;
|
const COMBATANT_ID_REGEX = /^c-(\d+)$/;
|
||||||
|
|
||||||
const EMPTY_ENCOUNTER: Encounter = {
|
const EMPTY_ENCOUNTER: Encounter = {
|
||||||
@@ -53,12 +99,6 @@ const EMPTY_ENCOUNTER: Encounter = {
|
|||||||
roundNumber: 1,
|
roundNumber: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
function initializeEncounter(): Encounter {
|
|
||||||
const stored = loadEncounter();
|
|
||||||
if (stored !== null) return stored;
|
|
||||||
return EMPTY_ENCOUNTER;
|
|
||||||
}
|
|
||||||
|
|
||||||
function deriveNextId(encounter: Encounter): number {
|
function deriveNextId(encounter: Encounter): number {
|
||||||
let max = 0;
|
let max = 0;
|
||||||
for (const c of encounter.combatants) {
|
for (const c of encounter.combatants) {
|
||||||
@@ -71,11 +111,283 @@ function deriveNextId(encounter: Encounter): number {
|
|||||||
return max;
|
return max;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initializeState(): EncounterState {
|
||||||
|
const encounter = loadEncounter() ?? EMPTY_ENCOUNTER;
|
||||||
|
return {
|
||||||
|
encounter,
|
||||||
|
undoRedoState: loadUndoRedoStacks(),
|
||||||
|
events: [],
|
||||||
|
nextId: deriveNextId(encounter),
|
||||||
|
lastCreatureId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Helpers --
|
||||||
|
|
||||||
|
function makeStoreFromState(state: EncounterState): {
|
||||||
|
store: EncounterStore;
|
||||||
|
getEncounter: () => Encounter;
|
||||||
|
} {
|
||||||
|
let current = state.encounter;
|
||||||
|
return {
|
||||||
|
store: {
|
||||||
|
get: () => current,
|
||||||
|
save: (e) => {
|
||||||
|
current = e;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getEncounter: () => current,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAndRename(store: EncounterStore, name: string): string {
|
||||||
|
const existingNames = store.get().combatants.map((c) => c.name);
|
||||||
|
const { newName, renames } = resolveCreatureName(name, existingNames);
|
||||||
|
|
||||||
|
for (const { from, to } of renames) {
|
||||||
|
const target = store.get().combatants.find((c) => c.name === from);
|
||||||
|
if (target) {
|
||||||
|
editCombatantUseCase(store, target.id, to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOneFromBestiary(
|
||||||
|
store: EncounterStore,
|
||||||
|
entry: BestiaryIndexEntry,
|
||||||
|
nextId: number,
|
||||||
|
): {
|
||||||
|
cId: CreatureId;
|
||||||
|
events: DomainEvent[];
|
||||||
|
nextId: number;
|
||||||
|
} | null {
|
||||||
|
const newName = resolveAndRename(store, entry.name);
|
||||||
|
|
||||||
|
const slug = entry.name
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||||
|
.replaceAll(/(^-|-$)/g, "");
|
||||||
|
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
|
||||||
|
|
||||||
|
const id = combatantId(`c-${nextId + 1}`);
|
||||||
|
const result = addCombatantUseCase(store, id, newName, {
|
||||||
|
maxHp: entry.hp,
|
||||||
|
ac: entry.ac > 0 ? entry.ac : undefined,
|
||||||
|
creatureId: cId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDomainError(result)) return null;
|
||||||
|
|
||||||
|
return { cId, events: result, nextId: nextId + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Reducer case handlers --
|
||||||
|
|
||||||
|
function handleUndoRedo(
|
||||||
|
state: EncounterState,
|
||||||
|
direction: "undo" | "redo",
|
||||||
|
): EncounterState {
|
||||||
|
const { store, getEncounter } = makeStoreFromState(state);
|
||||||
|
const undoRedoStore: UndoRedoStore = {
|
||||||
|
get: () => state.undoRedoState,
|
||||||
|
save: () => {},
|
||||||
|
};
|
||||||
|
const applyFn = direction === "undo" ? undoUseCase : redoUseCase;
|
||||||
|
const result = applyFn(store, undoRedoStore);
|
||||||
|
if (isDomainError(result)) return state;
|
||||||
|
|
||||||
|
const isUndo = direction === "undo";
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
encounter: getEncounter(),
|
||||||
|
undoRedoState: {
|
||||||
|
undoStack: isUndo
|
||||||
|
? state.undoRedoState.undoStack.slice(0, -1)
|
||||||
|
: [...state.undoRedoState.undoStack, state.encounter],
|
||||||
|
redoStack: isUndo
|
||||||
|
? [...state.undoRedoState.redoStack, state.encounter]
|
||||||
|
: state.undoRedoState.redoStack.slice(0, -1),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddFromBestiary(
|
||||||
|
state: EncounterState,
|
||||||
|
entry: BestiaryIndexEntry,
|
||||||
|
count: number,
|
||||||
|
): EncounterState {
|
||||||
|
const { store, getEncounter } = makeStoreFromState(state);
|
||||||
|
const allEvents: DomainEvent[] = [];
|
||||||
|
let nextId = state.nextId;
|
||||||
|
let lastCId: CreatureId | null = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const added = addOneFromBestiary(store, entry, nextId);
|
||||||
|
if (!added) return state;
|
||||||
|
allEvents.push(...added.events);
|
||||||
|
nextId = added.nextId;
|
||||||
|
lastCId = added.cId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
encounter: getEncounter(),
|
||||||
|
undoRedoState: pushUndo(state.undoRedoState, state.encounter),
|
||||||
|
events: [...state.events, ...allEvents],
|
||||||
|
nextId,
|
||||||
|
lastCreatureId: lastCId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddFromPlayerCharacter(
|
||||||
|
state: EncounterState,
|
||||||
|
pc: PlayerCharacter,
|
||||||
|
): EncounterState {
|
||||||
|
const { store, getEncounter } = makeStoreFromState(state);
|
||||||
|
const newName = resolveAndRename(store, pc.name);
|
||||||
|
const id = combatantId(`c-${state.nextId + 1}`);
|
||||||
|
const result = addCombatantUseCase(store, id, newName, {
|
||||||
|
maxHp: pc.maxHp,
|
||||||
|
ac: pc.ac > 0 ? pc.ac : undefined,
|
||||||
|
color: pc.color,
|
||||||
|
icon: pc.icon,
|
||||||
|
playerCharacterId: pc.id,
|
||||||
|
});
|
||||||
|
if (isDomainError(result)) return state;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
encounter: getEncounter(),
|
||||||
|
undoRedoState: pushUndo(state.undoRedoState, state.encounter),
|
||||||
|
events: [...state.events, ...result],
|
||||||
|
nextId: state.nextId + 1,
|
||||||
|
lastCreatureId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Reducer --
|
||||||
|
|
||||||
|
export function encounterReducer(
|
||||||
|
state: EncounterState,
|
||||||
|
action: EncounterAction,
|
||||||
|
): EncounterState {
|
||||||
|
switch (action.type) {
|
||||||
|
case "import":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
encounter: action.encounter,
|
||||||
|
undoRedoState: action.undoRedoState,
|
||||||
|
nextId: deriveNextId(action.encounter),
|
||||||
|
lastCreatureId: null,
|
||||||
|
};
|
||||||
|
case "undo":
|
||||||
|
case "redo":
|
||||||
|
return handleUndoRedo(state, action.type);
|
||||||
|
case "clear-encounter": {
|
||||||
|
const { store, getEncounter } = makeStoreFromState(state);
|
||||||
|
const result = clearEncounterUseCase(store);
|
||||||
|
if (isDomainError(result)) return state;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
encounter: getEncounter(),
|
||||||
|
undoRedoState: clearHistory(),
|
||||||
|
events: [...state.events, ...result],
|
||||||
|
nextId: 0,
|
||||||
|
lastCreatureId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "add-from-bestiary":
|
||||||
|
return handleAddFromBestiary(state, action.entry, 1);
|
||||||
|
case "add-multiple-from-bestiary":
|
||||||
|
return handleAddFromBestiary(state, action.entry, action.count);
|
||||||
|
case "add-from-player-character":
|
||||||
|
return handleAddFromPlayerCharacter(state, action.pc);
|
||||||
|
default:
|
||||||
|
return dispatchEncounterAction(state, action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchEncounterAction(
|
||||||
|
state: EncounterState,
|
||||||
|
action: Extract<
|
||||||
|
EncounterAction,
|
||||||
|
| { type: "advance-turn" }
|
||||||
|
| { type: "retreat-turn" }
|
||||||
|
| { type: "add-combatant" }
|
||||||
|
| { type: "remove-combatant" }
|
||||||
|
| { type: "edit-combatant" }
|
||||||
|
| { type: "set-initiative" }
|
||||||
|
| { type: "set-hp" }
|
||||||
|
| { type: "adjust-hp" }
|
||||||
|
| { type: "set-temp-hp" }
|
||||||
|
| { type: "set-ac" }
|
||||||
|
| { type: "toggle-condition" }
|
||||||
|
| { type: "toggle-concentration" }
|
||||||
|
>,
|
||||||
|
): EncounterState {
|
||||||
|
const { store, getEncounter } = makeStoreFromState(state);
|
||||||
|
let result: DomainEvent[] | DomainError;
|
||||||
|
|
||||||
|
switch (action.type) {
|
||||||
|
case "advance-turn":
|
||||||
|
result = advanceTurnUseCase(store);
|
||||||
|
break;
|
||||||
|
case "retreat-turn":
|
||||||
|
result = retreatTurnUseCase(store);
|
||||||
|
break;
|
||||||
|
case "add-combatant": {
|
||||||
|
const id = combatantId(`c-${state.nextId + 1}`);
|
||||||
|
result = addCombatantUseCase(store, id, action.name, action.init);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "remove-combatant":
|
||||||
|
result = removeCombatantUseCase(store, action.id);
|
||||||
|
break;
|
||||||
|
case "edit-combatant":
|
||||||
|
result = editCombatantUseCase(store, action.id, action.newName);
|
||||||
|
break;
|
||||||
|
case "set-initiative":
|
||||||
|
result = setInitiativeUseCase(store, action.id, action.value);
|
||||||
|
break;
|
||||||
|
case "set-hp":
|
||||||
|
result = setHpUseCase(store, action.id, action.maxHp);
|
||||||
|
break;
|
||||||
|
case "adjust-hp":
|
||||||
|
result = adjustHpUseCase(store, action.id, action.delta);
|
||||||
|
break;
|
||||||
|
case "set-temp-hp":
|
||||||
|
result = setTempHpUseCase(store, action.id, action.tempHp);
|
||||||
|
break;
|
||||||
|
case "set-ac":
|
||||||
|
result = setAcUseCase(store, action.id, action.value);
|
||||||
|
break;
|
||||||
|
case "toggle-condition":
|
||||||
|
result = toggleConditionUseCase(store, action.id, action.conditionId);
|
||||||
|
break;
|
||||||
|
case "toggle-concentration":
|
||||||
|
result = toggleConcentrationUseCase(store, action.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDomainError(result)) return state;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
encounter: getEncounter(),
|
||||||
|
undoRedoState: pushUndo(state.undoRedoState, state.encounter),
|
||||||
|
events: [...state.events, ...result],
|
||||||
|
nextId: action.type === "add-combatant" ? state.nextId + 1 : state.nextId,
|
||||||
|
lastCreatureId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Hook --
|
||||||
|
|
||||||
export function useEncounter() {
|
export function useEncounter() {
|
||||||
const [encounter, setEncounter] = useState<Encounter>(initializeEncounter);
|
const [state, dispatch] = useReducer(encounterReducer, null, initializeState);
|
||||||
const [events, setEvents] = useState<DomainEvent[]>([]);
|
const { encounter, undoRedoState, events } = state;
|
||||||
const [undoRedoState, setUndoRedoState] =
|
|
||||||
useState<UndoRedoState>(loadUndoRedoStacks);
|
|
||||||
const encounterRef = useRef(encounter);
|
const encounterRef = useRef(encounter);
|
||||||
encounterRef.current = encounter;
|
encounterRef.current = encounter;
|
||||||
const undoRedoRef = useRef(undoRedoState);
|
const undoRedoRef = useRef(undoRedoState);
|
||||||
@@ -89,22 +401,17 @@ export function useEncounter() {
|
|||||||
saveUndoRedoStacks(undoRedoState);
|
saveUndoRedoStacks(undoRedoState);
|
||||||
}, [undoRedoState]);
|
}, [undoRedoState]);
|
||||||
|
|
||||||
|
// Escape hatches for useInitiativeRolls (needs raw port access)
|
||||||
const makeStore = useCallback((): EncounterStore => {
|
const makeStore = useCallback((): EncounterStore => {
|
||||||
return {
|
return {
|
||||||
get: () => encounterRef.current,
|
get: () => encounterRef.current,
|
||||||
save: (e) => {
|
save: (e) => {
|
||||||
encounterRef.current = e;
|
encounterRef.current = e;
|
||||||
setEncounter(e);
|
dispatch({
|
||||||
},
|
type: "import",
|
||||||
};
|
encounter: e,
|
||||||
}, []);
|
undoRedoState: undoRedoRef.current,
|
||||||
|
});
|
||||||
const makeUndoRedoStore = useCallback((): UndoRedoStore => {
|
|
||||||
return {
|
|
||||||
get: () => undoRedoRef.current,
|
|
||||||
save: (s) => {
|
|
||||||
undoRedoRef.current = s;
|
|
||||||
setUndoRedoState(s);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
@@ -115,286 +422,21 @@ export function useEncounter() {
|
|||||||
if (!isDomainError(result)) {
|
if (!isDomainError(result)) {
|
||||||
const newState = pushUndo(undoRedoRef.current, snapshot);
|
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||||
undoRedoRef.current = newState;
|
undoRedoRef.current = newState;
|
||||||
setUndoRedoState(newState);
|
dispatch({
|
||||||
|
type: "import",
|
||||||
|
encounter: encounterRef.current,
|
||||||
|
undoRedoState: newState,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const advanceTurn = useCallback(() => {
|
// Derived state
|
||||||
const result = withUndo(() => advanceTurnUseCase(makeStore()));
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
|
||||||
}, [makeStore, withUndo]);
|
|
||||||
|
|
||||||
const retreatTurn = useCallback(() => {
|
|
||||||
const result = withUndo(() => retreatTurnUseCase(makeStore()));
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
|
||||||
}, [makeStore, withUndo]);
|
|
||||||
|
|
||||||
const nextId = useRef(deriveNextId(encounter));
|
|
||||||
|
|
||||||
const addCombatant = useCallback(
|
|
||||||
(name: string, init?: CombatantInit) => {
|
|
||||||
const id = combatantId(`c-${++nextId.current}`);
|
|
||||||
const result = withUndo(() =>
|
|
||||||
addCombatantUseCase(makeStore(), id, name, init),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
|
||||||
},
|
|
||||||
[makeStore, withUndo],
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeCombatant = useCallback(
|
|
||||||
(id: CombatantId) => {
|
|
||||||
const result = withUndo(() => removeCombatantUseCase(makeStore(), id));
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
|
||||||
},
|
|
||||||
[makeStore, withUndo],
|
|
||||||
);
|
|
||||||
|
|
||||||
const editCombatant = useCallback(
|
|
||||||
(id: CombatantId, newName: string) => {
|
|
||||||
const result = withUndo(() =>
|
|
||||||
editCombatantUseCase(makeStore(), id, newName),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
|
||||||
},
|
|
||||||
[makeStore, withUndo],
|
|
||||||
);
|
|
||||||
|
|
||||||
const setInitiative = useCallback(
|
|
||||||
(id: CombatantId, value: number | undefined) => {
|
|
||||||
const result = withUndo(() =>
|
|
||||||
setInitiativeUseCase(makeStore(), id, value),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
|
||||||
},
|
|
||||||
[makeStore, withUndo],
|
|
||||||
);
|
|
||||||
|
|
||||||
const setHp = useCallback(
|
|
||||||
(id: CombatantId, maxHp: number | undefined) => {
|
|
||||||
const result = withUndo(() => setHpUseCase(makeStore(), id, maxHp));
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
|
||||||
},
|
|
||||||
[makeStore, withUndo],
|
|
||||||
);
|
|
||||||
|
|
||||||
const adjustHp = useCallback(
|
|
||||||
(id: CombatantId, delta: number) => {
|
|
||||||
const result = withUndo(() => adjustHpUseCase(makeStore(), id, delta));
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
|
||||||
},
|
|
||||||
[makeStore, withUndo],
|
|
||||||
);
|
|
||||||
|
|
||||||
const setTempHp = useCallback(
|
|
||||||
(id: CombatantId, tempHp: number | undefined) => {
|
|
||||||
const result = withUndo(() => setTempHpUseCase(makeStore(), id, tempHp));
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
|
||||||
},
|
|
||||||
[makeStore, withUndo],
|
|
||||||
);
|
|
||||||
|
|
||||||
const setAc = useCallback(
|
|
||||||
(id: CombatantId, value: number | undefined) => {
|
|
||||||
const result = withUndo(() => setAcUseCase(makeStore(), id, value));
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
|
||||||
},
|
|
||||||
[makeStore, withUndo],
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleCondition = useCallback(
|
|
||||||
(id: CombatantId, conditionId: ConditionId) => {
|
|
||||||
const result = withUndo(() =>
|
|
||||||
toggleConditionUseCase(makeStore(), id, conditionId),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
|
||||||
},
|
|
||||||
[makeStore, withUndo],
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleConcentration = useCallback(
|
|
||||||
(id: CombatantId) => {
|
|
||||||
const result = withUndo(() =>
|
|
||||||
toggleConcentrationUseCase(makeStore(), id),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
|
||||||
},
|
|
||||||
[makeStore, withUndo],
|
|
||||||
);
|
|
||||||
|
|
||||||
const clearEncounter = useCallback(() => {
|
|
||||||
const result = clearEncounterUseCase(makeStore());
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleared = clearHistory();
|
|
||||||
undoRedoRef.current = cleared;
|
|
||||||
setUndoRedoState(cleared);
|
|
||||||
|
|
||||||
nextId.current = 0;
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
|
||||||
}, [makeStore]);
|
|
||||||
|
|
||||||
const addFromBestiary = useCallback(
|
|
||||||
(entry: BestiaryIndexEntry): CreatureId | null => {
|
|
||||||
const snapshot = encounterRef.current;
|
|
||||||
const store = makeStore();
|
|
||||||
const existingNames = store.get().combatants.map((c) => c.name);
|
|
||||||
const { newName, renames } = resolveCreatureName(
|
|
||||||
entry.name,
|
|
||||||
existingNames,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const { from, to } of renames) {
|
|
||||||
const target = store.get().combatants.find((c) => c.name === from);
|
|
||||||
if (target) {
|
|
||||||
editCombatantUseCase(makeStore(), target.id, to);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const slug = entry.name
|
|
||||||
.toLowerCase()
|
|
||||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
|
||||||
.replaceAll(/(^-|-$)/g, "");
|
|
||||||
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
|
|
||||||
|
|
||||||
const id = combatantId(`c-${++nextId.current}`);
|
|
||||||
const result = addCombatantUseCase(makeStore(), id, newName, {
|
|
||||||
maxHp: entry.hp,
|
|
||||||
ac: entry.ac > 0 ? entry.ac : undefined,
|
|
||||||
creatureId: cId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
|
||||||
store.save(snapshot);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newState = pushUndo(undoRedoRef.current, snapshot);
|
|
||||||
undoRedoRef.current = newState;
|
|
||||||
setUndoRedoState(newState);
|
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
|
||||||
return cId;
|
|
||||||
},
|
|
||||||
[makeStore],
|
|
||||||
);
|
|
||||||
|
|
||||||
const addFromPlayerCharacter = useCallback(
|
|
||||||
(pc: PlayerCharacter) => {
|
|
||||||
const snapshot = encounterRef.current;
|
|
||||||
const store = makeStore();
|
|
||||||
const existingNames = store.get().combatants.map((c) => c.name);
|
|
||||||
const { newName, renames } = resolveCreatureName(pc.name, existingNames);
|
|
||||||
|
|
||||||
for (const { from, to } of renames) {
|
|
||||||
const target = store.get().combatants.find((c) => c.name === from);
|
|
||||||
if (target) {
|
|
||||||
editCombatantUseCase(makeStore(), target.id, to);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = combatantId(`c-${++nextId.current}`);
|
|
||||||
const result = addCombatantUseCase(makeStore(), id, newName, {
|
|
||||||
maxHp: pc.maxHp,
|
|
||||||
ac: pc.ac > 0 ? pc.ac : undefined,
|
|
||||||
color: pc.color,
|
|
||||||
icon: pc.icon,
|
|
||||||
playerCharacterId: pc.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
|
||||||
store.save(snapshot);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newState = pushUndo(undoRedoRef.current, snapshot);
|
|
||||||
undoRedoRef.current = newState;
|
|
||||||
setUndoRedoState(newState);
|
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
|
||||||
},
|
|
||||||
[makeStore],
|
|
||||||
);
|
|
||||||
|
|
||||||
const undoAction = useCallback(() => {
|
|
||||||
undoUseCase(makeStore(), makeUndoRedoStore());
|
|
||||||
}, [makeStore, makeUndoRedoStore]);
|
|
||||||
|
|
||||||
const redoAction = useCallback(() => {
|
|
||||||
redoUseCase(makeStore(), makeUndoRedoStore());
|
|
||||||
}, [makeStore, makeUndoRedoStore]);
|
|
||||||
|
|
||||||
const canUndo = undoRedoState.undoStack.length > 0;
|
const canUndo = undoRedoState.undoStack.length > 0;
|
||||||
const canRedo = undoRedoState.redoStack.length > 0;
|
const canRedo = undoRedoState.redoStack.length > 0;
|
||||||
|
|
||||||
const hasTempHp = encounter.combatants.some(
|
const hasTempHp = encounter.combatants.some(
|
||||||
(c) => c.tempHp !== undefined && c.tempHp > 0,
|
(c) => c.tempHp !== undefined && c.tempHp > 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
const isEmpty = encounter.combatants.length === 0;
|
const isEmpty = encounter.combatants.length === 0;
|
||||||
const hasCreatureCombatants = encounter.combatants.some(
|
const hasCreatureCombatants = encounter.combatants.some(
|
||||||
(c) => c.creatureId != null,
|
(c) => c.creatureId != null,
|
||||||
@@ -405,6 +447,7 @@ export function useEncounter() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
encounter,
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
events,
|
events,
|
||||||
isEmpty,
|
isEmpty,
|
||||||
hasTempHp,
|
hasTempHp,
|
||||||
@@ -412,23 +455,105 @@ export function useEncounter() {
|
|||||||
canRollAllInitiative,
|
canRollAllInitiative,
|
||||||
canUndo,
|
canUndo,
|
||||||
canRedo,
|
canRedo,
|
||||||
advanceTurn,
|
advanceTurn: useCallback(() => dispatch({ type: "advance-turn" }), []),
|
||||||
retreatTurn,
|
retreatTurn: useCallback(() => dispatch({ type: "retreat-turn" }), []),
|
||||||
addCombatant,
|
addCombatant: useCallback(
|
||||||
clearEncounter,
|
(name: string, init?: CombatantInit) =>
|
||||||
removeCombatant,
|
dispatch({ type: "add-combatant", name, init }),
|
||||||
editCombatant,
|
[],
|
||||||
setInitiative,
|
),
|
||||||
setHp,
|
removeCombatant: useCallback(
|
||||||
adjustHp,
|
(id: CombatantId) => dispatch({ type: "remove-combatant", id }),
|
||||||
setTempHp,
|
[],
|
||||||
setAc,
|
),
|
||||||
toggleCondition,
|
editCombatant: useCallback(
|
||||||
toggleConcentration,
|
(id: CombatantId, newName: string) =>
|
||||||
addFromBestiary,
|
dispatch({ type: "edit-combatant", id, newName }),
|
||||||
addFromPlayerCharacter,
|
[],
|
||||||
undo: undoAction,
|
),
|
||||||
redo: redoAction,
|
setInitiative: useCallback(
|
||||||
|
(id: CombatantId, value: number | undefined) =>
|
||||||
|
dispatch({ type: "set-initiative", id, value }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
setHp: useCallback(
|
||||||
|
(id: CombatantId, maxHp: number | undefined) =>
|
||||||
|
dispatch({ type: "set-hp", id, maxHp }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
adjustHp: useCallback(
|
||||||
|
(id: CombatantId, delta: number) =>
|
||||||
|
dispatch({ type: "adjust-hp", id, delta }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
setTempHp: useCallback(
|
||||||
|
(id: CombatantId, tempHp: number | undefined) =>
|
||||||
|
dispatch({ type: "set-temp-hp", id, tempHp }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
setAc: useCallback(
|
||||||
|
(id: CombatantId, value: number | undefined) =>
|
||||||
|
dispatch({ type: "set-ac", id, value }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
toggleCondition: useCallback(
|
||||||
|
(id: CombatantId, conditionId: ConditionId) =>
|
||||||
|
dispatch({ type: "toggle-condition", id, conditionId }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
toggleConcentration: useCallback(
|
||||||
|
(id: CombatantId) => dispatch({ type: "toggle-concentration", id }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
clearEncounter: useCallback(
|
||||||
|
() => dispatch({ type: "clear-encounter" }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
addFromBestiary: useCallback(
|
||||||
|
(entry: BestiaryIndexEntry): CreatureId | null => {
|
||||||
|
dispatch({ type: "add-from-bestiary", entry });
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
addMultipleFromBestiary: useCallback(
|
||||||
|
(entry: BestiaryIndexEntry, count: number): CreatureId | null => {
|
||||||
|
dispatch({
|
||||||
|
type: "add-multiple-from-bestiary",
|
||||||
|
entry,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
addFromPlayerCharacter: useCallback(
|
||||||
|
(pc: PlayerCharacter) =>
|
||||||
|
dispatch({ type: "add-from-player-character", pc }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
undo: useCallback(() => dispatch({ type: "undo" }), []),
|
||||||
|
redo: useCallback(() => dispatch({ type: "redo" }), []),
|
||||||
|
setEncounter: useCallback(
|
||||||
|
(enc: Encounter) =>
|
||||||
|
dispatch({
|
||||||
|
type: "import",
|
||||||
|
encounter: enc,
|
||||||
|
undoRedoState: undoRedoRef.current,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
setUndoRedoState: useCallback(
|
||||||
|
(urs: UndoRedoState) =>
|
||||||
|
dispatch({
|
||||||
|
type: "import",
|
||||||
|
encounter: encounterRef.current,
|
||||||
|
undoRedoState: urs,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
),
|
||||||
makeStore,
|
makeStore,
|
||||||
|
withUndo,
|
||||||
|
lastCreatureId: state.lastCreatureId,
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function rollDice(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useInitiativeRolls() {
|
export function useInitiativeRolls() {
|
||||||
const { encounter, makeStore } = useEncounterContext();
|
const { encounter, makeStore, withUndo } = useEncounterContext();
|
||||||
const { getCreature } = useBestiaryContext();
|
const { getCreature } = useBestiaryContext();
|
||||||
const { showCreature } = useSidePanelContext();
|
const { showCreature } = useSidePanelContext();
|
||||||
|
|
||||||
@@ -28,12 +28,8 @@ export function useInitiativeRolls() {
|
|||||||
(id: CombatantId, mode: RollMode = "normal") => {
|
(id: CombatantId, mode: RollMode = "normal") => {
|
||||||
const diceRolls: [number, ...number[]] =
|
const diceRolls: [number, ...number[]] =
|
||||||
mode === "normal" ? [rollDice()] : [rollDice(), rollDice()];
|
mode === "normal" ? [rollDice()] : [rollDice(), rollDice()];
|
||||||
const result = rollInitiativeUseCase(
|
const result = withUndo(() =>
|
||||||
makeStore(),
|
rollInitiativeUseCase(makeStore(), id, diceRolls, getCreature, mode),
|
||||||
id,
|
|
||||||
diceRolls,
|
|
||||||
getCreature,
|
|
||||||
mode,
|
|
||||||
);
|
);
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
setRollSingleSkipped(true);
|
setRollSingleSkipped(true);
|
||||||
@@ -43,22 +39,19 @@ export function useInitiativeRolls() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[makeStore, getCreature, encounter.combatants, showCreature],
|
[makeStore, getCreature, withUndo, encounter.combatants, showCreature],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRollAllInitiative = useCallback(
|
const handleRollAllInitiative = useCallback(
|
||||||
(mode: RollMode = "normal") => {
|
(mode: RollMode = "normal") => {
|
||||||
const result = rollAllInitiativeUseCase(
|
const result = withUndo(() =>
|
||||||
makeStore(),
|
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature, mode),
|
||||||
rollDice,
|
|
||||||
getCreature,
|
|
||||||
mode,
|
|
||||||
);
|
);
|
||||||
if (!isDomainError(result) && result.skippedNoSource > 0) {
|
if (!isDomainError(result) && result.skippedNoSource > 0) {
|
||||||
setRollSkippedCount(result.skippedNoSource);
|
setRollSkippedCount(result.skippedNoSource);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[makeStore, getCreature],
|
[makeStore, getCreature, withUndo],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ interface EditFields {
|
|||||||
readonly maxHp?: number;
|
readonly maxHp?: number;
|
||||||
readonly color?: string | null;
|
readonly color?: string | null;
|
||||||
readonly icon?: string | null;
|
readonly icon?: string | null;
|
||||||
|
readonly level?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePlayerCharacters() {
|
export function usePlayerCharacters() {
|
||||||
@@ -57,6 +58,7 @@ export function usePlayerCharacters() {
|
|||||||
maxHp: number,
|
maxHp: number,
|
||||||
color: string | undefined,
|
color: string | undefined,
|
||||||
icon: string | undefined,
|
icon: string | undefined,
|
||||||
|
level: number | undefined,
|
||||||
) => {
|
) => {
|
||||||
const id = generatePcId();
|
const id = generatePcId();
|
||||||
const result = createPlayerCharacterUseCase(
|
const result = createPlayerCharacterUseCase(
|
||||||
@@ -67,6 +69,7 @@ export function usePlayerCharacters() {
|
|||||||
maxHp,
|
maxHp,
|
||||||
color,
|
color,
|
||||||
icon,
|
icon,
|
||||||
|
level,
|
||||||
);
|
);
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
return result;
|
return result;
|
||||||
@@ -103,6 +106,7 @@ export function usePlayerCharacters() {
|
|||||||
createCharacter,
|
createCharacter,
|
||||||
editCharacter,
|
editCharacter,
|
||||||
deleteCharacter,
|
deleteCharacter,
|
||||||
|
replacePlayerCharacters: setCharacters,
|
||||||
makeStore,
|
makeStore,
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,64 +122,7 @@ describe("loadEncounter", () => {
|
|||||||
expect(loadEncounter()).toBeNull();
|
expect(loadEncounter()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
// US3: Corrupt data scenarios
|
it("returns null when combatant has invalid required fields", () => {
|
||||||
it("returns null for non-object JSON (string)", () => {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify("hello"));
|
|
||||||
expect(loadEncounter()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for non-object JSON (number)", () => {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(42));
|
|
||||||
expect(loadEncounter()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for non-object JSON (array)", () => {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([1, 2, 3]));
|
|
||||||
expect(loadEncounter()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for non-object JSON (null)", () => {
|
|
||||||
localStorage.setItem(STORAGE_KEY, "null");
|
|
||||||
expect(loadEncounter()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when combatants is a string instead of array", () => {
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify({
|
|
||||||
combatants: "not-array",
|
|
||||||
activeIndex: 0,
|
|
||||||
roundNumber: 1,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(loadEncounter()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when activeIndex is a string instead of number", () => {
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify({
|
|
||||||
combatants: [{ id: "1", name: "Aria" }],
|
|
||||||
activeIndex: "zero",
|
|
||||||
roundNumber: 1,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(loadEncounter()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when combatant entry is missing id", () => {
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify({
|
|
||||||
combatants: [{ name: "Aria" }],
|
|
||||||
activeIndex: 0,
|
|
||||||
roundNumber: 1,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(loadEncounter()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when combatant entry is missing name", () => {
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
STORAGE_KEY,
|
STORAGE_KEY,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -191,88 +134,6 @@ describe("loadEncounter", () => {
|
|||||||
expect(loadEncounter()).toBeNull();
|
expect(loadEncounter()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for negative roundNumber", () => {
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify({
|
|
||||||
combatants: [{ id: "1", name: "Aria" }],
|
|
||||||
activeIndex: 0,
|
|
||||||
roundNumber: -1,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(loadEncounter()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty encounter for zero combatants (cleared state)", () => {
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify({ combatants: [], activeIndex: 0, roundNumber: 1 }),
|
|
||||||
);
|
|
||||||
const result = loadEncounter();
|
|
||||||
expect(result).toEqual({
|
|
||||||
combatants: [],
|
|
||||||
activeIndex: 0,
|
|
||||||
roundNumber: 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("round-trip preserves combatant AC value", () => {
|
|
||||||
const result = createEncounter(
|
|
||||||
[{ id: combatantId("1"), name: "Aria", ac: 18 }],
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
if (isDomainError(result)) throw new Error("unreachable");
|
|
||||||
saveEncounter(result);
|
|
||||||
const loaded = loadEncounter();
|
|
||||||
expect(loaded?.combatants[0].ac).toBe(18);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("round-trip preserves combatant without AC", () => {
|
|
||||||
const result = createEncounter(
|
|
||||||
[{ id: combatantId("1"), name: "Aria" }],
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
if (isDomainError(result)) throw new Error("unreachable");
|
|
||||||
saveEncounter(result);
|
|
||||||
const loaded = loadEncounter();
|
|
||||||
expect(loaded?.combatants[0].ac).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("discards invalid AC values during rehydration", () => {
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify({
|
|
||||||
combatants: [
|
|
||||||
{ id: "1", name: "Neg", ac: -1 },
|
|
||||||
{ id: "2", name: "Float", ac: 3.5 },
|
|
||||||
{ id: "3", name: "Str", ac: "high" },
|
|
||||||
],
|
|
||||||
activeIndex: 0,
|
|
||||||
roundNumber: 1,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const loaded = loadEncounter();
|
|
||||||
expect(loaded).not.toBeNull();
|
|
||||||
expect(loaded?.combatants[0].ac).toBeUndefined();
|
|
||||||
expect(loaded?.combatants[1].ac).toBeUndefined();
|
|
||||||
expect(loaded?.combatants[2].ac).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves AC of 0 during rehydration", () => {
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify({
|
|
||||||
combatants: [{ id: "1", name: "Aria", ac: 0 }],
|
|
||||||
activeIndex: 0,
|
|
||||||
roundNumber: 1,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const loaded = loadEncounter();
|
|
||||||
expect(loaded?.combatants[0].ac).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("saving after modifications persists the latest state", () => {
|
it("saving after modifications persists the latest state", () => {
|
||||||
const encounter = makeEncounter();
|
const encounter = makeEncounter();
|
||||||
saveEncounter(encounter);
|
saveEncounter(encounter);
|
||||||
|
|||||||
@@ -90,102 +90,7 @@ describe("player-character-storage", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("per-character validation", () => {
|
describe("delegation to domain rehydration", () => {
|
||||||
it("discards character with missing name", () => {
|
|
||||||
mockStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify([
|
|
||||||
{ id: "pc-1", ac: 10, maxHp: 50, color: "blue", icon: "sword" },
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
expect(loadPlayerCharacters()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("discards character with empty name", () => {
|
|
||||||
mockStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify([
|
|
||||||
{
|
|
||||||
id: "pc-1",
|
|
||||||
name: "",
|
|
||||||
ac: 10,
|
|
||||||
maxHp: 50,
|
|
||||||
color: "blue",
|
|
||||||
icon: "sword",
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
expect(loadPlayerCharacters()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("discards character with invalid color", () => {
|
|
||||||
mockStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify([
|
|
||||||
{
|
|
||||||
id: "pc-1",
|
|
||||||
name: "Test",
|
|
||||||
ac: 10,
|
|
||||||
maxHp: 50,
|
|
||||||
color: "neon",
|
|
||||||
icon: "sword",
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
expect(loadPlayerCharacters()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("discards character with invalid icon", () => {
|
|
||||||
mockStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify([
|
|
||||||
{
|
|
||||||
id: "pc-1",
|
|
||||||
name: "Test",
|
|
||||||
ac: 10,
|
|
||||||
maxHp: 50,
|
|
||||||
color: "blue",
|
|
||||||
icon: "banana",
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
expect(loadPlayerCharacters()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("discards character with negative AC", () => {
|
|
||||||
mockStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify([
|
|
||||||
{
|
|
||||||
id: "pc-1",
|
|
||||||
name: "Test",
|
|
||||||
ac: -1,
|
|
||||||
maxHp: 50,
|
|
||||||
color: "blue",
|
|
||||||
icon: "sword",
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
expect(loadPlayerCharacters()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("discards character with maxHp of 0", () => {
|
|
||||||
mockStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify([
|
|
||||||
{
|
|
||||||
id: "pc-1",
|
|
||||||
name: "Test",
|
|
||||||
ac: 10,
|
|
||||||
maxHp: 0,
|
|
||||||
color: "blue",
|
|
||||||
icon: "sword",
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
expect(loadPlayerCharacters()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps valid characters and discards invalid ones", () => {
|
it("keeps valid characters and discards invalid ones", () => {
|
||||||
mockStorage.setItem(
|
mockStorage.setItem(
|
||||||
STORAGE_KEY,
|
STORAGE_KEY,
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
type ConditionId,
|
type Combatant,
|
||||||
combatantId,
|
|
||||||
createEncounter,
|
createEncounter,
|
||||||
creatureId,
|
|
||||||
type Encounter,
|
type Encounter,
|
||||||
isDomainError,
|
isDomainError,
|
||||||
playerCharacterId,
|
rehydrateCombatant,
|
||||||
VALID_CONDITION_IDS,
|
|
||||||
VALID_PLAYER_COLORS,
|
|
||||||
VALID_PLAYER_ICONS,
|
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
|
|
||||||
const STORAGE_KEY = "initiative:encounter";
|
const STORAGE_KEY = "initiative:encounter";
|
||||||
@@ -21,93 +16,6 @@ export function saveEncounter(encounter: Encounter): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateAc(value: unknown): number | undefined {
|
|
||||||
return typeof value === "number" && Number.isInteger(value) && value >= 0
|
|
||||||
? value
|
|
||||||
: undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateConditions(value: unknown): ConditionId[] | undefined {
|
|
||||||
if (!Array.isArray(value)) return undefined;
|
|
||||||
const valid = value.filter(
|
|
||||||
(v): v is ConditionId =>
|
|
||||||
typeof v === "string" && VALID_CONDITION_IDS.has(v),
|
|
||||||
);
|
|
||||||
return valid.length > 0 ? valid : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateCreatureId(value: unknown) {
|
|
||||||
return typeof value === "string" && value.length > 0
|
|
||||||
? creatureId(value)
|
|
||||||
: undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateHp(
|
|
||||||
rawMaxHp: unknown,
|
|
||||||
rawCurrentHp: unknown,
|
|
||||||
): { maxHp: number; currentHp: number } | undefined {
|
|
||||||
if (
|
|
||||||
typeof rawMaxHp !== "number" ||
|
|
||||||
!Number.isInteger(rawMaxHp) ||
|
|
||||||
rawMaxHp < 1
|
|
||||||
) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const validCurrentHp =
|
|
||||||
typeof rawCurrentHp === "number" &&
|
|
||||||
Number.isInteger(rawCurrentHp) &&
|
|
||||||
rawCurrentHp >= 0 &&
|
|
||||||
rawCurrentHp <= rawMaxHp;
|
|
||||||
return {
|
|
||||||
maxHp: rawMaxHp,
|
|
||||||
currentHp: validCurrentHp ? rawCurrentHp : rawMaxHp,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function rehydrateCombatant(c: unknown) {
|
|
||||||
const entry = c as Record<string, unknown>;
|
|
||||||
const base = {
|
|
||||||
id: combatantId(entry.id as string),
|
|
||||||
name: entry.name as string,
|
|
||||||
initiative:
|
|
||||||
typeof entry.initiative === "number" ? entry.initiative : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const color =
|
|
||||||
typeof entry.color === "string" && VALID_PLAYER_COLORS.has(entry.color)
|
|
||||||
? entry.color
|
|
||||||
: undefined;
|
|
||||||
const icon =
|
|
||||||
typeof entry.icon === "string" && VALID_PLAYER_ICONS.has(entry.icon)
|
|
||||||
? entry.icon
|
|
||||||
: undefined;
|
|
||||||
const pcId =
|
|
||||||
typeof entry.playerCharacterId === "string" &&
|
|
||||||
entry.playerCharacterId.length > 0
|
|
||||||
? playerCharacterId(entry.playerCharacterId)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const shared = {
|
|
||||||
...base,
|
|
||||||
ac: validateAc(entry.ac),
|
|
||||||
conditions: validateConditions(entry.conditions),
|
|
||||||
isConcentrating: entry.isConcentrating === true ? true : undefined,
|
|
||||||
creatureId: validateCreatureId(entry.creatureId),
|
|
||||||
color,
|
|
||||||
icon,
|
|
||||||
playerCharacterId: pcId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const hp = validateHp(entry.maxHp, entry.currentHp);
|
|
||||||
return hp ? { ...shared, ...hp } : shared;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidCombatantEntry(c: unknown): boolean {
|
|
||||||
if (typeof c !== "object" || c === null || Array.isArray(c)) return false;
|
|
||||||
const entry = c as Record<string, unknown>;
|
|
||||||
return typeof entry.id === "string" && typeof entry.name === "string";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function rehydrateEncounter(parsed: unknown): Encounter | null {
|
export function rehydrateEncounter(parsed: unknown): Encounter | null {
|
||||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
||||||
return null;
|
return null;
|
||||||
@@ -129,14 +37,21 @@ export function rehydrateEncounter(parsed: unknown): Encounter | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!combatants.every(isValidCombatantEntry)) return null;
|
const rehydrated: Combatant[] = [];
|
||||||
|
for (const c of combatants) {
|
||||||
|
const result = rehydrateCombatant(c);
|
||||||
|
if (result === null) return null;
|
||||||
|
rehydrated.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
const rehydrated = combatants.map(rehydrateCombatant);
|
const encounter = createEncounter(
|
||||||
|
rehydrated,
|
||||||
|
obj.activeIndex,
|
||||||
|
obj.roundNumber,
|
||||||
|
);
|
||||||
|
if (isDomainError(encounter)) return null;
|
||||||
|
|
||||||
const result = createEncounter(rehydrated, obj.activeIndex, obj.roundNumber);
|
return encounter;
|
||||||
if (isDomainError(result)) return null;
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadEncounter(): Encounter | null {
|
export function loadEncounter(): Encounter | null {
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import type {
|
||||||
|
Encounter,
|
||||||
|
ExportBundle,
|
||||||
|
PlayerCharacter,
|
||||||
|
UndoRedoState,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { rehydratePlayerCharacter } from "@initiative/domain";
|
||||||
|
import { rehydrateEncounter } from "./encounter-storage.js";
|
||||||
|
|
||||||
|
function rehydrateStack(raw: unknown[]): Encounter[] {
|
||||||
|
const result: Encounter[] = [];
|
||||||
|
for (const entry of raw) {
|
||||||
|
const rehydrated = rehydrateEncounter(entry);
|
||||||
|
if (rehydrated !== null) {
|
||||||
|
result.push(rehydrated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rehydrateCharacters(raw: unknown[]): PlayerCharacter[] {
|
||||||
|
const result: PlayerCharacter[] = [];
|
||||||
|
for (const entry of raw) {
|
||||||
|
const rehydrated = rehydratePlayerCharacter(entry);
|
||||||
|
if (rehydrated !== null) {
|
||||||
|
result.push(rehydrated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateImportBundle(data: unknown): ExportBundle | string {
|
||||||
|
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
||||||
|
return "Invalid file format";
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = data as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (typeof obj.version !== "number" || obj.version !== 1) {
|
||||||
|
return "Invalid file format";
|
||||||
|
}
|
||||||
|
if (typeof obj.exportedAt !== "string") {
|
||||||
|
return "Invalid file format";
|
||||||
|
}
|
||||||
|
if (!Array.isArray(obj.undoStack) || !Array.isArray(obj.redoStack)) {
|
||||||
|
return "Invalid file format";
|
||||||
|
}
|
||||||
|
if (!Array.isArray(obj.playerCharacters)) {
|
||||||
|
return "Invalid file format";
|
||||||
|
}
|
||||||
|
|
||||||
|
const encounter = rehydrateEncounter(obj.encounter);
|
||||||
|
if (encounter === null) {
|
||||||
|
return "Invalid encounter data";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
exportedAt: obj.exportedAt,
|
||||||
|
encounter,
|
||||||
|
undoStack: rehydrateStack(obj.undoStack),
|
||||||
|
redoStack: rehydrateStack(obj.redoStack),
|
||||||
|
playerCharacters: rehydrateCharacters(obj.playerCharacters),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assembleExportBundle(
|
||||||
|
encounter: Encounter,
|
||||||
|
undoRedoState: UndoRedoState,
|
||||||
|
playerCharacters: readonly PlayerCharacter[],
|
||||||
|
includeHistory = true,
|
||||||
|
): ExportBundle {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
encounter,
|
||||||
|
undoStack: includeHistory ? undoRedoState.undoStack : [],
|
||||||
|
redoStack: includeHistory ? undoRedoState.redoStack : [],
|
||||||
|
playerCharacters: [...playerCharacters],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bundleToJson(bundle: ExportBundle): string {
|
||||||
|
return JSON.stringify(bundle, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFilename(name?: string): string {
|
||||||
|
const base =
|
||||||
|
name?.trim() ||
|
||||||
|
`initiative-export-${new Date().toISOString().slice(0, 10)}`;
|
||||||
|
return base.endsWith(".json") ? base : `${base}.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triggerDownload(bundle: ExportBundle, name?: string): void {
|
||||||
|
const blob = new Blob([bundleToJson(bundle)], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
const filename = resolveFilename(name);
|
||||||
|
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = filename;
|
||||||
|
anchor.click();
|
||||||
|
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readImportFile(
|
||||||
|
file: File,
|
||||||
|
): Promise<ExportBundle | string> {
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const parsed: unknown = JSON.parse(text);
|
||||||
|
return validateImportBundle(parsed);
|
||||||
|
} catch {
|
||||||
|
return "Invalid file format";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
import type { PlayerCharacter } from "@initiative/domain";
|
import type { PlayerCharacter } from "@initiative/domain";
|
||||||
import {
|
import { rehydratePlayerCharacter } from "@initiative/domain";
|
||||||
playerCharacterId,
|
|
||||||
VALID_PLAYER_COLORS,
|
|
||||||
VALID_PLAYER_ICONS,
|
|
||||||
} from "@initiative/domain";
|
|
||||||
|
|
||||||
const STORAGE_KEY = "initiative:player-characters";
|
const STORAGE_KEY = "initiative:player-characters";
|
||||||
|
|
||||||
@@ -15,46 +11,6 @@ export function savePlayerCharacters(characters: PlayerCharacter[]): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidOptionalMember(
|
|
||||||
value: unknown,
|
|
||||||
valid: ReadonlySet<string>,
|
|
||||||
): boolean {
|
|
||||||
return value === undefined || (typeof value === "string" && valid.has(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
|
||||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
||||||
return null;
|
|
||||||
const entry = raw as Record<string, unknown>;
|
|
||||||
|
|
||||||
if (typeof entry.id !== "string" || entry.id.length === 0) return null;
|
|
||||||
if (typeof entry.name !== "string" || entry.name.trim().length === 0)
|
|
||||||
return null;
|
|
||||||
if (
|
|
||||||
typeof entry.ac !== "number" ||
|
|
||||||
!Number.isInteger(entry.ac) ||
|
|
||||||
entry.ac < 0
|
|
||||||
)
|
|
||||||
return null;
|
|
||||||
if (
|
|
||||||
typeof entry.maxHp !== "number" ||
|
|
||||||
!Number.isInteger(entry.maxHp) ||
|
|
||||||
entry.maxHp < 1
|
|
||||||
)
|
|
||||||
return null;
|
|
||||||
if (!isValidOptionalMember(entry.color, VALID_PLAYER_COLORS)) return null;
|
|
||||||
if (!isValidOptionalMember(entry.icon, VALID_PLAYER_ICONS)) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: playerCharacterId(entry.id),
|
|
||||||
name: entry.name,
|
|
||||||
ac: entry.ac,
|
|
||||||
maxHp: entry.maxHp,
|
|
||||||
color: entry.color as PlayerCharacter["color"],
|
|
||||||
icon: entry.icon as PlayerCharacter["icon"],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadPlayerCharacters(): PlayerCharacter[] {
|
export function loadPlayerCharacters(): PlayerCharacter[] {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
@@ -65,7 +21,7 @@ export function loadPlayerCharacters(): PlayerCharacter[] {
|
|||||||
|
|
||||||
const characters: PlayerCharacter[] = [];
|
const characters: PlayerCharacter[] = [];
|
||||||
for (const item of parsed) {
|
for (const item of parsed) {
|
||||||
const pc = rehydrateCharacter(item);
|
const pc = rehydratePlayerCharacter(item);
|
||||||
if (pc !== null) {
|
if (pc !== null) {
|
||||||
characters.push(pc);
|
characters.push(pc);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
---
|
||||||
|
date: 2026-03-28T01:35:07.925247+00:00
|
||||||
|
git_commit: f4fb69dbc763fefe4a73b3491c27093bbd06af0d
|
||||||
|
branch: main
|
||||||
|
topic: "Entity rehydration: current implementation and migration surface"
|
||||||
|
tags: [research, codebase, rehydration, persistence, domain, player-character, combatant]
|
||||||
|
status: complete
|
||||||
|
---
|
||||||
|
|
||||||
|
# Research: Entity Rehydration — Current Implementation and Migration Surface
|
||||||
|
|
||||||
|
## Research Question
|
||||||
|
|
||||||
|
Map all entity rehydration logic (reconstructing typed domain objects from untyped JSON) across the codebase. Document what validation each rehydration function performs, where it lives, how functions cross-reference each other, and what the domain layer already provides that could replace adapter-level validation. This research supports Issue #20: Move entity rehydration to domain layer.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Entity rehydration currently lives entirely in `apps/web/src/persistence/`. Two primary rehydration functions exist:
|
||||||
|
|
||||||
|
1. **`rehydrateCharacter`** in `player-character-storage.ts` — validates and reconstructs `PlayerCharacter` from unknown JSON
|
||||||
|
2. **`rehydrateCombatant`** (private) + **`rehydrateEncounter`** (exported) in `encounter-storage.ts` — validates and reconstructs `Combatant`/`Encounter` from unknown JSON
|
||||||
|
|
||||||
|
These are consumed by three call sites: localStorage loading, undo/redo stack loading, and JSON import validation. The domain layer already contains parallel validation logic in `createPlayerCharacter`, `addCombatant`/`validateInit`, and `createEncounter`, but the rehydration functions duplicate this validation with subtly different rules (rehydration is lenient/recovering; creation is strict/rejecting).
|
||||||
|
|
||||||
|
## Detailed Findings
|
||||||
|
|
||||||
|
### 1. PlayerCharacter Rehydration
|
||||||
|
|
||||||
|
**File**: `apps/web/src/persistence/player-character-storage.ts:25-65`
|
||||||
|
|
||||||
|
`rehydrateCharacter(raw: unknown): PlayerCharacter | null` performs:
|
||||||
|
|
||||||
|
| Field | Validation | Behavior on invalid |
|
||||||
|
|-------|-----------|-------------------|
|
||||||
|
| `id` | `typeof string`, non-empty | Return `null` (reject entire PC) |
|
||||||
|
| `name` | `typeof string`, non-empty after trim | Return `null` |
|
||||||
|
| `ac` | `typeof number`, integer, `>= 0` | Return `null` |
|
||||||
|
| `maxHp` | `typeof number`, integer, `>= 1` | Return `null` |
|
||||||
|
| `color` | Optional; if present, must be in `VALID_PLAYER_COLORS` | Return `null` |
|
||||||
|
| `icon` | Optional; if present, must be in `VALID_PLAYER_ICONS` | Return `null` |
|
||||||
|
| `level` | Optional; if present, must be integer 1-20 | Return `null` |
|
||||||
|
|
||||||
|
Constructs result via branded `playerCharacterId()` and type assertions for color/icon.
|
||||||
|
|
||||||
|
**Helper**: `isValidOptionalMember(value, validSet)` — shared check for optional set-membership fields (lines 18-23).
|
||||||
|
|
||||||
|
**Callers**:
|
||||||
|
- `loadPlayerCharacters()` (same file, line 67) — loads from localStorage
|
||||||
|
- `rehydrateCharacters()` in `export-import.ts:21-30` — filters PCs during import validation
|
||||||
|
|
||||||
|
### 2. Combatant Rehydration
|
||||||
|
|
||||||
|
**File**: `apps/web/src/persistence/encounter-storage.ts:67-103`
|
||||||
|
|
||||||
|
`rehydrateCombatant(c: unknown)` (private, no return type annotation) performs:
|
||||||
|
|
||||||
|
| Field | Validation | Behavior on invalid |
|
||||||
|
|-------|-----------|-------------------|
|
||||||
|
| `id` | Cast directly (`entry.id as string`) | No validation (relies on `isValidCombatantEntry` pre-check) |
|
||||||
|
| `name` | Cast directly (`entry.name as string`) | No validation (relies on pre-check) |
|
||||||
|
| `initiative` | `typeof number` or `undefined` | Defaults to `undefined` |
|
||||||
|
| `ac` | Via `validateAc`: integer `>= 0` | Defaults to `undefined` |
|
||||||
|
| `conditions` | Via `validateConditions`: array, each in `VALID_CONDITION_IDS` | Defaults to `undefined` |
|
||||||
|
| `isConcentrating` | Strictly `=== true` | Defaults to `undefined` |
|
||||||
|
| `creatureId` | Via `validateCreatureId`: non-empty string | Defaults to `undefined` |
|
||||||
|
| `color` | String in `VALID_PLAYER_COLORS` | Defaults to `undefined` |
|
||||||
|
| `icon` | String in `VALID_PLAYER_ICONS` | Defaults to `undefined` |
|
||||||
|
| `playerCharacterId` | Non-empty string | Defaults to `undefined` |
|
||||||
|
| `maxHp` / `currentHp` | Via `validateHp`: maxHp integer >= 1, currentHp integer 0..maxHp | Defaults to `undefined`; invalid currentHp falls back to maxHp |
|
||||||
|
|
||||||
|
**Key difference from PC rehydration**: Combatant rehydration is *lenient* — invalid optional fields are silently dropped rather than rejecting the entire entity. Only `id` and `name` are required (checked by `isValidCombatantEntry` at line 105-109 before `rehydrateCombatant` is called).
|
||||||
|
|
||||||
|
**Helper functions** (all private):
|
||||||
|
- `validateAc(value)` — lines 24-28
|
||||||
|
- `validateConditions(value)` — lines 30-37
|
||||||
|
- `validateCreatureId(value)` — lines 39-43
|
||||||
|
- `validateHp(rawMaxHp, rawCurrentHp)` — lines 45-65
|
||||||
|
|
||||||
|
### 3. Encounter Rehydration
|
||||||
|
|
||||||
|
**File**: `apps/web/src/persistence/encounter-storage.ts:111-140`
|
||||||
|
|
||||||
|
`rehydrateEncounter(parsed: unknown): Encounter | null` validates the encounter envelope:
|
||||||
|
- Must be a non-null, non-array object
|
||||||
|
- `combatants` must be an array
|
||||||
|
- `activeIndex` must be a number
|
||||||
|
- `roundNumber` must be a number
|
||||||
|
- Empty combatant array → returns hardcoded `{ combatants: [], activeIndex: 0, roundNumber: 1 }`
|
||||||
|
- All entries must pass `isValidCombatantEntry` (id + name check)
|
||||||
|
- Maps entries through `rehydrateCombatant`, then passes to domain's `createEncounter` for invariant enforcement
|
||||||
|
|
||||||
|
**Callers**:
|
||||||
|
- `loadEncounter()` (same file, line 142) — localStorage
|
||||||
|
- `loadStack()` in `undo-redo-storage.ts:17-36` — undo/redo stacks from localStorage
|
||||||
|
- `rehydrateStack()` in `export-import.ts:10-19` — import validation
|
||||||
|
- `validateImportBundle()` in `export-import.ts:32-65` — import validation (direct call for the main encounter)
|
||||||
|
|
||||||
|
### 4. Import Bundle Validation
|
||||||
|
|
||||||
|
**File**: `apps/web/src/persistence/export-import.ts:32-65`
|
||||||
|
|
||||||
|
`validateImportBundle(data: unknown): ExportBundle | string` validates the bundle envelope:
|
||||||
|
- Version must be `1`
|
||||||
|
- `exportedAt` must be a string
|
||||||
|
- `undoStack` and `redoStack` must be arrays
|
||||||
|
- `playerCharacters` must be an array
|
||||||
|
- Delegates to `rehydrateEncounter` for the encounter
|
||||||
|
- Delegates to `rehydrateStack` (which calls `rehydrateEncounter`) for undo/redo
|
||||||
|
- Delegates to `rehydrateCharacters` (which calls `rehydrateCharacter`) for PCs
|
||||||
|
|
||||||
|
This function validates the *envelope* structure. Entity-level validation is fully delegated.
|
||||||
|
|
||||||
|
### 5. Domain Layer Validation (Existing)
|
||||||
|
|
||||||
|
The domain already contains validation for the same fields, but in *creation* context (typed inputs, DomainError returns):
|
||||||
|
|
||||||
|
**`createPlayerCharacter`** (`packages/domain/src/create-player-character.ts:17-100`):
|
||||||
|
- Same field rules as `rehydrateCharacter`: name non-empty, ac >= 0 integer, maxHp >= 1 integer, color/icon in valid sets, level 1-20
|
||||||
|
- Returns `DomainError` on invalid input (not `null`)
|
||||||
|
|
||||||
|
**`validateInit`** in `addCombatant` (`packages/domain/src/add-combatant.ts:27-53`):
|
||||||
|
- Validates maxHp (positive integer), ac (non-negative integer), initiative (integer)
|
||||||
|
- Does NOT validate conditions, color, icon, playerCharacterId, creatureId, isConcentrating
|
||||||
|
|
||||||
|
**`createEncounter`** (`packages/domain/src/types.ts:50-71`):
|
||||||
|
- Validates activeIndex bounds and roundNumber (positive integer)
|
||||||
|
- Already used by `rehydrateEncounter` as the final step
|
||||||
|
|
||||||
|
**`editPlayerCharacter`** (`packages/domain/src/edit-player-character.ts`):
|
||||||
|
- `validateFields` validates the same PC fields for edits
|
||||||
|
|
||||||
|
### 6. Validation Overlap and Gaps
|
||||||
|
|
||||||
|
| Field | Rehydration validates | Domain validates |
|
||||||
|
|-------|----------------------|-----------------|
|
||||||
|
| PC.id | Non-empty string | N/A (caller provides) |
|
||||||
|
| PC.name | Non-empty string | Non-empty (trimmed) |
|
||||||
|
| PC.ac | Integer >= 0 | Integer >= 0 |
|
||||||
|
| PC.maxHp | Integer >= 1 | Integer >= 1 |
|
||||||
|
| PC.color | In VALID_PLAYER_COLORS | In VALID_PLAYER_COLORS |
|
||||||
|
| PC.icon | In VALID_PLAYER_ICONS | In VALID_PLAYER_ICONS |
|
||||||
|
| PC.level | Integer 1-20 | Integer 1-20 |
|
||||||
|
| Combatant.id | Non-empty string (via pre-check) | N/A (caller provides) |
|
||||||
|
| Combatant.name | String type (via pre-check) | Non-empty (trimmed) |
|
||||||
|
| Combatant.initiative | `typeof number` | Integer |
|
||||||
|
| Combatant.ac | Integer >= 0 | Integer >= 0 |
|
||||||
|
| Combatant.maxHp | Integer >= 1 | Integer >= 1 |
|
||||||
|
| Combatant.currentHp | Integer 0..maxHp | N/A (set = maxHp on add) |
|
||||||
|
| Combatant.tempHp | **Not validated** | N/A |
|
||||||
|
| Combatant.conditions | Each in VALID_CONDITION_IDS | N/A (toggleCondition checks) |
|
||||||
|
| Combatant.isConcentrating | Strictly `true` or dropped | N/A (toggleConcentration) |
|
||||||
|
| Combatant.creatureId | Non-empty string | N/A (passed through) |
|
||||||
|
| Combatant.color | In VALID_PLAYER_COLORS | N/A (passed through) |
|
||||||
|
| Combatant.icon | In VALID_PLAYER_ICONS | N/A (passed through) |
|
||||||
|
| Combatant.playerCharacterId | Non-empty string | N/A (passed through) |
|
||||||
|
|
||||||
|
Key observations:
|
||||||
|
- Rehydration validates `id` (required for identity); domain creation functions receive `id` as a typed parameter
|
||||||
|
- Combatant rehydration does NOT validate `tempHp` at all — it's silently passed through or ignored
|
||||||
|
- Combatant rehydration checks `initiative` as `typeof number` but domain checks `Number.isInteger` — slightly different strictness
|
||||||
|
- Domain validation for combatant optional fields is scattered across individual mutation functions, not centralized
|
||||||
|
|
||||||
|
### 7. Test Coverage
|
||||||
|
|
||||||
|
**Persistence tests** (adapter layer):
|
||||||
|
- `encounter-storage.test.ts` — ~27 tests covering round-trip, corrupt data, AC validation, edge cases
|
||||||
|
- `player-character-storage.test.ts` — ~17 tests covering round-trip, corrupt data, field validation, level
|
||||||
|
|
||||||
|
**Import tests** (adapter layer):
|
||||||
|
- `validate-import-bundle.test.ts` — ~21 tests covering envelope validation, graceful recovery, PC filtering
|
||||||
|
- `export-import.test.ts` — ~15 tests covering bundle assembly, round-trip, filename resolution
|
||||||
|
|
||||||
|
**Domain tests**: No rehydration tests exist in `packages/domain/` — all rehydration testing is in the adapter layer.
|
||||||
|
|
||||||
|
### 8. Cross-Reference Map
|
||||||
|
|
||||||
|
```
|
||||||
|
loadPlayerCharacters() ──→ rehydrateCharacter()
|
||||||
|
↑
|
||||||
|
validateImportBundle() ──→ rehydrateCharacters() ──┘
|
||||||
|
├─→ rehydrateEncounter() ──→ isValidCombatantEntry()
|
||||||
|
│ ├─→ rehydrateCombatant() ──→ validateAc()
|
||||||
|
│ │ ├─→ validateConditions()
|
||||||
|
│ │ ├─→ validateCreatureId()
|
||||||
|
│ │ └─→ validateHp()
|
||||||
|
│ └─→ createEncounter() [domain]
|
||||||
|
└─→ rehydrateStack() ───→ rehydrateEncounter() [same as above]
|
||||||
|
|
||||||
|
loadEncounter() ───────→ rehydrateEncounter() [same as above]
|
||||||
|
|
||||||
|
loadUndoRedoStacks() ──→ loadStack() ──→ rehydrateEncounter() [same as above]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code References
|
||||||
|
|
||||||
|
- `apps/web/src/persistence/player-character-storage.ts:25-65` — `rehydrateCharacter` (PC rehydration)
|
||||||
|
- `apps/web/src/persistence/player-character-storage.ts:18-23` — `isValidOptionalMember` helper
|
||||||
|
- `apps/web/src/persistence/encounter-storage.ts:24-28` — `validateAc` helper
|
||||||
|
- `apps/web/src/persistence/encounter-storage.ts:30-37` — `validateConditions` helper
|
||||||
|
- `apps/web/src/persistence/encounter-storage.ts:39-43` — `validateCreatureId` helper
|
||||||
|
- `apps/web/src/persistence/encounter-storage.ts:45-65` — `validateHp` helper
|
||||||
|
- `apps/web/src/persistence/encounter-storage.ts:67-103` — `rehydrateCombatant` (combatant rehydration)
|
||||||
|
- `apps/web/src/persistence/encounter-storage.ts:105-109` — `isValidCombatantEntry` (pre-check)
|
||||||
|
- `apps/web/src/persistence/encounter-storage.ts:111-140` — `rehydrateEncounter` (encounter envelope rehydration)
|
||||||
|
- `apps/web/src/persistence/export-import.ts:10-30` — `rehydrateStack` / `rehydrateCharacters` (collection wrappers)
|
||||||
|
- `apps/web/src/persistence/export-import.ts:32-65` — `validateImportBundle` (import envelope validation)
|
||||||
|
- `apps/web/src/persistence/undo-redo-storage.ts:17-36` — `loadStack` (undo/redo rehydration)
|
||||||
|
- `packages/domain/src/create-player-character.ts:17-100` — PC creation validation
|
||||||
|
- `packages/domain/src/add-combatant.ts:27-53` — `validateInit` (combatant creation validation)
|
||||||
|
- `packages/domain/src/types.ts:50-71` — `createEncounter` (encounter invariant enforcement)
|
||||||
|
- `packages/domain/src/types.ts:12-26` — `Combatant` type definition
|
||||||
|
- `packages/domain/src/player-character-types.ts:70-83` — `PlayerCharacter` type definition
|
||||||
|
|
||||||
|
## Architecture Documentation
|
||||||
|
|
||||||
|
### Current pattern
|
||||||
|
Rehydration is an adapter concern — persistence adapters validate raw JSON and construct typed domain objects. The domain provides creation functions that validate typed inputs for new entities, but no functions for reconstructing entities from untyped serialized data.
|
||||||
|
|
||||||
|
### Rehydration vs. creation semantics
|
||||||
|
Rehydration and creation serve different purposes:
|
||||||
|
- **Creation** (domain): Validates business rules for *new* entities. Receives typed parameters. Returns `DomainError` on failure.
|
||||||
|
- **Rehydration** (adapter): Reconstructs *previously valid* entities from serialized JSON. Receives `unknown`. Returns `null` on failure. May be lenient (combatants drop invalid optional fields) or strict (PCs reject on any invalid field).
|
||||||
|
|
||||||
|
### Delegation chain
|
||||||
|
`rehydrateEncounter` already delegates to `createEncounter` for encounter-level invariants. The entity-level rehydration functions (`rehydrateCharacter`, `rehydrateCombatant`) do NOT delegate to any domain function — they re-implement field validation inline.
|
||||||
|
|
||||||
|
### tempHp gap
|
||||||
|
`Combatant.tempHp` is defined in the domain type but has no validation in the current rehydration code. It appears to be silently included or excluded depending on what `rehydrateCombatant` constructs (it's not in the explicit field list, so it would be dropped during rehydration).
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Should `rehydrateCombatant` remain lenient (drop invalid optional fields) or become strict like `rehydrateCharacter` (reject on any invalid field)?** The current asymmetry is intentional: combatants can exist with minimal data (just id + name), while PCs always require ac/maxHp.
|
||||||
|
|
||||||
|
2. **Should `tempHp` be validated during rehydration?** It's currently missing from combatant rehydration but is a valid field on the type.
|
||||||
|
|
||||||
|
3. **Should `rehydrateEncounter` move to domain too, or only the entity-level functions?** The issue acceptance criteria says "validateImportBundle and rehydrateEncounter are unchanged" — but `rehydrateEncounter` currently lives alongside `rehydrateCombatant` and would need to import from domain instead of calling the local function.
|
||||||
|
|
||||||
|
4. **Should `isValidCombatantEntry` (the pre-check) be part of the domain rehydration or remain in the adapter?** It's currently the gate that ensures `id` and `name` exist before `rehydrateCombatant` is called.
|
||||||
+27
-2
@@ -1,4 +1,29 @@
|
|||||||
pre-commit:
|
pre-commit:
|
||||||
|
parallel: true
|
||||||
jobs:
|
jobs:
|
||||||
- name: check
|
- name: audit
|
||||||
run: pnpm check
|
run: pnpm audit --audit-level=high
|
||||||
|
- name: knip
|
||||||
|
run: pnpm exec knip
|
||||||
|
- name: biome
|
||||||
|
run: pnpm exec biome check .
|
||||||
|
- name: check-ignores
|
||||||
|
run: node scripts/check-lint-ignores.mjs
|
||||||
|
- name: check-classnames
|
||||||
|
run: node scripts/check-cn-classnames.mjs
|
||||||
|
- name: check-props
|
||||||
|
run: node scripts/check-component-props.mjs
|
||||||
|
- name: jscpd
|
||||||
|
run: pnpm exec jscpd
|
||||||
|
- name: jsinspect
|
||||||
|
run: pnpm jsinspect
|
||||||
|
- name: typecheck-oxlint-test
|
||||||
|
group:
|
||||||
|
piped: true
|
||||||
|
jobs:
|
||||||
|
- name: typecheck
|
||||||
|
run: pnpm exec tsc --build
|
||||||
|
- name: oxlint
|
||||||
|
run: pnpm oxlint
|
||||||
|
- name: test
|
||||||
|
run: pnpm test
|
||||||
|
|||||||
+3
-1
@@ -11,6 +11,7 @@
|
|||||||
"@biomejs/biome": "2.4.8",
|
"@biomejs/biome": "2.4.8",
|
||||||
"@vitest/coverage-v8": "^4.1.0",
|
"@vitest/coverage-v8": "^4.1.0",
|
||||||
"jscpd": "^4.0.8",
|
"jscpd": "^4.0.8",
|
||||||
|
"jsinspect-plus": "^3.1.3",
|
||||||
"knip": "^5.88.1",
|
"knip": "^5.88.1",
|
||||||
"lefthook": "^2.1.4",
|
"lefthook": "^2.1.4",
|
||||||
"oxlint": "^1.56.0",
|
"oxlint": "^1.56.0",
|
||||||
@@ -29,10 +30,11 @@
|
|||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"knip": "knip",
|
"knip": "knip",
|
||||||
"jscpd": "jscpd",
|
"jscpd": "jscpd",
|
||||||
|
"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",
|
||||||
"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 . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && node scripts/check-component-props.mjs && tsc --build && vitest run && jscpd"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import type { Encounter, PlayerCharacter } from "@initiative/domain";
|
import type {
|
||||||
import { isDomainError } from "@initiative/domain";
|
Encounter,
|
||||||
import type { EncounterStore, PlayerCharacterStore } from "../ports.js";
|
PlayerCharacter,
|
||||||
|
UndoRedoState,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { EMPTY_UNDO_REDO_STATE, isDomainError } from "@initiative/domain";
|
||||||
|
import type {
|
||||||
|
EncounterStore,
|
||||||
|
PlayerCharacterStore,
|
||||||
|
UndoRedoStore,
|
||||||
|
} from "../ports.js";
|
||||||
|
|
||||||
export function requireSaved<T>(value: T | null): T {
|
export function requireSaved<T>(value: T | null): T {
|
||||||
if (value === null) throw new Error("Expected store.saved to be non-null");
|
if (value === null) throw new Error("Expected store.saved to be non-null");
|
||||||
@@ -52,3 +60,17 @@ export function stubPlayerCharacterStore(
|
|||||||
};
|
};
|
||||||
return stub;
|
return stub;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function stubUndoRedoStore(
|
||||||
|
initial: UndoRedoState = EMPTY_UNDO_REDO_STATE,
|
||||||
|
): UndoRedoStore & { saved: UndoRedoState | null } {
|
||||||
|
const stub = {
|
||||||
|
saved: null as UndoRedoState | null,
|
||||||
|
get: () => initial,
|
||||||
|
save: (state: UndoRedoState) => {
|
||||||
|
stub.saved = state;
|
||||||
|
stub.get = () => state;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return stub;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import {
|
|||||||
type ConditionId,
|
type ConditionId,
|
||||||
combatantId,
|
combatantId,
|
||||||
createEncounter,
|
createEncounter,
|
||||||
|
EMPTY_UNDO_REDO_STATE,
|
||||||
isDomainError,
|
isDomainError,
|
||||||
playerCharacterId,
|
playerCharacterId,
|
||||||
|
pushUndo,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { addCombatantUseCase } from "../add-combatant-use-case.js";
|
import { addCombatantUseCase } from "../add-combatant-use-case.js";
|
||||||
@@ -14,17 +16,21 @@ import { createPlayerCharacterUseCase } from "../create-player-character-use-cas
|
|||||||
import { deletePlayerCharacterUseCase } from "../delete-player-character-use-case.js";
|
import { deletePlayerCharacterUseCase } from "../delete-player-character-use-case.js";
|
||||||
import { editCombatantUseCase } from "../edit-combatant-use-case.js";
|
import { editCombatantUseCase } from "../edit-combatant-use-case.js";
|
||||||
import { editPlayerCharacterUseCase } from "../edit-player-character-use-case.js";
|
import { editPlayerCharacterUseCase } from "../edit-player-character-use-case.js";
|
||||||
|
import { redoUseCase } from "../redo-use-case.js";
|
||||||
import { removeCombatantUseCase } from "../remove-combatant-use-case.js";
|
import { removeCombatantUseCase } from "../remove-combatant-use-case.js";
|
||||||
import { retreatTurnUseCase } from "../retreat-turn-use-case.js";
|
import { retreatTurnUseCase } from "../retreat-turn-use-case.js";
|
||||||
import { setAcUseCase } from "../set-ac-use-case.js";
|
import { setAcUseCase } from "../set-ac-use-case.js";
|
||||||
import { setHpUseCase } from "../set-hp-use-case.js";
|
import { setHpUseCase } from "../set-hp-use-case.js";
|
||||||
import { setInitiativeUseCase } from "../set-initiative-use-case.js";
|
import { setInitiativeUseCase } from "../set-initiative-use-case.js";
|
||||||
|
import { setTempHpUseCase } from "../set-temp-hp-use-case.js";
|
||||||
import { toggleConcentrationUseCase } from "../toggle-concentration-use-case.js";
|
import { toggleConcentrationUseCase } from "../toggle-concentration-use-case.js";
|
||||||
import { toggleConditionUseCase } from "../toggle-condition-use-case.js";
|
import { toggleConditionUseCase } from "../toggle-condition-use-case.js";
|
||||||
|
import { undoUseCase } from "../undo-use-case.js";
|
||||||
import {
|
import {
|
||||||
requireSaved,
|
requireSaved,
|
||||||
stubEncounterStore,
|
stubEncounterStore,
|
||||||
stubPlayerCharacterStore,
|
stubPlayerCharacterStore,
|
||||||
|
stubUndoRedoStore,
|
||||||
} from "./helpers.js";
|
} from "./helpers.js";
|
||||||
|
|
||||||
const ID_A = combatantId("a");
|
const ID_A = combatantId("a");
|
||||||
@@ -386,3 +392,80 @@ describe("editPlayerCharacterUseCase", () => {
|
|||||||
expect(store.saved).toBeNull();
|
expect(store.saved).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("setTempHpUseCase", () => {
|
||||||
|
it("sets temp HP and saves", () => {
|
||||||
|
const enc = encounterWithHp("Goblin", 10);
|
||||||
|
const store = stubEncounterStore(enc);
|
||||||
|
const result = setTempHpUseCase(store, combatantId("Goblin"), 5);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
expect(requireSaved(store.saved).combatants[0].tempHp).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error for unknown combatant", () => {
|
||||||
|
const store = stubEncounterStore(emptyEncounter());
|
||||||
|
const result = setTempHpUseCase(store, ID_A, 5);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
expect(store.saved).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("undoUseCase", () => {
|
||||||
|
it("restores previous encounter and saves both stores", () => {
|
||||||
|
const previous = encounterWith("A");
|
||||||
|
const current = encounterWith("A", "B");
|
||||||
|
const undoRedoState = pushUndo(EMPTY_UNDO_REDO_STATE, previous);
|
||||||
|
const encounterStore = stubEncounterStore(current);
|
||||||
|
const undoRedoStore = stubUndoRedoStore(undoRedoState);
|
||||||
|
|
||||||
|
const result = undoUseCase(encounterStore, undoRedoStore);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
expect(requireSaved(encounterStore.saved).combatants).toHaveLength(1);
|
||||||
|
expect(undoRedoStore.saved).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error when nothing to undo", () => {
|
||||||
|
const encounterStore = stubEncounterStore(emptyEncounter());
|
||||||
|
const undoRedoStore = stubUndoRedoStore();
|
||||||
|
|
||||||
|
const result = undoUseCase(encounterStore, undoRedoStore);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
expect(encounterStore.saved).toBeNull();
|
||||||
|
expect(undoRedoStore.saved).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("redoUseCase", () => {
|
||||||
|
it("restores next encounter and saves both stores", () => {
|
||||||
|
const previous = encounterWith("A");
|
||||||
|
const current = encounterWith("A", "B");
|
||||||
|
// Simulate: undo pushed current to redoStack
|
||||||
|
const undoRedoState = {
|
||||||
|
undoStack: [],
|
||||||
|
redoStack: [current],
|
||||||
|
};
|
||||||
|
const encounterStore = stubEncounterStore(previous);
|
||||||
|
const undoRedoStore = stubUndoRedoStore(undoRedoState);
|
||||||
|
|
||||||
|
const result = redoUseCase(encounterStore, undoRedoStore);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
expect(requireSaved(encounterStore.saved).combatants).toHaveLength(2);
|
||||||
|
expect(undoRedoStore.saved).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error when nothing to redo", () => {
|
||||||
|
const encounterStore = stubEncounterStore(emptyEncounter());
|
||||||
|
const undoRedoStore = stubUndoRedoStore();
|
||||||
|
|
||||||
|
const result = redoUseCase(encounterStore, undoRedoStore);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
expect(encounterStore.saved).toBeNull();
|
||||||
|
expect(undoRedoStore.saved).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import {
|
|||||||
type CombatantInit,
|
type CombatantInit,
|
||||||
type DomainError,
|
type DomainError,
|
||||||
type DomainEvent,
|
type DomainEvent,
|
||||||
isDomainError,
|
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import type { EncounterStore } from "./ports.js";
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
import { runEncounterAction } from "./run-encounter-action.js";
|
||||||
|
|
||||||
export function addCombatantUseCase(
|
export function addCombatantUseCase(
|
||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
@@ -14,13 +14,7 @@ export function addCombatantUseCase(
|
|||||||
name: string,
|
name: string,
|
||||||
init?: CombatantInit,
|
init?: CombatantInit,
|
||||||
): DomainEvent[] | DomainError {
|
): DomainEvent[] | DomainError {
|
||||||
const encounter = store.get();
|
return runEncounterAction(store, (encounter) =>
|
||||||
const result = addCombatant(encounter, id, name, init);
|
addCombatant(encounter, id, name, init),
|
||||||
|
);
|
||||||
if (isDomainError(result)) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
store.save(result.encounter);
|
|
||||||
return result.events;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,22 +3,16 @@ import {
|
|||||||
type CombatantId,
|
type CombatantId,
|
||||||
type DomainError,
|
type DomainError,
|
||||||
type DomainEvent,
|
type DomainEvent,
|
||||||
isDomainError,
|
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import type { EncounterStore } from "./ports.js";
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
import { runEncounterAction } from "./run-encounter-action.js";
|
||||||
|
|
||||||
export function adjustHpUseCase(
|
export function adjustHpUseCase(
|
||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
combatantId: CombatantId,
|
combatantId: CombatantId,
|
||||||
delta: number,
|
delta: number,
|
||||||
): DomainEvent[] | DomainError {
|
): DomainEvent[] | DomainError {
|
||||||
const encounter = store.get();
|
return runEncounterAction(store, (encounter) =>
|
||||||
const result = adjustHp(encounter, combatantId, delta);
|
adjustHp(encounter, combatantId, delta),
|
||||||
|
);
|
||||||
if (isDomainError(result)) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
store.save(result.encounter);
|
|
||||||
return result.events;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,12 @@ import {
|
|||||||
advanceTurn,
|
advanceTurn,
|
||||||
type DomainError,
|
type DomainError,
|
||||||
type DomainEvent,
|
type DomainEvent,
|
||||||
isDomainError,
|
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import type { EncounterStore } from "./ports.js";
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
import { runEncounterAction } from "./run-encounter-action.js";
|
||||||
|
|
||||||
export function advanceTurnUseCase(
|
export function advanceTurnUseCase(
|
||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
): DomainEvent[] | DomainError {
|
): DomainEvent[] | DomainError {
|
||||||
const encounter = store.get();
|
return runEncounterAction(store, (encounter) => advanceTurn(encounter));
|
||||||
const result = advanceTurn(encounter);
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
store.save(result.encounter);
|
|
||||||
return result.events;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,12 @@ import {
|
|||||||
clearEncounter,
|
clearEncounter,
|
||||||
type DomainError,
|
type DomainError,
|
||||||
type DomainEvent,
|
type DomainEvent,
|
||||||
isDomainError,
|
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import type { EncounterStore } from "./ports.js";
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
import { runEncounterAction } from "./run-encounter-action.js";
|
||||||
|
|
||||||
export function clearEncounterUseCase(
|
export function clearEncounterUseCase(
|
||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
): DomainEvent[] | DomainError {
|
): DomainEvent[] | DomainError {
|
||||||
const encounter = store.get();
|
return runEncounterAction(store, (encounter) => clearEncounter(encounter));
|
||||||
const result = clearEncounter(encounter);
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
store.save(result.encounter);
|
|
||||||
return result.events;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export function createPlayerCharacterUseCase(
|
|||||||
maxHp: number,
|
maxHp: number,
|
||||||
color: string | undefined,
|
color: string | undefined,
|
||||||
icon: string | undefined,
|
icon: string | undefined,
|
||||||
|
level?: number,
|
||||||
): DomainEvent[] | DomainError {
|
): DomainEvent[] | DomainError {
|
||||||
const characters = store.getAll();
|
const characters = store.getAll();
|
||||||
const result = createPlayerCharacter(
|
const result = createPlayerCharacter(
|
||||||
@@ -25,6 +26,7 @@ export function createPlayerCharacterUseCase(
|
|||||||
maxHp,
|
maxHp,
|
||||||
color,
|
color,
|
||||||
icon,
|
icon,
|
||||||
|
level,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
|
|||||||
@@ -3,22 +3,16 @@ import {
|
|||||||
type DomainError,
|
type DomainError,
|
||||||
type DomainEvent,
|
type DomainEvent,
|
||||||
editCombatant,
|
editCombatant,
|
||||||
isDomainError,
|
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import type { EncounterStore } from "./ports.js";
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
import { runEncounterAction } from "./run-encounter-action.js";
|
||||||
|
|
||||||
export function editCombatantUseCase(
|
export function editCombatantUseCase(
|
||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
id: CombatantId,
|
id: CombatantId,
|
||||||
newName: string,
|
newName: string,
|
||||||
): DomainEvent[] | DomainError {
|
): DomainEvent[] | DomainError {
|
||||||
const encounter = store.get();
|
return runEncounterAction(store, (encounter) =>
|
||||||
const result = editCombatant(encounter, id, newName);
|
editCombatant(encounter, id, newName),
|
||||||
|
);
|
||||||
if (isDomainError(result)) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
store.save(result.encounter);
|
|
||||||
return result.events;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface EditFields {
|
|||||||
readonly maxHp?: number;
|
readonly maxHp?: number;
|
||||||
readonly color?: string | null;
|
readonly color?: string | null;
|
||||||
readonly icon?: string | null;
|
readonly icon?: string | null;
|
||||||
|
readonly level?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function editPlayerCharacterUseCase(
|
export function editPlayerCharacterUseCase(
|
||||||
|
|||||||
@@ -2,22 +2,16 @@ import {
|
|||||||
type CombatantId,
|
type CombatantId,
|
||||||
type DomainError,
|
type DomainError,
|
||||||
type DomainEvent,
|
type DomainEvent,
|
||||||
isDomainError,
|
|
||||||
removeCombatant,
|
removeCombatant,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import type { EncounterStore } from "./ports.js";
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
import { runEncounterAction } from "./run-encounter-action.js";
|
||||||
|
|
||||||
export function removeCombatantUseCase(
|
export function removeCombatantUseCase(
|
||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
id: CombatantId,
|
id: CombatantId,
|
||||||
): DomainEvent[] | DomainError {
|
): DomainEvent[] | DomainError {
|
||||||
const encounter = store.get();
|
return runEncounterAction(store, (encounter) =>
|
||||||
const result = removeCombatant(encounter, id);
|
removeCombatant(encounter, id),
|
||||||
|
);
|
||||||
if (isDomainError(result)) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
store.save(result.encounter);
|
|
||||||
return result.events;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
type DomainError,
|
type DomainError,
|
||||||
type DomainEvent,
|
type DomainEvent,
|
||||||
isDomainError,
|
|
||||||
retreatTurn,
|
retreatTurn,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import type { EncounterStore } from "./ports.js";
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
import { runEncounterAction } from "./run-encounter-action.js";
|
||||||
|
|
||||||
export function retreatTurnUseCase(
|
export function retreatTurnUseCase(
|
||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
): DomainEvent[] | DomainError {
|
): DomainEvent[] | DomainError {
|
||||||
const encounter = store.get();
|
return runEncounterAction(store, (encounter) => retreatTurn(encounter));
|
||||||
const result = retreatTurn(encounter);
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
store.save(result.encounter);
|
|
||||||
return result.events;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import {
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
type Encounter,
|
||||||
|
isDomainError,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
|
||||||
|
interface EncounterActionResult {
|
||||||
|
readonly encounter: Encounter;
|
||||||
|
readonly events: DomainEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runEncounterAction(
|
||||||
|
store: EncounterStore,
|
||||||
|
action: (encounter: Encounter) => EncounterActionResult | DomainError,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
const encounter = store.get();
|
||||||
|
const result = action(encounter);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.save(result.encounter);
|
||||||
|
return result.events;
|
||||||
|
}
|
||||||
@@ -2,23 +2,17 @@ import {
|
|||||||
type CombatantId,
|
type CombatantId,
|
||||||
type DomainError,
|
type DomainError,
|
||||||
type DomainEvent,
|
type DomainEvent,
|
||||||
isDomainError,
|
|
||||||
setAc,
|
setAc,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import type { EncounterStore } from "./ports.js";
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
import { runEncounterAction } from "./run-encounter-action.js";
|
||||||
|
|
||||||
export function setAcUseCase(
|
export function setAcUseCase(
|
||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
combatantId: CombatantId,
|
combatantId: CombatantId,
|
||||||
value: number | undefined,
|
value: number | undefined,
|
||||||
): DomainEvent[] | DomainError {
|
): DomainEvent[] | DomainError {
|
||||||
const encounter = store.get();
|
return runEncounterAction(store, (encounter) =>
|
||||||
const result = setAc(encounter, combatantId, value);
|
setAc(encounter, combatantId, value),
|
||||||
|
);
|
||||||
if (isDomainError(result)) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
store.save(result.encounter);
|
|
||||||
return result.events;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,23 +2,17 @@ import {
|
|||||||
type CombatantId,
|
type CombatantId,
|
||||||
type DomainError,
|
type DomainError,
|
||||||
type DomainEvent,
|
type DomainEvent,
|
||||||
isDomainError,
|
|
||||||
setHp,
|
setHp,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import type { EncounterStore } from "./ports.js";
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
import { runEncounterAction } from "./run-encounter-action.js";
|
||||||
|
|
||||||
export function setHpUseCase(
|
export function setHpUseCase(
|
||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
combatantId: CombatantId,
|
combatantId: CombatantId,
|
||||||
maxHp: number | undefined,
|
maxHp: number | undefined,
|
||||||
): DomainEvent[] | DomainError {
|
): DomainEvent[] | DomainError {
|
||||||
const encounter = store.get();
|
return runEncounterAction(store, (encounter) =>
|
||||||
const result = setHp(encounter, combatantId, maxHp);
|
setHp(encounter, combatantId, maxHp),
|
||||||
|
);
|
||||||
if (isDomainError(result)) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
store.save(result.encounter);
|
|
||||||
return result.events;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,23 +2,17 @@ import {
|
|||||||
type CombatantId,
|
type CombatantId,
|
||||||
type DomainError,
|
type DomainError,
|
||||||
type DomainEvent,
|
type DomainEvent,
|
||||||
isDomainError,
|
|
||||||
setInitiative,
|
setInitiative,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import type { EncounterStore } from "./ports.js";
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
import { runEncounterAction } from "./run-encounter-action.js";
|
||||||
|
|
||||||
export function setInitiativeUseCase(
|
export function setInitiativeUseCase(
|
||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
combatantId: CombatantId,
|
combatantId: CombatantId,
|
||||||
value: number | undefined,
|
value: number | undefined,
|
||||||
): DomainEvent[] | DomainError {
|
): DomainEvent[] | DomainError {
|
||||||
const encounter = store.get();
|
return runEncounterAction(store, (encounter) =>
|
||||||
const result = setInitiative(encounter, combatantId, value);
|
setInitiative(encounter, combatantId, value),
|
||||||
|
);
|
||||||
if (isDomainError(result)) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
store.save(result.encounter);
|
|
||||||
return result.events;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,23 +2,17 @@ import {
|
|||||||
type CombatantId,
|
type CombatantId,
|
||||||
type DomainError,
|
type DomainError,
|
||||||
type DomainEvent,
|
type DomainEvent,
|
||||||
isDomainError,
|
|
||||||
setTempHp,
|
setTempHp,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import type { EncounterStore } from "./ports.js";
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
import { runEncounterAction } from "./run-encounter-action.js";
|
||||||
|
|
||||||
export function setTempHpUseCase(
|
export function setTempHpUseCase(
|
||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
combatantId: CombatantId,
|
combatantId: CombatantId,
|
||||||
tempHp: number | undefined,
|
tempHp: number | undefined,
|
||||||
): DomainEvent[] | DomainError {
|
): DomainEvent[] | DomainError {
|
||||||
const encounter = store.get();
|
return runEncounterAction(store, (encounter) =>
|
||||||
const result = setTempHp(encounter, combatantId, tempHp);
|
setTempHp(encounter, combatantId, tempHp),
|
||||||
|
);
|
||||||
if (isDomainError(result)) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
store.save(result.encounter);
|
|
||||||
return result.events;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,22 +2,16 @@ import {
|
|||||||
type CombatantId,
|
type CombatantId,
|
||||||
type DomainError,
|
type DomainError,
|
||||||
type DomainEvent,
|
type DomainEvent,
|
||||||
isDomainError,
|
|
||||||
toggleConcentration,
|
toggleConcentration,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import type { EncounterStore } from "./ports.js";
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
import { runEncounterAction } from "./run-encounter-action.js";
|
||||||
|
|
||||||
export function toggleConcentrationUseCase(
|
export function toggleConcentrationUseCase(
|
||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
combatantId: CombatantId,
|
combatantId: CombatantId,
|
||||||
): DomainEvent[] | DomainError {
|
): DomainEvent[] | DomainError {
|
||||||
const encounter = store.get();
|
return runEncounterAction(store, (encounter) =>
|
||||||
const result = toggleConcentration(encounter, combatantId);
|
toggleConcentration(encounter, combatantId),
|
||||||
|
);
|
||||||
if (isDomainError(result)) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
store.save(result.encounter);
|
|
||||||
return result.events;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,23 +3,17 @@ import {
|
|||||||
type ConditionId,
|
type ConditionId,
|
||||||
type DomainError,
|
type DomainError,
|
||||||
type DomainEvent,
|
type DomainEvent,
|
||||||
isDomainError,
|
|
||||||
toggleCondition,
|
toggleCondition,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import type { EncounterStore } from "./ports.js";
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
import { runEncounterAction } from "./run-encounter-action.js";
|
||||||
|
|
||||||
export function toggleConditionUseCase(
|
export function toggleConditionUseCase(
|
||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
combatantId: CombatantId,
|
combatantId: CombatantId,
|
||||||
conditionId: ConditionId,
|
conditionId: ConditionId,
|
||||||
): DomainEvent[] | DomainError {
|
): DomainEvent[] | DomainError {
|
||||||
const encounter = store.get();
|
return runEncounterAction(store, (encounter) =>
|
||||||
const result = toggleCondition(encounter, combatantId, conditionId);
|
toggleCondition(encounter, combatantId, conditionId),
|
||||||
|
);
|
||||||
if (isDomainError(result)) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
store.save(result.encounter);
|
|
||||||
return result.events;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ function success(
|
|||||||
maxHp,
|
maxHp,
|
||||||
color,
|
color,
|
||||||
icon,
|
icon,
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
throw new Error(`Expected success, got error: ${result.message}`);
|
throw new Error(`Expected success, got error: ${result.message}`);
|
||||||
@@ -241,4 +242,76 @@ describe("createPlayerCharacter", () => {
|
|||||||
expect(events).toHaveLength(1);
|
expect(events).toHaveLength(1);
|
||||||
expect(events[0].type).toBe("PlayerCharacterCreated");
|
expect(events[0].type).toBe("PlayerCharacterCreated");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("creates a player character with a valid level", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
"blue",
|
||||||
|
"sword",
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].level).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a player character without a level", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
"blue",
|
||||||
|
"sword",
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].level).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects level below 1", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
"blue",
|
||||||
|
"sword",
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
expectDomainError(result, "invalid-level");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects level above 20", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
"blue",
|
||||||
|
"sword",
|
||||||
|
21,
|
||||||
|
);
|
||||||
|
expectDomainError(result, "invalid-level");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-integer level", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
"blue",
|
||||||
|
"sword",
|
||||||
|
3.5,
|
||||||
|
);
|
||||||
|
expectDomainError(result, "invalid-level");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -110,4 +110,33 @@ describe("editPlayerCharacter", () => {
|
|||||||
expect(event.oldName).toBe("Aragorn");
|
expect(event.oldName).toBe("Aragorn");
|
||||||
expect(event.newName).toBe("Strider");
|
expect(event.newName).toBe("Strider");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sets level on a player character", () => {
|
||||||
|
const result = editPlayerCharacter([makePC()], id, { level: 5 });
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].level).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears level when set to null", () => {
|
||||||
|
const result = editPlayerCharacter([makePC({ level: 5 })], id, {
|
||||||
|
level: null,
|
||||||
|
});
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].level).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid level", () => {
|
||||||
|
const result = editPlayerCharacter([makePC()], id, { level: 0 });
|
||||||
|
expectDomainError(result, "invalid-level");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects level above 20", () => {
|
||||||
|
const result = editPlayerCharacter([makePC()], id, { level: 21 });
|
||||||
|
expectDomainError(result, "invalid-level");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-integer level", () => {
|
||||||
|
const result = editPlayerCharacter([makePC()], id, { level: 3.5 });
|
||||||
|
expectDomainError(result, "invalid-level");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
calculateEncounterDifficulty,
|
||||||
|
crToXp,
|
||||||
|
} from "../encounter-difficulty.js";
|
||||||
|
|
||||||
|
describe("crToXp", () => {
|
||||||
|
it("returns 0 for CR 0", () => {
|
||||||
|
expect(crToXp("0")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 25 for CR 1/8", () => {
|
||||||
|
expect(crToXp("1/8")).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 50 for CR 1/4", () => {
|
||||||
|
expect(crToXp("1/4")).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 100 for CR 1/2", () => {
|
||||||
|
expect(crToXp("1/2")).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 for CR 1", () => {
|
||||||
|
expect(crToXp("1")).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 155000 for CR 30", () => {
|
||||||
|
expect(crToXp("30")).toBe(155000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 for unknown CR", () => {
|
||||||
|
expect(crToXp("99")).toBe(0);
|
||||||
|
expect(crToXp("")).toBe(0);
|
||||||
|
expect(crToXp("abc")).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("calculateEncounterDifficulty", () => {
|
||||||
|
it("returns trivial when monster XP is below Low threshold", () => {
|
||||||
|
// 4x level 1: Low = 200, Moderate = 300, High = 400
|
||||||
|
// 1x CR 0 = 0 XP → trivial
|
||||||
|
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["0"]);
|
||||||
|
expect(result.tier).toBe("trivial");
|
||||||
|
expect(result.totalMonsterXp).toBe(0);
|
||||||
|
expect(result.partyBudget).toEqual({
|
||||||
|
low: 200,
|
||||||
|
moderate: 300,
|
||||||
|
high: 400,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns low for 4x level 1 vs Bugbear (CR 1)", () => {
|
||||||
|
// DMG example: 4x level 1 PCs vs 1 Bugbear (CR 1, 200 XP)
|
||||||
|
// Low = 200, Moderate = 300 → 200 >= 200 but < 300 → Low
|
||||||
|
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["1"]);
|
||||||
|
expect(result.tier).toBe("low");
|
||||||
|
expect(result.totalMonsterXp).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns moderate for 5x level 3 vs 1125 XP", () => {
|
||||||
|
// 5x level 3: Low = 750, Moderate = 1125, High = 2000
|
||||||
|
// 1125 XP >= 1125 Moderate but < 2000 High → Moderate
|
||||||
|
// Using CR 3 (700) + CR 2 (450) = 1150 XP ≈ 1125 threshold
|
||||||
|
// Let's use exact: 5 * 225 = 1125 moderate budget
|
||||||
|
// Need monsters that sum exactly to 1125: CR 3 (700) + CR 2 (450) = 1150
|
||||||
|
const result = calculateEncounterDifficulty([3, 3, 3, 3, 3], ["3", "2"]);
|
||||||
|
expect(result.tier).toBe("moderate");
|
||||||
|
expect(result.totalMonsterXp).toBe(1150);
|
||||||
|
expect(result.partyBudget.moderate).toBe(1125);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns high when XP meets High threshold", () => {
|
||||||
|
// 4x level 1: High = 400
|
||||||
|
// 2x CR 1 = 400 XP → High
|
||||||
|
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["1", "1"]);
|
||||||
|
expect(result.tier).toBe("high");
|
||||||
|
expect(result.totalMonsterXp).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps at high when XP far exceeds threshold", () => {
|
||||||
|
// 4x level 1: High = 400
|
||||||
|
// CR 30 = 155000 XP → still High (no tier above)
|
||||||
|
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["30"]);
|
||||||
|
expect(result.tier).toBe("high");
|
||||||
|
expect(result.totalMonsterXp).toBe(155000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles mixed party levels", () => {
|
||||||
|
// 3x level 3 + 1x level 2
|
||||||
|
// level 3: low=150, mod=225, high=400 (x3 = 450, 675, 1200)
|
||||||
|
// level 2: low=100, mod=150, high=200 (x1 = 100, 150, 200)
|
||||||
|
// Total: low=550, mod=825, high=1400
|
||||||
|
const result = calculateEncounterDifficulty([3, 3, 3, 2], ["3"]);
|
||||||
|
expect(result.partyBudget).toEqual({
|
||||||
|
low: 550,
|
||||||
|
moderate: 825,
|
||||||
|
high: 1400,
|
||||||
|
});
|
||||||
|
expect(result.totalMonsterXp).toBe(700);
|
||||||
|
expect(result.tier).toBe("low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns trivial with empty monster array", () => {
|
||||||
|
const result = calculateEncounterDifficulty([5, 5], []);
|
||||||
|
expect(result.tier).toBe("trivial");
|
||||||
|
expect(result.totalMonsterXp).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns high with empty party array (zero budget thresholds)", () => {
|
||||||
|
// Domain function treats empty party as zero budgets — any XP exceeds all thresholds.
|
||||||
|
// The useDifficulty hook guards this path by returning null when no leveled PCs exist.
|
||||||
|
const result = calculateEncounterDifficulty([], ["1"]);
|
||||||
|
expect(result.tier).toBe("high");
|
||||||
|
expect(result.totalMonsterXp).toBe(200);
|
||||||
|
expect(result.partyBudget).toEqual({ low: 0, moderate: 0, high: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles fractional CRs", () => {
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[1, 1, 1, 1],
|
||||||
|
["1/8", "1/4", "1/2"],
|
||||||
|
);
|
||||||
|
expect(result.totalMonsterXp).toBe(175); // 25 + 50 + 100
|
||||||
|
expect(result.tier).toBe("trivial"); // 175 < 200 Low
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores unknown CRs (0 XP)", () => {
|
||||||
|
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["unknown"]);
|
||||||
|
expect(result.totalMonsterXp).toBe(0);
|
||||||
|
expect(result.tier).toBe("trivial");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { rehydrateCombatant } from "../rehydrate-combatant.js";
|
||||||
|
|
||||||
|
function validCombatant(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: "c-1",
|
||||||
|
name: "Goblin",
|
||||||
|
initiative: 12,
|
||||||
|
ac: 15,
|
||||||
|
maxHp: 7,
|
||||||
|
currentHp: 5,
|
||||||
|
tempHp: 3,
|
||||||
|
conditions: ["poisoned"],
|
||||||
|
isConcentrating: true,
|
||||||
|
creatureId: "creature-goblin",
|
||||||
|
color: "red",
|
||||||
|
icon: "skull",
|
||||||
|
playerCharacterId: "pc-1",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function minimalCombatant() {
|
||||||
|
return { id: "c-1", name: "Goblin" };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("rehydrateCombatant", () => {
|
||||||
|
describe("valid input", () => {
|
||||||
|
it("accepts a combatant with all fields", () => {
|
||||||
|
const result = rehydrateCombatant(validCombatant());
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.name).toBe("Goblin");
|
||||||
|
expect(result?.initiative).toBe(12);
|
||||||
|
expect(result?.ac).toBe(15);
|
||||||
|
expect(result?.maxHp).toBe(7);
|
||||||
|
expect(result?.currentHp).toBe(5);
|
||||||
|
expect(result?.tempHp).toBe(3);
|
||||||
|
expect(result?.conditions).toEqual(["poisoned"]);
|
||||||
|
expect(result?.isConcentrating).toBe(true);
|
||||||
|
expect(result?.creatureId).toBe("creature-goblin");
|
||||||
|
expect(result?.color).toBe("red");
|
||||||
|
expect(result?.icon).toBe("skull");
|
||||||
|
expect(result?.playerCharacterId).toBe("pc-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a minimal combatant (id + name only)", () => {
|
||||||
|
const result = rehydrateCombatant(minimalCombatant());
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.id).toBe("c-1");
|
||||||
|
expect(result?.name).toBe("Goblin");
|
||||||
|
expect(result?.initiative).toBeUndefined();
|
||||||
|
expect(result?.ac).toBeUndefined();
|
||||||
|
expect(result?.maxHp).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves branded CombatantId", () => {
|
||||||
|
const result = rehydrateCombatant(minimalCombatant());
|
||||||
|
expect(result?.id).toBe("c-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("required field rejection", () => {
|
||||||
|
it.each([
|
||||||
|
null,
|
||||||
|
42,
|
||||||
|
"string",
|
||||||
|
[1, 2],
|
||||||
|
undefined,
|
||||||
|
])("rejects non-object input: %j", (input) => {
|
||||||
|
expect(rehydrateCombatant(input)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing id", () => {
|
||||||
|
const { id: _, ...rest } = minimalCombatant();
|
||||||
|
expect(rehydrateCombatant(rest)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty id", () => {
|
||||||
|
expect(rehydrateCombatant({ ...minimalCombatant(), id: "" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing name", () => {
|
||||||
|
const { name: _, ...rest } = minimalCombatant();
|
||||||
|
expect(rehydrateCombatant(rest)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-string name", () => {
|
||||||
|
expect(
|
||||||
|
rehydrateCombatant({ ...minimalCombatant(), name: 42 }),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
rehydrateCombatant({ ...minimalCombatant(), name: null }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("optional field leniency", () => {
|
||||||
|
it("drops invalid ac — keeps combatant", () => {
|
||||||
|
for (const ac of [-1, 1.5, "15"]) {
|
||||||
|
const result = rehydrateCombatant({ ...minimalCombatant(), ac });
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.ac).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops invalid maxHp — keeps combatant", () => {
|
||||||
|
for (const maxHp of [0, 1.5, "7"]) {
|
||||||
|
const result = rehydrateCombatant({ ...minimalCombatant(), maxHp });
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.maxHp).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back currentHp to maxHp when currentHp invalid", () => {
|
||||||
|
const result = rehydrateCombatant({
|
||||||
|
...minimalCombatant(),
|
||||||
|
maxHp: 10,
|
||||||
|
currentHp: "bad",
|
||||||
|
});
|
||||||
|
expect(result?.maxHp).toBe(10);
|
||||||
|
expect(result?.currentHp).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back currentHp to maxHp when currentHp > maxHp", () => {
|
||||||
|
const result = rehydrateCombatant({
|
||||||
|
...minimalCombatant(),
|
||||||
|
maxHp: 10,
|
||||||
|
currentHp: 15,
|
||||||
|
});
|
||||||
|
expect(result?.maxHp).toBe(10);
|
||||||
|
expect(result?.currentHp).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops invalid initiative — keeps combatant", () => {
|
||||||
|
for (const initiative of [1.5, "12"]) {
|
||||||
|
const result = rehydrateCombatant({
|
||||||
|
...minimalCombatant(),
|
||||||
|
initiative,
|
||||||
|
});
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.initiative).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops invalid conditions — keeps combatant", () => {
|
||||||
|
const result = rehydrateCombatant({
|
||||||
|
...minimalCombatant(),
|
||||||
|
conditions: "poisoned",
|
||||||
|
});
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.conditions).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops unknown condition IDs", () => {
|
||||||
|
const result = rehydrateCombatant({
|
||||||
|
...minimalCombatant(),
|
||||||
|
conditions: ["fake-condition"],
|
||||||
|
});
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.conditions).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters valid conditions from mixed array", () => {
|
||||||
|
const result = rehydrateCombatant({
|
||||||
|
...minimalCombatant(),
|
||||||
|
conditions: ["poisoned", "fake", "blinded"],
|
||||||
|
});
|
||||||
|
expect(result?.conditions).toEqual(["poisoned", "blinded"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops invalid color — keeps combatant", () => {
|
||||||
|
const result = rehydrateCombatant({
|
||||||
|
...minimalCombatant(),
|
||||||
|
color: "neon",
|
||||||
|
});
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.color).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops invalid icon — keeps combatant", () => {
|
||||||
|
const result = rehydrateCombatant({
|
||||||
|
...minimalCombatant(),
|
||||||
|
icon: "rocket",
|
||||||
|
});
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.icon).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops isConcentrating when not strictly true", () => {
|
||||||
|
for (const isConcentrating of [false, "true", 1]) {
|
||||||
|
const result = rehydrateCombatant({
|
||||||
|
...minimalCombatant(),
|
||||||
|
isConcentrating,
|
||||||
|
});
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.isConcentrating).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops invalid creatureId", () => {
|
||||||
|
for (const creatureId of ["", 42]) {
|
||||||
|
const result = rehydrateCombatant({
|
||||||
|
...minimalCombatant(),
|
||||||
|
creatureId,
|
||||||
|
});
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.creatureId).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops invalid playerCharacterId", () => {
|
||||||
|
for (const playerCharacterId of ["", 42]) {
|
||||||
|
const result = rehydrateCombatant({
|
||||||
|
...minimalCombatant(),
|
||||||
|
playerCharacterId,
|
||||||
|
});
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.playerCharacterId).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops invalid tempHp — keeps combatant", () => {
|
||||||
|
for (const tempHp of [-1, 1.5, "3"]) {
|
||||||
|
const result = rehydrateCombatant({
|
||||||
|
...minimalCombatant(),
|
||||||
|
tempHp,
|
||||||
|
});
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.tempHp).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves valid tempHp of 0", () => {
|
||||||
|
const result = rehydrateCombatant({
|
||||||
|
...minimalCombatant(),
|
||||||
|
tempHp: 0,
|
||||||
|
});
|
||||||
|
expect(result?.tempHp).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { rehydratePlayerCharacter } from "../rehydrate-player-character.js";
|
||||||
|
|
||||||
|
function validPc(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: "pc-1",
|
||||||
|
name: "Aria",
|
||||||
|
ac: 16,
|
||||||
|
maxHp: 45,
|
||||||
|
color: "blue",
|
||||||
|
icon: "sword",
|
||||||
|
level: 5,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("rehydratePlayerCharacter", () => {
|
||||||
|
describe("valid input", () => {
|
||||||
|
it("accepts a valid PC with all fields", () => {
|
||||||
|
const result = rehydratePlayerCharacter(validPc());
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.name).toBe("Aria");
|
||||||
|
expect(result?.ac).toBe(16);
|
||||||
|
expect(result?.maxHp).toBe(45);
|
||||||
|
expect(result?.color).toBe("blue");
|
||||||
|
expect(result?.icon).toBe("sword");
|
||||||
|
expect(result?.level).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a valid PC without optional color/icon/level", () => {
|
||||||
|
const result = rehydratePlayerCharacter(
|
||||||
|
validPc({ color: undefined, icon: undefined, level: undefined }),
|
||||||
|
);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.color).toBeUndefined();
|
||||||
|
expect(result?.icon).toBeUndefined();
|
||||||
|
expect(result?.level).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves branded PlayerCharacterId", () => {
|
||||||
|
const result = rehydratePlayerCharacter(validPc());
|
||||||
|
expect(result?.id).toBe("pc-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("required field rejection", () => {
|
||||||
|
it.each([
|
||||||
|
null,
|
||||||
|
42,
|
||||||
|
"string",
|
||||||
|
[1, 2],
|
||||||
|
undefined,
|
||||||
|
])("rejects non-object input: %j", (input) => {
|
||||||
|
expect(rehydratePlayerCharacter(input)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing id", () => {
|
||||||
|
const { id: _, ...rest } = validPc();
|
||||||
|
expect(rehydratePlayerCharacter(rest)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty id", () => {
|
||||||
|
expect(rehydratePlayerCharacter(validPc({ id: "" }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing name", () => {
|
||||||
|
const { name: _, ...rest } = validPc();
|
||||||
|
expect(rehydratePlayerCharacter(rest)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty/whitespace name", () => {
|
||||||
|
expect(rehydratePlayerCharacter(validPc({ name: "" }))).toBeNull();
|
||||||
|
expect(rehydratePlayerCharacter(validPc({ name: " " }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing ac", () => {
|
||||||
|
const { ac: _, ...rest } = validPc();
|
||||||
|
expect(rehydratePlayerCharacter(rest)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects negative ac", () => {
|
||||||
|
expect(rehydratePlayerCharacter(validPc({ ac: -1 }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects float ac", () => {
|
||||||
|
expect(rehydratePlayerCharacter(validPc({ ac: 1.5 }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects string ac", () => {
|
||||||
|
expect(rehydratePlayerCharacter(validPc({ ac: "16" }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing maxHp", () => {
|
||||||
|
const { maxHp: _, ...rest } = validPc();
|
||||||
|
expect(rehydratePlayerCharacter(rest)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects maxHp of 0", () => {
|
||||||
|
expect(rehydratePlayerCharacter(validPc({ maxHp: 0 }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects float maxHp", () => {
|
||||||
|
expect(rehydratePlayerCharacter(validPc({ maxHp: 1.5 }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects string maxHp", () => {
|
||||||
|
expect(rehydratePlayerCharacter(validPc({ maxHp: "45" }))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("optional field rejection (strict)", () => {
|
||||||
|
it("rejects invalid color", () => {
|
||||||
|
expect(rehydratePlayerCharacter(validPc({ color: "neon" }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid icon", () => {
|
||||||
|
expect(rehydratePlayerCharacter(validPc({ icon: "rocket" }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects level 0", () => {
|
||||||
|
expect(rehydratePlayerCharacter(validPc({ level: 0 }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects level 21", () => {
|
||||||
|
expect(rehydratePlayerCharacter(validPc({ level: 21 }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects float level", () => {
|
||||||
|
expect(rehydratePlayerCharacter(validPc({ level: 3.5 }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects string level", () => {
|
||||||
|
expect(rehydratePlayerCharacter(validPc({ level: "5" }))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { DomainEvent } from "./events.js";
|
import type { DomainEvent } from "./events.js";
|
||||||
import type { CombatantId, DomainError, Encounter } from "./types.js";
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type Encounter,
|
||||||
|
findCombatant,
|
||||||
|
isDomainError,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
export interface AdjustHpSuccess {
|
export interface AdjustHpSuccess {
|
||||||
readonly encounter: Encounter;
|
readonly encounter: Encounter;
|
||||||
@@ -17,17 +23,9 @@ export function adjustHp(
|
|||||||
combatantId: CombatantId,
|
combatantId: CombatantId,
|
||||||
delta: number,
|
delta: number,
|
||||||
): AdjustHpSuccess | DomainError {
|
): AdjustHpSuccess | DomainError {
|
||||||
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
|
const found = findCombatant(encounter, combatantId);
|
||||||
|
if (isDomainError(found)) return found;
|
||||||
if (targetIdx === -1) {
|
const { combatant: target } = found;
|
||||||
return {
|
|
||||||
kind: "domain-error",
|
|
||||||
code: "combatant-not-found",
|
|
||||||
message: `No combatant found with ID "${combatantId}"`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = encounter.combatants[targetIdx];
|
|
||||||
|
|
||||||
if (target.maxHp === undefined || target.currentHp === undefined) {
|
if (target.maxHp === undefined || target.currentHp === undefined) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export function createPlayerCharacter(
|
|||||||
maxHp: number,
|
maxHp: number,
|
||||||
color: string | undefined,
|
color: string | undefined,
|
||||||
icon: string | undefined,
|
icon: string | undefined,
|
||||||
|
level?: number,
|
||||||
): CreatePlayerCharacterSuccess | DomainError {
|
): CreatePlayerCharacterSuccess | DomainError {
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
|
|
||||||
@@ -65,6 +66,17 @@ export function createPlayerCharacter(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
level !== undefined &&
|
||||||
|
(!Number.isInteger(level) || level < 1 || level > 20)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "invalid-level",
|
||||||
|
message: "Level must be an integer between 1 and 20",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const newCharacter: PlayerCharacter = {
|
const newCharacter: PlayerCharacter = {
|
||||||
id,
|
id,
|
||||||
name: trimmed,
|
name: trimmed,
|
||||||
@@ -72,6 +84,7 @@ export function createPlayerCharacter(
|
|||||||
maxHp,
|
maxHp,
|
||||||
color: color as PlayerCharacter["color"],
|
color: color as PlayerCharacter["color"],
|
||||||
icon: icon as PlayerCharacter["icon"],
|
icon: icon as PlayerCharacter["icon"],
|
||||||
|
level,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { DomainEvent } from "./events.js";
|
import type { DomainEvent } from "./events.js";
|
||||||
import type { CombatantId, DomainError, Encounter } from "./types.js";
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type Encounter,
|
||||||
|
findCombatant,
|
||||||
|
isDomainError,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
export interface EditCombatantSuccess {
|
export interface EditCombatantSuccess {
|
||||||
readonly encounter: Encounter;
|
readonly encounter: Encounter;
|
||||||
@@ -30,17 +36,9 @@ export function editCombatant(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const index = encounter.combatants.findIndex((c) => c.id === id);
|
const found = findCombatant(encounter, id);
|
||||||
|
if (isDomainError(found)) return found;
|
||||||
if (index === -1) {
|
const oldName = found.combatant.name;
|
||||||
return {
|
|
||||||
kind: "domain-error",
|
|
||||||
code: "combatant-not-found",
|
|
||||||
message: `No combatant found with ID "${id}"`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldName = encounter.combatants[index].name;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
encounter: {
|
encounter: {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface EditFields {
|
|||||||
readonly maxHp?: number;
|
readonly maxHp?: number;
|
||||||
readonly color?: string | null;
|
readonly color?: string | null;
|
||||||
readonly icon?: string | null;
|
readonly icon?: string | null;
|
||||||
|
readonly level?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateFields(fields: EditFields): DomainError | null {
|
function validateFields(fields: EditFields): DomainError | null {
|
||||||
@@ -72,6 +73,17 @@ function validateFields(fields: EditFields): DomainError | null {
|
|||||||
message: `Invalid icon: ${fields.icon}`,
|
message: `Invalid icon: ${fields.icon}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
fields.level !== undefined &&
|
||||||
|
fields.level !== null &&
|
||||||
|
(!Number.isInteger(fields.level) || fields.level < 1 || fields.level > 20)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "invalid-level",
|
||||||
|
message: "Level must be an integer between 1 and 20",
|
||||||
|
};
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +104,8 @@ function applyFields(
|
|||||||
fields.icon === undefined
|
fields.icon === undefined
|
||||||
? existing.icon
|
? existing.icon
|
||||||
: ((fields.icon as PlayerCharacter["icon"]) ?? undefined),
|
: ((fields.icon as PlayerCharacter["icon"]) ?? undefined),
|
||||||
|
level:
|
||||||
|
fields.level === undefined ? existing.level : (fields.level ?? undefined),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +134,8 @@ export function editPlayerCharacter(
|
|||||||
updated.ac === existing.ac &&
|
updated.ac === existing.ac &&
|
||||||
updated.maxHp === existing.maxHp &&
|
updated.maxHp === existing.maxHp &&
|
||||||
updated.color === existing.color &&
|
updated.color === existing.color &&
|
||||||
updated.icon === existing.icon
|
updated.icon === existing.icon &&
|
||||||
|
updated.level === existing.level
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
kind: "domain-error",
|
kind: "domain-error",
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
export type DifficultyTier = "trivial" | "low" | "moderate" | "high";
|
||||||
|
|
||||||
|
export interface DifficultyResult {
|
||||||
|
readonly tier: DifficultyTier;
|
||||||
|
readonly totalMonsterXp: number;
|
||||||
|
readonly partyBudget: {
|
||||||
|
readonly low: number;
|
||||||
|
readonly moderate: number;
|
||||||
|
readonly high: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps challenge rating strings to XP values (standard 5e). */
|
||||||
|
const CR_TO_XP: Readonly<Record<string, number>> = {
|
||||||
|
"0": 0,
|
||||||
|
"1/8": 25,
|
||||||
|
"1/4": 50,
|
||||||
|
"1/2": 100,
|
||||||
|
"1": 200,
|
||||||
|
"2": 450,
|
||||||
|
"3": 700,
|
||||||
|
"4": 1100,
|
||||||
|
"5": 1800,
|
||||||
|
"6": 2300,
|
||||||
|
"7": 2900,
|
||||||
|
"8": 3900,
|
||||||
|
"9": 5000,
|
||||||
|
"10": 5900,
|
||||||
|
"11": 7200,
|
||||||
|
"12": 8400,
|
||||||
|
"13": 10000,
|
||||||
|
"14": 11500,
|
||||||
|
"15": 13000,
|
||||||
|
"16": 15000,
|
||||||
|
"17": 18000,
|
||||||
|
"18": 20000,
|
||||||
|
"19": 22000,
|
||||||
|
"20": 25000,
|
||||||
|
"21": 33000,
|
||||||
|
"22": 41000,
|
||||||
|
"23": 50000,
|
||||||
|
"24": 62000,
|
||||||
|
"25": 75000,
|
||||||
|
"26": 90000,
|
||||||
|
"27": 105000,
|
||||||
|
"28": 120000,
|
||||||
|
"29": 135000,
|
||||||
|
"30": 155000,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Maps character level (1-20) to XP budget thresholds (2024 5.5e DMG). */
|
||||||
|
const XP_BUDGET_PER_CHARACTER: Readonly<
|
||||||
|
Record<number, { low: number; moderate: number; high: number }>
|
||||||
|
> = {
|
||||||
|
1: { low: 50, moderate: 75, high: 100 },
|
||||||
|
2: { low: 100, moderate: 150, high: 200 },
|
||||||
|
3: { low: 150, moderate: 225, high: 400 },
|
||||||
|
4: { low: 250, moderate: 375, high: 500 },
|
||||||
|
5: { low: 500, moderate: 750, high: 1100 },
|
||||||
|
6: { low: 600, moderate: 1000, high: 1400 },
|
||||||
|
7: { low: 750, moderate: 1300, high: 1700 },
|
||||||
|
8: { low: 1000, moderate: 1700, high: 2100 },
|
||||||
|
9: { low: 1300, moderate: 2000, high: 2600 },
|
||||||
|
10: { low: 1600, moderate: 2300, high: 3100 },
|
||||||
|
11: { low: 1900, moderate: 2900, high: 4100 },
|
||||||
|
12: { low: 2200, moderate: 3700, high: 4700 },
|
||||||
|
13: { low: 2600, moderate: 4200, high: 5400 },
|
||||||
|
14: { low: 2900, moderate: 4900, high: 6200 },
|
||||||
|
15: { low: 3300, moderate: 5400, high: 7800 },
|
||||||
|
16: { low: 3800, moderate: 6100, high: 9800 },
|
||||||
|
17: { low: 4500, moderate: 7200, high: 11700 },
|
||||||
|
18: { low: 5000, moderate: 8700, high: 14200 },
|
||||||
|
19: { low: 5500, moderate: 10700, high: 17200 },
|
||||||
|
20: { low: 6400, moderate: 13200, high: 22000 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Returns the XP value for a given CR string. Returns 0 for unknown CRs. */
|
||||||
|
export function crToXp(cr: string): number {
|
||||||
|
return CR_TO_XP[cr] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates encounter difficulty from party levels and monster CRs.
|
||||||
|
* Both arrays should be pre-filtered (only PCs with levels, only bestiary-linked monsters).
|
||||||
|
*/
|
||||||
|
export function calculateEncounterDifficulty(
|
||||||
|
partyLevels: readonly number[],
|
||||||
|
monsterCrs: readonly string[],
|
||||||
|
): DifficultyResult {
|
||||||
|
let budgetLow = 0;
|
||||||
|
let budgetModerate = 0;
|
||||||
|
let budgetHigh = 0;
|
||||||
|
|
||||||
|
for (const level of partyLevels) {
|
||||||
|
const budget = XP_BUDGET_PER_CHARACTER[level];
|
||||||
|
if (budget) {
|
||||||
|
budgetLow += budget.low;
|
||||||
|
budgetModerate += budget.moderate;
|
||||||
|
budgetHigh += budget.high;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalMonsterXp = 0;
|
||||||
|
for (const cr of monsterCrs) {
|
||||||
|
totalMonsterXp += crToXp(cr);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tier: DifficultyTier = "trivial";
|
||||||
|
if (totalMonsterXp >= budgetHigh) {
|
||||||
|
tier = "high";
|
||||||
|
} else if (totalMonsterXp >= budgetModerate) {
|
||||||
|
tier = "moderate";
|
||||||
|
} else if (totalMonsterXp >= budgetLow) {
|
||||||
|
tier = "low";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tier,
|
||||||
|
totalMonsterXp,
|
||||||
|
partyBudget: {
|
||||||
|
low: budgetLow,
|
||||||
|
moderate: budgetModerate,
|
||||||
|
high: budgetHigh,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { PlayerCharacter } from "./player-character-types.js";
|
||||||
|
import type { Encounter } from "./types.js";
|
||||||
|
|
||||||
|
export interface ExportBundle {
|
||||||
|
readonly version: number;
|
||||||
|
readonly exportedAt: string;
|
||||||
|
readonly encounter: Encounter;
|
||||||
|
readonly undoStack: readonly Encounter[];
|
||||||
|
readonly redoStack: readonly Encounter[];
|
||||||
|
readonly playerCharacters: readonly PlayerCharacter[];
|
||||||
|
}
|
||||||
@@ -48,6 +48,12 @@ export {
|
|||||||
type EditPlayerCharacterSuccess,
|
type EditPlayerCharacterSuccess,
|
||||||
editPlayerCharacter,
|
editPlayerCharacter,
|
||||||
} from "./edit-player-character.js";
|
} from "./edit-player-character.js";
|
||||||
|
export {
|
||||||
|
calculateEncounterDifficulty,
|
||||||
|
crToXp,
|
||||||
|
type DifficultyResult,
|
||||||
|
type DifficultyTier,
|
||||||
|
} from "./encounter-difficulty.js";
|
||||||
export type {
|
export type {
|
||||||
AcSet,
|
AcSet,
|
||||||
CombatantAdded,
|
CombatantAdded,
|
||||||
@@ -71,6 +77,7 @@ export type {
|
|||||||
TurnAdvanced,
|
TurnAdvanced,
|
||||||
TurnRetreated,
|
TurnRetreated,
|
||||||
} from "./events.js";
|
} from "./events.js";
|
||||||
|
export type { ExportBundle } from "./export-bundle.js";
|
||||||
export { deriveHpStatus, type HpStatus } from "./hp-status.js";
|
export { deriveHpStatus, type HpStatus } from "./hp-status.js";
|
||||||
export {
|
export {
|
||||||
calculateInitiative,
|
calculateInitiative,
|
||||||
@@ -87,6 +94,8 @@ export {
|
|||||||
VALID_PLAYER_COLORS,
|
VALID_PLAYER_COLORS,
|
||||||
VALID_PLAYER_ICONS,
|
VALID_PLAYER_ICONS,
|
||||||
} from "./player-character-types.js";
|
} from "./player-character-types.js";
|
||||||
|
export { rehydrateCombatant } from "./rehydrate-combatant.js";
|
||||||
|
export { rehydratePlayerCharacter } from "./rehydrate-player-character.js";
|
||||||
export {
|
export {
|
||||||
type RemoveCombatantSuccess,
|
type RemoveCombatantSuccess,
|
||||||
removeCombatant,
|
removeCombatant,
|
||||||
@@ -119,6 +128,7 @@ export {
|
|||||||
createEncounter,
|
createEncounter,
|
||||||
type DomainError,
|
type DomainError,
|
||||||
type Encounter,
|
type Encounter,
|
||||||
|
findCombatant,
|
||||||
isDomainError,
|
isDomainError,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export interface PlayerCharacter {
|
|||||||
readonly maxHp: number;
|
readonly maxHp: number;
|
||||||
readonly color?: PlayerColor;
|
readonly color?: PlayerColor;
|
||||||
readonly icon?: PlayerIcon;
|
readonly icon?: PlayerIcon;
|
||||||
|
readonly level?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlayerCharacterList {
|
export interface PlayerCharacterList {
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { ConditionId } from "./conditions.js";
|
||||||
|
import { VALID_CONDITION_IDS } from "./conditions.js";
|
||||||
|
import { creatureId } from "./creature-types.js";
|
||||||
|
import {
|
||||||
|
playerCharacterId,
|
||||||
|
VALID_PLAYER_COLORS,
|
||||||
|
VALID_PLAYER_ICONS,
|
||||||
|
} from "./player-character-types.js";
|
||||||
|
import type { Combatant } from "./types.js";
|
||||||
|
import { combatantId } from "./types.js";
|
||||||
|
|
||||||
|
function validateAc(value: unknown): number | undefined {
|
||||||
|
return typeof value === "number" && Number.isInteger(value) && value >= 0
|
||||||
|
? value
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateConditions(value: unknown): ConditionId[] | undefined {
|
||||||
|
if (!Array.isArray(value)) return undefined;
|
||||||
|
const valid = value.filter(
|
||||||
|
(v): v is ConditionId =>
|
||||||
|
typeof v === "string" && VALID_CONDITION_IDS.has(v),
|
||||||
|
);
|
||||||
|
return valid.length > 0 ? valid : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateHp(
|
||||||
|
rawMaxHp: unknown,
|
||||||
|
rawCurrentHp: unknown,
|
||||||
|
): { maxHp: number; currentHp: number } | undefined {
|
||||||
|
if (
|
||||||
|
typeof rawMaxHp !== "number" ||
|
||||||
|
!Number.isInteger(rawMaxHp) ||
|
||||||
|
rawMaxHp < 1
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const validCurrentHp =
|
||||||
|
typeof rawCurrentHp === "number" &&
|
||||||
|
Number.isInteger(rawCurrentHp) &&
|
||||||
|
rawCurrentHp >= 0 &&
|
||||||
|
rawCurrentHp <= rawMaxHp;
|
||||||
|
return {
|
||||||
|
maxHp: rawMaxHp,
|
||||||
|
currentHp: validCurrentHp ? rawCurrentHp : rawMaxHp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTempHp(value: unknown): number | undefined {
|
||||||
|
return typeof value === "number" && Number.isInteger(value) && value >= 0
|
||||||
|
? value
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateInteger(value: unknown): number | undefined {
|
||||||
|
return typeof value === "number" && Number.isInteger(value)
|
||||||
|
? value
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSetMember(
|
||||||
|
value: unknown,
|
||||||
|
valid: ReadonlySet<string>,
|
||||||
|
): string | undefined {
|
||||||
|
return typeof value === "string" && valid.has(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateNonEmptyString(value: unknown): string | undefined {
|
||||||
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalFields(entry: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
initiative: validateInteger(entry.initiative),
|
||||||
|
ac: validateAc(entry.ac),
|
||||||
|
conditions: validateConditions(entry.conditions),
|
||||||
|
isConcentrating: entry.isConcentrating === true ? true : undefined,
|
||||||
|
creatureId: validateNonEmptyString(entry.creatureId)
|
||||||
|
? creatureId(entry.creatureId as string)
|
||||||
|
: undefined,
|
||||||
|
color: validateSetMember(entry.color, VALID_PLAYER_COLORS),
|
||||||
|
icon: validateSetMember(entry.icon, VALID_PLAYER_ICONS),
|
||||||
|
playerCharacterId: validateNonEmptyString(entry.playerCharacterId)
|
||||||
|
? playerCharacterId(entry.playerCharacterId as string)
|
||||||
|
: undefined,
|
||||||
|
tempHp: validateTempHp(entry.tempHp),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rehydrateCombatant(raw: unknown): Combatant | null {
|
||||||
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
||||||
|
return null;
|
||||||
|
const entry = raw as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (typeof entry.id !== "string" || entry.id.length === 0) return null;
|
||||||
|
if (typeof entry.name !== "string") return null;
|
||||||
|
|
||||||
|
const shared: Combatant = {
|
||||||
|
id: combatantId(entry.id),
|
||||||
|
name: entry.name,
|
||||||
|
...parseOptionalFields(entry),
|
||||||
|
};
|
||||||
|
|
||||||
|
const hp = validateHp(entry.maxHp, entry.currentHp);
|
||||||
|
return hp ? { ...shared, ...hp } : shared;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { PlayerCharacter } from "./player-character-types.js";
|
||||||
|
import {
|
||||||
|
playerCharacterId,
|
||||||
|
VALID_PLAYER_COLORS,
|
||||||
|
VALID_PLAYER_ICONS,
|
||||||
|
} from "./player-character-types.js";
|
||||||
|
|
||||||
|
function isValidOptionalMember(
|
||||||
|
value: unknown,
|
||||||
|
valid: ReadonlySet<string>,
|
||||||
|
): boolean {
|
||||||
|
return value === undefined || (typeof value === "string" && valid.has(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rehydratePlayerCharacter(raw: unknown): PlayerCharacter | null {
|
||||||
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
||||||
|
return null;
|
||||||
|
const entry = raw as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (typeof entry.id !== "string" || entry.id.length === 0) return null;
|
||||||
|
if (typeof entry.name !== "string" || entry.name.trim().length === 0)
|
||||||
|
return null;
|
||||||
|
if (
|
||||||
|
typeof entry.ac !== "number" ||
|
||||||
|
!Number.isInteger(entry.ac) ||
|
||||||
|
entry.ac < 0
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
if (
|
||||||
|
typeof entry.maxHp !== "number" ||
|
||||||
|
!Number.isInteger(entry.maxHp) ||
|
||||||
|
entry.maxHp < 1
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
if (!isValidOptionalMember(entry.color, VALID_PLAYER_COLORS)) return null;
|
||||||
|
if (!isValidOptionalMember(entry.icon, VALID_PLAYER_ICONS)) return null;
|
||||||
|
if (
|
||||||
|
entry.level !== undefined &&
|
||||||
|
(typeof entry.level !== "number" ||
|
||||||
|
!Number.isInteger(entry.level) ||
|
||||||
|
entry.level < 1 ||
|
||||||
|
entry.level > 20)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: playerCharacterId(entry.id),
|
||||||
|
name: entry.name,
|
||||||
|
ac: entry.ac,
|
||||||
|
maxHp: entry.maxHp,
|
||||||
|
color: entry.color as PlayerCharacter["color"],
|
||||||
|
icon: entry.icon as PlayerCharacter["icon"],
|
||||||
|
level: entry.level,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { DomainEvent } from "./events.js";
|
import type { DomainEvent } from "./events.js";
|
||||||
import type { CombatantId, DomainError, Encounter } from "./types.js";
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type Encounter,
|
||||||
|
findCombatant,
|
||||||
|
isDomainError,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
export interface RemoveCombatantSuccess {
|
export interface RemoveCombatantSuccess {
|
||||||
readonly encounter: Encounter;
|
readonly encounter: Encounter;
|
||||||
@@ -22,17 +28,10 @@ export function removeCombatant(
|
|||||||
encounter: Encounter,
|
encounter: Encounter,
|
||||||
id: CombatantId,
|
id: CombatantId,
|
||||||
): RemoveCombatantSuccess | DomainError {
|
): RemoveCombatantSuccess | DomainError {
|
||||||
const removedIdx = encounter.combatants.findIndex((c) => c.id === id);
|
const found = findCombatant(encounter, id);
|
||||||
|
if (isDomainError(found)) return found;
|
||||||
|
|
||||||
if (removedIdx === -1) {
|
const { index: removedIdx, combatant: removed } = found;
|
||||||
return {
|
|
||||||
kind: "domain-error",
|
|
||||||
code: "combatant-not-found",
|
|
||||||
message: `No combatant found with ID "${id}"`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const removed = encounter.combatants[removedIdx];
|
|
||||||
const newCombatants = encounter.combatants.filter((_, i) => i !== removedIdx);
|
const newCombatants = encounter.combatants.filter((_, i) => i !== removedIdx);
|
||||||
|
|
||||||
let newActiveIndex: number;
|
let newActiveIndex: number;
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { DomainEvent } from "./events.js";
|
import type { DomainEvent } from "./events.js";
|
||||||
import type { CombatantId, DomainError, Encounter } from "./types.js";
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type Encounter,
|
||||||
|
findCombatant,
|
||||||
|
isDomainError,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
export interface SetAcSuccess {
|
export interface SetAcSuccess {
|
||||||
readonly encounter: Encounter;
|
readonly encounter: Encounter;
|
||||||
@@ -11,15 +17,8 @@ export function setAc(
|
|||||||
combatantId: CombatantId,
|
combatantId: CombatantId,
|
||||||
value: number | undefined,
|
value: number | undefined,
|
||||||
): SetAcSuccess | DomainError {
|
): SetAcSuccess | DomainError {
|
||||||
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
|
const found = findCombatant(encounter, combatantId);
|
||||||
|
if (isDomainError(found)) return found;
|
||||||
if (targetIdx === -1) {
|
|
||||||
return {
|
|
||||||
kind: "domain-error",
|
|
||||||
code: "combatant-not-found",
|
|
||||||
message: `No combatant found with ID "${combatantId}"`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
|
if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
|
||||||
return {
|
return {
|
||||||
@@ -29,8 +28,7 @@ export function setAc(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = encounter.combatants[targetIdx];
|
const previousAc = found.combatant.ac;
|
||||||
const previousAc = target.ac;
|
|
||||||
|
|
||||||
const updatedCombatants = encounter.combatants.map((c) =>
|
const updatedCombatants = encounter.combatants.map((c) =>
|
||||||
c.id === combatantId ? { ...c, ac: value } : c,
|
c.id === combatantId ? { ...c, ac: value } : c,
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { DomainEvent } from "./events.js";
|
import type { DomainEvent } from "./events.js";
|
||||||
import type { CombatantId, DomainError, Encounter } from "./types.js";
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type Encounter,
|
||||||
|
findCombatant,
|
||||||
|
isDomainError,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
export interface SetHpSuccess {
|
export interface SetHpSuccess {
|
||||||
readonly encounter: Encounter;
|
readonly encounter: Encounter;
|
||||||
@@ -18,15 +24,8 @@ export function setHp(
|
|||||||
combatantId: CombatantId,
|
combatantId: CombatantId,
|
||||||
maxHp: number | undefined,
|
maxHp: number | undefined,
|
||||||
): SetHpSuccess | DomainError {
|
): SetHpSuccess | DomainError {
|
||||||
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
|
const found = findCombatant(encounter, combatantId);
|
||||||
|
if (isDomainError(found)) return found;
|
||||||
if (targetIdx === -1) {
|
|
||||||
return {
|
|
||||||
kind: "domain-error",
|
|
||||||
code: "combatant-not-found",
|
|
||||||
message: `No combatant found with ID "${combatantId}"`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (maxHp !== undefined && (!Number.isInteger(maxHp) || maxHp < 1)) {
|
if (maxHp !== undefined && (!Number.isInteger(maxHp) || maxHp < 1)) {
|
||||||
return {
|
return {
|
||||||
@@ -36,9 +35,8 @@ export function setHp(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = encounter.combatants[targetIdx];
|
const previousMaxHp = found.combatant.maxHp;
|
||||||
const previousMaxHp = target.maxHp;
|
const previousCurrentHp = found.combatant.currentHp;
|
||||||
const previousCurrentHp = target.currentHp;
|
|
||||||
|
|
||||||
let newMaxHp: number | undefined;
|
let newMaxHp: number | undefined;
|
||||||
let newCurrentHp: number | undefined;
|
let newCurrentHp: number | undefined;
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import type { DomainEvent } from "./events.js";
|
import type { DomainEvent } from "./events.js";
|
||||||
import { sortByInitiative } from "./initiative-sort.js";
|
import { sortByInitiative } from "./initiative-sort.js";
|
||||||
import type { CombatantId, DomainError, Encounter } from "./types.js";
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type Encounter,
|
||||||
|
findCombatant,
|
||||||
|
isDomainError,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
export interface SetInitiativeSuccess {
|
export interface SetInitiativeSuccess {
|
||||||
readonly encounter: Encounter;
|
readonly encounter: Encounter;
|
||||||
@@ -24,15 +30,8 @@ export function setInitiative(
|
|||||||
combatantId: CombatantId,
|
combatantId: CombatantId,
|
||||||
value: number | undefined,
|
value: number | undefined,
|
||||||
): SetInitiativeSuccess | DomainError {
|
): SetInitiativeSuccess | DomainError {
|
||||||
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
|
const found = findCombatant(encounter, combatantId);
|
||||||
|
if (isDomainError(found)) return found;
|
||||||
if (targetIdx === -1) {
|
|
||||||
return {
|
|
||||||
kind: "domain-error",
|
|
||||||
code: "combatant-not-found",
|
|
||||||
message: `No combatant found with ID "${combatantId}"`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value !== undefined && !Number.isInteger(value)) {
|
if (value !== undefined && !Number.isInteger(value)) {
|
||||||
return {
|
return {
|
||||||
@@ -42,8 +41,7 @@ export function setInitiative(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = encounter.combatants[targetIdx];
|
const previousValue = found.combatant.initiative;
|
||||||
const previousValue = target.initiative;
|
|
||||||
|
|
||||||
// Create new combatants array with updated initiative
|
// Create new combatants array with updated initiative
|
||||||
const updated = encounter.combatants.map((c) =>
|
const updated = encounter.combatants.map((c) =>
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { DomainEvent } from "./events.js";
|
import type { DomainEvent } from "./events.js";
|
||||||
import type { CombatantId, DomainError, Encounter } from "./types.js";
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type Encounter,
|
||||||
|
findCombatant,
|
||||||
|
isDomainError,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
export interface SetTempHpSuccess {
|
export interface SetTempHpSuccess {
|
||||||
readonly encounter: Encounter;
|
readonly encounter: Encounter;
|
||||||
@@ -18,17 +24,9 @@ export function setTempHp(
|
|||||||
combatantId: CombatantId,
|
combatantId: CombatantId,
|
||||||
tempHp: number | undefined,
|
tempHp: number | undefined,
|
||||||
): SetTempHpSuccess | DomainError {
|
): SetTempHpSuccess | DomainError {
|
||||||
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
|
const found = findCombatant(encounter, combatantId);
|
||||||
|
if (isDomainError(found)) return found;
|
||||||
if (targetIdx === -1) {
|
const { combatant: target } = found;
|
||||||
return {
|
|
||||||
kind: "domain-error",
|
|
||||||
code: "combatant-not-found",
|
|
||||||
message: `No combatant found with ID "${combatantId}"`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = encounter.combatants[targetIdx];
|
|
||||||
|
|
||||||
if (target.maxHp === undefined || target.currentHp === undefined) {
|
if (target.maxHp === undefined || target.currentHp === undefined) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { DomainEvent } from "./events.js";
|
import type { DomainEvent } from "./events.js";
|
||||||
import type { CombatantId, DomainError, Encounter } from "./types.js";
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type Encounter,
|
||||||
|
findCombatant,
|
||||||
|
isDomainError,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
export interface ToggleConcentrationSuccess {
|
export interface ToggleConcentrationSuccess {
|
||||||
readonly encounter: Encounter;
|
readonly encounter: Encounter;
|
||||||
@@ -10,17 +16,9 @@ export function toggleConcentration(
|
|||||||
encounter: Encounter,
|
encounter: Encounter,
|
||||||
combatantId: CombatantId,
|
combatantId: CombatantId,
|
||||||
): ToggleConcentrationSuccess | DomainError {
|
): ToggleConcentrationSuccess | DomainError {
|
||||||
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
|
const found = findCombatant(encounter, combatantId);
|
||||||
|
if (isDomainError(found)) return found;
|
||||||
if (targetIdx === -1) {
|
const { combatant: target } = found;
|
||||||
return {
|
|
||||||
kind: "domain-error",
|
|
||||||
code: "combatant-not-found",
|
|
||||||
message: `No combatant found with ID "${combatantId}"`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = encounter.combatants[targetIdx];
|
|
||||||
const wasConcentrating = target.isConcentrating === true;
|
const wasConcentrating = target.isConcentrating === true;
|
||||||
|
|
||||||
const event: DomainEvent = wasConcentrating
|
const event: DomainEvent = wasConcentrating
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import type { ConditionId } from "./conditions.js";
|
import type { ConditionId } from "./conditions.js";
|
||||||
import { CONDITION_DEFINITIONS, VALID_CONDITION_IDS } from "./conditions.js";
|
import { CONDITION_DEFINITIONS, VALID_CONDITION_IDS } from "./conditions.js";
|
||||||
import type { DomainEvent } from "./events.js";
|
import type { DomainEvent } from "./events.js";
|
||||||
import type { CombatantId, DomainError, Encounter } from "./types.js";
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type Encounter,
|
||||||
|
findCombatant,
|
||||||
|
isDomainError,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
export interface ToggleConditionSuccess {
|
export interface ToggleConditionSuccess {
|
||||||
readonly encounter: Encounter;
|
readonly encounter: Encounter;
|
||||||
@@ -21,17 +27,9 @@ export function toggleCondition(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
|
const found = findCombatant(encounter, combatantId);
|
||||||
|
if (isDomainError(found)) return found;
|
||||||
if (targetIdx === -1) {
|
const { combatant: target } = found;
|
||||||
return {
|
|
||||||
kind: "domain-error",
|
|
||||||
code: "combatant-not-found",
|
|
||||||
message: `No combatant found with ID "${combatantId}"`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = encounter.combatants[targetIdx];
|
|
||||||
const current = target.conditions ?? [];
|
const current = target.conditions ?? [];
|
||||||
const isActive = current.includes(conditionId);
|
const isActive = current.includes(conditionId);
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,20 @@ export function createEncounter(
|
|||||||
return { combatants, activeIndex, roundNumber };
|
return { combatants, activeIndex, roundNumber };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function findCombatant(
|
||||||
|
encounter: Encounter,
|
||||||
|
id: CombatantId,
|
||||||
|
): { index: number; combatant: Combatant } | DomainError {
|
||||||
|
const index = encounter.combatants.findIndex((c) => c.id === id);
|
||||||
|
if (index === -1) {
|
||||||
|
return domainError(
|
||||||
|
"combatant-not-found",
|
||||||
|
`No combatant found with ID "${id}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { index, combatant: encounter.combatants[index] };
|
||||||
|
}
|
||||||
|
|
||||||
export function isDomainError(value: unknown): value is DomainError {
|
export function isDomainError(value: unknown): value is DomainError {
|
||||||
return (
|
return (
|
||||||
typeof value === "object" &&
|
typeof value === "object" &&
|
||||||
|
|||||||
Generated
+119
@@ -21,6 +21,9 @@ importers:
|
|||||||
jscpd:
|
jscpd:
|
||||||
specifier: ^4.0.8
|
specifier: ^4.0.8
|
||||||
version: 4.0.8
|
version: 4.0.8
|
||||||
|
jsinspect-plus:
|
||||||
|
specifier: ^3.1.3
|
||||||
|
version: 3.1.3
|
||||||
knip:
|
knip:
|
||||||
specifier: ^5.88.1
|
specifier: ^5.88.1
|
||||||
version: 5.88.1(@types/node@25.3.3)(typescript@5.9.3)
|
version: 5.88.1(@types/node@25.3.3)(typescript@5.9.3)
|
||||||
@@ -133,15 +136,28 @@ packages:
|
|||||||
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/helper-string-parser@8.0.0-rc.3':
|
||||||
|
resolution: {integrity: sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
|
||||||
'@babel/helper-validator-identifier@7.28.5':
|
'@babel/helper-validator-identifier@7.28.5':
|
||||||
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/helper-validator-identifier@8.0.0-rc.3':
|
||||||
|
resolution: {integrity: sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
|
||||||
'@babel/parser@7.29.0':
|
'@babel/parser@7.29.0':
|
||||||
resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==}
|
resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
'@babel/parser@8.0.0-rc.3':
|
||||||
|
resolution: {integrity: sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
'@babel/runtime@7.28.6':
|
'@babel/runtime@7.28.6':
|
||||||
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
|
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -150,6 +166,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/types@8.0.0-rc.3':
|
||||||
|
resolution: {integrity: sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
|
||||||
'@bcoe/v8-coverage@1.0.2':
|
'@bcoe/v8-coverage@1.0.2':
|
||||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -898,6 +918,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
ansi-styles@3.2.1:
|
||||||
|
resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
ansi-styles@5.2.0:
|
ansi-styles@5.2.0:
|
||||||
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
|
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -956,6 +980,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
chalk@2.4.2:
|
||||||
|
resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
character-parser@2.2.0:
|
character-parser@2.2.0:
|
||||||
resolution: {integrity: sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==}
|
resolution: {integrity: sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==}
|
||||||
|
|
||||||
@@ -970,10 +998,19 @@ packages:
|
|||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
color-convert@1.9.3:
|
||||||
|
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
|
||||||
|
|
||||||
|
color-name@1.1.3:
|
||||||
|
resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==}
|
||||||
|
|
||||||
colors@1.4.0:
|
colors@1.4.0:
|
||||||
resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==}
|
resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==}
|
||||||
engines: {node: '>=0.1.90'}
|
engines: {node: '>=0.1.90'}
|
||||||
|
|
||||||
|
commander@2.20.3:
|
||||||
|
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
||||||
|
|
||||||
commander@5.1.0:
|
commander@5.1.0:
|
||||||
resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==}
|
resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -1055,6 +1092,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
escape-string-regexp@1.0.5:
|
||||||
|
resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
|
||||||
|
engines: {node: '>=0.8.0'}
|
||||||
|
|
||||||
estree-walker@3.0.3:
|
estree-walker@3.0.3:
|
||||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||||
|
|
||||||
@@ -1088,6 +1129,9 @@ packages:
|
|||||||
picomatch:
|
picomatch:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
filepaths@0.3.0:
|
||||||
|
resolution: {integrity: sha512-QFAYdzHZxWfBOHtHIlZySPAej+pxz6c2TGe8LGgHQNsgxHmcfbbQfNmsIh0kaangjL+6D6g8IoR6VDnOFrLEFw==}
|
||||||
|
|
||||||
fill-range@7.1.1:
|
fill-range@7.1.1:
|
||||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1136,6 +1180,10 @@ packages:
|
|||||||
graceful-fs@4.2.11:
|
graceful-fs@4.2.11:
|
||||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||||
|
|
||||||
|
has-flag@3.0.0:
|
||||||
|
resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
has-flag@4.0.0:
|
has-flag@4.0.0:
|
||||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1251,6 +1299,10 @@ packages:
|
|||||||
canvas:
|
canvas:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
jsinspect-plus@3.1.3:
|
||||||
|
resolution: {integrity: sha512-0GbLXDlfz9nPuybM/QunzEYKTwaETxGJ5+V7vZFS7+l8w426ePVU77dBH6k+KrxiJemIgVwY6Yxr3PCzFJwxgw==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
jsonfile@6.2.0:
|
jsonfile@6.2.0:
|
||||||
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
|
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
|
||||||
|
|
||||||
@@ -1650,6 +1702,10 @@ packages:
|
|||||||
spark-md5@3.0.2:
|
spark-md5@3.0.2:
|
||||||
resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==}
|
resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==}
|
||||||
|
|
||||||
|
stable@0.1.8:
|
||||||
|
resolution: {integrity: sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==}
|
||||||
|
deprecated: 'Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility'
|
||||||
|
|
||||||
stackback@0.0.2:
|
stackback@0.0.2:
|
||||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||||
|
|
||||||
@@ -1672,10 +1728,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
|
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
strip-json-comments@3.1.1:
|
||||||
|
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
strip-json-comments@5.0.3:
|
strip-json-comments@5.0.3:
|
||||||
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
|
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
|
||||||
engines: {node: '>=14.16'}
|
engines: {node: '>=14.16'}
|
||||||
|
|
||||||
|
supports-color@5.5.0:
|
||||||
|
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
supports-color@7.2.0:
|
supports-color@7.2.0:
|
||||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1921,12 +1985,20 @@ snapshots:
|
|||||||
|
|
||||||
'@babel/helper-string-parser@7.27.1': {}
|
'@babel/helper-string-parser@7.27.1': {}
|
||||||
|
|
||||||
|
'@babel/helper-string-parser@8.0.0-rc.3': {}
|
||||||
|
|
||||||
'@babel/helper-validator-identifier@7.28.5': {}
|
'@babel/helper-validator-identifier@7.28.5': {}
|
||||||
|
|
||||||
|
'@babel/helper-validator-identifier@8.0.0-rc.3': {}
|
||||||
|
|
||||||
'@babel/parser@7.29.0':
|
'@babel/parser@7.29.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/types': 7.29.0
|
'@babel/types': 7.29.0
|
||||||
|
|
||||||
|
'@babel/parser@8.0.0-rc.3':
|
||||||
|
dependencies:
|
||||||
|
'@babel/types': 8.0.0-rc.3
|
||||||
|
|
||||||
'@babel/runtime@7.28.6': {}
|
'@babel/runtime@7.28.6': {}
|
||||||
|
|
||||||
'@babel/types@7.29.0':
|
'@babel/types@7.29.0':
|
||||||
@@ -1934,6 +2006,11 @@ snapshots:
|
|||||||
'@babel/helper-string-parser': 7.27.1
|
'@babel/helper-string-parser': 7.27.1
|
||||||
'@babel/helper-validator-identifier': 7.28.5
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
|
|
||||||
|
'@babel/types@8.0.0-rc.3':
|
||||||
|
dependencies:
|
||||||
|
'@babel/helper-string-parser': 8.0.0-rc.3
|
||||||
|
'@babel/helper-validator-identifier': 8.0.0-rc.3
|
||||||
|
|
||||||
'@bcoe/v8-coverage@1.0.2': {}
|
'@bcoe/v8-coverage@1.0.2': {}
|
||||||
|
|
||||||
'@biomejs/biome@2.4.8':
|
'@biomejs/biome@2.4.8':
|
||||||
@@ -2481,6 +2558,10 @@ snapshots:
|
|||||||
|
|
||||||
ansi-regex@5.0.1: {}
|
ansi-regex@5.0.1: {}
|
||||||
|
|
||||||
|
ansi-styles@3.2.1:
|
||||||
|
dependencies:
|
||||||
|
color-convert: 1.9.3
|
||||||
|
|
||||||
ansi-styles@5.2.0: {}
|
ansi-styles@5.2.0: {}
|
||||||
|
|
||||||
aria-query@5.3.0:
|
aria-query@5.3.0:
|
||||||
@@ -2534,6 +2615,12 @@ snapshots:
|
|||||||
|
|
||||||
chai@6.2.2: {}
|
chai@6.2.2: {}
|
||||||
|
|
||||||
|
chalk@2.4.2:
|
||||||
|
dependencies:
|
||||||
|
ansi-styles: 3.2.1
|
||||||
|
escape-string-regexp: 1.0.5
|
||||||
|
supports-color: 5.5.0
|
||||||
|
|
||||||
character-parser@2.2.0:
|
character-parser@2.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-regex: 1.2.1
|
is-regex: 1.2.1
|
||||||
@@ -2550,8 +2637,16 @@ snapshots:
|
|||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
|
color-convert@1.9.3:
|
||||||
|
dependencies:
|
||||||
|
color-name: 1.1.3
|
||||||
|
|
||||||
|
color-name@1.1.3: {}
|
||||||
|
|
||||||
colors@1.4.0: {}
|
colors@1.4.0: {}
|
||||||
|
|
||||||
|
commander@2.20.3: {}
|
||||||
|
|
||||||
commander@5.1.0: {}
|
commander@5.1.0: {}
|
||||||
|
|
||||||
constantinople@4.0.1:
|
constantinople@4.0.1:
|
||||||
@@ -2624,6 +2719,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
|
|
||||||
|
escape-string-regexp@1.0.5: {}
|
||||||
|
|
||||||
estree-walker@3.0.3:
|
estree-walker@3.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.8
|
||||||
@@ -2664,6 +2761,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
|
|
||||||
|
filepaths@0.3.0: {}
|
||||||
|
|
||||||
fill-range@7.1.1:
|
fill-range@7.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
to-regex-range: 5.0.1
|
to-regex-range: 5.0.1
|
||||||
@@ -2715,6 +2814,8 @@ snapshots:
|
|||||||
|
|
||||||
graceful-fs@4.2.11: {}
|
graceful-fs@4.2.11: {}
|
||||||
|
|
||||||
|
has-flag@3.0.0: {}
|
||||||
|
|
||||||
has-flag@4.0.0: {}
|
has-flag@4.0.0: {}
|
||||||
|
|
||||||
has-symbols@1.1.0: {}
|
has-symbols@1.1.0: {}
|
||||||
@@ -2841,6 +2942,16 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@noble/hashes'
|
- '@noble/hashes'
|
||||||
|
|
||||||
|
jsinspect-plus@3.1.3:
|
||||||
|
dependencies:
|
||||||
|
'@babel/parser': 8.0.0-rc.3
|
||||||
|
chalk: 2.4.2
|
||||||
|
commander: 2.20.3
|
||||||
|
filepaths: 0.3.0
|
||||||
|
stable: 0.1.8
|
||||||
|
strip-indent: 3.0.0
|
||||||
|
strip-json-comments: 3.1.1
|
||||||
|
|
||||||
jsonfile@6.2.0:
|
jsonfile@6.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
universalify: 2.0.1
|
universalify: 2.0.1
|
||||||
@@ -3268,6 +3379,8 @@ snapshots:
|
|||||||
|
|
||||||
spark-md5@3.0.2: {}
|
spark-md5@3.0.2: {}
|
||||||
|
|
||||||
|
stable@0.1.8: {}
|
||||||
|
|
||||||
stackback@0.0.2: {}
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
std-env@4.0.0: {}
|
std-env@4.0.0: {}
|
||||||
@@ -3288,8 +3401,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
min-indent: 1.0.1
|
min-indent: 1.0.1
|
||||||
|
|
||||||
|
strip-json-comments@3.1.1: {}
|
||||||
|
|
||||||
strip-json-comments@5.0.3: {}
|
strip-json-comments@5.0.3: {}
|
||||||
|
|
||||||
|
supports-color@5.5.0:
|
||||||
|
dependencies:
|
||||||
|
has-flag: 3.0.0
|
||||||
|
|
||||||
supports-color@7.2.0:
|
supports-color@7.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
has-flag: 4.0.0
|
has-flag: 4.0.0
|
||||||
|
|||||||
@@ -9,11 +9,14 @@
|
|||||||
* Only scans component files (not hooks, adapters, etc.) and only
|
* Only scans component files (not hooks, adapters, etc.) and only
|
||||||
* counts properties declared directly in *Props interfaces — inherited
|
* counts properties declared directly in *Props interfaces — inherited
|
||||||
* or extended HTML attributes are not counted.
|
* or extended HTML attributes are not counted.
|
||||||
|
*
|
||||||
|
* Uses the TypeScript compiler API for accurate AST-based counting,
|
||||||
|
* immune to comments, strings, and complex type syntax.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { relative } from "node:path";
|
import { relative } from "node:path";
|
||||||
|
import ts from "typescript";
|
||||||
|
|
||||||
const MAX_PROPS = 8;
|
const MAX_PROPS = 8;
|
||||||
|
|
||||||
@@ -25,66 +28,38 @@ const files = execSync(
|
|||||||
.split("\n")
|
.split("\n")
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const program = ts.createProgram(files, {
|
||||||
|
target: ts.ScriptTarget.ESNext,
|
||||||
|
module: ts.ModuleKind.ESNext,
|
||||||
|
jsx: ts.JsxEmit.ReactJSX,
|
||||||
|
strict: true,
|
||||||
|
noEmit: true,
|
||||||
|
skipLibCheck: true,
|
||||||
|
});
|
||||||
|
|
||||||
let errors = 0;
|
let errors = 0;
|
||||||
|
|
||||||
const propsRegex = /^(?:export\s+)?interface\s+(\w+Props)\s*\{/;
|
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const content = readFileSync(file, "utf-8");
|
const sourceFile = program.getSourceFile(file);
|
||||||
const lines = content.split("\n");
|
if (!sourceFile) continue;
|
||||||
|
|
||||||
let inInterface = false;
|
ts.forEachChild(sourceFile, (node) => {
|
||||||
let interfaceName = "";
|
if (!ts.isInterfaceDeclaration(node)) return;
|
||||||
let braceDepth = 0;
|
if (!node.name.text.endsWith("Props")) return;
|
||||||
let parenDepth = 0;
|
|
||||||
let propCount = 0;
|
|
||||||
let startLine = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
const propCount = node.members.filter((m) =>
|
||||||
const line = lines[i];
|
ts.isPropertySignature(m),
|
||||||
|
).length;
|
||||||
|
|
||||||
if (!inInterface) {
|
if (propCount > MAX_PROPS) {
|
||||||
const match = propsRegex.exec(line);
|
const rel = relative(process.cwd(), file);
|
||||||
if (match) {
|
const { line } = sourceFile.getLineAndCharacterOfPosition(node.name.pos);
|
||||||
inInterface = true;
|
console.error(
|
||||||
interfaceName = match[1];
|
`${rel}:${line + 1}: ${node.name.text} has ${propCount} props (max ${MAX_PROPS})`,
|
||||||
braceDepth = 0;
|
);
|
||||||
parenDepth = 0;
|
errors++;
|
||||||
propCount = 0;
|
|
||||||
startLine = i + 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
if (inInterface) {
|
|
||||||
for (const ch of line) {
|
|
||||||
if (ch === "{") braceDepth++;
|
|
||||||
if (ch === "}") braceDepth--;
|
|
||||||
if (ch === "(") parenDepth++;
|
|
||||||
if (ch === ")") parenDepth--;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count prop lines at brace depth 1 and not inside function params:
|
|
||||||
// Matches " propName?: type" and " readonly propName: type"
|
|
||||||
if (
|
|
||||||
braceDepth === 1 &&
|
|
||||||
parenDepth === 0 &&
|
|
||||||
/^\s+(?:readonly\s+)?\w+\??\s*:/.test(line)
|
|
||||||
) {
|
|
||||||
propCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (braceDepth === 0) {
|
|
||||||
if (propCount > MAX_PROPS) {
|
|
||||||
const rel = relative(process.cwd(), file);
|
|
||||||
console.error(
|
|
||||||
`${rel}:${startLine}: ${interfaceName} has ${propCount} props (max ${MAX_PROPS})`,
|
|
||||||
);
|
|
||||||
errors++;
|
|
||||||
}
|
|
||||||
inInterface = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errors > 0) {
|
if (errors > 0) {
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ Deleting a player character MUST NOT remove or modify any combatants currently i
|
|||||||
|
|
||||||
### Key Entities
|
### Key Entities
|
||||||
|
|
||||||
- **PlayerCharacter**: A persistent, reusable character template with a unique `PlayerCharacterId` (branded string), required `name`, `ac` (number), `maxHp` (number), `color` (string from predefined set), and `icon` (string identifier from preset icon set).
|
- **PlayerCharacter**: A persistent, reusable character template with a unique `PlayerCharacterId` (branded string), required `name`, `ac` (number), `maxHp` (number), `color` (string from predefined set), `icon` (string identifier from preset icon set), and optional `level` (integer 1-20, added by spec 008 for encounter difficulty calculation).
|
||||||
- **PlayerCharacterStore** (port): Interface for loading, saving, and deleting player characters. Implemented as a browser storage adapter.
|
- **PlayerCharacterStore** (port): Interface for loading, saving, and deleting player characters. Implemented as a browser storage adapter.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user