feat: launch local-first Sudoku workbench

This commit is contained in:
2026-08-30 14:14:11 +02:00
commit 659640b231
97 changed files with 19111 additions and 0 deletions
+427
View File
@@ -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] }),
};
}
+740
View File
@@ -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();
}
+105
View File
@@ -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("");
}
+5
View File
@@ -0,0 +1,5 @@
export * from "./document";
export * from "./fpuzzles";
export * from "./grid";
export * from "./share";
export * from "./types";
+66
View File
@@ -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);
}
+148
View File
@@ -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 };
}
}