19 Commits
0.9.1 ... main

Author SHA1 Message Date
Lukas
968cc7239b Downgrade Knip 6 to 5 for CI compatibility
All checks were successful
CI / check (push) Successful in 1m6s
CI / build-image (push) Successful in 22s
Knip 6 uses oxc-parser which attempts a 6GB ArrayBuffer allocation
that fails on the CI runner (3.7GB RAM, no swap). This is a known
oxc allocator issue (oxc-project/oxc#20513) with no fix yet.
Revert to Knip 5 which uses TypeScript's parser. Also revert the
NODE_OPTIONS workaround since it's no longer needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:31:58 +01:00
Lukas
d9562f850c Inline NODE_OPTIONS for CI check step
Some checks failed
CI / check (push) Failing after 18s
CI / build-image (push) Has been skipped
Step-level env may not propagate to pnpm subprocesses in Gitea
Actions. Inline the variable directly in the command instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:48:33 +01:00
Lukas
ec9f2e7877 Increase Node heap limit for CI check step
Some checks failed
CI / check (push) Failing after 16s
CI / build-image (push) Has been skipped
oxc-parser (used by Knip) fails with ArrayBuffer allocation
error on the CI runner's default heap size. Set max-old-space-size
to 2048MB to accommodate the buffer allocation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:40:33 +01:00
Lukas
c4079c384b Fix initiative input clipping inside container
Some checks failed
CI / check (push) Failing after 17s
CI / build-image (push) Has been skipped
Widen initiative grid column from 3rem to 3.5rem and use w-full
on the editing input so it fits within the rounded background
container without overflowing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:26:39 +01:00
Lukas
a4285fc415 Polish stat containers and optical alignment
Refine AC shield to use filled shape with border color instead of
stroke outline. Add subtle muted background to initiative container.
Apply optical vertical centering to round badge text (-3px) and
AC shield number (-2px). Unify round badge corners to rounded-md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:23:22 +01:00
Lukas
9c0e3398f1 Move AC shield next to initiative and refine shield style
Place AC between initiative and name to group static reference
stats on the left, leaving HP as the sole dynamic element on
the right. Dim the shield outline to 40% opacity so it recedes
visually, and nudge the number up 2px toward the visual center.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 13:54:20 +01:00
Lukas
9cdf004c15 Restyle HP display as compact rounded pill
Group current HP, temp HP, and max HP into a single bordered
pill container with a subtle slash separator. Removes the
scattered layout with separate elements and gaps. Temp HP +N
only renders when present (no invisible spacer).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 13:11:28 +01:00
Lukas
8bf69fd47d Add temporary hit points as a separate damage buffer
Temp HP absorbs damage before current HP, cannot be healed, and
does not stack (higher value wins). Displayed as cyan +N after
current HP with a Shield button in the HP adjustment popover.
Column space is reserved across all rows only when any combatant
has temp HP. Concentration pulse fires on any damage, including
damage fully absorbed by temp HP.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 11:39:47 +01:00
Lukas
7b83e3c3ea Upgrade pnpm 10.6.0 to 10.32.1
Fixes Node DEP0169 url.parse() deprecation warning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:17:07 +01:00
Lukas
c3c2cad798 Upgrade lefthook 1 to 2
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:15:23 +01:00
Lukas
3f6140303d Upgrade knip 5 to 6
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:09:56 +01:00
Lukas
fd30278474 Upgrade jsdom 28 to 29
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:09:11 +01:00
Lukas
278c06221f Upgrade Vite 8, plugin-react 6, Vitest 4
Vite 6→8 (Rolldown/Oxc), @vitejs/plugin-react 4→6 (Babel-free), Vitest 3→4 (AST coverage)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 09:41:14 +01:00
Lukas
722e8cc627 Update patch/minor dev dependencies
Biome 2.4.7→2.4.8, Tailwind 4.2.1→4.2.2, oxlint 1.55→1.56, oxlint-tsgolint 0.16→0.17.1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 09:17:26 +01:00
Lukas
64741956dd Preserve search input and focus when toggling browse mode
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:25:40 +01:00
Lukas
6336dec38a Add condition tooltips with 5.5e descriptions
All checks were successful
CI / check (push) Successful in 1m22s
CI / build-image (push) Successful in 19s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:48:23 +01:00
Lukas
9def2d7c24 Fix condition picker clipping out of viewport
All checks were successful
CI / check (push) Successful in 1m17s
CI / build-image (push) Successful in 27s
Render condition picker via React portal with fixed positioning so it
is no longer clipped by the overflow-y-auto combatant list container.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:34:15 +01:00
Lukas
f729e37689 Replace book icon with name-click stat block toggle and pencil rename
Name click now opens/collapses the stat block panel; a hover-visible
pencil icon next to the name handles renaming. Removes the standalone
book icon for a cleaner, more intuitive combatant row.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:29:56 +01:00
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
59 changed files with 2736 additions and 2588 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

@@ -20,15 +20,15 @@
"tailwind-merge": "^3.5.0" "tailwind-merge": "^3.5.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.2",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^6.0.1",
"jsdom": "^28.1.0", "jsdom": "^29.0.1",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.2",
"vite": "^6.2.0" "vite": "^8.0.1"
} }
} }

View File

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

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,12 +138,11 @@ describe("App integration", () => {
it("adds a combatant with HP, applies damage, and shows unconscious state", async () => { it("adds a combatant with HP, applies damage, and shows unconscious state", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<App />); render(<App />, { wrapper: AllProviders });
await addCombatant(user, "Ogre", { maxHp: "59" }); await addCombatant(user, "Ogre", { maxHp: "59" });
// Verify HP displays — currentHp and maxHp both show "59" // Verify HP displays — currentHp and maxHp both show "59"
expect(screen.getByText("/")).toBeInTheDocument();
const hpButton = screen.getByRole("button", { const hpButton = screen.getByRole("button", {
name: "Current HP: 59 (healthy)", name: "Current HP: 59 (healthy)",
}); });

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,67 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { combatantId } from "@initiative/domain"; import { type CreatureId, combatantId } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { CombatantRow } from "../combatant-row"; import { AllProviders } from "../../__tests__/test-providers.js";
import { PLAYER_COLOR_HEX } from "../player-icon-map"; import { CombatantRow } from "../combatant-row.js";
import { PLAYER_COLOR_HEX } from "../player-icon-map.js";
const TEMP_HP_REGEX = /^\+\d/;
// Mock persistence — no localStorage interaction
vi.mock("../../persistence/encounter-storage.js", () => ({
loadEncounter: () => null,
saveEncounter: () => {},
}));
vi.mock("../../persistence/player-character-storage.js", () => ({
loadPlayerCharacters: () => [],
savePlayerCharacters: () => {},
}));
// Mock bestiary — no IndexedDB or JSON index
vi.mock("../../adapters/bestiary-cache.js", () => ({
loadAllCachedCreatures: () => Promise.resolve(new Map()),
isSourceCached: () => Promise.resolve(false),
cacheSource: () => Promise.resolve(),
getCachedSources: () => Promise.resolve([]),
clearSource: () => Promise.resolve(),
clearAll: () => Promise.resolve(),
}));
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
getAllSourceCodes: () => [],
getDefaultFetchUrl: () => "",
getSourceDisplayName: (code: string) => code,
}));
// DOM API stubs
beforeAll(() => {
Object.defineProperty(globalThis, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});
afterEach(cleanup); afterEach(cleanup);
const defaultProps = {
onRename: vi.fn(),
onSetInitiative: vi.fn(),
onRemove: vi.fn(),
onSetHp: vi.fn(),
onAdjustHp: vi.fn(),
onSetAc: vi.fn(),
onToggleCondition: vi.fn(),
onToggleConcentration: vi.fn(),
};
function renderRow( function renderRow(
overrides: Partial<{ overrides: Partial<{
combatant: Parameters<typeof CombatantRow>[0]["combatant"]; combatant: Parameters<typeof CombatantRow>[0]["combatant"];
isActive: boolean; isActive: boolean;
onRollInitiative: (id: ReturnType<typeof combatantId>) => void;
onRemove: (id: ReturnType<typeof combatantId>) => void;
onShowStatBlock: () => void;
}> = {}, }> = {},
) { ) {
const combatant = overrides.combatant ?? { const combatant = overrides.combatant ?? {
@@ -38,15 +72,13 @@ function renderRow(
currentHp: 10, currentHp: 10,
ac: 13, ac: 13,
}; };
const props = { return render(
...defaultProps, <CombatantRow
combatant, combatant={combatant}
isActive: overrides.isActive ?? false, isActive={overrides.isActive ?? false}
onRollInitiative: overrides.onRollInitiative, />,
onShowStatBlock: overrides.onShowStatBlock, { wrapper: AllProviders },
onRemove: overrides.onRemove ?? defaultProps.onRemove, );
};
return render(<CombatantRow {...props} />);
} }
describe("CombatantRow", () => { describe("CombatantRow", () => {
@@ -93,14 +125,14 @@ describe("CombatantRow", () => {
expect(nameContainer).not.toBeNull(); expect(nameContainer).not.toBeNull();
}); });
it("shows '--' for current HP when no maxHp is set", () => { it("shows 'Max' placeholder when no maxHp is set", () => {
renderRow({ renderRow({
combatant: { combatant: {
id: combatantId("1"), id: combatantId("1"),
name: "Goblin", name: "Goblin",
}, },
}); });
expect(screen.getByLabelText("No HP set")).toBeInTheDocument(); expect(screen.getByText("Max")).toBeInTheDocument();
}); });
it("shows concentration icon when isConcentrating is true", () => { it("shows concentration icon when isConcentrating is true", () => {
@@ -132,10 +164,9 @@ describe("CombatantRow", () => {
expect(svgIcon).toHaveStyle({ color: PLAYER_COLOR_HEX.red }); expect(svgIcon).toHaveStyle({ color: PLAYER_COLOR_HEX.red });
}); });
it("remove button calls onRemove after confirmation", async () => { it("remove button removes after confirmation", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onRemove = vi.fn(); renderRow();
renderRow({ onRemove });
const removeBtn = screen.getByRole("button", { const removeBtn = screen.getByRole("button", {
name: "Remove combatant", name: "Remove combatant",
}); });
@@ -146,19 +177,124 @@ describe("CombatantRow", () => {
name: "Confirm remove combatant", name: "Confirm remove combatant",
}); });
await user.click(confirmBtn); await user.click(confirmBtn);
expect(onRemove).toHaveBeenCalledWith(combatantId("1")); // After confirming, the button returns to its initial state
expect(
screen.queryByRole("button", { name: "Confirm remove combatant" }),
).not.toBeInTheDocument();
}); });
it("shows d20 roll button when initiative is undefined and onRollInitiative is provided", () => { it("shows d20 roll button when initiative is undefined and combatant has creatureId", () => {
renderRow({ renderRow({
combatant: { combatant: {
id: combatantId("1"), id: combatantId("1"),
name: "Goblin", name: "Goblin",
creatureId: "srd:goblin" as CreatureId,
}, },
onRollInitiative: vi.fn(),
}); });
expect( expect(
screen.getByRole("button", { name: "Roll initiative" }), screen.getByRole("button", { name: "Roll initiative" }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
describe("concentration pulse", () => {
it("pulses when currentHp drops on a concentrating combatant", () => {
const combatant = {
id: combatantId("1"),
name: "Goblin",
maxHp: 20,
currentHp: 15,
isConcentrating: true,
};
const { rerender, container } = renderRow({ combatant });
rerender(
<CombatantRow
combatant={{ ...combatant, currentHp: 10 }}
isActive={false}
/>,
);
const row = container.firstElementChild;
expect(row?.className).toContain("animate-concentration-pulse");
});
it("does not pulse when not concentrating", () => {
const combatant = {
id: combatantId("1"),
name: "Goblin",
maxHp: 20,
currentHp: 15,
isConcentrating: false,
};
const { rerender, container } = renderRow({ combatant });
rerender(
<CombatantRow
combatant={{ ...combatant, currentHp: 10 }}
isActive={false}
/>,
);
const row = container.firstElementChild;
expect(row?.className).not.toContain("animate-concentration-pulse");
});
it("pulses when temp HP absorbs all damage on a concentrating combatant", () => {
const combatant = {
id: combatantId("1"),
name: "Goblin",
maxHp: 20,
currentHp: 15,
tempHp: 8,
isConcentrating: true,
};
const { rerender, container } = renderRow({ combatant });
// Temp HP absorbs all damage, currentHp unchanged
rerender(
<CombatantRow
combatant={{ ...combatant, tempHp: 3 }}
isActive={false}
/>,
);
const row = container.firstElementChild;
expect(row?.className).toContain("animate-concentration-pulse");
});
});
describe("temp HP display", () => {
it("shows +N when combatant has temp HP", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
maxHp: 20,
currentHp: 15,
tempHp: 5,
},
});
expect(screen.getByText("+5")).toBeInTheDocument();
});
it("does not show +N when combatant has no temp HP", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
maxHp: 20,
currentHp: 15,
},
});
expect(screen.queryByText(TEMP_HP_REGEX)).not.toBeInTheDocument();
});
it("temp HP display uses cyan color", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
maxHp: 20,
currentHp: 15,
tempHp: 8,
},
});
const tempHpEl = screen.getByText("+8");
expect(tempHpEl.className).toContain("text-cyan-400");
});
});
}); });

View File

@@ -4,6 +4,7 @@ import "@testing-library/jest-dom/vitest";
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain"; import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { createRef, type RefObject } from "react";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { ConditionPicker } from "../condition-picker"; import { ConditionPicker } from "../condition-picker";
@@ -18,8 +19,13 @@ function renderPicker(
) { ) {
const onToggle = overrides.onToggle ?? vi.fn(); const onToggle = overrides.onToggle ?? vi.fn();
const onClose = overrides.onClose ?? vi.fn(); const onClose = overrides.onClose ?? vi.fn();
const anchorRef = createRef<HTMLElement>() as RefObject<HTMLElement>;
const anchor = document.createElement("div");
document.body.appendChild(anchor);
(anchorRef as { current: HTMLElement }).current = anchor;
const result = render( const result = render(
<ConditionPicker <ConditionPicker
anchorRef={anchorRef}
activeConditions={overrides.activeConditions ?? []} activeConditions={overrides.activeConditions ?? []}
onToggle={onToggle} onToggle={onToggle}
onClose={onClose} onClose={onClose}

View File

@@ -11,15 +11,21 @@ afterEach(cleanup);
function renderPopover( function renderPopover(
overrides: Partial<{ overrides: Partial<{
onAdjust: (delta: number) => void; onAdjust: (delta: number) => void;
onSetTempHp: (value: number) => void;
onClose: () => void; onClose: () => void;
}> = {}, }> = {},
) { ) {
const onAdjust = overrides.onAdjust ?? vi.fn(); const onAdjust = overrides.onAdjust ?? vi.fn();
const onSetTempHp = overrides.onSetTempHp ?? vi.fn();
const onClose = overrides.onClose ?? vi.fn(); const onClose = overrides.onClose ?? vi.fn();
const result = render( const result = render(
<HpAdjustPopover onAdjust={onAdjust} onClose={onClose} />, <HpAdjustPopover
onAdjust={onAdjust}
onSetTempHp={onSetTempHp}
onClose={onClose}
/>,
); );
return { ...result, onAdjust, onClose }; return { ...result, onAdjust, onSetTempHp, onClose };
} }
describe("HpAdjustPopover", () => { describe("HpAdjustPopover", () => {
@@ -112,4 +118,31 @@ describe("HpAdjustPopover", () => {
await user.type(input, "12abc34"); await user.type(input, "12abc34");
expect(input).toHaveValue("1234"); expect(input).toHaveValue("1234");
}); });
describe("temp HP", () => {
it("shield button calls onSetTempHp with entered value and closes", async () => {
const user = userEvent.setup();
const { onSetTempHp, onClose } = renderPopover();
await user.type(screen.getByPlaceholderText("HP"), "8");
await user.click(screen.getByRole("button", { name: "Set temp HP" }));
expect(onSetTempHp).toHaveBeenCalledWith(8);
expect(onClose).toHaveBeenCalled();
});
it("shield button is disabled when input is empty", () => {
renderPopover();
expect(
screen.getByRole("button", { name: "Set temp HP" }),
).toBeDisabled();
});
it("shield button is disabled when input is '0'", async () => {
const user = userEvent.setup();
renderPopover();
await user.type(screen.getByPlaceholderText("HP"), "0");
expect(
screen.getByRole("button", { name: "Set temp HP" }),
).toBeDisabled();
});
});
}); });

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,40 @@ function renderNav(overrides: Partial<Encounter> = {}) {
...overrides, ...overrides,
}; };
return render( const value = {
<TurnNavigation encounter,
encounter={encounter} advanceTurn: vi.fn(),
onAdvanceTurn={vi.fn()} retreatTurn: vi.fn(),
onRetreatTurn={vi.fn()} clearEncounter: vi.fn(),
onClearEncounter={vi.fn()} isEmpty: encounter.combatants.length === 0,
/>, hasCreatureCombatants: false,
canRollAllInitiative: false,
addCombatant: vi.fn(),
removeCombatant: vi.fn(),
editCombatant: vi.fn(),
setInitiative: vi.fn(),
setHp: vi.fn(),
adjustHp: vi.fn(),
setTempHp: vi.fn(),
hasTempHp: false,
setAc: vi.fn(),
toggleCondition: vi.fn(),
toggleConcentration: vi.fn(),
addFromBestiary: vi.fn(),
addFromPlayerCharacter: vi.fn(),
makeStore: vi.fn(),
events: [],
};
mockUseEncounterContext.mockReturnValue(
value as ReturnType<typeof useEncounterContext>,
); );
return value;
}
function renderNav(overrides: Partial<Encounter> = {}) {
mockContext(overrides);
return render(<TurnNavigation />);
} }
describe("TurnNavigation", () => { describe("TurnNavigation", () => {
@@ -49,80 +87,39 @@ describe("TurnNavigation", () => {
it("does not render an em dash between round and name", () => { it("does not render an em dash between round and name", () => {
const { container } = renderNav(); const { container } = renderNav();
expect(container.textContent).not.toContain(""); expect(container.textContent).not.toContain("\u2014");
}); });
it("round badge and combatant name are siblings in the center area", () => { it("round badge and combatant name are siblings in the center area", () => {
renderNav(); renderNav();
const badge = screen.getByText("R1"); const badge = screen.getByText("R1");
const name = screen.getByText("Goblin"); const name = screen.getByText("Goblin");
expect(badge.parentElement).toBe(name.parentElement); // badge text is inside inner span > outer span, name is a direct child
expect(badge.closest(".flex")).toBe(name.parentElement);
}); });
it("updates the round badge when round changes", () => { it("updates the round badge when round changes", () => {
const { rerender } = render( mockContext({ roundNumber: 2 });
<TurnNavigation const { rerender } = render(<TurnNavigation />);
encounter={{
combatants: [{ id: combatantId("1"), name: "Goblin" }],
activeIndex: 0,
roundNumber: 2,
}}
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
/>,
);
expect(screen.getByText("R2")).toBeInTheDocument(); expect(screen.getByText("R2")).toBeInTheDocument();
rerender( mockContext({ roundNumber: 3 });
<TurnNavigation rerender(<TurnNavigation />);
encounter={{
combatants: [{ id: combatantId("1"), name: "Goblin" }],
activeIndex: 0,
roundNumber: 3,
}}
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
/>,
);
expect(screen.getByText("R3")).toBeInTheDocument(); expect(screen.getByText("R3")).toBeInTheDocument();
expect(screen.queryByText("R2")).not.toBeInTheDocument(); expect(screen.queryByText("R2")).not.toBeInTheDocument();
}); });
it("renders the next combatant name when turn advances", () => { it("renders the next combatant name when turn advances", () => {
const { rerender } = render( const combatants = [
<TurnNavigation
encounter={{
combatants: [
{ id: combatantId("1"), name: "Goblin" }, { id: combatantId("1"), name: "Goblin" },
{ id: combatantId("2"), name: "Conjurer" }, { id: combatantId("2"), name: "Conjurer" },
], ];
activeIndex: 0, mockContext({ combatants, activeIndex: 0 });
roundNumber: 1, const { rerender } = render(<TurnNavigation />);
}}
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
/>,
);
expect(screen.getByText("Goblin")).toBeInTheDocument(); expect(screen.getByText("Goblin")).toBeInTheDocument();
rerender( mockContext({ combatants, activeIndex: 1 });
<TurnNavigation rerender(<TurnNavigation />);
encounter={{
combatants: [
{ id: combatantId("1"), name: "Goblin" },
{ id: combatantId("2"), name: "Conjurer" },
],
activeIndex: 1,
roundNumber: 1,
}}
onAdvanceTurn={vi.fn()}
onRetreatTurn={vi.fn()}
onClearEncounter={vi.fn()}
/>,
);
expect(screen.getByText("Conjurer")).toBeInTheDocument(); expect(screen.getByText("Conjurer")).toBeInTheDocument();
}); });
}); });

View File

@@ -19,17 +19,15 @@ export function AcShield({ value, onClick, className }: AcShieldProps) {
> >
<svg <svg
viewBox="0 0 28 32" viewBox="0 0 28 32"
fill="none" fill="var(--color-border)"
stroke="currentColor" fillOpacity={0.5}
strokeWidth={1.5} stroke="none"
strokeLinecap="round"
strokeLinejoin="round"
className="absolute inset-0 h-full w-full" className="absolute inset-0 h-full w-full"
aria-hidden="true" aria-hidden="true"
> >
<path d="M14 1.5 L2.5 6.5 L2.5 15 Q2.5 25 14 30.5 Q25.5 25 25.5 15 L25.5 6.5 Z" /> <path d="M14 1.5 L2.5 6.5 L2.5 15 Q2.5 25 14 30.5 Q25.5 25 25.5 15 L25.5 6.5 Z" />
</svg> </svg>
<span className="relative font-medium text-xs leading-none"> <span className="relative -mt-0.5 font-medium text-xs leading-none">
{value == null ? "\u2014" : String(value)} {value == null ? "\u2014" : String(value)}
</span> </span>
</button> </button>

View File

@@ -1,4 +1,4 @@
import type { PlayerCharacter, RollMode } from "@initiative/domain"; import type { CreatureId, PlayerCharacter } from "@initiative/domain";
import { import {
Check, Check,
Eye, Eye,
@@ -18,11 +18,18 @@ import React, {
useDeferredValue, useDeferredValue,
useState, useState,
} from "react"; } from "react";
import type { SearchResult } from "../hooks/use-bestiary.js"; import type { SearchResult } from "../contexts/bestiary-context.js";
import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
import { useEncounterContext } from "../contexts/encounter-context.js";
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
import { useSidePanelContext } from "../contexts/side-panel-context.js";
import { useThemeContext } from "../contexts/theme-context.js";
import { useLongPress } from "../hooks/use-long-press.js"; import { useLongPress } from "../hooks/use-long-press.js";
import { cn } from "../lib/utils.js"; import { cn } from "../lib/utils.js";
import { D20Icon } from "./d20-icon.js"; import { D20Icon } from "./d20-icon.js";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map"; import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
import { RollModeMenu } from "./roll-mode-menu.js"; import { RollModeMenu } from "./roll-mode-menu.js";
import { Button } from "./ui/button.js"; import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js"; import { Input } from "./ui/input.js";
@@ -34,27 +41,9 @@ interface QueuedCreature {
} }
interface ActionBarProps { interface ActionBarProps {
onAddCombatant: (
name: string,
opts?: { initiative?: number; ac?: number; maxHp?: number },
) => void;
onAddFromBestiary: (result: SearchResult) => void;
bestiarySearch: (query: string) => SearchResult[];
bestiaryLoaded: boolean;
onViewStatBlock?: (result: SearchResult) => void;
onBulkImport?: () => void;
bulkImportDisabled?: boolean;
inputRef?: RefObject<HTMLInputElement | null>; inputRef?: RefObject<HTMLInputElement | null>;
playerCharacters?: readonly PlayerCharacter[];
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
onManagePlayers?: () => void;
onRollAllInitiative?: (mode?: RollMode) => void;
showRollAllInitiative?: boolean;
rollAllInitiativeDisabled?: boolean;
onOpenSourceManager?: () => void;
autoFocus?: boolean; autoFocus?: boolean;
themePreference?: "system" | "light" | "dark"; onManagePlayers?: () => void;
onCycleTheme?: () => void;
} }
function creatureKey(r: SearchResult): string { function creatureKey(r: SearchResult): string {
@@ -285,25 +274,48 @@ function buildOverflowItems(opts: {
} }
export function ActionBar({ export function ActionBar({
onAddCombatant,
onAddFromBestiary,
bestiarySearch,
bestiaryLoaded,
onViewStatBlock,
onBulkImport,
bulkImportDisabled,
inputRef, inputRef,
playerCharacters,
onAddFromPlayerCharacter,
onManagePlayers,
onRollAllInitiative,
showRollAllInitiative,
rollAllInitiativeDisabled,
onOpenSourceManager,
autoFocus, autoFocus,
themePreference, onManagePlayers,
onCycleTheme,
}: Readonly<ActionBarProps>) { }: Readonly<ActionBarProps>) {
const {
addCombatant,
addFromBestiary,
addFromPlayerCharacter,
hasCreatureCombatants,
canRollAllInitiative,
} = useEncounterContext();
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
useBestiaryContext();
const { characters: playerCharacters } = usePlayerCharactersContext();
const { showBulkImport, showSourceManager, showCreature, panelView } =
useSidePanelContext();
const { preference: themePreference, cycleTheme } = useThemeContext();
const { handleRollAllInitiative } = useInitiativeRollsContext();
const { state: bulkImportState } = useBulkImportContext();
const handleAddFromBestiary = useCallback(
(result: SearchResult) => {
const creatureId = addFromBestiary(result);
if (creatureId && panelView.mode === "closed") {
showCreature(creatureId);
}
},
[addFromBestiary, panelView.mode, showCreature],
);
const handleViewStatBlock = useCallback(
(result: SearchResult) => {
const slug = result.name
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, "-")
.replaceAll(/(^-|-$)/g, "");
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
showCreature(cId);
},
[showCreature],
);
const [nameInput, setNameInput] = useState(""); const [nameInput, setNameInput] = useState("");
const [suggestions, setSuggestions] = useState<SearchResult[]>([]); const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]); const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
@@ -340,7 +352,7 @@ export function ActionBar({
const confirmQueued = () => { const confirmQueued = () => {
if (!queued) return; if (!queued) return;
for (let i = 0; i < queued.count; i++) { for (let i = 0; i < queued.count; i++) {
onAddFromBestiary(queued.result); handleAddFromBestiary(queued.result);
} }
clearInput(); clearInput();
}; };
@@ -366,7 +378,7 @@ export function ActionBar({
if (init !== undefined) opts.initiative = init; if (init !== undefined) opts.initiative = init;
if (ac !== undefined) opts.ac = ac; if (ac !== undefined) opts.ac = ac;
if (maxHp !== undefined) opts.maxHp = maxHp; if (maxHp !== undefined) opts.maxHp = maxHp;
onAddCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined); addCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
setNameInput(""); setNameInput("");
setSuggestions([]); setSuggestions([]);
setPcMatches([]); setPcMatches([]);
@@ -468,21 +480,30 @@ export function ActionBar({
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1)); setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
} else if (e.key === "Enter" && suggestionIndex >= 0) { } else if (e.key === "Enter" && suggestionIndex >= 0) {
e.preventDefault(); e.preventDefault();
onViewStatBlock?.(suggestions[suggestionIndex]); handleViewStatBlock(suggestions[suggestionIndex]);
setBrowseMode(false); setBrowseMode(false);
clearInput(); clearInput();
} }
}; };
const handleBrowseSelect = (result: SearchResult) => { const handleBrowseSelect = (result: SearchResult) => {
onViewStatBlock?.(result); handleViewStatBlock(result);
setBrowseMode(false); setBrowseMode(false);
clearInput(); clearInput();
}; };
const toggleBrowseMode = () => { const toggleBrowseMode = () => {
setBrowseMode((m) => !m); setBrowseMode((prev) => {
clearInput(); const next = !prev;
setSuggestionIndex(-1);
setQueued(null);
if (next) {
handleBrowseSearch(nameInput);
} else {
handleAddSearch(nameInput);
}
return next;
});
clearCustomFields(); clearCustomFields();
}; };
@@ -507,12 +528,12 @@ export function ActionBar({
const overflowItems = buildOverflowItems({ const overflowItems = buildOverflowItems({
onManagePlayers, onManagePlayers,
onOpenSourceManager, onOpenSourceManager: showSourceManager,
bestiaryLoaded, bestiaryLoaded,
onBulkImport, onBulkImport: showBulkImport,
bulkImportDisabled, bulkImportDisabled: bulkImportState.status === "loading",
themePreference, themePreference,
onCycleTheme, onCycleTheme: cycleTheme,
}); });
return ( return (
@@ -535,7 +556,7 @@ export function ActionBar({
className="pr-8" className="pr-8"
autoFocus={autoFocus} autoFocus={autoFocus}
/> />
{bestiaryLoaded && !!onViewStatBlock && ( {!!bestiaryLoaded && (
<button <button
type="button" type="button"
tabIndex={-1} tabIndex={-1}
@@ -543,6 +564,7 @@ export function ActionBar({
"absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral", "absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
browseMode && "text-accent", browseMode && "text-accent",
)} )}
onMouseDown={(e) => e.preventDefault()}
onClick={toggleBrowseMode} onClick={toggleBrowseMode}
title={browseMode ? "Switch to add mode" : "Browse stat blocks"} title={browseMode ? "Switch to add mode" : "Browse stat blocks"}
aria-label={ aria-label={
@@ -596,7 +618,7 @@ export function ActionBar({
onSetSuggestionIndex={setSuggestionIndex} onSetSuggestionIndex={setSuggestionIndex}
onSetQueued={setQueued} onSetQueued={setQueued}
onConfirmQueued={confirmQueued} onConfirmQueued={confirmQueued}
onAddFromPlayerCharacter={onAddFromPlayerCharacter} onAddFromPlayerCharacter={addFromPlayerCharacter}
/> />
)} )}
</div> </div>
@@ -632,20 +654,20 @@ export function ActionBar({
{!browseMode && nameInput.length >= 2 && !hasSuggestions && ( {!browseMode && nameInput.length >= 2 && !hasSuggestions && (
<Button type="submit">Add</Button> <Button type="submit">Add</Button>
)} )}
{showRollAllInitiative && !!onRollAllInitiative && ( {!!hasCreatureCombatants && (
<> <>
<Button <Button
type="button" type="button"
size="icon" size="icon"
variant="ghost" variant="ghost"
className="text-muted-foreground hover:text-hover-action" className="text-muted-foreground hover:text-hover-action"
onClick={() => onRollAllInitiative()} onClick={() => handleRollAllInitiative()}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
openRollAllMenu(e.clientX, e.clientY); openRollAllMenu(e.clientX, e.clientY);
}} }}
{...rollAllLongPress} {...rollAllLongPress}
disabled={rollAllInitiativeDisabled} disabled={!canRollAllInitiative}
title="Roll all initiative" title="Roll all initiative"
aria-label="Roll all initiative" aria-label="Roll all initiative"
> >
@@ -654,7 +676,7 @@ export function ActionBar({
{!!rollAllMenuPos && ( {!!rollAllMenuPos && (
<RollModeMenu <RollModeMenu
position={rollAllMenuPos} position={rollAllMenuPos}
onSelect={(mode) => onRollAllInitiative(mode)} onSelect={(mode) => handleRollAllInitiative(mode)}
onClose={() => setRollAllMenuPos(null)} onClose={() => setRollAllMenuPos(null)}
/> />
)} )}

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,23 +1,27 @@
import { import {
type CombatantId, type CombatantId,
type ConditionId, type ConditionId,
type CreatureId,
deriveHpStatus, deriveHpStatus,
type PlayerIcon, type PlayerIcon,
type RollMode, type RollMode,
} from "@initiative/domain"; } from "@initiative/domain";
import { Book, BookOpen, Brain, X } from "lucide-react"; import { Brain, Pencil, X } from "lucide-react";
import { type Ref, useCallback, useEffect, useRef, useState } from "react"; import { type Ref, useCallback, useEffect, useRef, useState } from "react";
import { useLongPress } from "../hooks/use-long-press"; import { useEncounterContext } from "../contexts/encounter-context.js";
import { cn } from "../lib/utils"; import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
import { AcShield } from "./ac-shield"; import { useSidePanelContext } from "../contexts/side-panel-context.js";
import { ConditionPicker } from "./condition-picker"; import { useLongPress } from "../hooks/use-long-press.js";
import { ConditionTags } from "./condition-tags"; import { cn } from "../lib/utils.js";
import { D20Icon } from "./d20-icon"; import { AcShield } from "./ac-shield.js";
import { HpAdjustPopover } from "./hp-adjust-popover"; import { ConditionPicker } from "./condition-picker.js";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map"; import { ConditionTags } from "./condition-tags.js";
import { RollModeMenu } from "./roll-mode-menu"; import { D20Icon } from "./d20-icon.js";
import { ConfirmButton } from "./ui/confirm-button"; import { HpAdjustPopover } from "./hp-adjust-popover.js";
import { Input } from "./ui/input"; import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
import { RollModeMenu } from "./roll-mode-menu.js";
import { ConfirmButton } from "./ui/confirm-button.js";
import { Input } from "./ui/input.js";
interface Combatant { interface Combatant {
readonly id: CombatantId; readonly id: CombatantId;
@@ -25,27 +29,18 @@ interface Combatant {
readonly initiative?: number; readonly initiative?: number;
readonly maxHp?: number; readonly maxHp?: number;
readonly currentHp?: number; readonly currentHp?: number;
readonly tempHp?: number;
readonly ac?: number; readonly ac?: number;
readonly conditions?: readonly ConditionId[]; readonly conditions?: readonly ConditionId[];
readonly isConcentrating?: boolean; readonly isConcentrating?: boolean;
readonly color?: string; readonly color?: string;
readonly icon?: string; readonly icon?: string;
readonly creatureId?: CreatureId;
} }
interface CombatantRowProps { interface CombatantRowProps {
combatant: Combatant; combatant: Combatant;
isActive: boolean; isActive: boolean;
onRename: (id: CombatantId, newName: string) => void;
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
onRemove: (id: CombatantId) => void;
onSetHp: (id: CombatantId, maxHp: number | undefined) => void;
onAdjustHp: (id: CombatantId, delta: number) => void;
onSetAc: (id: CombatantId, value: number | undefined) => void;
onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void;
onToggleConcentration: (id: CombatantId) => void;
onShowStatBlock?: () => void;
isStatBlockOpen?: boolean;
onRollInitiative?: (id: CombatantId, mode?: RollMode) => void;
} }
function EditableName({ function EditableName({
@@ -53,11 +48,13 @@ function EditableName({
combatantId, combatantId,
onRename, onRename,
color, color,
onToggleStatBlock,
}: Readonly<{ }: Readonly<{
name: string; name: string;
combatantId: CombatantId; combatantId: CombatantId;
onRename: (id: CombatantId, newName: string) => void; onRename: (id: CombatantId, newName: string) => void;
color?: string; color?: string;
onToggleStatBlock?: () => void;
}>) { }>) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(name); const [draft, setDraft] = useState(name);
@@ -95,14 +92,31 @@ function EditableName({
} }
return ( return (
<>
<button <button
type="button" type="button"
onClick={startEditing} onClick={onToggleStatBlock}
className="cursor-text truncate text-left text-foreground text-sm transition-colors hover:text-hover-neutral" disabled={!onToggleStatBlock}
className={cn(
"truncate text-left text-sm transition-colors",
onToggleStatBlock
? "cursor-pointer text-foreground hover:text-hover-neutral"
: "cursor-default text-foreground",
)}
style={color ? { color } : undefined} style={color ? { color } : undefined}
> >
{name} {name}
</button> </button>
<button
type="button"
onClick={startEditing}
title="Rename"
aria-label="Rename"
className="inline-flex shrink-0 items-center rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-colors transition-opacity hover:bg-hover-neutral-bg hover:text-hover-neutral focus:opacity-100 group-hover:opacity-100"
>
<Pencil size={14} />
</button>
</>
); );
} }
@@ -158,7 +172,12 @@ function MaxHpDisplay({
<button <button
type="button" type="button"
onClick={startEditing} onClick={startEditing}
className="inline-block h-7 min-w-[3ch] text-center text-muted-foreground text-sm tabular-nums leading-7 transition-colors hover:text-hover-neutral" className={cn(
"inline-block h-7 min-w-[3ch] text-center leading-7 transition-colors hover:text-hover-neutral",
maxHp === undefined
? "text-muted-foreground text-sm"
: "text-muted-foreground text-xs",
)}
> >
{maxHp ?? "Max"} {maxHp ?? "Max"}
</button> </button>
@@ -168,51 +187,47 @@ function MaxHpDisplay({
function ClickableHp({ function ClickableHp({
currentHp, currentHp,
maxHp, maxHp,
tempHp,
onAdjust, onAdjust,
dimmed, onSetTempHp,
}: Readonly<{ }: Readonly<{
currentHp: number | undefined; currentHp: number | undefined;
maxHp: number | undefined; maxHp: number | undefined;
tempHp: number | undefined;
onAdjust: (delta: number) => void; onAdjust: (delta: number) => void;
dimmed?: boolean; onSetTempHp: (value: number) => void;
}>) { }>) {
const [popoverOpen, setPopoverOpen] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false);
const status = deriveHpStatus(currentHp, maxHp); const status = deriveHpStatus(currentHp, maxHp);
if (maxHp === undefined) { if (maxHp === undefined) {
return ( return null;
<span
className={cn(
"inline-block h-7 w-[4ch] text-center text-muted-foreground text-sm tabular-nums leading-7",
dimmed && "opacity-50",
)}
role="status"
aria-label="No HP set"
>
--
</span>
);
} }
return ( return (
<div className="relative"> <div className="relative flex items-center">
<button <button
type="button" type="button"
onClick={() => setPopoverOpen(true)} onClick={() => setPopoverOpen(true)}
aria-label={`Current HP: ${currentHp} (${status})`} aria-label={`Current HP: ${currentHp}${tempHp ? ` (+${tempHp} temp)` : ""} (${status})`}
className={cn( className={cn(
"inline-block h-7 min-w-[3ch] text-center font-medium text-sm tabular-nums leading-7 transition-colors hover:text-hover-neutral", "inline-block h-7 min-w-[3ch] text-center font-medium text-sm leading-7 transition-colors hover:text-hover-neutral",
status === "bloodied" && "text-amber-400", status === "bloodied" && "text-amber-400",
status === "unconscious" && "text-red-400", status === "unconscious" && "text-red-400",
status === "healthy" && "text-foreground", status === "healthy" && "text-foreground",
dimmed && "opacity-50",
)} )}
> >
{currentHp} {currentHp}
</button> </button>
{!!tempHp && (
<span className="font-medium text-cyan-400 text-sm leading-7">
+{tempHp}
</span>
)}
{!!popoverOpen && ( {!!popoverOpen && (
<HpAdjustPopover <HpAdjustPopover
onAdjust={onAdjust} onAdjust={onAdjust}
onSetTempHp={onSetTempHp}
onClose={() => setPopoverOpen(false)} onClose={() => setPopoverOpen(false)}
/> />
)} )}
@@ -333,7 +348,7 @@ function InitiativeDisplay({
value={draft} value={draft}
placeholder="--" placeholder="--"
className={cn( className={cn(
"h-7 w-[6ch] text-center text-sm tabular-nums", "h-7 w-full text-center text-sm tabular-nums",
dimmed && "opacity-50", dimmed && "opacity-50",
)} )}
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
@@ -346,7 +361,7 @@ function InitiativeDisplay({
); );
} }
// Empty + bestiary creature d20 roll button // Empty + bestiary creature -> d20 roll button
if (initiative === undefined && onRollInitiative) { if (initiative === undefined && onRollInitiative) {
return ( return (
<> <>
@@ -378,8 +393,8 @@ function InitiativeDisplay({
); );
} }
// Has value bold number, click to edit // Has value -> bold number, click to edit
// Empty + manual "--" placeholder, click to edit // Empty + manual -> "--" placeholder, click to edit
return ( return (
<button <button
type="button" type="button"
@@ -423,42 +438,67 @@ export function CombatantRow({
ref, ref,
combatant, combatant,
isActive, isActive,
onRename,
onSetInitiative,
onRemove,
onSetHp,
onAdjustHp,
onSetAc,
onToggleCondition,
onToggleConcentration,
onShowStatBlock,
isStatBlockOpen,
onRollInitiative,
}: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) { }: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) {
const {
editCombatant,
setInitiative,
removeCombatant,
setHp,
adjustHp,
setTempHp,
setAc,
toggleCondition,
toggleConcentration,
} = useEncounterContext();
const { selectedCreatureId, showCreature, toggleCollapse } =
useSidePanelContext();
const { handleRollInitiative } = useInitiativeRollsContext();
// Derive what was previously conditional props
const isStatBlockOpen = combatant.creatureId === selectedCreatureId;
const { creatureId } = combatant;
const hasStatBlock = !!creatureId;
const onToggleStatBlock = hasStatBlock
? () => {
if (isStatBlockOpen) {
toggleCollapse();
} else {
showCreature(creatureId);
}
}
: undefined;
const onRollInitiative = combatant.creatureId
? handleRollInitiative
: undefined;
const { id, name, initiative, maxHp, currentHp } = combatant; const { id, name, initiative, maxHp, currentHp } = combatant;
const status = deriveHpStatus(currentHp, maxHp); const status = deriveHpStatus(currentHp, maxHp);
const dimmed = status === "unconscious"; const dimmed = status === "unconscious";
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const conditionAnchorRef = useRef<HTMLDivElement>(null);
const prevHpRef = useRef(currentHp); const prevHpRef = useRef(currentHp);
const prevTempHpRef = useRef(combatant.tempHp);
const [isPulsing, setIsPulsing] = useState(false); const [isPulsing, setIsPulsing] = useState(false);
const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined); const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => { useEffect(() => {
const prevHp = prevHpRef.current; const prevHp = prevHpRef.current;
const prevTempHp = prevTempHpRef.current;
prevHpRef.current = currentHp; prevHpRef.current = currentHp;
prevTempHpRef.current = combatant.tempHp;
if ( const realHpDropped =
prevHp !== undefined && prevHp !== undefined && currentHp !== undefined && currentHp < prevHp;
currentHp !== undefined && const tempHpDropped =
currentHp < prevHp && prevTempHp !== undefined && (combatant.tempHp ?? 0) < prevTempHp;
combatant.isConcentrating
) { if ((realHpDropped || tempHpDropped) && combatant.isConcentrating) {
setIsPulsing(true); setIsPulsing(true);
clearTimeout(pulseTimerRef.current); clearTimeout(pulseTimerRef.current);
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200); pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
} }
}, [currentHp, combatant.isConcentrating]); }, [currentHp, combatant.tempHp, combatant.isConcentrating]);
useEffect(() => { useEffect(() => {
if (!combatant.isConcentrating) { if (!combatant.isConcentrating) {
@@ -480,11 +520,11 @@ export function CombatantRow({
isPulsing && "animate-concentration-pulse", isPulsing && "animate-concentration-pulse",
)} )}
> >
<div className="grid grid-cols-[2rem_3rem_1fr_auto_auto_2rem] items-center gap-3 py-2"> <div className="grid grid-cols-[2rem_3.5rem_auto_1fr_auto_2rem] items-center gap-3 py-2">
{/* Concentration */} {/* Concentration */}
<button <button
type="button" type="button"
onClick={() => onToggleConcentration(id)} onClick={() => toggleConcentration(id)}
title="Concentrating" title="Concentrating"
aria-label="Toggle concentration" aria-label="Toggle concentration"
className={cn( className={cn(
@@ -496,13 +536,20 @@ export function CombatantRow({
</button> </button>
{/* Initiative */} {/* Initiative */}
<div className="rounded-md bg-muted/30 px-1">
<InitiativeDisplay <InitiativeDisplay
initiative={initiative} initiative={initiative}
combatantId={id} combatantId={id}
dimmed={dimmed} dimmed={dimmed}
onSetInitiative={onSetInitiative} onSetInitiative={setInitiative}
onRollInitiative={onRollInitiative} onRollInitiative={onRollInitiative}
/> />
</div>
{/* AC */}
<div className={cn(dimmed && "opacity-50")}>
<AcDisplay ac={combatant.ac} onCommit={(v) => setAc(id, v)} />
</div>
{/* Name + Conditions */} {/* Name + Conditions */}
<div <div
@@ -511,17 +558,6 @@ export function CombatantRow({
dimmed && "opacity-50", dimmed && "opacity-50",
)} )}
> >
{!!onShowStatBlock && (
<button
type="button"
onClick={onShowStatBlock}
title="View stat block"
aria-label="View stat block"
className="shrink-0 text-foreground transition-colors hover:text-hover-neutral"
>
{isStatBlockOpen ? <BookOpen size={16} /> : <Book size={16} />}
</button>
)}
{!!combatant.icon && {!!combatant.icon &&
!!combatant.color && !!combatant.color &&
(() => { (() => {
@@ -541,56 +577,55 @@ export function CombatantRow({
<EditableName <EditableName
name={name} name={name}
combatantId={id} combatantId={id}
onRename={onRename} onRename={editCombatant}
color={pcColor} color={pcColor}
onToggleStatBlock={onToggleStatBlock}
/> />
<div ref={conditionAnchorRef}>
<ConditionTags <ConditionTags
conditions={combatant.conditions} conditions={combatant.conditions}
onRemove={(conditionId) => onToggleCondition(id, conditionId)} onRemove={(conditionId) => toggleCondition(id, conditionId)}
onOpenPicker={() => setPickerOpen((prev) => !prev)} onOpenPicker={() => setPickerOpen((prev) => !prev)}
/> />
</div>
{!!pickerOpen && ( {!!pickerOpen && (
<ConditionPicker <ConditionPicker
anchorRef={conditionAnchorRef}
activeConditions={combatant.conditions} activeConditions={combatant.conditions}
onToggle={(conditionId) => onToggleCondition(id, conditionId)} onToggle={(conditionId) => toggleCondition(id, conditionId)}
onClose={() => setPickerOpen(false)} onClose={() => setPickerOpen(false)}
/> />
)} )}
</div> </div>
{/* AC */}
<div className={cn(dimmed && "opacity-50")}>
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
</div>
{/* HP */} {/* HP */}
<div className="flex items-center gap-1"> <div
<ClickableHp
currentHp={currentHp}
maxHp={maxHp}
onAdjust={(delta) => onAdjustHp(id, delta)}
dimmed={dimmed}
/>
{maxHp !== undefined && (
<span
className={cn( className={cn(
"text-muted-foreground text-sm tabular-nums", "flex items-center rounded-md tabular-nums",
maxHp === undefined
? ""
: "gap-0.5 border border-border/50 bg-muted/30 px-1.5",
dimmed && "opacity-50", dimmed && "opacity-50",
)} )}
> >
/ <ClickableHp
</span> currentHp={currentHp}
maxHp={maxHp}
tempHp={combatant.tempHp}
onAdjust={(delta) => adjustHp(id, delta)}
onSetTempHp={(value) => setTempHp(id, value)}
/>
{maxHp !== undefined && (
<span className="text-muted-foreground/50 text-xs">/</span>
)} )}
<div className={cn(dimmed && "opacity-50")}> <MaxHpDisplay maxHp={maxHp} onCommit={(v) => setHp(id, v)} />
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => onSetHp(id, v)} />
</div>
</div> </div>
{/* Actions */} {/* Actions */}
<ConfirmButton <ConfirmButton
icon={<X size={16} />} icon={<X size={16} />}
label="Remove combatant" label="Remove combatant"
onConfirm={() => onRemove(id)} onConfirm={() => removeCombatant(id)}
className="pointer-events-none pointer-coarse:pointer-events-auto h-7 w-7 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-opacity focus:pointer-events-auto focus:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100" className="pointer-events-none pointer-coarse:pointer-events-auto h-7 w-7 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-opacity focus:pointer-events-auto focus:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"
/> />
</div> </div>

View File

@@ -18,7 +18,9 @@ import {
ZapOff, ZapOff,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { Tooltip } from "./ui/tooltip.js";
const ICON_MAP: Record<string, LucideIcon> = { const ICON_MAP: Record<string, LucideIcon> = {
EyeOff, EyeOff,
@@ -52,34 +54,45 @@ const COLOR_CLASSES: Record<string, string> = {
}; };
interface ConditionPickerProps { interface ConditionPickerProps {
anchorRef: React.RefObject<HTMLElement | null>;
activeConditions: readonly ConditionId[] | undefined; activeConditions: readonly ConditionId[] | undefined;
onToggle: (conditionId: ConditionId) => void; onToggle: (conditionId: ConditionId) => void;
onClose: () => void; onClose: () => void;
} }
export function ConditionPicker({ export function ConditionPicker({
anchorRef,
activeConditions, activeConditions,
onToggle, onToggle,
onClose, onClose,
}: Readonly<ConditionPickerProps>) { }: Readonly<ConditionPickerProps>) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [flipped, setFlipped] = useState(false); const [pos, setPos] = useState<{
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined); top: number;
left: number;
maxHeight: number;
} | null>(null);
useLayoutEffect(() => { useLayoutEffect(() => {
const anchor = anchorRef.current;
const el = ref.current; const el = ref.current;
if (!el) return; if (!anchor || !el) return;
const rect = el.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.top; const anchorRect = anchor.getBoundingClientRect();
const spaceAbove = rect.bottom; const menuHeight = el.scrollHeight;
const shouldFlip = const pad = 8;
rect.bottom > window.innerHeight && spaceAbove > spaceBelow;
setFlipped(shouldFlip); const spaceBelow = window.innerHeight - anchorRect.bottom - pad;
const available = shouldFlip ? spaceAbove : spaceBelow; const spaceAbove = anchorRect.top - pad;
if (rect.height > available) { const openBelow = spaceBelow >= menuHeight || spaceBelow >= spaceAbove;
setMaxHeight(available - 16);
} const top = openBelow
}, []); ? anchorRect.bottom + 4
: Math.max(pad, anchorRect.top - Math.min(menuHeight, spaceAbove) - 4);
const maxHeight = openBelow ? spaceBelow : Math.min(menuHeight, spaceAbove);
setPos({ top, left: anchorRect.left, maxHeight });
}, [anchorRef]);
useEffect(() => { useEffect(() => {
function handleClickOutside(e: MouseEvent) { function handleClickOutside(e: MouseEvent) {
@@ -93,14 +106,15 @@ export function ConditionPicker({
const active = new Set(activeConditions ?? []); const active = new Set(activeConditions ?? []);
return ( return createPortal(
<div <div
ref={ref} ref={ref}
className={cn( className="card-glow fixed z-50 w-fit overflow-y-auto rounded-lg border border-border bg-background p-1"
"card-glow absolute left-0 z-10 w-fit overflow-y-auto rounded-lg border border-border bg-background p-1", style={
flipped ? "bottom-full mb-1" : "top-full mt-1", pos
)} ? { top: pos.top, left: pos.left, maxHeight: pos.maxHeight }
style={maxHeight ? { maxHeight } : undefined} : { visibility: "hidden" as const }
}
> >
{CONDITION_DEFINITIONS.map((def) => { {CONDITION_DEFINITIONS.map((def) => {
const Icon = ICON_MAP[def.iconName]; const Icon = ICON_MAP[def.iconName];
@@ -108,8 +122,8 @@ export function ConditionPicker({
const isActive = active.has(def.id); const isActive = active.has(def.id);
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground"; const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
return ( return (
<Tooltip key={def.id} content={def.description} className="block">
<button <button
key={def.id}
type="button" type="button"
className={cn( className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-hover-neutral-bg", "flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-hover-neutral-bg",
@@ -122,13 +136,17 @@ export function ConditionPicker({
className={isActive ? colorClass : "text-muted-foreground"} className={isActive ? colorClass : "text-muted-foreground"}
/> />
<span <span
className={isActive ? "text-foreground" : "text-muted-foreground"} className={
isActive ? "text-foreground" : "text-muted-foreground"
}
> >
{def.label} {def.label}
</span> </span>
</button> </button>
</Tooltip>
); );
})} })}
</div> </div>,
document.body,
); );
} }

View File

@@ -19,6 +19,7 @@ import {
ZapOff, ZapOff,
} from "lucide-react"; } from "lucide-react";
import { cn } from "../lib/utils.js"; import { cn } from "../lib/utils.js";
import { Tooltip } from "./ui/tooltip.js";
const ICON_MAP: Record<string, LucideIcon> = { const ICON_MAP: Record<string, LucideIcon> = {
EyeOff, EyeOff,
@@ -71,10 +72,9 @@ export function ConditionTags({
if (!Icon) return null; if (!Icon) return null;
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground"; const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
return ( return (
<Tooltip key={condId} content={`${def.label}: ${def.description}`}>
<button <button
key={condId}
type="button" type="button"
title={def.label}
aria-label={`Remove ${def.label}`} aria-label={`Remove ${def.label}`}
className={cn( className={cn(
"inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg", "inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
@@ -87,6 +87,7 @@ export function ConditionTags({
> >
<Icon size={14} /> <Icon size={14} />
</button> </button>
</Tooltip>
); );
})} })}
<button <button

View File

@@ -1,4 +1,4 @@
import { Heart, Sword } from "lucide-react"; import { Heart, ShieldPlus, Sword } from "lucide-react";
import { import {
useCallback, useCallback,
useEffect, useEffect,
@@ -12,10 +12,15 @@ const DIGITS_ONLY_REGEX = /^\d+$/;
interface HpAdjustPopoverProps { interface HpAdjustPopoverProps {
readonly onAdjust: (delta: number) => void; readonly onAdjust: (delta: number) => void;
readonly onSetTempHp: (value: number) => void;
readonly onClose: () => void; readonly onClose: () => void;
} }
export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) { export function HpAdjustPopover({
onAdjust,
onSetTempHp,
onClose,
}: HpAdjustPopoverProps) {
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
const [pos, setPos] = useState<{ top: number; left: number } | null>(null); const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@@ -130,6 +135,21 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
> >
<Heart size={14} /> <Heart size={14} />
</button> </button>
<button
type="button"
disabled={!isValid}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-cyan-400 transition-colors hover:bg-cyan-400/10 hover:text-cyan-300 disabled:pointer-events-none disabled:opacity-50"
onClick={() => {
if (isValid && parsedValue) {
onSetTempHp(parsedValue);
onClose();
}
}}
title="Set temp HP"
aria-label="Set temp HP"
>
<ShieldPlus size={14} />
</button>
</div> </div>
</div> </div>
); );

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

@@ -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({
); );
} }
export function StatBlockPanel({ function usePanelRole(panelRole: "browse" | "pinned") {
const sidePanel = useSidePanelContext();
const { getCreature } = useBestiaryContext();
const creatureId =
panelRole === "browse"
? sidePanel.selectedCreatureId
: sidePanel.pinnedCreatureId;
const creature = creatureId ? (getCreature(creatureId) ?? null) : null;
const isBrowse = panelRole === "browse";
return {
creatureId, creatureId,
creature, creature,
isSourceCached, isCollapsed: isBrowse ? sidePanel.isRightPanelCollapsed : false,
fetchAndCacheSource, onToggleCollapse: isBrowse ? sidePanel.toggleCollapse : () => {},
uploadAndCacheSource, onDismiss: isBrowse ? sidePanel.dismissPanel : () => {},
refreshCache, onPin: isBrowse ? sidePanel.togglePin : () => {},
onUnpin: panelRole === "pinned" ? sidePanel.unpin : () => {},
showPinButton: isBrowse && sidePanel.isWideDesktop && !!creature,
bulkImportMode: isBrowse && sidePanel.bulkImportMode,
sourceManagerMode: isBrowse && sidePanel.sourceManagerMode,
};
}
export function StatBlockPanel({
panelRole, panelRole,
side,
}: Readonly<StatBlockPanelProps>) {
const { isSourceCached } = useBestiaryContext();
const {
creatureId,
creature,
isCollapsed, isCollapsed,
onToggleCollapse, onToggleCollapse,
onDismiss,
onPin, onPin,
onUnpin, onUnpin,
showPinButton, showPinButton,
side,
onDismiss,
bulkImportMode, bulkImportMode,
bulkImportState,
onStartBulkImport,
onBulkImportDone,
sourceManagerMode, sourceManagerMode,
}: Readonly<StatBlockPanelProps>) { } = usePanelRole(panelRole);
const [isDesktop, setIsDesktop] = useState( const [isDesktop, setIsDesktop] = useState(
() => globalThis.matchMedia("(min-width: 1024px)").matches, () => globalThis.matchMedia("(min-width: 1024px)").matches,
); );
@@ -285,29 +287,17 @@ export function StatBlockPanel({
const sourceCode = creatureId ? extractSourceCode(creatureId) : ""; const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
const handleSourceLoaded = async () => { const handleSourceLoaded = () => {
await refreshCache();
setNeedsFetch(false); setNeedsFetch(false);
}; };
const renderContent = () => { const renderContent = () => {
if (sourceManagerMode) { if (sourceManagerMode) {
return <SourceManager onCacheCleared={refreshCache} />; return <SourceManager />;
} }
if ( if (bulkImportMode) {
bulkImportMode && return <BulkImportPrompt />;
bulkImportState &&
onStartBulkImport &&
onBulkImportDone
) {
return (
<BulkImportPrompt
importState={bulkImportState}
onStartImport={onStartBulkImport}
onDone={onBulkImportDone}
/>
);
} }
if (checkingCache) { if (checkingCache) {
@@ -324,10 +314,7 @@ export function StatBlockPanel({
return ( return (
<SourceFetchPrompt <SourceFetchPrompt
sourceCode={sourceCode} sourceCode={sourceCode}
sourceDisplayName={getSourceDisplayName(sourceCode)}
fetchAndCacheSource={fetchAndCacheSource}
onSourceLoaded={handleSourceLoaded} onSourceLoaded={handleSourceLoaded}
onUploadSource={uploadAndCacheSource}
/> />
); );
} }

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"
@@ -34,9 +25,11 @@ export function TurnNavigation({
</Button> </Button>
<div className="flex min-w-0 flex-1 items-center justify-center gap-2 text-sm"> <div className="flex min-w-0 flex-1 items-center justify-center gap-2 text-sm">
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 font-semibold text-foreground text-sm"> <span className="shrink-0 rounded-md bg-muted px-2 py-0.5 font-semibold text-foreground text-sm">
<span className="-mt-[3px] inline-block">
R{encounter.roundNumber} R{encounter.roundNumber}
</span> </span>
</span>
{activeCombatant ? ( {activeCombatant ? (
<span className="truncate font-medium">{activeCombatant.name}</span> <span className="truncate font-medium">{activeCombatant.name}</span>
) : ( ) : (
@@ -48,14 +41,14 @@ export function TurnNavigation({
<ConfirmButton <ConfirmButton
icon={<Trash2 className="h-5 w-5" />} icon={<Trash2 className="h-5 w-5" />}
label="Clear encounter" label="Clear encounter"
onConfirm={onClearEncounter} onConfirm={clearEncounter}
disabled={!hasCombatants} disabled={!hasCombatants}
className="text-muted-foreground" className="text-muted-foreground"
/> />
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={onAdvanceTurn} onClick={advanceTurn}
disabled={!hasCombatants} disabled={!hasCombatants}
title="Next turn" title="Next turn"
aria-label="Next turn" aria-label="Next turn"

View File

@@ -0,0 +1,55 @@
import { type ReactNode, useRef, useState } from "react";
import { createPortal } from "react-dom";
interface TooltipProps {
content: string;
children: ReactNode;
className?: string;
}
export function Tooltip({
content,
children,
className,
}: Readonly<TooltipProps>) {
const ref = useRef<HTMLSpanElement>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
function show() {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
setPos({
top: rect.top - 4,
left: rect.left + rect.width / 2,
});
}
function hide() {
setPos(null);
}
return (
<>
<span
ref={ref}
onPointerEnter={show}
onPointerLeave={hide}
className={className ?? "inline-flex"}
>
{children}
</span>
{pos !== null &&
createPortal(
<div
role="tooltip"
className="pointer-events-none fixed z-[60] max-w-64 -translate-x-1/2 -translate-y-full rounded-md border border-border bg-background px-2.5 py-1.5 text-foreground text-xs leading-snug shadow-lg"
style={{ top: pos.top, left: pos.left }}
>
{content}
</div>,
document.body,
)}
</>
);
}

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

@@ -10,6 +10,7 @@ import {
setAcUseCase, setAcUseCase,
setHpUseCase, setHpUseCase,
setInitiativeUseCase, setInitiativeUseCase,
setTempHpUseCase,
toggleConcentrationUseCase, toggleConcentrationUseCase,
toggleConditionUseCase, toggleConditionUseCase,
} from "@initiative/application"; } from "@initiative/application";
@@ -215,6 +216,19 @@ export function useEncounter() {
[makeStore], [makeStore],
); );
const setTempHp = useCallback(
(id: CombatantId, tempHp: number | undefined) => {
const result = setTempHpUseCase(makeStore(), id, tempHp);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
);
const setAc = useCallback( const setAc = useCallback(
(id: CombatantId, value: number | undefined) => { (id: CombatantId, value: number | undefined) => {
const result = setAcUseCase(makeStore(), id, value); const result = setAcUseCase(makeStore(), id, value);
@@ -376,6 +390,10 @@ export function useEncounter() {
[makeStore], [makeStore],
); );
const hasTempHp = encounter.combatants.some(
(c) => c.tempHp !== undefined && c.tempHp > 0,
);
const isEmpty = encounter.combatants.length === 0; const isEmpty = encounter.combatants.length === 0;
const hasCreatureCombatants = encounter.combatants.some( const hasCreatureCombatants = encounter.combatants.some(
(c) => c.creatureId != null, (c) => c.creatureId != null,
@@ -388,6 +406,7 @@ export function useEncounter() {
encounter, encounter,
events, events,
isEmpty, isEmpty,
hasTempHp,
hasCreatureCombatants, hasCreatureCombatants,
canRollAllInitiative, canRollAllInitiative,
advanceTurn, advanceTurn,
@@ -399,6 +418,7 @@ export function useEncounter() {
setInitiative, setInitiative,
setHp, setHp,
adjustHp, adjustHp,
setTempHp,
setAc, setAc,
toggleCondition, toggleCondition,
toggleConcentration, toggleConcentration,

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

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

View File

@@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json", "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
"files": { "files": {
"includes": [ "includes": [
"**", "**",

View File

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

View File

@@ -1,21 +1,21 @@
{ {
"private": true, "private": true,
"packageManager": "pnpm@10.6.0", "packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be",
"pnpm": { "pnpm": {
"overrides": { "overrides": {
"undici": ">=7.24.0" "undici": ">=7.24.0"
} }
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.4.7", "@biomejs/biome": "2.4.8",
"@vitest/coverage-v8": "^3.2.4", "@vitest/coverage-v8": "^4.1.0",
"jscpd": "^4.0.8", "jscpd": "^4.0.8",
"knip": "^5.85.0", "knip": "^5.88.1",
"lefthook": "^1.11.0", "lefthook": "^2.1.4",
"oxlint": "^1.55.0", "oxlint": "^1.56.0",
"oxlint-tsgolint": "^0.16.0", "oxlint-tsgolint": "^0.17.1",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"vitest": "^3.0.0" "vitest": "^4.1.0"
}, },
"scripts": { "scripts": {
"prepare": "lefthook install", "prepare": "lefthook install",
@@ -31,6 +31,7 @@
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware", "oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
"check:ignores": "node scripts/check-lint-ignores.mjs", "check:ignores": "node scripts/check-lint-ignores.mjs",
"check:classnames": "node scripts/check-cn-classnames.mjs", "check:classnames": "node scripts/check-cn-classnames.mjs",
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && tsc --build && vitest run && jscpd" "check:props": "node scripts/check-component-props.mjs",
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && node scripts/check-component-props.mjs && tsc --build && vitest run && jscpd"
} }
} }

View File

@@ -21,5 +21,6 @@ export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
export { setAcUseCase } from "./set-ac-use-case.js"; export { setAcUseCase } from "./set-ac-use-case.js";
export { setHpUseCase } from "./set-hp-use-case.js"; export { setHpUseCase } from "./set-hp-use-case.js";
export { setInitiativeUseCase } from "./set-initiative-use-case.js"; export { setInitiativeUseCase } from "./set-initiative-use-case.js";
export { setTempHpUseCase } from "./set-temp-hp-use-case.js";
export { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js"; export { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js";
export { toggleConditionUseCase } from "./toggle-condition-use-case.js"; export { toggleConditionUseCase } from "./toggle-condition-use-case.js";

View File

@@ -0,0 +1,24 @@
import {
type CombatantId,
type DomainError,
type DomainEvent,
isDomainError,
setTempHp,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
export function setTempHpUseCase(
store: EncounterStore,
combatantId: CombatantId,
tempHp: number | undefined,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = setTempHp(encounter, combatantId, tempHp);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
}

View File

@@ -6,12 +6,18 @@ import { expectDomainError } from "./test-helpers.js";
function makeCombatant( function makeCombatant(
name: string, name: string,
opts?: { maxHp: number; currentHp: number }, opts?: { maxHp: number; currentHp: number; tempHp?: number },
): Combatant { ): Combatant {
return { return {
id: combatantId(name), id: combatantId(name),
name, name,
...(opts ? { maxHp: opts.maxHp, currentHp: opts.currentHp } : {}), ...(opts
? {
maxHp: opts.maxHp,
currentHp: opts.currentHp,
tempHp: opts.tempHp,
}
: {}),
}; };
} }
@@ -152,4 +158,96 @@ describe("adjustHp", () => {
expect(encounter.combatants[0].currentHp).toBe(5); expect(encounter.combatants[0].currentHp).toBe(5);
}); });
}); });
describe("temporary HP absorption", () => {
it("damage fully absorbed by temp HP — currentHp unchanged", () => {
const e = enc([
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 8 }),
]);
const { encounter } = successResult(e, "A", -5);
expect(encounter.combatants[0].currentHp).toBe(15);
expect(encounter.combatants[0].tempHp).toBe(3);
});
it("damage partially absorbed by temp HP — overflow reduces currentHp", () => {
const e = enc([
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 3 }),
]);
const { encounter } = successResult(e, "A", -10);
expect(encounter.combatants[0].tempHp).toBeUndefined();
expect(encounter.combatants[0].currentHp).toBe(8);
});
it("damage exceeding both temp HP and currentHp — both reach minimum", () => {
const e = enc([
makeCombatant("A", { maxHp: 20, currentHp: 5, tempHp: 3 }),
]);
const { encounter } = successResult(e, "A", -50);
expect(encounter.combatants[0].tempHp).toBeUndefined();
expect(encounter.combatants[0].currentHp).toBe(0);
});
it("healing does not restore temp HP", () => {
const e = enc([
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 3 }),
]);
const { encounter } = successResult(e, "A", 5);
expect(encounter.combatants[0].currentHp).toBe(15);
expect(encounter.combatants[0].tempHp).toBe(3);
});
it("temp HP cleared to undefined when fully depleted", () => {
const e = enc([
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 5 }),
]);
const { encounter } = successResult(e, "A", -5);
expect(encounter.combatants[0].tempHp).toBeUndefined();
expect(encounter.combatants[0].currentHp).toBe(15);
});
it("emits only TempHpSet when damage fully absorbed", () => {
const e = enc([
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 8 }),
]);
const { events } = successResult(e, "A", -3);
expect(events).toEqual([
{
type: "TempHpSet",
combatantId: combatantId("A"),
previousTempHp: 8,
newTempHp: 5,
},
]);
});
it("emits both TempHpSet and CurrentHpAdjusted when damage overflows", () => {
const e = enc([
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 3 }),
]);
const { events } = successResult(e, "A", -10);
expect(events).toHaveLength(2);
expect(events[0]).toEqual({
type: "TempHpSet",
combatantId: combatantId("A"),
previousTempHp: 3,
newTempHp: undefined,
});
expect(events[1]).toEqual({
type: "CurrentHpAdjusted",
combatantId: combatantId("A"),
previousHp: 15,
newHp: 8,
delta: -10,
});
});
it("damage with no temp HP works as before", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
const { encounter, events } = successResult(e, "A", -5);
expect(encounter.combatants[0].currentHp).toBe(10);
expect(encounter.combatants[0].tempHp).toBeUndefined();
expect(events).toHaveLength(1);
expect(events[0].type).toBe("CurrentHpAdjusted");
});
});
}); });

View File

@@ -69,6 +69,34 @@ describe("setHp", () => {
expect(encounter.combatants[0].maxHp).toBeUndefined(); expect(encounter.combatants[0].maxHp).toBeUndefined();
expect(encounter.combatants[0].currentHp).toBeUndefined(); expect(encounter.combatants[0].currentHp).toBeUndefined();
}); });
it("clears tempHp when maxHp is cleared", () => {
const e = enc([
{
id: combatantId("A"),
name: "A",
maxHp: 20,
currentHp: 15,
tempHp: 5,
},
]);
const { encounter } = successResult(e, "A", undefined);
expect(encounter.combatants[0].tempHp).toBeUndefined();
});
it("preserves tempHp when maxHp is updated", () => {
const e = enc([
{
id: combatantId("A"),
name: "A",
maxHp: 20,
currentHp: 15,
tempHp: 5,
},
]);
const { encounter } = successResult(e, "A", 25);
expect(encounter.combatants[0].tempHp).toBe(5);
});
}); });
describe("invariants", () => { describe("invariants", () => {

View File

@@ -0,0 +1,182 @@
import { describe, expect, it } from "vitest";
import { setTempHp } from "../set-temp-hp.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
function makeCombatant(
name: string,
opts?: { maxHp: number; currentHp: number; tempHp?: number },
): Combatant {
return {
id: combatantId(name),
name,
...(opts
? {
maxHp: opts.maxHp,
currentHp: opts.currentHp,
tempHp: opts.tempHp,
}
: {}),
};
}
function enc(combatants: Combatant[]): Encounter {
return { combatants, activeIndex: 0, roundNumber: 1 };
}
function successResult(
encounter: Encounter,
id: string,
tempHp: number | undefined,
) {
const result = setTempHp(encounter, combatantId(id), tempHp);
if (isDomainError(result)) {
throw new Error(`Expected success, got error: ${result.message}`);
}
return result;
}
describe("setTempHp", () => {
describe("acceptance scenarios", () => {
it("sets temp HP on a combatant with HP tracking enabled", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
const { encounter } = successResult(e, "A", 8);
expect(encounter.combatants[0].tempHp).toBe(8);
});
it("keeps higher value when existing temp HP is greater", () => {
const e = enc([
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 5 }),
]);
const { encounter } = successResult(e, "A", 3);
expect(encounter.combatants[0].tempHp).toBe(5);
});
it("replaces when new value is higher", () => {
const e = enc([
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 3 }),
]);
const { encounter } = successResult(e, "A", 7);
expect(encounter.combatants[0].tempHp).toBe(7);
});
it("clears temp HP when set to undefined", () => {
const e = enc([
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 5 }),
]);
const { encounter } = successResult(e, "A", undefined);
expect(encounter.combatants[0].tempHp).toBeUndefined();
});
});
describe("invariants", () => {
it("is pure — same input produces same output", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
const r1 = setTempHp(e, combatantId("A"), 5);
const r2 = setTempHp(e, combatantId("A"), 5);
expect(r1).toEqual(r2);
});
it("does not mutate input encounter", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
const original = JSON.parse(JSON.stringify(e));
setTempHp(e, combatantId("A"), 5);
expect(e).toEqual(original);
});
it("emits TempHpSet event with correct shape", () => {
const e = enc([
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 3 }),
]);
const { events } = successResult(e, "A", 7);
expect(events).toEqual([
{
type: "TempHpSet",
combatantId: combatantId("A"),
previousTempHp: 3,
newTempHp: 7,
},
]);
});
it("preserves activeIndex and roundNumber", () => {
const e = {
combatants: [
makeCombatant("A", { maxHp: 20, currentHp: 10 }),
makeCombatant("B"),
],
activeIndex: 1,
roundNumber: 5,
};
const { encounter } = successResult(e, "A", 5);
expect(encounter.activeIndex).toBe(1);
expect(encounter.roundNumber).toBe(5);
});
});
describe("error cases", () => {
it("returns error for nonexistent combatant", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const result = setTempHp(e, combatantId("Z"), 5);
expectDomainError(result, "combatant-not-found");
});
it("returns error when HP tracking is not enabled", () => {
const e = enc([makeCombatant("A")]);
const result = setTempHp(e, combatantId("A"), 5);
expectDomainError(result, "no-hp-tracking");
});
it("rejects temp HP of 0", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const result = setTempHp(e, combatantId("A"), 0);
expectDomainError(result, "invalid-temp-hp");
});
it("rejects negative temp HP", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const result = setTempHp(e, combatantId("A"), -3);
expectDomainError(result, "invalid-temp-hp");
});
it("rejects non-integer temp HP", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const result = setTempHp(e, combatantId("A"), 2.5);
expectDomainError(result, "invalid-temp-hp");
});
});
describe("edge cases", () => {
it("does not affect other combatants", () => {
const e = enc([
makeCombatant("A", { maxHp: 20, currentHp: 15 }),
makeCombatant("B", { maxHp: 30, currentHp: 25, tempHp: 4 }),
]);
const { encounter } = successResult(e, "A", 5);
expect(encounter.combatants[1].tempHp).toBe(4);
});
it("does not affect currentHp or maxHp", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
const { encounter } = successResult(e, "A", 8);
expect(encounter.combatants[0].maxHp).toBe(20);
expect(encounter.combatants[0].currentHp).toBe(15);
});
it("event reflects no change when existing value equals new value", () => {
const e = enc([
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 5 }),
]);
const { events } = successResult(e, "A", 5);
expect(events).toEqual([
{
type: "TempHpSet",
combatantId: combatantId("A"),
previousTempHp: 5,
newTempHp: 5,
},
]);
});
});
});

View File

@@ -54,24 +54,52 @@ export function adjustHp(
} }
const previousHp = target.currentHp; const previousHp = target.currentHp;
const newHp = Math.max(0, Math.min(target.maxHp, previousHp + delta)); const previousTempHp = target.tempHp ?? 0;
let newTempHp = previousTempHp;
let effectiveDelta = delta;
return { if (delta < 0 && previousTempHp > 0) {
encounter: { const absorbed = Math.min(previousTempHp, Math.abs(delta));
combatants: encounter.combatants.map((c) => newTempHp = previousTempHp - absorbed;
c.id === combatantId ? { ...c, currentHp: newHp } : c, effectiveDelta = delta + absorbed;
), }
activeIndex: encounter.activeIndex,
roundNumber: encounter.roundNumber, const newHp = Math.max(
}, 0,
events: [ Math.min(target.maxHp, previousHp + effectiveDelta),
{ );
const events: DomainEvent[] = [];
if (newTempHp !== previousTempHp) {
events.push({
type: "TempHpSet",
combatantId,
previousTempHp: previousTempHp || undefined,
newTempHp: newTempHp || undefined,
});
}
if (newHp !== previousHp) {
events.push({
type: "CurrentHpAdjusted", type: "CurrentHpAdjusted",
combatantId, combatantId,
previousHp, previousHp,
newHp, newHp,
delta, delta,
});
}
return {
encounter: {
combatants: encounter.combatants.map((c) =>
c.id === combatantId
? { ...c, currentHp: newHp, tempHp: newTempHp || undefined }
: c,
),
activeIndex: encounter.activeIndex,
roundNumber: encounter.roundNumber,
}, },
], events,
}; };
} }

View File

@@ -18,63 +18,127 @@ export type ConditionId =
export interface ConditionDefinition { export interface ConditionDefinition {
readonly id: ConditionId; readonly id: ConditionId;
readonly label: string; readonly label: string;
readonly description: string;
readonly iconName: string; readonly iconName: string;
readonly color: string; readonly color: string;
} }
export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
{ id: "blinded", label: "Blinded", iconName: "EyeOff", color: "neutral" }, {
{ id: "charmed", label: "Charmed", iconName: "Heart", color: "pink" }, id: "blinded",
{ id: "deafened", label: "Deafened", iconName: "EarOff", color: "neutral" }, label: "Blinded",
description:
"Can't see. Auto-fail sight checks. Attacks have Disadvantage; attacks against have Advantage.",
iconName: "EyeOff",
color: "neutral",
},
{
id: "charmed",
label: "Charmed",
description:
"Can't attack or target the charmer with harmful abilities. Charmer has Advantage on social checks.",
iconName: "Heart",
color: "pink",
},
{
id: "deafened",
label: "Deafened",
description: "Can't hear. Auto-fail hearing checks.",
iconName: "EarOff",
color: "neutral",
},
{ {
id: "exhaustion", id: "exhaustion",
label: "Exhaustion", label: "Exhaustion",
description:
"Subtract exhaustion level from D20 Tests and Spell save DCs. Speed reduced by 5 ft. \u00D7 level. Removed by long rest (1 level) or death at 10 levels.",
iconName: "BatteryLow", iconName: "BatteryLow",
color: "amber", color: "amber",
}, },
{ {
id: "frightened", id: "frightened",
label: "Frightened", label: "Frightened",
description:
"Disadvantage on ability checks and attacks while source of fear is in line of sight. Can't willingly move closer to the source.",
iconName: "Siren", iconName: "Siren",
color: "orange", color: "orange",
}, },
{ id: "grappled", label: "Grappled", iconName: "Hand", color: "neutral" }, {
id: "grappled",
label: "Grappled",
description:
"Speed is 0 and can't benefit from bonuses to speed. Ends if grappler is Incapacitated or moved out of reach.",
iconName: "Hand",
color: "neutral",
},
{ {
id: "incapacitated", id: "incapacitated",
label: "Incapacitated", label: "Incapacitated",
description:
"Can't take Actions, Bonus Actions, or Reactions. Concentration is broken.",
iconName: "Ban", iconName: "Ban",
color: "gray", color: "gray",
}, },
{ {
id: "invisible", id: "invisible",
label: "Invisible", label: "Invisible",
description:
"Impossible to see without magic or special sense. Heavily Obscured. Attacks have Advantage; attacks against have Disadvantage.",
iconName: "Ghost", iconName: "Ghost",
color: "violet", color: "violet",
}, },
{ {
id: "paralyzed", id: "paralyzed",
label: "Paralyzed", label: "Paralyzed",
description:
"Incapacitated. Can't move or speak. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
iconName: "ZapOff", iconName: "ZapOff",
color: "yellow", color: "yellow",
}, },
{ {
id: "petrified", id: "petrified",
label: "Petrified", label: "Petrified",
description:
"Turned to stone. Weight \u00D710. Incapacitated. Can't move or speak. Attacks against have Advantage. Auto-fail Str/Dex saves. Resistant to all damage. Immune to poison and disease.",
iconName: "Gem", iconName: "Gem",
color: "slate", color: "slate",
}, },
{ id: "poisoned", label: "Poisoned", iconName: "Droplet", color: "green" }, {
{ id: "prone", label: "Prone", iconName: "ArrowDown", color: "neutral" }, id: "poisoned",
label: "Poisoned",
description: "Disadvantage on attack rolls and ability checks.",
iconName: "Droplet",
color: "green",
},
{
id: "prone",
label: "Prone",
description:
"Can only crawl (costs extra movement). Disadvantage on attacks. Attacks within 5 ft. have Advantage; ranged attacks have Disadvantage. Standing up costs half movement.",
iconName: "ArrowDown",
color: "neutral",
},
{ {
id: "restrained", id: "restrained",
label: "Restrained", label: "Restrained",
description:
"Speed is 0. Attacks have Disadvantage. Attacks against have Advantage. Disadvantage on Dex saves.",
iconName: "Link", iconName: "Link",
color: "neutral", color: "neutral",
}, },
{ id: "stunned", label: "Stunned", iconName: "Sparkles", color: "yellow" }, {
id: "stunned",
label: "Stunned",
description:
"Incapacitated. Can't move. Can speak only falteringly. Auto-fail Str/Dex saves. Attacks against have Advantage.",
iconName: "Sparkles",
color: "yellow",
},
{ {
id: "unconscious", id: "unconscious",
label: "Unconscious", label: "Unconscious",
description:
"Incapacitated. Can't move or speak. Unaware of surroundings. Drops held items, falls Prone. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
iconName: "Moon", iconName: "Moon",
color: "indigo", color: "indigo",
}, },

View File

@@ -58,6 +58,13 @@ export interface CurrentHpAdjusted {
readonly delta: number; readonly delta: number;
} }
export interface TempHpSet {
readonly type: "TempHpSet";
readonly combatantId: CombatantId;
readonly previousTempHp: number | undefined;
readonly newTempHp: number | undefined;
}
export interface TurnRetreated { export interface TurnRetreated {
readonly type: "TurnRetreated"; readonly type: "TurnRetreated";
readonly previousCombatantId: CombatantId; readonly previousCombatantId: CombatantId;
@@ -132,6 +139,7 @@ export type DomainEvent =
| InitiativeSet | InitiativeSet
| MaxHpSet | MaxHpSet
| CurrentHpAdjusted | CurrentHpAdjusted
| TempHpSet
| TurnRetreated | TurnRetreated
| RoundRetreated | RoundRetreated
| AcSet | AcSet

View File

@@ -60,6 +60,7 @@ export type {
PlayerCharacterUpdated, PlayerCharacterUpdated,
RoundAdvanced, RoundAdvanced,
RoundRetreated, RoundRetreated,
TempHpSet,
TurnAdvanced, TurnAdvanced,
TurnRetreated, TurnRetreated,
} from "./events.js"; } from "./events.js";
@@ -95,6 +96,7 @@ export {
type SetInitiativeSuccess, type SetInitiativeSuccess,
setInitiative, setInitiative,
} from "./set-initiative.js"; } from "./set-initiative.js";
export { type SetTempHpSuccess, setTempHp } from "./set-temp-hp.js";
export { export {
type ToggleConcentrationSuccess, type ToggleConcentrationSuccess,
toggleConcentration, toggleConcentration,

View File

@@ -66,7 +66,12 @@ export function setHp(
encounter: { encounter: {
combatants: encounter.combatants.map((c) => combatants: encounter.combatants.map((c) =>
c.id === combatantId c.id === combatantId
? { ...c, maxHp: newMaxHp, currentHp: newCurrentHp } ? {
...c,
maxHp: newMaxHp,
currentHp: newCurrentHp,
tempHp: newMaxHp === undefined ? undefined : c.tempHp,
}
: c, : c,
), ),
activeIndex: encounter.activeIndex, activeIndex: encounter.activeIndex,

View File

@@ -0,0 +1,78 @@
import type { DomainEvent } from "./events.js";
import type { CombatantId, DomainError, Encounter } from "./types.js";
export interface SetTempHpSuccess {
readonly encounter: Encounter;
readonly events: DomainEvent[];
}
/**
* Pure function that sets or clears a combatant's temporary HP.
*
* - Setting tempHp when the combatant already has tempHp keeps the higher value.
* - Clearing tempHp (undefined) removes temp HP entirely.
* - Requires HP tracking to be enabled (maxHp must be set).
*/
export function setTempHp(
encounter: Encounter,
combatantId: CombatantId,
tempHp: number | undefined,
): SetTempHpSuccess | DomainError {
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
if (targetIdx === -1) {
return {
kind: "domain-error",
code: "combatant-not-found",
message: `No combatant found with ID "${combatantId}"`,
};
}
const target = encounter.combatants[targetIdx];
if (target.maxHp === undefined || target.currentHp === undefined) {
return {
kind: "domain-error",
code: "no-hp-tracking",
message: `Combatant "${combatantId}" does not have HP tracking enabled`,
};
}
if (tempHp !== undefined && (!Number.isInteger(tempHp) || tempHp < 1)) {
return {
kind: "domain-error",
code: "invalid-temp-hp",
message: `Temp HP must be a positive integer, got ${tempHp}`,
};
}
const previousTempHp = target.tempHp;
// Higher value wins when both are defined
let newTempHp: number | undefined;
if (tempHp === undefined) {
newTempHp = undefined;
} else if (previousTempHp === undefined) {
newTempHp = tempHp;
} else {
newTempHp = Math.max(previousTempHp, tempHp);
}
return {
encounter: {
combatants: encounter.combatants.map((c) =>
c.id === combatantId ? { ...c, tempHp: newTempHp } : c,
),
activeIndex: encounter.activeIndex,
roundNumber: encounter.roundNumber,
},
events: [
{
type: "TempHpSet",
combatantId,
previousTempHp,
newTempHp,
},
],
};
}

View File

@@ -15,6 +15,7 @@ export interface Combatant {
readonly initiative?: number; readonly initiative?: number;
readonly maxHp?: number; readonly maxHp?: number;
readonly currentHp?: number; readonly currentHp?: number;
readonly tempHp?: number;
readonly ac?: number; readonly ac?: number;
readonly conditions?: readonly ConditionId[]; readonly conditions?: readonly ConditionId[];
readonly isConcentrating?: boolean; readonly isConcentrating?: boolean;

2255
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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

View File

@@ -21,6 +21,7 @@ interface Combatant {
readonly initiative?: number; // integer, undefined = unset readonly initiative?: number; // integer, undefined = unset
readonly maxHp?: number; // positive integer readonly maxHp?: number; // positive integer
readonly currentHp?: number; // 0..maxHp readonly currentHp?: number; // 0..maxHp
readonly tempHp?: number; // positive integer, damage buffer
readonly ac?: number; // non-negative integer readonly ac?: number; // non-negative integer
readonly conditions?: readonly ConditionId[]; readonly conditions?: readonly ConditionId[];
readonly isConcentrating?: boolean; readonly isConcentrating?: boolean;
@@ -96,6 +97,19 @@ As a game master, I want HP values to survive page reloads so that I do not lose
Acceptance scenarios: Acceptance scenarios:
1. **Given** a combatant has max HP 30 and current HP 18, **When** the page is reloaded, **Then** both values are restored exactly. 1. **Given** a combatant has max HP 30 and current HP 18, **When** the page is reloaded, **Then** both values are restored exactly.
**Story HP-7 — Temporary Hit Points (P1)**
As a game master, I want to grant temporary HP to a combatant so that I can track damage buffers from spells like Heroism or False Life without manual bookkeeping.
Acceptance scenarios:
1. **Given** a combatant has 15/20 HP and no temp HP, **When** the user sets 8 temp HP via the popover, **Then** the combatant displays `15+8 / 20`.
2. **Given** a combatant has 15+8/20 HP, **When** 5 damage is dealt, **Then** temp HP decreases to 3 and current HP remains 15 → display `15+3 / 20`.
3. **Given** a combatant has 15+3/20 HP, **When** 10 damage is dealt, **Then** temp HP is fully consumed (3 absorbed) and current HP decreases by the remaining 7 → display `8 / 20`.
4. **Given** a combatant has 15+5/20 HP, **When** 8 healing is applied, **Then** current HP increases to 20 and temp HP remains 5 → display `20+5 / 20`.
5. **Given** a combatant has 10+5/20 HP, **When** the user sets 3 temp HP, **Then** temp HP remains 5 (higher value kept).
6. **Given** a combatant has 10+3/20 HP, **When** the user sets 7 temp HP, **Then** temp HP becomes 7.
7. **Given** no combatant has temp HP, **When** viewing the encounter, **Then** no extra space is reserved for temp HP display.
8. **Given** one combatant has temp HP, **When** viewing the encounter, **Then** all rows reserve space for the temp HP display to maintain column alignment.
### Requirements ### Requirements
- **FR-001**: Each combatant MAY have an optional `maxHp` value (positive integer >= 1). HP tracking is optional per combatant. - **FR-001**: Each combatant MAY have an optional `maxHp` value (positive integer >= 1). HP tracking is optional per combatant.
@@ -120,6 +134,15 @@ Acceptance scenarios:
- **FR-020**: The HP area MUST display the unconscious color treatment (red) and the combatant row MUST appear visually muted when status is `unconscious`. - **FR-020**: The HP area MUST display the unconscious color treatment (red) and the combatant row MUST appear visually muted when status is `unconscious`.
- **FR-021**: Status indicators MUST NOT be shown when `maxHp` is not set. - **FR-021**: Status indicators MUST NOT be shown when `maxHp` is not set.
- **FR-022**: Visual status indicators MUST update within the same interaction frame as the HP change — no perceptible delay. - **FR-022**: Visual status indicators MUST update within the same interaction frame as the HP change — no perceptible delay.
- **FR-023**: Each combatant MAY have an optional `tempHp` value (positive integer >= 1). Temp HP is independent of regular HP tracking but requires HP tracking to be enabled.
- **FR-024**: When damage is applied, temp HP MUST absorb damage first. Any remaining damage after temp HP is depleted MUST reduce `currentHp`.
- **FR-025**: Healing MUST NOT restore temp HP. Healing applies only to `currentHp`.
- **FR-026**: When setting temp HP on a combatant that already has temp HP, the system MUST keep the higher of the two values.
- **FR-027**: When `maxHp` is cleared (HP tracking disabled), `tempHp` MUST also be cleared.
- **FR-028**: The temp HP value MUST be displayed as a cyan `+N` immediately after the current HP value, only when temp HP > 0.
- **FR-029**: When any combatant in the encounter has temp HP > 0, all rows MUST reserve space for the temp HP display to maintain column alignment. When no combatant has temp HP, no space is reserved.
- **FR-030**: The HP adjustment popover MUST include a third button (Shield icon) for setting temp HP.
- **FR-031**: Temp HP MUST persist across page reloads via the existing persistence mechanism.
### Edge Cases ### Edge Cases
@@ -131,7 +154,10 @@ Acceptance scenarios:
- Submitting an empty delta input applies no change; the input remains ready. - Submitting an empty delta input applies no change; the input remains ready.
- When the user rapidly applies multiple deltas, each is applied sequentially; none are lost. - When the user rapidly applies multiple deltas, each is applied sequentially; none are lost.
- HP tracking is entirely absent for combatants with no `maxHp` set — no HP controls are shown. - HP tracking is entirely absent for combatants with no `maxHp` set — no HP controls are shown.
- There is no temporary HP in the MVP baseline. - Setting temp HP to 0 or clearing it removes temp HP entirely.
- Temp HP does not affect `HpStatus` derivation — a combatant with 5 current HP, 5 temp HP, and 20 max HP is still bloodied.
- When a concentrating combatant takes damage, the concentration pulse MUST trigger regardless of whether temp HP absorbs the damage — "taking damage" is the trigger, not losing real HP.
- A combatant at 0 currentHp with temp HP remaining is still unconscious.
- There is no death/unconscious game mechanic triggered at 0 HP; the system displays the state only. - There is no death/unconscious game mechanic triggered at 0 HP; the system displays the state only.
- There is no damage type tracking, resistance/vulnerability calculation, or hit log in the MVP baseline. - There is no damage type tracking, resistance/vulnerability calculation, or hit log in the MVP baseline.
- There is no undo/redo for HP changes in the MVP baseline. - There is no undo/redo for HP changes in the MVP baseline.