feat: complete advanced Sudoku workbench
This commit is contained in:
+60
-7
@@ -4,6 +4,30 @@ All notable changes are documented here.
|
|||||||
|
|
||||||
## Unreleased
|
## 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
|
- Added optional digit-completion counts with muted complete digits and red
|
||||||
over-completion warnings.
|
over-completion warnings.
|
||||||
- Added toggleable Ctrl/Command-click matching-digit highlights without turning
|
- Added toggleable Ctrl/Command-click matching-digit highlights without turning
|
||||||
@@ -12,10 +36,16 @@ All notable changes are documented here.
|
|||||||
remain accidentally active.
|
remain accidentally active.
|
||||||
- Replaced ambiguous V/X pair labels on the board with numbered 5/10 badges and
|
- 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.
|
gave inequalities a directional chevron with a marked lesser-value tip.
|
||||||
- Added thirteen uniquely checked built-in examples covering every supported
|
- Added fourteen uniquely checked built-in examples covering the original core
|
||||||
constraint family.
|
constraint families.
|
||||||
- Added bounded, seedable generation for classic and twelve variant families,
|
- Expanded bounded, seedable generation for classic and twelve variant families
|
||||||
plus independent uniqueness and evidence-based difficulty assessment.
|
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
|
- Added X-sum, skyscraper, quadruple and maximum-cell constraints across the
|
||||||
model, validator, exact solver, renderer, setter and f-puzzles interchange.
|
model, validator, exact solver, renderer, setter and f-puzzles interchange.
|
||||||
- Added per-clue false semantics for liar/Wrogn puzzles, including bounded
|
- 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,
|
- Added outside-grid clue margins, combined dual X-sum/skyscraper labels,
|
||||||
outward maximum arrows and visually distinct false clues.
|
outward maximum arrows and visually distinct false clues.
|
||||||
- Added strict, bounded local imports for SudokuPad/CTC data and supported
|
- Added strict, bounded local imports for SudokuPad/CTC data and supported
|
||||||
Penpa+ long links, including source reporting and explicit incompatibility
|
Penpa+ long links, with a structured preview of mapped semantics, preserved
|
||||||
lists instead of silently discarded constructs.
|
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
|
- Added a compatibility check which never fetches SudokuPad short IDs or other
|
||||||
server-hosted puzzle references.
|
server-hosted puzzle references.
|
||||||
- Added standalone SVG, high-resolution PNG and single-page PDF visual exports
|
- 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
|
- Added named savepoints, isolated hypothesis branches, keep/discard decisions
|
||||||
and read-only replay of complete solving states.
|
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
|
- Added an optional configurable aid-mémoire whose labelled scratch cells
|
||||||
support values, both note styles and colours without constraining the puzzle;
|
support values, both note styles and colours without constraining the puzzle;
|
||||||
its state is preserved in undo, replay, Library progress and project/share
|
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,
|
- Added technique-targeted practice generation which mines a bounded,
|
||||||
deterministic set of uniquely checked puzzles and verifies the requested
|
deterministic set of uniquely checked puzzles and verifies the requested
|
||||||
technique in the logical solve path.
|
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
|
- Added real ARIA row/gridcell semantics, detailed cell descriptions and
|
||||||
non-wrapping Arrow, Home/End, Control/Command + Home/End and Page Up/Down
|
non-wrapping Arrow, Home/End, Control/Command + Home/End and Page Up/Down
|
||||||
keyboard navigation for the puzzle and scratch grids.
|
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,
|
- **Play** — keyboard, mouse and touch entry; multi-cell selection; values,
|
||||||
corner/centre notes and colours; undo/redo; named savepoints, hypothesis
|
corner/centre notes and colours; undo/redo; named savepoints, hypothesis
|
||||||
branches and read-only replay; conflict highlighting; timer and local
|
branches and read-only replay; staged guided hints; candidate maintenance;
|
||||||
progress; optional digit-completion counts, matching-digit highlights and a
|
conflict highlighting; timer and local progress; optional digit-completion
|
||||||
configurable non-constraining aid-mémoire.
|
counts, matching-digit highlights and a configurable non-constraining
|
||||||
- **Set** — givens, metadata, regions and typed constraints; uniqueness checks
|
aid-mémoire.
|
||||||
with overlap-safe cage replacement and selection-based cage removal.
|
- **Set** — givens, metadata, regions and registry-backed constraints;
|
||||||
- **Generate** — seedable, bounded construction for classic and 12 variant
|
overlap-safe clue replacement and removal; quick uniqueness checks; and a
|
||||||
families; independent uniqueness verification and evidence-based difficulty
|
bounded quality lab for ambiguity, contradictions, redundancy and
|
||||||
assessment; deterministic mining for a requested logical technique.
|
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
|
- **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
|
- **Helpers** — generalized sum combinations with positional candidates and
|
||||||
manual eliminations; selected-cell and house candidate-link analysis; Killer
|
manual eliminations; selected-cell and house candidate-link analysis; Killer
|
||||||
combinations, 45-rule residuals, and Kropki, sum-pair or inequality pairs.
|
combinations, 45-rule residuals, and Kropki, sum-pair or inequality pairs.
|
||||||
|
|
||||||
Classic grids and common variants share one bounded puzzle model: irregular
|
## Constraint coverage
|
||||||
regions, diagonals, Killer cages, thermometers, arrows, Kropki and numbered
|
|
||||||
5/10 sum-pair clues, inequalities, renban lines, palindromes, X-sums,
|
Classic grids and variants share one validated puzzle model and constraint
|
||||||
skyscrapers, quadruples, maximum cells, anti-knight, anti-king and
|
registry. The production engine currently supports:
|
||||||
non-consecutive rules. Every local clue can also be required to be false for
|
|
||||||
liar/Wrogn constructions. Fourteen original bundled examples demonstrate every
|
- classic, irregular and extra regions; diagonals; disjoint groups;
|
||||||
supported constraint and are exact-search verified as unique.
|
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
|
The board exposes real row/gridcell semantics and concise per-cell state to
|
||||||
assistive technology, including candidates, notes, colours, conflicts and
|
assistive technology, including candidates, notes, colours, conflicts,
|
||||||
touching variant clues. Arrow keys never wrap; Home/End, Control/Command +
|
touching variant clues, hint previews and quality findings. Arrow keys never
|
||||||
Home/End and Page Up/Down provide row- and grid-level navigation. The same
|
wrap; Home/End, Control/Command + Home/End and Page Up/Down provide row- and
|
||||||
navigation model is available in the aid-mémoire scratch grid.
|
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
|
## Interoperability and visual export
|
||||||
|
|
||||||
The import dialog recognises compact grids, Sudoku Tools JSON/share fragments,
|
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
|
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
|
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,
|
are size/decompression bounded. Before applying one, a structured mapping
|
||||||
and stop with a list when a construct cannot be represented faithfully.
|
preview separates solver-enforced semantics, preserved drawings, retained
|
||||||
|
metadata and warnings.
|
||||||
|
|
||||||
SudokuPad compatibility currently preserves cells, givens and progress, notes,
|
The shared inert visual model supports bounded lines, polylines, rectangles,
|
||||||
regions, metadata, solutions, Killer cages and supported global rules. Penpa+
|
ellipses, circles and text. f-puzzles maps its decorative line, rectangle,
|
||||||
compatibility is intentionally narrower: square, unrotated Sudoku grids with
|
circle and text fields; SCL maps supported lines, underlays, overlays and
|
||||||
givens/progress, thermometers and arrows. Visual-only lines, overlays, custom
|
arrows. Drawings, local source identity and scalar metadata survive domain
|
||||||
symbols and other unsupported geometry are reported rather than ignored.
|
edits, explicit saves, autosaves, reopening, and project/share exports. They
|
||||||
f-puzzles export likewise refuses false clues it cannot preserve.
|
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
|
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
|
PDF entirely in the browser. Each visual uses the same trusted puzzle model and
|
||||||
renders region boundaries, supported variant clues and givens; current values,
|
renders region boundaries, supported variant clues, safe preserved drawings and
|
||||||
notes and cell colours can be included or omitted with the progress switch.
|
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
|
## Privacy, recovery and Library
|
||||||
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
|
|
||||||
|
|
||||||
Puzzle parsing, solving, generation and export happen entirely in the browser.
|
Puzzle parsing, solving, generation and export happen entirely in the browser.
|
||||||
There are no analytics, advertisements, accounts or network-backed puzzle
|
There are no analytics, advertisements, accounts or network-backed puzzle
|
||||||
lookups. Projects and progress are saved only in local IndexedDB after an
|
lookups. Explicit projects are stored in IndexedDB. A separate, debounced
|
||||||
explicit save action and can be cleared from inside the app.
|
autosave slot retains the current puzzle state, progress, aid-mémoire and a
|
||||||
Exported files and share URLs contain the puzzle data you chose to include.
|
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
|
SudokuPad short IDs identify server-hosted records and therefore cannot be
|
||||||
resolved by this deliberately offline application. Embedded f-puzzles payloads
|
resolved by this deliberately offline application. Embedded f-puzzles payloads
|
||||||
and user-provided Sudoku Tools JSON documents are accepted without contacting
|
and user-provided Sudoku Tools JSON documents are accepted without contacting
|
||||||
their originating site. Externally authored puzzle definitions are not bundled.
|
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
|
## Development
|
||||||
|
|
||||||
Requirements: Node.js 22 or newer and npm 11 or newer.
|
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,
|
The command verifies the manifest, types, formatting, tests, production build,
|
||||||
browser workflows and Toolbox contract before creating a deterministic
|
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
|
## Scope and references
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Corresponding source
|
# 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
|
The production archive is generated from that tag with the scripts and exact
|
||||||
dependency lock contained in the repository. It contains source identity,
|
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."
|
content="Set, play, solve and analyse Sudoku puzzles locally in your browser."
|
||||||
/>
|
/>
|
||||||
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
<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>
|
<title>Sudoku Tools</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sudoku-tools",
|
"name": "sudoku-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sudoku-tools",
|
"name": "sudoku-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3",
|
"@add-ideas/toolbox-contract": "0.2.3",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "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.",
|
"description": "Set, play, solve and analyse Sudoku puzzles locally in the browser.",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"author": "Albrecht Degering",
|
"author": "Albrecht Degering",
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { defineConfig, devices } from "@playwright/test";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests/browser",
|
testDir: "./tests/browser",
|
||||||
fullyParallel: false,
|
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,
|
timeout: 120_000,
|
||||||
expect: { timeout: 10_000 },
|
expect: { timeout: 10_000 },
|
||||||
reporter: [["list"]],
|
reporter: [["list"]],
|
||||||
|
|||||||
+60
-7
@@ -4,6 +4,30 @@ All notable changes are documented here.
|
|||||||
|
|
||||||
## Unreleased
|
## 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
|
- Added optional digit-completion counts with muted complete digits and red
|
||||||
over-completion warnings.
|
over-completion warnings.
|
||||||
- Added toggleable Ctrl/Command-click matching-digit highlights without turning
|
- Added toggleable Ctrl/Command-click matching-digit highlights without turning
|
||||||
@@ -12,10 +36,16 @@ All notable changes are documented here.
|
|||||||
remain accidentally active.
|
remain accidentally active.
|
||||||
- Replaced ambiguous V/X pair labels on the board with numbered 5/10 badges and
|
- 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.
|
gave inequalities a directional chevron with a marked lesser-value tip.
|
||||||
- Added thirteen uniquely checked built-in examples covering every supported
|
- Added fourteen uniquely checked built-in examples covering the original core
|
||||||
constraint family.
|
constraint families.
|
||||||
- Added bounded, seedable generation for classic and twelve variant families,
|
- Expanded bounded, seedable generation for classic and twelve variant families
|
||||||
plus independent uniqueness and evidence-based difficulty assessment.
|
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
|
- Added X-sum, skyscraper, quadruple and maximum-cell constraints across the
|
||||||
model, validator, exact solver, renderer, setter and f-puzzles interchange.
|
model, validator, exact solver, renderer, setter and f-puzzles interchange.
|
||||||
- Added per-clue false semantics for liar/Wrogn puzzles, including bounded
|
- 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,
|
- Added outside-grid clue margins, combined dual X-sum/skyscraper labels,
|
||||||
outward maximum arrows and visually distinct false clues.
|
outward maximum arrows and visually distinct false clues.
|
||||||
- Added strict, bounded local imports for SudokuPad/CTC data and supported
|
- Added strict, bounded local imports for SudokuPad/CTC data and supported
|
||||||
Penpa+ long links, including source reporting and explicit incompatibility
|
Penpa+ long links, with a structured preview of mapped semantics, preserved
|
||||||
lists instead of silently discarded constructs.
|
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
|
- Added a compatibility check which never fetches SudokuPad short IDs or other
|
||||||
server-hosted puzzle references.
|
server-hosted puzzle references.
|
||||||
- Added standalone SVG, high-resolution PNG and single-page PDF visual exports
|
- 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
|
- Added named savepoints, isolated hypothesis branches, keep/discard decisions
|
||||||
and read-only replay of complete solving states.
|
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
|
- Added an optional configurable aid-mémoire whose labelled scratch cells
|
||||||
support values, both note styles and colours without constraining the puzzle;
|
support values, both note styles and colours without constraining the puzzle;
|
||||||
its state is preserved in undo, replay, Library progress and project/share
|
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,
|
- Added technique-targeted practice generation which mines a bounded,
|
||||||
deterministic set of uniquely checked puzzles and verifies the requested
|
deterministic set of uniquely checked puzzles and verifies the requested
|
||||||
technique in the logical solve path.
|
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
|
- Added real ARIA row/gridcell semantics, detailed cell descriptions and
|
||||||
non-wrapping Arrow, Home/End, Control/Command + Home/End and Page Up/Down
|
non-wrapping Arrow, Home/End, Control/Command + Home/End and Page Up/Down
|
||||||
keyboard navigation for the puzzle and scratch grids.
|
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,
|
- **Play** — keyboard, mouse and touch entry; multi-cell selection; values,
|
||||||
corner/centre notes and colours; undo/redo; named savepoints, hypothesis
|
corner/centre notes and colours; undo/redo; named savepoints, hypothesis
|
||||||
branches and read-only replay; conflict highlighting; timer and local
|
branches and read-only replay; staged guided hints; candidate maintenance;
|
||||||
progress; optional digit-completion counts, matching-digit highlights and a
|
conflict highlighting; timer and local progress; optional digit-completion
|
||||||
configurable non-constraining aid-mémoire.
|
counts, matching-digit highlights and a configurable non-constraining
|
||||||
- **Set** — givens, metadata, regions and typed constraints; uniqueness checks
|
aid-mémoire.
|
||||||
with overlap-safe cage replacement and selection-based cage removal.
|
- **Set** — givens, metadata, regions and registry-backed constraints;
|
||||||
- **Generate** — seedable, bounded construction for classic and 12 variant
|
overlap-safe clue replacement and removal; quick uniqueness checks; and a
|
||||||
families; independent uniqueness verification and evidence-based difficulty
|
bounded quality lab for ambiguity, contradictions, redundancy and
|
||||||
assessment; deterministic mining for a requested logical technique.
|
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
|
- **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
|
- **Helpers** — generalized sum combinations with positional candidates and
|
||||||
manual eliminations; selected-cell and house candidate-link analysis; Killer
|
manual eliminations; selected-cell and house candidate-link analysis; Killer
|
||||||
combinations, 45-rule residuals, and Kropki, sum-pair or inequality pairs.
|
combinations, 45-rule residuals, and Kropki, sum-pair or inequality pairs.
|
||||||
|
|
||||||
Classic grids and common variants share one bounded puzzle model: irregular
|
## Constraint coverage
|
||||||
regions, diagonals, Killer cages, thermometers, arrows, Kropki and numbered
|
|
||||||
5/10 sum-pair clues, inequalities, renban lines, palindromes, X-sums,
|
Classic grids and variants share one validated puzzle model and constraint
|
||||||
skyscrapers, quadruples, maximum cells, anti-knight, anti-king and
|
registry. The production engine currently supports:
|
||||||
non-consecutive rules. Every local clue can also be required to be false for
|
|
||||||
liar/Wrogn constructions. Fourteen original bundled examples demonstrate every
|
- classic, irregular and extra regions; diagonals; disjoint groups;
|
||||||
supported constraint and are exact-search verified as unique.
|
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
|
The board exposes real row/gridcell semantics and concise per-cell state to
|
||||||
assistive technology, including candidates, notes, colours, conflicts and
|
assistive technology, including candidates, notes, colours, conflicts,
|
||||||
touching variant clues. Arrow keys never wrap; Home/End, Control/Command +
|
touching variant clues, hint previews and quality findings. Arrow keys never
|
||||||
Home/End and Page Up/Down provide row- and grid-level navigation. The same
|
wrap; Home/End, Control/Command + Home/End and Page Up/Down provide row- and
|
||||||
navigation model is available in the aid-mémoire scratch grid.
|
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
|
## Interoperability and visual export
|
||||||
|
|
||||||
The import dialog recognises compact grids, Sudoku Tools JSON/share fragments,
|
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
|
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
|
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,
|
are size/decompression bounded. Before applying one, a structured mapping
|
||||||
and stop with a list when a construct cannot be represented faithfully.
|
preview separates solver-enforced semantics, preserved drawings, retained
|
||||||
|
metadata and warnings.
|
||||||
|
|
||||||
SudokuPad compatibility currently preserves cells, givens and progress, notes,
|
The shared inert visual model supports bounded lines, polylines, rectangles,
|
||||||
regions, metadata, solutions, Killer cages and supported global rules. Penpa+
|
ellipses, circles and text. f-puzzles maps its decorative line, rectangle,
|
||||||
compatibility is intentionally narrower: square, unrotated Sudoku grids with
|
circle and text fields; SCL maps supported lines, underlays, overlays and
|
||||||
givens/progress, thermometers and arrows. Visual-only lines, overlays, custom
|
arrows. Drawings, local source identity and scalar metadata survive domain
|
||||||
symbols and other unsupported geometry are reported rather than ignored.
|
edits, explicit saves, autosaves, reopening, and project/share exports. They
|
||||||
f-puzzles export likewise refuses false clues it cannot preserve.
|
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
|
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
|
PDF entirely in the browser. Each visual uses the same trusted puzzle model and
|
||||||
renders region boundaries, supported variant clues and givens; current values,
|
renders region boundaries, supported variant clues, safe preserved drawings and
|
||||||
notes and cell colours can be included or omitted with the progress switch.
|
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
|
## Privacy, recovery and Library
|
||||||
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
|
|
||||||
|
|
||||||
Puzzle parsing, solving, generation and export happen entirely in the browser.
|
Puzzle parsing, solving, generation and export happen entirely in the browser.
|
||||||
There are no analytics, advertisements, accounts or network-backed puzzle
|
There are no analytics, advertisements, accounts or network-backed puzzle
|
||||||
lookups. Projects and progress are saved only in local IndexedDB after an
|
lookups. Explicit projects are stored in IndexedDB. A separate, debounced
|
||||||
explicit save action and can be cleared from inside the app.
|
autosave slot retains the current puzzle state, progress, aid-mémoire and a
|
||||||
Exported files and share URLs contain the puzzle data you chose to include.
|
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
|
SudokuPad short IDs identify server-hosted records and therefore cannot be
|
||||||
resolved by this deliberately offline application. Embedded f-puzzles payloads
|
resolved by this deliberately offline application. Embedded f-puzzles payloads
|
||||||
and user-provided Sudoku Tools JSON documents are accepted without contacting
|
and user-provided Sudoku Tools JSON documents are accepted without contacting
|
||||||
their originating site. Externally authored puzzle definitions are not bundled.
|
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
|
## Development
|
||||||
|
|
||||||
Requirements: Node.js 22 or newer and npm 11 or newer.
|
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,
|
The command verifies the manifest, types, formatting, tests, production build,
|
||||||
browser workflows and Toolbox contract before creating a deterministic
|
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
|
## Scope and references
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
# Corresponding source
|
# 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
|
The production archive is generated from that tag with the scripts and exact
|
||||||
dependency lock contained in the repository. It contains source identity,
|
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,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.sudoku-tools",
|
"id": "de.add-ideas.sudoku-tools",
|
||||||
"name": "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.",
|
"description": "Set, play, solve and analyse Sudoku puzzles locally in the browser.",
|
||||||
"entry": "./",
|
"entry": "./",
|
||||||
"icon": "./favicon.svg",
|
"icon": "./favicon.svg",
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ if (!force && ((await exists(output)) || (await exists(checksumOutput))))
|
|||||||
const input = path.join(root, "dist");
|
const input = path.join(root, "dist");
|
||||||
for (const name of [
|
for (const name of [
|
||||||
"index.html",
|
"index.html",
|
||||||
|
"manifest.webmanifest",
|
||||||
|
"sw.js",
|
||||||
"toolbox-app.json",
|
"toolbox-app.json",
|
||||||
"favicon.svg",
|
"favicon.svg",
|
||||||
"README.md",
|
"README.md",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const mediaTypes = new Map([
|
|||||||
[".html", "text/html; charset=utf-8"],
|
[".html", "text/html; charset=utf-8"],
|
||||||
[".js", "text/javascript; charset=utf-8"],
|
[".js", "text/javascript; charset=utf-8"],
|
||||||
[".json", "application/json; charset=utf-8"],
|
[".json", "application/json; charset=utf-8"],
|
||||||
|
[".webmanifest", "application/manifest+json; charset=utf-8"],
|
||||||
[".svg", "image/svg+xml"],
|
[".svg", "image/svg+xml"],
|
||||||
[".md", "text/markdown; charset=utf-8"],
|
[".md", "text/markdown; charset=utf-8"],
|
||||||
[".txt", "text/plain; 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 { useId, useState } from "react";
|
||||||
import { cellsFormQuadruple } from "../domain/geometry";
|
import {
|
||||||
import type { PuzzleDefinition, VariantConstraint } from "../domain/types";
|
cellsFormQuadruple,
|
||||||
|
classicRegions,
|
||||||
|
constraintLabel,
|
||||||
|
constraintMetadata,
|
||||||
|
littleKillerCells,
|
||||||
|
littleKillerDirectionEntersGrid,
|
||||||
|
validatePuzzle,
|
||||||
|
} from "../domain";
|
||||||
|
import type {
|
||||||
|
LittleKillerDirection,
|
||||||
|
OutsideClueSide,
|
||||||
|
PuzzleDefinition,
|
||||||
|
VariantConstraint,
|
||||||
|
} from "../domain/types";
|
||||||
import {
|
import {
|
||||||
removeKillerCagesAtCells,
|
removeKillerCagesAtCells,
|
||||||
replaceOverlappingKillerCages,
|
replaceOverlappingKillerCages,
|
||||||
@@ -17,6 +30,65 @@ interface ConstraintEditorProps {
|
|||||||
busy: boolean;
|
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) {
|
function describeConstraint(constraint: VariantConstraint, size: number) {
|
||||||
const cell = (index: number) =>
|
const cell = (index: number) =>
|
||||||
`r${String(Math.floor(index / size) + 1)}c${String((index % size) + 1)}`;
|
`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";
|
return "anti-king";
|
||||||
case "non-consecutive":
|
case "non-consecutive":
|
||||||
return "non-consecutive";
|
return "non-consecutive";
|
||||||
|
case "disjoint-groups":
|
||||||
|
return "disjoint groups · matching box positions do not repeat";
|
||||||
case "killer-cage":
|
case "killer-cage":
|
||||||
return marked(
|
return marked(
|
||||||
`${String(constraint.sum)} cage · ${String(constraint.cells.length)} cells${constraint.noRepeat === false ? " · repeats allowed" : ""}`,
|
`${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":
|
case "maximum":
|
||||||
return marked(`maximum · ${cell(constraint.cell)}`);
|
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 {
|
function supportsPolarity(constraint: VariantConstraint): boolean {
|
||||||
return !["diagonal", "anti-knight", "anti-king", "non-consecutive"].includes(
|
return constraintMetadata(constraint.type).negatable;
|
||||||
constraint.type,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseClueDigits(text: string, size: number): number[] {
|
function parseClueDigits(text: string, size: number): number[] {
|
||||||
@@ -109,21 +233,31 @@ export function ConstraintEditor({
|
|||||||
const [region, setRegion] = useState(1);
|
const [region, setRegion] = useState(1);
|
||||||
const [newCluesAreFalse, setNewCluesAreFalse] = useState(false);
|
const [newCluesAreFalse, setNewCluesAreFalse] = useState(false);
|
||||||
const [quadrupleDigits, setQuadrupleDigits] = useState("1, 2, 3");
|
const [quadrupleDigits, setQuadrupleDigits] = useState("1, 2, 3");
|
||||||
const [outsideType, setOutsideType] = useState<"x-sum" | "skyscraper">(
|
const [outsideType, setOutsideType] =
|
||||||
"x-sum",
|
useState<OutsideConstraintType>("x-sum");
|
||||||
);
|
const [outsideSide, setOutsideSide] = useState<OutsideClueSide>("top");
|
||||||
const [outsideSide, setOutsideSide] = useState<
|
const [littleKillerDirection, setLittleKillerDirection] =
|
||||||
"top" | "right" | "bottom" | "left"
|
useState<LittleKillerDirection>("down-right");
|
||||||
>("top");
|
|
||||||
const [outsideLine, setOutsideLine] = useState(1);
|
const [outsideLine, setOutsideLine] = useState(1);
|
||||||
const [outsideValue, setOutsideValue] = useState(3);
|
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 constraints = puzzle.constraints ?? [];
|
||||||
|
|
||||||
const append = (constraint: VariantConstraint) =>
|
const append = (
|
||||||
|
constraint: VariantConstraint,
|
||||||
|
replace: (candidate: VariantConstraint) => boolean = () => false,
|
||||||
|
) =>
|
||||||
onChange({
|
onChange({
|
||||||
...puzzle,
|
...puzzle,
|
||||||
constraints: [
|
constraints: [
|
||||||
...constraints,
|
...constraints.filter((candidate) => !replace(candidate)),
|
||||||
supportsPolarity(constraint) && newCluesAreFalse
|
supportsPolarity(constraint) && newCluesAreFalse
|
||||||
? ({ ...constraint, negated: true } as VariantConstraint)
|
? ({ ...constraint, negated: true } as VariantConstraint)
|
||||||
: constraint,
|
: constraint,
|
||||||
@@ -146,6 +280,7 @@ export function ConstraintEditor({
|
|||||||
? cageSum >= 1 && cageSum <= puzzle.size ** 3
|
? cageSum >= 1 && cageSum <= puzzle.size ** 3
|
||||||
: cageSum >= minimumCageSum && cageSum <= maximumCageSum);
|
: cageSum >= minimumCageSum && cageSum <= maximumCageSum);
|
||||||
const parsedQuadrupleDigits = parseClueDigits(quadrupleDigits, puzzle.size);
|
const parsedQuadrupleDigits = parseClueDigits(quadrupleDigits, puzzle.size);
|
||||||
|
const selectionIsUnique = new Set(selection).size === selection.length;
|
||||||
const validQuadruple =
|
const validQuadruple =
|
||||||
cellsFormQuadruple(puzzle.size, selection) &&
|
cellsFormQuadruple(puzzle.size, selection) &&
|
||||||
parsedQuadrupleDigits.length >= 1 &&
|
parsedQuadrupleDigits.length >= 1 &&
|
||||||
@@ -155,9 +290,95 @@ export function ConstraintEditor({
|
|||||||
(digit) => Number.isInteger(digit) && digit >= 1 && digit <= puzzle.size,
|
(digit) => Number.isInteger(digit) && digit >= 1 && digit <= puzzle.size,
|
||||||
);
|
);
|
||||||
const selectedCageExists = selectionTouchesKillerCage(constraints, selection);
|
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 = (
|
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);
|
const exists = constraints.some((constraint) => constraint.type === type);
|
||||||
onChange({
|
onChange({
|
||||||
@@ -180,7 +401,11 @@ export function ConstraintEditor({
|
|||||||
Grid
|
Grid
|
||||||
<select
|
<select
|
||||||
value={puzzle.size}
|
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="4">4 × 4</option>
|
||||||
<option value="6">6 × 6</option>
|
<option value="6">6 × 6</option>
|
||||||
@@ -419,10 +644,335 @@ export function ConstraintEditor({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!need(1)}
|
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
|
Maximum cell
|
||||||
</button>
|
</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>
|
||||||
<div className="inline-fields">
|
<div className="inline-fields">
|
||||||
<label className="compact-field">
|
<label className="compact-field">
|
||||||
@@ -478,10 +1028,11 @@ export function ConstraintEditor({
|
|||||||
|
|
||||||
<section className="panel-section">
|
<section className="panel-section">
|
||||||
<p className="eyebrow">Outside clues</p>
|
<p className="eyebrow">Outside clues</p>
|
||||||
<h3>X-sums and skyscrapers</h3>
|
<h3>Edge and diagonal sums</h3>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Choose the edge and row or column. Σ badges are X-sums; ▥ badges are
|
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>
|
</p>
|
||||||
<div className="inline-fields outside-clue-fields">
|
<div className="inline-fields outside-clue-fields">
|
||||||
<label className="compact-field">
|
<label className="compact-field">
|
||||||
@@ -489,22 +1040,28 @@ export function ConstraintEditor({
|
|||||||
<select
|
<select
|
||||||
value={outsideType}
|
value={outsideType}
|
||||||
onChange={(event) =>
|
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="x-sum">X-sum</option>
|
||||||
<option value="skyscraper">Skyscraper</option>
|
<option value="skyscraper">Skyscraper</option>
|
||||||
|
<option value="little-killer">Little killer</option>
|
||||||
|
<option value="sandwich">Sandwich sum</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="compact-field">
|
<label className="compact-field">
|
||||||
Side
|
Side
|
||||||
<select
|
<select
|
||||||
value={outsideSide}
|
value={outsideSide}
|
||||||
onChange={(event) =>
|
onChange={(event) => {
|
||||||
setOutsideSide(
|
const side = event.target.value as OutsideClueSide;
|
||||||
event.target.value as "top" | "right" | "bottom" | "left",
|
setOutsideSide(side);
|
||||||
)
|
if (
|
||||||
}
|
!littleKillerDirectionEntersGrid(side, littleKillerDirection)
|
||||||
|
) {
|
||||||
|
setLittleKillerDirection(firstLittleKillerDirection(side));
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<option value="top">Top</option>
|
<option value="top">Top</option>
|
||||||
<option value="right">Right</option>
|
<option value="right">Right</option>
|
||||||
@@ -522,52 +1079,78 @@ export function ConstraintEditor({
|
|||||||
onChange={(event) => setOutsideLine(Number(event.target.value))}
|
onChange={(event) => setOutsideLine(Number(event.target.value))}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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">
|
<label className="compact-field">
|
||||||
Clue
|
{outsideType === "skyscraper" ? "Count" : "Sum"}
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min={outsideMinimum}
|
||||||
max={
|
max={outsideMaximum}
|
||||||
newCluesAreFalse
|
|
||||||
? puzzle.size ** 4
|
|
||||||
: outsideType === "x-sum"
|
|
||||||
? (puzzle.size * (puzzle.size + 1)) / 2
|
|
||||||
: puzzle.size
|
|
||||||
}
|
|
||||||
value={outsideValue}
|
value={outsideValue}
|
||||||
onChange={(event) => setOutsideValue(Number(event.target.value))}
|
onChange={(event) => setOutsideValue(Number(event.target.value))}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={
|
disabled={!validOutsideClue}
|
||||||
!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)
|
|
||||||
}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const clue: VariantConstraint =
|
const clue: VariantConstraint = (() => {
|
||||||
outsideType === "x-sum"
|
switch (outsideType) {
|
||||||
? {
|
case "x-sum":
|
||||||
|
return {
|
||||||
type: "x-sum",
|
type: "x-sum",
|
||||||
side: outsideSide,
|
side: outsideSide,
|
||||||
index: outsideLine - 1,
|
index: outsideLine - 1,
|
||||||
sum: outsideValue,
|
sum: outsideValue,
|
||||||
}
|
};
|
||||||
: {
|
case "skyscraper":
|
||||||
|
return {
|
||||||
type: "skyscraper",
|
type: "skyscraper",
|
||||||
side: outsideSide,
|
side: outsideSide,
|
||||||
index: outsideLine - 1,
|
index: outsideLine - 1,
|
||||||
count: outsideValue,
|
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(
|
const withoutExisting = constraints.filter(
|
||||||
(constraint) =>
|
(constraint) =>
|
||||||
constraint.type !== outsideType ||
|
constraint.type !== outsideType ||
|
||||||
@@ -593,25 +1176,30 @@ export function ConstraintEditor({
|
|||||||
<section className="panel-section">
|
<section className="panel-section">
|
||||||
<p className="eyebrow">Global rules</p>
|
<p className="eyebrow">Global rules</p>
|
||||||
<div className="button-grid">
|
<div className="button-grid">
|
||||||
{(["anti-knight", "anti-king", "non-consecutive"] as const).map(
|
{(
|
||||||
(type) => (
|
[
|
||||||
<button
|
"anti-knight",
|
||||||
key={type}
|
"anti-king",
|
||||||
type="button"
|
"non-consecutive",
|
||||||
className={
|
"disjoint-groups",
|
||||||
constraints.some((constraint) => constraint.type === type)
|
] as const
|
||||||
? "is-active"
|
).map((type) => (
|
||||||
: ""
|
<button
|
||||||
}
|
key={type}
|
||||||
aria-pressed={constraints.some(
|
type="button"
|
||||||
(constraint) => constraint.type === type,
|
className={
|
||||||
)}
|
constraints.some((constraint) => constraint.type === type)
|
||||||
onClick={() => toggleGlobal(type)}
|
? "is-active"
|
||||||
>
|
: ""
|
||||||
{type}
|
}
|
||||||
</button>
|
aria-pressed={constraints.some(
|
||||||
),
|
(constraint) => constraint.type === type,
|
||||||
)}
|
)}
|
||||||
|
onClick={() => toggleGlobal(type)}
|
||||||
|
>
|
||||||
|
{constraintLabel(type)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
{(["main", "anti"] as const).map((direction) => {
|
{(["main", "anti"] as const).map((direction) => {
|
||||||
const active = constraints.some(
|
const active = constraints.some(
|
||||||
(constraint) =>
|
(constraint) =>
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ import { useMemo, useState, type FormEvent } from "react";
|
|||||||
import {
|
import {
|
||||||
GENERATOR_VARIANTS,
|
GENERATOR_VARIANTS,
|
||||||
PRACTICE_TECHNIQUES,
|
PRACTICE_TECHNIQUES,
|
||||||
|
type BatchRanking,
|
||||||
|
type ClueSymmetry,
|
||||||
|
type ConstraintDensity,
|
||||||
type DifficultyAssessment,
|
type DifficultyAssessment,
|
||||||
|
type GeneratedVariantBatch,
|
||||||
type GeneratedVariantPuzzle,
|
type GeneratedVariantPuzzle,
|
||||||
|
type GenerateVariantBatchOptions,
|
||||||
type GenerationDifficultyTarget,
|
type GenerationDifficultyTarget,
|
||||||
type GeneratorVariant,
|
type GeneratorVariant,
|
||||||
type GenerateVariantOptions,
|
type GenerateVariantOptions,
|
||||||
@@ -31,13 +36,21 @@ export function GeneratorWorkspace({
|
|||||||
busy,
|
busy,
|
||||||
assessment,
|
assessment,
|
||||||
generation,
|
generation,
|
||||||
|
batch,
|
||||||
onGenerate,
|
onGenerate,
|
||||||
|
onGenerateBatch,
|
||||||
|
onSelectGenerated,
|
||||||
|
onCancel,
|
||||||
onRate,
|
onRate,
|
||||||
}: {
|
}: {
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
assessment?: DifficultyAssessment;
|
assessment?: DifficultyAssessment;
|
||||||
generation?: GeneratedVariantPuzzle;
|
generation?: GeneratedVariantPuzzle;
|
||||||
|
batch?: GeneratedVariantBatch;
|
||||||
onGenerate: (options: GenerateVariantOptions) => void;
|
onGenerate: (options: GenerateVariantOptions) => void;
|
||||||
|
onGenerateBatch?: (options: GenerateVariantBatchOptions) => void;
|
||||||
|
onSelectGenerated?: (generation: GeneratedVariantPuzzle) => void;
|
||||||
|
onCancel?: () => void;
|
||||||
onRate: () => void;
|
onRate: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [variant, setVariant] = useState<GeneratorVariant>("classic");
|
const [variant, setVariant] = useState<GeneratorVariant>("classic");
|
||||||
@@ -48,35 +61,107 @@ export function GeneratorWorkspace({
|
|||||||
const [size, setSize] = useState(9);
|
const [size, setSize] = useState(9);
|
||||||
const [targetDifficulty, setTargetDifficulty] =
|
const [targetDifficulty, setTargetDifficulty] =
|
||||||
useState<GenerationDifficultyTarget>("medium");
|
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 [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 [seed, setSeed] = useState("");
|
||||||
const [requiredTechnique, setRequiredTechnique] = useState<
|
const [requiredTechnique, setRequiredTechnique] = useState<
|
||||||
PracticeTechnique | ""
|
PracticeTechnique | ""
|
||||||
>("");
|
>("");
|
||||||
const [maxTechniqueAttempts, setMaxTechniqueAttempts] = useState(10);
|
const [maxTechniqueAttempts, setMaxTechniqueAttempts] = useState(10);
|
||||||
const usesMarkingCount = ![
|
const [minimumTechniqueCount, setMinimumTechniqueCount] = useState(1);
|
||||||
"classic",
|
const [maximumTechniqueCount, setMaximumTechniqueCount] = useState("");
|
||||||
"diagonal",
|
const [forbiddenTechnique, setForbiddenTechnique] = useState<
|
||||||
"killer",
|
PracticeTechnique | ""
|
||||||
"anti-knight",
|
>("");
|
||||||
"anti-king",
|
const [hardestTechnique, setHardestTechnique] = useState<
|
||||||
"non-consecutive",
|
PracticeTechnique | ""
|
||||||
].includes(variant);
|
>("");
|
||||||
|
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>) => {
|
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onGenerate({
|
const profileCounts =
|
||||||
|
requiredTechnique !== "" &&
|
||||||
|
(minimumTechniqueCount !== 1 || maximumTechniqueCount !== "")
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
technique: requiredTechnique,
|
||||||
|
min: minimumTechniqueCount,
|
||||||
|
...(maximumTechniqueCount === ""
|
||||||
|
? {}
|
||||||
|
: { max: Number(maximumTechniqueCount) }),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
const options: GenerateVariantBatchOptions = {
|
||||||
variant,
|
variant,
|
||||||
|
...(additionalVariants.length === 0
|
||||||
|
? {}
|
||||||
|
: { variants: selectedFamilies }),
|
||||||
size,
|
size,
|
||||||
targetDifficulty,
|
targetDifficulty,
|
||||||
symmetry,
|
symmetry,
|
||||||
...(usesMarkingCount ? { constraintCount } : {}),
|
...(usesMarkingCount ? { constraintCount } : {}),
|
||||||
...(requiredTechnique === ""
|
...(usesConstraintDensity && constraintDensity !== "balanced"
|
||||||
? {}
|
? { constraintDensity }
|
||||||
: { requiredTechnique, maxTechniqueAttempts }),
|
: {}),
|
||||||
|
...(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)}`,
|
seed: seed.trim() || `local-${Date.now().toString(36)}`,
|
||||||
});
|
};
|
||||||
|
if (batchSize > 1 && onGenerateBatch !== undefined) {
|
||||||
|
onGenerateBatch({ ...options, batchSize, ranking: batchRanking });
|
||||||
|
} else {
|
||||||
|
onGenerate(options);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -103,6 +188,16 @@ export function GeneratorWorkspace({
|
|||||||
(item) => item.id === next,
|
(item) => item.id === next,
|
||||||
)!;
|
)!;
|
||||||
setVariant(next);
|
setVariant(next);
|
||||||
|
setAdditionalVariants((current) =>
|
||||||
|
current.filter(
|
||||||
|
(family) =>
|
||||||
|
family !== next &&
|
||||||
|
(
|
||||||
|
GENERATOR_VARIANTS.find(({ id }) => id === family)
|
||||||
|
?.supportedSizes as readonly number[]
|
||||||
|
).includes(size),
|
||||||
|
),
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
!(
|
!(
|
||||||
nextDefinition.supportedSizes as readonly number[]
|
nextDefinition.supportedSizes as readonly number[]
|
||||||
@@ -128,7 +223,18 @@ export function GeneratorWorkspace({
|
|||||||
Grid
|
Grid
|
||||||
<select
|
<select
|
||||||
value={size}
|
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) => (
|
{definition.supportedSizes.map((supportedSize) => (
|
||||||
<option key={supportedSize} value={supportedSize}>
|
<option key={supportedSize} value={supportedSize}>
|
||||||
@@ -137,6 +243,35 @@ export function GeneratorWorkspace({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
<label>
|
||||||
Requested profile
|
Requested profile
|
||||||
<select
|
<select
|
||||||
@@ -159,10 +294,15 @@ export function GeneratorWorkspace({
|
|||||||
<select
|
<select
|
||||||
value={symmetry}
|
value={symmetry}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setSymmetry(event.target.value as "none" | "rotational")
|
setSymmetry(event.target.value as ClueSymmetry)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="rotational">Rotational</option>
|
<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>
|
<option value="none">None</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@@ -180,6 +320,54 @@ export function GeneratorWorkspace({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</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>
|
<label>
|
||||||
Seed
|
Seed
|
||||||
<input
|
<input
|
||||||
@@ -202,7 +390,8 @@ export function GeneratorWorkspace({
|
|||||||
<option value="">No required technique</option>
|
<option value="">No required technique</option>
|
||||||
{PRACTICE_TECHNIQUES.filter(
|
{PRACTICE_TECHNIQUES.filter(
|
||||||
(technique) =>
|
(technique) =>
|
||||||
technique !== "killer-cage" || variant === "killer",
|
technique !== "killer-cage" ||
|
||||||
|
selectedFamilies.includes("killer"),
|
||||||
).map((technique) => (
|
).map((technique) => (
|
||||||
<option key={technique} value={technique}>
|
<option key={technique} value={technique}>
|
||||||
{techniqueLabel(technique)}
|
{techniqueLabel(technique)}
|
||||||
@@ -211,6 +400,75 @@ export function GeneratorWorkspace({
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{requiredTechnique !== "" && (
|
{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>
|
<label>
|
||||||
Mining attempts
|
Mining attempts
|
||||||
<input
|
<input
|
||||||
@@ -228,8 +486,17 @@ export function GeneratorWorkspace({
|
|||||||
<p className="generator-description">{definition.description}</p>
|
<p className="generator-description">{definition.description}</p>
|
||||||
<div className="action-row">
|
<div className="action-row">
|
||||||
<button className="primary-button" type="submit" disabled={busy}>
|
<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>
|
</button>
|
||||||
|
{busy && onCancel !== undefined && (
|
||||||
|
<button type="button" onClick={onCancel}>
|
||||||
|
Cancel generation
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button type="button" disabled={busy} onClick={onRate}>
|
<button type="button" disabled={busy} onClick={onRate}>
|
||||||
Rate current puzzle
|
Rate current puzzle
|
||||||
</button>
|
</button>
|
||||||
@@ -237,8 +504,8 @@ export function GeneratorWorkspace({
|
|||||||
<p className="muted">
|
<p className="muted">
|
||||||
Difficulty is an estimate from reproducible solver evidence, not a
|
Difficulty is an estimate from reproducible solver evidence, not a
|
||||||
universal promise. Technique practice only returns a puzzle whose
|
universal promise. Technique practice only returns a puzzle whose
|
||||||
logical path contains the requested move. Uniqueness is never claimed
|
complete independently analysed path matches every requested rule.
|
||||||
after a safety limit.
|
Uniqueness and minimality are never claimed after a safety limit.
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -291,6 +558,16 @@ export function GeneratorWorkspace({
|
|||||||
</strong>
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{generation && (
|
||||||
|
<span>
|
||||||
|
Minimality <strong>{generation.minimality.status}</strong>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{generation?.techniqueProfile && (
|
||||||
|
<span>
|
||||||
|
Profile <strong>{generation.techniqueProfile.status}</strong>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p>{assessment.summary}</p>
|
<p>{assessment.summary}</p>
|
||||||
{generation && (
|
{generation && (
|
||||||
@@ -298,8 +575,139 @@ export function GeneratorWorkspace({
|
|||||||
Seed: <code>{String(generation.seed)}</code> · Found in{" "}
|
Seed: <code>{String(generation.seed)}</code> · Found in{" "}
|
||||||
{generation.generationAttempts} attempt
|
{generation.generationAttempts} attempt
|
||||||
{generation.generationAttempts === 1 ? "" : "s"}.
|
{generation.generationAttempts === 1 ? "" : "s"}.
|
||||||
|
{generation.families.length > 1
|
||||||
|
? ` Families: ${generation.families.join(", ")}.`
|
||||||
|
: ""}
|
||||||
</p>
|
</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>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
<h3>Five complementary workspaces</h3>
|
||||||
<p>
|
<p>
|
||||||
<strong>Play</strong> keeps values, two kinds of notes, colours,
|
<strong>Play</strong> keeps values, two kinds of notes, colours,
|
||||||
branches, replay and elapsed time. <strong>Set</strong> edits clues
|
branches, replay, guided hints and elapsed time.{" "}
|
||||||
and constraints. <strong>Generate</strong> constructs and rates
|
<strong>Set</strong> edits registry-backed clues and runs bounded
|
||||||
bounded, seedable variants. <strong>Solve</strong> explains logical
|
setter-quality checks. <strong>Generate</strong> constructs and
|
||||||
steps and can verify uniqueness. <strong>Helpers</strong> answers
|
ranks bounded, seedable single or batch variants.{" "}
|
||||||
focused sum, candidate and relation questions without changing the
|
<strong>Solve</strong> explains logical steps and can verify
|
||||||
board.
|
uniqueness. <strong>Helpers</strong> answers focused sum, candidate
|
||||||
|
and relation questions without changing the board.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
@@ -65,16 +66,63 @@ export function HelpDialog({
|
|||||||
<dt>Ctrl/⌘ + click</dt>
|
<dt>Ctrl/⌘ + click</dt>
|
||||||
<dd>Highlight every placed copy of that digit</dd>
|
<dd>Highlight every placed copy of that digit</dd>
|
||||||
</div>
|
</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>
|
</dl>
|
||||||
</section>
|
</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>
|
<section>
|
||||||
<h3>Hints and solutions</h3>
|
<h3>Hints and solutions</h3>
|
||||||
<p>
|
<p>
|
||||||
Candidate legality is computed independently from handwritten notes.
|
A guided hint reveals where to look, the technique, its reasoning
|
||||||
Logical deductions report their premises, affected houses,
|
and an effects preview in separate stages; the board changes only
|
||||||
placements and eliminations. Exact search is separately labelled; it
|
after <strong>Apply this step</strong>. Candidate legality is
|
||||||
proves feasibility or uniqueness but is not presented as a human
|
computed independently from handwritten notes. Controls can fill
|
||||||
explanation.
|
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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
@@ -84,9 +132,10 @@ export function HelpDialog({
|
|||||||
checkpoint, isolate a hypothesis, or inspect an earlier grid.
|
checkpoint, isolate a hypothesis, or inspect an earlier grid.
|
||||||
Discarding a hypothesis restores its exact starting state but keeps
|
Discarding a hypothesis restores its exact starting state but keeps
|
||||||
the abandoned path available in replay. Replayed grids are read-only
|
the abandoned path available in replay. Replayed grids are read-only
|
||||||
until you return live or deliberately branch from that step. This
|
until you return live or deliberately branch from that step.
|
||||||
working history stays in the current browser session; save the
|
Validated history is included in local autosaves and explicit
|
||||||
puzzle to the Library for durable puzzle progress.
|
Library saves, so restoring that progress also restores its
|
||||||
|
savepoints and branches.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
@@ -115,25 +164,44 @@ export function HelpDialog({
|
|||||||
<section>
|
<section>
|
||||||
<h3>Variant and false clues</h3>
|
<h3>Variant and false clues</h3>
|
||||||
<p>
|
<p>
|
||||||
The setter supports cages, lines, pair clues, X-sums, skyscrapers,
|
The shared registry covers classic/irregular/extra regions;
|
||||||
quadruples and maximum cells. Enable Wrogn mode to make new local
|
diagonal, anti, disjoint and non-consecutive rules; cages, parity,
|
||||||
clues false, or switch existing clues individually or as a batch.
|
extrema and quadruples; thermo, arrow, renban, palindrome, between,
|
||||||
Red dashed artwork and a ≠ mark identify false clues; Σ and ▥
|
whisper, region-sum, modular, entropic, zipper and double-arrow
|
||||||
identify X-sum and skyscraper readings. A false multi-cell clue
|
lines; Kropki, 5/10 and inequality pairs; X-sum, skyscraper,
|
||||||
often stays undecided until enough of its cells are known, so exact
|
little-killer and sandwich clues; clone regions and row/column/box
|
||||||
searches for dense liar puzzles can be substantially slower.
|
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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<h3>Generation and ratings</h3>
|
<h3>Generation and ratings</h3>
|
||||||
<p>
|
<p>
|
||||||
Generation runs in a worker with explicit time and search limits. A
|
Generation runs in a worker with explicit time and search limits. It
|
||||||
requested level guides clue removal; the reported rating is then
|
supports single or deterministic batch generation, compatible mixed
|
||||||
calculated independently from logical techniques, clue load and
|
families, sparse/balanced/dense local constraints and several
|
||||||
bounded exact-search evidence. Practice mode deterministically mines
|
rotational, reflection and diagonal clue symmetries. Minimal-givens
|
||||||
several candidates and succeeds only when the analysed solve path
|
mode reports proof or the exact unknown reason; individual
|
||||||
contains the requested technique. A limit never becomes a false
|
minimisation can break the requested visual symmetry and reports
|
||||||
uniqueness claim.
|
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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
@@ -142,19 +210,49 @@ export function HelpDialog({
|
|||||||
Files, text, solving and generation stay in this browser. Compact
|
Files, text, solving and generation stay in this browser. Compact
|
||||||
grids, project JSON, share fragments, supported f-puzzles,
|
grids, project JSON, share fragments, supported f-puzzles,
|
||||||
SudokuPad/CTC inline data and supported Penpa+ long links are
|
SudokuPad/CTC inline data and supported Penpa+ long links are
|
||||||
decoded locally. Server short IDs are intentionally rejected. SVG,
|
decoded locally. Server short IDs are intentionally rejected. The
|
||||||
PNG and PDF rendering also stays in the browser. Review an export
|
compatibility check separates mapped Sudoku semantics, preserved
|
||||||
before sharing: titles, authors, rules, solutions, progress and
|
inert drawings, retained metadata and warnings before import. Source
|
||||||
aid-mémoire entries may be included.
|
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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<h3>Screen-reader detail</h3>
|
<h3>Screen-reader detail</h3>
|
||||||
<p>
|
<p>
|
||||||
Each Sudoku cell reports its row, column, region, value or notes,
|
Each visible Sudoku cell reports its row, column, region, value,
|
||||||
colour, conflict state, candidate highlights and touching variant
|
colour-and-pattern mark, conflict state and touching variant clues.
|
||||||
clues. The board and aid-mémoire use real row and gridcell roles;
|
Candidate detail can be set to <strong>Off</strong>, concise counts,
|
||||||
their keyboard instructions are attached to the grids.
|
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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import type { PuzzleDefinition } from "../domain/types";
|
|||||||
import { normalizePuzzle } from "../domain/validation";
|
import { normalizePuzzle } from "../domain/validation";
|
||||||
import {
|
import {
|
||||||
encodePuzzleHash,
|
encodePuzzleHash,
|
||||||
|
exportSudokuPadJson,
|
||||||
|
exportSudokuPadPayload,
|
||||||
exportFpuzzlesJson,
|
exportFpuzzlesJson,
|
||||||
exportFpuzzlesUrl,
|
exportFpuzzlesUrl,
|
||||||
|
extractPreservedDocumentExtras,
|
||||||
fromDomainPuzzle,
|
fromDomainPuzzle,
|
||||||
importPuzzle,
|
importPuzzle,
|
||||||
renderPuzzlePdf,
|
renderPuzzlePdf,
|
||||||
@@ -13,6 +16,9 @@ import {
|
|||||||
serializePlainGrid,
|
serializePlainGrid,
|
||||||
serializeSudokuDocument,
|
serializeSudokuDocument,
|
||||||
toDomainPuzzle,
|
toDomainPuzzle,
|
||||||
|
type PreservedSudokuDocumentExtras,
|
||||||
|
type PuzzleImportMappingPreview,
|
||||||
|
type PuzzleImportResult,
|
||||||
type SudokuDocument,
|
type SudokuDocument,
|
||||||
} from "../formats";
|
} from "../formats";
|
||||||
import type { PlaySession } from "../state/session";
|
import type { PlaySession } from "../state/session";
|
||||||
@@ -24,8 +30,9 @@ function withProgress(
|
|||||||
puzzle: PuzzleDefinition,
|
puzzle: PuzzleDefinition,
|
||||||
session: PlaySession,
|
session: PlaySession,
|
||||||
aidMemoire?: PortableAidMemoire,
|
aidMemoire?: PortableAidMemoire,
|
||||||
|
preservedExtras?: PreservedSudokuDocumentExtras,
|
||||||
): SudokuDocument {
|
): SudokuDocument {
|
||||||
const base = fromDomainPuzzle(puzzle);
|
const base = fromDomainPuzzle(puzzle, preservedExtras);
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
values: [...session.values],
|
values: [...session.values],
|
||||||
@@ -64,11 +71,13 @@ function checkedPuzzle(document: SudokuDocument): PuzzleDefinition {
|
|||||||
return normalizePuzzle(toDomainPuzzle(document) as PuzzleDefinition);
|
return normalizePuzzle(toDomainPuzzle(document) as PuzzleDefinition);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportExportDialogProps {
|
export interface ImportExportDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
puzzle: PuzzleDefinition;
|
puzzle: PuzzleDefinition;
|
||||||
session: PlaySession;
|
session: PlaySession;
|
||||||
aidMemoire?: PortableAidMemoire;
|
aidMemoire?: PortableAidMemoire;
|
||||||
|
/** Source-only data retained while the domain puzzle is edited. */
|
||||||
|
preservedExtras?: PreservedSudokuDocumentExtras;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onImport: (
|
onImport: (
|
||||||
puzzle: PuzzleDefinition,
|
puzzle: PuzzleDefinition,
|
||||||
@@ -82,14 +91,41 @@ interface ImportExportDialogProps {
|
|||||||
| "elapsedMs"
|
| "elapsedMs"
|
||||||
| "aidMemoire"
|
| "aidMemoire"
|
||||||
>,
|
>,
|
||||||
|
preservedExtras?: PreservedSudokuDocumentExtras,
|
||||||
) => void;
|
) => 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({
|
export function ImportExportDialog({
|
||||||
open,
|
open,
|
||||||
puzzle,
|
puzzle,
|
||||||
session,
|
session,
|
||||||
aidMemoire,
|
aidMemoire,
|
||||||
|
preservedExtras,
|
||||||
onClose,
|
onClose,
|
||||||
onImport,
|
onImport,
|
||||||
}: ImportExportDialogProps) {
|
}: ImportExportDialogProps) {
|
||||||
@@ -97,13 +133,17 @@ export function ImportExportDialog({
|
|||||||
const [feedback, setFeedback] = useState("");
|
const [feedback, setFeedback] = useState("");
|
||||||
const [includeProgress, setIncludeProgress] = useState(true);
|
const [includeProgress, setIncludeProgress] = useState(true);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [inspected, setInspected] = useState<{
|
||||||
|
readonly input: string;
|
||||||
|
readonly result: PuzzleImportResult<SudokuDocument>;
|
||||||
|
}>();
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const documentValue = useMemo(
|
const documentValue = useMemo(
|
||||||
() =>
|
() =>
|
||||||
includeProgress
|
includeProgress
|
||||||
? withProgress(puzzle, session, aidMemoire)
|
? withProgress(puzzle, session, aidMemoire, preservedExtras)
|
||||||
: fromDomainPuzzle(puzzle),
|
: fromDomainPuzzle(puzzle, preservedExtras),
|
||||||
[aidMemoire, includeProgress, puzzle, session],
|
[aidMemoire, includeProgress, preservedExtras, puzzle, session],
|
||||||
);
|
);
|
||||||
|
|
||||||
const copy = async (value: string, label: string) => {
|
const copy = async (value: string, label: string) => {
|
||||||
@@ -147,6 +187,7 @@ export function ImportExportDialog({
|
|||||||
try {
|
try {
|
||||||
const result = await importPuzzle(input);
|
const result = await importPuzzle(input);
|
||||||
checkedPuzzle(result.document);
|
checkedPuzzle(result.document);
|
||||||
|
setInspected({ input, result });
|
||||||
const givenCount = result.document.givens.filter(
|
const givenCount = result.document.givens.filter(
|
||||||
(value) => value !== 0,
|
(value) => value !== 0,
|
||||||
).length;
|
).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.`,
|
`${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) {
|
} catch (error) {
|
||||||
|
setInspected(undefined);
|
||||||
setFeedback(errorMessage(error, "The puzzle could not be inspected."));
|
setFeedback(errorMessage(error, "The puzzle could not be inspected."));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -163,17 +205,24 @@ export function ImportExportDialog({
|
|||||||
const applyImport = async () => {
|
const applyImport = async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const result = await importPuzzle(input);
|
const result =
|
||||||
|
inspected?.input === input
|
||||||
|
? inspected.result
|
||||||
|
: await importPuzzle(input);
|
||||||
const parsed = result.document;
|
const parsed = result.document;
|
||||||
onImport(checkedPuzzle(parsed), {
|
onImport(
|
||||||
values: parsed.values,
|
checkedPuzzle(parsed),
|
||||||
cornerMarks: parsed.cornerMarks,
|
{
|
||||||
centerMarks: parsed.centerMarks,
|
values: parsed.values,
|
||||||
candidates: parsed.candidates,
|
cornerMarks: parsed.cornerMarks,
|
||||||
colors: parsed.colors,
|
centerMarks: parsed.centerMarks,
|
||||||
elapsedMs: parsed.elapsedMs,
|
candidates: parsed.candidates,
|
||||||
aidMemoire: parsed.aidMemoire,
|
colors: parsed.colors,
|
||||||
});
|
elapsedMs: parsed.elapsedMs,
|
||||||
|
aidMemoire: parsed.aidMemoire,
|
||||||
|
},
|
||||||
|
extractPreservedDocumentExtras(parsed),
|
||||||
|
);
|
||||||
setFeedback(`${result.label} imported locally.`);
|
setFeedback(`${result.label} imported locally.`);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -239,6 +288,7 @@ export function ImportExportDialog({
|
|||||||
placeholder="Paste 81 characters, JSON or a puzzle URL…"
|
placeholder="Paste 81 characters, JSON or a puzzle URL…"
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setInput(event.target.value);
|
setInput(event.target.value);
|
||||||
|
setInspected(undefined);
|
||||||
setFeedback("");
|
setFeedback("");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -263,6 +313,7 @@ export function ImportExportDialog({
|
|||||||
.text()
|
.text()
|
||||||
.then((contents) => {
|
.then((contents) => {
|
||||||
setInput(contents);
|
setInput(contents);
|
||||||
|
setInspected(undefined);
|
||||||
setFeedback(
|
setFeedback(
|
||||||
`${file.name} loaded locally; review and import it.`,
|
`${file.name} loaded locally; review and import it.`,
|
||||||
);
|
);
|
||||||
@@ -287,6 +338,41 @@ export function ImportExportDialog({
|
|||||||
Import locally
|
Import locally
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
@@ -357,6 +443,46 @@ export function ImportExportDialog({
|
|||||||
>
|
>
|
||||||
Copy f-puzzles URL
|
Copy f-puzzles URL
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import type { SudokuProjectSummary } from "../storage";
|
import type { SudokuProjectSummary } from "../storage";
|
||||||
import { Modal } from "./Modal";
|
import { Modal } from "./Modal";
|
||||||
|
|
||||||
@@ -9,6 +9,26 @@ function date(value: number) {
|
|||||||
}).format(new Date(value));
|
}).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({
|
export function LibraryDialog({
|
||||||
open,
|
open,
|
||||||
summaries,
|
summaries,
|
||||||
@@ -21,6 +41,10 @@ export function LibraryDialog({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onClear,
|
onClear,
|
||||||
onExport,
|
onExport,
|
||||||
|
onExportSelected,
|
||||||
|
onDuplicateSelected,
|
||||||
|
onDeleteSelected,
|
||||||
|
onUpdateTags,
|
||||||
onImport,
|
onImport,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -34,16 +58,73 @@ export function LibraryDialog({
|
|||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
onExport: () => 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;
|
onImport: (file: File) => void;
|
||||||
}) {
|
}) {
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
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 (
|
return (
|
||||||
<Modal open={open} title="Local puzzle library" onClose={onClose} wide>
|
<Modal open={open} title="Local puzzle library" onClose={onClose} wide>
|
||||||
<div className="library-toolbar">
|
<div className="library-toolbar">
|
||||||
<div>
|
<div>
|
||||||
<p>
|
<p>
|
||||||
{mode === "indexeddb"
|
{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."}
|
: "IndexedDB is unavailable; saves last only for this open tab."}
|
||||||
</p>
|
</p>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
@@ -81,38 +162,206 @@ export function LibraryDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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 && (
|
{feedback && (
|
||||||
<p className="status-line" role="status">
|
<p className="status-line" role="status">
|
||||||
{feedback}
|
{feedback}
|
||||||
</p>
|
</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 ? (
|
{summaries.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<h3>No saved puzzles</h3>
|
<h3>No saved puzzles</h3>
|
||||||
<p>Save the current puzzle to build a local library.</p>
|
<p>Save the current puzzle to build a local library.</p>
|
||||||
</div>
|
</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">
|
<ul className="library-list">
|
||||||
{summaries.map((item) => (
|
{filtered.map((item) => (
|
||||||
<li key={item.id}>
|
<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
|
<button
|
||||||
className="library-open"
|
className="library-open"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpen(item.id)}
|
onClick={() => onOpen(item.id)}
|
||||||
>
|
>
|
||||||
<strong>{item.title || "Untitled puzzle"}</strong>
|
<LibraryThumbnail item={item} />
|
||||||
<span>
|
<span className="library-copy">
|
||||||
{item.size}×{item.size} · {date(item.updatedAt)}
|
<strong>{item.title || "Untitled puzzle"}</strong>
|
||||||
{item.completed ? " · complete" : ""}
|
<span>
|
||||||
|
{item.size}×{item.size} · {date(item.updatedAt)}
|
||||||
|
{item.completed ? " · complete" : " · in progress"}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<div className="library-item-actions">
|
||||||
className="text-button danger"
|
<div className="library-tags" aria-label="Project tags">
|
||||||
type="button"
|
{item.tags.map((value) => (
|
||||||
onClick={() => onDelete(item.id)}
|
<button
|
||||||
>
|
key={value}
|
||||||
Delete
|
type="button"
|
||||||
</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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { EntryMode } from "../state/session";
|
import type { EntryMode } from "../state/session";
|
||||||
import { symbolFor } from "../state/session";
|
import { symbolFor } from "../state/session";
|
||||||
|
import { colorMarkDescription } from "../state/uiPreferences";
|
||||||
|
|
||||||
const modes: Array<{ mode: EntryMode; label: string; key: string }> = [
|
const modes: Array<{ mode: EntryMode; label: string; key: string }> = [
|
||||||
{ mode: "value", label: "Value", key: "Z" },
|
{ mode: "value", label: "Value", key: "Z" },
|
||||||
@@ -56,7 +57,9 @@ export function NumberPad({
|
|||||||
{mode === "color" ? (
|
{mode === "color" ? (
|
||||||
<>
|
<>
|
||||||
<span aria-hidden="true" />{" "}
|
<span aria-hidden="true" />{" "}
|
||||||
<span className="sr-only">Colour {value}</span>
|
<span className="sr-only">
|
||||||
|
Colour {value}: {colorMarkDescription(value)}
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
symbolFor(value, size)
|
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 { cellColumn, cellRow, orthogonalNeighbours } from "./geometry";
|
||||||
import type {
|
import type { CellId, NormalizedPuzzle } from "./types";
|
||||||
CellId,
|
|
||||||
NormalizedPuzzle,
|
|
||||||
OutsideClueSide,
|
|
||||||
VariantConstraint,
|
|
||||||
} 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 {
|
export interface SudokuUnit {
|
||||||
readonly kind: UnitKind;
|
readonly kind: UnitKind;
|
||||||
@@ -30,63 +29,18 @@ function addPeerPair(peers: Set<CellId>[], a: CellId, b: CellId): void {
|
|||||||
peers[b]?.add(a);
|
peers[b]?.add(a);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cells seen from an outside clue, ordered from the clue into the grid. */
|
function disjointGroupCells(
|
||||||
export function outsideLineCells(
|
puzzle: NormalizedPuzzle,
|
||||||
size: number,
|
position: number,
|
||||||
side: OutsideClueSide,
|
|
||||||
index: number,
|
|
||||||
): CellId[] {
|
): CellId[] {
|
||||||
return Array.from({ length: size }, (_, offset) => {
|
const regions = Array.from({ length: puzzle.size }, () => [] as CellId[]);
|
||||||
switch (side) {
|
puzzle.regions.forEach((region, cell) => regions[region]?.push(cell));
|
||||||
case "top":
|
return regions.flatMap((cells) => {
|
||||||
return offset * size + index;
|
const cell = cells[position];
|
||||||
case "right":
|
return cell === undefined ? [] : [cell];
|
||||||
return index * size + size - offset - 1;
|
|
||||||
case "bottom":
|
|
||||||
return (size - offset - 1) * size + index;
|
|
||||||
case "left":
|
|
||||||
return index * size + offset;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
export function compilePuzzle(puzzle: NormalizedPuzzle): CompiledPuzzle {
|
||||||
const { size } = puzzle;
|
const { size } = puzzle;
|
||||||
const count = size * size;
|
const count = size * size;
|
||||||
@@ -115,9 +69,28 @@ export function compilePuzzle(puzzle: NormalizedPuzzle): CompiledPuzzle {
|
|||||||
units.push({
|
units.push({
|
||||||
kind: "diagonal",
|
kind: "diagonal",
|
||||||
index: constraint.direction === "main" ? 0 : 1,
|
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 peers = Array.from({ length: count }, () => new Set<CellId>());
|
||||||
const unitsByCell = Array.from({ length: count }, () => [] as number[]);
|
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[]);
|
const constraintsByCell = Array.from({ length: count }, () => [] as number[]);
|
||||||
puzzle.constraints.forEach((constraint, constraintIndex) => {
|
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);
|
constraintsByCell[cell]?.push(constraintIndex);
|
||||||
}
|
}
|
||||||
if (
|
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,
|
MAX_PUZZLE_SIZE,
|
||||||
MIN_PUZZLE_SIZE,
|
MIN_PUZZLE_SIZE,
|
||||||
type CellId,
|
type CellId,
|
||||||
|
type LittleKillerDirection,
|
||||||
|
type OutsideClueSide,
|
||||||
type PuzzleDefinition,
|
type PuzzleDefinition,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
@@ -46,15 +48,17 @@ export function assertCell(size: number, cell: CellId): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export interface ClassicBoxDimensions {
|
||||||
* Builds conventional rectangular regions. For non-composite sizes this falls
|
readonly rows: number;
|
||||||
* back to 1 x size regions, which is still a valid Latin-square topology.
|
readonly columns: number;
|
||||||
*/
|
readonly boxesPerRow: number;
|
||||||
export function classicRegions(
|
}
|
||||||
|
|
||||||
|
export function classicBoxDimensions(
|
||||||
size: number,
|
size: number,
|
||||||
boxRows?: number,
|
boxRows?: number,
|
||||||
boxColumns?: number,
|
boxColumns?: number,
|
||||||
): number[] {
|
): ClassicBoxDimensions {
|
||||||
assertSize(size);
|
assertSize(size);
|
||||||
let rows = boxRows;
|
let rows = boxRows;
|
||||||
let columns = boxColumns;
|
let columns = boxColumns;
|
||||||
@@ -84,8 +88,24 @@ export function classicRegions(
|
|||||||
"Box rows and columns must be positive factors whose product is size.",
|
"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 regions = new Array<number>(size * size);
|
||||||
const boxesPerRow = size / columns;
|
|
||||||
for (let row = 0; row < size; row += 1) {
|
for (let row = 0; row < size; row += 1) {
|
||||||
for (let column = 0; column < size; column += 1) {
|
for (let column = 0; column < size; column += 1) {
|
||||||
regions[row * size + column] =
|
regions[row * size + column] =
|
||||||
@@ -95,6 +115,52 @@ export function classicRegions(
|
|||||||
return regions;
|
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(
|
export function createEmptyPuzzle(
|
||||||
size = 9,
|
size = 9,
|
||||||
options: {
|
options: {
|
||||||
@@ -127,6 +193,76 @@ export function orthogonalNeighbours(size: number, cell: CellId): CellId[] {
|
|||||||
return result;
|
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. */
|
/** True when four cells meet at one internal grid intersection. */
|
||||||
export function cellsFormQuadruple(
|
export function cellsFormQuadruple(
|
||||||
size: number,
|
size: number,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from "./compile";
|
export * from "./compile";
|
||||||
|
export * from "./constraintRegistry";
|
||||||
export * from "./geometry";
|
export * from "./geometry";
|
||||||
export * from "./rules";
|
export * from "./rules";
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
|
|||||||
+536
-82
@@ -1,9 +1,14 @@
|
|||||||
|
import { compilePuzzle, type CompiledPuzzle } from "./compile";
|
||||||
|
import { constraintCells } from "./constraintRegistry";
|
||||||
import {
|
import {
|
||||||
compilePuzzle,
|
classicBoxCell,
|
||||||
|
classicBoxIndex,
|
||||||
|
classicBoxPosition,
|
||||||
|
classicRegions,
|
||||||
|
littleKillerCells,
|
||||||
|
orthogonalNeighbours,
|
||||||
outsideLineCells,
|
outsideLineCells,
|
||||||
type CompiledPuzzle,
|
} from "./geometry";
|
||||||
} from "./compile";
|
|
||||||
import { orthogonalNeighbours } from "./geometry";
|
|
||||||
import type {
|
import type {
|
||||||
CellId,
|
CellId,
|
||||||
NormalizedPuzzle,
|
NormalizedPuzzle,
|
||||||
@@ -255,33 +260,382 @@ function quadrupleCanMeet(
|
|||||||
return missing <= blanks;
|
return missing <= blanks;
|
||||||
}
|
}
|
||||||
|
|
||||||
function maximumCanMeet(
|
function extremumCanMeet(
|
||||||
cell: CellId,
|
cell: CellId,
|
||||||
values: readonly number[],
|
values: readonly number[],
|
||||||
size: number,
|
size: number,
|
||||||
|
kind: "maximum" | "minimum",
|
||||||
): boolean {
|
): boolean {
|
||||||
const maximum = values[cell] ?? 0;
|
const extremum = values[cell] ?? 0;
|
||||||
const neighbours = orthogonalNeighbours(size, cell).map(
|
const neighbours = orthogonalNeighbours(size, cell).map(
|
||||||
(neighbour) => values[neighbour] ?? 0,
|
(neighbour) => values[neighbour] ?? 0,
|
||||||
);
|
);
|
||||||
if (maximum === 0) {
|
if (extremum === 0) {
|
||||||
return neighbours.every((value) => value === 0 || value < size);
|
return neighbours.every((value) =>
|
||||||
|
value === 0 ? true : kind === "maximum" ? value < size : value > 1,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return neighbours.every((value) =>
|
return neighbours.every((value) => {
|
||||||
value === 0 ? maximum > 1 : value < maximum,
|
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(
|
function positiveConstraintIsFeasible(
|
||||||
constraint: VariantConstraint,
|
constraint: VariantConstraint,
|
||||||
values: readonly number[],
|
values: readonly number[],
|
||||||
size: number,
|
size: number,
|
||||||
|
regions: readonly number[],
|
||||||
): boolean {
|
): boolean {
|
||||||
switch (constraint.type) {
|
switch (constraint.type) {
|
||||||
case "diagonal":
|
case "diagonal":
|
||||||
case "anti-knight":
|
case "anti-knight":
|
||||||
case "anti-king":
|
case "anti-king":
|
||||||
|
case "disjoint-groups":
|
||||||
|
case "extra-region":
|
||||||
return true; // Equality conflicts are represented in compiled peers/units.
|
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": {
|
case "non-consecutive": {
|
||||||
for (let cell = 0; cell < size * size; cell += 1) {
|
for (let cell = 0; cell < size * size; cell += 1) {
|
||||||
const value = values[cell] ?? 0;
|
const value = values[cell] ?? 0;
|
||||||
@@ -400,7 +754,82 @@ function positiveConstraintIsFeasible(
|
|||||||
case "quadruple":
|
case "quadruple":
|
||||||
return quadrupleCanMeet(constraint, values);
|
return quadrupleCanMeet(constraint, values);
|
||||||
case "maximum":
|
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,
|
constraint: VariantConstraint,
|
||||||
size: number,
|
size: number,
|
||||||
): readonly CellId[] | undefined {
|
): readonly CellId[] | undefined {
|
||||||
switch (constraint.type) {
|
if (!("negated" in constraint)) return undefined;
|
||||||
case "diagonal":
|
return constraintCells(size, constraint);
|
||||||
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)];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -446,6 +852,7 @@ function negatedConstraintIsFeasible(
|
|||||||
constraint: VariantConstraint,
|
constraint: VariantConstraint,
|
||||||
values: readonly number[],
|
values: readonly number[],
|
||||||
size: number,
|
size: number,
|
||||||
|
regions: readonly number[],
|
||||||
): boolean {
|
): boolean {
|
||||||
if (constraint.type === "x-sum") {
|
if (constraint.type === "x-sum") {
|
||||||
const line = valuesAt(
|
const line = valuesAt(
|
||||||
@@ -476,15 +883,89 @@ function negatedConstraintIsFeasible(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (constraint.type === "maximum") {
|
if (constraint.type === "maximum") {
|
||||||
const maximum = values[constraint.cell] ?? 0;
|
const extremum = values[constraint.cell] ?? 0;
|
||||||
if (maximum === 0) return true;
|
if (extremum === 0) return true;
|
||||||
const neighbours = orthogonalNeighbours(size, constraint.cell).map(
|
const neighbours = orthogonalNeighbours(size, constraint.cell).map(
|
||||||
(cell) => values[cell] ?? 0,
|
(cell) => values[cell] ?? 0,
|
||||||
);
|
);
|
||||||
if (neighbours.some((value) => value !== 0 && value >= maximum)) {
|
if (neighbours.some((value) => value !== 0 && value >= extremum)) {
|
||||||
return true;
|
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") {
|
if (constraint.type === "palindrome") {
|
||||||
@@ -511,17 +992,19 @@ function negatedConstraintIsFeasible(
|
|||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return !positiveConstraintIsFeasible(constraint, values, size);
|
return !positiveConstraintIsFeasible(constraint, values, size, regions);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function constraintIsFeasible(
|
export function constraintIsFeasible(
|
||||||
constraint: VariantConstraint,
|
constraint: VariantConstraint,
|
||||||
values: readonly number[],
|
values: readonly number[],
|
||||||
size: number,
|
size: number,
|
||||||
|
regions: readonly number[] = classicRegions(size),
|
||||||
): boolean {
|
): boolean {
|
||||||
const negated = "negated" in constraint && constraint.negated === true;
|
const negated = "negated" in constraint && constraint.negated === true;
|
||||||
if (!negated) return positiveConstraintIsFeasible(constraint, values, size);
|
if (!negated)
|
||||||
return negatedConstraintIsFeasible(constraint, values, size);
|
return positiveConstraintIsFeasible(constraint, values, size, regions);
|
||||||
|
return negatedConstraintIsFeasible(constraint, values, size, regions);
|
||||||
}
|
}
|
||||||
|
|
||||||
function asCompiled(
|
function asCompiled(
|
||||||
@@ -550,7 +1033,7 @@ export function canPlaceValue(
|
|||||||
const constraint = compiled.puzzle.constraints[index];
|
const constraint = compiled.puzzle.constraints[index];
|
||||||
if (
|
if (
|
||||||
constraint !== undefined &&
|
constraint !== undefined &&
|
||||||
!constraintIsFeasible(constraint, next, size)
|
!constraintIsFeasible(constraint, next, size, compiled.puzzle.regions)
|
||||||
)
|
)
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -646,44 +1129,15 @@ export function findConflicts(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
compiled.puzzle.constraints.forEach((constraint, constraintIndex) => {
|
compiled.puzzle.constraints.forEach((constraint, constraintIndex) => {
|
||||||
if (!constraintIsFeasible(constraint, board, compiled.puzzle.size)) {
|
if (
|
||||||
const cells = (() => {
|
!constraintIsFeasible(
|
||||||
switch (constraint.type) {
|
constraint,
|
||||||
case "diagonal":
|
board,
|
||||||
case "anti-knight":
|
compiled.puzzle.size,
|
||||||
case "anti-king":
|
compiled.puzzle.regions,
|
||||||
case "non-consecutive":
|
)
|
||||||
return Array.from(
|
) {
|
||||||
{ length: compiled.puzzle.size * compiled.puzzle.size },
|
const cells = constraintCells(compiled.puzzle.size, constraint);
|
||||||
(_, 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),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
conflicts.push({
|
conflicts.push({
|
||||||
kind: "constraint",
|
kind: "constraint",
|
||||||
cells,
|
cells,
|
||||||
|
|||||||
+145
-1
@@ -23,6 +23,11 @@ export interface NonConsecutiveConstraint {
|
|||||||
readonly type: "non-consecutive";
|
readonly type: "non-consecutive";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Corresponding positions in every standard box form an additional house. */
|
||||||
|
export interface DisjointGroupsConstraint {
|
||||||
|
readonly type: "disjoint-groups";
|
||||||
|
}
|
||||||
|
|
||||||
export interface KillerCageConstraint {
|
export interface KillerCageConstraint {
|
||||||
readonly type: "killer-cage";
|
readonly type: "killer-cage";
|
||||||
readonly cells: readonly CellId[];
|
readonly cells: readonly CellId[];
|
||||||
@@ -122,11 +127,134 @@ export interface MaximumConstraint {
|
|||||||
readonly negated?: boolean;
|
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 =
|
export type VariantConstraint =
|
||||||
| DiagonalConstraint
|
| DiagonalConstraint
|
||||||
| AntiKnightConstraint
|
| AntiKnightConstraint
|
||||||
| AntiKingConstraint
|
| AntiKingConstraint
|
||||||
| NonConsecutiveConstraint
|
| NonConsecutiveConstraint
|
||||||
|
| DisjointGroupsConstraint
|
||||||
| KillerCageConstraint
|
| KillerCageConstraint
|
||||||
| ThermoConstraint
|
| ThermoConstraint
|
||||||
| ArrowConstraint
|
| ArrowConstraint
|
||||||
@@ -138,7 +266,23 @@ export type VariantConstraint =
|
|||||||
| XSumConstraint
|
| XSumConstraint
|
||||||
| SkyscraperConstraint
|
| SkyscraperConstraint
|
||||||
| QuadrupleConstraint
|
| QuadrupleConstraint
|
||||||
| MaximumConstraint;
|
| MaximumConstraint
|
||||||
|
| MinimumConstraint
|
||||||
|
| OddConstraint
|
||||||
|
| EvenConstraint
|
||||||
|
| LittleKillerConstraint
|
||||||
|
| SandwichConstraint
|
||||||
|
| BetweenLineConstraint
|
||||||
|
| GermanWhisperConstraint
|
||||||
|
| RegionSumLineConstraint
|
||||||
|
| CloneConstraint
|
||||||
|
| ExtraRegionConstraint
|
||||||
|
| ModularLineConstraint
|
||||||
|
| EntropicLineConstraint
|
||||||
|
| ZipperLineConstraint
|
||||||
|
| DoubleArrowConstraint
|
||||||
|
| IndexerConstraint
|
||||||
|
| FogConstraint;
|
||||||
|
|
||||||
export interface PuzzleDefinition {
|
export interface PuzzleDefinition {
|
||||||
readonly version: 1;
|
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 { compilePuzzle } from "./compile";
|
||||||
|
import {
|
||||||
|
constraintAllowedFields,
|
||||||
|
isConstraintType,
|
||||||
|
} from "./constraintRegistry";
|
||||||
import { findConflicts } from "./rules";
|
import { findConflicts } from "./rules";
|
||||||
import {
|
import {
|
||||||
MAX_PUZZLE_SIZE,
|
MAX_PUZZLE_SIZE,
|
||||||
MIN_PUZZLE_SIZE,
|
MIN_PUZZLE_SIZE,
|
||||||
PuzzleValidationError,
|
PuzzleValidationError,
|
||||||
|
type LittleKillerDirection,
|
||||||
type NormalizedPuzzle,
|
type NormalizedPuzzle,
|
||||||
|
type OutsideClueSide,
|
||||||
type PuzzleDefinition,
|
type PuzzleDefinition,
|
||||||
type ValidationIssue,
|
type ValidationIssue,
|
||||||
type ValidationResult,
|
type ValidationResult,
|
||||||
@@ -30,28 +41,8 @@ const ROOT_KEYS = new Set([
|
|||||||
"solution",
|
"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 reachableXSumCache = new Map<number, ReadonlySet<number>>();
|
||||||
|
const reachableSandwichCache = new Map<number, ReadonlySet<number>>();
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
@@ -182,6 +173,30 @@ function reachableXSumTotals(size: number): ReadonlySet<number> {
|
|||||||
return reachable;
|
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(
|
function validateConstraint(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
index: number,
|
index: number,
|
||||||
@@ -193,12 +208,12 @@ function validateConstraint(
|
|||||||
add(issues, path, "must be a constraint object with a type");
|
add(issues, path, "must be a constraint object with a type");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const type = value.type as VariantConstraint["type"];
|
if (!isConstraintType(value.type)) {
|
||||||
const allowed = CONSTRAINT_KEYS[type];
|
|
||||||
if (allowed === undefined) {
|
|
||||||
add(issues, `${path}.type`, "is not a supported constraint type");
|
add(issues, `${path}.type`, "is not a supported constraint type");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const type = value.type;
|
||||||
|
const allowed = constraintAllowedFields(type);
|
||||||
for (const key of Object.keys(value)) {
|
for (const key of Object.keys(value)) {
|
||||||
if (!allowed.has(key))
|
if (!allowed.has(key))
|
||||||
add(issues, `${path}.${key}`, "is not a recognized field");
|
add(issues, `${path}.${key}`, "is not a recognized field");
|
||||||
@@ -220,6 +235,7 @@ function validateConstraint(
|
|||||||
case "anti-knight":
|
case "anti-knight":
|
||||||
case "anti-king":
|
case "anti-king":
|
||||||
case "non-consecutive":
|
case "non-consecutive":
|
||||||
|
case "disjoint-groups":
|
||||||
break;
|
break;
|
||||||
case "killer-cage": {
|
case "killer-cage": {
|
||||||
const cellsValid = validateCells(
|
const cellsValid = validateCells(
|
||||||
@@ -410,8 +426,258 @@ function validateConstraint(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "maximum":
|
case "maximum":
|
||||||
|
case "minimum":
|
||||||
|
case "odd":
|
||||||
|
case "even":
|
||||||
validateCell(value.cell, `${path}.cell`, cellCount, issues);
|
validateCell(value.cell, `${path}.cell`, cellCount, issues);
|
||||||
break;
|
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-knight":
|
||||||
case "anti-king":
|
case "anti-king":
|
||||||
case "non-consecutive":
|
case "non-consecutive":
|
||||||
|
case "disjoint-groups":
|
||||||
return { type: constraint.type };
|
return { type: constraint.type };
|
||||||
case "killer-cage":
|
case "killer-cage":
|
||||||
return {
|
return {
|
||||||
@@ -464,6 +731,12 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
|
|||||||
case "thermo":
|
case "thermo":
|
||||||
case "renban":
|
case "renban":
|
||||||
case "palindrome":
|
case "palindrome":
|
||||||
|
case "between-line":
|
||||||
|
case "region-sum-line":
|
||||||
|
case "modular-line":
|
||||||
|
case "entropic-line":
|
||||||
|
case "zipper-line":
|
||||||
|
case "double-arrow":
|
||||||
return {
|
return {
|
||||||
type: constraint.type,
|
type: constraint.type,
|
||||||
cells: [...constraint.cells],
|
cells: [...constraint.cells],
|
||||||
@@ -539,6 +812,9 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
|
|||||||
: { negated: constraint.negated }),
|
: { negated: constraint.negated }),
|
||||||
};
|
};
|
||||||
case "maximum":
|
case "maximum":
|
||||||
|
case "minimum":
|
||||||
|
case "odd":
|
||||||
|
case "even":
|
||||||
return {
|
return {
|
||||||
type: constraint.type,
|
type: constraint.type,
|
||||||
cell: constraint.cell,
|
cell: constraint.cell,
|
||||||
@@ -546,6 +822,66 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
|
|||||||
? {}
|
? {}
|
||||||
: { negated: constraint.negated }),
|
: { 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) => {
|
.forEach((constraint, index) => {
|
||||||
validateConstraint(constraint, index, n, issues);
|
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.id, "id", MAX_SHORT_TEXT, issues);
|
||||||
validateText(puzzle.title, "title", 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 { cellsFormQuadruple } from "../domain/geometry";
|
||||||
import { normalizePortableAidMemoire } from "../state/aidMemoire";
|
import { normalizePortableAidMemoire } from "../state/aidMemoire";
|
||||||
|
import {
|
||||||
|
SafeVisualValidationError,
|
||||||
|
normalizeSafeVisualPrimitives,
|
||||||
|
normalizeScalarMetadata,
|
||||||
|
normalizeSourceIdentity,
|
||||||
|
} from "./safeVisuals";
|
||||||
import {
|
import {
|
||||||
SUDOKU_DOCUMENT_SCHEMA,
|
SUDOKU_DOCUMENT_SCHEMA,
|
||||||
SUDOKU_DOCUMENT_VERSION,
|
SUDOKU_DOCUMENT_VERSION,
|
||||||
cloneConstraint,
|
cloneConstraint,
|
||||||
|
cloneVisualPrimitive,
|
||||||
type PortableAidMemoire,
|
type PortableAidMemoire,
|
||||||
type PortableConstraint,
|
type PortableConstraint,
|
||||||
type SudokuDocument,
|
type SudokuDocument,
|
||||||
@@ -15,6 +22,30 @@ export const MAX_BOARD_SIZE = 16;
|
|||||||
export const MAX_CONSTRAINTS = 5_000;
|
export const MAX_CONSTRAINTS = 5_000;
|
||||||
const MAX_TEXT_LENGTH = 20_000;
|
const MAX_TEXT_LENGTH = 20_000;
|
||||||
const MAX_RULES = 1_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 {
|
export class SudokuFormatError extends Error {
|
||||||
readonly code: string;
|
readonly code: string;
|
||||||
@@ -176,6 +207,7 @@ function parseConstraint(
|
|||||||
case "anti-knight":
|
case "anti-knight":
|
||||||
case "anti-king":
|
case "anti-king":
|
||||||
case "non-consecutive":
|
case "non-consecutive":
|
||||||
|
case "disjoint-groups":
|
||||||
return { type: value.type };
|
return { type: value.type };
|
||||||
case "killer-cage": {
|
case "killer-cage": {
|
||||||
const cageCells = cells(value.cells, "killer-cage.cells", cellCount);
|
const cageCells = cells(value.cells, "killer-cage.cells", cellCount);
|
||||||
@@ -202,11 +234,119 @@ function parseConstraint(
|
|||||||
case "thermo":
|
case "thermo":
|
||||||
case "renban":
|
case "renban":
|
||||||
case "palindrome":
|
case "palindrome":
|
||||||
|
case "region-sum-line":
|
||||||
return {
|
return {
|
||||||
type: value.type,
|
type: value.type,
|
||||||
cells: cells(value.cells, `${value.type}.cells`, cellCount, 2),
|
cells: cells(value.cells, `${value.type}.cells`, cellCount, 2),
|
||||||
...cluePolarity(value, value.type),
|
...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":
|
case "arrow":
|
||||||
return {
|
return {
|
||||||
type: "arrow",
|
type: "arrow",
|
||||||
@@ -322,11 +462,57 @@ function parseConstraint(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
case "maximum":
|
case "maximum":
|
||||||
|
case "minimum":
|
||||||
|
case "odd":
|
||||||
|
case "even":
|
||||||
return {
|
return {
|
||||||
type: "maximum",
|
type: value.type,
|
||||||
cell: cell(value.cell, "maximum.cell", cellCount),
|
cell: cell(value.cell, `${value.type}.cell`, cellCount),
|
||||||
...cluePolarity(value, "maximum"),
|
...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:
|
default:
|
||||||
return fail(
|
return fail(
|
||||||
"UNSUPPORTED_CONSTRAINT",
|
"UNSUPPORTED_CONSTRAINT",
|
||||||
@@ -354,6 +540,15 @@ function textList(value: unknown, label: string): string[] | undefined {
|
|||||||
export function normalizeSudokuDocument(value: unknown): SudokuDocument {
|
export function normalizeSudokuDocument(value: unknown): SudokuDocument {
|
||||||
if (!isRecord(value))
|
if (!isRecord(value))
|
||||||
return fail("INVALID_DOCUMENT", "The puzzle document must be an object.");
|
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) {
|
if (value.schema !== SUDOKU_DOCUMENT_SCHEMA) {
|
||||||
return fail("INVALID_SCHEMA", `Expected 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) =>
|
const constraints = value.constraints.map((constraint) =>
|
||||||
parseConstraint(constraint, cellCount),
|
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 title = optionalText(value.title, "title");
|
||||||
const author = optionalText(value.author, "author");
|
const author = optionalText(value.author, "author");
|
||||||
const id = optionalText(value.id, "id");
|
const id = optionalText(value.id, "id");
|
||||||
const rules = textList(value.rules, "rules");
|
const rules = textList(value.rules, "rules");
|
||||||
const globalRules = textList(value.globalRules, "globalRules");
|
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 {
|
return {
|
||||||
schema: SUDOKU_DOCUMENT_SCHEMA,
|
schema: SUDOKU_DOCUMENT_SCHEMA,
|
||||||
@@ -484,6 +701,9 @@ export function normalizeSudokuDocument(value: unknown): SudokuDocument {
|
|||||||
...(id === undefined ? {} : { id }),
|
...(id === undefined ? {} : { id }),
|
||||||
...(rules === undefined ? {} : { rules }),
|
...(rules === undefined ? {} : { rules }),
|
||||||
...(globalRules === undefined ? {} : { globalRules }),
|
...(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
|
...(normalized.globalRules === undefined
|
||||||
? {}
|
? {}
|
||||||
: { globalRules: [...normalized.globalRules] }),
|
: { 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,
|
SudokuFormatError,
|
||||||
} from "./document";
|
} from "./document";
|
||||||
import { UnsupportedPuzzleConstructsError } from "./interoperability";
|
import { UnsupportedPuzzleConstructsError } from "./interoperability";
|
||||||
|
import {
|
||||||
|
SafeVisualValidationError,
|
||||||
|
normalizeSafeVisualColor,
|
||||||
|
normalizeSafeVisualPrimitives,
|
||||||
|
} from "./safeVisuals";
|
||||||
import {
|
import {
|
||||||
SUDOKU_DOCUMENT_SCHEMA,
|
SUDOKU_DOCUMENT_SCHEMA,
|
||||||
SUDOKU_DOCUMENT_VERSION,
|
SUDOKU_DOCUMENT_VERSION,
|
||||||
type PortableConstraint,
|
type PortableConstraint,
|
||||||
|
type SafeVisualAnchor,
|
||||||
|
type SafeVisualPrimitive,
|
||||||
|
type ScalarMetadataValue,
|
||||||
type SudokuDocument,
|
type SudokuDocument,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
@@ -33,6 +41,7 @@ const SUPPORTED_ROOT_FIELDS = new Set([
|
|||||||
"antiking",
|
"antiking",
|
||||||
"antikingsmove",
|
"antikingsmove",
|
||||||
"nonconsecutive",
|
"nonconsecutive",
|
||||||
|
"disjointgroups",
|
||||||
"killercage",
|
"killercage",
|
||||||
"thermometer",
|
"thermometer",
|
||||||
"arrow",
|
"arrow",
|
||||||
@@ -44,48 +53,45 @@ const SUPPORTED_ROOT_FIELDS = new Set([
|
|||||||
"skyscraper",
|
"skyscraper",
|
||||||
"quadruple",
|
"quadruple",
|
||||||
"maximum",
|
"maximum",
|
||||||
|
"minimum",
|
||||||
|
"even",
|
||||||
|
"odd",
|
||||||
|
"littlekillersum",
|
||||||
|
"sandwichsum",
|
||||||
|
"extraregion",
|
||||||
|
"clone",
|
||||||
|
"betweenline",
|
||||||
|
"whispers",
|
||||||
|
"regionsumline",
|
||||||
|
"entropicline",
|
||||||
|
"modularline",
|
||||||
|
"zipperline",
|
||||||
|
"doublearrow",
|
||||||
|
"rowindexer",
|
||||||
|
"columnindexer",
|
||||||
|
"boxindexer",
|
||||||
|
"fogofwar",
|
||||||
|
"foglight",
|
||||||
"renban",
|
"renban",
|
||||||
"palindrome",
|
"palindrome",
|
||||||
"disabledlogic",
|
"disabledlogic",
|
||||||
"truecandidatesoptions",
|
"truecandidatesoptions",
|
||||||
"successMessage",
|
"successMessage",
|
||||||
"successmessage",
|
"successmessage",
|
||||||
|
"id",
|
||||||
|
"line",
|
||||||
|
"rectangle",
|
||||||
|
"circle",
|
||||||
|
"text",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const UNSUPPORTED_RULE_FIELDS: Readonly<Record<string, string>> = {
|
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",
|
nabner: "Nabner lines",
|
||||||
doublearrow: "double arrows",
|
|
||||||
lockout: "lockout lines",
|
lockout: "lockout lines",
|
||||||
rowindexer: "row indexers",
|
|
||||||
columnindexer: "column indexers",
|
|
||||||
boxindexer: "box indexers",
|
|
||||||
fogofwar: "fog of war",
|
|
||||||
foglight: "fog lights",
|
|
||||||
cage: "generic cages",
|
cage: "generic cages",
|
||||||
negative: "negative constraints",
|
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 {
|
export class NetworkPuzzleIdError extends SudokuFormatError {
|
||||||
readonly puzzleId: string;
|
readonly puzzleId: string;
|
||||||
|
|
||||||
@@ -124,16 +130,7 @@ function assertSupportedRootFields(value: JsonRecord): void {
|
|||||||
constructs.push(unsupported);
|
constructs.push(unsupported);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const decoration = DECORATION_FIELDS[field];
|
if (!SUPPORTED_ROOT_FIELDS.has(field) && unsupported === undefined) {
|
||||||
if (decoration !== undefined && present(raw)) {
|
|
||||||
constructs.push(decoration);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!SUPPORTED_ROOT_FIELDS.has(field) &&
|
|
||||||
unsupported === undefined &&
|
|
||||||
decoration === undefined
|
|
||||||
) {
|
|
||||||
constructs.push(`Unknown fpuzzles field “${field}”`);
|
constructs.push(`Unknown fpuzzles field “${field}”`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,6 +304,337 @@ function numeric(
|
|||||||
return parsed as number;
|
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[] {
|
function readGrid(value: unknown, size: number): JsonRecord[] {
|
||||||
if (!Array.isArray(value))
|
if (!Array.isArray(value))
|
||||||
return fail("INVALID_FPUZZLES", "fpuzzles.grid must be an array.");
|
return fail("INVALID_FPUZZLES", "fpuzzles.grid must be an array.");
|
||||||
@@ -341,7 +669,17 @@ function addLineConstraints(
|
|||||||
output: PortableConstraint[],
|
output: PortableConstraint[],
|
||||||
source: unknown,
|
source: unknown,
|
||||||
size: number,
|
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 {
|
): void {
|
||||||
for (const item of objects(source)) {
|
for (const item of objects(source)) {
|
||||||
for (const line of lines(item.lines ?? item.cells, size))
|
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 {
|
function parseRules(value: unknown): string[] | undefined {
|
||||||
if (value === undefined || value === "") return undefined;
|
if (value === undefined || value === "") return undefined;
|
||||||
if (typeof value === "string") return [value];
|
if (typeof value === "string") return [value];
|
||||||
@@ -429,6 +811,8 @@ export function parseFpuzzles(value: unknown): SudokuDocument {
|
|||||||
constraints.push({ type: "anti-king" });
|
constraints.push({ type: "anti-king" });
|
||||||
if (value.nonconsecutive === true)
|
if (value.nonconsecutive === true)
|
||||||
constraints.push({ type: "non-consecutive" });
|
constraints.push({ type: "non-consecutive" });
|
||||||
|
if (value.disjointgroups === true)
|
||||||
|
constraints.push({ type: "disjoint-groups" });
|
||||||
|
|
||||||
for (const cage of objects(value.killercage)) {
|
for (const cage of objects(value.killercage)) {
|
||||||
if (cage.value === undefined || cage.value === "") {
|
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.thermometer, size, "thermo");
|
||||||
addLineConstraints(constraints, value.renban, size, "renban");
|
addLineConstraints(constraints, value.renban, size, "renban");
|
||||||
addLineConstraints(constraints, value.palindrome, size, "palindrome");
|
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)) {
|
for (const arrow of objects(value.arrow)) {
|
||||||
const bulb = fpCells(arrow.cells, size);
|
const bulb = fpCells(arrow.cells, size);
|
||||||
@@ -576,6 +979,95 @@ export function parseFpuzzles(value: unknown): SudokuDocument {
|
|||||||
cell: cellIndexFromAddress(clue.cell, size),
|
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 regions = readRegions(grid, size);
|
||||||
const rules = parseRules(value.ruleset);
|
const rules = parseRules(value.ruleset);
|
||||||
@@ -585,6 +1077,14 @@ export function parseFpuzzles(value: unknown): SudokuDocument {
|
|||||||
typeof value.author === "string"
|
typeof value.author === "string"
|
||||||
? value.author.slice(0, 20_000)
|
? value.author.slice(0, 20_000)
|
||||||
: undefined;
|
: 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 {
|
return {
|
||||||
schema: SUDOKU_DOCUMENT_SCHEMA,
|
schema: SUDOKU_DOCUMENT_SCHEMA,
|
||||||
@@ -600,6 +1100,13 @@ export function parseFpuzzles(value: unknown): SudokuDocument {
|
|||||||
...(rules === undefined ? {} : { rules }),
|
...(rules === undefined ? {} : { rules }),
|
||||||
...(title === undefined ? {} : { title }),
|
...(title === undefined ? {} : { title }),
|
||||||
...(author === undefined ? {} : { author }),
|
...(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));
|
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 {
|
export function exportFpuzzles(document: SudokuDocument): JsonRecord {
|
||||||
const { size } = document;
|
const { size } = document;
|
||||||
const output: JsonRecord = {
|
const output: JsonRecord = {
|
||||||
@@ -672,6 +1282,9 @@ export function exportFpuzzles(document: SudokuDocument): JsonRecord {
|
|||||||
case "non-consecutive":
|
case "non-consecutive":
|
||||||
output.nonconsecutive = true;
|
output.nonconsecutive = true;
|
||||||
break;
|
break;
|
||||||
|
case "disjoint-groups":
|
||||||
|
output.disjointgroups = true;
|
||||||
|
break;
|
||||||
case "killer-cage":
|
case "killer-cage":
|
||||||
append("killercage", {
|
append("killercage", {
|
||||||
cells: constraintCells(constraint.cells, size),
|
cells: constraintCells(constraint.cells, size),
|
||||||
@@ -692,6 +1305,31 @@ export function exportFpuzzles(document: SudokuDocument): JsonRecord {
|
|||||||
lines: [constraintCells(constraint.cells, size)],
|
lines: [constraintCells(constraint.cells, size)],
|
||||||
});
|
});
|
||||||
break;
|
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":
|
case "arrow":
|
||||||
append("arrow", {
|
append("arrow", {
|
||||||
cells: constraintCells(constraint.bulb, size),
|
cells: constraintCells(constraint.bulb, size),
|
||||||
@@ -764,9 +1402,80 @@ export function exportFpuzzles(document: SudokuDocument): JsonRecord {
|
|||||||
cell: addressFromCellIndex(constraint.cell, size),
|
cell: addressFromCellIndex(constraint.cell, size),
|
||||||
});
|
});
|
||||||
break;
|
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) {
|
if ((document.globalRules?.length ?? 0) > 0) {
|
||||||
return fail(
|
return fail(
|
||||||
"UNSUPPORTED_FPUZZLES",
|
"UNSUPPORTED_FPUZZLES",
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export function parsePlainGrid(
|
|||||||
size,
|
size,
|
||||||
givens,
|
givens,
|
||||||
constraints: [],
|
constraints: [],
|
||||||
|
source: { format: "plain-grid" },
|
||||||
...(options.title === undefined ? {} : { title: options.title }),
|
...(options.title === undefined ? {} : { title: options.title }),
|
||||||
...(options.author === undefined ? {} : { author: options.author }),
|
...(options.author === undefined ? {} : { author: options.author }),
|
||||||
};
|
};
|
||||||
|
|||||||
+45
-60
@@ -9,6 +9,7 @@ import {
|
|||||||
parseFpuzzles,
|
parseFpuzzles,
|
||||||
} from "./fpuzzles";
|
} from "./fpuzzles";
|
||||||
import {
|
import {
|
||||||
|
puzzleImportResult,
|
||||||
type PuzzleImportResult,
|
type PuzzleImportResult,
|
||||||
RemotePuzzleReferenceError,
|
RemotePuzzleReferenceError,
|
||||||
} from "./interoperability";
|
} from "./interoperability";
|
||||||
@@ -88,61 +89,45 @@ export async function importPuzzle(
|
|||||||
throw new RemotePuzzleReferenceError("The pasted URL");
|
throw new RemotePuzzleReferenceError("The pasted URL");
|
||||||
}
|
}
|
||||||
if (url?.kind === "penpa") {
|
if (url?.kind === "penpa") {
|
||||||
return {
|
return puzzleImportResult(await importPenpa(url.value), "penpa", "Penpa+");
|
||||||
document: await importPenpa(url.value),
|
|
||||||
format: "penpa",
|
|
||||||
label: "Penpa+",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (url?.kind === "sudokupad") {
|
if (url?.kind === "sudokupad") {
|
||||||
return {
|
return puzzleImportResult(
|
||||||
document: importSudokuPad(url.value),
|
importSudokuPad(url.value),
|
||||||
format: "sudokupad",
|
"sudokupad",
|
||||||
label: "SudokuPad/CTC",
|
"SudokuPad/CTC",
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
if (url?.kind === "fpuzzles") {
|
if (url?.kind === "fpuzzles") {
|
||||||
return {
|
return puzzleImportResult(
|
||||||
document: importFpuzzles(url.value),
|
importFpuzzles(url.value),
|
||||||
format: "fpuzzles",
|
"fpuzzles",
|
||||||
label: "f-puzzles",
|
"f-puzzles",
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trimmed.startsWith("#sudoku=") || trimmed.includes("#sudoku=")) {
|
if (trimmed.startsWith("#sudoku=") || trimmed.includes("#sudoku=")) {
|
||||||
return {
|
return puzzleImportResult(
|
||||||
document: decodePuzzleHash(trimmed),
|
decodePuzzleHash(trimmed),
|
||||||
format: "sudoku-tools",
|
"sudoku-tools",
|
||||||
label: "Sudoku Tools share link",
|
"Sudoku Tools share link",
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
if (/^(?:ctc|scl)/iu.test(trimmed)) {
|
if (/^(?:ctc|scl)/iu.test(trimmed)) {
|
||||||
return {
|
return puzzleImportResult(
|
||||||
document: importSudokuPad(trimmed),
|
importSudokuPad(trimmed),
|
||||||
format: "sudokupad",
|
"sudokupad",
|
||||||
label: "SudokuPad/CTC",
|
"SudokuPad/CTC",
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
if (/^penpa:/iu.test(trimmed) || /^[?#]?(?:m=[^&]+&)?p=/iu.test(trimmed)) {
|
if (/^penpa:/iu.test(trimmed) || /^[?#]?(?:m=[^&]+&)?p=/iu.test(trimmed)) {
|
||||||
return {
|
return puzzleImportResult(await importPenpa(trimmed), "penpa", "Penpa+");
|
||||||
document: await importPenpa(trimmed),
|
|
||||||
format: "penpa",
|
|
||||||
label: "Penpa+",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (/^(?:square|sudoku),[^\r\n]+[\r\n]/iu.test(trimmed)) {
|
if (/^(?:square|sudoku),[^\r\n]+[\r\n]/iu.test(trimmed)) {
|
||||||
return {
|
return puzzleImportResult(parsePenpaText(trimmed), "penpa", "Penpa+ text");
|
||||||
document: parsePenpaText(trimmed),
|
|
||||||
format: "penpa",
|
|
||||||
label: "Penpa+ text",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (/^fpuzzles/iu.test(trimmed)) {
|
if (/^fpuzzles/iu.test(trimmed)) {
|
||||||
return {
|
return puzzleImportResult(importFpuzzles(trimmed), "fpuzzles", "f-puzzles");
|
||||||
document: importFpuzzles(trimmed),
|
|
||||||
format: "fpuzzles",
|
|
||||||
label: "f-puzzles",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (trimmed.startsWith("{")) {
|
if (trimmed.startsWith("{")) {
|
||||||
if (new TextEncoder().encode(trimmed).byteLength > MAX_DOCUMENT_BYTES) {
|
if (new TextEncoder().encode(trimmed).byteLength > MAX_DOCUMENT_BYTES) {
|
||||||
@@ -162,28 +147,28 @@ export async function importPuzzle(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (isRecord(parsed) && "schema" in parsed) {
|
if (isRecord(parsed) && "schema" in parsed) {
|
||||||
return {
|
return puzzleImportResult(
|
||||||
document: parseSudokuDocument(trimmed),
|
parseSudokuDocument(trimmed),
|
||||||
format: "sudoku-tools",
|
"sudoku-tools",
|
||||||
label: "Sudoku Tools JSON",
|
"Sudoku Tools JSON",
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
if (isRecord(parsed) && "cells" in parsed && !("grid" in parsed)) {
|
if (isRecord(parsed) && "cells" in parsed && !("grid" in parsed)) {
|
||||||
return {
|
return puzzleImportResult(
|
||||||
document: parseSudokuPadPuzzle(parsed),
|
parseSudokuPadPuzzle(parsed),
|
||||||
format: "sudokupad",
|
"sudokupad",
|
||||||
label: "SudokuPad/CTC JSON",
|
"SudokuPad/CTC JSON",
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
return {
|
return puzzleImportResult(
|
||||||
document: parseFpuzzles(parsed),
|
parseFpuzzles(parsed),
|
||||||
format: "fpuzzles",
|
"fpuzzles",
|
||||||
label: "f-puzzles JSON",
|
"f-puzzles JSON",
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
return {
|
return puzzleImportResult(
|
||||||
document: parsePlainGrid(trimmed),
|
parsePlainGrid(trimmed),
|
||||||
format: "plain-grid",
|
"plain-grid",
|
||||||
label: "plain grid",
|
"plain grid",
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
export * from "./constraintVisuals";
|
||||||
export * from "./document";
|
export * from "./document";
|
||||||
export * from "./fpuzzles";
|
export * from "./fpuzzles";
|
||||||
export * from "./grid";
|
export * from "./grid";
|
||||||
export * from "./import";
|
export * from "./import";
|
||||||
export * from "./interoperability";
|
export * from "./interoperability";
|
||||||
export * from "./penpa";
|
export * from "./penpa";
|
||||||
|
export * from "./safeVisuals";
|
||||||
export * from "./share";
|
export * from "./share";
|
||||||
export * from "./sudokupad";
|
export * from "./sudokupad";
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
|
|||||||
@@ -1,12 +1,129 @@
|
|||||||
import { SudokuFormatError } from "./document";
|
import { SudokuFormatError } from "./document";
|
||||||
|
import type { SudokuDocument, SudokuSourceFormat } from "./types";
|
||||||
|
|
||||||
export type PuzzleSourceFormat =
|
export type PuzzleSourceFormat = SudokuSourceFormat;
|
||||||
"sudoku-tools" | "plain-grid" | "fpuzzles" | "sudokupad" | "penpa";
|
|
||||||
|
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> {
|
export interface PuzzleImportResult<T> {
|
||||||
readonly document: T;
|
readonly document: T;
|
||||||
readonly format: PuzzleSourceFormat;
|
readonly format: PuzzleSourceFormat;
|
||||||
readonly label: string;
|
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 {
|
export class UnsupportedPuzzleConstructsError extends SudokuFormatError {
|
||||||
|
|||||||
@@ -452,6 +452,7 @@ export function parsePenpaText(compressedText: string): SudokuDocument {
|
|||||||
givens,
|
givens,
|
||||||
values,
|
values,
|
||||||
constraints,
|
constraints,
|
||||||
|
source: { format: "penpa" },
|
||||||
...(title === undefined || title === "" ? {} : { title }),
|
...(title === undefined || title === "" ? {} : { title }),
|
||||||
...(author === undefined || author === "" ? {} : { author }),
|
...(author === undefined || author === "" ? {} : { author }),
|
||||||
...(rules === undefined ? {} : { rules: [rules] }),
|
...(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.",
|
"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 {
|
import {
|
||||||
LzStringOutputLimitError,
|
LzStringOutputLimitError,
|
||||||
decompressFromBase64OrUriComponentBounded,
|
decompressFromBase64OrUriComponentBounded,
|
||||||
@@ -7,16 +8,38 @@ import {
|
|||||||
MAX_DOCUMENT_BYTES,
|
MAX_DOCUMENT_BYTES,
|
||||||
MIN_BOARD_SIZE,
|
MIN_BOARD_SIZE,
|
||||||
SudokuFormatError,
|
SudokuFormatError,
|
||||||
|
normalizeSudokuDocument,
|
||||||
} from "./document";
|
} from "./document";
|
||||||
|
import { canonicalConstraintVisuals } from "./constraintVisuals";
|
||||||
import { UnsupportedPuzzleConstructsError } from "./interoperability";
|
import { UnsupportedPuzzleConstructsError } from "./interoperability";
|
||||||
|
import {
|
||||||
|
SafeVisualValidationError,
|
||||||
|
normalizeSafeVisualColor,
|
||||||
|
normalizeSafeVisualPrimitives,
|
||||||
|
normalizeScalarMetadata,
|
||||||
|
} from "./safeVisuals";
|
||||||
import {
|
import {
|
||||||
SUDOKU_DOCUMENT_SCHEMA,
|
SUDOKU_DOCUMENT_SCHEMA,
|
||||||
SUDOKU_DOCUMENT_VERSION,
|
SUDOKU_DOCUMENT_VERSION,
|
||||||
type PortableConstraint,
|
type PortableConstraint,
|
||||||
|
type SafeVisualAnchor,
|
||||||
|
type SafeVisualPrimitive,
|
||||||
|
type SafeVisualStyle,
|
||||||
type SudokuDocument,
|
type SudokuDocument,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const MAX_SUDOKUPAD_PAYLOAD_LENGTH = 262_144;
|
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>;
|
type JsonRecord = Record<string, unknown>;
|
||||||
|
|
||||||
@@ -38,6 +61,8 @@ const ROOT_FIELDS = new Set([
|
|||||||
"author",
|
"author",
|
||||||
"rules",
|
"rules",
|
||||||
"solution",
|
"solution",
|
||||||
|
"duration",
|
||||||
|
"foglight",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const CELL_FIELDS = new Set(["value", "given", "pencilMarks", "centremarks"]);
|
const CELL_FIELDS = new Set(["value", "given", "pencilMarks", "centremarks"]);
|
||||||
@@ -58,6 +83,21 @@ function present(value: unknown): boolean {
|
|||||||
return true;
|
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) {
|
function boundedText(value: unknown, label: string, maximum = 16_384) {
|
||||||
if (value === undefined || value === null || value === "") return undefined;
|
if (value === undefined || value === null || value === "") return undefined;
|
||||||
if (typeof value !== "string" || value.length > maximum) {
|
if (typeof value !== "string" || value.length > maximum) {
|
||||||
@@ -90,6 +130,378 @@ function integer(
|
|||||||
return parsed as number;
|
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 {
|
function coordinate(value: unknown, size: number, label: string): number {
|
||||||
if (
|
if (
|
||||||
!Array.isArray(value) ||
|
!Array.isArray(value) ||
|
||||||
@@ -272,6 +684,44 @@ function parseCages(
|
|||||||
return constraints;
|
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. */
|
/** Parse raw, already-decoded SudokuPad/CTC (often called SCL) JSON. */
|
||||||
export function parseSudokuPadPuzzle(value: unknown): SudokuDocument {
|
export function parseSudokuPadPuzzle(value: unknown): SudokuDocument {
|
||||||
if (!isRecord(value)) {
|
if (!isRecord(value)) {
|
||||||
@@ -282,9 +732,6 @@ export function parseSudokuPadPuzzle(value: unknown): SudokuDocument {
|
|||||||
if (!ROOT_FIELDS.has(field))
|
if (!ROOT_FIELDS.has(field))
|
||||||
unsupported.push(`unknown root field “${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 (present(value.global)) unsupported.push("custom global rules");
|
||||||
if (isRecord(value.settings)) {
|
if (isRecord(value.settings)) {
|
||||||
for (const key of Object.keys(value.settings)) {
|
for (const key of Object.keys(value.settings)) {
|
||||||
@@ -306,6 +753,13 @@ export function parseSudokuPadPuzzle(value: unknown): SudokuDocument {
|
|||||||
MIN_BOARD_SIZE,
|
MIN_BOARD_SIZE,
|
||||||
MAX_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)) {
|
if (!value.cells.every((row) => Array.isArray(row) && row.length === size)) {
|
||||||
return fail(
|
return fail(
|
||||||
"INVALID_SUDOKUPAD",
|
"INVALID_SUDOKUPAD",
|
||||||
@@ -364,15 +818,47 @@ export function parseSudokuPadPuzzle(value: unknown): SudokuDocument {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const metadata = metadataFrom(value);
|
const metadata = metadataFrom(value);
|
||||||
const constraints = parseCages(value.cages, size, metadata);
|
const title = metaText(value, metadata, "title");
|
||||||
if (metadata.antiknight === true || metadata.antiknight === "true") {
|
const author = metaText(value, metadata, "author");
|
||||||
constraints.push({ type: "anti-knight" });
|
const rules = metaText(value, metadata, "rules");
|
||||||
}
|
const solution = solutionDigits(metaText(value, metadata, "solution"), size);
|
||||||
if (metadata.antiking === true || metadata.antiking === "true") {
|
const cageConstraints = parseCages(value.cages, size, metadata);
|
||||||
constraints.push({ type: "anti-king" });
|
const transported = embeddedConstraints(
|
||||||
}
|
metadata[SUDOKU_TOOLS_CONSTRAINTS_METADATA],
|
||||||
if (metadata.nonconsecutive === true || metadata.nonconsecutive === "true") {
|
size,
|
||||||
constraints.push({ type: "non-consecutive" });
|
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([
|
const knownMetadata = new Set([
|
||||||
"title",
|
"title",
|
||||||
@@ -382,23 +868,41 @@ export function parseSudokuPadPuzzle(value: unknown): SudokuDocument {
|
|||||||
"antiknight",
|
"antiknight",
|
||||||
"antiking",
|
"antiking",
|
||||||
"nonconsecutive",
|
"nonconsecutive",
|
||||||
|
SUDOKU_TOOLS_CONSTRAINTS_METADATA,
|
||||||
]);
|
]);
|
||||||
const unknownMetadata = Object.keys(metadata).filter(
|
const rawScalarMetadata: Record<string, unknown> = {};
|
||||||
(key) => !knownMetadata.has(key),
|
for (const [key, raw] of Object.entries(metadata)) {
|
||||||
);
|
if (!knownMetadata.has(key)) rawScalarMetadata[key] = raw;
|
||||||
if (unknownMetadata.length > 0) {
|
|
||||||
throw new UnsupportedPuzzleConstructsError(
|
|
||||||
"SudokuPad/CTC",
|
|
||||||
unknownMetadata.map((key) => `metadata “${key}”`),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
const title = metaText(value, metadata, "title");
|
isRecord(value.settings) &&
|
||||||
const author = metaText(value, metadata, "author");
|
value.settings.conflictchecker !== undefined
|
||||||
const rules = metaText(value, metadata, "rules");
|
) {
|
||||||
const solution = solutionDigits(metaText(value, metadata, "solution"), size);
|
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 regions = parseRegions(value.regions, size);
|
||||||
const id = boundedText(value.id, "SudokuPad id", 256);
|
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 {
|
return {
|
||||||
schema: SUDOKU_DOCUMENT_SCHEMA,
|
schema: SUDOKU_DOCUMENT_SCHEMA,
|
||||||
version: SUDOKU_DOCUMENT_VERSION,
|
version: SUDOKU_DOCUMENT_VERSION,
|
||||||
@@ -414,6 +918,13 @@ export function parseSudokuPadPuzzle(value: unknown): SudokuDocument {
|
|||||||
...(rules === undefined ? {} : { rules: [rules.slice(0, 16_384)] }),
|
...(rules === undefined ? {} : { rules: [rules.slice(0, 16_384)] }),
|
||||||
...(solution === undefined ? {} : { solution }),
|
...(solution === undefined ? {} : { solution }),
|
||||||
...(id === undefined ? {} : { id }),
|
...(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)));
|
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 CellIndex = number;
|
||||||
export type OutsideSide = "top" | "right" | "bottom" | "left";
|
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 {
|
export interface CluePolarity {
|
||||||
/** When true, the completed clue must be false rather than true. */
|
/** When true, the completed clue must be false rather than true. */
|
||||||
readonly negated?: boolean;
|
readonly negated?: boolean;
|
||||||
@@ -21,6 +104,7 @@ export type PortableConstraint =
|
|||||||
| { readonly type: "anti-knight" }
|
| { readonly type: "anti-knight" }
|
||||||
| { readonly type: "anti-king" }
|
| { readonly type: "anti-king" }
|
||||||
| { readonly type: "non-consecutive" }
|
| { readonly type: "non-consecutive" }
|
||||||
|
| { readonly type: "disjoint-groups" }
|
||||||
| ({
|
| ({
|
||||||
readonly type: "killer-cage";
|
readonly type: "killer-cage";
|
||||||
readonly cells: readonly CellIndex[];
|
readonly cells: readonly CellIndex[];
|
||||||
@@ -71,6 +155,59 @@ export type PortableConstraint =
|
|||||||
readonly digits: readonly number[];
|
readonly digits: readonly number[];
|
||||||
} & CluePolarity)
|
} & CluePolarity)
|
||||||
| ({ readonly type: "maximum"; readonly cell: CellIndex } & 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 type: "renban";
|
||||||
readonly cells: readonly CellIndex[];
|
readonly cells: readonly CellIndex[];
|
||||||
@@ -107,6 +244,12 @@ export interface SudokuDocument {
|
|||||||
/** Named boolean/global rules which cannot be represented by a local shape. */
|
/** Named boolean/global rules which cannot be represented by a local shape. */
|
||||||
readonly globalRules?: readonly string[];
|
readonly globalRules?: readonly string[];
|
||||||
readonly id?: 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. */
|
/** 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 {
|
return {
|
||||||
schema: SUDOKU_DOCUMENT_SCHEMA,
|
...(document.visuals === undefined
|
||||||
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] }),
|
: { 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(
|
export function cloneConstraint(
|
||||||
constraint: PortableConstraint,
|
constraint: PortableConstraint,
|
||||||
): PortableConstraint {
|
): PortableConstraint {
|
||||||
@@ -176,7 +408,23 @@ export function cloneConstraint(
|
|||||||
case "thermo":
|
case "thermo":
|
||||||
case "renban":
|
case "renban":
|
||||||
case "palindrome":
|
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] };
|
return { ...constraint, cells: [...constraint.cells] };
|
||||||
|
case "fog":
|
||||||
|
return { ...constraint, lights: [...constraint.lights] };
|
||||||
|
case "clone":
|
||||||
|
return {
|
||||||
|
...constraint,
|
||||||
|
cells: [...constraint.cells],
|
||||||
|
cloneCells: [...constraint.cloneCells],
|
||||||
|
};
|
||||||
case "quadruple":
|
case "quadruple":
|
||||||
return {
|
return {
|
||||||
...constraint,
|
...constraint,
|
||||||
|
|||||||
+206
-9
@@ -1,3 +1,4 @@
|
|||||||
|
import { littleKillerCells } from "../domain/geometry";
|
||||||
import { normalizePuzzle } from "../domain/validation";
|
import { normalizePuzzle } from "../domain/validation";
|
||||||
import type {
|
import type {
|
||||||
NormalizedPuzzle,
|
NormalizedPuzzle,
|
||||||
@@ -6,7 +7,12 @@ import type {
|
|||||||
} from "../domain/types";
|
} from "../domain/types";
|
||||||
import { symbolFor } from "../state/session";
|
import { symbolFor } from "../state/session";
|
||||||
import { normalizeSudokuDocument, SudokuFormatError } from "./document";
|
import { normalizeSudokuDocument, SudokuFormatError } from "./document";
|
||||||
import { toDomainPuzzle, type SudokuDocument } from "./types";
|
import {
|
||||||
|
toDomainPuzzle,
|
||||||
|
type SafeVisualAnchor,
|
||||||
|
type SafeVisualPrimitive,
|
||||||
|
type SudokuDocument,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
export interface VisualExportOptions {
|
export interface VisualExportOptions {
|
||||||
readonly includeProgress?: boolean;
|
readonly includeProgress?: boolean;
|
||||||
@@ -76,6 +82,73 @@ function linePoints(
|
|||||||
.join(" ");
|
.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(
|
function pathBoundary(
|
||||||
size: number,
|
size: number,
|
||||||
cells: ReadonlySet<number>,
|
cells: ReadonlySet<number>,
|
||||||
@@ -141,6 +214,8 @@ function globalRuleLabels(puzzle: NormalizedPuzzle): string[] {
|
|||||||
labels.push("Anti-king");
|
labels.push("Anti-king");
|
||||||
if (puzzle.constraints.some(({ type }) => type === "non-consecutive"))
|
if (puzzle.constraints.some(({ type }) => type === "non-consecutive"))
|
||||||
labels.push("Non-consecutive");
|
labels.push("Non-consecutive");
|
||||||
|
if (puzzle.constraints.some(({ type }) => type === "disjoint-groups"))
|
||||||
|
labels.push("Disjoint groups");
|
||||||
return labels;
|
return labels;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,10 +232,41 @@ function renderConstraint(
|
|||||||
if (
|
if (
|
||||||
constraint.type === "anti-knight" ||
|
constraint.type === "anti-knight" ||
|
||||||
constraint.type === "anti-king" ||
|
constraint.type === "anti-king" ||
|
||||||
constraint.type === "non-consecutive"
|
constraint.type === "non-consecutive" ||
|
||||||
|
constraint.type === "disjoint-groups"
|
||||||
) {
|
) {
|
||||||
return "";
|
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") {
|
if (constraint.type === "diagonal") {
|
||||||
const startX =
|
const startX =
|
||||||
origin.x + (constraint.direction === "main" ? 0 : size * CELL_SIZE);
|
origin.x + (constraint.direction === "main" ? 0 : size * CELL_SIZE);
|
||||||
@@ -173,6 +279,9 @@ function renderConstraint(
|
|||||||
const label = point(size, first, origin);
|
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>`;
|
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") {
|
if (constraint.type === "thermo") {
|
||||||
const bulb = point(size, constraint.cells[0]!, origin);
|
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>`;
|
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));
|
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>`;
|
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)}"/>${
|
return `<g class="constraint ${constraint.type}${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, constraint.cells, origin)}"/>${
|
||||||
constraint.type === "palindrome"
|
constraint.type === "palindrome"
|
||||||
? constraint.cells
|
? constraint.cells
|
||||||
@@ -200,12 +314,43 @@ function renderConstraint(
|
|||||||
: ""
|
: ""
|
||||||
}</g>`;
|
}</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") {
|
if (constraint.type === "maximum") {
|
||||||
const center = point(size, constraint.cell, origin);
|
const center = point(size, constraint.cell, origin);
|
||||||
const offset = CELL_SIZE * 0.28;
|
const offset = CELL_SIZE * 0.28;
|
||||||
const inner = CELL_SIZE * 0.14;
|
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>`;
|
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") {
|
if (constraint.type === "quadruple") {
|
||||||
const positions = constraint.cells.map((cell) => point(size, cell, origin));
|
const positions = constraint.cells.map((cell) => point(size, cell, origin));
|
||||||
const center = {
|
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>`;
|
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(
|
const position = outsidePoint(
|
||||||
size,
|
size,
|
||||||
constraint.side,
|
constraint.side,
|
||||||
@@ -226,9 +375,10 @@ function renderConstraint(
|
|||||||
origin,
|
origin,
|
||||||
);
|
);
|
||||||
const value =
|
const value =
|
||||||
constraint.type === "x-sum" ? constraint.sum : constraint.count;
|
constraint.type === "skyscraper" ? constraint.count : constraint.sum;
|
||||||
const companionIndex = puzzle.constraints.findIndex(
|
const companionIndex = puzzle.constraints.findIndex(
|
||||||
(candidate) =>
|
(candidate) =>
|
||||||
|
constraint.type !== "sandwich" &&
|
||||||
candidate.type !== constraint.type &&
|
candidate.type !== constraint.type &&
|
||||||
(candidate.type === "x-sum" || candidate.type === "skyscraper") &&
|
(candidate.type === "x-sum" || candidate.type === "skyscraper") &&
|
||||||
candidate.side === constraint.side &&
|
candidate.side === constraint.side &&
|
||||||
@@ -242,7 +392,36 @@ function renderConstraint(
|
|||||||
if (combined) {
|
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 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(
|
const a = point(
|
||||||
size,
|
size,
|
||||||
@@ -329,7 +508,11 @@ export function renderPuzzleSvg(
|
|||||||
const includeProgress = options.includeProgress ?? true;
|
const includeProgress = options.includeProgress ?? true;
|
||||||
const includeNotes = options.includeNotes ?? includeProgress;
|
const includeNotes = options.includeNotes ?? includeProgress;
|
||||||
const hasOutside = puzzle.constraints.some(
|
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 sideMargin = hasOutside ? OUTSIDE_MARGIN : BOARD_MARGIN;
|
||||||
const boardSize = puzzle.size * CELL_SIZE;
|
const boardSize = puzzle.size * CELL_SIZE;
|
||||||
@@ -384,6 +567,20 @@ export function renderPuzzleSvg(
|
|||||||
renderConstraint(constraint, index, puzzle, origin),
|
renderConstraint(constraint, index, puzzle, origin),
|
||||||
)
|
)
|
||||||
.join("");
|
.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 digits = Array.from({ length: cellCount }, (_, cell) => {
|
||||||
const value = puzzle.givens[cell] || values[cell] || 0;
|
const value = puzzle.givens[cell] || values[cell] || 0;
|
||||||
const center = point(puzzle.size, cell, origin);
|
const center = point(puzzle.size, cell, origin);
|
||||||
@@ -425,11 +622,11 @@ export function renderPuzzleSvg(
|
|||||||
<title id="title">${escapeXml(title)}</title>
|
<title id="title">${escapeXml(title)}</title>
|
||||||
<desc id="description">${escapeXml(`${String(puzzle.size)} by ${String(puzzle.size)} Sudoku${includeProgress ? " with current progress" : ""}`)}</desc>
|
<desc id="description">${escapeXml(`${String(puzzle.size)} by ${String(puzzle.size)} Sudoku${includeProgress ? " with current progress" : ""}`)}</desc>
|
||||||
<style>
|
<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>
|
</style>
|
||||||
<rect class="background" width="100%" height="100%"/>
|
<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 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>`;
|
</svg>`;
|
||||||
if (new TextEncoder().encode(svg).byteLength > MAX_VISUAL_EXPORT_BYTES) {
|
if (new TextEncoder().encode(svg).byteLength > MAX_VISUAL_EXPORT_BYTES) {
|
||||||
throw new SudokuFormatError(
|
throw new SudokuFormatError(
|
||||||
|
|||||||
@@ -139,6 +139,10 @@ export function houseLabel(unit: Pick<SudokuUnit, "kind" | "index">): string {
|
|||||||
return `Region ${String(unit.index + 1)}`;
|
return `Region ${String(unit.index + 1)}`;
|
||||||
case "diagonal":
|
case "diagonal":
|
||||||
return unit.index === 0 ? "Main diagonal" : "Anti-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 />
|
<App />
|
||||||
</StrictMode>,
|
</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,
|
"xy-wing": 57,
|
||||||
"xyz-wing": 61,
|
"xyz-wing": 61,
|
||||||
swordfish: 66,
|
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(
|
function boundedInteger(
|
||||||
@@ -151,12 +162,15 @@ export function evaluateDifficulty(
|
|||||||
120_000,
|
120_000,
|
||||||
"exactTimeoutMs",
|
"exactTimeoutMs",
|
||||||
);
|
);
|
||||||
const logical = solveLogically(normalized, { maxSteps: logicalMaxSteps });
|
|
||||||
const exact = solveExact(normalized, {
|
const exact = solveExact(normalized, {
|
||||||
maxSolutions: 2,
|
maxSolutions: 2,
|
||||||
maxNodes: exactMaxNodes,
|
maxNodes: exactMaxNodes,
|
||||||
timeoutMs: exactTimeoutMs,
|
timeoutMs: exactTimeoutMs,
|
||||||
});
|
});
|
||||||
|
const logical = solveLogically(normalized, {
|
||||||
|
maxSteps: logicalMaxSteps,
|
||||||
|
uniquenessProven: exact.count === 1 && !exact.truncated,
|
||||||
|
});
|
||||||
|
|
||||||
if (exact.count === 0 && !exact.truncated) {
|
if (exact.count === 0 && !exact.truncated) {
|
||||||
return unrated(
|
return unrated(
|
||||||
|
|||||||
+280
-36
@@ -7,12 +7,45 @@ import {
|
|||||||
import { solveExact } from "./exact";
|
import { solveExact } from "./exact";
|
||||||
import { seededRandom, shuffled } from "./random";
|
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 {
|
export interface MinimizeOptions {
|
||||||
readonly seed?: string | number;
|
readonly seed?: string | number;
|
||||||
readonly targetClues?: number;
|
readonly targetClues?: number;
|
||||||
readonly symmetry?: ClueSymmetry;
|
readonly symmetry?: ClueSymmetry;
|
||||||
|
/** Continue with individual clue deletion tests until givens are minimal. */
|
||||||
|
readonly minimalGivens?: boolean;
|
||||||
readonly maxChecks?: number;
|
readonly maxChecks?: number;
|
||||||
readonly solveMaxNodes?: number;
|
readonly solveMaxNodes?: number;
|
||||||
readonly solveTimeoutMs?: number;
|
readonly solveTimeoutMs?: number;
|
||||||
@@ -117,11 +150,106 @@ function boundedOption(
|
|||||||
return result;
|
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,
|
puzzle: PuzzleDefinition | NormalizedPuzzle,
|
||||||
options: MinimizeOptions = {},
|
options: MinimizeOptions = {},
|
||||||
): NormalizedPuzzle {
|
): MinimizePuzzleResult {
|
||||||
const normalized = normalizePuzzle(puzzle);
|
const normalized = normalizePuzzle(puzzle);
|
||||||
|
const minimalGivens = options.minimalGivens === true;
|
||||||
const targetClues = boundedOption(
|
const targetClues = boundedOption(
|
||||||
options.targetClues,
|
options.targetClues,
|
||||||
Math.max(
|
Math.max(
|
||||||
@@ -134,17 +262,30 @@ export function minimizePuzzle(
|
|||||||
);
|
);
|
||||||
const maxChecks = boundedOption(
|
const maxChecks = boundedOption(
|
||||||
options.maxChecks,
|
options.maxChecks,
|
||||||
normalized.size * normalized.size * 2,
|
normalized.size * normalized.size * (minimalGivens ? 3 : 2),
|
||||||
1,
|
1,
|
||||||
normalized.size * normalized.size * 10,
|
normalized.size * normalized.size * 10,
|
||||||
"maxChecks",
|
"maxChecks",
|
||||||
);
|
);
|
||||||
const symmetry = options.symmetry ?? "rotational";
|
const solveMaxNodes = boundedOption(
|
||||||
if (symmetry !== "none" && symmetry !== "rotational") {
|
options.solveMaxNodes,
|
||||||
throw new RangeError('symmetry must be "none" or "rotational"');
|
2_000_000,
|
||||||
}
|
1,
|
||||||
const random = seededRandom(options.seed ?? "sudoku-tools");
|
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 givens = [...normalized.givens];
|
||||||
|
const initialClues = givens.filter((value) => value !== 0).length;
|
||||||
const countClues = (): number =>
|
const countClues = (): number =>
|
||||||
givens.reduce((count, value) => count + (value === 0 ? 0 : 1), 0);
|
givens.reduce((count, value) => count + (value === 0 ? 0 : 1), 0);
|
||||||
const order = shuffled(
|
const order = shuffled(
|
||||||
@@ -152,36 +293,139 @@ export function minimizePuzzle(
|
|||||||
random,
|
random,
|
||||||
);
|
);
|
||||||
let checks = 0;
|
let checks = 0;
|
||||||
for (const cell of order) {
|
let nodes = 0;
|
||||||
if (checks >= maxChecks || countClues() <= targetClues) break;
|
const criticalCells: number[] = [];
|
||||||
if (givens[cell] === 0) continue;
|
const unknownCells: number[] = [];
|
||||||
const mirror = givens.length - cell - 1;
|
const limitReasons = new Set<MinimalityLimitReason>();
|
||||||
const group =
|
let uniquenessPrerequisiteProven = true;
|
||||||
symmetry === "rotational" && mirror !== cell ? [cell, mirror] : [cell];
|
|
||||||
if (group.some((entry) => givens[entry] === 0)) continue;
|
// Minimality is meaningful only for a uniquely solvable starting puzzle.
|
||||||
if (countClues() - group.length < targetClues) continue;
|
// This check shares the same explicit check, node and wall-clock budgets as
|
||||||
const saved = group.map((entry) => givens[entry] ?? 0);
|
// every subsequent deletion test.
|
||||||
group.forEach((entry) => {
|
if (minimalGivens) {
|
||||||
givens[entry] = 0;
|
|
||||||
});
|
|
||||||
checks += 1;
|
checks += 1;
|
||||||
const candidate: PuzzleDefinition = {
|
const baseline = uniqueAfterRemoval(
|
||||||
...normalized,
|
{ ...normalized, givens, solution: normalized.solution },
|
||||||
givens,
|
solveMaxNodes,
|
||||||
solution: normalized.solution,
|
solveTimeoutMs,
|
||||||
};
|
);
|
||||||
const result = solveExact(candidate, {
|
nodes += baseline.nodes;
|
||||||
maxSolutions: 2,
|
if (baseline.count !== 1 || baseline.truncated) {
|
||||||
maxNodes: options.solveMaxNodes ?? 2_000_000,
|
uniquenessPrerequisiteProven = false;
|
||||||
timeoutMs: options.solveTimeoutMs ?? 10_000,
|
unknownCells.push(
|
||||||
});
|
...givens.flatMap((value, cell) => (value === 0 ? [] : [cell])),
|
||||||
if (result.count !== 1 || result.truncated) {
|
);
|
||||||
group.forEach((entry, index) => {
|
if (
|
||||||
givens[entry] = saved[index] ?? 0;
|
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(
|
export function generateClassic(
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
export * from "./advancedLogical";
|
||||||
export * from "./difficulty";
|
export * from "./difficulty";
|
||||||
export * from "./exact";
|
export * from "./exact";
|
||||||
export * from "./generator";
|
export * from "./generator";
|
||||||
export * from "./killer";
|
export * from "./killer";
|
||||||
export * from "./logical";
|
export * from "./logical";
|
||||||
|
export * from "./quality";
|
||||||
export * from "./variantGenerator";
|
export * from "./variantGenerator";
|
||||||
|
|||||||
+119
-4
@@ -12,6 +12,7 @@ import {
|
|||||||
type SudokuUnit,
|
type SudokuUnit,
|
||||||
type ValidationIssue,
|
type ValidationIssue,
|
||||||
} from "../domain";
|
} from "../domain";
|
||||||
|
import { findAdvancedLogicalStep } from "./advancedLogical";
|
||||||
|
|
||||||
export type LogicalTechnique =
|
export type LogicalTechnique =
|
||||||
| "naked-single"
|
| "naked-single"
|
||||||
@@ -26,6 +27,17 @@ export type LogicalTechnique =
|
|||||||
| "claiming"
|
| "claiming"
|
||||||
| "x-wing"
|
| "x-wing"
|
||||||
| "swordfish"
|
| "swordfish"
|
||||||
|
| "jellyfish"
|
||||||
|
| "finned-x-wing"
|
||||||
|
| "finned-swordfish"
|
||||||
|
| "skyscraper"
|
||||||
|
| "two-string-kite"
|
||||||
|
| "simple-colouring"
|
||||||
|
| "w-wing"
|
||||||
|
| "x-chain"
|
||||||
|
| "xy-chain"
|
||||||
|
| "aic"
|
||||||
|
| "unique-rectangle"
|
||||||
| "xy-wing"
|
| "xy-wing"
|
||||||
| "xyz-wing"
|
| "xyz-wing"
|
||||||
| "killer-cage";
|
| "killer-cage";
|
||||||
@@ -52,6 +64,19 @@ export type LogicalSolveStatus = "solved" | "stuck" | "invalid" | "step-limit";
|
|||||||
|
|
||||||
export interface LogicalSolveOptions {
|
export interface LogicalSolveOptions {
|
||||||
readonly values?: readonly number[];
|
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;
|
readonly maxSteps?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,9 +173,73 @@ function validateStart(
|
|||||||
if (issues.length > 0) throw new PuzzleValidationError(issues);
|
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(
|
function initializeState(
|
||||||
normalized: NormalizedPuzzle,
|
normalized: NormalizedPuzzle,
|
||||||
values: readonly number[],
|
values: readonly number[],
|
||||||
|
candidateRestrictions?: readonly number[],
|
||||||
): LogicalState {
|
): LogicalState {
|
||||||
const compiled = compilePuzzle(normalized);
|
const compiled = compilePuzzle(normalized);
|
||||||
return {
|
return {
|
||||||
@@ -158,10 +247,13 @@ function initializeState(
|
|||||||
values: [...values],
|
values: [...values],
|
||||||
masks: values.map((value, cell) => {
|
masks: values.map((value, cell) => {
|
||||||
if (value !== 0) return 0;
|
if (value !== 0) return 0;
|
||||||
return candidatesForCell(compiled, values, cell).reduce(
|
const legalMask = candidatesForCell(compiled, values, cell).reduce(
|
||||||
(mask, candidate) => mask | digitBit(candidate),
|
(mask, candidate) => mask | digitBit(candidate),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
return candidateRestrictions === undefined
|
||||||
|
? legalMask
|
||||||
|
: legalMask & (candidateRestrictions[cell] ?? 0);
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -624,7 +716,10 @@ function findKillerReduction(state: LogicalState): LogicalStep | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findStep(state: LogicalState): LogicalStep | undefined {
|
function findStep(
|
||||||
|
state: LogicalState,
|
||||||
|
uniquenessProven: boolean,
|
||||||
|
): LogicalStep | undefined {
|
||||||
return (
|
return (
|
||||||
findNakedSingle(state) ??
|
findNakedSingle(state) ??
|
||||||
findHiddenSingle(state) ??
|
findHiddenSingle(state) ??
|
||||||
@@ -634,6 +729,16 @@ function findStep(state: LogicalState): LogicalStep | undefined {
|
|||||||
findFish(state) ??
|
findFish(state) ??
|
||||||
findXyWing(state) ??
|
findXyWing(state) ??
|
||||||
findXyzWing(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)
|
findKillerReduction(state)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -677,7 +782,17 @@ export function solveLogically(
|
|||||||
if (!Number.isInteger(maxSteps) || maxSteps < 1 || maxSteps > 10_000) {
|
if (!Number.isInteger(maxSteps) || maxSteps < 1 || maxSteps > 10_000) {
|
||||||
throw new RangeError("maxSteps must be an integer from 1 to 10000");
|
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[] = [];
|
const steps: LogicalStep[] = [];
|
||||||
if (findConflicts(state.compiled, state.values).length > 0) {
|
if (findConflicts(state.compiled, state.values).length > 0) {
|
||||||
return {
|
return {
|
||||||
@@ -708,7 +823,7 @@ export function solveLogically(
|
|||||||
steps,
|
steps,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const step = findStep(state);
|
const step = findStep(state, options.uniquenessProven === true);
|
||||||
if (step === undefined) {
|
if (step === undefined) {
|
||||||
return {
|
return {
|
||||||
status: "stuck",
|
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 type { LogicalTechnique } from "./logical";
|
||||||
import {
|
import {
|
||||||
generateClassic,
|
generateClassic,
|
||||||
minimizePuzzle,
|
minimizePuzzleWithReport,
|
||||||
type ClueSymmetry,
|
type ClueSymmetry,
|
||||||
|
type MinimalGivensEvidence,
|
||||||
} from "./generator";
|
} from "./generator";
|
||||||
import { seededRandom, shuffled, type RandomSource } from "./random";
|
import { seededRandom, shuffled, type RandomSource } from "./random";
|
||||||
|
|
||||||
@@ -118,16 +119,61 @@ export const PRACTICE_TECHNIQUES = [
|
|||||||
"naked-quad",
|
"naked-quad",
|
||||||
"hidden-quad",
|
"hidden-quad",
|
||||||
"x-wing",
|
"x-wing",
|
||||||
|
"finned-x-wing",
|
||||||
"xy-wing",
|
"xy-wing",
|
||||||
"xyz-wing",
|
"xyz-wing",
|
||||||
"swordfish",
|
"swordfish",
|
||||||
|
"finned-swordfish",
|
||||||
|
"jellyfish",
|
||||||
|
"skyscraper",
|
||||||
|
"two-string-kite",
|
||||||
|
"simple-colouring",
|
||||||
|
"w-wing",
|
||||||
|
"x-chain",
|
||||||
|
"xy-chain",
|
||||||
|
"aic",
|
||||||
|
"unique-rectangle",
|
||||||
"killer-cage",
|
"killer-cage",
|
||||||
] as const satisfies readonly LogicalTechnique[];
|
] as const satisfies readonly LogicalTechnique[];
|
||||||
|
|
||||||
export type PracticeTechnique = (typeof PRACTICE_TECHNIQUES)[number];
|
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 {
|
export interface GenerateVariantOptions {
|
||||||
readonly variant?: GeneratorVariant;
|
readonly variant?: GeneratorVariant;
|
||||||
|
/** Canonicalized set of families for mixed-variant generation. */
|
||||||
|
readonly variants?: readonly GeneratorVariant[];
|
||||||
readonly size?: number;
|
readonly size?: number;
|
||||||
readonly boxRows?: number;
|
readonly boxRows?: number;
|
||||||
readonly boxColumns?: number;
|
readonly boxColumns?: number;
|
||||||
@@ -136,14 +182,18 @@ export interface GenerateVariantOptions {
|
|||||||
readonly targetDifficulty?: GenerationDifficultyTarget;
|
readonly targetDifficulty?: GenerationDifficultyTarget;
|
||||||
readonly targetClues?: number;
|
readonly targetClues?: number;
|
||||||
readonly symmetry?: ClueSymmetry;
|
readonly symmetry?: ClueSymmetry;
|
||||||
|
readonly minimalGivens?: boolean;
|
||||||
/** Number of local markings requested. Global rules, Killer and diagonal use structural counts. */
|
/** Number of local markings requested. Global rules, Killer and diagonal use structural counts. */
|
||||||
readonly constraintCount?: number;
|
readonly constraintCount?: number;
|
||||||
|
readonly constraintDensity?: ConstraintDensity;
|
||||||
readonly maxChecks?: number;
|
readonly maxChecks?: number;
|
||||||
readonly solveMaxNodes?: number;
|
readonly solveMaxNodes?: number;
|
||||||
readonly solveTimeoutMs?: number;
|
readonly solveTimeoutMs?: number;
|
||||||
readonly difficulty?: DifficultyOptions;
|
readonly difficulty?: DifficultyOptions;
|
||||||
/** Require the independently rated logical path to use this technique. */
|
/** Require the independently rated logical path to use this technique. */
|
||||||
readonly requiredTechnique?: PracticeTechnique;
|
readonly requiredTechnique?: PracticeTechnique;
|
||||||
|
/** Full independently checked logical-path profile. */
|
||||||
|
readonly techniqueProfile?: TechniqueProfile;
|
||||||
/** Bounded deterministic attempts used to find the requested technique. */
|
/** Bounded deterministic attempts used to find the requested technique. */
|
||||||
readonly maxTechniqueAttempts?: number;
|
readonly maxTechniqueAttempts?: number;
|
||||||
}
|
}
|
||||||
@@ -151,11 +201,50 @@ export interface GenerateVariantOptions {
|
|||||||
export interface GeneratedVariantPuzzle {
|
export interface GeneratedVariantPuzzle {
|
||||||
readonly puzzle: NormalizedPuzzle;
|
readonly puzzle: NormalizedPuzzle;
|
||||||
readonly difficulty: DifficultyAssessment;
|
readonly difficulty: DifficultyAssessment;
|
||||||
readonly variant: GeneratorVariant;
|
readonly variant: GeneratedVariantKind;
|
||||||
|
readonly families: readonly GeneratorVariant[];
|
||||||
readonly seed: string | number;
|
readonly seed: string | number;
|
||||||
readonly generatedConstraintCount: number;
|
readonly generatedConstraintCount: number;
|
||||||
readonly generationAttempts: number;
|
readonly generationAttempts: number;
|
||||||
|
readonly constraintDensity: ConstraintDensity;
|
||||||
|
readonly minimality: MinimalGivensEvidence;
|
||||||
readonly requestedTechnique?: PracticeTechnique;
|
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<
|
const DEFAULT_CONSTRAINT_COUNTS: Readonly<
|
||||||
@@ -229,6 +318,47 @@ function getDefinition(variant: string) {
|
|||||||
return GENERATOR_VARIANTS.find((definition) => definition.id === variant);
|
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 {
|
function clueTarget(size: number, target: GenerationDifficultyTarget): number {
|
||||||
const ratio: Record<GenerationDifficultyTarget, number> = {
|
const ratio: Record<GenerationDifficultyTarget, number> = {
|
||||||
beginner: 0.58,
|
beginner: 0.58,
|
||||||
@@ -240,6 +370,171 @@ function clueTarget(size: number, target: GenerationDifficultyTarget): number {
|
|||||||
return Math.max(size, Math.round(size * size * ratio[target]));
|
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]> {
|
function allEdges(size: number): Array<readonly [number, number]> {
|
||||||
const edges: Array<readonly [number, number]> = [];
|
const edges: Array<readonly [number, number]> = [];
|
||||||
for (let cell = 0; cell < size * size; cell += 1) {
|
for (let cell = 0; cell < size * size; cell += 1) {
|
||||||
@@ -285,6 +580,7 @@ function killerCages(
|
|||||||
size: number,
|
size: number,
|
||||||
solution: readonly number[],
|
solution: readonly number[],
|
||||||
random: RandomSource,
|
random: RandomSource,
|
||||||
|
density: ConstraintDensity,
|
||||||
): VariantConstraint[] {
|
): VariantConstraint[] {
|
||||||
const unassigned = new Set(
|
const unassigned = new Set(
|
||||||
Array.from({ length: size * size }, (_, cell) => cell),
|
Array.from({ length: size * size }, (_, cell) => cell),
|
||||||
@@ -292,7 +588,16 @@ function killerCages(
|
|||||||
const constraints: VariantConstraint[] = [];
|
const constraints: VariantConstraint[] = [];
|
||||||
for (const start of shuffled([...unassigned], random)) {
|
for (const start of shuffled([...unassigned], random)) {
|
||||||
if (!unassigned.has(start)) continue;
|
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 cells = [start];
|
||||||
const digits = new Set([solution[start]]);
|
const digits = new Set([solution[start]]);
|
||||||
unassigned.delete(start);
|
unassigned.delete(start);
|
||||||
@@ -497,8 +802,11 @@ function localConstraints(
|
|||||||
solution: readonly number[],
|
solution: readonly number[],
|
||||||
count: number,
|
count: number,
|
||||||
random: RandomSource,
|
random: RandomSource,
|
||||||
|
density: ConstraintDensity,
|
||||||
): VariantConstraint[] {
|
): VariantConstraint[] {
|
||||||
if (variant === "killer") return killerCages(size, solution, random);
|
if (variant === "killer") {
|
||||||
|
return killerCages(size, solution, random, density);
|
||||||
|
}
|
||||||
const candidates = (() => {
|
const candidates = (() => {
|
||||||
switch (variant) {
|
switch (variant) {
|
||||||
case "thermo":
|
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(
|
function fullSolution(
|
||||||
variant: GeneratorVariant,
|
families: readonly GeneratorVariant[],
|
||||||
size: number,
|
size: number,
|
||||||
boxRows: number | undefined,
|
boxRows: number | undefined,
|
||||||
boxColumns: number | undefined,
|
boxColumns: number | undefined,
|
||||||
@@ -536,12 +865,8 @@ function fullSolution(
|
|||||||
maxNodes: number,
|
maxNodes: number,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
): NormalizedPuzzle {
|
): NormalizedPuzzle {
|
||||||
const globalVariant =
|
const constraints = globalConstraints(families);
|
||||||
variant === "diagonal" ||
|
if (constraints.length === 0) {
|
||||||
variant === "anti-knight" ||
|
|
||||||
variant === "anti-king" ||
|
|
||||||
variant === "non-consecutive";
|
|
||||||
if (!globalVariant) {
|
|
||||||
return generateClassic({
|
return generateClassic({
|
||||||
size,
|
size,
|
||||||
boxRows,
|
boxRows,
|
||||||
@@ -551,13 +876,6 @@ function fullSolution(
|
|||||||
maxChecks: 1,
|
maxChecks: 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const constraints: VariantConstraint[] =
|
|
||||||
variant === "diagonal"
|
|
||||||
? [
|
|
||||||
{ type: "diagonal", direction: "main" },
|
|
||||||
{ type: "diagonal", direction: "anti" },
|
|
||||||
]
|
|
||||||
: [{ type: variant }];
|
|
||||||
const empty: PuzzleDefinition = {
|
const empty: PuzzleDefinition = {
|
||||||
version: 1,
|
version: 1,
|
||||||
size,
|
size,
|
||||||
@@ -569,17 +887,51 @@ function fullSolution(
|
|||||||
maxSolutions: 1,
|
maxSolutions: 1,
|
||||||
maxNodes,
|
maxNodes,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
seed: `${String(seed)}:${variant}-solution`,
|
seed: `${String(seed)}:${families.join("+")}-solution`,
|
||||||
});
|
});
|
||||||
const solution = solved.solutions[0];
|
const solution = solved.solutions[0];
|
||||||
if (solution === undefined) {
|
if (solution === undefined) {
|
||||||
throw new Error(
|
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 });
|
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
|
* Creates a unique, seedable variant puzzle. Generation is suitable for a Web
|
||||||
* Worker: every exact search has explicit node/time caps and worker termination
|
* Worker: every exact search has explicit node/time caps and worker termination
|
||||||
@@ -587,17 +939,21 @@ function fullSolution(
|
|||||||
*/
|
*/
|
||||||
function generateVariantAttempt(
|
function generateVariantAttempt(
|
||||||
options: GenerateVariantOptions = {},
|
options: GenerateVariantOptions = {},
|
||||||
|
techniqueProfile?: NormalizedTechniqueProfile,
|
||||||
): GeneratedVariantPuzzle {
|
): GeneratedVariantPuzzle {
|
||||||
const variant = options.variant ?? "classic";
|
const families = canonicalFamilies(options);
|
||||||
const definition = getDefinition(variant);
|
const variant: GeneratedVariantKind =
|
||||||
if (definition === undefined) {
|
families.length === 1 ? (families[0] as GeneratorVariant) : "mixed";
|
||||||
throw new RangeError(`Unsupported generator variant: ${String(variant)}.`);
|
|
||||||
}
|
|
||||||
const size = boundedInteger(options.size, 9, 4, 16, "size");
|
const size = boundedInteger(options.size, 9, 4, 16, "size");
|
||||||
if (!(definition.supportedSizes as readonly number[]).includes(size)) {
|
for (const family of families) {
|
||||||
throw new RangeError(
|
const definition = getDefinition(
|
||||||
`${definition.label} generation supports sizes ${definition.supportedSizes.join(", ")}.`,
|
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 seed = options.seed ?? "sudoku-tools";
|
||||||
const targetDifficulty = options.targetDifficulty ?? "medium";
|
const targetDifficulty = options.targetDifficulty ?? "medium";
|
||||||
@@ -610,7 +966,9 @@ function generateVariantAttempt(
|
|||||||
);
|
);
|
||||||
const maxChecks = boundedInteger(
|
const maxChecks = boundedInteger(
|
||||||
options.maxChecks,
|
options.maxChecks,
|
||||||
Math.min(size * size, 72),
|
options.minimalGivens === true
|
||||||
|
? Math.min(size * size * 3, size * size * 10)
|
||||||
|
: Math.min(size * size, 72),
|
||||||
1,
|
1,
|
||||||
size * size * 10,
|
size * size * 10,
|
||||||
"maxChecks",
|
"maxChecks",
|
||||||
@@ -629,23 +987,19 @@ function generateVariantAttempt(
|
|||||||
120_000,
|
120_000,
|
||||||
"solveTimeoutMs",
|
"solveTimeoutMs",
|
||||||
);
|
);
|
||||||
const requestedConstraintCount = boundedInteger(
|
const requestedConstraintCount =
|
||||||
options.constraintCount,
|
options.constraintCount === undefined
|
||||||
variant === "classic" ||
|
? undefined
|
||||||
variant === "diagonal" ||
|
: boundedInteger(
|
||||||
variant === "anti-knight" ||
|
options.constraintCount,
|
||||||
variant === "anti-king" ||
|
1,
|
||||||
variant === "non-consecutive" ||
|
1,
|
||||||
variant === "killer"
|
size * size * 2,
|
||||||
? 1
|
"constraintCount",
|
||||||
: DEFAULT_CONSTRAINT_COUNTS[variant],
|
);
|
||||||
1,
|
const constraintDensity = validatedDensity(options.constraintDensity);
|
||||||
size * size * 2,
|
|
||||||
"constraintCount",
|
|
||||||
);
|
|
||||||
const random = seededRandom(`${String(seed)}:${variant}:constraints`);
|
|
||||||
const full = fullSolution(
|
const full = fullSolution(
|
||||||
variant,
|
families,
|
||||||
size,
|
size,
|
||||||
options.boxRows,
|
options.boxRows,
|
||||||
options.boxColumns,
|
options.boxColumns,
|
||||||
@@ -657,22 +1011,28 @@ function generateVariantAttempt(
|
|||||||
if (solution === undefined) {
|
if (solution === undefined) {
|
||||||
throw new Error("Internal generator error: the completed grid was lost.");
|
throw new Error("Internal generator error: the completed grid was lost.");
|
||||||
}
|
}
|
||||||
const constraints: VariantConstraint[] =
|
const constraints: VariantConstraint[] = [...full.constraints];
|
||||||
variant === "classic"
|
for (const family of families) {
|
||||||
? []
|
if (!isLocalGeneratorVariant(family)) continue;
|
||||||
: variant === "diagonal" ||
|
const baseCount =
|
||||||
variant === "anti-knight" ||
|
requestedConstraintCount ??
|
||||||
variant === "anti-king" ||
|
(family === "killer" ? 1 : DEFAULT_CONSTRAINT_COUNTS[family]);
|
||||||
variant === "non-consecutive"
|
const familyCount = Math.max(
|
||||||
? [...full.constraints]
|
1,
|
||||||
: localConstraints(
|
Math.round(baseCount * densityMultiplier(constraintDensity)),
|
||||||
variant,
|
);
|
||||||
size,
|
constraints.push(
|
||||||
solution,
|
...localConstraints(
|
||||||
requestedConstraintCount,
|
family,
|
||||||
random,
|
size,
|
||||||
);
|
solution,
|
||||||
const title = `${definition.label} · ${String(seed)}`.slice(0, 256);
|
familyCount,
|
||||||
|
seededRandom(`${String(seed)}:${family}:constraints`),
|
||||||
|
constraintDensity,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const title = `${labelForFamilies(families)} · ${String(seed)}`.slice(0, 256);
|
||||||
const complete = normalizePuzzle({
|
const complete = normalizePuzzle({
|
||||||
version: 1,
|
version: 1,
|
||||||
size,
|
size,
|
||||||
@@ -682,29 +1042,41 @@ function generateVariantAttempt(
|
|||||||
solution,
|
solution,
|
||||||
title,
|
title,
|
||||||
author: "Sudoku Tools generator",
|
author: "Sudoku Tools generator",
|
||||||
rules: RULES[variant],
|
rules: rulesForFamilies(families),
|
||||||
});
|
});
|
||||||
const puzzle = minimizePuzzle(complete, {
|
const minimized = minimizePuzzleWithReport(complete, {
|
||||||
seed: `${String(seed)}:${variant}:clues`,
|
seed: `${String(seed)}:${families.join("+")}:clues`,
|
||||||
targetClues,
|
targetClues,
|
||||||
symmetry: options.symmetry ?? "rotational",
|
symmetry: options.symmetry ?? "rotational",
|
||||||
maxChecks,
|
maxChecks,
|
||||||
solveMaxNodes,
|
solveMaxNodes,
|
||||||
solveTimeoutMs,
|
solveTimeoutMs,
|
||||||
|
minimalGivens: options.minimalGivens,
|
||||||
});
|
});
|
||||||
|
const puzzle = minimized.puzzle;
|
||||||
const difficulty = evaluateDifficulty(puzzle, options.difficulty);
|
const difficulty = evaluateDifficulty(puzzle, options.difficulty);
|
||||||
if (difficulty.uniqueness !== "unique") {
|
if (difficulty.uniqueness !== "unique") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Generated puzzle did not pass the bounded uniqueness audit; no puzzle was returned.",
|
"Generated puzzle did not pass the bounded uniqueness audit; no puzzle was returned.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const profileEvidence =
|
||||||
|
techniqueProfile === undefined
|
||||||
|
? undefined
|
||||||
|
: evaluateNormalizedTechniqueProfile(difficulty, techniqueProfile);
|
||||||
return {
|
return {
|
||||||
puzzle,
|
puzzle,
|
||||||
difficulty,
|
difficulty,
|
||||||
variant,
|
variant,
|
||||||
|
families,
|
||||||
seed,
|
seed,
|
||||||
generatedConstraintCount: constraints.length,
|
generatedConstraintCount: constraints.length,
|
||||||
generationAttempts: 1,
|
generationAttempts: 1,
|
||||||
|
constraintDensity,
|
||||||
|
minimality: minimized.minimality,
|
||||||
|
...(profileEvidence === undefined
|
||||||
|
? {}
|
||||||
|
: { techniqueProfile: profileEvidence }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -716,15 +1088,18 @@ export function generateVariant(
|
|||||||
options: GenerateVariantOptions = {},
|
options: GenerateVariantOptions = {},
|
||||||
): GeneratedVariantPuzzle {
|
): GeneratedVariantPuzzle {
|
||||||
const requiredTechnique = options.requiredTechnique;
|
const requiredTechnique = options.requiredTechnique;
|
||||||
if (
|
const profile = normalizeTechniqueProfile(
|
||||||
requiredTechnique !== undefined &&
|
options.techniqueProfile,
|
||||||
!(PRACTICE_TECHNIQUES as readonly string[]).includes(requiredTechnique)
|
requiredTechnique,
|
||||||
) {
|
);
|
||||||
throw new RangeError(
|
const families = canonicalFamilies(options);
|
||||||
`Unsupported practice technique: ${String(requiredTechnique)}.`,
|
const requiresKiller =
|
||||||
);
|
profile?.hardestTechnique === "killer-cage" ||
|
||||||
}
|
profile?.requirements.some(
|
||||||
if (requiredTechnique === "killer-cage" && options.variant !== "killer") {
|
({ technique, minimum }) =>
|
||||||
|
technique === "killer-cage" && (minimum ?? 0) > 0,
|
||||||
|
) === true;
|
||||||
|
if (requiresKiller && !families.includes("killer")) {
|
||||||
throw new RangeError(
|
throw new RangeError(
|
||||||
"Killer-cage practice requires the Killer generator variant.",
|
"Killer-cage practice requires the Killer generator variant.",
|
||||||
);
|
);
|
||||||
@@ -732,7 +1107,7 @@ export function generateVariant(
|
|||||||
|
|
||||||
const maxAttempts = boundedInteger(
|
const maxAttempts = boundedInteger(
|
||||||
options.maxTechniqueAttempts,
|
options.maxTechniqueAttempts,
|
||||||
requiredTechnique === undefined ? 1 : 10,
|
profile === undefined ? 1 : 10,
|
||||||
1,
|
1,
|
||||||
32,
|
32,
|
||||||
"maxTechniqueAttempts",
|
"maxTechniqueAttempts",
|
||||||
@@ -742,11 +1117,14 @@ export function generateVariant(
|
|||||||
const seed =
|
const seed =
|
||||||
attempt === 0
|
attempt === 0
|
||||||
? baseSeed
|
? baseSeed
|
||||||
: `${String(baseSeed)}:practice:${String(requiredTechnique)}:${String(attempt + 1)}`;
|
: options.techniqueProfile === undefined &&
|
||||||
const generated = generateVariantAttempt({ ...options, seed });
|
requiredTechnique !== undefined
|
||||||
|
? `${String(baseSeed)}:practice:${String(requiredTechnique)}:${String(attempt + 1)}`
|
||||||
|
: `${String(baseSeed)}:profile:${String(attempt + 1)}`;
|
||||||
|
const generated = generateVariantAttempt({ ...options, seed }, profile);
|
||||||
if (
|
if (
|
||||||
requiredTechnique === undefined ||
|
profile === undefined ||
|
||||||
(generated.difficulty.techniqueCounts[requiredTechnique] ?? 0) > 0
|
generated.techniqueProfile?.status === "matched"
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
...generated,
|
...generated,
|
||||||
@@ -759,6 +1137,80 @@ export function generateVariant(
|
|||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
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 { maskValues, symbolFor, type EntryMode } from "./session";
|
||||||
|
import { colorMarkDescription } from "./uiPreferences";
|
||||||
|
|
||||||
export const AID_MEMOIRE_VERSION = 1 as const;
|
export const AID_MEMOIRE_VERSION = 1 as const;
|
||||||
export const MAX_AID_MEMOIRE_CELLS = 36;
|
export const MAX_AID_MEMOIRE_CELLS = 36;
|
||||||
@@ -260,7 +261,11 @@ export function aidMemoireCellDescription(
|
|||||||
`centre marks ${center.map((value) => symbolFor(value, size)).join(", ")}`,
|
`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(", ");
|
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,
|
type PlaySnapshot,
|
||||||
} from "./session";
|
} from "./session";
|
||||||
import {
|
import {
|
||||||
|
aidMemoireFromPortable,
|
||||||
|
aidMemoireToPortable,
|
||||||
cloneAidMemoire,
|
cloneAidMemoire,
|
||||||
createAidMemoire,
|
createAidMemoire,
|
||||||
type AidMemoireState,
|
type AidMemoireState,
|
||||||
@@ -57,6 +59,286 @@ export interface HistoryTransition {
|
|||||||
|
|
||||||
export const MAIN_BRANCH_ID = "main";
|
export const MAIN_BRANCH_ID = "main";
|
||||||
export const MAX_GAMEPLAY_MOMENTS = 500;
|
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 {
|
function cloneState(state: GameplayState): GameplayState {
|
||||||
return {
|
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 { SudokuFormatError } from "../formats";
|
||||||
import {
|
import {
|
||||||
cloneProjectRecord,
|
cloneProjectRecord,
|
||||||
|
createProjectRecord,
|
||||||
MAX_PROJECT_BYTES,
|
MAX_PROJECT_BYTES,
|
||||||
normalizeProjectRecord,
|
normalizeProjectRecord,
|
||||||
} from "./record";
|
} from "./record";
|
||||||
import type {
|
import type {
|
||||||
ProjectLibraryExport,
|
ProjectLibraryExport,
|
||||||
|
ProjectLibraryQuery,
|
||||||
SudokuProjectRecord,
|
SudokuProjectRecord,
|
||||||
SudokuProjectSummary,
|
SudokuProjectSummary,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const MAX_LIBRARY_PROJECTS = 256;
|
export const MAX_LIBRARY_PROJECTS = 256;
|
||||||
export const MAX_MEMORY_LIBRARY_BYTES = 32 * 1_048_576;
|
export const MAX_MEMORY_LIBRARY_BYTES = 32 * 1_048_576;
|
||||||
const DATABASE_VERSION = 1;
|
const DATABASE_VERSION = 2;
|
||||||
const STORE_NAME = "projects";
|
const STORE_NAME = "projects";
|
||||||
|
const AUTOSAVE_STORE_NAME = "autosaves";
|
||||||
|
const DEFAULT_AUTOSAVE_SLOT = "current";
|
||||||
|
|
||||||
export type ProjectLibraryMode = "indexeddb" | "memory";
|
export type ProjectLibraryMode = "indexeddb" | "memory";
|
||||||
|
|
||||||
@@ -30,9 +34,34 @@ function summary(record: SudokuProjectRecord): SudokuProjectSummary {
|
|||||||
updatedAt: record.updatedAt,
|
updatedAt: record.updatedAt,
|
||||||
size: record.puzzle.size,
|
size: record.puzzle.size,
|
||||||
completed: record.progress?.completed ?? false,
|
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 {
|
function bytes(record: SudokuProjectRecord): number {
|
||||||
return new TextEncoder().encode(JSON.stringify(record)).byteLength;
|
return new TextEncoder().encode(JSON.stringify(record)).byteLength;
|
||||||
}
|
}
|
||||||
@@ -63,10 +92,21 @@ async function openDatabase(
|
|||||||
const request = factory.open(name, DATABASE_VERSION);
|
const request = factory.open(name, DATABASE_VERSION);
|
||||||
request.onupgradeneeded = () => {
|
request.onupgradeneeded = () => {
|
||||||
const database = request.result;
|
const database = request.result;
|
||||||
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
const store = database.objectStoreNames.contains(STORE_NAME)
|
||||||
const store = database.createObjectStore(STORE_NAME, { keyPath: "id" });
|
? request.transaction!.objectStore(STORE_NAME)
|
||||||
|
: database.createObjectStore(STORE_NAME, { keyPath: "id" });
|
||||||
|
if (!store.indexNames.contains("updatedAt")) {
|
||||||
store.createIndex("updatedAt", "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.onsuccess = () => resolve(request.result);
|
||||||
request.onerror = () =>
|
request.onerror = () =>
|
||||||
@@ -80,6 +120,7 @@ export class ProjectLibrary {
|
|||||||
readonly #factory: IDBFactory | null;
|
readonly #factory: IDBFactory | null;
|
||||||
readonly #databaseName: string;
|
readonly #databaseName: string;
|
||||||
readonly #memory = new Map<string, SudokuProjectRecord>();
|
readonly #memory = new Map<string, SudokuProjectRecord>();
|
||||||
|
readonly #memoryAutosaves = new Map<string, SudokuProjectRecord>();
|
||||||
#database: Promise<IDBDatabase> | undefined;
|
#database: Promise<IDBDatabase> | undefined;
|
||||||
#mode: ProjectLibraryMode;
|
#mode: ProjectLibraryMode;
|
||||||
|
|
||||||
@@ -143,11 +184,14 @@ export class ProjectLibrary {
|
|||||||
this.#memory.set(record.id, cloneProjectRecord(record));
|
this.#memory.set(record.id, cloneProjectRecord(record));
|
||||||
}
|
}
|
||||||
|
|
||||||
async list(): Promise<readonly SudokuProjectSummary[]> {
|
async list(
|
||||||
|
query: ProjectLibraryQuery = {},
|
||||||
|
): Promise<readonly SudokuProjectSummary[]> {
|
||||||
const database = await this.#db();
|
const database = await this.#db();
|
||||||
if (database === undefined) {
|
if (database === undefined) {
|
||||||
return [...this.#memory.values()]
|
return [...this.#memory.values()]
|
||||||
.map(summary)
|
.map(summary)
|
||||||
|
.filter((item) => matchesQuery(item, query))
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) => b.updatedAt - a.updatedAt || a.title.localeCompare(b.title),
|
(a, b) => b.updatedAt - a.updatedAt || a.title.localeCompare(b.title),
|
||||||
);
|
);
|
||||||
@@ -166,13 +210,14 @@ export class ProjectLibrary {
|
|||||||
}
|
}
|
||||||
return records
|
return records
|
||||||
.map((record) => summary(normalizeProjectRecord(record)))
|
.map((record) => summary(normalizeProjectRecord(record)))
|
||||||
|
.filter((item) => matchesQuery(item, query))
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) => b.updatedAt - a.updatedAt || a.title.localeCompare(b.title),
|
(a, b) => b.updatedAt - a.updatedAt || a.title.localeCompare(b.title),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof SudokuFormatError) throw error;
|
if (error instanceof SudokuFormatError) throw error;
|
||||||
this.#mode = "memory";
|
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> {
|
async exportAll(): Promise<ProjectLibraryExport> {
|
||||||
const summaries = await this.list();
|
const summaries = await this.list();
|
||||||
const projects: SudokuProjectRecord[] = [];
|
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> {
|
async importAll(value: unknown, replace = false): Promise<number> {
|
||||||
if (
|
if (
|
||||||
typeof value !== "object" ||
|
typeof value !== "object" ||
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
normalizePortableAidMemoire,
|
normalizePortableAidMemoire,
|
||||||
type PortableAidMemoire,
|
type PortableAidMemoire,
|
||||||
} from "../state/aidMemoire";
|
} from "../state/aidMemoire";
|
||||||
|
import { parseGameplayHistory } from "../state/playHistory";
|
||||||
|
import { symbolFor } from "../state/session";
|
||||||
import {
|
import {
|
||||||
PROJECT_RECORD_SCHEMA,
|
PROJECT_RECORD_SCHEMA,
|
||||||
PROJECT_RECORD_VERSION,
|
PROJECT_RECORD_VERSION,
|
||||||
@@ -18,6 +20,8 @@ import {
|
|||||||
export const MAX_PROJECT_BYTES = MAX_DOCUMENT_BYTES * 2;
|
export const MAX_PROJECT_BYTES = MAX_DOCUMENT_BYTES * 2;
|
||||||
export const MAX_PROJECT_ID_LENGTH = 128;
|
export const MAX_PROJECT_ID_LENGTH = 128;
|
||||||
export const MAX_PROJECT_TITLE_LENGTH = 500;
|
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> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
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 {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
values,
|
values,
|
||||||
@@ -155,9 +179,57 @@ function progress(value: unknown, size: number): SudokuProgress | undefined {
|
|||||||
: { elapsedMs: value.elapsedMs as number }),
|
: { elapsedMs: value.elapsedMs as number }),
|
||||||
...(value.completed === undefined ? {} : { completed: value.completed }),
|
...(value.completed === undefined ? {} : { completed: value.completed }),
|
||||||
...(aidMemoire === undefined ? {} : { aidMemoire }),
|
...(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 {
|
export function normalizeProjectRecord(value: unknown): SudokuProjectRecord {
|
||||||
if (!isRecord(value))
|
if (!isRecord(value))
|
||||||
return storageError("INVALID_PROJECT", "A project must be an object.");
|
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 puzzle = normalizeSudokuDocument(value.puzzle);
|
||||||
|
const tags = projectTags(value.tags);
|
||||||
|
const thumbnail = projectThumbnail(
|
||||||
|
value.thumbnail,
|
||||||
|
puzzle.givens,
|
||||||
|
puzzle.size,
|
||||||
|
);
|
||||||
const normalized: SudokuProjectRecord = {
|
const normalized: SudokuProjectRecord = {
|
||||||
schema: PROJECT_RECORD_SCHEMA,
|
schema: PROJECT_RECORD_SCHEMA,
|
||||||
version: PROJECT_RECORD_VERSION,
|
version: PROJECT_RECORD_VERSION,
|
||||||
@@ -210,6 +288,8 @@ export function normalizeProjectRecord(value: unknown): SudokuProjectRecord {
|
|||||||
...(value.progress === undefined
|
...(value.progress === undefined
|
||||||
? {}
|
? {}
|
||||||
: { progress: progress(value.progress, puzzle.size) }),
|
: { progress: progress(value.progress, puzzle.size) }),
|
||||||
|
...(tags === undefined ? {} : { tags }),
|
||||||
|
thumbnail,
|
||||||
};
|
};
|
||||||
const bytes = new TextEncoder().encode(JSON.stringify(normalized)).byteLength;
|
const bytes = new TextEncoder().encode(JSON.stringify(normalized)).byteLength;
|
||||||
if (bytes > MAX_PROJECT_BYTES) {
|
if (bytes > MAX_PROJECT_BYTES) {
|
||||||
@@ -226,6 +306,7 @@ export interface NewProjectOptions {
|
|||||||
readonly title?: string;
|
readonly title?: string;
|
||||||
readonly now?: number;
|
readonly now?: number;
|
||||||
readonly progress?: SudokuProgress;
|
readonly progress?: SudokuProgress;
|
||||||
|
readonly tags?: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function randomId(): string {
|
function randomId(): string {
|
||||||
@@ -252,6 +333,7 @@ export function createProjectRecord(
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
puzzle,
|
puzzle,
|
||||||
...(options.progress === undefined ? {} : { progress: options.progress }),
|
...(options.progress === undefined ? {} : { progress: options.progress }),
|
||||||
|
...(options.tags === undefined ? {} : { tags: options.tags }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,6 +344,7 @@ export function cloneProjectRecord(
|
|||||||
return {
|
return {
|
||||||
...normalized,
|
...normalized,
|
||||||
puzzle: cloneSudokuDocument(normalized.puzzle),
|
puzzle: cloneSudokuDocument(normalized.puzzle),
|
||||||
|
...(normalized.tags === undefined ? {} : { tags: [...normalized.tags] }),
|
||||||
...(normalized.progress === undefined
|
...(normalized.progress === undefined
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
@@ -300,6 +383,11 @@ export function cloneProjectRecord(
|
|||||||
normalized.puzzle.size,
|
normalized.puzzle.size,
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
...(normalized.progress.gameplayHistory === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
gameplayHistory: normalized.progress.gameplayHistory,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface SudokuProgress {
|
|||||||
readonly elapsedMs?: number;
|
readonly elapsedMs?: number;
|
||||||
readonly completed?: boolean;
|
readonly completed?: boolean;
|
||||||
readonly aidMemoire?: PortableAidMemoire;
|
readonly aidMemoire?: PortableAidMemoire;
|
||||||
|
/** Canonical, bounded replay/savepoint history JSON. */
|
||||||
|
readonly gameplayHistory?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SudokuProjectRecord {
|
export interface SudokuProjectRecord {
|
||||||
@@ -26,6 +28,9 @@ export interface SudokuProjectRecord {
|
|||||||
readonly updatedAt: number;
|
readonly updatedAt: number;
|
||||||
readonly puzzle: SudokuDocument;
|
readonly puzzle: SudokuDocument;
|
||||||
readonly progress?: SudokuProgress;
|
readonly progress?: SudokuProgress;
|
||||||
|
readonly tags?: readonly string[];
|
||||||
|
/** Compact row-major symbols used for a script-free Library preview. */
|
||||||
|
readonly thumbnail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SudokuProjectSummary {
|
export interface SudokuProjectSummary {
|
||||||
@@ -35,6 +40,14 @@ export interface SudokuProjectSummary {
|
|||||||
readonly updatedAt: number;
|
readonly updatedAt: number;
|
||||||
readonly size: number;
|
readonly size: number;
|
||||||
readonly completed: boolean;
|
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 {
|
export interface ProjectLibraryExport {
|
||||||
|
|||||||
+1272
-13
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.sudoku-tools",
|
"id": "de.add-ideas.sudoku-tools",
|
||||||
"name": "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.",
|
"description": "Set, play, solve and analyse Sudoku puzzles locally in the browser.",
|
||||||
"entry": "./",
|
"entry": "./",
|
||||||
"icon": "./favicon.svg",
|
"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,
|
DifficultyOptions,
|
||||||
GenerateClassicOptions,
|
GenerateClassicOptions,
|
||||||
GeneratedVariantPuzzle,
|
GeneratedVariantPuzzle,
|
||||||
|
GeneratedVariantBatch,
|
||||||
|
GenerateVariantBatchOptions,
|
||||||
GenerateVariantOptions,
|
GenerateVariantOptions,
|
||||||
KillerCombinationOptions,
|
KillerCombinationOptions,
|
||||||
KillerCombinationResult,
|
KillerCombinationResult,
|
||||||
LogicalSolveOptions,
|
LogicalSolveOptions,
|
||||||
LogicalSolveResult,
|
LogicalSolveResult,
|
||||||
MinimizeOptions,
|
MinimizeOptions,
|
||||||
|
PuzzleQualityAnalysis,
|
||||||
|
PuzzleQualityOptions,
|
||||||
} from "../solver";
|
} from "../solver";
|
||||||
import type { NormalizedPuzzle, ValidationIssue } from "../domain";
|
import type { NormalizedPuzzle, ValidationIssue } from "../domain";
|
||||||
|
|
||||||
@@ -31,11 +35,20 @@ export type SolverWorkerOperation =
|
|||||||
readonly kind: "generate-variant";
|
readonly kind: "generate-variant";
|
||||||
readonly options?: GenerateVariantOptions;
|
readonly options?: GenerateVariantOptions;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
readonly kind: "generate-batch";
|
||||||
|
readonly options?: GenerateVariantBatchOptions;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
readonly kind: "difficulty";
|
readonly kind: "difficulty";
|
||||||
readonly puzzle: PuzzleDefinition;
|
readonly puzzle: PuzzleDefinition;
|
||||||
readonly options?: DifficultyOptions;
|
readonly options?: DifficultyOptions;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
readonly kind: "quality";
|
||||||
|
readonly puzzle: PuzzleDefinition;
|
||||||
|
readonly options?: PuzzleQualityOptions;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
readonly kind: "minimize";
|
readonly kind: "minimize";
|
||||||
readonly puzzle: PuzzleDefinition;
|
readonly puzzle: PuzzleDefinition;
|
||||||
@@ -56,7 +69,9 @@ export type SolverWorkerValue =
|
|||||||
| LogicalSolveResult
|
| LogicalSolveResult
|
||||||
| NormalizedPuzzle
|
| NormalizedPuzzle
|
||||||
| GeneratedVariantPuzzle
|
| GeneratedVariantPuzzle
|
||||||
|
| GeneratedVariantBatch
|
||||||
| DifficultyAssessment
|
| DifficultyAssessment
|
||||||
|
| PuzzleQualityAnalysis
|
||||||
| KillerCombinationResult;
|
| KillerCombinationResult;
|
||||||
|
|
||||||
export interface SolverWorkerError {
|
export interface SolverWorkerError {
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
import { PuzzleValidationError } from "../domain";
|
import { PuzzleValidationError } from "../domain";
|
||||||
import {
|
import {
|
||||||
|
analyzePuzzleQuality,
|
||||||
evaluateDifficulty,
|
evaluateDifficulty,
|
||||||
generateClassic,
|
generateClassic,
|
||||||
generateVariant,
|
generateVariant,
|
||||||
|
generateVariantBatch,
|
||||||
killerDigitCombinations,
|
killerDigitCombinations,
|
||||||
minimizePuzzle,
|
minimizePuzzle,
|
||||||
solveExact,
|
solveExact,
|
||||||
@@ -29,8 +31,12 @@ function run(request: SolverWorkerRequest): SolverWorkerValue {
|
|||||||
return generateClassic(operation.options);
|
return generateClassic(operation.options);
|
||||||
case "generate-variant":
|
case "generate-variant":
|
||||||
return generateVariant(operation.options);
|
return generateVariant(operation.options);
|
||||||
|
case "generate-batch":
|
||||||
|
return generateVariantBatch(operation.options);
|
||||||
case "difficulty":
|
case "difficulty":
|
||||||
return evaluateDifficulty(operation.puzzle, operation.options);
|
return evaluateDifficulty(operation.puzzle, operation.options);
|
||||||
|
case "quality":
|
||||||
|
return analyzePuzzleQuality(operation.puzzle, operation.options);
|
||||||
case "minimize":
|
case "minimize":
|
||||||
return minimizePuzzle(operation.puzzle, operation.options);
|
return minimizePuzzle(operation.puzzle, operation.options);
|
||||||
case "killer-combinations":
|
case "killer-combinations":
|
||||||
|
|||||||
@@ -248,3 +248,129 @@ test("keeps navigation, scratch work, branches and analysis tools local", async
|
|||||||
|
|
||||||
expect(runtimeErrors).toEqual([]);
|
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 userEvent from "@testing-library/user-event";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { GeneratorWorkspace } from "../../src/components/GeneratorWorkspace";
|
import { GeneratorWorkspace } from "../../src/components/GeneratorWorkspace";
|
||||||
|
import { classicRegions } from "../../src/domain";
|
||||||
|
import type {
|
||||||
|
GeneratedVariantBatch,
|
||||||
|
GeneratedVariantPuzzle,
|
||||||
|
} from "../../src/solver";
|
||||||
|
|
||||||
describe("Sudoku generator workspace", () => {
|
describe("Sudoku generator workspace", () => {
|
||||||
it("offers only reliable sizes and submits an explicit local recipe", async () => {
|
it("offers only reliable sizes and submits an explicit local recipe", async () => {
|
||||||
@@ -97,4 +102,158 @@ describe("Sudoku generator workspace", () => {
|
|||||||
}),
|
}),
|
||||||
).toBeInTheDocument();
|
).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 { ImportExportDialog } from "../../src/components/ImportExportDialog";
|
||||||
import { createEmptyPuzzle } from "../../src/domain";
|
import { createEmptyPuzzle } from "../../src/domain";
|
||||||
import type { PuzzleDefinition } from "../../src/domain/types";
|
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 type { PortableAidMemoire } from "../../src/state/aidMemoire";
|
||||||
import { createSession } from "../../src/state/session";
|
import { createSession } from "../../src/state/session";
|
||||||
|
|
||||||
@@ -40,6 +43,7 @@ function renderDialog(
|
|||||||
options: {
|
options: {
|
||||||
readonly puzzle?: PuzzleDefinition;
|
readonly puzzle?: PuzzleDefinition;
|
||||||
readonly aidMemoire?: PortableAidMemoire;
|
readonly aidMemoire?: PortableAidMemoire;
|
||||||
|
readonly preservedExtras?: PreservedSudokuDocumentExtras;
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const puzzle = options.puzzle ?? createEmptyPuzzle(4);
|
const puzzle = options.puzzle ?? createEmptyPuzzle(4);
|
||||||
@@ -51,6 +55,7 @@ function renderDialog(
|
|||||||
puzzle={puzzle}
|
puzzle={puzzle}
|
||||||
session={createSession(puzzle.givens)}
|
session={createSession(puzzle.givens)}
|
||||||
aidMemoire={options.aidMemoire}
|
aidMemoire={options.aidMemoire}
|
||||||
|
preservedExtras={options.preservedExtras}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onImport={onImport}
|
onImport={onImport}
|
||||||
/>,
|
/>,
|
||||||
@@ -79,11 +84,14 @@ describe("ImportExportDialog interoperability", () => {
|
|||||||
expect(onImport).toHaveBeenCalledWith(
|
expect(onImport).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ size: 4, givens: expect.any(Array) }),
|
expect.objectContaining({ size: 4, givens: expect.any(Array) }),
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
|
expect.objectContaining({
|
||||||
|
source: { format: "sudokupad", id: "synthetic-local-test" },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
expect(onClose).toHaveBeenCalledOnce();
|
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 user = userEvent.setup();
|
||||||
const fetchSpy = vi.fn();
|
const fetchSpy = vi.fn();
|
||||||
vi.stubGlobal("fetch", fetchSpy);
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
@@ -91,12 +99,35 @@ describe("ImportExportDialog interoperability", () => {
|
|||||||
const input = screen.getByPlaceholderText(/Paste 81 characters/u);
|
const input = screen.getByPlaceholderText(/Paste 81 characters/u);
|
||||||
|
|
||||||
fireEvent.change(input, {
|
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(
|
await user.click(
|
||||||
screen.getByRole("button", { name: "Check compatibility" }),
|
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, {
|
fireEvent.change(input, {
|
||||||
target: { value: "https://sudokupad.app/serverOnly42" },
|
target: { value: "https://sudokupad.app/serverOnly42" },
|
||||||
@@ -164,6 +195,47 @@ describe("ImportExportDialog interoperability", () => {
|
|||||||
expect(onImport).toHaveBeenCalledWith(
|
expect(onImport).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ size: 4 }),
|
expect.objectContaining({ size: 4 }),
|
||||||
expect.objectContaining({ aidMemoire: TEST_AID_MEMOIRE }),
|
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 { describe, expect, it, vi } from "vitest";
|
||||||
import { normalizePuzzle } from "../../src/domain";
|
import { normalizePuzzle } from "../../src/domain";
|
||||||
import { SudokuBoard } from "../../src/components/SudokuBoard";
|
import { SudokuBoard } from "../../src/components/SudokuBoard";
|
||||||
|
import { foggedCellsForPuzzle } from "../../src/components/fogVisibility";
|
||||||
|
|
||||||
describe("Sudoku board constraint visuals", () => {
|
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", () => {
|
it("distinguishes XV sums from directional inequalities", () => {
|
||||||
const puzzle = normalizePuzzle({
|
const puzzle = normalizePuzzle({
|
||||||
version: 1,
|
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", () => {
|
it("renders candidate filters and strong/weak graph overlays", () => {
|
||||||
const puzzle = normalizePuzzle({
|
const puzzle = normalizePuzzle({
|
||||||
version: 1,
|
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", () => {
|
it("exposes real row semantics and detailed per-cell state", () => {
|
||||||
const puzzle = normalizePuzzle({
|
const puzzle = normalizePuzzle({
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -215,4 +734,80 @@ describe("Sudoku board constraint visuals", () => {
|
|||||||
expect(annotated).toHaveAttribute("aria-invalid", "true");
|
expect(annotated).toHaveAttribute("aria-invalid", "true");
|
||||||
expect(annotated).toHaveAttribute("aria-selected", "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 userEvent from "@testing-library/user-event";
|
||||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
import { Workbench } from "../../src/components/Workbench";
|
import { Workbench } from "../../src/components/Workbench";
|
||||||
|
import { encodePuzzleHash, fromDomainPuzzle } from "../../src/formats";
|
||||||
|
import { SUDOKU_DOCUMENT_SCHEMA } from "../../src/formats";
|
||||||
|
|
||||||
class WorkerStub {
|
class WorkerStub {
|
||||||
addEventListener() {}
|
addEventListener() {}
|
||||||
@@ -63,6 +65,37 @@ describe("Sudoku workbench", () => {
|
|||||||
expect(undo).toBeDisabled();
|
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", () => {
|
it("supports non-wrapping WAI-ARIA grid navigation", () => {
|
||||||
render(<Workbench />);
|
render(<Workbench />);
|
||||||
|
|
||||||
@@ -91,6 +124,94 @@ describe("Sudoku workbench", () => {
|
|||||||
expect(cell(1, 1)).toHaveAttribute("tabindex", "0");
|
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 () => {
|
it("shows digit progress and toggles matching-digit highlights separately from selection", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<Workbench />);
|
render(<Workbench />);
|
||||||
@@ -257,8 +378,8 @@ describe("Sudoku workbench", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(container.querySelectorAll(".is-negated")).toHaveLength(2);
|
expect(container.querySelectorAll(".is-negated")).toHaveLength(2);
|
||||||
|
|
||||||
await user.clear(screen.getByLabelText("Clue"));
|
await user.clear(screen.getByLabelText("Sum"));
|
||||||
await user.type(screen.getByLabelText("Clue"), "6562");
|
await user.type(screen.getByLabelText("Sum"), "6562");
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Add / replace outside clue" }),
|
screen.getByRole("button", { name: "Add / replace outside clue" }),
|
||||||
).toBeDisabled();
|
).toBeDisabled();
|
||||||
@@ -375,4 +496,69 @@ describe("Sudoku workbench", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(fetchSpy).not.toHaveBeenCalled();
|
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);
|
const hash = encodePuzzleHash(source);
|
||||||
expect(hash).toMatch(/^#sudoku=v1\./u);
|
expect(hash).toMatch(/^#sudoku=v1\./u);
|
||||||
expect(decodePuzzleHash(`https://example.invalid/tools/${hash}`)).toEqual(
|
expect(decodePuzzleHash(`https://example.invalid/tools/${hash}`)).toEqual({
|
||||||
source,
|
...source,
|
||||||
);
|
source: { format: "sudoku-tools" },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("round-trips grids for every supported size and symbol", () => {
|
it("round-trips grids for every supported size and symbol", () => {
|
||||||
|
|||||||
@@ -115,6 +115,63 @@ describe("fpuzzles interoperability", () => {
|
|||||||
expect(imported.constraints).toEqual(expect.arrayContaining(expected));
|
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", () => {
|
it("recognizes server-only short puzzle IDs without making a request", () => {
|
||||||
expect(() => importFpuzzles("https://sudokupad.app/abc123")).toThrowError(
|
expect(() => importFpuzzles("https://sudokupad.app/abc123")).toThrowError(
|
||||||
NetworkPuzzleIdError,
|
NetworkPuzzleIdError,
|
||||||
@@ -220,9 +277,9 @@ describe("fpuzzles interoperability", () => {
|
|||||||
parseFpuzzles({
|
parseFpuzzles({
|
||||||
size: 9,
|
size: 9,
|
||||||
grid: emptyGrid(),
|
grid: emptyGrid(),
|
||||||
odd: [{ cell: "R1C1" }],
|
nabner: [{ lines: [["R1C1", "R1C2"]] }],
|
||||||
}),
|
}),
|
||||||
).toThrow(/odd cells.*silently weakening/u);
|
).toThrow(/Nabner lines.*silently weakening/u);
|
||||||
expect(() =>
|
expect(() =>
|
||||||
parseFpuzzles({
|
parseFpuzzles({
|
||||||
size: 9,
|
size: 9,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { compressToBase64 } from "lz-string";
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
NetworkPuzzleIdError,
|
NetworkPuzzleIdError,
|
||||||
UnsupportedPuzzleConstructsError,
|
|
||||||
importPenpa,
|
importPenpa,
|
||||||
importPuzzle,
|
importPuzzle,
|
||||||
importSudokuPad,
|
importSudokuPad,
|
||||||
@@ -91,27 +90,38 @@ describe("local puzzle interoperability", () => {
|
|||||||
expect(detected.document.givens[0]).toBe(1);
|
expect(detected.document.givens[0]).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects visual-only SCL constructs instead of weakening them", () => {
|
it("preserves allowlisted SCL drawings without treating them as rules", async () => {
|
||||||
expect(() =>
|
const source = {
|
||||||
parseSudokuPadPuzzle({
|
...sclPuzzle(),
|
||||||
...sclPuzzle(),
|
lines: [
|
||||||
lines: [
|
{
|
||||||
{
|
wayPoints: [
|
||||||
wayPoints: [
|
[0.5, 0.5],
|
||||||
[0.5, 0.5],
|
[1.5, 1.5],
|
||||||
[1.5, 1.5],
|
],
|
||||||
],
|
},
|
||||||
},
|
],
|
||||||
],
|
overlays: [{ center: [1, 1], width: 1, height: 1, text: "?" }],
|
||||||
overlays: [{ text: "?" }],
|
};
|
||||||
}),
|
const parsed = parseSudokuPadPuzzle(source);
|
||||||
).toThrow(UnsupportedPuzzleConstructsError);
|
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(() =>
|
expect(() =>
|
||||||
parseSudokuPadPuzzle({
|
parseSudokuPadPuzzle({
|
||||||
...sclPuzzle(),
|
...sclPuzzle(),
|
||||||
lines: [{}],
|
lines: [{}],
|
||||||
}),
|
}),
|
||||||
).toThrow(/visual lines/u);
|
).toThrow(/wayPoints|bounded/u);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses semantic Penpa+ Sudoku layers and local progress", () => {
|
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 { 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";
|
import { killerDigitCombinations, solveLogically } from "../../src/solver";
|
||||||
|
|
||||||
const easy: PuzzleDefinition = {
|
const easy: PuzzleDefinition = {
|
||||||
@@ -15,6 +19,14 @@ const easy: PuzzleDefinition = {
|
|||||||
constraints: [],
|
constraints: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const emptyFour: PuzzleDefinition = {
|
||||||
|
version: 1,
|
||||||
|
size: 4,
|
||||||
|
givens: Array<number>(16).fill(0),
|
||||||
|
regions: classicRegions(4),
|
||||||
|
constraints: [],
|
||||||
|
};
|
||||||
|
|
||||||
describe("logical solver", () => {
|
describe("logical solver", () => {
|
||||||
it("solves a standard puzzle and emits inspectable steps", () => {
|
it("solves a standard puzzle and emits inspectable steps", () => {
|
||||||
const result = solveLogically(easy);
|
const result = solveLogically(easy);
|
||||||
@@ -60,6 +72,103 @@ describe("logical solver", () => {
|
|||||||
expect(result.candidates[0]).toEqual([1, 2, 3, 4]);
|
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", () => {
|
it("returns bounded killer combinations", () => {
|
||||||
expect(
|
expect(
|
||||||
killerDigitCombinations({ size: 9, count: 2, sum: 10 }).combinations,
|
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, "center", 7, 9);
|
||||||
state = enterAidMemoireCell(state, 0, "color", 3, 9);
|
state = enterAidMemoireCell(state, 0, "color", 3, 9);
|
||||||
expect(aidMemoireCellDescription(state.cells[0]!, 0, 9)).toBe(
|
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([]);
|
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 () => {
|
it("exports and imports an explicitly versioned library", async () => {
|
||||||
const source = new ProjectLibrary({ indexedDB: null });
|
const source = new ProjectLibrary({ indexedDB: null });
|
||||||
await source.put(createProjectRecord(puzzle, { id: "one", now: 10 }));
|
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"));
|
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 () => {
|
it("falls back when IndexedDB cannot be opened", async () => {
|
||||||
const broken = {
|
const broken = {
|
||||||
open: () => {
|
open: () => {
|
||||||
@@ -90,4 +188,20 @@ describe("local project library", () => {
|
|||||||
}),
|
}),
|
||||||
).toThrow(/must contain 81/u);
|
).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