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>
This commit is contained in:
Lukas
2026-03-28 02:16:54 +01:00
parent ef76b9c90b
commit f4fb69dbc7
44 changed files with 550 additions and 696 deletions

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