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:
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user