feat: complete advanced Sudoku workbench
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user