Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
968cc7239b | ||
|
|
d9562f850c | ||
|
|
ec9f2e7877 | ||
|
|
c4079c384b | ||
|
|
a4285fc415 | ||
|
|
9c0e3398f1 | ||
|
|
9cdf004c15 | ||
|
|
8bf69fd47d | ||
|
|
7b83e3c3ea | ||
|
|
c3c2cad798 | ||
|
|
3f6140303d | ||
|
|
fd30278474 | ||
|
|
278c06221f | ||
|
|
722e8cc627 | ||
|
|
64741956dd | ||
|
|
6336dec38a | ||
|
|
9def2d7c24 | ||
|
|
f729e37689 | ||
|
|
86768842ff | ||
|
|
6584d8d064 | ||
|
|
7f38cbab73 |
@@ -1,9 +1,9 @@
|
||||
<!--
|
||||
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:
|
||||
- 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
|
||||
-->
|
||||
# Encounter Console Constitution
|
||||
@@ -38,6 +38,22 @@ dependency direction:
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
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 lint # Biome lint
|
||||
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 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.
|
||||
- **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks.
|
||||
- **Feature specs** live in `specs/NNN-feature-name/` with spec.md (and optionally plan.md, tasks.md for new work). Specs describe features, not individual changes. The project constitution is at `.specify/memory/constitution.md`.
|
||||
- **Component props** — max 8 explicitly declared props per component interface (enforced by `scripts/check-component-props.mjs`). Use React context for shared state; reserve props for per-instance config (data items, layout variants, refs).
|
||||
- **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
|
||||
|
||||
@@ -20,15 +20,15 @@
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"vite": "^6.2.0"
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"jsdom": "^29.0.1",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"vite": "^8.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,227 +1,43 @@
|
||||
import {
|
||||
rollAllInitiativeUseCase,
|
||||
rollInitiativeUseCase,
|
||||
} from "@initiative/application";
|
||||
import {
|
||||
type CombatantId,
|
||||
type Creature,
|
||||
type CreatureId,
|
||||
isDomainError,
|
||||
} from "@initiative/domain";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ActionBar } from "./components/action-bar";
|
||||
import { BulkImportToasts } from "./components/bulk-import-toasts";
|
||||
import { CombatantRow } from "./components/combatant-row";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { ActionBar } from "./components/action-bar.js";
|
||||
import { BulkImportToasts } from "./components/bulk-import-toasts.js";
|
||||
import { CombatantRow } from "./components/combatant-row.js";
|
||||
import {
|
||||
PlayerCharacterSection,
|
||||
type PlayerCharacterSectionHandle,
|
||||
} from "./components/player-character-section";
|
||||
import { StatBlockPanel } from "./components/stat-block-panel";
|
||||
import { Toast } from "./components/toast";
|
||||
import { TurnNavigation } from "./components/turn-navigation";
|
||||
import { type SearchResult, useBestiary } from "./hooks/use-bestiary";
|
||||
import { useBulkImport } from "./hooks/use-bulk-import";
|
||||
import { useEncounter } from "./hooks/use-encounter";
|
||||
import { usePlayerCharacters } from "./hooks/use-player-characters";
|
||||
import { useSidePanelState } from "./hooks/use-side-panel-state";
|
||||
import { useTheme } from "./hooks/use-theme";
|
||||
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),
|
||||
};
|
||||
}
|
||||
} from "./components/player-character-section.js";
|
||||
import { StatBlockPanel } from "./components/stat-block-panel.js";
|
||||
import { Toast } from "./components/toast.js";
|
||||
import { TurnNavigation } from "./components/turn-navigation.js";
|
||||
import { useEncounterContext } from "./contexts/encounter-context.js";
|
||||
import { useInitiativeRollsContext } from "./contexts/initiative-rolls-context.js";
|
||||
import { useSidePanelContext } from "./contexts/side-panel-context.js";
|
||||
import { useActionBarAnimation } from "./hooks/use-action-bar-animation.js";
|
||||
import { useAutoStatBlock } from "./hooks/use-auto-stat-block.js";
|
||||
import { cn } from "./lib/utils.js";
|
||||
|
||||
export function App() {
|
||||
const {
|
||||
encounter,
|
||||
isEmpty,
|
||||
hasCreatureCombatants,
|
||||
canRollAllInitiative,
|
||||
advanceTurn,
|
||||
retreatTurn,
|
||||
addCombatant,
|
||||
clearEncounter,
|
||||
removeCombatant,
|
||||
editCombatant,
|
||||
setInitiative,
|
||||
setHp,
|
||||
adjustHp,
|
||||
setAc,
|
||||
toggleCondition,
|
||||
toggleConcentration,
|
||||
addFromBestiary,
|
||||
addFromPlayerCharacter,
|
||||
makeStore,
|
||||
} = useEncounter();
|
||||
const { encounter, isEmpty } = useEncounterContext();
|
||||
const sidePanel = useSidePanelContext();
|
||||
const rolls = useInitiativeRollsContext();
|
||||
|
||||
const {
|
||||
characters: playerCharacters,
|
||||
createCharacter: createPlayerCharacter,
|
||||
editCharacter: editPlayerCharacter,
|
||||
deleteCharacter: deletePlayerCharacter,
|
||||
} = usePlayerCharacters();
|
||||
useAutoStatBlock();
|
||||
|
||||
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) => {
|
||||
const result = rollInitiativeUseCase(
|
||||
makeStore(),
|
||||
id,
|
||||
rollDice(),
|
||||
getCreature,
|
||||
);
|
||||
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(() => {
|
||||
const result = rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
||||
if (!isDomainError(result) && result.skippedNoSource > 0) {
|
||||
setRollSkippedCount(result.skippedNoSource);
|
||||
}
|
||||
}, [makeStore, getCreature]);
|
||||
|
||||
const handleViewStatBlock = useCallback(
|
||||
(result: SearchResult) => {
|
||||
const slug = result.name
|
||||
.toLowerCase()
|
||||
.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 actionBarInputRef = useRef<HTMLInputElement>(null);
|
||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
|
||||
|
||||
// Auto-update stat block panel when the active combatant changes
|
||||
const activeCreatureId =
|
||||
encounter.combatants[encounter.activeIndex]?.creatureId;
|
||||
// Auto-scroll to active combatant when turn changes
|
||||
const activeIndex = encounter.activeIndex;
|
||||
useEffect(() => {
|
||||
if (activeCreatureId && sidePanel.panelView.mode === "creature") {
|
||||
sidePanel.showCreature(activeCreatureId);
|
||||
if (activeIndex >= 0) {
|
||||
activeRowRef.current?.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}, [activeCreatureId, sidePanel.panelView.mode, sidePanel.showCreature]);
|
||||
|
||||
// Auto-scroll to the active combatant when the turn changes
|
||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
activeRowRef.current?.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, []);
|
||||
}, [activeIndex]);
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh flex-col">
|
||||
@@ -231,49 +47,27 @@ export function App() {
|
||||
className={cn("shrink-0 pt-8", actionBarAnim.topBarClass)}
|
||||
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
|
||||
>
|
||||
<TurnNavigation
|
||||
encounter={encounter}
|
||||
onAdvanceTurn={advanceTurn}
|
||||
onRetreatTurn={retreatTurn}
|
||||
onClearEncounter={clearEncounter}
|
||||
/>
|
||||
<TurnNavigation />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty ? (
|
||||
/* Empty state — ActionBar centered */
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center pt-8 pb-[15%]">
|
||||
<div
|
||||
className={cn("w-full", actionBarAnim.risingClass)}
|
||||
onAnimationEnd={actionBarAnim.onRiseEnd}
|
||||
>
|
||||
<ActionBar
|
||||
onAddCombatant={addCombatant}
|
||||
onAddFromBestiary={handleAddFromBestiary}
|
||||
bestiarySearch={search}
|
||||
bestiaryLoaded={isLoaded}
|
||||
onViewStatBlock={handleViewStatBlock}
|
||||
onBulkImport={sidePanel.showBulkImport}
|
||||
bulkImportDisabled={bulkImport.state.status === "loading"}
|
||||
inputRef={actionBarInputRef}
|
||||
playerCharacters={playerCharacters}
|
||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||
onManagePlayers={() =>
|
||||
playerCharacterRef.current?.openManagement()
|
||||
}
|
||||
onRollAllInitiative={handleRollAllInitiative}
|
||||
showRollAllInitiative={hasCreatureCombatants}
|
||||
rollAllInitiativeDisabled={!canRollAllInitiative}
|
||||
onOpenSourceManager={sidePanel.showSourceManager}
|
||||
themePreference={themePreference}
|
||||
onCycleTheme={cycleTheme}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Scrollable area — combatant list */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="flex flex-col px-2 py-2">
|
||||
{encounter.combatants.map((c, i) => (
|
||||
@@ -282,133 +76,51 @@ export function App() {
|
||||
ref={i === encounter.activeIndex ? activeRowRef : null}
|
||||
combatant={c}
|
||||
isActive={i === encounter.activeIndex}
|
||||
onRename={editCombatant}
|
||||
onSetInitiative={setInitiative}
|
||||
onRemove={removeCombatant}
|
||||
onSetHp={setHp}
|
||||
onAdjustHp={adjustHp}
|
||||
onSetAc={setAc}
|
||||
onToggleCondition={toggleCondition}
|
||||
onToggleConcentration={toggleConcentration}
|
||||
onShowStatBlock={
|
||||
c.creatureId
|
||||
? () => handleCombatantStatBlock(c.creatureId as string)
|
||||
: undefined
|
||||
}
|
||||
isStatBlockOpen={
|
||||
c.creatureId === sidePanel.selectedCreatureId
|
||||
}
|
||||
onRollInitiative={
|
||||
c.creatureId ? handleRollInitiative : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Bar — fixed at bottom */}
|
||||
<div
|
||||
className={cn("shrink-0 pb-8", actionBarAnim.settlingClass)}
|
||||
onAnimationEnd={actionBarAnim.onSettleEnd}
|
||||
>
|
||||
<ActionBar
|
||||
onAddCombatant={addCombatant}
|
||||
onAddFromBestiary={handleAddFromBestiary}
|
||||
bestiarySearch={search}
|
||||
bestiaryLoaded={isLoaded}
|
||||
onViewStatBlock={handleViewStatBlock}
|
||||
onBulkImport={sidePanel.showBulkImport}
|
||||
bulkImportDisabled={bulkImport.state.status === "loading"}
|
||||
inputRef={actionBarInputRef}
|
||||
playerCharacters={playerCharacters}
|
||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||
onManagePlayers={() =>
|
||||
playerCharacterRef.current?.openManagement()
|
||||
}
|
||||
onRollAllInitiative={handleRollAllInitiative}
|
||||
showRollAllInitiative={hasCreatureCombatants}
|
||||
rollAllInitiativeDisabled={!canRollAllInitiative}
|
||||
onOpenSourceManager={sidePanel.showSourceManager}
|
||||
themePreference={themePreference}
|
||||
onCycleTheme={cycleTheme}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pinned Stat Block Panel (left) */}
|
||||
{!!sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
|
||||
<StatBlockPanel
|
||||
creatureId={sidePanel.pinnedCreatureId}
|
||||
creature={pinnedCreature}
|
||||
isSourceCached={isSourceCached}
|
||||
fetchAndCacheSource={fetchAndCacheSource}
|
||||
uploadAndCacheSource={uploadAndCacheSource}
|
||||
refreshCache={refreshCache}
|
||||
panelRole="pinned"
|
||||
isCollapsed={false}
|
||||
onToggleCollapse={() => {}}
|
||||
onPin={() => {}}
|
||||
onUnpin={sidePanel.unpin}
|
||||
showPinButton={false}
|
||||
side="left"
|
||||
onDismiss={() => {}}
|
||||
/>
|
||||
<StatBlockPanel panelRole="pinned" side="left" />
|
||||
)}
|
||||
|
||||
{/* Browse Stat Block Panel (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}
|
||||
/>
|
||||
<StatBlockPanel panelRole="browse" side="right" />
|
||||
|
||||
<BulkImportToasts
|
||||
state={bulkImport.state}
|
||||
visible={!sidePanel.bulkImportMode || sidePanel.isRightPanelCollapsed}
|
||||
onReset={bulkImport.reset}
|
||||
/>
|
||||
<BulkImportToasts />
|
||||
|
||||
{rollSkippedCount > 0 && (
|
||||
{rolls.rollSkippedCount > 0 && (
|
||||
<Toast
|
||||
message={`${rollSkippedCount} skipped — bestiary source not loaded`}
|
||||
onDismiss={() => setRollSkippedCount(0)}
|
||||
message={`${rolls.rollSkippedCount} skipped — bestiary source not loaded`}
|
||||
onDismiss={rolls.dismissRollSkipped}
|
||||
autoDismissMs={4000}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!!rollSingleSkipped && (
|
||||
{!!rolls.rollSingleSkipped && (
|
||||
<Toast
|
||||
message="Can't roll — bestiary source not loaded"
|
||||
onDismiss={() => setRollSingleSkipped(false)}
|
||||
onDismiss={rolls.dismissRollSingleSkipped}
|
||||
autoDismissMs={4000}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PlayerCharacterSection
|
||||
ref={playerCharacterRef}
|
||||
characters={playerCharacters}
|
||||
onCreateCharacter={createPlayerCharacter}
|
||||
onEditCharacter={editPlayerCharacter}
|
||||
onDeleteCharacter={deletePlayerCharacter}
|
||||
/>
|
||||
<PlayerCharacterSection ref={playerCharacterRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { App } from "../App";
|
||||
import { App } from "../App.js";
|
||||
import { AllProviders } from "./test-providers.js";
|
||||
|
||||
// Mock persistence — no localStorage interaction
|
||||
vi.mock("../persistence/encounter-storage.js", () => ({
|
||||
@@ -76,7 +77,7 @@ async function addCombatant(
|
||||
describe("App integration", () => {
|
||||
it("adds a combatant and removes it, returning to empty state", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
render(<App />, { wrapper: AllProviders });
|
||||
|
||||
// Empty state: centered input visible, no TurnNavigation
|
||||
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
|
||||
@@ -109,7 +110,7 @@ describe("App integration", () => {
|
||||
|
||||
it("advances and retreats turns across two combatants", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
render(<App />, { wrapper: AllProviders });
|
||||
|
||||
await addCombatant(user, "Fighter");
|
||||
await addCombatant(user, "Wizard");
|
||||
@@ -137,12 +138,11 @@ describe("App integration", () => {
|
||||
|
||||
it("adds a combatant with HP, applies damage, and shows unconscious state", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
render(<App />, { wrapper: AllProviders });
|
||||
|
||||
await addCombatant(user, "Ogre", { maxHp: "59" });
|
||||
|
||||
// Verify HP displays — currentHp and maxHp both show "59"
|
||||
expect(screen.getByText("/")).toBeInTheDocument();
|
||||
const hpButton = screen.getByRole("button", {
|
||||
name: "Current HP: 59 (healthy)",
|
||||
});
|
||||
|
||||
@@ -4,11 +4,33 @@ import "@testing-library/jest-dom/vitest";
|
||||
import type { Creature, CreatureId } from "@initiative/domain";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { StatBlockPanel } from "../components/stat-block-panel";
|
||||
|
||||
// Mock the context modules
|
||||
vi.mock("../contexts/side-panel-context.js", () => ({
|
||||
useSidePanelContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../contexts/bestiary-context.js", () => ({
|
||||
useBestiaryContext: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock adapters to avoid IndexedDB
|
||||
vi.mock("../adapters/bestiary-index-adapter.js", () => ({
|
||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||
getAllSourceCodes: () => [],
|
||||
getDefaultFetchUrl: () => "",
|
||||
getSourceDisplayName: (code: string) => code,
|
||||
}));
|
||||
|
||||
import { StatBlockPanel } from "../components/stat-block-panel.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
|
||||
const mockUseSidePanelContext = vi.mocked(useSidePanelContext);
|
||||
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
|
||||
|
||||
const CLOSE_REGEX = /close/i;
|
||||
const COLLAPSE_REGEX = /collapse/i;
|
||||
|
||||
const CREATURE_ID = "srd:goblin" as CreatureId;
|
||||
const CREATURE: Creature = {
|
||||
id: CREATURE_ID,
|
||||
@@ -44,41 +66,65 @@ function mockMatchMedia(matches: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
interface PanelProps {
|
||||
interface PanelOverrides {
|
||||
creatureId?: CreatureId | null;
|
||||
creature?: Creature | null;
|
||||
panelRole?: "browse" | "pinned";
|
||||
isCollapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
onPin?: () => void;
|
||||
onUnpin?: () => void;
|
||||
showPinButton?: boolean;
|
||||
side?: "left" | "right";
|
||||
onDismiss?: () => void;
|
||||
bulkImportMode?: boolean;
|
||||
}
|
||||
|
||||
function renderPanel(overrides: PanelProps = {}) {
|
||||
const props = {
|
||||
creatureId: CREATURE_ID,
|
||||
creature: CREATURE,
|
||||
function setupMocks(overrides: PanelOverrides = {}) {
|
||||
const panelRole = overrides.panelRole ?? "browse";
|
||||
const creatureId = overrides.creatureId ?? CREATURE_ID;
|
||||
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),
|
||||
search: vi.fn().mockReturnValue([]),
|
||||
isLoaded: true,
|
||||
fetchAndCacheSource: vi.fn(),
|
||||
uploadAndCacheSource: vi.fn(),
|
||||
refreshCache: vi.fn(),
|
||||
panelRole: "browse" as const,
|
||||
isCollapsed: false,
|
||||
onToggleCollapse: vi.fn(),
|
||||
onPin: vi.fn(),
|
||||
onUnpin: vi.fn(),
|
||||
showPinButton: false,
|
||||
side: "right" as const,
|
||||
onDismiss: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
} as ReturnType<typeof useBestiaryContext>);
|
||||
|
||||
render(<StatBlockPanel {...props} />);
|
||||
return props;
|
||||
return { onToggleCollapse, onPin, onUnpin, onDismiss };
|
||||
}
|
||||
|
||||
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", () => {
|
||||
@@ -113,19 +159,19 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
||||
});
|
||||
|
||||
it("calls onToggleCollapse when collapse button is clicked", () => {
|
||||
const props = renderPanel();
|
||||
const callbacks = renderPanel();
|
||||
fireEvent.click(
|
||||
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", () => {
|
||||
const props = renderPanel({ isCollapsed: true });
|
||||
const callbacks = renderPanel({ isCollapsed: true });
|
||||
fireEvent.click(
|
||||
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)", () => {
|
||||
@@ -163,53 +209,58 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
||||
});
|
||||
|
||||
it("calls onDismiss when backdrop is clicked on mobile", () => {
|
||||
const props = renderPanel();
|
||||
const callbacks = renderPanel();
|
||||
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", () => {
|
||||
const { container } = render(
|
||||
<StatBlockPanel
|
||||
creatureId={CREATURE_ID}
|
||||
creature={CREATURE}
|
||||
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()}
|
||||
/>,
|
||||
(() => {
|
||||
setupMocks({ panelRole: "pinned" });
|
||||
return <StatBlockPanel panelRole="pinned" side="left" />;
|
||||
})(),
|
||||
);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("US2: Pin and Unpin", () => {
|
||||
it("shows pin button when showPinButton is true on desktop", () => {
|
||||
renderPanel({ showPinButton: true });
|
||||
it("shows pin button when isWideDesktop is true on desktop", () => {
|
||||
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(
|
||||
screen.getByRole("button", { name: "Pin creature" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides pin button when showPinButton is false", () => {
|
||||
renderPanel({ showPinButton: false });
|
||||
it("hides pin button when isWideDesktop is false", () => {
|
||||
renderPanel();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Pin creature" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
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" }));
|
||||
expect(props.onPin).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.togglePin).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const props = renderPanel({ panelRole: "pinned", side: "left" });
|
||||
const callbacks = renderPanel({ panelRole: "pinned", side: "left" });
|
||||
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", () => {
|
||||
@@ -255,7 +306,7 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
||||
});
|
||||
|
||||
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", {
|
||||
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 userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ActionBar } from "../action-bar";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { ActionBar } from "../action-bar.js";
|
||||
|
||||
// Mock persistence — no localStorage interaction
|
||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
||||
loadEncounter: () => null,
|
||||
saveEncounter: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../persistence/player-character-storage.js", () => ({
|
||||
loadPlayerCharacters: () => [],
|
||||
savePlayerCharacters: () => {},
|
||||
}));
|
||||
|
||||
// Mock bestiary — no IndexedDB or JSON index
|
||||
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
||||
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
||||
isSourceCached: () => Promise.resolve(false),
|
||||
cacheSource: () => Promise.resolve(),
|
||||
getCachedSources: () => Promise.resolve([]),
|
||||
clearSource: () => Promise.resolve(),
|
||||
clearAll: () => Promise.resolve(),
|
||||
}));
|
||||
|
||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||
getAllSourceCodes: () => [],
|
||||
getDefaultFetchUrl: () => "",
|
||||
getSourceDisplayName: (code: string) => code,
|
||||
}));
|
||||
|
||||
// DOM API stubs — jsdom doesn't implement these
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const defaultProps = {
|
||||
onAddCombatant: vi.fn(),
|
||||
onAddFromBestiary: vi.fn(),
|
||||
bestiarySearch: () => [],
|
||||
bestiaryLoaded: false,
|
||||
};
|
||||
|
||||
function renderBar(overrides: Partial<Parameters<typeof ActionBar>[0]> = {}) {
|
||||
const props = { ...defaultProps, ...overrides };
|
||||
return render(<ActionBar {...props} />);
|
||||
function renderBar(props: Partial<Parameters<typeof ActionBar>[0]> = {}) {
|
||||
return render(<ActionBar {...props} />, { wrapper: AllProviders });
|
||||
}
|
||||
|
||||
describe("ActionBar", () => {
|
||||
@@ -26,26 +64,26 @@ describe("ActionBar", () => {
|
||||
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 onAddCombatant = vi.fn();
|
||||
renderBar({ onAddCombatant });
|
||||
renderBar();
|
||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||
await user.type(input, "Goblin");
|
||||
// The Add button appears when name >= 2 chars and no suggestions
|
||||
const addButton = screen.getByRole("button", { name: "Add" });
|
||||
await user.click(addButton);
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
const onAddCombatant = vi.fn();
|
||||
renderBar({ onAddCombatant });
|
||||
renderBar();
|
||||
// Submit the form directly (Enter on empty input)
|
||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||
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 () => {
|
||||
@@ -66,23 +104,18 @@ describe("ActionBar", () => {
|
||||
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows roll all initiative button when showRollAllInitiative is true", () => {
|
||||
const onRollAllInitiative = vi.fn();
|
||||
renderBar({ showRollAllInitiative: true, onRollAllInitiative });
|
||||
it("does not show roll all initiative button when no creature combatants", () => {
|
||||
renderBar();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Roll all initiative" }),
|
||||
).toBeInTheDocument();
|
||||
screen.queryByRole("button", { name: "Roll all initiative" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("roll all initiative button is disabled when rollAllInitiativeDisabled is true", () => {
|
||||
const onRollAllInitiative = vi.fn();
|
||||
renderBar({
|
||||
showRollAllInitiative: true,
|
||||
onRollAllInitiative,
|
||||
rollAllInitiativeDisabled: true,
|
||||
});
|
||||
it("shows overflow menu items", () => {
|
||||
renderBar({ onManagePlayers: vi.fn() });
|
||||
// The overflow menu should be present (it contains Player Characters etc.)
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Roll all initiative" }),
|
||||
).toBeDisabled();
|
||||
screen.getByRole("button", { name: "More actions" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,33 +1,67 @@
|
||||
// @vitest-environment jsdom
|
||||
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 userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { CombatantRow } from "../combatant-row";
|
||||
import { PLAYER_COLOR_HEX } from "../player-icon-map";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { CombatantRow } from "../combatant-row.js";
|
||||
import { PLAYER_COLOR_HEX } from "../player-icon-map.js";
|
||||
|
||||
const TEMP_HP_REGEX = /^\+\d/;
|
||||
|
||||
// Mock persistence — no localStorage interaction
|
||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
||||
loadEncounter: () => null,
|
||||
saveEncounter: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../persistence/player-character-storage.js", () => ({
|
||||
loadPlayerCharacters: () => [],
|
||||
savePlayerCharacters: () => {},
|
||||
}));
|
||||
|
||||
// Mock bestiary — no IndexedDB or JSON index
|
||||
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
||||
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
||||
isSourceCached: () => Promise.resolve(false),
|
||||
cacheSource: () => Promise.resolve(),
|
||||
getCachedSources: () => Promise.resolve([]),
|
||||
clearSource: () => Promise.resolve(),
|
||||
clearAll: () => Promise.resolve(),
|
||||
}));
|
||||
|
||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||
getAllSourceCodes: () => [],
|
||||
getDefaultFetchUrl: () => "",
|
||||
getSourceDisplayName: (code: string) => code,
|
||||
}));
|
||||
|
||||
// DOM API stubs
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const defaultProps = {
|
||||
onRename: vi.fn(),
|
||||
onSetInitiative: vi.fn(),
|
||||
onRemove: vi.fn(),
|
||||
onSetHp: vi.fn(),
|
||||
onAdjustHp: vi.fn(),
|
||||
onSetAc: vi.fn(),
|
||||
onToggleCondition: vi.fn(),
|
||||
onToggleConcentration: vi.fn(),
|
||||
};
|
||||
|
||||
function renderRow(
|
||||
overrides: Partial<{
|
||||
combatant: Parameters<typeof CombatantRow>[0]["combatant"];
|
||||
isActive: boolean;
|
||||
onRollInitiative: (id: ReturnType<typeof combatantId>) => void;
|
||||
onRemove: (id: ReturnType<typeof combatantId>) => void;
|
||||
onShowStatBlock: () => void;
|
||||
}> = {},
|
||||
) {
|
||||
const combatant = overrides.combatant ?? {
|
||||
@@ -38,15 +72,13 @@ function renderRow(
|
||||
currentHp: 10,
|
||||
ac: 13,
|
||||
};
|
||||
const props = {
|
||||
...defaultProps,
|
||||
combatant,
|
||||
isActive: overrides.isActive ?? false,
|
||||
onRollInitiative: overrides.onRollInitiative,
|
||||
onShowStatBlock: overrides.onShowStatBlock,
|
||||
onRemove: overrides.onRemove ?? defaultProps.onRemove,
|
||||
};
|
||||
return render(<CombatantRow {...props} />);
|
||||
return render(
|
||||
<CombatantRow
|
||||
combatant={combatant}
|
||||
isActive={overrides.isActive ?? false}
|
||||
/>,
|
||||
{ wrapper: AllProviders },
|
||||
);
|
||||
}
|
||||
|
||||
describe("CombatantRow", () => {
|
||||
@@ -93,14 +125,14 @@ describe("CombatantRow", () => {
|
||||
expect(nameContainer).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows '--' for current HP when no maxHp is set", () => {
|
||||
it("shows 'Max' placeholder when no maxHp is set", () => {
|
||||
renderRow({
|
||||
combatant: {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
},
|
||||
});
|
||||
expect(screen.getByLabelText("No HP set")).toBeInTheDocument();
|
||||
expect(screen.getByText("Max")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows concentration icon when isConcentrating is true", () => {
|
||||
@@ -132,10 +164,9 @@ describe("CombatantRow", () => {
|
||||
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 onRemove = vi.fn();
|
||||
renderRow({ onRemove });
|
||||
renderRow();
|
||||
const removeBtn = screen.getByRole("button", {
|
||||
name: "Remove combatant",
|
||||
});
|
||||
@@ -146,19 +177,124 @@ describe("CombatantRow", () => {
|
||||
name: "Confirm remove combatant",
|
||||
});
|
||||
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({
|
||||
combatant: {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
creatureId: "srd:goblin" as CreatureId,
|
||||
},
|
||||
onRollInitiative: vi.fn(),
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Roll initiative" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("concentration pulse", () => {
|
||||
it("pulses when currentHp drops on a concentrating combatant", () => {
|
||||
const combatant = {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
maxHp: 20,
|
||||
currentHp: 15,
|
||||
isConcentrating: true,
|
||||
};
|
||||
const { rerender, container } = renderRow({ combatant });
|
||||
rerender(
|
||||
<CombatantRow
|
||||
combatant={{ ...combatant, currentHp: 10 }}
|
||||
isActive={false}
|
||||
/>,
|
||||
);
|
||||
const row = container.firstElementChild;
|
||||
expect(row?.className).toContain("animate-concentration-pulse");
|
||||
});
|
||||
|
||||
it("does not pulse when not concentrating", () => {
|
||||
const combatant = {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
maxHp: 20,
|
||||
currentHp: 15,
|
||||
isConcentrating: false,
|
||||
};
|
||||
const { rerender, container } = renderRow({ combatant });
|
||||
rerender(
|
||||
<CombatantRow
|
||||
combatant={{ ...combatant, currentHp: 10 }}
|
||||
isActive={false}
|
||||
/>,
|
||||
);
|
||||
const row = container.firstElementChild;
|
||||
expect(row?.className).not.toContain("animate-concentration-pulse");
|
||||
});
|
||||
|
||||
it("pulses when temp HP absorbs all damage on a concentrating combatant", () => {
|
||||
const combatant = {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
maxHp: 20,
|
||||
currentHp: 15,
|
||||
tempHp: 8,
|
||||
isConcentrating: true,
|
||||
};
|
||||
const { rerender, container } = renderRow({ combatant });
|
||||
// Temp HP absorbs all damage, currentHp unchanged
|
||||
rerender(
|
||||
<CombatantRow
|
||||
combatant={{ ...combatant, tempHp: 3 }}
|
||||
isActive={false}
|
||||
/>,
|
||||
);
|
||||
const row = container.firstElementChild;
|
||||
expect(row?.className).toContain("animate-concentration-pulse");
|
||||
});
|
||||
});
|
||||
|
||||
describe("temp HP display", () => {
|
||||
it("shows +N when combatant has temp HP", () => {
|
||||
renderRow({
|
||||
combatant: {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
maxHp: 20,
|
||||
currentHp: 15,
|
||||
tempHp: 5,
|
||||
},
|
||||
});
|
||||
expect(screen.getByText("+5")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show +N when combatant has no temp HP", () => {
|
||||
renderRow({
|
||||
combatant: {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
maxHp: 20,
|
||||
currentHp: 15,
|
||||
},
|
||||
});
|
||||
expect(screen.queryByText(TEMP_HP_REGEX)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("temp HP display uses cyan color", () => {
|
||||
renderRow({
|
||||
combatant: {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
maxHp: 20,
|
||||
currentHp: 15,
|
||||
tempHp: 8,
|
||||
},
|
||||
});
|
||||
const tempHpEl = screen.getByText("+8");
|
||||
expect(tempHpEl.className).toContain("text-cyan-400");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import "@testing-library/jest-dom/vitest";
|
||||
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRef, type RefObject } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ConditionPicker } from "../condition-picker";
|
||||
|
||||
@@ -18,8 +19,13 @@ function renderPicker(
|
||||
) {
|
||||
const onToggle = overrides.onToggle ?? vi.fn();
|
||||
const onClose = overrides.onClose ?? vi.fn();
|
||||
const anchorRef = createRef<HTMLElement>() as RefObject<HTMLElement>;
|
||||
const anchor = document.createElement("div");
|
||||
document.body.appendChild(anchor);
|
||||
(anchorRef as { current: HTMLElement }).current = anchor;
|
||||
const result = render(
|
||||
<ConditionPicker
|
||||
anchorRef={anchorRef}
|
||||
activeConditions={overrides.activeConditions ?? []}
|
||||
onToggle={onToggle}
|
||||
onClose={onClose}
|
||||
|
||||
@@ -11,15 +11,21 @@ afterEach(cleanup);
|
||||
function renderPopover(
|
||||
overrides: Partial<{
|
||||
onAdjust: (delta: number) => void;
|
||||
onSetTempHp: (value: number) => void;
|
||||
onClose: () => void;
|
||||
}> = {},
|
||||
) {
|
||||
const onAdjust = overrides.onAdjust ?? vi.fn();
|
||||
const onSetTempHp = overrides.onSetTempHp ?? vi.fn();
|
||||
const onClose = overrides.onClose ?? vi.fn();
|
||||
const result = render(
|
||||
<HpAdjustPopover onAdjust={onAdjust} onClose={onClose} />,
|
||||
<HpAdjustPopover
|
||||
onAdjust={onAdjust}
|
||||
onSetTempHp={onSetTempHp}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
return { ...result, onAdjust, onClose };
|
||||
return { ...result, onAdjust, onSetTempHp, onClose };
|
||||
}
|
||||
|
||||
describe("HpAdjustPopover", () => {
|
||||
@@ -112,4 +118,31 @@ describe("HpAdjustPopover", () => {
|
||||
await user.type(input, "12abc34");
|
||||
expect(input).toHaveValue("1234");
|
||||
});
|
||||
|
||||
describe("temp HP", () => {
|
||||
it("shield button calls onSetTempHp with entered value and closes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSetTempHp, onClose } = renderPopover();
|
||||
await user.type(screen.getByPlaceholderText("HP"), "8");
|
||||
await user.click(screen.getByRole("button", { name: "Set temp HP" }));
|
||||
expect(onSetTempHp).toHaveBeenCalledWith(8);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shield button is disabled when input is empty", () => {
|
||||
renderPopover();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Set temp HP" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shield button is disabled when input is '0'", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPopover();
|
||||
await user.type(screen.getByPlaceholderText("HP"), "0");
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Set temp HP" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,28 +11,51 @@ vi.mock("../../adapters/bestiary-cache.js", () => ({
|
||||
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 { SourceManager } from "../source-manager";
|
||||
import { useBestiaryContext } from "../../contexts/bestiary-context.js";
|
||||
import { SourceManager } from "../source-manager.js";
|
||||
|
||||
const mockGetCachedSources = vi.mocked(bestiaryCache.getCachedSources);
|
||||
const mockClearSource = vi.mocked(bestiaryCache.clearSource);
|
||||
const mockClearAll = vi.mocked(bestiaryCache.clearAll);
|
||||
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function setupMockContext() {
|
||||
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||
mockUseBestiaryContext.mockReturnValue({
|
||||
refreshCache,
|
||||
search: vi.fn().mockReturnValue([]),
|
||||
getCreature: vi.fn(),
|
||||
isLoaded: true,
|
||||
isSourceCached: vi.fn().mockResolvedValue(false),
|
||||
fetchAndCacheSource: vi.fn(),
|
||||
uploadAndCacheSource: vi.fn(),
|
||||
} as ReturnType<typeof useBestiaryContext>);
|
||||
return { refreshCache };
|
||||
}
|
||||
|
||||
describe("SourceManager", () => {
|
||||
it("shows 'No cached sources' empty state when no sources", async () => {
|
||||
setupMockContext();
|
||||
mockGetCachedSources.mockResolvedValue([]);
|
||||
render(<SourceManager onCacheCleared={vi.fn()} />);
|
||||
render(<SourceManager />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No cached sources")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("lists cached sources with display name and creature count", async () => {
|
||||
setupMockContext();
|
||||
mockGetCachedSources.mockResolvedValue([
|
||||
{
|
||||
sourceCode: "mm",
|
||||
@@ -47,7 +70,7 @@ describe("SourceManager", () => {
|
||||
cachedAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
render(<SourceManager onCacheCleared={vi.fn()} />);
|
||||
render(<SourceManager />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||
});
|
||||
@@ -56,9 +79,9 @@ describe("SourceManager", () => {
|
||||
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 onCacheCleared = vi.fn();
|
||||
const { refreshCache } = setupMockContext();
|
||||
mockGetCachedSources
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
@@ -70,7 +93,7 @@ describe("SourceManager", () => {
|
||||
])
|
||||
.mockResolvedValue([]);
|
||||
mockClearAll.mockResolvedValue(undefined);
|
||||
render(<SourceManager onCacheCleared={onCacheCleared} />);
|
||||
render(<SourceManager />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||
@@ -80,12 +103,12 @@ describe("SourceManager", () => {
|
||||
await waitFor(() => {
|
||||
expect(mockClearAll).toHaveBeenCalled();
|
||||
});
|
||||
expect(onCacheCleared).toHaveBeenCalled();
|
||||
expect(refreshCache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("individual source delete button calls clear for that source", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCacheCleared = vi.fn();
|
||||
const { refreshCache } = setupMockContext();
|
||||
mockGetCachedSources
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
@@ -111,7 +134,7 @@ describe("SourceManager", () => {
|
||||
]);
|
||||
mockClearSource.mockResolvedValue(undefined);
|
||||
|
||||
render(<SourceManager onCacheCleared={onCacheCleared} />);
|
||||
render(<SourceManager />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||
});
|
||||
@@ -122,6 +145,6 @@ describe("SourceManager", () => {
|
||||
await waitFor(() => {
|
||||
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 { cleanup, render, screen } from "@testing-library/react";
|
||||
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 = {
|
||||
combatants: [
|
||||
{ id: combatantId("1"), name: "Goblin" },
|
||||
@@ -20,14 +32,40 @@ function renderNav(overrides: Partial<Encounter> = {}) {
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return render(
|
||||
<TurnNavigation
|
||||
encounter={encounter}
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
/>,
|
||||
const value = {
|
||||
encounter,
|
||||
advanceTurn: vi.fn(),
|
||||
retreatTurn: vi.fn(),
|
||||
clearEncounter: 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", () => {
|
||||
@@ -49,80 +87,39 @@ describe("TurnNavigation", () => {
|
||||
|
||||
it("does not render an em dash between round and name", () => {
|
||||
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", () => {
|
||||
renderNav();
|
||||
const badge = screen.getByText("R1");
|
||||
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", () => {
|
||||
const { rerender } = render(
|
||||
<TurnNavigation
|
||||
encounter={{
|
||||
combatants: [{ id: combatantId("1"), name: "Goblin" }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 2,
|
||||
}}
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
mockContext({ roundNumber: 2 });
|
||||
const { rerender } = render(<TurnNavigation />);
|
||||
expect(screen.getByText("R2")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<TurnNavigation
|
||||
encounter={{
|
||||
combatants: [{ id: combatantId("1"), name: "Goblin" }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 3,
|
||||
}}
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
mockContext({ roundNumber: 3 });
|
||||
rerender(<TurnNavigation />);
|
||||
expect(screen.getByText("R3")).toBeInTheDocument();
|
||||
expect(screen.queryByText("R2")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the next combatant name when turn advances", () => {
|
||||
const { rerender } = render(
|
||||
<TurnNavigation
|
||||
encounter={{
|
||||
combatants: [
|
||||
{ id: combatantId("1"), name: "Goblin" },
|
||||
{ id: combatantId("2"), name: "Conjurer" },
|
||||
],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
}}
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const combatants = [
|
||||
{ id: combatantId("1"), name: "Goblin" },
|
||||
{ id: combatantId("2"), name: "Conjurer" },
|
||||
];
|
||||
mockContext({ combatants, activeIndex: 0 });
|
||||
const { rerender } = render(<TurnNavigation />);
|
||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||
|
||||
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()}
|
||||
/>,
|
||||
);
|
||||
mockContext({ combatants, activeIndex: 1 });
|
||||
rerender(<TurnNavigation />);
|
||||
expect(screen.getByText("Conjurer")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,17 +19,15 @@ export function AcShield({ value, onClick, className }: AcShieldProps) {
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 28 32"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="var(--color-border)"
|
||||
fillOpacity={0.5}
|
||||
stroke="none"
|
||||
className="absolute inset-0 h-full w-full"
|
||||
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" />
|
||||
</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)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import type { CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||
import {
|
||||
Check,
|
||||
Eye,
|
||||
@@ -12,11 +12,25 @@ import {
|
||||
Sun,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import React, { type RefObject, useDeferredValue, useState } from "react";
|
||||
import type { SearchResult } from "../hooks/use-bestiary.js";
|
||||
import React, {
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { SearchResult } from "../contexts/bestiary-context.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
import { useThemeContext } from "../contexts/theme-context.js";
|
||||
import { useLongPress } from "../hooks/use-long-press.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { D20Icon } from "./d20-icon.js";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
|
||||
import { RollModeMenu } from "./roll-mode-menu.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
|
||||
@@ -27,27 +41,9 @@ interface QueuedCreature {
|
||||
}
|
||||
|
||||
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>;
|
||||
playerCharacters?: readonly PlayerCharacter[];
|
||||
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
||||
onManagePlayers?: () => void;
|
||||
onRollAllInitiative?: () => void;
|
||||
showRollAllInitiative?: boolean;
|
||||
rollAllInitiativeDisabled?: boolean;
|
||||
onOpenSourceManager?: () => void;
|
||||
autoFocus?: boolean;
|
||||
themePreference?: "system" | "light" | "dark";
|
||||
onCycleTheme?: () => void;
|
||||
onManagePlayers?: () => void;
|
||||
}
|
||||
|
||||
function creatureKey(r: SearchResult): string {
|
||||
@@ -278,25 +274,48 @@ function buildOverflowItems(opts: {
|
||||
}
|
||||
|
||||
export function ActionBar({
|
||||
onAddCombatant,
|
||||
onAddFromBestiary,
|
||||
bestiarySearch,
|
||||
bestiaryLoaded,
|
||||
onViewStatBlock,
|
||||
onBulkImport,
|
||||
bulkImportDisabled,
|
||||
inputRef,
|
||||
playerCharacters,
|
||||
onAddFromPlayerCharacter,
|
||||
onManagePlayers,
|
||||
onRollAllInitiative,
|
||||
showRollAllInitiative,
|
||||
rollAllInitiativeDisabled,
|
||||
onOpenSourceManager,
|
||||
autoFocus,
|
||||
themePreference,
|
||||
onCycleTheme,
|
||||
onManagePlayers,
|
||||
}: Readonly<ActionBarProps>) {
|
||||
const {
|
||||
addCombatant,
|
||||
addFromBestiary,
|
||||
addFromPlayerCharacter,
|
||||
hasCreatureCombatants,
|
||||
canRollAllInitiative,
|
||||
} = useEncounterContext();
|
||||
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
|
||||
useBestiaryContext();
|
||||
const { characters: playerCharacters } = usePlayerCharactersContext();
|
||||
const { showBulkImport, showSourceManager, showCreature, panelView } =
|
||||
useSidePanelContext();
|
||||
const { preference: themePreference, cycleTheme } = useThemeContext();
|
||||
const { handleRollAllInitiative } = useInitiativeRollsContext();
|
||||
const { state: bulkImportState } = useBulkImportContext();
|
||||
|
||||
const handleAddFromBestiary = useCallback(
|
||||
(result: SearchResult) => {
|
||||
const creatureId = addFromBestiary(result);
|
||||
if (creatureId && panelView.mode === "closed") {
|
||||
showCreature(creatureId);
|
||||
}
|
||||
},
|
||||
[addFromBestiary, panelView.mode, showCreature],
|
||||
);
|
||||
|
||||
const handleViewStatBlock = useCallback(
|
||||
(result: SearchResult) => {
|
||||
const slug = result.name
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||
.replaceAll(/(^-|-$)/g, "");
|
||||
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
||||
showCreature(cId);
|
||||
},
|
||||
[showCreature],
|
||||
);
|
||||
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
||||
@@ -333,7 +352,7 @@ export function ActionBar({
|
||||
const confirmQueued = () => {
|
||||
if (!queued) return;
|
||||
for (let i = 0; i < queued.count; i++) {
|
||||
onAddFromBestiary(queued.result);
|
||||
handleAddFromBestiary(queued.result);
|
||||
}
|
||||
clearInput();
|
||||
};
|
||||
@@ -359,7 +378,7 @@ export function ActionBar({
|
||||
if (init !== undefined) opts.initiative = init;
|
||||
if (ac !== undefined) opts.ac = ac;
|
||||
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("");
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
@@ -461,32 +480,60 @@ export function ActionBar({
|
||||
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
||||
} else if (e.key === "Enter" && suggestionIndex >= 0) {
|
||||
e.preventDefault();
|
||||
onViewStatBlock?.(suggestions[suggestionIndex]);
|
||||
handleViewStatBlock(suggestions[suggestionIndex]);
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowseSelect = (result: SearchResult) => {
|
||||
onViewStatBlock?.(result);
|
||||
handleViewStatBlock(result);
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
};
|
||||
|
||||
const toggleBrowseMode = () => {
|
||||
setBrowseMode((m) => !m);
|
||||
clearInput();
|
||||
setBrowseMode((prev) => {
|
||||
const next = !prev;
|
||||
setSuggestionIndex(-1);
|
||||
setQueued(null);
|
||||
if (next) {
|
||||
handleBrowseSearch(nameInput);
|
||||
} else {
|
||||
handleAddSearch(nameInput);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
clearCustomFields();
|
||||
};
|
||||
|
||||
const [rollAllMenuPos, setRollAllMenuPos] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
const openRollAllMenu = useCallback((x: number, y: number) => {
|
||||
setRollAllMenuPos({ x, y });
|
||||
}, []);
|
||||
|
||||
const rollAllLongPress = useLongPress(
|
||||
useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
if (touch) openRollAllMenu(touch.clientX, touch.clientY);
|
||||
},
|
||||
[openRollAllMenu],
|
||||
),
|
||||
);
|
||||
|
||||
const overflowItems = buildOverflowItems({
|
||||
onManagePlayers,
|
||||
onOpenSourceManager,
|
||||
onOpenSourceManager: showSourceManager,
|
||||
bestiaryLoaded,
|
||||
onBulkImport,
|
||||
bulkImportDisabled,
|
||||
onBulkImport: showBulkImport,
|
||||
bulkImportDisabled: bulkImportState.status === "loading",
|
||||
themePreference,
|
||||
onCycleTheme,
|
||||
onCycleTheme: cycleTheme,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -509,7 +556,7 @@ export function ActionBar({
|
||||
className="pr-8"
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
{bestiaryLoaded && !!onViewStatBlock && (
|
||||
{!!bestiaryLoaded && (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
@@ -517,6 +564,7 @@ export function ActionBar({
|
||||
"absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
|
||||
browseMode && "text-accent",
|
||||
)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={toggleBrowseMode}
|
||||
title={browseMode ? "Switch to add mode" : "Browse stat blocks"}
|
||||
aria-label={
|
||||
@@ -570,7 +618,7 @@ export function ActionBar({
|
||||
onSetSuggestionIndex={setSuggestionIndex}
|
||||
onSetQueued={setQueued}
|
||||
onConfirmQueued={confirmQueued}
|
||||
onAddFromPlayerCharacter={onAddFromPlayerCharacter}
|
||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -606,19 +654,33 @@ export function ActionBar({
|
||||
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||
<Button type="submit">Add</Button>
|
||||
)}
|
||||
{showRollAllInitiative && !!onRollAllInitiative && (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-hover-action"
|
||||
onClick={onRollAllInitiative}
|
||||
disabled={rollAllInitiativeDisabled}
|
||||
title="Roll all initiative"
|
||||
aria-label="Roll all initiative"
|
||||
>
|
||||
<D20Icon className="h-6 w-6" />
|
||||
</Button>
|
||||
{!!hasCreatureCombatants && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-hover-action"
|
||||
onClick={() => handleRollAllInitiative()}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openRollAllMenu(e.clientX, e.clientY);
|
||||
}}
|
||||
{...rollAllLongPress}
|
||||
disabled={!canRollAllInitiative}
|
||||
title="Roll all initiative"
|
||||
aria-label="Roll all initiative"
|
||||
>
|
||||
<D20Icon className="h-6 w-6" />
|
||||
</Button>
|
||||
{!!rollAllMenuPos && (
|
||||
<RollModeMenu
|
||||
position={rollAllMenuPos}
|
||||
onSelect={(mode) => handleRollAllInitiative(mode)}
|
||||
onClose={() => setRollAllMenuPos(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
|
||||
</form>
|
||||
|
||||
@@ -1,35 +1,41 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
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 { Input } from "./ui/input.js";
|
||||
|
||||
const DEFAULT_BASE_URL =
|
||||
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
|
||||
|
||||
interface BulkImportPromptProps {
|
||||
importState: BulkImportState;
|
||||
onStartImport: (baseUrl: string) => void;
|
||||
onDone: () => void;
|
||||
}
|
||||
export function BulkImportPrompt() {
|
||||
const { fetchAndCacheSource, isSourceCached, refreshCache } =
|
||||
useBestiaryContext();
|
||||
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 baseUrlId = useId();
|
||||
const totalSources = getAllSourceCodes().length;
|
||||
|
||||
const handleStart = (url: string) => {
|
||||
startImport(url, fetchAndCacheSource, isSourceCached, refreshCache);
|
||||
};
|
||||
|
||||
const handleDone = () => {
|
||||
dismissPanel();
|
||||
reset();
|
||||
};
|
||||
|
||||
if (importState.status === "complete") {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-green-400 text-sm">
|
||||
All sources loaded
|
||||
</div>
|
||||
<Button onClick={onDone}>Done</Button>
|
||||
<Button onClick={handleDone}>Done</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -41,7 +47,7 @@ export function BulkImportPrompt({
|
||||
Loaded {importState.completed}/{importState.total} sources (
|
||||
{importState.failed} failed)
|
||||
</div>
|
||||
<Button onClick={onDone}>Done</Button>
|
||||
<Button onClick={handleDone}>Done</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -96,7 +102,7 @@ export function BulkImportPrompt({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => onStartImport(baseUrl)} disabled={isDisabled}>
|
||||
<Button onClick={() => handleStart(baseUrl)} disabled={isDisabled}>
|
||||
Load All
|
||||
</Button>
|
||||
</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";
|
||||
|
||||
interface BulkImportToastsProps {
|
||||
state: BulkImportState;
|
||||
visible: boolean;
|
||||
onReset: () => void;
|
||||
}
|
||||
export function BulkImportToasts() {
|
||||
const { state, reset } = useBulkImportContext();
|
||||
const { bulkImportMode, isRightPanelCollapsed } = useSidePanelContext();
|
||||
const visible = !bulkImportMode || isRightPanelCollapsed;
|
||||
|
||||
export function BulkImportToasts({
|
||||
state,
|
||||
visible,
|
||||
onReset,
|
||||
}: Readonly<BulkImportToastsProps>) {
|
||||
if (!visible) return null;
|
||||
|
||||
if (state.status === "loading") {
|
||||
@@ -30,7 +25,7 @@ export function BulkImportToasts({
|
||||
return (
|
||||
<Toast
|
||||
message="All sources loaded"
|
||||
onDismiss={onReset}
|
||||
onDismiss={reset}
|
||||
autoDismissMs={3000}
|
||||
/>
|
||||
);
|
||||
@@ -40,7 +35,7 @@ export function BulkImportToasts({
|
||||
return (
|
||||
<Toast
|
||||
message={`Loaded ${state.completed}/${state.total} sources (${state.failed} failed)`}
|
||||
onDismiss={onReset}
|
||||
onDismiss={reset}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import {
|
||||
type CombatantId,
|
||||
type ConditionId,
|
||||
type CreatureId,
|
||||
deriveHpStatus,
|
||||
type PlayerIcon,
|
||||
type RollMode,
|
||||
} 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 { cn } from "../lib/utils";
|
||||
import { AcShield } from "./ac-shield";
|
||||
import { ConditionPicker } from "./condition-picker";
|
||||
import { ConditionTags } from "./condition-tags";
|
||||
import { D20Icon } from "./d20-icon";
|
||||
import { HpAdjustPopover } from "./hp-adjust-popover";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||
import { ConfirmButton } from "./ui/confirm-button";
|
||||
import { Input } from "./ui/input";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
import { useLongPress } from "../hooks/use-long-press.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { AcShield } from "./ac-shield.js";
|
||||
import { ConditionPicker } from "./condition-picker.js";
|
||||
import { ConditionTags } from "./condition-tags.js";
|
||||
import { D20Icon } from "./d20-icon.js";
|
||||
import { HpAdjustPopover } from "./hp-adjust-popover.js";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
|
||||
import { RollModeMenu } from "./roll-mode-menu.js";
|
||||
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
interface Combatant {
|
||||
readonly id: CombatantId;
|
||||
@@ -22,27 +29,18 @@ interface Combatant {
|
||||
readonly initiative?: number;
|
||||
readonly maxHp?: number;
|
||||
readonly currentHp?: number;
|
||||
readonly tempHp?: number;
|
||||
readonly ac?: number;
|
||||
readonly conditions?: readonly ConditionId[];
|
||||
readonly isConcentrating?: boolean;
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
readonly creatureId?: CreatureId;
|
||||
}
|
||||
|
||||
interface CombatantRowProps {
|
||||
combatant: Combatant;
|
||||
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) => void;
|
||||
}
|
||||
|
||||
function EditableName({
|
||||
@@ -50,11 +48,13 @@ function EditableName({
|
||||
combatantId,
|
||||
onRename,
|
||||
color,
|
||||
onToggleStatBlock,
|
||||
}: Readonly<{
|
||||
name: string;
|
||||
combatantId: CombatantId;
|
||||
onRename: (id: CombatantId, newName: string) => void;
|
||||
color?: string;
|
||||
onToggleStatBlock?: () => void;
|
||||
}>) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(name);
|
||||
@@ -92,14 +92,31 @@ function EditableName({
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={startEditing}
|
||||
className="cursor-text truncate text-left text-foreground text-sm transition-colors hover:text-hover-neutral"
|
||||
style={color ? { color } : undefined}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleStatBlock}
|
||||
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}
|
||||
>
|
||||
{name}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,7 +172,12 @@ function MaxHpDisplay({
|
||||
<button
|
||||
type="button"
|
||||
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"}
|
||||
</button>
|
||||
@@ -165,51 +187,47 @@ function MaxHpDisplay({
|
||||
function ClickableHp({
|
||||
currentHp,
|
||||
maxHp,
|
||||
tempHp,
|
||||
onAdjust,
|
||||
dimmed,
|
||||
onSetTempHp,
|
||||
}: Readonly<{
|
||||
currentHp: number | undefined;
|
||||
maxHp: number | undefined;
|
||||
tempHp: number | undefined;
|
||||
onAdjust: (delta: number) => void;
|
||||
dimmed?: boolean;
|
||||
onSetTempHp: (value: number) => void;
|
||||
}>) {
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const status = deriveHpStatus(currentHp, maxHp);
|
||||
|
||||
if (maxHp === undefined) {
|
||||
return (
|
||||
<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 null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPopoverOpen(true)}
|
||||
aria-label={`Current HP: ${currentHp} (${status})`}
|
||||
aria-label={`Current HP: ${currentHp}${tempHp ? ` (+${tempHp} temp)` : ""} (${status})`}
|
||||
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 === "unconscious" && "text-red-400",
|
||||
status === "healthy" && "text-foreground",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
>
|
||||
{currentHp}
|
||||
</button>
|
||||
{!!tempHp && (
|
||||
<span className="font-medium text-cyan-400 text-sm leading-7">
|
||||
+{tempHp}
|
||||
</span>
|
||||
)}
|
||||
{!!popoverOpen && (
|
||||
<HpAdjustPopover
|
||||
onAdjust={onAdjust}
|
||||
onSetTempHp={onSetTempHp}
|
||||
onClose={() => setPopoverOpen(false)}
|
||||
/>
|
||||
)}
|
||||
@@ -279,11 +297,29 @@ function InitiativeDisplay({
|
||||
combatantId: CombatantId;
|
||||
dimmed: boolean;
|
||||
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
|
||||
onRollInitiative?: (id: CombatantId) => void;
|
||||
onRollInitiative?: (id: CombatantId, mode?: RollMode) => void;
|
||||
}>) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(initiative?.toString() ?? "");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [menuPos, setMenuPos] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
const openMenu = useCallback((x: number, y: number) => {
|
||||
setMenuPos({ x, y });
|
||||
}, []);
|
||||
|
||||
const longPress = useLongPress(
|
||||
useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
if (touch) openMenu(touch.clientX, touch.clientY);
|
||||
},
|
||||
[openMenu],
|
||||
),
|
||||
);
|
||||
|
||||
const commit = useCallback(() => {
|
||||
if (draft === "") {
|
||||
@@ -312,7 +348,7 @@ function InitiativeDisplay({
|
||||
value={draft}
|
||||
placeholder="--"
|
||||
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",
|
||||
)}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
@@ -325,26 +361,40 @@ function InitiativeDisplay({
|
||||
);
|
||||
}
|
||||
|
||||
// Empty + bestiary creature → d20 roll button
|
||||
// Empty + bestiary creature -> d20 roll button
|
||||
if (initiative === undefined && onRollInitiative) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRollInitiative(combatantId)}
|
||||
className={cn(
|
||||
"flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral",
|
||||
dimmed && "opacity-50",
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRollInitiative(combatantId)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openMenu(e.clientX, e.clientY);
|
||||
}}
|
||||
{...longPress}
|
||||
className={cn(
|
||||
"flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
title="Roll initiative"
|
||||
aria-label="Roll initiative"
|
||||
>
|
||||
<D20Icon className="h-7 w-7" />
|
||||
</button>
|
||||
{!!menuPos && (
|
||||
<RollModeMenu
|
||||
position={menuPos}
|
||||
onSelect={(mode) => onRollInitiative(combatantId, mode)}
|
||||
onClose={() => setMenuPos(null)}
|
||||
/>
|
||||
)}
|
||||
title="Roll initiative"
|
||||
aria-label="Roll initiative"
|
||||
>
|
||||
<D20Icon className="h-7 w-7" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Has value → bold number, click to edit
|
||||
// Empty + manual → "--" placeholder, click to edit
|
||||
// Has value -> bold number, click to edit
|
||||
// Empty + manual -> "--" placeholder, click to edit
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -388,42 +438,67 @@ export function CombatantRow({
|
||||
ref,
|
||||
combatant,
|
||||
isActive,
|
||||
onRename,
|
||||
onSetInitiative,
|
||||
onRemove,
|
||||
onSetHp,
|
||||
onAdjustHp,
|
||||
onSetAc,
|
||||
onToggleCondition,
|
||||
onToggleConcentration,
|
||||
onShowStatBlock,
|
||||
isStatBlockOpen,
|
||||
onRollInitiative,
|
||||
}: 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 status = deriveHpStatus(currentHp, maxHp);
|
||||
const dimmed = status === "unconscious";
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const conditionAnchorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const prevHpRef = useRef(currentHp);
|
||||
const prevTempHpRef = useRef(combatant.tempHp);
|
||||
const [isPulsing, setIsPulsing] = useState(false);
|
||||
const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const prevHp = prevHpRef.current;
|
||||
const prevTempHp = prevTempHpRef.current;
|
||||
prevHpRef.current = currentHp;
|
||||
prevTempHpRef.current = combatant.tempHp;
|
||||
|
||||
if (
|
||||
prevHp !== undefined &&
|
||||
currentHp !== undefined &&
|
||||
currentHp < prevHp &&
|
||||
combatant.isConcentrating
|
||||
) {
|
||||
const realHpDropped =
|
||||
prevHp !== undefined && currentHp !== undefined && currentHp < prevHp;
|
||||
const tempHpDropped =
|
||||
prevTempHp !== undefined && (combatant.tempHp ?? 0) < prevTempHp;
|
||||
|
||||
if ((realHpDropped || tempHpDropped) && combatant.isConcentrating) {
|
||||
setIsPulsing(true);
|
||||
clearTimeout(pulseTimerRef.current);
|
||||
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
|
||||
}
|
||||
}, [currentHp, combatant.isConcentrating]);
|
||||
}, [currentHp, combatant.tempHp, combatant.isConcentrating]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!combatant.isConcentrating) {
|
||||
@@ -445,11 +520,11 @@ export function CombatantRow({
|
||||
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 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleConcentration(id)}
|
||||
onClick={() => toggleConcentration(id)}
|
||||
title="Concentrating"
|
||||
aria-label="Toggle concentration"
|
||||
className={cn(
|
||||
@@ -461,13 +536,20 @@ export function CombatantRow({
|
||||
</button>
|
||||
|
||||
{/* Initiative */}
|
||||
<InitiativeDisplay
|
||||
initiative={initiative}
|
||||
combatantId={id}
|
||||
dimmed={dimmed}
|
||||
onSetInitiative={onSetInitiative}
|
||||
onRollInitiative={onRollInitiative}
|
||||
/>
|
||||
<div className="rounded-md bg-muted/30 px-1">
|
||||
<InitiativeDisplay
|
||||
initiative={initiative}
|
||||
combatantId={id}
|
||||
dimmed={dimmed}
|
||||
onSetInitiative={setInitiative}
|
||||
onRollInitiative={onRollInitiative}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* AC */}
|
||||
<div className={cn(dimmed && "opacity-50")}>
|
||||
<AcDisplay ac={combatant.ac} onCommit={(v) => setAc(id, v)} />
|
||||
</div>
|
||||
|
||||
{/* Name + Conditions */}
|
||||
<div
|
||||
@@ -476,17 +558,6 @@ export function CombatantRow({
|
||||
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.color &&
|
||||
(() => {
|
||||
@@ -506,56 +577,55 @@ export function CombatantRow({
|
||||
<EditableName
|
||||
name={name}
|
||||
combatantId={id}
|
||||
onRename={onRename}
|
||||
onRename={editCombatant}
|
||||
color={pcColor}
|
||||
onToggleStatBlock={onToggleStatBlock}
|
||||
/>
|
||||
<ConditionTags
|
||||
conditions={combatant.conditions}
|
||||
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
|
||||
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
||||
/>
|
||||
<div ref={conditionAnchorRef}>
|
||||
<ConditionTags
|
||||
conditions={combatant.conditions}
|
||||
onRemove={(conditionId) => toggleCondition(id, conditionId)}
|
||||
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
||||
/>
|
||||
</div>
|
||||
{!!pickerOpen && (
|
||||
<ConditionPicker
|
||||
anchorRef={conditionAnchorRef}
|
||||
activeConditions={combatant.conditions}
|
||||
onToggle={(conditionId) => onToggleCondition(id, conditionId)}
|
||||
onToggle={(conditionId) => toggleCondition(id, conditionId)}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AC */}
|
||||
<div className={cn(dimmed && "opacity-50")}>
|
||||
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
|
||||
</div>
|
||||
|
||||
{/* HP */}
|
||||
<div className="flex items-center gap-1">
|
||||
<div
|
||||
className={cn(
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
<ClickableHp
|
||||
currentHp={currentHp}
|
||||
maxHp={maxHp}
|
||||
onAdjust={(delta) => onAdjustHp(id, delta)}
|
||||
dimmed={dimmed}
|
||||
tempHp={combatant.tempHp}
|
||||
onAdjust={(delta) => adjustHp(id, delta)}
|
||||
onSetTempHp={(value) => setTempHp(id, value)}
|
||||
/>
|
||||
{maxHp !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground text-sm tabular-nums",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
>
|
||||
/
|
||||
</span>
|
||||
<span className="text-muted-foreground/50 text-xs">/</span>
|
||||
)}
|
||||
<div className={cn(dimmed && "opacity-50")}>
|
||||
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => onSetHp(id, v)} />
|
||||
</div>
|
||||
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => setHp(id, v)} />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<ConfirmButton
|
||||
icon={<X size={16} />}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,9 @@ import {
|
||||
ZapOff,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Tooltip } from "./ui/tooltip.js";
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
EyeOff,
|
||||
@@ -52,34 +54,45 @@ const COLOR_CLASSES: Record<string, string> = {
|
||||
};
|
||||
|
||||
interface ConditionPickerProps {
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
activeConditions: readonly ConditionId[] | undefined;
|
||||
onToggle: (conditionId: ConditionId) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ConditionPicker({
|
||||
anchorRef,
|
||||
activeConditions,
|
||||
onToggle,
|
||||
onClose,
|
||||
}: Readonly<ConditionPickerProps>) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [flipped, setFlipped] = useState(false);
|
||||
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined);
|
||||
const [pos, setPos] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
maxHeight: number;
|
||||
} | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const anchor = anchorRef.current;
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - rect.top;
|
||||
const spaceAbove = rect.bottom;
|
||||
const shouldFlip =
|
||||
rect.bottom > window.innerHeight && spaceAbove > spaceBelow;
|
||||
setFlipped(shouldFlip);
|
||||
const available = shouldFlip ? spaceAbove : spaceBelow;
|
||||
if (rect.height > available) {
|
||||
setMaxHeight(available - 16);
|
||||
}
|
||||
}, []);
|
||||
if (!anchor || !el) return;
|
||||
|
||||
const anchorRect = anchor.getBoundingClientRect();
|
||||
const menuHeight = el.scrollHeight;
|
||||
const pad = 8;
|
||||
|
||||
const spaceBelow = window.innerHeight - anchorRect.bottom - pad;
|
||||
const spaceAbove = anchorRect.top - pad;
|
||||
const openBelow = spaceBelow >= menuHeight || spaceBelow >= spaceAbove;
|
||||
|
||||
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(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
@@ -93,14 +106,15 @@ export function ConditionPicker({
|
||||
|
||||
const active = new Set(activeConditions ?? []);
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"card-glow absolute left-0 z-10 w-fit overflow-y-auto rounded-lg border border-border bg-background p-1",
|
||||
flipped ? "bottom-full mb-1" : "top-full mt-1",
|
||||
)}
|
||||
style={maxHeight ? { maxHeight } : undefined}
|
||||
className="card-glow fixed z-50 w-fit overflow-y-auto rounded-lg border border-border bg-background p-1"
|
||||
style={
|
||||
pos
|
||||
? { top: pos.top, left: pos.left, maxHeight: pos.maxHeight }
|
||||
: { visibility: "hidden" as const }
|
||||
}
|
||||
>
|
||||
{CONDITION_DEFINITIONS.map((def) => {
|
||||
const Icon = ICON_MAP[def.iconName];
|
||||
@@ -108,27 +122,31 @@ export function ConditionPicker({
|
||||
const isActive = active.has(def.id);
|
||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||
return (
|
||||
<button
|
||||
key={def.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-hover-neutral-bg",
|
||||
isActive && "bg-card/50",
|
||||
)}
|
||||
onClick={() => onToggle(def.id)}
|
||||
>
|
||||
<Icon
|
||||
size={14}
|
||||
className={isActive ? colorClass : "text-muted-foreground"}
|
||||
/>
|
||||
<span
|
||||
className={isActive ? "text-foreground" : "text-muted-foreground"}
|
||||
<Tooltip key={def.id} content={def.description} className="block">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-hover-neutral-bg",
|
||||
isActive && "bg-card/50",
|
||||
)}
|
||||
onClick={() => onToggle(def.id)}
|
||||
>
|
||||
{def.label}
|
||||
</span>
|
||||
</button>
|
||||
<Icon
|
||||
size={14}
|
||||
className={isActive ? colorClass : "text-muted-foreground"}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
isActive ? "text-foreground" : "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{def.label}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ZapOff,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { Tooltip } from "./ui/tooltip.js";
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
EyeOff,
|
||||
@@ -71,22 +72,22 @@ export function ConditionTags({
|
||||
if (!Icon) return null;
|
||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||
return (
|
||||
<button
|
||||
key={condId}
|
||||
type="button"
|
||||
title={def.label}
|
||||
aria-label={`Remove ${def.label}`}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
|
||||
colorClass,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(condId);
|
||||
}}
|
||||
>
|
||||
<Icon size={14} />
|
||||
</button>
|
||||
<Tooltip key={condId} content={`${def.label}: ${def.description}`}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${def.label}`}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
|
||||
colorClass,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(condId);
|
||||
}}
|
||||
>
|
||||
<Icon size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Heart, Sword } from "lucide-react";
|
||||
import { Heart, ShieldPlus, Sword } from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -12,10 +12,15 @@ const DIGITS_ONLY_REGEX = /^\d+$/;
|
||||
|
||||
interface HpAdjustPopoverProps {
|
||||
readonly onAdjust: (delta: number) => void;
|
||||
readonly onSetTempHp: (value: number) => void;
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
||||
export function HpAdjustPopover({
|
||||
onAdjust,
|
||||
onSetTempHp,
|
||||
onClose,
|
||||
}: HpAdjustPopoverProps) {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -130,6 +135,21 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
||||
>
|
||||
<Heart size={14} />
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -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 { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||
import { CreatePlayerModal } from "./create-player-modal.js";
|
||||
import { PlayerManagement } from "./player-management.js";
|
||||
|
||||
@@ -7,37 +8,14 @@ export interface PlayerCharacterSectionHandle {
|
||||
openManagement: () => void;
|
||||
}
|
||||
|
||||
interface PlayerCharacterSectionProps {
|
||||
characters: readonly PlayerCharacter[];
|
||||
onCreateCharacter: (
|
||||
name: string,
|
||||
ac: number,
|
||||
maxHp: number,
|
||||
color: string | undefined,
|
||||
icon: string | undefined,
|
||||
) => void;
|
||||
onEditCharacter: (
|
||||
id: PlayerCharacterId,
|
||||
fields: {
|
||||
name?: string;
|
||||
ac?: number;
|
||||
maxHp?: number;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
},
|
||||
) => void;
|
||||
onDeleteCharacter: (id: PlayerCharacterId) => void;
|
||||
}
|
||||
|
||||
export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
||||
characters,
|
||||
onCreateCharacter,
|
||||
onEditCharacter,
|
||||
onDeleteCharacter,
|
||||
ref,
|
||||
}: PlayerCharacterSectionProps & {
|
||||
}: {
|
||||
ref?: RefObject<PlayerCharacterSectionHandle | null>;
|
||||
}) {
|
||||
const { characters, createCharacter, editCharacter, deleteCharacter } =
|
||||
usePlayerCharactersContext();
|
||||
|
||||
const [managementOpen, setManagementOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editingPlayer, setEditingPlayer] = useState<
|
||||
@@ -59,7 +37,7 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
||||
}}
|
||||
onSave={(name, ac, maxHp, color, icon) => {
|
||||
if (editingPlayer) {
|
||||
onEditCharacter(editingPlayer.id, {
|
||||
editCharacter(editingPlayer.id, {
|
||||
name,
|
||||
ac,
|
||||
maxHp,
|
||||
@@ -67,7 +45,7 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
||||
icon: icon ?? null,
|
||||
});
|
||||
} else {
|
||||
onCreateCharacter(name, ac, maxHp, color, icon);
|
||||
createCharacter(name, ac, maxHp, color, icon);
|
||||
}
|
||||
}}
|
||||
playerCharacter={editingPlayer}
|
||||
@@ -81,7 +59,7 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
||||
setCreateOpen(true);
|
||||
setManagementOpen(false);
|
||||
}}
|
||||
onDelete={(id) => onDeleteCharacter(id)}
|
||||
onDelete={(id) => deleteCharacter(id)}
|
||||
onCreate={() => {
|
||||
setEditingPlayer(undefined);
|
||||
setCreateOpen(true);
|
||||
|
||||
88
apps/web/src/components/roll-mode-menu.tsx
Normal file
88
apps/web/src/components/roll-mode-menu.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { RollMode } from "@initiative/domain";
|
||||
import { ChevronsDown, ChevronsUp } from "lucide-react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
interface RollModeMenuProps {
|
||||
readonly position: { x: number; y: number };
|
||||
readonly onSelect: (mode: RollMode) => void;
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
export function RollModeMenu({
|
||||
position,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: RollModeMenuProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const vw = document.documentElement.clientWidth;
|
||||
const vh = document.documentElement.clientHeight;
|
||||
|
||||
let left = position.x;
|
||||
let top = position.y;
|
||||
|
||||
if (left + rect.width > vw) left = vw - rect.width - 8;
|
||||
if (left < 8) left = 8;
|
||||
if (top + rect.height > vh) top = position.y - rect.height;
|
||||
if (top < 8) top = 8;
|
||||
|
||||
setPos({ top, left });
|
||||
}, [position.x, position.y]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="card-glow fixed z-50 min-w-40 rounded-lg border border-border bg-card py-1"
|
||||
style={
|
||||
pos
|
||||
? { top: pos.top, left: pos.left }
|
||||
: { visibility: "hidden" as const }
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-emerald-400 text-sm hover:bg-hover-neutral-bg"
|
||||
onClick={() => {
|
||||
onSelect("advantage");
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<ChevronsUp className="h-4 w-4" />
|
||||
Advantage
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-red-400 text-sm hover:bg-hover-neutral-bg"
|
||||
onClick={() => {
|
||||
onSelect("disadvantage");
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<ChevronsDown className="h-4 w-4" />
|
||||
Disadvantage
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,24 @@
|
||||
import { Download, Loader2, Upload } from "lucide-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 { Input } from "./ui/input.js";
|
||||
|
||||
interface SourceFetchPromptProps {
|
||||
sourceCode: string;
|
||||
sourceDisplayName: string;
|
||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
|
||||
onSourceLoaded: () => void;
|
||||
onUploadSource: (sourceCode: string, jsonData: unknown) => Promise<void>;
|
||||
}
|
||||
|
||||
export function SourceFetchPrompt({
|
||||
sourceCode,
|
||||
sourceDisplayName,
|
||||
fetchAndCacheSource,
|
||||
onSourceLoaded,
|
||||
onUploadSource,
|
||||
}: Readonly<SourceFetchPromptProps>) {
|
||||
const { fetchAndCacheSource, uploadAndCacheSource } = useBestiaryContext();
|
||||
const sourceDisplayName = getSourceDisplayName(sourceCode);
|
||||
const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode));
|
||||
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
|
||||
const [error, setError] = useState<string>("");
|
||||
@@ -47,7 +47,7 @@ export function SourceFetchPrompt({
|
||||
try {
|
||||
const text = await file.text();
|
||||
const json = JSON.parse(text);
|
||||
await onUploadSource(sourceCode, json);
|
||||
await uploadAndCacheSource(sourceCode, json);
|
||||
onSourceLoaded();
|
||||
} catch (err) {
|
||||
setStatus("error");
|
||||
|
||||
@@ -8,16 +8,12 @@ import {
|
||||
} from "react";
|
||||
import type { CachedSourceInfo } 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 { Input } from "./ui/input.js";
|
||||
|
||||
interface SourceManagerProps {
|
||||
onCacheCleared: () => void;
|
||||
}
|
||||
|
||||
export function SourceManager({
|
||||
onCacheCleared,
|
||||
}: Readonly<SourceManagerProps>) {
|
||||
export function SourceManager() {
|
||||
const { refreshCache } = useBestiaryContext();
|
||||
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [optimisticSources, applyOptimistic] = useOptimistic(
|
||||
@@ -44,14 +40,14 @@ export function SourceManager({
|
||||
applyOptimistic({ type: "remove", sourceCode });
|
||||
await bestiaryCache.clearSource(sourceCode);
|
||||
await loadSources();
|
||||
onCacheCleared();
|
||||
void refreshCache();
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
applyOptimistic({ type: "clear" });
|
||||
await bestiaryCache.clearAll();
|
||||
await loadSources();
|
||||
onCacheCleared();
|
||||
void refreshCache();
|
||||
};
|
||||
|
||||
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 type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
|
||||
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
||||
@@ -13,28 +13,8 @@ import { StatBlock } from "./stat-block.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
|
||||
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";
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
onPin: () => void;
|
||||
onUnpin: () => void;
|
||||
showPinButton: boolean;
|
||||
side: "left" | "right";
|
||||
onDismiss: () => void;
|
||||
bulkImportMode?: boolean;
|
||||
bulkImportState?: BulkImportState;
|
||||
onStartBulkImport?: (baseUrl: string) => void;
|
||||
onBulkImportDone?: () => void;
|
||||
sourceManagerMode?: boolean;
|
||||
}
|
||||
|
||||
function extractSourceCode(cId: CreatureId): string {
|
||||
@@ -228,27 +208,49 @@ function MobileDrawer({
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
creature,
|
||||
isCollapsed: isBrowse ? sidePanel.isRightPanelCollapsed : false,
|
||||
onToggleCollapse: isBrowse ? sidePanel.toggleCollapse : () => {},
|
||||
onDismiss: isBrowse ? sidePanel.dismissPanel : () => {},
|
||||
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({
|
||||
creatureId,
|
||||
creature,
|
||||
isSourceCached,
|
||||
fetchAndCacheSource,
|
||||
uploadAndCacheSource,
|
||||
refreshCache,
|
||||
panelRole,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
onPin,
|
||||
onUnpin,
|
||||
showPinButton,
|
||||
side,
|
||||
onDismiss,
|
||||
bulkImportMode,
|
||||
bulkImportState,
|
||||
onStartBulkImport,
|
||||
onBulkImportDone,
|
||||
sourceManagerMode,
|
||||
}: Readonly<StatBlockPanelProps>) {
|
||||
const { isSourceCached } = useBestiaryContext();
|
||||
const {
|
||||
creatureId,
|
||||
creature,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
onDismiss,
|
||||
onPin,
|
||||
onUnpin,
|
||||
showPinButton,
|
||||
bulkImportMode,
|
||||
sourceManagerMode,
|
||||
} = usePanelRole(panelRole);
|
||||
|
||||
const [isDesktop, setIsDesktop] = useState(
|
||||
() => globalThis.matchMedia("(min-width: 1024px)").matches,
|
||||
);
|
||||
@@ -285,29 +287,17 @@ export function StatBlockPanel({
|
||||
|
||||
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
|
||||
|
||||
const handleSourceLoaded = async () => {
|
||||
await refreshCache();
|
||||
const handleSourceLoaded = () => {
|
||||
setNeedsFetch(false);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (sourceManagerMode) {
|
||||
return <SourceManager onCacheCleared={refreshCache} />;
|
||||
return <SourceManager />;
|
||||
}
|
||||
|
||||
if (
|
||||
bulkImportMode &&
|
||||
bulkImportState &&
|
||||
onStartBulkImport &&
|
||||
onBulkImportDone
|
||||
) {
|
||||
return (
|
||||
<BulkImportPrompt
|
||||
importState={bulkImportState}
|
||||
onStartImport={onStartBulkImport}
|
||||
onDone={onBulkImportDone}
|
||||
/>
|
||||
);
|
||||
if (bulkImportMode) {
|
||||
return <BulkImportPrompt />;
|
||||
}
|
||||
|
||||
if (checkingCache) {
|
||||
@@ -324,10 +314,7 @@ export function StatBlockPanel({
|
||||
return (
|
||||
<SourceFetchPrompt
|
||||
sourceCode={sourceCode}
|
||||
sourceDisplayName={getSourceDisplayName(sourceCode)}
|
||||
fetchAndCacheSource={fetchAndCacheSource}
|
||||
onSourceLoaded={handleSourceLoaded}
|
||||
onUploadSource={uploadAndCacheSource}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
import type { Encounter } from "@initiative/domain";
|
||||
import { StepBack, StepForward, Trash2 } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import { ConfirmButton } from "./ui/confirm-button";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||
|
||||
interface TurnNavigationProps {
|
||||
encounter: Encounter;
|
||||
onAdvanceTurn: () => void;
|
||||
onRetreatTurn: () => void;
|
||||
onClearEncounter: () => void;
|
||||
}
|
||||
export function TurnNavigation() {
|
||||
const { encounter, advanceTurn, retreatTurn, clearEncounter } =
|
||||
useEncounterContext();
|
||||
|
||||
export function TurnNavigation({
|
||||
encounter,
|
||||
onAdvanceTurn,
|
||||
onRetreatTurn,
|
||||
onClearEncounter,
|
||||
}: Readonly<TurnNavigationProps>) {
|
||||
const hasCombatants = encounter.combatants.length > 0;
|
||||
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
||||
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
||||
@@ -25,7 +16,7 @@ export function TurnNavigation({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRetreatTurn}
|
||||
onClick={retreatTurn}
|
||||
disabled={!hasCombatants || isAtStart}
|
||||
title="Previous turn"
|
||||
aria-label="Previous turn"
|
||||
@@ -34,8 +25,10 @@ export function TurnNavigation({
|
||||
</Button>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center gap-2 text-sm">
|
||||
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 font-semibold text-foreground text-sm">
|
||||
R{encounter.roundNumber}
|
||||
<span 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}
|
||||
</span>
|
||||
</span>
|
||||
{activeCombatant ? (
|
||||
<span className="truncate font-medium">{activeCombatant.name}</span>
|
||||
@@ -48,14 +41,14 @@ export function TurnNavigation({
|
||||
<ConfirmButton
|
||||
icon={<Trash2 className="h-5 w-5" />}
|
||||
label="Clear encounter"
|
||||
onConfirm={onClearEncounter}
|
||||
onConfirm={clearEncounter}
|
||||
disabled={!hasCombatants}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onAdvanceTurn}
|
||||
onClick={advanceTurn}
|
||||
disabled={!hasCombatants}
|
||||
title="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;
|
||||
|
||||
export interface BulkImportState {
|
||||
interface BulkImportState {
|
||||
readonly status: "idle" | "loading" | "complete" | "partial-failure";
|
||||
readonly total: number;
|
||||
readonly completed: number;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
setAcUseCase,
|
||||
setHpUseCase,
|
||||
setInitiativeUseCase,
|
||||
setTempHpUseCase,
|
||||
toggleConcentrationUseCase,
|
||||
toggleConditionUseCase,
|
||||
} from "@initiative/application";
|
||||
@@ -215,6 +216,19 @@ export function useEncounter() {
|
||||
[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(
|
||||
(id: CombatantId, value: number | undefined) => {
|
||||
const result = setAcUseCase(makeStore(), id, value);
|
||||
@@ -376,6 +390,10 @@ export function useEncounter() {
|
||||
[makeStore],
|
||||
);
|
||||
|
||||
const hasTempHp = encounter.combatants.some(
|
||||
(c) => c.tempHp !== undefined && c.tempHp > 0,
|
||||
);
|
||||
|
||||
const isEmpty = encounter.combatants.length === 0;
|
||||
const hasCreatureCombatants = encounter.combatants.some(
|
||||
(c) => c.creatureId != null,
|
||||
@@ -388,6 +406,7 @@ export function useEncounter() {
|
||||
encounter,
|
||||
events,
|
||||
isEmpty,
|
||||
hasTempHp,
|
||||
hasCreatureCombatants,
|
||||
canRollAllInitiative,
|
||||
advanceTurn,
|
||||
@@ -399,6 +418,7 @@ export function useEncounter() {
|
||||
setInitiative,
|
||||
setHp,
|
||||
adjustHp,
|
||||
setTempHp,
|
||||
setAc,
|
||||
toggleCondition,
|
||||
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;
|
||||
}
|
||||
32
apps/web/src/hooks/use-long-press.ts
Normal file
32
apps/web/src/hooks/use-long-press.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
const LONG_PRESS_MS = 500;
|
||||
|
||||
export function useLongPress(onLongPress: (e: React.TouchEvent) => void) {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const firedRef = useRef(false);
|
||||
|
||||
const onTouchStart = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
firedRef.current = false;
|
||||
timerRef.current = setTimeout(() => {
|
||||
firedRef.current = true;
|
||||
onLongPress(e);
|
||||
}, LONG_PRESS_MS);
|
||||
},
|
||||
[onLongPress],
|
||||
);
|
||||
|
||||
const onTouchEnd = useCallback((e: React.TouchEvent) => {
|
||||
clearTimeout(timerRef.current);
|
||||
if (firedRef.current) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onTouchMove = useCallback(() => {
|
||||
clearTimeout(timerRef.current);
|
||||
}, []);
|
||||
|
||||
return { onTouchStart, onTouchEnd, onTouchMove };
|
||||
}
|
||||
@@ -19,6 +19,7 @@ interface SidePanelState {
|
||||
|
||||
interface SidePanelActions {
|
||||
showCreature: (creatureId: CreatureId) => void;
|
||||
updateCreature: (creatureId: CreatureId) => void;
|
||||
showBulkImport: () => void;
|
||||
showSourceManager: () => void;
|
||||
dismissPanel: () => void;
|
||||
@@ -52,6 +53,10 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
|
||||
setIsRightPanelCollapsed(false);
|
||||
}, []);
|
||||
|
||||
const updateCreature = useCallback((creatureId: CreatureId) => {
|
||||
setPanelView({ mode: "creature", creatureId });
|
||||
}, []);
|
||||
|
||||
const showBulkImport = useCallback(() => {
|
||||
setPanelView({ mode: "bulk-import" });
|
||||
setIsRightPanelCollapsed(false);
|
||||
@@ -91,6 +96,7 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
|
||||
pinnedCreatureId,
|
||||
isWideDesktop,
|
||||
showCreature,
|
||||
updateCreature,
|
||||
showBulkImport,
|
||||
showSourceManager,
|
||||
dismissPanel,
|
||||
|
||||
@@ -1,13 +1,36 @@
|
||||
import { StrictMode } from "react";
|
||||
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";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<ThemeProvider>
|
||||
<EncounterProvider>
|
||||
<BestiaryProvider>
|
||||
<PlayerCharactersProvider>
|
||||
<BulkImportProvider>
|
||||
<SidePanelProvider>
|
||||
<InitiativeRollsProvider>
|
||||
<App />
|
||||
</InitiativeRollsProvider>
|
||||
</SidePanelProvider>
|
||||
</BulkImportProvider>
|
||||
</PlayerCharactersProvider>
|
||||
</BestiaryProvider>
|
||||
</EncounterProvider>
|
||||
</ThemeProvider>
|
||||
</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": {
|
||||
"includes": [
|
||||
"**",
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
"entry": ["scripts/*.mjs"]
|
||||
},
|
||||
"packages/*": {},
|
||||
"apps/*": {}
|
||||
"apps/web": {}
|
||||
}
|
||||
}
|
||||
|
||||
19
package.json
19
package.json
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.6.0",
|
||||
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"undici": ">=7.24.0"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.7",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@biomejs/biome": "2.4.8",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"jscpd": "^4.0.8",
|
||||
"knip": "^5.85.0",
|
||||
"lefthook": "^1.11.0",
|
||||
"oxlint": "^1.55.0",
|
||||
"oxlint-tsgolint": "^0.16.0",
|
||||
"knip": "^5.88.1",
|
||||
"lefthook": "^2.1.4",
|
||||
"oxlint": "^1.56.0",
|
||||
"oxlint-tsgolint": "^0.17.1",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^3.0.0"
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
"scripts": {
|
||||
"prepare": "lefthook install",
|
||||
@@ -31,6 +31,7 @@
|
||||
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
|
||||
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
||||
"check:classnames": "node scripts/check-cn-classnames.mjs",
|
||||
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && tsc --build && vitest run && jscpd"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +161,48 @@ describe("rollAllInitiativeUseCase", () => {
|
||||
expect(store.saved).toBeNull();
|
||||
});
|
||||
|
||||
it("uses higher roll with advantage", () => {
|
||||
const enc = encounterWithCombatants([
|
||||
{ name: "A", creatureId: "creature-a" },
|
||||
]);
|
||||
const store = stubEncounterStore(enc);
|
||||
const creature = makeCreature("creature-a");
|
||||
|
||||
// Alternating rolls: 5, 15 → advantage picks 15
|
||||
// Dex 14 → modifier +2, so 15 + 2 = 17
|
||||
let call = 0;
|
||||
const result = rollAllInitiativeUseCase(
|
||||
store,
|
||||
() => (++call % 2 === 1 ? 5 : 15),
|
||||
(id) => (id === CREATURE_A ? creature : undefined),
|
||||
"advantage",
|
||||
);
|
||||
|
||||
expectSuccess(result);
|
||||
expect(requireSaved(store.saved).combatants[0].initiative).toBe(17);
|
||||
});
|
||||
|
||||
it("uses lower roll with disadvantage", () => {
|
||||
const enc = encounterWithCombatants([
|
||||
{ name: "A", creatureId: "creature-a" },
|
||||
]);
|
||||
const store = stubEncounterStore(enc);
|
||||
const creature = makeCreature("creature-a");
|
||||
|
||||
// Alternating rolls: 15, 5 → disadvantage picks 5
|
||||
// Dex 14 → modifier +2, so 5 + 2 = 7
|
||||
let call = 0;
|
||||
const result = rollAllInitiativeUseCase(
|
||||
store,
|
||||
() => (++call % 2 === 1 ? 15 : 5),
|
||||
(id) => (id === CREATURE_A ? creature : undefined),
|
||||
"disadvantage",
|
||||
);
|
||||
|
||||
expectSuccess(result);
|
||||
expect(requireSaved(store.saved).combatants[0].initiative).toBe(7);
|
||||
});
|
||||
|
||||
it("saves encounter once at the end", () => {
|
||||
const enc = encounterWithCombatants([
|
||||
{ name: "A", creatureId: "creature-a" },
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("rollInitiativeUseCase", () => {
|
||||
const result = rollInitiativeUseCase(
|
||||
store,
|
||||
combatantId("unknown"),
|
||||
10,
|
||||
[10],
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
@@ -80,7 +80,7 @@ describe("rollInitiativeUseCase", () => {
|
||||
const result = rollInitiativeUseCase(
|
||||
store,
|
||||
combatantId("Fighter"),
|
||||
10,
|
||||
[10],
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
@@ -96,7 +96,7 @@ describe("rollInitiativeUseCase", () => {
|
||||
const result = rollInitiativeUseCase(
|
||||
store,
|
||||
combatantId("Goblin"),
|
||||
10,
|
||||
[10],
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
@@ -116,7 +116,7 @@ describe("rollInitiativeUseCase", () => {
|
||||
const result = rollInitiativeUseCase(
|
||||
store,
|
||||
combatantId("Goblin"),
|
||||
10,
|
||||
[10],
|
||||
(id) => (id === GOBLIN_ID ? creature : undefined),
|
||||
);
|
||||
|
||||
@@ -124,6 +124,42 @@ describe("rollInitiativeUseCase", () => {
|
||||
expect(requireSaved(store.saved).combatants[0].initiative).toBe(12);
|
||||
});
|
||||
|
||||
it("uses higher roll with advantage", () => {
|
||||
const creature = makeCreature();
|
||||
const enc = encounterWithCreatureLink("Goblin", GOBLIN_ID);
|
||||
const store = stubEncounterStore(enc);
|
||||
|
||||
// Dex 14 -> modifier +2, advantage picks max(5, 15) = 15, 15 + 2 = 17
|
||||
const result = rollInitiativeUseCase(
|
||||
store,
|
||||
combatantId("Goblin"),
|
||||
[5, 15],
|
||||
(id) => (id === GOBLIN_ID ? creature : undefined),
|
||||
"advantage",
|
||||
);
|
||||
|
||||
expect(isDomainError(result)).toBe(false);
|
||||
expect(requireSaved(store.saved).combatants[0].initiative).toBe(17);
|
||||
});
|
||||
|
||||
it("uses lower roll with disadvantage", () => {
|
||||
const creature = makeCreature();
|
||||
const enc = encounterWithCreatureLink("Goblin", GOBLIN_ID);
|
||||
const store = stubEncounterStore(enc);
|
||||
|
||||
// Dex 14 -> modifier +2, disadvantage picks min(5, 15) = 5, 5 + 2 = 7
|
||||
const result = rollInitiativeUseCase(
|
||||
store,
|
||||
combatantId("Goblin"),
|
||||
[5, 15],
|
||||
(id) => (id === GOBLIN_ID ? creature : undefined),
|
||||
"disadvantage",
|
||||
);
|
||||
|
||||
expect(isDomainError(result)).toBe(false);
|
||||
expect(requireSaved(store.saved).combatants[0].initiative).toBe(7);
|
||||
});
|
||||
|
||||
it("applies initiative proficiency bonus correctly", () => {
|
||||
// CR 5 -> PB 3, dex 16 -> mod +3, initiativeProficiency 1
|
||||
// modifier = 3 + 1*3 = 6, roll 8 + 6 = 14
|
||||
@@ -145,7 +181,7 @@ describe("rollInitiativeUseCase", () => {
|
||||
const result = rollInitiativeUseCase(
|
||||
store,
|
||||
combatantId("Monster"),
|
||||
8,
|
||||
[8],
|
||||
(id) => (id === GOBLIN_ID ? creature : undefined),
|
||||
);
|
||||
|
||||
|
||||
@@ -21,5 +21,6 @@ export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
|
||||
export { setAcUseCase } from "./set-ac-use-case.js";
|
||||
export { setHpUseCase } from "./set-hp-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 { toggleConditionUseCase } from "./toggle-condition-use-case.js";
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
isDomainError,
|
||||
type RollMode,
|
||||
rollInitiative,
|
||||
selectRoll,
|
||||
setInitiative,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
@@ -19,6 +21,7 @@ export function rollAllInitiativeUseCase(
|
||||
store: EncounterStore,
|
||||
rollDice: () => number,
|
||||
getCreature: (id: CreatureId) => Creature | undefined,
|
||||
mode: RollMode = "normal",
|
||||
): RollAllResult | DomainError {
|
||||
let encounter = store.get();
|
||||
const allEvents: DomainEvent[] = [];
|
||||
@@ -39,7 +42,10 @@ export function rollAllInitiativeUseCase(
|
||||
cr: creature.cr,
|
||||
initiativeProficiency: creature.initiativeProficiency,
|
||||
});
|
||||
const value = rollInitiative(rollDice(), modifier);
|
||||
const roll1 = rollDice();
|
||||
const effectiveRoll =
|
||||
mode === "normal" ? roll1 : selectRoll(roll1, rollDice(), mode);
|
||||
const value = rollInitiative(effectiveRoll, modifier);
|
||||
|
||||
if (isDomainError(value)) {
|
||||
return value;
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
isDomainError,
|
||||
type RollMode,
|
||||
rollInitiative,
|
||||
selectRoll,
|
||||
setInitiative,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
@@ -14,8 +16,9 @@ import type { EncounterStore } from "./ports.js";
|
||||
export function rollInitiativeUseCase(
|
||||
store: EncounterStore,
|
||||
combatantId: CombatantId,
|
||||
diceRoll: number,
|
||||
diceRolls: readonly [number, ...number[]],
|
||||
getCreature: (id: CreatureId) => Creature | undefined,
|
||||
mode: RollMode = "normal",
|
||||
): DomainEvent[] | DomainError {
|
||||
const encounter = store.get();
|
||||
const combatant = encounter.combatants.find((c) => c.id === combatantId);
|
||||
@@ -50,7 +53,11 @@ export function rollInitiativeUseCase(
|
||||
cr: creature.cr,
|
||||
initiativeProficiency: creature.initiativeProficiency,
|
||||
});
|
||||
const value = rollInitiative(diceRoll, modifier);
|
||||
const effectiveRoll =
|
||||
mode === "normal"
|
||||
? diceRolls[0]
|
||||
: selectRoll(diceRolls[0], diceRolls[1] ?? diceRolls[0], mode);
|
||||
const value = rollInitiative(effectiveRoll, modifier);
|
||||
|
||||
if (isDomainError(value)) {
|
||||
return value;
|
||||
|
||||
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(
|
||||
name: string,
|
||||
opts?: { maxHp: number; currentHp: number },
|
||||
opts?: { maxHp: number; currentHp: number; tempHp?: number },
|
||||
): Combatant {
|
||||
return {
|
||||
id: combatantId(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);
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { rollInitiative } from "../roll-initiative.js";
|
||||
import { rollInitiative, selectRoll } from "../roll-initiative.js";
|
||||
import { isDomainError } from "../types.js";
|
||||
import { expectDomainError } from "./test-helpers.js";
|
||||
|
||||
@@ -63,3 +63,31 @@ describe("rollInitiative", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectRoll", () => {
|
||||
it("normal mode returns the first roll", () => {
|
||||
expect(selectRoll(8, 15, "normal")).toBe(8);
|
||||
});
|
||||
|
||||
it("advantage returns the higher roll", () => {
|
||||
expect(selectRoll(8, 15, "advantage")).toBe(15);
|
||||
});
|
||||
|
||||
it("advantage returns the higher roll (reversed)", () => {
|
||||
expect(selectRoll(15, 8, "advantage")).toBe(15);
|
||||
});
|
||||
|
||||
it("disadvantage returns the lower roll", () => {
|
||||
expect(selectRoll(8, 15, "disadvantage")).toBe(8);
|
||||
});
|
||||
|
||||
it("disadvantage returns the lower roll (reversed)", () => {
|
||||
expect(selectRoll(15, 8, "disadvantage")).toBe(8);
|
||||
});
|
||||
|
||||
it("equal rolls return the same value for all modes", () => {
|
||||
expect(selectRoll(12, 12, "normal")).toBe(12);
|
||||
expect(selectRoll(12, 12, "advantage")).toBe(12);
|
||||
expect(selectRoll(12, 12, "disadvantage")).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,6 +69,34 @@ describe("setHp", () => {
|
||||
expect(encounter.combatants[0].maxHp).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", () => {
|
||||
|
||||
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 newHp = Math.max(0, Math.min(target.maxHp, previousHp + delta));
|
||||
const previousTempHp = target.tempHp ?? 0;
|
||||
let newTempHp = previousTempHp;
|
||||
let effectiveDelta = delta;
|
||||
|
||||
if (delta < 0 && previousTempHp > 0) {
|
||||
const absorbed = Math.min(previousTempHp, Math.abs(delta));
|
||||
newTempHp = previousTempHp - absorbed;
|
||||
effectiveDelta = delta + absorbed;
|
||||
}
|
||||
|
||||
const newHp = Math.max(
|
||||
0,
|
||||
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",
|
||||
combatantId,
|
||||
previousHp,
|
||||
newHp,
|
||||
delta,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
encounter: {
|
||||
combatants: encounter.combatants.map((c) =>
|
||||
c.id === combatantId ? { ...c, currentHp: newHp } : c,
|
||||
c.id === combatantId
|
||||
? { ...c, currentHp: newHp, tempHp: newTempHp || undefined }
|
||||
: c,
|
||||
),
|
||||
activeIndex: encounter.activeIndex,
|
||||
roundNumber: encounter.roundNumber,
|
||||
},
|
||||
events: [
|
||||
{
|
||||
type: "CurrentHpAdjusted",
|
||||
combatantId,
|
||||
previousHp,
|
||||
newHp,
|
||||
delta,
|
||||
},
|
||||
],
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,63 +18,127 @@ export type ConditionId =
|
||||
export interface ConditionDefinition {
|
||||
readonly id: ConditionId;
|
||||
readonly label: string;
|
||||
readonly description: string;
|
||||
readonly iconName: string;
|
||||
readonly color: string;
|
||||
}
|
||||
|
||||
export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||
{ id: "blinded", label: "Blinded", iconName: "EyeOff", color: "neutral" },
|
||||
{ id: "charmed", label: "Charmed", iconName: "Heart", color: "pink" },
|
||||
{ id: "deafened", label: "Deafened", iconName: "EarOff", color: "neutral" },
|
||||
{
|
||||
id: "blinded",
|
||||
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",
|
||||
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",
|
||||
color: "amber",
|
||||
},
|
||||
{
|
||||
id: "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",
|
||||
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",
|
||||
label: "Incapacitated",
|
||||
description:
|
||||
"Can't take Actions, Bonus Actions, or Reactions. Concentration is broken.",
|
||||
iconName: "Ban",
|
||||
color: "gray",
|
||||
},
|
||||
{
|
||||
id: "invisible",
|
||||
label: "Invisible",
|
||||
description:
|
||||
"Impossible to see without magic or special sense. Heavily Obscured. Attacks have Advantage; attacks against have Disadvantage.",
|
||||
iconName: "Ghost",
|
||||
color: "violet",
|
||||
},
|
||||
{
|
||||
id: "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",
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
id: "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",
|
||||
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",
|
||||
label: "Restrained",
|
||||
description:
|
||||
"Speed is 0. Attacks have Disadvantage. Attacks against have Advantage. Disadvantage on Dex saves.",
|
||||
iconName: "Link",
|
||||
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",
|
||||
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",
|
||||
color: "indigo",
|
||||
},
|
||||
|
||||
@@ -58,6 +58,13 @@ export interface CurrentHpAdjusted {
|
||||
readonly delta: number;
|
||||
}
|
||||
|
||||
export interface TempHpSet {
|
||||
readonly type: "TempHpSet";
|
||||
readonly combatantId: CombatantId;
|
||||
readonly previousTempHp: number | undefined;
|
||||
readonly newTempHp: number | undefined;
|
||||
}
|
||||
|
||||
export interface TurnRetreated {
|
||||
readonly type: "TurnRetreated";
|
||||
readonly previousCombatantId: CombatantId;
|
||||
@@ -132,6 +139,7 @@ export type DomainEvent =
|
||||
| InitiativeSet
|
||||
| MaxHpSet
|
||||
| CurrentHpAdjusted
|
||||
| TempHpSet
|
||||
| TurnRetreated
|
||||
| RoundRetreated
|
||||
| AcSet
|
||||
|
||||
@@ -60,6 +60,7 @@ export type {
|
||||
PlayerCharacterUpdated,
|
||||
RoundAdvanced,
|
||||
RoundRetreated,
|
||||
TempHpSet,
|
||||
TurnAdvanced,
|
||||
TurnRetreated,
|
||||
} from "./events.js";
|
||||
@@ -84,13 +85,18 @@ export {
|
||||
removeCombatant,
|
||||
} from "./remove-combatant.js";
|
||||
export { retreatTurn } from "./retreat-turn.js";
|
||||
export { rollInitiative } from "./roll-initiative.js";
|
||||
export {
|
||||
type RollMode,
|
||||
rollInitiative,
|
||||
selectRoll,
|
||||
} from "./roll-initiative.js";
|
||||
export { type SetAcSuccess, setAc } from "./set-ac.js";
|
||||
export { type SetHpSuccess, setHp } from "./set-hp.js";
|
||||
export {
|
||||
type SetInitiativeSuccess,
|
||||
setInitiative,
|
||||
} from "./set-initiative.js";
|
||||
export { type SetTempHpSuccess, setTempHp } from "./set-temp-hp.js";
|
||||
export {
|
||||
type ToggleConcentrationSuccess,
|
||||
toggleConcentration,
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import type { DomainError } from "./types.js";
|
||||
|
||||
export type RollMode = "normal" | "advantage" | "disadvantage";
|
||||
|
||||
/**
|
||||
* Selects the effective roll from two dice values based on the roll mode.
|
||||
* Advantage takes the higher, disadvantage takes the lower.
|
||||
*/
|
||||
export function selectRoll(
|
||||
roll1: number,
|
||||
roll2: number,
|
||||
mode: RollMode,
|
||||
): number {
|
||||
if (mode === "advantage") return Math.max(roll1, roll2);
|
||||
if (mode === "disadvantage") return Math.min(roll1, roll2);
|
||||
return roll1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function that computes initiative from a resolved dice roll and modifier.
|
||||
* The dice roll must be an integer in [1, 20].
|
||||
|
||||
@@ -66,7 +66,12 @@ export function setHp(
|
||||
encounter: {
|
||||
combatants: encounter.combatants.map((c) =>
|
||||
c.id === combatantId
|
||||
? { ...c, maxHp: newMaxHp, currentHp: newCurrentHp }
|
||||
? {
|
||||
...c,
|
||||
maxHp: newMaxHp,
|
||||
currentHp: newCurrentHp,
|
||||
tempHp: newMaxHp === undefined ? undefined : c.tempHp,
|
||||
}
|
||||
: c,
|
||||
),
|
||||
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 maxHp?: number;
|
||||
readonly currentHp?: number;
|
||||
readonly tempHp?: number;
|
||||
readonly ac?: number;
|
||||
readonly conditions?: readonly ConditionId[];
|
||||
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 maxHp?: number; // positive integer
|
||||
readonly currentHp?: number; // 0..maxHp
|
||||
readonly tempHp?: number; // positive integer, damage buffer
|
||||
readonly ac?: number; // non-negative integer
|
||||
readonly conditions?: readonly ConditionId[];
|
||||
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:
|
||||
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
|
||||
|
||||
- **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-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-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
|
||||
|
||||
@@ -131,7 +154,10 @@ Acceptance scenarios:
|
||||
- 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.
|
||||
- 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 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.
|
||||
|
||||
Reference in New Issue
Block a user