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(initializeEncounter); const [events, setEvents] = useState([]); const [undoRedoState, setUndoRedoState] = useState(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((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; }