Compare commits
41 Commits
0.2.0
...
ef0b755eec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,3 +11,4 @@ Thumbs.db
|
||||
.idea/
|
||||
coverage/
|
||||
*.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
|
||||
|
||||
```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 test # Run all tests (Vitest)
|
||||
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
|
||||
- Lucide React (icons)
|
||||
- `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)
|
||||
|
||||
## 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`).
|
||||
- **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.
|
||||
@@ -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`.
|
||||
- **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 (`/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/003-combatant-state/` — HP, AC, conditions, concentration, initiative
|
||||
- `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)
|
||||
|
||||
@@ -111,3 +122,10 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work:
|
||||
3. **Clarification-First** — Ask before making non-trivial assumptions.
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -7,7 +7,8 @@ A local-first initiative tracker and encounter manager for tabletop RPGs (D&D 5e
|
||||
- **Initiative tracking** — add combatants (batch-add from bestiary, custom creatures with optional stats), roll initiative (manual or d20), cycle turns and rounds
|
||||
- **Encounter state** — HP, AC, conditions, concentration tracking with visual status indicators
|
||||
- **Bestiary integration** — import bestiary JSON sources, search creatures, and view full stat blocks
|
||||
- **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
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
|
||||
@@ -2,25 +2,82 @@ import {
|
||||
rollAllInitiativeUseCase,
|
||||
rollInitiativeUseCase,
|
||||
} from "@initiative/application";
|
||||
import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
type CombatantId,
|
||||
type Creature,
|
||||
type CreatureId,
|
||||
isDomainError,
|
||||
} from "@initiative/domain";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ActionBar } from "./components/action-bar";
|
||||
import { BulkImportToasts } from "./components/bulk-import-toasts";
|
||||
import { CombatantRow } from "./components/combatant-row";
|
||||
import { SourceManager } from "./components/source-manager";
|
||||
import {
|
||||
PlayerCharacterSection,
|
||||
type PlayerCharacterSectionHandle,
|
||||
} from "./components/player-character-section";
|
||||
import { StatBlockPanel } from "./components/stat-block-panel";
|
||||
import { Toast } from "./components/toast";
|
||||
import { TurnNavigation } from "./components/turn-navigation";
|
||||
import { type SearchResult, useBestiary } from "./hooks/use-bestiary";
|
||||
import { useBulkImport } from "./hooks/use-bulk-import";
|
||||
import { useEncounter } from "./hooks/use-encounter";
|
||||
import { usePlayerCharacters } from "./hooks/use-player-characters";
|
||||
import { useSidePanelState } from "./hooks/use-side-panel-state";
|
||||
|
||||
function rollDice(): number {
|
||||
return Math.floor(Math.random() * 20) + 1;
|
||||
}
|
||||
|
||||
function useActionBarAnimation(combatantCount: number) {
|
||||
const wasEmptyRef = useRef(combatantCount === 0);
|
||||
const [settling, setSettling] = useState(false);
|
||||
const [rising, setRising] = useState(false);
|
||||
const [topBarExiting, setTopBarExiting] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const nowEmpty = combatantCount === 0;
|
||||
if (wasEmptyRef.current && !nowEmpty) {
|
||||
setSettling(true);
|
||||
} else if (!wasEmptyRef.current && nowEmpty) {
|
||||
setRising(true);
|
||||
setTopBarExiting(true);
|
||||
}
|
||||
wasEmptyRef.current = nowEmpty;
|
||||
}, [combatantCount]);
|
||||
|
||||
const empty = combatantCount === 0;
|
||||
const risingClass = rising ? " animate-rise-to-center" : "";
|
||||
const settlingClass = settling ? " animate-settle-to-bottom" : "";
|
||||
const 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() {
|
||||
const {
|
||||
encounter,
|
||||
isEmpty,
|
||||
hasCreatureCombatants,
|
||||
canRollAllInitiative,
|
||||
advanceTurn,
|
||||
retreatTurn,
|
||||
addCombatant,
|
||||
@@ -34,9 +91,17 @@ export function App() {
|
||||
toggleCondition,
|
||||
toggleConcentration,
|
||||
addFromBestiary,
|
||||
addFromPlayerCharacter,
|
||||
makeStore,
|
||||
} = useEncounter();
|
||||
|
||||
const {
|
||||
characters: playerCharacters,
|
||||
createCharacter: createPlayerCharacter,
|
||||
editCharacter: editPlayerCharacter,
|
||||
deleteCharacter: deletePlayerCharacter,
|
||||
} = usePlayerCharacters();
|
||||
|
||||
const {
|
||||
search,
|
||||
getCreature,
|
||||
@@ -48,53 +113,31 @@ export function App() {
|
||||
} = useBestiary();
|
||||
|
||||
const bulkImport = useBulkImport();
|
||||
const sidePanel = useSidePanelState();
|
||||
|
||||
const [selectedCreatureId, setSelectedCreatureId] =
|
||||
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,
|
||||
);
|
||||
const [rollSkippedCount, setRollSkippedCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(min-width: 1280px)");
|
||||
const handler = (e: MediaQueryListEvent) => setIsWideDesktop(e.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
const selectedCreature: Creature | null = selectedCreatureId
|
||||
? (getCreature(selectedCreatureId) ?? null)
|
||||
const selectedCreature: Creature | null = sidePanel.selectedCreatureId
|
||||
? (getCreature(sidePanel.selectedCreatureId) ?? null)
|
||||
: null;
|
||||
|
||||
const pinnedCreature: Creature | null = pinnedCreatureId
|
||||
? (getCreature(pinnedCreatureId) ?? null)
|
||||
const pinnedCreature: Creature | null = sidePanel.pinnedCreatureId
|
||||
? (getCreature(sidePanel.pinnedCreatureId) ?? null)
|
||||
: null;
|
||||
|
||||
const handleAddFromBestiary = useCallback(
|
||||
(result: SearchResult) => {
|
||||
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],
|
||||
);
|
||||
|
||||
const handleCombatantStatBlock = useCallback((creatureId: string) => {
|
||||
setSelectedCreatureId(creatureId as CreatureId);
|
||||
setIsRightPanelFolded(false);
|
||||
}, []);
|
||||
const handleCombatantStatBlock = useCallback(
|
||||
(creatureId: string) => {
|
||||
sidePanel.showCreature(creatureId as CreatureId);
|
||||
},
|
||||
[sidePanel.showCreature],
|
||||
);
|
||||
|
||||
const handleRollInitiative = useCallback(
|
||||
(id: CombatantId) => {
|
||||
@@ -104,23 +147,23 @@ export function App() {
|
||||
);
|
||||
|
||||
const handleRollAllInitiative = useCallback(() => {
|
||||
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
||||
const result = rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
||||
if (!isDomainError(result) && result.skippedNoSource > 0) {
|
||||
setRollSkippedCount(result.skippedNoSource);
|
||||
}
|
||||
}, [makeStore, getCreature]);
|
||||
|
||||
const handleViewStatBlock = useCallback((result: SearchResult) => {
|
||||
const slug = result.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
||||
setSelectedCreatureId(cId);
|
||||
setIsRightPanelFolded(false);
|
||||
}, []);
|
||||
|
||||
const handleBulkImport = useCallback(() => {
|
||||
setBulkImportMode(true);
|
||||
setSelectedCreatureId(null);
|
||||
}, []);
|
||||
const handleViewStatBlock = useCallback(
|
||||
(result: SearchResult) => {
|
||||
const slug = result.name
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||
.replaceAll(/(^-|-$)/g, "");
|
||||
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
||||
sidePanel.showCreature(cId);
|
||||
},
|
||||
[sidePanel.showCreature],
|
||||
);
|
||||
|
||||
const handleStartBulkImport = useCallback(
|
||||
(baseUrl: string) => {
|
||||
@@ -135,30 +178,13 @@ export function App() {
|
||||
);
|
||||
|
||||
const handleBulkImportDone = useCallback(() => {
|
||||
setBulkImportMode(false);
|
||||
sidePanel.dismissPanel();
|
||||
bulkImport.reset();
|
||||
}, [bulkImport.reset]);
|
||||
}, [sidePanel.dismissPanel, bulkImport.reset]);
|
||||
|
||||
const handleDismissBrowsePanel = useCallback(() => {
|
||||
setSelectedCreatureId(null);
|
||||
setBulkImportMode(false);
|
||||
}, []);
|
||||
|
||||
const handleToggleFold = useCallback(() => {
|
||||
setIsRightPanelFolded((f) => !f);
|
||||
}, []);
|
||||
|
||||
const handlePin = useCallback(() => {
|
||||
if (selectedCreatureId) {
|
||||
setPinnedCreatureId((prev) =>
|
||||
prev === selectedCreatureId ? null : selectedCreatureId,
|
||||
);
|
||||
}
|
||||
}, [selectedCreatureId]);
|
||||
|
||||
const handleUnpin = useCallback(() => {
|
||||
setPinnedCreatureId(null);
|
||||
}, []);
|
||||
const actionBarInputRef = useRef<HTMLInputElement>(null);
|
||||
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
|
||||
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
|
||||
|
||||
// Auto-scroll to the active combatant when the turn changes
|
||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||
@@ -167,7 +193,7 @@ export function App() {
|
||||
block: "nearest",
|
||||
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.
|
||||
@@ -176,97 +202,138 @@ export function App() {
|
||||
useEffect(() => {
|
||||
if (prevActiveIndexRef.current === encounter.activeIndex) return;
|
||||
prevActiveIndexRef.current = encounter.activeIndex;
|
||||
if (!window.matchMedia("(min-width: 1024px)").matches) return;
|
||||
if (!globalThis.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]);
|
||||
sidePanel.showCreature(active.creatureId);
|
||||
}, [
|
||||
encounter.activeIndex,
|
||||
encounter.combatants,
|
||||
isLoaded,
|
||||
sidePanel.showCreature,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-4 min-h-0">
|
||||
{/* Turn Navigation — fixed at top */}
|
||||
<div className="shrink-0 pt-8">
|
||||
<TurnNavigation
|
||||
encounter={encounter}
|
||||
onAdvanceTurn={advanceTurn}
|
||||
onRetreatTurn={retreatTurn}
|
||||
onClearEncounter={clearEncounter}
|
||||
onRollAllInitiative={handleRollAllInitiative}
|
||||
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{sourceManagerOpen && (
|
||||
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
|
||||
<SourceManager onCacheCleared={refreshCache} />
|
||||
<div className="relative mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col gap-3 px-4">
|
||||
{!!actionBarAnim.showTopBar && (
|
||||
<div
|
||||
className={`shrink-0 pt-8${actionBarAnim.topBarClass}`}
|
||||
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
|
||||
>
|
||||
<TurnNavigation
|
||||
encounter={encounter}
|
||||
onAdvanceTurn={advanceTurn}
|
||||
onRetreatTurn={retreatTurn}
|
||||
onClearEncounter={clearEncounter}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scrollable area — combatant list */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="flex flex-col px-2 py-2">
|
||||
{encounter.combatants.length === 0 ? (
|
||||
<p className="py-12 text-center text-sm text-muted-foreground">
|
||||
No combatants yet — add one to get started
|
||||
</p>
|
||||
) : (
|
||||
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
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{isEmpty ? (
|
||||
/* Empty state — ActionBar centered */
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center pt-8 pb-[15%]">
|
||||
<div
|
||||
className={`w-full${actionBarAnim.risingClass}`}
|
||||
onAnimationEnd={actionBarAnim.onRiseEnd}
|
||||
>
|
||||
<ActionBar
|
||||
onAddCombatant={addCombatant}
|
||||
onAddFromBestiary={handleAddFromBestiary}
|
||||
bestiarySearch={search}
|
||||
bestiaryLoaded={isLoaded}
|
||||
onViewStatBlock={handleViewStatBlock}
|
||||
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}
|
||||
autoFocus
|
||||
/>
|
||||
</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 */}
|
||||
<div className="shrink-0 pb-8">
|
||||
<ActionBar
|
||||
onAddCombatant={addCombatant}
|
||||
onAddFromBestiary={handleAddFromBestiary}
|
||||
bestiarySearch={search}
|
||||
bestiaryLoaded={isLoaded}
|
||||
onViewStatBlock={handleViewStatBlock}
|
||||
onBulkImport={handleBulkImport}
|
||||
bulkImportDisabled={bulkImport.state.status === "loading"}
|
||||
/>
|
||||
</div>
|
||||
{/* Action Bar — fixed at bottom */}
|
||||
<div
|
||||
className={`shrink-0 pb-8${actionBarAnim.settlingClass}`}
|
||||
onAnimationEnd={actionBarAnim.onSettleEnd}
|
||||
>
|
||||
<ActionBar
|
||||
onAddCombatant={addCombatant}
|
||||
onAddFromBestiary={handleAddFromBestiary}
|
||||
bestiarySearch={search}
|
||||
bestiaryLoaded={isLoaded}
|
||||
onViewStatBlock={handleViewStatBlock}
|
||||
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>
|
||||
|
||||
{/* Pinned Stat Block Panel (left) */}
|
||||
{pinnedCreatureId && isWideDesktop && (
|
||||
{!!sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
|
||||
<StatBlockPanel
|
||||
creatureId={pinnedCreatureId}
|
||||
creatureId={sidePanel.pinnedCreatureId}
|
||||
creature={pinnedCreature}
|
||||
isSourceCached={isSourceCached}
|
||||
fetchAndCacheSource={fetchAndCacheSource}
|
||||
uploadAndCacheSource={uploadAndCacheSource}
|
||||
refreshCache={refreshCache}
|
||||
panelRole="pinned"
|
||||
isFolded={false}
|
||||
onToggleFold={() => {}}
|
||||
isCollapsed={false}
|
||||
onToggleCollapse={() => {}}
|
||||
onPin={() => {}}
|
||||
onUnpin={handleUnpin}
|
||||
onUnpin={sidePanel.unpin}
|
||||
showPinButton={false}
|
||||
side="left"
|
||||
onDismiss={() => {}}
|
||||
@@ -275,52 +342,48 @@ export function App() {
|
||||
|
||||
{/* Browse Stat Block Panel (right) */}
|
||||
<StatBlockPanel
|
||||
creatureId={selectedCreatureId}
|
||||
creatureId={sidePanel.selectedCreatureId}
|
||||
creature={selectedCreature}
|
||||
isSourceCached={isSourceCached}
|
||||
fetchAndCacheSource={fetchAndCacheSource}
|
||||
uploadAndCacheSource={uploadAndCacheSource}
|
||||
refreshCache={refreshCache}
|
||||
panelRole="browse"
|
||||
isFolded={isRightPanelFolded}
|
||||
onToggleFold={handleToggleFold}
|
||||
onPin={handlePin}
|
||||
isCollapsed={sidePanel.isRightPanelCollapsed}
|
||||
onToggleCollapse={sidePanel.toggleCollapse}
|
||||
onPin={sidePanel.togglePin}
|
||||
onUnpin={() => {}}
|
||||
showPinButton={isWideDesktop && !!selectedCreature}
|
||||
showPinButton={sidePanel.isWideDesktop && !!selectedCreature}
|
||||
side="right"
|
||||
onDismiss={handleDismissBrowsePanel}
|
||||
bulkImportMode={bulkImportMode}
|
||||
onDismiss={sidePanel.dismissPanel}
|
||||
bulkImportMode={sidePanel.bulkImportMode}
|
||||
bulkImportState={bulkImport.state}
|
||||
onStartBulkImport={handleStartBulkImport}
|
||||
onBulkImportDone={handleBulkImportDone}
|
||||
sourceManagerMode={sidePanel.sourceManagerMode}
|
||||
/>
|
||||
|
||||
{/* Toast for bulk import progress when panel is closed */}
|
||||
{bulkImport.state.status === "loading" && !bulkImportMode && (
|
||||
<BulkImportToasts
|
||||
state={bulkImport.state}
|
||||
visible={!sidePanel.bulkImportMode || sidePanel.isRightPanelCollapsed}
|
||||
onReset={bulkImport.reset}
|
||||
/>
|
||||
|
||||
{rollSkippedCount > 0 && (
|
||||
<Toast
|
||||
message={`Loading sources... ${bulkImport.state.completed + bulkImport.state.failed}/${bulkImport.state.total}`}
|
||||
progress={
|
||||
bulkImport.state.total > 0
|
||||
? (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}
|
||||
message={`${rollSkippedCount} skipped — bestiary source not loaded`}
|
||||
onDismiss={() => setRollSkippedCount(0)}
|
||||
autoDismissMs={4000}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PlayerCharacterSection
|
||||
ref={playerCharacterRef}
|
||||
characters={playerCharacters}
|
||||
onCreateCharacter={createPlayerCharacter}
|
||||
onEditCharacter={editPlayerCharacter}
|
||||
onDeleteCharacter={deletePlayerCharacter}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -200,6 +200,7 @@ describe("ConfirmButton", () => {
|
||||
const parentHandler = vi.fn();
|
||||
render(
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: test wrapper
|
||||
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: test wrapper
|
||||
<div onKeyDown={parentHandler}>
|
||||
<ConfirmButton
|
||||
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 { 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: Creature = {
|
||||
id: CREATURE_ID,
|
||||
@@ -26,7 +29,7 @@ const CREATURE: Creature = {
|
||||
};
|
||||
|
||||
function mockMatchMedia(matches: boolean) {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
Object.defineProperty(globalThis, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches,
|
||||
@@ -45,8 +48,8 @@ interface PanelProps {
|
||||
creatureId?: CreatureId | null;
|
||||
creature?: Creature | null;
|
||||
panelRole?: "browse" | "pinned";
|
||||
isFolded?: boolean;
|
||||
onToggleFold?: () => void;
|
||||
isCollapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
onPin?: () => void;
|
||||
onUnpin?: () => void;
|
||||
showPinButton?: boolean;
|
||||
@@ -64,8 +67,8 @@ function renderPanel(overrides: PanelProps = {}) {
|
||||
uploadAndCacheSource: vi.fn(),
|
||||
refreshCache: vi.fn(),
|
||||
panelRole: "browse" as const,
|
||||
isFolded: false,
|
||||
onToggleFold: vi.fn(),
|
||||
isCollapsed: false,
|
||||
onToggleCollapse: vi.fn(),
|
||||
onPin: vi.fn(),
|
||||
onUnpin: vi.fn(),
|
||||
showPinButton: false,
|
||||
@@ -78,21 +81,21 @@ function renderPanel(overrides: PanelProps = {}) {
|
||||
return props;
|
||||
}
|
||||
|
||||
describe("Stat Block Panel Fold/Unfold and Pin", () => {
|
||||
describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
||||
beforeEach(() => {
|
||||
mockMatchMedia(true); // desktop by default
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("US1: Fold and Unfold", () => {
|
||||
it("shows fold button instead of close button on desktop", () => {
|
||||
describe("US1: Collapse and Expand", () => {
|
||||
it("shows collapse button instead of close button on desktop", () => {
|
||||
renderPanel();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Fold stat block panel" }),
|
||||
screen.getByRole("button", { name: "Collapse stat block panel" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /close/i }),
|
||||
screen.queryByRole("button", { name: CLOSE_REGEX }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -101,42 +104,42 @@ describe("Stat Block Panel Fold/Unfold and Pin", () => {
|
||||
expect(screen.queryByText("Stat Block")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders folded tab with creature name when isFolded is true", () => {
|
||||
renderPanel({ isFolded: true });
|
||||
it("renders collapsed tab with creature name when isCollapsed is true", () => {
|
||||
renderPanel({ isCollapsed: true });
|
||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Unfold stat block panel" }),
|
||||
screen.getByRole("button", { name: "Expand stat block panel" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onToggleFold when fold button is clicked", () => {
|
||||
it("calls onToggleCollapse when collapse button is clicked", () => {
|
||||
const props = renderPanel();
|
||||
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", () => {
|
||||
const props = renderPanel({ isFolded: true });
|
||||
it("calls onToggleCollapse when collapsed tab is clicked", () => {
|
||||
const props = renderPanel({ isCollapsed: true });
|
||||
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)", () => {
|
||||
renderPanel({ isFolded: true, side: "right" });
|
||||
it("applies translate-x class when collapsed (right side)", () => {
|
||||
renderPanel({ isCollapsed: true, side: "right" });
|
||||
const panel = screen
|
||||
.getByRole("button", { name: "Unfold stat block panel" })
|
||||
.getByRole("button", { name: "Expand stat block panel" })
|
||||
.closest("div");
|
||||
expect(panel?.className).toContain("translate-x-[calc(100%-40px)]");
|
||||
});
|
||||
|
||||
it("applies translate-x-0 when expanded", () => {
|
||||
renderPanel({ isFolded: false });
|
||||
renderPanel({ isCollapsed: false });
|
||||
const foldBtn = screen.getByRole("button", {
|
||||
name: "Fold stat block panel",
|
||||
name: "Collapse stat block panel",
|
||||
});
|
||||
const panel = foldBtn.closest("div.fixed") as HTMLElement;
|
||||
expect(panel?.className).toContain("translate-x-0");
|
||||
@@ -148,12 +151,12 @@ describe("Stat Block Panel Fold/Unfold and Pin", () => {
|
||||
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();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Fold stat block panel" }),
|
||||
screen.getByRole("button", { name: "Collapse stat block panel" }),
|
||||
).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 buttonLabels = buttons.map((b) => b.getAttribute("aria-label"));
|
||||
expect(buttonLabels).not.toContain("Close");
|
||||
@@ -175,8 +178,8 @@ describe("Stat Block Panel Fold/Unfold and Pin", () => {
|
||||
uploadAndCacheSource={vi.fn()}
|
||||
refreshCache={vi.fn()}
|
||||
panelRole="pinned"
|
||||
isFolded={false}
|
||||
onToggleFold={vi.fn()}
|
||||
isCollapsed={false}
|
||||
onToggleCollapse={vi.fn()}
|
||||
onPin={vi.fn()}
|
||||
onUnpin={vi.fn()}
|
||||
showPinButton={false}
|
||||
@@ -235,7 +238,7 @@ describe("Stat Block Panel Fold/Unfold and Pin", () => {
|
||||
it("positions browse panel on the right side", () => {
|
||||
renderPanel({ panelRole: "browse", side: "right" });
|
||||
const foldBtn = screen.getByRole("button", {
|
||||
name: "Fold stat block panel",
|
||||
name: "Collapse stat block panel",
|
||||
});
|
||||
const panel = foldBtn.closest("div.fixed") as HTMLElement;
|
||||
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", () => {
|
||||
it("pinned panel has no fold button", () => {
|
||||
describe("US3: Collapse independence with pinned panel", () => {
|
||||
it("pinned panel has no collapse button", () => {
|
||||
renderPanel({ panelRole: "pinned", side: "left" });
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /fold/i }),
|
||||
screen.queryByRole("button", { name: COLLAPSE_REGEX }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
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", {
|
||||
name: "Unpin creature",
|
||||
});
|
||||
@@ -30,11 +30,11 @@ describe("stripTags", () => {
|
||||
expect(stripTags("{@hit 5}")).toBe("+5");
|
||||
});
|
||||
|
||||
it("strips {@h} to Hit: ", () => {
|
||||
it("strips {@h} to 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: ");
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
import { creatureId, proficiencyBonus } from "@initiative/domain";
|
||||
import { stripTags } from "./strip-tags.js";
|
||||
|
||||
const LEADING_DIGITS_REGEX = /^(\d+)/;
|
||||
|
||||
// --- Raw 5etools types (minimal, for parsing) ---
|
||||
|
||||
interface RawMonster {
|
||||
@@ -168,7 +170,7 @@ function extractAc(ac: RawMonster["ac"]): {
|
||||
}
|
||||
if ("special" in first) {
|
||||
// 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 {
|
||||
value: match ? Number(match[1]) : 0,
|
||||
source: first.special,
|
||||
@@ -371,8 +373,8 @@ function extractCr(cr: string | { cr: string } | undefined): string {
|
||||
function makeCreatureId(source: string, name: string): CreatureId {
|
||||
const slug = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||
.replaceAll(/(^-|-$)/g, "");
|
||||
return creatureId(`${source.toLowerCase()}:${slug}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,55 +25,58 @@ export function stripTags(text: string): string {
|
||||
let result = text;
|
||||
|
||||
// {@h} → "Hit: "
|
||||
result = result.replace(/\{@h\}/g, "Hit: ");
|
||||
result = result.replaceAll("{@h}", "Hit: ");
|
||||
|
||||
// {@hom} → "Hit or Miss: "
|
||||
result = result.replace(/\{@hom\}/g, "Hit or Miss: ");
|
||||
result = result.replaceAll("{@hom}", "Hit or Miss: ");
|
||||
|
||||
// {@actTrigger} → "Trigger:"
|
||||
result = result.replace(/\{@actTrigger\}/g, "Trigger:");
|
||||
result = result.replaceAll("{@actTrigger}", "Trigger:");
|
||||
|
||||
// {@actResponse} → "Response:"
|
||||
result = result.replace(/\{@actResponse\}/g, "Response:");
|
||||
result = result.replaceAll("{@actResponse}", "Response:");
|
||||
|
||||
// {@actSaveSuccess} → "Success:"
|
||||
result = result.replace(/\{@actSaveSuccess\}/g, "Success:");
|
||||
result = result.replaceAll("{@actSaveSuccess}", "Success:");
|
||||
|
||||
// {@actSaveSuccessOrFail} → handled below as parameterized
|
||||
|
||||
// {@recharge 5} → "(Recharge 5-6)", {@recharge} → "(Recharge 6)"
|
||||
result = result.replace(/\{@recharge\s+(\d)\}/g, "(Recharge $1-6)");
|
||||
result = result.replace(/\{@recharge\}/g, "(Recharge 6)");
|
||||
result = result.replaceAll(/\{@recharge\s+(\d)\}/g, "(Recharge $1-6)");
|
||||
result = result.replaceAll("{@recharge}", "(Recharge 6)");
|
||||
|
||||
// {@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"
|
||||
result = result.replace(/\{@hit\s+(\d+)\}/g, "+$1");
|
||||
result = result.replaceAll(/\{@hit\s+(\d+)\}/g, "+$1");
|
||||
|
||||
// {@atkr type} → mapped attack roll text
|
||||
result = result.replace(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => {
|
||||
return ATKR_MAP[type.trim()] ?? `Attack Roll:`;
|
||||
result = result.replaceAll(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => {
|
||||
return ATKR_MAP[type.trim()] ?? "Attack Roll:";
|
||||
});
|
||||
|
||||
// {@actSave ability} → "Ability saving throw"
|
||||
result = result.replace(/\{@actSave\s+([^}]+)\}/g, (_, ability: string) => {
|
||||
const name = ABILITY_MAP[ability.trim().toLowerCase()];
|
||||
return name ? `${name} saving throw` : `${ability} saving throw`;
|
||||
});
|
||||
result = result.replaceAll(
|
||||
/\{@actSave\s+([^}]+)\}/g,
|
||||
(_, 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:"
|
||||
result = result.replace(
|
||||
result = result.replaceAll(
|
||||
/\{@actSaveFail\s+(\d+)\}/g,
|
||||
"Failure by $1 or More:",
|
||||
);
|
||||
result = result.replace(/\{@actSaveFail\}/g, "Failure:");
|
||||
result = result.replaceAll("{@actSaveFail}", "Failure:");
|
||||
|
||||
// {@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:"
|
||||
result = result.replace(
|
||||
result = result.replaceAll(
|
||||
/\{@actSaveFailBy\s+(\d+)\}/g,
|
||||
"Failure by $1 or More:",
|
||||
);
|
||||
@@ -81,7 +84,7 @@ export function stripTags(text: string): string {
|
||||
// Generic tags: {@tag Display|Source|...} → Display (first segment before |)
|
||||
// Covers: spell, condition, damage, dice, variantrule, action, skill,
|
||||
// creature, hazard, status, plus any unknown tags
|
||||
result = result.replace(
|
||||
result = result.replaceAll(
|
||||
/\{@(\w+)\s+([^}]+)\}/g,
|
||||
(_, tag: string, content: string) => {
|
||||
// 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-l-accent");
|
||||
});
|
||||
|
||||
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()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
onRollAllInitiative={vi.fn()}
|
||||
onOpenSourceManager={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -54,11 +52,11 @@ describe("TurnNavigation", () => {
|
||||
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();
|
||||
const badge = screen.getByText("R1");
|
||||
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", () => {
|
||||
@@ -72,8 +70,6 @@ describe("TurnNavigation", () => {
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
onRollAllInitiative={vi.fn()}
|
||||
onOpenSourceManager={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("R2")).toBeInTheDocument();
|
||||
@@ -88,8 +84,6 @@ describe("TurnNavigation", () => {
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
onRollAllInitiative={vi.fn()}
|
||||
onOpenSourceManager={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("R3")).toBeInTheDocument();
|
||||
@@ -110,8 +104,6 @@ describe("TurnNavigation", () => {
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
onRollAllInitiative={vi.fn()}
|
||||
onOpenSourceManager={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||
@@ -129,8 +121,6 @@ describe("TurnNavigation", () => {
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
onRollAllInitiative={vi.fn()}
|
||||
onOpenSourceManager={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Conjurer")).toBeInTheDocument();
|
||||
@@ -173,16 +163,6 @@ describe("TurnNavigation", () => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Next turn" }),
|
||||
).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", () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ export function AcShield({ value, onClick, className }: AcShieldProps) {
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
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,
|
||||
)}
|
||||
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" />
|
||||
</svg>
|
||||
<span className="relative text-xs font-medium leading-none">
|
||||
{value !== undefined ? value : "\u2014"}
|
||||
<span className="relative font-medium text-xs leading-none">
|
||||
{value == null ? "\u2014" : String(value)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import { Check, Eye, Import, Minus, Plus } from "lucide-react";
|
||||
import { type FormEvent, useEffect, useRef, useState } from "react";
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
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 { 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 { Input } from "./ui/input.js";
|
||||
import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
|
||||
|
||||
interface QueuedCreature {
|
||||
result: SearchResult;
|
||||
@@ -20,12 +34,218 @@ interface ActionBarProps {
|
||||
onViewStatBlock?: (result: SearchResult) => void;
|
||||
onBulkImport?: () => void;
|
||||
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 {
|
||||
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="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
|
||||
<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={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${(() => {
|
||||
if (isQueued) return "bg-accent/30 text-foreground";
|
||||
if (i === suggestionIndex)
|
||||
return "bg-accent/20 text-foreground";
|
||||
return "text-foreground 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({
|
||||
onAddCombatant,
|
||||
onAddFromBestiary,
|
||||
@@ -34,22 +254,27 @@ export function ActionBar({
|
||||
onViewStatBlock,
|
||||
onBulkImport,
|
||||
bulkImportDisabled,
|
||||
}: ActionBarProps) {
|
||||
inputRef,
|
||||
playerCharacters,
|
||||
onAddFromPlayerCharacter,
|
||||
onManagePlayers,
|
||||
onRollAllInitiative,
|
||||
showRollAllInitiative,
|
||||
rollAllInitiativeDisabled,
|
||||
onOpenSourceManager,
|
||||
autoFocus,
|
||||
}: Readonly<ActionBarProps>) {
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
||||
const deferredSuggestions = useDeferredValue(suggestions);
|
||||
const deferredPcMatches = useDeferredValue(pcMatches);
|
||||
const [suggestionIndex, setSuggestionIndex] = useState(-1);
|
||||
const [queued, setQueued] = useState<QueuedCreature | null>(null);
|
||||
const [customInit, setCustomInit] = useState("");
|
||||
const [customAc, setCustomAc] = useState("");
|
||||
const [customMaxHp, setCustomMaxHp] = useState("");
|
||||
|
||||
// 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 [browseMode, setBrowseMode] = useState(false);
|
||||
|
||||
const clearCustomFields = () => {
|
||||
setCustomInit("");
|
||||
@@ -57,15 +282,27 @@ export function ActionBar({
|
||||
setCustomMaxHp("");
|
||||
};
|
||||
|
||||
const clearInput = () => {
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
setQueued(null);
|
||||
setSuggestionIndex(-1);
|
||||
};
|
||||
|
||||
const dismissSuggestions = () => {
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
setQueued(null);
|
||||
setSuggestionIndex(-1);
|
||||
};
|
||||
|
||||
const confirmQueued = () => {
|
||||
if (!queued) return;
|
||||
for (let i = 0; i < queued.count; i++) {
|
||||
onAddFromBestiary(queued.result);
|
||||
}
|
||||
setQueued(null);
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
setSuggestionIndex(-1);
|
||||
clearInput();
|
||||
};
|
||||
|
||||
const parseNum = (v: string): number | undefined => {
|
||||
@@ -74,8 +311,9 @@ export function ActionBar({
|
||||
return Number.isNaN(n) ? undefined : n;
|
||||
};
|
||||
|
||||
const handleAdd = (e: FormEvent) => {
|
||||
const handleAdd = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (browseMode) return;
|
||||
if (queued) {
|
||||
confirmQueued();
|
||||
return;
|
||||
@@ -91,20 +329,32 @@ export function ActionBar({
|
||||
onAddCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
clearCustomFields();
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setNameInput(value);
|
||||
setSuggestionIndex(-1);
|
||||
const handleBrowseSearch = (value: string) => {
|
||||
setSuggestions(value.length >= 2 ? bestiarySearch(value) : []);
|
||||
};
|
||||
|
||||
const handleAddSearch = (value: string) => {
|
||||
let newSuggestions: SearchResult[] = [];
|
||||
let newPcMatches: PlayerCharacter[] = [];
|
||||
if (value.length >= 2) {
|
||||
newSuggestions = bestiarySearch(value);
|
||||
setSuggestions(newSuggestions);
|
||||
if (playerCharacters && playerCharacters.length > 0) {
|
||||
const lower = value.toLowerCase();
|
||||
newPcMatches = playerCharacters.filter((pc) =>
|
||||
pc.name.toLowerCase().includes(lower),
|
||||
);
|
||||
}
|
||||
setPcMatches(newPcMatches);
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
}
|
||||
if (newSuggestions.length > 0) {
|
||||
if (newSuggestions.length > 0 || newPcMatches.length > 0) {
|
||||
clearCustomFields();
|
||||
}
|
||||
if (queued) {
|
||||
@@ -116,6 +366,16 @@ export function ActionBar({
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setNameInput(value);
|
||||
setSuggestionIndex(-1);
|
||||
if (browseMode) {
|
||||
handleBrowseSearch(value);
|
||||
} else {
|
||||
handleAddSearch(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickSuggestion = (result: SearchResult) => {
|
||||
const key = creatureKey(result);
|
||||
if (queued && creatureKey(queued.result) === key) {
|
||||
@@ -133,8 +393,11 @@ export function ActionBar({
|
||||
}
|
||||
};
|
||||
|
||||
const hasSuggestions =
|
||||
deferredSuggestions.length > 0 || deferredPcMatches.length > 0;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (suggestions.length === 0) return;
|
||||
if (!hasSuggestions) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
@@ -146,73 +409,50 @@ export function ActionBar({
|
||||
e.preventDefault();
|
||||
handleEnter();
|
||||
} else if (e.key === "Escape") {
|
||||
setQueued(null);
|
||||
setSuggestionIndex(-1);
|
||||
setSuggestions([]);
|
||||
dismissSuggestions();
|
||||
}
|
||||
};
|
||||
|
||||
// Stat block viewer dropdown handlers
|
||||
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) => {
|
||||
const handleBrowseKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
closeViewer();
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
return;
|
||||
}
|
||||
if (viewerResults.length === 0) return;
|
||||
|
||||
if (suggestions.length === 0) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
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") {
|
||||
e.preventDefault();
|
||||
setViewerIndex((i) => (i > 0 ? i - 1 : viewerResults.length - 1));
|
||||
} else if (e.key === "Enter" && viewerIndex >= 0) {
|
||||
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
||||
} else if (e.key === "Enter" && suggestionIndex >= 0) {
|
||||
e.preventDefault();
|
||||
handleViewerSelect(viewerResults[viewerIndex]);
|
||||
onViewStatBlock?.(suggestions[suggestionIndex]);
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
}
|
||||
};
|
||||
|
||||
// Close viewer on outside click
|
||||
useEffect(() => {
|
||||
if (!viewerOpen) return;
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (viewerRef.current && !viewerRef.current.contains(e.target as Node)) {
|
||||
closeViewer();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [viewerOpen]);
|
||||
const handleBrowseSelect = (result: SearchResult) => {
|
||||
onViewStatBlock?.(result);
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
};
|
||||
|
||||
const toggleBrowseMode = () => {
|
||||
setBrowseMode((m) => !m);
|
||||
clearInput();
|
||||
clearCustomFields();
|
||||
};
|
||||
|
||||
const overflowItems = buildOverflowItems({
|
||||
onManagePlayers,
|
||||
onOpenSourceManager,
|
||||
bestiaryLoaded,
|
||||
onBulkImport,
|
||||
bulkImportDisabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
|
||||
@@ -220,101 +460,86 @@ export function ActionBar({
|
||||
onSubmit={handleAdd}
|
||||
className="relative flex flex-1 items-center gap-2"
|
||||
>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
value={nameInput}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="+ Add combatants"
|
||||
className="max-w-xs"
|
||||
/>
|
||||
{suggestions.length > 0 && (
|
||||
<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">
|
||||
{suggestions.map((result, i) => {
|
||||
const key = creatureKey(result);
|
||||
const isQueued =
|
||||
queued !== null && creatureKey(queued.result) === key;
|
||||
return (
|
||||
<li key={key}>
|
||||
<div className="flex-1">
|
||||
<div className="relative max-w-xs">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={nameInput}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
onKeyDown={browseMode ? handleBrowseKeyDown : handleKeyDown}
|
||||
placeholder={
|
||||
browseMode ? "Search stat blocks..." : "+ Add combatants"
|
||||
}
|
||||
className="pr-8"
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
{bestiaryLoaded && !!onViewStatBlock && (
|
||||
<button
|
||||
type="button"
|
||||
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="absolute bottom-full z-50 mb-1 w-full rounded-md border border-border bg-card shadow-lg">
|
||||
<ul className="max-h-48 overflow-y-auto py-1">
|
||||
{deferredSuggestions.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 ${
|
||||
isQueued
|
||||
? "bg-accent/30 text-foreground"
|
||||
: i === suggestionIndex
|
||||
? "bg-accent/20 text-foreground"
|
||||
: "text-foreground hover:bg-hover-neutral-bg"
|
||||
i === suggestionIndex
|
||||
? "bg-accent/20 text-foreground"
|
||||
: "text-foreground hover:bg-hover-neutral-bg"
|
||||
}`}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => handleClickSuggestion(result)}
|
||||
onClick={() => handleBrowseSelect(result)}
|
||||
onMouseEnter={() => setSuggestionIndex(i)}
|
||||
>
|
||||
<span>{result.name}</span>
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{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) {
|
||||
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 className="text-muted-foreground text-xs">
|
||||
{result.sourceDisplayName}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</ul>
|
||||
</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>
|
||||
{nameInput.length >= 2 && suggestions.length === 0 && (
|
||||
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
@@ -342,75 +567,24 @@ export function ActionBar({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" size="sm">
|
||||
Add
|
||||
</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>
|
||||
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||
<Button type="submit">Add</Button>
|
||||
)}
|
||||
{bestiaryLoaded && onBulkImport && (
|
||||
{showRollAllInitiative && !!onRollAllInitiative && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={onBulkImport}
|
||||
disabled={bulkImportDisabled}
|
||||
className="text-muted-foreground hover:text-hover-action"
|
||||
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>
|
||||
)}
|
||||
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { getAllSourceCodes } from "../adapters/bestiary-index-adapter.js";
|
||||
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
@@ -18,19 +18,18 @@ export function BulkImportPrompt({
|
||||
importState,
|
||||
onStartImport,
|
||||
onDone,
|
||||
}: BulkImportPromptProps) {
|
||||
}: Readonly<BulkImportPromptProps>) {
|
||||
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
|
||||
const baseUrlId = useId();
|
||||
const totalSources = getAllSourceCodes().length;
|
||||
|
||||
if (importState.status === "complete") {
|
||||
return (
|
||||
<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
|
||||
</div>
|
||||
<Button size="sm" onClick={onDone}>
|
||||
Done
|
||||
</Button>
|
||||
<Button onClick={onDone}>Done</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -42,9 +41,7 @@ export function BulkImportPrompt({
|
||||
Loaded {importState.completed}/{importState.total} sources (
|
||||
{importState.failed} failed)
|
||||
</div>
|
||||
<Button size="sm" onClick={onDone}>
|
||||
Done
|
||||
</Button>
|
||||
<Button onClick={onDone}>Done</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,7 +55,7 @@ export function BulkImportPrompt({
|
||||
|
||||
return (
|
||||
<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" />
|
||||
Loading sources... {processed}/{importState.total}
|
||||
</div>
|
||||
@@ -78,24 +75,20 @@ export function BulkImportPrompt({
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
Bulk Import Sources
|
||||
<h3 className="font-semibold text-foreground text-sm">
|
||||
Import All Sources
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Load stat block data for all {totalSources} sources at once. This will
|
||||
download approximately 12.5 MB of data.
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
Load stat block data for all {totalSources} sources at once.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor="bulk-base-url"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
<label htmlFor={baseUrlId} className="text-muted-foreground text-xs">
|
||||
Base URL
|
||||
</label>
|
||||
<Input
|
||||
id="bulk-base-url"
|
||||
id={baseUrlId}
|
||||
type="url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
@@ -103,11 +96,7 @@ export function BulkImportPrompt({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onStartImport(baseUrl)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Button onClick={() => onStartImport(baseUrl)} disabled={isDisabled}>
|
||||
Load All
|
||||
</Button>
|
||||
</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,6 +2,7 @@ import {
|
||||
type CombatantId,
|
||||
type ConditionId,
|
||||
deriveHpStatus,
|
||||
type PlayerIcon,
|
||||
} from "@initiative/domain";
|
||||
import { Brain, X } from "lucide-react";
|
||||
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
|
||||
@@ -11,6 +12,7 @@ import { ConditionPicker } from "./condition-picker";
|
||||
import { ConditionTags } from "./condition-tags";
|
||||
import { D20Icon } from "./d20-icon";
|
||||
import { HpAdjustPopover } from "./hp-adjust-popover";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||
import { ConfirmButton } from "./ui/confirm-button";
|
||||
import { Input } from "./ui/input";
|
||||
|
||||
@@ -23,6 +25,8 @@ interface Combatant {
|
||||
readonly ac?: number;
|
||||
readonly conditions?: readonly ConditionId[];
|
||||
readonly isConcentrating?: boolean;
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
}
|
||||
|
||||
interface CombatantRowProps {
|
||||
@@ -45,12 +49,14 @@ function EditableName({
|
||||
combatantId,
|
||||
onRename,
|
||||
onShowStatBlock,
|
||||
}: {
|
||||
color,
|
||||
}: Readonly<{
|
||||
name: string;
|
||||
combatantId: CombatantId;
|
||||
onRename: (id: CombatantId, newName: string) => void;
|
||||
onShowStatBlock?: () => void;
|
||||
}) {
|
||||
color?: string;
|
||||
}>) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(name);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -130,29 +136,28 @@ function EditableName({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={cancelLongPress}
|
||||
onTouchCancel={cancelLongPress}
|
||||
onTouchMove={cancelLongPress}
|
||||
className="truncate text-left text-sm text-foreground cursor-text hover:text-hover-neutral transition-colors"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
</>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={cancelLongPress}
|
||||
onTouchCancel={cancelLongPress}
|
||||
onTouchMove={cancelLongPress}
|
||||
className="cursor-text truncate text-left text-foreground text-sm transition-colors hover:text-hover-neutral"
|
||||
style={color ? { color } : undefined}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MaxHpDisplay({
|
||||
maxHp,
|
||||
onCommit,
|
||||
}: {
|
||||
}: Readonly<{
|
||||
maxHp: number | undefined;
|
||||
onCommit: (value: number | undefined) => void;
|
||||
}) {
|
||||
}>) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(maxHp?.toString() ?? "");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -198,7 +203,7 @@ function MaxHpDisplay({
|
||||
<button
|
||||
type="button"
|
||||
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"}
|
||||
</button>
|
||||
@@ -210,12 +215,12 @@ function ClickableHp({
|
||||
maxHp,
|
||||
onAdjust,
|
||||
dimmed,
|
||||
}: {
|
||||
}: Readonly<{
|
||||
currentHp: number | undefined;
|
||||
maxHp: number | undefined;
|
||||
onAdjust: (delta: number) => void;
|
||||
dimmed?: boolean;
|
||||
}) {
|
||||
}>) {
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const status = deriveHpStatus(currentHp, maxHp);
|
||||
|
||||
@@ -223,9 +228,11 @@ function ClickableHp({
|
||||
return (
|
||||
<span
|
||||
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",
|
||||
)}
|
||||
role="status"
|
||||
aria-label="No HP set"
|
||||
>
|
||||
--
|
||||
</span>
|
||||
@@ -238,7 +245,7 @@ function ClickableHp({
|
||||
type="button"
|
||||
onClick={() => setPopoverOpen(true)}
|
||||
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 === "unconscious" && "text-red-400",
|
||||
status === "healthy" && "text-foreground",
|
||||
@@ -247,7 +254,7 @@ function ClickableHp({
|
||||
>
|
||||
{currentHp}
|
||||
</button>
|
||||
{popoverOpen && (
|
||||
{!!popoverOpen && (
|
||||
<HpAdjustPopover
|
||||
onAdjust={onAdjust}
|
||||
onClose={() => setPopoverOpen(false)}
|
||||
@@ -260,10 +267,10 @@ function ClickableHp({
|
||||
function AcDisplay({
|
||||
ac,
|
||||
onCommit,
|
||||
}: {
|
||||
}: Readonly<{
|
||||
ac: number | undefined;
|
||||
onCommit: (value: number | undefined) => void;
|
||||
}) {
|
||||
}>) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(ac?.toString() ?? "");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -314,13 +321,13 @@ function InitiativeDisplay({
|
||||
dimmed,
|
||||
onSetInitiative,
|
||||
onRollInitiative,
|
||||
}: {
|
||||
}: Readonly<{
|
||||
initiative: number | undefined;
|
||||
combatantId: CombatantId;
|
||||
dimmed: boolean;
|
||||
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
|
||||
onRollInitiative?: (id: CombatantId) => void;
|
||||
}) {
|
||||
}>) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(initiative?.toString() ?? "");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -390,10 +397,10 @@ function InitiativeDisplay({
|
||||
type="button"
|
||||
onClick={startEditing}
|
||||
className={cn(
|
||||
"h-7 w-full text-center text-sm leading-7 tabular-nums transition-colors",
|
||||
initiative !== undefined
|
||||
? "font-medium text-foreground hover:text-hover-neutral"
|
||||
: "text-muted-foreground hover:text-hover-neutral",
|
||||
"h-7 w-full text-center text-sm tabular-nums leading-7 transition-colors",
|
||||
initiative === undefined
|
||||
? "text-muted-foreground hover:text-hover-neutral"
|
||||
: "font-medium text-foreground hover:text-hover-neutral",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
>
|
||||
@@ -478,8 +485,13 @@ export function CombatantRow({
|
||||
}
|
||||
}, [combatant.isConcentrating]);
|
||||
|
||||
const pcColor = combatant.color
|
||||
? PLAYER_COLOR_HEX[combatant.color as keyof typeof PLAYER_COLOR_HEX]
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
/* biome-ignore lint/a11y/noStaticElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
|
||||
/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
|
||||
<div
|
||||
ref={ref}
|
||||
role={onShowStatBlock ? "button" : undefined}
|
||||
@@ -506,7 +518,7 @@ export function CombatantRow({
|
||||
title="Concentrating"
|
||||
aria-label="Toggle concentration"
|
||||
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),
|
||||
)}
|
||||
>
|
||||
@@ -515,6 +527,7 @@ export function CombatantRow({
|
||||
|
||||
{/* Initiative */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
||||
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper for interactive children */}
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
@@ -531,22 +544,39 @@ export function CombatantRow({
|
||||
{/* Name + Conditions */}
|
||||
<div
|
||||
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",
|
||||
)}
|
||||
>
|
||||
{!!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
|
||||
name={name}
|
||||
combatantId={id}
|
||||
onRename={onRename}
|
||||
onShowStatBlock={onShowStatBlock}
|
||||
color={pcColor}
|
||||
/>
|
||||
<ConditionTags
|
||||
conditions={combatant.conditions}
|
||||
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
|
||||
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
||||
/>
|
||||
{pickerOpen && (
|
||||
{!!pickerOpen && (
|
||||
<ConditionPicker
|
||||
activeConditions={combatant.conditions}
|
||||
onToggle={(conditionId) => onToggleCondition(id, conditionId)}
|
||||
@@ -557,6 +587,7 @@ export function CombatantRow({
|
||||
|
||||
{/* AC */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
||||
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper for interactive children */}
|
||||
<div
|
||||
className={cn(dimmed && "opacity-50")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -567,6 +598,7 @@ export function CombatantRow({
|
||||
|
||||
{/* HP */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
||||
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper for interactive children */}
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -581,7 +613,7 @@ export function CombatantRow({
|
||||
{maxHp !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm tabular-nums text-muted-foreground",
|
||||
"text-muted-foreground text-sm tabular-nums",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
>
|
||||
@@ -598,7 +630,7 @@ export function CombatantRow({
|
||||
icon={<X size={16} />}
|
||||
label="Remove combatant"
|
||||
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>
|
||||
|
||||
@@ -61,7 +61,7 @@ export function ConditionPicker({
|
||||
activeConditions,
|
||||
onToggle,
|
||||
onClose,
|
||||
}: ConditionPickerProps) {
|
||||
}: Readonly<ConditionPickerProps>) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [flipped, setFlipped] = useState(false);
|
||||
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined);
|
||||
|
||||
@@ -60,7 +60,7 @@ export function ConditionTags({
|
||||
conditions,
|
||||
onRemove,
|
||||
onOpenPicker,
|
||||
}: ConditionTagsProps) {
|
||||
}: Readonly<ConditionTagsProps>) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-0.5">
|
||||
{conditions?.map((condId) => {
|
||||
@@ -75,7 +75,7 @@ export function ConditionTags({
|
||||
type="button"
|
||||
title={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={`inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg ${colorClass}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(condId);
|
||||
@@ -89,7 +89,7 @@ export function ConditionTags({
|
||||
type="button"
|
||||
title="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) => {
|
||||
e.stopPropagation();
|
||||
onOpenPicker();
|
||||
|
||||
191
apps/web/src/components/create-player-modal.tsx
Normal file
191
apps/web/src/components/create-player-modal.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, 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 [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(() => {
|
||||
if (!open) return;
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
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 (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
|
||||
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: backdrop click to close
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onMouseDown={onClose}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
|
||||
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: prevent close when clicking modal content */}
|
||||
<div
|
||||
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,9 +6,10 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
|
||||
const DIGITS_ONLY_REGEX = /^\d+$/;
|
||||
|
||||
interface HpAdjustPopoverProps {
|
||||
readonly onAdjust: (delta: number) => void;
|
||||
readonly onClose: () => void;
|
||||
@@ -103,36 +104,32 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
||||
className="h-7 w-[7ch] text-center text-sm tabular-nums"
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "" || /^\d+$/.test(v)) {
|
||||
if (v === "" || DIGITS_ONLY_REGEX.test(v)) {
|
||||
setInputValue(v);
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<Button
|
||||
<button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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)}
|
||||
title="Apply damage"
|
||||
aria-label="Apply damage"
|
||||
>
|
||||
<Sword size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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)}
|
||||
title="Apply healing"
|
||||
aria-label="Apply healing"
|
||||
>
|
||||
<Heart size={14} />
|
||||
</Button>
|
||||
</button>
|
||||
</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",
|
||||
};
|
||||
123
apps/web/src/components/player-management.tsx
Normal file
123
apps/web/src/components/player-management.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
||||
import { useEffect } 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>) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
|
||||
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: backdrop click to close
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onMouseDown={onClose}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
|
||||
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: prevent close when clicking modal content */}
|
||||
<div
|
||||
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
@@ -18,11 +18,12 @@ export function SourceFetchPrompt({
|
||||
fetchAndCacheSource,
|
||||
onSourceLoaded,
|
||||
onUploadSource,
|
||||
}: SourceFetchPromptProps) {
|
||||
}: Readonly<SourceFetchPromptProps>) {
|
||||
const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode));
|
||||
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
|
||||
const [error, setError] = useState<string>("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const sourceUrlId = useId();
|
||||
|
||||
const handleFetch = async () => {
|
||||
setStatus("fetching");
|
||||
@@ -64,21 +65,21 @@ export function SourceFetchPrompt({
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
<h3 className="font-semibold text-foreground text-sm">
|
||||
Load {sourceDisplayName}
|
||||
</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
|
||||
upload a JSON file.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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
|
||||
</label>
|
||||
<Input
|
||||
id="source-url"
|
||||
id={sourceUrlId}
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
@@ -88,11 +89,7 @@ export function SourceFetchPrompt({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleFetch}
|
||||
disabled={status === "fetching" || !url}
|
||||
>
|
||||
<Button onClick={handleFetch} disabled={status === "fetching" || !url}>
|
||||
{status === "fetching" ? (
|
||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
@@ -101,10 +98,9 @@ export function SourceFetchPrompt({
|
||||
{status === "fetching" ? "Loading..." : "Load"}
|
||||
</Button>
|
||||
|
||||
<span className="text-xs text-muted-foreground">or</span>
|
||||
<span className="text-muted-foreground text-xs">or</span>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={status === "fetching"}
|
||||
@@ -122,7 +118,7 @@ export function SourceFetchPrompt({
|
||||
</div>
|
||||
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Database, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useOptimistic, useState } from "react";
|
||||
import type { CachedSourceInfo } from "../adapters/bestiary-cache.js";
|
||||
import * as bestiaryCache from "../adapters/bestiary-cache.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
@@ -8,8 +8,20 @@ interface SourceManagerProps {
|
||||
onCacheCleared: () => void;
|
||||
}
|
||||
|
||||
export function SourceManager({ onCacheCleared }: SourceManagerProps) {
|
||||
export function SourceManager({
|
||||
onCacheCleared,
|
||||
}: Readonly<SourceManagerProps>) {
|
||||
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
|
||||
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 cached = await bestiaryCache.getCachedSources();
|
||||
@@ -17,26 +29,28 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSources();
|
||||
void loadSources();
|
||||
}, [loadSources]);
|
||||
|
||||
const handleClearSource = async (sourceCode: string) => {
|
||||
applyOptimistic({ type: "remove", sourceCode });
|
||||
await bestiaryCache.clearSource(sourceCode);
|
||||
await loadSources();
|
||||
onCacheCleared();
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
applyOptimistic({ type: "clear" });
|
||||
await bestiaryCache.clearAll();
|
||||
await loadSources();
|
||||
onCacheCleared();
|
||||
};
|
||||
|
||||
if (sources.length === 0) {
|
||||
if (optimisticSources.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -44,32 +58,37 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<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
|
||||
</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" />
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{sources.map((source) => (
|
||||
{optimisticSources.map((source) => (
|
||||
<li
|
||||
key={source.sourceCode}
|
||||
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-foreground">
|
||||
<span className="text-foreground text-sm">
|
||||
{source.displayName}
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
<span className="ml-2 text-muted-foreground text-xs">
|
||||
{source.creatureCount} creatures
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
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" />
|
||||
</button>
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
||||
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
||||
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
||||
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
||||
import { SourceManager } from "./source-manager.js";
|
||||
import { StatBlock } from "./stat-block.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
|
||||
interface StatBlockPanelProps {
|
||||
creatureId: CreatureId | null;
|
||||
@@ -20,8 +22,8 @@ interface StatBlockPanelProps {
|
||||
) => Promise<void>;
|
||||
refreshCache: () => Promise<void>;
|
||||
panelRole: "browse" | "pinned";
|
||||
isFolded: boolean;
|
||||
onToggleFold: () => void;
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
onPin: () => void;
|
||||
onUnpin: () => void;
|
||||
showPinButton: boolean;
|
||||
@@ -31,6 +33,7 @@ interface StatBlockPanelProps {
|
||||
bulkImportState?: BulkImportState;
|
||||
onStartBulkImport?: (baseUrl: string) => void;
|
||||
onBulkImportDone?: () => void;
|
||||
sourceManagerMode?: boolean;
|
||||
}
|
||||
|
||||
function extractSourceCode(cId: CreatureId): string {
|
||||
@@ -39,25 +42,25 @@ function extractSourceCode(cId: CreatureId): string {
|
||||
return cId.slice(0, colonIndex).toUpperCase();
|
||||
}
|
||||
|
||||
function FoldedTab({
|
||||
function CollapsedTab({
|
||||
creatureName,
|
||||
side,
|
||||
onToggleFold,
|
||||
}: {
|
||||
onToggleCollapse,
|
||||
}: Readonly<{
|
||||
creatureName: string;
|
||||
side: "left" | "right";
|
||||
onToggleFold: () => void;
|
||||
}) {
|
||||
onToggleCollapse: () => void;
|
||||
}>) {
|
||||
return (
|
||||
<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 ${
|
||||
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}
|
||||
</span>
|
||||
</button>
|
||||
@@ -67,50 +70,53 @@ function FoldedTab({
|
||||
function PanelHeader({
|
||||
panelRole,
|
||||
showPinButton,
|
||||
onToggleFold,
|
||||
onToggleCollapse,
|
||||
onPin,
|
||||
onUnpin,
|
||||
}: {
|
||||
}: Readonly<{
|
||||
panelRole: "browse" | "pinned";
|
||||
showPinButton: boolean;
|
||||
onToggleFold: () => void;
|
||||
onToggleCollapse: () => void;
|
||||
onPin: () => void;
|
||||
onUnpin: () => void;
|
||||
}) {
|
||||
}>) {
|
||||
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">
|
||||
{panelRole === "browse" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleFold}
|
||||
className="text-muted-foreground hover:text-hover-neutral"
|
||||
aria-label="Fold stat block panel"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onToggleCollapse}
|
||||
className="text-muted-foreground"
|
||||
aria-label="Collapse stat block panel"
|
||||
>
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{panelRole === "browse" && showPinButton && (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onPin}
|
||||
className="text-muted-foreground hover:text-hover-neutral"
|
||||
className="text-muted-foreground"
|
||||
aria-label="Pin creature"
|
||||
>
|
||||
<Pin className="h-4 w-4" />
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{panelRole === "pinned" && (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onUnpin}
|
||||
className="text-muted-foreground hover:text-hover-neutral"
|
||||
className="text-muted-foreground"
|
||||
aria-label="Unpin creature"
|
||||
>
|
||||
<PinOff className="h-4 w-4" />
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,48 +124,48 @@ function PanelHeader({
|
||||
}
|
||||
|
||||
function DesktopPanel({
|
||||
isFolded,
|
||||
isCollapsed,
|
||||
side,
|
||||
creatureName,
|
||||
panelRole,
|
||||
showPinButton,
|
||||
onToggleFold,
|
||||
onToggleCollapse,
|
||||
onPin,
|
||||
onUnpin,
|
||||
children,
|
||||
}: {
|
||||
isFolded: boolean;
|
||||
}: Readonly<{
|
||||
isCollapsed: boolean;
|
||||
side: "left" | "right";
|
||||
creatureName: string;
|
||||
panelRole: "browse" | "pinned";
|
||||
showPinButton: boolean;
|
||||
onToggleFold: () => void;
|
||||
onToggleCollapse: () => void;
|
||||
onPin: () => void;
|
||||
onUnpin: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
}>) {
|
||||
const sideClasses = side === "left" ? "left-0 border-r" : "right-0 border-l";
|
||||
const foldedTranslate =
|
||||
const collapsedTranslate =
|
||||
side === "right"
|
||||
? "translate-x-[calc(100%-40px)]"
|
||||
: "translate-x-[calc(-100%+40px)]";
|
||||
|
||||
return (
|
||||
<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={`fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel ${sideClasses} ${isCollapsed ? collapsedTranslate : "translate-x-0"}`}
|
||||
>
|
||||
{isFolded ? (
|
||||
<FoldedTab
|
||||
{isCollapsed ? (
|
||||
<CollapsedTab
|
||||
creatureName={creatureName}
|
||||
side={side}
|
||||
onToggleFold={onToggleFold}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<PanelHeader
|
||||
panelRole={panelRole}
|
||||
showPinButton={showPinButton}
|
||||
onToggleFold={onToggleFold}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onPin={onPin}
|
||||
onUnpin={onUnpin}
|
||||
/>
|
||||
@@ -173,36 +179,37 @@ function DesktopPanel({
|
||||
function MobileDrawer({
|
||||
onDismiss,
|
||||
children,
|
||||
}: {
|
||||
}: Readonly<{
|
||||
onDismiss: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
}>) {
|
||||
const { offsetX, isSwiping, handlers } = useSwipeToDismiss(onDismiss);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<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}
|
||||
aria-label="Close stat block"
|
||||
/>
|
||||
<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={`absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-border border-l bg-card shadow-xl ${isSwiping ? "" : "animate-slide-in-right"}`}
|
||||
style={
|
||||
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
|
||||
}
|
||||
{...handlers}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
||||
<button
|
||||
type="button"
|
||||
<div className="flex items-center justify-between border-border border-b px-4 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onDismiss}
|
||||
className="text-muted-foreground hover:text-hover-neutral"
|
||||
aria-label="Fold stat block panel"
|
||||
className="text-muted-foreground"
|
||||
aria-label="Collapse stat block panel"
|
||||
>
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
|
||||
{children}
|
||||
@@ -220,8 +227,8 @@ export function StatBlockPanel({
|
||||
uploadAndCacheSource,
|
||||
refreshCache,
|
||||
panelRole,
|
||||
isFolded,
|
||||
onToggleFold,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
onPin,
|
||||
onUnpin,
|
||||
showPinButton,
|
||||
@@ -231,15 +238,16 @@ export function StatBlockPanel({
|
||||
bulkImportState,
|
||||
onStartBulkImport,
|
||||
onBulkImportDone,
|
||||
}: StatBlockPanelProps) {
|
||||
sourceManagerMode,
|
||||
}: Readonly<StatBlockPanelProps>) {
|
||||
const [isDesktop, setIsDesktop] = useState(
|
||||
() => window.matchMedia("(min-width: 1024px)").matches,
|
||||
() => globalThis.matchMedia("(min-width: 1024px)").matches,
|
||||
);
|
||||
const [needsFetch, setNeedsFetch] = useState(false);
|
||||
const [checkingCache, setCheckingCache] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(min-width: 1024px)");
|
||||
const mq = globalThis.matchMedia("(min-width: 1024px)");
|
||||
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
@@ -258,13 +266,13 @@ export function StatBlockPanel({
|
||||
}
|
||||
|
||||
setCheckingCache(true);
|
||||
isSourceCached(sourceCode).then((cached) => {
|
||||
void isSourceCached(sourceCode).then((cached) => {
|
||||
setNeedsFetch(!cached);
|
||||
setCheckingCache(false);
|
||||
});
|
||||
}, [creatureId, creature, isSourceCached]);
|
||||
|
||||
if (!creatureId && !bulkImportMode) return null;
|
||||
if (!creatureId && !bulkImportMode && !sourceManagerMode) return null;
|
||||
|
||||
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
|
||||
|
||||
@@ -274,6 +282,10 @@ export function StatBlockPanel({
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (sourceManagerMode) {
|
||||
return <SourceManager onCacheCleared={refreshCache} />;
|
||||
}
|
||||
|
||||
if (
|
||||
bulkImportMode &&
|
||||
bulkImportState &&
|
||||
@@ -291,7 +303,7 @@ export function StatBlockPanel({
|
||||
|
||||
if (checkingCache) {
|
||||
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 +324,26 @@ export function StatBlockPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
<div className="p-4 text-muted-foreground text-sm">
|
||||
No stat block available
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const creatureName =
|
||||
creature?.name ?? (bulkImportMode ? "Bulk Import" : "Creature");
|
||||
let fallbackName = "Creature";
|
||||
if (sourceManagerMode) fallbackName = "Sources";
|
||||
else if (bulkImportMode) fallbackName = "Import All Sources";
|
||||
const creatureName = creature?.name ?? fallbackName;
|
||||
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<DesktopPanel
|
||||
isFolded={isFolded}
|
||||
isCollapsed={isCollapsed}
|
||||
side={side}
|
||||
creatureName={creatureName}
|
||||
panelRole={panelRole}
|
||||
showPinButton={showPinButton}
|
||||
onToggleFold={onToggleFold}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onPin={onPin}
|
||||
onUnpin={onUnpin}
|
||||
>
|
||||
|
||||
@@ -16,10 +16,10 @@ function abilityMod(score: number): string {
|
||||
function PropertyLine({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
}: Readonly<{
|
||||
label: string;
|
||||
value: string | undefined;
|
||||
}) {
|
||||
}>) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="text-sm">
|
||||
@@ -34,7 +34,7 @@ function SectionDivider() {
|
||||
);
|
||||
}
|
||||
|
||||
export function StatBlock({ creature }: StatBlockProps) {
|
||||
export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
const abilities = [
|
||||
{ label: "STR", score: creature.abilities.str },
|
||||
{ label: "DEX", score: creature.abilities.dex },
|
||||
@@ -54,11 +54,11 @@ export function StatBlock({ creature }: StatBlockProps) {
|
||||
<div className="space-y-1 text-foreground">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-amber-400">{creature.name}</h2>
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
<h2 className="font-bold text-amber-400 text-xl">{creature.name}</h2>
|
||||
<p className="text-muted-foreground text-sm italic">
|
||||
{creature.size} {creature.type}, {creature.alignment}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{creature.sourceDisplayName}
|
||||
</p>
|
||||
</div>
|
||||
@@ -69,7 +69,7 @@ export function StatBlock({ creature }: StatBlockProps) {
|
||||
<div className="space-y-0.5 text-sm">
|
||||
<div>
|
||||
<span className="font-semibold">Armor Class</span> {creature.ac}
|
||||
{creature.acSource && (
|
||||
{!!creature.acSource && (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
({creature.acSource})
|
||||
@@ -194,7 +194,7 @@ export function StatBlock({ creature }: StatBlockProps) {
|
||||
{creature.actions && creature.actions.length > 0 && (
|
||||
<>
|
||||
<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">
|
||||
{creature.actions.map((a) => (
|
||||
<div key={a.name} className="text-sm">
|
||||
@@ -209,7 +209,7 @@ export function StatBlock({ creature }: StatBlockProps) {
|
||||
{creature.bonusActions && creature.bonusActions.length > 0 && (
|
||||
<>
|
||||
<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">
|
||||
{creature.bonusActions.map((a) => (
|
||||
<div key={a.name} className="text-sm">
|
||||
@@ -224,7 +224,7 @@ export function StatBlock({ creature }: StatBlockProps) {
|
||||
{creature.reactions && creature.reactions.length > 0 && (
|
||||
<>
|
||||
<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">
|
||||
{creature.reactions.map((a) => (
|
||||
<div key={a.name} className="text-sm">
|
||||
@@ -236,13 +236,13 @@ export function StatBlock({ creature }: StatBlockProps) {
|
||||
)}
|
||||
|
||||
{/* Legendary Actions */}
|
||||
{creature.legendaryActions && (
|
||||
{!!creature.legendaryActions && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<h3 className="text-base font-bold text-amber-400">
|
||||
<h3 className="font-bold text-amber-400 text-base">
|
||||
Legendary Actions
|
||||
</h3>
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm italic">
|
||||
{creature.legendaryActions.preamble}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button } from "./ui/button.js";
|
||||
|
||||
interface ToastProps {
|
||||
message: string;
|
||||
@@ -22,9 +23,9 @@ export function Toast({
|
||||
}, [autoDismissMs, onDismiss]);
|
||||
|
||||
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">
|
||||
<span className="text-sm text-foreground">{message}</span>
|
||||
<span className="text-foreground text-sm">{message}</span>
|
||||
{progress !== undefined && (
|
||||
<div className="h-2 w-24 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
@@ -33,13 +34,14 @@ export function Toast({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onDismiss}
|
||||
className="text-muted-foreground hover:text-hover-neutral"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Encounter } from "@initiative/domain";
|
||||
import { Settings, StepBack, StepForward, Trash2 } from "lucide-react";
|
||||
import { D20Icon } from "./d20-icon";
|
||||
import { StepBack, StepForward, Trash2 } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import { ConfirmButton } from "./ui/confirm-button";
|
||||
|
||||
@@ -9,8 +8,6 @@ interface TurnNavigationProps {
|
||||
onAdvanceTurn: () => void;
|
||||
onRetreatTurn: () => void;
|
||||
onClearEncounter: () => void;
|
||||
onRollAllInitiative: () => void;
|
||||
onOpenSourceManager: () => void;
|
||||
}
|
||||
|
||||
export function TurnNavigation({
|
||||
@@ -18,76 +15,46 @@ export function TurnNavigation({
|
||||
onAdvanceTurn,
|
||||
onRetreatTurn,
|
||||
onClearEncounter,
|
||||
onRollAllInitiative,
|
||||
onOpenSourceManager,
|
||||
}: TurnNavigationProps) {
|
||||
}: Readonly<TurnNavigationProps>) {
|
||||
const hasCombatants = encounter.combatants.length > 0;
|
||||
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
||||
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
|
||||
<div className="flex flex-shrink-0 items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-foreground border-foreground hover:text-hover-action hover:border-hover-action hover:bg-transparent"
|
||||
onClick={onRetreatTurn}
|
||||
disabled={!hasCombatants || isAtStart}
|
||||
title="Previous turn"
|
||||
aria-label="Previous turn"
|
||||
>
|
||||
<StepBack className="h-5 w-5" />
|
||||
</Button>
|
||||
<span className="rounded-full bg-muted text-foreground text-sm px-2 py-0.5 font-semibold">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onRetreatTurn}
|
||||
disabled={!hasCombatants || isAtStart}
|
||||
title="Previous turn"
|
||||
aria-label="Previous turn"
|
||||
>
|
||||
<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="shrink-0 rounded-full bg-muted px-2 py-0.5 font-semibold text-foreground text-sm">
|
||||
R{encounter.roundNumber}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 text-center text-sm">
|
||||
{activeCombatant ? (
|
||||
<span className="truncate block font-medium">
|
||||
{activeCombatant.name}
|
||||
</span>
|
||||
<span className="truncate font-medium">{activeCombatant.name}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">No combatants</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-3">
|
||||
<div className="flex items-center gap-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-hover-action"
|
||||
onClick={onRollAllInitiative}
|
||||
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>
|
||||
<ConfirmButton
|
||||
icon={<Trash2 className="h-5 w-5" />}
|
||||
label="Clear encounter"
|
||||
onConfirm={onClearEncounter}
|
||||
disabled={!hasCombatants}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-foreground border-foreground hover:text-hover-action hover:border-hover-action hover:bg-transparent"
|
||||
onClick={onAdvanceTurn}
|
||||
disabled={!hasCombatants}
|
||||
title="Next turn"
|
||||
|
||||
@@ -13,9 +13,9 @@ const buttonVariants = cva(
|
||||
ghost: "hover:bg-hover-neutral-bg hover:text-hover-neutral",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 px-3 text-xs",
|
||||
default: "h-8 px-3 text-xs",
|
||||
icon: "h-8 w-8",
|
||||
"icon-sm": "h-6 w-6",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -13,6 +13,7 @@ interface ConfirmButtonProps {
|
||||
readonly onConfirm: () => void;
|
||||
readonly icon: ReactElement;
|
||||
readonly label: string;
|
||||
readonly size?: "icon" | "icon-sm";
|
||||
readonly className?: string;
|
||||
readonly disabled?: boolean;
|
||||
}
|
||||
@@ -23,6 +24,7 @@ export function ConfirmButton({
|
||||
onConfirm,
|
||||
icon,
|
||||
label,
|
||||
size = "icon",
|
||||
className,
|
||||
disabled,
|
||||
}: ConfirmButtonProps) {
|
||||
@@ -53,17 +55,17 @@ export function ConfirmButton({
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
function handleEscapeKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
revert();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
document.addEventListener("keydown", handleEscapeKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
document.removeEventListener("keydown", handleEscapeKey);
|
||||
};
|
||||
}, [isConfirming, revert]);
|
||||
|
||||
@@ -94,11 +96,12 @@ export function ConfirmButton({
|
||||
<div ref={wrapperRef} className="inline-flex">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
size={size}
|
||||
className={cn(
|
||||
className,
|
||||
isConfirming &&
|
||||
"bg-destructive text-primary-foreground rounded-md animate-confirm-pulse hover:bg-destructive hover:text-primary-foreground",
|
||||
isConfirming
|
||||
? "animate-confirm-pulse rounded-md bg-destructive text-primary-foreground hover:bg-destructive hover:text-primary-foreground"
|
||||
: "hover:text-hover-destructive",
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -107,7 +110,8 @@ export function ConfirmButton({
|
||||
aria-label={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
|
||||
title={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
|
||||
>
|
||||
{isConfirming ? <Check size={16} /> : icon}
|
||||
{isConfirming ? <Check size={16} /> : null}
|
||||
{!isConfirming && icon}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { forwardRef, type InputHTMLAttributes } from "react";
|
||||
import type { InputHTMLAttributes, RefObject } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
type InputProps = InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
export const Input = ({
|
||||
className,
|
||||
ref,
|
||||
...props
|
||||
}: InputProps & { ref?: RefObject<HTMLInputElement | null> }) => {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"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="absolute right-0 bottom-full z-50 mb-1 min-w-48 rounded-md border border-border bg-card py-1 shadow-lg">
|
||||
{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,
|
||||
CreatureId,
|
||||
} from "@initiative/domain";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
normalizeBestiary,
|
||||
setSourceDisplayNames,
|
||||
@@ -33,8 +33,9 @@ interface BestiaryHook {
|
||||
|
||||
export function useBestiary(): BestiaryHook {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const creatureMapRef = useRef<Map<CreatureId, Creature>>(new Map());
|
||||
const [, setTick] = useState(0);
|
||||
const [creatureMap, setCreatureMap] = useState(
|
||||
() => new Map<CreatureId, Creature>(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const index = loadBestiaryIndex();
|
||||
@@ -43,9 +44,8 @@ export function useBestiary(): BestiaryHook {
|
||||
setIsLoaded(true);
|
||||
}
|
||||
|
||||
bestiaryCache.loadAllCachedCreatures().then((map) => {
|
||||
creatureMapRef.current = map;
|
||||
setTick((t) => t + 1);
|
||||
void bestiaryCache.loadAllCachedCreatures().then((map) => {
|
||||
setCreatureMap(map);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -63,9 +63,12 @@ export function useBestiary(): BestiaryHook {
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const getCreature = useCallback((id: CreatureId): Creature | undefined => {
|
||||
return creatureMapRef.current.get(id);
|
||||
}, []);
|
||||
const getCreature = useCallback(
|
||||
(id: CreatureId): Creature | undefined => {
|
||||
return creatureMap.get(id);
|
||||
},
|
||||
[creatureMap],
|
||||
);
|
||||
|
||||
const isSourceCachedFn = useCallback(
|
||||
(sourceCode: string): Promise<boolean> => {
|
||||
@@ -86,10 +89,13 @@ export function useBestiary(): BestiaryHook {
|
||||
const creatures = normalizeBestiary(json);
|
||||
const displayName = getSourceDisplayName(sourceCode);
|
||||
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
||||
for (const c of creatures) {
|
||||
creatureMapRef.current.set(c.id, c);
|
||||
}
|
||||
setTick((t) => t + 1);
|
||||
setCreatureMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const c of creatures) {
|
||||
next.set(c.id, c);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -100,18 +106,20 @@ export function useBestiary(): BestiaryHook {
|
||||
const creatures = normalizeBestiary(jsonData as any);
|
||||
const displayName = getSourceDisplayName(sourceCode);
|
||||
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
||||
for (const c of creatures) {
|
||||
creatureMapRef.current.set(c.id, c);
|
||||
}
|
||||
setTick((t) => t + 1);
|
||||
setCreatureMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const c of creatures) {
|
||||
next.set(c.id, c);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const refreshCache = useCallback(async (): Promise<void> => {
|
||||
const map = await bestiaryCache.loadAllCachedCreatures();
|
||||
creatureMapRef.current = map;
|
||||
setTick((t) => t + 1);
|
||||
setCreatureMap(map);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
|
||||
@@ -48,7 +48,7 @@ export function useBulkImport(): BulkImportHook {
|
||||
countersRef.current = { completed: 0, failed: 0 };
|
||||
setState({ status: "loading", total, completed: 0, failed: 0 });
|
||||
|
||||
(async () => {
|
||||
void (async () => {
|
||||
const cacheChecks = await Promise.all(
|
||||
allCodes.map(async (code) => ({
|
||||
code,
|
||||
@@ -75,6 +75,7 @@ export function useBulkImport(): BulkImportHook {
|
||||
|
||||
for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
|
||||
const batch = uncached.slice(i, i + BATCH_SIZE);
|
||||
// biome-ignore lint/performance/noAwaitInLoops: sequential batching is intentional to avoid overwhelming the server with too many concurrent requests
|
||||
await Promise.allSettled(
|
||||
batch.map(async ({ code }) => {
|
||||
const url = getDefaultFetchUrl(code, baseUrl);
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
ConditionId,
|
||||
DomainEvent,
|
||||
Encounter,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
import {
|
||||
combatantId,
|
||||
@@ -32,6 +33,8 @@ import {
|
||||
saveEncounter,
|
||||
} from "../persistence/encounter-storage.js";
|
||||
|
||||
const COMBATANT_ID_REGEX = /^c-(\d+)$/;
|
||||
|
||||
const EMPTY_ENCOUNTER: Encounter = {
|
||||
combatants: [],
|
||||
activeIndex: 0,
|
||||
@@ -47,7 +50,7 @@ function initializeEncounter(): Encounter {
|
||||
function deriveNextId(encounter: Encounter): number {
|
||||
let max = 0;
|
||||
for (const c of encounter.combatants) {
|
||||
const match = /^c-(\d+)$/.exec(c.id);
|
||||
const match = COMBATANT_ID_REGEX.exec(c.id);
|
||||
if (match) {
|
||||
const n = Number.parseInt(match[1], 10);
|
||||
if (n > max) max = n;
|
||||
@@ -300,8 +303,8 @@ export function useEncounter() {
|
||||
// Derive creatureId from source + name
|
||||
const slug = entry.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||
.replaceAll(/(^-|-$)/g, "");
|
||||
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
|
||||
|
||||
// Set creatureId on the combatant (use store.save to keep ref in sync for batch calls)
|
||||
@@ -315,12 +318,75 @@ export function useEncounter() {
|
||||
|
||||
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 {
|
||||
encounter,
|
||||
events,
|
||||
isEmpty,
|
||||
hasCreatureCombatants,
|
||||
canRollAllInitiative,
|
||||
advanceTurn,
|
||||
retreatTurn,
|
||||
addCombatant,
|
||||
@@ -334,6 +400,7 @@ export function useEncounter() {
|
||||
toggleCondition,
|
||||
toggleConcentration,
|
||||
addFromBestiary,
|
||||
addFromPlayerCharacter,
|
||||
makeStore,
|
||||
} 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,
|
||||
};
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
--color-hover-neutral: var(--color-primary);
|
||||
--color-hover-action: var(--color-primary);
|
||||
--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-destructive-bg: transparent;
|
||||
--radius-sm: 0.25rem;
|
||||
@@ -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));
|
||||
|
||||
@utility animate-confirm-pulse {
|
||||
|
||||
@@ -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,
|
||||
type Encounter,
|
||||
isDomainError,
|
||||
playerCharacterId,
|
||||
VALID_CONDITION_IDS,
|
||||
VALID_PLAYER_COLORS,
|
||||
VALID_PLAYER_ICONS,
|
||||
} from "@initiative/domain";
|
||||
|
||||
const STORAGE_KEY = "initiative:encounter";
|
||||
@@ -70,12 +73,29 @@ function rehydrateCombatant(c: unknown) {
|
||||
typeof entry.initiative === "number" ? entry.initiative : undefined,
|
||||
};
|
||||
|
||||
const color =
|
||||
typeof entry.color === "string" && VALID_PLAYER_COLORS.has(entry.color)
|
||||
? entry.color
|
||||
: undefined;
|
||||
const icon =
|
||||
typeof entry.icon === "string" && VALID_PLAYER_ICONS.has(entry.icon)
|
||||
? entry.icon
|
||||
: undefined;
|
||||
const pcId =
|
||||
typeof entry.playerCharacterId === "string" &&
|
||||
entry.playerCharacterId.length > 0
|
||||
? playerCharacterId(entry.playerCharacterId)
|
||||
: undefined;
|
||||
|
||||
const shared = {
|
||||
...base,
|
||||
ac: validateAc(entry.ac),
|
||||
conditions: validateConditions(entry.conditions),
|
||||
isConcentrating: entry.isConcentrating === true ? true : undefined,
|
||||
creatureId: validateCreatureId(entry.creatureId),
|
||||
color,
|
||||
icon,
|
||||
playerCharacterId: pcId,
|
||||
};
|
||||
|
||||
const hp = validateHp(entry.maxHp, entry.currentHp);
|
||||
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
102
biome.json
102
biome.json
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
|
||||
"files": {
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/dist/**",
|
||||
"!.claude/**",
|
||||
"!.specify/**",
|
||||
"!specs/**",
|
||||
"!coverage/**",
|
||||
"!.pnpm-store/**"
|
||||
"!**/dist",
|
||||
"!.claude",
|
||||
"!.specify",
|
||||
"!specs",
|
||||
"!coverage",
|
||||
"!.pnpm-store"
|
||||
]
|
||||
},
|
||||
"assist": {
|
||||
@@ -21,6 +21,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"cssModules": false,
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab",
|
||||
@@ -30,13 +36,93 @@
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"a11y": {
|
||||
"noNoninteractiveElementInteractions": "error"
|
||||
},
|
||||
"complexity": {
|
||||
"noExcessiveCognitiveComplexity": {
|
||||
"level": "error",
|
||||
"options": {
|
||||
"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`)?
|
||||
12
package.json
12
package.json
@@ -1,12 +1,19 @@
|
||||
{
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.6.0",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"undici": ">=7.24.0"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.0.0",
|
||||
"@biomejs/biome": "2.4.7",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"jscpd": "^4.0.8",
|
||||
"knip": "^5.85.0",
|
||||
"lefthook": "^1.11.0",
|
||||
"oxlint": "^1.55.0",
|
||||
"oxlint-tsgolint": "^0.16.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
@@ -21,6 +28,7 @@
|
||||
"test:watch": "vitest",
|
||||
"knip": "knip",
|
||||
"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": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && 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 { advanceTurnUseCase } from "./advance-turn-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 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 { 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 { setAcUseCase } from "./set-ac-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 {
|
||||
get(): Encounter;
|
||||
@@ -9,3 +14,8 @@ export interface BestiarySourceCache {
|
||||
getCreature(creatureId: CreatureId): Creature | undefined;
|
||||
isSourceCached(sourceCode: string): boolean;
|
||||
}
|
||||
|
||||
export interface PlayerCharacterStore {
|
||||
getAll(): PlayerCharacter[];
|
||||
save(characters: PlayerCharacter[]): void;
|
||||
}
|
||||
|
||||
@@ -10,20 +10,29 @@ import {
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
|
||||
export interface RollAllResult {
|
||||
events: DomainEvent[];
|
||||
skippedNoSource: number;
|
||||
}
|
||||
|
||||
export function rollAllInitiativeUseCase(
|
||||
store: EncounterStore,
|
||||
rollDice: () => number,
|
||||
getCreature: (id: CreatureId) => Creature | undefined,
|
||||
): DomainEvent[] | DomainError {
|
||||
): RollAllResult | DomainError {
|
||||
let encounter = store.get();
|
||||
const allEvents: DomainEvent[] = [];
|
||||
let skippedNoSource = 0;
|
||||
|
||||
for (const combatant of encounter.combatants) {
|
||||
if (!combatant.creatureId) continue;
|
||||
if (combatant.initiative !== undefined) continue;
|
||||
|
||||
const creature = getCreature(combatant.creatureId);
|
||||
if (!creature) continue;
|
||||
if (!creature) {
|
||||
skippedNoSource++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const { modifier } = calculateInitiative({
|
||||
dexScore: creature.abilities.dex,
|
||||
@@ -47,5 +56,5 @@ export function rollAllInitiativeUseCase(
|
||||
}
|
||||
|
||||
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 type { Combatant, Encounter } from "../types.js";
|
||||
import { combatantId, isDomainError } from "../types.js";
|
||||
import { expectDomainError } from "./test-helpers.js";
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
@@ -112,20 +113,14 @@ describe("addCombatant", () => {
|
||||
const e = enc([A, B]);
|
||||
const result = addCombatant(e, combatantId("x"), "");
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-name");
|
||||
}
|
||||
expectDomainError(result, "invalid-name");
|
||||
});
|
||||
|
||||
it("scenario 6: whitespace-only name returns error", () => {
|
||||
const e = enc([A, B]);
|
||||
const result = addCombatant(e, combatantId("x"), " ");
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-name");
|
||||
}
|
||||
expectDomainError(result, "invalid-name");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -146,12 +141,10 @@ describe("addCombatant", () => {
|
||||
for (const e of scenarios) {
|
||||
const result = successResult(e, "new", "New");
|
||||
const { combatants, activeIndex } = result.encounter;
|
||||
if (combatants.length > 0) {
|
||||
expect(activeIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(activeIndex).toBeLessThan(combatants.length);
|
||||
} else {
|
||||
expect(activeIndex).toBe(0);
|
||||
}
|
||||
// After adding a combatant, list is always non-empty
|
||||
expect(combatants.length).toBeGreaterThan(0);
|
||||
expect(activeIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(activeIndex).toBeLessThan(combatants.length);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -188,7 +181,7 @@ describe("addCombatant", () => {
|
||||
it("INV-7: new combatant is always appended at the end", () => {
|
||||
const e = enc([A, B]);
|
||||
const { encounter } = successResult(e, "C", "C");
|
||||
expect(encounter.combatants[encounter.combatants.length - 1]).toEqual({
|
||||
expect(encounter.combatants.at(-1)).toEqual({
|
||||
id: combatantId("C"),
|
||||
name: "C",
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { adjustHp } from "../adjust-hp.js";
|
||||
import type { Combatant, Encounter } from "../types.js";
|
||||
import { combatantId, isDomainError } from "../types.js";
|
||||
import { expectDomainError } from "./test-helpers.js";
|
||||
|
||||
function makeCombatant(
|
||||
name: string,
|
||||
@@ -101,37 +102,25 @@ describe("adjustHp", () => {
|
||||
it("returns error for nonexistent combatant", () => {
|
||||
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||
const result = adjustHp(e, combatantId("Z"), -1);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("combatant-not-found");
|
||||
}
|
||||
expectDomainError(result, "combatant-not-found");
|
||||
});
|
||||
|
||||
it("returns error when combatant has no HP tracking", () => {
|
||||
const e = enc([makeCombatant("A")]);
|
||||
const result = adjustHp(e, combatantId("A"), -1);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("no-hp-tracking");
|
||||
}
|
||||
expectDomainError(result, "no-hp-tracking");
|
||||
});
|
||||
|
||||
it("returns error for zero delta", () => {
|
||||
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||
const result = adjustHp(e, combatantId("A"), 0);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("zero-delta");
|
||||
}
|
||||
expectDomainError(result, "zero-delta");
|
||||
});
|
||||
|
||||
it("returns error for non-integer delta", () => {
|
||||
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||
const result = adjustHp(e, combatantId("A"), 1.5);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-delta");
|
||||
}
|
||||
expectDomainError(result, "invalid-delta");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createEncounter,
|
||||
type Encounter,
|
||||
} from "../types.js";
|
||||
import { expectDomainError } from "./test-helpers.js";
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
@@ -150,10 +151,7 @@ describe("advanceTurn", () => {
|
||||
};
|
||||
const result = advanceTurn(enc);
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-encounter");
|
||||
}
|
||||
expectDomainError(result, "invalid-encounter");
|
||||
});
|
||||
|
||||
it("scenario 8: three advances on [A,B,C] completes a full round cycle", () => {
|
||||
|
||||
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 type { Combatant, Encounter } from "../types.js";
|
||||
import { combatantId, isDomainError } from "../types.js";
|
||||
import { expectDomainError } from "./test-helpers.js";
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
@@ -124,40 +125,28 @@ describe("editCombatant", () => {
|
||||
const e = enc([Alice, Bob]);
|
||||
const result = editCombatant(e, combatantId("nonexistent"), "NewName");
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("combatant-not-found");
|
||||
}
|
||||
expectDomainError(result, "combatant-not-found");
|
||||
});
|
||||
|
||||
it("empty name returns invalid-name error", () => {
|
||||
const e = enc([Alice, Bob]);
|
||||
const result = editCombatant(e, combatantId("Alice"), "");
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-name");
|
||||
}
|
||||
expectDomainError(result, "invalid-name");
|
||||
});
|
||||
|
||||
it("whitespace-only name returns invalid-name error", () => {
|
||||
const e = enc([Alice, Bob]);
|
||||
const result = editCombatant(e, combatantId("Alice"), " ");
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-name");
|
||||
}
|
||||
expectDomainError(result, "invalid-name");
|
||||
});
|
||||
|
||||
it("empty encounter returns combatant-not-found for any id", () => {
|
||||
const e = enc([]);
|
||||
const result = editCombatant(e, combatantId("any"), "Name");
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("combatant-not-found");
|
||||
}
|
||||
expectDomainError(result, "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 type { Combatant, Encounter } from "../types.js";
|
||||
import { combatantId, isDomainError } from "../types.js";
|
||||
import { expectDomainError } from "./test-helpers.js";
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
@@ -92,10 +93,7 @@ describe("removeCombatant", () => {
|
||||
const e = enc([A, B], 0, 1);
|
||||
const result = removeCombatant(e, combatantId("nonexistent"));
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("combatant-not-found");
|
||||
}
|
||||
expectDomainError(result, "combatant-not-found");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type Encounter,
|
||||
isDomainError,
|
||||
} from "../types.js";
|
||||
import { expectDomainError } from "./test-helpers.js";
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
@@ -83,10 +84,7 @@ describe("retreatTurn", () => {
|
||||
const enc = encounter([A, B, C], 0, 1);
|
||||
const result = retreatTurn(enc);
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("no-previous-turn");
|
||||
}
|
||||
expectDomainError(result, "no-previous-turn");
|
||||
});
|
||||
|
||||
it("scenario 4: single-combatant retreat — wraps to same combatant, decrements round", () => {
|
||||
@@ -117,10 +115,7 @@ describe("retreatTurn", () => {
|
||||
};
|
||||
const result = retreatTurn(enc);
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-encounter");
|
||||
}
|
||||
expectDomainError(result, "invalid-encounter");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { rollInitiative } from "../roll-initiative.js";
|
||||
import { isDomainError } from "../types.js";
|
||||
import { expectDomainError } from "./test-helpers.js";
|
||||
|
||||
describe("rollInitiative", () => {
|
||||
describe("valid rolls", () => {
|
||||
@@ -32,18 +33,12 @@ describe("rollInitiative", () => {
|
||||
describe("invalid dice rolls", () => {
|
||||
it("rejects 0", () => {
|
||||
const result = rollInitiative(0, 5);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-dice-roll");
|
||||
}
|
||||
expectDomainError(result, "invalid-dice-roll");
|
||||
});
|
||||
|
||||
it("rejects 21", () => {
|
||||
const result = rollInitiative(21, 5);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-dice-roll");
|
||||
}
|
||||
expectDomainError(result, "invalid-dice-roll");
|
||||
});
|
||||
|
||||
it("rejects non-integer (3.5)", () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { setAc } from "../set-ac.js";
|
||||
import type { Combatant, Encounter } from "../types.js";
|
||||
import { combatantId, isDomainError } from "../types.js";
|
||||
import { expectDomainError } from "./test-helpers.js";
|
||||
|
||||
function makeCombatant(name: string, ac?: number): Combatant {
|
||||
return ac === undefined
|
||||
@@ -67,30 +68,21 @@ describe("setAc", () => {
|
||||
const e = enc([makeCombatant("A")]);
|
||||
const result = setAc(e, combatantId("nonexistent"), 10);
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("combatant-not-found");
|
||||
}
|
||||
expectDomainError(result, "combatant-not-found");
|
||||
});
|
||||
|
||||
it("returns error for negative AC", () => {
|
||||
const e = enc([makeCombatant("A")]);
|
||||
const result = setAc(e, combatantId("A"), -1);
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-ac");
|
||||
}
|
||||
expectDomainError(result, "invalid-ac");
|
||||
});
|
||||
|
||||
it("returns error for non-integer AC", () => {
|
||||
const e = enc([makeCombatant("A")]);
|
||||
const result = setAc(e, combatantId("A"), 3.5);
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-ac");
|
||||
}
|
||||
expectDomainError(result, "invalid-ac");
|
||||
});
|
||||
|
||||
it("returns error for NaN", () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { setHp } from "../set-hp.js";
|
||||
import type { Combatant, Encounter } from "../types.js";
|
||||
import { combatantId, isDomainError } from "../types.js";
|
||||
import { expectDomainError } from "./test-helpers.js";
|
||||
|
||||
function makeCombatant(
|
||||
name: string,
|
||||
@@ -10,9 +11,9 @@ function makeCombatant(
|
||||
return {
|
||||
id: combatantId(name),
|
||||
name,
|
||||
...(opts?.maxHp !== undefined
|
||||
? { maxHp: opts.maxHp, currentHp: opts.currentHp ?? opts.maxHp }
|
||||
: {}),
|
||||
...(opts?.maxHp === undefined
|
||||
? {}
|
||||
: { maxHp: opts.maxHp, currentHp: opts.currentHp ?? opts.maxHp }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -116,37 +117,25 @@ describe("setHp", () => {
|
||||
it("returns error for nonexistent combatant", () => {
|
||||
const e = enc([makeCombatant("A")]);
|
||||
const result = setHp(e, combatantId("Z"), 10);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("combatant-not-found");
|
||||
}
|
||||
expectDomainError(result, "combatant-not-found");
|
||||
});
|
||||
|
||||
it("rejects maxHp of 0", () => {
|
||||
const e = enc([makeCombatant("A")]);
|
||||
const result = setHp(e, combatantId("A"), 0);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-max-hp");
|
||||
}
|
||||
expectDomainError(result, "invalid-max-hp");
|
||||
});
|
||||
|
||||
it("rejects negative maxHp", () => {
|
||||
const e = enc([makeCombatant("A")]);
|
||||
const result = setHp(e, combatantId("A"), -5);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-max-hp");
|
||||
}
|
||||
expectDomainError(result, "invalid-max-hp");
|
||||
});
|
||||
|
||||
it("rejects non-integer maxHp", () => {
|
||||
const e = enc([makeCombatant("A")]);
|
||||
const result = setHp(e, combatantId("A"), 3.5);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-max-hp");
|
||||
}
|
||||
expectDomainError(result, "invalid-max-hp");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { setInitiative } from "../set-initiative.js";
|
||||
import type { Combatant, Encounter } from "../types.js";
|
||||
import { combatantId, isDomainError } from "../types.js";
|
||||
import { expectDomainError } from "./test-helpers.js";
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
@@ -73,10 +74,7 @@ describe("setInitiative", () => {
|
||||
const e = enc([A, B], 0);
|
||||
const result = setInitiative(e, combatantId("A"), 3.5);
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-initiative");
|
||||
}
|
||||
expectDomainError(result, "invalid-initiative");
|
||||
});
|
||||
|
||||
it("AS-3b: reject NaN", () => {
|
||||
@@ -109,10 +107,7 @@ describe("setInitiative", () => {
|
||||
const e = enc([A, B], 0);
|
||||
const result = setInitiative(e, combatantId("nonexistent"), 10);
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("combatant-not-found");
|
||||
}
|
||||
expectDomainError(result, "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 type { Combatant, Encounter } from "../types.js";
|
||||
import { combatantId, isDomainError } from "../types.js";
|
||||
import { expectDomainError } from "./test-helpers.js";
|
||||
|
||||
function makeCombatant(name: string, isConcentrating?: boolean): Combatant {
|
||||
return isConcentrating
|
||||
@@ -46,10 +47,7 @@ describe("toggleConcentration", () => {
|
||||
const e = enc([makeCombatant("A")]);
|
||||
const result = toggleConcentration(e, combatantId("missing"));
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("combatant-not-found");
|
||||
}
|
||||
expectDomainError(result, "combatant-not-found");
|
||||
});
|
||||
|
||||
it("does not mutate input encounter", () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CONDITION_DEFINITIONS } from "../conditions.js";
|
||||
import { toggleCondition } from "../toggle-condition.js";
|
||||
import type { Combatant, Encounter } from "../types.js";
|
||||
import { combatantId, isDomainError } from "../types.js";
|
||||
import { expectDomainError } from "./test-helpers.js";
|
||||
|
||||
function makeCombatant(
|
||||
name: string,
|
||||
@@ -77,20 +78,14 @@ describe("toggleCondition", () => {
|
||||
"flying" as ConditionId,
|
||||
);
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("unknown-condition");
|
||||
}
|
||||
expectDomainError(result, "unknown-condition");
|
||||
});
|
||||
|
||||
it("returns error for nonexistent combatant", () => {
|
||||
const e = enc([makeCombatant("A")]);
|
||||
const result = toggleCondition(e, combatantId("missing"), "blinded");
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("combatant-not-found");
|
||||
}
|
||||
expectDomainError(result, "combatant-not-found");
|
||||
});
|
||||
|
||||
it("does not mutate input encounter", () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { DomainEvent } from "./events.js";
|
||||
import type { DomainError, Encounter } from "./types.js";
|
||||
import { isDomainError } from "./types.js";
|
||||
|
||||
interface AdvanceTurnSuccess {
|
||||
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) {
|
||||
exactMatches.push(i);
|
||||
} 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) {
|
||||
const num = Number.parseInt(match[1], 10);
|
||||
if (num > maxNumber) maxNumber = num;
|
||||
@@ -50,5 +53,5 @@ export function resolveCreatureName(
|
||||
}
|
||||
|
||||
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 { PlayerCharacterId } from "./player-character-types.js";
|
||||
import type { CombatantId } from "./types.js";
|
||||
|
||||
export interface TurnAdvanced {
|
||||
@@ -103,6 +104,25 @@ export interface EncounterCleared {
|
||||
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 =
|
||||
| TurnAdvanced
|
||||
| RoundAdvanced
|
||||
@@ -119,4 +139,7 @@ export type DomainEvent =
|
||||
| ConditionRemoved
|
||||
| ConcentrationStarted
|
||||
| ConcentrationEnded
|
||||
| EncounterCleared;
|
||||
| EncounterCleared
|
||||
| PlayerCharacterCreated
|
||||
| PlayerCharacterUpdated
|
||||
| PlayerCharacterDeleted;
|
||||
|
||||
@@ -12,6 +12,10 @@ export {
|
||||
type ConditionId,
|
||||
VALID_CONDITION_IDS,
|
||||
} from "./conditions.js";
|
||||
export {
|
||||
type CreatePlayerCharacterSuccess,
|
||||
createPlayerCharacter,
|
||||
} from "./create-player-character.js";
|
||||
export {
|
||||
type BestiaryIndex,
|
||||
type BestiaryIndexEntry,
|
||||
@@ -25,10 +29,18 @@ export {
|
||||
type SpellcastingBlock,
|
||||
type TraitBlock,
|
||||
} from "./creature-types.js";
|
||||
export {
|
||||
type DeletePlayerCharacterSuccess,
|
||||
deletePlayerCharacter,
|
||||
} from "./delete-player-character.js";
|
||||
export {
|
||||
type EditCombatantSuccess,
|
||||
editCombatant,
|
||||
} from "./edit-combatant.js";
|
||||
export {
|
||||
type EditPlayerCharacterSuccess,
|
||||
editPlayerCharacter,
|
||||
} from "./edit-player-character.js";
|
||||
export type {
|
||||
AcSet,
|
||||
CombatantAdded,
|
||||
@@ -43,6 +55,9 @@ export type {
|
||||
EncounterCleared,
|
||||
InitiativeSet,
|
||||
MaxHpSet,
|
||||
PlayerCharacterCreated,
|
||||
PlayerCharacterDeleted,
|
||||
PlayerCharacterUpdated,
|
||||
RoundAdvanced,
|
||||
RoundRetreated,
|
||||
TurnAdvanced,
|
||||
@@ -54,6 +69,16 @@ export {
|
||||
formatInitiativeModifier,
|
||||
type InitiativeResult,
|
||||
} 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 {
|
||||
type RemoveCombatantSuccess,
|
||||
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 (!Number.isInteger(value) || value < 0) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-ac",
|
||||
message: `AC must be a non-negative integer, got ${value}`,
|
||||
};
|
||||
}
|
||||
if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-ac",
|
||||
message: `AC must be a non-negative integer, got ${value}`,
|
||||
};
|
||||
}
|
||||
|
||||
const target = encounter.combatants[targetIdx];
|
||||
|
||||
@@ -28,14 +28,12 @@ export function setHp(
|
||||
};
|
||||
}
|
||||
|
||||
if (maxHp !== undefined) {
|
||||
if (!Number.isInteger(maxHp) || maxHp < 1) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-max-hp",
|
||||
message: `Max HP must be a positive integer, got ${maxHp}`,
|
||||
};
|
||||
}
|
||||
if (maxHp !== undefined && (!Number.isInteger(maxHp) || maxHp < 1)) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-max-hp",
|
||||
message: `Max HP must be a positive integer, got ${maxHp}`,
|
||||
};
|
||||
}
|
||||
|
||||
const target = encounter.combatants[targetIdx];
|
||||
|
||||
@@ -65,7 +65,7 @@ export function setInitiative(
|
||||
const aInit = a.c.initiative as number;
|
||||
const bInit = b.c.initiative as number;
|
||||
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;
|
||||
|
||||
@@ -7,6 +7,7 @@ export function combatantId(id: string): CombatantId {
|
||||
|
||||
import type { ConditionId } from "./conditions.js";
|
||||
import type { CreatureId } from "./creature-types.js";
|
||||
import type { PlayerCharacterId } from "./player-character-types.js";
|
||||
|
||||
export interface Combatant {
|
||||
readonly id: CombatantId;
|
||||
@@ -18,6 +19,9 @@ export interface Combatant {
|
||||
readonly conditions?: readonly ConditionId[];
|
||||
readonly isConcentrating?: boolean;
|
||||
readonly creatureId?: CreatureId;
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
readonly playerCharacterId?: PlayerCharacterId;
|
||||
}
|
||||
|
||||
export interface Encounter {
|
||||
|
||||
369
pnpm-lock.yaml
generated
369
pnpm-lock.yaml
generated
@@ -4,13 +4,16 @@ settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
undici: '>=7.24.0'
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: 2.0.0
|
||||
version: 2.0.0
|
||||
specifier: 2.4.7
|
||||
version: 2.4.7
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.4(vitest@3.2.4(@types/node@25.3.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.31.1))
|
||||
@@ -23,6 +26,12 @@ importers:
|
||||
lefthook:
|
||||
specifier: ^1.11.0
|
||||
version: 1.13.6
|
||||
oxlint:
|
||||
specifier: ^1.55.0
|
||||
version: 1.55.0(oxlint-tsgolint@0.16.0)
|
||||
oxlint-tsgolint:
|
||||
specifier: ^0.16.0
|
||||
version: 0.16.0
|
||||
typescript:
|
||||
specifier: ^5.8.0
|
||||
version: 5.9.3
|
||||
@@ -69,6 +78,9 @@ importers:
|
||||
'@testing-library/react':
|
||||
specifier: ^16.3.2
|
||||
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@testing-library/user-event':
|
||||
specifier: ^14.6.1
|
||||
version: 14.6.1(@testing-library/dom@10.4.1)
|
||||
'@types/react':
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.14
|
||||
@@ -209,55 +221,55 @@ packages:
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@biomejs/biome@2.0.0':
|
||||
resolution: {integrity: sha512-BlUoXEOI/UQTDEj/pVfnkMo8SrZw3oOWBDrXYFT43V7HTkIUDkBRY53IC5Jx1QkZbaB+0ai1wJIfYwp9+qaJTQ==}
|
||||
'@biomejs/biome@2.4.7':
|
||||
resolution: {integrity: sha512-vXrgcmNGZ4lpdwZSpMf1hWw1aWS6B+SyeSYKTLrNsiUsAdSRN0J4d/7mF3ogJFbIwFFSOL3wT92Zzxia/d5/ng==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
hasBin: true
|
||||
|
||||
'@biomejs/cli-darwin-arm64@2.0.0':
|
||||
resolution: {integrity: sha512-QvqWYtFFhhxdf8jMAdJzXW+Frc7X8XsnHQLY+TBM1fnT1TfeV/v9vsFI5L2J7GH6qN1+QEEJ19jHibCY2Ypplw==}
|
||||
'@biomejs/cli-darwin-arm64@2.4.7':
|
||||
resolution: {integrity: sha512-Oo0cF5mHzmvDmTXw8XSjhCia8K6YrZnk7aCS54+/HxyMdZMruMO3nfpDsrlar/EQWe41r1qrwKiCa2QDYHDzWA==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@biomejs/cli-darwin-x64@2.0.0':
|
||||
resolution: {integrity: sha512-5JFhls1EfmuIH4QGFPlNpxJQFC6ic3X1ltcoLN+eSRRIPr6H/lUS1ttuD0Fj7rPgPhZqopK/jfH8UVj/1hIsQw==}
|
||||
'@biomejs/cli-darwin-x64@2.4.7':
|
||||
resolution: {integrity: sha512-I+cOG3sd/7HdFtvDSnF9QQPrWguUH7zrkIMMykM3PtfWU9soTcS2yRb9Myq6MHmzbeCT08D1UmY+BaiMl5CcoQ==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@biomejs/cli-linux-arm64-musl@2.0.0':
|
||||
resolution: {integrity: sha512-Bxsz8ki8+b3PytMnS5SgrGV+mbAWwIxI3ydChb/d1rURlJTMdxTTq5LTebUnlsUWAX6OvJuFeiVq9Gjn1YbCyA==}
|
||||
'@biomejs/cli-linux-arm64-musl@2.4.7':
|
||||
resolution: {integrity: sha512-I2NvM9KPb09jWml93O2/5WMfNR7Lee5Latag1JThDRMURVhPX74p9UDnyTw3Ae6cE1DgXfw7sqQgX7rkvpc0vw==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@biomejs/cli-linux-arm64@2.0.0':
|
||||
resolution: {integrity: sha512-BAH4QVi06TzAbVchXdJPsL0Z/P87jOfes15rI+p3EX9/EGTfIjaQ9lBVlHunxcmoptaA5y1Hdb9UYojIhmnjIw==}
|
||||
'@biomejs/cli-linux-arm64@2.4.7':
|
||||
resolution: {integrity: sha512-om6FugwmibzfP/6ALj5WRDVSND4H2G9X0nkI1HZpp2ySf9lW2j0X68oQSaHEnls6666oy4KDsc5RFjT4m0kV0w==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@biomejs/cli-linux-x64-musl@2.0.0':
|
||||
resolution: {integrity: sha512-tiQ0ABxMJb9I6GlfNp0ulrTiQSFacJRJO8245FFwE3ty3bfsfxlU/miblzDIi+qNrgGsLq5wIZcVYGp4c+HXZA==}
|
||||
'@biomejs/cli-linux-x64-musl@2.4.7':
|
||||
resolution: {integrity: sha512-00kx4YrBMU8374zd2wHuRV5wseh0rom5HqRND+vDldJPrWwQw+mzd/d8byI9hPx926CG+vWzq6AeiT7Yi5y59g==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@biomejs/cli-linux-x64@2.0.0':
|
||||
resolution: {integrity: sha512-09PcOGYTtkopWRm6mZ/B6Mr6UHdkniUgIG/jLBv+2J8Z61ezRE+xQmpi3yNgUrFIAU4lPA9atg7mhvE/5Bo7Wg==}
|
||||
'@biomejs/cli-linux-x64@2.4.7':
|
||||
resolution: {integrity: sha512-bV8/uo2Tj+gumnk4sUdkerWyCPRabaZdv88IpbmDWARQQoA/Q0YaqPz1a+LSEDIL7OfrnPi9Hq1Llz4ZIGyIQQ==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@biomejs/cli-win32-arm64@2.0.0':
|
||||
resolution: {integrity: sha512-vrTtuGu91xNTEQ5ZcMJBZuDlqr32DWU1r14UfePIGndF//s2WUAmer4FmgoPgruo76rprk37e8S2A2c0psXdxw==}
|
||||
'@biomejs/cli-win32-arm64@2.4.7':
|
||||
resolution: {integrity: sha512-hOUHBMlFCvDhu3WCq6vaBoG0dp0LkWxSEnEEsxxXvOa9TfT6ZBnbh72A/xBM7CBYB7WgwqboetzFEVDnMxelyw==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@biomejs/cli-win32-x64@2.0.0':
|
||||
resolution: {integrity: sha512-2USVQ0hklNsph/KIR72ZdeptyXNnQ3JdzPn3NbjI4Sna34CnxeiYAaZcZzXPDl5PYNFBivV4xmvT3Z3rTmyDBg==}
|
||||
'@biomejs/cli-win32-x64@2.4.7':
|
||||
resolution: {integrity: sha512-qEpGjSkPC3qX4ycbMUthXvi9CkRq7kZpkqMY1OyhmYlYLnANnooDQ7hDerM8+0NJ+DZKVnsIc07h30XOpt7LtQ==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -629,6 +641,150 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint-tsgolint/darwin-arm64@0.16.0':
|
||||
resolution: {integrity: sha512-WQt5lGwRPJBw7q2KNR0mSPDAaMmZmVvDlEEti96xLO7ONhyomQc6fBZxxwZ4qTFedjJnrHX94sFelZ4OKzS7UQ==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxlint-tsgolint/darwin-x64@0.16.0':
|
||||
resolution: {integrity: sha512-VJo29XOzdkalvCTiE2v6FU3qZlgHaM8x8hUEVJGPU2i5W+FlocPpmn00+Ld2n7Q0pqIjyD5EyvZ5UmoIEJMfqg==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxlint-tsgolint/linux-arm64@0.16.0':
|
||||
resolution: {integrity: sha512-MPfqRt1+XRHv9oHomcBMQ3KpTE+CSkZz14wUxDQoqTNdUlV0HWdzwIE9q65I3D9YyxEnqpM7j4qtDQ3apqVvbQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint-tsgolint/linux-x64@0.16.0':
|
||||
resolution: {integrity: sha512-XQSwVUsnwLokMhe1TD6IjgvW5WMTPzOGGkdFDtXWQmlN2YeTw94s/NN0KgDrn2agM1WIgAenEkvnm0u7NgwEyw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint-tsgolint/win32-arm64@0.16.0':
|
||||
resolution: {integrity: sha512-EWdlspQiiFGsP2AiCYdhg5dTYyAlj6y1nRyNI2dQWq4Q/LITFHiSRVPe+7m7K7lcsZCEz2icN/bCeSkZaORqIg==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint-tsgolint/win32-x64@0.16.0':
|
||||
resolution: {integrity: sha512-1ufk8cgktXJuJZHKF63zCHAkaLMwZrEXnZ89H2y6NO85PtOXqu4zbdNl0VBpPP3fCUuUBu9RvNqMFiv0VsbXWA==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint/binding-android-arm-eabi@1.55.0':
|
||||
resolution: {integrity: sha512-NhvgAhncTSOhRahQSCnkK/4YIGPjTmhPurQQ2dwt2IvwCMTvZRW5vF2K10UBOxFve4GZDMw6LtXZdC2qeuYIVQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@oxlint/binding-android-arm64@1.55.0':
|
||||
resolution: {integrity: sha512-P9iWRh+Ugqhg+D7rkc7boHX8o3H2h7YPcZHQIgvVBgnua5tk4LR2L+IBlreZs58/95cd2x3/004p5VsQM9z4SA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@oxlint/binding-darwin-arm64@1.55.0':
|
||||
resolution: {integrity: sha512-esakkJIt7WFAhT30P/Qzn96ehFpzdZ1mNuzpOb8SCW7lI4oB8VsyQnkSHREM671jfpuBb/o2ppzBCx5l0jpgMA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxlint/binding-darwin-x64@1.55.0':
|
||||
resolution: {integrity: sha512-xDMFRCCAEK9fOH6As2z8ELsC+VDGSFRHwIKVSilw+xhgLwTDFu37rtmRbmUlx8rRGS6cWKQPTc47AVxAZEVVPQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxlint/binding-freebsd-x64@1.55.0':
|
||||
resolution: {integrity: sha512-mYZqnwUD7ALCRxGenyLd1uuG+rHCL+OTT6S8FcAbVm/ZT2AZMGjvibp3F6k1SKOb2aeqFATmwRykrE41Q0GWVw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@oxlint/binding-linux-arm-gnueabihf@1.55.0':
|
||||
resolution: {integrity: sha512-LcX6RYcF9vL9ESGwJW3yyIZ/d/ouzdOKXxCdey1q0XJOW1asrHsIg5MmyKdEBR4plQx+shvYeQne7AzW5f3T1w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint/binding-linux-arm-musleabihf@1.55.0':
|
||||
resolution: {integrity: sha512-C+8GS1rPtK+dI7mJFkqoRBkDuqbrNihnyYQsJPS9ez+8zF9JzfvU19lawqt4l/Y23o5uQswE/DORa8aiXUih3w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint/binding-linux-arm64-gnu@1.55.0':
|
||||
resolution: {integrity: sha512-ErLE4XbmcCopA4/CIDiH6J1IAaDOMnf/KSx/aFObs4/OjAAM3sFKWGZ57pNOMxhhyBdcmcXwYymph9GwcpcqgQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint/binding-linux-arm64-musl@1.55.0':
|
||||
resolution: {integrity: sha512-/kp65avi6zZfqEng56TTuhiy3P/3pgklKIdf38yvYeJ9/PgEeRA2A2AqKAKbZBNAqUzrzHhz9jF6j/PZvhJzTQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.55.0':
|
||||
resolution: {integrity: sha512-A6pTdXwcEEwL/nmz0eUJ6WxmxcoIS+97GbH96gikAyre3s5deC7sts38ZVVowjS2QQFuSWkpA4ZmQC0jZSNvJQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.55.0':
|
||||
resolution: {integrity: sha512-clj0lnIN+V52G9tdtZl0LbdTSurnZ1NZj92Je5X4lC7gP5jiCSW+Y/oiDiSauBAD4wrHt2S7nN3pA0zfKYK/6Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-musl@1.55.0':
|
||||
resolution: {integrity: sha512-NNu08pllN5x/O94/sgR3DA8lbrGBnTHsINZZR0hcav1sj79ksTiKKm1mRzvZvacwQ0hUnGinFo+JO75ok2PxYg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint/binding-linux-s390x-gnu@1.55.0':
|
||||
resolution: {integrity: sha512-BvfQz3PRlWZRoEZ17dZCqgQsMRdpzGZomJkVATwCIGhHVVeHJMQdmdXPSjcT1DCNUrOjXnVyj1RGDj5+/Je2+Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint/binding-linux-x64-gnu@1.55.0':
|
||||
resolution: {integrity: sha512-ngSOoFCSBMKVQd24H8zkbcBNc7EHhjnF1sv3mC9NNXQ/4rRjI/4Dj9+9XoDZeFEkF1SX1COSBXF1b2Pr9rqdEw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint/binding-linux-x64-musl@1.55.0':
|
||||
resolution: {integrity: sha512-BDpP7W8GlaG7BR6QjGZAleYzxoyKc/D24spZIF2mB3XsfALQJJT/OBmP8YpeTb1rveFSBHzl8T7l0aqwkWNdGA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint/binding-openharmony-arm64@1.55.0':
|
||||
resolution: {integrity: sha512-PS6GFvmde/pc3fCA2Srt51glr8Lcxhpf6WIBFfLphndjRrD34NEcses4TSxQrEcxYo6qVywGfylM0ZhSCF2gGA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@oxlint/binding-win32-arm64-msvc@1.55.0':
|
||||
resolution: {integrity: sha512-P6JcLJGs/q1UOvDLzN8otd9JsH4tsuuPDv+p7aHqHM3PrKmYdmUvkNj4K327PTd35AYcznOCN+l4ZOaq76QzSw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint/binding-win32-ia32-msvc@1.55.0':
|
||||
resolution: {integrity: sha512-gzkk4zE2zsE+WmRxFOiAZHpCpUNDFytEakqNXoNHW+PnYEOTPKDdW6nrzgSeTbGKVPXNAKQnRnMgrh7+n3Xueg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint/binding-win32-x64-msvc@1.55.0':
|
||||
resolution: {integrity: sha512-ZFALNow2/og75gvYzNP7qe+rREQ5xunktwA+lgykoozHZ6hw9bqg4fn5j2UvG4gIn1FXqrZHkOAXuPf5+GOYTQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -874,6 +1030,12 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@testing-library/user-event@14.6.1':
|
||||
resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
peerDependencies:
|
||||
'@testing-library/dom': '>=7.21.4'
|
||||
|
||||
'@tybys/wasm-util@0.10.1':
|
||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||
|
||||
@@ -1709,6 +1871,20 @@ packages:
|
||||
oxc-resolver@11.19.1:
|
||||
resolution: {integrity: sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==}
|
||||
|
||||
oxlint-tsgolint@0.16.0:
|
||||
resolution: {integrity: sha512-4RuJK2jP08XwqtUu+5yhCbxEauCm6tv2MFHKEMsjbosK2+vy5us82oI3VLuHwbNyZG7ekZA26U2LLHnGR4frIA==}
|
||||
hasBin: true
|
||||
|
||||
oxlint@1.55.0:
|
||||
resolution: {integrity: sha512-T+FjepiyWpaZMhekqRpH8Z3I4vNM610p6w+Vjfqgj5TZUxHXl7N8N5IPvmOU8U4XdTRxqtNNTh9Y4hLtr7yvFg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
oxlint-tsgolint: '>=0.15.0'
|
||||
peerDependenciesMeta:
|
||||
oxlint-tsgolint:
|
||||
optional: true
|
||||
|
||||
package-json-from-dist@1.0.1:
|
||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||
|
||||
@@ -2011,8 +2187,8 @@ packages:
|
||||
undici-types@7.18.2:
|
||||
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
||||
|
||||
undici@7.22.0:
|
||||
resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==}
|
||||
undici@7.24.2:
|
||||
resolution: {integrity: sha512-P9J1HWYV/ajFr8uCqk5QixwiRKmB1wOamgS0e+o2Z4A44Ej2+thFVRLG/eA7qprx88XXhnV5Bl8LHXTURpzB3Q==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
universalify@2.0.1:
|
||||
@@ -2305,39 +2481,39 @@ snapshots:
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@biomejs/biome@2.0.0':
|
||||
'@biomejs/biome@2.4.7':
|
||||
optionalDependencies:
|
||||
'@biomejs/cli-darwin-arm64': 2.0.0
|
||||
'@biomejs/cli-darwin-x64': 2.0.0
|
||||
'@biomejs/cli-linux-arm64': 2.0.0
|
||||
'@biomejs/cli-linux-arm64-musl': 2.0.0
|
||||
'@biomejs/cli-linux-x64': 2.0.0
|
||||
'@biomejs/cli-linux-x64-musl': 2.0.0
|
||||
'@biomejs/cli-win32-arm64': 2.0.0
|
||||
'@biomejs/cli-win32-x64': 2.0.0
|
||||
'@biomejs/cli-darwin-arm64': 2.4.7
|
||||
'@biomejs/cli-darwin-x64': 2.4.7
|
||||
'@biomejs/cli-linux-arm64': 2.4.7
|
||||
'@biomejs/cli-linux-arm64-musl': 2.4.7
|
||||
'@biomejs/cli-linux-x64': 2.4.7
|
||||
'@biomejs/cli-linux-x64-musl': 2.4.7
|
||||
'@biomejs/cli-win32-arm64': 2.4.7
|
||||
'@biomejs/cli-win32-x64': 2.4.7
|
||||
|
||||
'@biomejs/cli-darwin-arm64@2.0.0':
|
||||
'@biomejs/cli-darwin-arm64@2.4.7':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-darwin-x64@2.0.0':
|
||||
'@biomejs/cli-darwin-x64@2.4.7':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-arm64-musl@2.0.0':
|
||||
'@biomejs/cli-linux-arm64-musl@2.4.7':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-arm64@2.0.0':
|
||||
'@biomejs/cli-linux-arm64@2.4.7':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-x64-musl@2.0.0':
|
||||
'@biomejs/cli-linux-x64-musl@2.4.7':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-x64@2.0.0':
|
||||
'@biomejs/cli-linux-x64@2.4.7':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-win32-arm64@2.0.0':
|
||||
'@biomejs/cli-win32-arm64@2.4.7':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-win32-x64@2.0.0':
|
||||
'@biomejs/cli-win32-x64@2.4.7':
|
||||
optional: true
|
||||
|
||||
'@bramus/specificity@2.4.2':
|
||||
@@ -2611,6 +2787,81 @@ snapshots:
|
||||
'@oxc-resolver/binding-win32-x64-msvc@11.19.1':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/darwin-arm64@0.16.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/darwin-x64@0.16.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/linux-arm64@0.16.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/linux-x64@0.16.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/win32-arm64@0.16.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/win32-x64@0.16.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-android-arm-eabi@1.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-android-arm64@1.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-darwin-arm64@1.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-darwin-x64@1.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-freebsd-x64@1.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-arm-gnueabihf@1.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-arm-musleabihf@1.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-arm64-gnu@1.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-arm64-musl@1.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-riscv64-musl@1.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-s390x-gnu@1.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-x64-gnu@1.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-x64-musl@1.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-openharmony-arm64@1.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-win32-arm64-msvc@1.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-win32-ia32-msvc@1.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-win32-x64-msvc@1.55.0':
|
||||
optional: true
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
@@ -2789,6 +3040,10 @@ snapshots:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
|
||||
dependencies:
|
||||
'@testing-library/dom': 10.4.1
|
||||
|
||||
'@tybys/wasm-util@0.10.1':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -3420,7 +3675,7 @@ snapshots:
|
||||
saxes: 6.0.0
|
||||
symbol-tree: 3.2.4
|
||||
tough-cookie: 6.0.0
|
||||
undici: 7.22.0
|
||||
undici: 7.24.2
|
||||
w3c-xmlserializer: 5.0.0
|
||||
webidl-conversions: 8.0.1
|
||||
whatwg-mimetype: 5.0.0
|
||||
@@ -3665,6 +3920,38 @@ snapshots:
|
||||
'@oxc-resolver/binding-win32-ia32-msvc': 11.19.1
|
||||
'@oxc-resolver/binding-win32-x64-msvc': 11.19.1
|
||||
|
||||
oxlint-tsgolint@0.16.0:
|
||||
optionalDependencies:
|
||||
'@oxlint-tsgolint/darwin-arm64': 0.16.0
|
||||
'@oxlint-tsgolint/darwin-x64': 0.16.0
|
||||
'@oxlint-tsgolint/linux-arm64': 0.16.0
|
||||
'@oxlint-tsgolint/linux-x64': 0.16.0
|
||||
'@oxlint-tsgolint/win32-arm64': 0.16.0
|
||||
'@oxlint-tsgolint/win32-x64': 0.16.0
|
||||
|
||||
oxlint@1.55.0(oxlint-tsgolint@0.16.0):
|
||||
optionalDependencies:
|
||||
'@oxlint/binding-android-arm-eabi': 1.55.0
|
||||
'@oxlint/binding-android-arm64': 1.55.0
|
||||
'@oxlint/binding-darwin-arm64': 1.55.0
|
||||
'@oxlint/binding-darwin-x64': 1.55.0
|
||||
'@oxlint/binding-freebsd-x64': 1.55.0
|
||||
'@oxlint/binding-linux-arm-gnueabihf': 1.55.0
|
||||
'@oxlint/binding-linux-arm-musleabihf': 1.55.0
|
||||
'@oxlint/binding-linux-arm64-gnu': 1.55.0
|
||||
'@oxlint/binding-linux-arm64-musl': 1.55.0
|
||||
'@oxlint/binding-linux-ppc64-gnu': 1.55.0
|
||||
'@oxlint/binding-linux-riscv64-gnu': 1.55.0
|
||||
'@oxlint/binding-linux-riscv64-musl': 1.55.0
|
||||
'@oxlint/binding-linux-s390x-gnu': 1.55.0
|
||||
'@oxlint/binding-linux-x64-gnu': 1.55.0
|
||||
'@oxlint/binding-linux-x64-musl': 1.55.0
|
||||
'@oxlint/binding-openharmony-arm64': 1.55.0
|
||||
'@oxlint/binding-win32-arm64-msvc': 1.55.0
|
||||
'@oxlint/binding-win32-ia32-msvc': 1.55.0
|
||||
'@oxlint/binding-win32-x64-msvc': 1.55.0
|
||||
oxlint-tsgolint: 0.16.0
|
||||
|
||||
package-json-from-dist@1.0.1: {}
|
||||
|
||||
parse5@8.0.0:
|
||||
@@ -3973,7 +4260,7 @@ snapshots:
|
||||
|
||||
undici-types@7.18.2: {}
|
||||
|
||||
undici@7.22.0: {}
|
||||
undici@7.24.2: {}
|
||||
|
||||
universalify@2.0.1: {}
|
||||
|
||||
|
||||
34
specs/005-player-characters/checklists/requirements.md
Normal file
34
specs/005-player-characters/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Player Character Management
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-12
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass validation. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||
93
specs/005-player-characters/contracts/ui-contracts.md
Normal file
93
specs/005-player-characters/contracts/ui-contracts.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# UI Contracts: Player Character Management
|
||||
|
||||
**Branch**: `005-player-characters` | **Date**: 2026-03-12
|
||||
|
||||
## Bottom Bar — New "Create Player" Button
|
||||
|
||||
**Location**: Bottom bar, alongside existing search input
|
||||
**Trigger**: Icon button click
|
||||
**Icon**: `Users` (Lucide) or similar group/party icon
|
||||
**Action**: Opens Create Player modal
|
||||
|
||||
## Create/Edit Player Modal
|
||||
|
||||
**Trigger**: "Create Player" button (create) or edit action in management view (edit)
|
||||
**Layout**: Centered modal overlay
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Input Type | Placeholder/Label | Validation |
|
||||
|-------|-----------|-------------------|------------|
|
||||
| Name | Text input | "Character name" | Required, non-empty |
|
||||
| AC | Number input | "AC" | Required, >= 0 |
|
||||
| Max HP | Number input | "Max HP" | Required, > 0 |
|
||||
| Color | Palette grid | — | Required, select one |
|
||||
| Icon | Icon grid | — | Required, select one |
|
||||
|
||||
### Color Palette
|
||||
|
||||
Grid of ~10 color swatches. Selected color has a visible ring/border. Each swatch is a clickable circle or rounded square showing the color.
|
||||
|
||||
### Icon Grid
|
||||
|
||||
Grid of ~15 Lucide icons. Selected icon has a highlight/ring. Each icon is a clickable square with the icon rendered at a readable size.
|
||||
|
||||
### Actions
|
||||
|
||||
- **Save**: Validates and creates/updates. Closes modal on success.
|
||||
- **Cancel**: Discards changes. Closes modal.
|
||||
|
||||
## Search Dropdown — Player Characters Section
|
||||
|
||||
**Location**: Existing ActionBar search dropdown
|
||||
**Position**: Above bestiary results
|
||||
**Visibility**: Only when player characters match the query (hide section when no matches)
|
||||
|
||||
### Result Item
|
||||
|
||||
| Element | Content |
|
||||
|---------|---------|
|
||||
| Icon | Player character's chosen icon (small, tinted with chosen color) |
|
||||
| Name | Player character name |
|
||||
| Label | "Player" (to distinguish from bestiary) |
|
||||
|
||||
### Behavior
|
||||
|
||||
- Clicking a player character result adds it to the encounter (same as bestiary selection)
|
||||
- No count/batch — player characters are added one at a time
|
||||
- Player character results use substring matching (same as bestiary)
|
||||
|
||||
## Combatant Row — Color & Icon Display
|
||||
|
||||
**Location**: Existing combatant row, next to combatant name
|
||||
**Visibility**: Only for combatants with `color` and `icon` fields set
|
||||
|
||||
### Rendering
|
||||
|
||||
- Small icon (matching the player character's chosen icon) displayed to the left of the combatant name
|
||||
- Icon tinted with the player character's chosen color
|
||||
- Subtle color accent on the combatant row (e.g., left border or background tint)
|
||||
|
||||
## Player Character Management View
|
||||
|
||||
**Trigger**: Accessible from bottom bar or a dedicated affordance
|
||||
**Layout**: Modal or slide-over panel
|
||||
|
||||
### Character List
|
||||
|
||||
Each row shows:
|
||||
|
||||
| Element | Content |
|
||||
|---------|---------|
|
||||
| Icon | Chosen icon, tinted with chosen color |
|
||||
| Name | Character name |
|
||||
| AC | Armor class value |
|
||||
| Max HP | Max HP value |
|
||||
| Edit button | Opens edit modal |
|
||||
| Delete button | ConfirmButton (two-step) — removes character |
|
||||
|
||||
### Empty State
|
||||
|
||||
When no player characters exist:
|
||||
- Message: "No player characters yet"
|
||||
- Call-to-action button: "Create your first player character"
|
||||
141
specs/005-player-characters/data-model.md
Normal file
141
specs/005-player-characters/data-model.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Data Model: Player Character Management
|
||||
|
||||
**Branch**: `005-player-characters` | **Date**: 2026-03-12
|
||||
|
||||
## New Entities
|
||||
|
||||
### PlayerCharacterId (branded type)
|
||||
|
||||
```
|
||||
PlayerCharacterId = string & { __brand: "PlayerCharacterId" }
|
||||
```
|
||||
|
||||
Unique identifier for player characters. Generated by the application layer (same pattern as `CombatantId`).
|
||||
|
||||
### PlayerCharacter
|
||||
|
||||
| Field | Type | Required | Constraints |
|
||||
|-------|------|----------|-------------|
|
||||
| id | PlayerCharacterId | yes | Unique, immutable after creation |
|
||||
| name | string | yes | Non-empty after trimming |
|
||||
| ac | number | yes | Non-negative integer |
|
||||
| maxHp | number | yes | Positive integer |
|
||||
| color | PlayerColor | yes | One of predefined color values |
|
||||
| icon | PlayerIcon | yes | One of predefined icon identifiers |
|
||||
|
||||
### PlayerColor (constrained string)
|
||||
|
||||
Predefined set of ~10 distinguishable color identifiers:
|
||||
`"red" | "blue" | "green" | "purple" | "orange" | "pink" | "cyan" | "yellow" | "emerald" | "indigo"`
|
||||
|
||||
Each maps to a specific hex/tailwind value at the adapter layer.
|
||||
|
||||
### PlayerIcon (constrained string)
|
||||
|
||||
Predefined set of ~15 Lucide icon identifiers:
|
||||
`"sword" | "shield" | "skull" | "heart" | "wand" | "flame" | "crown" | "star" | "moon" | "sun" | "axe" | "crosshair" | "eye" | "feather" | "zap"`
|
||||
|
||||
### PlayerCharacterList (aggregate)
|
||||
|
||||
| Field | Type | Constraints |
|
||||
|-------|------|-------------|
|
||||
| characters | readonly PlayerCharacter[] | May be empty. IDs unique within list. |
|
||||
|
||||
## Modified Entities
|
||||
|
||||
### Combatant (extended)
|
||||
|
||||
Three new optional fields added:
|
||||
|
||||
| Field | Type | Required | Constraints |
|
||||
|-------|------|----------|-------------|
|
||||
| color | string | no | Copied from PlayerCharacter at add-time |
|
||||
| icon | string | no | Copied from PlayerCharacter at add-time |
|
||||
| playerCharacterId | PlayerCharacterId | no | Reference to source player character (informational only) |
|
||||
|
||||
These fields are set when a combatant is created from a player character. They are immutable snapshots — editing the source player character does not update existing combatants.
|
||||
|
||||
## State Transitions
|
||||
|
||||
### createPlayerCharacter
|
||||
|
||||
- **Input**: PlayerCharacterList + name + ac + maxHp + color + icon + id
|
||||
- **Output**: Updated PlayerCharacterList + `PlayerCharacterCreated` event | DomainError
|
||||
- **Validation**: name non-empty after trim, ac >= 0 integer, maxHp > 0 integer, color in set, icon in set
|
||||
- **Errors**: `invalid-name`, `invalid-ac`, `invalid-max-hp`, `invalid-color`, `invalid-icon`
|
||||
|
||||
### editPlayerCharacter
|
||||
|
||||
- **Input**: PlayerCharacterList + id + partial fields (name?, ac?, maxHp?, color?, icon?)
|
||||
- **Output**: Updated PlayerCharacterList + `PlayerCharacterUpdated` event | DomainError
|
||||
- **Validation**: Same as create for any provided field. At least one field must change.
|
||||
- **Errors**: `player-character-not-found`, `invalid-name`, `invalid-ac`, `invalid-max-hp`, `invalid-color`, `invalid-icon`
|
||||
|
||||
### deletePlayerCharacter
|
||||
|
||||
- **Input**: PlayerCharacterList + id
|
||||
- **Output**: Updated PlayerCharacterList + `PlayerCharacterDeleted` event | DomainError
|
||||
- **Validation**: ID must exist in list
|
||||
- **Errors**: `player-character-not-found`
|
||||
|
||||
## Domain Events
|
||||
|
||||
### PlayerCharacterCreated
|
||||
| Field | Type |
|
||||
|-------|------|
|
||||
| type | `"PlayerCharacterCreated"` |
|
||||
| playerCharacterId | PlayerCharacterId |
|
||||
| name | string |
|
||||
|
||||
### PlayerCharacterUpdated
|
||||
| Field | Type |
|
||||
|-------|------|
|
||||
| type | `"PlayerCharacterUpdated"` |
|
||||
| playerCharacterId | PlayerCharacterId |
|
||||
| oldName | string |
|
||||
| newName | string |
|
||||
|
||||
### PlayerCharacterDeleted
|
||||
| Field | Type |
|
||||
|-------|------|
|
||||
| type | `"PlayerCharacterDeleted"` |
|
||||
| playerCharacterId | PlayerCharacterId |
|
||||
| name | string |
|
||||
|
||||
## Persistence Schema
|
||||
|
||||
### localStorage key: `"initiative:player-characters"`
|
||||
|
||||
JSON array of serialized `PlayerCharacter` objects:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "pc_abc123",
|
||||
"name": "Aragorn",
|
||||
"ac": 16,
|
||||
"maxHp": 120,
|
||||
"color": "green",
|
||||
"icon": "sword"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Rehydration rules
|
||||
|
||||
- Parse JSON array; discard entire store on parse failure (return empty list)
|
||||
- Per-character validation: discard individual characters that fail validation
|
||||
- Required string fields: must be non-empty strings
|
||||
- Required number fields: must match domain constraints (ac >= 0, maxHp > 0)
|
||||
- Color/icon: must be members of the predefined sets; discard character if invalid
|
||||
|
||||
## Port Interface
|
||||
|
||||
### PlayerCharacterStore
|
||||
|
||||
```
|
||||
getAll(): PlayerCharacter[]
|
||||
save(characters: PlayerCharacter[]): void
|
||||
```
|
||||
|
||||
Synchronous, matching the `EncounterStore` pattern. Implementation: localStorage adapter.
|
||||
100
specs/005-player-characters/plan.md
Normal file
100
specs/005-player-characters/plan.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Implementation Plan: Player Character Management
|
||||
|
||||
**Branch**: `005-player-characters` | **Date**: 2026-03-12 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/005-player-characters/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Add persistent player character templates with name, AC, max HP, color, and icon. Player characters are stored independently from encounters and appear in the combatant search dropdown. When added to an encounter, a combatant is created as an independent snapshot with the player character's stats, color, and icon. A management view allows editing and deleting saved characters.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.8 (strict mode, `verbatimModuleSyntax`)
|
||||
**Primary Dependencies**: React 19, Vite 6, Tailwind CSS v4, Lucide React
|
||||
**Storage**: localStorage (new key `"initiative:player-characters"`)
|
||||
**Testing**: Vitest (unit tests for domain, persistence rehydration)
|
||||
**Target Platform**: Web browser (single-page app, no routing)
|
||||
**Project Type**: Web application (monorepo: domain → application → web adapter)
|
||||
**Performance Goals**: Instant load (synchronous localStorage), <16ms search on small list
|
||||
**Constraints**: Local-first, single-user, offline-capable
|
||||
**Scale/Scope**: Typical party size 3-8 player characters
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| I. Deterministic Domain Core | PASS | All PC operations are pure functions, no I/O |
|
||||
| II. Layered Architecture | PASS | Domain types/functions → Application use cases/ports → Web adapters/hooks/components |
|
||||
| III. Clarification-First | PASS | No non-trivial assumptions; all decisions documented in research.md |
|
||||
| IV. Escalation Gates | PASS | Feature has its own spec; cross-feature impacts (Combatant type extension) are documented |
|
||||
| V. MVP Baseline Language | PASS | Exclusions use "MVP baseline does not include" language |
|
||||
| VI. No Gameplay Rules | PASS | No game mechanics in spec or plan |
|
||||
|
||||
**Post-Phase 1 re-check**: PASS. Data model uses pure domain types. Color/icon are constrained string sets validated in domain. Storage adapter follows existing localStorage pattern.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/005-player-characters/
|
||||
├── plan.md # This file
|
||||
├── spec.md # Feature specification
|
||||
├── research.md # Phase 0: technical decisions
|
||||
├── data-model.md # Phase 1: entity definitions
|
||||
├── quickstart.md # Phase 1: implementation guide
|
||||
├── contracts/
|
||||
│ └── ui-contracts.md # Phase 1: UI component contracts
|
||||
├── checklists/
|
||||
│ └── requirements.md # Spec quality checklist
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
packages/domain/src/
|
||||
├── player-character-types.ts # NEW: PlayerCharacterId, PlayerCharacter, PlayerColor, PlayerIcon
|
||||
├── create-player-character.ts # NEW: Pure create function
|
||||
├── edit-player-character.ts # NEW: Pure edit function
|
||||
├── delete-player-character.ts # NEW: Pure delete function
|
||||
├── types.ts # MODIFIED: Add color?, icon?, playerCharacterId? to Combatant
|
||||
├── events.ts # MODIFIED: Add PC domain events to union
|
||||
├── index.ts # MODIFIED: Re-export new types/functions
|
||||
└── __tests__/
|
||||
├── create-player-character.test.ts # NEW
|
||||
├── edit-player-character.test.ts # NEW
|
||||
└── delete-player-character.test.ts # NEW
|
||||
|
||||
packages/application/src/
|
||||
├── ports.ts # MODIFIED: Add PlayerCharacterStore
|
||||
├── create-player-character-use-case.ts # NEW
|
||||
├── edit-player-character-use-case.ts # NEW
|
||||
├── delete-player-character-use-case.ts # NEW
|
||||
└── index.ts # MODIFIED: Re-export
|
||||
|
||||
apps/web/src/
|
||||
├── persistence/
|
||||
│ ├── player-character-storage.ts # NEW: localStorage adapter
|
||||
│ ├── encounter-storage.ts # MODIFIED: Handle new Combatant fields
|
||||
│ └── __tests__/
|
||||
│ └── player-character-storage.test.ts # NEW
|
||||
├── hooks/
|
||||
│ └── use-player-characters.ts # NEW: React state + persistence
|
||||
├── components/
|
||||
│ ├── create-player-modal.tsx # NEW: Create/edit modal
|
||||
│ ├── player-management.tsx # NEW: List/edit/delete view
|
||||
│ ├── color-palette.tsx # NEW: Color selection grid
|
||||
│ ├── icon-grid.tsx # NEW: Icon selection grid
|
||||
│ ├── action-bar.tsx # MODIFIED: Add "Players" section to dropdown
|
||||
│ └── combatant-row.tsx # MODIFIED: Render color/icon
|
||||
└── App.tsx # MODIFIED: Wire usePlayerCharacters
|
||||
```
|
||||
|
||||
**Structure Decision**: Follows existing monorepo layered architecture. New domain files for player character operations, new application use cases, new web adapter/hook/components. Modified files extend existing types and UI.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations. No complexity justification needed.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user