48 Commits

Author SHA1 Message Date
Lukas
d9562f850c Inline NODE_OPTIONS for CI check step
Some checks failed
CI / check (push) Failing after 18s
CI / build-image (push) Has been skipped
Step-level env may not propagate to pnpm subprocesses in Gitea
Actions. Inline the variable directly in the command instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:48:33 +01:00
Lukas
ec9f2e7877 Increase Node heap limit for CI check step
Some checks failed
CI / check (push) Failing after 16s
CI / build-image (push) Has been skipped
oxc-parser (used by Knip) fails with ArrayBuffer allocation
error on the CI runner's default heap size. Set max-old-space-size
to 2048MB to accommodate the buffer allocation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:40:33 +01:00
Lukas
c4079c384b Fix initiative input clipping inside container
Some checks failed
CI / check (push) Failing after 17s
CI / build-image (push) Has been skipped
Widen initiative grid column from 3rem to 3.5rem and use w-full
on the editing input so it fits within the rounded background
container without overflowing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:26:39 +01:00
Lukas
a4285fc415 Polish stat containers and optical alignment
Refine AC shield to use filled shape with border color instead of
stroke outline. Add subtle muted background to initiative container.
Apply optical vertical centering to round badge text (-3px) and
AC shield number (-2px). Unify round badge corners to rounded-md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:23:22 +01:00
Lukas
9c0e3398f1 Move AC shield next to initiative and refine shield style
Place AC between initiative and name to group static reference
stats on the left, leaving HP as the sole dynamic element on
the right. Dim the shield outline to 40% opacity so it recedes
visually, and nudge the number up 2px toward the visual center.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 13:54:20 +01:00
Lukas
9cdf004c15 Restyle HP display as compact rounded pill
Group current HP, temp HP, and max HP into a single bordered
pill container with a subtle slash separator. Removes the
scattered layout with separate elements and gaps. Temp HP +N
only renders when present (no invisible spacer).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 13:11:28 +01:00
Lukas
8bf69fd47d Add temporary hit points as a separate damage buffer
Temp HP absorbs damage before current HP, cannot be healed, and
does not stack (higher value wins). Displayed as cyan +N after
current HP with a Shield button in the HP adjustment popover.
Column space is reserved across all rows only when any combatant
has temp HP. Concentration pulse fires on any damage, including
damage fully absorbed by temp HP.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 11:39:47 +01:00
Lukas
7b83e3c3ea Upgrade pnpm 10.6.0 to 10.32.1
Fixes Node DEP0169 url.parse() deprecation warning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:17:07 +01:00
Lukas
c3c2cad798 Upgrade lefthook 1 to 2
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:15:23 +01:00
Lukas
3f6140303d Upgrade knip 5 to 6
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:09:56 +01:00
Lukas
fd30278474 Upgrade jsdom 28 to 29
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:09:11 +01:00
Lukas
278c06221f Upgrade Vite 8, plugin-react 6, Vitest 4
Vite 6→8 (Rolldown/Oxc), @vitejs/plugin-react 4→6 (Babel-free), Vitest 3→4 (AST coverage)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 09:41:14 +01:00
Lukas
722e8cc627 Update patch/minor dev dependencies
Biome 2.4.7→2.4.8, Tailwind 4.2.1→4.2.2, oxlint 1.55→1.56, oxlint-tsgolint 0.16→0.17.1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 09:17:26 +01:00
Lukas
64741956dd Preserve search input and focus when toggling browse mode
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:25:40 +01:00
Lukas
6336dec38a Add condition tooltips with 5.5e descriptions
All checks were successful
CI / check (push) Successful in 1m22s
CI / build-image (push) Successful in 19s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:48:23 +01:00
Lukas
9def2d7c24 Fix condition picker clipping out of viewport
All checks were successful
CI / check (push) Successful in 1m17s
CI / build-image (push) Successful in 27s
Render condition picker via React portal with fixed positioning so it
is no longer clipped by the overflow-y-auto combatant list container.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:34:15 +01:00
Lukas
f729e37689 Replace book icon with name-click stat block toggle and pencil rename
Name click now opens/collapses the stat block panel; a hover-visible
pencil icon next to the name handles renaming. Removes the standalone
book icon for a cleaner, more intuitive combatant row.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:29:56 +01:00
Lukas
86768842ff Refactor App.tsx from god component to context-based architecture
All checks were successful
CI / check (push) Successful in 1m18s
CI / build-image (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 15:33:33 +01:00
Lukas
6584d8d064 Add advantage/disadvantage rolling for initiative
All checks were successful
CI / check (push) Successful in 1m17s
CI / build-image (push) Successful in 19s
Right-click or long-press the d20 button (per-combatant or Roll All)
to open a context menu with Advantage and Disadvantage options.
Normal left-click behavior is unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:16:04 +01:00
Lukas
7f38cbab73 Preserve stat block panel collapsed state on turn advance
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 23:23:52 +01:00
Lukas
2971898f0c Add dark and light theme with OS preference support
All checks were successful
CI / check (push) Successful in 1m22s
CI / build-image (push) Successful in 36s
Follow OS color scheme by default, with a three-way toggle
(System / Light / Dark) in the kebab menu. Light theme uses warm,
neutral tones with soft card-to-background contrast. Semantic colors
(damage, healing, conditions) keep their hue across themes.

Closes #10

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 13:24:18 +01:00
Lukas
43780772f6 Improve bestiary icon UX and auto-update stat block on turn change
- Use Book/BookOpen icons to indicate stat block open state
- Bump combatant icons (bestiary + PC) from 14px to 16px
- Use text-foreground for bestiary icon visibility
- Auto-update stat block panel to active combatant's creature on turn advance
- Update bestiary spec edge case to reflect new behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 12:48:38 +01:00
Lukas
7b3dbe2069 Use ghost buttons for turn navigation to blend with top bar
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 12:18:31 +01:00
Lukas
827a3978e9 Show toast and open source panel when rolling initiative without loaded source
When a user clicks the d20 to roll initiative for a single combatant whose
bestiary source isn't cached, show an informative toast and open the stat
block panel so they can load the source directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 12:11:20 +01:00
Lukas
f024562a7d Auto-open stat block panel when adding first bestiary creature
When the side panel is in its initial closed state (not user-collapsed),
adding a combatant from the bestiary now opens the panel to show its
stat block. This makes the panel discoverable without overriding a
deliberate collapse.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 12:03:34 +01:00
Lukas
dfef2194a5 Add subtle radial gradient to app background
All checks were successful
CI / check (push) Successful in 1m25s
CI / build-image (push) Successful in 20s
Apply a soft blue radial glow centered on the viewport to add depth
to the dark background, replacing the flat solid color.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 14:27:38 +01:00
Lukas
502adca81b Fix layout shift on turn change and restore concentration border width
All checks were successful
CI / check (push) Successful in 1m26s
CI / build-image (push) Successful in 21s
Give all combatant rows a consistent border-l-2 + border on all sides
(transparent when inactive) so toggling active/concentration states
never changes the row's box size. Show purple left border when a
combatant is both active and concentrating.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 13:57:02 +01:00
Lukas
12e8bf6e69 Constrain rename input width to prevent row layout breakage
Cap the editable name input at max-w-48 so it doesn't stretch the
full column width and push icons/conditions onto separate lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 13:50:48 +01:00
Lukas
472574ac31 Bump border radius tokens for rounder UI surfaces
All checks were successful
CI / check (push) Successful in 1m23s
CI / build-image (push) Successful in 20s
Increase radius-md from 6px to 8px and radius-lg from 8px to 12px
for a more modern, polished look on buttons, inputs, and card surfaces.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 13:48:23 +01:00
Lukas
f4a7b53393 Restyle dark theme with blue-tinted palette, card glow, and rounded surfaces
Shift the dark theme from neutral gray to a richer blue-tinted palette
inspired by CharBuilder-style TTRPG apps. Deeper navy background, steel-blue
card surfaces, and visible blue borders create more depth and visual layering.

- Update design tokens: background, card, border, input, muted colors
- Add card-glow utility (radial gradient + blue box-shadow) for card surfaces
- Add panel-glow utility (top-down gradient) for tall panels like stat blocks
- Apply glow and rounded-lg to all card surfaces, dropdowns, dialogs, toasts
- Give outline buttons a subtle fill instead of transparent background
- Active combatant row now uses full border with glow instead of left accent

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 13:39:44 +01:00
Lukas
8aec460ee4 Fix production class extraction by replacing template-literal classNames with cn()
All checks were successful
CI / check (push) Successful in 1m22s
CI / build-image (push) Successful in 29s
Tailwind v4's static extractor missed classes adjacent to ${} in template
literals (e.g. `pb-8${...}`), causing missing styles in production builds.
Migrated all dynamic classNames to cn() and added a check script to prevent
regressions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 12:56:15 +01:00
Lukas
6e10238fe0 Add filter input to source manager for searching cached sources by name
All checks were successful
CI / check (push) Successful in 1m22s
CI / build-image (push) Successful in 18s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 12:05:45 +01:00
Lukas
b6e882add2 Add explicit text-foreground to ghost and outline button variants
Buttons should declare their own text color rather than relying on
inheritance, which breaks in contexts like native <dialog>. Remove
the text-foreground workaround from the dialog elements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:57:27 +01:00
Lukas
7a87d979bf Fix native dialog centering and text color
All checks were successful
CI / check (push) Successful in 1m24s
CI / build-image (push) Successful in 19s
Tailwind v4 preflight resets dialog margins and color inheritance.
Add m-auto to restore showModal() centering, and text-foreground
so ghost buttons inherit the correct color.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:55:18 +01:00
Lukas
02096bcee6 Fix bottom bar clipping on hosted deployment
All checks were successful
CI / check (push) Successful in 1m29s
CI / build-image (push) Successful in 31s
Use h-dvh (100dvh) instead of h-screen (100vh) so the layout
accounts for browser chrome (address bar, bottom toolbar).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:50:59 +01:00
Lukas
c092192b0e Add _copy field to RawMonster, remove noExplicitAny biome-ignore
The _copy field is a real property in the raw bestiary JSON — adding it
to the interface is more accurate than casting through any. Ratchet
source ignore threshold from 3 to 2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:31:34 +01:00
Lukas
4d1a7c6420 Remove noAwaitInLoops biome-ignore by chaining batches with reduce
Replace the for-loop with await-in-loop with a .reduce() chain that
sequences Promise.allSettled batches without triggering the lint rule.
Ratchet source ignore threshold from 4 to 3.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:24:31 +01:00
Lukas
46b444caba Refactor combatant row: single-click rename, book icon for stat blocks
Replace 250ms click timer and double-click detection with immediate
single-click rename for all combatant types. Add a BookOpen icon before
the name on bestiary rows as the dedicated stat block trigger. Remove
auto-show stat block on turn advance. Update specs to match: consistent
collapse/expand terminology, book icon requirements, no row-click stat
block behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:14:28 +01:00
Lukas
e68145319f Add biome-ignore backpressure script, convert modals to native <dialog>
Adds scripts/check-lint-ignores.mjs with four enforcement mechanisms:
ratcheting count cap (12 source / 3 test), banned rule prefixes,
required justification, and separate test thresholds. Wired into
pnpm check.

Converts player-management and create-player-modal from div-based
modals to native <dialog> with showModal()/close(), removing 8
biome-ignore comments. Remaining ignores are legitimate (Biome
false positives or stopPropagation wrappers with no fitting role).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 16:21:46 +01:00
Lukas
d64e1f5e4a Add integration tests for App.tsx with accessible HP status labels
3 integration tests render the full <App /> and exercise multi-component
flows: add/remove combatant, turn tracking across two combatants, and
HP adjustment with unconscious state. Add aria-label to the clickable HP
button so tests query accessible names instead of CSS classes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 01:46:32 +01:00
Lukas
ef0b755eec Add coverage thresholds for all tested directories, exclude dist from coverage
All checks were successful
CI / check (push) Successful in 1m4s
CI / build-image (push) Successful in 27s
Adds threshold entries for application, hooks, components, and components/ui
directories. Ratchets existing thresholds to match actual coverage. Excludes
**/dist/** from coverage to remove build output noise.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 16:19:56 +01:00
Lukas
4be816d10f Add test coverage for 5 components: HpAdjustPopover, ConditionPicker, CombatantRow, ActionBar, SourceManager
Adds aria-label attributes to HP placeholder and source delete buttons
for both accessibility and testability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 15:53:01 +01:00
Lukas
e531d82d1b Add test coverage for 3 hooks: useEncounter, usePlayerCharacters, useSidePanelState
29 tests covering state transitions, persistence sync, domain error
propagation, bestiary/PC add flows, and panel state machine logic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 15:38:51 +01:00
Lukas
5a262c66cd Add test coverage for all 17 application layer use cases
Tests verify the get→call→save wiring and error propagation for each
use case. The 15 formulaic use cases share a test file; rollInitiative
and rollAllInitiative have dedicated suites covering their multi-step
logic (creature lookup, modifier calculation, iteration, early return).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 15:31:35 +01:00
Lukas
32b69f8df1 Use Readonly props and optional chaining/nullish coalescing
Mark component props as Readonly<> across 15 component files and
simplify edit-player-character field access with optional chaining
and nullish coalescing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 15:13:39 +01:00
Lukas
8efba288f7 Use String.raw and RegExp.exec, add prefer-regexp-exec oxlint rule
Replace escaped backslash in template literal with String.raw in
auto-number.ts. Use RegExp#exec() instead of String#match() in
bestiary-adapter.ts. Enable typescript/prefer-regexp-exec in oxlint
for automated enforcement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 14:55:54 +01:00
Lukas
c94c30e459 Add oxlint for type-aware linting that Biome cannot cover
Install oxlint with tsgolint for TypeScript type information. Enable
rules for unnecessary type assertions, deprecated API usage, preferring
replaceAll over replace with global regex, and String.raw for escaped
backslashes. Fix all violations: remove redundant as-casts, replace
deprecated FormEvent with SubmitEvent, convert replace(/g) to
replaceAll, and use String.raw in escapeRegExp. Add oxlint to the
pnpm check gate alongside Biome.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 14:41:30 +01:00
Lukas
36768d3aa1 Upgrade Biome to 2.4.7 and enable 54 additional lint rules
Add rules covering bug prevention (noLeakedRender, noFloatingPromises,
noImportCycles, noReactForwardRef), security (noScriptUrl, noAlert),
performance (noAwaitInLoops, useTopLevelRegex), and code style
(noNestedTernary, useGlobalThis, useNullishCoalescing, useSortedClasses,
plus ~40 more). Fix all violations: extract top-level regex constants,
guard React && renders with boolean coercion, refactor nested ternaries,
replace window with globalThis, sort Tailwind classes, and introduce
expectDomainError test helper to eliminate conditional expects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 14:25:09 +01:00
115 changed files with 6531 additions and 3141 deletions

View File

@@ -20,7 +20,7 @@ jobs:
node-version: 22 node-version: 22
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm check - run: NODE_OPTIONS="--max-old-space-size=2048" pnpm check
build-image: build-image:
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')

27
.oxlintrc.json Normal file
View File

@@ -0,0 +1,27 @@
{
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/tc39-proposal-type-annotations/refs/heads/main/packages/oxlint/configuration_file_schema.json",
"plugins": ["typescript", "unicorn", "jest"],
"categories": {},
"rules": {
"typescript/no-unnecessary-type-assertion": "error",
"typescript/no-deprecated": "warn",
"typescript/prefer-regexp-exec": "error",
"unicorn/prefer-string-replace-all": "error",
"unicorn/prefer-string-raw": "error",
"jest/expect-expect": [
"error",
{
"assertFunctionNames": ["expect", "expectDomainError"]
}
]
},
"ignorePatterns": [
"dist",
"coverage",
".claude",
".specify",
"specs",
".pnpm-store",
"scripts"
]
}

View File

@@ -1,9 +1,9 @@
<!-- <!--
Sync Impact Report Sync Impact Report
─────────────────── ───────────────────
Version change: 2.2.1 → 3.0.0 (MAJOR — specs describe features not changes, proportional workflow) Version change: 3.0.0 → 3.1.0 (MINOR — new principle II-A: context-based state flow)
Modified sections: Modified sections:
- Development Workflow: specs are living feature documents; full pipeline for new features only - Core Principles: added II-A. Context-Based State Flow (max 8 props, context over prop drilling)
Templates requiring updates: none Templates requiring updates: none
--> -->
# Encounter Console Constitution # Encounter Console Constitution
@@ -38,6 +38,22 @@ dependency direction:
A module in an inner layer MUST NOT import from an outer layer. A module in an inner layer MUST NOT import from an outer layer.
### II-A. Context-Based State Flow
UI components MUST consume shared application state via React context
providers, not prop drilling. Props are reserved for per-instance
configuration (e.g., a specific data item, a layout variant, a ref).
- Components MUST NOT declare more than 8 explicit props in their
own interface. This is enforced by `scripts/check-component-props.mjs`
at pre-commit.
- Generic UI primitives (`components/ui/`) that extend HTML element
attributes are exempt — only explicitly declared props count, not
inherited HTML attributes.
- Coordinating hooks that consume multiple contexts (e.g.,
`useInitiativeRolls`) are preferred over wiring callbacks through
a parent component.
### III. Clarification-First ### III. Clarification-First
Before making any non-trivial assumption during specification, Before making any non-trivial assumption during specification,
@@ -140,4 +156,4 @@ MUST comply with its principles.
**Compliance review**: Every spec and plan MUST include a **Compliance review**: Every spec and plan MUST include a
Constitution Check section validating adherence to all principles. Constitution Check section validating adherence to all principles.
**Version**: 3.0.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-11 **Version**: 3.1.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-19

View File

@@ -5,13 +5,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Commands ## Commands
```bash ```bash
pnpm check # Merge gate — must pass before every commit (audit + knip + biome + typecheck + test/coverage + jscpd) pnpm check # Merge gate — must pass before every commit (audit + knip + biome + oxlint + typecheck + test/coverage + jscpd)
pnpm oxlint # Type-aware linting (oxlint — complements Biome)
pnpm knip # Unused code detection (Knip) pnpm knip # Unused code detection (Knip)
pnpm test # Run all tests (Vitest) pnpm test # Run all tests (Vitest)
pnpm test:watch # Tests in watch mode pnpm test:watch # Tests in watch mode
pnpm typecheck # tsc --build (project references) pnpm typecheck # tsc --build (project references)
pnpm lint # Biome lint pnpm lint # Biome lint
pnpm format # Biome format (writes) pnpm format # Biome format (writes)
pnpm check:props # Component prop count enforcement (max 8)
pnpm --filter web dev # Vite dev server (localhost:5173) pnpm --filter web dev # Vite dev server (localhost:5173)
pnpm --filter web build # Production build pnpm --filter web build # Production build
``` ```
@@ -58,17 +60,19 @@ docs/agents/ RPI skill artifacts (research reports, plans)
- React 19, Vite 6, Tailwind CSS v4 - React 19, Vite 6, Tailwind CSS v4
- Lucide React (icons) - Lucide React (icons)
- `idb` (IndexedDB wrapper for bestiary cache) - `idb` (IndexedDB wrapper for bestiary cache)
- Biome 2.0 (formatting + linting), Knip (unused code), jscpd (copy-paste detection) - Biome 2.4 (formatting + linting), oxlint (type-aware linting), Knip (unused code), jscpd (copy-paste detection)
- Vitest (testing, v8 coverage), Lefthook (pre-commit hooks) - Vitest (testing, v8 coverage), Lefthook (pre-commit hooks)
## Conventions ## Conventions
- **Biome 2.0** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically. - **Biome 2.4** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically.
- **oxlint** for type-aware linting that Biome can't do (unnecessary type assertions, deprecated APIs, `replaceAll` preference, `String.raw`). Configured in `.oxlintrc.json`.
- **TypeScript strict mode** with `verbatimModuleSyntax`. Use `.js` extensions in relative imports when required by the repo's ESM settings (e.g., `./types.js`). - **TypeScript strict mode** with `verbatimModuleSyntax`. Use `.js` extensions in relative imports when required by the repo's ESM settings (e.g., `./types.js`).
- **Branded types** for identity values (e.g., `CombatantId`). Prefer immutability/`readonly` where practical. - **Branded types** for identity values (e.g., `CombatantId`). Prefer immutability/`readonly` where practical.
- **Domain events** are plain data objects with a `type` discriminant — no classes. - **Domain events** are plain data objects with a `type` discriminant — no classes.
- **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks. - **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks.
- **Feature specs** live in `specs/NNN-feature-name/` with spec.md (and optionally plan.md, tasks.md for new work). Specs describe features, not individual changes. The project constitution is at `.specify/memory/constitution.md`. - **Feature specs** live in `specs/NNN-feature-name/` with spec.md (and optionally plan.md, tasks.md for new work). Specs describe features, not individual changes. The project constitution is at `.specify/memory/constitution.md`.
- **Component props** — max 8 explicitly declared props per component interface (enforced by `scripts/check-component-props.mjs`). Use React context for shared state; reserve props for per-instance config (data items, layout variants, refs).
- **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's `pnpm check` — the project's single earliest enforcement point. No gate may exist only as a CI step or manual process.
## Self-Review Checklist ## Self-Review Checklist

View File

@@ -20,14 +20,15 @@
"tailwind-merge": "^3.5.0" "tailwind-merge": "^3.5.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.2",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^6.0.1",
"jsdom": "^28.1.0", "jsdom": "^29.0.1",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.2",
"vite": "^6.2.0" "vite": "^8.0.1"
} }
} }

View File

@@ -1,269 +1,74 @@
import { import { useEffect, useRef } from "react";
rollAllInitiativeUseCase, import { ActionBar } from "./components/action-bar.js";
rollInitiativeUseCase, import { BulkImportToasts } from "./components/bulk-import-toasts.js";
} from "@initiative/application"; import { CombatantRow } from "./components/combatant-row.js";
import {
type CombatantId,
type Creature,
type CreatureId,
isDomainError,
} from "@initiative/domain";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { ActionBar } from "./components/action-bar";
import { BulkImportToasts } from "./components/bulk-import-toasts";
import { CombatantRow } from "./components/combatant-row";
import { import {
PlayerCharacterSection, PlayerCharacterSection,
type PlayerCharacterSectionHandle, type PlayerCharacterSectionHandle,
} from "./components/player-character-section"; } from "./components/player-character-section.js";
import { StatBlockPanel } from "./components/stat-block-panel"; import { StatBlockPanel } from "./components/stat-block-panel.js";
import { Toast } from "./components/toast"; import { Toast } from "./components/toast.js";
import { TurnNavigation } from "./components/turn-navigation"; import { TurnNavigation } from "./components/turn-navigation.js";
import { type SearchResult, useBestiary } from "./hooks/use-bestiary"; import { useEncounterContext } from "./contexts/encounter-context.js";
import { useBulkImport } from "./hooks/use-bulk-import"; import { useInitiativeRollsContext } from "./contexts/initiative-rolls-context.js";
import { useEncounter } from "./hooks/use-encounter"; import { useSidePanelContext } from "./contexts/side-panel-context.js";
import { usePlayerCharacters } from "./hooks/use-player-characters"; import { useActionBarAnimation } from "./hooks/use-action-bar-animation.js";
import { useSidePanelState } from "./hooks/use-side-panel-state"; import { useAutoStatBlock } from "./hooks/use-auto-stat-block.js";
import { cn } from "./lib/utils.js";
function rollDice(): number {
return Math.floor(Math.random() * 20) + 1;
}
function useActionBarAnimation(combatantCount: number) {
const wasEmptyRef = useRef(combatantCount === 0);
const [settling, setSettling] = useState(false);
const [rising, setRising] = useState(false);
const [topBarExiting, setTopBarExiting] = useState(false);
useLayoutEffect(() => {
const nowEmpty = combatantCount === 0;
if (wasEmptyRef.current && !nowEmpty) {
setSettling(true);
} else if (!wasEmptyRef.current && nowEmpty) {
setRising(true);
setTopBarExiting(true);
}
wasEmptyRef.current = nowEmpty;
}, [combatantCount]);
const empty = combatantCount === 0;
const risingClass = rising ? " animate-rise-to-center" : "";
const settlingClass = settling ? " animate-settle-to-bottom" : "";
const topBarClass = settling
? " animate-slide-down-in"
: topBarExiting
? " absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out"
: "";
const showTopBar = !empty || topBarExiting;
return {
risingClass,
settlingClass,
topBarClass,
showTopBar,
onSettleEnd: () => setSettling(false),
onRiseEnd: () => setRising(false),
onTopBarExitEnd: () => setTopBarExiting(false),
};
}
export function App() { export function App() {
const { const { encounter, isEmpty } = useEncounterContext();
encounter, const sidePanel = useSidePanelContext();
isEmpty, const rolls = useInitiativeRollsContext();
hasCreatureCombatants,
canRollAllInitiative,
advanceTurn,
retreatTurn,
addCombatant,
clearEncounter,
removeCombatant,
editCombatant,
setInitiative,
setHp,
adjustHp,
setAc,
toggleCondition,
toggleConcentration,
addFromBestiary,
addFromPlayerCharacter,
makeStore,
} = useEncounter();
const { useAutoStatBlock();
characters: playerCharacters,
createCharacter: createPlayerCharacter,
editCharacter: editPlayerCharacter,
deleteCharacter: deletePlayerCharacter,
} = usePlayerCharacters();
const {
search,
getCreature,
isLoaded,
isSourceCached,
fetchAndCacheSource,
uploadAndCacheSource,
refreshCache,
} = useBestiary();
const bulkImport = useBulkImport();
const sidePanel = useSidePanelState();
const [rollSkippedCount, setRollSkippedCount] = useState(0);
const selectedCreature: Creature | null = sidePanel.selectedCreatureId
? (getCreature(sidePanel.selectedCreatureId) ?? null)
: null;
const pinnedCreature: Creature | null = sidePanel.pinnedCreatureId
? (getCreature(sidePanel.pinnedCreatureId) ?? null)
: null;
const handleAddFromBestiary = useCallback(
(result: SearchResult) => {
addFromBestiary(result);
},
[addFromBestiary],
);
const handleCombatantStatBlock = useCallback(
(creatureId: string) => {
sidePanel.showCreature(creatureId as CreatureId);
},
[sidePanel.showCreature],
);
const handleRollInitiative = useCallback(
(id: CombatantId) => {
rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature);
},
[makeStore, getCreature],
);
const handleRollAllInitiative = useCallback(() => {
const result = rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
if (!isDomainError(result) && result.skippedNoSource > 0) {
setRollSkippedCount(result.skippedNoSource);
}
}, [makeStore, getCreature]);
const handleViewStatBlock = useCallback(
(result: SearchResult) => {
const slug = result.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
sidePanel.showCreature(cId);
},
[sidePanel.showCreature],
);
const handleStartBulkImport = useCallback(
(baseUrl: string) => {
bulkImport.startImport(
baseUrl,
fetchAndCacheSource,
isSourceCached,
refreshCache,
);
},
[bulkImport.startImport, fetchAndCacheSource, isSourceCached, refreshCache],
);
const handleBulkImportDone = useCallback(() => {
sidePanel.dismissPanel();
bulkImport.reset();
}, [sidePanel.dismissPanel, bulkImport.reset]);
const actionBarInputRef = useRef<HTMLInputElement>(null);
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null); const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
const actionBarInputRef = useRef<HTMLInputElement>(null);
const activeRowRef = useRef<HTMLDivElement>(null);
const actionBarAnim = useActionBarAnimation(encounter.combatants.length); const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
// Auto-scroll to the active combatant when the turn changes // Auto-scroll to active combatant when turn changes
const activeRowRef = useRef<HTMLDivElement>(null); const activeIndex = encounter.activeIndex;
useEffect(() => { useEffect(() => {
if (activeIndex >= 0) {
activeRowRef.current?.scrollIntoView({ activeRowRef.current?.scrollIntoView({
block: "nearest", block: "nearest",
behavior: "smooth", behavior: "smooth",
}); });
}, [encounter.activeIndex]); }
}, [activeIndex]);
// Auto-show stat block for the active combatant when turn changes,
// but only when the viewport is wide enough to show it alongside the tracker.
// Only react to activeIndex changes — not combatant reordering (e.g. Roll All).
const prevActiveIndexRef = useRef(encounter.activeIndex);
useEffect(() => {
if (prevActiveIndexRef.current === encounter.activeIndex) return;
prevActiveIndexRef.current = encounter.activeIndex;
if (!window.matchMedia("(min-width: 1024px)").matches) return;
const active = encounter.combatants[encounter.activeIndex];
if (!active?.creatureId || !isLoaded) return;
sidePanel.showCreature(active.creatureId as CreatureId);
}, [
encounter.activeIndex,
encounter.combatants,
isLoaded,
sidePanel.showCreature,
]);
return ( return (
<div className="flex h-screen flex-col"> <div className="flex h-dvh flex-col">
<div className="relative mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-4 min-h-0"> <div className="relative mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col gap-3 px-4">
{actionBarAnim.showTopBar && ( {!!actionBarAnim.showTopBar && (
<div <div
className={`shrink-0 pt-8${actionBarAnim.topBarClass}`} className={cn("shrink-0 pt-8", actionBarAnim.topBarClass)}
onAnimationEnd={actionBarAnim.onTopBarExitEnd} onAnimationEnd={actionBarAnim.onTopBarExitEnd}
> >
<TurnNavigation <TurnNavigation />
encounter={encounter}
onAdvanceTurn={advanceTurn}
onRetreatTurn={retreatTurn}
onClearEncounter={clearEncounter}
/>
</div> </div>
)} )}
{isEmpty ? ( {isEmpty ? (
/* Empty state — ActionBar centered */ <div className="flex min-h-0 flex-1 items-center justify-center pt-8 pb-[15%]">
<div className="flex flex-1 items-center justify-center min-h-0 pb-[15%] pt-8">
<div <div
className={`w-full${actionBarAnim.risingClass}`} className={cn("w-full", actionBarAnim.risingClass)}
onAnimationEnd={actionBarAnim.onRiseEnd} onAnimationEnd={actionBarAnim.onRiseEnd}
> >
<ActionBar <ActionBar
onAddCombatant={addCombatant}
onAddFromBestiary={handleAddFromBestiary}
bestiarySearch={search}
bestiaryLoaded={isLoaded}
onViewStatBlock={handleViewStatBlock}
onBulkImport={sidePanel.showBulkImport}
bulkImportDisabled={bulkImport.state.status === "loading"}
inputRef={actionBarInputRef} inputRef={actionBarInputRef}
playerCharacters={playerCharacters}
onAddFromPlayerCharacter={addFromPlayerCharacter}
onManagePlayers={() => onManagePlayers={() =>
playerCharacterRef.current?.openManagement() playerCharacterRef.current?.openManagement()
} }
onRollAllInitiative={handleRollAllInitiative}
showRollAllInitiative={hasCreatureCombatants}
rollAllInitiativeDisabled={!canRollAllInitiative}
onOpenSourceManager={sidePanel.showSourceManager}
autoFocus autoFocus
/> />
</div> </div>
</div> </div>
) : ( ) : (
<> <>
{/* Scrollable area — combatant list */} <div className="min-h-0 flex-1 overflow-y-auto">
<div className="flex-1 overflow-y-auto min-h-0">
<div className="flex flex-col px-2 py-2"> <div className="flex flex-col px-2 py-2">
{encounter.combatants.map((c, i) => ( {encounter.combatants.map((c, i) => (
<CombatantRow <CombatantRow
@@ -271,120 +76,51 @@ export function App() {
ref={i === encounter.activeIndex ? activeRowRef : null} ref={i === encounter.activeIndex ? activeRowRef : null}
combatant={c} combatant={c}
isActive={i === encounter.activeIndex} isActive={i === encounter.activeIndex}
onRename={editCombatant}
onSetInitiative={setInitiative}
onRemove={removeCombatant}
onSetHp={setHp}
onAdjustHp={adjustHp}
onSetAc={setAc}
onToggleCondition={toggleCondition}
onToggleConcentration={toggleConcentration}
onShowStatBlock={
c.creatureId
? () => handleCombatantStatBlock(c.creatureId as string)
: undefined
}
onRollInitiative={
c.creatureId ? handleRollInitiative : undefined
}
/> />
))} ))}
</div> </div>
</div> </div>
{/* Action Bar — fixed at bottom */}
<div <div
className={`shrink-0 pb-8${actionBarAnim.settlingClass}`} className={cn("shrink-0 pb-8", actionBarAnim.settlingClass)}
onAnimationEnd={actionBarAnim.onSettleEnd} onAnimationEnd={actionBarAnim.onSettleEnd}
> >
<ActionBar <ActionBar
onAddCombatant={addCombatant}
onAddFromBestiary={handleAddFromBestiary}
bestiarySearch={search}
bestiaryLoaded={isLoaded}
onViewStatBlock={handleViewStatBlock}
onBulkImport={sidePanel.showBulkImport}
bulkImportDisabled={bulkImport.state.status === "loading"}
inputRef={actionBarInputRef} inputRef={actionBarInputRef}
playerCharacters={playerCharacters}
onAddFromPlayerCharacter={addFromPlayerCharacter}
onManagePlayers={() => onManagePlayers={() =>
playerCharacterRef.current?.openManagement() playerCharacterRef.current?.openManagement()
} }
onRollAllInitiative={handleRollAllInitiative}
showRollAllInitiative={hasCreatureCombatants}
rollAllInitiativeDisabled={!canRollAllInitiative}
onOpenSourceManager={sidePanel.showSourceManager}
/> />
</div> </div>
</> </>
)} )}
</div> </div>
{/* Pinned Stat Block Panel (left) */} {!!sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
{sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && ( <StatBlockPanel panelRole="pinned" side="left" />
<StatBlockPanel
creatureId={sidePanel.pinnedCreatureId}
creature={pinnedCreature}
isSourceCached={isSourceCached}
fetchAndCacheSource={fetchAndCacheSource}
uploadAndCacheSource={uploadAndCacheSource}
refreshCache={refreshCache}
panelRole="pinned"
isCollapsed={false}
onToggleCollapse={() => {}}
onPin={() => {}}
onUnpin={sidePanel.unpin}
showPinButton={false}
side="left"
onDismiss={() => {}}
/>
)} )}
{/* Browse Stat Block Panel (right) */} <StatBlockPanel panelRole="browse" side="right" />
<StatBlockPanel
creatureId={sidePanel.selectedCreatureId}
creature={selectedCreature}
isSourceCached={isSourceCached}
fetchAndCacheSource={fetchAndCacheSource}
uploadAndCacheSource={uploadAndCacheSource}
refreshCache={refreshCache}
panelRole="browse"
isCollapsed={sidePanel.isRightPanelCollapsed}
onToggleCollapse={sidePanel.toggleCollapse}
onPin={sidePanel.togglePin}
onUnpin={() => {}}
showPinButton={sidePanel.isWideDesktop && !!selectedCreature}
side="right"
onDismiss={sidePanel.dismissPanel}
bulkImportMode={sidePanel.bulkImportMode}
bulkImportState={bulkImport.state}
onStartBulkImport={handleStartBulkImport}
onBulkImportDone={handleBulkImportDone}
sourceManagerMode={sidePanel.sourceManagerMode}
/>
<BulkImportToasts <BulkImportToasts />
state={bulkImport.state}
visible={!sidePanel.bulkImportMode || sidePanel.isRightPanelCollapsed}
onReset={bulkImport.reset}
/>
{rollSkippedCount > 0 && ( {rolls.rollSkippedCount > 0 && (
<Toast <Toast
message={`${rollSkippedCount} skipped — bestiary source not loaded`} message={`${rolls.rollSkippedCount} skipped — bestiary source not loaded`}
onDismiss={() => setRollSkippedCount(0)} onDismiss={rolls.dismissRollSkipped}
autoDismissMs={4000} autoDismissMs={4000}
/> />
)} )}
<PlayerCharacterSection {!!rolls.rollSingleSkipped && (
ref={playerCharacterRef} <Toast
characters={playerCharacters} message="Can't roll — bestiary source not loaded"
onCreateCharacter={createPlayerCharacter} onDismiss={rolls.dismissRollSingleSkipped}
onEditCharacter={editPlayerCharacter} autoDismissMs={4000}
onDeleteCharacter={deletePlayerCharacter}
/> />
)}
<PlayerCharacterSection ref={playerCharacterRef} />
</div> </div>
); );
} }

View File

@@ -0,0 +1,163 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { App } from "../App.js";
import { AllProviders } from "./test-providers.js";
// Mock persistence — no localStorage interaction
vi.mock("../persistence/encounter-storage.js", () => ({
loadEncounter: () => null,
saveEncounter: () => {},
}));
vi.mock("../persistence/player-character-storage.js", () => ({
loadPlayerCharacters: () => [],
savePlayerCharacters: () => {},
}));
// Mock bestiary — no IndexedDB or JSON index
vi.mock("../adapters/bestiary-cache.js", () => ({
loadAllCachedCreatures: () => Promise.resolve(new Map()),
isSourceCached: () => Promise.resolve(false),
cacheSource: () => Promise.resolve(),
getCachedSources: () => Promise.resolve([]),
clearSource: () => Promise.resolve(),
clearAll: () => Promise.resolve(),
}));
vi.mock("../adapters/bestiary-index-adapter.js", () => ({
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
getAllSourceCodes: () => [],
getDefaultFetchUrl: () => "",
getSourceDisplayName: (code: string) => code,
}));
// DOM API stubs — jsdom doesn't implement these
beforeAll(() => {
Object.defineProperty(globalThis, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
Element.prototype.scrollIntoView = vi.fn();
});
afterEach(cleanup);
async function addCombatant(
user: ReturnType<typeof userEvent.setup>,
name: string,
opts?: { maxHp?: string },
) {
const inputs = screen.getAllByPlaceholderText("+ Add combatants");
// biome-ignore lint/style/noNonNullAssertion: getAllBy always returns at least one
const input = inputs.at(-1)!;
await user.type(input, name);
if (opts?.maxHp) {
const maxHpInput = screen.getByPlaceholderText("MaxHP");
await user.type(maxHpInput, opts.maxHp);
}
const addButton = screen.getByRole("button", { name: "Add" });
await user.click(addButton);
}
describe("App integration", () => {
it("adds a combatant and removes it, returning to empty state", async () => {
const user = userEvent.setup();
render(<App />, { wrapper: AllProviders });
// Empty state: centered input visible, no TurnNavigation
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
expect(screen.queryByText("R1")).not.toBeInTheDocument();
// Add a combatant
await addCombatant(user, "Goblin");
// Verify combatant appears and TurnNavigation shows
expect(screen.getByRole("button", { name: "Goblin" })).toBeInTheDocument();
expect(screen.getByText("R1")).toBeInTheDocument();
expect(screen.getAllByText("Goblin").length).toBeGreaterThanOrEqual(2);
// Remove combatant via ConfirmButton (two clicks)
const removeBtn = screen.getByRole("button", {
name: "Remove combatant",
});
await user.click(removeBtn);
const confirmBtn = screen.getByRole("button", {
name: "Confirm remove combatant",
});
await user.click(confirmBtn);
// Back to empty state (R1 badge may linger due to exit animation in jsdom)
expect(
screen.queryByRole("button", { name: "Goblin" }),
).not.toBeInTheDocument();
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
});
it("advances and retreats turns across two combatants", async () => {
const user = userEvent.setup();
render(<App />, { wrapper: AllProviders });
await addCombatant(user, "Fighter");
await addCombatant(user, "Wizard");
// Initial state — R1, Fighter active (Previous turn disabled)
expect(screen.getByText("R1")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Previous turn" }),
).toBeDisabled();
// Advance turn — Wizard becomes active
await user.click(screen.getByRole("button", { name: "Next turn" }));
expect(screen.getByText("R1")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Previous turn" })).toBeEnabled();
// Advance again — wraps to R2, Fighter active
await user.click(screen.getByRole("button", { name: "Next turn" }));
expect(screen.getByText("R2")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Previous turn" })).toBeEnabled();
// Retreat — back to R1, Wizard active
await user.click(screen.getByRole("button", { name: "Previous turn" }));
expect(screen.getByText("R1")).toBeInTheDocument();
});
it("adds a combatant with HP, applies damage, and shows unconscious state", async () => {
const user = userEvent.setup();
render(<App />, { wrapper: AllProviders });
await addCombatant(user, "Ogre", { maxHp: "59" });
// Verify HP displays — currentHp and maxHp both show "59"
const hpButton = screen.getByRole("button", {
name: "Current HP: 59 (healthy)",
});
expect(hpButton).toBeInTheDocument();
// Click currentHp to open HpAdjustPopover, apply full damage
await user.click(hpButton);
const hpInput = screen.getByPlaceholderText("HP");
expect(hpInput).toBeInTheDocument();
await user.type(hpInput, "59");
await user.click(screen.getByRole("button", { name: "Apply damage" }));
// Verify HP decreased to 0 and unconscious state
expect(
screen.getByRole("button", { name: "Current HP: 0 (unconscious)" }),
).toBeInTheDocument();
});
});

View File

@@ -200,6 +200,7 @@ describe("ConfirmButton", () => {
const parentHandler = vi.fn(); const parentHandler = vi.fn();
render( render(
// biome-ignore lint/a11y/noStaticElementInteractions: test wrapper // biome-ignore lint/a11y/noStaticElementInteractions: test wrapper
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: test wrapper
<div onKeyDown={parentHandler}> <div onKeyDown={parentHandler}>
<ConfirmButton <ConfirmButton
icon={<XIcon />} icon={<XIcon />}

View File

@@ -4,8 +4,33 @@ import "@testing-library/jest-dom/vitest";
import type { Creature, CreatureId } from "@initiative/domain"; import type { Creature, CreatureId } from "@initiative/domain";
import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StatBlockPanel } from "../components/stat-block-panel";
// Mock the context modules
vi.mock("../contexts/side-panel-context.js", () => ({
useSidePanelContext: vi.fn(),
}));
vi.mock("../contexts/bestiary-context.js", () => ({
useBestiaryContext: vi.fn(),
}));
// Mock adapters to avoid IndexedDB
vi.mock("../adapters/bestiary-index-adapter.js", () => ({
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
getAllSourceCodes: () => [],
getDefaultFetchUrl: () => "",
getSourceDisplayName: (code: string) => code,
}));
import { StatBlockPanel } from "../components/stat-block-panel.js";
import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { useSidePanelContext } from "../contexts/side-panel-context.js";
const mockUseSidePanelContext = vi.mocked(useSidePanelContext);
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
const CLOSE_REGEX = /close/i;
const COLLAPSE_REGEX = /collapse/i;
const CREATURE_ID = "srd:goblin" as CreatureId; const CREATURE_ID = "srd:goblin" as CreatureId;
const CREATURE: Creature = { const CREATURE: Creature = {
id: CREATURE_ID, id: CREATURE_ID,
@@ -26,7 +51,7 @@ const CREATURE: Creature = {
}; };
function mockMatchMedia(matches: boolean) { function mockMatchMedia(matches: boolean) {
Object.defineProperty(window, "matchMedia", { Object.defineProperty(globalThis, "matchMedia", {
writable: true, writable: true,
value: vi.fn().mockImplementation((query: string) => ({ value: vi.fn().mockImplementation((query: string) => ({
matches, matches,
@@ -41,41 +66,65 @@ function mockMatchMedia(matches: boolean) {
}); });
} }
interface PanelProps { interface PanelOverrides {
creatureId?: CreatureId | null; creatureId?: CreatureId | null;
creature?: Creature | null; creature?: Creature | null;
panelRole?: "browse" | "pinned"; panelRole?: "browse" | "pinned";
isCollapsed?: boolean; isCollapsed?: boolean;
onToggleCollapse?: () => void;
onPin?: () => void;
onUnpin?: () => void;
showPinButton?: boolean;
side?: "left" | "right"; side?: "left" | "right";
onDismiss?: () => void;
bulkImportMode?: boolean; bulkImportMode?: boolean;
} }
function renderPanel(overrides: PanelProps = {}) { function setupMocks(overrides: PanelOverrides = {}) {
const props = { const panelRole = overrides.panelRole ?? "browse";
creatureId: CREATURE_ID, const creatureId = overrides.creatureId ?? CREATURE_ID;
creature: CREATURE, const creature = overrides.creature ?? CREATURE;
const isCollapsed = overrides.isCollapsed ?? false;
const onToggleCollapse = vi.fn();
const onPin = vi.fn();
const onUnpin = vi.fn();
const onDismiss = vi.fn();
mockUseSidePanelContext.mockReturnValue({
selectedCreatureId: panelRole === "browse" ? creatureId : null,
pinnedCreatureId: panelRole === "pinned" ? creatureId : null,
isRightPanelCollapsed: panelRole === "browse" ? isCollapsed : false,
isWideDesktop: false,
bulkImportMode: overrides.bulkImportMode ?? false,
sourceManagerMode: false,
panelView: creatureId
? { mode: "creature" as const, creatureId }
: { mode: "closed" as const },
showCreature: vi.fn(),
updateCreature: vi.fn(),
showBulkImport: vi.fn(),
showSourceManager: vi.fn(),
dismissPanel: onDismiss,
toggleCollapse: onToggleCollapse,
togglePin: onPin,
unpin: onUnpin,
} as ReturnType<typeof useSidePanelContext>);
mockUseBestiaryContext.mockReturnValue({
getCreature: (id: CreatureId) => (id === creatureId ? creature : undefined),
isSourceCached: vi.fn().mockResolvedValue(true), isSourceCached: vi.fn().mockResolvedValue(true),
search: vi.fn().mockReturnValue([]),
isLoaded: true,
fetchAndCacheSource: vi.fn(), fetchAndCacheSource: vi.fn(),
uploadAndCacheSource: vi.fn(), uploadAndCacheSource: vi.fn(),
refreshCache: vi.fn(), refreshCache: vi.fn(),
panelRole: "browse" as const, } as ReturnType<typeof useBestiaryContext>);
isCollapsed: false,
onToggleCollapse: vi.fn(),
onPin: vi.fn(),
onUnpin: vi.fn(),
showPinButton: false,
side: "right" as const,
onDismiss: vi.fn(),
...overrides,
};
render(<StatBlockPanel {...props} />); return { onToggleCollapse, onPin, onUnpin, onDismiss };
return props; }
function renderPanel(overrides: PanelOverrides = {}) {
const callbacks = setupMocks(overrides);
const panelRole = overrides.panelRole ?? "browse";
const side = overrides.side ?? (panelRole === "pinned" ? "left" : "right");
render(<StatBlockPanel panelRole={panelRole} side={side} />);
return callbacks;
} }
describe("Stat Block Panel Collapse/Expand and Pin", () => { describe("Stat Block Panel Collapse/Expand and Pin", () => {
@@ -92,7 +141,7 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
screen.getByRole("button", { name: "Collapse stat block panel" }), screen.getByRole("button", { name: "Collapse stat block panel" }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.queryByRole("button", { name: /close/i }), screen.queryByRole("button", { name: CLOSE_REGEX }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
@@ -110,19 +159,19 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
}); });
it("calls onToggleCollapse when collapse button is clicked", () => { it("calls onToggleCollapse when collapse button is clicked", () => {
const props = renderPanel(); const callbacks = renderPanel();
fireEvent.click( fireEvent.click(
screen.getByRole("button", { name: "Collapse stat block panel" }), screen.getByRole("button", { name: "Collapse stat block panel" }),
); );
expect(props.onToggleCollapse).toHaveBeenCalledTimes(1); expect(callbacks.onToggleCollapse).toHaveBeenCalledTimes(1);
}); });
it("calls onToggleCollapse when collapsed tab is clicked", () => { it("calls onToggleCollapse when collapsed tab is clicked", () => {
const props = renderPanel({ isCollapsed: true }); const callbacks = renderPanel({ isCollapsed: true });
fireEvent.click( fireEvent.click(
screen.getByRole("button", { name: "Expand stat block panel" }), screen.getByRole("button", { name: "Expand stat block panel" }),
); );
expect(props.onToggleCollapse).toHaveBeenCalledTimes(1); expect(callbacks.onToggleCollapse).toHaveBeenCalledTimes(1);
}); });
it("applies translate-x class when collapsed (right side)", () => { it("applies translate-x class when collapsed (right side)", () => {
@@ -160,53 +209,58 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
}); });
it("calls onDismiss when backdrop is clicked on mobile", () => { it("calls onDismiss when backdrop is clicked on mobile", () => {
const props = renderPanel(); const callbacks = renderPanel();
fireEvent.click(screen.getByRole("button", { name: "Close stat block" })); fireEvent.click(screen.getByRole("button", { name: "Close stat block" }));
expect(props.onDismiss).toHaveBeenCalledTimes(1); expect(callbacks.onDismiss).toHaveBeenCalledTimes(1);
}); });
it("does not render pinned panel on mobile", () => { it("does not render pinned panel on mobile", () => {
const { container } = render( const { container } = render(
<StatBlockPanel (() => {
creatureId={CREATURE_ID} setupMocks({ panelRole: "pinned" });
creature={CREATURE} return <StatBlockPanel panelRole="pinned" side="left" />;
isSourceCached={vi.fn().mockResolvedValue(true)} })(),
fetchAndCacheSource={vi.fn()}
uploadAndCacheSource={vi.fn()}
refreshCache={vi.fn()}
panelRole="pinned"
isCollapsed={false}
onToggleCollapse={vi.fn()}
onPin={vi.fn()}
onUnpin={vi.fn()}
showPinButton={false}
side="left"
onDismiss={vi.fn()}
/>,
); );
expect(container.innerHTML).toBe(""); expect(container.innerHTML).toBe("");
}); });
}); });
describe("US2: Pin and Unpin", () => { describe("US2: Pin and Unpin", () => {
it("shows pin button when showPinButton is true on desktop", () => { it("shows pin button when isWideDesktop is true on desktop", () => {
renderPanel({ showPinButton: true }); setupMocks();
// Override to set isWideDesktop
const ctx = mockUseSidePanelContext.mock.results[0]?.value as ReturnType<
typeof useSidePanelContext
>;
mockUseSidePanelContext.mockReturnValue({
...ctx,
isWideDesktop: true,
});
render(<StatBlockPanel panelRole="browse" side="right" />);
expect( expect(
screen.getByRole("button", { name: "Pin creature" }), screen.getByRole("button", { name: "Pin creature" }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("hides pin button when showPinButton is false", () => { it("hides pin button when isWideDesktop is false", () => {
renderPanel({ showPinButton: false }); renderPanel();
expect( expect(
screen.queryByRole("button", { name: "Pin creature" }), screen.queryByRole("button", { name: "Pin creature" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it("calls onPin when pin button is clicked", () => { it("calls onPin when pin button is clicked", () => {
const props = renderPanel({ showPinButton: true }); setupMocks();
const ctx = mockUseSidePanelContext.mock.results[0]?.value as ReturnType<
typeof useSidePanelContext
>;
mockUseSidePanelContext.mockReturnValue({
...ctx,
isWideDesktop: true,
});
render(<StatBlockPanel panelRole="browse" side="right" />);
fireEvent.click(screen.getByRole("button", { name: "Pin creature" })); fireEvent.click(screen.getByRole("button", { name: "Pin creature" }));
expect(props.onPin).toHaveBeenCalledTimes(1); expect(ctx.togglePin).toHaveBeenCalledTimes(1);
}); });
it("shows unpin button for pinned role", () => { it("shows unpin button for pinned role", () => {
@@ -217,9 +271,9 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
}); });
it("calls onUnpin when unpin button is clicked", () => { it("calls onUnpin when unpin button is clicked", () => {
const props = renderPanel({ panelRole: "pinned", side: "left" }); const callbacks = renderPanel({ panelRole: "pinned", side: "left" });
fireEvent.click(screen.getByRole("button", { name: "Unpin creature" })); fireEvent.click(screen.getByRole("button", { name: "Unpin creature" }));
expect(props.onUnpin).toHaveBeenCalledTimes(1); expect(callbacks.onUnpin).toHaveBeenCalledTimes(1);
}); });
it("positions pinned panel on the left side", () => { it("positions pinned panel on the left side", () => {
@@ -247,12 +301,12 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
it("pinned panel has no collapse button", () => { it("pinned panel has no collapse button", () => {
renderPanel({ panelRole: "pinned", side: "left" }); renderPanel({ panelRole: "pinned", side: "left" });
expect( expect(
screen.queryByRole("button", { name: /collapse/i }), screen.queryByRole("button", { name: COLLAPSE_REGEX }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it("pinned panel is always expanded (no translate offset)", () => { it("pinned panel is always expanded (no translate offset)", () => {
renderPanel({ panelRole: "pinned", side: "left", isCollapsed: false }); renderPanel({ panelRole: "pinned", side: "left" });
const unpinBtn = screen.getByRole("button", { const unpinBtn = screen.getByRole("button", {
name: "Unpin creature", name: "Unpin creature",
}); });

View File

@@ -0,0 +1,28 @@
import type { ReactNode } from "react";
import {
BestiaryProvider,
BulkImportProvider,
EncounterProvider,
InitiativeRollsProvider,
PlayerCharactersProvider,
SidePanelProvider,
ThemeProvider,
} from "../contexts/index.js";
export function AllProviders({ children }: { children: ReactNode }) {
return (
<ThemeProvider>
<EncounterProvider>
<BestiaryProvider>
<PlayerCharactersProvider>
<BulkImportProvider>
<SidePanelProvider>
<InitiativeRollsProvider>{children}</InitiativeRollsProvider>
</SidePanelProvider>
</BulkImportProvider>
</PlayerCharactersProvider>
</BestiaryProvider>
</EncounterProvider>
</ThemeProvider>
);
}

View File

@@ -9,6 +9,8 @@ import type {
import { creatureId, proficiencyBonus } from "@initiative/domain"; import { creatureId, proficiencyBonus } from "@initiative/domain";
import { stripTags } from "./strip-tags.js"; import { stripTags } from "./strip-tags.js";
const LEADING_DIGITS_REGEX = /^(\d+)/;
// --- Raw 5etools types (minimal, for parsing) --- // --- Raw 5etools types (minimal, for parsing) ---
interface RawMonster { interface RawMonster {
@@ -49,6 +51,7 @@ interface RawMonster {
legendaryHeader?: string[]; legendaryHeader?: string[];
spellcasting?: RawSpellcasting[]; spellcasting?: RawSpellcasting[];
initiative?: { proficiency?: number }; initiative?: { proficiency?: number };
_copy?: unknown;
} }
interface RawEntry { interface RawEntry {
@@ -168,7 +171,7 @@ function extractAc(ac: RawMonster["ac"]): {
} }
if ("special" in first) { if ("special" in first) {
// Variable AC (e.g. spell summons) — parse leading number if possible // Variable AC (e.g. spell summons) — parse leading number if possible
const match = first.special.match(/^(\d+)/); const match = LEADING_DIGITS_REGEX.exec(first.special);
return { return {
value: match ? Number(match[1]) : 0, value: match ? Number(match[1]) : 0,
source: first.special, source: first.special,
@@ -371,8 +374,8 @@ function extractCr(cr: string | { cr: string } | undefined): string {
function makeCreatureId(source: string, name: string): CreatureId { function makeCreatureId(source: string, name: string): CreatureId {
const slug = name const slug = name
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9]+/g, "-") .replaceAll(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, ""); .replaceAll(/(^-|-$)/g, "");
return creatureId(`${source.toLowerCase()}:${slug}`); return creatureId(`${source.toLowerCase()}:${slug}`);
} }
@@ -383,8 +386,7 @@ export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
// Filter out _copy entries (reference another source's monster) and // Filter out _copy entries (reference another source's monster) and
// monsters missing required fields (ac, hp, size, type) // monsters missing required fields (ac, hp, size, type)
const monsters = raw.monster.filter((m) => { const monsters = raw.monster.filter((m) => {
// biome-ignore lint/suspicious/noExplicitAny: raw JSON may have _copy field if (m._copy) return false;
if ((m as any)._copy) return false;
return ( return (
Array.isArray(m.ac) && Array.isArray(m.ac) &&
m.ac.length > 0 && m.ac.length > 0 &&

View File

@@ -25,55 +25,58 @@ export function stripTags(text: string): string {
let result = text; let result = text;
// {@h} → "Hit: " // {@h} → "Hit: "
result = result.replace(/\{@h\}/g, "Hit: "); result = result.replaceAll("{@h}", "Hit: ");
// {@hom} → "Hit or Miss: " // {@hom} → "Hit or Miss: "
result = result.replace(/\{@hom\}/g, "Hit or Miss: "); result = result.replaceAll("{@hom}", "Hit or Miss: ");
// {@actTrigger} → "Trigger:" // {@actTrigger} → "Trigger:"
result = result.replace(/\{@actTrigger\}/g, "Trigger:"); result = result.replaceAll("{@actTrigger}", "Trigger:");
// {@actResponse} → "Response:" // {@actResponse} → "Response:"
result = result.replace(/\{@actResponse\}/g, "Response:"); result = result.replaceAll("{@actResponse}", "Response:");
// {@actSaveSuccess} → "Success:" // {@actSaveSuccess} → "Success:"
result = result.replace(/\{@actSaveSuccess\}/g, "Success:"); result = result.replaceAll("{@actSaveSuccess}", "Success:");
// {@actSaveSuccessOrFail} → handled below as parameterized // {@actSaveSuccessOrFail} → handled below as parameterized
// {@recharge 5} → "(Recharge 5-6)", {@recharge} → "(Recharge 6)" // {@recharge 5} → "(Recharge 5-6)", {@recharge} → "(Recharge 6)"
result = result.replace(/\{@recharge\s+(\d)\}/g, "(Recharge $1-6)"); result = result.replaceAll(/\{@recharge\s+(\d)\}/g, "(Recharge $1-6)");
result = result.replace(/\{@recharge\}/g, "(Recharge 6)"); result = result.replaceAll("{@recharge}", "(Recharge 6)");
// {@dc N} → "DC N" // {@dc N} → "DC N"
result = result.replace(/\{@dc\s+(\d+)\}/g, "DC $1"); result = result.replaceAll(/\{@dc\s+(\d+)\}/g, "DC $1");
// {@hit N} → "+N" // {@hit N} → "+N"
result = result.replace(/\{@hit\s+(\d+)\}/g, "+$1"); result = result.replaceAll(/\{@hit\s+(\d+)\}/g, "+$1");
// {@atkr type} → mapped attack roll text // {@atkr type} → mapped attack roll text
result = result.replace(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => { result = result.replaceAll(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => {
return ATKR_MAP[type.trim()] ?? `Attack Roll:`; return ATKR_MAP[type.trim()] ?? "Attack Roll:";
}); });
// {@actSave ability} → "Ability saving throw" // {@actSave ability} → "Ability saving throw"
result = result.replace(/\{@actSave\s+([^}]+)\}/g, (_, ability: string) => { result = result.replaceAll(
/\{@actSave\s+([^}]+)\}/g,
(_, ability: string) => {
const name = ABILITY_MAP[ability.trim().toLowerCase()]; const name = ABILITY_MAP[ability.trim().toLowerCase()];
return name ? `${name} saving throw` : `${ability} saving throw`; return name ? `${name} saving throw` : `${ability} saving throw`;
}); },
);
// {@actSaveFail} → "Failure:" or {@actSaveFail N} → "Failure by N or More:" // {@actSaveFail} → "Failure:" or {@actSaveFail N} → "Failure by N or More:"
result = result.replace( result = result.replaceAll(
/\{@actSaveFail\s+(\d+)\}/g, /\{@actSaveFail\s+(\d+)\}/g,
"Failure by $1 or More:", "Failure by $1 or More:",
); );
result = result.replace(/\{@actSaveFail\}/g, "Failure:"); result = result.replaceAll("{@actSaveFail}", "Failure:");
// {@actSaveSuccessOrFail} → keep as-is label // {@actSaveSuccessOrFail} → keep as-is label
result = result.replace(/\{@actSaveSuccessOrFail\}/g, "Success or Failure:"); result = result.replaceAll("{@actSaveSuccessOrFail}", "Success or Failure:");
// {@actSaveFailBy N} → "Failure by N or More:" // {@actSaveFailBy N} → "Failure by N or More:"
result = result.replace( result = result.replaceAll(
/\{@actSaveFailBy\s+(\d+)\}/g, /\{@actSaveFailBy\s+(\d+)\}/g,
"Failure by $1 or More:", "Failure by $1 or More:",
); );
@@ -81,7 +84,7 @@ export function stripTags(text: string): string {
// Generic tags: {@tag Display|Source|...} → Display (first segment before |) // Generic tags: {@tag Display|Source|...} → Display (first segment before |)
// Covers: spell, condition, damage, dice, variantrule, action, skill, // Covers: spell, condition, damage, dice, variantrule, action, skill,
// creature, hazard, status, plus any unknown tags // creature, hazard, status, plus any unknown tags
result = result.replace( result = result.replaceAll(
/\{@(\w+)\s+([^}]+)\}/g, /\{@(\w+)\s+([^}]+)\}/g,
(_, tag: string, content: string) => { (_, tag: string, content: string) => {
// For tags with Display|Source format, extract first segment // For tags with Display|Source format, extract first segment

View File

@@ -0,0 +1,121 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { AllProviders } from "../../__tests__/test-providers.js";
import { ActionBar } from "../action-bar.js";
// Mock persistence — no localStorage interaction
vi.mock("../../persistence/encounter-storage.js", () => ({
loadEncounter: () => null,
saveEncounter: () => {},
}));
vi.mock("../../persistence/player-character-storage.js", () => ({
loadPlayerCharacters: () => [],
savePlayerCharacters: () => {},
}));
// Mock bestiary — no IndexedDB or JSON index
vi.mock("../../adapters/bestiary-cache.js", () => ({
loadAllCachedCreatures: () => Promise.resolve(new Map()),
isSourceCached: () => Promise.resolve(false),
cacheSource: () => Promise.resolve(),
getCachedSources: () => Promise.resolve([]),
clearSource: () => Promise.resolve(),
clearAll: () => Promise.resolve(),
}));
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
getAllSourceCodes: () => [],
getDefaultFetchUrl: () => "",
getSourceDisplayName: (code: string) => code,
}));
// DOM API stubs — jsdom doesn't implement these
beforeAll(() => {
Object.defineProperty(globalThis, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});
afterEach(cleanup);
function renderBar(props: Partial<Parameters<typeof ActionBar>[0]> = {}) {
return render(<ActionBar {...props} />, { wrapper: AllProviders });
}
describe("ActionBar", () => {
it("renders input with placeholder '+ Add combatants'", () => {
renderBar();
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
});
it("submitting with a name adds a combatant", async () => {
const user = userEvent.setup();
renderBar();
const input = screen.getByPlaceholderText("+ Add combatants");
await user.type(input, "Goblin");
// The Add button appears when name >= 2 chars and no suggestions
const addButton = screen.getByRole("button", { name: "Add" });
await user.click(addButton);
// Input is cleared after adding (context handles the state)
expect(input).toHaveValue("");
});
it("submitting with empty name does nothing", async () => {
const user = userEvent.setup();
renderBar();
// Submit the form directly (Enter on empty input)
const input = screen.getByPlaceholderText("+ Add combatants");
await user.type(input, "{Enter}");
// Input stays empty, no error
expect(input).toHaveValue("");
});
it("shows custom fields (Init, AC, MaxHP) when name >= 2 chars and no bestiary suggestions", async () => {
const user = userEvent.setup();
renderBar();
const input = screen.getByPlaceholderText("+ Add combatants");
await user.type(input, "Go");
expect(screen.getByPlaceholderText("Init")).toBeInTheDocument();
expect(screen.getByPlaceholderText("AC")).toBeInTheDocument();
expect(screen.getByPlaceholderText("MaxHP")).toBeInTheDocument();
});
it("shows Add button when name >= 2 chars and no suggestions", async () => {
const user = userEvent.setup();
renderBar();
const input = screen.getByPlaceholderText("+ Add combatants");
await user.type(input, "Go");
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
});
it("does not show roll all initiative button when no creature combatants", () => {
renderBar();
expect(
screen.queryByRole("button", { name: "Roll all initiative" }),
).not.toBeInTheDocument();
});
it("shows overflow menu items", () => {
renderBar({ onManagePlayers: vi.fn() });
// The overflow menu should be present (it contains Player Characters etc.)
expect(
screen.getByRole("button", { name: "More actions" }),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,300 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { type CreatureId, combatantId } 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 { AllProviders } from "../../__tests__/test-providers.js";
import { CombatantRow } from "../combatant-row.js";
import { PLAYER_COLOR_HEX } from "../player-icon-map.js";
const TEMP_HP_REGEX = /^\+\d/;
// Mock persistence — no localStorage interaction
vi.mock("../../persistence/encounter-storage.js", () => ({
loadEncounter: () => null,
saveEncounter: () => {},
}));
vi.mock("../../persistence/player-character-storage.js", () => ({
loadPlayerCharacters: () => [],
savePlayerCharacters: () => {},
}));
// Mock bestiary — no IndexedDB or JSON index
vi.mock("../../adapters/bestiary-cache.js", () => ({
loadAllCachedCreatures: () => Promise.resolve(new Map()),
isSourceCached: () => Promise.resolve(false),
cacheSource: () => Promise.resolve(),
getCachedSources: () => Promise.resolve([]),
clearSource: () => Promise.resolve(),
clearAll: () => Promise.resolve(),
}));
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
getAllSourceCodes: () => [],
getDefaultFetchUrl: () => "",
getSourceDisplayName: (code: string) => code,
}));
// DOM API stubs
beforeAll(() => {
Object.defineProperty(globalThis, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});
afterEach(cleanup);
function renderRow(
overrides: Partial<{
combatant: Parameters<typeof CombatantRow>[0]["combatant"];
isActive: boolean;
}> = {},
) {
const combatant = overrides.combatant ?? {
id: combatantId("1"),
name: "Goblin",
initiative: 15,
maxHp: 10,
currentHp: 10,
ac: 13,
};
return render(
<CombatantRow
combatant={combatant}
isActive={overrides.isActive ?? false}
/>,
{ wrapper: AllProviders },
);
}
describe("CombatantRow", () => {
it("renders combatant name", () => {
renderRow();
expect(screen.getByText("Goblin")).toBeInTheDocument();
});
it("renders initiative value", () => {
renderRow();
expect(screen.getByText("15")).toBeInTheDocument();
});
it("renders current HP", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
maxHp: 10,
currentHp: 7,
},
});
expect(screen.getByText("7")).toBeInTheDocument();
});
it("active combatant gets active border styling", () => {
const { container } = renderRow({ isActive: true });
const row = container.firstElementChild;
expect(row?.className).toContain("border-active-row-border");
});
it("unconscious combatant (currentHp === 0) gets dimmed styling", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
maxHp: 10,
currentHp: 0,
},
});
// The name area should have opacity-50
const nameEl = screen.getByText("Goblin");
const nameContainer = nameEl.closest(".opacity-50");
expect(nameContainer).not.toBeNull();
});
it("shows 'Max' placeholder when no maxHp is set", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
},
});
expect(screen.getByText("Max")).toBeInTheDocument();
});
it("shows concentration icon when isConcentrating is true", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
isConcentrating: true,
},
});
const concButton = screen.getByRole("button", {
name: "Toggle concentration",
});
expect(concButton.className).toContain("text-purple-400");
});
it("shows player character icon and color when set", () => {
const { container } = renderRow({
combatant: {
id: combatantId("1"),
name: "Aragorn",
color: "red",
icon: "sword",
},
});
// The icon should be rendered with the player color
const svgIcon = container.querySelector("svg[style]");
expect(svgIcon).not.toBeNull();
expect(svgIcon).toHaveStyle({ color: PLAYER_COLOR_HEX.red });
});
it("remove button removes after confirmation", async () => {
const user = userEvent.setup();
renderRow();
const removeBtn = screen.getByRole("button", {
name: "Remove combatant",
});
// First click enters confirm state
await user.click(removeBtn);
// Second click confirms
const confirmBtn = screen.getByRole("button", {
name: "Confirm remove combatant",
});
await user.click(confirmBtn);
// After confirming, the button returns to its initial state
expect(
screen.queryByRole("button", { name: "Confirm remove combatant" }),
).not.toBeInTheDocument();
});
it("shows d20 roll button when initiative is undefined and combatant has creatureId", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
creatureId: "srd:goblin" as CreatureId,
},
});
expect(
screen.getByRole("button", { name: "Roll initiative" }),
).toBeInTheDocument();
});
describe("concentration pulse", () => {
it("pulses when currentHp drops on a concentrating combatant", () => {
const combatant = {
id: combatantId("1"),
name: "Goblin",
maxHp: 20,
currentHp: 15,
isConcentrating: true,
};
const { rerender, container } = renderRow({ combatant });
rerender(
<CombatantRow
combatant={{ ...combatant, currentHp: 10 }}
isActive={false}
/>,
);
const row = container.firstElementChild;
expect(row?.className).toContain("animate-concentration-pulse");
});
it("does not pulse when not concentrating", () => {
const combatant = {
id: combatantId("1"),
name: "Goblin",
maxHp: 20,
currentHp: 15,
isConcentrating: false,
};
const { rerender, container } = renderRow({ combatant });
rerender(
<CombatantRow
combatant={{ ...combatant, currentHp: 10 }}
isActive={false}
/>,
);
const row = container.firstElementChild;
expect(row?.className).not.toContain("animate-concentration-pulse");
});
it("pulses when temp HP absorbs all damage on a concentrating combatant", () => {
const combatant = {
id: combatantId("1"),
name: "Goblin",
maxHp: 20,
currentHp: 15,
tempHp: 8,
isConcentrating: true,
};
const { rerender, container } = renderRow({ combatant });
// Temp HP absorbs all damage, currentHp unchanged
rerender(
<CombatantRow
combatant={{ ...combatant, tempHp: 3 }}
isActive={false}
/>,
);
const row = container.firstElementChild;
expect(row?.className).toContain("animate-concentration-pulse");
});
});
describe("temp HP display", () => {
it("shows +N when combatant has temp HP", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
maxHp: 20,
currentHp: 15,
tempHp: 5,
},
});
expect(screen.getByText("+5")).toBeInTheDocument();
});
it("does not show +N when combatant has no temp HP", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
maxHp: 20,
currentHp: 15,
},
});
expect(screen.queryByText(TEMP_HP_REGEX)).not.toBeInTheDocument();
});
it("temp HP display uses cyan color", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
maxHp: 20,
currentHp: 15,
tempHp: 8,
},
});
const tempHpEl = screen.getByText("+8");
expect(tempHpEl.className).toContain("text-cyan-400");
});
});
});

View File

@@ -0,0 +1,69 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createRef, type RefObject } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ConditionPicker } from "../condition-picker";
afterEach(cleanup);
function renderPicker(
overrides: Partial<{
activeConditions: readonly ConditionId[];
onToggle: (conditionId: ConditionId) => void;
onClose: () => void;
}> = {},
) {
const onToggle = overrides.onToggle ?? vi.fn();
const onClose = overrides.onClose ?? vi.fn();
const anchorRef = createRef<HTMLElement>() as RefObject<HTMLElement>;
const anchor = document.createElement("div");
document.body.appendChild(anchor);
(anchorRef as { current: HTMLElement }).current = anchor;
const result = render(
<ConditionPicker
anchorRef={anchorRef}
activeConditions={overrides.activeConditions ?? []}
onToggle={onToggle}
onClose={onClose}
/>,
);
return { ...result, onToggle, onClose };
}
describe("ConditionPicker", () => {
it("renders all condition definitions from domain", () => {
renderPicker();
for (const def of CONDITION_DEFINITIONS) {
expect(screen.getByText(def.label)).toBeInTheDocument();
}
});
it("active conditions are visually distinguished", () => {
renderPicker({ activeConditions: ["blinded"] });
const blindedButton = screen.getByText("Blinded").closest("button");
expect(blindedButton?.className).toContain("bg-card/50");
});
it("clicking a condition calls onToggle with that condition's ID", async () => {
const user = userEvent.setup();
const { onToggle } = renderPicker();
await user.click(screen.getByText("Poisoned"));
expect(onToggle).toHaveBeenCalledWith("poisoned");
});
it("non-active conditions render with muted styling", () => {
renderPicker({ activeConditions: [] });
const label = screen.getByText("Charmed");
expect(label.className).toContain("text-muted-foreground");
});
it("active condition labels use foreground color", () => {
renderPicker({ activeConditions: ["charmed"] });
const label = screen.getByText("Charmed");
expect(label.className).toContain("text-foreground");
});
});

View File

@@ -0,0 +1,148 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { HpAdjustPopover } from "../hp-adjust-popover";
afterEach(cleanup);
function renderPopover(
overrides: Partial<{
onAdjust: (delta: number) => void;
onSetTempHp: (value: number) => void;
onClose: () => void;
}> = {},
) {
const onAdjust = overrides.onAdjust ?? vi.fn();
const onSetTempHp = overrides.onSetTempHp ?? vi.fn();
const onClose = overrides.onClose ?? vi.fn();
const result = render(
<HpAdjustPopover
onAdjust={onAdjust}
onSetTempHp={onSetTempHp}
onClose={onClose}
/>,
);
return { ...result, onAdjust, onSetTempHp, onClose };
}
describe("HpAdjustPopover", () => {
it("renders input with placeholder 'HP'", () => {
renderPopover();
expect(screen.getByPlaceholderText("HP")).toBeInTheDocument();
});
it("damage and heal buttons are disabled when input is empty", () => {
renderPopover();
expect(screen.getByRole("button", { name: "Apply damage" })).toBeDisabled();
expect(
screen.getByRole("button", { name: "Apply healing" }),
).toBeDisabled();
});
it("damage and heal buttons are disabled when input is '0'", async () => {
const user = userEvent.setup();
renderPopover();
await user.type(screen.getByPlaceholderText("HP"), "0");
expect(screen.getByRole("button", { name: "Apply damage" })).toBeDisabled();
expect(
screen.getByRole("button", { name: "Apply healing" }),
).toBeDisabled();
});
it("typing a valid number enables both buttons", async () => {
const user = userEvent.setup();
renderPopover();
await user.type(screen.getByPlaceholderText("HP"), "5");
expect(
screen.getByRole("button", { name: "Apply damage" }),
).not.toBeDisabled();
expect(
screen.getByRole("button", { name: "Apply healing" }),
).not.toBeDisabled();
});
it("clicking damage button calls onAdjust with negative value and onClose", async () => {
const user = userEvent.setup();
const { onAdjust, onClose } = renderPopover();
await user.type(screen.getByPlaceholderText("HP"), "7");
await user.click(screen.getByRole("button", { name: "Apply damage" }));
expect(onAdjust).toHaveBeenCalledWith(-7);
expect(onClose).toHaveBeenCalled();
});
it("clicking heal button calls onAdjust with positive value and onClose", async () => {
const user = userEvent.setup();
const { onAdjust, onClose } = renderPopover();
await user.type(screen.getByPlaceholderText("HP"), "3");
await user.click(screen.getByRole("button", { name: "Apply healing" }));
expect(onAdjust).toHaveBeenCalledWith(3);
expect(onClose).toHaveBeenCalled();
});
it("Enter key applies damage (negative)", async () => {
const user = userEvent.setup();
const { onAdjust, onClose } = renderPopover();
const input = screen.getByPlaceholderText("HP");
await user.type(input, "4");
await user.keyboard("{Enter}");
expect(onAdjust).toHaveBeenCalledWith(-4);
expect(onClose).toHaveBeenCalled();
});
it("Shift+Enter applies healing (positive)", async () => {
const user = userEvent.setup();
const { onAdjust, onClose } = renderPopover();
const input = screen.getByPlaceholderText("HP");
await user.type(input, "6");
await user.keyboard("{Shift>}{Enter}{/Shift}");
expect(onAdjust).toHaveBeenCalledWith(6);
expect(onClose).toHaveBeenCalled();
});
it("Escape key calls onClose", async () => {
const user = userEvent.setup();
const { onClose } = renderPopover();
const input = screen.getByPlaceholderText("HP");
await user.type(input, "2");
await user.keyboard("{Escape}");
expect(onClose).toHaveBeenCalled();
});
it("only accepts digit characters in input", async () => {
const user = userEvent.setup();
renderPopover();
const input = screen.getByPlaceholderText("HP");
await user.type(input, "12abc34");
expect(input).toHaveValue("1234");
});
describe("temp HP", () => {
it("shield button calls onSetTempHp with entered value and closes", async () => {
const user = userEvent.setup();
const { onSetTempHp, onClose } = renderPopover();
await user.type(screen.getByPlaceholderText("HP"), "8");
await user.click(screen.getByRole("button", { name: "Set temp HP" }));
expect(onSetTempHp).toHaveBeenCalledWith(8);
expect(onClose).toHaveBeenCalled();
});
it("shield button is disabled when input is empty", () => {
renderPopover();
expect(
screen.getByRole("button", { name: "Set temp HP" }),
).toBeDisabled();
});
it("shield button is disabled when input is '0'", async () => {
const user = userEvent.setup();
renderPopover();
await user.type(screen.getByPlaceholderText("HP"), "0");
expect(
screen.getByRole("button", { name: "Set temp HP" }),
).toBeDisabled();
});
});
});

View File

@@ -0,0 +1,150 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("../../adapters/bestiary-cache.js", () => ({
getCachedSources: vi.fn(),
clearSource: vi.fn(),
clearAll: vi.fn(),
}));
// Mock the context module
vi.mock("../../contexts/bestiary-context.js", () => ({
useBestiaryContext: vi.fn(),
}));
import * as bestiaryCache from "../../adapters/bestiary-cache.js";
import { useBestiaryContext } from "../../contexts/bestiary-context.js";
import { SourceManager } from "../source-manager.js";
const mockGetCachedSources = vi.mocked(bestiaryCache.getCachedSources);
const mockClearSource = vi.mocked(bestiaryCache.clearSource);
const mockClearAll = vi.mocked(bestiaryCache.clearAll);
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
function setupMockContext() {
const refreshCache = vi.fn().mockResolvedValue(undefined);
mockUseBestiaryContext.mockReturnValue({
refreshCache,
search: vi.fn().mockReturnValue([]),
getCreature: vi.fn(),
isLoaded: true,
isSourceCached: vi.fn().mockResolvedValue(false),
fetchAndCacheSource: vi.fn(),
uploadAndCacheSource: vi.fn(),
} as ReturnType<typeof useBestiaryContext>);
return { refreshCache };
}
describe("SourceManager", () => {
it("shows 'No cached sources' empty state when no sources", async () => {
setupMockContext();
mockGetCachedSources.mockResolvedValue([]);
render(<SourceManager />);
await waitFor(() => {
expect(screen.getByText("No cached sources")).toBeInTheDocument();
});
});
it("lists cached sources with display name and creature count", async () => {
setupMockContext();
mockGetCachedSources.mockResolvedValue([
{
sourceCode: "mm",
displayName: "Monster Manual",
creatureCount: 300,
cachedAt: Date.now(),
},
{
sourceCode: "vgm",
displayName: "Volo's Guide",
creatureCount: 100,
cachedAt: Date.now(),
},
]);
render(<SourceManager />);
await waitFor(() => {
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
});
expect(screen.getByText("300 creatures")).toBeInTheDocument();
expect(screen.getByText("Volo's Guide")).toBeInTheDocument();
expect(screen.getByText("100 creatures")).toBeInTheDocument();
});
it("Clear All button calls cache clear and refreshCache", async () => {
const user = userEvent.setup();
const { refreshCache } = setupMockContext();
mockGetCachedSources
.mockResolvedValueOnce([
{
sourceCode: "mm",
displayName: "Monster Manual",
creatureCount: 300,
cachedAt: Date.now(),
},
])
.mockResolvedValue([]);
mockClearAll.mockResolvedValue(undefined);
render(<SourceManager />);
await waitFor(() => {
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: "Clear All" }));
await waitFor(() => {
expect(mockClearAll).toHaveBeenCalled();
});
expect(refreshCache).toHaveBeenCalled();
});
it("individual source delete button calls clear for that source", async () => {
const user = userEvent.setup();
const { refreshCache } = setupMockContext();
mockGetCachedSources
.mockResolvedValueOnce([
{
sourceCode: "mm",
displayName: "Monster Manual",
creatureCount: 300,
cachedAt: Date.now(),
},
{
sourceCode: "vgm",
displayName: "Volo's Guide",
creatureCount: 100,
cachedAt: Date.now(),
},
])
.mockResolvedValue([
{
sourceCode: "vgm",
displayName: "Volo's Guide",
creatureCount: 100,
cachedAt: Date.now(),
},
]);
mockClearSource.mockResolvedValue(undefined);
render(<SourceManager />);
await waitFor(() => {
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
});
await user.click(
screen.getByRole("button", { name: "Remove Monster Manual" }),
);
await waitFor(() => {
expect(mockClearSource).toHaveBeenCalledWith("mm");
});
expect(refreshCache).toHaveBeenCalled();
});
});

View File

@@ -5,11 +5,23 @@ import type { Encounter } from "@initiative/domain";
import { combatantId } from "@initiative/domain"; import { combatantId } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { TurnNavigation } from "../turn-navigation";
afterEach(cleanup); // Mock the context module
vi.mock("../../contexts/encounter-context.js", () => ({
useEncounterContext: vi.fn(),
}));
function renderNav(overrides: Partial<Encounter> = {}) { import { useEncounterContext } from "../../contexts/encounter-context.js";
import { TurnNavigation } from "../turn-navigation.js";
const mockUseEncounterContext = vi.mocked(useEncounterContext);
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
function mockContext(overrides: Partial<Encounter> = {}) {
const encounter: Encounter = { const encounter: Encounter = {
combatants: [ combatants: [
{ id: combatantId("1"), name: "Goblin" }, { id: combatantId("1"), name: "Goblin" },
@@ -20,14 +32,40 @@ function renderNav(overrides: Partial<Encounter> = {}) {
...overrides, ...overrides,
}; };
return render( const value = {
<TurnNavigation encounter,
encounter={encounter} advanceTurn: vi.fn(),
onAdvanceTurn={vi.fn()} retreatTurn: vi.fn(),
onRetreatTurn={vi.fn()} clearEncounter: vi.fn(),
onClearEncounter={vi.fn()} isEmpty: encounter.combatants.length === 0,
/>, hasCreatureCombatants: false,
canRollAllInitiative: false,
addCombatant: vi.fn(),
removeCombatant: vi.fn(),
editCombatant: vi.fn(),
setInitiative: vi.fn(),
setHp: vi.fn(),
adjustHp: vi.fn(),
setTempHp: vi.fn(),
hasTempHp: false,
setAc: vi.fn(),
toggleCondition: vi.fn(),
toggleConcentration: vi.fn(),
addFromBestiary: vi.fn(),
addFromPlayerCharacter: vi.fn(),
makeStore: vi.fn(),
events: [],
};
mockUseEncounterContext.mockReturnValue(
value as ReturnType<typeof useEncounterContext>,
); );
return value;
}
function renderNav(overrides: Partial<Encounter> = {}) {
mockContext(overrides);
return render(<TurnNavigation />);
} }
describe("TurnNavigation", () => { describe("TurnNavigation", () => {
@@ -49,80 +87,39 @@ describe("TurnNavigation", () => {
it("does not render an em dash between round and name", () => { it("does not render an em dash between round and name", () => {
const { container } = renderNav(); const { container } = renderNav();
expect(container.textContent).not.toContain(""); expect(container.textContent).not.toContain("\u2014");
}); });
it("round badge and combatant name are siblings in the center area", () => { it("round badge and combatant name are siblings in the center area", () => {
renderNav(); renderNav();
const badge = screen.getByText("R1"); const badge = screen.getByText("R1");
const name = screen.getByText("Goblin"); const name = screen.getByText("Goblin");
expect(badge.parentElement).toBe(name.parentElement); // badge text is inside inner span > outer span, name is a direct child
expect(badge.closest(".flex")).toBe(name.parentElement);
}); });
it("updates the round badge when round changes", () => { it("updates the round badge when round changes", () => {
const { rerender } = render( mockContext({ roundNumber: 2 });
<TurnNavigation const { rerender } = render(<TurnNavigation />);
encounter={{
combatants: [{ id: combatantId("1"), name: "Goblin" }],
activeIndex: 0,
roundNumber: 2,
}}
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
/>,
);
expect(screen.getByText("R2")).toBeInTheDocument(); expect(screen.getByText("R2")).toBeInTheDocument();
rerender( mockContext({ roundNumber: 3 });
<TurnNavigation rerender(<TurnNavigation />);
encounter={{
combatants: [{ id: combatantId("1"), name: "Goblin" }],
activeIndex: 0,
roundNumber: 3,
}}
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
/>,
);
expect(screen.getByText("R3")).toBeInTheDocument(); expect(screen.getByText("R3")).toBeInTheDocument();
expect(screen.queryByText("R2")).not.toBeInTheDocument(); expect(screen.queryByText("R2")).not.toBeInTheDocument();
}); });
it("renders the next combatant name when turn advances", () => { it("renders the next combatant name when turn advances", () => {
const { rerender } = render( const combatants = [
<TurnNavigation
encounter={{
combatants: [
{ id: combatantId("1"), name: "Goblin" }, { id: combatantId("1"), name: "Goblin" },
{ id: combatantId("2"), name: "Conjurer" }, { id: combatantId("2"), name: "Conjurer" },
], ];
activeIndex: 0, mockContext({ combatants, activeIndex: 0 });
roundNumber: 1, const { rerender } = render(<TurnNavigation />);
}}
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
/>,
);
expect(screen.getByText("Goblin")).toBeInTheDocument(); expect(screen.getByText("Goblin")).toBeInTheDocument();
rerender( mockContext({ combatants, activeIndex: 1 });
<TurnNavigation rerender(<TurnNavigation />);
encounter={{
combatants: [
{ id: combatantId("1"), name: "Goblin" },
{ id: combatantId("2"), name: "Conjurer" },
],
activeIndex: 1,
roundNumber: 1,
}}
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
/>,
);
expect(screen.getByText("Conjurer")).toBeInTheDocument(); expect(screen.getByText("Conjurer")).toBeInTheDocument();
}); });
}); });

View File

@@ -12,25 +12,23 @@ export function AcShield({ value, onClick, className }: AcShieldProps) {
type="button" type="button"
onClick={onClick} onClick={onClick}
className={cn( className={cn(
"relative inline-flex items-center justify-center text-sm tabular-nums text-muted-foreground transition-colors hover:text-hover-neutral", "relative inline-flex items-center justify-center text-muted-foreground text-sm tabular-nums transition-colors hover:text-hover-neutral",
className, className,
)} )}
style={{ width: 28, height: 32 }} style={{ width: 28, height: 32 }}
> >
<svg <svg
viewBox="0 0 28 32" viewBox="0 0 28 32"
fill="none" fill="var(--color-border)"
stroke="currentColor" fillOpacity={0.5}
strokeWidth={1.5} stroke="none"
strokeLinecap="round"
strokeLinejoin="round"
className="absolute inset-0 h-full w-full" className="absolute inset-0 h-full w-full"
aria-hidden="true" aria-hidden="true"
> >
<path d="M14 1.5 L2.5 6.5 L2.5 15 Q2.5 25 14 30.5 Q25.5 25 25.5 15 L25.5 6.5 Z" /> <path d="M14 1.5 L2.5 6.5 L2.5 15 Q2.5 25 14 30.5 Q25.5 25 25.5 15 L25.5 6.5 Z" />
</svg> </svg>
<span className="relative text-xs font-medium leading-none"> <span className="relative -mt-0.5 font-medium text-xs leading-none">
{value !== undefined ? value : "\u2014"} {value == null ? "\u2014" : String(value)}
</span> </span>
</button> </button>
); );

View File

@@ -1,4 +1,4 @@
import type { PlayerCharacter, PlayerIcon } from "@initiative/domain"; import type { CreatureId, PlayerCharacter } from "@initiative/domain";
import { import {
Check, Check,
Eye, Eye,
@@ -6,19 +6,31 @@ import {
Import, Import,
Library, Library,
Minus, Minus,
Monitor,
Moon,
Plus, Plus,
Sun,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { import React, {
type FormEvent,
type RefObject, type RefObject,
useCallback,
useDeferredValue, useDeferredValue,
useState, useState,
} from "react"; } from "react";
import type { SearchResult } from "../hooks/use-bestiary.js"; import type { SearchResult } from "../contexts/bestiary-context.js";
import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
import { useEncounterContext } from "../contexts/encounter-context.js";
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
import { useSidePanelContext } from "../contexts/side-panel-context.js";
import { useThemeContext } from "../contexts/theme-context.js";
import { useLongPress } from "../hooks/use-long-press.js";
import { cn } from "../lib/utils.js"; import { cn } from "../lib/utils.js";
import { D20Icon } from "./d20-icon.js"; import { D20Icon } from "./d20-icon.js";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map"; import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
import { RollModeMenu } from "./roll-mode-menu.js";
import { Button } from "./ui/button.js"; import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js"; import { Input } from "./ui/input.js";
import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js"; import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
@@ -29,25 +41,9 @@ interface QueuedCreature {
} }
interface ActionBarProps { interface ActionBarProps {
onAddCombatant: (
name: string,
opts?: { initiative?: number; ac?: number; maxHp?: number },
) => void;
onAddFromBestiary: (result: SearchResult) => void;
bestiarySearch: (query: string) => SearchResult[];
bestiaryLoaded: boolean;
onViewStatBlock?: (result: SearchResult) => void;
onBulkImport?: () => void;
bulkImportDisabled?: boolean;
inputRef?: RefObject<HTMLInputElement | null>; inputRef?: RefObject<HTMLInputElement | null>;
playerCharacters?: readonly PlayerCharacter[];
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
onManagePlayers?: () => void;
onRollAllInitiative?: () => void;
showRollAllInitiative?: boolean;
rollAllInitiativeDisabled?: boolean;
onOpenSourceManager?: () => void;
autoFocus?: boolean; autoFocus?: boolean;
onManagePlayers?: () => void;
} }
function creatureKey(r: SearchResult): string { function creatureKey(r: SearchResult): string {
@@ -67,7 +63,7 @@ function AddModeSuggestions({
onConfirmQueued, onConfirmQueued,
onAddFromPlayerCharacter, onAddFromPlayerCharacter,
onClear, onClear,
}: { }: Readonly<{
nameInput: string; nameInput: string;
suggestions: SearchResult[]; suggestions: SearchResult[];
pcMatches: PlayerCharacter[]; pcMatches: PlayerCharacter[];
@@ -80,51 +76,49 @@ function AddModeSuggestions({
onSetQueued: (q: QueuedCreature | null) => void; onSetQueued: (q: QueuedCreature | null) => void;
onConfirmQueued: () => void; onConfirmQueued: () => void;
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void; onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
}) { }>) {
return ( return (
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg"> <div className="card-glow absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-lg border border-border bg-card">
<button <button
type="button" type="button"
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-2 text-left text-sm text-accent hover:bg-accent/20" className="flex w-full items-center gap-1.5 border-border border-b px-3 py-2 text-left text-accent text-sm hover:bg-accent/20"
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={onDismiss} onClick={onDismiss}
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
<span className="flex-1">Add "{nameInput}" as custom</span> <span className="flex-1">Add "{nameInput}" as custom</span>
<kbd className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground"> <kbd className="rounded border border-border px-1.5 py-0.5 text-muted-foreground text-xs">
Esc Esc
</kbd> </kbd>
</button> </button>
<div className="max-h-48 overflow-y-auto py-1"> <div className="max-h-48 overflow-y-auto py-1">
{pcMatches.length > 0 && ( {pcMatches.length > 0 && (
<> <>
<div className="px-3 py-1 text-xs font-medium text-muted-foreground"> <div className="px-3 py-1 font-medium text-muted-foreground text-xs">
Players Players
</div> </div>
<ul> <ul>
{pcMatches.map((pc) => { {pcMatches.map((pc) => {
const PcIcon = pc.icon const PcIcon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
? PLAYER_ICON_MAP[pc.icon as PlayerIcon]
: undefined;
const pcColor = pc.color const pcColor = pc.color
? PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX] ? PLAYER_COLOR_HEX[pc.color]
: undefined; : undefined;
return ( return (
<li key={pc.id}> <li key={pc.id}>
<button <button
type="button" type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-foreground hover:bg-hover-neutral-bg" className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => { onClick={() => {
onAddFromPlayerCharacter?.(pc); onAddFromPlayerCharacter?.(pc);
onClear(); onClear();
}} }}
> >
{PcIcon && ( {!!PcIcon && (
<PcIcon size={14} style={{ color: pcColor }} /> <PcIcon size={14} style={{ color: pcColor }} />
)} )}
<span className="flex-1 truncate">{pc.name}</span> <span className="flex-1 truncate">{pc.name}</span>
<span className="text-xs text-muted-foreground"> <span className="text-muted-foreground text-xs">
Player Player
</span> </span>
</button> </button>
@@ -144,19 +138,20 @@ function AddModeSuggestions({
<li key={key}> <li key={key}>
<button <button
type="button" type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${ className={cn(
isQueued "flex w-full items-center justify-between px-3 py-1.5 text-left text-foreground text-sm",
? "bg-accent/30 text-foreground" isQueued && "bg-accent/30",
: i === suggestionIndex !isQueued && i === suggestionIndex && "bg-accent/20",
? "bg-accent/20 text-foreground" !isQueued &&
: "text-foreground hover:bg-hover-neutral-bg" i !== suggestionIndex &&
}`} "hover:bg-hover-neutral-bg",
)}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => onClickSuggestion(result)} onClick={() => onClickSuggestion(result)}
onMouseEnter={() => onSetSuggestionIndex(i)} onMouseEnter={() => onSetSuggestionIndex(i)}
> >
<span>{result.name}</span> <span>{result.name}</span>
<span className="flex items-center gap-1 text-xs text-muted-foreground"> <span className="flex items-center gap-1 text-muted-foreground text-xs">
{isQueued ? ( {isQueued ? (
<> <>
<button <button
@@ -177,7 +172,7 @@ function AddModeSuggestions({
> >
<Minus className="h-3 w-3" /> <Minus className="h-3 w-3" />
</button> </button>
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-foreground"> <span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground">
{queued.count} {queued.count}
</span> </span>
<button <button
@@ -221,12 +216,26 @@ function AddModeSuggestions({
); );
} }
const THEME_ICONS = {
system: Monitor,
light: Sun,
dark: Moon,
} as const;
const THEME_LABELS = {
system: "Theme: System",
light: "Theme: Light",
dark: "Theme: Dark",
} as const;
function buildOverflowItems(opts: { function buildOverflowItems(opts: {
onManagePlayers?: () => void; onManagePlayers?: () => void;
onOpenSourceManager?: () => void; onOpenSourceManager?: () => void;
bestiaryLoaded: boolean; bestiaryLoaded: boolean;
onBulkImport?: () => void; onBulkImport?: () => void;
bulkImportDisabled?: boolean; bulkImportDisabled?: boolean;
themePreference?: "system" | "light" | "dark";
onCycleTheme?: () => void;
}): OverflowMenuItem[] { }): OverflowMenuItem[] {
const items: OverflowMenuItem[] = []; const items: OverflowMenuItem[] = [];
if (opts.onManagePlayers) { if (opts.onManagePlayers) {
@@ -251,27 +260,62 @@ function buildOverflowItems(opts: {
disabled: opts.bulkImportDisabled, disabled: opts.bulkImportDisabled,
}); });
} }
if (opts.onCycleTheme) {
const pref = opts.themePreference ?? "system";
const ThemeIcon = THEME_ICONS[pref];
items.push({
icon: <ThemeIcon className="h-4 w-4" />,
label: THEME_LABELS[pref],
onClick: opts.onCycleTheme,
keepOpen: true,
});
}
return items; return items;
} }
export function ActionBar({ export function ActionBar({
onAddCombatant,
onAddFromBestiary,
bestiarySearch,
bestiaryLoaded,
onViewStatBlock,
onBulkImport,
bulkImportDisabled,
inputRef, inputRef,
playerCharacters,
onAddFromPlayerCharacter,
onManagePlayers,
onRollAllInitiative,
showRollAllInitiative,
rollAllInitiativeDisabled,
onOpenSourceManager,
autoFocus, autoFocus,
}: ActionBarProps) { onManagePlayers,
}: Readonly<ActionBarProps>) {
const {
addCombatant,
addFromBestiary,
addFromPlayerCharacter,
hasCreatureCombatants,
canRollAllInitiative,
} = useEncounterContext();
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
useBestiaryContext();
const { characters: playerCharacters } = usePlayerCharactersContext();
const { showBulkImport, showSourceManager, showCreature, panelView } =
useSidePanelContext();
const { preference: themePreference, cycleTheme } = useThemeContext();
const { handleRollAllInitiative } = useInitiativeRollsContext();
const { state: bulkImportState } = useBulkImportContext();
const handleAddFromBestiary = useCallback(
(result: SearchResult) => {
const creatureId = addFromBestiary(result);
if (creatureId && panelView.mode === "closed") {
showCreature(creatureId);
}
},
[addFromBestiary, panelView.mode, showCreature],
);
const handleViewStatBlock = useCallback(
(result: SearchResult) => {
const slug = result.name
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, "-")
.replaceAll(/(^-|-$)/g, "");
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
showCreature(cId);
},
[showCreature],
);
const [nameInput, setNameInput] = useState(""); const [nameInput, setNameInput] = useState("");
const [suggestions, setSuggestions] = useState<SearchResult[]>([]); const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]); const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
@@ -308,7 +352,7 @@ export function ActionBar({
const confirmQueued = () => { const confirmQueued = () => {
if (!queued) return; if (!queued) return;
for (let i = 0; i < queued.count; i++) { for (let i = 0; i < queued.count; i++) {
onAddFromBestiary(queued.result); handleAddFromBestiary(queued.result);
} }
clearInput(); clearInput();
}; };
@@ -319,7 +363,7 @@ export function ActionBar({
return Number.isNaN(n) ? undefined : n; return Number.isNaN(n) ? undefined : n;
}; };
const handleAdd = (e: FormEvent) => { const handleAdd = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
if (browseMode) return; if (browseMode) return;
if (queued) { if (queued) {
@@ -334,7 +378,7 @@ export function ActionBar({
if (init !== undefined) opts.initiative = init; if (init !== undefined) opts.initiative = init;
if (ac !== undefined) opts.ac = ac; if (ac !== undefined) opts.ac = ac;
if (maxHp !== undefined) opts.maxHp = maxHp; if (maxHp !== undefined) opts.maxHp = maxHp;
onAddCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined); addCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
setNameInput(""); setNameInput("");
setSuggestions([]); setSuggestions([]);
setPcMatches([]); setPcMatches([]);
@@ -436,34 +480,64 @@ export function ActionBar({
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1)); setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
} else if (e.key === "Enter" && suggestionIndex >= 0) { } else if (e.key === "Enter" && suggestionIndex >= 0) {
e.preventDefault(); e.preventDefault();
onViewStatBlock?.(suggestions[suggestionIndex]); handleViewStatBlock(suggestions[suggestionIndex]);
setBrowseMode(false); setBrowseMode(false);
clearInput(); clearInput();
} }
}; };
const handleBrowseSelect = (result: SearchResult) => { const handleBrowseSelect = (result: SearchResult) => {
onViewStatBlock?.(result); handleViewStatBlock(result);
setBrowseMode(false); setBrowseMode(false);
clearInput(); clearInput();
}; };
const toggleBrowseMode = () => { const toggleBrowseMode = () => {
setBrowseMode((m) => !m); setBrowseMode((prev) => {
clearInput(); const next = !prev;
setSuggestionIndex(-1);
setQueued(null);
if (next) {
handleBrowseSearch(nameInput);
} else {
handleAddSearch(nameInput);
}
return next;
});
clearCustomFields(); clearCustomFields();
}; };
const [rollAllMenuPos, setRollAllMenuPos] = useState<{
x: number;
y: number;
} | null>(null);
const openRollAllMenu = useCallback((x: number, y: number) => {
setRollAllMenuPos({ x, y });
}, []);
const rollAllLongPress = useLongPress(
useCallback(
(e: React.TouchEvent) => {
const touch = e.touches[0];
if (touch) openRollAllMenu(touch.clientX, touch.clientY);
},
[openRollAllMenu],
),
);
const overflowItems = buildOverflowItems({ const overflowItems = buildOverflowItems({
onManagePlayers, onManagePlayers,
onOpenSourceManager, onOpenSourceManager: showSourceManager,
bestiaryLoaded, bestiaryLoaded,
onBulkImport, onBulkImport: showBulkImport,
bulkImportDisabled, bulkImportDisabled: bulkImportState.status === "loading",
themePreference,
onCycleTheme: cycleTheme,
}); });
return ( return (
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3"> <div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
<form <form
onSubmit={handleAdd} onSubmit={handleAdd}
className="relative flex flex-1 items-center gap-2" className="relative flex flex-1 items-center gap-2"
@@ -482,14 +556,15 @@ export function ActionBar({
className="pr-8" className="pr-8"
autoFocus={autoFocus} autoFocus={autoFocus}
/> />
{bestiaryLoaded && onViewStatBlock && ( {!!bestiaryLoaded && (
<button <button
type="button" type="button"
tabIndex={-1} tabIndex={-1}
className={cn( className={cn(
"absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral", "absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
browseMode && "text-accent", browseMode && "text-accent",
)} )}
onMouseDown={(e) => e.preventDefault()}
onClick={toggleBrowseMode} onClick={toggleBrowseMode}
title={browseMode ? "Switch to add mode" : "Browse stat blocks"} title={browseMode ? "Switch to add mode" : "Browse stat blocks"}
aria-label={ aria-label={
@@ -504,23 +579,24 @@ export function ActionBar({
</button> </button>
)} )}
{browseMode && deferredSuggestions.length > 0 && ( {browseMode && deferredSuggestions.length > 0 && (
<div className="absolute bottom-full z-50 mb-1 w-full rounded-md border border-border bg-card shadow-lg"> <div className="card-glow absolute bottom-full z-50 mb-1 w-full rounded-lg border border-border bg-card">
<ul className="max-h-48 overflow-y-auto py-1"> <ul className="max-h-48 overflow-y-auto py-1">
{deferredSuggestions.map((result, i) => ( {deferredSuggestions.map((result, i) => (
<li key={creatureKey(result)}> <li key={creatureKey(result)}>
<button <button
type="button" type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${ className={cn(
"flex w-full items-center justify-between px-3 py-1.5 text-left text-sm",
i === suggestionIndex i === suggestionIndex
? "bg-accent/20 text-foreground" ? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg" : "text-foreground hover:bg-hover-neutral-bg",
}`} )}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => handleBrowseSelect(result)} onClick={() => handleBrowseSelect(result)}
onMouseEnter={() => setSuggestionIndex(i)} onMouseEnter={() => setSuggestionIndex(i)}
> >
<span>{result.name}</span> <span>{result.name}</span>
<span className="text-xs text-muted-foreground"> <span className="text-muted-foreground text-xs">
{result.sourceDisplayName} {result.sourceDisplayName}
</span> </span>
</button> </button>
@@ -542,7 +618,7 @@ export function ActionBar({
onSetSuggestionIndex={setSuggestionIndex} onSetSuggestionIndex={setSuggestionIndex}
onSetQueued={setQueued} onSetQueued={setQueued}
onConfirmQueued={confirmQueued} onConfirmQueued={confirmQueued}
onAddFromPlayerCharacter={onAddFromPlayerCharacter} onAddFromPlayerCharacter={addFromPlayerCharacter}
/> />
)} )}
</div> </div>
@@ -578,19 +654,33 @@ export function ActionBar({
{!browseMode && nameInput.length >= 2 && !hasSuggestions && ( {!browseMode && nameInput.length >= 2 && !hasSuggestions && (
<Button type="submit">Add</Button> <Button type="submit">Add</Button>
)} )}
{showRollAllInitiative && onRollAllInitiative && ( {!!hasCreatureCombatants && (
<>
<Button <Button
type="button" type="button"
size="icon" size="icon"
variant="ghost" variant="ghost"
className="text-muted-foreground hover:text-hover-action" className="text-muted-foreground hover:text-hover-action"
onClick={onRollAllInitiative} onClick={() => handleRollAllInitiative()}
disabled={rollAllInitiativeDisabled} onContextMenu={(e) => {
e.preventDefault();
openRollAllMenu(e.clientX, e.clientY);
}}
{...rollAllLongPress}
disabled={!canRollAllInitiative}
title="Roll all initiative" title="Roll all initiative"
aria-label="Roll all initiative" aria-label="Roll all initiative"
> >
<D20Icon className="h-6 w-6" /> <D20Icon className="h-6 w-6" />
</Button> </Button>
{!!rollAllMenuPos && (
<RollModeMenu
position={rollAllMenuPos}
onSelect={(mode) => handleRollAllInitiative(mode)}
onClose={() => setRollAllMenuPos(null)}
/>
)}
</>
)} )}
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />} {overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
</form> </form>

View File

@@ -1,34 +1,41 @@
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useState } from "react"; import { useId, useState } from "react";
import { getAllSourceCodes } from "../adapters/bestiary-index-adapter.js"; import { getAllSourceCodes } from "../adapters/bestiary-index-adapter.js";
import type { BulkImportState } from "../hooks/use-bulk-import.js"; import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
import { useSidePanelContext } from "../contexts/side-panel-context.js";
import { Button } from "./ui/button.js"; import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js"; import { Input } from "./ui/input.js";
const DEFAULT_BASE_URL = const DEFAULT_BASE_URL =
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/"; "https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
interface BulkImportPromptProps { export function BulkImportPrompt() {
importState: BulkImportState; const { fetchAndCacheSource, isSourceCached, refreshCache } =
onStartImport: (baseUrl: string) => void; useBestiaryContext();
onDone: () => void; const { state: importState, startImport, reset } = useBulkImportContext();
} const { dismissPanel } = useSidePanelContext();
export function BulkImportPrompt({
importState,
onStartImport,
onDone,
}: BulkImportPromptProps) {
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL); const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
const baseUrlId = useId();
const totalSources = getAllSourceCodes().length; const totalSources = getAllSourceCodes().length;
const handleStart = (url: string) => {
startImport(url, fetchAndCacheSource, isSourceCached, refreshCache);
};
const handleDone = () => {
dismissPanel();
reset();
};
if (importState.status === "complete") { if (importState.status === "complete") {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-sm text-green-400"> <div className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-green-400 text-sm">
All sources loaded All sources loaded
</div> </div>
<Button onClick={onDone}>Done</Button> <Button onClick={handleDone}>Done</Button>
</div> </div>
); );
} }
@@ -40,7 +47,7 @@ export function BulkImportPrompt({
Loaded {importState.completed}/{importState.total} sources ( Loaded {importState.completed}/{importState.total} sources (
{importState.failed} failed) {importState.failed} failed)
</div> </div>
<Button onClick={onDone}>Done</Button> <Button onClick={handleDone}>Done</Button>
</div> </div>
); );
} }
@@ -54,7 +61,7 @@ export function BulkImportPrompt({
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
Loading sources... {processed}/{importState.total} Loading sources... {processed}/{importState.total}
</div> </div>
@@ -74,23 +81,20 @@ export function BulkImportPrompt({
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div> <div>
<h3 className="text-sm font-semibold text-foreground"> <h3 className="font-semibold text-foreground text-sm">
Import All Sources Import All Sources
</h3> </h3>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-muted-foreground text-xs">
Load stat block data for all {totalSources} sources at once. Load stat block data for all {totalSources} sources at once.
</p> </p>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label <label htmlFor={baseUrlId} className="text-muted-foreground text-xs">
htmlFor="bulk-base-url"
className="text-xs text-muted-foreground"
>
Base URL Base URL
</label> </label>
<Input <Input
id="bulk-base-url" id={baseUrlId}
type="url" type="url"
value={baseUrl} value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)} onChange={(e) => setBaseUrl(e.target.value)}
@@ -98,7 +102,7 @@ export function BulkImportPrompt({
/> />
</div> </div>
<Button onClick={() => onStartImport(baseUrl)} disabled={isDisabled}> <Button onClick={() => handleStart(baseUrl)} disabled={isDisabled}>
Load All Load All
</Button> </Button>
</div> </div>

View File

@@ -1,17 +1,12 @@
import type { BulkImportState } from "../hooks/use-bulk-import.js"; import { useBulkImportContext } from "../contexts/bulk-import-context.js";
import { useSidePanelContext } from "../contexts/side-panel-context.js";
import { Toast } from "./toast.js"; import { Toast } from "./toast.js";
interface BulkImportToastsProps { export function BulkImportToasts() {
state: BulkImportState; const { state, reset } = useBulkImportContext();
visible: boolean; const { bulkImportMode, isRightPanelCollapsed } = useSidePanelContext();
onReset: () => void; const visible = !bulkImportMode || isRightPanelCollapsed;
}
export function BulkImportToasts({
state,
visible,
onReset,
}: BulkImportToastsProps) {
if (!visible) return null; if (!visible) return null;
if (state.status === "loading") { if (state.status === "loading") {
@@ -30,7 +25,7 @@ export function BulkImportToasts({
return ( return (
<Toast <Toast
message="All sources loaded" message="All sources loaded"
onDismiss={onReset} onDismiss={reset}
autoDismissMs={3000} autoDismissMs={3000}
/> />
); );
@@ -40,7 +35,7 @@ export function BulkImportToasts({
return ( return (
<Toast <Toast
message={`Loaded ${state.completed}/${state.total} sources (${state.failed} failed)`} message={`Loaded ${state.completed}/${state.total} sources (${state.failed} failed)`}
onDismiss={onReset} onDismiss={reset}
/> />
); );
} }

View File

@@ -9,7 +9,7 @@ interface ColorPaletteProps {
const COLORS = [...VALID_PLAYER_COLORS] as string[]; const COLORS = [...VALID_PLAYER_COLORS] as string[];
export function ColorPalette({ value, onChange }: ColorPaletteProps) { export function ColorPalette({ value, onChange }: Readonly<ColorPaletteProps>) {
return ( return (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{COLORS.map((color) => ( {COLORS.map((color) => (
@@ -20,7 +20,7 @@ export function ColorPalette({ value, onChange }: ColorPaletteProps) {
className={cn( className={cn(
"h-8 w-8 rounded-full transition-all", "h-8 w-8 rounded-full transition-all",
value === color value === color
? "ring-2 ring-foreground ring-offset-2 ring-offset-background scale-110" ? "scale-110 ring-2 ring-foreground ring-offset-2 ring-offset-background"
: "hover:scale-110", : "hover:scale-110",
)} )}
style={{ style={{

View File

@@ -1,20 +1,27 @@
import { import {
type CombatantId, type CombatantId,
type ConditionId, type ConditionId,
type CreatureId,
deriveHpStatus, deriveHpStatus,
type PlayerIcon, type PlayerIcon,
type RollMode,
} from "@initiative/domain"; } from "@initiative/domain";
import { Brain, X } from "lucide-react"; import { Brain, Pencil, X } from "lucide-react";
import { type Ref, useCallback, useEffect, useRef, useState } from "react"; import { type Ref, useCallback, useEffect, useRef, useState } from "react";
import { cn } from "../lib/utils"; import { useEncounterContext } from "../contexts/encounter-context.js";
import { AcShield } from "./ac-shield"; import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
import { ConditionPicker } from "./condition-picker"; import { useSidePanelContext } from "../contexts/side-panel-context.js";
import { ConditionTags } from "./condition-tags"; import { useLongPress } from "../hooks/use-long-press.js";
import { D20Icon } from "./d20-icon"; import { cn } from "../lib/utils.js";
import { HpAdjustPopover } from "./hp-adjust-popover"; import { AcShield } from "./ac-shield.js";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map"; import { ConditionPicker } from "./condition-picker.js";
import { ConfirmButton } from "./ui/confirm-button"; import { ConditionTags } from "./condition-tags.js";
import { Input } from "./ui/input"; import { D20Icon } from "./d20-icon.js";
import { HpAdjustPopover } from "./hp-adjust-popover.js";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
import { RollModeMenu } from "./roll-mode-menu.js";
import { ConfirmButton } from "./ui/confirm-button.js";
import { Input } from "./ui/input.js";
interface Combatant { interface Combatant {
readonly id: CombatantId; readonly id: CombatantId;
@@ -22,47 +29,36 @@ interface Combatant {
readonly initiative?: number; readonly initiative?: number;
readonly maxHp?: number; readonly maxHp?: number;
readonly currentHp?: number; readonly currentHp?: number;
readonly tempHp?: number;
readonly ac?: number; readonly ac?: number;
readonly conditions?: readonly ConditionId[]; readonly conditions?: readonly ConditionId[];
readonly isConcentrating?: boolean; readonly isConcentrating?: boolean;
readonly color?: string; readonly color?: string;
readonly icon?: string; readonly icon?: string;
readonly creatureId?: CreatureId;
} }
interface CombatantRowProps { interface CombatantRowProps {
combatant: Combatant; combatant: Combatant;
isActive: boolean; isActive: boolean;
onRename: (id: CombatantId, newName: string) => void;
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
onRemove: (id: CombatantId) => void;
onSetHp: (id: CombatantId, maxHp: number | undefined) => void;
onAdjustHp: (id: CombatantId, delta: number) => void;
onSetAc: (id: CombatantId, value: number | undefined) => void;
onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void;
onToggleConcentration: (id: CombatantId) => void;
onShowStatBlock?: () => void;
onRollInitiative?: (id: CombatantId) => void;
} }
function EditableName({ function EditableName({
name, name,
combatantId, combatantId,
onRename, onRename,
onShowStatBlock,
color, color,
}: { onToggleStatBlock,
}: Readonly<{
name: string; name: string;
combatantId: CombatantId; combatantId: CombatantId;
onRename: (id: CombatantId, newName: string) => void; onRename: (id: CombatantId, newName: string) => void;
onShowStatBlock?: () => void;
color?: string; color?: string;
}) { onToggleStatBlock?: () => void;
}>) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(name); const [draft, setDraft] = useState(name);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const longPressTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const longPressTriggeredRef = useRef(false);
const commit = useCallback(() => { const commit = useCallback(() => {
const trimmed = draft.trim(); const trimmed = draft.trim();
@@ -78,53 +74,13 @@ function EditableName({
requestAnimationFrame(() => inputRef.current?.select()); requestAnimationFrame(() => inputRef.current?.select());
}, [name]); }, [name]);
useEffect(() => {
return () => {
clearTimeout(clickTimerRef.current);
clearTimeout(longPressTimerRef.current);
};
}, []);
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (longPressTriggeredRef.current) {
longPressTriggeredRef.current = false;
return;
}
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
clickTimerRef.current = undefined;
startEditing();
} else {
clickTimerRef.current = setTimeout(() => {
clickTimerRef.current = undefined;
onShowStatBlock?.();
}, 250);
}
},
[startEditing, onShowStatBlock],
);
const handleTouchStart = useCallback(() => {
longPressTriggeredRef.current = false;
longPressTimerRef.current = setTimeout(() => {
longPressTriggeredRef.current = true;
startEditing();
}, 500);
}, [startEditing]);
const cancelLongPress = useCallback(() => {
clearTimeout(longPressTimerRef.current);
}, []);
if (editing) { if (editing) {
return ( return (
<Input <Input
ref={inputRef} ref={inputRef}
type="text" type="text"
value={draft} value={draft}
className="h-7 text-sm" className="h-7 max-w-48 text-sm"
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
onBlur={commit} onBlur={commit}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -139,16 +95,27 @@ function EditableName({
<> <>
<button <button
type="button" type="button"
onClick={handleClick} onClick={onToggleStatBlock}
onTouchStart={handleTouchStart} disabled={!onToggleStatBlock}
onTouchEnd={cancelLongPress} className={cn(
onTouchCancel={cancelLongPress} "truncate text-left text-sm transition-colors",
onTouchMove={cancelLongPress} onToggleStatBlock
className="truncate text-left text-sm text-foreground cursor-text hover:text-hover-neutral transition-colors" ? "cursor-pointer text-foreground hover:text-hover-neutral"
: "cursor-default text-foreground",
)}
style={color ? { color } : undefined} style={color ? { color } : undefined}
> >
{name} {name}
</button> </button>
<button
type="button"
onClick={startEditing}
title="Rename"
aria-label="Rename"
className="inline-flex shrink-0 items-center rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-colors transition-opacity hover:bg-hover-neutral-bg hover:text-hover-neutral focus:opacity-100 group-hover:opacity-100"
>
<Pencil size={14} />
</button>
</> </>
); );
} }
@@ -156,10 +123,10 @@ function EditableName({
function MaxHpDisplay({ function MaxHpDisplay({
maxHp, maxHp,
onCommit, onCommit,
}: { }: Readonly<{
maxHp: number | undefined; maxHp: number | undefined;
onCommit: (value: number | undefined) => void; onCommit: (value: number | undefined) => void;
}) { }>) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(maxHp?.toString() ?? ""); const [draft, setDraft] = useState(maxHp?.toString() ?? "");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -205,7 +172,12 @@ function MaxHpDisplay({
<button <button
type="button" type="button"
onClick={startEditing} onClick={startEditing}
className="inline-block h-7 min-w-[3ch] text-center text-sm leading-7 tabular-nums text-muted-foreground transition-colors hover:text-hover-neutral" className={cn(
"inline-block h-7 min-w-[3ch] text-center leading-7 transition-colors hover:text-hover-neutral",
maxHp === undefined
? "text-muted-foreground text-sm"
: "text-muted-foreground text-xs",
)}
> >
{maxHp ?? "Max"} {maxHp ?? "Max"}
</button> </button>
@@ -215,48 +187,47 @@ function MaxHpDisplay({
function ClickableHp({ function ClickableHp({
currentHp, currentHp,
maxHp, maxHp,
tempHp,
onAdjust, onAdjust,
dimmed, onSetTempHp,
}: { }: Readonly<{
currentHp: number | undefined; currentHp: number | undefined;
maxHp: number | undefined; maxHp: number | undefined;
tempHp: number | undefined;
onAdjust: (delta: number) => void; onAdjust: (delta: number) => void;
dimmed?: boolean; onSetTempHp: (value: number) => void;
}) { }>) {
const [popoverOpen, setPopoverOpen] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false);
const status = deriveHpStatus(currentHp, maxHp); const status = deriveHpStatus(currentHp, maxHp);
if (maxHp === undefined) { if (maxHp === undefined) {
return ( return null;
<span
className={cn(
"inline-block h-7 w-[4ch] text-center text-sm leading-7 tabular-nums text-muted-foreground",
dimmed && "opacity-50",
)}
>
--
</span>
);
} }
return ( return (
<div className="relative"> <div className="relative flex items-center">
<button <button
type="button" type="button"
onClick={() => setPopoverOpen(true)} onClick={() => setPopoverOpen(true)}
aria-label={`Current HP: ${currentHp}${tempHp ? ` (+${tempHp} temp)` : ""} (${status})`}
className={cn( className={cn(
"inline-block h-7 min-w-[3ch] text-center text-sm font-medium leading-7 tabular-nums transition-colors hover:text-hover-neutral", "inline-block h-7 min-w-[3ch] text-center font-medium text-sm leading-7 transition-colors hover:text-hover-neutral",
status === "bloodied" && "text-amber-400", status === "bloodied" && "text-amber-400",
status === "unconscious" && "text-red-400", status === "unconscious" && "text-red-400",
status === "healthy" && "text-foreground", status === "healthy" && "text-foreground",
dimmed && "opacity-50",
)} )}
> >
{currentHp} {currentHp}
</button> </button>
{popoverOpen && ( {!!tempHp && (
<span className="font-medium text-cyan-400 text-sm leading-7">
+{tempHp}
</span>
)}
{!!popoverOpen && (
<HpAdjustPopover <HpAdjustPopover
onAdjust={onAdjust} onAdjust={onAdjust}
onSetTempHp={onSetTempHp}
onClose={() => setPopoverOpen(false)} onClose={() => setPopoverOpen(false)}
/> />
)} )}
@@ -267,10 +238,10 @@ function ClickableHp({
function AcDisplay({ function AcDisplay({
ac, ac,
onCommit, onCommit,
}: { }: Readonly<{
ac: number | undefined; ac: number | undefined;
onCommit: (value: number | undefined) => void; onCommit: (value: number | undefined) => void;
}) { }>) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(ac?.toString() ?? ""); const [draft, setDraft] = useState(ac?.toString() ?? "");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -321,16 +292,34 @@ function InitiativeDisplay({
dimmed, dimmed,
onSetInitiative, onSetInitiative,
onRollInitiative, onRollInitiative,
}: { }: Readonly<{
initiative: number | undefined; initiative: number | undefined;
combatantId: CombatantId; combatantId: CombatantId;
dimmed: boolean; dimmed: boolean;
onSetInitiative: (id: CombatantId, value: number | undefined) => void; onSetInitiative: (id: CombatantId, value: number | undefined) => void;
onRollInitiative?: (id: CombatantId) => void; onRollInitiative?: (id: CombatantId, mode?: RollMode) => void;
}) { }>) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(initiative?.toString() ?? ""); const [draft, setDraft] = useState(initiative?.toString() ?? "");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [menuPos, setMenuPos] = useState<{
x: number;
y: number;
} | null>(null);
const openMenu = useCallback((x: number, y: number) => {
setMenuPos({ x, y });
}, []);
const longPress = useLongPress(
useCallback(
(e: React.TouchEvent) => {
const touch = e.touches[0];
if (touch) openMenu(touch.clientX, touch.clientY);
},
[openMenu],
),
);
const commit = useCallback(() => { const commit = useCallback(() => {
if (draft === "") { if (draft === "") {
@@ -359,7 +348,7 @@ function InitiativeDisplay({
value={draft} value={draft}
placeholder="--" placeholder="--"
className={cn( className={cn(
"h-7 w-[6ch] text-center text-sm tabular-nums", "h-7 w-full text-center text-sm tabular-nums",
dimmed && "opacity-50", dimmed && "opacity-50",
)} )}
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
@@ -372,12 +361,18 @@ function InitiativeDisplay({
); );
} }
// Empty + bestiary creature d20 roll button // Empty + bestiary creature -> d20 roll button
if (initiative === undefined && onRollInitiative) { if (initiative === undefined && onRollInitiative) {
return ( return (
<>
<button <button
type="button" type="button"
onClick={() => onRollInitiative(combatantId)} onClick={() => onRollInitiative(combatantId)}
onContextMenu={(e) => {
e.preventDefault();
openMenu(e.clientX, e.clientY);
}}
{...longPress}
className={cn( className={cn(
"flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral", "flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral",
dimmed && "opacity-50", dimmed && "opacity-50",
@@ -387,20 +382,28 @@ function InitiativeDisplay({
> >
<D20Icon className="h-7 w-7" /> <D20Icon className="h-7 w-7" />
</button> </button>
{!!menuPos && (
<RollModeMenu
position={menuPos}
onSelect={(mode) => onRollInitiative(combatantId, mode)}
onClose={() => setMenuPos(null)}
/>
)}
</>
); );
} }
// Has value bold number, click to edit // Has value -> bold number, click to edit
// Empty + manual "--" placeholder, click to edit // Empty + manual -> "--" placeholder, click to edit
return ( return (
<button <button
type="button" type="button"
onClick={startEditing} onClick={startEditing}
className={cn( className={cn(
"h-7 w-full text-center text-sm leading-7 tabular-nums transition-colors", "h-7 w-full text-center text-sm tabular-nums leading-7 transition-colors",
initiative !== undefined initiative === undefined
? "font-medium text-foreground hover:text-hover-neutral" ? "text-muted-foreground hover:text-hover-neutral"
: "text-muted-foreground hover:text-hover-neutral", : "font-medium text-foreground hover:text-hover-neutral",
dimmed && "opacity-50", dimmed && "opacity-50",
)} )}
> >
@@ -413,9 +416,13 @@ function rowBorderClass(
isActive: boolean, isActive: boolean,
isConcentrating: boolean | undefined, isConcentrating: boolean | undefined,
): string { ): string {
if (isActive) return "border-l-2 border-l-accent bg-accent/10"; if (isActive && isConcentrating)
if (isConcentrating) return "border-l-2 border-l-purple-400"; return "border border-l-2 border-active-row-border border-l-purple-400 bg-active-row-bg card-glow";
return "border-l-2 border-l-transparent"; if (isActive)
return "border border-l-2 border-active-row-border bg-active-row-bg card-glow";
if (isConcentrating)
return "border border-l-2 border-transparent border-l-purple-400";
return "border border-l-2 border-transparent";
} }
function concentrationIconClass( function concentrationIconClass(
@@ -427,56 +434,71 @@ function concentrationIconClass(
return dimmed ? "opacity-50 text-purple-400" : "opacity-100 text-purple-400"; return dimmed ? "opacity-50 text-purple-400" : "opacity-100 text-purple-400";
} }
function activateOnKeyDown(
handler: () => void,
): (e: { key: string; preventDefault: () => void }) => void {
return (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handler();
}
};
}
export function CombatantRow({ export function CombatantRow({
ref, ref,
combatant, combatant,
isActive, isActive,
onRename,
onSetInitiative,
onRemove,
onSetHp,
onAdjustHp,
onSetAc,
onToggleCondition,
onToggleConcentration,
onShowStatBlock,
onRollInitiative,
}: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) { }: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) {
const {
editCombatant,
setInitiative,
removeCombatant,
setHp,
adjustHp,
setTempHp,
setAc,
toggleCondition,
toggleConcentration,
} = useEncounterContext();
const { selectedCreatureId, showCreature, toggleCollapse } =
useSidePanelContext();
const { handleRollInitiative } = useInitiativeRollsContext();
// Derive what was previously conditional props
const isStatBlockOpen = combatant.creatureId === selectedCreatureId;
const { creatureId } = combatant;
const hasStatBlock = !!creatureId;
const onToggleStatBlock = hasStatBlock
? () => {
if (isStatBlockOpen) {
toggleCollapse();
} else {
showCreature(creatureId);
}
}
: undefined;
const onRollInitiative = combatant.creatureId
? handleRollInitiative
: undefined;
const { id, name, initiative, maxHp, currentHp } = combatant; const { id, name, initiative, maxHp, currentHp } = combatant;
const status = deriveHpStatus(currentHp, maxHp); const status = deriveHpStatus(currentHp, maxHp);
const dimmed = status === "unconscious"; const dimmed = status === "unconscious";
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const conditionAnchorRef = useRef<HTMLDivElement>(null);
const prevHpRef = useRef(currentHp); const prevHpRef = useRef(currentHp);
const prevTempHpRef = useRef(combatant.tempHp);
const [isPulsing, setIsPulsing] = useState(false); const [isPulsing, setIsPulsing] = useState(false);
const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined); const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => { useEffect(() => {
const prevHp = prevHpRef.current; const prevHp = prevHpRef.current;
const prevTempHp = prevTempHpRef.current;
prevHpRef.current = currentHp; prevHpRef.current = currentHp;
prevTempHpRef.current = combatant.tempHp;
if ( const realHpDropped =
prevHp !== undefined && prevHp !== undefined && currentHp !== undefined && currentHp < prevHp;
currentHp !== undefined && const tempHpDropped =
currentHp < prevHp && prevTempHp !== undefined && (combatant.tempHp ?? 0) < prevTempHp;
combatant.isConcentrating
) { if ((realHpDropped || tempHpDropped) && combatant.isConcentrating) {
setIsPulsing(true); setIsPulsing(true);
clearTimeout(pulseTimerRef.current); clearTimeout(pulseTimerRef.current);
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200); pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
} }
}, [currentHp, combatant.isConcentrating]); }, [currentHp, combatant.tempHp, combatant.isConcentrating]);
useEffect(() => { useEffect(() => {
if (!combatant.isConcentrating) { if (!combatant.isConcentrating) {
@@ -490,34 +512,23 @@ export function CombatantRow({
: undefined; : undefined;
return ( return (
/* biome-ignore lint/a11y/noStaticElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
<div <div
ref={ref} ref={ref}
role={onShowStatBlock ? "button" : undefined}
tabIndex={onShowStatBlock ? 0 : undefined}
className={cn( className={cn(
"group rounded-md pr-3 transition-colors", "group rounded-lg pr-3 transition-colors",
rowBorderClass(isActive, combatant.isConcentrating), rowBorderClass(isActive, combatant.isConcentrating),
isPulsing && "animate-concentration-pulse", isPulsing && "animate-concentration-pulse",
onShowStatBlock && "cursor-pointer",
)} )}
onClick={onShowStatBlock}
onKeyDown={
onShowStatBlock ? activateOnKeyDown(onShowStatBlock) : undefined
}
> >
<div className="grid grid-cols-[2rem_3rem_1fr_auto_auto_2rem] items-center gap-3 py-2"> <div className="grid grid-cols-[2rem_3.5rem_auto_1fr_auto_2rem] items-center gap-3 py-2">
{/* Concentration */} {/* Concentration */}
<button <button
type="button" type="button"
onClick={(e) => { onClick={() => toggleConcentration(id)}
e.stopPropagation();
onToggleConcentration(id);
}}
title="Concentrating" title="Concentrating"
aria-label="Toggle concentration" aria-label="Toggle concentration"
className={cn( className={cn(
"flex w-full items-center justify-center self-stretch -my-2 -ml-[2px] pl-[14px] transition-opacity hover:text-hover-neutral hover:opacity-100", "-my-2 -ml-[2px] flex w-full items-center justify-center self-stretch pl-[14px] transition-opacity hover:text-hover-neutral hover:opacity-100",
concentrationIconClass(combatant.isConcentrating, dimmed), concentrationIconClass(combatant.isConcentrating, dimmed),
)} )}
> >
@@ -525,39 +536,40 @@ export function CombatantRow({
</button> </button>
{/* Initiative */} {/* Initiative */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */} <div className="rounded-md bg-muted/30 px-1">
<div
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<InitiativeDisplay <InitiativeDisplay
initiative={initiative} initiative={initiative}
combatantId={id} combatantId={id}
dimmed={dimmed} dimmed={dimmed}
onSetInitiative={onSetInitiative} onSetInitiative={setInitiative}
onRollInitiative={onRollInitiative} onRollInitiative={onRollInitiative}
/> />
</div> </div>
{/* AC */}
<div className={cn(dimmed && "opacity-50")}>
<AcDisplay ac={combatant.ac} onCommit={(v) => setAc(id, v)} />
</div>
{/* Name + Conditions */} {/* Name + Conditions */}
<div <div
className={cn( className={cn(
"relative flex flex-wrap items-center gap-1 min-w-0", "relative flex min-w-0 flex-wrap items-center gap-1",
dimmed && "opacity-50", dimmed && "opacity-50",
)} )}
> >
{combatant.icon && {!!combatant.icon &&
combatant.color && !!combatant.color &&
(() => { (() => {
const PcIcon = PLAYER_ICON_MAP[combatant.icon as PlayerIcon]; const PcIcon = PLAYER_ICON_MAP[combatant.icon as PlayerIcon];
const pcColor = const iconColor =
PLAYER_COLOR_HEX[ PLAYER_COLOR_HEX[
combatant.color as keyof typeof PLAYER_COLOR_HEX combatant.color as keyof typeof PLAYER_COLOR_HEX
]; ];
return PcIcon ? ( return PcIcon ? (
<PcIcon <PcIcon
size={14} size={16}
style={{ color: pcColor }} style={{ color: iconColor }}
className="shrink-0" className="shrink-0"
/> />
) : null; ) : null;
@@ -565,68 +577,56 @@ export function CombatantRow({
<EditableName <EditableName
name={name} name={name}
combatantId={id} combatantId={id}
onRename={onRename} onRename={editCombatant}
onShowStatBlock={onShowStatBlock}
color={pcColor} color={pcColor}
onToggleStatBlock={onToggleStatBlock}
/> />
<div ref={conditionAnchorRef}>
<ConditionTags <ConditionTags
conditions={combatant.conditions} conditions={combatant.conditions}
onRemove={(conditionId) => onToggleCondition(id, conditionId)} onRemove={(conditionId) => toggleCondition(id, conditionId)}
onOpenPicker={() => setPickerOpen((prev) => !prev)} onOpenPicker={() => setPickerOpen((prev) => !prev)}
/> />
{pickerOpen && ( </div>
{!!pickerOpen && (
<ConditionPicker <ConditionPicker
anchorRef={conditionAnchorRef}
activeConditions={combatant.conditions} activeConditions={combatant.conditions}
onToggle={(conditionId) => onToggleCondition(id, conditionId)} onToggle={(conditionId) => toggleCondition(id, conditionId)}
onClose={() => setPickerOpen(false)} onClose={() => setPickerOpen(false)}
/> />
)} )}
</div> </div>
{/* AC */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
<div
className={cn(dimmed && "opacity-50")}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
</div>
{/* HP */} {/* HP */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
<div <div
className="flex items-center gap-1" className={cn(
onClick={(e) => e.stopPropagation()} "flex items-center rounded-md tabular-nums",
onKeyDown={(e) => e.stopPropagation()} maxHp === undefined
? ""
: "gap-0.5 border border-border/50 bg-muted/30 px-1.5",
dimmed && "opacity-50",
)}
> >
<ClickableHp <ClickableHp
currentHp={currentHp} currentHp={currentHp}
maxHp={maxHp} maxHp={maxHp}
onAdjust={(delta) => onAdjustHp(id, delta)} tempHp={combatant.tempHp}
dimmed={dimmed} onAdjust={(delta) => adjustHp(id, delta)}
onSetTempHp={(value) => setTempHp(id, value)}
/> />
{maxHp !== undefined && ( {maxHp !== undefined && (
<span <span className="text-muted-foreground/50 text-xs">/</span>
className={cn(
"text-sm tabular-nums text-muted-foreground",
dimmed && "opacity-50",
)} )}
> <MaxHpDisplay maxHp={maxHp} onCommit={(v) => setHp(id, v)} />
/
</span>
)}
<div className={cn(dimmed && "opacity-50")}>
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => onSetHp(id, v)} />
</div>
</div> </div>
{/* Actions */} {/* Actions */}
<ConfirmButton <ConfirmButton
icon={<X size={16} />} icon={<X size={16} />}
label="Remove combatant" label="Remove combatant"
onConfirm={() => onRemove(id)} onConfirm={() => removeCombatant(id)}
className="h-7 w-7 text-muted-foreground opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto focus:opacity-100 focus:pointer-events-auto pointer-coarse:opacity-100 pointer-coarse:pointer-events-auto transition-opacity" className="pointer-events-none pointer-coarse:pointer-events-auto h-7 w-7 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-opacity focus:pointer-events-auto focus:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"
/> />
</div> </div>
</div> </div>

View File

@@ -18,7 +18,9 @@ import {
ZapOff, ZapOff,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { Tooltip } from "./ui/tooltip.js";
const ICON_MAP: Record<string, LucideIcon> = { const ICON_MAP: Record<string, LucideIcon> = {
EyeOff, EyeOff,
@@ -52,34 +54,45 @@ const COLOR_CLASSES: Record<string, string> = {
}; };
interface ConditionPickerProps { interface ConditionPickerProps {
anchorRef: React.RefObject<HTMLElement | null>;
activeConditions: readonly ConditionId[] | undefined; activeConditions: readonly ConditionId[] | undefined;
onToggle: (conditionId: ConditionId) => void; onToggle: (conditionId: ConditionId) => void;
onClose: () => void; onClose: () => void;
} }
export function ConditionPicker({ export function ConditionPicker({
anchorRef,
activeConditions, activeConditions,
onToggle, onToggle,
onClose, onClose,
}: ConditionPickerProps) { }: Readonly<ConditionPickerProps>) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [flipped, setFlipped] = useState(false); const [pos, setPos] = useState<{
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined); top: number;
left: number;
maxHeight: number;
} | null>(null);
useLayoutEffect(() => { useLayoutEffect(() => {
const anchor = anchorRef.current;
const el = ref.current; const el = ref.current;
if (!el) return; if (!anchor || !el) return;
const rect = el.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.top; const anchorRect = anchor.getBoundingClientRect();
const spaceAbove = rect.bottom; const menuHeight = el.scrollHeight;
const shouldFlip = const pad = 8;
rect.bottom > window.innerHeight && spaceAbove > spaceBelow;
setFlipped(shouldFlip); const spaceBelow = window.innerHeight - anchorRect.bottom - pad;
const available = shouldFlip ? spaceAbove : spaceBelow; const spaceAbove = anchorRect.top - pad;
if (rect.height > available) { const openBelow = spaceBelow >= menuHeight || spaceBelow >= spaceAbove;
setMaxHeight(available - 16);
} const top = openBelow
}, []); ? anchorRect.bottom + 4
: Math.max(pad, anchorRect.top - Math.min(menuHeight, spaceAbove) - 4);
const maxHeight = openBelow ? spaceBelow : Math.min(menuHeight, spaceAbove);
setPos({ top, left: anchorRect.left, maxHeight });
}, [anchorRef]);
useEffect(() => { useEffect(() => {
function handleClickOutside(e: MouseEvent) { function handleClickOutside(e: MouseEvent) {
@@ -93,14 +106,15 @@ export function ConditionPicker({
const active = new Set(activeConditions ?? []); const active = new Set(activeConditions ?? []);
return ( return createPortal(
<div <div
ref={ref} ref={ref}
className={cn( className="card-glow fixed z-50 w-fit overflow-y-auto rounded-lg border border-border bg-background p-1"
"absolute left-0 z-10 w-fit overflow-y-auto rounded-md border border-border bg-background p-1 shadow-lg", style={
flipped ? "bottom-full mb-1" : "top-full mt-1", pos
)} ? { top: pos.top, left: pos.left, maxHeight: pos.maxHeight }
style={maxHeight ? { maxHeight } : undefined} : { visibility: "hidden" as const }
}
> >
{CONDITION_DEFINITIONS.map((def) => { {CONDITION_DEFINITIONS.map((def) => {
const Icon = ICON_MAP[def.iconName]; const Icon = ICON_MAP[def.iconName];
@@ -108,8 +122,8 @@ export function ConditionPicker({
const isActive = active.has(def.id); const isActive = active.has(def.id);
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground"; const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
return ( return (
<Tooltip key={def.id} content={def.description} className="block">
<button <button
key={def.id}
type="button" type="button"
className={cn( className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-hover-neutral-bg", "flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-hover-neutral-bg",
@@ -122,13 +136,17 @@ export function ConditionPicker({
className={isActive ? colorClass : "text-muted-foreground"} className={isActive ? colorClass : "text-muted-foreground"}
/> />
<span <span
className={isActive ? "text-foreground" : "text-muted-foreground"} className={
isActive ? "text-foreground" : "text-muted-foreground"
}
> >
{def.label} {def.label}
</span> </span>
</button> </button>
</Tooltip>
); );
})} })}
</div> </div>,
document.body,
); );
} }

View File

@@ -18,6 +18,8 @@ import {
Sparkles, Sparkles,
ZapOff, ZapOff,
} from "lucide-react"; } from "lucide-react";
import { cn } from "../lib/utils.js";
import { Tooltip } from "./ui/tooltip.js";
const ICON_MAP: Record<string, LucideIcon> = { const ICON_MAP: Record<string, LucideIcon> = {
EyeOff, EyeOff,
@@ -60,7 +62,7 @@ export function ConditionTags({
conditions, conditions,
onRemove, onRemove,
onOpenPicker, onOpenPicker,
}: ConditionTagsProps) { }: Readonly<ConditionTagsProps>) {
return ( return (
<div className="flex flex-wrap items-center gap-0.5"> <div className="flex flex-wrap items-center gap-0.5">
{conditions?.map((condId) => { {conditions?.map((condId) => {
@@ -70,12 +72,14 @@ export function ConditionTags({
if (!Icon) return null; if (!Icon) return null;
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground"; const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
return ( return (
<Tooltip key={condId} content={`${def.label}: ${def.description}`}>
<button <button
key={condId}
type="button" type="button"
title={def.label}
aria-label={`Remove ${def.label}`} aria-label={`Remove ${def.label}`}
className={`inline-flex items-center rounded p-0.5 hover:bg-hover-neutral-bg transition-colors ${colorClass}`} className={cn(
"inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
colorClass,
)}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onRemove(condId); onRemove(condId);
@@ -83,13 +87,14 @@ export function ConditionTags({
> >
<Icon size={14} /> <Icon size={14} />
</button> </button>
</Tooltip>
); );
})} })}
<button <button
type="button" type="button"
title="Add condition" title="Add condition"
aria-label="Add condition" aria-label="Add condition"
className="inline-flex items-center rounded p-0.5 text-muted-foreground hover:text-hover-neutral hover:bg-hover-neutral-bg transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 pointer-coarse:opacity-100 transition-opacity" className="inline-flex items-center rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-colors transition-opacity hover:bg-hover-neutral-bg hover:text-hover-neutral focus:opacity-100 group-hover:opacity-100"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onOpenPicker(); onOpenPicker();

View File

@@ -1,6 +1,6 @@
import type { PlayerCharacter } from "@initiative/domain"; import type { PlayerCharacter } from "@initiative/domain";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { type FormEvent, useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { ColorPalette } from "./color-palette"; import { ColorPalette } from "./color-palette";
import { IconGrid } from "./icon-grid"; import { IconGrid } from "./icon-grid";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
@@ -24,7 +24,8 @@ export function CreatePlayerModal({
onClose, onClose,
onSave, onSave,
playerCharacter, playerCharacter,
}: CreatePlayerModalProps) { }: Readonly<CreatePlayerModalProps>) {
const dialogRef = useRef<HTMLDialogElement>(null);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [ac, setAc] = useState("10"); const [ac, setAc] = useState("10");
const [maxHp, setMaxHp] = useState("10"); const [maxHp, setMaxHp] = useState("10");
@@ -54,17 +55,34 @@ export function CreatePlayerModal({
}, [open, playerCharacter]); }, [open, playerCharacter]);
useEffect(() => { useEffect(() => {
if (!open) return; const dialog = dialogRef.current;
function handleKeyDown(e: KeyboardEvent) { if (!dialog) return;
if (e.key === "Escape") onClose(); if (open && !dialog.open) {
dialog.showModal();
} else if (!open && dialog.open) {
dialog.close();
} }
document.addEventListener("keydown", handleKeyDown); }, [open]);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, onClose]);
if (!open) return null; useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
function handleCancel(e: Event) {
e.preventDefault();
onClose();
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === dialog) onClose();
}
dialog.addEventListener("cancel", handleCancel);
dialog.addEventListener("mousedown", handleBackdropClick);
return () => {
dialog.removeEventListener("cancel", handleCancel);
dialog.removeEventListener("mousedown", handleBackdropClick);
};
}, [onClose]);
const handleSubmit = (e: FormEvent) => { const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
const trimmed = name.trim(); const trimmed = name.trim();
if (trimmed === "") { if (trimmed === "") {
@@ -86,18 +104,12 @@ export function CreatePlayerModal({
}; };
return ( return (
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close <dialog
<div ref={dialogRef}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" className="card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
onMouseDown={onClose}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
<div
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
onMouseDown={(e) => e.stopPropagation()}
> >
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground"> <h2 className="font-semibold text-foreground text-lg">
{isEdit ? "Edit Player" : "Create Player"} {isEdit ? "Edit Player" : "Create Player"}
</h2> </h2>
<Button <Button
@@ -112,9 +124,7 @@ export function CreatePlayerModal({
<form onSubmit={handleSubmit} className="flex flex-col gap-4"> <form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div> <div>
<span className="mb-1 block text-sm text-muted-foreground"> <span className="mb-1 block text-muted-foreground text-sm">Name</span>
Name
</span>
<Input <Input
type="text" type="text"
value={name} value={name}
@@ -126,14 +136,12 @@ export function CreatePlayerModal({
aria-label="Name" aria-label="Name"
autoFocus autoFocus
/> />
{error && <p className="mt-1 text-sm text-destructive">{error}</p>} {!!error && <p className="mt-1 text-destructive text-sm">{error}</p>}
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<div className="flex-1"> <div className="flex-1">
<span className="mb-1 block text-sm text-muted-foreground"> <span className="mb-1 block text-muted-foreground text-sm">AC</span>
AC
</span>
<Input <Input
type="text" type="text"
inputMode="numeric" inputMode="numeric"
@@ -145,7 +153,7 @@ export function CreatePlayerModal({
/> />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<span className="mb-1 block text-sm text-muted-foreground"> <span className="mb-1 block text-muted-foreground text-sm">
Max HP Max HP
</span> </span>
<Input <Input
@@ -161,16 +169,14 @@ export function CreatePlayerModal({
</div> </div>
<div> <div>
<span className="mb-2 block text-sm text-muted-foreground"> <span className="mb-2 block text-muted-foreground text-sm">
Color Color
</span> </span>
<ColorPalette value={color} onChange={setColor} /> <ColorPalette value={color} onChange={setColor} />
</div> </div>
<div> <div>
<span className="mb-2 block text-sm text-muted-foreground"> <span className="mb-2 block text-muted-foreground text-sm">Icon</span>
Icon
</span>
<IconGrid value={icon} onChange={setIcon} /> <IconGrid value={icon} onChange={setIcon} />
</div> </div>
@@ -181,7 +187,6 @@ export function CreatePlayerModal({
<Button type="submit">{isEdit ? "Save" : "Create"}</Button> <Button type="submit">{isEdit ? "Save" : "Create"}</Button>
</div> </div>
</form> </form>
</div> </dialog>
</div>
); );
} }

View File

@@ -1,4 +1,4 @@
import { Heart, Sword } from "lucide-react"; import { Heart, ShieldPlus, Sword } from "lucide-react";
import { import {
useCallback, useCallback,
useEffect, useEffect,
@@ -8,12 +8,19 @@ import {
} from "react"; } from "react";
import { Input } from "./ui/input"; import { Input } from "./ui/input";
const DIGITS_ONLY_REGEX = /^\d+$/;
interface HpAdjustPopoverProps { interface HpAdjustPopoverProps {
readonly onAdjust: (delta: number) => void; readonly onAdjust: (delta: number) => void;
readonly onSetTempHp: (value: number) => void;
readonly onClose: () => void; readonly onClose: () => void;
} }
export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) { export function HpAdjustPopover({
onAdjust,
onSetTempHp,
onClose,
}: HpAdjustPopoverProps) {
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
const [pos, setPos] = useState<{ top: number; left: number } | null>(null); const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@@ -85,7 +92,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
return ( return (
<div <div
ref={ref} ref={ref}
className="fixed z-10 rounded-md border border-border bg-background p-2 shadow-lg" className="card-glow fixed z-10 rounded-lg border border-border bg-background p-2"
style={ style={
pos pos
? { top: pos.top, left: pos.left } ? { top: pos.top, left: pos.left }
@@ -102,7 +109,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
className="h-7 w-[7ch] text-center text-sm tabular-nums" className="h-7 w-[7ch] text-center text-sm tabular-nums"
onChange={(e) => { onChange={(e) => {
const v = e.target.value; const v = e.target.value;
if (v === "" || /^\d+$/.test(v)) { if (v === "" || DIGITS_ONLY_REGEX.test(v)) {
setInputValue(v); setInputValue(v);
} }
}} }}
@@ -111,7 +118,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
<button <button
type="button" type="button"
disabled={!isValid} disabled={!isValid}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-red-400 transition-colors hover:bg-red-950 hover:text-red-300 disabled:pointer-events-none disabled:opacity-50" className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-red-400 transition-colors hover:bg-hp-damage-hover-bg hover:text-red-300 disabled:pointer-events-none disabled:opacity-50"
onClick={() => applyDelta(-1)} onClick={() => applyDelta(-1)}
title="Apply damage" title="Apply damage"
aria-label="Apply damage" aria-label="Apply damage"
@@ -121,13 +128,28 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
<button <button
type="button" type="button"
disabled={!isValid} disabled={!isValid}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-emerald-400 transition-colors hover:bg-emerald-950 hover:text-emerald-300 disabled:pointer-events-none disabled:opacity-50" className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-emerald-400 transition-colors hover:bg-hp-heal-hover-bg hover:text-emerald-300 disabled:pointer-events-none disabled:opacity-50"
onClick={() => applyDelta(1)} onClick={() => applyDelta(1)}
title="Apply healing" title="Apply healing"
aria-label="Apply healing" aria-label="Apply healing"
> >
<Heart size={14} /> <Heart size={14} />
</button> </button>
<button
type="button"
disabled={!isValid}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-cyan-400 transition-colors hover:bg-cyan-400/10 hover:text-cyan-300 disabled:pointer-events-none disabled:opacity-50"
onClick={() => {
if (isValid && parsedValue) {
onSetTempHp(parsedValue);
onClose();
}
}}
title="Set temp HP"
aria-label="Set temp HP"
>
<ShieldPlus size={14} />
</button>
</div> </div>
</div> </div>
); );

View File

@@ -10,7 +10,7 @@ interface IconGridProps {
const ICONS = [...VALID_PLAYER_ICONS] as PlayerIcon[]; const ICONS = [...VALID_PLAYER_ICONS] as PlayerIcon[];
export function IconGrid({ value, onChange }: IconGridProps) { export function IconGrid({ value, onChange }: Readonly<IconGridProps>) {
return ( return (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{ICONS.map((iconId) => { {ICONS.map((iconId) => {
@@ -23,7 +23,7 @@ export function IconGrid({ value, onChange }: IconGridProps) {
className={cn( className={cn(
"flex h-9 w-9 items-center justify-center rounded-md transition-all", "flex h-9 w-9 items-center justify-center rounded-md transition-all",
value === iconId value === iconId
? "bg-primary/20 ring-2 ring-primary text-foreground" ? "bg-primary/20 text-foreground ring-2 ring-primary"
: "text-muted-foreground hover:bg-card hover:text-foreground", : "text-muted-foreground hover:bg-card hover:text-foreground",
)} )}
aria-label={iconId} aria-label={iconId}

View File

@@ -1,5 +1,6 @@
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain"; import type { PlayerCharacter } from "@initiative/domain";
import { forwardRef, useImperativeHandle, useState } from "react"; import { type RefObject, useImperativeHandle, useState } from "react";
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
import { CreatePlayerModal } from "./create-player-modal.js"; import { CreatePlayerModal } from "./create-player-modal.js";
import { PlayerManagement } from "./player-management.js"; import { PlayerManagement } from "./player-management.js";
@@ -7,35 +8,14 @@ export interface PlayerCharacterSectionHandle {
openManagement: () => void; openManagement: () => void;
} }
interface PlayerCharacterSectionProps { export const PlayerCharacterSection = function PlayerCharacterSectionInner({
characters: readonly PlayerCharacter[];
onCreateCharacter: (
name: string,
ac: number,
maxHp: number,
color: string | undefined,
icon: string | undefined,
) => void;
onEditCharacter: (
id: PlayerCharacterId,
fields: {
name?: string;
ac?: number;
maxHp?: number;
color?: string | null;
icon?: string | null;
},
) => void;
onDeleteCharacter: (id: PlayerCharacterId) => void;
}
export const PlayerCharacterSection = forwardRef<
PlayerCharacterSectionHandle,
PlayerCharacterSectionProps
>(function PlayerCharacterSection(
{ characters, onCreateCharacter, onEditCharacter, onDeleteCharacter },
ref, ref,
) { }: {
ref?: RefObject<PlayerCharacterSectionHandle | null>;
}) {
const { characters, createCharacter, editCharacter, deleteCharacter } =
usePlayerCharactersContext();
const [managementOpen, setManagementOpen] = useState(false); const [managementOpen, setManagementOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [editingPlayer, setEditingPlayer] = useState< const [editingPlayer, setEditingPlayer] = useState<
@@ -57,7 +37,7 @@ export const PlayerCharacterSection = forwardRef<
}} }}
onSave={(name, ac, maxHp, color, icon) => { onSave={(name, ac, maxHp, color, icon) => {
if (editingPlayer) { if (editingPlayer) {
onEditCharacter(editingPlayer.id, { editCharacter(editingPlayer.id, {
name, name,
ac, ac,
maxHp, maxHp,
@@ -65,7 +45,7 @@ export const PlayerCharacterSection = forwardRef<
icon: icon ?? null, icon: icon ?? null,
}); });
} else { } else {
onCreateCharacter(name, ac, maxHp, color, icon); createCharacter(name, ac, maxHp, color, icon);
} }
}} }}
playerCharacter={editingPlayer} playerCharacter={editingPlayer}
@@ -79,7 +59,7 @@ export const PlayerCharacterSection = forwardRef<
setCreateOpen(true); setCreateOpen(true);
setManagementOpen(false); setManagementOpen(false);
}} }}
onDelete={(id) => onDeleteCharacter(id)} onDelete={(id) => deleteCharacter(id)}
onCreate={() => { onCreate={() => {
setEditingPlayer(undefined); setEditingPlayer(undefined);
setCreateOpen(true); setCreateOpen(true);
@@ -88,4 +68,4 @@ export const PlayerCharacterSection = forwardRef<
/> />
</> </>
); );
}); };

View File

@@ -1,6 +1,6 @@
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain"; import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
import { Pencil, Plus, Trash2, X } from "lucide-react"; import { Pencil, Plus, Trash2, X } from "lucide-react";
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map"; import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { ConfirmButton } from "./ui/confirm-button"; import { ConfirmButton } from "./ui/confirm-button";
@@ -21,31 +21,44 @@ export function PlayerManagement({
onEdit, onEdit,
onDelete, onDelete,
onCreate, onCreate,
}: PlayerManagementProps) { }: Readonly<PlayerManagementProps>) {
useEffect(() => { const dialogRef = useRef<HTMLDialogElement>(null);
if (!open) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, onClose]);
if (!open) return null; useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) {
dialog.showModal();
} else if (!open && dialog.open) {
dialog.close();
}
}, [open]);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
function handleCancel(e: Event) {
e.preventDefault();
onClose();
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === dialog) onClose();
}
dialog.addEventListener("cancel", handleCancel);
dialog.addEventListener("mousedown", handleBackdropClick);
return () => {
dialog.removeEventListener("cancel", handleCancel);
dialog.removeEventListener("mousedown", handleBackdropClick);
};
}, [onClose]);
return ( return (
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close <dialog
<div ref={dialogRef}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" className="card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
onMouseDown={onClose}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
<div
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
onMouseDown={(e) => e.stopPropagation()}
> >
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground"> <h2 className="font-semibold text-foreground text-lg">
Player Characters Player Characters
</h2> </h2>
<Button <Button
@@ -76,16 +89,16 @@ export function PlayerManagement({
key={pc.id} key={pc.id}
className="group flex items-center gap-3 rounded-md px-3 py-2 hover:bg-hover-neutral-bg" className="group flex items-center gap-3 rounded-md px-3 py-2 hover:bg-hover-neutral-bg"
> >
{Icon && ( {!!Icon && (
<Icon size={18} style={{ color }} className="shrink-0" /> <Icon size={18} style={{ color }} className="shrink-0" />
)} )}
<span className="flex-1 truncate text-sm text-foreground"> <span className="flex-1 truncate text-foreground text-sm">
{pc.name} {pc.name}
</span> </span>
<span className="text-xs tabular-nums text-muted-foreground"> <span className="text-muted-foreground text-xs tabular-nums">
AC {pc.ac} AC {pc.ac}
</span> </span>
<span className="text-xs tabular-nums text-muted-foreground"> <span className="text-muted-foreground text-xs tabular-nums">
HP {pc.maxHp} HP {pc.maxHp}
</span> </span>
<Button <Button
@@ -115,7 +128,6 @@ export function PlayerManagement({
</div> </div>
</div> </div>
)} )}
</div> </dialog>
</div>
); );
} }

View File

@@ -0,0 +1,88 @@
import type { RollMode } from "@initiative/domain";
import { ChevronsDown, ChevronsUp } from "lucide-react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
interface RollModeMenuProps {
readonly position: { x: number; y: number };
readonly onSelect: (mode: RollMode) => void;
readonly onClose: () => void;
}
export function RollModeMenu({
position,
onSelect,
onClose,
}: RollModeMenuProps) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const vw = document.documentElement.clientWidth;
const vh = document.documentElement.clientHeight;
let left = position.x;
let top = position.y;
if (left + rect.width > vw) left = vw - rect.width - 8;
if (left < 8) left = 8;
if (top + rect.height > vh) top = position.y - rect.height;
if (top < 8) top = 8;
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]);
return (
<div
ref={ref}
className="card-glow fixed z-50 min-w-40 rounded-lg border border-border bg-card py-1"
style={
pos
? { top: pos.top, left: pos.left }
: { visibility: "hidden" as const }
}
>
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-emerald-400 text-sm hover:bg-hover-neutral-bg"
onClick={() => {
onSelect("advantage");
onClose();
}}
>
<ChevronsUp className="h-4 w-4" />
Advantage
</button>
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-red-400 text-sm hover:bg-hover-neutral-bg"
onClick={() => {
onSelect("disadvantage");
onClose();
}}
>
<ChevronsDown className="h-4 w-4" />
Disadvantage
</button>
</div>
);
}

View File

@@ -1,28 +1,29 @@
import { Download, Loader2, Upload } from "lucide-react"; import { Download, Loader2, Upload } from "lucide-react";
import { useRef, useState } from "react"; import { useId, useRef, useState } from "react";
import { getDefaultFetchUrl } from "../adapters/bestiary-index-adapter.js"; import {
getDefaultFetchUrl,
getSourceDisplayName,
} from "../adapters/bestiary-index-adapter.js";
import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { Button } from "./ui/button.js"; import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js"; import { Input } from "./ui/input.js";
interface SourceFetchPromptProps { interface SourceFetchPromptProps {
sourceCode: string; sourceCode: string;
sourceDisplayName: string;
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
onSourceLoaded: () => void; onSourceLoaded: () => void;
onUploadSource: (sourceCode: string, jsonData: unknown) => Promise<void>;
} }
export function SourceFetchPrompt({ export function SourceFetchPrompt({
sourceCode, sourceCode,
sourceDisplayName,
fetchAndCacheSource,
onSourceLoaded, onSourceLoaded,
onUploadSource, }: Readonly<SourceFetchPromptProps>) {
}: SourceFetchPromptProps) { const { fetchAndCacheSource, uploadAndCacheSource } = useBestiaryContext();
const sourceDisplayName = getSourceDisplayName(sourceCode);
const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode)); const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode));
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle"); const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>("");
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const sourceUrlId = useId();
const handleFetch = async () => { const handleFetch = async () => {
setStatus("fetching"); setStatus("fetching");
@@ -46,7 +47,7 @@ export function SourceFetchPrompt({
try { try {
const text = await file.text(); const text = await file.text();
const json = JSON.parse(text); const json = JSON.parse(text);
await onUploadSource(sourceCode, json); await uploadAndCacheSource(sourceCode, json);
onSourceLoaded(); onSourceLoaded();
} catch (err) { } catch (err) {
setStatus("error"); setStatus("error");
@@ -64,21 +65,21 @@ export function SourceFetchPrompt({
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div> <div>
<h3 className="text-sm font-semibold text-foreground"> <h3 className="font-semibold text-foreground text-sm">
Load {sourceDisplayName} Load {sourceDisplayName}
</h3> </h3>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-muted-foreground text-xs">
Stat block data for this source needs to be loaded. Enter a URL or Stat block data for this source needs to be loaded. Enter a URL or
upload a JSON file. upload a JSON file.
</p> </p>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="source-url" className="text-xs text-muted-foreground"> <label htmlFor={sourceUrlId} className="text-muted-foreground text-xs">
Source URL Source URL
</label> </label>
<Input <Input
id="source-url" id={sourceUrlId}
type="url" type="url"
value={url} value={url}
onChange={(e) => setUrl(e.target.value)} onChange={(e) => setUrl(e.target.value)}
@@ -97,7 +98,7 @@ export function SourceFetchPrompt({
{status === "fetching" ? "Loading..." : "Load"} {status === "fetching" ? "Loading..." : "Load"}
</Button> </Button>
<span className="text-xs text-muted-foreground">or</span> <span className="text-muted-foreground text-xs">or</span>
<Button <Button
variant="outline" variant="outline"
@@ -117,7 +118,7 @@ export function SourceFetchPrompt({
</div> </div>
{status === "error" && ( {status === "error" && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive"> <div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-destructive text-xs">
{error} {error}
</div> </div>
)} )}

View File

@@ -1,15 +1,21 @@
import { Database, Trash2 } from "lucide-react"; import { Database, Search, Trash2 } from "lucide-react";
import { useCallback, useEffect, useOptimistic, useState } from "react"; import {
useCallback,
useEffect,
useMemo,
useOptimistic,
useState,
} from "react";
import type { CachedSourceInfo } from "../adapters/bestiary-cache.js"; import type { CachedSourceInfo } from "../adapters/bestiary-cache.js";
import * as bestiaryCache from "../adapters/bestiary-cache.js"; import * as bestiaryCache from "../adapters/bestiary-cache.js";
import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { Button } from "./ui/button.js"; import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
interface SourceManagerProps { export function SourceManager() {
onCacheCleared: () => void; const { refreshCache } = useBestiaryContext();
}
export function SourceManager({ onCacheCleared }: SourceManagerProps) {
const [sources, setSources] = useState<CachedSourceInfo[]>([]); const [sources, setSources] = useState<CachedSourceInfo[]>([]);
const [filter, setFilter] = useState("");
const [optimisticSources, applyOptimistic] = useOptimistic( const [optimisticSources, applyOptimistic] = useOptimistic(
sources, sources,
( (
@@ -27,28 +33,37 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
}, []); }, []);
useEffect(() => { useEffect(() => {
loadSources(); void loadSources();
}, [loadSources]); }, [loadSources]);
const handleClearSource = async (sourceCode: string) => { const handleClearSource = async (sourceCode: string) => {
applyOptimistic({ type: "remove", sourceCode }); applyOptimistic({ type: "remove", sourceCode });
await bestiaryCache.clearSource(sourceCode); await bestiaryCache.clearSource(sourceCode);
await loadSources(); await loadSources();
onCacheCleared(); void refreshCache();
}; };
const handleClearAll = async () => { const handleClearAll = async () => {
applyOptimistic({ type: "clear" }); applyOptimistic({ type: "clear" });
await bestiaryCache.clearAll(); await bestiaryCache.clearAll();
await loadSources(); await loadSources();
onCacheCleared(); void refreshCache();
}; };
const filteredSources = useMemo(() => {
const term = filter.toLowerCase();
return term
? optimisticSources.filter((s) =>
s.displayName.toLowerCase().includes(term),
)
: optimisticSources;
}, [optimisticSources, filter]);
if (optimisticSources.length === 0) { if (optimisticSources.length === 0) {
return ( return (
<div className="flex flex-col items-center gap-2 py-8 text-center"> <div className="flex flex-col items-center gap-2 py-8 text-center">
<Database className="h-8 w-8 text-muted-foreground" /> <Database className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No cached sources</p> <p className="text-muted-foreground text-sm">No cached sources</p>
</div> </div>
); );
} }
@@ -56,29 +71,38 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-semibold text-foreground"> <span className="font-semibold text-foreground text-sm">
Cached Sources Cached Sources
</span> </span>
<Button <Button
variant="outline" variant="outline"
className="hover:text-hover-destructive hover:border-hover-destructive" className="hover:border-hover-destructive hover:text-hover-destructive"
onClick={handleClearAll} onClick={handleClearAll}
> >
<Trash2 className="mr-1 h-3 w-3" /> <Trash2 className="mr-1 h-3 w-3" />
Clear All Clear All
</Button> </Button>
</div> </div>
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-3 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Filter sources…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="pl-8"
/>
</div>
<ul className="flex flex-col gap-1"> <ul className="flex flex-col gap-1">
{optimisticSources.map((source) => ( {filteredSources.map((source) => (
<li <li
key={source.sourceCode} key={source.sourceCode}
className="flex items-center justify-between rounded-md border border-border px-3 py-2" className="flex items-center justify-between rounded-md border border-border px-3 py-2"
> >
<div> <div>
<span className="text-sm text-foreground"> <span className="text-foreground text-sm">
{source.displayName} {source.displayName}
</span> </span>
<span className="ml-2 text-xs text-muted-foreground"> <span className="ml-2 text-muted-foreground text-xs">
{source.creatureCount} creatures {source.creatureCount} creatures
</span> </span>
</div> </div>
@@ -86,6 +110,7 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
type="button" type="button"
onClick={() => handleClearSource(source.sourceCode)} onClick={() => handleClearSource(source.sourceCode)}
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-hover-destructive-bg hover:text-hover-destructive" className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-hover-destructive-bg hover:text-hover-destructive"
aria-label={`Remove ${source.displayName}`}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</button> </button>

View File

@@ -1,10 +1,11 @@
import type { Creature, CreatureId } from "@initiative/domain"; import type { CreatureId } from "@initiative/domain";
import { PanelRightClose, Pin, PinOff } from "lucide-react"; import { PanelRightClose, Pin, PinOff } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js"; import { useBestiaryContext } from "../contexts/bestiary-context.js";
import type { BulkImportState } from "../hooks/use-bulk-import.js"; import { useSidePanelContext } from "../contexts/side-panel-context.js";
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js"; import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
import { cn } from "../lib/utils.js";
import { BulkImportPrompt } from "./bulk-import-prompt.js"; import { BulkImportPrompt } from "./bulk-import-prompt.js";
import { SourceFetchPrompt } from "./source-fetch-prompt.js"; import { SourceFetchPrompt } from "./source-fetch-prompt.js";
import { SourceManager } from "./source-manager.js"; import { SourceManager } from "./source-manager.js";
@@ -12,28 +13,8 @@ import { StatBlock } from "./stat-block.js";
import { Button } from "./ui/button.js"; import { Button } from "./ui/button.js";
interface StatBlockPanelProps { interface StatBlockPanelProps {
creatureId: CreatureId | null;
creature: Creature | null;
isSourceCached: (sourceCode: string) => Promise<boolean>;
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
uploadAndCacheSource: (
sourceCode: string,
jsonData: unknown,
) => Promise<void>;
refreshCache: () => Promise<void>;
panelRole: "browse" | "pinned"; panelRole: "browse" | "pinned";
isCollapsed: boolean;
onToggleCollapse: () => void;
onPin: () => void;
onUnpin: () => void;
showPinButton: boolean;
side: "left" | "right"; side: "left" | "right";
onDismiss: () => void;
bulkImportMode?: boolean;
bulkImportState?: BulkImportState;
onStartBulkImport?: (baseUrl: string) => void;
onBulkImportDone?: () => void;
sourceManagerMode?: boolean;
} }
function extractSourceCode(cId: CreatureId): string { function extractSourceCode(cId: CreatureId): string {
@@ -46,21 +27,22 @@ function CollapsedTab({
creatureName, creatureName,
side, side,
onToggleCollapse, onToggleCollapse,
}: { }: Readonly<{
creatureName: string; creatureName: string;
side: "left" | "right"; side: "left" | "right";
onToggleCollapse: () => void; onToggleCollapse: () => void;
}) { }>) {
return ( return (
<button <button
type="button" type="button"
onClick={onToggleCollapse} onClick={onToggleCollapse}
className={`flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral ${ className={cn(
side === "right" ? "self-start" : "self-end" "flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral",
}`} side === "right" ? "self-start" : "self-end",
)}
aria-label="Expand stat block panel" aria-label="Expand stat block panel"
> >
<span className="writing-vertical-rl text-sm font-medium"> <span className="writing-vertical-rl font-medium text-sm">
{creatureName} {creatureName}
</span> </span>
</button> </button>
@@ -73,15 +55,15 @@ function PanelHeader({
onToggleCollapse, onToggleCollapse,
onPin, onPin,
onUnpin, onUnpin,
}: { }: Readonly<{
panelRole: "browse" | "pinned"; panelRole: "browse" | "pinned";
showPinButton: boolean; showPinButton: boolean;
onToggleCollapse: () => void; onToggleCollapse: () => void;
onPin: () => void; onPin: () => void;
onUnpin: () => void; onUnpin: () => void;
}) { }>) {
return ( return (
<div className="flex items-center justify-between border-b border-border px-4 py-2"> <div className="flex items-center justify-between border-border border-b px-4 py-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{panelRole === "browse" && ( {panelRole === "browse" && (
<Button <Button
@@ -133,7 +115,7 @@ function DesktopPanel({
onPin, onPin,
onUnpin, onUnpin,
children, children,
}: { }: Readonly<{
isCollapsed: boolean; isCollapsed: boolean;
side: "left" | "right"; side: "left" | "right";
creatureName: string; creatureName: string;
@@ -143,7 +125,7 @@ function DesktopPanel({
onPin: () => void; onPin: () => void;
onUnpin: () => void; onUnpin: () => void;
children: ReactNode; children: ReactNode;
}) { }>) {
const sideClasses = side === "left" ? "left-0 border-r" : "right-0 border-l"; const sideClasses = side === "left" ? "left-0 border-r" : "right-0 border-l";
const collapsedTranslate = const collapsedTranslate =
side === "right" side === "right"
@@ -152,7 +134,11 @@ function DesktopPanel({
return ( return (
<div <div
className={`fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel ${sideClasses} ${isCollapsed ? collapsedTranslate : "translate-x-0"}`} className={cn(
"panel-glow fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel",
sideClasses,
isCollapsed ? collapsedTranslate : "translate-x-0",
)}
> >
{isCollapsed ? ( {isCollapsed ? (
<CollapsedTab <CollapsedTab
@@ -179,28 +165,31 @@ function DesktopPanel({
function MobileDrawer({ function MobileDrawer({
onDismiss, onDismiss,
children, children,
}: { }: Readonly<{
onDismiss: () => void; onDismiss: () => void;
children: ReactNode; children: ReactNode;
}) { }>) {
const { offsetX, isSwiping, handlers } = useSwipeToDismiss(onDismiss); const { offsetX, isSwiping, handlers } = useSwipeToDismiss(onDismiss);
return ( return (
<div className="fixed inset-0 z-50"> <div className="fixed inset-0 z-50">
<button <button
type="button" type="button"
className="absolute inset-0 bg-black/50 animate-in fade-in" className="fade-in absolute inset-0 animate-in bg-black/50"
onClick={onDismiss} onClick={onDismiss}
aria-label="Close stat block" aria-label="Close stat block"
/> />
<div <div
className={`absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-l border-border bg-card shadow-xl ${isSwiping ? "" : "animate-slide-in-right"}`} className={cn(
"panel-glow absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-border border-l bg-card",
!isSwiping && "animate-slide-in-right",
)}
style={ style={
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
} }
{...handlers} {...handlers}
> >
<div className="flex items-center justify-between border-b border-border px-4 py-2"> <div className="flex items-center justify-between border-border border-b px-4 py-2">
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
@@ -219,35 +208,57 @@ function MobileDrawer({
); );
} }
export function StatBlockPanel({ function usePanelRole(panelRole: "browse" | "pinned") {
const sidePanel = useSidePanelContext();
const { getCreature } = useBestiaryContext();
const creatureId =
panelRole === "browse"
? sidePanel.selectedCreatureId
: sidePanel.pinnedCreatureId;
const creature = creatureId ? (getCreature(creatureId) ?? null) : null;
const isBrowse = panelRole === "browse";
return {
creatureId, creatureId,
creature, creature,
isSourceCached, isCollapsed: isBrowse ? sidePanel.isRightPanelCollapsed : false,
fetchAndCacheSource, onToggleCollapse: isBrowse ? sidePanel.toggleCollapse : () => {},
uploadAndCacheSource, onDismiss: isBrowse ? sidePanel.dismissPanel : () => {},
refreshCache, onPin: isBrowse ? sidePanel.togglePin : () => {},
onUnpin: panelRole === "pinned" ? sidePanel.unpin : () => {},
showPinButton: isBrowse && sidePanel.isWideDesktop && !!creature,
bulkImportMode: isBrowse && sidePanel.bulkImportMode,
sourceManagerMode: isBrowse && sidePanel.sourceManagerMode,
};
}
export function StatBlockPanel({
panelRole, panelRole,
side,
}: Readonly<StatBlockPanelProps>) {
const { isSourceCached } = useBestiaryContext();
const {
creatureId,
creature,
isCollapsed, isCollapsed,
onToggleCollapse, onToggleCollapse,
onDismiss,
onPin, onPin,
onUnpin, onUnpin,
showPinButton, showPinButton,
side,
onDismiss,
bulkImportMode, bulkImportMode,
bulkImportState,
onStartBulkImport,
onBulkImportDone,
sourceManagerMode, sourceManagerMode,
}: StatBlockPanelProps) { } = usePanelRole(panelRole);
const [isDesktop, setIsDesktop] = useState( const [isDesktop, setIsDesktop] = useState(
() => window.matchMedia("(min-width: 1024px)").matches, () => globalThis.matchMedia("(min-width: 1024px)").matches,
); );
const [needsFetch, setNeedsFetch] = useState(false); const [needsFetch, setNeedsFetch] = useState(false);
const [checkingCache, setCheckingCache] = useState(false); const [checkingCache, setCheckingCache] = useState(false);
useEffect(() => { useEffect(() => {
const mq = window.matchMedia("(min-width: 1024px)"); const mq = globalThis.matchMedia("(min-width: 1024px)");
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches); const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
mq.addEventListener("change", handler); mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler); return () => mq.removeEventListener("change", handler);
@@ -266,7 +277,7 @@ export function StatBlockPanel({
} }
setCheckingCache(true); setCheckingCache(true);
isSourceCached(sourceCode).then((cached) => { void isSourceCached(sourceCode).then((cached) => {
setNeedsFetch(!cached); setNeedsFetch(!cached);
setCheckingCache(false); setCheckingCache(false);
}); });
@@ -276,34 +287,22 @@ export function StatBlockPanel({
const sourceCode = creatureId ? extractSourceCode(creatureId) : ""; const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
const handleSourceLoaded = async () => { const handleSourceLoaded = () => {
await refreshCache();
setNeedsFetch(false); setNeedsFetch(false);
}; };
const renderContent = () => { const renderContent = () => {
if (sourceManagerMode) { if (sourceManagerMode) {
return <SourceManager onCacheCleared={refreshCache} />; return <SourceManager />;
} }
if ( if (bulkImportMode) {
bulkImportMode && return <BulkImportPrompt />;
bulkImportState &&
onStartBulkImport &&
onBulkImportDone
) {
return (
<BulkImportPrompt
importState={bulkImportState}
onStartImport={onStartBulkImport}
onDone={onBulkImportDone}
/>
);
} }
if (checkingCache) { if (checkingCache) {
return ( return (
<div className="p-4 text-sm text-muted-foreground">Loading...</div> <div className="p-4 text-muted-foreground text-sm">Loading...</div>
); );
} }
@@ -315,28 +314,22 @@ export function StatBlockPanel({
return ( return (
<SourceFetchPrompt <SourceFetchPrompt
sourceCode={sourceCode} sourceCode={sourceCode}
sourceDisplayName={getSourceDisplayName(sourceCode)}
fetchAndCacheSource={fetchAndCacheSource}
onSourceLoaded={handleSourceLoaded} onSourceLoaded={handleSourceLoaded}
onUploadSource={uploadAndCacheSource}
/> />
); );
} }
return ( return (
<div className="p-4 text-sm text-muted-foreground"> <div className="p-4 text-muted-foreground text-sm">
No stat block available No stat block available
</div> </div>
); );
}; };
const creatureName = let fallbackName = "Creature";
creature?.name ?? if (sourceManagerMode) fallbackName = "Sources";
(sourceManagerMode else if (bulkImportMode) fallbackName = "Import All Sources";
? "Sources" const creatureName = creature?.name ?? fallbackName;
: bulkImportMode
? "Import All Sources"
: "Creature");
if (isDesktop) { if (isDesktop) {
return ( return (
@@ -355,7 +348,7 @@ export function StatBlockPanel({
); );
} }
if (panelRole === "pinned") return null; if (panelRole === "pinned" || isCollapsed) return null;
return <MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>; return <MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>;
} }

View File

@@ -16,10 +16,10 @@ function abilityMod(score: number): string {
function PropertyLine({ function PropertyLine({
label, label,
value, value,
}: { }: Readonly<{
label: string; label: string;
value: string | undefined; value: string | undefined;
}) { }>) {
if (!value) return null; if (!value) return null;
return ( return (
<div className="text-sm"> <div className="text-sm">
@@ -30,11 +30,11 @@ function PropertyLine({
function SectionDivider() { function SectionDivider() {
return ( return (
<div className="my-2 h-px bg-gradient-to-r from-amber-800/60 via-amber-700/40 to-transparent" /> <div className="my-2 h-px bg-gradient-to-r from-stat-divider-from via-stat-divider-via to-transparent" />
); );
} }
export function StatBlock({ creature }: StatBlockProps) { export function StatBlock({ creature }: Readonly<StatBlockProps>) {
const abilities = [ const abilities = [
{ label: "STR", score: creature.abilities.str }, { label: "STR", score: creature.abilities.str },
{ label: "DEX", score: creature.abilities.dex }, { label: "DEX", score: creature.abilities.dex },
@@ -54,11 +54,11 @@ export function StatBlock({ creature }: StatBlockProps) {
<div className="space-y-1 text-foreground"> <div className="space-y-1 text-foreground">
{/* Header */} {/* Header */}
<div> <div>
<h2 className="text-xl font-bold text-amber-400">{creature.name}</h2> <h2 className="font-bold text-stat-heading text-xl">{creature.name}</h2>
<p className="text-sm italic text-muted-foreground"> <p className="text-muted-foreground text-sm italic">
{creature.size} {creature.type}, {creature.alignment} {creature.size} {creature.type}, {creature.alignment}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-muted-foreground text-xs">
{creature.sourceDisplayName} {creature.sourceDisplayName}
</p> </p>
</div> </div>
@@ -69,7 +69,7 @@ export function StatBlock({ creature }: StatBlockProps) {
<div className="space-y-0.5 text-sm"> <div className="space-y-0.5 text-sm">
<div> <div>
<span className="font-semibold">Armor Class</span> {creature.ac} <span className="font-semibold">Armor Class</span> {creature.ac}
{creature.acSource && ( {!!creature.acSource && (
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{" "} {" "}
({creature.acSource}) ({creature.acSource})
@@ -194,7 +194,7 @@ export function StatBlock({ creature }: StatBlockProps) {
{creature.actions && creature.actions.length > 0 && ( {creature.actions && creature.actions.length > 0 && (
<> <>
<SectionDivider /> <SectionDivider />
<h3 className="text-base font-bold text-amber-400">Actions</h3> <h3 className="font-bold text-base text-stat-heading">Actions</h3>
<div className="space-y-2"> <div className="space-y-2">
{creature.actions.map((a) => ( {creature.actions.map((a) => (
<div key={a.name} className="text-sm"> <div key={a.name} className="text-sm">
@@ -209,7 +209,9 @@ export function StatBlock({ creature }: StatBlockProps) {
{creature.bonusActions && creature.bonusActions.length > 0 && ( {creature.bonusActions && creature.bonusActions.length > 0 && (
<> <>
<SectionDivider /> <SectionDivider />
<h3 className="text-base font-bold text-amber-400">Bonus Actions</h3> <h3 className="font-bold text-base text-stat-heading">
Bonus Actions
</h3>
<div className="space-y-2"> <div className="space-y-2">
{creature.bonusActions.map((a) => ( {creature.bonusActions.map((a) => (
<div key={a.name} className="text-sm"> <div key={a.name} className="text-sm">
@@ -224,7 +226,7 @@ export function StatBlock({ creature }: StatBlockProps) {
{creature.reactions && creature.reactions.length > 0 && ( {creature.reactions && creature.reactions.length > 0 && (
<> <>
<SectionDivider /> <SectionDivider />
<h3 className="text-base font-bold text-amber-400">Reactions</h3> <h3 className="font-bold text-base text-stat-heading">Reactions</h3>
<div className="space-y-2"> <div className="space-y-2">
{creature.reactions.map((a) => ( {creature.reactions.map((a) => (
<div key={a.name} className="text-sm"> <div key={a.name} className="text-sm">
@@ -236,13 +238,13 @@ export function StatBlock({ creature }: StatBlockProps) {
)} )}
{/* Legendary Actions */} {/* Legendary Actions */}
{creature.legendaryActions && ( {!!creature.legendaryActions && (
<> <>
<SectionDivider /> <SectionDivider />
<h3 className="text-base font-bold text-amber-400"> <h3 className="font-bold text-base text-stat-heading">
Legendary Actions Legendary Actions
</h3> </h3>
<p className="text-sm italic text-muted-foreground"> <p className="text-muted-foreground text-sm italic">
{creature.legendaryActions.preamble} {creature.legendaryActions.preamble}
</p> </p>
<div className="space-y-2"> <div className="space-y-2">

View File

@@ -24,8 +24,8 @@ export function Toast({
return createPortal( return createPortal(
<div className="fixed bottom-4 left-4 z-50"> <div className="fixed bottom-4 left-4 z-50">
<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-lg"> <div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
<span className="text-sm text-foreground">{message}</span> <span className="text-foreground text-sm">{message}</span>
{progress !== undefined && ( {progress !== undefined && (
<div className="h-2 w-24 overflow-hidden rounded-full bg-muted"> <div className="h-2 w-24 overflow-hidden rounded-full bg-muted">
<div <div

View File

@@ -1,31 +1,22 @@
import type { Encounter } from "@initiative/domain";
import { StepBack, StepForward, Trash2 } from "lucide-react"; import { StepBack, StepForward, Trash2 } from "lucide-react";
import { Button } from "./ui/button"; import { useEncounterContext } from "../contexts/encounter-context.js";
import { ConfirmButton } from "./ui/confirm-button"; import { Button } from "./ui/button.js";
import { ConfirmButton } from "./ui/confirm-button.js";
interface TurnNavigationProps { export function TurnNavigation() {
encounter: Encounter; const { encounter, advanceTurn, retreatTurn, clearEncounter } =
onAdvanceTurn: () => void; useEncounterContext();
onRetreatTurn: () => void;
onClearEncounter: () => void;
}
export function TurnNavigation({
encounter,
onAdvanceTurn,
onRetreatTurn,
onClearEncounter,
}: TurnNavigationProps) {
const hasCombatants = encounter.combatants.length > 0; const hasCombatants = encounter.combatants.length > 0;
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0; const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
const activeCombatant = encounter.combatants[encounter.activeIndex]; const activeCombatant = encounter.combatants[encounter.activeIndex];
return ( return (
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3"> <div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
<Button <Button
variant="outline" variant="ghost"
size="icon" size="icon"
onClick={onRetreatTurn} onClick={retreatTurn}
disabled={!hasCombatants || isAtStart} disabled={!hasCombatants || isAtStart}
title="Previous turn" title="Previous turn"
aria-label="Previous turn" aria-label="Previous turn"
@@ -33,10 +24,12 @@ export function TurnNavigation({
<StepBack className="h-5 w-5" /> <StepBack className="h-5 w-5" />
</Button> </Button>
<div className="min-w-0 flex-1 flex items-center justify-center gap-2 text-sm"> <div className="flex min-w-0 flex-1 items-center justify-center gap-2 text-sm">
<span className="rounded-full bg-muted text-foreground text-sm px-2 py-0.5 font-semibold shrink-0"> <span className="shrink-0 rounded-md bg-muted px-2 py-0.5 font-semibold text-foreground text-sm">
<span className="-mt-[3px] inline-block">
R{encounter.roundNumber} R{encounter.roundNumber}
</span> </span>
</span>
{activeCombatant ? ( {activeCombatant ? (
<span className="truncate font-medium">{activeCombatant.name}</span> <span className="truncate font-medium">{activeCombatant.name}</span>
) : ( ) : (
@@ -48,14 +41,14 @@ export function TurnNavigation({
<ConfirmButton <ConfirmButton
icon={<Trash2 className="h-5 w-5" />} icon={<Trash2 className="h-5 w-5" />}
label="Clear encounter" label="Clear encounter"
onConfirm={onClearEncounter} onConfirm={clearEncounter}
disabled={!hasCombatants} disabled={!hasCombatants}
className="text-muted-foreground" className="text-muted-foreground"
/> />
<Button <Button
variant="outline" variant="ghost"
size="icon" size="icon"
onClick={onAdvanceTurn} onClick={advanceTurn}
disabled={!hasCombatants} disabled={!hasCombatants}
title="Next turn" title="Next turn"
aria-label="Next turn" aria-label="Next turn"

View File

@@ -9,8 +9,9 @@ const buttonVariants = cva(
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default: "bg-primary text-primary-foreground hover:bg-primary/90",
outline: outline:
"border border-border bg-transparent hover:bg-hover-neutral-bg hover:text-hover-neutral", "border border-border bg-background/50 text-foreground hover:bg-hover-neutral-bg hover:text-hover-neutral",
ghost: "hover:bg-hover-neutral-bg hover:text-hover-neutral", ghost:
"text-foreground hover:bg-hover-neutral-bg hover:text-hover-neutral",
}, },
size: { size: {
default: "h-8 px-3 text-xs", default: "h-8 px-3 text-xs",

View File

@@ -55,17 +55,17 @@ export function ConfirmButton({
} }
} }
function handleKeyDown(e: KeyboardEvent) { function handleEscapeKey(e: KeyboardEvent) {
if (e.key === "Escape") { if (e.key === "Escape") {
revert(); revert();
} }
} }
document.addEventListener("mousedown", handleMouseDown); document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleEscapeKey);
return () => { return () => {
document.removeEventListener("mousedown", handleMouseDown); document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown); document.removeEventListener("keydown", handleEscapeKey);
}; };
}, [isConfirming, revert]); }, [isConfirming, revert]);
@@ -100,7 +100,7 @@ export function ConfirmButton({
className={cn( className={cn(
className, className,
isConfirming isConfirming
? "bg-destructive text-primary-foreground rounded-md animate-confirm-pulse hover:bg-destructive hover:text-primary-foreground" ? "animate-confirm-pulse rounded-md bg-destructive text-primary-foreground hover:bg-destructive hover:text-primary-foreground"
: "hover:text-hover-destructive", : "hover:text-hover-destructive",
)} )}
onClick={handleClick} onClick={handleClick}
@@ -110,7 +110,8 @@ export function ConfirmButton({
aria-label={isConfirming ? `Confirm ${label.toLowerCase()}` : label} aria-label={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
title={isConfirming ? `Confirm ${label.toLowerCase()}` : label} title={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
> >
{isConfirming ? <Check size={16} /> : icon} {isConfirming ? <Check size={16} /> : null}
{!isConfirming && icon}
</Button> </Button>
</div> </div>
); );

View File

@@ -1,19 +1,21 @@
import { forwardRef, type InputHTMLAttributes } from "react"; import type { InputHTMLAttributes, RefObject } from "react";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
type InputProps = InputHTMLAttributes<HTMLInputElement>; type InputProps = InputHTMLAttributes<HTMLInputElement>;
export const Input = forwardRef<HTMLInputElement, InputProps>( export const Input = ({
({ className, ...props }, ref) => { className,
ref,
...props
}: InputProps & { ref?: RefObject<HTMLInputElement | null> }) => {
return ( return (
<input <input
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50", "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-foreground text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
{...props} {...props}
/> />
); );
}, };
);

View File

@@ -7,6 +7,7 @@ export interface OverflowMenuItem {
readonly label: string; readonly label: string;
readonly onClick: () => void; readonly onClick: () => void;
readonly disabled?: boolean; readonly disabled?: boolean;
readonly keepOpen?: boolean;
} }
interface OverflowMenuProps { interface OverflowMenuProps {
@@ -48,17 +49,17 @@ export function OverflowMenu({ items }: OverflowMenuProps) {
> >
<EllipsisVertical className="h-5 w-5" /> <EllipsisVertical className="h-5 w-5" />
</Button> </Button>
{open && ( {!!open && (
<div className="absolute bottom-full right-0 z-50 mb-1 min-w-48 rounded-md border border-border bg-card py-1 shadow-lg"> <div className="card-glow absolute right-0 bottom-full z-50 mb-1 min-w-48 rounded-lg border border-border bg-card py-1">
{items.map((item) => ( {items.map((item) => (
<button <button
key={item.label} key={item.label}
type="button" type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-foreground hover:bg-hover-neutral-bg disabled:pointer-events-none disabled:opacity-50" className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-foreground text-sm hover:bg-hover-neutral-bg disabled:pointer-events-none disabled:opacity-50"
disabled={item.disabled} disabled={item.disabled}
onClick={() => { onClick={() => {
item.onClick(); item.onClick();
setOpen(false); if (!item.keepOpen) setOpen(false);
}} }}
> >
{item.icon} {item.icon}

View File

@@ -0,0 +1,55 @@
import { type ReactNode, useRef, useState } from "react";
import { createPortal } from "react-dom";
interface TooltipProps {
content: string;
children: ReactNode;
className?: string;
}
export function Tooltip({
content,
children,
className,
}: Readonly<TooltipProps>) {
const ref = useRef<HTMLSpanElement>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
function show() {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
setPos({
top: rect.top - 4,
left: rect.left + rect.width / 2,
});
}
function hide() {
setPos(null);
}
return (
<>
<span
ref={ref}
onPointerEnter={show}
onPointerLeave={hide}
className={className ?? "inline-flex"}
>
{children}
</span>
{pos !== null &&
createPortal(
<div
role="tooltip"
className="pointer-events-none fixed z-[60] max-w-64 -translate-x-1/2 -translate-y-full rounded-md border border-border bg-background px-2.5 py-1.5 text-foreground text-xs leading-snug shadow-lg"
style={{ top: pos.top, left: pos.left }}
>
{content}
</div>,
document.body,
)}
</>
);
}

View File

@@ -0,0 +1,23 @@
import { createContext, type ReactNode, useContext } from "react";
import { useBestiary } from "../hooks/use-bestiary.js";
export type { SearchResult } from "../hooks/use-bestiary.js";
type BestiaryContextValue = ReturnType<typeof useBestiary>;
const BestiaryContext = createContext<BestiaryContextValue | null>(null);
export function BestiaryProvider({ children }: { children: ReactNode }) {
const value = useBestiary();
return (
<BestiaryContext.Provider value={value}>
{children}
</BestiaryContext.Provider>
);
}
export function useBestiaryContext(): BestiaryContextValue {
const ctx = useContext(BestiaryContext);
if (!ctx) throw new Error("useBestiaryContext requires BestiaryProvider");
return ctx;
}

View File

@@ -0,0 +1,21 @@
import { createContext, type ReactNode, useContext } from "react";
import { useBulkImport } from "../hooks/use-bulk-import.js";
type BulkImportContextValue = ReturnType<typeof useBulkImport>;
const BulkImportContext = createContext<BulkImportContextValue | null>(null);
export function BulkImportProvider({ children }: { children: ReactNode }) {
const value = useBulkImport();
return (
<BulkImportContext.Provider value={value}>
{children}
</BulkImportContext.Provider>
);
}
export function useBulkImportContext(): BulkImportContextValue {
const ctx = useContext(BulkImportContext);
if (!ctx) throw new Error("useBulkImportContext requires BulkImportProvider");
return ctx;
}

View File

@@ -0,0 +1,21 @@
import { createContext, type ReactNode, useContext } from "react";
import { useEncounter } from "../hooks/use-encounter.js";
type EncounterContextValue = ReturnType<typeof useEncounter>;
const EncounterContext = createContext<EncounterContextValue | null>(null);
export function EncounterProvider({ children }: { children: ReactNode }) {
const value = useEncounter();
return (
<EncounterContext.Provider value={value}>
{children}
</EncounterContext.Provider>
);
}
export function useEncounterContext(): EncounterContextValue {
const ctx = useContext(EncounterContext);
if (!ctx) throw new Error("useEncounterContext requires EncounterProvider");
return ctx;
}

View File

@@ -0,0 +1,7 @@
export { BestiaryProvider } from "./bestiary-context.js";
export { BulkImportProvider } from "./bulk-import-context.js";
export { EncounterProvider } from "./encounter-context.js";
export { InitiativeRollsProvider } from "./initiative-rolls-context.js";
export { PlayerCharactersProvider } from "./player-characters-context.js";
export { SidePanelProvider } from "./side-panel-context.js";
export { ThemeProvider } from "./theme-context.js";

View File

@@ -0,0 +1,25 @@
import { createContext, type ReactNode, useContext } from "react";
import { useInitiativeRolls } from "../hooks/use-initiative-rolls.js";
type InitiativeRollsContextValue = ReturnType<typeof useInitiativeRolls>;
const InitiativeRollsContext =
createContext<InitiativeRollsContextValue | null>(null);
export function InitiativeRollsProvider({ children }: { children: ReactNode }) {
const value = useInitiativeRolls();
return (
<InitiativeRollsContext.Provider value={value}>
{children}
</InitiativeRollsContext.Provider>
);
}
export function useInitiativeRollsContext(): InitiativeRollsContextValue {
const ctx = useContext(InitiativeRollsContext);
if (!ctx)
throw new Error(
"useInitiativeRollsContext requires InitiativeRollsProvider",
);
return ctx;
}

View File

@@ -0,0 +1,29 @@
import { createContext, type ReactNode, useContext } from "react";
import { usePlayerCharacters } from "../hooks/use-player-characters.js";
type PlayerCharactersContextValue = ReturnType<typeof usePlayerCharacters>;
const PlayerCharactersContext =
createContext<PlayerCharactersContextValue | null>(null);
export function PlayerCharactersProvider({
children,
}: {
children: ReactNode;
}) {
const value = usePlayerCharacters();
return (
<PlayerCharactersContext.Provider value={value}>
{children}
</PlayerCharactersContext.Provider>
);
}
export function usePlayerCharactersContext(): PlayerCharactersContextValue {
const ctx = useContext(PlayerCharactersContext);
if (!ctx)
throw new Error(
"usePlayerCharactersContext requires PlayerCharactersProvider",
);
return ctx;
}

View File

@@ -0,0 +1,21 @@
import { createContext, type ReactNode, useContext } from "react";
import { useSidePanelState } from "../hooks/use-side-panel-state.js";
type SidePanelContextValue = ReturnType<typeof useSidePanelState>;
const SidePanelContext = createContext<SidePanelContextValue | null>(null);
export function SidePanelProvider({ children }: { children: ReactNode }) {
const value = useSidePanelState();
return (
<SidePanelContext.Provider value={value}>
{children}
</SidePanelContext.Provider>
);
}
export function useSidePanelContext(): SidePanelContextValue {
const ctx = useContext(SidePanelContext);
if (!ctx) throw new Error("useSidePanelContext requires SidePanelProvider");
return ctx;
}

View File

@@ -0,0 +1,19 @@
import { createContext, type ReactNode, useContext } from "react";
import { useTheme } from "../hooks/use-theme.js";
type ThemeContextValue = ReturnType<typeof useTheme>;
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const value = useTheme();
return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
);
}
export function useThemeContext(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useThemeContext requires ThemeProvider");
return ctx;
}

View File

@@ -0,0 +1,225 @@
// @vitest-environment jsdom
import type { BestiaryIndexEntry, PlayerCharacter } from "@initiative/domain";
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useEncounter } from "../use-encounter.js";
vi.mock("../../persistence/encounter-storage.js", () => ({
loadEncounter: vi.fn().mockReturnValue(null),
saveEncounter: vi.fn(),
}));
const { loadEncounter: mockLoad, saveEncounter: mockSave } =
await vi.importMock<typeof import("../../persistence/encounter-storage.js")>(
"../../persistence/encounter-storage.js",
);
describe("useEncounter", () => {
beforeEach(() => {
vi.clearAllMocks();
mockLoad.mockReturnValue(null);
});
it("initializes with empty encounter when persistence returns null", () => {
const { result } = renderHook(() => useEncounter());
expect(result.current.encounter.combatants).toEqual([]);
expect(result.current.encounter.activeIndex).toBe(0);
expect(result.current.encounter.roundNumber).toBe(1);
expect(result.current.isEmpty).toBe(true);
});
it("initializes from stored encounter", () => {
const stored = {
combatants: [{ id: combatantId("c-1"), name: "Goblin" }],
activeIndex: 0,
roundNumber: 2,
};
mockLoad.mockReturnValue(stored);
const { result } = renderHook(() => useEncounter());
expect(result.current.encounter.combatants).toHaveLength(1);
expect(result.current.encounter.roundNumber).toBe(2);
expect(result.current.isEmpty).toBe(false);
});
it("addCombatant adds a combatant with incremental IDs and persists", () => {
const { result } = renderHook(() => useEncounter());
act(() => result.current.addCombatant("Goblin"));
act(() => result.current.addCombatant("Orc"));
expect(result.current.encounter.combatants).toHaveLength(2);
expect(result.current.encounter.combatants[0].name).toBe("Goblin");
expect(result.current.encounter.combatants[1].name).toBe("Orc");
expect(result.current.isEmpty).toBe(false);
expect(mockSave).toHaveBeenCalled();
});
it("removeCombatant removes a combatant and persists", () => {
const { result } = renderHook(() => useEncounter());
act(() => result.current.addCombatant("Goblin"));
const id = result.current.encounter.combatants[0].id;
act(() => result.current.removeCombatant(id));
expect(result.current.encounter.combatants).toHaveLength(0);
expect(result.current.isEmpty).toBe(true);
});
it("advanceTurn and retreatTurn update encounter state", () => {
const { result } = renderHook(() => useEncounter());
act(() => result.current.addCombatant("Goblin"));
act(() => result.current.addCombatant("Orc"));
const initialActive = result.current.encounter.activeIndex;
act(() => result.current.advanceTurn());
expect(result.current.encounter.activeIndex).not.toBe(initialActive);
act(() => result.current.retreatTurn());
expect(result.current.encounter.activeIndex).toBe(initialActive);
});
it("clearEncounter resets to empty and resets ID counter", () => {
const { result } = renderHook(() => useEncounter());
act(() => result.current.addCombatant("Goblin"));
act(() => result.current.clearEncounter());
expect(result.current.encounter.combatants).toHaveLength(0);
expect(result.current.isEmpty).toBe(true);
// After clear, IDs restart from c-1
act(() => result.current.addCombatant("Orc"));
expect(result.current.encounter.combatants[0].id).toBe("c-1");
});
it("addCombatant with opts applies initiative, ac, maxHp", () => {
const { result } = renderHook(() => useEncounter());
act(() =>
result.current.addCombatant("Goblin", {
initiative: 15,
ac: 13,
maxHp: 7,
}),
);
const goblin = result.current.encounter.combatants[0];
expect(goblin.initiative).toBe(15);
expect(goblin.ac).toBe(13);
expect(goblin.maxHp).toBe(7);
expect(goblin.currentHp).toBe(7);
});
it("derived flags: hasCreatureCombatants and canRollAllInitiative", () => {
const { result } = renderHook(() => useEncounter());
// No creatures yet
expect(result.current.hasCreatureCombatants).toBe(false);
expect(result.current.canRollAllInitiative).toBe(false);
// Add from bestiary to get a creature combatant
const entry: BestiaryIndexEntry = {
name: "Goblin",
source: "MM",
ac: 15,
hp: 7,
dex: 14,
cr: "1/4",
initiativeProficiency: 0,
size: "Small",
type: "humanoid",
};
act(() => {
result.current.addFromBestiary(entry);
});
expect(result.current.hasCreatureCombatants).toBe(true);
expect(result.current.canRollAllInitiative).toBe(true);
});
it("addFromBestiary adds combatant with HP, AC, creatureId", () => {
const { result } = renderHook(() => useEncounter());
const entry: BestiaryIndexEntry = {
name: "Goblin",
source: "MM",
ac: 15,
hp: 7,
dex: 14,
cr: "1/4",
initiativeProficiency: 0,
size: "Small",
type: "humanoid",
};
act(() => {
result.current.addFromBestiary(entry);
});
const combatant = result.current.encounter.combatants[0];
expect(combatant.name).toBe("Goblin");
expect(combatant.maxHp).toBe(7);
expect(combatant.currentHp).toBe(7);
expect(combatant.ac).toBe(15);
expect(combatant.creatureId).toBe(creatureId("mm:goblin"));
});
it("addFromBestiary auto-numbers duplicate names", () => {
const { result } = renderHook(() => useEncounter());
const entry: BestiaryIndexEntry = {
name: "Goblin",
source: "MM",
ac: 15,
hp: 7,
dex: 14,
cr: "1/4",
initiativeProficiency: 0,
size: "Small",
type: "humanoid",
};
act(() => {
result.current.addFromBestiary(entry);
});
act(() => {
result.current.addFromBestiary(entry);
});
const names = result.current.encounter.combatants.map((c) => c.name);
expect(names).toContain("Goblin 1");
expect(names).toContain("Goblin 2");
});
it("addFromPlayerCharacter adds combatant with HP, AC, color, icon", () => {
const { result } = renderHook(() => useEncounter());
const pc: PlayerCharacter = {
id: playerCharacterId("pc-1"),
name: "Aria",
ac: 16,
maxHp: 30,
color: "blue",
icon: "sword",
};
act(() => result.current.addFromPlayerCharacter(pc));
const combatant = result.current.encounter.combatants[0];
expect(combatant.name).toBe("Aria");
expect(combatant.maxHp).toBe(30);
expect(combatant.currentHp).toBe(30);
expect(combatant.ac).toBe(16);
expect(combatant.color).toBe("blue");
expect(combatant.icon).toBe("sword");
expect(combatant.playerCharacterId).toBe(playerCharacterId("pc-1"));
});
});

View File

@@ -0,0 +1,100 @@
// @vitest-environment jsdom
import { playerCharacterId } from "@initiative/domain";
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { usePlayerCharacters } from "../use-player-characters.js";
vi.mock("../../persistence/player-character-storage.js", () => ({
loadPlayerCharacters: vi.fn().mockReturnValue([]),
savePlayerCharacters: vi.fn(),
}));
const { loadPlayerCharacters: mockLoad, savePlayerCharacters: mockSave } =
await vi.importMock<
typeof import("../../persistence/player-character-storage.js")
>("../../persistence/player-character-storage.js");
describe("usePlayerCharacters", () => {
beforeEach(() => {
vi.clearAllMocks();
mockLoad.mockReturnValue([]);
});
it("initializes with characters from persistence", () => {
const stored = [
{
id: playerCharacterId("pc-1"),
name: "Aria",
ac: 16,
maxHp: 30,
color: undefined,
icon: undefined,
},
];
mockLoad.mockReturnValue(stored);
const { result } = renderHook(() => usePlayerCharacters());
expect(result.current.characters).toEqual(stored);
});
it("createCharacter adds a character and persists", () => {
const { result } = renderHook(() => usePlayerCharacters());
act(() => {
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
});
expect(result.current.characters).toHaveLength(1);
expect(result.current.characters[0].name).toBe("Vex");
expect(result.current.characters[0].ac).toBe(15);
expect(result.current.characters[0].maxHp).toBe(28);
expect(mockSave).toHaveBeenCalled();
});
it("createCharacter returns domain error for empty name", () => {
const { result } = renderHook(() => usePlayerCharacters());
let error: unknown;
act(() => {
error = result.current.createCharacter("", 15, 28, undefined, undefined);
});
expect(error).toMatchObject({ kind: "domain-error" });
expect(result.current.characters).toHaveLength(0);
});
it("editCharacter updates character and persists", () => {
const { result } = renderHook(() => usePlayerCharacters());
act(() => {
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
});
const id = result.current.characters[0].id;
act(() => {
result.current.editCharacter(id, { name: "Vex'ahlia" });
});
expect(result.current.characters[0].name).toBe("Vex'ahlia");
expect(mockSave).toHaveBeenCalled();
});
it("deleteCharacter removes character and persists", () => {
const { result } = renderHook(() => usePlayerCharacters());
act(() => {
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
});
const id = result.current.characters[0].id;
act(() => {
result.current.deleteCharacter(id);
});
expect(result.current.characters).toHaveLength(0);
expect(mockSave).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,159 @@
// @vitest-environment jsdom
import { creatureId } from "@initiative/domain";
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useSidePanelState } from "../use-side-panel-state.js";
function mockMatchMedia(matches: boolean) {
const listeners: Array<(e: MediaQueryListEvent) => void> = [];
const mql = {
matches,
addEventListener: vi.fn(
(_event: string, handler: (e: MediaQueryListEvent) => void) => {
listeners.push(handler);
},
),
removeEventListener: vi.fn(),
};
globalThis.matchMedia = vi.fn().mockReturnValue(mql) as typeof matchMedia;
return { mql, listeners };
}
const CREATURE_A = creatureId("creature-a");
describe("useSidePanelState", () => {
it("starts with closed panel, no selection, not collapsed", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
expect(result.current.panelView).toEqual({ mode: "closed" });
expect(result.current.selectedCreatureId).toBeNull();
expect(result.current.isRightPanelCollapsed).toBe(false);
expect(result.current.bulkImportMode).toBe(false);
expect(result.current.sourceManagerMode).toBe(false);
expect(result.current.pinnedCreatureId).toBeNull();
});
it("showCreature sets creature mode and selectedCreatureId", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
act(() => result.current.showCreature(CREATURE_A));
expect(result.current.panelView).toEqual({
mode: "creature",
creatureId: CREATURE_A,
});
expect(result.current.selectedCreatureId).toBe(CREATURE_A);
});
it("showBulkImport sets bulk-import mode, selectedCreatureId null", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
act(() => result.current.showBulkImport());
expect(result.current.panelView).toEqual({ mode: "bulk-import" });
expect(result.current.selectedCreatureId).toBeNull();
expect(result.current.bulkImportMode).toBe(true);
});
it("showSourceManager sets source-manager mode", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
act(() => result.current.showSourceManager());
expect(result.current.panelView).toEqual({ mode: "source-manager" });
expect(result.current.sourceManagerMode).toBe(true);
});
it("dismissPanel sets mode to closed", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
act(() => result.current.showCreature(CREATURE_A));
act(() => result.current.dismissPanel());
expect(result.current.panelView).toEqual({ mode: "closed" });
});
it("toggleCollapse flips isRightPanelCollapsed", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
expect(result.current.isRightPanelCollapsed).toBe(false);
act(() => result.current.toggleCollapse());
expect(result.current.isRightPanelCollapsed).toBe(true);
act(() => result.current.toggleCollapse());
expect(result.current.isRightPanelCollapsed).toBe(false);
});
it("showCreature resets collapse state", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
act(() => result.current.toggleCollapse());
expect(result.current.isRightPanelCollapsed).toBe(true);
act(() => result.current.showCreature(CREATURE_A));
expect(result.current.isRightPanelCollapsed).toBe(false);
});
it("togglePin pins the selected creature", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
act(() => result.current.showCreature(CREATURE_A));
act(() => result.current.togglePin());
expect(result.current.pinnedCreatureId).toBe(CREATURE_A);
});
it("togglePin unpins when already pinned to same creature", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
act(() => result.current.showCreature(CREATURE_A));
act(() => result.current.togglePin());
act(() => result.current.togglePin());
expect(result.current.pinnedCreatureId).toBeNull();
});
it("togglePin does nothing when no creature is selected", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
act(() => result.current.togglePin());
expect(result.current.pinnedCreatureId).toBeNull();
});
it("unpin clears pinned creature", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
act(() => result.current.showCreature(CREATURE_A));
act(() => result.current.togglePin());
act(() => result.current.unpin());
expect(result.current.pinnedCreatureId).toBeNull();
});
it("isWideDesktop reflects matchMedia result", () => {
mockMatchMedia(true);
const { result } = renderHook(() => useSidePanelState());
expect(result.current.isWideDesktop).toBe(true);
});
it("isWideDesktop is false on narrow viewport", () => {
mockMatchMedia(false);
const { result } = renderHook(() => useSidePanelState());
expect(result.current.isWideDesktop).toBe(false);
});
});

View File

@@ -0,0 +1,38 @@
import { useLayoutEffect, useRef, useState } from "react";
export function useActionBarAnimation(combatantCount: number) {
const wasEmptyRef = useRef(combatantCount === 0);
const [settling, setSettling] = useState(false);
const [rising, setRising] = useState(false);
const [topBarExiting, setTopBarExiting] = useState(false);
useLayoutEffect(() => {
const nowEmpty = combatantCount === 0;
if (wasEmptyRef.current && !nowEmpty) {
setSettling(true);
} else if (!wasEmptyRef.current && nowEmpty) {
setRising(true);
setTopBarExiting(true);
}
wasEmptyRef.current = nowEmpty;
}, [combatantCount]);
const empty = combatantCount === 0;
const risingClass = rising ? "animate-rise-to-center" : "";
const settlingClass = settling ? "animate-settle-to-bottom" : "";
const exitingClass = topBarExiting
? "absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out"
: "";
const topBarClass = settling ? "animate-slide-down-in" : exitingClass;
const showTopBar = !empty || topBarExiting;
return {
risingClass,
settlingClass,
topBarClass,
showTopBar,
onSettleEnd: () => setSettling(false),
onRiseEnd: () => setRising(false),
onTopBarExitEnd: () => setTopBarExiting(false),
};
}

View File

@@ -0,0 +1,17 @@
import { useEffect } from "react";
import { useEncounterContext } from "../contexts/encounter-context.js";
import { useSidePanelContext } from "../contexts/side-panel-context.js";
export function useAutoStatBlock(): void {
const { encounter } = useEncounterContext();
const { panelView, updateCreature } = useSidePanelContext();
const activeCreatureId =
encounter.combatants[encounter.activeIndex]?.creatureId;
useEffect(() => {
if (activeCreatureId && panelView.mode === "creature") {
updateCreature(activeCreatureId);
}
}, [activeCreatureId, panelView.mode, updateCreature]);
}

View File

@@ -44,7 +44,7 @@ export function useBestiary(): BestiaryHook {
setIsLoaded(true); setIsLoaded(true);
} }
bestiaryCache.loadAllCachedCreatures().then((map) => { void bestiaryCache.loadAllCachedCreatures().then((map) => {
setCreatureMap(map); setCreatureMap(map);
}); });
}, []); }, []);

View File

@@ -6,7 +6,7 @@ import {
const BATCH_SIZE = 6; const BATCH_SIZE = 6;
export interface BulkImportState { interface BulkImportState {
readonly status: "idle" | "loading" | "complete" | "partial-failure"; readonly status: "idle" | "loading" | "complete" | "partial-failure";
readonly total: number; readonly total: number;
readonly completed: number; readonly completed: number;
@@ -48,7 +48,7 @@ export function useBulkImport(): BulkImportHook {
countersRef.current = { completed: 0, failed: 0 }; countersRef.current = { completed: 0, failed: 0 };
setState({ status: "loading", total, completed: 0, failed: 0 }); setState({ status: "loading", total, completed: 0, failed: 0 });
(async () => { void (async () => {
const cacheChecks = await Promise.all( const cacheChecks = await Promise.all(
allCodes.map(async (code) => ({ allCodes.map(async (code) => ({
code, code,
@@ -73,9 +73,15 @@ export function useBulkImport(): BulkImportHook {
setState((s) => ({ ...s, completed: alreadyCached })); setState((s) => ({ ...s, completed: alreadyCached }));
const batches: { code: string }[][] = [];
for (let i = 0; i < uncached.length; i += BATCH_SIZE) { for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
const batch = uncached.slice(i, i + BATCH_SIZE); batches.push(uncached.slice(i, i + BATCH_SIZE));
await Promise.allSettled( }
await batches.reduce(
(chain, batch) =>
chain.then(() =>
Promise.allSettled(
batch.map(async ({ code }) => { batch.map(async ({ code }) => {
const url = getDefaultFetchUrl(code, baseUrl); const url = getDefaultFetchUrl(code, baseUrl);
try { try {
@@ -95,8 +101,10 @@ export function useBulkImport(): BulkImportHook {
failed: countersRef.current.failed, failed: countersRef.current.failed,
}); });
}), }),
),
),
Promise.resolve() as Promise<unknown>,
); );
}
await refreshCache(); await refreshCache();

View File

@@ -10,6 +10,7 @@ import {
setAcUseCase, setAcUseCase,
setHpUseCase, setHpUseCase,
setInitiativeUseCase, setInitiativeUseCase,
setTempHpUseCase,
toggleConcentrationUseCase, toggleConcentrationUseCase,
toggleConditionUseCase, toggleConditionUseCase,
} from "@initiative/application"; } from "@initiative/application";
@@ -17,6 +18,7 @@ import type {
BestiaryIndexEntry, BestiaryIndexEntry,
CombatantId, CombatantId,
ConditionId, ConditionId,
CreatureId,
DomainEvent, DomainEvent,
Encounter, Encounter,
PlayerCharacter, PlayerCharacter,
@@ -33,6 +35,8 @@ import {
saveEncounter, saveEncounter,
} from "../persistence/encounter-storage.js"; } from "../persistence/encounter-storage.js";
const COMBATANT_ID_REGEX = /^c-(\d+)$/;
const EMPTY_ENCOUNTER: Encounter = { const EMPTY_ENCOUNTER: Encounter = {
combatants: [], combatants: [],
activeIndex: 0, activeIndex: 0,
@@ -48,7 +52,7 @@ function initializeEncounter(): Encounter {
function deriveNextId(encounter: Encounter): number { function deriveNextId(encounter: Encounter): number {
let max = 0; let max = 0;
for (const c of encounter.combatants) { for (const c of encounter.combatants) {
const match = /^c-(\d+)$/.exec(c.id); const match = COMBATANT_ID_REGEX.exec(c.id);
if (match) { if (match) {
const n = Number.parseInt(match[1], 10); const n = Number.parseInt(match[1], 10);
if (n > max) max = n; if (n > max) max = n;
@@ -212,6 +216,19 @@ export function useEncounter() {
[makeStore], [makeStore],
); );
const setTempHp = useCallback(
(id: CombatantId, tempHp: number | undefined) => {
const result = setTempHpUseCase(makeStore(), id, tempHp);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
);
const setAc = useCallback( const setAc = useCallback(
(id: CombatantId, value: number | undefined) => { (id: CombatantId, value: number | undefined) => {
const result = setAcUseCase(makeStore(), id, value); const result = setAcUseCase(makeStore(), id, value);
@@ -263,7 +280,7 @@ export function useEncounter() {
}, [makeStore]); }, [makeStore]);
const addFromBestiary = useCallback( const addFromBestiary = useCallback(
(entry: BestiaryIndexEntry) => { (entry: BestiaryIndexEntry): CreatureId | null => {
const store = makeStore(); const store = makeStore();
const existingNames = store.get().combatants.map((c) => c.name); const existingNames = store.get().combatants.map((c) => c.name);
const { newName, renames } = resolveCreatureName( const { newName, renames } = resolveCreatureName(
@@ -282,7 +299,7 @@ export function useEncounter() {
// Add combatant with resolved name // Add combatant with resolved name
const id = combatantId(`c-${++nextId.current}`); const id = combatantId(`c-${++nextId.current}`);
const addResult = addCombatantUseCase(makeStore(), id, newName); const addResult = addCombatantUseCase(makeStore(), id, newName);
if (isDomainError(addResult)) return; if (isDomainError(addResult)) return null;
// Set HP // Set HP
const hpResult = setHpUseCase(makeStore(), id, entry.hp); const hpResult = setHpUseCase(makeStore(), id, entry.hp);
@@ -301,8 +318,8 @@ export function useEncounter() {
// Derive creatureId from source + name // Derive creatureId from source + name
const slug = entry.name const slug = entry.name
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9]+/g, "-") .replaceAll(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, ""); .replaceAll(/(^-|-$)/g, "");
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`); const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
// Set creatureId on the combatant (use store.save to keep ref in sync for batch calls) // Set creatureId on the combatant (use store.save to keep ref in sync for batch calls)
@@ -315,8 +332,10 @@ export function useEncounter() {
}); });
setEvents((prev) => [...prev, ...addResult]); setEvents((prev) => [...prev, ...addResult]);
return cId;
}, },
[makeStore, editCombatant], [makeStore],
); );
const addFromPlayerCharacter = useCallback( const addFromPlayerCharacter = useCallback(
@@ -368,7 +387,11 @@ export function useEncounter() {
setEvents((prev) => [...prev, ...addResult]); setEvents((prev) => [...prev, ...addResult]);
}, },
[makeStore, editCombatant], [makeStore],
);
const hasTempHp = encounter.combatants.some(
(c) => c.tempHp !== undefined && c.tempHp > 0,
); );
const isEmpty = encounter.combatants.length === 0; const isEmpty = encounter.combatants.length === 0;
@@ -383,6 +406,7 @@ export function useEncounter() {
encounter, encounter,
events, events,
isEmpty, isEmpty,
hasTempHp,
hasCreatureCombatants, hasCreatureCombatants,
canRollAllInitiative, canRollAllInitiative,
advanceTurn, advanceTurn,
@@ -394,6 +418,7 @@ export function useEncounter() {
setInitiative, setInitiative,
setHp, setHp,
adjustHp, adjustHp,
setTempHp,
setAc, setAc,
toggleCondition, toggleCondition,
toggleConcentration, toggleConcentration,

View File

@@ -0,0 +1,75 @@
import {
rollAllInitiativeUseCase,
rollInitiativeUseCase,
} from "@initiative/application";
import {
type CombatantId,
isDomainError,
type RollMode,
} from "@initiative/domain";
import { useCallback, useState } from "react";
import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { useEncounterContext } from "../contexts/encounter-context.js";
import { useSidePanelContext } from "../contexts/side-panel-context.js";
function rollDice(): number {
return Math.floor(Math.random() * 20) + 1;
}
export function useInitiativeRolls() {
const { encounter, makeStore } = useEncounterContext();
const { getCreature } = useBestiaryContext();
const { showCreature } = useSidePanelContext();
const [rollSkippedCount, setRollSkippedCount] = useState(0);
const [rollSingleSkipped, setRollSingleSkipped] = useState(false);
const handleRollInitiative = useCallback(
(id: CombatantId, mode: RollMode = "normal") => {
const diceRolls: [number, ...number[]] =
mode === "normal" ? [rollDice()] : [rollDice(), rollDice()];
const result = rollInitiativeUseCase(
makeStore(),
id,
diceRolls,
getCreature,
mode,
);
if (isDomainError(result)) {
setRollSingleSkipped(true);
const combatant = encounter.combatants.find((c) => c.id === id);
if (combatant?.creatureId) {
showCreature(combatant.creatureId);
}
}
},
[makeStore, getCreature, encounter.combatants, showCreature],
);
const handleRollAllInitiative = useCallback(
(mode: RollMode = "normal") => {
const result = rollAllInitiativeUseCase(
makeStore(),
rollDice,
getCreature,
mode,
);
if (!isDomainError(result) && result.skippedNoSource > 0) {
setRollSkippedCount(result.skippedNoSource);
}
},
[makeStore, getCreature],
);
return {
rollSkippedCount,
rollSingleSkipped,
dismissRollSkipped: useCallback(() => setRollSkippedCount(0), []),
dismissRollSingleSkipped: useCallback(
() => setRollSingleSkipped(false),
[],
),
handleRollInitiative,
handleRollAllInitiative,
} as const;
}

View File

@@ -0,0 +1,32 @@
import { useCallback, useRef } from "react";
const LONG_PRESS_MS = 500;
export function useLongPress(onLongPress: (e: React.TouchEvent) => void) {
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const firedRef = useRef(false);
const onTouchStart = useCallback(
(e: React.TouchEvent) => {
firedRef.current = false;
timerRef.current = setTimeout(() => {
firedRef.current = true;
onLongPress(e);
}, LONG_PRESS_MS);
},
[onLongPress],
);
const onTouchEnd = useCallback((e: React.TouchEvent) => {
clearTimeout(timerRef.current);
if (firedRef.current) {
e.preventDefault();
}
}, []);
const onTouchMove = useCallback(() => {
clearTimeout(timerRef.current);
}, []);
return { onTouchStart, onTouchEnd, onTouchMove };
}

View File

@@ -19,6 +19,7 @@ interface SidePanelState {
interface SidePanelActions { interface SidePanelActions {
showCreature: (creatureId: CreatureId) => void; showCreature: (creatureId: CreatureId) => void;
updateCreature: (creatureId: CreatureId) => void;
showBulkImport: () => void; showBulkImport: () => void;
showSourceManager: () => void; showSourceManager: () => void;
dismissPanel: () => void; dismissPanel: () => void;
@@ -34,11 +35,11 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
null, null,
); );
const [isWideDesktop, setIsWideDesktop] = useState( const [isWideDesktop, setIsWideDesktop] = useState(
() => window.matchMedia("(min-width: 1280px)").matches, () => globalThis.matchMedia("(min-width: 1280px)").matches,
); );
useEffect(() => { useEffect(() => {
const mq = window.matchMedia("(min-width: 1280px)"); const mq = globalThis.matchMedia("(min-width: 1280px)");
const handler = (e: MediaQueryListEvent) => setIsWideDesktop(e.matches); const handler = (e: MediaQueryListEvent) => setIsWideDesktop(e.matches);
mq.addEventListener("change", handler); mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler); return () => mq.removeEventListener("change", handler);
@@ -52,6 +53,10 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
setIsRightPanelCollapsed(false); setIsRightPanelCollapsed(false);
}, []); }, []);
const updateCreature = useCallback((creatureId: CreatureId) => {
setPanelView({ mode: "creature", creatureId });
}, []);
const showBulkImport = useCallback(() => { const showBulkImport = useCallback(() => {
setPanelView({ mode: "bulk-import" }); setPanelView({ mode: "bulk-import" });
setIsRightPanelCollapsed(false); setIsRightPanelCollapsed(false);
@@ -91,6 +96,7 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
pinnedCreatureId, pinnedCreatureId,
isWideDesktop, isWideDesktop,
showCreature, showCreature,
updateCreature,
showBulkImport, showBulkImport,
showSourceManager, showSourceManager,
dismissPanel, dismissPanel,

View File

@@ -0,0 +1,98 @@
import { useCallback, useEffect, useSyncExternalStore } from "react";
type ThemePreference = "system" | "light" | "dark";
type ResolvedTheme = "light" | "dark";
const STORAGE_KEY = "initiative:theme";
const listeners = new Set<() => void>();
let currentPreference: ThemePreference = loadPreference();
function loadPreference(): ThemePreference {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === "light" || raw === "dark" || raw === "system") return raw;
} catch {
// storage unavailable
}
return "system";
}
function savePreference(pref: ThemePreference): void {
try {
localStorage.setItem(STORAGE_KEY, pref);
} catch {
// quota exceeded or storage unavailable
}
}
function getSystemTheme(): ResolvedTheme {
if (typeof globalThis.matchMedia !== "function") return "dark";
return globalThis.matchMedia("(prefers-color-scheme: light)").matches
? "light"
: "dark";
}
function resolve(pref: ThemePreference): ResolvedTheme {
return pref === "system" ? getSystemTheme() : pref;
}
function applyTheme(resolved: ResolvedTheme): void {
document.documentElement.dataset.theme = resolved;
}
function notifyAll(): void {
for (const listener of listeners) {
listener();
}
}
// Apply on load
applyTheme(resolve(currentPreference));
// Listen for OS preference changes
if (typeof globalThis.matchMedia === "function") {
globalThis
.matchMedia("(prefers-color-scheme: light)")
.addEventListener("change", () => {
if (currentPreference === "system") {
applyTheme(resolve("system"));
notifyAll();
}
});
}
function subscribe(callback: () => void): () => void {
listeners.add(callback);
return () => listeners.delete(callback);
}
function getSnapshot(): ThemePreference {
return currentPreference;
}
const CYCLE: ThemePreference[] = ["system", "light", "dark"];
export function useTheme() {
const preference = useSyncExternalStore(subscribe, getSnapshot);
const resolved = resolve(preference);
useEffect(() => {
applyTheme(resolved);
}, [resolved]);
const setPreference = useCallback((pref: ThemePreference) => {
currentPreference = pref;
savePreference(pref);
applyTheme(resolve(pref));
notifyAll();
}, []);
const cycleTheme = useCallback(() => {
const idx = CYCLE.indexOf(currentPreference);
const next = CYCLE[(idx + 1) % CYCLE.length];
setPreference(next);
}, [setPreference]);
return { preference, resolved, setPreference, cycleTheme } as const;
}

View File

@@ -1,14 +1,14 @@
@import "tailwindcss"; @import "tailwindcss";
@theme { @theme {
--color-background: #0f172a; --color-background: #0e1a2e;
--color-foreground: #e2e8f0; --color-foreground: #e2e8f0;
--color-muted: #64748b; --color-muted: #7a8ba4;
--color-muted-foreground: #94a3b8; --color-muted-foreground: #94a3b8;
--color-card: #1e293b; --color-card: #1a2e4a;
--color-card-foreground: #e2e8f0; --color-card-foreground: #e2e8f0;
--color-border: #334155; --color-border: #2a5088;
--color-input: #334155; --color-input: #2a5088;
--color-primary: #3b82f6; --color-primary: #3b82f6;
--color-primary-foreground: #ffffff; --color-primary-foreground: #ffffff;
--color-accent: #3b82f6; --color-accent: #3b82f6;
@@ -19,12 +19,47 @@
--color-hover-neutral-bg: oklch(0.623 0.214 259 / 0.15); --color-hover-neutral-bg: oklch(0.623 0.214 259 / 0.15);
--color-hover-action-bg: var(--color-muted); --color-hover-action-bg: var(--color-muted);
--color-hover-destructive-bg: transparent; --color-hover-destructive-bg: transparent;
--color-stat-heading: #fbbf24;
--color-stat-divider-from: oklch(0.5 0.1 65 / 0.6);
--color-stat-divider-via: oklch(0.5 0.1 65 / 0.4);
--color-hp-damage-hover-bg: oklch(0.25 0.05 25);
--color-hp-heal-hover-bg: oklch(0.25 0.05 155);
--color-active-row-bg: oklch(0.623 0.214 259 / 0.1);
--color-active-row-border: oklch(0.623 0.214 259 / 0.4);
--radius-sm: 0.25rem; --radius-sm: 0.25rem;
--radius-md: 0.375rem; --radius-md: 0.5rem;
--radius-lg: 0.5rem; --radius-lg: 0.75rem;
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif; --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
} }
[data-theme="light"] {
--color-background: #eeecea;
--color-foreground: #374151;
--color-muted: #e0ddd9;
--color-muted-foreground: #6b7280;
--color-card: #f7f6f4;
--color-card-foreground: #374151;
--color-border: #ddd9d5;
--color-input: #cdc8c3;
--color-primary: #2563eb;
--color-primary-foreground: #ffffff;
--color-accent: #2563eb;
--color-destructive: #dc2626;
--color-hover-neutral: var(--color-primary);
--color-hover-action: var(--color-primary);
--color-hover-destructive: var(--color-destructive);
--color-hover-neutral-bg: oklch(0.623 0.214 259 / 0.08);
--color-hover-action-bg: var(--color-muted);
--color-hover-destructive-bg: transparent;
--color-stat-heading: #92400e;
--color-stat-divider-from: oklch(0.55 0.1 65 / 0.5);
--color-stat-divider-via: oklch(0.55 0.1 65 / 0.25);
--color-hp-damage-hover-bg: #fef2f2;
--color-hp-heal-hover-bg: #ecfdf5;
--color-active-row-bg: oklch(0.623 0.214 259 / 0.08);
--color-active-row-border: oklch(0.623 0.214 259 / 0.25);
}
@keyframes concentration-shake { @keyframes concentration-shake {
0% { 0% {
translate: 0; translate: 0;
@@ -169,6 +204,38 @@
concentration-glow 1200ms ease-out; concentration-glow 1200ms ease-out;
} }
@utility card-glow {
background-image: radial-gradient(
ellipse at 50% 50%,
oklch(0.35 0.05 250 / 0.5) 0%,
transparent 70%
);
box-shadow:
0 0 15px -2px oklch(0.623 0.214 259 / 0.2),
inset 0 1px 0 0 oklch(0.7 0.15 259 / 0.1);
[data-theme="light"] & {
background-image: none;
box-shadow: 0 1px 3px 0 oklch(0 0 0 / 0.08);
}
}
@utility panel-glow {
background-image: linear-gradient(
to bottom,
oklch(0.35 0.05 250 / 0.4) 0%,
transparent 40%
);
box-shadow:
0 0 20px -2px oklch(0.623 0.214 259 / 0.15),
inset 0 1px 0 0 oklch(0.7 0.15 259 / 0.1);
[data-theme="light"] & {
background-image: none;
box-shadow: -1px 0 6px 0 oklch(0 0 0 / 0.1);
}
}
* { * {
scrollbar-color: var(--color-border) transparent; scrollbar-color: var(--color-border) transparent;
scrollbar-width: thin; scrollbar-width: thin;
@@ -176,6 +243,16 @@
body { body {
background-color: var(--color-background); background-color: var(--color-background);
background-image: radial-gradient(
ellipse at 50% 40%,
oklch(0.26 0.055 250) 0%,
var(--color-background) 70%
);
background-attachment: fixed;
color: var(--color-foreground); color: var(--color-foreground);
font-family: var(--font-sans); font-family: var(--font-sans);
} }
[data-theme="light"] body {
background-image: none;
}

View File

@@ -1,13 +1,36 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { App } from "./App"; import { App } from "./App.js";
import {
BestiaryProvider,
BulkImportProvider,
EncounterProvider,
InitiativeRollsProvider,
PlayerCharactersProvider,
SidePanelProvider,
ThemeProvider,
} from "./contexts/index.js";
import "./index.css"; import "./index.css";
const root = document.getElementById("root"); const root = document.getElementById("root");
if (root) { if (root) {
createRoot(root).render( createRoot(root).render(
<StrictMode> <StrictMode>
<ThemeProvider>
<EncounterProvider>
<BestiaryProvider>
<PlayerCharactersProvider>
<BulkImportProvider>
<SidePanelProvider>
<InitiativeRollsProvider>
<App /> <App />
</InitiativeRollsProvider>
</SidePanelProvider>
</BulkImportProvider>
</PlayerCharactersProvider>
</BestiaryProvider>
</EncounterProvider>
</ThemeProvider>
</StrictMode>, </StrictMode>,
); );
} }

View File

@@ -1,14 +1,14 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json", "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
"files": { "files": {
"includes": [ "includes": [
"**", "**",
"!**/dist/**", "!**/dist",
"!.claude/**", "!.claude",
"!.specify/**", "!.specify",
"!specs/**", "!specs",
"!coverage/**", "!coverage",
"!.pnpm-store/**" "!.pnpm-store"
] ]
}, },
"assist": { "assist": {
@@ -21,6 +21,12 @@
} }
} }
}, },
"css": {
"parser": {
"cssModules": false,
"tailwindDirectives": true
}
},
"formatter": { "formatter": {
"enabled": true, "enabled": true,
"indentStyle": "tab", "indentStyle": "tab",
@@ -30,13 +36,93 @@
"enabled": true, "enabled": true,
"rules": { "rules": {
"recommended": true, "recommended": true,
"a11y": {
"noNoninteractiveElementInteractions": "error"
},
"complexity": { "complexity": {
"noExcessiveCognitiveComplexity": { "noExcessiveCognitiveComplexity": {
"level": "error", "level": "error",
"options": { "options": {
"maxAllowedComplexity": 15 "maxAllowedComplexity": 15
} }
} },
"noUselessStringConcat": "error"
},
"correctness": {
"noNestedComponentDefinitions": "error",
"noReactPropAssignments": "error"
},
"nursery": {
"noConditionalExpect": "error",
"noDuplicatedSpreadProps": "error",
"noFloatingPromises": "error",
"noLeakedRender": "error",
"noMisusedPromises": "error",
"noNestedPromises": "error",
"noReturnAssign": "error",
"noScriptUrl": "error",
"noShadow": "error",
"noUnnecessaryConditions": "error",
"noUselessReturn": "error",
"useArraySome": "error",
"useArraySortCompare": "error",
"useAwaitThenable": "error",
"useErrorCause": "error",
"useExhaustiveSwitchCases": "error",
"useFind": "error",
"useGlobalThis": "error",
"useNullishCoalescing": "error",
"useRegexpExec": "error",
"useSortedClasses": "error",
"useSpread": "error"
},
"performance": {
"noAwaitInLoops": "error",
"useTopLevelRegex": "error"
},
"style": {
"noCommonJs": "error",
"noDoneCallback": "error",
"noExportedImports": "error",
"noInferrableTypes": "error",
"noNamespace": "error",
"noNegationElse": "error",
"noNestedTernary": "error",
"noParameterAssign": "error",
"noSubstr": "error",
"noUnusedTemplateLiteral": "error",
"noUselessElse": "error",
"noYodaExpression": "error",
"useAsConstAssertion": "error",
"useAtIndex": "error",
"useCollapsedElseIf": "error",
"useCollapsedIf": "error",
"useConsistentBuiltinInstantiation": "error",
"useDefaultParameterLast": "error",
"useExplicitLengthCheck": "error",
"useForOf": "error",
"useFragmentSyntax": "error",
"useNumberNamespace": "error",
"useSelfClosingElements": "error",
"useShorthandAssign": "error",
"useThrowNewError": "error",
"useThrowOnlyError": "error",
"useTrimStartEnd": "error"
},
"suspicious": {
"noAlert": "error",
"noConstantBinaryExpressions": "error",
"noDeprecatedImports": "error",
"noEvolvingTypes": "error",
"noImportCycles": "error",
"noReactForwardRef": "error",
"noSkippedTests": "error",
"noTemplateCurlyInString": "error",
"noTsIgnore": "error",
"noUnusedExpressions": "error",
"noVar": "error",
"useAwait": "error",
"useErrorMessage": "error"
} }
} }
} }

View File

@@ -5,6 +5,6 @@
"entry": ["scripts/*.mjs"] "entry": ["scripts/*.mjs"]
}, },
"packages/*": {}, "packages/*": {},
"apps/*": {} "apps/web": {}
} }
} }

View File

@@ -1,19 +1,21 @@
{ {
"private": true, "private": true,
"packageManager": "pnpm@10.6.0", "packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be",
"pnpm": { "pnpm": {
"overrides": { "overrides": {
"undici": ">=7.24.0" "undici": ">=7.24.0"
} }
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.0.0", "@biomejs/biome": "2.4.8",
"@vitest/coverage-v8": "^3.2.4", "@vitest/coverage-v8": "^4.1.0",
"jscpd": "^4.0.8", "jscpd": "^4.0.8",
"knip": "^5.85.0", "knip": "^6.0.2",
"lefthook": "^1.11.0", "lefthook": "^2.1.4",
"oxlint": "^1.56.0",
"oxlint-tsgolint": "^0.17.1",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"vitest": "^3.0.0" "vitest": "^4.1.0"
}, },
"scripts": { "scripts": {
"prepare": "lefthook install", "prepare": "lefthook install",
@@ -26,6 +28,10 @@
"test:watch": "vitest", "test:watch": "vitest",
"knip": "knip", "knip": "knip",
"jscpd": "jscpd", "jscpd": "jscpd",
"check": "pnpm audit --audit-level=high && knip && biome check . && tsc --build && vitest run && jscpd" "oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
"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"
} }
} }

View File

@@ -0,0 +1,54 @@
import type { Encounter, PlayerCharacter } from "@initiative/domain";
import { isDomainError } from "@initiative/domain";
import type { EncounterStore, PlayerCharacterStore } from "../ports.js";
export function requireSaved<T>(value: T | null): T {
if (value === null) throw new Error("Expected store.saved to be non-null");
return value;
}
export function expectSuccess<T>(
result: T,
): asserts result is Exclude<T, { kind: "domain-error" }> {
if (isDomainError(result)) {
throw new Error(`Expected success, got domain error: ${result.message}`);
}
}
export function expectError(result: unknown): asserts result is {
kind: "domain-error";
code: string;
message: string;
} {
if (!isDomainError(result)) {
throw new Error("Expected domain error");
}
}
export function stubEncounterStore(
initial: Encounter,
): EncounterStore & { saved: Encounter | null } {
const stub = {
saved: null as Encounter | null,
get: () => initial,
save: (e: Encounter) => {
stub.saved = e;
stub.get = () => e;
},
};
return stub;
}
export function stubPlayerCharacterStore(
initial: readonly PlayerCharacter[],
): PlayerCharacterStore & { saved: readonly PlayerCharacter[] | null } {
const stub = {
saved: null as readonly PlayerCharacter[] | null,
getAll: () => [...initial],
save: (characters: PlayerCharacter[]) => {
stub.saved = characters;
stub.getAll = () => [...characters];
},
};
return stub;
}

View File

@@ -0,0 +1,237 @@
import {
type Creature,
combatantId,
createEncounter,
creatureId,
isDomainError,
} from "@initiative/domain";
import { describe, expect, it } from "vitest";
import { rollAllInitiativeUseCase } from "../roll-all-initiative-use-case.js";
import {
expectError,
expectSuccess,
requireSaved,
stubEncounterStore,
} from "./helpers.js";
const CREATURE_A = creatureId("creature-a");
const CREATURE_B = creatureId("creature-b");
function makeCreature(id: string, dex = 14): Creature {
return {
id: creatureId(id),
name: `Creature ${id}`,
source: "mm",
sourceDisplayName: "Monster Manual",
size: "Medium",
type: "humanoid",
alignment: "neutral",
ac: 12,
hp: { average: 10, formula: "2d8+2" },
speed: "30 ft.",
abilities: { str: 10, dex, con: 10, int: 10, wis: 10, cha: 10 },
cr: "1",
initiativeProficiency: 0,
proficiencyBonus: 2,
passive: 10,
};
}
function encounterWithCombatants(
combatants: Array<{
name: string;
creatureId?: string;
initiative?: number;
}>,
) {
const result = createEncounter(
combatants.map((c) => ({
id: combatantId(c.name),
name: c.name,
creatureId: c.creatureId ? creatureId(c.creatureId) : undefined,
initiative: c.initiative,
})),
);
if (isDomainError(result)) throw new Error("Setup failed");
return result;
}
describe("rollAllInitiativeUseCase", () => {
it("skips combatants without creatureId", () => {
const enc = encounterWithCombatants([
{ name: "Fighter" },
{ name: "Goblin", creatureId: "creature-a" },
]);
const store = stubEncounterStore(enc);
const creature = makeCreature("creature-a");
const result = rollAllInitiativeUseCase(
store,
() => 10,
(id) => (id === CREATURE_A ? creature : undefined),
);
expectSuccess(result);
expect(result.events.length).toBeGreaterThan(0);
const saved = requireSaved(store.saved);
const fighter = saved.combatants.find((c) => c.name === "Fighter");
const goblin = saved.combatants.find((c) => c.name === "Goblin");
expect(fighter?.initiative).toBeUndefined();
expect(goblin?.initiative).toBeDefined();
});
it("skips combatants that already have initiative", () => {
const enc = encounterWithCombatants([
{ name: "Goblin", creatureId: "creature-a", initiative: 15 },
]);
const store = stubEncounterStore(enc);
const result = rollAllInitiativeUseCase(
store,
() => 10,
() => makeCreature("creature-a"),
);
expectSuccess(result);
expect(result.events).toHaveLength(0);
expect(requireSaved(store.saved).combatants[0].initiative).toBe(15);
});
it("counts skippedNoSource when creature lookup returns undefined", () => {
const enc = encounterWithCombatants([
{ name: "Unknown", creatureId: "missing" },
]);
const store = stubEncounterStore(enc);
const result = rollAllInitiativeUseCase(
store,
() => 10,
() => undefined,
);
expectSuccess(result);
expect(result.skippedNoSource).toBe(1);
expect(result.events).toHaveLength(0);
});
it("accumulates events from multiple setInitiative calls", () => {
const enc = encounterWithCombatants([
{ name: "A", creatureId: "creature-a" },
{ name: "B", creatureId: "creature-b" },
]);
const store = stubEncounterStore(enc);
const creatureA = makeCreature("creature-a");
const creatureB = makeCreature("creature-b");
const result = rollAllInitiativeUseCase(
store,
() => 10,
(id) => {
if (id === CREATURE_A) return creatureA;
if (id === CREATURE_B) return creatureB;
return undefined;
},
);
expectSuccess(result);
expect(result.events).toHaveLength(2);
});
it("returns early with domain error on invalid dice roll", () => {
const enc = encounterWithCombatants([
{ name: "A", creatureId: "creature-a" },
{ name: "B", creatureId: "creature-b" },
]);
const store = stubEncounterStore(enc);
// rollDice returns 0 (invalid — must be 120), triggers early return
const result = rollAllInitiativeUseCase(
store,
() => 0,
(id) => {
if (id === CREATURE_A) return makeCreature("creature-a");
if (id === CREATURE_B) return makeCreature("creature-b");
return undefined;
},
);
expectError(result);
expect(result.code).toBe("invalid-dice-roll");
// Store should NOT have been saved since the loop aborted
expect(store.saved).toBeNull();
});
it("uses higher roll with advantage", () => {
const enc = encounterWithCombatants([
{ name: "A", creatureId: "creature-a" },
]);
const store = stubEncounterStore(enc);
const creature = makeCreature("creature-a");
// Alternating rolls: 5, 15 → advantage picks 15
// Dex 14 → modifier +2, so 15 + 2 = 17
let call = 0;
const result = rollAllInitiativeUseCase(
store,
() => (++call % 2 === 1 ? 5 : 15),
(id) => (id === CREATURE_A ? creature : undefined),
"advantage",
);
expectSuccess(result);
expect(requireSaved(store.saved).combatants[0].initiative).toBe(17);
});
it("uses lower roll with disadvantage", () => {
const enc = encounterWithCombatants([
{ name: "A", creatureId: "creature-a" },
]);
const store = stubEncounterStore(enc);
const creature = makeCreature("creature-a");
// Alternating rolls: 15, 5 → disadvantage picks 5
// Dex 14 → modifier +2, so 5 + 2 = 7
let call = 0;
const result = rollAllInitiativeUseCase(
store,
() => (++call % 2 === 1 ? 15 : 5),
(id) => (id === CREATURE_A ? creature : undefined),
"disadvantage",
);
expectSuccess(result);
expect(requireSaved(store.saved).combatants[0].initiative).toBe(7);
});
it("saves encounter once at the end", () => {
const enc = encounterWithCombatants([
{ name: "A", creatureId: "creature-a" },
{ name: "B", creatureId: "creature-b" },
]);
const store = stubEncounterStore(enc);
const creatureA = makeCreature("creature-a");
const creatureB = makeCreature("creature-b");
let saveCount = 0;
const originalSave = store.save.bind(store);
store.save = (e) => {
saveCount++;
originalSave(e);
};
rollAllInitiativeUseCase(
store,
() => 10,
(id) => {
if (id === CREATURE_A) return creatureA;
if (id === CREATURE_B) return creatureB;
return undefined;
},
);
expect(saveCount).toBe(1);
const saved = requireSaved(store.saved);
expect(saved.combatants[0].initiative).toBeDefined();
expect(saved.combatants[1].initiative).toBeDefined();
});
});

View File

@@ -0,0 +1,191 @@
import {
type Creature,
type CreatureId,
combatantId,
createEncounter,
creatureId,
isDomainError,
} from "@initiative/domain";
import { describe, expect, it } from "vitest";
import { addCombatantUseCase } from "../add-combatant-use-case.js";
import { rollInitiativeUseCase } from "../roll-initiative-use-case.js";
import { expectError, requireSaved, stubEncounterStore } from "./helpers.js";
const GOBLIN_ID = creatureId("goblin");
function makeCreature(overrides?: Partial<Creature>): Creature {
return {
id: GOBLIN_ID,
name: "Goblin",
source: "mm",
sourceDisplayName: "Monster Manual",
size: "Small",
type: "humanoid",
alignment: "neutral evil",
ac: 15,
hp: { average: 7, formula: "2d6" },
speed: "30 ft.",
abilities: { str: 8, dex: 14, con: 10, int: 10, wis: 8, cha: 8 },
cr: "1/4",
initiativeProficiency: 0,
proficiencyBonus: 2,
passive: 9,
...overrides,
};
}
function encounterWithCreatureLink(name: string, creature: CreatureId) {
const enc = createEncounter([]);
if (isDomainError(enc)) throw new Error("Setup failed");
const id = combatantId(name);
const store = stubEncounterStore(enc);
addCombatantUseCase(store, id, name);
const saved = requireSaved(store.saved);
const result = createEncounter(
saved.combatants.map((c) =>
c.id === id ? { ...c, creatureId: creature } : c,
),
saved.activeIndex,
saved.roundNumber,
);
if (isDomainError(result)) throw new Error("Setup failed");
return result;
}
describe("rollInitiativeUseCase", () => {
it("returns domain error when combatant not found", () => {
const enc = createEncounter([]);
if (isDomainError(enc)) throw new Error("Setup failed");
const store = stubEncounterStore(enc);
const result = rollInitiativeUseCase(
store,
combatantId("unknown"),
[10],
() => undefined,
);
expectError(result);
expect(result.code).toBe("combatant-not-found");
expect(store.saved).toBeNull();
});
it("returns domain error when combatant has no creature link", () => {
const enc = createEncounter([]);
if (isDomainError(enc)) throw new Error("Setup failed");
const store1 = stubEncounterStore(enc);
addCombatantUseCase(store1, combatantId("Fighter"), "Fighter");
const store = stubEncounterStore(requireSaved(store1.saved));
const result = rollInitiativeUseCase(
store,
combatantId("Fighter"),
[10],
() => undefined,
);
expectError(result);
expect(result.code).toBe("no-creature-link");
expect(store.saved).toBeNull();
});
it("returns domain error when creature not found in getter", () => {
const enc = encounterWithCreatureLink("Goblin", GOBLIN_ID);
const store = stubEncounterStore(enc);
const result = rollInitiativeUseCase(
store,
combatantId("Goblin"),
[10],
() => undefined,
);
expectError(result);
expect(result.code).toBe("creature-not-found");
expect(store.saved).toBeNull();
});
it("calculates initiative from creature and saves", () => {
const creature = makeCreature();
const enc = encounterWithCreatureLink("Goblin", GOBLIN_ID);
const store = stubEncounterStore(enc);
// Dex 14 -> modifier +2, CR 1/4 -> PB 2, initiativeProficiency 0
// So initiative modifier = 2 + 0*2 = 2
// Roll 10 + modifier 2 = 12
const result = rollInitiativeUseCase(
store,
combatantId("Goblin"),
[10],
(id) => (id === GOBLIN_ID ? creature : undefined),
);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].initiative).toBe(12);
});
it("uses higher roll with advantage", () => {
const creature = makeCreature();
const enc = encounterWithCreatureLink("Goblin", GOBLIN_ID);
const store = stubEncounterStore(enc);
// Dex 14 -> modifier +2, advantage picks max(5, 15) = 15, 15 + 2 = 17
const result = rollInitiativeUseCase(
store,
combatantId("Goblin"),
[5, 15],
(id) => (id === GOBLIN_ID ? creature : undefined),
"advantage",
);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].initiative).toBe(17);
});
it("uses lower roll with disadvantage", () => {
const creature = makeCreature();
const enc = encounterWithCreatureLink("Goblin", GOBLIN_ID);
const store = stubEncounterStore(enc);
// Dex 14 -> modifier +2, disadvantage picks min(5, 15) = 5, 5 + 2 = 7
const result = rollInitiativeUseCase(
store,
combatantId("Goblin"),
[5, 15],
(id) => (id === GOBLIN_ID ? creature : undefined),
"disadvantage",
);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].initiative).toBe(7);
});
it("applies initiative proficiency bonus correctly", () => {
// CR 5 -> PB 3, dex 16 -> mod +3, initiativeProficiency 1
// modifier = 3 + 1*3 = 6, roll 8 + 6 = 14
const creature = makeCreature({
abilities: {
str: 10,
dex: 16,
con: 10,
int: 10,
wis: 10,
cha: 10,
},
cr: "5",
initiativeProficiency: 1,
});
const enc = encounterWithCreatureLink("Monster", GOBLIN_ID);
const store = stubEncounterStore(enc);
const result = rollInitiativeUseCase(
store,
combatantId("Monster"),
[8],
(id) => (id === GOBLIN_ID ? creature : undefined),
);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].initiative).toBe(14);
});
});

View File

@@ -0,0 +1,388 @@
import {
type ConditionId,
combatantId,
createEncounter,
isDomainError,
playerCharacterId,
} from "@initiative/domain";
import { describe, expect, it } from "vitest";
import { addCombatantUseCase } from "../add-combatant-use-case.js";
import { adjustHpUseCase } from "../adjust-hp-use-case.js";
import { advanceTurnUseCase } from "../advance-turn-use-case.js";
import { clearEncounterUseCase } from "../clear-encounter-use-case.js";
import { createPlayerCharacterUseCase } from "../create-player-character-use-case.js";
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 { 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 { toggleConcentrationUseCase } from "../toggle-concentration-use-case.js";
import { toggleConditionUseCase } from "../toggle-condition-use-case.js";
import {
requireSaved,
stubEncounterStore,
stubPlayerCharacterStore,
} from "./helpers.js";
const ID_A = combatantId("a");
function emptyEncounter() {
const result = createEncounter([]);
if (isDomainError(result)) throw new Error("Test setup failed");
return result;
}
function encounterWith(...names: string[]) {
let enc = emptyEncounter();
for (const name of names) {
const id = combatantId(name);
const store = stubEncounterStore(enc);
const result = addCombatantUseCase(store, id, name);
if (isDomainError(result)) throw new Error(`Setup failed: ${name}`);
enc = requireSaved(store.saved);
}
return enc;
}
function encounterWithHp(name: string, maxHp: number) {
const enc = encounterWith(name);
const store = stubEncounterStore(enc);
const id = combatantId(name);
setHpUseCase(store, id, maxHp);
return requireSaved(store.saved);
}
function createPc(name: string) {
const store = stubPlayerCharacterStore([]);
const id = playerCharacterId("pc-1");
createPlayerCharacterUseCase(store, id, name, 15, 40, undefined, undefined);
return { id, characters: requireSaved(store.saved) };
}
describe("addCombatantUseCase", () => {
it("adds a combatant and saves", () => {
const store = stubEncounterStore(emptyEncounter());
const result = addCombatantUseCase(store, ID_A, "Goblin");
expect(isDomainError(result)).toBe(false);
const saved = requireSaved(store.saved);
expect(saved.combatants).toHaveLength(1);
expect(saved.combatants[0].name).toBe("Goblin");
});
it("returns domain error for empty name", () => {
const store = stubEncounterStore(emptyEncounter());
const result = addCombatantUseCase(store, ID_A, "");
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("adjustHpUseCase", () => {
it("adjusts HP and saves", () => {
const enc = encounterWithHp("Goblin", 10);
const store = stubEncounterStore(enc);
const result = adjustHpUseCase(store, combatantId("Goblin"), -3);
expect(isDomainError(result)).toBe(false);
const saved = requireSaved(store.saved);
expect(saved.combatants[0].currentHp).toBe(7);
});
it("returns domain error for unknown combatant", () => {
const store = stubEncounterStore(emptyEncounter());
const result = adjustHpUseCase(store, ID_A, -5);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("advanceTurnUseCase", () => {
it("advances turn and saves", () => {
const enc = encounterWith("A", "B");
const store = stubEncounterStore(enc);
const result = advanceTurnUseCase(store);
expect(isDomainError(result)).toBe(false);
const saved = requireSaved(store.saved);
expect(saved.activeIndex).toBe(1);
});
it("returns domain error on empty encounter", () => {
const store = stubEncounterStore(emptyEncounter());
const result = advanceTurnUseCase(store);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("clearEncounterUseCase", () => {
it("clears encounter and saves", () => {
const enc = encounterWith("Goblin");
const store = stubEncounterStore(enc);
const result = clearEncounterUseCase(store);
expect(isDomainError(result)).toBe(false);
const saved = requireSaved(store.saved);
expect(saved.combatants).toHaveLength(0);
});
});
describe("editCombatantUseCase", () => {
it("edits combatant name and saves", () => {
const enc = encounterWith("Goblin");
const store = stubEncounterStore(enc);
const result = editCombatantUseCase(
store,
combatantId("Goblin"),
"Hobgoblin",
);
expect(isDomainError(result)).toBe(false);
const saved = requireSaved(store.saved);
expect(saved.combatants[0].name).toBe("Hobgoblin");
});
it("returns domain error for unknown combatant", () => {
const store = stubEncounterStore(emptyEncounter());
const result = editCombatantUseCase(store, ID_A, "X");
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("removeCombatantUseCase", () => {
it("removes combatant and saves", () => {
const enc = encounterWith("Goblin");
const store = stubEncounterStore(enc);
const result = removeCombatantUseCase(store, combatantId("Goblin"));
expect(isDomainError(result)).toBe(false);
const saved = requireSaved(store.saved);
expect(saved.combatants).toHaveLength(0);
});
it("returns domain error for unknown combatant", () => {
const store = stubEncounterStore(emptyEncounter());
const result = removeCombatantUseCase(store, ID_A);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("retreatTurnUseCase", () => {
it("retreats turn and saves", () => {
const enc = encounterWith("A", "B");
const store1 = stubEncounterStore(enc);
advanceTurnUseCase(store1);
const store = stubEncounterStore(requireSaved(store1.saved));
const result = retreatTurnUseCase(store);
expect(isDomainError(result)).toBe(false);
expect(store.saved).not.toBeNull();
});
it("returns domain error on empty encounter", () => {
const store = stubEncounterStore(emptyEncounter());
const result = retreatTurnUseCase(store);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("setAcUseCase", () => {
it("sets AC and saves", () => {
const enc = encounterWith("Goblin");
const store = stubEncounterStore(enc);
const result = setAcUseCase(store, combatantId("Goblin"), 15);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].ac).toBe(15);
});
it("returns domain error for unknown combatant", () => {
const store = stubEncounterStore(emptyEncounter());
const result = setAcUseCase(store, ID_A, 15);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("setHpUseCase", () => {
it("sets max HP and saves", () => {
const enc = encounterWith("Goblin");
const store = stubEncounterStore(enc);
const result = setHpUseCase(store, combatantId("Goblin"), 20);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].maxHp).toBe(20);
});
it("returns domain error for unknown combatant", () => {
const store = stubEncounterStore(emptyEncounter());
const result = setHpUseCase(store, ID_A, 20);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("setInitiativeUseCase", () => {
it("sets initiative and saves", () => {
const enc = encounterWith("Goblin");
const store = stubEncounterStore(enc);
const result = setInitiativeUseCase(store, combatantId("Goblin"), 15);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].initiative).toBe(15);
});
it("returns domain error for unknown combatant", () => {
const store = stubEncounterStore(emptyEncounter());
const result = setInitiativeUseCase(store, ID_A, 15);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("toggleConcentrationUseCase", () => {
it("toggles concentration and saves", () => {
const enc = encounterWith("Wizard");
const store = stubEncounterStore(enc);
const result = toggleConcentrationUseCase(store, combatantId("Wizard"));
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].isConcentrating).toBe(true);
});
it("returns domain error for unknown combatant", () => {
const store = stubEncounterStore(emptyEncounter());
const result = toggleConcentrationUseCase(store, ID_A);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("toggleConditionUseCase", () => {
it("toggles condition and saves", () => {
const enc = encounterWith("Goblin");
const store = stubEncounterStore(enc);
const result = toggleConditionUseCase(
store,
combatantId("Goblin"),
"blinded" as ConditionId,
);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved).combatants[0].conditions).toContain(
"blinded",
);
});
it("returns domain error for unknown combatant", () => {
const store = stubEncounterStore(emptyEncounter());
const result = toggleConditionUseCase(
store,
ID_A,
"blinded" as ConditionId,
);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("createPlayerCharacterUseCase", () => {
it("creates a player character and saves", () => {
const store = stubPlayerCharacterStore([]);
const id = playerCharacterId("pc-1");
const result = createPlayerCharacterUseCase(
store,
id,
"Gandalf",
15,
40,
undefined,
undefined,
);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved)).toHaveLength(1);
});
it("returns domain error for invalid input", () => {
const store = stubPlayerCharacterStore([]);
const id = playerCharacterId("pc-1");
const result = createPlayerCharacterUseCase(
store,
id,
"",
15,
40,
undefined,
undefined,
);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("deletePlayerCharacterUseCase", () => {
it("deletes a player character and saves", () => {
const { id, characters } = createPc("Gandalf");
const store = stubPlayerCharacterStore(characters);
const result = deletePlayerCharacterUseCase(store, id);
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved)).toHaveLength(0);
});
it("returns domain error for unknown character", () => {
const store = stubPlayerCharacterStore([]);
const result = deletePlayerCharacterUseCase(
store,
playerCharacterId("unknown"),
);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});
describe("editPlayerCharacterUseCase", () => {
it("edits a player character and saves", () => {
const { id, characters } = createPc("Gandalf");
const store = stubPlayerCharacterStore(characters);
const result = editPlayerCharacterUseCase(store, id, {
name: "Gandalf the White",
});
expect(isDomainError(result)).toBe(false);
expect(requireSaved(store.saved)[0].name).toBe("Gandalf the White");
});
it("returns domain error for unknown character", () => {
const store = stubPlayerCharacterStore([]);
const result = editPlayerCharacterUseCase(
store,
playerCharacterId("unknown"),
{ name: "X" },
);
expect(isDomainError(result)).toBe(true);
expect(store.saved).toBeNull();
});
});

View File

@@ -21,5 +21,6 @@ export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
export { setAcUseCase } from "./set-ac-use-case.js"; export { setAcUseCase } from "./set-ac-use-case.js";
export { setHpUseCase } from "./set-hp-use-case.js"; export { setHpUseCase } from "./set-hp-use-case.js";
export { setInitiativeUseCase } from "./set-initiative-use-case.js"; export { setInitiativeUseCase } from "./set-initiative-use-case.js";
export { setTempHpUseCase } from "./set-temp-hp-use-case.js";
export { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js"; export { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js";
export { toggleConditionUseCase } from "./toggle-condition-use-case.js"; export { toggleConditionUseCase } from "./toggle-condition-use-case.js";

View File

@@ -5,7 +5,9 @@ import {
type DomainError, type DomainError,
type DomainEvent, type DomainEvent,
isDomainError, isDomainError,
type RollMode,
rollInitiative, rollInitiative,
selectRoll,
setInitiative, setInitiative,
} from "@initiative/domain"; } from "@initiative/domain";
import type { EncounterStore } from "./ports.js"; import type { EncounterStore } from "./ports.js";
@@ -19,6 +21,7 @@ export function rollAllInitiativeUseCase(
store: EncounterStore, store: EncounterStore,
rollDice: () => number, rollDice: () => number,
getCreature: (id: CreatureId) => Creature | undefined, getCreature: (id: CreatureId) => Creature | undefined,
mode: RollMode = "normal",
): RollAllResult | DomainError { ): RollAllResult | DomainError {
let encounter = store.get(); let encounter = store.get();
const allEvents: DomainEvent[] = []; const allEvents: DomainEvent[] = [];
@@ -39,7 +42,10 @@ export function rollAllInitiativeUseCase(
cr: creature.cr, cr: creature.cr,
initiativeProficiency: creature.initiativeProficiency, initiativeProficiency: creature.initiativeProficiency,
}); });
const value = rollInitiative(rollDice(), modifier); const roll1 = rollDice();
const effectiveRoll =
mode === "normal" ? roll1 : selectRoll(roll1, rollDice(), mode);
const value = rollInitiative(effectiveRoll, modifier);
if (isDomainError(value)) { if (isDomainError(value)) {
return value; return value;

View File

@@ -6,7 +6,9 @@ import {
type DomainError, type DomainError,
type DomainEvent, type DomainEvent,
isDomainError, isDomainError,
type RollMode,
rollInitiative, rollInitiative,
selectRoll,
setInitiative, setInitiative,
} from "@initiative/domain"; } from "@initiative/domain";
import type { EncounterStore } from "./ports.js"; import type { EncounterStore } from "./ports.js";
@@ -14,8 +16,9 @@ import type { EncounterStore } from "./ports.js";
export function rollInitiativeUseCase( export function rollInitiativeUseCase(
store: EncounterStore, store: EncounterStore,
combatantId: CombatantId, combatantId: CombatantId,
diceRoll: number, diceRolls: readonly [number, ...number[]],
getCreature: (id: CreatureId) => Creature | undefined, getCreature: (id: CreatureId) => Creature | undefined,
mode: RollMode = "normal",
): DomainEvent[] | DomainError { ): DomainEvent[] | DomainError {
const encounter = store.get(); const encounter = store.get();
const combatant = encounter.combatants.find((c) => c.id === combatantId); const combatant = encounter.combatants.find((c) => c.id === combatantId);
@@ -50,7 +53,11 @@ export function rollInitiativeUseCase(
cr: creature.cr, cr: creature.cr,
initiativeProficiency: creature.initiativeProficiency, initiativeProficiency: creature.initiativeProficiency,
}); });
const value = rollInitiative(diceRoll, modifier); const effectiveRoll =
mode === "normal"
? diceRolls[0]
: selectRoll(diceRolls[0], diceRolls[1] ?? diceRolls[0], mode);
const value = rollInitiative(effectiveRoll, modifier);
if (isDomainError(value)) { if (isDomainError(value)) {
return value; return value;

View File

@@ -0,0 +1,24 @@
import {
type CombatantId,
type DomainError,
type DomainEvent,
isDomainError,
setTempHp,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.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;
}

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { addCombatant } from "../add-combatant.js"; import { addCombatant } from "../add-combatant.js";
import type { Combatant, Encounter } from "../types.js"; import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js"; import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
// --- Helpers --- // --- Helpers ---
@@ -112,20 +113,14 @@ describe("addCombatant", () => {
const e = enc([A, B]); const e = enc([A, B]);
const result = addCombatant(e, combatantId("x"), ""); const result = addCombatant(e, combatantId("x"), "");
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-name");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
}); });
it("scenario 6: whitespace-only name returns error", () => { it("scenario 6: whitespace-only name returns error", () => {
const e = enc([A, B]); const e = enc([A, B]);
const result = addCombatant(e, combatantId("x"), " "); const result = addCombatant(e, combatantId("x"), " ");
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-name");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
}); });
}); });
@@ -146,12 +141,10 @@ describe("addCombatant", () => {
for (const e of scenarios) { for (const e of scenarios) {
const result = successResult(e, "new", "New"); const result = successResult(e, "new", "New");
const { combatants, activeIndex } = result.encounter; const { combatants, activeIndex } = result.encounter;
if (combatants.length > 0) { // After adding a combatant, list is always non-empty
expect(combatants.length).toBeGreaterThan(0);
expect(activeIndex).toBeGreaterThanOrEqual(0); expect(activeIndex).toBeGreaterThanOrEqual(0);
expect(activeIndex).toBeLessThan(combatants.length); expect(activeIndex).toBeLessThan(combatants.length);
} else {
expect(activeIndex).toBe(0);
}
} }
}); });
@@ -188,7 +181,7 @@ describe("addCombatant", () => {
it("INV-7: new combatant is always appended at the end", () => { it("INV-7: new combatant is always appended at the end", () => {
const e = enc([A, B]); const e = enc([A, B]);
const { encounter } = successResult(e, "C", "C"); const { encounter } = successResult(e, "C", "C");
expect(encounter.combatants[encounter.combatants.length - 1]).toEqual({ expect(encounter.combatants.at(-1)).toEqual({
id: combatantId("C"), id: combatantId("C"),
name: "C", name: "C",
}); });

View File

@@ -2,15 +2,22 @@ import { describe, expect, it } from "vitest";
import { adjustHp } from "../adjust-hp.js"; import { adjustHp } from "../adjust-hp.js";
import type { Combatant, Encounter } from "../types.js"; import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js"; import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
function makeCombatant( function makeCombatant(
name: string, name: string,
opts?: { maxHp: number; currentHp: number }, opts?: { maxHp: number; currentHp: number; tempHp?: number },
): Combatant { ): Combatant {
return { return {
id: combatantId(name), id: combatantId(name),
name, name,
...(opts ? { maxHp: opts.maxHp, currentHp: opts.currentHp } : {}), ...(opts
? {
maxHp: opts.maxHp,
currentHp: opts.currentHp,
tempHp: opts.tempHp,
}
: {}),
}; };
} }
@@ -101,37 +108,25 @@ describe("adjustHp", () => {
it("returns error for nonexistent combatant", () => { it("returns error for nonexistent combatant", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]); const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const result = adjustHp(e, combatantId("Z"), -1); const result = adjustHp(e, combatantId("Z"), -1);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "combatant-not-found");
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
}); });
it("returns error when combatant has no HP tracking", () => { it("returns error when combatant has no HP tracking", () => {
const e = enc([makeCombatant("A")]); const e = enc([makeCombatant("A")]);
const result = adjustHp(e, combatantId("A"), -1); const result = adjustHp(e, combatantId("A"), -1);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "no-hp-tracking");
if (isDomainError(result)) {
expect(result.code).toBe("no-hp-tracking");
}
}); });
it("returns error for zero delta", () => { it("returns error for zero delta", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]); const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const result = adjustHp(e, combatantId("A"), 0); const result = adjustHp(e, combatantId("A"), 0);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "zero-delta");
if (isDomainError(result)) {
expect(result.code).toBe("zero-delta");
}
}); });
it("returns error for non-integer delta", () => { it("returns error for non-integer delta", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]); const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const result = adjustHp(e, combatantId("A"), 1.5); const result = adjustHp(e, combatantId("A"), 1.5);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-delta");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-delta");
}
}); });
}); });
@@ -163,4 +158,96 @@ describe("adjustHp", () => {
expect(encounter.combatants[0].currentHp).toBe(5); expect(encounter.combatants[0].currentHp).toBe(5);
}); });
}); });
describe("temporary HP absorption", () => {
it("damage fully absorbed by temp HP — currentHp unchanged", () => {
const e = enc([
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 8 }),
]);
const { encounter } = successResult(e, "A", -5);
expect(encounter.combatants[0].currentHp).toBe(15);
expect(encounter.combatants[0].tempHp).toBe(3);
});
it("damage partially absorbed by temp HP — overflow reduces currentHp", () => {
const e = enc([
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 3 }),
]);
const { encounter } = successResult(e, "A", -10);
expect(encounter.combatants[0].tempHp).toBeUndefined();
expect(encounter.combatants[0].currentHp).toBe(8);
});
it("damage exceeding both temp HP and currentHp — both reach minimum", () => {
const e = enc([
makeCombatant("A", { maxHp: 20, currentHp: 5, tempHp: 3 }),
]);
const { encounter } = successResult(e, "A", -50);
expect(encounter.combatants[0].tempHp).toBeUndefined();
expect(encounter.combatants[0].currentHp).toBe(0);
});
it("healing does not restore temp HP", () => {
const e = enc([
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 3 }),
]);
const { encounter } = successResult(e, "A", 5);
expect(encounter.combatants[0].currentHp).toBe(15);
expect(encounter.combatants[0].tempHp).toBe(3);
});
it("temp HP cleared to undefined when fully depleted", () => {
const e = enc([
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 5 }),
]);
const { encounter } = successResult(e, "A", -5);
expect(encounter.combatants[0].tempHp).toBeUndefined();
expect(encounter.combatants[0].currentHp).toBe(15);
});
it("emits only TempHpSet when damage fully absorbed", () => {
const e = enc([
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 8 }),
]);
const { events } = successResult(e, "A", -3);
expect(events).toEqual([
{
type: "TempHpSet",
combatantId: combatantId("A"),
previousTempHp: 8,
newTempHp: 5,
},
]);
});
it("emits both TempHpSet and CurrentHpAdjusted when damage overflows", () => {
const e = enc([
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 3 }),
]);
const { events } = successResult(e, "A", -10);
expect(events).toHaveLength(2);
expect(events[0]).toEqual({
type: "TempHpSet",
combatantId: combatantId("A"),
previousTempHp: 3,
newTempHp: undefined,
});
expect(events[1]).toEqual({
type: "CurrentHpAdjusted",
combatantId: combatantId("A"),
previousHp: 15,
newHp: 8,
delta: -10,
});
});
it("damage with no temp HP works as before", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
const { encounter, events } = successResult(e, "A", -5);
expect(encounter.combatants[0].currentHp).toBe(10);
expect(encounter.combatants[0].tempHp).toBeUndefined();
expect(events).toHaveLength(1);
expect(events[0].type).toBe("CurrentHpAdjusted");
});
});
}); });

View File

@@ -7,6 +7,7 @@ import {
createEncounter, createEncounter,
type Encounter, type Encounter,
} from "../types.js"; } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
// --- Helpers --- // --- Helpers ---
@@ -150,10 +151,7 @@ describe("advanceTurn", () => {
}; };
const result = advanceTurn(enc); const result = advanceTurn(enc);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-encounter");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-encounter");
}
}); });
it("scenario 8: three advances on [A,B,C] completes a full round cycle", () => { it("scenario 8: three advances on [A,B,C] completes a full round cycle", () => {

View File

@@ -3,6 +3,7 @@ import { createPlayerCharacter } from "../create-player-character.js";
import type { PlayerCharacter } from "../player-character-types.js"; import type { PlayerCharacter } from "../player-character-types.js";
import { playerCharacterId } from "../player-character-types.js"; import { playerCharacterId } from "../player-character-types.js";
import { isDomainError } from "../types.js"; import { isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
const id = playerCharacterId("pc-1"); const id = playerCharacterId("pc-1");
@@ -80,10 +81,7 @@ describe("createPlayerCharacter", () => {
it("rejects empty name", () => { it("rejects empty name", () => {
const result = createPlayerCharacter([], id, "", 10, 50, "blue", "sword"); const result = createPlayerCharacter([], id, "", 10, 50, "blue", "sword");
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-name");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
}); });
it("rejects whitespace-only name", () => { it("rejects whitespace-only name", () => {
@@ -96,10 +94,7 @@ describe("createPlayerCharacter", () => {
"blue", "blue",
"sword", "sword",
); );
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-name");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
}); });
it("rejects negative AC", () => { it("rejects negative AC", () => {
@@ -112,10 +107,7 @@ describe("createPlayerCharacter", () => {
"blue", "blue",
"sword", "sword",
); );
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-ac");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
}); });
it("rejects non-integer AC", () => { it("rejects non-integer AC", () => {
@@ -128,10 +120,7 @@ describe("createPlayerCharacter", () => {
"blue", "blue",
"sword", "sword",
); );
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-ac");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
}); });
it("allows AC of 0", () => { it("allows AC of 0", () => {
@@ -149,10 +138,7 @@ describe("createPlayerCharacter", () => {
"blue", "blue",
"sword", "sword",
); );
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-max-hp");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
}); });
it("rejects negative maxHp", () => { it("rejects negative maxHp", () => {
@@ -165,10 +151,7 @@ describe("createPlayerCharacter", () => {
"blue", "blue",
"sword", "sword",
); );
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-max-hp");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
}); });
it("rejects non-integer maxHp", () => { it("rejects non-integer maxHp", () => {
@@ -181,10 +164,7 @@ describe("createPlayerCharacter", () => {
"blue", "blue",
"sword", "sword",
); );
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-max-hp");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
}); });
it("rejects invalid color", () => { it("rejects invalid color", () => {
@@ -197,10 +177,7 @@ describe("createPlayerCharacter", () => {
"neon", "neon",
"sword", "sword",
); );
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-color");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-color");
}
}); });
it("rejects invalid icon", () => { it("rejects invalid icon", () => {
@@ -213,10 +190,7 @@ describe("createPlayerCharacter", () => {
"blue", "blue",
"banana", "banana",
); );
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-icon");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-icon");
}
}); });
it("allows undefined color", () => { it("allows undefined color", () => {

View File

@@ -3,6 +3,7 @@ import { deletePlayerCharacter } from "../delete-player-character.js";
import type { PlayerCharacter } from "../player-character-types.js"; import type { PlayerCharacter } from "../player-character-types.js";
import { playerCharacterId } from "../player-character-types.js"; import { playerCharacterId } from "../player-character-types.js";
import { isDomainError } from "../types.js"; import { isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
const id1 = playerCharacterId("pc-1"); const id1 = playerCharacterId("pc-1");
const id2 = playerCharacterId("pc-2"); const id2 = playerCharacterId("pc-2");
@@ -28,10 +29,7 @@ describe("deletePlayerCharacter", () => {
it("returns error for not-found id", () => { it("returns error for not-found id", () => {
const result = deletePlayerCharacter([makePC()], id2); const result = deletePlayerCharacter([makePC()], id2);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "player-character-not-found");
if (isDomainError(result)) {
expect(result.code).toBe("player-character-not-found");
}
}); });
it("emits PlayerCharacterDeleted event", () => { it("emits PlayerCharacterDeleted event", () => {

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { editCombatant } from "../edit-combatant.js"; import { editCombatant } from "../edit-combatant.js";
import type { Combatant, Encounter } from "../types.js"; import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js"; import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
// --- Helpers --- // --- Helpers ---
@@ -124,40 +125,28 @@ describe("editCombatant", () => {
const e = enc([Alice, Bob]); const e = enc([Alice, Bob]);
const result = editCombatant(e, combatantId("nonexistent"), "NewName"); const result = editCombatant(e, combatantId("nonexistent"), "NewName");
expect(isDomainError(result)).toBe(true); expectDomainError(result, "combatant-not-found");
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
}); });
it("empty name returns invalid-name error", () => { it("empty name returns invalid-name error", () => {
const e = enc([Alice, Bob]); const e = enc([Alice, Bob]);
const result = editCombatant(e, combatantId("Alice"), ""); const result = editCombatant(e, combatantId("Alice"), "");
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-name");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
}); });
it("whitespace-only name returns invalid-name error", () => { it("whitespace-only name returns invalid-name error", () => {
const e = enc([Alice, Bob]); const e = enc([Alice, Bob]);
const result = editCombatant(e, combatantId("Alice"), " "); const result = editCombatant(e, combatantId("Alice"), " ");
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-name");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
}); });
it("empty encounter returns combatant-not-found for any id", () => { it("empty encounter returns combatant-not-found for any id", () => {
const e = enc([]); const e = enc([]);
const result = editCombatant(e, combatantId("any"), "Name"); const result = editCombatant(e, combatantId("any"), "Name");
expect(isDomainError(result)).toBe(true); expectDomainError(result, "combatant-not-found");
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
}); });
}); });
}); });

View File

@@ -3,6 +3,7 @@ import { editPlayerCharacter } from "../edit-player-character.js";
import type { PlayerCharacter } from "../player-character-types.js"; import type { PlayerCharacter } from "../player-character-types.js";
import { playerCharacterId } from "../player-character-types.js"; import { playerCharacterId } from "../player-character-types.js";
import { isDomainError } from "../types.js"; import { isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
const id = playerCharacterId("pc-1"); const id = playerCharacterId("pc-1");
@@ -42,50 +43,32 @@ describe("editPlayerCharacter", () => {
playerCharacterId("pc-999"), playerCharacterId("pc-999"),
{ name: "Nope" }, { name: "Nope" },
); );
expect(isDomainError(result)).toBe(true); expectDomainError(result, "player-character-not-found");
if (isDomainError(result)) {
expect(result.code).toBe("player-character-not-found");
}
}); });
it("rejects empty name", () => { it("rejects empty name", () => {
const result = editPlayerCharacter([makePC()], id, { name: "" }); const result = editPlayerCharacter([makePC()], id, { name: "" });
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-name");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
}); });
it("rejects invalid AC", () => { it("rejects invalid AC", () => {
const result = editPlayerCharacter([makePC()], id, { ac: -1 }); const result = editPlayerCharacter([makePC()], id, { ac: -1 });
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-ac");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
}); });
it("rejects invalid maxHp", () => { it("rejects invalid maxHp", () => {
const result = editPlayerCharacter([makePC()], id, { maxHp: 0 }); const result = editPlayerCharacter([makePC()], id, { maxHp: 0 });
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-max-hp");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
}); });
it("rejects invalid color", () => { it("rejects invalid color", () => {
const result = editPlayerCharacter([makePC()], id, { color: "neon" }); const result = editPlayerCharacter([makePC()], id, { color: "neon" });
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-color");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-color");
}
}); });
it("rejects invalid icon", () => { it("rejects invalid icon", () => {
const result = editPlayerCharacter([makePC()], id, { icon: "banana" }); const result = editPlayerCharacter([makePC()], id, { icon: "banana" });
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-icon");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-icon");
}
}); });
it("returns error when no fields changed", () => { it("returns error when no fields changed", () => {
@@ -94,10 +77,7 @@ describe("editPlayerCharacter", () => {
name: pc.name, name: pc.name,
ac: pc.ac, ac: pc.ac,
}); });
expect(isDomainError(result)).toBe(true); expectDomainError(result, "no-changes");
if (isDomainError(result)) {
expect(result.code).toBe("no-changes");
}
}); });
it("emits exactly one event on success", () => { it("emits exactly one event on success", () => {

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { removeCombatant } from "../remove-combatant.js"; import { removeCombatant } from "../remove-combatant.js";
import type { Combatant, Encounter } from "../types.js"; import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js"; import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
// --- Helpers --- // --- Helpers ---
@@ -92,10 +93,7 @@ describe("removeCombatant", () => {
const e = enc([A, B], 0, 1); const e = enc([A, B], 0, 1);
const result = removeCombatant(e, combatantId("nonexistent")); const result = removeCombatant(e, combatantId("nonexistent"));
expect(isDomainError(result)).toBe(true); expectDomainError(result, "combatant-not-found");
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
}); });
}); });

View File

@@ -8,6 +8,7 @@ import {
type Encounter, type Encounter,
isDomainError, isDomainError,
} from "../types.js"; } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
// --- Helpers --- // --- Helpers ---
@@ -83,10 +84,7 @@ describe("retreatTurn", () => {
const enc = encounter([A, B, C], 0, 1); const enc = encounter([A, B, C], 0, 1);
const result = retreatTurn(enc); const result = retreatTurn(enc);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "no-previous-turn");
if (isDomainError(result)) {
expect(result.code).toBe("no-previous-turn");
}
}); });
it("scenario 4: single-combatant retreat — wraps to same combatant, decrements round", () => { it("scenario 4: single-combatant retreat — wraps to same combatant, decrements round", () => {
@@ -117,10 +115,7 @@ describe("retreatTurn", () => {
}; };
const result = retreatTurn(enc); const result = retreatTurn(enc);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-encounter");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-encounter");
}
}); });
}); });

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { rollInitiative } from "../roll-initiative.js"; import { rollInitiative, selectRoll } from "../roll-initiative.js";
import { isDomainError } from "../types.js"; import { isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
describe("rollInitiative", () => { describe("rollInitiative", () => {
describe("valid rolls", () => { describe("valid rolls", () => {
@@ -32,18 +33,12 @@ describe("rollInitiative", () => {
describe("invalid dice rolls", () => { describe("invalid dice rolls", () => {
it("rejects 0", () => { it("rejects 0", () => {
const result = rollInitiative(0, 5); const result = rollInitiative(0, 5);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-dice-roll");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-dice-roll");
}
}); });
it("rejects 21", () => { it("rejects 21", () => {
const result = rollInitiative(21, 5); const result = rollInitiative(21, 5);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-dice-roll");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-dice-roll");
}
}); });
it("rejects non-integer (3.5)", () => { it("rejects non-integer (3.5)", () => {
@@ -68,3 +63,31 @@ describe("rollInitiative", () => {
}); });
}); });
}); });
describe("selectRoll", () => {
it("normal mode returns the first roll", () => {
expect(selectRoll(8, 15, "normal")).toBe(8);
});
it("advantage returns the higher roll", () => {
expect(selectRoll(8, 15, "advantage")).toBe(15);
});
it("advantage returns the higher roll (reversed)", () => {
expect(selectRoll(15, 8, "advantage")).toBe(15);
});
it("disadvantage returns the lower roll", () => {
expect(selectRoll(8, 15, "disadvantage")).toBe(8);
});
it("disadvantage returns the lower roll (reversed)", () => {
expect(selectRoll(15, 8, "disadvantage")).toBe(8);
});
it("equal rolls return the same value for all modes", () => {
expect(selectRoll(12, 12, "normal")).toBe(12);
expect(selectRoll(12, 12, "advantage")).toBe(12);
expect(selectRoll(12, 12, "disadvantage")).toBe(12);
});
});

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { setAc } from "../set-ac.js"; import { setAc } from "../set-ac.js";
import type { Combatant, Encounter } from "../types.js"; import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js"; import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
function makeCombatant(name: string, ac?: number): Combatant { function makeCombatant(name: string, ac?: number): Combatant {
return ac === undefined return ac === undefined
@@ -67,30 +68,21 @@ describe("setAc", () => {
const e = enc([makeCombatant("A")]); const e = enc([makeCombatant("A")]);
const result = setAc(e, combatantId("nonexistent"), 10); const result = setAc(e, combatantId("nonexistent"), 10);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "combatant-not-found");
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
}); });
it("returns error for negative AC", () => { it("returns error for negative AC", () => {
const e = enc([makeCombatant("A")]); const e = enc([makeCombatant("A")]);
const result = setAc(e, combatantId("A"), -1); const result = setAc(e, combatantId("A"), -1);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-ac");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
}); });
it("returns error for non-integer AC", () => { it("returns error for non-integer AC", () => {
const e = enc([makeCombatant("A")]); const e = enc([makeCombatant("A")]);
const result = setAc(e, combatantId("A"), 3.5); const result = setAc(e, combatantId("A"), 3.5);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-ac");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
}); });
it("returns error for NaN", () => { it("returns error for NaN", () => {

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { setHp } from "../set-hp.js"; import { setHp } from "../set-hp.js";
import type { Combatant, Encounter } from "../types.js"; import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js"; import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
function makeCombatant( function makeCombatant(
name: string, name: string,
@@ -10,9 +11,9 @@ function makeCombatant(
return { return {
id: combatantId(name), id: combatantId(name),
name, name,
...(opts?.maxHp !== undefined ...(opts?.maxHp === undefined
? { maxHp: opts.maxHp, currentHp: opts.currentHp ?? opts.maxHp } ? {}
: {}), : { maxHp: opts.maxHp, currentHp: opts.currentHp ?? opts.maxHp }),
}; };
} }
@@ -68,6 +69,34 @@ describe("setHp", () => {
expect(encounter.combatants[0].maxHp).toBeUndefined(); expect(encounter.combatants[0].maxHp).toBeUndefined();
expect(encounter.combatants[0].currentHp).toBeUndefined(); expect(encounter.combatants[0].currentHp).toBeUndefined();
}); });
it("clears tempHp when maxHp is cleared", () => {
const e = enc([
{
id: combatantId("A"),
name: "A",
maxHp: 20,
currentHp: 15,
tempHp: 5,
},
]);
const { encounter } = successResult(e, "A", undefined);
expect(encounter.combatants[0].tempHp).toBeUndefined();
});
it("preserves tempHp when maxHp is updated", () => {
const e = enc([
{
id: combatantId("A"),
name: "A",
maxHp: 20,
currentHp: 15,
tempHp: 5,
},
]);
const { encounter } = successResult(e, "A", 25);
expect(encounter.combatants[0].tempHp).toBe(5);
});
}); });
describe("invariants", () => { describe("invariants", () => {
@@ -116,37 +145,25 @@ describe("setHp", () => {
it("returns error for nonexistent combatant", () => { it("returns error for nonexistent combatant", () => {
const e = enc([makeCombatant("A")]); const e = enc([makeCombatant("A")]);
const result = setHp(e, combatantId("Z"), 10); const result = setHp(e, combatantId("Z"), 10);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "combatant-not-found");
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
}); });
it("rejects maxHp of 0", () => { it("rejects maxHp of 0", () => {
const e = enc([makeCombatant("A")]); const e = enc([makeCombatant("A")]);
const result = setHp(e, combatantId("A"), 0); const result = setHp(e, combatantId("A"), 0);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-max-hp");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
}); });
it("rejects negative maxHp", () => { it("rejects negative maxHp", () => {
const e = enc([makeCombatant("A")]); const e = enc([makeCombatant("A")]);
const result = setHp(e, combatantId("A"), -5); const result = setHp(e, combatantId("A"), -5);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-max-hp");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
}); });
it("rejects non-integer maxHp", () => { it("rejects non-integer maxHp", () => {
const e = enc([makeCombatant("A")]); const e = enc([makeCombatant("A")]);
const result = setHp(e, combatantId("A"), 3.5); const result = setHp(e, combatantId("A"), 3.5);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-max-hp");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
}); });
}); });

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { setInitiative } from "../set-initiative.js"; import { setInitiative } from "../set-initiative.js";
import type { Combatant, Encounter } from "../types.js"; import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js"; import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
// --- Helpers --- // --- Helpers ---
@@ -73,10 +74,7 @@ describe("setInitiative", () => {
const e = enc([A, B], 0); const e = enc([A, B], 0);
const result = setInitiative(e, combatantId("A"), 3.5); const result = setInitiative(e, combatantId("A"), 3.5);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "invalid-initiative");
if (isDomainError(result)) {
expect(result.code).toBe("invalid-initiative");
}
}); });
it("AS-3b: reject NaN", () => { it("AS-3b: reject NaN", () => {
@@ -109,10 +107,7 @@ describe("setInitiative", () => {
const e = enc([A, B], 0); const e = enc([A, B], 0);
const result = setInitiative(e, combatantId("nonexistent"), 10); const result = setInitiative(e, combatantId("nonexistent"), 10);
expect(isDomainError(result)).toBe(true); expectDomainError(result, "combatant-not-found");
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
}); });
}); });

View File

@@ -0,0 +1,182 @@
import { describe, expect, it } from "vitest";
import { setTempHp } from "../set-temp-hp.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
function makeCombatant(
name: string,
opts?: { maxHp: number; currentHp: number; tempHp?: number },
): Combatant {
return {
id: combatantId(name),
name,
...(opts
? {
maxHp: opts.maxHp,
currentHp: opts.currentHp,
tempHp: opts.tempHp,
}
: {}),
};
}
function enc(combatants: Combatant[]): Encounter {
return { combatants, activeIndex: 0, roundNumber: 1 };
}
function successResult(
encounter: Encounter,
id: string,
tempHp: number | undefined,
) {
const result = setTempHp(encounter, combatantId(id), tempHp);
if (isDomainError(result)) {
throw new Error(`Expected success, got error: ${result.message}`);
}
return result;
}
describe("setTempHp", () => {
describe("acceptance scenarios", () => {
it("sets temp HP on a combatant with HP tracking enabled", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
const { encounter } = successResult(e, "A", 8);
expect(encounter.combatants[0].tempHp).toBe(8);
});
it("keeps higher value when existing temp HP is greater", () => {
const e = enc([
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 5 }),
]);
const { encounter } = successResult(e, "A", 3);
expect(encounter.combatants[0].tempHp).toBe(5);
});
it("replaces when new value is higher", () => {
const e = enc([
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 3 }),
]);
const { encounter } = successResult(e, "A", 7);
expect(encounter.combatants[0].tempHp).toBe(7);
});
it("clears temp HP when set to undefined", () => {
const e = enc([
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 5 }),
]);
const { encounter } = successResult(e, "A", undefined);
expect(encounter.combatants[0].tempHp).toBeUndefined();
});
});
describe("invariants", () => {
it("is pure — same input produces same output", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
const r1 = setTempHp(e, combatantId("A"), 5);
const r2 = setTempHp(e, combatantId("A"), 5);
expect(r1).toEqual(r2);
});
it("does not mutate input encounter", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
const original = JSON.parse(JSON.stringify(e));
setTempHp(e, combatantId("A"), 5);
expect(e).toEqual(original);
});
it("emits TempHpSet event with correct shape", () => {
const e = enc([
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 3 }),
]);
const { events } = successResult(e, "A", 7);
expect(events).toEqual([
{
type: "TempHpSet",
combatantId: combatantId("A"),
previousTempHp: 3,
newTempHp: 7,
},
]);
});
it("preserves activeIndex and roundNumber", () => {
const e = {
combatants: [
makeCombatant("A", { maxHp: 20, currentHp: 10 }),
makeCombatant("B"),
],
activeIndex: 1,
roundNumber: 5,
};
const { encounter } = successResult(e, "A", 5);
expect(encounter.activeIndex).toBe(1);
expect(encounter.roundNumber).toBe(5);
});
});
describe("error cases", () => {
it("returns error for nonexistent combatant", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const result = setTempHp(e, combatantId("Z"), 5);
expectDomainError(result, "combatant-not-found");
});
it("returns error when HP tracking is not enabled", () => {
const e = enc([makeCombatant("A")]);
const result = setTempHp(e, combatantId("A"), 5);
expectDomainError(result, "no-hp-tracking");
});
it("rejects temp HP of 0", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const result = setTempHp(e, combatantId("A"), 0);
expectDomainError(result, "invalid-temp-hp");
});
it("rejects negative temp HP", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const result = setTempHp(e, combatantId("A"), -3);
expectDomainError(result, "invalid-temp-hp");
});
it("rejects non-integer temp HP", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const result = setTempHp(e, combatantId("A"), 2.5);
expectDomainError(result, "invalid-temp-hp");
});
});
describe("edge cases", () => {
it("does not affect other combatants", () => {
const e = enc([
makeCombatant("A", { maxHp: 20, currentHp: 15 }),
makeCombatant("B", { maxHp: 30, currentHp: 25, tempHp: 4 }),
]);
const { encounter } = successResult(e, "A", 5);
expect(encounter.combatants[1].tempHp).toBe(4);
});
it("does not affect currentHp or maxHp", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
const { encounter } = successResult(e, "A", 8);
expect(encounter.combatants[0].maxHp).toBe(20);
expect(encounter.combatants[0].currentHp).toBe(15);
});
it("event reflects no change when existing value equals new value", () => {
const e = enc([
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 5 }),
]);
const { events } = successResult(e, "A", 5);
expect(events).toEqual([
{
type: "TempHpSet",
combatantId: combatantId("A"),
previousTempHp: 5,
newTempHp: 5,
},
]);
});
});
});

View File

@@ -0,0 +1,9 @@
import { expect } from "vitest";
import { type DomainError, isDomainError } from "../types.js";
export function expectDomainError(result: unknown, code: string): DomainError {
expect(isDomainError(result)).toBe(true);
if (!isDomainError(result)) throw new Error("unreachable");
expect(result.code).toBe(code);
return result;
}

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { toggleConcentration } from "../toggle-concentration.js"; import { toggleConcentration } from "../toggle-concentration.js";
import type { Combatant, Encounter } from "../types.js"; import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js"; import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
function makeCombatant(name: string, isConcentrating?: boolean): Combatant { function makeCombatant(name: string, isConcentrating?: boolean): Combatant {
return isConcentrating return isConcentrating
@@ -46,10 +47,7 @@ describe("toggleConcentration", () => {
const e = enc([makeCombatant("A")]); const e = enc([makeCombatant("A")]);
const result = toggleConcentration(e, combatantId("missing")); const result = toggleConcentration(e, combatantId("missing"));
expect(isDomainError(result)).toBe(true); expectDomainError(result, "combatant-not-found");
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
}); });
it("does not mutate input encounter", () => { it("does not mutate input encounter", () => {

View File

@@ -4,6 +4,7 @@ import { CONDITION_DEFINITIONS } from "../conditions.js";
import { toggleCondition } from "../toggle-condition.js"; import { toggleCondition } from "../toggle-condition.js";
import type { Combatant, Encounter } from "../types.js"; import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js"; import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
function makeCombatant( function makeCombatant(
name: string, name: string,
@@ -77,20 +78,14 @@ describe("toggleCondition", () => {
"flying" as ConditionId, "flying" as ConditionId,
); );
expect(isDomainError(result)).toBe(true); expectDomainError(result, "unknown-condition");
if (isDomainError(result)) {
expect(result.code).toBe("unknown-condition");
}
}); });
it("returns error for nonexistent combatant", () => { it("returns error for nonexistent combatant", () => {
const e = enc([makeCombatant("A")]); const e = enc([makeCombatant("A")]);
const result = toggleCondition(e, combatantId("missing"), "blinded"); const result = toggleCondition(e, combatantId("missing"), "blinded");
expect(isDomainError(result)).toBe(true); expectDomainError(result, "combatant-not-found");
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
}); });
it("does not mutate input encounter", () => { it("does not mutate input encounter", () => {

View File

@@ -54,24 +54,52 @@ export function adjustHp(
} }
const previousHp = target.currentHp; const previousHp = target.currentHp;
const newHp = Math.max(0, Math.min(target.maxHp, previousHp + delta)); const previousTempHp = target.tempHp ?? 0;
let newTempHp = previousTempHp;
let effectiveDelta = delta;
return { if (delta < 0 && previousTempHp > 0) {
encounter: { const absorbed = Math.min(previousTempHp, Math.abs(delta));
combatants: encounter.combatants.map((c) => newTempHp = previousTempHp - absorbed;
c.id === combatantId ? { ...c, currentHp: newHp } : c, effectiveDelta = delta + absorbed;
), }
activeIndex: encounter.activeIndex,
roundNumber: encounter.roundNumber, const newHp = Math.max(
}, 0,
events: [ Math.min(target.maxHp, previousHp + effectiveDelta),
{ );
const events: DomainEvent[] = [];
if (newTempHp !== previousTempHp) {
events.push({
type: "TempHpSet",
combatantId,
previousTempHp: previousTempHp || undefined,
newTempHp: newTempHp || undefined,
});
}
if (newHp !== previousHp) {
events.push({
type: "CurrentHpAdjusted", type: "CurrentHpAdjusted",
combatantId, combatantId,
previousHp, previousHp,
newHp, newHp,
delta, delta,
});
}
return {
encounter: {
combatants: encounter.combatants.map((c) =>
c.id === combatantId
? { ...c, currentHp: newHp, tempHp: newTempHp || undefined }
: c,
),
activeIndex: encounter.activeIndex,
roundNumber: encounter.roundNumber,
}, },
], events,
}; };
} }

View File

@@ -1,6 +1,5 @@
import type { DomainEvent } from "./events.js"; import type { DomainEvent } from "./events.js";
import type { DomainError, Encounter } from "./types.js"; import type { DomainError, Encounter } from "./types.js";
import { isDomainError } from "./types.js";
interface AdvanceTurnSuccess { interface AdvanceTurnSuccess {
readonly encounter: Encounter; readonly encounter: Encounter;
@@ -62,4 +61,4 @@ export function advanceTurn(
}; };
} }
export { isDomainError }; export { isDomainError } from "./types.js";

View File

@@ -23,7 +23,10 @@ export function resolveCreatureName(
if (name === baseName) { if (name === baseName) {
exactMatches.push(i); exactMatches.push(i);
} else { } else {
const match = new RegExp(`^${escapeRegExp(baseName)} (\\d+)$`).exec(name); const match = new RegExp(
String.raw`^${escapeRegExp(baseName)} (\d+)$`,
).exec(name);
// biome-ignore lint/nursery/noUnnecessaryConditions: RegExp.exec() returns null on no match — false positive
if (match) { if (match) {
const num = Number.parseInt(match[1], 10); const num = Number.parseInt(match[1], 10);
if (num > maxNumber) maxNumber = num; if (num > maxNumber) maxNumber = num;
@@ -50,5 +53,5 @@ export function resolveCreatureName(
} }
function escapeRegExp(s: string): string { function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return s.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
} }

View File

@@ -18,63 +18,127 @@ export type ConditionId =
export interface ConditionDefinition { export interface ConditionDefinition {
readonly id: ConditionId; readonly id: ConditionId;
readonly label: string; readonly label: string;
readonly description: string;
readonly iconName: string; readonly iconName: string;
readonly color: string; readonly color: string;
} }
export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
{ id: "blinded", label: "Blinded", iconName: "EyeOff", color: "neutral" }, {
{ id: "charmed", label: "Charmed", iconName: "Heart", color: "pink" }, id: "blinded",
{ id: "deafened", label: "Deafened", iconName: "EarOff", color: "neutral" }, label: "Blinded",
description:
"Can't see. Auto-fail sight checks. Attacks have Disadvantage; attacks against have Advantage.",
iconName: "EyeOff",
color: "neutral",
},
{
id: "charmed",
label: "Charmed",
description:
"Can't attack or target the charmer with harmful abilities. Charmer has Advantage on social checks.",
iconName: "Heart",
color: "pink",
},
{
id: "deafened",
label: "Deafened",
description: "Can't hear. Auto-fail hearing checks.",
iconName: "EarOff",
color: "neutral",
},
{ {
id: "exhaustion", id: "exhaustion",
label: "Exhaustion", label: "Exhaustion",
description:
"Subtract exhaustion level from D20 Tests and Spell save DCs. Speed reduced by 5 ft. \u00D7 level. Removed by long rest (1 level) or death at 10 levels.",
iconName: "BatteryLow", iconName: "BatteryLow",
color: "amber", color: "amber",
}, },
{ {
id: "frightened", id: "frightened",
label: "Frightened", label: "Frightened",
description:
"Disadvantage on ability checks and attacks while source of fear is in line of sight. Can't willingly move closer to the source.",
iconName: "Siren", iconName: "Siren",
color: "orange", color: "orange",
}, },
{ id: "grappled", label: "Grappled", iconName: "Hand", color: "neutral" }, {
id: "grappled",
label: "Grappled",
description:
"Speed is 0 and can't benefit from bonuses to speed. Ends if grappler is Incapacitated or moved out of reach.",
iconName: "Hand",
color: "neutral",
},
{ {
id: "incapacitated", id: "incapacitated",
label: "Incapacitated", label: "Incapacitated",
description:
"Can't take Actions, Bonus Actions, or Reactions. Concentration is broken.",
iconName: "Ban", iconName: "Ban",
color: "gray", color: "gray",
}, },
{ {
id: "invisible", id: "invisible",
label: "Invisible", label: "Invisible",
description:
"Impossible to see without magic or special sense. Heavily Obscured. Attacks have Advantage; attacks against have Disadvantage.",
iconName: "Ghost", iconName: "Ghost",
color: "violet", color: "violet",
}, },
{ {
id: "paralyzed", id: "paralyzed",
label: "Paralyzed", label: "Paralyzed",
description:
"Incapacitated. Can't move or speak. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
iconName: "ZapOff", iconName: "ZapOff",
color: "yellow", color: "yellow",
}, },
{ {
id: "petrified", id: "petrified",
label: "Petrified", label: "Petrified",
description:
"Turned to stone. Weight \u00D710. Incapacitated. Can't move or speak. Attacks against have Advantage. Auto-fail Str/Dex saves. Resistant to all damage. Immune to poison and disease.",
iconName: "Gem", iconName: "Gem",
color: "slate", color: "slate",
}, },
{ id: "poisoned", label: "Poisoned", iconName: "Droplet", color: "green" }, {
{ id: "prone", label: "Prone", iconName: "ArrowDown", color: "neutral" }, id: "poisoned",
label: "Poisoned",
description: "Disadvantage on attack rolls and ability checks.",
iconName: "Droplet",
color: "green",
},
{
id: "prone",
label: "Prone",
description:
"Can only crawl (costs extra movement). Disadvantage on attacks. Attacks within 5 ft. have Advantage; ranged attacks have Disadvantage. Standing up costs half movement.",
iconName: "ArrowDown",
color: "neutral",
},
{ {
id: "restrained", id: "restrained",
label: "Restrained", label: "Restrained",
description:
"Speed is 0. Attacks have Disadvantage. Attacks against have Advantage. Disadvantage on Dex saves.",
iconName: "Link", iconName: "Link",
color: "neutral", color: "neutral",
}, },
{ id: "stunned", label: "Stunned", iconName: "Sparkles", color: "yellow" }, {
id: "stunned",
label: "Stunned",
description:
"Incapacitated. Can't move. Can speak only falteringly. Auto-fail Str/Dex saves. Attacks against have Advantage.",
iconName: "Sparkles",
color: "yellow",
},
{ {
id: "unconscious", id: "unconscious",
label: "Unconscious", label: "Unconscious",
description:
"Incapacitated. Can't move or speak. Unaware of surroundings. Drops held items, falls Prone. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
iconName: "Moon", iconName: "Moon",
color: "indigo", color: "indigo",
}, },

View File

@@ -23,7 +23,7 @@ interface EditFields {
} }
function validateFields(fields: EditFields): DomainError | null { function validateFields(fields: EditFields): DomainError | null {
if (fields.name !== undefined && fields.name.trim() === "") { if (fields.name?.trim() === "") {
return { return {
kind: "domain-error", kind: "domain-error",
code: "invalid-name", code: "invalid-name",
@@ -81,17 +81,17 @@ function applyFields(
): PlayerCharacter { ): PlayerCharacter {
return { return {
id: existing.id, id: existing.id,
name: fields.name !== undefined ? fields.name.trim() : existing.name, name: fields.name?.trim() ?? existing.name,
ac: fields.ac !== undefined ? fields.ac : existing.ac, ac: fields.ac ?? existing.ac,
maxHp: fields.maxHp !== undefined ? fields.maxHp : existing.maxHp, maxHp: fields.maxHp ?? existing.maxHp,
color: color:
fields.color !== undefined fields.color === undefined
? ((fields.color as PlayerCharacter["color"]) ?? undefined) ? existing.color
: existing.color, : ((fields.color as PlayerCharacter["color"]) ?? undefined),
icon: icon:
fields.icon !== undefined fields.icon === undefined
? ((fields.icon as PlayerCharacter["icon"]) ?? undefined) ? existing.icon
: existing.icon, : ((fields.icon as PlayerCharacter["icon"]) ?? undefined),
}; };
} }

View File

@@ -58,6 +58,13 @@ export interface CurrentHpAdjusted {
readonly delta: number; readonly delta: number;
} }
export interface TempHpSet {
readonly type: "TempHpSet";
readonly combatantId: CombatantId;
readonly previousTempHp: number | undefined;
readonly newTempHp: number | undefined;
}
export interface TurnRetreated { export interface TurnRetreated {
readonly type: "TurnRetreated"; readonly type: "TurnRetreated";
readonly previousCombatantId: CombatantId; readonly previousCombatantId: CombatantId;
@@ -132,6 +139,7 @@ export type DomainEvent =
| InitiativeSet | InitiativeSet
| MaxHpSet | MaxHpSet
| CurrentHpAdjusted | CurrentHpAdjusted
| TempHpSet
| TurnRetreated | TurnRetreated
| RoundRetreated | RoundRetreated
| AcSet | AcSet

View File

@@ -60,6 +60,7 @@ export type {
PlayerCharacterUpdated, PlayerCharacterUpdated,
RoundAdvanced, RoundAdvanced,
RoundRetreated, RoundRetreated,
TempHpSet,
TurnAdvanced, TurnAdvanced,
TurnRetreated, TurnRetreated,
} from "./events.js"; } from "./events.js";
@@ -84,13 +85,18 @@ export {
removeCombatant, removeCombatant,
} from "./remove-combatant.js"; } from "./remove-combatant.js";
export { retreatTurn } from "./retreat-turn.js"; export { retreatTurn } from "./retreat-turn.js";
export { rollInitiative } from "./roll-initiative.js"; export {
type RollMode,
rollInitiative,
selectRoll,
} from "./roll-initiative.js";
export { type SetAcSuccess, setAc } from "./set-ac.js"; export { type SetAcSuccess, setAc } from "./set-ac.js";
export { type SetHpSuccess, setHp } from "./set-hp.js"; export { type SetHpSuccess, setHp } from "./set-hp.js";
export { export {
type SetInitiativeSuccess, type SetInitiativeSuccess,
setInitiative, setInitiative,
} from "./set-initiative.js"; } from "./set-initiative.js";
export { type SetTempHpSuccess, setTempHp } from "./set-temp-hp.js";
export { export {
type ToggleConcentrationSuccess, type ToggleConcentrationSuccess,
toggleConcentration, toggleConcentration,

Some files were not shown because too many files have changed in this diff Show More