diff --git a/.jsinspectrc b/.jsinspectrc new file mode 100644 index 0000000..b8896e0 --- /dev/null +++ b/.jsinspectrc @@ -0,0 +1,9 @@ +{ + "threshold": 50, + "minInstances": 3, + "identifiers": false, + "literals": false, + "ignore": "dist|__tests__|node_modules", + "reporter": "default", + "truncate": 100 +} diff --git a/apps/web/src/components/condition-picker.tsx b/apps/web/src/components/condition-picker.tsx index 513cb6b..e868ebc 100644 --- a/apps/web/src/components/condition-picker.tsx +++ b/apps/web/src/components/condition-picker.tsx @@ -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 = { - EyeOff, - Heart, - EarOff, - BatteryLow, - Siren, - Hand, - Ban, - Ghost, - ZapOff, - Gem, - Droplet, - ArrowDown, - Link, - ShieldMinus, - Snail, - Sparkles, - Moon, -}; - -const COLOR_CLASSES: Record = { - 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; 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 ( = { + EyeOff, + Heart, + EarOff, + BatteryLow, + Siren, + Hand, + Ban, + Ghost, + ZapOff, + Gem, + Droplet, + ArrowDown, + Link, + ShieldMinus, + Snail, + Sparkles, + Moon, +}; + +export const CONDITION_COLOR_CLASSES: Record = { + 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", +}; diff --git a/apps/web/src/components/condition-tags.tsx b/apps/web/src/components/condition-tags.tsx index dbb6050..edd4467 100644 --- a/apps/web/src/components/condition-tags.tsx +++ b/apps/web/src/components/condition-tags.tsx @@ -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 = { - EyeOff, - Heart, - EarOff, - BatteryLow, - Siren, - Hand, - Ban, - Ghost, - ZapOff, - Gem, - Droplet, - ArrowDown, - Link, - ShieldMinus, - Snail, - Sparkles, - Moon, -}; - -const COLOR_CLASSES: Record = { - 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 ( -
-

Export Encounter

- -
+
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); diff --git a/apps/web/src/components/import-method-dialog.tsx b/apps/web/src/components/import-method-dialog.tsx index ee74acf..1e098a3 100644 --- a/apps/web/src/components/import-method-dialog.tsx +++ b/apps/web/src/components/import-method-dialog.tsx @@ -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 ( -
-

Import Encounter

- -
+ {mode === "pick" && (
-
+ {characters.length === 0 ? (
diff --git a/apps/web/src/components/roll-mode-menu.tsx b/apps/web/src/components/roll-mode-menu.tsx index b43d16d..f6fa3e0 100644 --- a/apps/web/src/components/roll-mode-menu.tsx +++ b/apps/web/src/components/roll-mode-menu.tsx @@ -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 (
) { return ( -
-

Settings

- -
+
diff --git a/apps/web/src/components/stat-block.tsx b/apps/web/src/components/stat-block.tsx index 702a3be..071cb40 100644 --- a/apps/web/src/components/stat-block.tsx +++ b/apps/web/src/components/stat-block.tsx @@ -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 ( + <> + + {heading ? ( +

{heading}

+ ) : null} +
+ {entries.map((e) => ( +
+ {e.name}. {e.text} +
+ ))} +
+ + ); +} + export function StatBlock({ creature }: Readonly) { const abilities = [ { label: "STR", score: creature.abilities.str }, @@ -134,19 +159,7 @@ export function StatBlock({ creature }: Readonly) {
- {/* Traits */} - {creature.traits && creature.traits.length > 0 && ( - <> - -
- {creature.traits.map((t) => ( -
- {t.name}. {t.text} -
- ))} -
- - )} + {/* Spellcasting */} {creature.spellcasting && creature.spellcasting.length > 0 && ( @@ -190,52 +203,9 @@ export function StatBlock({ creature }: Readonly) { )} - {/* Actions */} - {creature.actions && creature.actions.length > 0 && ( - <> - -

Actions

-
- {creature.actions.map((a) => ( -
- {a.name}. {a.text} -
- ))} -
- - )} - - {/* Bonus Actions */} - {creature.bonusActions && creature.bonusActions.length > 0 && ( - <> - -

- Bonus Actions -

-
- {creature.bonusActions.map((a) => ( -
- {a.name}. {a.text} -
- ))} -
- - )} - - {/* Reactions */} - {creature.reactions && creature.reactions.length > 0 && ( - <> - -

Reactions

-
- {creature.reactions.map((a) => ( -
- {a.name}. {a.text} -
- ))} -
- - )} + + + {/* Legendary Actions */} {!!creature.legendaryActions && ( diff --git a/apps/web/src/components/ui/confirm-button.tsx b/apps/web/src/components/ui/confirm-button.tsx index 4e90626..168471c 100644 --- a/apps/web/src/components/ui/confirm-button.tsx +++ b/apps/web/src/components/ui/confirm-button.tsx @@ -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 === " ") { diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index f90eb96..fcd80b2 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -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) {
); } + +export function DialogHeader({ + title, + onClose, +}: Readonly<{ title: string; onClose: () => void }>) { + return ( +
+

{title}

+ +
+ ); +} diff --git a/apps/web/src/components/ui/overflow-menu.tsx b/apps/web/src/components/ui/overflow-menu.tsx index f8f1fd7..07cf6e0 100644 --- a/apps/web/src/components/ui/overflow-menu.tsx +++ b/apps/web/src/components/ui/overflow-menu.tsx @@ -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(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 (
diff --git a/apps/web/src/hooks/use-click-outside.ts b/apps/web/src/hooks/use-click-outside.ts new file mode 100644 index 0000000..1ced58d --- /dev/null +++ b/apps/web/src/hooks/use-click-outside.ts @@ -0,0 +1,27 @@ +import type { RefObject } from "react"; +import { useEffect } from "react"; + +export function useClickOutside( + ref: RefObject, + 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]); +} diff --git a/apps/web/src/hooks/use-encounter.ts b/apps/web/src/hooks/use-encounter.ts index 5683da0..9bd419f 100644 --- a/apps/web/src/hooks/use-encounter.ts +++ b/apps/web/src/hooks/use-encounter.ts @@ -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(() => { diff --git a/knip.json b/knip.json index 3db2a0d..625a2f2 100644 --- a/knip.json +++ b/knip.json @@ -1,5 +1,6 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", + "ignoreDependencies": ["jsinspect-plus"], "workspaces": { ".": { "entry": ["scripts/*.mjs"] diff --git a/package.json b/package.json index 909c47a..9390df4 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/packages/application/src/add-combatant-use-case.ts b/packages/application/src/add-combatant-use-case.ts index 8f14e7a..e54eba8 100644 --- a/packages/application/src/add-combatant-use-case.ts +++ b/packages/application/src/add-combatant-use-case.ts @@ -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), + ); } diff --git a/packages/application/src/adjust-hp-use-case.ts b/packages/application/src/adjust-hp-use-case.ts index 2277876..fe5f0b8 100644 --- a/packages/application/src/adjust-hp-use-case.ts +++ b/packages/application/src/adjust-hp-use-case.ts @@ -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), + ); } diff --git a/packages/application/src/advance-turn-use-case.ts b/packages/application/src/advance-turn-use-case.ts index f6555ab..7db758a 100644 --- a/packages/application/src/advance-turn-use-case.ts +++ b/packages/application/src/advance-turn-use-case.ts @@ -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)); } diff --git a/packages/application/src/clear-encounter-use-case.ts b/packages/application/src/clear-encounter-use-case.ts index 72356f8..1a2531f 100644 --- a/packages/application/src/clear-encounter-use-case.ts +++ b/packages/application/src/clear-encounter-use-case.ts @@ -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)); } diff --git a/packages/application/src/edit-combatant-use-case.ts b/packages/application/src/edit-combatant-use-case.ts index 4895d6c..6cd7ae2 100644 --- a/packages/application/src/edit-combatant-use-case.ts +++ b/packages/application/src/edit-combatant-use-case.ts @@ -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), + ); } diff --git a/packages/application/src/remove-combatant-use-case.ts b/packages/application/src/remove-combatant-use-case.ts index 718c96f..70ea04c 100644 --- a/packages/application/src/remove-combatant-use-case.ts +++ b/packages/application/src/remove-combatant-use-case.ts @@ -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), + ); } diff --git a/packages/application/src/retreat-turn-use-case.ts b/packages/application/src/retreat-turn-use-case.ts index af088af..26697a3 100644 --- a/packages/application/src/retreat-turn-use-case.ts +++ b/packages/application/src/retreat-turn-use-case.ts @@ -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)); } diff --git a/packages/application/src/run-encounter-action.ts b/packages/application/src/run-encounter-action.ts new file mode 100644 index 0000000..dace9ea --- /dev/null +++ b/packages/application/src/run-encounter-action.ts @@ -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; +} diff --git a/packages/application/src/set-ac-use-case.ts b/packages/application/src/set-ac-use-case.ts index 6a6413f..b3d3297 100644 --- a/packages/application/src/set-ac-use-case.ts +++ b/packages/application/src/set-ac-use-case.ts @@ -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), + ); } diff --git a/packages/application/src/set-hp-use-case.ts b/packages/application/src/set-hp-use-case.ts index 568df9e..10a910b 100644 --- a/packages/application/src/set-hp-use-case.ts +++ b/packages/application/src/set-hp-use-case.ts @@ -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), + ); } diff --git a/packages/application/src/set-initiative-use-case.ts b/packages/application/src/set-initiative-use-case.ts index 3877d67..6073825 100644 --- a/packages/application/src/set-initiative-use-case.ts +++ b/packages/application/src/set-initiative-use-case.ts @@ -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), + ); } diff --git a/packages/application/src/set-temp-hp-use-case.ts b/packages/application/src/set-temp-hp-use-case.ts index 8edf9ff..2cc1acc 100644 --- a/packages/application/src/set-temp-hp-use-case.ts +++ b/packages/application/src/set-temp-hp-use-case.ts @@ -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), + ); } diff --git a/packages/application/src/toggle-concentration-use-case.ts b/packages/application/src/toggle-concentration-use-case.ts index f748f6b..6624e50 100644 --- a/packages/application/src/toggle-concentration-use-case.ts +++ b/packages/application/src/toggle-concentration-use-case.ts @@ -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), + ); } diff --git a/packages/application/src/toggle-condition-use-case.ts b/packages/application/src/toggle-condition-use-case.ts index 4225716..aa52c70 100644 --- a/packages/application/src/toggle-condition-use-case.ts +++ b/packages/application/src/toggle-condition-use-case.ts @@ -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), + ); } diff --git a/packages/domain/src/adjust-hp.ts b/packages/domain/src/adjust-hp.ts index 62a9cdd..9f33fe7 100644 --- a/packages/domain/src/adjust-hp.ts +++ b/packages/domain/src/adjust-hp.ts @@ -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 { diff --git a/packages/domain/src/edit-combatant.ts b/packages/domain/src/edit-combatant.ts index 70fcf14..e840ab5 100644 --- a/packages/domain/src/edit-combatant.ts +++ b/packages/domain/src/edit-combatant.ts @@ -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: { diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 910c6f7..b185d2c 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -126,6 +126,7 @@ export { createEncounter, type DomainError, type Encounter, + findCombatant, isDomainError, } from "./types.js"; export { diff --git a/packages/domain/src/remove-combatant.ts b/packages/domain/src/remove-combatant.ts index 18534a3..e5c121a 100644 --- a/packages/domain/src/remove-combatant.ts +++ b/packages/domain/src/remove-combatant.ts @@ -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; diff --git a/packages/domain/src/set-ac.ts b/packages/domain/src/set-ac.ts index 6940d24..85868a8 100644 --- a/packages/domain/src/set-ac.ts +++ b/packages/domain/src/set-ac.ts @@ -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, diff --git a/packages/domain/src/set-hp.ts b/packages/domain/src/set-hp.ts index 3edc00c..8dc806f 100644 --- a/packages/domain/src/set-hp.ts +++ b/packages/domain/src/set-hp.ts @@ -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; diff --git a/packages/domain/src/set-initiative.ts b/packages/domain/src/set-initiative.ts index 539af76..c0add99 100644 --- a/packages/domain/src/set-initiative.ts +++ b/packages/domain/src/set-initiative.ts @@ -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) => diff --git a/packages/domain/src/set-temp-hp.ts b/packages/domain/src/set-temp-hp.ts index 9233ea1..ef69d5b 100644 --- a/packages/domain/src/set-temp-hp.ts +++ b/packages/domain/src/set-temp-hp.ts @@ -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 { diff --git a/packages/domain/src/toggle-concentration.ts b/packages/domain/src/toggle-concentration.ts index 1f77fbd..37c75eb 100644 --- a/packages/domain/src/toggle-concentration.ts +++ b/packages/domain/src/toggle-concentration.ts @@ -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 diff --git a/packages/domain/src/toggle-condition.ts b/packages/domain/src/toggle-condition.ts index fea606c..c0e341e 100644 --- a/packages/domain/src/toggle-condition.ts +++ b/packages/domain/src/toggle-condition.ts @@ -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); diff --git a/packages/domain/src/types.ts b/packages/domain/src/types.ts index 8cbfb2e..b831c1b 100644 --- a/packages/domain/src/types.ts +++ b/packages/domain/src/types.ts @@ -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" && diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6ebfdf..29a27b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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