Files
initiative/apps/web/src/hooks/use-encounter.ts
Lukas f4fb69dbc7
All checks were successful
CI / check (push) Successful in 1m13s
CI / build-image (push) Has been skipped
Add jsinspect-plus structural duplication gate, extract shared helpers
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

400 lines
9.7 KiB
TypeScript

import type { EncounterStore, UndoRedoStore } from "@initiative/application";
import {
addCombatantUseCase,
adjustHpUseCase,
advanceTurnUseCase,
clearEncounterUseCase,
editCombatantUseCase,
redoUseCase,
removeCombatantUseCase,
retreatTurnUseCase,
setAcUseCase,
setHpUseCase,
setInitiativeUseCase,
setTempHpUseCase,
toggleConcentrationUseCase,
toggleConditionUseCase,
undoUseCase,
} from "@initiative/application";
import type {
BestiaryIndexEntry,
CombatantId,
CombatantInit,
ConditionId,
CreatureId,
DomainError,
DomainEvent,
Encounter,
PlayerCharacter,
UndoRedoState,
} from "@initiative/domain";
import {
clearHistory,
combatantId,
isDomainError,
creatureId as makeCreatureId,
pushUndo,
resolveCreatureName,
} from "@initiative/domain";
import { useCallback, useEffect, useRef, useState } from "react";
import {
loadEncounter,
saveEncounter,
} from "../persistence/encounter-storage.js";
import {
loadUndoRedoStacks,
saveUndoRedoStacks,
} from "../persistence/undo-redo-storage.js";
const COMBATANT_ID_REGEX = /^c-(\d+)$/;
const EMPTY_ENCOUNTER: Encounter = {
combatants: [],
activeIndex: 0,
roundNumber: 1,
};
function initializeEncounter(): Encounter {
const stored = loadEncounter();
if (stored !== null) return stored;
return EMPTY_ENCOUNTER;
}
function deriveNextId(encounter: Encounter): number {
let max = 0;
for (const c of encounter.combatants) {
const match = COMBATANT_ID_REGEX.exec(c.id);
if (match) {
const n = Number.parseInt(match[1], 10);
if (n > max) max = n;
}
}
return max;
}
export function useEncounter() {
const [encounter, setEncounter] = useState<Encounter>(initializeEncounter);
const [events, setEvents] = useState<DomainEvent[]>([]);
const [undoRedoState, setUndoRedoState] =
useState<UndoRedoState>(loadUndoRedoStacks);
const encounterRef = useRef(encounter);
encounterRef.current = encounter;
const undoRedoRef = useRef(undoRedoState);
undoRedoRef.current = undoRedoState;
useEffect(() => {
saveEncounter(encounter);
}, [encounter]);
useEffect(() => {
saveUndoRedoStacks(undoRedoState);
}, [undoRedoState]);
const makeStore = useCallback((): EncounterStore => {
return {
get: () => encounterRef.current,
save: (e) => {
encounterRef.current = e;
setEncounter(e);
},
};
}, []);
const makeUndoRedoStore = useCallback((): UndoRedoStore => {
return {
get: () => undoRedoRef.current,
save: (s) => {
undoRedoRef.current = s;
setUndoRedoState(s);
},
};
}, []);
const withUndo = useCallback(<T>(action: () => T): T => {
const snapshot = encounterRef.current;
const result = action();
if (!isDomainError(result)) {
const newState = pushUndo(undoRedoRef.current, snapshot);
undoRedoRef.current = newState;
setUndoRedoState(newState);
}
return result;
}, []);
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}`);
dispatchAction(() => addCombatantUseCase(makeStore(), id, name, init));
},
[makeStore, dispatchAction],
);
const removeCombatant = useCallback(
(id: CombatantId) =>
dispatchAction(() => removeCombatantUseCase(makeStore(), id)),
[makeStore, dispatchAction],
);
const editCombatant = useCallback(
(id: CombatantId, newName: string) =>
dispatchAction(() => editCombatantUseCase(makeStore(), id, newName)),
[makeStore, dispatchAction],
);
const setInitiative = useCallback(
(id: CombatantId, value: number | undefined) =>
dispatchAction(() => setInitiativeUseCase(makeStore(), id, value)),
[makeStore, dispatchAction],
);
const setHp = useCallback(
(id: CombatantId, maxHp: number | undefined) =>
dispatchAction(() => setHpUseCase(makeStore(), id, maxHp)),
[makeStore, dispatchAction],
);
const adjustHp = useCallback(
(id: CombatantId, delta: number) =>
dispatchAction(() => adjustHpUseCase(makeStore(), id, delta)),
[makeStore, dispatchAction],
);
const setTempHp = useCallback(
(id: CombatantId, tempHp: number | undefined) =>
dispatchAction(() => setTempHpUseCase(makeStore(), id, tempHp)),
[makeStore, dispatchAction],
);
const setAc = useCallback(
(id: CombatantId, value: number | undefined) =>
dispatchAction(() => setAcUseCase(makeStore(), id, value)),
[makeStore, dispatchAction],
);
const toggleCondition = useCallback(
(id: CombatantId, conditionId: ConditionId) =>
dispatchAction(() =>
toggleConditionUseCase(makeStore(), id, conditionId),
),
[makeStore, dispatchAction],
);
const toggleConcentration = useCallback(
(id: CombatantId) =>
dispatchAction(() => toggleConcentrationUseCase(makeStore(), id)),
[makeStore, dispatchAction],
);
const clearEncounter = useCallback(() => {
const result = clearEncounterUseCase(makeStore());
if (isDomainError(result)) {
return;
}
const cleared = clearHistory();
undoRedoRef.current = cleared;
setUndoRedoState(cleared);
nextId.current = 0;
setEvents((prev) => [...prev, ...result]);
}, [makeStore]);
const resolveAndRename = useCallback(
(name: string): string => {
const store = makeStore();
const existingNames = store.get().combatants.map((c) => c.name);
const { newName, renames } = resolveCreatureName(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);
}
}
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, "-")
.replaceAll(/(^-|-$)/g, "");
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
const id = combatantId(`c-${++nextId.current}`);
const result = addCombatantUseCase(makeStore(), id, newName, {
maxHp: entry.hp,
ac: entry.ac > 0 ? entry.ac : undefined,
creatureId: cId,
});
if (isDomainError(result)) return null;
return { cId, events: result };
},
[makeStore, resolveAndRename],
);
const addFromBestiary = useCallback(
(entry: BestiaryIndexEntry): CreatureId | null => {
const snapshot = encounterRef.current;
const added = addOneFromBestiary(entry);
if (!added) {
makeStore().save(snapshot);
return null;
}
const newState = pushUndo(undoRedoRef.current, snapshot);
undoRedoRef.current = newState;
setUndoRedoState(newState);
setEvents((prev) => [...prev, ...added.events]);
return added.cId;
},
[makeStore, addOneFromBestiary],
);
const addMultipleFromBestiary = useCallback(
(entry: BestiaryIndexEntry, count: number): CreatureId | null => {
const snapshot = encounterRef.current;
const allEvents: DomainEvent[] = [];
let lastCId: CreatureId | null = null;
for (let i = 0; i < count; i++) {
const added = addOneFromBestiary(entry);
if (!added) {
makeStore().save(snapshot);
return null;
}
allEvents.push(...added.events);
lastCId = added.cId;
}
const newState = pushUndo(undoRedoRef.current, snapshot);
undoRedoRef.current = newState;
setUndoRedoState(newState);
setEvents((prev) => [...prev, ...allEvents]);
return lastCId;
},
[makeStore, addOneFromBestiary],
);
const addFromPlayerCharacter = useCallback(
(pc: PlayerCharacter) => {
const snapshot = encounterRef.current;
const newName = resolveAndRename(pc.name);
const id = combatantId(`c-${++nextId.current}`);
const result = addCombatantUseCase(makeStore(), id, newName, {
maxHp: pc.maxHp,
ac: pc.ac > 0 ? pc.ac : undefined,
color: pc.color,
icon: pc.icon,
playerCharacterId: pc.id,
});
if (isDomainError(result)) {
makeStore().save(snapshot);
return;
}
const newState = pushUndo(undoRedoRef.current, snapshot);
undoRedoRef.current = newState;
setUndoRedoState(newState);
setEvents((prev) => [...prev, ...result]);
},
[makeStore, resolveAndRename],
);
const undoAction = useCallback(() => {
undoUseCase(makeStore(), makeUndoRedoStore());
}, [makeStore, makeUndoRedoStore]);
const redoAction = useCallback(() => {
redoUseCase(makeStore(), makeUndoRedoStore());
}, [makeStore, makeUndoRedoStore]);
const canUndo = undoRedoState.undoStack.length > 0;
const canRedo = undoRedoState.redoStack.length > 0;
const hasTempHp = encounter.combatants.some(
(c) => c.tempHp !== undefined && c.tempHp > 0,
);
const isEmpty = encounter.combatants.length === 0;
const hasCreatureCombatants = encounter.combatants.some(
(c) => c.creatureId != null,
);
const canRollAllInitiative = encounter.combatants.some(
(c) => c.creatureId != null && c.initiative == null,
);
return {
encounter,
undoRedoState,
events,
isEmpty,
hasTempHp,
hasCreatureCombatants,
canRollAllInitiative,
canUndo,
canRedo,
advanceTurn,
retreatTurn,
addCombatant,
clearEncounter,
removeCombatant,
editCombatant,
setInitiative,
setHp,
adjustHp,
setTempHp,
setAc,
toggleCondition,
toggleConcentration,
addFromBestiary,
addMultipleFromBestiary,
addFromPlayerCharacter,
undo: undoAction,
redo: redoAction,
setEncounter,
setUndoRedoState,
makeStore,
withUndo,
} as const;
}