Compare commits
16 Commits
0.9.14
...
b6ee4c8c86
| Author | SHA1 | Date | |
|---|---|---|---|
| b6ee4c8c86 | |||
| c295840b7b | |||
| d13641152f | |||
| 110f4726ae | |||
| 2bc22369ce | |||
| 2971d32f45 | |||
| a97044ec3e | |||
| a77db0eeee | |||
| d8c8a0c44d | |||
| 80dd68752e | |||
| 896fd427ed | |||
| 01b1bba6d6 | |||
| b7a97c3d88 | |||
| 1de00e3d8e | |||
| f4fb69dbc7 | |||
| ef76b9c90b |
@@ -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
|
||||
|
||||
```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 knip # Unused code detection (Knip)
|
||||
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.
|
||||
- **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.
|
||||
|
||||
@@ -60,7 +60,7 @@ docs/agents/ RPI skill artifacts (research reports, plans)
|
||||
- React 19, Vite 6, Tailwind CSS v4
|
||||
- Lucide React (icons)
|
||||
- `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)
|
||||
|
||||
## 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.
|
||||
- **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`.
|
||||
- **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()`.
|
||||
- **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
|
||||
|
||||
@@ -119,6 +119,7 @@ Speckit manages **what** to build (specs as living documents). RPI manages **how
|
||||
- `specs/005-player-characters/` — persistent player character templates (CRUD), search & add to encounters, color/icon visual distinction, `PlayerCharacterStore` port
|
||||
- `specs/006-undo-redo/` — undo/redo for encounter state mutations
|
||||
- `specs/007-json-import-export/` — JSON import/export for full encounter state (encounter, undo/redo, player characters)
|
||||
- `specs/008-encounter-difficulty/` — Live encounter difficulty indicator (5.5e XP budget system), optional PC level field
|
||||
|
||||
## Constitution (key principles)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -7,7 +7,8 @@ A local-first initiative tracker and encounter manager for tabletop RPGs (D&D 5e
|
||||
- **Initiative tracking** — add combatants (batch-add from bestiary, custom creatures with optional stats), roll initiative (manual or d20), cycle turns and rounds
|
||||
- **Encounter state** — HP, AC, conditions, concentration tracking with visual status indicators
|
||||
- **Bestiary integration** — import bestiary JSON sources, search creatures, and view full stat blocks
|
||||
- **Player characters** — create reusable player character templates with name, AC, HP, color, and icon; search and add them to encounters with pre-filled stats; manage (edit/delete) from a dedicated panel
|
||||
- **Player characters** — create reusable player character templates with name, AC, HP, level, color, and icon; search and add them to encounters with pre-filled stats; manage (edit/delete) from a dedicated panel
|
||||
- **Encounter difficulty** — live 3-bar indicator in the top bar showing encounter difficulty (Trivial/Low/Moderate/High) based on the 2024 5.5e XP budget system; automatically derived from PC levels and bestiary creature CRs
|
||||
- **Undo/redo** — reverse any encounter action with Undo/Redo buttons or keyboard shortcuts (Ctrl+Z / Ctrl+Shift+Z, Cmd on Mac); history persists across page reloads
|
||||
- **Import/export** — export the full encounter state (combatants, undo/redo history, player characters) as a JSON file or copy to clipboard; import from file upload or pasted JSON with validation and confirmation
|
||||
- **Persistent** — encounters survive page reloads via localStorage; bestiary data cached in IndexedDB; player characters stored independently
|
||||
@@ -33,17 +34,43 @@ Open `http://localhost:5173`.
|
||||
| `pnpm --filter web dev` | Start the dev server |
|
||||
| `pnpm --filter web build` | Production build |
|
||||
| `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
|
||||
|
||||
```
|
||||
apps/web/ React 19 + Vite — UI components, hooks, adapters
|
||||
packages/domain/ Pure functions — state transitions, types, validation
|
||||
packages/app/ Use cases — orchestrates domain via port interfaces
|
||||
data/bestiary/ Bestiary index for creature search
|
||||
scripts/ Build tooling (layer boundary checks, index generation)
|
||||
specs/ Feature specifications (spec → plan → tasks)
|
||||
apps/web/ React 19 + Vite — UI components, hooks, adapters
|
||||
packages/domain/ Pure functions — state transitions, types, validation
|
||||
packages/application/ Use cases — orchestrates domain via port interfaces
|
||||
data/bestiary/ Pre-built bestiary search index (~10k creatures)
|
||||
scripts/ Build tooling (layer checks, index generation)
|
||||
specs/ Feature specifications (spec → plan → tasks)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
@@ -54,5 +81,45 @@ Strict layered architecture with enforced dependency direction:
|
||||
apps/web (adapters) → packages/application (use cases) → packages/domain (pure logic)
|
||||
```
|
||||
|
||||
Domain is pure — no I/O, no randomness, no framework imports. Layer boundaries are enforced by automated import checks that run as part of the test suite. See [CLAUDE.md](./CLAUDE.md) for full conventions.
|
||||
- **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)) {
|
||||
// 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 userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { ActionBar } from "../action-bar.js";
|
||||
|
||||
@@ -50,6 +51,7 @@ beforeAll(() => {
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
polyfillDialog();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
@@ -118,4 +120,61 @@ describe("ActionBar", () => {
|
||||
screen.getByRole("button", { name: "More actions" }),
|
||||
).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();
|
||||
});
|
||||
});
|
||||
@@ -6,11 +6,19 @@ import { combatantId } from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock the context module
|
||||
// Mock the context modules
|
||||
vi.mock("../../contexts/encounter-context.js", () => ({
|
||||
useEncounterContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/player-characters-context.js", () => ({
|
||||
usePlayerCharactersContext: vi.fn().mockReturnValue({ characters: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||
useBestiaryContext: vi.fn().mockReturnValue({ getCreature: () => undefined }),
|
||||
}));
|
||||
|
||||
import { useEncounterContext } from "../../contexts/encounter-context.js";
|
||||
import { TurnNavigation } from "../turn-navigation.js";
|
||||
|
||||
@@ -64,6 +72,7 @@ function mockContext(overrides: Partial<Encounter> = {}) {
|
||||
setEncounter: vi.fn(),
|
||||
setUndoRedoState: vi.fn(),
|
||||
events: [],
|
||||
lastCreatureId: null,
|
||||
};
|
||||
|
||||
mockUseEncounterContext.mockReturnValue(
|
||||
|
||||
@@ -3,66 +3,17 @@ import {
|
||||
getConditionDescription,
|
||||
getConditionsForEdition,
|
||||
} from "@initiative/domain";
|
||||
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";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||
import { cn } from "../lib/utils";
|
||||
import {
|
||||
CONDITION_COLOR_CLASSES,
|
||||
CONDITION_ICON_MAP,
|
||||
} from "./condition-styles.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 {
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
activeConditions: readonly ConditionId[] | undefined;
|
||||
@@ -104,15 +55,7 @@ export function ConditionPicker({
|
||||
setPos({ top, left: anchorRect.left, maxHeight });
|
||||
}, [anchorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [onClose]);
|
||||
useClickOutside(ref, onClose);
|
||||
|
||||
const { edition } = useRulesEditionContext();
|
||||
const conditions = getConditionsForEdition(edition);
|
||||
@@ -129,10 +72,11 @@ export function ConditionPicker({
|
||||
}
|
||||
>
|
||||
{conditions.map((def) => {
|
||||
const Icon = ICON_MAP[def.iconName];
|
||||
const Icon = CONDITION_ICON_MAP[def.iconName];
|
||||
if (!Icon) return null;
|
||||
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 (
|
||||
<Tooltip
|
||||
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,
|
||||
getConditionDescription,
|
||||
} from "@initiative/domain";
|
||||
import type { LucideIcon } 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 { Plus } from "lucide-react";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import {
|
||||
CONDITION_COLOR_CLASSES,
|
||||
CONDITION_ICON_MAP,
|
||||
} from "./condition-styles.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 {
|
||||
conditions: readonly ConditionId[] | undefined;
|
||||
onRemove: (conditionId: ConditionId) => void;
|
||||
@@ -79,9 +29,10 @@ export function ConditionTags({
|
||||
{conditions?.map((condId) => {
|
||||
const def = CONDITION_DEFINITIONS.find((d) => d.id === condId);
|
||||
if (!def) return null;
|
||||
const Icon = ICON_MAP[def.iconName];
|
||||
const Icon = CONDITION_ICON_MAP[def.iconName];
|
||||
if (!Icon) return null;
|
||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||
const colorClass =
|
||||
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||
return (
|
||||
<Tooltip
|
||||
key={condId}
|
||||
|
||||
@@ -7,6 +7,13 @@ import { Button } from "./ui/button";
|
||||
import { Dialog } from "./ui/dialog";
|
||||
import { Input } from "./ui/input";
|
||||
|
||||
function parseLevel(value: string): number | undefined | "invalid" {
|
||||
if (value.trim() === "") return undefined;
|
||||
const n = Number.parseInt(value, 10);
|
||||
if (Number.isNaN(n) || n < 1 || n > 20) return "invalid";
|
||||
return n;
|
||||
}
|
||||
|
||||
interface CreatePlayerModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -16,6 +23,7 @@ interface CreatePlayerModalProps {
|
||||
maxHp: number,
|
||||
color: string | undefined,
|
||||
icon: string | undefined,
|
||||
level: number | undefined,
|
||||
) => void;
|
||||
playerCharacter?: PlayerCharacter;
|
||||
}
|
||||
@@ -31,6 +39,7 @@ export function CreatePlayerModal({
|
||||
const [maxHp, setMaxHp] = useState("10");
|
||||
const [color, setColor] = useState("blue");
|
||||
const [icon, setIcon] = useState("sword");
|
||||
const [level, setLevel] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const isEdit = !!playerCharacter;
|
||||
@@ -43,12 +52,18 @@ export function CreatePlayerModal({
|
||||
setMaxHp(String(playerCharacter.maxHp));
|
||||
setColor(playerCharacter.color ?? "");
|
||||
setIcon(playerCharacter.icon ?? "");
|
||||
setLevel(
|
||||
playerCharacter.level === undefined
|
||||
? ""
|
||||
: String(playerCharacter.level),
|
||||
);
|
||||
} else {
|
||||
setName("");
|
||||
setAc("10");
|
||||
setMaxHp("10");
|
||||
setColor("");
|
||||
setIcon("");
|
||||
setLevel("");
|
||||
}
|
||||
setError("");
|
||||
}
|
||||
@@ -71,7 +86,19 @@ export function CreatePlayerModal({
|
||||
setError("Max HP must be at least 1");
|
||||
return;
|
||||
}
|
||||
onSave(trimmed, acNum, hpNum, color || undefined, icon || undefined);
|
||||
const levelNum = parseLevel(level);
|
||||
if (levelNum === "invalid") {
|
||||
setError("Level must be between 1 and 20");
|
||||
return;
|
||||
}
|
||||
onSave(
|
||||
trimmed,
|
||||
acNum,
|
||||
hpNum,
|
||||
color || undefined,
|
||||
icon || undefined,
|
||||
levelNum,
|
||||
);
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -135,6 +162,20 @@ export function CreatePlayerModal({
|
||||
className="text-center"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className="mb-1 block text-muted-foreground text-sm">
|
||||
Level
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={level}
|
||||
onChange={(e) => setLevel(e.target.value)}
|
||||
placeholder="1-20"
|
||||
aria-label="Level"
|
||||
className="text-center"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { DifficultyResult, DifficultyTier } from "@initiative/domain";
|
||||
import { cn } from "../lib/utils.js";
|
||||
|
||||
const TIER_CONFIG: Record<
|
||||
DifficultyTier,
|
||||
{ filledBars: number; color: string; label: string }
|
||||
> = {
|
||||
trivial: { filledBars: 0, color: "", label: "Trivial" },
|
||||
low: { filledBars: 1, color: "bg-green-500", label: "Low" },
|
||||
moderate: { filledBars: 2, color: "bg-yellow-500", label: "Moderate" },
|
||||
high: { filledBars: 3, color: "bg-red-500", label: "High" },
|
||||
};
|
||||
|
||||
const BAR_HEIGHTS = ["h-2", "h-3", "h-4"] as const;
|
||||
|
||||
export function DifficultyIndicator({ result }: { result: DifficultyResult }) {
|
||||
const config = TIER_CONFIG[result.tier];
|
||||
const tooltip = `${config.label} encounter difficulty`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-end gap-0.5"
|
||||
title={tooltip}
|
||||
role="img"
|
||||
aria-label={tooltip}
|
||||
>
|
||||
{BAR_HEIGHTS.map((height, i) => (
|
||||
<div
|
||||
key={height}
|
||||
className={cn(
|
||||
"w-1 rounded-sm",
|
||||
height,
|
||||
i < config.filledBars ? config.color : "bg-muted",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 { Button } from "./ui/button.js";
|
||||
import { Dialog } from "./ui/dialog.js";
|
||||
import { Dialog, DialogHeader } from "./ui/dialog.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
interface ExportMethodDialogProps {
|
||||
@@ -30,18 +29,7 @@ export function ExportMethodDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} className="w-80">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<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>
|
||||
<DialogHeader title="Export Encounter" onClose={handleClose} />
|
||||
<div className="mb-3">
|
||||
<Input
|
||||
type="text"
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||
import { Input } from "./ui/input";
|
||||
|
||||
const DIGITS_ONLY_REGEX = /^\d+$/;
|
||||
@@ -48,15 +49,7 @@ export function HpAdjustPopover({
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [onClose]);
|
||||
useClickOutside(ref, onClose);
|
||||
|
||||
const parsedValue =
|
||||
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 { Button } from "./ui/button.js";
|
||||
import { Dialog } from "./ui/dialog.js";
|
||||
import { Dialog, DialogHeader } from "./ui/dialog.js";
|
||||
|
||||
interface ImportMethodDialogProps {
|
||||
open: boolean;
|
||||
@@ -41,18 +41,7 @@ export function ImportMethodDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} className="w-80">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<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>
|
||||
<DialogHeader title="Import Encounter" onClose={handleClose} />
|
||||
{mode === "pick" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
|
||||
@@ -35,7 +35,7 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
||||
setEditingPlayer(undefined);
|
||||
setManagementOpen(true);
|
||||
}}
|
||||
onSave={(name, ac, maxHp, color, icon) => {
|
||||
onSave={(name, ac, maxHp, color, icon, level) => {
|
||||
if (editingPlayer) {
|
||||
editCharacter(editingPlayer.id, {
|
||||
name,
|
||||
@@ -43,9 +43,10 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
||||
maxHp,
|
||||
color: color ?? null,
|
||||
icon: icon ?? null,
|
||||
level: level ?? null,
|
||||
});
|
||||
} else {
|
||||
createCharacter(name, ac, maxHp, color, icon);
|
||||
createCharacter(name, ac, maxHp, color, icon, level);
|
||||
}
|
||||
}}
|
||||
playerCharacter={editingPlayer}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 { Button } from "./ui/button";
|
||||
import { ConfirmButton } from "./ui/confirm-button";
|
||||
import { Dialog } from "./ui/dialog";
|
||||
import { Dialog, DialogHeader } from "./ui/dialog";
|
||||
|
||||
interface PlayerManagementProps {
|
||||
open: boolean;
|
||||
@@ -24,19 +24,7 @@ export function PlayerManagement({
|
||||
}: Readonly<PlayerManagementProps>) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-foreground text-lg">
|
||||
Player Characters
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<DialogHeader title="Player Characters" onClose={onClose} />
|
||||
|
||||
{characters.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||
@@ -68,6 +56,11 @@ export function PlayerManagement({
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
HP {pc.maxHp}
|
||||
</span>
|
||||
{pc.level !== undefined && (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
Lv {pc.level}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { RollMode } from "@initiative/domain";
|
||||
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 {
|
||||
readonly position: { x: number; y: number };
|
||||
@@ -34,22 +35,7 @@ export function RollModeMenu({
|
||||
setPos({ top, left });
|
||||
}, [position.x, position.y]);
|
||||
|
||||
useEffect(() => {
|
||||
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]);
|
||||
useClickOutside(ref, onClose);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
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 { useThemeContext } from "../contexts/theme-context.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Dialog } from "./ui/dialog.js";
|
||||
import { Dialog, DialogHeader } from "./ui/dialog.js";
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean;
|
||||
@@ -32,17 +31,7 @@ export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-sm">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-foreground text-lg">Settings</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<DialogHeader title="Settings" onClose={onClose} />
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
<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>) {
|
||||
const abilities = [
|
||||
{ label: "STR", score: creature.abilities.str },
|
||||
@@ -134,19 +159,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Traits */}
|
||||
{creature.traits && creature.traits.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<div className="space-y-2">
|
||||
{creature.traits.map((t) => (
|
||||
<div key={t.name} className="text-sm">
|
||||
<span className="font-semibold italic">{t.name}.</span> {t.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<TraitSection entries={creature.traits} />
|
||||
|
||||
{/* Spellcasting */}
|
||||
{creature.spellcasting && creature.spellcasting.length > 0 && (
|
||||
@@ -190,52 +203,9 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{creature.actions && creature.actions.length > 0 && (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
<TraitSection entries={creature.actions} heading="Actions" />
|
||||
<TraitSection entries={creature.bonusActions} heading="Bonus Actions" />
|
||||
<TraitSection entries={creature.reactions} heading="Reactions" />
|
||||
|
||||
{/* Legendary Actions */}
|
||||
{!!creature.legendaryActions && (
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Redo2, StepBack, StepForward, Trash2, Undo2 } from "lucide-react";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useDifficulty } from "../hooks/use-difficulty.js";
|
||||
import { DifficultyIndicator } from "./difficulty-indicator.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||
|
||||
@@ -15,6 +17,7 @@ export function TurnNavigation() {
|
||||
canRedo,
|
||||
} = useEncounterContext();
|
||||
|
||||
const difficulty = useDifficulty();
|
||||
const hasCombatants = encounter.combatants.length > 0;
|
||||
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
||||
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
||||
@@ -66,6 +69,7 @@ export function TurnNavigation() {
|
||||
) : (
|
||||
<span className="text-muted-foreground">No combatants</span>
|
||||
)}
|
||||
{difficulty && <DifficultyIndicator result={difficulty} />}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-3">
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useClickOutside } from "../../hooks/use-click-outside.js";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "./button";
|
||||
|
||||
@@ -42,32 +43,7 @@ export function ConfirmButton({
|
||||
return () => clearTimeout(timerRef.current);
|
||||
}, []);
|
||||
|
||||
// Click-outside listener when confirming
|
||||
useEffect(() => {
|
||||
if (!isConfirming) return;
|
||||
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (
|
||||
wrapperRef.current &&
|
||||
!wrapperRef.current.contains(e.target as Node)
|
||||
) {
|
||||
revert();
|
||||
}
|
||||
}
|
||||
|
||||
function 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]);
|
||||
useClickOutside(wrapperRef, revert, isConfirming);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { X } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
import { Button } from "./button.js";
|
||||
|
||||
interface DialogProps {
|
||||
open: boolean;
|
||||
@@ -48,3 +50,22 @@ export function Dialog({ open, onClose, className, children }: DialogProps) {
|
||||
</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 { 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";
|
||||
|
||||
export interface OverflowMenuItem {
|
||||
@@ -18,23 +19,7 @@ export function OverflowMenu({ items }: OverflowMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
useClickOutside(ref, () => setOpen(false), open);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
import type {
|
||||
BestiaryIndexEntry,
|
||||
ConditionId,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
import {
|
||||
combatantId,
|
||||
createEncounter,
|
||||
EMPTY_UNDO_REDO_STATE,
|
||||
isDomainError,
|
||||
playerCharacterId,
|
||||
} from "@initiative/domain";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { type EncounterState, encounterReducer } from "../use-encounter.js";
|
||||
|
||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
||||
loadEncounter: vi.fn().mockReturnValue(null),
|
||||
saveEncounter: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../persistence/undo-redo-storage.js", () => ({
|
||||
loadUndoRedoStacks: vi.fn().mockReturnValue(EMPTY_UNDO_REDO_STATE),
|
||||
saveUndoRedoStacks: vi.fn(),
|
||||
}));
|
||||
|
||||
function emptyState(): EncounterState {
|
||||
return {
|
||||
encounter: {
|
||||
combatants: [],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
},
|
||||
undoRedoState: EMPTY_UNDO_REDO_STATE,
|
||||
events: [],
|
||||
nextId: 0,
|
||||
lastCreatureId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function stateWith(...names: string[]): EncounterState {
|
||||
let state = emptyState();
|
||||
for (const name of names) {
|
||||
state = encounterReducer(state, { type: "add-combatant", name });
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function stateWithHp(name: string, maxHp: number): EncounterState {
|
||||
const state = stateWith(name);
|
||||
const id = state.encounter.combatants[0].id;
|
||||
return encounterReducer(state, {
|
||||
type: "set-hp",
|
||||
id,
|
||||
maxHp,
|
||||
});
|
||||
}
|
||||
|
||||
const BESTIARY_ENTRY: BestiaryIndexEntry = {
|
||||
name: "Goblin",
|
||||
source: "MM",
|
||||
ac: 15,
|
||||
hp: 7,
|
||||
dex: 14,
|
||||
cr: "1/4",
|
||||
initiativeProficiency: 0,
|
||||
size: "Small",
|
||||
type: "humanoid",
|
||||
};
|
||||
|
||||
describe("encounterReducer", () => {
|
||||
describe("add-combatant", () => {
|
||||
it("adds a combatant and pushes undo", () => {
|
||||
const next = encounterReducer(emptyState(), {
|
||||
type: "add-combatant",
|
||||
name: "Goblin",
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants).toHaveLength(1);
|
||||
expect(next.encounter.combatants[0].name).toBe("Goblin");
|
||||
expect(next.undoRedoState.undoStack).toHaveLength(1);
|
||||
expect(next.nextId).toBe(1);
|
||||
});
|
||||
|
||||
it("applies optional init values", () => {
|
||||
const next = encounterReducer(emptyState(), {
|
||||
type: "add-combatant",
|
||||
name: "Goblin",
|
||||
init: { initiative: 15, ac: 13, maxHp: 7 },
|
||||
});
|
||||
|
||||
const c = next.encounter.combatants[0];
|
||||
expect(c.initiative).toBe(15);
|
||||
expect(c.ac).toBe(13);
|
||||
expect(c.maxHp).toBe(7);
|
||||
expect(c.currentHp).toBe(7);
|
||||
});
|
||||
|
||||
it("increments IDs", () => {
|
||||
const s1 = encounterReducer(emptyState(), {
|
||||
type: "add-combatant",
|
||||
name: "A",
|
||||
});
|
||||
const s2 = encounterReducer(s1, {
|
||||
type: "add-combatant",
|
||||
name: "B",
|
||||
});
|
||||
|
||||
expect(s2.encounter.combatants[0].id).toBe("c-1");
|
||||
expect(s2.encounter.combatants[1].id).toBe("c-2");
|
||||
});
|
||||
|
||||
it("returns unchanged state for invalid name", () => {
|
||||
const state = emptyState();
|
||||
const next = encounterReducer(state, {
|
||||
type: "add-combatant",
|
||||
name: "",
|
||||
});
|
||||
|
||||
expect(next).toBe(state);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove-combatant", () => {
|
||||
it("removes combatant and pushes undo", () => {
|
||||
const state = stateWith("Goblin");
|
||||
const id = state.encounter.combatants[0].id;
|
||||
const next = encounterReducer(state, {
|
||||
type: "remove-combatant",
|
||||
id,
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants).toHaveLength(0);
|
||||
expect(next.undoRedoState.undoStack).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edit-combatant", () => {
|
||||
it("renames combatant", () => {
|
||||
const state = stateWith("Goblin");
|
||||
const id = state.encounter.combatants[0].id;
|
||||
const next = encounterReducer(state, {
|
||||
type: "edit-combatant",
|
||||
id,
|
||||
newName: "Hobgoblin",
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants[0].name).toBe("Hobgoblin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("advance-turn / retreat-turn", () => {
|
||||
it("advances and retreats turn", () => {
|
||||
const state = stateWith("A", "B");
|
||||
const advanced = encounterReducer(state, {
|
||||
type: "advance-turn",
|
||||
});
|
||||
expect(advanced.encounter.activeIndex).toBe(1);
|
||||
|
||||
const retreated = encounterReducer(advanced, {
|
||||
type: "retreat-turn",
|
||||
});
|
||||
expect(retreated.encounter.activeIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("returns unchanged state on empty encounter", () => {
|
||||
const state = emptyState();
|
||||
const next = encounterReducer(state, { type: "advance-turn" });
|
||||
expect(next).toBe(state);
|
||||
});
|
||||
});
|
||||
|
||||
describe("set-hp / adjust-hp / set-temp-hp", () => {
|
||||
it("sets max HP", () => {
|
||||
const state = stateWith("Goblin");
|
||||
const id = state.encounter.combatants[0].id;
|
||||
const next = encounterReducer(state, {
|
||||
type: "set-hp",
|
||||
id,
|
||||
maxHp: 20,
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants[0].maxHp).toBe(20);
|
||||
expect(next.encounter.combatants[0].currentHp).toBe(20);
|
||||
});
|
||||
|
||||
it("adjusts HP", () => {
|
||||
const state = stateWithHp("Goblin", 20);
|
||||
const id = state.encounter.combatants[0].id;
|
||||
const next = encounterReducer(state, {
|
||||
type: "adjust-hp",
|
||||
id,
|
||||
delta: -5,
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants[0].currentHp).toBe(15);
|
||||
});
|
||||
|
||||
it("sets temp HP", () => {
|
||||
const state = stateWithHp("Goblin", 20);
|
||||
const id = state.encounter.combatants[0].id;
|
||||
const next = encounterReducer(state, {
|
||||
type: "set-temp-hp",
|
||||
id,
|
||||
tempHp: 5,
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants[0].tempHp).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("set-ac", () => {
|
||||
it("sets AC", () => {
|
||||
const state = stateWith("Goblin");
|
||||
const id = state.encounter.combatants[0].id;
|
||||
const next = encounterReducer(state, {
|
||||
type: "set-ac",
|
||||
id,
|
||||
value: 15,
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants[0].ac).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe("set-initiative", () => {
|
||||
it("sets initiative", () => {
|
||||
const state = stateWith("Goblin");
|
||||
const id = state.encounter.combatants[0].id;
|
||||
const next = encounterReducer(state, {
|
||||
type: "set-initiative",
|
||||
id,
|
||||
value: 18,
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants[0].initiative).toBe(18);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggle-condition / toggle-concentration", () => {
|
||||
it("toggles condition", () => {
|
||||
const state = stateWith("Goblin");
|
||||
const id = state.encounter.combatants[0].id;
|
||||
const next = encounterReducer(state, {
|
||||
type: "toggle-condition",
|
||||
id,
|
||||
conditionId: "blinded" as ConditionId,
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants[0].conditions).toContain("blinded");
|
||||
});
|
||||
|
||||
it("toggles concentration", () => {
|
||||
const state = stateWith("Wizard");
|
||||
const id = state.encounter.combatants[0].id;
|
||||
const next = encounterReducer(state, {
|
||||
type: "toggle-concentration",
|
||||
id,
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants[0].isConcentrating).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear-encounter", () => {
|
||||
it("clears combatants, resets history and nextId", () => {
|
||||
const state = stateWith("A", "B");
|
||||
const next = encounterReducer(state, {
|
||||
type: "clear-encounter",
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants).toHaveLength(0);
|
||||
expect(next.undoRedoState.undoStack).toHaveLength(0);
|
||||
expect(next.undoRedoState.redoStack).toHaveLength(0);
|
||||
expect(next.nextId).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("undo / redo", () => {
|
||||
it("undo restores previous state", () => {
|
||||
const state = stateWith("Goblin");
|
||||
const next = encounterReducer(state, { type: "undo" });
|
||||
|
||||
expect(next.encounter.combatants).toHaveLength(0);
|
||||
expect(next.undoRedoState.undoStack).toHaveLength(0);
|
||||
expect(next.undoRedoState.redoStack).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("redo restores undone state", () => {
|
||||
const state = stateWith("Goblin");
|
||||
const undone = encounterReducer(state, { type: "undo" });
|
||||
const redone = encounterReducer(undone, { type: "redo" });
|
||||
|
||||
expect(redone.encounter.combatants).toHaveLength(1);
|
||||
expect(redone.encounter.combatants[0].name).toBe("Goblin");
|
||||
});
|
||||
|
||||
it("undo returns unchanged state when stack is empty", () => {
|
||||
const state = emptyState();
|
||||
const next = encounterReducer(state, { type: "undo" });
|
||||
expect(next).toBe(state);
|
||||
});
|
||||
|
||||
it("redo returns unchanged state when stack is empty", () => {
|
||||
const state = emptyState();
|
||||
const next = encounterReducer(state, { type: "redo" });
|
||||
expect(next).toBe(state);
|
||||
});
|
||||
});
|
||||
|
||||
describe("add-from-bestiary", () => {
|
||||
it("adds creature with HP, AC, and creatureId", () => {
|
||||
const next = encounterReducer(emptyState(), {
|
||||
type: "add-from-bestiary",
|
||||
entry: BESTIARY_ENTRY,
|
||||
});
|
||||
|
||||
const c = next.encounter.combatants[0];
|
||||
expect(c.name).toBe("Goblin");
|
||||
expect(c.maxHp).toBe(7);
|
||||
expect(c.ac).toBe(15);
|
||||
expect(c.creatureId).toBe("mm:goblin");
|
||||
expect(next.lastCreatureId).toBe("mm:goblin");
|
||||
expect(next.undoRedoState.undoStack).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("auto-numbers duplicate names", () => {
|
||||
const s1 = encounterReducer(emptyState(), {
|
||||
type: "add-from-bestiary",
|
||||
entry: BESTIARY_ENTRY,
|
||||
});
|
||||
const s2 = encounterReducer(s1, {
|
||||
type: "add-from-bestiary",
|
||||
entry: BESTIARY_ENTRY,
|
||||
});
|
||||
|
||||
const names = s2.encounter.combatants.map((c) => c.name);
|
||||
expect(names).toContain("Goblin 1");
|
||||
expect(names).toContain("Goblin 2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("add-multiple-from-bestiary", () => {
|
||||
it("adds multiple creatures in one action", () => {
|
||||
const next = encounterReducer(emptyState(), {
|
||||
type: "add-multiple-from-bestiary",
|
||||
entry: BESTIARY_ENTRY,
|
||||
count: 3,
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants).toHaveLength(3);
|
||||
expect(next.undoRedoState.undoStack).toHaveLength(1);
|
||||
expect(next.lastCreatureId).toBe("mm:goblin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("add-from-player-character", () => {
|
||||
it("adds combatant with PC attributes", () => {
|
||||
const pc: PlayerCharacter = {
|
||||
id: playerCharacterId("pc-1"),
|
||||
name: "Aria",
|
||||
ac: 16,
|
||||
maxHp: 30,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
};
|
||||
const next = encounterReducer(emptyState(), {
|
||||
type: "add-from-player-character",
|
||||
pc,
|
||||
});
|
||||
|
||||
const c = next.encounter.combatants[0];
|
||||
expect(c.name).toBe("Aria");
|
||||
expect(c.maxHp).toBe(30);
|
||||
expect(c.ac).toBe(16);
|
||||
expect(c.color).toBe("blue");
|
||||
expect(c.icon).toBe("sword");
|
||||
expect(c.playerCharacterId).toBe("pc-1");
|
||||
expect(next.lastCreatureId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("import", () => {
|
||||
it("replaces encounter and undo/redo state", () => {
|
||||
const state = stateWith("A", "B");
|
||||
const enc = createEncounter([
|
||||
{ id: combatantId("c-5"), name: "Imported" },
|
||||
]);
|
||||
if (isDomainError(enc)) throw new Error("Setup failed");
|
||||
|
||||
const next = encounterReducer(state, {
|
||||
type: "import",
|
||||
encounter: enc,
|
||||
undoRedoState: EMPTY_UNDO_REDO_STATE,
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants).toHaveLength(1);
|
||||
expect(next.encounter.combatants[0].name).toBe("Imported");
|
||||
expect(next.nextId).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("events accumulation", () => {
|
||||
it("accumulates events across actions", () => {
|
||||
const s1 = encounterReducer(emptyState(), {
|
||||
type: "add-combatant",
|
||||
name: "A",
|
||||
});
|
||||
const s2 = encounterReducer(s1, {
|
||||
type: "add-combatant",
|
||||
name: "B",
|
||||
});
|
||||
|
||||
expect(s2.events.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import { playerCharacterId } from "@initiative/domain";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SearchResult } from "../../contexts/bestiary-context.js";
|
||||
import { useActionBarState } from "../use-action-bar-state.js";
|
||||
|
||||
const mockAddCombatant = vi.fn();
|
||||
const mockAddFromBestiary = vi.fn();
|
||||
const mockAddMultipleFromBestiary = vi.fn();
|
||||
const mockAddFromPlayerCharacter = vi.fn();
|
||||
const mockBestiarySearch = vi.fn<(q: string) => SearchResult[]>();
|
||||
const mockShowCreature = vi.fn();
|
||||
const mockShowBulkImport = vi.fn();
|
||||
const mockShowSourceManager = vi.fn();
|
||||
|
||||
vi.mock("../../contexts/encounter-context.js", () => ({
|
||||
useEncounterContext: () => ({
|
||||
addCombatant: mockAddCombatant,
|
||||
addFromBestiary: mockAddFromBestiary,
|
||||
addMultipleFromBestiary: mockAddMultipleFromBestiary,
|
||||
addFromPlayerCharacter: mockAddFromPlayerCharacter,
|
||||
lastCreatureId: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||
useBestiaryContext: () => ({
|
||||
search: mockBestiarySearch,
|
||||
isLoaded: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/player-characters-context.js", () => ({
|
||||
usePlayerCharactersContext: () => ({
|
||||
characters: mockPlayerCharacters,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/side-panel-context.js", () => ({
|
||||
useSidePanelContext: () => ({
|
||||
showCreature: mockShowCreature,
|
||||
showBulkImport: mockShowBulkImport,
|
||||
showSourceManager: mockShowSourceManager,
|
||||
panelView: { mode: "closed" },
|
||||
}),
|
||||
}));
|
||||
|
||||
let mockPlayerCharacters: PlayerCharacter[] = [];
|
||||
|
||||
const GOBLIN: SearchResult = {
|
||||
name: "Goblin",
|
||||
source: "MM",
|
||||
sourceDisplayName: "Monster Manual",
|
||||
ac: 15,
|
||||
hp: 7,
|
||||
dex: 14,
|
||||
cr: "1/4",
|
||||
initiativeProficiency: 0,
|
||||
size: "Small",
|
||||
type: "humanoid",
|
||||
};
|
||||
|
||||
const ORC: SearchResult = {
|
||||
name: "Orc",
|
||||
source: "MM",
|
||||
sourceDisplayName: "Monster Manual",
|
||||
ac: 13,
|
||||
hp: 15,
|
||||
dex: 12,
|
||||
cr: "1/2",
|
||||
initiativeProficiency: 0,
|
||||
size: "Medium",
|
||||
type: "humanoid",
|
||||
};
|
||||
|
||||
function renderActionBar() {
|
||||
return renderHook(() => useActionBarState());
|
||||
}
|
||||
|
||||
describe("useActionBarState", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockBestiarySearch.mockReturnValue([]);
|
||||
mockPlayerCharacters = [];
|
||||
});
|
||||
|
||||
describe("search and suggestions", () => {
|
||||
it("starts with empty state", () => {
|
||||
const { result } = renderActionBar();
|
||||
|
||||
expect(result.current.nameInput).toBe("");
|
||||
expect(result.current.suggestions).toEqual([]);
|
||||
expect(result.current.queued).toBeNull();
|
||||
expect(result.current.browseMode).toBe(false);
|
||||
});
|
||||
|
||||
it("searches bestiary when input >= 2 chars", () => {
|
||||
mockBestiarySearch.mockReturnValue([GOBLIN]);
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.handleNameChange("go"));
|
||||
|
||||
expect(mockBestiarySearch).toHaveBeenCalledWith("go");
|
||||
expect(result.current.nameInput).toBe("go");
|
||||
});
|
||||
|
||||
it("does not search when input < 2 chars", () => {
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.handleNameChange("g"));
|
||||
|
||||
expect(mockBestiarySearch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("matches player characters by name", () => {
|
||||
mockPlayerCharacters = [
|
||||
{
|
||||
id: playerCharacterId("pc-1"),
|
||||
name: "Gandalf",
|
||||
ac: 15,
|
||||
maxHp: 40,
|
||||
},
|
||||
];
|
||||
mockBestiarySearch.mockReturnValue([]);
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.handleNameChange("gan"));
|
||||
|
||||
expect(result.current.pcMatches).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("queued creatures", () => {
|
||||
it("queues a creature on click", () => {
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||
|
||||
expect(result.current.queued).toEqual({
|
||||
result: GOBLIN,
|
||||
count: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("increments count when same creature clicked again", () => {
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||
|
||||
expect(result.current.queued?.count).toBe(2);
|
||||
});
|
||||
|
||||
it("resets queue when different creature clicked", () => {
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||
act(() => result.current.suggestionActions.clickSuggestion(ORC));
|
||||
|
||||
expect(result.current.queued).toEqual({
|
||||
result: ORC,
|
||||
count: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("confirmQueued calls addFromBestiary for count=1", () => {
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||
act(() => result.current.suggestionActions.confirmQueued());
|
||||
|
||||
expect(mockAddFromBestiary).toHaveBeenCalledWith(GOBLIN);
|
||||
expect(result.current.queued).toBeNull();
|
||||
});
|
||||
|
||||
it("confirmQueued calls addMultipleFromBestiary for count>1", () => {
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||
act(() => result.current.suggestionActions.confirmQueued());
|
||||
|
||||
expect(mockAddMultipleFromBestiary).toHaveBeenCalledWith(GOBLIN, 3);
|
||||
});
|
||||
|
||||
it("clears queued when search text changes and creature no longer visible", () => {
|
||||
mockBestiarySearch.mockReturnValue([GOBLIN]);
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.handleNameChange("go"));
|
||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||
|
||||
// Change search to something that won't match
|
||||
mockBestiarySearch.mockReturnValue([]);
|
||||
act(() => result.current.handleNameChange("xyz"));
|
||||
|
||||
expect(result.current.queued).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("form submission", () => {
|
||||
it("adds custom combatant on submit", () => {
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.handleNameChange("Fighter"));
|
||||
|
||||
const event = {
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as React.SubmitEvent<HTMLFormElement>;
|
||||
act(() => result.current.handleAdd(event));
|
||||
|
||||
expect(mockAddCombatant).toHaveBeenCalledWith("Fighter", undefined);
|
||||
expect(result.current.nameInput).toBe("");
|
||||
});
|
||||
|
||||
it("does not add when name is empty", () => {
|
||||
const { result } = renderActionBar();
|
||||
|
||||
const event = {
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as React.SubmitEvent<HTMLFormElement>;
|
||||
act(() => result.current.handleAdd(event));
|
||||
|
||||
expect(mockAddCombatant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes custom init/ac/maxHp when set", () => {
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.handleNameChange("Fighter"));
|
||||
act(() => result.current.setCustomInit("15"));
|
||||
act(() => result.current.setCustomAc("18"));
|
||||
act(() => result.current.setCustomMaxHp("45"));
|
||||
|
||||
const event = {
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as React.SubmitEvent<HTMLFormElement>;
|
||||
act(() => result.current.handleAdd(event));
|
||||
|
||||
expect(mockAddCombatant).toHaveBeenCalledWith("Fighter", {
|
||||
initiative: 15,
|
||||
ac: 18,
|
||||
maxHp: 45,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not submit in browse mode", () => {
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.toggleBrowseMode());
|
||||
act(() => result.current.handleNameChange("Fighter"));
|
||||
|
||||
const event = {
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as React.SubmitEvent<HTMLFormElement>;
|
||||
act(() => result.current.handleAdd(event));
|
||||
|
||||
expect(mockAddCombatant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("confirms queued on submit instead of adding by name", () => {
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||
|
||||
const event = {
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as React.SubmitEvent<HTMLFormElement>;
|
||||
act(() => result.current.handleAdd(event));
|
||||
|
||||
expect(mockAddFromBestiary).toHaveBeenCalledWith(GOBLIN);
|
||||
expect(mockAddCombatant).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("browse mode", () => {
|
||||
it("toggles browse mode", () => {
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.toggleBrowseMode());
|
||||
expect(result.current.browseMode).toBe(true);
|
||||
|
||||
act(() => result.current.toggleBrowseMode());
|
||||
expect(result.current.browseMode).toBe(false);
|
||||
});
|
||||
|
||||
it("handleBrowseSelect shows creature and exits browse mode", () => {
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.toggleBrowseMode());
|
||||
act(() => result.current.handleBrowseSelect(GOBLIN));
|
||||
|
||||
expect(mockShowCreature).toHaveBeenCalledWith("mm:goblin");
|
||||
expect(result.current.browseMode).toBe(false);
|
||||
expect(result.current.nameInput).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dismiss and clear", () => {
|
||||
it("dismissSuggestions clears suggestions and queued", () => {
|
||||
mockBestiarySearch.mockReturnValue([GOBLIN]);
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.handleNameChange("go"));
|
||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||
act(() => result.current.suggestionActions.dismiss());
|
||||
|
||||
expect(result.current.queued).toBeNull();
|
||||
expect(result.current.suggestionIndex).toBe(-1);
|
||||
});
|
||||
|
||||
it("clear resets everything", () => {
|
||||
mockBestiarySearch.mockReturnValue([GOBLIN]);
|
||||
const { result } = renderActionBar();
|
||||
|
||||
act(() => result.current.handleNameChange("go"));
|
||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
||||
act(() => result.current.suggestionActions.clear());
|
||||
|
||||
expect(result.current.nameInput).toBe("");
|
||||
expect(result.current.queued).toBeNull();
|
||||
expect(result.current.suggestionIndex).toBe(-1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
// @vitest-environment jsdom
|
||||
import type {
|
||||
Combatant,
|
||||
CreatureId,
|
||||
Encounter,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../contexts/encounter-context.js", () => ({
|
||||
useEncounterContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/player-characters-context.js", () => ({
|
||||
usePlayerCharactersContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||
useBestiaryContext: vi.fn(),
|
||||
}));
|
||||
|
||||
import { useBestiaryContext } from "../../contexts/bestiary-context.js";
|
||||
import { useEncounterContext } from "../../contexts/encounter-context.js";
|
||||
import { usePlayerCharactersContext } from "../../contexts/player-characters-context.js";
|
||||
import { useDifficulty } from "../use-difficulty.js";
|
||||
|
||||
const mockEncounterContext = vi.mocked(useEncounterContext);
|
||||
const mockPlayerCharactersContext = vi.mocked(usePlayerCharactersContext);
|
||||
const mockBestiaryContext = vi.mocked(useBestiaryContext);
|
||||
|
||||
const pcId1 = playerCharacterId("pc-1");
|
||||
const pcId2 = playerCharacterId("pc-2");
|
||||
const crId1 = creatureId("creature-1");
|
||||
const _crId2 = creatureId("creature-2");
|
||||
|
||||
function setup(options: {
|
||||
combatants: Combatant[];
|
||||
characters: PlayerCharacter[];
|
||||
creatures: Map<CreatureId, { cr: string }>;
|
||||
}) {
|
||||
const encounter = {
|
||||
combatants: options.combatants,
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
} as Encounter;
|
||||
|
||||
mockEncounterContext.mockReturnValue({
|
||||
encounter,
|
||||
} as ReturnType<typeof useEncounterContext>);
|
||||
|
||||
mockPlayerCharactersContext.mockReturnValue({
|
||||
characters: options.characters,
|
||||
} as ReturnType<typeof usePlayerCharactersContext>);
|
||||
|
||||
mockBestiaryContext.mockReturnValue({
|
||||
getCreature: (id: CreatureId) => options.creatures.get(id),
|
||||
} as ReturnType<typeof useBestiaryContext>);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("useDifficulty", () => {
|
||||
it("returns difficulty result for leveled PCs and bestiary monsters", () => {
|
||||
setup({
|
||||
combatants: [
|
||||
{ id: combatantId("c1"), name: "Hero", playerCharacterId: pcId1 },
|
||||
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
|
||||
],
|
||||
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }],
|
||||
creatures: new Map([[crId1, { cr: "1/4" }]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
|
||||
expect(result.current).not.toBeNull();
|
||||
expect(result.current?.tier).toBe("low");
|
||||
expect(result.current?.totalMonsterXp).toBe(50);
|
||||
});
|
||||
|
||||
describe("returns null when data is insufficient (ED-2)", () => {
|
||||
it("returns null when encounter has no combatants", () => {
|
||||
setup({ combatants: [], characters: [], creatures: new Map() });
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when only custom combatants (no creatureId)", () => {
|
||||
setup({
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c1"),
|
||||
name: "Custom",
|
||||
playerCharacterId: pcId1,
|
||||
},
|
||||
],
|
||||
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }],
|
||||
creatures: new Map(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when bestiary monsters present but no PC combatants", () => {
|
||||
setup({
|
||||
combatants: [
|
||||
{ id: combatantId("c1"), name: "Goblin", creatureId: crId1 },
|
||||
],
|
||||
characters: [],
|
||||
creatures: new Map([[crId1, { cr: "1" }]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when PC combatants have no level", () => {
|
||||
setup({
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
},
|
||||
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
|
||||
],
|
||||
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30 }],
|
||||
creatures: new Map([[crId1, { cr: "1" }]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when PC combatant references unknown player character", () => {
|
||||
setup({
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId2,
|
||||
},
|
||||
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
|
||||
],
|
||||
characters: [{ id: pcId1, name: "Other", ac: 15, maxHp: 30, level: 5 }],
|
||||
creatures: new Map([[crId1, { cr: "1" }]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("handles mixed combatants: only leveled PCs and bestiary monsters contribute", () => {
|
||||
// Party: one leveled PC, one without level (excluded)
|
||||
// Monsters: one bestiary creature, one custom (excluded)
|
||||
setup({
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c1"),
|
||||
name: "Leveled",
|
||||
playerCharacterId: pcId1,
|
||||
},
|
||||
{
|
||||
id: combatantId("c2"),
|
||||
name: "No Level",
|
||||
playerCharacterId: pcId2,
|
||||
},
|
||||
{ id: combatantId("c3"), name: "Goblin", creatureId: crId1 },
|
||||
{ id: combatantId("c4"), name: "Custom Monster" },
|
||||
],
|
||||
characters: [
|
||||
{ id: pcId1, name: "Leveled", ac: 15, maxHp: 30, level: 1 },
|
||||
{ id: pcId2, name: "No Level", ac: 12, maxHp: 20 },
|
||||
],
|
||||
creatures: new Map([[crId1, { cr: "1" }]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
|
||||
expect(result.current).not.toBeNull();
|
||||
// 1 level-1 PC: budget low=50, mod=75, high=100
|
||||
// 1 CR 1 monster: 200 XP → high (200 >= 100)
|
||||
expect(result.current?.tier).toBe("high");
|
||||
expect(result.current?.totalMonsterXp).toBe(200);
|
||||
expect(result.current?.partyBudget.low).toBe(50);
|
||||
});
|
||||
|
||||
it("includes duplicate PC combatants in budget", () => {
|
||||
// Same PC added twice → counts twice
|
||||
setup({
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c1"),
|
||||
name: "Hero 1",
|
||||
playerCharacterId: pcId1,
|
||||
},
|
||||
{
|
||||
id: combatantId("c2"),
|
||||
name: "Hero 2",
|
||||
playerCharacterId: pcId1,
|
||||
},
|
||||
{ id: combatantId("c3"), name: "Goblin", creatureId: crId1 },
|
||||
],
|
||||
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }],
|
||||
creatures: new Map([[crId1, { cr: "1/4" }]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty());
|
||||
|
||||
expect(result.current).not.toBeNull();
|
||||
// 2x level 1: budget low=100
|
||||
expect(result.current?.partyBudget.low).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,14 @@ describe("usePlayerCharacters", () => {
|
||||
const { result } = renderHook(() => usePlayerCharacters());
|
||||
|
||||
act(() => {
|
||||
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
|
||||
result.current.createCharacter(
|
||||
"Vex",
|
||||
15,
|
||||
28,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.characters).toHaveLength(1);
|
||||
@@ -57,7 +64,14 @@ describe("usePlayerCharacters", () => {
|
||||
|
||||
let error: unknown;
|
||||
act(() => {
|
||||
error = result.current.createCharacter("", 15, 28, undefined, undefined);
|
||||
error = result.current.createCharacter(
|
||||
"",
|
||||
15,
|
||||
28,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
expect(error).toMatchObject({ kind: "domain-error" });
|
||||
@@ -68,7 +82,14 @@ describe("usePlayerCharacters", () => {
|
||||
const { result } = renderHook(() => usePlayerCharacters());
|
||||
|
||||
act(() => {
|
||||
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
|
||||
result.current.createCharacter(
|
||||
"Vex",
|
||||
15,
|
||||
28,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
const id = result.current.characters[0].id;
|
||||
@@ -85,7 +106,14 @@ describe("usePlayerCharacters", () => {
|
||||
const { result } = renderHook(() => usePlayerCharacters());
|
||||
|
||||
act(() => {
|
||||
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
|
||||
result.current.createCharacter(
|
||||
"Vex",
|
||||
15,
|
||||
28,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
const id = result.current.characters[0].id;
|
||||
|
||||
@@ -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 { useCallback, useDeferredValue, useMemo, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { SearchResult } from "../contexts/bestiary-context.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
@@ -31,6 +38,7 @@ export function useActionBarState() {
|
||||
addFromBestiary,
|
||||
addMultipleFromBestiary,
|
||||
addFromPlayerCharacter,
|
||||
lastCreatureId,
|
||||
} = useEncounterContext();
|
||||
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
|
||||
useBestiaryContext();
|
||||
@@ -38,6 +46,20 @@ export function useActionBarState() {
|
||||
const { showBulkImport, showSourceManager, showCreature, panelView } =
|
||||
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 [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
||||
@@ -73,13 +95,9 @@ export function useActionBarState() {
|
||||
|
||||
const handleAddFromBestiary = useCallback(
|
||||
(result: SearchResult) => {
|
||||
const creatureId = addFromBestiary(result);
|
||||
const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches;
|
||||
if (creatureId && panelView.mode === "closed" && isDesktop) {
|
||||
showCreature(creatureId);
|
||||
}
|
||||
addFromBestiary(result);
|
||||
},
|
||||
[addFromBestiary, panelView.mode, showCreature],
|
||||
[addFromBestiary],
|
||||
);
|
||||
|
||||
const handleViewStatBlock = useCallback(
|
||||
@@ -99,21 +117,10 @@ export function useActionBarState() {
|
||||
if (queued.count === 1) {
|
||||
handleAddFromBestiary(queued.result);
|
||||
} else {
|
||||
const creatureId = addMultipleFromBestiary(queued.result, queued.count);
|
||||
const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches;
|
||||
if (creatureId && panelView.mode === "closed" && isDesktop) {
|
||||
showCreature(creatureId);
|
||||
}
|
||||
addMultipleFromBestiary(queued.result, queued.count);
|
||||
}
|
||||
clearInput();
|
||||
}, [
|
||||
queued,
|
||||
handleAddFromBestiary,
|
||||
addMultipleFromBestiary,
|
||||
panelView.mode,
|
||||
showCreature,
|
||||
clearInput,
|
||||
]);
|
||||
}, [queued, handleAddFromBestiary, addMultipleFromBestiary, clearInput]);
|
||||
|
||||
const parseNum = (v: string): number | undefined => {
|
||||
if (v.trim() === "") return undefined;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function useClickOutside(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
onClose: () => void,
|
||||
active = true,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [ref, onClose, active]);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type {
|
||||
Combatant,
|
||||
CreatureId,
|
||||
DifficultyResult,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
import { calculateEncounterDifficulty } from "@initiative/domain";
|
||||
import { useMemo } from "react";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||
|
||||
function derivePartyLevels(
|
||||
combatants: readonly Combatant[],
|
||||
characters: readonly PlayerCharacter[],
|
||||
): number[] {
|
||||
const levels: number[] = [];
|
||||
for (const c of combatants) {
|
||||
if (!c.playerCharacterId) continue;
|
||||
const pc = characters.find((p) => p.id === c.playerCharacterId);
|
||||
if (pc?.level !== undefined) levels.push(pc.level);
|
||||
}
|
||||
return levels;
|
||||
}
|
||||
|
||||
function deriveMonsterCrs(
|
||||
combatants: readonly Combatant[],
|
||||
getCreature: (id: CreatureId) => { cr: string } | undefined,
|
||||
): string[] {
|
||||
const crs: string[] = [];
|
||||
for (const c of combatants) {
|
||||
if (!c.creatureId) continue;
|
||||
const creature = getCreature(c.creatureId);
|
||||
if (creature) crs.push(creature.cr);
|
||||
}
|
||||
return crs;
|
||||
}
|
||||
|
||||
export function useDifficulty(): DifficultyResult | null {
|
||||
const { encounter } = useEncounterContext();
|
||||
const { characters } = usePlayerCharactersContext();
|
||||
const { getCreature } = useBestiaryContext();
|
||||
|
||||
return useMemo(() => {
|
||||
const partyLevels = derivePartyLevels(encounter.combatants, characters);
|
||||
const monsterCrs = deriveMonsterCrs(encounter.combatants, getCreature);
|
||||
|
||||
if (partyLevels.length === 0 || monsterCrs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return calculateEncounterDifficulty(partyLevels, monsterCrs);
|
||||
}, [encounter.combatants, characters, getCreature]);
|
||||
}
|
||||
+433
-352
@@ -22,6 +22,7 @@ import type {
|
||||
CombatantInit,
|
||||
ConditionId,
|
||||
CreatureId,
|
||||
DomainError,
|
||||
DomainEvent,
|
||||
Encounter,
|
||||
PlayerCharacter,
|
||||
@@ -35,7 +36,7 @@ import {
|
||||
pushUndo,
|
||||
resolveCreatureName,
|
||||
} from "@initiative/domain";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useReducer, useRef } from "react";
|
||||
import {
|
||||
loadEncounter,
|
||||
saveEncounter,
|
||||
@@ -45,6 +46,51 @@ import {
|
||||
saveUndoRedoStacks,
|
||||
} 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 EMPTY_ENCOUNTER: Encounter = {
|
||||
@@ -53,12 +99,6 @@ const EMPTY_ENCOUNTER: Encounter = {
|
||||
roundNumber: 1,
|
||||
};
|
||||
|
||||
function initializeEncounter(): Encounter {
|
||||
const stored = loadEncounter();
|
||||
if (stored !== null) return stored;
|
||||
return EMPTY_ENCOUNTER;
|
||||
}
|
||||
|
||||
function deriveNextId(encounter: Encounter): number {
|
||||
let max = 0;
|
||||
for (const c of encounter.combatants) {
|
||||
@@ -71,11 +111,283 @@ function deriveNextId(encounter: Encounter): number {
|
||||
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() {
|
||||
const [encounter, setEncounter] = useState<Encounter>(initializeEncounter);
|
||||
const [events, setEvents] = useState<DomainEvent[]>([]);
|
||||
const [undoRedoState, setUndoRedoState] =
|
||||
useState<UndoRedoState>(loadUndoRedoStacks);
|
||||
const [state, dispatch] = useReducer(encounterReducer, null, initializeState);
|
||||
const { encounter, undoRedoState, events } = state;
|
||||
|
||||
const encounterRef = useRef(encounter);
|
||||
encounterRef.current = encounter;
|
||||
const undoRedoRef = useRef(undoRedoState);
|
||||
@@ -89,22 +401,17 @@ export function useEncounter() {
|
||||
saveUndoRedoStacks(undoRedoState);
|
||||
}, [undoRedoState]);
|
||||
|
||||
// Escape hatches for useInitiativeRolls (needs raw port access)
|
||||
const makeStore = useCallback((): EncounterStore => {
|
||||
return {
|
||||
get: () => encounterRef.current,
|
||||
save: (e) => {
|
||||
encounterRef.current = e;
|
||||
setEncounter(e);
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
const makeUndoRedoStore = useCallback((): UndoRedoStore => {
|
||||
return {
|
||||
get: () => undoRedoRef.current,
|
||||
save: (s) => {
|
||||
undoRedoRef.current = s;
|
||||
setUndoRedoState(s);
|
||||
dispatch({
|
||||
type: "import",
|
||||
encounter: e,
|
||||
undoRedoState: undoRedoRef.current,
|
||||
});
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
@@ -115,325 +422,21 @@ export function useEncounter() {
|
||||
if (!isDomainError(result)) {
|
||||
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||
undoRedoRef.current = newState;
|
||||
setUndoRedoState(newState);
|
||||
dispatch({
|
||||
type: "import",
|
||||
encounter: encounterRef.current,
|
||||
undoRedoState: newState,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
const advanceTurn = useCallback(() => {
|
||||
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]);
|
||||
|
||||
// Derived state
|
||||
const canUndo = undoRedoState.undoStack.length > 0;
|
||||
const canRedo = undoRedoState.redoStack.length > 0;
|
||||
|
||||
const hasTempHp = encounter.combatants.some(
|
||||
(c) => c.tempHp !== undefined && c.tempHp > 0,
|
||||
);
|
||||
|
||||
const isEmpty = encounter.combatants.length === 0;
|
||||
const hasCreatureCombatants = encounter.combatants.some(
|
||||
(c) => c.creatureId != null,
|
||||
@@ -452,27 +455,105 @@ export function useEncounter() {
|
||||
canRollAllInitiative,
|
||||
canUndo,
|
||||
canRedo,
|
||||
advanceTurn,
|
||||
retreatTurn,
|
||||
addCombatant,
|
||||
clearEncounter,
|
||||
removeCombatant,
|
||||
editCombatant,
|
||||
setInitiative,
|
||||
setHp,
|
||||
adjustHp,
|
||||
setTempHp,
|
||||
setAc,
|
||||
toggleCondition,
|
||||
toggleConcentration,
|
||||
addFromBestiary,
|
||||
addMultipleFromBestiary,
|
||||
addFromPlayerCharacter,
|
||||
undo: undoAction,
|
||||
redo: redoAction,
|
||||
setEncounter,
|
||||
setUndoRedoState,
|
||||
advanceTurn: useCallback(() => dispatch({ type: "advance-turn" }), []),
|
||||
retreatTurn: useCallback(() => dispatch({ type: "retreat-turn" }), []),
|
||||
addCombatant: useCallback(
|
||||
(name: string, init?: CombatantInit) =>
|
||||
dispatch({ type: "add-combatant", name, init }),
|
||||
[],
|
||||
),
|
||||
removeCombatant: useCallback(
|
||||
(id: CombatantId) => dispatch({ type: "remove-combatant", id }),
|
||||
[],
|
||||
),
|
||||
editCombatant: useCallback(
|
||||
(id: CombatantId, newName: string) =>
|
||||
dispatch({ type: "edit-combatant", id, newName }),
|
||||
[],
|
||||
),
|
||||
setInitiative: useCallback(
|
||||
(id: CombatantId, value: number | undefined) =>
|
||||
dispatch({ type: "set-initiative", id, value }),
|
||||
[],
|
||||
),
|
||||
setHp: useCallback(
|
||||
(id: CombatantId, maxHp: number | undefined) =>
|
||||
dispatch({ type: "set-hp", id, maxHp }),
|
||||
[],
|
||||
),
|
||||
adjustHp: useCallback(
|
||||
(id: CombatantId, delta: number) =>
|
||||
dispatch({ type: "adjust-hp", id, delta }),
|
||||
[],
|
||||
),
|
||||
setTempHp: useCallback(
|
||||
(id: CombatantId, tempHp: number | undefined) =>
|
||||
dispatch({ type: "set-temp-hp", id, tempHp }),
|
||||
[],
|
||||
),
|
||||
setAc: useCallback(
|
||||
(id: CombatantId, value: number | undefined) =>
|
||||
dispatch({ type: "set-ac", id, value }),
|
||||
[],
|
||||
),
|
||||
toggleCondition: useCallback(
|
||||
(id: CombatantId, conditionId: ConditionId) =>
|
||||
dispatch({ type: "toggle-condition", id, conditionId }),
|
||||
[],
|
||||
),
|
||||
toggleConcentration: useCallback(
|
||||
(id: CombatantId) => dispatch({ type: "toggle-concentration", id }),
|
||||
[],
|
||||
),
|
||||
clearEncounter: useCallback(
|
||||
() => dispatch({ type: "clear-encounter" }),
|
||||
[],
|
||||
),
|
||||
addFromBestiary: useCallback(
|
||||
(entry: BestiaryIndexEntry): CreatureId | null => {
|
||||
dispatch({ type: "add-from-bestiary", entry });
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
),
|
||||
addMultipleFromBestiary: useCallback(
|
||||
(entry: BestiaryIndexEntry, count: number): CreatureId | null => {
|
||||
dispatch({
|
||||
type: "add-multiple-from-bestiary",
|
||||
entry,
|
||||
count,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
),
|
||||
addFromPlayerCharacter: useCallback(
|
||||
(pc: PlayerCharacter) =>
|
||||
dispatch({ type: "add-from-player-character", pc }),
|
||||
[],
|
||||
),
|
||||
undo: useCallback(() => dispatch({ type: "undo" }), []),
|
||||
redo: useCallback(() => dispatch({ type: "redo" }), []),
|
||||
setEncounter: useCallback(
|
||||
(enc: Encounter) =>
|
||||
dispatch({
|
||||
type: "import",
|
||||
encounter: enc,
|
||||
undoRedoState: undoRedoRef.current,
|
||||
}),
|
||||
[],
|
||||
),
|
||||
setUndoRedoState: useCallback(
|
||||
(urs: UndoRedoState) =>
|
||||
dispatch({
|
||||
type: "import",
|
||||
encounter: encounterRef.current,
|
||||
undoRedoState: urs,
|
||||
}),
|
||||
[],
|
||||
),
|
||||
makeStore,
|
||||
withUndo,
|
||||
lastCreatureId: state.lastCreatureId,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ interface EditFields {
|
||||
readonly maxHp?: number;
|
||||
readonly color?: string | null;
|
||||
readonly icon?: string | null;
|
||||
readonly level?: number | null;
|
||||
}
|
||||
|
||||
export function usePlayerCharacters() {
|
||||
@@ -57,6 +58,7 @@ export function usePlayerCharacters() {
|
||||
maxHp: number,
|
||||
color: string | undefined,
|
||||
icon: string | undefined,
|
||||
level: number | undefined,
|
||||
) => {
|
||||
const id = generatePcId();
|
||||
const result = createPlayerCharacterUseCase(
|
||||
@@ -67,6 +69,7 @@ export function usePlayerCharacters() {
|
||||
maxHp,
|
||||
color,
|
||||
icon,
|
||||
level,
|
||||
);
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
|
||||
@@ -122,64 +122,7 @@ describe("loadEncounter", () => {
|
||||
expect(loadEncounter()).toBeNull();
|
||||
});
|
||||
|
||||
// US3: Corrupt data scenarios
|
||||
it("returns null for non-object JSON (string)", () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify("hello"));
|
||||
expect(loadEncounter()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-object JSON (number)", () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(42));
|
||||
expect(loadEncounter()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-object JSON (array)", () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([1, 2, 3]));
|
||||
expect(loadEncounter()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-object JSON (null)", () => {
|
||||
localStorage.setItem(STORAGE_KEY, "null");
|
||||
expect(loadEncounter()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when combatants is a string instead of array", () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
combatants: "not-array",
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
}),
|
||||
);
|
||||
expect(loadEncounter()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when activeIndex is a string instead of number", () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
combatants: [{ id: "1", name: "Aria" }],
|
||||
activeIndex: "zero",
|
||||
roundNumber: 1,
|
||||
}),
|
||||
);
|
||||
expect(loadEncounter()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when combatant entry is missing id", () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
combatants: [{ name: "Aria" }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
}),
|
||||
);
|
||||
expect(loadEncounter()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when combatant entry is missing name", () => {
|
||||
it("returns null when combatant has invalid required fields", () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
@@ -191,88 +134,6 @@ describe("loadEncounter", () => {
|
||||
expect(loadEncounter()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for negative roundNumber", () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
combatants: [{ id: "1", name: "Aria" }],
|
||||
activeIndex: 0,
|
||||
roundNumber: -1,
|
||||
}),
|
||||
);
|
||||
expect(loadEncounter()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns empty encounter for zero combatants (cleared state)", () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ combatants: [], activeIndex: 0, roundNumber: 1 }),
|
||||
);
|
||||
const result = loadEncounter();
|
||||
expect(result).toEqual({
|
||||
combatants: [],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trip preserves combatant AC value", () => {
|
||||
const result = createEncounter(
|
||||
[{ id: combatantId("1"), name: "Aria", ac: 18 }],
|
||||
0,
|
||||
1,
|
||||
);
|
||||
if (isDomainError(result)) throw new Error("unreachable");
|
||||
saveEncounter(result);
|
||||
const loaded = loadEncounter();
|
||||
expect(loaded?.combatants[0].ac).toBe(18);
|
||||
});
|
||||
|
||||
it("round-trip preserves combatant without AC", () => {
|
||||
const result = createEncounter(
|
||||
[{ id: combatantId("1"), name: "Aria" }],
|
||||
0,
|
||||
1,
|
||||
);
|
||||
if (isDomainError(result)) throw new Error("unreachable");
|
||||
saveEncounter(result);
|
||||
const loaded = loadEncounter();
|
||||
expect(loaded?.combatants[0].ac).toBeUndefined();
|
||||
});
|
||||
|
||||
it("discards invalid AC values during rehydration", () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
combatants: [
|
||||
{ id: "1", name: "Neg", ac: -1 },
|
||||
{ id: "2", name: "Float", ac: 3.5 },
|
||||
{ id: "3", name: "Str", ac: "high" },
|
||||
],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
}),
|
||||
);
|
||||
const loaded = loadEncounter();
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded?.combatants[0].ac).toBeUndefined();
|
||||
expect(loaded?.combatants[1].ac).toBeUndefined();
|
||||
expect(loaded?.combatants[2].ac).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves AC of 0 during rehydration", () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
combatants: [{ id: "1", name: "Aria", ac: 0 }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
}),
|
||||
);
|
||||
const loaded = loadEncounter();
|
||||
expect(loaded?.combatants[0].ac).toBe(0);
|
||||
});
|
||||
|
||||
it("saving after modifications persists the latest state", () => {
|
||||
const encounter = makeEncounter();
|
||||
saveEncounter(encounter);
|
||||
|
||||
@@ -90,102 +90,7 @@ describe("player-character-storage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("per-character validation", () => {
|
||||
it("discards character with missing name", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{ id: "pc-1", ac: 10, maxHp: 50, color: "blue", icon: "sword" },
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards character with empty name", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "",
|
||||
ac: 10,
|
||||
maxHp: 50,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards character with invalid color", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Test",
|
||||
ac: 10,
|
||||
maxHp: 50,
|
||||
color: "neon",
|
||||
icon: "sword",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards character with invalid icon", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Test",
|
||||
ac: 10,
|
||||
maxHp: 50,
|
||||
color: "blue",
|
||||
icon: "banana",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards character with negative AC", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Test",
|
||||
ac: -1,
|
||||
maxHp: 50,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards character with maxHp of 0", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Test",
|
||||
ac: 10,
|
||||
maxHp: 0,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
describe("delegation to domain rehydration", () => {
|
||||
it("keeps valid characters and discards invalid ones", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import {
|
||||
type ConditionId,
|
||||
combatantId,
|
||||
type Combatant,
|
||||
createEncounter,
|
||||
creatureId,
|
||||
type Encounter,
|
||||
isDomainError,
|
||||
playerCharacterId,
|
||||
VALID_CONDITION_IDS,
|
||||
VALID_PLAYER_COLORS,
|
||||
VALID_PLAYER_ICONS,
|
||||
rehydrateCombatant,
|
||||
} from "@initiative/domain";
|
||||
|
||||
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 {
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
||||
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);
|
||||
if (isDomainError(result)) return null;
|
||||
|
||||
return result;
|
||||
return encounter;
|
||||
}
|
||||
|
||||
export function loadEncounter(): Encounter | null {
|
||||
|
||||
@@ -4,8 +4,8 @@ import type {
|
||||
PlayerCharacter,
|
||||
UndoRedoState,
|
||||
} from "@initiative/domain";
|
||||
import { rehydratePlayerCharacter } from "@initiative/domain";
|
||||
import { rehydrateEncounter } from "./encounter-storage.js";
|
||||
import { rehydrateCharacter } from "./player-character-storage.js";
|
||||
|
||||
function rehydrateStack(raw: unknown[]): Encounter[] {
|
||||
const result: Encounter[] = [];
|
||||
@@ -21,7 +21,7 @@ function rehydrateStack(raw: unknown[]): Encounter[] {
|
||||
function rehydrateCharacters(raw: unknown[]): PlayerCharacter[] {
|
||||
const result: PlayerCharacter[] = [];
|
||||
for (const entry of raw) {
|
||||
const rehydrated = rehydrateCharacter(entry);
|
||||
const rehydrated = rehydratePlayerCharacter(entry);
|
||||
if (rehydrated !== null) {
|
||||
result.push(rehydrated);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import {
|
||||
playerCharacterId,
|
||||
VALID_PLAYER_COLORS,
|
||||
VALID_PLAYER_ICONS,
|
||||
} from "@initiative/domain";
|
||||
import { rehydratePlayerCharacter } from "@initiative/domain";
|
||||
|
||||
const STORAGE_KEY = "initiative:player-characters";
|
||||
|
||||
@@ -15,46 +11,6 @@ export function savePlayerCharacters(characters: PlayerCharacter[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
function isValidOptionalMember(
|
||||
value: unknown,
|
||||
valid: ReadonlySet<string>,
|
||||
): boolean {
|
||||
return value === undefined || (typeof value === "string" && valid.has(value));
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
return {
|
||||
id: playerCharacterId(entry.id),
|
||||
name: entry.name,
|
||||
ac: entry.ac,
|
||||
maxHp: entry.maxHp,
|
||||
color: entry.color as PlayerCharacter["color"],
|
||||
icon: entry.icon as PlayerCharacter["icon"],
|
||||
};
|
||||
}
|
||||
|
||||
export function loadPlayerCharacters(): PlayerCharacter[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
@@ -65,7 +21,7 @@ export function loadPlayerCharacters(): PlayerCharacter[] {
|
||||
|
||||
const characters: PlayerCharacter[] = [];
|
||||
for (const item of parsed) {
|
||||
const pc = rehydrateCharacter(item);
|
||||
const pc = rehydratePlayerCharacter(item);
|
||||
if (pc !== null) {
|
||||
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:
|
||||
parallel: true
|
||||
jobs:
|
||||
- name: check
|
||||
run: pnpm check
|
||||
- name: audit
|
||||
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",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"jscpd": "^4.0.8",
|
||||
"jsinspect-plus": "^3.1.3",
|
||||
"knip": "^5.88.1",
|
||||
"lefthook": "^2.1.4",
|
||||
"oxlint": "^1.56.0",
|
||||
@@ -29,10 +30,11 @@
|
||||
"test:watch": "vitest",
|
||||
"knip": "knip",
|
||||
"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:classnames": "node scripts/check-cn-classnames.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 { isDomainError } from "@initiative/domain";
|
||||
import type { EncounterStore, PlayerCharacterStore } from "../ports.js";
|
||||
import type {
|
||||
Encounter,
|
||||
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 {
|
||||
if (value === null) throw new Error("Expected store.saved to be non-null");
|
||||
@@ -52,3 +60,17 @@ export function stubPlayerCharacterStore(
|
||||
};
|
||||
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,
|
||||
combatantId,
|
||||
createEncounter,
|
||||
EMPTY_UNDO_REDO_STATE,
|
||||
isDomainError,
|
||||
playerCharacterId,
|
||||
pushUndo,
|
||||
} from "@initiative/domain";
|
||||
import { describe, expect, it } from "vitest";
|
||||
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 { editCombatantUseCase } from "../edit-combatant-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 { retreatTurnUseCase } from "../retreat-turn-use-case.js";
|
||||
import { setAcUseCase } from "../set-ac-use-case.js";
|
||||
import { setHpUseCase } from "../set-hp-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 { toggleConditionUseCase } from "../toggle-condition-use-case.js";
|
||||
import { undoUseCase } from "../undo-use-case.js";
|
||||
import {
|
||||
requireSaved,
|
||||
stubEncounterStore,
|
||||
stubPlayerCharacterStore,
|
||||
stubUndoRedoStore,
|
||||
} from "./helpers.js";
|
||||
|
||||
const ID_A = combatantId("a");
|
||||
@@ -386,3 +392,80 @@ describe("editPlayerCharacterUseCase", () => {
|
||||
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 DomainError,
|
||||
type DomainEvent,
|
||||
isDomainError,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
import { runEncounterAction } from "./run-encounter-action.js";
|
||||
|
||||
export function addCombatantUseCase(
|
||||
store: EncounterStore,
|
||||
@@ -14,13 +14,7 @@ export function addCombatantUseCase(
|
||||
name: string,
|
||||
init?: CombatantInit,
|
||||
): DomainEvent[] | DomainError {
|
||||
const encounter = store.get();
|
||||
const result = addCombatant(encounter, id, name, init);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
store.save(result.encounter);
|
||||
return result.events;
|
||||
return runEncounterAction(store, (encounter) =>
|
||||
addCombatant(encounter, id, name, init),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,22 +3,16 @@ import {
|
||||
type CombatantId,
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
isDomainError,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
import { runEncounterAction } from "./run-encounter-action.js";
|
||||
|
||||
export function adjustHpUseCase(
|
||||
store: EncounterStore,
|
||||
combatantId: CombatantId,
|
||||
delta: number,
|
||||
): DomainEvent[] | DomainError {
|
||||
const encounter = store.get();
|
||||
const result = adjustHp(encounter, combatantId, delta);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
store.save(result.encounter);
|
||||
return result.events;
|
||||
return runEncounterAction(store, (encounter) =>
|
||||
adjustHp(encounter, combatantId, delta),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,20 +2,12 @@ import {
|
||||
advanceTurn,
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
isDomainError,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
import { runEncounterAction } from "./run-encounter-action.js";
|
||||
|
||||
export function advanceTurnUseCase(
|
||||
store: EncounterStore,
|
||||
): DomainEvent[] | DomainError {
|
||||
const encounter = store.get();
|
||||
const result = advanceTurn(encounter);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
store.save(result.encounter);
|
||||
return result.events;
|
||||
return runEncounterAction(store, (encounter) => advanceTurn(encounter));
|
||||
}
|
||||
|
||||
@@ -2,20 +2,12 @@ import {
|
||||
clearEncounter,
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
isDomainError,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
import { runEncounterAction } from "./run-encounter-action.js";
|
||||
|
||||
export function clearEncounterUseCase(
|
||||
store: EncounterStore,
|
||||
): DomainEvent[] | DomainError {
|
||||
const encounter = store.get();
|
||||
const result = clearEncounter(encounter);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
store.save(result.encounter);
|
||||
return result.events;
|
||||
return runEncounterAction(store, (encounter) => clearEncounter(encounter));
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export function createPlayerCharacterUseCase(
|
||||
maxHp: number,
|
||||
color: string | undefined,
|
||||
icon: string | undefined,
|
||||
level?: number,
|
||||
): DomainEvent[] | DomainError {
|
||||
const characters = store.getAll();
|
||||
const result = createPlayerCharacter(
|
||||
@@ -25,6 +26,7 @@ export function createPlayerCharacterUseCase(
|
||||
maxHp,
|
||||
color,
|
||||
icon,
|
||||
level,
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
|
||||
@@ -3,22 +3,16 @@ import {
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
editCombatant,
|
||||
isDomainError,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
import { runEncounterAction } from "./run-encounter-action.js";
|
||||
|
||||
export function editCombatantUseCase(
|
||||
store: EncounterStore,
|
||||
id: CombatantId,
|
||||
newName: string,
|
||||
): DomainEvent[] | DomainError {
|
||||
const encounter = store.get();
|
||||
const result = editCombatant(encounter, id, newName);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
store.save(result.encounter);
|
||||
return result.events;
|
||||
return runEncounterAction(store, (encounter) =>
|
||||
editCombatant(encounter, id, newName),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ interface EditFields {
|
||||
readonly maxHp?: number;
|
||||
readonly color?: string | null;
|
||||
readonly icon?: string | null;
|
||||
readonly level?: number | null;
|
||||
}
|
||||
|
||||
export function editPlayerCharacterUseCase(
|
||||
|
||||
@@ -2,22 +2,16 @@ import {
|
||||
type CombatantId,
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
isDomainError,
|
||||
removeCombatant,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
import { runEncounterAction } from "./run-encounter-action.js";
|
||||
|
||||
export function removeCombatantUseCase(
|
||||
store: EncounterStore,
|
||||
id: CombatantId,
|
||||
): DomainEvent[] | DomainError {
|
||||
const encounter = store.get();
|
||||
const result = removeCombatant(encounter, id);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
store.save(result.encounter);
|
||||
return result.events;
|
||||
return runEncounterAction(store, (encounter) =>
|
||||
removeCombatant(encounter, id),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import {
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
isDomainError,
|
||||
retreatTurn,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
import { runEncounterAction } from "./run-encounter-action.js";
|
||||
|
||||
export function retreatTurnUseCase(
|
||||
store: EncounterStore,
|
||||
): DomainEvent[] | DomainError {
|
||||
const encounter = store.get();
|
||||
const result = retreatTurn(encounter);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
store.save(result.encounter);
|
||||
return result.events;
|
||||
return runEncounterAction(store, (encounter) => retreatTurn(encounter));
|
||||
}
|
||||
|
||||
@@ -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 DomainError,
|
||||
type DomainEvent,
|
||||
isDomainError,
|
||||
setAc,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
import { runEncounterAction } from "./run-encounter-action.js";
|
||||
|
||||
export function setAcUseCase(
|
||||
store: EncounterStore,
|
||||
combatantId: CombatantId,
|
||||
value: number | undefined,
|
||||
): DomainEvent[] | DomainError {
|
||||
const encounter = store.get();
|
||||
const result = setAc(encounter, combatantId, value);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
store.save(result.encounter);
|
||||
return result.events;
|
||||
return runEncounterAction(store, (encounter) =>
|
||||
setAc(encounter, combatantId, value),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,23 +2,17 @@ import {
|
||||
type CombatantId,
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
isDomainError,
|
||||
setHp,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
import { runEncounterAction } from "./run-encounter-action.js";
|
||||
|
||||
export function setHpUseCase(
|
||||
store: EncounterStore,
|
||||
combatantId: CombatantId,
|
||||
maxHp: number | undefined,
|
||||
): DomainEvent[] | DomainError {
|
||||
const encounter = store.get();
|
||||
const result = setHp(encounter, combatantId, maxHp);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
store.save(result.encounter);
|
||||
return result.events;
|
||||
return runEncounterAction(store, (encounter) =>
|
||||
setHp(encounter, combatantId, maxHp),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,23 +2,17 @@ import {
|
||||
type CombatantId,
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
isDomainError,
|
||||
setInitiative,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
import { runEncounterAction } from "./run-encounter-action.js";
|
||||
|
||||
export function setInitiativeUseCase(
|
||||
store: EncounterStore,
|
||||
combatantId: CombatantId,
|
||||
value: number | undefined,
|
||||
): DomainEvent[] | DomainError {
|
||||
const encounter = store.get();
|
||||
const result = setInitiative(encounter, combatantId, value);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
store.save(result.encounter);
|
||||
return result.events;
|
||||
return runEncounterAction(store, (encounter) =>
|
||||
setInitiative(encounter, combatantId, value),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,23 +2,17 @@ import {
|
||||
type CombatantId,
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
isDomainError,
|
||||
setTempHp,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
import { runEncounterAction } from "./run-encounter-action.js";
|
||||
|
||||
export function setTempHpUseCase(
|
||||
store: EncounterStore,
|
||||
combatantId: CombatantId,
|
||||
tempHp: number | undefined,
|
||||
): DomainEvent[] | DomainError {
|
||||
const encounter = store.get();
|
||||
const result = setTempHp(encounter, combatantId, tempHp);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
store.save(result.encounter);
|
||||
return result.events;
|
||||
return runEncounterAction(store, (encounter) =>
|
||||
setTempHp(encounter, combatantId, tempHp),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,22 +2,16 @@ import {
|
||||
type CombatantId,
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
isDomainError,
|
||||
toggleConcentration,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
import { runEncounterAction } from "./run-encounter-action.js";
|
||||
|
||||
export function toggleConcentrationUseCase(
|
||||
store: EncounterStore,
|
||||
combatantId: CombatantId,
|
||||
): DomainEvent[] | DomainError {
|
||||
const encounter = store.get();
|
||||
const result = toggleConcentration(encounter, combatantId);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
store.save(result.encounter);
|
||||
return result.events;
|
||||
return runEncounterAction(store, (encounter) =>
|
||||
toggleConcentration(encounter, combatantId),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,23 +3,17 @@ import {
|
||||
type ConditionId,
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
isDomainError,
|
||||
toggleCondition,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
import { runEncounterAction } from "./run-encounter-action.js";
|
||||
|
||||
export function toggleConditionUseCase(
|
||||
store: EncounterStore,
|
||||
combatantId: CombatantId,
|
||||
conditionId: ConditionId,
|
||||
): DomainEvent[] | DomainError {
|
||||
const encounter = store.get();
|
||||
const result = toggleCondition(encounter, combatantId, conditionId);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
store.save(result.encounter);
|
||||
return result.events;
|
||||
return runEncounterAction(store, (encounter) =>
|
||||
toggleCondition(encounter, combatantId, conditionId),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ function success(
|
||||
maxHp,
|
||||
color,
|
||||
icon,
|
||||
undefined,
|
||||
);
|
||||
if (isDomainError(result)) {
|
||||
throw new Error(`Expected success, got error: ${result.message}`);
|
||||
@@ -241,4 +242,76 @@ describe("createPlayerCharacter", () => {
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].type).toBe("PlayerCharacterCreated");
|
||||
});
|
||||
|
||||
it("creates a player character with a valid level", () => {
|
||||
const result = createPlayerCharacter(
|
||||
[],
|
||||
id,
|
||||
"Test",
|
||||
10,
|
||||
50,
|
||||
"blue",
|
||||
"sword",
|
||||
5,
|
||||
);
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
expect(result.characters[0].level).toBe(5);
|
||||
});
|
||||
|
||||
it("creates a player character without a level", () => {
|
||||
const result = createPlayerCharacter(
|
||||
[],
|
||||
id,
|
||||
"Test",
|
||||
10,
|
||||
50,
|
||||
"blue",
|
||||
"sword",
|
||||
undefined,
|
||||
);
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
expect(result.characters[0].level).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects level below 1", () => {
|
||||
const result = createPlayerCharacter(
|
||||
[],
|
||||
id,
|
||||
"Test",
|
||||
10,
|
||||
50,
|
||||
"blue",
|
||||
"sword",
|
||||
0,
|
||||
);
|
||||
expectDomainError(result, "invalid-level");
|
||||
});
|
||||
|
||||
it("rejects level above 20", () => {
|
||||
const result = createPlayerCharacter(
|
||||
[],
|
||||
id,
|
||||
"Test",
|
||||
10,
|
||||
50,
|
||||
"blue",
|
||||
"sword",
|
||||
21,
|
||||
);
|
||||
expectDomainError(result, "invalid-level");
|
||||
});
|
||||
|
||||
it("rejects non-integer level", () => {
|
||||
const result = createPlayerCharacter(
|
||||
[],
|
||||
id,
|
||||
"Test",
|
||||
10,
|
||||
50,
|
||||
"blue",
|
||||
"sword",
|
||||
3.5,
|
||||
);
|
||||
expectDomainError(result, "invalid-level");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,4 +110,33 @@ describe("editPlayerCharacter", () => {
|
||||
expect(event.oldName).toBe("Aragorn");
|
||||
expect(event.newName).toBe("Strider");
|
||||
});
|
||||
|
||||
it("sets level on a player character", () => {
|
||||
const result = editPlayerCharacter([makePC()], id, { level: 5 });
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
expect(result.characters[0].level).toBe(5);
|
||||
});
|
||||
|
||||
it("clears level when set to null", () => {
|
||||
const result = editPlayerCharacter([makePC({ level: 5 })], id, {
|
||||
level: null,
|
||||
});
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
expect(result.characters[0].level).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects invalid level", () => {
|
||||
const result = editPlayerCharacter([makePC()], id, { level: 0 });
|
||||
expectDomainError(result, "invalid-level");
|
||||
});
|
||||
|
||||
it("rejects level above 20", () => {
|
||||
const result = editPlayerCharacter([makePC()], id, { level: 21 });
|
||||
expectDomainError(result, "invalid-level");
|
||||
});
|
||||
|
||||
it("rejects non-integer level", () => {
|
||||
const result = editPlayerCharacter([makePC()], id, { level: 3.5 });
|
||||
expectDomainError(result, "invalid-level");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
calculateEncounterDifficulty,
|
||||
crToXp,
|
||||
} from "../encounter-difficulty.js";
|
||||
|
||||
describe("crToXp", () => {
|
||||
it("returns 0 for CR 0", () => {
|
||||
expect(crToXp("0")).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 25 for CR 1/8", () => {
|
||||
expect(crToXp("1/8")).toBe(25);
|
||||
});
|
||||
|
||||
it("returns 50 for CR 1/4", () => {
|
||||
expect(crToXp("1/4")).toBe(50);
|
||||
});
|
||||
|
||||
it("returns 100 for CR 1/2", () => {
|
||||
expect(crToXp("1/2")).toBe(100);
|
||||
});
|
||||
|
||||
it("returns 200 for CR 1", () => {
|
||||
expect(crToXp("1")).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 155000 for CR 30", () => {
|
||||
expect(crToXp("30")).toBe(155000);
|
||||
});
|
||||
|
||||
it("returns 0 for unknown CR", () => {
|
||||
expect(crToXp("99")).toBe(0);
|
||||
expect(crToXp("")).toBe(0);
|
||||
expect(crToXp("abc")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateEncounterDifficulty", () => {
|
||||
it("returns trivial when monster XP is below Low threshold", () => {
|
||||
// 4x level 1: Low = 200, Moderate = 300, High = 400
|
||||
// 1x CR 0 = 0 XP → trivial
|
||||
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["0"]);
|
||||
expect(result.tier).toBe("trivial");
|
||||
expect(result.totalMonsterXp).toBe(0);
|
||||
expect(result.partyBudget).toEqual({
|
||||
low: 200,
|
||||
moderate: 300,
|
||||
high: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns low for 4x level 1 vs Bugbear (CR 1)", () => {
|
||||
// DMG example: 4x level 1 PCs vs 1 Bugbear (CR 1, 200 XP)
|
||||
// Low = 200, Moderate = 300 → 200 >= 200 but < 300 → Low
|
||||
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["1"]);
|
||||
expect(result.tier).toBe("low");
|
||||
expect(result.totalMonsterXp).toBe(200);
|
||||
});
|
||||
|
||||
it("returns moderate for 5x level 3 vs 1125 XP", () => {
|
||||
// 5x level 3: Low = 750, Moderate = 1125, High = 2000
|
||||
// 1125 XP >= 1125 Moderate but < 2000 High → Moderate
|
||||
// Using CR 3 (700) + CR 2 (450) = 1150 XP ≈ 1125 threshold
|
||||
// Let's use exact: 5 * 225 = 1125 moderate budget
|
||||
// Need monsters that sum exactly to 1125: CR 3 (700) + CR 2 (450) = 1150
|
||||
const result = calculateEncounterDifficulty([3, 3, 3, 3, 3], ["3", "2"]);
|
||||
expect(result.tier).toBe("moderate");
|
||||
expect(result.totalMonsterXp).toBe(1150);
|
||||
expect(result.partyBudget.moderate).toBe(1125);
|
||||
});
|
||||
|
||||
it("returns high when XP meets High threshold", () => {
|
||||
// 4x level 1: High = 400
|
||||
// 2x CR 1 = 400 XP → High
|
||||
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["1", "1"]);
|
||||
expect(result.tier).toBe("high");
|
||||
expect(result.totalMonsterXp).toBe(400);
|
||||
});
|
||||
|
||||
it("caps at high when XP far exceeds threshold", () => {
|
||||
// 4x level 1: High = 400
|
||||
// CR 30 = 155000 XP → still High (no tier above)
|
||||
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["30"]);
|
||||
expect(result.tier).toBe("high");
|
||||
expect(result.totalMonsterXp).toBe(155000);
|
||||
});
|
||||
|
||||
it("handles mixed party levels", () => {
|
||||
// 3x level 3 + 1x level 2
|
||||
// level 3: low=150, mod=225, high=400 (x3 = 450, 675, 1200)
|
||||
// level 2: low=100, mod=150, high=200 (x1 = 100, 150, 200)
|
||||
// Total: low=550, mod=825, high=1400
|
||||
const result = calculateEncounterDifficulty([3, 3, 3, 2], ["3"]);
|
||||
expect(result.partyBudget).toEqual({
|
||||
low: 550,
|
||||
moderate: 825,
|
||||
high: 1400,
|
||||
});
|
||||
expect(result.totalMonsterXp).toBe(700);
|
||||
expect(result.tier).toBe("low");
|
||||
});
|
||||
|
||||
it("returns trivial with empty monster array", () => {
|
||||
const result = calculateEncounterDifficulty([5, 5], []);
|
||||
expect(result.tier).toBe("trivial");
|
||||
expect(result.totalMonsterXp).toBe(0);
|
||||
});
|
||||
|
||||
it("returns high with empty party array (zero budget thresholds)", () => {
|
||||
// Domain function treats empty party as zero budgets — any XP exceeds all thresholds.
|
||||
// The useDifficulty hook guards this path by returning null when no leveled PCs exist.
|
||||
const result = calculateEncounterDifficulty([], ["1"]);
|
||||
expect(result.tier).toBe("high");
|
||||
expect(result.totalMonsterXp).toBe(200);
|
||||
expect(result.partyBudget).toEqual({ low: 0, moderate: 0, high: 0 });
|
||||
});
|
||||
|
||||
it("handles fractional CRs", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[1, 1, 1, 1],
|
||||
["1/8", "1/4", "1/2"],
|
||||
);
|
||||
expect(result.totalMonsterXp).toBe(175); // 25 + 50 + 100
|
||||
expect(result.tier).toBe("trivial"); // 175 < 200 Low
|
||||
});
|
||||
|
||||
it("ignores unknown CRs (0 XP)", () => {
|
||||
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["unknown"]);
|
||||
expect(result.totalMonsterXp).toBe(0);
|
||||
expect(result.tier).toBe("trivial");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { rehydrateCombatant } from "../rehydrate-combatant.js";
|
||||
|
||||
function validCombatant(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "c-1",
|
||||
name: "Goblin",
|
||||
initiative: 12,
|
||||
ac: 15,
|
||||
maxHp: 7,
|
||||
currentHp: 5,
|
||||
tempHp: 3,
|
||||
conditions: ["poisoned"],
|
||||
isConcentrating: true,
|
||||
creatureId: "creature-goblin",
|
||||
color: "red",
|
||||
icon: "skull",
|
||||
playerCharacterId: "pc-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function minimalCombatant() {
|
||||
return { id: "c-1", name: "Goblin" };
|
||||
}
|
||||
|
||||
describe("rehydrateCombatant", () => {
|
||||
describe("valid input", () => {
|
||||
it("accepts a combatant with all fields", () => {
|
||||
const result = rehydrateCombatant(validCombatant());
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.name).toBe("Goblin");
|
||||
expect(result?.initiative).toBe(12);
|
||||
expect(result?.ac).toBe(15);
|
||||
expect(result?.maxHp).toBe(7);
|
||||
expect(result?.currentHp).toBe(5);
|
||||
expect(result?.tempHp).toBe(3);
|
||||
expect(result?.conditions).toEqual(["poisoned"]);
|
||||
expect(result?.isConcentrating).toBe(true);
|
||||
expect(result?.creatureId).toBe("creature-goblin");
|
||||
expect(result?.color).toBe("red");
|
||||
expect(result?.icon).toBe("skull");
|
||||
expect(result?.playerCharacterId).toBe("pc-1");
|
||||
});
|
||||
|
||||
it("accepts a minimal combatant (id + name only)", () => {
|
||||
const result = rehydrateCombatant(minimalCombatant());
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.id).toBe("c-1");
|
||||
expect(result?.name).toBe("Goblin");
|
||||
expect(result?.initiative).toBeUndefined();
|
||||
expect(result?.ac).toBeUndefined();
|
||||
expect(result?.maxHp).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves branded CombatantId", () => {
|
||||
const result = rehydrateCombatant(minimalCombatant());
|
||||
expect(result?.id).toBe("c-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("required field rejection", () => {
|
||||
it.each([
|
||||
null,
|
||||
42,
|
||||
"string",
|
||||
[1, 2],
|
||||
undefined,
|
||||
])("rejects non-object input: %j", (input) => {
|
||||
expect(rehydrateCombatant(input)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects missing id", () => {
|
||||
const { id: _, ...rest } = minimalCombatant();
|
||||
expect(rehydrateCombatant(rest)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects empty id", () => {
|
||||
expect(rehydrateCombatant({ ...minimalCombatant(), id: "" })).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects missing name", () => {
|
||||
const { name: _, ...rest } = minimalCombatant();
|
||||
expect(rehydrateCombatant(rest)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects non-string name", () => {
|
||||
expect(
|
||||
rehydrateCombatant({ ...minimalCombatant(), name: 42 }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
rehydrateCombatant({ ...minimalCombatant(), name: null }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("optional field leniency", () => {
|
||||
it("drops invalid ac — keeps combatant", () => {
|
||||
for (const ac of [-1, 1.5, "15"]) {
|
||||
const result = rehydrateCombatant({ ...minimalCombatant(), ac });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.ac).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("drops invalid maxHp — keeps combatant", () => {
|
||||
for (const maxHp of [0, 1.5, "7"]) {
|
||||
const result = rehydrateCombatant({ ...minimalCombatant(), maxHp });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.maxHp).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back currentHp to maxHp when currentHp invalid", () => {
|
||||
const result = rehydrateCombatant({
|
||||
...minimalCombatant(),
|
||||
maxHp: 10,
|
||||
currentHp: "bad",
|
||||
});
|
||||
expect(result?.maxHp).toBe(10);
|
||||
expect(result?.currentHp).toBe(10);
|
||||
});
|
||||
|
||||
it("falls back currentHp to maxHp when currentHp > maxHp", () => {
|
||||
const result = rehydrateCombatant({
|
||||
...minimalCombatant(),
|
||||
maxHp: 10,
|
||||
currentHp: 15,
|
||||
});
|
||||
expect(result?.maxHp).toBe(10);
|
||||
expect(result?.currentHp).toBe(10);
|
||||
});
|
||||
|
||||
it("drops invalid initiative — keeps combatant", () => {
|
||||
for (const initiative of [1.5, "12"]) {
|
||||
const result = rehydrateCombatant({
|
||||
...minimalCombatant(),
|
||||
initiative,
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.initiative).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("drops invalid conditions — keeps combatant", () => {
|
||||
const result = rehydrateCombatant({
|
||||
...minimalCombatant(),
|
||||
conditions: "poisoned",
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.conditions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops unknown condition IDs", () => {
|
||||
const result = rehydrateCombatant({
|
||||
...minimalCombatant(),
|
||||
conditions: ["fake-condition"],
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.conditions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("filters valid conditions from mixed array", () => {
|
||||
const result = rehydrateCombatant({
|
||||
...minimalCombatant(),
|
||||
conditions: ["poisoned", "fake", "blinded"],
|
||||
});
|
||||
expect(result?.conditions).toEqual(["poisoned", "blinded"]);
|
||||
});
|
||||
|
||||
it("drops invalid color — keeps combatant", () => {
|
||||
const result = rehydrateCombatant({
|
||||
...minimalCombatant(),
|
||||
color: "neon",
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.color).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops invalid icon — keeps combatant", () => {
|
||||
const result = rehydrateCombatant({
|
||||
...minimalCombatant(),
|
||||
icon: "rocket",
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.icon).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops isConcentrating when not strictly true", () => {
|
||||
for (const isConcentrating of [false, "true", 1]) {
|
||||
const result = rehydrateCombatant({
|
||||
...minimalCombatant(),
|
||||
isConcentrating,
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.isConcentrating).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("drops invalid creatureId", () => {
|
||||
for (const creatureId of ["", 42]) {
|
||||
const result = rehydrateCombatant({
|
||||
...minimalCombatant(),
|
||||
creatureId,
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.creatureId).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("drops invalid playerCharacterId", () => {
|
||||
for (const playerCharacterId of ["", 42]) {
|
||||
const result = rehydrateCombatant({
|
||||
...minimalCombatant(),
|
||||
playerCharacterId,
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.playerCharacterId).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("drops invalid tempHp — keeps combatant", () => {
|
||||
for (const tempHp of [-1, 1.5, "3"]) {
|
||||
const result = rehydrateCombatant({
|
||||
...minimalCombatant(),
|
||||
tempHp,
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.tempHp).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves valid tempHp of 0", () => {
|
||||
const result = rehydrateCombatant({
|
||||
...minimalCombatant(),
|
||||
tempHp: 0,
|
||||
});
|
||||
expect(result?.tempHp).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { rehydratePlayerCharacter } from "../rehydrate-player-character.js";
|
||||
|
||||
function validPc(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "pc-1",
|
||||
name: "Aria",
|
||||
ac: 16,
|
||||
maxHp: 45,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
level: 5,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("rehydratePlayerCharacter", () => {
|
||||
describe("valid input", () => {
|
||||
it("accepts a valid PC with all fields", () => {
|
||||
const result = rehydratePlayerCharacter(validPc());
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.name).toBe("Aria");
|
||||
expect(result?.ac).toBe(16);
|
||||
expect(result?.maxHp).toBe(45);
|
||||
expect(result?.color).toBe("blue");
|
||||
expect(result?.icon).toBe("sword");
|
||||
expect(result?.level).toBe(5);
|
||||
});
|
||||
|
||||
it("accepts a valid PC without optional color/icon/level", () => {
|
||||
const result = rehydratePlayerCharacter(
|
||||
validPc({ color: undefined, icon: undefined, level: undefined }),
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.color).toBeUndefined();
|
||||
expect(result?.icon).toBeUndefined();
|
||||
expect(result?.level).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves branded PlayerCharacterId", () => {
|
||||
const result = rehydratePlayerCharacter(validPc());
|
||||
expect(result?.id).toBe("pc-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("required field rejection", () => {
|
||||
it.each([
|
||||
null,
|
||||
42,
|
||||
"string",
|
||||
[1, 2],
|
||||
undefined,
|
||||
])("rejects non-object input: %j", (input) => {
|
||||
expect(rehydratePlayerCharacter(input)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects missing id", () => {
|
||||
const { id: _, ...rest } = validPc();
|
||||
expect(rehydratePlayerCharacter(rest)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects empty id", () => {
|
||||
expect(rehydratePlayerCharacter(validPc({ id: "" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects missing name", () => {
|
||||
const { name: _, ...rest } = validPc();
|
||||
expect(rehydratePlayerCharacter(rest)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects empty/whitespace name", () => {
|
||||
expect(rehydratePlayerCharacter(validPc({ name: "" }))).toBeNull();
|
||||
expect(rehydratePlayerCharacter(validPc({ name: " " }))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects missing ac", () => {
|
||||
const { ac: _, ...rest } = validPc();
|
||||
expect(rehydratePlayerCharacter(rest)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects negative ac", () => {
|
||||
expect(rehydratePlayerCharacter(validPc({ ac: -1 }))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects float ac", () => {
|
||||
expect(rehydratePlayerCharacter(validPc({ ac: 1.5 }))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects string ac", () => {
|
||||
expect(rehydratePlayerCharacter(validPc({ ac: "16" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects missing maxHp", () => {
|
||||
const { maxHp: _, ...rest } = validPc();
|
||||
expect(rehydratePlayerCharacter(rest)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects maxHp of 0", () => {
|
||||
expect(rehydratePlayerCharacter(validPc({ maxHp: 0 }))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects float maxHp", () => {
|
||||
expect(rehydratePlayerCharacter(validPc({ maxHp: 1.5 }))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects string maxHp", () => {
|
||||
expect(rehydratePlayerCharacter(validPc({ maxHp: "45" }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("optional field rejection (strict)", () => {
|
||||
it("rejects invalid color", () => {
|
||||
expect(rehydratePlayerCharacter(validPc({ color: "neon" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects invalid icon", () => {
|
||||
expect(rehydratePlayerCharacter(validPc({ icon: "rocket" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects level 0", () => {
|
||||
expect(rehydratePlayerCharacter(validPc({ level: 0 }))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects level 21", () => {
|
||||
expect(rehydratePlayerCharacter(validPc({ level: 21 }))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects float level", () => {
|
||||
expect(rehydratePlayerCharacter(validPc({ level: 3.5 }))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects string level", () => {
|
||||
expect(rehydratePlayerCharacter(validPc({ level: "5" }))).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { DomainEvent } from "./events.js";
|
||||
import type { CombatantId, DomainError, Encounter } from "./types.js";
|
||||
import {
|
||||
type CombatantId,
|
||||
type DomainError,
|
||||
type Encounter,
|
||||
findCombatant,
|
||||
isDomainError,
|
||||
} from "./types.js";
|
||||
|
||||
export interface AdjustHpSuccess {
|
||||
readonly encounter: Encounter;
|
||||
@@ -17,17 +23,9 @@ export function adjustHp(
|
||||
combatantId: CombatantId,
|
||||
delta: number,
|
||||
): AdjustHpSuccess | DomainError {
|
||||
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
|
||||
|
||||
if (targetIdx === -1) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "combatant-not-found",
|
||||
message: `No combatant found with ID "${combatantId}"`,
|
||||
};
|
||||
}
|
||||
|
||||
const target = encounter.combatants[targetIdx];
|
||||
const found = findCombatant(encounter, combatantId);
|
||||
if (isDomainError(found)) return found;
|
||||
const { combatant: target } = found;
|
||||
|
||||
if (target.maxHp === undefined || target.currentHp === undefined) {
|
||||
return {
|
||||
|
||||
@@ -22,6 +22,7 @@ export function createPlayerCharacter(
|
||||
maxHp: number,
|
||||
color: string | undefined,
|
||||
icon: string | undefined,
|
||||
level?: number,
|
||||
): CreatePlayerCharacterSuccess | DomainError {
|
||||
const trimmed = name.trim();
|
||||
|
||||
@@ -65,6 +66,17 @@ export function createPlayerCharacter(
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
level !== undefined &&
|
||||
(!Number.isInteger(level) || level < 1 || level > 20)
|
||||
) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-level",
|
||||
message: "Level must be an integer between 1 and 20",
|
||||
};
|
||||
}
|
||||
|
||||
const newCharacter: PlayerCharacter = {
|
||||
id,
|
||||
name: trimmed,
|
||||
@@ -72,6 +84,7 @@ export function createPlayerCharacter(
|
||||
maxHp,
|
||||
color: color as PlayerCharacter["color"],
|
||||
icon: icon as PlayerCharacter["icon"],
|
||||
level,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
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 {
|
||||
readonly encounter: Encounter;
|
||||
@@ -30,17 +36,9 @@ export function editCombatant(
|
||||
};
|
||||
}
|
||||
|
||||
const index = encounter.combatants.findIndex((c) => c.id === id);
|
||||
|
||||
if (index === -1) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "combatant-not-found",
|
||||
message: `No combatant found with ID "${id}"`,
|
||||
};
|
||||
}
|
||||
|
||||
const oldName = encounter.combatants[index].name;
|
||||
const found = findCombatant(encounter, id);
|
||||
if (isDomainError(found)) return found;
|
||||
const oldName = found.combatant.name;
|
||||
|
||||
return {
|
||||
encounter: {
|
||||
|
||||
@@ -20,6 +20,7 @@ interface EditFields {
|
||||
readonly maxHp?: number;
|
||||
readonly color?: string | null;
|
||||
readonly icon?: string | null;
|
||||
readonly level?: number | null;
|
||||
}
|
||||
|
||||
function validateFields(fields: EditFields): DomainError | null {
|
||||
@@ -72,6 +73,17 @@ function validateFields(fields: EditFields): DomainError | null {
|
||||
message: `Invalid icon: ${fields.icon}`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
fields.level !== undefined &&
|
||||
fields.level !== null &&
|
||||
(!Number.isInteger(fields.level) || fields.level < 1 || fields.level > 20)
|
||||
) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-level",
|
||||
message: "Level must be an integer between 1 and 20",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -92,6 +104,8 @@ function applyFields(
|
||||
fields.icon === undefined
|
||||
? existing.icon
|
||||
: ((fields.icon as PlayerCharacter["icon"]) ?? undefined),
|
||||
level:
|
||||
fields.level === undefined ? existing.level : (fields.level ?? undefined),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,7 +134,8 @@ export function editPlayerCharacter(
|
||||
updated.ac === existing.ac &&
|
||||
updated.maxHp === existing.maxHp &&
|
||||
updated.color === existing.color &&
|
||||
updated.icon === existing.icon
|
||||
updated.icon === existing.icon &&
|
||||
updated.level === existing.level
|
||||
) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
export type DifficultyTier = "trivial" | "low" | "moderate" | "high";
|
||||
|
||||
export interface DifficultyResult {
|
||||
readonly tier: DifficultyTier;
|
||||
readonly totalMonsterXp: number;
|
||||
readonly partyBudget: {
|
||||
readonly low: number;
|
||||
readonly moderate: number;
|
||||
readonly high: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Maps challenge rating strings to XP values (standard 5e). */
|
||||
const CR_TO_XP: Readonly<Record<string, number>> = {
|
||||
"0": 0,
|
||||
"1/8": 25,
|
||||
"1/4": 50,
|
||||
"1/2": 100,
|
||||
"1": 200,
|
||||
"2": 450,
|
||||
"3": 700,
|
||||
"4": 1100,
|
||||
"5": 1800,
|
||||
"6": 2300,
|
||||
"7": 2900,
|
||||
"8": 3900,
|
||||
"9": 5000,
|
||||
"10": 5900,
|
||||
"11": 7200,
|
||||
"12": 8400,
|
||||
"13": 10000,
|
||||
"14": 11500,
|
||||
"15": 13000,
|
||||
"16": 15000,
|
||||
"17": 18000,
|
||||
"18": 20000,
|
||||
"19": 22000,
|
||||
"20": 25000,
|
||||
"21": 33000,
|
||||
"22": 41000,
|
||||
"23": 50000,
|
||||
"24": 62000,
|
||||
"25": 75000,
|
||||
"26": 90000,
|
||||
"27": 105000,
|
||||
"28": 120000,
|
||||
"29": 135000,
|
||||
"30": 155000,
|
||||
};
|
||||
|
||||
/** Maps character level (1-20) to XP budget thresholds (2024 5.5e DMG). */
|
||||
const XP_BUDGET_PER_CHARACTER: Readonly<
|
||||
Record<number, { low: number; moderate: number; high: number }>
|
||||
> = {
|
||||
1: { low: 50, moderate: 75, high: 100 },
|
||||
2: { low: 100, moderate: 150, high: 200 },
|
||||
3: { low: 150, moderate: 225, high: 400 },
|
||||
4: { low: 250, moderate: 375, high: 500 },
|
||||
5: { low: 500, moderate: 750, high: 1100 },
|
||||
6: { low: 600, moderate: 1000, high: 1400 },
|
||||
7: { low: 750, moderate: 1300, high: 1700 },
|
||||
8: { low: 1000, moderate: 1700, high: 2100 },
|
||||
9: { low: 1300, moderate: 2000, high: 2600 },
|
||||
10: { low: 1600, moderate: 2300, high: 3100 },
|
||||
11: { low: 1900, moderate: 2900, high: 4100 },
|
||||
12: { low: 2200, moderate: 3700, high: 4700 },
|
||||
13: { low: 2600, moderate: 4200, high: 5400 },
|
||||
14: { low: 2900, moderate: 4900, high: 6200 },
|
||||
15: { low: 3300, moderate: 5400, high: 7800 },
|
||||
16: { low: 3800, moderate: 6100, high: 9800 },
|
||||
17: { low: 4500, moderate: 7200, high: 11700 },
|
||||
18: { low: 5000, moderate: 8700, high: 14200 },
|
||||
19: { low: 5500, moderate: 10700, high: 17200 },
|
||||
20: { low: 6400, moderate: 13200, high: 22000 },
|
||||
};
|
||||
|
||||
/** Returns the XP value for a given CR string. Returns 0 for unknown CRs. */
|
||||
export function crToXp(cr: string): number {
|
||||
return CR_TO_XP[cr] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates encounter difficulty from party levels and monster CRs.
|
||||
* Both arrays should be pre-filtered (only PCs with levels, only bestiary-linked monsters).
|
||||
*/
|
||||
export function calculateEncounterDifficulty(
|
||||
partyLevels: readonly number[],
|
||||
monsterCrs: readonly string[],
|
||||
): DifficultyResult {
|
||||
let budgetLow = 0;
|
||||
let budgetModerate = 0;
|
||||
let budgetHigh = 0;
|
||||
|
||||
for (const level of partyLevels) {
|
||||
const budget = XP_BUDGET_PER_CHARACTER[level];
|
||||
if (budget) {
|
||||
budgetLow += budget.low;
|
||||
budgetModerate += budget.moderate;
|
||||
budgetHigh += budget.high;
|
||||
}
|
||||
}
|
||||
|
||||
let totalMonsterXp = 0;
|
||||
for (const cr of monsterCrs) {
|
||||
totalMonsterXp += crToXp(cr);
|
||||
}
|
||||
|
||||
let tier: DifficultyTier = "trivial";
|
||||
if (totalMonsterXp >= budgetHigh) {
|
||||
tier = "high";
|
||||
} else if (totalMonsterXp >= budgetModerate) {
|
||||
tier = "moderate";
|
||||
} else if (totalMonsterXp >= budgetLow) {
|
||||
tier = "low";
|
||||
}
|
||||
|
||||
return {
|
||||
tier,
|
||||
totalMonsterXp,
|
||||
partyBudget: {
|
||||
low: budgetLow,
|
||||
moderate: budgetModerate,
|
||||
high: budgetHigh,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -48,6 +48,12 @@ export {
|
||||
type EditPlayerCharacterSuccess,
|
||||
editPlayerCharacter,
|
||||
} from "./edit-player-character.js";
|
||||
export {
|
||||
calculateEncounterDifficulty,
|
||||
crToXp,
|
||||
type DifficultyResult,
|
||||
type DifficultyTier,
|
||||
} from "./encounter-difficulty.js";
|
||||
export type {
|
||||
AcSet,
|
||||
CombatantAdded,
|
||||
@@ -88,6 +94,8 @@ export {
|
||||
VALID_PLAYER_COLORS,
|
||||
VALID_PLAYER_ICONS,
|
||||
} from "./player-character-types.js";
|
||||
export { rehydrateCombatant } from "./rehydrate-combatant.js";
|
||||
export { rehydratePlayerCharacter } from "./rehydrate-player-character.js";
|
||||
export {
|
||||
type RemoveCombatantSuccess,
|
||||
removeCombatant,
|
||||
@@ -120,6 +128,7 @@ export {
|
||||
createEncounter,
|
||||
type DomainError,
|
||||
type Encounter,
|
||||
findCombatant,
|
||||
isDomainError,
|
||||
} from "./types.js";
|
||||
export {
|
||||
|
||||
@@ -74,6 +74,7 @@ export interface PlayerCharacter {
|
||||
readonly maxHp: number;
|
||||
readonly color?: PlayerColor;
|
||||
readonly icon?: PlayerIcon;
|
||||
readonly level?: number;
|
||||
}
|
||||
|
||||
export interface PlayerCharacterList {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { ConditionId } from "./conditions.js";
|
||||
import { VALID_CONDITION_IDS } from "./conditions.js";
|
||||
import { creatureId } from "./creature-types.js";
|
||||
import {
|
||||
playerCharacterId,
|
||||
VALID_PLAYER_COLORS,
|
||||
VALID_PLAYER_ICONS,
|
||||
} from "./player-character-types.js";
|
||||
import type { Combatant } from "./types.js";
|
||||
import { combatantId } from "./types.js";
|
||||
|
||||
function validateAc(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isInteger(value) && value >= 0
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function validateConditions(value: unknown): ConditionId[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const valid = value.filter(
|
||||
(v): v is ConditionId =>
|
||||
typeof v === "string" && VALID_CONDITION_IDS.has(v),
|
||||
);
|
||||
return valid.length > 0 ? valid : undefined;
|
||||
}
|
||||
|
||||
function validateHp(
|
||||
rawMaxHp: unknown,
|
||||
rawCurrentHp: unknown,
|
||||
): { maxHp: number; currentHp: number } | undefined {
|
||||
if (
|
||||
typeof rawMaxHp !== "number" ||
|
||||
!Number.isInteger(rawMaxHp) ||
|
||||
rawMaxHp < 1
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const validCurrentHp =
|
||||
typeof rawCurrentHp === "number" &&
|
||||
Number.isInteger(rawCurrentHp) &&
|
||||
rawCurrentHp >= 0 &&
|
||||
rawCurrentHp <= rawMaxHp;
|
||||
return {
|
||||
maxHp: rawMaxHp,
|
||||
currentHp: validCurrentHp ? rawCurrentHp : rawMaxHp,
|
||||
};
|
||||
}
|
||||
|
||||
function validateTempHp(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isInteger(value) && value >= 0
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function validateInteger(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isInteger(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function validateSetMember(
|
||||
value: unknown,
|
||||
valid: ReadonlySet<string>,
|
||||
): string | undefined {
|
||||
return typeof value === "string" && valid.has(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function validateNonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function parseOptionalFields(entry: Record<string, unknown>) {
|
||||
return {
|
||||
initiative: validateInteger(entry.initiative),
|
||||
ac: validateAc(entry.ac),
|
||||
conditions: validateConditions(entry.conditions),
|
||||
isConcentrating: entry.isConcentrating === true ? true : undefined,
|
||||
creatureId: validateNonEmptyString(entry.creatureId)
|
||||
? creatureId(entry.creatureId as string)
|
||||
: undefined,
|
||||
color: validateSetMember(entry.color, VALID_PLAYER_COLORS),
|
||||
icon: validateSetMember(entry.icon, VALID_PLAYER_ICONS),
|
||||
playerCharacterId: validateNonEmptyString(entry.playerCharacterId)
|
||||
? playerCharacterId(entry.playerCharacterId as string)
|
||||
: undefined,
|
||||
tempHp: validateTempHp(entry.tempHp),
|
||||
};
|
||||
}
|
||||
|
||||
export function rehydrateCombatant(raw: unknown): Combatant | null {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
||||
return null;
|
||||
const entry = raw as Record<string, unknown>;
|
||||
|
||||
if (typeof entry.id !== "string" || entry.id.length === 0) return null;
|
||||
if (typeof entry.name !== "string") return null;
|
||||
|
||||
const shared: Combatant = {
|
||||
id: combatantId(entry.id),
|
||||
name: entry.name,
|
||||
...parseOptionalFields(entry),
|
||||
};
|
||||
|
||||
const hp = validateHp(entry.maxHp, entry.currentHp);
|
||||
return hp ? { ...shared, ...hp } : shared;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { PlayerCharacter } from "./player-character-types.js";
|
||||
import {
|
||||
playerCharacterId,
|
||||
VALID_PLAYER_COLORS,
|
||||
VALID_PLAYER_ICONS,
|
||||
} from "./player-character-types.js";
|
||||
|
||||
function isValidOptionalMember(
|
||||
value: unknown,
|
||||
valid: ReadonlySet<string>,
|
||||
): boolean {
|
||||
return value === undefined || (typeof value === "string" && valid.has(value));
|
||||
}
|
||||
|
||||
export function rehydratePlayerCharacter(raw: unknown): PlayerCharacter | null {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
||||
return null;
|
||||
const entry = raw as Record<string, unknown>;
|
||||
|
||||
if (typeof entry.id !== "string" || entry.id.length === 0) return null;
|
||||
if (typeof entry.name !== "string" || entry.name.trim().length === 0)
|
||||
return null;
|
||||
if (
|
||||
typeof entry.ac !== "number" ||
|
||||
!Number.isInteger(entry.ac) ||
|
||||
entry.ac < 0
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
typeof entry.maxHp !== "number" ||
|
||||
!Number.isInteger(entry.maxHp) ||
|
||||
entry.maxHp < 1
|
||||
)
|
||||
return null;
|
||||
if (!isValidOptionalMember(entry.color, VALID_PLAYER_COLORS)) return null;
|
||||
if (!isValidOptionalMember(entry.icon, VALID_PLAYER_ICONS)) return null;
|
||||
if (
|
||||
entry.level !== undefined &&
|
||||
(typeof entry.level !== "number" ||
|
||||
!Number.isInteger(entry.level) ||
|
||||
entry.level < 1 ||
|
||||
entry.level > 20)
|
||||
)
|
||||
return null;
|
||||
|
||||
return {
|
||||
id: playerCharacterId(entry.id),
|
||||
name: entry.name,
|
||||
ac: entry.ac,
|
||||
maxHp: entry.maxHp,
|
||||
color: entry.color as PlayerCharacter["color"],
|
||||
icon: entry.icon as PlayerCharacter["icon"],
|
||||
level: entry.level,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { DomainEvent } from "./events.js";
|
||||
import type { CombatantId, DomainError, Encounter } from "./types.js";
|
||||
import {
|
||||
type CombatantId,
|
||||
type DomainError,
|
||||
type Encounter,
|
||||
findCombatant,
|
||||
isDomainError,
|
||||
} from "./types.js";
|
||||
|
||||
export interface RemoveCombatantSuccess {
|
||||
readonly encounter: Encounter;
|
||||
@@ -22,17 +28,10 @@ export function removeCombatant(
|
||||
encounter: Encounter,
|
||||
id: CombatantId,
|
||||
): RemoveCombatantSuccess | DomainError {
|
||||
const removedIdx = encounter.combatants.findIndex((c) => c.id === id);
|
||||
const found = findCombatant(encounter, id);
|
||||
if (isDomainError(found)) return found;
|
||||
|
||||
if (removedIdx === -1) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "combatant-not-found",
|
||||
message: `No combatant found with ID "${id}"`,
|
||||
};
|
||||
}
|
||||
|
||||
const removed = encounter.combatants[removedIdx];
|
||||
const { index: removedIdx, combatant: removed } = found;
|
||||
const newCombatants = encounter.combatants.filter((_, i) => i !== removedIdx);
|
||||
|
||||
let newActiveIndex: number;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
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 {
|
||||
readonly encounter: Encounter;
|
||||
@@ -11,15 +17,8 @@ export function setAc(
|
||||
combatantId: CombatantId,
|
||||
value: number | undefined,
|
||||
): SetAcSuccess | DomainError {
|
||||
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
|
||||
|
||||
if (targetIdx === -1) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "combatant-not-found",
|
||||
message: `No combatant found with ID "${combatantId}"`,
|
||||
};
|
||||
}
|
||||
const found = findCombatant(encounter, combatantId);
|
||||
if (isDomainError(found)) return found;
|
||||
|
||||
if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
|
||||
return {
|
||||
@@ -29,8 +28,7 @@ export function setAc(
|
||||
};
|
||||
}
|
||||
|
||||
const target = encounter.combatants[targetIdx];
|
||||
const previousAc = target.ac;
|
||||
const previousAc = found.combatant.ac;
|
||||
|
||||
const updatedCombatants = encounter.combatants.map((c) =>
|
||||
c.id === combatantId ? { ...c, ac: value } : c,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
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 {
|
||||
readonly encounter: Encounter;
|
||||
@@ -18,15 +24,8 @@ export function setHp(
|
||||
combatantId: CombatantId,
|
||||
maxHp: number | undefined,
|
||||
): SetHpSuccess | DomainError {
|
||||
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
|
||||
|
||||
if (targetIdx === -1) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "combatant-not-found",
|
||||
message: `No combatant found with ID "${combatantId}"`,
|
||||
};
|
||||
}
|
||||
const found = findCombatant(encounter, combatantId);
|
||||
if (isDomainError(found)) return found;
|
||||
|
||||
if (maxHp !== undefined && (!Number.isInteger(maxHp) || maxHp < 1)) {
|
||||
return {
|
||||
@@ -36,9 +35,8 @@ export function setHp(
|
||||
};
|
||||
}
|
||||
|
||||
const target = encounter.combatants[targetIdx];
|
||||
const previousMaxHp = target.maxHp;
|
||||
const previousCurrentHp = target.currentHp;
|
||||
const previousMaxHp = found.combatant.maxHp;
|
||||
const previousCurrentHp = found.combatant.currentHp;
|
||||
|
||||
let newMaxHp: number | undefined;
|
||||
let newCurrentHp: number | undefined;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { DomainEvent } from "./events.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 {
|
||||
readonly encounter: Encounter;
|
||||
@@ -24,15 +30,8 @@ export function setInitiative(
|
||||
combatantId: CombatantId,
|
||||
value: number | undefined,
|
||||
): SetInitiativeSuccess | DomainError {
|
||||
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
|
||||
|
||||
if (targetIdx === -1) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "combatant-not-found",
|
||||
message: `No combatant found with ID "${combatantId}"`,
|
||||
};
|
||||
}
|
||||
const found = findCombatant(encounter, combatantId);
|
||||
if (isDomainError(found)) return found;
|
||||
|
||||
if (value !== undefined && !Number.isInteger(value)) {
|
||||
return {
|
||||
@@ -42,8 +41,7 @@ export function setInitiative(
|
||||
};
|
||||
}
|
||||
|
||||
const target = encounter.combatants[targetIdx];
|
||||
const previousValue = target.initiative;
|
||||
const previousValue = found.combatant.initiative;
|
||||
|
||||
// Create new combatants array with updated initiative
|
||||
const updated = encounter.combatants.map((c) =>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
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 {
|
||||
readonly encounter: Encounter;
|
||||
@@ -18,17 +24,9 @@ export function setTempHp(
|
||||
combatantId: CombatantId,
|
||||
tempHp: number | undefined,
|
||||
): SetTempHpSuccess | DomainError {
|
||||
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
|
||||
|
||||
if (targetIdx === -1) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "combatant-not-found",
|
||||
message: `No combatant found with ID "${combatantId}"`,
|
||||
};
|
||||
}
|
||||
|
||||
const target = encounter.combatants[targetIdx];
|
||||
const found = findCombatant(encounter, combatantId);
|
||||
if (isDomainError(found)) return found;
|
||||
const { combatant: target } = found;
|
||||
|
||||
if (target.maxHp === undefined || target.currentHp === undefined) {
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
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 {
|
||||
readonly encounter: Encounter;
|
||||
@@ -10,17 +16,9 @@ export function toggleConcentration(
|
||||
encounter: Encounter,
|
||||
combatantId: CombatantId,
|
||||
): ToggleConcentrationSuccess | DomainError {
|
||||
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
|
||||
|
||||
if (targetIdx === -1) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "combatant-not-found",
|
||||
message: `No combatant found with ID "${combatantId}"`,
|
||||
};
|
||||
}
|
||||
|
||||
const target = encounter.combatants[targetIdx];
|
||||
const found = findCombatant(encounter, combatantId);
|
||||
if (isDomainError(found)) return found;
|
||||
const { combatant: target } = found;
|
||||
const wasConcentrating = target.isConcentrating === true;
|
||||
|
||||
const event: DomainEvent = wasConcentrating
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { ConditionId } from "./conditions.js";
|
||||
import { CONDITION_DEFINITIONS, VALID_CONDITION_IDS } from "./conditions.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 {
|
||||
readonly encounter: Encounter;
|
||||
@@ -21,17 +27,9 @@ export function toggleCondition(
|
||||
};
|
||||
}
|
||||
|
||||
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
|
||||
|
||||
if (targetIdx === -1) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "combatant-not-found",
|
||||
message: `No combatant found with ID "${combatantId}"`,
|
||||
};
|
||||
}
|
||||
|
||||
const target = encounter.combatants[targetIdx];
|
||||
const found = findCombatant(encounter, combatantId);
|
||||
if (isDomainError(found)) return found;
|
||||
const { combatant: target } = found;
|
||||
const current = target.conditions ?? [];
|
||||
const isActive = current.includes(conditionId);
|
||||
|
||||
|
||||
@@ -70,6 +70,20 @@ export function createEncounter(
|
||||
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 {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
|
||||
Generated
+119
@@ -21,6 +21,9 @@ importers:
|
||||
jscpd:
|
||||
specifier: ^4.0.8
|
||||
version: 4.0.8
|
||||
jsinspect-plus:
|
||||
specifier: ^3.1.3
|
||||
version: 3.1.3
|
||||
knip:
|
||||
specifier: ^5.88.1
|
||||
version: 5.88.1(@types/node@25.3.3)(typescript@5.9.3)
|
||||
@@ -133,15 +136,28 @@ packages:
|
||||
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
||||
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':
|
||||
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
||||
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':
|
||||
resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
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':
|
||||
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -150,6 +166,10 @@ packages:
|
||||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
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':
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -898,6 +918,10 @@ packages:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
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:
|
||||
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -956,6 +980,10 @@ packages:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
chalk@2.4.2:
|
||||
resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
character-parser@2.2.0:
|
||||
resolution: {integrity: sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==}
|
||||
|
||||
@@ -970,10 +998,19 @@ packages:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==}
|
||||
engines: {node: '>=0.1.90'}
|
||||
|
||||
commander@2.20.3:
|
||||
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
||||
|
||||
commander@5.1.0:
|
||||
resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -1055,6 +1092,10 @@ packages:
|
||||
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
@@ -1088,6 +1129,9 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
filepaths@0.3.0:
|
||||
resolution: {integrity: sha512-QFAYdzHZxWfBOHtHIlZySPAej+pxz6c2TGe8LGgHQNsgxHmcfbbQfNmsIh0kaangjL+6D6g8IoR6VDnOFrLEFw==}
|
||||
|
||||
fill-range@7.1.1:
|
||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1136,6 +1180,10 @@ packages:
|
||||
graceful-fs@4.2.11:
|
||||
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:
|
||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1251,6 +1299,10 @@ packages:
|
||||
canvas:
|
||||
optional: true
|
||||
|
||||
jsinspect-plus@3.1.3:
|
||||
resolution: {integrity: sha512-0GbLXDlfz9nPuybM/QunzEYKTwaETxGJ5+V7vZFS7+l8w426ePVU77dBH6k+KrxiJemIgVwY6Yxr3PCzFJwxgw==}
|
||||
hasBin: true
|
||||
|
||||
jsonfile@6.2.0:
|
||||
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
|
||||
|
||||
@@ -1650,6 +1702,10 @@ packages:
|
||||
spark-md5@3.0.2:
|
||||
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:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
@@ -1672,10 +1728,18 @@ packages:
|
||||
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
|
||||
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:
|
||||
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
supports-color@5.5.0:
|
||||
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
supports-color@7.2.0:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1921,12 +1985,20 @@ snapshots:
|
||||
|
||||
'@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@8.0.0-rc.3': {}
|
||||
|
||||
'@babel/parser@7.29.0':
|
||||
dependencies:
|
||||
'@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/types@7.29.0':
|
||||
@@ -1934,6 +2006,11 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@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': {}
|
||||
|
||||
'@biomejs/biome@2.4.8':
|
||||
@@ -2481,6 +2558,10 @@ snapshots:
|
||||
|
||||
ansi-regex@5.0.1: {}
|
||||
|
||||
ansi-styles@3.2.1:
|
||||
dependencies:
|
||||
color-convert: 1.9.3
|
||||
|
||||
ansi-styles@5.2.0: {}
|
||||
|
||||
aria-query@5.3.0:
|
||||
@@ -2534,6 +2615,12 @@ snapshots:
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
is-regex: 1.2.1
|
||||
@@ -2550,8 +2637,16 @@ snapshots:
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
color-convert@1.9.3:
|
||||
dependencies:
|
||||
color-name: 1.1.3
|
||||
|
||||
color-name@1.1.3: {}
|
||||
|
||||
colors@1.4.0: {}
|
||||
|
||||
commander@2.20.3: {}
|
||||
|
||||
commander@5.1.0: {}
|
||||
|
||||
constantinople@4.0.1:
|
||||
@@ -2624,6 +2719,8 @@ snapshots:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
||||
escape-string-regexp@1.0.5: {}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@@ -2664,6 +2761,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.4
|
||||
|
||||
filepaths@0.3.0: {}
|
||||
|
||||
fill-range@7.1.1:
|
||||
dependencies:
|
||||
to-regex-range: 5.0.1
|
||||
@@ -2715,6 +2814,8 @@ snapshots:
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
has-flag@3.0.0: {}
|
||||
|
||||
has-flag@4.0.0: {}
|
||||
|
||||
has-symbols@1.1.0: {}
|
||||
@@ -2841,6 +2942,16 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@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:
|
||||
dependencies:
|
||||
universalify: 2.0.1
|
||||
@@ -3268,6 +3379,8 @@ snapshots:
|
||||
|
||||
spark-md5@3.0.2: {}
|
||||
|
||||
stable@0.1.8: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
std-env@4.0.0: {}
|
||||
@@ -3288,8 +3401,14 @@ snapshots:
|
||||
dependencies:
|
||||
min-indent: 1.0.1
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
|
||||
strip-json-comments@5.0.3: {}
|
||||
|
||||
supports-color@5.5.0:
|
||||
dependencies:
|
||||
has-flag: 3.0.0
|
||||
|
||||
supports-color@7.2.0:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
|
||||
@@ -9,11 +9,14 @@
|
||||
* Only scans component files (not hooks, adapters, etc.) and only
|
||||
* counts properties declared directly in *Props interfaces — inherited
|
||||
* 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 { readFileSync } from "node:fs";
|
||||
import { relative } from "node:path";
|
||||
import ts from "typescript";
|
||||
|
||||
const MAX_PROPS = 8;
|
||||
|
||||
@@ -25,66 +28,38 @@ const files = execSync(
|
||||
.split("\n")
|
||||
.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;
|
||||
|
||||
const propsRegex = /^(?:export\s+)?interface\s+(\w+Props)\s*\{/;
|
||||
|
||||
for (const file of files) {
|
||||
const content = readFileSync(file, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
const sourceFile = program.getSourceFile(file);
|
||||
if (!sourceFile) continue;
|
||||
|
||||
let inInterface = false;
|
||||
let interfaceName = "";
|
||||
let braceDepth = 0;
|
||||
let parenDepth = 0;
|
||||
let propCount = 0;
|
||||
let startLine = 0;
|
||||
ts.forEachChild(sourceFile, (node) => {
|
||||
if (!ts.isInterfaceDeclaration(node)) return;
|
||||
if (!node.name.text.endsWith("Props")) return;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const propCount = node.members.filter((m) =>
|
||||
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 (propCount > MAX_PROPS) {
|
||||
const rel = relative(process.cwd(), file);
|
||||
const { line } = sourceFile.getLineAndCharacterOfPosition(node.name.pos);
|
||||
console.error(
|
||||
`${rel}:${line + 1}: ${node.name.text} has ${propCount} props (max ${MAX_PROPS})`,
|
||||
);
|
||||
errors++;
|
||||
}
|
||||
|
||||
if (inInterface) {
|
||||
for (const ch of line) {
|
||||
if (ch === "{") braceDepth++;
|
||||
if (ch === "}") braceDepth--;
|
||||
if (ch === "(") parenDepth++;
|
||||
if (ch === ")") parenDepth--;
|
||||
}
|
||||
|
||||
// Count prop lines at brace depth 1 and not inside function params:
|
||||
// Matches " propName?: type" and " readonly propName: type"
|
||||
if (
|
||||
braceDepth === 1 &&
|
||||
parenDepth === 0 &&
|
||||
/^\s+(?:readonly\s+)?\w+\??\s*:/.test(line)
|
||||
) {
|
||||
propCount++;
|
||||
}
|
||||
|
||||
if (braceDepth === 0) {
|
||||
if (propCount > MAX_PROPS) {
|
||||
const rel = relative(process.cwd(), file);
|
||||
console.error(
|
||||
`${rel}:${startLine}: ${interfaceName} has ${propCount} props (max ${MAX_PROPS})`,
|
||||
);
|
||||
errors++;
|
||||
}
|
||||
inInterface = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (errors > 0) {
|
||||
|
||||
@@ -226,7 +226,7 @@ Deleting a player character MUST NOT remove or modify any combatants currently i
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **PlayerCharacter**: A persistent, reusable character template with a unique `PlayerCharacterId` (branded string), required `name`, `ac` (number), `maxHp` (number), `color` (string from predefined set), and `icon` (string identifier from preset icon set).
|
||||
- **PlayerCharacter**: A persistent, reusable character template with a unique `PlayerCharacterId` (branded string), required `name`, `ac` (number), `maxHp` (number), `color` (string from predefined set), `icon` (string identifier from preset icon set), and optional `level` (integer 1-20, added by spec 008 for encounter difficulty calculation).
|
||||
- **PlayerCharacterStore** (port): Interface for loading, saving, and deleting player characters. Implemented as a browser storage adapter.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Specification Quality Checklist: Encounter Difficulty Indicator
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-27
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||
- The spec references `creatureId` and `playerCharacterId` field names — these are domain entity attributes, not implementation details.
|
||||
- Cross-dependency with spec 005 (PlayerCharacter) is documented in FR-011 and Assumptions.
|
||||
@@ -0,0 +1,145 @@
|
||||
# Data Model: Encounter Difficulty Indicator
|
||||
|
||||
**Date**: 2026-03-27 | **Feature**: 008-encounter-difficulty
|
||||
|
||||
## Entities
|
||||
|
||||
### PlayerCharacter (modified)
|
||||
|
||||
Existing entity from spec 005. Adding one optional field.
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| id | PlayerCharacterId | yes | Existing — branded string |
|
||||
| name | string | yes | Existing |
|
||||
| ac | number | yes | Existing |
|
||||
| maxHp | number | yes | Existing |
|
||||
| color | PlayerColor | no | Existing |
|
||||
| icon | PlayerIcon | no | Existing |
|
||||
| **level** | **number** | **no** | **NEW — integer 1-20. Used for XP budget calculation. PCs without level are excluded from difficulty calc.** |
|
||||
|
||||
**Validation rules for `level`**:
|
||||
- If provided, must be an integer
|
||||
- If provided, must be >= 1 and <= 20
|
||||
- If omitted/undefined, PC is excluded from difficulty budget
|
||||
|
||||
### DifficultyTier (new)
|
||||
|
||||
Enumeration of encounter difficulty categories.
|
||||
|
||||
| Value | Display Label | Visual |
|
||||
|-------|---------------|--------|
|
||||
| `"trivial"` | Trivial | 3 empty bars |
|
||||
| `"low"` | Low | 1 green bar |
|
||||
| `"moderate"` | Moderate | 2 yellow bars |
|
||||
| `"high"` | High | 3 red bars |
|
||||
|
||||
### DifficultyResult (new)
|
||||
|
||||
Output of the difficulty calculation. Pure data object.
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| tier | DifficultyTier | The determined difficulty category |
|
||||
| totalMonsterXp | number | Sum of XP for all bestiary-linked combatants |
|
||||
| partyBudget | { low: number; moderate: number; high: number } | XP thresholds for the party |
|
||||
|
||||
### XP Budget per Character (static lookup)
|
||||
|
||||
Maps character level to XP thresholds. Data from 2024 5.5e DMG.
|
||||
|
||||
| Level | Low | Moderate | High |
|
||||
|-------|-----|----------|------|
|
||||
| 1 | 50 | 75 | 100 |
|
||||
| 2 | 100 | 150 | 200 |
|
||||
| 3 | 150 | 225 | 400 |
|
||||
| 4 | 250 | 375 | 500 |
|
||||
| 5 | 500 | 750 | 1,100 |
|
||||
| 6 | 600 | 1,000 | 1,400 |
|
||||
| 7 | 750 | 1,300 | 1,700 |
|
||||
| 8 | 1,000 | 1,700 | 2,100 |
|
||||
| 9 | 1,300 | 2,000 | 2,600 |
|
||||
| 10 | 1,600 | 2,300 | 3,100 |
|
||||
| 11 | 1,900 | 2,900 | 4,100 |
|
||||
| 12 | 2,200 | 3,700 | 4,700 |
|
||||
| 13 | 2,600 | 4,200 | 5,400 |
|
||||
| 14 | 2,900 | 4,900 | 6,200 |
|
||||
| 15 | 3,300 | 5,400 | 7,800 |
|
||||
| 16 | 3,800 | 6,100 | 9,800 |
|
||||
| 17 | 4,500 | 7,200 | 11,700 |
|
||||
| 18 | 5,000 | 8,700 | 14,200 |
|
||||
| 19 | 5,500 | 10,700 | 17,200 |
|
||||
| 20 | 6,400 | 13,200 | 22,000 |
|
||||
|
||||
### CR-to-XP (static lookup)
|
||||
|
||||
Maps challenge rating strings to XP values. Standard 5e values.
|
||||
|
||||
| CR | XP |
|
||||
|----|-----|
|
||||
| 0 | 0 |
|
||||
| 1/8 | 25 |
|
||||
| 1/4 | 50 |
|
||||
| 1/2 | 100 |
|
||||
| 1 | 200 |
|
||||
| 2 | 450 |
|
||||
| 3 | 700 |
|
||||
| 4 | 1,100 |
|
||||
| 5 | 1,800 |
|
||||
| 6 | 2,300 |
|
||||
| 7 | 2,900 |
|
||||
| 8 | 3,900 |
|
||||
| 9 | 5,000 |
|
||||
| 10 | 5,900 |
|
||||
| 11 | 7,200 |
|
||||
| 12 | 8,400 |
|
||||
| 13 | 10,000 |
|
||||
| 14 | 11,500 |
|
||||
| 15 | 13,000 |
|
||||
| 16 | 15,000 |
|
||||
| 17 | 18,000 |
|
||||
| 18 | 20,000 |
|
||||
| 19 | 22,000 |
|
||||
| 20 | 25,000 |
|
||||
| 21 | 33,000 |
|
||||
| 22 | 41,000 |
|
||||
| 23 | 50,000 |
|
||||
| 24 | 62,000 |
|
||||
| 25 | 75,000 |
|
||||
| 26 | 90,000 |
|
||||
| 27 | 105,000 |
|
||||
| 28 | 120,000 |
|
||||
| 29 | 135,000 |
|
||||
| 30 | 155,000 |
|
||||
|
||||
## Relationships
|
||||
|
||||
```
|
||||
PlayerCharacter (has optional level)
|
||||
│
|
||||
▼ linked via playerCharacterId
|
||||
Combatant (in Encounter)
|
||||
│
|
||||
▼ linked via creatureId
|
||||
Creature (has cr string)
|
||||
│
|
||||
▼ lookup via CR_TO_XP table
|
||||
XP value (number)
|
||||
|
||||
Party levels ──► XP_BUDGET_TABLE ──► { low, moderate, high } thresholds
|
||||
Monster XP total ──► compare against thresholds ──► DifficultyTier
|
||||
```
|
||||
|
||||
## State Transitions
|
||||
|
||||
The difficulty calculation is stateless — it's a pure derivation from current encounter state. No state machine or transitions to model.
|
||||
|
||||
**Input derivation** (at adapter layer):
|
||||
1. For each combatant with `playerCharacterId` → look up `PlayerCharacter.level` → collect non-undefined levels
|
||||
2. For each combatant with `creatureId` → look up `Creature.cr` → collect CR strings
|
||||
3. Pass `(levels[], crs[])` to domain function
|
||||
|
||||
**Pure calculation** (domain layer):
|
||||
1. Sum XP budget per level → `partyBudget.{low, moderate, high}`
|
||||
2. Convert each CR to XP → sum → `totalMonsterXp`
|
||||
3. Compare `totalMonsterXp` against thresholds → `DifficultyTier`
|
||||
@@ -0,0 +1,81 @@
|
||||
# Implementation Plan: Encounter Difficulty Indicator
|
||||
|
||||
**Branch**: `008-encounter-difficulty` | **Date**: 2026-03-27 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `/specs/008-encounter-difficulty/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Add a live 3-bar encounter difficulty indicator to the top bar, based on the 2024 5.5e XP budget system. The domain layer gains pure lookup tables (CR-to-XP, XP Budget per Character) and a difficulty calculation function. The `PlayerCharacter` type gains an optional `level` field (1-20). The UI renders a compact bar indicator that derives difficulty from encounter combatants, player character levels, and bestiary creature CRs.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.8 (strict mode, `verbatimModuleSyntax`)
|
||||
**Primary Dependencies**: React 19, Vite 6, Tailwind CSS v4, Lucide React (icons)
|
||||
**Storage**: localStorage (encounter + player characters), IndexedDB (bestiary cache)
|
||||
**Testing**: Vitest (v8 coverage)
|
||||
**Target Platform**: Web (mobile-first responsive, desktop side panels)
|
||||
**Project Type**: Web application (monorepo: domain → application → web adapter)
|
||||
**Performance Goals**: Indicator updates within the same render cycle as combatant changes
|
||||
**Constraints**: Offline-capable, local-first, single-user. Max 8 props per component.
|
||||
**Scale/Scope**: Single-page app, ~15 components, 3-layer architecture
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| I. Deterministic Domain Core | PASS | Difficulty calculation is a pure function: `(partyLevels, monsterCRs) → DifficultyResult`. No I/O, no randomness, no clocks. Lookup tables are static data. |
|
||||
| II. Layered Architecture | PASS | New domain module (`encounter-difficulty.ts`) has zero imports from application/adapter. UI component composes data from existing contexts. |
|
||||
| II-A. Context-Based State Flow | PASS | Difficulty indicator reads from existing contexts (encounter, player characters, bestiary). No new props beyond what's needed for the component itself. |
|
||||
| III. Clarification-First | PASS | All design decisions resolved in spec: optional level, 3 tiers, 5.5e rules only, no multipliers, hidden when insufficient data. |
|
||||
| IV. Escalation Gates | PASS | Feature scoped to spec. MVP exclusions documented (no custom CR, no 2014 rules, no XP numbers in UI). |
|
||||
| V. MVP Baseline Language | PASS | Exclusions use "MVP baseline does not include" language. |
|
||||
| VI. No Gameplay Rules in Constitution | PASS | XP tables and difficulty rules live in the feature spec, not the constitution. |
|
||||
|
||||
No violations. Complexity Tracking section not needed.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/008-encounter-difficulty/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── quickstart.md # Phase 1 output
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
packages/domain/src/
|
||||
├── encounter-difficulty.ts # NEW: CR-to-XP, XP budget tables, difficulty calc
|
||||
├── player-character-types.ts # MODIFY: add optional level field
|
||||
├── create-player-character.ts # MODIFY: add level validation
|
||||
├── edit-player-character.ts # MODIFY: add level validation + apply
|
||||
├── __tests__/
|
||||
│ ├── encounter-difficulty.test.ts # NEW: unit tests for difficulty calc
|
||||
│ ├── create-player-character.test.ts # MODIFY: add level tests
|
||||
│ └── edit-player-character.test.ts # MODIFY: add level tests
|
||||
└── index.ts # MODIFY: export new functions/types
|
||||
|
||||
packages/application/src/
|
||||
├── create-player-character-use-case.ts # MODIFY: pass level through
|
||||
└── edit-player-character-use-case.ts # MODIFY: pass level through
|
||||
|
||||
apps/web/src/
|
||||
├── components/
|
||||
│ ├── difficulty-indicator.tsx # NEW: 3-bar indicator component
|
||||
│ ├── turn-navigation.tsx # MODIFY: add indicator to top bar
|
||||
│ ├── create-player-modal.tsx # MODIFY: add level field
|
||||
│ └── player-character-manager.tsx # MODIFY: show level, pass to edit
|
||||
├── hooks/
|
||||
│ └── use-difficulty.ts # NEW: hook composing contexts → difficulty result
|
||||
└── contexts/
|
||||
└── player-characters-context.tsx # MODIFY: pass level to create/edit
|
||||
```
|
||||
|
||||
**Structure Decision**: Follows existing layered architecture. New domain module for difficulty calculation. New UI component + hook at adapter layer. No new contexts needed — the difficulty hook composes existing contexts.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Quickstart: Encounter Difficulty Indicator
|
||||
|
||||
**Date**: 2026-03-27 | **Feature**: 008-encounter-difficulty
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Phase 1: Domain — Level field + validation
|
||||
|
||||
1. Add `level?: number` to `PlayerCharacter` in `player-character-types.ts`
|
||||
2. Add level validation to `createPlayerCharacter()` — validate if provided: integer, 1-20
|
||||
3. Add level validation to `editPlayerCharacter()` — same rules in `validateFields()`, apply in `applyFields()`
|
||||
4. Add tests for level validation in existing test files
|
||||
5. Export updated types from `index.ts`
|
||||
|
||||
### Phase 2: Domain — Difficulty calculation
|
||||
|
||||
1. Create `encounter-difficulty.ts` with:
|
||||
- `CR_TO_XP` lookup (Record<string, number>)
|
||||
- `XP_BUDGET_PER_CHARACTER` lookup (Record<number, { low, moderate, high }>)
|
||||
- `crToXp(cr: string): number` — returns 0 for unknown CRs
|
||||
- `calculateEncounterDifficulty(partyLevels: number[], monsterCrs: string[]): DifficultyResult`
|
||||
- `DifficultyTier` type and `DifficultyResult` type
|
||||
2. Add comprehensive unit tests covering:
|
||||
- All CR string formats (0, 1/8, 1/4, 1/2, integers)
|
||||
- All difficulty tiers including trivial
|
||||
- DMG example encounters (from issue comments)
|
||||
- Edge cases: empty arrays, unknown CRs, mixed levels
|
||||
3. Export from `index.ts`
|
||||
|
||||
### Phase 3: Application — Pass level through use cases
|
||||
|
||||
1. Update `CreatePlayerCharacterUseCase` to accept and pass `level`
|
||||
2. Update `EditPlayerCharacterUseCase` to accept and pass `level`
|
||||
|
||||
### Phase 4: Web — Level field in PC forms
|
||||
|
||||
1. Update player characters context to pass `level` in create/edit calls
|
||||
2. Add level input field to create player modal (optional number, 1-20)
|
||||
3. Add level display + edit in player character manager
|
||||
4. Test: create PC with level, edit level, verify persistence
|
||||
|
||||
### Phase 5: Web — Difficulty indicator
|
||||
|
||||
1. Create `useDifficulty()` hook:
|
||||
- Consume encounter context, player characters context, bestiary hook
|
||||
- Map combatants → party levels + monster CRs
|
||||
- Call domain `calculateEncounterDifficulty()`
|
||||
- Return `DifficultyResult | null` (null when insufficient data)
|
||||
2. Create `DifficultyIndicator` component:
|
||||
- Render 3 bars with conditional fill colors
|
||||
- Add `title` attribute for tooltip
|
||||
- Hidden when hook returns null
|
||||
3. Add indicator to `TurnNavigation` component, right of active combatant name
|
||||
4. Test: manual verification with various encounter compositions
|
||||
|
||||
## Key Patterns to Follow
|
||||
|
||||
- **Domain purity**: `calculateEncounterDifficulty` takes `number[]` and `string[]`, not domain types
|
||||
- **Validation pattern**: Follow `color`/`icon` optional field pattern in create/edit
|
||||
- **Hook composition**: `useDifficulty` composes multiple contexts like `useInitiativeRolls`
|
||||
- **Component size**: DifficultyIndicator should be <8 props (likely 0-1, just the result)
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Domain tests** (unit): Exhaustive coverage of `calculateEncounterDifficulty` and `crToXp` with table-driven tests. Cover all 34 CR values, all 20 levels, and the DMG example encounters.
|
||||
- **Domain tests** (level validation): Test create/edit with valid levels, invalid levels, and undefined level.
|
||||
- **Integration**: Verify indicator appears/hides correctly through component rendering (if existing test patterns support this).
|
||||
@@ -0,0 +1,87 @@
|
||||
# Research: Encounter Difficulty Indicator
|
||||
|
||||
**Date**: 2026-03-27 | **Feature**: 008-encounter-difficulty
|
||||
|
||||
## Research Questions & Findings
|
||||
|
||||
### 1. Where does the CR-to-XP mapping come from?
|
||||
|
||||
**Decision**: Create a static lookup table in the domain layer as a `Record<string, number>`.
|
||||
|
||||
**Rationale**: No CR-to-XP mapping exists in the codebase. The bestiary index stores CR as a string but does not include XP. The standard 5e CR-to-XP table is fixed (published rules), so a static lookup is the simplest approach. The existing `proficiencyBonus(cr)` function in `creature-types.ts` demonstrates the pattern: a pure function that maps a CR string to a derived value.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Adding XP to the bestiary index: Would require rebuilding the index generation script and inflating the index size. Unnecessary since XP is deterministically derived from CR.
|
||||
- Computing XP from CR via formula: No clean formula exists for the 5e table — it's an irregular curve. A lookup table is more reliable and readable.
|
||||
|
||||
### 2. How does the difficulty component access creature CR at runtime?
|
||||
|
||||
**Decision**: The difficulty hook (`useDifficulty`) will consume `useBestiary()` to access the creature map, then look up CR for each combatant with a `creatureId`.
|
||||
|
||||
**Rationale**: The `useBestiary()` hook already provides `getCreature(id): Creature | undefined`, which returns the full Creature including `cr`. This is the established pattern — `CombatantRow` and `StatBlockPanel` already use it. No new adapter or port is needed.
|
||||
|
||||
**Data flow**:
|
||||
```
|
||||
useDifficulty hook
|
||||
├── useEncounterContext() → encounter.combatants[]
|
||||
├── usePlayerCharactersContext() → characters[] (for level lookup)
|
||||
└── useBestiary() → getCreature(creatureId) → creature.cr
|
||||
└── domain: crToXp(cr) → xp
|
||||
└── domain: calculateDifficulty({ partyLevels, monsterXp }) → DifficultyResult
|
||||
```
|
||||
|
||||
**Alternatives considered**:
|
||||
- Passing CR through the application layer port: Would add a `BestiarySourceCache` dependency to the difficulty calculation, breaking domain purity. Better to resolve CRs to XP values in the hook (adapter layer) and pass pure data to the domain function.
|
||||
|
||||
### 3. How should the domain difficulty function be structured?
|
||||
|
||||
**Decision**: A single pure function that takes resolved inputs (party levels as `number[]`, monster XP values as `number[]`) and returns a `DifficultyResult`.
|
||||
|
||||
**Rationale**: Keeping the domain function agnostic to how levels and XP are obtained preserves purity and testability. The function doesn't need to know about `PlayerCharacter`, `Combatant`, or `Creature` types — just numbers. This follows the pattern of `advanceTurn(encounter)` and `proficiencyBonus(cr)`: pure inputs → pure outputs.
|
||||
|
||||
**Function signature** (domain):
|
||||
```
|
||||
calculateEncounterDifficulty(partyLevels: number[], monsterCrs: string[]): DifficultyResult
|
||||
```
|
||||
|
||||
Takes party levels (already filtered to only PCs with levels) and monster CR strings (already filtered to only bestiary-linked combatants). Returns the tier, total XP, and budget thresholds.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Taking full `Combatant[]` + `PlayerCharacter[]`: Would couple the difficulty module to combatant/PC types unnecessarily.
|
||||
- Separate functions for budget and XP: The calculation is simple enough for one function. Internal helpers can split the logic without exposing multiple public functions.
|
||||
|
||||
### 4. How does `level` integrate with the PlayerCharacter CRUD flow?
|
||||
|
||||
**Decision**: Add `level?: number` to `PlayerCharacter` interface. Validation in `createPlayerCharacter` and `editPlayerCharacter` domain functions. Passed through use cases and context like existing fields.
|
||||
|
||||
**Rationale**: The existing pattern for optional fields (e.g., `color`, `icon`) is well-established:
|
||||
- Domain type: optional field on interface
|
||||
- Create function: validate if provided, skip if undefined
|
||||
- Edit function: validate in `validateFields()`, apply in `applyFields()`
|
||||
- Use case: pass through from adapter
|
||||
- Context: expose in create/edit methods
|
||||
- UI: optional input field in modal
|
||||
|
||||
Following this exact pattern for `level` minimizes risk and code churn.
|
||||
|
||||
### 5. Does adding `level` to PlayerCharacter affect export compatibility?
|
||||
|
||||
**Decision**: No version bump needed. The field is optional, so existing exports (version 1) import correctly — `level` will be `undefined` for old data.
|
||||
|
||||
**Rationale**: The `ExportBundle` includes `playerCharacters: readonly PlayerCharacter[]`. Adding an optional field to `PlayerCharacter` is backward-compatible: old exports simply lack the field, which TypeScript treats as `undefined`. The `validateImportBundle()` function doesn't validate individual PlayerCharacter fields beyond basic structure checks.
|
||||
|
||||
### 6. Should the difficulty calculation live in a new domain module or extend an existing one?
|
||||
|
||||
**Decision**: New module `encounter-difficulty.ts` in `packages/domain/src/`.
|
||||
|
||||
**Rationale**: The difficulty calculation is a self-contained concern with its own lookup tables and types. It doesn't naturally belong in `creature-types.ts` (which is about creature data structures) or `types.ts` (which is about encounter/combatant structure). A dedicated module keeps concerns separated and makes the feature easy to find, test, and potentially remove.
|
||||
|
||||
### 7. How should the 3-bar indicator be rendered?
|
||||
|
||||
**Decision**: Simple `div` elements with Tailwind CSS classes for color and fill state. No external icon or SVG needed.
|
||||
|
||||
**Rationale**: The bars are simple rectangles with conditional fill colors (green/yellow/red). Tailwind's `bg-*` utilities handle this trivially. The existing codebase uses Tailwind for all styling with no CSS-in-JS or external style libraries. A native HTML tooltip (`title` attribute) handles the hover tooltip requirement.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Lucide icons: No suitable "signal bars" icon exists. Custom SVG would be overkill for 3 rectangles.
|
||||
- CSS custom properties for colors: Unnecessary abstraction for 3 fixed states.
|
||||
@@ -0,0 +1,205 @@
|
||||
# Feature Specification: Encounter Difficulty Indicator
|
||||
|
||||
**Feature Branch**: `008-encounter-difficulty`
|
||||
**Created**: 2026-03-27
|
||||
**Status**: Draft
|
||||
**Input**: Gitea issue #18 — "Encounter difficulty indicator (5.5e XP budget)"
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### Difficulty Calculation
|
||||
|
||||
**Story ED-1 — See encounter difficulty at a glance (Priority: P1)**
|
||||
|
||||
A game master is building an encounter by adding monsters and player characters. As they add bestiary-linked creatures alongside PC combatants that have levels assigned, a compact 3-bar difficulty indicator appears in the top bar next to the active combatant name. The bars fill and change color to reflect the current difficulty tier: one green bar for Low, two yellow bars for Moderate, three red bars for High. Hovering over the indicator shows a tooltip with the difficulty label (e.g., "Moderate encounter difficulty").
|
||||
|
||||
**Why this priority**: This is the entire feature — without the indicator there is nothing to show.
|
||||
|
||||
**Independent Test**: Can be fully tested by adding PC combatants (with levels) and bestiary-linked monsters to an encounter and verifying the indicator appears with the correct difficulty tier.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an encounter with at least one PC combatant whose player character has a level and at least one bestiary-linked combatant, **When** the total monster XP is below the Low threshold, **Then** the indicator shows three empty bars (trivial difficulty).
|
||||
|
||||
2. **Given** an encounter where total monster XP meets or exceeds the Low threshold but is below Moderate, **When** the indicator renders, **Then** it shows one filled green bar and the tooltip reads "Low encounter difficulty".
|
||||
|
||||
3. **Given** an encounter where total monster XP meets or exceeds the Moderate threshold but is below High, **When** the indicator renders, **Then** it shows two filled yellow bars and the tooltip reads "Moderate encounter difficulty".
|
||||
|
||||
4. **Given** an encounter where total monster XP meets or exceeds the High threshold, **When** the indicator renders, **Then** it shows three filled red bars and the tooltip reads "High encounter difficulty".
|
||||
|
||||
5. **Given** an encounter where total monster XP exceeds the High threshold by a large margin, **When** the indicator renders, **Then** it still shows three filled red bars (High is the cap — there is no "above High" tier).
|
||||
|
||||
6. **Given** the difficulty indicator is visible, **When** a bestiary-linked combatant is added or removed, **Then** the indicator updates immediately to reflect the new difficulty tier.
|
||||
|
||||
7. **Given** the difficulty indicator is visible, **When** a PC combatant is added or removed, **Then** the indicator updates immediately to reflect the new party budget.
|
||||
|
||||
---
|
||||
|
||||
### Indicator Visibility
|
||||
|
||||
**Story ED-2 — Indicator hidden when data is insufficient (Priority: P1)**
|
||||
|
||||
The difficulty indicator only appears when meaningful calculation is possible. If the encounter lacks PC combatants with levels or lacks bestiary-linked monsters, the indicator is hidden entirely rather than showing a confusing empty or zero state.
|
||||
|
||||
**Why this priority**: Showing an indicator when it can't calculate anything is worse than showing nothing — it would confuse users who don't use bestiary creatures or don't assign levels.
|
||||
|
||||
**Independent Test**: Can be tested by creating encounters with various combatant combinations and verifying the indicator appears or hides correctly.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an encounter with only custom combatants (no `creatureId`), **When** the top bar renders, **Then** no difficulty indicator is shown.
|
||||
|
||||
2. **Given** an encounter with bestiary-linked monsters but no PC combatants, **When** the top bar renders, **Then** no difficulty indicator is shown.
|
||||
|
||||
3. **Given** an encounter with PC combatants whose player characters have no level assigned, **When** the top bar renders, **Then** no difficulty indicator is shown (even if bestiary-linked monsters are present).
|
||||
|
||||
4. **Given** an encounter with one leveled PC combatant and one bestiary-linked monster, **When** the last leveled PC is removed, **Then** the indicator disappears.
|
||||
|
||||
5. **Given** an encounter with one leveled PC combatant and one bestiary-linked monster, **When** the last bestiary-linked monster is removed (only custom combatants remain), **Then** the indicator disappears.
|
||||
|
||||
---
|
||||
|
||||
### Player Character Level
|
||||
|
||||
**Story ED-3 — Assign a level to a player character (Priority: P1)**
|
||||
|
||||
The game master can set an optional level (1-20) when creating or editing a player character. This level is used to determine the party's XP budget for the difficulty calculation. Player characters without a level are silently excluded from the budget.
|
||||
|
||||
**Why this priority**: Without levels on PCs, the XP budget cannot be calculated and the indicator cannot function.
|
||||
|
||||
**Independent Test**: Can be tested by creating a player character with a level, adding it to an encounter with a bestiary creature, and verifying the difficulty indicator appears.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the create player character form is open, **When** the user sets level to 5, **Then** the player character is created with level 5.
|
||||
|
||||
2. **Given** the create player character form is open, **When** the user leaves the level field empty, **Then** the player character is created without a level.
|
||||
|
||||
3. **Given** a player character with no level, **When** the user edits the player character and sets level to 10, **Then** the level is saved and used in future difficulty calculations.
|
||||
|
||||
4. **Given** the level field is shown, **When** the user enters a value outside 1-20 (e.g., 0, 21, -1), **Then** a validation error is shown and the value is not accepted.
|
||||
|
||||
5. **Given** a player character with level 5 exists, **When** the page is reloaded, **Then** the level is restored as part of the player character data.
|
||||
|
||||
---
|
||||
|
||||
### XP Budget Calculation
|
||||
|
||||
**Story ED-4 — Correct XP budget from 5.5e rules (Priority: P1)**
|
||||
|
||||
The difficulty calculation uses the 2024 5.5e XP Budget per Character table and a standard CR-to-XP mapping. The party's XP budget is the sum of per-character budgets for each PC combatant that has a level. The total monster XP is the sum of XP values for each bestiary-linked combatant's CR. The difficulty tier is determined by comparing total monster XP against the Low, Moderate, and High budget thresholds.
|
||||
|
||||
**Why this priority**: Incorrect calculation would make the feature misleading — the math must match the published rules.
|
||||
|
||||
**Independent Test**: Can be tested with pure domain function unit tests using known party/monster combinations from the 2024 DMG examples.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a party of four level 1 PCs (Low budget: 50 each = 200 total), **When** facing a single Bugbear (CR 1, 200 XP), **Then** the difficulty is Low (200 XP meets the Low threshold of 200 but is below Moderate at 300).
|
||||
|
||||
2. **Given** a party of five level 3 PCs (Moderate budget: 225 each = 1,125 total), **When** facing monsters totaling 1,125 XP, **Then** the difficulty is Moderate.
|
||||
|
||||
3. **Given** a party with PCs at different levels (e.g., three level 3 and one level 2), **When** the budget is calculated, **Then** each PC's budget is looked up individually by level and summed (not averaged).
|
||||
|
||||
4. **Given** an encounter with both bestiary-linked and custom combatants, **When** the XP total is calculated, **Then** only bestiary-linked combatants contribute XP (custom combatants are excluded).
|
||||
|
||||
5. **Given** a PC combatant whose player character has no level, **When** the budget is calculated, **Then** that PC is excluded from the budget (as if they are not in the party).
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- **All bars empty (trivial)**: When total monster XP is greater than 0 but below the Low threshold, the indicator shows three empty bars. This communicates "we can calculate, but it's trivial."
|
||||
- **Zero monster XP**: If all combatants with `creatureId` have CR 0 (0 XP), the indicator shows three empty bars (trivial).
|
||||
- **Mixed party levels**: PCs at different levels each contribute their own budget — the system handles heterogeneous parties correctly.
|
||||
- **Duplicate PC combatants**: If the same player character is added to the encounter multiple times, each copy contributes to the party budget independently (each counts as a party member).
|
||||
- **CR fractions**: Bestiary creatures can have fractional CRs (e.g., "1/4", "1/2"). The CR-to-XP lookup must handle these string formats.
|
||||
- **Custom combatants silently excluded**: Custom combatants without `creatureId` do not appear in the XP total and are not flagged as warnings or errors.
|
||||
- **PCs without level silently excluded**: PC combatants whose player character has no level do not contribute to the budget and are not flagged.
|
||||
- **Indicator with empty encounter**: When the encounter has no combatants, the indicator is hidden (the top bar may not even render per existing behavior).
|
||||
- **Level field on existing player characters**: Existing player characters created before this feature will have no level. They are treated as "no level assigned" — no migration or default is needed.
|
||||
|
||||
---
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
#### FR-001 — XP Budget per Character table
|
||||
The system MUST contain the 2024 5.5e XP Budget per Character lookup table mapping character levels 1-20 to Low, Moderate, and High XP thresholds.
|
||||
|
||||
#### FR-002 — CR-to-XP lookup table
|
||||
The system MUST contain a CR-to-XP lookup table mapping all standard 5e challenge ratings (0, 1/8, 1/4, 1/2, 1-30) to their XP values.
|
||||
|
||||
#### FR-003 — Party XP budget calculation
|
||||
The system MUST calculate the party's XP budget by summing the per-character budget for each PC combatant whose player character has a level assigned. PCs without a level are excluded from the sum.
|
||||
|
||||
#### FR-004 — Monster XP total calculation
|
||||
The system MUST calculate the total monster XP by summing the XP value (derived from CR) for each combatant that has a `creatureId`. Combatants without `creatureId` are excluded.
|
||||
|
||||
#### FR-005 — Difficulty tier determination
|
||||
The system MUST determine the encounter difficulty tier by comparing total monster XP against the party's Low, Moderate, and High thresholds. The tier is the highest threshold that the total XP meets or exceeds. If below Low, the encounter is trivial (no tier label).
|
||||
|
||||
#### FR-006 — Difficulty indicator in top bar
|
||||
The system MUST display a 3-bar difficulty indicator in the top bar, positioned to the right of the active combatant name.
|
||||
|
||||
#### FR-007 — Bar visual states
|
||||
The indicator MUST display: three empty bars for trivial, one green filled bar for Low, two yellow filled bars for Moderate, three red filled bars for High.
|
||||
|
||||
#### FR-008 — Tooltip on hover
|
||||
The indicator MUST show a tooltip on hover displaying the difficulty label (e.g., "Moderate encounter difficulty"). For the trivial state, the tooltip MUST read "Trivial encounter difficulty".
|
||||
|
||||
#### FR-009 — Live updates
|
||||
The indicator MUST update immediately when combatants are added to or removed from the encounter.
|
||||
|
||||
#### FR-010 — Hidden when data insufficient
|
||||
The indicator MUST be hidden when the encounter has no PC combatants with levels OR no bestiary-linked combatants.
|
||||
|
||||
#### FR-011 — Optional level field on PlayerCharacter
|
||||
The `PlayerCharacter` entity MUST support an optional `level` field accepting integer values 1-20.
|
||||
|
||||
#### FR-012 — Level in create/edit forms
|
||||
The player character create and edit forms MUST include an optional level field with validation constraining values to the 1-20 range.
|
||||
|
||||
#### FR-013 — Level persistence
|
||||
The player character level MUST be persisted and restored across sessions, consistent with existing player character persistence behavior.
|
||||
|
||||
#### FR-014 — High is the cap
|
||||
When total monster XP exceeds the High threshold, the indicator MUST display the High state (three red bars). There is no tier above High.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **XP Budget Table**: A lookup mapping character level (1-20) to three XP thresholds (Low, Moderate, High), sourced from the 2024 5.5e DMG.
|
||||
- **CR-to-XP Table**: A lookup mapping challenge rating strings ("0", "1/8", "1/4", "1/2", "1"-"30") to XP integer values.
|
||||
- **DifficultyTier**: An enumeration of difficulty categories: Trivial, Low, Moderate, High.
|
||||
- **DifficultyResult**: The output of the calculation containing the tier, total monster XP, and per-tier budget thresholds.
|
||||
- **PlayerCharacter.level**: An optional integer (1-20) added to the existing `PlayerCharacter` entity defined in spec 005.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: The difficulty indicator correctly reflects the 2024 5.5e XP budget rules for all party level and monster CR combinations in the published tables.
|
||||
- **SC-002**: The indicator updates within the same render cycle as combatant additions/removals — no perceptible delay.
|
||||
- **SC-003**: Users can identify the encounter difficulty tier at a glance from the top bar without opening any modal or menu.
|
||||
- **SC-004**: The indicator is completely hidden when the encounter lacks sufficient data for calculation, avoiding user confusion.
|
||||
- **SC-005**: The difficulty calculation is a pure domain function with no I/O, consistent with the project's deterministic domain core.
|
||||
- **SC-006**: The domain module for difficulty calculation has zero imports from application, adapter, or UI layers.
|
||||
- **SC-007**: The optional level field integrates seamlessly into the existing player character create/edit workflow without disrupting existing functionality.
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The 2024 5.5e XP Budget per Character table and CR-to-XP table are static data that do not change at runtime.
|
||||
- The CR-to-XP mapping uses the standard 5e values (0 XP for CR 0, 25 XP for CR 1/8, 50 XP for CR 1/4, 100 XP for CR 1/2, 200 XP for CR 1, up to 155,000 XP for CR 30).
|
||||
- Monster XP is derived solely from CR — no encounter multipliers are applied (the 5.5e system dropped the 2014 multiplier mechanic).
|
||||
- The `level` field is added to the existing `PlayerCharacter` type from spec 005. No new entity or storage mechanism is needed.
|
||||
- Existing player characters without a level are treated as "no level assigned" with no migration.
|
||||
- The difficulty indicator occupies minimal horizontal space in the top bar and does not interfere with the combatant name truncation or other controls.
|
||||
- MVP baseline does not include CR assignment for custom (non-bestiary) combatants.
|
||||
- MVP baseline does not include the 2014 DMG encounter multiplier mechanic or the four-tier (Easy/Medium/Hard/Deadly) system.
|
||||
- MVP baseline does not include showing XP totals or budget numbers in the indicator — only the visual bars and tooltip label.
|
||||
- MVP baseline does not include per-combatant level overrides — level is always derived from the player character template.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user