635 lines
18 KiB
TypeScript
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}`;
|
|
}
|