Compare commits

..

2 Commits

Author SHA1 Message Date
Lukas 1de00e3d8e Move entity rehydration to domain layer, fix tempHp gap
CI / check (push) Successful in 1m16s
CI / build-image (push) Has been skipped
Rehydration functions (reconstructing typed domain objects from untyped
JSON) lived in persistence adapters, duplicating domain validation.
Adding a field required updating both the domain type and a separate
adapter function — the adapter was missed for `level`, silently dropping
it on reload. Now adding a field only requires updating the domain type
and its co-located rehydration function.

- Add `rehydratePlayerCharacter` and `rehydrateCombatant` to domain
- Persistence adapters delegate to domain instead of reimplementing
- Add `tempHp` validation (was silently dropped during rehydration)
- Tighten initiative validation to integer-only
- Exhaustive domain tests (53 cases); adapter tests slimmed to round-trip
- Remove stale `jsinspect-plus` Knip ignoreDependencies entry

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:12:41 +01:00
Lukas f4fb69dbc7 Add jsinspect-plus structural duplication gate, extract shared helpers
CI / check (push) Successful in 1m13s
CI / build-image (push) Has been skipped
Add jsinspect-plus (AST-based structural duplication detector) to pnpm
check with threshold 50 / min 3 instances. Fix all findings:

- Extract condition icon/color maps to shared condition-styles.ts
- Extract useClickOutside hook (5 components)
- Extract dispatchAction + resolveAndRename in use-encounter
- Extract runEncounterAction in application layer (13 use cases)
- Extract findCombatant helper in domain (9 functions)
- Extract TraitSection in stat-block (4 trait rendering blocks)
- Extract DialogHeader in dialog.tsx (4 dialogs)

Net result: -263 lines across 40 files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 02:16:54 +01:00
53 changed files with 1348 additions and 1121 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"threshold": 50,
"minInstances": 3,
"identifiers": false,
"literals": false,
"ignore": "dist|__tests__|node_modules",
"reporter": "default",
"truncate": 100
}
+10 -66
View File
@@ -3,66 +3,17 @@ import {
getConditionDescription, getConditionDescription,
getConditionsForEdition, getConditionsForEdition,
} from "@initiative/domain"; } from "@initiative/domain";
import type { LucideIcon } from "lucide-react"; import { useLayoutEffect, useRef, useState } from "react";
import {
ArrowDown,
Ban,
BatteryLow,
Droplet,
EarOff,
EyeOff,
Gem,
Ghost,
Hand,
Heart,
Link,
Moon,
ShieldMinus,
Siren,
Snail,
Sparkles,
ZapOff,
} from "lucide-react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useRulesEditionContext } from "../contexts/rules-edition-context.js"; import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
import { useClickOutside } from "../hooks/use-click-outside.js";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import {
CONDITION_COLOR_CLASSES,
CONDITION_ICON_MAP,
} from "./condition-styles.js";
import { Tooltip } from "./ui/tooltip.js"; import { Tooltip } from "./ui/tooltip.js";
const ICON_MAP: Record<string, LucideIcon> = {
EyeOff,
Heart,
EarOff,
BatteryLow,
Siren,
Hand,
Ban,
Ghost,
ZapOff,
Gem,
Droplet,
ArrowDown,
Link,
ShieldMinus,
Snail,
Sparkles,
Moon,
};
const COLOR_CLASSES: Record<string, string> = {
neutral: "text-muted-foreground",
pink: "text-pink-400",
amber: "text-amber-400",
orange: "text-orange-400",
gray: "text-gray-400",
violet: "text-violet-400",
yellow: "text-yellow-400",
slate: "text-slate-400",
green: "text-green-400",
indigo: "text-indigo-400",
sky: "text-sky-400",
};
interface ConditionPickerProps { interface ConditionPickerProps {
anchorRef: React.RefObject<HTMLElement | null>; anchorRef: React.RefObject<HTMLElement | null>;
activeConditions: readonly ConditionId[] | undefined; activeConditions: readonly ConditionId[] | undefined;
@@ -104,15 +55,7 @@ export function ConditionPicker({
setPos({ top, left: anchorRect.left, maxHeight }); setPos({ top, left: anchorRect.left, maxHeight });
}, [anchorRef]); }, [anchorRef]);
useEffect(() => { useClickOutside(ref, onClose);
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
const { edition } = useRulesEditionContext(); const { edition } = useRulesEditionContext();
const conditions = getConditionsForEdition(edition); const conditions = getConditionsForEdition(edition);
@@ -129,10 +72,11 @@ export function ConditionPicker({
} }
> >
{conditions.map((def) => { {conditions.map((def) => {
const Icon = ICON_MAP[def.iconName]; const Icon = CONDITION_ICON_MAP[def.iconName];
if (!Icon) return null; if (!Icon) return null;
const isActive = active.has(def.id); const isActive = active.has(def.id);
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground"; const colorClass =
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
return ( return (
<Tooltip <Tooltip
key={def.id} key={def.id}
@@ -0,0 +1,54 @@
import type { LucideIcon } from "lucide-react";
import {
ArrowDown,
Ban,
BatteryLow,
Droplet,
EarOff,
EyeOff,
Gem,
Ghost,
Hand,
Heart,
Link,
Moon,
ShieldMinus,
Siren,
Snail,
Sparkles,
ZapOff,
} from "lucide-react";
export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
EyeOff,
Heart,
EarOff,
BatteryLow,
Siren,
Hand,
Ban,
Ghost,
ZapOff,
Gem,
Droplet,
ArrowDown,
Link,
ShieldMinus,
Snail,
Sparkles,
Moon,
};
export const CONDITION_COLOR_CLASSES: Record<string, string> = {
neutral: "text-muted-foreground",
pink: "text-pink-400",
amber: "text-amber-400",
orange: "text-orange-400",
gray: "text-gray-400",
violet: "text-violet-400",
yellow: "text-yellow-400",
slate: "text-slate-400",
green: "text-green-400",
indigo: "text-indigo-400",
sky: "text-sky-400",
};
+8 -57
View File
@@ -3,65 +3,15 @@ import {
type ConditionId, type ConditionId,
getConditionDescription, getConditionDescription,
} from "@initiative/domain"; } from "@initiative/domain";
import type { LucideIcon } from "lucide-react"; import { Plus } from "lucide-react";
import {
ArrowDown,
Ban,
BatteryLow,
Droplet,
EarOff,
EyeOff,
Gem,
Ghost,
Hand,
Heart,
Link,
Moon,
Plus,
ShieldMinus,
Siren,
Snail,
Sparkles,
ZapOff,
} from "lucide-react";
import { useRulesEditionContext } from "../contexts/rules-edition-context.js"; import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
import { cn } from "../lib/utils.js"; import { cn } from "../lib/utils.js";
import {
CONDITION_COLOR_CLASSES,
CONDITION_ICON_MAP,
} from "./condition-styles.js";
import { Tooltip } from "./ui/tooltip.js"; import { Tooltip } from "./ui/tooltip.js";
const ICON_MAP: Record<string, LucideIcon> = {
EyeOff,
Heart,
EarOff,
BatteryLow,
Siren,
Hand,
Ban,
Ghost,
ZapOff,
Gem,
Droplet,
ArrowDown,
Link,
ShieldMinus,
Snail,
Sparkles,
Moon,
};
const COLOR_CLASSES: Record<string, string> = {
neutral: "text-muted-foreground",
pink: "text-pink-400",
amber: "text-amber-400",
orange: "text-orange-400",
gray: "text-gray-400",
violet: "text-violet-400",
yellow: "text-yellow-400",
slate: "text-slate-400",
green: "text-green-400",
indigo: "text-indigo-400",
sky: "text-sky-400",
};
interface ConditionTagsProps { interface ConditionTagsProps {
conditions: readonly ConditionId[] | undefined; conditions: readonly ConditionId[] | undefined;
onRemove: (conditionId: ConditionId) => void; onRemove: (conditionId: ConditionId) => void;
@@ -79,9 +29,10 @@ export function ConditionTags({
{conditions?.map((condId) => { {conditions?.map((condId) => {
const def = CONDITION_DEFINITIONS.find((d) => d.id === condId); const def = CONDITION_DEFINITIONS.find((d) => d.id === condId);
if (!def) return null; if (!def) return null;
const Icon = ICON_MAP[def.iconName]; const Icon = CONDITION_ICON_MAP[def.iconName];
if (!Icon) return null; if (!Icon) return null;
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground"; const colorClass =
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
return ( return (
<Tooltip <Tooltip
key={condId} key={condId}
@@ -1,7 +1,6 @@
import { Check, ClipboardCopy, Download, X } from "lucide-react"; import { Check, ClipboardCopy, Download } from "lucide-react";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { Button } from "./ui/button.js"; import { Dialog, DialogHeader } from "./ui/dialog.js";
import { Dialog } from "./ui/dialog.js";
import { Input } from "./ui/input.js"; import { Input } from "./ui/input.js";
interface ExportMethodDialogProps { interface ExportMethodDialogProps {
@@ -30,18 +29,7 @@ export function ExportMethodDialog({
return ( return (
<Dialog open={open} onClose={handleClose} className="w-80"> <Dialog open={open} onClose={handleClose} className="w-80">
<div className="mb-4 flex items-center justify-between"> <DialogHeader title="Export Encounter" onClose={handleClose} />
<h2 className="font-semibold text-lg">Export Encounter</h2>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={handleClose}
className="text-muted-foreground"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="mb-3"> <div className="mb-3">
<Input <Input
type="text" type="text"
@@ -6,6 +6,7 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { useClickOutside } from "../hooks/use-click-outside.js";
import { Input } from "./ui/input"; import { Input } from "./ui/input";
const DIGITS_ONLY_REGEX = /^\d+$/; const DIGITS_ONLY_REGEX = /^\d+$/;
@@ -48,15 +49,7 @@ export function HpAdjustPopover({
requestAnimationFrame(() => inputRef.current?.focus()); requestAnimationFrame(() => inputRef.current?.focus());
}, []); }, []);
useEffect(() => { useClickOutside(ref, onClose);
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
const parsedValue = const parsedValue =
inputValue === "" ? null : Number.parseInt(inputValue, 10); inputValue === "" ? null : Number.parseInt(inputValue, 10);
@@ -1,7 +1,7 @@
import { ClipboardPaste, FileUp, X } from "lucide-react"; import { ClipboardPaste, FileUp } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "./ui/button.js"; import { Button } from "./ui/button.js";
import { Dialog } from "./ui/dialog.js"; import { Dialog, DialogHeader } from "./ui/dialog.js";
interface ImportMethodDialogProps { interface ImportMethodDialogProps {
open: boolean; open: boolean;
@@ -41,18 +41,7 @@ export function ImportMethodDialog({
return ( return (
<Dialog open={open} onClose={handleClose} className="w-80"> <Dialog open={open} onClose={handleClose} className="w-80">
<div className="mb-4 flex items-center justify-between"> <DialogHeader title="Import Encounter" onClose={handleClose} />
<h2 className="font-semibold text-lg">Import Encounter</h2>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={handleClose}
className="text-muted-foreground"
>
<X className="h-4 w-4" />
</Button>
</div>
{mode === "pick" && ( {mode === "pick" && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<button <button
+3 -15
View File
@@ -1,9 +1,9 @@
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain"; import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
import { Pencil, Plus, Trash2, X } from "lucide-react"; import { Pencil, Plus, Trash2 } from "lucide-react";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map"; import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { ConfirmButton } from "./ui/confirm-button"; import { ConfirmButton } from "./ui/confirm-button";
import { Dialog } from "./ui/dialog"; import { Dialog, DialogHeader } from "./ui/dialog";
interface PlayerManagementProps { interface PlayerManagementProps {
open: boolean; open: boolean;
@@ -24,19 +24,7 @@ export function PlayerManagement({
}: Readonly<PlayerManagementProps>) { }: Readonly<PlayerManagementProps>) {
return ( return (
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md"> <Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
<div className="mb-4 flex items-center justify-between"> <DialogHeader title="Player Characters" onClose={onClose} />
<h2 className="font-semibold text-foreground text-lg">
Player Characters
</h2>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="text-muted-foreground"
>
<X size={20} />
</Button>
</div>
{characters.length === 0 ? ( {characters.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-8 text-center"> <div className="flex flex-col items-center gap-3 py-8 text-center">
+3 -17
View File
@@ -1,6 +1,7 @@
import type { RollMode } from "@initiative/domain"; import type { RollMode } from "@initiative/domain";
import { ChevronsDown, ChevronsUp } from "lucide-react"; import { ChevronsDown, ChevronsUp } from "lucide-react";
import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useLayoutEffect, useRef, useState } from "react";
import { useClickOutside } from "../hooks/use-click-outside.js";
interface RollModeMenuProps { interface RollModeMenuProps {
readonly position: { x: number; y: number }; readonly position: { x: number; y: number };
@@ -34,22 +35,7 @@ export function RollModeMenu({
setPos({ top, left }); setPos({ top, left });
}, [position.x, position.y]); }, [position.x, position.y]);
useEffect(() => { useClickOutside(ref, onClose);
function handleMouseDown(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [onClose]);
return ( return (
<div <div
+3 -14
View File
@@ -1,10 +1,9 @@
import type { RulesEdition } from "@initiative/domain"; import type { RulesEdition } from "@initiative/domain";
import { Monitor, Moon, Sun, X } from "lucide-react"; import { Monitor, Moon, Sun } from "lucide-react";
import { useRulesEditionContext } from "../contexts/rules-edition-context.js"; import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
import { useThemeContext } from "../contexts/theme-context.js"; import { useThemeContext } from "../contexts/theme-context.js";
import { cn } from "../lib/utils.js"; import { cn } from "../lib/utils.js";
import { Button } from "./ui/button.js"; import { Dialog, DialogHeader } from "./ui/dialog.js";
import { Dialog } from "./ui/dialog.js";
interface SettingsModalProps { interface SettingsModalProps {
open: boolean; open: boolean;
@@ -32,17 +31,7 @@ export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
return ( return (
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-sm"> <Dialog open={open} onClose={onClose} className="card-glow w-full max-w-sm">
<div className="mb-4 flex items-center justify-between"> <DialogHeader title="Settings" onClose={onClose} />
<h2 className="font-semibold text-foreground text-lg">Settings</h2>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="text-muted-foreground"
>
<X size={20} />
</Button>
</div>
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<div> <div>
+29 -59
View File
@@ -34,6 +34,31 @@ function SectionDivider() {
); );
} }
function TraitSection({
entries,
heading,
}: Readonly<{
entries: readonly { name: string; text: string }[] | undefined;
heading?: string;
}>) {
if (!entries || entries.length === 0) return null;
return (
<>
<SectionDivider />
{heading ? (
<h3 className="font-bold text-base text-stat-heading">{heading}</h3>
) : null}
<div className="space-y-2">
{entries.map((e) => (
<div key={e.name} className="text-sm">
<span className="font-semibold italic">{e.name}.</span> {e.text}
</div>
))}
</div>
</>
);
}
export function StatBlock({ creature }: Readonly<StatBlockProps>) { export function StatBlock({ creature }: Readonly<StatBlockProps>) {
const abilities = [ const abilities = [
{ label: "STR", score: creature.abilities.str }, { label: "STR", score: creature.abilities.str },
@@ -134,19 +159,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
</div> </div>
</div> </div>
{/* Traits */} <TraitSection entries={creature.traits} />
{creature.traits && creature.traits.length > 0 && (
<>
<SectionDivider />
<div className="space-y-2">
{creature.traits.map((t) => (
<div key={t.name} className="text-sm">
<span className="font-semibold italic">{t.name}.</span> {t.text}
</div>
))}
</div>
</>
)}
{/* Spellcasting */} {/* Spellcasting */}
{creature.spellcasting && creature.spellcasting.length > 0 && ( {creature.spellcasting && creature.spellcasting.length > 0 && (
@@ -190,52 +203,9 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
</> </>
)} )}
{/* Actions */} <TraitSection entries={creature.actions} heading="Actions" />
{creature.actions && creature.actions.length > 0 && ( <TraitSection entries={creature.bonusActions} heading="Bonus Actions" />
<> <TraitSection entries={creature.reactions} heading="Reactions" />
<SectionDivider />
<h3 className="font-bold text-base text-stat-heading">Actions</h3>
<div className="space-y-2">
{creature.actions.map((a) => (
<div key={a.name} className="text-sm">
<span className="font-semibold italic">{a.name}.</span> {a.text}
</div>
))}
</div>
</>
)}
{/* Bonus Actions */}
{creature.bonusActions && creature.bonusActions.length > 0 && (
<>
<SectionDivider />
<h3 className="font-bold text-base text-stat-heading">
Bonus Actions
</h3>
<div className="space-y-2">
{creature.bonusActions.map((a) => (
<div key={a.name} className="text-sm">
<span className="font-semibold italic">{a.name}.</span> {a.text}
</div>
))}
</div>
</>
)}
{/* Reactions */}
{creature.reactions && creature.reactions.length > 0 && (
<>
<SectionDivider />
<h3 className="font-bold text-base text-stat-heading">Reactions</h3>
<div className="space-y-2">
{creature.reactions.map((a) => (
<div key={a.name} className="text-sm">
<span className="font-semibold italic">{a.name}.</span> {a.text}
</div>
))}
</div>
</>
)}
{/* Legendary Actions */} {/* Legendary Actions */}
{!!creature.legendaryActions && ( {!!creature.legendaryActions && (
+2 -26
View File
@@ -6,6 +6,7 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { useClickOutside } from "../../hooks/use-click-outside.js";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { Button } from "./button"; import { Button } from "./button";
@@ -42,32 +43,7 @@ export function ConfirmButton({
return () => clearTimeout(timerRef.current); return () => clearTimeout(timerRef.current);
}, []); }, []);
// Click-outside listener when confirming useClickOutside(wrapperRef, revert, isConfirming);
useEffect(() => {
if (!isConfirming) return;
function handleMouseDown(e: MouseEvent) {
if (
wrapperRef.current &&
!wrapperRef.current.contains(e.target as Node)
) {
revert();
}
}
function handleEscapeKey(e: KeyboardEvent) {
if (e.key === "Escape") {
revert();
}
}
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleEscapeKey);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleEscapeKey);
};
}, [isConfirming, revert]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") { if (e.key === "Enter" || e.key === " ") {
+21
View File
@@ -1,5 +1,7 @@
import { X } from "lucide-react";
import { type ReactNode, useEffect, useRef } from "react"; import { type ReactNode, useEffect, useRef } from "react";
import { cn } from "../../lib/utils.js"; import { cn } from "../../lib/utils.js";
import { Button } from "./button.js";
interface DialogProps { interface DialogProps {
open: boolean; open: boolean;
@@ -48,3 +50,22 @@ export function Dialog({ open, onClose, className, children }: DialogProps) {
</dialog> </dialog>
); );
} }
export function DialogHeader({
title,
onClose,
}: Readonly<{ title: string; onClose: () => void }>) {
return (
<div className="mb-4 flex items-center justify-between">
<h2 className="font-semibold text-foreground text-lg">{title}</h2>
<Button
variant="ghost"
size="icon-sm"
onClick={onClose}
className="text-muted-foreground"
>
<X className="h-4 w-4" />
</Button>
</div>
);
}
+3 -18
View File
@@ -1,5 +1,6 @@
import { EllipsisVertical } from "lucide-react"; import { EllipsisVertical } from "lucide-react";
import { type ReactNode, useEffect, useRef, useState } from "react"; import { type ReactNode, useRef, useState } from "react";
import { useClickOutside } from "../../hooks/use-click-outside.js";
import { Button } from "./button"; import { Button } from "./button";
export interface OverflowMenuItem { export interface OverflowMenuItem {
@@ -18,23 +19,7 @@ export function OverflowMenu({ items }: OverflowMenuProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useEffect(() => { useClickOutside(ref, () => setOpen(false), open);
if (!open) return;
function handleMouseDown(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
return ( return (
<div ref={ref} className="relative"> <div ref={ref} className="relative">
+27
View File
@@ -0,0 +1,27 @@
import type { RefObject } from "react";
import { useEffect } from "react";
export function useClickOutside(
ref: RefObject<HTMLElement | null>,
onClose: () => void,
active = true,
): void {
useEffect(() => {
if (!active) return;
function handleMouseDown(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [ref, onClose, active]);
}
+66 -145
View File
@@ -22,6 +22,7 @@ import type {
CombatantInit, CombatantInit,
ConditionId, ConditionId,
CreatureId, CreatureId,
DomainError,
DomainEvent, DomainEvent,
Encounter, Encounter,
PlayerCharacter, PlayerCharacter,
@@ -120,167 +121,90 @@ export function useEncounter() {
return result; return result;
}, []); }, []);
const advanceTurn = useCallback(() => { const dispatchAction = useCallback(
const result = withUndo(() => advanceTurnUseCase(makeStore())); (action: () => DomainEvent[] | DomainError) => {
const result = withUndo(action);
if (isDomainError(result)) { if (!isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]); setEvents((prev) => [...prev, ...result]);
}, [makeStore, withUndo]);
const retreatTurn = useCallback(() => {
const result = withUndo(() => retreatTurnUseCase(makeStore()));
if (isDomainError(result)) {
return;
} }
},
setEvents((prev) => [...prev, ...result]); [withUndo],
}, [makeStore, withUndo]); );
const nextId = useRef(deriveNextId(encounter)); const nextId = useRef(deriveNextId(encounter));
const advanceTurn = useCallback(
() => dispatchAction(() => advanceTurnUseCase(makeStore())),
[makeStore, dispatchAction],
);
const retreatTurn = useCallback(
() => dispatchAction(() => retreatTurnUseCase(makeStore())),
[makeStore, dispatchAction],
);
const addCombatant = useCallback( const addCombatant = useCallback(
(name: string, init?: CombatantInit) => { (name: string, init?: CombatantInit) => {
const id = combatantId(`c-${++nextId.current}`); const id = combatantId(`c-${++nextId.current}`);
const result = withUndo(() => dispatchAction(() => addCombatantUseCase(makeStore(), id, name, init));
addCombatantUseCase(makeStore(), id, name, init),
);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
}, },
[makeStore, withUndo], [makeStore, dispatchAction],
); );
const removeCombatant = useCallback( const removeCombatant = useCallback(
(id: CombatantId) => { (id: CombatantId) =>
const result = withUndo(() => removeCombatantUseCase(makeStore(), id)); dispatchAction(() => removeCombatantUseCase(makeStore(), id)),
[makeStore, dispatchAction],
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore, withUndo],
); );
const editCombatant = useCallback( const editCombatant = useCallback(
(id: CombatantId, newName: string) => { (id: CombatantId, newName: string) =>
const result = withUndo(() => dispatchAction(() => editCombatantUseCase(makeStore(), id, newName)),
editCombatantUseCase(makeStore(), id, newName), [makeStore, dispatchAction],
);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore, withUndo],
); );
const setInitiative = useCallback( const setInitiative = useCallback(
(id: CombatantId, value: number | undefined) => { (id: CombatantId, value: number | undefined) =>
const result = withUndo(() => dispatchAction(() => setInitiativeUseCase(makeStore(), id, value)),
setInitiativeUseCase(makeStore(), id, value), [makeStore, dispatchAction],
);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore, withUndo],
); );
const setHp = useCallback( const setHp = useCallback(
(id: CombatantId, maxHp: number | undefined) => { (id: CombatantId, maxHp: number | undefined) =>
const result = withUndo(() => setHpUseCase(makeStore(), id, maxHp)); dispatchAction(() => setHpUseCase(makeStore(), id, maxHp)),
[makeStore, dispatchAction],
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore, withUndo],
); );
const adjustHp = useCallback( const adjustHp = useCallback(
(id: CombatantId, delta: number) => { (id: CombatantId, delta: number) =>
const result = withUndo(() => adjustHpUseCase(makeStore(), id, delta)); dispatchAction(() => adjustHpUseCase(makeStore(), id, delta)),
[makeStore, dispatchAction],
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore, withUndo],
); );
const setTempHp = useCallback( const setTempHp = useCallback(
(id: CombatantId, tempHp: number | undefined) => { (id: CombatantId, tempHp: number | undefined) =>
const result = withUndo(() => setTempHpUseCase(makeStore(), id, tempHp)); dispatchAction(() => setTempHpUseCase(makeStore(), id, tempHp)),
[makeStore, dispatchAction],
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore, withUndo],
); );
const setAc = useCallback( const setAc = useCallback(
(id: CombatantId, value: number | undefined) => { (id: CombatantId, value: number | undefined) =>
const result = withUndo(() => setAcUseCase(makeStore(), id, value)); dispatchAction(() => setAcUseCase(makeStore(), id, value)),
[makeStore, dispatchAction],
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore, withUndo],
); );
const toggleCondition = useCallback( const toggleCondition = useCallback(
(id: CombatantId, conditionId: ConditionId) => { (id: CombatantId, conditionId: ConditionId) =>
const result = withUndo(() => dispatchAction(() =>
toggleConditionUseCase(makeStore(), id, conditionId), toggleConditionUseCase(makeStore(), id, conditionId),
); ),
[makeStore, dispatchAction],
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore, withUndo],
); );
const toggleConcentration = useCallback( const toggleConcentration = useCallback(
(id: CombatantId) => { (id: CombatantId) =>
const result = withUndo(() => dispatchAction(() => toggleConcentrationUseCase(makeStore(), id)),
toggleConcentrationUseCase(makeStore(), id), [makeStore, dispatchAction],
);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore, withUndo],
); );
const clearEncounter = useCallback(() => { const clearEncounter = useCallback(() => {
@@ -298,16 +222,11 @@ export function useEncounter() {
setEvents((prev) => [...prev, ...result]); setEvents((prev) => [...prev, ...result]);
}, [makeStore]); }, [makeStore]);
const addOneFromBestiary = useCallback( const resolveAndRename = useCallback(
( (name: string): string => {
entry: BestiaryIndexEntry,
): { cId: CreatureId; events: DomainEvent[] } | null => {
const store = makeStore(); const store = makeStore();
const existingNames = store.get().combatants.map((c) => c.name); const existingNames = store.get().combatants.map((c) => c.name);
const { newName, renames } = resolveCreatureName( const { newName, renames } = resolveCreatureName(name, existingNames);
entry.name,
existingNames,
);
for (const { from, to } of renames) { for (const { from, to } of renames) {
const target = store.get().combatants.find((c) => c.name === from); const target = store.get().combatants.find((c) => c.name === from);
@@ -316,6 +235,17 @@ export function useEncounter() {
} }
} }
return newName;
},
[makeStore],
);
const addOneFromBestiary = useCallback(
(
entry: BestiaryIndexEntry,
): { cId: CreatureId; events: DomainEvent[] } | null => {
const newName = resolveAndRename(entry.name);
const slug = entry.name const slug = entry.name
.toLowerCase() .toLowerCase()
.replaceAll(/[^a-z0-9]+/g, "-") .replaceAll(/[^a-z0-9]+/g, "-")
@@ -333,7 +263,7 @@ export function useEncounter() {
return { cId, events: result }; return { cId, events: result };
}, },
[makeStore], [makeStore, resolveAndRename],
); );
const addFromBestiary = useCallback( const addFromBestiary = useCallback(
@@ -385,16 +315,7 @@ export function useEncounter() {
const addFromPlayerCharacter = useCallback( const addFromPlayerCharacter = useCallback(
(pc: PlayerCharacter) => { (pc: PlayerCharacter) => {
const snapshot = encounterRef.current; const snapshot = encounterRef.current;
const store = makeStore(); const newName = resolveAndRename(pc.name);
const existingNames = store.get().combatants.map((c) => c.name);
const { newName, renames } = resolveCreatureName(pc.name, existingNames);
for (const { from, to } of renames) {
const target = store.get().combatants.find((c) => c.name === from);
if (target) {
editCombatantUseCase(makeStore(), target.id, to);
}
}
const id = combatantId(`c-${++nextId.current}`); const id = combatantId(`c-${++nextId.current}`);
const result = addCombatantUseCase(makeStore(), id, newName, { const result = addCombatantUseCase(makeStore(), id, newName, {
@@ -406,7 +327,7 @@ export function useEncounter() {
}); });
if (isDomainError(result)) { if (isDomainError(result)) {
store.save(snapshot); makeStore().save(snapshot);
return; return;
} }
@@ -416,7 +337,7 @@ export function useEncounter() {
setEvents((prev) => [...prev, ...result]); setEvents((prev) => [...prev, ...result]);
}, },
[makeStore], [makeStore, resolveAndRename],
); );
const undoAction = useCallback(() => { const undoAction = useCallback(() => {
@@ -122,64 +122,7 @@ describe("loadEncounter", () => {
expect(loadEncounter()).toBeNull(); expect(loadEncounter()).toBeNull();
}); });
// US3: Corrupt data scenarios it("returns null when combatant has invalid required fields", () => {
it("returns null for non-object JSON (string)", () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify("hello"));
expect(loadEncounter()).toBeNull();
});
it("returns null for non-object JSON (number)", () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(42));
expect(loadEncounter()).toBeNull();
});
it("returns null for non-object JSON (array)", () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify([1, 2, 3]));
expect(loadEncounter()).toBeNull();
});
it("returns null for non-object JSON (null)", () => {
localStorage.setItem(STORAGE_KEY, "null");
expect(loadEncounter()).toBeNull();
});
it("returns null when combatants is a string instead of array", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
combatants: "not-array",
activeIndex: 0,
roundNumber: 1,
}),
);
expect(loadEncounter()).toBeNull();
});
it("returns null when activeIndex is a string instead of number", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
combatants: [{ id: "1", name: "Aria" }],
activeIndex: "zero",
roundNumber: 1,
}),
);
expect(loadEncounter()).toBeNull();
});
it("returns null when combatant entry is missing id", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
combatants: [{ name: "Aria" }],
activeIndex: 0,
roundNumber: 1,
}),
);
expect(loadEncounter()).toBeNull();
});
it("returns null when combatant entry is missing name", () => {
localStorage.setItem( localStorage.setItem(
STORAGE_KEY, STORAGE_KEY,
JSON.stringify({ JSON.stringify({
@@ -191,88 +134,6 @@ describe("loadEncounter", () => {
expect(loadEncounter()).toBeNull(); expect(loadEncounter()).toBeNull();
}); });
it("returns null for negative roundNumber", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
combatants: [{ id: "1", name: "Aria" }],
activeIndex: 0,
roundNumber: -1,
}),
);
expect(loadEncounter()).toBeNull();
});
it("returns empty encounter for zero combatants (cleared state)", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ combatants: [], activeIndex: 0, roundNumber: 1 }),
);
const result = loadEncounter();
expect(result).toEqual({
combatants: [],
activeIndex: 0,
roundNumber: 1,
});
});
it("round-trip preserves combatant AC value", () => {
const result = createEncounter(
[{ id: combatantId("1"), name: "Aria", ac: 18 }],
0,
1,
);
if (isDomainError(result)) throw new Error("unreachable");
saveEncounter(result);
const loaded = loadEncounter();
expect(loaded?.combatants[0].ac).toBe(18);
});
it("round-trip preserves combatant without AC", () => {
const result = createEncounter(
[{ id: combatantId("1"), name: "Aria" }],
0,
1,
);
if (isDomainError(result)) throw new Error("unreachable");
saveEncounter(result);
const loaded = loadEncounter();
expect(loaded?.combatants[0].ac).toBeUndefined();
});
it("discards invalid AC values during rehydration", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
combatants: [
{ id: "1", name: "Neg", ac: -1 },
{ id: "2", name: "Float", ac: 3.5 },
{ id: "3", name: "Str", ac: "high" },
],
activeIndex: 0,
roundNumber: 1,
}),
);
const loaded = loadEncounter();
expect(loaded).not.toBeNull();
expect(loaded?.combatants[0].ac).toBeUndefined();
expect(loaded?.combatants[1].ac).toBeUndefined();
expect(loaded?.combatants[2].ac).toBeUndefined();
});
it("preserves AC of 0 during rehydration", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
combatants: [{ id: "1", name: "Aria", ac: 0 }],
activeIndex: 0,
roundNumber: 1,
}),
);
const loaded = loadEncounter();
expect(loaded?.combatants[0].ac).toBe(0);
});
it("saving after modifications persists the latest state", () => { it("saving after modifications persists the latest state", () => {
const encounter = makeEncounter(); const encounter = makeEncounter();
saveEncounter(encounter); saveEncounter(encounter);
@@ -90,134 +90,7 @@ describe("player-character-storage", () => {
}); });
}); });
describe("per-character validation", () => { describe("delegation to domain rehydration", () => {
it("discards character with missing name", () => {
mockStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{ id: "pc-1", ac: 10, maxHp: 50, color: "blue", icon: "sword" },
]),
);
expect(loadPlayerCharacters()).toEqual([]);
});
it("discards character with empty name", () => {
mockStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{
id: "pc-1",
name: "",
ac: 10,
maxHp: 50,
color: "blue",
icon: "sword",
},
]),
);
expect(loadPlayerCharacters()).toEqual([]);
});
it("discards character with invalid color", () => {
mockStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{
id: "pc-1",
name: "Test",
ac: 10,
maxHp: 50,
color: "neon",
icon: "sword",
},
]),
);
expect(loadPlayerCharacters()).toEqual([]);
});
it("discards character with invalid icon", () => {
mockStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{
id: "pc-1",
name: "Test",
ac: 10,
maxHp: 50,
color: "blue",
icon: "banana",
},
]),
);
expect(loadPlayerCharacters()).toEqual([]);
});
it("discards character with negative AC", () => {
mockStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{
id: "pc-1",
name: "Test",
ac: -1,
maxHp: 50,
color: "blue",
icon: "sword",
},
]),
);
expect(loadPlayerCharacters()).toEqual([]);
});
it("discards character with maxHp of 0", () => {
mockStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{
id: "pc-1",
name: "Test",
ac: 10,
maxHp: 0,
color: "blue",
icon: "sword",
},
]),
);
expect(loadPlayerCharacters()).toEqual([]);
});
it("preserves level through save/load round-trip", () => {
const pc = makePC({ level: 5 });
savePlayerCharacters([pc]);
const loaded = loadPlayerCharacters();
expect(loaded[0].level).toBe(5);
});
it("preserves undefined level through save/load round-trip", () => {
const pc = makePC();
savePlayerCharacters([pc]);
const loaded = loadPlayerCharacters();
expect(loaded[0].level).toBeUndefined();
});
it("discards character with invalid level", () => {
mockStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{
id: "pc-1",
name: "Test",
ac: 10,
maxHp: 50,
color: "blue",
icon: "sword",
level: 25,
},
]),
);
expect(loadPlayerCharacters()).toEqual([]);
});
it("keeps valid characters and discards invalid ones", () => { it("keeps valid characters and discards invalid ones", () => {
mockStorage.setItem( mockStorage.setItem(
STORAGE_KEY, STORAGE_KEY,
+15 -100
View File
@@ -1,14 +1,9 @@
import { import {
type ConditionId, type Combatant,
combatantId,
createEncounter, createEncounter,
creatureId,
type Encounter, type Encounter,
isDomainError, isDomainError,
playerCharacterId, rehydrateCombatant,
VALID_CONDITION_IDS,
VALID_PLAYER_COLORS,
VALID_PLAYER_ICONS,
} from "@initiative/domain"; } from "@initiative/domain";
const STORAGE_KEY = "initiative:encounter"; const STORAGE_KEY = "initiative:encounter";
@@ -21,93 +16,6 @@ export function saveEncounter(encounter: Encounter): void {
} }
} }
function validateAc(value: unknown): number | undefined {
return typeof value === "number" && Number.isInteger(value) && value >= 0
? value
: undefined;
}
function validateConditions(value: unknown): ConditionId[] | undefined {
if (!Array.isArray(value)) return undefined;
const valid = value.filter(
(v): v is ConditionId =>
typeof v === "string" && VALID_CONDITION_IDS.has(v),
);
return valid.length > 0 ? valid : undefined;
}
function validateCreatureId(value: unknown) {
return typeof value === "string" && value.length > 0
? creatureId(value)
: undefined;
}
function validateHp(
rawMaxHp: unknown,
rawCurrentHp: unknown,
): { maxHp: number; currentHp: number } | undefined {
if (
typeof rawMaxHp !== "number" ||
!Number.isInteger(rawMaxHp) ||
rawMaxHp < 1
) {
return undefined;
}
const validCurrentHp =
typeof rawCurrentHp === "number" &&
Number.isInteger(rawCurrentHp) &&
rawCurrentHp >= 0 &&
rawCurrentHp <= rawMaxHp;
return {
maxHp: rawMaxHp,
currentHp: validCurrentHp ? rawCurrentHp : rawMaxHp,
};
}
function rehydrateCombatant(c: unknown) {
const entry = c as Record<string, unknown>;
const base = {
id: combatantId(entry.id as string),
name: entry.name as string,
initiative:
typeof entry.initiative === "number" ? entry.initiative : undefined,
};
const color =
typeof entry.color === "string" && VALID_PLAYER_COLORS.has(entry.color)
? entry.color
: undefined;
const icon =
typeof entry.icon === "string" && VALID_PLAYER_ICONS.has(entry.icon)
? entry.icon
: undefined;
const pcId =
typeof entry.playerCharacterId === "string" &&
entry.playerCharacterId.length > 0
? playerCharacterId(entry.playerCharacterId)
: undefined;
const shared = {
...base,
ac: validateAc(entry.ac),
conditions: validateConditions(entry.conditions),
isConcentrating: entry.isConcentrating === true ? true : undefined,
creatureId: validateCreatureId(entry.creatureId),
color,
icon,
playerCharacterId: pcId,
};
const hp = validateHp(entry.maxHp, entry.currentHp);
return hp ? { ...shared, ...hp } : shared;
}
function isValidCombatantEntry(c: unknown): boolean {
if (typeof c !== "object" || c === null || Array.isArray(c)) return false;
const entry = c as Record<string, unknown>;
return typeof entry.id === "string" && typeof entry.name === "string";
}
export function rehydrateEncounter(parsed: unknown): Encounter | null { export function rehydrateEncounter(parsed: unknown): Encounter | null {
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
return null; return null;
@@ -129,14 +37,21 @@ export function rehydrateEncounter(parsed: unknown): Encounter | null {
}; };
} }
if (!combatants.every(isValidCombatantEntry)) return null; const rehydrated: Combatant[] = [];
for (const c of combatants) {
const result = rehydrateCombatant(c);
if (result === null) return null;
rehydrated.push(result);
}
const rehydrated = combatants.map(rehydrateCombatant); const encounter = createEncounter(
rehydrated,
obj.activeIndex,
obj.roundNumber,
);
if (isDomainError(encounter)) return null;
const result = createEncounter(rehydrated, obj.activeIndex, obj.roundNumber); return encounter;
if (isDomainError(result)) return null;
return result;
} }
export function loadEncounter(): Encounter | null { export function loadEncounter(): Encounter | null {
+2 -2
View File
@@ -4,8 +4,8 @@ import type {
PlayerCharacter, PlayerCharacter,
UndoRedoState, UndoRedoState,
} from "@initiative/domain"; } from "@initiative/domain";
import { rehydratePlayerCharacter } from "@initiative/domain";
import { rehydrateEncounter } from "./encounter-storage.js"; import { rehydrateEncounter } from "./encounter-storage.js";
import { rehydrateCharacter } from "./player-character-storage.js";
function rehydrateStack(raw: unknown[]): Encounter[] { function rehydrateStack(raw: unknown[]): Encounter[] {
const result: Encounter[] = []; const result: Encounter[] = [];
@@ -21,7 +21,7 @@ function rehydrateStack(raw: unknown[]): Encounter[] {
function rehydrateCharacters(raw: unknown[]): PlayerCharacter[] { function rehydrateCharacters(raw: unknown[]): PlayerCharacter[] {
const result: PlayerCharacter[] = []; const result: PlayerCharacter[] = [];
for (const entry of raw) { for (const entry of raw) {
const rehydrated = rehydrateCharacter(entry); const rehydrated = rehydratePlayerCharacter(entry);
if (rehydrated !== null) { if (rehydrated !== null) {
result.push(rehydrated); result.push(rehydrated);
} }
@@ -1,9 +1,5 @@
import type { PlayerCharacter } from "@initiative/domain"; import type { PlayerCharacter } from "@initiative/domain";
import { import { rehydratePlayerCharacter } from "@initiative/domain";
playerCharacterId,
VALID_PLAYER_COLORS,
VALID_PLAYER_ICONS,
} from "@initiative/domain";
const STORAGE_KEY = "initiative:player-characters"; const STORAGE_KEY = "initiative:player-characters";
@@ -15,55 +11,6 @@ export function savePlayerCharacters(characters: PlayerCharacter[]): void {
} }
} }
function isValidOptionalMember(
value: unknown,
valid: ReadonlySet<string>,
): boolean {
return value === undefined || (typeof value === "string" && valid.has(value));
}
export function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
return null;
const entry = raw as Record<string, unknown>;
if (typeof entry.id !== "string" || entry.id.length === 0) return null;
if (typeof entry.name !== "string" || entry.name.trim().length === 0)
return null;
if (
typeof entry.ac !== "number" ||
!Number.isInteger(entry.ac) ||
entry.ac < 0
)
return null;
if (
typeof entry.maxHp !== "number" ||
!Number.isInteger(entry.maxHp) ||
entry.maxHp < 1
)
return null;
if (!isValidOptionalMember(entry.color, VALID_PLAYER_COLORS)) return null;
if (!isValidOptionalMember(entry.icon, VALID_PLAYER_ICONS)) return null;
if (
entry.level !== undefined &&
(typeof entry.level !== "number" ||
!Number.isInteger(entry.level) ||
entry.level < 1 ||
entry.level > 20)
)
return null;
return {
id: playerCharacterId(entry.id),
name: entry.name,
ac: entry.ac,
maxHp: entry.maxHp,
color: entry.color as PlayerCharacter["color"],
icon: entry.icon as PlayerCharacter["icon"],
level: entry.level,
};
}
export function loadPlayerCharacters(): PlayerCharacter[] { export function loadPlayerCharacters(): PlayerCharacter[] {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(STORAGE_KEY);
@@ -74,7 +21,7 @@ export function loadPlayerCharacters(): PlayerCharacter[] {
const characters: PlayerCharacter[] = []; const characters: PlayerCharacter[] = [];
for (const item of parsed) { for (const item of parsed) {
const pc = rehydrateCharacter(item); const pc = rehydratePlayerCharacter(item);
if (pc !== null) { if (pc !== null) {
characters.push(pc); characters.push(pc);
} }
@@ -0,0 +1,238 @@
---
date: 2026-03-28T01:35:07.925247+00:00
git_commit: f4fb69dbc763fefe4a73b3491c27093bbd06af0d
branch: main
topic: "Entity rehydration: current implementation and migration surface"
tags: [research, codebase, rehydration, persistence, domain, player-character, combatant]
status: complete
---
# Research: Entity Rehydration — Current Implementation and Migration Surface
## Research Question
Map all entity rehydration logic (reconstructing typed domain objects from untyped JSON) across the codebase. Document what validation each rehydration function performs, where it lives, how functions cross-reference each other, and what the domain layer already provides that could replace adapter-level validation. This research supports Issue #20: Move entity rehydration to domain layer.
## Summary
Entity rehydration currently lives entirely in `apps/web/src/persistence/`. Two primary rehydration functions exist:
1. **`rehydrateCharacter`** in `player-character-storage.ts` — validates and reconstructs `PlayerCharacter` from unknown JSON
2. **`rehydrateCombatant`** (private) + **`rehydrateEncounter`** (exported) in `encounter-storage.ts` — validates and reconstructs `Combatant`/`Encounter` from unknown JSON
These are consumed by three call sites: localStorage loading, undo/redo stack loading, and JSON import validation. The domain layer already contains parallel validation logic in `createPlayerCharacter`, `addCombatant`/`validateInit`, and `createEncounter`, but the rehydration functions duplicate this validation with subtly different rules (rehydration is lenient/recovering; creation is strict/rejecting).
## Detailed Findings
### 1. PlayerCharacter Rehydration
**File**: `apps/web/src/persistence/player-character-storage.ts:25-65`
`rehydrateCharacter(raw: unknown): PlayerCharacter | null` performs:
| Field | Validation | Behavior on invalid |
|-------|-----------|-------------------|
| `id` | `typeof string`, non-empty | Return `null` (reject entire PC) |
| `name` | `typeof string`, non-empty after trim | Return `null` |
| `ac` | `typeof number`, integer, `>= 0` | Return `null` |
| `maxHp` | `typeof number`, integer, `>= 1` | Return `null` |
| `color` | Optional; if present, must be in `VALID_PLAYER_COLORS` | Return `null` |
| `icon` | Optional; if present, must be in `VALID_PLAYER_ICONS` | Return `null` |
| `level` | Optional; if present, must be integer 1-20 | Return `null` |
Constructs result via branded `playerCharacterId()` and type assertions for color/icon.
**Helper**: `isValidOptionalMember(value, validSet)` — shared check for optional set-membership fields (lines 18-23).
**Callers**:
- `loadPlayerCharacters()` (same file, line 67) — loads from localStorage
- `rehydrateCharacters()` in `export-import.ts:21-30` — filters PCs during import validation
### 2. Combatant Rehydration
**File**: `apps/web/src/persistence/encounter-storage.ts:67-103`
`rehydrateCombatant(c: unknown)` (private, no return type annotation) performs:
| Field | Validation | Behavior on invalid |
|-------|-----------|-------------------|
| `id` | Cast directly (`entry.id as string`) | No validation (relies on `isValidCombatantEntry` pre-check) |
| `name` | Cast directly (`entry.name as string`) | No validation (relies on pre-check) |
| `initiative` | `typeof number` or `undefined` | Defaults to `undefined` |
| `ac` | Via `validateAc`: integer `>= 0` | Defaults to `undefined` |
| `conditions` | Via `validateConditions`: array, each in `VALID_CONDITION_IDS` | Defaults to `undefined` |
| `isConcentrating` | Strictly `=== true` | Defaults to `undefined` |
| `creatureId` | Via `validateCreatureId`: non-empty string | Defaults to `undefined` |
| `color` | String in `VALID_PLAYER_COLORS` | Defaults to `undefined` |
| `icon` | String in `VALID_PLAYER_ICONS` | Defaults to `undefined` |
| `playerCharacterId` | Non-empty string | Defaults to `undefined` |
| `maxHp` / `currentHp` | Via `validateHp`: maxHp integer >= 1, currentHp integer 0..maxHp | Defaults to `undefined`; invalid currentHp falls back to maxHp |
**Key difference from PC rehydration**: Combatant rehydration is *lenient* — invalid optional fields are silently dropped rather than rejecting the entire entity. Only `id` and `name` are required (checked by `isValidCombatantEntry` at line 105-109 before `rehydrateCombatant` is called).
**Helper functions** (all private):
- `validateAc(value)` — lines 24-28
- `validateConditions(value)` — lines 30-37
- `validateCreatureId(value)` — lines 39-43
- `validateHp(rawMaxHp, rawCurrentHp)` — lines 45-65
### 3. Encounter Rehydration
**File**: `apps/web/src/persistence/encounter-storage.ts:111-140`
`rehydrateEncounter(parsed: unknown): Encounter | null` validates the encounter envelope:
- Must be a non-null, non-array object
- `combatants` must be an array
- `activeIndex` must be a number
- `roundNumber` must be a number
- Empty combatant array → returns hardcoded `{ combatants: [], activeIndex: 0, roundNumber: 1 }`
- All entries must pass `isValidCombatantEntry` (id + name check)
- Maps entries through `rehydrateCombatant`, then passes to domain's `createEncounter` for invariant enforcement
**Callers**:
- `loadEncounter()` (same file, line 142) — localStorage
- `loadStack()` in `undo-redo-storage.ts:17-36` — undo/redo stacks from localStorage
- `rehydrateStack()` in `export-import.ts:10-19` — import validation
- `validateImportBundle()` in `export-import.ts:32-65` — import validation (direct call for the main encounter)
### 4. Import Bundle Validation
**File**: `apps/web/src/persistence/export-import.ts:32-65`
`validateImportBundle(data: unknown): ExportBundle | string` validates the bundle envelope:
- Version must be `1`
- `exportedAt` must be a string
- `undoStack` and `redoStack` must be arrays
- `playerCharacters` must be an array
- Delegates to `rehydrateEncounter` for the encounter
- Delegates to `rehydrateStack` (which calls `rehydrateEncounter`) for undo/redo
- Delegates to `rehydrateCharacters` (which calls `rehydrateCharacter`) for PCs
This function validates the *envelope* structure. Entity-level validation is fully delegated.
### 5. Domain Layer Validation (Existing)
The domain already contains validation for the same fields, but in *creation* context (typed inputs, DomainError returns):
**`createPlayerCharacter`** (`packages/domain/src/create-player-character.ts:17-100`):
- Same field rules as `rehydrateCharacter`: name non-empty, ac >= 0 integer, maxHp >= 1 integer, color/icon in valid sets, level 1-20
- Returns `DomainError` on invalid input (not `null`)
**`validateInit`** in `addCombatant` (`packages/domain/src/add-combatant.ts:27-53`):
- Validates maxHp (positive integer), ac (non-negative integer), initiative (integer)
- Does NOT validate conditions, color, icon, playerCharacterId, creatureId, isConcentrating
**`createEncounter`** (`packages/domain/src/types.ts:50-71`):
- Validates activeIndex bounds and roundNumber (positive integer)
- Already used by `rehydrateEncounter` as the final step
**`editPlayerCharacter`** (`packages/domain/src/edit-player-character.ts`):
- `validateFields` validates the same PC fields for edits
### 6. Validation Overlap and Gaps
| Field | Rehydration validates | Domain validates |
|-------|----------------------|-----------------|
| PC.id | Non-empty string | N/A (caller provides) |
| PC.name | Non-empty string | Non-empty (trimmed) |
| PC.ac | Integer >= 0 | Integer >= 0 |
| PC.maxHp | Integer >= 1 | Integer >= 1 |
| PC.color | In VALID_PLAYER_COLORS | In VALID_PLAYER_COLORS |
| PC.icon | In VALID_PLAYER_ICONS | In VALID_PLAYER_ICONS |
| PC.level | Integer 1-20 | Integer 1-20 |
| Combatant.id | Non-empty string (via pre-check) | N/A (caller provides) |
| Combatant.name | String type (via pre-check) | Non-empty (trimmed) |
| Combatant.initiative | `typeof number` | Integer |
| Combatant.ac | Integer >= 0 | Integer >= 0 |
| Combatant.maxHp | Integer >= 1 | Integer >= 1 |
| Combatant.currentHp | Integer 0..maxHp | N/A (set = maxHp on add) |
| Combatant.tempHp | **Not validated** | N/A |
| Combatant.conditions | Each in VALID_CONDITION_IDS | N/A (toggleCondition checks) |
| Combatant.isConcentrating | Strictly `true` or dropped | N/A (toggleConcentration) |
| Combatant.creatureId | Non-empty string | N/A (passed through) |
| Combatant.color | In VALID_PLAYER_COLORS | N/A (passed through) |
| Combatant.icon | In VALID_PLAYER_ICONS | N/A (passed through) |
| Combatant.playerCharacterId | Non-empty string | N/A (passed through) |
Key observations:
- Rehydration validates `id` (required for identity); domain creation functions receive `id` as a typed parameter
- Combatant rehydration does NOT validate `tempHp` at all — it's silently passed through or ignored
- Combatant rehydration checks `initiative` as `typeof number` but domain checks `Number.isInteger` — slightly different strictness
- Domain validation for combatant optional fields is scattered across individual mutation functions, not centralized
### 7. Test Coverage
**Persistence tests** (adapter layer):
- `encounter-storage.test.ts` — ~27 tests covering round-trip, corrupt data, AC validation, edge cases
- `player-character-storage.test.ts` — ~17 tests covering round-trip, corrupt data, field validation, level
**Import tests** (adapter layer):
- `validate-import-bundle.test.ts` — ~21 tests covering envelope validation, graceful recovery, PC filtering
- `export-import.test.ts` — ~15 tests covering bundle assembly, round-trip, filename resolution
**Domain tests**: No rehydration tests exist in `packages/domain/` — all rehydration testing is in the adapter layer.
### 8. Cross-Reference Map
```
loadPlayerCharacters() ──→ rehydrateCharacter()
validateImportBundle() ──→ rehydrateCharacters() ──┘
├─→ rehydrateEncounter() ──→ isValidCombatantEntry()
│ ├─→ rehydrateCombatant() ──→ validateAc()
│ │ ├─→ validateConditions()
│ │ ├─→ validateCreatureId()
│ │ └─→ validateHp()
│ └─→ createEncounter() [domain]
└─→ rehydrateStack() ───→ rehydrateEncounter() [same as above]
loadEncounter() ───────→ rehydrateEncounter() [same as above]
loadUndoRedoStacks() ──→ loadStack() ──→ rehydrateEncounter() [same as above]
```
## Code References
- `apps/web/src/persistence/player-character-storage.ts:25-65``rehydrateCharacter` (PC rehydration)
- `apps/web/src/persistence/player-character-storage.ts:18-23``isValidOptionalMember` helper
- `apps/web/src/persistence/encounter-storage.ts:24-28``validateAc` helper
- `apps/web/src/persistence/encounter-storage.ts:30-37``validateConditions` helper
- `apps/web/src/persistence/encounter-storage.ts:39-43``validateCreatureId` helper
- `apps/web/src/persistence/encounter-storage.ts:45-65``validateHp` helper
- `apps/web/src/persistence/encounter-storage.ts:67-103``rehydrateCombatant` (combatant rehydration)
- `apps/web/src/persistence/encounter-storage.ts:105-109``isValidCombatantEntry` (pre-check)
- `apps/web/src/persistence/encounter-storage.ts:111-140``rehydrateEncounter` (encounter envelope rehydration)
- `apps/web/src/persistence/export-import.ts:10-30``rehydrateStack` / `rehydrateCharacters` (collection wrappers)
- `apps/web/src/persistence/export-import.ts:32-65``validateImportBundle` (import envelope validation)
- `apps/web/src/persistence/undo-redo-storage.ts:17-36``loadStack` (undo/redo rehydration)
- `packages/domain/src/create-player-character.ts:17-100` — PC creation validation
- `packages/domain/src/add-combatant.ts:27-53``validateInit` (combatant creation validation)
- `packages/domain/src/types.ts:50-71``createEncounter` (encounter invariant enforcement)
- `packages/domain/src/types.ts:12-26``Combatant` type definition
- `packages/domain/src/player-character-types.ts:70-83``PlayerCharacter` type definition
## Architecture Documentation
### Current pattern
Rehydration is an adapter concern — persistence adapters validate raw JSON and construct typed domain objects. The domain provides creation functions that validate typed inputs for new entities, but no functions for reconstructing entities from untyped serialized data.
### Rehydration vs. creation semantics
Rehydration and creation serve different purposes:
- **Creation** (domain): Validates business rules for *new* entities. Receives typed parameters. Returns `DomainError` on failure.
- **Rehydration** (adapter): Reconstructs *previously valid* entities from serialized JSON. Receives `unknown`. Returns `null` on failure. May be lenient (combatants drop invalid optional fields) or strict (PCs reject on any invalid field).
### Delegation chain
`rehydrateEncounter` already delegates to `createEncounter` for encounter-level invariants. The entity-level rehydration functions (`rehydrateCharacter`, `rehydrateCombatant`) do NOT delegate to any domain function — they re-implement field validation inline.
### tempHp gap
`Combatant.tempHp` is defined in the domain type but has no validation in the current rehydration code. It appears to be silently included or excluded depending on what `rehydrateCombatant` constructs (it's not in the explicit field list, so it would be dropped during rehydration).
## Open Questions
1. **Should `rehydrateCombatant` remain lenient (drop invalid optional fields) or become strict like `rehydrateCharacter` (reject on any invalid field)?** The current asymmetry is intentional: combatants can exist with minimal data (just id + name), while PCs always require ac/maxHp.
2. **Should `tempHp` be validated during rehydration?** It's currently missing from combatant rehydration but is a valid field on the type.
3. **Should `rehydrateEncounter` move to domain too, or only the entity-level functions?** The issue acceptance criteria says "validateImportBundle and rehydrateEncounter are unchanged" — but `rehydrateEncounter` currently lives alongside `rehydrateCombatant` and would need to import from domain instead of calling the local function.
4. **Should `isValidCombatantEntry` (the pre-check) be part of the domain rehydration or remain in the adapter?** It's currently the gate that ensures `id` and `name` exist before `rehydrateCombatant` is called.
+3 -1
View File
@@ -11,6 +11,7 @@
"@biomejs/biome": "2.4.8", "@biomejs/biome": "2.4.8",
"@vitest/coverage-v8": "^4.1.0", "@vitest/coverage-v8": "^4.1.0",
"jscpd": "^4.0.8", "jscpd": "^4.0.8",
"jsinspect-plus": "^3.1.3",
"knip": "^5.88.1", "knip": "^5.88.1",
"lefthook": "^2.1.4", "lefthook": "^2.1.4",
"oxlint": "^1.56.0", "oxlint": "^1.56.0",
@@ -29,10 +30,11 @@
"test:watch": "vitest", "test:watch": "vitest",
"knip": "knip", "knip": "knip",
"jscpd": "jscpd", "jscpd": "jscpd",
"jsinspect": "jsinspect -c .jsinspectrc apps/web/src packages/domain/src packages/application/src",
"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:props": "node scripts/check-component-props.mjs", "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" "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 && pnpm jsinspect"
} }
} }
@@ -4,9 +4,9 @@ import {
type CombatantInit, type CombatantInit,
type DomainError, type DomainError,
type DomainEvent, type DomainEvent,
isDomainError,
} from "@initiative/domain"; } from "@initiative/domain";
import type { EncounterStore } from "./ports.js"; import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function addCombatantUseCase( export function addCombatantUseCase(
store: EncounterStore, store: EncounterStore,
@@ -14,13 +14,7 @@ export function addCombatantUseCase(
name: string, name: string,
init?: CombatantInit, init?: CombatantInit,
): DomainEvent[] | DomainError { ): DomainEvent[] | DomainError {
const encounter = store.get(); return runEncounterAction(store, (encounter) =>
const result = addCombatant(encounter, id, name, init); addCombatant(encounter, id, name, init),
);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
} }
+4 -10
View File
@@ -3,22 +3,16 @@ import {
type CombatantId, type CombatantId,
type DomainError, type DomainError,
type DomainEvent, type DomainEvent,
isDomainError,
} from "@initiative/domain"; } from "@initiative/domain";
import type { EncounterStore } from "./ports.js"; import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function adjustHpUseCase( export function adjustHpUseCase(
store: EncounterStore, store: EncounterStore,
combatantId: CombatantId, combatantId: CombatantId,
delta: number, delta: number,
): DomainEvent[] | DomainError { ): DomainEvent[] | DomainError {
const encounter = store.get(); return runEncounterAction(store, (encounter) =>
const result = adjustHp(encounter, combatantId, delta); adjustHp(encounter, combatantId, delta),
);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
} }
@@ -2,20 +2,12 @@ import {
advanceTurn, advanceTurn,
type DomainError, type DomainError,
type DomainEvent, type DomainEvent,
isDomainError,
} from "@initiative/domain"; } from "@initiative/domain";
import type { EncounterStore } from "./ports.js"; import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function advanceTurnUseCase( export function advanceTurnUseCase(
store: EncounterStore, store: EncounterStore,
): DomainEvent[] | DomainError { ): DomainEvent[] | DomainError {
const encounter = store.get(); return runEncounterAction(store, (encounter) => advanceTurn(encounter));
const result = advanceTurn(encounter);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
} }
@@ -2,20 +2,12 @@ import {
clearEncounter, clearEncounter,
type DomainError, type DomainError,
type DomainEvent, type DomainEvent,
isDomainError,
} from "@initiative/domain"; } from "@initiative/domain";
import type { EncounterStore } from "./ports.js"; import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function clearEncounterUseCase( export function clearEncounterUseCase(
store: EncounterStore, store: EncounterStore,
): DomainEvent[] | DomainError { ): DomainEvent[] | DomainError {
const encounter = store.get(); return runEncounterAction(store, (encounter) => clearEncounter(encounter));
const result = clearEncounter(encounter);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
} }
@@ -3,22 +3,16 @@ import {
type DomainError, type DomainError,
type DomainEvent, type DomainEvent,
editCombatant, editCombatant,
isDomainError,
} from "@initiative/domain"; } from "@initiative/domain";
import type { EncounterStore } from "./ports.js"; import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function editCombatantUseCase( export function editCombatantUseCase(
store: EncounterStore, store: EncounterStore,
id: CombatantId, id: CombatantId,
newName: string, newName: string,
): DomainEvent[] | DomainError { ): DomainEvent[] | DomainError {
const encounter = store.get(); return runEncounterAction(store, (encounter) =>
const result = editCombatant(encounter, id, newName); editCombatant(encounter, id, newName),
);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
} }
@@ -2,22 +2,16 @@ import {
type CombatantId, type CombatantId,
type DomainError, type DomainError,
type DomainEvent, type DomainEvent,
isDomainError,
removeCombatant, removeCombatant,
} from "@initiative/domain"; } from "@initiative/domain";
import type { EncounterStore } from "./ports.js"; import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function removeCombatantUseCase( export function removeCombatantUseCase(
store: EncounterStore, store: EncounterStore,
id: CombatantId, id: CombatantId,
): DomainEvent[] | DomainError { ): DomainEvent[] | DomainError {
const encounter = store.get(); return runEncounterAction(store, (encounter) =>
const result = removeCombatant(encounter, id); removeCombatant(encounter, id),
);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
} }
@@ -1,21 +1,13 @@
import { import {
type DomainError, type DomainError,
type DomainEvent, type DomainEvent,
isDomainError,
retreatTurn, retreatTurn,
} from "@initiative/domain"; } from "@initiative/domain";
import type { EncounterStore } from "./ports.js"; import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function retreatTurnUseCase( export function retreatTurnUseCase(
store: EncounterStore, store: EncounterStore,
): DomainEvent[] | DomainError { ): DomainEvent[] | DomainError {
const encounter = store.get(); return runEncounterAction(store, (encounter) => retreatTurn(encounter));
const result = retreatTurn(encounter);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
} }
@@ -0,0 +1,27 @@
import {
type DomainError,
type DomainEvent,
type Encounter,
isDomainError,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
interface EncounterActionResult {
readonly encounter: Encounter;
readonly events: DomainEvent[];
}
export function runEncounterAction(
store: EncounterStore,
action: (encounter: Encounter) => EncounterActionResult | DomainError,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = action(encounter);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
}
+4 -10
View File
@@ -2,23 +2,17 @@ import {
type CombatantId, type CombatantId,
type DomainError, type DomainError,
type DomainEvent, type DomainEvent,
isDomainError,
setAc, setAc,
} from "@initiative/domain"; } from "@initiative/domain";
import type { EncounterStore } from "./ports.js"; import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function setAcUseCase( export function setAcUseCase(
store: EncounterStore, store: EncounterStore,
combatantId: CombatantId, combatantId: CombatantId,
value: number | undefined, value: number | undefined,
): DomainEvent[] | DomainError { ): DomainEvent[] | DomainError {
const encounter = store.get(); return runEncounterAction(store, (encounter) =>
const result = setAc(encounter, combatantId, value); setAc(encounter, combatantId, value),
);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
} }
+4 -10
View File
@@ -2,23 +2,17 @@ import {
type CombatantId, type CombatantId,
type DomainError, type DomainError,
type DomainEvent, type DomainEvent,
isDomainError,
setHp, setHp,
} from "@initiative/domain"; } from "@initiative/domain";
import type { EncounterStore } from "./ports.js"; import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function setHpUseCase( export function setHpUseCase(
store: EncounterStore, store: EncounterStore,
combatantId: CombatantId, combatantId: CombatantId,
maxHp: number | undefined, maxHp: number | undefined,
): DomainEvent[] | DomainError { ): DomainEvent[] | DomainError {
const encounter = store.get(); return runEncounterAction(store, (encounter) =>
const result = setHp(encounter, combatantId, maxHp); setHp(encounter, combatantId, maxHp),
);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
} }
@@ -2,23 +2,17 @@ import {
type CombatantId, type CombatantId,
type DomainError, type DomainError,
type DomainEvent, type DomainEvent,
isDomainError,
setInitiative, setInitiative,
} from "@initiative/domain"; } from "@initiative/domain";
import type { EncounterStore } from "./ports.js"; import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function setInitiativeUseCase( export function setInitiativeUseCase(
store: EncounterStore, store: EncounterStore,
combatantId: CombatantId, combatantId: CombatantId,
value: number | undefined, value: number | undefined,
): DomainEvent[] | DomainError { ): DomainEvent[] | DomainError {
const encounter = store.get(); return runEncounterAction(store, (encounter) =>
const result = setInitiative(encounter, combatantId, value); setInitiative(encounter, combatantId, value),
);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
} }
@@ -2,23 +2,17 @@ import {
type CombatantId, type CombatantId,
type DomainError, type DomainError,
type DomainEvent, type DomainEvent,
isDomainError,
setTempHp, setTempHp,
} from "@initiative/domain"; } from "@initiative/domain";
import type { EncounterStore } from "./ports.js"; import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function setTempHpUseCase( export function setTempHpUseCase(
store: EncounterStore, store: EncounterStore,
combatantId: CombatantId, combatantId: CombatantId,
tempHp: number | undefined, tempHp: number | undefined,
): DomainEvent[] | DomainError { ): DomainEvent[] | DomainError {
const encounter = store.get(); return runEncounterAction(store, (encounter) =>
const result = setTempHp(encounter, combatantId, tempHp); setTempHp(encounter, combatantId, tempHp),
);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
} }
@@ -2,22 +2,16 @@ import {
type CombatantId, type CombatantId,
type DomainError, type DomainError,
type DomainEvent, type DomainEvent,
isDomainError,
toggleConcentration, toggleConcentration,
} from "@initiative/domain"; } from "@initiative/domain";
import type { EncounterStore } from "./ports.js"; import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function toggleConcentrationUseCase( export function toggleConcentrationUseCase(
store: EncounterStore, store: EncounterStore,
combatantId: CombatantId, combatantId: CombatantId,
): DomainEvent[] | DomainError { ): DomainEvent[] | DomainError {
const encounter = store.get(); return runEncounterAction(store, (encounter) =>
const result = toggleConcentration(encounter, combatantId); toggleConcentration(encounter, combatantId),
);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
} }
@@ -3,23 +3,17 @@ import {
type ConditionId, type ConditionId,
type DomainError, type DomainError,
type DomainEvent, type DomainEvent,
isDomainError,
toggleCondition, toggleCondition,
} from "@initiative/domain"; } from "@initiative/domain";
import type { EncounterStore } from "./ports.js"; import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function toggleConditionUseCase( export function toggleConditionUseCase(
store: EncounterStore, store: EncounterStore,
combatantId: CombatantId, combatantId: CombatantId,
conditionId: ConditionId, conditionId: ConditionId,
): DomainEvent[] | DomainError { ): DomainEvent[] | DomainError {
const encounter = store.get(); return runEncounterAction(store, (encounter) =>
const result = toggleCondition(encounter, combatantId, conditionId); toggleCondition(encounter, combatantId, conditionId),
);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
} }
@@ -0,0 +1,241 @@
import { describe, expect, it } from "vitest";
import { rehydrateCombatant } from "../rehydrate-combatant.js";
function validCombatant(overrides: Record<string, unknown> = {}) {
return {
id: "c-1",
name: "Goblin",
initiative: 12,
ac: 15,
maxHp: 7,
currentHp: 5,
tempHp: 3,
conditions: ["poisoned"],
isConcentrating: true,
creatureId: "creature-goblin",
color: "red",
icon: "skull",
playerCharacterId: "pc-1",
...overrides,
};
}
function minimalCombatant() {
return { id: "c-1", name: "Goblin" };
}
describe("rehydrateCombatant", () => {
describe("valid input", () => {
it("accepts a combatant with all fields", () => {
const result = rehydrateCombatant(validCombatant());
expect(result).not.toBeNull();
expect(result?.name).toBe("Goblin");
expect(result?.initiative).toBe(12);
expect(result?.ac).toBe(15);
expect(result?.maxHp).toBe(7);
expect(result?.currentHp).toBe(5);
expect(result?.tempHp).toBe(3);
expect(result?.conditions).toEqual(["poisoned"]);
expect(result?.isConcentrating).toBe(true);
expect(result?.creatureId).toBe("creature-goblin");
expect(result?.color).toBe("red");
expect(result?.icon).toBe("skull");
expect(result?.playerCharacterId).toBe("pc-1");
});
it("accepts a minimal combatant (id + name only)", () => {
const result = rehydrateCombatant(minimalCombatant());
expect(result).not.toBeNull();
expect(result?.id).toBe("c-1");
expect(result?.name).toBe("Goblin");
expect(result?.initiative).toBeUndefined();
expect(result?.ac).toBeUndefined();
expect(result?.maxHp).toBeUndefined();
});
it("preserves branded CombatantId", () => {
const result = rehydrateCombatant(minimalCombatant());
expect(result?.id).toBe("c-1");
});
});
describe("required field rejection", () => {
it.each([
null,
42,
"string",
[1, 2],
undefined,
])("rejects non-object input: %j", (input) => {
expect(rehydrateCombatant(input)).toBeNull();
});
it("rejects missing id", () => {
const { id: _, ...rest } = minimalCombatant();
expect(rehydrateCombatant(rest)).toBeNull();
});
it("rejects empty id", () => {
expect(rehydrateCombatant({ ...minimalCombatant(), id: "" })).toBeNull();
});
it("rejects missing name", () => {
const { name: _, ...rest } = minimalCombatant();
expect(rehydrateCombatant(rest)).toBeNull();
});
it("rejects non-string name", () => {
expect(
rehydrateCombatant({ ...minimalCombatant(), name: 42 }),
).toBeNull();
expect(
rehydrateCombatant({ ...minimalCombatant(), name: null }),
).toBeNull();
});
});
describe("optional field leniency", () => {
it("drops invalid ac — keeps combatant", () => {
for (const ac of [-1, 1.5, "15"]) {
const result = rehydrateCombatant({ ...minimalCombatant(), ac });
expect(result).not.toBeNull();
expect(result?.ac).toBeUndefined();
}
});
it("drops invalid maxHp — keeps combatant", () => {
for (const maxHp of [0, 1.5, "7"]) {
const result = rehydrateCombatant({ ...minimalCombatant(), maxHp });
expect(result).not.toBeNull();
expect(result?.maxHp).toBeUndefined();
}
});
it("falls back currentHp to maxHp when currentHp invalid", () => {
const result = rehydrateCombatant({
...minimalCombatant(),
maxHp: 10,
currentHp: "bad",
});
expect(result?.maxHp).toBe(10);
expect(result?.currentHp).toBe(10);
});
it("falls back currentHp to maxHp when currentHp > maxHp", () => {
const result = rehydrateCombatant({
...minimalCombatant(),
maxHp: 10,
currentHp: 15,
});
expect(result?.maxHp).toBe(10);
expect(result?.currentHp).toBe(10);
});
it("drops invalid initiative — keeps combatant", () => {
for (const initiative of [1.5, "12"]) {
const result = rehydrateCombatant({
...minimalCombatant(),
initiative,
});
expect(result).not.toBeNull();
expect(result?.initiative).toBeUndefined();
}
});
it("drops invalid conditions — keeps combatant", () => {
const result = rehydrateCombatant({
...minimalCombatant(),
conditions: "poisoned",
});
expect(result).not.toBeNull();
expect(result?.conditions).toBeUndefined();
});
it("drops unknown condition IDs", () => {
const result = rehydrateCombatant({
...minimalCombatant(),
conditions: ["fake-condition"],
});
expect(result).not.toBeNull();
expect(result?.conditions).toBeUndefined();
});
it("filters valid conditions from mixed array", () => {
const result = rehydrateCombatant({
...minimalCombatant(),
conditions: ["poisoned", "fake", "blinded"],
});
expect(result?.conditions).toEqual(["poisoned", "blinded"]);
});
it("drops invalid color — keeps combatant", () => {
const result = rehydrateCombatant({
...minimalCombatant(),
color: "neon",
});
expect(result).not.toBeNull();
expect(result?.color).toBeUndefined();
});
it("drops invalid icon — keeps combatant", () => {
const result = rehydrateCombatant({
...minimalCombatant(),
icon: "rocket",
});
expect(result).not.toBeNull();
expect(result?.icon).toBeUndefined();
});
it("drops isConcentrating when not strictly true", () => {
for (const isConcentrating of [false, "true", 1]) {
const result = rehydrateCombatant({
...minimalCombatant(),
isConcentrating,
});
expect(result).not.toBeNull();
expect(result?.isConcentrating).toBeUndefined();
}
});
it("drops invalid creatureId", () => {
for (const creatureId of ["", 42]) {
const result = rehydrateCombatant({
...minimalCombatant(),
creatureId,
});
expect(result).not.toBeNull();
expect(result?.creatureId).toBeUndefined();
}
});
it("drops invalid playerCharacterId", () => {
for (const playerCharacterId of ["", 42]) {
const result = rehydrateCombatant({
...minimalCombatant(),
playerCharacterId,
});
expect(result).not.toBeNull();
expect(result?.playerCharacterId).toBeUndefined();
}
});
it("drops invalid tempHp — keeps combatant", () => {
for (const tempHp of [-1, 1.5, "3"]) {
const result = rehydrateCombatant({
...minimalCombatant(),
tempHp,
});
expect(result).not.toBeNull();
expect(result?.tempHp).toBeUndefined();
}
});
it("preserves valid tempHp of 0", () => {
const result = rehydrateCombatant({
...minimalCombatant(),
tempHp: 0,
});
expect(result?.tempHp).toBe(0);
});
});
});
@@ -0,0 +1,136 @@
import { describe, expect, it } from "vitest";
import { rehydratePlayerCharacter } from "../rehydrate-player-character.js";
function validPc(overrides: Record<string, unknown> = {}) {
return {
id: "pc-1",
name: "Aria",
ac: 16,
maxHp: 45,
color: "blue",
icon: "sword",
level: 5,
...overrides,
};
}
describe("rehydratePlayerCharacter", () => {
describe("valid input", () => {
it("accepts a valid PC with all fields", () => {
const result = rehydratePlayerCharacter(validPc());
expect(result).not.toBeNull();
expect(result?.name).toBe("Aria");
expect(result?.ac).toBe(16);
expect(result?.maxHp).toBe(45);
expect(result?.color).toBe("blue");
expect(result?.icon).toBe("sword");
expect(result?.level).toBe(5);
});
it("accepts a valid PC without optional color/icon/level", () => {
const result = rehydratePlayerCharacter(
validPc({ color: undefined, icon: undefined, level: undefined }),
);
expect(result).not.toBeNull();
expect(result?.color).toBeUndefined();
expect(result?.icon).toBeUndefined();
expect(result?.level).toBeUndefined();
});
it("preserves branded PlayerCharacterId", () => {
const result = rehydratePlayerCharacter(validPc());
expect(result?.id).toBe("pc-1");
});
});
describe("required field rejection", () => {
it.each([
null,
42,
"string",
[1, 2],
undefined,
])("rejects non-object input: %j", (input) => {
expect(rehydratePlayerCharacter(input)).toBeNull();
});
it("rejects missing id", () => {
const { id: _, ...rest } = validPc();
expect(rehydratePlayerCharacter(rest)).toBeNull();
});
it("rejects empty id", () => {
expect(rehydratePlayerCharacter(validPc({ id: "" }))).toBeNull();
});
it("rejects missing name", () => {
const { name: _, ...rest } = validPc();
expect(rehydratePlayerCharacter(rest)).toBeNull();
});
it("rejects empty/whitespace name", () => {
expect(rehydratePlayerCharacter(validPc({ name: "" }))).toBeNull();
expect(rehydratePlayerCharacter(validPc({ name: " " }))).toBeNull();
});
it("rejects missing ac", () => {
const { ac: _, ...rest } = validPc();
expect(rehydratePlayerCharacter(rest)).toBeNull();
});
it("rejects negative ac", () => {
expect(rehydratePlayerCharacter(validPc({ ac: -1 }))).toBeNull();
});
it("rejects float ac", () => {
expect(rehydratePlayerCharacter(validPc({ ac: 1.5 }))).toBeNull();
});
it("rejects string ac", () => {
expect(rehydratePlayerCharacter(validPc({ ac: "16" }))).toBeNull();
});
it("rejects missing maxHp", () => {
const { maxHp: _, ...rest } = validPc();
expect(rehydratePlayerCharacter(rest)).toBeNull();
});
it("rejects maxHp of 0", () => {
expect(rehydratePlayerCharacter(validPc({ maxHp: 0 }))).toBeNull();
});
it("rejects float maxHp", () => {
expect(rehydratePlayerCharacter(validPc({ maxHp: 1.5 }))).toBeNull();
});
it("rejects string maxHp", () => {
expect(rehydratePlayerCharacter(validPc({ maxHp: "45" }))).toBeNull();
});
});
describe("optional field rejection (strict)", () => {
it("rejects invalid color", () => {
expect(rehydratePlayerCharacter(validPc({ color: "neon" }))).toBeNull();
});
it("rejects invalid icon", () => {
expect(rehydratePlayerCharacter(validPc({ icon: "rocket" }))).toBeNull();
});
it("rejects level 0", () => {
expect(rehydratePlayerCharacter(validPc({ level: 0 }))).toBeNull();
});
it("rejects level 21", () => {
expect(rehydratePlayerCharacter(validPc({ level: 21 }))).toBeNull();
});
it("rejects float level", () => {
expect(rehydratePlayerCharacter(validPc({ level: 3.5 }))).toBeNull();
});
it("rejects string level", () => {
expect(rehydratePlayerCharacter(validPc({ level: "5" }))).toBeNull();
});
});
});
+10 -12
View File
@@ -1,5 +1,11 @@
import type { DomainEvent } from "./events.js"; import type { DomainEvent } from "./events.js";
import type { CombatantId, DomainError, Encounter } from "./types.js"; import {
type CombatantId,
type DomainError,
type Encounter,
findCombatant,
isDomainError,
} from "./types.js";
export interface AdjustHpSuccess { export interface AdjustHpSuccess {
readonly encounter: Encounter; readonly encounter: Encounter;
@@ -17,17 +23,9 @@ export function adjustHp(
combatantId: CombatantId, combatantId: CombatantId,
delta: number, delta: number,
): AdjustHpSuccess | DomainError { ): AdjustHpSuccess | DomainError {
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId); const found = findCombatant(encounter, combatantId);
if (isDomainError(found)) return found;
if (targetIdx === -1) { const { combatant: target } = found;
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) { if (target.maxHp === undefined || target.currentHp === undefined) {
return { return {
+10 -12
View File
@@ -1,5 +1,11 @@
import type { DomainEvent } from "./events.js"; import type { DomainEvent } from "./events.js";
import type { CombatantId, DomainError, Encounter } from "./types.js"; import {
type CombatantId,
type DomainError,
type Encounter,
findCombatant,
isDomainError,
} from "./types.js";
export interface EditCombatantSuccess { export interface EditCombatantSuccess {
readonly encounter: Encounter; readonly encounter: Encounter;
@@ -30,17 +36,9 @@ export function editCombatant(
}; };
} }
const index = encounter.combatants.findIndex((c) => c.id === id); const found = findCombatant(encounter, id);
if (isDomainError(found)) return found;
if (index === -1) { const oldName = found.combatant.name;
return {
kind: "domain-error",
code: "combatant-not-found",
message: `No combatant found with ID "${id}"`,
};
}
const oldName = encounter.combatants[index].name;
return { return {
encounter: { encounter: {
+3
View File
@@ -94,6 +94,8 @@ export {
VALID_PLAYER_COLORS, VALID_PLAYER_COLORS,
VALID_PLAYER_ICONS, VALID_PLAYER_ICONS,
} from "./player-character-types.js"; } from "./player-character-types.js";
export { rehydrateCombatant } from "./rehydrate-combatant.js";
export { rehydratePlayerCharacter } from "./rehydrate-player-character.js";
export { export {
type RemoveCombatantSuccess, type RemoveCombatantSuccess,
removeCombatant, removeCombatant,
@@ -126,6 +128,7 @@ export {
createEncounter, createEncounter,
type DomainError, type DomainError,
type Encounter, type Encounter,
findCombatant,
isDomainError, isDomainError,
} from "./types.js"; } from "./types.js";
export { export {
+106
View File
@@ -0,0 +1,106 @@
import type { ConditionId } from "./conditions.js";
import { VALID_CONDITION_IDS } from "./conditions.js";
import { creatureId } from "./creature-types.js";
import {
playerCharacterId,
VALID_PLAYER_COLORS,
VALID_PLAYER_ICONS,
} from "./player-character-types.js";
import type { Combatant } from "./types.js";
import { combatantId } from "./types.js";
function validateAc(value: unknown): number | undefined {
return typeof value === "number" && Number.isInteger(value) && value >= 0
? value
: undefined;
}
function validateConditions(value: unknown): ConditionId[] | undefined {
if (!Array.isArray(value)) return undefined;
const valid = value.filter(
(v): v is ConditionId =>
typeof v === "string" && VALID_CONDITION_IDS.has(v),
);
return valid.length > 0 ? valid : undefined;
}
function validateHp(
rawMaxHp: unknown,
rawCurrentHp: unknown,
): { maxHp: number; currentHp: number } | undefined {
if (
typeof rawMaxHp !== "number" ||
!Number.isInteger(rawMaxHp) ||
rawMaxHp < 1
) {
return undefined;
}
const validCurrentHp =
typeof rawCurrentHp === "number" &&
Number.isInteger(rawCurrentHp) &&
rawCurrentHp >= 0 &&
rawCurrentHp <= rawMaxHp;
return {
maxHp: rawMaxHp,
currentHp: validCurrentHp ? rawCurrentHp : rawMaxHp,
};
}
function validateTempHp(value: unknown): number | undefined {
return typeof value === "number" && Number.isInteger(value) && value >= 0
? value
: undefined;
}
function validateInteger(value: unknown): number | undefined {
return typeof value === "number" && Number.isInteger(value)
? value
: undefined;
}
function validateSetMember(
value: unknown,
valid: ReadonlySet<string>,
): string | undefined {
return typeof value === "string" && valid.has(value) ? value : undefined;
}
function validateNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function parseOptionalFields(entry: Record<string, unknown>) {
return {
initiative: validateInteger(entry.initiative),
ac: validateAc(entry.ac),
conditions: validateConditions(entry.conditions),
isConcentrating: entry.isConcentrating === true ? true : undefined,
creatureId: validateNonEmptyString(entry.creatureId)
? creatureId(entry.creatureId as string)
: undefined,
color: validateSetMember(entry.color, VALID_PLAYER_COLORS),
icon: validateSetMember(entry.icon, VALID_PLAYER_ICONS),
playerCharacterId: validateNonEmptyString(entry.playerCharacterId)
? playerCharacterId(entry.playerCharacterId as string)
: undefined,
tempHp: validateTempHp(entry.tempHp),
};
}
export function rehydrateCombatant(raw: unknown): Combatant | null {
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
return null;
const entry = raw as Record<string, unknown>;
if (typeof entry.id !== "string" || entry.id.length === 0) return null;
if (typeof entry.name !== "string") return null;
const shared: Combatant = {
id: combatantId(entry.id),
name: entry.name,
...parseOptionalFields(entry),
};
const hp = validateHp(entry.maxHp, entry.currentHp);
return hp ? { ...shared, ...hp } : shared;
}
@@ -0,0 +1,55 @@
import type { PlayerCharacter } from "./player-character-types.js";
import {
playerCharacterId,
VALID_PLAYER_COLORS,
VALID_PLAYER_ICONS,
} from "./player-character-types.js";
function isValidOptionalMember(
value: unknown,
valid: ReadonlySet<string>,
): boolean {
return value === undefined || (typeof value === "string" && valid.has(value));
}
export function rehydratePlayerCharacter(raw: unknown): PlayerCharacter | null {
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
return null;
const entry = raw as Record<string, unknown>;
if (typeof entry.id !== "string" || entry.id.length === 0) return null;
if (typeof entry.name !== "string" || entry.name.trim().length === 0)
return null;
if (
typeof entry.ac !== "number" ||
!Number.isInteger(entry.ac) ||
entry.ac < 0
)
return null;
if (
typeof entry.maxHp !== "number" ||
!Number.isInteger(entry.maxHp) ||
entry.maxHp < 1
)
return null;
if (!isValidOptionalMember(entry.color, VALID_PLAYER_COLORS)) return null;
if (!isValidOptionalMember(entry.icon, VALID_PLAYER_ICONS)) return null;
if (
entry.level !== undefined &&
(typeof entry.level !== "number" ||
!Number.isInteger(entry.level) ||
entry.level < 1 ||
entry.level > 20)
)
return null;
return {
id: playerCharacterId(entry.id),
name: entry.name,
ac: entry.ac,
maxHp: entry.maxHp,
color: entry.color as PlayerCharacter["color"],
icon: entry.icon as PlayerCharacter["icon"],
level: entry.level,
};
}
+10 -11
View File
@@ -1,5 +1,11 @@
import type { DomainEvent } from "./events.js"; import type { DomainEvent } from "./events.js";
import type { CombatantId, DomainError, Encounter } from "./types.js"; import {
type CombatantId,
type DomainError,
type Encounter,
findCombatant,
isDomainError,
} from "./types.js";
export interface RemoveCombatantSuccess { export interface RemoveCombatantSuccess {
readonly encounter: Encounter; readonly encounter: Encounter;
@@ -22,17 +28,10 @@ export function removeCombatant(
encounter: Encounter, encounter: Encounter,
id: CombatantId, id: CombatantId,
): RemoveCombatantSuccess | DomainError { ): RemoveCombatantSuccess | DomainError {
const removedIdx = encounter.combatants.findIndex((c) => c.id === id); const found = findCombatant(encounter, id);
if (isDomainError(found)) return found;
if (removedIdx === -1) { const { index: removedIdx, combatant: removed } = found;
return {
kind: "domain-error",
code: "combatant-not-found",
message: `No combatant found with ID "${id}"`,
};
}
const removed = encounter.combatants[removedIdx];
const newCombatants = encounter.combatants.filter((_, i) => i !== removedIdx); const newCombatants = encounter.combatants.filter((_, i) => i !== removedIdx);
let newActiveIndex: number; let newActiveIndex: number;
+10 -12
View File
@@ -1,5 +1,11 @@
import type { DomainEvent } from "./events.js"; import type { DomainEvent } from "./events.js";
import type { CombatantId, DomainError, Encounter } from "./types.js"; import {
type CombatantId,
type DomainError,
type Encounter,
findCombatant,
isDomainError,
} from "./types.js";
export interface SetAcSuccess { export interface SetAcSuccess {
readonly encounter: Encounter; readonly encounter: Encounter;
@@ -11,15 +17,8 @@ export function setAc(
combatantId: CombatantId, combatantId: CombatantId,
value: number | undefined, value: number | undefined,
): SetAcSuccess | DomainError { ): SetAcSuccess | DomainError {
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId); const found = findCombatant(encounter, combatantId);
if (isDomainError(found)) return found;
if (targetIdx === -1) {
return {
kind: "domain-error",
code: "combatant-not-found",
message: `No combatant found with ID "${combatantId}"`,
};
}
if (value !== undefined && (!Number.isInteger(value) || value < 0)) { if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
return { return {
@@ -29,8 +28,7 @@ export function setAc(
}; };
} }
const target = encounter.combatants[targetIdx]; const previousAc = found.combatant.ac;
const previousAc = target.ac;
const updatedCombatants = encounter.combatants.map((c) => const updatedCombatants = encounter.combatants.map((c) =>
c.id === combatantId ? { ...c, ac: value } : c, c.id === combatantId ? { ...c, ac: value } : c,
+11 -13
View File
@@ -1,5 +1,11 @@
import type { DomainEvent } from "./events.js"; import type { DomainEvent } from "./events.js";
import type { CombatantId, DomainError, Encounter } from "./types.js"; import {
type CombatantId,
type DomainError,
type Encounter,
findCombatant,
isDomainError,
} from "./types.js";
export interface SetHpSuccess { export interface SetHpSuccess {
readonly encounter: Encounter; readonly encounter: Encounter;
@@ -18,15 +24,8 @@ export function setHp(
combatantId: CombatantId, combatantId: CombatantId,
maxHp: number | undefined, maxHp: number | undefined,
): SetHpSuccess | DomainError { ): SetHpSuccess | DomainError {
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId); const found = findCombatant(encounter, combatantId);
if (isDomainError(found)) return found;
if (targetIdx === -1) {
return {
kind: "domain-error",
code: "combatant-not-found",
message: `No combatant found with ID "${combatantId}"`,
};
}
if (maxHp !== undefined && (!Number.isInteger(maxHp) || maxHp < 1)) { if (maxHp !== undefined && (!Number.isInteger(maxHp) || maxHp < 1)) {
return { return {
@@ -36,9 +35,8 @@ export function setHp(
}; };
} }
const target = encounter.combatants[targetIdx]; const previousMaxHp = found.combatant.maxHp;
const previousMaxHp = target.maxHp; const previousCurrentHp = found.combatant.currentHp;
const previousCurrentHp = target.currentHp;
let newMaxHp: number | undefined; let newMaxHp: number | undefined;
let newCurrentHp: number | undefined; let newCurrentHp: number | undefined;
+10 -12
View File
@@ -1,6 +1,12 @@
import type { DomainEvent } from "./events.js"; import type { DomainEvent } from "./events.js";
import { sortByInitiative } from "./initiative-sort.js"; import { sortByInitiative } from "./initiative-sort.js";
import type { CombatantId, DomainError, Encounter } from "./types.js"; import {
type CombatantId,
type DomainError,
type Encounter,
findCombatant,
isDomainError,
} from "./types.js";
export interface SetInitiativeSuccess { export interface SetInitiativeSuccess {
readonly encounter: Encounter; readonly encounter: Encounter;
@@ -24,15 +30,8 @@ export function setInitiative(
combatantId: CombatantId, combatantId: CombatantId,
value: number | undefined, value: number | undefined,
): SetInitiativeSuccess | DomainError { ): SetInitiativeSuccess | DomainError {
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId); const found = findCombatant(encounter, combatantId);
if (isDomainError(found)) return found;
if (targetIdx === -1) {
return {
kind: "domain-error",
code: "combatant-not-found",
message: `No combatant found with ID "${combatantId}"`,
};
}
if (value !== undefined && !Number.isInteger(value)) { if (value !== undefined && !Number.isInteger(value)) {
return { return {
@@ -42,8 +41,7 @@ export function setInitiative(
}; };
} }
const target = encounter.combatants[targetIdx]; const previousValue = found.combatant.initiative;
const previousValue = target.initiative;
// Create new combatants array with updated initiative // Create new combatants array with updated initiative
const updated = encounter.combatants.map((c) => const updated = encounter.combatants.map((c) =>
+10 -12
View File
@@ -1,5 +1,11 @@
import type { DomainEvent } from "./events.js"; import type { DomainEvent } from "./events.js";
import type { CombatantId, DomainError, Encounter } from "./types.js"; import {
type CombatantId,
type DomainError,
type Encounter,
findCombatant,
isDomainError,
} from "./types.js";
export interface SetTempHpSuccess { export interface SetTempHpSuccess {
readonly encounter: Encounter; readonly encounter: Encounter;
@@ -18,17 +24,9 @@ export function setTempHp(
combatantId: CombatantId, combatantId: CombatantId,
tempHp: number | undefined, tempHp: number | undefined,
): SetTempHpSuccess | DomainError { ): SetTempHpSuccess | DomainError {
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId); const found = findCombatant(encounter, combatantId);
if (isDomainError(found)) return found;
if (targetIdx === -1) { const { combatant: target } = found;
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) { if (target.maxHp === undefined || target.currentHp === undefined) {
return { return {
+10 -12
View File
@@ -1,5 +1,11 @@
import type { DomainEvent } from "./events.js"; import type { DomainEvent } from "./events.js";
import type { CombatantId, DomainError, Encounter } from "./types.js"; import {
type CombatantId,
type DomainError,
type Encounter,
findCombatant,
isDomainError,
} from "./types.js";
export interface ToggleConcentrationSuccess { export interface ToggleConcentrationSuccess {
readonly encounter: Encounter; readonly encounter: Encounter;
@@ -10,17 +16,9 @@ export function toggleConcentration(
encounter: Encounter, encounter: Encounter,
combatantId: CombatantId, combatantId: CombatantId,
): ToggleConcentrationSuccess | DomainError { ): ToggleConcentrationSuccess | DomainError {
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId); const found = findCombatant(encounter, combatantId);
if (isDomainError(found)) return found;
if (targetIdx === -1) { const { combatant: target } = found;
return {
kind: "domain-error",
code: "combatant-not-found",
message: `No combatant found with ID "${combatantId}"`,
};
}
const target = encounter.combatants[targetIdx];
const wasConcentrating = target.isConcentrating === true; const wasConcentrating = target.isConcentrating === true;
const event: DomainEvent = wasConcentrating const event: DomainEvent = wasConcentrating
+10 -12
View File
@@ -1,7 +1,13 @@
import type { ConditionId } from "./conditions.js"; import type { ConditionId } from "./conditions.js";
import { CONDITION_DEFINITIONS, VALID_CONDITION_IDS } from "./conditions.js"; import { CONDITION_DEFINITIONS, VALID_CONDITION_IDS } from "./conditions.js";
import type { DomainEvent } from "./events.js"; import type { DomainEvent } from "./events.js";
import type { CombatantId, DomainError, Encounter } from "./types.js"; import {
type CombatantId,
type DomainError,
type Encounter,
findCombatant,
isDomainError,
} from "./types.js";
export interface ToggleConditionSuccess { export interface ToggleConditionSuccess {
readonly encounter: Encounter; readonly encounter: Encounter;
@@ -21,17 +27,9 @@ export function toggleCondition(
}; };
} }
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId); const found = findCombatant(encounter, combatantId);
if (isDomainError(found)) return found;
if (targetIdx === -1) { const { combatant: target } = found;
return {
kind: "domain-error",
code: "combatant-not-found",
message: `No combatant found with ID "${combatantId}"`,
};
}
const target = encounter.combatants[targetIdx];
const current = target.conditions ?? []; const current = target.conditions ?? [];
const isActive = current.includes(conditionId); const isActive = current.includes(conditionId);
+14
View File
@@ -70,6 +70,20 @@ export function createEncounter(
return { combatants, activeIndex, roundNumber }; return { combatants, activeIndex, roundNumber };
} }
export function findCombatant(
encounter: Encounter,
id: CombatantId,
): { index: number; combatant: Combatant } | DomainError {
const index = encounter.combatants.findIndex((c) => c.id === id);
if (index === -1) {
return domainError(
"combatant-not-found",
`No combatant found with ID "${id}"`,
);
}
return { index, combatant: encounter.combatants[index] };
}
export function isDomainError(value: unknown): value is DomainError { export function isDomainError(value: unknown): value is DomainError {
return ( return (
typeof value === "object" && typeof value === "object" &&
+119
View File
@@ -21,6 +21,9 @@ importers:
jscpd: jscpd:
specifier: ^4.0.8 specifier: ^4.0.8
version: 4.0.8 version: 4.0.8
jsinspect-plus:
specifier: ^3.1.3
version: 3.1.3
knip: knip:
specifier: ^5.88.1 specifier: ^5.88.1
version: 5.88.1(@types/node@25.3.3)(typescript@5.9.3) version: 5.88.1(@types/node@25.3.3)(typescript@5.9.3)
@@ -133,15 +136,28 @@ packages:
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/helper-string-parser@8.0.0-rc.3':
resolution: {integrity: sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==}
engines: {node: ^20.19.0 || >=22.12.0}
'@babel/helper-validator-identifier@7.28.5': '@babel/helper-validator-identifier@7.28.5':
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@8.0.0-rc.3':
resolution: {integrity: sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==}
engines: {node: ^20.19.0 || >=22.12.0}
'@babel/parser@7.29.0': '@babel/parser@7.29.0':
resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
'@babel/parser@8.0.0-rc.3':
resolution: {integrity: sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
'@babel/runtime@7.28.6': '@babel/runtime@7.28.6':
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -150,6 +166,10 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/types@8.0.0-rc.3':
resolution: {integrity: sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==}
engines: {node: ^20.19.0 || >=22.12.0}
'@bcoe/v8-coverage@1.0.2': '@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -898,6 +918,10 @@ packages:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
ansi-styles@3.2.1:
resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==}
engines: {node: '>=4'}
ansi-styles@5.2.0: ansi-styles@5.2.0:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -956,6 +980,10 @@ packages:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'} engines: {node: '>=18'}
chalk@2.4.2:
resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
engines: {node: '>=4'}
character-parser@2.2.0: character-parser@2.2.0:
resolution: {integrity: sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==} resolution: {integrity: sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==}
@@ -970,10 +998,19 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'} engines: {node: '>=6'}
color-convert@1.9.3:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
color-name@1.1.3:
resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==}
colors@1.4.0: colors@1.4.0:
resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==} resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==}
engines: {node: '>=0.1.90'} engines: {node: '>=0.1.90'}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
commander@5.1.0: commander@5.1.0:
resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -1055,6 +1092,10 @@ packages:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
escape-string-regexp@1.0.5:
resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
engines: {node: '>=0.8.0'}
estree-walker@3.0.3: estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
@@ -1088,6 +1129,9 @@ packages:
picomatch: picomatch:
optional: true optional: true
filepaths@0.3.0:
resolution: {integrity: sha512-QFAYdzHZxWfBOHtHIlZySPAej+pxz6c2TGe8LGgHQNsgxHmcfbbQfNmsIh0kaangjL+6D6g8IoR6VDnOFrLEFw==}
fill-range@7.1.1: fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1136,6 +1180,10 @@ packages:
graceful-fs@4.2.11: graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
has-flag@3.0.0:
resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
engines: {node: '>=4'}
has-flag@4.0.0: has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1251,6 +1299,10 @@ packages:
canvas: canvas:
optional: true optional: true
jsinspect-plus@3.1.3:
resolution: {integrity: sha512-0GbLXDlfz9nPuybM/QunzEYKTwaETxGJ5+V7vZFS7+l8w426ePVU77dBH6k+KrxiJemIgVwY6Yxr3PCzFJwxgw==}
hasBin: true
jsonfile@6.2.0: jsonfile@6.2.0:
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
@@ -1650,6 +1702,10 @@ packages:
spark-md5@3.0.2: spark-md5@3.0.2:
resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==} resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==}
stable@0.1.8:
resolution: {integrity: sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==}
deprecated: 'Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility'
stackback@0.0.2: stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
@@ -1672,10 +1728,18 @@ packages:
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
strip-json-comments@3.1.1:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
strip-json-comments@5.0.3: strip-json-comments@5.0.3:
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
engines: {node: '>=14.16'} engines: {node: '>=14.16'}
supports-color@5.5.0:
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
engines: {node: '>=4'}
supports-color@7.2.0: supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1921,12 +1985,20 @@ snapshots:
'@babel/helper-string-parser@7.27.1': {} '@babel/helper-string-parser@7.27.1': {}
'@babel/helper-string-parser@8.0.0-rc.3': {}
'@babel/helper-validator-identifier@7.28.5': {} '@babel/helper-validator-identifier@7.28.5': {}
'@babel/helper-validator-identifier@8.0.0-rc.3': {}
'@babel/parser@7.29.0': '@babel/parser@7.29.0':
dependencies: dependencies:
'@babel/types': 7.29.0 '@babel/types': 7.29.0
'@babel/parser@8.0.0-rc.3':
dependencies:
'@babel/types': 8.0.0-rc.3
'@babel/runtime@7.28.6': {} '@babel/runtime@7.28.6': {}
'@babel/types@7.29.0': '@babel/types@7.29.0':
@@ -1934,6 +2006,11 @@ snapshots:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
'@babel/types@8.0.0-rc.3':
dependencies:
'@babel/helper-string-parser': 8.0.0-rc.3
'@babel/helper-validator-identifier': 8.0.0-rc.3
'@bcoe/v8-coverage@1.0.2': {} '@bcoe/v8-coverage@1.0.2': {}
'@biomejs/biome@2.4.8': '@biomejs/biome@2.4.8':
@@ -2481,6 +2558,10 @@ snapshots:
ansi-regex@5.0.1: {} ansi-regex@5.0.1: {}
ansi-styles@3.2.1:
dependencies:
color-convert: 1.9.3
ansi-styles@5.2.0: {} ansi-styles@5.2.0: {}
aria-query@5.3.0: aria-query@5.3.0:
@@ -2534,6 +2615,12 @@ snapshots:
chai@6.2.2: {} chai@6.2.2: {}
chalk@2.4.2:
dependencies:
ansi-styles: 3.2.1
escape-string-regexp: 1.0.5
supports-color: 5.5.0
character-parser@2.2.0: character-parser@2.2.0:
dependencies: dependencies:
is-regex: 1.2.1 is-regex: 1.2.1
@@ -2550,8 +2637,16 @@ snapshots:
clsx@2.1.1: {} clsx@2.1.1: {}
color-convert@1.9.3:
dependencies:
color-name: 1.1.3
color-name@1.1.3: {}
colors@1.4.0: {} colors@1.4.0: {}
commander@2.20.3: {}
commander@5.1.0: {} commander@5.1.0: {}
constantinople@4.0.1: constantinople@4.0.1:
@@ -2624,6 +2719,8 @@ snapshots:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
escape-string-regexp@1.0.5: {}
estree-walker@3.0.3: estree-walker@3.0.3:
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
@@ -2664,6 +2761,8 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.4 picomatch: 4.0.4
filepaths@0.3.0: {}
fill-range@7.1.1: fill-range@7.1.1:
dependencies: dependencies:
to-regex-range: 5.0.1 to-regex-range: 5.0.1
@@ -2715,6 +2814,8 @@ snapshots:
graceful-fs@4.2.11: {} graceful-fs@4.2.11: {}
has-flag@3.0.0: {}
has-flag@4.0.0: {} has-flag@4.0.0: {}
has-symbols@1.1.0: {} has-symbols@1.1.0: {}
@@ -2841,6 +2942,16 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- '@noble/hashes' - '@noble/hashes'
jsinspect-plus@3.1.3:
dependencies:
'@babel/parser': 8.0.0-rc.3
chalk: 2.4.2
commander: 2.20.3
filepaths: 0.3.0
stable: 0.1.8
strip-indent: 3.0.0
strip-json-comments: 3.1.1
jsonfile@6.2.0: jsonfile@6.2.0:
dependencies: dependencies:
universalify: 2.0.1 universalify: 2.0.1
@@ -3268,6 +3379,8 @@ snapshots:
spark-md5@3.0.2: {} spark-md5@3.0.2: {}
stable@0.1.8: {}
stackback@0.0.2: {} stackback@0.0.2: {}
std-env@4.0.0: {} std-env@4.0.0: {}
@@ -3288,8 +3401,14 @@ snapshots:
dependencies: dependencies:
min-indent: 1.0.1 min-indent: 1.0.1
strip-json-comments@3.1.1: {}
strip-json-comments@5.0.3: {} strip-json-comments@5.0.3: {}
supports-color@5.5.0:
dependencies:
has-flag: 3.0.0
supports-color@7.2.0: supports-color@7.2.0:
dependencies: dependencies:
has-flag: 4.0.0 has-flag: 4.0.0