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:
Lukas
2026-03-26 23:30:33 +01:00
parent 9d81c8ad27
commit 17cc6ed72c
22 changed files with 1127 additions and 61 deletions

View File

@@ -108,45 +108,44 @@ function isValidCombatantEntry(c: unknown): boolean {
return typeof entry.id === "string" && typeof entry.name === "string";
}
export function rehydrateEncounter(parsed: unknown): Encounter | null {
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
return null;
const obj = parsed as Record<string, unknown>;
if (!Array.isArray(obj.combatants)) return null;
if (typeof obj.activeIndex !== "number") return null;
if (typeof obj.roundNumber !== "number") return null;
const combatants = obj.combatants as unknown[];
// Handle empty encounter (cleared state) directly — createEncounter rejects empty arrays
if (combatants.length === 0) {
return {
combatants: [],
activeIndex: 0,
roundNumber: 1,
};
}
if (!combatants.every(isValidCombatantEntry)) return null;
const rehydrated = combatants.map(rehydrateCombatant);
const result = createEncounter(rehydrated, obj.activeIndex, obj.roundNumber);
if (isDomainError(result)) return null;
return result;
}
export function loadEncounter(): Encounter | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) return null;
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
return null;
const obj = parsed as Record<string, unknown>;
if (!Array.isArray(obj.combatants)) return null;
if (typeof obj.activeIndex !== "number") return null;
if (typeof obj.roundNumber !== "number") return null;
const combatants = obj.combatants as unknown[];
// Handle empty encounter (cleared state) directly — createEncounter rejects empty arrays
if (combatants.length === 0) {
return {
combatants: [],
activeIndex: 0,
roundNumber: 1,
};
}
if (!combatants.every(isValidCombatantEntry)) return null;
const rehydrated = combatants.map(rehydrateCombatant);
const result = createEncounter(
rehydrated,
obj.activeIndex,
obj.roundNumber,
);
if (isDomainError(result)) return null;
return result;
return rehydrateEncounter(parsed);
} catch {
return null;
}

View File

@@ -0,0 +1,45 @@
import type { Encounter, UndoRedoState } from "@initiative/domain";
import { EMPTY_UNDO_REDO_STATE } from "@initiative/domain";
import { rehydrateEncounter } from "./encounter-storage.js";
const UNDO_KEY = "initiative:encounter:undo";
const REDO_KEY = "initiative:encounter:redo";
export function saveUndoRedoStacks(state: UndoRedoState): void {
try {
localStorage.setItem(UNDO_KEY, JSON.stringify(state.undoStack));
localStorage.setItem(REDO_KEY, JSON.stringify(state.redoStack));
} catch {
// Silently swallow errors (quota exceeded, storage unavailable)
}
}
function loadStack(key: string): readonly Encounter[] {
try {
const raw = localStorage.getItem(key);
if (raw === null) return [];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
const valid: Encounter[] = [];
for (const entry of parsed) {
const rehydrated = rehydrateEncounter(entry);
if (rehydrated !== null) {
valid.push(rehydrated);
}
}
return valid;
} catch {
return [];
}
}
export function loadUndoRedoStacks(): UndoRedoState {
const undoStack = loadStack(UNDO_KEY);
const redoStack = loadStack(REDO_KEY);
if (undoStack.length === 0 && redoStack.length === 0) {
return EMPTY_UNDO_REDO_STATE;
}
return { undoStack, redoStack };
}