feat: complete advanced Sudoku workbench

This commit is contained in:
2026-08-31 08:20:30 +02:00
parent 8ca9300ab3
commit 0a1bdc1a8c
99 changed files with 20793 additions and 923 deletions
+60 -7
View File
@@ -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 75200% 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.
+211 -44
View File
@@ -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 puzzles 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 0100 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 75200% 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 <kbd>M</kbd>, 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 0100 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
+2 -2
View File
@@ -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,
+2
View File
@@ -8,6 +8,8 @@
content="Set, play, solve and analyse Sudoku puzzles locally in your browser."
/>
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<link rel="manifest" href="./manifest.webmanifest" />
<meta name="theme-color" content="#272421" />
<title>Sudoku Tools</title>
</head>
<body>
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
+4 -1
View File
@@ -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"]],
+60 -7
View File
@@ -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 75200% 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.
+211 -44
View File
@@ -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 puzzles 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 0100 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 75200% 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 <kbd>M</kbd>, 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 0100 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
+2 -2
View File
@@ -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,
+19
View File
@@ -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"
}
]
}
+119
View File
@@ -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;
}),
),
);
});
+1 -1
View File
@@ -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",
+2
View File
@@ -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",
+1
View File
@@ -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"],
+183
View File
@@ -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<HTMLDivElement>(null);
const panOriginRef = useRef<PanOrigin | undefined>(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<HTMLElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
if (panOriginRef.current?.pointerId !== event.pointerId) return;
scrollerRef.current?.releasePointerCapture?.(event.pointerId);
panOriginRef.current = undefined;
};
const percentage = Math.round(scale * 100);
return (
<section
className={`board-viewport${panMode ? " is-pan-mode" : ""}`}
aria-label="Board zoom and pan"
onKeyDownCapture={onKeyDownCapture}
>
<div className="board-viewport__controls">
<div
className="board-zoom-buttons"
role="group"
aria-label="Board zoom"
>
<button
type="button"
aria-label="Zoom board out"
disabled={scale <= BOARD_SCALE_MIN}
onClick={() => updateScale(scale - BOARD_SCALE_STEP)}
>
</button>
<output aria-live="polite" aria-label="Board zoom level">
{percentage}%
</output>
<button
type="button"
aria-label="Zoom board in"
disabled={scale >= BOARD_SCALE_MAX}
onClick={() => updateScale(scale + BOARD_SCALE_STEP)}
>
+
</button>
<button type="button" onClick={fitBoard}>
Fit board
</button>
</div>
<button
type="button"
className={panMode ? "is-active" : ""}
aria-pressed={panMode}
onClick={() =>
setPanMode((current) => {
const enabled = !current;
panOriginRef.current = undefined;
onPanModeChange?.(enabled);
return enabled;
})
}
>
{panMode ? "Stop panning" : "Pan board"}
</button>
</div>
{panMode && (
<p className="board-pan-notice" role="status">
Pan mode: drag the board to move it. Cell taps are paused.
</p>
)}
<div
ref={scrollerRef}
className="board-viewport__scroller"
tabIndex={0}
aria-label={
panMode
? "Scrollable puzzle board; pan mode is on"
: "Scrollable puzzle board"
}
onPointerDown={beginPan}
onPointerMove={continuePan}
onPointerUp={endPan}
onPointerCancel={endPan}
>
<div
className="board-viewport__canvas"
style={{
width: `${String(percentage)}%`,
maxWidth: `${String(48 * scale)}rem`,
}}
>
{children}
</div>
</div>
</section>
);
}
+659 -71
View File
@@ -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<number> {
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<number, number>();
const actualToExpected = new Map<number, number>();
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<OutsideConstraintType>("x-sum");
const [outsideSide, setOutsideSide] = useState<OutsideClueSide>("top");
const [littleKillerDirection, setLittleKillerDirection] =
useState<LittleKillerDirection>("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
<select
value={puzzle.size}
onChange={(event) => onNewGrid(Number(event.target.value))}
onChange={(event) => {
const size = Number(event.target.value);
setWhisperMinimumDifference(Math.ceil(size / 2));
onNewGrid(size);
}}
>
<option value="4">4 × 4</option>
<option value="6">6 × 6</option>
@@ -419,10 +644,335 @@ export function ConstraintEditor({
<button
type="button"
disabled={!need(1)}
onClick={() => append({ type: "maximum", cell: selection[0]! })}
onClick={() =>
append(
{ type: "maximum", cell: selection[0]! },
(constraint) =>
constraint.type === "maximum" &&
constraint.cell === selection[0],
)
}
>
Maximum cell
</button>
<button
type="button"
disabled={!need(1)}
onClick={() =>
append(
{ type: "minimum", cell: selection[0]! },
(constraint) =>
constraint.type === "minimum" &&
constraint.cell === selection[0],
)
}
>
Minimum cell
</button>
<button
type="button"
disabled={!need(1)}
onClick={() =>
append(
{ type: "odd", cell: selection[0]! },
(constraint) =>
constraint.type === "odd" && constraint.cell === selection[0],
)
}
>
Odd cell (circle)
</button>
<button
type="button"
disabled={!need(1)}
onClick={() =>
append(
{ type: "even", cell: selection[0]! },
(constraint) =>
constraint.type === "even" &&
constraint.cell === selection[0],
)
}
>
Even cell (square)
</button>
<button
type="button"
className="danger"
disabled={!selectedCellMarkerExists}
onClick={() =>
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
</button>
</div>
<div className="pack-constraint-controls stack">
<div>
<p className="eyebrow">Expansion lines &amp; regions</p>
<p className="muted">
Selection order defines each line. For clone regions, select all
source cells first and then the same number of paired clone cells.
</p>
</div>
<div className="inline-fields">
<label className="compact-field">
Whisper minimum difference
<input
type="number"
min="1"
max={puzzle.size - 1}
value={whisperMinimumDifference}
onChange={(event) =>
setWhisperMinimumDifference(Number(event.target.value))
}
/>
</label>
</div>
<div className="button-grid">
<button
type="button"
disabled={!atLeast(3) || !selectionIsUnique}
onClick={() =>
append(
{ type: "between-line", cells: selection },
(constraint) =>
constraint.type === "between-line" &&
sameOrderedCells(constraint.cells, selection),
)
}
>
Between line
</button>
<button
type="button"
disabled={
!atLeast(2) || !selectionIsUnique || !validWhisperDifference
}
onClick={() =>
append(
{
type: "german-whisper",
cells: selection,
minimumDifference: whisperMinimumDifference,
},
(constraint) =>
constraint.type === "german-whisper" &&
sameOrderedCells(constraint.cells, selection),
)
}
>
German whisper
</button>
<button
type="button"
disabled={!atLeast(2) || !selectionIsUnique}
onClick={() =>
append(
{ type: "region-sum-line", cells: selection },
(constraint) =>
constraint.type === "region-sum-line" &&
sameOrderedCells(constraint.cells, selection),
)
}
>
Region-sum line
</button>
<button
type="button"
disabled={!validClone}
onClick={() =>
append(
{
type: "clone",
cells: cloneSource,
cloneCells: cloneTarget,
},
(constraint) =>
constraint.type === "clone" &&
sameOrderedCells(constraint.cells, cloneSource) &&
sameOrderedCells(constraint.cloneCells, cloneTarget),
)
}
>
Clone selection halves
</button>
<button
type="button"
disabled={selection.length !== puzzle.size || !selectionIsUnique}
onClick={() =>
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)
</button>
</div>
</div>
<div className="pack-constraint-controls pack-three-controls stack">
<div>
<p className="eyebrow">Pattern lines, indexers &amp; fog</p>
<p className="muted">
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.
</p>
</div>
<div className="inline-fields">
<label className="compact-field">
Indexer kind
<select
value={indexerKind}
onChange={(event) =>
setIndexerKind(event.target.value as "row" | "column" | "box")
}
>
<option value="row">Row indexer</option>
<option value="column">Column indexer</option>
<option value="box" disabled={!boxIndexerAvailable}>
Box indexer
</option>
</select>
</label>
<label className="compact-field">
Fog reveal radius
<select
value={fogRevealRadius}
onChange={(event) =>
setFogRevealRadius(Number(event.target.value) as 0 | 1)
}
>
<option value="1">1 cell (3 × 3 area)</option>
<option value="0">0 cells (entered cell only)</option>
</select>
</label>
</div>
<p className="muted pack-three-requirements">
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."}
</p>
<div className="button-grid">
<button
type="button"
disabled={!atLeast(3) || !selectionIsUnique}
onClick={() =>
append(
{ type: "modular-line", cells: selection },
(constraint) =>
constraint.type === "modular-line" &&
sameOrderedCells(constraint.cells, selection),
)
}
>
Modular line
</button>
<button
type="button"
disabled={
!atLeast(3) || !selectionIsUnique || puzzle.size % 3 !== 0
}
onClick={() =>
append(
{ type: "entropic-line", cells: selection },
(constraint) =>
constraint.type === "entropic-line" &&
sameOrderedCells(constraint.cells, selection),
)
}
>
Entropic line
</button>
<button
type="button"
disabled={
!atLeast(3) || !selectionIsUnique || selection.length % 2 === 0
}
onClick={() =>
append(
{ type: "zipper-line", cells: selection },
(constraint) =>
constraint.type === "zipper-line" &&
sameOrderedCells(constraint.cells, selection),
)
}
>
Zipper line
</button>
<button
type="button"
disabled={!atLeast(3) || !selectionIsUnique}
onClick={() =>
append(
{ type: "double-arrow", cells: selection },
(constraint) =>
constraint.type === "double-arrow" &&
sameOrderedCells(constraint.cells, selection),
)
}
>
Double arrow
</button>
<button
type="button"
disabled={
!need(1) || (indexerKind === "box" && !boxIndexerAvailable)
}
onClick={() =>
append(
{
type: "indexer",
kind: indexerKind,
cell: selection[0]!,
},
(constraint) =>
constraint.type === "indexer" &&
constraint.cell === selection[0],
)
}
>
Add / replace indexer
</button>
<button
type="button"
disabled={fogDisabledReason !== undefined}
aria-describedby={fogReasonId}
onClick={() =>
append(
{
type: "fog",
lights: selection,
revealRadius: fogRevealRadius,
},
(constraint) => constraint.type === "fog",
)
}
>
Add / replace Fog of War
</button>
</div>
<p id={fogReasonId} className="muted fog-setter-reason" role="status">
{fogDisabledReason ??
"Fog is ready: correct entries and givens reveal nearby cells."}
</p>
</div>
<div className="inline-fields">
<label className="compact-field">
@@ -478,10 +1028,11 @@ export function ConstraintEditor({
<section className="panel-section">
<p className="eyebrow">Outside clues</p>
<h3>X-sums and skyscrapers</h3>
<h3>Edge and diagonal sums</h3>
<p className="muted">
Choose the edge and row or column. Σ badges are X-sums; badges are
visibility counts.
visibility counts; 1N marks sandwich sums. Little-killer arrows show
the diagonal direction.
</p>
<div className="inline-fields outside-clue-fields">
<label className="compact-field">
@@ -489,22 +1040,28 @@ export function ConstraintEditor({
<select
value={outsideType}
onChange={(event) =>
setOutsideType(event.target.value as "x-sum" | "skyscraper")
setOutsideType(event.target.value as OutsideConstraintType)
}
>
<option value="x-sum">X-sum</option>
<option value="skyscraper">Skyscraper</option>
<option value="little-killer">Little killer</option>
<option value="sandwich">Sandwich sum</option>
</select>
</label>
<label className="compact-field">
Side
<select
value={outsideSide}
onChange={(event) =>
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));
}
}}
>
<option value="top">Top</option>
<option value="right">Right</option>
@@ -522,52 +1079,78 @@ export function ConstraintEditor({
onChange={(event) => setOutsideLine(Number(event.target.value))}
/>
</label>
{outsideType === "little-killer" && (
<label className="compact-field">
Direction
<select
value={littleKillerDirection}
onChange={(event) =>
setLittleKillerDirection(
event.target.value as LittleKillerDirection,
)
}
>
{LITTLE_KILLER_DIRECTIONS.map(([direction, label]) => (
<option
key={direction}
value={direction}
disabled={
!littleKillerDirectionEntersGrid(outsideSide, direction)
}
>
{label}
</option>
))}
</select>
</label>
)}
<label className="compact-field">
Clue
{outsideType === "skyscraper" ? "Count" : "Sum"}
<input
type="number"
min="1"
max={
newCluesAreFalse
? puzzle.size ** 4
: outsideType === "x-sum"
? (puzzle.size * (puzzle.size + 1)) / 2
: puzzle.size
}
min={outsideMinimum}
max={outsideMaximum}
value={outsideValue}
onChange={(event) => setOutsideValue(Number(event.target.value))}
/>
</label>
<button
type="button"
disabled={
!Number.isInteger(outsideLine) ||
outsideLine < 1 ||
outsideLine > 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({
<section className="panel-section">
<p className="eyebrow">Global rules</p>
<div className="button-grid">
{(["anti-knight", "anti-king", "non-consecutive"] as const).map(
(type) => (
<button
key={type}
type="button"
className={
constraints.some((constraint) => constraint.type === type)
? "is-active"
: ""
}
aria-pressed={constraints.some(
(constraint) => constraint.type === type,
)}
onClick={() => toggleGlobal(type)}
>
{type}
</button>
),
)}
{(
[
"anti-knight",
"anti-king",
"non-consecutive",
"disjoint-groups",
] as const
).map((type) => (
<button
key={type}
type="button"
className={
constraints.some((constraint) => constraint.type === type)
? "is-active"
: ""
}
aria-pressed={constraints.some(
(constraint) => constraint.type === type,
)}
onClick={() => toggleGlobal(type)}
>
{constraintLabel(type)}
</button>
))}
{(["main", "anti"] as const).map((direction) => {
const active = constraints.some(
(constraint) =>
+428 -20
View File
@@ -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<GeneratorVariant>("classic");
@@ -48,35 +61,107 @@ export function GeneratorWorkspace({
const [size, setSize] = useState(9);
const [targetDifficulty, setTargetDifficulty] =
useState<GenerationDifficultyTarget>("medium");
const [symmetry, setSymmetry] = useState<"none" | "rotational">("rotational");
const [additionalVariants, setAdditionalVariants] = useState<
GeneratorVariant[]
>([]);
const [symmetry, setSymmetry] = useState<ClueSymmetry>("rotational");
const [constraintCount, setConstraintCount] = useState(8);
const [constraintDensity, setConstraintDensity] =
useState<ConstraintDensity>("balanced");
const [minimalGivens, setMinimalGivens] = useState(false);
const [batchSize, setBatchSize] = useState(1);
const [batchRanking, setBatchRanking] = useState<BatchRanking>("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<HTMLFormElement>) => {
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
<select
value={size}
onChange={(event) => 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) => (
<option key={supportedSize} value={supportedSize}>
@@ -137,6 +243,35 @@ export function GeneratorWorkspace({
))}
</select>
</label>
<fieldset className="generator-family-picker">
<legend>Mix in variant families</legend>
<div className="generator-family-options">
{GENERATOR_VARIANTS.filter(({ id }) => id !== variant).map(
(item) => {
const supported = (
item.supportedSizes as readonly number[]
).includes(size);
return (
<label key={item.id}>
<input
type="checkbox"
checked={additionalVariants.includes(item.id)}
disabled={!supported}
onChange={(event) =>
setAdditionalVariants((current) =>
event.target.checked
? [...current, item.id]
: current.filter((id) => id !== item.id),
)
}
/>
{item.label}
</label>
);
},
)}
</div>
</fieldset>
<label>
Requested profile
<select
@@ -159,10 +294,15 @@ export function GeneratorWorkspace({
<select
value={symmetry}
onChange={(event) =>
setSymmetry(event.target.value as "none" | "rotational")
setSymmetry(event.target.value as ClueSymmetry)
}
>
<option value="rotational">Rotational</option>
<option value="horizontal">Horizontal reflection</option>
<option value="vertical">Vertical reflection</option>
<option value="diagonal-main">Main diagonal</option>
<option value="diagonal-anti">Anti-diagonal</option>
<option value="orthogonal">Four-way orthogonal</option>
<option value="none">None</option>
</select>
</label>
@@ -180,6 +320,54 @@ export function GeneratorWorkspace({
/>
</label>
)}
{usesConstraintDensity && (
<label>
Constraint density
<select
value={constraintDensity}
onChange={(event) =>
setConstraintDensity(event.target.value as ConstraintDensity)
}
>
<option value="sparse">Sparse</option>
<option value="balanced">Balanced</option>
<option value="dense">Dense</option>
</select>
</label>
)}
<label className="checkbox-field">
<input
type="checkbox"
checked={minimalGivens}
onChange={(event) => setMinimalGivens(event.target.checked)}
/>
Prove minimal givens
</label>
<label>
Batch size
<input
type="number"
min="1"
max="12"
value={batchSize}
onChange={(event) => setBatchSize(Number(event.target.value))}
/>
</label>
{batchSize > 1 && (
<label>
Batch ranking
<select
value={batchRanking}
onChange={(event) =>
setBatchRanking(event.target.value as BatchRanking)
}
>
<option value="difficulty">Difficulty</option>
<option value="fewest-givens">Fewest givens</option>
<option value="most-givens">Most givens</option>
</select>
</label>
)}
<label>
Seed
<input
@@ -202,7 +390,8 @@ export function GeneratorWorkspace({
<option value="">No required technique</option>
{PRACTICE_TECHNIQUES.filter(
(technique) =>
technique !== "killer-cage" || variant === "killer",
technique !== "killer-cage" ||
selectedFamilies.includes("killer"),
).map((technique) => (
<option key={technique} value={technique}>
{techniqueLabel(technique)}
@@ -211,6 +400,75 @@ export function GeneratorWorkspace({
</select>
</label>
{requiredTechnique !== "" && (
<>
<label>
Minimum occurrences
<input
type="number"
min="1"
max="10000"
value={minimumTechniqueCount}
onChange={(event) =>
setMinimumTechniqueCount(Number(event.target.value))
}
/>
</label>
<label>
Maximum occurrences
<input
type="number"
min="0"
max="10000"
value={maximumTechniqueCount}
placeholder="no maximum"
onChange={(event) =>
setMaximumTechniqueCount(event.target.value)
}
/>
</label>
</>
)}
<label>
Forbidden technique
<select
value={forbiddenTechnique}
onChange={(event) =>
setForbiddenTechnique(
event.target.value as PracticeTechnique | "",
)
}
>
<option value="">None</option>
{PRACTICE_TECHNIQUES.map((technique) => (
<option key={technique} value={technique}>
{techniqueLabel(technique)}
</option>
))}
</select>
</label>
<label>
Hardest technique target
<select
value={hardestTechnique}
onChange={(event) =>
setHardestTechnique(
event.target.value as PracticeTechnique | "",
)
}
>
<option value="">Any</option>
{PRACTICE_TECHNIQUES.filter(
(technique) =>
technique !== "killer-cage" ||
selectedFamilies.includes("killer"),
).map((technique) => (
<option key={technique} value={technique}>
{techniqueLabel(technique)}
</option>
))}
</select>
</label>
{hasTechniqueProfile && (
<label>
Mining attempts
<input
@@ -228,8 +486,17 @@ export function GeneratorWorkspace({
<p className="generator-description">{definition.description}</p>
<div className="action-row">
<button className="primary-button" type="submit" disabled={busy}>
{busy ? "Working locally…" : `Generate ${definition.label}`}
{busy
? "Working locally…"
: batchSize > 1
? `Generate ${String(batchSize)}-puzzle batch`
: `Generate ${definition.label}`}
</button>
{busy && onCancel !== undefined && (
<button type="button" onClick={onCancel}>
Cancel generation
</button>
)}
<button type="button" disabled={busy} onClick={onRate}>
Rate current puzzle
</button>
@@ -237,8 +504,8 @@ export function GeneratorWorkspace({
<p className="muted">
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.
</p>
</form>
@@ -291,6 +558,16 @@ export function GeneratorWorkspace({
</strong>
</span>
)}
{generation && (
<span>
Minimality <strong>{generation.minimality.status}</strong>
</span>
)}
{generation?.techniqueProfile && (
<span>
Profile <strong>{generation.techniqueProfile.status}</strong>
</span>
)}
</div>
<p>{assessment.summary}</p>
{generation && (
@@ -298,8 +575,139 @@ export function GeneratorWorkspace({
Seed: <code>{String(generation.seed)}</code> · Found in{" "}
{generation.generationAttempts} attempt
{generation.generationAttempts === 1 ? "" : "s"}.
{generation.families.length > 1
? ` Families: ${generation.families.join(", ")}.`
: ""}
</p>
)}
{generation?.minimality.status === "proven-minimal" && (
<p className="muted">
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."
: ""}
</p>
)}
{generation?.minimality.status === "unknown" && (
<p className="muted">
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"}
.
</p>
)}
{generation?.techniqueProfile && (
<p className="muted">
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)})`}
.
</p>
)}
</section>
)}
{batch && (
<section className="panel-section generator-batch" aria-live="polite">
<div className="section-heading">
<div>
<p className="eyebrow">Ranked local batch</p>
<h3>
{batch.completed} of {batch.requested} generated
</h3>
</div>
<span className="status-pill">
{batch.ranking.replaceAll("-", " ")}
</span>
</div>
{batch.summaries.length === 0 ? (
<p>No candidate passed the bounded checks.</p>
) : (
<div className="setter-quality__table-wrap">
<table className="setter-quality__table">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Seed</th>
<th scope="col">Families</th>
<th scope="col">Givens</th>
<th scope="col">Score</th>
<th scope="col">Evidence</th>
<th scope="col">Open</th>
</tr>
</thead>
<tbody>
{batch.summaries.map((summary, index) => {
const entry = batch.entries[index];
return (
<tr key={String(summary.seed)}>
<th scope="row">{summary.rank}</th>
<td>
<code>{String(summary.seed)}</code>
</td>
<td>{summary.families.join(" + ")}</td>
<td>{summary.clueCount}</td>
<td>{summary.score ?? "—"}</td>
<td>
{summary.minimalityStatus}
{summary.profileStatus
? ` · ${summary.profileStatus}`
: ""}
</td>
<td>
<button
type="button"
disabled={entry === undefined}
onClick={() => {
if (entry !== undefined) {
onSelectGenerated?.(entry);
}
}}
>
Use
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{batch.failures.length > 0 && (
<details>
<summary>
{batch.failures.length} bounded candidate
{batch.failures.length === 1 ? "" : "s"} did not complete or
match the profile
</summary>
<ul className="muted">
{batch.failures.map((failure) => (
<li key={String(failure.seed)}>
<code>{String(failure.seed)}</code>: {failure.message}
</li>
))}
</ul>
</details>
)}
</section>
)}
</div>
+273
View File
@@ -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<GuidedHintStage, string> = {
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 (
<section className="guided-hint stack" aria-labelledby="guided-hint-title">
<div className="guided-hint__heading">
<div>
<p className="eyebrow">Guided solving</p>
<h3 id="guided-hint-title">One clue at a time</h3>
</div>
{step !== undefined && (
<button
type="button"
className="guided-hint__dismiss"
onClick={onDismiss}
disabled={busy}
>
Dismiss
</button>
)}
</div>
<p className="guided-hint__intro muted">
Reveal only as much help as you want. Nothing changes until you apply
the fully previewed step.
</p>
<p
className="guided-hint__status status-line"
role="status"
aria-live="polite"
aria-atomic="true"
>
{busy
? "Finding a logical next step locally…"
: error !== undefined
? "The hint could not be prepared."
: step === undefined
? "No guided hint is open."
: stageStatus(stage)}
</p>
{error !== undefined && (
<p className="guided-hint__error error-callout" role="alert">
{error}
</p>
)}
{step !== undefined && (
<>
<ol
className="guided-hint__progress"
aria-label="Hint reveal progress"
>
{GUIDED_HINT_STAGES.map((item) => {
const revealed = isGuidedHintStageRevealed(stage, item);
return (
<li
key={item}
className={revealed ? "is-revealed" : "is-concealed"}
aria-current={item === stage ? "step" : undefined}
>
{STAGE_LABELS[item]}
</li>
);
})}
</ol>
<div className="guided-hint__stage">
<section
className="guided-hint__focus"
aria-labelledby="hint-focus-title"
>
<p className="eyebrow" id="hint-focus-title">
Where to look
</p>
<p>{guidedHintFocusSummary(step, size)}</p>
</section>
{isGuidedHintStageRevealed(stage, "technique") && (
<section
className="guided-hint__technique"
aria-labelledby="hint-technique-title"
>
<p className="eyebrow" id="hint-technique-title">
Technique
</p>
<p>
<strong>{logicalTechniqueName(step.technique)}</strong>
</p>
<p className="muted">
{logicalTechniqueDescription(step.technique)}
</p>
</section>
)}
{isGuidedHintStageRevealed(stage, "reasoning") && (
<section
className="guided-hint__reasoning"
aria-labelledby="hint-reasoning-title"
>
<p className="eyebrow" id="hint-reasoning-title">
Why it works
</p>
<p>{step.explanation}</p>
</section>
)}
{stage === "preview" && (
<section
className="guided-hint__preview"
aria-labelledby="hint-preview-title"
>
<p className="eyebrow" id="hint-preview-title">
Effects preview
</p>
{effectItems.length > 0 ? (
<ul className="guided-hint__effect-list">
{effectItems.map((effect, index) => (
<li key={`${String(index)}-${effect}`}>{effect}</li>
))}
</ul>
) : (
<p>This deduction does not change the board.</p>
)}
{step.eliminations.length > 0 && !candidateTrackingActive && (
<p className="guided-hint__tracking-note muted">
Applying this elimination will start a complete legal
centre-candidate grid, then remove the previewed candidates.
</p>
)}
</section>
)}
</div>
<div className="guided-hint__actions action-row">
{nextLabel !== undefined && (
<button type="button" onClick={onRevealNext} disabled={busy}>
{nextLabel}
</button>
)}
{stage === "preview" && (
<button
type="button"
className="guided-hint__apply"
onClick={onApply}
disabled={busy || effectItems.length === 0}
>
Apply this step
</button>
)}
<button type="button" onClick={onRequestHint} disabled={busy}>
New hint
</button>
</div>
</>
)}
{step === undefined && (
<div className="guided-hint__actions action-row">
<button type="button" onClick={onRequestHint} disabled={busy}>
Get a guided hint
</button>
</div>
)}
<fieldset className="guided-hint__maintenance">
<legend>Candidate maintenance</legend>
<p className="muted" id="guided-candidate-status">
{candidateTrackingActive
? "The guided candidate grid is active."
: "Candidate tracking is currently inactive."}
</p>
<div
className="guided-hint__maintenance-actions action-row"
aria-describedby="guided-candidate-status"
>
<button type="button" onClick={onFillLegalCandidates} disabled={busy}>
Fill legal candidates
</button>
<button type="button" onClick={onRemoveInvalidNotes} disabled={busy}>
Remove invalid notes
</button>
</div>
<label className="guided-hint__auto-maintain check-row">
<input
type="checkbox"
checked={autoMaintainPeerNotes}
disabled={busy}
onChange={(event) =>
onAutoMaintainPeerNotesChange(event.currentTarget.checked)
}
/>
Automatically remove peer notes after placing a digit
</label>
</fieldset>
</section>
);
}
+134 -36
View File
@@ -14,12 +14,13 @@ export function HelpDialog({
<h3>Five complementary workspaces</h3>
<p>
<strong>Play</strong> keeps values, two kinds of notes, colours,
branches, replay and elapsed time. <strong>Set</strong> edits clues
and constraints. <strong>Generate</strong> constructs and rates
bounded, seedable variants. <strong>Solve</strong> explains logical
steps and can verify uniqueness. <strong>Helpers</strong> answers
focused sum, candidate and relation questions without changing the
board.
branches, replay, guided hints and elapsed time.{" "}
<strong>Set</strong> edits registry-backed clues and runs bounded
setter-quality checks. <strong>Generate</strong> constructs and
ranks bounded, seedable single or batch variants.{" "}
<strong>Solve</strong> explains logical steps and can verify
uniqueness. <strong>Helpers</strong> answers focused sum, candidate
and relation questions without changing the board.
</p>
</section>
<section>
@@ -65,16 +66,63 @@ export function HelpDialog({
<dt>Ctrl/ + click</dt>
<dd>Highlight every placed copy of that digit</dd>
</div>
<div>
<dt>M</dt>
<dd>Toggle tap-by-tap multi-selection</dd>
</div>
<div>
<dt>Ctrl/ + + / </dt>
<dd>Zoom the board while focus is in the board area</dd>
</div>
<div>
<dt>Ctrl/ + 0</dt>
<dd>Fit the board at its default scale</dd>
</div>
</dl>
</section>
<section>
<h3>Touch, zoom and selection</h3>
<p>
Board zoom is stored in this browser. Use <strong>Pan board</strong>
before dragging a zoomed board; its strong border and status message
indicate that cell taps are temporarily paused. Stop panning to edit
again. <strong>Tap multi-select</strong> 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.
</p>
</section>
<section>
<h3>Hints and solutions</h3>
<p>
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 <strong>Apply this step</strong>. 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.
</p>
<p>
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.
</p>
</section>
<section>
<h3>Setter-quality checks</h3>
<p>
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.
</p>
</section>
<section>
@@ -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.
</p>
</section>
<section>
@@ -115,25 +164,44 @@ export function HelpDialog({
<section>
<h3>Variant and false clues</h3>
<p>
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.
</p>
<p>
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.
</p>
</section>
<section>
<h3>Generation and ratings</h3>
<p>
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.
</p>
<p>
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 0100 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.
</p>
</section>
<section>
@@ -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.
</p>
<p>
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.
</p>
</section>
<section>
<h3>Recovery, Library and offline use</h3>
<p>
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.
</p>
<p>
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.
</p>
</section>
<section>
<h3>Screen-reader detail</h3>
<p>
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 <strong>Off</strong>, 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.
</p>
</section>
</div>
+141 -15
View File
@@ -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 (
<div>
<h4>{title}</h4>
{entries.length === 0 ? (
<p className="muted">None.</p>
) : (
<ul>
{entries.map((entry) => (
<li key={entry.key}>
{entry.label}: {entry.count}
</li>
))}
</ul>
)}
</div>
);
}
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<SudokuDocument>;
}>();
const fileRef = useRef<HTMLInputElement>(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
</button>
</div>
{inspected?.input === input && (
<section
className="import-mapping-preview stack"
aria-label="Import mapping preview"
>
<div>
<p className="eyebrow">Mapping preview</p>
<h3>{inspected.result.label}</h3>
</div>
<PreviewList
title="Mapped semantics"
entries={inspected.result.preview.mappedSemantics}
/>
<PreviewList
title="Preserved visuals"
entries={inspected.result.preview.preservedVisuals}
/>
<PreviewList
title="Preserved metadata"
entries={inspected.result.preview.preservedMetadata}
/>
<div>
<h4>Warnings</h4>
{inspected.result.preview.warnings.length === 0 ? (
<p className="muted">None.</p>
) : (
<ul>
{inspected.result.preview.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
)}
</div>
</section>
)}
</section>
<section className="stack">
@@ -357,6 +443,46 @@ export function ImportExportDialog({
>
Copy f-puzzles URL
</button>
<button
type="button"
disabled={busy}
onClick={() =>
exportText(
"sudoku.scl.json",
"application/json",
"SudokuPad JSON",
() => exportSudokuPadJson(documentValue, true),
)
}
>
Download SudokuPad JSON
</button>
<button
type="button"
disabled={busy}
onClick={() =>
exportText(
"sudoku.scl",
"text/plain",
"SudokuPad SCL payload",
() => exportSudokuPadPayload(documentValue),
)
}
>
Download SudokuPad SCL payload
</button>
<button
type="button"
disabled={busy}
onClick={() =>
void copyExport(
() => exportSudokuPadPayload(documentValue),
"SudokuPad SCL payload",
)
}
>
Copy SudokuPad SCL payload
</button>
<button
type="button"
disabled={busy}
+263 -14
View File
@@ -1,4 +1,4 @@
import { useRef } from "react";
import { useMemo, useRef, useState } from "react";
import type { SudokuProjectSummary } from "../storage";
import { Modal } from "./Modal";
@@ -9,6 +9,26 @@ function date(value: number) {
}).format(new Date(value));
}
function LibraryThumbnail({ item }: { item: SudokuProjectSummary }) {
return (
<span
className="library-thumbnail"
style={{
gridTemplateColumns: `repeat(${String(item.size)}, 1fr)`,
fontSize: `${String(Math.max(0.18, Math.min(0.62, 2.7 / item.size)))}rem`,
}}
role="img"
aria-label={`${String(item.size)} by ${String(item.size)} puzzle preview`}
>
{[...item.thumbnail].map((symbol, index) => (
<span key={index} aria-hidden="true">
{symbol === "." ? "" : symbol}
</span>
))}
</span>
);
}
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<HTMLInputElement>(null);
const [search, setSearch] = useState("");
const [completion, setCompletion] = useState<"all" | "open" | "complete">(
"all",
);
const [tag, setTag] = useState("");
const [selected, setSelected] = useState<ReadonlySet<string>>(
() => new Set(),
);
const [editingTagsFor, setEditingTagsFor] = useState<string>();
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 (
<Modal open={open} title="Local puzzle library" onClose={onClose} wide>
<div className="library-toolbar">
<div>
<p>
{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."}
</p>
<p className="muted">
@@ -81,38 +162,206 @@ export function LibraryDialog({
/>
</div>
</div>
<div className="library-filters" aria-label="Filter saved puzzles">
<label>
<span>Search</span>
<input
type="search"
value={search}
placeholder="Title or tag"
onChange={(event) => setSearch(event.target.value)}
/>
</label>
<label>
<span>Status</span>
<select
aria-label="Completion filter"
value={completion}
onChange={(event) =>
setCompletion(event.target.value as typeof completion)
}
>
<option value="all">All progress</option>
<option value="open">In progress</option>
<option value="complete">Complete</option>
</select>
</label>
<label>
<span>Tag</span>
<select value={tag} onChange={(event) => setTag(event.target.value)}>
<option value="">All tags</option>
{allTags.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</label>
</div>
{feedback && (
<p className="status-line" role="status">
{feedback}
</p>
)}
{summaries.length > 0 && (
<div className="library-selection-toolbar">
<span>
{String(selectedIds.length)} selected · {String(filtered.length)}{" "}
shown
</span>
<div className="action-row">
<button
type="button"
className="text-button"
disabled={busy || filtered.length === 0}
onClick={() =>
setSelected(new Set(filtered.map((item) => item.id)))
}
>
Select shown
</button>
<button
type="button"
className="text-button"
disabled={busy || selectedIds.length === 0}
onClick={() => setSelected(new Set())}
>
Clear selection
</button>
<button
type="button"
disabled={busy || selectedIds.length === 0}
onClick={() => onExportSelected(selectedIds)}
>
Export selected
</button>
<button
type="button"
disabled={busy || selectedIds.length === 0}
onClick={() => onDuplicateSelected(selectedIds)}
>
Duplicate selected
</button>
<button
type="button"
className="danger"
disabled={busy || selectedIds.length === 0}
onClick={() => onDeleteSelected(selectedIds)}
>
Delete selected
</button>
</div>
</div>
)}
{summaries.length === 0 ? (
<div className="empty-state">
<h3>No saved puzzles</h3>
<p>Save the current puzzle to build a local library.</p>
</div>
) : filtered.length === 0 ? (
<div className="empty-state">
<h3>No matching puzzles</h3>
<p>Adjust the title, tag or progress filters.</p>
</div>
) : (
<ul className="library-list">
{summaries.map((item) => (
{filtered.map((item) => (
<li key={item.id}>
<label className="library-selector">
<input
type="checkbox"
checked={selected.has(item.id)}
aria-label={`Select ${item.title || "Untitled puzzle"}`}
onChange={() => toggleSelected(item.id)}
/>
</label>
<button
className="library-open"
type="button"
onClick={() => onOpen(item.id)}
>
<strong>{item.title || "Untitled puzzle"}</strong>
<span>
{item.size}×{item.size} · {date(item.updatedAt)}
{item.completed ? " · complete" : ""}
<LibraryThumbnail item={item} />
<span className="library-copy">
<strong>{item.title || "Untitled puzzle"}</strong>
<span>
{item.size}×{item.size} · {date(item.updatedAt)}
{item.completed ? " · complete" : " · in progress"}
</span>
</span>
</button>
<button
className="text-button danger"
type="button"
onClick={() => onDelete(item.id)}
>
Delete
</button>
<div className="library-item-actions">
<div className="library-tags" aria-label="Project tags">
{item.tags.map((value) => (
<button
key={value}
type="button"
className="tag-chip"
onClick={() => setTag(value)}
>
{value}
</button>
))}
</div>
{editingTagsFor === item.id ? (
<form
className="library-tag-editor"
onSubmit={(event) => {
event.preventDefault();
onUpdateTags(
item.id,
tagDraft
.split(",")
.map((value) => value.trim())
.filter(Boolean),
);
setEditingTagsFor(undefined);
}}
>
<label>
<span className="sr-only">Comma-separated tags</span>
<input
aria-label={`Tags for ${item.title || "Untitled puzzle"}`}
value={tagDraft}
maxLength={400}
placeholder="classic, hard"
onChange={(event) => setTagDraft(event.target.value)}
/>
</label>
<button type="submit" disabled={busy}>
Apply
</button>
<button
type="button"
className="text-button"
onClick={() => setEditingTagsFor(undefined)}
>
Cancel
</button>
</form>
) : (
<button
className="text-button"
type="button"
onClick={() => {
setEditingTagsFor(item.id);
setTagDraft(item.tags.join(", "));
}}
>
Edit tags
</button>
)}
<button
className="text-button danger"
type="button"
onClick={() => onDelete(item.id)}
>
Delete
</button>
</div>
</li>
))}
</ul>
+4 -1
View File
@@ -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" ? (
<>
<span aria-hidden="true" />{" "}
<span className="sr-only">Colour {value}</span>
<span className="sr-only">
Colour {value}: {colorMarkDescription(value)}
</span>
</>
) : (
symbolFor(value, size)
+139
View File
@@ -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<SVGElement> {
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 (
<g
className={`safe-visual-layer safe-visual-layer--${layer}`}
data-visual-layer={layer}
>
{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 (
<line
key={index}
{...common}
x1={start.x}
y1={start.y}
x2={end.x}
y2={end.y}
/>
);
}
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 ? (
<polygon key={index} {...common} points={points} />
) : (
<polyline key={index} {...common} points={points} />
);
}
if (visual.type === "rectangle") {
const center = anchorPoint(size, visual.center);
return (
<rect
key={index}
{...common}
x={center.x - visual.width / 2}
y={center.y - visual.height / 2}
width={visual.width}
height={visual.height}
rx={visual.cornerRadius}
/>
);
}
if (visual.type === "ellipse") {
const center = anchorPoint(size, visual.center);
return (
<ellipse
key={index}
{...common}
cx={center.x}
cy={center.y}
rx={visual.radiusX}
ry={visual.radiusY}
/>
);
}
if (visual.type === "circle") {
const center = anchorPoint(size, visual.center);
return (
<circle
key={index}
{...common}
cx={center.x}
cy={center.y}
r={visual.radius}
/>
);
}
const position = anchorPoint(size, visual.position);
return (
<text
key={index}
{...common}
x={position.x}
y={position.y}
fontSize={visual.style?.fontSize}
textAnchor="middle"
dominantBaseline="central"
>
{visual.text}
</text>
);
})}
</g>
);
}
+740
View File
@@ -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<typeof analyzePuzzleQuality>[1]
>;
export type SetterQualityRunKind = "quick" | "minimality";
export interface SetterQualityLabProps {
readonly size: number;
readonly result?: PuzzleQualityAnalysis;
readonly running?: SetterQualityRunKind;
readonly error?: string;
readonly initialOptions?: Partial<SetterQualityRunOptions>;
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<keyof QualityControls, number> = {
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 (
<section className="setter-quality__item-group">
<h5>{title}</h5>
{items.length === 0 ? (
<p className="muted">{emptyLabel}</p>
) : (
<ul className="setter-quality__item-list">
{items.map((item) => (
<li key={itemKey(item)}>
<button type="button" onClick={() => onFocusItem(item)}>
{itemLabel(item, size)}
</button>
</li>
))}
</ul>
)}
</section>
);
}
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 (
<section className="setter-quality__assessments">
<div className="section-heading">
<h4>{title}</h4>
<p className="setter-quality__counts muted">
{counts.critical} critical · {counts.redundant} redundant ·{" "}
{counts.unknown} unknown
</p>
</div>
{assessments.length === 0 ? (
<p className="muted">
No {title.toLowerCase()} were available to test.
</p>
) : (
<div className="setter-quality__table-wrap">
<table className="setter-quality__table">
<thead>
<tr>
<th scope="col">Clue</th>
<th scope="col">Finding</th>
<th scope="col">Evidence</th>
</tr>
</thead>
<tbody>
{assessments.map((assessment) => (
<tr
key={itemKey(assessment.item)}
className={`is-${assessment.classification}`}
>
<th scope="row">
<button
type="button"
onClick={() => onFocusItem(assessment.item)}
>
{itemLabel(assessment.item, size)}
</button>
</th>
<td>
<strong>{humanize(assessment.classification)}</strong>
</td>
<td>
{assessment.classification === "unknown"
? `Unknown / incomplete${assessment.unknownReason ? `: ${humanize(assessment.unknownReason)}` : ""}`
: assessment.solutionStatus
? `Without clue: ${humanize(assessment.solutionStatus)}`
: "Completed bounded check"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
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 (
<div className="setter-quality__results stack">
<section className="setter-quality__solution analysis-summary">
<div className="section-heading">
<div>
<p className="eyebrow">Solution status</p>
<h3>{solutionTitle(result.solutionStatus)}</h3>
</div>
<span
className={`status-pill status-${result.solutionStatus}`}
aria-label={`Solution status: ${humanize(result.solutionStatus)}`}
>
{humanize(result.solutionStatus)}
</span>
</div>
<p>{solutionDescription(result.solutionStatus)}</p>
</section>
{witness !== undefined && (
<section
className="setter-quality__witness panel-section"
aria-labelledby="quality-witness-title"
>
<div className="section-heading">
<div>
<p className="eyebrow">Ambiguity witness</p>
<h4 id="quality-witness-title">
{witness.differences.length} differing cell
{witness.differences.length === 1 ? "" : "s"}
</h4>
</div>
<button
type="button"
disabled={witness.differences.length === 0}
onClick={() =>
onFocusCells(witness.differences.map(({ cell }) => cell))
}
>
Focus differences
</button>
</div>
<p className="muted">
These values come from two concrete valid completions; they are not
guesses.
</p>
<div className="setter-quality__table-wrap">
<table className="setter-quality__table">
<thead>
<tr>
<th scope="col">Cell</th>
<th scope="col">Solution A</th>
<th scope="col">Solution B</th>
</tr>
</thead>
<tbody>
{witness.differences.map(({ cell, first, second }) => (
<tr key={cell}>
<th scope="row">
<button
type="button"
onClick={() => onFocusCells([cell])}
>
{cellName(cell, size)}
</button>
</th>
<td>{symbolFor(first, size)}</td>
<td>{symbolFor(second, size)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
<section
className={`setter-quality__contradiction panel-section status-${result.contradiction.status}`}
aria-labelledby="quality-contradiction-title"
>
<div className="section-heading">
<div>
<p className="eyebrow">Contradiction localization</p>
<h4 id="quality-contradiction-title">
{result.contradiction.status === "localized"
? "Contradiction suspects localized"
: result.contradiction.status === "incomplete"
? "Incomplete / unknown"
: "Not applicable"}
</h4>
</div>
<span className="status-pill">
{humanize(result.contradiction.status)}
</span>
</div>
{result.contradiction.reason && <p>{result.contradiction.reason}</p>}
{result.contradiction.status === "not-applicable" && (
<p className="muted">
Contradiction localization only applies after unsatisfiability is
proved.
</p>
)}
<div className="setter-quality__contradiction-groups">
<ItemList
title="Retained suspect core"
items={result.contradiction.core}
size={size}
emptyLabel="No retained suspects were established."
onFocusItem={onFocusItem}
/>
<ItemList
title="Proven necessary suspects"
items={result.contradiction.necessary}
size={size}
emptyLabel="No individual suspect was proved necessary."
onFocusItem={onFocusItem}
/>
<ItemList
title="Removable while still contradictory"
items={result.contradiction.removable}
size={size}
emptyLabel="No removable suspects were established."
onFocusItem={onFocusItem}
/>
<ItemList
title="Unknown at safety bounds"
items={result.contradiction.unknown}
size={size}
emptyLabel="No unresolved suspects."
onFocusItem={onFocusItem}
/>
</div>
</section>
<section className="setter-quality__redundancy panel-section">
<p className="eyebrow">Leave-one-out evidence</p>
<h3>Clue criticality and redundancy</h3>
<p className="muted">
Critical means removing that clue admits another completion. Redundant
means uniqueness was proved without it. Unknown always remains unknown
when a bound interrupts proof.
</p>
{result.analysisDepth === "baseline" ? (
<p className="setter-quality__not-analysed">
Not analysed in the quick baseline. Run the full bounded minimality
analysis to classify individual clues.
</p>
) : (
<>
<AssessmentTable
title="Givens"
assessments={result.redundancy.givens}
size={size}
onFocusItem={onFocusItem}
/>
<AssessmentTable
title="Constraints"
assessments={result.redundancy.constraints}
size={size}
onFocusItem={onFocusItem}
/>
</>
)}
</section>
<section
className="setter-quality__heatmap panel-section"
aria-labelledby="quality-heatmap-title"
>
<p className="eyebrow">Criticality heatmap</p>
<h3 id="quality-heatmap-title">Cell evidence</h3>
<p className="muted" id="quality-heatmap-help">
Every button states its evidence, so colour is never the only signal.
Select a cell to focus it on the puzzle.
</p>
{result.criticalityHeatmap.length === 0 ? (
<p>
{result.analysisDepth === "baseline"
? "Not analysed in the quick baseline."
: "No cell-level evidence is available."}
</p>
) : (
<div
className="setter-quality__heatmap-grid"
role="group"
aria-label="Cell criticality heatmap"
aria-describedby="quality-heatmap-help"
style={{
gridTemplateColumns: `repeat(${String(size)}, minmax(0, 1fr))`,
}}
>
{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 (
<button
key={entry.cell}
type="button"
className={`setter-quality__heat-cell is-${evidenceClass}`}
aria-label={label}
title={label}
onClick={() => onFocusCells([entry.cell])}
>
<span>{cellName(entry.cell, size)}</span>
<strong>
{entry.score === null ? "?" : entry.score.toFixed(1)}
</strong>
</button>
);
})}
</div>
)}
</section>
<section className="setter-quality__minimality panel-section">
<div className="section-heading">
<div>
<p className="eyebrow">Bounded minimality</p>
<h3>
{minimality === undefined
? "Not requested"
: humanize(minimality.status)}
</h3>
</div>
<span className="status-pill">
{minimality === undefined ? "Not run" : humanize(minimality.status)}
</span>
</div>
{minimality === undefined ? (
<p>
Minimality is unknown because the full bounded minimality pass was
not requested.
</p>
) : (
<>
{minimality.reason && <p>{minimality.reason}</p>}
<ItemList
title="Redundant clues"
items={minimality.redundant}
size={size}
emptyLabel="No redundant clue was proved."
onFocusItem={onFocusItem}
/>
<ItemList
title="Unknown clues"
items={minimality.unknown}
size={size}
emptyLabel="No clue remained unknown."
onFocusItem={onFocusItem}
/>
</>
)}
</section>
<section className="setter-quality__metrics panel-section">
<p className="eyebrow">Bounded-search record</p>
<h3>Checks and limits</h3>
<dl className="setter-quality__metric-list">
<div>
<dt>Analysis depth</dt>
<dd>
{result.analysisDepth === "baseline"
? "Quick baseline"
: "Full setter QC"}
</dd>
</div>
<div>
<dt>Analysis completeness</dt>
<dd>
{result.budget.truncated
? "Incomplete / unknown conclusions remain"
: "All requested checks completed"}
</dd>
</div>
<div>
<dt>Checks performed / planned</dt>
<dd>
{result.budget.checksPerformed.toLocaleString()} /{" "}
{result.budget.checksPlanned.toLocaleString()}
</dd>
</div>
<div>
<dt>Recorded check details</dt>
<dd>{result.checks.length.toLocaleString()}</dd>
</div>
<div>
<dt>Nodes searched</dt>
<dd>{result.budget.nodes.toLocaleString()}</dd>
</div>
<div>
<dt>Elapsed</dt>
<dd>{result.budget.elapsedMs.toLocaleString()} ms</dd>
</div>
<div>
<dt>Unknown reasons</dt>
<dd>
{result.budget.unknownReasons.length === 0
? "None"
: result.budget.unknownReasons.map(humanize).join(", ")}
</dd>
</div>
<div>
<dt>Per-check node / time bound</dt>
<dd>
{result.bounds.perCheck.maxNodes.toLocaleString()} nodes /{" "}
{result.bounds.perCheck.timeoutMs.toLocaleString()} ms
</dd>
</div>
<div>
<dt>Aggregate check / node / time bound</dt>
<dd>
{result.bounds.aggregate.maxChecks.toLocaleString()} checks /{" "}
{result.bounds.aggregate.maxNodes.toLocaleString()} nodes /{" "}
{result.bounds.aggregate.timeoutMs.toLocaleString()} ms
</dd>
</div>
</dl>
</section>
</div>
);
}
export function SetterQualityLab({
size,
result,
running,
error,
initialOptions,
onRunQuick,
onRunMinimality,
onCancel,
onFocusCells,
onFocusItem,
}: SetterQualityLabProps) {
const [controls, setControls] = useState<QualityControls>(() => ({
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<HTMLFormElement>) => {
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 (
<section
className="setter-quality stack"
aria-labelledby="setter-quality-title"
>
<div>
<p className="eyebrow">Setter quality</p>
<h2 id="setter-quality-title">Uniqueness, redundancy and minimality</h2>
<p className="muted">
Every claim is backed by a bounded local exact-search check. Reaching
a limit produces an explicit unknown result, never a guessed verdict.
</p>
</div>
<form
className="setter-quality__controls panel-section"
onSubmit={runQuick}
>
<div className="field-grid">
<label>
Nodes per check
<input
type="number"
min="1"
max="100000000"
value={controls.perCheckMaxNodes}
disabled={running !== undefined}
onChange={(event) =>
setControl("perCheckMaxNodes", event.target.value)
}
/>
</label>
<label>
Time per check (ms)
<input
type="number"
min="1"
max="120000"
value={controls.perCheckTimeoutMs}
disabled={running !== undefined}
onChange={(event) =>
setControl("perCheckTimeoutMs", event.target.value)
}
/>
</label>
<label>
Maximum checks
<input
type="number"
min="1"
max="20000"
value={controls.aggregateMaxChecks}
disabled={running !== undefined}
onChange={(event) =>
setControl("aggregateMaxChecks", event.target.value)
}
/>
</label>
<label>
Total node budget
<input
type="number"
min="1"
max="2000000000"
value={controls.aggregateMaxNodes}
disabled={running !== undefined}
onChange={(event) =>
setControl("aggregateMaxNodes", event.target.value)
}
/>
</label>
<label>
Total time budget (ms)
<input
type="number"
min="1"
max="600000"
value={controls.aggregateTimeoutMs}
disabled={running !== undefined}
onChange={(event) =>
setControl("aggregateTimeoutMs", event.target.value)
}
/>
</label>
</div>
<div className="setter-quality__actions action-row">
<button type="submit" disabled={running !== undefined}>
Run quick quality check
</button>
<button
type="button"
disabled={running !== undefined}
onClick={() => onRunMinimality(options("full", true))}
>
Run full bounded minimality
</button>
{running !== undefined && (
<button type="button" onClick={onCancel}>
Cancel analysis
</button>
)}
</div>
</form>
{running !== undefined && (
<p
className="setter-quality__running status-line"
role="status"
aria-live="polite"
>
{running === "quick"
? "Quick quality check running locally…"
: "Full bounded minimality analysis running locally…"}
</p>
)}
{error !== undefined && (
<p className="setter-quality__error error-callout" role="alert">
{error}
</p>
)}
{result !== undefined && (
<QualityResults
size={size}
result={result}
onFocusCells={onFocusCells}
onFocusItem={onFocusItem}
/>
)}
</section>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+60
View File
@@ -0,0 +1,60 @@
import type { FogConstraint, NormalizedPuzzle } from "../domain";
/**
* Resolve display-only Fog of War visibility without changing puzzle rules.
* A trusted correct entry and every given act as reveal sources in addition to
* the authored lights.
*/
export function foggedCellsForPuzzle(
puzzle: NormalizedPuzzle,
values: readonly number[],
): ReadonlySet<number> {
const fogConstraints = puzzle.constraints.filter(
(constraint): constraint is FogConstraint => constraint.type === "fog",
);
if (fogConstraints.length === 0) return new Set<number>();
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<number>();
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),
),
);
}
+223
View File
@@ -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<Record<LogicalTechnique, string>> = {
"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<number>,
): 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(" ");
}
+35 -62
View File
@@ -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<CellId>[], 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<CellId>());
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 (
+482
View File
@@ -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<VariantConstraint, { readonly type: Type }>,
) => readonly CellId[];
}
type ConstraintRegistry = {
readonly [Type in ConstraintType]: ConstraintRegistryEntry<Type>;
};
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<Type extends ConstraintType>(
value: ConstraintRegistryEntry<Type>,
): ConstraintRegistryEntry<Type> {
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 extends ConstraintType>(
type: Type,
): ConstraintRegistryEntry<Type> {
return CONSTRAINT_REGISTRY[type] as unknown as ConstraintRegistryEntry<Type>;
}
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<string> {
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))];
}
+143 -7
View File
@@ -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<number>(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,
+1
View File
@@ -1,4 +1,5 @@
export * from "./compile";
export * from "./constraintRegistry";
export * from "./geometry";
export * from "./rules";
export * from "./types";
+536 -82
View File
@@ -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<number> {
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<number>();
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<string>();
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<VariantConstraint, { readonly type: "indexer" }>,
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,
+145 -1
View File
@@ -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;
+432 -25
View File
@@ -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<VariantConstraint["type"], ReadonlySet<string>>
> = {
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<number, ReadonlySet<number>>();
const reachableSandwichCache = new Map<number, ReadonlySet<number>>();
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -182,6 +173,30 @@ function reachableXSumTotals(size: number): ReadonlySet<number> {
return reachable;
}
function reachableSandwichTotals(size: number): ReadonlySet<number> {
const cached = reachableSandwichCache.get(size);
if (cached !== undefined) return cached;
const reachable = new Set<number>([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);
+409
View File
@@ -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<SudokuDocument, "size" | "constraints">,
): SafeVisualPrimitive[] {
return document.constraints.flatMap((constraint) =>
renderConstraint(constraint, document.size),
);
}
+232 -3
View File
@@ -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 } }),
};
}
+747 -38
View File
@@ -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<Record<string, string>> = {
disjointgroups: "disjoint groups",
littlekillersum: "little killer sums",
sandwichsum: "sandwich sums",
even: "even cells",
odd: "odd cells",
extraregion: "extra regions",
clone: "clone regions",
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<Record<string, string>> = {
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<string>,
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<string, ScalarMetadataValue> {
const output: Record<string, ScalarMetadataValue> = {};
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",
+1
View File
@@ -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 }),
};
+45 -60
View File
@@ -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",
);
}
+2
View File
@@ -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";
+119 -2
View File
@@ -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<T> {
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<string, number>();
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<string, number>();
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<SudokuDocument> {
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 {
+1
View File
@@ -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] }),
+407
View File
@@ -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<SudokuSourceFormat>([
"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<Record<string, string>> = {
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function exactFields(
value: Record<string, unknown>,
allowed: ReadonlySet<string>,
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<string, ScalarMetadataValue> = {};
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;
}
+4 -1
View File
@@ -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;
}
+812 -25
View File
@@ -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<string, unknown>;
@@ -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<SafeVisualAnchor, { readonly kind: "coordinate" }> {
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<string>,
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<Extract<SafeVisualAnchor, { readonly kind: "coordinate" }>> {
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<string, unknown> = {};
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<PortableConstraint, { readonly type: "fog" }> =>
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;
}
+261 -13
View File
@@ -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<Record<string, ScalarMetadataValue>>;
/**
* 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<PreservedSudokuDocumentExtras>,
): 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<PreservedSudokuDocumentExtras>,
): 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,
+206 -9
View File
@@ -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 `<line ${attributes} x1="${number(start.x)}" y1="${number(start.y)}" x2="${number(end.x)}" y2="${number(end.y)}"/>`;
}
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 `<rect ${attributes} x="${number(center.x - width / 2)}" y="${number(center.y - height / 2)}" width="${number(width)}" height="${number(height)}"${visual.cornerRadius === undefined ? "" : ` rx="${number(visual.cornerRadius * CELL_SIZE)}"`}/>`;
}
if (visual.type === "ellipse") {
const center = safeVisualPoint(size, visual.center, origin);
return `<ellipse ${attributes} cx="${number(center.x)}" cy="${number(center.y)}" rx="${number(visual.radiusX * CELL_SIZE)}" ry="${number(visual.radiusY * CELL_SIZE)}"/>`;
}
if (visual.type === "circle") {
const center = safeVisualPoint(size, visual.center, origin);
return `<circle ${attributes} cx="${number(center.x)}" cy="${number(center.y)}" r="${number(visual.radius * CELL_SIZE)}"/>`;
}
const position = safeVisualPoint(size, visual.position, origin);
return `<text ${attributes} x="${number(position.x)}" y="${number(position.y)}"${style.fontSize === undefined ? "" : ` font-size="${number(style.fontSize * CELL_SIZE)}"`}>${escapeXml(visual.text)}</text>`;
}
function pathBoundary(
size: number,
cells: ReadonlySet<number>,
@@ -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 `<g class="constraint ${constraint.type}${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, constraint.cells, origin)}"/></g>`;
}
if (constraint.type === "zipper-line") {
const center = point(
size,
constraint.cells[Math.floor(constraint.cells.length / 2)]!,
origin,
);
return `<g class="constraint zipper-line${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, constraint.cells, origin)}"/><circle cx="${number(center.x)}" cy="${number(center.y)}" r="${number(CELL_SIZE * 0.22)}"/></g>`;
}
if (constraint.type === "double-arrow") {
const first = point(size, constraint.cells[0]!, origin);
const last = point(size, constraint.cells.at(-1)!, origin);
return `<g class="constraint double-arrow${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, constraint.cells, origin)}"/><circle cx="${number(first.x)}" cy="${number(first.y)}" r="${number(CELL_SIZE * 0.22)}"/><circle cx="${number(last.x)}" cy="${number(last.y)}" r="${number(CELL_SIZE * 0.22)}"/></g>`;
}
if (constraint.type === "indexer") {
const center = point(size, constraint.cell, origin);
const label =
constraint.kind === "row"
? "R"
: constraint.kind === "column"
? "C"
: "B";
return `<g class="constraint indexer indexer-${constraint.kind}${polarity}" data-constraint="${index}"><rect x="${number(center.x - CELL_SIZE * 0.24)}" y="${number(center.y - CELL_SIZE * 0.24)}" width="${number(CELL_SIZE * 0.48)}" height="${number(CELL_SIZE * 0.48)}" rx="${number(CELL_SIZE * 0.1)}"/><text x="${number(center.x)}" y="${number(center.y)}">${marker}${label}</text></g>`;
}
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 `<g class="constraint cage${polarity}" data-constraint="${index}"><path d="${pathBoundary(size, new Set(constraint.cells), origin, 7)}"/><text class="cage-label" x="${number(label.x - CELL_SIZE * 0.34)}" y="${number(label.y - CELL_SIZE * 0.27)}">${marker}${String(constraint.sum)}</text></g>`;
}
if (constraint.type === "extra-region") {
return `<g class="constraint extra-region" data-constraint="${index}"><path d="${pathBoundary(size, new Set(constraint.cells), origin, 4)}"/></g>`;
}
if (constraint.type === "thermo") {
const bulb = point(size, constraint.cells[0]!, origin);
return `<g class="constraint thermo${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, constraint.cells, origin)}"/><circle cx="${number(bulb.x)}" cy="${number(bulb.y)}" r="${number(CELL_SIZE * 0.3)}"/>${negated ? `<text class="false-marker" x="${number(bulb.x)}" y="${number(bulb.y)}">≠</text>` : ""}</g>`;
@@ -188,7 +297,12 @@ function renderConstraint(
const maximumY = Math.max(...bulbPoints.map(({ y }) => y));
return `<g class="constraint arrow${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, path, origin)}"/><rect x="${number(minimumX - CELL_SIZE * 0.3)}" y="${number(minimumY - CELL_SIZE * 0.3)}" width="${number(maximumX - minimumX + CELL_SIZE * 0.6)}" height="${number(maximumY - minimumY + CELL_SIZE * 0.6)}" rx="${number(CELL_SIZE * 0.3)}"/><circle class="arrow-tip" cx="${number(tip.x)}" cy="${number(tip.y)}" r="${number(CELL_SIZE * 0.075)}"/>${negated ? `<text class="false-marker" x="${number(bulb.x)}" y="${number(bulb.y)}">≠</text>` : ""}</g>`;
}
if (constraint.type === "renban" || constraint.type === "palindrome") {
if (
constraint.type === "renban" ||
constraint.type === "palindrome" ||
constraint.type === "german-whisper" ||
constraint.type === "region-sum-line"
) {
return `<g class="constraint ${constraint.type}${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, constraint.cells, origin)}"/>${
constraint.type === "palindrome"
? constraint.cells
@@ -200,12 +314,43 @@ function renderConstraint(
: ""
}</g>`;
}
if (constraint.type === "between-line") {
const first = point(size, constraint.cells[0]!, origin);
const last = point(size, constraint.cells.at(-1)!, origin);
return `<g class="constraint between-line${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, constraint.cells, origin)}"/><circle cx="${number(first.x)}" cy="${number(first.y)}" r="${number(CELL_SIZE * 0.27)}"/><circle cx="${number(last.x)}" cy="${number(last.y)}" r="${number(CELL_SIZE * 0.27)}"/>${negated ? `<text class="false-marker" x="${number(first.x)}" y="${number(first.y)}">≠</text>` : ""}</g>`;
}
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 `<line x1="${number(a.x)}" y1="${number(a.y)}" x2="${number(b.x)}" y2="${number(b.y)}"/>`;
})
.join("");
return `<g class="constraint clone${polarity}" data-constraint="${index}">${connectors}<path d="${pathBoundary(size, original, origin, 7)}"/><path d="${pathBoundary(size, clone, origin, 7)}"/>${negated ? `<text class="false-marker" x="${number(point(size, constraint.cells[0]!, origin).x)}" y="${number(point(size, constraint.cells[0]!, origin).y)}">≠</text>` : ""}</g>`;
}
if (constraint.type === "maximum") {
const center = point(size, constraint.cell, origin);
const offset = CELL_SIZE * 0.28;
const inner = CELL_SIZE * 0.14;
return `<g class="constraint maximum${polarity}" data-constraint="${index}"><circle cx="${number(center.x)}" cy="${number(center.y)}" r="${number(CELL_SIZE * 0.18)}"/><path d="M${number(center.x - inner)} ${number(center.y - inner)}L${number(center.x)} ${number(center.y - offset)}L${number(center.x + inner)} ${number(center.y - inner)}M${number(center.x - inner)} ${number(center.y + inner)}L${number(center.x)} ${number(center.y + offset)}L${number(center.x + inner)} ${number(center.y + inner)}M${number(center.x - inner)} ${number(center.y - inner)}L${number(center.x - offset)} ${number(center.y)}L${number(center.x - inner)} ${number(center.y + inner)}M${number(center.x + inner)} ${number(center.y - inner)}L${number(center.x + offset)} ${number(center.y)}L${number(center.x + inner)} ${number(center.y + inner)}"/></g>`;
}
if (constraint.type === "minimum") {
const center = point(size, constraint.cell, origin);
const offset = CELL_SIZE * 0.28;
const inner = CELL_SIZE * 0.14;
return `<g class="constraint minimum${polarity}" data-constraint="${index}"><circle cx="${number(center.x)}" cy="${number(center.y)}" r="${number(CELL_SIZE * 0.18)}"/><path d="M${number(center.x - offset)} ${number(center.y - offset)}L${number(center.x - inner)} ${number(center.y - inner)}M${number(center.x + offset)} ${number(center.y - offset)}L${number(center.x + inner)} ${number(center.y - inner)}M${number(center.x - offset)} ${number(center.y + offset)}L${number(center.x - inner)} ${number(center.y + inner)}M${number(center.x + offset)} ${number(center.y + offset)}L${number(center.x + inner)} ${number(center.y + inner)}"/>${negated ? `<text class="false-marker" x="${number(center.x)}" y="${number(center.y)}">≠</text>` : ""}</g>`;
}
if (constraint.type === "odd" || constraint.type === "even") {
const center = point(size, constraint.cell, origin);
const shape =
constraint.type === "odd"
? `<circle cx="${number(center.x)}" cy="${number(center.y)}" r="${number(CELL_SIZE * 0.19)}"/>`
: `<rect x="${number(center.x - CELL_SIZE * 0.19)}" y="${number(center.y - CELL_SIZE * 0.19)}" width="${number(CELL_SIZE * 0.38)}" height="${number(CELL_SIZE * 0.38)}" rx="${number(CELL_SIZE * 0.035)}"/>`;
return `<g class="constraint parity ${constraint.type}${polarity}" data-constraint="${index}">${shape}${negated ? `<text class="false-marker" x="${number(center.x)}" y="${number(center.y)}">≠</text>` : ""}</g>`;
}
if (constraint.type === "quadruple") {
const positions = constraint.cells.map((cell) => point(size, cell, origin));
const center = {
@@ -218,7 +363,11 @@ function renderConstraint(
};
return `<g class="constraint quadruple${polarity}" data-constraint="${index}"><circle cx="${number(center.x)}" cy="${number(center.y)}" r="${number(CELL_SIZE * 0.29)}"/><text x="${number(center.x)}" y="${number(center.y)}">${marker}${escapeXml(constraint.digits.map((digit) => symbolFor(digit, size)).join(""))}</text></g>`;
}
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 `<g class="constraint outside combined${polarity}" data-constraint="${index}"><rect x="${number(position.x - 25)}" y="${number(position.y - 23)}" width="50" height="46" rx="8"/><text class="outside-kinds" x="${number(position.x)}" y="${number(position.y - 8)}">Σ · ▥</text><text class="outside-value" x="${number(position.x)}" y="${number(position.y + 10)}">${marker}${String(value)}</text></g>`;
}
return `<g class="constraint outside ${constraint.type}${polarity}" data-constraint="${index}"><rect x="${number(position.x - 25)}" y="${number(position.y - 18)}" width="50" height="36" rx="8"/><text x="${number(position.x)}" y="${number(position.y)}">${constraint.type === "x-sum" ? "Σ" : "▥"}${marker}${String(value)}</text></g>`;
const symbol =
constraint.type === "x-sum"
? "Σ"
: constraint.type === "skyscraper"
? "▥"
: "1⋯N ";
return `<g class="constraint outside ${constraint.type}${polarity}" data-constraint="${index}"><rect x="${number(position.x - 25)}" y="${number(position.y - 18)}" width="50" height="36" rx="8"/><text x="${number(position.x)}" y="${number(position.y)}">${symbol}${marker}${String(value)}</text></g>`;
}
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 `<g class="constraint outside little-killer${polarity}" data-constraint="${index}"><text x="${number(position.x)}" y="${number(position.y)}">${marker}${String(constraint.sum)}</text><line x1="${number(position.x)}" y1="${number(position.y)}" x2="${number(first.x)}" y2="${number(first.y)}"/><circle class="arrow-tip" cx="${number(first.x)}" cy="${number(first.y)}" r="${number(CELL_SIZE * 0.06)}"/></g>`;
}
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(
<title id="title">${escapeXml(title)}</title>
<desc id="description">${escapeXml(`${String(puzzle.size)} by ${String(puzzle.size)} Sudoku${includeProgress ? " with current progress" : ""}`)}</desc>
<style>
.background{fill:#fff}.heading{fill:#172033;font-family:system-ui,sans-serif}.title{font-size:28px;font-weight:750}.byline,.global-rules{fill:#5d6678;font-size:14px}.grid-lines{fill:none;stroke:#a5acb8;stroke-width:1}.region-boundaries{fill:none;stroke:#172033;stroke-width:3;stroke-linecap:square}.constraint{fill:none;stroke:#596273;stroke-width:6;stroke-linecap:round;stroke-linejoin:round}.constraint text{dominant-baseline:central;text-anchor:middle;fill:#172033;stroke:none;font-family:system-ui,sans-serif;font-weight:700}.constraint.negated{stroke:#c63f52;stroke-dasharray:10 7}.constraint.negated text{fill:#a5283b}.diagonal{stroke:#4da3d6;stroke-width:3}.cage{stroke-width:2.2;stroke-dasharray:6 5}.cage-label{font-size:14px;text-anchor:start!important}.thermo{stroke:#c3c7ce;stroke-width:20}.thermo circle{fill:#c3c7ce;stroke:none}.thermo.negated{stroke-width:13}.false-marker{font-size:22px}.arrow{stroke:#929aa7;stroke-width:5}.arrow rect{fill:#fff;stroke:#929aa7}.arrow-tip{fill:#929aa7;stroke:none}.renban{stroke:#b55b8c;stroke-width:14;opacity:.72}.palindrome{stroke:#9ba1ad;stroke-width:13}.palindrome circle{fill:#d8dbe1;stroke:none}.maximum circle{fill:#eef0f4;stroke:#596273;stroke-width:2}.maximum path{stroke-width:3}.quadruple circle{fill:#fff;stroke:#596273;stroke-width:2}.quadruple text{font-size:15px}.outside rect{fill:#fff;stroke:#788191;stroke-width:1.5}.outside text{font-size:17px}.outside .outside-kinds{font-size:12px}.outside .outside-value{font-size:16px}.kropki circle{stroke:#172033;stroke-width:2}.kropki.white circle{fill:#fff}.kropki.black circle{fill:#172033}.pair-false-marker{fill:#c63f52!important;font-size:14px}.xv circle{fill:#fff;stroke:#fff}.xv text{font-size:18px}.inequality{stroke:#172033;stroke-width:4}.inequality-tip{fill:#172033;stroke:none}.cell-value,.corner-note,.center-note{dominant-baseline:central;text-anchor:middle;font-family:system-ui,sans-serif}.cell-value{fill:#273c75;font-size:${number(CELL_SIZE * 0.58)}px}.cell-value.given{fill:#111827;font-weight:800}.cell-value.progress{font-weight:600}.corner-note{fill:#596273;font-size:${number(CELL_SIZE * 0.15)}px}.center-note{fill:#596273;font-size:${number(CELL_SIZE * 0.2)}px;letter-spacing:1px}
.background{fill:#fff}.heading{fill:#172033;font-family:system-ui,sans-serif}.title{font-size:28px;font-weight:750}.byline,.global-rules{fill:#5d6678;font-size:14px}.source-visual{stroke-linecap:round;stroke-linejoin:round}.source-visual--text{dominant-baseline:central;text-anchor:middle;font-family:system-ui,sans-serif}.grid-lines{fill:none;stroke:#a5acb8;stroke-width:1}.region-boundaries{fill:none;stroke:#172033;stroke-width:3;stroke-linecap:square}.constraint{fill:none;stroke:#596273;stroke-width:6;stroke-linecap:round;stroke-linejoin:round}.constraint text{dominant-baseline:central;text-anchor:middle;fill:#172033;stroke:none;font-family:system-ui,sans-serif;font-weight:700}.constraint.negated{stroke:#c63f52;stroke-dasharray:10 7}.constraint.negated text{fill:#a5283b}.diagonal{stroke:#4da3d6;stroke-width:3}.cage{stroke-width:2.2;stroke-dasharray:6 5}.cage-label{font-size:14px;text-anchor:start!important}.extra-region{stroke:#6d62b5;stroke-width:3;stroke-dasharray:10 5}.thermo{stroke:#c3c7ce;stroke-width:20}.thermo circle{fill:#c3c7ce;stroke:none}.thermo.negated{stroke-width:13}.false-marker{font-size:22px}.arrow{stroke:#929aa7;stroke-width:5}.arrow rect{fill:#fff;stroke:#929aa7}.arrow-tip{fill:#929aa7;stroke:none}.renban{stroke:#b55b8c;stroke-width:14;opacity:.72}.palindrome{stroke:#9ba1ad;stroke-width:13}.palindrome circle{fill:#d8dbe1;stroke:none}.german-whisper{stroke:#4d9b69;stroke-width:10}.region-sum-line{stroke:#4a94a3;stroke-width:7}.between-line{stroke:#7e8796;stroke-width:5}.between-line circle{fill:#fff;stroke:#7e8796;stroke-width:5}.modular-line{stroke:#167f8f;stroke-width:10;stroke-dasharray:18 7}.entropic-line{stroke:#d47742;stroke-width:10;stroke-dasharray:3 7}.zipper-line{stroke:#7b60ad;stroke-width:7}.zipper-line circle{fill:#fff;stroke:#7b60ad;stroke-width:5}.double-arrow{stroke:#596273;stroke-width:5}.double-arrow circle{fill:#fff;stroke:#596273;stroke-width:4}.indexer{stroke-width:2}.indexer rect{fill:#fff}.indexer-row{stroke:#287fba}.indexer-column{stroke:#b94c50}.indexer-box{stroke:#438b58}.indexer text{font-size:15px}.clone{stroke:#6d62b5;stroke-width:2.5;stroke-dasharray:8 5}.clone line{stroke-width:1.5;stroke-dasharray:4 5;opacity:.45}.maximum circle,.minimum circle{fill:#eef0f4;stroke:#596273;stroke-width:2}.maximum path,.minimum path{stroke-width:3}.parity{stroke:#596273;stroke-width:2}.parity circle,.parity rect{fill:#eef0f4}.quadruple circle{fill:#fff;stroke:#596273;stroke-width:2}.quadruple text{font-size:15px}.outside rect{fill:#fff;stroke:#788191;stroke-width:1.5}.outside text{font-size:17px}.outside .outside-kinds{font-size:12px}.outside .outside-value{font-size:16px}.little-killer line{stroke-width:2}.little-killer .arrow-tip{fill:#596273}.kropki circle{stroke:#172033;stroke-width:2}.kropki.white circle{fill:#fff}.kropki.black circle{fill:#172033}.pair-false-marker{fill:#c63f52!important;font-size:14px}.xv circle{fill:#fff;stroke:#fff}.xv text{font-size:18px}.inequality{stroke:#172033;stroke-width:4}.inequality-tip{fill:#172033;stroke:none}.cell-value,.corner-note,.center-note{dominant-baseline:central;text-anchor:middle;font-family:system-ui,sans-serif}.cell-value{fill:#273c75;font-size:${number(CELL_SIZE * 0.58)}px}.cell-value.given{fill:#111827;font-weight:800}.cell-value.progress{font-weight:600}.corner-note{fill:#596273;font-size:${number(CELL_SIZE * 0.15)}px}.center-note{fill:#596273;font-size:${number(CELL_SIZE * 0.2)}px;letter-spacing:1px}
</style>
<rect class="background" width="100%" height="100%"/>
<g class="heading"><text class="title" x="${origin.x}" y="34">${escapeXml(title)}</text><text class="byline" x="${origin.x}" y="57">${escapeXml(byline)}</text>${globals.length > 0 ? `<text class="global-rules" x="${width - sideMargin}" y="57" text-anchor="end">${escapeXml(globals.join(" · "))}</text>` : ""}</g>
<g>${backgrounds}<g class="grid-lines">${gridLines}</g>${constraints}${renderRegionBoundaries(puzzle, origin)}${digits}</g>
<g>${backgrounds}${sourceUnderlays}<g class="grid-lines">${gridLines}</g>${constraints}${renderRegionBoundaries(puzzle, origin)}${digits}${sourceOverlays}</g>
</svg>`;
if (new TextEncoder().encode(svg).byteLength > MAX_VISUAL_EXPORT_BYTES) {
throw new SudokuFormatError(
+4
View File
@@ -139,6 +139,10 @@ export function houseLabel(unit: Pick<SudokuUnit, "kind" | "index">): 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)}`;
}
}
+14
View File
@@ -7,3 +7,17 @@ createRoot(document.getElementById("root")!).render(
<App />
</StrictMode>,
);
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.
});
});
}
+954
View File
@@ -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<CellId>[];
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<T>(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>): number[] {
return [...new Set(values)].sort((a, b) => a - b);
}
function step(
technique: LogicalTechnique,
eliminations: readonly LogicalElimination[],
focusCells: Iterable<CellId>,
explanation: string,
): LogicalStep | undefined {
if (eliminations.length === 0) return undefined;
const byCell = new Map<number, Set<number>>();
for (const elimination of eliminations) {
const values = byCell.get(elimination.cell) ?? new Set<number>();
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<CellId> {
const first = cells[0];
if (first === undefined) return new Set<CellId>();
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<CellId> = 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<number>();
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<string, CellStrongLink>();
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<CellId>[] {
const adjacency = Array.from(
{ length: context.values.length },
() => new Set<CellId>(),
);
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<number>();
for (let start = 0; start < context.values.length; start += 1) {
if (visited.has(start) || (adjacency[start]?.size ?? 0) === 0) continue;
const colours = new Map<number, 0 | 1>([[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<number, ReadonlySet<number>> {
const adjacency = new Map<number, Set<number>>();
const add = (a: CandidateNode, b: CandidateNode): void => {
const aId = nodeId(context, a);
const bId = nodeId(context, b);
const aLinks = adjacency.get(aId) ?? new Set<number>();
const bLinks = adjacency.get(bId) ?? new Set<number>();
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<number, number>();
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)
);
}
+15 -1
View File
@@ -58,6 +58,17 @@ const TECHNIQUE_WEIGHT: Readonly<Record<LogicalTechnique, number>> = {
"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(
+280 -36
View File
@@ -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<MinimalityLimitReason>();
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(
+2
View File
@@ -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";
+119 -4
View File
@@ -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<number>(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",
+791
View File
@@ -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<number>(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<string>,
): 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<QualityUnknownReason>;
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<QualityUnknownReason>(),
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;
}
+532 -80
View File
@@ -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<string>(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<GenerationDifficultyTarget, number> = {
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<PracticeTechnique>();
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<readonly [number, number]> {
const edges: Array<readonly [number, number]> = [];
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,
};
}
+6 -1
View File
@@ -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(", ");
}
+213
View File
@@ -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;
}
+282
View File
@@ -4,6 +4,8 @@ import {
type PlaySnapshot,
} from "./session";
import {
aidMemoireFromPortable,
aidMemoireToPortable,
cloneAidMemoire,
createAidMemoire,
type AidMemoireState,
@@ -57,6 +59,286 @@ export interface HistoryTransition {
export const MAIN_BRANCH_ID = "main";
export const MAX_GAMEPLAY_MOMENTS = 500;
export const MAX_GAMEPLAY_HISTORY_BYTES = 1_048_576;
const GAMEPLAY_HISTORY_SCHEMA =
"de.add-ideas.sudoku-tools.gameplay-history" as const;
function historyRecord(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new TypeError(`${label} must be an object.`);
}
return value as Record<string, unknown>;
}
function historyText(value: unknown, label: string, maximum = 128): string {
if (
typeof value !== "string" ||
value.length === 0 ||
value.length > maximum
) {
throw new TypeError(`${label} must be non-empty bounded text.`);
}
return value;
}
function historyInteger(
value: unknown,
label: string,
minimum: number,
maximum: number,
): number {
if (
!Number.isInteger(value) ||
(value as number) < minimum ||
(value as number) > maximum
) {
throw new TypeError(
`${label} must be an integer from ${String(minimum)} to ${String(maximum)}.`,
);
}
return value as number;
}
function parseHistoryState(value: unknown, size: number): GameplayState {
const record = historyRecord(value, "A gameplay state");
const count = size * size;
const numbers = (
input: unknown,
label: string,
minimum: number,
maximum: number,
): number[] => {
if (!Array.isArray(input) || input.length !== count) {
throw new TypeError(
`${label} must contain exactly ${String(count)} values.`,
);
}
return input.map((entry, index) =>
historyInteger(entry, `${label}[${String(index)}]`, minimum, maximum),
);
};
const maximumMask = 2 ** size - 1;
const parsed: GameplayState = {
values: numbers(record.values, "state.values", 0, size),
cornerMarks: numbers(
record.cornerMarks,
"state.cornerMarks",
0,
maximumMask,
),
centerMarks: numbers(
record.centerMarks,
"state.centerMarks",
0,
maximumMask,
),
colors: numbers(record.colors, "state.colors", 0, 8),
elapsedSeconds: historyInteger(
record.elapsedSeconds,
"state.elapsedSeconds",
0,
31_536_000,
),
...(record.aidMemoire === undefined
? {}
: { aidMemoire: aidMemoireFromPortable(record.aidMemoire, size) }),
};
return parsed;
}
function portableHistoryState(state: GameplayState, size: number) {
return {
values: [...state.values],
cornerMarks: [...state.cornerMarks],
centerMarks: [...state.centerMarks],
colors: [...state.colors],
elapsedSeconds: state.elapsedSeconds,
...(state.aidMemoire === undefined
? {}
: { aidMemoire: aidMemoireToPortable(state.aidMemoire, size) }),
};
}
/** Serialize bounded replay/savepoint history for durable project storage. */
export function serializeGameplayHistory(
history: GameplayHistory,
size: number,
): string {
const payload = JSON.stringify({
schema: GAMEPLAY_HISTORY_SCHEMA,
version: 1,
history: {
...history,
moments: history.moments.map((moment) => ({
...moment,
state: portableHistoryState(moment.state, size),
})),
branches: history.branches.map((branch) => ({
...branch,
baseState: portableHistoryState(branch.baseState, size),
})),
savepoints: history.savepoints.map((savepoint) => ({
...savepoint,
state: portableHistoryState(savepoint.state, size),
})),
},
});
if (
new TextEncoder().encode(payload).byteLength > MAX_GAMEPLAY_HISTORY_BYTES
) {
throw new RangeError("Gameplay history is too large to persist safely.");
}
// Parsing here gives callers one canonical validation boundary even when a
// history object was assembled outside the normal reducers.
void parseGameplayHistory(payload, size);
return payload;
}
/** Parse an untrusted persisted gameplay history into independent state. */
export function parseGameplayHistory(
input: string,
size: number,
): GameplayHistory {
if (new TextEncoder().encode(input).byteLength > MAX_GAMEPLAY_HISTORY_BYTES) {
throw new RangeError("Gameplay history is too large to open safely.");
}
let decoded: unknown;
try {
decoded = JSON.parse(input) as unknown;
} catch (error) {
throw new TypeError("Gameplay history is not valid JSON.", {
cause: error,
});
}
const envelope = historyRecord(decoded, "Gameplay history");
if (envelope.schema !== GAMEPLAY_HISTORY_SCHEMA || envelope.version !== 1) {
throw new TypeError("Unsupported gameplay-history version.");
}
const raw = historyRecord(envelope.history, "Gameplay history payload");
if (
!Array.isArray(raw.moments) ||
raw.moments.length < 1 ||
raw.moments.length > MAX_GAMEPLAY_MOMENTS ||
!Array.isArray(raw.branches) ||
raw.branches.length > MAX_GAMEPLAY_MOMENTS ||
!Array.isArray(raw.savepoints) ||
raw.savepoints.length > MAX_GAMEPLAY_MOMENTS
) {
throw new TypeError(
"Gameplay history collections are invalid or too large.",
);
}
const moments = raw.moments.map((value, index): GameplayMoment => {
const item = historyRecord(value, `moments[${String(index)}]`);
return {
id: historyText(item.id, `moments[${String(index)}].id`),
sequence: historyInteger(
item.sequence,
`moments[${String(index)}].sequence`,
0,
1_000_000,
),
branchId: historyText(
item.branchId,
`moments[${String(index)}].branchId`,
),
label: historyText(item.label, `moments[${String(index)}].label`, 200),
state: parseHistoryState(item.state, size),
};
});
const branches = raw.branches.map((value, index): HypothesisBranch => {
const item = historyRecord(value, `branches[${String(index)}]`);
if (
item.status !== "active" &&
item.status !== "kept" &&
item.status !== "discarded"
) {
throw new TypeError(`branches[${String(index)}].status is invalid.`);
}
return {
id: historyText(item.id, `branches[${String(index)}].id`),
name: historyText(item.name, `branches[${String(index)}].name`, 80),
parentBranchId: historyText(
item.parentBranchId,
`branches[${String(index)}].parentBranchId`,
),
baseMomentId: historyText(
item.baseMomentId,
`branches[${String(index)}].baseMomentId`,
),
baseState: parseHistoryState(item.baseState, size),
status: item.status,
};
});
const savepoints = raw.savepoints.map((value, index): NamedSavepoint => {
const item = historyRecord(value, `savepoints[${String(index)}]`);
return {
id: historyText(item.id, `savepoints[${String(index)}].id`),
name: historyText(item.name, `savepoints[${String(index)}].name`, 80),
momentId: historyText(
item.momentId,
`savepoints[${String(index)}].momentId`,
),
branchId: historyText(
item.branchId,
`savepoints[${String(index)}].branchId`,
),
state: parseHistoryState(item.state, size),
};
});
const unique = (values: readonly string[], label: string): void => {
if (new Set(values).size !== values.length) {
throw new TypeError(`${label} contains duplicate IDs.`);
}
};
unique(
moments.map(({ id }) => id),
"Gameplay moments",
);
unique(
branches.map(({ id }) => id),
"Gameplay branches",
);
unique(
savepoints.map(({ id }) => id),
"Gameplay savepoints",
);
const momentIds = new Set(moments.map(({ id }) => id));
const branchIds = new Set([MAIN_BRANCH_ID, ...branches.map(({ id }) => id)]);
const activeBranchId = historyText(raw.activeBranchId, "activeBranchId");
const currentMomentId = historyText(raw.currentMomentId, "currentMomentId");
if (!branchIds.has(activeBranchId) || !momentIds.has(currentMomentId)) {
throw new TypeError(
"Gameplay history points to a missing active branch or moment.",
);
}
if (
moments.some(({ branchId }) => !branchIds.has(branchId)) ||
branches.some(
({ parentBranchId, baseMomentId }) =>
!branchIds.has(parentBranchId) || !momentIds.has(baseMomentId),
) ||
savepoints.some(
({ momentId, branchId }) =>
!momentIds.has(momentId) || !branchIds.has(branchId),
)
) {
throw new TypeError("Gameplay history contains a dangling reference.");
}
return {
moments,
branches,
savepoints,
activeBranchId,
currentMomentId,
nextSequence: historyInteger(
raw.nextSequence,
"nextSequence",
1,
1_000_001,
),
};
}
function cloneState(state: GameplayState): GameplayState {
return {
+70
View File
@@ -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<Storage, "getItem"> | 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<Storage, "setItem"> | 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)}`;
}
+171 -5
View File
@@ -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<string, SudokuProjectRecord>();
readonly #memoryAutosaves = new Map<string, SudokuProjectRecord>();
#database: Promise<IDBDatabase> | undefined;
#mode: ProjectLibraryMode;
@@ -143,11 +184,14 @@ export class ProjectLibrary {
this.#memory.set(record.id, cloneProjectRecord(record));
}
async list(): Promise<readonly SudokuProjectSummary[]> {
async list(
query: ProjectLibraryQuery = {},
): Promise<readonly SudokuProjectSummary[]> {
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<SudokuProjectRecord> {
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<SudokuProjectRecord | undefined> {
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<void> {
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<ProjectLibraryExport> {
const summaries = await this.list();
const projects: SudokuProjectRecord[] = [];
@@ -279,6 +400,51 @@ export class ProjectLibrary {
};
}
async exportSelected(ids: readonly string[]): Promise<ProjectLibraryExport> {
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<number> {
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<number> {
if (
typeof value !== "object" ||
+88
View File
@@ -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<string, unknown> {
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,
}),
},
}),
};
+13
View File
@@ -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 {
+1272 -13
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -1 +1 @@
export const APPLICATION_VERSION = "0.1.0";
export const APPLICATION_VERSION = "0.2.0";
+15
View File
@@ -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 {
+6
View File
@@ -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":
+126
View File
@@ -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<void>((resolve, reject) => {
const request = indexedDB.deleteDatabase("sudoku-tools");
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
await new Promise<void>((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<number | undefined>((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);
}
});
+72
View File
@@ -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(
<BoardViewport>
<div>Board</div>
</BoardViewport>,
);
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(
<BoardViewport>
<div>Board</div>
</BoardViewport>,
);
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(
<BoardViewport>
<button type="button" onKeyDown={onCellKeyDown}>
Cell
</button>
</BoardViewport>,
);
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();
});
});
+474
View File
@@ -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 (
<>
<ConstraintEditor
puzzle={puzzle}
selection={selection}
onChange={setPuzzle}
onNewGrid={vi.fn()}
onCheck={vi.fn()}
onGenerate={vi.fn()}
busy={false}
/>
<output data-testid="puzzle-state">{JSON.stringify(puzzle)}</output>
</>
);
}
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(<EditorHarness selection={[5]} />);
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(<EditorHarness selection={[0, 1]} />);
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(<EditorHarness selection={[]} />);
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(<EditorHarness selection={[0, 1, 2]} />);
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(<EditorHarness selection={[0, 1, 4, 5]} />);
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(<EditorHarness selection={[0, 1, 2]} />);
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(
<EditorHarness selection={[0, 1, 2]} initial={createEmptyPuzzle(6)} />,
);
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(<EditorHarness selection={[0, 1, 2]} />);
expect(
screen.getByRole("button", { name: "Entropic line" }),
).toBeDisabled();
unmount();
render(
<EditorHarness selection={[0, 1, 2, 3]} initial={createEmptyPuzzle(6)} />,
);
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(<EditorHarness selection={[5]} />);
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(<EditorHarness selection={[0, 5]} />);
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(
<EditorHarness
selection={[0, 5]}
initial={{ ...createEmptyPuzzle(4), solution }}
/>,
);
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([]);
});
});
@@ -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(
<GeneratorWorkspace
busy={false}
onGenerate={vi.fn()}
onGenerateBatch={onGenerateBatch}
onRate={vi.fn()}
/>,
);
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(
<GeneratorWorkspace
busy={false}
batch={batch}
onGenerate={vi.fn()}
onSelectGenerated={onSelectGenerated}
onRate={vi.fn()}
/>,
);
expect(screen.getByText("1 of 1 generated")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Use" }));
expect(onSelectGenerated).toHaveBeenCalledWith(generation);
});
});
+181
View File
@@ -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> = {}): 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(<GuidedHint {...base} />);
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(<GuidedHint {...base} stage="technique" />);
expect(screen.getByText("Naked Pair")).toBeInTheDocument();
expect(screen.queryByText(step.explanation)).not.toBeInTheDocument();
expect(screen.queryByText("Place 4 in r2c1.")).not.toBeInTheDocument();
rerender(<GuidedHint {...base} stage="reasoning" />);
expect(screen.getByText(step.explanation)).toBeInTheDocument();
expect(screen.queryByText("Place 4 in r2c1.")).not.toBeInTheDocument();
rerender(<GuidedHint {...base} stage="preview" />);
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(
<GuidedHint {...props(callbacks)} stage="focus" />,
);
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(<GuidedHint {...props(callbacks)} stage="preview" />);
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(
<GuidedHint
{...props({
step: undefined,
onRequestHint,
onFillLegalCandidates,
onRemoveInvalidNotes,
onAutoMaintainPeerNotesChange,
})}
/>,
);
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);
});
});
+76 -4
View File
@@ -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<string, unknown>;
overlays?: unknown[];
};
expect(exported.metadata?.edition).toBe("nightly");
expect(exported.overlays).toEqual(
expect.arrayContaining([
expect.objectContaining({ text: "kept visual" }),
]),
);
});
+109
View File
@@ -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<string, unknown> = {}) {
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(<LibraryDialog {...props} />);
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",
]);
});
});
+30
View File
@@ -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(
<NumberPad
size={9}
mode="color"
onMode={vi.fn()}
onValue={onValue}
onErase={vi.fn()}
/>,
);
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);
});
});
+126
View File
@@ -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(
<svg viewBox="0 0 4 4">
<SafeVisualLayer size={4} visuals={visuals} layer="underlay" />
<SafeVisualLayer size={4} visuals={visuals} layer="overlay" />
</svg>,
);
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 =
'</text><script onload="alert(1)">x</script><image href="x">';
const { container } = render(
<svg viewBox="0 0 4 4">
<SafeVisualLayer
size={4}
layer="overlay"
visuals={[
{
type: "text",
layer: "overlay",
position: { kind: "cell", cell: 0 },
text: malicious,
style: { fill: "#000000" },
},
]}
/>
</svg>,
);
expect(container.querySelector(".source-visual--text")?.textContent).toBe(
malicious,
);
expect(container.querySelector("script, image")).toBeNull();
expect(
container.querySelector("[href], [src], [onclick], [onload]"),
).toBeNull();
});
});
+279
View File
@@ -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> = {},
): 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(<SetterQualityLab {...base} />);
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(<SetterQualityLab {...base} running="minimality" />);
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(
<SetterQualityLab
{...props({ result: multiple, onFocusCells, onFocusItem })}
/>,
);
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(<SetterQualityLab {...props({ result: unknown, onFocusItem })} />);
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);
});
});
+596 -1
View File
@@ -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<number>(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(
<SudokuBoard
puzzle={puzzle}
values={puzzle.givens}
visuals={[
{
type: "circle",
layer: "underlay",
center: { kind: "cell", cell: 0 },
radius: 0.3,
style: { fill: "#abcdef" },
},
{
type: "text",
layer: "overlay",
position: { kind: "cell", cell: 15 },
text: "overlay",
style: { fill: "#123456" },
},
]}
selected={new Set()}
activeCell={0}
onCellPointerDown={vi.fn()}
onCellPointerEnter={vi.fn()}
onKeyDown={vi.fn()}
/>,
);
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<number>(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(
<SudokuBoard
puzzle={puzzle}
values={puzzle.givens}
selected={new Set()}
activeCell={0}
onCellPointerDown={vi.fn()}
onCellPointerEnter={vi.fn()}
onKeyDown={vi.fn()}
/>,
);
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<number>(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(
<SudokuBoard
puzzle={puzzle}
values={puzzle.givens}
selected={new Set()}
activeCell={0}
onCellPointerDown={vi.fn()}
onCellPointerEnter={vi.fn()}
onKeyDown={vi.fn()}
/>,
);
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<number>(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(
<SudokuBoard
puzzle={puzzle}
values={puzzle.givens}
selected={new Set()}
activeCell={0}
onCellPointerDown={vi.fn()}
onCellPointerEnter={vi.fn()}
onKeyDown={vi.fn()}
/>,
);
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<number>(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<number>(16).fill(0);
wrongValues[5] = 2;
wrongValues[10] = 3;
const { container, rerender } = render(
<SudokuBoard
puzzle={puzzle}
values={wrongValues}
candidates={new Array<number>(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<number>(16).fill(0);
correctValues[5] = 4;
rerender(
<SudokuBoard
puzzle={puzzle}
values={correctValues}
selected={new Set()}
activeCell={1}
onCellPointerDown={pointerDown}
onCellPointerEnter={vi.fn()}
onKeyDown={keyDown}
/>,
);
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<number>(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<number>(16).fill(0),
});
const { container } = render(
<SudokuBoard
puzzle={puzzle}
values={puzzle.givens}
centerMarks={[0, 15, 15, ...new Array<number>(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<number>(16).fill(0),
});
const baseProps = {
puzzle,
values: puzzle.givens,
cornerMarks: [3, ...new Array<number>(15).fill(0)],
centerMarks: [12, ...new Array<number>(15).fill(0)],
selected: new Set([0]),
activeCell: 0,
onCellPointerDown: vi.fn(),
onCellPointerEnter: vi.fn(),
onKeyDown: vi.fn(),
} as const;
const { rerender } = render(
<SudokuBoard {...baseProps} candidateVerbosity="detailed" />,
);
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(<SudokuBoard {...baseProps} candidateVerbosity="concise" />);
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(<SudokuBoard {...baseProps} candidateVerbosity="off" />);
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<number>(16).fill(0),
});
const { container } = render(
<SudokuBoard
puzzle={puzzle}
values={puzzle.givens}
colors={[1, ...new Array<number>(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"),
);
});
});
+188 -2
View File
@@ -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(<Workbench />);
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(<Workbench />);
@@ -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(<Workbench />);
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(<Workbench />);
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<number>(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(<Workbench />);
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(<Workbench />);
@@ -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(<Workbench />);
const imported = {
schema: SUDOKU_DOCUMENT_SCHEMA,
version: 1,
size: 4,
givens: Array<number>(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);
});
});
+123
View File
@@ -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]);
});
});
+273
View File
@@ -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> = {}): PuzzleDefinition {
return {
version: 1,
size: 4,
givens: new Array<number>(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<number>(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<number>(16).fill(0),
0,
),
).toEqual([1, 3]);
expect(
candidatesForCell(
puzzle4({ constraints: [{ type: "even", cell: 0 }] }),
new Array<number>(16).fill(0),
0,
),
).toEqual([2, 4]);
const impossibleMinimum = new Array<number>(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<number>(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<number>(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<number>(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<number>(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);
});
});
+222
View File
@@ -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> = {}): PuzzleDefinition {
return {
version: 1,
size: 4,
givens: new Array<number>(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<number>(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<number>(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<number>(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<number>(16).fill(0);
partial[0] = 2;
expect(
candidatesForCell(puzzle4({ constraints: [constraint] }), partial, 5),
).toEqual([4]);
const impossible = new Array<number>(16).fill(0);
impossible[5] = 2;
expect(
constraintIsFeasible(
{ ...constraint, minimumDifference: 3 },
impossible,
4,
),
).toBe(false);
const complete = [1, 4, 2, ...new Array<number>(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<number>(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<number>(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<number>(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);
});
});
+355
View File
@@ -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> = {},
): PuzzleDefinition {
return {
version: 1,
size,
givens: new Array<number>(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<number>(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<number>(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<number>(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<number>(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<number>(33).fill(0)];
const invalid = [1, 2, 5, ...new Array<number>(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<number>(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<number>(13).fill(0)];
const invalid = [1, 3, 3, ...new Array<number>(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<number>(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<number>(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<number>(13).fill(0)];
const invalid = [1, 4, 2, ...new Array<number>(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<number>(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<number>(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<number>(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<number>(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);
});
});
+4 -3
View File
@@ -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", () => {
+59 -2
View File
@@ -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<number>(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,
+27 -17
View File
@@ -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", () => {
+360
View File
@@ -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<string, unknown>[][] {
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<string, unknown>;
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<string, unknown>;
};
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 =
'</text><script>alert(1)</script><image href="https://evil.invalid/x">';
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("<script>");
expect(svg).not.toContain("<image");
});
});
+311
View File
@@ -0,0 +1,311 @@
import { describe, expect, it } from "vitest";
import {
classicRegions,
compilePuzzle,
normalizePuzzle,
} from "../../src/domain";
import {
findAic,
findAdvancedLogicalStep,
findFinnedFish,
findJellyfish,
findSimpleColouring,
findSkyscraper,
findTwoStringKite,
findUniqueRectangle,
findWWing,
findXChain,
findXYChain,
solveLogically,
type AdvancedLogicalContext,
} from "../../src/solver";
const size = 9;
const empty = normalizePuzzle({
version: 1,
size,
givens: new Array<number>(size * size).fill(0),
regions: classicRegions(size),
constraints: [],
});
const compiled = compilePuzzle(empty);
function cell(row: number, column: number): number {
return row * size + column;
}
function mask(values: readonly number[]): number {
return values.reduce((result, value) => result | (1 << value), 0);
}
function context(
candidates: ReadonlyArray<readonly [number, readonly number[]]>,
options: Pick<
AdvancedLogicalContext,
"uniquenessProven" | "uniquenessPatternsSafe"
> = {},
): AdvancedLogicalContext {
const masks = new Array<number>(size * size).fill(0);
for (const [candidateCell, values] of candidates) {
masks[candidateCell] = mask(values);
}
return {
size,
values: new Array<number>(size * size).fill(0),
masks,
regions: empty.regions,
peers: compiled.peers,
units: compiled.units,
...options,
};
}
function expectElimination(
result: ReturnType<typeof findJellyfish>,
technique: string,
target: number,
value: number,
): void {
expect(result?.technique).toBe(technique);
expect(result?.placements).toEqual([]);
expect(result?.eliminations).toContainEqual({
cell: target,
values: [value],
});
expect(result?.explanation.length).toBeGreaterThan(12);
}
describe("advanced logical techniques", () => {
it("finds a row-based Jellyfish", () => {
const target = cell(4, 0);
const result = findJellyfish(
context([
[cell(0, 0), [5]],
[cell(0, 1), [5]],
[cell(1, 1), [5]],
[cell(1, 2), [5]],
[cell(2, 2), [5]],
[cell(2, 3), [5]],
[cell(3, 0), [5]],
[cell(3, 3), [5]],
[target, [5, 8]],
]),
);
expectElimination(result, "jellyfish", target, 5);
expect(
findAdvancedLogicalStep(
context([
[cell(0, 0), [5]],
[cell(0, 1), [5]],
[cell(1, 1), [5]],
[cell(1, 2), [5]],
[cell(2, 2), [5]],
[cell(2, 3), [5]],
[cell(3, 0), [5]],
[cell(3, 3), [5]],
[target, [5, 8]],
]),
),
).toEqual(result);
});
it("finds Finned X-Wings and Finned Swordfish", () => {
const xWingTarget = cell(2, 0);
const xWing = findFinnedFish(
context([
[cell(0, 0), [5]],
[cell(0, 1), [5]],
[cell(0, 2), [5]],
[cell(1, 0), [5]],
[cell(1, 1), [5]],
[xWingTarget, [5, 8]],
]),
);
expectElimination(xWing, "finned-x-wing", xWingTarget, 5);
const swordfishTarget = cell(1, 0);
const swordfish = findFinnedFish(
context([
[cell(0, 0), [6]],
[cell(0, 1), [6]],
[cell(0, 2), [6]],
[cell(3, 3), [6]],
[cell(3, 6), [6]],
[cell(4, 3), [6]],
[cell(4, 6), [6]],
[swordfishTarget, [6, 8]],
]),
);
expectElimination(swordfish, "finned-swordfish", swordfishTarget, 6);
});
it("finds a Skyscraper", () => {
const target = cell(2, 5);
const result = findSkyscraper(
context([
[cell(0, 0), [7]],
[cell(0, 3), [7]],
[cell(1, 0), [7]],
[cell(1, 4), [7]],
[target, [2, 7]],
]),
);
expectElimination(result, "skyscraper", target, 7);
});
it("finds a Two-String Kite", () => {
const target = cell(7, 6);
const result = findTwoStringKite(
context([
[cell(0, 0), [6]],
[cell(0, 6), [6]],
[cell(1, 1), [6]],
[cell(7, 1), [6]],
[target, [3, 6]],
]),
);
expectElimination(result, "two-string-kite", target, 6);
});
it("applies a simple-colouring colour trap", () => {
const target = cell(1, 1);
const result = findSimpleColouring(
context([
[cell(0, 0), [4]],
[cell(0, 4), [4]],
[cell(4, 4), [4]],
[cell(4, 1), [4]],
[target, [4, 8]],
[cell(2, 2), [4, 7]],
[cell(7, 1), [4, 9]],
]),
);
expectElimination(result, "simple-colouring", target, 4);
});
it("finds a W-Wing", () => {
const target = cell(0, 4);
const result = findWWing(
context([
[cell(0, 0), [1, 2]],
[cell(4, 4), [1, 2]],
[cell(0, 3), [1, 3, 4]],
[cell(4, 3), [1, 3, 4]],
[target, [2, 4]],
]),
);
expectElimination(result, "w-wing", target, 2);
});
it("finds an X-Chain", () => {
const target = cell(1, 1);
const result = findXChain(
context([
[cell(0, 0), [5]],
[cell(0, 4), [5]],
[cell(3, 4), [5]],
[cell(3, 1), [5]],
[target, [5, 8]],
[cell(2, 2), [5, 7]],
[cell(7, 1), [5, 9]],
]),
);
expectElimination(result, "x-chain", target, 5);
});
it("finds an XY-Chain", () => {
const target = cell(4, 0);
const result = findXYChain(
context([
[cell(0, 0), [1, 2]],
[cell(0, 4), [2, 3]],
[cell(4, 4), [1, 3]],
[target, [1, 4, 5]],
]),
);
expectElimination(result, "xy-chain", target, 1);
});
it("finds a mixed Alternating Inference Chain", () => {
const target = cell(1, 1);
const result = findAic(
context([
[cell(0, 0), [1, 2]],
[cell(0, 4), [2, 4, 5]],
[cell(4, 4), [2, 3, 4]],
[cell(4, 8), [1, 3, 4]],
[cell(4, 1), [1, 5, 6]],
[target, [1, 7, 8]],
]),
);
expectElimination(result, "aic", target, 1);
});
it("never uses a Unique Rectangle without both safety gates", () => {
const roof = cell(1, 3);
const candidates = [
[cell(0, 0), [1, 2]],
[cell(0, 3), [1, 2]],
[cell(1, 0), [1, 2]],
[roof, [1, 2, 3]],
] as const;
expect(findUniqueRectangle(context(candidates))).toBeUndefined();
expect(
findUniqueRectangle(context(candidates, { uniquenessProven: true })),
).toBeUndefined();
expect(
findUniqueRectangle(
context(candidates, {
uniquenessProven: true,
uniquenessPatternsSafe: true,
}),
),
).toMatchObject({
technique: "unique-rectangle",
eliminations: [{ cell: roof, values: [1, 2] }],
});
});
it("integrates the explicit uniqueness proof gate into solveLogically", () => {
const digits = Array.from({ length: size }, (_unused, index) => index + 1);
const candidates = Array.from({ length: size * size }, () => [...digits]);
const withoutPair = digits.filter((value) => value !== 1 && value !== 2);
const first = cell(0, 0);
const second = cell(0, 3);
const third = cell(1, 0);
const roof = cell(1, 3);
for (let current = 0; current < size * size; current += 1) {
const row = Math.floor(current / size);
const column = current % size;
if (row === 0 || column === 0 || empty.regions[current] === 0) {
candidates[current] = [...withoutPair];
}
}
candidates[first] = [1, 2];
candidates[second] = [1, 2];
candidates[third] = [1, 2];
candidates[roof] = [1, 2, 3];
const ordinary = solveLogically(empty, { candidates, maxSteps: 1 });
expect(ordinary.steps[0]?.technique).not.toBe("unique-rectangle");
const provenUnique = solveLogically(empty, {
candidates,
maxSteps: 1,
uniquenessProven: true,
});
expect(provenUnique.steps[0]).toMatchObject({
technique: "unique-rectangle",
eliminations: [{ cell: roof, values: [1, 2] }],
});
});
});
+297
View File
@@ -0,0 +1,297 @@
import { describe, expect, it } from "vitest";
import { classicRegions, type PuzzleDefinition } from "../../src/domain";
import {
clueOrbit,
evaluateTechniqueProfile,
generateVariant,
generateVariantBatch,
minimizePuzzleWithReport,
solveExact,
type ClueSymmetry,
type DifficultyAssessment,
type PracticeTechnique,
} from "../../src/solver";
const solution4 = [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3] as const;
const complete4: PuzzleDefinition = {
version: 1,
size: 4,
givens: solution4,
solution: solution4,
regions: classicRegions(4),
constraints: [],
};
describe("Generator 2.0 clue removal", () => {
it("builds all supported clue-symmetry orbits", () => {
expect(clueOrbit(4, 1, "none")).toEqual([1]);
expect(clueOrbit(4, 1, "rotational")).toEqual([1, 14]);
expect(clueOrbit(4, 1, "horizontal")).toEqual([1, 13]);
expect(clueOrbit(4, 1, "vertical")).toEqual([1, 2]);
expect(clueOrbit(4, 1, "diagonal-main")).toEqual([1, 4]);
expect(clueOrbit(4, 1, "diagonal-anti")).toEqual([1, 11]);
expect(clueOrbit(4, 1, "orthogonal")).toEqual([1, 7, 8, 14]);
expect(clueOrbit(4, 1, "four-way")).toEqual(clueOrbit(4, 1, "orthogonal"));
});
it.each([
"rotational",
"horizontal",
"vertical",
"diagonal-main",
"diagonal-anti",
"orthogonal",
] as const satisfies readonly ClueSymmetry[])(
"preserves %s symmetry during grouped removal",
(symmetry) => {
const result = minimizePuzzleWithReport(complete4, {
seed: `symmetry-${symmetry}`,
symmetry,
targetClues: 8,
maxChecks: 32,
});
for (let cell = 0; cell < 16; cell += 1) {
const present = (result.puzzle.givens[cell] ?? 0) !== 0;
expect(
clueOrbit(4, cell, symmetry).every(
(other) => ((result.puzzle.givens[other] ?? 0) !== 0) === present,
),
).toBe(true);
}
expect(result.minimality.symmetryPreserved).toBe(true);
},
);
it("proves individual-given minimality when every deletion check completes", () => {
const result = minimizePuzzleWithReport(complete4, {
seed: "minimal-proof",
symmetry: "rotational",
targetClues: 8,
minimalGivens: true,
maxChecks: 64,
solveTimeoutMs: 2_000,
});
expect(result.minimality.status).toBe("proven-minimal");
expect(result.minimality.unknownCells).toEqual([]);
for (let cell = 0; cell < result.puzzle.givens.length; cell += 1) {
if (result.puzzle.givens[cell] === 0) continue;
const givens = [...result.puzzle.givens];
givens[cell] = 0;
const exact = solveExact(
{ ...result.puzzle, givens },
{ maxSolutions: 2 },
);
expect(exact.count).toBe(2);
}
});
it("reports unknown instead of minimal after a tiny shared check cap", () => {
const result = minimizePuzzleWithReport(complete4, {
seed: "minimal-bounded",
targetClues: 16,
minimalGivens: true,
maxChecks: 1,
});
expect(result.minimality.status).toBe("unknown");
expect(result.minimality.checksPerformed).toBe(1);
expect(result.minimality.unknownCells.length).toBeGreaterThan(0);
expect(result.minimality.limitReasons).toContain("check-cap");
});
it("treats a truncated deletion search as unknown evidence", () => {
const result = minimizePuzzleWithReport(complete4, {
seed: "minimal-node-bound",
targetClues: 16,
minimalGivens: true,
maxChecks: 32,
solveMaxNodes: 1,
});
expect(result.minimality.status).toBe("unknown");
expect(result.minimality.limitReasons).toContain("node-cap");
expect(result.minimality.unknownCells.length).toBeGreaterThan(0);
});
it("never claims minimality before proving the input is unique", () => {
const ambiguous: PuzzleDefinition = {
version: 1,
size: 4,
givens: new Array<number>(16).fill(0),
regions: classicRegions(4),
constraints: [],
};
const result = minimizePuzzleWithReport(ambiguous, {
minimalGivens: true,
targetClues: 0,
maxChecks: 32,
});
expect(result.minimality.status).toBe("unknown");
expect(result.minimality.checksPerformed).toBe(1);
expect(result.minimality.limitReasons).toContain("not-unique");
expect(result.puzzle.givens).toEqual(ambiguous.givens);
});
});
describe("Generator 2.0 variants and profiles", () => {
it("combines multiple compatible variant families deterministically", () => {
const options = {
variants: ["kropki", "thermo"] as const,
size: 4,
seed: "mixed-local",
targetClues: 16,
maxChecks: 1,
constraintCount: 3,
};
const generated = generateVariant(options);
expect(generated.variant).toBe("mixed");
expect(generated.families).toEqual(["thermo", "kropki"]);
expect(
generated.puzzle.constraints.some(({ type }) => type === "thermo"),
).toBe(true);
expect(
generated.puzzle.constraints.some(({ type }) => type === "kropki"),
).toBe(true);
expect(generateVariant(options)).toEqual(generated);
});
it("scales local marking counts with explicit density", () => {
const common = {
variant: "inequality" as const,
size: 4,
seed: "density",
targetClues: 16,
maxChecks: 1,
constraintCount: 6,
};
const sparse = generateVariant({ ...common, constraintDensity: "sparse" });
const dense = generateVariant({ ...common, constraintDensity: "dense" });
expect(sparse.generatedConstraintCount).toBeLessThan(
dense.generatedConstraintCount,
);
expect(sparse.constraintDensity).toBe("sparse");
expect(dense.constraintDensity).toBe("dense");
const sparseKiller = generateVariant({
variant: "killer",
size: 4,
seed: "killer-density",
targetClues: 16,
maxChecks: 1,
constraintDensity: "sparse",
});
const denseKiller = generateVariant({
variant: "killer",
size: 4,
seed: "killer-density",
targetClues: 16,
maxChecks: 1,
constraintDensity: "dense",
});
expect(sparseKiller.generatedConstraintCount).toBeLessThanOrEqual(
denseKiller.generatedConstraintCount,
);
});
it("evaluates required, forbidden, count and hardest-technique evidence", () => {
const assessment: DifficultyAssessment = {
score: 52,
level: "medium",
label: "Medium",
uniqueness: "unique",
clueCount: 30,
emptyCount: 51,
logicalStatus: "solved",
logicalSteps: 7,
hardestTechnique: "x-wing",
techniqueCounts: {
"naked-single": 4,
"hidden-single": 2,
"x-wing": 1,
},
exactNodes: 100,
exactTruncated: false,
summary: "fixture",
};
const matched = evaluateTechniqueProfile(assessment, {
required: ["x-wing"],
forbidden: ["swordfish"],
counts: [{ technique: "naked-single", min: 3, max: 5 }],
hardestTechnique: "x-wing",
});
expect(matched.status).toBe("matched");
expect(matched.requirements.every(({ matched: ok }) => ok)).toBe(true);
expect(
evaluateTechniqueProfile(assessment, {
counts: [{ technique: "hidden-single", max: 1 }],
}).status,
).toBe("not-matched");
expect(
evaluateTechniqueProfile(
{ ...assessment, logicalStatus: "stuck" },
{ forbidden: ["aic"] },
),
).toMatchObject({ status: "not-matched", completePath: false });
});
it("accepts a generation profile only after its independent path matches", () => {
const options = {
variant: "classic" as const,
size: 4,
seed: "profile-path",
targetClues: 10,
maxChecks: 12,
};
const baseline = generateVariant(options);
expect(baseline.difficulty.logicalStatus).toBe("solved");
expect(baseline.difficulty.hardestTechnique).toBeDefined();
const hardest = baseline.difficulty.hardestTechnique as PracticeTechnique;
const profiled = generateVariant({
...options,
techniqueProfile: {
required: [hardest],
forbidden: ["aic"],
counts: [{ technique: hardest, min: 1, max: 16 }],
hardestTechnique: hardest,
},
maxTechniqueAttempts: 1,
});
expect(profiled.techniqueProfile).toMatchObject({
status: "matched",
completePath: true,
requestedHardestTechnique: hardest,
actualHardestTechnique: hardest,
hardestMatched: true,
});
});
it("returns a deterministic ranked batch with concise evidence summaries", () => {
const options = {
variant: "classic" as const,
size: 4,
seed: "batch",
targetClues: 10,
maxChecks: 12,
batchSize: 3,
ranking: "fewest-givens" as const,
};
const batch = generateVariantBatch(options);
expect(batch.completed).toBe(3);
expect(batch.truncated).toBe(false);
expect(batch.summaries.map(({ rank }) => rank)).toEqual([1, 2, 3]);
expect(batch.summaries.map(({ clueCount }) => clueCount)).toEqual(
[...batch.summaries.map(({ clueCount }) => clueCount)].sort(
(a, b) => a - b,
),
);
expect(generateVariantBatch(options)).toEqual(batch);
});
});
+110 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { classicRegions, type PuzzleDefinition } from "../../src/domain";
import {
PuzzleValidationError,
classicRegions,
type PuzzleDefinition,
} from "../../src/domain";
import { killerDigitCombinations, solveLogically } from "../../src/solver";
const easy: PuzzleDefinition = {
@@ -15,6 +19,14 @@ const easy: PuzzleDefinition = {
constraints: [],
};
const emptyFour: PuzzleDefinition = {
version: 1,
size: 4,
givens: Array<number>(16).fill(0),
regions: classicRegions(4),
constraints: [],
};
describe("logical solver", () => {
it("solves a standard puzzle and emits inspectable steps", () => {
const result = solveLogically(easy);
@@ -60,6 +72,103 @@ describe("logical solver", () => {
expect(result.candidates[0]).toEqual([1, 2, 3, 4]);
});
it("resumes after an elimination-only step from exposed candidates", () => {
const candidates = Array.from({ length: 16 }, () => [1, 2, 3, 4]);
candidates[0] = [1, 2];
candidates[1] = [1, 2];
candidates[4] = [1, 3];
candidates[5] = [1, 3];
const first = solveLogically(emptyFour, {
candidates,
maxSteps: 1,
});
expect(first.steps[0]).toMatchObject({
technique: "naked-pair",
focusCells: [0, 1],
placements: [],
});
expect(first.candidates[2]).toEqual([3, 4]);
expect(first.candidates[3]).toEqual([3, 4]);
const next = solveLogically(emptyFour, {
candidates: first.candidates,
maxSteps: 1,
});
expect(next.steps[0]?.placements).toEqual([]);
expect(next.steps[0]?.eliminations.length).toBeGreaterThan(0);
expect(next.steps[0]?.eliminations).not.toEqual(
first.steps[0]?.eliminations,
);
// The first elimination remains applied while the solver advances to a
// different logical effect. This is the key resume contract for guided
// hints that do not place a digit.
expect(next.candidates[2]).toEqual([3, 4]);
expect(next.candidates[3]).toEqual([3, 4]);
});
it("intersects supplied restrictions with candidates legal on the board", () => {
const candidates = Array.from({ length: 81 }, () =>
Array.from({ length: 9 }, (_, index) => index + 1),
);
const result = solveLogically(easy, { candidates, maxSteps: 1 });
expect(result.candidates[2]).not.toContain(3);
expect(result.candidates[2]).not.toContain(5);
expect(result.candidates[2]).not.toContain(6);
expect(result.candidates[2]).not.toContain(7);
expect(result.candidates[2]).not.toContain(8);
expect(result.candidates[2]).not.toContain(9);
});
it("reports a valid empty restriction as an invalid logical state", () => {
const candidates = Array.from({ length: 16 }, () => [1, 2, 3, 4]);
candidates[0] = [];
const result = solveLogically(emptyFour, { candidates });
expect(result.status).toBe("invalid");
expect(result.steps).toEqual([]);
expect(result.candidates[0]).toEqual([]);
});
it("rejects malformed candidate restrictions with precise issues", () => {
expect(() =>
solveLogically(emptyFour, {
candidates: Array.from({ length: 15 }, () => [1, 2, 3, 4]),
}),
).toThrow(PuzzleValidationError);
try {
solveLogically(emptyFour, {
candidates: [
[1, 1],
[0, 5],
...Array.from({ length: 14 }, () => [1, 2, 3, 4]),
],
});
throw new Error("Expected malformed candidates to be rejected");
} catch (error) {
expect(error).toBeInstanceOf(PuzzleValidationError);
expect((error as PuzzleValidationError).issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "candidates[0][1]" }),
expect.objectContaining({ path: "candidates[1][0]" }),
expect.objectContaining({ path: "candidates[1][1]" }),
]),
);
}
expect(() =>
solveLogically(emptyFour, {
candidates: [
[1, 2, 3, 4],
null,
...Array.from({ length: 14 }, () => [1, 2, 3, 4]),
] as unknown as readonly (readonly number[])[],
}),
).toThrow(PuzzleValidationError);
});
it("returns bounded killer combinations", () => {
expect(
killerDigitCombinations({ size: 9, count: 2, sum: 10 }).combinations,
+267
View File
@@ -0,0 +1,267 @@
import { describe, expect, it } from "vitest";
import { classicRegions, type PuzzleDefinition } from "../../src/domain";
import { analyzePuzzleQuality, qualityItemCells } from "../../src/solver";
const solution4 = [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3] as const;
const unique4: PuzzleDefinition = {
version: 1,
size: 4,
givens: [1, 0, 0, 4, 0, 4, 1, 0, 4, 0, 2, 0, 0, 1, 0, 3],
regions: classicRegions(4),
constraints: [],
};
describe("setter quality analysis", () => {
it("audits a unique puzzle without mutating it", () => {
const before = JSON.stringify(unique4);
const result = analyzePuzzleQuality(unique4, { proveMinimality: true });
expect(result.solutionStatus).toBe("unique");
expect(result.solution).toEqual(solution4);
expect(result.ambiguityWitness).toBeUndefined();
expect(result.contradiction.status).toBe("not-applicable");
expect(result.redundancy.givens).toHaveLength(
unique4.givens.filter(Boolean).length,
);
expect(
result.redundancy.givens.every(
({ classification }) => classification !== "unknown",
),
).toBe(true);
expect(
result.redundancy.givens.every(({ classification }) =>
["critical", "redundant"].includes(classification),
),
).toBe(true);
expect(result.criticalityHeatmap).toHaveLength(16);
expect(result.budget.checksPerformed).toBe(
1 + unique4.givens.filter(Boolean).length,
);
expect(result.budget.truncated).toBe(false);
expect(result.minimality?.status).toMatch(
/^(proven-minimal|not-minimal)$/u,
);
expect(JSON.stringify(unique4)).toBe(before);
});
it("returns two concrete solutions and their differing cells for ambiguity", () => {
const ambiguous: PuzzleDefinition = {
...unique4,
givens: [1, ...new Array<number>(15).fill(0)],
};
const result = analyzePuzzleQuality(ambiguous);
expect(result.solutionStatus).toBe("multiple");
expect(result.ambiguityWitness?.firstSolution).toHaveLength(16);
expect(result.ambiguityWitness?.secondSolution).toHaveLength(16);
expect(result.ambiguityWitness?.differences.length).toBeGreaterThan(0);
for (const difference of result.ambiguityWitness?.differences ?? []) {
expect(difference.first).not.toBe(difference.second);
}
expect(result.redundancy.givens).toEqual([
{
item: { kind: "given", cell: 0, value: 1 },
classification: "unknown",
unknownReason: "baseline-not-unique",
},
]);
});
it("localizes direct contradictory givens and reports each as critical", () => {
const contradictory: PuzzleDefinition = {
...unique4,
givens: [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
};
const before = JSON.stringify(contradictory);
const result = analyzePuzzleQuality(contradictory);
expect(result.solutionStatus).toBe("unsatisfiable");
expect(
result.redundancy.givens.map((entry) => entry.classification),
).toEqual(["critical", "critical"]);
expect(result.contradiction.status).toBe("localized");
expect(result.contradiction.core).toEqual([
{ kind: "given", cell: 0, value: 1 },
{ kind: "given", cell: 1, value: 1 },
]);
expect(result.contradiction.necessary).toEqual(result.contradiction.core);
expect(result.contradiction.removable).toEqual([]);
expect(JSON.stringify(contradictory)).toBe(before);
});
it("finds a minimal contradictory core made from overlapping constraints", () => {
const contradictory: PuzzleDefinition = {
...unique4,
givens: [0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
constraints: [
{ type: "killer-cage", cells: [0], sum: 1 },
{ type: "killer-cage", cells: [0], sum: 2 },
],
};
const result = analyzePuzzleQuality(contradictory);
expect(result.solutionStatus).toBe("unsatisfiable");
expect(
result.redundancy.constraints.map(({ classification }) => classification),
).toEqual(["critical", "critical"]);
expect(result.redundancy.givens[0]?.classification).toBe("redundant");
expect(result.contradiction.status).toBe("localized");
expect(result.contradiction.core).toEqual([
{ kind: "constraint", index: 0, constraintType: "killer-cage" },
{ kind: "constraint", index: 1, constraintType: "killer-cage" },
]);
expect(result.contradiction.removable).toEqual([
{ kind: "given", cell: 5, value: 4 },
]);
expect(result.contradiction.unknown).toEqual([]);
});
it("identifies redundant givens and constraints and derives non-minimality", () => {
const overSpecified: PuzzleDefinition = {
...unique4,
givens: solution4,
solution: solution4,
constraints: [
{ type: "diagonal", direction: "main" },
{ type: "diagonal", direction: "main" },
],
};
const result = analyzePuzzleQuality(overSpecified, {
proveMinimality: true,
});
expect(result.solutionStatus).toBe("unique");
expect(
result.redundancy.givens.every(
({ classification }) => classification === "redundant",
),
).toBe(true);
expect(
result.redundancy.constraints.every(
({ classification }) => classification === "redundant",
),
).toBe(true);
expect(result.minimality?.status).toBe("not-minimal");
expect(result.minimality?.redundant).toHaveLength(18);
expect(result.criticalityHeatmap[0]).toMatchObject({
score: 0,
criticalWeight: 0,
redundantWeight: 3,
unknownWeight: 0,
});
});
it("never turns per-check truncation into a uniqueness proof", () => {
const result = analyzePuzzleQuality(unique4, {
perCheckMaxNodes: 1,
aggregateMaxNodes: 100,
aggregateMaxChecks: 100,
proveMinimality: true,
});
expect(result.solutionStatus).toBe("unknown");
expect(result.checks).toHaveLength(1);
expect(result.checks[0]).toMatchObject({
solutionStatus: "unknown",
conclusive: false,
truncated: true,
limitReason: "node-cap",
unknownReason: "per-check-node-cap",
});
expect(
result.redundancy.givens.every(
({ classification }) => classification === "unknown",
),
).toBe(true);
expect(result.minimality?.status).toBe("not-applicable");
expect(result.budget.unknownReasons).toContain("per-check-node-cap");
expect(result.budget.unknownReasons).toContain("baseline-unknown");
});
it("shares an aggregate check budget across all removal checks", () => {
const result = analyzePuzzleQuality(
{ ...unique4, givens: solution4, solution: solution4 },
{
aggregateMaxChecks: 1,
aggregateMaxNodes: 1_000,
proveMinimality: true,
},
);
expect(result.solutionStatus).toBe("unique");
expect(result.budget.checksPerformed).toBe(1);
expect(result.budget.checksPlanned).toBe(17);
expect(result.budget.truncated).toBe(true);
expect(result.budget.unknownReasons).toContain("aggregate-check-cap");
expect(
result.redundancy.givens.every(
({ classification, unknownReason }) =>
classification === "unknown" &&
unknownReason === "aggregate-check-cap",
),
).toBe(true);
expect(result.minimality?.status).toBe("unknown");
});
it("leaves contradiction-core items unknown when the shared budget ends", () => {
const contradictory: PuzzleDefinition = {
...unique4,
givens: [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
};
const result = analyzePuzzleQuality(contradictory, {
aggregateMaxChecks: 1,
});
expect(result.solutionStatus).toBe("unsatisfiable");
expect(result.contradiction.status).toBe("incomplete");
expect(result.contradiction.necessary).toEqual([]);
expect(result.contradiction.unknown).toEqual(result.contradiction.core);
expect(result.budget.checksPerformed).toBe(1);
expect(result.budget.unknownReasons).toContain("aggregate-check-cap");
});
it("supports a cheap baseline-only depth", () => {
const result = analyzePuzzleQuality(unique4, {
analysisDepth: "baseline",
proveMinimality: true,
});
expect(result.analysisDepth).toBe("baseline");
expect(result.solutionStatus).toBe("unique");
expect(result.redundancy).toEqual({ givens: [], constraints: [] });
expect(result.criticalityHeatmap).toEqual([]);
expect(result.contradiction.status).toBe("not-applicable");
expect(result.minimality?.status).toBe("not-applicable");
expect(result.budget.checksPlanned).toBe(1);
expect(result.budget.checksPerformed).toBe(1);
});
it("maps given, local and outside findings to their board cells", () => {
const puzzle: PuzzleDefinition = {
...unique4,
constraints: [
{ type: "killer-cage", cells: [4, 5], sum: 7 },
{ type: "x-sum", side: "top", index: 2, sum: 6 },
],
};
expect(
qualityItemCells(puzzle, { kind: "given", cell: 0, value: 1 }),
).toEqual([0]);
expect(
qualityItemCells(puzzle, {
kind: "constraint",
index: 0,
constraintType: "killer-cage",
}),
).toEqual([4, 5]);
expect(
qualityItemCells(puzzle, {
kind: "constraint",
index: 1,
constraintType: "x-sum",
}),
).toEqual([2, 6, 10, 14]);
});
});
+1 -1
View File
@@ -143,7 +143,7 @@ describe("aid-mémoire state", () => {
state = enterAidMemoireCell(state, 0, "center", 7, 9);
state = enterAidMemoireCell(state, 0, "color", 3, 9);
expect(aidMemoireCellDescription(state.cells[0]!, 0, 9)).toBe(
"Aid-mémoire cell 1, label Odd candidates, empty, corner marks 1, centre marks 7, colour 3",
"Aid-mémoire cell 1, label Odd candidates, empty, corner marks 1, centre marks 7, colour 3: yellow, dots",
);
});
});
+145
View File
@@ -0,0 +1,145 @@
import { describe, expect, it } from "vitest";
import { classicRegions, type PuzzleDefinition } from "../../src/domain";
import type { LogicalStep } from "../../src/solver";
import {
applyLogicalStepToSession,
autoRemoveNotesAfterPlacements,
fillLegalCenterCandidates,
pruneInvalidNotes,
} from "../../src/state/candidateMaintenance";
import { createSession, maskValues } from "../../src/state/session";
function puzzle4(): PuzzleDefinition {
return {
version: 1,
size: 4,
givens: new Array<number>(16).fill(0),
regions: classicRegions(4),
constraints: [],
};
}
describe("candidate maintenance", () => {
it("fills legal center candidates without changing corner notes or metadata", () => {
const session = createSession(new Array<number>(16).fill(0));
session.values[0] = 1;
session.cornerMarks[1] = 0b1111;
session.centerMarks[10] = 0b0010;
session.colors[3] = 4;
session.elapsedSeconds = 73;
session.paused = true;
const result = fillLegalCenterCandidates(session, puzzle4());
expect(result).not.toBe(session);
expect(result.centerMarks).not.toBe(session.centerMarks);
expect(result.centerMarks[0]).toBe(0);
expect(maskValues(result.centerMarks[1]!, 4)).toEqual([2, 3, 4]);
expect(maskValues(result.centerMarks[10]!, 4)).toEqual([1, 2, 3, 4]);
expect(result.cornerMarks[1]).toBe(0b1111);
expect(result.colors[3]).toBe(4);
expect(result.elapsedSeconds).toBe(73);
expect(result.paused).toBe(true);
expect(session.centerMarks[10]).toBe(0b0010);
});
it("only removes illegal center and corner notes", () => {
const session = createSession(new Array<number>(16).fill(0));
session.values[0] = 1;
session.centerMarks.fill(0b1111);
session.cornerMarks.fill(0b1111 | (1 << 8));
const result = pruneInvalidNotes(session, puzzle4());
expect(result.centerMarks[0]).toBe(0);
expect(result.cornerMarks[0]).toBe(0);
expect(maskValues(result.centerMarks[1]!, 4)).toEqual([2, 3, 4]);
expect(maskValues(result.cornerMarks[4]!, 4)).toEqual([2, 3, 4]);
expect(maskValues(result.centerMarks[10]!, 4)).toEqual([1, 2, 3, 4]);
expect(result.cornerMarks[10]).toBe(0b1111);
expect(session.cornerMarks[1]).toBe(0b1111 | (1 << 8));
});
it("auto-removes notes after placements but does not invent notes on erase", () => {
const previous = createSession(new Array<number>(16).fill(0));
previous.centerMarks.fill(0b1111);
previous.cornerMarks.fill(0b1111);
const placed = createSession(new Array<number>(16).fill(0));
placed.centerMarks.fill(0b1111);
placed.cornerMarks.fill(0b1111);
placed.values[0] = 1;
const maintained = autoRemoveNotesAfterPlacements(
previous,
placed,
puzzle4(),
);
expect(maskValues(maintained.centerMarks[1]!, 4)).toEqual([2, 3, 4]);
expect(maskValues(maintained.cornerMarks[4]!, 4)).toEqual([2, 3, 4]);
expect(maintained.centerMarks[10]).toBe(0b1111);
const erased = createSession(new Array<number>(16).fill(0));
erased.centerMarks[1] = 0b0001;
const previousFilled = createSession(new Array<number>(16).fill(0));
previousFilled.values[0] = 1;
const afterErase = autoRemoveNotesAfterPlacements(
previousFilled,
erased,
puzzle4(),
);
expect(afterErase.centerMarks[1]).toBe(0b0001);
expect(afterErase).not.toBe(erased);
});
it("applies a logical step to tracked candidates as one immutable transition", () => {
const base = createSession(new Array<number>(16).fill(0));
base.cornerMarks.fill(0b1111);
base.colors[10] = 3;
base.elapsedSeconds = 99;
const session = fillLegalCenterCandidates(base, puzzle4());
const step: LogicalStep = {
technique: "naked-single",
placements: [{ cell: 0, value: 1 }],
eliminations: [{ cell: 10, values: [2] }],
focusCells: [0, 10],
explanation: "Test step",
};
const result = applyLogicalStepToSession(session, step, puzzle4());
expect(result.values[0]).toBe(1);
expect(result.centerMarks[0]).toBe(0);
expect(result.cornerMarks[0]).toBe(0);
expect(maskValues(result.centerMarks[1]!, 4)).toEqual([2, 3, 4]);
expect(maskValues(result.centerMarks[10]!, 4)).toEqual([1, 3, 4]);
expect(result.cornerMarks[1]).toBe(0b1111);
expect(result.colors[10]).toBe(3);
expect(result.elapsedSeconds).toBe(99);
expect(session.values[0]).toBe(0);
expect(session.centerMarks[10]).toBe(0b1111);
});
it("leaves absent tracked candidates absent and rejects stale steps", () => {
const session = createSession(new Array<number>(16).fill(0));
const elimination: LogicalStep = {
technique: "pointing",
placements: [],
eliminations: [{ cell: 1, values: [1] }],
focusCells: [1],
explanation: "Test elimination",
};
expect(
applyLogicalStepToSession(session, elimination, puzzle4()).centerMarks[1],
).toBe(0);
session.values[0] = 2;
const placement: LogicalStep = {
...elimination,
placements: [{ cell: 0, value: 1 }],
eliminations: [],
};
expect(() =>
applyLogicalStepToSession(session, placement, puzzle4()),
).toThrow(/filled cell/u);
});
});
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import {
createGameplayHistory,
parseGameplayHistory,
serializeGameplayHistory,
} from "../../src/state/playHistory";
import { createSession } from "../../src/state/session";
describe("gameplay history persistence", () => {
it("round-trips independent bounded history", () => {
const history = createGameplayHistory(
createSession(Array<number>(16).fill(0)),
);
const encoded = serializeGameplayHistory(history, 4);
const decoded = parseGameplayHistory(encoded, 4);
expect(decoded).toEqual(history);
(decoded.moments[0]!.state.values as number[])[0] = 4;
expect(history.moments[0]!.state.values[0]).toBe(0);
});
it("rejects dangling and oversized persisted state", () => {
const history = createGameplayHistory(
createSession(Array<number>(16).fill(0)),
);
const raw = JSON.parse(serializeGameplayHistory(history, 4)) as {
history: { currentMomentId: string };
};
raw.history.currentMomentId = "missing";
expect(() => parseGameplayHistory(JSON.stringify(raw), 4)).toThrow(
/missing active branch or moment/u,
);
expect(() => parseGameplayHistory("x".repeat(1_048_577), 4)).toThrow(
/too large/u,
);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from "vitest";
import {
BOARD_SCALE_STORAGE_KEY,
colorMarkDescription,
normalizeBoardScale,
parseCandidateVerbosity,
readStoredBoardScale,
writeStoredBoardScale,
} from "../../src/state/uiPreferences";
describe("UI accessibility preferences", () => {
it("clamps and snaps persisted board scale safely", () => {
expect(normalizeBoardScale(1.13)).toBe(1.25);
expect(normalizeBoardScale(0.1)).toBe(0.75);
expect(normalizeBoardScale(9)).toBe(2);
expect(normalizeBoardScale("not a scale")).toBe(1);
const storage = { getItem: vi.fn(() => "1.49") };
expect(readStoredBoardScale(storage)).toBe(1.5);
expect(storage.getItem).toHaveBeenCalledWith(BOARD_SCALE_STORAGE_KEY);
const setItem = vi.fn();
writeStoredBoardScale(1.74, { setItem });
expect(setItem).toHaveBeenCalledWith(BOARD_SCALE_STORAGE_KEY, "1.75");
});
it("survives unavailable storage and normalizes announcement settings", () => {
expect(
readStoredBoardScale({
getItem: () => {
throw new Error("blocked");
},
}),
).toBe(1);
expect(() =>
writeStoredBoardScale(1.5, {
setItem: () => {
throw new Error("blocked");
},
}),
).not.toThrow();
expect(parseCandidateVerbosity("concise")).toBe("concise");
expect(parseCandidateVerbosity("everything")).toBe("detailed");
});
it("gives every colour mark a non-colour identifier", () => {
expect(colorMarkDescription(1)).toBe("red, diagonal stripes");
expect(colorMarkDescription(8)).toBe("pink, rings");
expect(colorMarkDescription(99)).toBe("mark 99");
});
});
+114
View File
@@ -54,6 +54,69 @@ describe("local project library", () => {
expect(await library.list()).toEqual([]);
});
it("searches tags, exposes safe thumbnails and exports a selection", async () => {
const library = new ProjectLibrary({ indexedDB: null });
await library.put(
createProjectRecord(puzzle, {
id: "killer",
title: "Evening Killer",
tags: ["killer", "hard"],
now: 1,
}),
);
await library.put(
createProjectRecord(puzzle, {
id: "classic",
title: "Morning Classic",
tags: ["classic"],
now: 2,
}),
);
expect(
(await library.list({ search: "kill" })).map(({ id }) => id),
).toEqual(["killer"]);
expect(
(await library.list({ tags: ["classic"] }))[0]?.thumbnail,
).toHaveLength(81);
expect((await library.exportSelected(["classic"])).projects).toHaveLength(
1,
);
});
it("keeps recoverable autosaves separate from explicit projects", async () => {
const library = new ProjectLibrary({ indexedDB: null });
const record = createProjectRecord(puzzle, {
id: "draft",
title: "Draft",
now: 1,
});
await library.putAutosave(record);
expect((await library.getAutosave())?.id).toBe("draft");
expect(await library.list()).toEqual([]);
await library.clearAutosave();
expect(await library.getAutosave()).toBeUndefined();
});
it("duplicates selected projects with independent IDs and progress", async () => {
const library = new ProjectLibrary({ indexedDB: null });
await library.put(
createProjectRecord(puzzle, {
id: "source",
title: "Source",
tags: ["classic"],
now: 10,
progress: { version: 1, values: Array<number>(81).fill(0) },
}),
);
expect(await library.duplicateSelected(["source", "missing"])).toBe(1);
const records = await library.exportAll();
expect(records.projects).toHaveLength(2);
const copy = records.projects.find((record) => record.id !== "source");
expect(copy).toMatchObject({ title: "Source copy", tags: ["classic"] });
expect(copy?.id).not.toBe("source");
});
it("exports and imports an explicitly versioned library", async () => {
const source = new ProjectLibrary({ indexedDB: null });
await source.put(createProjectRecord(puzzle, { id: "one", now: 10 }));
@@ -63,6 +126,41 @@ describe("local project library", () => {
expect(await target.get("one")).toEqual(await source.get("one"));
});
it("retains source extras through explicit saves, autosaves and reopening", async () => {
const sourcePuzzle = {
...puzzle,
visuals: [
{
type: "text" as const,
layer: "overlay" as const,
position: { kind: "cell" as const, cell: 0 },
text: "retained",
style: { fill: "#123456" },
},
],
source: { format: "sudokupad" as const, id: "source-identity" },
metadata: { edition: "local" },
};
const library = new ProjectLibrary({ indexedDB: null });
const record = createProjectRecord(sourcePuzzle, {
id: "preserved",
title: "Preserved",
now: 10,
});
await library.put(record);
await library.putAutosave({ ...record, updatedAt: 11 });
const exported = await library.exportAll();
const reopened = await library.get("preserved");
const autosave = await library.getAutosave();
for (const stored of [reopened, autosave, exported.projects[0]]) {
expect(stored?.puzzle.visuals).toEqual(sourcePuzzle.visuals);
expect(stored?.puzzle.source).toEqual(sourcePuzzle.source);
expect(stored?.puzzle.metadata).toEqual(sourcePuzzle.metadata);
}
});
it("falls back when IndexedDB cannot be opened", async () => {
const broken = {
open: () => {
@@ -90,4 +188,20 @@ describe("local project library", () => {
}),
).toThrow(/must contain 81/u);
});
it("bounds tags and rejects executable thumbnail markup", () => {
const record = createProjectRecord(puzzle, { id: "bounded", now: 1 });
expect(() =>
normalizeProjectRecord({
...record,
tags: Array.from({ length: 21 }, (_, index) => `tag-${String(index)}`),
}),
).toThrow(/at most 20/u);
expect(() =>
normalizeProjectRecord({
...record,
thumbnail: `<svg onload="alert(1)">${".".repeat(58)}`,
}),
).toThrow(/safe row-major grid preview/u);
});
});