import type { EncounterStore, UndoRedoStore } from "@initiative/application"; import { addCombatantUseCase, addPersistentDamageUseCase, adjustHpUseCase, advanceTurnUseCase, clearEncounterUseCase, decrementConditionUseCase, editCombatantUseCase, redoUseCase, removeCombatantUseCase, removePersistentDamageUseCase, retreatTurnUseCase, setAcUseCase, setConditionValueUseCase, setCrUseCase, setHpUseCase, setInitiativeUseCase, setSideUseCase, setTempHpUseCase, toggleConcentrationUseCase, toggleConditionUseCase, undoUseCase, } from "@initiative/application"; import type { CombatantId, CombatantInit, ConditionId, CreatureId, DomainError, DomainEvent, Encounter, PersistentDamageType, Pf2eCreature, PlayerCharacter, UndoRedoState, } from "@initiative/domain"; import { acDelta, clearHistory, combatantId, hpDelta, 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: "add-persistent-damage"; id: CombatantId; damageType: PersistentDamageType; formula: string; } | { type: "remove-persistent-damage"; id: CombatantId; damageType: PersistentDamageType; } | { type: "clear-encounter" } | { type: "undo" } | { type: "redo" } | { type: "add-from-bestiary"; entry: SearchResult } | { type: "add-multiple-from-bestiary"; entry: SearchResult; count: number; } | { type: "set-creature-adjustment"; id: CombatantId; adjustment: "weak" | "elite" | undefined; baseCreature: Pf2eCreature; } | { 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, }; } function applyNamePrefix( name: string, oldAdj: "weak" | "elite" | undefined, newAdj: "weak" | "elite" | undefined, ): string { let base = name; if (oldAdj === "weak" && name.startsWith("Weak ")) base = name.slice(5); else if (oldAdj === "elite" && name.startsWith("Elite ")) base = name.slice(6); if (newAdj === "weak") return `Weak ${base}`; if (newAdj === "elite") return `Elite ${base}`; return base; } function handleSetCreatureAdjustment( state: EncounterState, id: CombatantId, adjustment: "weak" | "elite" | undefined, baseCreature: Pf2eCreature, ): EncounterState { const combatant = state.encounter.combatants.find((c) => c.id === id); if (!combatant) return state; const oldAdj = combatant.creatureAdjustment; if (oldAdj === adjustment) return state; const baseLevel = baseCreature.level; const oldHpDelta = oldAdj ? hpDelta(baseLevel, oldAdj) : 0; const newHpDelta = adjustment ? hpDelta(baseLevel, adjustment) : 0; const netHpDelta = newHpDelta - oldHpDelta; const oldAcDelta = oldAdj ? acDelta(oldAdj) : 0; const newAcDelta = adjustment ? acDelta(adjustment) : 0; const netAcDelta = newAcDelta - oldAcDelta; const newMaxHp = combatant.maxHp === undefined ? undefined : combatant.maxHp + netHpDelta; const newCurrentHp = combatant.currentHp === undefined || newMaxHp === undefined ? undefined : Math.max(0, Math.min(combatant.currentHp + netHpDelta, newMaxHp)); const newAc = combatant.ac === undefined ? undefined : combatant.ac + netAcDelta; const newName = applyNamePrefix(combatant.name, oldAdj, adjustment); const updatedCombatant: typeof combatant = { ...combatant, name: newName, ...(newMaxHp !== undefined && { maxHp: newMaxHp }), ...(newCurrentHp !== undefined && { currentHp: newCurrentHp }), ...(newAc !== undefined && { ac: newAc }), ...(adjustment === undefined ? { creatureAdjustment: undefined } : { creatureAdjustment: adjustment }), }; const combatants = state.encounter.combatants.map((c) => c.id === id ? updatedCombatant : c, ); return { ...state, encounter: { ...state.encounter, combatants }, events: [ ...state.events, { type: "CreatureAdjustmentSet", combatantId: id, adjustment }, ], }; } // -- 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 "set-creature-adjustment": return handleSetCreatureAdjustment( state, action.id, action.adjustment, action.baseCreature, ); 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" } | { type: "add-persistent-damage" } | { type: "remove-persistent-damage" } >, ): 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; case "add-persistent-damage": result = addPersistentDamageUseCase( store, action.id, action.damageType, action.formula, ); break; case "remove-persistent-damage": result = removePersistentDamageUseCase( store, action.id, action.damageType, ); 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 }), [], ), addPersistentDamage: useCallback( (id: CombatantId, damageType: PersistentDamageType, formula: string) => dispatch({ type: "add-persistent-damage", id, damageType, formula }), [], ), removePersistentDamage: useCallback( (id: CombatantId, damageType: PersistentDamageType) => dispatch({ type: "remove-persistent-damage", id, damageType }), [], ), setCreatureAdjustment: useCallback( ( id: CombatantId, adjustment: "weak" | "elite" | undefined, baseCreature: Pf2eCreature, ) => dispatch({ type: "set-creature-adjustment", id, adjustment, baseCreature, }), [], ), 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; }