From 80dd68752e544ee59fe2e64970963115bd8680bf Mon Sep 17 00:00:00 2001 From: Lukas Date: Sat, 28 Mar 2026 18:41:40 +0100 Subject: [PATCH] Refactor useEncounter from useState to useReducer Replaces 18 useCallback wrappers with a typed action union and encounterReducer. Undo/redo wrapping is now systematic per-case in the reducer instead of ad-hoc per operation. Complex cases (undo/redo, bestiary add, player character add) are extracted into helper functions. The stat block auto-show on bestiary add now uses lastCreatureId from reducer state instead of the synchronous return value, with a useEffect in use-action-bar-state to react to changes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/turn-navigation.test.tsx | 1 + apps/web/src/hooks/use-action-bar-state.ts | 47 +- apps/web/src/hooks/use-encounter.ts | 704 +++++++++++------- 3 files changed, 460 insertions(+), 292 deletions(-) diff --git a/apps/web/src/components/__tests__/turn-navigation.test.tsx b/apps/web/src/components/__tests__/turn-navigation.test.tsx index 1dc7455..4555402 100644 --- a/apps/web/src/components/__tests__/turn-navigation.test.tsx +++ b/apps/web/src/components/__tests__/turn-navigation.test.tsx @@ -72,6 +72,7 @@ function mockContext(overrides: Partial = {}) { setEncounter: vi.fn(), setUndoRedoState: vi.fn(), events: [], + lastCreatureId: null, }; mockUseEncounterContext.mockReturnValue( diff --git a/apps/web/src/hooks/use-action-bar-state.ts b/apps/web/src/hooks/use-action-bar-state.ts index 341532d..6eb48b9 100644 --- a/apps/web/src/hooks/use-action-bar-state.ts +++ b/apps/web/src/hooks/use-action-bar-state.ts @@ -1,5 +1,12 @@ import type { CreatureId, PlayerCharacter } from "@initiative/domain"; -import { useCallback, useDeferredValue, useMemo, useState } from "react"; +import { + useCallback, + useDeferredValue, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import type { SearchResult } from "../contexts/bestiary-context.js"; import { useBestiaryContext } from "../contexts/bestiary-context.js"; import { useEncounterContext } from "../contexts/encounter-context.js"; @@ -31,6 +38,7 @@ export function useActionBarState() { addFromBestiary, addMultipleFromBestiary, addFromPlayerCharacter, + lastCreatureId, } = useEncounterContext(); const { search: bestiarySearch, isLoaded: bestiaryLoaded } = useBestiaryContext(); @@ -38,6 +46,20 @@ export function useActionBarState() { const { showBulkImport, showSourceManager, showCreature, panelView } = useSidePanelContext(); + // Auto-show stat block when a bestiary creature is added on desktop + const prevCreatureIdRef = useRef(lastCreatureId); + useEffect(() => { + if ( + lastCreatureId && + lastCreatureId !== prevCreatureIdRef.current && + panelView.mode === "closed" && + globalThis.matchMedia("(min-width: 1024px)").matches + ) { + showCreature(lastCreatureId); + } + prevCreatureIdRef.current = lastCreatureId; + }, [lastCreatureId, panelView.mode, showCreature]); + const [nameInput, setNameInput] = useState(""); const [suggestions, setSuggestions] = useState([]); const [pcMatches, setPcMatches] = useState([]); @@ -73,13 +95,9 @@ export function useActionBarState() { const handleAddFromBestiary = useCallback( (result: SearchResult) => { - const creatureId = addFromBestiary(result); - const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches; - if (creatureId && panelView.mode === "closed" && isDesktop) { - showCreature(creatureId); - } + addFromBestiary(result); }, - [addFromBestiary, panelView.mode, showCreature], + [addFromBestiary], ); const handleViewStatBlock = useCallback( @@ -99,21 +117,10 @@ export function useActionBarState() { if (queued.count === 1) { handleAddFromBestiary(queued.result); } else { - const creatureId = addMultipleFromBestiary(queued.result, queued.count); - const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches; - if (creatureId && panelView.mode === "closed" && isDesktop) { - showCreature(creatureId); - } + addMultipleFromBestiary(queued.result, queued.count); } clearInput(); - }, [ - queued, - handleAddFromBestiary, - addMultipleFromBestiary, - panelView.mode, - showCreature, - clearInput, - ]); + }, [queued, handleAddFromBestiary, addMultipleFromBestiary, clearInput]); const parseNum = (v: string): number | undefined => { if (v.trim() === "") return undefined; diff --git a/apps/web/src/hooks/use-encounter.ts b/apps/web/src/hooks/use-encounter.ts index 9bd419f..db5db1f 100644 --- a/apps/web/src/hooks/use-encounter.ts +++ b/apps/web/src/hooks/use-encounter.ts @@ -36,7 +36,7 @@ import { pushUndo, resolveCreatureName, } from "@initiative/domain"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useReducer, useRef } from "react"; import { loadEncounter, saveEncounter, @@ -46,6 +46,51 @@ import { saveUndoRedoStacks, } from "../persistence/undo-redo-storage.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: "toggle-condition"; + id: CombatantId; + conditionId: ConditionId; + } + | { type: "toggle-concentration"; id: CombatantId } + | { type: "clear-encounter" } + | { type: "undo" } + | { type: "redo" } + | { type: "add-from-bestiary"; entry: BestiaryIndexEntry } + | { + type: "add-multiple-from-bestiary"; + entry: BestiaryIndexEntry; + count: number; + } + | { type: "add-from-player-character"; pc: PlayerCharacter } + | { + type: "import"; + encounter: Encounter; + undoRedoState: UndoRedoState; + }; + +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 = { @@ -54,12 +99,6 @@ const EMPTY_ENCOUNTER: Encounter = { 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) { @@ -72,11 +111,283 @@ function deriveNextId(encounter: Encounter): number { return max; } +function initializeState(): EncounterState { + const encounter = loadEncounter() ?? EMPTY_ENCOUNTER; + return { + encounter, + undoRedoState: loadUndoRedoStacks(), + 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: BestiaryIndexEntry, + 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, + 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: BestiaryIndexEntry, + 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 -- + +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: "toggle-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 "toggle-condition": + result = toggleConditionUseCase(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 [encounter, setEncounter] = useState(initializeEncounter); - const [events, setEvents] = useState([]); - const [undoRedoState, setUndoRedoState] = - useState(loadUndoRedoStacks); + const [state, dispatch] = useReducer(encounterReducer, null, initializeState); + const { encounter, undoRedoState, events } = state; + const encounterRef = useRef(encounter); encounterRef.current = encounter; const undoRedoRef = useRef(undoRedoState); @@ -90,22 +401,17 @@ export function useEncounter() { saveUndoRedoStacks(undoRedoState); }, [undoRedoState]); + // Escape hatches for useInitiativeRolls (needs raw port access) 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); + dispatch({ + type: "import", + encounter: e, + undoRedoState: undoRedoRef.current, + }); }, }; }, []); @@ -116,245 +422,21 @@ export function useEncounter() { if (!isDomainError(result)) { const newState = pushUndo(undoRedoRef.current, snapshot); undoRedoRef.current = newState; - setUndoRedoState(newState); + dispatch({ + type: "import", + encounter: encounterRef.current, + undoRedoState: 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]); - + // 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, @@ -373,27 +455,105 @@ export function useEncounter() { 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, + 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 }), + [], + ), + toggleCondition: useCallback( + (id: CombatantId, conditionId: ConditionId) => + dispatch({ type: "toggle-condition", id, conditionId }), + [], + ), + toggleConcentration: useCallback( + (id: CombatantId) => dispatch({ type: "toggle-concentration", id }), + [], + ), + clearEncounter: useCallback( + () => dispatch({ type: "clear-encounter" }), + [], + ), + addFromBestiary: useCallback( + (entry: BestiaryIndexEntry): CreatureId | null => { + dispatch({ type: "add-from-bestiary", entry }); + return null; + }, + [], + ), + addMultipleFromBestiary: useCallback( + (entry: BestiaryIndexEntry, 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; }