Compare commits
61 Commits
fc43f440aa
...
0.7.9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
472574ac31 | ||
|
|
f4a7b53393 | ||
|
|
8aec460ee4 | ||
|
|
6e10238fe0 | ||
|
|
b6e882add2 | ||
|
|
7a87d979bf | ||
|
|
02096bcee6 | ||
|
|
c092192b0e | ||
|
|
4d1a7c6420 | ||
|
|
46b444caba | ||
|
|
e68145319f | ||
|
|
d64e1f5e4a | ||
|
|
ef0b755eec | ||
|
|
4be816d10f | ||
|
|
e531d82d1b | ||
|
|
5a262c66cd | ||
|
|
32b69f8df1 | ||
|
|
8efba288f7 | ||
|
|
c94c30e459 | ||
|
|
36768d3aa1 | ||
|
|
473f1eaefe | ||
|
|
971e0ded49 | ||
|
|
36dcfc5076 | ||
|
|
127ed01064 | ||
|
|
179c3658ad | ||
|
|
01f2bb3ff1 | ||
|
|
930301de71 | ||
|
|
aa806d4fb9 | ||
|
|
61bc274715 | ||
|
|
1932e837fb | ||
|
|
cce87318fb | ||
|
|
3ef2370a34 | ||
|
|
c75d148d1e | ||
|
|
63e233bd8d | ||
|
|
8c62ec28f2 | ||
|
|
72195e90f6 | ||
|
|
6ac8e67970 | ||
|
|
a4797d5b15 | ||
|
|
d48e39ced4 | ||
|
|
b7406c4b54 | ||
|
|
07cdd4867a | ||
|
|
85acb5c185 | ||
|
|
f9ef64bb00 | ||
|
|
bd39808000 | ||
|
|
75778884bd | ||
|
|
72d4f30e60 | ||
|
|
96b37d4bdd | ||
|
|
76ca78c169 | ||
|
|
b0c27b8ab9 | ||
|
|
458c277e9f | ||
|
|
91703ddebc | ||
|
|
768e7a390f | ||
|
|
7feaf90eab | ||
|
|
b39e4923e1 | ||
|
|
369feb3cc8 | ||
|
|
51bdb799ae | ||
|
|
1baddad939 | ||
|
|
e701e4dd70 | ||
|
|
e2b0e7d5ee | ||
|
|
635e9c0705 | ||
|
|
582a42e62d |
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
.pnpm-store
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
.git
|
||||||
|
.claude
|
||||||
|
.specify
|
||||||
|
specs
|
||||||
|
docs
|
||||||
49
.gitea/workflows/ci.yml
Normal file
49
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ["*"]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm check
|
||||||
|
|
||||||
|
build-image:
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
needs: check
|
||||||
|
runs-on: host
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to Gitea registry
|
||||||
|
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.bahamut.nitrix.one -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
run: |
|
||||||
|
IMAGE=git.bahamut.nitrix.one/dostulata/initiative
|
||||||
|
TAG=${GITHUB_REF#refs/tags/}
|
||||||
|
docker build -t $IMAGE:$TAG -t $IMAGE:latest .
|
||||||
|
docker push $IMAGE:$TAG
|
||||||
|
docker push $IMAGE:latest
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
run: |
|
||||||
|
IMAGE=git.bahamut.nitrix.one/dostulata/initiative
|
||||||
|
TAG=${GITHUB_REF#refs/tags/}
|
||||||
|
docker stop initiative || true
|
||||||
|
docker rm initiative || true
|
||||||
|
docker run -d --name initiative --restart unless-stopped -p 8080:80 $IMAGE:$TAG
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,3 +11,4 @@ Thumbs.db
|
|||||||
.idea/
|
.idea/
|
||||||
coverage/
|
coverage/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
docs/agents/plans/
|
||||||
|
|||||||
27
.oxlintrc.json
Normal file
27
.oxlintrc.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
24
CLAUDE.md
24
CLAUDE.md
@@ -5,7 +5,8 @@ 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
|
||||||
@@ -58,12 +59,13 @@ 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.
|
||||||
@@ -71,6 +73,14 @@ docs/agents/ RPI skill artifacts (research reports, plans)
|
|||||||
- **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`.
|
||||||
- **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
|
||||||
|
|
||||||
|
Before finishing a change, consider:
|
||||||
|
- Is this the simplest approach that solves the current problem?
|
||||||
|
- Is there duplication that hurts readability? (But don't abstract prematurely.)
|
||||||
|
- Are errors handled correctly and communicated sensibly to the user?
|
||||||
|
- Does the UI follow modern patterns and feel intuitive to interact with?
|
||||||
|
|
||||||
## Speckit Workflow
|
## Speckit Workflow
|
||||||
|
|
||||||
Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Specs are **living documents** that describe features, not individual changes.
|
Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Specs are **living documents** that describe features, not individual changes.
|
||||||
@@ -101,6 +111,7 @@ Speckit manages **what** to build (specs as living documents). RPI manages **how
|
|||||||
- `specs/002-turn-tracking/` — rounds, turn order, advance/retreat, top bar
|
- `specs/002-turn-tracking/` — rounds, turn order, advance/retreat, top bar
|
||||||
- `specs/003-combatant-state/` — HP, AC, conditions, concentration, initiative
|
- `specs/003-combatant-state/` — HP, AC, conditions, concentration, initiative
|
||||||
- `specs/004-bestiary/` — search index, stat blocks, source management, panel UX
|
- `specs/004-bestiary/` — search index, stat blocks, source management, panel UX
|
||||||
|
- `specs/005-player-characters/` — persistent player character templates (CRUD), search & add to encounters, color/icon visual distinction, `PlayerCharacterStore` port
|
||||||
|
|
||||||
## Constitution (key principles)
|
## Constitution (key principles)
|
||||||
|
|
||||||
@@ -111,3 +122,10 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work:
|
|||||||
3. **Clarification-First** — Ask before making non-trivial assumptions.
|
3. **Clarification-First** — Ask before making non-trivial assumptions.
|
||||||
4. **MVP Baseline** — Say "MVP baseline does not include X", never permanent bans.
|
4. **MVP Baseline** — Say "MVP baseline does not include X", never permanent bans.
|
||||||
5. **Spec-driven features** — Features are described in living specs; evolve existing specs via `/integrate-issue`, create new ones via `/speckit.specify`. Bug fixes and tooling changes do not require specs.
|
5. **Spec-driven features** — Features are described in living specs; evolve existing specs via `/integrate-issue`, create new ones via `/speckit.specify`. Bug fixes and tooling changes do not require specs.
|
||||||
|
|
||||||
|
## Active Technologies
|
||||||
|
- TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Vite 6, Tailwind CSS v4, Lucide Reac (005-player-characters)
|
||||||
|
- localStorage (new key `"initiative:player-characters"`) (005-player-characters)
|
||||||
|
|
||||||
|
## Recent Changes
|
||||||
|
- 005-player-characters: Added TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Vite 6, Tailwind CSS v4, Lucide Reac
|
||||||
|
|||||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:22-slim AS build
|
||||||
|
ENV PNPM_HOME="/pnpm"
|
||||||
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
COPY packages/domain/package.json packages/domain/
|
||||||
|
COPY packages/application/package.json packages/application/
|
||||||
|
COPY apps/web/package.json apps/web/
|
||||||
|
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm --filter web build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
@@ -7,7 +7,8 @@ A local-first initiative tracker and encounter manager for tabletop RPGs (D&D 5e
|
|||||||
- **Initiative tracking** — add combatants (batch-add from bestiary, custom creatures with optional stats), roll initiative (manual or d20), cycle turns and rounds
|
- **Initiative tracking** — add combatants (batch-add from bestiary, custom creatures with optional stats), roll initiative (manual or d20), cycle turns and rounds
|
||||||
- **Encounter state** — HP, AC, conditions, concentration tracking with visual status indicators
|
- **Encounter state** — HP, AC, conditions, concentration tracking with visual status indicators
|
||||||
- **Bestiary integration** — import bestiary JSON sources, search creatures, and view full stat blocks
|
- **Bestiary integration** — import bestiary JSON sources, search creatures, and view full stat blocks
|
||||||
- **Persistent** — encounters survive page reloads via localStorage; bestiary data cached in IndexedDB
|
- **Player characters** — create reusable player character templates with name, AC, HP, color, and icon; search and add them to encounters with pre-filled stats; manage (edit/delete) from a dedicated panel
|
||||||
|
- **Persistent** — encounters survive page reloads via localStorage; bestiary data cached in IndexedDB; player characters stored independently
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"@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": "^4.3.0",
|
||||||
|
|||||||
@@ -2,25 +2,83 @@ import {
|
|||||||
rollAllInitiativeUseCase,
|
rollAllInitiativeUseCase,
|
||||||
rollInitiativeUseCase,
|
rollInitiativeUseCase,
|
||||||
} from "@initiative/application";
|
} from "@initiative/application";
|
||||||
import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
|
import {
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
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 { ActionBar } from "./components/action-bar";
|
||||||
|
import { BulkImportToasts } from "./components/bulk-import-toasts";
|
||||||
import { CombatantRow } from "./components/combatant-row";
|
import { CombatantRow } from "./components/combatant-row";
|
||||||
import { SourceManager } from "./components/source-manager";
|
import {
|
||||||
|
PlayerCharacterSection,
|
||||||
|
type PlayerCharacterSectionHandle,
|
||||||
|
} from "./components/player-character-section";
|
||||||
import { StatBlockPanel } from "./components/stat-block-panel";
|
import { StatBlockPanel } from "./components/stat-block-panel";
|
||||||
import { Toast } from "./components/toast";
|
import { Toast } from "./components/toast";
|
||||||
import { TurnNavigation } from "./components/turn-navigation";
|
import { TurnNavigation } from "./components/turn-navigation";
|
||||||
import { type SearchResult, useBestiary } from "./hooks/use-bestiary";
|
import { type SearchResult, useBestiary } from "./hooks/use-bestiary";
|
||||||
import { useBulkImport } from "./hooks/use-bulk-import";
|
import { useBulkImport } from "./hooks/use-bulk-import";
|
||||||
import { useEncounter } from "./hooks/use-encounter";
|
import { useEncounter } from "./hooks/use-encounter";
|
||||||
|
import { usePlayerCharacters } from "./hooks/use-player-characters";
|
||||||
|
import { useSidePanelState } from "./hooks/use-side-panel-state";
|
||||||
|
import { cn } from "./lib/utils";
|
||||||
|
|
||||||
function rollDice(): number {
|
function rollDice(): number {
|
||||||
return Math.floor(Math.random() * 20) + 1;
|
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 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const {
|
const {
|
||||||
encounter,
|
encounter,
|
||||||
|
isEmpty,
|
||||||
|
hasCreatureCombatants,
|
||||||
|
canRollAllInitiative,
|
||||||
advanceTurn,
|
advanceTurn,
|
||||||
retreatTurn,
|
retreatTurn,
|
||||||
addCombatant,
|
addCombatant,
|
||||||
@@ -34,9 +92,17 @@ export function App() {
|
|||||||
toggleCondition,
|
toggleCondition,
|
||||||
toggleConcentration,
|
toggleConcentration,
|
||||||
addFromBestiary,
|
addFromBestiary,
|
||||||
|
addFromPlayerCharacter,
|
||||||
makeStore,
|
makeStore,
|
||||||
} = useEncounter();
|
} = useEncounter();
|
||||||
|
|
||||||
|
const {
|
||||||
|
characters: playerCharacters,
|
||||||
|
createCharacter: createPlayerCharacter,
|
||||||
|
editCharacter: editPlayerCharacter,
|
||||||
|
deleteCharacter: deletePlayerCharacter,
|
||||||
|
} = usePlayerCharacters();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
search,
|
search,
|
||||||
getCreature,
|
getCreature,
|
||||||
@@ -48,53 +114,31 @@ export function App() {
|
|||||||
} = useBestiary();
|
} = useBestiary();
|
||||||
|
|
||||||
const bulkImport = useBulkImport();
|
const bulkImport = useBulkImport();
|
||||||
|
const sidePanel = useSidePanelState();
|
||||||
|
|
||||||
const [selectedCreatureId, setSelectedCreatureId] =
|
const [rollSkippedCount, setRollSkippedCount] = useState(0);
|
||||||
useState<CreatureId | null>(null);
|
|
||||||
const [bulkImportMode, setBulkImportMode] = useState(false);
|
|
||||||
const [sourceManagerOpen, setSourceManagerOpen] = useState(false);
|
|
||||||
const [isRightPanelFolded, setIsRightPanelFolded] = useState(false);
|
|
||||||
const [pinnedCreatureId, setPinnedCreatureId] = useState<CreatureId | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [isWideDesktop, setIsWideDesktop] = useState(
|
|
||||||
() => window.matchMedia("(min-width: 1280px)").matches,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const selectedCreature: Creature | null = sidePanel.selectedCreatureId
|
||||||
const mq = window.matchMedia("(min-width: 1280px)");
|
? (getCreature(sidePanel.selectedCreatureId) ?? null)
|
||||||
const handler = (e: MediaQueryListEvent) => setIsWideDesktop(e.matches);
|
|
||||||
mq.addEventListener("change", handler);
|
|
||||||
return () => mq.removeEventListener("change", handler);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const selectedCreature: Creature | null = selectedCreatureId
|
|
||||||
? (getCreature(selectedCreatureId) ?? null)
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const pinnedCreature: Creature | null = pinnedCreatureId
|
const pinnedCreature: Creature | null = sidePanel.pinnedCreatureId
|
||||||
? (getCreature(pinnedCreatureId) ?? null)
|
? (getCreature(sidePanel.pinnedCreatureId) ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const handleAddFromBestiary = useCallback(
|
const handleAddFromBestiary = useCallback(
|
||||||
(result: SearchResult) => {
|
(result: SearchResult) => {
|
||||||
addFromBestiary(result);
|
addFromBestiary(result);
|
||||||
// Derive the creature ID so stat block panel can try to show it
|
|
||||||
const slug = result.name
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/(^-|-$)/g, "");
|
|
||||||
setSelectedCreatureId(
|
|
||||||
`${result.source.toLowerCase()}:${slug}` as CreatureId,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
[addFromBestiary],
|
[addFromBestiary],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCombatantStatBlock = useCallback((creatureId: string) => {
|
const handleCombatantStatBlock = useCallback(
|
||||||
setSelectedCreatureId(creatureId as CreatureId);
|
(creatureId: string) => {
|
||||||
setIsRightPanelFolded(false);
|
sidePanel.showCreature(creatureId as CreatureId);
|
||||||
}, []);
|
},
|
||||||
|
[sidePanel.showCreature],
|
||||||
|
);
|
||||||
|
|
||||||
const handleRollInitiative = useCallback(
|
const handleRollInitiative = useCallback(
|
||||||
(id: CombatantId) => {
|
(id: CombatantId) => {
|
||||||
@@ -104,23 +148,23 @@ export function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleRollAllInitiative = useCallback(() => {
|
const handleRollAllInitiative = useCallback(() => {
|
||||||
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
const result = rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
||||||
|
if (!isDomainError(result) && result.skippedNoSource > 0) {
|
||||||
|
setRollSkippedCount(result.skippedNoSource);
|
||||||
|
}
|
||||||
}, [makeStore, getCreature]);
|
}, [makeStore, getCreature]);
|
||||||
|
|
||||||
const handleViewStatBlock = useCallback((result: SearchResult) => {
|
const handleViewStatBlock = useCallback(
|
||||||
const slug = result.name
|
(result: SearchResult) => {
|
||||||
.toLowerCase()
|
const slug = result.name
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
.toLowerCase()
|
||||||
.replace(/(^-|-$)/g, "");
|
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||||
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
.replaceAll(/(^-|-$)/g, "");
|
||||||
setSelectedCreatureId(cId);
|
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
||||||
setIsRightPanelFolded(false);
|
sidePanel.showCreature(cId);
|
||||||
}, []);
|
},
|
||||||
|
[sidePanel.showCreature],
|
||||||
const handleBulkImport = useCallback(() => {
|
);
|
||||||
setBulkImportMode(true);
|
|
||||||
setSelectedCreatureId(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleStartBulkImport = useCallback(
|
const handleStartBulkImport = useCallback(
|
||||||
(baseUrl: string) => {
|
(baseUrl: string) => {
|
||||||
@@ -135,30 +179,13 @@ export function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleBulkImportDone = useCallback(() => {
|
const handleBulkImportDone = useCallback(() => {
|
||||||
setBulkImportMode(false);
|
sidePanel.dismissPanel();
|
||||||
bulkImport.reset();
|
bulkImport.reset();
|
||||||
}, [bulkImport.reset]);
|
}, [sidePanel.dismissPanel, bulkImport.reset]);
|
||||||
|
|
||||||
const handleDismissBrowsePanel = useCallback(() => {
|
const actionBarInputRef = useRef<HTMLInputElement>(null);
|
||||||
setSelectedCreatureId(null);
|
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
|
||||||
setBulkImportMode(false);
|
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleToggleFold = useCallback(() => {
|
|
||||||
setIsRightPanelFolded((f) => !f);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handlePin = useCallback(() => {
|
|
||||||
if (selectedCreatureId) {
|
|
||||||
setPinnedCreatureId((prev) =>
|
|
||||||
prev === selectedCreatureId ? null : selectedCreatureId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [selectedCreatureId]);
|
|
||||||
|
|
||||||
const handleUnpin = useCallback(() => {
|
|
||||||
setPinnedCreatureId(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Auto-scroll to the active combatant when the turn changes
|
// Auto-scroll to the active combatant when the turn changes
|
||||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -167,106 +194,129 @@ export function App() {
|
|||||||
block: "nearest",
|
block: "nearest",
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
});
|
});
|
||||||
}, [encounter.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;
|
|
||||||
setSelectedCreatureId(active.creatureId as CreatureId);
|
|
||||||
}, [encounter.activeIndex, encounter.combatants, isLoaded]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-dvh flex-col">
|
||||||
<div className="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">
|
||||||
{/* Turn Navigation — fixed at top */}
|
{!!actionBarAnim.showTopBar && (
|
||||||
<div className="shrink-0 pt-8">
|
<div
|
||||||
<TurnNavigation
|
className={cn("shrink-0 pt-8", actionBarAnim.topBarClass)}
|
||||||
encounter={encounter}
|
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
|
||||||
onAdvanceTurn={advanceTurn}
|
>
|
||||||
onRetreatTurn={retreatTurn}
|
<TurnNavigation
|
||||||
onClearEncounter={clearEncounter}
|
encounter={encounter}
|
||||||
onRollAllInitiative={handleRollAllInitiative}
|
onAdvanceTurn={advanceTurn}
|
||||||
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
|
onRetreatTurn={retreatTurn}
|
||||||
/>
|
onClearEncounter={clearEncounter}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{sourceManagerOpen && (
|
|
||||||
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
|
|
||||||
<SourceManager onCacheCleared={refreshCache} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Scrollable area — combatant list */}
|
{isEmpty ? (
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
/* Empty state — ActionBar centered */
|
||||||
<div className="flex flex-col px-2 py-2">
|
<div className="flex min-h-0 flex-1 items-center justify-center pt-8 pb-[15%]">
|
||||||
{encounter.combatants.length === 0 ? (
|
<div
|
||||||
<p className="py-12 text-center text-sm text-muted-foreground">
|
className={cn("w-full", actionBarAnim.risingClass)}
|
||||||
No combatants yet — add one to get started
|
onAnimationEnd={actionBarAnim.onRiseEnd}
|
||||||
</p>
|
>
|
||||||
) : (
|
<ActionBar
|
||||||
encounter.combatants.map((c, i) => (
|
onAddCombatant={addCombatant}
|
||||||
<CombatantRow
|
onAddFromBestiary={handleAddFromBestiary}
|
||||||
key={c.id}
|
bestiarySearch={search}
|
||||||
ref={i === encounter.activeIndex ? activeRowRef : null}
|
bestiaryLoaded={isLoaded}
|
||||||
combatant={c}
|
onViewStatBlock={handleViewStatBlock}
|
||||||
isActive={i === encounter.activeIndex}
|
onBulkImport={sidePanel.showBulkImport}
|
||||||
onRename={editCombatant}
|
bulkImportDisabled={bulkImport.state.status === "loading"}
|
||||||
onSetInitiative={setInitiative}
|
inputRef={actionBarInputRef}
|
||||||
onRemove={removeCombatant}
|
playerCharacters={playerCharacters}
|
||||||
onSetHp={setHp}
|
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||||
onAdjustHp={adjustHp}
|
onManagePlayers={() =>
|
||||||
onSetAc={setAc}
|
playerCharacterRef.current?.openManagement()
|
||||||
onToggleCondition={toggleCondition}
|
}
|
||||||
onToggleConcentration={toggleConcentration}
|
onRollAllInitiative={handleRollAllInitiative}
|
||||||
onShowStatBlock={
|
showRollAllInitiative={hasCreatureCombatants}
|
||||||
c.creatureId
|
rollAllInitiativeDisabled={!canRollAllInitiative}
|
||||||
? () => handleCombatantStatBlock(c.creatureId as string)
|
onOpenSourceManager={sidePanel.showSourceManager}
|
||||||
: undefined
|
autoFocus
|
||||||
}
|
/>
|
||||||
onRollInitiative={
|
</div>
|
||||||
c.creatureId ? handleRollInitiative : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Scrollable area — combatant list */}
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<div className="flex flex-col px-2 py-2">
|
||||||
|
{encounter.combatants.map((c, i) => (
|
||||||
|
<CombatantRow
|
||||||
|
key={c.id}
|
||||||
|
ref={i === encounter.activeIndex ? activeRowRef : null}
|
||||||
|
combatant={c}
|
||||||
|
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>
|
||||||
|
|
||||||
{/* Action Bar — fixed at bottom */}
|
{/* Action Bar — fixed at bottom */}
|
||||||
<div className="shrink-0 pb-8">
|
<div
|
||||||
<ActionBar
|
className={cn("shrink-0 pb-8", actionBarAnim.settlingClass)}
|
||||||
onAddCombatant={addCombatant}
|
onAnimationEnd={actionBarAnim.onSettleEnd}
|
||||||
onAddFromBestiary={handleAddFromBestiary}
|
>
|
||||||
bestiarySearch={search}
|
<ActionBar
|
||||||
bestiaryLoaded={isLoaded}
|
onAddCombatant={addCombatant}
|
||||||
onViewStatBlock={handleViewStatBlock}
|
onAddFromBestiary={handleAddFromBestiary}
|
||||||
onBulkImport={handleBulkImport}
|
bestiarySearch={search}
|
||||||
bulkImportDisabled={bulkImport.state.status === "loading"}
|
bestiaryLoaded={isLoaded}
|
||||||
/>
|
onViewStatBlock={handleViewStatBlock}
|
||||||
</div>
|
onBulkImport={sidePanel.showBulkImport}
|
||||||
|
bulkImportDisabled={bulkImport.state.status === "loading"}
|
||||||
|
inputRef={actionBarInputRef}
|
||||||
|
playerCharacters={playerCharacters}
|
||||||
|
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||||
|
onManagePlayers={() =>
|
||||||
|
playerCharacterRef.current?.openManagement()
|
||||||
|
}
|
||||||
|
onRollAllInitiative={handleRollAllInitiative}
|
||||||
|
showRollAllInitiative={hasCreatureCombatants}
|
||||||
|
rollAllInitiativeDisabled={!canRollAllInitiative}
|
||||||
|
onOpenSourceManager={sidePanel.showSourceManager}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pinned Stat Block Panel (left) */}
|
{/* Pinned Stat Block Panel (left) */}
|
||||||
{pinnedCreatureId && isWideDesktop && (
|
{!!sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
|
||||||
<StatBlockPanel
|
<StatBlockPanel
|
||||||
creatureId={pinnedCreatureId}
|
creatureId={sidePanel.pinnedCreatureId}
|
||||||
creature={pinnedCreature}
|
creature={pinnedCreature}
|
||||||
isSourceCached={isSourceCached}
|
isSourceCached={isSourceCached}
|
||||||
fetchAndCacheSource={fetchAndCacheSource}
|
fetchAndCacheSource={fetchAndCacheSource}
|
||||||
uploadAndCacheSource={uploadAndCacheSource}
|
uploadAndCacheSource={uploadAndCacheSource}
|
||||||
refreshCache={refreshCache}
|
refreshCache={refreshCache}
|
||||||
panelRole="pinned"
|
panelRole="pinned"
|
||||||
isFolded={false}
|
isCollapsed={false}
|
||||||
onToggleFold={() => {}}
|
onToggleCollapse={() => {}}
|
||||||
onPin={() => {}}
|
onPin={() => {}}
|
||||||
onUnpin={handleUnpin}
|
onUnpin={sidePanel.unpin}
|
||||||
showPinButton={false}
|
showPinButton={false}
|
||||||
side="left"
|
side="left"
|
||||||
onDismiss={() => {}}
|
onDismiss={() => {}}
|
||||||
@@ -275,52 +325,48 @@ export function App() {
|
|||||||
|
|
||||||
{/* Browse Stat Block Panel (right) */}
|
{/* Browse Stat Block Panel (right) */}
|
||||||
<StatBlockPanel
|
<StatBlockPanel
|
||||||
creatureId={selectedCreatureId}
|
creatureId={sidePanel.selectedCreatureId}
|
||||||
creature={selectedCreature}
|
creature={selectedCreature}
|
||||||
isSourceCached={isSourceCached}
|
isSourceCached={isSourceCached}
|
||||||
fetchAndCacheSource={fetchAndCacheSource}
|
fetchAndCacheSource={fetchAndCacheSource}
|
||||||
uploadAndCacheSource={uploadAndCacheSource}
|
uploadAndCacheSource={uploadAndCacheSource}
|
||||||
refreshCache={refreshCache}
|
refreshCache={refreshCache}
|
||||||
panelRole="browse"
|
panelRole="browse"
|
||||||
isFolded={isRightPanelFolded}
|
isCollapsed={sidePanel.isRightPanelCollapsed}
|
||||||
onToggleFold={handleToggleFold}
|
onToggleCollapse={sidePanel.toggleCollapse}
|
||||||
onPin={handlePin}
|
onPin={sidePanel.togglePin}
|
||||||
onUnpin={() => {}}
|
onUnpin={() => {}}
|
||||||
showPinButton={isWideDesktop && !!selectedCreature}
|
showPinButton={sidePanel.isWideDesktop && !!selectedCreature}
|
||||||
side="right"
|
side="right"
|
||||||
onDismiss={handleDismissBrowsePanel}
|
onDismiss={sidePanel.dismissPanel}
|
||||||
bulkImportMode={bulkImportMode}
|
bulkImportMode={sidePanel.bulkImportMode}
|
||||||
bulkImportState={bulkImport.state}
|
bulkImportState={bulkImport.state}
|
||||||
onStartBulkImport={handleStartBulkImport}
|
onStartBulkImport={handleStartBulkImport}
|
||||||
onBulkImportDone={handleBulkImportDone}
|
onBulkImportDone={handleBulkImportDone}
|
||||||
|
sourceManagerMode={sidePanel.sourceManagerMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Toast for bulk import progress when panel is closed */}
|
<BulkImportToasts
|
||||||
{bulkImport.state.status === "loading" && !bulkImportMode && (
|
state={bulkImport.state}
|
||||||
|
visible={!sidePanel.bulkImportMode || sidePanel.isRightPanelCollapsed}
|
||||||
|
onReset={bulkImport.reset}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{rollSkippedCount > 0 && (
|
||||||
<Toast
|
<Toast
|
||||||
message={`Loading sources... ${bulkImport.state.completed + bulkImport.state.failed}/${bulkImport.state.total}`}
|
message={`${rollSkippedCount} skipped — bestiary source not loaded`}
|
||||||
progress={
|
onDismiss={() => setRollSkippedCount(0)}
|
||||||
bulkImport.state.total > 0
|
autoDismissMs={4000}
|
||||||
? (bulkImport.state.completed + bulkImport.state.failed) /
|
|
||||||
bulkImport.state.total
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
onDismiss={() => {}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{bulkImport.state.status === "complete" && !bulkImportMode && (
|
|
||||||
<Toast
|
|
||||||
message="All sources loaded"
|
|
||||||
onDismiss={bulkImport.reset}
|
|
||||||
autoDismissMs={3000}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{bulkImport.state.status === "partial-failure" && !bulkImportMode && (
|
|
||||||
<Toast
|
|
||||||
message={`Loaded ${bulkImport.state.completed}/${bulkImport.state.total} sources (${bulkImport.state.failed} failed)`}
|
|
||||||
onDismiss={bulkImport.reset}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<PlayerCharacterSection
|
||||||
|
ref={playerCharacterRef}
|
||||||
|
characters={playerCharacters}
|
||||||
|
onCreateCharacter={createPlayerCharacter}
|
||||||
|
onEditCharacter={editPlayerCharacter}
|
||||||
|
onDeleteCharacter={deletePlayerCharacter}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
163
apps/web/src/__tests__/app-integration.test.tsx
Normal file
163
apps/web/src/__tests__/app-integration.test.tsx
Normal 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";
|
||||||
|
|
||||||
|
// 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 />);
|
||||||
|
|
||||||
|
// 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 />);
|
||||||
|
|
||||||
|
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 />);
|
||||||
|
|
||||||
|
await addCombatant(user, "Ogre", { maxHp: "59" });
|
||||||
|
|
||||||
|
// Verify HP displays — currentHp and maxHp both show "59"
|
||||||
|
expect(screen.getByText("/")).toBeInTheDocument();
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 />}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ 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";
|
import { StatBlockPanel } from "../components/stat-block-panel";
|
||||||
|
|
||||||
|
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 +29,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,
|
||||||
@@ -45,8 +48,8 @@ interface PanelProps {
|
|||||||
creatureId?: CreatureId | null;
|
creatureId?: CreatureId | null;
|
||||||
creature?: Creature | null;
|
creature?: Creature | null;
|
||||||
panelRole?: "browse" | "pinned";
|
panelRole?: "browse" | "pinned";
|
||||||
isFolded?: boolean;
|
isCollapsed?: boolean;
|
||||||
onToggleFold?: () => void;
|
onToggleCollapse?: () => void;
|
||||||
onPin?: () => void;
|
onPin?: () => void;
|
||||||
onUnpin?: () => void;
|
onUnpin?: () => void;
|
||||||
showPinButton?: boolean;
|
showPinButton?: boolean;
|
||||||
@@ -64,8 +67,8 @@ function renderPanel(overrides: PanelProps = {}) {
|
|||||||
uploadAndCacheSource: vi.fn(),
|
uploadAndCacheSource: vi.fn(),
|
||||||
refreshCache: vi.fn(),
|
refreshCache: vi.fn(),
|
||||||
panelRole: "browse" as const,
|
panelRole: "browse" as const,
|
||||||
isFolded: false,
|
isCollapsed: false,
|
||||||
onToggleFold: vi.fn(),
|
onToggleCollapse: vi.fn(),
|
||||||
onPin: vi.fn(),
|
onPin: vi.fn(),
|
||||||
onUnpin: vi.fn(),
|
onUnpin: vi.fn(),
|
||||||
showPinButton: false,
|
showPinButton: false,
|
||||||
@@ -78,21 +81,21 @@ function renderPanel(overrides: PanelProps = {}) {
|
|||||||
return props;
|
return props;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("Stat Block Panel Fold/Unfold and Pin", () => {
|
describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockMatchMedia(true); // desktop by default
|
mockMatchMedia(true); // desktop by default
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
describe("US1: Fold and Unfold", () => {
|
describe("US1: Collapse and Expand", () => {
|
||||||
it("shows fold button instead of close button on desktop", () => {
|
it("shows collapse button instead of close button on desktop", () => {
|
||||||
renderPanel();
|
renderPanel();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Fold 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();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,42 +104,42 @@ describe("Stat Block Panel Fold/Unfold and Pin", () => {
|
|||||||
expect(screen.queryByText("Stat Block")).not.toBeInTheDocument();
|
expect(screen.queryByText("Stat Block")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders folded tab with creature name when isFolded is true", () => {
|
it("renders collapsed tab with creature name when isCollapsed is true", () => {
|
||||||
renderPanel({ isFolded: true });
|
renderPanel({ isCollapsed: true });
|
||||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Unfold stat block panel" }),
|
screen.getByRole("button", { name: "Expand stat block panel" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onToggleFold when fold button is clicked", () => {
|
it("calls onToggleCollapse when collapse button is clicked", () => {
|
||||||
const props = renderPanel();
|
const props = renderPanel();
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", { name: "Fold stat block panel" }),
|
screen.getByRole("button", { name: "Collapse stat block panel" }),
|
||||||
);
|
);
|
||||||
expect(props.onToggleFold).toHaveBeenCalledTimes(1);
|
expect(props.onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onToggleFold when folded tab is clicked", () => {
|
it("calls onToggleCollapse when collapsed tab is clicked", () => {
|
||||||
const props = renderPanel({ isFolded: true });
|
const props = renderPanel({ isCollapsed: true });
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", { name: "Unfold stat block panel" }),
|
screen.getByRole("button", { name: "Expand stat block panel" }),
|
||||||
);
|
);
|
||||||
expect(props.onToggleFold).toHaveBeenCalledTimes(1);
|
expect(props.onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies translate-x class when folded (right side)", () => {
|
it("applies translate-x class when collapsed (right side)", () => {
|
||||||
renderPanel({ isFolded: true, side: "right" });
|
renderPanel({ isCollapsed: true, side: "right" });
|
||||||
const panel = screen
|
const panel = screen
|
||||||
.getByRole("button", { name: "Unfold stat block panel" })
|
.getByRole("button", { name: "Expand stat block panel" })
|
||||||
.closest("div");
|
.closest("div");
|
||||||
expect(panel?.className).toContain("translate-x-[calc(100%-40px)]");
|
expect(panel?.className).toContain("translate-x-[calc(100%-40px)]");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies translate-x-0 when expanded", () => {
|
it("applies translate-x-0 when expanded", () => {
|
||||||
renderPanel({ isFolded: false });
|
renderPanel({ isCollapsed: false });
|
||||||
const foldBtn = screen.getByRole("button", {
|
const foldBtn = screen.getByRole("button", {
|
||||||
name: "Fold stat block panel",
|
name: "Collapse stat block panel",
|
||||||
});
|
});
|
||||||
const panel = foldBtn.closest("div.fixed") as HTMLElement;
|
const panel = foldBtn.closest("div.fixed") as HTMLElement;
|
||||||
expect(panel?.className).toContain("translate-x-0");
|
expect(panel?.className).toContain("translate-x-0");
|
||||||
@@ -148,12 +151,12 @@ describe("Stat Block Panel Fold/Unfold and Pin", () => {
|
|||||||
mockMatchMedia(false); // mobile
|
mockMatchMedia(false); // mobile
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows fold button instead of X close button on mobile drawer", () => {
|
it("shows collapse button instead of X close button on mobile drawer", () => {
|
||||||
renderPanel();
|
renderPanel();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Fold stat block panel" }),
|
screen.getByRole("button", { name: "Collapse stat block panel" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
// No X close icon button — only backdrop dismiss and fold toggle
|
// No X close icon button — only backdrop dismiss and collapse toggle
|
||||||
const buttons = screen.getAllByRole("button");
|
const buttons = screen.getAllByRole("button");
|
||||||
const buttonLabels = buttons.map((b) => b.getAttribute("aria-label"));
|
const buttonLabels = buttons.map((b) => b.getAttribute("aria-label"));
|
||||||
expect(buttonLabels).not.toContain("Close");
|
expect(buttonLabels).not.toContain("Close");
|
||||||
@@ -175,8 +178,8 @@ describe("Stat Block Panel Fold/Unfold and Pin", () => {
|
|||||||
uploadAndCacheSource={vi.fn()}
|
uploadAndCacheSource={vi.fn()}
|
||||||
refreshCache={vi.fn()}
|
refreshCache={vi.fn()}
|
||||||
panelRole="pinned"
|
panelRole="pinned"
|
||||||
isFolded={false}
|
isCollapsed={false}
|
||||||
onToggleFold={vi.fn()}
|
onToggleCollapse={vi.fn()}
|
||||||
onPin={vi.fn()}
|
onPin={vi.fn()}
|
||||||
onUnpin={vi.fn()}
|
onUnpin={vi.fn()}
|
||||||
showPinButton={false}
|
showPinButton={false}
|
||||||
@@ -235,7 +238,7 @@ describe("Stat Block Panel Fold/Unfold and Pin", () => {
|
|||||||
it("positions browse panel on the right side", () => {
|
it("positions browse panel on the right side", () => {
|
||||||
renderPanel({ panelRole: "browse", side: "right" });
|
renderPanel({ panelRole: "browse", side: "right" });
|
||||||
const foldBtn = screen.getByRole("button", {
|
const foldBtn = screen.getByRole("button", {
|
||||||
name: "Fold stat block panel",
|
name: "Collapse stat block panel",
|
||||||
});
|
});
|
||||||
const panel = foldBtn.closest("div.fixed") as HTMLElement;
|
const panel = foldBtn.closest("div.fixed") as HTMLElement;
|
||||||
expect(panel?.className).toContain("right-0");
|
expect(panel?.className).toContain("right-0");
|
||||||
@@ -243,16 +246,16 @@ describe("Stat Block Panel Fold/Unfold and Pin", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("US3: Fold independence with pinned panel", () => {
|
describe("US3: Collapse independence with pinned panel", () => {
|
||||||
it("pinned panel has no fold button", () => {
|
it("pinned panel has no collapse button", () => {
|
||||||
renderPanel({ panelRole: "pinned", side: "left" });
|
renderPanel({ panelRole: "pinned", side: "left" });
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole("button", { name: /fold/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", isFolded: false });
|
renderPanel({ panelRole: "pinned", side: "left", isCollapsed: false });
|
||||||
const unpinBtn = screen.getByRole("button", {
|
const unpinBtn = screen.getByRole("button", {
|
||||||
name: "Unpin creature",
|
name: "Unpin creature",
|
||||||
});
|
});
|
||||||
@@ -30,11 +30,11 @@ describe("stripTags", () => {
|
|||||||
expect(stripTags("{@hit 5}")).toBe("+5");
|
expect(stripTags("{@hit 5}")).toBe("+5");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strips {@h} to Hit: ", () => {
|
it("strips {@h} to Hit:", () => {
|
||||||
expect(stripTags("{@h}")).toBe("Hit: ");
|
expect(stripTags("{@h}")).toBe("Hit: ");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strips {@hom} to Hit or Miss: ", () => {
|
it("strips {@hom} to Hit or Miss:", () => {
|
||||||
expect(stripTags("{@hom}")).toBe("Hit or Miss: ");
|
expect(stripTags("{@hom}")).toBe("Hit or Miss: ");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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(
|
||||||
const name = ABILITY_MAP[ability.trim().toLowerCase()];
|
/\{@actSave\s+([^}]+)\}/g,
|
||||||
return name ? `${name} saving throw` : `${ability} saving throw`;
|
(_, ability: string) => {
|
||||||
});
|
const name = ABILITY_MAP[ability.trim().toLowerCase()];
|
||||||
|
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
|
||||||
|
|||||||
88
apps/web/src/components/__tests__/action-bar.test.tsx
Normal file
88
apps/web/src/components/__tests__/action-bar.test.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ActionBar } from "../action-bar";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
onAddCombatant: vi.fn(),
|
||||||
|
onAddFromBestiary: vi.fn(),
|
||||||
|
bestiarySearch: () => [],
|
||||||
|
bestiaryLoaded: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderBar(overrides: Partial<Parameters<typeof ActionBar>[0]> = {}) {
|
||||||
|
const props = { ...defaultProps, ...overrides };
|
||||||
|
return render(<ActionBar {...props} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ActionBar", () => {
|
||||||
|
it("renders input with placeholder '+ Add combatants'", () => {
|
||||||
|
renderBar();
|
||||||
|
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submitting with a name calls onAddCombatant", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onAddCombatant = vi.fn();
|
||||||
|
renderBar({ onAddCombatant });
|
||||||
|
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);
|
||||||
|
expect(onAddCombatant).toHaveBeenCalledWith("Goblin", undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submitting with empty name does nothing", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onAddCombatant = vi.fn();
|
||||||
|
renderBar({ onAddCombatant });
|
||||||
|
// Submit the form directly (Enter on empty input)
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "{Enter}");
|
||||||
|
expect(onAddCombatant).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
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("shows roll all initiative button when showRollAllInitiative is true", () => {
|
||||||
|
const onRollAllInitiative = vi.fn();
|
||||||
|
renderBar({ showRollAllInitiative: true, onRollAllInitiative });
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Roll all initiative" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("roll all initiative button is disabled when rollAllInitiativeDisabled is true", () => {
|
||||||
|
const onRollAllInitiative = vi.fn();
|
||||||
|
renderBar({
|
||||||
|
showRollAllInitiative: true,
|
||||||
|
onRollAllInitiative,
|
||||||
|
rollAllInitiativeDisabled: true,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Roll all initiative" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
164
apps/web/src/components/__tests__/combatant-row.test.tsx
Normal file
164
apps/web/src/components/__tests__/combatant-row.test.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { combatantId } from "@initiative/domain";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { CombatantRow } from "../combatant-row";
|
||||||
|
import { PLAYER_COLOR_HEX } from "../player-icon-map";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
onRename: vi.fn(),
|
||||||
|
onSetInitiative: vi.fn(),
|
||||||
|
onRemove: vi.fn(),
|
||||||
|
onSetHp: vi.fn(),
|
||||||
|
onAdjustHp: vi.fn(),
|
||||||
|
onSetAc: vi.fn(),
|
||||||
|
onToggleCondition: vi.fn(),
|
||||||
|
onToggleConcentration: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderRow(
|
||||||
|
overrides: Partial<{
|
||||||
|
combatant: Parameters<typeof CombatantRow>[0]["combatant"];
|
||||||
|
isActive: boolean;
|
||||||
|
onRollInitiative: (id: ReturnType<typeof combatantId>) => void;
|
||||||
|
onRemove: (id: ReturnType<typeof combatantId>) => void;
|
||||||
|
onShowStatBlock: () => void;
|
||||||
|
}> = {},
|
||||||
|
) {
|
||||||
|
const combatant = overrides.combatant ?? {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
initiative: 15,
|
||||||
|
maxHp: 10,
|
||||||
|
currentHp: 10,
|
||||||
|
ac: 13,
|
||||||
|
};
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
combatant,
|
||||||
|
isActive: overrides.isActive ?? false,
|
||||||
|
onRollInitiative: overrides.onRollInitiative,
|
||||||
|
onShowStatBlock: overrides.onShowStatBlock,
|
||||||
|
onRemove: overrides.onRemove ?? defaultProps.onRemove,
|
||||||
|
};
|
||||||
|
return render(<CombatantRow {...props} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
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-accent/40");
|
||||||
|
});
|
||||||
|
|
||||||
|
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 '--' for current HP when no maxHp is set", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(screen.getByLabelText("No HP set")).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 calls onRemove after confirmation", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onRemove = vi.fn();
|
||||||
|
renderRow({ onRemove });
|
||||||
|
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);
|
||||||
|
expect(onRemove).toHaveBeenCalledWith(combatantId("1"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows d20 roll button when initiative is undefined and onRollInitiative is provided", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
},
|
||||||
|
onRollInitiative: vi.fn(),
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Roll initiative" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
63
apps/web/src/components/__tests__/condition-picker.test.tsx
Normal file
63
apps/web/src/components/__tests__/condition-picker.test.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// @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 { 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 result = render(
|
||||||
|
<ConditionPicker
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
115
apps/web/src/components/__tests__/hp-adjust-popover.test.tsx
Normal file
115
apps/web/src/components/__tests__/hp-adjust-popover.test.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// @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;
|
||||||
|
onClose: () => void;
|
||||||
|
}> = {},
|
||||||
|
) {
|
||||||
|
const onAdjust = overrides.onAdjust ?? vi.fn();
|
||||||
|
const onClose = overrides.onClose ?? vi.fn();
|
||||||
|
const result = render(
|
||||||
|
<HpAdjustPopover onAdjust={onAdjust} onClose={onClose} />,
|
||||||
|
);
|
||||||
|
return { ...result, onAdjust, 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
127
apps/web/src/components/__tests__/source-manager.test.tsx
Normal file
127
apps/web/src/components/__tests__/source-manager.test.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
// @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(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import * as bestiaryCache from "../../adapters/bestiary-cache.js";
|
||||||
|
import { SourceManager } from "../source-manager";
|
||||||
|
|
||||||
|
const mockGetCachedSources = vi.mocked(bestiaryCache.getCachedSources);
|
||||||
|
const mockClearSource = vi.mocked(bestiaryCache.clearSource);
|
||||||
|
const mockClearAll = vi.mocked(bestiaryCache.clearAll);
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SourceManager", () => {
|
||||||
|
it("shows 'No cached sources' empty state when no sources", async () => {
|
||||||
|
mockGetCachedSources.mockResolvedValue([]);
|
||||||
|
render(<SourceManager onCacheCleared={vi.fn()} />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("No cached sources")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists cached sources with display name and creature count", async () => {
|
||||||
|
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 onCacheCleared={vi.fn()} />);
|
||||||
|
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 onCacheCleared", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onCacheCleared = vi.fn();
|
||||||
|
mockGetCachedSources
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
sourceCode: "mm",
|
||||||
|
displayName: "Monster Manual",
|
||||||
|
creatureCount: 300,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.mockResolvedValue([]);
|
||||||
|
mockClearAll.mockResolvedValue(undefined);
|
||||||
|
render(<SourceManager onCacheCleared={onCacheCleared} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Clear All" }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockClearAll).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
expect(onCacheCleared).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("individual source delete button calls clear for that source", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onCacheCleared = vi.fn();
|
||||||
|
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 onCacheCleared={onCacheCleared} />);
|
||||||
|
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(onCacheCleared).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,8 +26,6 @@ function renderNav(overrides: Partial<Encounter> = {}) {
|
|||||||
onAdvanceTurn={vi.fn()}
|
onAdvanceTurn={vi.fn()}
|
||||||
onRetreatTurn={vi.fn()}
|
onRetreatTurn={vi.fn()}
|
||||||
onClearEncounter={vi.fn()}
|
onClearEncounter={vi.fn()}
|
||||||
onRollAllInitiative={vi.fn()}
|
|
||||||
onOpenSourceManager={vi.fn()}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -54,11 +52,11 @@ describe("TurnNavigation", () => {
|
|||||||
expect(container.textContent).not.toContain("—");
|
expect(container.textContent).not.toContain("—");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("round badge and combatant name are in separate DOM elements", () => {
|
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).not.toBe(name.parentElement);
|
expect(badge.parentElement).toBe(name.parentElement);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates the round badge when round changes", () => {
|
it("updates the round badge when round changes", () => {
|
||||||
@@ -72,8 +70,6 @@ describe("TurnNavigation", () => {
|
|||||||
onAdvanceTurn={vi.fn()}
|
onAdvanceTurn={vi.fn()}
|
||||||
onRetreatTurn={vi.fn()}
|
onRetreatTurn={vi.fn()}
|
||||||
onClearEncounter={vi.fn()}
|
onClearEncounter={vi.fn()}
|
||||||
onRollAllInitiative={vi.fn()}
|
|
||||||
onOpenSourceManager={vi.fn()}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.getByText("R2")).toBeInTheDocument();
|
expect(screen.getByText("R2")).toBeInTheDocument();
|
||||||
@@ -88,8 +84,6 @@ describe("TurnNavigation", () => {
|
|||||||
onAdvanceTurn={vi.fn()}
|
onAdvanceTurn={vi.fn()}
|
||||||
onRetreatTurn={vi.fn()}
|
onRetreatTurn={vi.fn()}
|
||||||
onClearEncounter={vi.fn()}
|
onClearEncounter={vi.fn()}
|
||||||
onRollAllInitiative={vi.fn()}
|
|
||||||
onOpenSourceManager={vi.fn()}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.getByText("R3")).toBeInTheDocument();
|
expect(screen.getByText("R3")).toBeInTheDocument();
|
||||||
@@ -110,8 +104,6 @@ describe("TurnNavigation", () => {
|
|||||||
onAdvanceTurn={vi.fn()}
|
onAdvanceTurn={vi.fn()}
|
||||||
onRetreatTurn={vi.fn()}
|
onRetreatTurn={vi.fn()}
|
||||||
onClearEncounter={vi.fn()}
|
onClearEncounter={vi.fn()}
|
||||||
onRollAllInitiative={vi.fn()}
|
|
||||||
onOpenSourceManager={vi.fn()}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
@@ -129,8 +121,6 @@ describe("TurnNavigation", () => {
|
|||||||
onAdvanceTurn={vi.fn()}
|
onAdvanceTurn={vi.fn()}
|
||||||
onRetreatTurn={vi.fn()}
|
onRetreatTurn={vi.fn()}
|
||||||
onClearEncounter={vi.fn()}
|
onClearEncounter={vi.fn()}
|
||||||
onRollAllInitiative={vi.fn()}
|
|
||||||
onOpenSourceManager={vi.fn()}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.getByText("Conjurer")).toBeInTheDocument();
|
expect(screen.getByText("Conjurer")).toBeInTheDocument();
|
||||||
@@ -173,16 +163,6 @@ describe("TurnNavigation", () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Next turn" }),
|
screen.getByRole("button", { name: "Next turn" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(
|
|
||||||
screen.getByRole("button", {
|
|
||||||
name: "Roll all initiative",
|
|
||||||
}),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByRole("button", {
|
|
||||||
name: "Manage cached sources",
|
|
||||||
}),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a 40-character name without truncation class issues", () => {
|
it("renders a 40-character name without truncation class issues", () => {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ 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 }}
|
||||||
@@ -29,8 +29,8 @@ export function AcShield({ value, onClick, className }: AcShieldProps) {
|
|||||||
>
|
>
|
||||||
<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 font-medium text-xs leading-none">
|
||||||
{value !== undefined ? value : "\u2014"}
|
{value == null ? "\u2014" : String(value)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
import { Check, Eye, Import, Minus, Plus } from "lucide-react";
|
import type { PlayerCharacter } from "@initiative/domain";
|
||||||
import { type FormEvent, useEffect, useRef, useState } from "react";
|
import {
|
||||||
|
Check,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Import,
|
||||||
|
Library,
|
||||||
|
Minus,
|
||||||
|
Plus,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import React, { type RefObject, useDeferredValue, useState } from "react";
|
||||||
import type { SearchResult } from "../hooks/use-bestiary.js";
|
import type { SearchResult } from "../hooks/use-bestiary.js";
|
||||||
|
import { cn } from "../lib/utils.js";
|
||||||
|
import { D20Icon } from "./d20-icon.js";
|
||||||
|
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||||
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";
|
||||||
|
|
||||||
interface QueuedCreature {
|
interface QueuedCreature {
|
||||||
result: SearchResult;
|
result: SearchResult;
|
||||||
@@ -20,12 +34,220 @@ interface ActionBarProps {
|
|||||||
onViewStatBlock?: (result: SearchResult) => void;
|
onViewStatBlock?: (result: SearchResult) => void;
|
||||||
onBulkImport?: () => void;
|
onBulkImport?: () => void;
|
||||||
bulkImportDisabled?: boolean;
|
bulkImportDisabled?: boolean;
|
||||||
|
inputRef?: RefObject<HTMLInputElement | null>;
|
||||||
|
playerCharacters?: readonly PlayerCharacter[];
|
||||||
|
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
||||||
|
onManagePlayers?: () => void;
|
||||||
|
onRollAllInitiative?: () => void;
|
||||||
|
showRollAllInitiative?: boolean;
|
||||||
|
rollAllInitiativeDisabled?: boolean;
|
||||||
|
onOpenSourceManager?: () => void;
|
||||||
|
autoFocus?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function creatureKey(r: SearchResult): string {
|
function creatureKey(r: SearchResult): string {
|
||||||
return `${r.source}:${r.name}`;
|
return `${r.source}:${r.name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AddModeSuggestions({
|
||||||
|
nameInput,
|
||||||
|
suggestions,
|
||||||
|
pcMatches,
|
||||||
|
suggestionIndex,
|
||||||
|
queued,
|
||||||
|
onDismiss,
|
||||||
|
onClickSuggestion,
|
||||||
|
onSetSuggestionIndex,
|
||||||
|
onSetQueued,
|
||||||
|
onConfirmQueued,
|
||||||
|
onAddFromPlayerCharacter,
|
||||||
|
onClear,
|
||||||
|
}: Readonly<{
|
||||||
|
nameInput: string;
|
||||||
|
suggestions: SearchResult[];
|
||||||
|
pcMatches: PlayerCharacter[];
|
||||||
|
suggestionIndex: number;
|
||||||
|
queued: QueuedCreature | null;
|
||||||
|
onDismiss: () => void;
|
||||||
|
onClear: () => void;
|
||||||
|
onClickSuggestion: (result: SearchResult) => void;
|
||||||
|
onSetSuggestionIndex: (i: number) => void;
|
||||||
|
onSetQueued: (q: QueuedCreature | null) => void;
|
||||||
|
onConfirmQueued: () => void;
|
||||||
|
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<div className="card-glow absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-lg border border-border bg-card">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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()}
|
||||||
|
onClick={onDismiss}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
<span className="flex-1">Add "{nameInput}" as custom</span>
|
||||||
|
<kbd className="rounded border border-border px-1.5 py-0.5 text-muted-foreground text-xs">
|
||||||
|
Esc
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
<div className="max-h-48 overflow-y-auto py-1">
|
||||||
|
{pcMatches.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="px-3 py-1 font-medium text-muted-foreground text-xs">
|
||||||
|
Players
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{pcMatches.map((pc) => {
|
||||||
|
const PcIcon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
|
||||||
|
const pcColor = pc.color
|
||||||
|
? PLAYER_COLOR_HEX[pc.color]
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
|
<li key={pc.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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()}
|
||||||
|
onClick={() => {
|
||||||
|
onAddFromPlayerCharacter?.(pc);
|
||||||
|
onClear();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!!PcIcon && (
|
||||||
|
<PcIcon size={14} style={{ color: pcColor }} />
|
||||||
|
)}
|
||||||
|
<span className="flex-1 truncate">{pc.name}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Player
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<ul>
|
||||||
|
{suggestions.map((result, i) => {
|
||||||
|
const key = creatureKey(result);
|
||||||
|
const isQueued =
|
||||||
|
queued !== null && creatureKey(queued.result) === key;
|
||||||
|
return (
|
||||||
|
<li key={key}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between px-3 py-1.5 text-left text-foreground text-sm",
|
||||||
|
isQueued && "bg-accent/30",
|
||||||
|
!isQueued && i === suggestionIndex && "bg-accent/20",
|
||||||
|
!isQueued &&
|
||||||
|
i !== suggestionIndex &&
|
||||||
|
"hover:bg-hover-neutral-bg",
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => onClickSuggestion(result)}
|
||||||
|
onMouseEnter={() => onSetSuggestionIndex(i)}
|
||||||
|
>
|
||||||
|
<span>{result.name}</span>
|
||||||
|
<span className="flex items-center gap-1 text-muted-foreground text-xs">
|
||||||
|
{isQueued ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded p-0.5 text-foreground hover:bg-accent/40"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (queued.count <= 1) {
|
||||||
|
onSetQueued(null);
|
||||||
|
} else {
|
||||||
|
onSetQueued({
|
||||||
|
...queued,
|
||||||
|
count: queued.count - 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Minus className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-foreground">
|
||||||
|
{queued.count}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded p-0.5 text-foreground hover:bg-accent/40"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSetQueued({
|
||||||
|
...queued,
|
||||||
|
count: queued.count + 1,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ml-0.5 rounded p-0.5 text-foreground hover:bg-accent/40"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onConfirmQueued();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
result.sourceDisplayName
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOverflowItems(opts: {
|
||||||
|
onManagePlayers?: () => void;
|
||||||
|
onOpenSourceManager?: () => void;
|
||||||
|
bestiaryLoaded: boolean;
|
||||||
|
onBulkImport?: () => void;
|
||||||
|
bulkImportDisabled?: boolean;
|
||||||
|
}): OverflowMenuItem[] {
|
||||||
|
const items: OverflowMenuItem[] = [];
|
||||||
|
if (opts.onManagePlayers) {
|
||||||
|
items.push({
|
||||||
|
icon: <Users className="h-4 w-4" />,
|
||||||
|
label: "Player Characters",
|
||||||
|
onClick: opts.onManagePlayers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (opts.onOpenSourceManager) {
|
||||||
|
items.push({
|
||||||
|
icon: <Library className="h-4 w-4" />,
|
||||||
|
label: "Manage Sources",
|
||||||
|
onClick: opts.onOpenSourceManager,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (opts.bestiaryLoaded && opts.onBulkImport) {
|
||||||
|
items.push({
|
||||||
|
icon: <Import className="h-4 w-4" />,
|
||||||
|
label: "Import All Sources",
|
||||||
|
onClick: opts.onBulkImport,
|
||||||
|
disabled: opts.bulkImportDisabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
export function ActionBar({
|
export function ActionBar({
|
||||||
onAddCombatant,
|
onAddCombatant,
|
||||||
onAddFromBestiary,
|
onAddFromBestiary,
|
||||||
@@ -34,22 +256,27 @@ export function ActionBar({
|
|||||||
onViewStatBlock,
|
onViewStatBlock,
|
||||||
onBulkImport,
|
onBulkImport,
|
||||||
bulkImportDisabled,
|
bulkImportDisabled,
|
||||||
}: ActionBarProps) {
|
inputRef,
|
||||||
|
playerCharacters,
|
||||||
|
onAddFromPlayerCharacter,
|
||||||
|
onManagePlayers,
|
||||||
|
onRollAllInitiative,
|
||||||
|
showRollAllInitiative,
|
||||||
|
rollAllInitiativeDisabled,
|
||||||
|
onOpenSourceManager,
|
||||||
|
autoFocus,
|
||||||
|
}: Readonly<ActionBarProps>) {
|
||||||
const [nameInput, setNameInput] = useState("");
|
const [nameInput, setNameInput] = useState("");
|
||||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||||
|
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
||||||
|
const deferredSuggestions = useDeferredValue(suggestions);
|
||||||
|
const deferredPcMatches = useDeferredValue(pcMatches);
|
||||||
const [suggestionIndex, setSuggestionIndex] = useState(-1);
|
const [suggestionIndex, setSuggestionIndex] = useState(-1);
|
||||||
const [queued, setQueued] = useState<QueuedCreature | null>(null);
|
const [queued, setQueued] = useState<QueuedCreature | null>(null);
|
||||||
const [customInit, setCustomInit] = useState("");
|
const [customInit, setCustomInit] = useState("");
|
||||||
const [customAc, setCustomAc] = useState("");
|
const [customAc, setCustomAc] = useState("");
|
||||||
const [customMaxHp, setCustomMaxHp] = useState("");
|
const [customMaxHp, setCustomMaxHp] = useState("");
|
||||||
|
const [browseMode, setBrowseMode] = useState(false);
|
||||||
// Stat block viewer: separate dropdown
|
|
||||||
const [viewerOpen, setViewerOpen] = useState(false);
|
|
||||||
const [viewerQuery, setViewerQuery] = useState("");
|
|
||||||
const [viewerResults, setViewerResults] = useState<SearchResult[]>([]);
|
|
||||||
const [viewerIndex, setViewerIndex] = useState(-1);
|
|
||||||
const viewerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const viewerInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const clearCustomFields = () => {
|
const clearCustomFields = () => {
|
||||||
setCustomInit("");
|
setCustomInit("");
|
||||||
@@ -57,15 +284,27 @@ export function ActionBar({
|
|||||||
setCustomMaxHp("");
|
setCustomMaxHp("");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearInput = () => {
|
||||||
|
setNameInput("");
|
||||||
|
setSuggestions([]);
|
||||||
|
setPcMatches([]);
|
||||||
|
setQueued(null);
|
||||||
|
setSuggestionIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissSuggestions = () => {
|
||||||
|
setSuggestions([]);
|
||||||
|
setPcMatches([]);
|
||||||
|
setQueued(null);
|
||||||
|
setSuggestionIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
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);
|
onAddFromBestiary(queued.result);
|
||||||
}
|
}
|
||||||
setQueued(null);
|
clearInput();
|
||||||
setNameInput("");
|
|
||||||
setSuggestions([]);
|
|
||||||
setSuggestionIndex(-1);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseNum = (v: string): number | undefined => {
|
const parseNum = (v: string): number | undefined => {
|
||||||
@@ -74,8 +313,9 @@ 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 (queued) {
|
if (queued) {
|
||||||
confirmQueued();
|
confirmQueued();
|
||||||
return;
|
return;
|
||||||
@@ -91,20 +331,32 @@ export function ActionBar({
|
|||||||
onAddCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
|
onAddCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
|
||||||
setNameInput("");
|
setNameInput("");
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
|
setPcMatches([]);
|
||||||
clearCustomFields();
|
clearCustomFields();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNameChange = (value: string) => {
|
const handleBrowseSearch = (value: string) => {
|
||||||
setNameInput(value);
|
setSuggestions(value.length >= 2 ? bestiarySearch(value) : []);
|
||||||
setSuggestionIndex(-1);
|
};
|
||||||
|
|
||||||
|
const handleAddSearch = (value: string) => {
|
||||||
let newSuggestions: SearchResult[] = [];
|
let newSuggestions: SearchResult[] = [];
|
||||||
|
let newPcMatches: PlayerCharacter[] = [];
|
||||||
if (value.length >= 2) {
|
if (value.length >= 2) {
|
||||||
newSuggestions = bestiarySearch(value);
|
newSuggestions = bestiarySearch(value);
|
||||||
setSuggestions(newSuggestions);
|
setSuggestions(newSuggestions);
|
||||||
|
if (playerCharacters && playerCharacters.length > 0) {
|
||||||
|
const lower = value.toLowerCase();
|
||||||
|
newPcMatches = playerCharacters.filter((pc) =>
|
||||||
|
pc.name.toLowerCase().includes(lower),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setPcMatches(newPcMatches);
|
||||||
} else {
|
} else {
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
|
setPcMatches([]);
|
||||||
}
|
}
|
||||||
if (newSuggestions.length > 0) {
|
if (newSuggestions.length > 0 || newPcMatches.length > 0) {
|
||||||
clearCustomFields();
|
clearCustomFields();
|
||||||
}
|
}
|
||||||
if (queued) {
|
if (queued) {
|
||||||
@@ -116,6 +368,16 @@ export function ActionBar({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNameChange = (value: string) => {
|
||||||
|
setNameInput(value);
|
||||||
|
setSuggestionIndex(-1);
|
||||||
|
if (browseMode) {
|
||||||
|
handleBrowseSearch(value);
|
||||||
|
} else {
|
||||||
|
handleAddSearch(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleClickSuggestion = (result: SearchResult) => {
|
const handleClickSuggestion = (result: SearchResult) => {
|
||||||
const key = creatureKey(result);
|
const key = creatureKey(result);
|
||||||
if (queued && creatureKey(queued.result) === key) {
|
if (queued && creatureKey(queued.result) === key) {
|
||||||
@@ -133,8 +395,11 @@ export function ActionBar({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasSuggestions =
|
||||||
|
deferredSuggestions.length > 0 || deferredPcMatches.length > 0;
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (suggestions.length === 0) return;
|
if (!hasSuggestions) return;
|
||||||
|
|
||||||
if (e.key === "ArrowDown") {
|
if (e.key === "ArrowDown") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -146,175 +411,138 @@ export function ActionBar({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleEnter();
|
handleEnter();
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
setQueued(null);
|
dismissSuggestions();
|
||||||
setSuggestionIndex(-1);
|
|
||||||
setSuggestions([]);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stat block viewer dropdown handlers
|
const handleBrowseKeyDown = (e: React.KeyboardEvent) => {
|
||||||
const openViewer = () => {
|
|
||||||
setViewerOpen(true);
|
|
||||||
setViewerQuery("");
|
|
||||||
setViewerResults([]);
|
|
||||||
setViewerIndex(-1);
|
|
||||||
requestAnimationFrame(() => viewerInputRef.current?.focus());
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeViewer = () => {
|
|
||||||
setViewerOpen(false);
|
|
||||||
setViewerQuery("");
|
|
||||||
setViewerResults([]);
|
|
||||||
setViewerIndex(-1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleViewerQueryChange = (value: string) => {
|
|
||||||
setViewerQuery(value);
|
|
||||||
setViewerIndex(-1);
|
|
||||||
if (value.length >= 2) {
|
|
||||||
setViewerResults(bestiarySearch(value));
|
|
||||||
} else {
|
|
||||||
setViewerResults([]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleViewerSelect = (result: SearchResult) => {
|
|
||||||
onViewStatBlock?.(result);
|
|
||||||
closeViewer();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleViewerKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
closeViewer();
|
setBrowseMode(false);
|
||||||
|
clearInput();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (viewerResults.length === 0) return;
|
if (suggestions.length === 0) return;
|
||||||
|
|
||||||
if (e.key === "ArrowDown") {
|
if (e.key === "ArrowDown") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setViewerIndex((i) => (i < viewerResults.length - 1 ? i + 1 : 0));
|
setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
|
||||||
} else if (e.key === "ArrowUp") {
|
} else if (e.key === "ArrowUp") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setViewerIndex((i) => (i > 0 ? i - 1 : viewerResults.length - 1));
|
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
||||||
} else if (e.key === "Enter" && viewerIndex >= 0) {
|
} else if (e.key === "Enter" && suggestionIndex >= 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleViewerSelect(viewerResults[viewerIndex]);
|
onViewStatBlock?.(suggestions[suggestionIndex]);
|
||||||
|
setBrowseMode(false);
|
||||||
|
clearInput();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Close viewer on outside click
|
const handleBrowseSelect = (result: SearchResult) => {
|
||||||
useEffect(() => {
|
onViewStatBlock?.(result);
|
||||||
if (!viewerOpen) return;
|
setBrowseMode(false);
|
||||||
function handleClickOutside(e: MouseEvent) {
|
clearInput();
|
||||||
if (viewerRef.current && !viewerRef.current.contains(e.target as Node)) {
|
};
|
||||||
closeViewer();
|
|
||||||
}
|
const toggleBrowseMode = () => {
|
||||||
}
|
setBrowseMode((m) => !m);
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
clearInput();
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
clearCustomFields();
|
||||||
}, [viewerOpen]);
|
};
|
||||||
|
|
||||||
|
const overflowItems = buildOverflowItems({
|
||||||
|
onManagePlayers,
|
||||||
|
onOpenSourceManager,
|
||||||
|
bestiaryLoaded,
|
||||||
|
onBulkImport,
|
||||||
|
bulkImportDisabled,
|
||||||
|
});
|
||||||
|
|
||||||
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"
|
||||||
>
|
>
|
||||||
<div className="relative flex-1">
|
<div className="flex-1">
|
||||||
<Input
|
<div className="relative max-w-xs">
|
||||||
type="text"
|
<Input
|
||||||
value={nameInput}
|
ref={inputRef}
|
||||||
onChange={(e) => handleNameChange(e.target.value)}
|
type="text"
|
||||||
onKeyDown={handleKeyDown}
|
value={nameInput}
|
||||||
placeholder="Search creatures to add..."
|
onChange={(e) => handleNameChange(e.target.value)}
|
||||||
className="max-w-xs"
|
onKeyDown={browseMode ? handleBrowseKeyDown : handleKeyDown}
|
||||||
/>
|
placeholder={
|
||||||
{suggestions.length > 0 && (
|
browseMode ? "Search stat blocks..." : "+ Add combatants"
|
||||||
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
|
}
|
||||||
<ul className="max-h-48 overflow-y-auto py-1">
|
className="pr-8"
|
||||||
{suggestions.map((result, i) => {
|
autoFocus={autoFocus}
|
||||||
const key = creatureKey(result);
|
/>
|
||||||
const isQueued =
|
{bestiaryLoaded && !!onViewStatBlock && (
|
||||||
queued !== null && creatureKey(queued.result) === key;
|
<button
|
||||||
return (
|
type="button"
|
||||||
<li key={key}>
|
tabIndex={-1}
|
||||||
|
className={cn(
|
||||||
|
"absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
|
||||||
|
browseMode && "text-accent",
|
||||||
|
)}
|
||||||
|
onClick={toggleBrowseMode}
|
||||||
|
title={browseMode ? "Switch to add mode" : "Browse stat blocks"}
|
||||||
|
aria-label={
|
||||||
|
browseMode ? "Switch to add mode" : "Browse stat blocks"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{browseMode ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{browseMode && deferredSuggestions.length > 0 && (
|
||||||
|
<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">
|
||||||
|
{deferredSuggestions.map((result, i) => (
|
||||||
|
<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(
|
||||||
isQueued
|
"flex w-full items-center justify-between px-3 py-1.5 text-left text-sm",
|
||||||
? "bg-accent/30 text-foreground"
|
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={() => handleClickSuggestion(result)}
|
onClick={() => handleBrowseSelect(result)}
|
||||||
onMouseEnter={() => setSuggestionIndex(i)}
|
onMouseEnter={() => setSuggestionIndex(i)}
|
||||||
>
|
>
|
||||||
<span>{result.name}</span>
|
<span>{result.name}</span>
|
||||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
<span className="text-muted-foreground text-xs">
|
||||||
{isQueued ? (
|
{result.sourceDisplayName}
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded p-0.5 text-foreground hover:bg-accent/40"
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (queued.count <= 1) {
|
|
||||||
setQueued(null);
|
|
||||||
} else {
|
|
||||||
setQueued({
|
|
||||||
...queued,
|
|
||||||
count: queued.count - 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Minus className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-foreground">
|
|
||||||
{queued.count}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded p-0.5 text-foreground hover:bg-accent/40"
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setQueued({
|
|
||||||
...queued,
|
|
||||||
count: queued.count + 1,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ml-0.5 rounded p-0.5 text-foreground hover:bg-accent/40"
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
confirmQueued();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
result.sourceDisplayName
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
);
|
))}
|
||||||
})}
|
</ul>
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
{!browseMode && hasSuggestions && (
|
||||||
|
<AddModeSuggestions
|
||||||
|
nameInput={nameInput}
|
||||||
|
suggestions={deferredSuggestions}
|
||||||
|
pcMatches={deferredPcMatches}
|
||||||
|
suggestionIndex={suggestionIndex}
|
||||||
|
queued={queued}
|
||||||
|
onDismiss={dismissSuggestions}
|
||||||
|
onClear={clearInput}
|
||||||
|
onClickSuggestion={handleClickSuggestion}
|
||||||
|
onSetSuggestionIndex={setSuggestionIndex}
|
||||||
|
onSetQueued={setQueued}
|
||||||
|
onConfirmQueued={confirmQueued}
|
||||||
|
onAddFromPlayerCharacter={onAddFromPlayerCharacter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{nameInput.length >= 2 && suggestions.length === 0 && (
|
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -342,75 +570,24 @@ export function ActionBar({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button type="submit" size="sm">
|
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||||
Add
|
<Button type="submit">Add</Button>
|
||||||
</Button>
|
|
||||||
{bestiaryLoaded && onViewStatBlock && (
|
|
||||||
<div ref={viewerRef} className="relative">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => (viewerOpen ? closeViewer() : openViewer())}
|
|
||||||
>
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
{viewerOpen && (
|
|
||||||
<div className="absolute bottom-full right-0 z-50 mb-1 w-64 rounded-md border border-border bg-card shadow-lg">
|
|
||||||
<div className="p-2">
|
|
||||||
<Input
|
|
||||||
ref={viewerInputRef}
|
|
||||||
type="text"
|
|
||||||
value={viewerQuery}
|
|
||||||
onChange={(e) => handleViewerQueryChange(e.target.value)}
|
|
||||||
onKeyDown={handleViewerKeyDown}
|
|
||||||
placeholder="Search stat blocks..."
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{viewerResults.length > 0 && (
|
|
||||||
<ul className="max-h-48 overflow-y-auto border-t border-border py-1">
|
|
||||||
{viewerResults.map((result, i) => (
|
|
||||||
<li key={creatureKey(result)}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
|
|
||||||
i === viewerIndex
|
|
||||||
? "bg-accent/20 text-foreground"
|
|
||||||
: "text-foreground hover:bg-hover-neutral-bg"
|
|
||||||
}`}
|
|
||||||
onClick={() => handleViewerSelect(result)}
|
|
||||||
onMouseEnter={() => setViewerIndex(i)}
|
|
||||||
>
|
|
||||||
<span>{result.name}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{result.sourceDisplayName}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
{viewerQuery.length >= 2 && viewerResults.length === 0 && (
|
|
||||||
<div className="border-t border-border px-3 py-2 text-sm text-muted-foreground">
|
|
||||||
No creatures found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{bestiaryLoaded && onBulkImport && (
|
{showRollAllInitiative && !!onRollAllInitiative && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={onBulkImport}
|
className="text-muted-foreground hover:text-hover-action"
|
||||||
disabled={bulkImportDisabled}
|
onClick={onRollAllInitiative}
|
||||||
|
disabled={rollAllInitiativeDisabled}
|
||||||
|
title="Roll all initiative"
|
||||||
|
aria-label="Roll all initiative"
|
||||||
>
|
>
|
||||||
<Import className="h-4 w-4" />
|
<D20Icon className="h-6 w-6" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
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 type { BulkImportState } from "../hooks/use-bulk-import.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
@@ -18,19 +18,18 @@ export function BulkImportPrompt({
|
|||||||
importState,
|
importState,
|
||||||
onStartImport,
|
onStartImport,
|
||||||
onDone,
|
onDone,
|
||||||
}: BulkImportPromptProps) {
|
}: Readonly<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;
|
||||||
|
|
||||||
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 size="sm" onClick={onDone}>
|
<Button onClick={onDone}>Done</Button>
|
||||||
Done
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -42,9 +41,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 size="sm" onClick={onDone}>
|
<Button onClick={onDone}>Done</Button>
|
||||||
Done
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -58,7 +55,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>
|
||||||
@@ -78,24 +75,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">
|
||||||
Bulk Import 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. This will
|
Load stat block data for all {totalSources} sources at once.
|
||||||
download approximately 12.5 MB of data.
|
|
||||||
</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)}
|
||||||
@@ -103,11 +96,7 @@ export function BulkImportPrompt({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button onClick={() => onStartImport(baseUrl)} disabled={isDisabled}>
|
||||||
size="sm"
|
|
||||||
onClick={() => onStartImport(baseUrl)}
|
|
||||||
disabled={isDisabled}
|
|
||||||
>
|
|
||||||
Load All
|
Load All
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
49
apps/web/src/components/bulk-import-toasts.tsx
Normal file
49
apps/web/src/components/bulk-import-toasts.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
||||||
|
import { Toast } from "./toast.js";
|
||||||
|
|
||||||
|
interface BulkImportToastsProps {
|
||||||
|
state: BulkImportState;
|
||||||
|
visible: boolean;
|
||||||
|
onReset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BulkImportToasts({
|
||||||
|
state,
|
||||||
|
visible,
|
||||||
|
onReset,
|
||||||
|
}: Readonly<BulkImportToastsProps>) {
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
if (state.status === "loading") {
|
||||||
|
return (
|
||||||
|
<Toast
|
||||||
|
message={`Loading sources... ${state.completed + state.failed}/${state.total}`}
|
||||||
|
progress={
|
||||||
|
state.total > 0 ? (state.completed + state.failed) / state.total : 0
|
||||||
|
}
|
||||||
|
onDismiss={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === "complete") {
|
||||||
|
return (
|
||||||
|
<Toast
|
||||||
|
message="All sources loaded"
|
||||||
|
onDismiss={onReset}
|
||||||
|
autoDismissMs={3000}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === "partial-failure") {
|
||||||
|
return (
|
||||||
|
<Toast
|
||||||
|
message={`Loaded ${state.completed}/${state.total} sources (${state.failed} failed)`}
|
||||||
|
onDismiss={onReset}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
36
apps/web/src/components/color-palette.tsx
Normal file
36
apps/web/src/components/color-palette.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { VALID_PLAYER_COLORS } from "@initiative/domain";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { PLAYER_COLOR_HEX } from "./player-icon-map";
|
||||||
|
|
||||||
|
interface ColorPaletteProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (color: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = [...VALID_PLAYER_COLORS] as string[];
|
||||||
|
|
||||||
|
export function ColorPalette({ value, onChange }: Readonly<ColorPaletteProps>) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{COLORS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(value === color ? "" : color)}
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 rounded-full transition-all",
|
||||||
|
value === color
|
||||||
|
? "scale-110 ring-2 ring-foreground ring-offset-2 ring-offset-background"
|
||||||
|
: "hover:scale-110",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
PLAYER_COLOR_HEX[color as keyof typeof PLAYER_COLOR_HEX],
|
||||||
|
}}
|
||||||
|
aria-label={color}
|
||||||
|
title={color}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,8 +2,9 @@ import {
|
|||||||
type CombatantId,
|
type CombatantId,
|
||||||
type ConditionId,
|
type ConditionId,
|
||||||
deriveHpStatus,
|
deriveHpStatus,
|
||||||
|
type PlayerIcon,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { Brain, X } from "lucide-react";
|
import { BookOpen, Brain, 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 { cn } from "../lib/utils";
|
||||||
import { AcShield } from "./ac-shield";
|
import { AcShield } from "./ac-shield";
|
||||||
@@ -11,6 +12,7 @@ import { ConditionPicker } from "./condition-picker";
|
|||||||
import { ConditionTags } from "./condition-tags";
|
import { ConditionTags } from "./condition-tags";
|
||||||
import { D20Icon } from "./d20-icon";
|
import { D20Icon } from "./d20-icon";
|
||||||
import { HpAdjustPopover } from "./hp-adjust-popover";
|
import { HpAdjustPopover } from "./hp-adjust-popover";
|
||||||
|
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||||
import { ConfirmButton } from "./ui/confirm-button";
|
import { ConfirmButton } from "./ui/confirm-button";
|
||||||
import { Input } from "./ui/input";
|
import { Input } from "./ui/input";
|
||||||
|
|
||||||
@@ -23,6 +25,8 @@ interface Combatant {
|
|||||||
readonly ac?: number;
|
readonly ac?: number;
|
||||||
readonly conditions?: readonly ConditionId[];
|
readonly conditions?: readonly ConditionId[];
|
||||||
readonly isConcentrating?: boolean;
|
readonly isConcentrating?: boolean;
|
||||||
|
readonly color?: string;
|
||||||
|
readonly icon?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CombatantRowProps {
|
interface CombatantRowProps {
|
||||||
@@ -44,19 +48,16 @@ function EditableName({
|
|||||||
name,
|
name,
|
||||||
combatantId,
|
combatantId,
|
||||||
onRename,
|
onRename,
|
||||||
onShowStatBlock,
|
color,
|
||||||
}: {
|
}: 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;
|
||||||
}) {
|
}>) {
|
||||||
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();
|
||||||
@@ -72,46 +73,6 @@ 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
|
||||||
@@ -130,29 +91,24 @@ function EditableName({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={startEditing}
|
||||||
onClick={handleClick}
|
className="cursor-text truncate text-left text-foreground text-sm transition-colors hover:text-hover-neutral"
|
||||||
onTouchStart={handleTouchStart}
|
style={color ? { color } : undefined}
|
||||||
onTouchEnd={cancelLongPress}
|
>
|
||||||
onTouchCancel={cancelLongPress}
|
{name}
|
||||||
onTouchMove={cancelLongPress}
|
</button>
|
||||||
className="truncate text-left text-sm text-foreground cursor-text hover:text-hover-neutral transition-colors"
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -198,7 +154,7 @@ 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="inline-block h-7 min-w-[3ch] text-center text-muted-foreground text-sm tabular-nums leading-7 transition-colors hover:text-hover-neutral"
|
||||||
>
|
>
|
||||||
{maxHp ?? "Max"}
|
{maxHp ?? "Max"}
|
||||||
</button>
|
</button>
|
||||||
@@ -210,12 +166,12 @@ function ClickableHp({
|
|||||||
maxHp,
|
maxHp,
|
||||||
onAdjust,
|
onAdjust,
|
||||||
dimmed,
|
dimmed,
|
||||||
}: {
|
}: Readonly<{
|
||||||
currentHp: number | undefined;
|
currentHp: number | undefined;
|
||||||
maxHp: number | undefined;
|
maxHp: number | undefined;
|
||||||
onAdjust: (delta: number) => void;
|
onAdjust: (delta: number) => void;
|
||||||
dimmed?: boolean;
|
dimmed?: boolean;
|
||||||
}) {
|
}>) {
|
||||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||||
const status = deriveHpStatus(currentHp, maxHp);
|
const status = deriveHpStatus(currentHp, maxHp);
|
||||||
|
|
||||||
@@ -223,9 +179,11 @@ function ClickableHp({
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-block h-7 w-[4ch] text-center text-sm leading-7 tabular-nums text-muted-foreground",
|
"inline-block h-7 w-[4ch] text-center text-muted-foreground text-sm tabular-nums leading-7",
|
||||||
dimmed && "opacity-50",
|
dimmed && "opacity-50",
|
||||||
)}
|
)}
|
||||||
|
role="status"
|
||||||
|
aria-label="No HP set"
|
||||||
>
|
>
|
||||||
--
|
--
|
||||||
</span>
|
</span>
|
||||||
@@ -237,8 +195,9 @@ function ClickableHp({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPopoverOpen(true)}
|
onClick={() => setPopoverOpen(true)}
|
||||||
|
aria-label={`Current HP: ${currentHp} (${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 tabular-nums 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",
|
||||||
@@ -247,7 +206,7 @@ function ClickableHp({
|
|||||||
>
|
>
|
||||||
{currentHp}
|
{currentHp}
|
||||||
</button>
|
</button>
|
||||||
{popoverOpen && (
|
{!!popoverOpen && (
|
||||||
<HpAdjustPopover
|
<HpAdjustPopover
|
||||||
onAdjust={onAdjust}
|
onAdjust={onAdjust}
|
||||||
onClose={() => setPopoverOpen(false)}
|
onClose={() => setPopoverOpen(false)}
|
||||||
@@ -260,10 +219,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);
|
||||||
@@ -314,13 +273,13 @@ 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) => 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);
|
||||||
@@ -390,10 +349,10 @@ function InitiativeDisplay({
|
|||||||
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",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -406,7 +365,7 @@ 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) return "border border-accent/40 bg-accent/10 card-glow";
|
||||||
if (isConcentrating) return "border-l-2 border-l-purple-400";
|
if (isConcentrating) return "border-l-2 border-l-purple-400";
|
||||||
return "border-l-2 border-l-transparent";
|
return "border-l-2 border-l-transparent";
|
||||||
}
|
}
|
||||||
@@ -420,17 +379,6 @@ 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,
|
||||||
@@ -478,35 +426,28 @@ export function CombatantRow({
|
|||||||
}
|
}
|
||||||
}, [combatant.isConcentrating]);
|
}, [combatant.isConcentrating]);
|
||||||
|
|
||||||
|
const pcColor = combatant.color
|
||||||
|
? PLAYER_COLOR_HEX[combatant.color as keyof typeof PLAYER_COLOR_HEX]
|
||||||
|
: 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_3rem_1fr_auto_auto_2rem] items-center gap-3 py-2">
|
||||||
{/* Concentration */}
|
{/* Concentration */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={() => onToggleConcentration(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),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -514,39 +455,60 @@ export function CombatantRow({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Initiative */}
|
{/* Initiative */}
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
<InitiativeDisplay
|
||||||
<div
|
initiative={initiative}
|
||||||
onClick={(e) => e.stopPropagation()}
|
combatantId={id}
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
dimmed={dimmed}
|
||||||
>
|
onSetInitiative={onSetInitiative}
|
||||||
<InitiativeDisplay
|
onRollInitiative={onRollInitiative}
|
||||||
initiative={initiative}
|
/>
|
||||||
combatantId={id}
|
|
||||||
dimmed={dimmed}
|
|
||||||
onSetInitiative={onSetInitiative}
|
|
||||||
onRollInitiative={onRollInitiative}
|
|
||||||
/>
|
|
||||||
</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",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{!!onShowStatBlock && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onShowStatBlock}
|
||||||
|
title="View stat block"
|
||||||
|
aria-label="View stat block"
|
||||||
|
className="shrink-0 text-muted-foreground transition-colors hover:text-hover-neutral"
|
||||||
|
>
|
||||||
|
<BookOpen size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!!combatant.icon &&
|
||||||
|
!!combatant.color &&
|
||||||
|
(() => {
|
||||||
|
const PcIcon = PLAYER_ICON_MAP[combatant.icon as PlayerIcon];
|
||||||
|
const iconColor =
|
||||||
|
PLAYER_COLOR_HEX[
|
||||||
|
combatant.color as keyof typeof PLAYER_COLOR_HEX
|
||||||
|
];
|
||||||
|
return PcIcon ? (
|
||||||
|
<PcIcon
|
||||||
|
size={14}
|
||||||
|
style={{ color: iconColor }}
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
<EditableName
|
<EditableName
|
||||||
name={name}
|
name={name}
|
||||||
combatantId={id}
|
combatantId={id}
|
||||||
onRename={onRename}
|
onRename={onRename}
|
||||||
onShowStatBlock={onShowStatBlock}
|
color={pcColor}
|
||||||
/>
|
/>
|
||||||
<ConditionTags
|
<ConditionTags
|
||||||
conditions={combatant.conditions}
|
conditions={combatant.conditions}
|
||||||
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
|
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
|
||||||
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
||||||
/>
|
/>
|
||||||
{pickerOpen && (
|
{!!pickerOpen && (
|
||||||
<ConditionPicker
|
<ConditionPicker
|
||||||
activeConditions={combatant.conditions}
|
activeConditions={combatant.conditions}
|
||||||
onToggle={(conditionId) => onToggleCondition(id, conditionId)}
|
onToggle={(conditionId) => onToggleCondition(id, conditionId)}
|
||||||
@@ -556,22 +518,12 @@ export function CombatantRow({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AC */}
|
{/* AC */}
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
<div className={cn(dimmed && "opacity-50")}>
|
||||||
<div
|
|
||||||
className={cn(dimmed && "opacity-50")}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
|
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* HP */}
|
{/* HP */}
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
<div className="flex items-center gap-1">
|
||||||
<div
|
|
||||||
className="flex items-center gap-1"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<ClickableHp
|
<ClickableHp
|
||||||
currentHp={currentHp}
|
currentHp={currentHp}
|
||||||
maxHp={maxHp}
|
maxHp={maxHp}
|
||||||
@@ -581,7 +533,7 @@ export function CombatantRow({
|
|||||||
{maxHp !== undefined && (
|
{maxHp !== undefined && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm tabular-nums text-muted-foreground",
|
"text-muted-foreground text-sm tabular-nums",
|
||||||
dimmed && "opacity-50",
|
dimmed && "opacity-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -598,7 +550,7 @@ export function CombatantRow({
|
|||||||
icon={<X size={16} />}
|
icon={<X size={16} />}
|
||||||
label="Remove combatant"
|
label="Remove combatant"
|
||||||
onConfirm={() => onRemove(id)}
|
onConfirm={() => onRemove(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>
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export function ConditionPicker({
|
|||||||
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 [flipped, setFlipped] = useState(false);
|
||||||
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined);
|
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined);
|
||||||
@@ -97,7 +97,7 @@ export function ConditionPicker({
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute left-0 z-10 w-fit overflow-y-auto rounded-md border border-border bg-background p-1 shadow-lg",
|
"card-glow absolute left-0 z-10 w-fit overflow-y-auto rounded-lg border border-border bg-background p-1",
|
||||||
flipped ? "bottom-full mb-1" : "top-full mt-1",
|
flipped ? "bottom-full mb-1" : "top-full mt-1",
|
||||||
)}
|
)}
|
||||||
style={maxHeight ? { maxHeight } : undefined}
|
style={maxHeight ? { maxHeight } : undefined}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
ZapOff,
|
ZapOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { cn } from "../lib/utils.js";
|
||||||
|
|
||||||
const ICON_MAP: Record<string, LucideIcon> = {
|
const ICON_MAP: Record<string, LucideIcon> = {
|
||||||
EyeOff,
|
EyeOff,
|
||||||
@@ -60,7 +61,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) => {
|
||||||
@@ -75,7 +76,10 @@ export function ConditionTags({
|
|||||||
type="button"
|
type="button"
|
||||||
title={def.label}
|
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);
|
||||||
@@ -89,7 +93,7 @@ export function ConditionTags({
|
|||||||
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();
|
||||||
|
|||||||
192
apps/web/src/components/create-player-modal.tsx
Normal file
192
apps/web/src/components/create-player-modal.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import type { PlayerCharacter } from "@initiative/domain";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { ColorPalette } from "./color-palette";
|
||||||
|
import { IconGrid } from "./icon-grid";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Input } from "./ui/input";
|
||||||
|
|
||||||
|
interface CreatePlayerModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (
|
||||||
|
name: string,
|
||||||
|
ac: number,
|
||||||
|
maxHp: number,
|
||||||
|
color: string | undefined,
|
||||||
|
icon: string | undefined,
|
||||||
|
) => void;
|
||||||
|
playerCharacter?: PlayerCharacter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreatePlayerModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
playerCharacter,
|
||||||
|
}: Readonly<CreatePlayerModalProps>) {
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [ac, setAc] = useState("10");
|
||||||
|
const [maxHp, setMaxHp] = useState("10");
|
||||||
|
const [color, setColor] = useState("blue");
|
||||||
|
const [icon, setIcon] = useState("sword");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const isEdit = !!playerCharacter;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
if (playerCharacter) {
|
||||||
|
setName(playerCharacter.name);
|
||||||
|
setAc(String(playerCharacter.ac));
|
||||||
|
setMaxHp(String(playerCharacter.maxHp));
|
||||||
|
setColor(playerCharacter.color ?? "");
|
||||||
|
setIcon(playerCharacter.icon ?? "");
|
||||||
|
} else {
|
||||||
|
setName("");
|
||||||
|
setAc("10");
|
||||||
|
setMaxHp("10");
|
||||||
|
setColor("");
|
||||||
|
setIcon("");
|
||||||
|
}
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
}, [open, playerCharacter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
if (open && !dialog.open) {
|
||||||
|
dialog.showModal();
|
||||||
|
} else if (!open && dialog.open) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
function handleCancel(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
function handleBackdropClick(e: MouseEvent) {
|
||||||
|
if (e.target === dialog) onClose();
|
||||||
|
}
|
||||||
|
dialog.addEventListener("cancel", handleCancel);
|
||||||
|
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||||
|
return () => {
|
||||||
|
dialog.removeEventListener("cancel", handleCancel);
|
||||||
|
dialog.removeEventListener("mousedown", handleBackdropClick);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (trimmed === "") {
|
||||||
|
setError("Name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const acNum = Number.parseInt(ac, 10);
|
||||||
|
if (Number.isNaN(acNum) || acNum < 0) {
|
||||||
|
setError("AC must be a non-negative number");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hpNum = Number.parseInt(maxHp, 10);
|
||||||
|
if (Number.isNaN(hpNum) || hpNum < 1) {
|
||||||
|
setError("Max HP must be at least 1");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSave(trimmed, acNum, hpNum, color || undefined, icon || undefined);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={dialogRef}
|
||||||
|
className="card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold text-foreground text-lg">
|
||||||
|
{isEdit ? "Edit Player" : "Create Player"}
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="mb-1 block text-muted-foreground text-sm">Name</span>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setName(e.target.value);
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
placeholder="Character name"
|
||||||
|
aria-label="Name"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{!!error && <p className="mt-1 text-destructive text-sm">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="mb-1 block text-muted-foreground text-sm">AC</span>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={ac}
|
||||||
|
onChange={(e) => setAc(e.target.value)}
|
||||||
|
placeholder="AC"
|
||||||
|
aria-label="AC"
|
||||||
|
className="text-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="mb-1 block text-muted-foreground text-sm">
|
||||||
|
Max HP
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={maxHp}
|
||||||
|
onChange={(e) => setMaxHp(e.target.value)}
|
||||||
|
placeholder="Max HP"
|
||||||
|
aria-label="Max HP"
|
||||||
|
className="text-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="mb-2 block text-muted-foreground text-sm">
|
||||||
|
Color
|
||||||
|
</span>
|
||||||
|
<ColorPalette value={color} onChange={setColor} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="mb-2 block text-muted-foreground text-sm">Icon</span>
|
||||||
|
<IconGrid value={icon} onChange={setIcon} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button type="button" variant="ghost" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,9 +6,10 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Button } from "./ui/button";
|
|
||||||
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 onClose: () => void;
|
readonly onClose: () => void;
|
||||||
@@ -86,7 +87,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 }
|
||||||
@@ -103,36 +104,32 @@ 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);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
/>
|
/>
|
||||||
<Button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
disabled={!isValid}
|
disabled={!isValid}
|
||||||
className="h-7 w-7 shrink-0 text-red-400 hover:bg-red-950 hover:text-red-300"
|
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"
|
||||||
onClick={() => applyDelta(-1)}
|
onClick={() => applyDelta(-1)}
|
||||||
title="Apply damage"
|
title="Apply damage"
|
||||||
aria-label="Apply damage"
|
aria-label="Apply damage"
|
||||||
>
|
>
|
||||||
<Sword size={14} />
|
<Sword size={14} />
|
||||||
</Button>
|
</button>
|
||||||
<Button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
disabled={!isValid}
|
disabled={!isValid}
|
||||||
className="h-7 w-7 shrink-0 text-emerald-400 hover:bg-emerald-950 hover:text-emerald-300"
|
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"
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
38
apps/web/src/components/icon-grid.tsx
Normal file
38
apps/web/src/components/icon-grid.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { PlayerIcon } from "@initiative/domain";
|
||||||
|
import { VALID_PLAYER_ICONS } from "@initiative/domain";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { PLAYER_ICON_MAP } from "./player-icon-map";
|
||||||
|
|
||||||
|
interface IconGridProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (icon: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICONS = [...VALID_PLAYER_ICONS] as PlayerIcon[];
|
||||||
|
|
||||||
|
export function IconGrid({ value, onChange }: Readonly<IconGridProps>) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{ICONS.map((iconId) => {
|
||||||
|
const Icon = PLAYER_ICON_MAP[iconId];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={iconId}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(value === iconId ? "" : iconId)}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-9 items-center justify-center rounded-md transition-all",
|
||||||
|
value === iconId
|
||||||
|
? "bg-primary/20 text-foreground ring-2 ring-primary"
|
||||||
|
: "text-muted-foreground hover:bg-card hover:text-foreground",
|
||||||
|
)}
|
||||||
|
aria-label={iconId}
|
||||||
|
title={iconId}
|
||||||
|
>
|
||||||
|
<Icon size={20} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
apps/web/src/components/player-character-section.tsx
Normal file
93
apps/web/src/components/player-character-section.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||||
|
import { type RefObject, useImperativeHandle, useState } from "react";
|
||||||
|
import { CreatePlayerModal } from "./create-player-modal.js";
|
||||||
|
import { PlayerManagement } from "./player-management.js";
|
||||||
|
|
||||||
|
export interface PlayerCharacterSectionHandle {
|
||||||
|
openManagement: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlayerCharacterSectionProps {
|
||||||
|
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 = function PlayerCharacterSectionInner({
|
||||||
|
characters,
|
||||||
|
onCreateCharacter,
|
||||||
|
onEditCharacter,
|
||||||
|
onDeleteCharacter,
|
||||||
|
ref,
|
||||||
|
}: PlayerCharacterSectionProps & {
|
||||||
|
ref?: RefObject<PlayerCharacterSectionHandle | null>;
|
||||||
|
}) {
|
||||||
|
const [managementOpen, setManagementOpen] = useState(false);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [editingPlayer, setEditingPlayer] = useState<
|
||||||
|
PlayerCharacter | undefined
|
||||||
|
>();
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
openManagement: () => setManagementOpen(true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CreatePlayerModal
|
||||||
|
open={createOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setCreateOpen(false);
|
||||||
|
setEditingPlayer(undefined);
|
||||||
|
setManagementOpen(true);
|
||||||
|
}}
|
||||||
|
onSave={(name, ac, maxHp, color, icon) => {
|
||||||
|
if (editingPlayer) {
|
||||||
|
onEditCharacter(editingPlayer.id, {
|
||||||
|
name,
|
||||||
|
ac,
|
||||||
|
maxHp,
|
||||||
|
color: color ?? null,
|
||||||
|
icon: icon ?? null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onCreateCharacter(name, ac, maxHp, color, icon);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
playerCharacter={editingPlayer}
|
||||||
|
/>
|
||||||
|
<PlayerManagement
|
||||||
|
open={managementOpen}
|
||||||
|
onClose={() => setManagementOpen(false)}
|
||||||
|
characters={characters}
|
||||||
|
onEdit={(pc) => {
|
||||||
|
setEditingPlayer(pc);
|
||||||
|
setCreateOpen(true);
|
||||||
|
setManagementOpen(false);
|
||||||
|
}}
|
||||||
|
onDelete={(id) => onDeleteCharacter(id)}
|
||||||
|
onCreate={() => {
|
||||||
|
setEditingPlayer(undefined);
|
||||||
|
setCreateOpen(true);
|
||||||
|
setManagementOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
50
apps/web/src/components/player-icon-map.ts
Normal file
50
apps/web/src/components/player-icon-map.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type { PlayerColor, PlayerIcon } from "@initiative/domain";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Axe,
|
||||||
|
Crosshair,
|
||||||
|
Crown,
|
||||||
|
Eye,
|
||||||
|
Feather,
|
||||||
|
Flame,
|
||||||
|
Heart,
|
||||||
|
Moon,
|
||||||
|
Shield,
|
||||||
|
Skull,
|
||||||
|
Star,
|
||||||
|
Sun,
|
||||||
|
Sword,
|
||||||
|
Wand,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export const PLAYER_ICON_MAP: Record<PlayerIcon, LucideIcon> = {
|
||||||
|
sword: Sword,
|
||||||
|
shield: Shield,
|
||||||
|
skull: Skull,
|
||||||
|
heart: Heart,
|
||||||
|
wand: Wand,
|
||||||
|
flame: Flame,
|
||||||
|
crown: Crown,
|
||||||
|
star: Star,
|
||||||
|
moon: Moon,
|
||||||
|
sun: Sun,
|
||||||
|
axe: Axe,
|
||||||
|
crosshair: Crosshair,
|
||||||
|
eye: Eye,
|
||||||
|
feather: Feather,
|
||||||
|
zap: Zap,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PLAYER_COLOR_HEX: Record<PlayerColor, string> = {
|
||||||
|
red: "#ef4444",
|
||||||
|
blue: "#3b82f6",
|
||||||
|
green: "#22c55e",
|
||||||
|
purple: "#a855f7",
|
||||||
|
orange: "#f97316",
|
||||||
|
pink: "#ec4899",
|
||||||
|
cyan: "#06b6d4",
|
||||||
|
yellow: "#eab308",
|
||||||
|
emerald: "#10b981",
|
||||||
|
indigo: "#6366f1",
|
||||||
|
};
|
||||||
133
apps/web/src/components/player-management.tsx
Normal file
133
apps/web/src/components/player-management.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||||
|
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { ConfirmButton } from "./ui/confirm-button";
|
||||||
|
|
||||||
|
interface PlayerManagementProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
characters: readonly PlayerCharacter[];
|
||||||
|
onEdit: (pc: PlayerCharacter) => void;
|
||||||
|
onDelete: (id: PlayerCharacterId) => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlayerManagement({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
characters,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onCreate,
|
||||||
|
}: Readonly<PlayerManagementProps>) {
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
if (open && !dialog.open) {
|
||||||
|
dialog.showModal();
|
||||||
|
} else if (!open && dialog.open) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
function handleCancel(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
function handleBackdropClick(e: MouseEvent) {
|
||||||
|
if (e.target === dialog) onClose();
|
||||||
|
}
|
||||||
|
dialog.addEventListener("cancel", handleCancel);
|
||||||
|
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||||
|
return () => {
|
||||||
|
dialog.removeEventListener("cancel", handleCancel);
|
||||||
|
dialog.removeEventListener("mousedown", handleBackdropClick);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={dialogRef}
|
||||||
|
className="card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold text-foreground text-lg">
|
||||||
|
Player Characters
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{characters.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||||
|
<p className="text-muted-foreground">No player characters yet</p>
|
||||||
|
<Button onClick={onCreate}>
|
||||||
|
<Plus size={16} />
|
||||||
|
Create your first player character
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{characters.map((pc) => {
|
||||||
|
const Icon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
|
||||||
|
const color = pc.color ? PLAYER_COLOR_HEX[pc.color] : undefined;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={pc.id}
|
||||||
|
className="group flex items-center gap-3 rounded-md px-3 py-2 hover:bg-hover-neutral-bg"
|
||||||
|
>
|
||||||
|
{!!Icon && (
|
||||||
|
<Icon size={18} style={{ color }} className="shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="flex-1 truncate text-foreground text-sm">
|
||||||
|
{pc.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
|
AC {pc.ac}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
|
HP {pc.maxHp}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => onEdit(pc)}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</Button>
|
||||||
|
<ConfirmButton
|
||||||
|
icon={<Trash2 size={14} />}
|
||||||
|
label="Delete player character"
|
||||||
|
onConfirm={() => onDelete(pc.id)}
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="mt-2 flex justify-end">
|
||||||
|
<Button onClick={onCreate} variant="ghost">
|
||||||
|
<Plus size={16} />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
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 } from "../adapters/bestiary-index-adapter.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";
|
||||||
@@ -18,11 +18,12 @@ export function SourceFetchPrompt({
|
|||||||
fetchAndCacheSource,
|
fetchAndCacheSource,
|
||||||
onSourceLoaded,
|
onSourceLoaded,
|
||||||
onUploadSource,
|
onUploadSource,
|
||||||
}: SourceFetchPromptProps) {
|
}: Readonly<SourceFetchPromptProps>) {
|
||||||
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");
|
||||||
@@ -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)}
|
||||||
@@ -88,11 +89,7 @@ export function SourceFetchPrompt({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button onClick={handleFetch} disabled={status === "fetching" || !url}>
|
||||||
size="sm"
|
|
||||||
onClick={handleFetch}
|
|
||||||
disabled={status === "fetching" || !url}
|
|
||||||
>
|
|
||||||
{status === "fetching" ? (
|
{status === "fetching" ? (
|
||||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
@@ -101,10 +98,9 @@ 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
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={status === "fetching"}
|
disabled={status === "fetching"}
|
||||||
@@ -122,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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,15 +1,35 @@
|
|||||||
import { Database, Trash2 } from "lucide-react";
|
import { Database, Search, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, 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 { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
|
import { Input } from "./ui/input.js";
|
||||||
|
|
||||||
interface SourceManagerProps {
|
interface SourceManagerProps {
|
||||||
onCacheCleared: () => void;
|
onCacheCleared: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SourceManager({ onCacheCleared }: SourceManagerProps) {
|
export function SourceManager({
|
||||||
|
onCacheCleared,
|
||||||
|
}: Readonly<SourceManagerProps>) {
|
||||||
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
|
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
|
||||||
|
const [filter, setFilter] = useState("");
|
||||||
|
const [optimisticSources, applyOptimistic] = useOptimistic(
|
||||||
|
sources,
|
||||||
|
(
|
||||||
|
state,
|
||||||
|
action: { type: "remove"; sourceCode: string } | { type: "clear" },
|
||||||
|
) =>
|
||||||
|
action.type === "clear"
|
||||||
|
? []
|
||||||
|
: state.filter((s) => s.sourceCode !== action.sourceCode),
|
||||||
|
);
|
||||||
|
|
||||||
const loadSources = useCallback(async () => {
|
const loadSources = useCallback(async () => {
|
||||||
const cached = await bestiaryCache.getCachedSources();
|
const cached = await bestiaryCache.getCachedSources();
|
||||||
@@ -17,26 +37,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 });
|
||||||
await bestiaryCache.clearSource(sourceCode);
|
await bestiaryCache.clearSource(sourceCode);
|
||||||
await loadSources();
|
await loadSources();
|
||||||
onCacheCleared();
|
onCacheCleared();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearAll = async () => {
|
const handleClearAll = async () => {
|
||||||
|
applyOptimistic({ type: "clear" });
|
||||||
await bestiaryCache.clearAll();
|
await bestiaryCache.clearAll();
|
||||||
await loadSources();
|
await loadSources();
|
||||||
onCacheCleared();
|
onCacheCleared();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (sources.length === 0) {
|
const filteredSources = useMemo(() => {
|
||||||
|
const term = filter.toLowerCase();
|
||||||
|
return term
|
||||||
|
? optimisticSources.filter((s) =>
|
||||||
|
s.displayName.toLowerCase().includes(term),
|
||||||
|
)
|
||||||
|
: optimisticSources;
|
||||||
|
}, [optimisticSources, filter]);
|
||||||
|
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -44,32 +75,46 @@ 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 size="sm" variant="outline" onClick={handleClearAll}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hover:border-hover-destructive hover:text-hover-destructive"
|
||||||
|
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">
|
||||||
{sources.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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleClearSource(source.sourceCode)}
|
onClick={() => handleClearSource(source.sourceCode)}
|
||||||
className="text-muted-foreground hover:text-hover-danger"
|
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>
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import { useEffect, useState } from "react";
|
|||||||
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
|
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
|
||||||
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
import type { BulkImportState } from "../hooks/use-bulk-import.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 { StatBlock } from "./stat-block.js";
|
import { StatBlock } from "./stat-block.js";
|
||||||
|
import { Button } from "./ui/button.js";
|
||||||
|
|
||||||
interface StatBlockPanelProps {
|
interface StatBlockPanelProps {
|
||||||
creatureId: CreatureId | null;
|
creatureId: CreatureId | null;
|
||||||
@@ -20,8 +23,8 @@ interface StatBlockPanelProps {
|
|||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
refreshCache: () => Promise<void>;
|
refreshCache: () => Promise<void>;
|
||||||
panelRole: "browse" | "pinned";
|
panelRole: "browse" | "pinned";
|
||||||
isFolded: boolean;
|
isCollapsed: boolean;
|
||||||
onToggleFold: () => void;
|
onToggleCollapse: () => void;
|
||||||
onPin: () => void;
|
onPin: () => void;
|
||||||
onUnpin: () => void;
|
onUnpin: () => void;
|
||||||
showPinButton: boolean;
|
showPinButton: boolean;
|
||||||
@@ -31,6 +34,7 @@ interface StatBlockPanelProps {
|
|||||||
bulkImportState?: BulkImportState;
|
bulkImportState?: BulkImportState;
|
||||||
onStartBulkImport?: (baseUrl: string) => void;
|
onStartBulkImport?: (baseUrl: string) => void;
|
||||||
onBulkImportDone?: () => void;
|
onBulkImportDone?: () => void;
|
||||||
|
sourceManagerMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractSourceCode(cId: CreatureId): string {
|
function extractSourceCode(cId: CreatureId): string {
|
||||||
@@ -39,25 +43,26 @@ function extractSourceCode(cId: CreatureId): string {
|
|||||||
return cId.slice(0, colonIndex).toUpperCase();
|
return cId.slice(0, colonIndex).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function FoldedTab({
|
function CollapsedTab({
|
||||||
creatureName,
|
creatureName,
|
||||||
side,
|
side,
|
||||||
onToggleFold,
|
onToggleCollapse,
|
||||||
}: {
|
}: Readonly<{
|
||||||
creatureName: string;
|
creatureName: string;
|
||||||
side: "left" | "right";
|
side: "left" | "right";
|
||||||
onToggleFold: () => void;
|
onToggleCollapse: () => void;
|
||||||
}) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggleFold}
|
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="Unfold 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>
|
||||||
@@ -67,50 +72,53 @@ function FoldedTab({
|
|||||||
function PanelHeader({
|
function PanelHeader({
|
||||||
panelRole,
|
panelRole,
|
||||||
showPinButton,
|
showPinButton,
|
||||||
onToggleFold,
|
onToggleCollapse,
|
||||||
onPin,
|
onPin,
|
||||||
onUnpin,
|
onUnpin,
|
||||||
}: {
|
}: Readonly<{
|
||||||
panelRole: "browse" | "pinned";
|
panelRole: "browse" | "pinned";
|
||||||
showPinButton: boolean;
|
showPinButton: boolean;
|
||||||
onToggleFold: () => 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
|
||||||
type="button"
|
variant="ghost"
|
||||||
onClick={onToggleFold}
|
size="icon-sm"
|
||||||
className="text-muted-foreground hover:text-hover-neutral"
|
onClick={onToggleCollapse}
|
||||||
aria-label="Fold stat block panel"
|
className="text-muted-foreground"
|
||||||
|
aria-label="Collapse stat block panel"
|
||||||
>
|
>
|
||||||
<PanelRightClose className="h-4 w-4" />
|
<PanelRightClose className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{panelRole === "browse" && showPinButton && (
|
{panelRole === "browse" && showPinButton && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
onClick={onPin}
|
onClick={onPin}
|
||||||
className="text-muted-foreground hover:text-hover-neutral"
|
className="text-muted-foreground"
|
||||||
aria-label="Pin creature"
|
aria-label="Pin creature"
|
||||||
>
|
>
|
||||||
<Pin className="h-4 w-4" />
|
<Pin className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{panelRole === "pinned" && (
|
{panelRole === "pinned" && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
onClick={onUnpin}
|
onClick={onUnpin}
|
||||||
className="text-muted-foreground hover:text-hover-neutral"
|
className="text-muted-foreground"
|
||||||
aria-label="Unpin creature"
|
aria-label="Unpin creature"
|
||||||
>
|
>
|
||||||
<PinOff className="h-4 w-4" />
|
<PinOff className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,48 +126,52 @@ function PanelHeader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DesktopPanel({
|
function DesktopPanel({
|
||||||
isFolded,
|
isCollapsed,
|
||||||
side,
|
side,
|
||||||
creatureName,
|
creatureName,
|
||||||
panelRole,
|
panelRole,
|
||||||
showPinButton,
|
showPinButton,
|
||||||
onToggleFold,
|
onToggleCollapse,
|
||||||
onPin,
|
onPin,
|
||||||
onUnpin,
|
onUnpin,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: Readonly<{
|
||||||
isFolded: boolean;
|
isCollapsed: boolean;
|
||||||
side: "left" | "right";
|
side: "left" | "right";
|
||||||
creatureName: string;
|
creatureName: string;
|
||||||
panelRole: "browse" | "pinned";
|
panelRole: "browse" | "pinned";
|
||||||
showPinButton: boolean;
|
showPinButton: boolean;
|
||||||
onToggleFold: () => void;
|
onToggleCollapse: () => void;
|
||||||
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 foldedTranslate =
|
const collapsedTranslate =
|
||||||
side === "right"
|
side === "right"
|
||||||
? "translate-x-[calc(100%-40px)]"
|
? "translate-x-[calc(100%-40px)]"
|
||||||
: "translate-x-[calc(-100%+40px)]";
|
: "translate-x-[calc(-100%+40px)]";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel ${sideClasses} ${isFolded ? foldedTranslate : "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",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{isFolded ? (
|
{isCollapsed ? (
|
||||||
<FoldedTab
|
<CollapsedTab
|
||||||
creatureName={creatureName}
|
creatureName={creatureName}
|
||||||
side={side}
|
side={side}
|
||||||
onToggleFold={onToggleFold}
|
onToggleCollapse={onToggleCollapse}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<PanelHeader
|
<PanelHeader
|
||||||
panelRole={panelRole}
|
panelRole={panelRole}
|
||||||
showPinButton={showPinButton}
|
showPinButton={showPinButton}
|
||||||
onToggleFold={onToggleFold}
|
onToggleCollapse={onToggleCollapse}
|
||||||
onPin={onPin}
|
onPin={onPin}
|
||||||
onUnpin={onUnpin}
|
onUnpin={onUnpin}
|
||||||
/>
|
/>
|
||||||
@@ -173,36 +185,40 @@ 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
|
||||||
type="button"
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
className="text-muted-foreground hover:text-hover-neutral"
|
className="text-muted-foreground"
|
||||||
aria-label="Fold stat block panel"
|
aria-label="Collapse stat block panel"
|
||||||
>
|
>
|
||||||
<PanelRightClose className="h-4 w-4" />
|
<PanelRightClose className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
|
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
|
||||||
{children}
|
{children}
|
||||||
@@ -220,8 +236,8 @@ export function StatBlockPanel({
|
|||||||
uploadAndCacheSource,
|
uploadAndCacheSource,
|
||||||
refreshCache,
|
refreshCache,
|
||||||
panelRole,
|
panelRole,
|
||||||
isFolded,
|
isCollapsed,
|
||||||
onToggleFold,
|
onToggleCollapse,
|
||||||
onPin,
|
onPin,
|
||||||
onUnpin,
|
onUnpin,
|
||||||
showPinButton,
|
showPinButton,
|
||||||
@@ -231,15 +247,16 @@ export function StatBlockPanel({
|
|||||||
bulkImportState,
|
bulkImportState,
|
||||||
onStartBulkImport,
|
onStartBulkImport,
|
||||||
onBulkImportDone,
|
onBulkImportDone,
|
||||||
}: StatBlockPanelProps) {
|
sourceManagerMode,
|
||||||
|
}: Readonly<StatBlockPanelProps>) {
|
||||||
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);
|
||||||
@@ -258,13 +275,13 @@ export function StatBlockPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setCheckingCache(true);
|
setCheckingCache(true);
|
||||||
isSourceCached(sourceCode).then((cached) => {
|
void isSourceCached(sourceCode).then((cached) => {
|
||||||
setNeedsFetch(!cached);
|
setNeedsFetch(!cached);
|
||||||
setCheckingCache(false);
|
setCheckingCache(false);
|
||||||
});
|
});
|
||||||
}, [creatureId, creature, isSourceCached]);
|
}, [creatureId, creature, isSourceCached]);
|
||||||
|
|
||||||
if (!creatureId && !bulkImportMode) return null;
|
if (!creatureId && !bulkImportMode && !sourceManagerMode) return null;
|
||||||
|
|
||||||
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
|
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
|
||||||
|
|
||||||
@@ -274,6 +291,10 @@ export function StatBlockPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
|
if (sourceManagerMode) {
|
||||||
|
return <SourceManager onCacheCleared={refreshCache} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
bulkImportMode &&
|
bulkImportMode &&
|
||||||
bulkImportState &&
|
bulkImportState &&
|
||||||
@@ -291,7 +312,7 @@ export function StatBlockPanel({
|
|||||||
|
|
||||||
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,24 +333,26 @@ export function StatBlockPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 ?? (bulkImportMode ? "Bulk Import" : "Creature");
|
if (sourceManagerMode) fallbackName = "Sources";
|
||||||
|
else if (bulkImportMode) fallbackName = "Import All Sources";
|
||||||
|
const creatureName = creature?.name ?? fallbackName;
|
||||||
|
|
||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
return (
|
return (
|
||||||
<DesktopPanel
|
<DesktopPanel
|
||||||
isFolded={isFolded}
|
isCollapsed={isCollapsed}
|
||||||
side={side}
|
side={side}
|
||||||
creatureName={creatureName}
|
creatureName={creatureName}
|
||||||
panelRole={panelRole}
|
panelRole={panelRole}
|
||||||
showPinButton={showPinButton}
|
showPinButton={showPinButton}
|
||||||
onToggleFold={onToggleFold}
|
onToggleCollapse={onToggleCollapse}
|
||||||
onPin={onPin}
|
onPin={onPin}
|
||||||
onUnpin={onUnpin}
|
onUnpin={onUnpin}
|
||||||
>
|
>
|
||||||
@@ -338,7 +361,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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -34,7 +34,7 @@ function SectionDivider() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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-amber-400 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-amber-400 text-base">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,7 @@ 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-amber-400 text-base">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 +224,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-amber-400 text-base">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 +236,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-amber-400 text-base">
|
||||||
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">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { Button } from "./ui/button.js";
|
||||||
|
|
||||||
interface ToastProps {
|
interface ToastProps {
|
||||||
message: string;
|
message: string;
|
||||||
@@ -22,9 +23,9 @@ export function Toast({
|
|||||||
}, [autoDismissMs, onDismiss]);
|
}, [autoDismissMs, onDismiss]);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="fixed bottom-4 left-1/2 z-50 -translate-x-1/2">
|
<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
|
||||||
@@ -33,13 +34,14 @@ export function Toast({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
className="text-muted-foreground hover:text-hover-neutral"
|
className="text-muted-foreground"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Encounter } from "@initiative/domain";
|
import type { Encounter } from "@initiative/domain";
|
||||||
import { Settings, StepBack, StepForward, Trash2 } from "lucide-react";
|
import { StepBack, StepForward, Trash2 } from "lucide-react";
|
||||||
import { D20Icon } from "./d20-icon";
|
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { ConfirmButton } from "./ui/confirm-button";
|
import { ConfirmButton } from "./ui/confirm-button";
|
||||||
|
|
||||||
@@ -9,8 +8,6 @@ interface TurnNavigationProps {
|
|||||||
onAdvanceTurn: () => void;
|
onAdvanceTurn: () => void;
|
||||||
onRetreatTurn: () => void;
|
onRetreatTurn: () => void;
|
||||||
onClearEncounter: () => void;
|
onClearEncounter: () => void;
|
||||||
onRollAllInitiative: () => void;
|
|
||||||
onOpenSourceManager: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TurnNavigation({
|
export function TurnNavigation({
|
||||||
@@ -18,76 +15,46 @@ export function TurnNavigation({
|
|||||||
onAdvanceTurn,
|
onAdvanceTurn,
|
||||||
onRetreatTurn,
|
onRetreatTurn,
|
||||||
onClearEncounter,
|
onClearEncounter,
|
||||||
onRollAllInitiative,
|
}: Readonly<TurnNavigationProps>) {
|
||||||
onOpenSourceManager,
|
|
||||||
}: 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">
|
||||||
<div className="flex flex-shrink-0 items-center gap-3">
|
<Button
|
||||||
<Button
|
variant="outline"
|
||||||
variant="outline"
|
size="icon"
|
||||||
size="icon"
|
onClick={onRetreatTurn}
|
||||||
className="h-8 w-8 text-foreground border-foreground hover:text-hover-action hover:border-hover-action hover:bg-transparent"
|
disabled={!hasCombatants || isAtStart}
|
||||||
onClick={onRetreatTurn}
|
title="Previous turn"
|
||||||
disabled={!hasCombatants || isAtStart}
|
aria-label="Previous turn"
|
||||||
title="Previous turn"
|
>
|
||||||
aria-label="Previous turn"
|
<StepBack className="h-5 w-5" />
|
||||||
>
|
</Button>
|
||||||
<StepBack className="h-5 w-5" />
|
|
||||||
</Button>
|
<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">
|
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 font-semibold text-foreground text-sm">
|
||||||
R{encounter.roundNumber}
|
R{encounter.roundNumber}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1 text-center text-sm">
|
|
||||||
{activeCombatant ? (
|
{activeCombatant ? (
|
||||||
<span className="truncate block font-medium">
|
<span className="truncate font-medium">{activeCombatant.name}</span>
|
||||||
{activeCombatant.name}
|
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">No combatants</span>
|
<span className="text-muted-foreground">No combatants</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-shrink-0 items-center gap-3">
|
<div className="flex flex-shrink-0 items-center gap-3">
|
||||||
<div className="flex items-center gap-0">
|
<ConfirmButton
|
||||||
<Button
|
icon={<Trash2 className="h-5 w-5" />}
|
||||||
variant="ghost"
|
label="Clear encounter"
|
||||||
size="icon"
|
onConfirm={onClearEncounter}
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-hover-action"
|
disabled={!hasCombatants}
|
||||||
onClick={onRollAllInitiative}
|
className="text-muted-foreground"
|
||||||
title="Roll all initiative"
|
/>
|
||||||
aria-label="Roll all initiative"
|
|
||||||
>
|
|
||||||
<D20Icon className="h-6 w-6" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-hover-neutral"
|
|
||||||
onClick={onOpenSourceManager}
|
|
||||||
title="Manage cached sources"
|
|
||||||
aria-label="Manage cached sources"
|
|
||||||
>
|
|
||||||
<Settings className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
<ConfirmButton
|
|
||||||
icon={<Trash2 className="h-5 w-5" />}
|
|
||||||
label="Clear encounter"
|
|
||||||
onConfirm={onClearEncounter}
|
|
||||||
disabled={!hasCombatants}
|
|
||||||
className="h-8 w-8 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-foreground border-foreground hover:text-hover-action hover:border-hover-action hover:bg-transparent"
|
|
||||||
onClick={onAdvanceTurn}
|
onClick={onAdvanceTurn}
|
||||||
disabled={!hasCombatants}
|
disabled={!hasCombatants}
|
||||||
title="Next turn"
|
title="Next turn"
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ 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-9 px-4 py-2",
|
default: "h-8 px-3 text-xs",
|
||||||
sm: "h-8 px-3 text-xs",
|
|
||||||
icon: "h-8 w-8",
|
icon: "h-8 w-8",
|
||||||
|
"icon-sm": "h-6 w-6",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface ConfirmButtonProps {
|
|||||||
readonly onConfirm: () => void;
|
readonly onConfirm: () => void;
|
||||||
readonly icon: ReactElement;
|
readonly icon: ReactElement;
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
|
readonly size?: "icon" | "icon-sm";
|
||||||
readonly className?: string;
|
readonly className?: string;
|
||||||
readonly disabled?: boolean;
|
readonly disabled?: boolean;
|
||||||
}
|
}
|
||||||
@@ -23,6 +24,7 @@ export function ConfirmButton({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
|
size = "icon",
|
||||||
className,
|
className,
|
||||||
disabled,
|
disabled,
|
||||||
}: ConfirmButtonProps) {
|
}: ConfirmButtonProps) {
|
||||||
@@ -53,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]);
|
||||||
|
|
||||||
@@ -94,11 +96,12 @@ export function ConfirmButton({
|
|||||||
<div ref={wrapperRef} className="inline-flex">
|
<div ref={wrapperRef} className="inline-flex">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size={size}
|
||||||
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",
|
||||||
)}
|
)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
@@ -107,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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
return (
|
ref,
|
||||||
<input
|
...props
|
||||||
ref={ref}
|
}: InputProps & { ref?: RefObject<HTMLInputElement | null> }) => {
|
||||||
className={cn(
|
return (
|
||||||
"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",
|
<input
|
||||||
className,
|
ref={ref}
|
||||||
)}
|
className={cn(
|
||||||
{...props}
|
"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,
|
||||||
);
|
)}
|
||||||
},
|
{...props}
|
||||||
);
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
72
apps/web/src/components/ui/overflow-menu.tsx
Normal file
72
apps/web/src/components/ui/overflow-menu.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { EllipsisVertical } from "lucide-react";
|
||||||
|
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||||
|
import { Button } from "./button";
|
||||||
|
|
||||||
|
export interface OverflowMenuItem {
|
||||||
|
readonly icon: ReactNode;
|
||||||
|
readonly label: string;
|
||||||
|
readonly onClick: () => void;
|
||||||
|
readonly disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverflowMenuProps {
|
||||||
|
readonly items: readonly OverflowMenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverflowMenu({ items }: OverflowMenuProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function handleMouseDown(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleMouseDown);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleMouseDown);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-muted-foreground hover:text-hover-neutral"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
aria-label="More actions"
|
||||||
|
title="More actions"
|
||||||
|
>
|
||||||
|
<EllipsisVertical className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
{!!open && (
|
||||||
|
<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) => (
|
||||||
|
<button
|
||||||
|
key={item.label}
|
||||||
|
type="button"
|
||||||
|
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}
|
||||||
|
onClick={() => {
|
||||||
|
item.onClick();
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
217
apps/web/src/hooks/__tests__/use-encounter.test.ts
Normal file
217
apps/web/src/hooks/__tests__/use-encounter.test.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
// @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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
100
apps/web/src/hooks/__tests__/use-player-characters.test.ts
Normal file
100
apps/web/src/hooks/__tests__/use-player-characters.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
159
apps/web/src/hooks/__tests__/use-side-panel-state.test.ts
Normal file
159
apps/web/src/hooks/__tests__/use-side-panel-state.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@ import type {
|
|||||||
Creature,
|
Creature,
|
||||||
CreatureId,
|
CreatureId,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
normalizeBestiary,
|
normalizeBestiary,
|
||||||
setSourceDisplayNames,
|
setSourceDisplayNames,
|
||||||
@@ -33,8 +33,9 @@ interface BestiaryHook {
|
|||||||
|
|
||||||
export function useBestiary(): BestiaryHook {
|
export function useBestiary(): BestiaryHook {
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
const creatureMapRef = useRef<Map<CreatureId, Creature>>(new Map());
|
const [creatureMap, setCreatureMap] = useState(
|
||||||
const [, setTick] = useState(0);
|
() => new Map<CreatureId, Creature>(),
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const index = loadBestiaryIndex();
|
const index = loadBestiaryIndex();
|
||||||
@@ -43,9 +44,8 @@ export function useBestiary(): BestiaryHook {
|
|||||||
setIsLoaded(true);
|
setIsLoaded(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
bestiaryCache.loadAllCachedCreatures().then((map) => {
|
void bestiaryCache.loadAllCachedCreatures().then((map) => {
|
||||||
creatureMapRef.current = map;
|
setCreatureMap(map);
|
||||||
setTick((t) => t + 1);
|
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -63,9 +63,12 @@ export function useBestiary(): BestiaryHook {
|
|||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getCreature = useCallback((id: CreatureId): Creature | undefined => {
|
const getCreature = useCallback(
|
||||||
return creatureMapRef.current.get(id);
|
(id: CreatureId): Creature | undefined => {
|
||||||
}, []);
|
return creatureMap.get(id);
|
||||||
|
},
|
||||||
|
[creatureMap],
|
||||||
|
);
|
||||||
|
|
||||||
const isSourceCachedFn = useCallback(
|
const isSourceCachedFn = useCallback(
|
||||||
(sourceCode: string): Promise<boolean> => {
|
(sourceCode: string): Promise<boolean> => {
|
||||||
@@ -86,10 +89,13 @@ export function useBestiary(): BestiaryHook {
|
|||||||
const creatures = normalizeBestiary(json);
|
const creatures = normalizeBestiary(json);
|
||||||
const displayName = getSourceDisplayName(sourceCode);
|
const displayName = getSourceDisplayName(sourceCode);
|
||||||
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
||||||
for (const c of creatures) {
|
setCreatureMap((prev) => {
|
||||||
creatureMapRef.current.set(c.id, c);
|
const next = new Map(prev);
|
||||||
}
|
for (const c of creatures) {
|
||||||
setTick((t) => t + 1);
|
next.set(c.id, c);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -100,18 +106,20 @@ export function useBestiary(): BestiaryHook {
|
|||||||
const creatures = normalizeBestiary(jsonData as any);
|
const creatures = normalizeBestiary(jsonData as any);
|
||||||
const displayName = getSourceDisplayName(sourceCode);
|
const displayName = getSourceDisplayName(sourceCode);
|
||||||
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
||||||
for (const c of creatures) {
|
setCreatureMap((prev) => {
|
||||||
creatureMapRef.current.set(c.id, c);
|
const next = new Map(prev);
|
||||||
}
|
for (const c of creatures) {
|
||||||
setTick((t) => t + 1);
|
next.set(c.id, c);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshCache = useCallback(async (): Promise<void> => {
|
const refreshCache = useCallback(async (): Promise<void> => {
|
||||||
const map = await bestiaryCache.loadAllCachedCreatures();
|
const map = await bestiaryCache.loadAllCachedCreatures();
|
||||||
creatureMapRef.current = map;
|
setCreatureMap(map);
|
||||||
setTick((t) => t + 1);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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,31 +73,39 @@ 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(
|
|
||||||
batch.map(async ({ code }) => {
|
|
||||||
const url = getDefaultFetchUrl(code, baseUrl);
|
|
||||||
try {
|
|
||||||
await fetchAndCacheSource(code, url);
|
|
||||||
countersRef.current.completed++;
|
|
||||||
} catch (err) {
|
|
||||||
countersRef.current.failed++;
|
|
||||||
console.warn(
|
|
||||||
`[bulk-import] FAILED ${code} (${url}):`,
|
|
||||||
err instanceof Error ? err.message : err,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setState({
|
|
||||||
status: "loading",
|
|
||||||
total,
|
|
||||||
completed: countersRef.current.completed,
|
|
||||||
failed: countersRef.current.failed,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await batches.reduce(
|
||||||
|
(chain, batch) =>
|
||||||
|
chain.then(() =>
|
||||||
|
Promise.allSettled(
|
||||||
|
batch.map(async ({ code }) => {
|
||||||
|
const url = getDefaultFetchUrl(code, baseUrl);
|
||||||
|
try {
|
||||||
|
await fetchAndCacheSource(code, url);
|
||||||
|
countersRef.current.completed++;
|
||||||
|
} catch (err) {
|
||||||
|
countersRef.current.failed++;
|
||||||
|
console.warn(
|
||||||
|
`[bulk-import] FAILED ${code} (${url}):`,
|
||||||
|
err instanceof Error ? err.message : err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setState({
|
||||||
|
status: "loading",
|
||||||
|
total,
|
||||||
|
completed: countersRef.current.completed,
|
||||||
|
failed: countersRef.current.failed,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Promise.resolve() as Promise<unknown>,
|
||||||
|
);
|
||||||
|
|
||||||
await refreshCache();
|
await refreshCache();
|
||||||
|
|
||||||
const { completed, failed } = countersRef.current;
|
const { completed, failed } = countersRef.current;
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ import type {
|
|||||||
ConditionId,
|
ConditionId,
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
Encounter,
|
Encounter,
|
||||||
|
PlayerCharacter,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import {
|
import {
|
||||||
combatantId,
|
combatantId,
|
||||||
createEncounter,
|
|
||||||
isDomainError,
|
isDomainError,
|
||||||
creatureId as makeCreatureId,
|
creatureId as makeCreatureId,
|
||||||
resolveCreatureName,
|
resolveCreatureName,
|
||||||
@@ -33,30 +33,24 @@ import {
|
|||||||
saveEncounter,
|
saveEncounter,
|
||||||
} from "../persistence/encounter-storage.js";
|
} from "../persistence/encounter-storage.js";
|
||||||
|
|
||||||
function createDemoEncounter(): Encounter {
|
const COMBATANT_ID_REGEX = /^c-(\d+)$/;
|
||||||
const result = createEncounter([
|
|
||||||
{ id: combatantId("1"), name: "Aria" },
|
|
||||||
{ id: combatantId("2"), name: "Brak" },
|
|
||||||
{ id: combatantId("3"), name: "Cael" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
const EMPTY_ENCOUNTER: Encounter = {
|
||||||
throw new Error(`Failed to create demo encounter: ${result.message}`);
|
combatants: [],
|
||||||
}
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
return result;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
function initializeEncounter(): Encounter {
|
function initializeEncounter(): Encounter {
|
||||||
const stored = loadEncounter();
|
const stored = loadEncounter();
|
||||||
if (stored !== null) return stored;
|
if (stored !== null) return stored;
|
||||||
return createDemoEncounter();
|
return EMPTY_ENCOUNTER;
|
||||||
}
|
}
|
||||||
|
|
||||||
function deriveNextId(encounter: Encounter): number {
|
function deriveNextId(encounter: Encounter): number {
|
||||||
let max = 0;
|
let max = 0;
|
||||||
for (const c of encounter.combatants) {
|
for (const c of encounter.combatants) {
|
||||||
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;
|
||||||
@@ -309,8 +303,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)
|
||||||
@@ -324,12 +318,75 @@ export function useEncounter() {
|
|||||||
|
|
||||||
setEvents((prev) => [...prev, ...addResult]);
|
setEvents((prev) => [...prev, ...addResult]);
|
||||||
},
|
},
|
||||||
[makeStore, editCombatant],
|
[makeStore],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addFromPlayerCharacter = useCallback(
|
||||||
|
(pc: PlayerCharacter) => {
|
||||||
|
const store = makeStore();
|
||||||
|
const existingNames = store.get().combatants.map((c) => c.name);
|
||||||
|
const { newName, renames } = resolveCreatureName(pc.name, existingNames);
|
||||||
|
|
||||||
|
for (const { from, to } of renames) {
|
||||||
|
const target = store.get().combatants.find((c) => c.name === from);
|
||||||
|
if (target) {
|
||||||
|
editCombatantUseCase(makeStore(), target.id, to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = combatantId(`c-${++nextId.current}`);
|
||||||
|
const addResult = addCombatantUseCase(makeStore(), id, newName);
|
||||||
|
if (isDomainError(addResult)) return;
|
||||||
|
|
||||||
|
// Set HP
|
||||||
|
const hpResult = setHpUseCase(makeStore(), id, pc.maxHp);
|
||||||
|
if (!isDomainError(hpResult)) {
|
||||||
|
setEvents((prev) => [...prev, ...hpResult]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set AC
|
||||||
|
if (pc.ac > 0) {
|
||||||
|
const acResult = setAcUseCase(makeStore(), id, pc.ac);
|
||||||
|
if (!isDomainError(acResult)) {
|
||||||
|
setEvents((prev) => [...prev, ...acResult]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set color, icon, and playerCharacterId on the combatant
|
||||||
|
const currentEncounter = store.get();
|
||||||
|
store.save({
|
||||||
|
...currentEncounter,
|
||||||
|
combatants: currentEncounter.combatants.map((c) =>
|
||||||
|
c.id === id
|
||||||
|
? {
|
||||||
|
...c,
|
||||||
|
color: pc.color,
|
||||||
|
icon: pc.icon,
|
||||||
|
playerCharacterId: pc.id,
|
||||||
|
}
|
||||||
|
: c,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
setEvents((prev) => [...prev, ...addResult]);
|
||||||
|
},
|
||||||
|
[makeStore],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isEmpty = encounter.combatants.length === 0;
|
||||||
|
const hasCreatureCombatants = encounter.combatants.some(
|
||||||
|
(c) => c.creatureId != null,
|
||||||
|
);
|
||||||
|
const canRollAllInitiative = encounter.combatants.some(
|
||||||
|
(c) => c.creatureId != null && c.initiative == null,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
encounter,
|
encounter,
|
||||||
events,
|
events,
|
||||||
|
isEmpty,
|
||||||
|
hasCreatureCombatants,
|
||||||
|
canRollAllInitiative,
|
||||||
advanceTurn,
|
advanceTurn,
|
||||||
retreatTurn,
|
retreatTurn,
|
||||||
addCombatant,
|
addCombatant,
|
||||||
@@ -343,6 +400,7 @@ export function useEncounter() {
|
|||||||
toggleCondition,
|
toggleCondition,
|
||||||
toggleConcentration,
|
toggleConcentration,
|
||||||
addFromBestiary,
|
addFromBestiary,
|
||||||
|
addFromPlayerCharacter,
|
||||||
makeStore,
|
makeStore,
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|||||||
108
apps/web/src/hooks/use-player-characters.ts
Normal file
108
apps/web/src/hooks/use-player-characters.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import type { PlayerCharacterStore } from "@initiative/application";
|
||||||
|
import {
|
||||||
|
createPlayerCharacterUseCase,
|
||||||
|
deletePlayerCharacterUseCase,
|
||||||
|
editPlayerCharacterUseCase,
|
||||||
|
} from "@initiative/application";
|
||||||
|
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||||
|
import { isDomainError, playerCharacterId } from "@initiative/domain";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
loadPlayerCharacters,
|
||||||
|
savePlayerCharacters,
|
||||||
|
} from "../persistence/player-character-storage.js";
|
||||||
|
|
||||||
|
function initializeCharacters(): PlayerCharacter[] {
|
||||||
|
return loadPlayerCharacters();
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextPcId = 0;
|
||||||
|
|
||||||
|
function generatePcId(): PlayerCharacterId {
|
||||||
|
return playerCharacterId(`pc-${++nextPcId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditFields {
|
||||||
|
readonly name?: string;
|
||||||
|
readonly ac?: number;
|
||||||
|
readonly maxHp?: number;
|
||||||
|
readonly color?: string | null;
|
||||||
|
readonly icon?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlayerCharacters() {
|
||||||
|
const [characters, setCharacters] =
|
||||||
|
useState<PlayerCharacter[]>(initializeCharacters);
|
||||||
|
const charactersRef = useRef(characters);
|
||||||
|
charactersRef.current = characters;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
savePlayerCharacters(characters);
|
||||||
|
}, [characters]);
|
||||||
|
|
||||||
|
const makeStore = useCallback((): PlayerCharacterStore => {
|
||||||
|
return {
|
||||||
|
getAll: () => charactersRef.current,
|
||||||
|
save: (updated) => {
|
||||||
|
charactersRef.current = updated;
|
||||||
|
setCharacters(updated);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const createCharacter = useCallback(
|
||||||
|
(
|
||||||
|
name: string,
|
||||||
|
ac: number,
|
||||||
|
maxHp: number,
|
||||||
|
color: string | undefined,
|
||||||
|
icon: string | undefined,
|
||||||
|
) => {
|
||||||
|
const id = generatePcId();
|
||||||
|
const result = createPlayerCharacterUseCase(
|
||||||
|
makeStore(),
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
ac,
|
||||||
|
maxHp,
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
[makeStore],
|
||||||
|
);
|
||||||
|
|
||||||
|
const editCharacter = useCallback(
|
||||||
|
(id: PlayerCharacterId, fields: EditFields) => {
|
||||||
|
const result = editPlayerCharacterUseCase(makeStore(), id, fields);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
[makeStore],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteCharacter = useCallback(
|
||||||
|
(id: PlayerCharacterId) => {
|
||||||
|
const result = deletePlayerCharacterUseCase(makeStore(), id);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
[makeStore],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
characters,
|
||||||
|
createCharacter,
|
||||||
|
editCharacter,
|
||||||
|
deleteCharacter,
|
||||||
|
makeStore,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
101
apps/web/src/hooks/use-side-panel-state.ts
Normal file
101
apps/web/src/hooks/use-side-panel-state.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import type { CreatureId } from "@initiative/domain";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type PanelView =
|
||||||
|
| { mode: "closed" }
|
||||||
|
| { mode: "creature"; creatureId: CreatureId }
|
||||||
|
| { mode: "bulk-import" }
|
||||||
|
| { mode: "source-manager" };
|
||||||
|
|
||||||
|
interface SidePanelState {
|
||||||
|
panelView: PanelView;
|
||||||
|
selectedCreatureId: CreatureId | null;
|
||||||
|
bulkImportMode: boolean;
|
||||||
|
sourceManagerMode: boolean;
|
||||||
|
isRightPanelCollapsed: boolean;
|
||||||
|
pinnedCreatureId: CreatureId | null;
|
||||||
|
isWideDesktop: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidePanelActions {
|
||||||
|
showCreature: (creatureId: CreatureId) => void;
|
||||||
|
showBulkImport: () => void;
|
||||||
|
showSourceManager: () => void;
|
||||||
|
dismissPanel: () => void;
|
||||||
|
toggleCollapse: () => void;
|
||||||
|
togglePin: () => void;
|
||||||
|
unpin: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSidePanelState(): SidePanelState & SidePanelActions {
|
||||||
|
const [panelView, setPanelView] = useState<PanelView>({ mode: "closed" });
|
||||||
|
const [isRightPanelCollapsed, setIsRightPanelCollapsed] = useState(false);
|
||||||
|
const [pinnedCreatureId, setPinnedCreatureId] = useState<CreatureId | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [isWideDesktop, setIsWideDesktop] = useState(
|
||||||
|
() => globalThis.matchMedia("(min-width: 1280px)").matches,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = globalThis.matchMedia("(min-width: 1280px)");
|
||||||
|
const handler = (e: MediaQueryListEvent) => setIsWideDesktop(e.matches);
|
||||||
|
mq.addEventListener("change", handler);
|
||||||
|
return () => mq.removeEventListener("change", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedCreatureId =
|
||||||
|
panelView.mode === "creature" ? panelView.creatureId : null;
|
||||||
|
|
||||||
|
const showCreature = useCallback((creatureId: CreatureId) => {
|
||||||
|
setPanelView({ mode: "creature", creatureId });
|
||||||
|
setIsRightPanelCollapsed(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showBulkImport = useCallback(() => {
|
||||||
|
setPanelView({ mode: "bulk-import" });
|
||||||
|
setIsRightPanelCollapsed(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showSourceManager = useCallback(() => {
|
||||||
|
setPanelView({ mode: "source-manager" });
|
||||||
|
setIsRightPanelCollapsed(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismissPanel = useCallback(() => {
|
||||||
|
setPanelView({ mode: "closed" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleCollapse = useCallback(() => {
|
||||||
|
setIsRightPanelCollapsed((f) => !f);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const togglePin = useCallback(() => {
|
||||||
|
if (selectedCreatureId) {
|
||||||
|
setPinnedCreatureId((prev) =>
|
||||||
|
prev === selectedCreatureId ? null : selectedCreatureId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [selectedCreatureId]);
|
||||||
|
|
||||||
|
const unpin = useCallback(() => {
|
||||||
|
setPinnedCreatureId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
panelView,
|
||||||
|
selectedCreatureId,
|
||||||
|
bulkImportMode: panelView.mode === "bulk-import",
|
||||||
|
sourceManagerMode: panelView.mode === "source-manager",
|
||||||
|
isRightPanelCollapsed,
|
||||||
|
pinnedCreatureId,
|
||||||
|
isWideDesktop,
|
||||||
|
showCreature,
|
||||||
|
showBulkImport,
|
||||||
|
showSourceManager,
|
||||||
|
dismissPanel,
|
||||||
|
toggleCollapse,
|
||||||
|
togglePin,
|
||||||
|
unpin,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -16,12 +16,12 @@
|
|||||||
--color-hover-neutral: var(--color-primary);
|
--color-hover-neutral: var(--color-primary);
|
||||||
--color-hover-action: var(--color-primary);
|
--color-hover-action: var(--color-primary);
|
||||||
--color-hover-destructive: var(--color-destructive);
|
--color-hover-destructive: var(--color-destructive);
|
||||||
--color-hover-neutral-bg: var(--color-card);
|
--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;
|
||||||
--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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +80,75 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes settle-to-bottom {
|
||||||
|
from {
|
||||||
|
transform: translateY(-40vh);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility animate-settle-to-bottom {
|
||||||
|
animation: settle-to-bottom 700ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rise-to-center {
|
||||||
|
from {
|
||||||
|
transform: translateY(40vh);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility animate-rise-to-center {
|
||||||
|
animation: rise-to-center 700ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-down-in {
|
||||||
|
from {
|
||||||
|
transform: translateY(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility animate-slide-down-in {
|
||||||
|
animation: slide-down-in 700ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-up-out {
|
||||||
|
from {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility animate-slide-up-out {
|
||||||
|
animation: slide-up-out 700ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
@custom-variant pointer-coarse (@media (pointer: coarse));
|
@custom-variant pointer-coarse (@media (pointer: coarse));
|
||||||
|
|
||||||
@utility animate-confirm-pulse {
|
@utility animate-confirm-pulse {
|
||||||
@@ -100,6 +169,28 @@
|
|||||||
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
scrollbar-color: var(--color-border) transparent;
|
scrollbar-color: var(--color-border) transparent;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import type { PlayerCharacter } from "@initiative/domain";
|
||||||
|
import { playerCharacterId } from "@initiative/domain";
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
loadPlayerCharacters,
|
||||||
|
savePlayerCharacters,
|
||||||
|
} from "../player-character-storage.js";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "initiative:player-characters";
|
||||||
|
|
||||||
|
function createMockLocalStorage() {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
getItem: (key: string) => store.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => store.set(key, value),
|
||||||
|
removeItem: (key: string) => store.delete(key),
|
||||||
|
clear: () => store.clear(),
|
||||||
|
get length() {
|
||||||
|
return store.size;
|
||||||
|
},
|
||||||
|
key: (_index: number) => null,
|
||||||
|
store,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePC(overrides?: Partial<PlayerCharacter>): PlayerCharacter {
|
||||||
|
return {
|
||||||
|
id: playerCharacterId("pc-1"),
|
||||||
|
name: "Aragorn",
|
||||||
|
ac: 16,
|
||||||
|
maxHp: 120,
|
||||||
|
color: "green",
|
||||||
|
icon: "sword",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("player-character-storage", () => {
|
||||||
|
let mockStorage: ReturnType<typeof createMockLocalStorage>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockStorage = createMockLocalStorage();
|
||||||
|
Object.defineProperty(globalThis, "localStorage", {
|
||||||
|
value: mockStorage,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("round-trip save/load", () => {
|
||||||
|
it("saves and loads a single character", () => {
|
||||||
|
const pc = makePC();
|
||||||
|
savePlayerCharacters([pc]);
|
||||||
|
const loaded = loadPlayerCharacters();
|
||||||
|
expect(loaded).toEqual([pc]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves and loads multiple characters", () => {
|
||||||
|
const pcs = [
|
||||||
|
makePC({ id: playerCharacterId("pc-1"), name: "Aragorn" }),
|
||||||
|
makePC({
|
||||||
|
id: playerCharacterId("pc-2"),
|
||||||
|
name: "Legolas",
|
||||||
|
ac: 14,
|
||||||
|
maxHp: 90,
|
||||||
|
color: "blue",
|
||||||
|
icon: "eye",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
savePlayerCharacters(pcs);
|
||||||
|
const loaded = loadPlayerCharacters();
|
||||||
|
expect(loaded).toEqual(pcs);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("empty storage", () => {
|
||||||
|
it("returns empty array when no data exists", () => {
|
||||||
|
expect(loadPlayerCharacters()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("corrupt JSON", () => {
|
||||||
|
it("returns empty array for invalid JSON", () => {
|
||||||
|
mockStorage.setItem(STORAGE_KEY, "not-json{{{");
|
||||||
|
expect(loadPlayerCharacters()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array for non-array JSON", () => {
|
||||||
|
mockStorage.setItem(STORAGE_KEY, '{"foo": "bar"}');
|
||||||
|
expect(loadPlayerCharacters()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("per-character validation", () => {
|
||||||
|
it("discards character with missing name", () => {
|
||||||
|
mockStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify([
|
||||||
|
{ id: "pc-1", ac: 10, maxHp: 50, color: "blue", icon: "sword" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(loadPlayerCharacters()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discards character with empty name", () => {
|
||||||
|
mockStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
id: "pc-1",
|
||||||
|
name: "",
|
||||||
|
ac: 10,
|
||||||
|
maxHp: 50,
|
||||||
|
color: "blue",
|
||||||
|
icon: "sword",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(loadPlayerCharacters()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discards character with invalid color", () => {
|
||||||
|
mockStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
id: "pc-1",
|
||||||
|
name: "Test",
|
||||||
|
ac: 10,
|
||||||
|
maxHp: 50,
|
||||||
|
color: "neon",
|
||||||
|
icon: "sword",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(loadPlayerCharacters()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discards character with invalid icon", () => {
|
||||||
|
mockStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
id: "pc-1",
|
||||||
|
name: "Test",
|
||||||
|
ac: 10,
|
||||||
|
maxHp: 50,
|
||||||
|
color: "blue",
|
||||||
|
icon: "banana",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(loadPlayerCharacters()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discards character with negative AC", () => {
|
||||||
|
mockStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
id: "pc-1",
|
||||||
|
name: "Test",
|
||||||
|
ac: -1,
|
||||||
|
maxHp: 50,
|
||||||
|
color: "blue",
|
||||||
|
icon: "sword",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(loadPlayerCharacters()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discards character with maxHp of 0", () => {
|
||||||
|
mockStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
id: "pc-1",
|
||||||
|
name: "Test",
|
||||||
|
ac: 10,
|
||||||
|
maxHp: 0,
|
||||||
|
color: "blue",
|
||||||
|
icon: "sword",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(loadPlayerCharacters()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps valid characters and discards invalid ones", () => {
|
||||||
|
mockStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
id: "pc-1",
|
||||||
|
name: "Valid",
|
||||||
|
ac: 10,
|
||||||
|
maxHp: 50,
|
||||||
|
color: "blue",
|
||||||
|
icon: "sword",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pc-2",
|
||||||
|
name: "",
|
||||||
|
ac: 10,
|
||||||
|
maxHp: 50,
|
||||||
|
color: "blue",
|
||||||
|
icon: "sword",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const loaded = loadPlayerCharacters();
|
||||||
|
expect(loaded).toHaveLength(1);
|
||||||
|
expect(loaded[0].name).toBe("Valid");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("storage errors", () => {
|
||||||
|
it("save silently catches errors", () => {
|
||||||
|
Object.defineProperty(globalThis, "localStorage", {
|
||||||
|
value: {
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error("QuotaExceeded");
|
||||||
|
},
|
||||||
|
getItem: () => null,
|
||||||
|
},
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
expect(() => savePlayerCharacters([makePC()])).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,10 @@ import {
|
|||||||
creatureId,
|
creatureId,
|
||||||
type Encounter,
|
type Encounter,
|
||||||
isDomainError,
|
isDomainError,
|
||||||
|
playerCharacterId,
|
||||||
VALID_CONDITION_IDS,
|
VALID_CONDITION_IDS,
|
||||||
|
VALID_PLAYER_COLORS,
|
||||||
|
VALID_PLAYER_ICONS,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
|
|
||||||
const STORAGE_KEY = "initiative:encounter";
|
const STORAGE_KEY = "initiative:encounter";
|
||||||
@@ -70,12 +73,29 @@ function rehydrateCombatant(c: unknown) {
|
|||||||
typeof entry.initiative === "number" ? entry.initiative : undefined,
|
typeof entry.initiative === "number" ? entry.initiative : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const color =
|
||||||
|
typeof entry.color === "string" && VALID_PLAYER_COLORS.has(entry.color)
|
||||||
|
? entry.color
|
||||||
|
: undefined;
|
||||||
|
const icon =
|
||||||
|
typeof entry.icon === "string" && VALID_PLAYER_ICONS.has(entry.icon)
|
||||||
|
? entry.icon
|
||||||
|
: undefined;
|
||||||
|
const pcId =
|
||||||
|
typeof entry.playerCharacterId === "string" &&
|
||||||
|
entry.playerCharacterId.length > 0
|
||||||
|
? playerCharacterId(entry.playerCharacterId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const shared = {
|
const shared = {
|
||||||
...base,
|
...base,
|
||||||
ac: validateAc(entry.ac),
|
ac: validateAc(entry.ac),
|
||||||
conditions: validateConditions(entry.conditions),
|
conditions: validateConditions(entry.conditions),
|
||||||
isConcentrating: entry.isConcentrating === true ? true : undefined,
|
isConcentrating: entry.isConcentrating === true ? true : undefined,
|
||||||
creatureId: validateCreatureId(entry.creatureId),
|
creatureId: validateCreatureId(entry.creatureId),
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
playerCharacterId: pcId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const hp = validateHp(entry.maxHp, entry.currentHp);
|
const hp = validateHp(entry.maxHp, entry.currentHp);
|
||||||
|
|||||||
77
apps/web/src/persistence/player-character-storage.ts
Normal file
77
apps/web/src/persistence/player-character-storage.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import type { PlayerCharacter } from "@initiative/domain";
|
||||||
|
import {
|
||||||
|
playerCharacterId,
|
||||||
|
VALID_PLAYER_COLORS,
|
||||||
|
VALID_PLAYER_ICONS,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "initiative:player-characters";
|
||||||
|
|
||||||
|
export function savePlayerCharacters(characters: PlayerCharacter[]): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(characters));
|
||||||
|
} catch {
|
||||||
|
// Silently swallow errors (quota exceeded, storage unavailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidOptionalMember(
|
||||||
|
value: unknown,
|
||||||
|
valid: ReadonlySet<string>,
|
||||||
|
): boolean {
|
||||||
|
return value === undefined || (typeof value === "string" && valid.has(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
||||||
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
||||||
|
return null;
|
||||||
|
const entry = raw as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (typeof entry.id !== "string" || entry.id.length === 0) return null;
|
||||||
|
if (typeof entry.name !== "string" || entry.name.trim().length === 0)
|
||||||
|
return null;
|
||||||
|
if (
|
||||||
|
typeof entry.ac !== "number" ||
|
||||||
|
!Number.isInteger(entry.ac) ||
|
||||||
|
entry.ac < 0
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
if (
|
||||||
|
typeof entry.maxHp !== "number" ||
|
||||||
|
!Number.isInteger(entry.maxHp) ||
|
||||||
|
entry.maxHp < 1
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
if (!isValidOptionalMember(entry.color, VALID_PLAYER_COLORS)) return null;
|
||||||
|
if (!isValidOptionalMember(entry.icon, VALID_PLAYER_ICONS)) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: playerCharacterId(entry.id),
|
||||||
|
name: entry.name,
|
||||||
|
ac: entry.ac,
|
||||||
|
maxHp: entry.maxHp,
|
||||||
|
color: entry.color as PlayerCharacter["color"],
|
||||||
|
icon: entry.icon as PlayerCharacter["icon"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPlayerCharacters(): PlayerCharacter[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw === null) return [];
|
||||||
|
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
|
||||||
|
const characters: PlayerCharacter[] = [];
|
||||||
|
for (const item of parsed) {
|
||||||
|
const pc = rehydrateCharacter(item);
|
||||||
|
if (pc !== null) {
|
||||||
|
characters.push(pc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return characters;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
101
biome.json
101
biome.json
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"includes": [
|
"includes": [
|
||||||
"**",
|
"**",
|
||||||
"!**/dist/**",
|
"!**/dist",
|
||||||
"!.claude/**",
|
"!.claude",
|
||||||
"!.specify/**",
|
"!.specify",
|
||||||
"!specs/**",
|
"!specs",
|
||||||
"!coverage/**"
|
"!coverage",
|
||||||
|
"!.pnpm-store"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"assist": {
|
"assist": {
|
||||||
@@ -20,6 +21,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"css": {
|
||||||
|
"parser": {
|
||||||
|
"cssModules": false,
|
||||||
|
"tailwindDirectives": true
|
||||||
|
}
|
||||||
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"indentStyle": "tab",
|
"indentStyle": "tab",
|
||||||
@@ -29,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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
176
docs/agents/research/2026-03-13-action-bars-and-buttons.md
Normal file
176
docs/agents/research/2026-03-13-action-bars-and-buttons.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
---
|
||||||
|
date: "2026-03-13T14:39:15.661886+00:00"
|
||||||
|
git_commit: 75778884bd1be7d135b2f5ea9b8a8e77a0149f7b
|
||||||
|
branch: main
|
||||||
|
topic: "Action Bars Setup — Top Bar and Bottom Bar Buttons"
|
||||||
|
tags: [research, codebase, action-bar, turn-navigation, layout, buttons]
|
||||||
|
status: complete
|
||||||
|
---
|
||||||
|
|
||||||
|
# Research: Action Bars Setup — Top Bar and Bottom Bar Buttons
|
||||||
|
|
||||||
|
## Research Question
|
||||||
|
|
||||||
|
How are the top and bottom action bars set up, what buttons do they contain, and how are their actions wired?
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The application has two primary bar components that frame the encounter tracker UI:
|
||||||
|
|
||||||
|
1. **Top bar** — `TurnNavigation` (`turn-navigation.tsx`) — turn controls, round/combatant display, and encounter-wide actions.
|
||||||
|
2. **Bottom bar** — `ActionBar` (`action-bar.tsx`) — combatant input, bestiary search, stat block browsing, bulk import, and player character management.
|
||||||
|
|
||||||
|
Both bars share the same visual container styling (`flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3`). They are laid out in `App.tsx` within a flex column, with a scrollable combatant list between them. When the encounter is empty, only the ActionBar is shown (centered in the viewport); the TurnNavigation appears with an animation when the first combatant is added.
|
||||||
|
|
||||||
|
## Detailed Findings
|
||||||
|
|
||||||
|
### Layout Structure (`App.tsx:243-344`)
|
||||||
|
|
||||||
|
The bars live inside a `max-w-2xl` centered column:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ TurnNavigation (pt-8, shrink-0) │ ← top bar, conditionally shown
|
||||||
|
├──────────────────────────────────┤
|
||||||
|
│ SourceManager (optional inline) │ ← toggled by Library button in top bar
|
||||||
|
├──────────────────────────────────┤
|
||||||
|
│ Combatant list (flex-1, │ ← scrollable
|
||||||
|
│ overflow-y-auto) │
|
||||||
|
├──────────────────────────────────┤
|
||||||
|
│ ActionBar (pb-8, shrink-0) │ ← bottom bar
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Empty state**: When `encounter.combatants.length === 0`, the top bar is hidden and the ActionBar is vertically centered in a `flex items-center justify-center` wrapper with `pb-[15%]` offset. It receives `autoFocus` in this state.
|
||||||
|
|
||||||
|
**Animation** (`useActionBarAnimation`, `App.tsx:30-66`): Manages transitions between empty and populated states:
|
||||||
|
- Empty → populated: ActionBar plays `animate-settle-to-bottom`, TurnNavigation plays `animate-slide-down-in`.
|
||||||
|
- Populated → empty: ActionBar plays `animate-rise-to-center`, TurnNavigation plays `animate-slide-up-out` (with `absolute` positioning during exit).
|
||||||
|
|
||||||
|
The `showTopBar` flag is `true` when either combatants exist or the top bar exit animation is still running.
|
||||||
|
|
||||||
|
### Top Bar — TurnNavigation (`turn-navigation.tsx`)
|
||||||
|
|
||||||
|
**Props interface** (`turn-navigation.tsx:7-14`):
|
||||||
|
- `encounter: Encounter` — full encounter state
|
||||||
|
- `onAdvanceTurn`, `onRetreatTurn` — turn navigation callbacks
|
||||||
|
- `onClearEncounter` — destructive clear with confirmation
|
||||||
|
- `onRollAllInitiative` — rolls initiative for all combatants
|
||||||
|
- `onOpenSourceManager` — toggles source manager panel
|
||||||
|
|
||||||
|
**Layout**: Left–Center–Right structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ◀ Prev ] | [ R1 Active Combatant Name ] | [ 🎲 📚 🗑 ] [ Next ▶ ]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Buttons (left to right)**:
|
||||||
|
|
||||||
|
| # | Icon | Component | Variant | Action | Disabled when |
|
||||||
|
|---|------|-----------|---------|--------|---------------|
|
||||||
|
| 1 | `StepBack` | `Button` | default | `onRetreatTurn` | No combatants OR at round 1 index 0 |
|
||||||
|
| 2 | `D20Icon` | `Button` | ghost | `onRollAllInitiative` | Never |
|
||||||
|
| 3 | `Library` | `Button` | ghost | `onOpenSourceManager` | Never |
|
||||||
|
| 4 | `Trash2` | `ConfirmButton` | — | `onClearEncounter` | No combatants |
|
||||||
|
| 5 | `StepForward` | `Button` | default | `onAdvanceTurn` | No combatants |
|
||||||
|
|
||||||
|
**Center section** (`turn-navigation.tsx:40-49`): Displays a round badge (`R{n}` in a `rounded-full bg-muted` span) and the active combatant's name (truncated). Falls back to "No combatants" in muted text.
|
||||||
|
|
||||||
|
**Button grouping**: Buttons 2-4 are grouped in a `gap-0` div (tight spacing), while button 5 (Next) is separated by the outer `gap-3`.
|
||||||
|
|
||||||
|
**Wiring in App.tsx** (`App.tsx:251-258`):
|
||||||
|
- `onAdvanceTurn` → `advanceTurn` from `useEncounter()`
|
||||||
|
- `onRetreatTurn` → `retreatTurn` from `useEncounter()`
|
||||||
|
- `onClearEncounter` → `clearEncounter` from `useEncounter()`
|
||||||
|
- `onRollAllInitiative` → `handleRollAllInitiative` → calls `rollAllInitiativeUseCase(makeStore(), rollDice, getCreature)`
|
||||||
|
- `onOpenSourceManager` → toggles `sourceManagerOpen` state
|
||||||
|
|
||||||
|
### Bottom Bar — ActionBar (`action-bar.tsx`)
|
||||||
|
|
||||||
|
**Props interface** (`action-bar.tsx:20-36`):
|
||||||
|
- `onAddCombatant` — adds custom combatant with optional init/AC/maxHP
|
||||||
|
- `onAddFromBestiary` — adds creature from search result
|
||||||
|
- `bestiarySearch` — search function returning `SearchResult[]`
|
||||||
|
- `bestiaryLoaded` — whether bestiary index is loaded
|
||||||
|
- `onViewStatBlock` — opens stat block panel for a creature
|
||||||
|
- `onBulkImport` — triggers bulk source import mode
|
||||||
|
- `bulkImportDisabled` — disables import button during loading
|
||||||
|
- `inputRef` — external ref to the name input
|
||||||
|
- `playerCharacters` — list of player characters for quick-add
|
||||||
|
- `onAddFromPlayerCharacter` — adds a player character to encounter
|
||||||
|
- `onManagePlayers` — opens player management modal
|
||||||
|
- `autoFocus` — auto-focuses input (used in empty state)
|
||||||
|
|
||||||
|
**Layout**: Form with input, contextual fields, submit button, and action icons:
|
||||||
|
|
||||||
|
```
|
||||||
|
[ + Add combatants... ] [ Init ] [ AC ] [ MaxHP ] [ Add ] [ 👥 👁 📥 ]
|
||||||
|
```
|
||||||
|
|
||||||
|
The Init/AC/MaxHP fields only appear when the input has 2+ characters and no bestiary suggestions are showing.
|
||||||
|
|
||||||
|
**Buttons (left to right)**:
|
||||||
|
|
||||||
|
| # | Icon | Component | Variant | Action | Condition |
|
||||||
|
|---|------|-----------|---------|--------|-----------|
|
||||||
|
| 1 | — | `Button` | sm | Form submit → `handleAdd` | Always shown |
|
||||||
|
| 2 | `Users` | `Button` | ghost | `onManagePlayers` | Only if `onManagePlayers` provided |
|
||||||
|
| 3 | `Eye` | `Button` | ghost | Toggle stat block viewer dropdown | Only if `bestiaryLoaded && onViewStatBlock` |
|
||||||
|
| 4 | `Import` | `Button` | ghost | `onBulkImport` | Only if `bestiaryLoaded && onBulkImport` |
|
||||||
|
|
||||||
|
**Button grouping**: Buttons 2-4 are grouped in a `gap-0` div, mirroring the top bar's icon button grouping.
|
||||||
|
|
||||||
|
**Suggestion dropdown** (`action-bar.tsx:267-410`): Opens above the input when 2+ chars are typed and results exist. Contains:
|
||||||
|
- A "Add as custom" escape row at the top (with `Esc` keyboard hint)
|
||||||
|
- **Players section**: Lists matching player characters with colored icons; clicking adds them directly via `onAddFromPlayerCharacter`
|
||||||
|
- **Bestiary section**: Lists search results; clicking queues a creature. Queued creatures show:
|
||||||
|
- `Minus` button — decrements count (removes queue at 0)
|
||||||
|
- Count badge — current queued count
|
||||||
|
- `Plus` button — increments count
|
||||||
|
- `Check` button — confirms and adds all queued copies
|
||||||
|
|
||||||
|
**Stat block viewer dropdown** (`action-bar.tsx:470-513`): A separate search dropdown anchored to the Eye button. Has its own input, search results, and keyboard navigation. Selecting a result calls `onViewStatBlock`.
|
||||||
|
|
||||||
|
**Keyboard handling** (`action-bar.tsx:168-186`):
|
||||||
|
- Arrow Up/Down — navigate suggestion list
|
||||||
|
- Enter — queue selected suggestion or confirm queued batch
|
||||||
|
- Escape — clear suggestions and queue
|
||||||
|
|
||||||
|
**Wiring in App.tsx** (`App.tsx:269-282` and `328-340`):
|
||||||
|
- `onAddCombatant` → `addCombatant` from `useEncounter()`
|
||||||
|
- `onAddFromBestiary` → `handleAddFromBestiary` → `addFromBestiary` from `useEncounter()`
|
||||||
|
- `bestiarySearch` → `search` from `useBestiary()`
|
||||||
|
- `onViewStatBlock` → `handleViewStatBlock` → constructs `CreatureId` and sets `selectedCreatureId`
|
||||||
|
- `onBulkImport` → `handleBulkImport` → sets `bulkImportMode` and clears selection
|
||||||
|
- `onAddFromPlayerCharacter` → `addFromPlayerCharacter` from `useEncounter()`
|
||||||
|
- `onManagePlayers` → opens `managementOpen` state (shows `PlayerManagement` modal)
|
||||||
|
|
||||||
|
### Shared UI Primitives
|
||||||
|
|
||||||
|
**`Button`** (`ui/button.tsx`): CVA-based component with variants (`default`, `outline`, `ghost`) and sizes (`default`, `sm`, `icon`). Both bars use `size="icon"` with `variant="ghost"` for their icon button clusters, and `size="icon"` with default variant for the primary navigation buttons (Prev/Next in top bar).
|
||||||
|
|
||||||
|
**`ConfirmButton`** (`ui/confirm-button.tsx`): Two-click destructive action button. First click shows a red pulsing confirmation state with a Check icon; second click fires `onConfirm`. Auto-reverts after 5 seconds. Supports Escape and click-outside cancellation. Used for Clear Encounter in the top bar.
|
||||||
|
|
||||||
|
### Hover Color Convention
|
||||||
|
|
||||||
|
Both bars use consistent hover color classes on their ghost icon buttons:
|
||||||
|
- `hover:text-hover-action` — used on the D20 (roll initiative) button, suggesting an action/accent color
|
||||||
|
- `hover:text-hover-neutral` — used on Library, Users, Eye, Import buttons, suggesting a neutral/informational color
|
||||||
|
|
||||||
|
## Code References
|
||||||
|
|
||||||
|
- `apps/web/src/components/turn-navigation.tsx` — Top bar component (93 lines)
|
||||||
|
- `apps/web/src/components/action-bar.tsx` — Bottom bar component (533 lines)
|
||||||
|
- `apps/web/src/App.tsx:30-66` — `useActionBarAnimation` hook for bar transitions
|
||||||
|
- `apps/web/src/App.tsx:243-344` — Layout structure with both bars
|
||||||
|
- `apps/web/src/components/ui/button.tsx` — Shared Button component
|
||||||
|
- `apps/web/src/components/ui/confirm-button.tsx` — Two-step confirmation button
|
||||||
|
- `apps/web/src/components/d20-icon.tsx` — Custom D20 dice SVG icon
|
||||||
|
|
||||||
|
## Architecture Documentation
|
||||||
|
|
||||||
|
The bars follow the app's adapter-layer convention: they are pure presentational React components that receive all behavior via callback props. No business logic lives in either bar — they delegate to handlers defined in `App.tsx`, which in turn call use-case functions from the application layer or manipulate local UI state.
|
||||||
|
|
||||||
|
Both bars are rendered twice in `App.tsx` (once in the empty-state branch, once in the populated branch) rather than being conditionally repositioned, which simplifies the animation logic.
|
||||||
|
|
||||||
|
The `ActionBar` is the more complex of the two, managing multiple pieces of local state (input value, suggestions, queued creatures, custom fields, stat block viewer) while `TurnNavigation` is fully stateless — all its data comes from the `encounter` prop.
|
||||||
188
docs/agents/research/2026-03-13-css-classes-buttons-hover.md
Normal file
188
docs/agents/research/2026-03-13-css-classes-buttons-hover.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
---
|
||||||
|
date: "2026-03-13T15:35:07.699570+00:00"
|
||||||
|
git_commit: bd398080008349b47726d0016f4b03587f453833
|
||||||
|
branch: main
|
||||||
|
topic: "CSS class usage, button categorization, and hover effects across all components"
|
||||||
|
tags: [research, codebase, css, tailwind, buttons, hover, ui]
|
||||||
|
status: complete
|
||||||
|
---
|
||||||
|
|
||||||
|
# Research: CSS Class Usage, Button Categorization, and Hover Effects
|
||||||
|
|
||||||
|
## Research Question
|
||||||
|
How are CSS classes used across all components? How are buttons categorized — are there primary and secondary buttons? What hover effects exist, and are they unified?
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The project uses **Tailwind CSS v4** with a custom dark theme defined in `index.css` via `@theme`. All class merging goes through a `cn()` utility (clsx + tailwind-merge). Buttons are built on a shared `Button` component using **class-variance-authority (CVA)** with three variants: **default** (primary), **outline**, and **ghost**. Hover effects are partially unified through semantic color tokens (`hover-neutral`, `hover-action`, `hover-destructive`) defined in the theme, but several components use **one-off hardcoded hover colors** that bypass the token system.
|
||||||
|
|
||||||
|
## Detailed Findings
|
||||||
|
|
||||||
|
### Theme System (`index.css`)
|
||||||
|
|
||||||
|
All colors are defined as CSS custom properties via Tailwind v4's `@theme` directive (`index.css:3-26`):
|
||||||
|
|
||||||
|
| Token | Value | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `--color-background` | `#0f172a` | Page background |
|
||||||
|
| `--color-foreground` | `#e2e8f0` | Default text |
|
||||||
|
| `--color-muted` | `#64748b` | Subdued elements |
|
||||||
|
| `--color-muted-foreground` | `#94a3b8` | Secondary text |
|
||||||
|
| `--color-card` | `#1e293b` | Card/panel surfaces |
|
||||||
|
| `--color-border` | `#334155` | Borders |
|
||||||
|
| `--color-primary` | `#3b82f6` | Primary actions (blue) |
|
||||||
|
| `--color-accent` | `#3b82f6` | Accent (same as primary) |
|
||||||
|
| `--color-destructive` | `#ef4444` | Destructive actions (red) |
|
||||||
|
|
||||||
|
**Hover tokens** (semantic layer for hover states):
|
||||||
|
|
||||||
|
| Token | Resolves to | Usage |
|
||||||
|
|---|---|---|
|
||||||
|
| `hover-neutral` | `primary` (blue) | Text color on neutral hover |
|
||||||
|
| `hover-action` | `primary` (blue) | Text color on action hover |
|
||||||
|
| `hover-destructive` | `destructive` (red) | Text color on destructive hover |
|
||||||
|
| `hover-neutral-bg` | `card` (slate) | Background on neutral hover |
|
||||||
|
| `hover-action-bg` | `muted` | Background on action hover |
|
||||||
|
| `hover-destructive-bg` | `transparent` | Background on destructive hover |
|
||||||
|
|
||||||
|
### Button Component (`components/ui/button.tsx`)
|
||||||
|
|
||||||
|
Uses CVA with three variants and three sizes:
|
||||||
|
|
||||||
|
**Variants:**
|
||||||
|
|
||||||
|
| Variant | Base styles | Hover |
|
||||||
|
|---|---|---|
|
||||||
|
| `default` (primary) | `bg-primary text-primary-foreground` | `hover:bg-primary/90` |
|
||||||
|
| `outline` | `border border-border bg-transparent` | `hover:bg-hover-neutral-bg hover:text-hover-neutral` |
|
||||||
|
| `ghost` | (no background/border) | `hover:bg-hover-neutral-bg hover:text-hover-neutral` |
|
||||||
|
|
||||||
|
**Sizes:**
|
||||||
|
|
||||||
|
| Size | Classes |
|
||||||
|
|---|---|
|
||||||
|
| `default` | `h-9 px-4 py-2` |
|
||||||
|
| `sm` | `h-8 px-3 text-xs` |
|
||||||
|
| `icon` | `h-8 w-8` |
|
||||||
|
|
||||||
|
All variants share: `rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50`.
|
||||||
|
|
||||||
|
There is **no "secondary" variant** — the outline variant is the closest equivalent.
|
||||||
|
|
||||||
|
### Composite Button Components
|
||||||
|
|
||||||
|
**ConfirmButton** (`components/ui/confirm-button.tsx`):
|
||||||
|
- Wraps `Button variant="ghost" size="icon"`
|
||||||
|
- Default state: `hover:text-hover-destructive` (uses token)
|
||||||
|
- Confirming state: `bg-destructive text-primary-foreground animate-confirm-pulse hover:bg-destructive hover:text-primary-foreground`
|
||||||
|
|
||||||
|
**OverflowMenu** (`components/ui/overflow-menu.tsx`):
|
||||||
|
- Trigger: `Button variant="ghost" size="icon"` with `text-muted-foreground hover:text-hover-neutral`
|
||||||
|
- Menu items: raw `<button>` elements with `hover:bg-muted/20` (**not using the token system**)
|
||||||
|
|
||||||
|
### Button Usage Across Components
|
||||||
|
|
||||||
|
| Component | Button type | Variant/Style |
|
||||||
|
|---|---|---|
|
||||||
|
| `action-bar.tsx:556` | `<Button type="submit">` | default (primary) — "Add" |
|
||||||
|
| `action-bar.tsx:561` | `<Button type="button">` | default (primary) — "Roll all" |
|
||||||
|
| `turn-navigation.tsx:25,54` | `<Button size="icon">` | default — prev/next turn |
|
||||||
|
| `turn-navigation.tsx:47` | `<ConfirmButton>` | ghost+destructive — clear encounter |
|
||||||
|
| `source-fetch-prompt.tsx:91` | `<Button size="sm">` | default — "Load" |
|
||||||
|
| `source-fetch-prompt.tsx:106` | `<Button size="sm" variant="outline">` | outline — "Upload file" |
|
||||||
|
| `bulk-import-prompt.tsx:31,45,106` | `<Button size="sm">` | default — "Done"/"Load All" |
|
||||||
|
| `source-manager.tsx:50` | `<Button size="sm" variant="outline">` | outline — "Clear all" |
|
||||||
|
| `hp-adjust-popover.tsx:112` | `<Button variant="ghost" size="icon">` | ghost + custom red — damage |
|
||||||
|
| `hp-adjust-popover.tsx:124` | `<Button variant="ghost" size="icon">` | ghost + custom green — heal |
|
||||||
|
| `player-management.tsx:67` | `<Button>` | default — "Create first player" |
|
||||||
|
| `player-management.tsx:113` | `<Button variant="ghost">` | ghost — "Add player" |
|
||||||
|
| `create-player-modal.tsx:177` | `<Button variant="ghost">` | ghost — "Cancel" |
|
||||||
|
| `create-player-modal.tsx:180` | `<Button type="submit">` | default — "Save"/"Create" |
|
||||||
|
| `combatant-row.tsx:625` | `<ConfirmButton>` | ghost+destructive — remove combatant |
|
||||||
|
|
||||||
|
**Raw `<button>` elements** (not using the Button component):
|
||||||
|
- `action-bar.tsx` — suggestion items, count increment/decrement, browse toggle, custom add (all inline-styled)
|
||||||
|
- `combatant-row.tsx` — editable name, HP display, AC, initiative, concentration toggle
|
||||||
|
- `stat-block-panel.tsx` — fold/close/pin/unpin buttons
|
||||||
|
- `condition-picker.tsx` — condition items
|
||||||
|
- `condition-tags.tsx` — condition tags, add condition button
|
||||||
|
- `toast.tsx` — dismiss button
|
||||||
|
- `player-management.tsx` — close modal, edit player
|
||||||
|
- `create-player-modal.tsx` — close modal
|
||||||
|
- `color-palette.tsx` — color swatches
|
||||||
|
- `icon-grid.tsx` — icon options
|
||||||
|
|
||||||
|
### Hover Effects Inventory
|
||||||
|
|
||||||
|
**Using semantic tokens (unified):**
|
||||||
|
|
||||||
|
| Hover class | Meaning | Used in |
|
||||||
|
|---|---|---|
|
||||||
|
| `hover:bg-hover-neutral-bg` | Neutral background highlight | button.tsx (outline/ghost), action-bar.tsx, condition-picker.tsx, condition-tags.tsx |
|
||||||
|
| `hover:text-hover-neutral` | Text turns primary blue | button.tsx (outline/ghost), action-bar.tsx, combatant-row.tsx, stat-block-panel.tsx, ac-shield.tsx, toast.tsx, overflow-menu.tsx, condition-tags.tsx |
|
||||||
|
| `hover:text-hover-action` | Action text (same as neutral) | action-bar.tsx (overflow trigger) |
|
||||||
|
| `hover:text-hover-destructive` | Destructive text turns red | confirm-button.tsx, source-manager.tsx |
|
||||||
|
| `hover:bg-hover-destructive-bg` | Destructive background (transparent) | source-manager.tsx |
|
||||||
|
|
||||||
|
**One-off / hardcoded hover colors (NOT using tokens):**
|
||||||
|
|
||||||
|
| Hover class | Used in | Context |
|
||||||
|
|---|---|---|
|
||||||
|
| `hover:bg-primary/90` | button.tsx (default variant) | Primary button darken |
|
||||||
|
| `hover:bg-accent/20` | action-bar.tsx | Suggestion highlight, custom add |
|
||||||
|
| `hover:bg-accent/40` | action-bar.tsx | Count +/- buttons, confirm queued |
|
||||||
|
| `hover:bg-muted/20` | overflow-menu.tsx | Menu item highlight |
|
||||||
|
| `hover:bg-red-950` | hp-adjust-popover.tsx | Damage button |
|
||||||
|
| `hover:text-red-300` | hp-adjust-popover.tsx | Damage button text |
|
||||||
|
| `hover:bg-emerald-950` | hp-adjust-popover.tsx | Heal button |
|
||||||
|
| `hover:text-emerald-300` | hp-adjust-popover.tsx | Heal button text |
|
||||||
|
| `hover:text-foreground` | player-management.tsx, create-player-modal.tsx, icon-grid.tsx | Close/edit buttons |
|
||||||
|
| `hover:bg-background/50` | player-management.tsx | Player row hover |
|
||||||
|
| `hover:bg-card` | icon-grid.tsx | Icon option hover |
|
||||||
|
| `hover:border-hover-destructive` | source-manager.tsx | Clear all button border |
|
||||||
|
| `hover:scale-110` | color-palette.tsx | Color swatch enlarge |
|
||||||
|
| `hover:bg-destructive` | confirm-button.tsx (confirming state) | Maintain red bg on hover |
|
||||||
|
| `hover:text-primary-foreground` | confirm-button.tsx (confirming state) | Maintain white text on hover |
|
||||||
|
|
||||||
|
### Hover unification assessment
|
||||||
|
|
||||||
|
The hover token system (`hover-neutral`, `hover-action`, `hover-destructive`) provides a consistent pattern for the most common interactions. The `Button` component's outline and ghost variants use these tokens, and many inline buttons in action-bar, combatant-row, stat-block-panel, and condition components also use them.
|
||||||
|
|
||||||
|
However, there are notable gaps:
|
||||||
|
1. **HP adjust popover** uses hardcoded red/green colors (`red-950`, `emerald-950`) instead of tokens
|
||||||
|
2. **Overflow menu items** use `hover:bg-muted/20` instead of `hover:bg-hover-neutral-bg`
|
||||||
|
3. **Player management modals** use `hover:text-foreground` and `hover:bg-background/50` instead of the semantic tokens
|
||||||
|
4. **Action-bar suggestion items** use `hover:bg-accent/20` and `hover:bg-accent/40` — accent-specific patterns not in the token system
|
||||||
|
5. **Icon grid** and **color palette** use their own hover patterns (`hover:bg-card`, `hover:scale-110`)
|
||||||
|
|
||||||
|
## Code References
|
||||||
|
|
||||||
|
- `apps/web/src/index.css:3-26` — Theme color definitions including hover tokens
|
||||||
|
- `apps/web/src/components/ui/button.tsx:1-38` — Button component with CVA variants
|
||||||
|
- `apps/web/src/components/ui/confirm-button.tsx:93-115` — ConfirmButton with destructive hover states
|
||||||
|
- `apps/web/src/components/ui/overflow-menu.tsx:38-72` — OverflowMenu with non-token hover
|
||||||
|
- `apps/web/src/components/hp-adjust-popover.tsx:117-129` — Hardcoded red/green hover colors
|
||||||
|
- `apps/web/src/components/action-bar.tsx:80-188` — Mixed token and accent-based hovers
|
||||||
|
- `apps/web/src/components/combatant-row.tsx:147-629` — Inline buttons with token hovers
|
||||||
|
- `apps/web/src/components/player-management.tsx:58-98` — Non-token hover patterns
|
||||||
|
- `apps/web/src/components/stat-block-panel.tsx:55-109` — Consistent token usage
|
||||||
|
- `apps/web/src/lib/utils.ts:1-5` — `cn()` utility (clsx + twMerge)
|
||||||
|
|
||||||
|
## Architecture Documentation
|
||||||
|
|
||||||
|
The styling architecture follows this pattern:
|
||||||
|
|
||||||
|
1. **Theme layer**: `index.css` defines all color tokens via `@theme`, including semantic hover tokens
|
||||||
|
2. **Component layer**: `Button` (CVA) provides the shared button abstraction with three variants
|
||||||
|
3. **Composite layer**: `ConfirmButton` and `OverflowMenu` wrap `Button` with additional behavior
|
||||||
|
4. **Usage layer**: Components use either `Button` component or raw `<button>` elements with inline Tailwind classes
|
||||||
|
|
||||||
|
The `cn()` utility from `lib/utils.ts` is used in 9+ components for conditional class merging.
|
||||||
|
|
||||||
|
Custom animations are defined in `index.css` via `@keyframes` + `@utility` pairs: slide-in-right, confirm-pulse, settle-to-bottom, rise-to-center, slide-down-in, slide-up-out, concentration-pulse.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. The `hover-action` and `hover-action-bg` tokens are defined but rarely used — `hover-action` appears only once in `action-bar.tsx:565`. Is this intentional or an incomplete migration?
|
||||||
|
2. The `accent` color (`#3b82f6`) is identical to `primary` — are they intended to diverge in the future, or is this redundancy?
|
||||||
|
3. Should the hardcoded HP adjust colors (red/emerald) be promoted to theme tokens (e.g., `hover-damage`, `hover-heal`)?
|
||||||
9
nginx.conf
Normal file
9
nginx.conf
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
package.json
14
package.json
@@ -1,12 +1,19 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.6.0",
|
"packageManager": "pnpm@10.6.0",
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"undici": ">=7.24.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.0.0",
|
"@biomejs/biome": "2.4.7",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"jscpd": "^4.0.8",
|
"jscpd": "^4.0.8",
|
||||||
"knip": "^5.85.0",
|
"knip": "^5.85.0",
|
||||||
"lefthook": "^1.11.0",
|
"lefthook": "^1.11.0",
|
||||||
|
"oxlint": "^1.55.0",
|
||||||
|
"oxlint-tsgolint": "^0.16.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^3.0.0"
|
||||||
},
|
},
|
||||||
@@ -21,6 +28,9 @@
|
|||||||
"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": "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 && tsc --build && vitest run && jscpd"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
54
packages/application/src/__tests__/helpers.ts
Normal file
54
packages/application/src/__tests__/helpers.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
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 1–20), 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("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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
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("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);
|
||||||
|
});
|
||||||
|
});
|
||||||
388
packages/application/src/__tests__/use-cases.test.ts
Normal file
388
packages/application/src/__tests__/use-cases.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
36
packages/application/src/create-player-character-use-case.ts
Normal file
36
packages/application/src/create-player-character-use-case.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
createPlayerCharacter,
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
isDomainError,
|
||||||
|
type PlayerCharacterId,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { PlayerCharacterStore } from "./ports.js";
|
||||||
|
|
||||||
|
export function createPlayerCharacterUseCase(
|
||||||
|
store: PlayerCharacterStore,
|
||||||
|
id: PlayerCharacterId,
|
||||||
|
name: string,
|
||||||
|
ac: number,
|
||||||
|
maxHp: number,
|
||||||
|
color: string | undefined,
|
||||||
|
icon: string | undefined,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
const characters = store.getAll();
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
characters,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
ac,
|
||||||
|
maxHp,
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.save([...result.characters]);
|
||||||
|
return result.events;
|
||||||
|
}
|
||||||
23
packages/application/src/delete-player-character-use-case.ts
Normal file
23
packages/application/src/delete-player-character-use-case.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import {
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
deletePlayerCharacter,
|
||||||
|
isDomainError,
|
||||||
|
type PlayerCharacterId,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { PlayerCharacterStore } from "./ports.js";
|
||||||
|
|
||||||
|
export function deletePlayerCharacterUseCase(
|
||||||
|
store: PlayerCharacterStore,
|
||||||
|
id: PlayerCharacterId,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
const characters = store.getAll();
|
||||||
|
const result = deletePlayerCharacter(characters, id);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.save([...result.characters]);
|
||||||
|
return result.events;
|
||||||
|
}
|
||||||
32
packages/application/src/edit-player-character-use-case.ts
Normal file
32
packages/application/src/edit-player-character-use-case.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import {
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
editPlayerCharacter,
|
||||||
|
isDomainError,
|
||||||
|
type PlayerCharacterId,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { PlayerCharacterStore } from "./ports.js";
|
||||||
|
|
||||||
|
interface EditFields {
|
||||||
|
readonly name?: string;
|
||||||
|
readonly ac?: number;
|
||||||
|
readonly maxHp?: number;
|
||||||
|
readonly color?: string | null;
|
||||||
|
readonly icon?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function editPlayerCharacterUseCase(
|
||||||
|
store: PlayerCharacterStore,
|
||||||
|
id: PlayerCharacterId,
|
||||||
|
fields: EditFields,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
const characters = store.getAll();
|
||||||
|
const result = editPlayerCharacter(characters, id, fields);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.save([...result.characters]);
|
||||||
|
return result.events;
|
||||||
|
}
|
||||||
@@ -2,11 +2,21 @@ export { addCombatantUseCase } from "./add-combatant-use-case.js";
|
|||||||
export { adjustHpUseCase } from "./adjust-hp-use-case.js";
|
export { adjustHpUseCase } from "./adjust-hp-use-case.js";
|
||||||
export { advanceTurnUseCase } from "./advance-turn-use-case.js";
|
export { advanceTurnUseCase } from "./advance-turn-use-case.js";
|
||||||
export { clearEncounterUseCase } from "./clear-encounter-use-case.js";
|
export { clearEncounterUseCase } from "./clear-encounter-use-case.js";
|
||||||
|
export { createPlayerCharacterUseCase } from "./create-player-character-use-case.js";
|
||||||
|
export { deletePlayerCharacterUseCase } from "./delete-player-character-use-case.js";
|
||||||
export { editCombatantUseCase } from "./edit-combatant-use-case.js";
|
export { editCombatantUseCase } from "./edit-combatant-use-case.js";
|
||||||
export type { BestiarySourceCache, EncounterStore } from "./ports.js";
|
export { editPlayerCharacterUseCase } from "./edit-player-character-use-case.js";
|
||||||
|
export type {
|
||||||
|
BestiarySourceCache,
|
||||||
|
EncounterStore,
|
||||||
|
PlayerCharacterStore,
|
||||||
|
} from "./ports.js";
|
||||||
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
||||||
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
|
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
|
||||||
export { rollAllInitiativeUseCase } from "./roll-all-initiative-use-case.js";
|
export {
|
||||||
|
type RollAllResult,
|
||||||
|
rollAllInitiativeUseCase,
|
||||||
|
} from "./roll-all-initiative-use-case.js";
|
||||||
export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
|
export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
|
||||||
export { setAcUseCase } from "./set-ac-use-case.js";
|
export { setAcUseCase } from "./set-ac-use-case.js";
|
||||||
export { setHpUseCase } from "./set-hp-use-case.js";
|
export { setHpUseCase } from "./set-hp-use-case.js";
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import type { Creature, CreatureId, Encounter } from "@initiative/domain";
|
import type {
|
||||||
|
Creature,
|
||||||
|
CreatureId,
|
||||||
|
Encounter,
|
||||||
|
PlayerCharacter,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
|
||||||
export interface EncounterStore {
|
export interface EncounterStore {
|
||||||
get(): Encounter;
|
get(): Encounter;
|
||||||
@@ -9,3 +14,8 @@ export interface BestiarySourceCache {
|
|||||||
getCreature(creatureId: CreatureId): Creature | undefined;
|
getCreature(creatureId: CreatureId): Creature | undefined;
|
||||||
isSourceCached(sourceCode: string): boolean;
|
isSourceCached(sourceCode: string): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PlayerCharacterStore {
|
||||||
|
getAll(): PlayerCharacter[];
|
||||||
|
save(characters: PlayerCharacter[]): void;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,20 +10,29 @@ import {
|
|||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import type { EncounterStore } from "./ports.js";
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
|
||||||
|
export interface RollAllResult {
|
||||||
|
events: DomainEvent[];
|
||||||
|
skippedNoSource: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function rollAllInitiativeUseCase(
|
export function rollAllInitiativeUseCase(
|
||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
rollDice: () => number,
|
rollDice: () => number,
|
||||||
getCreature: (id: CreatureId) => Creature | undefined,
|
getCreature: (id: CreatureId) => Creature | undefined,
|
||||||
): DomainEvent[] | DomainError {
|
): RollAllResult | DomainError {
|
||||||
let encounter = store.get();
|
let encounter = store.get();
|
||||||
const allEvents: DomainEvent[] = [];
|
const allEvents: DomainEvent[] = [];
|
||||||
|
let skippedNoSource = 0;
|
||||||
|
|
||||||
for (const combatant of encounter.combatants) {
|
for (const combatant of encounter.combatants) {
|
||||||
if (!combatant.creatureId) continue;
|
if (!combatant.creatureId) continue;
|
||||||
if (combatant.initiative !== undefined) continue;
|
if (combatant.initiative !== undefined) continue;
|
||||||
|
|
||||||
const creature = getCreature(combatant.creatureId);
|
const creature = getCreature(combatant.creatureId);
|
||||||
if (!creature) continue;
|
if (!creature) {
|
||||||
|
skippedNoSource++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const { modifier } = calculateInitiative({
|
const { modifier } = calculateInitiative({
|
||||||
dexScore: creature.abilities.dex,
|
dexScore: creature.abilities.dex,
|
||||||
@@ -47,5 +56,5 @@ export function rollAllInitiativeUseCase(
|
|||||||
}
|
}
|
||||||
|
|
||||||
store.save(encounter);
|
store.save(encounter);
|
||||||
return allEvents;
|
return { events: allEvents, skippedNoSource };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(activeIndex).toBeGreaterThanOrEqual(0);
|
expect(combatants.length).toBeGreaterThan(0);
|
||||||
expect(activeIndex).toBeLessThan(combatants.length);
|
expect(activeIndex).toBeGreaterThanOrEqual(0);
|
||||||
} else {
|
expect(activeIndex).toBeLessThan(combatants.length);
|
||||||
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",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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,
|
||||||
@@ -101,37 +102,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");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
@@ -169,9 +167,9 @@ describe("advanceTurn", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("invariants", () => {
|
describe("invariants", () => {
|
||||||
it("INV-1: createEncounter rejects empty combatant list", () => {
|
it("INV-1: createEncounter accepts empty combatant list", () => {
|
||||||
const result = createEncounter([]);
|
const result = createEncounter([]);
|
||||||
expect(isDomainError(result)).toBe(true);
|
expect(isDomainError(result)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("INV-2: activeIndex always in bounds across all scenarios", () => {
|
it("INV-2: activeIndex always in bounds across all scenarios", () => {
|
||||||
|
|||||||
244
packages/domain/src/__tests__/create-player-character.test.ts
Normal file
244
packages/domain/src/__tests__/create-player-character.test.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createPlayerCharacter } from "../create-player-character.js";
|
||||||
|
import type { PlayerCharacter } from "../player-character-types.js";
|
||||||
|
import { playerCharacterId } from "../player-character-types.js";
|
||||||
|
import { isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
|
const id = playerCharacterId("pc-1");
|
||||||
|
|
||||||
|
function success(
|
||||||
|
characters: readonly PlayerCharacter[],
|
||||||
|
name: string,
|
||||||
|
ac: number,
|
||||||
|
maxHp: number,
|
||||||
|
color = "blue",
|
||||||
|
icon = "sword",
|
||||||
|
) {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
characters,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
ac,
|
||||||
|
maxHp,
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
throw new Error(`Expected success, got error: ${result.message}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createPlayerCharacter", () => {
|
||||||
|
it("creates a valid player character", () => {
|
||||||
|
const { characters, events } = success(
|
||||||
|
[],
|
||||||
|
"Aragorn",
|
||||||
|
16,
|
||||||
|
120,
|
||||||
|
"green",
|
||||||
|
"shield",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(characters).toHaveLength(1);
|
||||||
|
expect(characters[0]).toEqual({
|
||||||
|
id,
|
||||||
|
name: "Aragorn",
|
||||||
|
ac: 16,
|
||||||
|
maxHp: 120,
|
||||||
|
color: "green",
|
||||||
|
icon: "shield",
|
||||||
|
});
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "PlayerCharacterCreated",
|
||||||
|
playerCharacterId: id,
|
||||||
|
name: "Aragorn",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims whitespace from name", () => {
|
||||||
|
const { characters } = success([], " Gandalf ", 12, 80);
|
||||||
|
expect(characters[0].name).toBe("Gandalf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends to existing characters", () => {
|
||||||
|
const existing: PlayerCharacter = {
|
||||||
|
id: playerCharacterId("pc-0"),
|
||||||
|
name: "Legolas",
|
||||||
|
ac: 14,
|
||||||
|
maxHp: 90,
|
||||||
|
color: "green",
|
||||||
|
icon: "eye",
|
||||||
|
};
|
||||||
|
const { characters } = success([existing], "Gimli", 18, 100, "red", "axe");
|
||||||
|
expect(characters).toHaveLength(2);
|
||||||
|
expect(characters[0]).toEqual(existing);
|
||||||
|
expect(characters[1].name).toBe("Gimli");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty name", () => {
|
||||||
|
const result = createPlayerCharacter([], id, "", 10, 50, "blue", "sword");
|
||||||
|
expectDomainError(result, "invalid-name");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects whitespace-only name", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
" ",
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
"blue",
|
||||||
|
"sword",
|
||||||
|
);
|
||||||
|
expectDomainError(result, "invalid-name");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects negative AC", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
-1,
|
||||||
|
50,
|
||||||
|
"blue",
|
||||||
|
"sword",
|
||||||
|
);
|
||||||
|
expectDomainError(result, "invalid-ac");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-integer AC", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10.5,
|
||||||
|
50,
|
||||||
|
"blue",
|
||||||
|
"sword",
|
||||||
|
);
|
||||||
|
expectDomainError(result, "invalid-ac");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows AC of 0", () => {
|
||||||
|
const { characters } = success([], "Test", 0, 50);
|
||||||
|
expect(characters[0].ac).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects maxHp of 0", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
0,
|
||||||
|
"blue",
|
||||||
|
"sword",
|
||||||
|
);
|
||||||
|
expectDomainError(result, "invalid-max-hp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects negative maxHp", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
-5,
|
||||||
|
"blue",
|
||||||
|
"sword",
|
||||||
|
);
|
||||||
|
expectDomainError(result, "invalid-max-hp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-integer maxHp", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
50.5,
|
||||||
|
"blue",
|
||||||
|
"sword",
|
||||||
|
);
|
||||||
|
expectDomainError(result, "invalid-max-hp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid color", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
"neon",
|
||||||
|
"sword",
|
||||||
|
);
|
||||||
|
expectDomainError(result, "invalid-color");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid icon", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
"blue",
|
||||||
|
"banana",
|
||||||
|
);
|
||||||
|
expectDomainError(result, "invalid-icon");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows undefined color", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
undefined,
|
||||||
|
"sword",
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].color).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows undefined icon", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
"blue",
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].icon).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows both color and icon undefined", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].color).toBeUndefined();
|
||||||
|
expect(result.characters[0].icon).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits exactly one event on success", () => {
|
||||||
|
const { events } = success([], "Test", 10, 50);
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].type).toBe("PlayerCharacterCreated");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { deletePlayerCharacter } from "../delete-player-character.js";
|
||||||
|
import type { PlayerCharacter } from "../player-character-types.js";
|
||||||
|
import { playerCharacterId } from "../player-character-types.js";
|
||||||
|
import { isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
|
const id1 = playerCharacterId("pc-1");
|
||||||
|
const id2 = playerCharacterId("pc-2");
|
||||||
|
|
||||||
|
function makePC(overrides?: Partial<PlayerCharacter>): PlayerCharacter {
|
||||||
|
return {
|
||||||
|
id: id1,
|
||||||
|
name: "Aragorn",
|
||||||
|
ac: 16,
|
||||||
|
maxHp: 120,
|
||||||
|
color: "green",
|
||||||
|
icon: "sword",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("deletePlayerCharacter", () => {
|
||||||
|
it("deletes an existing character", () => {
|
||||||
|
const result = deletePlayerCharacter([makePC()], id1);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error for not-found id", () => {
|
||||||
|
const result = deletePlayerCharacter([makePC()], id2);
|
||||||
|
expectDomainError(result, "player-character-not-found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits PlayerCharacterDeleted event", () => {
|
||||||
|
const result = deletePlayerCharacter([makePC()], id1);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.events).toHaveLength(1);
|
||||||
|
expect(result.events[0].type).toBe("PlayerCharacterDeleted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves other characters when deleting one", () => {
|
||||||
|
const pc1 = makePC({ id: id1, name: "Aragorn" });
|
||||||
|
const pc2 = makePC({ id: id2, name: "Legolas" });
|
||||||
|
const result = deletePlayerCharacter([pc1, pc2], id1);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters).toHaveLength(1);
|
||||||
|
expect(result.characters[0].name).toBe("Legolas");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("event includes deleted character name", () => {
|
||||||
|
const result = deletePlayerCharacter([makePC()], id1);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
const event = result.events[0];
|
||||||
|
if (event.type !== "PlayerCharacterDeleted") throw new Error("wrong event");
|
||||||
|
expect(event.name).toBe("Aragorn");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
113
packages/domain/src/__tests__/edit-player-character.test.ts
Normal file
113
packages/domain/src/__tests__/edit-player-character.test.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { editPlayerCharacter } from "../edit-player-character.js";
|
||||||
|
import type { PlayerCharacter } from "../player-character-types.js";
|
||||||
|
import { playerCharacterId } from "../player-character-types.js";
|
||||||
|
import { isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
|
const id = playerCharacterId("pc-1");
|
||||||
|
|
||||||
|
function makePC(overrides?: Partial<PlayerCharacter>): PlayerCharacter {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: "Aragorn",
|
||||||
|
ac: 16,
|
||||||
|
maxHp: 120,
|
||||||
|
color: "green",
|
||||||
|
icon: "sword",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("editPlayerCharacter", () => {
|
||||||
|
it("edits name successfully", () => {
|
||||||
|
const result = editPlayerCharacter([makePC()], id, { name: "Strider" });
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].name).toBe("Strider");
|
||||||
|
expect(result.events[0].type).toBe("PlayerCharacterUpdated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("edits multiple fields", () => {
|
||||||
|
const result = editPlayerCharacter([makePC()], id, {
|
||||||
|
name: "Strider",
|
||||||
|
ac: 18,
|
||||||
|
});
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].name).toBe("Strider");
|
||||||
|
expect(result.characters[0].ac).toBe(18);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error for not-found id", () => {
|
||||||
|
const result = editPlayerCharacter(
|
||||||
|
[makePC()],
|
||||||
|
playerCharacterId("pc-999"),
|
||||||
|
{ name: "Nope" },
|
||||||
|
);
|
||||||
|
expectDomainError(result, "player-character-not-found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty name", () => {
|
||||||
|
const result = editPlayerCharacter([makePC()], id, { name: "" });
|
||||||
|
expectDomainError(result, "invalid-name");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid AC", () => {
|
||||||
|
const result = editPlayerCharacter([makePC()], id, { ac: -1 });
|
||||||
|
expectDomainError(result, "invalid-ac");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid maxHp", () => {
|
||||||
|
const result = editPlayerCharacter([makePC()], id, { maxHp: 0 });
|
||||||
|
expectDomainError(result, "invalid-max-hp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid color", () => {
|
||||||
|
const result = editPlayerCharacter([makePC()], id, { color: "neon" });
|
||||||
|
expectDomainError(result, "invalid-color");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid icon", () => {
|
||||||
|
const result = editPlayerCharacter([makePC()], id, { icon: "banana" });
|
||||||
|
expectDomainError(result, "invalid-icon");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error when no fields changed", () => {
|
||||||
|
const pc = makePC();
|
||||||
|
const result = editPlayerCharacter([pc], id, {
|
||||||
|
name: pc.name,
|
||||||
|
ac: pc.ac,
|
||||||
|
});
|
||||||
|
expectDomainError(result, "no-changes");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits exactly one event on success", () => {
|
||||||
|
const result = editPlayerCharacter([makePC()], id, { name: "Strider" });
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.events).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears color when set to null", () => {
|
||||||
|
const result = editPlayerCharacter([makePC({ color: "green" })], id, {
|
||||||
|
color: null,
|
||||||
|
});
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].color).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears icon when set to null", () => {
|
||||||
|
const result = editPlayerCharacter([makePC({ icon: "sword" })], id, {
|
||||||
|
icon: null,
|
||||||
|
});
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].icon).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("event includes old and new name", () => {
|
||||||
|
const result = editPlayerCharacter([makePC()], id, { name: "Strider" });
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
const event = result.events[0];
|
||||||
|
if (event.type !== "PlayerCharacterUpdated") throw new Error("wrong event");
|
||||||
|
expect(event.oldName).toBe("Aragorn");
|
||||||
|
expect(event.newName).toBe("Strider");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { rollInitiative } from "../roll-initiative.js";
|
import { rollInitiative } 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)", () => {
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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 }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,37 +117,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");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
9
packages/domain/src/__tests__/test-helpers.ts
Normal file
9
packages/domain/src/__tests__/test-helpers.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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`\$&`);
|
||||||
}
|
}
|
||||||
|
|||||||
87
packages/domain/src/create-player-character.ts
Normal file
87
packages/domain/src/create-player-character.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import type { DomainEvent } from "./events.js";
|
||||||
|
import type {
|
||||||
|
PlayerCharacter,
|
||||||
|
PlayerCharacterId,
|
||||||
|
} from "./player-character-types.js";
|
||||||
|
import {
|
||||||
|
VALID_PLAYER_COLORS,
|
||||||
|
VALID_PLAYER_ICONS,
|
||||||
|
} from "./player-character-types.js";
|
||||||
|
import type { DomainError } from "./types.js";
|
||||||
|
|
||||||
|
export interface CreatePlayerCharacterSuccess {
|
||||||
|
readonly characters: readonly PlayerCharacter[];
|
||||||
|
readonly events: DomainEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPlayerCharacter(
|
||||||
|
characters: readonly PlayerCharacter[],
|
||||||
|
id: PlayerCharacterId,
|
||||||
|
name: string,
|
||||||
|
ac: number,
|
||||||
|
maxHp: number,
|
||||||
|
color: string | undefined,
|
||||||
|
icon: string | undefined,
|
||||||
|
): CreatePlayerCharacterSuccess | DomainError {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
|
||||||
|
if (trimmed === "") {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "invalid-name",
|
||||||
|
message: "Player character name must not be empty",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(ac) || ac < 0) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "invalid-ac",
|
||||||
|
message: "AC must be a non-negative integer",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(maxHp) || maxHp < 1) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "invalid-max-hp",
|
||||||
|
message: "Max HP must be a positive integer",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (color !== undefined && !VALID_PLAYER_COLORS.has(color)) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "invalid-color",
|
||||||
|
message: `Invalid color: ${color}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (icon !== undefined && !VALID_PLAYER_ICONS.has(icon)) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "invalid-icon",
|
||||||
|
message: `Invalid icon: ${icon}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCharacter: PlayerCharacter = {
|
||||||
|
id,
|
||||||
|
name: trimmed,
|
||||||
|
ac,
|
||||||
|
maxHp,
|
||||||
|
color: color as PlayerCharacter["color"],
|
||||||
|
icon: icon as PlayerCharacter["icon"],
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
characters: [...characters, newCharacter],
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
type: "PlayerCharacterCreated",
|
||||||
|
playerCharacterId: id,
|
||||||
|
name: trimmed,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
39
packages/domain/src/delete-player-character.ts
Normal file
39
packages/domain/src/delete-player-character.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { DomainEvent } from "./events.js";
|
||||||
|
import type {
|
||||||
|
PlayerCharacter,
|
||||||
|
PlayerCharacterId,
|
||||||
|
} from "./player-character-types.js";
|
||||||
|
import type { DomainError } from "./types.js";
|
||||||
|
|
||||||
|
export interface DeletePlayerCharacterSuccess {
|
||||||
|
readonly characters: readonly PlayerCharacter[];
|
||||||
|
readonly events: DomainEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePlayerCharacter(
|
||||||
|
characters: readonly PlayerCharacter[],
|
||||||
|
id: PlayerCharacterId,
|
||||||
|
): DeletePlayerCharacterSuccess | DomainError {
|
||||||
|
const index = characters.findIndex((c) => c.id === id);
|
||||||
|
if (index === -1) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "player-character-not-found",
|
||||||
|
message: `Player character not found: ${id}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = characters[index];
|
||||||
|
const newList = characters.filter((_, i) => i !== index);
|
||||||
|
|
||||||
|
return {
|
||||||
|
characters: newList,
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
type: "PlayerCharacterDeleted",
|
||||||
|
playerCharacterId: id,
|
||||||
|
name: removed.name,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
145
packages/domain/src/edit-player-character.ts
Normal file
145
packages/domain/src/edit-player-character.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import type { DomainEvent } from "./events.js";
|
||||||
|
import type {
|
||||||
|
PlayerCharacter,
|
||||||
|
PlayerCharacterId,
|
||||||
|
} from "./player-character-types.js";
|
||||||
|
import {
|
||||||
|
VALID_PLAYER_COLORS,
|
||||||
|
VALID_PLAYER_ICONS,
|
||||||
|
} from "./player-character-types.js";
|
||||||
|
import type { DomainError } from "./types.js";
|
||||||
|
|
||||||
|
export interface EditPlayerCharacterSuccess {
|
||||||
|
readonly characters: readonly PlayerCharacter[];
|
||||||
|
readonly events: DomainEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditFields {
|
||||||
|
readonly name?: string;
|
||||||
|
readonly ac?: number;
|
||||||
|
readonly maxHp?: number;
|
||||||
|
readonly color?: string | null;
|
||||||
|
readonly icon?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateFields(fields: EditFields): DomainError | null {
|
||||||
|
if (fields.name?.trim() === "") {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "invalid-name",
|
||||||
|
message: "Player character name must not be empty",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
fields.ac !== undefined &&
|
||||||
|
(!Number.isInteger(fields.ac) || fields.ac < 0)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "invalid-ac",
|
||||||
|
message: "AC must be a non-negative integer",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
fields.maxHp !== undefined &&
|
||||||
|
(!Number.isInteger(fields.maxHp) || fields.maxHp < 1)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "invalid-max-hp",
|
||||||
|
message: "Max HP must be a positive integer",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
fields.color !== undefined &&
|
||||||
|
fields.color !== null &&
|
||||||
|
!VALID_PLAYER_COLORS.has(fields.color)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "invalid-color",
|
||||||
|
message: `Invalid color: ${fields.color}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
fields.icon !== undefined &&
|
||||||
|
fields.icon !== null &&
|
||||||
|
!VALID_PLAYER_ICONS.has(fields.icon)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "invalid-icon",
|
||||||
|
message: `Invalid icon: ${fields.icon}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFields(
|
||||||
|
existing: PlayerCharacter,
|
||||||
|
fields: EditFields,
|
||||||
|
): PlayerCharacter {
|
||||||
|
return {
|
||||||
|
id: existing.id,
|
||||||
|
name: fields.name?.trim() ?? existing.name,
|
||||||
|
ac: fields.ac ?? existing.ac,
|
||||||
|
maxHp: fields.maxHp ?? existing.maxHp,
|
||||||
|
color:
|
||||||
|
fields.color === undefined
|
||||||
|
? existing.color
|
||||||
|
: ((fields.color as PlayerCharacter["color"]) ?? undefined),
|
||||||
|
icon:
|
||||||
|
fields.icon === undefined
|
||||||
|
? existing.icon
|
||||||
|
: ((fields.icon as PlayerCharacter["icon"]) ?? undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function editPlayerCharacter(
|
||||||
|
characters: readonly PlayerCharacter[],
|
||||||
|
id: PlayerCharacterId,
|
||||||
|
fields: EditFields,
|
||||||
|
): EditPlayerCharacterSuccess | DomainError {
|
||||||
|
const index = characters.findIndex((c) => c.id === id);
|
||||||
|
if (index === -1) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "player-character-not-found",
|
||||||
|
message: `Player character not found: ${id}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationError = validateFields(fields);
|
||||||
|
if (validationError) return validationError;
|
||||||
|
|
||||||
|
const existing = characters[index];
|
||||||
|
const updated = applyFields(existing, fields);
|
||||||
|
|
||||||
|
if (
|
||||||
|
updated.name === existing.name &&
|
||||||
|
updated.ac === existing.ac &&
|
||||||
|
updated.maxHp === existing.maxHp &&
|
||||||
|
updated.color === existing.color &&
|
||||||
|
updated.icon === existing.icon
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "no-changes",
|
||||||
|
message: "No fields changed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const newList = characters.map((c, i) => (i === index ? updated : c));
|
||||||
|
|
||||||
|
return {
|
||||||
|
characters: newList,
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
type: "PlayerCharacterUpdated",
|
||||||
|
playerCharacterId: id,
|
||||||
|
oldName: existing.name,
|
||||||
|
newName: updated.name,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ConditionId } from "./conditions.js";
|
import type { ConditionId } from "./conditions.js";
|
||||||
|
import type { PlayerCharacterId } from "./player-character-types.js";
|
||||||
import type { CombatantId } from "./types.js";
|
import type { CombatantId } from "./types.js";
|
||||||
|
|
||||||
export interface TurnAdvanced {
|
export interface TurnAdvanced {
|
||||||
@@ -103,6 +104,25 @@ export interface EncounterCleared {
|
|||||||
readonly combatantCount: number;
|
readonly combatantCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PlayerCharacterCreated {
|
||||||
|
readonly type: "PlayerCharacterCreated";
|
||||||
|
readonly playerCharacterId: PlayerCharacterId;
|
||||||
|
readonly name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerCharacterUpdated {
|
||||||
|
readonly type: "PlayerCharacterUpdated";
|
||||||
|
readonly playerCharacterId: PlayerCharacterId;
|
||||||
|
readonly oldName: string;
|
||||||
|
readonly newName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerCharacterDeleted {
|
||||||
|
readonly type: "PlayerCharacterDeleted";
|
||||||
|
readonly playerCharacterId: PlayerCharacterId;
|
||||||
|
readonly name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type DomainEvent =
|
export type DomainEvent =
|
||||||
| TurnAdvanced
|
| TurnAdvanced
|
||||||
| RoundAdvanced
|
| RoundAdvanced
|
||||||
@@ -119,4 +139,7 @@ export type DomainEvent =
|
|||||||
| ConditionRemoved
|
| ConditionRemoved
|
||||||
| ConcentrationStarted
|
| ConcentrationStarted
|
||||||
| ConcentrationEnded
|
| ConcentrationEnded
|
||||||
| EncounterCleared;
|
| EncounterCleared
|
||||||
|
| PlayerCharacterCreated
|
||||||
|
| PlayerCharacterUpdated
|
||||||
|
| PlayerCharacterDeleted;
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ export {
|
|||||||
type ConditionId,
|
type ConditionId,
|
||||||
VALID_CONDITION_IDS,
|
VALID_CONDITION_IDS,
|
||||||
} from "./conditions.js";
|
} from "./conditions.js";
|
||||||
|
export {
|
||||||
|
type CreatePlayerCharacterSuccess,
|
||||||
|
createPlayerCharacter,
|
||||||
|
} from "./create-player-character.js";
|
||||||
export {
|
export {
|
||||||
type BestiaryIndex,
|
type BestiaryIndex,
|
||||||
type BestiaryIndexEntry,
|
type BestiaryIndexEntry,
|
||||||
@@ -25,10 +29,18 @@ export {
|
|||||||
type SpellcastingBlock,
|
type SpellcastingBlock,
|
||||||
type TraitBlock,
|
type TraitBlock,
|
||||||
} from "./creature-types.js";
|
} from "./creature-types.js";
|
||||||
|
export {
|
||||||
|
type DeletePlayerCharacterSuccess,
|
||||||
|
deletePlayerCharacter,
|
||||||
|
} from "./delete-player-character.js";
|
||||||
export {
|
export {
|
||||||
type EditCombatantSuccess,
|
type EditCombatantSuccess,
|
||||||
editCombatant,
|
editCombatant,
|
||||||
} from "./edit-combatant.js";
|
} from "./edit-combatant.js";
|
||||||
|
export {
|
||||||
|
type EditPlayerCharacterSuccess,
|
||||||
|
editPlayerCharacter,
|
||||||
|
} from "./edit-player-character.js";
|
||||||
export type {
|
export type {
|
||||||
AcSet,
|
AcSet,
|
||||||
CombatantAdded,
|
CombatantAdded,
|
||||||
@@ -43,6 +55,9 @@ export type {
|
|||||||
EncounterCleared,
|
EncounterCleared,
|
||||||
InitiativeSet,
|
InitiativeSet,
|
||||||
MaxHpSet,
|
MaxHpSet,
|
||||||
|
PlayerCharacterCreated,
|
||||||
|
PlayerCharacterDeleted,
|
||||||
|
PlayerCharacterUpdated,
|
||||||
RoundAdvanced,
|
RoundAdvanced,
|
||||||
RoundRetreated,
|
RoundRetreated,
|
||||||
TurnAdvanced,
|
TurnAdvanced,
|
||||||
@@ -54,6 +69,16 @@ export {
|
|||||||
formatInitiativeModifier,
|
formatInitiativeModifier,
|
||||||
type InitiativeResult,
|
type InitiativeResult,
|
||||||
} from "./initiative.js";
|
} from "./initiative.js";
|
||||||
|
export {
|
||||||
|
type PlayerCharacter,
|
||||||
|
type PlayerCharacterId,
|
||||||
|
type PlayerCharacterList,
|
||||||
|
type PlayerColor,
|
||||||
|
type PlayerIcon,
|
||||||
|
playerCharacterId,
|
||||||
|
VALID_PLAYER_COLORS,
|
||||||
|
VALID_PLAYER_ICONS,
|
||||||
|
} from "./player-character-types.js";
|
||||||
export {
|
export {
|
||||||
type RemoveCombatantSuccess,
|
type RemoveCombatantSuccess,
|
||||||
removeCombatant,
|
removeCombatant,
|
||||||
|
|||||||
81
packages/domain/src/player-character-types.ts
Normal file
81
packages/domain/src/player-character-types.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/** Branded string type for player character identity. */
|
||||||
|
export type PlayerCharacterId = string & {
|
||||||
|
readonly __brand: "PlayerCharacterId";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function playerCharacterId(id: string): PlayerCharacterId {
|
||||||
|
return id as PlayerCharacterId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlayerColor =
|
||||||
|
| "red"
|
||||||
|
| "blue"
|
||||||
|
| "green"
|
||||||
|
| "purple"
|
||||||
|
| "orange"
|
||||||
|
| "pink"
|
||||||
|
| "cyan"
|
||||||
|
| "yellow"
|
||||||
|
| "emerald"
|
||||||
|
| "indigo";
|
||||||
|
|
||||||
|
export const VALID_PLAYER_COLORS: ReadonlySet<string> = new Set<PlayerColor>([
|
||||||
|
"red",
|
||||||
|
"blue",
|
||||||
|
"green",
|
||||||
|
"purple",
|
||||||
|
"orange",
|
||||||
|
"pink",
|
||||||
|
"cyan",
|
||||||
|
"yellow",
|
||||||
|
"emerald",
|
||||||
|
"indigo",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type PlayerIcon =
|
||||||
|
| "sword"
|
||||||
|
| "shield"
|
||||||
|
| "skull"
|
||||||
|
| "heart"
|
||||||
|
| "wand"
|
||||||
|
| "flame"
|
||||||
|
| "crown"
|
||||||
|
| "star"
|
||||||
|
| "moon"
|
||||||
|
| "sun"
|
||||||
|
| "axe"
|
||||||
|
| "crosshair"
|
||||||
|
| "eye"
|
||||||
|
| "feather"
|
||||||
|
| "zap";
|
||||||
|
|
||||||
|
export const VALID_PLAYER_ICONS: ReadonlySet<string> = new Set<PlayerIcon>([
|
||||||
|
"sword",
|
||||||
|
"shield",
|
||||||
|
"skull",
|
||||||
|
"heart",
|
||||||
|
"wand",
|
||||||
|
"flame",
|
||||||
|
"crown",
|
||||||
|
"star",
|
||||||
|
"moon",
|
||||||
|
"sun",
|
||||||
|
"axe",
|
||||||
|
"crosshair",
|
||||||
|
"eye",
|
||||||
|
"feather",
|
||||||
|
"zap",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export interface PlayerCharacter {
|
||||||
|
readonly id: PlayerCharacterId;
|
||||||
|
readonly name: string;
|
||||||
|
readonly ac: number;
|
||||||
|
readonly maxHp: number;
|
||||||
|
readonly color?: PlayerColor;
|
||||||
|
readonly icon?: PlayerIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerCharacterList {
|
||||||
|
readonly characters: readonly PlayerCharacter[];
|
||||||
|
}
|
||||||
@@ -21,14 +21,12 @@ export function setAc(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value !== undefined) {
|
if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
|
||||||
if (!Number.isInteger(value) || value < 0) {
|
return {
|
||||||
return {
|
kind: "domain-error",
|
||||||
kind: "domain-error",
|
code: "invalid-ac",
|
||||||
code: "invalid-ac",
|
message: `AC must be a non-negative integer, got ${value}`,
|
||||||
message: `AC must be a non-negative integer, got ${value}`,
|
};
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = encounter.combatants[targetIdx];
|
const target = encounter.combatants[targetIdx];
|
||||||
|
|||||||
@@ -28,14 +28,12 @@ export function setHp(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maxHp !== undefined) {
|
if (maxHp !== undefined && (!Number.isInteger(maxHp) || maxHp < 1)) {
|
||||||
if (!Number.isInteger(maxHp) || maxHp < 1) {
|
return {
|
||||||
return {
|
kind: "domain-error",
|
||||||
kind: "domain-error",
|
code: "invalid-max-hp",
|
||||||
code: "invalid-max-hp",
|
message: `Max HP must be a positive integer, got ${maxHp}`,
|
||||||
message: `Max HP must be a positive integer, got ${maxHp}`,
|
};
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = encounter.combatants[targetIdx];
|
const target = encounter.combatants[targetIdx];
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export function setInitiative(
|
|||||||
const aInit = a.c.initiative as number;
|
const aInit = a.c.initiative as number;
|
||||||
const bInit = b.c.initiative as number;
|
const bInit = b.c.initiative as number;
|
||||||
const diff = bInit - aInit;
|
const diff = bInit - aInit;
|
||||||
return diff !== 0 ? diff : a.i - b.i;
|
return diff === 0 ? a.i - b.i : diff;
|
||||||
}
|
}
|
||||||
if (aHas && !bHas) return -1;
|
if (aHas && !bHas) return -1;
|
||||||
if (!aHas && bHas) return 1;
|
if (!aHas && bHas) return 1;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export function combatantId(id: string): CombatantId {
|
|||||||
|
|
||||||
import type { ConditionId } from "./conditions.js";
|
import type { ConditionId } from "./conditions.js";
|
||||||
import type { CreatureId } from "./creature-types.js";
|
import type { CreatureId } from "./creature-types.js";
|
||||||
|
import type { PlayerCharacterId } from "./player-character-types.js";
|
||||||
|
|
||||||
export interface Combatant {
|
export interface Combatant {
|
||||||
readonly id: CombatantId;
|
readonly id: CombatantId;
|
||||||
@@ -18,6 +19,9 @@ export interface Combatant {
|
|||||||
readonly conditions?: readonly ConditionId[];
|
readonly conditions?: readonly ConditionId[];
|
||||||
readonly isConcentrating?: boolean;
|
readonly isConcentrating?: boolean;
|
||||||
readonly creatureId?: CreatureId;
|
readonly creatureId?: CreatureId;
|
||||||
|
readonly color?: string;
|
||||||
|
readonly icon?: string;
|
||||||
|
readonly playerCharacterId?: PlayerCharacterId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Encounter {
|
export interface Encounter {
|
||||||
@@ -38,8 +42,8 @@ function domainError(code: string, message: string): DomainError {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a valid Encounter, enforcing INV-1, INV-2, INV-3.
|
* Creates a valid Encounter, enforcing INV-1, INV-2, INV-3.
|
||||||
* - INV-1: At least one combatant required.
|
* - INV-1: An encounter MAY have zero combatants.
|
||||||
* - INV-2: activeIndex defaults to 0 (always in bounds).
|
* - INV-2: activeIndex defaults to 0 (always in bounds when combatants exist).
|
||||||
* - INV-3: roundNumber defaults to 1 (positive integer).
|
* - INV-3: roundNumber defaults to 1 (positive integer).
|
||||||
*/
|
*/
|
||||||
export function createEncounter(
|
export function createEncounter(
|
||||||
@@ -47,13 +51,10 @@ export function createEncounter(
|
|||||||
activeIndex = 0,
|
activeIndex = 0,
|
||||||
roundNumber = 1,
|
roundNumber = 1,
|
||||||
): Encounter | DomainError {
|
): Encounter | DomainError {
|
||||||
if (combatants.length === 0) {
|
if (
|
||||||
return domainError(
|
combatants.length > 0 &&
|
||||||
"invalid-encounter",
|
(activeIndex < 0 || activeIndex >= combatants.length)
|
||||||
"An encounter must have at least one combatant",
|
) {
|
||||||
);
|
|
||||||
}
|
|
||||||
if (activeIndex < 0 || activeIndex >= combatants.length) {
|
|
||||||
return domainError(
|
return domainError(
|
||||||
"invalid-encounter",
|
"invalid-encounter",
|
||||||
`activeIndex ${activeIndex} out of bounds for ${combatants.length} combatants`,
|
`activeIndex ${activeIndex} out of bounds for ${combatants.length} combatants`,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user