feat: expand sudoku analysis and interoperability
This commit is contained in:
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user