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) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-28 18:41:40 +01:00
parent 896fd427ed
commit 80dd68752e
3 changed files with 460 additions and 292 deletions

View File

@@ -72,6 +72,7 @@ function mockContext(overrides: Partial<Encounter> = {}) {
setEncounter: vi.fn(), setEncounter: vi.fn(),
setUndoRedoState: vi.fn(), setUndoRedoState: vi.fn(),
events: [], events: [],
lastCreatureId: null,
}; };
mockUseEncounterContext.mockReturnValue( mockUseEncounterContext.mockReturnValue(

View File

@@ -1,5 +1,12 @@
import type { CreatureId, PlayerCharacter } from "@initiative/domain"; 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 type { SearchResult } from "../contexts/bestiary-context.js";
import { useBestiaryContext } from "../contexts/bestiary-context.js"; import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { useEncounterContext } from "../contexts/encounter-context.js"; import { useEncounterContext } from "../contexts/encounter-context.js";
@@ -31,6 +38,7 @@ export function useActionBarState() {
addFromBestiary, addFromBestiary,
addMultipleFromBestiary, addMultipleFromBestiary,
addFromPlayerCharacter, addFromPlayerCharacter,
lastCreatureId,
} = useEncounterContext(); } = useEncounterContext();
const { search: bestiarySearch, isLoaded: bestiaryLoaded } = const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
useBestiaryContext(); useBestiaryContext();
@@ -38,6 +46,20 @@ export function useActionBarState() {
const { showBulkImport, showSourceManager, showCreature, panelView } = const { showBulkImport, showSourceManager, showCreature, panelView } =
useSidePanelContext(); 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 [nameInput, setNameInput] = useState("");
const [suggestions, setSuggestions] = useState<SearchResult[]>([]); const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]); const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
@@ -73,13 +95,9 @@ export function useActionBarState() {
const handleAddFromBestiary = useCallback( const handleAddFromBestiary = useCallback(
(result: SearchResult) => { (result: SearchResult) => {
const creatureId = addFromBestiary(result); addFromBestiary(result);
const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches;
if (creatureId && panelView.mode === "closed" && isDesktop) {
showCreature(creatureId);
}
}, },
[addFromBestiary, panelView.mode, showCreature], [addFromBestiary],
); );
const handleViewStatBlock = useCallback( const handleViewStatBlock = useCallback(
@@ -99,21 +117,10 @@ export function useActionBarState() {
if (queued.count === 1) { if (queued.count === 1) {
handleAddFromBestiary(queued.result); handleAddFromBestiary(queued.result);
} else { } else {
const creatureId = addMultipleFromBestiary(queued.result, queued.count); addMultipleFromBestiary(queued.result, queued.count);
const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches;
if (creatureId && panelView.mode === "closed" && isDesktop) {
showCreature(creatureId);
}
} }
clearInput(); clearInput();
}, [ }, [queued, handleAddFromBestiary, addMultipleFromBestiary, clearInput]);
queued,
handleAddFromBestiary,
addMultipleFromBestiary,
panelView.mode,
showCreature,
clearInput,
]);
const parseNum = (v: string): number | undefined => { const parseNum = (v: string): number | undefined => {
if (v.trim() === "") return undefined; if (v.trim() === "") return undefined;

View File

@@ -36,7 +36,7 @@ import {
pushUndo, pushUndo,
resolveCreatureName, resolveCreatureName,
} from "@initiative/domain"; } from "@initiative/domain";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useReducer, useRef } from "react";
import { import {
loadEncounter, loadEncounter,
saveEncounter, saveEncounter,
@@ -46,6 +46,51 @@ import {
saveUndoRedoStacks, saveUndoRedoStacks,
} from "../persistence/undo-redo-storage.js"; } 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 COMBATANT_ID_REGEX = /^c-(\d+)$/;
const EMPTY_ENCOUNTER: Encounter = { const EMPTY_ENCOUNTER: Encounter = {
@@ -54,12 +99,6 @@ const EMPTY_ENCOUNTER: Encounter = {
roundNumber: 1, roundNumber: 1,
}; };
function initializeEncounter(): Encounter {
const stored = loadEncounter();
if (stored !== null) return stored;
return EMPTY_ENCOUNTER;
}
function deriveNextId(encounter: Encounter): number { function deriveNextId(encounter: Encounter): number {
let max = 0; let max = 0;
for (const c of encounter.combatants) { for (const c of encounter.combatants) {
@@ -72,11 +111,283 @@ function deriveNextId(encounter: Encounter): number {
return max; 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() { export function useEncounter() {
const [encounter, setEncounter] = useState<Encounter>(initializeEncounter); const [state, dispatch] = useReducer(encounterReducer, null, initializeState);
const [events, setEvents] = useState<DomainEvent[]>([]); const { encounter, undoRedoState, events } = state;
const [undoRedoState, setUndoRedoState] =
useState<UndoRedoState>(loadUndoRedoStacks);
const encounterRef = useRef(encounter); const encounterRef = useRef(encounter);
encounterRef.current = encounter; encounterRef.current = encounter;
const undoRedoRef = useRef(undoRedoState); const undoRedoRef = useRef(undoRedoState);
@@ -90,22 +401,17 @@ export function useEncounter() {
saveUndoRedoStacks(undoRedoState); saveUndoRedoStacks(undoRedoState);
}, [undoRedoState]); }, [undoRedoState]);
// Escape hatches for useInitiativeRolls (needs raw port access)
const makeStore = useCallback((): EncounterStore => { const makeStore = useCallback((): EncounterStore => {
return { return {
get: () => encounterRef.current, get: () => encounterRef.current,
save: (e) => { save: (e) => {
encounterRef.current = e; encounterRef.current = e;
setEncounter(e); dispatch({
}, type: "import",
}; encounter: e,
}, []); undoRedoState: undoRedoRef.current,
});
const makeUndoRedoStore = useCallback((): UndoRedoStore => {
return {
get: () => undoRedoRef.current,
save: (s) => {
undoRedoRef.current = s;
setUndoRedoState(s);
}, },
}; };
}, []); }, []);
@@ -116,245 +422,21 @@ export function useEncounter() {
if (!isDomainError(result)) { if (!isDomainError(result)) {
const newState = pushUndo(undoRedoRef.current, snapshot); const newState = pushUndo(undoRedoRef.current, snapshot);
undoRedoRef.current = newState; undoRedoRef.current = newState;
setUndoRedoState(newState); dispatch({
type: "import",
encounter: encounterRef.current,
undoRedoState: newState,
});
} }
return result; return result;
}, []); }, []);
const dispatchAction = useCallback( // Derived state
(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 canUndo = undoRedoState.undoStack.length > 0;
const canRedo = undoRedoState.redoStack.length > 0; const canRedo = undoRedoState.redoStack.length > 0;
const hasTempHp = encounter.combatants.some( const hasTempHp = encounter.combatants.some(
(c) => c.tempHp !== undefined && c.tempHp > 0, (c) => c.tempHp !== undefined && c.tempHp > 0,
); );
const isEmpty = encounter.combatants.length === 0; const isEmpty = encounter.combatants.length === 0;
const hasCreatureCombatants = encounter.combatants.some( const hasCreatureCombatants = encounter.combatants.some(
(c) => c.creatureId != null, (c) => c.creatureId != null,
@@ -373,27 +455,105 @@ export function useEncounter() {
canRollAllInitiative, canRollAllInitiative,
canUndo, canUndo,
canRedo, canRedo,
advanceTurn, advanceTurn: useCallback(() => dispatch({ type: "advance-turn" }), []),
retreatTurn, retreatTurn: useCallback(() => dispatch({ type: "retreat-turn" }), []),
addCombatant, addCombatant: useCallback(
clearEncounter, (name: string, init?: CombatantInit) =>
removeCombatant, dispatch({ type: "add-combatant", name, init }),
editCombatant, [],
setInitiative, ),
setHp, removeCombatant: useCallback(
adjustHp, (id: CombatantId) => dispatch({ type: "remove-combatant", id }),
setTempHp, [],
setAc, ),
toggleCondition, editCombatant: useCallback(
toggleConcentration, (id: CombatantId, newName: string) =>
addFromBestiary, dispatch({ type: "edit-combatant", id, newName }),
addMultipleFromBestiary, [],
addFromPlayerCharacter, ),
undo: undoAction, setInitiative: useCallback(
redo: redoAction, (id: CombatantId, value: number | undefined) =>
setEncounter, dispatch({ type: "set-initiative", id, value }),
setUndoRedoState, [],
),
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, makeStore,
withUndo, withUndo,
lastCreatureId: state.lastCreatureId,
} as const; } as const;
} }