1 Commits
0.9.15 ... main

Author SHA1 Message Date
Lukas
f4fb69dbc7 Add jsinspect-plus structural duplication gate, extract shared helpers
All checks were successful
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
44 changed files with 550 additions and 696 deletions

9
.jsinspectrc Normal file
View File

@@ -0,0 +1,9 @@
{
"threshold": 50,
"minInstances": 3,
"identifiers": false,
"literals": false,
"ignore": "dist|__tests__|node_modules",
"reporter": "default",
"truncate": 100
}

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}

View File

@@ -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",
};

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}

View File

@@ -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"

View File

@@ -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);

View File

@@ -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

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">

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

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>

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 && (

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 === " ") {

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

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">

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]);
}

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;
}
setEvents((prev) => [...prev, ...result]);
}, [makeStore, withUndo]);
const retreatTurn = useCallback(() => {
const result = withUndo(() => retreatTurnUseCase(makeStore()));
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
}, [makeStore, withUndo]);
const dispatchAction = useCallback(
(action: () => DomainEvent[] | DomainError) => {
const result = withUndo(action);
if (!isDomainError(result)) {
setEvents((prev) => [...prev, ...result]);
}
},
[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(() => {

View File

@@ -1,5 +1,6 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"ignoreDependencies": ["jsinspect-plus"],
"workspaces": {
".": {
"entry": ["scripts/*.mjs"]

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"
}
}

View File

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

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

View File

@@ -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));
}

View File

@@ -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));
}

View File

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

View File

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

View File

@@ -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));
}

View File

@@ -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;
}

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

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

View File

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

View File

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

View File

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

View File

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

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 {

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: {

View File

@@ -126,6 +126,7 @@ export {
createEncounter,
type DomainError,
type Encounter,
findCombatant,
isDomainError,
} from "./types.js";
export {

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;

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,

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;

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) =>

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 {

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

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);

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
pnpm-lock.yaml generated
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