feat: complete advanced Sudoku workbench
This commit is contained in:
+60
-7
@@ -4,6 +4,30 @@ All notable changes are documented here.
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 0.2.0 - 2026-08-31
|
||||
|
||||
- Added staged guided hints that reveal focus, technique, reasoning and effects
|
||||
before an explicit, undoable apply action.
|
||||
- Added legal centre-candidate filling, invalid note pruning and optional peer
|
||||
note maintenance after placements; erasing a value never invents candidates.
|
||||
- Added Jellyfish, finned X-Wing/Swordfish, Skyscraper, Two-String Kite, Simple
|
||||
Colouring, W-Wing, X-Chain, XY-Chain and alternating-inference-chain logical
|
||||
steps with deterministic evidence.
|
||||
- Added Unique Rectangle only behind a completed exact uniqueness proof; a
|
||||
truncated search never enables uniqueness-dependent logic.
|
||||
- Added quick and full setter-quality audits with ambiguity witnesses,
|
||||
contradiction suspects, per-item critical/redundant/unknown findings, a cell
|
||||
heatmap and optional bounded minimality proof.
|
||||
- Added explicit per-check and aggregate quality budgets, cancellation and
|
||||
serializable check evidence so a capped search remains unknown rather than
|
||||
becoming a false proof.
|
||||
- Added minimum, odd/even, disjoint-groups, little-killer and sandwich rules
|
||||
across validation, candidate filtering, exact solving, rendering and setting.
|
||||
- Added between, German-whisper, region-sum, modular, entropic and zipper lines;
|
||||
clone and extra regions; double arrows; and row, column and box indexers.
|
||||
- Added display-only Fog of War backed by a complete locally stored solution.
|
||||
Only initial lights, givens and correctly entered values reveal cells; fog is
|
||||
never treated as an extra solution constraint.
|
||||
- Added optional digit-completion counts with muted complete digits and red
|
||||
over-completion warnings.
|
||||
- Added toggleable Ctrl/Command-click matching-digit highlights without turning
|
||||
@@ -12,10 +36,16 @@ All notable changes are documented here.
|
||||
remain accidentally active.
|
||||
- Replaced ambiguous V/X pair labels on the board with numbered 5/10 badges and
|
||||
gave inequalities a directional chevron with a marked lesser-value tip.
|
||||
- Added thirteen uniquely checked built-in examples covering every supported
|
||||
constraint family.
|
||||
- Added bounded, seedable generation for classic and twelve variant families,
|
||||
plus independent uniqueness and evidence-based difficulty assessment.
|
||||
- Added fourteen uniquely checked built-in examples covering the original core
|
||||
constraint families.
|
||||
- Expanded bounded, seedable generation for classic and twelve variant families
|
||||
with mixed-family recipes, sparse/balanced/dense clue controls and additional
|
||||
reflection, diagonal and four-way clue symmetries.
|
||||
- Added explicit minimal-givens mode with proof/unknown reporting, deterministic
|
||||
batches of up to 12 candidates and rankings by difficulty or clue count.
|
||||
- Added required/forbidden/minimum/maximum logical-technique profiles and an
|
||||
exact hardest-technique target, accepted only against an independent complete
|
||||
logical path and exact uniqueness check.
|
||||
- Added X-sum, skyscraper, quadruple and maximum-cell constraints across the
|
||||
model, validator, exact solver, renderer, setter and f-puzzles interchange.
|
||||
- Added per-clue false semantics for liar/Wrogn puzzles, including bounded
|
||||
@@ -25,14 +55,30 @@ All notable changes are documented here.
|
||||
- Added outside-grid clue margins, combined dual X-sum/skyscraper labels,
|
||||
outward maximum arrows and visually distinct false clues.
|
||||
- Added strict, bounded local imports for SudokuPad/CTC data and supported
|
||||
Penpa+ long links, including source reporting and explicit incompatibility
|
||||
lists instead of silently discarded constructs.
|
||||
Penpa+ long links, with a structured preview of mapped semantics, preserved
|
||||
drawings, metadata and compatibility warnings.
|
||||
- Added a bounded inert visual model for f-puzzles and SCL lines, polylines,
|
||||
rectangles, ellipses, circles and text; source identity, scalar metadata and
|
||||
drawings survive edits, explicit saves, autosaves, reopening and project/share
|
||||
round-trips without becoming rules, and render beneath Fog on the board.
|
||||
- Added SudokuPad/CTC JSON and self-contained `scl…` export with progress,
|
||||
regions, cages and canonical constraint drawings, while rejecting negated
|
||||
clues or free-form global rules it cannot preserve safely.
|
||||
- Added a compatibility check which never fetches SudokuPad short IDs or other
|
||||
server-hosted puzzle references.
|
||||
- Added standalone SVG, high-resolution PNG and single-page PDF visual exports
|
||||
for supported constraints, givens and optional solving progress.
|
||||
for supported constraints, safe preserved drawings, givens and optional
|
||||
solving progress.
|
||||
- Added named savepoints, isolated hypothesis branches, keep/discard decisions
|
||||
and read-only replay of complete solving states.
|
||||
- Added debounced recovery snapshots for puzzle state, progress, aid-mémoire
|
||||
and a validated, size-bounded gameplay history, with explicit restore/discard
|
||||
handling after an interruption.
|
||||
- Upgraded the local Library with title/tag search, tag and completion filters,
|
||||
safe grid thumbnails, tag editing and selected export, duplication or
|
||||
confirmed deletion.
|
||||
- Added a relative-scope web app manifest and subpath-aware service worker with
|
||||
an offline application-shell fallback after the first successful load.
|
||||
- Added an optional configurable aid-mémoire whose labelled scratch cells
|
||||
support values, both note styles and colours without constraining the puzzle;
|
||||
its state is preserved in undo, replay, Library progress and project/share
|
||||
@@ -46,6 +92,13 @@ All notable changes are documented here.
|
||||
- Added technique-targeted practice generation which mines a bounded,
|
||||
deterministic set of uniquely checked puzzles and verifies the requested
|
||||
technique in the logical solve path.
|
||||
- Added a persisted 75–200% board viewport, Fit and board-scoped zoom shortcuts,
|
||||
plus an announced Pan mode which pauses cell pointer targeting.
|
||||
- Added tap-toggle multi-selection and a single safe-area-aware sticky entry pad
|
||||
at narrow widths, with touch-sized zoom controls.
|
||||
- Added eight hue-and-pattern colour marks, configurable off/concise/detailed
|
||||
candidate narration, and fog-safe board, toolbar, helper and guided-hint
|
||||
boundaries.
|
||||
- Added real ARIA row/gridcell semantics, detailed cell descriptions and
|
||||
non-wrapping Arrow, Home/End, Control/Command + Home/End and Page Up/Down
|
||||
keyboard navigation for the puzzle and scratch grids.
|
||||
|
||||
@@ -9,76 +9,243 @@ release also runs independently from any static HTTPS host or local preview.
|
||||
|
||||
- **Play** — keyboard, mouse and touch entry; multi-cell selection; values,
|
||||
corner/centre notes and colours; undo/redo; named savepoints, hypothesis
|
||||
branches and read-only replay; conflict highlighting; timer and local
|
||||
progress; optional digit-completion counts, matching-digit highlights and a
|
||||
configurable non-constraining aid-mémoire.
|
||||
- **Set** — givens, metadata, regions and typed constraints; uniqueness checks
|
||||
with overlap-safe cage replacement and selection-based cage removal.
|
||||
- **Generate** — seedable, bounded construction for classic and 12 variant
|
||||
families; independent uniqueness verification and evidence-based difficulty
|
||||
assessment; deterministic mining for a requested logical technique.
|
||||
branches and read-only replay; staged guided hints; candidate maintenance;
|
||||
conflict highlighting; timer and local progress; optional digit-completion
|
||||
counts, matching-digit highlights and a configurable non-constraining
|
||||
aid-mémoire.
|
||||
- **Set** — givens, metadata, regions and registry-backed constraints;
|
||||
overlap-safe clue replacement and removal; quick uniqueness checks; and a
|
||||
bounded quality lab for ambiguity, contradictions, redundancy and
|
||||
minimality.
|
||||
- **Generate** — seedable single or batch construction for classic and 12
|
||||
variant families, including mixed-family recipes, clue symmetry, constraint
|
||||
density, optional minimal-givens proof and independently checked technique
|
||||
profiles.
|
||||
- **Solve** — exact solution counting plus an original human-style engine whose
|
||||
steps include structured evidence, placements and eliminations.
|
||||
deterministic steps include structured evidence, placements and
|
||||
eliminations. Uniqueness-dependent logic is disabled unless uniqueness has
|
||||
already been proved by a completed exact search.
|
||||
- **Helpers** — generalized sum combinations with positional candidates and
|
||||
manual eliminations; selected-cell and house candidate-link analysis; Killer
|
||||
combinations, 45-rule residuals, and Kropki, sum-pair or inequality pairs.
|
||||
|
||||
Classic grids and common variants share one bounded puzzle model: irregular
|
||||
regions, diagonals, Killer cages, thermometers, arrows, Kropki and numbered
|
||||
5/10 sum-pair clues, inequalities, renban lines, palindromes, X-sums,
|
||||
skyscrapers, quadruples, maximum cells, anti-knight, anti-king and
|
||||
non-consecutive rules. Every local clue can also be required to be false for
|
||||
liar/Wrogn constructions. Fourteen original bundled examples demonstrate every
|
||||
supported constraint and are exact-search verified as unique.
|
||||
## Constraint coverage
|
||||
|
||||
Classic grids and variants share one validated puzzle model and constraint
|
||||
registry. The production engine currently supports:
|
||||
|
||||
- classic, irregular and extra regions; diagonals; disjoint groups;
|
||||
anti-knight, anti-king and non-consecutive rules;
|
||||
- Killer cages, ordered clone regions, quadruples, maximum/minimum cells and
|
||||
odd/even cells;
|
||||
- thermometers, arrows, renban and palindrome lines, between lines, German
|
||||
whispers, region-sum lines, modular lines, entropic lines, zipper lines and
|
||||
double arrows;
|
||||
- Kropki dots, numbered 5/10 sum pairs and inequalities;
|
||||
- X-sums, skyscrapers, little-killer diagonals and sandwich sums; and
|
||||
- row, column and rectangular-box indexers, plus display-only Fog of War.
|
||||
|
||||
Each supported semantic constraint is validated and participates in candidate
|
||||
filtering and exact solving. Negatable clues can instead be required to be false
|
||||
for liar/Wrogn constructions; global house rules, extra regions and fog are not
|
||||
given a misleading false mode. Fourteen original bundled examples cover the
|
||||
core families and the uniquely checked “Truth and lies” construction. The
|
||||
newer constraint packs are covered by focused validation, feasibility and exact
|
||||
solver tests rather than bundled third-party puzzles.
|
||||
|
||||
Fog is deliberately a presentation rule, not a hidden solver constraint. It
|
||||
can be set only when the puzzle carries a complete solution that validates
|
||||
against the current givens and constraints. Initial lights, givens and correctly
|
||||
entered digits reveal a radius-zero or radius-one Chebyshev neighbourhood;
|
||||
wrong entries reveal nothing. Obscured cells are disabled and their values,
|
||||
notes, candidates, hints, quality overlays and fully hidden clue graphics are
|
||||
omitted. The trusted solution remains part of the local puzzle document.
|
||||
|
||||
## Guided solving and candidate maintenance
|
||||
|
||||
A guided hint asks the worker for one supported logical step and discloses it in
|
||||
four explicit stages: where to look, technique, reasoning and effects preview.
|
||||
Later-stage answer content is not placed in the document before it is revealed,
|
||||
and the board does not change until **Apply this step** is chosen. Applying a
|
||||
step is one undoable transition.
|
||||
|
||||
Candidate controls can fill every empty cell with its currently legal centre
|
||||
candidates, remove invalid centre/corner notes, and optionally prune peer notes
|
||||
after a placement. Erasing a digit never invents candidates. If an
|
||||
elimination-only hint is applied without tracked candidates, the app first
|
||||
creates a complete legal centre-candidate grid and then applies the explicit
|
||||
elimination.
|
||||
|
||||
The logical engine covers singles; naked and hidden pairs, triples and quads;
|
||||
pointing and claiming; X-Wing, Swordfish and Jellyfish; finned X-Wing and
|
||||
finned Swordfish; XY-Wing and XYZ-Wing; Skyscraper; Two-String Kite; Simple
|
||||
Colouring; W-Wing; X-Chains, XY-Chains and alternating inference chains; and
|
||||
Killer-cage deductions. Unique Rectangle is available only when the caller
|
||||
supplies a completed uniqueness proof. The engine never infers uniqueness from
|
||||
a puzzle’s appearance, metadata or a truncated search.
|
||||
|
||||
## Setter-quality analysis
|
||||
|
||||
The quick setter check performs a bounded zero/one/two-solution audit. A pair of
|
||||
solutions produces a concrete ambiguity witness with every differing cell and
|
||||
value. For a uniquely proved baseline, full analysis uses leave-one-out exact
|
||||
searches to classify each given and constraint as critical, redundant or
|
||||
unknown and creates a per-cell criticality heatmap. For an unsatisfiable
|
||||
baseline it instead uses bounded deletion tests to localise a contradictory
|
||||
core. Optional minimality is proved only when the unique baseline and every
|
||||
required removal check complete.
|
||||
|
||||
Every quality search has configurable per-check node/time limits and shared
|
||||
aggregate check/node/time limits. Results retain the checks performed, elapsed
|
||||
time and reasons for unknown conclusions. Reaching a safety limit is never
|
||||
silently converted into uniqueness, redundancy, contradiction localisation or
|
||||
minimality. The worker analysis can be cancelled without discarding the last
|
||||
completed result.
|
||||
|
||||
## Generator 2.0
|
||||
|
||||
Generation supports the existing classic, diagonal, anti-knight, anti-king,
|
||||
non-consecutive, Killer, thermo, arrow, Kropki, XV, inequality, renban and
|
||||
palindrome families. Compatible families can be combined when they share a
|
||||
supported grid size. Local marking density can be sparse, balanced or dense;
|
||||
for Killer this changes cage granularity, while inherently global rules retain
|
||||
their fixed meaning.
|
||||
|
||||
Clue removal supports no symmetry, half-turn rotation, horizontal or vertical
|
||||
reflection, either diagonal reflection, and four-way quarter-turn symmetry.
|
||||
Minimal-givens mode checks every retained given individually and reports either
|
||||
a completed proof or the exact unknown reasons. Individual minimisation may
|
||||
break the requested visual symmetry, which is reported rather than hidden.
|
||||
|
||||
Technique profiles can require or forbid supported logical techniques, set
|
||||
minimum/maximum occurrence counts and require an exact hardest technique. A
|
||||
puzzle is accepted only when an independent, complete logical path matches the
|
||||
whole profile and an independent exact check proves uniqueness. Deterministic
|
||||
batches contain at most 12 candidates and can be ranked by difficulty, fewest
|
||||
givens or most givens; failed bounded candidates remain visible as failures and
|
||||
are never included in the verified ranking. Generation is cancellable by
|
||||
terminating and restarting its worker.
|
||||
|
||||
Requested difficulty still controls the clue-removal target rather than
|
||||
promising a rating. The displayed 0–100 result is calculated afterwards from
|
||||
logical techniques, clue load and reproducible exact-search evidence. Supported
|
||||
size/family combinations are deliberately restricted, and a bounded mixed
|
||||
construction may fail cleanly for an incompatible or unusually difficult
|
||||
recipe.
|
||||
|
||||
## Mobile and accessibility
|
||||
|
||||
The board viewport offers a locally persisted 75–200% scale in 25% steps,
|
||||
increment/decrement controls and a Fit action that returns to 100% and the
|
||||
origin. Control/Command + plus, minus or zero are board-scoped equivalents.
|
||||
Explicit Pan mode announces its state, cancels any in-progress drag selection
|
||||
and disables cell pointer targeting until it is stopped. Tap multi-select, also
|
||||
available with <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 0–100 result is calculated afterwards from logical techniques,
|
||||
clue load and reproducible exact-search evidence. A timeout or node limit is
|
||||
reported as unknown and never promoted to a uniqueness or difficulty claim.
|
||||
Technique practice performs a bounded, deterministic search and returns a
|
||||
puzzle only when its independently analysed solve path actually contains the
|
||||
requested technique.
|
||||
|
||||
## Privacy and storage
|
||||
## Privacy, recovery and Library
|
||||
|
||||
Puzzle parsing, solving, generation and export happen entirely in the browser.
|
||||
There are no analytics, advertisements, accounts or network-backed puzzle
|
||||
lookups. Projects and progress are saved only in local IndexedDB after an
|
||||
explicit save action and can be cleared from inside the app.
|
||||
Exported files and share URLs contain the puzzle data you chose to include.
|
||||
lookups. Explicit projects are stored in IndexedDB. A separate, debounced
|
||||
autosave slot retains the current puzzle state, progress, aid-mémoire and a
|
||||
bounded gameplay history. After an interrupted session, the app offers to
|
||||
restore or discard that recovery state without silently replacing an explicit
|
||||
project. Its status distinguishes checking, saving, saved, ready and unavailable
|
||||
states.
|
||||
|
||||
Gameplay history includes undo/redo moments, named savepoints and hypothesis
|
||||
branches, and is validated when restored. It is bounded to 500 moments and one
|
||||
MiB for its serialized history payload. The Library supports title and tag
|
||||
search, tag and completion filters, script-free grid thumbnails, bounded tag
|
||||
editing, and selected export, duplication or confirmed deletion. Whole-library
|
||||
import and export remain versioned operations.
|
||||
|
||||
If IndexedDB is unavailable, a bounded in-memory fallback keeps work for the
|
||||
current tab and the interface reports that persistence is unavailable. Browser
|
||||
storage can still be removed by the browser or user, so important projects
|
||||
should be exported. Exported files and share URLs contain the puzzle data you
|
||||
chose to include.
|
||||
|
||||
SudokuPad short IDs identify server-hosted records and therefore cannot be
|
||||
resolved by this deliberately offline application. Embedded f-puzzles payloads
|
||||
and user-provided Sudoku Tools JSON documents are accepted without contacting
|
||||
their originating site. Externally authored puzzle definitions are not bundled.
|
||||
|
||||
## Offline application shell
|
||||
|
||||
Production builds include a relative-scope web app manifest and register a
|
||||
subpath-aware service worker after the page has loaded. It caches the application
|
||||
shell and same-origin built assets, uses network-first navigation with a cached
|
||||
fallback, serves cached static assets when available and removes obsolete cache
|
||||
versions.
|
||||
|
||||
The first successful online load is still required to install those resources,
|
||||
and service-worker availability depends on browser support and a secure origin.
|
||||
Registration is progressive enhancement: the application continues to work
|
||||
when a service worker is unavailable, and puzzle operations never depend on a
|
||||
network connection.
|
||||
|
||||
## Development
|
||||
|
||||
Requirements: Node.js 22 or newer and npm 11 or newer.
|
||||
@@ -101,7 +268,7 @@ npm run release:artifact
|
||||
|
||||
The command verifies the manifest, types, formatting, tests, production build,
|
||||
browser workflows and Toolbox contract before creating a deterministic
|
||||
`release/sudoku-tools-0.1.0.zip` with a matching SHA-256 sidecar.
|
||||
`release/sudoku-tools-0.2.0.zip` with a matching SHA-256 sidecar.
|
||||
|
||||
## Scope and references
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -4,6 +4,30 @@ All notable changes are documented here.
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 0.2.0 - 2026-08-31
|
||||
|
||||
- Added staged guided hints that reveal focus, technique, reasoning and effects
|
||||
before an explicit, undoable apply action.
|
||||
- Added legal centre-candidate filling, invalid note pruning and optional peer
|
||||
note maintenance after placements; erasing a value never invents candidates.
|
||||
- Added Jellyfish, finned X-Wing/Swordfish, Skyscraper, Two-String Kite, Simple
|
||||
Colouring, W-Wing, X-Chain, XY-Chain and alternating-inference-chain logical
|
||||
steps with deterministic evidence.
|
||||
- Added Unique Rectangle only behind a completed exact uniqueness proof; a
|
||||
truncated search never enables uniqueness-dependent logic.
|
||||
- Added quick and full setter-quality audits with ambiguity witnesses,
|
||||
contradiction suspects, per-item critical/redundant/unknown findings, a cell
|
||||
heatmap and optional bounded minimality proof.
|
||||
- Added explicit per-check and aggregate quality budgets, cancellation and
|
||||
serializable check evidence so a capped search remains unknown rather than
|
||||
becoming a false proof.
|
||||
- Added minimum, odd/even, disjoint-groups, little-killer and sandwich rules
|
||||
across validation, candidate filtering, exact solving, rendering and setting.
|
||||
- Added between, German-whisper, region-sum, modular, entropic and zipper lines;
|
||||
clone and extra regions; double arrows; and row, column and box indexers.
|
||||
- Added display-only Fog of War backed by a complete locally stored solution.
|
||||
Only initial lights, givens and correctly entered values reveal cells; fog is
|
||||
never treated as an extra solution constraint.
|
||||
- Added optional digit-completion counts with muted complete digits and red
|
||||
over-completion warnings.
|
||||
- Added toggleable Ctrl/Command-click matching-digit highlights without turning
|
||||
@@ -12,10 +36,16 @@ All notable changes are documented here.
|
||||
remain accidentally active.
|
||||
- Replaced ambiguous V/X pair labels on the board with numbered 5/10 badges and
|
||||
gave inequalities a directional chevron with a marked lesser-value tip.
|
||||
- Added thirteen uniquely checked built-in examples covering every supported
|
||||
constraint family.
|
||||
- Added bounded, seedable generation for classic and twelve variant families,
|
||||
plus independent uniqueness and evidence-based difficulty assessment.
|
||||
- Added fourteen uniquely checked built-in examples covering the original core
|
||||
constraint families.
|
||||
- Expanded bounded, seedable generation for classic and twelve variant families
|
||||
with mixed-family recipes, sparse/balanced/dense clue controls and additional
|
||||
reflection, diagonal and four-way clue symmetries.
|
||||
- Added explicit minimal-givens mode with proof/unknown reporting, deterministic
|
||||
batches of up to 12 candidates and rankings by difficulty or clue count.
|
||||
- Added required/forbidden/minimum/maximum logical-technique profiles and an
|
||||
exact hardest-technique target, accepted only against an independent complete
|
||||
logical path and exact uniqueness check.
|
||||
- Added X-sum, skyscraper, quadruple and maximum-cell constraints across the
|
||||
model, validator, exact solver, renderer, setter and f-puzzles interchange.
|
||||
- Added per-clue false semantics for liar/Wrogn puzzles, including bounded
|
||||
@@ -25,14 +55,30 @@ All notable changes are documented here.
|
||||
- Added outside-grid clue margins, combined dual X-sum/skyscraper labels,
|
||||
outward maximum arrows and visually distinct false clues.
|
||||
- Added strict, bounded local imports for SudokuPad/CTC data and supported
|
||||
Penpa+ long links, including source reporting and explicit incompatibility
|
||||
lists instead of silently discarded constructs.
|
||||
Penpa+ long links, with a structured preview of mapped semantics, preserved
|
||||
drawings, metadata and compatibility warnings.
|
||||
- Added a bounded inert visual model for f-puzzles and SCL lines, polylines,
|
||||
rectangles, ellipses, circles and text; source identity, scalar metadata and
|
||||
drawings survive edits, explicit saves, autosaves, reopening and project/share
|
||||
round-trips without becoming rules, and render beneath Fog on the board.
|
||||
- Added SudokuPad/CTC JSON and self-contained `scl…` export with progress,
|
||||
regions, cages and canonical constraint drawings, while rejecting negated
|
||||
clues or free-form global rules it cannot preserve safely.
|
||||
- Added a compatibility check which never fetches SudokuPad short IDs or other
|
||||
server-hosted puzzle references.
|
||||
- Added standalone SVG, high-resolution PNG and single-page PDF visual exports
|
||||
for supported constraints, givens and optional solving progress.
|
||||
for supported constraints, safe preserved drawings, givens and optional
|
||||
solving progress.
|
||||
- Added named savepoints, isolated hypothesis branches, keep/discard decisions
|
||||
and read-only replay of complete solving states.
|
||||
- Added debounced recovery snapshots for puzzle state, progress, aid-mémoire
|
||||
and a validated, size-bounded gameplay history, with explicit restore/discard
|
||||
handling after an interruption.
|
||||
- Upgraded the local Library with title/tag search, tag and completion filters,
|
||||
safe grid thumbnails, tag editing and selected export, duplication or
|
||||
confirmed deletion.
|
||||
- Added a relative-scope web app manifest and subpath-aware service worker with
|
||||
an offline application-shell fallback after the first successful load.
|
||||
- Added an optional configurable aid-mémoire whose labelled scratch cells
|
||||
support values, both note styles and colours without constraining the puzzle;
|
||||
its state is preserved in undo, replay, Library progress and project/share
|
||||
@@ -46,6 +92,13 @@ All notable changes are documented here.
|
||||
- Added technique-targeted practice generation which mines a bounded,
|
||||
deterministic set of uniquely checked puzzles and verifies the requested
|
||||
technique in the logical solve path.
|
||||
- Added a persisted 75–200% board viewport, Fit and board-scoped zoom shortcuts,
|
||||
plus an announced Pan mode which pauses cell pointer targeting.
|
||||
- Added tap-toggle multi-selection and a single safe-area-aware sticky entry pad
|
||||
at narrow widths, with touch-sized zoom controls.
|
||||
- Added eight hue-and-pattern colour marks, configurable off/concise/detailed
|
||||
candidate narration, and fog-safe board, toolbar, helper and guided-hint
|
||||
boundaries.
|
||||
- Added real ARIA row/gridcell semantics, detailed cell descriptions and
|
||||
non-wrapping Arrow, Home/End, Control/Command + Home/End and Page Up/Down
|
||||
keyboard navigation for the puzzle and scratch grids.
|
||||
|
||||
+211
-44
@@ -9,76 +9,243 @@ release also runs independently from any static HTTPS host or local preview.
|
||||
|
||||
- **Play** — keyboard, mouse and touch entry; multi-cell selection; values,
|
||||
corner/centre notes and colours; undo/redo; named savepoints, hypothesis
|
||||
branches and read-only replay; conflict highlighting; timer and local
|
||||
progress; optional digit-completion counts, matching-digit highlights and a
|
||||
configurable non-constraining aid-mémoire.
|
||||
- **Set** — givens, metadata, regions and typed constraints; uniqueness checks
|
||||
with overlap-safe cage replacement and selection-based cage removal.
|
||||
- **Generate** — seedable, bounded construction for classic and 12 variant
|
||||
families; independent uniqueness verification and evidence-based difficulty
|
||||
assessment; deterministic mining for a requested logical technique.
|
||||
branches and read-only replay; staged guided hints; candidate maintenance;
|
||||
conflict highlighting; timer and local progress; optional digit-completion
|
||||
counts, matching-digit highlights and a configurable non-constraining
|
||||
aid-mémoire.
|
||||
- **Set** — givens, metadata, regions and registry-backed constraints;
|
||||
overlap-safe clue replacement and removal; quick uniqueness checks; and a
|
||||
bounded quality lab for ambiguity, contradictions, redundancy and
|
||||
minimality.
|
||||
- **Generate** — seedable single or batch construction for classic and 12
|
||||
variant families, including mixed-family recipes, clue symmetry, constraint
|
||||
density, optional minimal-givens proof and independently checked technique
|
||||
profiles.
|
||||
- **Solve** — exact solution counting plus an original human-style engine whose
|
||||
steps include structured evidence, placements and eliminations.
|
||||
deterministic steps include structured evidence, placements and
|
||||
eliminations. Uniqueness-dependent logic is disabled unless uniqueness has
|
||||
already been proved by a completed exact search.
|
||||
- **Helpers** — generalized sum combinations with positional candidates and
|
||||
manual eliminations; selected-cell and house candidate-link analysis; Killer
|
||||
combinations, 45-rule residuals, and Kropki, sum-pair or inequality pairs.
|
||||
|
||||
Classic grids and common variants share one bounded puzzle model: irregular
|
||||
regions, diagonals, Killer cages, thermometers, arrows, Kropki and numbered
|
||||
5/10 sum-pair clues, inequalities, renban lines, palindromes, X-sums,
|
||||
skyscrapers, quadruples, maximum cells, anti-knight, anti-king and
|
||||
non-consecutive rules. Every local clue can also be required to be false for
|
||||
liar/Wrogn constructions. Fourteen original bundled examples demonstrate every
|
||||
supported constraint and are exact-search verified as unique.
|
||||
## Constraint coverage
|
||||
|
||||
Classic grids and variants share one validated puzzle model and constraint
|
||||
registry. The production engine currently supports:
|
||||
|
||||
- classic, irregular and extra regions; diagonals; disjoint groups;
|
||||
anti-knight, anti-king and non-consecutive rules;
|
||||
- Killer cages, ordered clone regions, quadruples, maximum/minimum cells and
|
||||
odd/even cells;
|
||||
- thermometers, arrows, renban and palindrome lines, between lines, German
|
||||
whispers, region-sum lines, modular lines, entropic lines, zipper lines and
|
||||
double arrows;
|
||||
- Kropki dots, numbered 5/10 sum pairs and inequalities;
|
||||
- X-sums, skyscrapers, little-killer diagonals and sandwich sums; and
|
||||
- row, column and rectangular-box indexers, plus display-only Fog of War.
|
||||
|
||||
Each supported semantic constraint is validated and participates in candidate
|
||||
filtering and exact solving. Negatable clues can instead be required to be false
|
||||
for liar/Wrogn constructions; global house rules, extra regions and fog are not
|
||||
given a misleading false mode. Fourteen original bundled examples cover the
|
||||
core families and the uniquely checked “Truth and lies” construction. The
|
||||
newer constraint packs are covered by focused validation, feasibility and exact
|
||||
solver tests rather than bundled third-party puzzles.
|
||||
|
||||
Fog is deliberately a presentation rule, not a hidden solver constraint. It
|
||||
can be set only when the puzzle carries a complete solution that validates
|
||||
against the current givens and constraints. Initial lights, givens and correctly
|
||||
entered digits reveal a radius-zero or radius-one Chebyshev neighbourhood;
|
||||
wrong entries reveal nothing. Obscured cells are disabled and their values,
|
||||
notes, candidates, hints, quality overlays and fully hidden clue graphics are
|
||||
omitted. The trusted solution remains part of the local puzzle document.
|
||||
|
||||
## Guided solving and candidate maintenance
|
||||
|
||||
A guided hint asks the worker for one supported logical step and discloses it in
|
||||
four explicit stages: where to look, technique, reasoning and effects preview.
|
||||
Later-stage answer content is not placed in the document before it is revealed,
|
||||
and the board does not change until **Apply this step** is chosen. Applying a
|
||||
step is one undoable transition.
|
||||
|
||||
Candidate controls can fill every empty cell with its currently legal centre
|
||||
candidates, remove invalid centre/corner notes, and optionally prune peer notes
|
||||
after a placement. Erasing a digit never invents candidates. If an
|
||||
elimination-only hint is applied without tracked candidates, the app first
|
||||
creates a complete legal centre-candidate grid and then applies the explicit
|
||||
elimination.
|
||||
|
||||
The logical engine covers singles; naked and hidden pairs, triples and quads;
|
||||
pointing and claiming; X-Wing, Swordfish and Jellyfish; finned X-Wing and
|
||||
finned Swordfish; XY-Wing and XYZ-Wing; Skyscraper; Two-String Kite; Simple
|
||||
Colouring; W-Wing; X-Chains, XY-Chains and alternating inference chains; and
|
||||
Killer-cage deductions. Unique Rectangle is available only when the caller
|
||||
supplies a completed uniqueness proof. The engine never infers uniqueness from
|
||||
a puzzle’s appearance, metadata or a truncated search.
|
||||
|
||||
## Setter-quality analysis
|
||||
|
||||
The quick setter check performs a bounded zero/one/two-solution audit. A pair of
|
||||
solutions produces a concrete ambiguity witness with every differing cell and
|
||||
value. For a uniquely proved baseline, full analysis uses leave-one-out exact
|
||||
searches to classify each given and constraint as critical, redundant or
|
||||
unknown and creates a per-cell criticality heatmap. For an unsatisfiable
|
||||
baseline it instead uses bounded deletion tests to localise a contradictory
|
||||
core. Optional minimality is proved only when the unique baseline and every
|
||||
required removal check complete.
|
||||
|
||||
Every quality search has configurable per-check node/time limits and shared
|
||||
aggregate check/node/time limits. Results retain the checks performed, elapsed
|
||||
time and reasons for unknown conclusions. Reaching a safety limit is never
|
||||
silently converted into uniqueness, redundancy, contradiction localisation or
|
||||
minimality. The worker analysis can be cancelled without discarding the last
|
||||
completed result.
|
||||
|
||||
## Generator 2.0
|
||||
|
||||
Generation supports the existing classic, diagonal, anti-knight, anti-king,
|
||||
non-consecutive, Killer, thermo, arrow, Kropki, XV, inequality, renban and
|
||||
palindrome families. Compatible families can be combined when they share a
|
||||
supported grid size. Local marking density can be sparse, balanced or dense;
|
||||
for Killer this changes cage granularity, while inherently global rules retain
|
||||
their fixed meaning.
|
||||
|
||||
Clue removal supports no symmetry, half-turn rotation, horizontal or vertical
|
||||
reflection, either diagonal reflection, and four-way quarter-turn symmetry.
|
||||
Minimal-givens mode checks every retained given individually and reports either
|
||||
a completed proof or the exact unknown reasons. Individual minimisation may
|
||||
break the requested visual symmetry, which is reported rather than hidden.
|
||||
|
||||
Technique profiles can require or forbid supported logical techniques, set
|
||||
minimum/maximum occurrence counts and require an exact hardest technique. A
|
||||
puzzle is accepted only when an independent, complete logical path matches the
|
||||
whole profile and an independent exact check proves uniqueness. Deterministic
|
||||
batches contain at most 12 candidates and can be ranked by difficulty, fewest
|
||||
givens or most givens; failed bounded candidates remain visible as failures and
|
||||
are never included in the verified ranking. Generation is cancellable by
|
||||
terminating and restarting its worker.
|
||||
|
||||
Requested difficulty still controls the clue-removal target rather than
|
||||
promising a rating. The displayed 0–100 result is calculated afterwards from
|
||||
logical techniques, clue load and reproducible exact-search evidence. Supported
|
||||
size/family combinations are deliberately restricted, and a bounded mixed
|
||||
construction may fail cleanly for an incompatible or unusually difficult
|
||||
recipe.
|
||||
|
||||
## Mobile and accessibility
|
||||
|
||||
The board viewport offers a locally persisted 75–200% scale in 25% steps,
|
||||
increment/decrement controls and a Fit action that returns to 100% and the
|
||||
origin. Control/Command + plus, minus or zero are board-scoped equivalents.
|
||||
Explicit Pan mode announces its state, cancels any in-progress drag selection
|
||||
and disables cell pointer targeting until it is stopped. Tap multi-select, also
|
||||
available with <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 0–100 result is calculated afterwards from logical techniques,
|
||||
clue load and reproducible exact-search evidence. A timeout or node limit is
|
||||
reported as unknown and never promoted to a uniqueness or difficulty claim.
|
||||
Technique practice performs a bounded, deterministic search and returns a
|
||||
puzzle only when its independently analysed solve path actually contains the
|
||||
requested technique.
|
||||
|
||||
## Privacy and storage
|
||||
## Privacy, recovery and Library
|
||||
|
||||
Puzzle parsing, solving, generation and export happen entirely in the browser.
|
||||
There are no analytics, advertisements, accounts or network-backed puzzle
|
||||
lookups. Projects and progress are saved only in local IndexedDB after an
|
||||
explicit save action and can be cleared from inside the app.
|
||||
Exported files and share URLs contain the puzzle data you chose to include.
|
||||
lookups. Explicit projects are stored in IndexedDB. A separate, debounced
|
||||
autosave slot retains the current puzzle state, progress, aid-mémoire and a
|
||||
bounded gameplay history. After an interrupted session, the app offers to
|
||||
restore or discard that recovery state without silently replacing an explicit
|
||||
project. Its status distinguishes checking, saving, saved, ready and unavailable
|
||||
states.
|
||||
|
||||
Gameplay history includes undo/redo moments, named savepoints and hypothesis
|
||||
branches, and is validated when restored. It is bounded to 500 moments and one
|
||||
MiB for its serialized history payload. The Library supports title and tag
|
||||
search, tag and completion filters, script-free grid thumbnails, bounded tag
|
||||
editing, and selected export, duplication or confirmed deletion. Whole-library
|
||||
import and export remain versioned operations.
|
||||
|
||||
If IndexedDB is unavailable, a bounded in-memory fallback keeps work for the
|
||||
current tab and the interface reports that persistence is unavailable. Browser
|
||||
storage can still be removed by the browser or user, so important projects
|
||||
should be exported. Exported files and share URLs contain the puzzle data you
|
||||
chose to include.
|
||||
|
||||
SudokuPad short IDs identify server-hosted records and therefore cannot be
|
||||
resolved by this deliberately offline application. Embedded f-puzzles payloads
|
||||
and user-provided Sudoku Tools JSON documents are accepted without contacting
|
||||
their originating site. Externally authored puzzle definitions are not bundled.
|
||||
|
||||
## Offline application shell
|
||||
|
||||
Production builds include a relative-scope web app manifest and register a
|
||||
subpath-aware service worker after the page has loaded. It caches the application
|
||||
shell and same-origin built assets, uses network-first navigation with a cached
|
||||
fallback, serves cached static assets when available and removes obsolete cache
|
||||
versions.
|
||||
|
||||
The first successful online load is still required to install those resources,
|
||||
and service-worker availability depends on browser support and a secure origin.
|
||||
Registration is progressive enhancement: the application continues to work
|
||||
when a service worker is unavailable, and puzzle operations never depend on a
|
||||
network connection.
|
||||
|
||||
## Development
|
||||
|
||||
Requirements: Node.js 22 or newer and npm 11 or newer.
|
||||
@@ -101,7 +268,7 @@ npm run release:artifact
|
||||
|
||||
The command verifies the manifest, types, formatting, tests, production build,
|
||||
browser workflows and Toolbox contract before creating a deterministic
|
||||
`release/sudoku-tools-0.1.0.zip` with a matching SHA-256 sidecar.
|
||||
`release/sudoku-tools-0.2.0.zip` with a matching SHA-256 sidecar.
|
||||
|
||||
## Scope and references
|
||||
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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 & 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; 1⋯N 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) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 0–100 rating is then calculated
|
||||
from logical techniques, clue load and exact-search evidence. A
|
||||
cancelled or bounded-out candidate remains a failure or unknown,
|
||||
never a uniqueness/minimality claim.
|
||||
</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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+837
-78
File diff suppressed because it is too large
Load Diff
+972
-76
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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,4 +1,5 @@
|
||||
export * from "./compile";
|
||||
export * from "./constraintRegistry";
|
||||
export * from "./geometry";
|
||||
export * from "./rules";
|
||||
export * from "./types";
|
||||
|
||||
+536
-82
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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] }),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(", ");
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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" ||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -1 +1 @@
|
||||
export const APPLICATION_VERSION = "0.1.0";
|
||||
export const APPLICATION_VERSION = "0.2.0";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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] }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user