3 Commits
0.9.11 ... main

Author SHA1 Message Date
Lukas
f10c67a5ba Dismiss side panel when encounter becomes empty
All checks were successful
CI / check (push) Successful in 1m7s
CI / build-image (push) Successful in 15s
Closes the stat block / source manager panel when the last combatant
is removed or the encounter is cleared, giving a fully clean state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 00:16:32 +01:00
Lukas
9437272fe0 Batch bestiary add produces a single undo entry
All checks were successful
CI / check (push) Successful in 1m10s
CI / build-image (push) Successful in 15s
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) <noreply@anthropic.com>
2026-03-27 00:07:25 +01:00
Lukas
541e04b732 Wrap initiative rolls with undo so they produce undo entries
Initiative rolls (single and bulk) called makeStore() directly from
useInitiativeRolls, bypassing the withUndo wrapper. Expose withUndo
from the encounter context and wrap both roll paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 00:06:50 +01:00
5 changed files with 86 additions and 26 deletions

View File

@@ -30,6 +30,13 @@ export function App() {
const activeRowRef = useRef<HTMLDivElement>(null);
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
// Close the side panel when the encounter becomes empty
useEffect(() => {
if (isEmpty) {
sidePanel.dismissPanel();
}
}, [isEmpty, sidePanel.dismissPanel]);
// Auto-scroll to active combatant when turn changes
const activeIndex = encounter.activeIndex;
useEffect(() => {

View File

@@ -52,8 +52,10 @@ function mockContext(overrides: Partial<Encounter> = {}) {
toggleCondition: vi.fn(),
toggleConcentration: vi.fn(),
addFromBestiary: vi.fn(),
addMultipleFromBestiary: vi.fn(),
addFromPlayerCharacter: vi.fn(),
makeStore: vi.fn(),
withUndo: vi.fn((action: () => unknown) => action()),
undo: vi.fn(),
redo: vi.fn(),
canUndo: false,

View File

@@ -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;

View File

@@ -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,9 +465,11 @@ export function useEncounter() {
toggleCondition,
toggleConcentration,
addFromBestiary,
addMultipleFromBestiary,
addFromPlayerCharacter,
undo: undoAction,
redo: redoAction,
makeStore,
withUndo,
} as const;
}

View File

@@ -17,7 +17,7 @@ function rollDice(): number {
}
export function useInitiativeRolls() {
const { encounter, makeStore } = useEncounterContext();
const { encounter, makeStore, withUndo } = useEncounterContext();
const { getCreature } = useBestiaryContext();
const { showCreature } = useSidePanelContext();
@@ -28,12 +28,8 @@ export function useInitiativeRolls() {
(id: CombatantId, mode: RollMode = "normal") => {
const diceRolls: [number, ...number[]] =
mode === "normal" ? [rollDice()] : [rollDice(), rollDice()];
const result = rollInitiativeUseCase(
makeStore(),
id,
diceRolls,
getCreature,
mode,
const result = withUndo(() =>
rollInitiativeUseCase(makeStore(), id, diceRolls, getCreature, mode),
);
if (isDomainError(result)) {
setRollSingleSkipped(true);
@@ -43,22 +39,19 @@ export function useInitiativeRolls() {
}
}
},
[makeStore, getCreature, encounter.combatants, showCreature],
[makeStore, getCreature, withUndo, encounter.combatants, showCreature],
);
const handleRollAllInitiative = useCallback(
(mode: RollMode = "normal") => {
const result = rollAllInitiativeUseCase(
makeStore(),
rollDice,
getCreature,
mode,
const result = withUndo(() =>
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature, mode),
);
if (!isDomainError(result) && result.skippedNoSource > 0) {
setRollSkippedCount(result.skippedNoSource);
}
},
[makeStore, getCreature],
[makeStore, getCreature, withUndo],
);
return {