Compare commits
15 Commits
0.9.15
..
b6ee4c8c86
| Author | SHA1 | Date | |
|---|---|---|---|
| b6ee4c8c86 | |||
| c295840b7b | |||
| d13641152f | |||
| 110f4726ae | |||
| 2bc22369ce | |||
| 2971d32f45 | |||
| a97044ec3e | |||
| a77db0eeee | |||
| d8c8a0c44d | |||
| 80dd68752e | |||
| 896fd427ed | |||
| 01b1bba6d6 | |||
| b7a97c3d88 | |||
| 1de00e3d8e | |||
| f4fb69dbc7 |
@@ -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,9 +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).
|
||||||
- **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()`.
|
- **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's `pnpm check` — the project's single earliest enforcement point. No gate may exist only as a CI step or manual process.
|
- **Quality gates** are enforced at pre-commit via Lefthook (parallel jobs). No gate may exist only as a CI step or manual process.
|
||||||
|
|
||||||
## Self-Review Checklist
|
## Self-Review Checklist
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
@@ -34,16 +34,42 @@ 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)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -55,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.
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* jsdom doesn't implement HTMLDialogElement.showModal/close.
|
||||||
|
* Call this in beforeAll() for tests that render <Dialog>.
|
||||||
|
*/
|
||||||
|
export function polyfillDialog(): void {
|
||||||
|
if (typeof HTMLDialogElement.prototype.showModal !== "function") {
|
||||||
|
HTMLDialogElement.prototype.showModal = function showModal() {
|
||||||
|
this.setAttribute("open", "");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (typeof HTMLDialogElement.prototype.close !== "function") {
|
||||||
|
HTMLDialogElement.prototype.close = function close() {
|
||||||
|
this.removeAttribute("open");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ async function getDb(): Promise<IDBPDatabase | null> {
|
|||||||
}
|
}
|
||||||
if (oldVersion < 2 && database.objectStoreNames.contains(STORE_NAME)) {
|
if (oldVersion < 2 && database.objectStoreNames.contains(STORE_NAME)) {
|
||||||
// Clear cached creatures to pick up improved tag processing
|
// Clear cached creatures to pick up improved tag processing
|
||||||
transaction.objectStore(STORE_NAME).clear();
|
void transaction.objectStore(STORE_NAME).clear();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import "@testing-library/jest-dom/vitest";
|
|||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
import { ActionBar } from "../action-bar.js";
|
import { ActionBar } from "../action-bar.js";
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ beforeAll(() => {
|
|||||||
dispatchEvent: vi.fn(),
|
dispatchEvent: vi.fn(),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
polyfillDialog();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
@@ -118,4 +120,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,165 @@
|
|||||||
|
// @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 { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||||
|
import { CreatePlayerModal } from "../create-player-modal.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
polyfillDialog();
|
||||||
|
});
|
||||||
|
|
||||||
|
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,63 @@
|
|||||||
|
// @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 { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||||
|
import { Dialog, DialogHeader } from "../ui/dialog.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
polyfillDialog();
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -72,6 +72,7 @@ function mockContext(overrides: Partial<Encounter> = {}) {
|
|||||||
setEncounter: vi.fn(),
|
setEncounter: vi.fn(),
|
||||||
setUndoRedoState: vi.fn(),
|
setUndoRedoState: vi.fn(),
|
||||||
events: [],
|
events: [],
|
||||||
|
lastCreatureId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
mockUseEncounterContext.mockReturnValue(
|
mockUseEncounterContext.mockReturnValue(
|
||||||
|
|||||||
@@ -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,7 +1,6 @@
|
|||||||
import { Check, ClipboardCopy, Download, X } from "lucide-react";
|
import { Check, ClipboardCopy, Download } from "lucide-react";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { Button } from "./ui/button.js";
|
import { Dialog, DialogHeader } from "./ui/dialog.js";
|
||||||
import { Dialog } from "./ui/dialog.js";
|
|
||||||
import { Input } from "./ui/input.js";
|
import { Input } from "./ui/input.js";
|
||||||
|
|
||||||
interface ExportMethodDialogProps {
|
interface ExportMethodDialogProps {
|
||||||
@@ -30,18 +29,7 @@ export function ExportMethodDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={handleClose} className="w-80">
|
<Dialog open={open} onClose={handleClose} className="w-80">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<DialogHeader title="Export Encounter" onClose={handleClose} />
|
||||||
<h2 className="font-semibold text-lg">Export Encounter</h2>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
onClick={handleClose}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ClipboardPaste, FileUp, X } from "lucide-react";
|
import { ClipboardPaste, FileUp } from "lucide-react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
import { Dialog } from "./ui/dialog.js";
|
import { Dialog, DialogHeader } from "./ui/dialog.js";
|
||||||
|
|
||||||
interface ImportMethodDialogProps {
|
interface ImportMethodDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -41,18 +41,7 @@ export function ImportMethodDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={handleClose} className="w-80">
|
<Dialog open={open} onClose={handleClose} className="w-80">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<DialogHeader title="Import Encounter" onClose={handleClose} />
|
||||||
<h2 className="font-semibold text-lg">Import Encounter</h2>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
onClick={handleClose}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{mode === "pick" && (
|
{mode === "pick" && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -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 { 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 } from "./ui/dialog";
|
import { Dialog, DialogHeader } from "./ui/dialog";
|
||||||
|
|
||||||
interface PlayerManagementProps {
|
interface PlayerManagementProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -24,19 +24,7 @@ export function PlayerManagement({
|
|||||||
}: Readonly<PlayerManagementProps>) {
|
}: Readonly<PlayerManagementProps>) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
|
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<DialogHeader title="Player Characters" onClose={onClose} />
|
||||||
<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">
|
||||||
|
|||||||
@@ -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 { 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";
|
||||||
import { Dialog } from "./ui/dialog.js";
|
|
||||||
|
|
||||||
interface SettingsModalProps {
|
interface SettingsModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -32,17 +31,7 @@ export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-sm">
|
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-sm">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<DialogHeader title="Settings" onClose={onClose} />
|
||||||
<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>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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 === " ") {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { X } from "lucide-react";
|
||||||
import { type ReactNode, useEffect, useRef } from "react";
|
import { type ReactNode, useEffect, useRef } from "react";
|
||||||
import { cn } from "../../lib/utils.js";
|
import { cn } from "../../lib/utils.js";
|
||||||
|
import { Button } from "./button.js";
|
||||||
|
|
||||||
interface DialogProps {
|
interface DialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -48,3 +50,22 @@ export function Dialog({ open, onClose, className, children }: DialogProps) {
|
|||||||
</dialog>
|
</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,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";
|
||||||
@@ -31,6 +38,7 @@ export function useActionBarState() {
|
|||||||
addFromBestiary,
|
addFromBestiary,
|
||||||
addMultipleFromBestiary,
|
addMultipleFromBestiary,
|
||||||
addFromPlayerCharacter,
|
addFromPlayerCharacter,
|
||||||
|
lastCreatureId,
|
||||||
} = useEncounterContext();
|
} = useEncounterContext();
|
||||||
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
|
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
|
||||||
useBestiaryContext();
|
useBestiaryContext();
|
||||||
@@ -38,6 +46,20 @@ export function useActionBarState() {
|
|||||||
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[]>([]);
|
||||||
@@ -73,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(
|
||||||
@@ -99,21 +117,10 @@ export function useActionBarState() {
|
|||||||
if (queued.count === 1) {
|
if (queued.count === 1) {
|
||||||
handleAddFromBestiary(queued.result);
|
handleAddFromBestiary(queued.result);
|
||||||
} else {
|
} else {
|
||||||
const creatureId = addMultipleFromBestiary(queued.result, queued.count);
|
addMultipleFromBestiary(queued.result, queued.count);
|
||||||
const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches;
|
|
||||||
if (creatureId && panelView.mode === "closed" && isDesktop) {
|
|
||||||
showCreature(creatureId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
clearInput();
|
clearInput();
|
||||||
}, [
|
}, [queued, handleAddFromBestiary, addMultipleFromBestiary, clearInput]);
|
||||||
queued,
|
|
||||||
handleAddFromBestiary,
|
|
||||||
addMultipleFromBestiary,
|
|
||||||
panelView.mode,
|
|
||||||
showCreature,
|
|
||||||
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]);
|
||||||
|
}
|
||||||
+433
-352
@@ -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,325 +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 addOneFromBestiary = useCallback(
|
|
||||||
(
|
|
||||||
entry: BestiaryIndexEntry,
|
|
||||||
): { cId: CreatureId; events: DomainEvent[] } | null => {
|
|
||||||
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)) return null;
|
|
||||||
|
|
||||||
return { cId, events: result };
|
|
||||||
},
|
|
||||||
[makeStore],
|
|
||||||
);
|
|
||||||
|
|
||||||
const addFromBestiary = useCallback(
|
|
||||||
(entry: BestiaryIndexEntry): CreatureId | null => {
|
|
||||||
const snapshot = encounterRef.current;
|
|
||||||
const added = addOneFromBestiary(entry);
|
|
||||||
|
|
||||||
if (!added) {
|
|
||||||
makeStore().save(snapshot);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newState = pushUndo(undoRedoRef.current, snapshot);
|
|
||||||
undoRedoRef.current = newState;
|
|
||||||
setUndoRedoState(newState);
|
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...added.events]);
|
|
||||||
return added.cId;
|
|
||||||
},
|
|
||||||
[makeStore, addOneFromBestiary],
|
|
||||||
);
|
|
||||||
|
|
||||||
const addMultipleFromBestiary = useCallback(
|
|
||||||
(entry: BestiaryIndexEntry, count: number): CreatureId | null => {
|
|
||||||
const snapshot = encounterRef.current;
|
|
||||||
const allEvents: DomainEvent[] = [];
|
|
||||||
let lastCId: CreatureId | null = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const added = addOneFromBestiary(entry);
|
|
||||||
if (!added) {
|
|
||||||
makeStore().save(snapshot);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
allEvents.push(...added.events);
|
|
||||||
lastCId = added.cId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newState = pushUndo(undoRedoRef.current, snapshot);
|
|
||||||
undoRedoRef.current = newState;
|
|
||||||
setUndoRedoState(newState);
|
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...allEvents]);
|
|
||||||
return lastCId;
|
|
||||||
},
|
|
||||||
[makeStore, addOneFromBestiary],
|
|
||||||
);
|
|
||||||
|
|
||||||
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,
|
||||||
@@ -452,27 +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 }),
|
||||||
addMultipleFromBestiary,
|
[],
|
||||||
addFromPlayerCharacter,
|
),
|
||||||
undo: undoAction,
|
setInitiative: useCallback(
|
||||||
redo: redoAction,
|
(id: CombatantId, value: number | undefined) =>
|
||||||
setEncounter,
|
dispatch({ type: "set-initiative", id, value }),
|
||||||
setUndoRedoState,
|
[],
|
||||||
|
),
|
||||||
|
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,
|
withUndo,
|
||||||
|
lastCreatureId: state.lastCreatureId,
|
||||||
} 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,134 +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("preserves level through save/load round-trip", () => {
|
|
||||||
const pc = makePC({ level: 5 });
|
|
||||||
savePlayerCharacters([pc]);
|
|
||||||
const loaded = loadPlayerCharacters();
|
|
||||||
expect(loaded[0].level).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves undefined level through save/load round-trip", () => {
|
|
||||||
const pc = makePC();
|
|
||||||
savePlayerCharacters([pc]);
|
|
||||||
const loaded = loadPlayerCharacters();
|
|
||||||
expect(loaded[0].level).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("discards character with invalid level", () => {
|
|
||||||
mockStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify([
|
|
||||||
{
|
|
||||||
id: "pc-1",
|
|
||||||
name: "Test",
|
|
||||||
ac: 10,
|
|
||||||
maxHp: 50,
|
|
||||||
color: "blue",
|
|
||||||
icon: "sword",
|
|
||||||
level: 25,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import type {
|
|||||||
PlayerCharacter,
|
PlayerCharacter,
|
||||||
UndoRedoState,
|
UndoRedoState,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
|
import { rehydratePlayerCharacter } from "@initiative/domain";
|
||||||
import { rehydrateEncounter } from "./encounter-storage.js";
|
import { rehydrateEncounter } from "./encounter-storage.js";
|
||||||
import { rehydrateCharacter } from "./player-character-storage.js";
|
|
||||||
|
|
||||||
function rehydrateStack(raw: unknown[]): Encounter[] {
|
function rehydrateStack(raw: unknown[]): Encounter[] {
|
||||||
const result: Encounter[] = [];
|
const result: Encounter[] = [];
|
||||||
@@ -21,7 +21,7 @@ function rehydrateStack(raw: unknown[]): Encounter[] {
|
|||||||
function rehydrateCharacters(raw: unknown[]): PlayerCharacter[] {
|
function rehydrateCharacters(raw: unknown[]): PlayerCharacter[] {
|
||||||
const result: PlayerCharacter[] = [];
|
const result: PlayerCharacter[] = [];
|
||||||
for (const entry of raw) {
|
for (const entry of raw) {
|
||||||
const rehydrated = rehydrateCharacter(entry);
|
const rehydrated = rehydratePlayerCharacter(entry);
|
||||||
if (rehydrated !== null) {
|
if (rehydrated !== null) {
|
||||||
result.push(rehydrated);
|
result.push(rehydrated);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,55 +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));
|
|
||||||
}
|
|
||||||
|
|
||||||
export 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;
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadPlayerCharacters(): PlayerCharacter[] {
|
export function loadPlayerCharacters(): PlayerCharacter[] {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
@@ -74,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 -- --deny warnings
|
||||||
|
- name: test
|
||||||
|
run: pnpm test
|
||||||
|
|||||||
+4
-2
@@ -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",
|
||||||
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
|
"jsinspect": "jsinspect -c .jsinspectrc apps/web/src packages/domain/src packages/application/src",
|
||||||
|
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware --deny warnings",
|
||||||
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
||||||
"check:classnames": "node scripts/check-cn-classnames.mjs",
|
"check:classnames": "node scripts/check-cn-classnames.mjs",
|
||||||
"check:props": "node scripts/check-component-props.mjs",
|
"check:props": "node scripts/check-component-props.mjs",
|
||||||
"check": "pnpm audit --audit-level=high && knip && biome check . && 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 --deny warnings && 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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -94,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,
|
||||||
@@ -126,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 {
|
||||||
|
|||||||
@@ -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) {
|
|
||||||
const match = propsRegex.exec(line);
|
|
||||||
if (match) {
|
|
||||||
inInterface = true;
|
|
||||||
interfaceName = match[1];
|
|
||||||
braceDepth = 0;
|
|
||||||
parenDepth = 0;
|
|
||||||
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) {
|
if (propCount > MAX_PROPS) {
|
||||||
const rel = relative(process.cwd(), file);
|
const rel = relative(process.cwd(), file);
|
||||||
|
const { line } = sourceFile.getLineAndCharacterOfPosition(node.name.pos);
|
||||||
console.error(
|
console.error(
|
||||||
`${rel}:${startLine}: ${interfaceName} has ${propCount} props (max ${MAX_PROPS})`,
|
`${rel}:${line + 1}: ${node.name.text} has ${propCount} props (max ${MAX_PROPS})`,
|
||||||
);
|
);
|
||||||
errors++;
|
errors++;
|
||||||
}
|
}
|
||||||
inInterface = false;
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errors > 0) {
|
if (errors > 0) {
|
||||||
|
|||||||
+14
-15
@@ -9,34 +9,33 @@ export default defineConfig({
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
exclude: ["**/dist/**"],
|
exclude: ["**/dist/**"],
|
||||||
thresholds: {
|
thresholds: {
|
||||||
autoUpdate: true,
|
|
||||||
"packages/domain/src": {
|
"packages/domain/src": {
|
||||||
lines: 99,
|
lines: 98,
|
||||||
branches: 97,
|
branches: 96,
|
||||||
},
|
},
|
||||||
"packages/application/src": {
|
"packages/application/src": {
|
||||||
lines: 97,
|
lines: 96,
|
||||||
branches: 94,
|
branches: 90,
|
||||||
},
|
},
|
||||||
"apps/web/src/adapters": {
|
"apps/web/src/adapters": {
|
||||||
lines: 72,
|
lines: 68,
|
||||||
branches: 78,
|
branches: 56,
|
||||||
},
|
},
|
||||||
"apps/web/src/persistence": {
|
"apps/web/src/persistence": {
|
||||||
lines: 90,
|
lines: 85,
|
||||||
branches: 71,
|
branches: 70,
|
||||||
},
|
},
|
||||||
"apps/web/src/hooks": {
|
"apps/web/src/hooks": {
|
||||||
lines: 59,
|
lines: 72,
|
||||||
branches: 85,
|
branches: 55,
|
||||||
},
|
},
|
||||||
"apps/web/src/components": {
|
"apps/web/src/components": {
|
||||||
lines: 52,
|
lines: 59,
|
||||||
branches: 64,
|
branches: 55,
|
||||||
},
|
},
|
||||||
"apps/web/src/components/ui": {
|
"apps/web/src/components/ui": {
|
||||||
lines: 73,
|
lines: 93,
|
||||||
branches: 96,
|
branches: 90,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user