Files
sudoku-tools/src/state/playHistory.ts
T

635 lines
18 KiB
TypeScript

import {
snapshotSession,
type PlaySession,
type PlaySnapshot,
} from "./session";
import {
aidMemoireFromPortable,
aidMemoireToPortable,
cloneAidMemoire,
createAidMemoire,
type AidMemoireState,
} from "./aidMemoire";
export type HypothesisStatus = "active" | "kept" | "discarded";
export interface GameplayState extends PlaySnapshot {
readonly elapsedSeconds: number;
readonly aidMemoire?: AidMemoireState;
}
export interface GameplayMoment {
readonly id: string;
readonly sequence: number;
readonly branchId: string;
readonly label: string;
readonly state: GameplayState;
}
export interface HypothesisBranch {
readonly id: string;
readonly name: string;
readonly parentBranchId: string;
readonly baseMomentId: string;
readonly baseState: GameplayState;
readonly status: HypothesisStatus;
}
export interface NamedSavepoint {
readonly id: string;
readonly name: string;
readonly momentId: string;
readonly branchId: string;
readonly state: GameplayState;
}
export interface GameplayHistory {
readonly moments: readonly GameplayMoment[];
readonly branches: readonly HypothesisBranch[];
readonly savepoints: readonly NamedSavepoint[];
readonly activeBranchId: string;
readonly currentMomentId: string;
readonly nextSequence: number;
}
export interface HistoryTransition {
readonly history: GameplayHistory;
readonly state: GameplayState;
}
export const MAIN_BRANCH_ID = "main";
export const MAX_GAMEPLAY_MOMENTS = 500;
export const MAX_GAMEPLAY_HISTORY_BYTES = 1_048_576;
const GAMEPLAY_HISTORY_SCHEMA =
"de.add-ideas.sudoku-tools.gameplay-history" as const;
function historyRecord(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new TypeError(`${label} must be an object.`);
}
return value as Record<string, unknown>;
}
function historyText(value: unknown, label: string, maximum = 128): string {
if (
typeof value !== "string" ||
value.length === 0 ||
value.length > maximum
) {
throw new TypeError(`${label} must be non-empty bounded text.`);
}
return value;
}
function historyInteger(
value: unknown,
label: string,
minimum: number,
maximum: number,
): number {
if (
!Number.isInteger(value) ||
(value as number) < minimum ||
(value as number) > maximum
) {
throw new TypeError(
`${label} must be an integer from ${String(minimum)} to ${String(maximum)}.`,
);
}
return value as number;
}
function parseHistoryState(value: unknown, size: number): GameplayState {
const record = historyRecord(value, "A gameplay state");
const count = size * size;
const numbers = (
input: unknown,
label: string,
minimum: number,
maximum: number,
): number[] => {
if (!Array.isArray(input) || input.length !== count) {
throw new TypeError(
`${label} must contain exactly ${String(count)} values.`,
);
}
return input.map((entry, index) =>
historyInteger(entry, `${label}[${String(index)}]`, minimum, maximum),
);
};
const maximumMask = 2 ** size - 1;
const parsed: GameplayState = {
values: numbers(record.values, "state.values", 0, size),
cornerMarks: numbers(
record.cornerMarks,
"state.cornerMarks",
0,
maximumMask,
),
centerMarks: numbers(
record.centerMarks,
"state.centerMarks",
0,
maximumMask,
),
colors: numbers(record.colors, "state.colors", 0, 8),
elapsedSeconds: historyInteger(
record.elapsedSeconds,
"state.elapsedSeconds",
0,
31_536_000,
),
...(record.aidMemoire === undefined
? {}
: { aidMemoire: aidMemoireFromPortable(record.aidMemoire, size) }),
};
return parsed;
}
function portableHistoryState(state: GameplayState, size: number) {
return {
values: [...state.values],
cornerMarks: [...state.cornerMarks],
centerMarks: [...state.centerMarks],
colors: [...state.colors],
elapsedSeconds: state.elapsedSeconds,
...(state.aidMemoire === undefined
? {}
: { aidMemoire: aidMemoireToPortable(state.aidMemoire, size) }),
};
}
/** Serialize bounded replay/savepoint history for durable project storage. */
export function serializeGameplayHistory(
history: GameplayHistory,
size: number,
): string {
const payload = JSON.stringify({
schema: GAMEPLAY_HISTORY_SCHEMA,
version: 1,
history: {
...history,
moments: history.moments.map((moment) => ({
...moment,
state: portableHistoryState(moment.state, size),
})),
branches: history.branches.map((branch) => ({
...branch,
baseState: portableHistoryState(branch.baseState, size),
})),
savepoints: history.savepoints.map((savepoint) => ({
...savepoint,
state: portableHistoryState(savepoint.state, size),
})),
},
});
if (
new TextEncoder().encode(payload).byteLength > MAX_GAMEPLAY_HISTORY_BYTES
) {
throw new RangeError("Gameplay history is too large to persist safely.");
}
// Parsing here gives callers one canonical validation boundary even when a
// history object was assembled outside the normal reducers.
void parseGameplayHistory(payload, size);
return payload;
}
/** Parse an untrusted persisted gameplay history into independent state. */
export function parseGameplayHistory(
input: string,
size: number,
): GameplayHistory {
if (new TextEncoder().encode(input).byteLength > MAX_GAMEPLAY_HISTORY_BYTES) {
throw new RangeError("Gameplay history is too large to open safely.");
}
let decoded: unknown;
try {
decoded = JSON.parse(input) as unknown;
} catch (error) {
throw new TypeError("Gameplay history is not valid JSON.", {
cause: error,
});
}
const envelope = historyRecord(decoded, "Gameplay history");
if (envelope.schema !== GAMEPLAY_HISTORY_SCHEMA || envelope.version !== 1) {
throw new TypeError("Unsupported gameplay-history version.");
}
const raw = historyRecord(envelope.history, "Gameplay history payload");
if (
!Array.isArray(raw.moments) ||
raw.moments.length < 1 ||
raw.moments.length > MAX_GAMEPLAY_MOMENTS ||
!Array.isArray(raw.branches) ||
raw.branches.length > MAX_GAMEPLAY_MOMENTS ||
!Array.isArray(raw.savepoints) ||
raw.savepoints.length > MAX_GAMEPLAY_MOMENTS
) {
throw new TypeError(
"Gameplay history collections are invalid or too large.",
);
}
const moments = raw.moments.map((value, index): GameplayMoment => {
const item = historyRecord(value, `moments[${String(index)}]`);
return {
id: historyText(item.id, `moments[${String(index)}].id`),
sequence: historyInteger(
item.sequence,
`moments[${String(index)}].sequence`,
0,
1_000_000,
),
branchId: historyText(
item.branchId,
`moments[${String(index)}].branchId`,
),
label: historyText(item.label, `moments[${String(index)}].label`, 200),
state: parseHistoryState(item.state, size),
};
});
const branches = raw.branches.map((value, index): HypothesisBranch => {
const item = historyRecord(value, `branches[${String(index)}]`);
if (
item.status !== "active" &&
item.status !== "kept" &&
item.status !== "discarded"
) {
throw new TypeError(`branches[${String(index)}].status is invalid.`);
}
return {
id: historyText(item.id, `branches[${String(index)}].id`),
name: historyText(item.name, `branches[${String(index)}].name`, 80),
parentBranchId: historyText(
item.parentBranchId,
`branches[${String(index)}].parentBranchId`,
),
baseMomentId: historyText(
item.baseMomentId,
`branches[${String(index)}].baseMomentId`,
),
baseState: parseHistoryState(item.baseState, size),
status: item.status,
};
});
const savepoints = raw.savepoints.map((value, index): NamedSavepoint => {
const item = historyRecord(value, `savepoints[${String(index)}]`);
return {
id: historyText(item.id, `savepoints[${String(index)}].id`),
name: historyText(item.name, `savepoints[${String(index)}].name`, 80),
momentId: historyText(
item.momentId,
`savepoints[${String(index)}].momentId`,
),
branchId: historyText(
item.branchId,
`savepoints[${String(index)}].branchId`,
),
state: parseHistoryState(item.state, size),
};
});
const unique = (values: readonly string[], label: string): void => {
if (new Set(values).size !== values.length) {
throw new TypeError(`${label} contains duplicate IDs.`);
}
};
unique(
moments.map(({ id }) => id),
"Gameplay moments",
);
unique(
branches.map(({ id }) => id),
"Gameplay branches",
);
unique(
savepoints.map(({ id }) => id),
"Gameplay savepoints",
);
const momentIds = new Set(moments.map(({ id }) => id));
const branchIds = new Set([MAIN_BRANCH_ID, ...branches.map(({ id }) => id)]);
const activeBranchId = historyText(raw.activeBranchId, "activeBranchId");
const currentMomentId = historyText(raw.currentMomentId, "currentMomentId");
if (!branchIds.has(activeBranchId) || !momentIds.has(currentMomentId)) {
throw new TypeError(
"Gameplay history points to a missing active branch or moment.",
);
}
if (
moments.some(({ branchId }) => !branchIds.has(branchId)) ||
branches.some(
({ parentBranchId, baseMomentId }) =>
!branchIds.has(parentBranchId) || !momentIds.has(baseMomentId),
) ||
savepoints.some(
({ momentId, branchId }) =>
!momentIds.has(momentId) || !branchIds.has(branchId),
)
) {
throw new TypeError("Gameplay history contains a dangling reference.");
}
return {
moments,
branches,
savepoints,
activeBranchId,
currentMomentId,
nextSequence: historyInteger(
raw.nextSequence,
"nextSequence",
1,
1_000_001,
),
};
}
function cloneState(state: GameplayState): GameplayState {
return {
values: [...state.values],
cornerMarks: [...state.cornerMarks],
centerMarks: [...state.centerMarks],
colors: [...state.colors],
elapsedSeconds: state.elapsedSeconds,
...(state.aidMemoire === undefined
? {}
: { aidMemoire: cloneAidMemoire(state.aidMemoire) }),
};
}
function requireName(value: string, kind: string): string {
const name = value.trim();
if (!name) throw new Error(`${kind} name cannot be empty.`);
return name.slice(0, 80);
}
function appendMoment(
history: GameplayHistory,
state: GameplayState,
label: string,
branchId = history.activeBranchId,
): GameplayHistory {
const sequence = history.nextSequence;
const moment: GameplayMoment = {
id: `moment-${String(sequence)}`,
sequence,
branchId,
label,
state: cloneState(state),
};
return {
...history,
moments: [...history.moments, moment].slice(-MAX_GAMEPLAY_MOMENTS),
activeBranchId: branchId,
currentMomentId: moment.id,
nextSequence: sequence + 1,
};
}
export function captureGameplayState(
session: PlaySession,
aidMemoire?: AidMemoireState,
): GameplayState {
return {
...snapshotSession(session),
elapsedSeconds: session.elapsedSeconds,
...(aidMemoire === undefined
? {}
: { aidMemoire: cloneAidMemoire(aidMemoire) }),
};
}
export function sessionFromGameplayState(
state: GameplayState,
paused = false,
): PlaySession {
return {
values: [...state.values],
cornerMarks: [...state.cornerMarks],
centerMarks: [...state.centerMarks],
colors: [...state.colors],
elapsedSeconds: state.elapsedSeconds,
paused,
};
}
export function aidMemoireFromGameplayState(
state: GameplayState,
size: number,
): AidMemoireState {
return state.aidMemoire === undefined
? createAidMemoire(size)
: cloneAidMemoire(state.aidMemoire);
}
export function createGameplayHistory(
session: PlaySession,
aidMemoire?: AidMemoireState,
): GameplayHistory {
const initial: GameplayMoment = {
id: "moment-0",
sequence: 0,
branchId: MAIN_BRANCH_ID,
label: "Puzzle opened",
state: captureGameplayState(session, aidMemoire),
};
return {
moments: [initial],
branches: [],
savepoints: [],
activeBranchId: MAIN_BRANCH_ID,
currentMomentId: initial.id,
nextSequence: 1,
};
}
export function recordGameplayMoment(
history: GameplayHistory,
session: PlaySession,
label: string,
aidMemoire?: AidMemoireState,
): GameplayHistory {
const trimmedLabel = label.trim() || "Grid updated";
return appendMoment(
history,
captureGameplayState(session, aidMemoire),
trimmedLabel,
);
}
export function gameplayMoment(
history: GameplayHistory,
momentId: string,
): GameplayMoment | undefined {
return history.moments.find((moment) => moment.id === momentId);
}
export function activeHypothesis(
history: GameplayHistory,
): HypothesisBranch | undefined {
return history.branches.find(
(branch) =>
branch.id === history.activeBranchId && branch.status === "active",
);
}
export function beginHypothesis(
history: GameplayHistory,
session: PlaySession,
nameValue: string,
fromMomentId = history.currentMomentId,
aidMemoire?: AidMemoireState,
): HistoryTransition {
if (activeHypothesis(history) !== undefined) {
throw new Error("Finish the active hypothesis before starting another.");
}
const name = requireName(nameValue, "Hypothesis");
const source = gameplayMoment(history, fromMomentId);
if (source === undefined)
throw new Error("The replay moment is unavailable.");
const baseState =
fromMomentId === history.currentMomentId
? captureGameplayState(session, aidMemoire)
: cloneState(source.state);
const sequence = history.nextSequence;
const branchId = `branch-${String(sequence)}`;
const branch: HypothesisBranch = {
id: branchId,
name,
parentBranchId: history.activeBranchId,
baseMomentId: source.id,
baseState: cloneState(baseState),
status: "active",
};
const withBranch: GameplayHistory = {
...history,
branches: [...history.branches, branch],
activeBranchId: branchId,
currentMomentId: source.id,
};
return {
history: appendMoment(
withBranch,
baseState,
`Started hypothesis “${name}”`,
branchId,
),
state: cloneState(baseState),
};
}
export function finishHypothesis(
history: GameplayHistory,
session: PlaySession,
decision: "keep" | "discard",
aidMemoire?: AidMemoireState,
): HistoryTransition {
const branch = activeHypothesis(history);
if (branch === undefined) throw new Error("No hypothesis is active.");
const state =
decision === "keep"
? captureGameplayState(session, aidMemoire)
: cloneState(branch.baseState);
const status: HypothesisStatus = decision === "keep" ? "kept" : "discarded";
const updated: GameplayHistory = {
...history,
branches: history.branches.map((candidate) =>
candidate.id === branch.id ? { ...candidate, status } : candidate,
),
activeBranchId: branch.parentBranchId,
currentMomentId: branch.baseMomentId,
};
const verb = decision === "keep" ? "Kept" : "Discarded";
return {
history: appendMoment(
updated,
state,
`${verb} hypothesis “${branch.name}”`,
branch.parentBranchId,
),
state: cloneState(state),
};
}
export function createSavepoint(
history: GameplayHistory,
session: PlaySession,
nameValue: string,
aidMemoire?: AidMemoireState,
): GameplayHistory {
const name = requireName(nameValue, "Savepoint");
const state = captureGameplayState(session, aidMemoire);
const withMoment = appendMoment(history, state, `Saved “${name}”`);
const savepoint: NamedSavepoint = {
id: `savepoint-${String(withMoment.nextSequence)}`,
name,
momentId: withMoment.currentMomentId,
branchId: withMoment.activeBranchId,
state: cloneState(state),
};
return {
...withMoment,
savepoints: [...withMoment.savepoints, savepoint],
};
}
export function deleteSavepoint(
history: GameplayHistory,
savepointId: string,
): GameplayHistory {
return {
...history,
savepoints: history.savepoints.filter(
(savepoint) => savepoint.id !== savepointId,
),
};
}
export function restoreSavepoint(
history: GameplayHistory,
savepointId: string,
): HistoryTransition {
const savepoint = history.savepoints.find(
(candidate) => candidate.id === savepointId,
);
if (savepoint === undefined) throw new Error("The savepoint is unavailable.");
const state = cloneState(savepoint.state);
return {
history: appendMoment(history, state, `Restored “${savepoint.name}”`),
state,
};
}
export function describeGameplayChange(
before: PlaySession,
after: PlaySession,
size: number,
): string {
const changedValues: number[] = [];
const changedNotes: number[] = [];
const changedColors: number[] = [];
for (let cell = 0; cell < after.values.length; cell += 1) {
if (before.values[cell] !== after.values[cell]) changedValues.push(cell);
if (
before.cornerMarks[cell] !== after.cornerMarks[cell] ||
before.centerMarks[cell] !== after.centerMarks[cell]
) {
changedNotes.push(cell);
}
if (before.colors[cell] !== after.colors[cell]) changedColors.push(cell);
}
const cells = new Set([...changedValues, ...changedNotes, ...changedColors]);
if (cells.size !== 1) {
return cells.size === 0
? "Grid updated"
: `Updated ${String(cells.size)} cells`;
}
const cell = [...cells][0]!;
const label = `r${String(Math.floor(cell / size) + 1)}c${String((cell % size) + 1)}`;
if (changedValues.length) {
const value = after.values[cell] ?? 0;
return value === 0
? `Cleared ${label}`
: `Set ${label} to ${String(value)}`;
}
if (changedNotes.length) return `Changed notes in ${label}`;
return `Changed colour in ${label}`;
}