feat: expand sudoku analysis and interoperability

This commit is contained in:
2026-08-30 23:21:56 +02:00
parent 4a9869baa0
commit 8ca9300ab3
73 changed files with 12482 additions and 384 deletions
+352
View File
@@ -0,0 +1,352 @@
import {
snapshotSession,
type PlaySession,
type PlaySnapshot,
} from "./session";
import {
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;
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}`;
}