feat: launch local-first Sudoku workbench
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
import {
|
||||
SUDOKU_DOCUMENT_SCHEMA,
|
||||
SUDOKU_DOCUMENT_VERSION,
|
||||
cloneConstraint,
|
||||
type PortableConstraint,
|
||||
type SudokuDocument,
|
||||
} from "./types";
|
||||
|
||||
export const MAX_DOCUMENT_BYTES = 1_048_576;
|
||||
export const MIN_BOARD_SIZE = 4;
|
||||
export const MAX_BOARD_SIZE = 16;
|
||||
export const MAX_CONSTRAINTS = 5_000;
|
||||
const MAX_TEXT_LENGTH = 20_000;
|
||||
const MAX_RULES = 1_000;
|
||||
|
||||
export class SudokuFormatError extends Error {
|
||||
readonly code: string;
|
||||
|
||||
constructor(code: string, message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "SudokuFormatError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function fail(code: string, message: string): never {
|
||||
throw new SudokuFormatError(code, message);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function integer(
|
||||
value: unknown,
|
||||
label: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
if (
|
||||
!Number.isInteger(value) ||
|
||||
(value as number) < minimum ||
|
||||
(value as number) > maximum
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_NUMBER",
|
||||
`${label} must be an integer from ${minimum} to ${maximum}.`,
|
||||
);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function optionalText(value: unknown, label: string): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "string")
|
||||
return fail("INVALID_TEXT", `${label} must be text.`);
|
||||
if (value.length > MAX_TEXT_LENGTH) {
|
||||
return fail(
|
||||
"LIMIT_EXCEEDED",
|
||||
`${label} exceeds ${MAX_TEXT_LENGTH.toLocaleString()} characters.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function numberArray(
|
||||
value: unknown,
|
||||
label: string,
|
||||
expectedLength: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number[] {
|
||||
if (!Array.isArray(value) || value.length !== expectedLength) {
|
||||
return fail(
|
||||
"INVALID_GRID",
|
||||
`${label} must contain exactly ${expectedLength} entries.`,
|
||||
);
|
||||
}
|
||||
return value.map((entry, index) =>
|
||||
integer(entry, `${label}[${index}]`, minimum, maximum),
|
||||
);
|
||||
}
|
||||
|
||||
function cell(value: unknown, label: string, cellCount: number): number {
|
||||
return integer(value, label, 0, cellCount - 1);
|
||||
}
|
||||
|
||||
function cells(
|
||||
value: unknown,
|
||||
label: string,
|
||||
cellCount: number,
|
||||
minimumLength = 1,
|
||||
): number[] {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length < minimumLength ||
|
||||
value.length > cellCount
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_CELLS",
|
||||
`${label} must contain ${minimumLength} to ${cellCount} cell indices.`,
|
||||
);
|
||||
}
|
||||
const result = value.map((entry, index) =>
|
||||
cell(entry, `${label}[${index}]`, cellCount),
|
||||
);
|
||||
if (new Set(result).size !== result.length) {
|
||||
return fail("DUPLICATE_CELL", `${label} contains a duplicate cell.`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function pair(
|
||||
record: Record<string, unknown>,
|
||||
cellCount: number,
|
||||
): { a: number; b: number } {
|
||||
const a = cell(record.a, "constraint.a", cellCount);
|
||||
const b = cell(record.b, "constraint.b", cellCount);
|
||||
if (a === b)
|
||||
return fail("DUPLICATE_CELL", "A relation must join two different cells.");
|
||||
return { a, b };
|
||||
}
|
||||
|
||||
function parseConstraint(
|
||||
value: unknown,
|
||||
cellCount: number,
|
||||
): PortableConstraint {
|
||||
if (!isRecord(value) || typeof value.type !== "string") {
|
||||
return fail(
|
||||
"INVALID_CONSTRAINT",
|
||||
"Each constraint must be an object with a type.",
|
||||
);
|
||||
}
|
||||
switch (value.type) {
|
||||
case "diagonal": {
|
||||
if (value.direction !== "main" && value.direction !== "anti") {
|
||||
return fail(
|
||||
"INVALID_CONSTRAINT",
|
||||
"A diagonal direction must be main or anti.",
|
||||
);
|
||||
}
|
||||
return { type: "diagonal", direction: value.direction };
|
||||
}
|
||||
case "anti-knight":
|
||||
case "anti-king":
|
||||
case "non-consecutive":
|
||||
return { type: value.type };
|
||||
case "killer-cage": {
|
||||
const cageCells = cells(value.cells, "killer-cage.cells", cellCount);
|
||||
const sum = integer(
|
||||
value.sum,
|
||||
"killer-cage.sum",
|
||||
1,
|
||||
MAX_BOARD_SIZE * cellCount,
|
||||
);
|
||||
if (value.noRepeat !== undefined && typeof value.noRepeat !== "boolean") {
|
||||
return fail(
|
||||
"INVALID_CONSTRAINT",
|
||||
"killer-cage.noRepeat must be true or false.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
type: "killer-cage",
|
||||
cells: cageCells,
|
||||
sum,
|
||||
...(value.noRepeat === undefined ? {} : { noRepeat: value.noRepeat }),
|
||||
};
|
||||
}
|
||||
case "thermo":
|
||||
case "renban":
|
||||
case "palindrome":
|
||||
return {
|
||||
type: value.type,
|
||||
cells: cells(value.cells, `${value.type}.cells`, cellCount, 2),
|
||||
};
|
||||
case "arrow":
|
||||
return {
|
||||
type: "arrow",
|
||||
bulb: cells(value.bulb, "arrow.bulb", cellCount),
|
||||
line: cells(value.line, "arrow.line", cellCount),
|
||||
};
|
||||
case "kropki": {
|
||||
const related = pair(value, cellCount);
|
||||
if (value.kind !== "white" && value.kind !== "black") {
|
||||
return fail(
|
||||
"INVALID_CONSTRAINT",
|
||||
"A Kropki kind must be white or black.",
|
||||
);
|
||||
}
|
||||
return { type: "kropki", ...related, kind: value.kind };
|
||||
}
|
||||
case "xv": {
|
||||
const related = pair(value, cellCount);
|
||||
if (value.total !== 5 && value.total !== 10) {
|
||||
return fail("INVALID_CONSTRAINT", "An XV total must be 5 or 10.");
|
||||
}
|
||||
return {
|
||||
type: "xv",
|
||||
...related,
|
||||
total: value.total,
|
||||
};
|
||||
}
|
||||
case "inequality": {
|
||||
const lesser = cell(value.lesser, "inequality.lesser", cellCount);
|
||||
const greater = cell(value.greater, "inequality.greater", cellCount);
|
||||
if (lesser === greater) {
|
||||
return fail(
|
||||
"DUPLICATE_CELL",
|
||||
"An inequality must join two different cells.",
|
||||
);
|
||||
}
|
||||
return { type: "inequality", lesser, greater };
|
||||
}
|
||||
default:
|
||||
return fail(
|
||||
"UNSUPPORTED_CONSTRAINT",
|
||||
`Unsupported constraint type: ${value.type}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function textList(value: unknown, label: string): string[] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!Array.isArray(value) || value.length > MAX_RULES) {
|
||||
return fail(
|
||||
"LIMIT_EXCEEDED",
|
||||
`${label} must contain at most ${MAX_RULES} entries.`,
|
||||
);
|
||||
}
|
||||
return value.map((entry, index) => {
|
||||
if (typeof entry !== "string" || entry.length > MAX_TEXT_LENGTH) {
|
||||
return fail("INVALID_TEXT", `${label}[${index}] is not bounded text.`);
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeSudokuDocument(value: unknown): SudokuDocument {
|
||||
if (!isRecord(value))
|
||||
return fail("INVALID_DOCUMENT", "The puzzle document must be an object.");
|
||||
if (value.schema !== SUDOKU_DOCUMENT_SCHEMA) {
|
||||
return fail("INVALID_SCHEMA", `Expected schema ${SUDOKU_DOCUMENT_SCHEMA}.`);
|
||||
}
|
||||
if (value.version !== SUDOKU_DOCUMENT_VERSION) {
|
||||
return fail(
|
||||
"UNSUPPORTED_VERSION",
|
||||
`Unsupported puzzle document version: ${String(value.version)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const size = integer(value.size, "size", MIN_BOARD_SIZE, MAX_BOARD_SIZE);
|
||||
const cellCount = size * size;
|
||||
const givens = numberArray(value.givens, "givens", cellCount, 0, size);
|
||||
const values =
|
||||
value.values === undefined
|
||||
? undefined
|
||||
: numberArray(value.values, "values", cellCount, 0, size);
|
||||
if (values !== undefined) {
|
||||
givens.forEach((given, index) => {
|
||||
if (given !== 0 && values[index] !== given) {
|
||||
return fail(
|
||||
"INVALID_GRID",
|
||||
`values[${index}] must preserve its given digit.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
const solution =
|
||||
value.solution === undefined
|
||||
? undefined
|
||||
: numberArray(value.solution, "solution", cellCount, 1, size);
|
||||
const regions =
|
||||
value.regions === undefined
|
||||
? undefined
|
||||
: numberArray(value.regions, "regions", cellCount, 0, size - 1);
|
||||
|
||||
const marks = (input: unknown, label: string): number[][] | undefined => {
|
||||
if (input === undefined) return undefined;
|
||||
if (!Array.isArray(input) || input.length !== cellCount) {
|
||||
return fail(
|
||||
"INVALID_GRID",
|
||||
`${label} must contain exactly ${cellCount} entries.`,
|
||||
);
|
||||
}
|
||||
return input.map((entry, index) => {
|
||||
if (!Array.isArray(entry) || entry.length > size) {
|
||||
return fail(
|
||||
"INVALID_CANDIDATES",
|
||||
`${label}[${index}] must be an array.`,
|
||||
);
|
||||
}
|
||||
const parsed = entry.map((digit, digitIndex) =>
|
||||
integer(digit, `${label}[${index}][${digitIndex}]`, 1, size),
|
||||
);
|
||||
return [...new Set(parsed)].sort((a, b) => a - b);
|
||||
});
|
||||
};
|
||||
const cornerMarks = marks(value.cornerMarks, "cornerMarks");
|
||||
const centerMarks = marks(value.centerMarks, "centerMarks");
|
||||
const candidates = marks(value.candidates, "candidates");
|
||||
const colors =
|
||||
value.colors === undefined
|
||||
? undefined
|
||||
: numberArray(value.colors, "colors", cellCount, 0, 8);
|
||||
let elapsedMs: number | undefined;
|
||||
if (value.elapsedMs !== undefined) {
|
||||
if (
|
||||
typeof value.elapsedMs !== "number" ||
|
||||
!Number.isFinite(value.elapsedMs) ||
|
||||
value.elapsedMs < 0
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_NUMBER",
|
||||
"elapsedMs must be a non-negative finite number.",
|
||||
);
|
||||
}
|
||||
elapsedMs = value.elapsedMs;
|
||||
}
|
||||
|
||||
if (
|
||||
!Array.isArray(value.constraints) ||
|
||||
value.constraints.length > MAX_CONSTRAINTS
|
||||
) {
|
||||
return fail(
|
||||
"LIMIT_EXCEEDED",
|
||||
`constraints must contain at most ${MAX_CONSTRAINTS.toLocaleString()} entries.`,
|
||||
);
|
||||
}
|
||||
const constraints = value.constraints.map((constraint) =>
|
||||
parseConstraint(constraint, cellCount),
|
||||
);
|
||||
const title = optionalText(value.title, "title");
|
||||
const author = optionalText(value.author, "author");
|
||||
const id = optionalText(value.id, "id");
|
||||
const rules = textList(value.rules, "rules");
|
||||
const globalRules = textList(value.globalRules, "globalRules");
|
||||
|
||||
return {
|
||||
schema: SUDOKU_DOCUMENT_SCHEMA,
|
||||
version: SUDOKU_DOCUMENT_VERSION,
|
||||
size,
|
||||
givens,
|
||||
constraints,
|
||||
...(values === undefined ? {} : { values }),
|
||||
...(solution === undefined ? {} : { solution }),
|
||||
...(cornerMarks === undefined ? {} : { cornerMarks }),
|
||||
...(centerMarks === undefined ? {} : { centerMarks }),
|
||||
...(candidates === undefined ? {} : { candidates }),
|
||||
...(colors === undefined ? {} : { colors }),
|
||||
...(elapsedMs === undefined ? {} : { elapsedMs }),
|
||||
...(regions === undefined ? {} : { regions }),
|
||||
...(title === undefined ? {} : { title }),
|
||||
...(author === undefined ? {} : { author }),
|
||||
...(id === undefined ? {} : { id }),
|
||||
...(rules === undefined ? {} : { rules }),
|
||||
...(globalRules === undefined ? {} : { globalRules }),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSudokuDocument(input: string): SudokuDocument {
|
||||
if (new TextEncoder().encode(input).byteLength > MAX_DOCUMENT_BYTES) {
|
||||
return fail(
|
||||
"LIMIT_EXCEEDED",
|
||||
`Puzzle JSON exceeds ${MAX_DOCUMENT_BYTES.toLocaleString()} bytes.`,
|
||||
);
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(input) as unknown;
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_JSON",
|
||||
"The puzzle is not valid JSON.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
return normalizeSudokuDocument(parsed);
|
||||
}
|
||||
|
||||
export function serializeSudokuDocument(
|
||||
value: SudokuDocument,
|
||||
pretty = false,
|
||||
): string {
|
||||
const normalized = normalizeSudokuDocument(value);
|
||||
const result = JSON.stringify(normalized, null, pretty ? 2 : undefined);
|
||||
if (new TextEncoder().encode(result).byteLength > MAX_DOCUMENT_BYTES) {
|
||||
return fail(
|
||||
"LIMIT_EXCEEDED",
|
||||
`Puzzle JSON exceeds ${MAX_DOCUMENT_BYTES.toLocaleString()} bytes.`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function cloneSudokuDocument(value: SudokuDocument): SudokuDocument {
|
||||
const normalized = normalizeSudokuDocument(value);
|
||||
return {
|
||||
...normalized,
|
||||
givens: [...normalized.givens],
|
||||
constraints: normalized.constraints.map(cloneConstraint),
|
||||
...(normalized.values === undefined
|
||||
? {}
|
||||
: { values: [...normalized.values] }),
|
||||
...(normalized.solution === undefined
|
||||
? {}
|
||||
: { solution: [...normalized.solution] }),
|
||||
...(normalized.cornerMarks === undefined
|
||||
? {}
|
||||
: { cornerMarks: normalized.cornerMarks.map((entry) => [...entry]) }),
|
||||
...(normalized.centerMarks === undefined
|
||||
? {}
|
||||
: { centerMarks: normalized.centerMarks.map((entry) => [...entry]) }),
|
||||
...(normalized.candidates === undefined
|
||||
? {}
|
||||
: { candidates: normalized.candidates.map((entry) => [...entry]) }),
|
||||
...(normalized.colors === undefined
|
||||
? {}
|
||||
: { colors: [...normalized.colors] }),
|
||||
...(normalized.regions === undefined
|
||||
? {}
|
||||
: { regions: [...normalized.regions] }),
|
||||
...(normalized.rules === undefined ? {} : { rules: [...normalized.rules] }),
|
||||
...(normalized.globalRules === undefined
|
||||
? {}
|
||||
: { globalRules: [...normalized.globalRules] }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,740 @@
|
||||
import {
|
||||
compressToBase64,
|
||||
decompressFromBase64,
|
||||
decompressFromEncodedURIComponent,
|
||||
} from "lz-string";
|
||||
import {
|
||||
MAX_BOARD_SIZE,
|
||||
MAX_DOCUMENT_BYTES,
|
||||
MIN_BOARD_SIZE,
|
||||
SudokuFormatError,
|
||||
} from "./document";
|
||||
import {
|
||||
SUDOKU_DOCUMENT_SCHEMA,
|
||||
SUDOKU_DOCUMENT_VERSION,
|
||||
type PortableConstraint,
|
||||
type SudokuDocument,
|
||||
} from "./types";
|
||||
|
||||
export const MAX_FPUZZLES_PAYLOAD_LENGTH = 262_144;
|
||||
|
||||
const SUPPORTED_ROOT_FIELDS = new Set([
|
||||
"size",
|
||||
"grid",
|
||||
"title",
|
||||
"author",
|
||||
"ruleset",
|
||||
"solution",
|
||||
"diagonal+",
|
||||
"diagonal-",
|
||||
"antiknight",
|
||||
"antiking",
|
||||
"antikingsmove",
|
||||
"nonconsecutive",
|
||||
"killercage",
|
||||
"thermometer",
|
||||
"arrow",
|
||||
"difference",
|
||||
"ratio",
|
||||
"xv",
|
||||
"inequality",
|
||||
"renban",
|
||||
"palindrome",
|
||||
"disabledlogic",
|
||||
"truecandidatesoptions",
|
||||
"successMessage",
|
||||
"successmessage",
|
||||
]);
|
||||
|
||||
const UNSUPPORTED_RULE_FIELDS: Readonly<Record<string, string>> = {
|
||||
disjointgroups: "disjoint groups",
|
||||
littlekillersum: "little killer sums",
|
||||
sandwichsum: "sandwich sums",
|
||||
even: "even cells",
|
||||
odd: "odd cells",
|
||||
extraregion: "extra regions",
|
||||
clone: "clone regions",
|
||||
quadruple: "quadruples",
|
||||
betweenline: "between lines",
|
||||
minimum: "minimum cells",
|
||||
maximum: "maximum cells",
|
||||
whispers: "whisper lines",
|
||||
regionsumline: "region-sum lines",
|
||||
entropicline: "entropic lines",
|
||||
modularline: "modular lines",
|
||||
zipperline: "zipper lines",
|
||||
nabner: "Nabner lines",
|
||||
doublearrow: "double arrows",
|
||||
lockout: "lockout lines",
|
||||
rowindexer: "row indexers",
|
||||
columnindexer: "column indexers",
|
||||
boxindexer: "box indexers",
|
||||
xsum: "X-sums",
|
||||
skyscraper: "skyscrapers",
|
||||
fogofwar: "fog of war",
|
||||
foglight: "fog lights",
|
||||
cage: "generic cages",
|
||||
negative: "negative constraints",
|
||||
};
|
||||
|
||||
const DECORATION_FIELDS: Readonly<Record<string, string>> = {
|
||||
line: "decorative lines",
|
||||
rectangle: "rectangles",
|
||||
circle: "circles",
|
||||
text: "text decorations",
|
||||
};
|
||||
|
||||
export class NetworkPuzzleIdError extends SudokuFormatError {
|
||||
readonly puzzleId: string;
|
||||
|
||||
constructor(puzzleId: string) {
|
||||
super(
|
||||
"NETWORK_PUZZLE_ID",
|
||||
`“${puzzleId}” is a server-hosted SudokuPad puzzle ID. This local-only app cannot fetch short puzzle IDs; paste an inline fpuzzles URL or raw fpuzzles JSON instead.`,
|
||||
);
|
||||
this.name = "NetworkPuzzleIdError";
|
||||
this.puzzleId = puzzleId;
|
||||
}
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function fail(code: string, message: string): never {
|
||||
throw new SudokuFormatError(code, message);
|
||||
}
|
||||
|
||||
function present(value: unknown): boolean {
|
||||
if (value === undefined || value === null || value === false) return false;
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
if (typeof value === "string") return value.length > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
function assertSupportedRootFields(value: JsonRecord): void {
|
||||
for (const [field, raw] of Object.entries(value)) {
|
||||
const unsupported = UNSUPPORTED_RULE_FIELDS[field];
|
||||
if (unsupported !== undefined && present(raw)) {
|
||||
return fail(
|
||||
"UNSUPPORTED_FPUZZLES",
|
||||
`This puzzle uses ${unsupported}, which Sudoku Tools cannot enforce yet. Import stopped rather than silently weakening the puzzle.`,
|
||||
);
|
||||
}
|
||||
const decoration = DECORATION_FIELDS[field];
|
||||
if (decoration !== undefined && present(raw)) {
|
||||
return fail(
|
||||
"UNSUPPORTED_FPUZZLES",
|
||||
`This puzzle contains ${decoration}, which cannot be preserved yet. Import stopped rather than discarding them.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
!SUPPORTED_ROOT_FIELDS.has(field) &&
|
||||
unsupported === undefined &&
|
||||
decoration === undefined
|
||||
) {
|
||||
return fail(
|
||||
"UNSUPPORTED_FPUZZLES",
|
||||
`Unknown fpuzzles field “${field}”. Import stopped so a rule or visual cannot be silently lost.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function boundedJson(input: string): unknown {
|
||||
if (new TextEncoder().encode(input).byteLength > MAX_DOCUMENT_BYTES) {
|
||||
return fail(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The fpuzzles JSON is too large to open safely.",
|
||||
);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(input) as unknown;
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_FPUZZLES",
|
||||
"The fpuzzles data is not valid JSON.",
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function cellIndexFromAddress(value: unknown, size: number): number {
|
||||
if (typeof value !== "string")
|
||||
return fail("INVALID_CELL", "An fpuzzles cell must be RnCn text.");
|
||||
const match = /^R(\d+)C(\d+)$/iu.exec(value.trim());
|
||||
if (match === null)
|
||||
return fail("INVALID_CELL", `Invalid fpuzzles cell address: ${value}.`);
|
||||
const row = Number(match[1]);
|
||||
const column = Number(match[2]);
|
||||
if (row < 1 || row > size || column < 1 || column > size) {
|
||||
return fail(
|
||||
"INVALID_CELL",
|
||||
`Cell ${value} is outside the ${size}×${size} grid.`,
|
||||
);
|
||||
}
|
||||
return (row - 1) * size + column - 1;
|
||||
}
|
||||
|
||||
export function addressFromCellIndex(index: number, size: number): string {
|
||||
if (!Number.isInteger(index) || index < 0 || index >= size * size) {
|
||||
return fail(
|
||||
"INVALID_CELL",
|
||||
`Cell index ${index} is outside the ${size}×${size} grid.`,
|
||||
);
|
||||
}
|
||||
return `R${Math.floor(index / size) + 1}C${(index % size) + 1}`;
|
||||
}
|
||||
|
||||
function fpCells(value: unknown, size: number, minimum = 1): number[] {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length < minimum ||
|
||||
value.length > size * size
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_CELLS",
|
||||
"An fpuzzles constraint contains an invalid cell list.",
|
||||
);
|
||||
}
|
||||
const result = value.map((entry) => cellIndexFromAddress(entry, size));
|
||||
if (new Set(result).size !== result.length) {
|
||||
return fail(
|
||||
"DUPLICATE_CELL",
|
||||
"An fpuzzles constraint contains a duplicate cell.",
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function objects(value: unknown, maximum = 5_000): JsonRecord[] {
|
||||
if (value === undefined) return [];
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length > maximum ||
|
||||
!value.every(isRecord)
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_FPUZZLES",
|
||||
"An fpuzzles constraint collection is invalid or too large.",
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function lines(value: unknown, size: number): number[][] {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length === 0 ||
|
||||
value.length > size * size
|
||||
) {
|
||||
return fail("INVALID_CELLS", "An fpuzzles line collection is invalid.");
|
||||
}
|
||||
// Some producers use `lines: [[...]]`; tolerate a direct cell list as well.
|
||||
if (value.every((entry) => typeof entry === "string"))
|
||||
return [fpCells(value, size, 2)];
|
||||
return value.map((line) => fpCells(line, size, 2));
|
||||
}
|
||||
|
||||
function numeric(
|
||||
value: unknown,
|
||||
label: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const parsed =
|
||||
typeof value === "string" && value.trim() !== "" ? Number(value) : value;
|
||||
if (
|
||||
!Number.isInteger(parsed) ||
|
||||
(parsed as number) < minimum ||
|
||||
(parsed as number) > maximum
|
||||
) {
|
||||
return fail("INVALID_FPUZZLES", `${label} is outside the supported range.`);
|
||||
}
|
||||
return parsed as number;
|
||||
}
|
||||
|
||||
function readGrid(value: unknown, size: number): JsonRecord[] {
|
||||
if (!Array.isArray(value))
|
||||
return fail("INVALID_FPUZZLES", "fpuzzles.grid must be an array.");
|
||||
const flattened =
|
||||
value.length === size && value.every(Array.isArray) ? value.flat() : value;
|
||||
if (flattened.length !== size * size || !flattened.every(isRecord)) {
|
||||
return fail(
|
||||
"INVALID_FPUZZLES",
|
||||
`fpuzzles.grid must contain ${size * size} cells.`,
|
||||
);
|
||||
}
|
||||
return flattened;
|
||||
}
|
||||
|
||||
function readRegions(
|
||||
grid: readonly JsonRecord[],
|
||||
size: number,
|
||||
): number[] | undefined {
|
||||
const hasAny = grid.some((entry) => entry.region !== undefined);
|
||||
if (!hasAny) return undefined;
|
||||
return grid.map((entry, index) =>
|
||||
entry.region === undefined
|
||||
? fail(
|
||||
"INVALID_FPUZZLES",
|
||||
"A custom region map must assign every cell to a region.",
|
||||
)
|
||||
: numeric(entry.region, `grid[${index}].region`, 0, size - 1),
|
||||
);
|
||||
}
|
||||
|
||||
function addLineConstraints(
|
||||
output: PortableConstraint[],
|
||||
source: unknown,
|
||||
size: number,
|
||||
type: "thermo" | "renban" | "palindrome",
|
||||
): void {
|
||||
for (const item of objects(source)) {
|
||||
for (const line of lines(item.lines ?? item.cells, size))
|
||||
output.push({ type, cells: line });
|
||||
}
|
||||
}
|
||||
|
||||
function parseRules(value: unknown): string[] | undefined {
|
||||
if (value === undefined || value === "") return undefined;
|
||||
if (typeof value === "string") return [value];
|
||||
if (
|
||||
Array.isArray(value) &&
|
||||
value.every((entry) => typeof entry === "string")
|
||||
) {
|
||||
return value.slice(0, 1_000);
|
||||
}
|
||||
return fail(
|
||||
"INVALID_FPUZZLES",
|
||||
"fpuzzles.ruleset must be text or a list of text rules.",
|
||||
);
|
||||
}
|
||||
|
||||
/** Convert already-decoded fpuzzles JSON into the bounded Sudoku Tools model. */
|
||||
export function parseFpuzzles(value: unknown): SudokuDocument {
|
||||
if (!isRecord(value))
|
||||
return fail("INVALID_FPUZZLES", "The fpuzzles puzzle must be an object.");
|
||||
assertSupportedRootFields(value);
|
||||
const size = numeric(
|
||||
value.size ?? 9,
|
||||
"fpuzzles.size",
|
||||
MIN_BOARD_SIZE,
|
||||
MAX_BOARD_SIZE,
|
||||
);
|
||||
const grid = readGrid(value.grid, size);
|
||||
const givens = grid.map((entry, index) => {
|
||||
if (entry.given !== true) return 0;
|
||||
return numeric(entry.value, `grid[${index}].value`, 1, size);
|
||||
});
|
||||
const values = grid.map((entry, index) =>
|
||||
entry.value === undefined || entry.value === null || entry.value === ""
|
||||
? 0
|
||||
: numeric(entry.value, `grid[${index}].value`, 1, size),
|
||||
);
|
||||
const readMarks = (field: "centerPencilMarks" | "cornerPencilMarks") => {
|
||||
const parsed = grid.map((entry, cellIndex) => {
|
||||
const raw = entry[field];
|
||||
if (raw === undefined || raw === null) return [];
|
||||
if (!Array.isArray(raw) || raw.length > size) {
|
||||
return fail(
|
||||
"INVALID_FPUZZLES",
|
||||
`grid[${cellIndex}].${field} must be a bounded digit array.`,
|
||||
);
|
||||
}
|
||||
return [
|
||||
...new Set(
|
||||
raw.map((mark, markIndex) =>
|
||||
numeric(mark, `grid[${cellIndex}].${field}[${markIndex}]`, 1, size),
|
||||
),
|
||||
),
|
||||
].sort((a, b) => a - b);
|
||||
});
|
||||
return parsed.some((entry) => entry.length > 0) ? parsed : undefined;
|
||||
};
|
||||
const centerMarks = readMarks("centerPencilMarks");
|
||||
const cornerMarks = readMarks("cornerPencilMarks");
|
||||
const solution =
|
||||
value.solution === undefined
|
||||
? undefined
|
||||
: Array.isArray(value.solution) && value.solution.length === size * size
|
||||
? value.solution.map((entry, index) =>
|
||||
numeric(entry, `solution[${index}]`, 1, size),
|
||||
)
|
||||
: fail(
|
||||
"INVALID_FPUZZLES",
|
||||
`solution must contain exactly ${size * size} digits.`,
|
||||
);
|
||||
const constraints: PortableConstraint[] = [];
|
||||
|
||||
if (value["diagonal+"] === true)
|
||||
constraints.push({ type: "diagonal", direction: "main" });
|
||||
if (value["diagonal-"] === true)
|
||||
constraints.push({ type: "diagonal", direction: "anti" });
|
||||
if (value.antiknight === true) constraints.push({ type: "anti-knight" });
|
||||
if (value.antiking === true || value.antikingsmove === true)
|
||||
constraints.push({ type: "anti-king" });
|
||||
if (value.nonconsecutive === true)
|
||||
constraints.push({ type: "non-consecutive" });
|
||||
|
||||
for (const cage of objects(value.killercage)) {
|
||||
if (cage.value === undefined || cage.value === "") {
|
||||
return fail(
|
||||
"UNSUPPORTED_FPUZZLES",
|
||||
"A killer cage without a sum cannot be imported.",
|
||||
);
|
||||
}
|
||||
const sum = numeric(cage.value, "killercage.value", 1, size * size * size);
|
||||
constraints.push({
|
||||
type: "killer-cage",
|
||||
cells: fpCells(cage.cells, size),
|
||||
sum,
|
||||
noRepeat: cage.unique !== false,
|
||||
});
|
||||
}
|
||||
|
||||
addLineConstraints(constraints, value.thermometer, size, "thermo");
|
||||
addLineConstraints(constraints, value.renban, size, "renban");
|
||||
addLineConstraints(constraints, value.palindrome, size, "palindrome");
|
||||
|
||||
for (const arrow of objects(value.arrow)) {
|
||||
const bulb = fpCells(arrow.cells, size);
|
||||
for (const line of lines(arrow.lines, size)) {
|
||||
const path = line.filter((cell) => !bulb.includes(cell));
|
||||
if (path.length === 0) {
|
||||
return fail(
|
||||
"INVALID_FPUZZLES",
|
||||
"An arrow line must extend beyond its bulb.",
|
||||
);
|
||||
}
|
||||
constraints.push({ type: "arrow", bulb, line: path });
|
||||
}
|
||||
}
|
||||
|
||||
for (const dot of objects(value.difference)) {
|
||||
const dotCells = fpCells(dot.cells, size, 2);
|
||||
if (dotCells.length !== 2)
|
||||
return fail("INVALID_CELLS", "A difference dot needs two cells.");
|
||||
const difference = numeric(dot.value ?? 1, "difference.value", 1, size - 1);
|
||||
const [a, b] = dotCells as [number, number];
|
||||
if (difference !== 1) {
|
||||
return fail(
|
||||
"UNSUPPORTED_FPUZZLES",
|
||||
`Difference-${difference} dots are not supported; only standard white Kropki dots are available.`,
|
||||
);
|
||||
}
|
||||
constraints.push({ type: "kropki", a, b, kind: "white" });
|
||||
}
|
||||
for (const dot of objects(value.ratio)) {
|
||||
const dotCells = fpCells(dot.cells, size, 2);
|
||||
if (dotCells.length !== 2)
|
||||
return fail("INVALID_CELLS", "A ratio dot needs two cells.");
|
||||
const ratio = numeric(dot.value ?? 2, "ratio.value", 2, size);
|
||||
const [a, b] = dotCells as [number, number];
|
||||
if (ratio !== 2) {
|
||||
return fail(
|
||||
"UNSUPPORTED_FPUZZLES",
|
||||
`Ratio-${ratio} dots are not supported; only standard black Kropki dots are available.`,
|
||||
);
|
||||
}
|
||||
constraints.push({ type: "kropki", a, b, kind: "black" });
|
||||
}
|
||||
for (const xv of objects(value.xv)) {
|
||||
const xvCells = fpCells(xv.cells, size, 2);
|
||||
if (xvCells.length !== 2)
|
||||
return fail("INVALID_CELLS", "An XV clue needs two cells.");
|
||||
const total =
|
||||
typeof xv.value === "string" && xv.value.toUpperCase() === "V"
|
||||
? 5
|
||||
: typeof xv.value === "string" && xv.value.toUpperCase() === "X"
|
||||
? 10
|
||||
: numeric(xv.value, "xv.value", 1, size * 2);
|
||||
if (total !== 5 && total !== 10) {
|
||||
return fail(
|
||||
"UNSUPPORTED_FPUZZLES",
|
||||
`XV total ${total} is not supported; expected 5 or 10.`,
|
||||
);
|
||||
}
|
||||
const [a, b] = xvCells as [number, number];
|
||||
constraints.push({ type: "xv", a, b, total });
|
||||
}
|
||||
for (const inequality of objects(value.inequality)) {
|
||||
const inequalityCells = fpCells(inequality.cells, size, 2);
|
||||
if (inequalityCells.length !== 2) {
|
||||
return fail("INVALID_CELLS", "An inequality needs two cells.");
|
||||
}
|
||||
const [a, b] = inequalityCells as [number, number];
|
||||
if (inequality.value === ">")
|
||||
constraints.push({ type: "inequality", lesser: b, greater: a });
|
||||
else constraints.push({ type: "inequality", lesser: a, greater: b });
|
||||
}
|
||||
|
||||
const regions = readRegions(grid, size);
|
||||
const rules = parseRules(value.ruleset);
|
||||
const title =
|
||||
typeof value.title === "string" ? value.title.slice(0, 20_000) : undefined;
|
||||
const author =
|
||||
typeof value.author === "string"
|
||||
? value.author.slice(0, 20_000)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
schema: SUDOKU_DOCUMENT_SCHEMA,
|
||||
version: SUDOKU_DOCUMENT_VERSION,
|
||||
size,
|
||||
givens,
|
||||
values,
|
||||
constraints,
|
||||
...(solution === undefined ? {} : { solution }),
|
||||
...(cornerMarks === undefined ? {} : { cornerMarks }),
|
||||
...(centerMarks === undefined ? {} : { centerMarks }),
|
||||
...(regions === undefined ? {} : { regions }),
|
||||
...(rules === undefined ? {} : { rules }),
|
||||
...(title === undefined ? {} : { title }),
|
||||
...(author === undefined ? {} : { author }),
|
||||
};
|
||||
}
|
||||
|
||||
function constraintCells(cells: readonly number[], size: number): string[] {
|
||||
return cells.map((cell) => addressFromCellIndex(cell, size));
|
||||
}
|
||||
|
||||
export function exportFpuzzles(document: SudokuDocument): JsonRecord {
|
||||
const { size } = document;
|
||||
const output: JsonRecord = {
|
||||
size,
|
||||
grid: Array.from({ length: size }, (_, row) =>
|
||||
Array.from({ length: size }, (_unused, column) => {
|
||||
const index = row * size + column;
|
||||
const given = document.givens[index] ?? 0;
|
||||
const value = given || document.values?.[index] || 0;
|
||||
return {
|
||||
...(value === 0 ? {} : { value }),
|
||||
...(given === 0 ? {} : { given: true }),
|
||||
...((document.centerMarks ?? document.candidates)?.[index]?.length
|
||||
? {
|
||||
centerPencilMarks: [
|
||||
...(document.centerMarks ?? document.candidates)![index]!,
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
...(document.cornerMarks?.[index]?.length
|
||||
? { cornerPencilMarks: [...document.cornerMarks[index]!] }
|
||||
: {}),
|
||||
...(document.regions === undefined || document.regions[index] === -1
|
||||
? {}
|
||||
: { region: document.regions[index] }),
|
||||
};
|
||||
}),
|
||||
),
|
||||
...(document.title === undefined ? {} : { title: document.title }),
|
||||
...(document.author === undefined ? {} : { author: document.author }),
|
||||
...(document.solution === undefined
|
||||
? {}
|
||||
: { solution: [...document.solution] }),
|
||||
...(document.rules === undefined
|
||||
? {}
|
||||
: { ruleset: document.rules.join("\n") }),
|
||||
};
|
||||
|
||||
const append = (field: string, item: JsonRecord): void => {
|
||||
const collection = output[field];
|
||||
if (collection === undefined) output[field] = [item];
|
||||
else (collection as JsonRecord[]).push(item);
|
||||
};
|
||||
|
||||
for (const constraint of document.constraints) {
|
||||
switch (constraint.type) {
|
||||
case "diagonal":
|
||||
output[constraint.direction === "main" ? "diagonal+" : "diagonal-"] =
|
||||
true;
|
||||
break;
|
||||
case "anti-knight":
|
||||
output.antiknight = true;
|
||||
break;
|
||||
case "anti-king":
|
||||
output.antikingsmove = true;
|
||||
break;
|
||||
case "non-consecutive":
|
||||
output.nonconsecutive = true;
|
||||
break;
|
||||
case "killer-cage":
|
||||
append("killercage", {
|
||||
cells: constraintCells(constraint.cells, size),
|
||||
...(constraint.sum === undefined
|
||||
? {}
|
||||
: { value: String(constraint.sum) }),
|
||||
...(constraint.noRepeat === false ? { unique: false } : {}),
|
||||
});
|
||||
break;
|
||||
case "thermo":
|
||||
append("thermometer", {
|
||||
lines: [constraintCells(constraint.cells, size)],
|
||||
});
|
||||
break;
|
||||
case "renban":
|
||||
case "palindrome":
|
||||
append(constraint.type, {
|
||||
lines: [constraintCells(constraint.cells, size)],
|
||||
});
|
||||
break;
|
||||
case "arrow":
|
||||
append("arrow", {
|
||||
cells: constraintCells(constraint.bulb, size),
|
||||
lines: [
|
||||
constraintCells(
|
||||
[
|
||||
constraint.bulb.at(-1)!,
|
||||
...constraint.line.filter(
|
||||
(cell) => !constraint.bulb.includes(cell),
|
||||
),
|
||||
],
|
||||
size,
|
||||
),
|
||||
],
|
||||
});
|
||||
break;
|
||||
case "kropki":
|
||||
append(constraint.kind === "white" ? "difference" : "ratio", {
|
||||
cells: constraintCells([constraint.a, constraint.b], size),
|
||||
value: constraint.kind === "white" ? "1" : "2",
|
||||
});
|
||||
break;
|
||||
case "xv":
|
||||
append("xv", {
|
||||
cells: constraintCells([constraint.a, constraint.b], size),
|
||||
value:
|
||||
constraint.total === 5
|
||||
? "V"
|
||||
: constraint.total === 10
|
||||
? "X"
|
||||
: String(constraint.total),
|
||||
});
|
||||
break;
|
||||
case "inequality":
|
||||
append("inequality", {
|
||||
cells: constraintCells([constraint.lesser, constraint.greater], size),
|
||||
value: "<",
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ((document.globalRules?.length ?? 0) > 0) {
|
||||
return fail(
|
||||
"UNSUPPORTED_FPUZZLES",
|
||||
"This project contains global rules that fpuzzles export cannot preserve safely.",
|
||||
);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function extractPayload(input: string): string | undefined {
|
||||
const trimmed = input.trim();
|
||||
if (trimmed.startsWith("{")) return undefined;
|
||||
|
||||
let candidate = trimmed;
|
||||
if (/^https?:\/\//iu.test(trimmed)) {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(trimmed);
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_FPUZZLES_URL",
|
||||
"The fpuzzles URL is invalid.",
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
const queryId =
|
||||
url.searchParams.get("puzzleid") ?? url.searchParams.get("load");
|
||||
candidate = queryId ?? decodeURIComponent(url.pathname.replace(/^\//u, ""));
|
||||
}
|
||||
if (candidate.startsWith("fpuzzles"))
|
||||
return candidate.slice("fpuzzles".length);
|
||||
if (/^[\w-]{1,128}$/u.test(candidate))
|
||||
throw new NetworkPuzzleIdError(candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function decodePayload(payload: string): string {
|
||||
if (payload.length === 0 || payload.length > MAX_FPUZZLES_PAYLOAD_LENGTH) {
|
||||
return fail(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The compressed fpuzzles payload is empty or too large.",
|
||||
);
|
||||
}
|
||||
let decodedPayload = payload;
|
||||
try {
|
||||
decodedPayload = decodeURIComponent(payload);
|
||||
} catch {
|
||||
// URLSearchParams already decodes input. Keep the original if a literal % is malformed.
|
||||
}
|
||||
const base64 = decompressFromBase64(decodedPayload.replaceAll(" ", "+"));
|
||||
const uriEncoded =
|
||||
base64 || decompressFromEncodedURIComponent(decodedPayload);
|
||||
if (uriEncoded === null || uriEncoded === "") {
|
||||
return fail(
|
||||
"INVALID_FPUZZLES",
|
||||
"The inline fpuzzles payload could not be decompressed.",
|
||||
);
|
||||
}
|
||||
if (new TextEncoder().encode(uriEncoded).byteLength > MAX_DOCUMENT_BYTES) {
|
||||
return fail(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The decompressed fpuzzles puzzle is too large to open safely.",
|
||||
);
|
||||
}
|
||||
return uriEncoded;
|
||||
}
|
||||
|
||||
/** Import raw JSON, f-puzzles load URLs, or inline SudokuPad fpuzzles URLs. */
|
||||
export function importFpuzzles(input: string): SudokuDocument {
|
||||
const payload = extractPayload(input);
|
||||
return parseFpuzzles(
|
||||
boundedJson(payload === undefined ? input : decodePayload(payload)),
|
||||
);
|
||||
}
|
||||
|
||||
export function exportFpuzzlesJson(
|
||||
document: SudokuDocument,
|
||||
pretty = false,
|
||||
): string {
|
||||
const json = JSON.stringify(
|
||||
exportFpuzzles(document),
|
||||
null,
|
||||
pretty ? 2 : undefined,
|
||||
);
|
||||
if (new TextEncoder().encode(json).byteLength > MAX_DOCUMENT_BYTES) {
|
||||
return fail("LIMIT_EXCEEDED", "The exported fpuzzles JSON is too large.");
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
export function exportFpuzzlesPayload(document: SudokuDocument): string {
|
||||
const payload = compressToBase64(exportFpuzzlesJson(document));
|
||||
if (payload.length > MAX_FPUZZLES_PAYLOAD_LENGTH) {
|
||||
return fail(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The compressed fpuzzles payload is too large.",
|
||||
);
|
||||
}
|
||||
return encodeURIComponent(payload);
|
||||
}
|
||||
|
||||
export function exportFpuzzlesUrl(
|
||||
document: SudokuDocument,
|
||||
baseUrl = "https://sudokupad.app/",
|
||||
): string {
|
||||
const url = new URL(baseUrl);
|
||||
url.searchParams.set(
|
||||
"puzzleid",
|
||||
`fpuzzles${decodeURIComponent(exportFpuzzlesPayload(document))}`,
|
||||
);
|
||||
return url.toString();
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { MAX_BOARD_SIZE, MIN_BOARD_SIZE, SudokuFormatError } from "./document";
|
||||
import {
|
||||
SUDOKU_DOCUMENT_SCHEMA,
|
||||
SUDOKU_DOCUMENT_VERSION,
|
||||
type SudokuDocument,
|
||||
} from "./types";
|
||||
|
||||
const SEPARATORS = /[\s|+-]/gu;
|
||||
|
||||
function decodeSymbol(symbol: string): number {
|
||||
if (symbol === "." || symbol === "0") return 0;
|
||||
if (symbol >= "1" && symbol <= "9") return Number(symbol);
|
||||
const code = symbol.toUpperCase().charCodeAt(0);
|
||||
if (code >= 65 && code <= 80) return code - 55;
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_GRID_SYMBOL",
|
||||
`Unsupported grid symbol ${JSON.stringify(symbol)}. Use 0 or . for an empty cell.`,
|
||||
);
|
||||
}
|
||||
|
||||
function encodeSymbol(value: number): string {
|
||||
if (value === 0) return ".";
|
||||
if (value <= 9) return String(value);
|
||||
return String.fromCharCode(value + 55);
|
||||
}
|
||||
|
||||
export interface PlainGridOptions {
|
||||
readonly size?: number;
|
||||
readonly title?: string;
|
||||
readonly author?: string;
|
||||
}
|
||||
|
||||
export function parsePlainGrid(
|
||||
input: string,
|
||||
options: PlainGridOptions = {},
|
||||
): SudokuDocument {
|
||||
const compact = input.replace(/^\uFEFF/u, "").replace(SEPARATORS, "");
|
||||
const inferred = Math.sqrt(compact.length);
|
||||
const size = options.size ?? inferred;
|
||||
if (
|
||||
!Number.isInteger(size) ||
|
||||
size < MIN_BOARD_SIZE ||
|
||||
size > MAX_BOARD_SIZE
|
||||
) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_GRID_SIZE",
|
||||
`The grid must have a square number of cells and a side length from ${MIN_BOARD_SIZE} to ${MAX_BOARD_SIZE}.`,
|
||||
);
|
||||
}
|
||||
if (compact.length !== size * size) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_GRID_LENGTH",
|
||||
`Expected ${size * size} cell symbols for a ${size}×${size} grid, but found ${compact.length}.`,
|
||||
);
|
||||
}
|
||||
const givens = [...compact].map((symbol, index) => {
|
||||
const value = decodeSymbol(symbol);
|
||||
if (value > size) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_GRID_SYMBOL",
|
||||
`Cell ${index + 1} contains ${symbol}, which is outside 1–${size}.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
return {
|
||||
schema: SUDOKU_DOCUMENT_SCHEMA,
|
||||
version: SUDOKU_DOCUMENT_VERSION,
|
||||
size,
|
||||
givens,
|
||||
constraints: [],
|
||||
...(options.title === undefined ? {} : { title: options.title }),
|
||||
...(options.author === undefined ? {} : { author: options.author }),
|
||||
};
|
||||
}
|
||||
|
||||
export function serializePlainGrid(
|
||||
document: Pick<SudokuDocument, "size" | "givens" | "values">,
|
||||
source: "givens" | "values" = "givens",
|
||||
): string {
|
||||
const values = source === "values" ? document.values : document.givens;
|
||||
if (values === undefined) {
|
||||
throw new SudokuFormatError(
|
||||
"MISSING_GRID",
|
||||
"This puzzle has no current values to export.",
|
||||
);
|
||||
}
|
||||
if (values.length !== document.size * document.size) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_GRID_LENGTH",
|
||||
"The grid does not match the puzzle size.",
|
||||
);
|
||||
}
|
||||
return values
|
||||
.map((value, index) => {
|
||||
if (!Number.isInteger(value) || value < 0 || value > document.size) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_GRID_SYMBOL",
|
||||
`Cell ${index + 1} is out of range.`,
|
||||
);
|
||||
}
|
||||
return encodeSymbol(value);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./document";
|
||||
export * from "./fpuzzles";
|
||||
export * from "./grid";
|
||||
export * from "./share";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
compressToEncodedURIComponent,
|
||||
decompressFromEncodedURIComponent,
|
||||
} from "lz-string";
|
||||
import {
|
||||
MAX_DOCUMENT_BYTES,
|
||||
SudokuFormatError,
|
||||
parseSudokuDocument,
|
||||
serializeSudokuDocument,
|
||||
} from "./document";
|
||||
import type { SudokuDocument } from "./types";
|
||||
|
||||
export const PUZZLE_HASH_PREFIX = "#sudoku=v1.";
|
||||
export const MAX_SHARE_HASH_LENGTH = 131_072;
|
||||
|
||||
export function encodePuzzleHash(document: SudokuDocument): string {
|
||||
const payload = compressToEncodedURIComponent(
|
||||
serializeSudokuDocument(document),
|
||||
);
|
||||
const hash = `${PUZZLE_HASH_PREFIX}${payload}`;
|
||||
if (hash.length > MAX_SHARE_HASH_LENGTH) {
|
||||
throw new SudokuFormatError(
|
||||
"LIMIT_EXCEEDED",
|
||||
`The compressed puzzle exceeds the ${MAX_SHARE_HASH_LENGTH.toLocaleString()} character share limit.`,
|
||||
);
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
export function decodePuzzleHash(hashOrUrl: string): SudokuDocument {
|
||||
let hash = hashOrUrl.trim();
|
||||
try {
|
||||
if (/^[a-z][a-z\d+.-]*:\/\//iu.test(hash)) hash = new URL(hash).hash;
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError("INVALID_SHARE", "The share URL is invalid.", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
if (!hash.startsWith(PUZZLE_HASH_PREFIX)) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_SHARE",
|
||||
`Expected a hash beginning with ${PUZZLE_HASH_PREFIX}.`,
|
||||
);
|
||||
}
|
||||
if (hash.length > MAX_SHARE_HASH_LENGTH) {
|
||||
throw new SudokuFormatError(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The share hash is too large to open safely.",
|
||||
);
|
||||
}
|
||||
const compressed = hash.slice(PUZZLE_HASH_PREFIX.length);
|
||||
const json = decompressFromEncodedURIComponent(compressed);
|
||||
if (json === null || json === "") {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_SHARE",
|
||||
"The share payload could not be decompressed.",
|
||||
);
|
||||
}
|
||||
if (new TextEncoder().encode(json).byteLength > MAX_DOCUMENT_BYTES) {
|
||||
throw new SudokuFormatError(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The decompressed puzzle is too large to open safely.",
|
||||
);
|
||||
}
|
||||
return parseSudokuDocument(json);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
export const SUDOKU_DOCUMENT_SCHEMA =
|
||||
"de.add-ideas.sudoku-tools.puzzle" as const;
|
||||
export const SUDOKU_DOCUMENT_VERSION = 1 as const;
|
||||
|
||||
export type CellIndex = number;
|
||||
|
||||
export type PortableConstraint =
|
||||
| { readonly type: "diagonal"; readonly direction: "main" | "anti" }
|
||||
| { readonly type: "anti-knight" }
|
||||
| { readonly type: "anti-king" }
|
||||
| { readonly type: "non-consecutive" }
|
||||
| {
|
||||
readonly type: "killer-cage";
|
||||
readonly cells: readonly CellIndex[];
|
||||
readonly sum: number;
|
||||
readonly noRepeat?: boolean;
|
||||
}
|
||||
| { readonly type: "thermo"; readonly cells: readonly CellIndex[] }
|
||||
| {
|
||||
readonly type: "arrow";
|
||||
readonly bulb: readonly CellIndex[];
|
||||
readonly line: readonly CellIndex[];
|
||||
}
|
||||
| {
|
||||
readonly type: "kropki";
|
||||
readonly a: CellIndex;
|
||||
readonly b: CellIndex;
|
||||
readonly kind: "white" | "black";
|
||||
}
|
||||
| {
|
||||
readonly type: "xv";
|
||||
readonly a: CellIndex;
|
||||
readonly b: CellIndex;
|
||||
readonly total: 5 | 10;
|
||||
}
|
||||
| {
|
||||
readonly type: "inequality";
|
||||
readonly lesser: CellIndex;
|
||||
readonly greater: CellIndex;
|
||||
}
|
||||
| { readonly type: "renban"; readonly cells: readonly CellIndex[] }
|
||||
| { readonly type: "palindrome"; readonly cells: readonly CellIndex[] };
|
||||
|
||||
/**
|
||||
* Stable, versioned interchange format owned by Sudoku Tools. Cell indices are
|
||||
* row-major and zero based. Zero denotes an empty grid value.
|
||||
*/
|
||||
export interface SudokuDocument {
|
||||
readonly schema: typeof SUDOKU_DOCUMENT_SCHEMA;
|
||||
readonly version: typeof SUDOKU_DOCUMENT_VERSION;
|
||||
readonly size: number;
|
||||
readonly givens: readonly number[];
|
||||
readonly values?: readonly number[];
|
||||
readonly cornerMarks?: readonly (readonly number[])[];
|
||||
readonly centerMarks?: readonly (readonly number[])[];
|
||||
/** Legacy v1 alias for centre marks. */
|
||||
readonly candidates?: readonly (readonly number[])[];
|
||||
readonly colors?: readonly number[];
|
||||
readonly elapsedMs?: number;
|
||||
readonly solution?: readonly number[];
|
||||
readonly regions?: readonly number[];
|
||||
readonly constraints: readonly PortableConstraint[];
|
||||
readonly title?: string;
|
||||
readonly author?: string;
|
||||
readonly rules?: readonly string[];
|
||||
/** Named boolean/global rules which cannot be represented by a local shape. */
|
||||
readonly globalRules?: readonly string[];
|
||||
readonly id?: string;
|
||||
}
|
||||
|
||||
/** Minimal structural type accepted by the domain adapter. */
|
||||
export interface DomainPuzzleShape {
|
||||
readonly version: 1;
|
||||
readonly size: number;
|
||||
readonly givens: readonly number[];
|
||||
readonly regions?: readonly number[];
|
||||
readonly constraints?: readonly PortableConstraint[];
|
||||
readonly title?: string;
|
||||
readonly author?: string;
|
||||
readonly id?: string;
|
||||
readonly rules?: string;
|
||||
readonly solution?: readonly number[];
|
||||
}
|
||||
|
||||
export function toDomainPuzzle(document: SudokuDocument): DomainPuzzleShape {
|
||||
if ((document.globalRules?.length ?? 0) > 0) {
|
||||
throw new Error(
|
||||
"This document contains global rules that the current puzzle engine cannot enforce.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
size: document.size,
|
||||
givens: [...document.givens],
|
||||
...(document.regions === undefined
|
||||
? {}
|
||||
: { regions: [...document.regions] }),
|
||||
constraints: document.constraints.map(cloneConstraint),
|
||||
...(document.title === undefined ? {} : { title: document.title }),
|
||||
...(document.author === undefined ? {} : { author: document.author }),
|
||||
...(document.id === undefined ? {} : { id: document.id }),
|
||||
...(document.rules === undefined
|
||||
? {}
|
||||
: { rules: document.rules.join("\n") }),
|
||||
...(document.solution === undefined
|
||||
? {}
|
||||
: { solution: [...document.solution] }),
|
||||
};
|
||||
}
|
||||
|
||||
export function fromDomainPuzzle(puzzle: DomainPuzzleShape): SudokuDocument {
|
||||
return {
|
||||
schema: SUDOKU_DOCUMENT_SCHEMA,
|
||||
version: SUDOKU_DOCUMENT_VERSION,
|
||||
size: puzzle.size,
|
||||
givens: [...puzzle.givens],
|
||||
constraints: (puzzle.constraints ?? []).map(cloneConstraint),
|
||||
...(puzzle.regions === undefined ? {} : { regions: [...puzzle.regions] }),
|
||||
...(puzzle.title === undefined ? {} : { title: puzzle.title }),
|
||||
...(puzzle.author === undefined ? {} : { author: puzzle.author }),
|
||||
...(puzzle.id === undefined ? {} : { id: puzzle.id }),
|
||||
...(puzzle.rules === undefined ? {} : { rules: [puzzle.rules] }),
|
||||
...(puzzle.solution === undefined
|
||||
? {}
|
||||
: { solution: [...puzzle.solution] }),
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneConstraint(
|
||||
constraint: PortableConstraint,
|
||||
): PortableConstraint {
|
||||
switch (constraint.type) {
|
||||
case "killer-cage":
|
||||
return { ...constraint, cells: [...constraint.cells] };
|
||||
case "thermo":
|
||||
case "renban":
|
||||
case "palindrome":
|
||||
return { ...constraint, cells: [...constraint.cells] };
|
||||
case "arrow":
|
||||
return {
|
||||
...constraint,
|
||||
bulb: [...constraint.bulb],
|
||||
line: [...constraint.line],
|
||||
};
|
||||
default:
|
||||
return { ...constraint };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user