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:
@@ -10,7 +10,9 @@ export type {
|
||||
BestiarySourceCache,
|
||||
EncounterStore,
|
||||
PlayerCharacterStore,
|
||||
UndoRedoStore,
|
||||
} from "./ports.js";
|
||||
export { redoUseCase } from "./redo-use-case.js";
|
||||
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
||||
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
|
||||
export {
|
||||
@@ -24,3 +26,4 @@ export { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
||||
export { setTempHpUseCase } from "./set-temp-hp-use-case.js";
|
||||
export { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js";
|
||||
export { toggleConditionUseCase } from "./toggle-condition-use-case.js";
|
||||
export { undoUseCase } from "./undo-use-case.js";
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
CreatureId,
|
||||
Encounter,
|
||||
PlayerCharacter,
|
||||
UndoRedoState,
|
||||
} from "@initiative/domain";
|
||||
|
||||
export interface EncounterStore {
|
||||
@@ -19,3 +20,8 @@ export interface PlayerCharacterStore {
|
||||
getAll(): PlayerCharacter[];
|
||||
save(characters: PlayerCharacter[]): void;
|
||||
}
|
||||
|
||||
export interface UndoRedoStore {
|
||||
get(): UndoRedoState;
|
||||
save(state: UndoRedoState): void;
|
||||
}
|
||||
|
||||
24
packages/application/src/redo-use-case.ts
Normal file
24
packages/application/src/redo-use-case.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
type DomainError,
|
||||
type Encounter,
|
||||
isDomainError,
|
||||
redo,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore, UndoRedoStore } from "./ports.js";
|
||||
|
||||
export function redoUseCase(
|
||||
encounterStore: EncounterStore,
|
||||
undoRedoStore: UndoRedoStore,
|
||||
): Encounter | DomainError {
|
||||
const current = encounterStore.get();
|
||||
const state = undoRedoStore.get();
|
||||
const result = redo(state, current);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
encounterStore.save(result.encounter);
|
||||
undoRedoStore.save(result.state);
|
||||
return result.encounter;
|
||||
}
|
||||
24
packages/application/src/undo-use-case.ts
Normal file
24
packages/application/src/undo-use-case.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
type DomainError,
|
||||
type Encounter,
|
||||
isDomainError,
|
||||
undo,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore, UndoRedoStore } from "./ports.js";
|
||||
|
||||
export function undoUseCase(
|
||||
encounterStore: EncounterStore,
|
||||
undoRedoStore: UndoRedoStore,
|
||||
): Encounter | DomainError {
|
||||
const current = encounterStore.get();
|
||||
const state = undoRedoStore.get();
|
||||
const result = undo(state, current);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
encounterStore.save(result.encounter);
|
||||
undoRedoStore.save(result.state);
|
||||
return result.encounter;
|
||||
}
|
||||
124
packages/domain/src/__tests__/undo-redo.test.ts
Normal file
124
packages/domain/src/__tests__/undo-redo.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Encounter } from "../types.js";
|
||||
import { isDomainError } from "../types.js";
|
||||
import {
|
||||
clearHistory,
|
||||
EMPTY_UNDO_REDO_STATE,
|
||||
pushUndo,
|
||||
redo,
|
||||
undo,
|
||||
} from "../undo-redo.js";
|
||||
|
||||
function enc(roundNumber = 1, activeIndex = 0): Encounter {
|
||||
return { combatants: [], activeIndex, roundNumber };
|
||||
}
|
||||
|
||||
describe("pushUndo", () => {
|
||||
it("adds a snapshot to the undo stack", () => {
|
||||
const result = pushUndo(EMPTY_UNDO_REDO_STATE, enc(1));
|
||||
expect(result.undoStack).toHaveLength(1);
|
||||
expect(result.undoStack[0]).toEqual(enc(1));
|
||||
});
|
||||
|
||||
it("clears the redo stack", () => {
|
||||
const state = {
|
||||
undoStack: [enc(1)],
|
||||
redoStack: [enc(2)],
|
||||
};
|
||||
const result = pushUndo(state, enc(3));
|
||||
expect(result.redoStack).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("caps the undo stack at 50, dropping the oldest", () => {
|
||||
const undoStack = Array.from({ length: 50 }, (_, i) => enc(i + 1));
|
||||
const state = { undoStack, redoStack: [] };
|
||||
const result = pushUndo(state, enc(51));
|
||||
expect(result.undoStack).toHaveLength(50);
|
||||
expect(result.undoStack[0]).toEqual(enc(2));
|
||||
expect(result.undoStack[49]).toEqual(enc(51));
|
||||
});
|
||||
});
|
||||
|
||||
describe("undo", () => {
|
||||
it("pops from undo stack and pushes current to redo stack", () => {
|
||||
const state = { undoStack: [enc(1)], redoStack: [] };
|
||||
const result = undo(state, enc(2));
|
||||
expect(isDomainError(result)).toBe(false);
|
||||
if (isDomainError(result)) return;
|
||||
expect(result.encounter).toEqual(enc(1));
|
||||
expect(result.state.undoStack).toHaveLength(0);
|
||||
expect(result.state.redoStack).toEqual([enc(2)]);
|
||||
});
|
||||
|
||||
it("returns domain error when undo stack is empty", () => {
|
||||
const result = undo(EMPTY_UNDO_REDO_STATE, enc(1));
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (!isDomainError(result)) return;
|
||||
expect(result.code).toBe("nothing-to-undo");
|
||||
});
|
||||
|
||||
it("pops the most recent entry (last in stack)", () => {
|
||||
const state = { undoStack: [enc(1), enc(2), enc(3)], redoStack: [] };
|
||||
const result = undo(state, enc(4));
|
||||
expect(isDomainError(result)).toBe(false);
|
||||
if (isDomainError(result)) return;
|
||||
expect(result.encounter).toEqual(enc(3));
|
||||
expect(result.state.undoStack).toEqual([enc(1), enc(2)]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("redo", () => {
|
||||
it("pops from redo stack and pushes current to undo stack", () => {
|
||||
const state = { undoStack: [], redoStack: [enc(1)] };
|
||||
const result = redo(state, enc(2));
|
||||
expect(isDomainError(result)).toBe(false);
|
||||
if (isDomainError(result)) return;
|
||||
expect(result.encounter).toEqual(enc(1));
|
||||
expect(result.state.redoStack).toHaveLength(0);
|
||||
expect(result.state.undoStack).toEqual([enc(2)]);
|
||||
});
|
||||
|
||||
it("returns domain error when redo stack is empty", () => {
|
||||
const result = redo(EMPTY_UNDO_REDO_STATE, enc(1));
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (!isDomainError(result)) return;
|
||||
expect(result.code).toBe("nothing-to-redo");
|
||||
});
|
||||
|
||||
it("pops the most recent entry (last in stack)", () => {
|
||||
const state = { undoStack: [], redoStack: [enc(1), enc(2), enc(3)] };
|
||||
const result = redo(state, enc(4));
|
||||
expect(isDomainError(result)).toBe(false);
|
||||
if (isDomainError(result)) return;
|
||||
expect(result.encounter).toEqual(enc(3));
|
||||
expect(result.state.redoStack).toEqual([enc(1), enc(2)]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("undo-then-redo roundtrip", () => {
|
||||
it("returns the exact same encounter after undo then redo", () => {
|
||||
const original = enc(5);
|
||||
const current = enc(6);
|
||||
const afterPush = pushUndo(EMPTY_UNDO_REDO_STATE, original);
|
||||
|
||||
const undoResult = undo(afterPush, current);
|
||||
expect(isDomainError(undoResult)).toBe(false);
|
||||
if (isDomainError(undoResult)) return;
|
||||
|
||||
expect(undoResult.encounter).toEqual(original);
|
||||
|
||||
const redoResult = redo(undoResult.state, undoResult.encounter);
|
||||
expect(isDomainError(redoResult)).toBe(false);
|
||||
if (isDomainError(redoResult)) return;
|
||||
|
||||
expect(redoResult.encounter).toEqual(current);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearHistory", () => {
|
||||
it("empties both stacks", () => {
|
||||
const result = clearHistory();
|
||||
expect(result.undoStack).toHaveLength(0);
|
||||
expect(result.redoStack).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -121,3 +121,11 @@ export {
|
||||
type Encounter,
|
||||
isDomainError,
|
||||
} from "./types.js";
|
||||
export {
|
||||
clearHistory,
|
||||
EMPTY_UNDO_REDO_STATE,
|
||||
pushUndo,
|
||||
redo,
|
||||
type UndoRedoState,
|
||||
undo,
|
||||
} from "./undo-redo.js";
|
||||
|
||||
70
packages/domain/src/undo-redo.ts
Normal file
70
packages/domain/src/undo-redo.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { DomainError, Encounter } from "./types.js";
|
||||
|
||||
export interface UndoRedoState {
|
||||
readonly undoStack: readonly Encounter[];
|
||||
readonly redoStack: readonly Encounter[];
|
||||
}
|
||||
|
||||
const MAX_UNDO_STACK = 50;
|
||||
|
||||
export const EMPTY_UNDO_REDO_STATE: UndoRedoState = {
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
|
||||
export function pushUndo(
|
||||
state: UndoRedoState,
|
||||
snapshot: Encounter,
|
||||
): UndoRedoState {
|
||||
const newStack = [...state.undoStack, snapshot];
|
||||
if (newStack.length > MAX_UNDO_STACK) {
|
||||
newStack.shift();
|
||||
}
|
||||
return { undoStack: newStack, redoStack: [] };
|
||||
}
|
||||
|
||||
export function undo(
|
||||
state: UndoRedoState,
|
||||
currentEncounter: Encounter,
|
||||
): { state: UndoRedoState; encounter: Encounter } | DomainError {
|
||||
if (state.undoStack.length === 0) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "nothing-to-undo",
|
||||
message: "Nothing to undo",
|
||||
};
|
||||
}
|
||||
const restored = state.undoStack.at(-1) as Encounter;
|
||||
return {
|
||||
state: {
|
||||
undoStack: state.undoStack.slice(0, -1),
|
||||
redoStack: [...state.redoStack, currentEncounter],
|
||||
},
|
||||
encounter: restored,
|
||||
};
|
||||
}
|
||||
|
||||
export function redo(
|
||||
state: UndoRedoState,
|
||||
currentEncounter: Encounter,
|
||||
): { state: UndoRedoState; encounter: Encounter } | DomainError {
|
||||
if (state.redoStack.length === 0) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "nothing-to-redo",
|
||||
message: "Nothing to redo",
|
||||
};
|
||||
}
|
||||
const restored = state.redoStack.at(-1) as Encounter;
|
||||
return {
|
||||
state: {
|
||||
undoStack: [...state.undoStack, currentEncounter],
|
||||
redoStack: state.redoStack.slice(0, -1),
|
||||
},
|
||||
encounter: restored,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearHistory(): UndoRedoState {
|
||||
return EMPTY_UNDO_REDO_STATE;
|
||||
}
|
||||
Reference in New Issue
Block a user