Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
968cc7239b | ||
|
|
d9562f850c | ||
|
|
ec9f2e7877 | ||
|
|
c4079c384b | ||
|
|
a4285fc415 | ||
|
|
9c0e3398f1 | ||
|
|
9cdf004c15 | ||
|
|
8bf69fd47d | ||
|
|
7b83e3c3ea | ||
|
|
c3c2cad798 | ||
|
|
3f6140303d | ||
|
|
fd30278474 | ||
|
|
278c06221f | ||
|
|
722e8cc627 | ||
|
|
64741956dd | ||
|
|
6336dec38a | ||
|
|
9def2d7c24 | ||
|
|
f729e37689 | ||
|
|
86768842ff |
@@ -1,9 +1,9 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
───────────────────
|
───────────────────
|
||||||
Version change: 2.2.1 → 3.0.0 (MAJOR — specs describe features not changes, proportional workflow)
|
Version change: 3.0.0 → 3.1.0 (MINOR — new principle II-A: context-based state flow)
|
||||||
Modified sections:
|
Modified sections:
|
||||||
- Development Workflow: specs are living feature documents; full pipeline for new features only
|
- Core Principles: added II-A. Context-Based State Flow (max 8 props, context over prop drilling)
|
||||||
Templates requiring updates: none
|
Templates requiring updates: none
|
||||||
-->
|
-->
|
||||||
# Encounter Console Constitution
|
# Encounter Console Constitution
|
||||||
@@ -38,6 +38,22 @@ dependency direction:
|
|||||||
|
|
||||||
A module in an inner layer MUST NOT import from an outer layer.
|
A module in an inner layer MUST NOT import from an outer layer.
|
||||||
|
|
||||||
|
### II-A. Context-Based State Flow
|
||||||
|
|
||||||
|
UI components MUST consume shared application state via React context
|
||||||
|
providers, not prop drilling. Props are reserved for per-instance
|
||||||
|
configuration (e.g., a specific data item, a layout variant, a ref).
|
||||||
|
|
||||||
|
- Components MUST NOT declare more than 8 explicit props in their
|
||||||
|
own interface. This is enforced by `scripts/check-component-props.mjs`
|
||||||
|
at pre-commit.
|
||||||
|
- Generic UI primitives (`components/ui/`) that extend HTML element
|
||||||
|
attributes are exempt — only explicitly declared props count, not
|
||||||
|
inherited HTML attributes.
|
||||||
|
- Coordinating hooks that consume multiple contexts (e.g.,
|
||||||
|
`useInitiativeRolls`) are preferred over wiring callbacks through
|
||||||
|
a parent component.
|
||||||
|
|
||||||
### III. Clarification-First
|
### III. Clarification-First
|
||||||
|
|
||||||
Before making any non-trivial assumption during specification,
|
Before making any non-trivial assumption during specification,
|
||||||
@@ -140,4 +156,4 @@ MUST comply with its principles.
|
|||||||
**Compliance review**: Every spec and plan MUST include a
|
**Compliance review**: Every spec and plan MUST include a
|
||||||
Constitution Check section validating adherence to all principles.
|
Constitution Check section validating adherence to all principles.
|
||||||
|
|
||||||
**Version**: 3.0.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-11
|
**Version**: 3.1.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-19
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pnpm test:watch # Tests in watch mode
|
|||||||
pnpm typecheck # tsc --build (project references)
|
pnpm typecheck # tsc --build (project references)
|
||||||
pnpm lint # Biome lint
|
pnpm lint # Biome lint
|
||||||
pnpm format # Biome format (writes)
|
pnpm format # Biome format (writes)
|
||||||
|
pnpm check:props # Component prop count enforcement (max 8)
|
||||||
pnpm --filter web dev # Vite dev server (localhost:5173)
|
pnpm --filter web dev # Vite dev server (localhost:5173)
|
||||||
pnpm --filter web build # Production build
|
pnpm --filter web build # Production build
|
||||||
```
|
```
|
||||||
@@ -71,6 +72,7 @@ docs/agents/ RPI skill artifacts (research reports, plans)
|
|||||||
- **Domain events** are plain data objects with a `type` discriminant — no classes.
|
- **Domain events** are plain data objects with a `type` discriminant — no classes.
|
||||||
- **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks.
|
- **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks.
|
||||||
- **Feature specs** live in `specs/NNN-feature-name/` with spec.md (and optionally plan.md, tasks.md for new work). Specs describe features, not individual changes. The project constitution is at `.specify/memory/constitution.md`.
|
- **Feature specs** live in `specs/NNN-feature-name/` with spec.md (and optionally plan.md, tasks.md for new work). Specs describe features, not individual changes. The project constitution is at `.specify/memory/constitution.md`.
|
||||||
|
- **Component props** — max 8 explicitly declared props per component interface (enforced by `scripts/check-component-props.mjs`). Use React context for shared state; reserve props for per-instance config (data items, layout variants, refs).
|
||||||
- **Quality gates** are enforced at pre-commit via Lefthook's `pnpm check` — the project's single earliest enforcement point. No gate may exist only as a CI step or manual process.
|
- **Quality gates** are enforced at pre-commit via Lefthook's `pnpm check` — the project's single earliest enforcement point. No gate may exist only as a CI step or manual process.
|
||||||
|
|
||||||
## Self-Review Checklist
|
## Self-Review Checklist
|
||||||
|
|||||||
@@ -20,15 +20,15 @@
|
|||||||
"tailwind-merge": "^3.5.0"
|
"tailwind-merge": "^3.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^29.0.1",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.2",
|
||||||
"vite": "^6.2.0"
|
"vite": "^8.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,239 +1,43 @@
|
|||||||
import {
|
import { useEffect, useRef } from "react";
|
||||||
rollAllInitiativeUseCase,
|
import { ActionBar } from "./components/action-bar.js";
|
||||||
rollInitiativeUseCase,
|
import { BulkImportToasts } from "./components/bulk-import-toasts.js";
|
||||||
} from "@initiative/application";
|
import { CombatantRow } from "./components/combatant-row.js";
|
||||||
import {
|
|
||||||
type CombatantId,
|
|
||||||
type Creature,
|
|
||||||
type CreatureId,
|
|
||||||
isDomainError,
|
|
||||||
type RollMode,
|
|
||||||
} from "@initiative/domain";
|
|
||||||
import {
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useLayoutEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { ActionBar } from "./components/action-bar";
|
|
||||||
import { BulkImportToasts } from "./components/bulk-import-toasts";
|
|
||||||
import { CombatantRow } from "./components/combatant-row";
|
|
||||||
import {
|
import {
|
||||||
PlayerCharacterSection,
|
PlayerCharacterSection,
|
||||||
type PlayerCharacterSectionHandle,
|
type PlayerCharacterSectionHandle,
|
||||||
} from "./components/player-character-section";
|
} from "./components/player-character-section.js";
|
||||||
import { StatBlockPanel } from "./components/stat-block-panel";
|
import { StatBlockPanel } from "./components/stat-block-panel.js";
|
||||||
import { Toast } from "./components/toast";
|
import { Toast } from "./components/toast.js";
|
||||||
import { TurnNavigation } from "./components/turn-navigation";
|
import { TurnNavigation } from "./components/turn-navigation.js";
|
||||||
import { type SearchResult, useBestiary } from "./hooks/use-bestiary";
|
import { useEncounterContext } from "./contexts/encounter-context.js";
|
||||||
import { useBulkImport } from "./hooks/use-bulk-import";
|
import { useInitiativeRollsContext } from "./contexts/initiative-rolls-context.js";
|
||||||
import { useEncounter } from "./hooks/use-encounter";
|
import { useSidePanelContext } from "./contexts/side-panel-context.js";
|
||||||
import { usePlayerCharacters } from "./hooks/use-player-characters";
|
import { useActionBarAnimation } from "./hooks/use-action-bar-animation.js";
|
||||||
import { useSidePanelState } from "./hooks/use-side-panel-state";
|
import { useAutoStatBlock } from "./hooks/use-auto-stat-block.js";
|
||||||
import { useTheme } from "./hooks/use-theme";
|
import { cn } from "./lib/utils.js";
|
||||||
import { cn } from "./lib/utils";
|
|
||||||
|
|
||||||
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() {
|
export function App() {
|
||||||
const {
|
const { encounter, isEmpty } = useEncounterContext();
|
||||||
encounter,
|
const sidePanel = useSidePanelContext();
|
||||||
isEmpty,
|
const rolls = useInitiativeRollsContext();
|
||||||
hasCreatureCombatants,
|
|
||||||
canRollAllInitiative,
|
|
||||||
advanceTurn,
|
|
||||||
retreatTurn,
|
|
||||||
addCombatant,
|
|
||||||
clearEncounter,
|
|
||||||
removeCombatant,
|
|
||||||
editCombatant,
|
|
||||||
setInitiative,
|
|
||||||
setHp,
|
|
||||||
adjustHp,
|
|
||||||
setAc,
|
|
||||||
toggleCondition,
|
|
||||||
toggleConcentration,
|
|
||||||
addFromBestiary,
|
|
||||||
addFromPlayerCharacter,
|
|
||||||
makeStore,
|
|
||||||
} = useEncounter();
|
|
||||||
|
|
||||||
const {
|
useAutoStatBlock();
|
||||||
characters: playerCharacters,
|
|
||||||
createCharacter: createPlayerCharacter,
|
|
||||||
editCharacter: editPlayerCharacter,
|
|
||||||
deleteCharacter: deletePlayerCharacter,
|
|
||||||
} = usePlayerCharacters();
|
|
||||||
|
|
||||||
const {
|
|
||||||
search,
|
|
||||||
getCreature,
|
|
||||||
isLoaded,
|
|
||||||
isSourceCached,
|
|
||||||
fetchAndCacheSource,
|
|
||||||
uploadAndCacheSource,
|
|
||||||
refreshCache,
|
|
||||||
} = useBestiary();
|
|
||||||
|
|
||||||
const bulkImport = useBulkImport();
|
|
||||||
const sidePanel = useSidePanelState();
|
|
||||||
const { preference: themePreference, cycleTheme } = useTheme();
|
|
||||||
|
|
||||||
const [rollSkippedCount, setRollSkippedCount] = useState(0);
|
|
||||||
const [rollSingleSkipped, setRollSingleSkipped] = useState(false);
|
|
||||||
|
|
||||||
const selectedCreature: Creature | null = sidePanel.selectedCreatureId
|
|
||||||
? (getCreature(sidePanel.selectedCreatureId) ?? null)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const pinnedCreature: Creature | null = sidePanel.pinnedCreatureId
|
|
||||||
? (getCreature(sidePanel.pinnedCreatureId) ?? null)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const handleAddFromBestiary = useCallback(
|
|
||||||
(result: SearchResult) => {
|
|
||||||
const creatureId = addFromBestiary(result);
|
|
||||||
if (creatureId && sidePanel.panelView.mode === "closed") {
|
|
||||||
sidePanel.showCreature(creatureId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[addFromBestiary, sidePanel.panelView.mode, sidePanel.showCreature],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCombatantStatBlock = useCallback(
|
|
||||||
(creatureId: string) => {
|
|
||||||
sidePanel.showCreature(creatureId as CreatureId);
|
|
||||||
},
|
|
||||||
[sidePanel.showCreature],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRollInitiative = useCallback(
|
|
||||||
(id: CombatantId, mode: RollMode = "normal") => {
|
|
||||||
const diceRolls: [number, ...number[]] =
|
|
||||||
mode === "normal" ? [rollDice()] : [rollDice(), rollDice()];
|
|
||||||
const result = rollInitiativeUseCase(
|
|
||||||
makeStore(),
|
|
||||||
id,
|
|
||||||
diceRolls,
|
|
||||||
getCreature,
|
|
||||||
mode,
|
|
||||||
);
|
|
||||||
if (isDomainError(result)) {
|
|
||||||
setRollSingleSkipped(true);
|
|
||||||
const combatant = encounter.combatants.find((c) => c.id === id);
|
|
||||||
if (combatant?.creatureId) {
|
|
||||||
sidePanel.showCreature(combatant.creatureId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[makeStore, getCreature, encounter.combatants, sidePanel.showCreature],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRollAllInitiative = useCallback(
|
|
||||||
(mode: RollMode = "normal") => {
|
|
||||||
const result = rollAllInitiativeUseCase(
|
|
||||||
makeStore(),
|
|
||||||
rollDice,
|
|
||||||
getCreature,
|
|
||||||
mode,
|
|
||||||
);
|
|
||||||
if (!isDomainError(result) && result.skippedNoSource > 0) {
|
|
||||||
setRollSkippedCount(result.skippedNoSource);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[makeStore, getCreature],
|
|
||||||
);
|
|
||||||
|
|
||||||
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) => {
|
|
||||||
bulkImport.startImport(
|
|
||||||
baseUrl,
|
|
||||||
fetchAndCacheSource,
|
|
||||||
isSourceCached,
|
|
||||||
refreshCache,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[bulkImport.startImport, fetchAndCacheSource, isSourceCached, refreshCache],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleBulkImportDone = useCallback(() => {
|
|
||||||
sidePanel.dismissPanel();
|
|
||||||
bulkImport.reset();
|
|
||||||
}, [sidePanel.dismissPanel, bulkImport.reset]);
|
|
||||||
|
|
||||||
const actionBarInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
|
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
|
||||||
|
const actionBarInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||||
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
|
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
|
||||||
|
|
||||||
// Auto-update stat block panel when the active combatant changes
|
// Auto-scroll to active combatant when turn changes
|
||||||
const activeCreatureId =
|
const activeIndex = encounter.activeIndex;
|
||||||
encounter.combatants[encounter.activeIndex]?.creatureId;
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeCreatureId && sidePanel.panelView.mode === "creature") {
|
|
||||||
sidePanel.updateCreature(activeCreatureId);
|
|
||||||
}
|
|
||||||
}, [activeCreatureId, sidePanel.panelView.mode, sidePanel.updateCreature]);
|
|
||||||
|
|
||||||
// Auto-scroll to the active combatant when the turn changes
|
|
||||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (activeIndex >= 0) {
|
||||||
activeRowRef.current?.scrollIntoView({
|
activeRowRef.current?.scrollIntoView({
|
||||||
block: "nearest",
|
block: "nearest",
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
});
|
});
|
||||||
}, []);
|
}
|
||||||
|
}, [activeIndex]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh flex-col">
|
<div className="flex h-dvh flex-col">
|
||||||
@@ -243,49 +47,27 @@ export function App() {
|
|||||||
className={cn("shrink-0 pt-8", actionBarAnim.topBarClass)}
|
className={cn("shrink-0 pt-8", actionBarAnim.topBarClass)}
|
||||||
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
|
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
|
||||||
>
|
>
|
||||||
<TurnNavigation
|
<TurnNavigation />
|
||||||
encounter={encounter}
|
|
||||||
onAdvanceTurn={advanceTurn}
|
|
||||||
onRetreatTurn={retreatTurn}
|
|
||||||
onClearEncounter={clearEncounter}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isEmpty ? (
|
{isEmpty ? (
|
||||||
/* Empty state — ActionBar centered */
|
|
||||||
<div className="flex min-h-0 flex-1 items-center justify-center pt-8 pb-[15%]">
|
<div className="flex min-h-0 flex-1 items-center justify-center pt-8 pb-[15%]">
|
||||||
<div
|
<div
|
||||||
className={cn("w-full", actionBarAnim.risingClass)}
|
className={cn("w-full", actionBarAnim.risingClass)}
|
||||||
onAnimationEnd={actionBarAnim.onRiseEnd}
|
onAnimationEnd={actionBarAnim.onRiseEnd}
|
||||||
>
|
>
|
||||||
<ActionBar
|
<ActionBar
|
||||||
onAddCombatant={addCombatant}
|
|
||||||
onAddFromBestiary={handleAddFromBestiary}
|
|
||||||
bestiarySearch={search}
|
|
||||||
bestiaryLoaded={isLoaded}
|
|
||||||
onViewStatBlock={handleViewStatBlock}
|
|
||||||
onBulkImport={sidePanel.showBulkImport}
|
|
||||||
bulkImportDisabled={bulkImport.state.status === "loading"}
|
|
||||||
inputRef={actionBarInputRef}
|
inputRef={actionBarInputRef}
|
||||||
playerCharacters={playerCharacters}
|
|
||||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
|
||||||
onManagePlayers={() =>
|
onManagePlayers={() =>
|
||||||
playerCharacterRef.current?.openManagement()
|
playerCharacterRef.current?.openManagement()
|
||||||
}
|
}
|
||||||
onRollAllInitiative={handleRollAllInitiative}
|
|
||||||
showRollAllInitiative={hasCreatureCombatants}
|
|
||||||
rollAllInitiativeDisabled={!canRollAllInitiative}
|
|
||||||
onOpenSourceManager={sidePanel.showSourceManager}
|
|
||||||
themePreference={themePreference}
|
|
||||||
onCycleTheme={cycleTheme}
|
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Scrollable area — combatant list */}
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<div className="flex flex-col px-2 py-2">
|
<div className="flex flex-col px-2 py-2">
|
||||||
{encounter.combatants.map((c, i) => (
|
{encounter.combatants.map((c, i) => (
|
||||||
@@ -294,133 +76,51 @@ export function App() {
|
|||||||
ref={i === encounter.activeIndex ? activeRowRef : null}
|
ref={i === encounter.activeIndex ? activeRowRef : null}
|
||||||
combatant={c}
|
combatant={c}
|
||||||
isActive={i === encounter.activeIndex}
|
isActive={i === encounter.activeIndex}
|
||||||
onRename={editCombatant}
|
|
||||||
onSetInitiative={setInitiative}
|
|
||||||
onRemove={removeCombatant}
|
|
||||||
onSetHp={setHp}
|
|
||||||
onAdjustHp={adjustHp}
|
|
||||||
onSetAc={setAc}
|
|
||||||
onToggleCondition={toggleCondition}
|
|
||||||
onToggleConcentration={toggleConcentration}
|
|
||||||
onShowStatBlock={
|
|
||||||
c.creatureId
|
|
||||||
? () => handleCombatantStatBlock(c.creatureId as string)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
isStatBlockOpen={
|
|
||||||
c.creatureId === sidePanel.selectedCreatureId
|
|
||||||
}
|
|
||||||
onRollInitiative={
|
|
||||||
c.creatureId ? handleRollInitiative : undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Bar — fixed at bottom */}
|
|
||||||
<div
|
<div
|
||||||
className={cn("shrink-0 pb-8", actionBarAnim.settlingClass)}
|
className={cn("shrink-0 pb-8", actionBarAnim.settlingClass)}
|
||||||
onAnimationEnd={actionBarAnim.onSettleEnd}
|
onAnimationEnd={actionBarAnim.onSettleEnd}
|
||||||
>
|
>
|
||||||
<ActionBar
|
<ActionBar
|
||||||
onAddCombatant={addCombatant}
|
|
||||||
onAddFromBestiary={handleAddFromBestiary}
|
|
||||||
bestiarySearch={search}
|
|
||||||
bestiaryLoaded={isLoaded}
|
|
||||||
onViewStatBlock={handleViewStatBlock}
|
|
||||||
onBulkImport={sidePanel.showBulkImport}
|
|
||||||
bulkImportDisabled={bulkImport.state.status === "loading"}
|
|
||||||
inputRef={actionBarInputRef}
|
inputRef={actionBarInputRef}
|
||||||
playerCharacters={playerCharacters}
|
|
||||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
|
||||||
onManagePlayers={() =>
|
onManagePlayers={() =>
|
||||||
playerCharacterRef.current?.openManagement()
|
playerCharacterRef.current?.openManagement()
|
||||||
}
|
}
|
||||||
onRollAllInitiative={handleRollAllInitiative}
|
|
||||||
showRollAllInitiative={hasCreatureCombatants}
|
|
||||||
rollAllInitiativeDisabled={!canRollAllInitiative}
|
|
||||||
onOpenSourceManager={sidePanel.showSourceManager}
|
|
||||||
themePreference={themePreference}
|
|
||||||
onCycleTheme={cycleTheme}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pinned Stat Block Panel (left) */}
|
|
||||||
{!!sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
|
{!!sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
|
||||||
<StatBlockPanel
|
<StatBlockPanel panelRole="pinned" side="left" />
|
||||||
creatureId={sidePanel.pinnedCreatureId}
|
|
||||||
creature={pinnedCreature}
|
|
||||||
isSourceCached={isSourceCached}
|
|
||||||
fetchAndCacheSource={fetchAndCacheSource}
|
|
||||||
uploadAndCacheSource={uploadAndCacheSource}
|
|
||||||
refreshCache={refreshCache}
|
|
||||||
panelRole="pinned"
|
|
||||||
isCollapsed={false}
|
|
||||||
onToggleCollapse={() => {}}
|
|
||||||
onPin={() => {}}
|
|
||||||
onUnpin={sidePanel.unpin}
|
|
||||||
showPinButton={false}
|
|
||||||
side="left"
|
|
||||||
onDismiss={() => {}}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Browse Stat Block Panel (right) */}
|
<StatBlockPanel panelRole="browse" side="right" />
|
||||||
<StatBlockPanel
|
|
||||||
creatureId={sidePanel.selectedCreatureId}
|
|
||||||
creature={selectedCreature}
|
|
||||||
isSourceCached={isSourceCached}
|
|
||||||
fetchAndCacheSource={fetchAndCacheSource}
|
|
||||||
uploadAndCacheSource={uploadAndCacheSource}
|
|
||||||
refreshCache={refreshCache}
|
|
||||||
panelRole="browse"
|
|
||||||
isCollapsed={sidePanel.isRightPanelCollapsed}
|
|
||||||
onToggleCollapse={sidePanel.toggleCollapse}
|
|
||||||
onPin={sidePanel.togglePin}
|
|
||||||
onUnpin={() => {}}
|
|
||||||
showPinButton={sidePanel.isWideDesktop && !!selectedCreature}
|
|
||||||
side="right"
|
|
||||||
onDismiss={sidePanel.dismissPanel}
|
|
||||||
bulkImportMode={sidePanel.bulkImportMode}
|
|
||||||
bulkImportState={bulkImport.state}
|
|
||||||
onStartBulkImport={handleStartBulkImport}
|
|
||||||
onBulkImportDone={handleBulkImportDone}
|
|
||||||
sourceManagerMode={sidePanel.sourceManagerMode}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BulkImportToasts
|
<BulkImportToasts />
|
||||||
state={bulkImport.state}
|
|
||||||
visible={!sidePanel.bulkImportMode || sidePanel.isRightPanelCollapsed}
|
|
||||||
onReset={bulkImport.reset}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{rollSkippedCount > 0 && (
|
{rolls.rollSkippedCount > 0 && (
|
||||||
<Toast
|
<Toast
|
||||||
message={`${rollSkippedCount} skipped — bestiary source not loaded`}
|
message={`${rolls.rollSkippedCount} skipped — bestiary source not loaded`}
|
||||||
onDismiss={() => setRollSkippedCount(0)}
|
onDismiss={rolls.dismissRollSkipped}
|
||||||
autoDismissMs={4000}
|
autoDismissMs={4000}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!!rollSingleSkipped && (
|
{!!rolls.rollSingleSkipped && (
|
||||||
<Toast
|
<Toast
|
||||||
message="Can't roll — bestiary source not loaded"
|
message="Can't roll — bestiary source not loaded"
|
||||||
onDismiss={() => setRollSingleSkipped(false)}
|
onDismiss={rolls.dismissRollSingleSkipped}
|
||||||
autoDismissMs={4000}
|
autoDismissMs={4000}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PlayerCharacterSection
|
<PlayerCharacterSection ref={playerCharacterRef} />
|
||||||
ref={playerCharacterRef}
|
|
||||||
characters={playerCharacters}
|
|
||||||
onCreateCharacter={createPlayerCharacter}
|
|
||||||
onEditCharacter={editPlayerCharacter}
|
|
||||||
onDeleteCharacter={deletePlayerCharacter}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import "@testing-library/jest-dom/vitest";
|
|||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
import { App } from "../App";
|
import { App } from "../App.js";
|
||||||
|
import { AllProviders } from "./test-providers.js";
|
||||||
|
|
||||||
// Mock persistence — no localStorage interaction
|
// Mock persistence — no localStorage interaction
|
||||||
vi.mock("../persistence/encounter-storage.js", () => ({
|
vi.mock("../persistence/encounter-storage.js", () => ({
|
||||||
@@ -76,7 +77,7 @@ async function addCombatant(
|
|||||||
describe("App integration", () => {
|
describe("App integration", () => {
|
||||||
it("adds a combatant and removes it, returning to empty state", async () => {
|
it("adds a combatant and removes it, returning to empty state", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<App />);
|
render(<App />, { wrapper: AllProviders });
|
||||||
|
|
||||||
// Empty state: centered input visible, no TurnNavigation
|
// Empty state: centered input visible, no TurnNavigation
|
||||||
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
|
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
|
||||||
@@ -109,7 +110,7 @@ describe("App integration", () => {
|
|||||||
|
|
||||||
it("advances and retreats turns across two combatants", async () => {
|
it("advances and retreats turns across two combatants", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<App />);
|
render(<App />, { wrapper: AllProviders });
|
||||||
|
|
||||||
await addCombatant(user, "Fighter");
|
await addCombatant(user, "Fighter");
|
||||||
await addCombatant(user, "Wizard");
|
await addCombatant(user, "Wizard");
|
||||||
@@ -137,12 +138,11 @@ describe("App integration", () => {
|
|||||||
|
|
||||||
it("adds a combatant with HP, applies damage, and shows unconscious state", async () => {
|
it("adds a combatant with HP, applies damage, and shows unconscious state", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<App />);
|
render(<App />, { wrapper: AllProviders });
|
||||||
|
|
||||||
await addCombatant(user, "Ogre", { maxHp: "59" });
|
await addCombatant(user, "Ogre", { maxHp: "59" });
|
||||||
|
|
||||||
// Verify HP displays — currentHp and maxHp both show "59"
|
// Verify HP displays — currentHp and maxHp both show "59"
|
||||||
expect(screen.getByText("/")).toBeInTheDocument();
|
|
||||||
const hpButton = screen.getByRole("button", {
|
const hpButton = screen.getByRole("button", {
|
||||||
name: "Current HP: 59 (healthy)",
|
name: "Current HP: 59 (healthy)",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,11 +4,33 @@ import "@testing-library/jest-dom/vitest";
|
|||||||
import type { Creature, CreatureId } from "@initiative/domain";
|
import type { Creature, CreatureId } from "@initiative/domain";
|
||||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { StatBlockPanel } from "../components/stat-block-panel";
|
|
||||||
|
// Mock the context modules
|
||||||
|
vi.mock("../contexts/side-panel-context.js", () => ({
|
||||||
|
useSidePanelContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../contexts/bestiary-context.js", () => ({
|
||||||
|
useBestiaryContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock adapters to avoid IndexedDB
|
||||||
|
vi.mock("../adapters/bestiary-index-adapter.js", () => ({
|
||||||
|
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||||
|
getAllSourceCodes: () => [],
|
||||||
|
getDefaultFetchUrl: () => "",
|
||||||
|
getSourceDisplayName: (code: string) => code,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { StatBlockPanel } from "../components/stat-block-panel.js";
|
||||||
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
|
|
||||||
|
const mockUseSidePanelContext = vi.mocked(useSidePanelContext);
|
||||||
|
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
|
||||||
|
|
||||||
const CLOSE_REGEX = /close/i;
|
const CLOSE_REGEX = /close/i;
|
||||||
const COLLAPSE_REGEX = /collapse/i;
|
const COLLAPSE_REGEX = /collapse/i;
|
||||||
|
|
||||||
const CREATURE_ID = "srd:goblin" as CreatureId;
|
const CREATURE_ID = "srd:goblin" as CreatureId;
|
||||||
const CREATURE: Creature = {
|
const CREATURE: Creature = {
|
||||||
id: CREATURE_ID,
|
id: CREATURE_ID,
|
||||||
@@ -44,41 +66,65 @@ function mockMatchMedia(matches: boolean) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PanelProps {
|
interface PanelOverrides {
|
||||||
creatureId?: CreatureId | null;
|
creatureId?: CreatureId | null;
|
||||||
creature?: Creature | null;
|
creature?: Creature | null;
|
||||||
panelRole?: "browse" | "pinned";
|
panelRole?: "browse" | "pinned";
|
||||||
isCollapsed?: boolean;
|
isCollapsed?: boolean;
|
||||||
onToggleCollapse?: () => void;
|
|
||||||
onPin?: () => void;
|
|
||||||
onUnpin?: () => void;
|
|
||||||
showPinButton?: boolean;
|
|
||||||
side?: "left" | "right";
|
side?: "left" | "right";
|
||||||
onDismiss?: () => void;
|
|
||||||
bulkImportMode?: boolean;
|
bulkImportMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPanel(overrides: PanelProps = {}) {
|
function setupMocks(overrides: PanelOverrides = {}) {
|
||||||
const props = {
|
const panelRole = overrides.panelRole ?? "browse";
|
||||||
creatureId: CREATURE_ID,
|
const creatureId = overrides.creatureId ?? CREATURE_ID;
|
||||||
creature: CREATURE,
|
const creature = overrides.creature ?? CREATURE;
|
||||||
|
const isCollapsed = overrides.isCollapsed ?? false;
|
||||||
|
|
||||||
|
const onToggleCollapse = vi.fn();
|
||||||
|
const onPin = vi.fn();
|
||||||
|
const onUnpin = vi.fn();
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
|
||||||
|
mockUseSidePanelContext.mockReturnValue({
|
||||||
|
selectedCreatureId: panelRole === "browse" ? creatureId : null,
|
||||||
|
pinnedCreatureId: panelRole === "pinned" ? creatureId : null,
|
||||||
|
isRightPanelCollapsed: panelRole === "browse" ? isCollapsed : false,
|
||||||
|
isWideDesktop: false,
|
||||||
|
bulkImportMode: overrides.bulkImportMode ?? false,
|
||||||
|
sourceManagerMode: false,
|
||||||
|
panelView: creatureId
|
||||||
|
? { mode: "creature" as const, creatureId }
|
||||||
|
: { mode: "closed" as const },
|
||||||
|
showCreature: vi.fn(),
|
||||||
|
updateCreature: vi.fn(),
|
||||||
|
showBulkImport: vi.fn(),
|
||||||
|
showSourceManager: vi.fn(),
|
||||||
|
dismissPanel: onDismiss,
|
||||||
|
toggleCollapse: onToggleCollapse,
|
||||||
|
togglePin: onPin,
|
||||||
|
unpin: onUnpin,
|
||||||
|
} as ReturnType<typeof useSidePanelContext>);
|
||||||
|
|
||||||
|
mockUseBestiaryContext.mockReturnValue({
|
||||||
|
getCreature: (id: CreatureId) => (id === creatureId ? creature : undefined),
|
||||||
isSourceCached: vi.fn().mockResolvedValue(true),
|
isSourceCached: vi.fn().mockResolvedValue(true),
|
||||||
|
search: vi.fn().mockReturnValue([]),
|
||||||
|
isLoaded: true,
|
||||||
fetchAndCacheSource: vi.fn(),
|
fetchAndCacheSource: vi.fn(),
|
||||||
uploadAndCacheSource: vi.fn(),
|
uploadAndCacheSource: vi.fn(),
|
||||||
refreshCache: vi.fn(),
|
refreshCache: vi.fn(),
|
||||||
panelRole: "browse" as const,
|
} as ReturnType<typeof useBestiaryContext>);
|
||||||
isCollapsed: false,
|
|
||||||
onToggleCollapse: vi.fn(),
|
|
||||||
onPin: vi.fn(),
|
|
||||||
onUnpin: vi.fn(),
|
|
||||||
showPinButton: false,
|
|
||||||
side: "right" as const,
|
|
||||||
onDismiss: vi.fn(),
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
|
|
||||||
render(<StatBlockPanel {...props} />);
|
return { onToggleCollapse, onPin, onUnpin, onDismiss };
|
||||||
return props;
|
}
|
||||||
|
|
||||||
|
function renderPanel(overrides: PanelOverrides = {}) {
|
||||||
|
const callbacks = setupMocks(overrides);
|
||||||
|
const panelRole = overrides.panelRole ?? "browse";
|
||||||
|
const side = overrides.side ?? (panelRole === "pinned" ? "left" : "right");
|
||||||
|
render(<StatBlockPanel panelRole={panelRole} side={side} />);
|
||||||
|
return callbacks;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
||||||
@@ -113,19 +159,19 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("calls onToggleCollapse when collapse button is clicked", () => {
|
it("calls onToggleCollapse when collapse button is clicked", () => {
|
||||||
const props = renderPanel();
|
const callbacks = renderPanel();
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", { name: "Collapse stat block panel" }),
|
screen.getByRole("button", { name: "Collapse stat block panel" }),
|
||||||
);
|
);
|
||||||
expect(props.onToggleCollapse).toHaveBeenCalledTimes(1);
|
expect(callbacks.onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onToggleCollapse when collapsed tab is clicked", () => {
|
it("calls onToggleCollapse when collapsed tab is clicked", () => {
|
||||||
const props = renderPanel({ isCollapsed: true });
|
const callbacks = renderPanel({ isCollapsed: true });
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", { name: "Expand stat block panel" }),
|
screen.getByRole("button", { name: "Expand stat block panel" }),
|
||||||
);
|
);
|
||||||
expect(props.onToggleCollapse).toHaveBeenCalledTimes(1);
|
expect(callbacks.onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies translate-x class when collapsed (right side)", () => {
|
it("applies translate-x class when collapsed (right side)", () => {
|
||||||
@@ -163,53 +209,58 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("calls onDismiss when backdrop is clicked on mobile", () => {
|
it("calls onDismiss when backdrop is clicked on mobile", () => {
|
||||||
const props = renderPanel();
|
const callbacks = renderPanel();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Close stat block" }));
|
fireEvent.click(screen.getByRole("button", { name: "Close stat block" }));
|
||||||
expect(props.onDismiss).toHaveBeenCalledTimes(1);
|
expect(callbacks.onDismiss).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not render pinned panel on mobile", () => {
|
it("does not render pinned panel on mobile", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<StatBlockPanel
|
(() => {
|
||||||
creatureId={CREATURE_ID}
|
setupMocks({ panelRole: "pinned" });
|
||||||
creature={CREATURE}
|
return <StatBlockPanel panelRole="pinned" side="left" />;
|
||||||
isSourceCached={vi.fn().mockResolvedValue(true)}
|
})(),
|
||||||
fetchAndCacheSource={vi.fn()}
|
|
||||||
uploadAndCacheSource={vi.fn()}
|
|
||||||
refreshCache={vi.fn()}
|
|
||||||
panelRole="pinned"
|
|
||||||
isCollapsed={false}
|
|
||||||
onToggleCollapse={vi.fn()}
|
|
||||||
onPin={vi.fn()}
|
|
||||||
onUnpin={vi.fn()}
|
|
||||||
showPinButton={false}
|
|
||||||
side="left"
|
|
||||||
onDismiss={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
expect(container.innerHTML).toBe("");
|
expect(container.innerHTML).toBe("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("US2: Pin and Unpin", () => {
|
describe("US2: Pin and Unpin", () => {
|
||||||
it("shows pin button when showPinButton is true on desktop", () => {
|
it("shows pin button when isWideDesktop is true on desktop", () => {
|
||||||
renderPanel({ showPinButton: true });
|
setupMocks();
|
||||||
|
// Override to set isWideDesktop
|
||||||
|
const ctx = mockUseSidePanelContext.mock.results[0]?.value as ReturnType<
|
||||||
|
typeof useSidePanelContext
|
||||||
|
>;
|
||||||
|
mockUseSidePanelContext.mockReturnValue({
|
||||||
|
...ctx,
|
||||||
|
isWideDesktop: true,
|
||||||
|
});
|
||||||
|
render(<StatBlockPanel panelRole="browse" side="right" />);
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Pin creature" }),
|
screen.getByRole("button", { name: "Pin creature" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hides pin button when showPinButton is false", () => {
|
it("hides pin button when isWideDesktop is false", () => {
|
||||||
renderPanel({ showPinButton: false });
|
renderPanel();
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole("button", { name: "Pin creature" }),
|
screen.queryByRole("button", { name: "Pin creature" }),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onPin when pin button is clicked", () => {
|
it("calls onPin when pin button is clicked", () => {
|
||||||
const props = renderPanel({ showPinButton: true });
|
setupMocks();
|
||||||
|
const ctx = mockUseSidePanelContext.mock.results[0]?.value as ReturnType<
|
||||||
|
typeof useSidePanelContext
|
||||||
|
>;
|
||||||
|
mockUseSidePanelContext.mockReturnValue({
|
||||||
|
...ctx,
|
||||||
|
isWideDesktop: true,
|
||||||
|
});
|
||||||
|
render(<StatBlockPanel panelRole="browse" side="right" />);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Pin creature" }));
|
fireEvent.click(screen.getByRole("button", { name: "Pin creature" }));
|
||||||
expect(props.onPin).toHaveBeenCalledTimes(1);
|
expect(ctx.togglePin).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows unpin button for pinned role", () => {
|
it("shows unpin button for pinned role", () => {
|
||||||
@@ -220,9 +271,9 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("calls onUnpin when unpin button is clicked", () => {
|
it("calls onUnpin when unpin button is clicked", () => {
|
||||||
const props = renderPanel({ panelRole: "pinned", side: "left" });
|
const callbacks = renderPanel({ panelRole: "pinned", side: "left" });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Unpin creature" }));
|
fireEvent.click(screen.getByRole("button", { name: "Unpin creature" }));
|
||||||
expect(props.onUnpin).toHaveBeenCalledTimes(1);
|
expect(callbacks.onUnpin).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("positions pinned panel on the left side", () => {
|
it("positions pinned panel on the left side", () => {
|
||||||
@@ -255,7 +306,7 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("pinned panel is always expanded (no translate offset)", () => {
|
it("pinned panel is always expanded (no translate offset)", () => {
|
||||||
renderPanel({ panelRole: "pinned", side: "left", isCollapsed: false });
|
renderPanel({ panelRole: "pinned", side: "left" });
|
||||||
const unpinBtn = screen.getByRole("button", {
|
const unpinBtn = screen.getByRole("button", {
|
||||||
name: "Unpin creature",
|
name: "Unpin creature",
|
||||||
});
|
});
|
||||||
|
|||||||
28
apps/web/src/__tests__/test-providers.tsx
Normal file
28
apps/web/src/__tests__/test-providers.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
BestiaryProvider,
|
||||||
|
BulkImportProvider,
|
||||||
|
EncounterProvider,
|
||||||
|
InitiativeRollsProvider,
|
||||||
|
PlayerCharactersProvider,
|
||||||
|
SidePanelProvider,
|
||||||
|
ThemeProvider,
|
||||||
|
} from "../contexts/index.js";
|
||||||
|
|
||||||
|
export function AllProviders({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<EncounterProvider>
|
||||||
|
<BestiaryProvider>
|
||||||
|
<PlayerCharactersProvider>
|
||||||
|
<BulkImportProvider>
|
||||||
|
<SidePanelProvider>
|
||||||
|
<InitiativeRollsProvider>{children}</InitiativeRollsProvider>
|
||||||
|
</SidePanelProvider>
|
||||||
|
</BulkImportProvider>
|
||||||
|
</PlayerCharactersProvider>
|
||||||
|
</BestiaryProvider>
|
||||||
|
</EncounterProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,21 +3,59 @@ import "@testing-library/jest-dom/vitest";
|
|||||||
|
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
import { ActionBar } from "../action-bar";
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
|
import { ActionBar } from "../action-bar.js";
|
||||||
|
|
||||||
|
// Mock persistence — no localStorage interaction
|
||||||
|
vi.mock("../../persistence/encounter-storage.js", () => ({
|
||||||
|
loadEncounter: () => null,
|
||||||
|
saveEncounter: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../persistence/player-character-storage.js", () => ({
|
||||||
|
loadPlayerCharacters: () => [],
|
||||||
|
savePlayerCharacters: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock bestiary — no IndexedDB or JSON index
|
||||||
|
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
||||||
|
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
||||||
|
isSourceCached: () => Promise.resolve(false),
|
||||||
|
cacheSource: () => Promise.resolve(),
|
||||||
|
getCachedSources: () => Promise.resolve([]),
|
||||||
|
clearSource: () => Promise.resolve(),
|
||||||
|
clearAll: () => Promise.resolve(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
||||||
|
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||||
|
getAllSourceCodes: () => [],
|
||||||
|
getDefaultFetchUrl: () => "",
|
||||||
|
getSourceDisplayName: (code: string) => code,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// DOM API stubs — jsdom doesn't implement these
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
const defaultProps = {
|
function renderBar(props: Partial<Parameters<typeof ActionBar>[0]> = {}) {
|
||||||
onAddCombatant: vi.fn(),
|
return render(<ActionBar {...props} />, { wrapper: AllProviders });
|
||||||
onAddFromBestiary: vi.fn(),
|
|
||||||
bestiarySearch: () => [],
|
|
||||||
bestiaryLoaded: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
function renderBar(overrides: Partial<Parameters<typeof ActionBar>[0]> = {}) {
|
|
||||||
const props = { ...defaultProps, ...overrides };
|
|
||||||
return render(<ActionBar {...props} />);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("ActionBar", () => {
|
describe("ActionBar", () => {
|
||||||
@@ -26,26 +64,26 @@ describe("ActionBar", () => {
|
|||||||
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
|
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("submitting with a name calls onAddCombatant", async () => {
|
it("submitting with a name adds a combatant", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onAddCombatant = vi.fn();
|
renderBar();
|
||||||
renderBar({ onAddCombatant });
|
|
||||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
await user.type(input, "Goblin");
|
await user.type(input, "Goblin");
|
||||||
// The Add button appears when name >= 2 chars and no suggestions
|
// The Add button appears when name >= 2 chars and no suggestions
|
||||||
const addButton = screen.getByRole("button", { name: "Add" });
|
const addButton = screen.getByRole("button", { name: "Add" });
|
||||||
await user.click(addButton);
|
await user.click(addButton);
|
||||||
expect(onAddCombatant).toHaveBeenCalledWith("Goblin", undefined);
|
// Input is cleared after adding (context handles the state)
|
||||||
|
expect(input).toHaveValue("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("submitting with empty name does nothing", async () => {
|
it("submitting with empty name does nothing", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onAddCombatant = vi.fn();
|
renderBar();
|
||||||
renderBar({ onAddCombatant });
|
|
||||||
// Submit the form directly (Enter on empty input)
|
// Submit the form directly (Enter on empty input)
|
||||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
await user.type(input, "{Enter}");
|
await user.type(input, "{Enter}");
|
||||||
expect(onAddCombatant).not.toHaveBeenCalled();
|
// Input stays empty, no error
|
||||||
|
expect(input).toHaveValue("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows custom fields (Init, AC, MaxHP) when name >= 2 chars and no bestiary suggestions", async () => {
|
it("shows custom fields (Init, AC, MaxHP) when name >= 2 chars and no bestiary suggestions", async () => {
|
||||||
@@ -66,23 +104,18 @@ describe("ActionBar", () => {
|
|||||||
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows roll all initiative button when showRollAllInitiative is true", () => {
|
it("does not show roll all initiative button when no creature combatants", () => {
|
||||||
const onRollAllInitiative = vi.fn();
|
renderBar();
|
||||||
renderBar({ showRollAllInitiative: true, onRollAllInitiative });
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Roll all initiative" }),
|
screen.queryByRole("button", { name: "Roll all initiative" }),
|
||||||
).toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("roll all initiative button is disabled when rollAllInitiativeDisabled is true", () => {
|
it("shows overflow menu items", () => {
|
||||||
const onRollAllInitiative = vi.fn();
|
renderBar({ onManagePlayers: vi.fn() });
|
||||||
renderBar({
|
// The overflow menu should be present (it contains Player Characters etc.)
|
||||||
showRollAllInitiative: true,
|
|
||||||
onRollAllInitiative,
|
|
||||||
rollAllInitiativeDisabled: true,
|
|
||||||
});
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Roll all initiative" }),
|
screen.getByRole("button", { name: "More actions" }),
|
||||||
).toBeDisabled();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,33 +1,67 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
import { combatantId } from "@initiative/domain";
|
import { type CreatureId, combatantId } from "@initiative/domain";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
import { CombatantRow } from "../combatant-row";
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
import { PLAYER_COLOR_HEX } from "../player-icon-map";
|
import { CombatantRow } from "../combatant-row.js";
|
||||||
|
import { PLAYER_COLOR_HEX } from "../player-icon-map.js";
|
||||||
|
|
||||||
|
const TEMP_HP_REGEX = /^\+\d/;
|
||||||
|
|
||||||
|
// Mock persistence — no localStorage interaction
|
||||||
|
vi.mock("../../persistence/encounter-storage.js", () => ({
|
||||||
|
loadEncounter: () => null,
|
||||||
|
saveEncounter: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../persistence/player-character-storage.js", () => ({
|
||||||
|
loadPlayerCharacters: () => [],
|
||||||
|
savePlayerCharacters: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock bestiary — no IndexedDB or JSON index
|
||||||
|
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
||||||
|
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
||||||
|
isSourceCached: () => Promise.resolve(false),
|
||||||
|
cacheSource: () => Promise.resolve(),
|
||||||
|
getCachedSources: () => Promise.resolve([]),
|
||||||
|
clearSource: () => Promise.resolve(),
|
||||||
|
clearAll: () => Promise.resolve(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
||||||
|
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||||
|
getAllSourceCodes: () => [],
|
||||||
|
getDefaultFetchUrl: () => "",
|
||||||
|
getSourceDisplayName: (code: string) => code,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// DOM API stubs
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(cleanup);
|
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(
|
function renderRow(
|
||||||
overrides: Partial<{
|
overrides: Partial<{
|
||||||
combatant: Parameters<typeof CombatantRow>[0]["combatant"];
|
combatant: Parameters<typeof CombatantRow>[0]["combatant"];
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
onRollInitiative: (id: ReturnType<typeof combatantId>) => void;
|
|
||||||
onRemove: (id: ReturnType<typeof combatantId>) => void;
|
|
||||||
onShowStatBlock: () => void;
|
|
||||||
}> = {},
|
}> = {},
|
||||||
) {
|
) {
|
||||||
const combatant = overrides.combatant ?? {
|
const combatant = overrides.combatant ?? {
|
||||||
@@ -38,15 +72,13 @@ function renderRow(
|
|||||||
currentHp: 10,
|
currentHp: 10,
|
||||||
ac: 13,
|
ac: 13,
|
||||||
};
|
};
|
||||||
const props = {
|
return render(
|
||||||
...defaultProps,
|
<CombatantRow
|
||||||
combatant,
|
combatant={combatant}
|
||||||
isActive: overrides.isActive ?? false,
|
isActive={overrides.isActive ?? false}
|
||||||
onRollInitiative: overrides.onRollInitiative,
|
/>,
|
||||||
onShowStatBlock: overrides.onShowStatBlock,
|
{ wrapper: AllProviders },
|
||||||
onRemove: overrides.onRemove ?? defaultProps.onRemove,
|
);
|
||||||
};
|
|
||||||
return render(<CombatantRow {...props} />);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("CombatantRow", () => {
|
describe("CombatantRow", () => {
|
||||||
@@ -93,14 +125,14 @@ describe("CombatantRow", () => {
|
|||||||
expect(nameContainer).not.toBeNull();
|
expect(nameContainer).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows '--' for current HP when no maxHp is set", () => {
|
it("shows 'Max' placeholder when no maxHp is set", () => {
|
||||||
renderRow({
|
renderRow({
|
||||||
combatant: {
|
combatant: {
|
||||||
id: combatantId("1"),
|
id: combatantId("1"),
|
||||||
name: "Goblin",
|
name: "Goblin",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(screen.getByLabelText("No HP set")).toBeInTheDocument();
|
expect(screen.getByText("Max")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows concentration icon when isConcentrating is true", () => {
|
it("shows concentration icon when isConcentrating is true", () => {
|
||||||
@@ -132,10 +164,9 @@ describe("CombatantRow", () => {
|
|||||||
expect(svgIcon).toHaveStyle({ color: PLAYER_COLOR_HEX.red });
|
expect(svgIcon).toHaveStyle({ color: PLAYER_COLOR_HEX.red });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("remove button calls onRemove after confirmation", async () => {
|
it("remove button removes after confirmation", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onRemove = vi.fn();
|
renderRow();
|
||||||
renderRow({ onRemove });
|
|
||||||
const removeBtn = screen.getByRole("button", {
|
const removeBtn = screen.getByRole("button", {
|
||||||
name: "Remove combatant",
|
name: "Remove combatant",
|
||||||
});
|
});
|
||||||
@@ -146,19 +177,124 @@ describe("CombatantRow", () => {
|
|||||||
name: "Confirm remove combatant",
|
name: "Confirm remove combatant",
|
||||||
});
|
});
|
||||||
await user.click(confirmBtn);
|
await user.click(confirmBtn);
|
||||||
expect(onRemove).toHaveBeenCalledWith(combatantId("1"));
|
// After confirming, the button returns to its initial state
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Confirm remove combatant" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows d20 roll button when initiative is undefined and onRollInitiative is provided", () => {
|
it("shows d20 roll button when initiative is undefined and combatant has creatureId", () => {
|
||||||
renderRow({
|
renderRow({
|
||||||
combatant: {
|
combatant: {
|
||||||
id: combatantId("1"),
|
id: combatantId("1"),
|
||||||
name: "Goblin",
|
name: "Goblin",
|
||||||
|
creatureId: "srd:goblin" as CreatureId,
|
||||||
},
|
},
|
||||||
onRollInitiative: vi.fn(),
|
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Roll initiative" }),
|
screen.getByRole("button", { name: "Roll initiative" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("concentration pulse", () => {
|
||||||
|
it("pulses when currentHp drops on a concentrating combatant", () => {
|
||||||
|
const combatant = {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 15,
|
||||||
|
isConcentrating: true,
|
||||||
|
};
|
||||||
|
const { rerender, container } = renderRow({ combatant });
|
||||||
|
rerender(
|
||||||
|
<CombatantRow
|
||||||
|
combatant={{ ...combatant, currentHp: 10 }}
|
||||||
|
isActive={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const row = container.firstElementChild;
|
||||||
|
expect(row?.className).toContain("animate-concentration-pulse");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not pulse when not concentrating", () => {
|
||||||
|
const combatant = {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 15,
|
||||||
|
isConcentrating: false,
|
||||||
|
};
|
||||||
|
const { rerender, container } = renderRow({ combatant });
|
||||||
|
rerender(
|
||||||
|
<CombatantRow
|
||||||
|
combatant={{ ...combatant, currentHp: 10 }}
|
||||||
|
isActive={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const row = container.firstElementChild;
|
||||||
|
expect(row?.className).not.toContain("animate-concentration-pulse");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pulses when temp HP absorbs all damage on a concentrating combatant", () => {
|
||||||
|
const combatant = {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 15,
|
||||||
|
tempHp: 8,
|
||||||
|
isConcentrating: true,
|
||||||
|
};
|
||||||
|
const { rerender, container } = renderRow({ combatant });
|
||||||
|
// Temp HP absorbs all damage, currentHp unchanged
|
||||||
|
rerender(
|
||||||
|
<CombatantRow
|
||||||
|
combatant={{ ...combatant, tempHp: 3 }}
|
||||||
|
isActive={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const row = container.firstElementChild;
|
||||||
|
expect(row?.className).toContain("animate-concentration-pulse");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("temp HP display", () => {
|
||||||
|
it("shows +N when combatant has temp HP", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 15,
|
||||||
|
tempHp: 5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(screen.getByText("+5")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show +N when combatant has no temp HP", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(screen.queryByText(TEMP_HP_REGEX)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("temp HP display uses cyan color", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 15,
|
||||||
|
tempHp: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const tempHpEl = screen.getByText("+8");
|
||||||
|
expect(tempHpEl.className).toContain("text-cyan-400");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import "@testing-library/jest-dom/vitest";
|
|||||||
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
|
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { createRef, type RefObject } from "react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { ConditionPicker } from "../condition-picker";
|
import { ConditionPicker } from "../condition-picker";
|
||||||
|
|
||||||
@@ -18,8 +19,13 @@ function renderPicker(
|
|||||||
) {
|
) {
|
||||||
const onToggle = overrides.onToggle ?? vi.fn();
|
const onToggle = overrides.onToggle ?? vi.fn();
|
||||||
const onClose = overrides.onClose ?? vi.fn();
|
const onClose = overrides.onClose ?? vi.fn();
|
||||||
|
const anchorRef = createRef<HTMLElement>() as RefObject<HTMLElement>;
|
||||||
|
const anchor = document.createElement("div");
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
(anchorRef as { current: HTMLElement }).current = anchor;
|
||||||
const result = render(
|
const result = render(
|
||||||
<ConditionPicker
|
<ConditionPicker
|
||||||
|
anchorRef={anchorRef}
|
||||||
activeConditions={overrides.activeConditions ?? []}
|
activeConditions={overrides.activeConditions ?? []}
|
||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
|||||||
@@ -11,15 +11,21 @@ afterEach(cleanup);
|
|||||||
function renderPopover(
|
function renderPopover(
|
||||||
overrides: Partial<{
|
overrides: Partial<{
|
||||||
onAdjust: (delta: number) => void;
|
onAdjust: (delta: number) => void;
|
||||||
|
onSetTempHp: (value: number) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}> = {},
|
}> = {},
|
||||||
) {
|
) {
|
||||||
const onAdjust = overrides.onAdjust ?? vi.fn();
|
const onAdjust = overrides.onAdjust ?? vi.fn();
|
||||||
|
const onSetTempHp = overrides.onSetTempHp ?? vi.fn();
|
||||||
const onClose = overrides.onClose ?? vi.fn();
|
const onClose = overrides.onClose ?? vi.fn();
|
||||||
const result = render(
|
const result = render(
|
||||||
<HpAdjustPopover onAdjust={onAdjust} onClose={onClose} />,
|
<HpAdjustPopover
|
||||||
|
onAdjust={onAdjust}
|
||||||
|
onSetTempHp={onSetTempHp}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
return { ...result, onAdjust, onClose };
|
return { ...result, onAdjust, onSetTempHp, onClose };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("HpAdjustPopover", () => {
|
describe("HpAdjustPopover", () => {
|
||||||
@@ -112,4 +118,31 @@ describe("HpAdjustPopover", () => {
|
|||||||
await user.type(input, "12abc34");
|
await user.type(input, "12abc34");
|
||||||
expect(input).toHaveValue("1234");
|
expect(input).toHaveValue("1234");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("temp HP", () => {
|
||||||
|
it("shield button calls onSetTempHp with entered value and closes", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onSetTempHp, onClose } = renderPopover();
|
||||||
|
await user.type(screen.getByPlaceholderText("HP"), "8");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Set temp HP" }));
|
||||||
|
expect(onSetTempHp).toHaveBeenCalledWith(8);
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shield button is disabled when input is empty", () => {
|
||||||
|
renderPopover();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Set temp HP" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shield button is disabled when input is '0'", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPopover();
|
||||||
|
await user.type(screen.getByPlaceholderText("HP"), "0");
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Set temp HP" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,28 +11,51 @@ vi.mock("../../adapters/bestiary-cache.js", () => ({
|
|||||||
clearAll: vi.fn(),
|
clearAll: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock the context module
|
||||||
|
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||||
|
useBestiaryContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
import * as bestiaryCache from "../../adapters/bestiary-cache.js";
|
import * as bestiaryCache from "../../adapters/bestiary-cache.js";
|
||||||
import { SourceManager } from "../source-manager";
|
import { useBestiaryContext } from "../../contexts/bestiary-context.js";
|
||||||
|
import { SourceManager } from "../source-manager.js";
|
||||||
|
|
||||||
const mockGetCachedSources = vi.mocked(bestiaryCache.getCachedSources);
|
const mockGetCachedSources = vi.mocked(bestiaryCache.getCachedSources);
|
||||||
const mockClearSource = vi.mocked(bestiaryCache.clearSource);
|
const mockClearSource = vi.mocked(bestiaryCache.clearSource);
|
||||||
const mockClearAll = vi.mocked(bestiaryCache.clearAll);
|
const mockClearAll = vi.mocked(bestiaryCache.clearAll);
|
||||||
|
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function setupMockContext() {
|
||||||
|
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
mockUseBestiaryContext.mockReturnValue({
|
||||||
|
refreshCache,
|
||||||
|
search: vi.fn().mockReturnValue([]),
|
||||||
|
getCreature: vi.fn(),
|
||||||
|
isLoaded: true,
|
||||||
|
isSourceCached: vi.fn().mockResolvedValue(false),
|
||||||
|
fetchAndCacheSource: vi.fn(),
|
||||||
|
uploadAndCacheSource: vi.fn(),
|
||||||
|
} as ReturnType<typeof useBestiaryContext>);
|
||||||
|
return { refreshCache };
|
||||||
|
}
|
||||||
|
|
||||||
describe("SourceManager", () => {
|
describe("SourceManager", () => {
|
||||||
it("shows 'No cached sources' empty state when no sources", async () => {
|
it("shows 'No cached sources' empty state when no sources", async () => {
|
||||||
|
setupMockContext();
|
||||||
mockGetCachedSources.mockResolvedValue([]);
|
mockGetCachedSources.mockResolvedValue([]);
|
||||||
render(<SourceManager onCacheCleared={vi.fn()} />);
|
render(<SourceManager />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("No cached sources")).toBeInTheDocument();
|
expect(screen.getByText("No cached sources")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists cached sources with display name and creature count", async () => {
|
it("lists cached sources with display name and creature count", async () => {
|
||||||
|
setupMockContext();
|
||||||
mockGetCachedSources.mockResolvedValue([
|
mockGetCachedSources.mockResolvedValue([
|
||||||
{
|
{
|
||||||
sourceCode: "mm",
|
sourceCode: "mm",
|
||||||
@@ -47,7 +70,7 @@ describe("SourceManager", () => {
|
|||||||
cachedAt: Date.now(),
|
cachedAt: Date.now(),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
render(<SourceManager onCacheCleared={vi.fn()} />);
|
render(<SourceManager />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -56,9 +79,9 @@ describe("SourceManager", () => {
|
|||||||
expect(screen.getByText("100 creatures")).toBeInTheDocument();
|
expect(screen.getByText("100 creatures")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Clear All button calls cache clear and onCacheCleared", async () => {
|
it("Clear All button calls cache clear and refreshCache", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onCacheCleared = vi.fn();
|
const { refreshCache } = setupMockContext();
|
||||||
mockGetCachedSources
|
mockGetCachedSources
|
||||||
.mockResolvedValueOnce([
|
.mockResolvedValueOnce([
|
||||||
{
|
{
|
||||||
@@ -70,7 +93,7 @@ describe("SourceManager", () => {
|
|||||||
])
|
])
|
||||||
.mockResolvedValue([]);
|
.mockResolvedValue([]);
|
||||||
mockClearAll.mockResolvedValue(undefined);
|
mockClearAll.mockResolvedValue(undefined);
|
||||||
render(<SourceManager onCacheCleared={onCacheCleared} />);
|
render(<SourceManager />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||||
@@ -80,12 +103,12 @@ describe("SourceManager", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockClearAll).toHaveBeenCalled();
|
expect(mockClearAll).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
expect(onCacheCleared).toHaveBeenCalled();
|
expect(refreshCache).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("individual source delete button calls clear for that source", async () => {
|
it("individual source delete button calls clear for that source", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onCacheCleared = vi.fn();
|
const { refreshCache } = setupMockContext();
|
||||||
mockGetCachedSources
|
mockGetCachedSources
|
||||||
.mockResolvedValueOnce([
|
.mockResolvedValueOnce([
|
||||||
{
|
{
|
||||||
@@ -111,7 +134,7 @@ describe("SourceManager", () => {
|
|||||||
]);
|
]);
|
||||||
mockClearSource.mockResolvedValue(undefined);
|
mockClearSource.mockResolvedValue(undefined);
|
||||||
|
|
||||||
render(<SourceManager onCacheCleared={onCacheCleared} />);
|
render(<SourceManager />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -122,6 +145,6 @@ describe("SourceManager", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockClearSource).toHaveBeenCalledWith("mm");
|
expect(mockClearSource).toHaveBeenCalledWith("mm");
|
||||||
});
|
});
|
||||||
expect(onCacheCleared).toHaveBeenCalled();
|
expect(refreshCache).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,11 +5,23 @@ import type { Encounter } from "@initiative/domain";
|
|||||||
import { combatantId } from "@initiative/domain";
|
import { combatantId } from "@initiative/domain";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { TurnNavigation } from "../turn-navigation";
|
|
||||||
|
|
||||||
afterEach(cleanup);
|
// Mock the context module
|
||||||
|
vi.mock("../../contexts/encounter-context.js", () => ({
|
||||||
|
useEncounterContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
function renderNav(overrides: Partial<Encounter> = {}) {
|
import { useEncounterContext } from "../../contexts/encounter-context.js";
|
||||||
|
import { TurnNavigation } from "../turn-navigation.js";
|
||||||
|
|
||||||
|
const mockUseEncounterContext = vi.mocked(useEncounterContext);
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockContext(overrides: Partial<Encounter> = {}) {
|
||||||
const encounter: Encounter = {
|
const encounter: Encounter = {
|
||||||
combatants: [
|
combatants: [
|
||||||
{ id: combatantId("1"), name: "Goblin" },
|
{ id: combatantId("1"), name: "Goblin" },
|
||||||
@@ -20,14 +32,40 @@ function renderNav(overrides: Partial<Encounter> = {}) {
|
|||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
|
||||||
return render(
|
const value = {
|
||||||
<TurnNavigation
|
encounter,
|
||||||
encounter={encounter}
|
advanceTurn: vi.fn(),
|
||||||
onAdvanceTurn={vi.fn()}
|
retreatTurn: vi.fn(),
|
||||||
onRetreatTurn={vi.fn()}
|
clearEncounter: vi.fn(),
|
||||||
onClearEncounter={vi.fn()}
|
isEmpty: encounter.combatants.length === 0,
|
||||||
/>,
|
hasCreatureCombatants: false,
|
||||||
|
canRollAllInitiative: false,
|
||||||
|
addCombatant: vi.fn(),
|
||||||
|
removeCombatant: vi.fn(),
|
||||||
|
editCombatant: vi.fn(),
|
||||||
|
setInitiative: vi.fn(),
|
||||||
|
setHp: vi.fn(),
|
||||||
|
adjustHp: vi.fn(),
|
||||||
|
setTempHp: vi.fn(),
|
||||||
|
hasTempHp: false,
|
||||||
|
setAc: vi.fn(),
|
||||||
|
toggleCondition: vi.fn(),
|
||||||
|
toggleConcentration: vi.fn(),
|
||||||
|
addFromBestiary: vi.fn(),
|
||||||
|
addFromPlayerCharacter: vi.fn(),
|
||||||
|
makeStore: vi.fn(),
|
||||||
|
events: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockUseEncounterContext.mockReturnValue(
|
||||||
|
value as ReturnType<typeof useEncounterContext>,
|
||||||
);
|
);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNav(overrides: Partial<Encounter> = {}) {
|
||||||
|
mockContext(overrides);
|
||||||
|
return render(<TurnNavigation />);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("TurnNavigation", () => {
|
describe("TurnNavigation", () => {
|
||||||
@@ -49,80 +87,39 @@ describe("TurnNavigation", () => {
|
|||||||
|
|
||||||
it("does not render an em dash between round and name", () => {
|
it("does not render an em dash between round and name", () => {
|
||||||
const { container } = renderNav();
|
const { container } = renderNav();
|
||||||
expect(container.textContent).not.toContain("—");
|
expect(container.textContent).not.toContain("\u2014");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("round badge and combatant name are siblings in the center area", () => {
|
it("round badge and combatant name are siblings in the center area", () => {
|
||||||
renderNav();
|
renderNav();
|
||||||
const badge = screen.getByText("R1");
|
const badge = screen.getByText("R1");
|
||||||
const name = screen.getByText("Goblin");
|
const name = screen.getByText("Goblin");
|
||||||
expect(badge.parentElement).toBe(name.parentElement);
|
// badge text is inside inner span > outer span, name is a direct child
|
||||||
|
expect(badge.closest(".flex")).toBe(name.parentElement);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates the round badge when round changes", () => {
|
it("updates the round badge when round changes", () => {
|
||||||
const { rerender } = render(
|
mockContext({ roundNumber: 2 });
|
||||||
<TurnNavigation
|
const { rerender } = render(<TurnNavigation />);
|
||||||
encounter={{
|
|
||||||
combatants: [{ id: combatantId("1"), name: "Goblin" }],
|
|
||||||
activeIndex: 0,
|
|
||||||
roundNumber: 2,
|
|
||||||
}}
|
|
||||||
onAdvanceTurn={vi.fn()}
|
|
||||||
onRetreatTurn={vi.fn()}
|
|
||||||
onClearEncounter={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByText("R2")).toBeInTheDocument();
|
expect(screen.getByText("R2")).toBeInTheDocument();
|
||||||
|
|
||||||
rerender(
|
mockContext({ roundNumber: 3 });
|
||||||
<TurnNavigation
|
rerender(<TurnNavigation />);
|
||||||
encounter={{
|
|
||||||
combatants: [{ id: combatantId("1"), name: "Goblin" }],
|
|
||||||
activeIndex: 0,
|
|
||||||
roundNumber: 3,
|
|
||||||
}}
|
|
||||||
onAdvanceTurn={vi.fn()}
|
|
||||||
onRetreatTurn={vi.fn()}
|
|
||||||
onClearEncounter={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByText("R3")).toBeInTheDocument();
|
expect(screen.getByText("R3")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("R2")).not.toBeInTheDocument();
|
expect(screen.queryByText("R2")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the next combatant name when turn advances", () => {
|
it("renders the next combatant name when turn advances", () => {
|
||||||
const { rerender } = render(
|
const combatants = [
|
||||||
<TurnNavigation
|
|
||||||
encounter={{
|
|
||||||
combatants: [
|
|
||||||
{ id: combatantId("1"), name: "Goblin" },
|
{ id: combatantId("1"), name: "Goblin" },
|
||||||
{ id: combatantId("2"), name: "Conjurer" },
|
{ id: combatantId("2"), name: "Conjurer" },
|
||||||
],
|
];
|
||||||
activeIndex: 0,
|
mockContext({ combatants, activeIndex: 0 });
|
||||||
roundNumber: 1,
|
const { rerender } = render(<TurnNavigation />);
|
||||||
}}
|
|
||||||
onAdvanceTurn={vi.fn()}
|
|
||||||
onRetreatTurn={vi.fn()}
|
|
||||||
onClearEncounter={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
|
||||||
rerender(
|
mockContext({ combatants, activeIndex: 1 });
|
||||||
<TurnNavigation
|
rerender(<TurnNavigation />);
|
||||||
encounter={{
|
|
||||||
combatants: [
|
|
||||||
{ id: combatantId("1"), name: "Goblin" },
|
|
||||||
{ id: combatantId("2"), name: "Conjurer" },
|
|
||||||
],
|
|
||||||
activeIndex: 1,
|
|
||||||
roundNumber: 1,
|
|
||||||
}}
|
|
||||||
onAdvanceTurn={vi.fn()}
|
|
||||||
onRetreatTurn={vi.fn()}
|
|
||||||
onClearEncounter={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByText("Conjurer")).toBeInTheDocument();
|
expect(screen.getByText("Conjurer")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,17 +19,15 @@ export function AcShield({ value, onClick, className }: AcShieldProps) {
|
|||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 28 32"
|
viewBox="0 0 28 32"
|
||||||
fill="none"
|
fill="var(--color-border)"
|
||||||
stroke="currentColor"
|
fillOpacity={0.5}
|
||||||
strokeWidth={1.5}
|
stroke="none"
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className="absolute inset-0 h-full w-full"
|
className="absolute inset-0 h-full w-full"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path d="M14 1.5 L2.5 6.5 L2.5 15 Q2.5 25 14 30.5 Q25.5 25 25.5 15 L25.5 6.5 Z" />
|
<path d="M14 1.5 L2.5 6.5 L2.5 15 Q2.5 25 14 30.5 Q25.5 25 25.5 15 L25.5 6.5 Z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="relative font-medium text-xs leading-none">
|
<span className="relative -mt-0.5 font-medium text-xs leading-none">
|
||||||
{value == null ? "\u2014" : String(value)}
|
{value == null ? "\u2014" : String(value)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PlayerCharacter, RollMode } from "@initiative/domain";
|
import type { CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -18,11 +18,18 @@ import React, {
|
|||||||
useDeferredValue,
|
useDeferredValue,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type { SearchResult } from "../hooks/use-bestiary.js";
|
import type { SearchResult } from "../contexts/bestiary-context.js";
|
||||||
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
|
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||||
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
|
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
||||||
|
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||||
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
|
import { useThemeContext } from "../contexts/theme-context.js";
|
||||||
import { useLongPress } from "../hooks/use-long-press.js";
|
import { useLongPress } from "../hooks/use-long-press.js";
|
||||||
import { cn } from "../lib/utils.js";
|
import { cn } from "../lib/utils.js";
|
||||||
import { D20Icon } from "./d20-icon.js";
|
import { D20Icon } from "./d20-icon.js";
|
||||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
|
||||||
import { RollModeMenu } from "./roll-mode-menu.js";
|
import { RollModeMenu } from "./roll-mode-menu.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
import { Input } from "./ui/input.js";
|
import { Input } from "./ui/input.js";
|
||||||
@@ -34,27 +41,9 @@ interface QueuedCreature {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ActionBarProps {
|
interface ActionBarProps {
|
||||||
onAddCombatant: (
|
|
||||||
name: string,
|
|
||||||
opts?: { initiative?: number; ac?: number; maxHp?: number },
|
|
||||||
) => void;
|
|
||||||
onAddFromBestiary: (result: SearchResult) => void;
|
|
||||||
bestiarySearch: (query: string) => SearchResult[];
|
|
||||||
bestiaryLoaded: boolean;
|
|
||||||
onViewStatBlock?: (result: SearchResult) => void;
|
|
||||||
onBulkImport?: () => void;
|
|
||||||
bulkImportDisabled?: boolean;
|
|
||||||
inputRef?: RefObject<HTMLInputElement | null>;
|
inputRef?: RefObject<HTMLInputElement | null>;
|
||||||
playerCharacters?: readonly PlayerCharacter[];
|
|
||||||
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
|
||||||
onManagePlayers?: () => void;
|
|
||||||
onRollAllInitiative?: (mode?: RollMode) => void;
|
|
||||||
showRollAllInitiative?: boolean;
|
|
||||||
rollAllInitiativeDisabled?: boolean;
|
|
||||||
onOpenSourceManager?: () => void;
|
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
themePreference?: "system" | "light" | "dark";
|
onManagePlayers?: () => void;
|
||||||
onCycleTheme?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function creatureKey(r: SearchResult): string {
|
function creatureKey(r: SearchResult): string {
|
||||||
@@ -285,25 +274,48 @@ function buildOverflowItems(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ActionBar({
|
export function ActionBar({
|
||||||
onAddCombatant,
|
|
||||||
onAddFromBestiary,
|
|
||||||
bestiarySearch,
|
|
||||||
bestiaryLoaded,
|
|
||||||
onViewStatBlock,
|
|
||||||
onBulkImport,
|
|
||||||
bulkImportDisabled,
|
|
||||||
inputRef,
|
inputRef,
|
||||||
playerCharacters,
|
|
||||||
onAddFromPlayerCharacter,
|
|
||||||
onManagePlayers,
|
|
||||||
onRollAllInitiative,
|
|
||||||
showRollAllInitiative,
|
|
||||||
rollAllInitiativeDisabled,
|
|
||||||
onOpenSourceManager,
|
|
||||||
autoFocus,
|
autoFocus,
|
||||||
themePreference,
|
onManagePlayers,
|
||||||
onCycleTheme,
|
|
||||||
}: Readonly<ActionBarProps>) {
|
}: Readonly<ActionBarProps>) {
|
||||||
|
const {
|
||||||
|
addCombatant,
|
||||||
|
addFromBestiary,
|
||||||
|
addFromPlayerCharacter,
|
||||||
|
hasCreatureCombatants,
|
||||||
|
canRollAllInitiative,
|
||||||
|
} = useEncounterContext();
|
||||||
|
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
|
||||||
|
useBestiaryContext();
|
||||||
|
const { characters: playerCharacters } = usePlayerCharactersContext();
|
||||||
|
const { showBulkImport, showSourceManager, showCreature, panelView } =
|
||||||
|
useSidePanelContext();
|
||||||
|
const { preference: themePreference, cycleTheme } = useThemeContext();
|
||||||
|
const { handleRollAllInitiative } = useInitiativeRollsContext();
|
||||||
|
const { state: bulkImportState } = useBulkImportContext();
|
||||||
|
|
||||||
|
const handleAddFromBestiary = useCallback(
|
||||||
|
(result: SearchResult) => {
|
||||||
|
const creatureId = addFromBestiary(result);
|
||||||
|
if (creatureId && panelView.mode === "closed") {
|
||||||
|
showCreature(creatureId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addFromBestiary, panelView.mode, showCreature],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleViewStatBlock = useCallback(
|
||||||
|
(result: SearchResult) => {
|
||||||
|
const slug = result.name
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||||
|
.replaceAll(/(^-|-$)/g, "");
|
||||||
|
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
||||||
|
showCreature(cId);
|
||||||
|
},
|
||||||
|
[showCreature],
|
||||||
|
);
|
||||||
|
|
||||||
const [nameInput, setNameInput] = useState("");
|
const [nameInput, setNameInput] = useState("");
|
||||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||||
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
||||||
@@ -340,7 +352,7 @@ export function ActionBar({
|
|||||||
const confirmQueued = () => {
|
const confirmQueued = () => {
|
||||||
if (!queued) return;
|
if (!queued) return;
|
||||||
for (let i = 0; i < queued.count; i++) {
|
for (let i = 0; i < queued.count; i++) {
|
||||||
onAddFromBestiary(queued.result);
|
handleAddFromBestiary(queued.result);
|
||||||
}
|
}
|
||||||
clearInput();
|
clearInput();
|
||||||
};
|
};
|
||||||
@@ -366,7 +378,7 @@ export function ActionBar({
|
|||||||
if (init !== undefined) opts.initiative = init;
|
if (init !== undefined) opts.initiative = init;
|
||||||
if (ac !== undefined) opts.ac = ac;
|
if (ac !== undefined) opts.ac = ac;
|
||||||
if (maxHp !== undefined) opts.maxHp = maxHp;
|
if (maxHp !== undefined) opts.maxHp = maxHp;
|
||||||
onAddCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
|
addCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
|
||||||
setNameInput("");
|
setNameInput("");
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
setPcMatches([]);
|
setPcMatches([]);
|
||||||
@@ -468,21 +480,30 @@ export function ActionBar({
|
|||||||
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
||||||
} else if (e.key === "Enter" && suggestionIndex >= 0) {
|
} else if (e.key === "Enter" && suggestionIndex >= 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onViewStatBlock?.(suggestions[suggestionIndex]);
|
handleViewStatBlock(suggestions[suggestionIndex]);
|
||||||
setBrowseMode(false);
|
setBrowseMode(false);
|
||||||
clearInput();
|
clearInput();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBrowseSelect = (result: SearchResult) => {
|
const handleBrowseSelect = (result: SearchResult) => {
|
||||||
onViewStatBlock?.(result);
|
handleViewStatBlock(result);
|
||||||
setBrowseMode(false);
|
setBrowseMode(false);
|
||||||
clearInput();
|
clearInput();
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleBrowseMode = () => {
|
const toggleBrowseMode = () => {
|
||||||
setBrowseMode((m) => !m);
|
setBrowseMode((prev) => {
|
||||||
clearInput();
|
const next = !prev;
|
||||||
|
setSuggestionIndex(-1);
|
||||||
|
setQueued(null);
|
||||||
|
if (next) {
|
||||||
|
handleBrowseSearch(nameInput);
|
||||||
|
} else {
|
||||||
|
handleAddSearch(nameInput);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
clearCustomFields();
|
clearCustomFields();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -507,12 +528,12 @@ export function ActionBar({
|
|||||||
|
|
||||||
const overflowItems = buildOverflowItems({
|
const overflowItems = buildOverflowItems({
|
||||||
onManagePlayers,
|
onManagePlayers,
|
||||||
onOpenSourceManager,
|
onOpenSourceManager: showSourceManager,
|
||||||
bestiaryLoaded,
|
bestiaryLoaded,
|
||||||
onBulkImport,
|
onBulkImport: showBulkImport,
|
||||||
bulkImportDisabled,
|
bulkImportDisabled: bulkImportState.status === "loading",
|
||||||
themePreference,
|
themePreference,
|
||||||
onCycleTheme,
|
onCycleTheme: cycleTheme,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -535,7 +556,7 @@ export function ActionBar({
|
|||||||
className="pr-8"
|
className="pr-8"
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
/>
|
/>
|
||||||
{bestiaryLoaded && !!onViewStatBlock && (
|
{!!bestiaryLoaded && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -543,6 +564,7 @@ export function ActionBar({
|
|||||||
"absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
|
"absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
|
||||||
browseMode && "text-accent",
|
browseMode && "text-accent",
|
||||||
)}
|
)}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={toggleBrowseMode}
|
onClick={toggleBrowseMode}
|
||||||
title={browseMode ? "Switch to add mode" : "Browse stat blocks"}
|
title={browseMode ? "Switch to add mode" : "Browse stat blocks"}
|
||||||
aria-label={
|
aria-label={
|
||||||
@@ -596,7 +618,7 @@ export function ActionBar({
|
|||||||
onSetSuggestionIndex={setSuggestionIndex}
|
onSetSuggestionIndex={setSuggestionIndex}
|
||||||
onSetQueued={setQueued}
|
onSetQueued={setQueued}
|
||||||
onConfirmQueued={confirmQueued}
|
onConfirmQueued={confirmQueued}
|
||||||
onAddFromPlayerCharacter={onAddFromPlayerCharacter}
|
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -632,20 +654,20 @@ export function ActionBar({
|
|||||||
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||||
<Button type="submit">Add</Button>
|
<Button type="submit">Add</Button>
|
||||||
)}
|
)}
|
||||||
{showRollAllInitiative && !!onRollAllInitiative && (
|
{!!hasCreatureCombatants && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-muted-foreground hover:text-hover-action"
|
className="text-muted-foreground hover:text-hover-action"
|
||||||
onClick={() => onRollAllInitiative()}
|
onClick={() => handleRollAllInitiative()}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openRollAllMenu(e.clientX, e.clientY);
|
openRollAllMenu(e.clientX, e.clientY);
|
||||||
}}
|
}}
|
||||||
{...rollAllLongPress}
|
{...rollAllLongPress}
|
||||||
disabled={rollAllInitiativeDisabled}
|
disabled={!canRollAllInitiative}
|
||||||
title="Roll all initiative"
|
title="Roll all initiative"
|
||||||
aria-label="Roll all initiative"
|
aria-label="Roll all initiative"
|
||||||
>
|
>
|
||||||
@@ -654,7 +676,7 @@ export function ActionBar({
|
|||||||
{!!rollAllMenuPos && (
|
{!!rollAllMenuPos && (
|
||||||
<RollModeMenu
|
<RollModeMenu
|
||||||
position={rollAllMenuPos}
|
position={rollAllMenuPos}
|
||||||
onSelect={(mode) => onRollAllInitiative(mode)}
|
onSelect={(mode) => handleRollAllInitiative(mode)}
|
||||||
onClose={() => setRollAllMenuPos(null)}
|
onClose={() => setRollAllMenuPos(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,35 +1,41 @@
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useId, useState } from "react";
|
import { useId, useState } from "react";
|
||||||
import { getAllSourceCodes } from "../adapters/bestiary-index-adapter.js";
|
import { getAllSourceCodes } from "../adapters/bestiary-index-adapter.js";
|
||||||
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
|
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||||
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
import { Input } from "./ui/input.js";
|
import { Input } from "./ui/input.js";
|
||||||
|
|
||||||
const DEFAULT_BASE_URL =
|
const DEFAULT_BASE_URL =
|
||||||
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
|
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
|
||||||
|
|
||||||
interface BulkImportPromptProps {
|
export function BulkImportPrompt() {
|
||||||
importState: BulkImportState;
|
const { fetchAndCacheSource, isSourceCached, refreshCache } =
|
||||||
onStartImport: (baseUrl: string) => void;
|
useBestiaryContext();
|
||||||
onDone: () => void;
|
const { state: importState, startImport, reset } = useBulkImportContext();
|
||||||
}
|
const { dismissPanel } = useSidePanelContext();
|
||||||
|
|
||||||
export function BulkImportPrompt({
|
|
||||||
importState,
|
|
||||||
onStartImport,
|
|
||||||
onDone,
|
|
||||||
}: Readonly<BulkImportPromptProps>) {
|
|
||||||
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
|
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
|
||||||
const baseUrlId = useId();
|
const baseUrlId = useId();
|
||||||
const totalSources = getAllSourceCodes().length;
|
const totalSources = getAllSourceCodes().length;
|
||||||
|
|
||||||
|
const handleStart = (url: string) => {
|
||||||
|
startImport(url, fetchAndCacheSource, isSourceCached, refreshCache);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDone = () => {
|
||||||
|
dismissPanel();
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
|
||||||
if (importState.status === "complete") {
|
if (importState.status === "complete") {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-green-400 text-sm">
|
<div className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-green-400 text-sm">
|
||||||
All sources loaded
|
All sources loaded
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={onDone}>Done</Button>
|
<Button onClick={handleDone}>Done</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -41,7 +47,7 @@ export function BulkImportPrompt({
|
|||||||
Loaded {importState.completed}/{importState.total} sources (
|
Loaded {importState.completed}/{importState.total} sources (
|
||||||
{importState.failed} failed)
|
{importState.failed} failed)
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={onDone}>Done</Button>
|
<Button onClick={handleDone}>Done</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -96,7 +102,7 @@ export function BulkImportPrompt({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button onClick={() => onStartImport(baseUrl)} disabled={isDisabled}>
|
<Button onClick={() => handleStart(baseUrl)} disabled={isDisabled}>
|
||||||
Load All
|
Load All
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||||
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
import { Toast } from "./toast.js";
|
import { Toast } from "./toast.js";
|
||||||
|
|
||||||
interface BulkImportToastsProps {
|
export function BulkImportToasts() {
|
||||||
state: BulkImportState;
|
const { state, reset } = useBulkImportContext();
|
||||||
visible: boolean;
|
const { bulkImportMode, isRightPanelCollapsed } = useSidePanelContext();
|
||||||
onReset: () => void;
|
const visible = !bulkImportMode || isRightPanelCollapsed;
|
||||||
}
|
|
||||||
|
|
||||||
export function BulkImportToasts({
|
|
||||||
state,
|
|
||||||
visible,
|
|
||||||
onReset,
|
|
||||||
}: Readonly<BulkImportToastsProps>) {
|
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
if (state.status === "loading") {
|
if (state.status === "loading") {
|
||||||
@@ -30,7 +25,7 @@ export function BulkImportToasts({
|
|||||||
return (
|
return (
|
||||||
<Toast
|
<Toast
|
||||||
message="All sources loaded"
|
message="All sources loaded"
|
||||||
onDismiss={onReset}
|
onDismiss={reset}
|
||||||
autoDismissMs={3000}
|
autoDismissMs={3000}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -40,7 +35,7 @@ export function BulkImportToasts({
|
|||||||
return (
|
return (
|
||||||
<Toast
|
<Toast
|
||||||
message={`Loaded ${state.completed}/${state.total} sources (${state.failed} failed)`}
|
message={`Loaded ${state.completed}/${state.total} sources (${state.failed} failed)`}
|
||||||
onDismiss={onReset}
|
onDismiss={reset}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,27 @@
|
|||||||
import {
|
import {
|
||||||
type CombatantId,
|
type CombatantId,
|
||||||
type ConditionId,
|
type ConditionId,
|
||||||
|
type CreatureId,
|
||||||
deriveHpStatus,
|
deriveHpStatus,
|
||||||
type PlayerIcon,
|
type PlayerIcon,
|
||||||
type RollMode,
|
type RollMode,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { Book, BookOpen, Brain, X } from "lucide-react";
|
import { Brain, Pencil, X } from "lucide-react";
|
||||||
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
|
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useLongPress } from "../hooks/use-long-press";
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
import { cn } from "../lib/utils";
|
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
||||||
import { AcShield } from "./ac-shield";
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
import { ConditionPicker } from "./condition-picker";
|
import { useLongPress } from "../hooks/use-long-press.js";
|
||||||
import { ConditionTags } from "./condition-tags";
|
import { cn } from "../lib/utils.js";
|
||||||
import { D20Icon } from "./d20-icon";
|
import { AcShield } from "./ac-shield.js";
|
||||||
import { HpAdjustPopover } from "./hp-adjust-popover";
|
import { ConditionPicker } from "./condition-picker.js";
|
||||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
import { ConditionTags } from "./condition-tags.js";
|
||||||
import { RollModeMenu } from "./roll-mode-menu";
|
import { D20Icon } from "./d20-icon.js";
|
||||||
import { ConfirmButton } from "./ui/confirm-button";
|
import { HpAdjustPopover } from "./hp-adjust-popover.js";
|
||||||
import { Input } from "./ui/input";
|
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
|
||||||
|
import { RollModeMenu } from "./roll-mode-menu.js";
|
||||||
|
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||||
|
import { Input } from "./ui/input.js";
|
||||||
|
|
||||||
interface Combatant {
|
interface Combatant {
|
||||||
readonly id: CombatantId;
|
readonly id: CombatantId;
|
||||||
@@ -25,27 +29,18 @@ interface Combatant {
|
|||||||
readonly initiative?: number;
|
readonly initiative?: number;
|
||||||
readonly maxHp?: number;
|
readonly maxHp?: number;
|
||||||
readonly currentHp?: number;
|
readonly currentHp?: number;
|
||||||
|
readonly tempHp?: number;
|
||||||
readonly ac?: number;
|
readonly ac?: number;
|
||||||
readonly conditions?: readonly ConditionId[];
|
readonly conditions?: readonly ConditionId[];
|
||||||
readonly isConcentrating?: boolean;
|
readonly isConcentrating?: boolean;
|
||||||
readonly color?: string;
|
readonly color?: string;
|
||||||
readonly icon?: string;
|
readonly icon?: string;
|
||||||
|
readonly creatureId?: CreatureId;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CombatantRowProps {
|
interface CombatantRowProps {
|
||||||
combatant: Combatant;
|
combatant: Combatant;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
onRename: (id: CombatantId, newName: string) => void;
|
|
||||||
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
|
|
||||||
onRemove: (id: CombatantId) => void;
|
|
||||||
onSetHp: (id: CombatantId, maxHp: number | undefined) => void;
|
|
||||||
onAdjustHp: (id: CombatantId, delta: number) => void;
|
|
||||||
onSetAc: (id: CombatantId, value: number | undefined) => void;
|
|
||||||
onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void;
|
|
||||||
onToggleConcentration: (id: CombatantId) => void;
|
|
||||||
onShowStatBlock?: () => void;
|
|
||||||
isStatBlockOpen?: boolean;
|
|
||||||
onRollInitiative?: (id: CombatantId, mode?: RollMode) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditableName({
|
function EditableName({
|
||||||
@@ -53,11 +48,13 @@ function EditableName({
|
|||||||
combatantId,
|
combatantId,
|
||||||
onRename,
|
onRename,
|
||||||
color,
|
color,
|
||||||
|
onToggleStatBlock,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
name: string;
|
name: string;
|
||||||
combatantId: CombatantId;
|
combatantId: CombatantId;
|
||||||
onRename: (id: CombatantId, newName: string) => void;
|
onRename: (id: CombatantId, newName: string) => void;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
onToggleStatBlock?: () => void;
|
||||||
}>) {
|
}>) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState(name);
|
const [draft, setDraft] = useState(name);
|
||||||
@@ -95,14 +92,31 @@ function EditableName({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={startEditing}
|
onClick={onToggleStatBlock}
|
||||||
className="cursor-text truncate text-left text-foreground text-sm transition-colors hover:text-hover-neutral"
|
disabled={!onToggleStatBlock}
|
||||||
|
className={cn(
|
||||||
|
"truncate text-left text-sm transition-colors",
|
||||||
|
onToggleStatBlock
|
||||||
|
? "cursor-pointer text-foreground hover:text-hover-neutral"
|
||||||
|
: "cursor-default text-foreground",
|
||||||
|
)}
|
||||||
style={color ? { color } : undefined}
|
style={color ? { color } : undefined}
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={startEditing}
|
||||||
|
title="Rename"
|
||||||
|
aria-label="Rename"
|
||||||
|
className="inline-flex shrink-0 items-center rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-colors transition-opacity hover:bg-hover-neutral-bg hover:text-hover-neutral focus:opacity-100 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +172,12 @@ function MaxHpDisplay({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={startEditing}
|
onClick={startEditing}
|
||||||
className="inline-block h-7 min-w-[3ch] text-center text-muted-foreground text-sm tabular-nums leading-7 transition-colors hover:text-hover-neutral"
|
className={cn(
|
||||||
|
"inline-block h-7 min-w-[3ch] text-center leading-7 transition-colors hover:text-hover-neutral",
|
||||||
|
maxHp === undefined
|
||||||
|
? "text-muted-foreground text-sm"
|
||||||
|
: "text-muted-foreground text-xs",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{maxHp ?? "Max"}
|
{maxHp ?? "Max"}
|
||||||
</button>
|
</button>
|
||||||
@@ -168,51 +187,47 @@ function MaxHpDisplay({
|
|||||||
function ClickableHp({
|
function ClickableHp({
|
||||||
currentHp,
|
currentHp,
|
||||||
maxHp,
|
maxHp,
|
||||||
|
tempHp,
|
||||||
onAdjust,
|
onAdjust,
|
||||||
dimmed,
|
onSetTempHp,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
currentHp: number | undefined;
|
currentHp: number | undefined;
|
||||||
maxHp: number | undefined;
|
maxHp: number | undefined;
|
||||||
|
tempHp: number | undefined;
|
||||||
onAdjust: (delta: number) => void;
|
onAdjust: (delta: number) => void;
|
||||||
dimmed?: boolean;
|
onSetTempHp: (value: number) => void;
|
||||||
}>) {
|
}>) {
|
||||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||||
const status = deriveHpStatus(currentHp, maxHp);
|
const status = deriveHpStatus(currentHp, maxHp);
|
||||||
|
|
||||||
if (maxHp === undefined) {
|
if (maxHp === undefined) {
|
||||||
return (
|
return null;
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-block h-7 w-[4ch] text-center text-muted-foreground text-sm tabular-nums leading-7",
|
|
||||||
dimmed && "opacity-50",
|
|
||||||
)}
|
|
||||||
role="status"
|
|
||||||
aria-label="No HP set"
|
|
||||||
>
|
|
||||||
--
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative flex items-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPopoverOpen(true)}
|
onClick={() => setPopoverOpen(true)}
|
||||||
aria-label={`Current HP: ${currentHp} (${status})`}
|
aria-label={`Current HP: ${currentHp}${tempHp ? ` (+${tempHp} temp)` : ""} (${status})`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-block h-7 min-w-[3ch] text-center font-medium text-sm tabular-nums leading-7 transition-colors hover:text-hover-neutral",
|
"inline-block h-7 min-w-[3ch] text-center font-medium text-sm leading-7 transition-colors hover:text-hover-neutral",
|
||||||
status === "bloodied" && "text-amber-400",
|
status === "bloodied" && "text-amber-400",
|
||||||
status === "unconscious" && "text-red-400",
|
status === "unconscious" && "text-red-400",
|
||||||
status === "healthy" && "text-foreground",
|
status === "healthy" && "text-foreground",
|
||||||
dimmed && "opacity-50",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{currentHp}
|
{currentHp}
|
||||||
</button>
|
</button>
|
||||||
|
{!!tempHp && (
|
||||||
|
<span className="font-medium text-cyan-400 text-sm leading-7">
|
||||||
|
+{tempHp}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{!!popoverOpen && (
|
{!!popoverOpen && (
|
||||||
<HpAdjustPopover
|
<HpAdjustPopover
|
||||||
onAdjust={onAdjust}
|
onAdjust={onAdjust}
|
||||||
|
onSetTempHp={onSetTempHp}
|
||||||
onClose={() => setPopoverOpen(false)}
|
onClose={() => setPopoverOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -333,7 +348,7 @@ function InitiativeDisplay({
|
|||||||
value={draft}
|
value={draft}
|
||||||
placeholder="--"
|
placeholder="--"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-7 w-[6ch] text-center text-sm tabular-nums",
|
"h-7 w-full text-center text-sm tabular-nums",
|
||||||
dimmed && "opacity-50",
|
dimmed && "opacity-50",
|
||||||
)}
|
)}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
@@ -346,7 +361,7 @@ function InitiativeDisplay({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empty + bestiary creature → d20 roll button
|
// Empty + bestiary creature -> d20 roll button
|
||||||
if (initiative === undefined && onRollInitiative) {
|
if (initiative === undefined && onRollInitiative) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -378,8 +393,8 @@ function InitiativeDisplay({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Has value → bold number, click to edit
|
// Has value -> bold number, click to edit
|
||||||
// Empty + manual → "--" placeholder, click to edit
|
// Empty + manual -> "--" placeholder, click to edit
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -423,42 +438,67 @@ export function CombatantRow({
|
|||||||
ref,
|
ref,
|
||||||
combatant,
|
combatant,
|
||||||
isActive,
|
isActive,
|
||||||
onRename,
|
|
||||||
onSetInitiative,
|
|
||||||
onRemove,
|
|
||||||
onSetHp,
|
|
||||||
onAdjustHp,
|
|
||||||
onSetAc,
|
|
||||||
onToggleCondition,
|
|
||||||
onToggleConcentration,
|
|
||||||
onShowStatBlock,
|
|
||||||
isStatBlockOpen,
|
|
||||||
onRollInitiative,
|
|
||||||
}: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) {
|
}: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) {
|
||||||
|
const {
|
||||||
|
editCombatant,
|
||||||
|
setInitiative,
|
||||||
|
removeCombatant,
|
||||||
|
setHp,
|
||||||
|
adjustHp,
|
||||||
|
setTempHp,
|
||||||
|
setAc,
|
||||||
|
toggleCondition,
|
||||||
|
toggleConcentration,
|
||||||
|
} = useEncounterContext();
|
||||||
|
const { selectedCreatureId, showCreature, toggleCollapse } =
|
||||||
|
useSidePanelContext();
|
||||||
|
const { handleRollInitiative } = useInitiativeRollsContext();
|
||||||
|
|
||||||
|
// Derive what was previously conditional props
|
||||||
|
const isStatBlockOpen = combatant.creatureId === selectedCreatureId;
|
||||||
|
const { creatureId } = combatant;
|
||||||
|
const hasStatBlock = !!creatureId;
|
||||||
|
const onToggleStatBlock = hasStatBlock
|
||||||
|
? () => {
|
||||||
|
if (isStatBlockOpen) {
|
||||||
|
toggleCollapse();
|
||||||
|
} else {
|
||||||
|
showCreature(creatureId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
const onRollInitiative = combatant.creatureId
|
||||||
|
? handleRollInitiative
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const { id, name, initiative, maxHp, currentHp } = combatant;
|
const { id, name, initiative, maxHp, currentHp } = combatant;
|
||||||
const status = deriveHpStatus(currentHp, maxHp);
|
const status = deriveHpStatus(currentHp, maxHp);
|
||||||
const dimmed = status === "unconscious";
|
const dimmed = status === "unconscious";
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
const conditionAnchorRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const prevHpRef = useRef(currentHp);
|
const prevHpRef = useRef(currentHp);
|
||||||
|
const prevTempHpRef = useRef(combatant.tempHp);
|
||||||
const [isPulsing, setIsPulsing] = useState(false);
|
const [isPulsing, setIsPulsing] = useState(false);
|
||||||
const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prevHp = prevHpRef.current;
|
const prevHp = prevHpRef.current;
|
||||||
|
const prevTempHp = prevTempHpRef.current;
|
||||||
prevHpRef.current = currentHp;
|
prevHpRef.current = currentHp;
|
||||||
|
prevTempHpRef.current = combatant.tempHp;
|
||||||
|
|
||||||
if (
|
const realHpDropped =
|
||||||
prevHp !== undefined &&
|
prevHp !== undefined && currentHp !== undefined && currentHp < prevHp;
|
||||||
currentHp !== undefined &&
|
const tempHpDropped =
|
||||||
currentHp < prevHp &&
|
prevTempHp !== undefined && (combatant.tempHp ?? 0) < prevTempHp;
|
||||||
combatant.isConcentrating
|
|
||||||
) {
|
if ((realHpDropped || tempHpDropped) && combatant.isConcentrating) {
|
||||||
setIsPulsing(true);
|
setIsPulsing(true);
|
||||||
clearTimeout(pulseTimerRef.current);
|
clearTimeout(pulseTimerRef.current);
|
||||||
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
|
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
|
||||||
}
|
}
|
||||||
}, [currentHp, combatant.isConcentrating]);
|
}, [currentHp, combatant.tempHp, combatant.isConcentrating]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!combatant.isConcentrating) {
|
if (!combatant.isConcentrating) {
|
||||||
@@ -480,11 +520,11 @@ export function CombatantRow({
|
|||||||
isPulsing && "animate-concentration-pulse",
|
isPulsing && "animate-concentration-pulse",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-[2rem_3rem_1fr_auto_auto_2rem] items-center gap-3 py-2">
|
<div className="grid grid-cols-[2rem_3.5rem_auto_1fr_auto_2rem] items-center gap-3 py-2">
|
||||||
{/* Concentration */}
|
{/* Concentration */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onToggleConcentration(id)}
|
onClick={() => toggleConcentration(id)}
|
||||||
title="Concentrating"
|
title="Concentrating"
|
||||||
aria-label="Toggle concentration"
|
aria-label="Toggle concentration"
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -496,13 +536,20 @@ export function CombatantRow({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Initiative */}
|
{/* Initiative */}
|
||||||
|
<div className="rounded-md bg-muted/30 px-1">
|
||||||
<InitiativeDisplay
|
<InitiativeDisplay
|
||||||
initiative={initiative}
|
initiative={initiative}
|
||||||
combatantId={id}
|
combatantId={id}
|
||||||
dimmed={dimmed}
|
dimmed={dimmed}
|
||||||
onSetInitiative={onSetInitiative}
|
onSetInitiative={setInitiative}
|
||||||
onRollInitiative={onRollInitiative}
|
onRollInitiative={onRollInitiative}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AC */}
|
||||||
|
<div className={cn(dimmed && "opacity-50")}>
|
||||||
|
<AcDisplay ac={combatant.ac} onCommit={(v) => setAc(id, v)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Name + Conditions */}
|
{/* Name + Conditions */}
|
||||||
<div
|
<div
|
||||||
@@ -511,17 +558,6 @@ export function CombatantRow({
|
|||||||
dimmed && "opacity-50",
|
dimmed && "opacity-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!!onShowStatBlock && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onShowStatBlock}
|
|
||||||
title="View stat block"
|
|
||||||
aria-label="View stat block"
|
|
||||||
className="shrink-0 text-foreground transition-colors hover:text-hover-neutral"
|
|
||||||
>
|
|
||||||
{isStatBlockOpen ? <BookOpen size={16} /> : <Book size={16} />}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{!!combatant.icon &&
|
{!!combatant.icon &&
|
||||||
!!combatant.color &&
|
!!combatant.color &&
|
||||||
(() => {
|
(() => {
|
||||||
@@ -541,56 +577,55 @@ export function CombatantRow({
|
|||||||
<EditableName
|
<EditableName
|
||||||
name={name}
|
name={name}
|
||||||
combatantId={id}
|
combatantId={id}
|
||||||
onRename={onRename}
|
onRename={editCombatant}
|
||||||
color={pcColor}
|
color={pcColor}
|
||||||
|
onToggleStatBlock={onToggleStatBlock}
|
||||||
/>
|
/>
|
||||||
|
<div ref={conditionAnchorRef}>
|
||||||
<ConditionTags
|
<ConditionTags
|
||||||
conditions={combatant.conditions}
|
conditions={combatant.conditions}
|
||||||
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
|
onRemove={(conditionId) => toggleCondition(id, conditionId)}
|
||||||
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
{!!pickerOpen && (
|
{!!pickerOpen && (
|
||||||
<ConditionPicker
|
<ConditionPicker
|
||||||
|
anchorRef={conditionAnchorRef}
|
||||||
activeConditions={combatant.conditions}
|
activeConditions={combatant.conditions}
|
||||||
onToggle={(conditionId) => onToggleCondition(id, conditionId)}
|
onToggle={(conditionId) => toggleCondition(id, conditionId)}
|
||||||
onClose={() => setPickerOpen(false)}
|
onClose={() => setPickerOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AC */}
|
|
||||||
<div className={cn(dimmed && "opacity-50")}>
|
|
||||||
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* HP */}
|
{/* HP */}
|
||||||
<div className="flex items-center gap-1">
|
<div
|
||||||
<ClickableHp
|
|
||||||
currentHp={currentHp}
|
|
||||||
maxHp={maxHp}
|
|
||||||
onAdjust={(delta) => onAdjustHp(id, delta)}
|
|
||||||
dimmed={dimmed}
|
|
||||||
/>
|
|
||||||
{maxHp !== undefined && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-muted-foreground text-sm tabular-nums",
|
"flex items-center rounded-md tabular-nums",
|
||||||
|
maxHp === undefined
|
||||||
|
? ""
|
||||||
|
: "gap-0.5 border border-border/50 bg-muted/30 px-1.5",
|
||||||
dimmed && "opacity-50",
|
dimmed && "opacity-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
/
|
<ClickableHp
|
||||||
</span>
|
currentHp={currentHp}
|
||||||
|
maxHp={maxHp}
|
||||||
|
tempHp={combatant.tempHp}
|
||||||
|
onAdjust={(delta) => adjustHp(id, delta)}
|
||||||
|
onSetTempHp={(value) => setTempHp(id, value)}
|
||||||
|
/>
|
||||||
|
{maxHp !== undefined && (
|
||||||
|
<span className="text-muted-foreground/50 text-xs">/</span>
|
||||||
)}
|
)}
|
||||||
<div className={cn(dimmed && "opacity-50")}>
|
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => setHp(id, v)} />
|
||||||
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => onSetHp(id, v)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
icon={<X size={16} />}
|
icon={<X size={16} />}
|
||||||
label="Remove combatant"
|
label="Remove combatant"
|
||||||
onConfirm={() => onRemove(id)}
|
onConfirm={() => removeCombatant(id)}
|
||||||
className="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"
|
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>
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import {
|
|||||||
ZapOff,
|
ZapOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import { Tooltip } from "./ui/tooltip.js";
|
||||||
|
|
||||||
const ICON_MAP: Record<string, LucideIcon> = {
|
const ICON_MAP: Record<string, LucideIcon> = {
|
||||||
EyeOff,
|
EyeOff,
|
||||||
@@ -52,34 +54,45 @@ const COLOR_CLASSES: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface ConditionPickerProps {
|
interface ConditionPickerProps {
|
||||||
|
anchorRef: React.RefObject<HTMLElement | null>;
|
||||||
activeConditions: readonly ConditionId[] | undefined;
|
activeConditions: readonly ConditionId[] | undefined;
|
||||||
onToggle: (conditionId: ConditionId) => void;
|
onToggle: (conditionId: ConditionId) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConditionPicker({
|
export function ConditionPicker({
|
||||||
|
anchorRef,
|
||||||
activeConditions,
|
activeConditions,
|
||||||
onToggle,
|
onToggle,
|
||||||
onClose,
|
onClose,
|
||||||
}: Readonly<ConditionPickerProps>) {
|
}: Readonly<ConditionPickerProps>) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const [flipped, setFlipped] = useState(false);
|
const [pos, setPos] = useState<{
|
||||||
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined);
|
top: number;
|
||||||
|
left: number;
|
||||||
|
maxHeight: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
const anchor = anchorRef.current;
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (!el) return;
|
if (!anchor || !el) return;
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
const spaceBelow = window.innerHeight - rect.top;
|
const anchorRect = anchor.getBoundingClientRect();
|
||||||
const spaceAbove = rect.bottom;
|
const menuHeight = el.scrollHeight;
|
||||||
const shouldFlip =
|
const pad = 8;
|
||||||
rect.bottom > window.innerHeight && spaceAbove > spaceBelow;
|
|
||||||
setFlipped(shouldFlip);
|
const spaceBelow = window.innerHeight - anchorRect.bottom - pad;
|
||||||
const available = shouldFlip ? spaceAbove : spaceBelow;
|
const spaceAbove = anchorRect.top - pad;
|
||||||
if (rect.height > available) {
|
const openBelow = spaceBelow >= menuHeight || spaceBelow >= spaceAbove;
|
||||||
setMaxHeight(available - 16);
|
|
||||||
}
|
const top = openBelow
|
||||||
}, []);
|
? anchorRect.bottom + 4
|
||||||
|
: Math.max(pad, anchorRect.top - Math.min(menuHeight, spaceAbove) - 4);
|
||||||
|
const maxHeight = openBelow ? spaceBelow : Math.min(menuHeight, spaceAbove);
|
||||||
|
|
||||||
|
setPos({ top, left: anchorRect.left, maxHeight });
|
||||||
|
}, [anchorRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(e: MouseEvent) {
|
function handleClickOutside(e: MouseEvent) {
|
||||||
@@ -93,14 +106,15 @@ export function ConditionPicker({
|
|||||||
|
|
||||||
const active = new Set(activeConditions ?? []);
|
const active = new Set(activeConditions ?? []);
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className="card-glow fixed z-50 w-fit overflow-y-auto rounded-lg border border-border bg-background p-1"
|
||||||
"card-glow absolute left-0 z-10 w-fit overflow-y-auto rounded-lg border border-border bg-background p-1",
|
style={
|
||||||
flipped ? "bottom-full mb-1" : "top-full mt-1",
|
pos
|
||||||
)}
|
? { top: pos.top, left: pos.left, maxHeight: pos.maxHeight }
|
||||||
style={maxHeight ? { maxHeight } : undefined}
|
: { visibility: "hidden" as const }
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{CONDITION_DEFINITIONS.map((def) => {
|
{CONDITION_DEFINITIONS.map((def) => {
|
||||||
const Icon = ICON_MAP[def.iconName];
|
const Icon = ICON_MAP[def.iconName];
|
||||||
@@ -108,8 +122,8 @@ export function ConditionPicker({
|
|||||||
const isActive = active.has(def.id);
|
const isActive = active.has(def.id);
|
||||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||||
return (
|
return (
|
||||||
|
<Tooltip key={def.id} content={def.description} className="block">
|
||||||
<button
|
<button
|
||||||
key={def.id}
|
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-hover-neutral-bg",
|
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-hover-neutral-bg",
|
||||||
@@ -122,13 +136,17 @@ export function ConditionPicker({
|
|||||||
className={isActive ? colorClass : "text-muted-foreground"}
|
className={isActive ? colorClass : "text-muted-foreground"}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={isActive ? "text-foreground" : "text-muted-foreground"}
|
className={
|
||||||
|
isActive ? "text-foreground" : "text-muted-foreground"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{def.label}
|
{def.label}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
ZapOff,
|
ZapOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "../lib/utils.js";
|
import { cn } from "../lib/utils.js";
|
||||||
|
import { Tooltip } from "./ui/tooltip.js";
|
||||||
|
|
||||||
const ICON_MAP: Record<string, LucideIcon> = {
|
const ICON_MAP: Record<string, LucideIcon> = {
|
||||||
EyeOff,
|
EyeOff,
|
||||||
@@ -71,10 +72,9 @@ export function ConditionTags({
|
|||||||
if (!Icon) return null;
|
if (!Icon) return null;
|
||||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||||
return (
|
return (
|
||||||
|
<Tooltip key={condId} content={`${def.label}: ${def.description}`}>
|
||||||
<button
|
<button
|
||||||
key={condId}
|
|
||||||
type="button"
|
type="button"
|
||||||
title={def.label}
|
|
||||||
aria-label={`Remove ${def.label}`}
|
aria-label={`Remove ${def.label}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
|
"inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
|
||||||
@@ -87,6 +87,7 @@ export function ConditionTags({
|
|||||||
>
|
>
|
||||||
<Icon size={14} />
|
<Icon size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Heart, Sword } from "lucide-react";
|
import { Heart, ShieldPlus, Sword } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -12,10 +12,15 @@ const DIGITS_ONLY_REGEX = /^\d+$/;
|
|||||||
|
|
||||||
interface HpAdjustPopoverProps {
|
interface HpAdjustPopoverProps {
|
||||||
readonly onAdjust: (delta: number) => void;
|
readonly onAdjust: (delta: number) => void;
|
||||||
|
readonly onSetTempHp: (value: number) => void;
|
||||||
readonly onClose: () => void;
|
readonly onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
export function HpAdjustPopover({
|
||||||
|
onAdjust,
|
||||||
|
onSetTempHp,
|
||||||
|
onClose,
|
||||||
|
}: HpAdjustPopoverProps) {
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -130,6 +135,21 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
|||||||
>
|
>
|
||||||
<Heart size={14} />
|
<Heart size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!isValid}
|
||||||
|
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-cyan-400 transition-colors hover:bg-cyan-400/10 hover:text-cyan-300 disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
onClick={() => {
|
||||||
|
if (isValid && parsedValue) {
|
||||||
|
onSetTempHp(parsedValue);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Set temp HP"
|
||||||
|
aria-label="Set temp HP"
|
||||||
|
>
|
||||||
|
<ShieldPlus size={14} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
import type { PlayerCharacter } from "@initiative/domain";
|
||||||
import { type RefObject, useImperativeHandle, useState } from "react";
|
import { type RefObject, useImperativeHandle, useState } from "react";
|
||||||
|
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||||
import { CreatePlayerModal } from "./create-player-modal.js";
|
import { CreatePlayerModal } from "./create-player-modal.js";
|
||||||
import { PlayerManagement } from "./player-management.js";
|
import { PlayerManagement } from "./player-management.js";
|
||||||
|
|
||||||
@@ -7,37 +8,14 @@ export interface PlayerCharacterSectionHandle {
|
|||||||
openManagement: () => void;
|
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({
|
export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
||||||
characters,
|
|
||||||
onCreateCharacter,
|
|
||||||
onEditCharacter,
|
|
||||||
onDeleteCharacter,
|
|
||||||
ref,
|
ref,
|
||||||
}: PlayerCharacterSectionProps & {
|
}: {
|
||||||
ref?: RefObject<PlayerCharacterSectionHandle | null>;
|
ref?: RefObject<PlayerCharacterSectionHandle | null>;
|
||||||
}) {
|
}) {
|
||||||
|
const { characters, createCharacter, editCharacter, deleteCharacter } =
|
||||||
|
usePlayerCharactersContext();
|
||||||
|
|
||||||
const [managementOpen, setManagementOpen] = useState(false);
|
const [managementOpen, setManagementOpen] = useState(false);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editingPlayer, setEditingPlayer] = useState<
|
const [editingPlayer, setEditingPlayer] = useState<
|
||||||
@@ -59,7 +37,7 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
|||||||
}}
|
}}
|
||||||
onSave={(name, ac, maxHp, color, icon) => {
|
onSave={(name, ac, maxHp, color, icon) => {
|
||||||
if (editingPlayer) {
|
if (editingPlayer) {
|
||||||
onEditCharacter(editingPlayer.id, {
|
editCharacter(editingPlayer.id, {
|
||||||
name,
|
name,
|
||||||
ac,
|
ac,
|
||||||
maxHp,
|
maxHp,
|
||||||
@@ -67,7 +45,7 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
|||||||
icon: icon ?? null,
|
icon: icon ?? null,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
onCreateCharacter(name, ac, maxHp, color, icon);
|
createCharacter(name, ac, maxHp, color, icon);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
playerCharacter={editingPlayer}
|
playerCharacter={editingPlayer}
|
||||||
@@ -81,7 +59,7 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
|||||||
setCreateOpen(true);
|
setCreateOpen(true);
|
||||||
setManagementOpen(false);
|
setManagementOpen(false);
|
||||||
}}
|
}}
|
||||||
onDelete={(id) => onDeleteCharacter(id)}
|
onDelete={(id) => deleteCharacter(id)}
|
||||||
onCreate={() => {
|
onCreate={() => {
|
||||||
setEditingPlayer(undefined);
|
setEditingPlayer(undefined);
|
||||||
setCreateOpen(true);
|
setCreateOpen(true);
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import { Download, Loader2, Upload } from "lucide-react";
|
import { Download, Loader2, Upload } from "lucide-react";
|
||||||
import { useId, useRef, useState } from "react";
|
import { useId, useRef, useState } from "react";
|
||||||
import { getDefaultFetchUrl } from "../adapters/bestiary-index-adapter.js";
|
import {
|
||||||
|
getDefaultFetchUrl,
|
||||||
|
getSourceDisplayName,
|
||||||
|
} from "../adapters/bestiary-index-adapter.js";
|
||||||
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
import { Input } from "./ui/input.js";
|
import { Input } from "./ui/input.js";
|
||||||
|
|
||||||
interface SourceFetchPromptProps {
|
interface SourceFetchPromptProps {
|
||||||
sourceCode: string;
|
sourceCode: string;
|
||||||
sourceDisplayName: string;
|
|
||||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
|
|
||||||
onSourceLoaded: () => void;
|
onSourceLoaded: () => void;
|
||||||
onUploadSource: (sourceCode: string, jsonData: unknown) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SourceFetchPrompt({
|
export function SourceFetchPrompt({
|
||||||
sourceCode,
|
sourceCode,
|
||||||
sourceDisplayName,
|
|
||||||
fetchAndCacheSource,
|
|
||||||
onSourceLoaded,
|
onSourceLoaded,
|
||||||
onUploadSource,
|
|
||||||
}: Readonly<SourceFetchPromptProps>) {
|
}: Readonly<SourceFetchPromptProps>) {
|
||||||
|
const { fetchAndCacheSource, uploadAndCacheSource } = useBestiaryContext();
|
||||||
|
const sourceDisplayName = getSourceDisplayName(sourceCode);
|
||||||
const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode));
|
const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode));
|
||||||
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
|
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
|
||||||
const [error, setError] = useState<string>("");
|
const [error, setError] = useState<string>("");
|
||||||
@@ -47,7 +47,7 @@ export function SourceFetchPrompt({
|
|||||||
try {
|
try {
|
||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
const json = JSON.parse(text);
|
const json = JSON.parse(text);
|
||||||
await onUploadSource(sourceCode, json);
|
await uploadAndCacheSource(sourceCode, json);
|
||||||
onSourceLoaded();
|
onSourceLoaded();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
|
|||||||
@@ -8,16 +8,12 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import type { CachedSourceInfo } from "../adapters/bestiary-cache.js";
|
import type { CachedSourceInfo } from "../adapters/bestiary-cache.js";
|
||||||
import * as bestiaryCache from "../adapters/bestiary-cache.js";
|
import * as bestiaryCache from "../adapters/bestiary-cache.js";
|
||||||
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
import { Input } from "./ui/input.js";
|
import { Input } from "./ui/input.js";
|
||||||
|
|
||||||
interface SourceManagerProps {
|
export function SourceManager() {
|
||||||
onCacheCleared: () => void;
|
const { refreshCache } = useBestiaryContext();
|
||||||
}
|
|
||||||
|
|
||||||
export function SourceManager({
|
|
||||||
onCacheCleared,
|
|
||||||
}: Readonly<SourceManagerProps>) {
|
|
||||||
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
|
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
|
||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
const [optimisticSources, applyOptimistic] = useOptimistic(
|
const [optimisticSources, applyOptimistic] = useOptimistic(
|
||||||
@@ -44,14 +40,14 @@ export function SourceManager({
|
|||||||
applyOptimistic({ type: "remove", sourceCode });
|
applyOptimistic({ type: "remove", sourceCode });
|
||||||
await bestiaryCache.clearSource(sourceCode);
|
await bestiaryCache.clearSource(sourceCode);
|
||||||
await loadSources();
|
await loadSources();
|
||||||
onCacheCleared();
|
void refreshCache();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearAll = async () => {
|
const handleClearAll = async () => {
|
||||||
applyOptimistic({ type: "clear" });
|
applyOptimistic({ type: "clear" });
|
||||||
await bestiaryCache.clearAll();
|
await bestiaryCache.clearAll();
|
||||||
await loadSources();
|
await loadSources();
|
||||||
onCacheCleared();
|
void refreshCache();
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredSources = useMemo(() => {
|
const filteredSources = useMemo(() => {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { Creature, CreatureId } from "@initiative/domain";
|
import type { CreatureId } from "@initiative/domain";
|
||||||
import { PanelRightClose, Pin, PinOff } from "lucide-react";
|
import { PanelRightClose, Pin, PinOff } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
||||||
import { cn } from "../lib/utils.js";
|
import { cn } from "../lib/utils.js";
|
||||||
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
||||||
@@ -13,28 +13,8 @@ import { StatBlock } from "./stat-block.js";
|
|||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
|
|
||||||
interface StatBlockPanelProps {
|
interface StatBlockPanelProps {
|
||||||
creatureId: CreatureId | null;
|
|
||||||
creature: Creature | null;
|
|
||||||
isSourceCached: (sourceCode: string) => Promise<boolean>;
|
|
||||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
|
|
||||||
uploadAndCacheSource: (
|
|
||||||
sourceCode: string,
|
|
||||||
jsonData: unknown,
|
|
||||||
) => Promise<void>;
|
|
||||||
refreshCache: () => Promise<void>;
|
|
||||||
panelRole: "browse" | "pinned";
|
panelRole: "browse" | "pinned";
|
||||||
isCollapsed: boolean;
|
|
||||||
onToggleCollapse: () => void;
|
|
||||||
onPin: () => void;
|
|
||||||
onUnpin: () => void;
|
|
||||||
showPinButton: boolean;
|
|
||||||
side: "left" | "right";
|
side: "left" | "right";
|
||||||
onDismiss: () => void;
|
|
||||||
bulkImportMode?: boolean;
|
|
||||||
bulkImportState?: BulkImportState;
|
|
||||||
onStartBulkImport?: (baseUrl: string) => void;
|
|
||||||
onBulkImportDone?: () => void;
|
|
||||||
sourceManagerMode?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractSourceCode(cId: CreatureId): string {
|
function extractSourceCode(cId: CreatureId): string {
|
||||||
@@ -228,27 +208,49 @@ function MobileDrawer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatBlockPanel({
|
function usePanelRole(panelRole: "browse" | "pinned") {
|
||||||
|
const sidePanel = useSidePanelContext();
|
||||||
|
const { getCreature } = useBestiaryContext();
|
||||||
|
|
||||||
|
const creatureId =
|
||||||
|
panelRole === "browse"
|
||||||
|
? sidePanel.selectedCreatureId
|
||||||
|
: sidePanel.pinnedCreatureId;
|
||||||
|
const creature = creatureId ? (getCreature(creatureId) ?? null) : null;
|
||||||
|
|
||||||
|
const isBrowse = panelRole === "browse";
|
||||||
|
return {
|
||||||
creatureId,
|
creatureId,
|
||||||
creature,
|
creature,
|
||||||
isSourceCached,
|
isCollapsed: isBrowse ? sidePanel.isRightPanelCollapsed : false,
|
||||||
fetchAndCacheSource,
|
onToggleCollapse: isBrowse ? sidePanel.toggleCollapse : () => {},
|
||||||
uploadAndCacheSource,
|
onDismiss: isBrowse ? sidePanel.dismissPanel : () => {},
|
||||||
refreshCache,
|
onPin: isBrowse ? sidePanel.togglePin : () => {},
|
||||||
|
onUnpin: panelRole === "pinned" ? sidePanel.unpin : () => {},
|
||||||
|
showPinButton: isBrowse && sidePanel.isWideDesktop && !!creature,
|
||||||
|
bulkImportMode: isBrowse && sidePanel.bulkImportMode,
|
||||||
|
sourceManagerMode: isBrowse && sidePanel.sourceManagerMode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatBlockPanel({
|
||||||
panelRole,
|
panelRole,
|
||||||
|
side,
|
||||||
|
}: Readonly<StatBlockPanelProps>) {
|
||||||
|
const { isSourceCached } = useBestiaryContext();
|
||||||
|
const {
|
||||||
|
creatureId,
|
||||||
|
creature,
|
||||||
isCollapsed,
|
isCollapsed,
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
|
onDismiss,
|
||||||
onPin,
|
onPin,
|
||||||
onUnpin,
|
onUnpin,
|
||||||
showPinButton,
|
showPinButton,
|
||||||
side,
|
|
||||||
onDismiss,
|
|
||||||
bulkImportMode,
|
bulkImportMode,
|
||||||
bulkImportState,
|
|
||||||
onStartBulkImport,
|
|
||||||
onBulkImportDone,
|
|
||||||
sourceManagerMode,
|
sourceManagerMode,
|
||||||
}: Readonly<StatBlockPanelProps>) {
|
} = usePanelRole(panelRole);
|
||||||
|
|
||||||
const [isDesktop, setIsDesktop] = useState(
|
const [isDesktop, setIsDesktop] = useState(
|
||||||
() => globalThis.matchMedia("(min-width: 1024px)").matches,
|
() => globalThis.matchMedia("(min-width: 1024px)").matches,
|
||||||
);
|
);
|
||||||
@@ -285,29 +287,17 @@ export function StatBlockPanel({
|
|||||||
|
|
||||||
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
|
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
|
||||||
|
|
||||||
const handleSourceLoaded = async () => {
|
const handleSourceLoaded = () => {
|
||||||
await refreshCache();
|
|
||||||
setNeedsFetch(false);
|
setNeedsFetch(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (sourceManagerMode) {
|
if (sourceManagerMode) {
|
||||||
return <SourceManager onCacheCleared={refreshCache} />;
|
return <SourceManager />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (bulkImportMode) {
|
||||||
bulkImportMode &&
|
return <BulkImportPrompt />;
|
||||||
bulkImportState &&
|
|
||||||
onStartBulkImport &&
|
|
||||||
onBulkImportDone
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<BulkImportPrompt
|
|
||||||
importState={bulkImportState}
|
|
||||||
onStartImport={onStartBulkImport}
|
|
||||||
onDone={onBulkImportDone}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checkingCache) {
|
if (checkingCache) {
|
||||||
@@ -324,10 +314,7 @@ export function StatBlockPanel({
|
|||||||
return (
|
return (
|
||||||
<SourceFetchPrompt
|
<SourceFetchPrompt
|
||||||
sourceCode={sourceCode}
|
sourceCode={sourceCode}
|
||||||
sourceDisplayName={getSourceDisplayName(sourceCode)}
|
|
||||||
fetchAndCacheSource={fetchAndCacheSource}
|
|
||||||
onSourceLoaded={handleSourceLoaded}
|
onSourceLoaded={handleSourceLoaded}
|
||||||
onUploadSource={uploadAndCacheSource}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,12 @@
|
|||||||
import type { Encounter } from "@initiative/domain";
|
|
||||||
import { StepBack, StepForward, Trash2 } from "lucide-react";
|
import { StepBack, StepForward, Trash2 } from "lucide-react";
|
||||||
import { Button } from "./ui/button";
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
import { ConfirmButton } from "./ui/confirm-button";
|
import { Button } from "./ui/button.js";
|
||||||
|
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||||
|
|
||||||
interface TurnNavigationProps {
|
export function TurnNavigation() {
|
||||||
encounter: Encounter;
|
const { encounter, advanceTurn, retreatTurn, clearEncounter } =
|
||||||
onAdvanceTurn: () => void;
|
useEncounterContext();
|
||||||
onRetreatTurn: () => void;
|
|
||||||
onClearEncounter: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TurnNavigation({
|
|
||||||
encounter,
|
|
||||||
onAdvanceTurn,
|
|
||||||
onRetreatTurn,
|
|
||||||
onClearEncounter,
|
|
||||||
}: Readonly<TurnNavigationProps>) {
|
|
||||||
const hasCombatants = encounter.combatants.length > 0;
|
const hasCombatants = encounter.combatants.length > 0;
|
||||||
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
||||||
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
||||||
@@ -25,7 +16,7 @@ export function TurnNavigation({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={onRetreatTurn}
|
onClick={retreatTurn}
|
||||||
disabled={!hasCombatants || isAtStart}
|
disabled={!hasCombatants || isAtStart}
|
||||||
title="Previous turn"
|
title="Previous turn"
|
||||||
aria-label="Previous turn"
|
aria-label="Previous turn"
|
||||||
@@ -34,9 +25,11 @@ export function TurnNavigation({
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 items-center justify-center gap-2 text-sm">
|
<div className="flex min-w-0 flex-1 items-center justify-center gap-2 text-sm">
|
||||||
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 font-semibold text-foreground text-sm">
|
<span className="shrink-0 rounded-md bg-muted px-2 py-0.5 font-semibold text-foreground text-sm">
|
||||||
|
<span className="-mt-[3px] inline-block">
|
||||||
R{encounter.roundNumber}
|
R{encounter.roundNumber}
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
{activeCombatant ? (
|
{activeCombatant ? (
|
||||||
<span className="truncate font-medium">{activeCombatant.name}</span>
|
<span className="truncate font-medium">{activeCombatant.name}</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -48,14 +41,14 @@ export function TurnNavigation({
|
|||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
icon={<Trash2 className="h-5 w-5" />}
|
icon={<Trash2 className="h-5 w-5" />}
|
||||||
label="Clear encounter"
|
label="Clear encounter"
|
||||||
onConfirm={onClearEncounter}
|
onConfirm={clearEncounter}
|
||||||
disabled={!hasCombatants}
|
disabled={!hasCombatants}
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={onAdvanceTurn}
|
onClick={advanceTurn}
|
||||||
disabled={!hasCombatants}
|
disabled={!hasCombatants}
|
||||||
title="Next turn"
|
title="Next turn"
|
||||||
aria-label="Next turn"
|
aria-label="Next turn"
|
||||||
|
|||||||
55
apps/web/src/components/ui/tooltip.tsx
Normal file
55
apps/web/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { type ReactNode, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
content: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tooltip({
|
||||||
|
content,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: Readonly<TooltipProps>) {
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
setPos({
|
||||||
|
top: rect.top - 4,
|
||||||
|
left: rect.left + rect.width / 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
setPos(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
ref={ref}
|
||||||
|
onPointerEnter={show}
|
||||||
|
onPointerLeave={hide}
|
||||||
|
className={className ?? "inline-flex"}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
{pos !== null &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
role="tooltip"
|
||||||
|
className="pointer-events-none fixed z-[60] max-w-64 -translate-x-1/2 -translate-y-full rounded-md border border-border bg-background px-2.5 py-1.5 text-foreground text-xs leading-snug shadow-lg"
|
||||||
|
style={{ top: pos.top, left: pos.left }}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
apps/web/src/contexts/bestiary-context.tsx
Normal file
23
apps/web/src/contexts/bestiary-context.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { createContext, type ReactNode, useContext } from "react";
|
||||||
|
import { useBestiary } from "../hooks/use-bestiary.js";
|
||||||
|
|
||||||
|
export type { SearchResult } from "../hooks/use-bestiary.js";
|
||||||
|
|
||||||
|
type BestiaryContextValue = ReturnType<typeof useBestiary>;
|
||||||
|
|
||||||
|
const BestiaryContext = createContext<BestiaryContextValue | null>(null);
|
||||||
|
|
||||||
|
export function BestiaryProvider({ children }: { children: ReactNode }) {
|
||||||
|
const value = useBestiary();
|
||||||
|
return (
|
||||||
|
<BestiaryContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</BestiaryContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBestiaryContext(): BestiaryContextValue {
|
||||||
|
const ctx = useContext(BestiaryContext);
|
||||||
|
if (!ctx) throw new Error("useBestiaryContext requires BestiaryProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
21
apps/web/src/contexts/bulk-import-context.tsx
Normal file
21
apps/web/src/contexts/bulk-import-context.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { createContext, type ReactNode, useContext } from "react";
|
||||||
|
import { useBulkImport } from "../hooks/use-bulk-import.js";
|
||||||
|
|
||||||
|
type BulkImportContextValue = ReturnType<typeof useBulkImport>;
|
||||||
|
|
||||||
|
const BulkImportContext = createContext<BulkImportContextValue | null>(null);
|
||||||
|
|
||||||
|
export function BulkImportProvider({ children }: { children: ReactNode }) {
|
||||||
|
const value = useBulkImport();
|
||||||
|
return (
|
||||||
|
<BulkImportContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</BulkImportContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBulkImportContext(): BulkImportContextValue {
|
||||||
|
const ctx = useContext(BulkImportContext);
|
||||||
|
if (!ctx) throw new Error("useBulkImportContext requires BulkImportProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
21
apps/web/src/contexts/encounter-context.tsx
Normal file
21
apps/web/src/contexts/encounter-context.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { createContext, type ReactNode, useContext } from "react";
|
||||||
|
import { useEncounter } from "../hooks/use-encounter.js";
|
||||||
|
|
||||||
|
type EncounterContextValue = ReturnType<typeof useEncounter>;
|
||||||
|
|
||||||
|
const EncounterContext = createContext<EncounterContextValue | null>(null);
|
||||||
|
|
||||||
|
export function EncounterProvider({ children }: { children: ReactNode }) {
|
||||||
|
const value = useEncounter();
|
||||||
|
return (
|
||||||
|
<EncounterContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</EncounterContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEncounterContext(): EncounterContextValue {
|
||||||
|
const ctx = useContext(EncounterContext);
|
||||||
|
if (!ctx) throw new Error("useEncounterContext requires EncounterProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
7
apps/web/src/contexts/index.ts
Normal file
7
apps/web/src/contexts/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { BestiaryProvider } from "./bestiary-context.js";
|
||||||
|
export { BulkImportProvider } from "./bulk-import-context.js";
|
||||||
|
export { EncounterProvider } from "./encounter-context.js";
|
||||||
|
export { InitiativeRollsProvider } from "./initiative-rolls-context.js";
|
||||||
|
export { PlayerCharactersProvider } from "./player-characters-context.js";
|
||||||
|
export { SidePanelProvider } from "./side-panel-context.js";
|
||||||
|
export { ThemeProvider } from "./theme-context.js";
|
||||||
25
apps/web/src/contexts/initiative-rolls-context.tsx
Normal file
25
apps/web/src/contexts/initiative-rolls-context.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { createContext, type ReactNode, useContext } from "react";
|
||||||
|
import { useInitiativeRolls } from "../hooks/use-initiative-rolls.js";
|
||||||
|
|
||||||
|
type InitiativeRollsContextValue = ReturnType<typeof useInitiativeRolls>;
|
||||||
|
|
||||||
|
const InitiativeRollsContext =
|
||||||
|
createContext<InitiativeRollsContextValue | null>(null);
|
||||||
|
|
||||||
|
export function InitiativeRollsProvider({ children }: { children: ReactNode }) {
|
||||||
|
const value = useInitiativeRolls();
|
||||||
|
return (
|
||||||
|
<InitiativeRollsContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</InitiativeRollsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInitiativeRollsContext(): InitiativeRollsContextValue {
|
||||||
|
const ctx = useContext(InitiativeRollsContext);
|
||||||
|
if (!ctx)
|
||||||
|
throw new Error(
|
||||||
|
"useInitiativeRollsContext requires InitiativeRollsProvider",
|
||||||
|
);
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
29
apps/web/src/contexts/player-characters-context.tsx
Normal file
29
apps/web/src/contexts/player-characters-context.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { createContext, type ReactNode, useContext } from "react";
|
||||||
|
import { usePlayerCharacters } from "../hooks/use-player-characters.js";
|
||||||
|
|
||||||
|
type PlayerCharactersContextValue = ReturnType<typeof usePlayerCharacters>;
|
||||||
|
|
||||||
|
const PlayerCharactersContext =
|
||||||
|
createContext<PlayerCharactersContextValue | null>(null);
|
||||||
|
|
||||||
|
export function PlayerCharactersProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const value = usePlayerCharacters();
|
||||||
|
return (
|
||||||
|
<PlayerCharactersContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</PlayerCharactersContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlayerCharactersContext(): PlayerCharactersContextValue {
|
||||||
|
const ctx = useContext(PlayerCharactersContext);
|
||||||
|
if (!ctx)
|
||||||
|
throw new Error(
|
||||||
|
"usePlayerCharactersContext requires PlayerCharactersProvider",
|
||||||
|
);
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
21
apps/web/src/contexts/side-panel-context.tsx
Normal file
21
apps/web/src/contexts/side-panel-context.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { createContext, type ReactNode, useContext } from "react";
|
||||||
|
import { useSidePanelState } from "../hooks/use-side-panel-state.js";
|
||||||
|
|
||||||
|
type SidePanelContextValue = ReturnType<typeof useSidePanelState>;
|
||||||
|
|
||||||
|
const SidePanelContext = createContext<SidePanelContextValue | null>(null);
|
||||||
|
|
||||||
|
export function SidePanelProvider({ children }: { children: ReactNode }) {
|
||||||
|
const value = useSidePanelState();
|
||||||
|
return (
|
||||||
|
<SidePanelContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</SidePanelContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSidePanelContext(): SidePanelContextValue {
|
||||||
|
const ctx = useContext(SidePanelContext);
|
||||||
|
if (!ctx) throw new Error("useSidePanelContext requires SidePanelProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
19
apps/web/src/contexts/theme-context.tsx
Normal file
19
apps/web/src/contexts/theme-context.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { createContext, type ReactNode, useContext } from "react";
|
||||||
|
import { useTheme } from "../hooks/use-theme.js";
|
||||||
|
|
||||||
|
type ThemeContextValue = ReturnType<typeof useTheme>;
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const value = useTheme();
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useThemeContext(): ThemeContextValue {
|
||||||
|
const ctx = useContext(ThemeContext);
|
||||||
|
if (!ctx) throw new Error("useThemeContext requires ThemeProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
38
apps/web/src/hooks/use-action-bar-animation.ts
Normal file
38
apps/web/src/hooks/use-action-bar-animation.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { useLayoutEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export function useActionBarAnimation(combatantCount: number) {
|
||||||
|
const wasEmptyRef = useRef(combatantCount === 0);
|
||||||
|
const [settling, setSettling] = useState(false);
|
||||||
|
const [rising, setRising] = useState(false);
|
||||||
|
const [topBarExiting, setTopBarExiting] = useState(false);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const nowEmpty = combatantCount === 0;
|
||||||
|
if (wasEmptyRef.current && !nowEmpty) {
|
||||||
|
setSettling(true);
|
||||||
|
} else if (!wasEmptyRef.current && nowEmpty) {
|
||||||
|
setRising(true);
|
||||||
|
setTopBarExiting(true);
|
||||||
|
}
|
||||||
|
wasEmptyRef.current = nowEmpty;
|
||||||
|
}, [combatantCount]);
|
||||||
|
|
||||||
|
const empty = combatantCount === 0;
|
||||||
|
const risingClass = rising ? "animate-rise-to-center" : "";
|
||||||
|
const settlingClass = settling ? "animate-settle-to-bottom" : "";
|
||||||
|
const exitingClass = topBarExiting
|
||||||
|
? "absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out"
|
||||||
|
: "";
|
||||||
|
const topBarClass = settling ? "animate-slide-down-in" : exitingClass;
|
||||||
|
const showTopBar = !empty || topBarExiting;
|
||||||
|
|
||||||
|
return {
|
||||||
|
risingClass,
|
||||||
|
settlingClass,
|
||||||
|
topBarClass,
|
||||||
|
showTopBar,
|
||||||
|
onSettleEnd: () => setSettling(false),
|
||||||
|
onRiseEnd: () => setRising(false),
|
||||||
|
onTopBarExitEnd: () => setTopBarExiting(false),
|
||||||
|
};
|
||||||
|
}
|
||||||
17
apps/web/src/hooks/use-auto-stat-block.ts
Normal file
17
apps/web/src/hooks/use-auto-stat-block.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
|
|
||||||
|
export function useAutoStatBlock(): void {
|
||||||
|
const { encounter } = useEncounterContext();
|
||||||
|
const { panelView, updateCreature } = useSidePanelContext();
|
||||||
|
|
||||||
|
const activeCreatureId =
|
||||||
|
encounter.combatants[encounter.activeIndex]?.creatureId;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeCreatureId && panelView.mode === "creature") {
|
||||||
|
updateCreature(activeCreatureId);
|
||||||
|
}
|
||||||
|
}, [activeCreatureId, panelView.mode, updateCreature]);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
|
|
||||||
const BATCH_SIZE = 6;
|
const BATCH_SIZE = 6;
|
||||||
|
|
||||||
export interface BulkImportState {
|
interface BulkImportState {
|
||||||
readonly status: "idle" | "loading" | "complete" | "partial-failure";
|
readonly status: "idle" | "loading" | "complete" | "partial-failure";
|
||||||
readonly total: number;
|
readonly total: number;
|
||||||
readonly completed: number;
|
readonly completed: number;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
setAcUseCase,
|
setAcUseCase,
|
||||||
setHpUseCase,
|
setHpUseCase,
|
||||||
setInitiativeUseCase,
|
setInitiativeUseCase,
|
||||||
|
setTempHpUseCase,
|
||||||
toggleConcentrationUseCase,
|
toggleConcentrationUseCase,
|
||||||
toggleConditionUseCase,
|
toggleConditionUseCase,
|
||||||
} from "@initiative/application";
|
} from "@initiative/application";
|
||||||
@@ -215,6 +216,19 @@ export function useEncounter() {
|
|||||||
[makeStore],
|
[makeStore],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setTempHp = useCallback(
|
||||||
|
(id: CombatantId, tempHp: number | undefined) => {
|
||||||
|
const result = setTempHpUseCase(makeStore(), id, tempHp);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEvents((prev) => [...prev, ...result]);
|
||||||
|
},
|
||||||
|
[makeStore],
|
||||||
|
);
|
||||||
|
|
||||||
const setAc = useCallback(
|
const setAc = useCallback(
|
||||||
(id: CombatantId, value: number | undefined) => {
|
(id: CombatantId, value: number | undefined) => {
|
||||||
const result = setAcUseCase(makeStore(), id, value);
|
const result = setAcUseCase(makeStore(), id, value);
|
||||||
@@ -376,6 +390,10 @@ export function useEncounter() {
|
|||||||
[makeStore],
|
[makeStore],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasTempHp = encounter.combatants.some(
|
||||||
|
(c) => c.tempHp !== undefined && c.tempHp > 0,
|
||||||
|
);
|
||||||
|
|
||||||
const isEmpty = encounter.combatants.length === 0;
|
const isEmpty = encounter.combatants.length === 0;
|
||||||
const hasCreatureCombatants = encounter.combatants.some(
|
const hasCreatureCombatants = encounter.combatants.some(
|
||||||
(c) => c.creatureId != null,
|
(c) => c.creatureId != null,
|
||||||
@@ -388,6 +406,7 @@ export function useEncounter() {
|
|||||||
encounter,
|
encounter,
|
||||||
events,
|
events,
|
||||||
isEmpty,
|
isEmpty,
|
||||||
|
hasTempHp,
|
||||||
hasCreatureCombatants,
|
hasCreatureCombatants,
|
||||||
canRollAllInitiative,
|
canRollAllInitiative,
|
||||||
advanceTurn,
|
advanceTurn,
|
||||||
@@ -399,6 +418,7 @@ export function useEncounter() {
|
|||||||
setInitiative,
|
setInitiative,
|
||||||
setHp,
|
setHp,
|
||||||
adjustHp,
|
adjustHp,
|
||||||
|
setTempHp,
|
||||||
setAc,
|
setAc,
|
||||||
toggleCondition,
|
toggleCondition,
|
||||||
toggleConcentration,
|
toggleConcentration,
|
||||||
|
|||||||
75
apps/web/src/hooks/use-initiative-rolls.ts
Normal file
75
apps/web/src/hooks/use-initiative-rolls.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import {
|
||||||
|
rollAllInitiativeUseCase,
|
||||||
|
rollInitiativeUseCase,
|
||||||
|
} from "@initiative/application";
|
||||||
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
isDomainError,
|
||||||
|
type RollMode,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
|
|
||||||
|
function rollDice(): number {
|
||||||
|
return Math.floor(Math.random() * 20) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInitiativeRolls() {
|
||||||
|
const { encounter, makeStore } = useEncounterContext();
|
||||||
|
const { getCreature } = useBestiaryContext();
|
||||||
|
const { showCreature } = useSidePanelContext();
|
||||||
|
|
||||||
|
const [rollSkippedCount, setRollSkippedCount] = useState(0);
|
||||||
|
const [rollSingleSkipped, setRollSingleSkipped] = useState(false);
|
||||||
|
|
||||||
|
const handleRollInitiative = useCallback(
|
||||||
|
(id: CombatantId, mode: RollMode = "normal") => {
|
||||||
|
const diceRolls: [number, ...number[]] =
|
||||||
|
mode === "normal" ? [rollDice()] : [rollDice(), rollDice()];
|
||||||
|
const result = rollInitiativeUseCase(
|
||||||
|
makeStore(),
|
||||||
|
id,
|
||||||
|
diceRolls,
|
||||||
|
getCreature,
|
||||||
|
mode,
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
setRollSingleSkipped(true);
|
||||||
|
const combatant = encounter.combatants.find((c) => c.id === id);
|
||||||
|
if (combatant?.creatureId) {
|
||||||
|
showCreature(combatant.creatureId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[makeStore, getCreature, encounter.combatants, showCreature],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRollAllInitiative = useCallback(
|
||||||
|
(mode: RollMode = "normal") => {
|
||||||
|
const result = rollAllInitiativeUseCase(
|
||||||
|
makeStore(),
|
||||||
|
rollDice,
|
||||||
|
getCreature,
|
||||||
|
mode,
|
||||||
|
);
|
||||||
|
if (!isDomainError(result) && result.skippedNoSource > 0) {
|
||||||
|
setRollSkippedCount(result.skippedNoSource);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[makeStore, getCreature],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rollSkippedCount,
|
||||||
|
rollSingleSkipped,
|
||||||
|
dismissRollSkipped: useCallback(() => setRollSkippedCount(0), []),
|
||||||
|
dismissRollSingleSkipped: useCallback(
|
||||||
|
() => setRollSingleSkipped(false),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
handleRollInitiative,
|
||||||
|
handleRollAllInitiative,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
@@ -1,13 +1,36 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { App } from "./App";
|
import { App } from "./App.js";
|
||||||
|
import {
|
||||||
|
BestiaryProvider,
|
||||||
|
BulkImportProvider,
|
||||||
|
EncounterProvider,
|
||||||
|
InitiativeRollsProvider,
|
||||||
|
PlayerCharactersProvider,
|
||||||
|
SidePanelProvider,
|
||||||
|
ThemeProvider,
|
||||||
|
} from "./contexts/index.js";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
if (root) {
|
if (root) {
|
||||||
createRoot(root).render(
|
createRoot(root).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<ThemeProvider>
|
||||||
|
<EncounterProvider>
|
||||||
|
<BestiaryProvider>
|
||||||
|
<PlayerCharactersProvider>
|
||||||
|
<BulkImportProvider>
|
||||||
|
<SidePanelProvider>
|
||||||
|
<InitiativeRollsProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</InitiativeRollsProvider>
|
||||||
|
</SidePanelProvider>
|
||||||
|
</BulkImportProvider>
|
||||||
|
</PlayerCharactersProvider>
|
||||||
|
</BestiaryProvider>
|
||||||
|
</EncounterProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"includes": [
|
"includes": [
|
||||||
"**",
|
"**",
|
||||||
|
|||||||
@@ -5,6 +5,6 @@
|
|||||||
"entry": ["scripts/*.mjs"]
|
"entry": ["scripts/*.mjs"]
|
||||||
},
|
},
|
||||||
"packages/*": {},
|
"packages/*": {},
|
||||||
"apps/*": {}
|
"apps/web": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
package.json
19
package.json
@@ -1,21 +1,21 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.6.0",
|
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"undici": ">=7.24.0"
|
"undici": ">=7.24.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.7",
|
"@biomejs/biome": "2.4.8",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^4.1.0",
|
||||||
"jscpd": "^4.0.8",
|
"jscpd": "^4.0.8",
|
||||||
"knip": "^5.85.0",
|
"knip": "^5.88.1",
|
||||||
"lefthook": "^1.11.0",
|
"lefthook": "^2.1.4",
|
||||||
"oxlint": "^1.55.0",
|
"oxlint": "^1.56.0",
|
||||||
"oxlint-tsgolint": "^0.16.0",
|
"oxlint-tsgolint": "^0.17.1",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^4.1.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "lefthook install",
|
"prepare": "lefthook install",
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
|
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
|
||||||
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
||||||
"check:classnames": "node scripts/check-cn-classnames.mjs",
|
"check:classnames": "node scripts/check-cn-classnames.mjs",
|
||||||
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && tsc --build && vitest run && jscpd"
|
"check:props": "node scripts/check-component-props.mjs",
|
||||||
|
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && node scripts/check-component-props.mjs && tsc --build && vitest run && jscpd"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,5 +21,6 @@ export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
|
|||||||
export { setAcUseCase } from "./set-ac-use-case.js";
|
export { setAcUseCase } from "./set-ac-use-case.js";
|
||||||
export { setHpUseCase } from "./set-hp-use-case.js";
|
export { setHpUseCase } from "./set-hp-use-case.js";
|
||||||
export { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
export { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
||||||
|
export { setTempHpUseCase } from "./set-temp-hp-use-case.js";
|
||||||
export { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js";
|
export { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js";
|
||||||
export { toggleConditionUseCase } from "./toggle-condition-use-case.js";
|
export { toggleConditionUseCase } from "./toggle-condition-use-case.js";
|
||||||
|
|||||||
24
packages/application/src/set-temp-hp-use-case.ts
Normal file
24
packages/application/src/set-temp-hp-use-case.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
isDomainError,
|
||||||
|
setTempHp,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
|
||||||
|
export function setTempHpUseCase(
|
||||||
|
store: EncounterStore,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
tempHp: number | undefined,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
const encounter = store.get();
|
||||||
|
const result = setTempHp(encounter, combatantId, tempHp);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.save(result.encounter);
|
||||||
|
return result.events;
|
||||||
|
}
|
||||||
@@ -6,12 +6,18 @@ import { expectDomainError } from "./test-helpers.js";
|
|||||||
|
|
||||||
function makeCombatant(
|
function makeCombatant(
|
||||||
name: string,
|
name: string,
|
||||||
opts?: { maxHp: number; currentHp: number },
|
opts?: { maxHp: number; currentHp: number; tempHp?: number },
|
||||||
): Combatant {
|
): Combatant {
|
||||||
return {
|
return {
|
||||||
id: combatantId(name),
|
id: combatantId(name),
|
||||||
name,
|
name,
|
||||||
...(opts ? { maxHp: opts.maxHp, currentHp: opts.currentHp } : {}),
|
...(opts
|
||||||
|
? {
|
||||||
|
maxHp: opts.maxHp,
|
||||||
|
currentHp: opts.currentHp,
|
||||||
|
tempHp: opts.tempHp,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,4 +158,96 @@ describe("adjustHp", () => {
|
|||||||
expect(encounter.combatants[0].currentHp).toBe(5);
|
expect(encounter.combatants[0].currentHp).toBe(5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("temporary HP absorption", () => {
|
||||||
|
it("damage fully absorbed by temp HP — currentHp unchanged", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 8 }),
|
||||||
|
]);
|
||||||
|
const { encounter } = successResult(e, "A", -5);
|
||||||
|
expect(encounter.combatants[0].currentHp).toBe(15);
|
||||||
|
expect(encounter.combatants[0].tempHp).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("damage partially absorbed by temp HP — overflow reduces currentHp", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 3 }),
|
||||||
|
]);
|
||||||
|
const { encounter } = successResult(e, "A", -10);
|
||||||
|
expect(encounter.combatants[0].tempHp).toBeUndefined();
|
||||||
|
expect(encounter.combatants[0].currentHp).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("damage exceeding both temp HP and currentHp — both reach minimum", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 5, tempHp: 3 }),
|
||||||
|
]);
|
||||||
|
const { encounter } = successResult(e, "A", -50);
|
||||||
|
expect(encounter.combatants[0].tempHp).toBeUndefined();
|
||||||
|
expect(encounter.combatants[0].currentHp).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("healing does not restore temp HP", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 3 }),
|
||||||
|
]);
|
||||||
|
const { encounter } = successResult(e, "A", 5);
|
||||||
|
expect(encounter.combatants[0].currentHp).toBe(15);
|
||||||
|
expect(encounter.combatants[0].tempHp).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("temp HP cleared to undefined when fully depleted", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 5 }),
|
||||||
|
]);
|
||||||
|
const { encounter } = successResult(e, "A", -5);
|
||||||
|
expect(encounter.combatants[0].tempHp).toBeUndefined();
|
||||||
|
expect(encounter.combatants[0].currentHp).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits only TempHpSet when damage fully absorbed", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 8 }),
|
||||||
|
]);
|
||||||
|
const { events } = successResult(e, "A", -3);
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "TempHpSet",
|
||||||
|
combatantId: combatantId("A"),
|
||||||
|
previousTempHp: 8,
|
||||||
|
newTempHp: 5,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits both TempHpSet and CurrentHpAdjusted when damage overflows", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 3 }),
|
||||||
|
]);
|
||||||
|
const { events } = successResult(e, "A", -10);
|
||||||
|
expect(events).toHaveLength(2);
|
||||||
|
expect(events[0]).toEqual({
|
||||||
|
type: "TempHpSet",
|
||||||
|
combatantId: combatantId("A"),
|
||||||
|
previousTempHp: 3,
|
||||||
|
newTempHp: undefined,
|
||||||
|
});
|
||||||
|
expect(events[1]).toEqual({
|
||||||
|
type: "CurrentHpAdjusted",
|
||||||
|
combatantId: combatantId("A"),
|
||||||
|
previousHp: 15,
|
||||||
|
newHp: 8,
|
||||||
|
delta: -10,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("damage with no temp HP works as before", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
|
||||||
|
const { encounter, events } = successResult(e, "A", -5);
|
||||||
|
expect(encounter.combatants[0].currentHp).toBe(10);
|
||||||
|
expect(encounter.combatants[0].tempHp).toBeUndefined();
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].type).toBe("CurrentHpAdjusted");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -69,6 +69,34 @@ describe("setHp", () => {
|
|||||||
expect(encounter.combatants[0].maxHp).toBeUndefined();
|
expect(encounter.combatants[0].maxHp).toBeUndefined();
|
||||||
expect(encounter.combatants[0].currentHp).toBeUndefined();
|
expect(encounter.combatants[0].currentHp).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clears tempHp when maxHp is cleared", () => {
|
||||||
|
const e = enc([
|
||||||
|
{
|
||||||
|
id: combatantId("A"),
|
||||||
|
name: "A",
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 15,
|
||||||
|
tempHp: 5,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { encounter } = successResult(e, "A", undefined);
|
||||||
|
expect(encounter.combatants[0].tempHp).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves tempHp when maxHp is updated", () => {
|
||||||
|
const e = enc([
|
||||||
|
{
|
||||||
|
id: combatantId("A"),
|
||||||
|
name: "A",
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 15,
|
||||||
|
tempHp: 5,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { encounter } = successResult(e, "A", 25);
|
||||||
|
expect(encounter.combatants[0].tempHp).toBe(5);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("invariants", () => {
|
describe("invariants", () => {
|
||||||
|
|||||||
182
packages/domain/src/__tests__/set-temp-hp.test.ts
Normal file
182
packages/domain/src/__tests__/set-temp-hp.test.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { setTempHp } from "../set-temp-hp.js";
|
||||||
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
|
function makeCombatant(
|
||||||
|
name: string,
|
||||||
|
opts?: { maxHp: number; currentHp: number; tempHp?: number },
|
||||||
|
): Combatant {
|
||||||
|
return {
|
||||||
|
id: combatantId(name),
|
||||||
|
name,
|
||||||
|
...(opts
|
||||||
|
? {
|
||||||
|
maxHp: opts.maxHp,
|
||||||
|
currentHp: opts.currentHp,
|
||||||
|
tempHp: opts.tempHp,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function enc(combatants: Combatant[]): Encounter {
|
||||||
|
return { combatants, activeIndex: 0, roundNumber: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function successResult(
|
||||||
|
encounter: Encounter,
|
||||||
|
id: string,
|
||||||
|
tempHp: number | undefined,
|
||||||
|
) {
|
||||||
|
const result = setTempHp(encounter, combatantId(id), tempHp);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
throw new Error(`Expected success, got error: ${result.message}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("setTempHp", () => {
|
||||||
|
describe("acceptance scenarios", () => {
|
||||||
|
it("sets temp HP on a combatant with HP tracking enabled", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
|
||||||
|
const { encounter } = successResult(e, "A", 8);
|
||||||
|
expect(encounter.combatants[0].tempHp).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps higher value when existing temp HP is greater", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 5 }),
|
||||||
|
]);
|
||||||
|
const { encounter } = successResult(e, "A", 3);
|
||||||
|
expect(encounter.combatants[0].tempHp).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces when new value is higher", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 3 }),
|
||||||
|
]);
|
||||||
|
const { encounter } = successResult(e, "A", 7);
|
||||||
|
expect(encounter.combatants[0].tempHp).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears temp HP when set to undefined", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 5 }),
|
||||||
|
]);
|
||||||
|
const { encounter } = successResult(e, "A", undefined);
|
||||||
|
expect(encounter.combatants[0].tempHp).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("invariants", () => {
|
||||||
|
it("is pure — same input produces same output", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
|
||||||
|
const r1 = setTempHp(e, combatantId("A"), 5);
|
||||||
|
const r2 = setTempHp(e, combatantId("A"), 5);
|
||||||
|
expect(r1).toEqual(r2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate input encounter", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
|
||||||
|
const original = JSON.parse(JSON.stringify(e));
|
||||||
|
setTempHp(e, combatantId("A"), 5);
|
||||||
|
expect(e).toEqual(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits TempHpSet event with correct shape", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 3 }),
|
||||||
|
]);
|
||||||
|
const { events } = successResult(e, "A", 7);
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "TempHpSet",
|
||||||
|
combatantId: combatantId("A"),
|
||||||
|
previousTempHp: 3,
|
||||||
|
newTempHp: 7,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves activeIndex and roundNumber", () => {
|
||||||
|
const e = {
|
||||||
|
combatants: [
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 10 }),
|
||||||
|
makeCombatant("B"),
|
||||||
|
],
|
||||||
|
activeIndex: 1,
|
||||||
|
roundNumber: 5,
|
||||||
|
};
|
||||||
|
const { encounter } = successResult(e, "A", 5);
|
||||||
|
expect(encounter.activeIndex).toBe(1);
|
||||||
|
expect(encounter.roundNumber).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("error cases", () => {
|
||||||
|
it("returns error for nonexistent combatant", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||||
|
const result = setTempHp(e, combatantId("Z"), 5);
|
||||||
|
expectDomainError(result, "combatant-not-found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error when HP tracking is not enabled", () => {
|
||||||
|
const e = enc([makeCombatant("A")]);
|
||||||
|
const result = setTempHp(e, combatantId("A"), 5);
|
||||||
|
expectDomainError(result, "no-hp-tracking");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects temp HP of 0", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||||
|
const result = setTempHp(e, combatantId("A"), 0);
|
||||||
|
expectDomainError(result, "invalid-temp-hp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects negative temp HP", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||||
|
const result = setTempHp(e, combatantId("A"), -3);
|
||||||
|
expectDomainError(result, "invalid-temp-hp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-integer temp HP", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||||
|
const result = setTempHp(e, combatantId("A"), 2.5);
|
||||||
|
expectDomainError(result, "invalid-temp-hp");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("does not affect other combatants", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 15 }),
|
||||||
|
makeCombatant("B", { maxHp: 30, currentHp: 25, tempHp: 4 }),
|
||||||
|
]);
|
||||||
|
const { encounter } = successResult(e, "A", 5);
|
||||||
|
expect(encounter.combatants[1].tempHp).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not affect currentHp or maxHp", () => {
|
||||||
|
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
|
||||||
|
const { encounter } = successResult(e, "A", 8);
|
||||||
|
expect(encounter.combatants[0].maxHp).toBe(20);
|
||||||
|
expect(encounter.combatants[0].currentHp).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("event reflects no change when existing value equals new value", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 5 }),
|
||||||
|
]);
|
||||||
|
const { events } = successResult(e, "A", 5);
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "TempHpSet",
|
||||||
|
combatantId: combatantId("A"),
|
||||||
|
previousTempHp: 5,
|
||||||
|
newTempHp: 5,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -54,24 +54,52 @@ export function adjustHp(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const previousHp = target.currentHp;
|
const previousHp = target.currentHp;
|
||||||
const newHp = Math.max(0, Math.min(target.maxHp, previousHp + delta));
|
const previousTempHp = target.tempHp ?? 0;
|
||||||
|
let newTempHp = previousTempHp;
|
||||||
|
let effectiveDelta = delta;
|
||||||
|
|
||||||
return {
|
if (delta < 0 && previousTempHp > 0) {
|
||||||
encounter: {
|
const absorbed = Math.min(previousTempHp, Math.abs(delta));
|
||||||
combatants: encounter.combatants.map((c) =>
|
newTempHp = previousTempHp - absorbed;
|
||||||
c.id === combatantId ? { ...c, currentHp: newHp } : c,
|
effectiveDelta = delta + absorbed;
|
||||||
),
|
}
|
||||||
activeIndex: encounter.activeIndex,
|
|
||||||
roundNumber: encounter.roundNumber,
|
const newHp = Math.max(
|
||||||
},
|
0,
|
||||||
events: [
|
Math.min(target.maxHp, previousHp + effectiveDelta),
|
||||||
{
|
);
|
||||||
|
|
||||||
|
const events: DomainEvent[] = [];
|
||||||
|
|
||||||
|
if (newTempHp !== previousTempHp) {
|
||||||
|
events.push({
|
||||||
|
type: "TempHpSet",
|
||||||
|
combatantId,
|
||||||
|
previousTempHp: previousTempHp || undefined,
|
||||||
|
newTempHp: newTempHp || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newHp !== previousHp) {
|
||||||
|
events.push({
|
||||||
type: "CurrentHpAdjusted",
|
type: "CurrentHpAdjusted",
|
||||||
combatantId,
|
combatantId,
|
||||||
previousHp,
|
previousHp,
|
||||||
newHp,
|
newHp,
|
||||||
delta,
|
delta,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
encounter: {
|
||||||
|
combatants: encounter.combatants.map((c) =>
|
||||||
|
c.id === combatantId
|
||||||
|
? { ...c, currentHp: newHp, tempHp: newTempHp || undefined }
|
||||||
|
: c,
|
||||||
|
),
|
||||||
|
activeIndex: encounter.activeIndex,
|
||||||
|
roundNumber: encounter.roundNumber,
|
||||||
},
|
},
|
||||||
],
|
events,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,63 +18,127 @@ export type ConditionId =
|
|||||||
export interface ConditionDefinition {
|
export interface ConditionDefinition {
|
||||||
readonly id: ConditionId;
|
readonly id: ConditionId;
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
|
readonly description: string;
|
||||||
readonly iconName: string;
|
readonly iconName: string;
|
||||||
readonly color: string;
|
readonly color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||||
{ id: "blinded", label: "Blinded", iconName: "EyeOff", color: "neutral" },
|
{
|
||||||
{ id: "charmed", label: "Charmed", iconName: "Heart", color: "pink" },
|
id: "blinded",
|
||||||
{ id: "deafened", label: "Deafened", iconName: "EarOff", color: "neutral" },
|
label: "Blinded",
|
||||||
|
description:
|
||||||
|
"Can't see. Auto-fail sight checks. Attacks have Disadvantage; attacks against have Advantage.",
|
||||||
|
iconName: "EyeOff",
|
||||||
|
color: "neutral",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "charmed",
|
||||||
|
label: "Charmed",
|
||||||
|
description:
|
||||||
|
"Can't attack or target the charmer with harmful abilities. Charmer has Advantage on social checks.",
|
||||||
|
iconName: "Heart",
|
||||||
|
color: "pink",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "deafened",
|
||||||
|
label: "Deafened",
|
||||||
|
description: "Can't hear. Auto-fail hearing checks.",
|
||||||
|
iconName: "EarOff",
|
||||||
|
color: "neutral",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "exhaustion",
|
id: "exhaustion",
|
||||||
label: "Exhaustion",
|
label: "Exhaustion",
|
||||||
|
description:
|
||||||
|
"Subtract exhaustion level from D20 Tests and Spell save DCs. Speed reduced by 5 ft. \u00D7 level. Removed by long rest (1 level) or death at 10 levels.",
|
||||||
iconName: "BatteryLow",
|
iconName: "BatteryLow",
|
||||||
color: "amber",
|
color: "amber",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "frightened",
|
id: "frightened",
|
||||||
label: "Frightened",
|
label: "Frightened",
|
||||||
|
description:
|
||||||
|
"Disadvantage on ability checks and attacks while source of fear is in line of sight. Can't willingly move closer to the source.",
|
||||||
iconName: "Siren",
|
iconName: "Siren",
|
||||||
color: "orange",
|
color: "orange",
|
||||||
},
|
},
|
||||||
{ id: "grappled", label: "Grappled", iconName: "Hand", color: "neutral" },
|
{
|
||||||
|
id: "grappled",
|
||||||
|
label: "Grappled",
|
||||||
|
description:
|
||||||
|
"Speed is 0 and can't benefit from bonuses to speed. Ends if grappler is Incapacitated or moved out of reach.",
|
||||||
|
iconName: "Hand",
|
||||||
|
color: "neutral",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "incapacitated",
|
id: "incapacitated",
|
||||||
label: "Incapacitated",
|
label: "Incapacitated",
|
||||||
|
description:
|
||||||
|
"Can't take Actions, Bonus Actions, or Reactions. Concentration is broken.",
|
||||||
iconName: "Ban",
|
iconName: "Ban",
|
||||||
color: "gray",
|
color: "gray",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "invisible",
|
id: "invisible",
|
||||||
label: "Invisible",
|
label: "Invisible",
|
||||||
|
description:
|
||||||
|
"Impossible to see without magic or special sense. Heavily Obscured. Attacks have Advantage; attacks against have Disadvantage.",
|
||||||
iconName: "Ghost",
|
iconName: "Ghost",
|
||||||
color: "violet",
|
color: "violet",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "paralyzed",
|
id: "paralyzed",
|
||||||
label: "Paralyzed",
|
label: "Paralyzed",
|
||||||
|
description:
|
||||||
|
"Incapacitated. Can't move or speak. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
||||||
iconName: "ZapOff",
|
iconName: "ZapOff",
|
||||||
color: "yellow",
|
color: "yellow",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "petrified",
|
id: "petrified",
|
||||||
label: "Petrified",
|
label: "Petrified",
|
||||||
|
description:
|
||||||
|
"Turned to stone. Weight \u00D710. Incapacitated. Can't move or speak. Attacks against have Advantage. Auto-fail Str/Dex saves. Resistant to all damage. Immune to poison and disease.",
|
||||||
iconName: "Gem",
|
iconName: "Gem",
|
||||||
color: "slate",
|
color: "slate",
|
||||||
},
|
},
|
||||||
{ id: "poisoned", label: "Poisoned", iconName: "Droplet", color: "green" },
|
{
|
||||||
{ id: "prone", label: "Prone", iconName: "ArrowDown", color: "neutral" },
|
id: "poisoned",
|
||||||
|
label: "Poisoned",
|
||||||
|
description: "Disadvantage on attack rolls and ability checks.",
|
||||||
|
iconName: "Droplet",
|
||||||
|
color: "green",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prone",
|
||||||
|
label: "Prone",
|
||||||
|
description:
|
||||||
|
"Can only crawl (costs extra movement). Disadvantage on attacks. Attacks within 5 ft. have Advantage; ranged attacks have Disadvantage. Standing up costs half movement.",
|
||||||
|
iconName: "ArrowDown",
|
||||||
|
color: "neutral",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "restrained",
|
id: "restrained",
|
||||||
label: "Restrained",
|
label: "Restrained",
|
||||||
|
description:
|
||||||
|
"Speed is 0. Attacks have Disadvantage. Attacks against have Advantage. Disadvantage on Dex saves.",
|
||||||
iconName: "Link",
|
iconName: "Link",
|
||||||
color: "neutral",
|
color: "neutral",
|
||||||
},
|
},
|
||||||
{ id: "stunned", label: "Stunned", iconName: "Sparkles", color: "yellow" },
|
{
|
||||||
|
id: "stunned",
|
||||||
|
label: "Stunned",
|
||||||
|
description:
|
||||||
|
"Incapacitated. Can't move. Can speak only falteringly. Auto-fail Str/Dex saves. Attacks against have Advantage.",
|
||||||
|
iconName: "Sparkles",
|
||||||
|
color: "yellow",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "unconscious",
|
id: "unconscious",
|
||||||
label: "Unconscious",
|
label: "Unconscious",
|
||||||
|
description:
|
||||||
|
"Incapacitated. Can't move or speak. Unaware of surroundings. Drops held items, falls Prone. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
||||||
iconName: "Moon",
|
iconName: "Moon",
|
||||||
color: "indigo",
|
color: "indigo",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -58,6 +58,13 @@ export interface CurrentHpAdjusted {
|
|||||||
readonly delta: number;
|
readonly delta: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TempHpSet {
|
||||||
|
readonly type: "TempHpSet";
|
||||||
|
readonly combatantId: CombatantId;
|
||||||
|
readonly previousTempHp: number | undefined;
|
||||||
|
readonly newTempHp: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TurnRetreated {
|
export interface TurnRetreated {
|
||||||
readonly type: "TurnRetreated";
|
readonly type: "TurnRetreated";
|
||||||
readonly previousCombatantId: CombatantId;
|
readonly previousCombatantId: CombatantId;
|
||||||
@@ -132,6 +139,7 @@ export type DomainEvent =
|
|||||||
| InitiativeSet
|
| InitiativeSet
|
||||||
| MaxHpSet
|
| MaxHpSet
|
||||||
| CurrentHpAdjusted
|
| CurrentHpAdjusted
|
||||||
|
| TempHpSet
|
||||||
| TurnRetreated
|
| TurnRetreated
|
||||||
| RoundRetreated
|
| RoundRetreated
|
||||||
| AcSet
|
| AcSet
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export type {
|
|||||||
PlayerCharacterUpdated,
|
PlayerCharacterUpdated,
|
||||||
RoundAdvanced,
|
RoundAdvanced,
|
||||||
RoundRetreated,
|
RoundRetreated,
|
||||||
|
TempHpSet,
|
||||||
TurnAdvanced,
|
TurnAdvanced,
|
||||||
TurnRetreated,
|
TurnRetreated,
|
||||||
} from "./events.js";
|
} from "./events.js";
|
||||||
@@ -95,6 +96,7 @@ export {
|
|||||||
type SetInitiativeSuccess,
|
type SetInitiativeSuccess,
|
||||||
setInitiative,
|
setInitiative,
|
||||||
} from "./set-initiative.js";
|
} from "./set-initiative.js";
|
||||||
|
export { type SetTempHpSuccess, setTempHp } from "./set-temp-hp.js";
|
||||||
export {
|
export {
|
||||||
type ToggleConcentrationSuccess,
|
type ToggleConcentrationSuccess,
|
||||||
toggleConcentration,
|
toggleConcentration,
|
||||||
|
|||||||
@@ -66,7 +66,12 @@ export function setHp(
|
|||||||
encounter: {
|
encounter: {
|
||||||
combatants: encounter.combatants.map((c) =>
|
combatants: encounter.combatants.map((c) =>
|
||||||
c.id === combatantId
|
c.id === combatantId
|
||||||
? { ...c, maxHp: newMaxHp, currentHp: newCurrentHp }
|
? {
|
||||||
|
...c,
|
||||||
|
maxHp: newMaxHp,
|
||||||
|
currentHp: newCurrentHp,
|
||||||
|
tempHp: newMaxHp === undefined ? undefined : c.tempHp,
|
||||||
|
}
|
||||||
: c,
|
: c,
|
||||||
),
|
),
|
||||||
activeIndex: encounter.activeIndex,
|
activeIndex: encounter.activeIndex,
|
||||||
|
|||||||
78
packages/domain/src/set-temp-hp.ts
Normal file
78
packages/domain/src/set-temp-hp.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import type { DomainEvent } from "./events.js";
|
||||||
|
import type { CombatantId, DomainError, Encounter } from "./types.js";
|
||||||
|
|
||||||
|
export interface SetTempHpSuccess {
|
||||||
|
readonly encounter: Encounter;
|
||||||
|
readonly events: DomainEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure function that sets or clears a combatant's temporary HP.
|
||||||
|
*
|
||||||
|
* - Setting tempHp when the combatant already has tempHp keeps the higher value.
|
||||||
|
* - Clearing tempHp (undefined) removes temp HP entirely.
|
||||||
|
* - Requires HP tracking to be enabled (maxHp must be set).
|
||||||
|
*/
|
||||||
|
export function setTempHp(
|
||||||
|
encounter: Encounter,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
tempHp: number | undefined,
|
||||||
|
): SetTempHpSuccess | DomainError {
|
||||||
|
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
|
||||||
|
|
||||||
|
if (targetIdx === -1) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "combatant-not-found",
|
||||||
|
message: `No combatant found with ID "${combatantId}"`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = encounter.combatants[targetIdx];
|
||||||
|
|
||||||
|
if (target.maxHp === undefined || target.currentHp === undefined) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "no-hp-tracking",
|
||||||
|
message: `Combatant "${combatantId}" does not have HP tracking enabled`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tempHp !== undefined && (!Number.isInteger(tempHp) || tempHp < 1)) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "invalid-temp-hp",
|
||||||
|
message: `Temp HP must be a positive integer, got ${tempHp}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousTempHp = target.tempHp;
|
||||||
|
|
||||||
|
// Higher value wins when both are defined
|
||||||
|
let newTempHp: number | undefined;
|
||||||
|
if (tempHp === undefined) {
|
||||||
|
newTempHp = undefined;
|
||||||
|
} else if (previousTempHp === undefined) {
|
||||||
|
newTempHp = tempHp;
|
||||||
|
} else {
|
||||||
|
newTempHp = Math.max(previousTempHp, tempHp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
encounter: {
|
||||||
|
combatants: encounter.combatants.map((c) =>
|
||||||
|
c.id === combatantId ? { ...c, tempHp: newTempHp } : c,
|
||||||
|
),
|
||||||
|
activeIndex: encounter.activeIndex,
|
||||||
|
roundNumber: encounter.roundNumber,
|
||||||
|
},
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
type: "TempHpSet",
|
||||||
|
combatantId,
|
||||||
|
previousTempHp,
|
||||||
|
newTempHp,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ export interface Combatant {
|
|||||||
readonly initiative?: number;
|
readonly initiative?: number;
|
||||||
readonly maxHp?: number;
|
readonly maxHp?: number;
|
||||||
readonly currentHp?: number;
|
readonly currentHp?: number;
|
||||||
|
readonly tempHp?: number;
|
||||||
readonly ac?: number;
|
readonly ac?: number;
|
||||||
readonly conditions?: readonly ConditionId[];
|
readonly conditions?: readonly ConditionId[];
|
||||||
readonly isConcentrating?: boolean;
|
readonly isConcentrating?: boolean;
|
||||||
|
|||||||
2255
pnpm-lock.yaml
generated
2255
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
99
scripts/check-component-props.mjs
Normal file
99
scripts/check-component-props.mjs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Enforce a maximum number of explicitly declared props per component
|
||||||
|
* interface.
|
||||||
|
*
|
||||||
|
* Components should consume shared application state via React context
|
||||||
|
* providers, not prop drilling. Props are reserved for per-instance
|
||||||
|
* configuration (a specific data item, a layout variant, a ref).
|
||||||
|
*
|
||||||
|
* Only scans component files (not hooks, adapters, etc.) and only
|
||||||
|
* counts properties declared directly in *Props interfaces — inherited
|
||||||
|
* or extended HTML attributes are not counted.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { relative } from "node:path";
|
||||||
|
|
||||||
|
const MAX_PROPS = 8;
|
||||||
|
|
||||||
|
const files = execSync(
|
||||||
|
"git ls-files -- 'apps/web/src/components/*.tsx' 'apps/web/src/components/**/*.tsx'",
|
||||||
|
{ encoding: "utf-8" },
|
||||||
|
)
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
const propsRegex = /^(?:export\s+)?interface\s+(\w+Props)\s*\{/;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const content = readFileSync(file, "utf-8");
|
||||||
|
const lines = content.split("\n");
|
||||||
|
|
||||||
|
let inInterface = false;
|
||||||
|
let interfaceName = "";
|
||||||
|
let braceDepth = 0;
|
||||||
|
let parenDepth = 0;
|
||||||
|
let propCount = 0;
|
||||||
|
let startLine = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
|
||||||
|
if (!inInterface) {
|
||||||
|
const match = propsRegex.exec(line);
|
||||||
|
if (match) {
|
||||||
|
inInterface = true;
|
||||||
|
interfaceName = match[1];
|
||||||
|
braceDepth = 0;
|
||||||
|
parenDepth = 0;
|
||||||
|
propCount = 0;
|
||||||
|
startLine = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inInterface) {
|
||||||
|
for (const ch of line) {
|
||||||
|
if (ch === "{") braceDepth++;
|
||||||
|
if (ch === "}") braceDepth--;
|
||||||
|
if (ch === "(") parenDepth++;
|
||||||
|
if (ch === ")") parenDepth--;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count prop lines at brace depth 1 and not inside function params:
|
||||||
|
// Matches " propName?: type" and " readonly propName: type"
|
||||||
|
if (
|
||||||
|
braceDepth === 1 &&
|
||||||
|
parenDepth === 0 &&
|
||||||
|
/^\s+(?:readonly\s+)?\w+\??\s*:/.test(line)
|
||||||
|
) {
|
||||||
|
propCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (braceDepth === 0) {
|
||||||
|
if (propCount > MAX_PROPS) {
|
||||||
|
const rel = relative(process.cwd(), file);
|
||||||
|
console.error(
|
||||||
|
`${rel}:${startLine}: ${interfaceName} has ${propCount} props (max ${MAX_PROPS})`,
|
||||||
|
);
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
inInterface = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors > 0) {
|
||||||
|
console.error(
|
||||||
|
`\n${errors} component(s) exceed the ${MAX_PROPS}-prop limit. Use React context to reduce props.`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`check-component-props: all component interfaces within ${MAX_PROPS}-prop limit`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ interface Combatant {
|
|||||||
readonly initiative?: number; // integer, undefined = unset
|
readonly initiative?: number; // integer, undefined = unset
|
||||||
readonly maxHp?: number; // positive integer
|
readonly maxHp?: number; // positive integer
|
||||||
readonly currentHp?: number; // 0..maxHp
|
readonly currentHp?: number; // 0..maxHp
|
||||||
|
readonly tempHp?: number; // positive integer, damage buffer
|
||||||
readonly ac?: number; // non-negative integer
|
readonly ac?: number; // non-negative integer
|
||||||
readonly conditions?: readonly ConditionId[];
|
readonly conditions?: readonly ConditionId[];
|
||||||
readonly isConcentrating?: boolean;
|
readonly isConcentrating?: boolean;
|
||||||
@@ -96,6 +97,19 @@ As a game master, I want HP values to survive page reloads so that I do not lose
|
|||||||
Acceptance scenarios:
|
Acceptance scenarios:
|
||||||
1. **Given** a combatant has max HP 30 and current HP 18, **When** the page is reloaded, **Then** both values are restored exactly.
|
1. **Given** a combatant has max HP 30 and current HP 18, **When** the page is reloaded, **Then** both values are restored exactly.
|
||||||
|
|
||||||
|
**Story HP-7 — Temporary Hit Points (P1)**
|
||||||
|
As a game master, I want to grant temporary HP to a combatant so that I can track damage buffers from spells like Heroism or False Life without manual bookkeeping.
|
||||||
|
|
||||||
|
Acceptance scenarios:
|
||||||
|
1. **Given** a combatant has 15/20 HP and no temp HP, **When** the user sets 8 temp HP via the popover, **Then** the combatant displays `15+8 / 20`.
|
||||||
|
2. **Given** a combatant has 15+8/20 HP, **When** 5 damage is dealt, **Then** temp HP decreases to 3 and current HP remains 15 → display `15+3 / 20`.
|
||||||
|
3. **Given** a combatant has 15+3/20 HP, **When** 10 damage is dealt, **Then** temp HP is fully consumed (3 absorbed) and current HP decreases by the remaining 7 → display `8 / 20`.
|
||||||
|
4. **Given** a combatant has 15+5/20 HP, **When** 8 healing is applied, **Then** current HP increases to 20 and temp HP remains 5 → display `20+5 / 20`.
|
||||||
|
5. **Given** a combatant has 10+5/20 HP, **When** the user sets 3 temp HP, **Then** temp HP remains 5 (higher value kept).
|
||||||
|
6. **Given** a combatant has 10+3/20 HP, **When** the user sets 7 temp HP, **Then** temp HP becomes 7.
|
||||||
|
7. **Given** no combatant has temp HP, **When** viewing the encounter, **Then** no extra space is reserved for temp HP display.
|
||||||
|
8. **Given** one combatant has temp HP, **When** viewing the encounter, **Then** all rows reserve space for the temp HP display to maintain column alignment.
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- **FR-001**: Each combatant MAY have an optional `maxHp` value (positive integer >= 1). HP tracking is optional per combatant.
|
- **FR-001**: Each combatant MAY have an optional `maxHp` value (positive integer >= 1). HP tracking is optional per combatant.
|
||||||
@@ -120,6 +134,15 @@ Acceptance scenarios:
|
|||||||
- **FR-020**: The HP area MUST display the unconscious color treatment (red) and the combatant row MUST appear visually muted when status is `unconscious`.
|
- **FR-020**: The HP area MUST display the unconscious color treatment (red) and the combatant row MUST appear visually muted when status is `unconscious`.
|
||||||
- **FR-021**: Status indicators MUST NOT be shown when `maxHp` is not set.
|
- **FR-021**: Status indicators MUST NOT be shown when `maxHp` is not set.
|
||||||
- **FR-022**: Visual status indicators MUST update within the same interaction frame as the HP change — no perceptible delay.
|
- **FR-022**: Visual status indicators MUST update within the same interaction frame as the HP change — no perceptible delay.
|
||||||
|
- **FR-023**: Each combatant MAY have an optional `tempHp` value (positive integer >= 1). Temp HP is independent of regular HP tracking but requires HP tracking to be enabled.
|
||||||
|
- **FR-024**: When damage is applied, temp HP MUST absorb damage first. Any remaining damage after temp HP is depleted MUST reduce `currentHp`.
|
||||||
|
- **FR-025**: Healing MUST NOT restore temp HP. Healing applies only to `currentHp`.
|
||||||
|
- **FR-026**: When setting temp HP on a combatant that already has temp HP, the system MUST keep the higher of the two values.
|
||||||
|
- **FR-027**: When `maxHp` is cleared (HP tracking disabled), `tempHp` MUST also be cleared.
|
||||||
|
- **FR-028**: The temp HP value MUST be displayed as a cyan `+N` immediately after the current HP value, only when temp HP > 0.
|
||||||
|
- **FR-029**: When any combatant in the encounter has temp HP > 0, all rows MUST reserve space for the temp HP display to maintain column alignment. When no combatant has temp HP, no space is reserved.
|
||||||
|
- **FR-030**: The HP adjustment popover MUST include a third button (Shield icon) for setting temp HP.
|
||||||
|
- **FR-031**: Temp HP MUST persist across page reloads via the existing persistence mechanism.
|
||||||
|
|
||||||
### Edge Cases
|
### Edge Cases
|
||||||
|
|
||||||
@@ -131,7 +154,10 @@ Acceptance scenarios:
|
|||||||
- Submitting an empty delta input applies no change; the input remains ready.
|
- Submitting an empty delta input applies no change; the input remains ready.
|
||||||
- When the user rapidly applies multiple deltas, each is applied sequentially; none are lost.
|
- When the user rapidly applies multiple deltas, each is applied sequentially; none are lost.
|
||||||
- HP tracking is entirely absent for combatants with no `maxHp` set — no HP controls are shown.
|
- HP tracking is entirely absent for combatants with no `maxHp` set — no HP controls are shown.
|
||||||
- There is no temporary HP in the MVP baseline.
|
- Setting temp HP to 0 or clearing it removes temp HP entirely.
|
||||||
|
- Temp HP does not affect `HpStatus` derivation — a combatant with 5 current HP, 5 temp HP, and 20 max HP is still bloodied.
|
||||||
|
- When a concentrating combatant takes damage, the concentration pulse MUST trigger regardless of whether temp HP absorbs the damage — "taking damage" is the trigger, not losing real HP.
|
||||||
|
- A combatant at 0 currentHp with temp HP remaining is still unconscious.
|
||||||
- There is no death/unconscious game mechanic triggered at 0 HP; the system displays the state only.
|
- There is no death/unconscious game mechanic triggered at 0 HP; the system displays the state only.
|
||||||
- There is no damage type tracking, resistance/vulnerability calculation, or hit log in the MVP baseline.
|
- There is no damage type tracking, resistance/vulnerability calculation, or hit log in the MVP baseline.
|
||||||
- There is no undo/redo for HP changes in the MVP baseline.
|
- There is no undo/redo for HP changes in the MVP baseline.
|
||||||
|
|||||||
Reference in New Issue
Block a user