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 { if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new TypeError(`${label} must be an object.`); } return value as Record; } 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}`; }