From 9437272fe05b8182f265be92d578133d0ae9fa62 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 27 Mar 2026 00:07:25 +0100 Subject: [PATCH] Batch bestiary add produces a single undo entry Extract addOneFromBestiary (no undo) and build addMultipleFromBestiary on top so confirming N creatures from the bestiary panel creates one undo entry that restores the entire batch, not N individual entries. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/turn-navigation.test.tsx | 1 + apps/web/src/hooks/use-action-bar-state.ts | 25 +++++++-- apps/web/src/hooks/use-encounter.ts | 56 ++++++++++++++++--- 3 files changed, 70 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/__tests__/turn-navigation.test.tsx b/apps/web/src/components/__tests__/turn-navigation.test.tsx index 8a559a3..9e04ea1 100644 --- a/apps/web/src/components/__tests__/turn-navigation.test.tsx +++ b/apps/web/src/components/__tests__/turn-navigation.test.tsx @@ -52,6 +52,7 @@ function mockContext(overrides: Partial = {}) { toggleCondition: vi.fn(), toggleConcentration: vi.fn(), addFromBestiary: vi.fn(), + addMultipleFromBestiary: vi.fn(), addFromPlayerCharacter: vi.fn(), makeStore: vi.fn(), withUndo: vi.fn((action: () => unknown) => action()), diff --git a/apps/web/src/hooks/use-action-bar-state.ts b/apps/web/src/hooks/use-action-bar-state.ts index 0dc6a5c..341532d 100644 --- a/apps/web/src/hooks/use-action-bar-state.ts +++ b/apps/web/src/hooks/use-action-bar-state.ts @@ -26,8 +26,12 @@ export function creatureKey(r: SearchResult): string { } export function useActionBarState() { - const { addCombatant, addFromBestiary, addFromPlayerCharacter } = - useEncounterContext(); + const { + addCombatant, + addFromBestiary, + addMultipleFromBestiary, + addFromPlayerCharacter, + } = useEncounterContext(); const { search: bestiarySearch, isLoaded: bestiaryLoaded } = useBestiaryContext(); const { characters: playerCharacters } = usePlayerCharactersContext(); @@ -92,11 +96,24 @@ export function useActionBarState() { const confirmQueued = useCallback(() => { if (!queued) return; - for (let i = 0; i < queued.count; i++) { + 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); + } } clearInput(); - }, [queued, handleAddFromBestiary, clearInput]); + }, [ + queued, + handleAddFromBestiary, + addMultipleFromBestiary, + panelView.mode, + showCreature, + 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 79dbd37..1f9945c 100644 --- a/apps/web/src/hooks/use-encounter.ts +++ b/apps/web/src/hooks/use-encounter.ts @@ -298,9 +298,10 @@ export function useEncounter() { setEvents((prev) => [...prev, ...result]); }, [makeStore]); - const addFromBestiary = useCallback( - (entry: BestiaryIndexEntry): CreatureId | null => { - const snapshot = encounterRef.current; + const addOneFromBestiary = useCallback( + ( + entry: BestiaryIndexEntry, + ): { cId: CreatureId; events: DomainEvent[] } | null => { const store = makeStore(); const existingNames = store.get().combatants.map((c) => c.name); const { newName, renames } = resolveCreatureName( @@ -328,8 +329,20 @@ export function useEncounter() { creatureId: cId, }); - if (isDomainError(result)) { - store.save(snapshot); + if (isDomainError(result)) return null; + + return { cId, events: result }; + }, + [makeStore], + ); + + const addFromBestiary = useCallback( + (entry: BestiaryIndexEntry): CreatureId | null => { + const snapshot = encounterRef.current; + const added = addOneFromBestiary(entry); + + if (!added) { + makeStore().save(snapshot); return null; } @@ -337,10 +350,36 @@ export function useEncounter() { undoRedoRef.current = newState; setUndoRedoState(newState); - setEvents((prev) => [...prev, ...result]); - return cId; + setEvents((prev) => [...prev, ...added.events]); + return added.cId; }, - [makeStore], + [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( @@ -426,6 +465,7 @@ export function useEncounter() { toggleCondition, toggleConcentration, addFromBestiary, + addMultipleFromBestiary, addFromPlayerCharacter, undo: undoAction, redo: redoAction,