Add undo/redo for all encounter actions
Memento-based undo/redo with full encounter snapshots. Undo stack capped at 50 entries, persisted to localStorage. Triggered via buttons in the top bar (inboard of turn navigation) and keyboard shortcuts (Ctrl+Z / Ctrl+Shift+Z, Cmd on Mac, case-insensitive key matching). Clear encounter resets both stacks. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import type { EncounterStore } from "@initiative/application";
|
||||
import type { EncounterStore, UndoRedoStore } from "@initiative/application";
|
||||
import {
|
||||
addCombatantUseCase,
|
||||
adjustHpUseCase,
|
||||
advanceTurnUseCase,
|
||||
clearEncounterUseCase,
|
||||
editCombatantUseCase,
|
||||
redoUseCase,
|
||||
removeCombatantUseCase,
|
||||
retreatTurnUseCase,
|
||||
setAcUseCase,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
setTempHpUseCase,
|
||||
toggleConcentrationUseCase,
|
||||
toggleConditionUseCase,
|
||||
undoUseCase,
|
||||
} from "@initiative/application";
|
||||
import type {
|
||||
BestiaryIndexEntry,
|
||||
@@ -23,11 +25,14 @@ import type {
|
||||
DomainEvent,
|
||||
Encounter,
|
||||
PlayerCharacter,
|
||||
UndoRedoState,
|
||||
} from "@initiative/domain";
|
||||
import {
|
||||
clearHistory,
|
||||
combatantId,
|
||||
isDomainError,
|
||||
creatureId as makeCreatureId,
|
||||
pushUndo,
|
||||
resolveCreatureName,
|
||||
} from "@initiative/domain";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
@@ -35,6 +40,10 @@ import {
|
||||
loadEncounter,
|
||||
saveEncounter,
|
||||
} from "../persistence/encounter-storage.js";
|
||||
import {
|
||||
loadUndoRedoStacks,
|
||||
saveUndoRedoStacks,
|
||||
} from "../persistence/undo-redo-storage.js";
|
||||
|
||||
const COMBATANT_ID_REGEX = /^c-(\d+)$/;
|
||||
|
||||
@@ -65,13 +74,21 @@ function deriveNextId(encounter: Encounter): number {
|
||||
export function useEncounter() {
|
||||
const [encounter, setEncounter] = useState<Encounter>(initializeEncounter);
|
||||
const [events, setEvents] = useState<DomainEvent[]>([]);
|
||||
const [undoRedoState, setUndoRedoState] =
|
||||
useState<UndoRedoState>(loadUndoRedoStacks);
|
||||
const encounterRef = useRef(encounter);
|
||||
encounterRef.current = encounter;
|
||||
const undoRedoRef = useRef(undoRedoState);
|
||||
undoRedoRef.current = undoRedoState;
|
||||
|
||||
useEffect(() => {
|
||||
saveEncounter(encounter);
|
||||
}, [encounter]);
|
||||
|
||||
useEffect(() => {
|
||||
saveUndoRedoStacks(undoRedoState);
|
||||
}, [undoRedoState]);
|
||||
|
||||
const makeStore = useCallback((): EncounterStore => {
|
||||
return {
|
||||
get: () => encounterRef.current,
|
||||
@@ -82,32 +99,55 @@ export function useEncounter() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const makeUndoRedoStore = useCallback((): UndoRedoStore => {
|
||||
return {
|
||||
get: () => undoRedoRef.current,
|
||||
save: (s) => {
|
||||
undoRedoRef.current = s;
|
||||
setUndoRedoState(s);
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
const withUndo = useCallback(<T>(action: () => T): T => {
|
||||
const snapshot = encounterRef.current;
|
||||
const result = action();
|
||||
if (!isDomainError(result)) {
|
||||
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||
undoRedoRef.current = newState;
|
||||
setUndoRedoState(newState);
|
||||
}
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
const advanceTurn = useCallback(() => {
|
||||
const result = advanceTurnUseCase(makeStore());
|
||||
const result = withUndo(() => advanceTurnUseCase(makeStore()));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
}, [makeStore]);
|
||||
}, [makeStore, withUndo]);
|
||||
|
||||
const retreatTurn = useCallback(() => {
|
||||
const result = retreatTurnUseCase(makeStore());
|
||||
const result = withUndo(() => retreatTurnUseCase(makeStore()));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
}, [makeStore]);
|
||||
}, [makeStore, withUndo]);
|
||||
|
||||
const nextId = useRef(deriveNextId(encounter));
|
||||
|
||||
const addCombatant = useCallback(
|
||||
(name: string, init?: CombatantInit) => {
|
||||
const id = combatantId(`c-${++nextId.current}`);
|
||||
const result = addCombatantUseCase(makeStore(), id, name, init);
|
||||
const result = withUndo(() =>
|
||||
addCombatantUseCase(makeStore(), id, name, init),
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
@@ -115,12 +155,12 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const removeCombatant = useCallback(
|
||||
(id: CombatantId) => {
|
||||
const result = removeCombatantUseCase(makeStore(), id);
|
||||
const result = withUndo(() => removeCombatantUseCase(makeStore(), id));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
@@ -128,12 +168,14 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const editCombatant = useCallback(
|
||||
(id: CombatantId, newName: string) => {
|
||||
const result = editCombatantUseCase(makeStore(), id, newName);
|
||||
const result = withUndo(() =>
|
||||
editCombatantUseCase(makeStore(), id, newName),
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
@@ -141,12 +183,14 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const setInitiative = useCallback(
|
||||
(id: CombatantId, value: number | undefined) => {
|
||||
const result = setInitiativeUseCase(makeStore(), id, value);
|
||||
const result = withUndo(() =>
|
||||
setInitiativeUseCase(makeStore(), id, value),
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
@@ -154,12 +198,12 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const setHp = useCallback(
|
||||
(id: CombatantId, maxHp: number | undefined) => {
|
||||
const result = setHpUseCase(makeStore(), id, maxHp);
|
||||
const result = withUndo(() => setHpUseCase(makeStore(), id, maxHp));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
@@ -167,12 +211,12 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const adjustHp = useCallback(
|
||||
(id: CombatantId, delta: number) => {
|
||||
const result = adjustHpUseCase(makeStore(), id, delta);
|
||||
const result = withUndo(() => adjustHpUseCase(makeStore(), id, delta));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
@@ -180,12 +224,12 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const setTempHp = useCallback(
|
||||
(id: CombatantId, tempHp: number | undefined) => {
|
||||
const result = setTempHpUseCase(makeStore(), id, tempHp);
|
||||
const result = withUndo(() => setTempHpUseCase(makeStore(), id, tempHp));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
@@ -193,12 +237,12 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const setAc = useCallback(
|
||||
(id: CombatantId, value: number | undefined) => {
|
||||
const result = setAcUseCase(makeStore(), id, value);
|
||||
const result = withUndo(() => setAcUseCase(makeStore(), id, value));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
@@ -206,12 +250,14 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const toggleCondition = useCallback(
|
||||
(id: CombatantId, conditionId: ConditionId) => {
|
||||
const result = toggleConditionUseCase(makeStore(), id, conditionId);
|
||||
const result = withUndo(() =>
|
||||
toggleConditionUseCase(makeStore(), id, conditionId),
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
@@ -219,12 +265,14 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const toggleConcentration = useCallback(
|
||||
(id: CombatantId) => {
|
||||
const result = toggleConcentrationUseCase(makeStore(), id);
|
||||
const result = withUndo(() =>
|
||||
toggleConcentrationUseCase(makeStore(), id),
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
@@ -232,7 +280,7 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const clearEncounter = useCallback(() => {
|
||||
@@ -242,12 +290,17 @@ export function useEncounter() {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleared = clearHistory();
|
||||
undoRedoRef.current = cleared;
|
||||
setUndoRedoState(cleared);
|
||||
|
||||
nextId.current = 0;
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
}, [makeStore]);
|
||||
|
||||
const addFromBestiary = useCallback(
|
||||
(entry: BestiaryIndexEntry): CreatureId | null => {
|
||||
const snapshot = encounterRef.current;
|
||||
const store = makeStore();
|
||||
const existingNames = store.get().combatants.map((c) => c.name);
|
||||
const { newName, renames } = resolveCreatureName(
|
||||
@@ -277,6 +330,10 @@ export function useEncounter() {
|
||||
|
||||
if (isDomainError(result)) return null;
|
||||
|
||||
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||
undoRedoRef.current = newState;
|
||||
setUndoRedoState(newState);
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
return cId;
|
||||
},
|
||||
@@ -285,6 +342,7 @@ export function useEncounter() {
|
||||
|
||||
const addFromPlayerCharacter = useCallback(
|
||||
(pc: PlayerCharacter) => {
|
||||
const snapshot = encounterRef.current;
|
||||
const store = makeStore();
|
||||
const existingNames = store.get().combatants.map((c) => c.name);
|
||||
const { newName, renames } = resolveCreatureName(pc.name, existingNames);
|
||||
@@ -307,11 +365,26 @@ export function useEncounter() {
|
||||
|
||||
if (isDomainError(result)) return;
|
||||
|
||||
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||
undoRedoRef.current = newState;
|
||||
setUndoRedoState(newState);
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
);
|
||||
|
||||
const undoAction = useCallback(() => {
|
||||
undoUseCase(makeStore(), makeUndoRedoStore());
|
||||
}, [makeStore, makeUndoRedoStore]);
|
||||
|
||||
const redoAction = useCallback(() => {
|
||||
redoUseCase(makeStore(), makeUndoRedoStore());
|
||||
}, [makeStore, makeUndoRedoStore]);
|
||||
|
||||
const canUndo = undoRedoState.undoStack.length > 0;
|
||||
const canRedo = undoRedoState.redoStack.length > 0;
|
||||
|
||||
const hasTempHp = encounter.combatants.some(
|
||||
(c) => c.tempHp !== undefined && c.tempHp > 0,
|
||||
);
|
||||
@@ -331,6 +404,8 @@ export function useEncounter() {
|
||||
hasTempHp,
|
||||
hasCreatureCombatants,
|
||||
canRollAllInitiative,
|
||||
canUndo,
|
||||
canRedo,
|
||||
advanceTurn,
|
||||
retreatTurn,
|
||||
addCombatant,
|
||||
@@ -346,6 +421,8 @@ export function useEncounter() {
|
||||
toggleConcentration,
|
||||
addFromBestiary,
|
||||
addFromPlayerCharacter,
|
||||
undo: undoAction,
|
||||
redo: redoAction,
|
||||
makeStore,
|
||||
} as const;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user