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,
getConditionsForEdition,
} from "@initiative/domain";
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";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
import { useClickOutside } from "../hooks/use-click-outside.js";
import { cn } from "../lib/utils";
import {
CONDITION_COLOR_CLASSES,
CONDITION_ICON_MAP,
} from "./condition-styles.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 {
anchorRef: React.RefObject<HTMLElement | null>;
activeConditions: readonly ConditionId[] | undefined;
@@ -104,15 +55,7 @@ export function ConditionPicker({
setPos({ top, left: anchorRect.left, maxHeight });
}, [anchorRef]);
useEffect(() => {
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]);
useClickOutside(ref, onClose);
const { edition } = useRulesEditionContext();
const conditions = getConditionsForEdition(edition);
@@ -129,10 +72,11 @@ export function ConditionPicker({
}
>
{conditions.map((def) => {
const Icon = ICON_MAP[def.iconName];
const Icon = CONDITION_ICON_MAP[def.iconName];
if (!Icon) return null;
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 (
<Tooltip
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,
getConditionDescription,
} from "@initiative/domain";
import type { LucideIcon } 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 { Plus } from "lucide-react";
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
import { cn } from "../lib/utils.js";
import {
CONDITION_COLOR_CLASSES,
CONDITION_ICON_MAP,
} from "./condition-styles.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 {
conditions: readonly ConditionId[] | undefined;
onRemove: (conditionId: ConditionId) => void;
@@ -79,9 +29,10 @@ export function ConditionTags({
{conditions?.map((condId) => {
const def = CONDITION_DEFINITIONS.find((d) => d.id === condId);
if (!def) return null;
const Icon = ICON_MAP[def.iconName];
const Icon = CONDITION_ICON_MAP[def.iconName];
if (!Icon) return null;
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
const colorClass =
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
return (
<Tooltip
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 { Button } from "./ui/button.js";
import { Dialog } from "./ui/dialog.js";
import { Dialog, DialogHeader } from "./ui/dialog.js";
import { Input } from "./ui/input.js";
interface ExportMethodDialogProps {
@@ -30,18 +29,7 @@ export function ExportMethodDialog({
return (
<Dialog open={open} onClose={handleClose} className="w-80">
<div className="mb-4 flex items-center justify-between">
<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>
<DialogHeader title="Export Encounter" onClose={handleClose} />
<div className="mb-3">
<Input
type="text"
@@ -6,6 +6,7 @@ import {
useRef,
useState,
} from "react";
import { useClickOutside } from "../hooks/use-click-outside.js";
import { Input } from "./ui/input";
const DIGITS_ONLY_REGEX = /^\d+$/;
@@ -48,15 +49,7 @@ export function HpAdjustPopover({
requestAnimationFrame(() => inputRef.current?.focus());
}, []);
useEffect(() => {
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]);
useClickOutside(ref, onClose);
const parsedValue =
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 { Button } from "./ui/button.js";
import { Dialog } from "./ui/dialog.js";
import { Dialog, DialogHeader } from "./ui/dialog.js";
interface ImportMethodDialogProps {
open: boolean;
@@ -41,18 +41,7 @@ export function ImportMethodDialog({
return (
<Dialog open={open} onClose={handleClose} className="w-80">
<div className="mb-4 flex items-center justify-between">
<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>
<DialogHeader title="Import Encounter" onClose={handleClose} />
{mode === "pick" && (
<div className="flex flex-col gap-2">
<button
+3 -15
View File
@@ -1,9 +1,9 @@
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 { Button } from "./ui/button";
import { ConfirmButton } from "./ui/confirm-button";
import { Dialog } from "./ui/dialog";
import { Dialog, DialogHeader } from "./ui/dialog";
interface PlayerManagementProps {
open: boolean;
@@ -24,19 +24,7 @@ export function PlayerManagement({
}: Readonly<PlayerManagementProps>) {
return (
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
<div className="mb-4 flex items-center justify-between">
<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>
<DialogHeader title="Player Characters" onClose={onClose} />
{characters.length === 0 ? (
<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 { 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 {
readonly position: { x: number; y: number };
@@ -34,22 +35,7 @@ export function RollModeMenu({
setPos({ top, left });
}, [position.x, position.y]);
useEffect(() => {
function handleMouseDown(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [onClose]);
useClickOutside(ref, onClose);
return (
<div
+3 -14
View File
@@ -1,10 +1,9 @@
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 { useThemeContext } from "../contexts/theme-context.js";
import { cn } from "../lib/utils.js";
import { Button } from "./ui/button.js";
import { Dialog } from "./ui/dialog.js";
import { Dialog, DialogHeader } from "./ui/dialog.js";
interface SettingsModalProps {
open: boolean;
@@ -32,17 +31,7 @@ export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
return (
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-sm">
<div className="mb-4 flex items-center justify-between">
<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>
<DialogHeader title="Settings" onClose={onClose} />
<div className="flex flex-col gap-5">
<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>) {
const abilities = [
{ label: "STR", score: creature.abilities.str },
@@ -134,19 +159,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
</div>
</div>
{/* 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>
</>
)}
<TraitSection entries={creature.traits} />
{/* Spellcasting */}
{creature.spellcasting && creature.spellcasting.length > 0 && (
@@ -190,52 +203,9 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
</>
)}
{/* Actions */}
{creature.actions && creature.actions.length > 0 && (
<>
<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>
</>
)}
<TraitSection entries={creature.actions} heading="Actions" />
<TraitSection entries={creature.bonusActions} heading="Bonus Actions" />
<TraitSection entries={creature.reactions} heading="Reactions" />
{/* Legendary Actions */}
{!!creature.legendaryActions && (
+2 -26
View File
@@ -6,6 +6,7 @@ import {
useRef,
useState,
} from "react";
import { useClickOutside } from "../../hooks/use-click-outside.js";
import { cn } from "../../lib/utils";
import { Button } from "./button";
@@ -42,32 +43,7 @@ export function ConfirmButton({
return () => clearTimeout(timerRef.current);
}, []);
// Click-outside listener when confirming
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]);
useClickOutside(wrapperRef, revert, isConfirming);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
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 { cn } from "../../lib/utils.js";
import { Button } from "./button.js";
interface DialogProps {
open: boolean;
@@ -48,3 +50,22 @@ export function Dialog({ open, onClose, className, children }: DialogProps) {
</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 { 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";
export interface OverflowMenuItem {
@@ -18,23 +19,7 @@ export function OverflowMenu({ items }: OverflowMenuProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
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]);
useClickOutside(ref, () => setOpen(false), open);
return (
<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,
ConditionId,
CreatureId,
DomainError,
DomainEvent,
Encounter,
PlayerCharacter,
@@ -120,167 +121,90 @@ export function useEncounter() {
return result;
}, []);
const advanceTurn = useCallback(() => {
const result = withUndo(() => advanceTurnUseCase(makeStore()));
if (isDomainError(result)) {
return;
}
const dispatchAction = useCallback(
(action: () => DomainEvent[] | DomainError) => {
const result = withUndo(action);
if (!isDomainError(result)) {
setEvents((prev) => [...prev, ...result]);
}, [makeStore, withUndo]);
const retreatTurn = useCallback(() => {
const result = withUndo(() => retreatTurnUseCase(makeStore()));
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
}, [makeStore, withUndo]);
},
[withUndo],
);
const nextId = useRef(deriveNextId(encounter));
const advanceTurn = useCallback(
() => dispatchAction(() => advanceTurnUseCase(makeStore())),
[makeStore, dispatchAction],
);
const retreatTurn = useCallback(
() => dispatchAction(() => retreatTurnUseCase(makeStore())),
[makeStore, dispatchAction],
);
const addCombatant = useCallback(
(name: string, init?: CombatantInit) => {
const id = combatantId(`c-${++nextId.current}`);
const result = withUndo(() =>
addCombatantUseCase(makeStore(), id, name, init),
);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
dispatchAction(() => addCombatantUseCase(makeStore(), id, name, init));
},
[makeStore, withUndo],
[makeStore, dispatchAction],
);
const removeCombatant = useCallback(
(id: CombatantId) => {
const result = withUndo(() => removeCombatantUseCase(makeStore(), id));
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore, withUndo],
(id: CombatantId) =>
dispatchAction(() => removeCombatantUseCase(makeStore(), id)),
[makeStore, dispatchAction],
);
const editCombatant = useCallback(
(id: CombatantId, newName: string) => {
const result = withUndo(() =>
editCombatantUseCase(makeStore(), id, newName),
);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore, withUndo],
(id: CombatantId, newName: string) =>
dispatchAction(() => editCombatantUseCase(makeStore(), id, newName)),
[makeStore, dispatchAction],
);
const setInitiative = useCallback(
(id: CombatantId, value: number | undefined) => {
const result = withUndo(() =>
setInitiativeUseCase(makeStore(), id, value),
);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore, withUndo],
(id: CombatantId, value: number | undefined) =>
dispatchAction(() => setInitiativeUseCase(makeStore(), id, value)),
[makeStore, dispatchAction],
);
const setHp = useCallback(
(id: CombatantId, maxHp: number | undefined) => {
const result = withUndo(() => setHpUseCase(makeStore(), id, maxHp));
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore, withUndo],
(id: CombatantId, maxHp: number | undefined) =>
dispatchAction(() => setHpUseCase(makeStore(), id, maxHp)),
[makeStore, dispatchAction],
);
const adjustHp = useCallback(
(id: CombatantId, delta: number) => {
const result = withUndo(() => adjustHpUseCase(makeStore(), id, delta));
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore, withUndo],
(id: CombatantId, delta: number) =>
dispatchAction(() => adjustHpUseCase(makeStore(), id, delta)),
[makeStore, dispatchAction],
);
const setTempHp = useCallback(
(id: CombatantId, tempHp: number | undefined) => {
const result = withUndo(() => setTempHpUseCase(makeStore(), id, tempHp));
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore, withUndo],
(id: CombatantId, tempHp: number | undefined) =>
dispatchAction(() => setTempHpUseCase(makeStore(), id, tempHp)),
[makeStore, dispatchAction],
);
const setAc = useCallback(
(id: CombatantId, value: number | undefined) => {
const result = withUndo(() => setAcUseCase(makeStore(), id, value));
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore, withUndo],
(id: CombatantId, value: number | undefined) =>
dispatchAction(() => setAcUseCase(makeStore(), id, value)),
[makeStore, dispatchAction],
);
const toggleCondition = useCallback(
(id: CombatantId, conditionId: ConditionId) => {
const result = withUndo(() =>
(id: CombatantId, conditionId: ConditionId) =>
dispatchAction(() =>
toggleConditionUseCase(makeStore(), id, conditionId),
);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore, withUndo],
),
[makeStore, dispatchAction],
);
const toggleConcentration = useCallback(
(id: CombatantId) => {
const result = withUndo(() =>
toggleConcentrationUseCase(makeStore(), id),
);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore, withUndo],
(id: CombatantId) =>
dispatchAction(() => toggleConcentrationUseCase(makeStore(), id)),
[makeStore, dispatchAction],
);
const clearEncounter = useCallback(() => {
@@ -298,16 +222,11 @@ export function useEncounter() {
setEvents((prev) => [...prev, ...result]);
}, [makeStore]);
const addOneFromBestiary = useCallback(
(
entry: BestiaryIndexEntry,
): { cId: CreatureId; events: DomainEvent[] } | null => {
const resolveAndRename = useCallback(
(name: string): string => {
const store = makeStore();
const existingNames = store.get().combatants.map((c) => c.name);
const { newName, renames } = resolveCreatureName(
entry.name,
existingNames,
);
const { newName, renames } = resolveCreatureName(name, existingNames);
for (const { from, to } of renames) {
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
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, "-")
@@ -333,7 +263,7 @@ export function useEncounter() {
return { cId, events: result };
},
[makeStore],
[makeStore, resolveAndRename],
);
const addFromBestiary = useCallback(
@@ -385,16 +315,7 @@ export function useEncounter() {
const addFromPlayerCharacter = useCallback(
(pc: PlayerCharacter) => {
const snapshot = encounterRef.current;
const store = makeStore();
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 newName = resolveAndRename(pc.name);
const id = combatantId(`c-${++nextId.current}`);
const result = addCombatantUseCase(makeStore(), id, newName, {
@@ -406,7 +327,7 @@ export function useEncounter() {
});
if (isDomainError(result)) {
store.save(snapshot);
makeStore().save(snapshot);
return;
}
@@ -416,7 +337,7 @@ export function useEncounter() {
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
[makeStore, resolveAndRename],
);
const undoAction = useCallback(() => {
@@ -122,64 +122,7 @@ describe("loadEncounter", () => {
expect(loadEncounter()).toBeNull();
});
// US3: Corrupt data scenarios
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", () => {
it("returns null when combatant has invalid required fields", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
@@ -191,88 +134,6 @@ describe("loadEncounter", () => {
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", () => {
const encounter = makeEncounter();
saveEncounter(encounter);
@@ -90,134 +90,7 @@ describe("player-character-storage", () => {
});
});
describe("per-character validation", () => {
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([]);
});
describe("delegation to domain rehydration", () => {
it("keeps valid characters and discards invalid ones", () => {
mockStorage.setItem(
STORAGE_KEY,
+15 -100
View File
@@ -1,14 +1,9 @@
import {
type ConditionId,
combatantId,
type Combatant,
createEncounter,
creatureId,
type Encounter,
isDomainError,
playerCharacterId,
VALID_CONDITION_IDS,
VALID_PLAYER_COLORS,
VALID_PLAYER_ICONS,
rehydrateCombatant,
} from "@initiative/domain";
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 {
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
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);
if (isDomainError(result)) return null;
return result;
return encounter;
}
export function loadEncounter(): Encounter | null {
+2 -2
View File
@@ -4,8 +4,8 @@ import type {
PlayerCharacter,
UndoRedoState,
} from "@initiative/domain";
import { rehydratePlayerCharacter } from "@initiative/domain";
import { rehydrateEncounter } from "./encounter-storage.js";
import { rehydrateCharacter } from "./player-character-storage.js";
function rehydrateStack(raw: unknown[]): Encounter[] {
const result: Encounter[] = [];
@@ -21,7 +21,7 @@ function rehydrateStack(raw: unknown[]): Encounter[] {
function rehydrateCharacters(raw: unknown[]): PlayerCharacter[] {
const result: PlayerCharacter[] = [];
for (const entry of raw) {
const rehydrated = rehydrateCharacter(entry);
const rehydrated = rehydratePlayerCharacter(entry);
if (rehydrated !== null) {
result.push(rehydrated);
}
@@ -1,9 +1,5 @@
import type { PlayerCharacter } from "@initiative/domain";
import {
playerCharacterId,
VALID_PLAYER_COLORS,
VALID_PLAYER_ICONS,
} from "@initiative/domain";
import { rehydratePlayerCharacter } from "@initiative/domain";
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[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
@@ -74,7 +21,7 @@ export function loadPlayerCharacters(): PlayerCharacter[] {
const characters: PlayerCharacter[] = [];
for (const item of parsed) {
const pc = rehydrateCharacter(item);
const pc = rehydratePlayerCharacter(item);
if (pc !== null) {
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",
"@vitest/coverage-v8": "^4.1.0",
"jscpd": "^4.0.8",
"jsinspect-plus": "^3.1.3",
"knip": "^5.88.1",
"lefthook": "^2.1.4",
"oxlint": "^1.56.0",
@@ -29,10 +30,11 @@
"test:watch": "vitest",
"knip": "knip",
"jscpd": "jscpd",
"jsinspect": "jsinspect -c .jsinspectrc apps/web/src packages/domain/src packages/application/src",
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
"check:ignores": "node scripts/check-lint-ignores.mjs",
"check:classnames": "node scripts/check-cn-classnames.mjs",
"check: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 DomainError,
type DomainEvent,
isDomainError,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function addCombatantUseCase(
store: EncounterStore,
@@ -14,13 +14,7 @@ export function addCombatantUseCase(
name: string,
init?: CombatantInit,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = addCombatant(encounter, id, name, init);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
return runEncounterAction(store, (encounter) =>
addCombatant(encounter, id, name, init),
);
}
+4 -10
View File
@@ -3,22 +3,16 @@ import {
type CombatantId,
type DomainError,
type DomainEvent,
isDomainError,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function adjustHpUseCase(
store: EncounterStore,
combatantId: CombatantId,
delta: number,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = adjustHp(encounter, combatantId, delta);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
return runEncounterAction(store, (encounter) =>
adjustHp(encounter, combatantId, delta),
);
}
@@ -2,20 +2,12 @@ import {
advanceTurn,
type DomainError,
type DomainEvent,
isDomainError,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function advanceTurnUseCase(
store: EncounterStore,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = advanceTurn(encounter);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
return runEncounterAction(store, (encounter) => advanceTurn(encounter));
}
@@ -2,20 +2,12 @@ import {
clearEncounter,
type DomainError,
type DomainEvent,
isDomainError,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function clearEncounterUseCase(
store: EncounterStore,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = clearEncounter(encounter);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
return runEncounterAction(store, (encounter) => clearEncounter(encounter));
}
@@ -3,22 +3,16 @@ import {
type DomainError,
type DomainEvent,
editCombatant,
isDomainError,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function editCombatantUseCase(
store: EncounterStore,
id: CombatantId,
newName: string,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = editCombatant(encounter, id, newName);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
return runEncounterAction(store, (encounter) =>
editCombatant(encounter, id, newName),
);
}
@@ -2,22 +2,16 @@ import {
type CombatantId,
type DomainError,
type DomainEvent,
isDomainError,
removeCombatant,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function removeCombatantUseCase(
store: EncounterStore,
id: CombatantId,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = removeCombatant(encounter, id);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
return runEncounterAction(store, (encounter) =>
removeCombatant(encounter, id),
);
}
@@ -1,21 +1,13 @@
import {
type DomainError,
type DomainEvent,
isDomainError,
retreatTurn,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function retreatTurnUseCase(
store: EncounterStore,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = retreatTurn(encounter);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
return runEncounterAction(store, (encounter) => retreatTurn(encounter));
}
@@ -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 DomainError,
type DomainEvent,
isDomainError,
setAc,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function setAcUseCase(
store: EncounterStore,
combatantId: CombatantId,
value: number | undefined,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = setAc(encounter, combatantId, value);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
return runEncounterAction(store, (encounter) =>
setAc(encounter, combatantId, value),
);
}
+4 -10
View File
@@ -2,23 +2,17 @@ import {
type CombatantId,
type DomainError,
type DomainEvent,
isDomainError,
setHp,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function setHpUseCase(
store: EncounterStore,
combatantId: CombatantId,
maxHp: number | undefined,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = setHp(encounter, combatantId, maxHp);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
return runEncounterAction(store, (encounter) =>
setHp(encounter, combatantId, maxHp),
);
}
@@ -2,23 +2,17 @@ import {
type CombatantId,
type DomainError,
type DomainEvent,
isDomainError,
setInitiative,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function setInitiativeUseCase(
store: EncounterStore,
combatantId: CombatantId,
value: number | undefined,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = setInitiative(encounter, combatantId, value);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
return runEncounterAction(store, (encounter) =>
setInitiative(encounter, combatantId, value),
);
}
@@ -2,23 +2,17 @@ import {
type CombatantId,
type DomainError,
type DomainEvent,
isDomainError,
setTempHp,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function setTempHpUseCase(
store: EncounterStore,
combatantId: CombatantId,
tempHp: number | undefined,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = setTempHp(encounter, combatantId, tempHp);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
return runEncounterAction(store, (encounter) =>
setTempHp(encounter, combatantId, tempHp),
);
}
@@ -2,22 +2,16 @@ import {
type CombatantId,
type DomainError,
type DomainEvent,
isDomainError,
toggleConcentration,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function toggleConcentrationUseCase(
store: EncounterStore,
combatantId: CombatantId,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = toggleConcentration(encounter, combatantId);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
return runEncounterAction(store, (encounter) =>
toggleConcentration(encounter, combatantId),
);
}
@@ -3,23 +3,17 @@ import {
type ConditionId,
type DomainError,
type DomainEvent,
isDomainError,
toggleCondition,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function toggleConditionUseCase(
store: EncounterStore,
combatantId: CombatantId,
conditionId: ConditionId,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = toggleCondition(encounter, combatantId, conditionId);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
return runEncounterAction(store, (encounter) =>
toggleCondition(encounter, combatantId, conditionId),
);
}
@@ -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 { CombatantId, DomainError, Encounter } from "./types.js";
import {
type CombatantId,
type DomainError,
type Encounter,
findCombatant,
isDomainError,
} from "./types.js";
export interface AdjustHpSuccess {
readonly encounter: Encounter;
@@ -17,17 +23,9 @@ export function adjustHp(
combatantId: CombatantId,
delta: number,
): AdjustHpSuccess | DomainError {
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
if (targetIdx === -1) {
return {
kind: "domain-error",
code: "combatant-not-found",
message: `No combatant found with ID "${combatantId}"`,
};
}
const target = encounter.combatants[targetIdx];
const found = findCombatant(encounter, combatantId);
if (isDomainError(found)) return found;
const { combatant: target } = found;
if (target.maxHp === undefined || target.currentHp === undefined) {
return {
+10 -12
View File
@@ -1,5 +1,11 @@
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 {
readonly encounter: Encounter;
@@ -30,17 +36,9 @@ export function editCombatant(
};
}
const index = encounter.combatants.findIndex((c) => c.id === id);
if (index === -1) {
return {
kind: "domain-error",
code: "combatant-not-found",
message: `No combatant found with ID "${id}"`,
};
}
const oldName = encounter.combatants[index].name;
const found = findCombatant(encounter, id);
if (isDomainError(found)) return found;
const oldName = found.combatant.name;
return {
encounter: {
+3
View File
@@ -94,6 +94,8 @@ export {
VALID_PLAYER_COLORS,
VALID_PLAYER_ICONS,
} from "./player-character-types.js";
export { rehydrateCombatant } from "./rehydrate-combatant.js";
export { rehydratePlayerCharacter } from "./rehydrate-player-character.js";
export {
type RemoveCombatantSuccess,
removeCombatant,
@@ -126,6 +128,7 @@ export {
createEncounter,
type DomainError,
type Encounter,
findCombatant,
isDomainError,
} from "./types.js";
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 { CombatantId, DomainError, Encounter } from "./types.js";
import {
type CombatantId,
type DomainError,
type Encounter,
findCombatant,
isDomainError,
} from "./types.js";
export interface RemoveCombatantSuccess {
readonly encounter: Encounter;
@@ -22,17 +28,10 @@ export function removeCombatant(
encounter: Encounter,
id: CombatantId,
): RemoveCombatantSuccess | DomainError {
const removedIdx = encounter.combatants.findIndex((c) => c.id === id);
const found = findCombatant(encounter, id);
if (isDomainError(found)) return found;
if (removedIdx === -1) {
return {
kind: "domain-error",
code: "combatant-not-found",
message: `No combatant found with ID "${id}"`,
};
}
const removed = encounter.combatants[removedIdx];
const { index: removedIdx, combatant: removed } = found;
const newCombatants = encounter.combatants.filter((_, i) => i !== removedIdx);
let newActiveIndex: number;
+10 -12
View File
@@ -1,5 +1,11 @@
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 {
readonly encounter: Encounter;
@@ -11,15 +17,8 @@ export function setAc(
combatantId: CombatantId,
value: number | undefined,
): SetAcSuccess | DomainError {
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
if (targetIdx === -1) {
return {
kind: "domain-error",
code: "combatant-not-found",
message: `No combatant found with ID "${combatantId}"`,
};
}
const found = findCombatant(encounter, combatantId);
if (isDomainError(found)) return found;
if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
return {
@@ -29,8 +28,7 @@ export function setAc(
};
}
const target = encounter.combatants[targetIdx];
const previousAc = target.ac;
const previousAc = found.combatant.ac;
const updatedCombatants = encounter.combatants.map((c) =>
c.id === combatantId ? { ...c, ac: value } : c,
+11 -13
View File
@@ -1,5 +1,11 @@
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 {
readonly encounter: Encounter;
@@ -18,15 +24,8 @@ export function setHp(
combatantId: CombatantId,
maxHp: number | undefined,
): SetHpSuccess | DomainError {
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
if (targetIdx === -1) {
return {
kind: "domain-error",
code: "combatant-not-found",
message: `No combatant found with ID "${combatantId}"`,
};
}
const found = findCombatant(encounter, combatantId);
if (isDomainError(found)) return found;
if (maxHp !== undefined && (!Number.isInteger(maxHp) || maxHp < 1)) {
return {
@@ -36,9 +35,8 @@ export function setHp(
};
}
const target = encounter.combatants[targetIdx];
const previousMaxHp = target.maxHp;
const previousCurrentHp = target.currentHp;
const previousMaxHp = found.combatant.maxHp;
const previousCurrentHp = found.combatant.currentHp;
let newMaxHp: number | undefined;
let newCurrentHp: number | undefined;
+10 -12
View File
@@ -1,6 +1,12 @@
import type { DomainEvent } from "./events.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 {
readonly encounter: Encounter;
@@ -24,15 +30,8 @@ export function setInitiative(
combatantId: CombatantId,
value: number | undefined,
): SetInitiativeSuccess | DomainError {
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
if (targetIdx === -1) {
return {
kind: "domain-error",
code: "combatant-not-found",
message: `No combatant found with ID "${combatantId}"`,
};
}
const found = findCombatant(encounter, combatantId);
if (isDomainError(found)) return found;
if (value !== undefined && !Number.isInteger(value)) {
return {
@@ -42,8 +41,7 @@ export function setInitiative(
};
}
const target = encounter.combatants[targetIdx];
const previousValue = target.initiative;
const previousValue = found.combatant.initiative;
// Create new combatants array with updated initiative
const updated = encounter.combatants.map((c) =>
+10 -12
View File
@@ -1,5 +1,11 @@
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 {
readonly encounter: Encounter;
@@ -18,17 +24,9 @@ export function setTempHp(
combatantId: CombatantId,
tempHp: number | undefined,
): SetTempHpSuccess | DomainError {
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
if (targetIdx === -1) {
return {
kind: "domain-error",
code: "combatant-not-found",
message: `No combatant found with ID "${combatantId}"`,
};
}
const target = encounter.combatants[targetIdx];
const found = findCombatant(encounter, combatantId);
if (isDomainError(found)) return found;
const { combatant: target } = found;
if (target.maxHp === undefined || target.currentHp === undefined) {
return {
+10 -12
View File
@@ -1,5 +1,11 @@
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 {
readonly encounter: Encounter;
@@ -10,17 +16,9 @@ export function toggleConcentration(
encounter: Encounter,
combatantId: CombatantId,
): ToggleConcentrationSuccess | DomainError {
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
if (targetIdx === -1) {
return {
kind: "domain-error",
code: "combatant-not-found",
message: `No combatant found with ID "${combatantId}"`,
};
}
const target = encounter.combatants[targetIdx];
const found = findCombatant(encounter, combatantId);
if (isDomainError(found)) return found;
const { combatant: target } = found;
const wasConcentrating = target.isConcentrating === true;
const event: DomainEvent = wasConcentrating
+10 -12
View File
@@ -1,7 +1,13 @@
import type { ConditionId } from "./conditions.js";
import { CONDITION_DEFINITIONS, VALID_CONDITION_IDS } from "./conditions.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 {
readonly encounter: Encounter;
@@ -21,17 +27,9 @@ export function toggleCondition(
};
}
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
if (targetIdx === -1) {
return {
kind: "domain-error",
code: "combatant-not-found",
message: `No combatant found with ID "${combatantId}"`,
};
}
const target = encounter.combatants[targetIdx];
const found = findCombatant(encounter, combatantId);
if (isDomainError(found)) return found;
const { combatant: target } = found;
const current = target.conditions ?? [];
const isActive = current.includes(conditionId);
+14
View File
@@ -70,6 +70,20 @@ export function createEncounter(
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 {
return (
typeof value === "object" &&
+119
View File
@@ -21,6 +21,9 @@ importers:
jscpd:
specifier: ^4.0.8
version: 4.0.8
jsinspect-plus:
specifier: ^3.1.3
version: 3.1.3
knip:
specifier: ^5.88.1
version: 5.88.1(@types/node@25.3.3)(typescript@5.9.3)
@@ -133,15 +136,28 @@ packages:
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
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':
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
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':
resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==}
engines: {node: '>=6.0.0'}
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':
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
engines: {node: '>=6.9.0'}
@@ -150,6 +166,10 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
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':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
@@ -898,6 +918,10 @@ packages:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
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:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'}
@@ -956,6 +980,10 @@ packages:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
chalk@2.4.2:
resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
engines: {node: '>=4'}
character-parser@2.2.0:
resolution: {integrity: sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==}
@@ -970,10 +998,19 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
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:
resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==}
engines: {node: '>=0.1.90'}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
commander@5.1.0:
resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==}
engines: {node: '>= 6'}
@@ -1055,6 +1092,10 @@ packages:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
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:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
@@ -1088,6 +1129,9 @@ packages:
picomatch:
optional: true
filepaths@0.3.0:
resolution: {integrity: sha512-QFAYdzHZxWfBOHtHIlZySPAej+pxz6c2TGe8LGgHQNsgxHmcfbbQfNmsIh0kaangjL+6D6g8IoR6VDnOFrLEFw==}
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
@@ -1136,6 +1180,10 @@ packages:
graceful-fs@4.2.11:
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:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
@@ -1251,6 +1299,10 @@ packages:
canvas:
optional: true
jsinspect-plus@3.1.3:
resolution: {integrity: sha512-0GbLXDlfz9nPuybM/QunzEYKTwaETxGJ5+V7vZFS7+l8w426ePVU77dBH6k+KrxiJemIgVwY6Yxr3PCzFJwxgw==}
hasBin: true
jsonfile@6.2.0:
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
@@ -1650,6 +1702,10 @@ packages:
spark-md5@3.0.2:
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:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
@@ -1672,10 +1728,18 @@ packages:
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
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:
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
engines: {node: '>=14.16'}
supports-color@5.5.0:
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
engines: {node: '>=4'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -1921,12 +1985,20 @@ snapshots:
'@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@8.0.0-rc.3': {}
'@babel/parser@7.29.0':
dependencies:
'@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/types@7.29.0':
@@ -1934,6 +2006,11 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@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': {}
'@biomejs/biome@2.4.8':
@@ -2481,6 +2558,10 @@ snapshots:
ansi-regex@5.0.1: {}
ansi-styles@3.2.1:
dependencies:
color-convert: 1.9.3
ansi-styles@5.2.0: {}
aria-query@5.3.0:
@@ -2534,6 +2615,12 @@ snapshots:
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:
dependencies:
is-regex: 1.2.1
@@ -2550,8 +2637,16 @@ snapshots:
clsx@2.1.1: {}
color-convert@1.9.3:
dependencies:
color-name: 1.1.3
color-name@1.1.3: {}
colors@1.4.0: {}
commander@2.20.3: {}
commander@5.1.0: {}
constantinople@4.0.1:
@@ -2624,6 +2719,8 @@ snapshots:
dependencies:
es-errors: 1.3.0
escape-string-regexp@1.0.5: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.8
@@ -2664,6 +2761,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.4
filepaths@0.3.0: {}
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
@@ -2715,6 +2814,8 @@ snapshots:
graceful-fs@4.2.11: {}
has-flag@3.0.0: {}
has-flag@4.0.0: {}
has-symbols@1.1.0: {}
@@ -2841,6 +2942,16 @@ snapshots:
transitivePeerDependencies:
- '@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:
dependencies:
universalify: 2.0.1
@@ -3268,6 +3379,8 @@ snapshots:
spark-md5@3.0.2: {}
stable@0.1.8: {}
stackback@0.0.2: {}
std-env@4.0.0: {}
@@ -3288,8 +3401,14 @@ snapshots:
dependencies:
min-indent: 1.0.1
strip-json-comments@3.1.1: {}
strip-json-comments@5.0.3: {}
supports-color@5.5.0:
dependencies:
has-flag: 3.0.0
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0