import type { EncounterStore } from "@initiative/application"; import { addCombatantUseCase, adjustHpUseCase, advanceTurnUseCase, clearEncounterUseCase, editCombatantUseCase, removeCombatantUseCase, retreatTurnUseCase, setAcUseCase, setHpUseCase, setInitiativeUseCase, setTempHpUseCase, toggleConcentrationUseCase, toggleConditionUseCase, } from "@initiative/application"; import type { BestiaryIndexEntry, CombatantId, ConditionId, CreatureId, DomainEvent, Encounter, PlayerCharacter, } from "@initiative/domain"; import { combatantId, isDomainError, creatureId as makeCreatureId, resolveCreatureName, } from "@initiative/domain"; import { useCallback, useEffect, useRef, useState } from "react"; import { loadEncounter, saveEncounter, } from "../persistence/encounter-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; } interface CombatantOpts { initiative?: number; ac?: number; maxHp?: number; } function applyCombatantOpts( makeStore: () => EncounterStore, id: ReturnType, opts: CombatantOpts, ): DomainEvent[] { const events: DomainEvent[] = []; if (opts.maxHp !== undefined) { const r = setHpUseCase(makeStore(), id, opts.maxHp); if (!isDomainError(r)) events.push(...r); } if (opts.ac !== undefined) { const r = setAcUseCase(makeStore(), id, opts.ac); if (!isDomainError(r)) events.push(...r); } if (opts.initiative !== undefined) { const r = setInitiativeUseCase(makeStore(), id, opts.initiative); if (!isDomainError(r)) events.push(...r); } return events; } export function useEncounter() { const [encounter, setEncounter] = useState(initializeEncounter); const [events, setEvents] = useState([]); const encounterRef = useRef(encounter); encounterRef.current = encounter; useEffect(() => { saveEncounter(encounter); }, [encounter]); const makeStore = useCallback((): EncounterStore => { return { get: () => encounterRef.current, save: (e) => { encounterRef.current = e; setEncounter(e); }, }; }, []); const advanceTurn = useCallback(() => { const result = advanceTurnUseCase(makeStore()); if (isDomainError(result)) { return; } setEvents((prev) => [...prev, ...result]); }, [makeStore]); const retreatTurn = useCallback(() => { const result = retreatTurnUseCase(makeStore()); if (isDomainError(result)) { return; } setEvents((prev) => [...prev, ...result]); }, [makeStore]); const nextId = useRef(deriveNextId(encounter)); const addCombatant = useCallback( (name: string, opts?: CombatantOpts) => { const id = combatantId(`c-${++nextId.current}`); const result = addCombatantUseCase(makeStore(), id, name); if (isDomainError(result)) { return; } if (opts) { const optEvents = applyCombatantOpts(makeStore, id, opts); if (optEvents.length > 0) { setEvents((prev) => [...prev, ...optEvents]); } } setEvents((prev) => [...prev, ...result]); }, [makeStore], ); const removeCombatant = useCallback( (id: CombatantId) => { const result = removeCombatantUseCase(makeStore(), id); if (isDomainError(result)) { return; } setEvents((prev) => [...prev, ...result]); }, [makeStore], ); const editCombatant = useCallback( (id: CombatantId, newName: string) => { const result = editCombatantUseCase(makeStore(), id, newName); if (isDomainError(result)) { return; } setEvents((prev) => [...prev, ...result]); }, [makeStore], ); const setInitiative = useCallback( (id: CombatantId, value: number | undefined) => { const result = setInitiativeUseCase(makeStore(), id, value); if (isDomainError(result)) { return; } setEvents((prev) => [...prev, ...result]); }, [makeStore], ); const setHp = useCallback( (id: CombatantId, maxHp: number | undefined) => { const result = setHpUseCase(makeStore(), id, maxHp); if (isDomainError(result)) { return; } setEvents((prev) => [...prev, ...result]); }, [makeStore], ); const adjustHp = useCallback( (id: CombatantId, delta: number) => { const result = adjustHpUseCase(makeStore(), id, delta); if (isDomainError(result)) { return; } setEvents((prev) => [...prev, ...result]); }, [makeStore], ); const setTempHp = useCallback( (id: CombatantId, tempHp: number | undefined) => { const result = setTempHpUseCase(makeStore(), id, tempHp); if (isDomainError(result)) { return; } setEvents((prev) => [...prev, ...result]); }, [makeStore], ); const setAc = useCallback( (id: CombatantId, value: number | undefined) => { const result = setAcUseCase(makeStore(), id, value); if (isDomainError(result)) { return; } setEvents((prev) => [...prev, ...result]); }, [makeStore], ); const toggleCondition = useCallback( (id: CombatantId, conditionId: ConditionId) => { const result = toggleConditionUseCase(makeStore(), id, conditionId); if (isDomainError(result)) { return; } setEvents((prev) => [...prev, ...result]); }, [makeStore], ); const toggleConcentration = useCallback( (id: CombatantId) => { const result = toggleConcentrationUseCase(makeStore(), id); if (isDomainError(result)) { return; } setEvents((prev) => [...prev, ...result]); }, [makeStore], ); const clearEncounter = useCallback(() => { const result = clearEncounterUseCase(makeStore()); if (isDomainError(result)) { return; } nextId.current = 0; setEvents((prev) => [...prev, ...result]); }, [makeStore]); const addFromBestiary = useCallback( (entry: BestiaryIndexEntry): CreatureId | null => { const store = makeStore(); const existingNames = store.get().combatants.map((c) => c.name); const { newName, renames } = resolveCreatureName( entry.name, existingNames, ); // Apply renames (e.g., "Goblin" → "Goblin 1") for (const { from, to } of renames) { const target = store.get().combatants.find((c) => c.name === from); if (target) { editCombatantUseCase(makeStore(), target.id, to); } } // Add combatant with resolved name const id = combatantId(`c-${++nextId.current}`); const addResult = addCombatantUseCase(makeStore(), id, newName); if (isDomainError(addResult)) return null; // Set HP const hpResult = setHpUseCase(makeStore(), id, entry.hp); if (!isDomainError(hpResult)) { setEvents((prev) => [...prev, ...hpResult]); } // Set AC if (entry.ac > 0) { const acResult = setAcUseCase(makeStore(), id, entry.ac); if (!isDomainError(acResult)) { setEvents((prev) => [...prev, ...acResult]); } } // Derive creatureId from source + name const slug = entry.name .toLowerCase() .replaceAll(/[^a-z0-9]+/g, "-") .replaceAll(/(^-|-$)/g, ""); const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`); // Set creatureId on the combatant (use store.save to keep ref in sync for batch calls) const currentEncounter = store.get(); store.save({ ...currentEncounter, combatants: currentEncounter.combatants.map((c) => c.id === id ? { ...c, creatureId: cId } : c, ), }); setEvents((prev) => [...prev, ...addResult]); return cId; }, [makeStore], ); const addFromPlayerCharacter = useCallback( (pc: PlayerCharacter) => { 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 id = combatantId(`c-${++nextId.current}`); const addResult = addCombatantUseCase(makeStore(), id, newName); if (isDomainError(addResult)) return; // Set HP const hpResult = setHpUseCase(makeStore(), id, pc.maxHp); if (!isDomainError(hpResult)) { setEvents((prev) => [...prev, ...hpResult]); } // Set AC if (pc.ac > 0) { const acResult = setAcUseCase(makeStore(), id, pc.ac); if (!isDomainError(acResult)) { setEvents((prev) => [...prev, ...acResult]); } } // Set color, icon, and playerCharacterId on the combatant const currentEncounter = store.get(); store.save({ ...currentEncounter, combatants: currentEncounter.combatants.map((c) => c.id === id ? { ...c, color: pc.color, icon: pc.icon, playerCharacterId: pc.id, } : c, ), }); setEvents((prev) => [...prev, ...addResult]); }, [makeStore], ); 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, events, isEmpty, hasTempHp, hasCreatureCombatants, canRollAllInitiative, advanceTurn, retreatTurn, addCombatant, clearEncounter, removeCombatant, editCombatant, setInitiative, setHp, adjustHp, setTempHp, setAc, toggleCondition, toggleConcentration, addFromBestiary, addFromPlayerCharacter, makeStore, } as const; }