feat: complete advanced Sudoku workbench

This commit is contained in:
2026-08-31 08:20:30 +02:00
parent 8ca9300ab3
commit 0a1bdc1a8c
99 changed files with 20793 additions and 923 deletions
+282
View File
@@ -4,6 +4,8 @@ import {
type PlaySnapshot,
} from "./session";
import {
aidMemoireFromPortable,
aidMemoireToPortable,
cloneAidMemoire,
createAidMemoire,
type AidMemoireState,
@@ -57,6 +59,286 @@ export interface HistoryTransition {
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 {