diff --git a/CHANGELOG.md b/CHANGELOG.md
index a30e3ee..ab1d3dc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,30 @@ All notable changes are documented here.
## Unreleased
+## 0.2.0 - 2026-08-31
+
+- Added staged guided hints that reveal focus, technique, reasoning and effects
+ before an explicit, undoable apply action.
+- Added legal centre-candidate filling, invalid note pruning and optional peer
+ note maintenance after placements; erasing a value never invents candidates.
+- Added Jellyfish, finned X-Wing/Swordfish, Skyscraper, Two-String Kite, Simple
+ Colouring, W-Wing, X-Chain, XY-Chain and alternating-inference-chain logical
+ steps with deterministic evidence.
+- Added Unique Rectangle only behind a completed exact uniqueness proof; a
+ truncated search never enables uniqueness-dependent logic.
+- Added quick and full setter-quality audits with ambiguity witnesses,
+ contradiction suspects, per-item critical/redundant/unknown findings, a cell
+ heatmap and optional bounded minimality proof.
+- Added explicit per-check and aggregate quality budgets, cancellation and
+ serializable check evidence so a capped search remains unknown rather than
+ becoming a false proof.
+- Added minimum, odd/even, disjoint-groups, little-killer and sandwich rules
+ across validation, candidate filtering, exact solving, rendering and setting.
+- Added between, German-whisper, region-sum, modular, entropic and zipper lines;
+ clone and extra regions; double arrows; and row, column and box indexers.
+- Added display-only Fog of War backed by a complete locally stored solution.
+ Only initial lights, givens and correctly entered values reveal cells; fog is
+ never treated as an extra solution constraint.
- Added optional digit-completion counts with muted complete digits and red
over-completion warnings.
- Added toggleable Ctrl/Command-click matching-digit highlights without turning
@@ -12,10 +36,16 @@ All notable changes are documented here.
remain accidentally active.
- Replaced ambiguous V/X pair labels on the board with numbered 5/10 badges and
gave inequalities a directional chevron with a marked lesser-value tip.
-- Added thirteen uniquely checked built-in examples covering every supported
- constraint family.
-- Added bounded, seedable generation for classic and twelve variant families,
- plus independent uniqueness and evidence-based difficulty assessment.
+- Added fourteen uniquely checked built-in examples covering the original core
+ constraint families.
+- Expanded bounded, seedable generation for classic and twelve variant families
+ with mixed-family recipes, sparse/balanced/dense clue controls and additional
+ reflection, diagonal and four-way clue symmetries.
+- Added explicit minimal-givens mode with proof/unknown reporting, deterministic
+ batches of up to 12 candidates and rankings by difficulty or clue count.
+- Added required/forbidden/minimum/maximum logical-technique profiles and an
+ exact hardest-technique target, accepted only against an independent complete
+ logical path and exact uniqueness check.
- Added X-sum, skyscraper, quadruple and maximum-cell constraints across the
model, validator, exact solver, renderer, setter and f-puzzles interchange.
- Added per-clue false semantics for liar/Wrogn puzzles, including bounded
@@ -25,14 +55,30 @@ All notable changes are documented here.
- Added outside-grid clue margins, combined dual X-sum/skyscraper labels,
outward maximum arrows and visually distinct false clues.
- Added strict, bounded local imports for SudokuPad/CTC data and supported
- Penpa+ long links, including source reporting and explicit incompatibility
- lists instead of silently discarded constructs.
+ Penpa+ long links, with a structured preview of mapped semantics, preserved
+ drawings, metadata and compatibility warnings.
+- Added a bounded inert visual model for f-puzzles and SCL lines, polylines,
+ rectangles, ellipses, circles and text; source identity, scalar metadata and
+ drawings survive edits, explicit saves, autosaves, reopening and project/share
+ round-trips without becoming rules, and render beneath Fog on the board.
+- Added SudokuPad/CTC JSON and self-contained `scl…` export with progress,
+ regions, cages and canonical constraint drawings, while rejecting negated
+ clues or free-form global rules it cannot preserve safely.
- Added a compatibility check which never fetches SudokuPad short IDs or other
server-hosted puzzle references.
- Added standalone SVG, high-resolution PNG and single-page PDF visual exports
- for supported constraints, givens and optional solving progress.
+ for supported constraints, safe preserved drawings, givens and optional
+ solving progress.
- Added named savepoints, isolated hypothesis branches, keep/discard decisions
and read-only replay of complete solving states.
+- Added debounced recovery snapshots for puzzle state, progress, aid-mémoire
+ and a validated, size-bounded gameplay history, with explicit restore/discard
+ handling after an interruption.
+- Upgraded the local Library with title/tag search, tag and completion filters,
+ safe grid thumbnails, tag editing and selected export, duplication or
+ confirmed deletion.
+- Added a relative-scope web app manifest and subpath-aware service worker with
+ an offline application-shell fallback after the first successful load.
- Added an optional configurable aid-mémoire whose labelled scratch cells
support values, both note styles and colours without constraining the puzzle;
its state is preserved in undo, replay, Library progress and project/share
@@ -46,6 +92,13 @@ All notable changes are documented here.
- Added technique-targeted practice generation which mines a bounded,
deterministic set of uniquely checked puzzles and verifies the requested
technique in the logical solve path.
+- Added a persisted 75–200% board viewport, Fit and board-scoped zoom shortcuts,
+ plus an announced Pan mode which pauses cell pointer targeting.
+- Added tap-toggle multi-selection and a single safe-area-aware sticky entry pad
+ at narrow widths, with touch-sized zoom controls.
+- Added eight hue-and-pattern colour marks, configurable off/concise/detailed
+ candidate narration, and fog-safe board, toolbar, helper and guided-hint
+ boundaries.
- Added real ARIA row/gridcell semantics, detailed cell descriptions and
non-wrapping Arrow, Home/End, Control/Command + Home/End and Page Up/Down
keyboard navigation for the puzzle and scratch grids.
diff --git a/README.md b/README.md
index 7e8418c..c047285 100644
--- a/README.md
+++ b/README.md
@@ -9,76 +9,243 @@ release also runs independently from any static HTTPS host or local preview.
- **Play** — keyboard, mouse and touch entry; multi-cell selection; values,
corner/centre notes and colours; undo/redo; named savepoints, hypothesis
- branches and read-only replay; conflict highlighting; timer and local
- progress; optional digit-completion counts, matching-digit highlights and a
- configurable non-constraining aid-mémoire.
-- **Set** — givens, metadata, regions and typed constraints; uniqueness checks
- with overlap-safe cage replacement and selection-based cage removal.
-- **Generate** — seedable, bounded construction for classic and 12 variant
- families; independent uniqueness verification and evidence-based difficulty
- assessment; deterministic mining for a requested logical technique.
+ branches and read-only replay; staged guided hints; candidate maintenance;
+ conflict highlighting; timer and local progress; optional digit-completion
+ counts, matching-digit highlights and a configurable non-constraining
+ aid-mémoire.
+- **Set** — givens, metadata, regions and registry-backed constraints;
+ overlap-safe clue replacement and removal; quick uniqueness checks; and a
+ bounded quality lab for ambiguity, contradictions, redundancy and
+ minimality.
+- **Generate** — seedable single or batch construction for classic and 12
+ variant families, including mixed-family recipes, clue symmetry, constraint
+ density, optional minimal-givens proof and independently checked technique
+ profiles.
- **Solve** — exact solution counting plus an original human-style engine whose
- steps include structured evidence, placements and eliminations.
+ deterministic steps include structured evidence, placements and
+ eliminations. Uniqueness-dependent logic is disabled unless uniqueness has
+ already been proved by a completed exact search.
- **Helpers** — generalized sum combinations with positional candidates and
manual eliminations; selected-cell and house candidate-link analysis; Killer
combinations, 45-rule residuals, and Kropki, sum-pair or inequality pairs.
-Classic grids and common variants share one bounded puzzle model: irregular
-regions, diagonals, Killer cages, thermometers, arrows, Kropki and numbered
-5/10 sum-pair clues, inequalities, renban lines, palindromes, X-sums,
-skyscrapers, quadruples, maximum cells, anti-knight, anti-king and
-non-consecutive rules. Every local clue can also be required to be false for
-liar/Wrogn constructions. Fourteen original bundled examples demonstrate every
-supported constraint and are exact-search verified as unique.
+## Constraint coverage
+
+Classic grids and variants share one validated puzzle model and constraint
+registry. The production engine currently supports:
+
+- classic, irregular and extra regions; diagonals; disjoint groups;
+ anti-knight, anti-king and non-consecutive rules;
+- Killer cages, ordered clone regions, quadruples, maximum/minimum cells and
+ odd/even cells;
+- thermometers, arrows, renban and palindrome lines, between lines, German
+ whispers, region-sum lines, modular lines, entropic lines, zipper lines and
+ double arrows;
+- Kropki dots, numbered 5/10 sum pairs and inequalities;
+- X-sums, skyscrapers, little-killer diagonals and sandwich sums; and
+- row, column and rectangular-box indexers, plus display-only Fog of War.
+
+Each supported semantic constraint is validated and participates in candidate
+filtering and exact solving. Negatable clues can instead be required to be false
+for liar/Wrogn constructions; global house rules, extra regions and fog are not
+given a misleading false mode. Fourteen original bundled examples cover the
+core families and the uniquely checked “Truth and lies” construction. The
+newer constraint packs are covered by focused validation, feasibility and exact
+solver tests rather than bundled third-party puzzles.
+
+Fog is deliberately a presentation rule, not a hidden solver constraint. It
+can be set only when the puzzle carries a complete solution that validates
+against the current givens and constraints. Initial lights, givens and correctly
+entered digits reveal a radius-zero or radius-one Chebyshev neighbourhood;
+wrong entries reveal nothing. Obscured cells are disabled and their values,
+notes, candidates, hints, quality overlays and fully hidden clue graphics are
+omitted. The trusted solution remains part of the local puzzle document.
+
+## Guided solving and candidate maintenance
+
+A guided hint asks the worker for one supported logical step and discloses it in
+four explicit stages: where to look, technique, reasoning and effects preview.
+Later-stage answer content is not placed in the document before it is revealed,
+and the board does not change until **Apply this step** is chosen. Applying a
+step is one undoable transition.
+
+Candidate controls can fill every empty cell with its currently legal centre
+candidates, remove invalid centre/corner notes, and optionally prune peer notes
+after a placement. Erasing a digit never invents candidates. If an
+elimination-only hint is applied without tracked candidates, the app first
+creates a complete legal centre-candidate grid and then applies the explicit
+elimination.
+
+The logical engine covers singles; naked and hidden pairs, triples and quads;
+pointing and claiming; X-Wing, Swordfish and Jellyfish; finned X-Wing and
+finned Swordfish; XY-Wing and XYZ-Wing; Skyscraper; Two-String Kite; Simple
+Colouring; W-Wing; X-Chains, XY-Chains and alternating inference chains; and
+Killer-cage deductions. Unique Rectangle is available only when the caller
+supplies a completed uniqueness proof. The engine never infers uniqueness from
+a puzzle’s appearance, metadata or a truncated search.
+
+## Setter-quality analysis
+
+The quick setter check performs a bounded zero/one/two-solution audit. A pair of
+solutions produces a concrete ambiguity witness with every differing cell and
+value. For a uniquely proved baseline, full analysis uses leave-one-out exact
+searches to classify each given and constraint as critical, redundant or
+unknown and creates a per-cell criticality heatmap. For an unsatisfiable
+baseline it instead uses bounded deletion tests to localise a contradictory
+core. Optional minimality is proved only when the unique baseline and every
+required removal check complete.
+
+Every quality search has configurable per-check node/time limits and shared
+aggregate check/node/time limits. Results retain the checks performed, elapsed
+time and reasons for unknown conclusions. Reaching a safety limit is never
+silently converted into uniqueness, redundancy, contradiction localisation or
+minimality. The worker analysis can be cancelled without discarding the last
+completed result.
+
+## Generator 2.0
+
+Generation supports the existing classic, diagonal, anti-knight, anti-king,
+non-consecutive, Killer, thermo, arrow, Kropki, XV, inequality, renban and
+palindrome families. Compatible families can be combined when they share a
+supported grid size. Local marking density can be sparse, balanced or dense;
+for Killer this changes cage granularity, while inherently global rules retain
+their fixed meaning.
+
+Clue removal supports no symmetry, half-turn rotation, horizontal or vertical
+reflection, either diagonal reflection, and four-way quarter-turn symmetry.
+Minimal-givens mode checks every retained given individually and reports either
+a completed proof or the exact unknown reasons. Individual minimisation may
+break the requested visual symmetry, which is reported rather than hidden.
+
+Technique profiles can require or forbid supported logical techniques, set
+minimum/maximum occurrence counts and require an exact hardest technique. A
+puzzle is accepted only when an independent, complete logical path matches the
+whole profile and an independent exact check proves uniqueness. Deterministic
+batches contain at most 12 candidates and can be ranked by difficulty, fewest
+givens or most givens; failed bounded candidates remain visible as failures and
+are never included in the verified ranking. Generation is cancellable by
+terminating and restarting its worker.
+
+Requested difficulty still controls the clue-removal target rather than
+promising a rating. The displayed 0–100 result is calculated afterwards from
+logical techniques, clue load and reproducible exact-search evidence. Supported
+size/family combinations are deliberately restricted, and a bounded mixed
+construction may fail cleanly for an incompatible or unusually difficult
+recipe.
+
+## Mobile and accessibility
+
+The board viewport offers a locally persisted 75–200% scale in 25% steps,
+increment/decrement controls and a Fit action that returns to 100% and the
+origin. Control/Command + plus, minus or zero are board-scoped equivalents.
+Explicit Pan mode announces its state, cancels any in-progress drag selection
+and disables cell pointer targeting until it is stopped. Tap multi-select, also
+available with M , toggles cells individually without drag selection
+and never leaves the active selection empty.
+
+At widths up to 48rem, Play and Set show exactly one sticky entry pad (outside
+read-only replay) with safe-area padding and touch-sized zoom controls. Eight
+colour marks use distinct patterns as well as hues and expose the hue/pattern
+name on both the Sudoku board and aid-mémoire. Screen-reader candidate detail is
+independently selectable as Off, concise counts or detailed digits without
+changing the visible board.
The board exposes real row/gridcell semantics and concise per-cell state to
-assistive technology, including candidates, notes, colours, conflicts and
-touching variant clues. Arrow keys never wrap; Home/End, Control/Command +
-Home/End and Page Up/Down provide row- and grid-level navigation. The same
-navigation model is available in the aid-mémoire scratch grid.
+assistive technology, including candidates, notes, colours, conflicts,
+touching variant clues, hint previews and quality findings. Arrow keys never
+wrap; Home/End, Control/Command + Home/End and Page Up/Down provide row- and
+grid-level navigation. The same navigation model is available in the
+aid-mémoire scratch grid. Guided-hint and quality status changes use live
+regions.
+
+Fogged cells remain disabled and expose only an obscured status to assistive
+technology. Hidden values, notes, constraint descriptions, candidate/helper
+overlays and hint effects are masked, and toolbar/helper selection is reduced
+to visible cells. A guided hint is shown only if all of its focus and effect
+cells are visible; otherwise the app returns a generic no-visible-hint status.
## Interoperability and visual export
The import dialog recognises compact grids, Sudoku Tools JSON/share fragments,
raw or inline f-puzzles data, SudokuPad/CTC (SCL) data, and Penpa+ self-contained
long links. It never follows a URL or resolves a server-side short ID. Imports
-are size/decompression bounded, report their detected source before applying,
-and stop with a list when a construct cannot be represented faithfully.
+are size/decompression bounded. Before applying one, a structured mapping
+preview separates solver-enforced semantics, preserved drawings, retained
+metadata and warnings.
-SudokuPad compatibility currently preserves cells, givens and progress, notes,
-regions, metadata, solutions, Killer cages and supported global rules. Penpa+
-compatibility is intentionally narrower: square, unrotated Sudoku grids with
-givens/progress, thermometers and arrows. Visual-only lines, overlays, custom
-symbols and other unsupported geometry are reported rather than ignored.
-f-puzzles export likewise refuses false clues it cannot preserve.
+The shared inert visual model supports bounded lines, polylines, rectangles,
+ellipses, circles and text. f-puzzles maps its decorative line, rectangle,
+circle and text fields; SCL maps supported lines, underlays, overlays and
+arrows. Drawings, local source identity and scalar metadata survive domain
+edits, explicit saves, autosaves, reopening, and project/share exports. They
+never become Sudoku rules unless the preview also lists a mapped semantic
+constraint. Raw SVG, HTML, CSS, paths or code, unsafe colour syntax, unknown
+visual fields, excessive data and rotated or otherwise unrepresentable layouts
+are rejected rather than executed or silently changed.
+
+SudokuPad compatibility maps cells, givens and progress, notes, regions,
+metadata, solutions, Killer cages and supported global rules. Export can create
+readable SudokuPad/CTC JSON or a self-contained `scl…` payload with progress,
+regions, cages and canonical drawings for registered constraints. Sudoku Tools
+retains its bounded constraint record for its own exact round-trip; another SCL
+consumer should only be assumed to enforce constructs it represents natively.
+Negated clues and free-form global rules cannot be exported to SCL.
+
+Penpa+ compatibility remains intentionally narrower: square, unrotated Sudoku
+grids with givens/progress, thermometers and arrows. f-puzzles export likewise
+refuses false clues and underlay, free-coordinate or offset drawings it cannot
+preserve faithfully. Preserved source drawings appear on the interactive board
+and in static/SCL exports; Fog covers their obscured in-grid portions.
Export can produce a standalone SVG, a high-resolution PNG, or a single-page
PDF entirely in the browser. Each visual uses the same trusted puzzle model and
-renders region boundaries, supported variant clues and givens; current values,
-notes and cell colours can be included or omitted with the progress switch.
+renders region boundaries, supported variant clues, safe preserved drawings and
+givens; current values, notes and cell colours can be included or omitted with
+the progress switch.
-Generation supports only size/variant combinations which pass the bounded
-construction checks. The requested difficulty controls clue-removal targets;
-the displayed 0–100 result is calculated afterwards from logical techniques,
-clue load and reproducible exact-search evidence. A timeout or node limit is
-reported as unknown and never promoted to a uniqueness or difficulty claim.
-Technique practice performs a bounded, deterministic search and returns a
-puzzle only when its independently analysed solve path actually contains the
-requested technique.
-
-## Privacy and storage
+## Privacy, recovery and Library
Puzzle parsing, solving, generation and export happen entirely in the browser.
There are no analytics, advertisements, accounts or network-backed puzzle
-lookups. Projects and progress are saved only in local IndexedDB after an
-explicit save action and can be cleared from inside the app.
-Exported files and share URLs contain the puzzle data you chose to include.
+lookups. Explicit projects are stored in IndexedDB. A separate, debounced
+autosave slot retains the current puzzle state, progress, aid-mémoire and a
+bounded gameplay history. After an interrupted session, the app offers to
+restore or discard that recovery state without silently replacing an explicit
+project. Its status distinguishes checking, saving, saved, ready and unavailable
+states.
+
+Gameplay history includes undo/redo moments, named savepoints and hypothesis
+branches, and is validated when restored. It is bounded to 500 moments and one
+MiB for its serialized history payload. The Library supports title and tag
+search, tag and completion filters, script-free grid thumbnails, bounded tag
+editing, and selected export, duplication or confirmed deletion. Whole-library
+import and export remain versioned operations.
+
+If IndexedDB is unavailable, a bounded in-memory fallback keeps work for the
+current tab and the interface reports that persistence is unavailable. Browser
+storage can still be removed by the browser or user, so important projects
+should be exported. Exported files and share URLs contain the puzzle data you
+chose to include.
SudokuPad short IDs identify server-hosted records and therefore cannot be
resolved by this deliberately offline application. Embedded f-puzzles payloads
and user-provided Sudoku Tools JSON documents are accepted without contacting
their originating site. Externally authored puzzle definitions are not bundled.
+## Offline application shell
+
+Production builds include a relative-scope web app manifest and register a
+subpath-aware service worker after the page has loaded. It caches the application
+shell and same-origin built assets, uses network-first navigation with a cached
+fallback, serves cached static assets when available and removes obsolete cache
+versions.
+
+The first successful online load is still required to install those resources,
+and service-worker availability depends on browser support and a secure origin.
+Registration is progressive enhancement: the application continues to work
+when a service worker is unavailable, and puzzle operations never depend on a
+network connection.
+
## Development
Requirements: Node.js 22 or newer and npm 11 or newer.
@@ -101,7 +268,7 @@ npm run release:artifact
The command verifies the manifest, types, formatting, tests, production build,
browser workflows and Toolbox contract before creating a deterministic
-`release/sudoku-tools-0.1.0.zip` with a matching SHA-256 sidecar.
+`release/sudoku-tools-0.2.0.zip` with a matching SHA-256 sidecar.
## Scope and references
diff --git a/SOURCE.md b/SOURCE.md
index d2e59d2..78807b7 100644
--- a/SOURCE.md
+++ b/SOURCE.md
@@ -1,8 +1,8 @@
# Corresponding source
-The corresponding source for Sudoku Tools 0.1.0 is available at:
+The corresponding source for Sudoku Tools 0.2.0 is available at:
-https://git.add-ideas.de/lotobo/sudoku-tools/src/tag/v0.1.0
+https://git.add-ideas.de/lotobo/sudoku-tools/src/tag/v0.2.0
The production archive is generated from that tag with the scripts and exact
dependency lock contained in the repository. It contains source identity,
diff --git a/index.html b/index.html
index 66873cf..8514723 100644
--- a/index.html
+++ b/index.html
@@ -8,6 +8,8 @@
content="Set, play, solve and analyse Sudoku puzzles locally in your browser."
/>
+
+
Sudoku Tools
diff --git a/package-lock.json b/package-lock.json
index 84a2934..4a8365a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "sudoku-tools",
- "version": "0.1.0",
+ "version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sudoku-tools",
- "version": "0.1.0",
+ "version": "0.2.0",
"license": "GPL-3.0-or-later",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3",
diff --git a/package.json b/package.json
index 002a5d4..a46db04 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "sudoku-tools",
- "version": "0.1.0",
+ "version": "0.2.0",
"description": "Set, play, solve and analyse Sudoku puzzles locally in the browser.",
"license": "GPL-3.0-or-later",
"author": "Albrecht Degering",
diff --git a/playwright.config.ts b/playwright.config.ts
index 60dbc27..0f4c61e 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -3,7 +3,10 @@ import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/browser",
fullyParallel: false,
- workers: 2,
+ // Run the two browser projects serially. Concurrent service-worker installs
+ // against the shared nested-path test server can make Firefox wait forever
+ // for the load event even though the application itself is healthy.
+ workers: 1,
timeout: 120_000,
expect: { timeout: 10_000 },
reporter: [["list"]],
diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md
index a30e3ee..ab1d3dc 100644
--- a/public/CHANGELOG.md
+++ b/public/CHANGELOG.md
@@ -4,6 +4,30 @@ All notable changes are documented here.
## Unreleased
+## 0.2.0 - 2026-08-31
+
+- Added staged guided hints that reveal focus, technique, reasoning and effects
+ before an explicit, undoable apply action.
+- Added legal centre-candidate filling, invalid note pruning and optional peer
+ note maintenance after placements; erasing a value never invents candidates.
+- Added Jellyfish, finned X-Wing/Swordfish, Skyscraper, Two-String Kite, Simple
+ Colouring, W-Wing, X-Chain, XY-Chain and alternating-inference-chain logical
+ steps with deterministic evidence.
+- Added Unique Rectangle only behind a completed exact uniqueness proof; a
+ truncated search never enables uniqueness-dependent logic.
+- Added quick and full setter-quality audits with ambiguity witnesses,
+ contradiction suspects, per-item critical/redundant/unknown findings, a cell
+ heatmap and optional bounded minimality proof.
+- Added explicit per-check and aggregate quality budgets, cancellation and
+ serializable check evidence so a capped search remains unknown rather than
+ becoming a false proof.
+- Added minimum, odd/even, disjoint-groups, little-killer and sandwich rules
+ across validation, candidate filtering, exact solving, rendering and setting.
+- Added between, German-whisper, region-sum, modular, entropic and zipper lines;
+ clone and extra regions; double arrows; and row, column and box indexers.
+- Added display-only Fog of War backed by a complete locally stored solution.
+ Only initial lights, givens and correctly entered values reveal cells; fog is
+ never treated as an extra solution constraint.
- Added optional digit-completion counts with muted complete digits and red
over-completion warnings.
- Added toggleable Ctrl/Command-click matching-digit highlights without turning
@@ -12,10 +36,16 @@ All notable changes are documented here.
remain accidentally active.
- Replaced ambiguous V/X pair labels on the board with numbered 5/10 badges and
gave inequalities a directional chevron with a marked lesser-value tip.
-- Added thirteen uniquely checked built-in examples covering every supported
- constraint family.
-- Added bounded, seedable generation for classic and twelve variant families,
- plus independent uniqueness and evidence-based difficulty assessment.
+- Added fourteen uniquely checked built-in examples covering the original core
+ constraint families.
+- Expanded bounded, seedable generation for classic and twelve variant families
+ with mixed-family recipes, sparse/balanced/dense clue controls and additional
+ reflection, diagonal and four-way clue symmetries.
+- Added explicit minimal-givens mode with proof/unknown reporting, deterministic
+ batches of up to 12 candidates and rankings by difficulty or clue count.
+- Added required/forbidden/minimum/maximum logical-technique profiles and an
+ exact hardest-technique target, accepted only against an independent complete
+ logical path and exact uniqueness check.
- Added X-sum, skyscraper, quadruple and maximum-cell constraints across the
model, validator, exact solver, renderer, setter and f-puzzles interchange.
- Added per-clue false semantics for liar/Wrogn puzzles, including bounded
@@ -25,14 +55,30 @@ All notable changes are documented here.
- Added outside-grid clue margins, combined dual X-sum/skyscraper labels,
outward maximum arrows and visually distinct false clues.
- Added strict, bounded local imports for SudokuPad/CTC data and supported
- Penpa+ long links, including source reporting and explicit incompatibility
- lists instead of silently discarded constructs.
+ Penpa+ long links, with a structured preview of mapped semantics, preserved
+ drawings, metadata and compatibility warnings.
+- Added a bounded inert visual model for f-puzzles and SCL lines, polylines,
+ rectangles, ellipses, circles and text; source identity, scalar metadata and
+ drawings survive edits, explicit saves, autosaves, reopening and project/share
+ round-trips without becoming rules, and render beneath Fog on the board.
+- Added SudokuPad/CTC JSON and self-contained `scl…` export with progress,
+ regions, cages and canonical constraint drawings, while rejecting negated
+ clues or free-form global rules it cannot preserve safely.
- Added a compatibility check which never fetches SudokuPad short IDs or other
server-hosted puzzle references.
- Added standalone SVG, high-resolution PNG and single-page PDF visual exports
- for supported constraints, givens and optional solving progress.
+ for supported constraints, safe preserved drawings, givens and optional
+ solving progress.
- Added named savepoints, isolated hypothesis branches, keep/discard decisions
and read-only replay of complete solving states.
+- Added debounced recovery snapshots for puzzle state, progress, aid-mémoire
+ and a validated, size-bounded gameplay history, with explicit restore/discard
+ handling after an interruption.
+- Upgraded the local Library with title/tag search, tag and completion filters,
+ safe grid thumbnails, tag editing and selected export, duplication or
+ confirmed deletion.
+- Added a relative-scope web app manifest and subpath-aware service worker with
+ an offline application-shell fallback after the first successful load.
- Added an optional configurable aid-mémoire whose labelled scratch cells
support values, both note styles and colours without constraining the puzzle;
its state is preserved in undo, replay, Library progress and project/share
@@ -46,6 +92,13 @@ All notable changes are documented here.
- Added technique-targeted practice generation which mines a bounded,
deterministic set of uniquely checked puzzles and verifies the requested
technique in the logical solve path.
+- Added a persisted 75–200% board viewport, Fit and board-scoped zoom shortcuts,
+ plus an announced Pan mode which pauses cell pointer targeting.
+- Added tap-toggle multi-selection and a single safe-area-aware sticky entry pad
+ at narrow widths, with touch-sized zoom controls.
+- Added eight hue-and-pattern colour marks, configurable off/concise/detailed
+ candidate narration, and fog-safe board, toolbar, helper and guided-hint
+ boundaries.
- Added real ARIA row/gridcell semantics, detailed cell descriptions and
non-wrapping Arrow, Home/End, Control/Command + Home/End and Page Up/Down
keyboard navigation for the puzzle and scratch grids.
diff --git a/public/README.md b/public/README.md
index 7e8418c..c047285 100644
--- a/public/README.md
+++ b/public/README.md
@@ -9,76 +9,243 @@ release also runs independently from any static HTTPS host or local preview.
- **Play** — keyboard, mouse and touch entry; multi-cell selection; values,
corner/centre notes and colours; undo/redo; named savepoints, hypothesis
- branches and read-only replay; conflict highlighting; timer and local
- progress; optional digit-completion counts, matching-digit highlights and a
- configurable non-constraining aid-mémoire.
-- **Set** — givens, metadata, regions and typed constraints; uniqueness checks
- with overlap-safe cage replacement and selection-based cage removal.
-- **Generate** — seedable, bounded construction for classic and 12 variant
- families; independent uniqueness verification and evidence-based difficulty
- assessment; deterministic mining for a requested logical technique.
+ branches and read-only replay; staged guided hints; candidate maintenance;
+ conflict highlighting; timer and local progress; optional digit-completion
+ counts, matching-digit highlights and a configurable non-constraining
+ aid-mémoire.
+- **Set** — givens, metadata, regions and registry-backed constraints;
+ overlap-safe clue replacement and removal; quick uniqueness checks; and a
+ bounded quality lab for ambiguity, contradictions, redundancy and
+ minimality.
+- **Generate** — seedable single or batch construction for classic and 12
+ variant families, including mixed-family recipes, clue symmetry, constraint
+ density, optional minimal-givens proof and independently checked technique
+ profiles.
- **Solve** — exact solution counting plus an original human-style engine whose
- steps include structured evidence, placements and eliminations.
+ deterministic steps include structured evidence, placements and
+ eliminations. Uniqueness-dependent logic is disabled unless uniqueness has
+ already been proved by a completed exact search.
- **Helpers** — generalized sum combinations with positional candidates and
manual eliminations; selected-cell and house candidate-link analysis; Killer
combinations, 45-rule residuals, and Kropki, sum-pair or inequality pairs.
-Classic grids and common variants share one bounded puzzle model: irregular
-regions, diagonals, Killer cages, thermometers, arrows, Kropki and numbered
-5/10 sum-pair clues, inequalities, renban lines, palindromes, X-sums,
-skyscrapers, quadruples, maximum cells, anti-knight, anti-king and
-non-consecutive rules. Every local clue can also be required to be false for
-liar/Wrogn constructions. Fourteen original bundled examples demonstrate every
-supported constraint and are exact-search verified as unique.
+## Constraint coverage
+
+Classic grids and variants share one validated puzzle model and constraint
+registry. The production engine currently supports:
+
+- classic, irregular and extra regions; diagonals; disjoint groups;
+ anti-knight, anti-king and non-consecutive rules;
+- Killer cages, ordered clone regions, quadruples, maximum/minimum cells and
+ odd/even cells;
+- thermometers, arrows, renban and palindrome lines, between lines, German
+ whispers, region-sum lines, modular lines, entropic lines, zipper lines and
+ double arrows;
+- Kropki dots, numbered 5/10 sum pairs and inequalities;
+- X-sums, skyscrapers, little-killer diagonals and sandwich sums; and
+- row, column and rectangular-box indexers, plus display-only Fog of War.
+
+Each supported semantic constraint is validated and participates in candidate
+filtering and exact solving. Negatable clues can instead be required to be false
+for liar/Wrogn constructions; global house rules, extra regions and fog are not
+given a misleading false mode. Fourteen original bundled examples cover the
+core families and the uniquely checked “Truth and lies” construction. The
+newer constraint packs are covered by focused validation, feasibility and exact
+solver tests rather than bundled third-party puzzles.
+
+Fog is deliberately a presentation rule, not a hidden solver constraint. It
+can be set only when the puzzle carries a complete solution that validates
+against the current givens and constraints. Initial lights, givens and correctly
+entered digits reveal a radius-zero or radius-one Chebyshev neighbourhood;
+wrong entries reveal nothing. Obscured cells are disabled and their values,
+notes, candidates, hints, quality overlays and fully hidden clue graphics are
+omitted. The trusted solution remains part of the local puzzle document.
+
+## Guided solving and candidate maintenance
+
+A guided hint asks the worker for one supported logical step and discloses it in
+four explicit stages: where to look, technique, reasoning and effects preview.
+Later-stage answer content is not placed in the document before it is revealed,
+and the board does not change until **Apply this step** is chosen. Applying a
+step is one undoable transition.
+
+Candidate controls can fill every empty cell with its currently legal centre
+candidates, remove invalid centre/corner notes, and optionally prune peer notes
+after a placement. Erasing a digit never invents candidates. If an
+elimination-only hint is applied without tracked candidates, the app first
+creates a complete legal centre-candidate grid and then applies the explicit
+elimination.
+
+The logical engine covers singles; naked and hidden pairs, triples and quads;
+pointing and claiming; X-Wing, Swordfish and Jellyfish; finned X-Wing and
+finned Swordfish; XY-Wing and XYZ-Wing; Skyscraper; Two-String Kite; Simple
+Colouring; W-Wing; X-Chains, XY-Chains and alternating inference chains; and
+Killer-cage deductions. Unique Rectangle is available only when the caller
+supplies a completed uniqueness proof. The engine never infers uniqueness from
+a puzzle’s appearance, metadata or a truncated search.
+
+## Setter-quality analysis
+
+The quick setter check performs a bounded zero/one/two-solution audit. A pair of
+solutions produces a concrete ambiguity witness with every differing cell and
+value. For a uniquely proved baseline, full analysis uses leave-one-out exact
+searches to classify each given and constraint as critical, redundant or
+unknown and creates a per-cell criticality heatmap. For an unsatisfiable
+baseline it instead uses bounded deletion tests to localise a contradictory
+core. Optional minimality is proved only when the unique baseline and every
+required removal check complete.
+
+Every quality search has configurable per-check node/time limits and shared
+aggregate check/node/time limits. Results retain the checks performed, elapsed
+time and reasons for unknown conclusions. Reaching a safety limit is never
+silently converted into uniqueness, redundancy, contradiction localisation or
+minimality. The worker analysis can be cancelled without discarding the last
+completed result.
+
+## Generator 2.0
+
+Generation supports the existing classic, diagonal, anti-knight, anti-king,
+non-consecutive, Killer, thermo, arrow, Kropki, XV, inequality, renban and
+palindrome families. Compatible families can be combined when they share a
+supported grid size. Local marking density can be sparse, balanced or dense;
+for Killer this changes cage granularity, while inherently global rules retain
+their fixed meaning.
+
+Clue removal supports no symmetry, half-turn rotation, horizontal or vertical
+reflection, either diagonal reflection, and four-way quarter-turn symmetry.
+Minimal-givens mode checks every retained given individually and reports either
+a completed proof or the exact unknown reasons. Individual minimisation may
+break the requested visual symmetry, which is reported rather than hidden.
+
+Technique profiles can require or forbid supported logical techniques, set
+minimum/maximum occurrence counts and require an exact hardest technique. A
+puzzle is accepted only when an independent, complete logical path matches the
+whole profile and an independent exact check proves uniqueness. Deterministic
+batches contain at most 12 candidates and can be ranked by difficulty, fewest
+givens or most givens; failed bounded candidates remain visible as failures and
+are never included in the verified ranking. Generation is cancellable by
+terminating and restarting its worker.
+
+Requested difficulty still controls the clue-removal target rather than
+promising a rating. The displayed 0–100 result is calculated afterwards from
+logical techniques, clue load and reproducible exact-search evidence. Supported
+size/family combinations are deliberately restricted, and a bounded mixed
+construction may fail cleanly for an incompatible or unusually difficult
+recipe.
+
+## Mobile and accessibility
+
+The board viewport offers a locally persisted 75–200% scale in 25% steps,
+increment/decrement controls and a Fit action that returns to 100% and the
+origin. Control/Command + plus, minus or zero are board-scoped equivalents.
+Explicit Pan mode announces its state, cancels any in-progress drag selection
+and disables cell pointer targeting until it is stopped. Tap multi-select, also
+available with M , toggles cells individually without drag selection
+and never leaves the active selection empty.
+
+At widths up to 48rem, Play and Set show exactly one sticky entry pad (outside
+read-only replay) with safe-area padding and touch-sized zoom controls. Eight
+colour marks use distinct patterns as well as hues and expose the hue/pattern
+name on both the Sudoku board and aid-mémoire. Screen-reader candidate detail is
+independently selectable as Off, concise counts or detailed digits without
+changing the visible board.
The board exposes real row/gridcell semantics and concise per-cell state to
-assistive technology, including candidates, notes, colours, conflicts and
-touching variant clues. Arrow keys never wrap; Home/End, Control/Command +
-Home/End and Page Up/Down provide row- and grid-level navigation. The same
-navigation model is available in the aid-mémoire scratch grid.
+assistive technology, including candidates, notes, colours, conflicts,
+touching variant clues, hint previews and quality findings. Arrow keys never
+wrap; Home/End, Control/Command + Home/End and Page Up/Down provide row- and
+grid-level navigation. The same navigation model is available in the
+aid-mémoire scratch grid. Guided-hint and quality status changes use live
+regions.
+
+Fogged cells remain disabled and expose only an obscured status to assistive
+technology. Hidden values, notes, constraint descriptions, candidate/helper
+overlays and hint effects are masked, and toolbar/helper selection is reduced
+to visible cells. A guided hint is shown only if all of its focus and effect
+cells are visible; otherwise the app returns a generic no-visible-hint status.
## Interoperability and visual export
The import dialog recognises compact grids, Sudoku Tools JSON/share fragments,
raw or inline f-puzzles data, SudokuPad/CTC (SCL) data, and Penpa+ self-contained
long links. It never follows a URL or resolves a server-side short ID. Imports
-are size/decompression bounded, report their detected source before applying,
-and stop with a list when a construct cannot be represented faithfully.
+are size/decompression bounded. Before applying one, a structured mapping
+preview separates solver-enforced semantics, preserved drawings, retained
+metadata and warnings.
-SudokuPad compatibility currently preserves cells, givens and progress, notes,
-regions, metadata, solutions, Killer cages and supported global rules. Penpa+
-compatibility is intentionally narrower: square, unrotated Sudoku grids with
-givens/progress, thermometers and arrows. Visual-only lines, overlays, custom
-symbols and other unsupported geometry are reported rather than ignored.
-f-puzzles export likewise refuses false clues it cannot preserve.
+The shared inert visual model supports bounded lines, polylines, rectangles,
+ellipses, circles and text. f-puzzles maps its decorative line, rectangle,
+circle and text fields; SCL maps supported lines, underlays, overlays and
+arrows. Drawings, local source identity and scalar metadata survive domain
+edits, explicit saves, autosaves, reopening, and project/share exports. They
+never become Sudoku rules unless the preview also lists a mapped semantic
+constraint. Raw SVG, HTML, CSS, paths or code, unsafe colour syntax, unknown
+visual fields, excessive data and rotated or otherwise unrepresentable layouts
+are rejected rather than executed or silently changed.
+
+SudokuPad compatibility maps cells, givens and progress, notes, regions,
+metadata, solutions, Killer cages and supported global rules. Export can create
+readable SudokuPad/CTC JSON or a self-contained `scl…` payload with progress,
+regions, cages and canonical drawings for registered constraints. Sudoku Tools
+retains its bounded constraint record for its own exact round-trip; another SCL
+consumer should only be assumed to enforce constructs it represents natively.
+Negated clues and free-form global rules cannot be exported to SCL.
+
+Penpa+ compatibility remains intentionally narrower: square, unrotated Sudoku
+grids with givens/progress, thermometers and arrows. f-puzzles export likewise
+refuses false clues and underlay, free-coordinate or offset drawings it cannot
+preserve faithfully. Preserved source drawings appear on the interactive board
+and in static/SCL exports; Fog covers their obscured in-grid portions.
Export can produce a standalone SVG, a high-resolution PNG, or a single-page
PDF entirely in the browser. Each visual uses the same trusted puzzle model and
-renders region boundaries, supported variant clues and givens; current values,
-notes and cell colours can be included or omitted with the progress switch.
+renders region boundaries, supported variant clues, safe preserved drawings and
+givens; current values, notes and cell colours can be included or omitted with
+the progress switch.
-Generation supports only size/variant combinations which pass the bounded
-construction checks. The requested difficulty controls clue-removal targets;
-the displayed 0–100 result is calculated afterwards from logical techniques,
-clue load and reproducible exact-search evidence. A timeout or node limit is
-reported as unknown and never promoted to a uniqueness or difficulty claim.
-Technique practice performs a bounded, deterministic search and returns a
-puzzle only when its independently analysed solve path actually contains the
-requested technique.
-
-## Privacy and storage
+## Privacy, recovery and Library
Puzzle parsing, solving, generation and export happen entirely in the browser.
There are no analytics, advertisements, accounts or network-backed puzzle
-lookups. Projects and progress are saved only in local IndexedDB after an
-explicit save action and can be cleared from inside the app.
-Exported files and share URLs contain the puzzle data you chose to include.
+lookups. Explicit projects are stored in IndexedDB. A separate, debounced
+autosave slot retains the current puzzle state, progress, aid-mémoire and a
+bounded gameplay history. After an interrupted session, the app offers to
+restore or discard that recovery state without silently replacing an explicit
+project. Its status distinguishes checking, saving, saved, ready and unavailable
+states.
+
+Gameplay history includes undo/redo moments, named savepoints and hypothesis
+branches, and is validated when restored. It is bounded to 500 moments and one
+MiB for its serialized history payload. The Library supports title and tag
+search, tag and completion filters, script-free grid thumbnails, bounded tag
+editing, and selected export, duplication or confirmed deletion. Whole-library
+import and export remain versioned operations.
+
+If IndexedDB is unavailable, a bounded in-memory fallback keeps work for the
+current tab and the interface reports that persistence is unavailable. Browser
+storage can still be removed by the browser or user, so important projects
+should be exported. Exported files and share URLs contain the puzzle data you
+chose to include.
SudokuPad short IDs identify server-hosted records and therefore cannot be
resolved by this deliberately offline application. Embedded f-puzzles payloads
and user-provided Sudoku Tools JSON documents are accepted without contacting
their originating site. Externally authored puzzle definitions are not bundled.
+## Offline application shell
+
+Production builds include a relative-scope web app manifest and register a
+subpath-aware service worker after the page has loaded. It caches the application
+shell and same-origin built assets, uses network-first navigation with a cached
+fallback, serves cached static assets when available and removes obsolete cache
+versions.
+
+The first successful online load is still required to install those resources,
+and service-worker availability depends on browser support and a secure origin.
+Registration is progressive enhancement: the application continues to work
+when a service worker is unavailable, and puzzle operations never depend on a
+network connection.
+
## Development
Requirements: Node.js 22 or newer and npm 11 or newer.
@@ -101,7 +268,7 @@ npm run release:artifact
The command verifies the manifest, types, formatting, tests, production build,
browser workflows and Toolbox contract before creating a deterministic
-`release/sudoku-tools-0.1.0.zip` with a matching SHA-256 sidecar.
+`release/sudoku-tools-0.2.0.zip` with a matching SHA-256 sidecar.
## Scope and references
diff --git a/public/SOURCE.md b/public/SOURCE.md
index d2e59d2..78807b7 100644
--- a/public/SOURCE.md
+++ b/public/SOURCE.md
@@ -1,8 +1,8 @@
# Corresponding source
-The corresponding source for Sudoku Tools 0.1.0 is available at:
+The corresponding source for Sudoku Tools 0.2.0 is available at:
-https://git.add-ideas.de/lotobo/sudoku-tools/src/tag/v0.1.0
+https://git.add-ideas.de/lotobo/sudoku-tools/src/tag/v0.2.0
The production archive is generated from that tag with the scripts and exact
dependency lock contained in the repository. It contains source identity,
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
new file mode 100644
index 0000000..164865d
--- /dev/null
+++ b/public/manifest.webmanifest
@@ -0,0 +1,19 @@
+{
+ "name": "Sudoku Tools",
+ "short_name": "Sudoku",
+ "description": "Set, play, solve and analyse Sudoku puzzles locally.",
+ "id": "./",
+ "start_url": "./",
+ "scope": "./",
+ "display": "standalone",
+ "background_color": "#f4f0e8",
+ "theme_color": "#272421",
+ "icons": [
+ {
+ "src": "./favicon.svg",
+ "sizes": "any",
+ "type": "image/svg+xml",
+ "purpose": "any"
+ }
+ ]
+}
diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 0000000..edc01c1
--- /dev/null
+++ b/public/sw.js
@@ -0,0 +1,119 @@
+/* Sudoku Tools offline worker. This file intentionally has no dependencies. */
+const CACHE_PREFIX = "sudoku-tools-shell-";
+const CACHE_VERSION = "v1";
+const CACHE_NAME = `${CACHE_PREFIX}${CACHE_VERSION}`;
+const CORE_ASSETS = ["./", "./manifest.webmanifest", "./favicon.svg"];
+const BUILD_ASSET_PATTERN =
+ /(?:(?:\.\/)?assets\/)?[A-Za-z0-9][A-Za-z0-9._-]*-[A-Za-z0-9_-]{6,}\.(?:css|js|json|png|svg|wasm)/gu;
+
+async function cacheAssetTree(cache, assetUrl, discovered, depth = 0) {
+ const url = new URL(assetUrl, self.registration.scope).href;
+ if (discovered.has(url)) return;
+ discovered.add(url);
+ const response = await fetch(url, { cache: "no-cache" });
+ if (!response.ok) return;
+ await cache.put(url, response.clone());
+ const contentType = response.headers.get("content-type") ?? "";
+ if (depth >= 3 || !/(?:javascript|text\/css)/iu.test(contentType)) return;
+ const source = await response.text();
+ await Promise.all(
+ [...source.matchAll(BUILD_ASSET_PATTERN)].map((match) => {
+ const reference = match[0];
+ const base = reference.includes("assets/")
+ ? self.registration.scope
+ : url;
+ return cacheAssetTree(
+ cache,
+ new URL(reference, base).href,
+ discovered,
+ depth + 1,
+ );
+ }),
+ );
+}
+
+async function cacheDocumentAssets(cache) {
+ const response = await fetch("./", { cache: "no-cache" });
+ if (!response.ok) throw new Error("Could not cache the application shell.");
+ await cache.put("./", response.clone());
+ const html = await response.text();
+ const urls = new Set(CORE_ASSETS.slice(1));
+ for (const match of html.matchAll(/(?:src|href)=["']([^"'#]+)["']/giu)) {
+ const raw = match[1];
+ if (raw === undefined) continue;
+ const resolved = new URL(raw, self.registration.scope);
+ if (resolved.origin === self.location.origin) urls.add(resolved.href);
+ }
+ const canonicalUrls = new Set([new URL("./", self.registration.scope).href]);
+ await Promise.all(
+ [...urls].map((url) => cacheAssetTree(cache, url, canonicalUrls)),
+ );
+ await Promise.all(
+ (await cache.keys())
+ .filter((request) => !canonicalUrls.has(request.url))
+ .map((request) => cache.delete(request)),
+ );
+}
+
+self.addEventListener("install", (event) => {
+ event.waitUntil(
+ caches
+ .open(CACHE_NAME)
+ .then(cacheDocumentAssets)
+ .then(() => self.skipWaiting()),
+ );
+});
+
+self.addEventListener("activate", (event) => {
+ event.waitUntil(
+ caches
+ .keys()
+ .then((keys) =>
+ Promise.all(
+ keys
+ .filter((key) => key.startsWith(CACHE_PREFIX) && key !== CACHE_NAME)
+ .map((key) => caches.delete(key)),
+ ),
+ )
+ .then(() => self.clients.claim()),
+ );
+});
+
+self.addEventListener("fetch", (event) => {
+ const request = event.request;
+ if (request.method !== "GET") return;
+ const url = new URL(request.url);
+ if (url.origin !== self.location.origin) return;
+
+ if (request.mode === "navigate") {
+ event.respondWith(
+ fetch(request)
+ .then(async (response) => {
+ if (response.ok) {
+ const cache = await caches.open(CACHE_NAME);
+ await cache.put("./", response.clone());
+ }
+ return response;
+ })
+ .catch(async () => {
+ const cached = await caches.match("./");
+ return cached ?? Response.error();
+ }),
+ );
+ return;
+ }
+
+ event.respondWith(
+ caches.match(request).then(
+ (cached) =>
+ cached ??
+ fetch(request).then(async (response) => {
+ if (response.ok && response.type === "basic") {
+ const cache = await caches.open(CACHE_NAME);
+ await cache.put(request, response.clone());
+ }
+ return response;
+ }),
+ ),
+ );
+});
diff --git a/public/toolbox-app.json b/public/toolbox-app.json
index 189b401..754ea75 100644
--- a/public/toolbox-app.json
+++ b/public/toolbox-app.json
@@ -3,7 +3,7 @@
"schemaVersion": 1,
"id": "de.add-ideas.sudoku-tools",
"name": "Sudoku Tools",
- "version": "0.1.0",
+ "version": "0.2.0",
"description": "Set, play, solve and analyse Sudoku puzzles locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
diff --git a/scripts/package-release.mjs b/scripts/package-release.mjs
index 9d4aae2..e6d8460 100644
--- a/scripts/package-release.mjs
+++ b/scripts/package-release.mjs
@@ -50,6 +50,8 @@ if (!force && ((await exists(output)) || (await exists(checksumOutput))))
const input = path.join(root, "dist");
for (const name of [
"index.html",
+ "manifest.webmanifest",
+ "sw.js",
"toolbox-app.json",
"favicon.svg",
"README.md",
diff --git a/scripts/serve-test.mjs b/scripts/serve-test.mjs
index 185b9fa..5aa5743 100644
--- a/scripts/serve-test.mjs
+++ b/scripts/serve-test.mjs
@@ -14,6 +14,7 @@ const mediaTypes = new Map([
[".html", "text/html; charset=utf-8"],
[".js", "text/javascript; charset=utf-8"],
[".json", "application/json; charset=utf-8"],
+ [".webmanifest", "application/manifest+json; charset=utf-8"],
[".svg", "image/svg+xml"],
[".md", "text/markdown; charset=utf-8"],
[".txt", "text/plain; charset=utf-8"],
diff --git a/src/components/BoardViewport.tsx b/src/components/BoardViewport.tsx
new file mode 100644
index 0000000..a5834bd
--- /dev/null
+++ b/src/components/BoardViewport.tsx
@@ -0,0 +1,183 @@
+import {
+ useEffect,
+ useRef,
+ useState,
+ type KeyboardEvent,
+ type PointerEvent,
+ type ReactNode,
+} from "react";
+import {
+ BOARD_SCALE_MAX,
+ BOARD_SCALE_MIN,
+ BOARD_SCALE_STEP,
+ normalizeBoardScale,
+ readStoredBoardScale,
+ writeStoredBoardScale,
+} from "../state/uiPreferences";
+
+interface PanOrigin {
+ readonly pointerId: number;
+ readonly clientX: number;
+ readonly clientY: number;
+ readonly scrollLeft: number;
+ readonly scrollTop: number;
+}
+
+export function BoardViewport({
+ children,
+ onPanModeChange,
+}: {
+ readonly children: ReactNode;
+ readonly onPanModeChange?: (enabled: boolean) => void;
+}) {
+ const [scale, setScale] = useState(readStoredBoardScale);
+ const [panMode, setPanMode] = useState(false);
+ const scrollerRef = useRef(null);
+ const panOriginRef = useRef(undefined);
+
+ useEffect(() => writeStoredBoardScale(scale), [scale]);
+
+ const updateScale = (value: number) => setScale(normalizeBoardScale(value));
+ const fitBoard = () => {
+ updateScale(1);
+ if (scrollerRef.current !== null) {
+ scrollerRef.current.scrollLeft = 0;
+ scrollerRef.current.scrollTop = 0;
+ }
+ };
+ const onKeyDownCapture = (event: KeyboardEvent) => {
+ if (!(event.ctrlKey || event.metaKey)) return;
+ if (["+", "="].includes(event.key)) {
+ event.preventDefault();
+ event.stopPropagation();
+ updateScale(scale + BOARD_SCALE_STEP);
+ } else if (event.key === "-") {
+ event.preventDefault();
+ event.stopPropagation();
+ updateScale(scale - BOARD_SCALE_STEP);
+ } else if (event.key === "0") {
+ event.preventDefault();
+ event.stopPropagation();
+ fitBoard();
+ }
+ };
+ const beginPan = (event: PointerEvent) => {
+ if (!panMode || (event.pointerType === "mouse" && event.button !== 0))
+ return;
+ const scroller = scrollerRef.current;
+ if (scroller === null) return;
+ event.preventDefault();
+ scroller.setPointerCapture?.(event.pointerId);
+ panOriginRef.current = {
+ pointerId: event.pointerId,
+ clientX: event.clientX,
+ clientY: event.clientY,
+ scrollLeft: scroller.scrollLeft,
+ scrollTop: scroller.scrollTop,
+ };
+ };
+ const continuePan = (event: PointerEvent) => {
+ const origin = panOriginRef.current;
+ const scroller = scrollerRef.current;
+ if (
+ !panMode ||
+ origin === undefined ||
+ origin.pointerId !== event.pointerId ||
+ scroller === null
+ ) {
+ return;
+ }
+ event.preventDefault();
+ scroller.scrollLeft = origin.scrollLeft - (event.clientX - origin.clientX);
+ scroller.scrollTop = origin.scrollTop - (event.clientY - origin.clientY);
+ };
+ const endPan = (event: PointerEvent) => {
+ if (panOriginRef.current?.pointerId !== event.pointerId) return;
+ scrollerRef.current?.releasePointerCapture?.(event.pointerId);
+ panOriginRef.current = undefined;
+ };
+ const percentage = Math.round(scale * 100);
+
+ return (
+
+
+
+ updateScale(scale - BOARD_SCALE_STEP)}
+ >
+ −
+
+
+ {percentage}%
+
+ = BOARD_SCALE_MAX}
+ onClick={() => updateScale(scale + BOARD_SCALE_STEP)}
+ >
+ +
+
+
+ Fit board
+
+
+
+ setPanMode((current) => {
+ const enabled = !current;
+ panOriginRef.current = undefined;
+ onPanModeChange?.(enabled);
+ return enabled;
+ })
+ }
+ >
+ {panMode ? "Stop panning" : "Pan board"}
+
+
+ {panMode && (
+
+ Pan mode: drag the board to move it. Cell taps are paused.
+
+ )}
+
+
+ );
+}
diff --git a/src/components/ConstraintEditor.tsx b/src/components/ConstraintEditor.tsx
index 6012a44..49e1fab 100644
--- a/src/components/ConstraintEditor.tsx
+++ b/src/components/ConstraintEditor.tsx
@@ -1,6 +1,19 @@
-import { useState } from "react";
-import { cellsFormQuadruple } from "../domain/geometry";
-import type { PuzzleDefinition, VariantConstraint } from "../domain/types";
+import { useId, useState } from "react";
+import {
+ cellsFormQuadruple,
+ classicRegions,
+ constraintLabel,
+ constraintMetadata,
+ littleKillerCells,
+ littleKillerDirectionEntersGrid,
+ validatePuzzle,
+} from "../domain";
+import type {
+ LittleKillerDirection,
+ OutsideClueSide,
+ PuzzleDefinition,
+ VariantConstraint,
+} from "../domain/types";
import {
removeKillerCagesAtCells,
replaceOverlappingKillerCages,
@@ -17,6 +30,65 @@ interface ConstraintEditorProps {
busy: boolean;
}
+type OutsideConstraintType =
+ "x-sum" | "skyscraper" | "little-killer" | "sandwich";
+
+const LITTLE_KILLER_DIRECTIONS = [
+ ["down-right", "Down right ↘"],
+ ["down-left", "Down left ↙"],
+ ["up-right", "Up right ↗"],
+ ["up-left", "Up left ↖"],
+] as const satisfies readonly (readonly [LittleKillerDirection, string])[];
+
+function firstLittleKillerDirection(side: OutsideClueSide) {
+ return (
+ LITTLE_KILLER_DIRECTIONS.find(([direction]) =>
+ littleKillerDirectionEntersGrid(side, direction),
+ )?.[0] ?? "down-right"
+ );
+}
+
+function reachableSandwichSums(size: number): ReadonlySet {
+ let totals = new Set([0]);
+ for (let digit = 2; digit < size; digit += 1) {
+ totals = new Set([...totals, ...[...totals].map((total) => total + digit)]);
+ }
+ return totals;
+}
+
+function sameOrderedCells(
+ first: readonly number[],
+ second: readonly number[],
+): boolean {
+ return (
+ first.length === second.length &&
+ first.every((cell, index) => cell === second[index])
+ );
+}
+
+function hasClassicRegionPartition(puzzle: PuzzleDefinition): boolean {
+ const expected = classicRegions(puzzle.size);
+ const actual = puzzle.regions ?? expected;
+ if (actual.length !== expected.length) return false;
+ const expectedToActual = new Map();
+ const actualToExpected = new Map();
+ return expected.every((expectedRegion, cell) => {
+ const actualRegion = actual[cell];
+ if (actualRegion === undefined) return false;
+ const mappedActual = expectedToActual.get(expectedRegion);
+ const mappedExpected = actualToExpected.get(actualRegion);
+ if (
+ (mappedActual !== undefined && mappedActual !== actualRegion) ||
+ (mappedExpected !== undefined && mappedExpected !== expectedRegion)
+ ) {
+ return false;
+ }
+ expectedToActual.set(expectedRegion, actualRegion);
+ actualToExpected.set(actualRegion, expectedRegion);
+ return true;
+ });
+}
+
function describeConstraint(constraint: VariantConstraint, size: number) {
const cell = (index: number) =>
`r${String(Math.floor(index / size) + 1)}c${String((index % size) + 1)}`;
@@ -33,6 +105,8 @@ function describeConstraint(constraint: VariantConstraint, size: number) {
return "anti-king";
case "non-consecutive":
return "non-consecutive";
+ case "disjoint-groups":
+ return "disjoint groups · matching box positions do not repeat";
case "killer-cage":
return marked(
`${String(constraint.sum)} cage · ${String(constraint.cells.length)} cells${constraint.noRepeat === false ? " · repeats allowed" : ""}`,
@@ -71,13 +145,63 @@ function describeConstraint(constraint: VariantConstraint, size: number) {
);
case "maximum":
return marked(`maximum · ${cell(constraint.cell)}`);
+ case "minimum":
+ return marked(`minimum · ${cell(constraint.cell)}`);
+ case "odd":
+ return marked(`odd circle · ${cell(constraint.cell)}`);
+ case "even":
+ return marked(`even square · ${cell(constraint.cell)}`);
+ case "little-killer":
+ return marked(
+ `little killer ${String(constraint.sum)} · ${constraint.side} ${String(constraint.index + 1)} · ${constraint.direction.replace("-", " ")}`,
+ );
+ case "sandwich":
+ return marked(
+ `sandwich ${String(constraint.sum)} · ${constraint.side} ${String(constraint.index + 1)}`,
+ );
+ case "between-line":
+ return marked(
+ `between line · ${String(constraint.cells.length)} ordered cells`,
+ );
+ case "german-whisper":
+ return marked(
+ `German whisper ≥ ${String(constraint.minimumDifference ?? Math.ceil(size / 2))} · ${String(constraint.cells.length)} ordered cells`,
+ );
+ case "region-sum-line":
+ return marked(
+ `region-sum line · ${String(constraint.cells.length)} ordered cells`,
+ );
+ case "clone":
+ return marked(
+ `clone regions · ${String(constraint.cells.length)} + ${String(constraint.cloneCells.length)} paired cells`,
+ );
+ case "extra-region":
+ return `extra region · ${String(constraint.cells.length)} cells`;
+ case "modular-line":
+ return marked(
+ `modular line · ${String(constraint.cells.length)} ordered cells`,
+ );
+ case "entropic-line":
+ return marked(
+ `entropic line · ${String(constraint.cells.length)} ordered cells`,
+ );
+ case "zipper-line":
+ return marked(
+ `zipper line · ${String(constraint.cells.length)} ordered cells`,
+ );
+ case "double-arrow":
+ return marked(
+ `double arrow · ${String(constraint.cells.length)} ordered cells`,
+ );
+ case "indexer":
+ return marked(`${constraint.kind} indexer · ${cell(constraint.cell)}`);
+ case "fog":
+ return `fog · ${String(constraint.lights.length)} initial light${constraint.lights.length === 1 ? "" : "s"} · radius ${String(constraint.revealRadius ?? 1)}`;
}
}
function supportsPolarity(constraint: VariantConstraint): boolean {
- return !["diagonal", "anti-knight", "anti-king", "non-consecutive"].includes(
- constraint.type,
- );
+ return constraintMetadata(constraint.type).negatable;
}
function parseClueDigits(text: string, size: number): number[] {
@@ -109,21 +233,31 @@ export function ConstraintEditor({
const [region, setRegion] = useState(1);
const [newCluesAreFalse, setNewCluesAreFalse] = useState(false);
const [quadrupleDigits, setQuadrupleDigits] = useState("1, 2, 3");
- const [outsideType, setOutsideType] = useState<"x-sum" | "skyscraper">(
- "x-sum",
- );
- const [outsideSide, setOutsideSide] = useState<
- "top" | "right" | "bottom" | "left"
- >("top");
+ const [outsideType, setOutsideType] =
+ useState("x-sum");
+ const [outsideSide, setOutsideSide] = useState("top");
+ const [littleKillerDirection, setLittleKillerDirection] =
+ useState("down-right");
const [outsideLine, setOutsideLine] = useState(1);
const [outsideValue, setOutsideValue] = useState(3);
+ const [whisperMinimumDifference, setWhisperMinimumDifference] = useState(
+ Math.ceil(puzzle.size / 2),
+ );
+ const [indexerKind, setIndexerKind] = useState<"row" | "column" | "box">(
+ "row",
+ );
+ const [fogRevealRadius, setFogRevealRadius] = useState<0 | 1>(1);
+ const fogReasonId = useId();
const constraints = puzzle.constraints ?? [];
- const append = (constraint: VariantConstraint) =>
+ const append = (
+ constraint: VariantConstraint,
+ replace: (candidate: VariantConstraint) => boolean = () => false,
+ ) =>
onChange({
...puzzle,
constraints: [
- ...constraints,
+ ...constraints.filter((candidate) => !replace(candidate)),
supportsPolarity(constraint) && newCluesAreFalse
? ({ ...constraint, negated: true } as VariantConstraint)
: constraint,
@@ -146,6 +280,7 @@ export function ConstraintEditor({
? cageSum >= 1 && cageSum <= puzzle.size ** 3
: cageSum >= minimumCageSum && cageSum <= maximumCageSum);
const parsedQuadrupleDigits = parseClueDigits(quadrupleDigits, puzzle.size);
+ const selectionIsUnique = new Set(selection).size === selection.length;
const validQuadruple =
cellsFormQuadruple(puzzle.size, selection) &&
parsedQuadrupleDigits.length >= 1 &&
@@ -155,9 +290,95 @@ export function ConstraintEditor({
(digit) => Number.isInteger(digit) && digit >= 1 && digit <= puzzle.size,
);
const selectedCageExists = selectionTouchesKillerCage(constraints, selection);
+ const selectedCells = new Set(selection);
+ const selectedCellMarkerExists = constraints.some(
+ (constraint) =>
+ (constraint.type === "maximum" ||
+ constraint.type === "minimum" ||
+ constraint.type === "odd" ||
+ constraint.type === "even") &&
+ selectedCells.has(constraint.cell),
+ );
+
+ const littleKillerLine = littleKillerCells(
+ puzzle.size,
+ outsideSide,
+ outsideLine - 1,
+ littleKillerDirection,
+ );
+ const validOutsidePosition =
+ Number.isInteger(outsideLine) &&
+ outsideLine >= 1 &&
+ outsideLine <= puzzle.size;
+ const validLittleKillerDirection = littleKillerDirectionEntersGrid(
+ outsideSide,
+ littleKillerDirection,
+ );
+ const outsideMinimum = newCluesAreFalse
+ ? outsideType === "sandwich"
+ ? 0
+ : 1
+ : outsideType === "sandwich"
+ ? 0
+ : outsideType === "little-killer"
+ ? littleKillerLine.length
+ : 1;
+ const outsideMaximum = newCluesAreFalse
+ ? puzzle.size ** 4
+ : outsideType === "x-sum"
+ ? (puzzle.size * (puzzle.size + 1)) / 2
+ : outsideType === "skyscraper"
+ ? puzzle.size
+ : outsideType === "little-killer"
+ ? littleKillerLine.length * puzzle.size
+ : (puzzle.size * (puzzle.size + 1)) / 2 - puzzle.size - 1;
+ const validOutsideValue =
+ Number.isInteger(outsideValue) &&
+ outsideValue >= outsideMinimum &&
+ outsideValue <= outsideMaximum &&
+ (newCluesAreFalse ||
+ outsideType !== "sandwich" ||
+ reachableSandwichSums(puzzle.size).has(outsideValue));
+ const validOutsideClue =
+ validOutsidePosition &&
+ validOutsideValue &&
+ (outsideType !== "little-killer" ||
+ (validLittleKillerDirection && littleKillerLine.length >= 2));
+ const validWhisperDifference =
+ Number.isInteger(whisperMinimumDifference) &&
+ whisperMinimumDifference >= 1 &&
+ whisperMinimumDifference < puzzle.size;
+ const cloneHalf = selection.length / 2;
+ const cloneSource = Number.isInteger(cloneHalf)
+ ? selection.slice(0, cloneHalf)
+ : [];
+ const cloneTarget = Number.isInteger(cloneHalf)
+ ? selection.slice(cloneHalf)
+ : [];
+ const validClone =
+ selectionIsUnique &&
+ cloneSource.length >= 1 &&
+ cloneSource.length === cloneTarget.length &&
+ cloneTarget.every((cell) => !new Set(cloneSource).has(cell));
+ const solutionIsComplete =
+ puzzle.solution?.length === puzzle.size * puzzle.size &&
+ puzzle.solution.every(
+ (value, cell) =>
+ Number.isInteger(value) &&
+ value >= 1 &&
+ value <= puzzle.size &&
+ ((puzzle.givens[cell] ?? 0) === 0 || puzzle.givens[cell] === value),
+ );
+ const trustedSolution = solutionIsComplete && validatePuzzle(puzzle).valid;
+ const boxIndexerAvailable = hasClassicRegionPartition(puzzle);
+ const fogDisabledReason = !trustedSolution
+ ? "Fog of War requires a complete trusted solution. Generate or import one before choosing initial lights."
+ : !atLeast(1) || !selectionIsUnique
+ ? "Select at least one unique initial light cell."
+ : undefined;
const toggleGlobal = (
- type: "anti-knight" | "anti-king" | "non-consecutive",
+ type: "anti-knight" | "anti-king" | "non-consecutive" | "disjoint-groups",
) => {
const exists = constraints.some((constraint) => constraint.type === type);
onChange({
@@ -180,7 +401,11 @@ export function ConstraintEditor({
Grid
onNewGrid(Number(event.target.value))}
+ onChange={(event) => {
+ const size = Number(event.target.value);
+ setWhisperMinimumDifference(Math.ceil(size / 2));
+ onNewGrid(size);
+ }}
>
4 × 4
6 × 6
@@ -419,10 +644,335 @@ export function ConstraintEditor({
append({ type: "maximum", cell: selection[0]! })}
+ onClick={() =>
+ append(
+ { type: "maximum", cell: selection[0]! },
+ (constraint) =>
+ constraint.type === "maximum" &&
+ constraint.cell === selection[0],
+ )
+ }
>
Maximum cell
+
+ append(
+ { type: "minimum", cell: selection[0]! },
+ (constraint) =>
+ constraint.type === "minimum" &&
+ constraint.cell === selection[0],
+ )
+ }
+ >
+ Minimum cell
+
+
+ append(
+ { type: "odd", cell: selection[0]! },
+ (constraint) =>
+ constraint.type === "odd" && constraint.cell === selection[0],
+ )
+ }
+ >
+ Odd cell (circle)
+
+
+ append(
+ { type: "even", cell: selection[0]! },
+ (constraint) =>
+ constraint.type === "even" &&
+ constraint.cell === selection[0],
+ )
+ }
+ >
+ Even cell (square)
+
+
+ onChange({
+ ...puzzle,
+ constraints: constraints.filter(
+ (constraint) =>
+ !(
+ (constraint.type === "maximum" ||
+ constraint.type === "minimum" ||
+ constraint.type === "odd" ||
+ constraint.type === "even") &&
+ selectedCells.has(constraint.cell)
+ ),
+ ),
+ })
+ }
+ >
+ Remove selected cell markers
+
+
+
+
+
Expansion lines & regions
+
+ Selection order defines each line. For clone regions, select all
+ source cells first and then the same number of paired clone cells.
+
+
+
+
+ Whisper minimum difference
+
+ setWhisperMinimumDifference(Number(event.target.value))
+ }
+ />
+
+
+
+
+ append(
+ { type: "between-line", cells: selection },
+ (constraint) =>
+ constraint.type === "between-line" &&
+ sameOrderedCells(constraint.cells, selection),
+ )
+ }
+ >
+ Between line
+
+
+ append(
+ {
+ type: "german-whisper",
+ cells: selection,
+ minimumDifference: whisperMinimumDifference,
+ },
+ (constraint) =>
+ constraint.type === "german-whisper" &&
+ sameOrderedCells(constraint.cells, selection),
+ )
+ }
+ >
+ German whisper
+
+
+ append(
+ { type: "region-sum-line", cells: selection },
+ (constraint) =>
+ constraint.type === "region-sum-line" &&
+ sameOrderedCells(constraint.cells, selection),
+ )
+ }
+ >
+ Region-sum line
+
+
+ append(
+ {
+ type: "clone",
+ cells: cloneSource,
+ cloneCells: cloneTarget,
+ },
+ (constraint) =>
+ constraint.type === "clone" &&
+ sameOrderedCells(constraint.cells, cloneSource) &&
+ sameOrderedCells(constraint.cloneCells, cloneTarget),
+ )
+ }
+ >
+ Clone selection halves
+
+
+ append(
+ { type: "extra-region", cells: selection },
+ (constraint) =>
+ constraint.type === "extra-region" &&
+ new Set(constraint.cells).size === selection.length &&
+ selection.every((cell) => constraint.cells.includes(cell)),
+ )
+ }
+ >
+ Extra region ({String(puzzle.size)} cells)
+
+
+
+
+
+
Pattern lines, indexers & fog
+
+ Selection order runs from the first selected cell to the last.
+ Indexers use one selected marker cell. Fog uses every selected
+ cell as an initial light.
+
+
+
+
+ Indexer kind
+
+ setIndexerKind(event.target.value as "row" | "column" | "box")
+ }
+ >
+ Row indexer
+ Column indexer
+
+ Box indexer
+
+
+
+
+ Fog reveal radius
+
+ setFogRevealRadius(Number(event.target.value) as 0 | 1)
+ }
+ >
+ 1 cell (3 × 3 area)
+ 0 cells (entered cell only)
+
+
+
+
+ Entropic lines require a grid size divisible by 3; zipper lines
+ require an odd number of selected cells.
+ {!boxIndexerAvailable &&
+ " Box indexers require the standard rectangular box layout."}
+
+
+
+ append(
+ { type: "modular-line", cells: selection },
+ (constraint) =>
+ constraint.type === "modular-line" &&
+ sameOrderedCells(constraint.cells, selection),
+ )
+ }
+ >
+ Modular line
+
+
+ append(
+ { type: "entropic-line", cells: selection },
+ (constraint) =>
+ constraint.type === "entropic-line" &&
+ sameOrderedCells(constraint.cells, selection),
+ )
+ }
+ >
+ Entropic line
+
+
+ append(
+ { type: "zipper-line", cells: selection },
+ (constraint) =>
+ constraint.type === "zipper-line" &&
+ sameOrderedCells(constraint.cells, selection),
+ )
+ }
+ >
+ Zipper line
+
+
+ append(
+ { type: "double-arrow", cells: selection },
+ (constraint) =>
+ constraint.type === "double-arrow" &&
+ sameOrderedCells(constraint.cells, selection),
+ )
+ }
+ >
+ Double arrow
+
+
+ append(
+ {
+ type: "indexer",
+ kind: indexerKind,
+ cell: selection[0]!,
+ },
+ (constraint) =>
+ constraint.type === "indexer" &&
+ constraint.cell === selection[0],
+ )
+ }
+ >
+ Add / replace indexer
+
+
+ append(
+ {
+ type: "fog",
+ lights: selection,
+ revealRadius: fogRevealRadius,
+ },
+ (constraint) => constraint.type === "fog",
+ )
+ }
+ >
+ Add / replace Fog of War
+
+
+
+ {fogDisabledReason ??
+ "Fog is ready: correct entries and givens reveal nearby cells."}
+
@@ -478,10 +1028,11 @@ export function ConstraintEditor({
Outside clues
- X-sums and skyscrapers
+ Edge and diagonal sums
Choose the edge and row or column. Σ badges are X-sums; ▥ badges are
- visibility counts.
+ visibility counts; 1⋯N marks sandwich sums. Little-killer arrows show
+ the diagonal direction.
@@ -489,22 +1040,28 @@ export function ConstraintEditor({
- setOutsideType(event.target.value as "x-sum" | "skyscraper")
+ setOutsideType(event.target.value as OutsideConstraintType)
}
>
X-sum
Skyscraper
+ Little killer
+ Sandwich sum
Side
- setOutsideSide(
- event.target.value as "top" | "right" | "bottom" | "left",
- )
- }
+ onChange={(event) => {
+ const side = event.target.value as OutsideClueSide;
+ setOutsideSide(side);
+ if (
+ !littleKillerDirectionEntersGrid(side, littleKillerDirection)
+ ) {
+ setLittleKillerDirection(firstLittleKillerDirection(side));
+ }
+ }}
>
Top
Right
@@ -522,52 +1079,78 @@ export function ConstraintEditor({
onChange={(event) => setOutsideLine(Number(event.target.value))}
/>
+ {outsideType === "little-killer" && (
+
+ Direction
+
+ setLittleKillerDirection(
+ event.target.value as LittleKillerDirection,
+ )
+ }
+ >
+ {LITTLE_KILLER_DIRECTIONS.map(([direction, label]) => (
+
+ {label}
+
+ ))}
+
+
+ )}
- Clue
+ {outsideType === "skyscraper" ? "Count" : "Sum"}
setOutsideValue(Number(event.target.value))}
/>
puzzle.size ||
- !Number.isInteger(outsideValue) ||
- outsideValue < 1 ||
- outsideValue >
- (newCluesAreFalse
- ? puzzle.size ** 4
- : outsideType === "x-sum"
- ? (puzzle.size * (puzzle.size + 1)) / 2
- : puzzle.size)
- }
+ disabled={!validOutsideClue}
onClick={() => {
- const clue: VariantConstraint =
- outsideType === "x-sum"
- ? {
+ const clue: VariantConstraint = (() => {
+ switch (outsideType) {
+ case "x-sum":
+ return {
type: "x-sum",
side: outsideSide,
index: outsideLine - 1,
sum: outsideValue,
- }
- : {
+ };
+ case "skyscraper":
+ return {
type: "skyscraper",
side: outsideSide,
index: outsideLine - 1,
count: outsideValue,
};
+ case "little-killer":
+ return {
+ type: "little-killer",
+ side: outsideSide,
+ index: outsideLine - 1,
+ direction: littleKillerDirection,
+ sum: outsideValue,
+ };
+ case "sandwich":
+ return {
+ type: "sandwich",
+ side: outsideSide,
+ index: outsideLine - 1,
+ sum: outsideValue,
+ };
+ }
+ })();
const withoutExisting = constraints.filter(
(constraint) =>
constraint.type !== outsideType ||
@@ -593,25 +1176,30 @@ export function ConstraintEditor({
Global rules
- {(["anti-knight", "anti-king", "non-consecutive"] as const).map(
- (type) => (
-
constraint.type === type)
- ? "is-active"
- : ""
- }
- aria-pressed={constraints.some(
- (constraint) => constraint.type === type,
- )}
- onClick={() => toggleGlobal(type)}
- >
- {type}
-
- ),
- )}
+ {(
+ [
+ "anti-knight",
+ "anti-king",
+ "non-consecutive",
+ "disjoint-groups",
+ ] as const
+ ).map((type) => (
+
constraint.type === type)
+ ? "is-active"
+ : ""
+ }
+ aria-pressed={constraints.some(
+ (constraint) => constraint.type === type,
+ )}
+ onClick={() => toggleGlobal(type)}
+ >
+ {constraintLabel(type)}
+
+ ))}
{(["main", "anti"] as const).map((direction) => {
const active = constraints.some(
(constraint) =>
diff --git a/src/components/GeneratorWorkspace.tsx b/src/components/GeneratorWorkspace.tsx
index 9580545..ffacfd3 100644
--- a/src/components/GeneratorWorkspace.tsx
+++ b/src/components/GeneratorWorkspace.tsx
@@ -2,8 +2,13 @@ import { useMemo, useState, type FormEvent } from "react";
import {
GENERATOR_VARIANTS,
PRACTICE_TECHNIQUES,
+ type BatchRanking,
+ type ClueSymmetry,
+ type ConstraintDensity,
type DifficultyAssessment,
+ type GeneratedVariantBatch,
type GeneratedVariantPuzzle,
+ type GenerateVariantBatchOptions,
type GenerationDifficultyTarget,
type GeneratorVariant,
type GenerateVariantOptions,
@@ -31,13 +36,21 @@ export function GeneratorWorkspace({
busy,
assessment,
generation,
+ batch,
onGenerate,
+ onGenerateBatch,
+ onSelectGenerated,
+ onCancel,
onRate,
}: {
busy: boolean;
assessment?: DifficultyAssessment;
generation?: GeneratedVariantPuzzle;
+ batch?: GeneratedVariantBatch;
onGenerate: (options: GenerateVariantOptions) => void;
+ onGenerateBatch?: (options: GenerateVariantBatchOptions) => void;
+ onSelectGenerated?: (generation: GeneratedVariantPuzzle) => void;
+ onCancel?: () => void;
onRate: () => void;
}) {
const [variant, setVariant] = useState
("classic");
@@ -48,35 +61,107 @@ export function GeneratorWorkspace({
const [size, setSize] = useState(9);
const [targetDifficulty, setTargetDifficulty] =
useState("medium");
- const [symmetry, setSymmetry] = useState<"none" | "rotational">("rotational");
+ const [additionalVariants, setAdditionalVariants] = useState<
+ GeneratorVariant[]
+ >([]);
+ const [symmetry, setSymmetry] = useState("rotational");
const [constraintCount, setConstraintCount] = useState(8);
+ const [constraintDensity, setConstraintDensity] =
+ useState("balanced");
+ const [minimalGivens, setMinimalGivens] = useState(false);
+ const [batchSize, setBatchSize] = useState(1);
+ const [batchRanking, setBatchRanking] = useState("difficulty");
const [seed, setSeed] = useState("");
const [requiredTechnique, setRequiredTechnique] = useState<
PracticeTechnique | ""
>("");
const [maxTechniqueAttempts, setMaxTechniqueAttempts] = useState(10);
- const usesMarkingCount = ![
- "classic",
- "diagonal",
- "killer",
- "anti-knight",
- "anti-king",
- "non-consecutive",
- ].includes(variant);
+ const [minimumTechniqueCount, setMinimumTechniqueCount] = useState(1);
+ const [maximumTechniqueCount, setMaximumTechniqueCount] = useState("");
+ const [forbiddenTechnique, setForbiddenTechnique] = useState<
+ PracticeTechnique | ""
+ >("");
+ const [hardestTechnique, setHardestTechnique] = useState<
+ PracticeTechnique | ""
+ >("");
+ const selectedFamilies = [variant, ...additionalVariants];
+ const usesMarkingCount = selectedFamilies.some(
+ (family) =>
+ ![
+ "classic",
+ "diagonal",
+ "killer",
+ "anti-knight",
+ "anti-king",
+ "non-consecutive",
+ ].includes(family),
+ );
+ const usesConstraintDensity = selectedFamilies.some(
+ (family) =>
+ family === "killer" ||
+ ![
+ "classic",
+ "diagonal",
+ "anti-knight",
+ "anti-king",
+ "non-consecutive",
+ ].includes(family),
+ );
+ const hasTechniqueProfile =
+ requiredTechnique !== "" ||
+ forbiddenTechnique !== "" ||
+ hardestTechnique !== "" ||
+ maximumTechniqueCount !== "" ||
+ minimumTechniqueCount !== 1;
const submit = (event: FormEvent) => {
event.preventDefault();
- onGenerate({
+ const profileCounts =
+ requiredTechnique !== "" &&
+ (minimumTechniqueCount !== 1 || maximumTechniqueCount !== "")
+ ? [
+ {
+ technique: requiredTechnique,
+ min: minimumTechniqueCount,
+ ...(maximumTechniqueCount === ""
+ ? {}
+ : { max: Number(maximumTechniqueCount) }),
+ },
+ ]
+ : [];
+ const options: GenerateVariantBatchOptions = {
variant,
+ ...(additionalVariants.length === 0
+ ? {}
+ : { variants: selectedFamilies }),
size,
targetDifficulty,
symmetry,
...(usesMarkingCount ? { constraintCount } : {}),
- ...(requiredTechnique === ""
- ? {}
- : { requiredTechnique, maxTechniqueAttempts }),
+ ...(usesConstraintDensity && constraintDensity !== "balanced"
+ ? { constraintDensity }
+ : {}),
+ ...(minimalGivens ? { minimalGivens: true } : {}),
+ ...(requiredTechnique === "" ? {} : { requiredTechnique }),
+ ...(hasTechniqueProfile
+ ? {
+ techniqueProfile: {
+ ...(forbiddenTechnique === ""
+ ? {}
+ : { forbidden: [forbiddenTechnique] }),
+ ...(profileCounts.length === 0 ? {} : { counts: profileCounts }),
+ ...(hardestTechnique === "" ? {} : { hardestTechnique }),
+ },
+ maxTechniqueAttempts,
+ }
+ : {}),
seed: seed.trim() || `local-${Date.now().toString(36)}`,
- });
+ };
+ if (batchSize > 1 && onGenerateBatch !== undefined) {
+ onGenerateBatch({ ...options, batchSize, ranking: batchRanking });
+ } else {
+ onGenerate(options);
+ }
};
return (
@@ -103,6 +188,16 @@ export function GeneratorWorkspace({
(item) => item.id === next,
)!;
setVariant(next);
+ setAdditionalVariants((current) =>
+ current.filter(
+ (family) =>
+ family !== next &&
+ (
+ GENERATOR_VARIANTS.find(({ id }) => id === family)
+ ?.supportedSizes as readonly number[]
+ ).includes(size),
+ ),
+ );
if (
!(
nextDefinition.supportedSizes as readonly number[]
@@ -128,7 +223,18 @@ export function GeneratorWorkspace({
Grid
setSize(Number(event.target.value))}
+ onChange={(event) => {
+ const nextSize = Number(event.target.value);
+ setSize(nextSize);
+ setAdditionalVariants((current) =>
+ current.filter((family) =>
+ (
+ GENERATOR_VARIANTS.find(({ id }) => id === family)
+ ?.supportedSizes as readonly number[]
+ ).includes(nextSize),
+ ),
+ );
+ }}
>
{definition.supportedSizes.map((supportedSize) => (
@@ -137,6 +243,35 @@ export function GeneratorWorkspace({
))}
+
+ Mix in variant families
+
+ {GENERATOR_VARIANTS.filter(({ id }) => id !== variant).map(
+ (item) => {
+ const supported = (
+ item.supportedSizes as readonly number[]
+ ).includes(size);
+ return (
+
+
+ setAdditionalVariants((current) =>
+ event.target.checked
+ ? [...current, item.id]
+ : current.filter((id) => id !== item.id),
+ )
+ }
+ />
+ {item.label}
+
+ );
+ },
+ )}
+
+
Requested profile
- setSymmetry(event.target.value as "none" | "rotational")
+ setSymmetry(event.target.value as ClueSymmetry)
}
>
Rotational
+ Horizontal reflection
+ Vertical reflection
+ Main diagonal
+ Anti-diagonal
+ Four-way orthogonal
None
@@ -180,6 +320,54 @@ export function GeneratorWorkspace({
/>
)}
+ {usesConstraintDensity && (
+
+ Constraint density
+
+ setConstraintDensity(event.target.value as ConstraintDensity)
+ }
+ >
+ Sparse
+ Balanced
+ Dense
+
+
+ )}
+
+ setMinimalGivens(event.target.checked)}
+ />
+ Prove minimal givens
+
+
+ Batch size
+ setBatchSize(Number(event.target.value))}
+ />
+
+ {batchSize > 1 && (
+
+ Batch ranking
+
+ setBatchRanking(event.target.value as BatchRanking)
+ }
+ >
+ Difficulty
+ Fewest givens
+ Most givens
+
+
+ )}
Seed
No required technique
{PRACTICE_TECHNIQUES.filter(
(technique) =>
- technique !== "killer-cage" || variant === "killer",
+ technique !== "killer-cage" ||
+ selectedFamilies.includes("killer"),
).map((technique) => (
{techniqueLabel(technique)}
@@ -211,6 +400,75 @@ export function GeneratorWorkspace({
{requiredTechnique !== "" && (
+ <>
+
+ Minimum occurrences
+
+ setMinimumTechniqueCount(Number(event.target.value))
+ }
+ />
+
+
+ Maximum occurrences
+
+ setMaximumTechniqueCount(event.target.value)
+ }
+ />
+
+ >
+ )}
+
+ Forbidden technique
+
+ setForbiddenTechnique(
+ event.target.value as PracticeTechnique | "",
+ )
+ }
+ >
+ None
+ {PRACTICE_TECHNIQUES.map((technique) => (
+
+ {techniqueLabel(technique)}
+
+ ))}
+
+
+
+ Hardest technique target
+
+ setHardestTechnique(
+ event.target.value as PracticeTechnique | "",
+ )
+ }
+ >
+ Any
+ {PRACTICE_TECHNIQUES.filter(
+ (technique) =>
+ technique !== "killer-cage" ||
+ selectedFamilies.includes("killer"),
+ ).map((technique) => (
+
+ {techniqueLabel(technique)}
+
+ ))}
+
+
+ {hasTechniqueProfile && (
Mining attempts
{definition.description}
- {busy ? "Working locally…" : `Generate ${definition.label}`}
+ {busy
+ ? "Working locally…"
+ : batchSize > 1
+ ? `Generate ${String(batchSize)}-puzzle batch`
+ : `Generate ${definition.label}`}
+ {busy && onCancel !== undefined && (
+
+ Cancel generation
+
+ )}
Rate current puzzle
@@ -237,8 +504,8 @@ export function GeneratorWorkspace({
Difficulty is an estimate from reproducible solver evidence, not a
universal promise. Technique practice only returns a puzzle whose
- logical path contains the requested move. Uniqueness is never claimed
- after a safety limit.
+ complete independently analysed path matches every requested rule.
+ Uniqueness and minimality are never claimed after a safety limit.
@@ -291,6 +558,16 @@ export function GeneratorWorkspace({
)}
+ {generation && (
+
+ Minimality {generation.minimality.status}
+
+ )}
+ {generation?.techniqueProfile && (
+
+ Profile {generation.techniqueProfile.status}
+
+ )}
{assessment.summary}
{generation && (
@@ -298,8 +575,139 @@ export function GeneratorWorkspace({
Seed: {String(generation.seed)} · Found in{" "}
{generation.generationAttempts} attempt
{generation.generationAttempts === 1 ? "" : "s"}.
+ {generation.families.length > 1
+ ? ` Families: ${generation.families.join(", ")}.`
+ : ""}
)}
+ {generation?.minimality.status === "proven-minimal" && (
+
+ Minimal-givens proof completed in{" "}
+ {generation.minimality.checksPerformed} bounded checks; every
+ remaining given is critical.
+ {!generation.minimality.symmetryPreserved
+ ? " Individual minimization changed the requested clue symmetry."
+ : ""}
+
+ )}
+ {generation?.minimality.status === "unknown" && (
+
+ Minimality remains unknown after{" "}
+ {generation.minimality.checksPerformed} bounded checks. Unresolved
+ givens: {generation.minimality.unknownCells.length}. Reasons:{" "}
+ {generation.minimality.limitReasons
+ .map((reason) => reason.replaceAll("-", " "))
+ .join(", ") || "incomplete evidence"}
+ .
+
+ )}
+ {generation?.techniqueProfile && (
+
+ Independent logical path:{" "}
+ {generation.techniqueProfile.completePath
+ ? "complete"
+ : "incomplete"}
+ {generation.techniqueProfile.requirements.length === 0
+ ? ""
+ : ` · ${generation.techniqueProfile.requirements
+ .map(
+ ({ technique, actual, minimum, maximum }) =>
+ `${techniqueLabel(technique)} ${String(actual)}${minimum === undefined ? "" : ` ≥ ${String(minimum)}`}${maximum === undefined ? "" : ` ≤ ${String(maximum)}`}`,
+ )
+ .join(" · ")}`}
+ {generation.techniqueProfile.requestedHardestTechnique ===
+ undefined
+ ? ""
+ : ` · Hardest: ${techniqueLabel(generation.techniqueProfile.actualHardestTechnique)} (target ${techniqueLabel(generation.techniqueProfile.requestedHardestTechnique)})`}
+ .
+
+ )}
+
+ )}
+
+ {batch && (
+
+
+
+
Ranked local batch
+
+ {batch.completed} of {batch.requested} generated
+
+
+
+ {batch.ranking.replaceAll("-", " ")}
+
+
+ {batch.summaries.length === 0 ? (
+ No candidate passed the bounded checks.
+ ) : (
+
+
+
+
+ Rank
+ Seed
+ Families
+ Givens
+ Score
+ Evidence
+ Open
+
+
+
+ {batch.summaries.map((summary, index) => {
+ const entry = batch.entries[index];
+ return (
+
+ {summary.rank}
+
+ {String(summary.seed)}
+
+ {summary.families.join(" + ")}
+ {summary.clueCount}
+ {summary.score ?? "—"}
+
+ {summary.minimalityStatus}
+ {summary.profileStatus
+ ? ` · ${summary.profileStatus}`
+ : ""}
+
+
+ {
+ if (entry !== undefined) {
+ onSelectGenerated?.(entry);
+ }
+ }}
+ >
+ Use
+
+
+
+ );
+ })}
+
+
+
+ )}
+ {batch.failures.length > 0 && (
+
+
+ {batch.failures.length} bounded candidate
+ {batch.failures.length === 1 ? "" : "s"} did not complete or
+ match the profile
+
+
+ {batch.failures.map((failure) => (
+
+ {String(failure.seed)}: {failure.message}
+
+ ))}
+
+
+ )}
)}
diff --git a/src/components/GuidedHint.tsx b/src/components/GuidedHint.tsx
new file mode 100644
index 0000000..13bb178
--- /dev/null
+++ b/src/components/GuidedHint.tsx
@@ -0,0 +1,273 @@
+import type { LogicalStep } from "../solver";
+import {
+ GUIDED_HINT_STAGES,
+ guidedHintEffectItems,
+ guidedHintFocusSummary,
+ isGuidedHintStageRevealed,
+ logicalTechniqueDescription,
+ logicalTechniqueName,
+ nextGuidedHintStage,
+ type GuidedHintStage,
+} from "./guidedHint";
+
+const STAGE_LABELS: Record = {
+ focus: "Where to look",
+ technique: "Technique",
+ reasoning: "Reasoning",
+ preview: "Effects preview",
+};
+
+function revealButtonLabel(stage: GuidedHintStage): string | undefined {
+ const next = nextGuidedHintStage(stage);
+ if (next === "technique") return "Reveal technique";
+ if (next === "reasoning") return "Reveal reasoning";
+ if (next === "preview") return "Preview effects";
+ return undefined;
+}
+
+function stageStatus(stage: GuidedHintStage): string {
+ if (stage === "focus") return "Hint ready: where to look.";
+ if (stage === "technique") return "Technique revealed.";
+ if (stage === "reasoning") return "Reasoning revealed.";
+ return "Effects ready to preview and apply.";
+}
+
+export interface GuidedHintProps {
+ readonly size: number;
+ readonly step?: LogicalStep;
+ readonly stage: GuidedHintStage;
+ readonly busy?: boolean;
+ readonly error?: string;
+ readonly candidateTrackingActive?: boolean;
+ readonly autoMaintainPeerNotes: boolean;
+ readonly onRequestHint: () => void;
+ readonly onRevealNext: () => void;
+ readonly onApply: () => void;
+ readonly onDismiss: () => void;
+ readonly onFillLegalCandidates: () => void;
+ readonly onRemoveInvalidNotes: () => void;
+ readonly onAutoMaintainPeerNotesChange: (enabled: boolean) => void;
+}
+
+/**
+ * A controlled, progressively disclosed hint. The caller owns the hint step
+ * and stage so it can keep the board overlay and undo history in sync.
+ */
+export function GuidedHint({
+ size,
+ step,
+ stage,
+ busy = false,
+ error,
+ candidateTrackingActive = false,
+ autoMaintainPeerNotes,
+ onRequestHint,
+ onRevealNext,
+ onApply,
+ onDismiss,
+ onFillLegalCandidates,
+ onRemoveInvalidNotes,
+ onAutoMaintainPeerNotesChange,
+}: GuidedHintProps) {
+ const nextLabel = step === undefined ? undefined : revealButtonLabel(stage);
+ const effectItems =
+ step !== undefined && stage === "preview"
+ ? guidedHintEffectItems(step, size)
+ : [];
+
+ return (
+
+
+
+
Guided solving
+
One clue at a time
+
+ {step !== undefined && (
+
+ Dismiss
+
+ )}
+
+
+
+ Reveal only as much help as you want. Nothing changes until you apply
+ the fully previewed step.
+
+
+
+ {busy
+ ? "Finding a logical next step locally…"
+ : error !== undefined
+ ? "The hint could not be prepared."
+ : step === undefined
+ ? "No guided hint is open."
+ : stageStatus(stage)}
+
+
+ {error !== undefined && (
+
+ {error}
+
+ )}
+
+ {step !== undefined && (
+ <>
+
+ {GUIDED_HINT_STAGES.map((item) => {
+ const revealed = isGuidedHintStageRevealed(stage, item);
+ return (
+
+ {STAGE_LABELS[item]}
+
+ );
+ })}
+
+
+
+
+
+ Where to look
+
+ {guidedHintFocusSummary(step, size)}
+
+
+ {isGuidedHintStageRevealed(stage, "technique") && (
+
+
+ Technique
+
+
+ {logicalTechniqueName(step.technique)}
+
+
+ {logicalTechniqueDescription(step.technique)}
+
+
+ )}
+
+ {isGuidedHintStageRevealed(stage, "reasoning") && (
+
+
+ Why it works
+
+ {step.explanation}
+
+ )}
+
+ {stage === "preview" && (
+
+
+ Effects preview
+
+ {effectItems.length > 0 ? (
+
+ {effectItems.map((effect, index) => (
+ {effect}
+ ))}
+
+ ) : (
+ This deduction does not change the board.
+ )}
+ {step.eliminations.length > 0 && !candidateTrackingActive && (
+
+ Applying this elimination will start a complete legal
+ centre-candidate grid, then remove the previewed candidates.
+
+ )}
+
+ )}
+
+
+
+ {nextLabel !== undefined && (
+
+ {nextLabel}
+
+ )}
+ {stage === "preview" && (
+
+ Apply this step
+
+ )}
+
+ New hint
+
+
+ >
+ )}
+
+ {step === undefined && (
+
+
+ Get a guided hint
+
+
+ )}
+
+
+ Candidate maintenance
+
+ {candidateTrackingActive
+ ? "The guided candidate grid is active."
+ : "Candidate tracking is currently inactive."}
+
+
+
+ Fill legal candidates
+
+
+ Remove invalid notes
+
+
+
+
+ onAutoMaintainPeerNotesChange(event.currentTarget.checked)
+ }
+ />
+ Automatically remove peer notes after placing a digit
+
+
+
+ );
+}
diff --git a/src/components/HelpDialog.tsx b/src/components/HelpDialog.tsx
index 49c65ad..2a58c9c 100644
--- a/src/components/HelpDialog.tsx
+++ b/src/components/HelpDialog.tsx
@@ -14,12 +14,13 @@ export function HelpDialog({
Five complementary workspaces
Play keeps values, two kinds of notes, colours,
- branches, replay and elapsed time. Set edits clues
- and constraints. Generate constructs and rates
- bounded, seedable variants. Solve explains logical
- steps and can verify uniqueness. Helpers answers
- focused sum, candidate and relation questions without changing the
- board.
+ branches, replay, guided hints and elapsed time.{" "}
+ Set edits registry-backed clues and runs bounded
+ setter-quality checks. Generate constructs and
+ ranks bounded, seedable single or batch variants.{" "}
+ Solve explains logical steps and can verify
+ uniqueness. Helpers answers focused sum, candidate
+ and relation questions without changing the board.
@@ -65,16 +66,63 @@ export function HelpDialog({
Ctrl/⌘ + click
Highlight every placed copy of that digit
+
+
M
+ Toggle tap-by-tap multi-selection
+
+
+
Ctrl/⌘ + + / −
+ Zoom the board while focus is in the board area
+
+
+
Ctrl/⌘ + 0
+ Fit the board at its default scale
+
+
+ Touch, zoom and selection
+
+ Board zoom is stored in this browser. Use Pan board
+ before dragging a zoomed board; its strong border and status message
+ indicate that cell taps are temporarily paused. Stop panning to edit
+ again. Tap multi-select toggles individual cells
+ without a drag gesture and keeps one active cell for keyboard entry.
+ On narrow screens the entry pad stays close to the bottom edge and
+ respects the device safe area.
+
+
Hints and solutions
- Candidate legality is computed independently from handwritten notes.
- Logical deductions report their premises, affected houses,
- placements and eliminations. Exact search is separately labelled; it
- proves feasibility or uniqueness but is not presented as a human
- explanation.
+ A guided hint reveals where to look, the technique, its reasoning
+ and an effects preview in separate stages; the board changes only
+ after Apply this step . Candidate legality is
+ computed independently from handwritten notes. Controls can fill
+ legal centre candidates, prune invalid centre/corner notes and
+ optionally maintain peer notes after placements. Erasing a value
+ never invents candidates.
+
+
+ Logical deductions report premises, affected houses, placements and
+ eliminations for singles, subsets, intersections, fish, wings,
+ colouring, chains and Killer cages. Unique Rectangle is disabled
+ unless a completed exact search has already proved uniqueness. Exact
+ search is separately labelled and is not presented as a human
+ explanation; reaching a limit remains unknown.
+
+
+
+ Setter-quality checks
+
+ Quick analysis checks for zero, one or two solutions and can show
+ the cells where two completions differ. Full analysis uses bounded
+ deletion searches to classify givens and constraints as critical,
+ redundant or unknown, build a cell heatmap, or localise a
+ contradictory core. Optional minimality is claimed only when the
+ unique baseline and every required removal check complete. Per-check
+ and aggregate budgets are visible, and analysis can be cancelled
+ without turning a capped search into proof.
@@ -84,9 +132,10 @@ export function HelpDialog({
checkpoint, isolate a hypothesis, or inspect an earlier grid.
Discarding a hypothesis restores its exact starting state but keeps
the abandoned path available in replay. Replayed grids are read-only
- until you return live or deliberately branch from that step. This
- working history stays in the current browser session; save the
- puzzle to the Library for durable puzzle progress.
+ until you return live or deliberately branch from that step.
+ Validated history is included in local autosaves and explicit
+ Library saves, so restoring that progress also restores its
+ savepoints and branches.
@@ -115,25 +164,44 @@ export function HelpDialog({
Variant and false clues
- The setter supports cages, lines, pair clues, X-sums, skyscrapers,
- quadruples and maximum cells. Enable Wrogn mode to make new local
- clues false, or switch existing clues individually or as a batch.
- Red dashed artwork and a ≠ mark identify false clues; Σ and ▥
- identify X-sum and skyscraper readings. A false multi-cell clue
- often stays undecided until enough of its cells are known, so exact
- searches for dense liar puzzles can be substantially slower.
+ The shared registry covers classic/irregular/extra regions;
+ diagonal, anti, disjoint and non-consecutive rules; cages, parity,
+ extrema and quadruples; thermo, arrow, renban, palindrome, between,
+ whisper, region-sum, modular, entropic, zipper and double-arrow
+ lines; Kropki, 5/10 and inequality pairs; X-sum, skyscraper,
+ little-killer and sandwich clues; clone regions and row/column/box
+ indexers.
+
+
+ Enable Wrogn mode to make a supported local clue false, or switch
+ existing clues individually or as a batch. Red dashed artwork and a
+ ≠ mark identify false clues; global house rules, extra regions and
+ fog are not given a misleading false mode. Fog is display-only and
+ can be set only with a complete solution which validates against the
+ current puzzle. Wrong entries reveal nothing and the trusted
+ solution remains in the local puzzle document.
Generation and ratings
- Generation runs in a worker with explicit time and search limits. A
- requested level guides clue removal; the reported rating is then
- calculated independently from logical techniques, clue load and
- bounded exact-search evidence. Practice mode deterministically mines
- several candidates and succeeds only when the analysed solve path
- contains the requested technique. A limit never becomes a false
- uniqueness claim.
+ Generation runs in a worker with explicit time and search limits. It
+ supports single or deterministic batch generation, compatible mixed
+ families, sparse/balanced/dense local constraints and several
+ rotational, reflection and diagonal clue symmetries. Minimal-givens
+ mode reports proof or the exact unknown reason; individual
+ minimisation can break the requested visual symmetry and reports
+ that fact.
+
+
+ Technique profiles can require, forbid or count techniques and set
+ an exact hardest technique. A result is accepted only when an
+ independent complete logical path matches the whole profile and an
+ independent exact search proves uniqueness. Requested difficulty
+ guides clue removal; the reported 0–100 rating is then calculated
+ from logical techniques, clue load and exact-search evidence. A
+ cancelled or bounded-out candidate remains a failure or unknown,
+ never a uniqueness/minimality claim.
@@ -142,19 +210,49 @@ export function HelpDialog({
Files, text, solving and generation stay in this browser. Compact
grids, project JSON, share fragments, supported f-puzzles,
SudokuPad/CTC inline data and supported Penpa+ long links are
- decoded locally. Server short IDs are intentionally rejected. SVG,
- PNG and PDF rendering also stays in the browser. Review an export
- before sharing: titles, authors, rules, solutions, progress and
- aid-mémoire entries may be included.
+ decoded locally. Server short IDs are intentionally rejected. The
+ compatibility check separates mapped Sudoku semantics, preserved
+ inert drawings, retained metadata and warnings before import. Source
+ identity and allowlisted drawings survive local edits and saves but
+ never become rules by appearance alone.
+
+
+ Export includes Sudoku Tools JSON/share data, f-puzzles, SudokuPad
+ JSON or self-contained SCL where representable, plus local SVG, PNG
+ and PDF rendering. Unsupported false/global semantics and visual
+ geometry are rejected rather than silently weakened. Review an
+ export before sharing: titles, authors, rules, solutions, progress,
+ source metadata, drawings and aid-mémoire entries may be included.
+
+
+
+ Recovery, Library and offline use
+
+ A separate debounced local autosave can restore or discard an
+ interrupted session, including validated bounded history, branches
+ and savepoints. Explicit Library projects offer title/tag search,
+ completion filters, safe previews and selected export, duplication
+ or deletion. Browser storage can be cleared independently, so export
+ important projects.
+
+
+ On a production HTTPS host, the first successful load can install a
+ relative-scope offline application shell. Service-worker support is
+ progressive enhancement: puzzle work stays local and remains usable
+ when registration is unavailable.
Screen-reader detail
- Each Sudoku cell reports its row, column, region, value or notes,
- colour, conflict state, candidate highlights and touching variant
- clues. The board and aid-mémoire use real row and gridcell roles;
- their keyboard instructions are attached to the grids.
+ Each visible Sudoku cell reports its row, column, region, value,
+ colour-and-pattern mark, conflict state and touching variant clues.
+ Candidate detail can be set to Off , concise counts,
+ or detailed digits without changing what is drawn. The board and
+ aid-mémoire use real row and gridcell roles; their keyboard
+ instructions are attached to the grids. Fogged cells report only
+ that they are obscured: hidden values, notes, clues, candidate
+ overlays and guided-hint steps are not exposed through board labels.
diff --git a/src/components/ImportExportDialog.tsx b/src/components/ImportExportDialog.tsx
index eb25124..3df48cd 100644
--- a/src/components/ImportExportDialog.tsx
+++ b/src/components/ImportExportDialog.tsx
@@ -3,8 +3,11 @@ import type { PuzzleDefinition } from "../domain/types";
import { normalizePuzzle } from "../domain/validation";
import {
encodePuzzleHash,
+ exportSudokuPadJson,
+ exportSudokuPadPayload,
exportFpuzzlesJson,
exportFpuzzlesUrl,
+ extractPreservedDocumentExtras,
fromDomainPuzzle,
importPuzzle,
renderPuzzlePdf,
@@ -13,6 +16,9 @@ import {
serializePlainGrid,
serializeSudokuDocument,
toDomainPuzzle,
+ type PreservedSudokuDocumentExtras,
+ type PuzzleImportMappingPreview,
+ type PuzzleImportResult,
type SudokuDocument,
} from "../formats";
import type { PlaySession } from "../state/session";
@@ -24,8 +30,9 @@ function withProgress(
puzzle: PuzzleDefinition,
session: PlaySession,
aidMemoire?: PortableAidMemoire,
+ preservedExtras?: PreservedSudokuDocumentExtras,
): SudokuDocument {
- const base = fromDomainPuzzle(puzzle);
+ const base = fromDomainPuzzle(puzzle, preservedExtras);
return {
...base,
values: [...session.values],
@@ -64,11 +71,13 @@ function checkedPuzzle(document: SudokuDocument): PuzzleDefinition {
return normalizePuzzle(toDomainPuzzle(document) as PuzzleDefinition);
}
-interface ImportExportDialogProps {
+export interface ImportExportDialogProps {
open: boolean;
puzzle: PuzzleDefinition;
session: PlaySession;
aidMemoire?: PortableAidMemoire;
+ /** Source-only data retained while the domain puzzle is edited. */
+ preservedExtras?: PreservedSudokuDocumentExtras;
onClose: () => void;
onImport: (
puzzle: PuzzleDefinition,
@@ -82,14 +91,41 @@ interface ImportExportDialogProps {
| "elapsedMs"
| "aidMemoire"
>,
+ preservedExtras?: PreservedSudokuDocumentExtras,
) => void;
}
+function PreviewList({
+ title,
+ entries,
+}: {
+ readonly title: string;
+ readonly entries: PuzzleImportMappingPreview["mappedSemantics"];
+}) {
+ return (
+
+
{title}
+ {entries.length === 0 ? (
+
None.
+ ) : (
+
+ {entries.map((entry) => (
+
+ {entry.label}: {entry.count}
+
+ ))}
+
+ )}
+
+ );
+}
+
export function ImportExportDialog({
open,
puzzle,
session,
aidMemoire,
+ preservedExtras,
onClose,
onImport,
}: ImportExportDialogProps) {
@@ -97,13 +133,17 @@ export function ImportExportDialog({
const [feedback, setFeedback] = useState("");
const [includeProgress, setIncludeProgress] = useState(true);
const [busy, setBusy] = useState(false);
+ const [inspected, setInspected] = useState<{
+ readonly input: string;
+ readonly result: PuzzleImportResult;
+ }>();
const fileRef = useRef(null);
const documentValue = useMemo(
() =>
includeProgress
- ? withProgress(puzzle, session, aidMemoire)
- : fromDomainPuzzle(puzzle),
- [aidMemoire, includeProgress, puzzle, session],
+ ? withProgress(puzzle, session, aidMemoire, preservedExtras)
+ : fromDomainPuzzle(puzzle, preservedExtras),
+ [aidMemoire, includeProgress, preservedExtras, puzzle, session],
);
const copy = async (value: string, label: string) => {
@@ -147,6 +187,7 @@ export function ImportExportDialog({
try {
const result = await importPuzzle(input);
checkedPuzzle(result.document);
+ setInspected({ input, result });
const givenCount = result.document.givens.filter(
(value) => value !== 0,
).length;
@@ -154,6 +195,7 @@ export function ImportExportDialog({
`${result.label}: ${String(result.document.size)}×${String(result.document.size)}, ${String(givenCount)} givens and ${String(result.document.constraints.length)} constraints. Compatible and ready to import.`,
);
} catch (error) {
+ setInspected(undefined);
setFeedback(errorMessage(error, "The puzzle could not be inspected."));
} finally {
setBusy(false);
@@ -163,17 +205,24 @@ export function ImportExportDialog({
const applyImport = async () => {
setBusy(true);
try {
- const result = await importPuzzle(input);
+ const result =
+ inspected?.input === input
+ ? inspected.result
+ : await importPuzzle(input);
const parsed = result.document;
- onImport(checkedPuzzle(parsed), {
- values: parsed.values,
- cornerMarks: parsed.cornerMarks,
- centerMarks: parsed.centerMarks,
- candidates: parsed.candidates,
- colors: parsed.colors,
- elapsedMs: parsed.elapsedMs,
- aidMemoire: parsed.aidMemoire,
- });
+ onImport(
+ checkedPuzzle(parsed),
+ {
+ values: parsed.values,
+ cornerMarks: parsed.cornerMarks,
+ centerMarks: parsed.centerMarks,
+ candidates: parsed.candidates,
+ colors: parsed.colors,
+ elapsedMs: parsed.elapsedMs,
+ aidMemoire: parsed.aidMemoire,
+ },
+ extractPreservedDocumentExtras(parsed),
+ );
setFeedback(`${result.label} imported locally.`);
onClose();
} catch (error) {
@@ -239,6 +288,7 @@ export function ImportExportDialog({
placeholder="Paste 81 characters, JSON or a puzzle URL…"
onChange={(event) => {
setInput(event.target.value);
+ setInspected(undefined);
setFeedback("");
}}
/>
@@ -263,6 +313,7 @@ export function ImportExportDialog({
.text()
.then((contents) => {
setInput(contents);
+ setInspected(undefined);
setFeedback(
`${file.name} loaded locally; review and import it.`,
);
@@ -287,6 +338,41 @@ export function ImportExportDialog({
Import locally
+ {inspected?.input === input && (
+
+
+
Mapping preview
+
{inspected.result.label}
+
+
+
+
+
+
Warnings
+ {inspected.result.preview.warnings.length === 0 ? (
+
None.
+ ) : (
+
+ {inspected.result.preview.warnings.map((warning) => (
+ {warning}
+ ))}
+
+ )}
+
+
+ )}
@@ -357,6 +443,46 @@ export function ImportExportDialog({
>
Copy f-puzzles URL
+
+ exportText(
+ "sudoku.scl.json",
+ "application/json",
+ "SudokuPad JSON",
+ () => exportSudokuPadJson(documentValue, true),
+ )
+ }
+ >
+ Download SudokuPad JSON
+
+
+ exportText(
+ "sudoku.scl",
+ "text/plain",
+ "SudokuPad SCL payload",
+ () => exportSudokuPadPayload(documentValue),
+ )
+ }
+ >
+ Download SudokuPad SCL payload
+
+
+ void copyExport(
+ () => exportSudokuPadPayload(documentValue),
+ "SudokuPad SCL payload",
+ )
+ }
+ >
+ Copy SudokuPad SCL payload
+
+ {[...item.thumbnail].map((symbol, index) => (
+
+ {symbol === "." ? "" : symbol}
+
+ ))}
+
+ );
+}
+
export function LibraryDialog({
open,
summaries,
@@ -21,6 +41,10 @@ export function LibraryDialog({
onDelete,
onClear,
onExport,
+ onExportSelected,
+ onDuplicateSelected,
+ onDeleteSelected,
+ onUpdateTags,
onImport,
}: {
open: boolean;
@@ -34,16 +58,73 @@ export function LibraryDialog({
onDelete: (id: string) => void;
onClear: () => void;
onExport: () => void;
+ onExportSelected: (ids: readonly string[]) => void;
+ onDuplicateSelected: (ids: readonly string[]) => void;
+ onDeleteSelected: (ids: readonly string[]) => void;
+ onUpdateTags: (id: string, tags: readonly string[]) => void;
onImport: (file: File) => void;
}) {
const fileRef = useRef(null);
+ const [search, setSearch] = useState("");
+ const [completion, setCompletion] = useState<"all" | "open" | "complete">(
+ "all",
+ );
+ const [tag, setTag] = useState("");
+ const [selected, setSelected] = useState>(
+ () => new Set(),
+ );
+ const [editingTagsFor, setEditingTagsFor] = useState();
+ const [tagDraft, setTagDraft] = useState("");
+
+ const allTags = useMemo(
+ () =>
+ [...new Set(summaries.flatMap((item) => item.tags))].sort((a, b) =>
+ a.localeCompare(b),
+ ),
+ [summaries],
+ );
+ const filtered = useMemo(() => {
+ const query = search.trim().toLocaleLowerCase();
+ return summaries.filter((item) => {
+ if (
+ query &&
+ !item.title.toLocaleLowerCase().includes(query) &&
+ !item.tags.some((value) => value.toLocaleLowerCase().includes(query))
+ ) {
+ return false;
+ }
+ if (tag && !item.tags.includes(tag)) return false;
+ return (
+ completion === "all" ||
+ (completion === "complete" ? item.completed : !item.completed)
+ );
+ });
+ }, [completion, search, summaries, tag]);
+ const knownIds = useMemo(
+ () => new Set(summaries.map((item) => item.id)),
+ [summaries],
+ );
+ const selectedIds = useMemo(
+ () => [...selected].filter((id) => knownIds.has(id)),
+ [knownIds, selected],
+ );
+
+ const toggleSelected = (id: string) => {
+ setSelected((current) => {
+ const next = new Set(current);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ };
+
return (
{mode === "indexeddb"
- ? "Saved in this browser profile."
+ ? "Saved in this browser profile. Working changes are autosaved separately."
: "IndexedDB is unavailable; saves last only for this open tab."}
@@ -81,38 +162,206 @@ export function LibraryDialog({
/>
+
+
+
+ Search
+ setSearch(event.target.value)}
+ />
+
+
+ Status
+
+ setCompletion(event.target.value as typeof completion)
+ }
+ >
+ All progress
+ In progress
+ Complete
+
+
+
+ Tag
+ setTag(event.target.value)}>
+ All tags
+ {allTags.map((value) => (
+
+ {value}
+
+ ))}
+
+
+
+
{feedback && (
{feedback}
)}
+
+ {summaries.length > 0 && (
+
+
+ {String(selectedIds.length)} selected · {String(filtered.length)}{" "}
+ shown
+
+
+
+ setSelected(new Set(filtered.map((item) => item.id)))
+ }
+ >
+ Select shown
+
+ setSelected(new Set())}
+ >
+ Clear selection
+
+ onExportSelected(selectedIds)}
+ >
+ Export selected
+
+ onDuplicateSelected(selectedIds)}
+ >
+ Duplicate selected
+
+ onDeleteSelected(selectedIds)}
+ >
+ Delete selected
+
+
+
+ )}
+
{summaries.length === 0 ? (
No saved puzzles
Save the current puzzle to build a local library.
+ ) : filtered.length === 0 ? (
+
+
No matching puzzles
+
Adjust the title, tag or progress filters.
+
) : (
- {summaries.map((item) => (
+ {filtered.map((item) => (
+
+ toggleSelected(item.id)}
+ />
+
onOpen(item.id)}
>
- {item.title || "Untitled puzzle"}
-
- {item.size}×{item.size} · {date(item.updatedAt)}
- {item.completed ? " · complete" : ""}
+
+
+ {item.title || "Untitled puzzle"}
+
+ {item.size}×{item.size} · {date(item.updatedAt)}
+ {item.completed ? " · complete" : " · in progress"}
+
- onDelete(item.id)}
- >
- Delete
-
+
+
+ {item.tags.map((value) => (
+ setTag(value)}
+ >
+ {value}
+
+ ))}
+
+ {editingTagsFor === item.id ? (
+
+ ) : (
+
{
+ setEditingTagsFor(item.id);
+ setTagDraft(item.tags.join(", "));
+ }}
+ >
+ Edit tags
+
+ )}
+
onDelete(item.id)}
+ >
+ Delete
+
+
))}
diff --git a/src/components/NumberPad.tsx b/src/components/NumberPad.tsx
index a0c9967..c652b72 100644
--- a/src/components/NumberPad.tsx
+++ b/src/components/NumberPad.tsx
@@ -1,5 +1,6 @@
import type { EntryMode } from "../state/session";
import { symbolFor } from "../state/session";
+import { colorMarkDescription } from "../state/uiPreferences";
const modes: Array<{ mode: EntryMode; label: string; key: string }> = [
{ mode: "value", label: "Value", key: "Z" },
@@ -56,7 +57,9 @@ export function NumberPad({
{mode === "color" ? (
<>
{" "}
- Colour {value}
+
+ Colour {value}: {colorMarkDescription(value)}
+
>
) : (
symbolFor(value, size)
diff --git a/src/components/SafeVisualLayer.tsx b/src/components/SafeVisualLayer.tsx
new file mode 100644
index 0000000..900a41d
--- /dev/null
+++ b/src/components/SafeVisualLayer.tsx
@@ -0,0 +1,139 @@
+import type { SVGAttributes } from "react";
+import type {
+ SafeVisualAnchor,
+ SafeVisualPrimitive,
+ SafeVisualStyle,
+} from "../formats";
+
+export interface SafeVisualLayerProps {
+ readonly size: number;
+ readonly visuals: readonly SafeVisualPrimitive[];
+ readonly layer: "underlay" | "overlay";
+}
+
+function anchorPoint(size: number, anchor: SafeVisualAnchor) {
+ if (anchor.kind === "coordinate") {
+ return { x: anchor.x, y: anchor.y };
+ }
+ return {
+ x: (anchor.cell % size) + 0.5 + (anchor.offsetX ?? 0),
+ y: Math.floor(anchor.cell / size) + 0.5 + (anchor.offsetY ?? 0),
+ };
+}
+
+function visualStyle(
+ style: SafeVisualStyle | undefined,
+): SVGAttributes {
+ return {
+ stroke: style?.stroke ?? "transparent",
+ fill: style?.fill ?? "transparent",
+ ...(style?.strokeWidth === undefined
+ ? {}
+ : { strokeWidth: style.strokeWidth }),
+ ...(style?.opacity === undefined ? {} : { opacity: style.opacity }),
+ };
+}
+
+/**
+ * Render already-normalized inert drawings with typed SVG properties only.
+ * Text is supplied as a React text node; no source markup is ever interpreted.
+ */
+export function SafeVisualLayer({
+ size,
+ visuals,
+ layer,
+}: SafeVisualLayerProps) {
+ return (
+
+ {visuals.map((visual, index) => {
+ if (visual.layer !== layer) return null;
+ const common = {
+ ...visualStyle(visual.style),
+ className: `source-visual source-visual--${visual.type}`,
+ "data-visual": index,
+ };
+ if (visual.type === "line") {
+ const start = anchorPoint(size, visual.start);
+ const end = anchorPoint(size, visual.end);
+ return (
+
+ );
+ }
+ if (visual.type === "polyline") {
+ const points = visual.points
+ .map((anchor) => anchorPoint(size, anchor))
+ .map(({ x, y }) => `${String(x)},${String(y)}`)
+ .join(" ");
+ return visual.closed === true ? (
+
+ ) : (
+
+ );
+ }
+ if (visual.type === "rectangle") {
+ const center = anchorPoint(size, visual.center);
+ return (
+
+ );
+ }
+ if (visual.type === "ellipse") {
+ const center = anchorPoint(size, visual.center);
+ return (
+
+ );
+ }
+ if (visual.type === "circle") {
+ const center = anchorPoint(size, visual.center);
+ return (
+
+ );
+ }
+ const position = anchorPoint(size, visual.position);
+ return (
+
+ {visual.text}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/SetterQualityLab.tsx b/src/components/SetterQualityLab.tsx
new file mode 100644
index 0000000..80daf16
--- /dev/null
+++ b/src/components/SetterQualityLab.tsx
@@ -0,0 +1,740 @@
+import { useState, type FormEvent } from "react";
+import type {
+ PuzzleQualityAnalysis,
+ QualityItemAssessment,
+ QualityItemReference,
+ analyzePuzzleQuality,
+} from "../solver/quality";
+import { symbolFor } from "../state/session";
+
+export type SetterQualityRunOptions = NonNullable<
+ Parameters[1]
+>;
+
+export type SetterQualityRunKind = "quick" | "minimality";
+
+export interface SetterQualityLabProps {
+ readonly size: number;
+ readonly result?: PuzzleQualityAnalysis;
+ readonly running?: SetterQualityRunKind;
+ readonly error?: string;
+ readonly initialOptions?: Partial;
+ readonly onRunQuick: (options: SetterQualityRunOptions) => void;
+ readonly onRunMinimality: (options: SetterQualityRunOptions) => void;
+ readonly onCancel: () => void;
+ readonly onFocusCells: (cells: readonly number[]) => void;
+ readonly onFocusItem: (item: QualityItemReference) => void;
+}
+
+interface QualityControls {
+ readonly perCheckMaxNodes: number;
+ readonly perCheckTimeoutMs: number;
+ readonly aggregateMaxChecks: number;
+ readonly aggregateMaxNodes: number;
+ readonly aggregateTimeoutMs: number;
+}
+
+const DEFAULT_CONTROLS: QualityControls = {
+ perCheckMaxNodes: 2_000_000,
+ perCheckTimeoutMs: 10_000,
+ aggregateMaxChecks: 1_000,
+ aggregateMaxNodes: 20_000_000,
+ aggregateTimeoutMs: 30_000,
+};
+
+const CONTROL_MAXIMUMS: Record = {
+ perCheckMaxNodes: 100_000_000,
+ perCheckTimeoutMs: 120_000,
+ aggregateMaxChecks: 20_000,
+ aggregateMaxNodes: 2_000_000_000,
+ aggregateTimeoutMs: 600_000,
+};
+
+function boundedPositiveInteger(
+ value: string,
+ fallback: number,
+ maximum: number,
+): number {
+ const parsed = Number(value);
+ return Number.isInteger(parsed) && parsed > 0
+ ? Math.min(parsed, maximum)
+ : fallback;
+}
+
+function humanize(value: string): string {
+ return value
+ .replaceAll(/([a-z])([A-Z])/gu, "$1 $2")
+ .replaceAll("-", " ")
+ .replace(/^./u, (first) => first.toUpperCase());
+}
+
+function cellName(cell: number, size: number): string {
+ return `r${String(Math.floor(cell / size) + 1)}c${String((cell % size) + 1)}`;
+}
+
+function itemLabel(item: QualityItemReference, size: number): string {
+ if (item.kind === "given") {
+ return `Given ${symbolFor(item.value, size)} at ${cellName(item.cell, size)}`;
+ }
+ return `Constraint ${String(item.index + 1)} · ${humanize(item.constraintType)}`;
+}
+
+function itemKey(item: QualityItemReference): string {
+ return item.kind === "given"
+ ? `given-${String(item.cell)}`
+ : `constraint-${String(item.index)}`;
+}
+
+function ItemList({
+ title,
+ items,
+ size,
+ emptyLabel,
+ onFocusItem,
+}: {
+ title: string;
+ items: readonly QualityItemReference[];
+ size: number;
+ emptyLabel: string;
+ onFocusItem: (item: QualityItemReference) => void;
+}) {
+ return (
+
+ {title}
+ {items.length === 0 ? (
+ {emptyLabel}
+ ) : (
+
+ {items.map((item) => (
+
+ onFocusItem(item)}>
+ {itemLabel(item, size)}
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+function AssessmentTable({
+ title,
+ assessments,
+ size,
+ onFocusItem,
+}: {
+ title: string;
+ assessments: readonly QualityItemAssessment[];
+ size: number;
+ onFocusItem: (item: QualityItemReference) => void;
+}) {
+ const counts = {
+ critical: assessments.filter(
+ ({ classification }) => classification === "critical",
+ ).length,
+ redundant: assessments.filter(
+ ({ classification }) => classification === "redundant",
+ ).length,
+ unknown: assessments.filter(
+ ({ classification }) => classification === "unknown",
+ ).length,
+ };
+ return (
+
+
+
{title}
+
+ {counts.critical} critical · {counts.redundant} redundant ·{" "}
+ {counts.unknown} unknown
+
+
+ {assessments.length === 0 ? (
+
+ No {title.toLowerCase()} were available to test.
+
+ ) : (
+
+
+
+
+ Clue
+ Finding
+ Evidence
+
+
+
+ {assessments.map((assessment) => (
+
+
+ onFocusItem(assessment.item)}
+ >
+ {itemLabel(assessment.item, size)}
+
+
+
+ {humanize(assessment.classification)}
+
+
+ {assessment.classification === "unknown"
+ ? `Unknown / incomplete${assessment.unknownReason ? `: ${humanize(assessment.unknownReason)}` : ""}`
+ : assessment.solutionStatus
+ ? `Without clue: ${humanize(assessment.solutionStatus)}`
+ : "Completed bounded check"}
+
+
+ ))}
+
+
+
+ )}
+
+ );
+}
+
+function solutionTitle(
+ status: PuzzleQualityAnalysis["solutionStatus"],
+): string {
+ if (status === "unique") return "Unique completion proven";
+ if (status === "multiple") return "Multiple completions found";
+ if (status === "unsatisfiable") return "No completion exists";
+ return "Solution status unknown";
+}
+
+function solutionDescription(
+ status: PuzzleQualityAnalysis["solutionStatus"],
+): string {
+ if (status === "unique")
+ return "The bounded exact check completed and proved one solution.";
+ if (status === "multiple")
+ return "Two different completions are enough to prove ambiguity.";
+ if (status === "unsatisfiable")
+ return "The bounded exact check completed without finding any valid completion.";
+ return "Incomplete / unknown: a configured safety bound was reached before the solution status could be proved.";
+}
+
+function QualityResults({
+ size,
+ result,
+ onFocusCells,
+ onFocusItem,
+}: {
+ size: number;
+ result: PuzzleQualityAnalysis;
+ onFocusCells: (cells: readonly number[]) => void;
+ onFocusItem: (item: QualityItemReference) => void;
+}) {
+ const witness = result.ambiguityWitness;
+ const minimality = result.minimality;
+ return (
+
+
+
+
+
Solution status
+
{solutionTitle(result.solutionStatus)}
+
+
+ {humanize(result.solutionStatus)}
+
+
+ {solutionDescription(result.solutionStatus)}
+
+
+ {witness !== undefined && (
+
+
+
+
Ambiguity witness
+
+ {witness.differences.length} differing cell
+ {witness.differences.length === 1 ? "" : "s"}
+
+
+
+ onFocusCells(witness.differences.map(({ cell }) => cell))
+ }
+ >
+ Focus differences
+
+
+
+ These values come from two concrete valid completions; they are not
+ guesses.
+
+
+
+
+
+ Cell
+ Solution A
+ Solution B
+
+
+
+ {witness.differences.map(({ cell, first, second }) => (
+
+
+ onFocusCells([cell])}
+ >
+ {cellName(cell, size)}
+
+
+ {symbolFor(first, size)}
+ {symbolFor(second, size)}
+
+ ))}
+
+
+
+
+ )}
+
+
+
+
+
Contradiction localization
+
+ {result.contradiction.status === "localized"
+ ? "Contradiction suspects localized"
+ : result.contradiction.status === "incomplete"
+ ? "Incomplete / unknown"
+ : "Not applicable"}
+
+
+
+ {humanize(result.contradiction.status)}
+
+
+ {result.contradiction.reason && {result.contradiction.reason}
}
+ {result.contradiction.status === "not-applicable" && (
+
+ Contradiction localization only applies after unsatisfiability is
+ proved.
+
+ )}
+
+
+
+
+
+
+
+
+
+ Leave-one-out evidence
+ Clue criticality and redundancy
+
+ Critical means removing that clue admits another completion. Redundant
+ means uniqueness was proved without it. Unknown always remains unknown
+ when a bound interrupts proof.
+
+ {result.analysisDepth === "baseline" ? (
+
+ Not analysed in the quick baseline. Run the full bounded minimality
+ analysis to classify individual clues.
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+
+ Criticality heatmap
+ Cell evidence
+
+ Every button states its evidence, so colour is never the only signal.
+ Select a cell to focus it on the puzzle.
+
+ {result.criticalityHeatmap.length === 0 ? (
+
+ {result.analysisDepth === "baseline"
+ ? "Not analysed in the quick baseline."
+ : "No cell-level evidence is available."}
+
+ ) : (
+
+ {result.criticalityHeatmap.map((entry) => {
+ const evidenceClass =
+ entry.score === null
+ ? "unknown"
+ : entry.criticalWeight > entry.redundantWeight
+ ? "critical"
+ : entry.redundantWeight > entry.criticalWeight
+ ? "redundant"
+ : entry.criticalWeight + entry.redundantWeight > 0
+ ? "mixed"
+ : "neutral";
+ const score =
+ entry.score === null ? "unknown" : entry.score.toFixed(2);
+ const label = `${cellName(entry.cell, size)}: ${evidenceClass}; score ${score}; critical weight ${String(entry.criticalWeight)}, redundant weight ${String(entry.redundantWeight)}, unknown weight ${String(entry.unknownWeight)}`;
+ return (
+ onFocusCells([entry.cell])}
+ >
+ {cellName(entry.cell, size)}
+
+ {entry.score === null ? "?" : entry.score.toFixed(1)}
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+
Bounded minimality
+
+ {minimality === undefined
+ ? "Not requested"
+ : humanize(minimality.status)}
+
+
+
+ {minimality === undefined ? "Not run" : humanize(minimality.status)}
+
+
+ {minimality === undefined ? (
+
+ Minimality is unknown because the full bounded minimality pass was
+ not requested.
+
+ ) : (
+ <>
+ {minimality.reason && {minimality.reason}
}
+
+
+ >
+ )}
+
+
+
+ Bounded-search record
+ Checks and limits
+
+
+
Analysis depth
+
+ {result.analysisDepth === "baseline"
+ ? "Quick baseline"
+ : "Full setter QC"}
+
+
+
+
Analysis completeness
+
+ {result.budget.truncated
+ ? "Incomplete / unknown conclusions remain"
+ : "All requested checks completed"}
+
+
+
+
Checks performed / planned
+
+ {result.budget.checksPerformed.toLocaleString()} /{" "}
+ {result.budget.checksPlanned.toLocaleString()}
+
+
+
+
Recorded check details
+ {result.checks.length.toLocaleString()}
+
+
+
Nodes searched
+ {result.budget.nodes.toLocaleString()}
+
+
+
Elapsed
+ {result.budget.elapsedMs.toLocaleString()} ms
+
+
+
Unknown reasons
+
+ {result.budget.unknownReasons.length === 0
+ ? "None"
+ : result.budget.unknownReasons.map(humanize).join(", ")}
+
+
+
+
Per-check node / time bound
+
+ {result.bounds.perCheck.maxNodes.toLocaleString()} nodes /{" "}
+ {result.bounds.perCheck.timeoutMs.toLocaleString()} ms
+
+
+
+
Aggregate check / node / time bound
+
+ {result.bounds.aggregate.maxChecks.toLocaleString()} checks /{" "}
+ {result.bounds.aggregate.maxNodes.toLocaleString()} nodes /{" "}
+ {result.bounds.aggregate.timeoutMs.toLocaleString()} ms
+
+
+
+
+
+ );
+}
+
+export function SetterQualityLab({
+ size,
+ result,
+ running,
+ error,
+ initialOptions,
+ onRunQuick,
+ onRunMinimality,
+ onCancel,
+ onFocusCells,
+ onFocusItem,
+}: SetterQualityLabProps) {
+ const [controls, setControls] = useState(() => ({
+ perCheckMaxNodes:
+ initialOptions?.perCheckMaxNodes ?? DEFAULT_CONTROLS.perCheckMaxNodes,
+ perCheckTimeoutMs:
+ initialOptions?.perCheckTimeoutMs ?? DEFAULT_CONTROLS.perCheckTimeoutMs,
+ aggregateMaxChecks:
+ initialOptions?.aggregateMaxChecks ?? DEFAULT_CONTROLS.aggregateMaxChecks,
+ aggregateMaxNodes:
+ initialOptions?.aggregateMaxNodes ?? DEFAULT_CONTROLS.aggregateMaxNodes,
+ aggregateTimeoutMs:
+ initialOptions?.aggregateTimeoutMs ?? DEFAULT_CONTROLS.aggregateTimeoutMs,
+ }));
+
+ const options = (
+ analysisDepth: "baseline" | "full",
+ proveMinimality: boolean,
+ ): SetterQualityRunOptions => ({
+ ...controls,
+ analysisDepth,
+ proveMinimality,
+ });
+ const runQuick = (event: FormEvent) => {
+ event.preventDefault();
+ onRunQuick(options("baseline", false));
+ };
+ const setControl = (key: keyof QualityControls, value: string) => {
+ setControls((current) => ({
+ ...current,
+ [key]: boundedPositiveInteger(value, current[key], CONTROL_MAXIMUMS[key]),
+ }));
+ };
+
+ return (
+
+
+
Setter quality
+
Uniqueness, redundancy and minimality
+
+ Every claim is backed by a bounded local exact-search check. Reaching
+ a limit produces an explicit unknown result, never a guessed verdict.
+
+
+
+
+
+ {running !== undefined && (
+
+ {running === "quick"
+ ? "Quick quality check running locally…"
+ : "Full bounded minimality analysis running locally…"}
+
+ )}
+ {error !== undefined && (
+
+ {error}
+
+ )}
+ {result !== undefined && (
+
+ )}
+
+ );
+}
diff --git a/src/components/SudokuBoard.tsx b/src/components/SudokuBoard.tsx
index a058548..9842e7a 100644
--- a/src/components/SudokuBoard.tsx
+++ b/src/components/SudokuBoard.tsx
@@ -4,9 +4,24 @@ import {
type KeyboardEvent,
type PointerEvent,
} from "react";
+import {
+ constraintCells,
+ littleKillerCells,
+ outsideLineCells,
+ type LittleKillerDirection,
+} from "../domain";
import type { NormalizedPuzzle, VariantConstraint } from "../domain/types";
import type { CandidateNode, CandidateOverlay } from "../helpers";
+import type { SafeVisualPrimitive } from "../formats";
+import type { CellCriticality } from "../solver";
+import { navigateGridCell } from "../state/gridNavigation";
import { maskValues, symbolFor } from "../state/session";
+import {
+ colorMarkDescription,
+ type CandidateVerbosity,
+} from "../state/uiPreferences";
+import { foggedCellsForPuzzle } from "./fogVisibility";
+import { SafeVisualLayer } from "./SafeVisualLayer";
interface SudokuBoardProps {
puzzle: NormalizedPuzzle;
@@ -21,6 +36,11 @@ interface SudokuBoardProps {
activeCell: number;
showCandidates?: boolean;
candidateOverlay?: CandidateOverlay;
+ guidedHintOverlay?: GuidedHintBoardOverlay;
+ qualityHeatmap?: readonly CellCriticality[];
+ candidateVerbosity?: CandidateVerbosity;
+ /** Normalized, inert source drawings retained by the document adapter. */
+ visuals?: readonly SafeVisualPrimitive[];
onCellPointerDown: (
cell: number,
event: PointerEvent,
@@ -32,6 +52,18 @@ interface SudokuBoardProps {
onKeyDown: (event: KeyboardEvent) => void;
}
+export interface GuidedHintBoardOverlay {
+ readonly focusCells: readonly number[];
+ readonly placements?: readonly {
+ readonly cell: number;
+ readonly value: number;
+ }[];
+ readonly eliminations?: readonly {
+ readonly cell: number;
+ readonly values: readonly number[];
+ }[];
+}
+
function candidatePoint(size: number, node: CandidateNode) {
const columns = Math.ceil(Math.sqrt(size));
const rows = Math.ceil(size / columns);
@@ -100,6 +132,21 @@ function point(size: number, cell: number) {
};
}
+function chevronToward(
+ from: { readonly x: number; readonly y: number },
+ toward: { readonly x: number; readonly y: number },
+) {
+ const distance = Math.hypot(toward.x - from.x, toward.y - from.y) || 1;
+ const vector = {
+ x: (toward.x - from.x) / distance,
+ y: (toward.y - from.y) / distance,
+ };
+ const tip = { x: from.x + vector.x * 0.34, y: from.y + vector.y * 0.34 };
+ const back = { x: tip.x - vector.x * 0.14, y: tip.y - vector.y * 0.14 };
+ const perpendicular = { x: -vector.y * 0.09, y: vector.x * 0.09 };
+ return `M${String(back.x + perpendicular.x)} ${String(back.y + perpendicular.y)}L${String(tip.x)} ${String(tip.y)}L${String(back.x - perpendicular.x)} ${String(back.y - perpendicular.y)}`;
+}
+
function polyline(size: number, cells: readonly number[]) {
return cells
.map((cell) => {
@@ -126,6 +173,45 @@ function outsidePoint(
}
}
+function littleKillerVector(direction: LittleKillerDirection) {
+ switch (direction) {
+ case "down-right":
+ return { x: 1, y: 1 };
+ case "down-left":
+ return { x: -1, y: 1 };
+ case "up-right":
+ return { x: 1, y: -1 };
+ case "up-left":
+ return { x: -1, y: -1 };
+ }
+}
+
+function littleKillerArrowPath(
+ origin: { readonly x: number; readonly y: number },
+ direction: LittleKillerDirection,
+) {
+ const raw = littleKillerVector(direction);
+ const scale = 1 / Math.sqrt(2);
+ const vector = { x: raw.x * scale, y: raw.y * scale };
+ const start = {
+ x: origin.x + vector.x * 0.34,
+ y: origin.y + vector.y * 0.34,
+ };
+ const tip = {
+ x: origin.x + vector.x * 0.8,
+ y: origin.y + vector.y * 0.8,
+ };
+ const back = {
+ x: tip.x - vector.x * 0.14,
+ y: tip.y - vector.y * 0.14,
+ };
+ const perpendicular = { x: -vector.y * 0.09, y: vector.x * 0.09 };
+ return {
+ line: `M${String(start.x)} ${String(start.y)}L${String(tip.x)} ${String(tip.y)}`,
+ tip: `M${String(back.x + perpendicular.x)} ${String(back.y + perpendicular.y)}L${String(tip.x)} ${String(tip.y)}L${String(back.x - perpendicular.x)} ${String(back.y - perpendicular.y)}`,
+ };
+}
+
function boundaryPath(size: number, cells: ReadonlySet, inset: number) {
const commands: string[] = [];
for (const cell of cells) {
@@ -168,6 +254,8 @@ function describeConstraint(
return "Anti-king rule.";
case "non-consecutive":
return "Non-consecutive orthogonal neighbours rule.";
+ case "disjoint-groups":
+ return "Disjoint groups rule: corresponding positions in every standard box contain each digit once.";
case "killer-cage":
return `${polarity}killer cage ${String(constraint.sum)} at ${cells(constraint.cells)}.`;
case "thermo":
@@ -192,6 +280,45 @@ function describeConstraint(
return `${polarity}quadruple digits ${constraint.digits.map((digit) => symbolFor(digit, size)).join(", ")} touching ${cells(constraint.cells)}.`;
case "maximum":
return `${polarity}maximum at ${cell(constraint.cell)}.`;
+ case "minimum":
+ return `${polarity}minimum at ${cell(constraint.cell)}.`;
+ case "odd":
+ return `${polarity}odd digit at ${cell(constraint.cell)}.`;
+ case "even":
+ return `${polarity}even digit at ${cell(constraint.cell)}.`;
+ case "little-killer":
+ return `${polarity}little killer sum ${String(constraint.sum)} from the ${constraint.side}, ${constraint.side === "left" || constraint.side === "right" ? "row" : "column"} ${String(constraint.index + 1)}, travelling ${constraint.direction.replace("-", " ")}.`;
+ case "sandwich":
+ return `${polarity}sandwich sum ${String(constraint.sum)} from the ${constraint.side}, ${constraint.side === "left" || constraint.side === "right" ? "row" : "column"} ${String(constraint.index + 1)}, between 1 and ${symbolFor(size, size)}.`;
+ case "between-line":
+ return `${polarity}between line from ${cell(constraint.cells[0]!)} through ${cells(constraint.cells.slice(1, -1))} to ${cell(constraint.cells.at(-1)!)}; interior digits lie strictly between the endpoint values.`;
+ case "german-whisper":
+ return `${polarity}German whisper through ${cells(constraint.cells)}; adjacent digits differ by at least ${String(constraint.minimumDifference ?? Math.ceil(size / 2))}.`;
+ case "region-sum-line":
+ return `${polarity}region-sum line through ${cells(constraint.cells)}; each contiguous region segment has the same sum.`;
+ case "clone":
+ return `${polarity}clone regions pairing ${cells(constraint.cells)} with ${cells(constraint.cloneCells)} in order.`;
+ case "extra-region":
+ return `Extra region through ${cells(constraint.cells)}; every digit appears exactly once.`;
+ case "modular-line":
+ return `${polarity}modular line through ${cells(constraint.cells)}; every three consecutive cells contain all three residues modulo 3.`;
+ case "entropic-line":
+ return `${polarity}entropic line through ${cells(constraint.cells)}; every three consecutive cells contain one low, one middle, and one high digit.`;
+ case "zipper-line":
+ return `${polarity}zipper line through ${cells(constraint.cells)}; pairs equidistant from the centre sum to the centre digit.`;
+ case "double-arrow":
+ return `${polarity}double arrow from ${cell(constraint.cells[0]!)} through ${cells(constraint.cells.slice(1, -1))} to ${cell(constraint.cells.at(-1)!)}; the endpoint digits sum to the interior digits combined.`;
+ case "indexer": {
+ const rule =
+ constraint.kind === "row"
+ ? "the marker digit selects a row in the same column, whose target contains the marker row number"
+ : constraint.kind === "column"
+ ? "the marker digit selects a column in the same row, whose target contains the marker column number"
+ : "the marker digit selects a box at the same position, whose target contains the marker box number";
+ return `${polarity}${constraint.kind} indexer at ${cell(constraint.cell)}; ${rule}.`;
+ }
+ case "fog":
+ return `Fog of War with ${String(constraint.lights.length)} initial light${constraint.lights.length === 1 ? "" : "s"} and reveal radius ${String(constraint.revealRadius ?? 1)}; correct entries reveal nearby cells.`;
}
}
@@ -213,7 +340,19 @@ function constraintTouchesCell(
case "renban":
case "palindrome":
case "quadruple":
+ case "between-line":
+ case "german-whisper":
+ case "region-sum-line":
+ case "extra-region":
+ case "modular-line":
+ case "entropic-line":
+ case "zipper-line":
+ case "double-arrow":
return constraint.cells.includes(cell);
+ case "clone":
+ return (
+ constraint.cells.includes(cell) || constraint.cloneCells.includes(cell)
+ );
case "arrow":
return constraint.bulb.includes(cell) || constraint.line.includes(cell);
case "kropki":
@@ -222,18 +361,30 @@ function constraintTouchesCell(
case "inequality":
return constraint.lesser === cell || constraint.greater === cell;
case "maximum":
+ case "minimum":
+ case "odd":
+ case "even":
return constraint.cell === cell;
+ case "indexer":
+ return constraintCells(size, constraint).includes(cell);
case "x-sum":
- case "skyscraper": {
- const row = Math.floor(cell / size);
- const column = cell % size;
- return constraint.side === "left" || constraint.side === "right"
- ? row === constraint.index
- : column === constraint.index;
- }
+ case "skyscraper":
+ case "sandwich":
+ return outsideLineCells(size, constraint.side, constraint.index).includes(
+ cell,
+ );
+ case "little-killer":
+ return littleKillerCells(
+ size,
+ constraint.side,
+ constraint.index,
+ constraint.direction,
+ ).includes(cell);
case "anti-knight":
case "anti-king":
case "non-consecutive":
+ case "disjoint-groups":
+ case "fog":
return false;
}
}
@@ -248,6 +399,12 @@ function cellAccessibleDescription({
color,
conflict,
candidateHighlights,
+ hintFocus,
+ hintPlacement,
+ hintEliminations,
+ quality,
+ foggedCells,
+ candidateVerbosity,
}: {
puzzle: NormalizedPuzzle;
cell: number;
@@ -258,36 +415,74 @@ function cellAccessibleDescription({
color: number;
conflict: boolean;
candidateHighlights: readonly number[];
+ hintFocus: boolean;
+ hintPlacement?: number;
+ hintEliminations: readonly number[];
+ quality?: CellCriticality;
+ foggedCells: ReadonlySet;
+ candidateVerbosity: CandidateVerbosity;
}): string {
const parts = [`Region ${String((puzzle.regions[cell] ?? 0) + 1)}`];
if (value !== 0) {
parts.push(puzzle.givens[cell] ? "Given digit" : "Entered digit");
} else {
- if (corner.length > 0) {
+ if (corner.length > 0 && candidateVerbosity !== "off") {
parts.push(
- `corner notes ${corner.map((digit) => symbolFor(digit, puzzle.size)).join(", ")}`,
+ candidateVerbosity === "detailed"
+ ? `corner notes ${corner.map((digit) => symbolFor(digit, puzzle.size)).join(", ")}`
+ : `${String(corner.length)} corner note${corner.length === 1 ? "" : "s"}`,
);
}
- if (center.length > 0) {
+ if (center.length > 0 && candidateVerbosity !== "off") {
+ const label = centerIsAutomatic ? "automatic candidate" : "centre note";
parts.push(
- `${centerIsAutomatic ? "automatic candidates" : "centre notes"} ${center
- .map((digit) => symbolFor(digit, puzzle.size))
- .join(", ")}`,
+ candidateVerbosity === "detailed"
+ ? `${label}${center.length === 1 ? "" : "s"} ${center.map((digit) => symbolFor(digit, puzzle.size)).join(", ")}`
+ : `${String(center.length)} ${label}${center.length === 1 ? "" : "s"}`,
);
}
}
- if (color > 0) parts.push(`colour ${String(color)}`);
+ if (color > 0) {
+ parts.push(`colour ${String(color)}: ${colorMarkDescription(color)}`);
+ }
if (conflict) parts.push("conflict");
- if (candidateHighlights.length > 0) {
+ if (candidateHighlights.length > 0 && candidateVerbosity !== "off") {
parts.push(
- `Candidate Lab highlights ${candidateHighlights
- .map((digit) => symbolFor(digit, puzzle.size))
- .join(", ")}`,
+ candidateVerbosity === "detailed"
+ ? `Candidate Lab highlights ${candidateHighlights.map((digit) => symbolFor(digit, puzzle.size)).join(", ")}`
+ : `Candidate Lab highlights ${String(candidateHighlights.length)} candidate${candidateHighlights.length === 1 ? "" : "s"}`,
);
}
+ if (hintFocus) parts.push("guided hint focus");
+ if (hintPlacement !== undefined) {
+ parts.push(`hint preview: place ${symbolFor(hintPlacement, puzzle.size)}`);
+ }
+ if (hintEliminations.length > 0 && candidateVerbosity !== "off") {
+ parts.push(
+ candidateVerbosity === "detailed"
+ ? `hint preview: remove ${hintEliminations.map((digit) => symbolFor(digit, puzzle.size)).join(", ")} from the candidates`
+ : `hint preview: remove ${String(hintEliminations.length)} candidate${hintEliminations.length === 1 ? "" : "s"}`,
+ );
+ }
+ if (quality !== undefined) {
+ if (quality.score === null && quality.unknownWeight > 0) {
+ parts.push(
+ `setter quality unknown from ${String(quality.unknownWeight)} bounded check${quality.unknownWeight === 1 ? "" : "s"}`,
+ );
+ } else if (quality.score !== null) {
+ parts.push(
+ `setter criticality ${String(Math.round(quality.score * 100))} percent; ${String(quality.criticalWeight)} critical, ${String(quality.redundantWeight)} redundant, ${String(quality.unknownWeight)} unknown checks`,
+ );
+ }
+ }
- const localConstraints = puzzle.constraints.filter((constraint) =>
- constraintTouchesCell(constraint, cell, puzzle.size),
+ const localConstraints = puzzle.constraints.filter(
+ (constraint) =>
+ constraint.type !== "fog" &&
+ constraintTouchesCell(constraint, cell, puzzle.size) &&
+ constraintCells(puzzle.size, constraint).every(
+ (clueCell) => !foggedCells.has(clueCell),
+ ),
);
if (localConstraints.length > 0) {
parts.push(
@@ -299,12 +494,28 @@ function cellAccessibleDescription({
return parts.join(", ");
}
-function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
+function ConstraintLayer({
+ puzzle,
+ foggedCells,
+ visuals,
+}: {
+ puzzle: NormalizedPuzzle;
+ foggedCells: ReadonlySet;
+ visuals: readonly SafeVisualPrimitive[];
+}) {
const { size } = puzzle;
const regionCells = Array.from({ length: size }, () => new Set());
puzzle.regions.forEach((region, cell) => regionCells[region]?.add(cell));
const renderConstraint = (constraint: VariantConstraint, index: number) => {
+ if (constraint.type === "fog") return null;
+ const affectedCells = constraintCells(size, constraint);
+ if (
+ affectedCells.length > 0 &&
+ affectedCells.every((cell) => foggedCells.has(cell))
+ ) {
+ return null;
+ }
const polarityClass =
"negated" in constraint && constraint.negated === true
? " is-negated"
@@ -321,6 +532,21 @@ function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
/>
);
}
+ if (constraint.type === "disjoint-groups") {
+ if (
+ puzzle.constraints.findIndex(({ type }) => type === constraint.type) !==
+ index
+ ) {
+ return null;
+ }
+ return (
+
+ {regionCells.map((cells, region) => (
+
+ ))}
+
+ );
+ }
if (constraint.type === "killer-cage") {
const cells = new Set(constraint.cells);
const first = Math.min(...constraint.cells);
@@ -349,6 +575,175 @@ function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
);
}
+ if (constraint.type === "between-line") {
+ const first = point(size, constraint.cells[0]!);
+ const last = point(size, constraint.cells.at(-1)!);
+ return (
+
+
+
+
+ {constraint.negated === true && (
+
+ ≠
+
+ )}
+
+ );
+ }
+ if (constraint.type === "german-whisper") {
+ return (
+
+
+
+ );
+ }
+ if (constraint.type === "region-sum-line") {
+ const dividers = constraint.cells.flatMap((cell, position) => {
+ const next = constraint.cells[position + 1];
+ if (
+ next === undefined ||
+ puzzle.regions[cell] === puzzle.regions[next]
+ ) {
+ return [];
+ }
+ const a = point(size, cell);
+ const b = point(size, next);
+ return [{ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }];
+ });
+ return (
+
+
+ {dividers.map((divider, dividerIndex) => (
+
+ ))}
+
+ );
+ }
+ if (
+ constraint.type === "modular-line" ||
+ constraint.type === "entropic-line" ||
+ constraint.type === "zipper-line"
+ ) {
+ const first = point(size, constraint.cells[0]!);
+ const centre = point(
+ size,
+ constraint.cells[Math.floor(constraint.cells.length / 2)]!,
+ );
+ return (
+
+ {constraint.type === "entropic-line" && (
+
+ )}
+
+ {constraint.type === "modular-line" &&
+ constraint.cells.map((cell, markerIndex) => {
+ const marker = point(size, cell);
+ return (
+
+ );
+ })}
+ {constraint.type === "zipper-line" && (
+
+ )}
+ {constraint.negated === true && (
+
+ ≠
+
+ )}
+
+ );
+ }
+ if (constraint.type === "double-arrow") {
+ const first = point(size, constraint.cells[0]!);
+ const second = point(size, constraint.cells[1]!);
+ const last = point(size, constraint.cells.at(-1)!);
+ const penultimate = point(size, constraint.cells.at(-2)!);
+ return (
+
+
+
+
+
+
+ {constraint.negated === true && (
+
+ ≠
+
+ )}
+
+ );
+ }
+ if (constraint.type === "indexer") {
+ const centre = point(size, constraint.cell);
+ const className = `constraint-indexer constraint-indexer--${constraint.kind}${polarityClass}`;
+ return (
+
+ {constraint.kind === "row" ? (
+
+ ) : constraint.kind === "column" ? (
+
+ ) : (
+
+ )}
+
+ {constraint.negated === true ? "≠" : ""}
+ {constraint.kind === "row"
+ ? "R"
+ : constraint.kind === "column"
+ ? "C"
+ : "B"}
+
+
+ );
+ }
if (constraint.type === "arrow") {
const bulb = point(size, constraint.bulb[0]!);
const end = point(size, constraint.line.at(-1)!);
@@ -361,22 +756,159 @@ function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
);
}
- if (constraint.type === "maximum") {
+ if (constraint.type === "maximum" || constraint.type === "minimum") {
const center = point(size, constraint.cell);
+ const inward = constraint.type === "minimum";
return (
-
+
);
}
+ if (constraint.type === "odd" || constraint.type === "even") {
+ const center = point(size, constraint.cell);
+ return (
+
+ {constraint.type === "odd" ? (
+
+ ) : (
+
+ )}
+ {constraint.negated === true && (
+
+ ≠
+
+ )}
+
+ );
+ }
+ if (constraint.type === "clone") {
+ const source = new Set(constraint.cells);
+ const clone = new Set(constraint.cloneCells);
+ const sourcePoints = constraint.cells.map((cell) => point(size, cell));
+ const clonePoints = constraint.cloneCells.map((cell) =>
+ point(size, cell),
+ );
+ const sourceCenter = {
+ x:
+ sourcePoints.reduce((sum, position) => sum + position.x, 0) /
+ sourcePoints.length,
+ y:
+ sourcePoints.reduce((sum, position) => sum + position.y, 0) /
+ sourcePoints.length,
+ };
+ const cloneCenter = {
+ x:
+ clonePoints.reduce((sum, position) => sum + position.x, 0) /
+ clonePoints.length,
+ y:
+ clonePoints.reduce((sum, position) => sum + position.y, 0) /
+ clonePoints.length,
+ };
+ return (
+
+ {[...constraint.cells, ...constraint.cloneCells].map((cell) => {
+ const row = Math.floor(cell / size);
+ const column = cell % size;
+ return (
+
+ );
+ })}
+
+
+
+
+ {constraint.negated === true ? "≠" : ""}A
+
+
+ A′
+
+
+ );
+ }
+ if (constraint.type === "extra-region") {
+ const region = new Set(constraint.cells);
+ const first = point(size, Math.min(...constraint.cells));
+ return (
+
+ {constraint.cells.map((cell) => {
+ const row = Math.floor(cell / size);
+ const column = cell % size;
+ return (
+
+ );
+ })}
+
+
+ +
+
+
+ );
+ }
if (constraint.type === "quadruple") {
const positions = constraint.cells.map((cell) => point(size, cell));
const center = {
@@ -446,6 +978,48 @@ function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
);
}
+ if (constraint.type === "sandwich") {
+ const position = outsidePoint(size, constraint.side, constraint.index);
+ return (
+
+
+
+ 1⋯N
+
+
+ {constraint.negated === true ? "≠" : ""}
+ {constraint.sum}
+
+
+ );
+ }
+ if (constraint.type === "little-killer") {
+ const position = outsidePoint(size, constraint.side, constraint.index);
+ const arrow = littleKillerArrowPath(position, constraint.direction);
+ return (
+
+
+
+
+ {constraint.negated === true ? "≠" : ""}
+ {constraint.sum}
+
+
+
+
+
+ );
+ }
if (constraint.type === "renban" || constraint.type === "palindrome") {
return (
+
{regionCells.map((cells, index) => (
))}
{puzzle.constraints.map(renderConstraint)}
+
+ {foggedCells.size > 0 && (
+
+ {[...foggedCells].map((cell) => {
+ const row = Math.floor(cell / size);
+ const column = cell % size;
+ return ;
+ })}
+
+ )}
);
}
@@ -548,6 +1133,10 @@ export function SudokuBoard({
activeCell,
showCandidates,
candidateOverlay,
+ guidedHintOverlay,
+ qualityHeatmap,
+ candidateVerbosity = "detailed",
+ visuals = [],
onCellPointerDown,
onCellPointerEnter,
onKeyDown,
@@ -555,12 +1144,89 @@ export function SudokuBoard({
const { size } = puzzle;
const constraintDescriptionId = useId();
const navigationDescriptionId = useId();
+ const foggedCells = foggedCellsForPuzzle(puzzle, values);
+ const clueIsRevealed = (constraint: VariantConstraint) =>
+ constraint.type === "fog" ||
+ constraintCells(size, constraint).every((cell) => !foggedCells.has(cell));
const hasOutsideClues = puzzle.constraints.some(
- ({ type }) => type === "x-sum" || type === "skyscraper",
+ (constraint) =>
+ clueIsRevealed(constraint) &&
+ (constraint.type === "x-sum" ||
+ constraint.type === "skyscraper" ||
+ constraint.type === "little-killer" ||
+ constraint.type === "sandwich"),
);
const constraintDescription = puzzle.constraints
+ .filter(clueIsRevealed)
.map((constraint) => describeConstraint(constraint, size))
.join(" ");
+ const hintFocusCells = new Set(guidedHintOverlay?.focusCells ?? []);
+ const hintPlacements = new Map(
+ (guidedHintOverlay?.placements ?? []).map((placement) => [
+ placement.cell,
+ placement.value,
+ ]),
+ );
+ const hintEliminations = new Map(
+ (guidedHintOverlay?.eliminations ?? []).map((elimination) => [
+ elimination.cell,
+ elimination.values,
+ ]),
+ );
+ const visibleCandidateOverlay =
+ candidateOverlay === undefined
+ ? undefined
+ : {
+ ...candidateOverlay,
+ candidateCells: candidateOverlay.candidateCells.filter(
+ (cell) => !foggedCells.has(cell),
+ ),
+ links: candidateOverlay.links.filter(
+ ({ a, b }) => !foggedCells.has(a.cell) && !foggedCells.has(b.cell),
+ ),
+ };
+ const handleBoardKeyDown = (event: KeyboardEvent) => {
+ if (foggedCells.size > 0) {
+ const command = event.ctrlKey || event.metaKey;
+ if (command && event.key.toLowerCase() === "a") {
+ event.preventDefault();
+ event.stopPropagation();
+ return;
+ }
+ const eventCell = Number(
+ (event.target as HTMLElement).closest("[data-cell]")
+ ?.dataset.cell ?? activeCell,
+ );
+ const nextCell = navigateGridCell(eventCell, size, event.key, command);
+ if (nextCell !== null && foggedCells.has(nextCell)) {
+ event.preventDefault();
+ event.stopPropagation();
+ return;
+ }
+ const selectionContainsFog = [...selected].some((cell) =>
+ foggedCells.has(cell),
+ );
+ const mayChangeSelectedCells =
+ event.key === "Backspace" ||
+ event.key === "Delete" ||
+ event.key === "0" ||
+ (!command && /^[1-9A-G]$/iu.test(event.key));
+ if (selectionContainsFog && mayChangeSelectedCells) {
+ event.preventDefault();
+ event.stopPropagation();
+ return;
+ }
+ if (
+ foggedCells.has(eventCell) &&
+ !(command && ["z", "y"].includes(event.key.toLowerCase()))
+ ) {
+ event.preventDefault();
+ event.stopPropagation();
+ return;
+ }
+ }
+ onKeyDown(event);
+ };
return (
{Array.from({ length: size }, (_, rowIndex) => (
{Array.from({ length: size }, (_, columnIndex) => {
const cell = rowIndex * size + columnIndex;
+ const fogged = foggedCells.has(cell);
const value = values[cell] ?? 0;
const isGiven = Boolean(puzzle.givens[cell]);
const corner = maskValues(cornerMarks[cell] ?? 0, size);
@@ -608,27 +1275,56 @@ export function SudokuBoard({
userCenterMask === 0 && automaticMask !== 0;
const center = maskValues(userCenterMask || automaticMask, size);
const candidateHighlighted =
- candidateOverlay?.candidateCells.includes(cell) ?? false;
+ !fogged &&
+ (visibleCandidateOverlay?.candidateCells.includes(cell) ??
+ false);
const candidateMask = candidates[cell] ?? 0;
const candidateHighlights = candidateHighlighted
- ? (candidateOverlay?.activeValues.filter(
+ ? (visibleCandidateOverlay?.activeValues.filter(
(digit) =>
candidateMask === 0 ||
(candidateMask & (1 << (digit - 1))) !== 0,
) ?? [])
: [];
- const conflict = conflicts.has(cell);
- const accessibleDescription = cellAccessibleDescription({
- puzzle,
- cell,
- value,
- corner,
- center,
- centerIsAutomatic,
- color: colors[cell] ?? 0,
- conflict,
- candidateHighlights,
- });
+ const conflict = !fogged && conflicts.has(cell);
+ const hintFocus = !fogged && hintFocusCells.has(cell);
+ const hintPlacement = fogged
+ ? undefined
+ : hintPlacements.get(cell);
+ const hintRemovedValues = fogged
+ ? []
+ : (hintEliminations.get(cell) ?? []);
+ const quality = fogged ? undefined : qualityHeatmap?.[cell];
+ const qualityClass =
+ quality === undefined ||
+ (quality.score === null && quality.unknownWeight === 0)
+ ? ""
+ : quality.score === null
+ ? "is-quality-unknown"
+ : quality.score === 1
+ ? "is-quality-critical"
+ : quality.score === 0
+ ? "is-quality-redundant"
+ : "is-quality-mixed";
+ const accessibleDescription = fogged
+ ? "Obscured by Fog of War. This cell cannot be selected until revealed."
+ : cellAccessibleDescription({
+ puzzle,
+ cell,
+ value,
+ corner,
+ center,
+ centerIsAutomatic,
+ color: colors[cell] ?? 0,
+ conflict,
+ candidateHighlights,
+ hintFocus,
+ hintPlacement,
+ hintEliminations: hintRemovedValues,
+ quality,
+ foggedCells,
+ candidateVerbosity,
+ });
return (
onCellPointerDown(cell, event)}
- onPointerEnter={(event) => onCellPointerEnter(cell, event)}
+ aria-selected={fogged ? false : selected.has(cell)}
+ aria-readonly={fogged || isGiven}
+ aria-invalid={!fogged && conflict ? true : undefined}
+ disabled={fogged}
+ tabIndex={!fogged && cell === activeCell ? 0 : -1}
+ className={
+ fogged
+ ? "sudoku-cell is-fogged"
+ : [
+ "sudoku-cell",
+ isGiven ? "is-given" : "",
+ highlighted.has(cell) ? "is-digit-highlighted" : "",
+ candidateHighlighted
+ ? "is-candidate-highlighted"
+ : "",
+ hintFocus ? "is-hint-focus" : "",
+ hintPlacement !== undefined
+ ? "is-hint-placement"
+ : "",
+ hintRemovedValues.length > 0
+ ? "is-hint-elimination"
+ : "",
+ qualityClass,
+ selected.has(cell) ? "is-selected" : "",
+ conflict ? "has-conflict" : "",
+ colors[cell]
+ ? `has-color-${String(colors[cell])}`
+ : "",
+ ]
+ .filter(Boolean)
+ .join(" ")
+ }
+ onPointerDown={(event) => {
+ if (!fogged) onCellPointerDown(cell, event);
+ }}
+ onPointerEnter={(event) => {
+ if (!fogged) onCellPointerEnter(cell, event);
+ }}
data-cell={cell}
>
- {value ? (
- {symbolFor(value, size)}
- ) : (
+ {fogged ? null : (
<>
-
- {corner.map((mark) => (
- {symbolFor(mark, size)}
- ))}
-
-
- {center.map((mark) => symbolFor(mark, size)).join("")}
-
+ {(colors[cell] ?? 0) > 0 && (
+
+ )}
+ {value ? (
+
+ {symbolFor(value, size)}
+
+ ) : (
+ <>
+
+ {corner.map((mark) => (
+ {symbolFor(mark, size)}
+ ))}
+
+
+ {center
+ .map((mark) => symbolFor(mark, size))
+ .join("")}
+
+ {hintPlacement !== undefined && (
+
+ {symbolFor(hintPlacement, size)}
+
+ )}
+ {hintRemovedValues.length > 0 && (
+
+ −
+ {hintRemovedValues
+ .map((mark) => symbolFor(mark, size))
+ .join("")}
+
+ )}
+ >
+ )}
>
)}
@@ -677,10 +1431,15 @@ export function SudokuBoard({
))}
-
- {candidateOverlay !== undefined && candidateOverlay.links.length > 0 && (
-
- )}
+
+ {visibleCandidateOverlay !== undefined &&
+ visibleCandidateOverlay.links.length > 0 && (
+
+ )}
);
}
diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx
index 13214af..32f9c59 100644
--- a/src/components/Workbench.tsx
+++ b/src/components/Workbench.tsx
@@ -10,6 +10,7 @@ import {
import {
allCandidates,
classicRegions,
+ constraintLabel,
createEmptyPuzzle,
findConflicts,
isSolved,
@@ -20,8 +21,11 @@ import {
} from "../domain";
import {
decodePuzzleHash,
+ extractPreservedDocumentExtras,
fromDomainPuzzle,
+ normalizeSafeVisualPrimitives,
toDomainPuzzle,
+ type PreservedSudokuDocumentExtras,
type SudokuDocument,
} from "../formats";
import type { CandidateOverlay } from "../helpers";
@@ -29,10 +33,17 @@ import { CLASSIC_SAMPLE, SAMPLE_CATALOG } from "../data/samples";
import type {
DifficultyAssessment,
ExactSolveResult,
+ GeneratedVariantBatch,
GeneratedVariantPuzzle,
+ GenerateVariantBatchOptions,
GenerateVariantOptions,
+ LogicalStep,
LogicalSolveResult,
+ PuzzleQualityAnalysis,
+ PuzzleQualityOptions,
+ QualityItemReference,
} from "../solver";
+import { qualityItemCells } from "../solver";
import {
createProjectLibrary,
createProjectRecord,
@@ -53,6 +64,12 @@ import {
type PlaySession,
type PlaySnapshot,
} from "../state/session";
+import {
+ applyLogicalStepToSession,
+ autoRemoveNotesAfterPlacements,
+ fillLegalCenterCandidates,
+ pruneInvalidNotes,
+} from "../state/candidateMaintenance";
import {
digitCompletions,
matchingDigitCells,
@@ -69,6 +86,10 @@ import {
type AidMemoireState,
} from "../state/aidMemoire";
import { navigateGridCell } from "../state/gridNavigation";
+import {
+ parseCandidateVerbosity,
+ type CandidateVerbosity,
+} from "../state/uiPreferences";
import {
activeHypothesis,
aidMemoireFromGameplayState,
@@ -79,22 +100,37 @@ import {
describeGameplayChange,
finishHypothesis,
gameplayMoment,
+ parseGameplayHistory,
recordGameplayMoment,
restoreSavepoint,
+ serializeGameplayHistory,
sessionFromGameplayState,
type GameplayHistory,
} from "../state/playHistory";
import { createSolverWorkerClient, type SolverWorkerClient } from "../workers";
import { ConstraintEditor } from "./ConstraintEditor";
import { AidMemoire } from "./AidMemoire";
+import { BoardViewport } from "./BoardViewport";
import { DigitCompletionBar } from "./DigitCompletionBar";
import { GameplayHistoryDialog } from "./GameplayHistoryDialog";
+import { GuidedHint } from "./GuidedHint";
+import { foggedCellsForPuzzle } from "./fogVisibility";
+import {
+ guidedHintOverlay,
+ guidedHintStepIsVisible,
+ nextGuidedHintStage,
+ type GuidedHintStage,
+} from "./guidedHint";
import { GeneratorWorkspace } from "./GeneratorWorkspace";
import { HelpersWorkspace } from "./HelpersWorkspace";
import { ImportExportDialog } from "./ImportExportDialog";
import { LibraryDialog } from "./LibraryDialog";
import { NumberPad } from "./NumberPad";
import { SolveWorkspace } from "./SolveWorkspace";
+import {
+ SetterQualityLab,
+ type SetterQualityRunKind,
+} from "./SetterQualityLab";
import { SudokuBoard } from "./SudokuBoard";
type Workspace = "play" | "set" | "generate" | "solve" | "helpers";
@@ -105,14 +141,19 @@ interface Feedback {
readonly message: string;
}
+type AutosaveStatus = "checking" | "idle" | "saving" | "saved" | "error";
+
interface HistoryEntry {
readonly puzzle: PuzzleDefinition;
readonly session: PlaySnapshot;
readonly aidMemoire: AidMemoireState;
+ readonly guidedCandidateTracking: boolean;
}
const library = createProjectLibrary();
const MAX_HISTORY = 100;
+const AUTOSAVE_DRAFT_ID = "autosave-current";
+const AUTOSAVE_DELAY_MS = 750;
function valuesToMask(values: readonly number[] | undefined): number {
return (values ?? []).reduce((mask, value) => mask | (1 << (value - 1)), 0);
@@ -174,6 +215,7 @@ function progressFromSession(
session: PlaySession,
completed: boolean,
aidMemoire: AidMemoireState,
+ gameplayHistory?: GameplayHistory,
): SudokuProgress {
return {
version: 1,
@@ -188,6 +230,14 @@ function progressFromSession(
elapsedMs: session.elapsedSeconds * 1_000,
completed,
aidMemoire: aidMemoireToPortable(aidMemoire, puzzle.size),
+ ...(gameplayHistory === undefined
+ ? {}
+ : {
+ gameplayHistory: serializeGameplayHistory(
+ gameplayHistory,
+ puzzle.size,
+ ),
+ }),
};
}
@@ -228,6 +278,7 @@ function initialProject(): {
readonly puzzle: PuzzleDefinition;
readonly session: PlaySession;
readonly aidMemoire: AidMemoireState;
+ readonly preservedExtras?: PreservedSudokuDocumentExtras;
} {
if (typeof location !== "undefined" && location.hash.includes("sudoku=")) {
try {
@@ -237,6 +288,7 @@ function initialProject(): {
puzzle,
session: sessionFromDocument(puzzle, document),
aidMemoire: aidMemoireFromPortable(document.aidMemoire, puzzle.size),
+ preservedExtras: extractPreservedDocumentExtras(document),
};
} catch {
// An invalid hash is non-fatal; the import dialog can report details.
@@ -284,14 +336,41 @@ function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "The operation failed.";
}
-function constraintLabel(type: string): string {
- if (type === "xv") return "Sum pairs (5/10)";
- if (type === "x-sum") return "X-sums";
- if (type === "false-clues") return "False clues";
- return type
- .split("-")
- .map((part) => part[0]?.toUpperCase() + part.slice(1))
- .join(" ");
+function useMediaQuery(query: string): boolean {
+ const [matches, setMatches] = useState(
+ () =>
+ typeof window !== "undefined" &&
+ typeof window.matchMedia === "function" &&
+ window.matchMedia(query).matches,
+ );
+ useEffect(() => {
+ if (typeof window.matchMedia !== "function") return undefined;
+ const media = window.matchMedia(query);
+ const update = (event: MediaQueryListEvent) => setMatches(event.matches);
+ media.addEventListener("change", update);
+ return () => media.removeEventListener("change", update);
+ }, [query]);
+ return matches;
+}
+
+function sameProjectContent(
+ left: SudokuProjectRecord,
+ right: SudokuProjectRecord,
+): boolean {
+ return (
+ JSON.stringify({
+ title: left.title,
+ puzzle: left.puzzle,
+ progress: left.progress,
+ tags: left.tags ?? [],
+ }) ===
+ JSON.stringify({
+ title: right.title,
+ puzzle: right.puzzle,
+ progress: right.progress,
+ tags: right.tags ?? [],
+ })
+ );
}
export function Workbench() {
@@ -299,14 +378,26 @@ export function Workbench() {
const [workspace, setWorkspace] = useState("play");
const [puzzle, setPuzzle] = useState(boot.puzzle);
const [session, setSession] = useState(boot.session);
+ const [preservedDocumentExtras, setPreservedDocumentExtras] = useState<
+ PreservedSudokuDocumentExtras | undefined
+ >(boot.preservedExtras);
const [selection, setSelection] = useState([0]);
const [activeCell, setActiveCell] = useState(0);
const [entryMode, setEntryMode] = useState("value");
const [showCandidates, setShowCandidates] = useState(false);
+ const [candidateVerbosity, setCandidateVerbosity] =
+ useState("detailed");
const [showConflicts, setShowConflicts] = useState(true);
const [showDigitCompletion, setShowDigitCompletion] = useState(true);
const [enableDigitHighlight, setEnableDigitHighlight] = useState(true);
+ const [tapMultiSelect, setTapMultiSelect] = useState(false);
const [highlightedDigit, setHighlightedDigit] = useState(null);
+ const [guidedHint, setGuidedHint] = useState();
+ const [guidedHintStage, setGuidedHintStage] =
+ useState("focus");
+ const [guidedHintError, setGuidedHintError] = useState();
+ const [guidedCandidateTracking, setGuidedCandidateTracking] = useState(false);
+ const [autoMaintainPeerNotes, setAutoMaintainPeerNotes] = useState(false);
const [candidateOverlay, setCandidateOverlay] = useState();
const [aidMemoire, setAidMemoire] = useState(
boot.aidMemoire,
@@ -325,7 +416,13 @@ export function Workbench() {
const [logical, setLogical] = useState();
const [exact, setExact] = useState();
const [difficulty, setDifficulty] = useState();
+ const [quality, setQuality] = useState();
+ const [qualityRunning, setQualityRunning] = useState();
+ const [qualityError, setQualityError] = useState();
const [generation, setGeneration] = useState();
+ const [generationBatch, setGenerationBatch] =
+ useState();
+ const [generationRunning, setGenerationRunning] = useState(false);
const [solveError, setSolveError] = useState();
const [importOpen, setImportOpen] = useState(false);
const [libraryOpen, setLibraryOpen] = useState(false);
@@ -337,13 +434,41 @@ export function Workbench() {
const [libraryBusy, setLibraryBusy] = useState(false);
const [libraryFeedback, setLibraryFeedback] = useState();
const [currentProjectId, setCurrentProjectId] = useState();
+ const [recoverableAutosave, setRecoverableAutosave] =
+ useState();
+ const [autosaveChecked, setAutosaveChecked] = useState(false);
+ const [autosaveStatus, setAutosaveStatus] =
+ useState("checking");
+ const [autosaveMessage, setAutosaveMessage] = useState(
+ "Checking for recoverable local work…",
+ );
const workerRef = useRef(null);
+ const qualityAbortRef = useRef(null);
+ const generationAbortRef = useRef(null);
const draggingRef = useRef(false);
const workbenchRef = useRef(null);
const replayReturnSessionRef = useRef(null);
const replayReturnAidMemoireRef = useRef(null);
+ const latestAutosaveStateRef = useRef({
+ puzzle,
+ session,
+ aidMemoire,
+ gameplayHistory,
+ preservedDocumentExtras,
+ solved: false,
+ currentProjectId,
+ });
+ const mobileLayout = useMediaQuery("(max-width: 48rem)");
const normalized = useMemo(() => safeNormalize(puzzle), [puzzle]);
+ const normalizedSourceVisuals = useMemo(
+ () =>
+ normalizeSafeVisualPrimitives(
+ preservedDocumentExtras?.visuals,
+ puzzle.size,
+ ) ?? [],
+ [preservedDocumentExtras?.visuals, puzzle.size],
+ );
const replayMoment = useMemo(
() =>
replayMomentId === undefined
@@ -355,7 +480,25 @@ export function Workbench() {
() => activeHypothesis(gameplayHistory),
[gameplayHistory],
);
- const selectedSet = useMemo(() => new Set(selection), [selection]);
+ const foggedCells = useMemo(
+ () => foggedCellsForPuzzle(normalized.puzzle, session.values),
+ [normalized.puzzle, session.values],
+ );
+ const effectiveSelection = useMemo(() => {
+ const visible = selection.filter((cell) => !foggedCells.has(cell));
+ if (visible.length > 0) return visible;
+ const firstVisible = session.values.findIndex((_, cell) =>
+ foggedCells.has(cell) ? false : true,
+ );
+ return firstVisible < 0 ? [] : [firstVisible];
+ }, [foggedCells, selection, session.values]);
+ const effectiveActiveCell = foggedCells.has(activeCell)
+ ? (effectiveSelection[0] ?? activeCell)
+ : activeCell;
+ const selectedSet = useMemo(
+ () => new Set(effectiveSelection),
+ [effectiveSelection],
+ );
const boardSelectedSet = useMemo(
() => (aidMemoireActive ? new Set() : selectedSet),
[aidMemoireActive, selectedSet],
@@ -387,6 +530,25 @@ export function Workbench() {
return session.values.map(() => 0);
}
}, [normalized, session.values]);
+ const visibleGuidedHint =
+ guidedHint !== undefined && guidedHintStepIsVisible(guidedHint, foggedCells)
+ ? guidedHint
+ : undefined;
+ const guidedBoardOverlay = useMemo(() => {
+ if (visibleGuidedHint === undefined) return undefined;
+ const overlay = guidedHintOverlay(visibleGuidedHint, guidedHintStage);
+ return {
+ focusCells: overlay.focusCells,
+ placements:
+ guidedHintStage === "preview"
+ ? visibleGuidedHint.placements
+ : undefined,
+ eliminations:
+ guidedHintStage === "preview"
+ ? visibleGuidedHint.eliminations
+ : undefined,
+ };
+ }, [guidedHintStage, visibleGuidedHint]);
const conflictData = useMemo(() => {
if (normalized.error) return [];
try {
@@ -411,12 +573,41 @@ export function Workbench() {
}
}, [normalized, session.values]);
+ useEffect(() => {
+ latestAutosaveStateRef.current = {
+ puzzle,
+ session,
+ aidMemoire,
+ gameplayHistory,
+ preservedDocumentExtras,
+ solved,
+ currentProjectId,
+ };
+ }, [
+ aidMemoire,
+ currentProjectId,
+ gameplayHistory,
+ preservedDocumentExtras,
+ puzzle,
+ session,
+ solved,
+ ]);
+
const clearAnalysis = useCallback(() => {
setLogical(undefined);
setExact(undefined);
setDifficulty(undefined);
setGeneration(undefined);
+ setGenerationBatch(undefined);
setSolveError(undefined);
+ setGuidedHint(undefined);
+ setGuidedHintStage("focus");
+ setGuidedHintError(undefined);
+ qualityAbortRef.current?.abort();
+ qualityAbortRef.current = null;
+ setQuality(undefined);
+ setQualityRunning(undefined);
+ setQualityError(undefined);
}, []);
const currentHistory = useCallback(
@@ -424,8 +615,9 @@ export function Workbench() {
puzzle,
session: snapshotSession(session),
aidMemoire: cloneAidMemoire(aidMemoire),
+ guidedCandidateTracking,
}),
- [aidMemoire, puzzle, session],
+ [aidMemoire, guidedCandidateTracking, puzzle, session],
);
const commit = useCallback(
@@ -447,6 +639,7 @@ export function Workbench() {
setReplayMomentId(undefined);
setPuzzle(nextPuzzle);
setSession(nextSession);
+ if (nextPuzzle !== puzzle) setGuidedCandidateTracking(false);
clearAnalysis();
setFeedback(undefined);
},
@@ -467,6 +660,7 @@ export function Workbench() {
| "aidMemoire"
>,
projectId?: string,
+ preservedExtras?: PreservedSudokuDocumentExtras,
): void => {
const valid = normalizePuzzle(nextPuzzle);
const nextSession = sessionFromDocument(valid, progress);
@@ -476,6 +670,7 @@ export function Workbench() {
);
setPuzzle(valid);
setSession(nextSession);
+ setPreservedDocumentExtras(preservedExtras);
setAidMemoire(nextAidMemoire);
setAidMemoireCell(0);
setAidMemoireActive(false);
@@ -487,6 +682,7 @@ export function Workbench() {
setSelection([0]);
setActiveCell(0);
setHighlightedDigit(null);
+ setGuidedCandidateTracking(false);
setPast([]);
setFuture([]);
setCurrentProjectId(projectId);
@@ -506,10 +702,15 @@ export function Workbench() {
);
setPuzzle(valid);
setSession(nextSession);
+ setPreservedDocumentExtras(extractPreservedDocumentExtras(record.puzzle));
setAidMemoire(nextAidMemoire);
setAidMemoireCell(0);
setAidMemoireActive(false);
- setGameplayHistory(createGameplayHistory(nextSession, nextAidMemoire));
+ setGameplayHistory(
+ record.progress?.gameplayHistory === undefined
+ ? createGameplayHistory(nextSession, nextAidMemoire)
+ : parseGameplayHistory(record.progress.gameplayHistory, valid.size),
+ );
replayReturnSessionRef.current = null;
replayReturnAidMemoireRef.current = null;
setReplayMomentId(undefined);
@@ -517,9 +718,12 @@ export function Workbench() {
setSelection([0]);
setActiveCell(0);
setHighlightedDigit(null);
+ setGuidedCandidateTracking(false);
setPast([]);
setFuture([]);
- setCurrentProjectId(record.id);
+ setCurrentProjectId(
+ record.id === AUTOSAVE_DRAFT_ID ? undefined : record.id,
+ );
clearAnalysis();
setFeedback({
kind: "success",
@@ -534,9 +738,134 @@ export function Workbench() {
setSummaries(await library.list());
}, []);
+ useEffect(() => {
+ let cancelled = false;
+ void (async () => {
+ try {
+ setLibraryMode(await library.ready());
+ const autosave = await library.getAutosave();
+ const explicit =
+ autosave !== undefined && autosave.id !== AUTOSAVE_DRAFT_ID
+ ? await library.get(autosave.id)
+ : undefined;
+ const recovered =
+ autosave !== undefined &&
+ explicit !== undefined &&
+ sameProjectContent(autosave, explicit)
+ ? undefined
+ : autosave;
+ if (cancelled) return;
+ setRecoverableAutosave(recovered);
+ setAutosaveStatus(recovered === undefined ? "idle" : "saved");
+ setAutosaveMessage(
+ recovered === undefined
+ ? "Autosave is ready."
+ : "Recoverable local work is available.",
+ );
+ } catch (error) {
+ if (cancelled) return;
+ setAutosaveStatus("error");
+ setAutosaveMessage(`Autosave unavailable: ${errorMessage(error)}`);
+ } finally {
+ if (!cancelled) setAutosaveChecked(true);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const autosaveElapsedBucket = Math.floor(session.elapsedSeconds / 30);
+ useEffect(() => {
+ if (
+ !autosaveChecked ||
+ recoverableAutosave !== undefined ||
+ replayMomentId !== undefined
+ ) {
+ return;
+ }
+ let cancelled = false;
+ const timer = window.setTimeout(() => {
+ setAutosaveStatus("saving");
+ setAutosaveMessage("Saving work locally…");
+ void (async () => {
+ try {
+ const latest = latestAutosaveStateRef.current;
+ const now = Date.now();
+ const document = fromDomainPuzzle(
+ latest.puzzle,
+ latest.preservedDocumentExtras,
+ );
+ const progress = progressFromSession(
+ latest.puzzle,
+ latest.session,
+ latest.solved,
+ latest.aidMemoire,
+ latest.gameplayHistory,
+ );
+ const existing = latest.currentProjectId
+ ? await library.get(latest.currentProjectId)
+ : undefined;
+ const record =
+ existing === undefined
+ ? createProjectRecord(document, {
+ id: latest.currentProjectId ?? AUTOSAVE_DRAFT_ID,
+ title: latest.puzzle.title?.trim() || "Untitled puzzle",
+ progress,
+ now,
+ })
+ : {
+ ...existing,
+ title: latest.puzzle.title?.trim() || "Untitled puzzle",
+ updatedAt: now,
+ puzzle: document,
+ progress,
+ thumbnail: undefined,
+ };
+ await library.putAutosave(record);
+ if (cancelled) return;
+ setAutosaveStatus("saved");
+ setAutosaveMessage(
+ `Saved locally at ${new Intl.DateTimeFormat(undefined, {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ }).format(now)}.`,
+ );
+ } catch (error) {
+ if (cancelled) return;
+ setAutosaveStatus("error");
+ setAutosaveMessage(`Autosave failed: ${errorMessage(error)}`);
+ }
+ })();
+ }, AUTOSAVE_DELAY_MS);
+ return () => {
+ cancelled = true;
+ window.clearTimeout(timer);
+ };
+ }, [
+ aidMemoire,
+ autosaveChecked,
+ autosaveElapsedBucket,
+ currentProjectId,
+ gameplayHistory,
+ preservedDocumentExtras,
+ puzzle,
+ recoverableAutosave,
+ replayMomentId,
+ session.centerMarks,
+ session.colors,
+ session.cornerMarks,
+ session.paused,
+ session.values,
+ solved,
+ ]);
+
useEffect(() => {
workerRef.current = createSolverWorkerClient();
return () => {
+ qualityAbortRef.current?.abort();
+ qualityAbortRef.current = null;
workerRef.current?.terminate();
workerRef.current = null;
};
@@ -578,6 +907,7 @@ export function Workbench() {
setPuzzle(target.puzzle);
setSession(restored);
setAidMemoire(cloneAidMemoire(target.aidMemoire));
+ setGuidedCandidateTracking(target.guidedCandidateTracking);
setAidMemoireActive(false);
setGameplayHistory((current) =>
workspace === "play" && target.puzzle === puzzle
@@ -599,6 +929,7 @@ export function Workbench() {
setPuzzle(target.puzzle);
setSession(restored);
setAidMemoire(cloneAidMemoire(target.aidMemoire));
+ setGuidedCandidateTracking(target.guidedCandidateTracking);
setAidMemoireActive(false);
setGameplayHistory((current) =>
workspace === "play" && target.puzzle === puzzle
@@ -647,6 +978,11 @@ export function Workbench() {
return;
}
if (!command) setHighlightedDigit(null);
+ if (tapMultiSelect) {
+ draggingRef.current = false;
+ changeSelection(cell, true, true);
+ return;
+ }
draggingRef.current = true;
const additive = event.shiftKey || command;
changeSelection(cell, additive, command);
@@ -657,6 +993,7 @@ export function Workbench() {
puzzle.size,
session.paused,
session.values,
+ tapMultiSelect,
workspace,
],
);
@@ -722,19 +1059,43 @@ export function Workbench() {
{ ...puzzle, givens },
{ ...session, values, cornerMarks, centerMarks },
);
+ setGuidedCandidateTracking(false);
return;
}
- commit(
- puzzle,
- enterSelection(session, selectedSet, entryMode, value, puzzle.givens),
+ let nextSession = enterSelection(
+ session,
+ selectedSet,
+ entryMode,
+ value,
+ puzzle.givens,
);
+ if (entryMode === "value" && autoMaintainPeerNotes) {
+ nextSession = autoRemoveNotesAfterPlacements(
+ session,
+ nextSession,
+ normalized.puzzle,
+ );
+ }
+ commit(puzzle, nextSession);
+ if (
+ entryMode === "center" ||
+ (entryMode === "value" &&
+ nextSession.values.some(
+ (nextValue, cell) =>
+ (session.values[cell] ?? 0) !== 0 && nextValue === 0,
+ ))
+ ) {
+ setGuidedCandidateTracking(false);
+ }
},
[
aidMemoire,
aidMemoireActive,
aidMemoireCell,
+ autoMaintainPeerNotes,
commit,
entryMode,
+ normalized.puzzle,
puzzle,
selectedSet,
session,
@@ -766,12 +1127,16 @@ export function Workbench() {
values[cell] = 0;
}
commit({ ...puzzle, givens }, { ...session, values });
+ setGuidedCandidateTracking(false);
return;
}
commit(
puzzle,
eraseSelection(session, selectedSet, entryMode, puzzle.givens),
);
+ if (entryMode === "value" || entryMode === "center") {
+ setGuidedCandidateTracking(false);
+ }
},
[
aidMemoire,
@@ -789,7 +1154,12 @@ export function Workbench() {
const moveActive = useCallback(
(key: string, extend: boolean, command: boolean): void => {
- const next = navigateGridCell(activeCell, puzzle.size, key, command);
+ const next = navigateGridCell(
+ effectiveActiveCell,
+ puzzle.size,
+ key,
+ command,
+ );
if (next === null) return;
changeSelection(next, extend, false);
requestAnimationFrame(() => {
@@ -798,7 +1168,7 @@ export function Workbench() {
?.focus();
});
},
- [activeCell, changeSelection, puzzle.size],
+ [changeSelection, effectiveActiveCell, puzzle.size],
);
const handleKeyDown = useCallback(
@@ -827,6 +1197,11 @@ export function Workbench() {
);
return;
}
+ if (!command && event.key.toLowerCase() === "m") {
+ event.preventDefault();
+ setTapMultiSelect((current) => !current);
+ return;
+ }
if (
[
"ArrowUp",
@@ -937,37 +1312,139 @@ export function Workbench() {
return;
}
setBusy(true);
+ setGuidedHintError(undefined);
try {
const result = (await workerRef.current.request(
{
kind: "logical",
puzzle: normalized.puzzle,
- options: { values: session.values, maxSteps: 1 },
+ options: {
+ values: session.values,
+ maxSteps: foggedCells.size > 0 ? 32 : 1,
+ ...(guidedCandidateTracking
+ ? {
+ candidates: session.centerMarks.map((mask) =>
+ maskValues(mask, puzzle.size),
+ ),
+ }
+ : {}),
+ },
},
{ timeoutMs: 15_000 },
)) as LogicalSolveResult;
- const step = result.steps[0];
+ const step = result.steps.find((candidate) =>
+ guidedHintStepIsVisible(candidate, foggedCells),
+ );
if (step === undefined) {
+ setGuidedHint(undefined);
+ setGuidedHintStage("focus");
setFeedback({
kind: result.status === "solved" ? "success" : "info",
message:
- result.status === "solved"
- ? "The grid is already solved."
- : "No supported logical next step was found.",
+ result.steps.length > 0 && foggedCells.size > 0
+ ? "No currently visible logical hint was found. Reveal more of the fog by solving a visible cell."
+ : result.status === "solved"
+ ? "The grid is already solved."
+ : "No supported logical next step was found.",
});
} else {
- if (step.focusCells.length) {
- setSelection([...step.focusCells]);
- setActiveCell(step.focusCells[0]!);
- }
- setFeedback({ kind: "info", message: step.explanation });
+ setGuidedHint(step);
+ setGuidedHintStage("focus");
+ setFeedback(undefined);
}
} catch (error) {
- setFeedback({ kind: "error", message: errorMessage(error) });
+ setGuidedHintError(errorMessage(error));
} finally {
setBusy(false);
}
- }, [normalized, session.values]);
+ }, [foggedCells, guidedCandidateTracking, normalized, puzzle.size, session]);
+
+ const revealNextHintStage = useCallback(() => {
+ setGuidedHintStage((current) => nextGuidedHintStage(current) ?? current);
+ }, []);
+
+ const applyGuidedHint = useCallback(() => {
+ if (guidedHint === undefined || normalized.error) return;
+ if (!guidedHintStepIsVisible(guidedHint, foggedCells)) {
+ setGuidedHint(undefined);
+ setGuidedHintStage("focus");
+ setFeedback({
+ kind: "info",
+ message:
+ "That hint is no longer visible under the fog. Request another hint.",
+ });
+ return;
+ }
+ try {
+ const startsTracking =
+ !guidedCandidateTracking && guidedHint.eliminations.length > 0;
+ const baseSession = startsTracking
+ ? fillLegalCenterCandidates(session, normalized.puzzle)
+ : session;
+ const nextSession = applyLogicalStepToSession(
+ baseSession,
+ guidedHint,
+ normalized.puzzle,
+ );
+ commit(puzzle, nextSession);
+ if (startsTracking) setGuidedCandidateTracking(true);
+ setFeedback({
+ kind: "success",
+ message: `Applied ${guidedHint.technique.replaceAll("-", " ")} as one undoable step${startsTracking ? " and started a complete candidate grid" : ""}.`,
+ });
+ } catch (error) {
+ setGuidedHintError(errorMessage(error));
+ }
+ }, [
+ commit,
+ foggedCells,
+ guidedCandidateTracking,
+ guidedHint,
+ normalized,
+ puzzle,
+ session,
+ ]);
+
+ const fillGuidedCandidates = useCallback(() => {
+ if (normalized.error) return;
+ try {
+ const nextSession = fillLegalCenterCandidates(session, normalized.puzzle);
+ commit(puzzle, nextSession);
+ setGuidedCandidateTracking(true);
+ setFeedback({
+ kind: "success",
+ message:
+ "Filled every empty cell with its currently legal centre candidates. Undo restores your previous notes.",
+ });
+ } catch (error) {
+ setFeedback({ kind: "error", message: errorMessage(error) });
+ }
+ }, [commit, normalized, puzzle, session]);
+
+ const removeInvalidCandidateNotes = useCallback(() => {
+ if (normalized.error) return;
+ try {
+ const nextSession = pruneInvalidNotes(session, normalized.puzzle);
+ const changed =
+ nextSession.cornerMarks.some(
+ (mask, cell) => mask !== session.cornerMarks[cell],
+ ) ||
+ nextSession.centerMarks.some(
+ (mask, cell) => mask !== session.centerMarks[cell],
+ );
+ if (!changed) {
+ setFeedback({ kind: "info", message: "All notes are already legal." });
+ return;
+ }
+ commit(puzzle, nextSession);
+ setFeedback({
+ kind: "success",
+ message: "Removed currently impossible corner and centre notes.",
+ });
+ } catch (error) {
+ setFeedback({ kind: "error", message: errorMessage(error) });
+ }
+ }, [commit, normalized, puzzle, session]);
const checkDefinition = useCallback(async () => {
const validation = validatePuzzle(puzzle);
@@ -1021,9 +1498,63 @@ export function Workbench() {
}
}, [puzzle]);
+ const runQualityAudit = useCallback(
+ async (kind: SetterQualityRunKind, options: PuzzleQualityOptions) => {
+ if (workerRef.current === null) {
+ setQualityError("Solver worker is not ready yet.");
+ return;
+ }
+ qualityAbortRef.current?.abort();
+ const controller = new AbortController();
+ qualityAbortRef.current = controller;
+ setBusy(true);
+ setQualityRunning(kind);
+ setQualityError(undefined);
+ try {
+ const result = (await workerRef.current.request(
+ { kind: "quality", puzzle, options },
+ {
+ signal: controller.signal,
+ timeoutMs: Math.min(
+ 300_000,
+ (options.aggregateTimeoutMs ?? 30_000) + 15_000,
+ ),
+ },
+ )) as PuzzleQualityAnalysis;
+ if (!controller.signal.aborted) setQuality(result);
+ } catch (error) {
+ if (!controller.signal.aborted) setQualityError(errorMessage(error));
+ } finally {
+ if (qualityAbortRef.current === controller) {
+ qualityAbortRef.current = null;
+ setQualityRunning(undefined);
+ setBusy(false);
+ }
+ }
+ },
+ [puzzle],
+ );
+
+ const cancelQualityAudit = useCallback(() => {
+ qualityAbortRef.current?.abort();
+ qualityAbortRef.current = null;
+ setQualityRunning(undefined);
+ setBusy(false);
+ setQualityError(undefined);
+ setFeedback({
+ kind: "info",
+ message:
+ "Setter-quality analysis cancelled; the previous result remains visible.",
+ });
+ }, []);
+
const generateConfigured = useCallback(
async (options: GenerateVariantOptions) => {
if (workerRef.current === null) return;
+ generationAbortRef.current?.abort();
+ const controller = new AbortController();
+ generationAbortRef.current = controller;
+ setGenerationRunning(true);
setBusy(true);
setFeedback({
kind: "info",
@@ -1033,10 +1564,12 @@ export function Workbench() {
try {
const generated = (await workerRef.current.request(
{ kind: "generate-variant", options },
- { timeoutMs: 240_000 },
+ { timeoutMs: 240_000, signal: controller.signal },
)) as GeneratedVariantPuzzle;
+ if (controller.signal.aborted) return;
loadPuzzle(generated.puzzle);
setGeneration(generated);
+ setGenerationBatch(undefined);
setDifficulty(generated.difficulty);
setWorkspace("generate");
setFeedback({
@@ -1044,14 +1577,96 @@ export function Workbench() {
message: `Generated a unique ${generated.variant} puzzle${generated.requestedTechnique === undefined ? "" : ` featuring ${generated.requestedTechnique.replaceAll("-", " ")}`}. Rated ${generated.difficulty.label}${generated.difficulty.score === null ? "" : ` (${String(generated.difficulty.score)}/100)`}.`,
});
} catch (error) {
- setFeedback({ kind: "error", message: errorMessage(error) });
+ if (!controller.signal.aborted) {
+ setFeedback({ kind: "error", message: errorMessage(error) });
+ }
} finally {
- setBusy(false);
+ if (generationAbortRef.current === controller) {
+ generationAbortRef.current = null;
+ setGenerationRunning(false);
+ setBusy(false);
+ }
}
},
[loadPuzzle],
);
+ const generateBatchConfigured = useCallback(
+ async (options: GenerateVariantBatchOptions) => {
+ if (workerRef.current === null) return;
+ generationAbortRef.current?.abort();
+ const controller = new AbortController();
+ generationAbortRef.current = controller;
+ setGenerationRunning(true);
+ setBusy(true);
+ setFeedback({
+ kind: "info",
+ message:
+ "Generating, independently checking and ranking a bounded local batch. The current board stays available until a result is ready…",
+ });
+ try {
+ const batch = (await workerRef.current.request(
+ { kind: "generate-batch", options },
+ { timeoutMs: 300_000, signal: controller.signal },
+ )) as GeneratedVariantBatch;
+ if (controller.signal.aborted) return;
+ const best = batch.entries[0];
+ if (best !== undefined) {
+ loadPuzzle(best.puzzle);
+ setGeneration(best);
+ setDifficulty(best.difficulty);
+ }
+ setGenerationBatch(batch);
+ setWorkspace("generate");
+ setFeedback({
+ kind: batch.completed > 0 ? "success" : "error",
+ message:
+ batch.completed > 0
+ ? `Generated and ranked ${String(batch.completed)} of ${String(batch.requested)} bounded candidates; the top result is open.`
+ : "No batch candidate completed every bounded verification.",
+ });
+ } catch (error) {
+ if (!controller.signal.aborted) {
+ setFeedback({ kind: "error", message: errorMessage(error) });
+ }
+ } finally {
+ if (generationAbortRef.current === controller) {
+ generationAbortRef.current = null;
+ setGenerationRunning(false);
+ setBusy(false);
+ }
+ }
+ },
+ [loadPuzzle],
+ );
+
+ const selectGeneratedCandidate = useCallback(
+ (generated: GeneratedVariantPuzzle) => {
+ const batch = generationBatch;
+ loadPuzzle(generated.puzzle);
+ setGeneration(generated);
+ setDifficulty(generated.difficulty);
+ setGenerationBatch(batch);
+ setWorkspace("generate");
+ setFeedback({
+ kind: "success",
+ message: `Opened ranked candidate ${String(generated.seed)}.`,
+ });
+ },
+ [generationBatch, loadPuzzle],
+ );
+
+ const cancelGeneration = useCallback(() => {
+ generationAbortRef.current?.abort();
+ generationAbortRef.current = null;
+ setGenerationRunning(false);
+ setBusy(false);
+ setFeedback({
+ kind: "info",
+ message: "Generation cancelled; the current puzzle was left unchanged.",
+ });
+ }, []);
+
const rateCurrentPuzzle = useCallback(async () => {
if (workerRef.current === null) return;
if (normalized.error) {
@@ -1069,6 +1684,7 @@ export function Workbench() {
{ timeoutMs: 60_000 },
)) as DifficultyAssessment;
setGeneration(undefined);
+ setGenerationBatch(undefined);
setDifficulty(assessment);
setFeedback({
kind: assessment.uniqueness === "unique" ? "success" : "info",
@@ -1111,8 +1727,14 @@ export function Workbench() {
setLibraryFeedback(undefined);
try {
const now = Date.now();
- const document = fromDomainPuzzle(puzzle);
- const progress = progressFromSession(puzzle, session, solved, aidMemoire);
+ const document = fromDomainPuzzle(puzzle, preservedDocumentExtras);
+ const progress = progressFromSession(
+ puzzle,
+ session,
+ solved,
+ aidMemoire,
+ gameplayHistory,
+ );
let record: SudokuProjectRecord;
const existing = currentProjectId
? await library.get(currentProjectId)
@@ -1124,6 +1746,7 @@ export function Workbench() {
updatedAt: now,
puzzle: document,
progress,
+ thumbnail: undefined,
};
} else {
record = createProjectRecord(document, {
@@ -1141,7 +1764,16 @@ export function Workbench() {
} finally {
setLibraryBusy(false);
}
- }, [aidMemoire, currentProjectId, puzzle, refreshLibrary, session, solved]);
+ }, [
+ aidMemoire,
+ currentProjectId,
+ gameplayHistory,
+ preservedDocumentExtras,
+ puzzle,
+ refreshLibrary,
+ session,
+ solved,
+ ]);
const openProject = useCallback(
async (id: string) => {
@@ -1212,6 +1844,89 @@ export function Workbench() {
}
}, []);
+ const exportSelectedProjects = useCallback(async (ids: readonly string[]) => {
+ setLibraryBusy(true);
+ try {
+ const exported = await library.exportSelected(ids);
+ if (exported.projects.length === 0) {
+ throw new Error("Select at least one saved project to export.");
+ }
+ downloadJson("sudoku-tools-selection.json", exported);
+ setLibraryFeedback(
+ `${String(exported.projects.length)} selected project${exported.projects.length === 1 ? "" : "s"} exported.`,
+ );
+ } catch (error) {
+ setLibraryFeedback(errorMessage(error));
+ } finally {
+ setLibraryBusy(false);
+ }
+ }, []);
+
+ const duplicateSelectedProjects = useCallback(
+ async (ids: readonly string[]) => {
+ setLibraryBusy(true);
+ try {
+ const count = await library.duplicateSelected(ids);
+ await refreshLibrary();
+ setLibraryFeedback(
+ `${String(count)} project${count === 1 ? "" : "s"} copied locally.`,
+ );
+ } catch (error) {
+ setLibraryFeedback(errorMessage(error));
+ } finally {
+ setLibraryBusy(false);
+ }
+ },
+ [refreshLibrary],
+ );
+
+ const deleteSelectedProjects = useCallback(
+ async (ids: readonly string[]) => {
+ if (
+ ids.length === 0 ||
+ !window.confirm(
+ `Delete ${String(ids.length)} selected saved project${ids.length === 1 ? "" : "s"} from this browser?`,
+ )
+ ) {
+ return;
+ }
+ setLibraryBusy(true);
+ try {
+ for (const id of ids) await library.delete(id);
+ if (currentProjectId && ids.includes(currentProjectId)) {
+ setCurrentProjectId(undefined);
+ }
+ await refreshLibrary();
+ setLibraryFeedback("Selected projects deleted.");
+ } catch (error) {
+ setLibraryFeedback(errorMessage(error));
+ } finally {
+ setLibraryBusy(false);
+ }
+ },
+ [currentProjectId, refreshLibrary],
+ );
+
+ const updateProjectTags = useCallback(
+ async (id: string, tags: readonly string[]) => {
+ setLibraryBusy(true);
+ try {
+ const record = await library.get(id);
+ if (record === undefined) {
+ throw new Error("The saved puzzle no longer exists.");
+ }
+ await library.put({ ...record, tags, updatedAt: Date.now() });
+ await refreshLibrary();
+ setLibraryFeedback("Project tags updated.");
+ } catch (error) {
+ setLibraryFeedback(errorMessage(error));
+ } finally {
+ setLibraryBusy(false);
+ }
+ },
+ [refreshLibrary],
+ );
+
const importLibrary = useCallback(
async (file: File) => {
setLibraryBusy(true);
@@ -1234,9 +1949,39 @@ export function Workbench() {
[refreshLibrary],
);
+ const restoreAutosave = useCallback(() => {
+ if (recoverableAutosave === undefined) return;
+ try {
+ restoreProject(recoverableAutosave);
+ setRecoverableAutosave(undefined);
+ setAutosaveStatus("saved");
+ setAutosaveMessage("Recovered local work; autosave is active.");
+ } catch (error) {
+ setAutosaveStatus("error");
+ setAutosaveMessage(`Recovery failed: ${errorMessage(error)}`);
+ }
+ }, [recoverableAutosave, restoreProject]);
+
+ const discardAutosave = useCallback(async () => {
+ try {
+ await library.clearAutosave();
+ setRecoverableAutosave(undefined);
+ setAutosaveStatus("idle");
+ setAutosaveMessage(
+ "Previous recovery data discarded; autosave is active.",
+ );
+ } catch (error) {
+ setAutosaveStatus("error");
+ setAutosaveMessage(
+ `Could not discard recovery data: ${errorMessage(error)}`,
+ );
+ }
+ }, []);
+
const applyValues = useCallback(
(values: readonly number[]) => {
commit(puzzle, { ...session, values: [...values] });
+ setGuidedCandidateTracking(false);
},
[commit, puzzle, session],
);
@@ -1248,6 +1993,13 @@ export function Workbench() {
setAidMemoireActive(false);
}, []);
+ const focusQualityItem = useCallback(
+ (item: QualityItemReference) => {
+ focusCells(qualityItemCells(normalized.puzzle, item));
+ },
+ [focusCells, normalized.puzzle],
+ );
+
const createNamedSavepoint = useCallback(
(name: string) => {
try {
@@ -1276,6 +2028,7 @@ export function Workbench() {
setFuture([]);
setGameplayHistory(transition.history);
setSession(restored);
+ setGuidedCandidateTracking(false);
setAidMemoire(
aidMemoireFromGameplayState(transition.state, puzzle.size),
);
@@ -1305,6 +2058,7 @@ export function Workbench() {
);
setGameplayHistory(transition.history);
setSession(sessionFromGameplayState(transition.state));
+ setGuidedCandidateTracking(false);
setAidMemoire(
aidMemoireFromGameplayState(transition.state, puzzle.size),
);
@@ -1338,6 +2092,7 @@ export function Workbench() {
);
setGameplayHistory(transition.history);
setSession(sessionFromGameplayState(transition.state));
+ setGuidedCandidateTracking(false);
setAidMemoire(
aidMemoireFromGameplayState(transition.state, puzzle.size),
);
@@ -1527,8 +2282,50 @@ export function Workbench() {
>
{formatTime(session.elapsedSeconds)}
+
+ {autosaveStatus === "checking"
+ ? "Autosave checking"
+ : autosaveStatus === "saving"
+ ? "Autosaving…"
+ : autosaveStatus === "saved"
+ ? "Autosaved"
+ : autosaveStatus === "error"
+ ? "Autosave issue"
+ : "Autosave ready"}
+
+ {recoverableAutosave && (
+
+
+
Recover unsaved local work?
+
+ “{recoverableAutosave.title || "Untitled puzzle"}” was autosaved{" "}
+ {new Intl.DateTimeFormat(undefined, {
+ dateStyle: "medium",
+ timeStyle: "short",
+ }).format(recoverableAutosave.updatedAt)}
+ .
+
+
+
+
+ Restore
+
+ void discardAutosave()}
+ >
+ Discard
+
+
+
+ )}
+
{feedback && (
Redo
+ setTapMultiSelect((current) => !current)}
+ >
+ Tap multi-select
+
{workspace === "play" && (
setHistoryOpen(true)}>
{currentHypothesis
@@ -1576,33 +2382,47 @@ export function Workbench() {
? `Replay · ${replayMoment.label}`
: aidMemoireActive && aidMemoire.enabled
? `Aid-mémoire · ${aidMemoire.cells[aidMemoireCell]?.label || `cell ${String(aidMemoireCell + 1)}`}`
- : selection.length === 1
- ? cellLabel(selection[0]!, puzzle.size)
- : `${String(selection.length)} cells selected`}
+ : effectiveSelection.length === 1
+ ? cellLabel(effectiveSelection[0]!, puzzle.size)
+ : `${String(effectiveSelection.length)} cells selected`}
-
+ {
+ draggingRef.current = false;
+ }}
+ >
+
+
{session.paused && replayMoment === undefined && (
)}
+ {mobileLayout &&
+ (workspace === "play" || workspace === "set") &&
+ replayMoment === undefined && (
+
+
+
+ )}
{workspace === "play" && showDigitCompletion && (
-
+ )}
+ void requestHint()}
+ onRevealNext={revealNextHintStage}
+ onApply={applyGuidedHint}
+ onDismiss={() => {
+ setGuidedHint(undefined);
+ setGuidedHintStage("focus");
+ setGuidedHintError(undefined);
+ }}
+ onFillLegalCandidates={fillGuidedCandidates}
+ onRemoveInvalidNotes={removeInvalidCandidateNotes}
+ onAutoMaintainPeerNotesChange={setAutoMaintainPeerNotes}
/>
-
- Hint
-
Check
@@ -1740,6 +2592,21 @@ export function Workbench() {
/>
Show automatically calculated candidates
+
+ Screen-reader candidate detail
+
+ setCandidateVerbosity(
+ parseCandidateVerbosity(event.target.value),
+ )
+ }
+ >
+ Off
+ Concise counts
+ Detailed digits
+
+
commit(puzzle, createSession(puzzle.givens))}
+ onClick={() => {
+ commit(puzzle, createSession(puzzle.givens));
+ setGuidedCandidateTracking(false);
+ }}
>
Restart puzzle
@@ -1813,13 +2683,15 @@ export function Workbench() {
In Value mode, digits become fixed clues. Notes and colours
remain play-test annotations.
-
+ {!mobileLayout && (
+
+ )}
void checkDefinition()}
onGenerate={() => setWorkspace("generate")}
/>
+ void runQualityAudit("quick", options)}
+ onRunMinimality={(options) =>
+ void runQualityAudit("minimality", options)
+ }
+ onCancel={cancelQualityAudit}
+ onFocusCells={focusCells}
+ onFocusItem={focusQualityItem}
+ />
)}
@@ -1844,7 +2729,13 @@ export function Workbench() {
busy={busy}
assessment={difficulty}
generation={generation}
+ batch={generationBatch}
onGenerate={(options) => void generateConfigured(options)}
+ onGenerateBatch={(options) =>
+ void generateBatchConfigured(options)
+ }
+ onSelectGenerated={selectGeneratedCandidate}
+ onCancel={generationRunning ? cancelGeneration : undefined}
onRate={() => void rateCurrentPuzzle()}
/>
)}
@@ -1869,7 +2760,7 @@ export function Workbench() {
size={puzzle.size}
puzzle={normalized.puzzle}
values={session.values}
- selectedCells={selection}
+ selectedCells={effectiveSelection}
candidateMasks={candidateMasks}
onCandidateOverlayChange={handleCandidateOverlayChange}
/>
@@ -1882,10 +2773,11 @@ export function Workbench() {
puzzle={puzzle}
session={session}
aidMemoire={aidMemoireToPortable(aidMemoire, puzzle.size)}
+ preservedExtras={preservedDocumentExtras}
onClose={() => setImportOpen(false)}
- onImport={(next, progress) => {
+ onImport={(next, progress, preservedExtras) => {
try {
- loadPuzzle(next, progress);
+ loadPuzzle(next, progress, undefined, preservedExtras);
setWorkspace("play");
} catch (error) {
setFeedback({ kind: "error", message: errorMessage(error) });
@@ -1904,6 +2796,10 @@ export function Workbench() {
onDelete={(id) => void deleteProject(id)}
onClear={() => void clearLibrary()}
onExport={() => void exportLibrary()}
+ onExportSelected={(ids) => void exportSelectedProjects(ids)}
+ onDuplicateSelected={(ids) => void duplicateSelectedProjects(ids)}
+ onDeleteSelected={(ids) => void deleteSelectedProjects(ids)}
+ onUpdateTags={(id, tags) => void updateProjectTags(id, tags)}
onImport={(file) => void importLibrary(file)}
/>
{
+ const fogConstraints = puzzle.constraints.filter(
+ (constraint): constraint is FogConstraint => constraint.type === "fog",
+ );
+ if (fogConstraints.length === 0) return new Set();
+
+ const correctEntries = values.flatMap((value, cell) =>
+ value !== 0 && puzzle.solution?.[cell] === value ? [cell] : [],
+ );
+ const givens = puzzle.givens.flatMap((value, cell) =>
+ value === 0 ? [] : [cell],
+ );
+ const visible = new Set();
+ for (const constraint of fogConstraints) {
+ const radius = constraint.revealRadius ?? 1;
+ const sources = new Set([
+ ...constraint.lights,
+ ...givens,
+ ...correctEntries,
+ ]);
+ for (const source of sources) {
+ const row = Math.floor(source / puzzle.size);
+ const column = source % puzzle.size;
+ for (let rowOffset = -radius; rowOffset <= radius; rowOffset += 1) {
+ for (
+ let columnOffset = -radius;
+ columnOffset <= radius;
+ columnOffset += 1
+ ) {
+ const targetRow = row + rowOffset;
+ const targetColumn = column + columnOffset;
+ if (
+ targetRow >= 0 &&
+ targetRow < puzzle.size &&
+ targetColumn >= 0 &&
+ targetColumn < puzzle.size
+ ) {
+ visible.add(targetRow * puzzle.size + targetColumn);
+ }
+ }
+ }
+ }
+ }
+
+ return new Set(
+ Array.from({ length: puzzle.size * puzzle.size }, (_, cell) => cell).filter(
+ (cell) => !visible.has(cell),
+ ),
+ );
+}
diff --git a/src/components/guidedHint.ts b/src/components/guidedHint.ts
new file mode 100644
index 0000000..265db47
--- /dev/null
+++ b/src/components/guidedHint.ts
@@ -0,0 +1,223 @@
+import type { LogicalStep, LogicalTechnique } from "../solver";
+import { symbolFor } from "../state/session";
+
+export const GUIDED_HINT_STAGES = [
+ "focus",
+ "technique",
+ "reasoning",
+ "preview",
+] as const;
+
+export type GuidedHintStage = (typeof GUIDED_HINT_STAGES)[number];
+
+export interface GuidedHintCellSets {
+ readonly focusCells: readonly number[];
+ readonly placementCells: readonly number[];
+ readonly eliminationCells: readonly number[];
+ readonly affectedCells: readonly number[];
+}
+
+export interface GuidedHintOverlay {
+ readonly focusCells: readonly number[];
+ readonly placementCells: readonly number[];
+ readonly eliminationCells: readonly number[];
+}
+
+const TECHNIQUE_DESCRIPTIONS: Partial> = {
+ "naked-single": "A cell has only one legal candidate left.",
+ "hidden-single": "A digit has only one possible position in a house.",
+ "naked-pair":
+ "Two cells reserve the same two candidates, excluding them elsewhere in their house.",
+ "naked-triple":
+ "Three cells reserve three candidates, excluding them elsewhere in their house.",
+ "naked-quad":
+ "Four cells reserve four candidates, excluding them elsewhere in their house.",
+ "hidden-pair": "Two digits can occur in only the same two cells of a house.",
+ "hidden-triple":
+ "Three digits can occur in only the same three cells of a house.",
+ "hidden-quad":
+ "Four digits can occur in only the same four cells of a house.",
+ pointing:
+ "A candidate confined to one line inside a region can be removed farther along that line.",
+ claiming:
+ "A candidate confined to one region along a line can be removed from the rest of that region.",
+ "x-wing":
+ "Two matching rows or columns lock a candidate into two opposite positions.",
+ swordfish:
+ "Three matching rows or columns lock a candidate into three crossing lines.",
+ "xy-wing":
+ "Three linked bivalue cells force a shared candidate out of cells that see both wings.",
+ "xyz-wing":
+ "A three-candidate pivot and two wings force their shared candidate elsewhere.",
+ "killer-cage":
+ "A cage's remaining sum and legal combinations restrict its unsolved cells.",
+};
+
+function uniqueCells(cells: readonly number[]): number[] {
+ return [...new Set(cells)].filter(
+ (cell) => Number.isInteger(cell) && cell >= 0,
+ );
+}
+
+function naturalList(items: readonly string[]): string {
+ if (items.length <= 1) return items[0] ?? "";
+ if (items.length === 2) return `${items[0]} and ${items[1]}`;
+ return `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`;
+}
+
+export function guidedHintStageIndex(stage: GuidedHintStage): number {
+ return GUIDED_HINT_STAGES.indexOf(stage);
+}
+
+export function nextGuidedHintStage(
+ stage: GuidedHintStage,
+): GuidedHintStage | undefined {
+ return GUIDED_HINT_STAGES[guidedHintStageIndex(stage) + 1];
+}
+
+export function isGuidedHintStageRevealed(
+ current: GuidedHintStage,
+ stage: GuidedHintStage,
+): boolean {
+ return guidedHintStageIndex(current) >= guidedHintStageIndex(stage);
+}
+
+export function logicalTechniqueName(technique: LogicalTechnique): string {
+ return technique
+ .split("-")
+ .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
+ .join(" ");
+}
+
+export function logicalTechniqueDescription(
+ technique: LogicalTechnique,
+): string {
+ return (
+ TECHNIQUE_DESCRIPTIONS[technique] ??
+ `This ${logicalTechniqueName(technique).toLowerCase()} pattern creates a logical deduction.`
+ );
+}
+
+export function guidedHintCellName(cell: number, size: number): string {
+ return `r${String(Math.floor(cell / size) + 1)}c${String((cell % size) + 1)}`;
+}
+
+export function deriveGuidedHintCellSets(
+ step: LogicalStep,
+): GuidedHintCellSets {
+ const focusCells = uniqueCells(step.focusCells);
+ const placementCells = uniqueCells(
+ step.placements.map((placement) => placement.cell),
+ );
+ const eliminationCells = uniqueCells(
+ step.eliminations.map((elimination) => elimination.cell),
+ );
+ return {
+ focusCells,
+ placementCells,
+ eliminationCells,
+ affectedCells: uniqueCells([...placementCells, ...eliminationCells]),
+ };
+}
+
+/**
+ * A guided step is safe to disclose only when every premise and every effect
+ * is currently visible. This deliberately includes focus cells: an otherwise
+ * harmless-looking technique name or explanation can reveal a hidden clue.
+ */
+export function guidedHintStepIsVisible(
+ step: LogicalStep,
+ hiddenCells: ReadonlySet,
+): boolean {
+ const { focusCells, placementCells, eliminationCells } =
+ deriveGuidedHintCellSets(step);
+ return [...focusCells, ...placementCells, ...eliminationCells].every(
+ (cell) => !hiddenCells.has(cell),
+ );
+}
+
+/**
+ * Returns only the cells that may be visualised at the current disclosure
+ * stage. In particular, effect cells stay absent until the preview stage.
+ */
+export function guidedHintOverlay(
+ step: LogicalStep,
+ stage: GuidedHintStage,
+): GuidedHintOverlay {
+ const cells = deriveGuidedHintCellSets(step);
+ return {
+ focusCells: cells.focusCells,
+ placementCells: stage === "preview" ? cells.placementCells : [],
+ eliminationCells: stage === "preview" ? cells.eliminationCells : [],
+ };
+}
+
+export function guidedHintFocusSummary(
+ step: LogicalStep,
+ size: number,
+): string {
+ const { focusCells, affectedCells } = deriveGuidedHintCellSets(step);
+ const cells = focusCells.length > 0 ? focusCells : affectedCells;
+ if (cells.length === 0) return "Review the current candidate grid.";
+ if (cells.length === 1) {
+ return `Look closely at ${guidedHintCellName(cells[0]!, size)}.`;
+ }
+
+ const rows = new Set(cells.map((cell) => Math.floor(cell / size)));
+ if (rows.size === 1) {
+ return `Look across row ${String((Math.floor(cells[0]! / size) || 0) + 1)}.`;
+ }
+ const columns = new Set(cells.map((cell) => cell % size));
+ if (columns.size === 1) {
+ return `Look down column ${String(((cells[0] ?? 0) % size) + 1)}.`;
+ }
+
+ const boxSize = Math.sqrt(size);
+ if (Number.isInteger(boxSize)) {
+ const boxes = new Set(
+ cells.map((cell) => {
+ const row = Math.floor(cell / size);
+ const column = cell % size;
+ return (
+ Math.floor(row / boxSize) * boxSize + Math.floor(column / boxSize)
+ );
+ }),
+ );
+ if (boxes.size === 1) {
+ return `Look within box ${String((boxes.values().next().value as number) + 1)}.`;
+ }
+ }
+
+ if (cells.length <= 4) {
+ return `Compare ${naturalList(cells.map((cell) => guidedHintCellName(cell, size)))}.`;
+ }
+ return `Compare the ${String(cells.length)} highlighted cells.`;
+}
+
+export function guidedHintEffectItems(
+ step: LogicalStep,
+ size: number,
+): readonly string[] {
+ const placements = step.placements.map(
+ ({ cell, value }) =>
+ `Place ${symbolFor(value, size)} in ${guidedHintCellName(cell, size)}.`,
+ );
+ const eliminations = step.eliminations.map(({ cell, values }) => {
+ const symbols = values.map((value) => symbolFor(value, size));
+ const object =
+ symbols.length === 1
+ ? symbols[0]
+ : naturalList(symbols.map((symbol) => String(symbol)));
+ return `Remove ${object} from ${guidedHintCellName(cell, size)}.`;
+ });
+ return [...placements, ...eliminations];
+}
+
+export function guidedHintEffectSummary(
+ step: LogicalStep,
+ size: number,
+): string {
+ const effects = guidedHintEffectItems(step, size);
+ if (effects.length === 0) return "This deduction does not change the board.";
+ return effects.join(" ");
+}
diff --git a/src/domain/compile.ts b/src/domain/compile.ts
index c786cc3..0640ec4 100644
--- a/src/domain/compile.ts
+++ b/src/domain/compile.ts
@@ -1,12 +1,11 @@
+import { constraintCells } from "./constraintRegistry";
import { cellColumn, cellRow, orthogonalNeighbours } from "./geometry";
-import type {
- CellId,
- NormalizedPuzzle,
- OutsideClueSide,
- VariantConstraint,
-} from "./types";
+import type { CellId, NormalizedPuzzle } from "./types";
-export type UnitKind = "row" | "column" | "region" | "diagonal";
+export { outsideLineCells } from "./geometry";
+
+export type UnitKind =
+ "row" | "column" | "region" | "diagonal" | "disjoint-group" | "extra-region";
export interface SudokuUnit {
readonly kind: UnitKind;
@@ -30,63 +29,18 @@ function addPeerPair(peers: Set[], a: CellId, b: CellId): void {
peers[b]?.add(a);
}
-/** Cells seen from an outside clue, ordered from the clue into the grid. */
-export function outsideLineCells(
- size: number,
- side: OutsideClueSide,
- index: number,
+function disjointGroupCells(
+ puzzle: NormalizedPuzzle,
+ position: number,
): CellId[] {
- return Array.from({ length: size }, (_, offset) => {
- switch (side) {
- case "top":
- return offset * size + index;
- case "right":
- return index * size + size - offset - 1;
- case "bottom":
- return (size - offset - 1) * size + index;
- case "left":
- return index * size + offset;
- }
+ const regions = Array.from({ length: puzzle.size }, () => [] as CellId[]);
+ puzzle.regions.forEach((region, cell) => regions[region]?.push(cell));
+ return regions.flatMap((cells) => {
+ const cell = cells[position];
+ return cell === undefined ? [] : [cell];
});
}
-function cellsForConstraint(
- size: number,
- constraint: VariantConstraint,
-): readonly CellId[] {
- switch (constraint.type) {
- case "diagonal":
- return Array.from({ length: size }, (_, index) =>
- constraint.direction === "main"
- ? index * size + index
- : index * size + size - index - 1,
- );
- case "anti-knight":
- case "anti-king":
- case "non-consecutive":
- return Array.from({ length: size * size }, (_, cell) => cell);
- case "killer-cage":
- case "thermo":
- case "renban":
- case "palindrome":
- return constraint.cells;
- case "arrow":
- return [...constraint.bulb, ...constraint.line];
- case "kropki":
- case "xv":
- return [constraint.a, constraint.b];
- case "inequality":
- return [constraint.lesser, constraint.greater];
- case "x-sum":
- case "skyscraper":
- return outsideLineCells(size, constraint.side, constraint.index);
- case "quadruple":
- return constraint.cells;
- case "maximum":
- return [constraint.cell, ...orthogonalNeighbours(size, constraint.cell)];
- }
-}
-
export function compilePuzzle(puzzle: NormalizedPuzzle): CompiledPuzzle {
const { size } = puzzle;
const count = size * size;
@@ -115,9 +69,28 @@ export function compilePuzzle(puzzle: NormalizedPuzzle): CompiledPuzzle {
units.push({
kind: "diagonal",
index: constraint.direction === "main" ? 0 : 1,
- cells: cellsForConstraint(size, constraint),
+ cells: constraintCells(size, constraint),
});
}
+ if (puzzle.constraints.some(({ type }) => type === "disjoint-groups")) {
+ for (let position = 0; position < size; position += 1) {
+ units.push({
+ kind: "disjoint-group",
+ index: position,
+ cells: disjointGroupCells(puzzle, position),
+ });
+ }
+ }
+ let extraRegionIndex = 0;
+ for (const constraint of puzzle.constraints) {
+ if (constraint.type !== "extra-region") continue;
+ units.push({
+ kind: "extra-region",
+ index: extraRegionIndex,
+ cells: constraint.cells,
+ });
+ extraRegionIndex += 1;
+ }
const peers = Array.from({ length: count }, () => new Set());
const unitsByCell = Array.from({ length: count }, () => [] as number[]);
@@ -130,7 +103,7 @@ export function compilePuzzle(puzzle: NormalizedPuzzle): CompiledPuzzle {
const constraintsByCell = Array.from({ length: count }, () => [] as number[]);
puzzle.constraints.forEach((constraint, constraintIndex) => {
- for (const cell of new Set(cellsForConstraint(size, constraint))) {
+ for (const cell of constraintCells(size, constraint)) {
constraintsByCell[cell]?.push(constraintIndex);
}
if (
diff --git a/src/domain/constraintRegistry.ts b/src/domain/constraintRegistry.ts
new file mode 100644
index 0000000..969d479
--- /dev/null
+++ b/src/domain/constraintRegistry.ts
@@ -0,0 +1,482 @@
+import {
+ correspondingBoxPositionCells,
+ littleKillerCells,
+ orthogonalNeighbours,
+ outsideLineCells,
+} from "./geometry";
+import type { CellId, VariantConstraint } from "./types";
+
+export type ConstraintType = VariantConstraint["type"];
+export type ConstraintCategory =
+ "global" | "region" | "line" | "adjacency" | "outside" | "cell";
+
+export type ConstraintFieldKind =
+ | "discriminator"
+ | "boolean"
+ | "integer"
+ | "enum"
+ | "cell"
+ | "cells"
+ | "integers"
+ | "outside-side";
+
+export interface ConstraintFieldMetadata {
+ readonly key: string;
+ readonly kind: ConstraintFieldKind;
+ readonly required: boolean;
+}
+
+export interface ConstraintRegistryEntry<
+ Type extends ConstraintType = ConstraintType,
+> {
+ readonly type: Type;
+ readonly label: string;
+ readonly category: ConstraintCategory;
+ readonly negatable: boolean;
+ readonly fields: readonly ConstraintFieldMetadata[];
+ readonly cells: (
+ size: number,
+ constraint: Extract,
+ ) => readonly CellId[];
+}
+
+type ConstraintRegistry = {
+ readonly [Type in ConstraintType]: ConstraintRegistryEntry;
+};
+
+const typeField: ConstraintFieldMetadata = {
+ key: "type",
+ kind: "discriminator",
+ required: true,
+};
+const negatedField: ConstraintFieldMetadata = {
+ key: "negated",
+ kind: "boolean",
+ required: false,
+};
+const allCells = (size: number): CellId[] =>
+ Array.from({ length: size * size }, (_, cell) => cell);
+const cellField = (key = "cell"): ConstraintFieldMetadata => ({
+ key,
+ kind: "cell",
+ required: true,
+});
+const cellsField = (key = "cells"): ConstraintFieldMetadata => ({
+ key,
+ kind: "cells",
+ required: true,
+});
+const integerField = (key: string): ConstraintFieldMetadata => ({
+ key,
+ kind: "integer",
+ required: true,
+});
+const enumField = (key: string): ConstraintFieldMetadata => ({
+ key,
+ kind: "enum",
+ required: true,
+});
+const outsideFields = (
+ valueField: string,
+): readonly ConstraintFieldMetadata[] => [
+ typeField,
+ { key: "side", kind: "outside-side", required: true },
+ integerField("index"),
+ integerField(valueField),
+ negatedField,
+];
+
+function entry(
+ value: ConstraintRegistryEntry,
+): ConstraintRegistryEntry {
+ return value;
+}
+
+export const CONSTRAINT_REGISTRY = {
+ diagonal: entry({
+ type: "diagonal",
+ label: "Diagonal",
+ category: "global",
+ negatable: false,
+ fields: [typeField, enumField("direction")],
+ cells: (size, constraint) =>
+ Array.from({ length: size }, (_, index) =>
+ constraint.direction === "main"
+ ? index * size + index
+ : index * size + size - index - 1,
+ ),
+ }),
+ "anti-knight": entry({
+ type: "anti-knight",
+ label: "Anti-knight",
+ category: "global",
+ negatable: false,
+ fields: [typeField],
+ cells: (size) => allCells(size),
+ }),
+ "anti-king": entry({
+ type: "anti-king",
+ label: "Anti-king",
+ category: "global",
+ negatable: false,
+ fields: [typeField],
+ cells: (size) => allCells(size),
+ }),
+ "non-consecutive": entry({
+ type: "non-consecutive",
+ label: "Non-consecutive",
+ category: "global",
+ negatable: false,
+ fields: [typeField],
+ cells: (size) => allCells(size),
+ }),
+ "disjoint-groups": entry({
+ type: "disjoint-groups",
+ label: "Disjoint groups",
+ category: "global",
+ negatable: false,
+ fields: [typeField],
+ cells: (size) => allCells(size),
+ }),
+ "killer-cage": entry({
+ type: "killer-cage",
+ label: "Killer cage",
+ category: "region",
+ negatable: true,
+ fields: [
+ typeField,
+ cellsField(),
+ integerField("sum"),
+ { key: "noRepeat", kind: "boolean", required: false },
+ negatedField,
+ ],
+ cells: (_size, constraint) => constraint.cells,
+ }),
+ thermo: entry({
+ type: "thermo",
+ label: "Thermometer",
+ category: "line",
+ negatable: true,
+ fields: [typeField, cellsField(), negatedField],
+ cells: (_size, constraint) => constraint.cells,
+ }),
+ arrow: entry({
+ type: "arrow",
+ label: "Arrow",
+ category: "line",
+ negatable: true,
+ fields: [typeField, cellsField("bulb"), cellsField("line"), negatedField],
+ cells: (_size, constraint) => [...constraint.bulb, ...constraint.line],
+ }),
+ kropki: entry({
+ type: "kropki",
+ label: "Kropki dot",
+ category: "adjacency",
+ negatable: true,
+ fields: [
+ typeField,
+ cellField("a"),
+ cellField("b"),
+ enumField("kind"),
+ negatedField,
+ ],
+ cells: (_size, constraint) => [constraint.a, constraint.b],
+ }),
+ xv: entry({
+ type: "xv",
+ label: "XV pair",
+ category: "adjacency",
+ negatable: true,
+ fields: [
+ typeField,
+ cellField("a"),
+ cellField("b"),
+ integerField("total"),
+ negatedField,
+ ],
+ cells: (_size, constraint) => [constraint.a, constraint.b],
+ }),
+ inequality: entry({
+ type: "inequality",
+ label: "Inequality",
+ category: "adjacency",
+ negatable: true,
+ fields: [
+ typeField,
+ cellField("lesser"),
+ cellField("greater"),
+ negatedField,
+ ],
+ cells: (_size, constraint) => [constraint.lesser, constraint.greater],
+ }),
+ renban: entry({
+ type: "renban",
+ label: "Renban line",
+ category: "line",
+ negatable: true,
+ fields: [typeField, cellsField(), negatedField],
+ cells: (_size, constraint) => constraint.cells,
+ }),
+ palindrome: entry({
+ type: "palindrome",
+ label: "Palindrome line",
+ category: "line",
+ negatable: true,
+ fields: [typeField, cellsField(), negatedField],
+ cells: (_size, constraint) => constraint.cells,
+ }),
+ "x-sum": entry({
+ type: "x-sum",
+ label: "X-sum",
+ category: "outside",
+ negatable: true,
+ fields: outsideFields("sum"),
+ cells: (size, constraint) =>
+ outsideLineCells(size, constraint.side, constraint.index),
+ }),
+ skyscraper: entry({
+ type: "skyscraper",
+ label: "Skyscraper",
+ category: "outside",
+ negatable: true,
+ fields: outsideFields("count"),
+ cells: (size, constraint) =>
+ outsideLineCells(size, constraint.side, constraint.index),
+ }),
+ quadruple: entry({
+ type: "quadruple",
+ label: "Quadruple",
+ category: "cell",
+ negatable: true,
+ fields: [
+ typeField,
+ cellsField(),
+ { key: "digits", kind: "integers", required: true },
+ negatedField,
+ ],
+ cells: (_size, constraint) => constraint.cells,
+ }),
+ maximum: entry({
+ type: "maximum",
+ label: "Maximum cell",
+ category: "cell",
+ negatable: true,
+ fields: [typeField, cellField(), negatedField],
+ cells: (size, constraint) => [
+ constraint.cell,
+ ...orthogonalNeighbours(size, constraint.cell),
+ ],
+ }),
+ minimum: entry({
+ type: "minimum",
+ label: "Minimum cell",
+ category: "cell",
+ negatable: true,
+ fields: [typeField, cellField(), negatedField],
+ cells: (size, constraint) => [
+ constraint.cell,
+ ...orthogonalNeighbours(size, constraint.cell),
+ ],
+ }),
+ odd: entry({
+ type: "odd",
+ label: "Odd cell",
+ category: "cell",
+ negatable: true,
+ fields: [typeField, cellField(), negatedField],
+ cells: (_size, constraint) => [constraint.cell],
+ }),
+ even: entry({
+ type: "even",
+ label: "Even cell",
+ category: "cell",
+ negatable: true,
+ fields: [typeField, cellField(), negatedField],
+ cells: (_size, constraint) => [constraint.cell],
+ }),
+ "little-killer": entry({
+ type: "little-killer",
+ label: "Little killer",
+ category: "outside",
+ negatable: true,
+ fields: [
+ typeField,
+ { key: "side", kind: "outside-side", required: true },
+ integerField("index"),
+ enumField("direction"),
+ integerField("sum"),
+ negatedField,
+ ],
+ cells: (size, constraint) =>
+ littleKillerCells(
+ size,
+ constraint.side,
+ constraint.index,
+ constraint.direction,
+ ),
+ }),
+ sandwich: entry({
+ type: "sandwich",
+ label: "Sandwich sum",
+ category: "outside",
+ negatable: true,
+ fields: outsideFields("sum"),
+ cells: (size, constraint) =>
+ outsideLineCells(size, constraint.side, constraint.index),
+ }),
+ "between-line": entry({
+ type: "between-line",
+ label: "Between line",
+ category: "line",
+ negatable: true,
+ fields: [typeField, cellsField(), negatedField],
+ cells: (_size, constraint) => constraint.cells,
+ }),
+ "german-whisper": entry({
+ type: "german-whisper",
+ label: "German whisper",
+ category: "line",
+ negatable: true,
+ fields: [
+ typeField,
+ cellsField(),
+ { key: "minimumDifference", kind: "integer", required: false },
+ negatedField,
+ ],
+ cells: (_size, constraint) => constraint.cells,
+ }),
+ "region-sum-line": entry({
+ type: "region-sum-line",
+ label: "Region-sum line",
+ category: "line",
+ negatable: true,
+ fields: [typeField, cellsField(), negatedField],
+ cells: (_size, constraint) => constraint.cells,
+ }),
+ clone: entry({
+ type: "clone",
+ label: "Clone regions",
+ category: "region",
+ negatable: true,
+ fields: [typeField, cellsField(), cellsField("cloneCells"), negatedField],
+ cells: (_size, constraint) => [
+ ...constraint.cells,
+ ...constraint.cloneCells,
+ ],
+ }),
+ "extra-region": entry({
+ type: "extra-region",
+ label: "Extra region",
+ category: "region",
+ negatable: false,
+ fields: [typeField, cellsField()],
+ cells: (_size, constraint) => constraint.cells,
+ }),
+ "modular-line": entry({
+ type: "modular-line",
+ label: "Modular line",
+ category: "line",
+ negatable: true,
+ fields: [typeField, cellsField(), negatedField],
+ cells: (_size, constraint) => constraint.cells,
+ }),
+ "entropic-line": entry({
+ type: "entropic-line",
+ label: "Entropic line",
+ category: "line",
+ negatable: true,
+ fields: [typeField, cellsField(), negatedField],
+ cells: (_size, constraint) => constraint.cells,
+ }),
+ "zipper-line": entry({
+ type: "zipper-line",
+ label: "Zipper line",
+ category: "line",
+ negatable: true,
+ fields: [typeField, cellsField(), negatedField],
+ cells: (_size, constraint) => constraint.cells,
+ }),
+ "double-arrow": entry({
+ type: "double-arrow",
+ label: "Double arrow",
+ category: "line",
+ negatable: true,
+ fields: [typeField, cellsField(), negatedField],
+ cells: (_size, constraint) => constraint.cells,
+ }),
+ indexer: entry({
+ type: "indexer",
+ label: "Indexer",
+ category: "cell",
+ negatable: true,
+ fields: [typeField, enumField("kind"), cellField(), negatedField],
+ cells: (size, constraint) => {
+ const row = Math.floor(constraint.cell / size);
+ const column = constraint.cell % size;
+ if (constraint.kind === "row") {
+ return Array.from(
+ { length: size },
+ (_, targetRow) => targetRow * size + column,
+ );
+ }
+ if (constraint.kind === "column") {
+ return Array.from(
+ { length: size },
+ (_, targetColumn) => row * size + targetColumn,
+ );
+ }
+ return correspondingBoxPositionCells(size, constraint.cell);
+ },
+ }),
+ fog: entry({
+ type: "fog",
+ label: "Fog of war",
+ category: "global",
+ negatable: false,
+ fields: [
+ typeField,
+ cellsField("lights"),
+ { key: "revealRadius", kind: "integer", required: false },
+ ],
+ cells: (_size, constraint) => constraint.lights,
+ }),
+} satisfies ConstraintRegistry;
+
+export const CONSTRAINT_TYPES = Object.freeze(
+ Object.keys(CONSTRAINT_REGISTRY) as ConstraintType[],
+);
+
+export function isConstraintType(value: string): value is ConstraintType {
+ return Object.hasOwn(CONSTRAINT_REGISTRY, value);
+}
+
+export function constraintMetadata(
+ type: Type,
+): ConstraintRegistryEntry {
+ return CONSTRAINT_REGISTRY[type] as unknown as ConstraintRegistryEntry;
+}
+
+export function constraintLabel(type: string): string {
+ if (isConstraintType(type)) return CONSTRAINT_REGISTRY[type].label;
+ return type
+ .split("-")
+ .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
+ .join(" ");
+}
+
+export function constraintAllowedFields(
+ type: ConstraintType,
+): ReadonlySet {
+ return new Set(CONSTRAINT_REGISTRY[type].fields.map(({ key }) => key));
+}
+
+export function constraintCells(
+ size: number,
+ constraint: VariantConstraint,
+): readonly CellId[] {
+ const resolver = CONSTRAINT_REGISTRY[constraint.type].cells as (
+ size: number,
+ constraint: never,
+ ) => readonly CellId[];
+ return [...new Set(resolver(size, constraint as never))];
+}
diff --git a/src/domain/geometry.ts b/src/domain/geometry.ts
index 4d04612..212cec1 100644
--- a/src/domain/geometry.ts
+++ b/src/domain/geometry.ts
@@ -3,6 +3,8 @@ import {
MAX_PUZZLE_SIZE,
MIN_PUZZLE_SIZE,
type CellId,
+ type LittleKillerDirection,
+ type OutsideClueSide,
type PuzzleDefinition,
} from "./types";
@@ -46,15 +48,17 @@ export function assertCell(size: number, cell: CellId): void {
}
}
-/**
- * Builds conventional rectangular regions. For non-composite sizes this falls
- * back to 1 x size regions, which is still a valid Latin-square topology.
- */
-export function classicRegions(
+export interface ClassicBoxDimensions {
+ readonly rows: number;
+ readonly columns: number;
+ readonly boxesPerRow: number;
+}
+
+export function classicBoxDimensions(
size: number,
boxRows?: number,
boxColumns?: number,
-): number[] {
+): ClassicBoxDimensions {
assertSize(size);
let rows = boxRows;
let columns = boxColumns;
@@ -84,8 +88,24 @@ export function classicRegions(
"Box rows and columns must be positive factors whose product is size.",
);
}
+ return { rows, columns, boxesPerRow: size / columns };
+}
+
+/**
+ * Builds conventional rectangular regions. For non-composite sizes this falls
+ * back to 1 x size regions, which is still a valid Latin-square topology.
+ */
+export function classicRegions(
+ size: number,
+ boxRows?: number,
+ boxColumns?: number,
+): number[] {
+ const { rows, columns, boxesPerRow } = classicBoxDimensions(
+ size,
+ boxRows,
+ boxColumns,
+ );
const regions = new Array(size * size);
- const boxesPerRow = size / columns;
for (let row = 0; row < size; row += 1) {
for (let column = 0; column < size; column += 1) {
regions[row * size + column] =
@@ -95,6 +115,52 @@ export function classicRegions(
return regions;
}
+export function classicBoxIndex(size: number, cell: CellId): number {
+ assertCell(size, cell);
+ const { rows, columns, boxesPerRow } = classicBoxDimensions(size);
+ return (
+ Math.floor(cellRow(size, cell) / rows) * boxesPerRow +
+ Math.floor(cellColumn(size, cell) / columns)
+ );
+}
+
+export function classicBoxPosition(size: number, cell: CellId): number {
+ assertCell(size, cell);
+ const { rows, columns } = classicBoxDimensions(size);
+ return (
+ (cellRow(size, cell) % rows) * columns + (cellColumn(size, cell) % columns)
+ );
+}
+
+export function classicBoxCell(
+ size: number,
+ boxIndex: number,
+ position: number,
+): CellId {
+ const { rows, columns, boxesPerRow } = classicBoxDimensions(size);
+ if (!Number.isInteger(boxIndex) || boxIndex < 0 || boxIndex >= size) {
+ throw new RangeError("Box index is outside the grid.");
+ }
+ if (!Number.isInteger(position) || position < 0 || position >= size) {
+ throw new RangeError("Box position is outside the grid.");
+ }
+ const boxRow = Math.floor(boxIndex / boxesPerRow);
+ const boxColumn = boxIndex % boxesPerRow;
+ const row = boxRow * rows + Math.floor(position / columns);
+ const column = boxColumn * columns + (position % columns);
+ return cellId(size, row, column);
+}
+
+export function correspondingBoxPositionCells(
+ size: number,
+ cell: CellId,
+): CellId[] {
+ const position = classicBoxPosition(size, cell);
+ return Array.from({ length: size }, (_, box) =>
+ classicBoxCell(size, box, position),
+ );
+}
+
export function createEmptyPuzzle(
size = 9,
options: {
@@ -127,6 +193,76 @@ export function orthogonalNeighbours(size: number, cell: CellId): CellId[] {
return result;
}
+/** Cells seen from an outside clue, ordered from the clue into the grid. */
+export function outsideLineCells(
+ size: number,
+ side: OutsideClueSide,
+ index: number,
+): CellId[] {
+ assertSize(size);
+ if (!Number.isInteger(index) || index < 0 || index >= size) return [];
+ return Array.from({ length: size }, (_, offset) => {
+ switch (side) {
+ case "top":
+ return offset * size + index;
+ case "right":
+ return index * size + size - offset - 1;
+ case "bottom":
+ return (size - offset - 1) * size + index;
+ case "left":
+ return index * size + offset;
+ }
+ });
+}
+
+export function littleKillerDirectionEntersGrid(
+ side: OutsideClueSide,
+ direction: LittleKillerDirection,
+): boolean {
+ switch (side) {
+ case "top":
+ return direction === "down-left" || direction === "down-right";
+ case "right":
+ return direction === "down-left" || direction === "up-left";
+ case "bottom":
+ return direction === "up-left" || direction === "up-right";
+ case "left":
+ return direction === "down-right" || direction === "up-right";
+ }
+}
+
+/** Diagonal cells crossed by a little-killer clue, from its outside origin. */
+export function littleKillerCells(
+ size: number,
+ side: OutsideClueSide,
+ index: number,
+ direction: LittleKillerDirection,
+): CellId[] {
+ assertSize(size);
+ if (!Number.isInteger(index) || index < 0 || index >= size) return [];
+ const [rowStep, columnStep] = (() => {
+ switch (direction) {
+ case "down-right":
+ return [1, 1] as const;
+ case "down-left":
+ return [1, -1] as const;
+ case "up-right":
+ return [-1, 1] as const;
+ case "up-left":
+ return [-1, -1] as const;
+ }
+ })();
+ let row = side === "top" ? 0 : side === "bottom" ? size - 1 : index;
+ let column = side === "left" ? 0 : side === "right" ? size - 1 : index;
+ const cells: CellId[] = [];
+ while (row >= 0 && row < size && column >= 0 && column < size) {
+ cells.push(row * size + column);
+ row += rowStep;
+ column += columnStep;
+ }
+ return cells;
+}
+
/** True when four cells meet at one internal grid intersection. */
export function cellsFormQuadruple(
size: number,
diff --git a/src/domain/index.ts b/src/domain/index.ts
index abce4ac..dfdd660 100644
--- a/src/domain/index.ts
+++ b/src/domain/index.ts
@@ -1,4 +1,5 @@
export * from "./compile";
+export * from "./constraintRegistry";
export * from "./geometry";
export * from "./rules";
export * from "./types";
diff --git a/src/domain/rules.ts b/src/domain/rules.ts
index 8faa670..d02d130 100644
--- a/src/domain/rules.ts
+++ b/src/domain/rules.ts
@@ -1,9 +1,14 @@
+import { compilePuzzle, type CompiledPuzzle } from "./compile";
+import { constraintCells } from "./constraintRegistry";
import {
- compilePuzzle,
+ classicBoxCell,
+ classicBoxIndex,
+ classicBoxPosition,
+ classicRegions,
+ littleKillerCells,
+ orthogonalNeighbours,
outsideLineCells,
- type CompiledPuzzle,
-} from "./compile";
-import { orthogonalNeighbours } from "./geometry";
+} from "./geometry";
import type {
CellId,
NormalizedPuzzle,
@@ -255,33 +260,382 @@ function quadrupleCanMeet(
return missing <= blanks;
}
-function maximumCanMeet(
+function extremumCanMeet(
cell: CellId,
values: readonly number[],
size: number,
+ kind: "maximum" | "minimum",
): boolean {
- const maximum = values[cell] ?? 0;
+ const extremum = values[cell] ?? 0;
const neighbours = orthogonalNeighbours(size, cell).map(
(neighbour) => values[neighbour] ?? 0,
);
- if (maximum === 0) {
- return neighbours.every((value) => value === 0 || value < size);
+ if (extremum === 0) {
+ return neighbours.every((value) =>
+ value === 0 ? true : kind === "maximum" ? value < size : value > 1,
+ );
}
- return neighbours.every((value) =>
- value === 0 ? maximum > 1 : value < maximum,
+ return neighbours.every((value) => {
+ if (value === 0) return kind === "maximum" ? extremum > 1 : extremum < size;
+ return kind === "maximum" ? value < extremum : value > extremum;
+ });
+}
+
+function littleKillerCanMeet(
+ values: readonly number[],
+ cells: readonly CellId[],
+ size: number,
+ target: number,
+): boolean {
+ const [minimum, maximum] = sumsCanMeet(values, cells, size);
+ return target >= minimum && target <= maximum;
+}
+
+function sandwichPossibleSums(
+ values: readonly number[],
+ cells: readonly CellId[],
+ size: number,
+): ReadonlySet {
+ const line = valuesAt(values, cells);
+ const fixed = line.filter((value) => value !== 0);
+ if (
+ fixed.some((value) => value < 1 || value > size) ||
+ new Set(fixed).size !== fixed.length
+ ) {
+ return new Set();
+ }
+ const fixedOne = line.indexOf(1);
+ const fixedMaximum = line.indexOf(size);
+ const possible = new Set();
+ for (let one = 0; one < size; one += 1) {
+ if (fixedOne >= 0 && one !== fixedOne) continue;
+ if (line[one] !== 0 && line[one] !== 1) continue;
+ for (let maximum = 0; maximum < size; maximum += 1) {
+ if (maximum === one) continue;
+ if (fixedMaximum >= 0 && maximum !== fixedMaximum) continue;
+ if (line[maximum] !== 0 && line[maximum] !== size) continue;
+ const start = Math.min(one, maximum) + 1;
+ const end = Math.max(one, maximum);
+ let assignedSum = 0;
+ let blanks = 0;
+ for (let position = start; position < end; position += 1) {
+ const value = line[position] ?? 0;
+ if (value === 0) blanks += 1;
+ else assignedSum += value;
+ }
+ const reserved = new Set(fixed);
+ reserved.add(1);
+ reserved.add(size);
+ const available = Array.from(
+ { length: Math.max(0, size - 2) },
+ (_, index) => index + 2,
+ ).filter((digit) => !reserved.has(digit));
+ const maximumExtra = available.reduce((sum, digit) => sum + digit, 0);
+ for (let extra = 0; extra <= maximumExtra; extra += 1) {
+ if (canChooseDistinctSum(available, blanks, extra)) {
+ possible.add(assignedSum + extra);
+ }
+ }
+ }
+ }
+ return possible;
+}
+
+function betweenLineCanMeet(
+ cells: readonly CellId[],
+ values: readonly number[],
+ size: number,
+ negated: boolean,
+): boolean {
+ const firstFixed = values[cells[0] ?? -1] ?? 0;
+ const lastFixed = values[cells.at(-1) ?? -1] ?? 0;
+ const choices = (fixed: number): readonly number[] =>
+ fixed === 0
+ ? Array.from({ length: size }, (_, index) => index + 1)
+ : [fixed];
+ for (const first of choices(firstFixed)) {
+ for (const last of choices(lastFixed)) {
+ const low = Math.min(first, last);
+ const high = Math.max(first, last);
+ if (high - low < 2) {
+ if (negated) return true;
+ continue;
+ }
+ let positivePossible = true;
+ let negativePossible = false;
+ for (const cell of cells.slice(1, -1)) {
+ const value = values[cell] ?? 0;
+ if (value === 0) {
+ negativePossible = true;
+ continue;
+ }
+ if (value <= low || value >= high) {
+ positivePossible = false;
+ negativePossible = true;
+ }
+ }
+ if (negated ? negativePossible : positivePossible) return true;
+ }
+ }
+ return false;
+}
+
+function germanWhisperCanMeet(
+ cells: readonly CellId[],
+ values: readonly number[],
+ size: number,
+ minimumDifference: number,
+ requireViolation: boolean,
+): boolean {
+ const choices = (cell: CellId): readonly number[] => {
+ const fixed = values[cell] ?? 0;
+ return fixed === 0
+ ? Array.from({ length: size }, (_, index) => index + 1)
+ : [fixed];
+ };
+ let states = new Set(choices(cells[0] ?? -1).map((digit) => `${digit}:0`));
+ for (let position = 1; position < cells.length; position += 1) {
+ const next = new Set();
+ for (const state of states) {
+ const [previousText, violationText] = state.split(":");
+ const previous = Number(previousText);
+ const violated = violationText === "1";
+ for (const digit of choices(cells[position] ?? -1)) {
+ const pairViolates = Math.abs(previous - digit) < minimumDifference;
+ if (!requireViolation && pairViolates) continue;
+ next.add(`${digit}:${violated || pairViolates ? "1" : "0"}`);
+ }
+ }
+ states = next;
+ if (states.size === 0) return false;
+ }
+ return requireViolation
+ ? [...states].some((state) => state.endsWith(":1"))
+ : states.size > 0;
+}
+
+function regionLineSegments(
+ cells: readonly CellId[],
+ regions: readonly number[],
+): readonly (readonly CellId[])[] {
+ const segments: CellId[][] = [];
+ for (const cell of cells) {
+ const previous = segments.at(-1);
+ const previousCell = previous?.at(-1);
+ if (
+ previous === undefined ||
+ previousCell === undefined ||
+ regions[previousCell] !== regions[cell]
+ ) {
+ segments.push([cell]);
+ } else {
+ previous.push(cell);
+ }
+ }
+ return segments;
+}
+
+function regionSumRanges(
+ cells: readonly CellId[],
+ values: readonly number[],
+ regions: readonly number[],
+ size: number,
+): readonly (readonly [number, number])[] {
+ return regionLineSegments(cells, regions).map((segment) =>
+ sumsCanMeet(values, segment, size),
);
}
+function regionSumLineCanMeet(
+ cells: readonly CellId[],
+ values: readonly number[],
+ regions: readonly number[],
+ size: number,
+ negated: boolean,
+): boolean {
+ const ranges = regionSumRanges(cells, values, regions, size);
+ if (ranges.length < 2) return !negated;
+ if (negated) {
+ const first = ranges[0]!;
+ return (
+ first[0] !== first[1] ||
+ ranges
+ .slice(1)
+ .some((range) => range[0] !== range[1] || range[0] !== first[0])
+ );
+ }
+ const minimum = Math.max(...ranges.map((range) => range[0]));
+ const maximum = Math.min(...ranges.map((range) => range[1]));
+ return minimum <= maximum;
+}
+
+function cloneCanMeet(
+ source: readonly CellId[],
+ clone: readonly CellId[],
+ values: readonly number[],
+ negated: boolean,
+): boolean {
+ let hasUnknown = false;
+ for (let index = 0; index < source.length; index += 1) {
+ const first = values[source[index] ?? -1] ?? 0;
+ const second = values[clone[index] ?? -1] ?? 0;
+ if (first === 0 || second === 0) hasUnknown = true;
+ else if (first !== second) return negated;
+ }
+ return negated ? hasUnknown : true;
+}
+
+const THREE_CLASS_PERMUTATIONS = [
+ [0, 1, 2],
+ [0, 2, 1],
+ [1, 0, 2],
+ [1, 2, 0],
+ [2, 0, 1],
+ [2, 1, 0],
+] as const;
+
+function threeClassLineCanMeet(
+ cells: readonly CellId[],
+ values: readonly number[],
+ classify: (value: number) => 0 | 1 | 2,
+ negated: boolean,
+): boolean {
+ if (negated && cells.some((cell) => (values[cell] ?? 0) === 0)) {
+ // Every class has at least one digit on validated grids. A blank can
+ // therefore be chosen to violate one window; peer rules can only narrow
+ // this, so retaining the state is a sound over-approximation.
+ return true;
+ }
+ const positive = THREE_CLASS_PERMUTATIONS.some((classes) =>
+ cells.every((cell, position) => {
+ const value = values[cell] ?? 0;
+ return value === 0 || classify(value) === classes[position % 3];
+ }),
+ );
+ return negated ? !positive : positive;
+}
+
+function zipperLineCanMeet(
+ cells: readonly CellId[],
+ values: readonly number[],
+ size: number,
+ negated: boolean,
+): boolean {
+ const middle = Math.floor(cells.length / 2);
+ const fixedCentre = values[cells[middle] ?? -1] ?? 0;
+ const centres =
+ fixedCentre === 0
+ ? Array.from({ length: size }, (_, index) => index + 1)
+ : [fixedCentre];
+ for (const centre of centres) {
+ let positivePossible = true;
+ let negativePossible = false;
+ for (let offset = 1; offset <= middle; offset += 1) {
+ const left = values[cells[middle - offset] ?? -1] ?? 0;
+ const right = values[cells[middle + offset] ?? -1] ?? 0;
+ if (left !== 0 && right !== 0) {
+ if (left + right !== centre) {
+ positivePossible = false;
+ negativePossible = true;
+ }
+ continue;
+ }
+ negativePossible = true;
+ if (left === 0 && right === 0) {
+ if (centre < 2 || centre > size * 2) positivePossible = false;
+ } else {
+ const fixed = left === 0 ? right : left;
+ const missing = centre - fixed;
+ if (missing < 1 || missing > size) positivePossible = false;
+ }
+ }
+ if (negated ? negativePossible : positivePossible) return true;
+ }
+ return false;
+}
+
+function doubleArrowCanMeet(
+ cells: readonly CellId[],
+ values: readonly number[],
+ size: number,
+ negated: boolean,
+): boolean {
+ const endpoints = [cells[0]!, cells.at(-1)!];
+ const interior = cells.slice(1, -1);
+ const endpointRange = sumsCanMeet(values, endpoints, size);
+ const interiorRange = sumsCanMeet(values, interior, size);
+ if (negated) {
+ const complete = cells.every((cell) => (values[cell] ?? 0) !== 0);
+ if (!complete) {
+ // Each blank occurs on only one side of the equation and has at least
+ // four possible raw digits. Keeping it cannot reject a valid completion.
+ return true;
+ }
+ return endpointRange[0] !== interiorRange[0];
+ }
+ return (
+ endpointRange[0] <= interiorRange[1] && interiorRange[0] <= endpointRange[1]
+ );
+}
+
+function indexerTarget(
+ kind: "row" | "column" | "box",
+ cell: CellId,
+ value: number,
+ size: number,
+): readonly [target: CellId, required: number] {
+ const row = Math.floor(cell / size);
+ const column = cell % size;
+ if (kind === "row") return [(value - 1) * size + column, row + 1];
+ if (kind === "column") return [row * size + value - 1, column + 1];
+ return [
+ classicBoxCell(size, value - 1, classicBoxPosition(size, cell)),
+ classicBoxIndex(size, cell) + 1,
+ ];
+}
+
+function indexerCanMeet(
+ constraint: Extract,
+ values: readonly number[],
+ size: number,
+ negated: boolean,
+): boolean {
+ const fixed = values[constraint.cell] ?? 0;
+ const candidates =
+ fixed === 0
+ ? Array.from({ length: size }, (_, index) => index + 1)
+ : [fixed];
+ for (const value of candidates) {
+ const [target, required] = indexerTarget(
+ constraint.kind,
+ constraint.cell,
+ value,
+ size,
+ );
+ const targetValue =
+ target === constraint.cell ? value : (values[target] ?? 0);
+ if (!negated && (targetValue === 0 || targetValue === required))
+ return true;
+ if (negated && (targetValue === 0 || targetValue !== required)) return true;
+ }
+ return false;
+}
+
function positiveConstraintIsFeasible(
constraint: VariantConstraint,
values: readonly number[],
size: number,
+ regions: readonly number[],
): boolean {
switch (constraint.type) {
case "diagonal":
case "anti-knight":
case "anti-king":
+ case "disjoint-groups":
+ case "extra-region":
return true; // Equality conflicts are represented in compiled peers/units.
+ case "fog":
+ return true; // Fog is presentation state and never changes solutions.
case "non-consecutive": {
for (let cell = 0; cell < size * size; cell += 1) {
const value = values[cell] ?? 0;
@@ -400,7 +754,82 @@ function positiveConstraintIsFeasible(
case "quadruple":
return quadrupleCanMeet(constraint, values);
case "maximum":
- return maximumCanMeet(constraint.cell, values, size);
+ return extremumCanMeet(constraint.cell, values, size, "maximum");
+ case "minimum":
+ return extremumCanMeet(constraint.cell, values, size, "minimum");
+ case "odd": {
+ const value = values[constraint.cell] ?? 0;
+ return value === 0 || value % 2 === 1;
+ }
+ case "even": {
+ const value = values[constraint.cell] ?? 0;
+ return value === 0 || value % 2 === 0;
+ }
+ case "little-killer":
+ return littleKillerCanMeet(
+ values,
+ littleKillerCells(
+ size,
+ constraint.side,
+ constraint.index,
+ constraint.direction,
+ ),
+ size,
+ constraint.sum,
+ );
+ case "sandwich":
+ return sandwichPossibleSums(
+ values,
+ outsideLineCells(size, constraint.side, constraint.index),
+ size,
+ ).has(constraint.sum);
+ case "between-line":
+ return betweenLineCanMeet(constraint.cells, values, size, false);
+ case "german-whisper":
+ return germanWhisperCanMeet(
+ constraint.cells,
+ values,
+ size,
+ constraint.minimumDifference ?? Math.ceil(size / 2),
+ false,
+ );
+ case "region-sum-line":
+ return regionSumLineCanMeet(
+ constraint.cells,
+ values,
+ regions,
+ size,
+ false,
+ );
+ case "clone":
+ return cloneCanMeet(
+ constraint.cells,
+ constraint.cloneCells,
+ values,
+ false,
+ );
+ case "modular-line":
+ return threeClassLineCanMeet(
+ constraint.cells,
+ values,
+ (value) => (value % 3) as 0 | 1 | 2,
+ false,
+ );
+ case "entropic-line": {
+ const bandSize = size / 3;
+ return threeClassLineCanMeet(
+ constraint.cells,
+ values,
+ (value) => Math.floor((value - 1) / bandSize) as 0 | 1 | 2,
+ false,
+ );
+ }
+ case "zipper-line":
+ return zipperLineCanMeet(constraint.cells, values, size, false);
+ case "double-arrow":
+ return doubleArrowCanMeet(constraint.cells, values, size, false);
+ case "indexer":
+ return indexerCanMeet(constraint, values, size, false);
}
}
@@ -408,31 +837,8 @@ function completedCells(
constraint: VariantConstraint,
size: number,
): readonly CellId[] | undefined {
- switch (constraint.type) {
- case "diagonal":
- case "anti-knight":
- case "anti-king":
- case "non-consecutive":
- return undefined;
- case "killer-cage":
- case "thermo":
- case "renban":
- case "palindrome":
- case "quadruple":
- return constraint.cells;
- case "arrow":
- return [...constraint.bulb, ...constraint.line];
- case "kropki":
- case "xv":
- return [constraint.a, constraint.b];
- case "inequality":
- return [constraint.lesser, constraint.greater];
- case "x-sum":
- case "skyscraper":
- return outsideLineCells(size, constraint.side, constraint.index);
- case "maximum":
- return [constraint.cell, ...orthogonalNeighbours(size, constraint.cell)];
- }
+ if (!("negated" in constraint)) return undefined;
+ return constraintCells(size, constraint);
}
/**
@@ -446,6 +852,7 @@ function negatedConstraintIsFeasible(
constraint: VariantConstraint,
values: readonly number[],
size: number,
+ regions: readonly number[],
): boolean {
if (constraint.type === "x-sum") {
const line = valuesAt(
@@ -476,15 +883,89 @@ function negatedConstraintIsFeasible(
}
if (constraint.type === "maximum") {
- const maximum = values[constraint.cell] ?? 0;
- if (maximum === 0) return true;
+ const extremum = values[constraint.cell] ?? 0;
+ if (extremum === 0) return true;
const neighbours = orthogonalNeighbours(size, constraint.cell).map(
(cell) => values[cell] ?? 0,
);
- if (neighbours.some((value) => value !== 0 && value >= maximum)) {
+ if (neighbours.some((value) => value !== 0 && value >= extremum)) {
return true;
}
- return !(maximum === size || neighbours.every((value) => value !== 0));
+ return !(extremum === size || neighbours.every((value) => value !== 0));
+ }
+
+ if (constraint.type === "minimum") {
+ const extremum = values[constraint.cell] ?? 0;
+ if (extremum === 0) return true;
+ const neighbours = orthogonalNeighbours(size, constraint.cell).map(
+ (cell) => values[cell] ?? 0,
+ );
+ if (neighbours.some((value) => value !== 0 && value <= extremum)) {
+ return true;
+ }
+ return !(extremum === 1 || neighbours.every((value) => value !== 0));
+ }
+
+ if (constraint.type === "sandwich") {
+ const sums = sandwichPossibleSums(
+ values,
+ outsideLineCells(size, constraint.side, constraint.index),
+ size,
+ );
+ return [...sums].some((sum) => sum !== constraint.sum);
+ }
+
+ if (constraint.type === "between-line") {
+ return betweenLineCanMeet(constraint.cells, values, size, true);
+ }
+
+ if (constraint.type === "german-whisper") {
+ return germanWhisperCanMeet(
+ constraint.cells,
+ values,
+ size,
+ constraint.minimumDifference ?? Math.ceil(size / 2),
+ true,
+ );
+ }
+
+ if (constraint.type === "region-sum-line") {
+ return regionSumLineCanMeet(constraint.cells, values, regions, size, true);
+ }
+
+ if (constraint.type === "clone") {
+ return cloneCanMeet(constraint.cells, constraint.cloneCells, values, true);
+ }
+
+ if (constraint.type === "modular-line") {
+ return threeClassLineCanMeet(
+ constraint.cells,
+ values,
+ (value) => (value % 3) as 0 | 1 | 2,
+ true,
+ );
+ }
+
+ if (constraint.type === "entropic-line") {
+ const bandSize = size / 3;
+ return threeClassLineCanMeet(
+ constraint.cells,
+ values,
+ (value) => Math.floor((value - 1) / bandSize) as 0 | 1 | 2,
+ true,
+ );
+ }
+
+ if (constraint.type === "zipper-line") {
+ return zipperLineCanMeet(constraint.cells, values, size, true);
+ }
+
+ if (constraint.type === "double-arrow") {
+ return doubleArrowCanMeet(constraint.cells, values, size, true);
+ }
+
+ if (constraint.type === "indexer") {
+ return indexerCanMeet(constraint, values, size, true);
}
if (constraint.type === "palindrome") {
@@ -511,17 +992,19 @@ function negatedConstraintIsFeasible(
) {
return true;
}
- return !positiveConstraintIsFeasible(constraint, values, size);
+ return !positiveConstraintIsFeasible(constraint, values, size, regions);
}
export function constraintIsFeasible(
constraint: VariantConstraint,
values: readonly number[],
size: number,
+ regions: readonly number[] = classicRegions(size),
): boolean {
const negated = "negated" in constraint && constraint.negated === true;
- if (!negated) return positiveConstraintIsFeasible(constraint, values, size);
- return negatedConstraintIsFeasible(constraint, values, size);
+ if (!negated)
+ return positiveConstraintIsFeasible(constraint, values, size, regions);
+ return negatedConstraintIsFeasible(constraint, values, size, regions);
}
function asCompiled(
@@ -550,7 +1033,7 @@ export function canPlaceValue(
const constraint = compiled.puzzle.constraints[index];
if (
constraint !== undefined &&
- !constraintIsFeasible(constraint, next, size)
+ !constraintIsFeasible(constraint, next, size, compiled.puzzle.regions)
)
return false;
}
@@ -646,44 +1129,15 @@ export function findConflicts(
}
});
compiled.puzzle.constraints.forEach((constraint, constraintIndex) => {
- if (!constraintIsFeasible(constraint, board, compiled.puzzle.size)) {
- const cells = (() => {
- switch (constraint.type) {
- case "diagonal":
- case "anti-knight":
- case "anti-king":
- case "non-consecutive":
- return Array.from(
- { length: compiled.puzzle.size * compiled.puzzle.size },
- (_, cell) => cell,
- );
- case "killer-cage":
- case "thermo":
- case "renban":
- case "palindrome":
- case "quadruple":
- return constraint.cells;
- case "arrow":
- return [...constraint.bulb, ...constraint.line];
- case "kropki":
- case "xv":
- return [constraint.a, constraint.b];
- case "inequality":
- return [constraint.lesser, constraint.greater];
- case "x-sum":
- case "skyscraper":
- return outsideLineCells(
- compiled.puzzle.size,
- constraint.side,
- constraint.index,
- );
- case "maximum":
- return [
- constraint.cell,
- ...orthogonalNeighbours(compiled.puzzle.size, constraint.cell),
- ];
- }
- })();
+ if (
+ !constraintIsFeasible(
+ constraint,
+ board,
+ compiled.puzzle.size,
+ compiled.puzzle.regions,
+ )
+ ) {
+ const cells = constraintCells(compiled.puzzle.size, constraint);
conflicts.push({
kind: "constraint",
cells,
diff --git a/src/domain/types.ts b/src/domain/types.ts
index 0bf003a..14511f4 100644
--- a/src/domain/types.ts
+++ b/src/domain/types.ts
@@ -23,6 +23,11 @@ export interface NonConsecutiveConstraint {
readonly type: "non-consecutive";
}
+/** Corresponding positions in every standard box form an additional house. */
+export interface DisjointGroupsConstraint {
+ readonly type: "disjoint-groups";
+}
+
export interface KillerCageConstraint {
readonly type: "killer-cage";
readonly cells: readonly CellId[];
@@ -122,11 +127,134 @@ export interface MaximumConstraint {
readonly negated?: boolean;
}
+/** The marked cell is less than each orthogonally adjacent cell. */
+export interface MinimumConstraint {
+ readonly type: "minimum";
+ readonly cell: CellId;
+ readonly negated?: boolean;
+}
+
+/** The marked cell contains an odd digit. */
+export interface OddConstraint {
+ readonly type: "odd";
+ readonly cell: CellId;
+ readonly negated?: boolean;
+}
+
+/** The marked cell contains an even digit. */
+export interface EvenConstraint {
+ readonly type: "even";
+ readonly cell: CellId;
+ readonly negated?: boolean;
+}
+
+export type LittleKillerDirection =
+ "down-right" | "down-left" | "up-right" | "up-left";
+
+/** An outside sum along a diagonal entering the grid from one edge. */
+export interface LittleKillerConstraint {
+ readonly type: "little-killer";
+ readonly side: OutsideClueSide;
+ readonly index: number;
+ readonly direction: LittleKillerDirection;
+ readonly sum: number;
+ readonly negated?: boolean;
+}
+
+/** Sum of the digits strictly between 1 and N on an outside line. */
+export interface SandwichConstraint {
+ readonly type: "sandwich";
+ readonly side: OutsideClueSide;
+ readonly index: number;
+ readonly sum: number;
+ readonly negated?: boolean;
+}
+
+/** Interior line digits lie strictly between the two endpoint values. */
+export interface BetweenLineConstraint {
+ readonly type: "between-line";
+ readonly cells: readonly CellId[];
+ readonly negated?: boolean;
+}
+
+/** Adjacent line digits differ by at least the configured amount. */
+export interface GermanWhisperConstraint {
+ readonly type: "german-whisper";
+ readonly cells: readonly CellId[];
+ /** Defaults to ceil(size / 2). */
+ readonly minimumDifference?: number;
+ readonly negated?: boolean;
+}
+
+/** Every contiguous segment in a region has the same sum. */
+export interface RegionSumLineConstraint {
+ readonly type: "region-sum-line";
+ readonly cells: readonly CellId[];
+ readonly negated?: boolean;
+}
+
+/** Ordered cells in the two regions contain pairwise equal digits. */
+export interface CloneConstraint {
+ readonly type: "clone";
+ readonly cells: readonly CellId[];
+ readonly cloneCells: readonly CellId[];
+ readonly negated?: boolean;
+}
+
+/** An additional all-different house containing exactly N cells. */
+export interface ExtraRegionConstraint {
+ readonly type: "extra-region";
+ readonly cells: readonly CellId[];
+}
+
+/** Every three consecutive cells contain all three digit residues modulo 3. */
+export interface ModularLineConstraint {
+ readonly type: "modular-line";
+ readonly cells: readonly CellId[];
+ readonly negated?: boolean;
+}
+
+/** Every three consecutive cells contain one digit from each equal value band. */
+export interface EntropicLineConstraint {
+ readonly type: "entropic-line";
+ readonly cells: readonly CellId[];
+ readonly negated?: boolean;
+}
+
+/** Equidistant pairs sum to the digit in the line's centre cell. */
+export interface ZipperLineConstraint {
+ readonly type: "zipper-line";
+ readonly cells: readonly CellId[];
+ readonly negated?: boolean;
+}
+
+/** The two endpoint digits sum to all interior digits combined. */
+export interface DoubleArrowConstraint {
+ readonly type: "double-arrow";
+ readonly cells: readonly CellId[];
+ readonly negated?: boolean;
+}
+
+export interface IndexerConstraint {
+ readonly type: "indexer";
+ readonly kind: "row" | "column" | "box";
+ readonly cell: CellId;
+ readonly negated?: boolean;
+}
+
+/** Canonical reveal seed data; fog never changes Sudoku solution semantics. */
+export interface FogConstraint {
+ readonly type: "fog";
+ readonly lights: readonly CellId[];
+ readonly revealRadius?: 0 | 1;
+}
+
export type VariantConstraint =
| DiagonalConstraint
| AntiKnightConstraint
| AntiKingConstraint
| NonConsecutiveConstraint
+ | DisjointGroupsConstraint
| KillerCageConstraint
| ThermoConstraint
| ArrowConstraint
@@ -138,7 +266,23 @@ export type VariantConstraint =
| XSumConstraint
| SkyscraperConstraint
| QuadrupleConstraint
- | MaximumConstraint;
+ | MaximumConstraint
+ | MinimumConstraint
+ | OddConstraint
+ | EvenConstraint
+ | LittleKillerConstraint
+ | SandwichConstraint
+ | BetweenLineConstraint
+ | GermanWhisperConstraint
+ | RegionSumLineConstraint
+ | CloneConstraint
+ | ExtraRegionConstraint
+ | ModularLineConstraint
+ | EntropicLineConstraint
+ | ZipperLineConstraint
+ | DoubleArrowConstraint
+ | IndexerConstraint
+ | FogConstraint;
export interface PuzzleDefinition {
readonly version: 1;
diff --git a/src/domain/validation.ts b/src/domain/validation.ts
index 468a0d5..c42bcda 100644
--- a/src/domain/validation.ts
+++ b/src/domain/validation.ts
@@ -1,11 +1,22 @@
-import { cellsFormQuadruple, classicRegions } from "./geometry";
+import {
+ cellsFormQuadruple,
+ classicRegions,
+ littleKillerCells,
+ littleKillerDirectionEntersGrid,
+} from "./geometry";
import { compilePuzzle } from "./compile";
+import {
+ constraintAllowedFields,
+ isConstraintType,
+} from "./constraintRegistry";
import { findConflicts } from "./rules";
import {
MAX_PUZZLE_SIZE,
MIN_PUZZLE_SIZE,
PuzzleValidationError,
+ type LittleKillerDirection,
type NormalizedPuzzle,
+ type OutsideClueSide,
type PuzzleDefinition,
type ValidationIssue,
type ValidationResult,
@@ -30,28 +41,8 @@ const ROOT_KEYS = new Set([
"solution",
]);
-const CONSTRAINT_KEYS: Readonly<
- Record>
-> = {
- diagonal: new Set(["type", "direction"]),
- "anti-knight": new Set(["type"]),
- "anti-king": new Set(["type"]),
- "non-consecutive": new Set(["type"]),
- "killer-cage": new Set(["type", "cells", "sum", "noRepeat", "negated"]),
- thermo: new Set(["type", "cells", "negated"]),
- arrow: new Set(["type", "bulb", "line", "negated"]),
- kropki: new Set(["type", "a", "b", "kind", "negated"]),
- xv: new Set(["type", "a", "b", "total", "negated"]),
- inequality: new Set(["type", "lesser", "greater", "negated"]),
- renban: new Set(["type", "cells", "negated"]),
- palindrome: new Set(["type", "cells", "negated"]),
- "x-sum": new Set(["type", "side", "index", "sum", "negated"]),
- skyscraper: new Set(["type", "side", "index", "count", "negated"]),
- quadruple: new Set(["type", "cells", "digits", "negated"]),
- maximum: new Set(["type", "cell", "negated"]),
-};
-
const reachableXSumCache = new Map>();
+const reachableSandwichCache = new Map>();
function isRecord(value: unknown): value is Record {
return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -182,6 +173,30 @@ function reachableXSumTotals(size: number): ReadonlySet {
return reachable;
}
+function reachableSandwichTotals(size: number): ReadonlySet {
+ const cached = reachableSandwichCache.get(size);
+ if (cached !== undefined) return cached;
+ const reachable = new Set([0]);
+ for (let digit = 2; digit < size; digit += 1) {
+ for (const sum of [...reachable]) reachable.add(sum + digit);
+ }
+ reachableSandwichCache.set(size, reachable);
+ return reachable;
+}
+
+function sameRegionPartition(
+ first: readonly number[],
+ second: readonly number[],
+): boolean {
+ if (first.length !== second.length) return false;
+ for (let a = 0; a < first.length; a += 1) {
+ for (let b = a + 1; b < first.length; b += 1) {
+ if ((first[a] === first[b]) !== (second[a] === second[b])) return false;
+ }
+ }
+ return true;
+}
+
function validateConstraint(
value: unknown,
index: number,
@@ -193,12 +208,12 @@ function validateConstraint(
add(issues, path, "must be a constraint object with a type");
return;
}
- const type = value.type as VariantConstraint["type"];
- const allowed = CONSTRAINT_KEYS[type];
- if (allowed === undefined) {
+ if (!isConstraintType(value.type)) {
add(issues, `${path}.type`, "is not a supported constraint type");
return;
}
+ const type = value.type;
+ const allowed = constraintAllowedFields(type);
for (const key of Object.keys(value)) {
if (!allowed.has(key))
add(issues, `${path}.${key}`, "is not a recognized field");
@@ -220,6 +235,7 @@ function validateConstraint(
case "anti-knight":
case "anti-king":
case "non-consecutive":
+ case "disjoint-groups":
break;
case "killer-cage": {
const cellsValid = validateCells(
@@ -410,8 +426,258 @@ function validateConstraint(
break;
}
case "maximum":
+ case "minimum":
+ case "odd":
+ case "even":
validateCell(value.cell, `${path}.cell`, cellCount, issues);
break;
+ case "little-killer": {
+ validateOutsideClue(value, path, size, issues);
+ const directionValid =
+ value.direction === "down-right" ||
+ value.direction === "down-left" ||
+ value.direction === "up-right" ||
+ value.direction === "up-left";
+ if (!directionValid) {
+ add(
+ issues,
+ `${path}.direction`,
+ 'must be "down-right", "down-left", "up-right" or "up-left"',
+ );
+ } else if (
+ (value.side === "top" ||
+ value.side === "right" ||
+ value.side === "bottom" ||
+ value.side === "left") &&
+ !littleKillerDirectionEntersGrid(
+ value.side,
+ value.direction as LittleKillerDirection,
+ )
+ ) {
+ add(issues, `${path}.direction`, "must point into the grid");
+ }
+ const indexValid =
+ Number.isInteger(value.index) &&
+ (value.index as number) >= 0 &&
+ (value.index as number) < size;
+ const sideValid =
+ value.side === "top" ||
+ value.side === "right" ||
+ value.side === "bottom" ||
+ value.side === "left";
+ const cells =
+ directionValid && indexValid && sideValid
+ ? littleKillerCells(
+ size,
+ value.side as OutsideClueSide,
+ value.index as number,
+ value.direction as LittleKillerDirection,
+ )
+ : [];
+ if (cells.length === 1) {
+ add(
+ issues,
+ path,
+ "little-killer diagonal must cross at least two cells",
+ );
+ }
+ if (!Number.isInteger(value.sum)) {
+ add(issues, `${path}.sum`, "must be an integer");
+ } else if (cells.length >= 2) {
+ const minimum = value.negated === true ? 1 : cells.length;
+ const maximum =
+ value.negated === true
+ ? maximumFalseClueValue(size)
+ : cells.length * size;
+ if (
+ (value.sum as number) < minimum ||
+ (value.sum as number) > maximum
+ ) {
+ add(
+ issues,
+ `${path}.sum`,
+ value.negated === true
+ ? `must be a bounded positive integer (1 to ${maximum})`
+ : `must be reachable (${minimum} to ${maximum})`,
+ );
+ }
+ }
+ break;
+ }
+ case "sandwich": {
+ validateOutsideClue(value, path, size, issues);
+ const valid = validateIntegerRange(
+ value.sum,
+ `${path}.sum`,
+ 0,
+ value.negated === true
+ ? maximumFalseClueValue(size)
+ : (size * (size + 1)) / 2 - size - 1,
+ issues,
+ );
+ if (
+ valid &&
+ value.negated !== true &&
+ !reachableSandwichTotals(size).has(value.sum as number)
+ ) {
+ add(
+ issues,
+ `${path}.sum`,
+ "cannot be formed by digits between 1 and the maximum digit",
+ );
+ }
+ break;
+ }
+ case "between-line":
+ validateCells(
+ value.cells,
+ `${path}.cells`,
+ cellCount,
+ 3,
+ cellCount,
+ issues,
+ );
+ break;
+ case "german-whisper":
+ validateCells(
+ value.cells,
+ `${path}.cells`,
+ cellCount,
+ 2,
+ cellCount,
+ issues,
+ );
+ if (value.minimumDifference !== undefined) {
+ validateIntegerRange(
+ value.minimumDifference,
+ `${path}.minimumDifference`,
+ 1,
+ size - 1,
+ issues,
+ );
+ }
+ break;
+ case "region-sum-line":
+ validateCells(
+ value.cells,
+ `${path}.cells`,
+ cellCount,
+ 2,
+ cellCount,
+ issues,
+ );
+ break;
+ case "clone": {
+ const sourceValid = validateCells(
+ value.cells,
+ `${path}.cells`,
+ cellCount,
+ 1,
+ cellCount,
+ issues,
+ );
+ const cloneValid = validateCells(
+ value.cloneCells,
+ `${path}.cloneCells`,
+ cellCount,
+ 1,
+ cellCount,
+ issues,
+ );
+ if (sourceValid && cloneValid) {
+ const source = value.cells as readonly number[];
+ const clone = value.cloneCells as readonly number[];
+ if (source.length !== clone.length) {
+ add(issues, path, "clone cell lists must have equal length");
+ }
+ }
+ break;
+ }
+ case "extra-region":
+ validateCells(
+ value.cells,
+ `${path}.cells`,
+ cellCount,
+ size,
+ size,
+ issues,
+ );
+ break;
+ case "modular-line":
+ validateCells(
+ value.cells,
+ `${path}.cells`,
+ cellCount,
+ 3,
+ cellCount,
+ issues,
+ );
+ break;
+ case "entropic-line":
+ validateCells(
+ value.cells,
+ `${path}.cells`,
+ cellCount,
+ 3,
+ cellCount,
+ issues,
+ );
+ if (size % 3 !== 0) {
+ add(issues, path, "entropic lines require a grid size divisible by 3");
+ }
+ break;
+ case "zipper-line":
+ if (
+ validateCells(
+ value.cells,
+ `${path}.cells`,
+ cellCount,
+ 3,
+ cellCount,
+ issues,
+ ) &&
+ (value.cells as readonly number[]).length % 2 === 0
+ ) {
+ add(issues, `${path}.cells`, "must contain an odd number of cells");
+ }
+ break;
+ case "double-arrow":
+ validateCells(
+ value.cells,
+ `${path}.cells`,
+ cellCount,
+ 3,
+ cellCount,
+ issues,
+ );
+ break;
+ case "indexer":
+ validateCell(value.cell, `${path}.cell`, cellCount, issues);
+ if (
+ value.kind !== "row" &&
+ value.kind !== "column" &&
+ value.kind !== "box"
+ ) {
+ add(issues, `${path}.kind`, 'must be "row", "column" or "box"');
+ }
+ break;
+ case "fog":
+ validateCells(
+ value.lights,
+ `${path}.lights`,
+ cellCount,
+ 1,
+ cellCount,
+ issues,
+ );
+ if (
+ value.revealRadius !== undefined &&
+ value.revealRadius !== 0 &&
+ value.revealRadius !== 1
+ ) {
+ add(issues, `${path}.revealRadius`, "must be 0 or 1");
+ }
+ break;
}
}
@@ -448,6 +714,7 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
case "anti-knight":
case "anti-king":
case "non-consecutive":
+ case "disjoint-groups":
return { type: constraint.type };
case "killer-cage":
return {
@@ -464,6 +731,12 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
case "thermo":
case "renban":
case "palindrome":
+ case "between-line":
+ case "region-sum-line":
+ case "modular-line":
+ case "entropic-line":
+ case "zipper-line":
+ case "double-arrow":
return {
type: constraint.type,
cells: [...constraint.cells],
@@ -539,6 +812,9 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
: { negated: constraint.negated }),
};
case "maximum":
+ case "minimum":
+ case "odd":
+ case "even":
return {
type: constraint.type,
cell: constraint.cell,
@@ -546,6 +822,66 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
? {}
: { negated: constraint.negated }),
};
+ case "little-killer":
+ return {
+ type: constraint.type,
+ side: constraint.side,
+ index: constraint.index,
+ direction: constraint.direction,
+ sum: constraint.sum,
+ ...(constraint.negated === undefined
+ ? {}
+ : { negated: constraint.negated }),
+ };
+ case "sandwich":
+ return {
+ type: constraint.type,
+ side: constraint.side,
+ index: constraint.index,
+ sum: constraint.sum,
+ ...(constraint.negated === undefined
+ ? {}
+ : { negated: constraint.negated }),
+ };
+ case "german-whisper":
+ return {
+ type: constraint.type,
+ cells: [...constraint.cells],
+ ...(constraint.minimumDifference === undefined
+ ? {}
+ : { minimumDifference: constraint.minimumDifference }),
+ ...(constraint.negated === undefined
+ ? {}
+ : { negated: constraint.negated }),
+ };
+ case "clone":
+ return {
+ type: constraint.type,
+ cells: [...constraint.cells],
+ cloneCells: [...constraint.cloneCells],
+ ...(constraint.negated === undefined
+ ? {}
+ : { negated: constraint.negated }),
+ };
+ case "extra-region":
+ return { type: constraint.type, cells: [...constraint.cells] };
+ case "indexer":
+ return {
+ type: constraint.type,
+ kind: constraint.kind,
+ cell: constraint.cell,
+ ...(constraint.negated === undefined
+ ? {}
+ : { negated: constraint.negated }),
+ };
+ case "fog":
+ return {
+ type: constraint.type,
+ lights: [...constraint.lights],
+ ...(constraint.revealRadius === undefined
+ ? {}
+ : { revealRadius: constraint.revealRadius }),
+ };
}
}
@@ -654,6 +990,77 @@ export function validatePuzzle(puzzle: unknown): ValidationResult {
.forEach((constraint, index) => {
validateConstraint(constraint, index, n, issues);
});
+ if (regionsValid) {
+ const actualRegions =
+ puzzle.regions === undefined
+ ? classicRegions(n)
+ : (puzzle.regions as readonly number[]);
+ if (!sameRegionPartition(actualRegions, classicRegions(n))) {
+ puzzle.constraints.forEach((constraint, index) => {
+ if (!isRecord(constraint)) return;
+ if (constraint.type === "disjoint-groups") {
+ add(
+ issues,
+ `constraints[${index}]`,
+ "disjoint groups require the standard rectangular box layout",
+ );
+ }
+ if (constraint.type === "indexer" && constraint.kind === "box") {
+ add(
+ issues,
+ `constraints[${index}]`,
+ "box indexers require the standard rectangular box layout",
+ );
+ }
+ });
+ }
+ puzzle.constraints.forEach((constraint, index) => {
+ if (
+ !isRecord(constraint) ||
+ constraint.type !== "region-sum-line" ||
+ !Array.isArray(constraint.cells) ||
+ constraint.cells.length < 2 ||
+ constraint.cells.some(
+ (cell) =>
+ !Number.isInteger(cell) ||
+ (cell as number) < 0 ||
+ (cell as number) >= n * n,
+ )
+ ) {
+ return;
+ }
+ const cells = constraint.cells as readonly number[];
+ let segmentCount = 1;
+ for (let position = 1; position < cells.length; position += 1) {
+ if (
+ actualRegions[cells[position]!] !==
+ actualRegions[cells[position - 1]!]
+ ) {
+ segmentCount += 1;
+ }
+ }
+ if (segmentCount < 2) {
+ add(
+ issues,
+ `constraints[${index}].cells`,
+ "region-sum line must cross at least one region boundary",
+ );
+ }
+ });
+ }
+ puzzle.constraints.forEach((constraint, index) => {
+ if (
+ isRecord(constraint) &&
+ constraint.type === "fog" &&
+ puzzle.solution === undefined
+ ) {
+ add(
+ issues,
+ `constraints[${index}]`,
+ "fog requires a complete trusted puzzle solution",
+ );
+ }
+ });
}
validateText(puzzle.id, "id", MAX_SHORT_TEXT, issues);
validateText(puzzle.title, "title", MAX_SHORT_TEXT, issues);
diff --git a/src/formats/constraintVisuals.ts b/src/formats/constraintVisuals.ts
new file mode 100644
index 0000000..f25334c
--- /dev/null
+++ b/src/formats/constraintVisuals.ts
@@ -0,0 +1,409 @@
+import { littleKillerCells } from "../domain/geometry";
+import type {
+ PortableConstraint,
+ SafeVisualAnchor,
+ SafeVisualPrimitive,
+ SafeVisualStyle,
+ SudokuDocument,
+} from "./types";
+
+const lineStyle = (
+ stroke: string,
+ strokeWidth: number,
+ opacity = 1,
+): SafeVisualStyle => ({ stroke, fill: "transparent", strokeWidth, opacity });
+const cell = (cellIndex: number): SafeVisualAnchor => ({
+ kind: "cell",
+ cell: cellIndex,
+});
+const coordinate = (x: number, y: number): SafeVisualAnchor => ({
+ kind: "coordinate",
+ x,
+ y,
+});
+
+function cellPoint(size: number, cellIndex: number) {
+ return {
+ x: (cellIndex % size) + 0.5,
+ y: Math.floor(cellIndex / size) + 0.5,
+ };
+}
+
+function average(size: number, cells: readonly number[]): SafeVisualAnchor {
+ const points = cells.map((entry) => cellPoint(size, entry));
+ return coordinate(
+ points.reduce((sum, point) => sum + point.x, 0) / points.length,
+ points.reduce((sum, point) => sum + point.y, 0) / points.length,
+ );
+}
+
+function midpoint(size: number, a: number, b: number): SafeVisualAnchor {
+ return average(size, [a, b]);
+}
+
+function outsideAnchor(
+ size: number,
+ side: "top" | "right" | "bottom" | "left",
+ index: number,
+): SafeVisualAnchor {
+ switch (side) {
+ case "top":
+ return coordinate(index + 0.5, -0.45);
+ case "right":
+ return coordinate(size + 0.45, index + 0.5);
+ case "bottom":
+ return coordinate(index + 0.5, size + 0.45);
+ case "left":
+ return coordinate(-0.45, index + 0.5);
+ }
+}
+
+function polyline(
+ cells: readonly number[],
+ style: SafeVisualStyle,
+ layer: "underlay" | "overlay" = "underlay",
+): SafeVisualPrimitive {
+ return { type: "polyline", layer, points: cells.map(cell), style };
+}
+
+function text(
+ position: SafeVisualAnchor,
+ value: string,
+ fontSize = 0.34,
+): SafeVisualPrimitive {
+ return {
+ type: "text",
+ layer: "overlay",
+ position,
+ text: value,
+ style: { fill: "#172033", fontSize, opacity: 1 },
+ };
+}
+
+function renderConstraint(
+ constraint: PortableConstraint,
+ size: number,
+): SafeVisualPrimitive[] {
+ switch (constraint.type) {
+ case "anti-knight":
+ case "anti-king":
+ case "non-consecutive":
+ case "disjoint-groups":
+ case "fog":
+ return [];
+ case "diagonal":
+ return [
+ {
+ type: "line",
+ layer: "underlay",
+ start: coordinate(constraint.direction === "main" ? 0 : size, 0),
+ end: coordinate(constraint.direction === "main" ? size : 0, size),
+ style: lineStyle("#34bbe6", 0.04),
+ },
+ ];
+ case "killer-cage":
+ // SCL has a native cage representation; it is emitted separately.
+ return [];
+ case "thermo":
+ return [
+ polyline(constraint.cells, lineStyle("#cfcfcf", 0.32)),
+ {
+ type: "circle",
+ layer: "underlay",
+ center: cell(constraint.cells[0]!),
+ radius: 0.42,
+ style: {
+ fill: "#cfcfcf",
+ stroke: "#cfcfcf",
+ strokeWidth: 0.03,
+ },
+ },
+ ];
+ case "arrow": {
+ const start = constraint.bulb.at(-1)!;
+ return [
+ {
+ type: "circle",
+ layer: "underlay",
+ center: average(size, constraint.bulb),
+ radius: Math.max(0.38, Math.sqrt(constraint.bulb.length) * 0.3),
+ style: {
+ fill: "#ffffff",
+ stroke: "#a1a1a1",
+ strokeWidth: 0.07,
+ },
+ },
+ polyline([start, ...constraint.line], lineStyle("#a1a1a1", 0.07)),
+ ];
+ }
+ case "kropki":
+ return [
+ {
+ type: "circle",
+ layer: "overlay",
+ center: midpoint(size, constraint.a, constraint.b),
+ radius: 0.12,
+ style: {
+ fill: constraint.kind === "black" ? "#000000" : "#ffffff",
+ stroke: "#000000",
+ strokeWidth: 0.025,
+ },
+ },
+ ];
+ case "xv": {
+ const position = midpoint(size, constraint.a, constraint.b);
+ return [
+ {
+ type: "circle",
+ layer: "overlay",
+ center: position,
+ radius: 0.2,
+ style: { fill: "#ffffff", stroke: "#ffffff", strokeWidth: 0.01 },
+ },
+ text(position, constraint.total === 5 ? "V" : "X", 0.3),
+ ];
+ }
+ case "inequality": {
+ const lesser = cellPoint(size, constraint.lesser);
+ const greater = cellPoint(size, constraint.greater);
+ const center = {
+ x: (lesser.x + greater.x) / 2,
+ y: (lesser.y + greater.y) / 2,
+ };
+ const dx = greater.x - lesser.x;
+ const dy = greater.y - lesser.y;
+ const distance = Math.hypot(dx, dy) || 1;
+ const ux = dx / distance;
+ const uy = dy / distance;
+ const px = -uy;
+ const py = ux;
+ return [
+ {
+ type: "polyline",
+ layer: "overlay",
+ points: [
+ coordinate(
+ center.x + ux * 0.18 + px * 0.16,
+ center.y + uy * 0.18 + py * 0.16,
+ ),
+ coordinate(center.x - ux * 0.18, center.y - uy * 0.18),
+ coordinate(
+ center.x + ux * 0.18 - px * 0.16,
+ center.y + uy * 0.18 - py * 0.16,
+ ),
+ ],
+ style: lineStyle("#172033", 0.05),
+ },
+ ];
+ }
+ case "renban":
+ return [polyline(constraint.cells, lineStyle("#b55b8c", 0.2, 0.72))];
+ case "palindrome":
+ return [
+ polyline(constraint.cells, lineStyle("#cfcfcf", 0.2)),
+ ...constraint.cells.map((entry): SafeVisualPrimitive => ({
+ type: "circle",
+ layer: "underlay",
+ center: cell(entry),
+ radius: 0.13,
+ style: { fill: "#d8dbe1", stroke: "transparent" },
+ })),
+ ];
+ case "x-sum":
+ return [
+ text(
+ outsideAnchor(size, constraint.side, constraint.index),
+ `Σ ${String(constraint.sum)}`,
+ ),
+ ];
+ case "skyscraper":
+ return [
+ text(
+ outsideAnchor(size, constraint.side, constraint.index),
+ `▥ ${String(constraint.count)}`,
+ ),
+ ];
+ case "quadruple": {
+ const position = average(size, constraint.cells);
+ return [
+ {
+ type: "circle",
+ layer: "overlay",
+ center: position,
+ radius: 0.29,
+ style: { fill: "#ffffff", stroke: "#596273", strokeWidth: 0.03 },
+ },
+ text(position, constraint.digits.join(""), 0.23),
+ ];
+ }
+ case "maximum":
+ return [text(cell(constraint.cell), "◆", 0.34)];
+ case "minimum":
+ return [text(cell(constraint.cell), "◇", 0.34)];
+ case "odd":
+ return [
+ {
+ type: "circle",
+ layer: "underlay",
+ center: cell(constraint.cell),
+ radius: 0.19,
+ style: { fill: "#cfcfcf", stroke: "transparent" },
+ },
+ ];
+ case "even":
+ return [
+ {
+ type: "rectangle",
+ layer: "underlay",
+ center: cell(constraint.cell),
+ width: 0.38,
+ height: 0.38,
+ style: { fill: "#cfcfcf", stroke: "transparent" },
+ },
+ ];
+ case "little-killer": {
+ const clue = outsideAnchor(size, constraint.side, constraint.index);
+ const first = littleKillerCells(
+ size,
+ constraint.side,
+ constraint.index,
+ constraint.direction,
+ )[0];
+ return [
+ text(clue, String(constraint.sum)),
+ ...(first === undefined
+ ? []
+ : [
+ {
+ type: "line" as const,
+ layer: "overlay" as const,
+ start: clue,
+ end: cell(first),
+ style: lineStyle("#172033", 0.03),
+ },
+ ]),
+ ];
+ }
+ case "sandwich":
+ return [
+ text(
+ outsideAnchor(size, constraint.side, constraint.index),
+ `1⋯N ${String(constraint.sum)}`,
+ ),
+ ];
+ case "between-line":
+ return [
+ polyline(constraint.cells, lineStyle("#7e8796", 0.07)),
+ ...[constraint.cells[0]!, constraint.cells.at(-1)!].map(
+ (entry): SafeVisualPrimitive => ({
+ type: "circle",
+ layer: "underlay",
+ center: cell(entry),
+ radius: 0.27,
+ style: { fill: "#ffffff", stroke: "#7e8796", strokeWidth: 0.06 },
+ }),
+ ),
+ ];
+ case "german-whisper":
+ return [polyline(constraint.cells, lineStyle("#4d9b69", 0.14))];
+ case "region-sum-line":
+ return [polyline(constraint.cells, lineStyle("#4a94a3", 0.1))];
+ case "clone":
+ return [...constraint.cells, ...constraint.cloneCells].map(
+ (entry): SafeVisualPrimitive => ({
+ type: "rectangle",
+ layer: "underlay",
+ center: cell(entry),
+ width: 0.88,
+ height: 0.88,
+ style: {
+ fill: "#6d62b533",
+ stroke: "#6d62b5",
+ strokeWidth: 0.025,
+ },
+ }),
+ );
+ case "extra-region":
+ return constraint.cells.map((entry): SafeVisualPrimitive => ({
+ type: "rectangle",
+ layer: "underlay",
+ center: cell(entry),
+ width: 0.9,
+ height: 0.9,
+ style: {
+ fill: "transparent",
+ stroke: "#6d62b5",
+ strokeWidth: 0.04,
+ },
+ }));
+ case "modular-line":
+ return [polyline(constraint.cells, lineStyle("#167f8f", 0.14))];
+ case "entropic-line":
+ return [polyline(constraint.cells, lineStyle("#d47742", 0.14))];
+ case "zipper-line":
+ return [
+ polyline(constraint.cells, lineStyle("#7b60ad", 0.1)),
+ {
+ type: "circle",
+ layer: "underlay",
+ center: cell(
+ constraint.cells[Math.floor(constraint.cells.length / 2)]!,
+ ),
+ radius: 0.22,
+ style: { fill: "#ffffff", stroke: "#7b60ad", strokeWidth: 0.06 },
+ },
+ ];
+ case "double-arrow":
+ return [
+ polyline(constraint.cells, lineStyle("#596273", 0.07)),
+ ...[constraint.cells[0]!, constraint.cells.at(-1)!].map(
+ (entry): SafeVisualPrimitive => ({
+ type: "circle",
+ layer: "underlay",
+ center: cell(entry),
+ radius: 0.22,
+ style: { fill: "#ffffff", stroke: "#596273", strokeWidth: 0.05 },
+ }),
+ ),
+ ];
+ case "indexer":
+ return [
+ {
+ type: "rectangle",
+ layer: "overlay",
+ center: cell(constraint.cell),
+ width: 0.48,
+ height: 0.48,
+ cornerRadius: 0.1,
+ style: {
+ fill: "#ffffff",
+ stroke:
+ constraint.kind === "row"
+ ? "#287fba"
+ : constraint.kind === "column"
+ ? "#b94c50"
+ : "#438b58",
+ strokeWidth: 0.03,
+ },
+ },
+ text(
+ cell(constraint.cell),
+ constraint.kind === "row"
+ ? "R"
+ : constraint.kind === "column"
+ ? "C"
+ : "B",
+ 0.24,
+ ),
+ ];
+ }
+}
+
+/** Canonical, inert visual equivalents for every shape-based domain clue. */
+export function canonicalConstraintVisuals(
+ document: Pick,
+): SafeVisualPrimitive[] {
+ return document.constraints.flatMap((constraint) =>
+ renderConstraint(constraint, document.size),
+ );
+}
diff --git a/src/formats/document.ts b/src/formats/document.ts
index 5fe15f6..ea2e3f7 100644
--- a/src/formats/document.ts
+++ b/src/formats/document.ts
@@ -1,9 +1,16 @@
import { cellsFormQuadruple } from "../domain/geometry";
import { normalizePortableAidMemoire } from "../state/aidMemoire";
+import {
+ SafeVisualValidationError,
+ normalizeSafeVisualPrimitives,
+ normalizeScalarMetadata,
+ normalizeSourceIdentity,
+} from "./safeVisuals";
import {
SUDOKU_DOCUMENT_SCHEMA,
SUDOKU_DOCUMENT_VERSION,
cloneConstraint,
+ cloneVisualPrimitive,
type PortableAidMemoire,
type PortableConstraint,
type SudokuDocument,
@@ -15,6 +22,30 @@ export const MAX_BOARD_SIZE = 16;
export const MAX_CONSTRAINTS = 5_000;
const MAX_TEXT_LENGTH = 20_000;
const MAX_RULES = 1_000;
+const DOCUMENT_FIELDS = new Set([
+ "schema",
+ "version",
+ "size",
+ "givens",
+ "values",
+ "cornerMarks",
+ "centerMarks",
+ "candidates",
+ "colors",
+ "elapsedMs",
+ "aidMemoire",
+ "solution",
+ "regions",
+ "constraints",
+ "title",
+ "author",
+ "rules",
+ "globalRules",
+ "id",
+ "visuals",
+ "source",
+ "metadata",
+]);
export class SudokuFormatError extends Error {
readonly code: string;
@@ -176,6 +207,7 @@ function parseConstraint(
case "anti-knight":
case "anti-king":
case "non-consecutive":
+ case "disjoint-groups":
return { type: value.type };
case "killer-cage": {
const cageCells = cells(value.cells, "killer-cage.cells", cellCount);
@@ -202,11 +234,119 @@ function parseConstraint(
case "thermo":
case "renban":
case "palindrome":
+ case "region-sum-line":
return {
type: value.type,
cells: cells(value.cells, `${value.type}.cells`, cellCount, 2),
...cluePolarity(value, value.type),
};
+ case "between-line":
+ return {
+ type: "between-line",
+ cells: cells(value.cells, "between-line.cells", cellCount, 3),
+ ...cluePolarity(value, "between-line"),
+ };
+ case "modular-line":
+ case "entropic-line":
+ case "double-arrow":
+ return {
+ type: value.type,
+ cells: cells(value.cells, `${value.type}.cells`, cellCount, 3),
+ ...cluePolarity(value, value.type),
+ };
+ case "zipper-line": {
+ const lineCells = cells(value.cells, "zipper-line.cells", cellCount, 3);
+ if (lineCells.length % 2 === 0) {
+ return fail(
+ "INVALID_CELLS",
+ "zipper-line.cells must contain an odd number of cells.",
+ );
+ }
+ return {
+ type: "zipper-line",
+ cells: lineCells,
+ ...cluePolarity(value, "zipper-line"),
+ };
+ }
+ case "german-whisper":
+ return {
+ type: "german-whisper",
+ cells: cells(value.cells, "german-whisper.cells", cellCount, 2),
+ ...(value.minimumDifference === undefined
+ ? {}
+ : {
+ minimumDifference: integer(
+ value.minimumDifference,
+ "german-whisper.minimumDifference",
+ 1,
+ size - 1,
+ ),
+ }),
+ ...cluePolarity(value, "german-whisper"),
+ };
+ case "clone": {
+ const original = cells(value.cells, "clone.cells", cellCount);
+ const cloned = cells(value.cloneCells, "clone.cloneCells", cellCount);
+ if (original.length !== cloned.length) {
+ return fail(
+ "INVALID_CELLS",
+ "clone.cells and clone.cloneCells must have equal lengths.",
+ );
+ }
+ return {
+ type: "clone",
+ cells: original,
+ cloneCells: cloned,
+ ...cluePolarity(value, "clone"),
+ };
+ }
+ case "extra-region": {
+ const regionCells = cells(value.cells, "extra-region.cells", cellCount);
+ if (regionCells.length !== size) {
+ return fail(
+ "INVALID_CELLS",
+ `extra-region.cells must contain exactly ${size} cells.`,
+ );
+ }
+ return { type: "extra-region", cells: regionCells };
+ }
+ case "indexer": {
+ if (
+ value.kind !== "row" &&
+ value.kind !== "column" &&
+ value.kind !== "box"
+ ) {
+ return fail(
+ "INVALID_CONSTRAINT",
+ "indexer.kind must be row, column, or box.",
+ );
+ }
+ return {
+ type: "indexer",
+ kind: value.kind,
+ cell: cell(value.cell, "indexer.cell", cellCount),
+ ...cluePolarity(value, "indexer"),
+ };
+ }
+ case "fog": {
+ if (
+ value.revealRadius !== undefined &&
+ value.revealRadius !== 0 &&
+ value.revealRadius !== 1
+ ) {
+ return fail(
+ "INVALID_CONSTRAINT",
+ "fog.revealRadius must be zero or one.",
+ );
+ }
+ return {
+ type: "fog",
+ lights: cells(value.lights, "fog.lights", cellCount),
+ ...(value.revealRadius === undefined
+ ? {}
+ : { revealRadius: value.revealRadius }),
+ };
+ }
case "arrow":
return {
type: "arrow",
@@ -322,11 +462,57 @@ function parseConstraint(
};
}
case "maximum":
+ case "minimum":
+ case "odd":
+ case "even":
return {
- type: "maximum",
- cell: cell(value.cell, "maximum.cell", cellCount),
- ...cluePolarity(value, "maximum"),
+ type: value.type,
+ cell: cell(value.cell, `${value.type}.cell`, cellCount),
+ ...cluePolarity(value, value.type),
};
+ case "little-killer": {
+ const direction = value.direction;
+ if (
+ direction !== "down-right" &&
+ direction !== "down-left" &&
+ direction !== "up-right" &&
+ direction !== "up-left"
+ ) {
+ return fail(
+ "INVALID_CONSTRAINT",
+ "A little-killer direction must point diagonally into the grid.",
+ );
+ }
+ const polarity = cluePolarity(value, "little-killer");
+ return {
+ type: "little-killer",
+ side: outsideSide(value.side),
+ index: integer(value.index, "little-killer.index", 0, size - 1),
+ direction,
+ sum: integer(
+ value.sum,
+ "little-killer.sum",
+ 1,
+ polarity.negated === true ? size ** 4 : size ** 3,
+ ),
+ ...polarity,
+ };
+ }
+ case "sandwich": {
+ const polarity = cluePolarity(value, "sandwich");
+ return {
+ type: "sandwich",
+ side: outsideSide(value.side),
+ index: integer(value.index, "sandwich.index", 0, size - 1),
+ sum: integer(
+ value.sum,
+ "sandwich.sum",
+ 0,
+ polarity.negated === true ? size ** 4 : size ** 3,
+ ),
+ ...polarity,
+ };
+ }
default:
return fail(
"UNSUPPORTED_CONSTRAINT",
@@ -354,6 +540,15 @@ function textList(value: unknown, label: string): string[] | undefined {
export function normalizeSudokuDocument(value: unknown): SudokuDocument {
if (!isRecord(value))
return fail("INVALID_DOCUMENT", "The puzzle document must be an object.");
+ const unknownField = Object.keys(value).find(
+ (field) => !DOCUMENT_FIELDS.has(field),
+ );
+ if (unknownField !== undefined) {
+ return fail(
+ "UNSUPPORTED_DOCUMENT_FIELD",
+ `The puzzle document field “${unknownField}” is not supported. Raw code, markup and custom fields are never imported.`,
+ );
+ }
if (value.schema !== SUDOKU_DOCUMENT_SCHEMA) {
return fail("INVALID_SCHEMA", `Expected schema ${SUDOKU_DOCUMENT_SCHEMA}.`);
}
@@ -458,11 +653,33 @@ export function normalizeSudokuDocument(value: unknown): SudokuDocument {
const constraints = value.constraints.map((constraint) =>
parseConstraint(constraint, cellCount),
);
+ if (
+ constraints.some(({ type }) => type === "fog") &&
+ solution === undefined
+ ) {
+ return fail(
+ "INVALID_CONSTRAINT",
+ "Fog of War requires a complete trusted solution for correct reveals.",
+ );
+ }
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");
+ let visuals;
+ let source;
+ let metadata;
+ try {
+ visuals = normalizeSafeVisualPrimitives(value.visuals, size);
+ source = normalizeSourceIdentity(value.source);
+ metadata = normalizeScalarMetadata(value.metadata);
+ } catch (error) {
+ if (error instanceof SafeVisualValidationError) {
+ return fail("INVALID_SOURCE_EXTRAS", error.message);
+ }
+ throw error;
+ }
return {
schema: SUDOKU_DOCUMENT_SCHEMA,
@@ -484,6 +701,9 @@ export function normalizeSudokuDocument(value: unknown): SudokuDocument {
...(id === undefined ? {} : { id }),
...(rules === undefined ? {} : { rules }),
...(globalRules === undefined ? {} : { globalRules }),
+ ...(visuals === undefined ? {} : { visuals }),
+ ...(source === undefined ? {} : { source }),
+ ...(metadata === undefined ? {} : { metadata }),
};
}
@@ -561,5 +781,14 @@ export function cloneSudokuDocument(value: SudokuDocument): SudokuDocument {
...(normalized.globalRules === undefined
? {}
: { globalRules: [...normalized.globalRules] }),
+ ...(normalized.visuals === undefined
+ ? {}
+ : { visuals: normalized.visuals.map(cloneVisualPrimitive) }),
+ ...(normalized.source === undefined
+ ? {}
+ : { source: { ...normalized.source } }),
+ ...(normalized.metadata === undefined
+ ? {}
+ : { metadata: { ...normalized.metadata } }),
};
}
diff --git a/src/formats/fpuzzles.ts b/src/formats/fpuzzles.ts
index 5d37e5d..7e8f823 100644
--- a/src/formats/fpuzzles.ts
+++ b/src/formats/fpuzzles.ts
@@ -11,10 +11,18 @@ import {
SudokuFormatError,
} from "./document";
import { UnsupportedPuzzleConstructsError } from "./interoperability";
+import {
+ SafeVisualValidationError,
+ normalizeSafeVisualColor,
+ normalizeSafeVisualPrimitives,
+} from "./safeVisuals";
import {
SUDOKU_DOCUMENT_SCHEMA,
SUDOKU_DOCUMENT_VERSION,
type PortableConstraint,
+ type SafeVisualAnchor,
+ type SafeVisualPrimitive,
+ type ScalarMetadataValue,
type SudokuDocument,
} from "./types";
@@ -33,6 +41,7 @@ const SUPPORTED_ROOT_FIELDS = new Set([
"antiking",
"antikingsmove",
"nonconsecutive",
+ "disjointgroups",
"killercage",
"thermometer",
"arrow",
@@ -44,48 +53,45 @@ const SUPPORTED_ROOT_FIELDS = new Set([
"skyscraper",
"quadruple",
"maximum",
+ "minimum",
+ "even",
+ "odd",
+ "littlekillersum",
+ "sandwichsum",
+ "extraregion",
+ "clone",
+ "betweenline",
+ "whispers",
+ "regionsumline",
+ "entropicline",
+ "modularline",
+ "zipperline",
+ "doublearrow",
+ "rowindexer",
+ "columnindexer",
+ "boxindexer",
+ "fogofwar",
+ "foglight",
"renban",
"palindrome",
"disabledlogic",
"truecandidatesoptions",
"successMessage",
"successmessage",
+ "id",
+ "line",
+ "rectangle",
+ "circle",
+ "text",
]);
const UNSUPPORTED_RULE_FIELDS: Readonly> = {
- disjointgroups: "disjoint groups",
- littlekillersum: "little killer sums",
- sandwichsum: "sandwich sums",
- even: "even cells",
- odd: "odd cells",
- extraregion: "extra regions",
- clone: "clone regions",
- betweenline: "between lines",
- minimum: "minimum 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",
- fogofwar: "fog of war",
- foglight: "fog lights",
cage: "generic cages",
negative: "negative constraints",
};
-const DECORATION_FIELDS: Readonly> = {
- line: "decorative lines",
- rectangle: "rectangles",
- circle: "circles",
- text: "text decorations",
-};
-
export class NetworkPuzzleIdError extends SudokuFormatError {
readonly puzzleId: string;
@@ -124,16 +130,7 @@ function assertSupportedRootFields(value: JsonRecord): void {
constructs.push(unsupported);
continue;
}
- const decoration = DECORATION_FIELDS[field];
- if (decoration !== undefined && present(raw)) {
- constructs.push(decoration);
- continue;
- }
- if (
- !SUPPORTED_ROOT_FIELDS.has(field) &&
- unsupported === undefined &&
- decoration === undefined
- ) {
+ if (!SUPPORTED_ROOT_FIELDS.has(field) && unsupported === undefined) {
constructs.push(`Unknown fpuzzles field “${field}”`);
}
}
@@ -307,6 +304,337 @@ function numeric(
return parsed as number;
}
+function finiteNumber(
+ value: unknown,
+ label: string,
+ minimum: number,
+ maximum: number,
+ fallback?: number,
+): number {
+ if (value === undefined && fallback !== undefined) return fallback;
+ const parsed =
+ typeof value === "string" && value.trim() !== "" ? Number(value) : value;
+ if (
+ typeof parsed !== "number" ||
+ !Number.isFinite(parsed) ||
+ parsed < minimum ||
+ parsed > maximum
+ ) {
+ return fail("INVALID_FPUZZLES", `${label} is outside the supported range.`);
+ }
+ return parsed;
+}
+
+function visualColor(value: unknown, label: string, fallback: string): string {
+ try {
+ return normalizeSafeVisualColor(value ?? fallback, label);
+ } catch (error) {
+ if (error instanceof SafeVisualValidationError) {
+ return fail("UNSAFE_VISUAL_STYLE", error.message);
+ }
+ throw error;
+ }
+}
+
+function assertVisualFields(
+ value: JsonRecord,
+ allowed: ReadonlySet,
+ label: string,
+): void {
+ const unknown = Object.keys(value).find((field) => !allowed.has(field));
+ if (unknown !== undefined) {
+ return fail(
+ "UNSAFE_VISUAL_CONSTRUCT",
+ `${label}.${unknown} is not an allowlisted decorative field. Raw paths, styles and custom code are not imported.`,
+ );
+ }
+}
+
+function fpVisualAnchor(
+ value: unknown,
+ size: number,
+ label: string,
+): SafeVisualAnchor {
+ if (typeof value !== "string") {
+ return fail("INVALID_FPUZZLES", `${label} must be an RnCn position.`);
+ }
+ const match = /^R(-?\d+(?:\.\d+)?)C(-?\d+(?:\.\d+)?)$/iu.exec(value.trim());
+ if (match === null) {
+ return fail("INVALID_FPUZZLES", `${label} is not an RnCn position.`);
+ }
+ const row = Number(match[1]);
+ const column = Number(match[2]);
+ if (
+ !Number.isFinite(row) ||
+ !Number.isFinite(column) ||
+ row < -3.5 ||
+ row > size + 4.5 ||
+ column < -3.5 ||
+ column > size + 4.5
+ ) {
+ return fail("INVALID_FPUZZLES", `${label} is outside the visual canvas.`);
+ }
+ if (
+ Number.isInteger(row) &&
+ Number.isInteger(column) &&
+ row >= 1 &&
+ row <= size &&
+ column >= 1 &&
+ column <= size
+ ) {
+ return { kind: "cell", cell: (row - 1) * size + column - 1 };
+ }
+ return { kind: "coordinate", x: column - 0.5, y: row - 0.5 };
+}
+
+function fpVisualCenter(
+ value: JsonRecord,
+ size: number,
+ label: string,
+): SafeVisualAnchor {
+ if (value.cell !== undefined)
+ return fpVisualAnchor(value.cell, size, `${label}.cell`);
+ if (!Array.isArray(value.cells) || value.cells.length === 0) {
+ return fail("INVALID_FPUZZLES", `${label} needs a cell or cells anchor.`);
+ }
+ if (value.cells.length > size * size) {
+ return fail("LIMIT_EXCEEDED", `${label}.cells contains too many anchors.`);
+ }
+ const anchors = value.cells.map((entry, index) =>
+ fpVisualAnchor(entry, size, `${label}.cells[${String(index)}]`),
+ );
+ if (anchors.length === 1) return anchors[0]!;
+ const coordinates = anchors.map((anchor) =>
+ anchor.kind === "coordinate"
+ ? anchor
+ : {
+ x: (anchor.cell % size) + 0.5 + (anchor.offsetX ?? 0),
+ y: Math.floor(anchor.cell / size) + 0.5 + (anchor.offsetY ?? 0),
+ },
+ );
+ return {
+ kind: "coordinate",
+ x:
+ coordinates.reduce((sum, anchor) => sum + anchor.x, 0) /
+ coordinates.length,
+ y:
+ coordinates.reduce((sum, anchor) => sum + anchor.y, 0) /
+ coordinates.length,
+ };
+}
+
+function parseFpuzzlesVisuals(
+ value: JsonRecord,
+ size: number,
+): SafeVisualPrimitive[] {
+ const output: SafeVisualPrimitive[] = [];
+ const lineFields = new Set([
+ "lines",
+ "outlineC",
+ "width",
+ "opacity",
+ "isLLConstraint",
+ "fromConstraint",
+ ]);
+ for (const [index, item] of objects(value.line).entries()) {
+ assertVisualFields(item, lineFields, `line[${String(index)}]`);
+ if (!Array.isArray(item.lines) || item.lines.length > size * size) {
+ return fail("INVALID_FPUZZLES", "line.lines must be a bounded array.");
+ }
+ for (const [lineIndex, rawLine] of item.lines.entries()) {
+ if (
+ !Array.isArray(rawLine) ||
+ rawLine.length < 2 ||
+ rawLine.length > size * size
+ ) {
+ return fail(
+ "INVALID_FPUZZLES",
+ "A decorative line needs bounded points.",
+ );
+ }
+ output.push({
+ type: "polyline",
+ layer: "overlay",
+ points: rawLine.map((entry, pointIndex) =>
+ fpVisualAnchor(
+ entry,
+ size,
+ `line[${String(index)}].lines[${String(lineIndex)}][${String(pointIndex)}]`,
+ ),
+ ),
+ style: {
+ stroke: visualColor(item.outlineC, "line.outlineC", "#000000"),
+ fill: "transparent",
+ strokeWidth: finiteNumber(item.width, "line.width", 0, 4, 0.05),
+ opacity: finiteNumber(item.opacity, "line.opacity", 0, 1, 1),
+ },
+ });
+ }
+ }
+
+ const shapeFields = new Set([
+ "cell",
+ "cells",
+ "baseC",
+ "outlineC",
+ "fontC",
+ "width",
+ "height",
+ "angle",
+ "value",
+ "opacity",
+ "isLLConstraint",
+ "fromConstraint",
+ ]);
+ for (const kind of ["rectangle", "circle"] as const) {
+ for (const [index, item] of objects(value[kind]).entries()) {
+ const label = `${kind}[${String(index)}]`;
+ assertVisualFields(item, shapeFields, label);
+ if (item.angle !== undefined && Number(item.angle) !== 0) {
+ return fail(
+ "UNSAFE_VISUAL_CONSTRUCT",
+ `${label}.angle cannot be represented by the safe visual model.`,
+ );
+ }
+ const center = fpVisualCenter(item, size, label);
+ const width = finiteNumber(
+ item.width,
+ `${label}.width`,
+ 0.01,
+ size + 8,
+ 1,
+ );
+ const height = finiteNumber(
+ item.height,
+ `${label}.height`,
+ 0.01,
+ size + 8,
+ 1,
+ );
+ const style = {
+ stroke: visualColor(item.outlineC, `${label}.outlineC`, "transparent"),
+ fill: visualColor(item.baseC, `${label}.baseC`, "transparent"),
+ strokeWidth: 0.02,
+ opacity: finiteNumber(item.opacity, `${label}.opacity`, 0, 1, 1),
+ };
+ output.push(
+ kind === "circle"
+ ? width === height
+ ? {
+ type: "circle",
+ layer: "overlay",
+ center,
+ radius: width / 2,
+ style,
+ }
+ : {
+ type: "ellipse",
+ layer: "overlay",
+ center,
+ radiusX: width / 2,
+ radiusY: height / 2,
+ style,
+ }
+ : {
+ type: "rectangle",
+ layer: "overlay",
+ center,
+ width,
+ height,
+ style,
+ },
+ );
+ if (item.value !== undefined && String(item.value).length > 0) {
+ output.push({
+ type: "text",
+ layer: "overlay",
+ position: center,
+ text: String(item.value),
+ style: {
+ fill: visualColor(item.fontC, `${label}.fontC`, "#000000"),
+ fontSize: 0.5,
+ opacity: style.opacity,
+ },
+ });
+ }
+ }
+ }
+
+ const textFields = new Set([
+ "cell",
+ "cells",
+ "value",
+ "fontC",
+ "size",
+ "angle",
+ "opacity",
+ ]);
+ for (const [index, item] of objects(value.text).entries()) {
+ const label = `text[${String(index)}]`;
+ assertVisualFields(item, textFields, label);
+ if (item.angle !== undefined && Number(item.angle) !== 0) {
+ return fail(
+ "UNSAFE_VISUAL_CONSTRUCT",
+ `${label}.angle cannot be represented by the safe visual model.`,
+ );
+ }
+ if (item.value === undefined) continue;
+ output.push({
+ type: "text",
+ layer: "overlay",
+ position: fpVisualCenter(item, size, label),
+ text: String(item.value),
+ style: {
+ fill: visualColor(item.fontC, `${label}.fontC`, "#000000"),
+ fontSize: 0.5 * finiteNumber(item.size, `${label}.size`, 0.1, 16, 1),
+ opacity: finiteNumber(item.opacity, `${label}.opacity`, 0, 1, 1),
+ },
+ });
+ }
+ try {
+ return normalizeSafeVisualPrimitives(output, size) ?? [];
+ } catch (error) {
+ if (error instanceof SafeVisualValidationError) {
+ return fail("INVALID_FPUZZLES_VISUAL", error.message);
+ }
+ throw error;
+ }
+}
+
+function fpuzzlesScalarMetadata(
+ value: JsonRecord,
+): Record {
+ const output: Record = {};
+ for (const key of [
+ "successMessage",
+ "successmessage",
+ "disabledlogic",
+ "truecandidatesoptions",
+ ] as const) {
+ const raw = value[key];
+ if (raw === undefined) continue;
+ if (
+ raw !== null &&
+ typeof raw !== "string" &&
+ typeof raw !== "number" &&
+ typeof raw !== "boolean"
+ ) {
+ return fail(
+ "UNSUPPORTED_FPUZZLES",
+ `${key} is not scalar metadata and cannot be retained safely.`,
+ );
+ }
+ if (typeof raw === "number" && !Number.isFinite(raw)) {
+ return fail("INVALID_FPUZZLES", `${key} must be finite.`);
+ }
+ if (typeof raw === "string" && raw.length > 8_192) {
+ return fail("LIMIT_EXCEEDED", `${key} is too long.`);
+ }
+ output[key] = raw;
+ }
+ return output;
+}
+
function readGrid(value: unknown, size: number): JsonRecord[] {
if (!Array.isArray(value))
return fail("INVALID_FPUZZLES", "fpuzzles.grid must be an array.");
@@ -341,7 +669,17 @@ function addLineConstraints(
output: PortableConstraint[],
source: unknown,
size: number,
- type: "thermo" | "renban" | "palindrome",
+ type:
+ | "thermo"
+ | "renban"
+ | "palindrome"
+ | "between-line"
+ | "german-whisper"
+ | "region-sum-line"
+ | "modular-line"
+ | "entropic-line"
+ | "zipper-line"
+ | "double-arrow",
): void {
for (const item of objects(source)) {
for (const line of lines(item.lines ?? item.cells, size))
@@ -349,6 +687,50 @@ function addLineConstraints(
}
}
+function littleKillerDirection(value: unknown) {
+ if (typeof value !== "string") {
+ return fail(
+ "INVALID_FPUZZLES",
+ "A little-killer direction must be UL, UR, DL, or DR.",
+ );
+ }
+ const normalized = value.replaceAll(/[^a-z]/giu, "").toUpperCase();
+ switch (normalized) {
+ case "UL":
+ case "UPLEFT":
+ return "up-left" as const;
+ case "UR":
+ case "UPRIGHT":
+ return "up-right" as const;
+ case "DL":
+ case "DOWNLEFT":
+ return "down-left" as const;
+ case "DR":
+ case "DOWNRIGHT":
+ return "down-right" as const;
+ default:
+ return fail(
+ "INVALID_FPUZZLES",
+ "A little-killer direction must be UL, UR, DL, or DR.",
+ );
+ }
+}
+
+function fpLittleKillerDirection(
+ value: "up-left" | "up-right" | "down-left" | "down-right",
+): "UL" | "UR" | "DL" | "DR" {
+ switch (value) {
+ case "up-left":
+ return "UL";
+ case "up-right":
+ return "UR";
+ case "down-left":
+ return "DL";
+ case "down-right":
+ return "DR";
+ }
+}
+
function parseRules(value: unknown): string[] | undefined {
if (value === undefined || value === "") return undefined;
if (typeof value === "string") return [value];
@@ -429,6 +811,8 @@ export function parseFpuzzles(value: unknown): SudokuDocument {
constraints.push({ type: "anti-king" });
if (value.nonconsecutive === true)
constraints.push({ type: "non-consecutive" });
+ if (value.disjointgroups === true)
+ constraints.push({ type: "disjoint-groups" });
for (const cage of objects(value.killercage)) {
if (cage.value === undefined || cage.value === "") {
@@ -449,6 +833,25 @@ export function parseFpuzzles(value: unknown): SudokuDocument {
addLineConstraints(constraints, value.thermometer, size, "thermo");
addLineConstraints(constraints, value.renban, size, "renban");
addLineConstraints(constraints, value.palindrome, size, "palindrome");
+ addLineConstraints(constraints, value.betweenline, size, "between-line");
+ addLineConstraints(constraints, value.regionsumline, size, "region-sum-line");
+ addLineConstraints(constraints, value.modularline, size, "modular-line");
+ addLineConstraints(constraints, value.entropicline, size, "entropic-line");
+ addLineConstraints(constraints, value.zipperline, size, "zipper-line");
+ addLineConstraints(constraints, value.doublearrow, size, "double-arrow");
+ for (const whisper of objects(value.whispers)) {
+ const minimumDifference =
+ whisper.value === undefined || whisper.value === ""
+ ? undefined
+ : numeric(whisper.value, "whispers.value", 1, size - 1);
+ for (const line of lines(whisper.lines ?? whisper.cells, size)) {
+ constraints.push({
+ type: "german-whisper",
+ cells: line,
+ ...(minimumDifference === undefined ? {} : { minimumDifference }),
+ });
+ }
+ }
for (const arrow of objects(value.arrow)) {
const bulb = fpCells(arrow.cells, size);
@@ -576,6 +979,95 @@ export function parseFpuzzles(value: unknown): SudokuDocument {
cell: cellIndexFromAddress(clue.cell, size),
});
}
+ for (const clue of objects(value.minimum)) {
+ constraints.push({
+ type: "minimum",
+ cell: cellIndexFromAddress(clue.cell, size),
+ });
+ }
+ for (const clue of objects(value.odd)) {
+ constraints.push({
+ type: "odd",
+ cell: cellIndexFromAddress(clue.cell, size),
+ });
+ }
+ for (const clue of objects(value.even)) {
+ constraints.push({
+ type: "even",
+ cell: cellIndexFromAddress(clue.cell, size),
+ });
+ }
+ for (const clue of objects(value.littlekillersum)) {
+ constraints.push({
+ type: "little-killer",
+ ...outsideClueFromAddress(clue.cell, size),
+ direction: littleKillerDirection(clue.direction),
+ sum: numeric(clue.value, "littlekillersum.value", 1, size ** 3),
+ });
+ }
+ for (const clue of objects(value.sandwichsum)) {
+ constraints.push({
+ type: "sandwich",
+ ...outsideClueFromAddress(clue.cell, size),
+ sum: numeric(clue.value, "sandwichsum.value", 0, size ** 3),
+ });
+ }
+ for (const clue of objects(value.extraregion)) {
+ constraints.push({
+ type: "extra-region",
+ cells: fpCells(clue.cells, size),
+ });
+ }
+ for (const clue of objects(value.clone)) {
+ constraints.push({
+ type: "clone",
+ cells: fpCells(clue.cells, size),
+ cloneCells: fpCells(clue.cloneCells, size),
+ });
+ }
+ for (const kind of ["row", "column", "box"] as const) {
+ const field = `${kind}indexer`;
+ for (const clue of objects(value[field])) {
+ const rawCells = clue.cells;
+ if (Array.isArray(rawCells)) {
+ for (const indexedCell of fpCells(rawCells, size)) {
+ constraints.push({ type: "indexer", kind, cell: indexedCell });
+ }
+ } else {
+ constraints.push({
+ type: "indexer",
+ kind,
+ cell: cellIndexFromAddress(clue.cell, size),
+ });
+ }
+ }
+ }
+
+ if (value.fogofwar === true || present(value.foglight)) {
+ const lights: number[] = [];
+ for (const clue of objects(value.foglight)) {
+ if (Array.isArray(clue.cells)) lights.push(...fpCells(clue.cells, size));
+ else lights.push(cellIndexFromAddress(clue.cell, size));
+ }
+ if (lights.length === 0) {
+ for (let cell = 0; cell < givens.length; cell += 1) {
+ if ((givens[cell] ?? 0) !== 0) lights.push(cell);
+ }
+ }
+ if (lights.length === 0) {
+ return fail(
+ "INVALID_FPUZZLES",
+ "Fog of War requires at least one fog light or given cell.",
+ );
+ }
+ if (solution === undefined) {
+ return fail(
+ "INVALID_FPUZZLES",
+ "Fog of War requires an embedded solution for safe local reveals.",
+ );
+ }
+ constraints.push({ type: "fog", lights: [...new Set(lights)] });
+ }
const regions = readRegions(grid, size);
const rules = parseRules(value.ruleset);
@@ -585,6 +1077,14 @@ export function parseFpuzzles(value: unknown): SudokuDocument {
typeof value.author === "string"
? value.author.slice(0, 20_000)
: undefined;
+ const visuals = parseFpuzzlesVisuals(value, size);
+ const metadata = fpuzzlesScalarMetadata(value);
+ const sourceId =
+ value.id === undefined
+ ? undefined
+ : typeof value.id === "string" && value.id.length <= 512
+ ? value.id
+ : fail("INVALID_FPUZZLES", "fpuzzles.id must be bounded text.");
return {
schema: SUDOKU_DOCUMENT_SCHEMA,
@@ -600,6 +1100,13 @@ export function parseFpuzzles(value: unknown): SudokuDocument {
...(rules === undefined ? {} : { rules }),
...(title === undefined ? {} : { title }),
...(author === undefined ? {} : { author }),
+ ...(sourceId === undefined ? {} : { id: sourceId }),
+ ...(visuals.length === 0 ? {} : { visuals }),
+ source: {
+ format: "fpuzzles",
+ ...(sourceId === undefined ? {} : { id: sourceId }),
+ },
+ ...(Object.keys(metadata).length === 0 ? {} : { metadata }),
};
}
@@ -607,6 +1114,109 @@ function constraintCells(cells: readonly number[], size: number): string[] {
return cells.map((cell) => addressFromCellIndex(cell, size));
}
+function fpAddressFromVisualAnchor(
+ anchor: SafeVisualAnchor,
+ size: number,
+): string {
+ if (anchor.kind === "cell") {
+ if ((anchor.offsetX ?? 0) !== 0 || (anchor.offsetY ?? 0) !== 0) {
+ return fail(
+ "UNSUPPORTED_FPUZZLES_VISUAL",
+ "f-puzzles cannot preserve an offset visual anchor.",
+ );
+ }
+ return addressFromCellIndex(anchor.cell, size);
+ }
+ const row = anchor.y + 0.5;
+ const column = anchor.x + 0.5;
+ if (
+ !Number.isInteger(row) ||
+ !Number.isInteger(column) ||
+ row < 1 ||
+ row > size ||
+ column < 1 ||
+ column > size
+ ) {
+ return fail(
+ "UNSUPPORTED_FPUZZLES_VISUAL",
+ "f-puzzles cannot preserve a free-coordinate visual anchor.",
+ );
+ }
+ return `R${String(row)}C${String(column)}`;
+}
+
+function exportFpuzzlesVisual(
+ visual: SafeVisualPrimitive,
+ size: number,
+): readonly [
+ field: "line" | "rectangle" | "circle" | "text",
+ value: JsonRecord,
+] {
+ if (visual.layer !== "overlay") {
+ return fail(
+ "UNSUPPORTED_FPUZZLES_VISUAL",
+ "f-puzzles cannot preserve explicit underlay ordering.",
+ );
+ }
+ const style = visual.style ?? {};
+ switch (visual.type) {
+ case "line":
+ case "polyline": {
+ const anchors =
+ visual.type === "line" ? [visual.start, visual.end] : visual.points;
+ return [
+ "line",
+ {
+ lines: [
+ anchors.map((anchor) => fpAddressFromVisualAnchor(anchor, size)),
+ ],
+ outlineC: style.stroke ?? "#000000",
+ width: style.strokeWidth ?? 0.05,
+ ...(style.opacity === undefined ? {} : { opacity: style.opacity }),
+ },
+ ];
+ }
+ case "rectangle":
+ return [
+ "rectangle",
+ {
+ cells: [fpAddressFromVisualAnchor(visual.center, size)],
+ width: visual.width,
+ height: visual.height,
+ baseC: style.fill ?? "transparent",
+ outlineC: style.stroke ?? "transparent",
+ ...(style.opacity === undefined ? {} : { opacity: style.opacity }),
+ },
+ ];
+ case "ellipse":
+ case "circle":
+ return [
+ "circle",
+ {
+ cells: [fpAddressFromVisualAnchor(visual.center, size)],
+ width:
+ visual.type === "circle" ? visual.radius * 2 : visual.radiusX * 2,
+ height:
+ visual.type === "circle" ? visual.radius * 2 : visual.radiusY * 2,
+ baseC: style.fill ?? "transparent",
+ outlineC: style.stroke ?? "transparent",
+ ...(style.opacity === undefined ? {} : { opacity: style.opacity }),
+ },
+ ];
+ case "text":
+ return [
+ "text",
+ {
+ cells: [fpAddressFromVisualAnchor(visual.position, size)],
+ value: visual.text,
+ fontC: style.fill ?? "#000000",
+ size: (style.fontSize ?? 0.5) / 0.5,
+ ...(style.opacity === undefined ? {} : { opacity: style.opacity }),
+ },
+ ];
+ }
+}
+
export function exportFpuzzles(document: SudokuDocument): JsonRecord {
const { size } = document;
const output: JsonRecord = {
@@ -672,6 +1282,9 @@ export function exportFpuzzles(document: SudokuDocument): JsonRecord {
case "non-consecutive":
output.nonconsecutive = true;
break;
+ case "disjoint-groups":
+ output.disjointgroups = true;
+ break;
case "killer-cage":
append("killercage", {
cells: constraintCells(constraint.cells, size),
@@ -692,6 +1305,31 @@ export function exportFpuzzles(document: SudokuDocument): JsonRecord {
lines: [constraintCells(constraint.cells, size)],
});
break;
+ case "between-line":
+ case "region-sum-line":
+ case "modular-line":
+ case "entropic-line":
+ case "zipper-line":
+ case "double-arrow": {
+ const field =
+ constraint.type === "between-line"
+ ? "betweenline"
+ : constraint.type === "region-sum-line"
+ ? "regionsumline"
+ : constraint.type.replace("-", "");
+ append(field, {
+ lines: [constraintCells(constraint.cells, size)],
+ });
+ break;
+ }
+ case "german-whisper":
+ append("whispers", {
+ lines: [constraintCells(constraint.cells, size)],
+ ...(constraint.minimumDifference === undefined
+ ? {}
+ : { value: String(constraint.minimumDifference) }),
+ });
+ break;
case "arrow":
append("arrow", {
cells: constraintCells(constraint.bulb, size),
@@ -764,9 +1402,80 @@ export function exportFpuzzles(document: SudokuDocument): JsonRecord {
cell: addressFromCellIndex(constraint.cell, size),
});
break;
+ case "minimum":
+ case "odd":
+ case "even":
+ append(constraint.type, {
+ cell: addressFromCellIndex(constraint.cell, size),
+ });
+ break;
+ case "little-killer":
+ append("littlekillersum", {
+ cell: addressFromOutsideClue(
+ constraint.side,
+ numeric(constraint.index, "littlekillersum.index", 0, size - 1),
+ size,
+ ),
+ direction: fpLittleKillerDirection(constraint.direction),
+ value: String(constraint.sum),
+ });
+ break;
+ case "sandwich":
+ append("sandwichsum", {
+ cell: addressFromOutsideClue(
+ constraint.side,
+ numeric(constraint.index, "sandwichsum.index", 0, size - 1),
+ size,
+ ),
+ value: String(constraint.sum),
+ });
+ break;
+ case "clone":
+ append("clone", {
+ cells: constraintCells(constraint.cells, size),
+ cloneCells: constraintCells(constraint.cloneCells, size),
+ });
+ break;
+ case "extra-region":
+ append("extraregion", {
+ cells: constraintCells(constraint.cells, size),
+ });
+ break;
+ case "indexer":
+ append(`${constraint.kind}indexer`, {
+ cell: addressFromCellIndex(constraint.cell, size),
+ });
+ break;
+ case "fog":
+ output.fogofwar = true;
+ append("foglight", {
+ cells: constraintCells(constraint.lights, size),
+ });
+ break;
}
}
+ for (const visual of document.visuals ?? []) {
+ const [field, value] = exportFpuzzlesVisual(visual, size);
+ append(field, value);
+ }
+ for (const [key, value] of Object.entries(document.metadata ?? {})) {
+ if (
+ key === "successMessage" ||
+ key === "successmessage" ||
+ key === "disabledlogic" ||
+ key === "truecandidatesoptions"
+ ) {
+ output[key] = value;
+ }
+ }
+ if (
+ document.source?.format === "fpuzzles" &&
+ document.source.id !== undefined
+ ) {
+ output.id = document.source.id;
+ }
+
if ((document.globalRules?.length ?? 0) > 0) {
return fail(
"UNSUPPORTED_FPUZZLES",
diff --git a/src/formats/grid.ts b/src/formats/grid.ts
index 60ce249..521b76b 100644
--- a/src/formats/grid.ts
+++ b/src/formats/grid.ts
@@ -69,6 +69,7 @@ export function parsePlainGrid(
size,
givens,
constraints: [],
+ source: { format: "plain-grid" },
...(options.title === undefined ? {} : { title: options.title }),
...(options.author === undefined ? {} : { author: options.author }),
};
diff --git a/src/formats/import.ts b/src/formats/import.ts
index c0e5af3..aa2ce9a 100644
--- a/src/formats/import.ts
+++ b/src/formats/import.ts
@@ -9,6 +9,7 @@ import {
parseFpuzzles,
} from "./fpuzzles";
import {
+ puzzleImportResult,
type PuzzleImportResult,
RemotePuzzleReferenceError,
} from "./interoperability";
@@ -88,61 +89,45 @@ export async function importPuzzle(
throw new RemotePuzzleReferenceError("The pasted URL");
}
if (url?.kind === "penpa") {
- return {
- document: await importPenpa(url.value),
- format: "penpa",
- label: "Penpa+",
- };
+ return puzzleImportResult(await importPenpa(url.value), "penpa", "Penpa+");
}
if (url?.kind === "sudokupad") {
- return {
- document: importSudokuPad(url.value),
- format: "sudokupad",
- label: "SudokuPad/CTC",
- };
+ return puzzleImportResult(
+ importSudokuPad(url.value),
+ "sudokupad",
+ "SudokuPad/CTC",
+ );
}
if (url?.kind === "fpuzzles") {
- return {
- document: importFpuzzles(url.value),
- format: "fpuzzles",
- label: "f-puzzles",
- };
+ return puzzleImportResult(
+ importFpuzzles(url.value),
+ "fpuzzles",
+ "f-puzzles",
+ );
}
if (trimmed.startsWith("#sudoku=") || trimmed.includes("#sudoku=")) {
- return {
- document: decodePuzzleHash(trimmed),
- format: "sudoku-tools",
- label: "Sudoku Tools share link",
- };
+ return puzzleImportResult(
+ decodePuzzleHash(trimmed),
+ "sudoku-tools",
+ "Sudoku Tools share link",
+ );
}
if (/^(?:ctc|scl)/iu.test(trimmed)) {
- return {
- document: importSudokuPad(trimmed),
- format: "sudokupad",
- label: "SudokuPad/CTC",
- };
+ return puzzleImportResult(
+ importSudokuPad(trimmed),
+ "sudokupad",
+ "SudokuPad/CTC",
+ );
}
if (/^penpa:/iu.test(trimmed) || /^[?#]?(?:m=[^&]+&)?p=/iu.test(trimmed)) {
- return {
- document: await importPenpa(trimmed),
- format: "penpa",
- label: "Penpa+",
- };
+ return puzzleImportResult(await importPenpa(trimmed), "penpa", "Penpa+");
}
if (/^(?:square|sudoku),[^\r\n]+[\r\n]/iu.test(trimmed)) {
- return {
- document: parsePenpaText(trimmed),
- format: "penpa",
- label: "Penpa+ text",
- };
+ return puzzleImportResult(parsePenpaText(trimmed), "penpa", "Penpa+ text");
}
if (/^fpuzzles/iu.test(trimmed)) {
- return {
- document: importFpuzzles(trimmed),
- format: "fpuzzles",
- label: "f-puzzles",
- };
+ return puzzleImportResult(importFpuzzles(trimmed), "fpuzzles", "f-puzzles");
}
if (trimmed.startsWith("{")) {
if (new TextEncoder().encode(trimmed).byteLength > MAX_DOCUMENT_BYTES) {
@@ -162,28 +147,28 @@ export async function importPuzzle(
);
}
if (isRecord(parsed) && "schema" in parsed) {
- return {
- document: parseSudokuDocument(trimmed),
- format: "sudoku-tools",
- label: "Sudoku Tools JSON",
- };
+ return puzzleImportResult(
+ parseSudokuDocument(trimmed),
+ "sudoku-tools",
+ "Sudoku Tools JSON",
+ );
}
if (isRecord(parsed) && "cells" in parsed && !("grid" in parsed)) {
- return {
- document: parseSudokuPadPuzzle(parsed),
- format: "sudokupad",
- label: "SudokuPad/CTC JSON",
- };
+ return puzzleImportResult(
+ parseSudokuPadPuzzle(parsed),
+ "sudokupad",
+ "SudokuPad/CTC JSON",
+ );
}
- return {
- document: parseFpuzzles(parsed),
- format: "fpuzzles",
- label: "f-puzzles JSON",
- };
+ return puzzleImportResult(
+ parseFpuzzles(parsed),
+ "fpuzzles",
+ "f-puzzles JSON",
+ );
}
- return {
- document: parsePlainGrid(trimmed),
- format: "plain-grid",
- label: "plain grid",
- };
+ return puzzleImportResult(
+ parsePlainGrid(trimmed),
+ "plain-grid",
+ "plain grid",
+ );
}
diff --git a/src/formats/index.ts b/src/formats/index.ts
index cbe2823..ae54e3f 100644
--- a/src/formats/index.ts
+++ b/src/formats/index.ts
@@ -1,9 +1,11 @@
+export * from "./constraintVisuals";
export * from "./document";
export * from "./fpuzzles";
export * from "./grid";
export * from "./import";
export * from "./interoperability";
export * from "./penpa";
+export * from "./safeVisuals";
export * from "./share";
export * from "./sudokupad";
export * from "./types";
diff --git a/src/formats/interoperability.ts b/src/formats/interoperability.ts
index adbffd7..c0a030d 100644
--- a/src/formats/interoperability.ts
+++ b/src/formats/interoperability.ts
@@ -1,12 +1,129 @@
import { SudokuFormatError } from "./document";
+import type { SudokuDocument, SudokuSourceFormat } from "./types";
-export type PuzzleSourceFormat =
- "sudoku-tools" | "plain-grid" | "fpuzzles" | "sudokupad" | "penpa";
+export type PuzzleSourceFormat = SudokuSourceFormat;
+
+export interface PuzzleMappingEntry {
+ readonly key: string;
+ readonly label: string;
+ readonly count: number;
+}
+
+export interface PuzzleImportMappingPreview {
+ readonly mappedSemantics: readonly PuzzleMappingEntry[];
+ readonly preservedVisuals: readonly PuzzleMappingEntry[];
+ readonly preservedMetadata: readonly PuzzleMappingEntry[];
+ readonly warnings: readonly string[];
+}
export interface PuzzleImportResult {
readonly document: T;
readonly format: PuzzleSourceFormat;
readonly label: string;
+ readonly preview: PuzzleImportMappingPreview;
+}
+
+function readableType(value: string): string {
+ return value
+ .split("-")
+ .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
+ .join(" ");
+}
+
+export function buildPuzzleImportPreview(
+ document: SudokuDocument,
+ extraWarnings: readonly string[] = [],
+): PuzzleImportMappingPreview {
+ const semanticCounts = new Map();
+ for (const constraint of document.constraints) {
+ semanticCounts.set(
+ constraint.type,
+ (semanticCounts.get(constraint.type) ?? 0) + 1,
+ );
+ }
+ const givenCount = document.givens.filter((value) => value !== 0).length;
+ const mappedSemantics: PuzzleMappingEntry[] = [
+ { key: "givens", label: "Given digits", count: givenCount },
+ ...[...semanticCounts].map(([key, count]) => ({
+ key,
+ label: readableType(key),
+ count,
+ })),
+ ];
+ if (document.regions !== undefined) {
+ mappedSemantics.push({ key: "regions", label: "Region map", count: 1 });
+ }
+
+ const visualCounts = new Map();
+ for (const visual of document.visuals ?? []) {
+ const key = `${visual.layer}:${visual.type}`;
+ visualCounts.set(key, (visualCounts.get(key) ?? 0) + 1);
+ }
+ const preservedVisuals = [...visualCounts].map(([key, count]) => {
+ const [layer = "overlay", type = key] = key.split(":");
+ return {
+ key,
+ label: `${readableType(type)} ${layer}`,
+ count,
+ };
+ });
+
+ const preservedMetadata: PuzzleMappingEntry[] = [];
+ if (document.source !== undefined) {
+ preservedMetadata.push({
+ key: "source",
+ label: `Source identity (${document.source.format})`,
+ count: 1,
+ });
+ }
+ for (const key of Object.keys(document.metadata ?? {})) {
+ preservedMetadata.push({ key, label: key, count: 1 });
+ }
+ for (const [key, present] of [
+ ["title", document.title !== undefined],
+ ["author", document.author !== undefined],
+ ["rules", (document.rules?.length ?? 0) > 0],
+ ["progress", document.values !== undefined],
+ ] as const) {
+ if (present)
+ preservedMetadata.push({ key, label: readableType(key), count: 1 });
+ }
+
+ const warnings = [...extraWarnings];
+ if (preservedVisuals.length > 0) {
+ warnings.push(
+ "Imported drawings are preserved as inert visuals; a visual clue is not solver-enforced unless it also appears under mapped semantics.",
+ );
+ }
+ if (Object.keys(document.metadata ?? {}).length > 0) {
+ warnings.push(
+ "Uninterpreted scalar metadata is retained for round-trip export but does not change puzzle rules.",
+ );
+ }
+ return {
+ mappedSemantics,
+ preservedVisuals,
+ preservedMetadata,
+ warnings: [...new Set(warnings)],
+ };
+}
+
+export function puzzleImportResult(
+ document: SudokuDocument,
+ format: PuzzleSourceFormat,
+ label: string,
+ warnings: readonly string[] = [],
+): PuzzleImportResult {
+ const sourced =
+ document.source === undefined
+ ? { ...document, source: { format } as const }
+ : document;
+ return {
+ document: sourced,
+ format,
+ label,
+ preview: buildPuzzleImportPreview(sourced, warnings),
+ };
}
export class UnsupportedPuzzleConstructsError extends SudokuFormatError {
diff --git a/src/formats/penpa.ts b/src/formats/penpa.ts
index 30d61c8..26e5b66 100644
--- a/src/formats/penpa.ts
+++ b/src/formats/penpa.ts
@@ -452,6 +452,7 @@ export function parsePenpaText(compressedText: string): SudokuDocument {
givens,
values,
constraints,
+ source: { format: "penpa" },
...(title === undefined || title === "" ? {} : { title }),
...(author === undefined || author === "" ? {} : { author }),
...(rules === undefined ? {} : { rules: [rules] }),
diff --git a/src/formats/safeVisuals.ts b/src/formats/safeVisuals.ts
new file mode 100644
index 0000000..1b386e6
--- /dev/null
+++ b/src/formats/safeVisuals.ts
@@ -0,0 +1,407 @@
+import type {
+ SafeVisualAnchor,
+ SafeVisualPrimitive,
+ SafeVisualStyle,
+ ScalarMetadata,
+ ScalarMetadataValue,
+ SudokuSourceFormat,
+ SudokuSourceIdentity,
+} from "./types";
+
+export const MAX_VISUAL_PRIMITIVES = 2_000;
+export const MAX_VISUAL_POINTS = 20_000;
+export const MAX_VISUAL_TEXT_LENGTH = 4_096;
+export const MAX_SCALAR_METADATA_FIELDS = 128;
+export const MAX_SCALAR_METADATA_KEY_LENGTH = 96;
+export const MAX_SCALAR_METADATA_TEXT_LENGTH = 8_192;
+
+const SOURCE_FORMATS = new Set([
+ "sudoku-tools",
+ "plain-grid",
+ "fpuzzles",
+ "sudokupad",
+ "penpa",
+]);
+const EXECUTABLE_KEY =
+ /^(?:on[a-z]+|script|javascript|html|svg|css|style|src|href|url|code|customcode|customstyle)$/iu;
+const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/iu;
+const NAMED_COLORS: Readonly> = {
+ black: "#000000",
+ white: "#ffffff",
+ transparent: "transparent",
+ none: "transparent",
+};
+
+export class SafeVisualValidationError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "SafeVisualValidationError";
+ }
+}
+
+function fail(message: string): never {
+ throw new SafeVisualValidationError(message);
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function exactFields(
+ value: Record,
+ allowed: ReadonlySet,
+ label: string,
+): void {
+ const invalid = Object.keys(value).find((key) => !allowed.has(key));
+ if (invalid !== undefined) {
+ fail(`${label}.${invalid} is not an allowlisted visual property.`);
+ }
+}
+
+function finite(
+ value: unknown,
+ label: string,
+ minimum: number,
+ maximum: number,
+): number {
+ if (
+ typeof value !== "number" ||
+ !Number.isFinite(value) ||
+ value < minimum ||
+ value > maximum
+ ) {
+ return fail(
+ `${label} must be a finite number from ${minimum} to ${maximum}.`,
+ );
+ }
+ return value;
+}
+
+/** Accept only inert hexadecimal tokens (or transparent), never CSS syntax. */
+export function normalizeSafeVisualColor(
+ value: unknown,
+ label = "visual colour",
+): string {
+ if (typeof value !== "string")
+ return fail(`${label} must be a colour token.`);
+ const trimmed = value.trim().toLowerCase();
+ const named = NAMED_COLORS[trimmed];
+ if (named !== undefined) return named;
+ if (!HEX_COLOR.test(trimmed)) {
+ return fail(
+ `${label} must be a hexadecimal colour or transparent; CSS expressions are not accepted.`,
+ );
+ }
+ if (trimmed.length === 4 || trimmed.length === 5) {
+ return `#${[...trimmed.slice(1)].map((digit) => digit + digit).join("")}`;
+ }
+ return trimmed;
+}
+
+function normalizeAnchor(
+ value: unknown,
+ size: number,
+ label: string,
+): SafeVisualAnchor {
+ if (!isRecord(value)) return fail(`${label} must be a visual anchor.`);
+ if (value.kind === "coordinate") {
+ exactFields(value, new Set(["kind", "x", "y"]), label);
+ return {
+ kind: "coordinate",
+ x: finite(value.x, `${label}.x`, -4, size + 4),
+ y: finite(value.y, `${label}.y`, -4, size + 4),
+ };
+ }
+ if (value.kind === "cell") {
+ exactFields(value, new Set(["kind", "cell", "offsetX", "offsetY"]), label);
+ if (
+ !Number.isInteger(value.cell) ||
+ (value.cell as number) < 0 ||
+ (value.cell as number) >= size * size
+ ) {
+ return fail(`${label}.cell is outside the grid.`);
+ }
+ return {
+ kind: "cell",
+ cell: value.cell as number,
+ ...(value.offsetX === undefined
+ ? {}
+ : { offsetX: finite(value.offsetX, `${label}.offsetX`, -4, 4) }),
+ ...(value.offsetY === undefined
+ ? {}
+ : { offsetY: finite(value.offsetY, `${label}.offsetY`, -4, 4) }),
+ };
+ }
+ return fail(`${label}.kind must be coordinate or cell.`);
+}
+
+function normalizeStyle(
+ value: unknown,
+ label: string,
+): SafeVisualStyle | undefined {
+ if (value === undefined) return undefined;
+ if (!isRecord(value)) return fail(`${label} must be a visual style object.`);
+ exactFields(
+ value,
+ new Set(["stroke", "fill", "strokeWidth", "opacity", "fontSize"]),
+ label,
+ );
+ return {
+ ...(value.stroke === undefined
+ ? {}
+ : { stroke: normalizeSafeVisualColor(value.stroke, `${label}.stroke`) }),
+ ...(value.fill === undefined
+ ? {}
+ : { fill: normalizeSafeVisualColor(value.fill, `${label}.fill`) }),
+ ...(value.strokeWidth === undefined
+ ? {}
+ : {
+ strokeWidth: finite(value.strokeWidth, `${label}.strokeWidth`, 0, 4),
+ }),
+ ...(value.opacity === undefined
+ ? {}
+ : { opacity: finite(value.opacity, `${label}.opacity`, 0, 1) }),
+ ...(value.fontSize === undefined
+ ? {}
+ : { fontSize: finite(value.fontSize, `${label}.fontSize`, 0.05, 8) }),
+ };
+}
+
+function layer(value: unknown, label: string): "underlay" | "overlay" {
+ if (value !== "underlay" && value !== "overlay") {
+ return fail(`${label} must be underlay or overlay.`);
+ }
+ return value;
+}
+
+function primitive(
+ value: unknown,
+ size: number,
+ index: number,
+): SafeVisualPrimitive {
+ const label = `visuals[${String(index)}]`;
+ if (!isRecord(value) || typeof value.type !== "string") {
+ return fail(`${label} must be a typed visual primitive.`);
+ }
+ const common = {
+ layer: layer(value.layer, `${label}.layer`),
+ ...(value.style === undefined
+ ? {}
+ : { style: normalizeStyle(value.style, `${label}.style`)! }),
+ } as const;
+ switch (value.type) {
+ case "line":
+ exactFields(
+ value,
+ new Set(["type", "layer", "style", "start", "end"]),
+ label,
+ );
+ return {
+ type: "line",
+ ...common,
+ start: normalizeAnchor(value.start, size, `${label}.start`),
+ end: normalizeAnchor(value.end, size, `${label}.end`),
+ };
+ case "polyline": {
+ exactFields(
+ value,
+ new Set(["type", "layer", "style", "points", "closed"]),
+ label,
+ );
+ if (
+ !Array.isArray(value.points) ||
+ value.points.length < 2 ||
+ value.points.length > MAX_VISUAL_POINTS
+ ) {
+ return fail(
+ `${label}.points must contain 2 to ${MAX_VISUAL_POINTS} anchors.`,
+ );
+ }
+ if (value.closed !== undefined && typeof value.closed !== "boolean") {
+ return fail(`${label}.closed must be true or false.`);
+ }
+ return {
+ type: "polyline",
+ ...common,
+ points: value.points.map((point, pointIndex) =>
+ normalizeAnchor(
+ point,
+ size,
+ `${label}.points[${String(pointIndex)}]`,
+ ),
+ ),
+ ...(value.closed === true ? { closed: true } : {}),
+ };
+ }
+ case "rectangle":
+ exactFields(
+ value,
+ new Set([
+ "type",
+ "layer",
+ "style",
+ "center",
+ "width",
+ "height",
+ "cornerRadius",
+ ]),
+ label,
+ );
+ return {
+ type: "rectangle",
+ ...common,
+ center: normalizeAnchor(value.center, size, `${label}.center`),
+ width: finite(value.width, `${label}.width`, 0.01, size + 8),
+ height: finite(value.height, `${label}.height`, 0.01, size + 8),
+ ...(value.cornerRadius === undefined
+ ? {}
+ : {
+ cornerRadius: finite(
+ value.cornerRadius,
+ `${label}.cornerRadius`,
+ 0,
+ size + 8,
+ ),
+ }),
+ };
+ case "ellipse":
+ exactFields(
+ value,
+ new Set(["type", "layer", "style", "center", "radiusX", "radiusY"]),
+ label,
+ );
+ return {
+ type: "ellipse",
+ ...common,
+ center: normalizeAnchor(value.center, size, `${label}.center`),
+ radiusX: finite(value.radiusX, `${label}.radiusX`, 0.005, size + 4),
+ radiusY: finite(value.radiusY, `${label}.radiusY`, 0.005, size + 4),
+ };
+ case "circle":
+ exactFields(
+ value,
+ new Set(["type", "layer", "style", "center", "radius"]),
+ label,
+ );
+ return {
+ type: "circle",
+ ...common,
+ center: normalizeAnchor(value.center, size, `${label}.center`),
+ radius: finite(value.radius, `${label}.radius`, 0.005, size + 4),
+ };
+ case "text":
+ exactFields(
+ value,
+ new Set(["type", "layer", "style", "position", "text"]),
+ label,
+ );
+ if (
+ typeof value.text !== "string" ||
+ value.text.length > MAX_VISUAL_TEXT_LENGTH
+ ) {
+ return fail(
+ `${label}.text must be text of at most ${MAX_VISUAL_TEXT_LENGTH} characters.`,
+ );
+ }
+ return {
+ type: "text",
+ ...common,
+ position: normalizeAnchor(value.position, size, `${label}.position`),
+ text: value.text,
+ };
+ default:
+ return fail(`${label}.type is not an allowlisted visual primitive.`);
+ }
+}
+
+export function normalizeSafeVisualPrimitives(
+ value: unknown,
+ size: number,
+): SafeVisualPrimitive[] | undefined {
+ if (value === undefined) return undefined;
+ if (!Array.isArray(value) || value.length > MAX_VISUAL_PRIMITIVES) {
+ return fail(
+ `visuals must contain at most ${MAX_VISUAL_PRIMITIVES} primitives.`,
+ );
+ }
+ const result = value.map((entry, index) => primitive(entry, size, index));
+ const pointCount = result.reduce((count, entry) => {
+ if (entry.type === "polyline") return count + entry.points.length;
+ if (entry.type === "line") return count + 2;
+ return count + 1;
+ }, 0);
+ if (pointCount > MAX_VISUAL_POINTS) {
+ return fail(`visuals contain more than ${MAX_VISUAL_POINTS} anchors.`);
+ }
+ return result;
+}
+
+export function normalizeSourceIdentity(
+ value: unknown,
+): SudokuSourceIdentity | undefined {
+ if (value === undefined) return undefined;
+ if (!isRecord(value)) return fail("source must be an identity object.");
+ exactFields(value, new Set(["format", "id", "version"]), "source");
+ if (
+ typeof value.format !== "string" ||
+ !SOURCE_FORMATS.has(value.format as SudokuSourceFormat)
+ ) {
+ return fail("source.format is not supported.");
+ }
+ const text = (entry: unknown, label: string): string | undefined => {
+ if (entry === undefined) return undefined;
+ if (typeof entry !== "string" || entry.length > 512) {
+ return fail(`${label} must be bounded text.`);
+ }
+ return entry;
+ };
+ const id = text(value.id, "source.id");
+ const version = text(value.version, "source.version");
+ return {
+ format: value.format as SudokuSourceFormat,
+ ...(id === undefined ? {} : { id }),
+ ...(version === undefined ? {} : { version }),
+ };
+}
+
+function scalarMetadataValue(
+ value: unknown,
+ label: string,
+): ScalarMetadataValue {
+ if (value === null || typeof value === "boolean") return value;
+ if (typeof value === "number" && Number.isFinite(value)) return value;
+ if (
+ typeof value === "string" &&
+ value.length <= MAX_SCALAR_METADATA_TEXT_LENGTH
+ )
+ return value;
+ return fail(`${label} must be a bounded JSON scalar.`);
+}
+
+export function normalizeScalarMetadata(
+ value: unknown,
+): ScalarMetadata | undefined {
+ if (value === undefined) return undefined;
+ if (!isRecord(value)) return fail("metadata must be a scalar object.");
+ const entries = Object.entries(value);
+ if (entries.length > MAX_SCALAR_METADATA_FIELDS) {
+ return fail(
+ `metadata must contain at most ${MAX_SCALAR_METADATA_FIELDS} fields.`,
+ );
+ }
+ const output: Record = {};
+ for (const [key, raw] of entries) {
+ if (
+ key.length === 0 ||
+ key.length > MAX_SCALAR_METADATA_KEY_LENGTH ||
+ EXECUTABLE_KEY.test(key) ||
+ key === "__proto__" ||
+ key === "prototype" ||
+ key === "constructor"
+ ) {
+ return fail(`metadata key “${key}” is not accepted.`);
+ }
+ output[key] = scalarMetadataValue(raw, `metadata.${key}`);
+ }
+ return output;
+}
diff --git a/src/formats/share.ts b/src/formats/share.ts
index 24a534e..d96e920 100644
--- a/src/formats/share.ts
+++ b/src/formats/share.ts
@@ -72,5 +72,8 @@ export function decodePuzzleHash(hashOrUrl: string): SudokuDocument {
"The share payload could not be decompressed.",
);
}
- return parseSudokuDocument(json);
+ const document = parseSudokuDocument(json);
+ return document.source === undefined
+ ? { ...document, source: { format: "sudoku-tools" } }
+ : document;
}
diff --git a/src/formats/sudokupad.ts b/src/formats/sudokupad.ts
index 188f855..5913796 100644
--- a/src/formats/sudokupad.ts
+++ b/src/formats/sudokupad.ts
@@ -1,3 +1,4 @@
+import { compressToBase64 } from "lz-string";
import {
LzStringOutputLimitError,
decompressFromBase64OrUriComponentBounded,
@@ -7,16 +8,38 @@ import {
MAX_DOCUMENT_BYTES,
MIN_BOARD_SIZE,
SudokuFormatError,
+ normalizeSudokuDocument,
} from "./document";
+import { canonicalConstraintVisuals } from "./constraintVisuals";
import { UnsupportedPuzzleConstructsError } from "./interoperability";
+import {
+ SafeVisualValidationError,
+ normalizeSafeVisualColor,
+ normalizeSafeVisualPrimitives,
+ normalizeScalarMetadata,
+} from "./safeVisuals";
import {
SUDOKU_DOCUMENT_SCHEMA,
SUDOKU_DOCUMENT_VERSION,
type PortableConstraint,
+ type SafeVisualAnchor,
+ type SafeVisualPrimitive,
+ type SafeVisualStyle,
type SudokuDocument,
} from "./types";
export const MAX_SUDOKUPAD_PAYLOAD_LENGTH = 262_144;
+const SUDOKU_TOOLS_CONSTRAINTS_METADATA = "sudokuToolsConstraints";
+const RESERVED_SEMANTIC_METADATA = new Set([
+ "title",
+ "author",
+ "rules",
+ "solution",
+ "antiknight",
+ "antiking",
+ "nonconsecutive",
+ SUDOKU_TOOLS_CONSTRAINTS_METADATA,
+]);
type JsonRecord = Record;
@@ -38,6 +61,8 @@ const ROOT_FIELDS = new Set([
"author",
"rules",
"solution",
+ "duration",
+ "foglight",
]);
const CELL_FIELDS = new Set(["value", "given", "pencilMarks", "centremarks"]);
@@ -58,6 +83,21 @@ function present(value: unknown): boolean {
return true;
}
+function objects(value: unknown, maximum = 5_000): JsonRecord[] {
+ if (value === undefined) return [];
+ if (
+ !Array.isArray(value) ||
+ value.length > maximum ||
+ !value.every(isRecord)
+ ) {
+ return fail(
+ "LIMIT_EXCEEDED",
+ "A SudokuPad collection is invalid or too large.",
+ );
+ }
+ return value;
+}
+
function boundedText(value: unknown, label: string, maximum = 16_384) {
if (value === undefined || value === null || value === "") return undefined;
if (typeof value !== "string" || value.length > maximum) {
@@ -90,6 +130,378 @@ function integer(
return parsed as number;
}
+function finiteNumber(
+ value: unknown,
+ label: string,
+ minimum: number,
+ maximum: number,
+ fallback?: number,
+): number {
+ if (value === undefined && fallback !== undefined) return fallback;
+ const parsed =
+ typeof value === "string" && value.trim() !== "" ? Number(value) : value;
+ if (
+ typeof parsed !== "number" ||
+ !Number.isFinite(parsed) ||
+ parsed < minimum ||
+ parsed > maximum
+ ) {
+ return fail(
+ "INVALID_SUDOKUPAD",
+ `${label} must be a finite number from ${minimum} to ${maximum}.`,
+ );
+ }
+ return parsed;
+}
+
+function visualColor(value: unknown, label: string, fallback: string): string {
+ try {
+ return normalizeSafeVisualColor(value ?? fallback, label);
+ } catch (error) {
+ if (error instanceof SafeVisualValidationError) {
+ return fail("UNSAFE_VISUAL_STYLE", error.message);
+ }
+ throw error;
+ }
+}
+
+function visualPoint(
+ value: unknown,
+ size: number,
+ label: string,
+): Extract {
+ if (!Array.isArray(value) || value.length !== 2) {
+ return fail("INVALID_SUDOKUPAD", `${label} must be a [row, column] point.`);
+ }
+ return {
+ kind: "coordinate",
+ y: finiteNumber(value[0], `${label}[0]`, -4, size + 4),
+ x: finiteNumber(value[1], `${label}[1]`, -4, size + 4),
+ };
+}
+
+function visualLayer(
+ value: unknown,
+ fallback: "underlay" | "overlay",
+ label: string,
+): "underlay" | "overlay" {
+ if (value === undefined || value === "arrows" || value === "cell-grids")
+ return fallback;
+ if (value === "underlay" || value === "overlay") return value;
+ return fail(
+ "UNSAFE_VISUAL_CONSTRUCT",
+ `${label} targets an unsupported layer.`,
+ );
+}
+
+function assertVisualFields(
+ value: JsonRecord,
+ allowed: ReadonlySet,
+ label: string,
+): void {
+ const unknown = Object.keys(value).find((field) => !allowed.has(field));
+ if (unknown !== undefined) {
+ return fail(
+ "UNSAFE_VISUAL_CONSTRUCT",
+ `${label}.${unknown} is not an allowlisted visual field. Raw SVG paths, styles and custom code are never imported.`,
+ );
+ }
+}
+
+function visualWaypoints(
+ value: unknown,
+ size: number,
+ label: string,
+): Array> {
+ if (
+ !Array.isArray(value) ||
+ value.length < 2 ||
+ value.length > size * size * 4
+ ) {
+ return fail(
+ "INVALID_SUDOKUPAD",
+ `${label} must contain bounded waypoints.`,
+ );
+ }
+ return value.map((point, index) =>
+ visualPoint(point, size, `${label}[${String(index)}]`),
+ );
+}
+
+function parseSudokuPadVisuals(
+ root: JsonRecord,
+ size: number,
+ cellSize: number,
+): SafeVisualPrimitive[] {
+ const output: SafeVisualPrimitive[] = [];
+ const lineFields = new Set([
+ "target",
+ "color",
+ "thickness",
+ "wayPoints",
+ "opacity",
+ ]);
+ for (const [index, raw] of objects(root.lines).entries()) {
+ const label = `lines[${String(index)}]`;
+ assertVisualFields(raw, lineFields, label);
+ output.push({
+ type: "polyline",
+ layer: visualLayer(raw.target, "overlay", `${label}.target`),
+ points: visualWaypoints(raw.wayPoints, size, `${label}.wayPoints`),
+ style: {
+ stroke: visualColor(raw.color, `${label}.color`, "#000000"),
+ fill: "transparent",
+ strokeWidth:
+ finiteNumber(
+ raw.thickness,
+ `${label}.thickness`,
+ 0,
+ cellSize * 4,
+ 2,
+ ) / cellSize,
+ opacity: finiteNumber(raw.opacity, `${label}.opacity`, 0, 1, 1),
+ },
+ });
+ }
+
+ const shapeFields = new Set([
+ "target",
+ "center",
+ "width",
+ "height",
+ "rounded",
+ "roundedRadius",
+ "borderColor",
+ "backgroundColor",
+ "borderSize",
+ "thickness",
+ "opacity",
+ "text",
+ "textColor",
+ "color",
+ "fontSize",
+ "textStroke",
+ "textAnchor",
+ "maxWidth",
+ "angle",
+ ]);
+ for (const [rootField, fallbackLayer] of [
+ ["underlays", "underlay"],
+ ["overlays", "overlay"],
+ ] as const) {
+ for (const [index, raw] of objects(root[rootField]).entries()) {
+ const label = `${rootField}[${String(index)}]`;
+ assertVisualFields(raw, shapeFields, label);
+ if (raw.angle !== undefined && Number(raw.angle) !== 0) {
+ return fail(
+ "UNSAFE_VISUAL_CONSTRUCT",
+ `${label}.angle cannot be represented by the safe visual model.`,
+ );
+ }
+ if (raw.textAnchor !== undefined && raw.textAnchor !== "middle") {
+ return fail(
+ "UNSAFE_VISUAL_CONSTRUCT",
+ `${label}.textAnchor cannot be represented without changing layout.`,
+ );
+ }
+ const center = visualPoint(raw.center, size, `${label}.center`);
+ const width = finiteNumber(
+ raw.width,
+ `${label}.width`,
+ 0.01,
+ size + 8,
+ 1,
+ );
+ const height = finiteNumber(
+ raw.height,
+ `${label}.height`,
+ 0.01,
+ size + 8,
+ 1,
+ );
+ const layer = visualLayer(raw.target, fallbackLayer, `${label}.target`);
+ const style: SafeVisualStyle = {
+ stroke: visualColor(
+ raw.borderColor,
+ `${label}.borderColor`,
+ "transparent",
+ ),
+ fill: visualColor(
+ raw.backgroundColor,
+ `${label}.backgroundColor`,
+ "transparent",
+ ),
+ strokeWidth:
+ finiteNumber(
+ raw.borderSize ?? raw.thickness,
+ `${label}.borderSize`,
+ 0,
+ cellSize * 4,
+ 0,
+ ) / cellSize,
+ opacity: finiteNumber(raw.opacity, `${label}.opacity`, 0, 1, 1),
+ };
+ if (raw.rounded === true && width === height) {
+ output.push({
+ type: "circle",
+ layer,
+ center,
+ radius: width / 2,
+ style,
+ });
+ } else if (raw.rounded === true) {
+ output.push({
+ type: "ellipse",
+ layer,
+ center,
+ radiusX: width / 2,
+ radiusY: height / 2,
+ style,
+ });
+ } else {
+ output.push({
+ type: "rectangle",
+ layer,
+ center,
+ width,
+ height,
+ ...(raw.roundedRadius === undefined
+ ? {}
+ : {
+ cornerRadius:
+ finiteNumber(
+ raw.roundedRadius,
+ `${label}.roundedRadius`,
+ 0,
+ cellSize * (size + 8),
+ ) / cellSize,
+ }),
+ style,
+ });
+ }
+ if (raw.text !== undefined) {
+ output.push({
+ type: "text",
+ layer,
+ position: center,
+ text: String(raw.text),
+ style: {
+ fill: visualColor(
+ raw.textColor ?? raw.color,
+ `${label}.textColor`,
+ "#000000",
+ ),
+ ...(raw.textStroke === undefined
+ ? {}
+ : {
+ stroke: visualColor(
+ raw.textStroke,
+ `${label}.textStroke`,
+ "transparent",
+ ),
+ strokeWidth: 0.01,
+ }),
+ fontSize:
+ finiteNumber(
+ raw.fontSize,
+ `${label}.fontSize`,
+ 1,
+ cellSize * 8,
+ cellSize * 0.5,
+ ) / cellSize,
+ opacity: style.opacity,
+ },
+ });
+ }
+ }
+ }
+
+ const arrowFields = new Set([
+ "target",
+ "color",
+ "opacity",
+ "thickness",
+ "headLength",
+ "headStyle",
+ "headAngle",
+ "headIndent",
+ "wayPoints",
+ ]);
+ for (const [index, raw] of objects(root.arrows).entries()) {
+ const label = `arrows[${String(index)}]`;
+ assertVisualFields(raw, arrowFields, label);
+ if (
+ raw.headStyle !== undefined &&
+ raw.headStyle !== "stroke" &&
+ raw.headStyle !== "fill"
+ ) {
+ return fail(
+ "UNSAFE_VISUAL_CONSTRUCT",
+ `${label}.headStyle is unsupported.`,
+ );
+ }
+ const points = visualWaypoints(raw.wayPoints, size, `${label}.wayPoints`);
+ const visualStyle: SafeVisualStyle = {
+ stroke: visualColor(raw.color, `${label}.color`, "#000000"),
+ fill: "transparent",
+ strokeWidth:
+ finiteNumber(raw.thickness, `${label}.thickness`, 0, cellSize * 4, 2) /
+ cellSize,
+ opacity: finiteNumber(raw.opacity, `${label}.opacity`, 0, 1, 1),
+ };
+ const layer = visualLayer(raw.target, "overlay", `${label}.target`);
+ output.push({ type: "polyline", layer, points, style: visualStyle });
+ const before = points.at(-2)!;
+ const tip = points.at(-1)!;
+ const dx = tip.x - before.x;
+ const dy = tip.y - before.y;
+ const distance = Math.hypot(dx, dy);
+ if (distance === 0) {
+ return fail(
+ "INVALID_SUDOKUPAD",
+ `${label} has a zero-length final segment.`,
+ );
+ }
+ const length = finiteNumber(
+ raw.headLength,
+ `${label}.headLength`,
+ 0.02,
+ 4,
+ 0.3,
+ );
+ const angle =
+ (finiteNumber(raw.headAngle, `${label}.headAngle`, 10, 170, 90) / 2) *
+ (Math.PI / 180);
+ const ux = -dx / distance;
+ const uy = -dy / distance;
+ const wing = (sign: -1 | 1): SafeVisualAnchor => ({
+ kind: "coordinate",
+ x: tip.x + (ux * Math.cos(angle) - sign * uy * Math.sin(angle)) * length,
+ y: tip.y + (uy * Math.cos(angle) + sign * ux * Math.sin(angle)) * length,
+ });
+ output.push({
+ type: "polyline",
+ layer,
+ points: [wing(-1), tip, wing(1)],
+ ...(raw.headStyle === "fill" ? { closed: true } : {}),
+ style: {
+ ...visualStyle,
+ ...(raw.headStyle === "fill"
+ ? { fill: visualStyle.stroke, strokeWidth: 0 }
+ : {}),
+ },
+ });
+ }
+ try {
+ return normalizeSafeVisualPrimitives(output, size) ?? [];
+ } catch (error) {
+ if (error instanceof SafeVisualValidationError) {
+ return fail("INVALID_SUDOKUPAD_VISUAL", error.message);
+ }
+ throw error;
+ }
+}
+
function coordinate(value: unknown, size: number, label: string): number {
if (
!Array.isArray(value) ||
@@ -272,6 +684,44 @@ function parseCages(
return constraints;
}
+function embeddedConstraints(
+ value: unknown,
+ size: number,
+ givens: readonly number[],
+ solution: readonly number[] | undefined,
+): PortableConstraint[] | undefined {
+ if (value === undefined) return undefined;
+ if (typeof value !== "string" || value.length > 131_072) {
+ return fail(
+ "INVALID_SUDOKUPAD",
+ `${SUDOKU_TOOLS_CONSTRAINTS_METADATA} must be bounded JSON text.`,
+ );
+ }
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(value) as unknown;
+ } catch {
+ return fail(
+ "INVALID_SUDOKUPAD",
+ `${SUDOKU_TOOLS_CONSTRAINTS_METADATA} is not valid JSON.`,
+ );
+ }
+ if (!Array.isArray(parsed)) {
+ return fail(
+ "INVALID_SUDOKUPAD",
+ `${SUDOKU_TOOLS_CONSTRAINTS_METADATA} must contain a constraint array.`,
+ );
+ }
+ return normalizeSudokuDocument({
+ schema: SUDOKU_DOCUMENT_SCHEMA,
+ version: SUDOKU_DOCUMENT_VERSION,
+ size,
+ givens,
+ constraints: parsed,
+ ...(solution === undefined ? {} : { solution }),
+ }).constraints as PortableConstraint[];
+}
+
/** Parse raw, already-decoded SudokuPad/CTC (often called SCL) JSON. */
export function parseSudokuPadPuzzle(value: unknown): SudokuDocument {
if (!isRecord(value)) {
@@ -282,9 +732,6 @@ export function parseSudokuPadPuzzle(value: unknown): SudokuDocument {
if (!ROOT_FIELDS.has(field))
unsupported.push(`unknown root field “${field}”`);
}
- for (const field of ["lines", "underlays", "overlays", "arrows"] as const) {
- if (present(value[field])) unsupported.push(`visual ${field}`);
- }
if (present(value.global)) unsupported.push("custom global rules");
if (isRecord(value.settings)) {
for (const key of Object.keys(value.settings)) {
@@ -306,6 +753,13 @@ export function parseSudokuPadPuzzle(value: unknown): SudokuDocument {
MIN_BOARD_SIZE,
MAX_BOARD_SIZE,
);
+ const cellSize = finiteNumber(
+ value.cellSize,
+ "SudokuPad cellSize",
+ 16,
+ 256,
+ 50,
+ );
if (!value.cells.every((row) => Array.isArray(row) && row.length === size)) {
return fail(
"INVALID_SUDOKUPAD",
@@ -364,15 +818,47 @@ export function parseSudokuPadPuzzle(value: unknown): SudokuDocument {
}
const metadata = metadataFrom(value);
- const constraints = parseCages(value.cages, size, metadata);
- if (metadata.antiknight === true || metadata.antiknight === "true") {
- constraints.push({ type: "anti-knight" });
- }
- if (metadata.antiking === true || metadata.antiking === "true") {
- constraints.push({ type: "anti-king" });
- }
- if (metadata.nonconsecutive === true || metadata.nonconsecutive === "true") {
- constraints.push({ type: "non-consecutive" });
+ const title = metaText(value, metadata, "title");
+ const author = metaText(value, metadata, "author");
+ const rules = metaText(value, metadata, "rules");
+ const solution = solutionDigits(metaText(value, metadata, "solution"), size);
+ const cageConstraints = parseCages(value.cages, size, metadata);
+ const transported = embeddedConstraints(
+ metadata[SUDOKU_TOOLS_CONSTRAINTS_METADATA],
+ size,
+ givens,
+ solution,
+ );
+ const constraints = transported ?? cageConstraints;
+ if (transported === undefined) {
+ if (metadata.antiknight === true || metadata.antiknight === "true") {
+ constraints.push({ type: "anti-knight" });
+ }
+ if (metadata.antiking === true || metadata.antiking === "true") {
+ constraints.push({ type: "anti-king" });
+ }
+ if (
+ metadata.nonconsecutive === true ||
+ metadata.nonconsecutive === "true"
+ ) {
+ constraints.push({ type: "non-consecutive" });
+ }
+ if (value.foglight !== undefined) {
+ if (!Array.isArray(value.foglight) || value.foglight.length === 0) {
+ return fail("INVALID_SUDOKUPAD", "foglight must be a non-empty array.");
+ }
+ constraints.push({
+ type: "fog",
+ lights: [
+ ...new Set(
+ value.foglight.map((entry, index) =>
+ coordinate(entry, size, `foglight[${String(index)}]`),
+ ),
+ ),
+ ],
+ revealRadius: 1,
+ });
+ }
}
const knownMetadata = new Set([
"title",
@@ -382,23 +868,41 @@ export function parseSudokuPadPuzzle(value: unknown): SudokuDocument {
"antiknight",
"antiking",
"nonconsecutive",
+ SUDOKU_TOOLS_CONSTRAINTS_METADATA,
]);
- const unknownMetadata = Object.keys(metadata).filter(
- (key) => !knownMetadata.has(key),
- );
- if (unknownMetadata.length > 0) {
- throw new UnsupportedPuzzleConstructsError(
- "SudokuPad/CTC",
- unknownMetadata.map((key) => `metadata “${key}”`),
- );
+ const rawScalarMetadata: Record = {};
+ for (const [key, raw] of Object.entries(metadata)) {
+ if (!knownMetadata.has(key)) rawScalarMetadata[key] = raw;
}
-
- const title = metaText(value, metadata, "title");
- const author = metaText(value, metadata, "author");
- const rules = metaText(value, metadata, "rules");
- const solution = solutionDigits(metaText(value, metadata, "solution"), size);
+ if (
+ isRecord(value.settings) &&
+ value.settings.conflictchecker !== undefined
+ ) {
+ rawScalarMetadata["setting.conflictchecker"] =
+ value.settings.conflictchecker;
+ }
+ rawScalarMetadata.cellSize = cellSize;
+ let scalarMetadata;
+ try {
+ scalarMetadata = normalizeScalarMetadata(rawScalarMetadata);
+ } catch (error) {
+ if (error instanceof SafeVisualValidationError) {
+ return fail("UNSAFE_SUDOKUPAD_METADATA", error.message);
+ }
+ throw error;
+ }
+ const visuals = parseSudokuPadVisuals(value, size, cellSize);
const regions = parseRegions(value.regions, size);
const id = boundedText(value.id, "SudokuPad id", 256);
+ const elapsedMs =
+ value.duration === undefined
+ ? undefined
+ : finiteNumber(
+ value.duration,
+ "SudokuPad duration",
+ 0,
+ 1_000_000_000_000,
+ );
return {
schema: SUDOKU_DOCUMENT_SCHEMA,
version: SUDOKU_DOCUMENT_VERSION,
@@ -414,6 +918,13 @@ export function parseSudokuPadPuzzle(value: unknown): SudokuDocument {
...(rules === undefined ? {} : { rules: [rules.slice(0, 16_384)] }),
...(solution === undefined ? {} : { solution }),
...(id === undefined ? {} : { id }),
+ ...(elapsedMs === undefined ? {} : { elapsedMs }),
+ ...(visuals.length === 0 ? {} : { visuals }),
+ source: {
+ format: "sudokupad",
+ ...(id === undefined ? {} : { id }),
+ },
+ ...(scalarMetadata === undefined ? {} : { metadata: scalarMetadata }),
};
}
@@ -487,3 +998,279 @@ export function importSudokuPad(input: string): SudokuDocument {
}
return parseSudokuPadPuzzle(boundedJson(decodePayload(payload)));
}
+
+function sclPoint(
+ anchor: SafeVisualAnchor,
+ size: number,
+): readonly [number, number] {
+ if (anchor.kind === "coordinate") return [anchor.y, anchor.x];
+ return [
+ Math.floor(anchor.cell / size) + 0.5 + (anchor.offsetY ?? 0),
+ (anchor.cell % size) + 0.5 + (anchor.offsetX ?? 0),
+ ];
+}
+
+interface SclVisualCollections {
+ readonly lines: JsonRecord[];
+ readonly underlays: JsonRecord[];
+ readonly overlays: JsonRecord[];
+}
+
+function appendSclVisual(
+ output: SclVisualCollections,
+ visual: SafeVisualPrimitive,
+ size: number,
+ cellSize: number,
+): void {
+ const style = visual.style ?? {};
+ const target = visual.layer;
+ if (visual.type === "line" || visual.type === "polyline") {
+ const points =
+ visual.type === "line"
+ ? [visual.start, visual.end]
+ : visual.closed
+ ? [...visual.points, visual.points[0]!]
+ : visual.points;
+ output.lines.push({
+ target,
+ color: style.stroke ?? "#000000",
+ thickness: (style.strokeWidth ?? 0.03) * cellSize,
+ wayPoints: points.map((point) => sclPoint(point, size)),
+ ...(style.opacity === undefined ? {} : { opacity: style.opacity }),
+ });
+ return;
+ }
+
+ const collection =
+ visual.layer === "underlay" ? output.underlays : output.overlays;
+ if (visual.type === "text") {
+ collection.push({
+ center: sclPoint(visual.position, size),
+ width: 0.25,
+ height: 0.25,
+ rounded: false,
+ backgroundColor: "transparent",
+ borderColor: "transparent",
+ color: style.fill ?? "#000000",
+ fontSize: (style.fontSize ?? 0.5) * cellSize,
+ text: visual.text,
+ ...(style.opacity === undefined ? {} : { opacity: style.opacity }),
+ });
+ return;
+ }
+
+ const center = sclPoint(visual.center, size);
+ const base = {
+ center,
+ backgroundColor: style.fill ?? "transparent",
+ borderColor: style.stroke ?? "transparent",
+ borderSize: (style.strokeWidth ?? 0) * cellSize,
+ ...(style.opacity === undefined ? {} : { opacity: style.opacity }),
+ };
+ if (visual.type === "rectangle") {
+ collection.push({
+ ...base,
+ width: visual.width,
+ height: visual.height,
+ rounded: false,
+ ...(visual.cornerRadius === undefined
+ ? {}
+ : { roundedRadius: visual.cornerRadius * cellSize }),
+ });
+ } else if (visual.type === "circle") {
+ collection.push({
+ ...base,
+ width: visual.radius * 2,
+ height: visual.radius * 2,
+ rounded: true,
+ });
+ } else {
+ collection.push({
+ ...base,
+ width: visual.radiusX * 2,
+ height: visual.radiusY * 2,
+ rounded: true,
+ });
+ }
+}
+
+/** Build uncompressed, standards-shaped SudokuPad/CTC SCL JSON data. */
+export function exportSudokuPadPuzzle(document: SudokuDocument): JsonRecord {
+ const normalized = normalizeSudokuDocument(document);
+ if ((normalized.globalRules?.length ?? 0) > 0) {
+ return fail(
+ "UNSUPPORTED_SUDOKUPAD",
+ "SudokuPad export cannot preserve free-form global rules safely.",
+ );
+ }
+ const negated = normalized.constraints.find(
+ (constraint) => "negated" in constraint && constraint.negated === true,
+ );
+ if (negated !== undefined) {
+ return fail(
+ "UNSUPPORTED_SUDOKUPAD",
+ "SudokuPad export cannot enforce individually false clues. Use Sudoku Tools JSON instead.",
+ );
+ }
+ const { size } = normalized;
+ const sourceId =
+ normalized.source?.format === "sudokupad"
+ ? normalized.source.id
+ : undefined;
+ const outputId = sourceId ?? normalized.id;
+ const cellSize =
+ typeof normalized.metadata?.cellSize === "number" &&
+ normalized.metadata.cellSize >= 16 &&
+ normalized.metadata.cellSize <= 256
+ ? normalized.metadata.cellSize
+ : 50;
+ const retainedMetadata = Object.fromEntries(
+ Object.entries(normalized.metadata ?? {}).filter(
+ ([key]) => !RESERVED_SEMANTIC_METADATA.has(key),
+ ),
+ );
+ const metadata: JsonRecord = {
+ ...retainedMetadata,
+ ...(normalized.title === undefined ? {} : { title: normalized.title }),
+ ...(normalized.author === undefined ? {} : { author: normalized.author }),
+ ...(normalized.rules === undefined
+ ? {}
+ : { rules: normalized.rules.join("\n") }),
+ ...(normalized.solution === undefined
+ ? {}
+ : { solution: normalized.solution.join(",") }),
+ [SUDOKU_TOOLS_CONSTRAINTS_METADATA]: JSON.stringify(normalized.constraints),
+ };
+ delete metadata.cellSize;
+ delete metadata["setting.conflictchecker"];
+ if (normalized.constraints.some(({ type }) => type === "anti-knight"))
+ metadata.antiknight = true;
+ if (normalized.constraints.some(({ type }) => type === "anti-king"))
+ metadata.antiking = true;
+ if (normalized.constraints.some(({ type }) => type === "non-consecutive"))
+ metadata.nonconsecutive = true;
+
+ const visuals: SclVisualCollections = {
+ lines: [],
+ underlays: [],
+ overlays: [],
+ };
+ const renderedVisuals = [
+ ...canonicalConstraintVisuals(normalized),
+ ...(normalized.visuals ?? []),
+ ];
+ for (const visual of renderedVisuals) {
+ appendSclVisual(visuals, visual, size, cellSize);
+ }
+
+ const cages = normalized.constraints
+ .filter(
+ (
+ constraint,
+ ): constraint is Extract<
+ PortableConstraint,
+ { readonly type: "killer-cage" }
+ > => constraint.type === "killer-cage",
+ )
+ .map((constraint) => ({
+ cells: constraint.cells.map((cell) => [
+ Math.floor(cell / size),
+ cell % size,
+ ]),
+ value: String(constraint.sum),
+ sum: constraint.sum,
+ unique: constraint.noRepeat !== false,
+ }));
+ const fogLights = normalized.constraints
+ .filter(
+ (
+ constraint,
+ ): constraint is Extract =>
+ constraint.type === "fog",
+ )
+ .flatMap((constraint) => constraint.lights)
+ .map((cell) => [Math.floor(cell / size), cell % size]);
+ const regions =
+ normalized.regions === undefined
+ ? undefined
+ : Array.from({ length: size }, (_, region) =>
+ normalized
+ .regions!.map((candidate, cell) =>
+ candidate === region
+ ? ([Math.floor(cell / size), cell % size] as const)
+ : undefined,
+ )
+ .filter(
+ (entry): entry is readonly [number, number] =>
+ entry !== undefined,
+ ),
+ );
+ const values = normalized.values ?? normalized.givens;
+ const output: JsonRecord = {
+ ...(outputId === undefined ? {} : { id: outputId }),
+ cellSize,
+ cells: Array.from({ length: size }, (_, row) =>
+ Array.from({ length: size }, (_unused, column) => {
+ const index = row * size + column;
+ const given = normalized.givens[index] ?? 0;
+ const value = given || values[index] || 0;
+ return {
+ ...(value === 0 ? {} : { value }),
+ ...(value === 0 ? {} : { given: given !== 0 }),
+ ...(normalized.cornerMarks?.[index]?.length
+ ? { pencilMarks: [...normalized.cornerMarks[index]!] }
+ : {}),
+ ...((normalized.centerMarks ?? normalized.candidates)?.[index]?.length
+ ? {
+ centremarks: [
+ ...(normalized.centerMarks ?? normalized.candidates)![index]!,
+ ],
+ }
+ : {}),
+ };
+ }),
+ ),
+ metadata,
+ settings: {
+ conflictchecker: normalized.metadata?.["setting.conflictchecker"] ?? true,
+ },
+ ...(normalized.elapsedMs === undefined
+ ? {}
+ : { duration: normalized.elapsedMs }),
+ ...(regions === undefined ? {} : { regions }),
+ ...(cages.length === 0 ? {} : { cages }),
+ ...(visuals.lines.length === 0 ? {} : { lines: visuals.lines }),
+ ...(visuals.underlays.length === 0 ? {} : { underlays: visuals.underlays }),
+ ...(visuals.overlays.length === 0 ? {} : { overlays: visuals.overlays }),
+ ...(fogLights.length === 0 ? {} : { foglight: fogLights }),
+ };
+ return output;
+}
+
+export function exportSudokuPadJson(
+ document: SudokuDocument,
+ pretty = false,
+): string {
+ const json = JSON.stringify(
+ exportSudokuPadPuzzle(document),
+ null,
+ pretty ? 2 : undefined,
+ );
+ if (new TextEncoder().encode(json).byteLength > MAX_DOCUMENT_BYTES) {
+ return fail("LIMIT_EXCEEDED", "The exported SudokuPad JSON is too large.");
+ }
+ return json;
+}
+
+/** Self-contained `scl…` payload accepted by SudokuPad without a request. */
+export function exportSudokuPadPayload(document: SudokuDocument): string {
+ const compressed = compressToBase64(exportSudokuPadJson(document));
+ const payload = `scl${encodeURIComponent(compressed)}`;
+ if (payload.length > MAX_SUDOKUPAD_PAYLOAD_LENGTH) {
+ return fail(
+ "LIMIT_EXCEEDED",
+ "The compressed SudokuPad payload is too large.",
+ );
+ }
+ return payload;
+}
diff --git a/src/formats/types.ts b/src/formats/types.ts
index 03404cc..14cc831 100644
--- a/src/formats/types.ts
+++ b/src/formats/types.ts
@@ -11,6 +11,89 @@ export const SUDOKU_DOCUMENT_VERSION = 1 as const;
export type CellIndex = number;
export type OutsideSide = "top" | "right" | "bottom" | "left";
+export type SudokuSourceFormat =
+ "sudoku-tools" | "plain-grid" | "fpuzzles" | "sudokupad" | "penpa";
+
+/** Provenance only; it is never dereferenced or fetched by Sudoku Tools. */
+export interface SudokuSourceIdentity {
+ readonly format: SudokuSourceFormat;
+ readonly id?: string;
+ readonly version?: string;
+}
+
+export type ScalarMetadataValue = string | number | boolean | null;
+export type ScalarMetadata = Readonly>;
+
+/**
+ * Grid-relative anchor used by inert visual primitives. Coordinates have their
+ * origin at the grid's top-left corner; one unit equals one cell.
+ */
+export type SafeVisualAnchor =
+ | {
+ readonly kind: "coordinate";
+ readonly x: number;
+ readonly y: number;
+ }
+ | {
+ readonly kind: "cell";
+ readonly cell: CellIndex;
+ readonly offsetX?: number;
+ readonly offsetY?: number;
+ };
+
+/** Only these inert presentation properties can cross an import boundary. */
+export interface SafeVisualStyle {
+ /** A normalized hexadecimal colour or `transparent`. */
+ readonly stroke?: string;
+ /** A normalized hexadecimal colour or `transparent`. */
+ readonly fill?: string;
+ /** Grid-relative width, not a CSS value. */
+ readonly strokeWidth?: number;
+ readonly opacity?: number;
+ /** Grid-relative size, not a CSS value. */
+ readonly fontSize?: number;
+}
+
+interface SafeVisualBase {
+ readonly layer: "underlay" | "overlay";
+ readonly style?: SafeVisualStyle;
+}
+
+export type SafeVisualPrimitive =
+ | (SafeVisualBase & {
+ readonly type: "line";
+ readonly start: SafeVisualAnchor;
+ readonly end: SafeVisualAnchor;
+ })
+ | (SafeVisualBase & {
+ readonly type: "polyline";
+ readonly points: readonly SafeVisualAnchor[];
+ readonly closed?: boolean;
+ })
+ | (SafeVisualBase & {
+ readonly type: "rectangle";
+ readonly center: SafeVisualAnchor;
+ readonly width: number;
+ readonly height: number;
+ readonly cornerRadius?: number;
+ })
+ | (SafeVisualBase & {
+ readonly type: "ellipse";
+ readonly center: SafeVisualAnchor;
+ readonly radiusX: number;
+ readonly radiusY: number;
+ })
+ | (SafeVisualBase & {
+ readonly type: "circle";
+ readonly center: SafeVisualAnchor;
+ readonly radius: number;
+ })
+ | (SafeVisualBase & {
+ readonly type: "text";
+ readonly position: SafeVisualAnchor;
+ readonly text: string;
+ });
+
export interface CluePolarity {
/** When true, the completed clue must be false rather than true. */
readonly negated?: boolean;
@@ -21,6 +104,7 @@ export type PortableConstraint =
| { readonly type: "anti-knight" }
| { readonly type: "anti-king" }
| { readonly type: "non-consecutive" }
+ | { readonly type: "disjoint-groups" }
| ({
readonly type: "killer-cage";
readonly cells: readonly CellIndex[];
@@ -71,6 +155,59 @@ export type PortableConstraint =
readonly digits: readonly number[];
} & CluePolarity)
| ({ readonly type: "maximum"; readonly cell: CellIndex } & CluePolarity)
+ | ({ readonly type: "minimum"; readonly cell: CellIndex } & CluePolarity)
+ | ({ readonly type: "odd"; readonly cell: CellIndex } & CluePolarity)
+ | ({ readonly type: "even"; readonly cell: CellIndex } & CluePolarity)
+ | ({
+ readonly type: "little-killer";
+ readonly side: OutsideSide;
+ readonly index: number;
+ readonly direction: "down-right" | "down-left" | "up-right" | "up-left";
+ readonly sum: number;
+ } & CluePolarity)
+ | ({
+ readonly type: "sandwich";
+ readonly side: OutsideSide;
+ readonly index: number;
+ readonly sum: number;
+ } & CluePolarity)
+ | ({
+ readonly type: "between-line";
+ readonly cells: readonly CellIndex[];
+ } & CluePolarity)
+ | ({
+ readonly type: "german-whisper";
+ readonly cells: readonly CellIndex[];
+ readonly minimumDifference?: number;
+ } & CluePolarity)
+ | ({
+ readonly type: "region-sum-line";
+ readonly cells: readonly CellIndex[];
+ } & CluePolarity)
+ | ({
+ readonly type: "clone";
+ readonly cells: readonly CellIndex[];
+ readonly cloneCells: readonly CellIndex[];
+ } & CluePolarity)
+ | {
+ readonly type: "extra-region";
+ readonly cells: readonly CellIndex[];
+ }
+ | ({
+ readonly type:
+ "modular-line" | "entropic-line" | "zipper-line" | "double-arrow";
+ readonly cells: readonly CellIndex[];
+ } & CluePolarity)
+ | ({
+ readonly type: "indexer";
+ readonly kind: "row" | "column" | "box";
+ readonly cell: CellIndex;
+ } & CluePolarity)
+ | {
+ readonly type: "fog";
+ readonly lights: readonly CellIndex[];
+ readonly revealRadius?: 0 | 1;
+ }
| ({
readonly type: "renban";
readonly cells: readonly CellIndex[];
@@ -107,6 +244,12 @@ export interface SudokuDocument {
/** Named boolean/global rules which cannot be represented by a local shape. */
readonly globalRules?: readonly string[];
readonly id?: string;
+ /** Inert, bounded drawings retained without accepting source SVG or HTML. */
+ readonly visuals?: readonly SafeVisualPrimitive[];
+ /** Original local interchange identity; never used as a network location. */
+ readonly source?: SudokuSourceIdentity;
+ /** Uninterpreted bounded scalar source metadata. */
+ readonly metadata?: ScalarMetadata;
}
/** Minimal structural type accepted by the domain adapter. */
@@ -149,24 +292,113 @@ export function toDomainPuzzle(document: SudokuDocument): DomainPuzzleShape {
};
}
-export function fromDomainPuzzle(puzzle: DomainPuzzleShape): SudokuDocument {
+export type PreservedSudokuDocumentExtras = Pick<
+ SudokuDocument,
+ "visuals" | "source" | "metadata"
+>;
+
+/**
+ * Copy the source-only portion of a document before adapting it to the domain
+ * puzzle model. An undefined result lets callers avoid carrying empty state.
+ */
+export function extractPreservedDocumentExtras(
+ document: SudokuDocument,
+): PreservedSudokuDocumentExtras | undefined {
+ if (
+ document.visuals === undefined &&
+ document.source === undefined &&
+ document.metadata === undefined
+ ) {
+ return undefined;
+ }
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
+ ...(document.visuals === undefined
? {}
- : { solution: [...puzzle.solution] }),
+ : { visuals: document.visuals.map(cloneVisualPrimitive) }),
+ ...(document.source === undefined
+ ? {}
+ : { source: { ...document.source } }),
+ ...(document.metadata === undefined
+ ? {}
+ : { metadata: { ...document.metadata } }),
};
}
+export function cloneVisualPrimitive(
+ primitive: SafeVisualPrimitive,
+): SafeVisualPrimitive {
+ const style =
+ primitive.style === undefined ? {} : { style: { ...primitive.style } };
+ switch (primitive.type) {
+ case "line":
+ return {
+ ...primitive,
+ start: { ...primitive.start },
+ end: { ...primitive.end },
+ ...style,
+ };
+ case "polyline":
+ return {
+ ...primitive,
+ points: primitive.points.map((point) => ({ ...point })),
+ ...style,
+ };
+ case "rectangle":
+ case "ellipse":
+ case "circle":
+ return { ...primitive, center: { ...primitive.center }, ...style };
+ case "text":
+ return { ...primitive, position: { ...primitive.position }, ...style };
+ }
+}
+
+/**
+ * Merge data which the domain model intentionally cannot carry. Callers keep
+ * ownership of the returned arrays and records.
+ */
+export function mergePreservedDocumentExtras(
+ document: SudokuDocument,
+ preserved?: Partial,
+): SudokuDocument {
+ if (preserved === undefined) return document;
+ return {
+ ...document,
+ ...(preserved.visuals === undefined
+ ? {}
+ : { visuals: preserved.visuals.map(cloneVisualPrimitive) }),
+ ...(preserved.source === undefined
+ ? {}
+ : { source: { ...preserved.source } }),
+ ...(preserved.metadata === undefined
+ ? {}
+ : { metadata: { ...preserved.metadata } }),
+ };
+}
+
+export function fromDomainPuzzle(
+ puzzle: DomainPuzzleShape,
+ preserved?: Partial,
+): SudokuDocument {
+ return mergePreservedDocumentExtras(
+ {
+ 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] }),
+ },
+ preserved,
+ );
+}
+
export function cloneConstraint(
constraint: PortableConstraint,
): PortableConstraint {
@@ -176,7 +408,23 @@ export function cloneConstraint(
case "thermo":
case "renban":
case "palindrome":
+ case "between-line":
+ case "german-whisper":
+ case "region-sum-line":
+ case "extra-region":
+ case "modular-line":
+ case "entropic-line":
+ case "zipper-line":
+ case "double-arrow":
return { ...constraint, cells: [...constraint.cells] };
+ case "fog":
+ return { ...constraint, lights: [...constraint.lights] };
+ case "clone":
+ return {
+ ...constraint,
+ cells: [...constraint.cells],
+ cloneCells: [...constraint.cloneCells],
+ };
case "quadruple":
return {
...constraint,
diff --git a/src/formats/visual.ts b/src/formats/visual.ts
index 744e889..6fb370e 100644
--- a/src/formats/visual.ts
+++ b/src/formats/visual.ts
@@ -1,3 +1,4 @@
+import { littleKillerCells } from "../domain/geometry";
import { normalizePuzzle } from "../domain/validation";
import type {
NormalizedPuzzle,
@@ -6,7 +7,12 @@ import type {
} from "../domain/types";
import { symbolFor } from "../state/session";
import { normalizeSudokuDocument, SudokuFormatError } from "./document";
-import { toDomainPuzzle, type SudokuDocument } from "./types";
+import {
+ toDomainPuzzle,
+ type SafeVisualAnchor,
+ type SafeVisualPrimitive,
+ type SudokuDocument,
+} from "./types";
export interface VisualExportOptions {
readonly includeProgress?: boolean;
@@ -76,6 +82,73 @@ function linePoints(
.join(" ");
}
+function safeVisualPoint(
+ size: number,
+ anchor: SafeVisualAnchor,
+ origin: BoardOrigin,
+) {
+ if (anchor.kind === "coordinate") {
+ return {
+ x: origin.x + anchor.x * CELL_SIZE,
+ y: origin.y + anchor.y * CELL_SIZE,
+ };
+ }
+ const center = point(size, anchor.cell, origin);
+ return {
+ x: center.x + (anchor.offsetX ?? 0) * CELL_SIZE,
+ y: center.y + (anchor.offsetY ?? 0) * CELL_SIZE,
+ };
+}
+
+function renderSafeVisual(
+ visual: SafeVisualPrimitive,
+ size: number,
+ origin: BoardOrigin,
+ index: number,
+): string {
+ const style = visual.style ?? {};
+ const attributes = [
+ `class="source-visual source-visual--${visual.type}"`,
+ `data-visual="${String(index)}"`,
+ `stroke="${escapeXml(style.stroke ?? "transparent")}"`,
+ `fill="${escapeXml(style.fill ?? "transparent")}"`,
+ ...(style.strokeWidth === undefined
+ ? []
+ : [`stroke-width="${number(style.strokeWidth * CELL_SIZE)}"`]),
+ ...(style.opacity === undefined
+ ? []
+ : [`opacity="${number(style.opacity)}"`]),
+ ].join(" ");
+ if (visual.type === "line") {
+ const start = safeVisualPoint(size, visual.start, origin);
+ const end = safeVisualPoint(size, visual.end, origin);
+ return ` `;
+ }
+ if (visual.type === "polyline") {
+ const points = visual.points
+ .map((anchor) => safeVisualPoint(size, anchor, origin))
+ .map(({ x, y }) => `${number(x)},${number(y)}`)
+ .join(" ");
+ return `<${visual.closed === true ? "polygon" : "polyline"} ${attributes} points="${points}"/>`;
+ }
+ if (visual.type === "rectangle") {
+ const center = safeVisualPoint(size, visual.center, origin);
+ const width = visual.width * CELL_SIZE;
+ const height = visual.height * CELL_SIZE;
+ return ` `;
+ }
+ if (visual.type === "ellipse") {
+ const center = safeVisualPoint(size, visual.center, origin);
+ return ` `;
+ }
+ if (visual.type === "circle") {
+ const center = safeVisualPoint(size, visual.center, origin);
+ return ` `;
+ }
+ const position = safeVisualPoint(size, visual.position, origin);
+ return `${escapeXml(visual.text)} `;
+}
+
function pathBoundary(
size: number,
cells: ReadonlySet,
@@ -141,6 +214,8 @@ function globalRuleLabels(puzzle: NormalizedPuzzle): string[] {
labels.push("Anti-king");
if (puzzle.constraints.some(({ type }) => type === "non-consecutive"))
labels.push("Non-consecutive");
+ if (puzzle.constraints.some(({ type }) => type === "disjoint-groups"))
+ labels.push("Disjoint groups");
return labels;
}
@@ -157,10 +232,41 @@ function renderConstraint(
if (
constraint.type === "anti-knight" ||
constraint.type === "anti-king" ||
- constraint.type === "non-consecutive"
+ constraint.type === "non-consecutive" ||
+ constraint.type === "disjoint-groups"
) {
return "";
}
+ if (
+ constraint.type === "modular-line" ||
+ constraint.type === "entropic-line"
+ ) {
+ return ` `;
+ }
+ if (constraint.type === "zipper-line") {
+ const center = point(
+ size,
+ constraint.cells[Math.floor(constraint.cells.length / 2)]!,
+ origin,
+ );
+ return ` `;
+ }
+ if (constraint.type === "double-arrow") {
+ const first = point(size, constraint.cells[0]!, origin);
+ const last = point(size, constraint.cells.at(-1)!, origin);
+ return ` `;
+ }
+ if (constraint.type === "indexer") {
+ const center = point(size, constraint.cell, origin);
+ const label =
+ constraint.kind === "row"
+ ? "R"
+ : constraint.kind === "column"
+ ? "C"
+ : "B";
+ return `${marker}${label} `;
+ }
+ if (constraint.type === "fog") return "";
if (constraint.type === "diagonal") {
const startX =
origin.x + (constraint.direction === "main" ? 0 : size * CELL_SIZE);
@@ -173,6 +279,9 @@ function renderConstraint(
const label = point(size, first, origin);
return `${marker}${String(constraint.sum)} `;
}
+ if (constraint.type === "extra-region") {
+ return ``;
+ }
if (constraint.type === "thermo") {
const bulb = point(size, constraint.cells[0]!, origin);
return ` ${negated ? `≠ ` : ""} `;
@@ -188,7 +297,12 @@ function renderConstraint(
const maximumY = Math.max(...bulbPoints.map(({ y }) => y));
return ` ${negated ? `≠ ` : ""} `;
}
- if (constraint.type === "renban" || constraint.type === "palindrome") {
+ if (
+ constraint.type === "renban" ||
+ constraint.type === "palindrome" ||
+ constraint.type === "german-whisper" ||
+ constraint.type === "region-sum-line"
+ ) {
return ` ${
constraint.type === "palindrome"
? constraint.cells
@@ -200,12 +314,43 @@ function renderConstraint(
: ""
} `;
}
+ if (constraint.type === "between-line") {
+ const first = point(size, constraint.cells[0]!, origin);
+ const last = point(size, constraint.cells.at(-1)!, origin);
+ return ` ${negated ? `≠ ` : ""} `;
+ }
+ if (constraint.type === "clone") {
+ const original = new Set(constraint.cells);
+ const clone = new Set(constraint.cloneCells);
+ const connectors = constraint.cells
+ .map((cell, pairIndex) => {
+ const a = point(size, cell, origin);
+ const b = point(size, constraint.cloneCells[pairIndex]!, origin);
+ return ` `;
+ })
+ .join("");
+ return `${connectors} ${negated ? `≠ ` : ""} `;
+ }
if (constraint.type === "maximum") {
const center = point(size, constraint.cell, origin);
const offset = CELL_SIZE * 0.28;
const inner = CELL_SIZE * 0.14;
return ` `;
}
+ if (constraint.type === "minimum") {
+ const center = point(size, constraint.cell, origin);
+ const offset = CELL_SIZE * 0.28;
+ const inner = CELL_SIZE * 0.14;
+ return ` ${negated ? `≠ ` : ""} `;
+ }
+ if (constraint.type === "odd" || constraint.type === "even") {
+ const center = point(size, constraint.cell, origin);
+ const shape =
+ constraint.type === "odd"
+ ? ` `
+ : ` `;
+ return `${shape}${negated ? `≠ ` : ""} `;
+ }
if (constraint.type === "quadruple") {
const positions = constraint.cells.map((cell) => point(size, cell, origin));
const center = {
@@ -218,7 +363,11 @@ function renderConstraint(
};
return `${marker}${escapeXml(constraint.digits.map((digit) => symbolFor(digit, size)).join(""))} `;
}
- if (constraint.type === "x-sum" || constraint.type === "skyscraper") {
+ if (
+ constraint.type === "x-sum" ||
+ constraint.type === "skyscraper" ||
+ constraint.type === "sandwich"
+ ) {
const position = outsidePoint(
size,
constraint.side,
@@ -226,9 +375,10 @@ function renderConstraint(
origin,
);
const value =
- constraint.type === "x-sum" ? constraint.sum : constraint.count;
+ constraint.type === "skyscraper" ? constraint.count : constraint.sum;
const companionIndex = puzzle.constraints.findIndex(
(candidate) =>
+ constraint.type !== "sandwich" &&
candidate.type !== constraint.type &&
(candidate.type === "x-sum" || candidate.type === "skyscraper") &&
candidate.side === constraint.side &&
@@ -242,7 +392,36 @@ function renderConstraint(
if (combined) {
return `Σ · ▥ ${marker}${String(value)} `;
}
- return `${constraint.type === "x-sum" ? "Σ" : "▥"}${marker}${String(value)} `;
+ const symbol =
+ constraint.type === "x-sum"
+ ? "Σ"
+ : constraint.type === "skyscraper"
+ ? "▥"
+ : "1⋯N ";
+ return `${symbol}${marker}${String(value)} `;
+ }
+ if (constraint.type === "little-killer") {
+ const position = outsidePoint(
+ size,
+ constraint.side,
+ constraint.index,
+ origin,
+ );
+ const cells = littleKillerCells(
+ size,
+ constraint.side,
+ constraint.index,
+ constraint.direction,
+ );
+ const first = point(size, cells[0]!, origin);
+ return `${marker}${String(constraint.sum)} `;
+ }
+ if (
+ constraint.type !== "kropki" &&
+ constraint.type !== "xv" &&
+ constraint.type !== "inequality"
+ ) {
+ return "";
}
const a = point(
size,
@@ -329,7 +508,11 @@ export function renderPuzzleSvg(
const includeProgress = options.includeProgress ?? true;
const includeNotes = options.includeNotes ?? includeProgress;
const hasOutside = puzzle.constraints.some(
- ({ type }) => type === "x-sum" || type === "skyscraper",
+ ({ type }) =>
+ type === "x-sum" ||
+ type === "skyscraper" ||
+ type === "little-killer" ||
+ type === "sandwich",
);
const sideMargin = hasOutside ? OUTSIDE_MARGIN : BOARD_MARGIN;
const boardSize = puzzle.size * CELL_SIZE;
@@ -384,6 +567,20 @@ export function renderPuzzleSvg(
renderConstraint(constraint, index, puzzle, origin),
)
.join("");
+ const sourceUnderlays = (normalizedDocument.visuals ?? [])
+ .map((visual, index) => ({ visual, index }))
+ .filter(({ visual }) => visual.layer === "underlay")
+ .map(({ visual, index }) =>
+ renderSafeVisual(visual, puzzle.size, origin, index),
+ )
+ .join("");
+ const sourceOverlays = (normalizedDocument.visuals ?? [])
+ .map((visual, index) => ({ visual, index }))
+ .filter(({ visual }) => visual.layer === "overlay")
+ .map(({ visual, index }) =>
+ renderSafeVisual(visual, puzzle.size, origin, index),
+ )
+ .join("");
const digits = Array.from({ length: cellCount }, (_, cell) => {
const value = puzzle.givens[cell] || values[cell] || 0;
const center = point(puzzle.size, cell, origin);
@@ -425,11 +622,11 @@ export function renderPuzzleSvg(
${escapeXml(title)}
${escapeXml(`${String(puzzle.size)} by ${String(puzzle.size)} Sudoku${includeProgress ? " with current progress" : ""}`)}
${escapeXml(title)} ${escapeXml(byline)} ${globals.length > 0 ? `${escapeXml(globals.join(" · "))} ` : ""}
- ${backgrounds}${gridLines} ${constraints}${renderRegionBoundaries(puzzle, origin)}${digits}
+ ${backgrounds}${sourceUnderlays}${gridLines} ${constraints}${renderRegionBoundaries(puzzle, origin)}${digits}${sourceOverlays}
`;
if (new TextEncoder().encode(svg).byteLength > MAX_VISUAL_EXPORT_BYTES) {
throw new SudokuFormatError(
diff --git a/src/helpers/candidates.ts b/src/helpers/candidates.ts
index 265366f..cd05064 100644
--- a/src/helpers/candidates.ts
+++ b/src/helpers/candidates.ts
@@ -139,6 +139,10 @@ export function houseLabel(unit: Pick): string {
return `Region ${String(unit.index + 1)}`;
case "diagonal":
return unit.index === 0 ? "Main diagonal" : "Anti-diagonal";
+ case "disjoint-group":
+ return `Disjoint group ${String(unit.index + 1)}`;
+ case "extra-region":
+ return `Extra region ${String(unit.index + 1)}`;
}
}
diff --git a/src/main.tsx b/src/main.tsx
index 1c711a7..058daae 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -7,3 +7,17 @@ createRoot(document.getElementById("root")!).render(
,
);
+
+if ("serviceWorker" in navigator && import.meta.env.PROD) {
+ window.addEventListener("load", () => {
+ const url = new URL("./sw.js", document.baseURI);
+ void navigator.serviceWorker
+ .register(url, {
+ scope: new URL("./", document.baseURI).pathname,
+ })
+ .catch(() => {
+ // Offline support is progressive enhancement; the workbench remains
+ // fully usable when a host or private browsing mode blocks workers.
+ });
+ });
+}
diff --git a/src/solver/advancedLogical.ts b/src/solver/advancedLogical.ts
new file mode 100644
index 0000000..b078456
--- /dev/null
+++ b/src/solver/advancedLogical.ts
@@ -0,0 +1,954 @@
+import type { CellId, SudokuUnit } from "../domain";
+import type {
+ LogicalElimination,
+ LogicalStep,
+ LogicalTechnique,
+} from "./logical";
+
+export interface AdvancedLogicalContext {
+ readonly size: number;
+ readonly values: readonly number[];
+ /** Candidate masks use bit `1 << digit`, matching the logical solver. */
+ readonly masks: readonly number[];
+ readonly regions: readonly number[];
+ readonly peers: readonly ReadonlySet[];
+ readonly units: readonly SudokuUnit[];
+ /** Must come from a completed uniqueness proof; false/omitted is the default. */
+ readonly uniquenessProven?: boolean;
+ /** Extra constraints can invalidate uniqueness-pattern swap arguments. */
+ readonly uniquenessPatternsSafe?: boolean;
+}
+
+const MAX_CHAIN_NODES = 10;
+const MAX_CHAIN_VISITS = 200_000;
+
+function digitBit(value: number): number {
+ return 1 << value;
+}
+
+function popcount(mask: number): number {
+ let value = mask >>> 0;
+ let count = 0;
+ while (value !== 0) {
+ value &= value - 1;
+ count += 1;
+ }
+ return count;
+}
+
+function maskDigits(mask: number, size: number): number[] {
+ const result: number[] = [];
+ for (let value = 1; value <= size; value += 1) {
+ if ((mask & digitBit(value)) !== 0) result.push(value);
+ }
+ return result;
+}
+
+function hasCandidate(
+ context: AdvancedLogicalContext,
+ cell: CellId,
+ value: number,
+): boolean {
+ return (
+ context.values[cell] === 0 &&
+ ((context.masks[cell] ?? 0) & digitBit(value)) !== 0
+ );
+}
+
+function combinations(values: readonly T[], count: number): T[][] {
+ const result: T[][] = [];
+ const selected: T[] = [];
+ const visit = (start: number): void => {
+ if (selected.length === count) {
+ result.push([...selected]);
+ return;
+ }
+ for (
+ let index = start;
+ index <= values.length - (count - selected.length);
+ index += 1
+ ) {
+ const value = values[index];
+ if (value === undefined) continue;
+ selected.push(value);
+ visit(index + 1);
+ selected.pop();
+ }
+ };
+ visit(0);
+ return result;
+}
+
+function uniqueSorted(values: Iterable): number[] {
+ return [...new Set(values)].sort((a, b) => a - b);
+}
+
+function step(
+ technique: LogicalTechnique,
+ eliminations: readonly LogicalElimination[],
+ focusCells: Iterable,
+ explanation: string,
+): LogicalStep | undefined {
+ if (eliminations.length === 0) return undefined;
+ const byCell = new Map>();
+ for (const elimination of eliminations) {
+ const values = byCell.get(elimination.cell) ?? new Set();
+ for (const value of elimination.values) values.add(value);
+ byCell.set(elimination.cell, values);
+ }
+ return {
+ technique,
+ placements: [],
+ eliminations: [...byCell]
+ .sort(([a], [b]) => a - b)
+ .map(([cell, values]) => ({
+ cell,
+ values: [...values].sort((a, b) => a - b),
+ })),
+ focusCells: uniqueSorted(focusCells),
+ explanation,
+ };
+}
+
+function unitCandidates(
+ context: AdvancedLogicalContext,
+ unit: SudokuUnit,
+ value: number,
+): number[] {
+ return unit.cells.filter((cell) => hasCandidate(context, cell, value));
+}
+
+function lineUnits(
+ context: AdvancedLogicalContext,
+ kind: "row" | "column",
+): SudokuUnit[] {
+ return context.units
+ .filter((unit) => unit.kind === kind)
+ .sort((a, b) => a.index - b.index);
+}
+
+function baseIndex(
+ context: AdvancedLogicalContext,
+ kind: "row" | "column",
+ cell: CellId,
+): number {
+ return kind === "row" ? Math.floor(cell / context.size) : cell % context.size;
+}
+
+function coverIndex(
+ context: AdvancedLogicalContext,
+ kind: "row" | "column",
+ cell: CellId,
+): number {
+ return kind === "row" ? cell % context.size : Math.floor(cell / context.size);
+}
+
+function commonPeers(
+ context: AdvancedLogicalContext,
+ cells: readonly CellId[],
+): Set {
+ const first = cells[0];
+ if (first === undefined) return new Set();
+ const result = new Set(context.peers[first] ?? []);
+ for (const cell of cells.slice(1)) {
+ for (const candidate of result) {
+ if (!(context.peers[cell]?.has(candidate) ?? false)) {
+ result.delete(candidate);
+ }
+ }
+ }
+ for (const cell of cells) result.delete(cell);
+ return result;
+}
+
+function commonPeerEliminations(
+ context: AdvancedLogicalContext,
+ endpoints: readonly CellId[],
+ value: number,
+ excluded: ReadonlySet = new Set(),
+): LogicalElimination[] {
+ return [...commonPeers(context, endpoints)]
+ .filter((cell) => !excluded.has(cell) && hasCandidate(context, cell, value))
+ .sort((a, b) => a - b)
+ .map((cell) => ({ cell, values: [value] }));
+}
+
+export function findJellyfish(
+ context: AdvancedLogicalContext,
+): LogicalStep | undefined {
+ const count = 4;
+ for (const baseKind of ["row", "column"] as const) {
+ const bases = lineUnits(context, baseKind);
+ for (let value = 1; value <= context.size; value += 1) {
+ const eligible = bases.filter((unit) => {
+ const total = unitCandidates(context, unit, value).length;
+ return total >= 2 && total <= count;
+ });
+ for (const selected of combinations(eligible, count)) {
+ const covers = new Set();
+ const focus: number[] = [];
+ for (const unit of selected) {
+ for (const cell of unitCandidates(context, unit, value)) {
+ covers.add(coverIndex(context, baseKind, cell));
+ focus.push(cell);
+ }
+ }
+ if (covers.size !== count) continue;
+ const selectedBases = new Set(selected.map((unit) => unit.index));
+ const eliminations: LogicalElimination[] = [];
+ for (let cell = 0; cell < context.values.length; cell += 1) {
+ if (
+ !selectedBases.has(baseIndex(context, baseKind, cell)) &&
+ covers.has(coverIndex(context, baseKind, cell)) &&
+ hasCandidate(context, cell, value)
+ ) {
+ eliminations.push({ cell, values: [value] });
+ }
+ }
+ const found = step(
+ "jellyfish",
+ eliminations,
+ focus,
+ `${value} is confined to four cover lines across four ${baseKind}s, forming a Jellyfish.`,
+ );
+ if (found !== undefined) return found;
+ }
+ }
+ }
+ return undefined;
+}
+
+function findFinnedFishOfSize(
+ context: AdvancedLogicalContext,
+ count: 2 | 3,
+): LogicalStep | undefined {
+ for (const baseKind of ["row", "column"] as const) {
+ const bases = lineUnits(context, baseKind);
+ for (let value = 1; value <= context.size; value += 1) {
+ const eligible = bases.filter((unit) => {
+ const total = unitCandidates(context, unit, value).length;
+ return total >= 2 && total <= count + 2;
+ });
+ for (const selected of combinations(eligible, count)) {
+ const allCovers = uniqueSorted(
+ selected.flatMap((unit) =>
+ unitCandidates(context, unit, value).map((cell) =>
+ coverIndex(context, baseKind, cell),
+ ),
+ ),
+ );
+ // One or two extra cover lines provide ordinary fins without allowing
+ // an unbounded cover-subset search on large grids.
+ if (allCovers.length < count + 1 || allCovers.length > count + 2) {
+ continue;
+ }
+ for (const covers of combinations(allCovers, count)) {
+ const coverSet = new Set(covers);
+ for (const finBase of selected) {
+ let compatible = true;
+ const bodyCells: number[] = [];
+ const finCells: number[] = [];
+ for (const unit of selected) {
+ for (const cell of unitCandidates(context, unit, value)) {
+ if (coverSet.has(coverIndex(context, baseKind, cell))) {
+ bodyCells.push(cell);
+ } else if (unit.index === finBase.index) {
+ finCells.push(cell);
+ } else {
+ compatible = false;
+ }
+ }
+ }
+ if (!compatible || finCells.length === 0) continue;
+ if (
+ selected.some(
+ (unit) =>
+ unitCandidates(context, unit, value).filter((cell) =>
+ coverSet.has(coverIndex(context, baseKind, cell)),
+ ).length === 0,
+ )
+ ) {
+ continue;
+ }
+ const usedBodyCovers = new Set(
+ bodyCells.map((cell) => coverIndex(context, baseKind, cell)),
+ );
+ if (usedBodyCovers.size !== count) continue;
+ const finRegion = context.regions[finCells[0] as number];
+ if (
+ finRegion === undefined ||
+ finCells.some((cell) => context.regions[cell] !== finRegion)
+ ) {
+ continue;
+ }
+ const baseSet = new Set(selected.map((unit) => unit.index));
+ const finPeers = commonPeers(context, finCells);
+ const eliminations: LogicalElimination[] = [];
+ for (let cell = 0; cell < context.values.length; cell += 1) {
+ if (
+ !baseSet.has(baseIndex(context, baseKind, cell)) &&
+ coverSet.has(coverIndex(context, baseKind, cell)) &&
+ finPeers.has(cell) &&
+ hasCandidate(context, cell, value)
+ ) {
+ eliminations.push({ cell, values: [value] });
+ }
+ }
+ const technique: LogicalTechnique =
+ count === 2 ? "finned-x-wing" : "finned-swordfish";
+ const found = step(
+ technique,
+ eliminations,
+ [...bodyCells, ...finCells],
+ `${value} forms a ${count === 2 ? "Finned X-Wing" : "Finned Swordfish"}; the fin and fish body both eliminate it in their shared region.`,
+ );
+ if (found !== undefined) return found;
+ }
+ }
+ }
+ }
+ }
+ return undefined;
+}
+
+export function findFinnedFish(
+ context: AdvancedLogicalContext,
+): LogicalStep | undefined {
+ return findFinnedFishOfSize(context, 2) ?? findFinnedFishOfSize(context, 3);
+}
+
+export function findSkyscraper(
+ context: AdvancedLogicalContext,
+): LogicalStep | undefined {
+ for (const baseKind of ["row", "column"] as const) {
+ const bases = lineUnits(context, baseKind);
+ for (let value = 1; value <= context.size; value += 1) {
+ const strongLines = bases
+ .map((unit) => ({ unit, cells: unitCandidates(context, unit, value) }))
+ .filter(({ cells }) => cells.length === 2);
+ for (const pair of combinations(strongLines, 2)) {
+ const first = pair[0];
+ const second = pair[1];
+ if (first === undefined || second === undefined) continue;
+ for (let firstBase = 0; firstBase < 2; firstBase += 1) {
+ for (let secondBase = 0; secondBase < 2; secondBase += 1) {
+ const aBase = first.cells[firstBase];
+ const bBase = second.cells[secondBase];
+ const aRoof = first.cells[1 - firstBase];
+ const bRoof = second.cells[1 - secondBase];
+ if (
+ aBase === undefined ||
+ bBase === undefined ||
+ aRoof === undefined ||
+ bRoof === undefined ||
+ coverIndex(context, baseKind, aBase) !==
+ coverIndex(context, baseKind, bBase)
+ ) {
+ continue;
+ }
+ const pattern = new Set([aBase, bBase, aRoof, bRoof]);
+ if (pattern.size !== 4) continue;
+ const found = step(
+ "skyscraper",
+ commonPeerEliminations(context, [aRoof, bRoof], value, pattern),
+ pattern,
+ `${value} forms a Skyscraper: at least one of the two roof cells must contain it.`,
+ );
+ if (found !== undefined) return found;
+ }
+ }
+ }
+ }
+ }
+ return undefined;
+}
+
+export function findTwoStringKite(
+ context: AdvancedLogicalContext,
+): LogicalStep | undefined {
+ const rows = lineUnits(context, "row");
+ const columns = lineUnits(context, "column");
+ for (let value = 1; value <= context.size; value += 1) {
+ const rowLinks = rows
+ .map((unit) => ({ unit, cells: unitCandidates(context, unit, value) }))
+ .filter(({ cells }) => cells.length === 2);
+ const columnLinks = columns
+ .map((unit) => ({ unit, cells: unitCandidates(context, unit, value) }))
+ .filter(({ cells }) => cells.length === 2);
+ for (const row of rowLinks) {
+ for (const column of columnLinks) {
+ for (let rowBaseIndex = 0; rowBaseIndex < 2; rowBaseIndex += 1) {
+ for (
+ let columnBaseIndex = 0;
+ columnBaseIndex < 2;
+ columnBaseIndex += 1
+ ) {
+ const rowBase = row.cells[rowBaseIndex];
+ const columnBase = column.cells[columnBaseIndex];
+ const rowRoof = row.cells[1 - rowBaseIndex];
+ const columnRoof = column.cells[1 - columnBaseIndex];
+ if (
+ rowBase === undefined ||
+ columnBase === undefined ||
+ rowRoof === undefined ||
+ columnRoof === undefined
+ ) {
+ continue;
+ }
+ const pattern = new Set([rowBase, columnBase, rowRoof, columnRoof]);
+ if (
+ pattern.size !== 4 ||
+ context.regions[rowBase] !== context.regions[columnBase]
+ ) {
+ continue;
+ }
+ const found = step(
+ "two-string-kite",
+ commonPeerEliminations(
+ context,
+ [rowRoof, columnRoof],
+ value,
+ pattern,
+ ),
+ pattern,
+ `${value} forms a Two-String Kite through conjugate row and column links.`,
+ );
+ if (found !== undefined) return found;
+ }
+ }
+ }
+ }
+ }
+ return undefined;
+}
+
+interface CellStrongLink {
+ readonly a: CellId;
+ readonly b: CellId;
+ readonly value: number;
+}
+
+function cellStrongLinks(
+ context: AdvancedLogicalContext,
+ requestedValue?: number,
+): CellStrongLink[] {
+ const links = new Map();
+ const first = requestedValue ?? 1;
+ const last = requestedValue ?? context.size;
+ for (let value = first; value <= last; value += 1) {
+ for (const unit of context.units) {
+ const cells = unitCandidates(context, unit, value);
+ if (cells.length !== 2) continue;
+ const a = Math.min(cells[0] as number, cells[1] as number);
+ const b = Math.max(cells[0] as number, cells[1] as number);
+ links.set(`${value}:${a}:${b}`, { a, b, value });
+ }
+ }
+ return [...links.values()].sort(
+ (a, b) => a.value - b.value || a.a - b.a || a.b - b.b,
+ );
+}
+
+function strongCellAdjacency(
+ context: AdvancedLogicalContext,
+ value: number,
+): readonly ReadonlySet[] {
+ const adjacency = Array.from(
+ { length: context.values.length },
+ () => new Set(),
+ );
+ for (const link of cellStrongLinks(context, value)) {
+ adjacency[link.a]?.add(link.b);
+ adjacency[link.b]?.add(link.a);
+ }
+ return adjacency;
+}
+
+export function findSimpleColouring(
+ context: AdvancedLogicalContext,
+): LogicalStep | undefined {
+ for (let value = 1; value <= context.size; value += 1) {
+ const adjacency = strongCellAdjacency(context, value);
+ const visited = new Set();
+ for (let start = 0; start < context.values.length; start += 1) {
+ if (visited.has(start) || (adjacency[start]?.size ?? 0) === 0) continue;
+ const colours = new Map([[start, 0]]);
+ const queue = [start];
+ let bipartite = true;
+ while (queue.length > 0) {
+ const cell = queue.shift() as number;
+ visited.add(cell);
+ const colour = colours.get(cell) as 0 | 1;
+ const neighbours = [...(adjacency[cell] ?? [])].sort((a, b) => a - b);
+ for (const neighbour of neighbours) {
+ const expected = colour === 0 ? 1 : 0;
+ const existing = colours.get(neighbour);
+ if (existing === undefined) {
+ colours.set(neighbour, expected);
+ queue.push(neighbour);
+ } else if (existing !== expected) {
+ bipartite = false;
+ }
+ }
+ }
+ if (!bipartite) continue;
+ const component = uniqueSorted(colours.keys());
+ const componentSet = new Set(component);
+ for (const colour of [0, 1] as const) {
+ const cells = component.filter((cell) => colours.get(cell) === colour);
+ const conflict = cells.some((cell, index) =>
+ cells
+ .slice(index + 1)
+ .some((other) => context.peers[cell]?.has(other) ?? false),
+ );
+ if (!conflict) continue;
+ const found = step(
+ "simple-colouring",
+ cells.map((cell) => ({ cell, values: [value] })),
+ component,
+ `Two ${value} candidates with the same colour see each other, so that colour is false.`,
+ );
+ if (found !== undefined) return found;
+ }
+
+ const colourZero = component.filter((cell) => colours.get(cell) === 0);
+ const colourOne = component.filter((cell) => colours.get(cell) === 1);
+ const eliminations: LogicalElimination[] = [];
+ for (let cell = 0; cell < context.values.length; cell += 1) {
+ if (!hasCandidate(context, cell, value) || componentSet.has(cell)) {
+ continue;
+ }
+ if (
+ colourZero.some(
+ (coloured) => context.peers[cell]?.has(coloured) ?? false,
+ ) &&
+ colourOne.some(
+ (coloured) => context.peers[cell]?.has(coloured) ?? false,
+ )
+ ) {
+ eliminations.push({ cell, values: [value] });
+ }
+ }
+ const found = step(
+ "simple-colouring",
+ eliminations,
+ component,
+ `This ${value} candidate sees both colours of one conjugate chain.`,
+ );
+ if (found !== undefined) return found;
+ }
+ }
+ return undefined;
+}
+
+export function findWWing(
+ context: AdvancedLogicalContext,
+): LogicalStep | undefined {
+ const bivalue = context.masks
+ .map((mask, cell) => ({ cell, mask }))
+ .filter(
+ ({ cell, mask }) => context.values[cell] === 0 && popcount(mask) === 2,
+ );
+ const links = cellStrongLinks(context);
+ for (const pair of combinations(bivalue, 2)) {
+ const first = pair[0];
+ const second = pair[1];
+ if (
+ first === undefined ||
+ second === undefined ||
+ first.mask !== second.mask ||
+ (context.peers[first.cell]?.has(second.cell) ?? false)
+ ) {
+ continue;
+ }
+ for (const linkValue of maskDigits(first.mask, context.size)) {
+ const otherValue = maskDigits(
+ first.mask & ~digitBit(linkValue),
+ context.size,
+ )[0];
+ if (otherValue === undefined) continue;
+ for (const link of links) {
+ if (link.value !== linkValue) continue;
+ if (new Set([first.cell, second.cell, link.a, link.b]).size !== 4) {
+ continue;
+ }
+ const connected =
+ ((context.peers[first.cell]?.has(link.a) ?? false) &&
+ (context.peers[second.cell]?.has(link.b) ?? false)) ||
+ ((context.peers[first.cell]?.has(link.b) ?? false) &&
+ (context.peers[second.cell]?.has(link.a) ?? false));
+ if (!connected) continue;
+ const pattern = new Set([first.cell, second.cell, link.a, link.b]);
+ const found = step(
+ "w-wing",
+ commonPeerEliminations(
+ context,
+ [first.cell, second.cell],
+ otherValue,
+ pattern,
+ ),
+ pattern,
+ `The ${linkValue} conjugate link joins two ${linkValue}/${otherValue} cells, so one wing must contain ${otherValue}.`,
+ );
+ if (found !== undefined) return found;
+ }
+ }
+ }
+ return undefined;
+}
+
+export function findXChain(
+ context: AdvancedLogicalContext,
+): LogicalStep | undefined {
+ for (let value = 1; value <= context.size; value += 1) {
+ const strong = strongCellAdjacency(context, value);
+ const candidates = Array.from(
+ { length: context.values.length },
+ (_unused, cell) => cell,
+ ).filter((cell) => hasCandidate(context, cell, value));
+ let visits = 0;
+ for (const start of candidates) {
+ const path = [start];
+ const inPath = new Set(path);
+ const search = (nextStrong: boolean): LogicalStep | undefined => {
+ if (visits >= MAX_CHAIN_VISITS) return undefined;
+ visits += 1;
+ const current = path[path.length - 1] as number;
+ const edges = path.length - 1;
+ if (!nextStrong && edges >= 3) {
+ const found = step(
+ "x-chain",
+ commonPeerEliminations(context, [start, current], value, inPath),
+ path,
+ `An alternating strong/weak X-Chain proves that at least one endpoint is ${value}.`,
+ );
+ if (found !== undefined) return found;
+ }
+ if (path.length >= MAX_CHAIN_NODES) return undefined;
+ const neighbours = nextStrong
+ ? [...(strong[current] ?? [])]
+ : [...(context.peers[current] ?? [])].filter((cell) =>
+ hasCandidate(context, cell, value),
+ );
+ neighbours.sort((a, b) => a - b);
+ for (const neighbour of neighbours) {
+ if (inPath.has(neighbour)) continue;
+ path.push(neighbour);
+ inPath.add(neighbour);
+ const found = search(!nextStrong);
+ if (found !== undefined) return found;
+ inPath.delete(neighbour);
+ path.pop();
+ }
+ return undefined;
+ };
+ const found = search(true);
+ if (found !== undefined) return found;
+ if (visits >= MAX_CHAIN_VISITS) return undefined;
+ }
+ }
+ return undefined;
+}
+
+export function findXYChain(
+ context: AdvancedLogicalContext,
+): LogicalStep | undefined {
+ const bivalue = context.masks
+ .map((mask, cell) => ({ cell, mask }))
+ .filter(
+ ({ cell, mask }) => context.values[cell] === 0 && popcount(mask) === 2,
+ );
+ const byCell = new Map(bivalue.map((entry) => [entry.cell, entry.mask]));
+ let visits = 0;
+ for (const start of bivalue) {
+ for (const target of maskDigits(start.mask, context.size)) {
+ const initialLink = maskDigits(
+ start.mask & ~digitBit(target),
+ context.size,
+ )[0];
+ if (initialLink === undefined) continue;
+ const path = [start.cell];
+ const inPath = new Set(path);
+ const search = (linkValue: number): LogicalStep | undefined => {
+ if (visits >= MAX_CHAIN_VISITS) return undefined;
+ visits += 1;
+ if (path.length >= MAX_CHAIN_NODES) return undefined;
+ const current = path[path.length - 1] as number;
+ const neighbours = [...(context.peers[current] ?? [])]
+ .filter(
+ (cell) =>
+ !inPath.has(cell) &&
+ ((byCell.get(cell) ?? 0) & digitBit(linkValue)) !== 0,
+ )
+ .sort((a, b) => a - b);
+ for (const neighbour of neighbours) {
+ const mask = byCell.get(neighbour) as number;
+ const outgoing = maskDigits(
+ mask & ~digitBit(linkValue),
+ context.size,
+ )[0];
+ if (outgoing === undefined) continue;
+ path.push(neighbour);
+ inPath.add(neighbour);
+ if (outgoing === target && path.length >= 3) {
+ const found = step(
+ "xy-chain",
+ commonPeerEliminations(
+ context,
+ [start.cell, neighbour],
+ target,
+ inPath,
+ ),
+ path,
+ `This XY-Chain forces ${target} into at least one endpoint.`,
+ );
+ if (found !== undefined) return found;
+ }
+ const found = search(outgoing);
+ if (found !== undefined) return found;
+ inPath.delete(neighbour);
+ path.pop();
+ }
+ return undefined;
+ };
+ const found = search(initialLink);
+ if (found !== undefined) return found;
+ if (visits >= MAX_CHAIN_VISITS) return undefined;
+ }
+ }
+ return undefined;
+}
+
+interface CandidateNode {
+ readonly cell: CellId;
+ readonly value: number;
+}
+
+function nodeId(context: AdvancedLogicalContext, node: CandidateNode): number {
+ return node.cell * (context.size + 1) + node.value;
+}
+
+function nodeFromId(
+ context: AdvancedLogicalContext,
+ id: number,
+): CandidateNode {
+ return {
+ cell: Math.floor(id / (context.size + 1)),
+ value: id % (context.size + 1),
+ };
+}
+
+function aicStrongAdjacency(
+ context: AdvancedLogicalContext,
+): ReadonlyMap> {
+ const adjacency = new Map>();
+ const add = (a: CandidateNode, b: CandidateNode): void => {
+ const aId = nodeId(context, a);
+ const bId = nodeId(context, b);
+ const aLinks = adjacency.get(aId) ?? new Set();
+ const bLinks = adjacency.get(bId) ?? new Set();
+ aLinks.add(bId);
+ bLinks.add(aId);
+ adjacency.set(aId, aLinks);
+ adjacency.set(bId, bLinks);
+ };
+ for (const link of cellStrongLinks(context)) {
+ add(
+ { cell: link.a, value: link.value },
+ { cell: link.b, value: link.value },
+ );
+ }
+ for (let cell = 0; cell < context.values.length; cell += 1) {
+ const values = maskDigits(context.masks[cell] ?? 0, context.size);
+ if (context.values[cell] !== 0 || values.length !== 2) continue;
+ add(
+ { cell, value: values[0] as number },
+ { cell, value: values[1] as number },
+ );
+ }
+ return adjacency;
+}
+
+function aicWeakNeighbours(
+ context: AdvancedLogicalContext,
+ node: CandidateNode,
+): number[] {
+ const ids: number[] = [];
+ for (const value of maskDigits(context.masks[node.cell] ?? 0, context.size)) {
+ if (value !== node.value)
+ ids.push(nodeId(context, { cell: node.cell, value }));
+ }
+ for (const cell of context.peers[node.cell] ?? []) {
+ if (hasCandidate(context, cell, node.value)) {
+ ids.push(nodeId(context, { cell, value: node.value }));
+ }
+ }
+ return uniqueSorted(ids);
+}
+
+export function findAic(
+ context: AdvancedLogicalContext,
+): LogicalStep | undefined {
+ const strong = aicStrongAdjacency(context);
+ const starts = [...strong.keys()].sort((a, b) => a - b);
+ let visits = 0;
+ for (const startId of starts) {
+ const start = nodeFromId(context, startId);
+ const path = [startId];
+ const inPath = new Set(path);
+ const search = (nextStrong: boolean): LogicalStep | undefined => {
+ if (visits >= MAX_CHAIN_VISITS) return undefined;
+ visits += 1;
+ const currentId = path[path.length - 1] as number;
+ const current = nodeFromId(context, currentId);
+ const edges = path.length - 1;
+ if (
+ !nextStrong &&
+ edges >= 3 &&
+ current.cell !== start.cell &&
+ current.value === start.value
+ ) {
+ const pathCells = new Set(
+ path.map((id) => nodeFromId(context, id).cell),
+ );
+ const found = step(
+ "aic",
+ commonPeerEliminations(
+ context,
+ [start.cell, current.cell],
+ start.value,
+ pathCells,
+ ),
+ pathCells,
+ `An Alternating Inference Chain proves that at least one endpoint is ${start.value}.`,
+ );
+ if (found !== undefined) return found;
+ }
+ if (path.length >= MAX_CHAIN_NODES) return undefined;
+ const neighbours = nextStrong
+ ? [...(strong.get(currentId) ?? [])].sort((a, b) => a - b)
+ : aicWeakNeighbours(context, current);
+ for (const neighbour of neighbours) {
+ if (inPath.has(neighbour)) continue;
+ path.push(neighbour);
+ inPath.add(neighbour);
+ const found = search(!nextStrong);
+ if (found !== undefined) return found;
+ inPath.delete(neighbour);
+ path.pop();
+ }
+ return undefined;
+ };
+ const found = search(true);
+ if (found !== undefined) return found;
+ if (visits >= MAX_CHAIN_VISITS) return undefined;
+ }
+ return undefined;
+}
+
+export function findUniqueRectangle(
+ context: AdvancedLogicalContext,
+): LogicalStep | undefined {
+ if (
+ context.uniquenessProven !== true ||
+ context.uniquenessPatternsSafe !== true
+ ) {
+ return undefined;
+ }
+ for (let firstRow = 0; firstRow < context.size; firstRow += 1) {
+ for (
+ let secondRow = firstRow + 1;
+ secondRow < context.size;
+ secondRow += 1
+ ) {
+ for (let firstColumn = 0; firstColumn < context.size; firstColumn += 1) {
+ for (
+ let secondColumn = firstColumn + 1;
+ secondColumn < context.size;
+ secondColumn += 1
+ ) {
+ const cells = [
+ firstRow * context.size + firstColumn,
+ firstRow * context.size + secondColumn,
+ secondRow * context.size + firstColumn,
+ secondRow * context.size + secondColumn,
+ ];
+ if (cells.some((cell) => context.values[cell] !== 0)) continue;
+ const regionCounts = new Map();
+ for (const cell of cells) {
+ const region = context.regions[cell];
+ if (region === undefined) continue;
+ regionCounts.set(region, (regionCounts.get(region) ?? 0) + 1);
+ }
+ if (
+ regionCounts.size !== 2 ||
+ [...regionCounts.values()].some((count) => count !== 2)
+ ) {
+ continue;
+ }
+ const regionsPreservePair = [...regionCounts.keys()].every(
+ (region) => {
+ const pair = cells.filter(
+ (cell) => context.regions[cell] === region,
+ );
+ const first = pair[0];
+ const second = pair[1];
+ return (
+ first !== undefined &&
+ second !== undefined &&
+ (Math.floor(first / context.size) ===
+ Math.floor(second / context.size) ||
+ first % context.size === second % context.size)
+ );
+ },
+ );
+ if (!regionsPreservePair) continue;
+ for (let roofIndex = 0; roofIndex < cells.length; roofIndex += 1) {
+ const roof = cells[roofIndex] as number;
+ const floor = cells.filter((_cell, index) => index !== roofIndex);
+ const pairMask = context.masks[floor[0] as number] ?? 0;
+ if (
+ popcount(pairMask) !== 2 ||
+ floor.some((cell) => (context.masks[cell] ?? 0) !== pairMask)
+ ) {
+ continue;
+ }
+ const roofMask = context.masks[roof] ?? 0;
+ if (
+ (roofMask & pairMask) !== pairMask ||
+ popcount(roofMask & ~pairMask) === 0
+ ) {
+ continue;
+ }
+ const values = maskDigits(pairMask, context.size);
+ const found = step(
+ "unique-rectangle",
+ [{ cell: roof, values }],
+ cells,
+ `A proven-unique puzzle cannot complete this ${values.join("/")} rectangle in two interchangeable ways.`,
+ );
+ if (found !== undefined) return found;
+ }
+ }
+ }
+ }
+ }
+ return undefined;
+}
+
+export function findAdvancedLogicalStep(
+ context: AdvancedLogicalContext,
+): LogicalStep | undefined {
+ return (
+ findJellyfish(context) ??
+ findFinnedFish(context) ??
+ findSkyscraper(context) ??
+ findTwoStringKite(context) ??
+ findSimpleColouring(context) ??
+ findWWing(context) ??
+ findUniqueRectangle(context) ??
+ findXChain(context) ??
+ findXYChain(context) ??
+ findAic(context)
+ );
+}
diff --git a/src/solver/difficulty.ts b/src/solver/difficulty.ts
index 0741568..755acf9 100644
--- a/src/solver/difficulty.ts
+++ b/src/solver/difficulty.ts
@@ -58,6 +58,17 @@ const TECHNIQUE_WEIGHT: Readonly> = {
"xy-wing": 57,
"xyz-wing": 61,
swordfish: 66,
+ skyscraper: 67,
+ "two-string-kite": 68,
+ "finned-x-wing": 69,
+ "simple-colouring": 70,
+ jellyfish: 72,
+ "w-wing": 73,
+ "unique-rectangle": 74,
+ "finned-swordfish": 75,
+ "x-chain": 78,
+ "xy-chain": 82,
+ aic: 88,
};
function boundedInteger(
@@ -151,12 +162,15 @@ export function evaluateDifficulty(
120_000,
"exactTimeoutMs",
);
- const logical = solveLogically(normalized, { maxSteps: logicalMaxSteps });
const exact = solveExact(normalized, {
maxSolutions: 2,
maxNodes: exactMaxNodes,
timeoutMs: exactTimeoutMs,
});
+ const logical = solveLogically(normalized, {
+ maxSteps: logicalMaxSteps,
+ uniquenessProven: exact.count === 1 && !exact.truncated,
+ });
if (exact.count === 0 && !exact.truncated) {
return unrated(
diff --git a/src/solver/generator.ts b/src/solver/generator.ts
index e41e36c..84d5c09 100644
--- a/src/solver/generator.ts
+++ b/src/solver/generator.ts
@@ -7,12 +7,45 @@ import {
import { solveExact } from "./exact";
import { seededRandom, shuffled } from "./random";
-export type ClueSymmetry = "none" | "rotational";
+export type ClueSymmetry =
+ | "none"
+ | "rotational"
+ | "horizontal"
+ | "vertical"
+ | "diagonal-main"
+ | "diagonal-anti"
+ | "orthogonal"
+ /** Alias for orthogonal quarter-turn symmetry. */
+ | "four-way";
+
+export type MinimalGivensStatus =
+ "not-requested" | "proven-minimal" | "unknown";
+
+export type MinimalityLimitReason =
+ "check-cap" | "node-cap" | "timeout" | "not-unique" | "inconsistent-result";
+
+export interface MinimalGivensEvidence {
+ readonly status: MinimalGivensStatus;
+ readonly checksPerformed: number;
+ readonly nodes: number;
+ readonly removedClues: number;
+ readonly criticalCells: readonly number[];
+ readonly unknownCells: readonly number[];
+ readonly limitReasons: readonly MinimalityLimitReason[];
+ readonly symmetryPreserved: boolean;
+}
+
+export interface MinimizePuzzleResult {
+ readonly puzzle: NormalizedPuzzle;
+ readonly minimality: MinimalGivensEvidence;
+}
export interface MinimizeOptions {
readonly seed?: string | number;
readonly targetClues?: number;
readonly symmetry?: ClueSymmetry;
+ /** Continue with individual clue deletion tests until givens are minimal. */
+ readonly minimalGivens?: boolean;
readonly maxChecks?: number;
readonly solveMaxNodes?: number;
readonly solveTimeoutMs?: number;
@@ -117,11 +150,106 @@ function boundedOption(
return result;
}
-export function minimizePuzzle(
+function validatedSymmetry(value: ClueSymmetry | undefined): ClueSymmetry {
+ const symmetry = value ?? "rotational";
+ if (
+ symmetry !== "none" &&
+ symmetry !== "rotational" &&
+ symmetry !== "horizontal" &&
+ symmetry !== "vertical" &&
+ symmetry !== "diagonal-main" &&
+ symmetry !== "diagonal-anti" &&
+ symmetry !== "orthogonal" &&
+ symmetry !== "four-way"
+ ) {
+ throw new RangeError(`Unsupported clue symmetry: ${String(symmetry)}.`);
+ }
+ return symmetry;
+}
+
+/** Returns the complete deterministic clue orbit for a symmetry. */
+export function clueOrbit(
+ size: number,
+ cell: number,
+ requestedSymmetry: ClueSymmetry,
+): readonly number[] {
+ if (
+ !Number.isInteger(size) ||
+ size < 1 ||
+ !Number.isInteger(cell) ||
+ cell < 0 ||
+ cell >= size * size
+ ) {
+ throw new RangeError("Clue orbit requires a valid square-grid cell.");
+ }
+ const symmetry = validatedSymmetry(requestedSymmetry);
+ const row = Math.floor(cell / size);
+ const column = cell % size;
+ const at = (nextRow: number, nextColumn: number): number =>
+ nextRow * size + nextColumn;
+ const horizontal = at(size - row - 1, column);
+ const vertical = at(row, size - column - 1);
+ const rotational = at(size - row - 1, size - column - 1);
+ const quarterTurn = at(column, size - row - 1);
+ const threeQuarterTurn = at(size - column - 1, row);
+ const cells = (() => {
+ switch (symmetry) {
+ case "none":
+ return [cell];
+ case "rotational":
+ return [cell, rotational];
+ case "horizontal":
+ return [cell, horizontal];
+ case "vertical":
+ return [cell, vertical];
+ case "diagonal-main":
+ return [cell, at(column, row)];
+ case "diagonal-anti":
+ return [cell, at(size - column - 1, size - row - 1)];
+ case "orthogonal":
+ case "four-way":
+ return [cell, quarterTurn, rotational, threeQuarterTurn];
+ }
+ })();
+ return [...new Set(cells)].sort((a, b) => a - b);
+}
+
+function cluePatternPreservesSymmetry(
+ givens: readonly number[],
+ size: number,
+ symmetry: ClueSymmetry,
+): boolean {
+ for (let cell = 0; cell < givens.length; cell += 1) {
+ const present = (givens[cell] ?? 0) !== 0;
+ if (
+ clueOrbit(size, cell, symmetry).some(
+ (other) => ((givens[other] ?? 0) !== 0) !== present,
+ )
+ ) {
+ return false;
+ }
+ }
+ return true;
+}
+
+function uniqueAfterRemoval(
+ puzzle: PuzzleDefinition,
+ maxNodes: number,
+ timeoutMs: number,
+) {
+ return solveExact(puzzle, {
+ maxSolutions: 2,
+ maxNodes,
+ timeoutMs,
+ });
+}
+
+export function minimizePuzzleWithReport(
puzzle: PuzzleDefinition | NormalizedPuzzle,
options: MinimizeOptions = {},
-): NormalizedPuzzle {
+): MinimizePuzzleResult {
const normalized = normalizePuzzle(puzzle);
+ const minimalGivens = options.minimalGivens === true;
const targetClues = boundedOption(
options.targetClues,
Math.max(
@@ -134,17 +262,30 @@ export function minimizePuzzle(
);
const maxChecks = boundedOption(
options.maxChecks,
- normalized.size * normalized.size * 2,
+ normalized.size * normalized.size * (minimalGivens ? 3 : 2),
1,
normalized.size * normalized.size * 10,
"maxChecks",
);
- const symmetry = options.symmetry ?? "rotational";
- if (symmetry !== "none" && symmetry !== "rotational") {
- throw new RangeError('symmetry must be "none" or "rotational"');
- }
- const random = seededRandom(options.seed ?? "sudoku-tools");
+ const solveMaxNodes = boundedOption(
+ options.solveMaxNodes,
+ 2_000_000,
+ 1,
+ 100_000_000,
+ "solveMaxNodes",
+ );
+ const solveTimeoutMs = boundedOption(
+ options.solveTimeoutMs,
+ 10_000,
+ 1,
+ 120_000,
+ "solveTimeoutMs",
+ );
+ const symmetry = validatedSymmetry(options.symmetry);
+ const seed = options.seed ?? "sudoku-tools";
+ const random = seededRandom(seed);
const givens = [...normalized.givens];
+ const initialClues = givens.filter((value) => value !== 0).length;
const countClues = (): number =>
givens.reduce((count, value) => count + (value === 0 ? 0 : 1), 0);
const order = shuffled(
@@ -152,36 +293,139 @@ export function minimizePuzzle(
random,
);
let checks = 0;
- for (const cell of order) {
- if (checks >= maxChecks || countClues() <= targetClues) break;
- if (givens[cell] === 0) continue;
- const mirror = givens.length - cell - 1;
- const group =
- symmetry === "rotational" && mirror !== cell ? [cell, mirror] : [cell];
- if (group.some((entry) => givens[entry] === 0)) continue;
- if (countClues() - group.length < targetClues) continue;
- const saved = group.map((entry) => givens[entry] ?? 0);
- group.forEach((entry) => {
- givens[entry] = 0;
- });
+ let nodes = 0;
+ const criticalCells: number[] = [];
+ const unknownCells: number[] = [];
+ const limitReasons = new Set();
+ let uniquenessPrerequisiteProven = true;
+
+ // Minimality is meaningful only for a uniquely solvable starting puzzle.
+ // This check shares the same explicit check, node and wall-clock budgets as
+ // every subsequent deletion test.
+ if (minimalGivens) {
checks += 1;
- const candidate: PuzzleDefinition = {
- ...normalized,
- givens,
- solution: normalized.solution,
- };
- const result = solveExact(candidate, {
- maxSolutions: 2,
- maxNodes: options.solveMaxNodes ?? 2_000_000,
- timeoutMs: options.solveTimeoutMs ?? 10_000,
- });
- if (result.count !== 1 || result.truncated) {
- group.forEach((entry, index) => {
- givens[entry] = saved[index] ?? 0;
- });
+ const baseline = uniqueAfterRemoval(
+ { ...normalized, givens, solution: normalized.solution },
+ solveMaxNodes,
+ solveTimeoutMs,
+ );
+ nodes += baseline.nodes;
+ if (baseline.count !== 1 || baseline.truncated) {
+ uniquenessPrerequisiteProven = false;
+ unknownCells.push(
+ ...givens.flatMap((value, cell) => (value === 0 ? [] : [cell])),
+ );
+ if (
+ baseline.count >= 2 ||
+ (baseline.count === 0 && !baseline.truncated)
+ ) {
+ limitReasons.add("not-unique");
+ } else if (baseline.limitReason === "node-cap") {
+ limitReasons.add("node-cap");
+ } else if (baseline.limitReason === "timeout") {
+ limitReasons.add("timeout");
+ } else {
+ limitReasons.add("inconsistent-result");
+ }
}
}
- return normalizePuzzle({ ...normalized, givens });
+
+ if (!minimalGivens || uniquenessPrerequisiteProven) {
+ for (const cell of order) {
+ if (checks >= maxChecks || countClues() <= targetClues) break;
+ if (givens[cell] === 0) continue;
+ const group = clueOrbit(normalized.size, cell, symmetry);
+ if (group.some((entry) => givens[entry] === 0)) continue;
+ if (countClues() - group.length < targetClues) continue;
+ const saved = group.map((entry) => givens[entry] ?? 0);
+ group.forEach((entry) => {
+ givens[entry] = 0;
+ });
+ checks += 1;
+ const result = uniqueAfterRemoval(
+ { ...normalized, givens, solution: normalized.solution },
+ solveMaxNodes,
+ solveTimeoutMs,
+ );
+ nodes += result.nodes;
+ if (result.count !== 1 || result.truncated) {
+ group.forEach((entry, index) => {
+ givens[entry] = saved[index] ?? 0;
+ });
+ }
+ }
+ }
+
+ if (minimalGivens && uniquenessPrerequisiteProven) {
+ const minimalOrder = shuffled(
+ Array.from({ length: givens.length }, (_, cell) => cell).filter(
+ (cell) => givens[cell] !== 0,
+ ),
+ seededRandom(`${String(seed)}:minimal-givens`),
+ );
+ for (let index = 0; index < minimalOrder.length; index += 1) {
+ const cell = minimalOrder[index] as number;
+ if (givens[cell] === 0) continue;
+ if (checks >= maxChecks) {
+ limitReasons.add("check-cap");
+ unknownCells.push(
+ ...minimalOrder
+ .slice(index)
+ .filter((remaining) => givens[remaining] !== 0),
+ );
+ break;
+ }
+ const saved = givens[cell] as number;
+ givens[cell] = 0;
+ checks += 1;
+ const result = uniqueAfterRemoval(
+ { ...normalized, givens, solution: normalized.solution },
+ solveMaxNodes,
+ solveTimeoutMs,
+ );
+ nodes += result.nodes;
+ if (result.count === 1 && !result.truncated) continue;
+ givens[cell] = saved;
+ if (result.count >= 2) {
+ criticalCells.push(cell);
+ } else {
+ unknownCells.push(cell);
+ if (result.limitReason === "node-cap") limitReasons.add("node-cap");
+ else if (result.limitReason === "timeout") limitReasons.add("timeout");
+ else limitReasons.add("inconsistent-result");
+ }
+ }
+ }
+
+ const minimized = normalizePuzzle({ ...normalized, givens });
+ return {
+ puzzle: minimized,
+ minimality: {
+ status: minimalGivens
+ ? uniquenessPrerequisiteProven && unknownCells.length === 0
+ ? "proven-minimal"
+ : "unknown"
+ : "not-requested",
+ checksPerformed: checks,
+ nodes,
+ removedClues: initialClues - countClues(),
+ criticalCells: [...new Set(criticalCells)].sort((a, b) => a - b),
+ unknownCells: [...new Set(unknownCells)].sort((a, b) => a - b),
+ limitReasons: [...limitReasons].sort(),
+ symmetryPreserved: cluePatternPreservesSymmetry(
+ givens,
+ normalized.size,
+ symmetry,
+ ),
+ },
+ };
+}
+
+export function minimizePuzzle(
+ puzzle: PuzzleDefinition | NormalizedPuzzle,
+ options: MinimizeOptions = {},
+): NormalizedPuzzle {
+ return minimizePuzzleWithReport(puzzle, options).puzzle;
}
export function generateClassic(
diff --git a/src/solver/index.ts b/src/solver/index.ts
index 8f208f5..3209eef 100644
--- a/src/solver/index.ts
+++ b/src/solver/index.ts
@@ -1,6 +1,8 @@
+export * from "./advancedLogical";
export * from "./difficulty";
export * from "./exact";
export * from "./generator";
export * from "./killer";
export * from "./logical";
+export * from "./quality";
export * from "./variantGenerator";
diff --git a/src/solver/logical.ts b/src/solver/logical.ts
index dd73487..d613176 100644
--- a/src/solver/logical.ts
+++ b/src/solver/logical.ts
@@ -12,6 +12,7 @@ import {
type SudokuUnit,
type ValidationIssue,
} from "../domain";
+import { findAdvancedLogicalStep } from "./advancedLogical";
export type LogicalTechnique =
| "naked-single"
@@ -26,6 +27,17 @@ export type LogicalTechnique =
| "claiming"
| "x-wing"
| "swordfish"
+ | "jellyfish"
+ | "finned-x-wing"
+ | "finned-swordfish"
+ | "skyscraper"
+ | "two-string-kite"
+ | "simple-colouring"
+ | "w-wing"
+ | "x-chain"
+ | "xy-chain"
+ | "aic"
+ | "unique-rectangle"
| "xy-wing"
| "xyz-wing"
| "killer-cage";
@@ -52,6 +64,19 @@ export type LogicalSolveStatus = "solved" | "stuck" | "invalid" | "step-limit";
export interface LogicalSolveOptions {
readonly values?: readonly number[];
+ /**
+ * Optional per-cell candidate restrictions to resume a logical solve after
+ * applying an elimination-only step. Each entry contains ordinary Sudoku
+ * digits (1 through the puzzle size), rather than an implementation-specific
+ * bit mask. Restrictions are intersected with the candidates that remain
+ * legal on the supplied board; entries for filled cells are ignored.
+ */
+ readonly candidates?: readonly (readonly number[])[];
+ /**
+ * Enables uniqueness-dependent deductions only after the caller has proved
+ * exactly one solution with an exhaustive solver result. Never inferred.
+ */
+ readonly uniquenessProven?: boolean;
readonly maxSteps?: number;
}
@@ -148,9 +173,73 @@ function validateStart(
if (issues.length > 0) throw new PuzzleValidationError(issues);
}
+function compileCandidateRestrictions(
+ normalized: NormalizedPuzzle,
+ input: unknown,
+): number[] | undefined {
+ if (input === undefined) return undefined;
+
+ const issues: ValidationIssue[] = [];
+ const cellCount = normalized.size * normalized.size;
+ if (!Array.isArray(input)) {
+ throw new PuzzleValidationError([
+ { path: "candidates", message: "must be an array of candidate arrays" },
+ ]);
+ }
+ if (input.length !== cellCount) {
+ issues.push({
+ path: "candidates",
+ message: `must contain exactly ${cellCount} candidate arrays`,
+ });
+ }
+
+ const masks = new Array(cellCount).fill(0);
+ for (let cell = 0; cell < Math.min(input.length, cellCount); cell += 1) {
+ const cellCandidates: unknown = input[cell];
+ if (!Array.isArray(cellCandidates)) {
+ issues.push({
+ path: `candidates[${cell}]`,
+ message: "must be an array of candidate digits",
+ });
+ continue;
+ }
+
+ let mask = 0;
+ for (let index = 0; index < cellCandidates.length; index += 1) {
+ const value: unknown = cellCandidates[index];
+ if (
+ typeof value !== "number" ||
+ !Number.isInteger(value) ||
+ value < 1 ||
+ value > normalized.size
+ ) {
+ issues.push({
+ path: `candidates[${cell}][${index}]`,
+ message: `must be an integer from 1 to ${normalized.size}`,
+ });
+ continue;
+ }
+ const bit = digitBit(value);
+ if ((mask & bit) !== 0) {
+ issues.push({
+ path: `candidates[${cell}][${index}]`,
+ message: `contains duplicate candidate ${value}`,
+ });
+ continue;
+ }
+ mask |= bit;
+ }
+ masks[cell] = mask;
+ }
+
+ if (issues.length > 0) throw new PuzzleValidationError(issues);
+ return masks;
+}
+
function initializeState(
normalized: NormalizedPuzzle,
values: readonly number[],
+ candidateRestrictions?: readonly number[],
): LogicalState {
const compiled = compilePuzzle(normalized);
return {
@@ -158,10 +247,13 @@ function initializeState(
values: [...values],
masks: values.map((value, cell) => {
if (value !== 0) return 0;
- return candidatesForCell(compiled, values, cell).reduce(
+ const legalMask = candidatesForCell(compiled, values, cell).reduce(
(mask, candidate) => mask | digitBit(candidate),
0,
);
+ return candidateRestrictions === undefined
+ ? legalMask
+ : legalMask & (candidateRestrictions[cell] ?? 0);
}),
};
}
@@ -624,7 +716,10 @@ function findKillerReduction(state: LogicalState): LogicalStep | undefined {
return undefined;
}
-function findStep(state: LogicalState): LogicalStep | undefined {
+function findStep(
+ state: LogicalState,
+ uniquenessProven: boolean,
+): LogicalStep | undefined {
return (
findNakedSingle(state) ??
findHiddenSingle(state) ??
@@ -634,6 +729,16 @@ function findStep(state: LogicalState): LogicalStep | undefined {
findFish(state) ??
findXyWing(state) ??
findXyzWing(state) ??
+ findAdvancedLogicalStep({
+ size: state.compiled.puzzle.size,
+ values: state.values,
+ masks: state.masks,
+ regions: state.compiled.puzzle.regions,
+ peers: state.compiled.peers,
+ units: state.compiled.units,
+ uniquenessProven,
+ uniquenessPatternsSafe: state.compiled.puzzle.constraints.length === 0,
+ }) ??
findKillerReduction(state)
);
}
@@ -677,7 +782,17 @@ export function solveLogically(
if (!Number.isInteger(maxSteps) || maxSteps < 1 || maxSteps > 10_000) {
throw new RangeError("maxSteps must be an integer from 1 to 10000");
}
- const state = initializeState(normalized, values);
+ if (
+ options.uniquenessProven !== undefined &&
+ typeof options.uniquenessProven !== "boolean"
+ ) {
+ throw new TypeError("uniquenessProven must be a boolean when supplied");
+ }
+ const candidateRestrictions = compileCandidateRestrictions(
+ normalized,
+ options.candidates,
+ );
+ const state = initializeState(normalized, values, candidateRestrictions);
const steps: LogicalStep[] = [];
if (findConflicts(state.compiled, state.values).length > 0) {
return {
@@ -708,7 +823,7 @@ export function solveLogically(
steps,
};
}
- const step = findStep(state);
+ const step = findStep(state, options.uniquenessProven === true);
if (step === undefined) {
return {
status: "stuck",
diff --git a/src/solver/quality.ts b/src/solver/quality.ts
new file mode 100644
index 0000000..7e46c2f
--- /dev/null
+++ b/src/solver/quality.ts
@@ -0,0 +1,791 @@
+import {
+ PuzzleValidationError,
+ constraintCells,
+ normalizePuzzle,
+ type NormalizedPuzzle,
+ type PuzzleDefinition,
+ type VariantConstraint,
+} from "../domain";
+import {
+ solveExact,
+ type ExactLimitReason,
+ type ExactSolveResult,
+} from "./exact";
+
+export type QualitySolutionStatus =
+ "unsatisfiable" | "unique" | "multiple" | "unknown";
+
+export type QualityClassification = "critical" | "redundant" | "unknown";
+
+export type QualityUnknownReason =
+ | "baseline-not-unique"
+ | "baseline-unknown"
+ | "per-check-node-cap"
+ | "per-check-timeout"
+ | "aggregate-check-cap"
+ | "aggregate-node-cap"
+ | "aggregate-timeout"
+ | "unexpected-unsatisfiable-relaxation";
+
+export type QualityCheckPurpose =
+ "baseline" | "redundancy" | "contradiction-core";
+
+export type QualityItemReference =
+ | {
+ readonly kind: "given";
+ readonly cell: number;
+ readonly value: number;
+ }
+ | {
+ readonly kind: "constraint";
+ readonly index: number;
+ readonly constraintType: VariantConstraint["type"];
+ };
+
+export interface PuzzleQualityOptions {
+ /** Baseline only is a fast 0/1/2-solution check; full adds setter QC. */
+ readonly analysisDepth?: "baseline" | "full";
+ /** Maximum search nodes consumed by any one exact-solver check. */
+ readonly perCheckMaxNodes?: number;
+ /** Wall-clock limit for any one exact-solver check. */
+ readonly perCheckTimeoutMs?: number;
+ /** Maximum number of exact-solver checks in the whole analysis. */
+ readonly aggregateMaxChecks?: number;
+ /** Maximum exact-solver nodes shared by the whole analysis. */
+ readonly aggregateMaxNodes?: number;
+ /** Wall-clock limit shared by the whole analysis. */
+ readonly aggregateTimeoutMs?: number;
+ /** Derive a bounded single-clue/constraint minimality proof. */
+ readonly proveMinimality?: boolean;
+}
+
+export interface QualityBounds {
+ readonly perCheck: {
+ readonly maxNodes: number;
+ readonly timeoutMs: number;
+ };
+ readonly aggregate: {
+ readonly maxChecks: number;
+ readonly maxNodes: number;
+ readonly timeoutMs: number;
+ };
+}
+
+export interface QualitySearchCheck {
+ readonly index: number;
+ readonly purpose: QualityCheckPurpose;
+ readonly item?: QualityItemReference;
+ readonly solutionStatus: QualitySolutionStatus;
+ readonly solutionsFound: number;
+ /** False only when a node/time limit prevented a conclusion. */
+ readonly conclusive: boolean;
+ /** Mirrors the exact solver; two found solutions intentionally hit solution-cap. */
+ readonly truncated: boolean;
+ readonly limitReason?: ExactLimitReason;
+ readonly unknownReason?: QualityUnknownReason;
+ readonly nodes: number;
+ readonly elapsedMs: number;
+}
+
+export interface AmbiguityDifference {
+ readonly cell: number;
+ readonly first: number;
+ readonly second: number;
+}
+
+export interface AmbiguityWitness {
+ readonly firstSolution: readonly number[];
+ readonly secondSolution: readonly number[];
+ readonly differences: readonly AmbiguityDifference[];
+}
+
+export interface QualityItemAssessment {
+ readonly item: QualityItemReference;
+ readonly classification: QualityClassification;
+ readonly checkIndex?: number;
+ readonly solutionStatus?: QualitySolutionStatus;
+ readonly unknownReason?: QualityUnknownReason;
+}
+
+export interface ContradictionLocalization {
+ readonly status: "not-applicable" | "localized" | "incomplete";
+ /** A deletion-minimized unsatisfiable core. Unknown items remain in this set. */
+ readonly core: readonly QualityItemReference[];
+ /** Core items proven necessary: removing one made the current core satisfiable. */
+ readonly necessary: readonly QualityItemReference[];
+ /** Items proven unnecessary to retain unsatisfiability. */
+ readonly removable: readonly QualityItemReference[];
+ readonly unknown: readonly QualityItemReference[];
+ readonly reason?: string;
+}
+
+export interface CellCriticality {
+ readonly cell: number;
+ /** Critical share among conclusive assessments, or null without one. */
+ readonly score: number | null;
+ readonly criticalWeight: number;
+ readonly redundantWeight: number;
+ readonly unknownWeight: number;
+}
+
+export interface QualityMinimalityAnalysis {
+ readonly status:
+ "proven-minimal" | "not-minimal" | "unknown" | "not-applicable";
+ readonly redundant: readonly QualityItemReference[];
+ readonly unknown: readonly QualityItemReference[];
+ readonly reason?: string;
+}
+
+export interface QualityBudgetSummary {
+ readonly checksPlanned: number;
+ readonly checksPerformed: number;
+ readonly nodes: number;
+ readonly elapsedMs: number;
+ /** True when at least one requested conclusion remained unknown. */
+ readonly truncated: boolean;
+ readonly unknownReasons: readonly QualityUnknownReason[];
+}
+
+export interface PuzzleQualityAnalysis {
+ readonly analysisDepth: "baseline" | "full";
+ readonly solutionStatus: QualitySolutionStatus;
+ readonly solution?: readonly number[];
+ readonly baselineCheckIndex?: number;
+ readonly ambiguityWitness?: AmbiguityWitness;
+ readonly contradiction: ContradictionLocalization;
+ readonly redundancy: {
+ readonly givens: readonly QualityItemAssessment[];
+ readonly constraints: readonly QualityItemAssessment[];
+ };
+ readonly criticalityHeatmap: readonly CellCriticality[];
+ readonly minimality?: QualityMinimalityAnalysis;
+ readonly checks: readonly QualitySearchCheck[];
+ readonly bounds: QualityBounds;
+ readonly budget: QualityBudgetSummary;
+}
+
+const DEFAULT_BOUNDS: QualityBounds = {
+ perCheck: { maxNodes: 2_000_000, timeoutMs: 10_000 },
+ aggregate: { maxChecks: 1_000, maxNodes: 20_000_000, timeoutMs: 30_000 },
+};
+
+function boundedInteger(
+ value: number | undefined,
+ fallback: number,
+ minimum: number,
+ maximum: number,
+ name: string,
+): number {
+ const result = value ?? fallback;
+ if (!Number.isInteger(result) || result < minimum || result > maximum) {
+ throw new RangeError(
+ `${name} must be an integer from ${minimum} to ${maximum}.`,
+ );
+ }
+ return result;
+}
+
+function resolveBounds(options: PuzzleQualityOptions): QualityBounds {
+ return {
+ perCheck: {
+ maxNodes: boundedInteger(
+ options.perCheckMaxNodes,
+ DEFAULT_BOUNDS.perCheck.maxNodes,
+ 1,
+ 100_000_000,
+ "perCheckMaxNodes",
+ ),
+ timeoutMs: boundedInteger(
+ options.perCheckTimeoutMs,
+ DEFAULT_BOUNDS.perCheck.timeoutMs,
+ 1,
+ 120_000,
+ "perCheckTimeoutMs",
+ ),
+ },
+ aggregate: {
+ maxChecks: boundedInteger(
+ options.aggregateMaxChecks,
+ DEFAULT_BOUNDS.aggregate.maxChecks,
+ 1,
+ 20_000,
+ "aggregateMaxChecks",
+ ),
+ maxNodes: boundedInteger(
+ options.aggregateMaxNodes,
+ DEFAULT_BOUNDS.aggregate.maxNodes,
+ 1,
+ 2_000_000_000,
+ "aggregateMaxNodes",
+ ),
+ timeoutMs: boundedInteger(
+ options.aggregateTimeoutMs,
+ DEFAULT_BOUNDS.aggregate.timeoutMs,
+ 1,
+ 600_000,
+ "aggregateTimeoutMs",
+ ),
+ },
+ };
+}
+
+/**
+ * Quality analysis deliberately accepts structurally valid but contradictory
+ * givens. The regular normalizer rejects those because they are not playable,
+ * so validate the same document with an empty board and retain the original
+ * givens for the diagnostic search.
+ */
+function normalizeForQuality(
+ puzzle: PuzzleDefinition | NormalizedPuzzle,
+): NormalizedPuzzle {
+ try {
+ return normalizePuzzle(puzzle);
+ } catch (error) {
+ if (!(error instanceof PuzzleValidationError)) throw error;
+ if (error.issues.some((issue) => issue.path !== "givens")) throw error;
+ const cellCount = puzzle.size * puzzle.size;
+ if (
+ !Number.isInteger(puzzle.size) ||
+ puzzle.givens.length !== cellCount ||
+ puzzle.givens.some(
+ (value) => !Number.isInteger(value) || value < 0 || value > puzzle.size,
+ )
+ ) {
+ throw error;
+ }
+ const skeleton = normalizePuzzle({
+ ...puzzle,
+ givens: new Array(cellCount).fill(0),
+ solution: undefined,
+ });
+ return {
+ ...skeleton,
+ givens: [...puzzle.givens],
+ };
+ }
+}
+
+function itemKey(item: QualityItemReference): string {
+ return item.kind === "given"
+ ? `given:${item.cell}`
+ : `constraint:${item.index}`;
+}
+
+function puzzleItems(puzzle: NormalizedPuzzle): QualityItemReference[] {
+ const givens: QualityItemReference[] = [];
+ puzzle.givens.forEach((value, cell) => {
+ if (value !== 0) givens.push({ kind: "given", cell, value });
+ });
+ const constraints: QualityItemReference[] = puzzle.constraints.map(
+ (constraint, index) => ({
+ kind: "constraint",
+ index,
+ constraintType: constraint.type,
+ }),
+ );
+ return [...givens, ...constraints];
+}
+
+function withActiveItems(
+ puzzle: NormalizedPuzzle,
+ active: ReadonlySet,
+): PuzzleDefinition {
+ return {
+ ...puzzle,
+ givens: puzzle.givens.map((value, cell) =>
+ active.has(`given:${cell}`) ? value : 0,
+ ),
+ constraints: puzzle.constraints.filter((_constraint, index) =>
+ active.has(`constraint:${index}`),
+ ),
+ };
+}
+
+function withoutItem(
+ puzzle: NormalizedPuzzle,
+ item: QualityItemReference,
+): PuzzleDefinition {
+ if (item.kind === "given") {
+ return {
+ ...puzzle,
+ givens: puzzle.givens.map((value, cell) =>
+ cell === item.cell ? 0 : value,
+ ),
+ };
+ }
+ return {
+ ...puzzle,
+ constraints: puzzle.constraints.filter(
+ (_constraint, index) => index !== item.index,
+ ),
+ };
+}
+
+function statusOf(result: ExactSolveResult): QualitySolutionStatus {
+ if (result.count >= 2) return "multiple";
+ if (result.truncated) return "unknown";
+ return result.count === 1 ? "unique" : "unsatisfiable";
+}
+
+interface SearchOutcome {
+ readonly status: QualitySolutionStatus;
+ readonly checkIndex?: number;
+ readonly unknownReason?: QualityUnknownReason;
+ readonly solutions: readonly (readonly number[])[];
+}
+
+interface SearchBudget {
+ readonly bounds: QualityBounds;
+ readonly started: number;
+ readonly checks: QualitySearchCheck[];
+ readonly unknownReasons: Set;
+ nodes: number;
+}
+
+function skipped(
+ budget: SearchBudget,
+ reason: QualityUnknownReason,
+): SearchOutcome {
+ budget.unknownReasons.add(reason);
+ return { status: "unknown", unknownReason: reason, solutions: [] };
+}
+
+function runCheck(
+ budget: SearchBudget,
+ puzzle: PuzzleDefinition,
+ purpose: QualityCheckPurpose,
+ item?: QualityItemReference,
+): SearchOutcome {
+ if (budget.checks.length >= budget.bounds.aggregate.maxChecks) {
+ return skipped(budget, "aggregate-check-cap");
+ }
+ const remainingNodes = budget.bounds.aggregate.maxNodes - budget.nodes;
+ if (remainingNodes <= 0) return skipped(budget, "aggregate-node-cap");
+ const aggregateElapsed = Date.now() - budget.started;
+ const remainingMs = budget.bounds.aggregate.timeoutMs - aggregateElapsed;
+ if (remainingMs <= 0) return skipped(budget, "aggregate-timeout");
+
+ const maxNodes = Math.min(budget.bounds.perCheck.maxNodes, remainingNodes);
+ const timeoutMs = Math.min(budget.bounds.perCheck.timeoutMs, remainingMs);
+ const searchStarted = Date.now();
+ let result: ExactSolveResult;
+ try {
+ result = solveExact(puzzle, {
+ maxSolutions: 2,
+ maxNodes,
+ timeoutMs,
+ });
+ } catch (error) {
+ if (!(error instanceof PuzzleValidationError)) throw error;
+ if (error.issues.some((issue) => issue.path !== "givens")) throw error;
+ // All generated relaxations are structurally normalized. A validation
+ // failure here therefore means their active givens already conflict.
+ result = {
+ solutions: [],
+ count: 0,
+ truncated: false,
+ nodes: 0,
+ elapsedMs: Date.now() - searchStarted,
+ };
+ }
+ budget.nodes += result.nodes;
+ const status = statusOf(result);
+ let unknownReason: QualityUnknownReason | undefined;
+ if (status === "unknown") {
+ if (result.limitReason === "node-cap") {
+ unknownReason =
+ maxNodes < budget.bounds.perCheck.maxNodes
+ ? "aggregate-node-cap"
+ : "per-check-node-cap";
+ } else {
+ unknownReason =
+ timeoutMs < budget.bounds.perCheck.timeoutMs
+ ? "aggregate-timeout"
+ : "per-check-timeout";
+ }
+ budget.unknownReasons.add(unknownReason);
+ }
+ const checkIndex = budget.checks.length;
+ budget.checks.push({
+ index: checkIndex,
+ purpose,
+ ...(item === undefined ? {} : { item }),
+ solutionStatus: status,
+ solutionsFound: result.count,
+ conclusive: status !== "unknown",
+ truncated: result.truncated,
+ ...(result.limitReason === undefined
+ ? {}
+ : { limitReason: result.limitReason }),
+ ...(unknownReason === undefined ? {} : { unknownReason }),
+ nodes: result.nodes,
+ elapsedMs: result.elapsedMs,
+ });
+ return {
+ status,
+ checkIndex,
+ ...(unknownReason === undefined ? {} : { unknownReason }),
+ solutions: result.solutions,
+ };
+}
+
+function unknownAssessment(
+ item: QualityItemReference,
+ reason: QualityUnknownReason,
+): QualityItemAssessment {
+ return { item, classification: "unknown", unknownReason: reason };
+}
+
+function assessItem(
+ baseline: QualitySolutionStatus,
+ item: QualityItemReference,
+ outcome: SearchOutcome,
+): QualityItemAssessment {
+ if (outcome.status === "unknown") {
+ return {
+ item,
+ classification: "unknown",
+ ...(outcome.checkIndex === undefined
+ ? {}
+ : { checkIndex: outcome.checkIndex }),
+ solutionStatus: "unknown",
+ ...(outcome.unknownReason === undefined
+ ? {}
+ : { unknownReason: outcome.unknownReason }),
+ };
+ }
+ if (baseline === "unique") {
+ if (outcome.status === "unique") {
+ return {
+ item,
+ classification: "redundant",
+ checkIndex: outcome.checkIndex,
+ solutionStatus: outcome.status,
+ };
+ }
+ if (outcome.status === "multiple") {
+ return {
+ item,
+ classification: "critical",
+ checkIndex: outcome.checkIndex,
+ solutionStatus: outcome.status,
+ };
+ }
+ return {
+ item,
+ classification: "unknown",
+ checkIndex: outcome.checkIndex,
+ solutionStatus: outcome.status,
+ unknownReason: "unexpected-unsatisfiable-relaxation",
+ };
+ }
+ // For an inconsistent definition, an item is critical if removing it makes
+ // the definition satisfiable, and redundant if inconsistency remains.
+ if (baseline === "unsatisfiable") {
+ return {
+ item,
+ classification:
+ outcome.status === "unsatisfiable" ? "redundant" : "critical",
+ checkIndex: outcome.checkIndex,
+ solutionStatus: outcome.status,
+ };
+ }
+ return unknownAssessment(
+ item,
+ baseline === "multiple" ? "baseline-not-unique" : "baseline-unknown",
+ );
+}
+
+function redundancyAnalysis(
+ budget: SearchBudget,
+ puzzle: NormalizedPuzzle,
+ items: readonly QualityItemReference[],
+ baseline: QualitySolutionStatus,
+): {
+ readonly givens: readonly QualityItemAssessment[];
+ readonly constraints: readonly QualityItemAssessment[];
+} {
+ const assessments: QualityItemAssessment[] = [];
+ if (baseline === "multiple" || baseline === "unknown") {
+ const reason: QualityUnknownReason =
+ baseline === "multiple" ? "baseline-not-unique" : "baseline-unknown";
+ budget.unknownReasons.add(reason);
+ assessments.push(...items.map((item) => unknownAssessment(item, reason)));
+ } else {
+ for (const item of items) {
+ const outcome = runCheck(
+ budget,
+ withoutItem(puzzle, item),
+ "redundancy",
+ item,
+ );
+ const assessment = assessItem(baseline, item, outcome);
+ if (assessment.unknownReason !== undefined) {
+ budget.unknownReasons.add(assessment.unknownReason);
+ }
+ assessments.push(assessment);
+ }
+ }
+ return {
+ givens: assessments.filter(
+ (assessment) => assessment.item.kind === "given",
+ ),
+ constraints: assessments.filter(
+ (assessment) => assessment.item.kind === "constraint",
+ ),
+ };
+}
+
+function localizeContradiction(
+ budget: SearchBudget,
+ puzzle: NormalizedPuzzle,
+ items: readonly QualityItemReference[],
+ baseline: QualitySolutionStatus,
+): ContradictionLocalization {
+ if (baseline !== "unsatisfiable") {
+ return {
+ status: "not-applicable",
+ core: [],
+ necessary: [],
+ removable: [],
+ unknown: [],
+ reason:
+ baseline === "unknown"
+ ? "Unsatisfiability was not proven within the search bounds."
+ : "The puzzle is satisfiable.",
+ };
+ }
+
+ const active = new Set(items.map(itemKey));
+ const necessary: QualityItemReference[] = [];
+ const removable: QualityItemReference[] = [];
+ const unknown: QualityItemReference[] = [];
+ for (const item of items) {
+ active.delete(itemKey(item));
+ const outcome = runCheck(
+ budget,
+ withActiveItems(puzzle, active),
+ "contradiction-core",
+ item,
+ );
+ if (outcome.status === "unsatisfiable") {
+ removable.push(item);
+ continue;
+ }
+ active.add(itemKey(item));
+ if (outcome.status === "unique" || outcome.status === "multiple") {
+ necessary.push(item);
+ } else {
+ unknown.push(item);
+ }
+ }
+ const core = items.filter((item) => active.has(itemKey(item)));
+ return {
+ status: unknown.length === 0 ? "localized" : "incomplete",
+ core,
+ necessary,
+ removable,
+ unknown,
+ ...(unknown.length === 0
+ ? {}
+ : { reason: "One or more deletion checks exhausted the shared bounds." }),
+ };
+}
+
+function buildHeatmap(
+ puzzle: NormalizedPuzzle,
+ assessments: readonly QualityItemAssessment[],
+): CellCriticality[] {
+ const weights = Array.from({ length: puzzle.size * puzzle.size }, () => ({
+ critical: 0,
+ redundant: 0,
+ unknown: 0,
+ }));
+ for (const assessment of assessments) {
+ const cells =
+ assessment.item.kind === "given"
+ ? [assessment.item.cell]
+ : constraintCells(
+ puzzle.size,
+ puzzle.constraints[assessment.item.index] as VariantConstraint,
+ );
+ for (const cell of new Set(cells)) {
+ const weight = weights[cell];
+ if (weight === undefined) continue;
+ if (assessment.classification === "critical") weight.critical += 1;
+ else if (assessment.classification === "redundant") weight.redundant += 1;
+ else weight.unknown += 1;
+ }
+ }
+ return weights.map((weight, cell) => {
+ const conclusive = weight.critical + weight.redundant;
+ return {
+ cell,
+ score: conclusive === 0 ? null : weight.critical / conclusive,
+ criticalWeight: weight.critical,
+ redundantWeight: weight.redundant,
+ unknownWeight: weight.unknown,
+ };
+ });
+}
+
+/** Resolves the board cells represented by a quality finding. */
+export function qualityItemCells(
+ puzzle: PuzzleDefinition | NormalizedPuzzle,
+ item: QualityItemReference,
+): readonly number[] {
+ const normalized = normalizeForQuality(puzzle);
+ if (item.kind === "given") {
+ if (item.cell < 0 || item.cell >= normalized.size * normalized.size) {
+ throw new RangeError("Given quality item cell is outside the puzzle.");
+ }
+ return [item.cell];
+ }
+ const constraint = normalized.constraints[item.index];
+ if (constraint === undefined || constraint.type !== item.constraintType) {
+ throw new RangeError("Constraint quality item does not match the puzzle.");
+ }
+ return constraintCells(normalized.size, constraint);
+}
+
+function minimalityFrom(
+ baseline: QualitySolutionStatus,
+ assessments: readonly QualityItemAssessment[],
+): QualityMinimalityAnalysis {
+ if (baseline !== "unique") {
+ return {
+ status: "not-applicable",
+ redundant: [],
+ unknown: assessments.map((assessment) => assessment.item),
+ reason: "Minimality requires a proven unique baseline puzzle.",
+ };
+ }
+ const redundant = assessments
+ .filter((assessment) => assessment.classification === "redundant")
+ .map((assessment) => assessment.item);
+ const unknown = assessments
+ .filter((assessment) => assessment.classification === "unknown")
+ .map((assessment) => assessment.item);
+ if (redundant.length > 0) {
+ return { status: "not-minimal", redundant, unknown };
+ }
+ if (unknown.length > 0) {
+ return {
+ status: "unknown",
+ redundant,
+ unknown,
+ reason: "At least one removal check was inconclusive.",
+ };
+ }
+ return { status: "proven-minimal", redundant, unknown };
+}
+
+/**
+ * Runs a bounded, immutable setter-quality audit. Every exact-solver call uses
+ * both the per-check limits and one shared aggregate budget. A capped search
+ * is always reported as unknown unless two solutions already prove ambiguity.
+ */
+export function analyzePuzzleQuality(
+ puzzle: PuzzleDefinition | NormalizedPuzzle,
+ options: PuzzleQualityOptions = {},
+): PuzzleQualityAnalysis {
+ const normalized = normalizeForQuality(puzzle);
+ const analysisDepth = options.analysisDepth ?? "full";
+ if (analysisDepth !== "baseline" && analysisDepth !== "full") {
+ throw new RangeError('analysisDepth must be "baseline" or "full".');
+ }
+ const bounds = resolveBounds(options);
+ const budget: SearchBudget = {
+ bounds,
+ started: Date.now(),
+ checks: [],
+ unknownReasons: new Set(),
+ nodes: 0,
+ };
+ const items = puzzleItems(normalized);
+ const baseline = runCheck(budget, normalized, "baseline");
+ const solutionStatus = baseline.status;
+ const ambiguityWitness = (() => {
+ const firstSolution = baseline.solutions[0];
+ const secondSolution = baseline.solutions[1];
+ if (
+ solutionStatus !== "multiple" ||
+ firstSolution === undefined ||
+ secondSolution === undefined
+ ) {
+ return undefined;
+ }
+ return {
+ firstSolution: [...firstSolution],
+ secondSolution: [...secondSolution],
+ differences: firstSolution.flatMap((first, cell) => {
+ const second = secondSolution[cell] as number;
+ return first === second ? [] : [{ cell, first, second }];
+ }),
+ } satisfies AmbiguityWitness;
+ })();
+
+ const redundancy =
+ analysisDepth === "full"
+ ? redundancyAnalysis(budget, normalized, items, solutionStatus)
+ : { givens: [], constraints: [] };
+ const contradiction =
+ analysisDepth === "full"
+ ? localizeContradiction(budget, normalized, items, solutionStatus)
+ : {
+ status: "not-applicable" as const,
+ core: [],
+ necessary: [],
+ removable: [],
+ unknown: [],
+ reason: "Full quality analysis was not requested.",
+ };
+ const assessments = [...redundancy.givens, ...redundancy.constraints];
+ const elapsedMs = Date.now() - budget.started;
+ const result: PuzzleQualityAnalysis = {
+ analysisDepth,
+ solutionStatus,
+ ...(solutionStatus === "unique" && baseline.solutions[0] !== undefined
+ ? { solution: [...baseline.solutions[0]] }
+ : {}),
+ ...(baseline.checkIndex === undefined
+ ? {}
+ : { baselineCheckIndex: baseline.checkIndex }),
+ ...(ambiguityWitness === undefined ? {} : { ambiguityWitness }),
+ contradiction,
+ redundancy,
+ criticalityHeatmap:
+ analysisDepth === "full" ? buildHeatmap(normalized, assessments) : [],
+ ...(options.proveMinimality === true
+ ? {
+ minimality:
+ analysisDepth === "full"
+ ? minimalityFrom(solutionStatus, assessments)
+ : {
+ status: "not-applicable" as const,
+ redundant: [],
+ unknown: [],
+ reason: "A minimality proof requires full quality analysis.",
+ },
+ }
+ : {}),
+ checks: budget.checks,
+ bounds,
+ budget: {
+ checksPlanned:
+ analysisDepth === "baseline" ||
+ solutionStatus === "multiple" ||
+ solutionStatus === "unknown"
+ ? 1
+ : 1 +
+ items.length +
+ (solutionStatus === "unsatisfiable" ? items.length : 0),
+ checksPerformed: budget.checks.length,
+ nodes: budget.nodes,
+ elapsedMs,
+ truncated: budget.unknownReasons.size > 0,
+ unknownReasons: [...budget.unknownReasons],
+ },
+ };
+ return result;
+}
diff --git a/src/solver/variantGenerator.ts b/src/solver/variantGenerator.ts
index 472d6ec..3c783bf 100644
--- a/src/solver/variantGenerator.ts
+++ b/src/solver/variantGenerator.ts
@@ -15,8 +15,9 @@ import { solveExact } from "./exact";
import type { LogicalTechnique } from "./logical";
import {
generateClassic,
- minimizePuzzle,
+ minimizePuzzleWithReport,
type ClueSymmetry,
+ type MinimalGivensEvidence,
} from "./generator";
import { seededRandom, shuffled, type RandomSource } from "./random";
@@ -118,16 +119,61 @@ export const PRACTICE_TECHNIQUES = [
"naked-quad",
"hidden-quad",
"x-wing",
+ "finned-x-wing",
"xy-wing",
"xyz-wing",
"swordfish",
+ "finned-swordfish",
+ "jellyfish",
+ "skyscraper",
+ "two-string-kite",
+ "simple-colouring",
+ "w-wing",
+ "x-chain",
+ "xy-chain",
+ "aic",
+ "unique-rectangle",
"killer-cage",
] as const satisfies readonly LogicalTechnique[];
export type PracticeTechnique = (typeof PRACTICE_TECHNIQUES)[number];
+export type ConstraintDensity = "sparse" | "balanced" | "dense";
+export type GeneratedVariantKind = GeneratorVariant | "mixed";
+
+export interface TechniqueCountRequirement {
+ readonly technique: PracticeTechnique;
+ readonly min?: number;
+ readonly max?: number;
+}
+
+export interface TechniqueProfile {
+ readonly required?: readonly PracticeTechnique[];
+ readonly forbidden?: readonly PracticeTechnique[];
+ readonly counts?: readonly TechniqueCountRequirement[];
+ readonly hardestTechnique?: PracticeTechnique;
+}
+
+export interface TechniqueRequirementEvidence {
+ readonly technique: PracticeTechnique;
+ readonly actual: number;
+ readonly minimum?: number;
+ readonly maximum?: number;
+ readonly matched: boolean;
+}
+
+export interface TechniqueProfileEvidence {
+ readonly status: "matched" | "not-matched";
+ readonly completePath: boolean;
+ readonly requirements: readonly TechniqueRequirementEvidence[];
+ readonly requestedHardestTechnique?: PracticeTechnique;
+ readonly actualHardestTechnique?: LogicalTechnique;
+ readonly hardestMatched: boolean;
+}
export interface GenerateVariantOptions {
readonly variant?: GeneratorVariant;
+ /** Canonicalized set of families for mixed-variant generation. */
+ readonly variants?: readonly GeneratorVariant[];
readonly size?: number;
readonly boxRows?: number;
readonly boxColumns?: number;
@@ -136,14 +182,18 @@ export interface GenerateVariantOptions {
readonly targetDifficulty?: GenerationDifficultyTarget;
readonly targetClues?: number;
readonly symmetry?: ClueSymmetry;
+ readonly minimalGivens?: boolean;
/** Number of local markings requested. Global rules, Killer and diagonal use structural counts. */
readonly constraintCount?: number;
+ readonly constraintDensity?: ConstraintDensity;
readonly maxChecks?: number;
readonly solveMaxNodes?: number;
readonly solveTimeoutMs?: number;
readonly difficulty?: DifficultyOptions;
/** Require the independently rated logical path to use this technique. */
readonly requiredTechnique?: PracticeTechnique;
+ /** Full independently checked logical-path profile. */
+ readonly techniqueProfile?: TechniqueProfile;
/** Bounded deterministic attempts used to find the requested technique. */
readonly maxTechniqueAttempts?: number;
}
@@ -151,11 +201,50 @@ export interface GenerateVariantOptions {
export interface GeneratedVariantPuzzle {
readonly puzzle: NormalizedPuzzle;
readonly difficulty: DifficultyAssessment;
- readonly variant: GeneratorVariant;
+ readonly variant: GeneratedVariantKind;
+ readonly families: readonly GeneratorVariant[];
readonly seed: string | number;
readonly generatedConstraintCount: number;
readonly generationAttempts: number;
+ readonly constraintDensity: ConstraintDensity;
+ readonly minimality: MinimalGivensEvidence;
readonly requestedTechnique?: PracticeTechnique;
+ readonly techniqueProfile?: TechniqueProfileEvidence;
+}
+
+export type BatchRanking = "difficulty" | "fewest-givens" | "most-givens";
+
+export interface GenerateVariantBatchOptions extends GenerateVariantOptions {
+ readonly batchSize?: number;
+ readonly ranking?: BatchRanking;
+}
+
+export interface GeneratedPuzzleSummary {
+ readonly rank: number;
+ readonly seed: string | number;
+ readonly families: readonly GeneratorVariant[];
+ readonly clueCount: number;
+ readonly constraintCount: number;
+ readonly score: number | null;
+ readonly level: DifficultyAssessment["level"];
+ readonly minimalityStatus: MinimalGivensEvidence["status"];
+ readonly profileStatus?: TechniqueProfileEvidence["status"];
+}
+
+export interface BatchGenerationFailure {
+ readonly seed: string | number;
+ readonly message: string;
+}
+
+export interface GeneratedVariantBatch {
+ readonly entries: readonly GeneratedVariantPuzzle[];
+ readonly summaries: readonly GeneratedPuzzleSummary[];
+ readonly failures: readonly BatchGenerationFailure[];
+ readonly requested: number;
+ readonly completed: number;
+ readonly truncated: boolean;
+ readonly ranking: BatchRanking;
+ readonly baseSeed: string | number;
}
const DEFAULT_CONSTRAINT_COUNTS: Readonly<
@@ -229,6 +318,47 @@ function getDefinition(variant: string) {
return GENERATOR_VARIANTS.find((definition) => definition.id === variant);
}
+function canonicalFamilies(
+ options: GenerateVariantOptions,
+): GeneratorVariant[] {
+ const requested =
+ options.variants === undefined
+ ? [options.variant ?? "classic"]
+ : [...options.variants];
+ if (requested.length === 0 || requested.length > GENERATOR_VARIANTS.length) {
+ throw new RangeError(
+ `variants must contain 1 to ${String(GENERATOR_VARIANTS.length)} supported families.`,
+ );
+ }
+ const requestedSet = new Set(requested);
+ for (const variant of requestedSet) {
+ if (getDefinition(variant) === undefined) {
+ throw new RangeError(
+ `Unsupported generator variant: ${String(variant)}.`,
+ );
+ }
+ }
+ return GENERATOR_VARIANTS.map(({ id }) => id).filter((id) =>
+ requestedSet.has(id),
+ );
+}
+
+function validatedDensity(
+ value: ConstraintDensity | undefined,
+): ConstraintDensity {
+ const density = value ?? "balanced";
+ if (density !== "sparse" && density !== "balanced" && density !== "dense") {
+ throw new RangeError(
+ 'constraintDensity must be "sparse", "balanced" or "dense".',
+ );
+ }
+ return density;
+}
+
+function densityMultiplier(density: ConstraintDensity): number {
+ return density === "sparse" ? 0.55 : density === "dense" ? 1.55 : 1;
+}
+
function clueTarget(size: number, target: GenerationDifficultyTarget): number {
const ratio: Record = {
beginner: 0.58,
@@ -240,6 +370,171 @@ function clueTarget(size: number, target: GenerationDifficultyTarget): number {
return Math.max(size, Math.round(size * size * ratio[target]));
}
+function isPracticeTechnique(value: unknown): value is PracticeTechnique {
+ return (
+ typeof value === "string" &&
+ (PRACTICE_TECHNIQUES as readonly string[]).includes(value)
+ );
+}
+
+interface NormalizedTechniqueProfile {
+ readonly requirements: readonly {
+ readonly technique: PracticeTechnique;
+ readonly minimum?: number;
+ readonly maximum?: number;
+ }[];
+ readonly hardestTechnique?: PracticeTechnique;
+}
+
+function techniqueBound(
+ value: number | undefined,
+ name: string,
+): number | undefined {
+ if (value === undefined) return undefined;
+ return boundedInteger(value, 0, 0, 10_000, name);
+}
+
+function normalizeTechniqueProfile(
+ profile: TechniqueProfile | undefined,
+ legacyRequired?: PracticeTechnique,
+): NormalizedTechniqueProfile | undefined {
+ if (legacyRequired !== undefined && !isPracticeTechnique(legacyRequired)) {
+ throw new RangeError(
+ `Unsupported practice technique: ${String(legacyRequired)}.`,
+ );
+ }
+ const requirements = new Map<
+ PracticeTechnique,
+ { minimum?: number; maximum?: number }
+ >();
+ const ensure = (technique: unknown) => {
+ if (!isPracticeTechnique(technique)) {
+ throw new RangeError(
+ `Unsupported practice technique: ${String(technique)}.`,
+ );
+ }
+ const current = requirements.get(technique) ?? {};
+ requirements.set(technique, current);
+ return current;
+ };
+ if (legacyRequired !== undefined) {
+ ensure(legacyRequired).minimum = 1;
+ }
+ for (const technique of profile?.required ?? []) {
+ const requirement = ensure(technique);
+ requirement.minimum = Math.max(requirement.minimum ?? 0, 1);
+ }
+ for (const technique of profile?.forbidden ?? []) {
+ const requirement = ensure(technique);
+ requirement.maximum = Math.min(requirement.maximum ?? 0, 0);
+ }
+ const seenCounts = new Set();
+ for (const count of profile?.counts ?? []) {
+ const requirement = ensure(count.technique);
+ if (seenCounts.has(count.technique)) {
+ throw new RangeError(
+ `Technique count profile repeats ${count.technique}.`,
+ );
+ }
+ seenCounts.add(count.technique);
+ const minimum = techniqueBound(
+ count.min,
+ `techniqueProfile.counts.${count.technique}.min`,
+ );
+ const maximum = techniqueBound(
+ count.max,
+ `techniqueProfile.counts.${count.technique}.max`,
+ );
+ if (minimum === undefined && maximum === undefined) {
+ throw new RangeError(
+ `Technique count profile for ${count.technique} needs min or max.`,
+ );
+ }
+ if (minimum !== undefined) {
+ requirement.minimum = Math.max(requirement.minimum ?? 0, minimum);
+ }
+ if (maximum !== undefined) {
+ requirement.maximum = Math.min(requirement.maximum ?? maximum, maximum);
+ }
+ }
+ for (const [technique, requirement] of requirements) {
+ if (
+ requirement.minimum !== undefined &&
+ requirement.maximum !== undefined &&
+ requirement.minimum > requirement.maximum
+ ) {
+ throw new RangeError(
+ `Technique profile for ${technique} has a minimum above its maximum.`,
+ );
+ }
+ }
+ const hardestTechnique = profile?.hardestTechnique;
+ if (
+ hardestTechnique !== undefined &&
+ !isPracticeTechnique(hardestTechnique)
+ ) {
+ throw new RangeError(
+ `Unsupported hardest technique: ${String(hardestTechnique)}.`,
+ );
+ }
+ if (requirements.size === 0 && hardestTechnique === undefined) {
+ return undefined;
+ }
+ const ordered = PRACTICE_TECHNIQUES.flatMap((technique) => {
+ const requirement = requirements.get(technique);
+ return requirement === undefined ? [] : [{ technique, ...requirement }];
+ });
+ return {
+ requirements: ordered,
+ ...(hardestTechnique === undefined ? {} : { hardestTechnique }),
+ };
+}
+
+function evaluateNormalizedTechniqueProfile(
+ assessment: DifficultyAssessment,
+ profile: NormalizedTechniqueProfile,
+): TechniqueProfileEvidence {
+ const requirements = profile.requirements.map((requirement) => {
+ const actual = assessment.techniqueCounts[requirement.technique] ?? 0;
+ const matched =
+ (requirement.minimum === undefined || actual >= requirement.minimum) &&
+ (requirement.maximum === undefined || actual <= requirement.maximum);
+ return { ...requirement, actual, matched };
+ });
+ const completePath = assessment.logicalStatus === "solved";
+ const hardestMatched =
+ profile.hardestTechnique === undefined ||
+ assessment.hardestTechnique === profile.hardestTechnique;
+ return {
+ status:
+ completePath &&
+ requirements.every(({ matched }) => matched) &&
+ hardestMatched
+ ? "matched"
+ : "not-matched",
+ completePath,
+ requirements,
+ ...(profile.hardestTechnique === undefined
+ ? {}
+ : { requestedHardestTechnique: profile.hardestTechnique }),
+ ...(assessment.hardestTechnique === undefined
+ ? {}
+ : { actualHardestTechnique: assessment.hardestTechnique }),
+ hardestMatched,
+ };
+}
+
+export function evaluateTechniqueProfile(
+ assessment: DifficultyAssessment,
+ profile: TechniqueProfile,
+): TechniqueProfileEvidence {
+ const normalized = normalizeTechniqueProfile(profile);
+ if (normalized === undefined) {
+ throw new RangeError("Technique profile must contain a requirement.");
+ }
+ return evaluateNormalizedTechniqueProfile(assessment, normalized);
+}
+
function allEdges(size: number): Array {
const edges: Array = [];
for (let cell = 0; cell < size * size; cell += 1) {
@@ -285,6 +580,7 @@ function killerCages(
size: number,
solution: readonly number[],
random: RandomSource,
+ density: ConstraintDensity,
): VariantConstraint[] {
const unassigned = new Set(
Array.from({ length: size * size }, (_, cell) => cell),
@@ -292,7 +588,16 @@ function killerCages(
const constraints: VariantConstraint[] = [];
for (const start of shuffled([...unassigned], random)) {
if (!unassigned.has(start)) continue;
- const target = 2 + Math.floor(random() * Math.min(3, size - 1));
+ const minimumTarget = density === "sparse" ? Math.min(3, size) : 2;
+ const maximumTarget =
+ density === "dense"
+ ? Math.min(3, size)
+ : density === "sparse"
+ ? Math.min(5, size)
+ : Math.min(4, size);
+ const target =
+ minimumTarget +
+ Math.floor(random() * (maximumTarget - minimumTarget + 1));
const cells = [start];
const digits = new Set([solution[start]]);
unassigned.delete(start);
@@ -497,8 +802,11 @@ function localConstraints(
solution: readonly number[],
count: number,
random: RandomSource,
+ density: ConstraintDensity,
): VariantConstraint[] {
- if (variant === "killer") return killerCages(size, solution, random);
+ if (variant === "killer") {
+ return killerCages(size, solution, random, density);
+ }
const candidates = (() => {
switch (variant) {
case "thermo":
@@ -527,8 +835,29 @@ function localConstraints(
);
}
+function globalConstraints(
+ families: readonly GeneratorVariant[],
+): VariantConstraint[] {
+ const constraints: VariantConstraint[] = [];
+ for (const variant of families) {
+ if (variant === "diagonal") {
+ constraints.push(
+ { type: "diagonal", direction: "main" },
+ { type: "diagonal", direction: "anti" },
+ );
+ } else if (
+ variant === "anti-knight" ||
+ variant === "anti-king" ||
+ variant === "non-consecutive"
+ ) {
+ constraints.push({ type: variant });
+ }
+ }
+ return constraints;
+}
+
function fullSolution(
- variant: GeneratorVariant,
+ families: readonly GeneratorVariant[],
size: number,
boxRows: number | undefined,
boxColumns: number | undefined,
@@ -536,12 +865,8 @@ function fullSolution(
maxNodes: number,
timeoutMs: number,
): NormalizedPuzzle {
- const globalVariant =
- variant === "diagonal" ||
- variant === "anti-knight" ||
- variant === "anti-king" ||
- variant === "non-consecutive";
- if (!globalVariant) {
+ const constraints = globalConstraints(families);
+ if (constraints.length === 0) {
return generateClassic({
size,
boxRows,
@@ -551,13 +876,6 @@ function fullSolution(
maxChecks: 1,
});
}
- const constraints: VariantConstraint[] =
- variant === "diagonal"
- ? [
- { type: "diagonal", direction: "main" },
- { type: "diagonal", direction: "anti" },
- ]
- : [{ type: variant }];
const empty: PuzzleDefinition = {
version: 1,
size,
@@ -569,17 +887,51 @@ function fullSolution(
maxSolutions: 1,
maxNodes,
timeoutMs,
- seed: `${String(seed)}:${variant}-solution`,
+ seed: `${String(seed)}:${families.join("+")}-solution`,
});
const solution = solved.solutions[0];
if (solution === undefined) {
throw new Error(
- `Could not construct a ${variant} solution within the ${String(timeoutMs)} ms / ${String(maxNodes)} node generation limit.`,
+ `Could not construct a ${families.join(" + ")} solution within the ${String(timeoutMs)} ms / ${String(maxNodes)} node generation limit.`,
);
}
return normalizePuzzle({ ...empty, givens: solution, solution });
}
+type LocalGeneratorVariant = Exclude<
+ GeneratorVariant,
+ "classic" | "diagonal" | "anti-knight" | "anti-king" | "non-consecutive"
+>;
+
+function isLocalGeneratorVariant(
+ variant: GeneratorVariant,
+): variant is LocalGeneratorVariant {
+ return (
+ variant !== "classic" &&
+ variant !== "diagonal" &&
+ variant !== "anti-knight" &&
+ variant !== "anti-king" &&
+ variant !== "non-consecutive"
+ );
+}
+
+function rulesForFamilies(families: readonly GeneratorVariant[]): string {
+ if (families.length === 1) return RULES[families[0] as GeneratorVariant];
+ return families
+ .map((family) => RULES[family])
+ .join(" ")
+ .slice(0, 16_384);
+}
+
+function labelForFamilies(families: readonly GeneratorVariant[]): string {
+ if (families.length === 1) {
+ return getDefinition(families[0] as GeneratorVariant)?.label ?? "Sudoku";
+ }
+ return `Mixed · ${families
+ .map((family) => getDefinition(family)?.label ?? family)
+ .join(" + ")}`;
+}
+
/**
* Creates a unique, seedable variant puzzle. Generation is suitable for a Web
* Worker: every exact search has explicit node/time caps and worker termination
@@ -587,17 +939,21 @@ function fullSolution(
*/
function generateVariantAttempt(
options: GenerateVariantOptions = {},
+ techniqueProfile?: NormalizedTechniqueProfile,
): GeneratedVariantPuzzle {
- const variant = options.variant ?? "classic";
- const definition = getDefinition(variant);
- if (definition === undefined) {
- throw new RangeError(`Unsupported generator variant: ${String(variant)}.`);
- }
+ const families = canonicalFamilies(options);
+ const variant: GeneratedVariantKind =
+ families.length === 1 ? (families[0] as GeneratorVariant) : "mixed";
const size = boundedInteger(options.size, 9, 4, 16, "size");
- if (!(definition.supportedSizes as readonly number[]).includes(size)) {
- throw new RangeError(
- `${definition.label} generation supports sizes ${definition.supportedSizes.join(", ")}.`,
- );
+ for (const family of families) {
+ const definition = getDefinition(
+ family,
+ ) as (typeof GENERATOR_VARIANTS)[number];
+ if (!(definition.supportedSizes as readonly number[]).includes(size)) {
+ throw new RangeError(
+ `${definition.label} generation supports sizes ${definition.supportedSizes.join(", ")}.`,
+ );
+ }
}
const seed = options.seed ?? "sudoku-tools";
const targetDifficulty = options.targetDifficulty ?? "medium";
@@ -610,7 +966,9 @@ function generateVariantAttempt(
);
const maxChecks = boundedInteger(
options.maxChecks,
- Math.min(size * size, 72),
+ options.minimalGivens === true
+ ? Math.min(size * size * 3, size * size * 10)
+ : Math.min(size * size, 72),
1,
size * size * 10,
"maxChecks",
@@ -629,23 +987,19 @@ function generateVariantAttempt(
120_000,
"solveTimeoutMs",
);
- const requestedConstraintCount = boundedInteger(
- options.constraintCount,
- variant === "classic" ||
- variant === "diagonal" ||
- variant === "anti-knight" ||
- variant === "anti-king" ||
- variant === "non-consecutive" ||
- variant === "killer"
- ? 1
- : DEFAULT_CONSTRAINT_COUNTS[variant],
- 1,
- size * size * 2,
- "constraintCount",
- );
- const random = seededRandom(`${String(seed)}:${variant}:constraints`);
+ const requestedConstraintCount =
+ options.constraintCount === undefined
+ ? undefined
+ : boundedInteger(
+ options.constraintCount,
+ 1,
+ 1,
+ size * size * 2,
+ "constraintCount",
+ );
+ const constraintDensity = validatedDensity(options.constraintDensity);
const full = fullSolution(
- variant,
+ families,
size,
options.boxRows,
options.boxColumns,
@@ -657,22 +1011,28 @@ function generateVariantAttempt(
if (solution === undefined) {
throw new Error("Internal generator error: the completed grid was lost.");
}
- const constraints: VariantConstraint[] =
- variant === "classic"
- ? []
- : variant === "diagonal" ||
- variant === "anti-knight" ||
- variant === "anti-king" ||
- variant === "non-consecutive"
- ? [...full.constraints]
- : localConstraints(
- variant,
- size,
- solution,
- requestedConstraintCount,
- random,
- );
- const title = `${definition.label} · ${String(seed)}`.slice(0, 256);
+ const constraints: VariantConstraint[] = [...full.constraints];
+ for (const family of families) {
+ if (!isLocalGeneratorVariant(family)) continue;
+ const baseCount =
+ requestedConstraintCount ??
+ (family === "killer" ? 1 : DEFAULT_CONSTRAINT_COUNTS[family]);
+ const familyCount = Math.max(
+ 1,
+ Math.round(baseCount * densityMultiplier(constraintDensity)),
+ );
+ constraints.push(
+ ...localConstraints(
+ family,
+ size,
+ solution,
+ familyCount,
+ seededRandom(`${String(seed)}:${family}:constraints`),
+ constraintDensity,
+ ),
+ );
+ }
+ const title = `${labelForFamilies(families)} · ${String(seed)}`.slice(0, 256);
const complete = normalizePuzzle({
version: 1,
size,
@@ -682,29 +1042,41 @@ function generateVariantAttempt(
solution,
title,
author: "Sudoku Tools generator",
- rules: RULES[variant],
+ rules: rulesForFamilies(families),
});
- const puzzle = minimizePuzzle(complete, {
- seed: `${String(seed)}:${variant}:clues`,
+ const minimized = minimizePuzzleWithReport(complete, {
+ seed: `${String(seed)}:${families.join("+")}:clues`,
targetClues,
symmetry: options.symmetry ?? "rotational",
maxChecks,
solveMaxNodes,
solveTimeoutMs,
+ minimalGivens: options.minimalGivens,
});
+ const puzzle = minimized.puzzle;
const difficulty = evaluateDifficulty(puzzle, options.difficulty);
if (difficulty.uniqueness !== "unique") {
throw new Error(
"Generated puzzle did not pass the bounded uniqueness audit; no puzzle was returned.",
);
}
+ const profileEvidence =
+ techniqueProfile === undefined
+ ? undefined
+ : evaluateNormalizedTechniqueProfile(difficulty, techniqueProfile);
return {
puzzle,
difficulty,
variant,
+ families,
seed,
generatedConstraintCount: constraints.length,
generationAttempts: 1,
+ constraintDensity,
+ minimality: minimized.minimality,
+ ...(profileEvidence === undefined
+ ? {}
+ : { techniqueProfile: profileEvidence }),
};
}
@@ -716,15 +1088,18 @@ export function generateVariant(
options: GenerateVariantOptions = {},
): GeneratedVariantPuzzle {
const requiredTechnique = options.requiredTechnique;
- if (
- requiredTechnique !== undefined &&
- !(PRACTICE_TECHNIQUES as readonly string[]).includes(requiredTechnique)
- ) {
- throw new RangeError(
- `Unsupported practice technique: ${String(requiredTechnique)}.`,
- );
- }
- if (requiredTechnique === "killer-cage" && options.variant !== "killer") {
+ const profile = normalizeTechniqueProfile(
+ options.techniqueProfile,
+ requiredTechnique,
+ );
+ const families = canonicalFamilies(options);
+ const requiresKiller =
+ profile?.hardestTechnique === "killer-cage" ||
+ profile?.requirements.some(
+ ({ technique, minimum }) =>
+ technique === "killer-cage" && (minimum ?? 0) > 0,
+ ) === true;
+ if (requiresKiller && !families.includes("killer")) {
throw new RangeError(
"Killer-cage practice requires the Killer generator variant.",
);
@@ -732,7 +1107,7 @@ export function generateVariant(
const maxAttempts = boundedInteger(
options.maxTechniqueAttempts,
- requiredTechnique === undefined ? 1 : 10,
+ profile === undefined ? 1 : 10,
1,
32,
"maxTechniqueAttempts",
@@ -742,11 +1117,14 @@ export function generateVariant(
const seed =
attempt === 0
? baseSeed
- : `${String(baseSeed)}:practice:${String(requiredTechnique)}:${String(attempt + 1)}`;
- const generated = generateVariantAttempt({ ...options, seed });
+ : options.techniqueProfile === undefined &&
+ requiredTechnique !== undefined
+ ? `${String(baseSeed)}:practice:${String(requiredTechnique)}:${String(attempt + 1)}`
+ : `${String(baseSeed)}:profile:${String(attempt + 1)}`;
+ const generated = generateVariantAttempt({ ...options, seed }, profile);
if (
- requiredTechnique === undefined ||
- (generated.difficulty.techniqueCounts[requiredTechnique] ?? 0) > 0
+ profile === undefined ||
+ generated.techniqueProfile?.status === "matched"
) {
return {
...generated,
@@ -759,6 +1137,80 @@ export function generateVariant(
}
throw new Error(
- `No uniquely checked puzzle using ${requiredTechnique?.replaceAll("-", " ")} was found in ${String(maxAttempts)} deterministic attempts. Try a different seed, difficulty profile, or a larger attempt limit.`,
+ `No uniquely checked puzzle matching the requested technique profile was found in ${String(maxAttempts)} deterministic attempts. Try a different seed, profile, or a larger attempt limit.`,
);
}
+
+function rankingComparator(ranking: BatchRanking) {
+ return (
+ left: GeneratedVariantPuzzle,
+ right: GeneratedVariantPuzzle,
+ ): number => {
+ const leftClues = left.puzzle.givens.filter(Boolean).length;
+ const rightClues = right.puzzle.givens.filter(Boolean).length;
+ if (ranking === "fewest-givens" && leftClues !== rightClues) {
+ return leftClues - rightClues;
+ }
+ if (ranking === "most-givens" && leftClues !== rightClues) {
+ return rightClues - leftClues;
+ }
+ const scoreDifference =
+ (right.difficulty.score ?? -1) - (left.difficulty.score ?? -1);
+ if (scoreDifference !== 0) return scoreDifference;
+ if (leftClues !== rightClues) return leftClues - rightClues;
+ return String(left.seed).localeCompare(String(right.seed));
+ };
+}
+
+/** Generates a deterministic bounded batch and ranks only verified results. */
+export function generateVariantBatch(
+ options: GenerateVariantBatchOptions = {},
+): GeneratedVariantBatch {
+ const batchSize = boundedInteger(options.batchSize, 4, 1, 12, "batchSize");
+ const ranking = options.ranking ?? "difficulty";
+ if (
+ ranking !== "difficulty" &&
+ ranking !== "fewest-givens" &&
+ ranking !== "most-givens"
+ ) {
+ throw new RangeError(`Unsupported batch ranking: ${String(ranking)}.`);
+ }
+ const baseSeed = options.seed ?? "sudoku-tools";
+ const entries: GeneratedVariantPuzzle[] = [];
+ const failures: BatchGenerationFailure[] = [];
+ for (let index = 0; index < batchSize; index += 1) {
+ const seed = `${String(baseSeed)}:batch:${String(index + 1)}`;
+ try {
+ entries.push(generateVariant({ ...options, seed }));
+ } catch (error) {
+ failures.push({
+ seed,
+ message: error instanceof Error ? error.message : "Generation failed.",
+ });
+ }
+ }
+ entries.sort(rankingComparator(ranking));
+ const summaries = entries.map((entry, index): GeneratedPuzzleSummary => ({
+ rank: index + 1,
+ seed: entry.seed,
+ families: entry.families,
+ clueCount: entry.puzzle.givens.filter(Boolean).length,
+ constraintCount: entry.generatedConstraintCount,
+ score: entry.difficulty.score,
+ level: entry.difficulty.level,
+ minimalityStatus: entry.minimality.status,
+ ...(entry.techniqueProfile === undefined
+ ? {}
+ : { profileStatus: entry.techniqueProfile.status }),
+ }));
+ return {
+ entries,
+ summaries,
+ failures,
+ requested: batchSize,
+ completed: entries.length,
+ truncated: failures.length > 0,
+ ranking,
+ baseSeed,
+ };
+}
diff --git a/src/state/aidMemoire.ts b/src/state/aidMemoire.ts
index 9981b4a..18e8a57 100644
--- a/src/state/aidMemoire.ts
+++ b/src/state/aidMemoire.ts
@@ -1,4 +1,5 @@
import { maskValues, symbolFor, type EntryMode } from "./session";
+import { colorMarkDescription } from "./uiPreferences";
export const AID_MEMOIRE_VERSION = 1 as const;
export const MAX_AID_MEMOIRE_CELLS = 36;
@@ -260,7 +261,11 @@ export function aidMemoireCellDescription(
`centre marks ${center.map((value) => symbolFor(value, size)).join(", ")}`,
);
}
- if (cell.color) parts.push(`colour ${String(cell.color)}`);
+ if (cell.color) {
+ parts.push(
+ `colour ${String(cell.color)}: ${colorMarkDescription(cell.color)}`,
+ );
+ }
return parts.join(", ");
}
diff --git a/src/state/candidateMaintenance.ts b/src/state/candidateMaintenance.ts
new file mode 100644
index 0000000..5e07e4b
--- /dev/null
+++ b/src/state/candidateMaintenance.ts
@@ -0,0 +1,213 @@
+import {
+ allCandidates,
+ type CompiledPuzzle,
+ type NormalizedPuzzle,
+ type PuzzleDefinition,
+} from "../domain";
+import type { LogicalStep } from "../solver/logical";
+import type { PlaySession } from "./session";
+
+export type CandidatePuzzle =
+ PuzzleDefinition | NormalizedPuzzle | CompiledPuzzle;
+
+export interface NoteMaintenanceOptions {
+ /** Maintain small/corner pencil marks. Defaults to true. */
+ readonly cornerMarks?: boolean;
+ /** Maintain central candidate marks. Defaults to true. */
+ readonly centerMarks?: boolean;
+}
+
+function sizeOf(puzzle: CandidatePuzzle): number {
+ return "puzzle" in puzzle ? puzzle.puzzle.size : puzzle.size;
+}
+
+function cloneSession(session: PlaySession): PlaySession {
+ return {
+ ...session,
+ values: [...session.values],
+ cornerMarks: [...session.cornerMarks],
+ centerMarks: [...session.centerMarks],
+ colors: [...session.colors],
+ };
+}
+
+function assertSessionShape(
+ session: PlaySession,
+ puzzle: CandidatePuzzle,
+ label = "session",
+): number {
+ const size = sizeOf(puzzle);
+ const cells = size * size;
+ for (const [name, values] of [
+ ["values", session.values],
+ ["cornerMarks", session.cornerMarks],
+ ["centerMarks", session.centerMarks],
+ ["colors", session.colors],
+ ] as const) {
+ if (values.length !== cells) {
+ throw new RangeError(
+ `${label}.${name} must contain exactly ${String(cells)} entries`,
+ );
+ }
+ }
+ return size;
+}
+
+function valuesMask(values: readonly number[], size: number): number {
+ let mask = 0;
+ for (const value of values) {
+ if (Number.isInteger(value) && value >= 1 && value <= size) {
+ mask |= 1 << (value - 1);
+ }
+ }
+ return mask;
+}
+
+function legalMasks(
+ session: PlaySession,
+ puzzle: CandidatePuzzle,
+ size: number,
+): number[] {
+ return allCandidates(puzzle, session.values).map((values) =>
+ valuesMask(values, size),
+ );
+}
+
+function pruneWithOptions(
+ session: PlaySession,
+ puzzle: CandidatePuzzle,
+ options: NoteMaintenanceOptions,
+): PlaySession {
+ const size = assertSessionShape(session, puzzle);
+ const allowed = legalMasks(session, puzzle, size);
+ const next = cloneSession(session);
+ if (options.cornerMarks !== false) {
+ next.cornerMarks = next.cornerMarks.map(
+ (mask, cell) => mask & (allowed[cell] ?? 0),
+ );
+ }
+ if (options.centerMarks !== false) {
+ next.centerMarks = next.centerMarks.map(
+ (mask, cell) => mask & (allowed[cell] ?? 0),
+ );
+ }
+ return next;
+}
+
+/**
+ * Replace every center-mark set with the candidates that are legal in the
+ * current position. Filled cells receive no center marks. Other session state,
+ * including deliberately entered corner marks, is retained unchanged.
+ */
+export function fillLegalCenterCandidates(
+ session: PlaySession,
+ puzzle: CandidatePuzzle,
+): PlaySession {
+ const size = assertSessionShape(session, puzzle);
+ const next = cloneSession(session);
+ next.centerMarks = legalMasks(session, puzzle, size);
+ return next;
+}
+
+/**
+ * Remove notes that cannot currently be placed according to all active Sudoku
+ * constraints. This never adds a note; use fillLegalCenterCandidates when a
+ * complete center-candidate grid is desired.
+ */
+export function pruneInvalidNotes(
+ session: PlaySession,
+ puzzle: CandidatePuzzle,
+ options: NoteMaintenanceOptions = {},
+): PlaySession {
+ return pruneWithOptions(session, puzzle, options);
+}
+
+/**
+ * Prune selected note kinds after one or more values have just been placed.
+ * Erasures alone do not add candidates or otherwise rewrite notes. Both input
+ * sessions remain untouched, and the returned session is detached from them.
+ */
+export function autoRemoveNotesAfterPlacements(
+ previous: PlaySession,
+ nextSession: PlaySession,
+ puzzle: CandidatePuzzle,
+ options: NoteMaintenanceOptions = {},
+): PlaySession {
+ assertSessionShape(previous, puzzle, "previous");
+ assertSessionShape(nextSession, puzzle, "nextSession");
+ const hasPlacement = nextSession.values.some(
+ (value, cell) => value !== 0 && value !== previous.values[cell],
+ );
+ return hasPlacement
+ ? pruneWithOptions(nextSession, puzzle, options)
+ : cloneSession(nextSession);
+}
+
+function assertCell(cell: number, cells: number, path: string): void {
+ if (!Number.isInteger(cell) || cell < 0 || cell >= cells) {
+ throw new RangeError(
+ `${path} must identify a cell from 0 to ${String(cells - 1)}`,
+ );
+ }
+}
+
+/**
+ * Apply a solver-produced logical step as one immutable session transition.
+ * Placements update values and clear notes in the placed cells. Existing center
+ * candidates are pruned after placements, then the step's explicit candidate
+ * eliminations are removed. Empty center-mark sets stay empty: callers that
+ * want a complete tracked candidate grid should call fillLegalCenterCandidates
+ * before applying the first elimination. Corner marks outside placed cells are
+ * intentionally left alone.
+ */
+export function applyLogicalStepToSession(
+ session: PlaySession,
+ step: LogicalStep,
+ puzzle: CandidatePuzzle,
+): PlaySession {
+ const size = assertSessionShape(session, puzzle);
+ const cells = size * size;
+ const next = cloneSession(session);
+
+ for (const [index, placement] of step.placements.entries()) {
+ assertCell(placement.cell, cells, `placements[${String(index)}].cell`);
+ if (
+ !Number.isInteger(placement.value) ||
+ placement.value < 1 ||
+ placement.value > size
+ ) {
+ throw new RangeError(
+ `placements[${String(index)}].value must be from 1 to ${String(size)}`,
+ );
+ }
+ if (next.values[placement.cell] !== 0) {
+ throw new RangeError(
+ `placements[${String(index)}] targets a filled cell`,
+ );
+ }
+ next.values[placement.cell] = placement.value;
+ next.cornerMarks[placement.cell] = 0;
+ next.centerMarks[placement.cell] = 0;
+ }
+
+ const maintained = autoRemoveNotesAfterPlacements(session, next, puzzle, {
+ cornerMarks: false,
+ centerMarks: true,
+ });
+
+ for (const [index, elimination] of step.eliminations.entries()) {
+ assertCell(elimination.cell, cells, `eliminations[${String(index)}].cell`);
+ let mask = maintained.centerMarks[elimination.cell] ?? 0;
+ for (const value of elimination.values) {
+ if (!Number.isInteger(value) || value < 1 || value > size) {
+ throw new RangeError(
+ `eliminations[${String(index)}].values must be from 1 to ${String(size)}`,
+ );
+ }
+ mask &= ~(1 << (value - 1));
+ }
+ maintained.centerMarks[elimination.cell] = mask;
+ }
+
+ return maintained;
+}
diff --git a/src/state/playHistory.ts b/src/state/playHistory.ts
index 86f3867..d5dbd13 100644
--- a/src/state/playHistory.ts
+++ b/src/state/playHistory.ts
@@ -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 {
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
+ throw new TypeError(`${label} must be an object.`);
+ }
+ return value as Record;
+}
+
+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 {
diff --git a/src/state/uiPreferences.ts b/src/state/uiPreferences.ts
new file mode 100644
index 0000000..8aae204
--- /dev/null
+++ b/src/state/uiPreferences.ts
@@ -0,0 +1,70 @@
+export type CandidateVerbosity = "off" | "concise" | "detailed";
+
+export const BOARD_SCALE_MIN = 0.75;
+export const BOARD_SCALE_MAX = 2;
+export const BOARD_SCALE_STEP = 0.25;
+export const BOARD_SCALE_STORAGE_KEY = "sudoku-tools:board-scale:v1";
+
+export const COLOR_MARK_DESCRIPTIONS = [
+ "red, diagonal stripes",
+ "orange, reverse diagonal stripes",
+ "yellow, dots",
+ "green, crosshatch",
+ "teal, horizontal bars",
+ "blue, vertical bars",
+ "purple, checkerboard",
+ "pink, rings",
+] as const;
+
+export function normalizeBoardScale(value: unknown): number {
+ const numeric = typeof value === "number" ? value : Number(value);
+ if (!Number.isFinite(numeric)) return 1;
+ const clamped = Math.min(BOARD_SCALE_MAX, Math.max(BOARD_SCALE_MIN, numeric));
+ return Math.round(clamped / BOARD_SCALE_STEP) * BOARD_SCALE_STEP;
+}
+
+function browserStorage(): Storage | undefined {
+ if (typeof window === "undefined") return undefined;
+ try {
+ return window.localStorage;
+ } catch {
+ return undefined;
+ }
+}
+
+export function readStoredBoardScale(
+ storage: Pick | undefined = browserStorage(),
+): number {
+ if (storage === undefined) return 1;
+ try {
+ const value = storage.getItem(BOARD_SCALE_STORAGE_KEY);
+ return value === null ? 1 : normalizeBoardScale(value);
+ } catch {
+ return 1;
+ }
+}
+
+export function writeStoredBoardScale(
+ scale: number,
+ storage: Pick | undefined = browserStorage(),
+): void {
+ if (storage === undefined) return;
+ try {
+ storage.setItem(
+ BOARD_SCALE_STORAGE_KEY,
+ String(normalizeBoardScale(scale)),
+ );
+ } catch {
+ // A blocked or full storage area must not make the local board unusable.
+ }
+}
+
+export function parseCandidateVerbosity(value: unknown): CandidateVerbosity {
+ return value === "off" || value === "concise" || value === "detailed"
+ ? value
+ : "detailed";
+}
+
+export function colorMarkDescription(value: number): string {
+ return COLOR_MARK_DESCRIPTIONS[value - 1] ?? `mark ${String(value)}`;
+}
diff --git a/src/storage/library.ts b/src/storage/library.ts
index ee40941..953c791 100644
--- a/src/storage/library.ts
+++ b/src/storage/library.ts
@@ -1,19 +1,23 @@
import { SudokuFormatError } from "../formats";
import {
cloneProjectRecord,
+ createProjectRecord,
MAX_PROJECT_BYTES,
normalizeProjectRecord,
} from "./record";
import type {
ProjectLibraryExport,
+ ProjectLibraryQuery,
SudokuProjectRecord,
SudokuProjectSummary,
} from "./types";
export const MAX_LIBRARY_PROJECTS = 256;
export const MAX_MEMORY_LIBRARY_BYTES = 32 * 1_048_576;
-const DATABASE_VERSION = 1;
+const DATABASE_VERSION = 2;
const STORE_NAME = "projects";
+const AUTOSAVE_STORE_NAME = "autosaves";
+const DEFAULT_AUTOSAVE_SLOT = "current";
export type ProjectLibraryMode = "indexeddb" | "memory";
@@ -30,9 +34,34 @@ function summary(record: SudokuProjectRecord): SudokuProjectSummary {
updatedAt: record.updatedAt,
size: record.puzzle.size,
completed: record.progress?.completed ?? false,
+ tags: [...(record.tags ?? [])],
+ thumbnail:
+ record.thumbnail ??
+ record.puzzle.givens
+ .map((value) => (value === 0 ? "." : String(value)))
+ .join(""),
};
}
+function matchesQuery(
+ item: SudokuProjectSummary,
+ query: ProjectLibraryQuery,
+): boolean {
+ const search = query.search?.trim().toLocaleLowerCase();
+ if (
+ search &&
+ !item.title.toLocaleLowerCase().includes(search) &&
+ !item.tags.some((tag) => tag.toLocaleLowerCase().includes(search))
+ ) {
+ return false;
+ }
+ const tags = query.tags?.filter(Boolean) ?? [];
+ if (tags.length > 0 && !tags.every((tag) => item.tags.includes(tag))) {
+ return false;
+ }
+ return query.completed === undefined || item.completed === query.completed;
+}
+
function bytes(record: SudokuProjectRecord): number {
return new TextEncoder().encode(JSON.stringify(record)).byteLength;
}
@@ -63,10 +92,21 @@ async function openDatabase(
const request = factory.open(name, DATABASE_VERSION);
request.onupgradeneeded = () => {
const database = request.result;
- if (!database.objectStoreNames.contains(STORE_NAME)) {
- const store = database.createObjectStore(STORE_NAME, { keyPath: "id" });
+ const store = database.objectStoreNames.contains(STORE_NAME)
+ ? request.transaction!.objectStore(STORE_NAME)
+ : database.createObjectStore(STORE_NAME, { keyPath: "id" });
+ if (!store.indexNames.contains("updatedAt")) {
store.createIndex("updatedAt", "updatedAt");
}
+ if (!store.indexNames.contains("title")) {
+ store.createIndex("title", "title");
+ }
+ if (!store.indexNames.contains("tags")) {
+ store.createIndex("tags", "tags", { multiEntry: true });
+ }
+ if (!database.objectStoreNames.contains(AUTOSAVE_STORE_NAME)) {
+ database.createObjectStore(AUTOSAVE_STORE_NAME, { keyPath: "slot" });
+ }
};
request.onsuccess = () => resolve(request.result);
request.onerror = () =>
@@ -80,6 +120,7 @@ export class ProjectLibrary {
readonly #factory: IDBFactory | null;
readonly #databaseName: string;
readonly #memory = new Map();
+ readonly #memoryAutosaves = new Map();
#database: Promise | undefined;
#mode: ProjectLibraryMode;
@@ -143,11 +184,14 @@ export class ProjectLibrary {
this.#memory.set(record.id, cloneProjectRecord(record));
}
- async list(): Promise {
+ async list(
+ query: ProjectLibraryQuery = {},
+ ): Promise {
const database = await this.#db();
if (database === undefined) {
return [...this.#memory.values()]
.map(summary)
+ .filter((item) => matchesQuery(item, query))
.sort(
(a, b) => b.updatedAt - a.updatedAt || a.title.localeCompare(b.title),
);
@@ -166,13 +210,14 @@ export class ProjectLibrary {
}
return records
.map((record) => summary(normalizeProjectRecord(record)))
+ .filter((item) => matchesQuery(item, query))
.sort(
(a, b) => b.updatedAt - a.updatedAt || a.title.localeCompare(b.title),
);
} catch (error) {
if (error instanceof SudokuFormatError) throw error;
this.#mode = "memory";
- return this.list();
+ return this.list(query);
}
}
@@ -264,6 +309,82 @@ export class ProjectLibrary {
}
}
+ /** Persist the latest working state separately from explicit Library saves. */
+ async putAutosave(
+ value: SudokuProjectRecord,
+ slot = DEFAULT_AUTOSAVE_SLOT,
+ ): Promise {
+ if (!slot || slot.length > 128) {
+ throw new SudokuFormatError(
+ "INVALID_PROJECT",
+ "Autosave slot is invalid.",
+ );
+ }
+ const record = normalizeProjectRecord(value);
+ const database = await this.#db();
+ if (database === undefined) {
+ this.#memoryAutosaves.set(slot, cloneProjectRecord(record));
+ return cloneProjectRecord(record);
+ }
+ try {
+ const transaction = database.transaction(
+ AUTOSAVE_STORE_NAME,
+ "readwrite",
+ );
+ transaction.objectStore(AUTOSAVE_STORE_NAME).put({ slot, record });
+ await transactionDone(transaction);
+ return cloneProjectRecord(record);
+ } catch {
+ this.#mode = "memory";
+ this.#memoryAutosaves.set(slot, cloneProjectRecord(record));
+ return cloneProjectRecord(record);
+ }
+ }
+
+ async getAutosave(
+ slot = DEFAULT_AUTOSAVE_SLOT,
+ ): Promise {
+ const database = await this.#db();
+ if (database === undefined) {
+ const record = this.#memoryAutosaves.get(slot);
+ return record === undefined ? undefined : cloneProjectRecord(record);
+ }
+ try {
+ const transaction = database.transaction(AUTOSAVE_STORE_NAME, "readonly");
+ const value = await requestResult(
+ transaction.objectStore(AUTOSAVE_STORE_NAME).get(slot),
+ );
+ await transactionDone(transaction);
+ if (typeof value !== "object" || value === null || !("record" in value)) {
+ return undefined;
+ }
+ return normalizeProjectRecord((value as { record: unknown }).record);
+ } catch (error) {
+ if (error instanceof SudokuFormatError) throw error;
+ this.#mode = "memory";
+ return this.getAutosave(slot);
+ }
+ }
+
+ async clearAutosave(slot = DEFAULT_AUTOSAVE_SLOT): Promise {
+ const database = await this.#db();
+ if (database === undefined) {
+ this.#memoryAutosaves.delete(slot);
+ return;
+ }
+ try {
+ const transaction = database.transaction(
+ AUTOSAVE_STORE_NAME,
+ "readwrite",
+ );
+ transaction.objectStore(AUTOSAVE_STORE_NAME).delete(slot);
+ await transactionDone(transaction);
+ } catch {
+ this.#mode = "memory";
+ this.#memoryAutosaves.delete(slot);
+ }
+ }
+
async exportAll(): Promise {
const summaries = await this.list();
const projects: SudokuProjectRecord[] = [];
@@ -279,6 +400,51 @@ export class ProjectLibrary {
};
}
+ async exportSelected(ids: readonly string[]): Promise {
+ const unique = [...new Set(ids)].slice(0, MAX_LIBRARY_PROJECTS);
+ const projects: SudokuProjectRecord[] = [];
+ for (const id of unique) {
+ const record = await this.get(id);
+ if (record !== undefined) projects.push(record);
+ }
+ return {
+ schema: "de.add-ideas.sudoku-tools.library",
+ version: 1,
+ exportedAt: Date.now(),
+ projects,
+ };
+ }
+
+ /** Create independent local copies without reusing source record IDs. */
+ async duplicateSelected(ids: readonly string[]): Promise {
+ const unique = [...new Set(ids)].slice(0, MAX_LIBRARY_PROJECTS);
+ const sources: SudokuProjectRecord[] = [];
+ for (const id of unique) {
+ const source = await this.get(id);
+ if (source !== undefined) sources.push(source);
+ }
+ if ((await this.list()).length + sources.length > MAX_LIBRARY_PROJECTS) {
+ throw new SudokuFormatError(
+ "STORAGE_LIMIT",
+ `Copying this selection would exceed the ${String(MAX_LIBRARY_PROJECTS)}-project limit.`,
+ );
+ }
+ let copied = 0;
+ for (const source of sources) {
+ const now = Date.now() + copied;
+ await this.put(
+ createProjectRecord(source.puzzle, {
+ title: `${source.title || "Untitled puzzle"} copy`,
+ progress: source.progress,
+ tags: source.tags,
+ now,
+ }),
+ );
+ copied += 1;
+ }
+ return copied;
+ }
+
async importAll(value: unknown, replace = false): Promise {
if (
typeof value !== "object" ||
diff --git a/src/storage/record.ts b/src/storage/record.ts
index 13f956a..05ad0e9 100644
--- a/src/storage/record.ts
+++ b/src/storage/record.ts
@@ -8,6 +8,8 @@ import {
normalizePortableAidMemoire,
type PortableAidMemoire,
} from "../state/aidMemoire";
+import { parseGameplayHistory } from "../state/playHistory";
+import { symbolFor } from "../state/session";
import {
PROJECT_RECORD_SCHEMA,
PROJECT_RECORD_VERSION,
@@ -18,6 +20,8 @@ import {
export const MAX_PROJECT_BYTES = MAX_DOCUMENT_BYTES * 2;
export const MAX_PROJECT_ID_LENGTH = 128;
export const MAX_PROJECT_TITLE_LENGTH = 500;
+export const MAX_PROJECT_TAGS = 20;
+export const MAX_PROJECT_TAG_LENGTH = 40;
function isRecord(value: unknown): value is Record {
return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -143,6 +147,26 @@ function progress(value: unknown, size: number): SudokuProgress | undefined {
);
}
}
+ let gameplayHistory: string | undefined;
+ if (value.gameplayHistory !== undefined) {
+ if (typeof value.gameplayHistory !== "string") {
+ return storageError(
+ "INVALID_PROJECT",
+ "Progress gameplayHistory must be serialized text.",
+ );
+ }
+ try {
+ void parseGameplayHistory(value.gameplayHistory, size);
+ gameplayHistory = value.gameplayHistory;
+ } catch (error) {
+ return storageError(
+ "INVALID_PROJECT",
+ error instanceof Error
+ ? error.message
+ : "Progress gameplay history is invalid.",
+ );
+ }
+ }
return {
version: 1,
values,
@@ -155,9 +179,57 @@ function progress(value: unknown, size: number): SudokuProgress | undefined {
: { elapsedMs: value.elapsedMs as number }),
...(value.completed === undefined ? {} : { completed: value.completed }),
...(aidMemoire === undefined ? {} : { aidMemoire }),
+ ...(gameplayHistory === undefined ? {} : { gameplayHistory }),
};
}
+function projectTags(value: unknown): string[] | undefined {
+ if (value === undefined) return undefined;
+ if (!Array.isArray(value) || value.length > MAX_PROJECT_TAGS) {
+ return storageError(
+ "INVALID_PROJECT",
+ `Project tags must contain at most ${MAX_PROJECT_TAGS} entries.`,
+ );
+ }
+ const tags = value.map((tag, index) => {
+ if (
+ typeof tag !== "string" ||
+ tag.trim().length === 0 ||
+ tag.length > MAX_PROJECT_TAG_LENGTH
+ ) {
+ return storageError(
+ "INVALID_PROJECT",
+ `Project tag ${String(index + 1)} is empty or too long.`,
+ );
+ }
+ return tag.trim();
+ });
+ return [...new Set(tags)].sort((a, b) => a.localeCompare(b));
+}
+
+function projectThumbnail(
+ value: unknown,
+ givens: readonly number[],
+ size: number,
+) {
+ if (value === undefined) {
+ return givens
+ .map((digit) => (digit === 0 ? "." : symbolFor(digit, size)))
+ .join("");
+ }
+ if (
+ typeof value !== "string" ||
+ [...value].length !== size * size ||
+ !/^[.1-9A-G]+$/u.test(value)
+ ) {
+ return storageError(
+ "INVALID_PROJECT",
+ "Project thumbnail must be a safe row-major grid preview.",
+ );
+ }
+ return value;
+}
+
export function normalizeProjectRecord(value: unknown): SudokuProjectRecord {
if (!isRecord(value))
return storageError("INVALID_PROJECT", "A project must be an object.");
@@ -199,6 +271,12 @@ export function normalizeProjectRecord(value: unknown): SudokuProjectRecord {
);
}
const puzzle = normalizeSudokuDocument(value.puzzle);
+ const tags = projectTags(value.tags);
+ const thumbnail = projectThumbnail(
+ value.thumbnail,
+ puzzle.givens,
+ puzzle.size,
+ );
const normalized: SudokuProjectRecord = {
schema: PROJECT_RECORD_SCHEMA,
version: PROJECT_RECORD_VERSION,
@@ -210,6 +288,8 @@ export function normalizeProjectRecord(value: unknown): SudokuProjectRecord {
...(value.progress === undefined
? {}
: { progress: progress(value.progress, puzzle.size) }),
+ ...(tags === undefined ? {} : { tags }),
+ thumbnail,
};
const bytes = new TextEncoder().encode(JSON.stringify(normalized)).byteLength;
if (bytes > MAX_PROJECT_BYTES) {
@@ -226,6 +306,7 @@ export interface NewProjectOptions {
readonly title?: string;
readonly now?: number;
readonly progress?: SudokuProgress;
+ readonly tags?: readonly string[];
}
function randomId(): string {
@@ -252,6 +333,7 @@ export function createProjectRecord(
updatedAt: now,
puzzle,
...(options.progress === undefined ? {} : { progress: options.progress }),
+ ...(options.tags === undefined ? {} : { tags: options.tags }),
});
}
@@ -262,6 +344,7 @@ export function cloneProjectRecord(
return {
...normalized,
puzzle: cloneSudokuDocument(normalized.puzzle),
+ ...(normalized.tags === undefined ? {} : { tags: [...normalized.tags] }),
...(normalized.progress === undefined
? {}
: {
@@ -300,6 +383,11 @@ export function cloneProjectRecord(
normalized.puzzle.size,
),
}),
+ ...(normalized.progress.gameplayHistory === undefined
+ ? {}
+ : {
+ gameplayHistory: normalized.progress.gameplayHistory,
+ }),
},
}),
};
diff --git a/src/storage/types.ts b/src/storage/types.ts
index 4ed0e6c..d9328b4 100644
--- a/src/storage/types.ts
+++ b/src/storage/types.ts
@@ -15,6 +15,8 @@ export interface SudokuProgress {
readonly elapsedMs?: number;
readonly completed?: boolean;
readonly aidMemoire?: PortableAidMemoire;
+ /** Canonical, bounded replay/savepoint history JSON. */
+ readonly gameplayHistory?: string;
}
export interface SudokuProjectRecord {
@@ -26,6 +28,9 @@ export interface SudokuProjectRecord {
readonly updatedAt: number;
readonly puzzle: SudokuDocument;
readonly progress?: SudokuProgress;
+ readonly tags?: readonly string[];
+ /** Compact row-major symbols used for a script-free Library preview. */
+ readonly thumbnail?: string;
}
export interface SudokuProjectSummary {
@@ -35,6 +40,14 @@ export interface SudokuProjectSummary {
readonly updatedAt: number;
readonly size: number;
readonly completed: boolean;
+ readonly tags: readonly string[];
+ readonly thumbnail: string;
+}
+
+export interface ProjectLibraryQuery {
+ readonly search?: string;
+ readonly tags?: readonly string[];
+ readonly completed?: boolean;
}
export interface ProjectLibraryExport {
diff --git a/src/styles.css b/src/styles.css
index 08c3f66..81614b8 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -571,6 +571,59 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
letter-spacing: 0.04em;
}
+.autosave-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ padding: 0.28rem 0.52rem;
+ border: 1px solid var(--toolbox-border);
+ border-radius: 999px;
+ background: var(--toolbox-surface);
+ color: var(--toolbox-muted);
+ white-space: nowrap;
+}
+
+.autosave-status::before {
+ width: 0.48rem;
+ height: 0.48rem;
+ border-radius: 50%;
+ background: var(--toolbox-success, #2b8a64);
+ content: "";
+}
+
+.autosave-status--checking::before,
+.autosave-status--saving::before {
+ background: var(--toolbox-accent);
+ box-shadow: 0 0 0 0.18rem
+ color-mix(in srgb, var(--toolbox-accent) 18%, transparent);
+}
+
+.autosave-status--error::before {
+ background: var(--toolbox-danger, #b23a48);
+}
+
+.recovery-callout {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.8rem 1.2rem;
+ padding: 0.8rem 0.95rem;
+ border: 1px solid
+ color-mix(in srgb, var(--toolbox-accent) 44%, var(--toolbox-border));
+ border-radius: 0.78rem;
+ background: color-mix(
+ in srgb,
+ var(--toolbox-accent) 9%,
+ var(--toolbox-surface)
+ );
+}
+
+.recovery-callout p {
+ margin-top: 0.18rem;
+ color: var(--toolbox-muted);
+ font-size: 0.78rem;
+}
+
.workbench-grid {
min-width: 0;
display: grid;
@@ -639,6 +692,141 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
overflow: hidden;
}
+.board-viewport {
+ width: 100%;
+ min-width: 0;
+ display: grid;
+ gap: 0.55rem;
+}
+
+.board-viewport__controls {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+}
+
+.board-zoom-buttons {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.3rem;
+}
+
+.board-viewport__controls button {
+ min-height: 2.1rem;
+ padding: 0.35rem 0.58rem;
+ font-size: 0.72rem;
+}
+
+.board-viewport__controls button.is-active {
+ border-color: var(--toolbox-accent);
+ background: var(--toolbox-accent);
+ color: var(--toolbox-accent-contrast);
+}
+
+.board-zoom-buttons output {
+ min-width: 3.3rem;
+ color: var(--toolbox-muted);
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
+ font-size: 0.72rem;
+ font-variant-numeric: tabular-nums;
+ font-weight: 760;
+ text-align: center;
+}
+
+.board-viewport__scroller {
+ width: 100%;
+ max-height: min(82dvh, 56rem);
+ overflow: auto;
+ overscroll-behavior: contain;
+ scrollbar-gutter: stable;
+}
+
+.board-viewport__scroller:focus-visible {
+ outline: 2px solid var(--toolbox-focus);
+ outline-offset: 2px;
+}
+
+.board-viewport__canvas {
+ min-width: 100%;
+ margin-inline: auto;
+}
+
+.board-viewport__canvas > .sudoku-board-frame {
+ width: 100%;
+ max-width: none;
+}
+
+.board-viewport.is-pan-mode .board-viewport__scroller {
+ border: 2px dashed var(--toolbox-accent);
+ border-radius: 0.62rem;
+ background: color-mix(
+ in srgb,
+ var(--toolbox-accent-soft) 34%,
+ var(--toolbox-surface)
+ );
+ box-shadow: 0 0 0 3px
+ color-mix(in srgb, var(--toolbox-accent) 12%, transparent);
+ cursor: grab;
+ touch-action: none;
+}
+
+.board-viewport.is-pan-mode .board-viewport__scroller:active {
+ cursor: grabbing;
+}
+
+.board-viewport.is-pan-mode .sudoku-board-frame {
+ opacity: 0.82;
+ pointer-events: none;
+}
+
+.board-pan-notice {
+ margin: 0;
+ padding: 0.5rem 0.65rem;
+ border: 1px solid
+ color-mix(in srgb, var(--toolbox-accent) 52%, var(--toolbox-border));
+ border-radius: 0.52rem;
+ background: var(--toolbox-accent-soft);
+ color: var(--toolbox-accent);
+ font-size: 0.74rem;
+ font-weight: 760;
+}
+
+.mobile-number-pad {
+ position: sticky;
+ z-index: 12;
+ bottom: 0;
+ width: min(100%, 45rem);
+ margin-inline: auto;
+ padding: 0.65rem 0.7rem max(0.65rem, env(safe-area-inset-bottom));
+ border: 1px solid
+ color-mix(in srgb, var(--toolbox-accent) 26%, var(--toolbox-border));
+ border-radius: 0.78rem 0.78rem 0 0;
+ background: color-mix(
+ in srgb,
+ var(--toolbox-surface) 94%,
+ var(--toolbox-accent-soft)
+ );
+ box-shadow: 0 -0.7rem 1.6rem rgb(20 30 60 / 14%);
+}
+
+.accessibility-select {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(8.5rem, auto);
+ align-items: center;
+ gap: 0.65rem;
+ padding-block: 0.35rem;
+ color: var(--toolbox-text);
+ font-size: 0.8rem;
+ font-weight: 610;
+}
+
+.accessibility-select select {
+ min-height: 2.35rem;
+}
+
.board-surface.is-paused .sudoku-board-frame {
opacity: 0.72;
filter: saturate(0.6);
@@ -1102,6 +1290,413 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
gap: 0.72rem;
}
+.guided-hint {
+ gap: 0.72rem;
+ padding: clamp(0.8rem, 1.45vw, 1rem);
+ border: 1px solid
+ color-mix(in srgb, var(--toolbox-accent) 28%, var(--toolbox-border));
+ border-radius: 0.78rem;
+ background: color-mix(
+ in srgb,
+ var(--toolbox-accent-soft) 25%,
+ var(--toolbox-surface)
+ );
+}
+
+.guided-hint__heading {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 0.65rem;
+}
+
+.guided-hint__heading h3 {
+ font-size: 1.02rem;
+}
+
+.guided-hint__dismiss {
+ min-height: 2rem !important;
+ padding: 0.3rem 0.5rem !important;
+ border-color: transparent !important;
+ background: transparent !important;
+ box-shadow: none !important;
+ color: var(--toolbox-muted) !important;
+}
+
+.guided-hint__intro {
+ font-size: 0.78rem;
+}
+
+.guided-hint__status {
+ padding: 0.5rem 0.62rem;
+ font-size: 0.74rem;
+}
+
+.guided-hint__progress {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 0.3rem;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.guided-hint__progress li {
+ min-width: 0;
+ padding: 0.36rem 0.22rem;
+ border: 1px solid var(--toolbox-border);
+ border-radius: 0.42rem;
+ background: var(--toolbox-surface);
+ color: var(--toolbox-muted);
+ font-size: 0.61rem;
+ font-weight: 720;
+ line-height: 1.2;
+ text-align: center;
+}
+
+.guided-hint__progress li.is-revealed {
+ border-color: color-mix(
+ in srgb,
+ var(--toolbox-accent) 42%,
+ var(--toolbox-border)
+ );
+ background: var(--toolbox-accent-soft);
+ color: var(--toolbox-accent);
+}
+
+.guided-hint__progress li[aria-current="step"] {
+ box-shadow: inset 0 -2px 0 var(--toolbox-accent);
+}
+
+.guided-hint__stage {
+ display: grid;
+ gap: 0.55rem;
+}
+
+.guided-hint__stage > section {
+ display: grid;
+ gap: 0.25rem;
+ padding: 0.62rem;
+ border-left: 3px solid var(--toolbox-accent);
+ border-radius: 0.32rem 0.55rem 0.55rem 0.32rem;
+ background: var(--toolbox-surface);
+ font-size: 0.8rem;
+}
+
+.guided-hint__effect-list {
+ display: grid;
+ gap: 0.28rem;
+ margin: 0;
+ padding-left: 1.15rem;
+ color: var(--toolbox-text);
+}
+
+.guided-hint__tracking-note {
+ margin-top: 0.2rem;
+ font-size: 0.73rem;
+}
+
+.guided-hint__actions .guided-hint__apply {
+ border-color: var(--toolbox-accent);
+ background: var(--toolbox-accent);
+ color: var(--toolbox-accent-contrast);
+}
+
+.guided-hint__maintenance {
+ min-width: 0;
+ display: grid;
+ gap: 0.55rem;
+ margin: 0;
+ padding: 0.68rem;
+ border: 1px solid var(--toolbox-border);
+ border-radius: 0.58rem;
+}
+
+.guided-hint__maintenance legend {
+ padding-inline: 0.25rem;
+ color: var(--toolbox-text);
+ font-size: 0.72rem;
+ font-weight: 760;
+}
+
+.guided-hint__maintenance > p {
+ font-size: 0.72rem;
+}
+
+.guided-hint__maintenance-actions button {
+ flex: 1 1 8rem;
+ min-height: 2.25rem;
+ padding: 0.38rem 0.52rem;
+ font-size: 0.7rem;
+}
+
+/* Setter quality and bounded proof lab */
+
+.setter-quality {
+ gap: 0.85rem;
+ padding-top: 0.25rem;
+ border-top: 1px solid var(--toolbox-border);
+}
+
+.setter-quality :where(h3, h4, h5) {
+ margin: 0;
+ color: var(--toolbox-text);
+ line-height: 1.25;
+}
+
+.setter-quality h4 {
+ font-size: 0.9rem;
+}
+
+.setter-quality h5 {
+ font-size: 0.76rem;
+}
+
+.setter-quality__controls .field-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.setter-quality__controls .field-grid label:last-child {
+ grid-column: 1 / -1;
+}
+
+.setter-quality__actions button {
+ flex: 1 1 9rem;
+}
+
+.setter-quality__results {
+ gap: 0.75rem;
+}
+
+.setter-quality__solution {
+ display: grid;
+ gap: 0.55rem;
+}
+
+.status-pill {
+ display: inline-flex;
+ align-items: center;
+ align-self: flex-start;
+ padding: 0.24rem 0.48rem;
+ border: 1px solid var(--toolbox-border);
+ border-radius: 999px;
+ background: var(--toolbox-surface);
+ color: var(--toolbox-muted);
+ font-size: 0.64rem;
+ font-weight: 780;
+ white-space: nowrap;
+}
+
+.status-pill.status-unique,
+.status-pill.status-localized {
+ border-color: color-mix(
+ in srgb,
+ var(--sudoku-success) 38%,
+ var(--toolbox-border)
+ );
+ color: var(--sudoku-success);
+}
+
+.status-pill.status-multiple,
+.status-pill.status-unsatisfiable {
+ border-color: color-mix(
+ in srgb,
+ var(--toolbox-danger) 38%,
+ var(--toolbox-border)
+ );
+ color: var(--toolbox-danger);
+}
+
+.setter-quality__table-wrap {
+ max-width: 100%;
+ overflow: auto;
+ border: 1px solid var(--toolbox-border);
+ border-radius: 0.55rem;
+}
+
+.setter-quality__table {
+ width: 100%;
+ min-width: 30rem;
+ border-collapse: collapse;
+ background: var(--toolbox-surface);
+ color: var(--toolbox-text);
+ font-size: 0.72rem;
+}
+
+.setter-quality__table :where(th, td) {
+ padding: 0.48rem 0.55rem;
+ border-bottom: 1px solid var(--toolbox-border);
+ text-align: left;
+ vertical-align: top;
+}
+
+.setter-quality__table tr:last-child :where(th, td) {
+ border-bottom: 0;
+}
+
+.setter-quality__table thead th {
+ background: var(--toolbox-surface-soft);
+ color: var(--toolbox-muted);
+ font-size: 0.64rem;
+ letter-spacing: 0.035em;
+ text-transform: uppercase;
+}
+
+.setter-quality__table button,
+.setter-quality__item-list button {
+ min-height: 1.8rem !important;
+ padding: 0.2rem 0.32rem !important;
+ border-color: transparent !important;
+ background: transparent !important;
+ box-shadow: none !important;
+ color: var(--toolbox-accent) !important;
+ font-size: inherit;
+ text-align: left;
+}
+
+.setter-quality__table tr.is-critical strong {
+ color: var(--sudoku-success);
+}
+
+.setter-quality__table tr.is-redundant strong {
+ color: var(--toolbox-danger);
+}
+
+.setter-quality__table tr.is-unknown strong {
+ color: var(--sudoku-warning);
+}
+
+.setter-quality__assessments,
+.setter-quality__contradiction-groups,
+.setter-quality__item-group {
+ display: grid;
+ gap: 0.45rem;
+}
+
+.setter-quality__counts {
+ font-size: 0.68rem;
+ text-align: right;
+}
+
+.setter-quality__contradiction-groups {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ align-items: start;
+}
+
+.setter-quality__item-group {
+ align-content: start;
+ padding: 0.55rem;
+ border: 1px solid var(--toolbox-border);
+ border-radius: 0.5rem;
+ background: var(--toolbox-surface);
+}
+
+.setter-quality__item-list {
+ display: grid;
+ gap: 0.2rem;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.setter-quality__heatmap-grid {
+ width: min(100%, 28rem);
+ display: grid;
+ gap: 0.2rem;
+}
+
+.setter-quality__heat-cell {
+ min-width: 0 !important;
+ min-height: 2.5rem !important;
+ display: grid !important;
+ gap: 0.04rem !important;
+ padding: 0.2rem !important;
+ border-radius: 0.35rem !important;
+}
+
+.setter-quality__heat-cell span {
+ overflow: hidden;
+ font-size: 0.54rem;
+ text-overflow: ellipsis;
+}
+
+.setter-quality__heat-cell strong {
+ font-size: 0.72rem;
+}
+
+.setter-quality__heat-cell.is-critical {
+ border-color: color-mix(
+ in srgb,
+ var(--sudoku-success) 48%,
+ var(--toolbox-border)
+ );
+ background: color-mix(
+ in srgb,
+ var(--sudoku-success) 16%,
+ var(--toolbox-surface)
+ );
+}
+
+.setter-quality__heat-cell.is-redundant {
+ border-color: color-mix(
+ in srgb,
+ var(--toolbox-danger) 42%,
+ var(--toolbox-border)
+ );
+ background: color-mix(
+ in srgb,
+ var(--toolbox-danger) 13%,
+ var(--toolbox-surface)
+ );
+}
+
+.setter-quality__heat-cell.is-mixed {
+ border-color: color-mix(
+ in srgb,
+ var(--sudoku-warning) 44%,
+ var(--toolbox-border)
+ );
+ background: color-mix(
+ in srgb,
+ var(--sudoku-warning) 14%,
+ var(--toolbox-surface)
+ );
+}
+
+.setter-quality__heat-cell.is-unknown {
+ border-style: dashed;
+ color: var(--toolbox-muted);
+}
+
+.setter-quality__metric-list {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.4rem;
+}
+
+.setter-quality__metric-list > div {
+ min-width: 0;
+ display: grid;
+ gap: 0.1rem;
+ padding: 0.48rem;
+ border: 1px solid var(--toolbox-border);
+ border-radius: 0.45rem;
+ background: var(--toolbox-surface);
+}
+
+.setter-quality__metric-list dt {
+ color: var(--toolbox-muted);
+ font-size: 0.62rem;
+}
+
+.setter-quality__metric-list dd {
+ margin: 0;
+ overflow-wrap: anywhere;
+ color: var(--toolbox-text);
+ font-size: 0.72rem;
+ font-weight: 720;
+}
+
.panel-section.action-row {
display: flex;
}
@@ -1270,6 +1865,41 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
);
}
+.generator-family-picker {
+ grid-column: 1 / -1;
+ min-width: 0;
+ margin: 0;
+ padding: 0.65rem 0.7rem;
+ border: 1px solid var(--toolbox-border);
+ border-radius: 0.62rem;
+ background: color-mix(in srgb, var(--toolbox-surface) 84%, transparent);
+}
+
+.generator-family-picker legend {
+ padding-inline: 0.25rem;
+ color: var(--toolbox-muted);
+ font-size: 0.74rem;
+ font-weight: 720;
+}
+
+.generator-family-options {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr));
+ gap: 0.45rem 0.7rem;
+}
+
+.generator-family-options label {
+ display: flex;
+ align-items: center;
+ gap: 0.42rem;
+ color: var(--toolbox-text);
+ font-weight: 610;
+}
+
+.generator-batch {
+ overflow: hidden;
+}
+
.generator-description {
padding: 0.55rem 0.65rem;
border-left: 3px solid var(--toolbox-accent);
@@ -1428,6 +2058,24 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
cursor: default;
}
+.sudoku-cell.is-fogged,
+.sudoku-cell.is-fogged:disabled {
+ --cell-fill: color-mix(
+ in srgb,
+ var(--toolbox-text) 82%,
+ var(--toolbox-surface)
+ );
+ opacity: 1;
+ background: repeating-linear-gradient(
+ 135deg,
+ color-mix(in srgb, var(--toolbox-text) 80%, var(--toolbox-surface)) 0 5px,
+ color-mix(in srgb, var(--toolbox-muted) 90%, var(--toolbox-surface)) 5px
+ 10px
+ ) !important;
+ color: transparent !important;
+ cursor: not-allowed;
+}
+
.sudoku-cell[class*="has-color-"] {
--cell-fill: color-mix(
in srgb,
@@ -1436,6 +2084,18 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
);
}
+.cell-color-pattern,
+.aid-memoire__cell[class*="has-color-"]::before {
+ position: absolute;
+ z-index: 2;
+ inset: 0;
+ background-image: var(--sudoku-mark-pattern);
+ background-size: var(--sudoku-mark-pattern-size, auto);
+ content: "";
+ opacity: 0.52;
+ pointer-events: none;
+}
+
.sudoku-cell.is-digit-highlighted {
background: color-mix(
in srgb,
@@ -1452,6 +2112,68 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
) !important;
}
+.sudoku-cell.is-quality-critical {
+ --cell-fill: color-mix(
+ in srgb,
+ var(--sudoku-success) 20%,
+ var(--toolbox-surface)
+ );
+}
+
+.sudoku-cell.is-quality-redundant {
+ --cell-fill: color-mix(
+ in srgb,
+ var(--toolbox-danger) 18%,
+ var(--toolbox-surface)
+ );
+}
+
+.sudoku-cell.is-quality-mixed {
+ --cell-fill: color-mix(
+ in srgb,
+ var(--sudoku-warning) 20%,
+ var(--toolbox-surface)
+ );
+}
+
+.sudoku-cell.is-quality-unknown {
+ --cell-fill: color-mix(
+ in srgb,
+ var(--toolbox-muted) 14%,
+ var(--toolbox-surface)
+ );
+}
+
+.sudoku-cell.is-hint-focus {
+ background: color-mix(
+ in srgb,
+ var(--toolbox-focus) 16%,
+ var(--cell-fill)
+ ) !important;
+ box-shadow: inset 0 0 0 max(1.5px, 0.25cqi)
+ color-mix(in srgb, var(--toolbox-focus) 72%, transparent) !important;
+}
+
+.sudoku-cell.is-hint-placement {
+ background: color-mix(
+ in srgb,
+ var(--sudoku-success) 18%,
+ var(--cell-fill)
+ ) !important;
+ box-shadow: inset 0 0 0 max(2px, 0.3cqi)
+ color-mix(in srgb, var(--sudoku-success) 76%, transparent) !important;
+}
+
+.sudoku-cell.is-hint-elimination {
+ background: color-mix(
+ in srgb,
+ var(--toolbox-danger) 12%,
+ var(--cell-fill)
+ ) !important;
+ box-shadow: inset 0 0 0 max(2px, 0.3cqi)
+ color-mix(in srgb, var(--toolbox-danger) 62%, transparent) !important;
+}
+
.sudoku-cell.is-selected {
background: color-mix(
in srgb,
@@ -1522,6 +2244,30 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
pointer-events: none;
}
+.hint-placement-preview {
+ position: relative;
+ z-index: 5;
+ color: var(--sudoku-success);
+ font-size: clamp(0.74rem, 4.2cqi, 2rem);
+ font-weight: 820;
+ line-height: 1;
+ pointer-events: none;
+}
+
+.hint-elimination-preview {
+ position: absolute;
+ z-index: 5;
+ right: 7%;
+ bottom: 5%;
+ max-width: 86%;
+ overflow: hidden;
+ color: var(--toolbox-danger);
+ font-size: clamp(0.34rem, 1.28cqi, 0.62rem);
+ font-weight: 820;
+ line-height: 1;
+ pointer-events: none;
+}
+
.cell-value {
display: block;
font-size: clamp(0.74rem, 4.2cqi, 2rem);
@@ -1618,10 +2364,14 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
text-anchor: middle;
}
-.constraint-layer :where(path, polyline, line, circle, rect) {
+.constraint-layer :where(path, polyline, polygon, line, circle, ellipse, rect) {
vector-effect: non-scaling-stroke;
}
+.safe-visual-layer {
+ pointer-events: none;
+}
+
.region-boundaries path {
fill: none;
stroke: var(--sudoku-line-strong);
@@ -1637,6 +2387,15 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
opacity: 0.66;
}
+.constraint-disjoint-groups path {
+ fill: none;
+ stroke: color-mix(in srgb, var(--toolbox-accent) 48%, transparent);
+ stroke-dasharray: 1.2 3;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: clamp(1px, 0.16cqi, 1.6px);
+}
+
.constraint-cage path {
fill: none;
stroke: color-mix(in srgb, var(--toolbox-text) 76%, var(--toolbox-muted));
@@ -1670,6 +2429,143 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
stroke-width: clamp(1px, 0.15cqi, 1.5px);
}
+.constraint-between-line polyline {
+ fill: none;
+ stroke: color-mix(in srgb, var(--toolbox-text) 70%, var(--toolbox-muted));
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: clamp(2.2px, 0.36cqi, 3.5px);
+}
+
+.constraint-between-line circle {
+ fill: color-mix(in srgb, var(--toolbox-surface) 88%, transparent);
+ stroke: color-mix(in srgb, var(--toolbox-text) 72%, var(--toolbox-muted));
+ stroke-width: clamp(1.7px, 0.27cqi, 2.7px);
+}
+
+.constraint-german-whisper polyline {
+ fill: none;
+ stroke: color-mix(in srgb, #36a66a 72%, var(--toolbox-surface));
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: clamp(7px, 1.55cqi, 13px);
+}
+
+.constraint-region-sum-line polyline {
+ fill: none;
+ stroke: color-mix(in srgb, var(--sudoku-info) 72%, var(--toolbox-accent));
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: clamp(3px, 0.58cqi, 5px);
+}
+
+.constraint-region-sum-line .region-sum-divider {
+ fill: var(--toolbox-surface);
+ stroke: color-mix(in srgb, var(--sudoku-info) 76%, var(--toolbox-text));
+ stroke-width: clamp(1.2px, 0.2cqi, 2px);
+}
+
+.constraint-modular-line .modular-line-path {
+ fill: none;
+ stroke: color-mix(in srgb, #7c4db8 76%, var(--toolbox-text));
+ stroke-dasharray: 7 2.4;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: clamp(3px, 0.56cqi, 5px);
+}
+
+.constraint-modular-line .modular-line-node {
+ fill: var(--toolbox-surface);
+ stroke: color-mix(in srgb, #7c4db8 84%, var(--toolbox-text));
+ stroke-width: clamp(1px, 0.16cqi, 1.7px);
+}
+
+.constraint-entropic-line .entropic-line-underlay {
+ fill: none;
+ stroke: color-mix(in srgb, #b56e39 38%, var(--toolbox-surface));
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: clamp(9px, 1.85cqi, 16px);
+}
+
+.constraint-entropic-line .entropic-line-path {
+ fill: none;
+ stroke: color-mix(in srgb, #b05b25 82%, var(--toolbox-text));
+ stroke-dasharray: 1.4 3.2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: clamp(2.5px, 0.43cqi, 4px);
+}
+
+.constraint-zipper-line .zipper-line-path {
+ fill: none;
+ stroke: color-mix(in srgb, #237b86 78%, var(--toolbox-text));
+ stroke-dasharray: 5 1.8 1 1.8;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: clamp(4px, 0.72cqi, 6px);
+}
+
+.constraint-zipper-line .zipper-line-centre {
+ fill: color-mix(in srgb, var(--toolbox-surface) 88%, #237b86);
+ stroke: color-mix(in srgb, #237b86 84%, var(--toolbox-text));
+ stroke-width: clamp(1.4px, 0.22cqi, 2.2px);
+}
+
+.constraint-double-arrow polyline {
+ fill: none;
+ stroke: color-mix(in srgb, #a34755 76%, var(--toolbox-text));
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: clamp(2.3px, 0.38cqi, 3.7px);
+}
+
+.constraint-double-arrow circle {
+ fill: color-mix(in srgb, var(--toolbox-surface) 88%, #a34755);
+ stroke: color-mix(in srgb, #a34755 78%, var(--toolbox-text));
+ stroke-width: clamp(1.5px, 0.25cqi, 2.5px);
+}
+
+.constraint-double-arrow .double-arrow-chevron {
+ fill: none;
+ stroke: color-mix(in srgb, #a34755 88%, var(--toolbox-text));
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: clamp(1.8px, 0.29cqi, 2.9px);
+}
+
+.constraint-indexer :where(circle, rect) {
+ fill: color-mix(in srgb, var(--toolbox-surface) 90%, var(--sudoku-info));
+ stroke: color-mix(in srgb, var(--sudoku-info) 72%, var(--toolbox-text));
+ stroke-width: clamp(1.4px, 0.23cqi, 2.3px);
+}
+
+.constraint-indexer--column rect {
+ stroke-dasharray: 3 1.5;
+}
+
+.constraint-indexer--box rect {
+ fill: color-mix(in srgb, var(--sudoku-warning) 15%, var(--toolbox-surface));
+ stroke: color-mix(in srgb, var(--sudoku-warning) 78%, var(--toolbox-text));
+}
+
+.constraint-indexer text {
+ dominant-baseline: central;
+ fill: var(--toolbox-text);
+ font-family: var(--toolbox-font);
+ font-size: 0.18px;
+ font-weight: 900;
+ paint-order: stroke;
+ stroke: var(--toolbox-surface);
+ stroke-width: 0.025px;
+ text-anchor: middle;
+}
+
+.fog-constraint-mask rect {
+ fill: color-mix(in srgb, var(--toolbox-text) 82%, var(--toolbox-surface));
+ stroke: none;
+}
+
.constraint-arrow polyline {
fill: none;
stroke: color-mix(in srgb, var(--toolbox-text) 62%, var(--toolbox-muted));
@@ -1757,19 +2653,93 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
stroke-width: clamp(0.8px, 0.12cqi, 1.2px);
}
-.constraint-maximum circle {
+.constraint-maximum circle,
+.constraint-minimum circle {
fill: color-mix(in srgb, var(--toolbox-muted) 38%, var(--toolbox-surface));
stroke: color-mix(in srgb, var(--toolbox-text) 62%, var(--toolbox-muted));
stroke-width: clamp(1.2px, 0.2cqi, 2px);
}
-.constraint-maximum path {
+.constraint-maximum path,
+.constraint-minimum path {
fill: none;
stroke: color-mix(in srgb, var(--toolbox-text) 84%, var(--toolbox-accent));
+ stroke-linecap: round;
stroke-linejoin: round;
stroke-width: clamp(1.4px, 0.23cqi, 2.3px);
}
+.constraint-minimum circle {
+ fill: color-mix(in srgb, var(--sudoku-info) 14%, var(--toolbox-surface));
+ stroke: color-mix(in srgb, var(--sudoku-info) 56%, var(--toolbox-text));
+}
+
+.constraint-odd circle,
+.constraint-even rect {
+ fill: color-mix(in srgb, var(--toolbox-muted) 22%, var(--toolbox-surface));
+ stroke: color-mix(in srgb, var(--toolbox-text) 58%, var(--toolbox-muted));
+ stroke-width: clamp(1.2px, 0.2cqi, 2px);
+}
+
+.constraint-odd circle {
+ fill: color-mix(in srgb, var(--sudoku-warning) 13%, var(--toolbox-surface));
+}
+
+.constraint-even rect {
+ fill: color-mix(in srgb, var(--sudoku-info) 12%, var(--toolbox-surface));
+}
+
+.constraint-clone .clone-cell-fill {
+ fill: color-mix(in srgb, #9668d5 14%, transparent);
+ stroke: none;
+}
+
+.constraint-clone .clone-boundary {
+ fill: none;
+ stroke: color-mix(in srgb, #8652c5 72%, var(--toolbox-text));
+ stroke-dasharray: 5 2.5;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: clamp(1.5px, 0.25cqi, 2.5px);
+}
+
+.constraint-clone .clone-link {
+ stroke: color-mix(in srgb, #8652c5 58%, var(--toolbox-muted));
+ stroke-dasharray: 2.5 3;
+ stroke-width: clamp(1px, 0.16cqi, 1.7px);
+}
+
+.constraint-clone .clone-label,
+.constraint-extra-region .extra-region-label {
+ dominant-baseline: central;
+ fill: color-mix(in srgb, #7441b5 80%, var(--toolbox-text));
+ font-family: var(--toolbox-font);
+ font-size: 0.2px;
+ font-weight: 900;
+ paint-order: stroke;
+ stroke: var(--toolbox-surface);
+ stroke-width: 0.045px;
+ text-anchor: middle;
+}
+
+.constraint-extra-region .extra-region-cell-fill {
+ fill: color-mix(in srgb, var(--sudoku-warning) 10%, transparent);
+ stroke: none;
+}
+
+.constraint-extra-region path {
+ fill: none;
+ stroke: color-mix(in srgb, var(--sudoku-warning) 74%, var(--toolbox-text));
+ stroke-dasharray: 1.5 2.5;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: clamp(1.6px, 0.27cqi, 2.7px);
+}
+
+.constraint-extra-region .extra-region-label {
+ fill: color-mix(in srgb, var(--sudoku-warning) 82%, var(--toolbox-text));
+}
+
.constraint-quadruple circle {
fill: color-mix(in srgb, var(--toolbox-surface) 94%, var(--toolbox-accent));
stroke: color-mix(in srgb, var(--toolbox-text) 80%, var(--toolbox-muted));
@@ -1814,6 +2784,23 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
fill: color-mix(in srgb, var(--sudoku-info) 10%, var(--toolbox-surface));
}
+.constraint-sandwich rect {
+ fill: color-mix(in srgb, var(--sudoku-warning) 11%, var(--toolbox-surface));
+}
+
+.constraint-little-killer rect {
+ fill: color-mix(in srgb, var(--toolbox-accent) 10%, var(--toolbox-surface));
+}
+
+.constraint-little-killer .little-killer-arrow,
+.constraint-little-killer .little-killer-arrow-tip {
+ fill: none;
+ stroke: color-mix(in srgb, var(--toolbox-accent) 76%, var(--toolbox-text));
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: clamp(1.8px, 0.29cqi, 2.8px);
+}
+
.constraint-layer .is-negated :where(path, polyline, line, circle, rect),
.constraint-layer :where(path, polyline, line, circle, rect).is-negated {
stroke: var(--toolbox-danger) !important;
@@ -1899,7 +2886,9 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
display: block;
border: 2px solid color-mix(in srgb, var(--sudoku-mark-color) 75%, white);
border-radius: 50%;
- background: var(--sudoku-mark-color);
+ background-color: var(--sudoku-mark-color);
+ background-image: var(--sudoku-mark-pattern);
+ background-size: var(--sudoku-mark-pattern-size, auto);
box-shadow:
0 0 0 1px color-mix(in srgb, var(--sudoku-mark-color) 72%, black),
inset 0 0.18rem 0.28rem rgb(255 255 255 / 22%);
@@ -1908,41 +2897,101 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
.color-1,
.has-color-1 {
--sudoku-mark-color: #e56f65;
+ --sudoku-mark-pattern: repeating-linear-gradient(
+ 45deg,
+ transparent 0 5px,
+ color-mix(in srgb, var(--sudoku-mark-color) 42%, var(--toolbox-text)) 5px
+ 7px
+ );
}
.color-2,
.has-color-2 {
--sudoku-mark-color: #eca73f;
+ --sudoku-mark-pattern: repeating-linear-gradient(
+ -45deg,
+ transparent 0 5px,
+ color-mix(in srgb, var(--sudoku-mark-color) 42%, var(--toolbox-text)) 5px
+ 7px
+ );
}
.color-3,
.has-color-3 {
--sudoku-mark-color: #e0c84f;
+ --sudoku-mark-pattern: radial-gradient(
+ circle,
+ color-mix(in srgb, var(--sudoku-mark-color) 36%, var(--toolbox-text)) 0
+ 1.5px,
+ transparent 1.7px
+ );
+ --sudoku-mark-pattern-size: 8px 8px;
}
.color-4,
.has-color-4 {
--sudoku-mark-color: #62b77a;
+ --sudoku-mark-pattern:
+ repeating-linear-gradient(
+ 45deg,
+ transparent 0 6px,
+ color-mix(in srgb, var(--sudoku-mark-color) 38%, var(--toolbox-text)) 6px
+ 7.5px
+ ),
+ repeating-linear-gradient(
+ -45deg,
+ transparent 0 6px,
+ color-mix(in srgb, var(--sudoku-mark-color) 38%, var(--toolbox-text)) 6px
+ 7.5px
+ );
}
.color-5,
.has-color-5 {
--sudoku-mark-color: #4bb8b4;
+ --sudoku-mark-pattern: repeating-linear-gradient(
+ 0deg,
+ transparent 0 5px,
+ color-mix(in srgb, var(--sudoku-mark-color) 38%, var(--toolbox-text)) 5px
+ 7px
+ );
}
.color-6,
.has-color-6 {
--sudoku-mark-color: #5d93dd;
+ --sudoku-mark-pattern: repeating-linear-gradient(
+ 90deg,
+ transparent 0 5px,
+ color-mix(in srgb, var(--sudoku-mark-color) 38%, var(--toolbox-text)) 5px
+ 7px
+ );
}
.color-7,
.has-color-7 {
--sudoku-mark-color: #9a7cda;
+ --sudoku-mark-pattern: conic-gradient(
+ from 90deg,
+ color-mix(in srgb, var(--sudoku-mark-color) 35%, var(--toolbox-text)) 25%,
+ transparent 0 50%,
+ color-mix(in srgb, var(--sudoku-mark-color) 35%, var(--toolbox-text)) 0 75%,
+ transparent 0
+ );
+ --sudoku-mark-pattern-size: 8px 8px;
}
.color-8,
.has-color-8 {
--sudoku-mark-color: #ce78b0;
+ --sudoku-mark-pattern: radial-gradient(
+ circle,
+ transparent 0 2px,
+ color-mix(in srgb, var(--sudoku-mark-color) 38%, var(--toolbox-text)) 2px
+ 3.5px,
+ transparent 3.7px
+ );
+ --sudoku-mark-pattern-size: 11px 11px;
}
/* Setter, solver, and helper result surfaces */
@@ -2003,6 +3052,36 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
}
+.pack-constraint-controls {
+ padding: 0.72rem;
+ border: 1px solid
+ color-mix(in srgb, var(--toolbox-accent) 22%, var(--toolbox-border));
+ border-radius: 0.68rem;
+ background: color-mix(
+ in srgb,
+ var(--toolbox-accent-soft) 22%,
+ var(--toolbox-surface)
+ );
+}
+
+.pack-constraint-controls .inline-fields {
+ grid-template-columns: minmax(10rem, 15rem);
+}
+
+.pack-constraint-controls .compact-field {
+ width: 100%;
+}
+
+.pack-three-controls .inline-fields {
+ grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
+}
+
+.pack-three-requirements,
+.fog-setter-reason {
+ min-height: 1.4em;
+ margin: 0;
+}
+
.subtabs {
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
}
@@ -2617,6 +3696,35 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
margin-bottom: 1rem;
}
+.library-filters {
+ display: grid;
+ grid-template-columns: minmax(12rem, 2fr) repeat(2, minmax(9rem, 1fr));
+ gap: 0.65rem;
+ margin-bottom: 0.8rem;
+ padding: 0.7rem;
+ border: 1px solid var(--toolbox-border);
+ border-radius: 0.72rem;
+ background: var(--toolbox-surface-soft);
+}
+
+.library-filters label {
+ display: grid;
+ gap: 0.28rem;
+ color: var(--toolbox-muted);
+ font-size: 0.72rem;
+ font-weight: 680;
+}
+
+.library-selection-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.55rem 1rem;
+ margin-bottom: 0.7rem;
+ color: var(--toolbox-muted);
+ font-size: 0.76rem;
+}
+
.library-list {
display: grid;
gap: 0.45rem;
@@ -2625,7 +3733,7 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
.library-list li {
min-width: 0;
display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
+ grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.5rem;
padding: 0.38rem;
@@ -2634,17 +3742,55 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
background: var(--toolbox-surface);
}
+.library-selector {
+ display: grid;
+ place-items: center;
+ padding: 0.35rem;
+}
+
.library-open {
min-width: 0;
display: grid !important;
+ grid-template-columns: 3.25rem minmax(0, 1fr);
+ align-items: center !important;
justify-items: start !important;
- gap: 0.18rem !important;
+ gap: 0.7rem !important;
border-color: transparent !important;
background: transparent !important;
box-shadow: none !important;
text-align: left !important;
}
+.library-thumbnail {
+ width: 3.25rem;
+ aspect-ratio: 1;
+ display: grid;
+ overflow: hidden;
+ border: 1px solid var(--toolbox-border-strong, var(--toolbox-border));
+ border-radius: 0.24rem;
+ background: var(--toolbox-surface);
+}
+
+.library-thumbnail > span {
+ min-width: 0;
+ display: grid;
+ place-items: center;
+ border: 0 solid color-mix(in srgb, var(--toolbox-border) 60%, transparent);
+ border-right-width: 1px;
+ border-bottom-width: 1px;
+ color: var(--toolbox-text);
+ font-size: inherit;
+ font-weight: 720;
+ line-height: 1;
+}
+
+.library-copy {
+ min-width: 0;
+ display: grid;
+ justify-items: start;
+ gap: 0.18rem;
+}
+
.library-open strong {
max-width: 100%;
overflow: hidden;
@@ -2659,6 +3805,40 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
font-weight: 520;
}
+.library-item-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+}
+
+.library-tags {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 0.25rem;
+}
+
+.tag-chip {
+ min-height: 1.7rem !important;
+ padding: 0.18rem 0.45rem !important;
+ border-radius: 999px !important;
+ color: var(--toolbox-muted) !important;
+ font-size: 0.67rem !important;
+}
+
+.library-tag-editor {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+}
+
+.library-tag-editor input {
+ width: min(14rem, 100%);
+}
+
.empty-state {
display: grid;
justify-items: center;
@@ -2812,7 +3992,9 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
@media (max-width: 48rem) {
.toolbox-shell__main {
- padding-inline: 0.75rem;
+ padding-right: 0.75rem;
+ padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
+ padding-left: 0.75rem;
}
.workbench-hero {
@@ -2846,6 +4028,23 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
padding: 0.75rem;
}
+ .board-viewport__controls button {
+ min-width: 2.75rem;
+ min-height: 2.75rem;
+ }
+
+ .board-viewport__scroller {
+ max-height: min(72dvh, 48rem);
+ }
+
+ .mobile-number-pad .number-pad {
+ gap: 0.45rem;
+ }
+
+ .mobile-number-pad .digit-pad button {
+ min-height: 2.75rem;
+ }
+
.subtabs,
.import-export-grid,
.help-grid,
@@ -2872,6 +4071,16 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
.library-toolbar {
flex-direction: column;
}
+
+ .recovery-callout,
+ .library-selection-toolbar {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .library-filters {
+ grid-template-columns: minmax(0, 1fr);
+ }
}
@media (max-width: 38rem) {
@@ -2881,10 +4090,17 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
.field-grid,
.helper-controls,
- .candidate-lab__controls {
+ .candidate-lab__controls,
+ .setter-quality__controls .field-grid,
+ .setter-quality__contradiction-groups,
+ .setter-quality__metric-list {
grid-template-columns: minmax(0, 1fr);
}
+ .setter-quality__controls .field-grid label:last-child {
+ grid-column: auto;
+ }
+
.section-heading {
align-items: stretch;
flex-direction: column;
@@ -2894,6 +4110,10 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
width: 100%;
}
+ .accessibility-select {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
.panel-section.action-row,
.action-row,
.hero-actions {
@@ -2925,8 +4145,17 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
grid-template-columns: minmax(0, 1fr);
}
- .library-list li > .text-button {
- justify-self: end;
+ .library-selector {
+ justify-content: start;
+ }
+
+ .library-item-actions,
+ .library-tags {
+ justify-content: flex-start;
+ }
+
+ .library-open {
+ width: 100%;
}
}
@@ -3039,11 +4268,22 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
outline-offset: -4px;
}
- .constraint-layer :where(path, polyline, line, circle),
+ .constraint-layer
+ :where(path, polyline, polygon, line, circle, ellipse, rect),
.candidate-link-layer :where(line, circle),
.region-boundaries path {
stroke: CanvasText;
}
+
+ .safe-visual-layer text {
+ fill: CanvasText;
+ }
+
+ .sudoku-cell.is-fogged,
+ .fog-constraint-mask rect {
+ background: CanvasText !important;
+ fill: CanvasText;
+ }
}
/* A print is a clean puzzle sheet, not a snapshot of the controls. */
@@ -3075,6 +4315,9 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
.digit-completion,
.side-panel,
.number-pad,
+ .mobile-number-pad,
+ .board-viewport__controls,
+ .board-pan-notice,
.paused-cover,
.feedback-callout,
.status-line,
@@ -3121,7 +4364,18 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
box-shadow: none !important;
}
- .sudoku-board-frame {
+ .board-viewport__scroller {
+ max-height: none;
+ overflow: visible;
+ }
+
+ .board-viewport__canvas {
+ width: 100% !important;
+ max-width: none !important;
+ }
+
+ .sudoku-board-frame,
+ .board-viewport__canvas > .sudoku-board-frame {
--toolbox-surface: #fff;
--toolbox-text: #000;
--toolbox-muted: #444;
@@ -3139,7 +4393,8 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
print-color-adjust: exact;
}
- .sudoku-board-frame.has-outside-clues {
+ .sudoku-board-frame.has-outside-clues,
+ .board-viewport__canvas > .sudoku-board-frame.has-outside-clues {
--board-pad: 0.85cm;
width: min(100%, 19.7cm);
}
@@ -3158,6 +4413,10 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
display: none !important;
}
+ .cell-color-pattern {
+ display: none !important;
+ }
+
.board-meta,
.rule-card {
width: min(100%, 18cm);
diff --git a/src/toolbox/manifest.source.json b/src/toolbox/manifest.source.json
index 189b401..754ea75 100644
--- a/src/toolbox/manifest.source.json
+++ b/src/toolbox/manifest.source.json
@@ -3,7 +3,7 @@
"schemaVersion": 1,
"id": "de.add-ideas.sudoku-tools",
"name": "Sudoku Tools",
- "version": "0.1.0",
+ "version": "0.2.0",
"description": "Set, play, solve and analyse Sudoku puzzles locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
diff --git a/src/version.ts b/src/version.ts
index 162ff34..da2384c 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1 +1 @@
-export const APPLICATION_VERSION = "0.1.0";
+export const APPLICATION_VERSION = "0.2.0";
diff --git a/src/workers/protocol.ts b/src/workers/protocol.ts
index 2259b83..6d52ed0 100644
--- a/src/workers/protocol.ts
+++ b/src/workers/protocol.ts
@@ -6,12 +6,16 @@ import type {
DifficultyOptions,
GenerateClassicOptions,
GeneratedVariantPuzzle,
+ GeneratedVariantBatch,
+ GenerateVariantBatchOptions,
GenerateVariantOptions,
KillerCombinationOptions,
KillerCombinationResult,
LogicalSolveOptions,
LogicalSolveResult,
MinimizeOptions,
+ PuzzleQualityAnalysis,
+ PuzzleQualityOptions,
} from "../solver";
import type { NormalizedPuzzle, ValidationIssue } from "../domain";
@@ -31,11 +35,20 @@ export type SolverWorkerOperation =
readonly kind: "generate-variant";
readonly options?: GenerateVariantOptions;
}
+ | {
+ readonly kind: "generate-batch";
+ readonly options?: GenerateVariantBatchOptions;
+ }
| {
readonly kind: "difficulty";
readonly puzzle: PuzzleDefinition;
readonly options?: DifficultyOptions;
}
+ | {
+ readonly kind: "quality";
+ readonly puzzle: PuzzleDefinition;
+ readonly options?: PuzzleQualityOptions;
+ }
| {
readonly kind: "minimize";
readonly puzzle: PuzzleDefinition;
@@ -56,7 +69,9 @@ export type SolverWorkerValue =
| LogicalSolveResult
| NormalizedPuzzle
| GeneratedVariantPuzzle
+ | GeneratedVariantBatch
| DifficultyAssessment
+ | PuzzleQualityAnalysis
| KillerCombinationResult;
export interface SolverWorkerError {
diff --git a/src/workers/solver.worker.ts b/src/workers/solver.worker.ts
index 92fd7b8..b12ab05 100644
--- a/src/workers/solver.worker.ts
+++ b/src/workers/solver.worker.ts
@@ -2,9 +2,11 @@
import { PuzzleValidationError } from "../domain";
import {
+ analyzePuzzleQuality,
evaluateDifficulty,
generateClassic,
generateVariant,
+ generateVariantBatch,
killerDigitCombinations,
minimizePuzzle,
solveExact,
@@ -29,8 +31,12 @@ function run(request: SolverWorkerRequest): SolverWorkerValue {
return generateClassic(operation.options);
case "generate-variant":
return generateVariant(operation.options);
+ case "generate-batch":
+ return generateVariantBatch(operation.options);
case "difficulty":
return evaluateDifficulty(operation.puzzle, operation.options);
+ case "quality":
+ return analyzePuzzleQuality(operation.puzzle, operation.options);
case "minimize":
return minimizePuzzle(operation.puzzle, operation.options);
case "killer-combinations":
diff --git a/tests/browser/workbench.spec.ts b/tests/browser/workbench.spec.ts
index 60d6211..fa1c933 100644
--- a/tests/browser/workbench.spec.ts
+++ b/tests/browser/workbench.spec.ts
@@ -248,3 +248,129 @@ test("keeps navigation, scratch work, branches and analysis tools local", async
expect(runtimeErrors).toEqual([]);
});
+
+test("migrates the local database and recovers autosaved history", async ({
+ page,
+}) => {
+ await page.goto("/deep/nested/sudoku/favicon.svg");
+ await page.evaluate(async () => {
+ await new Promise((resolve, reject) => {
+ const request = indexedDB.deleteDatabase("sudoku-tools");
+ request.onsuccess = () => resolve();
+ request.onerror = () => reject(request.error);
+ });
+ await new Promise((resolve, reject) => {
+ const request = indexedDB.open("sudoku-tools", 1);
+ request.onupgradeneeded = () => {
+ const store = request.result.createObjectStore("projects", {
+ keyPath: "id",
+ });
+ store.createIndex("updatedAt", "updatedAt");
+ };
+ request.onsuccess = () => {
+ request.result.close();
+ resolve();
+ };
+ request.onerror = () => reject(request.error);
+ });
+ });
+
+ await page.goto("/deep/nested/sudoku/");
+ const grid = page.getByRole("grid", { name: "9 by 9 Sudoku grid" });
+ await grid.getByRole("gridcell", { name: "Row 1, column 3, empty" }).click();
+ await page
+ .getByRole("group", { name: "Digits" })
+ .getByRole("button", { name: "2", exact: true })
+ .click();
+ await expect
+ .poll(
+ async () =>
+ await page.evaluate(
+ async () =>
+ await new Promise((resolve, reject) => {
+ const open = indexedDB.open("sudoku-tools");
+ open.onerror = () => reject(open.error);
+ open.onsuccess = () => {
+ const database = open.result;
+ const request = database
+ .transaction("autosaves", "readonly")
+ .objectStore("autosaves")
+ .get("current");
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => {
+ const value = request.result as
+ | { record?: { progress?: { values?: number[] } } }
+ | undefined;
+ database.close();
+ resolve(value?.record?.progress?.values?.[2]);
+ };
+ };
+ }),
+ ),
+ { timeout: 10_000 },
+ )
+ .toBe(2);
+
+ await page.reload();
+ await expect(
+ page.getByRole("heading", { name: "A first classic" }),
+ ).toBeVisible();
+ await expect(page.getByText("Recover unsaved local work?")).toBeVisible();
+ await page.getByRole("button", { name: "Restore" }).click();
+ await expect(
+ page
+ .getByRole("grid", { name: "9 by 9 Sudoku grid" })
+ .getByRole("gridcell", { name: "Row 1, column 3, 2" }),
+ ).toBeVisible();
+ await page.getByRole("button", { name: "History & branches" }).click();
+ await expect(
+ page
+ .getByRole("list", { name: "Solve history" })
+ .getByRole("button", { name: /Set r1c3 to 2/u }),
+ ).toBeVisible();
+});
+
+test("installs a subpath-safe application shell that reopens offline", async ({
+ context,
+ page,
+}) => {
+ await page.goto("/deep/nested/sudoku/");
+ await page.waitForFunction(async () => {
+ await navigator.serviceWorker.ready;
+ return navigator.serviceWorker.controller !== null;
+ });
+ const cachedUrls = await page.evaluate(async () => {
+ const names = await caches.keys();
+ return (
+ await Promise.all(
+ names
+ .filter((name) => name.startsWith("sudoku-tools-shell-"))
+ .map(async (name) =>
+ (await caches.open(name))
+ .keys()
+ .then((requests) => requests.map((request) => request.url)),
+ ),
+ )
+ ).flat();
+ });
+ expect(
+ cachedUrls.some((url) => /\/solver\.worker-[^/]+\.js$/u.test(url)),
+ ).toBe(true);
+ await page.reload();
+ await expect(
+ page.getByRole("heading", { name: "A first classic" }),
+ ).toBeVisible();
+
+ await context.setOffline(true);
+ try {
+ await page.reload();
+ await expect(
+ page.getByRole("heading", { name: "A first classic" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("grid", { name: "9 by 9 Sudoku grid" }),
+ ).toBeVisible();
+ } finally {
+ await context.setOffline(false);
+ }
+});
diff --git a/tests/components/boardViewport.test.tsx b/tests/components/boardViewport.test.tsx
new file mode 100644
index 0000000..864a785
--- /dev/null
+++ b/tests/components/boardViewport.test.tsx
@@ -0,0 +1,72 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { BoardViewport } from "../../src/components/BoardViewport";
+import { BOARD_SCALE_STORAGE_KEY } from "../../src/state/uiPreferences";
+
+describe("BoardViewport", () => {
+ beforeEach(() => localStorage.clear());
+
+ it("zooms, fits and restores the persisted scale", async () => {
+ const user = userEvent.setup();
+ const { unmount } = render(
+
+ Board
+ ,
+ );
+
+ expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("100%");
+ await user.click(screen.getByRole("button", { name: "Zoom board in" }));
+ expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("125%");
+ expect(localStorage.getItem(BOARD_SCALE_STORAGE_KEY)).toBe("1.25");
+ unmount();
+
+ render(
+
+ Board
+ ,
+ );
+ expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("125%");
+ await user.click(screen.getByRole("button", { name: "Fit board" }));
+ expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("100%");
+ });
+
+ it("offers explicit pan mode and board-scoped zoom shortcuts", async () => {
+ const user = userEvent.setup();
+ const onCellKeyDown = vi.fn();
+ render(
+
+
+ Cell
+
+ ,
+ );
+
+ const pan = screen.getByRole("button", { name: "Pan board" });
+ expect(pan).toHaveAttribute("aria-pressed", "false");
+ await user.click(pan);
+ expect(
+ screen.getByRole("button", { name: "Stop panning" }),
+ ).toHaveAttribute("aria-pressed", "true");
+ expect(
+ screen.getByRole("status", {
+ name: "",
+ }),
+ ).toHaveTextContent(
+ "Pan mode: drag the board to move it. Cell taps are paused.",
+ );
+ expect(screen.getByLabelText(/pan mode is on/iu)).toBeInTheDocument();
+
+ fireEvent.keyDown(screen.getByRole("button", { name: "Cell" }), {
+ key: "+",
+ ctrlKey: true,
+ });
+ expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("125%");
+ fireEvent.keyDown(screen.getByRole("button", { name: "Cell" }), {
+ key: "0",
+ ctrlKey: true,
+ });
+ expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("100%");
+ expect(onCellKeyDown).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/components/constraintEditor.test.tsx b/tests/components/constraintEditor.test.tsx
new file mode 100644
index 0000000..52577f6
--- /dev/null
+++ b/tests/components/constraintEditor.test.tsx
@@ -0,0 +1,474 @@
+import { useState } from "react";
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { ConstraintEditor } from "../../src/components/ConstraintEditor";
+import { createEmptyPuzzle, type PuzzleDefinition } from "../../src/domain";
+
+function EditorHarness({
+ selection,
+ initial = createEmptyPuzzle(4),
+}: {
+ readonly selection: readonly number[];
+ readonly initial?: PuzzleDefinition;
+}) {
+ const [puzzle, setPuzzle] = useState(initial);
+ return (
+ <>
+
+ {JSON.stringify(puzzle)}
+ >
+ );
+}
+
+function currentPuzzle(): PuzzleDefinition {
+ return JSON.parse(
+ screen.getByTestId("puzzle-state").textContent ?? "{}",
+ ) as PuzzleDefinition;
+}
+
+describe("ConstraintEditor expansion controls", () => {
+ it("validates, replaces, polarizes and removes selected-cell markers", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(
+ screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
+ );
+ await user.click(screen.getByRole("button", { name: "Minimum cell" }));
+ expect(screen.getByText("false · minimum · r2c2")).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "Odd cell (circle)" }));
+ expect(screen.getByText("false · odd circle · r2c2")).toBeInTheDocument();
+
+ await user.click(
+ screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
+ );
+ await user.click(screen.getByRole("button", { name: "Odd cell (circle)" }));
+ expect(screen.getByText("odd circle · r2c2")).toBeInTheDocument();
+ expect(
+ currentPuzzle().constraints?.filter(({ type }) => type === "odd"),
+ ).toHaveLength(1);
+
+ await user.click(
+ screen.getByRole("button", { name: "Even cell (square)" }),
+ );
+ expect(screen.getByText("even square · r2c2")).toBeInTheDocument();
+ await user.click(
+ screen.getByRole("button", { name: "Remove selected cell markers" }),
+ );
+ expect(currentPuzzle().constraints).toEqual([]);
+ });
+
+ it("requires exactly one selected cell for cell-marker creation", () => {
+ render( );
+
+ for (const name of [
+ "Maximum cell",
+ "Minimum cell",
+ "Odd cell (circle)",
+ "Even cell (square)",
+ ]) {
+ expect(screen.getByRole("button", { name })).toBeDisabled();
+ }
+ expect(
+ screen.getByRole("button", { name: "Remove selected cell markers" }),
+ ).toBeDisabled();
+ });
+
+ it("toggles disjoint groups and validates/replaces new outside clues", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const disjoint = screen.getByRole("button", { name: "Disjoint groups" });
+ expect(disjoint).toHaveAttribute("aria-pressed", "false");
+ await user.click(disjoint);
+ expect(disjoint).toHaveAttribute("aria-pressed", "true");
+ expect(
+ screen.getByText(
+ "disjoint groups · matching box positions do not repeat",
+ ),
+ ).toBeInTheDocument();
+ await user.click(disjoint);
+ expect(disjoint).toHaveAttribute("aria-pressed", "false");
+
+ await user.selectOptions(screen.getByLabelText("Type"), "little-killer");
+ await user.selectOptions(screen.getByLabelText("Direction"), "down-left");
+ const add = screen.getByRole("button", {
+ name: "Add / replace outside clue",
+ });
+ // Top line 1 travelling down-left leaves the grid after one cell.
+ expect(add).toBeDisabled();
+
+ const line = screen.getByRole("spinbutton", { name: "Row / column" });
+ await user.clear(line);
+ await user.type(line, "2");
+ expect(add).toBeEnabled();
+ await user.click(add);
+ expect(
+ screen.getByText("little killer 3 · top 2 · down left"),
+ ).toBeInTheDocument();
+
+ const sum = screen.getByRole("spinbutton", { name: "Sum" });
+ await user.clear(sum);
+ await user.type(sum, "4");
+ await user.click(add);
+ expect(
+ screen.queryByText("little killer 3 · top 2 · down left"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByText("little killer 4 · top 2 · down left"),
+ ).toBeInTheDocument();
+ expect(
+ currentPuzzle().constraints?.filter(
+ ({ type }) => type === "little-killer",
+ ),
+ ).toHaveLength(1);
+
+ await user.selectOptions(screen.getByLabelText("Type"), "sandwich");
+ await user.clear(sum);
+ await user.type(sum, "1");
+ expect(add).toBeDisabled();
+ await user.clear(sum);
+ await user.type(sum, "3");
+ expect(add).toBeEnabled();
+ await user.click(add);
+ expect(screen.getByText("sandwich 3 · top 2")).toBeInTheDocument();
+
+ const constraintList = screen.getByRole("list");
+ await user.click(
+ within(constraintList).getByRole("button", {
+ name: "Remove little killer 4 · top 2 · down left",
+ }),
+ );
+ expect(
+ currentPuzzle().constraints?.some(({ type }) => type === "little-killer"),
+ ).toBe(false);
+ });
+
+ it("creates ordered Pack 2 lines with bounded whisper settings", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(
+ screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
+ );
+ await user.click(screen.getByRole("button", { name: "Between line" }));
+ expect(
+ screen.getByText("false · between line · 3 ordered cells"),
+ ).toBeInTheDocument();
+ await user.click(
+ screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
+ );
+
+ const difference = screen.getByRole("spinbutton", {
+ name: "Whisper minimum difference",
+ });
+ await user.clear(difference);
+ await user.type(difference, "0");
+ expect(
+ screen.getByRole("button", { name: "German whisper" }),
+ ).toBeDisabled();
+ await user.clear(difference);
+ await user.type(difference, "3");
+ await user.click(screen.getByRole("button", { name: "German whisper" }));
+ expect(
+ screen.getByText("German whisper ≥ 3 · 3 ordered cells"),
+ ).toBeInTheDocument();
+
+ await user.clear(difference);
+ await user.type(difference, "2");
+ await user.click(screen.getByRole("button", { name: "German whisper" }));
+ expect(
+ screen.queryByText("German whisper ≥ 3 · 3 ordered cells"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByText("German whisper ≥ 2 · 3 ordered cells"),
+ ).toBeInTheDocument();
+ expect(
+ currentPuzzle().constraints?.filter(
+ ({ type }) => type === "german-whisper",
+ ),
+ ).toHaveLength(1);
+
+ await user.click(screen.getByRole("button", { name: "Region-sum line" }));
+ expect(
+ screen.getByText("region-sum line · 3 ordered cells"),
+ ).toBeInTheDocument();
+ expect(currentPuzzle().constraints).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ type: "between-line",
+ cells: [0, 1, 2],
+ negated: true,
+ }),
+ expect.objectContaining({
+ type: "german-whisper",
+ cells: [0, 1, 2],
+ minimumDifference: 2,
+ }),
+ expect.objectContaining({
+ type: "region-sum-line",
+ cells: [0, 1, 2],
+ }),
+ ]),
+ );
+
+ await user.click(
+ screen.getByRole("button", {
+ name: "Remove region-sum line · 3 ordered cells",
+ }),
+ );
+ expect(
+ currentPuzzle().constraints?.some(
+ ({ type }) => type === "region-sum-line",
+ ),
+ ).toBe(false);
+ });
+
+ it("splits an ordered selection into clone pairs and builds an extra house", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const cloneButton = screen.getByRole("button", {
+ name: "Clone selection halves",
+ });
+ const extraButton = screen.getByRole("button", {
+ name: "Extra region (4 cells)",
+ });
+ expect(cloneButton).toBeEnabled();
+ expect(extraButton).toBeEnabled();
+
+ await user.click(cloneButton);
+ await user.click(cloneButton);
+ expect(
+ screen.getByText("clone regions · 2 + 2 paired cells"),
+ ).toBeInTheDocument();
+ expect(
+ currentPuzzle().constraints?.filter(({ type }) => type === "clone"),
+ ).toHaveLength(1);
+ expect(currentPuzzle().constraints).toContainEqual({
+ type: "clone",
+ cells: [0, 1],
+ cloneCells: [4, 5],
+ });
+
+ await user.click(extraButton);
+ await user.click(extraButton);
+ const extraDescription = screen.getByText("extra region · 4 cells");
+ expect(
+ currentPuzzle().constraints?.filter(
+ ({ type }) => type === "extra-region",
+ ),
+ ).toHaveLength(1);
+ const extraItem = extraDescription.closest("li");
+ expect(extraItem).not.toBeNull();
+ expect(
+ within(extraItem as HTMLElement).queryByRole("button", {
+ name: /require false/iu,
+ }),
+ ).not.toBeInTheDocument();
+ expect(
+ within(
+ screen
+ .getByText("clone regions · 2 + 2 paired cells")
+ .closest("li") as HTMLElement,
+ ).getByRole("button", { name: /require false/iu }),
+ ).toBeInTheDocument();
+
+ await user.click(
+ screen.getByRole("button", {
+ name: "Remove clone regions · 2 + 2 paired cells",
+ }),
+ );
+ await user.click(
+ screen.getByRole("button", { name: "Remove extra region · 4 cells" }),
+ );
+ expect(currentPuzzle().constraints).toEqual([]);
+ });
+
+ it("rejects incomplete Pack 2 selections", () => {
+ render( );
+
+ expect(
+ screen.getByRole("button", { name: "Clone selection halves" }),
+ ).toBeDisabled();
+ expect(
+ screen.getByRole("button", { name: "Extra region (4 cells)" }),
+ ).toBeDisabled();
+ });
+
+ it("creates, replaces, polarizes and removes ordered Pack 3 lines", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await user.click(
+ screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
+ );
+ for (const name of [
+ "Modular line",
+ "Entropic line",
+ "Zipper line",
+ "Double arrow",
+ ]) {
+ const button = screen.getByRole("button", { name });
+ expect(button).toBeEnabled();
+ await user.click(button);
+ }
+
+ expect(
+ screen.getByText("false · modular line · 3 ordered cells"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText("false · entropic line · 3 ordered cells"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText("false · zipper line · 3 ordered cells"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText("false · double arrow · 3 ordered cells"),
+ ).toBeInTheDocument();
+ expect(currentPuzzle().constraints).toHaveLength(4);
+
+ await user.click(screen.getByRole("button", { name: "Modular line" }));
+ expect(
+ currentPuzzle().constraints?.filter(
+ ({ type }) => type === "modular-line",
+ ),
+ ).toHaveLength(1);
+
+ await user.click(
+ screen.getByRole("button", {
+ name: "Remove false · zipper line · 3 ordered cells",
+ }),
+ );
+ expect(
+ currentPuzzle().constraints?.some(({ type }) => type === "zipper-line"),
+ ).toBe(false);
+ });
+
+ it("rejects incompatible Pack 3 grid and line lengths", () => {
+ const { unmount } = render( );
+
+ expect(
+ screen.getByRole("button", { name: "Entropic line" }),
+ ).toBeDisabled();
+ unmount();
+
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("button", { name: "Modular line" })).toBeEnabled();
+ expect(screen.getByRole("button", { name: "Double arrow" })).toBeEnabled();
+ expect(screen.getByRole("button", { name: "Entropic line" })).toBeEnabled();
+ expect(screen.getByRole("button", { name: "Zipper line" })).toBeDisabled();
+ expect(
+ screen.getByRole("button", { name: "Add / replace indexer" }),
+ ).toBeDisabled();
+ });
+
+ it("replaces indexer kinds at one marker and keeps polarity controls", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(
+ screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
+ );
+ await user.click(
+ screen.getByRole("button", { name: "Add / replace indexer" }),
+ );
+ expect(screen.getByText("false · row indexer · r2c2")).toBeInTheDocument();
+
+ await user.selectOptions(screen.getByLabelText("Indexer kind"), "column");
+ await user.click(
+ screen.getByRole("button", { name: "Add / replace indexer" }),
+ );
+ expect(
+ screen.queryByText("false · row indexer · r2c2"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByText("false · column indexer · r2c2"),
+ ).toBeInTheDocument();
+ expect(
+ currentPuzzle().constraints?.filter(({ type }) => type === "indexer"),
+ ).toHaveLength(1);
+
+ await user.click(
+ screen.getByRole("button", {
+ name: "Remove false · column indexer · r2c2",
+ }),
+ );
+ expect(currentPuzzle().constraints).toEqual([]);
+ });
+
+ it("requires a trusted solution for fog and replaces its lights and radius", async () => {
+ const user = userEvent.setup();
+ const { unmount } = render( );
+ const addFog = screen.getByRole("button", {
+ name: "Add / replace Fog of War",
+ });
+ expect(addFog).toBeDisabled();
+ expect(
+ screen.getByText(
+ "Fog of War requires a complete trusted solution. Generate or import one before choosing initial lights.",
+ ),
+ ).toBeInTheDocument();
+ unmount();
+
+ const solution = [1, 2, 3, 4, 3, 4, 1, 2, 2, 1, 4, 3, 4, 3, 2, 1];
+ render(
+ ,
+ );
+ const enabledFog = screen.getByRole("button", {
+ name: "Add / replace Fog of War",
+ });
+ expect(enabledFog).toBeEnabled();
+ await user.click(enabledFog);
+ expect(
+ screen.getByText("fog · 2 initial lights · radius 1"),
+ ).toBeInTheDocument();
+ expect(currentPuzzle().constraints).toContainEqual({
+ type: "fog",
+ lights: [0, 5],
+ revealRadius: 1,
+ });
+
+ await user.selectOptions(screen.getByLabelText("Fog reveal radius"), "0");
+ await user.click(enabledFog);
+ expect(
+ screen.queryByText("fog · 2 initial lights · radius 1"),
+ ).not.toBeInTheDocument();
+ const description = screen.getByText("fog · 2 initial lights · radius 0");
+ expect(description).toBeInTheDocument();
+ expect(
+ currentPuzzle().constraints?.filter(({ type }) => type === "fog"),
+ ).toHaveLength(1);
+ const item = description.closest("li");
+ expect(item).not.toBeNull();
+ expect(
+ within(item as HTMLElement).queryByRole("button", {
+ name: /require false/iu,
+ }),
+ ).not.toBeInTheDocument();
+ await user.click(
+ within(item as HTMLElement).getByRole("button", {
+ name: "Remove fog · 2 initial lights · radius 0",
+ }),
+ );
+ expect(currentPuzzle().constraints).toEqual([]);
+ });
+});
diff --git a/tests/components/generatorWorkspace.test.tsx b/tests/components/generatorWorkspace.test.tsx
index e585fe5..8e1eef3 100644
--- a/tests/components/generatorWorkspace.test.tsx
+++ b/tests/components/generatorWorkspace.test.tsx
@@ -2,6 +2,11 @@ import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { GeneratorWorkspace } from "../../src/components/GeneratorWorkspace";
+import { classicRegions } from "../../src/domain";
+import type {
+ GeneratedVariantBatch,
+ GeneratedVariantPuzzle,
+} from "../../src/solver";
describe("Sudoku generator workspace", () => {
it("offers only reliable sizes and submits an explicit local recipe", async () => {
@@ -97,4 +102,158 @@ describe("Sudoku generator workspace", () => {
}),
).toBeInTheDocument();
});
+
+ it("submits mixed families, density, minimality and a full profile as a batch", async () => {
+ const user = userEvent.setup();
+ const onGenerateBatch = vi.fn();
+ render(
+ ,
+ );
+
+ await user.selectOptions(screen.getByLabelText("Variant"), "thermo");
+ await user.click(screen.getByRole("checkbox", { name: "Kropki dots" }));
+ await user.selectOptions(
+ screen.getByLabelText("Given symmetry"),
+ "horizontal",
+ );
+ await user.selectOptions(
+ screen.getByLabelText("Constraint density"),
+ "dense",
+ );
+ await user.click(
+ screen.getByRole("checkbox", { name: "Prove minimal givens" }),
+ );
+ await user.selectOptions(
+ screen.getByLabelText("Practice technique"),
+ "x-wing",
+ );
+ await user.clear(screen.getByLabelText("Minimum occurrences"));
+ await user.type(screen.getByLabelText("Minimum occurrences"), "2");
+ await user.type(screen.getByLabelText("Maximum occurrences"), "3");
+ await user.selectOptions(
+ screen.getByLabelText("Forbidden technique"),
+ "swordfish",
+ );
+ await user.selectOptions(
+ screen.getByLabelText("Hardest technique target"),
+ "x-wing",
+ );
+ await user.clear(screen.getByLabelText("Batch size"));
+ await user.type(screen.getByLabelText("Batch size"), "3");
+ await user.selectOptions(
+ screen.getByLabelText("Batch ranking"),
+ "fewest-givens",
+ );
+ await user.type(screen.getByLabelText("Seed"), "mixed-batch");
+ await user.click(
+ screen.getByRole("button", { name: "Generate 3-puzzle batch" }),
+ );
+
+ expect(onGenerateBatch).toHaveBeenCalledWith({
+ variant: "thermo",
+ variants: ["thermo", "kropki"],
+ size: 9,
+ targetDifficulty: "medium",
+ symmetry: "horizontal",
+ constraintCount: 8,
+ constraintDensity: "dense",
+ minimalGivens: true,
+ requiredTechnique: "x-wing",
+ techniqueProfile: {
+ forbidden: ["swordfish"],
+ counts: [{ technique: "x-wing", min: 2, max: 3 }],
+ hardestTechnique: "x-wing",
+ },
+ maxTechniqueAttempts: 10,
+ seed: "mixed-batch",
+ batchSize: 3,
+ ranking: "fewest-givens",
+ });
+ });
+
+ it("renders ranked evidence and lets the caller open a candidate", async () => {
+ const user = userEvent.setup();
+ const onSelectGenerated = vi.fn();
+ const solution = [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3];
+ const generation: GeneratedVariantPuzzle = {
+ puzzle: {
+ version: 1,
+ size: 4,
+ givens: solution,
+ solution,
+ regions: classicRegions(4),
+ constraints: [],
+ },
+ difficulty: {
+ score: 12,
+ level: "beginner",
+ label: "Beginner",
+ uniqueness: "unique",
+ clueCount: 16,
+ emptyCount: 0,
+ logicalStatus: "solved",
+ logicalSteps: 0,
+ techniqueCounts: {},
+ exactNodes: 1,
+ exactTruncated: false,
+ summary: "fixture",
+ },
+ variant: "classic",
+ families: ["classic"],
+ seed: "ranked:1",
+ generatedConstraintCount: 0,
+ generationAttempts: 1,
+ constraintDensity: "balanced",
+ minimality: {
+ status: "not-requested",
+ checksPerformed: 0,
+ nodes: 0,
+ removedClues: 0,
+ criticalCells: [],
+ unknownCells: [],
+ limitReasons: [],
+ symmetryPreserved: true,
+ },
+ };
+ const batch: GeneratedVariantBatch = {
+ entries: [generation],
+ summaries: [
+ {
+ rank: 1,
+ seed: generation.seed,
+ families: generation.families,
+ clueCount: 16,
+ constraintCount: 0,
+ score: 12,
+ level: "beginner",
+ minimalityStatus: "not-requested",
+ },
+ ],
+ failures: [],
+ requested: 1,
+ completed: 1,
+ truncated: false,
+ ranking: "difficulty",
+ baseSeed: "ranked",
+ };
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText("1 of 1 generated")).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "Use" }));
+ expect(onSelectGenerated).toHaveBeenCalledWith(generation);
+ });
});
diff --git a/tests/components/guidedHint.test.tsx b/tests/components/guidedHint.test.tsx
new file mode 100644
index 0000000..2143055
--- /dev/null
+++ b/tests/components/guidedHint.test.tsx
@@ -0,0 +1,181 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import {
+ GuidedHint,
+ type GuidedHintProps,
+} from "../../src/components/GuidedHint";
+import {
+ deriveGuidedHintCellSets,
+ guidedHintEffectItems,
+ guidedHintFocusSummary,
+ guidedHintOverlay,
+ guidedHintStepIsVisible,
+ nextGuidedHintStage,
+} from "../../src/components/guidedHint";
+import type { LogicalStep } from "../../src/solver";
+
+const step: LogicalStep = {
+ technique: "naked-pair",
+ focusCells: [0, 1, 0],
+ placements: [{ cell: 9, value: 4 }],
+ eliminations: [{ cell: 10, values: [2, 3] }],
+ explanation: "The private pair reasoning is now visible.",
+};
+
+function props(overrides: Partial = {}): GuidedHintProps {
+ return {
+ size: 9,
+ step,
+ stage: "focus",
+ autoMaintainPeerNotes: false,
+ onRequestHint: vi.fn(),
+ onRevealNext: vi.fn(),
+ onApply: vi.fn(),
+ onDismiss: vi.fn(),
+ onFillLegalCandidates: vi.fn(),
+ onRemoveInvalidNotes: vi.fn(),
+ onAutoMaintainPeerNotesChange: vi.fn(),
+ ...overrides,
+ };
+}
+
+describe("guided hint presentation helpers", () => {
+ it("derives unique focus and effect cells without revealing effects early", () => {
+ expect(deriveGuidedHintCellSets(step)).toEqual({
+ focusCells: [0, 1],
+ placementCells: [9],
+ eliminationCells: [10],
+ affectedCells: [9, 10],
+ });
+ expect(guidedHintOverlay(step, "reasoning")).toEqual({
+ focusCells: [0, 1],
+ placementCells: [],
+ eliminationCells: [],
+ });
+ expect(guidedHintOverlay(step, "preview")).toEqual({
+ focusCells: [0, 1],
+ placementCells: [9],
+ eliminationCells: [10],
+ });
+ });
+
+ it("describes focus locations, effects and the finite reveal sequence", () => {
+ expect(guidedHintFocusSummary(step, 9)).toBe("Look across row 1.");
+ expect(guidedHintFocusSummary({ ...step, focusCells: [0, 9] }, 9)).toBe(
+ "Look down column 1.",
+ );
+ expect(guidedHintFocusSummary({ ...step, focusCells: [0, 10] }, 9)).toBe(
+ "Look within box 1.",
+ );
+ expect(guidedHintEffectItems(step, 9)).toEqual([
+ "Place 4 in r2c1.",
+ "Remove 2 and 3 from r2c2.",
+ ]);
+ expect(nextGuidedHintStage("focus")).toBe("technique");
+ expect(nextGuidedHintStage("technique")).toBe("reasoning");
+ expect(nextGuidedHintStage("reasoning")).toBe("preview");
+ expect(nextGuidedHintStage("preview")).toBeUndefined();
+ });
+
+ it("rejects a hint when fog hides a premise, placement or elimination", () => {
+ expect(guidedHintStepIsVisible(step, new Set())).toBe(true);
+ expect(guidedHintStepIsVisible(step, new Set([0]))).toBe(false);
+ expect(guidedHintStepIsVisible(step, new Set([9]))).toBe(false);
+ expect(guidedHintStepIsVisible(step, new Set([10]))).toBe(false);
+ expect(guidedHintStepIsVisible(step, new Set([80]))).toBe(true);
+ });
+});
+
+describe("guided hint", () => {
+ it("keeps unrevealed answer content out of the document", () => {
+ const base = props();
+ const { rerender } = render( );
+
+ expect(screen.getByText("Look across row 1.")).toBeInTheDocument();
+ expect(screen.queryByText("Naked Pair")).not.toBeInTheDocument();
+ expect(screen.queryByText(step.explanation)).not.toBeInTheDocument();
+ expect(screen.queryByText("Place 4 in r2c1.")).not.toBeInTheDocument();
+
+ rerender( );
+ expect(screen.getByText("Naked Pair")).toBeInTheDocument();
+ expect(screen.queryByText(step.explanation)).not.toBeInTheDocument();
+ expect(screen.queryByText("Place 4 in r2c1.")).not.toBeInTheDocument();
+
+ rerender( );
+ expect(screen.getByText(step.explanation)).toBeInTheDocument();
+ expect(screen.queryByText("Place 4 in r2c1.")).not.toBeInTheDocument();
+
+ rerender( );
+ expect(screen.getByText("Place 4 in r2c1.")).toBeInTheDocument();
+ expect(screen.getByText("Remove 2 and 3 from r2c2.")).toBeInTheDocument();
+ expect(
+ screen.getByText(/will start a complete legal centre-candidate grid/u),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Apply this step" }),
+ ).toBeEnabled();
+ });
+
+ it("delegates reveal, apply, replacement and dismissal to its parent", async () => {
+ const user = userEvent.setup();
+ const callbacks = {
+ onRevealNext: vi.fn(),
+ onApply: vi.fn(),
+ onRequestHint: vi.fn(),
+ onDismiss: vi.fn(),
+ };
+ const { rerender } = render(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Reveal technique" }));
+ expect(callbacks.onRevealNext).toHaveBeenCalledOnce();
+
+ await user.click(screen.getByRole("button", { name: "New hint" }));
+ await user.click(screen.getByRole("button", { name: "Dismiss" }));
+ expect(callbacks.onRequestHint).toHaveBeenCalledOnce();
+ expect(callbacks.onDismiss).toHaveBeenCalledOnce();
+
+ rerender( );
+ await user.click(screen.getByRole("button", { name: "Apply this step" }));
+ expect(callbacks.onApply).toHaveBeenCalledOnce();
+ });
+
+ it("offers candidate maintenance without requiring an open hint", async () => {
+ const user = userEvent.setup();
+ const onRequestHint = vi.fn();
+ const onFillLegalCandidates = vi.fn();
+ const onRemoveInvalidNotes = vi.fn();
+ const onAutoMaintainPeerNotesChange = vi.fn();
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Get a guided hint" }));
+ await user.click(
+ screen.getByRole("button", { name: "Fill legal candidates" }),
+ );
+ await user.click(
+ screen.getByRole("button", { name: "Remove invalid notes" }),
+ );
+ await user.click(
+ screen.getByRole("checkbox", {
+ name: "Automatically remove peer notes after placing a digit",
+ }),
+ );
+
+ expect(onRequestHint).toHaveBeenCalledOnce();
+ expect(onFillLegalCandidates).toHaveBeenCalledOnce();
+ expect(onRemoveInvalidNotes).toHaveBeenCalledOnce();
+ expect(onAutoMaintainPeerNotesChange).toHaveBeenCalledWith(true);
+ });
+});
diff --git a/tests/components/importExportDialog.test.tsx b/tests/components/importExportDialog.test.tsx
index 1e620eb..8977ab2 100644
--- a/tests/components/importExportDialog.test.tsx
+++ b/tests/components/importExportDialog.test.tsx
@@ -4,7 +4,10 @@ import { describe, expect, it, vi } from "vitest";
import { ImportExportDialog } from "../../src/components/ImportExportDialog";
import { createEmptyPuzzle } from "../../src/domain";
import type { PuzzleDefinition } from "../../src/domain/types";
-import { fromDomainPuzzle } from "../../src/formats";
+import {
+ fromDomainPuzzle,
+ type PreservedSudokuDocumentExtras,
+} from "../../src/formats";
import type { PortableAidMemoire } from "../../src/state/aidMemoire";
import { createSession } from "../../src/state/session";
@@ -40,6 +43,7 @@ function renderDialog(
options: {
readonly puzzle?: PuzzleDefinition;
readonly aidMemoire?: PortableAidMemoire;
+ readonly preservedExtras?: PreservedSudokuDocumentExtras;
} = {},
) {
const puzzle = options.puzzle ?? createEmptyPuzzle(4);
@@ -51,6 +55,7 @@ function renderDialog(
puzzle={puzzle}
session={createSession(puzzle.givens)}
aidMemoire={options.aidMemoire}
+ preservedExtras={options.preservedExtras}
onClose={onClose}
onImport={onImport}
/>,
@@ -79,11 +84,14 @@ describe("ImportExportDialog interoperability", () => {
expect(onImport).toHaveBeenCalledWith(
expect.objectContaining({ size: 4, givens: expect.any(Array) }),
expect.any(Object),
+ expect.objectContaining({
+ source: { format: "sudokupad", id: "synthetic-local-test" },
+ }),
);
expect(onClose).toHaveBeenCalledOnce();
});
- it("reports unsupported constructs and never fetches short IDs", async () => {
+ it("previews safe visuals and never fetches short IDs", async () => {
const user = userEvent.setup();
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
@@ -91,12 +99,35 @@ describe("ImportExportDialog interoperability", () => {
const input = screen.getByPlaceholderText(/Paste 81 characters/u);
fireEvent.change(input, {
- target: { value: localScl({ overlays: [{ text: "visual only" }] }) },
+ target: {
+ value: localScl({
+ overlays: [
+ {
+ center: [0.5, 0.5],
+ width: 1,
+ height: 1,
+ text: "visual only",
+ },
+ ],
+ }),
+ },
});
await user.click(
screen.getByRole("button", { name: "Check compatibility" }),
);
- expect(await screen.findByText(/visual overlays/u)).toBeInTheDocument();
+ expect(
+ await screen.findByRole("region", { name: "Import mapping preview" }),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Preserved visuals")).toBeInTheDocument();
+ expect(screen.getByText("Text overlay: 1")).toBeInTheDocument();
+
+ fireEvent.change(input, {
+ target: { value: localScl({ overlays: [{ text: "missing center" }] }) },
+ });
+ await user.click(
+ screen.getByRole("button", { name: "Check compatibility" }),
+ );
+ expect(await screen.findByText(/center.*point/u)).toBeInTheDocument();
fireEvent.change(input, {
target: { value: "https://sudokupad.app/serverOnly42" },
@@ -164,6 +195,47 @@ describe("ImportExportDialog interoperability", () => {
expect(onImport).toHaveBeenCalledWith(
expect.objectContaining({ size: 4 }),
expect.objectContaining({ aidMemoire: TEST_AID_MEMOIRE }),
+ { source: { format: "sudoku-tools" } },
+ );
+ });
+
+ it("includes preserved source extras in SudokuPad exports", async () => {
+ const user = userEvent.setup();
+ let downloaded: Blob | undefined;
+ vi.mocked(URL.createObjectURL).mockImplementation((value) => {
+ downloaded = value as Blob;
+ return "blob:scl-export-test";
+ });
+ vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
+ renderDialog({
+ preservedExtras: {
+ source: { format: "sudokupad", id: "kept-source" },
+ metadata: { edition: "nightly" },
+ visuals: [
+ {
+ type: "text",
+ layer: "overlay",
+ position: { kind: "cell", cell: 0 },
+ text: "kept visual",
+ style: { fill: "#123456" },
+ },
+ ],
+ },
+ });
+
+ await user.click(
+ screen.getByRole("button", { name: "Download SudokuPad JSON" }),
+ );
+
+ const exported = JSON.parse((await downloaded?.text()) ?? "{}") as {
+ metadata?: Record;
+ overlays?: unknown[];
+ };
+ expect(exported.metadata?.edition).toBe("nightly");
+ expect(exported.overlays).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ text: "kept visual" }),
+ ]),
);
});
diff --git a/tests/components/libraryDialog.test.tsx b/tests/components/libraryDialog.test.tsx
new file mode 100644
index 0000000..49eb219
--- /dev/null
+++ b/tests/components/libraryDialog.test.tsx
@@ -0,0 +1,109 @@
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { LibraryDialog } from "../../src/components/LibraryDialog";
+
+const summaries = [
+ {
+ id: "killer",
+ title: "Evening Killer",
+ createdAt: 1,
+ updatedAt: 2,
+ size: 4,
+ completed: false,
+ tags: ["killer", "hard"],
+ thumbnail: "1...............",
+ },
+ {
+ id: "classic",
+ title: "Morning Classic",
+ createdAt: 1,
+ updatedAt: 3,
+ size: 4,
+ completed: true,
+ tags: ["classic"],
+ thumbnail: "....2...........",
+ },
+] as const;
+
+function renderDialog(overrides: Record = {}) {
+ const props = {
+ open: true,
+ summaries,
+ mode: "indexeddb" as const,
+ busy: false,
+ onClose: vi.fn(),
+ onSave: vi.fn(),
+ onOpen: vi.fn(),
+ onDelete: vi.fn(),
+ onClear: vi.fn(),
+ onExport: vi.fn(),
+ onExportSelected: vi.fn(),
+ onDuplicateSelected: vi.fn(),
+ onDeleteSelected: vi.fn(),
+ onUpdateTags: vi.fn(),
+ onImport: vi.fn(),
+ ...overrides,
+ };
+ render( );
+ return props;
+}
+
+describe("LibraryDialog", () => {
+ it("filters by text, tag and completion while showing safe previews", async () => {
+ const user = userEvent.setup();
+ renderDialog();
+
+ expect(
+ screen.getAllByRole("img", { name: /puzzle preview/u }),
+ ).toHaveLength(2);
+ await user.type(screen.getByRole("searchbox"), "killer");
+ expect(screen.getByText("Evening Killer")).toBeInTheDocument();
+ expect(screen.queryByText("Morning Classic")).not.toBeInTheDocument();
+
+ await user.clear(screen.getByRole("searchbox"));
+ await user.selectOptions(
+ screen.getByLabelText("Completion filter"),
+ "complete",
+ );
+ expect(screen.getByText("Morning Classic")).toBeInTheDocument();
+ expect(screen.queryByText("Evening Killer")).not.toBeInTheDocument();
+ });
+
+ it("exports, duplicates and deletes an explicit selection", async () => {
+ const user = userEvent.setup();
+ const props = renderDialog();
+
+ await user.click(screen.getByLabelText("Select Evening Killer"));
+ const selection = screen.getByText(/1 selected/u).parentElement!;
+ await user.click(
+ within(selection).getByRole("button", { name: "Export selected" }),
+ );
+ await user.click(
+ within(selection).getByRole("button", { name: "Duplicate selected" }),
+ );
+ await user.click(
+ within(selection).getByRole("button", { name: "Delete selected" }),
+ );
+
+ expect(props.onExportSelected).toHaveBeenCalledWith(["killer"]);
+ expect(props.onDuplicateSelected).toHaveBeenCalledWith(["killer"]);
+ expect(props.onDeleteSelected).toHaveBeenCalledWith(["killer"]);
+ });
+
+ it("edits bounded comma-separated tags", async () => {
+ const user = userEvent.setup();
+ const props = renderDialog();
+
+ const item = screen.getByText("Evening Killer").closest("li")!;
+ await user.click(within(item).getByRole("button", { name: "Edit tags" }));
+ const input = within(item).getByLabelText("Tags for Evening Killer");
+ await user.clear(input);
+ await user.type(input, "killer, weekend");
+ await user.click(within(item).getByRole("button", { name: "Apply" }));
+ expect(props.onUpdateTags).toHaveBeenCalledWith("killer", [
+ "killer",
+ "weekend",
+ ]);
+ });
+});
diff --git a/tests/components/numberPad.test.tsx b/tests/components/numberPad.test.tsx
new file mode 100644
index 0000000..272cdb4
--- /dev/null
+++ b/tests/components/numberPad.test.tsx
@@ -0,0 +1,30 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { NumberPad } from "../../src/components/NumberPad";
+
+describe("NumberPad colour accessibility", () => {
+ it("names each colour by both hue and pattern", async () => {
+ const user = userEvent.setup();
+ const onValue = vi.fn();
+ const { container } = render(
+ ,
+ );
+
+ const stripes = screen.getByRole("button", {
+ name: "Colour 1: red, diagonal stripes",
+ });
+ expect(stripes).toHaveClass("color-1");
+ expect(container.querySelector(".color-8")).toHaveAccessibleName(
+ "Colour 8: pink, rings",
+ );
+ await user.click(stripes);
+ expect(onValue).toHaveBeenCalledWith(1);
+ });
+});
diff --git a/tests/components/safeVisualLayer.test.tsx b/tests/components/safeVisualLayer.test.tsx
new file mode 100644
index 0000000..4e4a584
--- /dev/null
+++ b/tests/components/safeVisualLayer.test.tsx
@@ -0,0 +1,126 @@
+import { render } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { SafeVisualLayer } from "../../src/components/SafeVisualLayer";
+import type { SafeVisualPrimitive } from "../../src/formats";
+
+const visuals: readonly SafeVisualPrimitive[] = [
+ {
+ type: "line",
+ layer: "underlay",
+ start: { kind: "coordinate", x: 0.25, y: 0.75 },
+ end: { kind: "cell", cell: 5, offsetX: 0.1, offsetY: -0.1 },
+ style: { stroke: "#123456", strokeWidth: 0.05, opacity: 0.75 },
+ },
+ {
+ type: "polyline",
+ layer: "underlay",
+ points: [
+ { kind: "cell", cell: 0 },
+ { kind: "cell", cell: 1 },
+ { kind: "cell", cell: 5 },
+ ],
+ style: { stroke: "#abcdef", fill: "transparent" },
+ },
+ {
+ type: "rectangle",
+ layer: "overlay",
+ center: { kind: "cell", cell: 6 },
+ width: 0.8,
+ height: 0.6,
+ cornerRadius: 0.1,
+ style: { fill: "#ffeecc", stroke: "#112233" },
+ },
+ {
+ type: "ellipse",
+ layer: "overlay",
+ center: { kind: "coordinate", x: 2.5, y: 2.5 },
+ radiusX: 0.4,
+ radiusY: 0.2,
+ },
+ {
+ type: "circle",
+ layer: "overlay",
+ center: { kind: "cell", cell: 10 },
+ radius: 0.25,
+ },
+ {
+ type: "text",
+ layer: "overlay",
+ position: { kind: "cell", cell: 15 },
+ text: "source label",
+ style: { fill: "#010203", fontSize: 0.4 },
+ },
+];
+
+describe("SafeVisualLayer", () => {
+ it("renders every canonical primitive with stable classes and grid geometry", () => {
+ const { container } = render(
+
+
+
+ ,
+ );
+
+ const line = container.querySelector(".source-visual--line");
+ expect(line).toHaveAttribute("x1", "0.25");
+ expect(line).toHaveAttribute("y1", "0.75");
+ expect(line).toHaveAttribute("x2", "1.6");
+ expect(line).toHaveAttribute("y2", "1.4");
+ expect(line).toHaveAttribute("stroke", "#123456");
+ expect(line).toHaveAttribute("stroke-width", "0.05");
+ expect(container.querySelector(".source-visual--polyline")).toHaveAttribute(
+ "points",
+ "0.5,0.5 1.5,0.5 1.5,1.5",
+ );
+ expect(
+ container.querySelector(".source-visual--rectangle"),
+ ).toHaveAttribute("rx", "0.1");
+ expect(container.querySelector(".source-visual--ellipse")).toHaveAttribute(
+ "rx",
+ "0.4",
+ );
+ expect(container.querySelector(".source-visual--circle")).toHaveAttribute(
+ "r",
+ "0.25",
+ );
+ expect(container.querySelector(".source-visual--text")).toHaveTextContent(
+ "source label",
+ );
+ expect(
+ container.querySelectorAll(".safe-visual-layer--underlay > *"),
+ ).toHaveLength(2);
+ expect(
+ container.querySelectorAll(".safe-visual-layer--overlay > *"),
+ ).toHaveLength(4);
+ });
+
+ it("uses a React text node and never creates executable descendants", () => {
+ const malicious =
+ '';
+ const { container } = render(
+
+
+ ,
+ );
+
+ expect(container.querySelector(".source-visual--text")?.textContent).toBe(
+ malicious,
+ );
+ expect(container.querySelector("script, image")).toBeNull();
+ expect(
+ container.querySelector("[href], [src], [onclick], [onload]"),
+ ).toBeNull();
+ });
+});
diff --git a/tests/components/setterQualityLab.test.tsx b/tests/components/setterQualityLab.test.tsx
new file mode 100644
index 0000000..2acf65e
--- /dev/null
+++ b/tests/components/setterQualityLab.test.tsx
@@ -0,0 +1,279 @@
+import { fireEvent, render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import {
+ SetterQualityLab,
+ type SetterQualityLabProps,
+} from "../../src/components/SetterQualityLab";
+import type {
+ PuzzleQualityAnalysis,
+ QualityItemReference,
+} from "../../src/solver/quality";
+
+const given: QualityItemReference = { kind: "given", cell: 0, value: 1 };
+const constraint: QualityItemReference = {
+ kind: "constraint",
+ index: 0,
+ constraintType: "diagonal",
+};
+
+const bounds = {
+ perCheck: { maxNodes: 2_000_000, timeoutMs: 10_000 },
+ aggregate: { maxChecks: 1_000, maxNodes: 20_000_000, timeoutMs: 30_000 },
+} as const;
+
+const multiple: PuzzleQualityAnalysis = {
+ analysisDepth: "full",
+ solutionStatus: "multiple",
+ ambiguityWitness: {
+ firstSolution: [1, 2, 3, 4],
+ secondSolution: [2, 1, 3, 4],
+ differences: [
+ { cell: 0, first: 1, second: 2 },
+ { cell: 1, first: 2, second: 1 },
+ ],
+ },
+ contradiction: {
+ status: "not-applicable",
+ core: [],
+ necessary: [],
+ removable: [],
+ unknown: [],
+ },
+ redundancy: {
+ givens: [
+ {
+ item: given,
+ classification: "critical",
+ checkIndex: 1,
+ solutionStatus: "multiple",
+ },
+ ],
+ constraints: [
+ {
+ item: constraint,
+ classification: "redundant",
+ checkIndex: 2,
+ solutionStatus: "unique",
+ },
+ ],
+ },
+ criticalityHeatmap: [
+ {
+ cell: 0,
+ score: 1,
+ criticalWeight: 1,
+ redundantWeight: 0,
+ unknownWeight: 0,
+ },
+ {
+ cell: 1,
+ score: null,
+ criticalWeight: 0,
+ redundantWeight: 0,
+ unknownWeight: 1,
+ },
+ ],
+ minimality: {
+ status: "not-minimal",
+ redundant: [constraint],
+ unknown: [],
+ },
+ checks: [
+ {
+ index: 0,
+ purpose: "baseline",
+ solutionStatus: "multiple",
+ solutionsFound: 2,
+ conclusive: true,
+ truncated: true,
+ limitReason: "solution-cap",
+ nodes: 10,
+ elapsedMs: 2,
+ },
+ ],
+ bounds,
+ budget: {
+ checksPlanned: 3,
+ checksPerformed: 3,
+ nodes: 42,
+ elapsedMs: 12,
+ truncated: false,
+ unknownReasons: [],
+ },
+};
+
+function props(
+ overrides: Partial = {},
+): SetterQualityLabProps {
+ return {
+ size: 4,
+ onRunQuick: vi.fn(),
+ onRunMinimality: vi.fn(),
+ onCancel: vi.fn(),
+ onFocusCells: vi.fn(),
+ onFocusItem: vi.fn(),
+ ...overrides,
+ };
+}
+
+describe("setter quality lab", () => {
+ it("runs distinct quick and bounded-minimality profiles and supports cancel", async () => {
+ const user = userEvent.setup();
+ const onRunQuick = vi.fn();
+ const onRunMinimality = vi.fn();
+ const onCancel = vi.fn();
+ const base = props({ onRunQuick, onRunMinimality, onCancel });
+ const { rerender } = render( );
+
+ fireEvent.change(screen.getByLabelText("Nodes per check"), {
+ target: { value: "123456" },
+ });
+ await user.click(
+ screen.getByRole("button", { name: "Run quick quality check" }),
+ );
+ expect(onRunQuick).toHaveBeenCalledWith({
+ perCheckMaxNodes: 123_456,
+ perCheckTimeoutMs: 10_000,
+ aggregateMaxChecks: 1_000,
+ aggregateMaxNodes: 20_000_000,
+ aggregateTimeoutMs: 30_000,
+ analysisDepth: "baseline",
+ proveMinimality: false,
+ });
+
+ await user.click(
+ screen.getByRole("button", { name: "Run full bounded minimality" }),
+ );
+ expect(onRunMinimality).toHaveBeenCalledWith(
+ expect.objectContaining({
+ analysisDepth: "full",
+ proveMinimality: true,
+ }),
+ );
+
+ rerender( );
+ expect(
+ screen.getByText("Full bounded minimality analysis running locally…"),
+ ).toHaveAttribute("role", "status");
+ expect(
+ screen.getByRole("button", { name: "Run quick quality check" }),
+ ).toBeDisabled();
+ await user.click(screen.getByRole("button", { name: "Cancel analysis" }));
+ expect(onCancel).toHaveBeenCalledOnce();
+ });
+
+ it("renders an ambiguity witness, findings, heatmap and bounded metrics", async () => {
+ const user = userEvent.setup();
+ const onFocusCells = vi.fn();
+ const onFocusItem = vi.fn();
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByRole("heading", { name: "Multiple completions found" }),
+ ).toBeInTheDocument();
+ expect(screen.getByText("2 differing cells")).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "Focus differences" }));
+ expect(onFocusCells).toHaveBeenCalledWith([0, 1]);
+
+ await user.click(
+ screen.getByRole("button", {
+ name: /r1c1: critical; score 1\.00/u,
+ }),
+ );
+ expect(onFocusCells).toHaveBeenLastCalledWith([0]);
+ expect(
+ screen.getByRole("button", { name: /r1c2: unknown; score unknown/u }),
+ ).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "Given 1 at r1c1" }));
+ expect(onFocusItem).toHaveBeenCalledWith(given);
+
+ const metrics = screen
+ .getByRole("heading", { name: "Checks and limits" })
+ .closest("section")!;
+ expect(within(metrics).getByText("3 / 3")).toBeInTheDocument();
+ expect(
+ within(metrics).getByText("2,000,000 nodes / 10,000 ms"),
+ ).toBeInTheDocument();
+ expect(
+ within(metrics).getByText("1,000 checks / 20,000,000 nodes / 30,000 ms"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("heading", { name: "Not minimal" }),
+ ).toBeInTheDocument();
+ });
+
+ it("labels bounded unknowns and exposes contradiction suspects", async () => {
+ const user = userEvent.setup();
+ const onFocusItem = vi.fn();
+ const unknown: PuzzleQualityAnalysis = {
+ ...multiple,
+ solutionStatus: "unknown",
+ ambiguityWitness: undefined,
+ contradiction: {
+ status: "incomplete",
+ core: [given, constraint],
+ necessary: [given],
+ removable: [],
+ unknown: [constraint],
+ reason: "The aggregate time budget ended localization.",
+ },
+ redundancy: {
+ givens: [
+ {
+ item: given,
+ classification: "unknown",
+ unknownReason: "aggregate-timeout",
+ },
+ ],
+ constraints: [],
+ },
+ criticalityHeatmap: [
+ {
+ cell: 0,
+ score: null,
+ criticalWeight: 0,
+ redundantWeight: 0,
+ unknownWeight: 1,
+ },
+ ],
+ minimality: {
+ status: "unknown",
+ redundant: [],
+ unknown: [constraint],
+ reason: "One or more clue checks were bounded.",
+ },
+ budget: {
+ ...multiple.budget,
+ checksPerformed: 2,
+ truncated: true,
+ unknownReasons: ["aggregate-timeout"],
+ },
+ };
+ render( );
+
+ expect(
+ screen.getByRole("heading", { name: "Solution status unknown" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getAllByText(/incomplete \/ unknown/iu).length,
+ ).toBeGreaterThan(1);
+ expect(
+ screen.getByText("The aggregate time budget ended localization."),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Aggregate timeout")).toBeInTheDocument();
+
+ const necessary = screen
+ .getByRole("heading", { name: "Proven necessary suspects" })
+ .closest("section")!;
+ await user.click(
+ within(necessary).getByRole("button", { name: "Given 1 at r1c1" }),
+ );
+ expect(onFocusItem).toHaveBeenCalledWith(given);
+ });
+});
diff --git a/tests/components/sudokuBoard.test.tsx b/tests/components/sudokuBoard.test.tsx
index 5269028..51aca62 100644
--- a/tests/components/sudokuBoard.test.tsx
+++ b/tests/components/sudokuBoard.test.tsx
@@ -1,9 +1,77 @@
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { normalizePuzzle } from "../../src/domain";
import { SudokuBoard } from "../../src/components/SudokuBoard";
+import { foggedCellsForPuzzle } from "../../src/components/fogVisibility";
describe("Sudoku board constraint visuals", () => {
+ it("orders retained underlays before semantic clues and overlays afterward", () => {
+ const puzzle = normalizePuzzle({
+ version: 1,
+ size: 4,
+ givens: new Array(16).fill(0),
+ solution: [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3],
+ constraints: [
+ { type: "diagonal", direction: "main" },
+ { type: "fog", lights: [0], revealRadius: 0 },
+ ],
+ });
+ const { container } = render(
+ ,
+ );
+
+ const underlay = container.querySelector(".safe-visual-layer--underlay")!;
+ const regions = container.querySelector(".region-boundaries")!;
+ const semantic = container.querySelector(".constraint-diagonal")!;
+ const overlay = container.querySelector(".safe-visual-layer--overlay")!;
+ const fogMask = container.querySelector(".fog-constraint-mask")!;
+ expect(
+ underlay.compareDocumentPosition(regions) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ expect(
+ underlay.compareDocumentPosition(semantic) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ expect(
+ semantic.compareDocumentPosition(overlay) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ expect(
+ overlay.compareDocumentPosition(fogMask) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ expect(fogMask.querySelectorAll("rect").length).toBeGreaterThan(0);
+ expect(underlay.querySelector(".source-visual--circle")).toBeTruthy();
+ expect(overlay.querySelector(".source-visual--text")).toHaveTextContent(
+ "overlay",
+ );
+ });
+
it("distinguishes XV sums from directional inequalities", () => {
const puzzle = normalizePuzzle({
version: 1,
@@ -112,6 +180,412 @@ describe("Sudoku board constraint visuals", () => {
);
});
+ it("renders Pack 1 cell, global and outside clues with distinct semantics", () => {
+ const puzzle = normalizePuzzle({
+ version: 1,
+ size: 4,
+ givens: new Array(16).fill(0),
+ constraints: [
+ { type: "maximum", cell: 4 },
+ { type: "minimum", cell: 5, negated: true },
+ { type: "odd", cell: 9 },
+ { type: "even", cell: 10 },
+ { type: "disjoint-groups" },
+ {
+ type: "little-killer",
+ side: "top",
+ index: 1,
+ direction: "down-right",
+ sum: 7,
+ },
+ { type: "sandwich", side: "left", index: 2, sum: 3 },
+ ],
+ });
+
+ const { container } = render(
+ ,
+ );
+
+ const maximumPath = container
+ .querySelector(".constraint-maximum path")
+ ?.getAttribute("d");
+ const minimumPath = container
+ .querySelector(".constraint-minimum path")
+ ?.getAttribute("d");
+ expect(maximumPath).toBeTruthy();
+ expect(minimumPath).toBeTruthy();
+ expect(minimumPath).not.toBe(maximumPath);
+ expect(container.querySelector(".constraint-minimum")).toHaveClass(
+ "is-negated",
+ );
+ expect(
+ container.querySelector(".constraint-odd circle"),
+ ).toBeInTheDocument();
+ expect(
+ container.querySelector(".constraint-even rect"),
+ ).toBeInTheDocument();
+ expect(
+ container.querySelectorAll(".constraint-disjoint-groups path"),
+ ).toHaveLength(4);
+ expect(
+ container.querySelector(
+ '.constraint-little-killer[data-direction="down-right"] .little-killer-arrow',
+ ),
+ ).toBeInTheDocument();
+ expect(
+ container.querySelector(".constraint-little-killer text"),
+ ).toHaveTextContent("7");
+ expect(
+ container.querySelector(".constraint-sandwich .outside-clue-kinds"),
+ ).toHaveTextContent("1⋯N");
+ expect(
+ container.querySelector(".constraint-sandwich .outside-clue-value"),
+ ).toHaveTextContent("3");
+
+ const board = screen.getByRole("grid", { name: "4 by 4 Sudoku grid" });
+ expect(board).toHaveAccessibleDescription(
+ expect.stringContaining(
+ "Disjoint groups rule: corresponding positions in every standard box contain each digit once.",
+ ),
+ );
+ expect(board).toHaveAccessibleDescription(
+ expect.stringContaining(
+ "little killer sum 7 from the top, column 2, travelling down right.",
+ ),
+ );
+ expect(board).toHaveAccessibleDescription(
+ expect.stringContaining(
+ "sandwich sum 3 from the left, row 3, between 1 and 4.",
+ ),
+ );
+ expect(
+ screen.getByRole("gridcell", { name: "Row 3, column 2, empty" }),
+ ).toHaveAccessibleDescription(
+ expect.stringMatching(/odd digit.*sandwich sum 3/iu),
+ );
+ });
+
+ it("renders Pack 2 lines and regions as distinct accessible clues", () => {
+ const puzzle = normalizePuzzle({
+ version: 1,
+ size: 4,
+ givens: new Array(16).fill(0),
+ constraints: [
+ { type: "between-line", cells: [0, 1, 2], negated: true },
+ {
+ type: "german-whisper",
+ cells: [4, 5, 6],
+ minimumDifference: 2,
+ },
+ { type: "region-sum-line", cells: [8, 9, 10] },
+ { type: "clone", cells: [0, 4], cloneCells: [3, 7] },
+ { type: "extra-region", cells: [0, 3, 12, 15] },
+ ],
+ });
+
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector(".constraint-between-line")).toHaveClass(
+ "is-negated",
+ );
+ expect(
+ container.querySelectorAll(".constraint-between-line > circle"),
+ ).toHaveLength(2);
+ expect(
+ container.querySelector(".constraint-german-whisper polyline"),
+ ).toBeInTheDocument();
+ expect(
+ container.querySelector(".constraint-region-sum-line polyline"),
+ ).toBeInTheDocument();
+ expect(
+ container.querySelectorAll(
+ ".constraint-region-sum-line .region-sum-divider",
+ ),
+ ).toHaveLength(1);
+ expect(
+ container.querySelectorAll(".constraint-clone .clone-cell-fill"),
+ ).toHaveLength(4);
+ expect(
+ container.querySelectorAll(".constraint-clone .clone-boundary"),
+ ).toHaveLength(2);
+ expect(
+ container.querySelector(".constraint-clone .clone-link"),
+ ).toBeInTheDocument();
+ expect(
+ container.querySelectorAll(".constraint-clone .clone-label"),
+ ).toHaveLength(2);
+ expect(
+ container.querySelectorAll(
+ ".constraint-extra-region .extra-region-cell-fill",
+ ),
+ ).toHaveLength(4);
+
+ const board = screen.getByRole("grid", { name: "4 by 4 Sudoku grid" });
+ expect(board).toHaveAccessibleDescription(
+ expect.stringContaining(
+ "False clue: between line from row 1, column 1 through row 1, column 2 to row 1, column 3",
+ ),
+ );
+ expect(board).toHaveAccessibleDescription(
+ expect.stringContaining(
+ "German whisper through row 2, column 1; row 2, column 2; row 2, column 3; adjacent digits differ by at least 2.",
+ ),
+ );
+ expect(board).toHaveAccessibleDescription(
+ expect.stringContaining(
+ "clone regions pairing row 1, column 1; row 2, column 1 with row 1, column 4; row 2, column 4 in order.",
+ ),
+ );
+ expect(board).toHaveAccessibleDescription(
+ expect.stringContaining(
+ "Extra region through row 1, column 1; row 1, column 4; row 4, column 1; row 4, column 4; every digit appears exactly once.",
+ ),
+ );
+ expect(
+ screen.getByRole("gridcell", { name: "Row 1, column 1, empty" }),
+ ).toHaveAccessibleDescription(
+ expect.stringMatching(/between line.*clone regions.*extra region/iu),
+ );
+ });
+
+ it("renders Pack 3 pattern lines and indexers with distinct non-colour shapes", () => {
+ const puzzle = normalizePuzzle({
+ version: 1,
+ size: 6,
+ givens: new Array(36).fill(0),
+ constraints: [
+ { type: "modular-line", cells: [0, 1, 2], negated: true },
+ { type: "entropic-line", cells: [6, 7, 8] },
+ { type: "zipper-line", cells: [12, 13, 14, 15, 16] },
+ { type: "double-arrow", cells: [18, 19, 20] },
+ { type: "indexer", kind: "row", cell: 24 },
+ { type: "indexer", kind: "column", cell: 25 },
+ { type: "indexer", kind: "box", cell: 26 },
+ ],
+ });
+
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector(".constraint-modular-line")).toHaveClass(
+ "is-negated",
+ );
+ expect(
+ container.querySelectorAll(".constraint-modular-line .modular-line-node"),
+ ).toHaveLength(3);
+ expect(
+ container.querySelector(
+ ".constraint-entropic-line .entropic-line-underlay",
+ ),
+ ).toBeInTheDocument();
+ expect(
+ container.querySelector(".constraint-entropic-line .entropic-line-path"),
+ ).toBeInTheDocument();
+ expect(
+ container.querySelector(".constraint-zipper-line .zipper-line-centre"),
+ ).toBeInTheDocument();
+ expect(
+ container.querySelectorAll(".constraint-double-arrow > circle"),
+ ).toHaveLength(2);
+ expect(
+ container.querySelectorAll(
+ ".constraint-double-arrow .double-arrow-chevron",
+ ),
+ ).toHaveLength(2);
+ expect(
+ container.querySelector(".constraint-indexer--row circle"),
+ ).toBeInTheDocument();
+ expect(
+ container.querySelector(".constraint-indexer--column rect"),
+ ).toBeInTheDocument();
+ expect(
+ container.querySelector(".constraint-indexer--box rect"),
+ ).toBeInTheDocument();
+ expect(
+ container.querySelector(".constraint-indexer--row text"),
+ ).toHaveTextContent("R");
+ expect(
+ container.querySelector(".constraint-indexer--column text"),
+ ).toHaveTextContent("C");
+ expect(
+ container.querySelector(".constraint-indexer--box text"),
+ ).toHaveTextContent("B");
+
+ const board = screen.getByRole("grid", { name: "6 by 6 Sudoku grid" });
+ expect(board).toHaveAccessibleDescription(
+ expect.stringContaining(
+ "False clue: modular line through row 1, column 1; row 1, column 2; row 1, column 3",
+ ),
+ );
+ expect(board).toHaveAccessibleDescription(
+ expect.stringContaining(
+ "entropic line through row 2, column 1; row 2, column 2; row 2, column 3",
+ ),
+ );
+ expect(board).toHaveAccessibleDescription(
+ expect.stringContaining(
+ "row indexer at row 5, column 1; the marker digit selects a row in the same column",
+ ),
+ );
+ });
+
+ it("masks fogged content and clues until a correct entry reveals them", () => {
+ const solution = [1, 2, 3, 4, 3, 4, 1, 2, 2, 1, 4, 3, 4, 3, 2, 1];
+ const puzzle = normalizePuzzle({
+ version: 1,
+ size: 4,
+ givens: new Array(16).fill(0),
+ solution,
+ constraints: [
+ { type: "fog", lights: [0], revealRadius: 1 },
+ { type: "even", cell: 10 },
+ ],
+ });
+ const pointerDown = vi.fn();
+ const keyDown = vi.fn();
+ const wrongValues = new Array(16).fill(0);
+ wrongValues[5] = 2;
+ wrongValues[10] = 3;
+ const { container, rerender } = render(
+ (16).fill(15)}
+ selected={new Set([10])}
+ highlighted={new Set([10])}
+ conflicts={new Set([10])}
+ activeCell={1}
+ candidateOverlay={{
+ activeValues: [4],
+ candidateCells: [0, 10],
+ links: [
+ {
+ id: "hidden-link",
+ kind: "strong",
+ a: { cell: 0, value: 4 },
+ b: { cell: 10, value: 4 },
+ contexts: [{ kind: "house", label: "Test" }],
+ },
+ ],
+ }}
+ guidedHintOverlay={{
+ focusCells: [10],
+ placements: [{ cell: 10, value: 4 }],
+ }}
+ onCellPointerDown={pointerDown}
+ onCellPointerEnter={vi.fn()}
+ onKeyDown={keyDown}
+ />,
+ );
+
+ const hidden = screen.getByRole("gridcell", {
+ name: "Row 3, column 3, obscured by fog",
+ });
+ expect(hidden).toBeDisabled();
+ expect(hidden).toHaveClass("is-fogged");
+ expect(hidden).not.toHaveClass(
+ "is-selected",
+ "has-conflict",
+ "is-hint-focus",
+ );
+ expect(hidden).toHaveAccessibleDescription(
+ "Obscured by Fog of War. This cell cannot be selected until revealed.",
+ );
+ expect(hidden).toBeEmptyDOMElement();
+ expect(container.querySelector(".constraint-even")).not.toBeInTheDocument();
+ expect(
+ container.querySelectorAll(".fog-constraint-mask rect"),
+ ).toHaveLength(12);
+ expect(
+ container.querySelector(".candidate-link-layer"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("grid", { name: "4 by 4 Sudoku grid" }),
+ ).not.toHaveAccessibleDescription(expect.stringContaining("even digit"));
+
+ fireEvent.pointerDown(hidden);
+ expect(pointerDown).not.toHaveBeenCalled();
+ fireEvent.pointerDown(
+ screen.getByRole("gridcell", { name: "Row 1, column 1, empty" }),
+ );
+ expect(pointerDown).toHaveBeenCalledWith(0, expect.anything());
+ fireEvent.keyDown(
+ screen.getByRole("gridcell", { name: "Row 1, column 2, empty" }),
+ { key: "ArrowRight" },
+ );
+ fireEvent.keyDown(
+ screen.getByRole("gridcell", { name: "Row 1, column 2, empty" }),
+ { key: "a", ctrlKey: true },
+ );
+ expect(keyDown).not.toHaveBeenCalled();
+
+ const correctValues = new Array(16).fill(0);
+ correctValues[5] = 4;
+ rerender(
+ ,
+ );
+ expect(
+ screen.getByRole("gridcell", { name: "Row 3, column 3, empty" }),
+ ).toHaveAccessibleDescription(expect.stringContaining("even digit"));
+ expect(container.querySelector(".constraint-even")).toBeInTheDocument();
+ expect(
+ container.querySelectorAll(".fog-constraint-mask rect"),
+ ).toHaveLength(7);
+ });
+
+ it("reveals given cells and their Chebyshev neighbourhood under fog", () => {
+ const solution = [1, 2, 3, 4, 3, 4, 1, 2, 2, 1, 4, 3, 4, 3, 2, 1];
+ const givens = new Array(16).fill(0);
+ givens[15] = 1;
+ const puzzle = normalizePuzzle({
+ version: 1,
+ size: 4,
+ givens,
+ solution,
+ constraints: [{ type: "fog", lights: [0], revealRadius: 1 }],
+ });
+
+ const fogged = foggedCellsForPuzzle(puzzle, givens);
+ expect(fogged.has(10)).toBe(false);
+ expect(fogged.has(15)).toBe(false);
+ expect(fogged.has(9)).toBe(true);
+ });
+
it("renders candidate filters and strong/weak graph overlays", () => {
const puzzle = normalizePuzzle({
version: 1,
@@ -164,6 +638,51 @@ describe("Sudoku board constraint visuals", () => {
);
});
+ it("keeps guided effects distinct from focus cells and exposes the preview", () => {
+ const puzzle = normalizePuzzle({
+ version: 1,
+ size: 4,
+ givens: new Array(16).fill(0),
+ });
+ const { container } = render(
+ (13).fill(0)]}
+ selected={new Set()}
+ activeCell={0}
+ guidedHintOverlay={{
+ focusCells: [0, 1, 2],
+ placements: [{ cell: 0, value: 4 }],
+ eliminations: [{ cell: 1, values: [2, 3] }],
+ }}
+ onCellPointerDown={vi.fn()}
+ onCellPointerEnter={vi.fn()}
+ onKeyDown={vi.fn()}
+ />,
+ );
+
+ expect(container.querySelectorAll(".is-hint-focus")).toHaveLength(3);
+ expect(container.querySelectorAll(".is-hint-placement")).toHaveLength(1);
+ expect(container.querySelectorAll(".is-hint-elimination")).toHaveLength(1);
+ expect(
+ container.querySelector(".hint-placement-preview"),
+ ).toHaveTextContent("4");
+ expect(
+ container.querySelector(".hint-elimination-preview"),
+ ).toHaveTextContent("−23");
+ expect(
+ screen.getByRole("gridcell", { name: "Row 1, column 1, empty" }),
+ ).toHaveAccessibleDescription(
+ expect.stringContaining("hint preview: place 4"),
+ );
+ expect(
+ screen.getByRole("gridcell", { name: "Row 1, column 2, empty" }),
+ ).toHaveAccessibleDescription(
+ expect.stringContaining("hint preview: remove 2, 3 from the candidates"),
+ );
+ });
+
it("exposes real row semantics and detailed per-cell state", () => {
const puzzle = normalizePuzzle({
version: 1,
@@ -215,4 +734,80 @@ describe("Sudoku board constraint visuals", () => {
expect(annotated).toHaveAttribute("aria-invalid", "true");
expect(annotated).toHaveAttribute("aria-selected", "true");
});
+
+ it("offers off, concise and detailed screen-reader candidate output", () => {
+ const puzzle = normalizePuzzle({
+ version: 1,
+ size: 4,
+ givens: new Array(16).fill(0),
+ });
+ const baseProps = {
+ puzzle,
+ values: puzzle.givens,
+ cornerMarks: [3, ...new Array(15).fill(0)],
+ centerMarks: [12, ...new Array(15).fill(0)],
+ selected: new Set([0]),
+ activeCell: 0,
+ onCellPointerDown: vi.fn(),
+ onCellPointerEnter: vi.fn(),
+ onKeyDown: vi.fn(),
+ } as const;
+ const { rerender } = render(
+ ,
+ );
+ const cell = () =>
+ screen.getByRole("gridcell", { name: "Row 1, column 1, empty" });
+
+ expect(cell()).toHaveAccessibleDescription(
+ expect.stringContaining("corner notes 1, 2"),
+ );
+ expect(cell()).toHaveAccessibleDescription(
+ expect.stringContaining("centre notes 3, 4"),
+ );
+
+ rerender( );
+ expect(cell()).toHaveAccessibleDescription(
+ expect.stringContaining("2 corner notes"),
+ );
+ expect(cell()).toHaveAccessibleDescription(
+ expect.stringContaining("2 centre notes"),
+ );
+ expect(cell()).not.toHaveAccessibleDescription(
+ expect.stringContaining("corner notes 1, 2"),
+ );
+
+ rerender( );
+ expect(cell()).not.toHaveAccessibleDescription(
+ expect.stringMatching(/candidate|corner note|centre note/iu),
+ );
+ });
+
+ it("adds a non-colour pattern layer and names the pattern", () => {
+ const puzzle = normalizePuzzle({
+ version: 1,
+ size: 4,
+ givens: new Array(16).fill(0),
+ });
+ const { container } = render(
+ (15).fill(0)]}
+ selected={new Set()}
+ activeCell={0}
+ onCellPointerDown={vi.fn()}
+ onCellPointerEnter={vi.fn()}
+ onKeyDown={vi.fn()}
+ />,
+ );
+
+ expect(
+ container.querySelector(".has-color-1 .cell-color-pattern"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("gridcell", { name: "Row 1, column 1, empty" }),
+ ).toHaveAccessibleDescription(
+ expect.stringContaining("colour 1: red, diagonal stripes"),
+ );
+ });
});
diff --git a/tests/components/workbench.test.tsx b/tests/components/workbench.test.tsx
index 0746570..4445835 100644
--- a/tests/components/workbench.test.tsx
+++ b/tests/components/workbench.test.tsx
@@ -2,6 +2,8 @@ import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { Workbench } from "../../src/components/Workbench";
+import { encodePuzzleHash, fromDomainPuzzle } from "../../src/formats";
+import { SUDOKU_DOCUMENT_SCHEMA } from "../../src/formats";
class WorkerStub {
addEventListener() {}
@@ -63,6 +65,37 @@ describe("Sudoku workbench", () => {
expect(undo).toBeDisabled();
});
+ it("fills a tracked candidate grid as one undoable maintenance action", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ expect(
+ screen.getByText("Candidate tracking is currently inactive."),
+ ).toBeInTheDocument();
+ await user.click(
+ screen.getByRole("button", { name: "Fill legal candidates" }),
+ );
+
+ expect(
+ screen.getByText("The guided candidate grid is active."),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("gridcell", {
+ name: "Row 1, column 3, empty",
+ }),
+ ).toHaveAccessibleDescription(expect.stringContaining("centre notes"));
+
+ await user.click(screen.getByRole("button", { name: "Undo" }));
+ expect(
+ screen.getByText("Candidate tracking is currently inactive."),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("gridcell", {
+ name: "Row 1, column 3, empty",
+ }),
+ ).not.toHaveAccessibleDescription(expect.stringContaining("centre notes"));
+ });
+
it("supports non-wrapping WAI-ARIA grid navigation", () => {
render( );
@@ -91,6 +124,94 @@ describe("Sudoku workbench", () => {
expect(cell(1, 1)).toHaveAttribute("tabindex", "0");
});
+ it("offers tap-by-tap multi-selection without drag selection", async () => {
+ const user = userEvent.setup();
+ render( );
+ const grid = screen.getByRole("grid", { name: "9 by 9 Sudoku grid" });
+ const cell = (column: number) =>
+ within(grid).getByRole("gridcell", {
+ name: new RegExp(`^Row 1, column ${String(column)},`, "u"),
+ });
+
+ fireEvent.pointerDown(cell(3), { buttons: 1 });
+ const toggleMode = screen.getByRole("button", { name: "Tap multi-select" });
+ expect(toggleMode).toHaveAttribute("aria-pressed", "false");
+ await user.click(toggleMode);
+ expect(toggleMode).toHaveAttribute("aria-pressed", "true");
+
+ fireEvent.pointerDown(cell(4), { buttons: 1 });
+ expect(cell(3)).toHaveAttribute("aria-selected", "true");
+ expect(cell(4)).toHaveAttribute("aria-selected", "true");
+ fireEvent.pointerEnter(cell(5), { buttons: 1 });
+ expect(cell(5)).toHaveAttribute("aria-selected", "false");
+
+ fireEvent.pointerDown(cell(3), { buttons: 1 });
+ expect(cell(3)).toHaveAttribute("aria-selected", "false");
+ expect(cell(4)).toHaveAttribute("aria-selected", "true");
+ });
+
+ it("renders one sticky entry pad in a narrow viewport", () => {
+ const original = Object.getOwnPropertyDescriptor(window, "matchMedia");
+ Object.defineProperty(window, "matchMedia", {
+ configurable: true,
+ value: (query: string) => ({
+ matches: query === "(max-width: 48rem)",
+ media: query,
+ onchange: null,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }),
+ });
+
+ const { container, unmount } = render( );
+ expect(container.querySelectorAll(".mobile-number-pad")).toHaveLength(1);
+ expect(screen.getAllByRole("group", { name: "Entry mode" })).toHaveLength(
+ 1,
+ );
+ unmount();
+ if (original === undefined) Reflect.deleteProperty(window, "matchMedia");
+ else Object.defineProperty(window, "matchMedia", original);
+ });
+
+ it("keeps fogged selections out of toolbar and helper output", async () => {
+ const user = userEvent.setup();
+ const previousHash = window.location.hash;
+ window.location.hash = encodePuzzleHash(
+ fromDomainPuzzle({
+ version: 1,
+ size: 4,
+ givens: new Array(16).fill(0),
+ solution: [1, 2, 3, 4, 3, 4, 1, 2, 2, 1, 4, 3, 4, 3, 2, 1],
+ constraints: [{ type: "fog", lights: [15], revealRadius: 0 }],
+ }),
+ );
+ const { unmount } = render( );
+
+ expect(
+ screen.getByRole("gridcell", {
+ name: "Row 1, column 1, obscured by fog",
+ }),
+ ).toBeDisabled();
+ expect(screen.getByText("r4c4")).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "Helpers" }));
+ await user.click(screen.getByRole("tab", { name: "Sum Lab" }));
+ await user.clear(screen.getByLabelText("Target sum"));
+ await user.type(screen.getByLabelText("Target sum"), "4");
+ await user.click(
+ screen.getByRole("checkbox", {
+ name: "Use 1 board cell and candidates",
+ }),
+ );
+ expect(screen.getByText(/^r4c4:/u)).toBeInTheDocument();
+
+ unmount();
+ window.location.hash = previousHash;
+ });
+
it("shows digit progress and toggles matching-digit highlights separately from selection", async () => {
const user = userEvent.setup();
render( );
@@ -257,8 +378,8 @@ describe("Sudoku workbench", () => {
).toBeInTheDocument();
expect(container.querySelectorAll(".is-negated")).toHaveLength(2);
- await user.clear(screen.getByLabelText("Clue"));
- await user.type(screen.getByLabelText("Clue"), "6562");
+ await user.clear(screen.getByLabelText("Sum"));
+ await user.type(screen.getByLabelText("Sum"), "6562");
expect(
screen.getByRole("button", { name: "Add / replace outside clue" }),
).toBeDisabled();
@@ -375,4 +496,69 @@ describe("Sudoku workbench", () => {
).toBeInTheDocument();
expect(fetchSpy).not.toHaveBeenCalled();
});
+
+ it("keeps imported visuals, provenance and metadata after a board edit", async () => {
+ const user = userEvent.setup();
+ let downloaded: Blob | undefined;
+ vi.mocked(URL.createObjectURL).mockImplementation((value) => {
+ downloaded = value as Blob;
+ return "blob:preserved-workbench-export";
+ });
+ vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
+ render( );
+
+ const imported = {
+ schema: SUDOKU_DOCUMENT_SCHEMA,
+ version: 1,
+ size: 4,
+ givens: Array(16).fill(0),
+ constraints: [],
+ title: "Preservation regression",
+ visuals: [
+ {
+ type: "text",
+ layer: "overlay",
+ position: { kind: "cell", cell: 0 },
+ text: "source label",
+ style: { fill: "#123456" },
+ },
+ ],
+ source: { format: "sudokupad", id: "original-source" },
+ metadata: { edition: "kept" },
+ };
+ await user.click(screen.getByRole("button", { name: "Import / export" }));
+ fireEvent.change(screen.getByPlaceholderText(/Paste 81 characters/u), {
+ target: { value: JSON.stringify(imported) },
+ });
+ await user.click(screen.getByRole("button", { name: "Import locally" }));
+ expect(document.querySelector(".source-visual--text")).toHaveTextContent(
+ "source label",
+ );
+
+ fireEvent.pointerDown(
+ screen.getByRole("gridcell", { name: "Row 1, column 1, empty" }),
+ { buttons: 1 },
+ );
+ await user.click(
+ within(screen.getByRole("group", { name: "Digits" })).getByRole(
+ "button",
+ { name: "1" },
+ ),
+ );
+ await user.click(screen.getByRole("button", { name: "Import / export" }));
+ await user.click(
+ screen.getByRole("button", { name: "Download project JSON" }),
+ );
+
+ const exported = JSON.parse((await downloaded?.text()) ?? "{}") as {
+ values?: number[];
+ visuals?: unknown[];
+ source?: unknown;
+ metadata?: unknown;
+ };
+ expect(exported.values?.[0]).toBe(1);
+ expect(exported.visuals).toEqual(imported.visuals);
+ expect(exported.source).toEqual(imported.source);
+ expect(exported.metadata).toEqual(imported.metadata);
+ });
});
diff --git a/tests/domain/constraintRegistry.test.ts b/tests/domain/constraintRegistry.test.ts
new file mode 100644
index 0000000..fa84bd1
--- /dev/null
+++ b/tests/domain/constraintRegistry.test.ts
@@ -0,0 +1,123 @@
+import { describe, expect, it } from "vitest";
+import {
+ CONSTRAINT_REGISTRY,
+ CONSTRAINT_TYPES,
+ constraintAllowedFields,
+ constraintCells,
+ constraintLabel,
+ constraintMetadata,
+ isConstraintType,
+ type ConstraintType,
+} from "../../src/domain";
+
+const expectedTypes = [
+ "diagonal",
+ "anti-knight",
+ "anti-king",
+ "non-consecutive",
+ "disjoint-groups",
+ "killer-cage",
+ "thermo",
+ "arrow",
+ "kropki",
+ "xv",
+ "inequality",
+ "renban",
+ "palindrome",
+ "x-sum",
+ "skyscraper",
+ "quadruple",
+ "maximum",
+ "minimum",
+ "odd",
+ "even",
+ "little-killer",
+ "sandwich",
+ "between-line",
+ "german-whisper",
+ "region-sum-line",
+ "clone",
+ "extra-region",
+ "modular-line",
+ "entropic-line",
+ "zipper-line",
+ "double-arrow",
+ "indexer",
+ "fog",
+] as const satisfies readonly ConstraintType[];
+
+describe("constraint registry", () => {
+ it("contains one complete metadata entry for every constraint type", () => {
+ expect([...CONSTRAINT_TYPES].sort()).toEqual([...expectedTypes].sort());
+ expect(Object.keys(CONSTRAINT_REGISTRY).sort()).toEqual(
+ [...expectedTypes].sort(),
+ );
+ for (const type of expectedTypes) {
+ const metadata = constraintMetadata(type);
+ expect(metadata.type).toBe(type);
+ expect(metadata.label.length).toBeGreaterThan(0);
+ expect(metadata.fields[0]).toEqual({
+ key: "type",
+ kind: "discriminator",
+ required: true,
+ });
+ expect(new Set(metadata.fields.map(({ key }) => key)).size).toBe(
+ metadata.fields.length,
+ );
+ expect(constraintAllowedFields(type).has("negated")).toBe(
+ metadata.negatable,
+ );
+ }
+ });
+
+ it("provides labels, field metadata and a safe type guard", () => {
+ expect(constraintLabel("little-killer")).toBe("Little killer");
+ expect(constraintLabel("future-rule")).toBe("Future Rule");
+ expect(isConstraintType("sandwich")).toBe(true);
+ expect(isConstraintType("not-a-rule")).toBe(false);
+ expect(constraintAllowedFields("little-killer")).toEqual(
+ new Set(["type", "side", "index", "direction", "sum", "negated"]),
+ );
+ });
+
+ it("resolves global, line, outside and local footprints centrally", () => {
+ expect(constraintCells(4, { type: "diagonal", direction: "anti" })).toEqual(
+ [3, 6, 9, 12],
+ );
+ expect(constraintCells(4, { type: "disjoint-groups" })).toHaveLength(16);
+ expect(constraintCells(4, { type: "minimum", cell: 5 })).toEqual([
+ 5, 1, 9, 4, 6,
+ ]);
+ expect(constraintCells(4, { type: "odd", cell: 5 })).toEqual([5]);
+ expect(
+ constraintCells(4, {
+ type: "little-killer",
+ side: "top",
+ index: 0,
+ direction: "down-right",
+ sum: 10,
+ }),
+ ).toEqual([0, 5, 10, 15]);
+ expect(
+ constraintCells(4, {
+ type: "sandwich",
+ side: "right",
+ index: 2,
+ sum: 3,
+ }),
+ ).toEqual([11, 10, 9, 8]);
+ expect(
+ constraintCells(4, {
+ type: "clone",
+ cells: [0, 1],
+ cloneCells: [10, 11],
+ }),
+ ).toEqual([0, 1, 10, 11]);
+ expect(
+ constraintCells(4, { type: "indexer", kind: "box", cell: 0 }),
+ ).toEqual([0, 2, 8, 10]);
+ expect(
+ constraintCells(4, { type: "fog", lights: [0, 5], revealRadius: 1 }),
+ ).toEqual([0, 5]);
+ });
+});
diff --git a/tests/domain/pack1Constraints.test.ts b/tests/domain/pack1Constraints.test.ts
new file mode 100644
index 0000000..ab6052c
--- /dev/null
+++ b/tests/domain/pack1Constraints.test.ts
@@ -0,0 +1,273 @@
+import { describe, expect, it } from "vitest";
+import {
+ candidatesForCell,
+ classicRegions,
+ compilePuzzle,
+ constraintIsFeasible,
+ normalizePuzzle,
+ validatePuzzle,
+ type PuzzleDefinition,
+ type VariantConstraint,
+} from "../../src/domain";
+import { solveExact } from "../../src/solver";
+
+const solved4 = [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3] as const;
+
+function puzzle4(overrides: Partial = {}): PuzzleDefinition {
+ return {
+ version: 1,
+ size: 4,
+ givens: new Array(16).fill(0),
+ regions: classicRegions(4),
+ constraints: [],
+ ...overrides,
+ };
+}
+
+const packOneConstraints: readonly VariantConstraint[] = [
+ { type: "minimum", cell: 0 },
+ { type: "odd", cell: 0 },
+ { type: "even", cell: 1 },
+ { type: "disjoint-groups" },
+ {
+ type: "little-killer",
+ side: "top",
+ index: 0,
+ direction: "down-right",
+ sum: 10,
+ },
+ { type: "sandwich", side: "left", index: 0, sum: 5 },
+];
+
+describe("Pack 1 constraint validation", () => {
+ it("accepts and clones every production shape", () => {
+ const source = puzzle4({ constraints: packOneConstraints });
+ expect(validatePuzzle(source)).toEqual({ valid: true, issues: [] });
+ const normalized = normalizePuzzle(source);
+ expect(normalized.constraints).toEqual(packOneConstraints);
+ expect(normalized.constraints).not.toBe(packOneConstraints);
+ });
+
+ it("rejects invalid cells, fields, directions, paths and unreachable sums", () => {
+ const invalid: readonly unknown[] = [
+ { type: "minimum", cell: 16 },
+ { type: "odd", cell: 0, surprise: true },
+ { type: "even", cell: -1 },
+ { type: "disjoint-groups", negated: true },
+ {
+ type: "little-killer",
+ side: "top",
+ index: 0,
+ direction: "up-right",
+ sum: 10,
+ },
+ {
+ type: "little-killer",
+ side: "top",
+ index: 0,
+ direction: "down-left",
+ sum: 4,
+ },
+ {
+ type: "little-killer",
+ side: "top",
+ index: 0,
+ direction: "down-right",
+ sum: 17,
+ },
+ { type: "sandwich", side: "left", index: 0, sum: 1 },
+ { type: "sandwich", side: "inside", index: 0, sum: 5 },
+ ];
+ for (const constraint of invalid) {
+ const result = validatePuzzle({
+ ...puzzle4(),
+ constraints: [constraint],
+ });
+ expect(result.valid, JSON.stringify(constraint)).toBe(false);
+ expect(
+ result.issues.some(({ path }) => path.startsWith("constraints[0]")),
+ ).toBe(true);
+ }
+ });
+
+ it("rejects disjoint groups on jigsaw regions but accepts renamed standard boxes", () => {
+ const jigsaw = [0, 0, 1, 1, 0, 2, 2, 1, 0, 2, 3, 1, 2, 3, 3, 3];
+ const rejected = validatePuzzle(
+ puzzle4({ regions: jigsaw, constraints: [{ type: "disjoint-groups" }] }),
+ );
+ expect(rejected.valid).toBe(false);
+ expect(rejected.issues).toContainEqual({
+ path: "constraints[0]",
+ message: "disjoint groups require the standard rectangular box layout",
+ });
+
+ const renamed = classicRegions(4).map((region) => [2, 0, 3, 1][region]!);
+ expect(
+ validatePuzzle(
+ puzzle4({
+ regions: renamed,
+ constraints: [{ type: "disjoint-groups" }],
+ }),
+ ).valid,
+ ).toBe(true);
+ });
+});
+
+describe("Pack 1 partial feasibility", () => {
+ it("prunes minimum, odd and even cells before the grid is complete", () => {
+ const values = new Array(16).fill(0);
+ values[1] = 3;
+ expect(
+ candidatesForCell(
+ puzzle4({ constraints: [{ type: "minimum", cell: 0 }] }),
+ values,
+ 0,
+ ),
+ ).toEqual([1, 2]);
+ expect(
+ candidatesForCell(
+ puzzle4({ constraints: [{ type: "odd", cell: 0 }] }),
+ new Array(16).fill(0),
+ 0,
+ ),
+ ).toEqual([1, 3]);
+ expect(
+ candidatesForCell(
+ puzzle4({ constraints: [{ type: "even", cell: 0 }] }),
+ new Array(16).fill(0),
+ 0,
+ ),
+ ).toEqual([2, 4]);
+
+ const impossibleMinimum = new Array(16).fill(0);
+ impossibleMinimum[1] = 1;
+ expect(
+ constraintIsFeasible({ type: "minimum", cell: 0 }, impossibleMinimum, 4),
+ ).toBe(false);
+ expect(
+ constraintIsFeasible(
+ { type: "minimum", cell: 0, negated: true },
+ [2, 1, ...new Array(14).fill(0)],
+ 4,
+ ),
+ ).toBe(true);
+ });
+
+ it("compiles four disjoint houses and enforces their remote peers", () => {
+ const plain = normalizePuzzle(puzzle4());
+ const disjoint = compilePuzzle(
+ normalizePuzzle(puzzle4({ constraints: [{ type: "disjoint-groups" }] })),
+ );
+ expect(
+ disjoint.units.filter(({ kind }) => kind === "disjoint-group"),
+ ).toHaveLength(4);
+ const values = new Array(16).fill(0);
+ values[0] = 1;
+ expect(candidatesForCell(plain, values, 10)).toContain(1);
+ expect(candidatesForCell(disjoint, values, 10)).not.toContain(1);
+ });
+
+ it("uses the whole little-killer diagonal for partial sum bounds", () => {
+ const constraint = {
+ type: "little-killer",
+ side: "top",
+ index: 1,
+ direction: "down-right",
+ sum: 6,
+ } as const;
+ const values = new Array(16).fill(0);
+ values[1] = 4;
+ expect(
+ candidatesForCell(puzzle4({ constraints: [constraint] }), values, 6),
+ ).toEqual([1]);
+
+ const complete = [...solved4];
+ expect(
+ constraintIsFeasible(
+ {
+ type: "little-killer",
+ side: "top",
+ index: 0,
+ direction: "down-right",
+ sum: 10,
+ },
+ complete,
+ 4,
+ ),
+ ).toBe(true);
+ expect(
+ constraintIsFeasible(
+ {
+ type: "little-killer",
+ side: "top",
+ index: 0,
+ direction: "down-right",
+ sum: 10,
+ negated: true,
+ },
+ complete,
+ 4,
+ ),
+ ).toBe(false);
+ });
+
+ it("computes exact sandwich possibilities from partial permutations", () => {
+ const partial = [1, 2, 0, 4, ...new Array(12).fill(0)];
+ expect(
+ constraintIsFeasible(
+ { type: "sandwich", side: "left", index: 0, sum: 5 },
+ partial,
+ 4,
+ ),
+ ).toBe(true);
+ expect(
+ constraintIsFeasible(
+ { type: "sandwich", side: "left", index: 0, sum: 3 },
+ partial,
+ 4,
+ ),
+ ).toBe(false);
+ expect(
+ constraintIsFeasible(
+ {
+ type: "sandwich",
+ side: "left",
+ index: 0,
+ sum: 5,
+ negated: true,
+ },
+ solved4,
+ 4,
+ ),
+ ).toBe(false);
+ expect(
+ constraintIsFeasible(
+ {
+ type: "sandwich",
+ side: "left",
+ index: 0,
+ sum: 3,
+ negated: true,
+ },
+ solved4,
+ 4,
+ ),
+ ).toBe(true);
+ });
+});
+
+describe("Pack 1 exact solving", () => {
+ it.each(
+ packOneConstraints.map(
+ (constraint) => [constraint.type, constraint] as const,
+ ),
+ )("solves a puzzle containing %s", (_type, constraint) => {
+ const givens: number[] = [...solved4];
+ givens[5] = 0;
+ givens[10] = 0;
+ const result = solveExact(puzzle4({ givens, constraints: [constraint] }));
+ expect(result.count).toBe(1);
+ expect(result.truncated).toBe(false);
+ expect(result.solutions[0]).toEqual(solved4);
+ });
+});
diff --git a/tests/domain/pack2Constraints.test.ts b/tests/domain/pack2Constraints.test.ts
new file mode 100644
index 0000000..4487ce1
--- /dev/null
+++ b/tests/domain/pack2Constraints.test.ts
@@ -0,0 +1,222 @@
+import { describe, expect, it } from "vitest";
+import {
+ candidatesForCell,
+ classicRegions,
+ compilePuzzle,
+ constraintIsFeasible,
+ normalizePuzzle,
+ validatePuzzle,
+ type PuzzleDefinition,
+ type VariantConstraint,
+} from "../../src/domain";
+import { solveExact } from "../../src/solver";
+
+const solved4 = [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3] as const;
+
+function puzzle4(overrides: Partial = {}): PuzzleDefinition {
+ return {
+ version: 1,
+ size: 4,
+ givens: new Array(16).fill(0),
+ regions: classicRegions(4),
+ constraints: [],
+ ...overrides,
+ };
+}
+
+const packTwoConstraints: readonly VariantConstraint[] = [
+ { type: "between-line", cells: [0, 1, 2] },
+ { type: "german-whisper", cells: [0, 3, 1] },
+ { type: "region-sum-line", cells: [0, 1, 2] },
+ { type: "clone", cells: [0, 1], cloneCells: [6, 7] },
+ { type: "extra-region", cells: [0, 7, 9, 14] },
+];
+
+describe("Pack 2 constraint validation", () => {
+ it("accepts and clones every production shape", () => {
+ const source = puzzle4({ constraints: packTwoConstraints });
+ expect(validatePuzzle(source)).toEqual({ valid: true, issues: [] });
+ const normalized = normalizePuzzle(source);
+ expect(normalized.constraints).toEqual(packTwoConstraints);
+ expect(normalized.constraints).not.toBe(packTwoConstraints);
+ });
+
+ it.each([
+ { type: "between-line", cells: [0, 1] },
+ { type: "between-line", cells: [0, 1, 0] },
+ { type: "german-whisper", cells: [0] },
+ { type: "german-whisper", cells: [0, 1], minimumDifference: 0 },
+ { type: "german-whisper", cells: [0, 1], minimumDifference: 4 },
+ { type: "region-sum-line", cells: [0, 1] },
+ { type: "clone", cells: [0, 1], cloneCells: [8] },
+ { type: "clone", cells: [0, 1], cloneCells: [8, 8] },
+ { type: "extra-region", cells: [0, 1, 2] },
+ { type: "extra-region", cells: [0, 1, 2, 3], negated: true },
+ ] as const)("rejects malformed constraint $type", (constraint) => {
+ const result = validatePuzzle({ ...puzzle4(), constraints: [constraint] });
+ expect(result.valid).toBe(false);
+ expect(
+ result.issues.some(({ path }) => path.startsWith("constraints[0]")),
+ ).toBe(true);
+ });
+
+ it("uses the puzzle's actual regions when checking region-sum crossings", () => {
+ expect(
+ validatePuzzle(
+ puzzle4({ constraints: [{ type: "region-sum-line", cells: [0, 1] }] }),
+ ).issues,
+ ).toContainEqual({
+ path: "constraints[0].cells",
+ message: "region-sum line must cross at least one region boundary",
+ });
+ expect(
+ validatePuzzle(
+ puzzle4({ constraints: [{ type: "region-sum-line", cells: [1, 2] }] }),
+ ).valid,
+ ).toBe(true);
+ });
+});
+
+describe("Pack 2 partial and completed semantics", () => {
+ it("enforces between-line interiors and its negated truth", () => {
+ const constraint = { type: "between-line", cells: [0, 1, 2] } as const;
+ const partial = new Array(16).fill(0);
+ partial[0] = 1;
+ partial[2] = 4;
+ expect(
+ candidatesForCell(puzzle4({ constraints: [constraint] }), partial, 1),
+ ).toEqual([2, 3]);
+ expect(
+ constraintIsFeasible(
+ constraint,
+ [2, 0, 3, ...new Array(13).fill(0)],
+ 4,
+ ),
+ ).toBe(false);
+ expect(constraintIsFeasible(constraint, solved4, 4)).toBe(true);
+ expect(
+ constraintIsFeasible({ ...constraint, negated: true }, solved4, 4),
+ ).toBe(false);
+ expect(
+ constraintIsFeasible(
+ { ...constraint, negated: true },
+ [1, 4, 3, ...new Array(13).fill(0)],
+ 4,
+ ),
+ ).toBe(true);
+ });
+
+ it("runs exact dynamic feasibility for default and explicit German whispers", () => {
+ const constraint = {
+ type: "german-whisper",
+ cells: [0, 5, 10],
+ } as const;
+ const partial = new Array(16).fill(0);
+ partial[0] = 2;
+ expect(
+ candidatesForCell(puzzle4({ constraints: [constraint] }), partial, 5),
+ ).toEqual([4]);
+ const impossible = new Array(16).fill(0);
+ impossible[5] = 2;
+ expect(
+ constraintIsFeasible(
+ { ...constraint, minimumDifference: 3 },
+ impossible,
+ 4,
+ ),
+ ).toBe(false);
+
+ const complete = [1, 4, 2, ...new Array(13).fill(0)];
+ const short = { type: "german-whisper", cells: [0, 1, 2] } as const;
+ expect(constraintIsFeasible(short, complete, 4)).toBe(true);
+ expect(constraintIsFeasible({ ...short, negated: true }, complete, 4)).toBe(
+ false,
+ );
+ complete[1] = 2;
+ expect(constraintIsFeasible({ ...short, negated: true }, complete, 4)).toBe(
+ true,
+ );
+ });
+
+ it("balances contiguous region sums using the supplied region map", () => {
+ const constraint = {
+ type: "region-sum-line",
+ cells: [0, 1, 2],
+ } as const;
+ const partial = new Array(16).fill(0);
+ partial[0] = 1;
+ partial[2] = 3;
+ expect(
+ candidatesForCell(puzzle4({ constraints: [constraint] }), partial, 1),
+ ).toEqual([2]);
+ expect(constraintIsFeasible(constraint, solved4, 4)).toBe(true);
+ expect(
+ constraintIsFeasible({ ...constraint, negated: true }, solved4, 4),
+ ).toBe(false);
+ const unequal = [...solved4];
+ unequal[2] = 4;
+ expect(
+ constraintIsFeasible({ ...constraint, negated: true }, unequal, 4),
+ ).toBe(true);
+ });
+
+ it("enforces ordered clone equality and exact negated state", () => {
+ const constraint = {
+ type: "clone",
+ cells: [0, 1],
+ cloneCells: [6, 7],
+ } as const;
+ const partial = new Array(16).fill(0);
+ partial[0] = 1;
+ partial[1] = 2;
+ partial[6] = 1;
+ expect(
+ candidatesForCell(puzzle4({ constraints: [constraint] }), partial, 7),
+ ).toEqual([2]);
+ expect(constraintIsFeasible(constraint, solved4, 4)).toBe(true);
+ expect(
+ constraintIsFeasible({ ...constraint, negated: true }, solved4, 4),
+ ).toBe(false);
+ const mismatch = [...solved4];
+ mismatch[7] = 3;
+ expect(constraintIsFeasible(constraint, mismatch, 4)).toBe(false);
+ expect(
+ constraintIsFeasible({ ...constraint, negated: true }, mismatch, 4),
+ ).toBe(true);
+ });
+
+ it("compiles extra regions as all-different units and remote peers", () => {
+ const constraint = {
+ type: "extra-region",
+ cells: [0, 7, 9, 14],
+ } as const;
+ const compiled = compilePuzzle(
+ normalizePuzzle(puzzle4({ constraints: [constraint] })),
+ );
+ expect(
+ compiled.units.filter(({ kind }) => kind === "extra-region"),
+ ).toEqual([{ kind: "extra-region", index: 0, cells: constraint.cells }]);
+ const partial = new Array(16).fill(0);
+ partial[0] = 1;
+ expect(candidatesForCell(compiled, partial, 7)).not.toContain(1);
+ expect(compilePuzzle(normalizePuzzle(puzzle4())).peers[7]?.has(0)).toBe(
+ false,
+ );
+ });
+});
+
+describe("Pack 2 exact solving", () => {
+ it.each(
+ packTwoConstraints.map(
+ (constraint) => [constraint.type, constraint] as const,
+ ),
+ )("solves a puzzle containing %s", (_type, constraint) => {
+ const givens: number[] = [...solved4];
+ givens[5] = 0;
+ givens[10] = 0;
+ const result = solveExact(puzzle4({ givens, constraints: [constraint] }));
+ expect(result.count).toBe(1);
+ expect(result.truncated).toBe(false);
+ expect(result.solutions[0]).toEqual(solved4);
+ });
+});
diff --git a/tests/domain/pack3Constraints.test.ts b/tests/domain/pack3Constraints.test.ts
new file mode 100644
index 0000000..a1377b5
--- /dev/null
+++ b/tests/domain/pack3Constraints.test.ts
@@ -0,0 +1,355 @@
+import { describe, expect, it } from "vitest";
+import {
+ candidatesForCell,
+ classicRegions,
+ constraintIsFeasible,
+ normalizePuzzle,
+ validatePuzzle,
+ type PuzzleDefinition,
+ type VariantConstraint,
+} from "../../src/domain";
+import { solveExact } from "../../src/solver";
+
+const solved4 = [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3] as const;
+const solved6 = [
+ 1, 2, 3, 4, 5, 6, 4, 5, 6, 1, 2, 3, 2, 3, 4, 5, 6, 1, 5, 6, 1, 2, 3, 4, 3, 4,
+ 5, 6, 1, 2, 6, 1, 2, 3, 4, 5,
+] as const;
+
+function puzzle(
+ size: number,
+ overrides: Partial = {},
+): PuzzleDefinition {
+ return {
+ version: 1,
+ size,
+ givens: new Array(size * size).fill(0),
+ regions: classicRegions(size),
+ constraints: [],
+ ...overrides,
+ };
+}
+
+const semantic4: readonly VariantConstraint[] = [
+ { type: "modular-line", cells: [0, 1, 2, 3] },
+ { type: "zipper-line", cells: [0, 2, 1] },
+ { type: "double-arrow", cells: [0, 2, 1] },
+ { type: "indexer", kind: "row", cell: 7 },
+ { type: "indexer", kind: "column", cell: 5 },
+ { type: "indexer", kind: "box", cell: 14 },
+];
+
+describe("Pack 3 validation and cloning", () => {
+ it("accepts all production shapes and preserves canonical fog data", () => {
+ const definition = puzzle(4, {
+ solution: solved4,
+ constraints: [
+ ...semantic4,
+ { type: "fog", lights: [0, 5], revealRadius: 1 },
+ ],
+ });
+ expect(validatePuzzle(definition)).toEqual({ valid: true, issues: [] });
+ const normalized = normalizePuzzle(definition);
+ expect(normalized.constraints).toEqual(definition.constraints);
+ expect(normalized.constraints.find(({ type }) => type === "fog")).toEqual({
+ type: "fog",
+ lights: [0, 5],
+ revealRadius: 1,
+ });
+
+ expect(
+ validatePuzzle(
+ puzzle(6, {
+ constraints: [{ type: "entropic-line", cells: [0, 2, 4] }],
+ }),
+ ).valid,
+ ).toBe(true);
+ });
+
+ it.each([
+ [4, { type: "modular-line", cells: [0, 1] }],
+ [4, { type: "entropic-line", cells: [0, 1, 2] }],
+ [6, { type: "entropic-line", cells: [0, 1] }],
+ [4, { type: "zipper-line", cells: [0, 1, 2, 3] }],
+ [4, { type: "zipper-line", cells: [0, 1] }],
+ [4, { type: "double-arrow", cells: [0, 1] }],
+ [4, { type: "indexer", kind: "diagonal", cell: 0 }],
+ [4, { type: "indexer", kind: "row", cell: 16 }],
+ [4, { type: "fog", lights: [], revealRadius: 1 }],
+ [4, { type: "fog", lights: [0], revealRadius: 2 }],
+ ] as const)(
+ "rejects malformed Pack 3 data on size %i",
+ (size, constraint) => {
+ const result = validatePuzzle({
+ ...puzzle(size),
+ solution: size === 4 ? solved4 : solved6,
+ constraints: [constraint],
+ });
+ expect(result.valid).toBe(false);
+ expect(
+ result.issues.some(({ path }) => path.startsWith("constraints[0]")),
+ ).toBe(true);
+ },
+ );
+
+ it("requires a complete valid trusted solution for fog", () => {
+ const withoutSolution = validatePuzzle(
+ puzzle(4, { constraints: [{ type: "fog", lights: [0] }] }),
+ );
+ expect(withoutSolution.issues).toContainEqual({
+ path: "constraints[0]",
+ message: "fog requires a complete trusted puzzle solution",
+ });
+ expect(
+ validatePuzzle(
+ puzzle(4, {
+ solution: new Array(16).fill(0),
+ constraints: [{ type: "fog", lights: [0] }],
+ }),
+ ).valid,
+ ).toBe(false);
+ });
+
+ it("rejects box indexers on custom regions while row and column remain valid", () => {
+ const jigsaw = [0, 0, 1, 1, 0, 2, 2, 1, 0, 2, 3, 1, 2, 3, 3, 3];
+ expect(
+ validatePuzzle(
+ puzzle(4, {
+ regions: jigsaw,
+ constraints: [{ type: "indexer", kind: "box", cell: 0 }],
+ }),
+ ).issues,
+ ).toContainEqual({
+ path: "constraints[0]",
+ message: "box indexers require the standard rectangular box layout",
+ });
+ expect(
+ validatePuzzle(
+ puzzle(4, {
+ regions: jigsaw,
+ constraints: [{ type: "indexer", kind: "row", cell: 0 }],
+ }),
+ ).valid,
+ ).toBe(true);
+ });
+});
+
+describe("Pack 3 line semantics", () => {
+ it("enforces modular residue windows and their negation", () => {
+ const constraint = {
+ type: "modular-line",
+ cells: [0, 1, 2, 3],
+ } as const;
+ const partial = new Array(16).fill(0);
+ partial[0] = 1;
+ partial[1] = 2;
+ expect(
+ candidatesForCell(puzzle(4, { constraints: [constraint] }), partial, 2),
+ ).toEqual([3]);
+ expect(constraintIsFeasible(constraint, solved4, 4)).toBe(true);
+ expect(
+ constraintIsFeasible({ ...constraint, negated: true }, solved4, 4),
+ ).toBe(false);
+ const invalid = [1, 2, 4, 1, ...new Array(12).fill(0)];
+ expect(constraintIsFeasible(constraint, invalid, 4)).toBe(false);
+ expect(
+ constraintIsFeasible({ ...constraint, negated: true }, invalid, 4),
+ ).toBe(true);
+ });
+
+ it("enforces equal entropic bands on divisible grids", () => {
+ const constraint = {
+ type: "entropic-line",
+ cells: [0, 1, 2],
+ } as const;
+ const partial = new Array(36).fill(0);
+ partial[0] = 1;
+ partial[1] = 3;
+ expect(
+ candidatesForCell(puzzle(6, { constraints: [constraint] }), partial, 2),
+ ).toEqual([5, 6]);
+ const valid = [1, 3, 5, ...new Array(33).fill(0)];
+ const invalid = [1, 2, 5, ...new Array(33).fill(0)];
+ expect(constraintIsFeasible(constraint, valid, 6)).toBe(true);
+ expect(constraintIsFeasible(constraint, invalid, 6)).toBe(false);
+ expect(
+ constraintIsFeasible({ ...constraint, negated: true }, valid, 6),
+ ).toBe(false);
+ expect(
+ constraintIsFeasible({ ...constraint, negated: true }, invalid, 6),
+ ).toBe(true);
+ });
+
+ it("enforces zipper pair sums around the centre", () => {
+ const constraint = { type: "zipper-line", cells: [0, 1, 2] } as const;
+ const partial = new Array(16).fill(0);
+ partial[0] = 1;
+ partial[1] = 3;
+ expect(
+ candidatesForCell(puzzle(4, { constraints: [constraint] }), partial, 2),
+ ).toEqual([2]);
+ const valid = [1, 3, 2, ...new Array(13).fill(0)];
+ const invalid = [1, 3, 3, ...new Array(13).fill(0)];
+ expect(constraintIsFeasible(constraint, valid, 4)).toBe(true);
+ expect(constraintIsFeasible(constraint, invalid, 4)).toBe(false);
+ expect(
+ constraintIsFeasible({ ...constraint, negated: true }, valid, 4),
+ ).toBe(false);
+ expect(
+ constraintIsFeasible({ ...constraint, negated: true }, invalid, 4),
+ ).toBe(true);
+ const impossibleCentre = new Array(16).fill(0);
+ impossibleCentre[1] = 1;
+ expect(constraintIsFeasible(constraint, impossibleCentre, 4)).toBe(false);
+ });
+
+ it("balances double-arrow endpoints against all interior digits", () => {
+ const constraint = { type: "double-arrow", cells: [0, 1, 2] } as const;
+ const partial = new Array(16).fill(0);
+ partial[0] = 1;
+ partial[2] = 2;
+ expect(
+ candidatesForCell(puzzle(4, { constraints: [constraint] }), partial, 1),
+ ).toEqual([3]);
+ const valid = [1, 3, 2, ...new Array(13).fill(0)];
+ const invalid = [1, 4, 2, ...new Array(13).fill(0)];
+ expect(constraintIsFeasible(constraint, valid, 4)).toBe(true);
+ expect(constraintIsFeasible(constraint, invalid, 4)).toBe(false);
+ expect(
+ constraintIsFeasible({ ...constraint, negated: true }, valid, 4),
+ ).toBe(false);
+ expect(
+ constraintIsFeasible({ ...constraint, negated: true }, invalid, 4),
+ ).toBe(true);
+ });
+
+ it("keeps sound partial bounds when peer rules may narrow remaining digits", () => {
+ const empty = new Array(16).fill(0);
+ expect(
+ constraintIsFeasible(
+ { type: "zipper-line", cells: [0, 1, 2, 3, 4] },
+ empty,
+ 4,
+ ),
+ ).toBe(true);
+ expect(
+ constraintIsFeasible(
+ { type: "double-arrow", cells: [0, 1, 2, 3] },
+ empty,
+ 4,
+ ),
+ ).toBe(true);
+ expect(
+ constraintIsFeasible(
+ { type: "modular-line", cells: [0, 1, 2], negated: true },
+ empty,
+ 4,
+ ),
+ ).toBe(true);
+ expect(
+ constraintIsFeasible(
+ { type: "zipper-line", cells: [0, 1, 2], negated: true },
+ empty,
+ 4,
+ ),
+ ).toBe(true);
+ expect(
+ constraintIsFeasible(
+ { type: "double-arrow", cells: [0, 1, 2], negated: true },
+ empty,
+ 4,
+ ),
+ ).toBe(true);
+ expect(
+ constraintIsFeasible(
+ { type: "indexer", kind: "row", cell: 0, negated: true },
+ empty,
+ 4,
+ ),
+ ).toBe(true);
+ });
+});
+
+describe("Pack 3 indexers and fog", () => {
+ it("resolves row, column and rectangular-box targets", () => {
+ const row = { type: "indexer", kind: "row", cell: 1 } as const;
+ const rowValues = new Array(16).fill(0);
+ rowValues[1] = 2;
+ expect(
+ candidatesForCell(puzzle(4, { constraints: [row] }), rowValues, 5),
+ ).toEqual([1]);
+
+ const column = { type: "indexer", kind: "column", cell: 5 } as const;
+ expect(constraintIsFeasible(column, solved4, 4)).toBe(true);
+
+ const box = { type: "indexer", kind: "box", cell: 1 } as const;
+ const boxValues = new Array(16).fill(0);
+ boxValues[1] = 2;
+ expect(
+ candidatesForCell(puzzle(4, { constraints: [box] }), boxValues, 3),
+ ).toEqual([1]);
+ boxValues[3] = 2;
+ expect(constraintIsFeasible(box, boxValues, 4)).toBe(false);
+ expect(constraintIsFeasible({ ...box, negated: true }, boxValues, 4)).toBe(
+ true,
+ );
+
+ // In a 2x3-box grid, r2c5 has within-box position 5. Digit 3 points at
+ // the same position in box 3 (r4c2), which must contain box index 2.
+ const rectangularBox = {
+ type: "indexer",
+ kind: "box",
+ cell: 10,
+ } as const;
+ const rectangularValues = new Array(36).fill(0);
+ rectangularValues[10] = 3;
+ expect(
+ candidatesForCell(
+ puzzle(6, { constraints: [rectangularBox] }),
+ rectangularValues,
+ 19,
+ ),
+ ).toEqual([2]);
+ });
+
+ it("keeps fog entirely non-semantic in exact search", () => {
+ const givens = [1, 0, 0, 4, 0, 4, 1, 0, 4, 0, 2, 0, 0, 1, 0, 3];
+ const plain = solveExact(puzzle(4, { givens }));
+ const fogged = solveExact(
+ puzzle(4, {
+ givens,
+ solution: solved4,
+ constraints: [{ type: "fog", lights: [0], revealRadius: 1 }],
+ }),
+ );
+ expect(fogged.solutions).toEqual(plain.solutions);
+ expect(fogged.count).toBe(plain.count);
+ });
+});
+
+describe("Pack 3 exact solving", () => {
+ it.each(
+ semantic4.map((constraint) => [constraint.type, constraint] as const),
+ )("solves a 4x4 puzzle containing %s", (_type, constraint) => {
+ const givens: number[] = [...solved4];
+ givens[5] = 0;
+ givens[10] = 0;
+ const result = solveExact(puzzle(4, { givens, constraints: [constraint] }));
+ expect(result.count).toBe(1);
+ expect(result.solutions[0]).toEqual(solved4);
+ });
+
+ it("solves a 6x6 puzzle with an entropic line", () => {
+ const givens: number[] = [...solved6];
+ givens[2] = 0;
+ givens[20] = 0;
+ const result = solveExact(
+ puzzle(6, {
+ givens,
+ constraints: [{ type: "entropic-line", cells: [0, 2, 4] }],
+ }),
+ );
+ expect(result.count).toBe(1);
+ expect(result.solutions[0]).toEqual(solved6);
+ });
+});
diff --git a/tests/formats/document.test.ts b/tests/formats/document.test.ts
index 2d31bbe..f6805fc 100644
--- a/tests/formats/document.test.ts
+++ b/tests/formats/document.test.ts
@@ -98,9 +98,10 @@ describe("Sudoku Tools document format", () => {
};
const hash = encodePuzzleHash(source);
expect(hash).toMatch(/^#sudoku=v1\./u);
- expect(decodePuzzleHash(`https://example.invalid/tools/${hash}`)).toEqual(
- source,
- );
+ expect(decodePuzzleHash(`https://example.invalid/tools/${hash}`)).toEqual({
+ ...source,
+ source: { format: "sudoku-tools" },
+ });
});
it("round-trips grids for every supported size and symbol", () => {
diff --git a/tests/formats/fpuzzles.test.ts b/tests/formats/fpuzzles.test.ts
index 43f674b..ba7646e 100644
--- a/tests/formats/fpuzzles.test.ts
+++ b/tests/formats/fpuzzles.test.ts
@@ -115,6 +115,63 @@ describe("fpuzzles interoperability", () => {
expect(imported.constraints).toEqual(expect.arrayContaining(expected));
});
+ it("round-trips the registered expansion-pack constraints", () => {
+ const solution = [
+ 1, 2, 3, 4, 5, 6, 7, 8, 9, 4, 5, 6, 7, 8, 9, 1, 2, 3, 7, 8, 9, 1, 2, 3, 4,
+ 5, 6, 2, 3, 4, 5, 6, 7, 8, 9, 1, 5, 6, 7, 8, 9, 1, 2, 3, 4, 8, 9, 1, 2, 3,
+ 4, 5, 6, 7, 3, 4, 5, 6, 7, 8, 9, 1, 2, 6, 7, 8, 9, 1, 2, 3, 4, 5, 9, 1, 2,
+ 3, 4, 5, 6, 7, 8,
+ ];
+ const source: SudokuDocument = {
+ schema: SUDOKU_DOCUMENT_SCHEMA,
+ version: 1,
+ size: 9,
+ givens: Array(81).fill(0),
+ solution,
+ constraints: [
+ { type: "disjoint-groups" },
+ { type: "minimum", cell: 0 },
+ { type: "odd", cell: 1 },
+ { type: "even", cell: 2 },
+ {
+ type: "little-killer",
+ side: "top",
+ index: 0,
+ direction: "down-right",
+ sum: 45,
+ },
+ { type: "sandwich", side: "left", index: 0, sum: 20 },
+ { type: "between-line", cells: [0, 1, 2] },
+ { type: "german-whisper", cells: [9, 10], minimumDifference: 4 },
+ { type: "region-sum-line", cells: [18, 19, 20, 21] },
+ { type: "clone", cells: [27, 28], cloneCells: [36, 37] },
+ {
+ type: "extra-region",
+ cells: [0, 10, 20, 30, 40, 50, 60, 70, 80],
+ },
+ { type: "modular-line", cells: [45, 46, 47] },
+ { type: "entropic-line", cells: [54, 55, 56] },
+ { type: "zipper-line", cells: [63, 64, 65] },
+ { type: "double-arrow", cells: [72, 73, 74] },
+ { type: "indexer", kind: "row", cell: 3 },
+ { type: "indexer", kind: "column", cell: 4 },
+ { type: "indexer", kind: "box", cell: 5 },
+ { type: "fog", lights: [0, 40], revealRadius: 1 },
+ ],
+ };
+
+ const imported = parseFpuzzles(exportFpuzzles(source));
+ expect(imported.constraints).toEqual(
+ expect.arrayContaining(
+ source.constraints.map((constraint) =>
+ constraint.type === "fog"
+ ? { type: "fog", lights: constraint.lights }
+ : constraint,
+ ),
+ ),
+ );
+ });
+
it("recognizes server-only short puzzle IDs without making a request", () => {
expect(() => importFpuzzles("https://sudokupad.app/abc123")).toThrowError(
NetworkPuzzleIdError,
@@ -220,9 +277,9 @@ describe("fpuzzles interoperability", () => {
parseFpuzzles({
size: 9,
grid: emptyGrid(),
- odd: [{ cell: "R1C1" }],
+ nabner: [{ lines: [["R1C1", "R1C2"]] }],
}),
- ).toThrow(/odd cells.*silently weakening/u);
+ ).toThrow(/Nabner lines.*silently weakening/u);
expect(() =>
parseFpuzzles({
size: 9,
diff --git a/tests/formats/interoperability.test.ts b/tests/formats/interoperability.test.ts
index 813a962..84cd1e7 100644
--- a/tests/formats/interoperability.test.ts
+++ b/tests/formats/interoperability.test.ts
@@ -2,7 +2,6 @@ import { compressToBase64 } from "lz-string";
import { describe, expect, it, vi } from "vitest";
import {
NetworkPuzzleIdError,
- UnsupportedPuzzleConstructsError,
importPenpa,
importPuzzle,
importSudokuPad,
@@ -91,27 +90,38 @@ describe("local puzzle interoperability", () => {
expect(detected.document.givens[0]).toBe(1);
});
- it("rejects visual-only SCL constructs instead of weakening them", () => {
- expect(() =>
- parseSudokuPadPuzzle({
- ...sclPuzzle(),
- lines: [
- {
- wayPoints: [
- [0.5, 0.5],
- [1.5, 1.5],
- ],
- },
- ],
- overlays: [{ text: "?" }],
- }),
- ).toThrow(UnsupportedPuzzleConstructsError);
+ it("preserves allowlisted SCL drawings without treating them as rules", async () => {
+ const source = {
+ ...sclPuzzle(),
+ lines: [
+ {
+ wayPoints: [
+ [0.5, 0.5],
+ [1.5, 1.5],
+ ],
+ },
+ ],
+ overlays: [{ center: [1, 1], width: 1, height: 1, text: "?" }],
+ };
+ const parsed = parseSudokuPadPuzzle(source);
+ const imported = await importPuzzle(JSON.stringify(source));
+
+ expect(parsed.visuals).toHaveLength(3);
+ expect(parsed.source).toEqual({ format: "sudokupad", id: "local-scl" });
+ expect(imported.preview.preservedVisuals).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ key: "overlay:polyline", count: 1 }),
+ expect.objectContaining({ key: "overlay:text", count: 1 }),
+ ]),
+ );
+ expect(imported.preview.warnings.join(" ")).toMatch(/not solver-enforced/u);
+
expect(() =>
parseSudokuPadPuzzle({
...sclPuzzle(),
lines: [{}],
}),
- ).toThrow(/visual lines/u);
+ ).toThrow(/wayPoints|bounded/u);
});
it("parses semantic Penpa+ Sudoku layers and local progress", () => {
diff --git a/tests/formats/sourcePreservation.test.ts b/tests/formats/sourcePreservation.test.ts
new file mode 100644
index 0000000..58e3d89
--- /dev/null
+++ b/tests/formats/sourcePreservation.test.ts
@@ -0,0 +1,360 @@
+import { describe, expect, it } from "vitest";
+import { normalizePuzzle } from "../../src/domain";
+import {
+ MAX_VISUAL_POINTS,
+ MAX_VISUAL_PRIMITIVES,
+ SUDOKU_DOCUMENT_SCHEMA,
+ cloneSudokuDocument,
+ exportFpuzzles,
+ exportSudokuPadJson,
+ exportSudokuPadPayload,
+ extractPreservedDocumentExtras,
+ fromDomainPuzzle,
+ importSudokuPad,
+ normalizeSudokuDocument,
+ parseFpuzzles,
+ parseSudokuPadPuzzle,
+ renderPuzzleSvg,
+ toDomainPuzzle,
+ type SafeVisualPrimitive,
+ type SudokuDocument,
+} from "../../src/formats";
+
+const REGIONS_4X4 = [0, 0, 1, 1, 0, 0, 1, 1, 2, 2, 3, 3, 2, 2, 3, 3] as const;
+
+function emptyCells(size = 4): Record[][] {
+ return Array.from({ length: size }, () =>
+ Array.from({ length: size }, () => ({})),
+ );
+}
+
+function sourceVisuals(): SafeVisualPrimitive[] {
+ return [
+ {
+ type: "line",
+ layer: "underlay",
+ start: { kind: "coordinate", x: 0, y: 0 },
+ end: { kind: "coordinate", x: 4, y: 4 },
+ style: { stroke: "#123456", strokeWidth: 0.04, opacity: 0.8 },
+ },
+ {
+ type: "polyline",
+ layer: "overlay",
+ points: [
+ { kind: "cell", cell: 0 },
+ { kind: "cell", cell: 1 },
+ { kind: "cell", cell: 5 },
+ ],
+ style: { stroke: "#abcdef", fill: "transparent" },
+ },
+ {
+ type: "rectangle",
+ layer: "underlay",
+ center: { kind: "cell", cell: 6 },
+ width: 0.8,
+ height: 0.6,
+ cornerRadius: 0.1,
+ style: { fill: "#ffeedd", stroke: "#112233" },
+ },
+ {
+ type: "ellipse",
+ layer: "overlay",
+ center: { kind: "cell", cell: 9 },
+ radiusX: 0.35,
+ radiusY: 0.2,
+ style: { fill: "transparent", stroke: "#445566" },
+ },
+ {
+ type: "circle",
+ layer: "overlay",
+ center: { kind: "cell", cell: 10 },
+ radius: 0.25,
+ style: { fill: "#778899", opacity: 0.5 },
+ },
+ {
+ type: "text",
+ layer: "overlay",
+ position: { kind: "cell", cell: 15 },
+ text: "safe label",
+ style: { fill: "#010203", fontSize: 0.4 },
+ },
+ ];
+}
+
+function sourceDocument(): SudokuDocument {
+ return {
+ schema: SUDOKU_DOCUMENT_SCHEMA,
+ version: 1,
+ size: 4,
+ givens: [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+ values: [1, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+ cornerMarks: Array.from({ length: 16 }, (_unused, cell) =>
+ cell === 2 ? [3, 4] : [],
+ ),
+ centerMarks: Array.from({ length: 16 }, (_unused, cell) =>
+ cell === 3 ? [2, 3] : [],
+ ),
+ elapsedMs: 12_345,
+ regions: REGIONS_4X4,
+ constraints: [
+ { type: "killer-cage", cells: [0, 1], sum: 3, noRepeat: true },
+ { type: "thermo", cells: [4, 5] },
+ ],
+ title: "Preserved fixture",
+ author: "Local setter",
+ id: "domain-id",
+ visuals: sourceVisuals(),
+ source: { format: "sudokupad", id: "source-id", version: "1" },
+ metadata: { edition: "nightly", featured: true, attempt: 2 },
+ };
+}
+
+describe("source-preserving interoperability", () => {
+ it("clones and explicitly merges non-domain source extras", () => {
+ const source = normalizeSudokuDocument(sourceDocument());
+ const clone = cloneSudokuDocument(source);
+ const extras = extractPreservedDocumentExtras(source);
+ const domain = normalizePuzzle(toDomainPuzzle(source));
+ const merged = fromDomainPuzzle(domain, extras);
+
+ expect(clone.visuals).toEqual(source.visuals);
+ expect(clone.visuals).not.toBe(source.visuals);
+ expect(clone.source).not.toBe(source.source);
+ expect(clone.metadata).not.toBe(source.metadata);
+ expect(merged.visuals).toEqual(source.visuals);
+ expect(merged.source).toEqual(source.source);
+ expect(merged.metadata).toEqual(source.metadata);
+ });
+
+ it("round-trips f-puzzles decorative primitives and source scalars", () => {
+ const grid = emptyCells();
+ grid[0]![0] = { value: 1, given: true };
+ const parsed = parseFpuzzles({
+ id: "fp-source",
+ size: 4,
+ grid,
+ successMessage: "Done",
+ line: [
+ {
+ lines: [["R1C1", "R1C2"]],
+ outlineC: "#123456",
+ width: 0.1,
+ },
+ ],
+ rectangle: [
+ {
+ cells: ["R2C2"],
+ width: 0.8,
+ height: 0.6,
+ baseC: "#abcdef",
+ },
+ ],
+ circle: [
+ {
+ cells: ["R3C3"],
+ width: 0.5,
+ height: 0.5,
+ outlineC: "#112233",
+ },
+ ],
+ text: [{ cells: ["R4C4"], value: "A&B", fontC: "#445566" }],
+ });
+
+ expect(parsed.visuals?.map(({ type }) => type)).toEqual([
+ "polyline",
+ "rectangle",
+ "circle",
+ "text",
+ ]);
+ expect(parsed.source).toEqual({ format: "fpuzzles", id: "fp-source" });
+ expect(parsed.metadata).toEqual({ successMessage: "Done" });
+
+ const exported = exportFpuzzles(parsed);
+ expect(exported.id).toBe("fp-source");
+ expect(exported.line).toBeInstanceOf(Array);
+ expect(exported.rectangle).toBeInstanceOf(Array);
+ expect(exported.circle).toBeInstanceOf(Array);
+ expect(exported.text).toBeInstanceOf(Array);
+ expect(exported.successMessage).toBe("Done");
+ });
+
+ it("exports real SCL JSON and payload with progress and exact semantics", () => {
+ const source = sourceDocument();
+ const json = exportSudokuPadJson(source, true);
+ const raw = JSON.parse(json) as Record;
+ const payload = exportSudokuPadPayload(source);
+ const importedJson = importSudokuPad(json);
+ const importedPayload = importSudokuPad(payload);
+
+ expect(raw.id).toBe("source-id");
+ expect(raw.cells).toBeInstanceOf(Array);
+ expect(raw.regions).toBeInstanceOf(Array);
+ expect(raw.cages).toBeInstanceOf(Array);
+ expect(raw.lines).toBeInstanceOf(Array);
+ expect(raw.underlays).toBeInstanceOf(Array);
+ expect(raw.overlays).toBeInstanceOf(Array);
+ expect(payload).toMatch(/^scl/u);
+ for (const imported of [importedJson, importedPayload]) {
+ expect(imported.constraints).toEqual(source.constraints);
+ expect(imported.values).toEqual(source.values);
+ expect(imported.cornerMarks).toEqual(source.cornerMarks);
+ expect(imported.centerMarks).toEqual(source.centerMarks);
+ expect(imported.elapsedMs).toBe(source.elapsedMs);
+ expect(imported.regions).toEqual(source.regions);
+ expect(imported.metadata).toEqual(
+ expect.objectContaining({ edition: "nightly", featured: true }),
+ );
+ expect(imported.source).toEqual({
+ format: "sudokupad",
+ id: "source-id",
+ });
+ expect(imported.visuals?.length).toBeGreaterThanOrEqual(
+ source.visuals!.length,
+ );
+ }
+ });
+
+ it("does not promote retained metadata into SudokuPad rule fields", () => {
+ const source: SudokuDocument = {
+ ...sourceDocument(),
+ constraints: [],
+ title: undefined,
+ author: undefined,
+ rules: undefined,
+ solution: undefined,
+ metadata: {
+ antiknight: true,
+ antiking: true,
+ nonconsecutive: true,
+ title: "not a title",
+ author: "not an author",
+ rules: "not a rule",
+ solution: "1234341221434321",
+ sudokuToolsConstraints: '[{"type":"anti-knight"}]',
+ edition: "retained",
+ },
+ };
+ const raw = JSON.parse(exportSudokuPadJson(source)) as {
+ metadata: Record;
+ };
+ const imported = importSudokuPad(JSON.stringify(raw));
+
+ expect(raw.metadata).not.toHaveProperty("antiknight");
+ expect(raw.metadata).not.toHaveProperty("antiking");
+ expect(raw.metadata).not.toHaveProperty("nonconsecutive");
+ expect(raw.metadata).not.toHaveProperty("title");
+ expect(raw.metadata).not.toHaveProperty("author");
+ expect(raw.metadata).not.toHaveProperty("rules");
+ expect(raw.metadata).not.toHaveProperty("solution");
+ expect(raw.metadata.edition).toBe("retained");
+ expect(imported.constraints).toEqual([]);
+ expect(imported.title).toBeUndefined();
+ expect(imported.solution).toBeUndefined();
+ });
+
+ it.each(["onclick", "style", "html", "d", "href"])(
+ "rejects the executable or raw visual field %s",
+ (field) => {
+ expect(() =>
+ parseSudokuPadPuzzle({
+ cells: emptyCells(),
+ overlays: [
+ {
+ center: [0.5, 0.5],
+ width: 1,
+ height: 1,
+ [field]: "javascript:alert(1)",
+ },
+ ],
+ }),
+ ).toThrow(/not an allowlisted visual field/u);
+ },
+ );
+
+ it("rejects unsafe colours and non-finite or out-of-bounds geometry", () => {
+ expect(() =>
+ parseSudokuPadPuzzle({
+ cells: emptyCells(),
+ lines: [
+ {
+ wayPoints: [
+ [0.5, 0.5],
+ [1.5, 1.5],
+ ],
+ color: "url(javascript:alert(1))",
+ },
+ ],
+ }),
+ ).toThrow(/hexadecimal colour/u);
+ expect(() =>
+ parseSudokuPadPuzzle({
+ cells: emptyCells(),
+ overlays: [
+ { center: [Number.POSITIVE_INFINITY, 0], width: 1, height: 1 },
+ ],
+ }),
+ ).toThrow(/finite number/u);
+ expect(() =>
+ parseSudokuPadPuzzle({
+ cells: emptyCells(),
+ overlays: [{ center: [99, 99], width: 1, height: 1 }],
+ }),
+ ).toThrow(/finite number/u);
+ });
+
+ it("enforces primitive and aggregate point limits", () => {
+ const visual = sourceVisuals()[0]!;
+ expect(() =>
+ normalizeSudokuDocument({
+ ...sourceDocument(),
+ visuals: Array.from(
+ { length: MAX_VISUAL_PRIMITIVES + 1 },
+ () => visual,
+ ),
+ }),
+ ).toThrow(/at most .* primitives/u);
+
+ const points = Array.from({ length: MAX_VISUAL_POINTS / 2 + 1 }, () => ({
+ kind: "coordinate" as const,
+ x: 0,
+ y: 0,
+ }));
+ expect(() =>
+ normalizeSudokuDocument({
+ ...sourceDocument(),
+ visuals: [
+ { type: "polyline", layer: "overlay", points },
+ { type: "polyline", layer: "overlay", points },
+ ],
+ }),
+ ).toThrow(/more than .* anchors/u);
+ });
+
+ it("escapes imported text and emits no executable SVG attributes", () => {
+ const malicious =
+ '';
+ const document = normalizeSudokuDocument({
+ ...sourceDocument(),
+ visuals: [
+ {
+ type: "text",
+ layer: "overlay",
+ position: { kind: "cell", cell: 0 },
+ text: malicious,
+ style: { fill: "#000000" },
+ },
+ ],
+ });
+ const svg = renderPuzzleSvg(document);
+ const parsed = new DOMParser().parseFromString(svg, "image/svg+xml");
+
+ expect(parsed.querySelector("parsererror")).toBeNull();
+ expect(parsed.querySelector("script, image")).toBeNull();
+ expect(
+ parsed.querySelector("[href], [src], [onclick], [onload]"),
+ ).toBeNull();
+ expect(parsed.querySelector(".source-visual")?.textContent).toBe(malicious);
+ expect(svg).not.toContain("