3 Commits

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:16:04 +01:00
Lukas
7f38cbab73 Preserve stat block panel collapsed state on turn advance
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 23:23:52 +01:00
45 changed files with 1435 additions and 813 deletions

View File

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

View File

@@ -13,6 +13,7 @@ pnpm test:watch # Tests in watch mode
pnpm typecheck # tsc --build (project references) pnpm typecheck # tsc --build (project references)
pnpm lint # Biome lint pnpm lint # Biome lint
pnpm format # Biome format (writes) pnpm format # Biome format (writes)
pnpm check:props # Component prop count enforcement (max 8)
pnpm --filter web dev # Vite dev server (localhost:5173) pnpm --filter web dev # Vite dev server (localhost:5173)
pnpm --filter web build # Production build pnpm --filter web build # Production build
``` ```
@@ -71,6 +72,7 @@ docs/agents/ RPI skill artifacts (research reports, plans)
- **Domain events** are plain data objects with a `type` discriminant — no classes. - **Domain events** are plain data objects with a `type` discriminant — no classes.
- **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks. - **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks.
- **Feature specs** live in `specs/NNN-feature-name/` with spec.md (and optionally plan.md, tasks.md for new work). Specs describe features, not individual changes. The project constitution is at `.specify/memory/constitution.md`. - **Feature specs** live in `specs/NNN-feature-name/` with spec.md (and optionally plan.md, tasks.md for new work). Specs describe features, not individual changes. The project constitution is at `.specify/memory/constitution.md`.
- **Component props** — max 8 explicitly declared props per component interface (enforced by `scripts/check-component-props.mjs`). Use React context for shared state; reserve props for per-instance config (data items, layout variants, refs).
- **Quality gates** are enforced at pre-commit via Lefthook's `pnpm check` — the project's single earliest enforcement point. No gate may exist only as a CI step or manual process. - **Quality gates** are enforced at pre-commit via Lefthook's `pnpm check` — the project's single earliest enforcement point. No gate may exist only as a CI step or manual process.
## Self-Review Checklist ## Self-Review Checklist

View File

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

View File

@@ -4,7 +4,8 @@ import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { App } from "../App"; import { App } from "../App.js";
import { AllProviders } from "./test-providers.js";
// Mock persistence — no localStorage interaction // Mock persistence — no localStorage interaction
vi.mock("../persistence/encounter-storage.js", () => ({ vi.mock("../persistence/encounter-storage.js", () => ({
@@ -76,7 +77,7 @@ async function addCombatant(
describe("App integration", () => { describe("App integration", () => {
it("adds a combatant and removes it, returning to empty state", async () => { it("adds a combatant and removes it, returning to empty state", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<App />); render(<App />, { wrapper: AllProviders });
// Empty state: centered input visible, no TurnNavigation // Empty state: centered input visible, no TurnNavigation
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument(); expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
@@ -109,7 +110,7 @@ describe("App integration", () => {
it("advances and retreats turns across two combatants", async () => { it("advances and retreats turns across two combatants", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<App />); render(<App />, { wrapper: AllProviders });
await addCombatant(user, "Fighter"); await addCombatant(user, "Fighter");
await addCombatant(user, "Wizard"); await addCombatant(user, "Wizard");
@@ -137,7 +138,7 @@ describe("App integration", () => {
it("adds a combatant with HP, applies damage, and shows unconscious state", async () => { it("adds a combatant with HP, applies damage, and shows unconscious state", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<App />); render(<App />, { wrapper: AllProviders });
await addCombatant(user, "Ogre", { maxHp: "59" }); await addCombatant(user, "Ogre", { maxHp: "59" });

View File

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

View File

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

View File

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

View File

@@ -1,33 +1,65 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { combatantId } from "@initiative/domain"; import { type CreatureId, combatantId } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { CombatantRow } from "../combatant-row"; import { AllProviders } from "../../__tests__/test-providers.js";
import { PLAYER_COLOR_HEX } from "../player-icon-map"; import { CombatantRow } from "../combatant-row.js";
import { PLAYER_COLOR_HEX } from "../player-icon-map.js";
// Mock persistence — no localStorage interaction
vi.mock("../../persistence/encounter-storage.js", () => ({
loadEncounter: () => null,
saveEncounter: () => {},
}));
vi.mock("../../persistence/player-character-storage.js", () => ({
loadPlayerCharacters: () => [],
savePlayerCharacters: () => {},
}));
// Mock bestiary — no IndexedDB or JSON index
vi.mock("../../adapters/bestiary-cache.js", () => ({
loadAllCachedCreatures: () => Promise.resolve(new Map()),
isSourceCached: () => Promise.resolve(false),
cacheSource: () => Promise.resolve(),
getCachedSources: () => Promise.resolve([]),
clearSource: () => Promise.resolve(),
clearAll: () => Promise.resolve(),
}));
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
getAllSourceCodes: () => [],
getDefaultFetchUrl: () => "",
getSourceDisplayName: (code: string) => code,
}));
// DOM API stubs
beforeAll(() => {
Object.defineProperty(globalThis, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});
afterEach(cleanup); afterEach(cleanup);
const defaultProps = {
onRename: vi.fn(),
onSetInitiative: vi.fn(),
onRemove: vi.fn(),
onSetHp: vi.fn(),
onAdjustHp: vi.fn(),
onSetAc: vi.fn(),
onToggleCondition: vi.fn(),
onToggleConcentration: vi.fn(),
};
function renderRow( function renderRow(
overrides: Partial<{ overrides: Partial<{
combatant: Parameters<typeof CombatantRow>[0]["combatant"]; combatant: Parameters<typeof CombatantRow>[0]["combatant"];
isActive: boolean; isActive: boolean;
onRollInitiative: (id: ReturnType<typeof combatantId>) => void;
onRemove: (id: ReturnType<typeof combatantId>) => void;
onShowStatBlock: () => void;
}> = {}, }> = {},
) { ) {
const combatant = overrides.combatant ?? { const combatant = overrides.combatant ?? {
@@ -38,15 +70,13 @@ function renderRow(
currentHp: 10, currentHp: 10,
ac: 13, ac: 13,
}; };
const props = { return render(
...defaultProps, <CombatantRow
combatant, combatant={combatant}
isActive: overrides.isActive ?? false, isActive={overrides.isActive ?? false}
onRollInitiative: overrides.onRollInitiative, />,
onShowStatBlock: overrides.onShowStatBlock, { wrapper: AllProviders },
onRemove: overrides.onRemove ?? defaultProps.onRemove, );
};
return render(<CombatantRow {...props} />);
} }
describe("CombatantRow", () => { describe("CombatantRow", () => {
@@ -132,10 +162,9 @@ describe("CombatantRow", () => {
expect(svgIcon).toHaveStyle({ color: PLAYER_COLOR_HEX.red }); expect(svgIcon).toHaveStyle({ color: PLAYER_COLOR_HEX.red });
}); });
it("remove button calls onRemove after confirmation", async () => { it("remove button removes after confirmation", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onRemove = vi.fn(); renderRow();
renderRow({ onRemove });
const removeBtn = screen.getByRole("button", { const removeBtn = screen.getByRole("button", {
name: "Remove combatant", name: "Remove combatant",
}); });
@@ -146,16 +175,19 @@ describe("CombatantRow", () => {
name: "Confirm remove combatant", name: "Confirm remove combatant",
}); });
await user.click(confirmBtn); await user.click(confirmBtn);
expect(onRemove).toHaveBeenCalledWith(combatantId("1")); // After confirming, the button returns to its initial state
expect(
screen.queryByRole("button", { name: "Confirm remove combatant" }),
).not.toBeInTheDocument();
}); });
it("shows d20 roll button when initiative is undefined and onRollInitiative is provided", () => { it("shows d20 roll button when initiative is undefined and combatant has creatureId", () => {
renderRow({ renderRow({
combatant: { combatant: {
id: combatantId("1"), id: combatantId("1"),
name: "Goblin", name: "Goblin",
creatureId: "srd:goblin" as CreatureId,
}, },
onRollInitiative: vi.fn(),
}); });
expect( expect(
screen.getByRole("button", { name: "Roll initiative" }), screen.getByRole("button", { name: "Roll initiative" }),

View File

@@ -11,28 +11,51 @@ vi.mock("../../adapters/bestiary-cache.js", () => ({
clearAll: vi.fn(), clearAll: vi.fn(),
})); }));
// Mock the context module
vi.mock("../../contexts/bestiary-context.js", () => ({
useBestiaryContext: vi.fn(),
}));
import * as bestiaryCache from "../../adapters/bestiary-cache.js"; import * as bestiaryCache from "../../adapters/bestiary-cache.js";
import { SourceManager } from "../source-manager"; import { useBestiaryContext } from "../../contexts/bestiary-context.js";
import { SourceManager } from "../source-manager.js";
const mockGetCachedSources = vi.mocked(bestiaryCache.getCachedSources); const mockGetCachedSources = vi.mocked(bestiaryCache.getCachedSources);
const mockClearSource = vi.mocked(bestiaryCache.clearSource); const mockClearSource = vi.mocked(bestiaryCache.clearSource);
const mockClearAll = vi.mocked(bestiaryCache.clearAll); const mockClearAll = vi.mocked(bestiaryCache.clearAll);
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.clearAllMocks(); vi.clearAllMocks();
}); });
function setupMockContext() {
const refreshCache = vi.fn().mockResolvedValue(undefined);
mockUseBestiaryContext.mockReturnValue({
refreshCache,
search: vi.fn().mockReturnValue([]),
getCreature: vi.fn(),
isLoaded: true,
isSourceCached: vi.fn().mockResolvedValue(false),
fetchAndCacheSource: vi.fn(),
uploadAndCacheSource: vi.fn(),
} as ReturnType<typeof useBestiaryContext>);
return { refreshCache };
}
describe("SourceManager", () => { describe("SourceManager", () => {
it("shows 'No cached sources' empty state when no sources", async () => { it("shows 'No cached sources' empty state when no sources", async () => {
setupMockContext();
mockGetCachedSources.mockResolvedValue([]); mockGetCachedSources.mockResolvedValue([]);
render(<SourceManager onCacheCleared={vi.fn()} />); render(<SourceManager />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("No cached sources")).toBeInTheDocument(); expect(screen.getByText("No cached sources")).toBeInTheDocument();
}); });
}); });
it("lists cached sources with display name and creature count", async () => { it("lists cached sources with display name and creature count", async () => {
setupMockContext();
mockGetCachedSources.mockResolvedValue([ mockGetCachedSources.mockResolvedValue([
{ {
sourceCode: "mm", sourceCode: "mm",
@@ -47,7 +70,7 @@ describe("SourceManager", () => {
cachedAt: Date.now(), cachedAt: Date.now(),
}, },
]); ]);
render(<SourceManager onCacheCleared={vi.fn()} />); render(<SourceManager />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Monster Manual")).toBeInTheDocument(); expect(screen.getByText("Monster Manual")).toBeInTheDocument();
}); });
@@ -56,9 +79,9 @@ describe("SourceManager", () => {
expect(screen.getByText("100 creatures")).toBeInTheDocument(); expect(screen.getByText("100 creatures")).toBeInTheDocument();
}); });
it("Clear All button calls cache clear and onCacheCleared", async () => { it("Clear All button calls cache clear and refreshCache", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onCacheCleared = vi.fn(); const { refreshCache } = setupMockContext();
mockGetCachedSources mockGetCachedSources
.mockResolvedValueOnce([ .mockResolvedValueOnce([
{ {
@@ -70,7 +93,7 @@ describe("SourceManager", () => {
]) ])
.mockResolvedValue([]); .mockResolvedValue([]);
mockClearAll.mockResolvedValue(undefined); mockClearAll.mockResolvedValue(undefined);
render(<SourceManager onCacheCleared={onCacheCleared} />); render(<SourceManager />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Monster Manual")).toBeInTheDocument(); expect(screen.getByText("Monster Manual")).toBeInTheDocument();
@@ -80,12 +103,12 @@ describe("SourceManager", () => {
await waitFor(() => { await waitFor(() => {
expect(mockClearAll).toHaveBeenCalled(); expect(mockClearAll).toHaveBeenCalled();
}); });
expect(onCacheCleared).toHaveBeenCalled(); expect(refreshCache).toHaveBeenCalled();
}); });
it("individual source delete button calls clear for that source", async () => { it("individual source delete button calls clear for that source", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onCacheCleared = vi.fn(); const { refreshCache } = setupMockContext();
mockGetCachedSources mockGetCachedSources
.mockResolvedValueOnce([ .mockResolvedValueOnce([
{ {
@@ -111,7 +134,7 @@ describe("SourceManager", () => {
]); ]);
mockClearSource.mockResolvedValue(undefined); mockClearSource.mockResolvedValue(undefined);
render(<SourceManager onCacheCleared={onCacheCleared} />); render(<SourceManager />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Monster Manual")).toBeInTheDocument(); expect(screen.getByText("Monster Manual")).toBeInTheDocument();
}); });
@@ -122,6 +145,6 @@ describe("SourceManager", () => {
await waitFor(() => { await waitFor(() => {
expect(mockClearSource).toHaveBeenCalledWith("mm"); expect(mockClearSource).toHaveBeenCalledWith("mm");
}); });
expect(onCacheCleared).toHaveBeenCalled(); expect(refreshCache).toHaveBeenCalled();
}); });
}); });

View File

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

View File

@@ -1,4 +1,4 @@
import type { PlayerCharacter } from "@initiative/domain"; import type { CreatureId, PlayerCharacter } from "@initiative/domain";
import { import {
Check, Check,
Eye, Eye,
@@ -12,11 +12,25 @@ import {
Sun, Sun,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import React, { type RefObject, useDeferredValue, useState } from "react"; import React, {
import type { SearchResult } from "../hooks/use-bestiary.js"; 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 { cn } from "../lib/utils.js";
import { D20Icon } from "./d20-icon.js"; import { D20Icon } from "./d20-icon.js";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map"; import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
import { RollModeMenu } from "./roll-mode-menu.js";
import { Button } from "./ui/button.js"; import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js"; import { Input } from "./ui/input.js";
import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js"; import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
@@ -27,27 +41,9 @@ interface QueuedCreature {
} }
interface ActionBarProps { interface ActionBarProps {
onAddCombatant: (
name: string,
opts?: { initiative?: number; ac?: number; maxHp?: number },
) => void;
onAddFromBestiary: (result: SearchResult) => void;
bestiarySearch: (query: string) => SearchResult[];
bestiaryLoaded: boolean;
onViewStatBlock?: (result: SearchResult) => void;
onBulkImport?: () => void;
bulkImportDisabled?: boolean;
inputRef?: RefObject<HTMLInputElement | null>; inputRef?: RefObject<HTMLInputElement | null>;
playerCharacters?: readonly PlayerCharacter[];
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
onManagePlayers?: () => void;
onRollAllInitiative?: () => void;
showRollAllInitiative?: boolean;
rollAllInitiativeDisabled?: boolean;
onOpenSourceManager?: () => void;
autoFocus?: boolean; autoFocus?: boolean;
themePreference?: "system" | "light" | "dark"; onManagePlayers?: () => void;
onCycleTheme?: () => void;
} }
function creatureKey(r: SearchResult): string { function creatureKey(r: SearchResult): string {
@@ -278,25 +274,48 @@ function buildOverflowItems(opts: {
} }
export function ActionBar({ export function ActionBar({
onAddCombatant,
onAddFromBestiary,
bestiarySearch,
bestiaryLoaded,
onViewStatBlock,
onBulkImport,
bulkImportDisabled,
inputRef, inputRef,
playerCharacters,
onAddFromPlayerCharacter,
onManagePlayers,
onRollAllInitiative,
showRollAllInitiative,
rollAllInitiativeDisabled,
onOpenSourceManager,
autoFocus, autoFocus,
themePreference, onManagePlayers,
onCycleTheme,
}: Readonly<ActionBarProps>) { }: Readonly<ActionBarProps>) {
const {
addCombatant,
addFromBestiary,
addFromPlayerCharacter,
hasCreatureCombatants,
canRollAllInitiative,
} = useEncounterContext();
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
useBestiaryContext();
const { characters: playerCharacters } = usePlayerCharactersContext();
const { showBulkImport, showSourceManager, showCreature, panelView } =
useSidePanelContext();
const { preference: themePreference, cycleTheme } = useThemeContext();
const { handleRollAllInitiative } = useInitiativeRollsContext();
const { state: bulkImportState } = useBulkImportContext();
const handleAddFromBestiary = useCallback(
(result: SearchResult) => {
const creatureId = addFromBestiary(result);
if (creatureId && panelView.mode === "closed") {
showCreature(creatureId);
}
},
[addFromBestiary, panelView.mode, showCreature],
);
const handleViewStatBlock = useCallback(
(result: SearchResult) => {
const slug = result.name
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, "-")
.replaceAll(/(^-|-$)/g, "");
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
showCreature(cId);
},
[showCreature],
);
const [nameInput, setNameInput] = useState(""); const [nameInput, setNameInput] = useState("");
const [suggestions, setSuggestions] = useState<SearchResult[]>([]); const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]); const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
@@ -333,7 +352,7 @@ export function ActionBar({
const confirmQueued = () => { const confirmQueued = () => {
if (!queued) return; if (!queued) return;
for (let i = 0; i < queued.count; i++) { for (let i = 0; i < queued.count; i++) {
onAddFromBestiary(queued.result); handleAddFromBestiary(queued.result);
} }
clearInput(); clearInput();
}; };
@@ -359,7 +378,7 @@ export function ActionBar({
if (init !== undefined) opts.initiative = init; if (init !== undefined) opts.initiative = init;
if (ac !== undefined) opts.ac = ac; if (ac !== undefined) opts.ac = ac;
if (maxHp !== undefined) opts.maxHp = maxHp; if (maxHp !== undefined) opts.maxHp = maxHp;
onAddCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined); addCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
setNameInput(""); setNameInput("");
setSuggestions([]); setSuggestions([]);
setPcMatches([]); setPcMatches([]);
@@ -461,14 +480,14 @@ export function ActionBar({
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1)); setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
} else if (e.key === "Enter" && suggestionIndex >= 0) { } else if (e.key === "Enter" && suggestionIndex >= 0) {
e.preventDefault(); e.preventDefault();
onViewStatBlock?.(suggestions[suggestionIndex]); handleViewStatBlock(suggestions[suggestionIndex]);
setBrowseMode(false); setBrowseMode(false);
clearInput(); clearInput();
} }
}; };
const handleBrowseSelect = (result: SearchResult) => { const handleBrowseSelect = (result: SearchResult) => {
onViewStatBlock?.(result); handleViewStatBlock(result);
setBrowseMode(false); setBrowseMode(false);
clearInput(); clearInput();
}; };
@@ -479,14 +498,33 @@ export function ActionBar({
clearCustomFields(); clearCustomFields();
}; };
const [rollAllMenuPos, setRollAllMenuPos] = useState<{
x: number;
y: number;
} | null>(null);
const openRollAllMenu = useCallback((x: number, y: number) => {
setRollAllMenuPos({ x, y });
}, []);
const rollAllLongPress = useLongPress(
useCallback(
(e: React.TouchEvent) => {
const touch = e.touches[0];
if (touch) openRollAllMenu(touch.clientX, touch.clientY);
},
[openRollAllMenu],
),
);
const overflowItems = buildOverflowItems({ const overflowItems = buildOverflowItems({
onManagePlayers, onManagePlayers,
onOpenSourceManager, onOpenSourceManager: showSourceManager,
bestiaryLoaded, bestiaryLoaded,
onBulkImport, onBulkImport: showBulkImport,
bulkImportDisabled, bulkImportDisabled: bulkImportState.status === "loading",
themePreference, themePreference,
onCycleTheme, onCycleTheme: cycleTheme,
}); });
return ( return (
@@ -509,7 +547,7 @@ export function ActionBar({
className="pr-8" className="pr-8"
autoFocus={autoFocus} autoFocus={autoFocus}
/> />
{bestiaryLoaded && !!onViewStatBlock && ( {!!bestiaryLoaded && (
<button <button
type="button" type="button"
tabIndex={-1} tabIndex={-1}
@@ -570,7 +608,7 @@ export function ActionBar({
onSetSuggestionIndex={setSuggestionIndex} onSetSuggestionIndex={setSuggestionIndex}
onSetQueued={setQueued} onSetQueued={setQueued}
onConfirmQueued={confirmQueued} onConfirmQueued={confirmQueued}
onAddFromPlayerCharacter={onAddFromPlayerCharacter} onAddFromPlayerCharacter={addFromPlayerCharacter}
/> />
)} )}
</div> </div>
@@ -606,19 +644,33 @@ export function ActionBar({
{!browseMode && nameInput.length >= 2 && !hasSuggestions && ( {!browseMode && nameInput.length >= 2 && !hasSuggestions && (
<Button type="submit">Add</Button> <Button type="submit">Add</Button>
)} )}
{showRollAllInitiative && !!onRollAllInitiative && ( {!!hasCreatureCombatants && (
<Button <>
type="button" <Button
size="icon" type="button"
variant="ghost" size="icon"
className="text-muted-foreground hover:text-hover-action" variant="ghost"
onClick={onRollAllInitiative} className="text-muted-foreground hover:text-hover-action"
disabled={rollAllInitiativeDisabled} onClick={() => handleRollAllInitiative()}
title="Roll all initiative" onContextMenu={(e) => {
aria-label="Roll all initiative" e.preventDefault();
> openRollAllMenu(e.clientX, e.clientY);
<D20Icon className="h-6 w-6" /> }}
</Button> {...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} />} {overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
</form> </form>

View File

@@ -1,35 +1,41 @@
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useId, useState } from "react"; import { useId, useState } from "react";
import { getAllSourceCodes } from "../adapters/bestiary-index-adapter.js"; import { getAllSourceCodes } from "../adapters/bestiary-index-adapter.js";
import type { BulkImportState } from "../hooks/use-bulk-import.js"; import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
import { useSidePanelContext } from "../contexts/side-panel-context.js";
import { Button } from "./ui/button.js"; import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js"; import { Input } from "./ui/input.js";
const DEFAULT_BASE_URL = const DEFAULT_BASE_URL =
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/"; "https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
interface BulkImportPromptProps { export function BulkImportPrompt() {
importState: BulkImportState; const { fetchAndCacheSource, isSourceCached, refreshCache } =
onStartImport: (baseUrl: string) => void; useBestiaryContext();
onDone: () => void; const { state: importState, startImport, reset } = useBulkImportContext();
} const { dismissPanel } = useSidePanelContext();
export function BulkImportPrompt({
importState,
onStartImport,
onDone,
}: Readonly<BulkImportPromptProps>) {
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL); const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
const baseUrlId = useId(); const baseUrlId = useId();
const totalSources = getAllSourceCodes().length; const totalSources = getAllSourceCodes().length;
const handleStart = (url: string) => {
startImport(url, fetchAndCacheSource, isSourceCached, refreshCache);
};
const handleDone = () => {
dismissPanel();
reset();
};
if (importState.status === "complete") { if (importState.status === "complete") {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-green-400 text-sm"> <div className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-green-400 text-sm">
All sources loaded All sources loaded
</div> </div>
<Button onClick={onDone}>Done</Button> <Button onClick={handleDone}>Done</Button>
</div> </div>
); );
} }
@@ -41,7 +47,7 @@ export function BulkImportPrompt({
Loaded {importState.completed}/{importState.total} sources ( Loaded {importState.completed}/{importState.total} sources (
{importState.failed} failed) {importState.failed} failed)
</div> </div>
<Button onClick={onDone}>Done</Button> <Button onClick={handleDone}>Done</Button>
</div> </div>
); );
} }
@@ -96,7 +102,7 @@ export function BulkImportPrompt({
/> />
</div> </div>
<Button onClick={() => onStartImport(baseUrl)} disabled={isDisabled}> <Button onClick={() => handleStart(baseUrl)} disabled={isDisabled}>
Load All Load All
</Button> </Button>
</div> </div>

View File

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

View File

@@ -1,20 +1,27 @@
import { import {
type CombatantId, type CombatantId,
type ConditionId, type ConditionId,
type CreatureId,
deriveHpStatus, deriveHpStatus,
type PlayerIcon, type PlayerIcon,
type RollMode,
} from "@initiative/domain"; } from "@initiative/domain";
import { Book, BookOpen, Brain, X } from "lucide-react"; import { Book, BookOpen, Brain, X } from "lucide-react";
import { type Ref, useCallback, useEffect, useRef, useState } from "react"; import { type Ref, useCallback, useEffect, useRef, useState } from "react";
import { cn } from "../lib/utils"; import { useEncounterContext } from "../contexts/encounter-context.js";
import { AcShield } from "./ac-shield"; import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
import { ConditionPicker } from "./condition-picker"; import { useSidePanelContext } from "../contexts/side-panel-context.js";
import { ConditionTags } from "./condition-tags"; import { useLongPress } from "../hooks/use-long-press.js";
import { D20Icon } from "./d20-icon"; import { cn } from "../lib/utils.js";
import { HpAdjustPopover } from "./hp-adjust-popover"; import { AcShield } from "./ac-shield.js";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map"; import { ConditionPicker } from "./condition-picker.js";
import { ConfirmButton } from "./ui/confirm-button"; import { ConditionTags } from "./condition-tags.js";
import { Input } from "./ui/input"; import { D20Icon } from "./d20-icon.js";
import { HpAdjustPopover } from "./hp-adjust-popover.js";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
import { RollModeMenu } from "./roll-mode-menu.js";
import { ConfirmButton } from "./ui/confirm-button.js";
import { Input } from "./ui/input.js";
interface Combatant { interface Combatant {
readonly id: CombatantId; readonly id: CombatantId;
@@ -27,22 +34,12 @@ interface Combatant {
readonly isConcentrating?: boolean; readonly isConcentrating?: boolean;
readonly color?: string; readonly color?: string;
readonly icon?: string; readonly icon?: string;
readonly creatureId?: CreatureId;
} }
interface CombatantRowProps { interface CombatantRowProps {
combatant: Combatant; combatant: Combatant;
isActive: boolean; isActive: boolean;
onRename: (id: CombatantId, newName: string) => void;
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
onRemove: (id: CombatantId) => void;
onSetHp: (id: CombatantId, maxHp: number | undefined) => void;
onAdjustHp: (id: CombatantId, delta: number) => void;
onSetAc: (id: CombatantId, value: number | undefined) => void;
onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void;
onToggleConcentration: (id: CombatantId) => void;
onShowStatBlock?: () => void;
isStatBlockOpen?: boolean;
onRollInitiative?: (id: CombatantId) => void;
} }
function EditableName({ function EditableName({
@@ -279,11 +276,29 @@ function InitiativeDisplay({
combatantId: CombatantId; combatantId: CombatantId;
dimmed: boolean; dimmed: boolean;
onSetInitiative: (id: CombatantId, value: number | undefined) => void; onSetInitiative: (id: CombatantId, value: number | undefined) => void;
onRollInitiative?: (id: CombatantId) => void; onRollInitiative?: (id: CombatantId, mode?: RollMode) => void;
}>) { }>) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(initiative?.toString() ?? ""); const [draft, setDraft] = useState(initiative?.toString() ?? "");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [menuPos, setMenuPos] = useState<{
x: number;
y: number;
} | null>(null);
const openMenu = useCallback((x: number, y: number) => {
setMenuPos({ x, y });
}, []);
const longPress = useLongPress(
useCallback(
(e: React.TouchEvent) => {
const touch = e.touches[0];
if (touch) openMenu(touch.clientX, touch.clientY);
},
[openMenu],
),
);
const commit = useCallback(() => { const commit = useCallback(() => {
if (draft === "") { if (draft === "") {
@@ -325,26 +340,40 @@ function InitiativeDisplay({
); );
} }
// Empty + bestiary creature d20 roll button // Empty + bestiary creature -> d20 roll button
if (initiative === undefined && onRollInitiative) { if (initiative === undefined && onRollInitiative) {
return ( return (
<button <>
type="button" <button
onClick={() => onRollInitiative(combatantId)} type="button"
className={cn( onClick={() => onRollInitiative(combatantId)}
"flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral", onContextMenu={(e) => {
dimmed && "opacity-50", 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 // Has value -> bold number, click to edit
// Empty + manual "--" placeholder, click to edit // Empty + manual -> "--" placeholder, click to edit
return ( return (
<button <button
type="button" type="button"
@@ -388,18 +417,30 @@ export function CombatantRow({
ref, ref,
combatant, combatant,
isActive, isActive,
onRename,
onSetInitiative,
onRemove,
onSetHp,
onAdjustHp,
onSetAc,
onToggleCondition,
onToggleConcentration,
onShowStatBlock,
isStatBlockOpen,
onRollInitiative,
}: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) { }: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) {
const {
editCombatant,
setInitiative,
removeCombatant,
setHp,
adjustHp,
setAc,
toggleCondition,
toggleConcentration,
} = useEncounterContext();
const { selectedCreatureId, showCreature } = useSidePanelContext();
const { handleRollInitiative } = useInitiativeRollsContext();
// Derive what was previously conditional props
const isStatBlockOpen = combatant.creatureId === selectedCreatureId;
const { creatureId } = combatant;
const onShowStatBlock = creatureId
? () => showCreature(creatureId)
: undefined;
const onRollInitiative = combatant.creatureId
? handleRollInitiative
: undefined;
const { id, name, initiative, maxHp, currentHp } = combatant; const { id, name, initiative, maxHp, currentHp } = combatant;
const status = deriveHpStatus(currentHp, maxHp); const status = deriveHpStatus(currentHp, maxHp);
const dimmed = status === "unconscious"; const dimmed = status === "unconscious";
@@ -449,7 +490,7 @@ export function CombatantRow({
{/* Concentration */} {/* Concentration */}
<button <button
type="button" type="button"
onClick={() => onToggleConcentration(id)} onClick={() => toggleConcentration(id)}
title="Concentrating" title="Concentrating"
aria-label="Toggle concentration" aria-label="Toggle concentration"
className={cn( className={cn(
@@ -465,7 +506,7 @@ export function CombatantRow({
initiative={initiative} initiative={initiative}
combatantId={id} combatantId={id}
dimmed={dimmed} dimmed={dimmed}
onSetInitiative={onSetInitiative} onSetInitiative={setInitiative}
onRollInitiative={onRollInitiative} onRollInitiative={onRollInitiative}
/> />
@@ -506,18 +547,18 @@ export function CombatantRow({
<EditableName <EditableName
name={name} name={name}
combatantId={id} combatantId={id}
onRename={onRename} onRename={editCombatant}
color={pcColor} color={pcColor}
/> />
<ConditionTags <ConditionTags
conditions={combatant.conditions} conditions={combatant.conditions}
onRemove={(conditionId) => onToggleCondition(id, conditionId)} onRemove={(conditionId) => toggleCondition(id, conditionId)}
onOpenPicker={() => setPickerOpen((prev) => !prev)} onOpenPicker={() => setPickerOpen((prev) => !prev)}
/> />
{!!pickerOpen && ( {!!pickerOpen && (
<ConditionPicker <ConditionPicker
activeConditions={combatant.conditions} activeConditions={combatant.conditions}
onToggle={(conditionId) => onToggleCondition(id, conditionId)} onToggle={(conditionId) => toggleCondition(id, conditionId)}
onClose={() => setPickerOpen(false)} onClose={() => setPickerOpen(false)}
/> />
)} )}
@@ -525,7 +566,7 @@ export function CombatantRow({
{/* AC */} {/* AC */}
<div className={cn(dimmed && "opacity-50")}> <div className={cn(dimmed && "opacity-50")}>
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} /> <AcDisplay ac={combatant.ac} onCommit={(v) => setAc(id, v)} />
</div> </div>
{/* HP */} {/* HP */}
@@ -533,7 +574,7 @@ export function CombatantRow({
<ClickableHp <ClickableHp
currentHp={currentHp} currentHp={currentHp}
maxHp={maxHp} maxHp={maxHp}
onAdjust={(delta) => onAdjustHp(id, delta)} onAdjust={(delta) => adjustHp(id, delta)}
dimmed={dimmed} dimmed={dimmed}
/> />
{maxHp !== undefined && ( {maxHp !== undefined && (
@@ -547,7 +588,7 @@ export function CombatantRow({
</span> </span>
)} )}
<div className={cn(dimmed && "opacity-50")}> <div className={cn(dimmed && "opacity-50")}>
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => onSetHp(id, v)} /> <MaxHpDisplay maxHp={maxHp} onCommit={(v) => setHp(id, v)} />
</div> </div>
</div> </div>
@@ -555,7 +596,7 @@ export function CombatantRow({
<ConfirmButton <ConfirmButton
icon={<X size={16} />} icon={<X size={16} />}
label="Remove combatant" label="Remove combatant"
onConfirm={() => onRemove(id)} onConfirm={() => removeCombatant(id)}
className="pointer-events-none pointer-coarse:pointer-events-auto h-7 w-7 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-opacity focus:pointer-events-auto focus:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100" className="pointer-events-none pointer-coarse:pointer-events-auto h-7 w-7 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-opacity focus:pointer-events-auto focus:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"
/> />
</div> </div>

View File

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

View File

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

View File

@@ -1,24 +1,24 @@
import { Download, Loader2, Upload } from "lucide-react"; import { Download, Loader2, Upload } from "lucide-react";
import { useId, useRef, useState } from "react"; import { useId, useRef, useState } from "react";
import { getDefaultFetchUrl } from "../adapters/bestiary-index-adapter.js"; import {
getDefaultFetchUrl,
getSourceDisplayName,
} from "../adapters/bestiary-index-adapter.js";
import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { Button } from "./ui/button.js"; import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js"; import { Input } from "./ui/input.js";
interface SourceFetchPromptProps { interface SourceFetchPromptProps {
sourceCode: string; sourceCode: string;
sourceDisplayName: string;
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
onSourceLoaded: () => void; onSourceLoaded: () => void;
onUploadSource: (sourceCode: string, jsonData: unknown) => Promise<void>;
} }
export function SourceFetchPrompt({ export function SourceFetchPrompt({
sourceCode, sourceCode,
sourceDisplayName,
fetchAndCacheSource,
onSourceLoaded, onSourceLoaded,
onUploadSource,
}: Readonly<SourceFetchPromptProps>) { }: Readonly<SourceFetchPromptProps>) {
const { fetchAndCacheSource, uploadAndCacheSource } = useBestiaryContext();
const sourceDisplayName = getSourceDisplayName(sourceCode);
const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode)); const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode));
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle"); const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>("");
@@ -47,7 +47,7 @@ export function SourceFetchPrompt({
try { try {
const text = await file.text(); const text = await file.text();
const json = JSON.parse(text); const json = JSON.parse(text);
await onUploadSource(sourceCode, json); await uploadAndCacheSource(sourceCode, json);
onSourceLoaded(); onSourceLoaded();
} catch (err) { } catch (err) {
setStatus("error"); setStatus("error");

View File

@@ -8,16 +8,12 @@ import {
} from "react"; } from "react";
import type { CachedSourceInfo } from "../adapters/bestiary-cache.js"; import type { CachedSourceInfo } from "../adapters/bestiary-cache.js";
import * as bestiaryCache from "../adapters/bestiary-cache.js"; import * as bestiaryCache from "../adapters/bestiary-cache.js";
import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { Button } from "./ui/button.js"; import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js"; import { Input } from "./ui/input.js";
interface SourceManagerProps { export function SourceManager() {
onCacheCleared: () => void; const { refreshCache } = useBestiaryContext();
}
export function SourceManager({
onCacheCleared,
}: Readonly<SourceManagerProps>) {
const [sources, setSources] = useState<CachedSourceInfo[]>([]); const [sources, setSources] = useState<CachedSourceInfo[]>([]);
const [filter, setFilter] = useState(""); const [filter, setFilter] = useState("");
const [optimisticSources, applyOptimistic] = useOptimistic( const [optimisticSources, applyOptimistic] = useOptimistic(
@@ -44,14 +40,14 @@ export function SourceManager({
applyOptimistic({ type: "remove", sourceCode }); applyOptimistic({ type: "remove", sourceCode });
await bestiaryCache.clearSource(sourceCode); await bestiaryCache.clearSource(sourceCode);
await loadSources(); await loadSources();
onCacheCleared(); void refreshCache();
}; };
const handleClearAll = async () => { const handleClearAll = async () => {
applyOptimistic({ type: "clear" }); applyOptimistic({ type: "clear" });
await bestiaryCache.clearAll(); await bestiaryCache.clearAll();
await loadSources(); await loadSources();
onCacheCleared(); void refreshCache();
}; };
const filteredSources = useMemo(() => { const filteredSources = useMemo(() => {

View File

@@ -1,9 +1,9 @@
import type { Creature, CreatureId } from "@initiative/domain"; import type { CreatureId } from "@initiative/domain";
import { PanelRightClose, Pin, PinOff } from "lucide-react"; import { PanelRightClose, Pin, PinOff } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js"; import { useBestiaryContext } from "../contexts/bestiary-context.js";
import type { BulkImportState } from "../hooks/use-bulk-import.js"; import { useSidePanelContext } from "../contexts/side-panel-context.js";
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js"; import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
import { cn } from "../lib/utils.js"; import { cn } from "../lib/utils.js";
import { BulkImportPrompt } from "./bulk-import-prompt.js"; import { BulkImportPrompt } from "./bulk-import-prompt.js";
@@ -13,28 +13,8 @@ import { StatBlock } from "./stat-block.js";
import { Button } from "./ui/button.js"; import { Button } from "./ui/button.js";
interface StatBlockPanelProps { interface StatBlockPanelProps {
creatureId: CreatureId | null;
creature: Creature | null;
isSourceCached: (sourceCode: string) => Promise<boolean>;
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
uploadAndCacheSource: (
sourceCode: string,
jsonData: unknown,
) => Promise<void>;
refreshCache: () => Promise<void>;
panelRole: "browse" | "pinned"; panelRole: "browse" | "pinned";
isCollapsed: boolean;
onToggleCollapse: () => void;
onPin: () => void;
onUnpin: () => void;
showPinButton: boolean;
side: "left" | "right"; side: "left" | "right";
onDismiss: () => void;
bulkImportMode?: boolean;
bulkImportState?: BulkImportState;
onStartBulkImport?: (baseUrl: string) => void;
onBulkImportDone?: () => void;
sourceManagerMode?: boolean;
} }
function extractSourceCode(cId: CreatureId): string { function extractSourceCode(cId: CreatureId): string {
@@ -228,27 +208,49 @@ function MobileDrawer({
); );
} }
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({ export function StatBlockPanel({
creatureId,
creature,
isSourceCached,
fetchAndCacheSource,
uploadAndCacheSource,
refreshCache,
panelRole, panelRole,
isCollapsed,
onToggleCollapse,
onPin,
onUnpin,
showPinButton,
side, side,
onDismiss,
bulkImportMode,
bulkImportState,
onStartBulkImport,
onBulkImportDone,
sourceManagerMode,
}: Readonly<StatBlockPanelProps>) { }: Readonly<StatBlockPanelProps>) {
const { isSourceCached } = useBestiaryContext();
const {
creatureId,
creature,
isCollapsed,
onToggleCollapse,
onDismiss,
onPin,
onUnpin,
showPinButton,
bulkImportMode,
sourceManagerMode,
} = usePanelRole(panelRole);
const [isDesktop, setIsDesktop] = useState( const [isDesktop, setIsDesktop] = useState(
() => globalThis.matchMedia("(min-width: 1024px)").matches, () => globalThis.matchMedia("(min-width: 1024px)").matches,
); );
@@ -285,29 +287,17 @@ export function StatBlockPanel({
const sourceCode = creatureId ? extractSourceCode(creatureId) : ""; const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
const handleSourceLoaded = async () => { const handleSourceLoaded = () => {
await refreshCache();
setNeedsFetch(false); setNeedsFetch(false);
}; };
const renderContent = () => { const renderContent = () => {
if (sourceManagerMode) { if (sourceManagerMode) {
return <SourceManager onCacheCleared={refreshCache} />; return <SourceManager />;
} }
if ( if (bulkImportMode) {
bulkImportMode && return <BulkImportPrompt />;
bulkImportState &&
onStartBulkImport &&
onBulkImportDone
) {
return (
<BulkImportPrompt
importState={bulkImportState}
onStartImport={onStartBulkImport}
onDone={onBulkImportDone}
/>
);
} }
if (checkingCache) { if (checkingCache) {
@@ -324,10 +314,7 @@ export function StatBlockPanel({
return ( return (
<SourceFetchPrompt <SourceFetchPrompt
sourceCode={sourceCode} sourceCode={sourceCode}
sourceDisplayName={getSourceDisplayName(sourceCode)}
fetchAndCacheSource={fetchAndCacheSource}
onSourceLoaded={handleSourceLoaded} onSourceLoaded={handleSourceLoaded}
onUploadSource={uploadAndCacheSource}
/> />
); );
} }

View File

@@ -1,21 +1,12 @@
import type { Encounter } from "@initiative/domain";
import { StepBack, StepForward, Trash2 } from "lucide-react"; import { StepBack, StepForward, Trash2 } from "lucide-react";
import { Button } from "./ui/button"; import { useEncounterContext } from "../contexts/encounter-context.js";
import { ConfirmButton } from "./ui/confirm-button"; import { Button } from "./ui/button.js";
import { ConfirmButton } from "./ui/confirm-button.js";
interface TurnNavigationProps { export function TurnNavigation() {
encounter: Encounter; const { encounter, advanceTurn, retreatTurn, clearEncounter } =
onAdvanceTurn: () => void; useEncounterContext();
onRetreatTurn: () => void;
onClearEncounter: () => void;
}
export function TurnNavigation({
encounter,
onAdvanceTurn,
onRetreatTurn,
onClearEncounter,
}: Readonly<TurnNavigationProps>) {
const hasCombatants = encounter.combatants.length > 0; const hasCombatants = encounter.combatants.length > 0;
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0; const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
const activeCombatant = encounter.combatants[encounter.activeIndex]; const activeCombatant = encounter.combatants[encounter.activeIndex];
@@ -25,7 +16,7 @@ export function TurnNavigation({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={onRetreatTurn} onClick={retreatTurn}
disabled={!hasCombatants || isAtStart} disabled={!hasCombatants || isAtStart}
title="Previous turn" title="Previous turn"
aria-label="Previous turn" aria-label="Previous turn"
@@ -48,14 +39,14 @@ export function TurnNavigation({
<ConfirmButton <ConfirmButton
icon={<Trash2 className="h-5 w-5" />} icon={<Trash2 className="h-5 w-5" />}
label="Clear encounter" label="Clear encounter"
onConfirm={onClearEncounter} onConfirm={clearEncounter}
disabled={!hasCombatants} disabled={!hasCombatants}
className="text-muted-foreground" className="text-muted-foreground"
/> />
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={onAdvanceTurn} onClick={advanceTurn}
disabled={!hasCombatants} disabled={!hasCombatants}
title="Next turn" title="Next turn"
aria-label="Next turn" aria-label="Next turn"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import {
const BATCH_SIZE = 6; const BATCH_SIZE = 6;
export interface BulkImportState { interface BulkImportState {
readonly status: "idle" | "loading" | "complete" | "partial-failure"; readonly status: "idle" | "loading" | "complete" | "partial-failure";
readonly total: number; readonly total: number;
readonly completed: number; readonly completed: number;

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ interface SidePanelState {
interface SidePanelActions { interface SidePanelActions {
showCreature: (creatureId: CreatureId) => void; showCreature: (creatureId: CreatureId) => void;
updateCreature: (creatureId: CreatureId) => void;
showBulkImport: () => void; showBulkImport: () => void;
showSourceManager: () => void; showSourceManager: () => void;
dismissPanel: () => void; dismissPanel: () => void;
@@ -52,6 +53,10 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
setIsRightPanelCollapsed(false); setIsRightPanelCollapsed(false);
}, []); }, []);
const updateCreature = useCallback((creatureId: CreatureId) => {
setPanelView({ mode: "creature", creatureId });
}, []);
const showBulkImport = useCallback(() => { const showBulkImport = useCallback(() => {
setPanelView({ mode: "bulk-import" }); setPanelView({ mode: "bulk-import" });
setIsRightPanelCollapsed(false); setIsRightPanelCollapsed(false);
@@ -91,6 +96,7 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
pinnedCreatureId, pinnedCreatureId,
isWideDesktop, isWideDesktop,
showCreature, showCreature,
updateCreature,
showBulkImport, showBulkImport,
showSourceManager, showSourceManager,
dismissPanel, dismissPanel,

View File

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

View File

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

View File

@@ -31,6 +31,7 @@
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware", "oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
"check:ignores": "node scripts/check-lint-ignores.mjs", "check:ignores": "node scripts/check-lint-ignores.mjs",
"check:classnames": "node scripts/check-cn-classnames.mjs", "check:classnames": "node scripts/check-cn-classnames.mjs",
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && tsc --build && vitest run && jscpd" "check:props": "node scripts/check-component-props.mjs",
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && node scripts/check-component-props.mjs && tsc --build && vitest run && jscpd"
} }
} }

View File

@@ -161,6 +161,48 @@ describe("rollAllInitiativeUseCase", () => {
expect(store.saved).toBeNull(); 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", () => { it("saves encounter once at the end", () => {
const enc = encounterWithCombatants([ const enc = encounterWithCombatants([
{ name: "A", creatureId: "creature-a" }, { name: "A", creatureId: "creature-a" },

View File

@@ -61,7 +61,7 @@ describe("rollInitiativeUseCase", () => {
const result = rollInitiativeUseCase( const result = rollInitiativeUseCase(
store, store,
combatantId("unknown"), combatantId("unknown"),
10, [10],
() => undefined, () => undefined,
); );
@@ -80,7 +80,7 @@ describe("rollInitiativeUseCase", () => {
const result = rollInitiativeUseCase( const result = rollInitiativeUseCase(
store, store,
combatantId("Fighter"), combatantId("Fighter"),
10, [10],
() => undefined, () => undefined,
); );
@@ -96,7 +96,7 @@ describe("rollInitiativeUseCase", () => {
const result = rollInitiativeUseCase( const result = rollInitiativeUseCase(
store, store,
combatantId("Goblin"), combatantId("Goblin"),
10, [10],
() => undefined, () => undefined,
); );
@@ -116,7 +116,7 @@ describe("rollInitiativeUseCase", () => {
const result = rollInitiativeUseCase( const result = rollInitiativeUseCase(
store, store,
combatantId("Goblin"), combatantId("Goblin"),
10, [10],
(id) => (id === GOBLIN_ID ? creature : undefined), (id) => (id === GOBLIN_ID ? creature : undefined),
); );
@@ -124,6 +124,42 @@ describe("rollInitiativeUseCase", () => {
expect(requireSaved(store.saved).combatants[0].initiative).toBe(12); 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", () => { it("applies initiative proficiency bonus correctly", () => {
// CR 5 -> PB 3, dex 16 -> mod +3, initiativeProficiency 1 // CR 5 -> PB 3, dex 16 -> mod +3, initiativeProficiency 1
// modifier = 3 + 1*3 = 6, roll 8 + 6 = 14 // modifier = 3 + 1*3 = 6, roll 8 + 6 = 14
@@ -145,7 +181,7 @@ describe("rollInitiativeUseCase", () => {
const result = rollInitiativeUseCase( const result = rollInitiativeUseCase(
store, store,
combatantId("Monster"), combatantId("Monster"),
8, [8],
(id) => (id === GOBLIN_ID ? creature : undefined), (id) => (id === GOBLIN_ID ? creature : undefined),
); );

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { rollInitiative } from "../roll-initiative.js"; import { rollInitiative, selectRoll } from "../roll-initiative.js";
import { isDomainError } from "../types.js"; import { isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js"; 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);
});
});

View File

@@ -84,7 +84,11 @@ export {
removeCombatant, removeCombatant,
} from "./remove-combatant.js"; } from "./remove-combatant.js";
export { retreatTurn } from "./retreat-turn.js"; export { retreatTurn } from "./retreat-turn.js";
export { rollInitiative } from "./roll-initiative.js"; export {
type RollMode,
rollInitiative,
selectRoll,
} from "./roll-initiative.js";
export { type SetAcSuccess, setAc } from "./set-ac.js"; export { type SetAcSuccess, setAc } from "./set-ac.js";
export { type SetHpSuccess, setHp } from "./set-hp.js"; export { type SetHpSuccess, setHp } from "./set-hp.js";
export { export {

View File

@@ -1,5 +1,21 @@
import type { DomainError } from "./types.js"; 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. * Pure function that computes initiative from a resolved dice roll and modifier.
* The dice roll must be an integer in [1, 20]. * The dice roll must be an integer in [1, 20].

View 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`,
);
}