import type { EncounterStore, UndoRedoStore } from "@initiative/application"; import { addCombatantUseCase, adjustHpUseCase, advanceTurnUseCase, clearEncounterUseCase, decrementConditionUseCase, editCombatantUseCase, redoUseCase, removeCombatantUseCase, retreatTurnUseCase, setAcUseCase, setConditionValueUseCase, setCrUseCase, setHpUseCase, setInitiativeUseCase, setSideUseCase, setTempHpUseCase, toggleConcentrationUseCase, toggleConditionUseCase, undoUseCase, } from "@initiative/application"; import type { 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, useReducer, useRef } from "react"; import { useAdapters } from "../contexts/adapter-context.js"; import type { SearchResult } from "./use-bestiary.js"; // -- Types -- type EncounterAction = | { type: "advance-turn" } | { type: "retreat-turn" } | { type: "add-combatant"; name: string; init?: CombatantInit } | { type: "remove-combatant"; id: CombatantId } | { type: "edit-combatant"; id: CombatantId; newName: string } | { type: "set-initiative"; id: CombatantId; value: number | undefined } | { type: "set-hp"; id: CombatantId; maxHp: number | undefined } | { type: "adjust-hp"; id: CombatantId; delta: number } | { type: "set-temp-hp"; id: CombatantId; tempHp: number | undefined } | { type: "set-ac"; id: CombatantId; value: number | undefined } | { type: "set-cr"; id: CombatantId; value: string | undefined } | { type: "set-side"; id: CombatantId; value: "party" | "enemy" } | { type: "toggle-condition"; id: CombatantId; conditionId: ConditionId; } | { type: "set-condition-value"; id: CombatantId; conditionId: ConditionId; value: number; } | { type: "decrement-condition"; id: CombatantId; conditionId: ConditionId; } | { type: "toggle-concentration"; id: CombatantId } | { type: "clear-encounter" } | { type: "undo" } | { type: "redo" } | { type: "add-from-bestiary"; entry: SearchResult } | { type: "add-multiple-from-bestiary"; entry: SearchResult; count: number; } | { type: "add-from-player-character"; pc: PlayerCharacter } | { type: "import"; encounter: Encounter; undoRedoState: UndoRedoState; }; export interface EncounterState { readonly encounter: Encounter; readonly undoRedoState: UndoRedoState; readonly events: readonly DomainEvent[]; readonly nextId: number; readonly lastCreatureId: CreatureId | null; } // -- Initialization -- const COMBATANT_ID_REGEX = /^c-(\d+)$/; const EMPTY_ENCOUNTER: Encounter = { combatants: [], activeIndex: 0, roundNumber: 1, }; 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; } function initializeState( loadEncounterFn: () => Encounter | null, loadUndoRedoFn: () => UndoRedoState, ): EncounterState { const encounter = loadEncounterFn() ?? EMPTY_ENCOUNTER; return { encounter, undoRedoState: loadUndoRedoFn(), events: [], nextId: deriveNextId(encounter), lastCreatureId: null, }; } // -- Helpers -- function makeStoreFromState(state: EncounterState): { store: EncounterStore; getEncounter: () => Encounter; } { let current = state.encounter; return { store: { get: () => current, save: (e) => { current = e; }, }, getEncounter: () => current, }; } function resolveAndRename(store: EncounterStore, name: string): string { 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(store, target.id, to); } } return newName; } function addOneFromBestiary( store: EncounterStore, entry: SearchResult, nextId: number, ): { cId: CreatureId; events: DomainEvent[]; nextId: number; } | null { const newName = resolveAndRename(store, 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 + 1}`); const result = addCombatantUseCase(store, id, newName, { maxHp: entry.hp > 0 ? entry.hp : undefined, ac: entry.ac > 0 ? entry.ac : undefined, creatureId: cId, }); if (isDomainError(result)) return null; return { cId, events: result, nextId: nextId + 1 }; } // -- Reducer case handlers -- function handleUndoRedo( state: EncounterState, direction: "undo" | "redo", ): EncounterState { const { store, getEncounter } = makeStoreFromState(state); const undoRedoStore: UndoRedoStore = { get: () => state.undoRedoState, save: () => {}, }; const applyFn = direction === "undo" ? undoUseCase : redoUseCase; const result = applyFn(store, undoRedoStore); if (isDomainError(result)) return state; const isUndo = direction === "undo"; return { ...state, encounter: getEncounter(), undoRedoState: { undoStack: isUndo ? state.undoRedoState.undoStack.slice(0, -1) : [...state.undoRedoState.undoStack, state.encounter], redoStack: isUndo ? [...state.undoRedoState.redoStack, state.encounter] : state.undoRedoState.redoStack.slice(0, -1), }, }; } function handleAddFromBestiary( state: EncounterState, entry: SearchResult, count: number, ): EncounterState { const { store, getEncounter } = makeStoreFromState(state); const allEvents: DomainEvent[] = []; let nextId = state.nextId; let lastCId: CreatureId | null = null; for (let i = 0; i < count; i++) { const added = addOneFromBestiary(store, entry, nextId); if (!added) return state; allEvents.push(...added.events); nextId = added.nextId; lastCId = added.cId; } return { ...state, encounter: getEncounter(), undoRedoState: pushUndo(state.undoRedoState, state.encounter), events: [...state.events, ...allEvents], nextId, lastCreatureId: lastCId, }; } function handleAddFromPlayerCharacter( state: EncounterState, pc: PlayerCharacter, ): EncounterState { const { store, getEncounter } = makeStoreFromState(state); const newName = resolveAndRename(store, pc.name); const id = combatantId(`c-${state.nextId + 1}`); const result = addCombatantUseCase(store, id, newName, { maxHp: pc.maxHp, ac: pc.ac > 0 ? pc.ac : undefined, color: pc.color, icon: pc.icon, playerCharacterId: pc.id, }); if (isDomainError(result)) return state; return { ...state, encounter: getEncounter(), undoRedoState: pushUndo(state.undoRedoState, state.encounter), events: [...state.events, ...result], nextId: state.nextId + 1, lastCreatureId: null, }; } // -- Reducer -- export function encounterReducer( state: EncounterState, action: EncounterAction, ): EncounterState { switch (action.type) { case "import": return { ...state, encounter: action.encounter, undoRedoState: action.undoRedoState, nextId: deriveNextId(action.encounter), lastCreatureId: null, }; case "undo": case "redo": return handleUndoRedo(state, action.type); case "clear-encounter": { const { store, getEncounter } = makeStoreFromState(state); const result = clearEncounterUseCase(store); if (isDomainError(result)) return state; return { ...state, encounter: getEncounter(), undoRedoState: clearHistory(), events: [...state.events, ...result], nextId: 0, lastCreatureId: null, }; } case "add-from-bestiary": return handleAddFromBestiary(state, action.entry, 1); case "add-multiple-from-bestiary": return handleAddFromBestiary(state, action.entry, action.count); case "add-from-player-character": return handleAddFromPlayerCharacter(state, action.pc); default: return dispatchEncounterAction(state, action); } } function dispatchEncounterAction( state: EncounterState, action: Extract< EncounterAction, | { type: "advance-turn" } | { type: "retreat-turn" } | { type: "add-combatant" } | { type: "remove-combatant" } | { type: "edit-combatant" } | { type: "set-initiative" } | { type: "set-hp" } | { type: "adjust-hp" } | { type: "set-temp-hp" } | { type: "set-ac" } | { type: "set-cr" } | { type: "set-side" } | { type: "toggle-condition" } | { type: "set-condition-value" } | { type: "decrement-condition" } | { type: "toggle-concentration" } >, ): EncounterState { const { store, getEncounter } = makeStoreFromState(state); let result: DomainEvent[] | DomainError; switch (action.type) { case "advance-turn": result = advanceTurnUseCase(store); break; case "retreat-turn": result = retreatTurnUseCase(store); break; case "add-combatant": { const id = combatantId(`c-${state.nextId + 1}`); result = addCombatantUseCase(store, id, action.name, action.init); break; } case "remove-combatant": result = removeCombatantUseCase(store, action.id); break; case "edit-combatant": result = editCombatantUseCase(store, action.id, action.newName); break; case "set-initiative": result = setInitiativeUseCase(store, action.id, action.value); break; case "set-hp": result = setHpUseCase(store, action.id, action.maxHp); break; case "adjust-hp": result = adjustHpUseCase(store, action.id, action.delta); break; case "set-temp-hp": result = setTempHpUseCase(store, action.id, action.tempHp); break; case "set-ac": result = setAcUseCase(store, action.id, action.value); break; case "set-cr": result = setCrUseCase(store, action.id, action.value); break; case "set-side": result = setSideUseCase(store, action.id, action.value); break; case "toggle-condition": result = toggleConditionUseCase(store, action.id, action.conditionId); break; case "set-condition-value": result = setConditionValueUseCase( store, action.id, action.conditionId, action.value, ); break; case "decrement-condition": result = decrementConditionUseCase(store, action.id, action.conditionId); break; case "toggle-concentration": result = toggleConcentrationUseCase(store, action.id); break; } if (isDomainError(result)) return state; return { ...state, encounter: getEncounter(), undoRedoState: pushUndo(state.undoRedoState, state.encounter), events: [...state.events, ...result], nextId: action.type === "add-combatant" ? state.nextId + 1 : state.nextId, lastCreatureId: null, }; } // -- Hook -- export function useEncounter() { const { encounterPersistence, undoRedoPersistence } = useAdapters(); const [state, dispatch] = useReducer(encounterReducer, null, () => initializeState(encounterPersistence.load, undoRedoPersistence.load), ); const { encounter, undoRedoState, events } = state; const encounterRef = useRef(encounter); encounterRef.current = encounter; const undoRedoRef = useRef(undoRedoState); undoRedoRef.current = undoRedoState; useEffect(() => { encounterPersistence.save(encounter); }, [encounter, encounterPersistence]); useEffect(() => { undoRedoPersistence.save(undoRedoState); }, [undoRedoState, undoRedoPersistence]); // Escape hatches for useInitiativeRolls (needs raw port access) const makeStore = useCallback((): EncounterStore => { return { get: () => encounterRef.current, save: (e) => { encounterRef.current = e; dispatch({ type: "import", encounter: e, undoRedoState: undoRedoRef.current, }); }, }; }, []); 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; dispatch({ type: "import", encounter: encounterRef.current, undoRedoState: newState, }); } return result; }, []); // Derived state 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: useCallback(() => dispatch({ type: "advance-turn" }), []), retreatTurn: useCallback(() => dispatch({ type: "retreat-turn" }), []), addCombatant: useCallback( (name: string, init?: CombatantInit) => dispatch({ type: "add-combatant", name, init }), [], ), removeCombatant: useCallback( (id: CombatantId) => dispatch({ type: "remove-combatant", id }), [], ), editCombatant: useCallback( (id: CombatantId, newName: string) => dispatch({ type: "edit-combatant", id, newName }), [], ), setInitiative: useCallback( (id: CombatantId, value: number | undefined) => dispatch({ type: "set-initiative", id, value }), [], ), setHp: useCallback( (id: CombatantId, maxHp: number | undefined) => dispatch({ type: "set-hp", id, maxHp }), [], ), adjustHp: useCallback( (id: CombatantId, delta: number) => dispatch({ type: "adjust-hp", id, delta }), [], ), setTempHp: useCallback( (id: CombatantId, tempHp: number | undefined) => dispatch({ type: "set-temp-hp", id, tempHp }), [], ), setAc: useCallback( (id: CombatantId, value: number | undefined) => dispatch({ type: "set-ac", id, value }), [], ), setCr: useCallback( (id: CombatantId, value: string | undefined) => dispatch({ type: "set-cr", id, value }), [], ), setSide: useCallback( (id: CombatantId, value: "party" | "enemy") => dispatch({ type: "set-side", id, value }), [], ), toggleCondition: useCallback( (id: CombatantId, conditionId: ConditionId) => dispatch({ type: "toggle-condition", id, conditionId }), [], ), setConditionValue: useCallback( (id: CombatantId, conditionId: ConditionId, value: number) => dispatch({ type: "set-condition-value", id, conditionId, value }), [], ), decrementCondition: useCallback( (id: CombatantId, conditionId: ConditionId) => dispatch({ type: "decrement-condition", id, conditionId }), [], ), toggleConcentration: useCallback( (id: CombatantId) => dispatch({ type: "toggle-concentration", id }), [], ), clearEncounter: useCallback( () => dispatch({ type: "clear-encounter" }), [], ), addFromBestiary: useCallback((entry: SearchResult): CreatureId | null => { dispatch({ type: "add-from-bestiary", entry }); return null; }, []), addMultipleFromBestiary: useCallback( (entry: SearchResult, count: number): CreatureId | null => { dispatch({ type: "add-multiple-from-bestiary", entry, count, }); return null; }, [], ), addFromPlayerCharacter: useCallback( (pc: PlayerCharacter) => dispatch({ type: "add-from-player-character", pc }), [], ), undo: useCallback(() => dispatch({ type: "undo" }), []), redo: useCallback(() => dispatch({ type: "redo" }), []), setEncounter: useCallback( (enc: Encounter) => dispatch({ type: "import", encounter: enc, undoRedoState: undoRedoRef.current, }), [], ), setUndoRedoState: useCallback( (urs: UndoRedoState) => dispatch({ type: "import", encounter: encounterRef.current, undoRedoState: urs, }), [], ), makeStore, withUndo, lastCreatureId: state.lastCreatureId, } as const; }