Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca7a6270d0 | ||
|
|
0a1bdc1a8c | ||
|
|
8ca9300ab3 | ||
|
|
4a9869baa0 |
+111
@@ -4,6 +4,117 @@ All notable changes are documented here.
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 0.2.1 - 2026-08-31
|
||||
|
||||
- Fixed compact solver-step cards so their explanations no longer overflow.
|
||||
- Fixed 75% board zoom and removed unnecessary inner scrolling at fitted and
|
||||
reduced board sizes.
|
||||
- Changed the standard digit keypad to a 3-by-3 layout with a full-width Erase
|
||||
key, while retaining a 4-column layout for larger grids.
|
||||
- Kept board and sidebar widths stable across Play, Set, Generate, Solve and
|
||||
Helpers, with helper controls reflowing inside the shared sidebar width.
|
||||
|
||||
## 0.2.0 - 2026-08-31
|
||||
|
||||
- Added staged guided hints that reveal focus, technique, reasoning and effects
|
||||
before an explicit, undoable apply action.
|
||||
- Added legal centre-candidate filling, invalid note pruning and optional peer
|
||||
note maintenance after placements; erasing a value never invents candidates.
|
||||
- Added Jellyfish, finned X-Wing/Swordfish, Skyscraper, Two-String Kite, Simple
|
||||
Colouring, W-Wing, X-Chain, XY-Chain and alternating-inference-chain logical
|
||||
steps with deterministic evidence.
|
||||
- Added Unique Rectangle only behind a completed exact uniqueness proof; a
|
||||
truncated search never enables uniqueness-dependent logic.
|
||||
- Added quick and full setter-quality audits with ambiguity witnesses,
|
||||
contradiction suspects, per-item critical/redundant/unknown findings, a cell
|
||||
heatmap and optional bounded minimality proof.
|
||||
- Added explicit per-check and aggregate quality budgets, cancellation and
|
||||
serializable check evidence so a capped search remains unknown rather than
|
||||
becoming a false proof.
|
||||
- Added minimum, odd/even, disjoint-groups, little-killer and sandwich rules
|
||||
across validation, candidate filtering, exact solving, rendering and setting.
|
||||
- Added between, German-whisper, region-sum, modular, entropic and zipper lines;
|
||||
clone and extra regions; double arrows; and row, column and box indexers.
|
||||
- Added display-only Fog of War backed by a complete locally stored solution.
|
||||
Only initial lights, givens and correctly entered values reveal cells; fog is
|
||||
never treated as an extra solution constraint.
|
||||
- Added optional digit-completion counts with muted complete digits and red
|
||||
over-completion warnings.
|
||||
- Added toggleable Ctrl/Command-click matching-digit highlights without turning
|
||||
the highlights into an editable multi-selection.
|
||||
- Fixed cage replacement and removal in the setter so overlapping cages cannot
|
||||
remain accidentally active.
|
||||
- Replaced ambiguous V/X pair labels on the board with numbered 5/10 badges and
|
||||
gave inequalities a directional chevron with a marked lesser-value tip.
|
||||
- Added fourteen uniquely checked built-in examples covering the original core
|
||||
constraint families.
|
||||
- Expanded bounded, seedable generation for classic and twelve variant families
|
||||
with mixed-family recipes, sparse/balanced/dense clue controls and additional
|
||||
reflection, diagonal and four-way clue symmetries.
|
||||
- Added explicit minimal-givens mode with proof/unknown reporting, deterministic
|
||||
batches of up to 12 candidates and rankings by difficulty or clue count.
|
||||
- Added required/forbidden/minimum/maximum logical-technique profiles and an
|
||||
exact hardest-technique target, accepted only against an independent complete
|
||||
logical path and exact uniqueness check.
|
||||
- Added X-sum, skyscraper, quadruple and maximum-cell constraints across the
|
||||
model, validator, exact solver, renderer, setter and f-puzzles interchange.
|
||||
- Added per-clue false semantics for liar/Wrogn puzzles, including bounded
|
||||
impossible decoy numbers and a setter-wide false-clue mode.
|
||||
- Added the original, uniquely verified “Truth and lies” example demonstrating
|
||||
true and false outside, inclusion, maximum, cage, line and pair clues.
|
||||
- Added outside-grid clue margins, combined dual X-sum/skyscraper labels,
|
||||
outward maximum arrows and visually distinct false clues.
|
||||
- Added strict, bounded local imports for SudokuPad/CTC data and supported
|
||||
Penpa+ long links, with a structured preview of mapped semantics, preserved
|
||||
drawings, metadata and compatibility warnings.
|
||||
- Added a bounded inert visual model for f-puzzles and SCL lines, polylines,
|
||||
rectangles, ellipses, circles and text; source identity, scalar metadata and
|
||||
drawings survive edits, explicit saves, autosaves, reopening and project/share
|
||||
round-trips without becoming rules, and render beneath Fog on the board.
|
||||
- Added SudokuPad/CTC JSON and self-contained `scl…` export with progress,
|
||||
regions, cages and canonical constraint drawings, while rejecting negated
|
||||
clues or free-form global rules it cannot preserve safely.
|
||||
- Added a compatibility check which never fetches SudokuPad short IDs or other
|
||||
server-hosted puzzle references.
|
||||
- Added standalone SVG, high-resolution PNG and single-page PDF visual exports
|
||||
for supported constraints, safe preserved drawings, givens and optional
|
||||
solving progress.
|
||||
- Added named savepoints, isolated hypothesis branches, keep/discard decisions
|
||||
and read-only replay of complete solving states.
|
||||
- Added debounced recovery snapshots for puzzle state, progress, aid-mémoire
|
||||
and a validated, size-bounded gameplay history, with explicit restore/discard
|
||||
handling after an interruption.
|
||||
- Upgraded the local Library with title/tag search, tag and completion filters,
|
||||
safe grid thumbnails, tag editing and selected export, duplication or
|
||||
confirmed deletion.
|
||||
- Added a relative-scope web app manifest and subpath-aware service worker with
|
||||
an offline application-shell fallback after the first successful load.
|
||||
- Added an optional configurable aid-mémoire whose labelled scratch cells
|
||||
support values, both note styles and colours without constraining the puzzle;
|
||||
its state is preserved in undo, replay, Library progress and project/share
|
||||
interchange.
|
||||
- Added a generalized Sum Lab with independent digit bounds, repeats,
|
||||
required/excluded digits, selected-cell candidates, positional assignments
|
||||
and reversible manual eliminations.
|
||||
- Added Candidate Lab filters, selected/touching/specific-house scopes, house
|
||||
positions, strong conjugate/bivalue links, weak links and bounded board
|
||||
overlays.
|
||||
- Added technique-targeted practice generation which mines a bounded,
|
||||
deterministic set of uniquely checked puzzles and verifies the requested
|
||||
technique in the logical solve path.
|
||||
- Added a persisted 75–200% board viewport, Fit and board-scoped zoom shortcuts,
|
||||
plus an announced Pan mode which pauses cell pointer targeting.
|
||||
- Added tap-toggle multi-selection and a single safe-area-aware sticky entry pad
|
||||
at narrow widths, with touch-sized zoom controls.
|
||||
- Added eight hue-and-pattern colour marks, configurable off/concise/detailed
|
||||
candidate narration, and fog-safe board, toolbar, helper and guided-hint
|
||||
boundaries.
|
||||
- Added real ARIA row/gridcell semantics, detailed cell descriptions and
|
||||
non-wrapping Arrow, Home/End, Control/Command + Home/End and Page Up/Down
|
||||
keyboard navigation for the puzzle and scratch grids.
|
||||
- Made Killer cage repeat semantics explicit in the setter and independent of
|
||||
whether a clue is required to be true or false.
|
||||
|
||||
## 0.1.0 - 2026-08-30
|
||||
|
||||
- Initial local-first Sudoku setting, playing, solving and analysis workbench.
|
||||
|
||||
@@ -8,33 +8,243 @@ release also runs independently from any static HTTPS host or local preview.
|
||||
## Workspaces
|
||||
|
||||
- **Play** — keyboard, mouse and touch entry; multi-cell selection; values,
|
||||
corner/centre notes and colours; undo/redo; conflict highlighting; timer and
|
||||
local progress.
|
||||
- **Set** — givens, metadata, regions and typed constraints; uniqueness checks
|
||||
and seeded classic puzzle generation.
|
||||
corner/centre notes and colours; undo/redo; named savepoints, hypothesis
|
||||
branches and read-only replay; staged guided hints; candidate maintenance;
|
||||
conflict highlighting; timer and local progress; optional digit-completion
|
||||
counts, matching-digit highlights and a configurable non-constraining
|
||||
aid-mémoire.
|
||||
- **Set** — givens, metadata, regions and registry-backed constraints;
|
||||
overlap-safe clue replacement and removal; quick uniqueness checks; and a
|
||||
bounded quality lab for ambiguity, contradictions, redundancy and
|
||||
minimality.
|
||||
- **Generate** — seedable single or batch construction for classic and 12
|
||||
variant families, including mixed-family recipes, clue symmetry, constraint
|
||||
density, optional minimal-givens proof and independently checked technique
|
||||
profiles.
|
||||
- **Solve** — exact solution counting plus an original human-style engine whose
|
||||
steps include structured evidence, placements and eliminations.
|
||||
- **Helpers** — Killer combinations and candidate-aware assignments, 45-rule
|
||||
residuals, and Kropki, XV or inequality relation pairs.
|
||||
deterministic steps include structured evidence, placements and
|
||||
eliminations. Uniqueness-dependent logic is disabled unless uniqueness has
|
||||
already been proved by a completed exact search.
|
||||
- **Helpers** — generalized sum combinations with positional candidates and
|
||||
manual eliminations; selected-cell and house candidate-link analysis; Killer
|
||||
combinations, 45-rule residuals, and Kropki, sum-pair or inequality pairs.
|
||||
|
||||
Classic grids and common variants share one bounded puzzle model: irregular
|
||||
regions, diagonals, Killer cages, thermometers, arrows, Kropki and XV clues,
|
||||
inequalities, renban lines, palindromes, anti-knight, anti-king and
|
||||
non-consecutive rules. Import accepts compact grid strings, this project's JSON
|
||||
format, share fragments, and the common f-puzzles fields supported by the
|
||||
model. Unknown constraints are reported rather than silently discarded.
|
||||
## Constraint coverage
|
||||
|
||||
## Privacy and storage
|
||||
Classic grids and variants share one validated puzzle model and constraint
|
||||
registry. The production engine currently supports:
|
||||
|
||||
- classic, irregular and extra regions; diagonals; disjoint groups;
|
||||
anti-knight, anti-king and non-consecutive rules;
|
||||
- Killer cages, ordered clone regions, quadruples, maximum/minimum cells and
|
||||
odd/even cells;
|
||||
- thermometers, arrows, renban and palindrome lines, between lines, German
|
||||
whispers, region-sum lines, modular lines, entropic lines, zipper lines and
|
||||
double arrows;
|
||||
- Kropki dots, numbered 5/10 sum pairs and inequalities;
|
||||
- X-sums, skyscrapers, little-killer diagonals and sandwich sums; and
|
||||
- row, column and rectangular-box indexers, plus display-only Fog of War.
|
||||
|
||||
Each supported semantic constraint is validated and participates in candidate
|
||||
filtering and exact solving. Negatable clues can instead be required to be false
|
||||
for liar/Wrogn constructions; global house rules, extra regions and fog are not
|
||||
given a misleading false mode. Fourteen original bundled examples cover the
|
||||
core families and the uniquely checked “Truth and lies” construction. The
|
||||
newer constraint packs are covered by focused validation, feasibility and exact
|
||||
solver tests rather than bundled third-party puzzles.
|
||||
|
||||
Fog is deliberately a presentation rule, not a hidden solver constraint. It
|
||||
can be set only when the puzzle carries a complete solution that validates
|
||||
against the current givens and constraints. Initial lights, givens and correctly
|
||||
entered digits reveal a radius-zero or radius-one Chebyshev neighbourhood;
|
||||
wrong entries reveal nothing. Obscured cells are disabled and their values,
|
||||
notes, candidates, hints, quality overlays and fully hidden clue graphics are
|
||||
omitted. The trusted solution remains part of the local puzzle document.
|
||||
|
||||
## Guided solving and candidate maintenance
|
||||
|
||||
A guided hint asks the worker for one supported logical step and discloses it in
|
||||
four explicit stages: where to look, technique, reasoning and effects preview.
|
||||
Later-stage answer content is not placed in the document before it is revealed,
|
||||
and the board does not change until **Apply this step** is chosen. Applying a
|
||||
step is one undoable transition.
|
||||
|
||||
Candidate controls can fill every empty cell with its currently legal centre
|
||||
candidates, remove invalid centre/corner notes, and optionally prune peer notes
|
||||
after a placement. Erasing a digit never invents candidates. If an
|
||||
elimination-only hint is applied without tracked candidates, the app first
|
||||
creates a complete legal centre-candidate grid and then applies the explicit
|
||||
elimination.
|
||||
|
||||
The logical engine covers singles; naked and hidden pairs, triples and quads;
|
||||
pointing and claiming; X-Wing, Swordfish and Jellyfish; finned X-Wing and
|
||||
finned Swordfish; XY-Wing and XYZ-Wing; Skyscraper; Two-String Kite; Simple
|
||||
Colouring; W-Wing; X-Chains, XY-Chains and alternating inference chains; and
|
||||
Killer-cage deductions. Unique Rectangle is available only when the caller
|
||||
supplies a completed uniqueness proof. The engine never infers uniqueness from
|
||||
a puzzle’s appearance, metadata or a truncated search.
|
||||
|
||||
## Setter-quality analysis
|
||||
|
||||
The quick setter check performs a bounded zero/one/two-solution audit. A pair of
|
||||
solutions produces a concrete ambiguity witness with every differing cell and
|
||||
value. For a uniquely proved baseline, full analysis uses leave-one-out exact
|
||||
searches to classify each given and constraint as critical, redundant or
|
||||
unknown and creates a per-cell criticality heatmap. For an unsatisfiable
|
||||
baseline it instead uses bounded deletion tests to localise a contradictory
|
||||
core. Optional minimality is proved only when the unique baseline and every
|
||||
required removal check complete.
|
||||
|
||||
Every quality search has configurable per-check node/time limits and shared
|
||||
aggregate check/node/time limits. Results retain the checks performed, elapsed
|
||||
time and reasons for unknown conclusions. Reaching a safety limit is never
|
||||
silently converted into uniqueness, redundancy, contradiction localisation or
|
||||
minimality. The worker analysis can be cancelled without discarding the last
|
||||
completed result.
|
||||
|
||||
## Generator 2.0
|
||||
|
||||
Generation supports the existing classic, diagonal, anti-knight, anti-king,
|
||||
non-consecutive, Killer, thermo, arrow, Kropki, XV, inequality, renban and
|
||||
palindrome families. Compatible families can be combined when they share a
|
||||
supported grid size. Local marking density can be sparse, balanced or dense;
|
||||
for Killer this changes cage granularity, while inherently global rules retain
|
||||
their fixed meaning.
|
||||
|
||||
Clue removal supports no symmetry, half-turn rotation, horizontal or vertical
|
||||
reflection, either diagonal reflection, and four-way quarter-turn symmetry.
|
||||
Minimal-givens mode checks every retained given individually and reports either
|
||||
a completed proof or the exact unknown reasons. Individual minimisation may
|
||||
break the requested visual symmetry, which is reported rather than hidden.
|
||||
|
||||
Technique profiles can require or forbid supported logical techniques, set
|
||||
minimum/maximum occurrence counts and require an exact hardest technique. A
|
||||
puzzle is accepted only when an independent, complete logical path matches the
|
||||
whole profile and an independent exact check proves uniqueness. Deterministic
|
||||
batches contain at most 12 candidates and can be ranked by difficulty, fewest
|
||||
givens or most givens; failed bounded candidates remain visible as failures and
|
||||
are never included in the verified ranking. Generation is cancellable by
|
||||
terminating and restarting its worker.
|
||||
|
||||
Requested difficulty still controls the clue-removal target rather than
|
||||
promising a rating. The displayed 0–100 result is calculated afterwards from
|
||||
logical techniques, clue load and reproducible exact-search evidence. Supported
|
||||
size/family combinations are deliberately restricted, and a bounded mixed
|
||||
construction may fail cleanly for an incompatible or unusually difficult
|
||||
recipe.
|
||||
|
||||
## Mobile and accessibility
|
||||
|
||||
The board viewport offers a locally persisted 75–200% scale in 25% steps,
|
||||
increment/decrement controls and a Fit action that returns to 100% and the
|
||||
origin. Control/Command + plus, minus or zero are board-scoped equivalents.
|
||||
Explicit Pan mode announces its state, cancels any in-progress drag selection
|
||||
and disables cell pointer targeting until it is stopped. Tap multi-select, also
|
||||
available with <kbd>M</kbd>, toggles cells individually without drag selection
|
||||
and never leaves the active selection empty.
|
||||
|
||||
At widths up to 48rem, Play and Set show exactly one sticky entry pad (outside
|
||||
read-only replay) with safe-area padding and touch-sized zoom controls. Eight
|
||||
colour marks use distinct patterns as well as hues and expose the hue/pattern
|
||||
name on both the Sudoku board and aid-mémoire. Screen-reader candidate detail is
|
||||
independently selectable as Off, concise counts or detailed digits without
|
||||
changing the visible board.
|
||||
|
||||
The board exposes real row/gridcell semantics and concise per-cell state to
|
||||
assistive technology, including candidates, notes, colours, conflicts,
|
||||
touching variant clues, hint previews and quality findings. Arrow keys never
|
||||
wrap; Home/End, Control/Command + Home/End and Page Up/Down provide row- and
|
||||
grid-level navigation. The same navigation model is available in the
|
||||
aid-mémoire scratch grid. Guided-hint and quality status changes use live
|
||||
regions.
|
||||
|
||||
Fogged cells remain disabled and expose only an obscured status to assistive
|
||||
technology. Hidden values, notes, constraint descriptions, candidate/helper
|
||||
overlays and hint effects are masked, and toolbar/helper selection is reduced
|
||||
to visible cells. A guided hint is shown only if all of its focus and effect
|
||||
cells are visible; otherwise the app returns a generic no-visible-hint status.
|
||||
|
||||
## Interoperability and visual export
|
||||
|
||||
The import dialog recognises compact grids, Sudoku Tools JSON/share fragments,
|
||||
raw or inline f-puzzles data, SudokuPad/CTC (SCL) data, and Penpa+ self-contained
|
||||
long links. It never follows a URL or resolves a server-side short ID. Imports
|
||||
are size/decompression bounded. Before applying one, a structured mapping
|
||||
preview separates solver-enforced semantics, preserved drawings, retained
|
||||
metadata and warnings.
|
||||
|
||||
The shared inert visual model supports bounded lines, polylines, rectangles,
|
||||
ellipses, circles and text. f-puzzles maps its decorative line, rectangle,
|
||||
circle and text fields; SCL maps supported lines, underlays, overlays and
|
||||
arrows. Drawings, local source identity and scalar metadata survive domain
|
||||
edits, explicit saves, autosaves, reopening, and project/share exports. They
|
||||
never become Sudoku rules unless the preview also lists a mapped semantic
|
||||
constraint. Raw SVG, HTML, CSS, paths or code, unsafe colour syntax, unknown
|
||||
visual fields, excessive data and rotated or otherwise unrepresentable layouts
|
||||
are rejected rather than executed or silently changed.
|
||||
|
||||
SudokuPad compatibility maps cells, givens and progress, notes, regions,
|
||||
metadata, solutions, Killer cages and supported global rules. Export can create
|
||||
readable SudokuPad/CTC JSON or a self-contained `scl…` payload with progress,
|
||||
regions, cages and canonical drawings for registered constraints. Sudoku Tools
|
||||
retains its bounded constraint record for its own exact round-trip; another SCL
|
||||
consumer should only be assumed to enforce constructs it represents natively.
|
||||
Negated clues and free-form global rules cannot be exported to SCL.
|
||||
|
||||
Penpa+ compatibility remains intentionally narrower: square, unrotated Sudoku
|
||||
grids with givens/progress, thermometers and arrows. f-puzzles export likewise
|
||||
refuses false clues and underlay, free-coordinate or offset drawings it cannot
|
||||
preserve faithfully. Preserved source drawings appear on the interactive board
|
||||
and in static/SCL exports; Fog covers their obscured in-grid portions.
|
||||
|
||||
Export can produce a standalone SVG, a high-resolution PNG, or a single-page
|
||||
PDF entirely in the browser. Each visual uses the same trusted puzzle model and
|
||||
renders region boundaries, supported variant clues, safe preserved drawings and
|
||||
givens; current values, notes and cell colours can be included or omitted with
|
||||
the progress switch.
|
||||
|
||||
## Privacy, recovery and Library
|
||||
|
||||
Puzzle parsing, solving, generation and export happen entirely in the browser.
|
||||
There are no analytics, advertisements, accounts or network-backed puzzle
|
||||
lookups. Projects and progress are saved only in local IndexedDB after an
|
||||
explicit save action and can be cleared from inside the app.
|
||||
Exported files and share URLs contain the puzzle data you chose to include.
|
||||
lookups. Explicit projects are stored in IndexedDB. A separate, debounced
|
||||
autosave slot retains the current puzzle state, progress, aid-mémoire and a
|
||||
bounded gameplay history. After an interrupted session, the app offers to
|
||||
restore or discard that recovery state without silently replacing an explicit
|
||||
project. Its status distinguishes checking, saving, saved, ready and unavailable
|
||||
states.
|
||||
|
||||
Gameplay history includes undo/redo moments, named savepoints and hypothesis
|
||||
branches, and is validated when restored. It is bounded to 500 moments and one
|
||||
MiB for its serialized history payload. The Library supports title and tag
|
||||
search, tag and completion filters, script-free grid thumbnails, bounded tag
|
||||
editing, and selected export, duplication or confirmed deletion. Whole-library
|
||||
import and export remain versioned operations.
|
||||
|
||||
If IndexedDB is unavailable, a bounded in-memory fallback keeps work for the
|
||||
current tab and the interface reports that persistence is unavailable. Browser
|
||||
storage can still be removed by the browser or user, so important projects
|
||||
should be exported. Exported files and share URLs contain the puzzle data you
|
||||
chose to include.
|
||||
|
||||
SudokuPad short IDs identify server-hosted records and therefore cannot be
|
||||
resolved by this deliberately offline application. Embedded f-puzzles payloads
|
||||
are accepted without contacting their originating site.
|
||||
and user-provided Sudoku Tools JSON documents are accepted without contacting
|
||||
their originating site. Externally authored puzzle definitions are not bundled.
|
||||
|
||||
## Offline application shell
|
||||
|
||||
Production builds include a relative-scope web app manifest and register a
|
||||
subpath-aware service worker after the page has loaded. It caches the application
|
||||
shell and same-origin built assets, uses network-first navigation with a cached
|
||||
fallback, serves cached static assets when available and removes obsolete cache
|
||||
versions.
|
||||
|
||||
The first successful online load is still required to install those resources,
|
||||
and service-worker availability depends on browser support and a secure origin.
|
||||
Registration is progressive enhancement: the application continues to work
|
||||
when a service worker is unavailable, and puzzle operations never depend on a
|
||||
network connection.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -58,14 +268,14 @@ npm run release:artifact
|
||||
|
||||
The command verifies the manifest, types, formatting, tests, production build,
|
||||
browser workflows and Toolbox contract before creating a deterministic
|
||||
`release/sudoku-tools-0.1.0.zip` with a matching SHA-256 sidecar.
|
||||
`release/sudoku-tools-0.2.1.zip` with a matching SHA-256 sidecar.
|
||||
|
||||
## Scope and references
|
||||
|
||||
The interaction ideas and published solving concepts explored by SudokuPad,
|
||||
SudokuWiki and the wider puzzle community informed the product requirements.
|
||||
No code, artwork, wording or proprietary puzzle data from those applications is
|
||||
included. Algorithms and explanations in this repository are independently
|
||||
No externally authored puzzle definitions, code, artwork or documentation text
|
||||
is bundled. Algorithms, UI, original examples and explanations are independently
|
||||
implemented from mathematical rules and public standards.
|
||||
|
||||
## Licence
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Corresponding source
|
||||
|
||||
The corresponding source for Sudoku Tools 0.1.0 is available at:
|
||||
The corresponding source for Sudoku Tools 0.2.1 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.1
|
||||
|
||||
The production archive is generated from that tag with the scripts and exact
|
||||
dependency lock contained in the repository. It contains source identity,
|
||||
|
||||
@@ -16,4 +16,12 @@ runtime package. Development-only packages build and test the source but are
|
||||
not shipped as runtime modules.
|
||||
|
||||
SudokuPad, SudokuWiki and HoDoKu are research references only. Their source,
|
||||
documentation, wording, visual assets and puzzle data are not bundled or copied.
|
||||
documentation wording, visual assets and externally authored puzzle data are
|
||||
not bundled or copied.
|
||||
|
||||
The local compatibility code was tested against the open format behaviour in
|
||||
[Penpa+](https://github.com/swaroopg92/penpa-edit) (MIT),
|
||||
[penpa-to-scl](https://github.com/marktekfan/penpa-to-scl) (MIT), and
|
||||
[SudokuPad sudokutools](https://github.com/SudokuPad/sudokutools) (MPL-2.0).
|
||||
Those projects are format references only: no upstream executable code,
|
||||
artwork, documentation text or puzzle data is distributed with Sudoku Tools.
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
content="Set, play, solve and analyse Sudoku puzzles locally in your browser."
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
||||
<link rel="manifest" href="./manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#272421" />
|
||||
<title>Sudoku Tools</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sudoku-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sudoku-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.1",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sudoku-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.1",
|
||||
"description": "Set, play, solve and analyse Sudoku puzzles locally in the browser.",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"author": "Albrecht Degering",
|
||||
|
||||
@@ -3,7 +3,10 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
export default defineConfig({
|
||||
testDir: "./tests/browser",
|
||||
fullyParallel: false,
|
||||
workers: 2,
|
||||
// Run the two browser projects serially. Concurrent service-worker installs
|
||||
// against the shared nested-path test server can make Firefox wait forever
|
||||
// for the load event even though the application itself is healthy.
|
||||
workers: 1,
|
||||
timeout: 120_000,
|
||||
expect: { timeout: 10_000 },
|
||||
reporter: [["list"]],
|
||||
|
||||
@@ -4,6 +4,117 @@ All notable changes are documented here.
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 0.2.1 - 2026-08-31
|
||||
|
||||
- Fixed compact solver-step cards so their explanations no longer overflow.
|
||||
- Fixed 75% board zoom and removed unnecessary inner scrolling at fitted and
|
||||
reduced board sizes.
|
||||
- Changed the standard digit keypad to a 3-by-3 layout with a full-width Erase
|
||||
key, while retaining a 4-column layout for larger grids.
|
||||
- Kept board and sidebar widths stable across Play, Set, Generate, Solve and
|
||||
Helpers, with helper controls reflowing inside the shared sidebar width.
|
||||
|
||||
## 0.2.0 - 2026-08-31
|
||||
|
||||
- Added staged guided hints that reveal focus, technique, reasoning and effects
|
||||
before an explicit, undoable apply action.
|
||||
- Added legal centre-candidate filling, invalid note pruning and optional peer
|
||||
note maintenance after placements; erasing a value never invents candidates.
|
||||
- Added Jellyfish, finned X-Wing/Swordfish, Skyscraper, Two-String Kite, Simple
|
||||
Colouring, W-Wing, X-Chain, XY-Chain and alternating-inference-chain logical
|
||||
steps with deterministic evidence.
|
||||
- Added Unique Rectangle only behind a completed exact uniqueness proof; a
|
||||
truncated search never enables uniqueness-dependent logic.
|
||||
- Added quick and full setter-quality audits with ambiguity witnesses,
|
||||
contradiction suspects, per-item critical/redundant/unknown findings, a cell
|
||||
heatmap and optional bounded minimality proof.
|
||||
- Added explicit per-check and aggregate quality budgets, cancellation and
|
||||
serializable check evidence so a capped search remains unknown rather than
|
||||
becoming a false proof.
|
||||
- Added minimum, odd/even, disjoint-groups, little-killer and sandwich rules
|
||||
across validation, candidate filtering, exact solving, rendering and setting.
|
||||
- Added between, German-whisper, region-sum, modular, entropic and zipper lines;
|
||||
clone and extra regions; double arrows; and row, column and box indexers.
|
||||
- Added display-only Fog of War backed by a complete locally stored solution.
|
||||
Only initial lights, givens and correctly entered values reveal cells; fog is
|
||||
never treated as an extra solution constraint.
|
||||
- Added optional digit-completion counts with muted complete digits and red
|
||||
over-completion warnings.
|
||||
- Added toggleable Ctrl/Command-click matching-digit highlights without turning
|
||||
the highlights into an editable multi-selection.
|
||||
- Fixed cage replacement and removal in the setter so overlapping cages cannot
|
||||
remain accidentally active.
|
||||
- Replaced ambiguous V/X pair labels on the board with numbered 5/10 badges and
|
||||
gave inequalities a directional chevron with a marked lesser-value tip.
|
||||
- Added fourteen uniquely checked built-in examples covering the original core
|
||||
constraint families.
|
||||
- Expanded bounded, seedable generation for classic and twelve variant families
|
||||
with mixed-family recipes, sparse/balanced/dense clue controls and additional
|
||||
reflection, diagonal and four-way clue symmetries.
|
||||
- Added explicit minimal-givens mode with proof/unknown reporting, deterministic
|
||||
batches of up to 12 candidates and rankings by difficulty or clue count.
|
||||
- Added required/forbidden/minimum/maximum logical-technique profiles and an
|
||||
exact hardest-technique target, accepted only against an independent complete
|
||||
logical path and exact uniqueness check.
|
||||
- Added X-sum, skyscraper, quadruple and maximum-cell constraints across the
|
||||
model, validator, exact solver, renderer, setter and f-puzzles interchange.
|
||||
- Added per-clue false semantics for liar/Wrogn puzzles, including bounded
|
||||
impossible decoy numbers and a setter-wide false-clue mode.
|
||||
- Added the original, uniquely verified “Truth and lies” example demonstrating
|
||||
true and false outside, inclusion, maximum, cage, line and pair clues.
|
||||
- Added outside-grid clue margins, combined dual X-sum/skyscraper labels,
|
||||
outward maximum arrows and visually distinct false clues.
|
||||
- Added strict, bounded local imports for SudokuPad/CTC data and supported
|
||||
Penpa+ long links, with a structured preview of mapped semantics, preserved
|
||||
drawings, metadata and compatibility warnings.
|
||||
- Added a bounded inert visual model for f-puzzles and SCL lines, polylines,
|
||||
rectangles, ellipses, circles and text; source identity, scalar metadata and
|
||||
drawings survive edits, explicit saves, autosaves, reopening and project/share
|
||||
round-trips without becoming rules, and render beneath Fog on the board.
|
||||
- Added SudokuPad/CTC JSON and self-contained `scl…` export with progress,
|
||||
regions, cages and canonical constraint drawings, while rejecting negated
|
||||
clues or free-form global rules it cannot preserve safely.
|
||||
- Added a compatibility check which never fetches SudokuPad short IDs or other
|
||||
server-hosted puzzle references.
|
||||
- Added standalone SVG, high-resolution PNG and single-page PDF visual exports
|
||||
for supported constraints, safe preserved drawings, givens and optional
|
||||
solving progress.
|
||||
- Added named savepoints, isolated hypothesis branches, keep/discard decisions
|
||||
and read-only replay of complete solving states.
|
||||
- Added debounced recovery snapshots for puzzle state, progress, aid-mémoire
|
||||
and a validated, size-bounded gameplay history, with explicit restore/discard
|
||||
handling after an interruption.
|
||||
- Upgraded the local Library with title/tag search, tag and completion filters,
|
||||
safe grid thumbnails, tag editing and selected export, duplication or
|
||||
confirmed deletion.
|
||||
- Added a relative-scope web app manifest and subpath-aware service worker with
|
||||
an offline application-shell fallback after the first successful load.
|
||||
- Added an optional configurable aid-mémoire whose labelled scratch cells
|
||||
support values, both note styles and colours without constraining the puzzle;
|
||||
its state is preserved in undo, replay, Library progress and project/share
|
||||
interchange.
|
||||
- Added a generalized Sum Lab with independent digit bounds, repeats,
|
||||
required/excluded digits, selected-cell candidates, positional assignments
|
||||
and reversible manual eliminations.
|
||||
- Added Candidate Lab filters, selected/touching/specific-house scopes, house
|
||||
positions, strong conjugate/bivalue links, weak links and bounded board
|
||||
overlays.
|
||||
- Added technique-targeted practice generation which mines a bounded,
|
||||
deterministic set of uniquely checked puzzles and verifies the requested
|
||||
technique in the logical solve path.
|
||||
- Added a persisted 75–200% board viewport, Fit and board-scoped zoom shortcuts,
|
||||
plus an announced Pan mode which pauses cell pointer targeting.
|
||||
- Added tap-toggle multi-selection and a single safe-area-aware sticky entry pad
|
||||
at narrow widths, with touch-sized zoom controls.
|
||||
- Added eight hue-and-pattern colour marks, configurable off/concise/detailed
|
||||
candidate narration, and fog-safe board, toolbar, helper and guided-hint
|
||||
boundaries.
|
||||
- Added real ARIA row/gridcell semantics, detailed cell descriptions and
|
||||
non-wrapping Arrow, Home/End, Control/Command + Home/End and Page Up/Down
|
||||
keyboard navigation for the puzzle and scratch grids.
|
||||
- Made Killer cage repeat semantics explicit in the setter and independent of
|
||||
whether a clue is required to be true or false.
|
||||
|
||||
## 0.1.0 - 2026-08-30
|
||||
|
||||
- Initial local-first Sudoku setting, playing, solving and analysis workbench.
|
||||
|
||||
+231
-21
@@ -8,33 +8,243 @@ release also runs independently from any static HTTPS host or local preview.
|
||||
## Workspaces
|
||||
|
||||
- **Play** — keyboard, mouse and touch entry; multi-cell selection; values,
|
||||
corner/centre notes and colours; undo/redo; conflict highlighting; timer and
|
||||
local progress.
|
||||
- **Set** — givens, metadata, regions and typed constraints; uniqueness checks
|
||||
and seeded classic puzzle generation.
|
||||
corner/centre notes and colours; undo/redo; named savepoints, hypothesis
|
||||
branches and read-only replay; staged guided hints; candidate maintenance;
|
||||
conflict highlighting; timer and local progress; optional digit-completion
|
||||
counts, matching-digit highlights and a configurable non-constraining
|
||||
aid-mémoire.
|
||||
- **Set** — givens, metadata, regions and registry-backed constraints;
|
||||
overlap-safe clue replacement and removal; quick uniqueness checks; and a
|
||||
bounded quality lab for ambiguity, contradictions, redundancy and
|
||||
minimality.
|
||||
- **Generate** — seedable single or batch construction for classic and 12
|
||||
variant families, including mixed-family recipes, clue symmetry, constraint
|
||||
density, optional minimal-givens proof and independently checked technique
|
||||
profiles.
|
||||
- **Solve** — exact solution counting plus an original human-style engine whose
|
||||
steps include structured evidence, placements and eliminations.
|
||||
- **Helpers** — Killer combinations and candidate-aware assignments, 45-rule
|
||||
residuals, and Kropki, XV or inequality relation pairs.
|
||||
deterministic steps include structured evidence, placements and
|
||||
eliminations. Uniqueness-dependent logic is disabled unless uniqueness has
|
||||
already been proved by a completed exact search.
|
||||
- **Helpers** — generalized sum combinations with positional candidates and
|
||||
manual eliminations; selected-cell and house candidate-link analysis; Killer
|
||||
combinations, 45-rule residuals, and Kropki, sum-pair or inequality pairs.
|
||||
|
||||
Classic grids and common variants share one bounded puzzle model: irregular
|
||||
regions, diagonals, Killer cages, thermometers, arrows, Kropki and XV clues,
|
||||
inequalities, renban lines, palindromes, anti-knight, anti-king and
|
||||
non-consecutive rules. Import accepts compact grid strings, this project's JSON
|
||||
format, share fragments, and the common f-puzzles fields supported by the
|
||||
model. Unknown constraints are reported rather than silently discarded.
|
||||
## Constraint coverage
|
||||
|
||||
## Privacy and storage
|
||||
Classic grids and variants share one validated puzzle model and constraint
|
||||
registry. The production engine currently supports:
|
||||
|
||||
- classic, irregular and extra regions; diagonals; disjoint groups;
|
||||
anti-knight, anti-king and non-consecutive rules;
|
||||
- Killer cages, ordered clone regions, quadruples, maximum/minimum cells and
|
||||
odd/even cells;
|
||||
- thermometers, arrows, renban and palindrome lines, between lines, German
|
||||
whispers, region-sum lines, modular lines, entropic lines, zipper lines and
|
||||
double arrows;
|
||||
- Kropki dots, numbered 5/10 sum pairs and inequalities;
|
||||
- X-sums, skyscrapers, little-killer diagonals and sandwich sums; and
|
||||
- row, column and rectangular-box indexers, plus display-only Fog of War.
|
||||
|
||||
Each supported semantic constraint is validated and participates in candidate
|
||||
filtering and exact solving. Negatable clues can instead be required to be false
|
||||
for liar/Wrogn constructions; global house rules, extra regions and fog are not
|
||||
given a misleading false mode. Fourteen original bundled examples cover the
|
||||
core families and the uniquely checked “Truth and lies” construction. The
|
||||
newer constraint packs are covered by focused validation, feasibility and exact
|
||||
solver tests rather than bundled third-party puzzles.
|
||||
|
||||
Fog is deliberately a presentation rule, not a hidden solver constraint. It
|
||||
can be set only when the puzzle carries a complete solution that validates
|
||||
against the current givens and constraints. Initial lights, givens and correctly
|
||||
entered digits reveal a radius-zero or radius-one Chebyshev neighbourhood;
|
||||
wrong entries reveal nothing. Obscured cells are disabled and their values,
|
||||
notes, candidates, hints, quality overlays and fully hidden clue graphics are
|
||||
omitted. The trusted solution remains part of the local puzzle document.
|
||||
|
||||
## Guided solving and candidate maintenance
|
||||
|
||||
A guided hint asks the worker for one supported logical step and discloses it in
|
||||
four explicit stages: where to look, technique, reasoning and effects preview.
|
||||
Later-stage answer content is not placed in the document before it is revealed,
|
||||
and the board does not change until **Apply this step** is chosen. Applying a
|
||||
step is one undoable transition.
|
||||
|
||||
Candidate controls can fill every empty cell with its currently legal centre
|
||||
candidates, remove invalid centre/corner notes, and optionally prune peer notes
|
||||
after a placement. Erasing a digit never invents candidates. If an
|
||||
elimination-only hint is applied without tracked candidates, the app first
|
||||
creates a complete legal centre-candidate grid and then applies the explicit
|
||||
elimination.
|
||||
|
||||
The logical engine covers singles; naked and hidden pairs, triples and quads;
|
||||
pointing and claiming; X-Wing, Swordfish and Jellyfish; finned X-Wing and
|
||||
finned Swordfish; XY-Wing and XYZ-Wing; Skyscraper; Two-String Kite; Simple
|
||||
Colouring; W-Wing; X-Chains, XY-Chains and alternating inference chains; and
|
||||
Killer-cage deductions. Unique Rectangle is available only when the caller
|
||||
supplies a completed uniqueness proof. The engine never infers uniqueness from
|
||||
a puzzle’s appearance, metadata or a truncated search.
|
||||
|
||||
## Setter-quality analysis
|
||||
|
||||
The quick setter check performs a bounded zero/one/two-solution audit. A pair of
|
||||
solutions produces a concrete ambiguity witness with every differing cell and
|
||||
value. For a uniquely proved baseline, full analysis uses leave-one-out exact
|
||||
searches to classify each given and constraint as critical, redundant or
|
||||
unknown and creates a per-cell criticality heatmap. For an unsatisfiable
|
||||
baseline it instead uses bounded deletion tests to localise a contradictory
|
||||
core. Optional minimality is proved only when the unique baseline and every
|
||||
required removal check complete.
|
||||
|
||||
Every quality search has configurable per-check node/time limits and shared
|
||||
aggregate check/node/time limits. Results retain the checks performed, elapsed
|
||||
time and reasons for unknown conclusions. Reaching a safety limit is never
|
||||
silently converted into uniqueness, redundancy, contradiction localisation or
|
||||
minimality. The worker analysis can be cancelled without discarding the last
|
||||
completed result.
|
||||
|
||||
## Generator 2.0
|
||||
|
||||
Generation supports the existing classic, diagonal, anti-knight, anti-king,
|
||||
non-consecutive, Killer, thermo, arrow, Kropki, XV, inequality, renban and
|
||||
palindrome families. Compatible families can be combined when they share a
|
||||
supported grid size. Local marking density can be sparse, balanced or dense;
|
||||
for Killer this changes cage granularity, while inherently global rules retain
|
||||
their fixed meaning.
|
||||
|
||||
Clue removal supports no symmetry, half-turn rotation, horizontal or vertical
|
||||
reflection, either diagonal reflection, and four-way quarter-turn symmetry.
|
||||
Minimal-givens mode checks every retained given individually and reports either
|
||||
a completed proof or the exact unknown reasons. Individual minimisation may
|
||||
break the requested visual symmetry, which is reported rather than hidden.
|
||||
|
||||
Technique profiles can require or forbid supported logical techniques, set
|
||||
minimum/maximum occurrence counts and require an exact hardest technique. A
|
||||
puzzle is accepted only when an independent, complete logical path matches the
|
||||
whole profile and an independent exact check proves uniqueness. Deterministic
|
||||
batches contain at most 12 candidates and can be ranked by difficulty, fewest
|
||||
givens or most givens; failed bounded candidates remain visible as failures and
|
||||
are never included in the verified ranking. Generation is cancellable by
|
||||
terminating and restarting its worker.
|
||||
|
||||
Requested difficulty still controls the clue-removal target rather than
|
||||
promising a rating. The displayed 0–100 result is calculated afterwards from
|
||||
logical techniques, clue load and reproducible exact-search evidence. Supported
|
||||
size/family combinations are deliberately restricted, and a bounded mixed
|
||||
construction may fail cleanly for an incompatible or unusually difficult
|
||||
recipe.
|
||||
|
||||
## Mobile and accessibility
|
||||
|
||||
The board viewport offers a locally persisted 75–200% scale in 25% steps,
|
||||
increment/decrement controls and a Fit action that returns to 100% and the
|
||||
origin. Control/Command + plus, minus or zero are board-scoped equivalents.
|
||||
Explicit Pan mode announces its state, cancels any in-progress drag selection
|
||||
and disables cell pointer targeting until it is stopped. Tap multi-select, also
|
||||
available with <kbd>M</kbd>, toggles cells individually without drag selection
|
||||
and never leaves the active selection empty.
|
||||
|
||||
At widths up to 48rem, Play and Set show exactly one sticky entry pad (outside
|
||||
read-only replay) with safe-area padding and touch-sized zoom controls. Eight
|
||||
colour marks use distinct patterns as well as hues and expose the hue/pattern
|
||||
name on both the Sudoku board and aid-mémoire. Screen-reader candidate detail is
|
||||
independently selectable as Off, concise counts or detailed digits without
|
||||
changing the visible board.
|
||||
|
||||
The board exposes real row/gridcell semantics and concise per-cell state to
|
||||
assistive technology, including candidates, notes, colours, conflicts,
|
||||
touching variant clues, hint previews and quality findings. Arrow keys never
|
||||
wrap; Home/End, Control/Command + Home/End and Page Up/Down provide row- and
|
||||
grid-level navigation. The same navigation model is available in the
|
||||
aid-mémoire scratch grid. Guided-hint and quality status changes use live
|
||||
regions.
|
||||
|
||||
Fogged cells remain disabled and expose only an obscured status to assistive
|
||||
technology. Hidden values, notes, constraint descriptions, candidate/helper
|
||||
overlays and hint effects are masked, and toolbar/helper selection is reduced
|
||||
to visible cells. A guided hint is shown only if all of its focus and effect
|
||||
cells are visible; otherwise the app returns a generic no-visible-hint status.
|
||||
|
||||
## Interoperability and visual export
|
||||
|
||||
The import dialog recognises compact grids, Sudoku Tools JSON/share fragments,
|
||||
raw or inline f-puzzles data, SudokuPad/CTC (SCL) data, and Penpa+ self-contained
|
||||
long links. It never follows a URL or resolves a server-side short ID. Imports
|
||||
are size/decompression bounded. Before applying one, a structured mapping
|
||||
preview separates solver-enforced semantics, preserved drawings, retained
|
||||
metadata and warnings.
|
||||
|
||||
The shared inert visual model supports bounded lines, polylines, rectangles,
|
||||
ellipses, circles and text. f-puzzles maps its decorative line, rectangle,
|
||||
circle and text fields; SCL maps supported lines, underlays, overlays and
|
||||
arrows. Drawings, local source identity and scalar metadata survive domain
|
||||
edits, explicit saves, autosaves, reopening, and project/share exports. They
|
||||
never become Sudoku rules unless the preview also lists a mapped semantic
|
||||
constraint. Raw SVG, HTML, CSS, paths or code, unsafe colour syntax, unknown
|
||||
visual fields, excessive data and rotated or otherwise unrepresentable layouts
|
||||
are rejected rather than executed or silently changed.
|
||||
|
||||
SudokuPad compatibility maps cells, givens and progress, notes, regions,
|
||||
metadata, solutions, Killer cages and supported global rules. Export can create
|
||||
readable SudokuPad/CTC JSON or a self-contained `scl…` payload with progress,
|
||||
regions, cages and canonical drawings for registered constraints. Sudoku Tools
|
||||
retains its bounded constraint record for its own exact round-trip; another SCL
|
||||
consumer should only be assumed to enforce constructs it represents natively.
|
||||
Negated clues and free-form global rules cannot be exported to SCL.
|
||||
|
||||
Penpa+ compatibility remains intentionally narrower: square, unrotated Sudoku
|
||||
grids with givens/progress, thermometers and arrows. f-puzzles export likewise
|
||||
refuses false clues and underlay, free-coordinate or offset drawings it cannot
|
||||
preserve faithfully. Preserved source drawings appear on the interactive board
|
||||
and in static/SCL exports; Fog covers their obscured in-grid portions.
|
||||
|
||||
Export can produce a standalone SVG, a high-resolution PNG, or a single-page
|
||||
PDF entirely in the browser. Each visual uses the same trusted puzzle model and
|
||||
renders region boundaries, supported variant clues, safe preserved drawings and
|
||||
givens; current values, notes and cell colours can be included or omitted with
|
||||
the progress switch.
|
||||
|
||||
## Privacy, recovery and Library
|
||||
|
||||
Puzzle parsing, solving, generation and export happen entirely in the browser.
|
||||
There are no analytics, advertisements, accounts or network-backed puzzle
|
||||
lookups. Projects and progress are saved only in local IndexedDB after an
|
||||
explicit save action and can be cleared from inside the app.
|
||||
Exported files and share URLs contain the puzzle data you chose to include.
|
||||
lookups. Explicit projects are stored in IndexedDB. A separate, debounced
|
||||
autosave slot retains the current puzzle state, progress, aid-mémoire and a
|
||||
bounded gameplay history. After an interrupted session, the app offers to
|
||||
restore or discard that recovery state without silently replacing an explicit
|
||||
project. Its status distinguishes checking, saving, saved, ready and unavailable
|
||||
states.
|
||||
|
||||
Gameplay history includes undo/redo moments, named savepoints and hypothesis
|
||||
branches, and is validated when restored. It is bounded to 500 moments and one
|
||||
MiB for its serialized history payload. The Library supports title and tag
|
||||
search, tag and completion filters, script-free grid thumbnails, bounded tag
|
||||
editing, and selected export, duplication or confirmed deletion. Whole-library
|
||||
import and export remain versioned operations.
|
||||
|
||||
If IndexedDB is unavailable, a bounded in-memory fallback keeps work for the
|
||||
current tab and the interface reports that persistence is unavailable. Browser
|
||||
storage can still be removed by the browser or user, so important projects
|
||||
should be exported. Exported files and share URLs contain the puzzle data you
|
||||
chose to include.
|
||||
|
||||
SudokuPad short IDs identify server-hosted records and therefore cannot be
|
||||
resolved by this deliberately offline application. Embedded f-puzzles payloads
|
||||
are accepted without contacting their originating site.
|
||||
and user-provided Sudoku Tools JSON documents are accepted without contacting
|
||||
their originating site. Externally authored puzzle definitions are not bundled.
|
||||
|
||||
## Offline application shell
|
||||
|
||||
Production builds include a relative-scope web app manifest and register a
|
||||
subpath-aware service worker after the page has loaded. It caches the application
|
||||
shell and same-origin built assets, uses network-first navigation with a cached
|
||||
fallback, serves cached static assets when available and removes obsolete cache
|
||||
versions.
|
||||
|
||||
The first successful online load is still required to install those resources,
|
||||
and service-worker availability depends on browser support and a secure origin.
|
||||
Registration is progressive enhancement: the application continues to work
|
||||
when a service worker is unavailable, and puzzle operations never depend on a
|
||||
network connection.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -58,14 +268,14 @@ npm run release:artifact
|
||||
|
||||
The command verifies the manifest, types, formatting, tests, production build,
|
||||
browser workflows and Toolbox contract before creating a deterministic
|
||||
`release/sudoku-tools-0.1.0.zip` with a matching SHA-256 sidecar.
|
||||
`release/sudoku-tools-0.2.1.zip` with a matching SHA-256 sidecar.
|
||||
|
||||
## Scope and references
|
||||
|
||||
The interaction ideas and published solving concepts explored by SudokuPad,
|
||||
SudokuWiki and the wider puzzle community informed the product requirements.
|
||||
No code, artwork, wording or proprietary puzzle data from those applications is
|
||||
included. Algorithms and explanations in this repository are independently
|
||||
No externally authored puzzle definitions, code, artwork or documentation text
|
||||
is bundled. Algorithms, UI, original examples and explanations are independently
|
||||
implemented from mathematical rules and public standards.
|
||||
|
||||
## Licence
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
# Corresponding source
|
||||
|
||||
The corresponding source for Sudoku Tools 0.1.0 is available at:
|
||||
The corresponding source for Sudoku Tools 0.2.1 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.1
|
||||
|
||||
The production archive is generated from that tag with the scripts and exact
|
||||
dependency lock contained in the repository. It contains source identity,
|
||||
|
||||
@@ -16,4 +16,12 @@ runtime package. Development-only packages build and test the source but are
|
||||
not shipped as runtime modules.
|
||||
|
||||
SudokuPad, SudokuWiki and HoDoKu are research references only. Their source,
|
||||
documentation, wording, visual assets and puzzle data are not bundled or copied.
|
||||
documentation wording, visual assets and externally authored puzzle data are
|
||||
not bundled or copied.
|
||||
|
||||
The local compatibility code was tested against the open format behaviour in
|
||||
[Penpa+](https://github.com/swaroopg92/penpa-edit) (MIT),
|
||||
[penpa-to-scl](https://github.com/marktekfan/penpa-to-scl) (MIT), and
|
||||
[SudokuPad sudokutools](https://github.com/SudokuPad/sudokutools) (MPL-2.0).
|
||||
Those projects are format references only: no upstream executable code,
|
||||
artwork, documentation text or puzzle data is distributed with Sudoku Tools.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "Sudoku Tools",
|
||||
"short_name": "Sudoku",
|
||||
"description": "Set, play, solve and analyse Sudoku puzzles locally.",
|
||||
"id": "./",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#f4f0e8",
|
||||
"theme_color": "#272421",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
/* Sudoku Tools offline worker. This file intentionally has no dependencies. */
|
||||
const CACHE_PREFIX = "sudoku-tools-shell-";
|
||||
const CACHE_VERSION = "v1";
|
||||
const CACHE_NAME = `${CACHE_PREFIX}${CACHE_VERSION}`;
|
||||
const CORE_ASSETS = ["./", "./manifest.webmanifest", "./favicon.svg"];
|
||||
const BUILD_ASSET_PATTERN =
|
||||
/(?:(?:\.\/)?assets\/)?[A-Za-z0-9][A-Za-z0-9._-]*-[A-Za-z0-9_-]{6,}\.(?:css|js|json|png|svg|wasm)/gu;
|
||||
|
||||
async function cacheAssetTree(cache, assetUrl, discovered, depth = 0) {
|
||||
const url = new URL(assetUrl, self.registration.scope).href;
|
||||
if (discovered.has(url)) return;
|
||||
discovered.add(url);
|
||||
const response = await fetch(url, { cache: "no-cache" });
|
||||
if (!response.ok) return;
|
||||
await cache.put(url, response.clone());
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (depth >= 3 || !/(?:javascript|text\/css)/iu.test(contentType)) return;
|
||||
const source = await response.text();
|
||||
await Promise.all(
|
||||
[...source.matchAll(BUILD_ASSET_PATTERN)].map((match) => {
|
||||
const reference = match[0];
|
||||
const base = reference.includes("assets/")
|
||||
? self.registration.scope
|
||||
: url;
|
||||
return cacheAssetTree(
|
||||
cache,
|
||||
new URL(reference, base).href,
|
||||
discovered,
|
||||
depth + 1,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function cacheDocumentAssets(cache) {
|
||||
const response = await fetch("./", { cache: "no-cache" });
|
||||
if (!response.ok) throw new Error("Could not cache the application shell.");
|
||||
await cache.put("./", response.clone());
|
||||
const html = await response.text();
|
||||
const urls = new Set(CORE_ASSETS.slice(1));
|
||||
for (const match of html.matchAll(/(?:src|href)=["']([^"'#]+)["']/giu)) {
|
||||
const raw = match[1];
|
||||
if (raw === undefined) continue;
|
||||
const resolved = new URL(raw, self.registration.scope);
|
||||
if (resolved.origin === self.location.origin) urls.add(resolved.href);
|
||||
}
|
||||
const canonicalUrls = new Set([new URL("./", self.registration.scope).href]);
|
||||
await Promise.all(
|
||||
[...urls].map((url) => cacheAssetTree(cache, url, canonicalUrls)),
|
||||
);
|
||||
await Promise.all(
|
||||
(await cache.keys())
|
||||
.filter((request) => !canonicalUrls.has(request.url))
|
||||
.map((request) => cache.delete(request)),
|
||||
);
|
||||
}
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(CACHE_NAME)
|
||||
.then(cacheDocumentAssets)
|
||||
.then(() => self.skipWaiting()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter((key) => key.startsWith(CACHE_PREFIX) && key !== CACHE_NAME)
|
||||
.map((key) => caches.delete(key)),
|
||||
),
|
||||
)
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const request = event.request;
|
||||
if (request.method !== "GET") return;
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then(async (response) => {
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
await cache.put("./", response.clone());
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(async () => {
|
||||
const cached = await caches.match("./");
|
||||
return cached ?? Response.error();
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
caches.match(request).then(
|
||||
(cached) =>
|
||||
cached ??
|
||||
fetch(request).then(async (response) => {
|
||||
if (response.ok && response.type === "basic") {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
await cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.sudoku-tools",
|
||||
"name": "Sudoku Tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.1",
|
||||
"description": "Set, play, solve and analyse Sudoku puzzles locally in the browser.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
|
||||
@@ -50,6 +50,8 @@ if (!force && ((await exists(output)) || (await exists(checksumOutput))))
|
||||
const input = path.join(root, "dist");
|
||||
for (const name of [
|
||||
"index.html",
|
||||
"manifest.webmanifest",
|
||||
"sw.js",
|
||||
"toolbox-app.json",
|
||||
"favicon.svg",
|
||||
"README.md",
|
||||
|
||||
@@ -14,6 +14,7 @@ const mediaTypes = new Map([
|
||||
[".html", "text/html; charset=utf-8"],
|
||||
[".js", "text/javascript; charset=utf-8"],
|
||||
[".json", "application/json; charset=utf-8"],
|
||||
[".webmanifest", "application/manifest+json; charset=utf-8"],
|
||||
[".svg", "image/svg+xml"],
|
||||
[".md", "text/markdown; charset=utf-8"],
|
||||
[".txt", "text/plain; charset=utf-8"],
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import { useRef, type CSSProperties, type KeyboardEvent } from "react";
|
||||
import {
|
||||
aidMemoireCellDescription,
|
||||
clearAidMemoireEntries,
|
||||
compactAidMemoireColumns,
|
||||
configureAidMemoire,
|
||||
enterAidMemoireCell,
|
||||
eraseAidMemoireCell,
|
||||
labelAidMemoireCell,
|
||||
MAX_AID_MEMOIRE_CELLS,
|
||||
MAX_AID_MEMOIRE_COLUMNS,
|
||||
resetAidMemoire,
|
||||
setAidMemoireEnabled,
|
||||
type AidMemoireState,
|
||||
} from "../state/aidMemoire";
|
||||
import {
|
||||
maskValues,
|
||||
symbolFor,
|
||||
valueForKey,
|
||||
type EntryMode,
|
||||
} from "../state/session";
|
||||
|
||||
interface AidMemoireProps {
|
||||
readonly size: number;
|
||||
readonly state: AidMemoireState;
|
||||
readonly selectedCell: number;
|
||||
readonly active: boolean;
|
||||
readonly readOnly?: boolean;
|
||||
readonly mode: EntryMode;
|
||||
readonly onStateChange: (state: AidMemoireState) => void;
|
||||
readonly onSelect: (cell: number) => void;
|
||||
readonly onMode: (mode: EntryMode) => void;
|
||||
}
|
||||
|
||||
const modes: ReadonlyArray<{ mode: EntryMode; label: string; key: string }> = [
|
||||
{ mode: "value", label: "Value", key: "Z" },
|
||||
{ mode: "corner", label: "Corner", key: "X" },
|
||||
{ mode: "center", label: "Centre", key: "C" },
|
||||
{ mode: "color", label: "Colour", key: "V" },
|
||||
];
|
||||
|
||||
export function AidMemoire({
|
||||
size,
|
||||
state,
|
||||
selectedCell,
|
||||
active,
|
||||
readOnly = false,
|
||||
mode,
|
||||
onStateChange,
|
||||
onSelect,
|
||||
onMode,
|
||||
}: AidMemoireProps) {
|
||||
const cellRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const selected = Math.max(0, Math.min(state.cells.length - 1, selectedCell));
|
||||
|
||||
const selectAndFocus = (cell: number) => {
|
||||
const next = Math.max(0, Math.min(state.cells.length - 1, cell));
|
||||
onSelect(next);
|
||||
cellRefs.current[next]?.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (
|
||||
cell: number,
|
||||
event: KeyboardEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
const row = Math.floor(cell / state.columns);
|
||||
const column = cell % state.columns;
|
||||
const rowStart = row * state.columns;
|
||||
const rowEnd = Math.min(
|
||||
state.cells.length - 1,
|
||||
rowStart + state.columns - 1,
|
||||
);
|
||||
let destination: number | undefined;
|
||||
if (event.key === "ArrowLeft") destination = Math.max(rowStart, cell - 1);
|
||||
if (event.key === "ArrowRight") destination = Math.min(rowEnd, cell + 1);
|
||||
if (event.key === "ArrowUp") {
|
||||
destination = cell - state.columns >= 0 ? cell - state.columns : cell;
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
destination =
|
||||
cell + state.columns < state.cells.length ? cell + state.columns : cell;
|
||||
}
|
||||
if (event.key === "Home") {
|
||||
destination = event.ctrlKey || event.metaKey ? 0 : rowStart;
|
||||
}
|
||||
if (event.key === "End") {
|
||||
destination =
|
||||
event.ctrlKey || event.metaKey ? state.cells.length - 1 : rowEnd;
|
||||
}
|
||||
if (event.key === "PageUp") destination = column;
|
||||
if (event.key === "PageDown") {
|
||||
const lastRowStart =
|
||||
Math.floor((state.cells.length - 1) / state.columns) * state.columns;
|
||||
destination = Math.min(state.cells.length - 1, lastRowStart + column);
|
||||
}
|
||||
if (destination !== undefined) {
|
||||
event.preventDefault();
|
||||
selectAndFocus(destination);
|
||||
return;
|
||||
}
|
||||
if (readOnly) return;
|
||||
if (
|
||||
event.key === "Backspace" ||
|
||||
event.key === "Delete" ||
|
||||
event.key === "0"
|
||||
) {
|
||||
event.preventDefault();
|
||||
onStateChange(eraseAidMemoireCell(state, cell, mode));
|
||||
return;
|
||||
}
|
||||
if (!event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||
const modeKey: Record<string, EntryMode> = {
|
||||
z: "value",
|
||||
x: "corner",
|
||||
c: "center",
|
||||
v: "color",
|
||||
};
|
||||
const nextMode = modeKey[event.key.toLowerCase()];
|
||||
if (nextMode !== undefined) {
|
||||
event.preventDefault();
|
||||
onMode(nextMode);
|
||||
return;
|
||||
}
|
||||
const value = valueForKey(event.key, mode === "color" ? 8 : size);
|
||||
if (value !== null) {
|
||||
event.preventDefault();
|
||||
onStateChange(enterAidMemoireCell(state, cell, mode, value, size));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const configure = (cellCount: number, columns: number) => {
|
||||
const next = configureAidMemoire(state, size, cellCount, columns);
|
||||
onStateChange(next);
|
||||
if (selected >= next.cells.length) onSelect(next.cells.length - 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="aid-memoire" aria-labelledby="aid-memoire-title">
|
||||
<header className="section-heading aid-memoire__heading">
|
||||
<div>
|
||||
<p className="eyebrow">Optional scratch cells</p>
|
||||
<h2 id="aid-memoire-title">Aid-mémoire</h2>
|
||||
<p className="muted">
|
||||
Track contextual sets, pseudo-digits or mappings. These cells do not
|
||||
constrain the Sudoku.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-button"
|
||||
disabled={readOnly}
|
||||
onClick={() => onStateChange(setAidMemoireEnabled(state, false))}
|
||||
>
|
||||
Hide
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="aid-memoire__scroller"
|
||||
aria-label="Scrollable aid-mémoire area"
|
||||
>
|
||||
<div
|
||||
className="aid-memoire__grid"
|
||||
role="grid"
|
||||
aria-label={`${String(state.cells.length)} aid-mémoire scratch cells`}
|
||||
aria-colcount={state.columns}
|
||||
aria-rowcount={Math.ceil(state.cells.length / state.columns)}
|
||||
aria-readonly={readOnly}
|
||||
style={{ "--aid-columns": state.columns } as CSSProperties}
|
||||
>
|
||||
{Array.from(
|
||||
{ length: Math.ceil(state.cells.length / state.columns) },
|
||||
(_, rowIndex) => (
|
||||
<div
|
||||
key={rowIndex}
|
||||
className="aid-memoire__row"
|
||||
role="row"
|
||||
aria-rowindex={rowIndex + 1}
|
||||
>
|
||||
{state.cells
|
||||
.slice(
|
||||
rowIndex * state.columns,
|
||||
(rowIndex + 1) * state.columns,
|
||||
)
|
||||
.map((cell, columnIndex) => {
|
||||
const index = rowIndex * state.columns + columnIndex;
|
||||
const cornerMarks = maskValues(cell.cornerMarks, size);
|
||||
const centerMarks = maskValues(cell.centerMarks, size);
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
ref={(element) => {
|
||||
cellRefs.current[index] = element;
|
||||
}}
|
||||
type="button"
|
||||
role="gridcell"
|
||||
className={`aid-memoire__cell${active && selected === index ? " is-selected" : ""}${cell.color ? ` has-color-${String(cell.color)}` : ""}`}
|
||||
aria-label={aidMemoireCellDescription(
|
||||
cell,
|
||||
index,
|
||||
size,
|
||||
)}
|
||||
aria-colindex={columnIndex + 1}
|
||||
aria-selected={active && selected === index}
|
||||
tabIndex={selected === index ? 0 : -1}
|
||||
onClick={() => {
|
||||
if (!readOnly) onSelect(index);
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!readOnly) onSelect(index);
|
||||
}}
|
||||
onKeyDown={(event) => handleKeyDown(index, event)}
|
||||
>
|
||||
<span className="aid-memoire__label" aria-hidden="true">
|
||||
{cell.label || `#${String(index + 1)}`}
|
||||
</span>
|
||||
{cell.value ? (
|
||||
<span
|
||||
className="aid-memoire__value"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{symbolFor(cell.value, size)}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className="aid-memoire__corner"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{cornerMarks
|
||||
.map((mark) => symbolFor(mark, size))
|
||||
.join(" ")}
|
||||
</span>
|
||||
<span
|
||||
className="aid-memoire__centre"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{centerMarks
|
||||
.map((mark) => symbolFor(mark, size))
|
||||
.join("")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mode-switcher aid-memoire__modes"
|
||||
role="group"
|
||||
aria-label="Aid-mémoire entry mode"
|
||||
>
|
||||
{modes.map((item) => (
|
||||
<button
|
||||
key={item.mode}
|
||||
type="button"
|
||||
disabled={readOnly}
|
||||
className={mode === item.mode ? "is-active" : ""}
|
||||
aria-pressed={mode === item.mode}
|
||||
title={`${item.label} mode (${item.key})`}
|
||||
onClick={() => onMode(item.mode)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="aid-memoire__selected">
|
||||
<label>
|
||||
Selected cell label
|
||||
<input
|
||||
value={state.cells[selected]?.label ?? ""}
|
||||
maxLength={40}
|
||||
disabled={readOnly}
|
||||
onChange={(event) =>
|
||||
onStateChange(
|
||||
labelAidMemoireCell(state, selected, event.target.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<p className="muted">
|
||||
The regular keypad edits this cell in {mode} mode. Digits, Z/X/C/V,
|
||||
Delete and arrow keys also work directly in the scratch grid.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<details className="aid-memoire__settings">
|
||||
<summary>Configure scratch layout</summary>
|
||||
<div className="aid-memoire__settings-body">
|
||||
<div className="inline-fields">
|
||||
<label>
|
||||
Cells
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_AID_MEMOIRE_CELLS}
|
||||
value={state.cells.length}
|
||||
disabled={readOnly}
|
||||
onChange={(event) =>
|
||||
configure(Number(event.target.value), state.columns)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Columns
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={Math.min(MAX_AID_MEMOIRE_COLUMNS, state.cells.length)}
|
||||
value={state.columns}
|
||||
disabled={readOnly}
|
||||
onChange={(event) =>
|
||||
configure(state.cells.length, Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="action-row">
|
||||
<button
|
||||
type="button"
|
||||
disabled={readOnly}
|
||||
onClick={() => configure(state.cells.length, state.cells.length)}
|
||||
>
|
||||
Single row
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={readOnly}
|
||||
onClick={() =>
|
||||
configure(
|
||||
state.cells.length,
|
||||
compactAidMemoireColumns(state.cells.length),
|
||||
)
|
||||
}
|
||||
>
|
||||
Compact grid
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={readOnly}
|
||||
onClick={() => {
|
||||
if (window.confirm("Clear every aid-mémoire entry?")) {
|
||||
onStateChange(clearAidMemoireEntries(state));
|
||||
}
|
||||
}}
|
||||
>
|
||||
Clear entries
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
disabled={readOnly}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
"Reset the aid-mémoire layout, labels and entries?",
|
||||
)
|
||||
) {
|
||||
onStateChange(resetAidMemoire(state, size));
|
||||
onSelect(0);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Reset all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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${scale > 1 ? " is-zoomed-in" : ""}${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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { compilePuzzle, type NormalizedPuzzle } from "../domain";
|
||||
import {
|
||||
candidateCellsForValues,
|
||||
candidateUnitIndicesForCells,
|
||||
cellLabel,
|
||||
deriveCandidateLinks,
|
||||
houseLabel,
|
||||
inspectCandidateCells,
|
||||
inspectCandidateHouses,
|
||||
type CandidateLink,
|
||||
type CandidateOverlay,
|
||||
} from "../helpers";
|
||||
import { symbolFor } from "../state/session";
|
||||
|
||||
const LINK_LIST_LIMIT = 160;
|
||||
const OVERLAY_LINK_LIMIT = 500;
|
||||
|
||||
interface CandidateLabProps {
|
||||
readonly puzzle: NormalizedPuzzle;
|
||||
readonly values: readonly number[];
|
||||
readonly candidateMasks: readonly number[];
|
||||
readonly selectedCells: readonly number[];
|
||||
readonly onOverlayChange?: (overlay: CandidateOverlay | undefined) => void;
|
||||
}
|
||||
|
||||
function nodeLabel(node: CandidateLink["a"], size: number): string {
|
||||
return `${cellLabel(node.cell, size)}(${symbolFor(node.value, size)})`;
|
||||
}
|
||||
|
||||
function linkLabel(link: CandidateLink, size: number): string {
|
||||
return `${nodeLabel(link.a, size)} ${link.kind === "strong" ? "⇔" : "—"} ${nodeLabel(link.b, size)}`;
|
||||
}
|
||||
|
||||
export function CandidateLab({
|
||||
puzzle,
|
||||
values,
|
||||
candidateMasks,
|
||||
selectedCells,
|
||||
onOverlayChange,
|
||||
}: CandidateLabProps) {
|
||||
const [activeValues, setActiveValues] = useState<readonly number[]>([]);
|
||||
const [scope, setScope] = useState("selection");
|
||||
const [showStrong, setShowStrong] = useState(true);
|
||||
const [showWeak, setShowWeak] = useState(false);
|
||||
const [includeCellLinks, setIncludeCellLinks] = useState(true);
|
||||
const [focusedLinkId, setFocusedLinkId] = useState<string>();
|
||||
const compiled = useMemo(() => compilePuzzle(puzzle), [puzzle]);
|
||||
const effectiveActiveValues = useMemo(
|
||||
() => activeValues.filter((value) => value <= puzzle.size),
|
||||
[activeValues, puzzle.size],
|
||||
);
|
||||
const relatedUnitIndices = useMemo(
|
||||
() => candidateUnitIndicesForCells(puzzle, selectedCells),
|
||||
[puzzle, selectedCells],
|
||||
);
|
||||
const selectedUnitIndex = scope.startsWith("unit:")
|
||||
? Number(scope.slice(5))
|
||||
: undefined;
|
||||
const effectiveScope =
|
||||
selectedUnitIndex !== undefined &&
|
||||
!relatedUnitIndices.includes(selectedUnitIndex)
|
||||
? "selection"
|
||||
: scope;
|
||||
const scopedUnitIndices = useMemo(() => {
|
||||
if (effectiveScope === "all")
|
||||
return compiled.units.map((_, index) => index);
|
||||
if (
|
||||
selectedUnitIndex !== undefined &&
|
||||
Number.isInteger(selectedUnitIndex) &&
|
||||
compiled.units[selectedUnitIndex] !== undefined
|
||||
) {
|
||||
return [selectedUnitIndex];
|
||||
}
|
||||
return relatedUnitIndices;
|
||||
}, [compiled.units, effectiveScope, relatedUnitIndices, selectedUnitIndex]);
|
||||
const scopedCells = useMemo(() => {
|
||||
if (effectiveScope === "selection") return [...new Set(selectedCells)];
|
||||
return [
|
||||
...new Set(
|
||||
scopedUnitIndices.flatMap(
|
||||
(unitIndex) => compiled.units[unitIndex]?.cells ?? [],
|
||||
),
|
||||
),
|
||||
];
|
||||
}, [compiled.units, effectiveScope, scopedUnitIndices, selectedCells]);
|
||||
const cellInspections = useMemo(
|
||||
() => inspectCandidateCells(puzzle, candidateMasks, selectedCells),
|
||||
[candidateMasks, puzzle, selectedCells],
|
||||
);
|
||||
const houseInspections = useMemo(
|
||||
() =>
|
||||
inspectCandidateHouses(puzzle, values, candidateMasks, scopedUnitIndices),
|
||||
[candidateMasks, puzzle, scopedUnitIndices, values],
|
||||
);
|
||||
const links = useMemo(
|
||||
() =>
|
||||
deriveCandidateLinks(puzzle, candidateMasks, {
|
||||
unitIndices: scopedUnitIndices,
|
||||
cellIndices: scopedCells,
|
||||
...(effectiveActiveValues.length === 0
|
||||
? {}
|
||||
: { values: effectiveActiveValues }),
|
||||
includeCellLinks,
|
||||
}).filter(({ kind }) => (kind === "strong" ? showStrong : showWeak)),
|
||||
[
|
||||
candidateMasks,
|
||||
effectiveActiveValues,
|
||||
includeCellLinks,
|
||||
puzzle,
|
||||
scopedCells,
|
||||
scopedUnitIndices,
|
||||
showStrong,
|
||||
showWeak,
|
||||
],
|
||||
);
|
||||
const focusedLink = links.find(({ id }) => id === focusedLinkId);
|
||||
const visibleLinks = useMemo(
|
||||
() =>
|
||||
focusedLink === undefined
|
||||
? links.slice(0, OVERLAY_LINK_LIMIT)
|
||||
: [focusedLink],
|
||||
[focusedLink, links],
|
||||
);
|
||||
const candidateCells = useMemo(() => {
|
||||
if (effectiveActiveValues.length === 0) return [];
|
||||
return candidateCellsForValues(candidateMasks, effectiveActiveValues);
|
||||
}, [candidateMasks, effectiveActiveValues]);
|
||||
const overlay = useMemo<CandidateOverlay>(
|
||||
() => ({
|
||||
activeValues: effectiveActiveValues,
|
||||
candidateCells,
|
||||
links: visibleLinks,
|
||||
}),
|
||||
[candidateCells, effectiveActiveValues, visibleLinks],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onOverlayChange?.(overlay);
|
||||
}, [onOverlayChange, overlay]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
onOverlayChange?.(undefined);
|
||||
},
|
||||
[onOverlayChange],
|
||||
);
|
||||
|
||||
const toggleValue = (value: number) => {
|
||||
setFocusedLinkId(undefined);
|
||||
setActiveValues((current) => {
|
||||
const valid = current.filter((candidate) => candidate <= puzzle.size);
|
||||
return valid.includes(value)
|
||||
? valid.filter((candidate) => candidate !== value)
|
||||
: [...valid, value].sort((a, b) => a - b);
|
||||
});
|
||||
};
|
||||
const strongCount = links.filter(({ kind }) => kind === "strong").length;
|
||||
const weakCount = links.length - strongCount;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="candidate-lab stack"
|
||||
aria-labelledby="candidate-lab-title"
|
||||
>
|
||||
<div>
|
||||
<p className="eyebrow">Candidate graph</p>
|
||||
<h3 id="candidate-lab-title">Links and houses</h3>
|
||||
<p className="muted">
|
||||
Inspect legal candidates without changing any notes. A solid line is
|
||||
strong (at least one endpoint is true); a dashed line is weak (both
|
||||
endpoints cannot be true).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<fieldset className="candidate-filter">
|
||||
<legend>Digit filters</legend>
|
||||
<div
|
||||
className={`candidate-filter__digits${puzzle.size > 9 ? " candidate-filter__digits--wide" : ""}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={effectiveActiveValues.length === 0 ? "is-active" : ""}
|
||||
aria-pressed={effectiveActiveValues.length === 0}
|
||||
onClick={() => {
|
||||
setActiveValues([]);
|
||||
setFocusedLinkId(undefined);
|
||||
}}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{Array.from({ length: puzzle.size }, (_, index) => index + 1).map(
|
||||
(value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={
|
||||
effectiveActiveValues.includes(value) ? "is-active" : ""
|
||||
}
|
||||
aria-label={`Filter candidate ${symbolFor(value, puzzle.size)}`}
|
||||
aria-pressed={effectiveActiveValues.includes(value)}
|
||||
onClick={() => toggleValue(value)}
|
||||
>
|
||||
{symbolFor(value, puzzle.size)}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="candidate-lab__controls">
|
||||
<label>
|
||||
Inspect
|
||||
<select
|
||||
aria-label="Candidate house scope"
|
||||
value={effectiveScope}
|
||||
onChange={(event) => {
|
||||
setScope(event.target.value);
|
||||
setFocusedLinkId(undefined);
|
||||
}}
|
||||
>
|
||||
<option value="selection">
|
||||
Selected cells and touching houses
|
||||
</option>
|
||||
{relatedUnitIndices.map((unitIndex) => (
|
||||
<option key={unitIndex} value={`unit:${String(unitIndex)}`}>
|
||||
{houseLabel(compiled.units[unitIndex]!)}
|
||||
</option>
|
||||
))}
|
||||
<option value="all">Whole grid</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showStrong}
|
||||
onChange={(event) => {
|
||||
setShowStrong(event.target.checked);
|
||||
setFocusedLinkId(undefined);
|
||||
}}
|
||||
/>
|
||||
Strong links
|
||||
</label>
|
||||
<label className="check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showWeak}
|
||||
onChange={(event) => {
|
||||
setShowWeak(event.target.checked);
|
||||
setFocusedLinkId(undefined);
|
||||
}}
|
||||
/>
|
||||
Weak links
|
||||
</label>
|
||||
<label className="check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeCellLinks}
|
||||
onChange={(event) => {
|
||||
setIncludeCellLinks(event.target.checked);
|
||||
setFocusedLinkId(undefined);
|
||||
}}
|
||||
/>
|
||||
Links inside cells
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="metric-row" aria-live="polite">
|
||||
<span>
|
||||
Candidate cells <strong>{candidateCells.length}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Strong links <strong>{strongCount}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Weak links <strong>{weakCount}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section
|
||||
className="candidate-inspection"
|
||||
aria-labelledby="selected-candidates-title"
|
||||
>
|
||||
<h4 id="selected-candidates-title">Selected cells</h4>
|
||||
<div className="candidate-cell-list">
|
||||
{cellInspections.map((inspection) => (
|
||||
<article key={inspection.cell}>
|
||||
<strong>{cellLabel(inspection.cell, puzzle.size)}</strong>
|
||||
<span>
|
||||
{inspection.values.length === 0
|
||||
? values[inspection.cell]
|
||||
? `Filled ${symbolFor(values[inspection.cell]!, puzzle.size)}`
|
||||
: "No legal candidates"
|
||||
: inspection.values
|
||||
.map((value) => symbolFor(value, puzzle.size))
|
||||
.join(" ")}
|
||||
</span>
|
||||
<small>{inspection.houseLabels.join(" · ")}</small>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="candidate-inspection"
|
||||
aria-labelledby="house-candidates-title"
|
||||
>
|
||||
<h4 id="house-candidates-title">House positions</h4>
|
||||
<div className="candidate-house-list">
|
||||
{houseInspections.map((inspection) => (
|
||||
<details
|
||||
key={inspection.unitIndex}
|
||||
open={houseInspections.length === 1}
|
||||
>
|
||||
<summary>
|
||||
<strong>{inspection.label}</strong>
|
||||
<span>{inspection.missingValues.length} missing</span>
|
||||
</summary>
|
||||
<dl>
|
||||
{inspection.positions
|
||||
.filter(
|
||||
({ value }) =>
|
||||
effectiveActiveValues.length === 0 ||
|
||||
effectiveActiveValues.includes(value),
|
||||
)
|
||||
.map((position) => (
|
||||
<div
|
||||
key={position.value}
|
||||
data-link-kind={position.linkKind}
|
||||
>
|
||||
<dt>{symbolFor(position.value, puzzle.size)}</dt>
|
||||
<dd>
|
||||
{position.cells.length === 0
|
||||
? "nowhere"
|
||||
: position.cells
|
||||
.map((cell) => cellLabel(cell, puzzle.size))
|
||||
.join(" · ")}
|
||||
{position.linkKind === "strong" ? " · strong" : ""}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="candidate-inspection"
|
||||
aria-labelledby="candidate-links-title"
|
||||
>
|
||||
<div className="candidate-section-heading">
|
||||
<h4 id="candidate-links-title">Visible links</h4>
|
||||
{focusedLink !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-button"
|
||||
onClick={() => setFocusedLinkId(undefined)}
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{links.length === 0 ? (
|
||||
<p className="muted">
|
||||
No links match this scope and filter. Enable weak links or inspect
|
||||
another house.
|
||||
</p>
|
||||
) : (
|
||||
<div className="candidate-link-list">
|
||||
{links.slice(0, LINK_LIST_LIMIT).map((link) => (
|
||||
<button
|
||||
key={link.id}
|
||||
type="button"
|
||||
className={`candidate-link candidate-link--${link.kind}`}
|
||||
aria-pressed={focusedLink?.id === link.id}
|
||||
title={link.contexts.map(({ label }) => label).join(", ")}
|
||||
onClick={() =>
|
||||
setFocusedLinkId((current) =>
|
||||
current === link.id ? undefined : link.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>{linkLabel(link, puzzle.size)}</span>
|
||||
<small>
|
||||
{link.kind} ·{" "}
|
||||
{link.contexts.map(({ label }) => label).join(" / ")}
|
||||
</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{links.length > LINK_LIST_LIMIT && (
|
||||
<p className="muted">
|
||||
Showing the first {LINK_LIST_LIMIT} of {links.length} links. Narrow
|
||||
the digit or house filter to inspect the rest.
|
||||
</p>
|
||||
)}
|
||||
{focusedLink === undefined && links.length > OVERLAY_LINK_LIMIT && (
|
||||
<p className="muted">
|
||||
The board overlay is capped at {OVERLAY_LINK_LIMIT} links for
|
||||
responsiveness. Focus a listed link or narrow the filters to see a
|
||||
precise relationship.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+1030
-65
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
import type { DigitCompletion } from "../state/gameplayHelpers";
|
||||
import { symbolFor } from "../state/session";
|
||||
|
||||
function completionLabel(item: DigitCompletion, size: number): string {
|
||||
const symbol = symbolFor(item.digit, size);
|
||||
if (item.status === "done")
|
||||
return `Digit ${symbol}: complete, ${String(item.placed)} of ${String(item.target)} placed`;
|
||||
if (item.status === "overdone")
|
||||
return `Digit ${symbol}: overdone by ${String(item.excess)}, ${String(item.placed)} of ${String(item.target)} placed`;
|
||||
return `Digit ${symbol}: ${String(item.remaining)} remaining, ${String(item.placed)} of ${String(item.target)} placed`;
|
||||
}
|
||||
|
||||
export function DigitCompletionBar({
|
||||
size,
|
||||
completions,
|
||||
highlightedDigit,
|
||||
highlightingEnabled,
|
||||
onHighlight,
|
||||
}: {
|
||||
size: number;
|
||||
completions: readonly DigitCompletion[];
|
||||
highlightedDigit: number | null;
|
||||
highlightingEnabled: boolean;
|
||||
onHighlight: (digit: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="digit-completion" aria-label="Digit completion">
|
||||
<div className="digit-completion__heading">
|
||||
<span>Digit progress</span>
|
||||
<small>muted = complete · red = too many</small>
|
||||
</div>
|
||||
<div
|
||||
className={`digit-completion__bar${size > 9 ? " digit-completion__bar--wide" : ""}`}
|
||||
role="group"
|
||||
aria-label="Highlight matching digits"
|
||||
>
|
||||
{completions.map((item) => (
|
||||
<button
|
||||
key={item.digit}
|
||||
type="button"
|
||||
className={`digit-completion__digit is-${item.status}${highlightedDigit === item.digit ? " is-highlighted" : ""}`}
|
||||
aria-label={completionLabel(item, size)}
|
||||
aria-pressed={highlightedDigit === item.digit}
|
||||
disabled={!highlightingEnabled}
|
||||
title={completionLabel(item, size)}
|
||||
onClick={() => onHighlight(item.digit)}
|
||||
>
|
||||
<strong>{symbolFor(item.digit, size)}</strong>
|
||||
<span>
|
||||
{item.placed}/{item.target}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import {
|
||||
activeHypothesis,
|
||||
gameplayMoment,
|
||||
MAIN_BRANCH_ID,
|
||||
type GameplayHistory,
|
||||
} from "../state/playHistory";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
interface GameplayHistoryDialogProps {
|
||||
readonly open: boolean;
|
||||
readonly history: GameplayHistory;
|
||||
readonly replayMomentId?: string;
|
||||
readonly onClose: () => void;
|
||||
readonly onCreateSavepoint: (name: string) => void;
|
||||
readonly onRestoreSavepoint: (savepointId: string) => void;
|
||||
readonly onDeleteSavepoint: (savepointId: string) => void;
|
||||
readonly onStartHypothesis: (name: string, fromMomentId?: string) => void;
|
||||
readonly onFinishHypothesis: (decision: "keep" | "discard") => void;
|
||||
readonly onReplayMoment: (momentId: string) => void;
|
||||
readonly onReturnLive: () => void;
|
||||
}
|
||||
|
||||
function formatElapsed(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3_600);
|
||||
const minutes = Math.floor((seconds % 3_600) / 60);
|
||||
const rest = seconds % 60;
|
||||
return [hours, minutes, rest]
|
||||
.map((part) => String(part).padStart(2, "0"))
|
||||
.join(":");
|
||||
}
|
||||
|
||||
export function GameplayHistoryDialog({
|
||||
open,
|
||||
history,
|
||||
replayMomentId,
|
||||
onClose,
|
||||
onCreateSavepoint,
|
||||
onRestoreSavepoint,
|
||||
onDeleteSavepoint,
|
||||
onStartHypothesis,
|
||||
onFinishHypothesis,
|
||||
onReplayMoment,
|
||||
onReturnLive,
|
||||
}: GameplayHistoryDialogProps) {
|
||||
const [hypothesisName, setHypothesisName] = useState("");
|
||||
const [savepointName, setSavepointName] = useState("");
|
||||
const hypothesis = activeHypothesis(history);
|
||||
const replayMoment = replayMomentId
|
||||
? gameplayMoment(history, replayMomentId)
|
||||
: undefined;
|
||||
const replayIndex = replayMoment
|
||||
? history.moments.findIndex((moment) => moment.id === replayMoment.id)
|
||||
: history.moments.length - 1;
|
||||
const branchNames = useMemo(
|
||||
() =>
|
||||
new Map([
|
||||
[MAIN_BRANCH_ID, "Main solve"],
|
||||
...history.branches.map((branch) => [branch.id, branch.name] as const),
|
||||
]),
|
||||
[history.branches],
|
||||
);
|
||||
|
||||
const submitHypothesis = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const name = hypothesisName.trim();
|
||||
if (!name) return;
|
||||
onStartHypothesis(name, replayMoment?.id);
|
||||
setHypothesisName("");
|
||||
};
|
||||
|
||||
const submitSavepoint = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const name = savepointName.trim();
|
||||
if (!name) return;
|
||||
onCreateSavepoint(name);
|
||||
setSavepointName("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="Branches, savepoints and replay"
|
||||
onClose={onClose}
|
||||
wide
|
||||
>
|
||||
<div className="history-dialog-grid">
|
||||
<div className="stack">
|
||||
<section className="panel-section history-section">
|
||||
<div>
|
||||
<p className="eyebrow">Hypotheses</p>
|
||||
<h3>Try a path without losing your solve</h3>
|
||||
</div>
|
||||
{hypothesis ? (
|
||||
<div className="hypothesis-active" role="status">
|
||||
<div>
|
||||
<strong>{hypothesis.name}</strong>
|
||||
<span>Every move is being kept on this branch.</span>
|
||||
</div>
|
||||
<div className="action-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFinishHypothesis("keep")}
|
||||
>
|
||||
Keep changes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={() => onFinishHypothesis("discard")}
|
||||
>
|
||||
Discard and return
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form className="history-name-form" onSubmit={submitHypothesis}>
|
||||
<label>
|
||||
Hypothesis name
|
||||
<input
|
||||
value={hypothesisName}
|
||||
maxLength={80}
|
||||
placeholder="e.g. Assume r4c7 is 8"
|
||||
onChange={(event) => setHypothesisName(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={!hypothesisName.trim()}>
|
||||
{replayMoment ? "Branch from this step" : "Start hypothesis"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{history.branches.length > 0 && (
|
||||
<ul className="branch-list" aria-label="Hypothesis branches">
|
||||
{history.branches.map((branch) => (
|
||||
<li key={branch.id}>
|
||||
<span>{branch.name}</span>
|
||||
<span
|
||||
className={`branch-status branch-status--${branch.status}`}
|
||||
>
|
||||
{branch.status}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel-section history-section">
|
||||
<div>
|
||||
<p className="eyebrow">Named savepoints</p>
|
||||
<h3>Return to a deliberate checkpoint</h3>
|
||||
</div>
|
||||
<form className="history-name-form" onSubmit={submitSavepoint}>
|
||||
<label>
|
||||
Savepoint name
|
||||
<input
|
||||
value={savepointName}
|
||||
maxLength={80}
|
||||
disabled={replayMoment !== undefined}
|
||||
placeholder="e.g. Before the long chain"
|
||||
onChange={(event) => setSavepointName(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!savepointName.trim() || replayMoment !== undefined}
|
||||
>
|
||||
Save current grid
|
||||
</button>
|
||||
</form>
|
||||
{history.savepoints.length === 0 ? (
|
||||
<p className="muted">No savepoints yet.</p>
|
||||
) : (
|
||||
<ul className="savepoint-list" aria-label="Named savepoints">
|
||||
{history.savepoints.map((savepoint) => (
|
||||
<li key={savepoint.id}>
|
||||
<div>
|
||||
<strong>{savepoint.name}</strong>
|
||||
<span>
|
||||
{formatElapsed(savepoint.state.elapsedSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="action-row">
|
||||
<button
|
||||
type="button"
|
||||
disabled={replayMoment !== undefined}
|
||||
onClick={() => onRestoreSavepoint(savepoint.id)}
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
aria-label={`Delete savepoint ${savepoint.name}`}
|
||||
onClick={() => onDeleteSavepoint(savepoint.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="panel-section history-section history-replay">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Replay</p>
|
||||
<h3>Review every recorded state</h3>
|
||||
</div>
|
||||
<span className="status-pill">
|
||||
{String(Math.max(0, history.moments.length - 1))} changes
|
||||
</span>
|
||||
</div>
|
||||
{replayMoment ? (
|
||||
<p className="callout" role="status">
|
||||
Viewing “{replayMoment.label}”. The board is read-only until you
|
||||
return live or branch from this step.
|
||||
</p>
|
||||
) : (
|
||||
<p className="muted">
|
||||
Choose a step to inspect its complete grid, notes, colours and
|
||||
elapsed time.
|
||||
</p>
|
||||
)}
|
||||
<div className="action-row history-replay-controls">
|
||||
<button
|
||||
type="button"
|
||||
disabled={hypothesis !== undefined || replayIndex <= 0}
|
||||
onClick={() =>
|
||||
onReplayMoment(history.moments[replayIndex - 1]!.id)
|
||||
}
|
||||
>
|
||||
Previous step
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
replayMoment === undefined ||
|
||||
hypothesis !== undefined ||
|
||||
replayIndex >= history.moments.length - 1
|
||||
}
|
||||
onClick={() =>
|
||||
onReplayMoment(history.moments[replayIndex + 1]!.id)
|
||||
}
|
||||
>
|
||||
Next step
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={replayMoment === undefined}
|
||||
onClick={onReturnLive}
|
||||
>
|
||||
Return to live grid
|
||||
</button>
|
||||
</div>
|
||||
<ol className="history-list" aria-label="Solve history">
|
||||
{[...history.moments].reverse().map((moment) => {
|
||||
const isReplay = moment.id === replayMoment?.id;
|
||||
const isCurrent =
|
||||
replayMoment === undefined &&
|
||||
moment.id === history.currentMomentId;
|
||||
return (
|
||||
<li key={moment.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={isReplay || isCurrent ? "is-current" : ""}
|
||||
disabled={hypothesis !== undefined}
|
||||
aria-current={isReplay || isCurrent ? "step" : undefined}
|
||||
onClick={() =>
|
||||
isCurrent ? onReturnLive() : onReplayMoment(moment.id)
|
||||
}
|
||||
>
|
||||
<span className="history-step-number">
|
||||
{String(moment.sequence).padStart(2, "0")}
|
||||
</span>
|
||||
<span className="history-step-copy">
|
||||
<strong>{moment.label}</strong>
|
||||
<span>
|
||||
{branchNames.get(moment.branchId) ?? "Hypothesis"} ·{" "}
|
||||
{formatElapsed(moment.state.elapsedSeconds)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,715 @@
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import {
|
||||
GENERATOR_VARIANTS,
|
||||
PRACTICE_TECHNIQUES,
|
||||
type BatchRanking,
|
||||
type ClueSymmetry,
|
||||
type ConstraintDensity,
|
||||
type DifficultyAssessment,
|
||||
type GeneratedVariantBatch,
|
||||
type GeneratedVariantPuzzle,
|
||||
type GenerateVariantBatchOptions,
|
||||
type GenerationDifficultyTarget,
|
||||
type GeneratorVariant,
|
||||
type GenerateVariantOptions,
|
||||
type PracticeTechnique,
|
||||
} from "../solver";
|
||||
|
||||
const difficultyTargets: readonly GenerationDifficultyTarget[] = [
|
||||
"beginner",
|
||||
"easy",
|
||||
"medium",
|
||||
"hard",
|
||||
"expert",
|
||||
];
|
||||
|
||||
function techniqueLabel(value: string | undefined): string {
|
||||
return value === undefined
|
||||
? "Direct placements"
|
||||
: value
|
||||
.split("-")
|
||||
.map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function GeneratorWorkspace({
|
||||
busy,
|
||||
assessment,
|
||||
generation,
|
||||
batch,
|
||||
onGenerate,
|
||||
onGenerateBatch,
|
||||
onSelectGenerated,
|
||||
onCancel,
|
||||
onRate,
|
||||
}: {
|
||||
busy: boolean;
|
||||
assessment?: DifficultyAssessment;
|
||||
generation?: GeneratedVariantPuzzle;
|
||||
batch?: GeneratedVariantBatch;
|
||||
onGenerate: (options: GenerateVariantOptions) => void;
|
||||
onGenerateBatch?: (options: GenerateVariantBatchOptions) => void;
|
||||
onSelectGenerated?: (generation: GeneratedVariantPuzzle) => void;
|
||||
onCancel?: () => void;
|
||||
onRate: () => void;
|
||||
}) {
|
||||
const [variant, setVariant] = useState<GeneratorVariant>("classic");
|
||||
const definition = useMemo(
|
||||
() => GENERATOR_VARIANTS.find((item) => item.id === variant)!,
|
||||
[variant],
|
||||
);
|
||||
const [size, setSize] = useState(9);
|
||||
const [targetDifficulty, setTargetDifficulty] =
|
||||
useState<GenerationDifficultyTarget>("medium");
|
||||
const [additionalVariants, setAdditionalVariants] = useState<
|
||||
GeneratorVariant[]
|
||||
>([]);
|
||||
const [symmetry, setSymmetry] = useState<ClueSymmetry>("rotational");
|
||||
const [constraintCount, setConstraintCount] = useState(8);
|
||||
const [constraintDensity, setConstraintDensity] =
|
||||
useState<ConstraintDensity>("balanced");
|
||||
const [minimalGivens, setMinimalGivens] = useState(false);
|
||||
const [batchSize, setBatchSize] = useState(1);
|
||||
const [batchRanking, setBatchRanking] = useState<BatchRanking>("difficulty");
|
||||
const [seed, setSeed] = useState("");
|
||||
const [requiredTechnique, setRequiredTechnique] = useState<
|
||||
PracticeTechnique | ""
|
||||
>("");
|
||||
const [maxTechniqueAttempts, setMaxTechniqueAttempts] = useState(10);
|
||||
const [minimumTechniqueCount, setMinimumTechniqueCount] = useState(1);
|
||||
const [maximumTechniqueCount, setMaximumTechniqueCount] = useState("");
|
||||
const [forbiddenTechnique, setForbiddenTechnique] = useState<
|
||||
PracticeTechnique | ""
|
||||
>("");
|
||||
const [hardestTechnique, setHardestTechnique] = useState<
|
||||
PracticeTechnique | ""
|
||||
>("");
|
||||
const selectedFamilies = [variant, ...additionalVariants];
|
||||
const usesMarkingCount = selectedFamilies.some(
|
||||
(family) =>
|
||||
![
|
||||
"classic",
|
||||
"diagonal",
|
||||
"killer",
|
||||
"anti-knight",
|
||||
"anti-king",
|
||||
"non-consecutive",
|
||||
].includes(family),
|
||||
);
|
||||
const usesConstraintDensity = selectedFamilies.some(
|
||||
(family) =>
|
||||
family === "killer" ||
|
||||
![
|
||||
"classic",
|
||||
"diagonal",
|
||||
"anti-knight",
|
||||
"anti-king",
|
||||
"non-consecutive",
|
||||
].includes(family),
|
||||
);
|
||||
const hasTechniqueProfile =
|
||||
requiredTechnique !== "" ||
|
||||
forbiddenTechnique !== "" ||
|
||||
hardestTechnique !== "" ||
|
||||
maximumTechniqueCount !== "" ||
|
||||
minimumTechniqueCount !== 1;
|
||||
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const profileCounts =
|
||||
requiredTechnique !== "" &&
|
||||
(minimumTechniqueCount !== 1 || maximumTechniqueCount !== "")
|
||||
? [
|
||||
{
|
||||
technique: requiredTechnique,
|
||||
min: minimumTechniqueCount,
|
||||
...(maximumTechniqueCount === ""
|
||||
? {}
|
||||
: { max: Number(maximumTechniqueCount) }),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const options: GenerateVariantBatchOptions = {
|
||||
variant,
|
||||
...(additionalVariants.length === 0
|
||||
? {}
|
||||
: { variants: selectedFamilies }),
|
||||
size,
|
||||
targetDifficulty,
|
||||
symmetry,
|
||||
...(usesMarkingCount ? { constraintCount } : {}),
|
||||
...(usesConstraintDensity && constraintDensity !== "balanced"
|
||||
? { constraintDensity }
|
||||
: {}),
|
||||
...(minimalGivens ? { minimalGivens: true } : {}),
|
||||
...(requiredTechnique === "" ? {} : { requiredTechnique }),
|
||||
...(hasTechniqueProfile
|
||||
? {
|
||||
techniqueProfile: {
|
||||
...(forbiddenTechnique === ""
|
||||
? {}
|
||||
: { forbidden: [forbiddenTechnique] }),
|
||||
...(profileCounts.length === 0 ? {} : { counts: profileCounts }),
|
||||
...(hardestTechnique === "" ? {} : { hardestTechnique }),
|
||||
},
|
||||
maxTechniqueAttempts,
|
||||
}
|
||||
: {}),
|
||||
seed: seed.trim() || `local-${Date.now().toString(36)}`,
|
||||
};
|
||||
if (batchSize > 1 && onGenerateBatch !== undefined) {
|
||||
onGenerateBatch({ ...options, batchSize, ranking: batchRanking });
|
||||
} else {
|
||||
onGenerate(options);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="generator-workspace stack">
|
||||
<div>
|
||||
<p className="eyebrow">Bounded local construction</p>
|
||||
<h2>Sudoku generator</h2>
|
||||
<p className="muted">
|
||||
Build a seedable, uniquely checked puzzle in the worker. The requested
|
||||
level guides clue removal; the finished puzzle receives an independent
|
||||
evidence-based rating.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="panel-section generator-form" onSubmit={submit}>
|
||||
<div className="field-grid">
|
||||
<label>
|
||||
Variant
|
||||
<select
|
||||
value={variant}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value as GeneratorVariant;
|
||||
const nextDefinition = GENERATOR_VARIANTS.find(
|
||||
(item) => item.id === next,
|
||||
)!;
|
||||
setVariant(next);
|
||||
setAdditionalVariants((current) =>
|
||||
current.filter(
|
||||
(family) =>
|
||||
family !== next &&
|
||||
(
|
||||
GENERATOR_VARIANTS.find(({ id }) => id === family)
|
||||
?.supportedSizes as readonly number[]
|
||||
).includes(size),
|
||||
),
|
||||
);
|
||||
if (
|
||||
!(
|
||||
nextDefinition.supportedSizes as readonly number[]
|
||||
).includes(size)
|
||||
) {
|
||||
setSize(nextDefinition.supportedSizes[0]);
|
||||
}
|
||||
if (next !== "killer") {
|
||||
setRequiredTechnique((current) =>
|
||||
current === "killer-cage" ? "" : current,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{GENERATOR_VARIANTS.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Grid
|
||||
<select
|
||||
value={size}
|
||||
onChange={(event) => {
|
||||
const nextSize = Number(event.target.value);
|
||||
setSize(nextSize);
|
||||
setAdditionalVariants((current) =>
|
||||
current.filter((family) =>
|
||||
(
|
||||
GENERATOR_VARIANTS.find(({ id }) => id === family)
|
||||
?.supportedSizes as readonly number[]
|
||||
).includes(nextSize),
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{definition.supportedSizes.map((supportedSize) => (
|
||||
<option key={supportedSize} value={supportedSize}>
|
||||
{supportedSize} × {supportedSize}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<fieldset className="generator-family-picker">
|
||||
<legend>Mix in variant families</legend>
|
||||
<div className="generator-family-options">
|
||||
{GENERATOR_VARIANTS.filter(({ id }) => id !== variant).map(
|
||||
(item) => {
|
||||
const supported = (
|
||||
item.supportedSizes as readonly number[]
|
||||
).includes(size);
|
||||
return (
|
||||
<label key={item.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={additionalVariants.includes(item.id)}
|
||||
disabled={!supported}
|
||||
onChange={(event) =>
|
||||
setAdditionalVariants((current) =>
|
||||
event.target.checked
|
||||
? [...current, item.id]
|
||||
: current.filter((id) => id !== item.id),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{item.label}
|
||||
</label>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
<label>
|
||||
Requested profile
|
||||
<select
|
||||
value={targetDifficulty}
|
||||
onChange={(event) =>
|
||||
setTargetDifficulty(
|
||||
event.target.value as GenerationDifficultyTarget,
|
||||
)
|
||||
}
|
||||
>
|
||||
{difficultyTargets.map((target) => (
|
||||
<option key={target} value={target}>
|
||||
{target[0]!.toUpperCase() + target.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Given symmetry
|
||||
<select
|
||||
value={symmetry}
|
||||
onChange={(event) =>
|
||||
setSymmetry(event.target.value as ClueSymmetry)
|
||||
}
|
||||
>
|
||||
<option value="rotational">Rotational</option>
|
||||
<option value="horizontal">Horizontal reflection</option>
|
||||
<option value="vertical">Vertical reflection</option>
|
||||
<option value="diagonal-main">Main diagonal</option>
|
||||
<option value="diagonal-anti">Anti-diagonal</option>
|
||||
<option value="orthogonal">Four-way orthogonal</option>
|
||||
<option value="none">None</option>
|
||||
</select>
|
||||
</label>
|
||||
{usesMarkingCount && (
|
||||
<label>
|
||||
Requested markings
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={size * size * 2}
|
||||
value={constraintCount}
|
||||
onChange={(event) =>
|
||||
setConstraintCount(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{usesConstraintDensity && (
|
||||
<label>
|
||||
Constraint density
|
||||
<select
|
||||
value={constraintDensity}
|
||||
onChange={(event) =>
|
||||
setConstraintDensity(event.target.value as ConstraintDensity)
|
||||
}
|
||||
>
|
||||
<option value="sparse">Sparse</option>
|
||||
<option value="balanced">Balanced</option>
|
||||
<option value="dense">Dense</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label className="checkbox-field">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={minimalGivens}
|
||||
onChange={(event) => setMinimalGivens(event.target.checked)}
|
||||
/>
|
||||
Prove minimal givens
|
||||
</label>
|
||||
<label>
|
||||
Batch size
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="12"
|
||||
value={batchSize}
|
||||
onChange={(event) => setBatchSize(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
{batchSize > 1 && (
|
||||
<label>
|
||||
Batch ranking
|
||||
<select
|
||||
value={batchRanking}
|
||||
onChange={(event) =>
|
||||
setBatchRanking(event.target.value as BatchRanking)
|
||||
}
|
||||
>
|
||||
<option value="difficulty">Difficulty</option>
|
||||
<option value="fewest-givens">Fewest givens</option>
|
||||
<option value="most-givens">Most givens</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
Seed
|
||||
<input
|
||||
value={seed}
|
||||
maxLength={128}
|
||||
placeholder="blank = fresh local seed"
|
||||
onChange={(event) => setSeed(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Practice technique
|
||||
<select
|
||||
value={requiredTechnique}
|
||||
onChange={(event) =>
|
||||
setRequiredTechnique(
|
||||
event.target.value as PracticeTechnique | "",
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">No required technique</option>
|
||||
{PRACTICE_TECHNIQUES.filter(
|
||||
(technique) =>
|
||||
technique !== "killer-cage" ||
|
||||
selectedFamilies.includes("killer"),
|
||||
).map((technique) => (
|
||||
<option key={technique} value={technique}>
|
||||
{techniqueLabel(technique)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{requiredTechnique !== "" && (
|
||||
<>
|
||||
<label>
|
||||
Minimum occurrences
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000"
|
||||
value={minimumTechniqueCount}
|
||||
onChange={(event) =>
|
||||
setMinimumTechniqueCount(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Maximum occurrences
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="10000"
|
||||
value={maximumTechniqueCount}
|
||||
placeholder="no maximum"
|
||||
onChange={(event) =>
|
||||
setMaximumTechniqueCount(event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<label>
|
||||
Forbidden technique
|
||||
<select
|
||||
value={forbiddenTechnique}
|
||||
onChange={(event) =>
|
||||
setForbiddenTechnique(
|
||||
event.target.value as PracticeTechnique | "",
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{PRACTICE_TECHNIQUES.map((technique) => (
|
||||
<option key={technique} value={technique}>
|
||||
{techniqueLabel(technique)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Hardest technique target
|
||||
<select
|
||||
value={hardestTechnique}
|
||||
onChange={(event) =>
|
||||
setHardestTechnique(
|
||||
event.target.value as PracticeTechnique | "",
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Any</option>
|
||||
{PRACTICE_TECHNIQUES.filter(
|
||||
(technique) =>
|
||||
technique !== "killer-cage" ||
|
||||
selectedFamilies.includes("killer"),
|
||||
).map((technique) => (
|
||||
<option key={technique} value={technique}>
|
||||
{techniqueLabel(technique)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{hasTechniqueProfile && (
|
||||
<label>
|
||||
Mining attempts
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="32"
|
||||
value={maxTechniqueAttempts}
|
||||
onChange={(event) =>
|
||||
setMaxTechniqueAttempts(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<p className="generator-description">{definition.description}</p>
|
||||
<div className="action-row">
|
||||
<button className="primary-button" type="submit" disabled={busy}>
|
||||
{busy
|
||||
? "Working locally…"
|
||||
: batchSize > 1
|
||||
? `Generate ${String(batchSize)}-puzzle batch`
|
||||
: `Generate ${definition.label}`}
|
||||
</button>
|
||||
{busy && onCancel !== undefined && (
|
||||
<button type="button" onClick={onCancel}>
|
||||
Cancel generation
|
||||
</button>
|
||||
)}
|
||||
<button type="button" disabled={busy} onClick={onRate}>
|
||||
Rate current puzzle
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted">
|
||||
Difficulty is an estimate from reproducible solver evidence, not a
|
||||
universal promise. Technique practice only returns a puzzle whose
|
||||
complete independently analysed path matches every requested rule.
|
||||
Uniqueness and minimality are never claimed after a safety limit.
|
||||
</p>
|
||||
</form>
|
||||
|
||||
{assessment && (
|
||||
<section
|
||||
className="analysis-summary difficulty-card"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Difficulty assessment</p>
|
||||
<h3>{assessment.label}</h3>
|
||||
</div>
|
||||
<span
|
||||
className={`status-pill${assessment.uniqueness === "unique" ? " status-solved" : " status-invalid"}`}
|
||||
>
|
||||
{assessment.uniqueness}
|
||||
</span>
|
||||
</div>
|
||||
<div className="metric-row">
|
||||
<span>
|
||||
Score <strong>{assessment.score ?? "—"}/100</strong>
|
||||
</span>
|
||||
<span>
|
||||
Givens <strong>{assessment.clueCount}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Logical steps <strong>{assessment.logicalSteps}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Hardest{" "}
|
||||
<strong>{techniqueLabel(assessment.hardestTechnique)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Search nodes <strong>{assessment.exactNodes}</strong>
|
||||
</span>
|
||||
{generation && (
|
||||
<span>
|
||||
Markings <strong>{generation.generatedConstraintCount}</strong>
|
||||
</span>
|
||||
)}
|
||||
{generation?.requestedTechnique && (
|
||||
<span>
|
||||
Practice{" "}
|
||||
<strong>
|
||||
{techniqueLabel(generation.requestedTechnique)} ×
|
||||
{generation.difficulty.techniqueCounts[
|
||||
generation.requestedTechnique
|
||||
] ?? 0}
|
||||
</strong>
|
||||
</span>
|
||||
)}
|
||||
{generation && (
|
||||
<span>
|
||||
Minimality <strong>{generation.minimality.status}</strong>
|
||||
</span>
|
||||
)}
|
||||
{generation?.techniqueProfile && (
|
||||
<span>
|
||||
Profile <strong>{generation.techniqueProfile.status}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p>{assessment.summary}</p>
|
||||
{generation && (
|
||||
<p className="muted">
|
||||
Seed: <code>{String(generation.seed)}</code> · Found in{" "}
|
||||
{generation.generationAttempts} attempt
|
||||
{generation.generationAttempts === 1 ? "" : "s"}.
|
||||
{generation.families.length > 1
|
||||
? ` Families: ${generation.families.join(", ")}.`
|
||||
: ""}
|
||||
</p>
|
||||
)}
|
||||
{generation?.minimality.status === "proven-minimal" && (
|
||||
<p className="muted">
|
||||
Minimal-givens proof completed in{" "}
|
||||
{generation.minimality.checksPerformed} bounded checks; every
|
||||
remaining given is critical.
|
||||
{!generation.minimality.symmetryPreserved
|
||||
? " Individual minimization changed the requested clue symmetry."
|
||||
: ""}
|
||||
</p>
|
||||
)}
|
||||
{generation?.minimality.status === "unknown" && (
|
||||
<p className="muted">
|
||||
Minimality remains unknown after{" "}
|
||||
{generation.minimality.checksPerformed} bounded checks. Unresolved
|
||||
givens: {generation.minimality.unknownCells.length}. Reasons:{" "}
|
||||
{generation.minimality.limitReasons
|
||||
.map((reason) => reason.replaceAll("-", " "))
|
||||
.join(", ") || "incomplete evidence"}
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
{generation?.techniqueProfile && (
|
||||
<p className="muted">
|
||||
Independent logical path:{" "}
|
||||
{generation.techniqueProfile.completePath
|
||||
? "complete"
|
||||
: "incomplete"}
|
||||
{generation.techniqueProfile.requirements.length === 0
|
||||
? ""
|
||||
: ` · ${generation.techniqueProfile.requirements
|
||||
.map(
|
||||
({ technique, actual, minimum, maximum }) =>
|
||||
`${techniqueLabel(technique)} ${String(actual)}${minimum === undefined ? "" : ` ≥ ${String(minimum)}`}${maximum === undefined ? "" : ` ≤ ${String(maximum)}`}`,
|
||||
)
|
||||
.join(" · ")}`}
|
||||
{generation.techniqueProfile.requestedHardestTechnique ===
|
||||
undefined
|
||||
? ""
|
||||
: ` · Hardest: ${techniqueLabel(generation.techniqueProfile.actualHardestTechnique)} (target ${techniqueLabel(generation.techniqueProfile.requestedHardestTechnique)})`}
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{batch && (
|
||||
<section className="panel-section generator-batch" aria-live="polite">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Ranked local batch</p>
|
||||
<h3>
|
||||
{batch.completed} of {batch.requested} generated
|
||||
</h3>
|
||||
</div>
|
||||
<span className="status-pill">
|
||||
{batch.ranking.replaceAll("-", " ")}
|
||||
</span>
|
||||
</div>
|
||||
{batch.summaries.length === 0 ? (
|
||||
<p>No candidate passed the bounded checks.</p>
|
||||
) : (
|
||||
<div className="setter-quality__table-wrap">
|
||||
<table className="setter-quality__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Rank</th>
|
||||
<th scope="col">Seed</th>
|
||||
<th scope="col">Families</th>
|
||||
<th scope="col">Givens</th>
|
||||
<th scope="col">Score</th>
|
||||
<th scope="col">Evidence</th>
|
||||
<th scope="col">Open</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{batch.summaries.map((summary, index) => {
|
||||
const entry = batch.entries[index];
|
||||
return (
|
||||
<tr key={String(summary.seed)}>
|
||||
<th scope="row">{summary.rank}</th>
|
||||
<td>
|
||||
<code>{String(summary.seed)}</code>
|
||||
</td>
|
||||
<td>{summary.families.join(" + ")}</td>
|
||||
<td>{summary.clueCount}</td>
|
||||
<td>{summary.score ?? "—"}</td>
|
||||
<td>
|
||||
{summary.minimalityStatus}
|
||||
{summary.profileStatus
|
||||
? ` · ${summary.profileStatus}`
|
||||
: ""}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
disabled={entry === undefined}
|
||||
onClick={() => {
|
||||
if (entry !== undefined) {
|
||||
onSelectGenerated?.(entry);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Use
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{batch.failures.length > 0 && (
|
||||
<details>
|
||||
<summary>
|
||||
{batch.failures.length} bounded candidate
|
||||
{batch.failures.length === 1 ? "" : "s"} did not complete or
|
||||
match the profile
|
||||
</summary>
|
||||
<ul className="muted">
|
||||
{batch.failures.map((failure) => (
|
||||
<li key={String(failure.seed)}>
|
||||
<code>{String(failure.seed)}</code>: {failure.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import type { LogicalStep } from "../solver";
|
||||
import {
|
||||
GUIDED_HINT_STAGES,
|
||||
guidedHintEffectItems,
|
||||
guidedHintFocusSummary,
|
||||
isGuidedHintStageRevealed,
|
||||
logicalTechniqueDescription,
|
||||
logicalTechniqueName,
|
||||
nextGuidedHintStage,
|
||||
type GuidedHintStage,
|
||||
} from "./guidedHint";
|
||||
|
||||
const STAGE_LABELS: Record<GuidedHintStage, string> = {
|
||||
focus: "Where to look",
|
||||
technique: "Technique",
|
||||
reasoning: "Reasoning",
|
||||
preview: "Effects preview",
|
||||
};
|
||||
|
||||
function revealButtonLabel(stage: GuidedHintStage): string | undefined {
|
||||
const next = nextGuidedHintStage(stage);
|
||||
if (next === "technique") return "Reveal technique";
|
||||
if (next === "reasoning") return "Reveal reasoning";
|
||||
if (next === "preview") return "Preview effects";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function stageStatus(stage: GuidedHintStage): string {
|
||||
if (stage === "focus") return "Hint ready: where to look.";
|
||||
if (stage === "technique") return "Technique revealed.";
|
||||
if (stage === "reasoning") return "Reasoning revealed.";
|
||||
return "Effects ready to preview and apply.";
|
||||
}
|
||||
|
||||
export interface GuidedHintProps {
|
||||
readonly size: number;
|
||||
readonly step?: LogicalStep;
|
||||
readonly stage: GuidedHintStage;
|
||||
readonly busy?: boolean;
|
||||
readonly error?: string;
|
||||
readonly candidateTrackingActive?: boolean;
|
||||
readonly autoMaintainPeerNotes: boolean;
|
||||
readonly onRequestHint: () => void;
|
||||
readonly onRevealNext: () => void;
|
||||
readonly onApply: () => void;
|
||||
readonly onDismiss: () => void;
|
||||
readonly onFillLegalCandidates: () => void;
|
||||
readonly onRemoveInvalidNotes: () => void;
|
||||
readonly onAutoMaintainPeerNotesChange: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A controlled, progressively disclosed hint. The caller owns the hint step
|
||||
* and stage so it can keep the board overlay and undo history in sync.
|
||||
*/
|
||||
export function GuidedHint({
|
||||
size,
|
||||
step,
|
||||
stage,
|
||||
busy = false,
|
||||
error,
|
||||
candidateTrackingActive = false,
|
||||
autoMaintainPeerNotes,
|
||||
onRequestHint,
|
||||
onRevealNext,
|
||||
onApply,
|
||||
onDismiss,
|
||||
onFillLegalCandidates,
|
||||
onRemoveInvalidNotes,
|
||||
onAutoMaintainPeerNotesChange,
|
||||
}: GuidedHintProps) {
|
||||
const nextLabel = step === undefined ? undefined : revealButtonLabel(stage);
|
||||
const effectItems =
|
||||
step !== undefined && stage === "preview"
|
||||
? guidedHintEffectItems(step, size)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<section className="guided-hint stack" aria-labelledby="guided-hint-title">
|
||||
<div className="guided-hint__heading">
|
||||
<div>
|
||||
<p className="eyebrow">Guided solving</p>
|
||||
<h3 id="guided-hint-title">One clue at a time</h3>
|
||||
</div>
|
||||
{step !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
className="guided-hint__dismiss"
|
||||
onClick={onDismiss}
|
||||
disabled={busy}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="guided-hint__intro muted">
|
||||
Reveal only as much help as you want. Nothing changes until you apply
|
||||
the fully previewed step.
|
||||
</p>
|
||||
|
||||
<p
|
||||
className="guided-hint__status status-line"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{busy
|
||||
? "Finding a logical next step locally…"
|
||||
: error !== undefined
|
||||
? "The hint could not be prepared."
|
||||
: step === undefined
|
||||
? "No guided hint is open."
|
||||
: stageStatus(stage)}
|
||||
</p>
|
||||
|
||||
{error !== undefined && (
|
||||
<p className="guided-hint__error error-callout" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{step !== undefined && (
|
||||
<>
|
||||
<ol
|
||||
className="guided-hint__progress"
|
||||
aria-label="Hint reveal progress"
|
||||
>
|
||||
{GUIDED_HINT_STAGES.map((item) => {
|
||||
const revealed = isGuidedHintStageRevealed(stage, item);
|
||||
return (
|
||||
<li
|
||||
key={item}
|
||||
className={revealed ? "is-revealed" : "is-concealed"}
|
||||
aria-current={item === stage ? "step" : undefined}
|
||||
>
|
||||
{STAGE_LABELS[item]}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
<div className="guided-hint__stage">
|
||||
<section
|
||||
className="guided-hint__focus"
|
||||
aria-labelledby="hint-focus-title"
|
||||
>
|
||||
<p className="eyebrow" id="hint-focus-title">
|
||||
Where to look
|
||||
</p>
|
||||
<p>{guidedHintFocusSummary(step, size)}</p>
|
||||
</section>
|
||||
|
||||
{isGuidedHintStageRevealed(stage, "technique") && (
|
||||
<section
|
||||
className="guided-hint__technique"
|
||||
aria-labelledby="hint-technique-title"
|
||||
>
|
||||
<p className="eyebrow" id="hint-technique-title">
|
||||
Technique
|
||||
</p>
|
||||
<p>
|
||||
<strong>{logicalTechniqueName(step.technique)}</strong>
|
||||
</p>
|
||||
<p className="muted">
|
||||
{logicalTechniqueDescription(step.technique)}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{isGuidedHintStageRevealed(stage, "reasoning") && (
|
||||
<section
|
||||
className="guided-hint__reasoning"
|
||||
aria-labelledby="hint-reasoning-title"
|
||||
>
|
||||
<p className="eyebrow" id="hint-reasoning-title">
|
||||
Why it works
|
||||
</p>
|
||||
<p>{step.explanation}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{stage === "preview" && (
|
||||
<section
|
||||
className="guided-hint__preview"
|
||||
aria-labelledby="hint-preview-title"
|
||||
>
|
||||
<p className="eyebrow" id="hint-preview-title">
|
||||
Effects preview
|
||||
</p>
|
||||
{effectItems.length > 0 ? (
|
||||
<ul className="guided-hint__effect-list">
|
||||
{effectItems.map((effect, index) => (
|
||||
<li key={`${String(index)}-${effect}`}>{effect}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>This deduction does not change the board.</p>
|
||||
)}
|
||||
{step.eliminations.length > 0 && !candidateTrackingActive && (
|
||||
<p className="guided-hint__tracking-note muted">
|
||||
Applying this elimination will start a complete legal
|
||||
centre-candidate grid, then remove the previewed candidates.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="guided-hint__actions action-row">
|
||||
{nextLabel !== undefined && (
|
||||
<button type="button" onClick={onRevealNext} disabled={busy}>
|
||||
{nextLabel}
|
||||
</button>
|
||||
)}
|
||||
{stage === "preview" && (
|
||||
<button
|
||||
type="button"
|
||||
className="guided-hint__apply"
|
||||
onClick={onApply}
|
||||
disabled={busy || effectItems.length === 0}
|
||||
>
|
||||
Apply this step
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={onRequestHint} disabled={busy}>
|
||||
New hint
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === undefined && (
|
||||
<div className="guided-hint__actions action-row">
|
||||
<button type="button" onClick={onRequestHint} disabled={busy}>
|
||||
Get a guided hint
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<fieldset className="guided-hint__maintenance">
|
||||
<legend>Candidate maintenance</legend>
|
||||
<p className="muted" id="guided-candidate-status">
|
||||
{candidateTrackingActive
|
||||
? "The guided candidate grid is active."
|
||||
: "Candidate tracking is currently inactive."}
|
||||
</p>
|
||||
<div
|
||||
className="guided-hint__maintenance-actions action-row"
|
||||
aria-describedby="guided-candidate-status"
|
||||
>
|
||||
<button type="button" onClick={onFillLegalCandidates} disabled={busy}>
|
||||
Fill legal candidates
|
||||
</button>
|
||||
<button type="button" onClick={onRemoveInvalidNotes} disabled={busy}>
|
||||
Remove invalid notes
|
||||
</button>
|
||||
</div>
|
||||
<label className="guided-hint__auto-maintain check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoMaintainPeerNotes}
|
||||
disabled={busy}
|
||||
onChange={(event) =>
|
||||
onAutoMaintainPeerNotesChange(event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
Automatically remove peer notes after placing a digit
|
||||
</label>
|
||||
</fieldset>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+200
-14
@@ -11,13 +11,16 @@ export function HelpDialog({
|
||||
<Modal open={open} title="Sudoku Tools help" onClose={onClose} wide>
|
||||
<div className="help-grid">
|
||||
<section>
|
||||
<h3>Four complementary workspaces</h3>
|
||||
<h3>Five complementary workspaces</h3>
|
||||
<p>
|
||||
<strong>Play</strong> keeps values, two kinds of notes, colours,
|
||||
history and elapsed time. <strong>Set</strong> edits clues and
|
||||
constraints. <strong>Solve</strong> explains logical steps and can
|
||||
verify uniqueness. <strong>Helpers</strong> answers focused
|
||||
questions without changing the board.
|
||||
branches, replay, guided hints and elapsed time.{" "}
|
||||
<strong>Set</strong> edits registry-backed clues and runs bounded
|
||||
setter-quality checks. <strong>Generate</strong> constructs and
|
||||
ranks bounded, seedable single or batch variants.{" "}
|
||||
<strong>Solve</strong> explains logical steps and can verify
|
||||
uniqueness. <strong>Helpers</strong> answers focused sum, candidate
|
||||
and relation questions without changing the board.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
@@ -31,6 +34,18 @@ export function HelpDialog({
|
||||
<dt>Shift + arrows</dt>
|
||||
<dd>Extend the selection</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Home / End</dt>
|
||||
<dd>Move to the first or last cell in the row</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Ctrl/⌘ + Home/End</dt>
|
||||
<dd>Move to the first or last cell in the grid</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Page Up / Down</dt>
|
||||
<dd>Move to the first or last row in the same column</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>1–9 / A–G</dt>
|
||||
<dd>Enter the selected symbol</dd>
|
||||
@@ -47,26 +62,197 @@ export function HelpDialog({
|
||||
<dt>Ctrl/⌘ + Z/Y</dt>
|
||||
<dd>Undo or redo</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Ctrl/⌘ + click</dt>
|
||||
<dd>Highlight every placed copy of that digit</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>M</dt>
|
||||
<dd>Toggle tap-by-tap multi-selection</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Ctrl/⌘ + + / −</dt>
|
||||
<dd>Zoom the board while focus is in the board area</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Ctrl/⌘ + 0</dt>
|
||||
<dd>Fit the board at its default scale</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Touch, zoom and selection</h3>
|
||||
<p>
|
||||
Board zoom is stored in this browser. Use <strong>Pan board</strong>
|
||||
before dragging a zoomed board; its strong border and status message
|
||||
indicate that cell taps are temporarily paused. Stop panning to edit
|
||||
again. <strong>Tap multi-select</strong> toggles individual cells
|
||||
without a drag gesture and keeps one active cell for keyboard entry.
|
||||
On narrow screens the entry pad stays close to the bottom edge and
|
||||
respects the device safe area.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Hints and solutions</h3>
|
||||
<p>
|
||||
Candidate legality is computed independently from handwritten notes.
|
||||
Logical deductions report their premises, affected houses,
|
||||
placements and eliminations. Exact search is separately labelled; it
|
||||
proves feasibility or uniqueness but is not presented as a human
|
||||
explanation.
|
||||
A guided hint reveals where to look, the technique, its reasoning
|
||||
and an effects preview in separate stages; the board changes only
|
||||
after <strong>Apply this step</strong>. Candidate legality is
|
||||
computed independently from handwritten notes. Controls can fill
|
||||
legal centre candidates, prune invalid centre/corner notes and
|
||||
optionally maintain peer notes after placements. Erasing a value
|
||||
never invents candidates.
|
||||
</p>
|
||||
<p>
|
||||
Logical deductions report premises, affected houses, placements and
|
||||
eliminations for singles, subsets, intersections, fish, wings,
|
||||
colouring, chains and Killer cages. Unique Rectangle is disabled
|
||||
unless a completed exact search has already proved uniqueness. Exact
|
||||
search is separately labelled and is not presented as a human
|
||||
explanation; reaching a limit remains unknown.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Setter-quality checks</h3>
|
||||
<p>
|
||||
Quick analysis checks for zero, one or two solutions and can show
|
||||
the cells where two completions differ. Full analysis uses bounded
|
||||
deletion searches to classify givens and constraints as critical,
|
||||
redundant or unknown, build a cell heatmap, or localise a
|
||||
contradictory core. Optional minimality is claimed only when the
|
||||
unique baseline and every required removal check complete. Per-check
|
||||
and aggregate budgets are visible, and analysis can be cancelled
|
||||
without turning a capped search into proof.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Branches, savepoints and replay</h3>
|
||||
<p>
|
||||
Open <strong>History & branches</strong> while playing to name a
|
||||
checkpoint, isolate a hypothesis, or inspect an earlier grid.
|
||||
Discarding a hypothesis restores its exact starting state but keeps
|
||||
the abandoned path available in replay. Replayed grids are read-only
|
||||
until you return live or deliberately branch from that step.
|
||||
Validated history is included in local autosaves and explicit
|
||||
Library saves, so restoring that progress also restores its
|
||||
savepoints and branches.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Sum and Candidate Labs</h3>
|
||||
<p>
|
||||
Sum Lab enumerates bounded combinations and, when selected board
|
||||
cells are enabled, candidate-compatible assignments per position.
|
||||
Required, excluded and manually eliminated combinations remain local
|
||||
helper state. Candidate Lab inspects selected cells and houses,
|
||||
filters one or more digits, and can overlay strong and weak links
|
||||
without changing handwritten notes.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Aid-mémoire scratch cells</h3>
|
||||
<p>
|
||||
Enable the optional aid-mémoire in Play for a non-constraining row
|
||||
or compact grid of scratch cells. Each cell has its own label and
|
||||
accepts values, corner marks, centre marks and colours through the
|
||||
regular keypad or keyboard. Arrow keys stay within the configured
|
||||
grid; Home/End move within a row, Ctrl/⌘ + Home/End reach the first
|
||||
or last cell, and Page Up/Down move to the first or last row. The
|
||||
layout and entries are preserved with local Library progress.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Variant and false clues</h3>
|
||||
<p>
|
||||
The shared registry covers classic/irregular/extra regions;
|
||||
diagonal, anti, disjoint and non-consecutive rules; cages, parity,
|
||||
extrema and quadruples; thermo, arrow, renban, palindrome, between,
|
||||
whisper, region-sum, modular, entropic, zipper and double-arrow
|
||||
lines; Kropki, 5/10 and inequality pairs; X-sum, skyscraper,
|
||||
little-killer and sandwich clues; clone regions and row/column/box
|
||||
indexers.
|
||||
</p>
|
||||
<p>
|
||||
Enable Wrogn mode to make a supported local clue false, or switch
|
||||
existing clues individually or as a batch. Red dashed artwork and a
|
||||
≠ mark identify false clues; global house rules, extra regions and
|
||||
fog are not given a misleading false mode. Fog is display-only and
|
||||
can be set only with a complete solution which validates against the
|
||||
current puzzle. Wrong entries reveal nothing and the trusted
|
||||
solution remains in the local puzzle document.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Generation and ratings</h3>
|
||||
<p>
|
||||
Generation runs in a worker with explicit time and search limits. It
|
||||
supports single or deterministic batch generation, compatible mixed
|
||||
families, sparse/balanced/dense local constraints and several
|
||||
rotational, reflection and diagonal clue symmetries. Minimal-givens
|
||||
mode reports proof or the exact unknown reason; individual
|
||||
minimisation can break the requested visual symmetry and reports
|
||||
that fact.
|
||||
</p>
|
||||
<p>
|
||||
Technique profiles can require, forbid or count techniques and set
|
||||
an exact hardest technique. A result is accepted only when an
|
||||
independent complete logical path matches the whole profile and an
|
||||
independent exact search proves uniqueness. Requested difficulty
|
||||
guides clue removal; the reported 0–100 rating is then calculated
|
||||
from logical techniques, clue load and exact-search evidence. A
|
||||
cancelled or bounded-out candidate remains a failure or unknown,
|
||||
never a uniqueness/minimality claim.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Import and privacy</h3>
|
||||
<p>
|
||||
Files, text, solving and generation stay in this browser. Compact
|
||||
grids, project JSON, share fragments and supported f-puzzles data
|
||||
are decoded locally. SudokuPad short IDs need its server and are
|
||||
intentionally rejected. Review an export before sharing: titles,
|
||||
authors, rules, solutions and progress may be included.
|
||||
grids, project JSON, share fragments, supported f-puzzles,
|
||||
SudokuPad/CTC inline data and supported Penpa+ long links are
|
||||
decoded locally. Server short IDs are intentionally rejected. The
|
||||
compatibility check separates mapped Sudoku semantics, preserved
|
||||
inert drawings, retained metadata and warnings before import. Source
|
||||
identity and allowlisted drawings survive local edits and saves but
|
||||
never become rules by appearance alone.
|
||||
</p>
|
||||
<p>
|
||||
Export includes Sudoku Tools JSON/share data, f-puzzles, SudokuPad
|
||||
JSON or self-contained SCL where representable, plus local SVG, PNG
|
||||
and PDF rendering. Unsupported false/global semantics and visual
|
||||
geometry are rejected rather than silently weakened. Review an
|
||||
export before sharing: titles, authors, rules, solutions, progress,
|
||||
source metadata, drawings and aid-mémoire entries may be included.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Recovery, Library and offline use</h3>
|
||||
<p>
|
||||
A separate debounced local autosave can restore or discard an
|
||||
interrupted session, including validated bounded history, branches
|
||||
and savepoints. Explicit Library projects offer title/tag search,
|
||||
completion filters, safe previews and selected export, duplication
|
||||
or deletion. Browser storage can be cleared independently, so export
|
||||
important projects.
|
||||
</p>
|
||||
<p>
|
||||
On a production HTTPS host, the first successful load can install a
|
||||
relative-scope offline application shell. Service-worker support is
|
||||
progressive enhancement: puzzle work stays local and remains usable
|
||||
when registration is unavailable.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Screen-reader detail</h3>
|
||||
<p>
|
||||
Each visible Sudoku cell reports its row, column, region, value,
|
||||
colour-and-pattern mark, conflict state and touching variant clues.
|
||||
Candidate detail can be set to <strong>Off</strong>, concise counts,
|
||||
or detailed digits without changing what is drawn. The board and
|
||||
aid-mémoire use real row and gridcell roles; their keyboard
|
||||
instructions are attached to the grids. Fogged cells report only
|
||||
that they are obscured: hidden values, notes, clues, candidate
|
||||
overlays and guided-hint steps are not exposed through board labels.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { NormalizedPuzzle } from "../domain";
|
||||
import {
|
||||
analyzeKillerCage,
|
||||
analyzeSumLab,
|
||||
calculateResidual,
|
||||
relationPairs,
|
||||
type CandidateOverlay,
|
||||
type RelationSpec,
|
||||
} from "../helpers";
|
||||
import { maskValues, symbolFor } from "../state/session";
|
||||
import { CandidateLab } from "./CandidateLab";
|
||||
|
||||
function digits(text: string) {
|
||||
return [
|
||||
@@ -25,18 +29,24 @@ function numbers(text: string) {
|
||||
|
||||
export function HelpersWorkspace({
|
||||
size,
|
||||
puzzle,
|
||||
values,
|
||||
selectedCells,
|
||||
candidateMasks,
|
||||
onCandidateOverlayChange,
|
||||
}: {
|
||||
size: number;
|
||||
puzzle: NormalizedPuzzle;
|
||||
values: readonly number[];
|
||||
selectedCells: readonly number[];
|
||||
candidateMasks: readonly number[];
|
||||
onCandidateOverlayChange?: (overlay: CandidateOverlay | undefined) => void;
|
||||
}) {
|
||||
const [helper, setHelper] = useState<"killer" | "residual" | "relations">(
|
||||
"killer",
|
||||
);
|
||||
const [helper, setHelper] = useState<
|
||||
"candidates" | "killer" | "sum" | "residual" | "relations"
|
||||
>("killer");
|
||||
const [cellCount, setCellCount] = useState(2);
|
||||
const [sum, setSum] = useState(10);
|
||||
const [sum, setSum] = useState(Math.min(10, size + 1));
|
||||
const [allowed, setAllowed] = useState("");
|
||||
const [required, setRequired] = useState("");
|
||||
const [excluded, setExcluded] = useState("");
|
||||
@@ -47,6 +57,19 @@ export function HelpersWorkspace({
|
||||
const [relation, setRelation] = useState("white");
|
||||
const [firstCandidates, setFirstCandidates] = useState("");
|
||||
const [secondCandidates, setSecondCandidates] = useState("");
|
||||
const [sumLabCellCount, setSumLabCellCount] = useState(2);
|
||||
const [sumLabTarget, setSumLabTarget] = useState(Math.min(10, size + 1));
|
||||
const [sumLabMinimum, setSumLabMinimum] = useState(1);
|
||||
const [sumLabMaximum, setSumLabMaximum] = useState(size);
|
||||
const [sumLabRequired, setSumLabRequired] = useState("");
|
||||
const [sumLabExcluded, setSumLabExcluded] = useState("");
|
||||
const [sumLabRepeats, setSumLabRepeats] = useState(false);
|
||||
const [sumLabUseCandidates, setSumLabUseCandidates] = useState(false);
|
||||
const [sumLabShowEliminated, setSumLabShowEliminated] = useState(true);
|
||||
const [sumLabEliminations, setSumLabEliminations] = useState<{
|
||||
readonly scope: string;
|
||||
readonly keys: ReadonlySet<string>;
|
||||
}>({ scope: "", keys: new Set() });
|
||||
|
||||
const effectiveCount =
|
||||
useBoardCandidates && selectedCells.length > 0
|
||||
@@ -137,6 +160,84 @@ export function HelpersWorkspace({
|
||||
}
|
||||
}, [firstCandidates, relation, secondCandidates, size]);
|
||||
|
||||
const sumLabCandidateMasks = useMemo(
|
||||
() =>
|
||||
sumLabUseCandidates && selectedCells.length > 0
|
||||
? selectedCells.map((cell) => candidateMasks[cell] ?? 0)
|
||||
: undefined,
|
||||
[candidateMasks, selectedCells, sumLabUseCandidates],
|
||||
);
|
||||
const sumLabEffectiveCount =
|
||||
sumLabCandidateMasks === undefined
|
||||
? sumLabCellCount
|
||||
: sumLabCandidateMasks.length;
|
||||
const sumLabScope = JSON.stringify({
|
||||
count: sumLabEffectiveCount,
|
||||
target: sumLabTarget,
|
||||
minimum: sumLabMinimum,
|
||||
maximum: sumLabMaximum,
|
||||
required: sumLabRequired,
|
||||
excluded: sumLabExcluded,
|
||||
repeats: sumLabRepeats,
|
||||
candidates: sumLabCandidateMasks,
|
||||
});
|
||||
const sumLabEliminatedKeys = useMemo(
|
||||
() =>
|
||||
sumLabEliminations.scope === sumLabScope
|
||||
? sumLabEliminations.keys
|
||||
: new Set<string>(),
|
||||
[sumLabEliminations, sumLabScope],
|
||||
);
|
||||
const sumLab = useMemo(() => {
|
||||
try {
|
||||
return {
|
||||
result: analyzeSumLab({
|
||||
cellCount: sumLabEffectiveCount,
|
||||
target: sumLabTarget,
|
||||
minimumDigit: sumLabMinimum,
|
||||
maximumDigit: sumLabMaximum,
|
||||
allowRepeats: sumLabRepeats,
|
||||
...(sumLabRequired.trim()
|
||||
? { requiredDigits: digits(sumLabRequired) }
|
||||
: {}),
|
||||
...(sumLabExcluded.trim()
|
||||
? { excludedDigits: digits(sumLabExcluded) }
|
||||
: {}),
|
||||
...(sumLabCandidateMasks === undefined
|
||||
? {}
|
||||
: { candidateMasks: sumLabCandidateMasks }),
|
||||
eliminatedKeys: sumLabEliminatedKeys,
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error:
|
||||
error instanceof Error ? error.message : "Invalid Sum Lab input.",
|
||||
};
|
||||
}
|
||||
}, [
|
||||
sumLabCandidateMasks,
|
||||
sumLabEffectiveCount,
|
||||
sumLabExcluded,
|
||||
sumLabMaximum,
|
||||
sumLabMinimum,
|
||||
sumLabEliminatedKeys,
|
||||
sumLabRepeats,
|
||||
sumLabRequired,
|
||||
sumLabTarget,
|
||||
]);
|
||||
|
||||
const toggleSumLabCombination = (key: string) => {
|
||||
setSumLabEliminations((current) => {
|
||||
const keys = new Set(
|
||||
current.scope === sumLabScope ? current.keys : undefined,
|
||||
);
|
||||
if (keys.has(key)) keys.delete(key);
|
||||
else keys.add(key);
|
||||
return { scope: sumLabScope, keys };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="helpers-workspace stack">
|
||||
<div>
|
||||
@@ -151,6 +252,8 @@ export function HelpersWorkspace({
|
||||
{(
|
||||
[
|
||||
["killer", "Killer combinations"],
|
||||
["sum", "Sum Lab"],
|
||||
["candidates", "Candidate links"],
|
||||
["residual", `${String((size * (size + 1)) / 2)}-rule residual`],
|
||||
["relations", "Pair relations"],
|
||||
] as const
|
||||
@@ -168,6 +271,238 @@ export function HelpersWorkspace({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{helper === "candidates" && (
|
||||
<div className="helper-card">
|
||||
<CandidateLab
|
||||
puzzle={puzzle}
|
||||
values={values}
|
||||
candidateMasks={candidateMasks}
|
||||
selectedCells={selectedCells}
|
||||
onOverlayChange={onCandidateOverlayChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{helper === "sum" && (
|
||||
<section className="helper-card">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h3>Generalized Sum Lab</h3>
|
||||
<p className="muted">
|
||||
Explore sums with custom ranges and repeats. Select board cells
|
||||
first to filter cell orderings through their live candidates.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-button"
|
||||
disabled={sumLabEliminatedKeys.size === 0}
|
||||
onClick={() =>
|
||||
setSumLabEliminations({
|
||||
scope: sumLabScope,
|
||||
keys: new Set(),
|
||||
})
|
||||
}
|
||||
>
|
||||
Restore all
|
||||
</button>
|
||||
</div>
|
||||
<div className="helper-controls sum-lab-controls">
|
||||
<label>
|
||||
Cell count
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="25"
|
||||
value={sumLabEffectiveCount}
|
||||
disabled={sumLabCandidateMasks !== undefined}
|
||||
onChange={(event) =>
|
||||
setSumLabCellCount(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Target sum
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max={sumLabMaximum * sumLabEffectiveCount}
|
||||
value={sumLabTarget}
|
||||
onChange={(event) =>
|
||||
setSumLabTarget(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Minimum digit
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={size}
|
||||
value={sumLabMinimum}
|
||||
onChange={(event) =>
|
||||
setSumLabMinimum(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Maximum digit
|
||||
<input
|
||||
type="number"
|
||||
min={sumLabMinimum}
|
||||
max={size}
|
||||
value={sumLabMaximum}
|
||||
onChange={(event) =>
|
||||
setSumLabMaximum(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Must include
|
||||
<input
|
||||
value={sumLabRequired}
|
||||
placeholder="e.g. 1, 7"
|
||||
onChange={(event) => setSumLabRequired(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Exclude
|
||||
<input
|
||||
value={sumLabExcluded}
|
||||
placeholder="e.g. 5"
|
||||
onChange={(event) => setSumLabExcluded(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sumLabRepeats}
|
||||
onChange={(event) => setSumLabRepeats(event.target.checked)}
|
||||
/>
|
||||
Allow repeated digits
|
||||
</label>
|
||||
<label className="check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sumLabUseCandidates}
|
||||
disabled={selectedCells.length === 0}
|
||||
onChange={(event) =>
|
||||
setSumLabUseCandidates(event.target.checked)
|
||||
}
|
||||
/>
|
||||
Use {selectedCells.length || "selected"} board cell
|
||||
{selectedCells.length === 1 ? "" : "s"} and candidates
|
||||
</label>
|
||||
<label className="check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sumLabShowEliminated}
|
||||
onChange={(event) =>
|
||||
setSumLabShowEliminated(event.target.checked)
|
||||
}
|
||||
/>
|
||||
Show eliminated combinations
|
||||
</label>
|
||||
</div>
|
||||
{sumLab.error ? (
|
||||
<p className="error-callout" role="alert">
|
||||
{sumLab.error}
|
||||
</p>
|
||||
) : sumLab.result ? (
|
||||
<div className="helper-result">
|
||||
<div className="metric-row">
|
||||
<span>
|
||||
<strong>{sumLab.result.activeCombinations.length}</strong>{" "}
|
||||
active of {sumLab.result.combinations.length}
|
||||
</span>
|
||||
<span>
|
||||
Possible{" "}
|
||||
<strong>
|
||||
{sumLab.result.possibleDigits
|
||||
.map((value) => symbolFor(value, size))
|
||||
.join(" ") || "none"}
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
Necessary{" "}
|
||||
<strong>
|
||||
{sumLab.result.necessaryDigits
|
||||
.map((value) => symbolFor(value, size))
|
||||
.join(" ") || "none"}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
{sumLabCandidateMasks !== undefined && (
|
||||
<ol className="cell-possibilities">
|
||||
{sumLab.result.possibleByCell.map((entry, index) => {
|
||||
const cell = selectedCells[index];
|
||||
const label =
|
||||
cell === undefined
|
||||
? `Cell ${String(index + 1)}`
|
||||
: `r${String(Math.floor(cell / size) + 1)}c${String((cell % size) + 1)}`;
|
||||
return (
|
||||
<li key={cell ?? index}>
|
||||
{label}:{" "}
|
||||
{entry
|
||||
.map((value) => symbolFor(value, size))
|
||||
.join(" ") || "—"}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
<p className="muted sum-lab-guidance">
|
||||
Select a combination to eliminate it; select it again to restore
|
||||
it. Eliminated combinations are excluded from the summaries
|
||||
above.
|
||||
</p>
|
||||
<div
|
||||
className="combination-cloud sum-combination-cloud"
|
||||
aria-label="Sum combinations"
|
||||
>
|
||||
{sumLab.result.combinations
|
||||
.filter(
|
||||
(combination) =>
|
||||
sumLabShowEliminated || !combination.eliminated,
|
||||
)
|
||||
.slice(0, 500)
|
||||
.map((combination) => (
|
||||
<button
|
||||
key={combination.key}
|
||||
type="button"
|
||||
className={`sum-combination${combination.eliminated ? " is-eliminated" : ""}`}
|
||||
aria-pressed={combination.eliminated}
|
||||
aria-label={`${combination.digits
|
||||
.map((value) => symbolFor(value, size))
|
||||
.join(
|
||||
" + ",
|
||||
)}; ${combination.eliminated ? "restore" : "eliminate"}`}
|
||||
onClick={() => toggleSumLabCombination(combination.key)}
|
||||
>
|
||||
{combination.digits
|
||||
.map((value) => symbolFor(value, size))
|
||||
.join(" + ")}
|
||||
</button>
|
||||
))}
|
||||
{sumLab.result.combinations.length === 0 && (
|
||||
<span className="muted">No compatible combinations.</span>
|
||||
)}
|
||||
</div>
|
||||
{(sumLab.result.combinations.length > 500 ||
|
||||
sumLab.result.truncated) && (
|
||||
<p className="callout">
|
||||
{sumLab.result.truncated
|
||||
? `Analysis reached its ${sumLab.result.truncationReason ?? "safety"} limit. `
|
||||
: ""}
|
||||
The visible list is bounded; narrow the range or add filters
|
||||
before making deductions from an incomplete result.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{helper === "killer" && (
|
||||
<section className="helper-card">
|
||||
<div className="helper-controls">
|
||||
@@ -362,12 +697,16 @@ export function HelpersWorkspace({
|
||||
value={relation}
|
||||
onChange={(event) => setRelation(event.target.value)}
|
||||
>
|
||||
<option value="white">White Kropki · difference 1</option>
|
||||
<option value="black">Black Kropki · ratio 1:2</option>
|
||||
<option value="v">V · sum 5</option>
|
||||
<option value="x">X · sum 10</option>
|
||||
<option value="less">First < second</option>
|
||||
<option value="greater">First > second</option>
|
||||
<option value="white">White dot · difference 1</option>
|
||||
<option value="black">Black dot · ratio 1:2</option>
|
||||
<option value="v">Sum = 5 · XV rule</option>
|
||||
<option value="x">Sum = 10 · XV rule</option>
|
||||
<option value="less">
|
||||
First cell < second cell · inequality
|
||||
</option>
|
||||
<option value="greater">
|
||||
First cell > second cell · inequality
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
|
||||
@@ -1,49 +1,38 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import type { PuzzleDefinition } from "../domain/types";
|
||||
import { normalizePuzzle } from "../domain/validation";
|
||||
import {
|
||||
decodePuzzleHash,
|
||||
encodePuzzleHash,
|
||||
exportSudokuPadJson,
|
||||
exportSudokuPadPayload,
|
||||
exportFpuzzlesJson,
|
||||
exportFpuzzlesUrl,
|
||||
extractPreservedDocumentExtras,
|
||||
fromDomainPuzzle,
|
||||
importFpuzzles,
|
||||
parseFpuzzles,
|
||||
parsePlainGrid,
|
||||
parseSudokuDocument,
|
||||
importPuzzle,
|
||||
renderPuzzlePdf,
|
||||
renderPuzzlePng,
|
||||
renderPuzzleSvg,
|
||||
serializePlainGrid,
|
||||
serializeSudokuDocument,
|
||||
toDomainPuzzle,
|
||||
type PreservedSudokuDocumentExtras,
|
||||
type PuzzleImportMappingPreview,
|
||||
type PuzzleImportResult,
|
||||
type SudokuDocument,
|
||||
} from "../formats";
|
||||
import type { PlaySession } from "../state/session";
|
||||
import { maskValues } from "../state/session";
|
||||
import type { PortableAidMemoire } from "../state/aidMemoire";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
function parseImport(input: string): SudokuDocument {
|
||||
const trimmed = input.trim();
|
||||
if (trimmed.startsWith("#sudoku=") || trimmed.includes("#sudoku="))
|
||||
return decodePuzzleHash(trimmed);
|
||||
if (
|
||||
/^(?:https?:\/\/)?(?:www\.)?(?:f-puzzles\.com|sudokupad\.app)\//iu.test(
|
||||
trimmed,
|
||||
)
|
||||
)
|
||||
return importFpuzzles(trimmed);
|
||||
if (trimmed.startsWith("fpuzzles")) return importFpuzzles(trimmed);
|
||||
if (trimmed.startsWith("{")) {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (typeof parsed === "object" && parsed !== null && "schema" in parsed)
|
||||
return parseSudokuDocument(trimmed);
|
||||
return parseFpuzzles(parsed);
|
||||
}
|
||||
return parsePlainGrid(trimmed);
|
||||
}
|
||||
|
||||
function withProgress(
|
||||
puzzle: PuzzleDefinition,
|
||||
session: PlaySession,
|
||||
aidMemoire?: PortableAidMemoire,
|
||||
preservedExtras?: PreservedSudokuDocumentExtras,
|
||||
): SudokuDocument {
|
||||
const base = fromDomainPuzzle(puzzle);
|
||||
const base = fromDomainPuzzle(puzzle, preservedExtras);
|
||||
return {
|
||||
...base,
|
||||
values: [...session.values],
|
||||
@@ -55,22 +44,40 @@ function withProgress(
|
||||
),
|
||||
colors: [...session.colors],
|
||||
elapsedMs: session.elapsedSeconds * 1_000,
|
||||
...(aidMemoire === undefined ? {} : { aidMemoire }),
|
||||
};
|
||||
}
|
||||
|
||||
function download(name: string, contents: string, type: string) {
|
||||
const url = URL.createObjectURL(new Blob([contents], { type }));
|
||||
function downloadBlob(name: string, blob: Blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = name;
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
interface ImportExportDialogProps {
|
||||
function download(name: string, contents: string, type: string) {
|
||||
downloadBlob(name, new Blob([contents], { type }));
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
function checkedPuzzle(document: SudokuDocument): PuzzleDefinition {
|
||||
return normalizePuzzle(toDomainPuzzle(document) as PuzzleDefinition);
|
||||
}
|
||||
|
||||
export interface ImportExportDialogProps {
|
||||
open: boolean;
|
||||
puzzle: PuzzleDefinition;
|
||||
session: PlaySession;
|
||||
aidMemoire?: PortableAidMemoire;
|
||||
/** Source-only data retained while the domain puzzle is edited. */
|
||||
preservedExtras?: PreservedSudokuDocumentExtras;
|
||||
onClose: () => void;
|
||||
onImport: (
|
||||
puzzle: PuzzleDefinition,
|
||||
@@ -82,27 +89,61 @@ interface ImportExportDialogProps {
|
||||
| "candidates"
|
||||
| "colors"
|
||||
| "elapsedMs"
|
||||
| "aidMemoire"
|
||||
>,
|
||||
preservedExtras?: PreservedSudokuDocumentExtras,
|
||||
) => void;
|
||||
}
|
||||
|
||||
function PreviewList({
|
||||
title,
|
||||
entries,
|
||||
}: {
|
||||
readonly title: string;
|
||||
readonly entries: PuzzleImportMappingPreview["mappedSemantics"];
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h4>{title}</h4>
|
||||
{entries.length === 0 ? (
|
||||
<p className="muted">None.</p>
|
||||
) : (
|
||||
<ul>
|
||||
{entries.map((entry) => (
|
||||
<li key={entry.key}>
|
||||
{entry.label}: {entry.count}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImportExportDialog({
|
||||
open,
|
||||
puzzle,
|
||||
session,
|
||||
aidMemoire,
|
||||
preservedExtras,
|
||||
onClose,
|
||||
onImport,
|
||||
}: ImportExportDialogProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [includeProgress, setIncludeProgress] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [inspected, setInspected] = useState<{
|
||||
readonly input: string;
|
||||
readonly result: PuzzleImportResult<SudokuDocument>;
|
||||
}>();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const documentValue = useMemo(
|
||||
() =>
|
||||
includeProgress
|
||||
? withProgress(puzzle, session)
|
||||
: fromDomainPuzzle(puzzle),
|
||||
[includeProgress, puzzle, session],
|
||||
? withProgress(puzzle, session, aidMemoire, preservedExtras)
|
||||
: fromDomainPuzzle(puzzle, preservedExtras),
|
||||
[aidMemoire, includeProgress, preservedExtras, puzzle, session],
|
||||
);
|
||||
|
||||
const copy = async (value: string, label: string) => {
|
||||
@@ -116,18 +157,128 @@ export function ImportExportDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const copyExport = async (render: () => string, label: string) => {
|
||||
let value: string;
|
||||
try {
|
||||
value = render();
|
||||
} catch (error) {
|
||||
setFeedback(errorMessage(error, `${label} could not be created.`));
|
||||
return;
|
||||
}
|
||||
await copy(value, label);
|
||||
};
|
||||
|
||||
const exportText = (
|
||||
name: string,
|
||||
type: string,
|
||||
label: string,
|
||||
render: () => string,
|
||||
) => {
|
||||
try {
|
||||
download(name, render(), type);
|
||||
setFeedback(`${label} downloaded locally.`);
|
||||
} catch (error) {
|
||||
setFeedback(errorMessage(error, `${label} export failed.`));
|
||||
}
|
||||
};
|
||||
|
||||
const inspectImport = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await importPuzzle(input);
|
||||
checkedPuzzle(result.document);
|
||||
setInspected({ input, result });
|
||||
const givenCount = result.document.givens.filter(
|
||||
(value) => value !== 0,
|
||||
).length;
|
||||
setFeedback(
|
||||
`${result.label}: ${String(result.document.size)}×${String(result.document.size)}, ${String(givenCount)} givens and ${String(result.document.constraints.length)} constraints. Compatible and ready to import.`,
|
||||
);
|
||||
} catch (error) {
|
||||
setInspected(undefined);
|
||||
setFeedback(errorMessage(error, "The puzzle could not be inspected."));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyImport = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result =
|
||||
inspected?.input === input
|
||||
? inspected.result
|
||||
: await importPuzzle(input);
|
||||
const parsed = result.document;
|
||||
onImport(
|
||||
checkedPuzzle(parsed),
|
||||
{
|
||||
values: parsed.values,
|
||||
cornerMarks: parsed.cornerMarks,
|
||||
centerMarks: parsed.centerMarks,
|
||||
candidates: parsed.candidates,
|
||||
colors: parsed.colors,
|
||||
elapsedMs: parsed.elapsedMs,
|
||||
aidMemoire: parsed.aidMemoire,
|
||||
},
|
||||
extractPreservedDocumentExtras(parsed),
|
||||
);
|
||||
setFeedback(`${result.label} imported locally.`);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setFeedback(errorMessage(error, "The puzzle could not be imported."));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportVisual = async (format: "svg" | "png" | "pdf") => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const options = {
|
||||
includeProgress,
|
||||
includeNotes: includeProgress,
|
||||
};
|
||||
if (format === "svg") {
|
||||
download(
|
||||
"sudoku.svg",
|
||||
renderPuzzleSvg(documentValue, options),
|
||||
"image/svg+xml;charset=utf-8",
|
||||
);
|
||||
} else if (format === "png") {
|
||||
downloadBlob(
|
||||
"sudoku.png",
|
||||
await renderPuzzlePng(documentValue, options),
|
||||
);
|
||||
} else {
|
||||
downloadBlob(
|
||||
"sudoku.pdf",
|
||||
await renderPuzzlePdf(documentValue, options),
|
||||
);
|
||||
}
|
||||
setFeedback(`${format.toUpperCase()} rendered and downloaded locally.`);
|
||||
} catch (error) {
|
||||
setFeedback(
|
||||
errorMessage(error, `The ${format.toUpperCase()} export failed.`),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} title="Import and export" onClose={onClose} wide>
|
||||
<div className="import-export-grid">
|
||||
<div className="import-export-grid" aria-busy={busy}>
|
||||
<section className="stack">
|
||||
<div>
|
||||
<p className="eyebrow">Import</p>
|
||||
<h3>Paste puzzle data</h3>
|
||||
</div>
|
||||
<p className="muted">
|
||||
Accepts a plain grid, Sudoku Tools JSON/share link, raw f-puzzles
|
||||
JSON, or a self-contained f-puzzles/SudokuPad link. Server-only
|
||||
short IDs are never fetched.
|
||||
Accepts a plain grid, Sudoku Tools JSON/share link, f-puzzles or
|
||||
SudokuPad/CTC data, and supported Penpa+ long links. Everything is
|
||||
decoded locally. Server-only short IDs are never fetched.
|
||||
Unsupported constructs stop the import instead of being discarded.
|
||||
</p>
|
||||
<textarea
|
||||
rows={12}
|
||||
@@ -135,7 +286,11 @@ export function ImportExportDialog({
|
||||
maxLength={1_048_576}
|
||||
spellCheck={false}
|
||||
placeholder="Paste 81 characters, JSON or a puzzle URL…"
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setInput(event.target.value);
|
||||
setInspected(undefined);
|
||||
setFeedback("");
|
||||
}}
|
||||
/>
|
||||
<div className="action-row">
|
||||
<button type="button" onClick={() => fileRef.current?.click()}>
|
||||
@@ -145,7 +300,7 @@ export function ImportExportDialog({
|
||||
ref={fileRef}
|
||||
className="sr-only"
|
||||
type="file"
|
||||
accept="application/json,text/plain,.json,.txt,.sdk"
|
||||
accept="application/json,text/plain,.json,.txt,.sdk,.scl,.ctc,.penpa"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
@@ -158,6 +313,7 @@ export function ImportExportDialog({
|
||||
.text()
|
||||
.then((contents) => {
|
||||
setInput(contents);
|
||||
setInspected(undefined);
|
||||
setFeedback(
|
||||
`${file.name} loaded locally; review and import it.`,
|
||||
);
|
||||
@@ -169,32 +325,54 @@ export function ImportExportDialog({
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!input.trim()}
|
||||
onClick={() => {
|
||||
try {
|
||||
const parsed = parseImport(input);
|
||||
onImport(toDomainPuzzle(parsed) as PuzzleDefinition, {
|
||||
values: parsed.values,
|
||||
cornerMarks: parsed.cornerMarks,
|
||||
centerMarks: parsed.centerMarks,
|
||||
candidates: parsed.candidates,
|
||||
colors: parsed.colors,
|
||||
elapsedMs: parsed.elapsedMs,
|
||||
});
|
||||
setFeedback("Puzzle imported locally.");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setFeedback(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "The puzzle could not be imported.",
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={busy || !input.trim()}
|
||||
onClick={() => void inspectImport()}
|
||||
>
|
||||
Check compatibility
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !input.trim()}
|
||||
onClick={() => void applyImport()}
|
||||
>
|
||||
Import locally
|
||||
</button>
|
||||
</div>
|
||||
{inspected?.input === input && (
|
||||
<section
|
||||
className="import-mapping-preview stack"
|
||||
aria-label="Import mapping preview"
|
||||
>
|
||||
<div>
|
||||
<p className="eyebrow">Mapping preview</p>
|
||||
<h3>{inspected.result.label}</h3>
|
||||
</div>
|
||||
<PreviewList
|
||||
title="Mapped semantics"
|
||||
entries={inspected.result.preview.mappedSemantics}
|
||||
/>
|
||||
<PreviewList
|
||||
title="Preserved visuals"
|
||||
entries={inspected.result.preview.preservedVisuals}
|
||||
/>
|
||||
<PreviewList
|
||||
title="Preserved metadata"
|
||||
entries={inspected.result.preview.preservedMetadata}
|
||||
/>
|
||||
<div>
|
||||
<h4>Warnings</h4>
|
||||
{inspected.result.preview.warnings.length === 0 ? (
|
||||
<p className="muted">None.</p>
|
||||
) : (
|
||||
<ul>
|
||||
{inspected.result.preview.warnings.map((warning) => (
|
||||
<li key={warning}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="stack">
|
||||
@@ -213,11 +391,13 @@ export function ImportExportDialog({
|
||||
<div className="export-actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
download(
|
||||
exportText(
|
||||
"sudoku-tools-puzzle.json",
|
||||
serializeSudokuDocument(documentValue, true),
|
||||
"application/json",
|
||||
"Project JSON",
|
||||
() => serializeSudokuDocument(documentValue, true),
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -225,14 +405,13 @@ export function ImportExportDialog({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
download(
|
||||
"sudoku.txt",
|
||||
exportText("sudoku.txt", "text/plain", "Grid text", () =>
|
||||
serializePlainGrid(
|
||||
documentValue,
|
||||
includeProgress ? "values" : "givens",
|
||||
),
|
||||
"text/plain",
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -240,11 +419,13 @@ export function ImportExportDialog({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
download(
|
||||
exportText(
|
||||
"sudoku.fpuzzles.json",
|
||||
exportFpuzzlesJson(documentValue, true),
|
||||
"application/json",
|
||||
"f-puzzles JSON",
|
||||
() => exportFpuzzlesJson(documentValue, true),
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -252,21 +433,90 @@ export function ImportExportDialog({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void copy(exportFpuzzlesUrl(documentValue), "f-puzzles URL")
|
||||
void copyExport(
|
||||
() => exportFpuzzlesUrl(documentValue),
|
||||
"f-puzzles URL",
|
||||
)
|
||||
}
|
||||
>
|
||||
Copy f-puzzles URL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
exportText(
|
||||
"sudoku.scl.json",
|
||||
"application/json",
|
||||
"SudokuPad JSON",
|
||||
() => exportSudokuPadJson(documentValue, true),
|
||||
)
|
||||
}
|
||||
>
|
||||
Download SudokuPad JSON
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
exportText(
|
||||
"sudoku.scl",
|
||||
"text/plain",
|
||||
"SudokuPad SCL payload",
|
||||
() => exportSudokuPadPayload(documentValue),
|
||||
)
|
||||
}
|
||||
>
|
||||
Download SudokuPad SCL payload
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void copyExport(
|
||||
() => exportSudokuPadPayload(documentValue),
|
||||
"SudokuPad SCL payload",
|
||||
)
|
||||
}
|
||||
>
|
||||
Copy SudokuPad SCL payload
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
const share = `${location.href.split("#", 1)[0]}${encodePuzzleHash(documentValue)}`;
|
||||
void copy(share, "Local share URL");
|
||||
void copyExport(
|
||||
() =>
|
||||
`${location.href.split("#", 1)[0]}${encodePuzzleHash(documentValue)}`,
|
||||
"Local share URL",
|
||||
);
|
||||
}}
|
||||
>
|
||||
Copy local share URL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void exportVisual("svg")}
|
||||
>
|
||||
Download SVG
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void exportVisual("png")}
|
||||
>
|
||||
Download PNG
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void exportVisual("pdf")}
|
||||
>
|
||||
Download PDF
|
||||
</button>
|
||||
</div>
|
||||
<p className="callout">
|
||||
Share links are self-contained. They may expose title, setter,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import type { SudokuProjectSummary } from "../storage";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
@@ -9,6 +9,26 @@ function date(value: number) {
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function LibraryThumbnail({ item }: { item: SudokuProjectSummary }) {
|
||||
return (
|
||||
<span
|
||||
className="library-thumbnail"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${String(item.size)}, 1fr)`,
|
||||
fontSize: `${String(Math.max(0.18, Math.min(0.62, 2.7 / item.size)))}rem`,
|
||||
}}
|
||||
role="img"
|
||||
aria-label={`${String(item.size)} by ${String(item.size)} puzzle preview`}
|
||||
>
|
||||
{[...item.thumbnail].map((symbol, index) => (
|
||||
<span key={index} aria-hidden="true">
|
||||
{symbol === "." ? "" : symbol}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function LibraryDialog({
|
||||
open,
|
||||
summaries,
|
||||
@@ -21,6 +41,10 @@ export function LibraryDialog({
|
||||
onDelete,
|
||||
onClear,
|
||||
onExport,
|
||||
onExportSelected,
|
||||
onDuplicateSelected,
|
||||
onDeleteSelected,
|
||||
onUpdateTags,
|
||||
onImport,
|
||||
}: {
|
||||
open: boolean;
|
||||
@@ -34,16 +58,73 @@ export function LibraryDialog({
|
||||
onDelete: (id: string) => void;
|
||||
onClear: () => void;
|
||||
onExport: () => void;
|
||||
onExportSelected: (ids: readonly string[]) => void;
|
||||
onDuplicateSelected: (ids: readonly string[]) => void;
|
||||
onDeleteSelected: (ids: readonly string[]) => void;
|
||||
onUpdateTags: (id: string, tags: readonly string[]) => void;
|
||||
onImport: (file: File) => void;
|
||||
}) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [completion, setCompletion] = useState<"all" | "open" | "complete">(
|
||||
"all",
|
||||
);
|
||||
const [tag, setTag] = useState("");
|
||||
const [selected, setSelected] = useState<ReadonlySet<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [editingTagsFor, setEditingTagsFor] = useState<string>();
|
||||
const [tagDraft, setTagDraft] = useState("");
|
||||
|
||||
const allTags = useMemo(
|
||||
() =>
|
||||
[...new Set(summaries.flatMap((item) => item.tags))].sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
),
|
||||
[summaries],
|
||||
);
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLocaleLowerCase();
|
||||
return summaries.filter((item) => {
|
||||
if (
|
||||
query &&
|
||||
!item.title.toLocaleLowerCase().includes(query) &&
|
||||
!item.tags.some((value) => value.toLocaleLowerCase().includes(query))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (tag && !item.tags.includes(tag)) return false;
|
||||
return (
|
||||
completion === "all" ||
|
||||
(completion === "complete" ? item.completed : !item.completed)
|
||||
);
|
||||
});
|
||||
}, [completion, search, summaries, tag]);
|
||||
const knownIds = useMemo(
|
||||
() => new Set(summaries.map((item) => item.id)),
|
||||
[summaries],
|
||||
);
|
||||
const selectedIds = useMemo(
|
||||
() => [...selected].filter((id) => knownIds.has(id)),
|
||||
[knownIds, selected],
|
||||
);
|
||||
|
||||
const toggleSelected = (id: string) => {
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} title="Local puzzle library" onClose={onClose} wide>
|
||||
<div className="library-toolbar">
|
||||
<div>
|
||||
<p>
|
||||
{mode === "indexeddb"
|
||||
? "Saved in this browser profile."
|
||||
? "Saved in this browser profile. Working changes are autosaved separately."
|
||||
: "IndexedDB is unavailable; saves last only for this open tab."}
|
||||
</p>
|
||||
<p className="muted">
|
||||
@@ -81,38 +162,206 @@ export function LibraryDialog({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="library-filters" aria-label="Filter saved puzzles">
|
||||
<label>
|
||||
<span>Search</span>
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
placeholder="Title or tag"
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Status</span>
|
||||
<select
|
||||
aria-label="Completion filter"
|
||||
value={completion}
|
||||
onChange={(event) =>
|
||||
setCompletion(event.target.value as typeof completion)
|
||||
}
|
||||
>
|
||||
<option value="all">All progress</option>
|
||||
<option value="open">In progress</option>
|
||||
<option value="complete">Complete</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Tag</span>
|
||||
<select value={tag} onChange={(event) => setTag(event.target.value)}>
|
||||
<option value="">All tags</option>
|
||||
{allTags.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{feedback && (
|
||||
<p className="status-line" role="status">
|
||||
{feedback}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{summaries.length > 0 && (
|
||||
<div className="library-selection-toolbar">
|
||||
<span>
|
||||
{String(selectedIds.length)} selected · {String(filtered.length)}{" "}
|
||||
shown
|
||||
</span>
|
||||
<div className="action-row">
|
||||
<button
|
||||
type="button"
|
||||
className="text-button"
|
||||
disabled={busy || filtered.length === 0}
|
||||
onClick={() =>
|
||||
setSelected(new Set(filtered.map((item) => item.id)))
|
||||
}
|
||||
>
|
||||
Select shown
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-button"
|
||||
disabled={busy || selectedIds.length === 0}
|
||||
onClick={() => setSelected(new Set())}
|
||||
>
|
||||
Clear selection
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || selectedIds.length === 0}
|
||||
onClick={() => onExportSelected(selectedIds)}
|
||||
>
|
||||
Export selected
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || selectedIds.length === 0}
|
||||
onClick={() => onDuplicateSelected(selectedIds)}
|
||||
>
|
||||
Duplicate selected
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
disabled={busy || selectedIds.length === 0}
|
||||
onClick={() => onDeleteSelected(selectedIds)}
|
||||
>
|
||||
Delete selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summaries.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No saved puzzles</h3>
|
||||
<p>Save the current puzzle to build a local library.</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No matching puzzles</h3>
|
||||
<p>Adjust the title, tag or progress filters.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="library-list">
|
||||
{summaries.map((item) => (
|
||||
{filtered.map((item) => (
|
||||
<li key={item.id}>
|
||||
<label className="library-selector">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(item.id)}
|
||||
aria-label={`Select ${item.title || "Untitled puzzle"}`}
|
||||
onChange={() => toggleSelected(item.id)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="library-open"
|
||||
type="button"
|
||||
onClick={() => onOpen(item.id)}
|
||||
>
|
||||
<strong>{item.title || "Untitled puzzle"}</strong>
|
||||
<span>
|
||||
{item.size}×{item.size} · {date(item.updatedAt)}
|
||||
{item.completed ? " · complete" : ""}
|
||||
<LibraryThumbnail item={item} />
|
||||
<span className="library-copy">
|
||||
<strong>{item.title || "Untitled puzzle"}</strong>
|
||||
<span>
|
||||
{item.size}×{item.size} · {date(item.updatedAt)}
|
||||
{item.completed ? " · complete" : " · in progress"}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className="text-button danger"
|
||||
type="button"
|
||||
onClick={() => onDelete(item.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<div className="library-item-actions">
|
||||
<div className="library-tags" aria-label="Project tags">
|
||||
{item.tags.map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className="tag-chip"
|
||||
onClick={() => setTag(value)}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{editingTagsFor === item.id ? (
|
||||
<form
|
||||
className="library-tag-editor"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onUpdateTags(
|
||||
item.id,
|
||||
tagDraft
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
setEditingTagsFor(undefined);
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
<span className="sr-only">Comma-separated tags</span>
|
||||
<input
|
||||
aria-label={`Tags for ${item.title || "Untitled puzzle"}`}
|
||||
value={tagDraft}
|
||||
maxLength={400}
|
||||
placeholder="classic, hard"
|
||||
onChange={(event) => setTagDraft(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={busy}>
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-button"
|
||||
onClick={() => setEditingTagsFor(undefined)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
className="text-button"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingTagsFor(item.id);
|
||||
setTagDraft(item.tags.join(", "));
|
||||
}}
|
||||
>
|
||||
Edit tags
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="text-button danger"
|
||||
type="button"
|
||||
onClick={() => onDelete(item.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { EntryMode } from "../state/session";
|
||||
import { symbolFor } from "../state/session";
|
||||
import { colorMarkDescription } from "../state/uiPreferences";
|
||||
|
||||
const modes: Array<{ mode: EntryMode; label: string; key: string }> = [
|
||||
{ mode: "value", label: "Value", key: "Z" },
|
||||
@@ -56,7 +57,9 @@ export function NumberPad({
|
||||
{mode === "color" ? (
|
||||
<>
|
||||
<span aria-hidden="true" />{" "}
|
||||
<span className="sr-only">Colour {value}</span>
|
||||
<span className="sr-only">
|
||||
Colour {value}: {colorMarkDescription(value)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
symbolFor(value, size)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { SVGAttributes } from "react";
|
||||
import type {
|
||||
SafeVisualAnchor,
|
||||
SafeVisualPrimitive,
|
||||
SafeVisualStyle,
|
||||
} from "../formats";
|
||||
|
||||
export interface SafeVisualLayerProps {
|
||||
readonly size: number;
|
||||
readonly visuals: readonly SafeVisualPrimitive[];
|
||||
readonly layer: "underlay" | "overlay";
|
||||
}
|
||||
|
||||
function anchorPoint(size: number, anchor: SafeVisualAnchor) {
|
||||
if (anchor.kind === "coordinate") {
|
||||
return { x: anchor.x, y: anchor.y };
|
||||
}
|
||||
return {
|
||||
x: (anchor.cell % size) + 0.5 + (anchor.offsetX ?? 0),
|
||||
y: Math.floor(anchor.cell / size) + 0.5 + (anchor.offsetY ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function visualStyle(
|
||||
style: SafeVisualStyle | undefined,
|
||||
): SVGAttributes<SVGElement> {
|
||||
return {
|
||||
stroke: style?.stroke ?? "transparent",
|
||||
fill: style?.fill ?? "transparent",
|
||||
...(style?.strokeWidth === undefined
|
||||
? {}
|
||||
: { strokeWidth: style.strokeWidth }),
|
||||
...(style?.opacity === undefined ? {} : { opacity: style.opacity }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render already-normalized inert drawings with typed SVG properties only.
|
||||
* Text is supplied as a React text node; no source markup is ever interpreted.
|
||||
*/
|
||||
export function SafeVisualLayer({
|
||||
size,
|
||||
visuals,
|
||||
layer,
|
||||
}: SafeVisualLayerProps) {
|
||||
return (
|
||||
<g
|
||||
className={`safe-visual-layer safe-visual-layer--${layer}`}
|
||||
data-visual-layer={layer}
|
||||
>
|
||||
{visuals.map((visual, index) => {
|
||||
if (visual.layer !== layer) return null;
|
||||
const common = {
|
||||
...visualStyle(visual.style),
|
||||
className: `source-visual source-visual--${visual.type}`,
|
||||
"data-visual": index,
|
||||
};
|
||||
if (visual.type === "line") {
|
||||
const start = anchorPoint(size, visual.start);
|
||||
const end = anchorPoint(size, visual.end);
|
||||
return (
|
||||
<line
|
||||
key={index}
|
||||
{...common}
|
||||
x1={start.x}
|
||||
y1={start.y}
|
||||
x2={end.x}
|
||||
y2={end.y}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (visual.type === "polyline") {
|
||||
const points = visual.points
|
||||
.map((anchor) => anchorPoint(size, anchor))
|
||||
.map(({ x, y }) => `${String(x)},${String(y)}`)
|
||||
.join(" ");
|
||||
return visual.closed === true ? (
|
||||
<polygon key={index} {...common} points={points} />
|
||||
) : (
|
||||
<polyline key={index} {...common} points={points} />
|
||||
);
|
||||
}
|
||||
if (visual.type === "rectangle") {
|
||||
const center = anchorPoint(size, visual.center);
|
||||
return (
|
||||
<rect
|
||||
key={index}
|
||||
{...common}
|
||||
x={center.x - visual.width / 2}
|
||||
y={center.y - visual.height / 2}
|
||||
width={visual.width}
|
||||
height={visual.height}
|
||||
rx={visual.cornerRadius}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (visual.type === "ellipse") {
|
||||
const center = anchorPoint(size, visual.center);
|
||||
return (
|
||||
<ellipse
|
||||
key={index}
|
||||
{...common}
|
||||
cx={center.x}
|
||||
cy={center.y}
|
||||
rx={visual.radiusX}
|
||||
ry={visual.radiusY}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (visual.type === "circle") {
|
||||
const center = anchorPoint(size, visual.center);
|
||||
return (
|
||||
<circle
|
||||
key={index}
|
||||
{...common}
|
||||
cx={center.x}
|
||||
cy={center.y}
|
||||
r={visual.radius}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const position = anchorPoint(size, visual.position);
|
||||
return (
|
||||
<text
|
||||
key={index}
|
||||
{...common}
|
||||
x={position.x}
|
||||
y={position.y}
|
||||
fontSize={visual.style?.fontSize}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
>
|
||||
{visual.text}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,740 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import type {
|
||||
PuzzleQualityAnalysis,
|
||||
QualityItemAssessment,
|
||||
QualityItemReference,
|
||||
analyzePuzzleQuality,
|
||||
} from "../solver/quality";
|
||||
import { symbolFor } from "../state/session";
|
||||
|
||||
export type SetterQualityRunOptions = NonNullable<
|
||||
Parameters<typeof analyzePuzzleQuality>[1]
|
||||
>;
|
||||
|
||||
export type SetterQualityRunKind = "quick" | "minimality";
|
||||
|
||||
export interface SetterQualityLabProps {
|
||||
readonly size: number;
|
||||
readonly result?: PuzzleQualityAnalysis;
|
||||
readonly running?: SetterQualityRunKind;
|
||||
readonly error?: string;
|
||||
readonly initialOptions?: Partial<SetterQualityRunOptions>;
|
||||
readonly onRunQuick: (options: SetterQualityRunOptions) => void;
|
||||
readonly onRunMinimality: (options: SetterQualityRunOptions) => void;
|
||||
readonly onCancel: () => void;
|
||||
readonly onFocusCells: (cells: readonly number[]) => void;
|
||||
readonly onFocusItem: (item: QualityItemReference) => void;
|
||||
}
|
||||
|
||||
interface QualityControls {
|
||||
readonly perCheckMaxNodes: number;
|
||||
readonly perCheckTimeoutMs: number;
|
||||
readonly aggregateMaxChecks: number;
|
||||
readonly aggregateMaxNodes: number;
|
||||
readonly aggregateTimeoutMs: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONTROLS: QualityControls = {
|
||||
perCheckMaxNodes: 2_000_000,
|
||||
perCheckTimeoutMs: 10_000,
|
||||
aggregateMaxChecks: 1_000,
|
||||
aggregateMaxNodes: 20_000_000,
|
||||
aggregateTimeoutMs: 30_000,
|
||||
};
|
||||
|
||||
const CONTROL_MAXIMUMS: Record<keyof QualityControls, number> = {
|
||||
perCheckMaxNodes: 100_000_000,
|
||||
perCheckTimeoutMs: 120_000,
|
||||
aggregateMaxChecks: 20_000,
|
||||
aggregateMaxNodes: 2_000_000_000,
|
||||
aggregateTimeoutMs: 600_000,
|
||||
};
|
||||
|
||||
function boundedPositiveInteger(
|
||||
value: string,
|
||||
fallback: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed > 0
|
||||
? Math.min(parsed, maximum)
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function humanize(value: string): string {
|
||||
return value
|
||||
.replaceAll(/([a-z])([A-Z])/gu, "$1 $2")
|
||||
.replaceAll("-", " ")
|
||||
.replace(/^./u, (first) => first.toUpperCase());
|
||||
}
|
||||
|
||||
function cellName(cell: number, size: number): string {
|
||||
return `r${String(Math.floor(cell / size) + 1)}c${String((cell % size) + 1)}`;
|
||||
}
|
||||
|
||||
function itemLabel(item: QualityItemReference, size: number): string {
|
||||
if (item.kind === "given") {
|
||||
return `Given ${symbolFor(item.value, size)} at ${cellName(item.cell, size)}`;
|
||||
}
|
||||
return `Constraint ${String(item.index + 1)} · ${humanize(item.constraintType)}`;
|
||||
}
|
||||
|
||||
function itemKey(item: QualityItemReference): string {
|
||||
return item.kind === "given"
|
||||
? `given-${String(item.cell)}`
|
||||
: `constraint-${String(item.index)}`;
|
||||
}
|
||||
|
||||
function ItemList({
|
||||
title,
|
||||
items,
|
||||
size,
|
||||
emptyLabel,
|
||||
onFocusItem,
|
||||
}: {
|
||||
title: string;
|
||||
items: readonly QualityItemReference[];
|
||||
size: number;
|
||||
emptyLabel: string;
|
||||
onFocusItem: (item: QualityItemReference) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="setter-quality__item-group">
|
||||
<h5>{title}</h5>
|
||||
{items.length === 0 ? (
|
||||
<p className="muted">{emptyLabel}</p>
|
||||
) : (
|
||||
<ul className="setter-quality__item-list">
|
||||
{items.map((item) => (
|
||||
<li key={itemKey(item)}>
|
||||
<button type="button" onClick={() => onFocusItem(item)}>
|
||||
{itemLabel(item, size)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AssessmentTable({
|
||||
title,
|
||||
assessments,
|
||||
size,
|
||||
onFocusItem,
|
||||
}: {
|
||||
title: string;
|
||||
assessments: readonly QualityItemAssessment[];
|
||||
size: number;
|
||||
onFocusItem: (item: QualityItemReference) => void;
|
||||
}) {
|
||||
const counts = {
|
||||
critical: assessments.filter(
|
||||
({ classification }) => classification === "critical",
|
||||
).length,
|
||||
redundant: assessments.filter(
|
||||
({ classification }) => classification === "redundant",
|
||||
).length,
|
||||
unknown: assessments.filter(
|
||||
({ classification }) => classification === "unknown",
|
||||
).length,
|
||||
};
|
||||
return (
|
||||
<section className="setter-quality__assessments">
|
||||
<div className="section-heading">
|
||||
<h4>{title}</h4>
|
||||
<p className="setter-quality__counts muted">
|
||||
{counts.critical} critical · {counts.redundant} redundant ·{" "}
|
||||
{counts.unknown} unknown
|
||||
</p>
|
||||
</div>
|
||||
{assessments.length === 0 ? (
|
||||
<p className="muted">
|
||||
No {title.toLowerCase()} were available to test.
|
||||
</p>
|
||||
) : (
|
||||
<div className="setter-quality__table-wrap">
|
||||
<table className="setter-quality__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Clue</th>
|
||||
<th scope="col">Finding</th>
|
||||
<th scope="col">Evidence</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{assessments.map((assessment) => (
|
||||
<tr
|
||||
key={itemKey(assessment.item)}
|
||||
className={`is-${assessment.classification}`}
|
||||
>
|
||||
<th scope="row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFocusItem(assessment.item)}
|
||||
>
|
||||
{itemLabel(assessment.item, size)}
|
||||
</button>
|
||||
</th>
|
||||
<td>
|
||||
<strong>{humanize(assessment.classification)}</strong>
|
||||
</td>
|
||||
<td>
|
||||
{assessment.classification === "unknown"
|
||||
? `Unknown / incomplete${assessment.unknownReason ? `: ${humanize(assessment.unknownReason)}` : ""}`
|
||||
: assessment.solutionStatus
|
||||
? `Without clue: ${humanize(assessment.solutionStatus)}`
|
||||
: "Completed bounded check"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function solutionTitle(
|
||||
status: PuzzleQualityAnalysis["solutionStatus"],
|
||||
): string {
|
||||
if (status === "unique") return "Unique completion proven";
|
||||
if (status === "multiple") return "Multiple completions found";
|
||||
if (status === "unsatisfiable") return "No completion exists";
|
||||
return "Solution status unknown";
|
||||
}
|
||||
|
||||
function solutionDescription(
|
||||
status: PuzzleQualityAnalysis["solutionStatus"],
|
||||
): string {
|
||||
if (status === "unique")
|
||||
return "The bounded exact check completed and proved one solution.";
|
||||
if (status === "multiple")
|
||||
return "Two different completions are enough to prove ambiguity.";
|
||||
if (status === "unsatisfiable")
|
||||
return "The bounded exact check completed without finding any valid completion.";
|
||||
return "Incomplete / unknown: a configured safety bound was reached before the solution status could be proved.";
|
||||
}
|
||||
|
||||
function QualityResults({
|
||||
size,
|
||||
result,
|
||||
onFocusCells,
|
||||
onFocusItem,
|
||||
}: {
|
||||
size: number;
|
||||
result: PuzzleQualityAnalysis;
|
||||
onFocusCells: (cells: readonly number[]) => void;
|
||||
onFocusItem: (item: QualityItemReference) => void;
|
||||
}) {
|
||||
const witness = result.ambiguityWitness;
|
||||
const minimality = result.minimality;
|
||||
return (
|
||||
<div className="setter-quality__results stack">
|
||||
<section className="setter-quality__solution analysis-summary">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Solution status</p>
|
||||
<h3>{solutionTitle(result.solutionStatus)}</h3>
|
||||
</div>
|
||||
<span
|
||||
className={`status-pill status-${result.solutionStatus}`}
|
||||
aria-label={`Solution status: ${humanize(result.solutionStatus)}`}
|
||||
>
|
||||
{humanize(result.solutionStatus)}
|
||||
</span>
|
||||
</div>
|
||||
<p>{solutionDescription(result.solutionStatus)}</p>
|
||||
</section>
|
||||
|
||||
{witness !== undefined && (
|
||||
<section
|
||||
className="setter-quality__witness panel-section"
|
||||
aria-labelledby="quality-witness-title"
|
||||
>
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Ambiguity witness</p>
|
||||
<h4 id="quality-witness-title">
|
||||
{witness.differences.length} differing cell
|
||||
{witness.differences.length === 1 ? "" : "s"}
|
||||
</h4>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={witness.differences.length === 0}
|
||||
onClick={() =>
|
||||
onFocusCells(witness.differences.map(({ cell }) => cell))
|
||||
}
|
||||
>
|
||||
Focus differences
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted">
|
||||
These values come from two concrete valid completions; they are not
|
||||
guesses.
|
||||
</p>
|
||||
<div className="setter-quality__table-wrap">
|
||||
<table className="setter-quality__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Cell</th>
|
||||
<th scope="col">Solution A</th>
|
||||
<th scope="col">Solution B</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{witness.differences.map(({ cell, first, second }) => (
|
||||
<tr key={cell}>
|
||||
<th scope="row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFocusCells([cell])}
|
||||
>
|
||||
{cellName(cell, size)}
|
||||
</button>
|
||||
</th>
|
||||
<td>{symbolFor(first, size)}</td>
|
||||
<td>{symbolFor(second, size)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section
|
||||
className={`setter-quality__contradiction panel-section status-${result.contradiction.status}`}
|
||||
aria-labelledby="quality-contradiction-title"
|
||||
>
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Contradiction localization</p>
|
||||
<h4 id="quality-contradiction-title">
|
||||
{result.contradiction.status === "localized"
|
||||
? "Contradiction suspects localized"
|
||||
: result.contradiction.status === "incomplete"
|
||||
? "Incomplete / unknown"
|
||||
: "Not applicable"}
|
||||
</h4>
|
||||
</div>
|
||||
<span className="status-pill">
|
||||
{humanize(result.contradiction.status)}
|
||||
</span>
|
||||
</div>
|
||||
{result.contradiction.reason && <p>{result.contradiction.reason}</p>}
|
||||
{result.contradiction.status === "not-applicable" && (
|
||||
<p className="muted">
|
||||
Contradiction localization only applies after unsatisfiability is
|
||||
proved.
|
||||
</p>
|
||||
)}
|
||||
<div className="setter-quality__contradiction-groups">
|
||||
<ItemList
|
||||
title="Retained suspect core"
|
||||
items={result.contradiction.core}
|
||||
size={size}
|
||||
emptyLabel="No retained suspects were established."
|
||||
onFocusItem={onFocusItem}
|
||||
/>
|
||||
<ItemList
|
||||
title="Proven necessary suspects"
|
||||
items={result.contradiction.necessary}
|
||||
size={size}
|
||||
emptyLabel="No individual suspect was proved necessary."
|
||||
onFocusItem={onFocusItem}
|
||||
/>
|
||||
<ItemList
|
||||
title="Removable while still contradictory"
|
||||
items={result.contradiction.removable}
|
||||
size={size}
|
||||
emptyLabel="No removable suspects were established."
|
||||
onFocusItem={onFocusItem}
|
||||
/>
|
||||
<ItemList
|
||||
title="Unknown at safety bounds"
|
||||
items={result.contradiction.unknown}
|
||||
size={size}
|
||||
emptyLabel="No unresolved suspects."
|
||||
onFocusItem={onFocusItem}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="setter-quality__redundancy panel-section">
|
||||
<p className="eyebrow">Leave-one-out evidence</p>
|
||||
<h3>Clue criticality and redundancy</h3>
|
||||
<p className="muted">
|
||||
Critical means removing that clue admits another completion. Redundant
|
||||
means uniqueness was proved without it. Unknown always remains unknown
|
||||
when a bound interrupts proof.
|
||||
</p>
|
||||
{result.analysisDepth === "baseline" ? (
|
||||
<p className="setter-quality__not-analysed">
|
||||
Not analysed in the quick baseline. Run the full bounded minimality
|
||||
analysis to classify individual clues.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<AssessmentTable
|
||||
title="Givens"
|
||||
assessments={result.redundancy.givens}
|
||||
size={size}
|
||||
onFocusItem={onFocusItem}
|
||||
/>
|
||||
<AssessmentTable
|
||||
title="Constraints"
|
||||
assessments={result.redundancy.constraints}
|
||||
size={size}
|
||||
onFocusItem={onFocusItem}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="setter-quality__heatmap panel-section"
|
||||
aria-labelledby="quality-heatmap-title"
|
||||
>
|
||||
<p className="eyebrow">Criticality heatmap</p>
|
||||
<h3 id="quality-heatmap-title">Cell evidence</h3>
|
||||
<p className="muted" id="quality-heatmap-help">
|
||||
Every button states its evidence, so colour is never the only signal.
|
||||
Select a cell to focus it on the puzzle.
|
||||
</p>
|
||||
{result.criticalityHeatmap.length === 0 ? (
|
||||
<p>
|
||||
{result.analysisDepth === "baseline"
|
||||
? "Not analysed in the quick baseline."
|
||||
: "No cell-level evidence is available."}
|
||||
</p>
|
||||
) : (
|
||||
<div
|
||||
className="setter-quality__heatmap-grid"
|
||||
role="group"
|
||||
aria-label="Cell criticality heatmap"
|
||||
aria-describedby="quality-heatmap-help"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${String(size)}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{result.criticalityHeatmap.map((entry) => {
|
||||
const evidenceClass =
|
||||
entry.score === null
|
||||
? "unknown"
|
||||
: entry.criticalWeight > entry.redundantWeight
|
||||
? "critical"
|
||||
: entry.redundantWeight > entry.criticalWeight
|
||||
? "redundant"
|
||||
: entry.criticalWeight + entry.redundantWeight > 0
|
||||
? "mixed"
|
||||
: "neutral";
|
||||
const score =
|
||||
entry.score === null ? "unknown" : entry.score.toFixed(2);
|
||||
const label = `${cellName(entry.cell, size)}: ${evidenceClass}; score ${score}; critical weight ${String(entry.criticalWeight)}, redundant weight ${String(entry.redundantWeight)}, unknown weight ${String(entry.unknownWeight)}`;
|
||||
return (
|
||||
<button
|
||||
key={entry.cell}
|
||||
type="button"
|
||||
className={`setter-quality__heat-cell is-${evidenceClass}`}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={() => onFocusCells([entry.cell])}
|
||||
>
|
||||
<span>{cellName(entry.cell, size)}</span>
|
||||
<strong>
|
||||
{entry.score === null ? "?" : entry.score.toFixed(1)}
|
||||
</strong>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="setter-quality__minimality panel-section">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Bounded minimality</p>
|
||||
<h3>
|
||||
{minimality === undefined
|
||||
? "Not requested"
|
||||
: humanize(minimality.status)}
|
||||
</h3>
|
||||
</div>
|
||||
<span className="status-pill">
|
||||
{minimality === undefined ? "Not run" : humanize(minimality.status)}
|
||||
</span>
|
||||
</div>
|
||||
{minimality === undefined ? (
|
||||
<p>
|
||||
Minimality is unknown because the full bounded minimality pass was
|
||||
not requested.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{minimality.reason && <p>{minimality.reason}</p>}
|
||||
<ItemList
|
||||
title="Redundant clues"
|
||||
items={minimality.redundant}
|
||||
size={size}
|
||||
emptyLabel="No redundant clue was proved."
|
||||
onFocusItem={onFocusItem}
|
||||
/>
|
||||
<ItemList
|
||||
title="Unknown clues"
|
||||
items={minimality.unknown}
|
||||
size={size}
|
||||
emptyLabel="No clue remained unknown."
|
||||
onFocusItem={onFocusItem}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="setter-quality__metrics panel-section">
|
||||
<p className="eyebrow">Bounded-search record</p>
|
||||
<h3>Checks and limits</h3>
|
||||
<dl className="setter-quality__metric-list">
|
||||
<div>
|
||||
<dt>Analysis depth</dt>
|
||||
<dd>
|
||||
{result.analysisDepth === "baseline"
|
||||
? "Quick baseline"
|
||||
: "Full setter QC"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Analysis completeness</dt>
|
||||
<dd>
|
||||
{result.budget.truncated
|
||||
? "Incomplete / unknown conclusions remain"
|
||||
: "All requested checks completed"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Checks performed / planned</dt>
|
||||
<dd>
|
||||
{result.budget.checksPerformed.toLocaleString()} /{" "}
|
||||
{result.budget.checksPlanned.toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Recorded check details</dt>
|
||||
<dd>{result.checks.length.toLocaleString()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Nodes searched</dt>
|
||||
<dd>{result.budget.nodes.toLocaleString()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Elapsed</dt>
|
||||
<dd>{result.budget.elapsedMs.toLocaleString()} ms</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Unknown reasons</dt>
|
||||
<dd>
|
||||
{result.budget.unknownReasons.length === 0
|
||||
? "None"
|
||||
: result.budget.unknownReasons.map(humanize).join(", ")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Per-check node / time bound</dt>
|
||||
<dd>
|
||||
{result.bounds.perCheck.maxNodes.toLocaleString()} nodes /{" "}
|
||||
{result.bounds.perCheck.timeoutMs.toLocaleString()} ms
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Aggregate check / node / time bound</dt>
|
||||
<dd>
|
||||
{result.bounds.aggregate.maxChecks.toLocaleString()} checks /{" "}
|
||||
{result.bounds.aggregate.maxNodes.toLocaleString()} nodes /{" "}
|
||||
{result.bounds.aggregate.timeoutMs.toLocaleString()} ms
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SetterQualityLab({
|
||||
size,
|
||||
result,
|
||||
running,
|
||||
error,
|
||||
initialOptions,
|
||||
onRunQuick,
|
||||
onRunMinimality,
|
||||
onCancel,
|
||||
onFocusCells,
|
||||
onFocusItem,
|
||||
}: SetterQualityLabProps) {
|
||||
const [controls, setControls] = useState<QualityControls>(() => ({
|
||||
perCheckMaxNodes:
|
||||
initialOptions?.perCheckMaxNodes ?? DEFAULT_CONTROLS.perCheckMaxNodes,
|
||||
perCheckTimeoutMs:
|
||||
initialOptions?.perCheckTimeoutMs ?? DEFAULT_CONTROLS.perCheckTimeoutMs,
|
||||
aggregateMaxChecks:
|
||||
initialOptions?.aggregateMaxChecks ?? DEFAULT_CONTROLS.aggregateMaxChecks,
|
||||
aggregateMaxNodes:
|
||||
initialOptions?.aggregateMaxNodes ?? DEFAULT_CONTROLS.aggregateMaxNodes,
|
||||
aggregateTimeoutMs:
|
||||
initialOptions?.aggregateTimeoutMs ?? DEFAULT_CONTROLS.aggregateTimeoutMs,
|
||||
}));
|
||||
|
||||
const options = (
|
||||
analysisDepth: "baseline" | "full",
|
||||
proveMinimality: boolean,
|
||||
): SetterQualityRunOptions => ({
|
||||
...controls,
|
||||
analysisDepth,
|
||||
proveMinimality,
|
||||
});
|
||||
const runQuick = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
onRunQuick(options("baseline", false));
|
||||
};
|
||||
const setControl = (key: keyof QualityControls, value: string) => {
|
||||
setControls((current) => ({
|
||||
...current,
|
||||
[key]: boundedPositiveInteger(value, current[key], CONTROL_MAXIMUMS[key]),
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className="setter-quality stack"
|
||||
aria-labelledby="setter-quality-title"
|
||||
>
|
||||
<div>
|
||||
<p className="eyebrow">Setter quality</p>
|
||||
<h2 id="setter-quality-title">Uniqueness, redundancy and minimality</h2>
|
||||
<p className="muted">
|
||||
Every claim is backed by a bounded local exact-search check. Reaching
|
||||
a limit produces an explicit unknown result, never a guessed verdict.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="setter-quality__controls panel-section"
|
||||
onSubmit={runQuick}
|
||||
>
|
||||
<div className="field-grid">
|
||||
<label>
|
||||
Nodes per check
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100000000"
|
||||
value={controls.perCheckMaxNodes}
|
||||
disabled={running !== undefined}
|
||||
onChange={(event) =>
|
||||
setControl("perCheckMaxNodes", event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Time per check (ms)
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="120000"
|
||||
value={controls.perCheckTimeoutMs}
|
||||
disabled={running !== undefined}
|
||||
onChange={(event) =>
|
||||
setControl("perCheckTimeoutMs", event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Maximum checks
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="20000"
|
||||
value={controls.aggregateMaxChecks}
|
||||
disabled={running !== undefined}
|
||||
onChange={(event) =>
|
||||
setControl("aggregateMaxChecks", event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Total node budget
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="2000000000"
|
||||
value={controls.aggregateMaxNodes}
|
||||
disabled={running !== undefined}
|
||||
onChange={(event) =>
|
||||
setControl("aggregateMaxNodes", event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Total time budget (ms)
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="600000"
|
||||
value={controls.aggregateTimeoutMs}
|
||||
disabled={running !== undefined}
|
||||
onChange={(event) =>
|
||||
setControl("aggregateTimeoutMs", event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="setter-quality__actions action-row">
|
||||
<button type="submit" disabled={running !== undefined}>
|
||||
Run quick quality check
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={running !== undefined}
|
||||
onClick={() => onRunMinimality(options("full", true))}
|
||||
>
|
||||
Run full bounded minimality
|
||||
</button>
|
||||
{running !== undefined && (
|
||||
<button type="button" onClick={onCancel}>
|
||||
Cancel analysis
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{running !== undefined && (
|
||||
<p
|
||||
className="setter-quality__running status-line"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{running === "quick"
|
||||
? "Quick quality check running locally…"
|
||||
: "Full bounded minimality analysis running locally…"}
|
||||
</p>
|
||||
)}
|
||||
{error !== undefined && (
|
||||
<p className="setter-quality__error error-callout" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{result !== undefined && (
|
||||
<QualityResults
|
||||
size={size}
|
||||
result={result}
|
||||
onFocusCells={onFocusCells}
|
||||
onFocusItem={onFocusItem}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -141,13 +141,15 @@ export function SolveWorkspace({
|
||||
</span>
|
||||
</div>
|
||||
<p>
|
||||
{exact.count === 0
|
||||
? "No completion satisfies every supported rule."
|
||||
: exact.count === 1 && !exact.truncated
|
||||
? "The current puzzle has exactly one solution within the configured search bounds."
|
||||
: exact.count >= 2
|
||||
? "At least two solutions exist; the puzzle is not unique."
|
||||
: "Search stopped at a safety limit, so uniqueness is not established."}
|
||||
{exact.count >= 2
|
||||
? "At least two solutions exist; the puzzle is not unique."
|
||||
: exact.truncated
|
||||
? exact.count === 0
|
||||
? "Search stopped at a safety limit before finding a solution, so solvability is not established."
|
||||
: "Search stopped at a safety limit, so uniqueness is not established."
|
||||
: exact.count === 0
|
||||
? "No completion satisfies every supported rule."
|
||||
: "The current puzzle has exactly one solution."}
|
||||
</p>
|
||||
{exact.solutions[0] && (
|
||||
<button
|
||||
|
||||
+1246
-72
File diff suppressed because it is too large
Load Diff
+1680
-175
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
import type { KillerCageConstraint, VariantConstraint } from "../domain/types";
|
||||
|
||||
function touchesAnyCell(
|
||||
constraint: KillerCageConstraint,
|
||||
cells: ReadonlySet<number>,
|
||||
): boolean {
|
||||
return constraint.cells.some((cell) => cells.has(cell));
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter cages form a partition: drawing a cage replaces every existing cage
|
||||
* that touches its cells instead of leaving hidden, overlapping sum rules.
|
||||
*/
|
||||
export function replaceOverlappingKillerCages(
|
||||
constraints: readonly VariantConstraint[],
|
||||
cage: KillerCageConstraint,
|
||||
): VariantConstraint[] {
|
||||
const cells = new Set(cage.cells);
|
||||
return [
|
||||
...constraints.filter(
|
||||
(constraint) =>
|
||||
constraint.type !== "killer-cage" || !touchesAnyCell(constraint, cells),
|
||||
),
|
||||
{ ...cage, cells: [...cage.cells] },
|
||||
];
|
||||
}
|
||||
|
||||
/** Remove every cage touching at least one selected cell. */
|
||||
export function removeKillerCagesAtCells(
|
||||
constraints: readonly VariantConstraint[],
|
||||
cells: readonly number[],
|
||||
): VariantConstraint[] {
|
||||
if (cells.length === 0) return [...constraints];
|
||||
const selected = new Set(cells);
|
||||
return constraints.filter(
|
||||
(constraint) =>
|
||||
constraint.type !== "killer-cage" ||
|
||||
!touchesAnyCell(constraint, selected),
|
||||
);
|
||||
}
|
||||
|
||||
export function selectionTouchesKillerCage(
|
||||
constraints: readonly VariantConstraint[],
|
||||
cells: readonly number[],
|
||||
): boolean {
|
||||
if (cells.length === 0) return false;
|
||||
const selected = new Set(cells);
|
||||
return constraints.some(
|
||||
(constraint) =>
|
||||
constraint.type === "killer-cage" && touchesAnyCell(constraint, selected),
|
||||
);
|
||||
}
|
||||
@@ -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(" ");
|
||||
}
|
||||
+304
-4
@@ -1,12 +1,23 @@
|
||||
import type { PuzzleDefinition } from "../domain/types";
|
||||
|
||||
const classicGrid =
|
||||
"460000001000300476009060050804000607000854000102000805030040900928001000500000013";
|
||||
function grid(...rows: readonly string[]): number[] {
|
||||
return [...rows.join("")].map(Number);
|
||||
}
|
||||
|
||||
export const CLASSIC_SAMPLE: PuzzleDefinition = {
|
||||
version: 1,
|
||||
size: 9,
|
||||
givens: [...classicGrid].map(Number),
|
||||
givens: grid(
|
||||
"460000001",
|
||||
"000300476",
|
||||
"009060050",
|
||||
"804000607",
|
||||
"000854000",
|
||||
"102000805",
|
||||
"030040900",
|
||||
"928001000",
|
||||
"500000013",
|
||||
),
|
||||
title: "A first classic",
|
||||
author: "Sudoku Tools",
|
||||
rules: "Place 1–9 exactly once in every row, column and outlined 3×3 region.",
|
||||
@@ -32,4 +43,293 @@ export const KILLER_SAMPLE: PuzzleDefinition = {
|
||||
],
|
||||
};
|
||||
|
||||
export const SAMPLE_PUZZLES = [CLASSIC_SAMPLE, KILLER_SAMPLE] as const;
|
||||
export const DIAGONAL_SAMPLE: PuzzleDefinition = {
|
||||
version: 1,
|
||||
size: 9,
|
||||
givens: grid(
|
||||
"080000037",
|
||||
"013090020",
|
||||
"207305860",
|
||||
"408003016",
|
||||
"700601008",
|
||||
"130900702",
|
||||
"021509604",
|
||||
"060030950",
|
||||
"570000080",
|
||||
),
|
||||
constraints: [
|
||||
{ type: "diagonal", direction: "main" },
|
||||
{ type: "diagonal", direction: "anti" },
|
||||
],
|
||||
title: "Crossing paths",
|
||||
author: "Sudoku Tools",
|
||||
rules:
|
||||
"Place 1–9 once in every row, column, region and on both marked long diagonals.",
|
||||
solution: grid(
|
||||
"985426137",
|
||||
"613897425",
|
||||
"247315869",
|
||||
"498273516",
|
||||
"752641398",
|
||||
"136958742",
|
||||
"321589674",
|
||||
"864732951",
|
||||
"579164283",
|
||||
),
|
||||
};
|
||||
|
||||
export const THERMO_SAMPLE: PuzzleDefinition = {
|
||||
version: 1,
|
||||
size: 6,
|
||||
givens: grid("310240", "245300", "100003", "400002", "004621", "021034"),
|
||||
constraints: [
|
||||
{ type: "thermo", cells: [3, 9, 15, 16] },
|
||||
{ type: "thermo", cells: [6, 7, 13] },
|
||||
{ type: "thermo", cells: [12, 13, 19] },
|
||||
{ type: "thermo", cells: [29, 23, 22] },
|
||||
],
|
||||
title: "Warm fronts",
|
||||
author: "Sudoku Tools",
|
||||
rules:
|
||||
"Place 1–6 once in every row, column and region. Digits increase strictly from each thermometer bulb to its tip.",
|
||||
solution: grid("316245", "245316", "152463", "463152", "534621", "621534"),
|
||||
};
|
||||
|
||||
export const ARROW_SAMPLE: PuzzleDefinition = {
|
||||
version: 1,
|
||||
size: 6,
|
||||
givens: grid("201045", "450103", "500200", "002001", "603012", "120304"),
|
||||
constraints: [
|
||||
{ type: "arrow", bulb: [19], line: [14, 20] },
|
||||
{ type: "arrow", bulb: [7], line: [6, 13] },
|
||||
{ type: "arrow", bulb: [24], line: [25, 20] },
|
||||
{ type: "arrow", bulb: [6], line: [13, 18] },
|
||||
],
|
||||
title: "Follow the sum",
|
||||
author: "Sudoku Tools",
|
||||
rules:
|
||||
"Place 1–6 once in every row, column and region. Digits on each arrow line sum to the digit in its bulb.",
|
||||
solution: grid("231645", "456123", "514236", "362451", "643512", "125364"),
|
||||
};
|
||||
|
||||
export const KROPKI_SAMPLE: PuzzleDefinition = {
|
||||
version: 1,
|
||||
size: 6,
|
||||
givens: grid("250001", "134500", "360400", "002013", "005236", "600054"),
|
||||
constraints: [
|
||||
{ type: "kropki", a: 14, b: 20, kind: "black" },
|
||||
{ type: "kropki", a: 27, b: 28, kind: "white" },
|
||||
{ type: "kropki", a: 5, b: 11, kind: "white" },
|
||||
{ type: "kropki", a: 0, b: 6, kind: "black" },
|
||||
],
|
||||
title: "Black and white",
|
||||
author: "Sudoku Tools",
|
||||
rules:
|
||||
"Place 1–6 once in every row, column and region. White dots mark consecutive digits and black dots mark a 1:2 ratio. Unmarked pairs have no extra rule.",
|
||||
solution: grid("256341", "134562", "361425", "542613", "415236", "623154"),
|
||||
};
|
||||
|
||||
export const XV_SAMPLE: PuzzleDefinition = {
|
||||
version: 1,
|
||||
size: 6,
|
||||
givens: grid("153000", "420000", "201645", "564302", "000026", "000453"),
|
||||
constraints: [
|
||||
{ type: "xv", a: 4, b: 5, total: 10 },
|
||||
{ type: "xv", a: 14, b: 20, total: 5 },
|
||||
{ type: "xv", a: 19, b: 25, total: 10 },
|
||||
{ type: "xv", a: 12, b: 13, total: 5 },
|
||||
],
|
||||
title: "Five or ten",
|
||||
author: "Sudoku Tools",
|
||||
rules:
|
||||
"Place 1–6 once in every row, column and region. Numbered pair badges show a required sum of 5 or 10. Unmarked pairs have no extra rule.",
|
||||
solution: grid("153264", "426531", "231645", "564312", "345126", "612453"),
|
||||
};
|
||||
|
||||
export const INEQUALITY_SAMPLE: PuzzleDefinition = {
|
||||
version: 1,
|
||||
size: 6,
|
||||
givens: grid("152300", "340105", "520000", "000032", "605043", "004651"),
|
||||
constraints: [
|
||||
{ type: "inequality", lesser: 9, greater: 15 },
|
||||
{ type: "inequality", lesser: 5, greater: 4 },
|
||||
{ type: "inequality", lesser: 18, greater: 12 },
|
||||
{ type: "inequality", lesser: 9, greater: 3 },
|
||||
{ type: "inequality", lesser: 23, greater: 29 },
|
||||
{ type: "inequality", lesser: 13, greater: 14 },
|
||||
{ type: "inequality", lesser: 29, greater: 28 },
|
||||
{ type: "inequality", lesser: 22, greater: 28 },
|
||||
],
|
||||
title: "Lesser and greater",
|
||||
author: "Sudoku Tools",
|
||||
rules:
|
||||
"Place 1–6 once in every row, column and region. Each inequality opens toward the greater digit.",
|
||||
solution: grid("152364", "346125", "523416", "461532", "615243", "234651"),
|
||||
};
|
||||
|
||||
export const RENBAN_SAMPLE: PuzzleDefinition = {
|
||||
version: 1,
|
||||
size: 6,
|
||||
givens: grid("104230", "002060", "050312", "213050", "040500", "025103"),
|
||||
constraints: [
|
||||
{ type: "renban", cells: [9, 8, 7, 13] },
|
||||
{ type: "renban", cells: [20, 21, 27, 28, 34] },
|
||||
{ type: "renban", cells: [9, 15, 16, 17] },
|
||||
{ type: "renban", cells: [34, 35, 29, 28, 22] },
|
||||
],
|
||||
title: "Consecutive company",
|
||||
author: "Sudoku Tools",
|
||||
rules:
|
||||
"Place 1–6 once in every row, column and region. Each renban line contains a distinct consecutive set in any order.",
|
||||
solution: grid("164235", "532461", "456312", "213654", "341526", "625143"),
|
||||
};
|
||||
|
||||
export const PALINDROME_SAMPLE: PuzzleDefinition = {
|
||||
version: 1,
|
||||
size: 6,
|
||||
givens: grid("062501", "540060", "005023", "420600", "050034", "104250"),
|
||||
constraints: [
|
||||
{ type: "palindrome", cells: [4, 18] },
|
||||
{ type: "palindrome", cells: [10, 26] },
|
||||
{ type: "palindrome", cells: [27, 30] },
|
||||
{ type: "palindrome", cells: [24, 33] },
|
||||
],
|
||||
title: "Mirror lines",
|
||||
author: "Sudoku Tools",
|
||||
rules:
|
||||
"Place 1–6 once in every row, column and region. Digits on every palindrome line read the same from either end.",
|
||||
solution: grid("362541", "541362", "615423", "423615", "256134", "134256"),
|
||||
};
|
||||
|
||||
export const ANTI_KNIGHT_SAMPLE: PuzzleDefinition = {
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: grid("0124", "4003", "2001", "1340"),
|
||||
constraints: [{ type: "anti-knight" }],
|
||||
title: "No knight's move",
|
||||
author: "Sudoku Tools",
|
||||
rules:
|
||||
"Place 1–4 once in every row, column and region. Equal digits may not be a chess knight's move apart.",
|
||||
solution: grid("3124", "4213", "2431", "1342"),
|
||||
};
|
||||
|
||||
export const ANTI_KING_SAMPLE: PuzzleDefinition = {
|
||||
version: 1,
|
||||
size: 6,
|
||||
givens: grid("010034", "604051", "526000", "000562", "360405", "450020"),
|
||||
constraints: [{ type: "anti-king" }],
|
||||
title: "Kings keep apart",
|
||||
author: "Sudoku Tools",
|
||||
rules:
|
||||
"Place 1–6 once in every row, column and region. Equal digits may not touch diagonally, like kings in chess.",
|
||||
solution: grid("215634", "634251", "526143", "143562", "362415", "451326"),
|
||||
};
|
||||
|
||||
export const NON_CONSECUTIVE_SAMPLE: PuzzleDefinition = {
|
||||
version: 1,
|
||||
size: 6,
|
||||
givens: grid("010264", "602030", "060053", "530020", "050602", "426010"),
|
||||
constraints: [{ type: "non-consecutive" }],
|
||||
title: "Mind the gap",
|
||||
author: "Sudoku Tools",
|
||||
rules:
|
||||
"Place 1–6 once in every row, column and region. Orthogonally adjacent digits may not be consecutive.",
|
||||
solution: grid("315264", "642531", "264153", "531426", "153642", "426315"),
|
||||
};
|
||||
|
||||
/** Original compact fixture demonstrating outside, inclusion, maximum and liar clues. */
|
||||
export const TRUTH_AND_LIES_SAMPLE: PuzzleDefinition = {
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: grid("0234", "3012", "4301", "2140"),
|
||||
title: "Truth and lies",
|
||||
author: "Sudoku Tools",
|
||||
rules:
|
||||
"Normal 4×4 Sudoku rules apply. Red dashed clues marked ≠ must be false; all other clues are true.",
|
||||
solution: grid("1234", "3412", "4321", "2143"),
|
||||
constraints: [
|
||||
{ type: "x-sum", side: "left", index: 0, sum: 1 },
|
||||
{ type: "skyscraper", side: "left", index: 1, count: 2 },
|
||||
{ type: "quadruple", cells: [0, 1, 4, 5], digits: [1, 2, 3, 4] },
|
||||
{ type: "maximum", cell: 5 },
|
||||
{
|
||||
type: "killer-cage",
|
||||
cells: [8, 9],
|
||||
sum: 6,
|
||||
noRepeat: false,
|
||||
negated: true,
|
||||
},
|
||||
{ type: "thermo", cells: [12, 13], negated: true },
|
||||
{ type: "kropki", a: 14, b: 15, kind: "black", negated: true },
|
||||
{ type: "xv", a: 2, b: 3, total: 5, negated: true },
|
||||
{ type: "inequality", lesser: 10, greater: 11, negated: true },
|
||||
{ type: "renban", cells: [12, 14], negated: true },
|
||||
{ type: "palindrome", cells: [8, 11], negated: true },
|
||||
{ type: "x-sum", side: "right", index: 2, sum: 2, negated: true },
|
||||
{
|
||||
type: "skyscraper",
|
||||
side: "bottom",
|
||||
index: 3,
|
||||
count: 3,
|
||||
negated: true,
|
||||
},
|
||||
{
|
||||
type: "quadruple",
|
||||
cells: [4, 5, 8, 9],
|
||||
digits: [1],
|
||||
negated: true,
|
||||
},
|
||||
{ type: "maximum", cell: 10, negated: true },
|
||||
],
|
||||
};
|
||||
|
||||
export interface SamplePuzzleEntry {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly puzzle: PuzzleDefinition;
|
||||
}
|
||||
|
||||
export const SAMPLE_CATALOG: readonly SamplePuzzleEntry[] = [
|
||||
{ id: "classic", label: "Classic 9 × 9", puzzle: CLASSIC_SAMPLE },
|
||||
{ id: "killer", label: "Killer 4 × 4", puzzle: KILLER_SAMPLE },
|
||||
{ id: "diagonal", label: "Diagonal 9 × 9", puzzle: DIAGONAL_SAMPLE },
|
||||
{ id: "thermo", label: "Thermo 6 × 6", puzzle: THERMO_SAMPLE },
|
||||
{ id: "arrow", label: "Arrow 6 × 6", puzzle: ARROW_SAMPLE },
|
||||
{ id: "kropki", label: "Kropki 6 × 6", puzzle: KROPKI_SAMPLE },
|
||||
{ id: "xv", label: "Sum pairs 6 × 6", puzzle: XV_SAMPLE },
|
||||
{
|
||||
id: "inequality",
|
||||
label: "Inequality 6 × 6",
|
||||
puzzle: INEQUALITY_SAMPLE,
|
||||
},
|
||||
{ id: "renban", label: "Renban 6 × 6", puzzle: RENBAN_SAMPLE },
|
||||
{
|
||||
id: "palindrome",
|
||||
label: "Palindrome 6 × 6",
|
||||
puzzle: PALINDROME_SAMPLE,
|
||||
},
|
||||
{
|
||||
id: "anti-knight",
|
||||
label: "Anti-knight 4 × 4",
|
||||
puzzle: ANTI_KNIGHT_SAMPLE,
|
||||
},
|
||||
{
|
||||
id: "anti-king",
|
||||
label: "Anti-king 6 × 6",
|
||||
puzzle: ANTI_KING_SAMPLE,
|
||||
},
|
||||
{
|
||||
id: "non-consecutive",
|
||||
label: "Non-consecutive 6 × 6",
|
||||
puzzle: NON_CONSECUTIVE_SAMPLE,
|
||||
},
|
||||
{
|
||||
id: "truth-and-lies",
|
||||
label: "Truth and lies 4 × 4",
|
||||
puzzle: TRUTH_AND_LIES_SAMPLE,
|
||||
},
|
||||
];
|
||||
|
||||
export const SAMPLE_PUZZLES: readonly PuzzleDefinition[] = SAMPLE_CATALOG.map(
|
||||
({ puzzle }) => puzzle,
|
||||
);
|
||||
|
||||
+42
-33
@@ -1,7 +1,11 @@
|
||||
import { constraintCells } from "./constraintRegistry";
|
||||
import { cellColumn, cellRow, orthogonalNeighbours } from "./geometry";
|
||||
import type { CellId, NormalizedPuzzle, VariantConstraint } from "./types";
|
||||
import type { CellId, NormalizedPuzzle } from "./types";
|
||||
|
||||
export type UnitKind = "row" | "column" | "region" | "diagonal";
|
||||
export { outsideLineCells } from "./geometry";
|
||||
|
||||
export type UnitKind =
|
||||
"row" | "column" | "region" | "diagonal" | "disjoint-group" | "extra-region";
|
||||
|
||||
export interface SudokuUnit {
|
||||
readonly kind: UnitKind;
|
||||
@@ -25,34 +29,16 @@ function addPeerPair(peers: Set<CellId>[], a: CellId, b: CellId): void {
|
||||
peers[b]?.add(a);
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
function disjointGroupCells(
|
||||
puzzle: NormalizedPuzzle,
|
||||
position: number,
|
||||
): CellId[] {
|
||||
const regions = Array.from({ length: puzzle.size }, () => [] as CellId[]);
|
||||
puzzle.regions.forEach((region, cell) => regions[region]?.push(cell));
|
||||
return regions.flatMap((cells) => {
|
||||
const cell = cells[position];
|
||||
return cell === undefined ? [] : [cell];
|
||||
});
|
||||
}
|
||||
|
||||
export function compilePuzzle(puzzle: NormalizedPuzzle): CompiledPuzzle {
|
||||
@@ -83,9 +69,28 @@ export function compilePuzzle(puzzle: NormalizedPuzzle): CompiledPuzzle {
|
||||
units.push({
|
||||
kind: "diagonal",
|
||||
index: constraint.direction === "main" ? 0 : 1,
|
||||
cells: cellsForConstraint(size, constraint),
|
||||
cells: constraintCells(size, constraint),
|
||||
});
|
||||
}
|
||||
if (puzzle.constraints.some(({ type }) => type === "disjoint-groups")) {
|
||||
for (let position = 0; position < size; position += 1) {
|
||||
units.push({
|
||||
kind: "disjoint-group",
|
||||
index: position,
|
||||
cells: disjointGroupCells(puzzle, position),
|
||||
});
|
||||
}
|
||||
}
|
||||
let extraRegionIndex = 0;
|
||||
for (const constraint of puzzle.constraints) {
|
||||
if (constraint.type !== "extra-region") continue;
|
||||
units.push({
|
||||
kind: "extra-region",
|
||||
index: extraRegionIndex,
|
||||
cells: constraint.cells,
|
||||
});
|
||||
extraRegionIndex += 1;
|
||||
}
|
||||
|
||||
const peers = Array.from({ length: count }, () => new Set<CellId>());
|
||||
const unitsByCell = Array.from({ length: count }, () => [] as number[]);
|
||||
@@ -98,10 +103,14 @@ export function compilePuzzle(puzzle: NormalizedPuzzle): CompiledPuzzle {
|
||||
|
||||
const constraintsByCell = Array.from({ length: count }, () => [] as number[]);
|
||||
puzzle.constraints.forEach((constraint, constraintIndex) => {
|
||||
for (const cell of new Set(cellsForConstraint(size, constraint))) {
|
||||
for (const cell of constraintCells(size, constraint)) {
|
||||
constraintsByCell[cell]?.push(constraintIndex);
|
||||
}
|
||||
if (constraint.type === "killer-cage" && constraint.noRepeat !== false) {
|
||||
if (
|
||||
constraint.type === "killer-cage" &&
|
||||
constraint.noRepeat !== false &&
|
||||
constraint.negated !== true
|
||||
) {
|
||||
for (const a of constraint.cells) {
|
||||
for (const b of constraint.cells) addPeerPair(peers, a, b);
|
||||
}
|
||||
|
||||
@@ -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))];
|
||||
}
|
||||
+176
-7
@@ -3,6 +3,8 @@ import {
|
||||
MAX_PUZZLE_SIZE,
|
||||
MIN_PUZZLE_SIZE,
|
||||
type CellId,
|
||||
type LittleKillerDirection,
|
||||
type OutsideClueSide,
|
||||
type PuzzleDefinition,
|
||||
} from "./types";
|
||||
|
||||
@@ -46,15 +48,17 @@ export function assertCell(size: number, cell: CellId): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds conventional rectangular regions. For non-composite sizes this falls
|
||||
* back to 1 x size regions, which is still a valid Latin-square topology.
|
||||
*/
|
||||
export function classicRegions(
|
||||
export interface ClassicBoxDimensions {
|
||||
readonly rows: number;
|
||||
readonly columns: number;
|
||||
readonly boxesPerRow: number;
|
||||
}
|
||||
|
||||
export function classicBoxDimensions(
|
||||
size: number,
|
||||
boxRows?: number,
|
||||
boxColumns?: number,
|
||||
): number[] {
|
||||
): ClassicBoxDimensions {
|
||||
assertSize(size);
|
||||
let rows = boxRows;
|
||||
let columns = boxColumns;
|
||||
@@ -84,8 +88,24 @@ export function classicRegions(
|
||||
"Box rows and columns must be positive factors whose product is size.",
|
||||
);
|
||||
}
|
||||
return { rows, columns, boxesPerRow: size / columns };
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds conventional rectangular regions. For non-composite sizes this falls
|
||||
* back to 1 x size regions, which is still a valid Latin-square topology.
|
||||
*/
|
||||
export function classicRegions(
|
||||
size: number,
|
||||
boxRows?: number,
|
||||
boxColumns?: number,
|
||||
): number[] {
|
||||
const { rows, columns, boxesPerRow } = classicBoxDimensions(
|
||||
size,
|
||||
boxRows,
|
||||
boxColumns,
|
||||
);
|
||||
const regions = new Array<number>(size * size);
|
||||
const boxesPerRow = size / columns;
|
||||
for (let row = 0; row < size; row += 1) {
|
||||
for (let column = 0; column < size; column += 1) {
|
||||
regions[row * size + column] =
|
||||
@@ -95,6 +115,52 @@ export function classicRegions(
|
||||
return regions;
|
||||
}
|
||||
|
||||
export function classicBoxIndex(size: number, cell: CellId): number {
|
||||
assertCell(size, cell);
|
||||
const { rows, columns, boxesPerRow } = classicBoxDimensions(size);
|
||||
return (
|
||||
Math.floor(cellRow(size, cell) / rows) * boxesPerRow +
|
||||
Math.floor(cellColumn(size, cell) / columns)
|
||||
);
|
||||
}
|
||||
|
||||
export function classicBoxPosition(size: number, cell: CellId): number {
|
||||
assertCell(size, cell);
|
||||
const { rows, columns } = classicBoxDimensions(size);
|
||||
return (
|
||||
(cellRow(size, cell) % rows) * columns + (cellColumn(size, cell) % columns)
|
||||
);
|
||||
}
|
||||
|
||||
export function classicBoxCell(
|
||||
size: number,
|
||||
boxIndex: number,
|
||||
position: number,
|
||||
): CellId {
|
||||
const { rows, columns, boxesPerRow } = classicBoxDimensions(size);
|
||||
if (!Number.isInteger(boxIndex) || boxIndex < 0 || boxIndex >= size) {
|
||||
throw new RangeError("Box index is outside the grid.");
|
||||
}
|
||||
if (!Number.isInteger(position) || position < 0 || position >= size) {
|
||||
throw new RangeError("Box position is outside the grid.");
|
||||
}
|
||||
const boxRow = Math.floor(boxIndex / boxesPerRow);
|
||||
const boxColumn = boxIndex % boxesPerRow;
|
||||
const row = boxRow * rows + Math.floor(position / columns);
|
||||
const column = boxColumn * columns + (position % columns);
|
||||
return cellId(size, row, column);
|
||||
}
|
||||
|
||||
export function correspondingBoxPositionCells(
|
||||
size: number,
|
||||
cell: CellId,
|
||||
): CellId[] {
|
||||
const position = classicBoxPosition(size, cell);
|
||||
return Array.from({ length: size }, (_, box) =>
|
||||
classicBoxCell(size, box, position),
|
||||
);
|
||||
}
|
||||
|
||||
export function createEmptyPuzzle(
|
||||
size = 9,
|
||||
options: {
|
||||
@@ -126,3 +192,106 @@ export function orthogonalNeighbours(size: number, cell: CellId): CellId[] {
|
||||
if (column + 1 < size) result.push(cell + 1);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Cells seen from an outside clue, ordered from the clue into the grid. */
|
||||
export function outsideLineCells(
|
||||
size: number,
|
||||
side: OutsideClueSide,
|
||||
index: number,
|
||||
): CellId[] {
|
||||
assertSize(size);
|
||||
if (!Number.isInteger(index) || index < 0 || index >= size) return [];
|
||||
return Array.from({ length: size }, (_, offset) => {
|
||||
switch (side) {
|
||||
case "top":
|
||||
return offset * size + index;
|
||||
case "right":
|
||||
return index * size + size - offset - 1;
|
||||
case "bottom":
|
||||
return (size - offset - 1) * size + index;
|
||||
case "left":
|
||||
return index * size + offset;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function littleKillerDirectionEntersGrid(
|
||||
side: OutsideClueSide,
|
||||
direction: LittleKillerDirection,
|
||||
): boolean {
|
||||
switch (side) {
|
||||
case "top":
|
||||
return direction === "down-left" || direction === "down-right";
|
||||
case "right":
|
||||
return direction === "down-left" || direction === "up-left";
|
||||
case "bottom":
|
||||
return direction === "up-left" || direction === "up-right";
|
||||
case "left":
|
||||
return direction === "down-right" || direction === "up-right";
|
||||
}
|
||||
}
|
||||
|
||||
/** Diagonal cells crossed by a little-killer clue, from its outside origin. */
|
||||
export function littleKillerCells(
|
||||
size: number,
|
||||
side: OutsideClueSide,
|
||||
index: number,
|
||||
direction: LittleKillerDirection,
|
||||
): CellId[] {
|
||||
assertSize(size);
|
||||
if (!Number.isInteger(index) || index < 0 || index >= size) return [];
|
||||
const [rowStep, columnStep] = (() => {
|
||||
switch (direction) {
|
||||
case "down-right":
|
||||
return [1, 1] as const;
|
||||
case "down-left":
|
||||
return [1, -1] as const;
|
||||
case "up-right":
|
||||
return [-1, 1] as const;
|
||||
case "up-left":
|
||||
return [-1, -1] as const;
|
||||
}
|
||||
})();
|
||||
let row = side === "top" ? 0 : side === "bottom" ? size - 1 : index;
|
||||
let column = side === "left" ? 0 : side === "right" ? size - 1 : index;
|
||||
const cells: CellId[] = [];
|
||||
while (row >= 0 && row < size && column >= 0 && column < size) {
|
||||
cells.push(row * size + column);
|
||||
row += rowStep;
|
||||
column += columnStep;
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
/** True when four cells meet at one internal grid intersection. */
|
||||
export function cellsFormQuadruple(
|
||||
size: number,
|
||||
cells: readonly CellId[],
|
||||
): boolean {
|
||||
if (cells.length !== 4 || new Set(cells).size !== 4) return false;
|
||||
try {
|
||||
const rows = [...new Set(cells.map((cell) => cellRow(size, cell)))].sort(
|
||||
(a, b) => a - b,
|
||||
);
|
||||
const columns = [
|
||||
...new Set(cells.map((cell) => cellColumn(size, cell))),
|
||||
].sort((a, b) => a - b);
|
||||
if (
|
||||
rows.length !== 2 ||
|
||||
columns.length !== 2 ||
|
||||
rows[1] !== rows[0]! + 1 ||
|
||||
columns[1] !== columns[0]! + 1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const expected = new Set([
|
||||
cellId(size, rows[0]!, columns[0]!),
|
||||
cellId(size, rows[0]!, columns[1]!),
|
||||
cellId(size, rows[1]!, columns[0]!),
|
||||
cellId(size, rows[1]!, columns[1]!),
|
||||
]);
|
||||
return cells.every((cell) => expected.has(cell));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./compile";
|
||||
export * from "./constraintRegistry";
|
||||
export * from "./geometry";
|
||||
export * from "./rules";
|
||||
export * from "./types";
|
||||
|
||||
+853
-30
@@ -1,4 +1,14 @@
|
||||
import { compilePuzzle, type CompiledPuzzle } from "./compile";
|
||||
import { constraintCells } from "./constraintRegistry";
|
||||
import {
|
||||
classicBoxCell,
|
||||
classicBoxIndex,
|
||||
classicBoxPosition,
|
||||
classicRegions,
|
||||
littleKillerCells,
|
||||
orthogonalNeighbours,
|
||||
outsideLineCells,
|
||||
} from "./geometry";
|
||||
import type {
|
||||
CellId,
|
||||
NormalizedPuzzle,
|
||||
@@ -21,8 +31,9 @@ function sumRange(
|
||||
size: number,
|
||||
noRepeat: boolean,
|
||||
): readonly [number, number] {
|
||||
const current = assigned.reduce((sum, value) => sum + value, 0);
|
||||
if (blanks === 0) return [current, current];
|
||||
if (!noRepeat) {
|
||||
const current = assigned.reduce((sum, value) => sum + value, 0);
|
||||
return [current + blanks, current + blanks * size];
|
||||
}
|
||||
const used = new Set(assigned);
|
||||
@@ -32,7 +43,6 @@ function sumRange(
|
||||
).filter((value) => !used.has(value));
|
||||
if (available.length < blanks)
|
||||
return [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY];
|
||||
const current = assigned.reduce((sum, value) => sum + value, 0);
|
||||
const low = available
|
||||
.slice(0, blanks)
|
||||
.reduce((sum, value) => sum + value, current);
|
||||
@@ -57,16 +67,575 @@ function sumsCanMeet(
|
||||
return [sum + blanks, sum + blanks * size];
|
||||
}
|
||||
|
||||
export function constraintIsFeasible(
|
||||
function canChooseDistinctSum(
|
||||
available: readonly number[],
|
||||
count: number,
|
||||
target: number,
|
||||
): boolean {
|
||||
if (count < 0 || count > available.length || target < 0) return false;
|
||||
if (count === 0) return target === 0;
|
||||
const ordered = [...available].sort((a, b) => a - b);
|
||||
const minimum = ordered
|
||||
.slice(0, count)
|
||||
.reduce((total, digit) => total + digit, 0);
|
||||
const maximum = ordered
|
||||
.slice(-count)
|
||||
.reduce((total, digit) => total + digit, 0);
|
||||
if (target < minimum || target > maximum) return false;
|
||||
|
||||
const sums = new Array<bigint>(count + 1).fill(0n);
|
||||
sums[0] = 1n;
|
||||
let processed = 0;
|
||||
for (const digit of ordered) {
|
||||
processed += 1;
|
||||
for (
|
||||
let selected = Math.min(count, processed);
|
||||
selected >= 1;
|
||||
selected -= 1
|
||||
) {
|
||||
sums[selected] =
|
||||
(sums[selected] ?? 0n) | ((sums[selected - 1] ?? 0n) << BigInt(digit));
|
||||
}
|
||||
}
|
||||
return ((sums[count] ?? 0n) & (1n << BigInt(target))) !== 0n;
|
||||
}
|
||||
|
||||
function xSumCanMeet(
|
||||
values: readonly number[],
|
||||
cells: readonly CellId[],
|
||||
size: number,
|
||||
target: number,
|
||||
): boolean {
|
||||
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 false;
|
||||
}
|
||||
|
||||
const first = line[0] ?? 0;
|
||||
const fixedDigits = new Set(fixed);
|
||||
const possibleFirsts =
|
||||
first === 0
|
||||
? Array.from({ length: size }, (_, index) => index + 1).filter(
|
||||
(digit) => !fixedDigits.has(digit),
|
||||
)
|
||||
: [first];
|
||||
|
||||
return possibleFirsts.some((firstDigit) => {
|
||||
if (firstDigit < 1 || firstDigit > size) return false;
|
||||
const used = new Set(fixedDigits);
|
||||
used.add(firstDigit);
|
||||
let assignedSum = firstDigit;
|
||||
let blanks = 0;
|
||||
for (let position = 1; position < firstDigit; position += 1) {
|
||||
const value = line[position] ?? 0;
|
||||
if (value === 0) blanks += 1;
|
||||
else assignedSum += value;
|
||||
}
|
||||
const available = Array.from(
|
||||
{ length: size },
|
||||
(_, index) => index + 1,
|
||||
).filter((digit) => !used.has(digit));
|
||||
return canChooseDistinctSum(available, blanks, target - assignedSum);
|
||||
});
|
||||
}
|
||||
|
||||
type SearchResult = "yes" | "no" | "unknown";
|
||||
const MAX_SKYSCRAPER_STATES = 50_000;
|
||||
|
||||
function skyscraperCanMeet(
|
||||
values: readonly number[],
|
||||
cells: readonly CellId[],
|
||||
size: number,
|
||||
target: number,
|
||||
): boolean {
|
||||
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 false;
|
||||
}
|
||||
if (fixed.length === 0) return target >= 1 && target <= size;
|
||||
|
||||
let prefixMaximum = 0;
|
||||
let prefixVisible = 0;
|
||||
for (const value of line) {
|
||||
if (value === 0) break;
|
||||
if (value > prefixMaximum) {
|
||||
prefixMaximum = value;
|
||||
prefixVisible += 1;
|
||||
}
|
||||
}
|
||||
const minimumVisible = prefixVisible + (prefixMaximum < size ? 1 : 0);
|
||||
const maximumVisible = prefixVisible + (size - prefixMaximum);
|
||||
if (target < minimumVisible || target > maximumVisible) return false;
|
||||
|
||||
let availableMask = 0;
|
||||
for (let digit = 1; digit <= size; digit += 1) {
|
||||
if (!fixed.includes(digit)) availableMask |= 1 << (digit - 1);
|
||||
}
|
||||
let explored = 0;
|
||||
const memo = new Map<string, SearchResult>();
|
||||
const visit = (
|
||||
position: number,
|
||||
remainingMask: number,
|
||||
tallest: number,
|
||||
visible: number,
|
||||
): SearchResult => {
|
||||
if (visible > target) return "no";
|
||||
if (position === size) return visible === target ? "yes" : "no";
|
||||
const positionsLeft = size - position;
|
||||
if (
|
||||
visible + positionsLeft < target ||
|
||||
visible + (size - tallest) < target
|
||||
) {
|
||||
return "no";
|
||||
}
|
||||
explored += 1;
|
||||
if (explored > MAX_SKYSCRAPER_STATES) return "unknown";
|
||||
const key = `${position}:${remainingMask}:${tallest}:${visible}`;
|
||||
const cached = memo.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const fixedValue = line[position] ?? 0;
|
||||
if (fixedValue !== 0) {
|
||||
const result = visit(
|
||||
position + 1,
|
||||
remainingMask,
|
||||
Math.max(tallest, fixedValue),
|
||||
visible + (fixedValue > tallest ? 1 : 0),
|
||||
);
|
||||
memo.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
let sawUnknown = false;
|
||||
for (let mask = remainingMask; mask !== 0; mask &= mask - 1) {
|
||||
const bit = mask & -mask;
|
||||
const digit = 32 - Math.clz32(bit);
|
||||
const result = visit(
|
||||
position + 1,
|
||||
remainingMask & ~bit,
|
||||
Math.max(tallest, digit),
|
||||
visible + (digit > tallest ? 1 : 0),
|
||||
);
|
||||
if (result === "yes") {
|
||||
memo.set(key, result);
|
||||
return result;
|
||||
}
|
||||
if (result === "unknown") sawUnknown = true;
|
||||
}
|
||||
const result = sawUnknown ? "unknown" : "no";
|
||||
memo.set(key, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
return visit(0, availableMask, 0, 0) !== "no";
|
||||
}
|
||||
|
||||
function quadrupleCanMeet(
|
||||
constraint: Extract<VariantConstraint, { readonly type: "quadruple" }>,
|
||||
values: readonly number[],
|
||||
): boolean {
|
||||
const assigned = new Map<number, number>();
|
||||
let blanks = 0;
|
||||
for (const cell of constraint.cells) {
|
||||
const value = values[cell] ?? 0;
|
||||
if (value === 0) blanks += 1;
|
||||
else assigned.set(value, (assigned.get(value) ?? 0) + 1);
|
||||
}
|
||||
const required = new Map<number, number>();
|
||||
for (const digit of constraint.digits) {
|
||||
required.set(digit, (required.get(digit) ?? 0) + 1);
|
||||
}
|
||||
let missing = 0;
|
||||
for (const [digit, count] of required) {
|
||||
missing += Math.max(0, count - (assigned.get(digit) ?? 0));
|
||||
}
|
||||
return missing <= blanks;
|
||||
}
|
||||
|
||||
function extremumCanMeet(
|
||||
cell: CellId,
|
||||
values: readonly number[],
|
||||
size: number,
|
||||
kind: "maximum" | "minimum",
|
||||
): boolean {
|
||||
const extremum = values[cell] ?? 0;
|
||||
const neighbours = orthogonalNeighbours(size, cell).map(
|
||||
(neighbour) => values[neighbour] ?? 0,
|
||||
);
|
||||
if (extremum === 0) {
|
||||
return neighbours.every((value) =>
|
||||
value === 0 ? true : kind === "maximum" ? value < size : value > 1,
|
||||
);
|
||||
}
|
||||
return neighbours.every((value) => {
|
||||
if (value === 0) return kind === "maximum" ? extremum > 1 : extremum < size;
|
||||
return kind === "maximum" ? value < extremum : value > extremum;
|
||||
});
|
||||
}
|
||||
|
||||
function littleKillerCanMeet(
|
||||
values: readonly number[],
|
||||
cells: readonly CellId[],
|
||||
size: number,
|
||||
target: number,
|
||||
): boolean {
|
||||
const [minimum, maximum] = sumsCanMeet(values, cells, size);
|
||||
return target >= minimum && target <= maximum;
|
||||
}
|
||||
|
||||
function sandwichPossibleSums(
|
||||
values: readonly number[],
|
||||
cells: readonly CellId[],
|
||||
size: number,
|
||||
): ReadonlySet<number> {
|
||||
const line = valuesAt(values, cells);
|
||||
const fixed = line.filter((value) => value !== 0);
|
||||
if (
|
||||
fixed.some((value) => value < 1 || value > size) ||
|
||||
new Set(fixed).size !== fixed.length
|
||||
) {
|
||||
return new Set();
|
||||
}
|
||||
const fixedOne = line.indexOf(1);
|
||||
const fixedMaximum = line.indexOf(size);
|
||||
const possible = new Set<number>();
|
||||
for (let one = 0; one < size; one += 1) {
|
||||
if (fixedOne >= 0 && one !== fixedOne) continue;
|
||||
if (line[one] !== 0 && line[one] !== 1) continue;
|
||||
for (let maximum = 0; maximum < size; maximum += 1) {
|
||||
if (maximum === one) continue;
|
||||
if (fixedMaximum >= 0 && maximum !== fixedMaximum) continue;
|
||||
if (line[maximum] !== 0 && line[maximum] !== size) continue;
|
||||
const start = Math.min(one, maximum) + 1;
|
||||
const end = Math.max(one, maximum);
|
||||
let assignedSum = 0;
|
||||
let blanks = 0;
|
||||
for (let position = start; position < end; position += 1) {
|
||||
const value = line[position] ?? 0;
|
||||
if (value === 0) blanks += 1;
|
||||
else assignedSum += value;
|
||||
}
|
||||
const reserved = new Set(fixed);
|
||||
reserved.add(1);
|
||||
reserved.add(size);
|
||||
const available = Array.from(
|
||||
{ length: Math.max(0, size - 2) },
|
||||
(_, index) => index + 2,
|
||||
).filter((digit) => !reserved.has(digit));
|
||||
const maximumExtra = available.reduce((sum, digit) => sum + digit, 0);
|
||||
for (let extra = 0; extra <= maximumExtra; extra += 1) {
|
||||
if (canChooseDistinctSum(available, blanks, extra)) {
|
||||
possible.add(assignedSum + extra);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return possible;
|
||||
}
|
||||
|
||||
function betweenLineCanMeet(
|
||||
cells: readonly CellId[],
|
||||
values: readonly number[],
|
||||
size: number,
|
||||
negated: boolean,
|
||||
): boolean {
|
||||
const firstFixed = values[cells[0] ?? -1] ?? 0;
|
||||
const lastFixed = values[cells.at(-1) ?? -1] ?? 0;
|
||||
const choices = (fixed: number): readonly number[] =>
|
||||
fixed === 0
|
||||
? Array.from({ length: size }, (_, index) => index + 1)
|
||||
: [fixed];
|
||||
for (const first of choices(firstFixed)) {
|
||||
for (const last of choices(lastFixed)) {
|
||||
const low = Math.min(first, last);
|
||||
const high = Math.max(first, last);
|
||||
if (high - low < 2) {
|
||||
if (negated) return true;
|
||||
continue;
|
||||
}
|
||||
let positivePossible = true;
|
||||
let negativePossible = false;
|
||||
for (const cell of cells.slice(1, -1)) {
|
||||
const value = values[cell] ?? 0;
|
||||
if (value === 0) {
|
||||
negativePossible = true;
|
||||
continue;
|
||||
}
|
||||
if (value <= low || value >= high) {
|
||||
positivePossible = false;
|
||||
negativePossible = true;
|
||||
}
|
||||
}
|
||||
if (negated ? negativePossible : positivePossible) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function germanWhisperCanMeet(
|
||||
cells: readonly CellId[],
|
||||
values: readonly number[],
|
||||
size: number,
|
||||
minimumDifference: number,
|
||||
requireViolation: boolean,
|
||||
): boolean {
|
||||
const choices = (cell: CellId): readonly number[] => {
|
||||
const fixed = values[cell] ?? 0;
|
||||
return fixed === 0
|
||||
? Array.from({ length: size }, (_, index) => index + 1)
|
||||
: [fixed];
|
||||
};
|
||||
let states = new Set(choices(cells[0] ?? -1).map((digit) => `${digit}:0`));
|
||||
for (let position = 1; position < cells.length; position += 1) {
|
||||
const next = new Set<string>();
|
||||
for (const state of states) {
|
||||
const [previousText, violationText] = state.split(":");
|
||||
const previous = Number(previousText);
|
||||
const violated = violationText === "1";
|
||||
for (const digit of choices(cells[position] ?? -1)) {
|
||||
const pairViolates = Math.abs(previous - digit) < minimumDifference;
|
||||
if (!requireViolation && pairViolates) continue;
|
||||
next.add(`${digit}:${violated || pairViolates ? "1" : "0"}`);
|
||||
}
|
||||
}
|
||||
states = next;
|
||||
if (states.size === 0) return false;
|
||||
}
|
||||
return requireViolation
|
||||
? [...states].some((state) => state.endsWith(":1"))
|
||||
: states.size > 0;
|
||||
}
|
||||
|
||||
function regionLineSegments(
|
||||
cells: readonly CellId[],
|
||||
regions: readonly number[],
|
||||
): readonly (readonly CellId[])[] {
|
||||
const segments: CellId[][] = [];
|
||||
for (const cell of cells) {
|
||||
const previous = segments.at(-1);
|
||||
const previousCell = previous?.at(-1);
|
||||
if (
|
||||
previous === undefined ||
|
||||
previousCell === undefined ||
|
||||
regions[previousCell] !== regions[cell]
|
||||
) {
|
||||
segments.push([cell]);
|
||||
} else {
|
||||
previous.push(cell);
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
function regionSumRanges(
|
||||
cells: readonly CellId[],
|
||||
values: readonly number[],
|
||||
regions: readonly number[],
|
||||
size: number,
|
||||
): readonly (readonly [number, number])[] {
|
||||
return regionLineSegments(cells, regions).map((segment) =>
|
||||
sumsCanMeet(values, segment, size),
|
||||
);
|
||||
}
|
||||
|
||||
function regionSumLineCanMeet(
|
||||
cells: readonly CellId[],
|
||||
values: readonly number[],
|
||||
regions: readonly number[],
|
||||
size: number,
|
||||
negated: boolean,
|
||||
): boolean {
|
||||
const ranges = regionSumRanges(cells, values, regions, size);
|
||||
if (ranges.length < 2) return !negated;
|
||||
if (negated) {
|
||||
const first = ranges[0]!;
|
||||
return (
|
||||
first[0] !== first[1] ||
|
||||
ranges
|
||||
.slice(1)
|
||||
.some((range) => range[0] !== range[1] || range[0] !== first[0])
|
||||
);
|
||||
}
|
||||
const minimum = Math.max(...ranges.map((range) => range[0]));
|
||||
const maximum = Math.min(...ranges.map((range) => range[1]));
|
||||
return minimum <= maximum;
|
||||
}
|
||||
|
||||
function cloneCanMeet(
|
||||
source: readonly CellId[],
|
||||
clone: readonly CellId[],
|
||||
values: readonly number[],
|
||||
negated: boolean,
|
||||
): boolean {
|
||||
let hasUnknown = false;
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
const first = values[source[index] ?? -1] ?? 0;
|
||||
const second = values[clone[index] ?? -1] ?? 0;
|
||||
if (first === 0 || second === 0) hasUnknown = true;
|
||||
else if (first !== second) return negated;
|
||||
}
|
||||
return negated ? hasUnknown : true;
|
||||
}
|
||||
|
||||
const THREE_CLASS_PERMUTATIONS = [
|
||||
[0, 1, 2],
|
||||
[0, 2, 1],
|
||||
[1, 0, 2],
|
||||
[1, 2, 0],
|
||||
[2, 0, 1],
|
||||
[2, 1, 0],
|
||||
] as const;
|
||||
|
||||
function threeClassLineCanMeet(
|
||||
cells: readonly CellId[],
|
||||
values: readonly number[],
|
||||
classify: (value: number) => 0 | 1 | 2,
|
||||
negated: boolean,
|
||||
): boolean {
|
||||
if (negated && cells.some((cell) => (values[cell] ?? 0) === 0)) {
|
||||
// Every class has at least one digit on validated grids. A blank can
|
||||
// therefore be chosen to violate one window; peer rules can only narrow
|
||||
// this, so retaining the state is a sound over-approximation.
|
||||
return true;
|
||||
}
|
||||
const positive = THREE_CLASS_PERMUTATIONS.some((classes) =>
|
||||
cells.every((cell, position) => {
|
||||
const value = values[cell] ?? 0;
|
||||
return value === 0 || classify(value) === classes[position % 3];
|
||||
}),
|
||||
);
|
||||
return negated ? !positive : positive;
|
||||
}
|
||||
|
||||
function zipperLineCanMeet(
|
||||
cells: readonly CellId[],
|
||||
values: readonly number[],
|
||||
size: number,
|
||||
negated: boolean,
|
||||
): boolean {
|
||||
const middle = Math.floor(cells.length / 2);
|
||||
const fixedCentre = values[cells[middle] ?? -1] ?? 0;
|
||||
const centres =
|
||||
fixedCentre === 0
|
||||
? Array.from({ length: size }, (_, index) => index + 1)
|
||||
: [fixedCentre];
|
||||
for (const centre of centres) {
|
||||
let positivePossible = true;
|
||||
let negativePossible = false;
|
||||
for (let offset = 1; offset <= middle; offset += 1) {
|
||||
const left = values[cells[middle - offset] ?? -1] ?? 0;
|
||||
const right = values[cells[middle + offset] ?? -1] ?? 0;
|
||||
if (left !== 0 && right !== 0) {
|
||||
if (left + right !== centre) {
|
||||
positivePossible = false;
|
||||
negativePossible = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
negativePossible = true;
|
||||
if (left === 0 && right === 0) {
|
||||
if (centre < 2 || centre > size * 2) positivePossible = false;
|
||||
} else {
|
||||
const fixed = left === 0 ? right : left;
|
||||
const missing = centre - fixed;
|
||||
if (missing < 1 || missing > size) positivePossible = false;
|
||||
}
|
||||
}
|
||||
if (negated ? negativePossible : positivePossible) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function doubleArrowCanMeet(
|
||||
cells: readonly CellId[],
|
||||
values: readonly number[],
|
||||
size: number,
|
||||
negated: boolean,
|
||||
): boolean {
|
||||
const endpoints = [cells[0]!, cells.at(-1)!];
|
||||
const interior = cells.slice(1, -1);
|
||||
const endpointRange = sumsCanMeet(values, endpoints, size);
|
||||
const interiorRange = sumsCanMeet(values, interior, size);
|
||||
if (negated) {
|
||||
const complete = cells.every((cell) => (values[cell] ?? 0) !== 0);
|
||||
if (!complete) {
|
||||
// Each blank occurs on only one side of the equation and has at least
|
||||
// four possible raw digits. Keeping it cannot reject a valid completion.
|
||||
return true;
|
||||
}
|
||||
return endpointRange[0] !== interiorRange[0];
|
||||
}
|
||||
return (
|
||||
endpointRange[0] <= interiorRange[1] && interiorRange[0] <= endpointRange[1]
|
||||
);
|
||||
}
|
||||
|
||||
function indexerTarget(
|
||||
kind: "row" | "column" | "box",
|
||||
cell: CellId,
|
||||
value: number,
|
||||
size: number,
|
||||
): readonly [target: CellId, required: number] {
|
||||
const row = Math.floor(cell / size);
|
||||
const column = cell % size;
|
||||
if (kind === "row") return [(value - 1) * size + column, row + 1];
|
||||
if (kind === "column") return [row * size + value - 1, column + 1];
|
||||
return [
|
||||
classicBoxCell(size, value - 1, classicBoxPosition(size, cell)),
|
||||
classicBoxIndex(size, cell) + 1,
|
||||
];
|
||||
}
|
||||
|
||||
function indexerCanMeet(
|
||||
constraint: Extract<VariantConstraint, { readonly type: "indexer" }>,
|
||||
values: readonly number[],
|
||||
size: number,
|
||||
negated: boolean,
|
||||
): boolean {
|
||||
const fixed = values[constraint.cell] ?? 0;
|
||||
const candidates =
|
||||
fixed === 0
|
||||
? Array.from({ length: size }, (_, index) => index + 1)
|
||||
: [fixed];
|
||||
for (const value of candidates) {
|
||||
const [target, required] = indexerTarget(
|
||||
constraint.kind,
|
||||
constraint.cell,
|
||||
value,
|
||||
size,
|
||||
);
|
||||
const targetValue =
|
||||
target === constraint.cell ? value : (values[target] ?? 0);
|
||||
if (!negated && (targetValue === 0 || targetValue === required))
|
||||
return true;
|
||||
if (negated && (targetValue === 0 || targetValue !== required)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function positiveConstraintIsFeasible(
|
||||
constraint: VariantConstraint,
|
||||
values: readonly number[],
|
||||
size: number,
|
||||
regions: readonly number[],
|
||||
): boolean {
|
||||
switch (constraint.type) {
|
||||
case "diagonal":
|
||||
case "anti-knight":
|
||||
case "anti-king":
|
||||
case "disjoint-groups":
|
||||
case "extra-region":
|
||||
return true; // Equality conflicts are represented in compiled peers/units.
|
||||
case "fog":
|
||||
return true; // Fog is presentation state and never changes solutions.
|
||||
case "non-consecutive": {
|
||||
for (let cell = 0; cell < size * size; cell += 1) {
|
||||
const value = values[cell] ?? 0;
|
||||
@@ -168,9 +737,276 @@ export function constraintIsFeasible(
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case "x-sum":
|
||||
return xSumCanMeet(
|
||||
values,
|
||||
outsideLineCells(size, constraint.side, constraint.index),
|
||||
size,
|
||||
constraint.sum,
|
||||
);
|
||||
case "skyscraper":
|
||||
return skyscraperCanMeet(
|
||||
values,
|
||||
outsideLineCells(size, constraint.side, constraint.index),
|
||||
size,
|
||||
constraint.count,
|
||||
);
|
||||
case "quadruple":
|
||||
return quadrupleCanMeet(constraint, values);
|
||||
case "maximum":
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function completedCells(
|
||||
constraint: VariantConstraint,
|
||||
size: number,
|
||||
): readonly CellId[] | undefined {
|
||||
if (!("negated" in constraint)) return undefined;
|
||||
return constraintCells(size, constraint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a false clue can still be satisfied. Unlike an ordinary
|
||||
* feasibility check, a false clue only constrains the grid once the truth of
|
||||
* its positive statement is fixed. Several clue families become fixed before
|
||||
* every geometrically related cell is filled, which is important for bounded
|
||||
* exact search on liar/Wrogn puzzles.
|
||||
*/
|
||||
function negatedConstraintIsFeasible(
|
||||
constraint: VariantConstraint,
|
||||
values: readonly number[],
|
||||
size: number,
|
||||
regions: readonly number[],
|
||||
): boolean {
|
||||
if (constraint.type === "x-sum") {
|
||||
const line = valuesAt(
|
||||
values,
|
||||
outsideLineCells(size, constraint.side, constraint.index),
|
||||
);
|
||||
const first = line[0] ?? 0;
|
||||
if (first === 0) return true;
|
||||
const prefix = line.slice(0, first);
|
||||
if (prefix.some((value) => value === 0)) return true;
|
||||
return prefix.reduce((sum, value) => sum + value, 0) !== constraint.sum;
|
||||
}
|
||||
|
||||
if (constraint.type === "quadruple") {
|
||||
const assigned = new Map<number, number>();
|
||||
for (const cell of constraint.cells) {
|
||||
const value = values[cell] ?? 0;
|
||||
if (value !== 0) assigned.set(value, (assigned.get(value) ?? 0) + 1);
|
||||
}
|
||||
const required = new Map<number, number>();
|
||||
for (const digit of constraint.digits) {
|
||||
required.set(digit, (required.get(digit) ?? 0) + 1);
|
||||
}
|
||||
const positiveAlreadyTrue = [...required].every(
|
||||
([digit, count]) => (assigned.get(digit) ?? 0) >= count,
|
||||
);
|
||||
return !positiveAlreadyTrue;
|
||||
}
|
||||
|
||||
if (constraint.type === "maximum") {
|
||||
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 === 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") {
|
||||
let allPairsKnown = true;
|
||||
for (
|
||||
let index = 0;
|
||||
index < Math.floor(constraint.cells.length / 2);
|
||||
index += 1
|
||||
) {
|
||||
const a = values[constraint.cells[index] ?? -1] ?? 0;
|
||||
const b =
|
||||
values[constraint.cells[constraint.cells.length - index - 1] ?? -1] ??
|
||||
0;
|
||||
if (a !== 0 && b !== 0 && a !== b) return true;
|
||||
if (a === 0 || b === 0) allPairsKnown = false;
|
||||
}
|
||||
if (allPairsKnown) return false;
|
||||
}
|
||||
|
||||
const relevant = completedCells(constraint, size);
|
||||
if (
|
||||
relevant === undefined ||
|
||||
relevant.some((cell) => (values[cell] ?? 0) === 0)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return !positiveConstraintIsFeasible(constraint, values, size, regions);
|
||||
}
|
||||
|
||||
export function constraintIsFeasible(
|
||||
constraint: VariantConstraint,
|
||||
values: readonly number[],
|
||||
size: number,
|
||||
regions: readonly number[] = classicRegions(size),
|
||||
): boolean {
|
||||
const negated = "negated" in constraint && constraint.negated === true;
|
||||
if (!negated)
|
||||
return positiveConstraintIsFeasible(constraint, values, size, regions);
|
||||
return negatedConstraintIsFeasible(constraint, values, size, regions);
|
||||
}
|
||||
|
||||
function asCompiled(
|
||||
puzzle: PuzzleDefinition | NormalizedPuzzle | CompiledPuzzle,
|
||||
): CompiledPuzzle {
|
||||
@@ -197,7 +1033,7 @@ export function canPlaceValue(
|
||||
const constraint = compiled.puzzle.constraints[index];
|
||||
if (
|
||||
constraint !== undefined &&
|
||||
!constraintIsFeasible(constraint, next, size)
|
||||
!constraintIsFeasible(constraint, next, size, compiled.puzzle.regions)
|
||||
)
|
||||
return false;
|
||||
}
|
||||
@@ -293,35 +1129,22 @@ export function findConflicts(
|
||||
}
|
||||
});
|
||||
compiled.puzzle.constraints.forEach((constraint, constraintIndex) => {
|
||||
if (!constraintIsFeasible(constraint, board, compiled.puzzle.size)) {
|
||||
const cells = (() => {
|
||||
switch (constraint.type) {
|
||||
case "diagonal":
|
||||
case "anti-knight":
|
||||
case "anti-king":
|
||||
case "non-consecutive":
|
||||
return Array.from(
|
||||
{ length: compiled.puzzle.size * compiled.puzzle.size },
|
||||
(_, cell) => cell,
|
||||
);
|
||||
case "killer-cage":
|
||||
case "thermo":
|
||||
case "renban":
|
||||
case "palindrome":
|
||||
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];
|
||||
}
|
||||
})();
|
||||
if (
|
||||
!constraintIsFeasible(
|
||||
constraint,
|
||||
board,
|
||||
compiled.puzzle.size,
|
||||
compiled.puzzle.regions,
|
||||
)
|
||||
) {
|
||||
const cells = constraintCells(compiled.puzzle.size, constraint);
|
||||
conflicts.push({
|
||||
kind: "constraint",
|
||||
cells,
|
||||
message: `${constraint.type} constraint cannot be satisfied.`,
|
||||
message:
|
||||
"negated" in constraint && constraint.negated === true
|
||||
? `${constraint.type} clue is true but must be false.`
|
||||
: `${constraint.type} constraint cannot be satisfied.`,
|
||||
constraintIndex,
|
||||
});
|
||||
}
|
||||
|
||||
+196
-1
@@ -23,18 +23,26 @@ export interface NonConsecutiveConstraint {
|
||||
readonly type: "non-consecutive";
|
||||
}
|
||||
|
||||
/** Corresponding positions in every standard box form an additional house. */
|
||||
export interface DisjointGroupsConstraint {
|
||||
readonly type: "disjoint-groups";
|
||||
}
|
||||
|
||||
export interface KillerCageConstraint {
|
||||
readonly type: "killer-cage";
|
||||
readonly cells: readonly CellId[];
|
||||
readonly sum: number;
|
||||
/** Killer cages normally do not repeat digits. Defaults to true. */
|
||||
readonly noRepeat?: boolean;
|
||||
/** If true, the completed clue must be false instead of true. */
|
||||
readonly negated?: boolean;
|
||||
}
|
||||
|
||||
export interface ThermoConstraint {
|
||||
readonly type: "thermo";
|
||||
/** Ordered from bulb to tip. Values must increase strictly. */
|
||||
readonly cells: readonly CellId[];
|
||||
readonly negated?: boolean;
|
||||
}
|
||||
|
||||
export interface ArrowConstraint {
|
||||
@@ -42,6 +50,7 @@ export interface ArrowConstraint {
|
||||
/** Bulb cells. Their values sum to the values on the line. */
|
||||
readonly bulb: readonly CellId[];
|
||||
readonly line: readonly CellId[];
|
||||
readonly negated?: boolean;
|
||||
}
|
||||
|
||||
export interface KropkiConstraint {
|
||||
@@ -50,6 +59,7 @@ export interface KropkiConstraint {
|
||||
readonly b: CellId;
|
||||
/** White means consecutive; black means a 1:2 ratio. */
|
||||
readonly kind: "white" | "black";
|
||||
readonly negated?: boolean;
|
||||
}
|
||||
|
||||
export interface XvConstraint {
|
||||
@@ -57,22 +67,186 @@ export interface XvConstraint {
|
||||
readonly a: CellId;
|
||||
readonly b: CellId;
|
||||
readonly total: 5 | 10;
|
||||
readonly negated?: boolean;
|
||||
}
|
||||
|
||||
export interface InequalityConstraint {
|
||||
readonly type: "inequality";
|
||||
readonly lesser: CellId;
|
||||
readonly greater: CellId;
|
||||
readonly negated?: boolean;
|
||||
}
|
||||
|
||||
export interface RenbanConstraint {
|
||||
readonly type: "renban";
|
||||
readonly cells: readonly CellId[];
|
||||
readonly negated?: boolean;
|
||||
}
|
||||
|
||||
export interface PalindromeConstraint {
|
||||
readonly type: "palindrome";
|
||||
readonly cells: readonly CellId[];
|
||||
readonly negated?: boolean;
|
||||
}
|
||||
|
||||
export type OutsideClueSide = "top" | "right" | "bottom" | "left";
|
||||
|
||||
/**
|
||||
* The first digit seen from `side` selects how many cells, including itself,
|
||||
* must add to `sum`. `index` is the zero-based row or column on that side.
|
||||
*/
|
||||
export interface XSumConstraint {
|
||||
readonly type: "x-sum";
|
||||
readonly side: OutsideClueSide;
|
||||
readonly index: number;
|
||||
readonly sum: number;
|
||||
readonly negated?: boolean;
|
||||
}
|
||||
|
||||
/** Number of increasing-height records seen across a row or column. */
|
||||
export interface SkyscraperConstraint {
|
||||
readonly type: "skyscraper";
|
||||
readonly side: OutsideClueSide;
|
||||
readonly index: number;
|
||||
readonly count: number;
|
||||
readonly negated?: boolean;
|
||||
}
|
||||
|
||||
/** Every listed digit, including repetitions, must occur in the clue cells. */
|
||||
export interface QuadrupleConstraint {
|
||||
readonly type: "quadruple";
|
||||
readonly cells: readonly CellId[];
|
||||
readonly digits: readonly CellValue[];
|
||||
readonly negated?: boolean;
|
||||
}
|
||||
|
||||
/** The marked cell is greater than each orthogonally adjacent cell. */
|
||||
export interface MaximumConstraint {
|
||||
readonly type: "maximum";
|
||||
readonly cell: CellId;
|
||||
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 =
|
||||
@@ -80,6 +254,7 @@ export type VariantConstraint =
|
||||
| AntiKnightConstraint
|
||||
| AntiKingConstraint
|
||||
| NonConsecutiveConstraint
|
||||
| DisjointGroupsConstraint
|
||||
| KillerCageConstraint
|
||||
| ThermoConstraint
|
||||
| ArrowConstraint
|
||||
@@ -87,7 +262,27 @@ export type VariantConstraint =
|
||||
| XvConstraint
|
||||
| InequalityConstraint
|
||||
| RenbanConstraint
|
||||
| PalindromeConstraint;
|
||||
| PalindromeConstraint
|
||||
| XSumConstraint
|
||||
| SkyscraperConstraint
|
||||
| QuadrupleConstraint
|
||||
| MaximumConstraint
|
||||
| MinimumConstraint
|
||||
| OddConstraint
|
||||
| EvenConstraint
|
||||
| LittleKillerConstraint
|
||||
| SandwichConstraint
|
||||
| BetweenLineConstraint
|
||||
| GermanWhisperConstraint
|
||||
| RegionSumLineConstraint
|
||||
| CloneConstraint
|
||||
| ExtraRegionConstraint
|
||||
| ModularLineConstraint
|
||||
| EntropicLineConstraint
|
||||
| ZipperLineConstraint
|
||||
| DoubleArrowConstraint
|
||||
| IndexerConstraint
|
||||
| FogConstraint;
|
||||
|
||||
export interface PuzzleDefinition {
|
||||
readonly version: 1;
|
||||
|
||||
+658
-23
@@ -1,11 +1,22 @@
|
||||
import { classicRegions } from "./geometry";
|
||||
import {
|
||||
cellsFormQuadruple,
|
||||
classicRegions,
|
||||
littleKillerCells,
|
||||
littleKillerDirectionEntersGrid,
|
||||
} from "./geometry";
|
||||
import { compilePuzzle } from "./compile";
|
||||
import {
|
||||
constraintAllowedFields,
|
||||
isConstraintType,
|
||||
} from "./constraintRegistry";
|
||||
import { findConflicts } from "./rules";
|
||||
import {
|
||||
MAX_PUZZLE_SIZE,
|
||||
MIN_PUZZLE_SIZE,
|
||||
PuzzleValidationError,
|
||||
type LittleKillerDirection,
|
||||
type NormalizedPuzzle,
|
||||
type OutsideClueSide,
|
||||
type PuzzleDefinition,
|
||||
type ValidationIssue,
|
||||
type ValidationResult,
|
||||
@@ -15,6 +26,8 @@ import {
|
||||
const MAX_CONSTRAINTS = 4_096;
|
||||
const MAX_SHORT_TEXT = 256;
|
||||
const MAX_RULES_TEXT = 16_384;
|
||||
/** Keeps deliberately impossible false-clue labels finite without requiring reachability. */
|
||||
const maximumFalseClueValue = (size: number): number => size ** 4;
|
||||
const ROOT_KEYS = new Set([
|
||||
"version",
|
||||
"size",
|
||||
@@ -28,22 +41,8 @@ const ROOT_KEYS = new Set([
|
||||
"solution",
|
||||
]);
|
||||
|
||||
const CONSTRAINT_KEYS: Readonly<
|
||||
Record<VariantConstraint["type"], ReadonlySet<string>>
|
||||
> = {
|
||||
diagonal: new Set(["type", "direction"]),
|
||||
"anti-knight": new Set(["type"]),
|
||||
"anti-king": new Set(["type"]),
|
||||
"non-consecutive": new Set(["type"]),
|
||||
"killer-cage": new Set(["type", "cells", "sum", "noRepeat"]),
|
||||
thermo: new Set(["type", "cells"]),
|
||||
arrow: new Set(["type", "bulb", "line"]),
|
||||
kropki: new Set(["type", "a", "b", "kind"]),
|
||||
xv: new Set(["type", "a", "b", "total"]),
|
||||
inequality: new Set(["type", "lesser", "greater"]),
|
||||
renban: new Set(["type", "cells"]),
|
||||
palindrome: new Set(["type", "cells"]),
|
||||
};
|
||||
const reachableXSumCache = new Map<number, ReadonlySet<number>>();
|
||||
const reachableSandwichCache = new Map<number, ReadonlySet<number>>();
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
@@ -108,6 +107,96 @@ function validateCells(
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateIntegerRange(
|
||||
value: unknown,
|
||||
path: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
issues: ValidationIssue[],
|
||||
): value is number {
|
||||
if (
|
||||
!Number.isInteger(value) ||
|
||||
(value as number) < minimum ||
|
||||
(value as number) > maximum
|
||||
) {
|
||||
add(issues, path, `must be an integer from ${minimum} to ${maximum}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateOutsideClue(
|
||||
value: Record<string, unknown>,
|
||||
path: string,
|
||||
size: number,
|
||||
issues: ValidationIssue[],
|
||||
): void {
|
||||
if (
|
||||
value.side !== "top" &&
|
||||
value.side !== "right" &&
|
||||
value.side !== "bottom" &&
|
||||
value.side !== "left"
|
||||
) {
|
||||
add(issues, `${path}.side`, 'must be "top", "right", "bottom" or "left"');
|
||||
}
|
||||
validateIntegerRange(value.index, `${path}.index`, 0, size - 1, issues);
|
||||
}
|
||||
|
||||
function reachableXSumTotals(size: number): ReadonlySet<number> {
|
||||
const cached = reachableXSumCache.get(size);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const total = (size * (size + 1)) / 2;
|
||||
const reachable = new Set<number>();
|
||||
for (let first = 1; first <= size; first += 1) {
|
||||
const choose = first - 1;
|
||||
const sums = Array.from(
|
||||
{ length: choose + 1 },
|
||||
() => new Uint8Array(total + 1),
|
||||
);
|
||||
sums[0]![0] = 1;
|
||||
for (let digit = 1; digit <= size; digit += 1) {
|
||||
if (digit === first) continue;
|
||||
for (let count = choose; count >= 1; count -= 1) {
|
||||
const current = sums[count]!;
|
||||
const previous = sums[count - 1]!;
|
||||
for (let sum = total; sum >= digit; sum -= 1) {
|
||||
if (previous[sum - digit] !== 0) current[sum] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let sum = 0; sum <= total - first; sum += 1) {
|
||||
if (sums[choose]![sum] !== 0) reachable.add(first + sum);
|
||||
}
|
||||
}
|
||||
reachableXSumCache.set(size, 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(
|
||||
value: unknown,
|
||||
index: number,
|
||||
@@ -119,16 +208,23 @@ function validateConstraint(
|
||||
add(issues, path, "must be a constraint object with a type");
|
||||
return;
|
||||
}
|
||||
const type = value.type as VariantConstraint["type"];
|
||||
const allowed = CONSTRAINT_KEYS[type];
|
||||
if (allowed === undefined) {
|
||||
if (!isConstraintType(value.type)) {
|
||||
add(issues, `${path}.type`, "is not a supported constraint type");
|
||||
return;
|
||||
}
|
||||
const type = value.type;
|
||||
const allowed = constraintAllowedFields(type);
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowed.has(key))
|
||||
add(issues, `${path}.${key}`, "is not a recognized field");
|
||||
}
|
||||
if (
|
||||
allowed.has("negated") &&
|
||||
value.negated !== undefined &&
|
||||
typeof value.negated !== "boolean"
|
||||
) {
|
||||
add(issues, `${path}.negated`, "must be a boolean");
|
||||
}
|
||||
const cellCount = size * size;
|
||||
switch (type) {
|
||||
case "diagonal":
|
||||
@@ -139,6 +235,7 @@ function validateConstraint(
|
||||
case "anti-knight":
|
||||
case "anti-king":
|
||||
case "non-consecutive":
|
||||
case "disjoint-groups":
|
||||
break;
|
||||
case "killer-cage": {
|
||||
const cellsValid = validateCells(
|
||||
@@ -166,8 +263,17 @@ function validateConstraint(
|
||||
? (length * (2 * size - length + 1)) / 2
|
||||
: length * size;
|
||||
if (
|
||||
(value.sum as number) < minimum ||
|
||||
(value.sum as number) > maximum
|
||||
value.negated === true &&
|
||||
((value.sum as number) < 1 || (value.sum as number) > size ** 3)
|
||||
) {
|
||||
add(
|
||||
issues,
|
||||
`${path}.sum`,
|
||||
`must be a bounded positive integer (1 to ${size ** 3})`,
|
||||
);
|
||||
} else if (
|
||||
value.negated !== true &&
|
||||
((value.sum as number) < minimum || (value.sum as number) > maximum)
|
||||
) {
|
||||
add(
|
||||
issues,
|
||||
@@ -242,6 +348,336 @@ function validateConstraint(
|
||||
issues,
|
||||
);
|
||||
break;
|
||||
case "x-sum": {
|
||||
validateOutsideClue(value, path, size, issues);
|
||||
const sumValid = validateIntegerRange(
|
||||
value.sum,
|
||||
`${path}.sum`,
|
||||
1,
|
||||
value.negated === true
|
||||
? maximumFalseClueValue(size)
|
||||
: (size * (size + 1)) / 2,
|
||||
issues,
|
||||
);
|
||||
if (
|
||||
sumValid &&
|
||||
value.negated !== true &&
|
||||
!reachableXSumTotals(size).has(value.sum as number)
|
||||
) {
|
||||
add(issues, `${path}.sum`, "cannot be formed by a valid X-sum line");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "skyscraper":
|
||||
validateOutsideClue(value, path, size, issues);
|
||||
validateIntegerRange(
|
||||
value.count,
|
||||
`${path}.count`,
|
||||
1,
|
||||
value.negated === true ? maximumFalseClueValue(size) : size,
|
||||
issues,
|
||||
);
|
||||
break;
|
||||
case "quadruple": {
|
||||
const cellsValid = validateCells(
|
||||
value.cells,
|
||||
`${path}.cells`,
|
||||
cellCount,
|
||||
1,
|
||||
4,
|
||||
issues,
|
||||
);
|
||||
if (!Array.isArray(value.digits)) {
|
||||
add(issues, `${path}.digits`, "must be an array");
|
||||
break;
|
||||
}
|
||||
if (value.digits.length < 1 || value.digits.length > 4) {
|
||||
add(issues, `${path}.digits`, "must contain 1 to 4 digits");
|
||||
}
|
||||
value.digits.forEach((digit, digitIndex) => {
|
||||
validateIntegerRange(
|
||||
digit,
|
||||
`${path}.digits[${digitIndex}]`,
|
||||
1,
|
||||
size,
|
||||
issues,
|
||||
);
|
||||
});
|
||||
if (
|
||||
cellsValid &&
|
||||
value.digits.length > (value.cells as readonly number[]).length
|
||||
) {
|
||||
add(
|
||||
issues,
|
||||
`${path}.digits`,
|
||||
"must not contain more digits than clue cells",
|
||||
);
|
||||
}
|
||||
if (
|
||||
cellsValid &&
|
||||
!cellsFormQuadruple(size, value.cells as readonly number[])
|
||||
) {
|
||||
add(
|
||||
issues,
|
||||
`${path}.cells`,
|
||||
"must be the four cells surrounding one grid intersection",
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "maximum":
|
||||
case "minimum":
|
||||
case "odd":
|
||||
case "even":
|
||||
validateCell(value.cell, `${path}.cell`, cellCount, issues);
|
||||
break;
|
||||
case "little-killer": {
|
||||
validateOutsideClue(value, path, size, issues);
|
||||
const directionValid =
|
||||
value.direction === "down-right" ||
|
||||
value.direction === "down-left" ||
|
||||
value.direction === "up-right" ||
|
||||
value.direction === "up-left";
|
||||
if (!directionValid) {
|
||||
add(
|
||||
issues,
|
||||
`${path}.direction`,
|
||||
'must be "down-right", "down-left", "up-right" or "up-left"',
|
||||
);
|
||||
} else if (
|
||||
(value.side === "top" ||
|
||||
value.side === "right" ||
|
||||
value.side === "bottom" ||
|
||||
value.side === "left") &&
|
||||
!littleKillerDirectionEntersGrid(
|
||||
value.side,
|
||||
value.direction as LittleKillerDirection,
|
||||
)
|
||||
) {
|
||||
add(issues, `${path}.direction`, "must point into the grid");
|
||||
}
|
||||
const indexValid =
|
||||
Number.isInteger(value.index) &&
|
||||
(value.index as number) >= 0 &&
|
||||
(value.index as number) < size;
|
||||
const sideValid =
|
||||
value.side === "top" ||
|
||||
value.side === "right" ||
|
||||
value.side === "bottom" ||
|
||||
value.side === "left";
|
||||
const cells =
|
||||
directionValid && indexValid && sideValid
|
||||
? littleKillerCells(
|
||||
size,
|
||||
value.side as OutsideClueSide,
|
||||
value.index as number,
|
||||
value.direction as LittleKillerDirection,
|
||||
)
|
||||
: [];
|
||||
if (cells.length === 1) {
|
||||
add(
|
||||
issues,
|
||||
path,
|
||||
"little-killer diagonal must cross at least two cells",
|
||||
);
|
||||
}
|
||||
if (!Number.isInteger(value.sum)) {
|
||||
add(issues, `${path}.sum`, "must be an integer");
|
||||
} else if (cells.length >= 2) {
|
||||
const minimum = value.negated === true ? 1 : cells.length;
|
||||
const maximum =
|
||||
value.negated === true
|
||||
? maximumFalseClueValue(size)
|
||||
: cells.length * size;
|
||||
if (
|
||||
(value.sum as number) < minimum ||
|
||||
(value.sum as number) > maximum
|
||||
) {
|
||||
add(
|
||||
issues,
|
||||
`${path}.sum`,
|
||||
value.negated === true
|
||||
? `must be a bounded positive integer (1 to ${maximum})`
|
||||
: `must be reachable (${minimum} to ${maximum})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "sandwich": {
|
||||
validateOutsideClue(value, path, size, issues);
|
||||
const valid = validateIntegerRange(
|
||||
value.sum,
|
||||
`${path}.sum`,
|
||||
0,
|
||||
value.negated === true
|
||||
? maximumFalseClueValue(size)
|
||||
: (size * (size + 1)) / 2 - size - 1,
|
||||
issues,
|
||||
);
|
||||
if (
|
||||
valid &&
|
||||
value.negated !== true &&
|
||||
!reachableSandwichTotals(size).has(value.sum as number)
|
||||
) {
|
||||
add(
|
||||
issues,
|
||||
`${path}.sum`,
|
||||
"cannot be formed by digits between 1 and the maximum digit",
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "between-line":
|
||||
validateCells(
|
||||
value.cells,
|
||||
`${path}.cells`,
|
||||
cellCount,
|
||||
3,
|
||||
cellCount,
|
||||
issues,
|
||||
);
|
||||
break;
|
||||
case "german-whisper":
|
||||
validateCells(
|
||||
value.cells,
|
||||
`${path}.cells`,
|
||||
cellCount,
|
||||
2,
|
||||
cellCount,
|
||||
issues,
|
||||
);
|
||||
if (value.minimumDifference !== undefined) {
|
||||
validateIntegerRange(
|
||||
value.minimumDifference,
|
||||
`${path}.minimumDifference`,
|
||||
1,
|
||||
size - 1,
|
||||
issues,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "region-sum-line":
|
||||
validateCells(
|
||||
value.cells,
|
||||
`${path}.cells`,
|
||||
cellCount,
|
||||
2,
|
||||
cellCount,
|
||||
issues,
|
||||
);
|
||||
break;
|
||||
case "clone": {
|
||||
const sourceValid = validateCells(
|
||||
value.cells,
|
||||
`${path}.cells`,
|
||||
cellCount,
|
||||
1,
|
||||
cellCount,
|
||||
issues,
|
||||
);
|
||||
const cloneValid = validateCells(
|
||||
value.cloneCells,
|
||||
`${path}.cloneCells`,
|
||||
cellCount,
|
||||
1,
|
||||
cellCount,
|
||||
issues,
|
||||
);
|
||||
if (sourceValid && cloneValid) {
|
||||
const source = value.cells as readonly number[];
|
||||
const clone = value.cloneCells as readonly number[];
|
||||
if (source.length !== clone.length) {
|
||||
add(issues, path, "clone cell lists must have equal length");
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "extra-region":
|
||||
validateCells(
|
||||
value.cells,
|
||||
`${path}.cells`,
|
||||
cellCount,
|
||||
size,
|
||||
size,
|
||||
issues,
|
||||
);
|
||||
break;
|
||||
case "modular-line":
|
||||
validateCells(
|
||||
value.cells,
|
||||
`${path}.cells`,
|
||||
cellCount,
|
||||
3,
|
||||
cellCount,
|
||||
issues,
|
||||
);
|
||||
break;
|
||||
case "entropic-line":
|
||||
validateCells(
|
||||
value.cells,
|
||||
`${path}.cells`,
|
||||
cellCount,
|
||||
3,
|
||||
cellCount,
|
||||
issues,
|
||||
);
|
||||
if (size % 3 !== 0) {
|
||||
add(issues, path, "entropic lines require a grid size divisible by 3");
|
||||
}
|
||||
break;
|
||||
case "zipper-line":
|
||||
if (
|
||||
validateCells(
|
||||
value.cells,
|
||||
`${path}.cells`,
|
||||
cellCount,
|
||||
3,
|
||||
cellCount,
|
||||
issues,
|
||||
) &&
|
||||
(value.cells as readonly number[]).length % 2 === 0
|
||||
) {
|
||||
add(issues, `${path}.cells`, "must contain an odd number of cells");
|
||||
}
|
||||
break;
|
||||
case "double-arrow":
|
||||
validateCells(
|
||||
value.cells,
|
||||
`${path}.cells`,
|
||||
cellCount,
|
||||
3,
|
||||
cellCount,
|
||||
issues,
|
||||
);
|
||||
break;
|
||||
case "indexer":
|
||||
validateCell(value.cell, `${path}.cell`, cellCount, issues);
|
||||
if (
|
||||
value.kind !== "row" &&
|
||||
value.kind !== "column" &&
|
||||
value.kind !== "box"
|
||||
) {
|
||||
add(issues, `${path}.kind`, 'must be "row", "column" or "box"');
|
||||
}
|
||||
break;
|
||||
case "fog":
|
||||
validateCells(
|
||||
value.lights,
|
||||
`${path}.lights`,
|
||||
cellCount,
|
||||
1,
|
||||
cellCount,
|
||||
issues,
|
||||
);
|
||||
if (
|
||||
value.revealRadius !== undefined &&
|
||||
value.revealRadius !== 0 &&
|
||||
value.revealRadius !== 1
|
||||
) {
|
||||
add(issues, `${path}.revealRadius`, "must be 0 or 1");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,6 +714,7 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
|
||||
case "anti-knight":
|
||||
case "anti-king":
|
||||
case "non-consecutive":
|
||||
case "disjoint-groups":
|
||||
return { type: constraint.type };
|
||||
case "killer-cage":
|
||||
return {
|
||||
@@ -287,16 +724,34 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
|
||||
...(constraint.noRepeat === undefined
|
||||
? {}
|
||||
: { noRepeat: constraint.noRepeat }),
|
||||
...(constraint.negated === undefined
|
||||
? {}
|
||||
: { negated: constraint.negated }),
|
||||
};
|
||||
case "thermo":
|
||||
case "renban":
|
||||
case "palindrome":
|
||||
return { type: constraint.type, cells: [...constraint.cells] };
|
||||
case "between-line":
|
||||
case "region-sum-line":
|
||||
case "modular-line":
|
||||
case "entropic-line":
|
||||
case "zipper-line":
|
||||
case "double-arrow":
|
||||
return {
|
||||
type: constraint.type,
|
||||
cells: [...constraint.cells],
|
||||
...(constraint.negated === undefined
|
||||
? {}
|
||||
: { negated: constraint.negated }),
|
||||
};
|
||||
case "arrow":
|
||||
return {
|
||||
type: constraint.type,
|
||||
bulb: [...constraint.bulb],
|
||||
line: [...constraint.line],
|
||||
...(constraint.negated === undefined
|
||||
? {}
|
||||
: { negated: constraint.negated }),
|
||||
};
|
||||
case "kropki":
|
||||
return {
|
||||
@@ -304,6 +759,9 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
|
||||
a: constraint.a,
|
||||
b: constraint.b,
|
||||
kind: constraint.kind,
|
||||
...(constraint.negated === undefined
|
||||
? {}
|
||||
: { negated: constraint.negated }),
|
||||
};
|
||||
case "xv":
|
||||
return {
|
||||
@@ -311,12 +769,118 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
|
||||
a: constraint.a,
|
||||
b: constraint.b,
|
||||
total: constraint.total,
|
||||
...(constraint.negated === undefined
|
||||
? {}
|
||||
: { negated: constraint.negated }),
|
||||
};
|
||||
case "inequality":
|
||||
return {
|
||||
type: constraint.type,
|
||||
lesser: constraint.lesser,
|
||||
greater: constraint.greater,
|
||||
...(constraint.negated === undefined
|
||||
? {}
|
||||
: { negated: constraint.negated }),
|
||||
};
|
||||
case "x-sum":
|
||||
return {
|
||||
type: constraint.type,
|
||||
side: constraint.side,
|
||||
index: constraint.index,
|
||||
sum: constraint.sum,
|
||||
...(constraint.negated === undefined
|
||||
? {}
|
||||
: { negated: constraint.negated }),
|
||||
};
|
||||
case "skyscraper":
|
||||
return {
|
||||
type: constraint.type,
|
||||
side: constraint.side,
|
||||
index: constraint.index,
|
||||
count: constraint.count,
|
||||
...(constraint.negated === undefined
|
||||
? {}
|
||||
: { negated: constraint.negated }),
|
||||
};
|
||||
case "quadruple":
|
||||
return {
|
||||
type: constraint.type,
|
||||
cells: [...constraint.cells],
|
||||
digits: [...constraint.digits],
|
||||
...(constraint.negated === undefined
|
||||
? {}
|
||||
: { negated: constraint.negated }),
|
||||
};
|
||||
case "maximum":
|
||||
case "minimum":
|
||||
case "odd":
|
||||
case "even":
|
||||
return {
|
||||
type: constraint.type,
|
||||
cell: constraint.cell,
|
||||
...(constraint.negated === undefined
|
||||
? {}
|
||||
: { 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 }),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -426,6 +990,77 @@ export function validatePuzzle(puzzle: unknown): ValidationResult {
|
||||
.forEach((constraint, index) => {
|
||||
validateConstraint(constraint, index, n, issues);
|
||||
});
|
||||
if (regionsValid) {
|
||||
const actualRegions =
|
||||
puzzle.regions === undefined
|
||||
? classicRegions(n)
|
||||
: (puzzle.regions as readonly number[]);
|
||||
if (!sameRegionPartition(actualRegions, classicRegions(n))) {
|
||||
puzzle.constraints.forEach((constraint, index) => {
|
||||
if (!isRecord(constraint)) return;
|
||||
if (constraint.type === "disjoint-groups") {
|
||||
add(
|
||||
issues,
|
||||
`constraints[${index}]`,
|
||||
"disjoint groups require the standard rectangular box layout",
|
||||
);
|
||||
}
|
||||
if (constraint.type === "indexer" && constraint.kind === "box") {
|
||||
add(
|
||||
issues,
|
||||
`constraints[${index}]`,
|
||||
"box indexers require the standard rectangular box layout",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
puzzle.constraints.forEach((constraint, index) => {
|
||||
if (
|
||||
!isRecord(constraint) ||
|
||||
constraint.type !== "region-sum-line" ||
|
||||
!Array.isArray(constraint.cells) ||
|
||||
constraint.cells.length < 2 ||
|
||||
constraint.cells.some(
|
||||
(cell) =>
|
||||
!Number.isInteger(cell) ||
|
||||
(cell as number) < 0 ||
|
||||
(cell as number) >= n * n,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const cells = constraint.cells as readonly number[];
|
||||
let segmentCount = 1;
|
||||
for (let position = 1; position < cells.length; position += 1) {
|
||||
if (
|
||||
actualRegions[cells[position]!] !==
|
||||
actualRegions[cells[position - 1]!]
|
||||
) {
|
||||
segmentCount += 1;
|
||||
}
|
||||
}
|
||||
if (segmentCount < 2) {
|
||||
add(
|
||||
issues,
|
||||
`constraints[${index}].cells`,
|
||||
"region-sum line must cross at least one region boundary",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
puzzle.constraints.forEach((constraint, index) => {
|
||||
if (
|
||||
isRecord(constraint) &&
|
||||
constraint.type === "fog" &&
|
||||
puzzle.solution === undefined
|
||||
) {
|
||||
add(
|
||||
issues,
|
||||
`constraints[${index}]`,
|
||||
"fog requires a complete trusted puzzle solution",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
validateText(puzzle.id, "id", MAX_SHORT_TEXT, issues);
|
||||
validateText(puzzle.title, "title", MAX_SHORT_TEXT, issues);
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* A size-bounded adaptation of the LZ-String decompressor (MIT).
|
||||
*
|
||||
* LZ-String's public decompressors only return after constructing the complete
|
||||
* output. Importers need to stop while expanding untrusted payloads, so this
|
||||
* implementation applies both UTF-16 character and UTF-8 byte budgets before
|
||||
* appending every decoded dictionary entry.
|
||||
*/
|
||||
|
||||
const BASE64_ALPHABET =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
|
||||
const URI_SAFE_ALPHABET =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$";
|
||||
|
||||
export class LzStringOutputLimitError extends Error {
|
||||
readonly maxOutputBytes: number;
|
||||
|
||||
constructor(maxOutputBytes: number) {
|
||||
super(
|
||||
`The decompressed LZ-String output exceeds ${maxOutputBytes.toLocaleString()} bytes.`,
|
||||
);
|
||||
this.name = "LzStringOutputLimitError";
|
||||
this.maxOutputBytes = maxOutputBytes;
|
||||
}
|
||||
}
|
||||
|
||||
class OutputBudget {
|
||||
private characters = 0;
|
||||
private bytes = 0;
|
||||
private pendingHighSurrogate = false;
|
||||
private readonly maximum: number;
|
||||
|
||||
constructor(maximum: number) {
|
||||
this.maximum = maximum;
|
||||
}
|
||||
|
||||
append(value: string): void {
|
||||
this.characters += value.length;
|
||||
if (this.characters > this.maximum) this.exceeded();
|
||||
|
||||
let index = 0;
|
||||
if (this.pendingHighSurrogate) {
|
||||
if (isLowSurrogate(value.charCodeAt(0))) {
|
||||
this.bytes += 4;
|
||||
index = 1;
|
||||
} else {
|
||||
this.bytes += 3;
|
||||
}
|
||||
this.pendingHighSurrogate = false;
|
||||
}
|
||||
|
||||
while (index < value.length) {
|
||||
const codeUnit = value.charCodeAt(index);
|
||||
if (codeUnit <= 0x7f) {
|
||||
this.bytes += 1;
|
||||
} else if (codeUnit <= 0x7ff) {
|
||||
this.bytes += 2;
|
||||
} else if (isHighSurrogate(codeUnit)) {
|
||||
const next = value.charCodeAt(index + 1);
|
||||
if (isLowSurrogate(next)) {
|
||||
this.bytes += 4;
|
||||
index += 1;
|
||||
} else if (index + 1 === value.length) {
|
||||
this.pendingHighSurrogate = true;
|
||||
} else {
|
||||
this.bytes += 3;
|
||||
}
|
||||
} else {
|
||||
// TextEncoder replaces lone low surrogates with U+FFFD (three bytes).
|
||||
this.bytes += 3;
|
||||
}
|
||||
if (this.bytes > this.maximum) this.exceeded();
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
finish(): void {
|
||||
if (this.pendingHighSurrogate) {
|
||||
this.bytes += 3;
|
||||
this.pendingHighSurrogate = false;
|
||||
}
|
||||
if (this.bytes > this.maximum) this.exceeded();
|
||||
}
|
||||
|
||||
private exceeded(): never {
|
||||
throw new LzStringOutputLimitError(this.maximum);
|
||||
}
|
||||
}
|
||||
|
||||
function isHighSurrogate(codeUnit: number): boolean {
|
||||
return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
|
||||
}
|
||||
|
||||
function isLowSurrogate(codeUnit: number): boolean {
|
||||
return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
|
||||
}
|
||||
|
||||
function alphabetReader(alphabet: string, input: string) {
|
||||
const reverse = new Map<string, number>();
|
||||
for (let index = 0; index < alphabet.length; index += 1) {
|
||||
reverse.set(alphabet.charAt(index), index);
|
||||
}
|
||||
return (index: number) => reverse.get(input.charAt(index)) ?? 0;
|
||||
}
|
||||
|
||||
function decompressBounded(
|
||||
input: string,
|
||||
alphabet: string,
|
||||
maxOutputBytes: number,
|
||||
): string | null {
|
||||
if (input.length === 0) return null;
|
||||
if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 1) {
|
||||
throw new RangeError("maxOutputBytes must be a positive safe integer.");
|
||||
}
|
||||
|
||||
const nextValue = alphabetReader(alphabet, input);
|
||||
const dictionary: Array<string | null | undefined> = ["", "", ""];
|
||||
const output: string[] = [];
|
||||
const budget = new OutputBudget(maxOutputBytes);
|
||||
let enlargeIn = 4;
|
||||
let dictionarySize = 4;
|
||||
let numberOfBits = 3;
|
||||
let dataValue = nextValue(0);
|
||||
let dataPosition = 32;
|
||||
let dataIndex = 1;
|
||||
|
||||
const readBits = (count: number) => {
|
||||
let bits = 0;
|
||||
let power = 1;
|
||||
const maximumPower = 2 ** count;
|
||||
while (power !== maximumPower) {
|
||||
const bit = dataValue & dataPosition;
|
||||
dataPosition >>= 1;
|
||||
if (dataPosition === 0) {
|
||||
dataPosition = 32;
|
||||
dataValue = nextValue(dataIndex);
|
||||
dataIndex += 1;
|
||||
}
|
||||
if (bit > 0) bits |= power;
|
||||
power <<= 1;
|
||||
}
|
||||
return bits;
|
||||
};
|
||||
|
||||
const firstCode = readBits(2);
|
||||
let first: string;
|
||||
if (firstCode === 0) first = String.fromCharCode(readBits(8));
|
||||
else if (firstCode === 1) first = String.fromCharCode(readBits(16));
|
||||
else if (firstCode === 2) return "";
|
||||
else return null;
|
||||
|
||||
dictionary[3] = first;
|
||||
let previous = first;
|
||||
budget.append(first);
|
||||
output.push(first);
|
||||
|
||||
while (true) {
|
||||
if (dataIndex > input.length) return "";
|
||||
|
||||
let code = readBits(numberOfBits);
|
||||
if (code === 0 || code === 1) {
|
||||
const literal = String.fromCharCode(readBits(code === 0 ? 8 : 16));
|
||||
dictionary[dictionarySize] = literal;
|
||||
code = dictionarySize;
|
||||
dictionarySize += 1;
|
||||
enlargeIn -= 1;
|
||||
} else if (code === 2) {
|
||||
budget.finish();
|
||||
return output.join("");
|
||||
}
|
||||
|
||||
if (enlargeIn === 0) {
|
||||
enlargeIn = 2 ** numberOfBits;
|
||||
numberOfBits += 1;
|
||||
}
|
||||
|
||||
const known = dictionary[code];
|
||||
let entry: string;
|
||||
if (known === null) {
|
||||
throw new LzStringOutputLimitError(maxOutputBytes);
|
||||
} else if (known !== undefined && known.length > 0) {
|
||||
entry = known;
|
||||
} else if (code === dictionarySize) {
|
||||
if (previous.length >= maxOutputBytes) {
|
||||
throw new LzStringOutputLimitError(maxOutputBytes);
|
||||
}
|
||||
entry = previous + previous.charAt(0);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
budget.append(entry);
|
||||
output.push(entry);
|
||||
|
||||
dictionary[dictionarySize] =
|
||||
previous.length < maxOutputBytes ? previous + entry.charAt(0) : null;
|
||||
dictionarySize += 1;
|
||||
enlargeIn -= 1;
|
||||
previous = entry;
|
||||
|
||||
if (enlargeIn === 0) {
|
||||
enlargeIn = 2 ** numberOfBits;
|
||||
numberOfBits += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function decompressFromBase64Bounded(
|
||||
input: string,
|
||||
maxOutputBytes: number,
|
||||
): string | null {
|
||||
return decompressBounded(input, BASE64_ALPHABET, maxOutputBytes);
|
||||
}
|
||||
|
||||
export function decompressFromEncodedURIComponentBounded(
|
||||
input: string,
|
||||
maxOutputBytes: number,
|
||||
): string | null {
|
||||
return decompressBounded(
|
||||
input.replaceAll(" ", "+"),
|
||||
URI_SAFE_ALPHABET,
|
||||
maxOutputBytes,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an LZ-String payload which may use either public six-bit alphabet.
|
||||
*
|
||||
* Values 0–62 have identical symbols in the Base64 and URI-safe alphabets.
|
||||
* Base64 uniquely uses `/` and `=`, while the URI-safe form uniquely uses `-`
|
||||
* and `$`. Selecting from those markers is important: LZ-String treats an
|
||||
* unknown symbol as zero, which can otherwise yield non-empty, subtly corrupt
|
||||
* JSON instead of a decompression failure.
|
||||
*/
|
||||
export function decompressFromBase64OrUriComponentBounded(
|
||||
input: string,
|
||||
maxOutputBytes: number,
|
||||
): string | null {
|
||||
const normalized = input.replaceAll(" ", "+");
|
||||
const hasBase64OnlySymbol = /[/=]/u.test(normalized);
|
||||
const hasUriOnlySymbol = /[-$]/u.test(normalized);
|
||||
if (hasBase64OnlySymbol && hasUriOnlySymbol) return null;
|
||||
if (hasUriOnlySymbol) {
|
||||
return decompressFromEncodedURIComponentBounded(normalized, maxOutputBytes);
|
||||
}
|
||||
return decompressFromBase64Bounded(normalized, maxOutputBytes);
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
+369
-2
@@ -1,7 +1,17 @@
|
||||
import { cellsFormQuadruple } from "../domain/geometry";
|
||||
import { normalizePortableAidMemoire } from "../state/aidMemoire";
|
||||
import {
|
||||
SafeVisualValidationError,
|
||||
normalizeSafeVisualPrimitives,
|
||||
normalizeScalarMetadata,
|
||||
normalizeSourceIdentity,
|
||||
} from "./safeVisuals";
|
||||
import {
|
||||
SUDOKU_DOCUMENT_SCHEMA,
|
||||
SUDOKU_DOCUMENT_VERSION,
|
||||
cloneConstraint,
|
||||
cloneVisualPrimitive,
|
||||
type PortableAidMemoire,
|
||||
type PortableConstraint,
|
||||
type SudokuDocument,
|
||||
} from "./types";
|
||||
@@ -12,6 +22,30 @@ export const MAX_BOARD_SIZE = 16;
|
||||
export const MAX_CONSTRAINTS = 5_000;
|
||||
const MAX_TEXT_LENGTH = 20_000;
|
||||
const MAX_RULES = 1_000;
|
||||
const DOCUMENT_FIELDS = new Set([
|
||||
"schema",
|
||||
"version",
|
||||
"size",
|
||||
"givens",
|
||||
"values",
|
||||
"cornerMarks",
|
||||
"centerMarks",
|
||||
"candidates",
|
||||
"colors",
|
||||
"elapsedMs",
|
||||
"aidMemoire",
|
||||
"solution",
|
||||
"regions",
|
||||
"constraints",
|
||||
"title",
|
||||
"author",
|
||||
"rules",
|
||||
"globalRules",
|
||||
"id",
|
||||
"visuals",
|
||||
"source",
|
||||
"metadata",
|
||||
]);
|
||||
|
||||
export class SudokuFormatError extends Error {
|
||||
readonly code: string;
|
||||
@@ -121,6 +155,34 @@ function pair(
|
||||
return { a, b };
|
||||
}
|
||||
|
||||
function outsideSide(value: unknown): "top" | "right" | "bottom" | "left" {
|
||||
if (
|
||||
value !== "top" &&
|
||||
value !== "right" &&
|
||||
value !== "bottom" &&
|
||||
value !== "left"
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_CONSTRAINT",
|
||||
"An outside clue side must be top, right, bottom, or left.",
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function cluePolarity(
|
||||
value: Record<string, unknown>,
|
||||
label: string,
|
||||
): { negated?: boolean } {
|
||||
if (value.negated !== undefined && typeof value.negated !== "boolean") {
|
||||
return fail(
|
||||
"INVALID_CONSTRAINT",
|
||||
`${label}.negated must be true or false.`,
|
||||
);
|
||||
}
|
||||
return value.negated === true ? { negated: true } : {};
|
||||
}
|
||||
|
||||
function parseConstraint(
|
||||
value: unknown,
|
||||
cellCount: number,
|
||||
@@ -131,6 +193,7 @@ function parseConstraint(
|
||||
"Each constraint must be an object with a type.",
|
||||
);
|
||||
}
|
||||
const size = Math.sqrt(cellCount);
|
||||
switch (value.type) {
|
||||
case "diagonal": {
|
||||
if (value.direction !== "main" && value.direction !== "anti") {
|
||||
@@ -144,6 +207,7 @@ function parseConstraint(
|
||||
case "anti-knight":
|
||||
case "anti-king":
|
||||
case "non-consecutive":
|
||||
case "disjoint-groups":
|
||||
return { type: value.type };
|
||||
case "killer-cage": {
|
||||
const cageCells = cells(value.cells, "killer-cage.cells", cellCount);
|
||||
@@ -164,20 +228,131 @@ function parseConstraint(
|
||||
cells: cageCells,
|
||||
sum,
|
||||
...(value.noRepeat === undefined ? {} : { noRepeat: value.noRepeat }),
|
||||
...cluePolarity(value, "killer-cage"),
|
||||
};
|
||||
}
|
||||
case "thermo":
|
||||
case "renban":
|
||||
case "palindrome":
|
||||
case "region-sum-line":
|
||||
return {
|
||||
type: value.type,
|
||||
cells: cells(value.cells, `${value.type}.cells`, cellCount, 2),
|
||||
...cluePolarity(value, value.type),
|
||||
};
|
||||
case "between-line":
|
||||
return {
|
||||
type: "between-line",
|
||||
cells: cells(value.cells, "between-line.cells", cellCount, 3),
|
||||
...cluePolarity(value, "between-line"),
|
||||
};
|
||||
case "modular-line":
|
||||
case "entropic-line":
|
||||
case "double-arrow":
|
||||
return {
|
||||
type: value.type,
|
||||
cells: cells(value.cells, `${value.type}.cells`, cellCount, 3),
|
||||
...cluePolarity(value, value.type),
|
||||
};
|
||||
case "zipper-line": {
|
||||
const lineCells = cells(value.cells, "zipper-line.cells", cellCount, 3);
|
||||
if (lineCells.length % 2 === 0) {
|
||||
return fail(
|
||||
"INVALID_CELLS",
|
||||
"zipper-line.cells must contain an odd number of cells.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
type: "zipper-line",
|
||||
cells: lineCells,
|
||||
...cluePolarity(value, "zipper-line"),
|
||||
};
|
||||
}
|
||||
case "german-whisper":
|
||||
return {
|
||||
type: "german-whisper",
|
||||
cells: cells(value.cells, "german-whisper.cells", cellCount, 2),
|
||||
...(value.minimumDifference === undefined
|
||||
? {}
|
||||
: {
|
||||
minimumDifference: integer(
|
||||
value.minimumDifference,
|
||||
"german-whisper.minimumDifference",
|
||||
1,
|
||||
size - 1,
|
||||
),
|
||||
}),
|
||||
...cluePolarity(value, "german-whisper"),
|
||||
};
|
||||
case "clone": {
|
||||
const original = cells(value.cells, "clone.cells", cellCount);
|
||||
const cloned = cells(value.cloneCells, "clone.cloneCells", cellCount);
|
||||
if (original.length !== cloned.length) {
|
||||
return fail(
|
||||
"INVALID_CELLS",
|
||||
"clone.cells and clone.cloneCells must have equal lengths.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
type: "clone",
|
||||
cells: original,
|
||||
cloneCells: cloned,
|
||||
...cluePolarity(value, "clone"),
|
||||
};
|
||||
}
|
||||
case "extra-region": {
|
||||
const regionCells = cells(value.cells, "extra-region.cells", cellCount);
|
||||
if (regionCells.length !== size) {
|
||||
return fail(
|
||||
"INVALID_CELLS",
|
||||
`extra-region.cells must contain exactly ${size} cells.`,
|
||||
);
|
||||
}
|
||||
return { type: "extra-region", cells: regionCells };
|
||||
}
|
||||
case "indexer": {
|
||||
if (
|
||||
value.kind !== "row" &&
|
||||
value.kind !== "column" &&
|
||||
value.kind !== "box"
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_CONSTRAINT",
|
||||
"indexer.kind must be row, column, or box.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
type: "indexer",
|
||||
kind: value.kind,
|
||||
cell: cell(value.cell, "indexer.cell", cellCount),
|
||||
...cluePolarity(value, "indexer"),
|
||||
};
|
||||
}
|
||||
case "fog": {
|
||||
if (
|
||||
value.revealRadius !== undefined &&
|
||||
value.revealRadius !== 0 &&
|
||||
value.revealRadius !== 1
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_CONSTRAINT",
|
||||
"fog.revealRadius must be zero or one.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
type: "fog",
|
||||
lights: cells(value.lights, "fog.lights", cellCount),
|
||||
...(value.revealRadius === undefined
|
||||
? {}
|
||||
: { revealRadius: value.revealRadius }),
|
||||
};
|
||||
}
|
||||
case "arrow":
|
||||
return {
|
||||
type: "arrow",
|
||||
bulb: cells(value.bulb, "arrow.bulb", cellCount),
|
||||
line: cells(value.line, "arrow.line", cellCount),
|
||||
...cluePolarity(value, "arrow"),
|
||||
};
|
||||
case "kropki": {
|
||||
const related = pair(value, cellCount);
|
||||
@@ -187,7 +362,12 @@ function parseConstraint(
|
||||
"A Kropki kind must be white or black.",
|
||||
);
|
||||
}
|
||||
return { type: "kropki", ...related, kind: value.kind };
|
||||
return {
|
||||
type: "kropki",
|
||||
...related,
|
||||
kind: value.kind,
|
||||
...cluePolarity(value, "kropki"),
|
||||
};
|
||||
}
|
||||
case "xv": {
|
||||
const related = pair(value, cellCount);
|
||||
@@ -198,6 +378,7 @@ function parseConstraint(
|
||||
type: "xv",
|
||||
...related,
|
||||
total: value.total,
|
||||
...cluePolarity(value, "xv"),
|
||||
};
|
||||
}
|
||||
case "inequality": {
|
||||
@@ -209,7 +390,128 @@ function parseConstraint(
|
||||
"An inequality must join two different cells.",
|
||||
);
|
||||
}
|
||||
return { type: "inequality", lesser, greater };
|
||||
return {
|
||||
type: "inequality",
|
||||
lesser,
|
||||
greater,
|
||||
...cluePolarity(value, "inequality"),
|
||||
};
|
||||
}
|
||||
case "x-sum": {
|
||||
const polarity = cluePolarity(value, "x-sum");
|
||||
return {
|
||||
type: "x-sum",
|
||||
side: outsideSide(value.side),
|
||||
index: integer(value.index, "x-sum.index", 0, size - 1),
|
||||
sum: integer(
|
||||
value.sum,
|
||||
"x-sum.sum",
|
||||
1,
|
||||
polarity.negated === true ? size ** 4 : (size * (size + 1)) / 2,
|
||||
),
|
||||
...polarity,
|
||||
};
|
||||
}
|
||||
case "skyscraper": {
|
||||
const polarity = cluePolarity(value, "skyscraper");
|
||||
return {
|
||||
type: "skyscraper",
|
||||
side: outsideSide(value.side),
|
||||
index: integer(value.index, "skyscraper.index", 0, size - 1),
|
||||
count: integer(
|
||||
value.count,
|
||||
"skyscraper.count",
|
||||
1,
|
||||
polarity.negated === true ? size ** 4 : size,
|
||||
),
|
||||
...polarity,
|
||||
};
|
||||
}
|
||||
case "quadruple": {
|
||||
const quadrupleCells = cells(value.cells, "quadruple.cells", cellCount);
|
||||
if (quadrupleCells.length > 4) {
|
||||
return fail(
|
||||
"INVALID_CELLS",
|
||||
"quadruple.cells must contain at most four cells.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Array.isArray(value.digits) ||
|
||||
value.digits.length < 1 ||
|
||||
value.digits.length > 4 ||
|
||||
value.digits.length > quadrupleCells.length
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_CONSTRAINT",
|
||||
"quadruple.digits must contain one to four digits and no more digits than clue cells.",
|
||||
);
|
||||
}
|
||||
if (!cellsFormQuadruple(size, quadrupleCells)) {
|
||||
return fail(
|
||||
"INVALID_CELLS",
|
||||
"quadruple.cells must be the four cells surrounding one grid intersection.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
type: "quadruple",
|
||||
cells: quadrupleCells,
|
||||
digits: value.digits.map((digit, index) =>
|
||||
integer(digit, `quadruple.digits[${index}]`, 1, size),
|
||||
),
|
||||
...cluePolarity(value, "quadruple"),
|
||||
};
|
||||
}
|
||||
case "maximum":
|
||||
case "minimum":
|
||||
case "odd":
|
||||
case "even":
|
||||
return {
|
||||
type: value.type,
|
||||
cell: cell(value.cell, `${value.type}.cell`, cellCount),
|
||||
...cluePolarity(value, value.type),
|
||||
};
|
||||
case "little-killer": {
|
||||
const direction = value.direction;
|
||||
if (
|
||||
direction !== "down-right" &&
|
||||
direction !== "down-left" &&
|
||||
direction !== "up-right" &&
|
||||
direction !== "up-left"
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_CONSTRAINT",
|
||||
"A little-killer direction must point diagonally into the grid.",
|
||||
);
|
||||
}
|
||||
const polarity = cluePolarity(value, "little-killer");
|
||||
return {
|
||||
type: "little-killer",
|
||||
side: outsideSide(value.side),
|
||||
index: integer(value.index, "little-killer.index", 0, size - 1),
|
||||
direction,
|
||||
sum: integer(
|
||||
value.sum,
|
||||
"little-killer.sum",
|
||||
1,
|
||||
polarity.negated === true ? size ** 4 : size ** 3,
|
||||
),
|
||||
...polarity,
|
||||
};
|
||||
}
|
||||
case "sandwich": {
|
||||
const polarity = cluePolarity(value, "sandwich");
|
||||
return {
|
||||
type: "sandwich",
|
||||
side: outsideSide(value.side),
|
||||
index: integer(value.index, "sandwich.index", 0, size - 1),
|
||||
sum: integer(
|
||||
value.sum,
|
||||
"sandwich.sum",
|
||||
0,
|
||||
polarity.negated === true ? size ** 4 : size ** 3,
|
||||
),
|
||||
...polarity,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return fail(
|
||||
@@ -238,6 +540,15 @@ function textList(value: unknown, label: string): string[] | undefined {
|
||||
export function normalizeSudokuDocument(value: unknown): SudokuDocument {
|
||||
if (!isRecord(value))
|
||||
return fail("INVALID_DOCUMENT", "The puzzle document must be an object.");
|
||||
const unknownField = Object.keys(value).find(
|
||||
(field) => !DOCUMENT_FIELDS.has(field),
|
||||
);
|
||||
if (unknownField !== undefined) {
|
||||
return fail(
|
||||
"UNSUPPORTED_DOCUMENT_FIELD",
|
||||
`The puzzle document field “${unknownField}” is not supported. Raw code, markup and custom fields are never imported.`,
|
||||
);
|
||||
}
|
||||
if (value.schema !== SUDOKU_DOCUMENT_SCHEMA) {
|
||||
return fail("INVALID_SCHEMA", `Expected schema ${SUDOKU_DOCUMENT_SCHEMA}.`);
|
||||
}
|
||||
@@ -316,6 +627,19 @@ export function normalizeSudokuDocument(value: unknown): SudokuDocument {
|
||||
}
|
||||
elapsedMs = value.elapsedMs;
|
||||
}
|
||||
let aidMemoire: PortableAidMemoire | undefined;
|
||||
if (value.aidMemoire !== undefined) {
|
||||
try {
|
||||
aidMemoire = normalizePortableAidMemoire(value.aidMemoire, size);
|
||||
} catch (error) {
|
||||
return fail(
|
||||
"INVALID_AID_MEMOIRE",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "The aid-mémoire data is invalid.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!Array.isArray(value.constraints) ||
|
||||
@@ -329,11 +653,33 @@ export function normalizeSudokuDocument(value: unknown): SudokuDocument {
|
||||
const constraints = value.constraints.map((constraint) =>
|
||||
parseConstraint(constraint, cellCount),
|
||||
);
|
||||
if (
|
||||
constraints.some(({ type }) => type === "fog") &&
|
||||
solution === undefined
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_CONSTRAINT",
|
||||
"Fog of War requires a complete trusted solution for correct reveals.",
|
||||
);
|
||||
}
|
||||
const title = optionalText(value.title, "title");
|
||||
const author = optionalText(value.author, "author");
|
||||
const id = optionalText(value.id, "id");
|
||||
const rules = textList(value.rules, "rules");
|
||||
const globalRules = textList(value.globalRules, "globalRules");
|
||||
let visuals;
|
||||
let source;
|
||||
let metadata;
|
||||
try {
|
||||
visuals = normalizeSafeVisualPrimitives(value.visuals, size);
|
||||
source = normalizeSourceIdentity(value.source);
|
||||
metadata = normalizeScalarMetadata(value.metadata);
|
||||
} catch (error) {
|
||||
if (error instanceof SafeVisualValidationError) {
|
||||
return fail("INVALID_SOURCE_EXTRAS", error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
schema: SUDOKU_DOCUMENT_SCHEMA,
|
||||
@@ -348,12 +694,16 @@ export function normalizeSudokuDocument(value: unknown): SudokuDocument {
|
||||
...(candidates === undefined ? {} : { candidates }),
|
||||
...(colors === undefined ? {} : { colors }),
|
||||
...(elapsedMs === undefined ? {} : { elapsedMs }),
|
||||
...(aidMemoire === undefined ? {} : { aidMemoire }),
|
||||
...(regions === undefined ? {} : { regions }),
|
||||
...(title === undefined ? {} : { title }),
|
||||
...(author === undefined ? {} : { author }),
|
||||
...(id === undefined ? {} : { id }),
|
||||
...(rules === undefined ? {} : { rules }),
|
||||
...(globalRules === undefined ? {} : { globalRules }),
|
||||
...(visuals === undefined ? {} : { visuals }),
|
||||
...(source === undefined ? {} : { source }),
|
||||
...(metadata === undefined ? {} : { metadata }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -416,6 +766,14 @@ export function cloneSudokuDocument(value: SudokuDocument): SudokuDocument {
|
||||
...(normalized.colors === undefined
|
||||
? {}
|
||||
: { colors: [...normalized.colors] }),
|
||||
...(normalized.aidMemoire === undefined
|
||||
? {}
|
||||
: {
|
||||
aidMemoire: normalizePortableAidMemoire(
|
||||
normalized.aidMemoire,
|
||||
normalized.size,
|
||||
),
|
||||
}),
|
||||
...(normalized.regions === undefined
|
||||
? {}
|
||||
: { regions: [...normalized.regions] }),
|
||||
@@ -423,5 +781,14 @@ export function cloneSudokuDocument(value: SudokuDocument): SudokuDocument {
|
||||
...(normalized.globalRules === undefined
|
||||
? {}
|
||||
: { globalRules: [...normalized.globalRules] }),
|
||||
...(normalized.visuals === undefined
|
||||
? {}
|
||||
: { visuals: normalized.visuals.map(cloneVisualPrimitive) }),
|
||||
...(normalized.source === undefined
|
||||
? {}
|
||||
: { source: { ...normalized.source } }),
|
||||
...(normalized.metadata === undefined
|
||||
? {}
|
||||
: { metadata: { ...normalized.metadata } }),
|
||||
};
|
||||
}
|
||||
|
||||
+924
-66
File diff suppressed because it is too large
Load Diff
@@ -69,6 +69,7 @@ export function parsePlainGrid(
|
||||
size,
|
||||
givens,
|
||||
constraints: [],
|
||||
source: { format: "plain-grid" },
|
||||
...(options.title === undefined ? {} : { title: options.title }),
|
||||
...(options.author === undefined ? {} : { author: options.author }),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
MAX_DOCUMENT_BYTES,
|
||||
parseSudokuDocument,
|
||||
SudokuFormatError,
|
||||
} from "./document";
|
||||
import {
|
||||
importFpuzzles,
|
||||
NetworkPuzzleIdError,
|
||||
parseFpuzzles,
|
||||
} from "./fpuzzles";
|
||||
import {
|
||||
puzzleImportResult,
|
||||
type PuzzleImportResult,
|
||||
RemotePuzzleReferenceError,
|
||||
} from "./interoperability";
|
||||
import { importPenpa, parsePenpaText } from "./penpa";
|
||||
import { parsePlainGrid } from "./grid";
|
||||
import { decodePuzzleHash } from "./share";
|
||||
import { importSudokuPad, parseSudokuPadPuzzle } from "./sudokupad";
|
||||
import type { SudokuDocument } from "./types";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function urlPayload(input: string): {
|
||||
readonly kind: "fpuzzles" | "sudokupad" | "penpa" | "remote";
|
||||
readonly value: string;
|
||||
} | null {
|
||||
if (!/^https?:\/\//iu.test(input)) return null;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(input);
|
||||
} catch {
|
||||
return { kind: "remote", value: input };
|
||||
}
|
||||
const host = url.hostname.toLowerCase();
|
||||
if (url.pathname.includes("/penpa-edit/")) {
|
||||
return { kind: "penpa", value: input };
|
||||
}
|
||||
if (host === "f-puzzles.com" || host === "www.f-puzzles.com") {
|
||||
return { kind: "fpuzzles", value: input };
|
||||
}
|
||||
if (host === "sudokupad.app" || host.endsWith(".sudokupad.app")) {
|
||||
let pathCandidate: string;
|
||||
try {
|
||||
pathCandidate = decodeURIComponent(
|
||||
url.pathname.replace(/^\/+|\/+$/gu, ""),
|
||||
);
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_SUDOKUPAD_URL",
|
||||
"The SudokuPad URL contains invalid escaped text.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
const candidate =
|
||||
url.searchParams.get("puzzleid") ??
|
||||
url.searchParams.get("load") ??
|
||||
pathCandidate;
|
||||
if (/^(?:ctc|scl)/iu.test(candidate)) {
|
||||
return { kind: "sudokupad", value: candidate };
|
||||
}
|
||||
if (/^fpuzzles/iu.test(candidate)) {
|
||||
return { kind: "fpuzzles", value: input };
|
||||
}
|
||||
throw new NetworkPuzzleIdError(candidate || "unknown");
|
||||
}
|
||||
return { kind: "remote", value: input };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect and import every supported local representation. The function is
|
||||
* asynchronous only because Penpa+ uses browser-native raw-deflate streams;
|
||||
* no branch performs a network request.
|
||||
*/
|
||||
export async function importPuzzle(
|
||||
input: string,
|
||||
): Promise<PuzzleImportResult<SudokuDocument>> {
|
||||
if (input.length > MAX_DOCUMENT_BYTES) {
|
||||
throw new SudokuFormatError(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The pasted puzzle data is too large to open safely.",
|
||||
);
|
||||
}
|
||||
const trimmed = input.trim().replace(/^\uFEFF/u, "");
|
||||
const url = urlPayload(trimmed);
|
||||
if (url?.kind === "remote") {
|
||||
throw new RemotePuzzleReferenceError("The pasted URL");
|
||||
}
|
||||
if (url?.kind === "penpa") {
|
||||
return puzzleImportResult(await importPenpa(url.value), "penpa", "Penpa+");
|
||||
}
|
||||
if (url?.kind === "sudokupad") {
|
||||
return puzzleImportResult(
|
||||
importSudokuPad(url.value),
|
||||
"sudokupad",
|
||||
"SudokuPad/CTC",
|
||||
);
|
||||
}
|
||||
if (url?.kind === "fpuzzles") {
|
||||
return puzzleImportResult(
|
||||
importFpuzzles(url.value),
|
||||
"fpuzzles",
|
||||
"f-puzzles",
|
||||
);
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("#sudoku=") || trimmed.includes("#sudoku=")) {
|
||||
return puzzleImportResult(
|
||||
decodePuzzleHash(trimmed),
|
||||
"sudoku-tools",
|
||||
"Sudoku Tools share link",
|
||||
);
|
||||
}
|
||||
if (/^(?:ctc|scl)/iu.test(trimmed)) {
|
||||
return puzzleImportResult(
|
||||
importSudokuPad(trimmed),
|
||||
"sudokupad",
|
||||
"SudokuPad/CTC",
|
||||
);
|
||||
}
|
||||
if (/^penpa:/iu.test(trimmed) || /^[?#]?(?:m=[^&]+&)?p=/iu.test(trimmed)) {
|
||||
return puzzleImportResult(await importPenpa(trimmed), "penpa", "Penpa+");
|
||||
}
|
||||
if (/^(?:square|sudoku),[^\r\n]+[\r\n]/iu.test(trimmed)) {
|
||||
return puzzleImportResult(parsePenpaText(trimmed), "penpa", "Penpa+ text");
|
||||
}
|
||||
if (/^fpuzzles/iu.test(trimmed)) {
|
||||
return puzzleImportResult(importFpuzzles(trimmed), "fpuzzles", "f-puzzles");
|
||||
}
|
||||
if (trimmed.startsWith("{")) {
|
||||
if (new TextEncoder().encode(trimmed).byteLength > MAX_DOCUMENT_BYTES) {
|
||||
throw new SudokuFormatError(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The pasted puzzle JSON is too large to open safely.",
|
||||
);
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed) as unknown;
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_JSON",
|
||||
"The pasted puzzle data is not valid JSON.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
if (isRecord(parsed) && "schema" in parsed) {
|
||||
return puzzleImportResult(
|
||||
parseSudokuDocument(trimmed),
|
||||
"sudoku-tools",
|
||||
"Sudoku Tools JSON",
|
||||
);
|
||||
}
|
||||
if (isRecord(parsed) && "cells" in parsed && !("grid" in parsed)) {
|
||||
return puzzleImportResult(
|
||||
parseSudokuPadPuzzle(parsed),
|
||||
"sudokupad",
|
||||
"SudokuPad/CTC JSON",
|
||||
);
|
||||
}
|
||||
return puzzleImportResult(
|
||||
parseFpuzzles(parsed),
|
||||
"fpuzzles",
|
||||
"f-puzzles JSON",
|
||||
);
|
||||
}
|
||||
return puzzleImportResult(
|
||||
parsePlainGrid(trimmed),
|
||||
"plain-grid",
|
||||
"plain grid",
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
export * from "./constraintVisuals";
|
||||
export * from "./document";
|
||||
export * from "./fpuzzles";
|
||||
export * from "./grid";
|
||||
export * from "./import";
|
||||
export * from "./interoperability";
|
||||
export * from "./penpa";
|
||||
export * from "./safeVisuals";
|
||||
export * from "./share";
|
||||
export * from "./sudokupad";
|
||||
export * from "./types";
|
||||
export * from "./visual";
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { SudokuFormatError } from "./document";
|
||||
import type { SudokuDocument, SudokuSourceFormat } from "./types";
|
||||
|
||||
export type PuzzleSourceFormat = SudokuSourceFormat;
|
||||
|
||||
export interface PuzzleMappingEntry {
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly count: number;
|
||||
}
|
||||
|
||||
export interface PuzzleImportMappingPreview {
|
||||
readonly mappedSemantics: readonly PuzzleMappingEntry[];
|
||||
readonly preservedVisuals: readonly PuzzleMappingEntry[];
|
||||
readonly preservedMetadata: readonly PuzzleMappingEntry[];
|
||||
readonly warnings: readonly string[];
|
||||
}
|
||||
|
||||
export interface PuzzleImportResult<T> {
|
||||
readonly document: T;
|
||||
readonly format: PuzzleSourceFormat;
|
||||
readonly label: string;
|
||||
readonly preview: PuzzleImportMappingPreview;
|
||||
}
|
||||
|
||||
function readableType(value: string): string {
|
||||
return value
|
||||
.split("-")
|
||||
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function buildPuzzleImportPreview(
|
||||
document: SudokuDocument,
|
||||
extraWarnings: readonly string[] = [],
|
||||
): PuzzleImportMappingPreview {
|
||||
const semanticCounts = new Map<string, number>();
|
||||
for (const constraint of document.constraints) {
|
||||
semanticCounts.set(
|
||||
constraint.type,
|
||||
(semanticCounts.get(constraint.type) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
const givenCount = document.givens.filter((value) => value !== 0).length;
|
||||
const mappedSemantics: PuzzleMappingEntry[] = [
|
||||
{ key: "givens", label: "Given digits", count: givenCount },
|
||||
...[...semanticCounts].map(([key, count]) => ({
|
||||
key,
|
||||
label: readableType(key),
|
||||
count,
|
||||
})),
|
||||
];
|
||||
if (document.regions !== undefined) {
|
||||
mappedSemantics.push({ key: "regions", label: "Region map", count: 1 });
|
||||
}
|
||||
|
||||
const visualCounts = new Map<string, number>();
|
||||
for (const visual of document.visuals ?? []) {
|
||||
const key = `${visual.layer}:${visual.type}`;
|
||||
visualCounts.set(key, (visualCounts.get(key) ?? 0) + 1);
|
||||
}
|
||||
const preservedVisuals = [...visualCounts].map(([key, count]) => {
|
||||
const [layer = "overlay", type = key] = key.split(":");
|
||||
return {
|
||||
key,
|
||||
label: `${readableType(type)} ${layer}`,
|
||||
count,
|
||||
};
|
||||
});
|
||||
|
||||
const preservedMetadata: PuzzleMappingEntry[] = [];
|
||||
if (document.source !== undefined) {
|
||||
preservedMetadata.push({
|
||||
key: "source",
|
||||
label: `Source identity (${document.source.format})`,
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
for (const key of Object.keys(document.metadata ?? {})) {
|
||||
preservedMetadata.push({ key, label: key, count: 1 });
|
||||
}
|
||||
for (const [key, present] of [
|
||||
["title", document.title !== undefined],
|
||||
["author", document.author !== undefined],
|
||||
["rules", (document.rules?.length ?? 0) > 0],
|
||||
["progress", document.values !== undefined],
|
||||
] as const) {
|
||||
if (present)
|
||||
preservedMetadata.push({ key, label: readableType(key), count: 1 });
|
||||
}
|
||||
|
||||
const warnings = [...extraWarnings];
|
||||
if (preservedVisuals.length > 0) {
|
||||
warnings.push(
|
||||
"Imported drawings are preserved as inert visuals; a visual clue is not solver-enforced unless it also appears under mapped semantics.",
|
||||
);
|
||||
}
|
||||
if (Object.keys(document.metadata ?? {}).length > 0) {
|
||||
warnings.push(
|
||||
"Uninterpreted scalar metadata is retained for round-trip export but does not change puzzle rules.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
mappedSemantics,
|
||||
preservedVisuals,
|
||||
preservedMetadata,
|
||||
warnings: [...new Set(warnings)],
|
||||
};
|
||||
}
|
||||
|
||||
export function puzzleImportResult(
|
||||
document: SudokuDocument,
|
||||
format: PuzzleSourceFormat,
|
||||
label: string,
|
||||
warnings: readonly string[] = [],
|
||||
): PuzzleImportResult<SudokuDocument> {
|
||||
const sourced =
|
||||
document.source === undefined
|
||||
? { ...document, source: { format } as const }
|
||||
: document;
|
||||
return {
|
||||
document: sourced,
|
||||
format,
|
||||
label,
|
||||
preview: buildPuzzleImportPreview(sourced, warnings),
|
||||
};
|
||||
}
|
||||
|
||||
export class UnsupportedPuzzleConstructsError extends SudokuFormatError {
|
||||
readonly format: string;
|
||||
readonly constructs: readonly string[];
|
||||
|
||||
constructor(format: string, constructs: readonly string[]) {
|
||||
const unique = [...new Set(constructs)].slice(0, 24);
|
||||
const remainder = Math.max(0, new Set(constructs).size - unique.length);
|
||||
super(
|
||||
`UNSUPPORTED_${format.toUpperCase().replaceAll(/[^A-Z0-9]+/gu, "_")}`,
|
||||
`${format} contains unsupported constructs: ${unique.join(", ")}${remainder > 0 ? `, and ${String(remainder)} more` : ""}. Import stopped rather than silently weakening or changing the puzzle.`,
|
||||
);
|
||||
this.name = "UnsupportedPuzzleConstructsError";
|
||||
this.format = format;
|
||||
this.constructs = unique;
|
||||
}
|
||||
}
|
||||
|
||||
export class RemotePuzzleReferenceError extends SudokuFormatError {
|
||||
constructor(service: string) {
|
||||
super(
|
||||
"REMOTE_PUZZLE_REFERENCE",
|
||||
`${service} is a server-hosted or shortened puzzle reference. This local-only app never fetches remote puzzle IDs; paste a self-contained link or exported puzzle data instead.`,
|
||||
);
|
||||
this.name = "RemotePuzzleReferenceError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
import {
|
||||
MAX_BOARD_SIZE,
|
||||
MAX_DOCUMENT_BYTES,
|
||||
MIN_BOARD_SIZE,
|
||||
SudokuFormatError,
|
||||
} from "./document";
|
||||
import {
|
||||
RemotePuzzleReferenceError,
|
||||
UnsupportedPuzzleConstructsError,
|
||||
} from "./interoperability";
|
||||
import {
|
||||
SUDOKU_DOCUMENT_SCHEMA,
|
||||
SUDOKU_DOCUMENT_VERSION,
|
||||
type PortableConstraint,
|
||||
type SudokuDocument,
|
||||
} from "./types";
|
||||
|
||||
export const MAX_PENPA_PAYLOAD_LENGTH = 524_288;
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
/* Penpa+ applies these substitutions before raw-deflate compression. */
|
||||
const PENPA_SUBSTITUTIONS = [
|
||||
["z", "zZ"],
|
||||
['"qa"', "z9"],
|
||||
['"pu_q"', "zQ"],
|
||||
['"pu_a"', "zA"],
|
||||
['"grid"', "zG"],
|
||||
['"edit_mode"', "zM"],
|
||||
['"surface"', "zS"],
|
||||
['"line"', "zL"],
|
||||
['"lineE"', "zE"],
|
||||
['"wall"', "zW"],
|
||||
['"cage"', "zC"],
|
||||
['"number"', "zN"],
|
||||
['"symbol"', "zY"],
|
||||
['"special"', "zP"],
|
||||
['"board"', "zB"],
|
||||
['"command_redo"', "zR"],
|
||||
['"command_undo"', "zU"],
|
||||
['"command_replay"', "z8"],
|
||||
['"numberS"', "z1"],
|
||||
['"freeline"', "zF"],
|
||||
['"freelineE"', "z2"],
|
||||
['"thermo"', "zT"],
|
||||
['"arrows"', "z3"],
|
||||
['"direction"', "zD"],
|
||||
['"squareframe"', "z0"],
|
||||
['"polygon"', "z5"],
|
||||
['"deletelineE"', "z4"],
|
||||
['"killercages"', "z6"],
|
||||
['"nobulbthermo"', "z7"],
|
||||
['"__a"', "z_"],
|
||||
["null", "zO"],
|
||||
] as const;
|
||||
|
||||
const LAYER_FIELDS = new Set([
|
||||
"command_redo",
|
||||
"command_undo",
|
||||
"command_replay",
|
||||
"surface",
|
||||
"number",
|
||||
"numberS",
|
||||
"symbol",
|
||||
"thermo",
|
||||
"arrows",
|
||||
"direction",
|
||||
"squareframe",
|
||||
"polygon",
|
||||
"line",
|
||||
"lineE",
|
||||
"wall",
|
||||
"cage",
|
||||
"freeline",
|
||||
"freelineE",
|
||||
"deletelineE",
|
||||
"killercages",
|
||||
"nobulbthermo",
|
||||
]);
|
||||
|
||||
function fail(code: string, message: string): never {
|
||||
throw new SudokuFormatError(code, message);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function boundedJson(input: string, label: string): unknown {
|
||||
if (input.length > MAX_DOCUMENT_BYTES) {
|
||||
return fail("LIMIT_EXCEEDED", `${label} is too large to open safely.`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(input) as unknown;
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_PENPA",
|
||||
`${label} is not valid JSON.`,
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function integer(
|
||||
value: unknown,
|
||||
label: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const parsed =
|
||||
typeof value === "string" && value.trim() !== "" ? Number(value) : value;
|
||||
if (
|
||||
!Number.isInteger(parsed) ||
|
||||
(parsed as number) < minimum ||
|
||||
(parsed as number) > maximum
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_PENPA",
|
||||
`${label} must be an integer from ${minimum} to ${maximum}.`,
|
||||
);
|
||||
}
|
||||
return parsed as number;
|
||||
}
|
||||
|
||||
function hasContent(value: unknown): boolean {
|
||||
if (value === undefined || value === null) return false;
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
if (isRecord(value)) {
|
||||
if (
|
||||
Object.keys(value).length === 1 &&
|
||||
isRecord(value.command_redo) &&
|
||||
Array.isArray(value.command_redo.__a)
|
||||
) {
|
||||
return value.command_redo.__a.length > 0;
|
||||
}
|
||||
return Object.keys(value).length > 0;
|
||||
}
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
function expandCenterList(value: unknown): number[] {
|
||||
if (!Array.isArray(value) || value.length > MAX_BOARD_SIZE ** 2) {
|
||||
return fail("LIMIT_EXCEEDED", "Penpa+ has an invalid or oversized grid.");
|
||||
}
|
||||
const result: number[] = [];
|
||||
let previous = 0;
|
||||
value.forEach((raw, index) => {
|
||||
const delta = integer(
|
||||
raw,
|
||||
`centerlist[${String(index)}]`,
|
||||
-100_000,
|
||||
100_000,
|
||||
);
|
||||
const point = index === 0 ? delta : previous + delta;
|
||||
if (point < 0) {
|
||||
return fail(
|
||||
"INVALID_PENPA",
|
||||
"Penpa+ centerlist contains a negative point.",
|
||||
);
|
||||
}
|
||||
result.push(point);
|
||||
previous = point;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function restoreSubstitutions(value: string): string {
|
||||
let restored = value;
|
||||
for (let index = PENPA_SUBSTITUTIONS.length - 1; index >= 0; index -= 1) {
|
||||
const substitution = PENPA_SUBSTITUTIONS[index];
|
||||
if (substitution === undefined) continue;
|
||||
restored = restored.split(substitution[1]).join(substitution[0]);
|
||||
}
|
||||
return restored;
|
||||
}
|
||||
|
||||
function decodeNumber(value: unknown, size: number): number | undefined {
|
||||
if (!Array.isArray(value) || value.length < 3 || value.length > 8) {
|
||||
return undefined;
|
||||
}
|
||||
const rawDigit = value[0];
|
||||
const type = String(value[2]);
|
||||
if (["1", "2", "4", "10"].includes(type)) {
|
||||
const parsed =
|
||||
typeof rawDigit === "string" && /^\d+$/u.test(rawDigit)
|
||||
? Number(rawDigit)
|
||||
: rawDigit;
|
||||
return Number.isInteger(parsed) && (parsed as number) >= 1 && parsed <= size
|
||||
? (parsed as number)
|
||||
: undefined;
|
||||
}
|
||||
if (type === "7" && Array.isArray(rawDigit) && rawDigit.length === size) {
|
||||
const enabled = rawDigit
|
||||
.map((flag, index) => (flag === 1 ? index + 1 : 0))
|
||||
.filter(Boolean);
|
||||
return enabled.length === 1 ? enabled[0] : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function plainMetadata(value: string | undefined, prefix: string) {
|
||||
if (value === undefined) return undefined;
|
||||
return value
|
||||
.replaceAll("%2C", ",")
|
||||
.replace(new RegExp(`^${prefix}:\\s*`, "iu"), "")
|
||||
.slice(0, 256);
|
||||
}
|
||||
|
||||
function rulesMetadata(value: string | undefined) {
|
||||
if (value === undefined || value === "") return undefined;
|
||||
return value
|
||||
.replaceAll("%2C", ",")
|
||||
.replaceAll("%2D", "\n")
|
||||
.replaceAll("%2E", "&")
|
||||
.replaceAll("%2F", "=")
|
||||
.slice(0, 16_384);
|
||||
}
|
||||
|
||||
function layer(value: unknown, label: string): JsonRecord {
|
||||
if (!isRecord(value) || Object.keys(value).length > 100) {
|
||||
return fail("INVALID_PENPA", `${label} must be a bounded object.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateLayerFields(value: JsonRecord, label: string): void {
|
||||
const unsupported = Object.keys(value)
|
||||
.filter((field) => !LAYER_FIELDS.has(field))
|
||||
.map((field) => `${label} field “${field}”`);
|
||||
if (unsupported.length > 0) {
|
||||
throw new UnsupportedPuzzleConstructsError("Penpa+", unsupported);
|
||||
}
|
||||
}
|
||||
|
||||
function parseLineConstraints(
|
||||
value: unknown,
|
||||
field: "thermo" | "arrows",
|
||||
pointToCell: ReadonlyMap<number, number>,
|
||||
): PortableConstraint[] {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value) || value.length > 5_000) {
|
||||
return fail("LIMIT_EXCEEDED", `Penpa+ ${field} data is too large.`);
|
||||
}
|
||||
return value.map((raw, index) => {
|
||||
if (
|
||||
!Array.isArray(raw) ||
|
||||
raw.length < 2 ||
|
||||
raw.length > pointToCell.size
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_PENPA",
|
||||
`Penpa+ ${field}[${String(index)}] is not a bounded cell path.`,
|
||||
);
|
||||
}
|
||||
const cells = raw.map((point, pointIndex) => {
|
||||
const parsed = integer(
|
||||
point,
|
||||
`${field}[${String(index)}][${String(pointIndex)}]`,
|
||||
0,
|
||||
1_000_000,
|
||||
);
|
||||
const cell = pointToCell.get(parsed);
|
||||
if (cell === undefined) {
|
||||
return fail(
|
||||
"UNSUPPORTED_PENPA_GEOMETRY",
|
||||
`Penpa+ ${field} uses a point outside the rectangular Sudoku grid.`,
|
||||
);
|
||||
}
|
||||
return cell;
|
||||
});
|
||||
if (new Set(cells).size !== cells.length) {
|
||||
return fail("INVALID_PENPA", `Penpa+ ${field} repeats a path cell.`);
|
||||
}
|
||||
return field === "thermo"
|
||||
? ({ type: "thermo", cells } as const)
|
||||
: ({ type: "arrow", bulb: [cells[0]!], line: cells.slice(1) } as const);
|
||||
});
|
||||
}
|
||||
|
||||
/** Parse the locally decompressed text stored in a Penpa+ long URL. */
|
||||
export function parsePenpaText(compressedText: string): SudokuDocument {
|
||||
if (
|
||||
new TextEncoder().encode(compressedText).byteLength > MAX_DOCUMENT_BYTES
|
||||
) {
|
||||
return fail(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The decompressed Penpa+ puzzle is too large to open safely.",
|
||||
);
|
||||
}
|
||||
const text = restoreSubstitutions(compressedText);
|
||||
const rows = text.split("\n");
|
||||
if (rows.length < 7 || rows.length > 32) {
|
||||
return fail("INVALID_PENPA", "The Penpa+ puzzle record is incomplete.");
|
||||
}
|
||||
const header = rows[0]?.split(",") ?? [];
|
||||
if (header.length < 15) {
|
||||
return fail("INVALID_PENPA", "The Penpa+ puzzle header is incomplete.");
|
||||
}
|
||||
const gridType = header[0]?.toLowerCase();
|
||||
if (gridType !== "square" && gridType !== "sudoku") {
|
||||
throw new UnsupportedPuzzleConstructsError("Penpa+", [
|
||||
`grid type “${gridType ?? "unknown"}”`,
|
||||
]);
|
||||
}
|
||||
const nx = integer(header[1], "Penpa+ width", 1, 100);
|
||||
const ny = integer(header[2], "Penpa+ height", 1, 100);
|
||||
const rotation = integer(header[4] ?? 0, "Penpa+ rotation", -360, 360);
|
||||
const reflectX = integer(header[5] ?? 1, "Penpa+ reflection", -1, 1);
|
||||
const reflectY = integer(header[6] ?? 1, "Penpa+ reflection", -1, 1);
|
||||
if (rotation !== 0 || reflectX !== 1 || reflectY !== 1) {
|
||||
throw new UnsupportedPuzzleConstructsError("Penpa+", [
|
||||
"rotated or reflected grids",
|
||||
]);
|
||||
}
|
||||
boundedJson(rows[1] ?? "", "Penpa+ spacing data");
|
||||
const problem = layer(
|
||||
boundedJson(rows[3] ?? "", "Penpa+ problem layer"),
|
||||
"Penpa+ problem layer",
|
||||
);
|
||||
const progress = layer(
|
||||
boundedJson(rows[4] ?? "{}", "Penpa+ answer layer"),
|
||||
"Penpa+ answer layer",
|
||||
);
|
||||
validateLayerFields(problem, "problem layer");
|
||||
validateLayerFields(progress, "answer layer");
|
||||
|
||||
const centerList = expandCenterList(
|
||||
boundedJson(rows[5] ?? "", "Penpa+ centerlist"),
|
||||
);
|
||||
if (centerList.length === 0) {
|
||||
return fail("INVALID_PENPA", "Penpa+ contains no active grid cells.");
|
||||
}
|
||||
const nx0 = nx + 4;
|
||||
const positions = centerList.map((point) => ({
|
||||
point,
|
||||
row: Math.floor(point / nx0) - 2,
|
||||
column: (point % nx0) - 2,
|
||||
}));
|
||||
const minimumRow = Math.min(...positions.map(({ row }) => row));
|
||||
const maximumRow = Math.max(...positions.map(({ row }) => row));
|
||||
const minimumColumn = Math.min(...positions.map(({ column }) => column));
|
||||
const maximumColumn = Math.max(...positions.map(({ column }) => column));
|
||||
const height = maximumRow - minimumRow + 1;
|
||||
const width = maximumColumn - minimumColumn + 1;
|
||||
if (
|
||||
width !== height ||
|
||||
width < MIN_BOARD_SIZE ||
|
||||
width > MAX_BOARD_SIZE ||
|
||||
positions.length !== width * height ||
|
||||
width > nx ||
|
||||
height > ny
|
||||
) {
|
||||
throw new UnsupportedPuzzleConstructsError("Penpa+", [
|
||||
"masked, non-square or unsupported-size Sudoku grids",
|
||||
]);
|
||||
}
|
||||
const pointToCell = new Map<number, number>();
|
||||
for (const position of positions) {
|
||||
const row = position.row - minimumRow;
|
||||
const column = position.column - minimumColumn;
|
||||
const cell = row * width + column;
|
||||
if (
|
||||
pointToCell.has(position.point) ||
|
||||
[...pointToCell.values()].includes(cell)
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_PENPA",
|
||||
"Penpa+ centerlist contains duplicate cells.",
|
||||
);
|
||||
}
|
||||
pointToCell.set(position.point, cell);
|
||||
}
|
||||
|
||||
const unsupported: string[] = [];
|
||||
const supportedFields = new Set(["number", "thermo", "arrows"]);
|
||||
for (const [field, raw] of Object.entries(problem)) {
|
||||
if (
|
||||
!supportedFields.has(field) &&
|
||||
!field.startsWith("command_") &&
|
||||
hasContent(raw)
|
||||
) {
|
||||
unsupported.push(`problem ${field}`);
|
||||
}
|
||||
}
|
||||
for (const [field, raw] of Object.entries(progress)) {
|
||||
if (
|
||||
field !== "number" &&
|
||||
!field.startsWith("command_") &&
|
||||
hasContent(raw)
|
||||
) {
|
||||
unsupported.push(`answer ${field}`);
|
||||
}
|
||||
}
|
||||
if (unsupported.length > 0) {
|
||||
throw new UnsupportedPuzzleConstructsError("Penpa+", unsupported);
|
||||
}
|
||||
|
||||
const givens = Array<number>(width * width).fill(0);
|
||||
const values = Array<number>(width * width).fill(0);
|
||||
const parseNumbers = (
|
||||
raw: unknown,
|
||||
target: number[],
|
||||
requireGivenStyle: boolean,
|
||||
label: string,
|
||||
) => {
|
||||
if (raw === undefined) return;
|
||||
if (!isRecord(raw) || Object.keys(raw).length > width * width * 4) {
|
||||
return fail("LIMIT_EXCEEDED", `Penpa+ ${label} numbers are too large.`);
|
||||
}
|
||||
const unsupportedNumbers: string[] = [];
|
||||
for (const [pointText, entry] of Object.entries(raw)) {
|
||||
const point = integer(pointText, `${label} point`, 0, 1_000_000);
|
||||
const cell = pointToCell.get(point);
|
||||
const digit = decodeNumber(entry, width);
|
||||
const style = Array.isArray(entry) ? entry[1] : undefined;
|
||||
if (
|
||||
cell === undefined ||
|
||||
digit === undefined ||
|
||||
(requireGivenStyle && style !== 1)
|
||||
) {
|
||||
unsupportedNumbers.push(
|
||||
`${label} number or style at point ${pointText}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
target[cell] = digit;
|
||||
}
|
||||
if (unsupportedNumbers.length > 0) {
|
||||
throw new UnsupportedPuzzleConstructsError("Penpa+", unsupportedNumbers);
|
||||
}
|
||||
};
|
||||
parseNumbers(problem.number, givens, true, "problem");
|
||||
values.splice(0, values.length, ...givens);
|
||||
parseNumbers(progress.number, values, false, "answer");
|
||||
for (const [cell, given] of givens.entries()) {
|
||||
if (given !== 0) values[cell] = given;
|
||||
}
|
||||
|
||||
const constraints = [
|
||||
...parseLineConstraints(problem.thermo, "thermo", pointToCell),
|
||||
...parseLineConstraints(problem.arrows, "arrows", pointToCell),
|
||||
];
|
||||
const title = plainMetadata(header[15], "Title");
|
||||
const author = plainMetadata(header[16], "Author");
|
||||
const rules = rulesMetadata(header[18]);
|
||||
return {
|
||||
schema: SUDOKU_DOCUMENT_SCHEMA,
|
||||
version: SUDOKU_DOCUMENT_VERSION,
|
||||
size: width,
|
||||
givens,
|
||||
values,
|
||||
constraints,
|
||||
source: { format: "penpa" },
|
||||
...(title === undefined || title === "" ? {} : { title }),
|
||||
...(author === undefined || author === "" ? {} : { author }),
|
||||
...(rules === undefined ? {} : { rules: [rules] }),
|
||||
};
|
||||
}
|
||||
|
||||
function extractPenpaPayload(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (trimmed.startsWith("penpa:")) return trimmed.slice("penpa:".length);
|
||||
let params: URLSearchParams;
|
||||
if (/^https?:\/\//iu.test(trimmed)) {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(trimmed);
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError("INVALID_PENPA_URL", "Invalid Penpa+ URL.", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
if (!url.pathname.includes("/penpa-edit/")) {
|
||||
throw new RemotePuzzleReferenceError("The pasted URL");
|
||||
}
|
||||
params = new URLSearchParams(
|
||||
url.hash.length > 1 ? url.hash.slice(1) : url.search.slice(1),
|
||||
);
|
||||
} else {
|
||||
params = new URLSearchParams(trimmed.replace(/^[?#]/u, ""));
|
||||
}
|
||||
const payload = params.get("p");
|
||||
if (payload === null || payload.startsWith("http")) {
|
||||
throw new RemotePuzzleReferenceError("The Penpa+ link");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function base64Bytes(payload: string): Uint8Array {
|
||||
const normalized = payload.replaceAll(" ", "+");
|
||||
if (
|
||||
normalized.length === 0 ||
|
||||
normalized.length > MAX_PENPA_PAYLOAD_LENGTH ||
|
||||
!/^[A-Za-z0-9+/]*={0,2}$/u.test(normalized)
|
||||
) {
|
||||
return fail("INVALID_PENPA", "The Penpa+ payload is invalid or too large.");
|
||||
}
|
||||
let binary: string;
|
||||
try {
|
||||
binary = atob(normalized);
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_PENPA",
|
||||
"The Penpa+ payload is not valid base64.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
async function inflateRaw(bytes: Uint8Array): Promise<string> {
|
||||
if (typeof DecompressionStream === "undefined") {
|
||||
return fail(
|
||||
"UNSUPPORTED_BROWSER",
|
||||
"This browser cannot decompress Penpa+ data locally.",
|
||||
);
|
||||
}
|
||||
let stream: ReadableStream<Uint8Array>;
|
||||
try {
|
||||
const input = new ArrayBuffer(bytes.byteLength);
|
||||
new Uint8Array(input).set(bytes);
|
||||
const body = new Response(input).body;
|
||||
if (body === null) {
|
||||
return fail("INVALID_PENPA", "The Penpa+ payload stream is empty.");
|
||||
}
|
||||
stream = body.pipeThrough(new DecompressionStream("deflate-raw"));
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_PENPA",
|
||||
"The Penpa+ raw-deflate stream could not be opened.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
const reader = stream.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
total += chunk.value.byteLength;
|
||||
if (total > MAX_DOCUMENT_BYTES) {
|
||||
await reader.cancel();
|
||||
return fail(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The decompressed Penpa+ puzzle is too large to open safely.",
|
||||
);
|
||||
}
|
||||
chunks.push(chunk.value);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_PENPA",
|
||||
"The Penpa+ payload could not be decompressed.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
const output = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
output.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(output);
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_PENPA",
|
||||
"The decompressed Penpa+ payload is not valid UTF-8 text.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Import a self-contained Penpa+ long URL without making a network request. */
|
||||
export async function importPenpa(input: string): Promise<SudokuDocument> {
|
||||
return parsePenpaText(
|
||||
await inflateRaw(base64Bytes(extractPenpaPayload(input))),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+24
-11
@@ -1,7 +1,8 @@
|
||||
import { compressToEncodedURIComponent } from "lz-string";
|
||||
import {
|
||||
compressToEncodedURIComponent,
|
||||
decompressFromEncodedURIComponent,
|
||||
} from "lz-string";
|
||||
LzStringOutputLimitError,
|
||||
decompressFromEncodedURIComponentBounded,
|
||||
} from "./boundedLz";
|
||||
import {
|
||||
MAX_DOCUMENT_BYTES,
|
||||
SudokuFormatError,
|
||||
@@ -49,18 +50,30 @@ export function decodePuzzleHash(hashOrUrl: string): SudokuDocument {
|
||||
);
|
||||
}
|
||||
const compressed = hash.slice(PUZZLE_HASH_PREFIX.length);
|
||||
const json = decompressFromEncodedURIComponent(compressed);
|
||||
let json: string | null;
|
||||
try {
|
||||
json = decompressFromEncodedURIComponentBounded(
|
||||
compressed,
|
||||
MAX_DOCUMENT_BYTES,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof LzStringOutputLimitError) {
|
||||
throw new SudokuFormatError(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The decompressed puzzle is too large to open safely.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (json === null || json === "") {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_SHARE",
|
||||
"The share payload could not be decompressed.",
|
||||
);
|
||||
}
|
||||
if (new TextEncoder().encode(json).byteLength > MAX_DOCUMENT_BYTES) {
|
||||
throw new SudokuFormatError(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The decompressed puzzle is too large to open safely.",
|
||||
);
|
||||
}
|
||||
return parseSudokuDocument(json);
|
||||
const document = parseSudokuDocument(json);
|
||||
return document.source === undefined
|
||||
? { ...document, source: { format: "sudoku-tools" } }
|
||||
: document;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+320
-25
@@ -1,45 +1,221 @@
|
||||
import type { PortableAidMemoire } from "../state/aidMemoire";
|
||||
|
||||
export type {
|
||||
PortableAidMemoire,
|
||||
PortableAidMemoireCell,
|
||||
} from "../state/aidMemoire";
|
||||
|
||||
export const SUDOKU_DOCUMENT_SCHEMA =
|
||||
"de.add-ideas.sudoku-tools.puzzle" as const;
|
||||
export const SUDOKU_DOCUMENT_VERSION = 1 as const;
|
||||
|
||||
export type CellIndex = number;
|
||||
export type OutsideSide = "top" | "right" | "bottom" | "left";
|
||||
export type SudokuSourceFormat =
|
||||
"sudoku-tools" | "plain-grid" | "fpuzzles" | "sudokupad" | "penpa";
|
||||
|
||||
/** Provenance only; it is never dereferenced or fetched by Sudoku Tools. */
|
||||
export interface SudokuSourceIdentity {
|
||||
readonly format: SudokuSourceFormat;
|
||||
readonly id?: string;
|
||||
readonly version?: string;
|
||||
}
|
||||
|
||||
export type ScalarMetadataValue = string | number | boolean | null;
|
||||
export type ScalarMetadata = Readonly<Record<string, ScalarMetadataValue>>;
|
||||
|
||||
/**
|
||||
* Grid-relative anchor used by inert visual primitives. Coordinates have their
|
||||
* origin at the grid's top-left corner; one unit equals one cell.
|
||||
*/
|
||||
export type SafeVisualAnchor =
|
||||
| {
|
||||
readonly kind: "coordinate";
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
}
|
||||
| {
|
||||
readonly kind: "cell";
|
||||
readonly cell: CellIndex;
|
||||
readonly offsetX?: number;
|
||||
readonly offsetY?: number;
|
||||
};
|
||||
|
||||
/** Only these inert presentation properties can cross an import boundary. */
|
||||
export interface SafeVisualStyle {
|
||||
/** A normalized hexadecimal colour or `transparent`. */
|
||||
readonly stroke?: string;
|
||||
/** A normalized hexadecimal colour or `transparent`. */
|
||||
readonly fill?: string;
|
||||
/** Grid-relative width, not a CSS value. */
|
||||
readonly strokeWidth?: number;
|
||||
readonly opacity?: number;
|
||||
/** Grid-relative size, not a CSS value. */
|
||||
readonly fontSize?: number;
|
||||
}
|
||||
|
||||
interface SafeVisualBase {
|
||||
readonly layer: "underlay" | "overlay";
|
||||
readonly style?: SafeVisualStyle;
|
||||
}
|
||||
|
||||
export type SafeVisualPrimitive =
|
||||
| (SafeVisualBase & {
|
||||
readonly type: "line";
|
||||
readonly start: SafeVisualAnchor;
|
||||
readonly end: SafeVisualAnchor;
|
||||
})
|
||||
| (SafeVisualBase & {
|
||||
readonly type: "polyline";
|
||||
readonly points: readonly SafeVisualAnchor[];
|
||||
readonly closed?: boolean;
|
||||
})
|
||||
| (SafeVisualBase & {
|
||||
readonly type: "rectangle";
|
||||
readonly center: SafeVisualAnchor;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly cornerRadius?: number;
|
||||
})
|
||||
| (SafeVisualBase & {
|
||||
readonly type: "ellipse";
|
||||
readonly center: SafeVisualAnchor;
|
||||
readonly radiusX: number;
|
||||
readonly radiusY: number;
|
||||
})
|
||||
| (SafeVisualBase & {
|
||||
readonly type: "circle";
|
||||
readonly center: SafeVisualAnchor;
|
||||
readonly radius: number;
|
||||
})
|
||||
| (SafeVisualBase & {
|
||||
readonly type: "text";
|
||||
readonly position: SafeVisualAnchor;
|
||||
readonly text: string;
|
||||
});
|
||||
|
||||
export interface CluePolarity {
|
||||
/** When true, the completed clue must be false rather than true. */
|
||||
readonly negated?: boolean;
|
||||
}
|
||||
|
||||
export type PortableConstraint =
|
||||
| { readonly type: "diagonal"; readonly direction: "main" | "anti" }
|
||||
| { readonly type: "anti-knight" }
|
||||
| { readonly type: "anti-king" }
|
||||
| { readonly type: "non-consecutive" }
|
||||
| {
|
||||
| { readonly type: "disjoint-groups" }
|
||||
| ({
|
||||
readonly type: "killer-cage";
|
||||
readonly cells: readonly CellIndex[];
|
||||
readonly sum: number;
|
||||
readonly noRepeat?: boolean;
|
||||
}
|
||||
| { readonly type: "thermo"; readonly cells: readonly CellIndex[] }
|
||||
| {
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "thermo";
|
||||
readonly cells: readonly CellIndex[];
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "arrow";
|
||||
readonly bulb: readonly CellIndex[];
|
||||
readonly line: readonly CellIndex[];
|
||||
}
|
||||
| {
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "kropki";
|
||||
readonly a: CellIndex;
|
||||
readonly b: CellIndex;
|
||||
readonly kind: "white" | "black";
|
||||
}
|
||||
| {
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "xv";
|
||||
readonly a: CellIndex;
|
||||
readonly b: CellIndex;
|
||||
readonly total: 5 | 10;
|
||||
}
|
||||
| {
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "inequality";
|
||||
readonly lesser: CellIndex;
|
||||
readonly greater: CellIndex;
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "x-sum";
|
||||
readonly side: OutsideSide;
|
||||
readonly index: number;
|
||||
readonly sum: number;
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "skyscraper";
|
||||
readonly side: OutsideSide;
|
||||
readonly index: number;
|
||||
readonly count: number;
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "quadruple";
|
||||
readonly cells: readonly CellIndex[];
|
||||
readonly digits: readonly number[];
|
||||
} & CluePolarity)
|
||||
| ({ readonly type: "maximum"; readonly cell: CellIndex } & CluePolarity)
|
||||
| ({ readonly type: "minimum"; readonly cell: CellIndex } & CluePolarity)
|
||||
| ({ readonly type: "odd"; readonly cell: CellIndex } & CluePolarity)
|
||||
| ({ readonly type: "even"; readonly cell: CellIndex } & CluePolarity)
|
||||
| ({
|
||||
readonly type: "little-killer";
|
||||
readonly side: OutsideSide;
|
||||
readonly index: number;
|
||||
readonly direction: "down-right" | "down-left" | "up-right" | "up-left";
|
||||
readonly sum: number;
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "sandwich";
|
||||
readonly side: OutsideSide;
|
||||
readonly index: number;
|
||||
readonly sum: number;
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "between-line";
|
||||
readonly cells: readonly CellIndex[];
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "german-whisper";
|
||||
readonly cells: readonly CellIndex[];
|
||||
readonly minimumDifference?: number;
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "region-sum-line";
|
||||
readonly cells: readonly CellIndex[];
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "clone";
|
||||
readonly cells: readonly CellIndex[];
|
||||
readonly cloneCells: readonly CellIndex[];
|
||||
} & CluePolarity)
|
||||
| {
|
||||
readonly type: "extra-region";
|
||||
readonly cells: readonly CellIndex[];
|
||||
}
|
||||
| { readonly type: "renban"; readonly cells: readonly CellIndex[] }
|
||||
| { readonly type: "palindrome"; readonly cells: readonly CellIndex[] };
|
||||
| ({
|
||||
readonly type:
|
||||
"modular-line" | "entropic-line" | "zipper-line" | "double-arrow";
|
||||
readonly cells: readonly CellIndex[];
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "indexer";
|
||||
readonly kind: "row" | "column" | "box";
|
||||
readonly cell: CellIndex;
|
||||
} & CluePolarity)
|
||||
| {
|
||||
readonly type: "fog";
|
||||
readonly lights: readonly CellIndex[];
|
||||
readonly revealRadius?: 0 | 1;
|
||||
}
|
||||
| ({
|
||||
readonly type: "renban";
|
||||
readonly cells: readonly CellIndex[];
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "palindrome";
|
||||
readonly cells: readonly CellIndex[];
|
||||
} & CluePolarity);
|
||||
|
||||
/**
|
||||
* Stable, versioned interchange format owned by Sudoku Tools. Cell indices are
|
||||
@@ -57,6 +233,8 @@ export interface SudokuDocument {
|
||||
readonly candidates?: readonly (readonly number[])[];
|
||||
readonly colors?: readonly number[];
|
||||
readonly elapsedMs?: number;
|
||||
/** Optional, non-constraining scratch cells used while solving. */
|
||||
readonly aidMemoire?: PortableAidMemoire;
|
||||
readonly solution?: readonly number[];
|
||||
readonly regions?: readonly number[];
|
||||
readonly constraints: readonly PortableConstraint[];
|
||||
@@ -66,6 +244,12 @@ export interface SudokuDocument {
|
||||
/** Named boolean/global rules which cannot be represented by a local shape. */
|
||||
readonly globalRules?: readonly string[];
|
||||
readonly id?: string;
|
||||
/** Inert, bounded drawings retained without accepting source SVG or HTML. */
|
||||
readonly visuals?: readonly SafeVisualPrimitive[];
|
||||
/** Original local interchange identity; never used as a network location. */
|
||||
readonly source?: SudokuSourceIdentity;
|
||||
/** Uninterpreted bounded scalar source metadata. */
|
||||
readonly metadata?: ScalarMetadata;
|
||||
}
|
||||
|
||||
/** Minimal structural type accepted by the domain adapter. */
|
||||
@@ -108,24 +292,113 @@ export function toDomainPuzzle(document: SudokuDocument): DomainPuzzleShape {
|
||||
};
|
||||
}
|
||||
|
||||
export function fromDomainPuzzle(puzzle: DomainPuzzleShape): SudokuDocument {
|
||||
export type PreservedSudokuDocumentExtras = Pick<
|
||||
SudokuDocument,
|
||||
"visuals" | "source" | "metadata"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Copy the source-only portion of a document before adapting it to the domain
|
||||
* puzzle model. An undefined result lets callers avoid carrying empty state.
|
||||
*/
|
||||
export function extractPreservedDocumentExtras(
|
||||
document: SudokuDocument,
|
||||
): PreservedSudokuDocumentExtras | undefined {
|
||||
if (
|
||||
document.visuals === undefined &&
|
||||
document.source === undefined &&
|
||||
document.metadata === undefined
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
schema: SUDOKU_DOCUMENT_SCHEMA,
|
||||
version: SUDOKU_DOCUMENT_VERSION,
|
||||
size: puzzle.size,
|
||||
givens: [...puzzle.givens],
|
||||
constraints: (puzzle.constraints ?? []).map(cloneConstraint),
|
||||
...(puzzle.regions === undefined ? {} : { regions: [...puzzle.regions] }),
|
||||
...(puzzle.title === undefined ? {} : { title: puzzle.title }),
|
||||
...(puzzle.author === undefined ? {} : { author: puzzle.author }),
|
||||
...(puzzle.id === undefined ? {} : { id: puzzle.id }),
|
||||
...(puzzle.rules === undefined ? {} : { rules: [puzzle.rules] }),
|
||||
...(puzzle.solution === undefined
|
||||
...(document.visuals === undefined
|
||||
? {}
|
||||
: { solution: [...puzzle.solution] }),
|
||||
: { visuals: document.visuals.map(cloneVisualPrimitive) }),
|
||||
...(document.source === undefined
|
||||
? {}
|
||||
: { source: { ...document.source } }),
|
||||
...(document.metadata === undefined
|
||||
? {}
|
||||
: { metadata: { ...document.metadata } }),
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneVisualPrimitive(
|
||||
primitive: SafeVisualPrimitive,
|
||||
): SafeVisualPrimitive {
|
||||
const style =
|
||||
primitive.style === undefined ? {} : { style: { ...primitive.style } };
|
||||
switch (primitive.type) {
|
||||
case "line":
|
||||
return {
|
||||
...primitive,
|
||||
start: { ...primitive.start },
|
||||
end: { ...primitive.end },
|
||||
...style,
|
||||
};
|
||||
case "polyline":
|
||||
return {
|
||||
...primitive,
|
||||
points: primitive.points.map((point) => ({ ...point })),
|
||||
...style,
|
||||
};
|
||||
case "rectangle":
|
||||
case "ellipse":
|
||||
case "circle":
|
||||
return { ...primitive, center: { ...primitive.center }, ...style };
|
||||
case "text":
|
||||
return { ...primitive, position: { ...primitive.position }, ...style };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge data which the domain model intentionally cannot carry. Callers keep
|
||||
* ownership of the returned arrays and records.
|
||||
*/
|
||||
export function mergePreservedDocumentExtras(
|
||||
document: SudokuDocument,
|
||||
preserved?: Partial<PreservedSudokuDocumentExtras>,
|
||||
): SudokuDocument {
|
||||
if (preserved === undefined) return document;
|
||||
return {
|
||||
...document,
|
||||
...(preserved.visuals === undefined
|
||||
? {}
|
||||
: { visuals: preserved.visuals.map(cloneVisualPrimitive) }),
|
||||
...(preserved.source === undefined
|
||||
? {}
|
||||
: { source: { ...preserved.source } }),
|
||||
...(preserved.metadata === undefined
|
||||
? {}
|
||||
: { metadata: { ...preserved.metadata } }),
|
||||
};
|
||||
}
|
||||
|
||||
export function fromDomainPuzzle(
|
||||
puzzle: DomainPuzzleShape,
|
||||
preserved?: Partial<PreservedSudokuDocumentExtras>,
|
||||
): SudokuDocument {
|
||||
return mergePreservedDocumentExtras(
|
||||
{
|
||||
schema: SUDOKU_DOCUMENT_SCHEMA,
|
||||
version: SUDOKU_DOCUMENT_VERSION,
|
||||
size: puzzle.size,
|
||||
givens: [...puzzle.givens],
|
||||
constraints: (puzzle.constraints ?? []).map(cloneConstraint),
|
||||
...(puzzle.regions === undefined ? {} : { regions: [...puzzle.regions] }),
|
||||
...(puzzle.title === undefined ? {} : { title: puzzle.title }),
|
||||
...(puzzle.author === undefined ? {} : { author: puzzle.author }),
|
||||
...(puzzle.id === undefined ? {} : { id: puzzle.id }),
|
||||
...(puzzle.rules === undefined ? {} : { rules: [puzzle.rules] }),
|
||||
...(puzzle.solution === undefined
|
||||
? {}
|
||||
: { solution: [...puzzle.solution] }),
|
||||
},
|
||||
preserved,
|
||||
);
|
||||
}
|
||||
|
||||
export function cloneConstraint(
|
||||
constraint: PortableConstraint,
|
||||
): PortableConstraint {
|
||||
@@ -135,7 +408,29 @@ export function cloneConstraint(
|
||||
case "thermo":
|
||||
case "renban":
|
||||
case "palindrome":
|
||||
case "between-line":
|
||||
case "german-whisper":
|
||||
case "region-sum-line":
|
||||
case "extra-region":
|
||||
case "modular-line":
|
||||
case "entropic-line":
|
||||
case "zipper-line":
|
||||
case "double-arrow":
|
||||
return { ...constraint, cells: [...constraint.cells] };
|
||||
case "fog":
|
||||
return { ...constraint, lights: [...constraint.lights] };
|
||||
case "clone":
|
||||
return {
|
||||
...constraint,
|
||||
cells: [...constraint.cells],
|
||||
cloneCells: [...constraint.cloneCells],
|
||||
};
|
||||
case "quadruple":
|
||||
return {
|
||||
...constraint,
|
||||
cells: [...constraint.cells],
|
||||
digits: [...constraint.digits],
|
||||
};
|
||||
case "arrow":
|
||||
return {
|
||||
...constraint,
|
||||
|
||||
@@ -0,0 +1,866 @@
|
||||
import { littleKillerCells } from "../domain/geometry";
|
||||
import { normalizePuzzle } from "../domain/validation";
|
||||
import type {
|
||||
NormalizedPuzzle,
|
||||
OutsideClueSide,
|
||||
VariantConstraint,
|
||||
} from "../domain/types";
|
||||
import { symbolFor } from "../state/session";
|
||||
import { normalizeSudokuDocument, SudokuFormatError } from "./document";
|
||||
import {
|
||||
toDomainPuzzle,
|
||||
type SafeVisualAnchor,
|
||||
type SafeVisualPrimitive,
|
||||
type SudokuDocument,
|
||||
} from "./types";
|
||||
|
||||
export interface VisualExportOptions {
|
||||
readonly includeProgress?: boolean;
|
||||
readonly includeNotes?: boolean;
|
||||
readonly rasterScale?: number;
|
||||
}
|
||||
|
||||
export const MAX_VISUAL_EXPORT_DIMENSION = 4_096;
|
||||
export const MAX_VISUAL_EXPORT_BYTES = 5_242_880;
|
||||
|
||||
const CELL_SIZE = 72;
|
||||
const OUTSIDE_MARGIN = 58;
|
||||
const BOARD_MARGIN = 20;
|
||||
const HEADER_HEIGHT = 74;
|
||||
|
||||
function escapeXml(value: unknown): string {
|
||||
const unicode = new TextDecoder().decode(
|
||||
new TextEncoder().encode(String(value)),
|
||||
);
|
||||
const xmlCharacters = [...unicode]
|
||||
.map((character) => {
|
||||
const codePoint = character.codePointAt(0)!;
|
||||
return codePoint === 0x09 ||
|
||||
codePoint === 0x0a ||
|
||||
codePoint === 0x0d ||
|
||||
(codePoint >= 0x20 && codePoint <= 0xd7ff) ||
|
||||
(codePoint >= 0xe000 && codePoint <= 0xfffd) ||
|
||||
(codePoint >= 0x1_0000 && codePoint <= 0x10_ffff)
|
||||
? character
|
||||
: "�";
|
||||
})
|
||||
.join("");
|
||||
return xmlCharacters
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function number(value: number): string {
|
||||
return Number(value.toFixed(3)).toString();
|
||||
}
|
||||
|
||||
interface BoardOrigin {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
}
|
||||
|
||||
function point(size: number, cell: number, origin: BoardOrigin) {
|
||||
return {
|
||||
x: origin.x + ((cell % size) + 0.5) * CELL_SIZE,
|
||||
y: origin.y + (Math.floor(cell / size) + 0.5) * CELL_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
function linePoints(
|
||||
size: number,
|
||||
cells: readonly number[],
|
||||
origin: BoardOrigin,
|
||||
) {
|
||||
return cells
|
||||
.map((cell) => {
|
||||
const position = point(size, cell, origin);
|
||||
return `${number(position.x)},${number(position.y)}`;
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function safeVisualPoint(
|
||||
size: number,
|
||||
anchor: SafeVisualAnchor,
|
||||
origin: BoardOrigin,
|
||||
) {
|
||||
if (anchor.kind === "coordinate") {
|
||||
return {
|
||||
x: origin.x + anchor.x * CELL_SIZE,
|
||||
y: origin.y + anchor.y * CELL_SIZE,
|
||||
};
|
||||
}
|
||||
const center = point(size, anchor.cell, origin);
|
||||
return {
|
||||
x: center.x + (anchor.offsetX ?? 0) * CELL_SIZE,
|
||||
y: center.y + (anchor.offsetY ?? 0) * CELL_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
function renderSafeVisual(
|
||||
visual: SafeVisualPrimitive,
|
||||
size: number,
|
||||
origin: BoardOrigin,
|
||||
index: number,
|
||||
): string {
|
||||
const style = visual.style ?? {};
|
||||
const attributes = [
|
||||
`class="source-visual source-visual--${visual.type}"`,
|
||||
`data-visual="${String(index)}"`,
|
||||
`stroke="${escapeXml(style.stroke ?? "transparent")}"`,
|
||||
`fill="${escapeXml(style.fill ?? "transparent")}"`,
|
||||
...(style.strokeWidth === undefined
|
||||
? []
|
||||
: [`stroke-width="${number(style.strokeWidth * CELL_SIZE)}"`]),
|
||||
...(style.opacity === undefined
|
||||
? []
|
||||
: [`opacity="${number(style.opacity)}"`]),
|
||||
].join(" ");
|
||||
if (visual.type === "line") {
|
||||
const start = safeVisualPoint(size, visual.start, origin);
|
||||
const end = safeVisualPoint(size, visual.end, origin);
|
||||
return `<line ${attributes} x1="${number(start.x)}" y1="${number(start.y)}" x2="${number(end.x)}" y2="${number(end.y)}"/>`;
|
||||
}
|
||||
if (visual.type === "polyline") {
|
||||
const points = visual.points
|
||||
.map((anchor) => safeVisualPoint(size, anchor, origin))
|
||||
.map(({ x, y }) => `${number(x)},${number(y)}`)
|
||||
.join(" ");
|
||||
return `<${visual.closed === true ? "polygon" : "polyline"} ${attributes} points="${points}"/>`;
|
||||
}
|
||||
if (visual.type === "rectangle") {
|
||||
const center = safeVisualPoint(size, visual.center, origin);
|
||||
const width = visual.width * CELL_SIZE;
|
||||
const height = visual.height * CELL_SIZE;
|
||||
return `<rect ${attributes} x="${number(center.x - width / 2)}" y="${number(center.y - height / 2)}" width="${number(width)}" height="${number(height)}"${visual.cornerRadius === undefined ? "" : ` rx="${number(visual.cornerRadius * CELL_SIZE)}"`}/>`;
|
||||
}
|
||||
if (visual.type === "ellipse") {
|
||||
const center = safeVisualPoint(size, visual.center, origin);
|
||||
return `<ellipse ${attributes} cx="${number(center.x)}" cy="${number(center.y)}" rx="${number(visual.radiusX * CELL_SIZE)}" ry="${number(visual.radiusY * CELL_SIZE)}"/>`;
|
||||
}
|
||||
if (visual.type === "circle") {
|
||||
const center = safeVisualPoint(size, visual.center, origin);
|
||||
return `<circle ${attributes} cx="${number(center.x)}" cy="${number(center.y)}" r="${number(visual.radius * CELL_SIZE)}"/>`;
|
||||
}
|
||||
const position = safeVisualPoint(size, visual.position, origin);
|
||||
return `<text ${attributes} x="${number(position.x)}" y="${number(position.y)}"${style.fontSize === undefined ? "" : ` font-size="${number(style.fontSize * CELL_SIZE)}"`}>${escapeXml(visual.text)}</text>`;
|
||||
}
|
||||
|
||||
function pathBoundary(
|
||||
size: number,
|
||||
cells: ReadonlySet<number>,
|
||||
origin: BoardOrigin,
|
||||
inset: number,
|
||||
): string {
|
||||
const commands: string[] = [];
|
||||
for (const cell of cells) {
|
||||
const row = Math.floor(cell / size);
|
||||
const column = cell % size;
|
||||
const x0 = origin.x + column * CELL_SIZE + inset;
|
||||
const x1 = origin.x + (column + 1) * CELL_SIZE - inset;
|
||||
const y0 = origin.y + row * CELL_SIZE + inset;
|
||||
const y1 = origin.y + (row + 1) * CELL_SIZE - inset;
|
||||
if (row === 0 || !cells.has(cell - size))
|
||||
commands.push(`M${number(x0)} ${number(y0)}H${number(x1)}`);
|
||||
if (row === size - 1 || !cells.has(cell + size))
|
||||
commands.push(`M${number(x0)} ${number(y1)}H${number(x1)}`);
|
||||
if (column === 0 || !cells.has(cell - 1))
|
||||
commands.push(`M${number(x0)} ${number(y0)}V${number(y1)}`);
|
||||
if (column === size - 1 || !cells.has(cell + 1))
|
||||
commands.push(`M${number(x1)} ${number(y0)}V${number(y1)}`);
|
||||
}
|
||||
return commands.join(" ");
|
||||
}
|
||||
|
||||
function outsidePoint(
|
||||
size: number,
|
||||
side: OutsideClueSide,
|
||||
index: number,
|
||||
origin: BoardOrigin,
|
||||
) {
|
||||
const board = size * CELL_SIZE;
|
||||
switch (side) {
|
||||
case "top":
|
||||
return {
|
||||
x: origin.x + (index + 0.5) * CELL_SIZE,
|
||||
y: origin.y - OUTSIDE_MARGIN * 0.52,
|
||||
};
|
||||
case "right":
|
||||
return {
|
||||
x: origin.x + board + OUTSIDE_MARGIN * 0.52,
|
||||
y: origin.y + (index + 0.5) * CELL_SIZE,
|
||||
};
|
||||
case "bottom":
|
||||
return {
|
||||
x: origin.x + (index + 0.5) * CELL_SIZE,
|
||||
y: origin.y + board + OUTSIDE_MARGIN * 0.52,
|
||||
};
|
||||
case "left":
|
||||
return {
|
||||
x: origin.x - OUTSIDE_MARGIN * 0.52,
|
||||
y: origin.y + (index + 0.5) * CELL_SIZE,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function globalRuleLabels(puzzle: NormalizedPuzzle): string[] {
|
||||
const labels: string[] = [];
|
||||
if (puzzle.constraints.some(({ type }) => type === "anti-knight"))
|
||||
labels.push("Anti-knight");
|
||||
if (puzzle.constraints.some(({ type }) => type === "anti-king"))
|
||||
labels.push("Anti-king");
|
||||
if (puzzle.constraints.some(({ type }) => type === "non-consecutive"))
|
||||
labels.push("Non-consecutive");
|
||||
if (puzzle.constraints.some(({ type }) => type === "disjoint-groups"))
|
||||
labels.push("Disjoint groups");
|
||||
return labels;
|
||||
}
|
||||
|
||||
function renderConstraint(
|
||||
constraint: VariantConstraint,
|
||||
index: number,
|
||||
puzzle: NormalizedPuzzle,
|
||||
origin: BoardOrigin,
|
||||
): string {
|
||||
const { size } = puzzle;
|
||||
const negated = "negated" in constraint && constraint.negated === true;
|
||||
const polarity = negated ? " negated" : "";
|
||||
const marker = negated ? "≠" : "";
|
||||
if (
|
||||
constraint.type === "anti-knight" ||
|
||||
constraint.type === "anti-king" ||
|
||||
constraint.type === "non-consecutive" ||
|
||||
constraint.type === "disjoint-groups"
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
if (
|
||||
constraint.type === "modular-line" ||
|
||||
constraint.type === "entropic-line"
|
||||
) {
|
||||
return `<g class="constraint ${constraint.type}${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, constraint.cells, origin)}"/></g>`;
|
||||
}
|
||||
if (constraint.type === "zipper-line") {
|
||||
const center = point(
|
||||
size,
|
||||
constraint.cells[Math.floor(constraint.cells.length / 2)]!,
|
||||
origin,
|
||||
);
|
||||
return `<g class="constraint zipper-line${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, constraint.cells, origin)}"/><circle cx="${number(center.x)}" cy="${number(center.y)}" r="${number(CELL_SIZE * 0.22)}"/></g>`;
|
||||
}
|
||||
if (constraint.type === "double-arrow") {
|
||||
const first = point(size, constraint.cells[0]!, origin);
|
||||
const last = point(size, constraint.cells.at(-1)!, origin);
|
||||
return `<g class="constraint double-arrow${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, constraint.cells, origin)}"/><circle cx="${number(first.x)}" cy="${number(first.y)}" r="${number(CELL_SIZE * 0.22)}"/><circle cx="${number(last.x)}" cy="${number(last.y)}" r="${number(CELL_SIZE * 0.22)}"/></g>`;
|
||||
}
|
||||
if (constraint.type === "indexer") {
|
||||
const center = point(size, constraint.cell, origin);
|
||||
const label =
|
||||
constraint.kind === "row"
|
||||
? "R"
|
||||
: constraint.kind === "column"
|
||||
? "C"
|
||||
: "B";
|
||||
return `<g class="constraint indexer indexer-${constraint.kind}${polarity}" data-constraint="${index}"><rect x="${number(center.x - CELL_SIZE * 0.24)}" y="${number(center.y - CELL_SIZE * 0.24)}" width="${number(CELL_SIZE * 0.48)}" height="${number(CELL_SIZE * 0.48)}" rx="${number(CELL_SIZE * 0.1)}"/><text x="${number(center.x)}" y="${number(center.y)}">${marker}${label}</text></g>`;
|
||||
}
|
||||
if (constraint.type === "fog") return "";
|
||||
if (constraint.type === "diagonal") {
|
||||
const startX =
|
||||
origin.x + (constraint.direction === "main" ? 0 : size * CELL_SIZE);
|
||||
const endX =
|
||||
origin.x + (constraint.direction === "main" ? size * CELL_SIZE : 0);
|
||||
return `<line class="constraint diagonal" x1="${number(startX)}" y1="${number(origin.y)}" x2="${number(endX)}" y2="${number(origin.y + size * CELL_SIZE)}"/>`;
|
||||
}
|
||||
if (constraint.type === "killer-cage") {
|
||||
const first = Math.min(...constraint.cells);
|
||||
const label = point(size, first, origin);
|
||||
return `<g class="constraint cage${polarity}" data-constraint="${index}"><path d="${pathBoundary(size, new Set(constraint.cells), origin, 7)}"/><text class="cage-label" x="${number(label.x - CELL_SIZE * 0.34)}" y="${number(label.y - CELL_SIZE * 0.27)}">${marker}${String(constraint.sum)}</text></g>`;
|
||||
}
|
||||
if (constraint.type === "extra-region") {
|
||||
return `<g class="constraint extra-region" data-constraint="${index}"><path d="${pathBoundary(size, new Set(constraint.cells), origin, 4)}"/></g>`;
|
||||
}
|
||||
if (constraint.type === "thermo") {
|
||||
const bulb = point(size, constraint.cells[0]!, origin);
|
||||
return `<g class="constraint thermo${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, constraint.cells, origin)}"/><circle cx="${number(bulb.x)}" cy="${number(bulb.y)}" r="${number(CELL_SIZE * 0.3)}"/>${negated ? `<text class="false-marker" x="${number(bulb.x)}" y="${number(bulb.y)}">≠</text>` : ""}</g>`;
|
||||
}
|
||||
if (constraint.type === "arrow") {
|
||||
const bulbPoints = constraint.bulb.map((cell) => point(size, cell, origin));
|
||||
const bulb = bulbPoints[0]!;
|
||||
const path = [constraint.bulb.at(-1)!, ...constraint.line];
|
||||
const tip = point(size, constraint.line.at(-1)!, origin);
|
||||
const minimumX = Math.min(...bulbPoints.map(({ x }) => x));
|
||||
const maximumX = Math.max(...bulbPoints.map(({ x }) => x));
|
||||
const minimumY = Math.min(...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>`;
|
||||
}
|
||||
if (
|
||||
constraint.type === "renban" ||
|
||||
constraint.type === "palindrome" ||
|
||||
constraint.type === "german-whisper" ||
|
||||
constraint.type === "region-sum-line"
|
||||
) {
|
||||
return `<g class="constraint ${constraint.type}${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, constraint.cells, origin)}"/>${
|
||||
constraint.type === "palindrome"
|
||||
? constraint.cells
|
||||
.map((cell) => {
|
||||
const center = point(size, cell, origin);
|
||||
return `<circle cx="${number(center.x)}" cy="${number(center.y)}" r="${number(CELL_SIZE * 0.13)}"/>`;
|
||||
})
|
||||
.join("")
|
||||
: ""
|
||||
}</g>`;
|
||||
}
|
||||
if (constraint.type === "between-line") {
|
||||
const first = point(size, constraint.cells[0]!, origin);
|
||||
const last = point(size, constraint.cells.at(-1)!, origin);
|
||||
return `<g class="constraint between-line${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, constraint.cells, origin)}"/><circle cx="${number(first.x)}" cy="${number(first.y)}" r="${number(CELL_SIZE * 0.27)}"/><circle cx="${number(last.x)}" cy="${number(last.y)}" r="${number(CELL_SIZE * 0.27)}"/>${negated ? `<text class="false-marker" x="${number(first.x)}" y="${number(first.y)}">≠</text>` : ""}</g>`;
|
||||
}
|
||||
if (constraint.type === "clone") {
|
||||
const original = new Set(constraint.cells);
|
||||
const clone = new Set(constraint.cloneCells);
|
||||
const connectors = constraint.cells
|
||||
.map((cell, pairIndex) => {
|
||||
const a = point(size, cell, origin);
|
||||
const b = point(size, constraint.cloneCells[pairIndex]!, origin);
|
||||
return `<line x1="${number(a.x)}" y1="${number(a.y)}" x2="${number(b.x)}" y2="${number(b.y)}"/>`;
|
||||
})
|
||||
.join("");
|
||||
return `<g class="constraint clone${polarity}" data-constraint="${index}">${connectors}<path d="${pathBoundary(size, original, origin, 7)}"/><path d="${pathBoundary(size, clone, origin, 7)}"/>${negated ? `<text class="false-marker" x="${number(point(size, constraint.cells[0]!, origin).x)}" y="${number(point(size, constraint.cells[0]!, origin).y)}">≠</text>` : ""}</g>`;
|
||||
}
|
||||
if (constraint.type === "maximum") {
|
||||
const center = point(size, constraint.cell, origin);
|
||||
const offset = CELL_SIZE * 0.28;
|
||||
const inner = CELL_SIZE * 0.14;
|
||||
return `<g class="constraint maximum${polarity}" data-constraint="${index}"><circle cx="${number(center.x)}" cy="${number(center.y)}" r="${number(CELL_SIZE * 0.18)}"/><path d="M${number(center.x - inner)} ${number(center.y - inner)}L${number(center.x)} ${number(center.y - offset)}L${number(center.x + inner)} ${number(center.y - inner)}M${number(center.x - inner)} ${number(center.y + inner)}L${number(center.x)} ${number(center.y + offset)}L${number(center.x + inner)} ${number(center.y + inner)}M${number(center.x - inner)} ${number(center.y - inner)}L${number(center.x - offset)} ${number(center.y)}L${number(center.x - inner)} ${number(center.y + inner)}M${number(center.x + inner)} ${number(center.y - inner)}L${number(center.x + offset)} ${number(center.y)}L${number(center.x + inner)} ${number(center.y + inner)}"/></g>`;
|
||||
}
|
||||
if (constraint.type === "minimum") {
|
||||
const center = point(size, constraint.cell, origin);
|
||||
const offset = CELL_SIZE * 0.28;
|
||||
const inner = CELL_SIZE * 0.14;
|
||||
return `<g class="constraint minimum${polarity}" data-constraint="${index}"><circle cx="${number(center.x)}" cy="${number(center.y)}" r="${number(CELL_SIZE * 0.18)}"/><path d="M${number(center.x - offset)} ${number(center.y - offset)}L${number(center.x - inner)} ${number(center.y - inner)}M${number(center.x + offset)} ${number(center.y - offset)}L${number(center.x + inner)} ${number(center.y - inner)}M${number(center.x - offset)} ${number(center.y + offset)}L${number(center.x - inner)} ${number(center.y + inner)}M${number(center.x + offset)} ${number(center.y + offset)}L${number(center.x + inner)} ${number(center.y + inner)}"/>${negated ? `<text class="false-marker" x="${number(center.x)}" y="${number(center.y)}">≠</text>` : ""}</g>`;
|
||||
}
|
||||
if (constraint.type === "odd" || constraint.type === "even") {
|
||||
const center = point(size, constraint.cell, origin);
|
||||
const shape =
|
||||
constraint.type === "odd"
|
||||
? `<circle cx="${number(center.x)}" cy="${number(center.y)}" r="${number(CELL_SIZE * 0.19)}"/>`
|
||||
: `<rect x="${number(center.x - CELL_SIZE * 0.19)}" y="${number(center.y - CELL_SIZE * 0.19)}" width="${number(CELL_SIZE * 0.38)}" height="${number(CELL_SIZE * 0.38)}" rx="${number(CELL_SIZE * 0.035)}"/>`;
|
||||
return `<g class="constraint parity ${constraint.type}${polarity}" data-constraint="${index}">${shape}${negated ? `<text class="false-marker" x="${number(center.x)}" y="${number(center.y)}">≠</text>` : ""}</g>`;
|
||||
}
|
||||
if (constraint.type === "quadruple") {
|
||||
const positions = constraint.cells.map((cell) => point(size, cell, origin));
|
||||
const center = {
|
||||
x:
|
||||
positions.reduce((sum, position) => sum + position.x, 0) /
|
||||
positions.length,
|
||||
y:
|
||||
positions.reduce((sum, position) => sum + position.y, 0) /
|
||||
positions.length,
|
||||
};
|
||||
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" ||
|
||||
constraint.type === "sandwich"
|
||||
) {
|
||||
const position = outsidePoint(
|
||||
size,
|
||||
constraint.side,
|
||||
constraint.index,
|
||||
origin,
|
||||
);
|
||||
const value =
|
||||
constraint.type === "skyscraper" ? constraint.count : constraint.sum;
|
||||
const companionIndex = puzzle.constraints.findIndex(
|
||||
(candidate) =>
|
||||
constraint.type !== "sandwich" &&
|
||||
candidate.type !== constraint.type &&
|
||||
(candidate.type === "x-sum" || candidate.type === "skyscraper") &&
|
||||
candidate.side === constraint.side &&
|
||||
candidate.index === constraint.index &&
|
||||
(candidate.type === "x-sum" ? candidate.sum : candidate.count) ===
|
||||
value &&
|
||||
("negated" in candidate && candidate.negated === true) === negated,
|
||||
);
|
||||
const combined = companionIndex >= 0;
|
||||
if (combined && companionIndex < index) return "";
|
||||
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>`;
|
||||
}
|
||||
const symbol =
|
||||
constraint.type === "x-sum"
|
||||
? "Σ"
|
||||
: constraint.type === "skyscraper"
|
||||
? "▥"
|
||||
: "1⋯N ";
|
||||
return `<g class="constraint outside ${constraint.type}${polarity}" data-constraint="${index}"><rect x="${number(position.x - 25)}" y="${number(position.y - 18)}" width="50" height="36" rx="8"/><text x="${number(position.x)}" y="${number(position.y)}">${symbol}${marker}${String(value)}</text></g>`;
|
||||
}
|
||||
if (constraint.type === "little-killer") {
|
||||
const position = outsidePoint(
|
||||
size,
|
||||
constraint.side,
|
||||
constraint.index,
|
||||
origin,
|
||||
);
|
||||
const cells = littleKillerCells(
|
||||
size,
|
||||
constraint.side,
|
||||
constraint.index,
|
||||
constraint.direction,
|
||||
);
|
||||
const first = point(size, cells[0]!, origin);
|
||||
return `<g class="constraint outside little-killer${polarity}" data-constraint="${index}"><text x="${number(position.x)}" y="${number(position.y)}">${marker}${String(constraint.sum)}</text><line x1="${number(position.x)}" y1="${number(position.y)}" x2="${number(first.x)}" y2="${number(first.y)}"/><circle class="arrow-tip" cx="${number(first.x)}" cy="${number(first.y)}" r="${number(CELL_SIZE * 0.06)}"/></g>`;
|
||||
}
|
||||
if (
|
||||
constraint.type !== "kropki" &&
|
||||
constraint.type !== "xv" &&
|
||||
constraint.type !== "inequality"
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
const a = point(
|
||||
size,
|
||||
constraint.type === "inequality" ? constraint.lesser : constraint.a,
|
||||
origin,
|
||||
);
|
||||
const b = point(
|
||||
size,
|
||||
constraint.type === "inequality" ? constraint.greater : constraint.b,
|
||||
origin,
|
||||
);
|
||||
const middle = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||||
if (constraint.type === "kropki") {
|
||||
return `<g class="constraint kropki ${constraint.kind}${polarity}" data-constraint="${index}"><circle cx="${number(middle.x)}" cy="${number(middle.y)}" r="${number(CELL_SIZE * 0.11)}"/>${negated ? `<text class="pair-false-marker" x="${number(middle.x)}" y="${number(middle.y)}">×</text>` : ""}</g>`;
|
||||
}
|
||||
if (constraint.type === "xv") {
|
||||
return `<g class="constraint xv total-${String(constraint.total)}${polarity}" data-constraint="${index}"><circle cx="${number(middle.x)}" cy="${number(middle.y)}" r="${number(CELL_SIZE * 0.19)}"/><text x="${number(middle.x)}" y="${number(middle.y)}">${marker}${String(constraint.total)}</text></g>`;
|
||||
}
|
||||
const dx = a.x - b.x;
|
||||
const dy = a.y - b.y;
|
||||
const length = Math.hypot(dx, dy) || 1;
|
||||
const ux = dx / length;
|
||||
const uy = dy / length;
|
||||
const perpendicular = { x: -uy, y: ux };
|
||||
const tip = {
|
||||
x: middle.x + ux * CELL_SIZE * 0.15,
|
||||
y: middle.y + uy * CELL_SIZE * 0.15,
|
||||
};
|
||||
const back = {
|
||||
x: middle.x - ux * CELL_SIZE * 0.15,
|
||||
y: middle.y - uy * CELL_SIZE * 0.15,
|
||||
};
|
||||
return `<g class="constraint inequality${polarity}" data-constraint="${index}"><polyline points="${number(back.x + perpendicular.x * CELL_SIZE * 0.14)},${number(back.y + perpendicular.y * CELL_SIZE * 0.14)} ${number(tip.x)},${number(tip.y)} ${number(back.x - perpendicular.x * CELL_SIZE * 0.14)},${number(back.y - perpendicular.y * CELL_SIZE * 0.14)}"/><circle class="inequality-tip" cx="${number(tip.x)}" cy="${number(tip.y)}" r="${number(CELL_SIZE * 0.035)}"/></g>`;
|
||||
}
|
||||
|
||||
function renderRegionBoundaries(puzzle: NormalizedPuzzle, origin: BoardOrigin) {
|
||||
const { size, regions } = puzzle;
|
||||
const lines: string[] = [];
|
||||
const board = size * CELL_SIZE;
|
||||
lines.push(
|
||||
`<rect x="${origin.x}" y="${origin.y}" width="${board}" height="${board}"/>`,
|
||||
);
|
||||
for (let row = 0; row < size; row += 1) {
|
||||
for (let column = 0; column < size; column += 1) {
|
||||
const cell = row * size + column;
|
||||
if (column < size - 1 && regions[cell] !== regions[cell + 1]) {
|
||||
const x = origin.x + (column + 1) * CELL_SIZE;
|
||||
const y = origin.y + row * CELL_SIZE;
|
||||
lines.push(
|
||||
`<line x1="${x}" y1="${y}" x2="${x}" y2="${y + CELL_SIZE}"/>`,
|
||||
);
|
||||
}
|
||||
if (row < size - 1 && regions[cell] !== regions[cell + size]) {
|
||||
const x = origin.x + column * CELL_SIZE;
|
||||
const y = origin.y + (row + 1) * CELL_SIZE;
|
||||
lines.push(
|
||||
`<line x1="${x}" y1="${y}" x2="${x + CELL_SIZE}" y2="${y}"/>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return `<g class="region-boundaries">${lines.join("")}</g>`;
|
||||
}
|
||||
|
||||
const COLOR_FILLS = [
|
||||
"transparent",
|
||||
"#fff2a8",
|
||||
"#ffd0c7",
|
||||
"#ccefd8",
|
||||
"#cde6ff",
|
||||
"#e3d4ff",
|
||||
"#ffe0b7",
|
||||
"#cdeeed",
|
||||
"#f3d0e6",
|
||||
];
|
||||
|
||||
/** Render a standalone, print-friendly SVG without external resources. */
|
||||
export function renderPuzzleSvg(
|
||||
document: SudokuDocument,
|
||||
options: VisualExportOptions = {},
|
||||
): string {
|
||||
const normalizedDocument = normalizeSudokuDocument(document);
|
||||
const puzzle = normalizePuzzle(toDomainPuzzle(normalizedDocument));
|
||||
const includeProgress = options.includeProgress ?? true;
|
||||
const includeNotes = options.includeNotes ?? includeProgress;
|
||||
const hasOutside = puzzle.constraints.some(
|
||||
({ type }) =>
|
||||
type === "x-sum" ||
|
||||
type === "skyscraper" ||
|
||||
type === "little-killer" ||
|
||||
type === "sandwich",
|
||||
);
|
||||
const sideMargin = hasOutside ? OUTSIDE_MARGIN : BOARD_MARGIN;
|
||||
const boardSize = puzzle.size * CELL_SIZE;
|
||||
const width = boardSize + sideMargin * 2;
|
||||
const height = HEADER_HEIGHT + boardSize + sideMargin * 2;
|
||||
const origin: BoardOrigin = {
|
||||
x: sideMargin,
|
||||
y: sideMargin + HEADER_HEIGHT,
|
||||
};
|
||||
const title = (normalizedDocument.title ?? "Sudoku").slice(0, 256);
|
||||
const byline = normalizedDocument.author
|
||||
? `by ${normalizedDocument.author.slice(0, 256)}`
|
||||
: "Sudoku Tools";
|
||||
const globals = globalRuleLabels(puzzle);
|
||||
const values = includeProgress
|
||||
? (normalizedDocument.values ?? puzzle.givens)
|
||||
: puzzle.givens;
|
||||
const cornerMarks = includeNotes
|
||||
? (normalizedDocument.cornerMarks ?? [])
|
||||
: [];
|
||||
const centerMarks = includeNotes
|
||||
? (normalizedDocument.centerMarks ?? normalizedDocument.candidates ?? [])
|
||||
: [];
|
||||
const colors = includeProgress ? (normalizedDocument.colors ?? []) : [];
|
||||
const cellCount = puzzle.size * puzzle.size;
|
||||
if (
|
||||
values.length !== cellCount ||
|
||||
(normalizedDocument.colors !== undefined &&
|
||||
normalizedDocument.colors.length !== cellCount)
|
||||
) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_VISUAL_EXPORT",
|
||||
"Puzzle progress does not match the grid size.",
|
||||
);
|
||||
}
|
||||
|
||||
const backgrounds = Array.from({ length: cellCount }, (_, cell) => {
|
||||
const color = colors[cell] ?? 0;
|
||||
const fill = COLOR_FILLS[color] ?? "transparent";
|
||||
if (fill === "transparent") return "";
|
||||
const row = Math.floor(cell / puzzle.size);
|
||||
const column = cell % puzzle.size;
|
||||
return `<rect class="cell-color" x="${origin.x + column * CELL_SIZE}" y="${origin.y + row * CELL_SIZE}" width="${CELL_SIZE}" height="${CELL_SIZE}" fill="${fill}"/>`;
|
||||
}).join("");
|
||||
|
||||
const gridLines = Array.from({ length: puzzle.size - 1 }, (_, index) => {
|
||||
const offset = (index + 1) * CELL_SIZE;
|
||||
return `<path d="M${origin.x + offset} ${origin.y}V${origin.y + boardSize}M${origin.x} ${origin.y + offset}H${origin.x + boardSize}"/>`;
|
||||
}).join("");
|
||||
const constraints = puzzle.constraints
|
||||
.map((constraint, index) =>
|
||||
renderConstraint(constraint, index, puzzle, origin),
|
||||
)
|
||||
.join("");
|
||||
const sourceUnderlays = (normalizedDocument.visuals ?? [])
|
||||
.map((visual, index) => ({ visual, index }))
|
||||
.filter(({ visual }) => visual.layer === "underlay")
|
||||
.map(({ visual, index }) =>
|
||||
renderSafeVisual(visual, puzzle.size, origin, index),
|
||||
)
|
||||
.join("");
|
||||
const sourceOverlays = (normalizedDocument.visuals ?? [])
|
||||
.map((visual, index) => ({ visual, index }))
|
||||
.filter(({ visual }) => visual.layer === "overlay")
|
||||
.map(({ visual, index }) =>
|
||||
renderSafeVisual(visual, puzzle.size, origin, index),
|
||||
)
|
||||
.join("");
|
||||
const digits = Array.from({ length: cellCount }, (_, cell) => {
|
||||
const value = puzzle.givens[cell] || values[cell] || 0;
|
||||
const center = point(puzzle.size, cell, origin);
|
||||
if (value !== 0) {
|
||||
if (!Number.isInteger(value) || value < 1 || value > puzzle.size) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_VISUAL_EXPORT",
|
||||
`Cell ${String(cell + 1)} contains an out-of-range value.`,
|
||||
);
|
||||
}
|
||||
return `<text class="cell-value ${puzzle.givens[cell] ? "given" : "progress"}" x="${number(center.x)}" y="${number(center.y)}">${escapeXml(symbolFor(value, puzzle.size))}</text>`;
|
||||
}
|
||||
const corner = cornerMarks[cell] ?? [];
|
||||
const centerNotes = centerMarks[cell] ?? [];
|
||||
const cornerText = corner
|
||||
.slice(0, puzzle.size)
|
||||
.map((digit, noteIndex) => {
|
||||
const columns = Math.ceil(Math.sqrt(puzzle.size));
|
||||
const x =
|
||||
center.x -
|
||||
CELL_SIZE * 0.39 +
|
||||
((noteIndex % columns) * (CELL_SIZE * 0.78)) /
|
||||
Math.max(1, columns - 1);
|
||||
const y =
|
||||
center.y -
|
||||
CELL_SIZE * 0.34 +
|
||||
Math.floor(noteIndex / columns) * CELL_SIZE * 0.19;
|
||||
return `<text class="corner-note" x="${number(x)}" y="${number(y)}">${escapeXml(symbolFor(digit, puzzle.size))}</text>`;
|
||||
})
|
||||
.join("");
|
||||
const centerText = centerNotes.length
|
||||
? `<text class="center-note" x="${number(center.x)}" y="${number(center.y)}">${escapeXml(centerNotes.map((digit) => symbolFor(digit, puzzle.size)).join(""))}</text>`
|
||||
: "";
|
||||
return cornerText + centerText;
|
||||
}).join("");
|
||||
|
||||
const svg = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-labelledby="title description">
|
||||
<title id="title">${escapeXml(title)}</title>
|
||||
<desc id="description">${escapeXml(`${String(puzzle.size)} by ${String(puzzle.size)} Sudoku${includeProgress ? " with current progress" : ""}`)}</desc>
|
||||
<style>
|
||||
.background{fill:#fff}.heading{fill:#172033;font-family:system-ui,sans-serif}.title{font-size:28px;font-weight:750}.byline,.global-rules{fill:#5d6678;font-size:14px}.source-visual{stroke-linecap:round;stroke-linejoin:round}.source-visual--text{dominant-baseline:central;text-anchor:middle;font-family:system-ui,sans-serif}.grid-lines{fill:none;stroke:#a5acb8;stroke-width:1}.region-boundaries{fill:none;stroke:#172033;stroke-width:3;stroke-linecap:square}.constraint{fill:none;stroke:#596273;stroke-width:6;stroke-linecap:round;stroke-linejoin:round}.constraint text{dominant-baseline:central;text-anchor:middle;fill:#172033;stroke:none;font-family:system-ui,sans-serif;font-weight:700}.constraint.negated{stroke:#c63f52;stroke-dasharray:10 7}.constraint.negated text{fill:#a5283b}.diagonal{stroke:#4da3d6;stroke-width:3}.cage{stroke-width:2.2;stroke-dasharray:6 5}.cage-label{font-size:14px;text-anchor:start!important}.extra-region{stroke:#6d62b5;stroke-width:3;stroke-dasharray:10 5}.thermo{stroke:#c3c7ce;stroke-width:20}.thermo circle{fill:#c3c7ce;stroke:none}.thermo.negated{stroke-width:13}.false-marker{font-size:22px}.arrow{stroke:#929aa7;stroke-width:5}.arrow rect{fill:#fff;stroke:#929aa7}.arrow-tip{fill:#929aa7;stroke:none}.renban{stroke:#b55b8c;stroke-width:14;opacity:.72}.palindrome{stroke:#9ba1ad;stroke-width:13}.palindrome circle{fill:#d8dbe1;stroke:none}.german-whisper{stroke:#4d9b69;stroke-width:10}.region-sum-line{stroke:#4a94a3;stroke-width:7}.between-line{stroke:#7e8796;stroke-width:5}.between-line circle{fill:#fff;stroke:#7e8796;stroke-width:5}.modular-line{stroke:#167f8f;stroke-width:10;stroke-dasharray:18 7}.entropic-line{stroke:#d47742;stroke-width:10;stroke-dasharray:3 7}.zipper-line{stroke:#7b60ad;stroke-width:7}.zipper-line circle{fill:#fff;stroke:#7b60ad;stroke-width:5}.double-arrow{stroke:#596273;stroke-width:5}.double-arrow circle{fill:#fff;stroke:#596273;stroke-width:4}.indexer{stroke-width:2}.indexer rect{fill:#fff}.indexer-row{stroke:#287fba}.indexer-column{stroke:#b94c50}.indexer-box{stroke:#438b58}.indexer text{font-size:15px}.clone{stroke:#6d62b5;stroke-width:2.5;stroke-dasharray:8 5}.clone line{stroke-width:1.5;stroke-dasharray:4 5;opacity:.45}.maximum circle,.minimum circle{fill:#eef0f4;stroke:#596273;stroke-width:2}.maximum path,.minimum path{stroke-width:3}.parity{stroke:#596273;stroke-width:2}.parity circle,.parity rect{fill:#eef0f4}.quadruple circle{fill:#fff;stroke:#596273;stroke-width:2}.quadruple text{font-size:15px}.outside rect{fill:#fff;stroke:#788191;stroke-width:1.5}.outside text{font-size:17px}.outside .outside-kinds{font-size:12px}.outside .outside-value{font-size:16px}.little-killer line{stroke-width:2}.little-killer .arrow-tip{fill:#596273}.kropki circle{stroke:#172033;stroke-width:2}.kropki.white circle{fill:#fff}.kropki.black circle{fill:#172033}.pair-false-marker{fill:#c63f52!important;font-size:14px}.xv circle{fill:#fff;stroke:#fff}.xv text{font-size:18px}.inequality{stroke:#172033;stroke-width:4}.inequality-tip{fill:#172033;stroke:none}.cell-value,.corner-note,.center-note{dominant-baseline:central;text-anchor:middle;font-family:system-ui,sans-serif}.cell-value{fill:#273c75;font-size:${number(CELL_SIZE * 0.58)}px}.cell-value.given{fill:#111827;font-weight:800}.cell-value.progress{font-weight:600}.corner-note{fill:#596273;font-size:${number(CELL_SIZE * 0.15)}px}.center-note{fill:#596273;font-size:${number(CELL_SIZE * 0.2)}px;letter-spacing:1px}
|
||||
</style>
|
||||
<rect class="background" width="100%" height="100%"/>
|
||||
<g class="heading"><text class="title" x="${origin.x}" y="34">${escapeXml(title)}</text><text class="byline" x="${origin.x}" y="57">${escapeXml(byline)}</text>${globals.length > 0 ? `<text class="global-rules" x="${width - sideMargin}" y="57" text-anchor="end">${escapeXml(globals.join(" · "))}</text>` : ""}</g>
|
||||
<g>${backgrounds}${sourceUnderlays}<g class="grid-lines">${gridLines}</g>${constraints}${renderRegionBoundaries(puzzle, origin)}${digits}${sourceOverlays}</g>
|
||||
</svg>`;
|
||||
if (new TextEncoder().encode(svg).byteLength > MAX_VISUAL_EXPORT_BYTES) {
|
||||
throw new SudokuFormatError(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The rendered SVG is too large to export safely.",
|
||||
);
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
|
||||
function svgDimensions(svg: string): { width: number; height: number } {
|
||||
const match = /<svg[^>]+width="(\d+)"[^>]+height="(\d+)"/u.exec(svg);
|
||||
if (match === null) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_VISUAL_EXPORT",
|
||||
"The generated SVG has no dimensions.",
|
||||
);
|
||||
}
|
||||
return { width: Number(match[1]), height: Number(match[2]) };
|
||||
}
|
||||
|
||||
async function loadSvgImage(svg: string): Promise<CanvasImageSource> {
|
||||
const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
|
||||
if (typeof createImageBitmap === "function") {
|
||||
try {
|
||||
return await createImageBitmap(blob);
|
||||
} catch {
|
||||
// Safari and some hardened browsers cannot decode SVG through ImageBitmap.
|
||||
}
|
||||
}
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
return await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () =>
|
||||
reject(new Error("The SVG image could not be decoded."));
|
||||
image.src = url;
|
||||
});
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
async function rasterizeSvg(
|
||||
svg: string,
|
||||
scale: number,
|
||||
): Promise<HTMLCanvasElement> {
|
||||
if (!Number.isFinite(scale) || scale < 0.5 || scale > 4) {
|
||||
throw new RangeError("rasterScale must be from 0.5 to 4.");
|
||||
}
|
||||
const dimensions = svgDimensions(svg);
|
||||
const boundedScale = Math.min(
|
||||
scale,
|
||||
MAX_VISUAL_EXPORT_DIMENSION / Math.max(dimensions.width, dimensions.height),
|
||||
);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.max(1, Math.round(dimensions.width * boundedScale));
|
||||
canvas.height = Math.max(1, Math.round(dimensions.height * boundedScale));
|
||||
const context = canvas.getContext("2d");
|
||||
if (context === null) {
|
||||
throw new SudokuFormatError(
|
||||
"UNSUPPORTED_BROWSER",
|
||||
"This browser cannot create a canvas for visual export.",
|
||||
);
|
||||
}
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
const image = await loadSvgImage(svg);
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
if ("close" in image && typeof image.close === "function") image.close();
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function canvasBlob(
|
||||
canvas: HTMLCanvasElement,
|
||||
type: "image/png" | "image/jpeg",
|
||||
quality?: number,
|
||||
): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) =>
|
||||
blob === null
|
||||
? reject(new Error(`The browser could not encode ${type}.`))
|
||||
: resolve(blob),
|
||||
type,
|
||||
quality,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function renderPuzzlePng(
|
||||
document: SudokuDocument,
|
||||
options: VisualExportOptions = {},
|
||||
): Promise<Blob> {
|
||||
const canvas = await rasterizeSvg(
|
||||
renderPuzzleSvg(document, options),
|
||||
options.rasterScale ?? 2,
|
||||
);
|
||||
const blob = await canvasBlob(canvas, "image/png");
|
||||
if (blob.size > MAX_VISUAL_EXPORT_BYTES) {
|
||||
throw new SudokuFormatError(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The rendered PNG is too large to export safely.",
|
||||
);
|
||||
}
|
||||
return blob;
|
||||
}
|
||||
|
||||
function ascii(value: string): Uint8Array {
|
||||
return new TextEncoder().encode(value);
|
||||
}
|
||||
|
||||
function concatenate(parts: readonly Uint8Array[]): Uint8Array {
|
||||
const length = parts.reduce((sum, part) => sum + part.byteLength, 0);
|
||||
const output = new Uint8Array(length);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
output.set(part, offset);
|
||||
offset += part.byteLength;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/** Build a single-page PDF around browser-generated JPEG bytes. */
|
||||
export function buildJpegPdf(
|
||||
jpeg: Uint8Array,
|
||||
imageWidth: number,
|
||||
imageHeight: number,
|
||||
): Uint8Array {
|
||||
if (
|
||||
jpeg.byteLength < 4 ||
|
||||
jpeg.byteLength > MAX_VISUAL_EXPORT_BYTES ||
|
||||
jpeg[0] !== 0xff ||
|
||||
jpeg[1] !== 0xd8 ||
|
||||
jpeg.at(-2) !== 0xff ||
|
||||
jpeg.at(-1) !== 0xd9
|
||||
) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_VISUAL_EXPORT",
|
||||
"The PDF renderer did not receive a bounded JPEG image.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(imageWidth) ||
|
||||
!Number.isInteger(imageHeight) ||
|
||||
imageWidth < 1 ||
|
||||
imageHeight < 1 ||
|
||||
imageWidth > MAX_VISUAL_EXPORT_DIMENSION ||
|
||||
imageHeight > MAX_VISUAL_EXPORT_DIMENSION
|
||||
) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_VISUAL_EXPORT",
|
||||
"The PDF image dimensions are invalid.",
|
||||
);
|
||||
}
|
||||
const pageWidth = 595;
|
||||
const pageHeight = 842;
|
||||
const maximumWidth = pageWidth - 64;
|
||||
const maximumHeight = pageHeight - 64;
|
||||
const scale = Math.min(
|
||||
maximumWidth / imageWidth,
|
||||
maximumHeight / imageHeight,
|
||||
);
|
||||
const drawWidth = imageWidth * scale;
|
||||
const drawHeight = imageHeight * scale;
|
||||
const x = (pageWidth - drawWidth) / 2;
|
||||
const y = (pageHeight - drawHeight) / 2;
|
||||
const content = `q\n${number(drawWidth)} 0 0 ${number(drawHeight)} ${number(x)} ${number(y)} cm\n/Im0 Do\nQ\n`;
|
||||
const objects: Uint8Array[] = [
|
||||
ascii("<< /Type /Catalog /Pages 2 0 R >>"),
|
||||
ascii("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"),
|
||||
ascii(
|
||||
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageWidth} ${pageHeight}] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>`,
|
||||
),
|
||||
concatenate([
|
||||
ascii(
|
||||
`<< /Type /XObject /Subtype /Image /Width ${String(imageWidth)} /Height ${String(imageHeight)} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${String(jpeg.byteLength)} >>\nstream\n`,
|
||||
),
|
||||
jpeg,
|
||||
ascii("\nendstream"),
|
||||
]),
|
||||
ascii(
|
||||
`<< /Length ${String(ascii(content).byteLength)} >>\nstream\n${content}endstream`,
|
||||
),
|
||||
];
|
||||
const parts: Uint8Array[] = [
|
||||
new Uint8Array([
|
||||
...ascii("%PDF-1.4\n%"),
|
||||
0xe2,
|
||||
0xe3,
|
||||
0xcf,
|
||||
0xd3,
|
||||
...ascii("\n"),
|
||||
]),
|
||||
];
|
||||
const offsets = [0];
|
||||
let byteOffset = parts[0]!.byteLength;
|
||||
objects.forEach((object, index) => {
|
||||
offsets.push(byteOffset);
|
||||
const wrapped = concatenate([
|
||||
ascii(`${String(index + 1)} 0 obj\n`),
|
||||
object,
|
||||
ascii("\nendobj\n"),
|
||||
]);
|
||||
parts.push(wrapped);
|
||||
byteOffset += wrapped.byteLength;
|
||||
});
|
||||
const xrefOffset = byteOffset;
|
||||
const xref = [
|
||||
`xref\n0 ${String(objects.length + 1)}\n`,
|
||||
"0000000000 65535 f \n",
|
||||
...offsets
|
||||
.slice(1)
|
||||
.map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`),
|
||||
`trailer\n<< /Size ${String(objects.length + 1)} /Root 1 0 R >>\nstartxref\n${String(xrefOffset)}\n%%EOF\n`,
|
||||
].join("");
|
||||
parts.push(ascii(xref));
|
||||
return concatenate(parts);
|
||||
}
|
||||
|
||||
export async function renderPuzzlePdf(
|
||||
document: SudokuDocument,
|
||||
options: VisualExportOptions = {},
|
||||
): Promise<Blob> {
|
||||
const canvas = await rasterizeSvg(
|
||||
renderPuzzleSvg(document, options),
|
||||
options.rasterScale ?? 2,
|
||||
);
|
||||
const jpeg = new Uint8Array(
|
||||
await (await canvasBlob(canvas, "image/jpeg", 0.94)).arrayBuffer(),
|
||||
);
|
||||
const pdf = buildJpegPdf(jpeg, canvas.width, canvas.height);
|
||||
const buffer = new ArrayBuffer(pdf.byteLength);
|
||||
new Uint8Array(buffer).set(pdf);
|
||||
return new Blob([buffer], { type: "application/pdf" });
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import {
|
||||
compilePuzzle,
|
||||
type CompiledPuzzle,
|
||||
type NormalizedPuzzle,
|
||||
type SudokuUnit,
|
||||
type UnitKind,
|
||||
} from "../domain";
|
||||
|
||||
export interface CandidateNode {
|
||||
readonly cell: number;
|
||||
readonly value: number;
|
||||
}
|
||||
|
||||
export type CandidateLinkKind = "strong" | "weak";
|
||||
|
||||
export interface CandidateLinkContext {
|
||||
readonly kind: "cell" | "house";
|
||||
readonly label: string;
|
||||
readonly unitKind?: UnitKind;
|
||||
readonly unitIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A graph edge between two candidates. Strong edges mean that at least one
|
||||
* endpoint must be true; weak edges mean that both endpoints cannot be true.
|
||||
* A strong Sudoku link is also weak, but is rendered once as the more useful
|
||||
* strong relationship.
|
||||
*/
|
||||
export interface CandidateLink {
|
||||
readonly id: string;
|
||||
readonly kind: CandidateLinkKind;
|
||||
readonly a: CandidateNode;
|
||||
readonly b: CandidateNode;
|
||||
readonly contexts: readonly CandidateLinkContext[];
|
||||
}
|
||||
|
||||
export interface CandidatePosition {
|
||||
readonly value: number;
|
||||
readonly cells: readonly number[];
|
||||
readonly linkKind: CandidateLinkKind | "none";
|
||||
}
|
||||
|
||||
export interface HouseCandidateInspection {
|
||||
readonly unitIndex: number;
|
||||
readonly kind: UnitKind;
|
||||
readonly index: number;
|
||||
readonly label: string;
|
||||
readonly cells: readonly number[];
|
||||
readonly missingValues: readonly number[];
|
||||
readonly positions: readonly CandidatePosition[];
|
||||
}
|
||||
|
||||
export interface CellCandidateInspection {
|
||||
readonly cell: number;
|
||||
readonly values: readonly number[];
|
||||
readonly houseLabels: readonly string[];
|
||||
}
|
||||
|
||||
export interface CandidateOverlay {
|
||||
readonly activeValues: readonly number[];
|
||||
readonly candidateCells: readonly number[];
|
||||
readonly links: readonly CandidateLink[];
|
||||
}
|
||||
|
||||
export interface CandidateLinkOptions {
|
||||
/** Only derive house links from these compiled unit indices. */
|
||||
readonly unitIndices?: readonly number[];
|
||||
/** Only retain links whose two endpoints use one of these values. */
|
||||
readonly values?: readonly number[];
|
||||
/** Cells from which same-cell links are derived. Defaults to every cell. */
|
||||
readonly cellIndices?: readonly number[];
|
||||
readonly includeCellLinks?: boolean;
|
||||
}
|
||||
|
||||
function nodeKey(node: CandidateNode): string {
|
||||
return `${String(node.cell)}:${String(node.value)}`;
|
||||
}
|
||||
|
||||
function orderedNodes(
|
||||
first: CandidateNode,
|
||||
second: CandidateNode,
|
||||
): readonly [CandidateNode, CandidateNode] {
|
||||
return nodeKey(first) < nodeKey(second) ? [first, second] : [second, first];
|
||||
}
|
||||
|
||||
function linkKey(a: CandidateNode, b: CandidateNode): string {
|
||||
const [first, second] = orderedNodes(a, b);
|
||||
return `${nodeKey(first)}-${nodeKey(second)}`;
|
||||
}
|
||||
|
||||
function addLink(
|
||||
links: Map<
|
||||
string,
|
||||
{
|
||||
kind: CandidateLinkKind;
|
||||
a: CandidateNode;
|
||||
b: CandidateNode;
|
||||
contexts: CandidateLinkContext[];
|
||||
}
|
||||
>,
|
||||
kind: CandidateLinkKind,
|
||||
first: CandidateNode,
|
||||
second: CandidateNode,
|
||||
context: CandidateLinkContext,
|
||||
): void {
|
||||
const [a, b] = orderedNodes(first, second);
|
||||
const key = linkKey(a, b);
|
||||
const existing = links.get(key);
|
||||
if (existing === undefined) {
|
||||
links.set(key, { kind, a, b, contexts: [context] });
|
||||
return;
|
||||
}
|
||||
if (kind === "strong") existing.kind = "strong";
|
||||
if (!existing.contexts.some(({ label }) => label === context.label)) {
|
||||
existing.contexts.push(context);
|
||||
}
|
||||
}
|
||||
|
||||
function candidateValues(mask: number, size: number): number[] {
|
||||
const result: number[] = [];
|
||||
for (let value = 1; value <= size; value += 1) {
|
||||
if ((mask & (1 << (value - 1))) !== 0) result.push(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function candidateValuesFromMask(mask: number, size: number): number[] {
|
||||
if (!Number.isInteger(size) || size < 1 || size > 30) return [];
|
||||
return candidateValues(Number.isInteger(mask) ? mask : 0, size);
|
||||
}
|
||||
|
||||
export function houseLabel(unit: Pick<SudokuUnit, "kind" | "index">): string {
|
||||
switch (unit.kind) {
|
||||
case "row":
|
||||
return `Row ${String(unit.index + 1)}`;
|
||||
case "column":
|
||||
return `Column ${String(unit.index + 1)}`;
|
||||
case "region":
|
||||
return `Region ${String(unit.index + 1)}`;
|
||||
case "diagonal":
|
||||
return unit.index === 0 ? "Main diagonal" : "Anti-diagonal";
|
||||
case "disjoint-group":
|
||||
return `Disjoint group ${String(unit.index + 1)}`;
|
||||
case "extra-region":
|
||||
return `Extra region ${String(unit.index + 1)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** All houses touching at least one of the supplied cells, in compile order. */
|
||||
export function candidateUnitIndicesForCells(
|
||||
puzzle: NormalizedPuzzle,
|
||||
cells: readonly number[],
|
||||
): number[] {
|
||||
const compiled = compilePuzzle(puzzle);
|
||||
const indices = new Set<number>();
|
||||
for (const cell of cells) {
|
||||
for (const unitIndex of compiled.unitsByCell[cell] ?? []) {
|
||||
indices.add(unitIndex);
|
||||
}
|
||||
}
|
||||
return [...indices].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function inspectCandidateCells(
|
||||
puzzle: NormalizedPuzzle,
|
||||
candidateMasks: readonly number[],
|
||||
cells: readonly number[],
|
||||
): CellCandidateInspection[] {
|
||||
const compiled = compilePuzzle(puzzle);
|
||||
return [...new Set(cells)]
|
||||
.filter(
|
||||
(cell) => Number.isInteger(cell) && cell >= 0 && cell < puzzle.size ** 2,
|
||||
)
|
||||
.sort((a, b) => a - b)
|
||||
.map((cell) => ({
|
||||
cell,
|
||||
values: candidateValues(candidateMasks[cell] ?? 0, puzzle.size),
|
||||
houseLabels: (compiled.unitsByCell[cell] ?? []).map((unitIndex) =>
|
||||
houseLabel(compiled.units[unitIndex]!),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
export function inspectCandidateHouse(
|
||||
puzzle: NormalizedPuzzle,
|
||||
values: readonly number[],
|
||||
candidateMasks: readonly number[],
|
||||
unitIndex: number,
|
||||
): HouseCandidateInspection | undefined {
|
||||
const compiled = compilePuzzle(puzzle);
|
||||
return inspectCandidateHouseWithCompiled(
|
||||
compiled,
|
||||
values,
|
||||
candidateMasks,
|
||||
unitIndex,
|
||||
);
|
||||
}
|
||||
|
||||
function inspectCandidateHouseWithCompiled(
|
||||
compiled: CompiledPuzzle,
|
||||
values: readonly number[],
|
||||
candidateMasks: readonly number[],
|
||||
unitIndex: number,
|
||||
): HouseCandidateInspection | undefined {
|
||||
const { puzzle } = compiled;
|
||||
const unit = compiled.units[unitIndex];
|
||||
if (unit === undefined) return undefined;
|
||||
const placed = new Set(
|
||||
unit.cells
|
||||
.map((cell) => values[cell] ?? 0)
|
||||
.filter((value) => value >= 1 && value <= puzzle.size),
|
||||
);
|
||||
const missingValues = Array.from(
|
||||
{ length: puzzle.size },
|
||||
(_, index) => index + 1,
|
||||
).filter((value) => !placed.has(value));
|
||||
const positions = missingValues.map((value) => {
|
||||
const cells = unit.cells.filter(
|
||||
(cell) =>
|
||||
(values[cell] ?? 0) === 0 &&
|
||||
((candidateMasks[cell] ?? 0) & (1 << (value - 1))) !== 0,
|
||||
);
|
||||
return {
|
||||
value,
|
||||
cells,
|
||||
linkKind:
|
||||
cells.length === 2
|
||||
? ("strong" as const)
|
||||
: cells.length > 2
|
||||
? ("weak" as const)
|
||||
: ("none" as const),
|
||||
};
|
||||
});
|
||||
return {
|
||||
unitIndex,
|
||||
kind: unit.kind,
|
||||
index: unit.index,
|
||||
label: houseLabel(unit),
|
||||
cells: unit.cells,
|
||||
missingValues,
|
||||
positions,
|
||||
};
|
||||
}
|
||||
|
||||
export function inspectCandidateHouses(
|
||||
puzzle: NormalizedPuzzle,
|
||||
values: readonly number[],
|
||||
candidateMasks: readonly number[],
|
||||
unitIndices?: readonly number[],
|
||||
): HouseCandidateInspection[] {
|
||||
const compiled = compilePuzzle(puzzle);
|
||||
const indices = unitIndices ?? compiled.units.map((_, index) => index);
|
||||
return [...new Set(indices)]
|
||||
.map((unitIndex) =>
|
||||
inspectCandidateHouseWithCompiled(
|
||||
compiled,
|
||||
values,
|
||||
candidateMasks,
|
||||
unitIndex,
|
||||
),
|
||||
)
|
||||
.filter((inspection): inspection is HouseCandidateInspection =>
|
||||
Boolean(inspection),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the standard candidate-link graph from houses and cells.
|
||||
*
|
||||
* - two positions for a digit in a house form a strong link;
|
||||
* - three or more positions form pairwise weak links;
|
||||
* - two candidates in one cell form a strong link;
|
||||
* - three or more candidates in one cell form pairwise weak links.
|
||||
*/
|
||||
export function deriveCandidateLinks(
|
||||
puzzle: NormalizedPuzzle,
|
||||
candidateMasks: readonly number[],
|
||||
options: CandidateLinkOptions = {},
|
||||
): CandidateLink[] {
|
||||
const compiled = compilePuzzle(puzzle);
|
||||
const activeValues =
|
||||
options.values === undefined
|
||||
? undefined
|
||||
: new Set(
|
||||
options.values.filter(
|
||||
(value) =>
|
||||
Number.isInteger(value) && value >= 1 && value <= puzzle.size,
|
||||
),
|
||||
);
|
||||
const links = new Map<
|
||||
string,
|
||||
{
|
||||
kind: CandidateLinkKind;
|
||||
a: CandidateNode;
|
||||
b: CandidateNode;
|
||||
contexts: CandidateLinkContext[];
|
||||
}
|
||||
>();
|
||||
const unitIndices =
|
||||
options.unitIndices ?? compiled.units.map((_, index) => index);
|
||||
|
||||
for (const unitIndex of new Set(unitIndices)) {
|
||||
const unit = compiled.units[unitIndex];
|
||||
if (unit === undefined) continue;
|
||||
for (let value = 1; value <= puzzle.size; value += 1) {
|
||||
if (activeValues !== undefined && !activeValues.has(value)) continue;
|
||||
const nodes = unit.cells
|
||||
.filter(
|
||||
(cell) => ((candidateMasks[cell] ?? 0) & (1 << (value - 1))) !== 0,
|
||||
)
|
||||
.map((cell) => ({ cell, value }));
|
||||
if (nodes.length < 2) continue;
|
||||
const kind: CandidateLinkKind = nodes.length === 2 ? "strong" : "weak";
|
||||
for (let a = 0; a < nodes.length; a += 1) {
|
||||
for (let b = a + 1; b < nodes.length; b += 1) {
|
||||
addLink(links, kind, nodes[a]!, nodes[b]!, {
|
||||
kind: "house",
|
||||
label: houseLabel(unit),
|
||||
unitKind: unit.kind,
|
||||
unitIndex,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.includeCellLinks !== false) {
|
||||
const cellIndices =
|
||||
options.cellIndices ??
|
||||
Array.from({ length: puzzle.size ** 2 }, (_, cell) => cell);
|
||||
for (const cell of new Set(cellIndices)) {
|
||||
if (!Number.isInteger(cell) || cell < 0 || cell >= puzzle.size ** 2)
|
||||
continue;
|
||||
const allValues = candidateValues(candidateMasks[cell] ?? 0, puzzle.size);
|
||||
if (allValues.length < 2) continue;
|
||||
const kind: CandidateLinkKind =
|
||||
allValues.length === 2 ? "strong" : "weak";
|
||||
const visibleValues =
|
||||
activeValues === undefined
|
||||
? allValues
|
||||
: allValues.filter((value) => activeValues.has(value));
|
||||
for (let a = 0; a < visibleValues.length; a += 1) {
|
||||
for (let b = a + 1; b < visibleValues.length; b += 1) {
|
||||
addLink(
|
||||
links,
|
||||
kind,
|
||||
{ cell, value: visibleValues[a]! },
|
||||
{ cell, value: visibleValues[b]! },
|
||||
{ kind: "cell", label: `Cell ${cellLabel(cell, puzzle.size)}` },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...links.entries()]
|
||||
.map(([id, link]) => ({ id, ...link }))
|
||||
.sort((a, b) => {
|
||||
if (a.kind !== b.kind) return a.kind === "strong" ? -1 : 1;
|
||||
return a.id.localeCompare(b.id, undefined, { numeric: true });
|
||||
});
|
||||
}
|
||||
|
||||
export function candidateCellsForValues(
|
||||
candidateMasks: readonly number[],
|
||||
values: readonly number[],
|
||||
): number[] {
|
||||
const combinedMask = values.reduce(
|
||||
(mask, value) =>
|
||||
Number.isInteger(value) && value >= 1 && value <= 30
|
||||
? mask | (1 << (value - 1))
|
||||
: mask,
|
||||
0,
|
||||
);
|
||||
if (combinedMask === 0) return [];
|
||||
return candidateMasks.flatMap((mask, cell) =>
|
||||
(mask & combinedMask) !== 0 ? [cell] : [],
|
||||
);
|
||||
}
|
||||
|
||||
export function cellLabel(cell: number, size: number): string {
|
||||
return `r${String(Math.floor(cell / size) + 1)}c${String((cell % size) + 1)}`;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from "./candidates";
|
||||
export * from "./killer";
|
||||
export * from "./relations";
|
||||
export * from "./residual";
|
||||
export * from "./sumLab";
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
export const MAX_SUM_LAB_DIGIT = 25;
|
||||
export const MAX_SUM_LAB_COMBINATIONS = 10_000;
|
||||
export const MAX_SUM_LAB_ASSIGNMENTS = 50_000;
|
||||
export const MAX_SUM_LAB_SEARCH_NODES = 500_000;
|
||||
|
||||
export interface SumLabOptions {
|
||||
/** Number of cells whose values add up to the target. */
|
||||
readonly cellCount: number;
|
||||
readonly target: number;
|
||||
readonly minimumDigit?: number;
|
||||
readonly maximumDigit?: number;
|
||||
readonly allowRepeats?: boolean;
|
||||
/** Every listed digit must occur at least once. */
|
||||
readonly requiredDigits?: readonly number[];
|
||||
readonly excludedDigits?: readonly number[];
|
||||
/** One bit mask per cell, with digit 1 represented by the least-significant bit. */
|
||||
readonly candidateMasks?: readonly number[];
|
||||
/** Canonical keys returned by `sumCombinationKey`. */
|
||||
readonly eliminatedKeys?: ReadonlySet<string> | readonly string[];
|
||||
readonly maxCombinations?: number;
|
||||
readonly maxAssignments?: number;
|
||||
readonly maxSearchNodes?: number;
|
||||
}
|
||||
|
||||
export interface SumLabCombination {
|
||||
readonly key: string;
|
||||
/** Sorted digits. Repeated values are retained. */
|
||||
readonly digits: readonly number[];
|
||||
readonly eliminated: boolean;
|
||||
/** Number of candidate-compatible cell orderings retained by this analysis. */
|
||||
readonly assignmentCount: number;
|
||||
}
|
||||
|
||||
export interface SumLabAnalysis {
|
||||
/** All candidate-compatible combinations, including manually eliminated ones. */
|
||||
readonly combinations: readonly SumLabCombination[];
|
||||
readonly activeCombinations: readonly SumLabCombination[];
|
||||
readonly eliminatedCombinations: readonly SumLabCombination[];
|
||||
/** Candidate-compatible cell-ordered assignments for active combinations. */
|
||||
readonly assignments: readonly (readonly number[])[];
|
||||
readonly possibleDigits: readonly number[];
|
||||
/** Digits present in every active combination. */
|
||||
readonly necessaryDigits: readonly number[];
|
||||
readonly possibleByCell: readonly (readonly number[])[];
|
||||
readonly exploredNodes: number;
|
||||
readonly truncated: boolean;
|
||||
readonly truncationReason?: "combinations" | "assignments" | "search";
|
||||
}
|
||||
|
||||
function boundedInteger(
|
||||
value: number,
|
||||
label: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
if (!Number.isInteger(value) || value < minimum || value > maximum) {
|
||||
throw new RangeError(
|
||||
`${label} must be an integer from ${minimum} to ${maximum}.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function uniqueSortedDigits(
|
||||
values: readonly number[],
|
||||
label: string,
|
||||
minimumDigit: number,
|
||||
maximumDigit: number,
|
||||
): number[] {
|
||||
const result = [...new Set(values)];
|
||||
for (const value of result) {
|
||||
if (
|
||||
!Number.isInteger(value) ||
|
||||
value < minimumDigit ||
|
||||
value > maximumDigit
|
||||
) {
|
||||
throw new RangeError(
|
||||
`${label} must only contain integers from ${minimumDigit} to ${maximumDigit}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return result.sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
function intersect(
|
||||
left: ReadonlySet<number>,
|
||||
right: ReadonlySet<number>,
|
||||
): Set<number> {
|
||||
return new Set([...left].filter((value) => right.has(value)));
|
||||
}
|
||||
|
||||
function emptyAnalysis(cellCount: number): SumLabAnalysis {
|
||||
return {
|
||||
combinations: [],
|
||||
activeCombinations: [],
|
||||
eliminatedCombinations: [],
|
||||
assignments: [],
|
||||
possibleDigits: [],
|
||||
necessaryDigits: [],
|
||||
possibleByCell: Array.from({ length: cellCount }, () => []),
|
||||
exploredNodes: 0,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable identifier for a sorted sum combination. It is intentionally human
|
||||
* readable so callers can safely retain eliminations while rendering results.
|
||||
*/
|
||||
export function sumCombinationKey(digits: readonly number[]): string {
|
||||
return digits.join(",");
|
||||
}
|
||||
|
||||
function digitIsInMask(mask: number, digit: number): boolean {
|
||||
return (mask & (2 ** (digit - 1))) !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate generalized Sudoku sums without permutations. Results are ordered
|
||||
* lexicographically and bounded by independent combination, assignment and
|
||||
* search-node limits. Candidate masks are only used when a positional cell
|
||||
* selection was supplied.
|
||||
*/
|
||||
export function analyzeSumLab(options: SumLabOptions): SumLabAnalysis {
|
||||
const minimumDigit = boundedInteger(
|
||||
options.minimumDigit ?? 1,
|
||||
"minimumDigit",
|
||||
1,
|
||||
MAX_SUM_LAB_DIGIT,
|
||||
);
|
||||
const maximumDigit = boundedInteger(
|
||||
options.maximumDigit ?? 9,
|
||||
"maximumDigit",
|
||||
minimumDigit,
|
||||
MAX_SUM_LAB_DIGIT,
|
||||
);
|
||||
const cellCount = boundedInteger(options.cellCount, "cellCount", 1, 25);
|
||||
const target = boundedInteger(
|
||||
options.target,
|
||||
"target",
|
||||
0,
|
||||
maximumDigit * cellCount,
|
||||
);
|
||||
const maxCombinations = boundedInteger(
|
||||
options.maxCombinations ?? 5_000,
|
||||
"maxCombinations",
|
||||
1,
|
||||
MAX_SUM_LAB_COMBINATIONS,
|
||||
);
|
||||
const maxAssignments = boundedInteger(
|
||||
options.maxAssignments ?? 20_000,
|
||||
"maxAssignments",
|
||||
1,
|
||||
MAX_SUM_LAB_ASSIGNMENTS,
|
||||
);
|
||||
const maxSearchNodes = boundedInteger(
|
||||
options.maxSearchNodes ?? 200_000,
|
||||
"maxSearchNodes",
|
||||
1,
|
||||
MAX_SUM_LAB_SEARCH_NODES,
|
||||
);
|
||||
const required = uniqueSortedDigits(
|
||||
options.requiredDigits ?? [],
|
||||
"requiredDigits",
|
||||
minimumDigit,
|
||||
maximumDigit,
|
||||
);
|
||||
const excluded = new Set(
|
||||
uniqueSortedDigits(
|
||||
options.excludedDigits ?? [],
|
||||
"excludedDigits",
|
||||
minimumDigit,
|
||||
maximumDigit,
|
||||
),
|
||||
);
|
||||
const digits = Array.from(
|
||||
{ length: maximumDigit - minimumDigit + 1 },
|
||||
(_, index) => minimumDigit + index,
|
||||
).filter((digit) => !excluded.has(digit));
|
||||
const allowRepeats = options.allowRepeats ?? false;
|
||||
const eliminatedKeys = new Set(options.eliminatedKeys ?? []);
|
||||
|
||||
if (
|
||||
digits.length === 0 ||
|
||||
required.some((digit) => excluded.has(digit)) ||
|
||||
required.length > cellCount ||
|
||||
(!allowRepeats && cellCount > digits.length)
|
||||
) {
|
||||
return emptyAnalysis(cellCount);
|
||||
}
|
||||
|
||||
let candidateMasks: readonly number[] | undefined;
|
||||
if (options.candidateMasks !== undefined) {
|
||||
if (options.candidateMasks.length !== cellCount) {
|
||||
throw new RangeError(
|
||||
`candidateMasks must contain exactly ${cellCount} cell entries.`,
|
||||
);
|
||||
}
|
||||
for (const mask of options.candidateMasks) {
|
||||
if (!Number.isSafeInteger(mask) || mask < 0) {
|
||||
throw new RangeError("candidate masks must be non-negative integers.");
|
||||
}
|
||||
}
|
||||
candidateMasks = options.candidateMasks;
|
||||
}
|
||||
|
||||
const rawCombinations: {
|
||||
digits: number[];
|
||||
assignments: number[][];
|
||||
}[] = [];
|
||||
let exploredNodes = 0;
|
||||
let retainedAssignments = 0;
|
||||
let truncationReason: SumLabAnalysis["truncationReason"];
|
||||
|
||||
const assignmentsFor = (combination: readonly number[]): number[][] => {
|
||||
if (candidateMasks === undefined) return [[...combination]];
|
||||
const result: number[][] = [];
|
||||
const remaining = new Map<number, number>();
|
||||
for (const digit of combination) {
|
||||
remaining.set(digit, (remaining.get(digit) ?? 0) + 1);
|
||||
}
|
||||
const assignment = Array<number>(cellCount).fill(0);
|
||||
|
||||
const visit = (position: number): void => {
|
||||
if (truncationReason !== undefined) return;
|
||||
exploredNodes += 1;
|
||||
if (exploredNodes > maxSearchNodes) {
|
||||
truncationReason = "search";
|
||||
return;
|
||||
}
|
||||
if (position === cellCount) {
|
||||
if (retainedAssignments + result.length >= maxAssignments) {
|
||||
truncationReason = "assignments";
|
||||
return;
|
||||
}
|
||||
result.push([...assignment]);
|
||||
return;
|
||||
}
|
||||
const mask = candidateMasks[position] ?? 0;
|
||||
for (const digit of [...remaining.keys()].sort(
|
||||
(left, right) => left - right,
|
||||
)) {
|
||||
const count = remaining.get(digit) ?? 0;
|
||||
if (count === 0 || !digitIsInMask(mask, digit)) continue;
|
||||
assignment[position] = digit;
|
||||
remaining.set(digit, count - 1);
|
||||
visit(position + 1);
|
||||
remaining.set(digit, count);
|
||||
if (truncationReason !== undefined) return;
|
||||
}
|
||||
};
|
||||
visit(0);
|
||||
return result;
|
||||
};
|
||||
|
||||
const current: number[] = [];
|
||||
const enumerate = (
|
||||
startIndex: number,
|
||||
remainingCells: number,
|
||||
remainingSum: number,
|
||||
): void => {
|
||||
if (truncationReason !== undefined) return;
|
||||
exploredNodes += 1;
|
||||
if (exploredNodes > maxSearchNodes) {
|
||||
truncationReason = "search";
|
||||
return;
|
||||
}
|
||||
if (remainingCells === 0) {
|
||||
if (
|
||||
remainingSum !== 0 ||
|
||||
!required.every((digit) => current.includes(digit))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const assignments = assignmentsFor(current);
|
||||
if (assignments.length === 0) return;
|
||||
if (rawCombinations.length >= maxCombinations) {
|
||||
truncationReason = "combinations";
|
||||
return;
|
||||
}
|
||||
rawCombinations.push({ digits: [...current], assignments });
|
||||
retainedAssignments += assignments.length;
|
||||
return;
|
||||
}
|
||||
if (startIndex >= digits.length) return;
|
||||
|
||||
if (allowRepeats) {
|
||||
const minimum = (digits[startIndex] ?? 0) * remainingCells;
|
||||
const maximum = (digits.at(-1) ?? 0) * remainingCells;
|
||||
if (remainingSum < minimum || remainingSum > maximum) return;
|
||||
} else {
|
||||
if (digits.length - startIndex < remainingCells) return;
|
||||
let minimum = 0;
|
||||
let maximum = 0;
|
||||
for (let offset = 0; offset < remainingCells; offset += 1) {
|
||||
minimum += digits[startIndex + offset] ?? 0;
|
||||
maximum += digits[digits.length - 1 - offset] ?? 0;
|
||||
}
|
||||
if (remainingSum < minimum || remainingSum > maximum) return;
|
||||
}
|
||||
|
||||
for (let index = startIndex; index < digits.length; index += 1) {
|
||||
const digit = digits[index];
|
||||
if (digit === undefined || digit > remainingSum) break;
|
||||
current.push(digit);
|
||||
enumerate(
|
||||
allowRepeats ? index : index + 1,
|
||||
remainingCells - 1,
|
||||
remainingSum - digit,
|
||||
);
|
||||
current.pop();
|
||||
if (truncationReason !== undefined) return;
|
||||
}
|
||||
};
|
||||
enumerate(0, cellCount, target);
|
||||
|
||||
const combinations: SumLabCombination[] = [];
|
||||
const activeCombinations: SumLabCombination[] = [];
|
||||
const eliminatedCombinations: SumLabCombination[] = [];
|
||||
const assignments: number[][] = [];
|
||||
const possibleByCell = Array.from(
|
||||
{ length: cellCount },
|
||||
() => new Set<number>(),
|
||||
);
|
||||
const possibleDigits = new Set<number>();
|
||||
let necessaryDigits: Set<number> | undefined;
|
||||
|
||||
for (const raw of rawCombinations) {
|
||||
const key = sumCombinationKey(raw.digits);
|
||||
const eliminated = eliminatedKeys.has(key);
|
||||
const combination: SumLabCombination = {
|
||||
key,
|
||||
digits: raw.digits,
|
||||
eliminated,
|
||||
assignmentCount: raw.assignments.length,
|
||||
};
|
||||
combinations.push(combination);
|
||||
if (eliminated) {
|
||||
eliminatedCombinations.push(combination);
|
||||
continue;
|
||||
}
|
||||
activeCombinations.push(combination);
|
||||
const digitSet = new Set(raw.digits);
|
||||
necessaryDigits =
|
||||
necessaryDigits === undefined
|
||||
? digitSet
|
||||
: intersect(necessaryDigits, digitSet);
|
||||
for (const digit of raw.digits) possibleDigits.add(digit);
|
||||
for (const assignment of raw.assignments) {
|
||||
assignments.push(assignment);
|
||||
for (const [position, digit] of assignment.entries()) {
|
||||
possibleByCell[position]?.add(digit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
combinations,
|
||||
activeCombinations,
|
||||
eliminatedCombinations,
|
||||
assignments,
|
||||
possibleDigits: [...possibleDigits].sort((left, right) => left - right),
|
||||
necessaryDigits: [...(necessaryDigits ?? [])].sort(
|
||||
(left, right) => left - right,
|
||||
),
|
||||
possibleByCell: possibleByCell.map((entry) =>
|
||||
[...entry].sort((left, right) => left - right),
|
||||
),
|
||||
exploredNodes,
|
||||
truncated: truncationReason !== undefined,
|
||||
...(truncationReason === undefined ? {} : { truncationReason }),
|
||||
};
|
||||
}
|
||||
@@ -7,3 +7,17 @@ createRoot(document.getElementById("root")!).render(
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
if ("serviceWorker" in navigator && import.meta.env.PROD) {
|
||||
window.addEventListener("load", () => {
|
||||
const url = new URL("./sw.js", document.baseURI);
|
||||
void navigator.serviceWorker
|
||||
.register(url, {
|
||||
scope: new URL("./", document.baseURI).pathname,
|
||||
})
|
||||
.catch(() => {
|
||||
// Offline support is progressive enhancement; the workbench remains
|
||||
// fully usable when a host or private browsing mode blocks workers.
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,954 @@
|
||||
import type { CellId, SudokuUnit } from "../domain";
|
||||
import type {
|
||||
LogicalElimination,
|
||||
LogicalStep,
|
||||
LogicalTechnique,
|
||||
} from "./logical";
|
||||
|
||||
export interface AdvancedLogicalContext {
|
||||
readonly size: number;
|
||||
readonly values: readonly number[];
|
||||
/** Candidate masks use bit `1 << digit`, matching the logical solver. */
|
||||
readonly masks: readonly number[];
|
||||
readonly regions: readonly number[];
|
||||
readonly peers: readonly ReadonlySet<CellId>[];
|
||||
readonly units: readonly SudokuUnit[];
|
||||
/** Must come from a completed uniqueness proof; false/omitted is the default. */
|
||||
readonly uniquenessProven?: boolean;
|
||||
/** Extra constraints can invalidate uniqueness-pattern swap arguments. */
|
||||
readonly uniquenessPatternsSafe?: boolean;
|
||||
}
|
||||
|
||||
const MAX_CHAIN_NODES = 10;
|
||||
const MAX_CHAIN_VISITS = 200_000;
|
||||
|
||||
function digitBit(value: number): number {
|
||||
return 1 << value;
|
||||
}
|
||||
|
||||
function popcount(mask: number): number {
|
||||
let value = mask >>> 0;
|
||||
let count = 0;
|
||||
while (value !== 0) {
|
||||
value &= value - 1;
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function maskDigits(mask: number, size: number): number[] {
|
||||
const result: number[] = [];
|
||||
for (let value = 1; value <= size; value += 1) {
|
||||
if ((mask & digitBit(value)) !== 0) result.push(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function hasCandidate(
|
||||
context: AdvancedLogicalContext,
|
||||
cell: CellId,
|
||||
value: number,
|
||||
): boolean {
|
||||
return (
|
||||
context.values[cell] === 0 &&
|
||||
((context.masks[cell] ?? 0) & digitBit(value)) !== 0
|
||||
);
|
||||
}
|
||||
|
||||
function combinations<T>(values: readonly T[], count: number): T[][] {
|
||||
const result: T[][] = [];
|
||||
const selected: T[] = [];
|
||||
const visit = (start: number): void => {
|
||||
if (selected.length === count) {
|
||||
result.push([...selected]);
|
||||
return;
|
||||
}
|
||||
for (
|
||||
let index = start;
|
||||
index <= values.length - (count - selected.length);
|
||||
index += 1
|
||||
) {
|
||||
const value = values[index];
|
||||
if (value === undefined) continue;
|
||||
selected.push(value);
|
||||
visit(index + 1);
|
||||
selected.pop();
|
||||
}
|
||||
};
|
||||
visit(0);
|
||||
return result;
|
||||
}
|
||||
|
||||
function uniqueSorted(values: Iterable<number>): number[] {
|
||||
return [...new Set(values)].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function step(
|
||||
technique: LogicalTechnique,
|
||||
eliminations: readonly LogicalElimination[],
|
||||
focusCells: Iterable<CellId>,
|
||||
explanation: string,
|
||||
): LogicalStep | undefined {
|
||||
if (eliminations.length === 0) return undefined;
|
||||
const byCell = new Map<number, Set<number>>();
|
||||
for (const elimination of eliminations) {
|
||||
const values = byCell.get(elimination.cell) ?? new Set<number>();
|
||||
for (const value of elimination.values) values.add(value);
|
||||
byCell.set(elimination.cell, values);
|
||||
}
|
||||
return {
|
||||
technique,
|
||||
placements: [],
|
||||
eliminations: [...byCell]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([cell, values]) => ({
|
||||
cell,
|
||||
values: [...values].sort((a, b) => a - b),
|
||||
})),
|
||||
focusCells: uniqueSorted(focusCells),
|
||||
explanation,
|
||||
};
|
||||
}
|
||||
|
||||
function unitCandidates(
|
||||
context: AdvancedLogicalContext,
|
||||
unit: SudokuUnit,
|
||||
value: number,
|
||||
): number[] {
|
||||
return unit.cells.filter((cell) => hasCandidate(context, cell, value));
|
||||
}
|
||||
|
||||
function lineUnits(
|
||||
context: AdvancedLogicalContext,
|
||||
kind: "row" | "column",
|
||||
): SudokuUnit[] {
|
||||
return context.units
|
||||
.filter((unit) => unit.kind === kind)
|
||||
.sort((a, b) => a.index - b.index);
|
||||
}
|
||||
|
||||
function baseIndex(
|
||||
context: AdvancedLogicalContext,
|
||||
kind: "row" | "column",
|
||||
cell: CellId,
|
||||
): number {
|
||||
return kind === "row" ? Math.floor(cell / context.size) : cell % context.size;
|
||||
}
|
||||
|
||||
function coverIndex(
|
||||
context: AdvancedLogicalContext,
|
||||
kind: "row" | "column",
|
||||
cell: CellId,
|
||||
): number {
|
||||
return kind === "row" ? cell % context.size : Math.floor(cell / context.size);
|
||||
}
|
||||
|
||||
function commonPeers(
|
||||
context: AdvancedLogicalContext,
|
||||
cells: readonly CellId[],
|
||||
): Set<CellId> {
|
||||
const first = cells[0];
|
||||
if (first === undefined) return new Set<CellId>();
|
||||
const result = new Set(context.peers[first] ?? []);
|
||||
for (const cell of cells.slice(1)) {
|
||||
for (const candidate of result) {
|
||||
if (!(context.peers[cell]?.has(candidate) ?? false)) {
|
||||
result.delete(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const cell of cells) result.delete(cell);
|
||||
return result;
|
||||
}
|
||||
|
||||
function commonPeerEliminations(
|
||||
context: AdvancedLogicalContext,
|
||||
endpoints: readonly CellId[],
|
||||
value: number,
|
||||
excluded: ReadonlySet<CellId> = new Set(),
|
||||
): LogicalElimination[] {
|
||||
return [...commonPeers(context, endpoints)]
|
||||
.filter((cell) => !excluded.has(cell) && hasCandidate(context, cell, value))
|
||||
.sort((a, b) => a - b)
|
||||
.map((cell) => ({ cell, values: [value] }));
|
||||
}
|
||||
|
||||
export function findJellyfish(
|
||||
context: AdvancedLogicalContext,
|
||||
): LogicalStep | undefined {
|
||||
const count = 4;
|
||||
for (const baseKind of ["row", "column"] as const) {
|
||||
const bases = lineUnits(context, baseKind);
|
||||
for (let value = 1; value <= context.size; value += 1) {
|
||||
const eligible = bases.filter((unit) => {
|
||||
const total = unitCandidates(context, unit, value).length;
|
||||
return total >= 2 && total <= count;
|
||||
});
|
||||
for (const selected of combinations(eligible, count)) {
|
||||
const covers = new Set<number>();
|
||||
const focus: number[] = [];
|
||||
for (const unit of selected) {
|
||||
for (const cell of unitCandidates(context, unit, value)) {
|
||||
covers.add(coverIndex(context, baseKind, cell));
|
||||
focus.push(cell);
|
||||
}
|
||||
}
|
||||
if (covers.size !== count) continue;
|
||||
const selectedBases = new Set(selected.map((unit) => unit.index));
|
||||
const eliminations: LogicalElimination[] = [];
|
||||
for (let cell = 0; cell < context.values.length; cell += 1) {
|
||||
if (
|
||||
!selectedBases.has(baseIndex(context, baseKind, cell)) &&
|
||||
covers.has(coverIndex(context, baseKind, cell)) &&
|
||||
hasCandidate(context, cell, value)
|
||||
) {
|
||||
eliminations.push({ cell, values: [value] });
|
||||
}
|
||||
}
|
||||
const found = step(
|
||||
"jellyfish",
|
||||
eliminations,
|
||||
focus,
|
||||
`${value} is confined to four cover lines across four ${baseKind}s, forming a Jellyfish.`,
|
||||
);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findFinnedFishOfSize(
|
||||
context: AdvancedLogicalContext,
|
||||
count: 2 | 3,
|
||||
): LogicalStep | undefined {
|
||||
for (const baseKind of ["row", "column"] as const) {
|
||||
const bases = lineUnits(context, baseKind);
|
||||
for (let value = 1; value <= context.size; value += 1) {
|
||||
const eligible = bases.filter((unit) => {
|
||||
const total = unitCandidates(context, unit, value).length;
|
||||
return total >= 2 && total <= count + 2;
|
||||
});
|
||||
for (const selected of combinations(eligible, count)) {
|
||||
const allCovers = uniqueSorted(
|
||||
selected.flatMap((unit) =>
|
||||
unitCandidates(context, unit, value).map((cell) =>
|
||||
coverIndex(context, baseKind, cell),
|
||||
),
|
||||
),
|
||||
);
|
||||
// One or two extra cover lines provide ordinary fins without allowing
|
||||
// an unbounded cover-subset search on large grids.
|
||||
if (allCovers.length < count + 1 || allCovers.length > count + 2) {
|
||||
continue;
|
||||
}
|
||||
for (const covers of combinations(allCovers, count)) {
|
||||
const coverSet = new Set(covers);
|
||||
for (const finBase of selected) {
|
||||
let compatible = true;
|
||||
const bodyCells: number[] = [];
|
||||
const finCells: number[] = [];
|
||||
for (const unit of selected) {
|
||||
for (const cell of unitCandidates(context, unit, value)) {
|
||||
if (coverSet.has(coverIndex(context, baseKind, cell))) {
|
||||
bodyCells.push(cell);
|
||||
} else if (unit.index === finBase.index) {
|
||||
finCells.push(cell);
|
||||
} else {
|
||||
compatible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!compatible || finCells.length === 0) continue;
|
||||
if (
|
||||
selected.some(
|
||||
(unit) =>
|
||||
unitCandidates(context, unit, value).filter((cell) =>
|
||||
coverSet.has(coverIndex(context, baseKind, cell)),
|
||||
).length === 0,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const usedBodyCovers = new Set(
|
||||
bodyCells.map((cell) => coverIndex(context, baseKind, cell)),
|
||||
);
|
||||
if (usedBodyCovers.size !== count) continue;
|
||||
const finRegion = context.regions[finCells[0] as number];
|
||||
if (
|
||||
finRegion === undefined ||
|
||||
finCells.some((cell) => context.regions[cell] !== finRegion)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const baseSet = new Set(selected.map((unit) => unit.index));
|
||||
const finPeers = commonPeers(context, finCells);
|
||||
const eliminations: LogicalElimination[] = [];
|
||||
for (let cell = 0; cell < context.values.length; cell += 1) {
|
||||
if (
|
||||
!baseSet.has(baseIndex(context, baseKind, cell)) &&
|
||||
coverSet.has(coverIndex(context, baseKind, cell)) &&
|
||||
finPeers.has(cell) &&
|
||||
hasCandidate(context, cell, value)
|
||||
) {
|
||||
eliminations.push({ cell, values: [value] });
|
||||
}
|
||||
}
|
||||
const technique: LogicalTechnique =
|
||||
count === 2 ? "finned-x-wing" : "finned-swordfish";
|
||||
const found = step(
|
||||
technique,
|
||||
eliminations,
|
||||
[...bodyCells, ...finCells],
|
||||
`${value} forms a ${count === 2 ? "Finned X-Wing" : "Finned Swordfish"}; the fin and fish body both eliminate it in their shared region.`,
|
||||
);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function findFinnedFish(
|
||||
context: AdvancedLogicalContext,
|
||||
): LogicalStep | undefined {
|
||||
return findFinnedFishOfSize(context, 2) ?? findFinnedFishOfSize(context, 3);
|
||||
}
|
||||
|
||||
export function findSkyscraper(
|
||||
context: AdvancedLogicalContext,
|
||||
): LogicalStep | undefined {
|
||||
for (const baseKind of ["row", "column"] as const) {
|
||||
const bases = lineUnits(context, baseKind);
|
||||
for (let value = 1; value <= context.size; value += 1) {
|
||||
const strongLines = bases
|
||||
.map((unit) => ({ unit, cells: unitCandidates(context, unit, value) }))
|
||||
.filter(({ cells }) => cells.length === 2);
|
||||
for (const pair of combinations(strongLines, 2)) {
|
||||
const first = pair[0];
|
||||
const second = pair[1];
|
||||
if (first === undefined || second === undefined) continue;
|
||||
for (let firstBase = 0; firstBase < 2; firstBase += 1) {
|
||||
for (let secondBase = 0; secondBase < 2; secondBase += 1) {
|
||||
const aBase = first.cells[firstBase];
|
||||
const bBase = second.cells[secondBase];
|
||||
const aRoof = first.cells[1 - firstBase];
|
||||
const bRoof = second.cells[1 - secondBase];
|
||||
if (
|
||||
aBase === undefined ||
|
||||
bBase === undefined ||
|
||||
aRoof === undefined ||
|
||||
bRoof === undefined ||
|
||||
coverIndex(context, baseKind, aBase) !==
|
||||
coverIndex(context, baseKind, bBase)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const pattern = new Set([aBase, bBase, aRoof, bRoof]);
|
||||
if (pattern.size !== 4) continue;
|
||||
const found = step(
|
||||
"skyscraper",
|
||||
commonPeerEliminations(context, [aRoof, bRoof], value, pattern),
|
||||
pattern,
|
||||
`${value} forms a Skyscraper: at least one of the two roof cells must contain it.`,
|
||||
);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function findTwoStringKite(
|
||||
context: AdvancedLogicalContext,
|
||||
): LogicalStep | undefined {
|
||||
const rows = lineUnits(context, "row");
|
||||
const columns = lineUnits(context, "column");
|
||||
for (let value = 1; value <= context.size; value += 1) {
|
||||
const rowLinks = rows
|
||||
.map((unit) => ({ unit, cells: unitCandidates(context, unit, value) }))
|
||||
.filter(({ cells }) => cells.length === 2);
|
||||
const columnLinks = columns
|
||||
.map((unit) => ({ unit, cells: unitCandidates(context, unit, value) }))
|
||||
.filter(({ cells }) => cells.length === 2);
|
||||
for (const row of rowLinks) {
|
||||
for (const column of columnLinks) {
|
||||
for (let rowBaseIndex = 0; rowBaseIndex < 2; rowBaseIndex += 1) {
|
||||
for (
|
||||
let columnBaseIndex = 0;
|
||||
columnBaseIndex < 2;
|
||||
columnBaseIndex += 1
|
||||
) {
|
||||
const rowBase = row.cells[rowBaseIndex];
|
||||
const columnBase = column.cells[columnBaseIndex];
|
||||
const rowRoof = row.cells[1 - rowBaseIndex];
|
||||
const columnRoof = column.cells[1 - columnBaseIndex];
|
||||
if (
|
||||
rowBase === undefined ||
|
||||
columnBase === undefined ||
|
||||
rowRoof === undefined ||
|
||||
columnRoof === undefined
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const pattern = new Set([rowBase, columnBase, rowRoof, columnRoof]);
|
||||
if (
|
||||
pattern.size !== 4 ||
|
||||
context.regions[rowBase] !== context.regions[columnBase]
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const found = step(
|
||||
"two-string-kite",
|
||||
commonPeerEliminations(
|
||||
context,
|
||||
[rowRoof, columnRoof],
|
||||
value,
|
||||
pattern,
|
||||
),
|
||||
pattern,
|
||||
`${value} forms a Two-String Kite through conjugate row and column links.`,
|
||||
);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface CellStrongLink {
|
||||
readonly a: CellId;
|
||||
readonly b: CellId;
|
||||
readonly value: number;
|
||||
}
|
||||
|
||||
function cellStrongLinks(
|
||||
context: AdvancedLogicalContext,
|
||||
requestedValue?: number,
|
||||
): CellStrongLink[] {
|
||||
const links = new Map<string, CellStrongLink>();
|
||||
const first = requestedValue ?? 1;
|
||||
const last = requestedValue ?? context.size;
|
||||
for (let value = first; value <= last; value += 1) {
|
||||
for (const unit of context.units) {
|
||||
const cells = unitCandidates(context, unit, value);
|
||||
if (cells.length !== 2) continue;
|
||||
const a = Math.min(cells[0] as number, cells[1] as number);
|
||||
const b = Math.max(cells[0] as number, cells[1] as number);
|
||||
links.set(`${value}:${a}:${b}`, { a, b, value });
|
||||
}
|
||||
}
|
||||
return [...links.values()].sort(
|
||||
(a, b) => a.value - b.value || a.a - b.a || a.b - b.b,
|
||||
);
|
||||
}
|
||||
|
||||
function strongCellAdjacency(
|
||||
context: AdvancedLogicalContext,
|
||||
value: number,
|
||||
): readonly ReadonlySet<CellId>[] {
|
||||
const adjacency = Array.from(
|
||||
{ length: context.values.length },
|
||||
() => new Set<CellId>(),
|
||||
);
|
||||
for (const link of cellStrongLinks(context, value)) {
|
||||
adjacency[link.a]?.add(link.b);
|
||||
adjacency[link.b]?.add(link.a);
|
||||
}
|
||||
return adjacency;
|
||||
}
|
||||
|
||||
export function findSimpleColouring(
|
||||
context: AdvancedLogicalContext,
|
||||
): LogicalStep | undefined {
|
||||
for (let value = 1; value <= context.size; value += 1) {
|
||||
const adjacency = strongCellAdjacency(context, value);
|
||||
const visited = new Set<number>();
|
||||
for (let start = 0; start < context.values.length; start += 1) {
|
||||
if (visited.has(start) || (adjacency[start]?.size ?? 0) === 0) continue;
|
||||
const colours = new Map<number, 0 | 1>([[start, 0]]);
|
||||
const queue = [start];
|
||||
let bipartite = true;
|
||||
while (queue.length > 0) {
|
||||
const cell = queue.shift() as number;
|
||||
visited.add(cell);
|
||||
const colour = colours.get(cell) as 0 | 1;
|
||||
const neighbours = [...(adjacency[cell] ?? [])].sort((a, b) => a - b);
|
||||
for (const neighbour of neighbours) {
|
||||
const expected = colour === 0 ? 1 : 0;
|
||||
const existing = colours.get(neighbour);
|
||||
if (existing === undefined) {
|
||||
colours.set(neighbour, expected);
|
||||
queue.push(neighbour);
|
||||
} else if (existing !== expected) {
|
||||
bipartite = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!bipartite) continue;
|
||||
const component = uniqueSorted(colours.keys());
|
||||
const componentSet = new Set(component);
|
||||
for (const colour of [0, 1] as const) {
|
||||
const cells = component.filter((cell) => colours.get(cell) === colour);
|
||||
const conflict = cells.some((cell, index) =>
|
||||
cells
|
||||
.slice(index + 1)
|
||||
.some((other) => context.peers[cell]?.has(other) ?? false),
|
||||
);
|
||||
if (!conflict) continue;
|
||||
const found = step(
|
||||
"simple-colouring",
|
||||
cells.map((cell) => ({ cell, values: [value] })),
|
||||
component,
|
||||
`Two ${value} candidates with the same colour see each other, so that colour is false.`,
|
||||
);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
|
||||
const colourZero = component.filter((cell) => colours.get(cell) === 0);
|
||||
const colourOne = component.filter((cell) => colours.get(cell) === 1);
|
||||
const eliminations: LogicalElimination[] = [];
|
||||
for (let cell = 0; cell < context.values.length; cell += 1) {
|
||||
if (!hasCandidate(context, cell, value) || componentSet.has(cell)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
colourZero.some(
|
||||
(coloured) => context.peers[cell]?.has(coloured) ?? false,
|
||||
) &&
|
||||
colourOne.some(
|
||||
(coloured) => context.peers[cell]?.has(coloured) ?? false,
|
||||
)
|
||||
) {
|
||||
eliminations.push({ cell, values: [value] });
|
||||
}
|
||||
}
|
||||
const found = step(
|
||||
"simple-colouring",
|
||||
eliminations,
|
||||
component,
|
||||
`This ${value} candidate sees both colours of one conjugate chain.`,
|
||||
);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function findWWing(
|
||||
context: AdvancedLogicalContext,
|
||||
): LogicalStep | undefined {
|
||||
const bivalue = context.masks
|
||||
.map((mask, cell) => ({ cell, mask }))
|
||||
.filter(
|
||||
({ cell, mask }) => context.values[cell] === 0 && popcount(mask) === 2,
|
||||
);
|
||||
const links = cellStrongLinks(context);
|
||||
for (const pair of combinations(bivalue, 2)) {
|
||||
const first = pair[0];
|
||||
const second = pair[1];
|
||||
if (
|
||||
first === undefined ||
|
||||
second === undefined ||
|
||||
first.mask !== second.mask ||
|
||||
(context.peers[first.cell]?.has(second.cell) ?? false)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for (const linkValue of maskDigits(first.mask, context.size)) {
|
||||
const otherValue = maskDigits(
|
||||
first.mask & ~digitBit(linkValue),
|
||||
context.size,
|
||||
)[0];
|
||||
if (otherValue === undefined) continue;
|
||||
for (const link of links) {
|
||||
if (link.value !== linkValue) continue;
|
||||
if (new Set([first.cell, second.cell, link.a, link.b]).size !== 4) {
|
||||
continue;
|
||||
}
|
||||
const connected =
|
||||
((context.peers[first.cell]?.has(link.a) ?? false) &&
|
||||
(context.peers[second.cell]?.has(link.b) ?? false)) ||
|
||||
((context.peers[first.cell]?.has(link.b) ?? false) &&
|
||||
(context.peers[second.cell]?.has(link.a) ?? false));
|
||||
if (!connected) continue;
|
||||
const pattern = new Set([first.cell, second.cell, link.a, link.b]);
|
||||
const found = step(
|
||||
"w-wing",
|
||||
commonPeerEliminations(
|
||||
context,
|
||||
[first.cell, second.cell],
|
||||
otherValue,
|
||||
pattern,
|
||||
),
|
||||
pattern,
|
||||
`The ${linkValue} conjugate link joins two ${linkValue}/${otherValue} cells, so one wing must contain ${otherValue}.`,
|
||||
);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function findXChain(
|
||||
context: AdvancedLogicalContext,
|
||||
): LogicalStep | undefined {
|
||||
for (let value = 1; value <= context.size; value += 1) {
|
||||
const strong = strongCellAdjacency(context, value);
|
||||
const candidates = Array.from(
|
||||
{ length: context.values.length },
|
||||
(_unused, cell) => cell,
|
||||
).filter((cell) => hasCandidate(context, cell, value));
|
||||
let visits = 0;
|
||||
for (const start of candidates) {
|
||||
const path = [start];
|
||||
const inPath = new Set(path);
|
||||
const search = (nextStrong: boolean): LogicalStep | undefined => {
|
||||
if (visits >= MAX_CHAIN_VISITS) return undefined;
|
||||
visits += 1;
|
||||
const current = path[path.length - 1] as number;
|
||||
const edges = path.length - 1;
|
||||
if (!nextStrong && edges >= 3) {
|
||||
const found = step(
|
||||
"x-chain",
|
||||
commonPeerEliminations(context, [start, current], value, inPath),
|
||||
path,
|
||||
`An alternating strong/weak X-Chain proves that at least one endpoint is ${value}.`,
|
||||
);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
if (path.length >= MAX_CHAIN_NODES) return undefined;
|
||||
const neighbours = nextStrong
|
||||
? [...(strong[current] ?? [])]
|
||||
: [...(context.peers[current] ?? [])].filter((cell) =>
|
||||
hasCandidate(context, cell, value),
|
||||
);
|
||||
neighbours.sort((a, b) => a - b);
|
||||
for (const neighbour of neighbours) {
|
||||
if (inPath.has(neighbour)) continue;
|
||||
path.push(neighbour);
|
||||
inPath.add(neighbour);
|
||||
const found = search(!nextStrong);
|
||||
if (found !== undefined) return found;
|
||||
inPath.delete(neighbour);
|
||||
path.pop();
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const found = search(true);
|
||||
if (found !== undefined) return found;
|
||||
if (visits >= MAX_CHAIN_VISITS) return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function findXYChain(
|
||||
context: AdvancedLogicalContext,
|
||||
): LogicalStep | undefined {
|
||||
const bivalue = context.masks
|
||||
.map((mask, cell) => ({ cell, mask }))
|
||||
.filter(
|
||||
({ cell, mask }) => context.values[cell] === 0 && popcount(mask) === 2,
|
||||
);
|
||||
const byCell = new Map(bivalue.map((entry) => [entry.cell, entry.mask]));
|
||||
let visits = 0;
|
||||
for (const start of bivalue) {
|
||||
for (const target of maskDigits(start.mask, context.size)) {
|
||||
const initialLink = maskDigits(
|
||||
start.mask & ~digitBit(target),
|
||||
context.size,
|
||||
)[0];
|
||||
if (initialLink === undefined) continue;
|
||||
const path = [start.cell];
|
||||
const inPath = new Set(path);
|
||||
const search = (linkValue: number): LogicalStep | undefined => {
|
||||
if (visits >= MAX_CHAIN_VISITS) return undefined;
|
||||
visits += 1;
|
||||
if (path.length >= MAX_CHAIN_NODES) return undefined;
|
||||
const current = path[path.length - 1] as number;
|
||||
const neighbours = [...(context.peers[current] ?? [])]
|
||||
.filter(
|
||||
(cell) =>
|
||||
!inPath.has(cell) &&
|
||||
((byCell.get(cell) ?? 0) & digitBit(linkValue)) !== 0,
|
||||
)
|
||||
.sort((a, b) => a - b);
|
||||
for (const neighbour of neighbours) {
|
||||
const mask = byCell.get(neighbour) as number;
|
||||
const outgoing = maskDigits(
|
||||
mask & ~digitBit(linkValue),
|
||||
context.size,
|
||||
)[0];
|
||||
if (outgoing === undefined) continue;
|
||||
path.push(neighbour);
|
||||
inPath.add(neighbour);
|
||||
if (outgoing === target && path.length >= 3) {
|
||||
const found = step(
|
||||
"xy-chain",
|
||||
commonPeerEliminations(
|
||||
context,
|
||||
[start.cell, neighbour],
|
||||
target,
|
||||
inPath,
|
||||
),
|
||||
path,
|
||||
`This XY-Chain forces ${target} into at least one endpoint.`,
|
||||
);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
const found = search(outgoing);
|
||||
if (found !== undefined) return found;
|
||||
inPath.delete(neighbour);
|
||||
path.pop();
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const found = search(initialLink);
|
||||
if (found !== undefined) return found;
|
||||
if (visits >= MAX_CHAIN_VISITS) return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface CandidateNode {
|
||||
readonly cell: CellId;
|
||||
readonly value: number;
|
||||
}
|
||||
|
||||
function nodeId(context: AdvancedLogicalContext, node: CandidateNode): number {
|
||||
return node.cell * (context.size + 1) + node.value;
|
||||
}
|
||||
|
||||
function nodeFromId(
|
||||
context: AdvancedLogicalContext,
|
||||
id: number,
|
||||
): CandidateNode {
|
||||
return {
|
||||
cell: Math.floor(id / (context.size + 1)),
|
||||
value: id % (context.size + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function aicStrongAdjacency(
|
||||
context: AdvancedLogicalContext,
|
||||
): ReadonlyMap<number, ReadonlySet<number>> {
|
||||
const adjacency = new Map<number, Set<number>>();
|
||||
const add = (a: CandidateNode, b: CandidateNode): void => {
|
||||
const aId = nodeId(context, a);
|
||||
const bId = nodeId(context, b);
|
||||
const aLinks = adjacency.get(aId) ?? new Set<number>();
|
||||
const bLinks = adjacency.get(bId) ?? new Set<number>();
|
||||
aLinks.add(bId);
|
||||
bLinks.add(aId);
|
||||
adjacency.set(aId, aLinks);
|
||||
adjacency.set(bId, bLinks);
|
||||
};
|
||||
for (const link of cellStrongLinks(context)) {
|
||||
add(
|
||||
{ cell: link.a, value: link.value },
|
||||
{ cell: link.b, value: link.value },
|
||||
);
|
||||
}
|
||||
for (let cell = 0; cell < context.values.length; cell += 1) {
|
||||
const values = maskDigits(context.masks[cell] ?? 0, context.size);
|
||||
if (context.values[cell] !== 0 || values.length !== 2) continue;
|
||||
add(
|
||||
{ cell, value: values[0] as number },
|
||||
{ cell, value: values[1] as number },
|
||||
);
|
||||
}
|
||||
return adjacency;
|
||||
}
|
||||
|
||||
function aicWeakNeighbours(
|
||||
context: AdvancedLogicalContext,
|
||||
node: CandidateNode,
|
||||
): number[] {
|
||||
const ids: number[] = [];
|
||||
for (const value of maskDigits(context.masks[node.cell] ?? 0, context.size)) {
|
||||
if (value !== node.value)
|
||||
ids.push(nodeId(context, { cell: node.cell, value }));
|
||||
}
|
||||
for (const cell of context.peers[node.cell] ?? []) {
|
||||
if (hasCandidate(context, cell, node.value)) {
|
||||
ids.push(nodeId(context, { cell, value: node.value }));
|
||||
}
|
||||
}
|
||||
return uniqueSorted(ids);
|
||||
}
|
||||
|
||||
export function findAic(
|
||||
context: AdvancedLogicalContext,
|
||||
): LogicalStep | undefined {
|
||||
const strong = aicStrongAdjacency(context);
|
||||
const starts = [...strong.keys()].sort((a, b) => a - b);
|
||||
let visits = 0;
|
||||
for (const startId of starts) {
|
||||
const start = nodeFromId(context, startId);
|
||||
const path = [startId];
|
||||
const inPath = new Set(path);
|
||||
const search = (nextStrong: boolean): LogicalStep | undefined => {
|
||||
if (visits >= MAX_CHAIN_VISITS) return undefined;
|
||||
visits += 1;
|
||||
const currentId = path[path.length - 1] as number;
|
||||
const current = nodeFromId(context, currentId);
|
||||
const edges = path.length - 1;
|
||||
if (
|
||||
!nextStrong &&
|
||||
edges >= 3 &&
|
||||
current.cell !== start.cell &&
|
||||
current.value === start.value
|
||||
) {
|
||||
const pathCells = new Set(
|
||||
path.map((id) => nodeFromId(context, id).cell),
|
||||
);
|
||||
const found = step(
|
||||
"aic",
|
||||
commonPeerEliminations(
|
||||
context,
|
||||
[start.cell, current.cell],
|
||||
start.value,
|
||||
pathCells,
|
||||
),
|
||||
pathCells,
|
||||
`An Alternating Inference Chain proves that at least one endpoint is ${start.value}.`,
|
||||
);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
if (path.length >= MAX_CHAIN_NODES) return undefined;
|
||||
const neighbours = nextStrong
|
||||
? [...(strong.get(currentId) ?? [])].sort((a, b) => a - b)
|
||||
: aicWeakNeighbours(context, current);
|
||||
for (const neighbour of neighbours) {
|
||||
if (inPath.has(neighbour)) continue;
|
||||
path.push(neighbour);
|
||||
inPath.add(neighbour);
|
||||
const found = search(!nextStrong);
|
||||
if (found !== undefined) return found;
|
||||
inPath.delete(neighbour);
|
||||
path.pop();
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const found = search(true);
|
||||
if (found !== undefined) return found;
|
||||
if (visits >= MAX_CHAIN_VISITS) return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function findUniqueRectangle(
|
||||
context: AdvancedLogicalContext,
|
||||
): LogicalStep | undefined {
|
||||
if (
|
||||
context.uniquenessProven !== true ||
|
||||
context.uniquenessPatternsSafe !== true
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
for (let firstRow = 0; firstRow < context.size; firstRow += 1) {
|
||||
for (
|
||||
let secondRow = firstRow + 1;
|
||||
secondRow < context.size;
|
||||
secondRow += 1
|
||||
) {
|
||||
for (let firstColumn = 0; firstColumn < context.size; firstColumn += 1) {
|
||||
for (
|
||||
let secondColumn = firstColumn + 1;
|
||||
secondColumn < context.size;
|
||||
secondColumn += 1
|
||||
) {
|
||||
const cells = [
|
||||
firstRow * context.size + firstColumn,
|
||||
firstRow * context.size + secondColumn,
|
||||
secondRow * context.size + firstColumn,
|
||||
secondRow * context.size + secondColumn,
|
||||
];
|
||||
if (cells.some((cell) => context.values[cell] !== 0)) continue;
|
||||
const regionCounts = new Map<number, number>();
|
||||
for (const cell of cells) {
|
||||
const region = context.regions[cell];
|
||||
if (region === undefined) continue;
|
||||
regionCounts.set(region, (regionCounts.get(region) ?? 0) + 1);
|
||||
}
|
||||
if (
|
||||
regionCounts.size !== 2 ||
|
||||
[...regionCounts.values()].some((count) => count !== 2)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const regionsPreservePair = [...regionCounts.keys()].every(
|
||||
(region) => {
|
||||
const pair = cells.filter(
|
||||
(cell) => context.regions[cell] === region,
|
||||
);
|
||||
const first = pair[0];
|
||||
const second = pair[1];
|
||||
return (
|
||||
first !== undefined &&
|
||||
second !== undefined &&
|
||||
(Math.floor(first / context.size) ===
|
||||
Math.floor(second / context.size) ||
|
||||
first % context.size === second % context.size)
|
||||
);
|
||||
},
|
||||
);
|
||||
if (!regionsPreservePair) continue;
|
||||
for (let roofIndex = 0; roofIndex < cells.length; roofIndex += 1) {
|
||||
const roof = cells[roofIndex] as number;
|
||||
const floor = cells.filter((_cell, index) => index !== roofIndex);
|
||||
const pairMask = context.masks[floor[0] as number] ?? 0;
|
||||
if (
|
||||
popcount(pairMask) !== 2 ||
|
||||
floor.some((cell) => (context.masks[cell] ?? 0) !== pairMask)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const roofMask = context.masks[roof] ?? 0;
|
||||
if (
|
||||
(roofMask & pairMask) !== pairMask ||
|
||||
popcount(roofMask & ~pairMask) === 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const values = maskDigits(pairMask, context.size);
|
||||
const found = step(
|
||||
"unique-rectangle",
|
||||
[{ cell: roof, values }],
|
||||
cells,
|
||||
`A proven-unique puzzle cannot complete this ${values.join("/")} rectangle in two interchangeable ways.`,
|
||||
);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function findAdvancedLogicalStep(
|
||||
context: AdvancedLogicalContext,
|
||||
): LogicalStep | undefined {
|
||||
return (
|
||||
findJellyfish(context) ??
|
||||
findFinnedFish(context) ??
|
||||
findSkyscraper(context) ??
|
||||
findTwoStringKite(context) ??
|
||||
findSimpleColouring(context) ??
|
||||
findWWing(context) ??
|
||||
findUniqueRectangle(context) ??
|
||||
findXChain(context) ??
|
||||
findXYChain(context) ??
|
||||
findAic(context)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import {
|
||||
normalizePuzzle,
|
||||
type NormalizedPuzzle,
|
||||
type PuzzleDefinition,
|
||||
} from "../domain";
|
||||
import { solveExact, type ExactLimitReason } from "./exact";
|
||||
import {
|
||||
solveLogically,
|
||||
type LogicalSolveStatus,
|
||||
type LogicalTechnique,
|
||||
} from "./logical";
|
||||
|
||||
export type DifficultyLevel =
|
||||
"beginner" | "easy" | "medium" | "hard" | "expert" | "extreme" | "unrated";
|
||||
|
||||
export type UniquenessStatus = "unique" | "multiple" | "none" | "unknown";
|
||||
|
||||
export interface DifficultyOptions {
|
||||
/** Maximum logical steps considered by the human-style solver. */
|
||||
readonly logicalMaxSteps?: number;
|
||||
/** Maximum exact-search nodes used to verify uniqueness. */
|
||||
readonly exactMaxNodes?: number;
|
||||
/** Wall-clock safety limit for uniqueness verification. */
|
||||
readonly exactTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface DifficultyAssessment {
|
||||
/** A stable 0–100 estimate, or null when the puzzle cannot be rated safely. */
|
||||
readonly score: number | null;
|
||||
readonly level: DifficultyLevel;
|
||||
readonly label: string;
|
||||
readonly uniqueness: UniquenessStatus;
|
||||
readonly clueCount: number;
|
||||
readonly emptyCount: number;
|
||||
readonly logicalStatus: LogicalSolveStatus;
|
||||
readonly logicalSteps: number;
|
||||
readonly hardestTechnique?: LogicalTechnique;
|
||||
readonly techniqueCounts: Readonly<Partial<Record<LogicalTechnique, number>>>;
|
||||
readonly exactNodes: number;
|
||||
readonly exactTruncated: boolean;
|
||||
readonly exactLimitReason?: ExactLimitReason;
|
||||
readonly summary: string;
|
||||
}
|
||||
|
||||
const TECHNIQUE_WEIGHT: Readonly<Record<LogicalTechnique, number>> = {
|
||||
"naked-single": 3,
|
||||
"hidden-single": 8,
|
||||
"killer-cage": 16,
|
||||
"naked-pair": 18,
|
||||
pointing: 23,
|
||||
claiming: 25,
|
||||
"hidden-pair": 27,
|
||||
"naked-triple": 31,
|
||||
"hidden-triple": 36,
|
||||
"naked-quad": 40,
|
||||
"hidden-quad": 44,
|
||||
"x-wing": 50,
|
||||
"xy-wing": 57,
|
||||
"xyz-wing": 61,
|
||||
swordfish: 66,
|
||||
skyscraper: 67,
|
||||
"two-string-kite": 68,
|
||||
"finned-x-wing": 69,
|
||||
"simple-colouring": 70,
|
||||
jellyfish: 72,
|
||||
"w-wing": 73,
|
||||
"unique-rectangle": 74,
|
||||
"finned-swordfish": 75,
|
||||
"x-chain": 78,
|
||||
"xy-chain": 82,
|
||||
aic: 88,
|
||||
};
|
||||
|
||||
function boundedInteger(
|
||||
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 levelForScore(score: number): Exclude<DifficultyLevel, "unrated"> {
|
||||
if (score <= 20) return "beginner";
|
||||
if (score <= 36) return "easy";
|
||||
if (score <= 54) return "medium";
|
||||
if (score <= 72) return "hard";
|
||||
if (score <= 88) return "expert";
|
||||
return "extreme";
|
||||
}
|
||||
|
||||
function titleCase(value: string): string {
|
||||
return `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
|
||||
}
|
||||
|
||||
function unrated(
|
||||
normalized: NormalizedPuzzle,
|
||||
uniqueness: Exclude<UniquenessStatus, "unique">,
|
||||
logicalStatus: LogicalSolveStatus,
|
||||
logicalSteps: number,
|
||||
exact: {
|
||||
readonly nodes: number;
|
||||
readonly truncated: boolean;
|
||||
readonly limitReason?: ExactLimitReason;
|
||||
},
|
||||
summary: string,
|
||||
): DifficultyAssessment {
|
||||
const clueCount = normalized.givens.filter((value) => value !== 0).length;
|
||||
return {
|
||||
score: null,
|
||||
level: "unrated",
|
||||
label: "Unrated",
|
||||
uniqueness,
|
||||
clueCount,
|
||||
emptyCount: normalized.givens.length - clueCount,
|
||||
logicalStatus,
|
||||
logicalSteps,
|
||||
techniqueCounts: {},
|
||||
exactNodes: exact.nodes,
|
||||
exactTruncated: exact.truncated,
|
||||
...(exact.limitReason === undefined
|
||||
? {}
|
||||
: { exactLimitReason: exact.limitReason }),
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rates a puzzle using only deterministic solver evidence. Uniqueness is never
|
||||
* claimed when the exact search hits a node or time limit.
|
||||
*/
|
||||
export function evaluateDifficulty(
|
||||
puzzle: PuzzleDefinition | NormalizedPuzzle,
|
||||
options: DifficultyOptions = {},
|
||||
): DifficultyAssessment {
|
||||
const normalized = normalizePuzzle(puzzle);
|
||||
const logicalMaxSteps = boundedInteger(
|
||||
options.logicalMaxSteps,
|
||||
2_000,
|
||||
1,
|
||||
10_000,
|
||||
"logicalMaxSteps",
|
||||
);
|
||||
const exactMaxNodes = boundedInteger(
|
||||
options.exactMaxNodes,
|
||||
2_000_000,
|
||||
1,
|
||||
100_000_000,
|
||||
"exactMaxNodes",
|
||||
);
|
||||
const exactTimeoutMs = boundedInteger(
|
||||
options.exactTimeoutMs,
|
||||
10_000,
|
||||
1,
|
||||
120_000,
|
||||
"exactTimeoutMs",
|
||||
);
|
||||
const exact = solveExact(normalized, {
|
||||
maxSolutions: 2,
|
||||
maxNodes: exactMaxNodes,
|
||||
timeoutMs: exactTimeoutMs,
|
||||
});
|
||||
const logical = solveLogically(normalized, {
|
||||
maxSteps: logicalMaxSteps,
|
||||
uniquenessProven: exact.count === 1 && !exact.truncated,
|
||||
});
|
||||
|
||||
if (exact.count === 0 && !exact.truncated) {
|
||||
return unrated(
|
||||
normalized,
|
||||
"none",
|
||||
logical.status,
|
||||
logical.steps.length,
|
||||
exact,
|
||||
"The puzzle has no solution, so it cannot be rated.",
|
||||
);
|
||||
}
|
||||
if (exact.count >= 2) {
|
||||
return unrated(
|
||||
normalized,
|
||||
"multiple",
|
||||
logical.status,
|
||||
logical.steps.length,
|
||||
exact,
|
||||
"The puzzle has multiple solutions, so a difficulty rating would be misleading.",
|
||||
);
|
||||
}
|
||||
if (exact.truncated) {
|
||||
return unrated(
|
||||
normalized,
|
||||
"unknown",
|
||||
logical.status,
|
||||
logical.steps.length,
|
||||
exact,
|
||||
"The bounded uniqueness check did not finish; uniqueness and difficulty remain unknown.",
|
||||
);
|
||||
}
|
||||
|
||||
const techniqueCounts: Partial<Record<LogicalTechnique, number>> = {};
|
||||
let hardestTechnique: LogicalTechnique | undefined;
|
||||
let hardestWeight = 0;
|
||||
for (const step of logical.steps) {
|
||||
techniqueCounts[step.technique] =
|
||||
(techniqueCounts[step.technique] ?? 0) + 1;
|
||||
const weight = TECHNIQUE_WEIGHT[step.technique];
|
||||
if (weight >= hardestWeight) {
|
||||
hardestWeight = weight;
|
||||
hardestTechnique = step.technique;
|
||||
}
|
||||
}
|
||||
|
||||
const clueCount = normalized.givens.filter((value) => value !== 0).length;
|
||||
const emptyCount = normalized.givens.length - clueCount;
|
||||
const emptyRatio = emptyCount / normalized.givens.length;
|
||||
let score: number;
|
||||
if (logical.status === "solved") {
|
||||
const stepLoad = Math.min(
|
||||
13,
|
||||
Math.round(
|
||||
Math.log2(logical.steps.length + 1) * 2 +
|
||||
logical.steps.length / normalized.size,
|
||||
),
|
||||
);
|
||||
score = Math.round(3 + emptyRatio * 14 + hardestWeight * 0.78 + stepLoad);
|
||||
} else {
|
||||
// A solver that is stuck after the supported human techniques is at least
|
||||
// hard. Exact node count distinguishes bounded search effort without using
|
||||
// elapsed time, keeping the score stable across devices.
|
||||
score = Math.round(
|
||||
70 +
|
||||
emptyRatio * 8 +
|
||||
Math.min(22, Math.log2(Math.max(1, exact.nodes)) * 2.1),
|
||||
);
|
||||
}
|
||||
score = Math.max(0, Math.min(100, score));
|
||||
const level = levelForScore(score);
|
||||
const hardest =
|
||||
hardestTechnique === undefined
|
||||
? "direct placements"
|
||||
: hardestTechnique.replaceAll("-", " ");
|
||||
const summary =
|
||||
logical.status === "solved"
|
||||
? `Solved logically in ${String(logical.steps.length)} steps; hardest supported technique: ${hardest}.`
|
||||
: `Unique, but the supported logical solver became ${logical.status}; exact search visited ${String(exact.nodes)} nodes.`;
|
||||
|
||||
return {
|
||||
score,
|
||||
level,
|
||||
label: titleCase(level),
|
||||
uniqueness: "unique",
|
||||
clueCount,
|
||||
emptyCount,
|
||||
logicalStatus: logical.status,
|
||||
logicalSteps: logical.steps.length,
|
||||
...(hardestTechnique === undefined ? {} : { hardestTechnique }),
|
||||
techniqueCounts,
|
||||
exactNodes: exact.nodes,
|
||||
exactTruncated: false,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
@@ -111,6 +111,13 @@ export function solveExact(
|
||||
"timeoutMs",
|
||||
);
|
||||
const compiled = compilePuzzle(normalized);
|
||||
const preferredSolution =
|
||||
normalized.solution !== undefined &&
|
||||
board.every(
|
||||
(value, cell) => value === 0 || value === normalized.solution?.[cell],
|
||||
)
|
||||
? normalized.solution
|
||||
: undefined;
|
||||
const random: RandomSource | undefined =
|
||||
options.seed === undefined ? undefined : seededRandom(options.seed);
|
||||
const solutions: number[][] = [];
|
||||
@@ -157,6 +164,15 @@ export function solveExact(
|
||||
const chosen = selection[0];
|
||||
let choices = selection[1];
|
||||
if (random !== undefined) choices = shuffled(choices, random);
|
||||
else {
|
||||
const preferred = preferredSolution?.[chosen];
|
||||
if (preferred !== undefined && choices.includes(preferred)) {
|
||||
choices = [
|
||||
preferred,
|
||||
...choices.filter((value) => value !== preferred),
|
||||
];
|
||||
}
|
||||
}
|
||||
for (const value of choices) {
|
||||
board[chosen] = value;
|
||||
search();
|
||||
|
||||
+280
-36
@@ -7,12 +7,45 @@ import {
|
||||
import { solveExact } from "./exact";
|
||||
import { seededRandom, shuffled } from "./random";
|
||||
|
||||
export type ClueSymmetry = "none" | "rotational";
|
||||
export type ClueSymmetry =
|
||||
| "none"
|
||||
| "rotational"
|
||||
| "horizontal"
|
||||
| "vertical"
|
||||
| "diagonal-main"
|
||||
| "diagonal-anti"
|
||||
| "orthogonal"
|
||||
/** Alias for orthogonal quarter-turn symmetry. */
|
||||
| "four-way";
|
||||
|
||||
export type MinimalGivensStatus =
|
||||
"not-requested" | "proven-minimal" | "unknown";
|
||||
|
||||
export type MinimalityLimitReason =
|
||||
"check-cap" | "node-cap" | "timeout" | "not-unique" | "inconsistent-result";
|
||||
|
||||
export interface MinimalGivensEvidence {
|
||||
readonly status: MinimalGivensStatus;
|
||||
readonly checksPerformed: number;
|
||||
readonly nodes: number;
|
||||
readonly removedClues: number;
|
||||
readonly criticalCells: readonly number[];
|
||||
readonly unknownCells: readonly number[];
|
||||
readonly limitReasons: readonly MinimalityLimitReason[];
|
||||
readonly symmetryPreserved: boolean;
|
||||
}
|
||||
|
||||
export interface MinimizePuzzleResult {
|
||||
readonly puzzle: NormalizedPuzzle;
|
||||
readonly minimality: MinimalGivensEvidence;
|
||||
}
|
||||
|
||||
export interface MinimizeOptions {
|
||||
readonly seed?: string | number;
|
||||
readonly targetClues?: number;
|
||||
readonly symmetry?: ClueSymmetry;
|
||||
/** Continue with individual clue deletion tests until givens are minimal. */
|
||||
readonly minimalGivens?: boolean;
|
||||
readonly maxChecks?: number;
|
||||
readonly solveMaxNodes?: number;
|
||||
readonly solveTimeoutMs?: number;
|
||||
@@ -117,11 +150,106 @@ function boundedOption(
|
||||
return result;
|
||||
}
|
||||
|
||||
export function minimizePuzzle(
|
||||
function validatedSymmetry(value: ClueSymmetry | undefined): ClueSymmetry {
|
||||
const symmetry = value ?? "rotational";
|
||||
if (
|
||||
symmetry !== "none" &&
|
||||
symmetry !== "rotational" &&
|
||||
symmetry !== "horizontal" &&
|
||||
symmetry !== "vertical" &&
|
||||
symmetry !== "diagonal-main" &&
|
||||
symmetry !== "diagonal-anti" &&
|
||||
symmetry !== "orthogonal" &&
|
||||
symmetry !== "four-way"
|
||||
) {
|
||||
throw new RangeError(`Unsupported clue symmetry: ${String(symmetry)}.`);
|
||||
}
|
||||
return symmetry;
|
||||
}
|
||||
|
||||
/** Returns the complete deterministic clue orbit for a symmetry. */
|
||||
export function clueOrbit(
|
||||
size: number,
|
||||
cell: number,
|
||||
requestedSymmetry: ClueSymmetry,
|
||||
): readonly number[] {
|
||||
if (
|
||||
!Number.isInteger(size) ||
|
||||
size < 1 ||
|
||||
!Number.isInteger(cell) ||
|
||||
cell < 0 ||
|
||||
cell >= size * size
|
||||
) {
|
||||
throw new RangeError("Clue orbit requires a valid square-grid cell.");
|
||||
}
|
||||
const symmetry = validatedSymmetry(requestedSymmetry);
|
||||
const row = Math.floor(cell / size);
|
||||
const column = cell % size;
|
||||
const at = (nextRow: number, nextColumn: number): number =>
|
||||
nextRow * size + nextColumn;
|
||||
const horizontal = at(size - row - 1, column);
|
||||
const vertical = at(row, size - column - 1);
|
||||
const rotational = at(size - row - 1, size - column - 1);
|
||||
const quarterTurn = at(column, size - row - 1);
|
||||
const threeQuarterTurn = at(size - column - 1, row);
|
||||
const cells = (() => {
|
||||
switch (symmetry) {
|
||||
case "none":
|
||||
return [cell];
|
||||
case "rotational":
|
||||
return [cell, rotational];
|
||||
case "horizontal":
|
||||
return [cell, horizontal];
|
||||
case "vertical":
|
||||
return [cell, vertical];
|
||||
case "diagonal-main":
|
||||
return [cell, at(column, row)];
|
||||
case "diagonal-anti":
|
||||
return [cell, at(size - column - 1, size - row - 1)];
|
||||
case "orthogonal":
|
||||
case "four-way":
|
||||
return [cell, quarterTurn, rotational, threeQuarterTurn];
|
||||
}
|
||||
})();
|
||||
return [...new Set(cells)].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function cluePatternPreservesSymmetry(
|
||||
givens: readonly number[],
|
||||
size: number,
|
||||
symmetry: ClueSymmetry,
|
||||
): boolean {
|
||||
for (let cell = 0; cell < givens.length; cell += 1) {
|
||||
const present = (givens[cell] ?? 0) !== 0;
|
||||
if (
|
||||
clueOrbit(size, cell, symmetry).some(
|
||||
(other) => ((givens[other] ?? 0) !== 0) !== present,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function uniqueAfterRemoval(
|
||||
puzzle: PuzzleDefinition,
|
||||
maxNodes: number,
|
||||
timeoutMs: number,
|
||||
) {
|
||||
return solveExact(puzzle, {
|
||||
maxSolutions: 2,
|
||||
maxNodes,
|
||||
timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function minimizePuzzleWithReport(
|
||||
puzzle: PuzzleDefinition | NormalizedPuzzle,
|
||||
options: MinimizeOptions = {},
|
||||
): NormalizedPuzzle {
|
||||
): MinimizePuzzleResult {
|
||||
const normalized = normalizePuzzle(puzzle);
|
||||
const minimalGivens = options.minimalGivens === true;
|
||||
const targetClues = boundedOption(
|
||||
options.targetClues,
|
||||
Math.max(
|
||||
@@ -134,17 +262,30 @@ export function minimizePuzzle(
|
||||
);
|
||||
const maxChecks = boundedOption(
|
||||
options.maxChecks,
|
||||
normalized.size * normalized.size * 2,
|
||||
normalized.size * normalized.size * (minimalGivens ? 3 : 2),
|
||||
1,
|
||||
normalized.size * normalized.size * 10,
|
||||
"maxChecks",
|
||||
);
|
||||
const symmetry = options.symmetry ?? "rotational";
|
||||
if (symmetry !== "none" && symmetry !== "rotational") {
|
||||
throw new RangeError('symmetry must be "none" or "rotational"');
|
||||
}
|
||||
const random = seededRandom(options.seed ?? "sudoku-tools");
|
||||
const solveMaxNodes = boundedOption(
|
||||
options.solveMaxNodes,
|
||||
2_000_000,
|
||||
1,
|
||||
100_000_000,
|
||||
"solveMaxNodes",
|
||||
);
|
||||
const solveTimeoutMs = boundedOption(
|
||||
options.solveTimeoutMs,
|
||||
10_000,
|
||||
1,
|
||||
120_000,
|
||||
"solveTimeoutMs",
|
||||
);
|
||||
const symmetry = validatedSymmetry(options.symmetry);
|
||||
const seed = options.seed ?? "sudoku-tools";
|
||||
const random = seededRandom(seed);
|
||||
const givens = [...normalized.givens];
|
||||
const initialClues = givens.filter((value) => value !== 0).length;
|
||||
const countClues = (): number =>
|
||||
givens.reduce((count, value) => count + (value === 0 ? 0 : 1), 0);
|
||||
const order = shuffled(
|
||||
@@ -152,36 +293,139 @@ export function minimizePuzzle(
|
||||
random,
|
||||
);
|
||||
let checks = 0;
|
||||
for (const cell of order) {
|
||||
if (checks >= maxChecks || countClues() <= targetClues) break;
|
||||
if (givens[cell] === 0) continue;
|
||||
const mirror = givens.length - cell - 1;
|
||||
const group =
|
||||
symmetry === "rotational" && mirror !== cell ? [cell, mirror] : [cell];
|
||||
if (group.some((entry) => givens[entry] === 0)) continue;
|
||||
if (countClues() - group.length < targetClues) continue;
|
||||
const saved = group.map((entry) => givens[entry] ?? 0);
|
||||
group.forEach((entry) => {
|
||||
givens[entry] = 0;
|
||||
});
|
||||
let nodes = 0;
|
||||
const criticalCells: number[] = [];
|
||||
const unknownCells: number[] = [];
|
||||
const limitReasons = new Set<MinimalityLimitReason>();
|
||||
let uniquenessPrerequisiteProven = true;
|
||||
|
||||
// Minimality is meaningful only for a uniquely solvable starting puzzle.
|
||||
// This check shares the same explicit check, node and wall-clock budgets as
|
||||
// every subsequent deletion test.
|
||||
if (minimalGivens) {
|
||||
checks += 1;
|
||||
const candidate: PuzzleDefinition = {
|
||||
...normalized,
|
||||
givens,
|
||||
solution: normalized.solution,
|
||||
};
|
||||
const result = solveExact(candidate, {
|
||||
maxSolutions: 2,
|
||||
maxNodes: options.solveMaxNodes ?? 2_000_000,
|
||||
timeoutMs: options.solveTimeoutMs ?? 10_000,
|
||||
});
|
||||
if (result.count !== 1 || result.truncated) {
|
||||
group.forEach((entry, index) => {
|
||||
givens[entry] = saved[index] ?? 0;
|
||||
});
|
||||
const baseline = uniqueAfterRemoval(
|
||||
{ ...normalized, givens, solution: normalized.solution },
|
||||
solveMaxNodes,
|
||||
solveTimeoutMs,
|
||||
);
|
||||
nodes += baseline.nodes;
|
||||
if (baseline.count !== 1 || baseline.truncated) {
|
||||
uniquenessPrerequisiteProven = false;
|
||||
unknownCells.push(
|
||||
...givens.flatMap((value, cell) => (value === 0 ? [] : [cell])),
|
||||
);
|
||||
if (
|
||||
baseline.count >= 2 ||
|
||||
(baseline.count === 0 && !baseline.truncated)
|
||||
) {
|
||||
limitReasons.add("not-unique");
|
||||
} else if (baseline.limitReason === "node-cap") {
|
||||
limitReasons.add("node-cap");
|
||||
} else if (baseline.limitReason === "timeout") {
|
||||
limitReasons.add("timeout");
|
||||
} else {
|
||||
limitReasons.add("inconsistent-result");
|
||||
}
|
||||
}
|
||||
}
|
||||
return normalizePuzzle({ ...normalized, givens });
|
||||
|
||||
if (!minimalGivens || uniquenessPrerequisiteProven) {
|
||||
for (const cell of order) {
|
||||
if (checks >= maxChecks || countClues() <= targetClues) break;
|
||||
if (givens[cell] === 0) continue;
|
||||
const group = clueOrbit(normalized.size, cell, symmetry);
|
||||
if (group.some((entry) => givens[entry] === 0)) continue;
|
||||
if (countClues() - group.length < targetClues) continue;
|
||||
const saved = group.map((entry) => givens[entry] ?? 0);
|
||||
group.forEach((entry) => {
|
||||
givens[entry] = 0;
|
||||
});
|
||||
checks += 1;
|
||||
const result = uniqueAfterRemoval(
|
||||
{ ...normalized, givens, solution: normalized.solution },
|
||||
solveMaxNodes,
|
||||
solveTimeoutMs,
|
||||
);
|
||||
nodes += result.nodes;
|
||||
if (result.count !== 1 || result.truncated) {
|
||||
group.forEach((entry, index) => {
|
||||
givens[entry] = saved[index] ?? 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (minimalGivens && uniquenessPrerequisiteProven) {
|
||||
const minimalOrder = shuffled(
|
||||
Array.from({ length: givens.length }, (_, cell) => cell).filter(
|
||||
(cell) => givens[cell] !== 0,
|
||||
),
|
||||
seededRandom(`${String(seed)}:minimal-givens`),
|
||||
);
|
||||
for (let index = 0; index < minimalOrder.length; index += 1) {
|
||||
const cell = minimalOrder[index] as number;
|
||||
if (givens[cell] === 0) continue;
|
||||
if (checks >= maxChecks) {
|
||||
limitReasons.add("check-cap");
|
||||
unknownCells.push(
|
||||
...minimalOrder
|
||||
.slice(index)
|
||||
.filter((remaining) => givens[remaining] !== 0),
|
||||
);
|
||||
break;
|
||||
}
|
||||
const saved = givens[cell] as number;
|
||||
givens[cell] = 0;
|
||||
checks += 1;
|
||||
const result = uniqueAfterRemoval(
|
||||
{ ...normalized, givens, solution: normalized.solution },
|
||||
solveMaxNodes,
|
||||
solveTimeoutMs,
|
||||
);
|
||||
nodes += result.nodes;
|
||||
if (result.count === 1 && !result.truncated) continue;
|
||||
givens[cell] = saved;
|
||||
if (result.count >= 2) {
|
||||
criticalCells.push(cell);
|
||||
} else {
|
||||
unknownCells.push(cell);
|
||||
if (result.limitReason === "node-cap") limitReasons.add("node-cap");
|
||||
else if (result.limitReason === "timeout") limitReasons.add("timeout");
|
||||
else limitReasons.add("inconsistent-result");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const minimized = normalizePuzzle({ ...normalized, givens });
|
||||
return {
|
||||
puzzle: minimized,
|
||||
minimality: {
|
||||
status: minimalGivens
|
||||
? uniquenessPrerequisiteProven && unknownCells.length === 0
|
||||
? "proven-minimal"
|
||||
: "unknown"
|
||||
: "not-requested",
|
||||
checksPerformed: checks,
|
||||
nodes,
|
||||
removedClues: initialClues - countClues(),
|
||||
criticalCells: [...new Set(criticalCells)].sort((a, b) => a - b),
|
||||
unknownCells: [...new Set(unknownCells)].sort((a, b) => a - b),
|
||||
limitReasons: [...limitReasons].sort(),
|
||||
symmetryPreserved: cluePatternPreservesSymmetry(
|
||||
givens,
|
||||
normalized.size,
|
||||
symmetry,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function minimizePuzzle(
|
||||
puzzle: PuzzleDefinition | NormalizedPuzzle,
|
||||
options: MinimizeOptions = {},
|
||||
): NormalizedPuzzle {
|
||||
return minimizePuzzleWithReport(puzzle, options).puzzle;
|
||||
}
|
||||
|
||||
export function generateClassic(
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export * from "./advancedLogical";
|
||||
export * from "./difficulty";
|
||||
export * from "./exact";
|
||||
export * from "./generator";
|
||||
export * from "./killer";
|
||||
export * from "./logical";
|
||||
export * from "./quality";
|
||||
export * from "./variantGenerator";
|
||||
|
||||
+121
-5
@@ -12,6 +12,7 @@ import {
|
||||
type SudokuUnit,
|
||||
type ValidationIssue,
|
||||
} from "../domain";
|
||||
import { findAdvancedLogicalStep } from "./advancedLogical";
|
||||
|
||||
export type LogicalTechnique =
|
||||
| "naked-single"
|
||||
@@ -26,6 +27,17 @@ export type LogicalTechnique =
|
||||
| "claiming"
|
||||
| "x-wing"
|
||||
| "swordfish"
|
||||
| "jellyfish"
|
||||
| "finned-x-wing"
|
||||
| "finned-swordfish"
|
||||
| "skyscraper"
|
||||
| "two-string-kite"
|
||||
| "simple-colouring"
|
||||
| "w-wing"
|
||||
| "x-chain"
|
||||
| "xy-chain"
|
||||
| "aic"
|
||||
| "unique-rectangle"
|
||||
| "xy-wing"
|
||||
| "xyz-wing"
|
||||
| "killer-cage";
|
||||
@@ -52,6 +64,19 @@ export type LogicalSolveStatus = "solved" | "stuck" | "invalid" | "step-limit";
|
||||
|
||||
export interface LogicalSolveOptions {
|
||||
readonly values?: readonly number[];
|
||||
/**
|
||||
* Optional per-cell candidate restrictions to resume a logical solve after
|
||||
* applying an elimination-only step. Each entry contains ordinary Sudoku
|
||||
* digits (1 through the puzzle size), rather than an implementation-specific
|
||||
* bit mask. Restrictions are intersected with the candidates that remain
|
||||
* legal on the supplied board; entries for filled cells are ignored.
|
||||
*/
|
||||
readonly candidates?: readonly (readonly number[])[];
|
||||
/**
|
||||
* Enables uniqueness-dependent deductions only after the caller has proved
|
||||
* exactly one solution with an exhaustive solver result. Never inferred.
|
||||
*/
|
||||
readonly uniquenessProven?: boolean;
|
||||
readonly maxSteps?: number;
|
||||
}
|
||||
|
||||
@@ -148,9 +173,73 @@ function validateStart(
|
||||
if (issues.length > 0) throw new PuzzleValidationError(issues);
|
||||
}
|
||||
|
||||
function compileCandidateRestrictions(
|
||||
normalized: NormalizedPuzzle,
|
||||
input: unknown,
|
||||
): number[] | undefined {
|
||||
if (input === undefined) return undefined;
|
||||
|
||||
const issues: ValidationIssue[] = [];
|
||||
const cellCount = normalized.size * normalized.size;
|
||||
if (!Array.isArray(input)) {
|
||||
throw new PuzzleValidationError([
|
||||
{ path: "candidates", message: "must be an array of candidate arrays" },
|
||||
]);
|
||||
}
|
||||
if (input.length !== cellCount) {
|
||||
issues.push({
|
||||
path: "candidates",
|
||||
message: `must contain exactly ${cellCount} candidate arrays`,
|
||||
});
|
||||
}
|
||||
|
||||
const masks = new Array<number>(cellCount).fill(0);
|
||||
for (let cell = 0; cell < Math.min(input.length, cellCount); cell += 1) {
|
||||
const cellCandidates: unknown = input[cell];
|
||||
if (!Array.isArray(cellCandidates)) {
|
||||
issues.push({
|
||||
path: `candidates[${cell}]`,
|
||||
message: "must be an array of candidate digits",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let mask = 0;
|
||||
for (let index = 0; index < cellCandidates.length; index += 1) {
|
||||
const value: unknown = cellCandidates[index];
|
||||
if (
|
||||
typeof value !== "number" ||
|
||||
!Number.isInteger(value) ||
|
||||
value < 1 ||
|
||||
value > normalized.size
|
||||
) {
|
||||
issues.push({
|
||||
path: `candidates[${cell}][${index}]`,
|
||||
message: `must be an integer from 1 to ${normalized.size}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const bit = digitBit(value);
|
||||
if ((mask & bit) !== 0) {
|
||||
issues.push({
|
||||
path: `candidates[${cell}][${index}]`,
|
||||
message: `contains duplicate candidate ${value}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
mask |= bit;
|
||||
}
|
||||
masks[cell] = mask;
|
||||
}
|
||||
|
||||
if (issues.length > 0) throw new PuzzleValidationError(issues);
|
||||
return masks;
|
||||
}
|
||||
|
||||
function initializeState(
|
||||
normalized: NormalizedPuzzle,
|
||||
values: readonly number[],
|
||||
candidateRestrictions?: readonly number[],
|
||||
): LogicalState {
|
||||
const compiled = compilePuzzle(normalized);
|
||||
return {
|
||||
@@ -158,10 +247,13 @@ function initializeState(
|
||||
values: [...values],
|
||||
masks: values.map((value, cell) => {
|
||||
if (value !== 0) return 0;
|
||||
return candidatesForCell(compiled, values, cell).reduce(
|
||||
const legalMask = candidatesForCell(compiled, values, cell).reduce(
|
||||
(mask, candidate) => mask | digitBit(candidate),
|
||||
0,
|
||||
);
|
||||
return candidateRestrictions === undefined
|
||||
? legalMask
|
||||
: legalMask & (candidateRestrictions[cell] ?? 0);
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -553,7 +645,8 @@ function findXyzWing(state: LogicalState): LogicalStep | undefined {
|
||||
function findKillerReduction(state: LogicalState): LogicalStep | undefined {
|
||||
const size = state.compiled.puzzle.size;
|
||||
for (const constraint of state.compiled.puzzle.constraints) {
|
||||
if (constraint.type !== "killer-cage") continue;
|
||||
if (constraint.type !== "killer-cage" || constraint.negated === true)
|
||||
continue;
|
||||
const empty = constraint.cells.filter((cell) => state.values[cell] === 0);
|
||||
if (empty.length === 0) continue;
|
||||
const assigned = constraint.cells
|
||||
@@ -623,7 +716,10 @@ function findKillerReduction(state: LogicalState): LogicalStep | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findStep(state: LogicalState): LogicalStep | undefined {
|
||||
function findStep(
|
||||
state: LogicalState,
|
||||
uniquenessProven: boolean,
|
||||
): LogicalStep | undefined {
|
||||
return (
|
||||
findNakedSingle(state) ??
|
||||
findHiddenSingle(state) ??
|
||||
@@ -633,6 +729,16 @@ function findStep(state: LogicalState): LogicalStep | undefined {
|
||||
findFish(state) ??
|
||||
findXyWing(state) ??
|
||||
findXyzWing(state) ??
|
||||
findAdvancedLogicalStep({
|
||||
size: state.compiled.puzzle.size,
|
||||
values: state.values,
|
||||
masks: state.masks,
|
||||
regions: state.compiled.puzzle.regions,
|
||||
peers: state.compiled.peers,
|
||||
units: state.compiled.units,
|
||||
uniquenessProven,
|
||||
uniquenessPatternsSafe: state.compiled.puzzle.constraints.length === 0,
|
||||
}) ??
|
||||
findKillerReduction(state)
|
||||
);
|
||||
}
|
||||
@@ -676,7 +782,17 @@ export function solveLogically(
|
||||
if (!Number.isInteger(maxSteps) || maxSteps < 1 || maxSteps > 10_000) {
|
||||
throw new RangeError("maxSteps must be an integer from 1 to 10000");
|
||||
}
|
||||
const state = initializeState(normalized, values);
|
||||
if (
|
||||
options.uniquenessProven !== undefined &&
|
||||
typeof options.uniquenessProven !== "boolean"
|
||||
) {
|
||||
throw new TypeError("uniquenessProven must be a boolean when supplied");
|
||||
}
|
||||
const candidateRestrictions = compileCandidateRestrictions(
|
||||
normalized,
|
||||
options.candidates,
|
||||
);
|
||||
const state = initializeState(normalized, values, candidateRestrictions);
|
||||
const steps: LogicalStep[] = [];
|
||||
if (findConflicts(state.compiled, state.values).length > 0) {
|
||||
return {
|
||||
@@ -707,7 +823,7 @@ export function solveLogically(
|
||||
steps,
|
||||
};
|
||||
}
|
||||
const step = findStep(state);
|
||||
const step = findStep(state, options.uniquenessProven === true);
|
||||
if (step === undefined) {
|
||||
return {
|
||||
status: "stuck",
|
||||
|
||||
@@ -0,0 +1,791 @@
|
||||
import {
|
||||
PuzzleValidationError,
|
||||
constraintCells,
|
||||
normalizePuzzle,
|
||||
type NormalizedPuzzle,
|
||||
type PuzzleDefinition,
|
||||
type VariantConstraint,
|
||||
} from "../domain";
|
||||
import {
|
||||
solveExact,
|
||||
type ExactLimitReason,
|
||||
type ExactSolveResult,
|
||||
} from "./exact";
|
||||
|
||||
export type QualitySolutionStatus =
|
||||
"unsatisfiable" | "unique" | "multiple" | "unknown";
|
||||
|
||||
export type QualityClassification = "critical" | "redundant" | "unknown";
|
||||
|
||||
export type QualityUnknownReason =
|
||||
| "baseline-not-unique"
|
||||
| "baseline-unknown"
|
||||
| "per-check-node-cap"
|
||||
| "per-check-timeout"
|
||||
| "aggregate-check-cap"
|
||||
| "aggregate-node-cap"
|
||||
| "aggregate-timeout"
|
||||
| "unexpected-unsatisfiable-relaxation";
|
||||
|
||||
export type QualityCheckPurpose =
|
||||
"baseline" | "redundancy" | "contradiction-core";
|
||||
|
||||
export type QualityItemReference =
|
||||
| {
|
||||
readonly kind: "given";
|
||||
readonly cell: number;
|
||||
readonly value: number;
|
||||
}
|
||||
| {
|
||||
readonly kind: "constraint";
|
||||
readonly index: number;
|
||||
readonly constraintType: VariantConstraint["type"];
|
||||
};
|
||||
|
||||
export interface PuzzleQualityOptions {
|
||||
/** Baseline only is a fast 0/1/2-solution check; full adds setter QC. */
|
||||
readonly analysisDepth?: "baseline" | "full";
|
||||
/** Maximum search nodes consumed by any one exact-solver check. */
|
||||
readonly perCheckMaxNodes?: number;
|
||||
/** Wall-clock limit for any one exact-solver check. */
|
||||
readonly perCheckTimeoutMs?: number;
|
||||
/** Maximum number of exact-solver checks in the whole analysis. */
|
||||
readonly aggregateMaxChecks?: number;
|
||||
/** Maximum exact-solver nodes shared by the whole analysis. */
|
||||
readonly aggregateMaxNodes?: number;
|
||||
/** Wall-clock limit shared by the whole analysis. */
|
||||
readonly aggregateTimeoutMs?: number;
|
||||
/** Derive a bounded single-clue/constraint minimality proof. */
|
||||
readonly proveMinimality?: boolean;
|
||||
}
|
||||
|
||||
export interface QualityBounds {
|
||||
readonly perCheck: {
|
||||
readonly maxNodes: number;
|
||||
readonly timeoutMs: number;
|
||||
};
|
||||
readonly aggregate: {
|
||||
readonly maxChecks: number;
|
||||
readonly maxNodes: number;
|
||||
readonly timeoutMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface QualitySearchCheck {
|
||||
readonly index: number;
|
||||
readonly purpose: QualityCheckPurpose;
|
||||
readonly item?: QualityItemReference;
|
||||
readonly solutionStatus: QualitySolutionStatus;
|
||||
readonly solutionsFound: number;
|
||||
/** False only when a node/time limit prevented a conclusion. */
|
||||
readonly conclusive: boolean;
|
||||
/** Mirrors the exact solver; two found solutions intentionally hit solution-cap. */
|
||||
readonly truncated: boolean;
|
||||
readonly limitReason?: ExactLimitReason;
|
||||
readonly unknownReason?: QualityUnknownReason;
|
||||
readonly nodes: number;
|
||||
readonly elapsedMs: number;
|
||||
}
|
||||
|
||||
export interface AmbiguityDifference {
|
||||
readonly cell: number;
|
||||
readonly first: number;
|
||||
readonly second: number;
|
||||
}
|
||||
|
||||
export interface AmbiguityWitness {
|
||||
readonly firstSolution: readonly number[];
|
||||
readonly secondSolution: readonly number[];
|
||||
readonly differences: readonly AmbiguityDifference[];
|
||||
}
|
||||
|
||||
export interface QualityItemAssessment {
|
||||
readonly item: QualityItemReference;
|
||||
readonly classification: QualityClassification;
|
||||
readonly checkIndex?: number;
|
||||
readonly solutionStatus?: QualitySolutionStatus;
|
||||
readonly unknownReason?: QualityUnknownReason;
|
||||
}
|
||||
|
||||
export interface ContradictionLocalization {
|
||||
readonly status: "not-applicable" | "localized" | "incomplete";
|
||||
/** A deletion-minimized unsatisfiable core. Unknown items remain in this set. */
|
||||
readonly core: readonly QualityItemReference[];
|
||||
/** Core items proven necessary: removing one made the current core satisfiable. */
|
||||
readonly necessary: readonly QualityItemReference[];
|
||||
/** Items proven unnecessary to retain unsatisfiability. */
|
||||
readonly removable: readonly QualityItemReference[];
|
||||
readonly unknown: readonly QualityItemReference[];
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
export interface CellCriticality {
|
||||
readonly cell: number;
|
||||
/** Critical share among conclusive assessments, or null without one. */
|
||||
readonly score: number | null;
|
||||
readonly criticalWeight: number;
|
||||
readonly redundantWeight: number;
|
||||
readonly unknownWeight: number;
|
||||
}
|
||||
|
||||
export interface QualityMinimalityAnalysis {
|
||||
readonly status:
|
||||
"proven-minimal" | "not-minimal" | "unknown" | "not-applicable";
|
||||
readonly redundant: readonly QualityItemReference[];
|
||||
readonly unknown: readonly QualityItemReference[];
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
export interface QualityBudgetSummary {
|
||||
readonly checksPlanned: number;
|
||||
readonly checksPerformed: number;
|
||||
readonly nodes: number;
|
||||
readonly elapsedMs: number;
|
||||
/** True when at least one requested conclusion remained unknown. */
|
||||
readonly truncated: boolean;
|
||||
readonly unknownReasons: readonly QualityUnknownReason[];
|
||||
}
|
||||
|
||||
export interface PuzzleQualityAnalysis {
|
||||
readonly analysisDepth: "baseline" | "full";
|
||||
readonly solutionStatus: QualitySolutionStatus;
|
||||
readonly solution?: readonly number[];
|
||||
readonly baselineCheckIndex?: number;
|
||||
readonly ambiguityWitness?: AmbiguityWitness;
|
||||
readonly contradiction: ContradictionLocalization;
|
||||
readonly redundancy: {
|
||||
readonly givens: readonly QualityItemAssessment[];
|
||||
readonly constraints: readonly QualityItemAssessment[];
|
||||
};
|
||||
readonly criticalityHeatmap: readonly CellCriticality[];
|
||||
readonly minimality?: QualityMinimalityAnalysis;
|
||||
readonly checks: readonly QualitySearchCheck[];
|
||||
readonly bounds: QualityBounds;
|
||||
readonly budget: QualityBudgetSummary;
|
||||
}
|
||||
|
||||
const DEFAULT_BOUNDS: QualityBounds = {
|
||||
perCheck: { maxNodes: 2_000_000, timeoutMs: 10_000 },
|
||||
aggregate: { maxChecks: 1_000, maxNodes: 20_000_000, timeoutMs: 30_000 },
|
||||
};
|
||||
|
||||
function boundedInteger(
|
||||
value: number | undefined,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
name: string,
|
||||
): number {
|
||||
const result = value ?? fallback;
|
||||
if (!Number.isInteger(result) || result < minimum || result > maximum) {
|
||||
throw new RangeError(
|
||||
`${name} must be an integer from ${minimum} to ${maximum}.`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolveBounds(options: PuzzleQualityOptions): QualityBounds {
|
||||
return {
|
||||
perCheck: {
|
||||
maxNodes: boundedInteger(
|
||||
options.perCheckMaxNodes,
|
||||
DEFAULT_BOUNDS.perCheck.maxNodes,
|
||||
1,
|
||||
100_000_000,
|
||||
"perCheckMaxNodes",
|
||||
),
|
||||
timeoutMs: boundedInteger(
|
||||
options.perCheckTimeoutMs,
|
||||
DEFAULT_BOUNDS.perCheck.timeoutMs,
|
||||
1,
|
||||
120_000,
|
||||
"perCheckTimeoutMs",
|
||||
),
|
||||
},
|
||||
aggregate: {
|
||||
maxChecks: boundedInteger(
|
||||
options.aggregateMaxChecks,
|
||||
DEFAULT_BOUNDS.aggregate.maxChecks,
|
||||
1,
|
||||
20_000,
|
||||
"aggregateMaxChecks",
|
||||
),
|
||||
maxNodes: boundedInteger(
|
||||
options.aggregateMaxNodes,
|
||||
DEFAULT_BOUNDS.aggregate.maxNodes,
|
||||
1,
|
||||
2_000_000_000,
|
||||
"aggregateMaxNodes",
|
||||
),
|
||||
timeoutMs: boundedInteger(
|
||||
options.aggregateTimeoutMs,
|
||||
DEFAULT_BOUNDS.aggregate.timeoutMs,
|
||||
1,
|
||||
600_000,
|
||||
"aggregateTimeoutMs",
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Quality analysis deliberately accepts structurally valid but contradictory
|
||||
* givens. The regular normalizer rejects those because they are not playable,
|
||||
* so validate the same document with an empty board and retain the original
|
||||
* givens for the diagnostic search.
|
||||
*/
|
||||
function normalizeForQuality(
|
||||
puzzle: PuzzleDefinition | NormalizedPuzzle,
|
||||
): NormalizedPuzzle {
|
||||
try {
|
||||
return normalizePuzzle(puzzle);
|
||||
} catch (error) {
|
||||
if (!(error instanceof PuzzleValidationError)) throw error;
|
||||
if (error.issues.some((issue) => issue.path !== "givens")) throw error;
|
||||
const cellCount = puzzle.size * puzzle.size;
|
||||
if (
|
||||
!Number.isInteger(puzzle.size) ||
|
||||
puzzle.givens.length !== cellCount ||
|
||||
puzzle.givens.some(
|
||||
(value) => !Number.isInteger(value) || value < 0 || value > puzzle.size,
|
||||
)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
const skeleton = normalizePuzzle({
|
||||
...puzzle,
|
||||
givens: new Array<number>(cellCount).fill(0),
|
||||
solution: undefined,
|
||||
});
|
||||
return {
|
||||
...skeleton,
|
||||
givens: [...puzzle.givens],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function itemKey(item: QualityItemReference): string {
|
||||
return item.kind === "given"
|
||||
? `given:${item.cell}`
|
||||
: `constraint:${item.index}`;
|
||||
}
|
||||
|
||||
function puzzleItems(puzzle: NormalizedPuzzle): QualityItemReference[] {
|
||||
const givens: QualityItemReference[] = [];
|
||||
puzzle.givens.forEach((value, cell) => {
|
||||
if (value !== 0) givens.push({ kind: "given", cell, value });
|
||||
});
|
||||
const constraints: QualityItemReference[] = puzzle.constraints.map(
|
||||
(constraint, index) => ({
|
||||
kind: "constraint",
|
||||
index,
|
||||
constraintType: constraint.type,
|
||||
}),
|
||||
);
|
||||
return [...givens, ...constraints];
|
||||
}
|
||||
|
||||
function withActiveItems(
|
||||
puzzle: NormalizedPuzzle,
|
||||
active: ReadonlySet<string>,
|
||||
): PuzzleDefinition {
|
||||
return {
|
||||
...puzzle,
|
||||
givens: puzzle.givens.map((value, cell) =>
|
||||
active.has(`given:${cell}`) ? value : 0,
|
||||
),
|
||||
constraints: puzzle.constraints.filter((_constraint, index) =>
|
||||
active.has(`constraint:${index}`),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function withoutItem(
|
||||
puzzle: NormalizedPuzzle,
|
||||
item: QualityItemReference,
|
||||
): PuzzleDefinition {
|
||||
if (item.kind === "given") {
|
||||
return {
|
||||
...puzzle,
|
||||
givens: puzzle.givens.map((value, cell) =>
|
||||
cell === item.cell ? 0 : value,
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...puzzle,
|
||||
constraints: puzzle.constraints.filter(
|
||||
(_constraint, index) => index !== item.index,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function statusOf(result: ExactSolveResult): QualitySolutionStatus {
|
||||
if (result.count >= 2) return "multiple";
|
||||
if (result.truncated) return "unknown";
|
||||
return result.count === 1 ? "unique" : "unsatisfiable";
|
||||
}
|
||||
|
||||
interface SearchOutcome {
|
||||
readonly status: QualitySolutionStatus;
|
||||
readonly checkIndex?: number;
|
||||
readonly unknownReason?: QualityUnknownReason;
|
||||
readonly solutions: readonly (readonly number[])[];
|
||||
}
|
||||
|
||||
interface SearchBudget {
|
||||
readonly bounds: QualityBounds;
|
||||
readonly started: number;
|
||||
readonly checks: QualitySearchCheck[];
|
||||
readonly unknownReasons: Set<QualityUnknownReason>;
|
||||
nodes: number;
|
||||
}
|
||||
|
||||
function skipped(
|
||||
budget: SearchBudget,
|
||||
reason: QualityUnknownReason,
|
||||
): SearchOutcome {
|
||||
budget.unknownReasons.add(reason);
|
||||
return { status: "unknown", unknownReason: reason, solutions: [] };
|
||||
}
|
||||
|
||||
function runCheck(
|
||||
budget: SearchBudget,
|
||||
puzzle: PuzzleDefinition,
|
||||
purpose: QualityCheckPurpose,
|
||||
item?: QualityItemReference,
|
||||
): SearchOutcome {
|
||||
if (budget.checks.length >= budget.bounds.aggregate.maxChecks) {
|
||||
return skipped(budget, "aggregate-check-cap");
|
||||
}
|
||||
const remainingNodes = budget.bounds.aggregate.maxNodes - budget.nodes;
|
||||
if (remainingNodes <= 0) return skipped(budget, "aggregate-node-cap");
|
||||
const aggregateElapsed = Date.now() - budget.started;
|
||||
const remainingMs = budget.bounds.aggregate.timeoutMs - aggregateElapsed;
|
||||
if (remainingMs <= 0) return skipped(budget, "aggregate-timeout");
|
||||
|
||||
const maxNodes = Math.min(budget.bounds.perCheck.maxNodes, remainingNodes);
|
||||
const timeoutMs = Math.min(budget.bounds.perCheck.timeoutMs, remainingMs);
|
||||
const searchStarted = Date.now();
|
||||
let result: ExactSolveResult;
|
||||
try {
|
||||
result = solveExact(puzzle, {
|
||||
maxSolutions: 2,
|
||||
maxNodes,
|
||||
timeoutMs,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof PuzzleValidationError)) throw error;
|
||||
if (error.issues.some((issue) => issue.path !== "givens")) throw error;
|
||||
// All generated relaxations are structurally normalized. A validation
|
||||
// failure here therefore means their active givens already conflict.
|
||||
result = {
|
||||
solutions: [],
|
||||
count: 0,
|
||||
truncated: false,
|
||||
nodes: 0,
|
||||
elapsedMs: Date.now() - searchStarted,
|
||||
};
|
||||
}
|
||||
budget.nodes += result.nodes;
|
||||
const status = statusOf(result);
|
||||
let unknownReason: QualityUnknownReason | undefined;
|
||||
if (status === "unknown") {
|
||||
if (result.limitReason === "node-cap") {
|
||||
unknownReason =
|
||||
maxNodes < budget.bounds.perCheck.maxNodes
|
||||
? "aggregate-node-cap"
|
||||
: "per-check-node-cap";
|
||||
} else {
|
||||
unknownReason =
|
||||
timeoutMs < budget.bounds.perCheck.timeoutMs
|
||||
? "aggregate-timeout"
|
||||
: "per-check-timeout";
|
||||
}
|
||||
budget.unknownReasons.add(unknownReason);
|
||||
}
|
||||
const checkIndex = budget.checks.length;
|
||||
budget.checks.push({
|
||||
index: checkIndex,
|
||||
purpose,
|
||||
...(item === undefined ? {} : { item }),
|
||||
solutionStatus: status,
|
||||
solutionsFound: result.count,
|
||||
conclusive: status !== "unknown",
|
||||
truncated: result.truncated,
|
||||
...(result.limitReason === undefined
|
||||
? {}
|
||||
: { limitReason: result.limitReason }),
|
||||
...(unknownReason === undefined ? {} : { unknownReason }),
|
||||
nodes: result.nodes,
|
||||
elapsedMs: result.elapsedMs,
|
||||
});
|
||||
return {
|
||||
status,
|
||||
checkIndex,
|
||||
...(unknownReason === undefined ? {} : { unknownReason }),
|
||||
solutions: result.solutions,
|
||||
};
|
||||
}
|
||||
|
||||
function unknownAssessment(
|
||||
item: QualityItemReference,
|
||||
reason: QualityUnknownReason,
|
||||
): QualityItemAssessment {
|
||||
return { item, classification: "unknown", unknownReason: reason };
|
||||
}
|
||||
|
||||
function assessItem(
|
||||
baseline: QualitySolutionStatus,
|
||||
item: QualityItemReference,
|
||||
outcome: SearchOutcome,
|
||||
): QualityItemAssessment {
|
||||
if (outcome.status === "unknown") {
|
||||
return {
|
||||
item,
|
||||
classification: "unknown",
|
||||
...(outcome.checkIndex === undefined
|
||||
? {}
|
||||
: { checkIndex: outcome.checkIndex }),
|
||||
solutionStatus: "unknown",
|
||||
...(outcome.unknownReason === undefined
|
||||
? {}
|
||||
: { unknownReason: outcome.unknownReason }),
|
||||
};
|
||||
}
|
||||
if (baseline === "unique") {
|
||||
if (outcome.status === "unique") {
|
||||
return {
|
||||
item,
|
||||
classification: "redundant",
|
||||
checkIndex: outcome.checkIndex,
|
||||
solutionStatus: outcome.status,
|
||||
};
|
||||
}
|
||||
if (outcome.status === "multiple") {
|
||||
return {
|
||||
item,
|
||||
classification: "critical",
|
||||
checkIndex: outcome.checkIndex,
|
||||
solutionStatus: outcome.status,
|
||||
};
|
||||
}
|
||||
return {
|
||||
item,
|
||||
classification: "unknown",
|
||||
checkIndex: outcome.checkIndex,
|
||||
solutionStatus: outcome.status,
|
||||
unknownReason: "unexpected-unsatisfiable-relaxation",
|
||||
};
|
||||
}
|
||||
// For an inconsistent definition, an item is critical if removing it makes
|
||||
// the definition satisfiable, and redundant if inconsistency remains.
|
||||
if (baseline === "unsatisfiable") {
|
||||
return {
|
||||
item,
|
||||
classification:
|
||||
outcome.status === "unsatisfiable" ? "redundant" : "critical",
|
||||
checkIndex: outcome.checkIndex,
|
||||
solutionStatus: outcome.status,
|
||||
};
|
||||
}
|
||||
return unknownAssessment(
|
||||
item,
|
||||
baseline === "multiple" ? "baseline-not-unique" : "baseline-unknown",
|
||||
);
|
||||
}
|
||||
|
||||
function redundancyAnalysis(
|
||||
budget: SearchBudget,
|
||||
puzzle: NormalizedPuzzle,
|
||||
items: readonly QualityItemReference[],
|
||||
baseline: QualitySolutionStatus,
|
||||
): {
|
||||
readonly givens: readonly QualityItemAssessment[];
|
||||
readonly constraints: readonly QualityItemAssessment[];
|
||||
} {
|
||||
const assessments: QualityItemAssessment[] = [];
|
||||
if (baseline === "multiple" || baseline === "unknown") {
|
||||
const reason: QualityUnknownReason =
|
||||
baseline === "multiple" ? "baseline-not-unique" : "baseline-unknown";
|
||||
budget.unknownReasons.add(reason);
|
||||
assessments.push(...items.map((item) => unknownAssessment(item, reason)));
|
||||
} else {
|
||||
for (const item of items) {
|
||||
const outcome = runCheck(
|
||||
budget,
|
||||
withoutItem(puzzle, item),
|
||||
"redundancy",
|
||||
item,
|
||||
);
|
||||
const assessment = assessItem(baseline, item, outcome);
|
||||
if (assessment.unknownReason !== undefined) {
|
||||
budget.unknownReasons.add(assessment.unknownReason);
|
||||
}
|
||||
assessments.push(assessment);
|
||||
}
|
||||
}
|
||||
return {
|
||||
givens: assessments.filter(
|
||||
(assessment) => assessment.item.kind === "given",
|
||||
),
|
||||
constraints: assessments.filter(
|
||||
(assessment) => assessment.item.kind === "constraint",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function localizeContradiction(
|
||||
budget: SearchBudget,
|
||||
puzzle: NormalizedPuzzle,
|
||||
items: readonly QualityItemReference[],
|
||||
baseline: QualitySolutionStatus,
|
||||
): ContradictionLocalization {
|
||||
if (baseline !== "unsatisfiable") {
|
||||
return {
|
||||
status: "not-applicable",
|
||||
core: [],
|
||||
necessary: [],
|
||||
removable: [],
|
||||
unknown: [],
|
||||
reason:
|
||||
baseline === "unknown"
|
||||
? "Unsatisfiability was not proven within the search bounds."
|
||||
: "The puzzle is satisfiable.",
|
||||
};
|
||||
}
|
||||
|
||||
const active = new Set(items.map(itemKey));
|
||||
const necessary: QualityItemReference[] = [];
|
||||
const removable: QualityItemReference[] = [];
|
||||
const unknown: QualityItemReference[] = [];
|
||||
for (const item of items) {
|
||||
active.delete(itemKey(item));
|
||||
const outcome = runCheck(
|
||||
budget,
|
||||
withActiveItems(puzzle, active),
|
||||
"contradiction-core",
|
||||
item,
|
||||
);
|
||||
if (outcome.status === "unsatisfiable") {
|
||||
removable.push(item);
|
||||
continue;
|
||||
}
|
||||
active.add(itemKey(item));
|
||||
if (outcome.status === "unique" || outcome.status === "multiple") {
|
||||
necessary.push(item);
|
||||
} else {
|
||||
unknown.push(item);
|
||||
}
|
||||
}
|
||||
const core = items.filter((item) => active.has(itemKey(item)));
|
||||
return {
|
||||
status: unknown.length === 0 ? "localized" : "incomplete",
|
||||
core,
|
||||
necessary,
|
||||
removable,
|
||||
unknown,
|
||||
...(unknown.length === 0
|
||||
? {}
|
||||
: { reason: "One or more deletion checks exhausted the shared bounds." }),
|
||||
};
|
||||
}
|
||||
|
||||
function buildHeatmap(
|
||||
puzzle: NormalizedPuzzle,
|
||||
assessments: readonly QualityItemAssessment[],
|
||||
): CellCriticality[] {
|
||||
const weights = Array.from({ length: puzzle.size * puzzle.size }, () => ({
|
||||
critical: 0,
|
||||
redundant: 0,
|
||||
unknown: 0,
|
||||
}));
|
||||
for (const assessment of assessments) {
|
||||
const cells =
|
||||
assessment.item.kind === "given"
|
||||
? [assessment.item.cell]
|
||||
: constraintCells(
|
||||
puzzle.size,
|
||||
puzzle.constraints[assessment.item.index] as VariantConstraint,
|
||||
);
|
||||
for (const cell of new Set(cells)) {
|
||||
const weight = weights[cell];
|
||||
if (weight === undefined) continue;
|
||||
if (assessment.classification === "critical") weight.critical += 1;
|
||||
else if (assessment.classification === "redundant") weight.redundant += 1;
|
||||
else weight.unknown += 1;
|
||||
}
|
||||
}
|
||||
return weights.map((weight, cell) => {
|
||||
const conclusive = weight.critical + weight.redundant;
|
||||
return {
|
||||
cell,
|
||||
score: conclusive === 0 ? null : weight.critical / conclusive,
|
||||
criticalWeight: weight.critical,
|
||||
redundantWeight: weight.redundant,
|
||||
unknownWeight: weight.unknown,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolves the board cells represented by a quality finding. */
|
||||
export function qualityItemCells(
|
||||
puzzle: PuzzleDefinition | NormalizedPuzzle,
|
||||
item: QualityItemReference,
|
||||
): readonly number[] {
|
||||
const normalized = normalizeForQuality(puzzle);
|
||||
if (item.kind === "given") {
|
||||
if (item.cell < 0 || item.cell >= normalized.size * normalized.size) {
|
||||
throw new RangeError("Given quality item cell is outside the puzzle.");
|
||||
}
|
||||
return [item.cell];
|
||||
}
|
||||
const constraint = normalized.constraints[item.index];
|
||||
if (constraint === undefined || constraint.type !== item.constraintType) {
|
||||
throw new RangeError("Constraint quality item does not match the puzzle.");
|
||||
}
|
||||
return constraintCells(normalized.size, constraint);
|
||||
}
|
||||
|
||||
function minimalityFrom(
|
||||
baseline: QualitySolutionStatus,
|
||||
assessments: readonly QualityItemAssessment[],
|
||||
): QualityMinimalityAnalysis {
|
||||
if (baseline !== "unique") {
|
||||
return {
|
||||
status: "not-applicable",
|
||||
redundant: [],
|
||||
unknown: assessments.map((assessment) => assessment.item),
|
||||
reason: "Minimality requires a proven unique baseline puzzle.",
|
||||
};
|
||||
}
|
||||
const redundant = assessments
|
||||
.filter((assessment) => assessment.classification === "redundant")
|
||||
.map((assessment) => assessment.item);
|
||||
const unknown = assessments
|
||||
.filter((assessment) => assessment.classification === "unknown")
|
||||
.map((assessment) => assessment.item);
|
||||
if (redundant.length > 0) {
|
||||
return { status: "not-minimal", redundant, unknown };
|
||||
}
|
||||
if (unknown.length > 0) {
|
||||
return {
|
||||
status: "unknown",
|
||||
redundant,
|
||||
unknown,
|
||||
reason: "At least one removal check was inconclusive.",
|
||||
};
|
||||
}
|
||||
return { status: "proven-minimal", redundant, unknown };
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a bounded, immutable setter-quality audit. Every exact-solver call uses
|
||||
* both the per-check limits and one shared aggregate budget. A capped search
|
||||
* is always reported as unknown unless two solutions already prove ambiguity.
|
||||
*/
|
||||
export function analyzePuzzleQuality(
|
||||
puzzle: PuzzleDefinition | NormalizedPuzzle,
|
||||
options: PuzzleQualityOptions = {},
|
||||
): PuzzleQualityAnalysis {
|
||||
const normalized = normalizeForQuality(puzzle);
|
||||
const analysisDepth = options.analysisDepth ?? "full";
|
||||
if (analysisDepth !== "baseline" && analysisDepth !== "full") {
|
||||
throw new RangeError('analysisDepth must be "baseline" or "full".');
|
||||
}
|
||||
const bounds = resolveBounds(options);
|
||||
const budget: SearchBudget = {
|
||||
bounds,
|
||||
started: Date.now(),
|
||||
checks: [],
|
||||
unknownReasons: new Set<QualityUnknownReason>(),
|
||||
nodes: 0,
|
||||
};
|
||||
const items = puzzleItems(normalized);
|
||||
const baseline = runCheck(budget, normalized, "baseline");
|
||||
const solutionStatus = baseline.status;
|
||||
const ambiguityWitness = (() => {
|
||||
const firstSolution = baseline.solutions[0];
|
||||
const secondSolution = baseline.solutions[1];
|
||||
if (
|
||||
solutionStatus !== "multiple" ||
|
||||
firstSolution === undefined ||
|
||||
secondSolution === undefined
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
firstSolution: [...firstSolution],
|
||||
secondSolution: [...secondSolution],
|
||||
differences: firstSolution.flatMap((first, cell) => {
|
||||
const second = secondSolution[cell] as number;
|
||||
return first === second ? [] : [{ cell, first, second }];
|
||||
}),
|
||||
} satisfies AmbiguityWitness;
|
||||
})();
|
||||
|
||||
const redundancy =
|
||||
analysisDepth === "full"
|
||||
? redundancyAnalysis(budget, normalized, items, solutionStatus)
|
||||
: { givens: [], constraints: [] };
|
||||
const contradiction =
|
||||
analysisDepth === "full"
|
||||
? localizeContradiction(budget, normalized, items, solutionStatus)
|
||||
: {
|
||||
status: "not-applicable" as const,
|
||||
core: [],
|
||||
necessary: [],
|
||||
removable: [],
|
||||
unknown: [],
|
||||
reason: "Full quality analysis was not requested.",
|
||||
};
|
||||
const assessments = [...redundancy.givens, ...redundancy.constraints];
|
||||
const elapsedMs = Date.now() - budget.started;
|
||||
const result: PuzzleQualityAnalysis = {
|
||||
analysisDepth,
|
||||
solutionStatus,
|
||||
...(solutionStatus === "unique" && baseline.solutions[0] !== undefined
|
||||
? { solution: [...baseline.solutions[0]] }
|
||||
: {}),
|
||||
...(baseline.checkIndex === undefined
|
||||
? {}
|
||||
: { baselineCheckIndex: baseline.checkIndex }),
|
||||
...(ambiguityWitness === undefined ? {} : { ambiguityWitness }),
|
||||
contradiction,
|
||||
redundancy,
|
||||
criticalityHeatmap:
|
||||
analysisDepth === "full" ? buildHeatmap(normalized, assessments) : [],
|
||||
...(options.proveMinimality === true
|
||||
? {
|
||||
minimality:
|
||||
analysisDepth === "full"
|
||||
? minimalityFrom(solutionStatus, assessments)
|
||||
: {
|
||||
status: "not-applicable" as const,
|
||||
redundant: [],
|
||||
unknown: [],
|
||||
reason: "A minimality proof requires full quality analysis.",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
checks: budget.checks,
|
||||
bounds,
|
||||
budget: {
|
||||
checksPlanned:
|
||||
analysisDepth === "baseline" ||
|
||||
solutionStatus === "multiple" ||
|
||||
solutionStatus === "unknown"
|
||||
? 1
|
||||
: 1 +
|
||||
items.length +
|
||||
(solutionStatus === "unsatisfiable" ? items.length : 0),
|
||||
checksPerformed: budget.checks.length,
|
||||
nodes: budget.nodes,
|
||||
elapsedMs,
|
||||
truncated: budget.unknownReasons.size > 0,
|
||||
unknownReasons: [...budget.unknownReasons],
|
||||
},
|
||||
};
|
||||
return result;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,409 @@
|
||||
import { maskValues, symbolFor, type EntryMode } from "./session";
|
||||
import { colorMarkDescription } from "./uiPreferences";
|
||||
|
||||
export const AID_MEMOIRE_VERSION = 1 as const;
|
||||
export const MAX_AID_MEMOIRE_CELLS = 36;
|
||||
export const MAX_AID_MEMOIRE_COLUMNS = 36;
|
||||
export const MAX_AID_MEMOIRE_LABEL_LENGTH = 40;
|
||||
|
||||
export interface AidMemoireCell {
|
||||
readonly label: string;
|
||||
readonly value: number;
|
||||
readonly cornerMarks: number;
|
||||
readonly centerMarks: number;
|
||||
readonly color: number;
|
||||
}
|
||||
|
||||
export interface AidMemoireState {
|
||||
readonly version: typeof AID_MEMOIRE_VERSION;
|
||||
readonly enabled: boolean;
|
||||
readonly columns: number;
|
||||
readonly cells: readonly AidMemoireCell[];
|
||||
}
|
||||
|
||||
export interface PortableAidMemoireCell {
|
||||
readonly label: string;
|
||||
readonly value: number;
|
||||
readonly cornerMarks: readonly number[];
|
||||
readonly centerMarks: readonly number[];
|
||||
readonly color: number;
|
||||
}
|
||||
|
||||
export interface PortableAidMemoire {
|
||||
readonly version: typeof AID_MEMOIRE_VERSION;
|
||||
readonly enabled: boolean;
|
||||
readonly columns: number;
|
||||
readonly cells: readonly PortableAidMemoireCell[];
|
||||
}
|
||||
|
||||
function integer(
|
||||
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 defaultLabel(index: number): string {
|
||||
return String(index + 1);
|
||||
}
|
||||
|
||||
function emptyCell(index: number): AidMemoireCell {
|
||||
return {
|
||||
label: defaultLabel(index),
|
||||
value: 0,
|
||||
cornerMarks: 0,
|
||||
centerMarks: 0,
|
||||
color: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneCell(cell: AidMemoireCell): AidMemoireCell {
|
||||
return { ...cell };
|
||||
}
|
||||
|
||||
function normalizedCount(size: number, count?: number): number {
|
||||
const requested = Number.isFinite(count)
|
||||
? Math.trunc(count as number)
|
||||
: Math.min(size, 16);
|
||||
return Math.max(1, Math.min(MAX_AID_MEMOIRE_CELLS, requested));
|
||||
}
|
||||
|
||||
export function createAidMemoire(
|
||||
size: number,
|
||||
options: {
|
||||
readonly enabled?: boolean;
|
||||
readonly cellCount?: number;
|
||||
readonly columns?: number;
|
||||
} = {},
|
||||
): AidMemoireState {
|
||||
const count = normalizedCount(size, options.cellCount);
|
||||
const columns = Math.max(
|
||||
1,
|
||||
Math.min(MAX_AID_MEMOIRE_COLUMNS, count, options.columns ?? count),
|
||||
);
|
||||
return {
|
||||
version: AID_MEMOIRE_VERSION,
|
||||
enabled: options.enabled ?? false,
|
||||
columns,
|
||||
cells: Array.from({ length: count }, (_, index) => emptyCell(index)),
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneAidMemoire(state: AidMemoireState): AidMemoireState {
|
||||
return { ...state, cells: state.cells.map(cloneCell) };
|
||||
}
|
||||
|
||||
export function setAidMemoireEnabled(
|
||||
state: AidMemoireState,
|
||||
enabled: boolean,
|
||||
): AidMemoireState {
|
||||
return { ...state, enabled };
|
||||
}
|
||||
|
||||
export function configureAidMemoire(
|
||||
state: AidMemoireState,
|
||||
size: number,
|
||||
cellCount: number,
|
||||
columns: number,
|
||||
): AidMemoireState {
|
||||
const count = normalizedCount(size, cellCount);
|
||||
const nextColumns = Math.max(
|
||||
1,
|
||||
Math.min(MAX_AID_MEMOIRE_COLUMNS, count, Math.trunc(columns) || 1),
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
columns: nextColumns,
|
||||
cells: Array.from({ length: count }, (_, index) =>
|
||||
state.cells[index] ? cloneCell(state.cells[index]) : emptyCell(index),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function compactAidMemoireColumns(cellCount: number): number {
|
||||
const count = Number.isFinite(cellCount)
|
||||
? Math.max(1, Math.min(MAX_AID_MEMOIRE_CELLS, Math.trunc(cellCount)))
|
||||
: 1;
|
||||
return Math.ceil(Math.sqrt(count));
|
||||
}
|
||||
|
||||
export function labelAidMemoireCell(
|
||||
state: AidMemoireState,
|
||||
cell: number,
|
||||
label: string,
|
||||
): AidMemoireState {
|
||||
if (cell < 0 || cell >= state.cells.length) return state;
|
||||
const cells = state.cells.map(cloneCell);
|
||||
cells[cell] = {
|
||||
...cells[cell]!,
|
||||
label: label.slice(0, MAX_AID_MEMOIRE_LABEL_LENGTH),
|
||||
};
|
||||
return { ...state, cells };
|
||||
}
|
||||
|
||||
function toggleMask(mask: number, value: number): number {
|
||||
const bit = 1 << (value - 1);
|
||||
return mask & bit ? mask & ~bit : mask | bit;
|
||||
}
|
||||
|
||||
export function enterAidMemoireCell(
|
||||
state: AidMemoireState,
|
||||
cell: number,
|
||||
mode: EntryMode,
|
||||
value: number,
|
||||
size: number,
|
||||
): AidMemoireState {
|
||||
if (cell < 0 || cell >= state.cells.length) return state;
|
||||
if (!Number.isInteger(value)) return state;
|
||||
if (mode !== "color" && (value < 1 || value > size)) return state;
|
||||
if (mode === "color" && (value < 1 || value > 8)) return state;
|
||||
const cells = state.cells.map(cloneCell);
|
||||
const current = cells[cell]!;
|
||||
if (mode === "value") {
|
||||
cells[cell] = {
|
||||
...current,
|
||||
value: current.value === value ? 0 : value,
|
||||
cornerMarks: 0,
|
||||
centerMarks: 0,
|
||||
};
|
||||
} else if (mode === "corner") {
|
||||
if (current.value !== 0) return state;
|
||||
cells[cell] = {
|
||||
...current,
|
||||
cornerMarks: toggleMask(current.cornerMarks, value),
|
||||
};
|
||||
} else if (mode === "center") {
|
||||
if (current.value !== 0) return state;
|
||||
cells[cell] = {
|
||||
...current,
|
||||
centerMarks: toggleMask(current.centerMarks, value),
|
||||
};
|
||||
} else {
|
||||
cells[cell] = {
|
||||
...current,
|
||||
color: current.color === value ? 0 : value,
|
||||
};
|
||||
}
|
||||
return { ...state, cells };
|
||||
}
|
||||
|
||||
export function eraseAidMemoireCell(
|
||||
state: AidMemoireState,
|
||||
cell: number,
|
||||
mode: EntryMode,
|
||||
): AidMemoireState {
|
||||
if (cell < 0 || cell >= state.cells.length) return state;
|
||||
const cells = state.cells.map(cloneCell);
|
||||
const current = cells[cell]!;
|
||||
cells[cell] = {
|
||||
...current,
|
||||
...(mode === "value" ? { value: 0 } : {}),
|
||||
...(mode === "corner" ? { cornerMarks: 0 } : {}),
|
||||
...(mode === "center" ? { centerMarks: 0 } : {}),
|
||||
...(mode === "color" ? { color: 0 } : {}),
|
||||
};
|
||||
return { ...state, cells };
|
||||
}
|
||||
|
||||
export function clearAidMemoireEntries(
|
||||
state: AidMemoireState,
|
||||
): AidMemoireState {
|
||||
return {
|
||||
...state,
|
||||
cells: state.cells.map((cell) => ({
|
||||
...cell,
|
||||
value: 0,
|
||||
cornerMarks: 0,
|
||||
centerMarks: 0,
|
||||
color: 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function resetAidMemoire(
|
||||
state: AidMemoireState,
|
||||
size: number,
|
||||
): AidMemoireState {
|
||||
return createAidMemoire(size, { enabled: state.enabled });
|
||||
}
|
||||
|
||||
export function aidMemoireCellDescription(
|
||||
cell: AidMemoireCell,
|
||||
index: number,
|
||||
size: number,
|
||||
): string {
|
||||
const parts = [
|
||||
`Aid-mémoire cell ${String(index + 1)}`,
|
||||
cell.label ? `label ${cell.label}` : "unlabelled",
|
||||
];
|
||||
if (cell.value) parts.push(`value ${symbolFor(cell.value, size)}`);
|
||||
else parts.push("empty");
|
||||
const corner = maskValues(cell.cornerMarks, size);
|
||||
const center = maskValues(cell.centerMarks, size);
|
||||
if (corner.length) {
|
||||
parts.push(
|
||||
`corner marks ${corner.map((value) => symbolFor(value, size)).join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (center.length) {
|
||||
parts.push(
|
||||
`centre marks ${center.map((value) => symbolFor(value, size)).join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (cell.color) {
|
||||
parts.push(
|
||||
`colour ${String(cell.color)}: ${colorMarkDescription(cell.color)}`,
|
||||
);
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function portableMarks(value: unknown, label: string, size: number): number[] {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value) || value.length > size) {
|
||||
throw new TypeError(
|
||||
`${label} must contain at most ${String(size)} digits.`,
|
||||
);
|
||||
}
|
||||
const values = value.map((entry, index) =>
|
||||
integer(entry, `${label}[${String(index)}]`, 1, size),
|
||||
);
|
||||
return [...new Set(values)].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function normalizePortableAidMemoire(
|
||||
value: unknown,
|
||||
size: number,
|
||||
): PortableAidMemoire {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new TypeError("aidMemoire must be an object.");
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.version !== AID_MEMOIRE_VERSION) {
|
||||
throw new TypeError("aidMemoire has an unsupported version.");
|
||||
}
|
||||
if (typeof record.enabled !== "boolean") {
|
||||
throw new TypeError("aidMemoire.enabled must be true or false.");
|
||||
}
|
||||
if (
|
||||
!Array.isArray(record.cells) ||
|
||||
record.cells.length < 1 ||
|
||||
record.cells.length > MAX_AID_MEMOIRE_CELLS
|
||||
) {
|
||||
throw new TypeError(
|
||||
`aidMemoire.cells must contain 1 to ${String(MAX_AID_MEMOIRE_CELLS)} entries.`,
|
||||
);
|
||||
}
|
||||
const columns = integer(
|
||||
record.columns,
|
||||
"aidMemoire.columns",
|
||||
1,
|
||||
Math.min(MAX_AID_MEMOIRE_COLUMNS, record.cells.length),
|
||||
);
|
||||
const cells = record.cells.map((rawCell, index): PortableAidMemoireCell => {
|
||||
if (
|
||||
typeof rawCell !== "object" ||
|
||||
rawCell === null ||
|
||||
Array.isArray(rawCell)
|
||||
) {
|
||||
throw new TypeError(
|
||||
`aidMemoire.cells[${String(index)}] must be an object.`,
|
||||
);
|
||||
}
|
||||
const cell = rawCell as Record<string, unknown>;
|
||||
if (
|
||||
cell.label !== undefined &&
|
||||
(typeof cell.label !== "string" ||
|
||||
cell.label.length > MAX_AID_MEMOIRE_LABEL_LENGTH)
|
||||
) {
|
||||
throw new TypeError(
|
||||
`aidMemoire.cells[${String(index)}].label is invalid.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
label: (cell.label as string | undefined) ?? defaultLabel(index),
|
||||
value: integer(
|
||||
cell.value ?? 0,
|
||||
`aidMemoire.cells[${String(index)}].value`,
|
||||
0,
|
||||
size,
|
||||
),
|
||||
cornerMarks: portableMarks(
|
||||
cell.cornerMarks,
|
||||
`aidMemoire.cells[${String(index)}].cornerMarks`,
|
||||
size,
|
||||
),
|
||||
centerMarks: portableMarks(
|
||||
cell.centerMarks,
|
||||
`aidMemoire.cells[${String(index)}].centerMarks`,
|
||||
size,
|
||||
),
|
||||
color: integer(
|
||||
cell.color ?? 0,
|
||||
`aidMemoire.cells[${String(index)}].color`,
|
||||
0,
|
||||
8,
|
||||
),
|
||||
};
|
||||
});
|
||||
return {
|
||||
version: AID_MEMOIRE_VERSION,
|
||||
enabled: record.enabled,
|
||||
columns,
|
||||
cells,
|
||||
};
|
||||
}
|
||||
|
||||
export function aidMemoireToPortable(
|
||||
state: AidMemoireState,
|
||||
size: number,
|
||||
): PortableAidMemoire {
|
||||
return normalizePortableAidMemoire(
|
||||
{
|
||||
version: AID_MEMOIRE_VERSION,
|
||||
enabled: state.enabled,
|
||||
columns: state.columns,
|
||||
cells: state.cells.map((cell) => ({
|
||||
label: cell.label,
|
||||
value: cell.value,
|
||||
cornerMarks: maskValues(cell.cornerMarks, size),
|
||||
centerMarks: maskValues(cell.centerMarks, size),
|
||||
color: cell.color,
|
||||
})),
|
||||
},
|
||||
size,
|
||||
);
|
||||
}
|
||||
|
||||
export function aidMemoireFromPortable(
|
||||
value: unknown,
|
||||
size: number,
|
||||
): AidMemoireState {
|
||||
if (value === undefined) return createAidMemoire(size);
|
||||
const portable = normalizePortableAidMemoire(value, size);
|
||||
const mask = (values: readonly number[]) =>
|
||||
values.reduce((result, digit) => result | (1 << (digit - 1)), 0);
|
||||
return {
|
||||
version: AID_MEMOIRE_VERSION,
|
||||
enabled: portable.enabled,
|
||||
columns: portable.columns,
|
||||
cells: portable.cells.map((cell) => ({
|
||||
label: cell.label,
|
||||
value: cell.value,
|
||||
cornerMarks: mask(cell.cornerMarks),
|
||||
centerMarks: mask(cell.centerMarks),
|
||||
color: cell.color,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { MAX_PUZZLE_SIZE, MIN_PUZZLE_SIZE } from "../domain/types";
|
||||
|
||||
export type DigitCompletionStatus = "undone" | "done" | "overdone";
|
||||
|
||||
/** The gameplay-facing count for one symbol in a completed Sudoku grid. */
|
||||
export interface DigitCompletion {
|
||||
readonly digit: number;
|
||||
readonly placed: number;
|
||||
readonly target: number;
|
||||
readonly remaining: number;
|
||||
readonly excess: number;
|
||||
readonly status: DigitCompletionStatus;
|
||||
}
|
||||
|
||||
function assertPuzzleSize(size: number): void {
|
||||
if (
|
||||
!Number.isInteger(size) ||
|
||||
size < MIN_PUZZLE_SIZE ||
|
||||
size > MAX_PUZZLE_SIZE
|
||||
) {
|
||||
throw new RangeError(
|
||||
`Puzzle size must be an integer from ${MIN_PUZZLE_SIZE} to ${MAX_PUZZLE_SIZE}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isDigit(value: number | undefined, size: number): value is number {
|
||||
return (
|
||||
Number.isInteger(value) && value !== undefined && value > 0 && value <= size
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts placed digits for a completion bar. A valid solved size-N Sudoku has
|
||||
* exactly N copies of every digit. Invalid or out-of-range values are ignored
|
||||
* so a damaged in-progress document cannot make the helper itself fail.
|
||||
*/
|
||||
export function digitCompletions(
|
||||
size: number,
|
||||
values: readonly number[],
|
||||
): readonly DigitCompletion[] {
|
||||
assertPuzzleSize(size);
|
||||
const counts = new Array<number>(size + 1).fill(0);
|
||||
const cellCount = size * size;
|
||||
|
||||
for (let cell = 0; cell < Math.min(values.length, cellCount); cell += 1) {
|
||||
const value = values[cell];
|
||||
if (isDigit(value, size)) counts[value] = (counts[value] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return Array.from({ length: size }, (_, index) => {
|
||||
const digit = index + 1;
|
||||
const placed = counts[digit] ?? 0;
|
||||
const delta = size - placed;
|
||||
return {
|
||||
digit,
|
||||
placed,
|
||||
target: size,
|
||||
remaining: Math.max(0, delta),
|
||||
excess: Math.max(0, -delta),
|
||||
status: delta > 0 ? "undone" : delta === 0 ? "done" : "overdone",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns the valid placed digit at a grid cell, or null for an empty/bad cell. */
|
||||
export function placedDigitAt(
|
||||
size: number,
|
||||
values: readonly number[],
|
||||
cell: number,
|
||||
): number | null {
|
||||
assertPuzzleSize(size);
|
||||
if (!Number.isInteger(cell) || cell < 0 || cell >= size * size) return null;
|
||||
const value = values[cell];
|
||||
return isDigit(value, size) ? value : null;
|
||||
}
|
||||
|
||||
/** Finds every grid cell containing a digit, in stable row-major order. */
|
||||
export function matchingDigitCells(
|
||||
size: number,
|
||||
values: readonly number[],
|
||||
digit: number,
|
||||
): readonly number[] {
|
||||
assertPuzzleSize(size);
|
||||
if (!isDigit(digit, size)) return [];
|
||||
const cells: number[] = [];
|
||||
const cellCount = Math.min(values.length, size * size);
|
||||
for (let cell = 0; cell < cellCount; cell += 1) {
|
||||
if (values[cell] === digit) cells.push(cell);
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the cells to highlight after activating digit matching on a filled
|
||||
* cell (for example with Ctrl-click or Cmd-click). Empty cells yield no match.
|
||||
*/
|
||||
export function matchingDigitCellsAt(
|
||||
size: number,
|
||||
values: readonly number[],
|
||||
cell: number,
|
||||
): readonly number[] {
|
||||
const digit = placedDigitAt(size, values, cell);
|
||||
return digit === null ? [] : matchingDigitCells(size, values, digit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles a digit highlight from a modified click. Calling this for an empty
|
||||
* or invalid cell preserves the current highlight, leaving normal cell
|
||||
* selection semantics to the UI.
|
||||
*/
|
||||
export function toggledDigitHighlight(
|
||||
size: number,
|
||||
values: readonly number[],
|
||||
cell: number,
|
||||
currentDigit: number | null,
|
||||
): number | null {
|
||||
const clickedDigit = placedDigitAt(size, values, cell);
|
||||
if (clickedDigit === null) return currentDigit;
|
||||
return clickedDigit === currentDigit ? null : clickedDigit;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export type GridNavigationKey =
|
||||
| "ArrowUp"
|
||||
| "ArrowDown"
|
||||
| "ArrowLeft"
|
||||
| "ArrowRight"
|
||||
| "Home"
|
||||
| "End"
|
||||
| "PageUp"
|
||||
| "PageDown";
|
||||
|
||||
/**
|
||||
* Resolve WAI-ARIA grid navigation without wrapping at an edge. Home/End move
|
||||
* within a row; Ctrl/Command+Home/End move to the first/last cell. Page keys
|
||||
* retain the column while moving to the first/last row.
|
||||
*/
|
||||
export function navigateGridCell(
|
||||
activeCell: number,
|
||||
size: number,
|
||||
key: string,
|
||||
command = false,
|
||||
): number | null {
|
||||
if (!Number.isInteger(size) || size < 1 || size > 64) {
|
||||
throw new RangeError("size must be an integer from 1 to 64");
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(activeCell) ||
|
||||
activeCell < 0 ||
|
||||
activeCell >= size * size
|
||||
) {
|
||||
throw new RangeError("activeCell is outside the grid");
|
||||
}
|
||||
const row = Math.floor(activeCell / size);
|
||||
const column = activeCell % size;
|
||||
switch (key as GridNavigationKey) {
|
||||
case "ArrowUp":
|
||||
return Math.max(0, row - 1) * size + column;
|
||||
case "ArrowDown":
|
||||
return Math.min(size - 1, row + 1) * size + column;
|
||||
case "ArrowLeft":
|
||||
return row * size + Math.max(0, column - 1);
|
||||
case "ArrowRight":
|
||||
return row * size + Math.min(size - 1, column + 1);
|
||||
case "Home":
|
||||
return command ? 0 : row * size;
|
||||
case "End":
|
||||
return command ? size * size - 1 : row * size + size - 1;
|
||||
case "PageUp":
|
||||
return column;
|
||||
case "PageDown":
|
||||
return (size - 1) * size + column;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
import {
|
||||
snapshotSession,
|
||||
type PlaySession,
|
||||
type PlaySnapshot,
|
||||
} from "./session";
|
||||
import {
|
||||
aidMemoireFromPortable,
|
||||
aidMemoireToPortable,
|
||||
cloneAidMemoire,
|
||||
createAidMemoire,
|
||||
type AidMemoireState,
|
||||
} from "./aidMemoire";
|
||||
|
||||
export type HypothesisStatus = "active" | "kept" | "discarded";
|
||||
|
||||
export interface GameplayState extends PlaySnapshot {
|
||||
readonly elapsedSeconds: number;
|
||||
readonly aidMemoire?: AidMemoireState;
|
||||
}
|
||||
|
||||
export interface GameplayMoment {
|
||||
readonly id: string;
|
||||
readonly sequence: number;
|
||||
readonly branchId: string;
|
||||
readonly label: string;
|
||||
readonly state: GameplayState;
|
||||
}
|
||||
|
||||
export interface HypothesisBranch {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly parentBranchId: string;
|
||||
readonly baseMomentId: string;
|
||||
readonly baseState: GameplayState;
|
||||
readonly status: HypothesisStatus;
|
||||
}
|
||||
|
||||
export interface NamedSavepoint {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly momentId: string;
|
||||
readonly branchId: string;
|
||||
readonly state: GameplayState;
|
||||
}
|
||||
|
||||
export interface GameplayHistory {
|
||||
readonly moments: readonly GameplayMoment[];
|
||||
readonly branches: readonly HypothesisBranch[];
|
||||
readonly savepoints: readonly NamedSavepoint[];
|
||||
readonly activeBranchId: string;
|
||||
readonly currentMomentId: string;
|
||||
readonly nextSequence: number;
|
||||
}
|
||||
|
||||
export interface HistoryTransition {
|
||||
readonly history: GameplayHistory;
|
||||
readonly state: GameplayState;
|
||||
}
|
||||
|
||||
export const MAIN_BRANCH_ID = "main";
|
||||
export const MAX_GAMEPLAY_MOMENTS = 500;
|
||||
export const MAX_GAMEPLAY_HISTORY_BYTES = 1_048_576;
|
||||
const GAMEPLAY_HISTORY_SCHEMA =
|
||||
"de.add-ideas.sudoku-tools.gameplay-history" as const;
|
||||
|
||||
function historyRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new TypeError(`${label} must be an object.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function historyText(value: unknown, label: string, maximum = 128): string {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
value.length === 0 ||
|
||||
value.length > maximum
|
||||
) {
|
||||
throw new TypeError(`${label} must be non-empty bounded text.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function historyInteger(
|
||||
value: unknown,
|
||||
label: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
if (
|
||||
!Number.isInteger(value) ||
|
||||
(value as number) < minimum ||
|
||||
(value as number) > maximum
|
||||
) {
|
||||
throw new TypeError(
|
||||
`${label} must be an integer from ${String(minimum)} to ${String(maximum)}.`,
|
||||
);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function parseHistoryState(value: unknown, size: number): GameplayState {
|
||||
const record = historyRecord(value, "A gameplay state");
|
||||
const count = size * size;
|
||||
const numbers = (
|
||||
input: unknown,
|
||||
label: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number[] => {
|
||||
if (!Array.isArray(input) || input.length !== count) {
|
||||
throw new TypeError(
|
||||
`${label} must contain exactly ${String(count)} values.`,
|
||||
);
|
||||
}
|
||||
return input.map((entry, index) =>
|
||||
historyInteger(entry, `${label}[${String(index)}]`, minimum, maximum),
|
||||
);
|
||||
};
|
||||
const maximumMask = 2 ** size - 1;
|
||||
const parsed: GameplayState = {
|
||||
values: numbers(record.values, "state.values", 0, size),
|
||||
cornerMarks: numbers(
|
||||
record.cornerMarks,
|
||||
"state.cornerMarks",
|
||||
0,
|
||||
maximumMask,
|
||||
),
|
||||
centerMarks: numbers(
|
||||
record.centerMarks,
|
||||
"state.centerMarks",
|
||||
0,
|
||||
maximumMask,
|
||||
),
|
||||
colors: numbers(record.colors, "state.colors", 0, 8),
|
||||
elapsedSeconds: historyInteger(
|
||||
record.elapsedSeconds,
|
||||
"state.elapsedSeconds",
|
||||
0,
|
||||
31_536_000,
|
||||
),
|
||||
...(record.aidMemoire === undefined
|
||||
? {}
|
||||
: { aidMemoire: aidMemoireFromPortable(record.aidMemoire, size) }),
|
||||
};
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function portableHistoryState(state: GameplayState, size: number) {
|
||||
return {
|
||||
values: [...state.values],
|
||||
cornerMarks: [...state.cornerMarks],
|
||||
centerMarks: [...state.centerMarks],
|
||||
colors: [...state.colors],
|
||||
elapsedSeconds: state.elapsedSeconds,
|
||||
...(state.aidMemoire === undefined
|
||||
? {}
|
||||
: { aidMemoire: aidMemoireToPortable(state.aidMemoire, size) }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Serialize bounded replay/savepoint history for durable project storage. */
|
||||
export function serializeGameplayHistory(
|
||||
history: GameplayHistory,
|
||||
size: number,
|
||||
): string {
|
||||
const payload = JSON.stringify({
|
||||
schema: GAMEPLAY_HISTORY_SCHEMA,
|
||||
version: 1,
|
||||
history: {
|
||||
...history,
|
||||
moments: history.moments.map((moment) => ({
|
||||
...moment,
|
||||
state: portableHistoryState(moment.state, size),
|
||||
})),
|
||||
branches: history.branches.map((branch) => ({
|
||||
...branch,
|
||||
baseState: portableHistoryState(branch.baseState, size),
|
||||
})),
|
||||
savepoints: history.savepoints.map((savepoint) => ({
|
||||
...savepoint,
|
||||
state: portableHistoryState(savepoint.state, size),
|
||||
})),
|
||||
},
|
||||
});
|
||||
if (
|
||||
new TextEncoder().encode(payload).byteLength > MAX_GAMEPLAY_HISTORY_BYTES
|
||||
) {
|
||||
throw new RangeError("Gameplay history is too large to persist safely.");
|
||||
}
|
||||
// Parsing here gives callers one canonical validation boundary even when a
|
||||
// history object was assembled outside the normal reducers.
|
||||
void parseGameplayHistory(payload, size);
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** Parse an untrusted persisted gameplay history into independent state. */
|
||||
export function parseGameplayHistory(
|
||||
input: string,
|
||||
size: number,
|
||||
): GameplayHistory {
|
||||
if (new TextEncoder().encode(input).byteLength > MAX_GAMEPLAY_HISTORY_BYTES) {
|
||||
throw new RangeError("Gameplay history is too large to open safely.");
|
||||
}
|
||||
let decoded: unknown;
|
||||
try {
|
||||
decoded = JSON.parse(input) as unknown;
|
||||
} catch (error) {
|
||||
throw new TypeError("Gameplay history is not valid JSON.", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
const envelope = historyRecord(decoded, "Gameplay history");
|
||||
if (envelope.schema !== GAMEPLAY_HISTORY_SCHEMA || envelope.version !== 1) {
|
||||
throw new TypeError("Unsupported gameplay-history version.");
|
||||
}
|
||||
const raw = historyRecord(envelope.history, "Gameplay history payload");
|
||||
if (
|
||||
!Array.isArray(raw.moments) ||
|
||||
raw.moments.length < 1 ||
|
||||
raw.moments.length > MAX_GAMEPLAY_MOMENTS ||
|
||||
!Array.isArray(raw.branches) ||
|
||||
raw.branches.length > MAX_GAMEPLAY_MOMENTS ||
|
||||
!Array.isArray(raw.savepoints) ||
|
||||
raw.savepoints.length > MAX_GAMEPLAY_MOMENTS
|
||||
) {
|
||||
throw new TypeError(
|
||||
"Gameplay history collections are invalid or too large.",
|
||||
);
|
||||
}
|
||||
const moments = raw.moments.map((value, index): GameplayMoment => {
|
||||
const item = historyRecord(value, `moments[${String(index)}]`);
|
||||
return {
|
||||
id: historyText(item.id, `moments[${String(index)}].id`),
|
||||
sequence: historyInteger(
|
||||
item.sequence,
|
||||
`moments[${String(index)}].sequence`,
|
||||
0,
|
||||
1_000_000,
|
||||
),
|
||||
branchId: historyText(
|
||||
item.branchId,
|
||||
`moments[${String(index)}].branchId`,
|
||||
),
|
||||
label: historyText(item.label, `moments[${String(index)}].label`, 200),
|
||||
state: parseHistoryState(item.state, size),
|
||||
};
|
||||
});
|
||||
const branches = raw.branches.map((value, index): HypothesisBranch => {
|
||||
const item = historyRecord(value, `branches[${String(index)}]`);
|
||||
if (
|
||||
item.status !== "active" &&
|
||||
item.status !== "kept" &&
|
||||
item.status !== "discarded"
|
||||
) {
|
||||
throw new TypeError(`branches[${String(index)}].status is invalid.`);
|
||||
}
|
||||
return {
|
||||
id: historyText(item.id, `branches[${String(index)}].id`),
|
||||
name: historyText(item.name, `branches[${String(index)}].name`, 80),
|
||||
parentBranchId: historyText(
|
||||
item.parentBranchId,
|
||||
`branches[${String(index)}].parentBranchId`,
|
||||
),
|
||||
baseMomentId: historyText(
|
||||
item.baseMomentId,
|
||||
`branches[${String(index)}].baseMomentId`,
|
||||
),
|
||||
baseState: parseHistoryState(item.baseState, size),
|
||||
status: item.status,
|
||||
};
|
||||
});
|
||||
const savepoints = raw.savepoints.map((value, index): NamedSavepoint => {
|
||||
const item = historyRecord(value, `savepoints[${String(index)}]`);
|
||||
return {
|
||||
id: historyText(item.id, `savepoints[${String(index)}].id`),
|
||||
name: historyText(item.name, `savepoints[${String(index)}].name`, 80),
|
||||
momentId: historyText(
|
||||
item.momentId,
|
||||
`savepoints[${String(index)}].momentId`,
|
||||
),
|
||||
branchId: historyText(
|
||||
item.branchId,
|
||||
`savepoints[${String(index)}].branchId`,
|
||||
),
|
||||
state: parseHistoryState(item.state, size),
|
||||
};
|
||||
});
|
||||
const unique = (values: readonly string[], label: string): void => {
|
||||
if (new Set(values).size !== values.length) {
|
||||
throw new TypeError(`${label} contains duplicate IDs.`);
|
||||
}
|
||||
};
|
||||
unique(
|
||||
moments.map(({ id }) => id),
|
||||
"Gameplay moments",
|
||||
);
|
||||
unique(
|
||||
branches.map(({ id }) => id),
|
||||
"Gameplay branches",
|
||||
);
|
||||
unique(
|
||||
savepoints.map(({ id }) => id),
|
||||
"Gameplay savepoints",
|
||||
);
|
||||
const momentIds = new Set(moments.map(({ id }) => id));
|
||||
const branchIds = new Set([MAIN_BRANCH_ID, ...branches.map(({ id }) => id)]);
|
||||
const activeBranchId = historyText(raw.activeBranchId, "activeBranchId");
|
||||
const currentMomentId = historyText(raw.currentMomentId, "currentMomentId");
|
||||
if (!branchIds.has(activeBranchId) || !momentIds.has(currentMomentId)) {
|
||||
throw new TypeError(
|
||||
"Gameplay history points to a missing active branch or moment.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
moments.some(({ branchId }) => !branchIds.has(branchId)) ||
|
||||
branches.some(
|
||||
({ parentBranchId, baseMomentId }) =>
|
||||
!branchIds.has(parentBranchId) || !momentIds.has(baseMomentId),
|
||||
) ||
|
||||
savepoints.some(
|
||||
({ momentId, branchId }) =>
|
||||
!momentIds.has(momentId) || !branchIds.has(branchId),
|
||||
)
|
||||
) {
|
||||
throw new TypeError("Gameplay history contains a dangling reference.");
|
||||
}
|
||||
return {
|
||||
moments,
|
||||
branches,
|
||||
savepoints,
|
||||
activeBranchId,
|
||||
currentMomentId,
|
||||
nextSequence: historyInteger(
|
||||
raw.nextSequence,
|
||||
"nextSequence",
|
||||
1,
|
||||
1_000_001,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneState(state: GameplayState): GameplayState {
|
||||
return {
|
||||
values: [...state.values],
|
||||
cornerMarks: [...state.cornerMarks],
|
||||
centerMarks: [...state.centerMarks],
|
||||
colors: [...state.colors],
|
||||
elapsedSeconds: state.elapsedSeconds,
|
||||
...(state.aidMemoire === undefined
|
||||
? {}
|
||||
: { aidMemoire: cloneAidMemoire(state.aidMemoire) }),
|
||||
};
|
||||
}
|
||||
|
||||
function requireName(value: string, kind: string): string {
|
||||
const name = value.trim();
|
||||
if (!name) throw new Error(`${kind} name cannot be empty.`);
|
||||
return name.slice(0, 80);
|
||||
}
|
||||
|
||||
function appendMoment(
|
||||
history: GameplayHistory,
|
||||
state: GameplayState,
|
||||
label: string,
|
||||
branchId = history.activeBranchId,
|
||||
): GameplayHistory {
|
||||
const sequence = history.nextSequence;
|
||||
const moment: GameplayMoment = {
|
||||
id: `moment-${String(sequence)}`,
|
||||
sequence,
|
||||
branchId,
|
||||
label,
|
||||
state: cloneState(state),
|
||||
};
|
||||
return {
|
||||
...history,
|
||||
moments: [...history.moments, moment].slice(-MAX_GAMEPLAY_MOMENTS),
|
||||
activeBranchId: branchId,
|
||||
currentMomentId: moment.id,
|
||||
nextSequence: sequence + 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function captureGameplayState(
|
||||
session: PlaySession,
|
||||
aidMemoire?: AidMemoireState,
|
||||
): GameplayState {
|
||||
return {
|
||||
...snapshotSession(session),
|
||||
elapsedSeconds: session.elapsedSeconds,
|
||||
...(aidMemoire === undefined
|
||||
? {}
|
||||
: { aidMemoire: cloneAidMemoire(aidMemoire) }),
|
||||
};
|
||||
}
|
||||
|
||||
export function sessionFromGameplayState(
|
||||
state: GameplayState,
|
||||
paused = false,
|
||||
): PlaySession {
|
||||
return {
|
||||
values: [...state.values],
|
||||
cornerMarks: [...state.cornerMarks],
|
||||
centerMarks: [...state.centerMarks],
|
||||
colors: [...state.colors],
|
||||
elapsedSeconds: state.elapsedSeconds,
|
||||
paused,
|
||||
};
|
||||
}
|
||||
|
||||
export function aidMemoireFromGameplayState(
|
||||
state: GameplayState,
|
||||
size: number,
|
||||
): AidMemoireState {
|
||||
return state.aidMemoire === undefined
|
||||
? createAidMemoire(size)
|
||||
: cloneAidMemoire(state.aidMemoire);
|
||||
}
|
||||
|
||||
export function createGameplayHistory(
|
||||
session: PlaySession,
|
||||
aidMemoire?: AidMemoireState,
|
||||
): GameplayHistory {
|
||||
const initial: GameplayMoment = {
|
||||
id: "moment-0",
|
||||
sequence: 0,
|
||||
branchId: MAIN_BRANCH_ID,
|
||||
label: "Puzzle opened",
|
||||
state: captureGameplayState(session, aidMemoire),
|
||||
};
|
||||
return {
|
||||
moments: [initial],
|
||||
branches: [],
|
||||
savepoints: [],
|
||||
activeBranchId: MAIN_BRANCH_ID,
|
||||
currentMomentId: initial.id,
|
||||
nextSequence: 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function recordGameplayMoment(
|
||||
history: GameplayHistory,
|
||||
session: PlaySession,
|
||||
label: string,
|
||||
aidMemoire?: AidMemoireState,
|
||||
): GameplayHistory {
|
||||
const trimmedLabel = label.trim() || "Grid updated";
|
||||
return appendMoment(
|
||||
history,
|
||||
captureGameplayState(session, aidMemoire),
|
||||
trimmedLabel,
|
||||
);
|
||||
}
|
||||
|
||||
export function gameplayMoment(
|
||||
history: GameplayHistory,
|
||||
momentId: string,
|
||||
): GameplayMoment | undefined {
|
||||
return history.moments.find((moment) => moment.id === momentId);
|
||||
}
|
||||
|
||||
export function activeHypothesis(
|
||||
history: GameplayHistory,
|
||||
): HypothesisBranch | undefined {
|
||||
return history.branches.find(
|
||||
(branch) =>
|
||||
branch.id === history.activeBranchId && branch.status === "active",
|
||||
);
|
||||
}
|
||||
|
||||
export function beginHypothesis(
|
||||
history: GameplayHistory,
|
||||
session: PlaySession,
|
||||
nameValue: string,
|
||||
fromMomentId = history.currentMomentId,
|
||||
aidMemoire?: AidMemoireState,
|
||||
): HistoryTransition {
|
||||
if (activeHypothesis(history) !== undefined) {
|
||||
throw new Error("Finish the active hypothesis before starting another.");
|
||||
}
|
||||
const name = requireName(nameValue, "Hypothesis");
|
||||
const source = gameplayMoment(history, fromMomentId);
|
||||
if (source === undefined)
|
||||
throw new Error("The replay moment is unavailable.");
|
||||
|
||||
const baseState =
|
||||
fromMomentId === history.currentMomentId
|
||||
? captureGameplayState(session, aidMemoire)
|
||||
: cloneState(source.state);
|
||||
const sequence = history.nextSequence;
|
||||
const branchId = `branch-${String(sequence)}`;
|
||||
const branch: HypothesisBranch = {
|
||||
id: branchId,
|
||||
name,
|
||||
parentBranchId: history.activeBranchId,
|
||||
baseMomentId: source.id,
|
||||
baseState: cloneState(baseState),
|
||||
status: "active",
|
||||
};
|
||||
const withBranch: GameplayHistory = {
|
||||
...history,
|
||||
branches: [...history.branches, branch],
|
||||
activeBranchId: branchId,
|
||||
currentMomentId: source.id,
|
||||
};
|
||||
return {
|
||||
history: appendMoment(
|
||||
withBranch,
|
||||
baseState,
|
||||
`Started hypothesis “${name}”`,
|
||||
branchId,
|
||||
),
|
||||
state: cloneState(baseState),
|
||||
};
|
||||
}
|
||||
|
||||
export function finishHypothesis(
|
||||
history: GameplayHistory,
|
||||
session: PlaySession,
|
||||
decision: "keep" | "discard",
|
||||
aidMemoire?: AidMemoireState,
|
||||
): HistoryTransition {
|
||||
const branch = activeHypothesis(history);
|
||||
if (branch === undefined) throw new Error("No hypothesis is active.");
|
||||
const state =
|
||||
decision === "keep"
|
||||
? captureGameplayState(session, aidMemoire)
|
||||
: cloneState(branch.baseState);
|
||||
const status: HypothesisStatus = decision === "keep" ? "kept" : "discarded";
|
||||
const updated: GameplayHistory = {
|
||||
...history,
|
||||
branches: history.branches.map((candidate) =>
|
||||
candidate.id === branch.id ? { ...candidate, status } : candidate,
|
||||
),
|
||||
activeBranchId: branch.parentBranchId,
|
||||
currentMomentId: branch.baseMomentId,
|
||||
};
|
||||
const verb = decision === "keep" ? "Kept" : "Discarded";
|
||||
return {
|
||||
history: appendMoment(
|
||||
updated,
|
||||
state,
|
||||
`${verb} hypothesis “${branch.name}”`,
|
||||
branch.parentBranchId,
|
||||
),
|
||||
state: cloneState(state),
|
||||
};
|
||||
}
|
||||
|
||||
export function createSavepoint(
|
||||
history: GameplayHistory,
|
||||
session: PlaySession,
|
||||
nameValue: string,
|
||||
aidMemoire?: AidMemoireState,
|
||||
): GameplayHistory {
|
||||
const name = requireName(nameValue, "Savepoint");
|
||||
const state = captureGameplayState(session, aidMemoire);
|
||||
const withMoment = appendMoment(history, state, `Saved “${name}”`);
|
||||
const savepoint: NamedSavepoint = {
|
||||
id: `savepoint-${String(withMoment.nextSequence)}`,
|
||||
name,
|
||||
momentId: withMoment.currentMomentId,
|
||||
branchId: withMoment.activeBranchId,
|
||||
state: cloneState(state),
|
||||
};
|
||||
return {
|
||||
...withMoment,
|
||||
savepoints: [...withMoment.savepoints, savepoint],
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteSavepoint(
|
||||
history: GameplayHistory,
|
||||
savepointId: string,
|
||||
): GameplayHistory {
|
||||
return {
|
||||
...history,
|
||||
savepoints: history.savepoints.filter(
|
||||
(savepoint) => savepoint.id !== savepointId,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function restoreSavepoint(
|
||||
history: GameplayHistory,
|
||||
savepointId: string,
|
||||
): HistoryTransition {
|
||||
const savepoint = history.savepoints.find(
|
||||
(candidate) => candidate.id === savepointId,
|
||||
);
|
||||
if (savepoint === undefined) throw new Error("The savepoint is unavailable.");
|
||||
const state = cloneState(savepoint.state);
|
||||
return {
|
||||
history: appendMoment(history, state, `Restored “${savepoint.name}”`),
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
export function describeGameplayChange(
|
||||
before: PlaySession,
|
||||
after: PlaySession,
|
||||
size: number,
|
||||
): string {
|
||||
const changedValues: number[] = [];
|
||||
const changedNotes: number[] = [];
|
||||
const changedColors: number[] = [];
|
||||
for (let cell = 0; cell < after.values.length; cell += 1) {
|
||||
if (before.values[cell] !== after.values[cell]) changedValues.push(cell);
|
||||
if (
|
||||
before.cornerMarks[cell] !== after.cornerMarks[cell] ||
|
||||
before.centerMarks[cell] !== after.centerMarks[cell]
|
||||
) {
|
||||
changedNotes.push(cell);
|
||||
}
|
||||
if (before.colors[cell] !== after.colors[cell]) changedColors.push(cell);
|
||||
}
|
||||
const cells = new Set([...changedValues, ...changedNotes, ...changedColors]);
|
||||
if (cells.size !== 1) {
|
||||
return cells.size === 0
|
||||
? "Grid updated"
|
||||
: `Updated ${String(cells.size)} cells`;
|
||||
}
|
||||
const cell = [...cells][0]!;
|
||||
const label = `r${String(Math.floor(cell / size) + 1)}c${String((cell % size) + 1)}`;
|
||||
if (changedValues.length) {
|
||||
const value = after.values[cell] ?? 0;
|
||||
return value === 0
|
||||
? `Cleared ${label}`
|
||||
: `Set ${label} to ${String(value)}`;
|
||||
}
|
||||
if (changedNotes.length) return `Changed notes in ${label}`;
|
||||
return `Changed colour in ${label}`;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
export type CandidateVerbosity = "off" | "concise" | "detailed";
|
||||
|
||||
export const BOARD_SCALE_MIN = 0.75;
|
||||
export const BOARD_SCALE_MAX = 2;
|
||||
export const BOARD_SCALE_STEP = 0.25;
|
||||
export const BOARD_SCALE_STORAGE_KEY = "sudoku-tools:board-scale:v1";
|
||||
|
||||
export const COLOR_MARK_DESCRIPTIONS = [
|
||||
"red, diagonal stripes",
|
||||
"orange, reverse diagonal stripes",
|
||||
"yellow, dots",
|
||||
"green, crosshatch",
|
||||
"teal, horizontal bars",
|
||||
"blue, vertical bars",
|
||||
"purple, checkerboard",
|
||||
"pink, rings",
|
||||
] as const;
|
||||
|
||||
export function normalizeBoardScale(value: unknown): number {
|
||||
const numeric = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(numeric)) return 1;
|
||||
const clamped = Math.min(BOARD_SCALE_MAX, Math.max(BOARD_SCALE_MIN, numeric));
|
||||
return Math.round(clamped / BOARD_SCALE_STEP) * BOARD_SCALE_STEP;
|
||||
}
|
||||
|
||||
function browserStorage(): Storage | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function readStoredBoardScale(
|
||||
storage: Pick<Storage, "getItem"> | undefined = browserStorage(),
|
||||
): number {
|
||||
if (storage === undefined) return 1;
|
||||
try {
|
||||
const value = storage.getItem(BOARD_SCALE_STORAGE_KEY);
|
||||
return value === null ? 1 : normalizeBoardScale(value);
|
||||
} catch {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredBoardScale(
|
||||
scale: number,
|
||||
storage: Pick<Storage, "setItem"> | undefined = browserStorage(),
|
||||
): void {
|
||||
if (storage === undefined) return;
|
||||
try {
|
||||
storage.setItem(
|
||||
BOARD_SCALE_STORAGE_KEY,
|
||||
String(normalizeBoardScale(scale)),
|
||||
);
|
||||
} catch {
|
||||
// A blocked or full storage area must not make the local board unusable.
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCandidateVerbosity(value: unknown): CandidateVerbosity {
|
||||
return value === "off" || value === "concise" || value === "detailed"
|
||||
? value
|
||||
: "detailed";
|
||||
}
|
||||
|
||||
export function colorMarkDescription(value: number): string {
|
||||
return COLOR_MARK_DESCRIPTIONS[value - 1] ?? `mark ${String(value)}`;
|
||||
}
|
||||
+171
-5
@@ -1,19 +1,23 @@
|
||||
import { SudokuFormatError } from "../formats";
|
||||
import {
|
||||
cloneProjectRecord,
|
||||
createProjectRecord,
|
||||
MAX_PROJECT_BYTES,
|
||||
normalizeProjectRecord,
|
||||
} from "./record";
|
||||
import type {
|
||||
ProjectLibraryExport,
|
||||
ProjectLibraryQuery,
|
||||
SudokuProjectRecord,
|
||||
SudokuProjectSummary,
|
||||
} from "./types";
|
||||
|
||||
export const MAX_LIBRARY_PROJECTS = 256;
|
||||
export const MAX_MEMORY_LIBRARY_BYTES = 32 * 1_048_576;
|
||||
const DATABASE_VERSION = 1;
|
||||
const DATABASE_VERSION = 2;
|
||||
const STORE_NAME = "projects";
|
||||
const AUTOSAVE_STORE_NAME = "autosaves";
|
||||
const DEFAULT_AUTOSAVE_SLOT = "current";
|
||||
|
||||
export type ProjectLibraryMode = "indexeddb" | "memory";
|
||||
|
||||
@@ -30,9 +34,34 @@ function summary(record: SudokuProjectRecord): SudokuProjectSummary {
|
||||
updatedAt: record.updatedAt,
|
||||
size: record.puzzle.size,
|
||||
completed: record.progress?.completed ?? false,
|
||||
tags: [...(record.tags ?? [])],
|
||||
thumbnail:
|
||||
record.thumbnail ??
|
||||
record.puzzle.givens
|
||||
.map((value) => (value === 0 ? "." : String(value)))
|
||||
.join(""),
|
||||
};
|
||||
}
|
||||
|
||||
function matchesQuery(
|
||||
item: SudokuProjectSummary,
|
||||
query: ProjectLibraryQuery,
|
||||
): boolean {
|
||||
const search = query.search?.trim().toLocaleLowerCase();
|
||||
if (
|
||||
search &&
|
||||
!item.title.toLocaleLowerCase().includes(search) &&
|
||||
!item.tags.some((tag) => tag.toLocaleLowerCase().includes(search))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const tags = query.tags?.filter(Boolean) ?? [];
|
||||
if (tags.length > 0 && !tags.every((tag) => item.tags.includes(tag))) {
|
||||
return false;
|
||||
}
|
||||
return query.completed === undefined || item.completed === query.completed;
|
||||
}
|
||||
|
||||
function bytes(record: SudokuProjectRecord): number {
|
||||
return new TextEncoder().encode(JSON.stringify(record)).byteLength;
|
||||
}
|
||||
@@ -63,10 +92,21 @@ async function openDatabase(
|
||||
const request = factory.open(name, DATABASE_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const database = request.result;
|
||||
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
||||
const store = database.createObjectStore(STORE_NAME, { keyPath: "id" });
|
||||
const store = database.objectStoreNames.contains(STORE_NAME)
|
||||
? request.transaction!.objectStore(STORE_NAME)
|
||||
: database.createObjectStore(STORE_NAME, { keyPath: "id" });
|
||||
if (!store.indexNames.contains("updatedAt")) {
|
||||
store.createIndex("updatedAt", "updatedAt");
|
||||
}
|
||||
if (!store.indexNames.contains("title")) {
|
||||
store.createIndex("title", "title");
|
||||
}
|
||||
if (!store.indexNames.contains("tags")) {
|
||||
store.createIndex("tags", "tags", { multiEntry: true });
|
||||
}
|
||||
if (!database.objectStoreNames.contains(AUTOSAVE_STORE_NAME)) {
|
||||
database.createObjectStore(AUTOSAVE_STORE_NAME, { keyPath: "slot" });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () =>
|
||||
@@ -80,6 +120,7 @@ export class ProjectLibrary {
|
||||
readonly #factory: IDBFactory | null;
|
||||
readonly #databaseName: string;
|
||||
readonly #memory = new Map<string, SudokuProjectRecord>();
|
||||
readonly #memoryAutosaves = new Map<string, SudokuProjectRecord>();
|
||||
#database: Promise<IDBDatabase> | undefined;
|
||||
#mode: ProjectLibraryMode;
|
||||
|
||||
@@ -143,11 +184,14 @@ export class ProjectLibrary {
|
||||
this.#memory.set(record.id, cloneProjectRecord(record));
|
||||
}
|
||||
|
||||
async list(): Promise<readonly SudokuProjectSummary[]> {
|
||||
async list(
|
||||
query: ProjectLibraryQuery = {},
|
||||
): Promise<readonly SudokuProjectSummary[]> {
|
||||
const database = await this.#db();
|
||||
if (database === undefined) {
|
||||
return [...this.#memory.values()]
|
||||
.map(summary)
|
||||
.filter((item) => matchesQuery(item, query))
|
||||
.sort(
|
||||
(a, b) => b.updatedAt - a.updatedAt || a.title.localeCompare(b.title),
|
||||
);
|
||||
@@ -166,13 +210,14 @@ export class ProjectLibrary {
|
||||
}
|
||||
return records
|
||||
.map((record) => summary(normalizeProjectRecord(record)))
|
||||
.filter((item) => matchesQuery(item, query))
|
||||
.sort(
|
||||
(a, b) => b.updatedAt - a.updatedAt || a.title.localeCompare(b.title),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof SudokuFormatError) throw error;
|
||||
this.#mode = "memory";
|
||||
return this.list();
|
||||
return this.list(query);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +309,82 @@ export class ProjectLibrary {
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the latest working state separately from explicit Library saves. */
|
||||
async putAutosave(
|
||||
value: SudokuProjectRecord,
|
||||
slot = DEFAULT_AUTOSAVE_SLOT,
|
||||
): Promise<SudokuProjectRecord> {
|
||||
if (!slot || slot.length > 128) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_PROJECT",
|
||||
"Autosave slot is invalid.",
|
||||
);
|
||||
}
|
||||
const record = normalizeProjectRecord(value);
|
||||
const database = await this.#db();
|
||||
if (database === undefined) {
|
||||
this.#memoryAutosaves.set(slot, cloneProjectRecord(record));
|
||||
return cloneProjectRecord(record);
|
||||
}
|
||||
try {
|
||||
const transaction = database.transaction(
|
||||
AUTOSAVE_STORE_NAME,
|
||||
"readwrite",
|
||||
);
|
||||
transaction.objectStore(AUTOSAVE_STORE_NAME).put({ slot, record });
|
||||
await transactionDone(transaction);
|
||||
return cloneProjectRecord(record);
|
||||
} catch {
|
||||
this.#mode = "memory";
|
||||
this.#memoryAutosaves.set(slot, cloneProjectRecord(record));
|
||||
return cloneProjectRecord(record);
|
||||
}
|
||||
}
|
||||
|
||||
async getAutosave(
|
||||
slot = DEFAULT_AUTOSAVE_SLOT,
|
||||
): Promise<SudokuProjectRecord | undefined> {
|
||||
const database = await this.#db();
|
||||
if (database === undefined) {
|
||||
const record = this.#memoryAutosaves.get(slot);
|
||||
return record === undefined ? undefined : cloneProjectRecord(record);
|
||||
}
|
||||
try {
|
||||
const transaction = database.transaction(AUTOSAVE_STORE_NAME, "readonly");
|
||||
const value = await requestResult(
|
||||
transaction.objectStore(AUTOSAVE_STORE_NAME).get(slot),
|
||||
);
|
||||
await transactionDone(transaction);
|
||||
if (typeof value !== "object" || value === null || !("record" in value)) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeProjectRecord((value as { record: unknown }).record);
|
||||
} catch (error) {
|
||||
if (error instanceof SudokuFormatError) throw error;
|
||||
this.#mode = "memory";
|
||||
return this.getAutosave(slot);
|
||||
}
|
||||
}
|
||||
|
||||
async clearAutosave(slot = DEFAULT_AUTOSAVE_SLOT): Promise<void> {
|
||||
const database = await this.#db();
|
||||
if (database === undefined) {
|
||||
this.#memoryAutosaves.delete(slot);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const transaction = database.transaction(
|
||||
AUTOSAVE_STORE_NAME,
|
||||
"readwrite",
|
||||
);
|
||||
transaction.objectStore(AUTOSAVE_STORE_NAME).delete(slot);
|
||||
await transactionDone(transaction);
|
||||
} catch {
|
||||
this.#mode = "memory";
|
||||
this.#memoryAutosaves.delete(slot);
|
||||
}
|
||||
}
|
||||
|
||||
async exportAll(): Promise<ProjectLibraryExport> {
|
||||
const summaries = await this.list();
|
||||
const projects: SudokuProjectRecord[] = [];
|
||||
@@ -279,6 +400,51 @@ export class ProjectLibrary {
|
||||
};
|
||||
}
|
||||
|
||||
async exportSelected(ids: readonly string[]): Promise<ProjectLibraryExport> {
|
||||
const unique = [...new Set(ids)].slice(0, MAX_LIBRARY_PROJECTS);
|
||||
const projects: SudokuProjectRecord[] = [];
|
||||
for (const id of unique) {
|
||||
const record = await this.get(id);
|
||||
if (record !== undefined) projects.push(record);
|
||||
}
|
||||
return {
|
||||
schema: "de.add-ideas.sudoku-tools.library",
|
||||
version: 1,
|
||||
exportedAt: Date.now(),
|
||||
projects,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create independent local copies without reusing source record IDs. */
|
||||
async duplicateSelected(ids: readonly string[]): Promise<number> {
|
||||
const unique = [...new Set(ids)].slice(0, MAX_LIBRARY_PROJECTS);
|
||||
const sources: SudokuProjectRecord[] = [];
|
||||
for (const id of unique) {
|
||||
const source = await this.get(id);
|
||||
if (source !== undefined) sources.push(source);
|
||||
}
|
||||
if ((await this.list()).length + sources.length > MAX_LIBRARY_PROJECTS) {
|
||||
throw new SudokuFormatError(
|
||||
"STORAGE_LIMIT",
|
||||
`Copying this selection would exceed the ${String(MAX_LIBRARY_PROJECTS)}-project limit.`,
|
||||
);
|
||||
}
|
||||
let copied = 0;
|
||||
for (const source of sources) {
|
||||
const now = Date.now() + copied;
|
||||
await this.put(
|
||||
createProjectRecord(source.puzzle, {
|
||||
title: `${source.title || "Untitled puzzle"} copy`,
|
||||
progress: source.progress,
|
||||
tags: source.tags,
|
||||
now,
|
||||
}),
|
||||
);
|
||||
copied += 1;
|
||||
}
|
||||
return copied;
|
||||
}
|
||||
|
||||
async importAll(value: unknown, replace = false): Promise<number> {
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
|
||||
@@ -4,6 +4,12 @@ import {
|
||||
cloneSudokuDocument,
|
||||
normalizeSudokuDocument,
|
||||
} from "../formats";
|
||||
import {
|
||||
normalizePortableAidMemoire,
|
||||
type PortableAidMemoire,
|
||||
} from "../state/aidMemoire";
|
||||
import { parseGameplayHistory } from "../state/playHistory";
|
||||
import { symbolFor } from "../state/session";
|
||||
import {
|
||||
PROJECT_RECORD_SCHEMA,
|
||||
PROJECT_RECORD_VERSION,
|
||||
@@ -14,6 +20,8 @@ import {
|
||||
export const MAX_PROJECT_BYTES = MAX_DOCUMENT_BYTES * 2;
|
||||
export const MAX_PROJECT_ID_LENGTH = 128;
|
||||
export const MAX_PROJECT_TITLE_LENGTH = 500;
|
||||
export const MAX_PROJECT_TAGS = 20;
|
||||
export const MAX_PROJECT_TAG_LENGTH = 40;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
@@ -126,6 +134,39 @@ function progress(value: unknown, size: number): SudokuProgress | undefined {
|
||||
if (value.completed !== undefined && typeof value.completed !== "boolean") {
|
||||
return storageError("INVALID_PROJECT", "completed must be true or false.");
|
||||
}
|
||||
let aidMemoire: PortableAidMemoire | undefined;
|
||||
if (value.aidMemoire !== undefined) {
|
||||
try {
|
||||
aidMemoire = normalizePortableAidMemoire(value.aidMemoire, size);
|
||||
} catch (error) {
|
||||
return storageError(
|
||||
"INVALID_PROJECT",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Progress aid-mémoire data is invalid.",
|
||||
);
|
||||
}
|
||||
}
|
||||
let gameplayHistory: string | undefined;
|
||||
if (value.gameplayHistory !== undefined) {
|
||||
if (typeof value.gameplayHistory !== "string") {
|
||||
return storageError(
|
||||
"INVALID_PROJECT",
|
||||
"Progress gameplayHistory must be serialized text.",
|
||||
);
|
||||
}
|
||||
try {
|
||||
void parseGameplayHistory(value.gameplayHistory, size);
|
||||
gameplayHistory = value.gameplayHistory;
|
||||
} catch (error) {
|
||||
return storageError(
|
||||
"INVALID_PROJECT",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Progress gameplay history is invalid.",
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
values,
|
||||
@@ -137,9 +178,58 @@ function progress(value: unknown, size: number): SudokuProgress | undefined {
|
||||
? {}
|
||||
: { elapsedMs: value.elapsedMs as number }),
|
||||
...(value.completed === undefined ? {} : { completed: value.completed }),
|
||||
...(aidMemoire === undefined ? {} : { aidMemoire }),
|
||||
...(gameplayHistory === undefined ? {} : { gameplayHistory }),
|
||||
};
|
||||
}
|
||||
|
||||
function projectTags(value: unknown): string[] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!Array.isArray(value) || value.length > MAX_PROJECT_TAGS) {
|
||||
return storageError(
|
||||
"INVALID_PROJECT",
|
||||
`Project tags must contain at most ${MAX_PROJECT_TAGS} entries.`,
|
||||
);
|
||||
}
|
||||
const tags = value.map((tag, index) => {
|
||||
if (
|
||||
typeof tag !== "string" ||
|
||||
tag.trim().length === 0 ||
|
||||
tag.length > MAX_PROJECT_TAG_LENGTH
|
||||
) {
|
||||
return storageError(
|
||||
"INVALID_PROJECT",
|
||||
`Project tag ${String(index + 1)} is empty or too long.`,
|
||||
);
|
||||
}
|
||||
return tag.trim();
|
||||
});
|
||||
return [...new Set(tags)].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function projectThumbnail(
|
||||
value: unknown,
|
||||
givens: readonly number[],
|
||||
size: number,
|
||||
) {
|
||||
if (value === undefined) {
|
||||
return givens
|
||||
.map((digit) => (digit === 0 ? "." : symbolFor(digit, size)))
|
||||
.join("");
|
||||
}
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
[...value].length !== size * size ||
|
||||
!/^[.1-9A-G]+$/u.test(value)
|
||||
) {
|
||||
return storageError(
|
||||
"INVALID_PROJECT",
|
||||
"Project thumbnail must be a safe row-major grid preview.",
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeProjectRecord(value: unknown): SudokuProjectRecord {
|
||||
if (!isRecord(value))
|
||||
return storageError("INVALID_PROJECT", "A project must be an object.");
|
||||
@@ -181,6 +271,12 @@ export function normalizeProjectRecord(value: unknown): SudokuProjectRecord {
|
||||
);
|
||||
}
|
||||
const puzzle = normalizeSudokuDocument(value.puzzle);
|
||||
const tags = projectTags(value.tags);
|
||||
const thumbnail = projectThumbnail(
|
||||
value.thumbnail,
|
||||
puzzle.givens,
|
||||
puzzle.size,
|
||||
);
|
||||
const normalized: SudokuProjectRecord = {
|
||||
schema: PROJECT_RECORD_SCHEMA,
|
||||
version: PROJECT_RECORD_VERSION,
|
||||
@@ -192,6 +288,8 @@ export function normalizeProjectRecord(value: unknown): SudokuProjectRecord {
|
||||
...(value.progress === undefined
|
||||
? {}
|
||||
: { progress: progress(value.progress, puzzle.size) }),
|
||||
...(tags === undefined ? {} : { tags }),
|
||||
thumbnail,
|
||||
};
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(normalized)).byteLength;
|
||||
if (bytes > MAX_PROJECT_BYTES) {
|
||||
@@ -208,6 +306,7 @@ export interface NewProjectOptions {
|
||||
readonly title?: string;
|
||||
readonly now?: number;
|
||||
readonly progress?: SudokuProgress;
|
||||
readonly tags?: readonly string[];
|
||||
}
|
||||
|
||||
function randomId(): string {
|
||||
@@ -234,6 +333,7 @@ export function createProjectRecord(
|
||||
updatedAt: now,
|
||||
puzzle,
|
||||
...(options.progress === undefined ? {} : { progress: options.progress }),
|
||||
...(options.tags === undefined ? {} : { tags: options.tags }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -244,6 +344,7 @@ export function cloneProjectRecord(
|
||||
return {
|
||||
...normalized,
|
||||
puzzle: cloneSudokuDocument(normalized.puzzle),
|
||||
...(normalized.tags === undefined ? {} : { tags: [...normalized.tags] }),
|
||||
...(normalized.progress === undefined
|
||||
? {}
|
||||
: {
|
||||
@@ -274,6 +375,19 @@ export function cloneProjectRecord(
|
||||
...entry,
|
||||
]),
|
||||
}),
|
||||
...(normalized.progress.aidMemoire === undefined
|
||||
? {}
|
||||
: {
|
||||
aidMemoire: normalizePortableAidMemoire(
|
||||
normalized.progress.aidMemoire,
|
||||
normalized.puzzle.size,
|
||||
),
|
||||
}),
|
||||
...(normalized.progress.gameplayHistory === undefined
|
||||
? {}
|
||||
: {
|
||||
gameplayHistory: normalized.progress.gameplayHistory,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
+15
-1
@@ -1,4 +1,4 @@
|
||||
import type { SudokuDocument } from "../formats";
|
||||
import type { PortableAidMemoire, SudokuDocument } from "../formats";
|
||||
|
||||
export const PROJECT_RECORD_SCHEMA =
|
||||
"de.add-ideas.sudoku-tools.project" as const;
|
||||
@@ -14,6 +14,9 @@ export interface SudokuProgress {
|
||||
readonly candidates?: readonly (readonly number[])[];
|
||||
readonly elapsedMs?: number;
|
||||
readonly completed?: boolean;
|
||||
readonly aidMemoire?: PortableAidMemoire;
|
||||
/** Canonical, bounded replay/savepoint history JSON. */
|
||||
readonly gameplayHistory?: string;
|
||||
}
|
||||
|
||||
export interface SudokuProjectRecord {
|
||||
@@ -25,6 +28,9 @@ export interface SudokuProjectRecord {
|
||||
readonly updatedAt: number;
|
||||
readonly puzzle: SudokuDocument;
|
||||
readonly progress?: SudokuProgress;
|
||||
readonly tags?: readonly string[];
|
||||
/** Compact row-major symbols used for a script-free Library preview. */
|
||||
readonly thumbnail?: string;
|
||||
}
|
||||
|
||||
export interface SudokuProjectSummary {
|
||||
@@ -34,6 +40,14 @@ export interface SudokuProjectSummary {
|
||||
readonly updatedAt: number;
|
||||
readonly size: number;
|
||||
readonly completed: boolean;
|
||||
readonly tags: readonly string[];
|
||||
readonly thumbnail: string;
|
||||
}
|
||||
|
||||
export interface ProjectLibraryQuery {
|
||||
readonly search?: string;
|
||||
readonly tags?: readonly string[];
|
||||
readonly completed?: boolean;
|
||||
}
|
||||
|
||||
export interface ProjectLibraryExport {
|
||||
|
||||
+2290
-33
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.sudoku-tools",
|
||||
"name": "Sudoku Tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.1",
|
||||
"description": "Set, play, solve and analyse Sudoku puzzles locally in the browser.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const APPLICATION_VERSION = "0.1.0";
|
||||
export const APPLICATION_VERSION = "0.2.1";
|
||||
|
||||
@@ -2,12 +2,20 @@ import type { PuzzleDefinition } from "../domain";
|
||||
import type {
|
||||
ExactSolveOptions,
|
||||
ExactSolveResult,
|
||||
DifficultyAssessment,
|
||||
DifficultyOptions,
|
||||
GenerateClassicOptions,
|
||||
GeneratedVariantPuzzle,
|
||||
GeneratedVariantBatch,
|
||||
GenerateVariantBatchOptions,
|
||||
GenerateVariantOptions,
|
||||
KillerCombinationOptions,
|
||||
KillerCombinationResult,
|
||||
LogicalSolveOptions,
|
||||
LogicalSolveResult,
|
||||
MinimizeOptions,
|
||||
PuzzleQualityAnalysis,
|
||||
PuzzleQualityOptions,
|
||||
} from "../solver";
|
||||
import type { NormalizedPuzzle, ValidationIssue } from "../domain";
|
||||
|
||||
@@ -23,6 +31,24 @@ export type SolverWorkerOperation =
|
||||
readonly options?: LogicalSolveOptions;
|
||||
}
|
||||
| { readonly kind: "generate"; readonly options?: GenerateClassicOptions }
|
||||
| {
|
||||
readonly kind: "generate-variant";
|
||||
readonly options?: GenerateVariantOptions;
|
||||
}
|
||||
| {
|
||||
readonly kind: "generate-batch";
|
||||
readonly options?: GenerateVariantBatchOptions;
|
||||
}
|
||||
| {
|
||||
readonly kind: "difficulty";
|
||||
readonly puzzle: PuzzleDefinition;
|
||||
readonly options?: DifficultyOptions;
|
||||
}
|
||||
| {
|
||||
readonly kind: "quality";
|
||||
readonly puzzle: PuzzleDefinition;
|
||||
readonly options?: PuzzleQualityOptions;
|
||||
}
|
||||
| {
|
||||
readonly kind: "minimize";
|
||||
readonly puzzle: PuzzleDefinition;
|
||||
@@ -42,6 +68,10 @@ export type SolverWorkerValue =
|
||||
| ExactSolveResult
|
||||
| LogicalSolveResult
|
||||
| NormalizedPuzzle
|
||||
| GeneratedVariantPuzzle
|
||||
| GeneratedVariantBatch
|
||||
| DifficultyAssessment
|
||||
| PuzzleQualityAnalysis
|
||||
| KillerCombinationResult;
|
||||
|
||||
export interface SolverWorkerError {
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
import { PuzzleValidationError } from "../domain";
|
||||
import {
|
||||
analyzePuzzleQuality,
|
||||
evaluateDifficulty,
|
||||
generateClassic,
|
||||
generateVariant,
|
||||
generateVariantBatch,
|
||||
killerDigitCombinations,
|
||||
minimizePuzzle,
|
||||
solveExact,
|
||||
@@ -25,6 +29,14 @@ function run(request: SolverWorkerRequest): SolverWorkerValue {
|
||||
return solveLogically(operation.puzzle, operation.options);
|
||||
case "generate":
|
||||
return generateClassic(operation.options);
|
||||
case "generate-variant":
|
||||
return generateVariant(operation.options);
|
||||
case "generate-batch":
|
||||
return generateVariantBatch(operation.options);
|
||||
case "difficulty":
|
||||
return evaluateDifficulty(operation.puzzle, operation.options);
|
||||
case "quality":
|
||||
return analyzePuzzleQuality(operation.puzzle, operation.options);
|
||||
case "minimize":
|
||||
return minimizePuzzle(operation.puzzle, operation.options);
|
||||
case "killer-combinations":
|
||||
|
||||
@@ -32,6 +32,16 @@ test("loads standalone and keeps the core play workflow local", async ({
|
||||
grid.getByRole("gridcell", { name: "Row 1, column 3, empty" }),
|
||||
).toBeVisible();
|
||||
|
||||
const placedFour = grid.getByRole("gridcell", {
|
||||
name: "Row 1, column 1, 4",
|
||||
});
|
||||
await placedFour.click({ modifiers: ["Control"] });
|
||||
expect(await grid.locator(".is-digit-highlighted").count()).toBeGreaterThan(
|
||||
1,
|
||||
);
|
||||
await placedFour.click({ modifiers: ["Control"] });
|
||||
await expect(grid.locator(".is-digit-highlighted")).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: "Helpers" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Sudoku helpers" }),
|
||||
@@ -54,3 +64,402 @@ test("loads standalone and keeps the core play workflow local", async ({
|
||||
|
||||
expect(runtimeErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test("keeps the board, keypad and solver controls within their layouts", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await page.goto("/deep/nested/sudoku/");
|
||||
|
||||
const frame = page.locator(".sudoku-board-frame");
|
||||
const sidebar = page.locator(".side-panel");
|
||||
const boardColumn = page.locator(".board-column");
|
||||
const scroller = page.locator(".board-viewport__scroller");
|
||||
await expect(frame).toBeVisible();
|
||||
|
||||
const fittedWidth = (await frame.boundingBox())?.width ?? 0;
|
||||
expect(fittedWidth).toBeGreaterThan(0);
|
||||
expect(
|
||||
await scroller.evaluate(
|
||||
(element) => element.scrollHeight <= element.clientHeight + 1,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const digits = page.getByRole("group", { name: "Digits" });
|
||||
const digitBoxes = await Promise.all(
|
||||
["1", "2", "3", "4", "7", "9"].map(async (digit) =>
|
||||
digits.getByRole("button", { name: digit, exact: true }).boundingBox(),
|
||||
),
|
||||
);
|
||||
const [one, two, three, four, seven, nine] = digitBoxes;
|
||||
const erase = await digits
|
||||
.getByRole("button", { name: "Erase" })
|
||||
.boundingBox();
|
||||
for (const box of [...digitBoxes, erase]) expect(box).not.toBeNull();
|
||||
expect(Math.abs(one!.y - two!.y)).toBeLessThan(1);
|
||||
expect(Math.abs(two!.y - three!.y)).toBeLessThan(1);
|
||||
expect(Math.abs(one!.x - four!.x)).toBeLessThan(1);
|
||||
expect(Math.abs(four!.x - seven!.x)).toBeLessThan(1);
|
||||
expect(four!.y).toBeGreaterThan(one!.y + one!.height);
|
||||
expect(seven!.y).toBeGreaterThan(four!.y + four!.height);
|
||||
expect(Math.abs(erase!.x - one!.x)).toBeLessThan(1);
|
||||
expect(
|
||||
Math.abs(erase!.x + erase!.width - (three!.x + three!.width)),
|
||||
).toBeLessThan(1);
|
||||
expect(erase!.y).toBeGreaterThan(nine!.y + nine!.height);
|
||||
|
||||
await page.getByRole("button", { name: "Zoom board out" }).click();
|
||||
await expect(page.getByLabel("Board zoom level")).toHaveText("75%");
|
||||
const reducedWidth = (await frame.boundingBox())?.width ?? 0;
|
||||
expect(reducedWidth / fittedWidth).toBeCloseTo(0.75, 2);
|
||||
await page.getByRole("button", { name: "Fit board" }).click();
|
||||
|
||||
const baselineSidebarWidth = (await sidebar.boundingBox())?.width ?? 0;
|
||||
const baselineBoardWidth = (await boardColumn.boundingBox())?.width ?? 0;
|
||||
for (const workspace of ["Set", "Generate", "Solve", "Helpers"]) {
|
||||
await page.getByRole("button", { name: workspace, exact: true }).click();
|
||||
expect((await sidebar.boundingBox())?.width ?? 0).toBeCloseTo(
|
||||
baselineSidebarWidth,
|
||||
1,
|
||||
);
|
||||
expect((await boardColumn.boundingBox())?.width ?? 0).toBeCloseTo(
|
||||
baselineBoardWidth,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
await page.getByRole("tab", { name: "Candidate links" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Links and houses" }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
await sidebar.evaluate(
|
||||
(element) => element.scrollWidth <= element.clientWidth + 1,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await page.getByRole("button", { name: "Solve", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Build logical solve path" }).click();
|
||||
const solveSteps = page.locator(".solve-step");
|
||||
await expect(solveSteps.first()).toBeVisible({ timeout: 60_000 });
|
||||
expect(await solveSteps.count()).toBeGreaterThan(0);
|
||||
expect(
|
||||
await solveSteps.evaluateAll((steps) =>
|
||||
steps.every(
|
||||
(step) =>
|
||||
step.scrollHeight <= step.clientHeight + 1 &&
|
||||
step.scrollWidth <= step.clientWidth + 1,
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("replaces setter cages and generates a rated variant", async ({
|
||||
page,
|
||||
}) => {
|
||||
const runtimeErrors: string[] = [];
|
||||
page.on("pageerror", (error) => runtimeErrors.push(error.message));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") runtimeErrors.push(message.text());
|
||||
});
|
||||
|
||||
await page.goto("/deep/nested/sudoku/");
|
||||
await page.getByRole("button", { name: "New blank" }).click();
|
||||
const grid = page.getByRole("grid", { name: "9 by 9 Sudoku grid" });
|
||||
await grid.getByRole("gridcell", { name: "Row 1, column 1, empty" }).click();
|
||||
await grid
|
||||
.getByRole("gridcell", { name: "Row 1, column 2, empty" })
|
||||
.click({ modifiers: ["Shift"] });
|
||||
|
||||
await page.getByRole("spinbutton", { name: "Cage sum" }).fill("3");
|
||||
await page.getByRole("button", { name: "Add / replace cage" }).click();
|
||||
await expect(page.getByText("3 cage · 2 cells")).toBeVisible();
|
||||
await page.getByRole("spinbutton", { name: "Cage sum" }).fill("4");
|
||||
await page.getByRole("button", { name: "Add / replace cage" }).click();
|
||||
await expect(page.getByText("3 cage · 2 cells")).toHaveCount(0);
|
||||
await expect(page.getByText("4 cage · 2 cells")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Remove selected cage" }).click();
|
||||
await expect(page.getByText("4 cage · 2 cells")).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: "Generate", exact: true }).click();
|
||||
await page.getByLabel("Variant").selectOption("thermo");
|
||||
await page
|
||||
.getByRole("combobox", { name: "Grid", exact: true })
|
||||
.selectOption("4");
|
||||
await page.getByLabel("Requested profile").selectOption("beginner");
|
||||
await page.getByLabel("Seed").fill("browser-thermo");
|
||||
await page.getByRole("button", { name: "Generate Thermo" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Thermo · browser-thermo" }),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
await expect(page.getByText("Difficulty assessment")).toBeVisible();
|
||||
await expect(page.locator(".difficulty-card .status-pill")).toHaveText(
|
||||
"unique",
|
||||
);
|
||||
await expect(page.locator(".constraint-thermo")).not.toHaveCount(0);
|
||||
|
||||
expect(runtimeErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test("uses distinct numbered sum badges and directional inequalities", async ({
|
||||
page,
|
||||
}) => {
|
||||
const runtimeErrors: string[] = [];
|
||||
page.on("pageerror", (error) => runtimeErrors.push(error.message));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") runtimeErrors.push(message.text());
|
||||
});
|
||||
|
||||
await page.goto("/deep/nested/sudoku/");
|
||||
const examples = page.getByLabel("Open built-in puzzle");
|
||||
|
||||
await examples.selectOption("xv");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Five or ten" }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator(".constraint-xv--5 text").first()).toHaveText("5");
|
||||
await expect(page.locator(".constraint-xv--10 text").first()).toHaveText(
|
||||
"10",
|
||||
);
|
||||
|
||||
await examples.selectOption("inequality");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Lesser and greater" }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator(".constraint-inequality")).not.toHaveCount(0);
|
||||
await expect(
|
||||
page.locator(".constraint-inequality-tip").first(),
|
||||
).toBeVisible();
|
||||
|
||||
await examples.selectOption("truth-and-lies");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Truth and lies" }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator(".constraint-outside")).toHaveCount(4);
|
||||
await expect(page.locator(".constraint-quadruple")).toHaveCount(2);
|
||||
await expect(page.locator(".constraint-maximum")).toHaveCount(2);
|
||||
await expect(page.locator(".constraint-outside.is-negated")).toHaveCount(2);
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const outsideBox = await page
|
||||
.locator(".constraint-outside")
|
||||
.first()
|
||||
.boundingBox();
|
||||
const maximumBox = await page
|
||||
.locator(".constraint-maximum")
|
||||
.first()
|
||||
.boundingBox();
|
||||
expect(outsideBox?.width ?? 0).toBeGreaterThan(18);
|
||||
expect(outsideBox?.height ?? 0).toBeGreaterThan(12);
|
||||
expect(maximumBox?.width ?? 0).toBeGreaterThan(12);
|
||||
expect(maximumBox?.height ?? 0).toBeGreaterThan(12);
|
||||
await expect(
|
||||
page.locator(".constraint-thermo .constraint-false-mark").first(),
|
||||
).toBeVisible();
|
||||
|
||||
expect(runtimeErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test("keeps navigation, scratch work, branches and analysis tools local", async ({
|
||||
page,
|
||||
}) => {
|
||||
const runtimeErrors: string[] = [];
|
||||
page.on("pageerror", (error) => runtimeErrors.push(error.message));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") runtimeErrors.push(message.text());
|
||||
});
|
||||
|
||||
await page.goto("/deep/nested/sudoku/");
|
||||
const grid = page.getByRole("grid", { name: "9 by 9 Sudoku grid" });
|
||||
const first = grid.getByRole("gridcell", {
|
||||
name: "Row 1, column 1, 4",
|
||||
});
|
||||
await first.focus();
|
||||
await first.press("End");
|
||||
await expect(
|
||||
grid.getByRole("gridcell", { name: /Row 1, column 9,/u }),
|
||||
).toBeFocused();
|
||||
await page.keyboard.press("PageDown");
|
||||
await expect(
|
||||
grid.getByRole("gridcell", { name: /Row 9, column 9,/u }),
|
||||
).toBeFocused();
|
||||
await page.keyboard.press("Control+Home");
|
||||
await expect(first).toBeFocused();
|
||||
|
||||
await page.getByLabel("Show aid-mémoire scratch cells").check();
|
||||
const scratch = page.getByRole("grid", {
|
||||
name: "9 aid-mémoire scratch cells",
|
||||
});
|
||||
const scratchCell = scratch.getByRole("gridcell").first();
|
||||
await scratchCell.click();
|
||||
await page
|
||||
.getByRole("group", { name: "Digits" })
|
||||
.getByRole("button", { name: "2", exact: true })
|
||||
.click();
|
||||
await expect(scratch.getByRole("gridcell").first()).toHaveAccessibleName(
|
||||
/value 2/u,
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: "History & branches" }).click();
|
||||
await page.getByLabel("Savepoint name").fill("Before test branch");
|
||||
await page.getByRole("button", { name: "Save current grid" }).click();
|
||||
await expect(
|
||||
page.getByText("Before test branch", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByLabel("Hypothesis name").fill("Try a two");
|
||||
await page.getByRole("button", { name: "Start hypothesis" }).click();
|
||||
|
||||
const empty = grid.getByRole("gridcell", {
|
||||
name: "Row 1, column 3, empty",
|
||||
});
|
||||
await empty.click();
|
||||
await page
|
||||
.getByRole("group", { name: "Digits" })
|
||||
.getByRole("button", { name: "2", exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
grid.getByRole("gridcell", { name: "Row 1, column 3, 2" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Hypothesis: Try a two" }).click();
|
||||
await page.getByRole("button", { name: "Discard and return" }).click();
|
||||
await expect(empty).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Helpers" }).click();
|
||||
await page.getByRole("tab", { name: "Sum Lab" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Generalized Sum Lab" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("tab", { name: "Candidate links" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Links and houses" }),
|
||||
).toBeVisible();
|
||||
|
||||
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,148 @@
|
||||
import { useState } from "react";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AidMemoire } from "../../src/components/AidMemoire";
|
||||
import {
|
||||
configureAidMemoire,
|
||||
createAidMemoire,
|
||||
type AidMemoireState,
|
||||
} from "../../src/state/aidMemoire";
|
||||
import type { EntryMode } from "../../src/state/session";
|
||||
|
||||
function Harness({
|
||||
initial,
|
||||
readOnly = false,
|
||||
}: {
|
||||
initial?: AidMemoireState;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
const [state, setState] = useState(
|
||||
initial ?? createAidMemoire(9, { enabled: true }),
|
||||
);
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [active, setActive] = useState(false);
|
||||
const [mode, setMode] = useState<EntryMode>("value");
|
||||
return (
|
||||
<AidMemoire
|
||||
size={9}
|
||||
state={state}
|
||||
selectedCell={selected}
|
||||
active={active}
|
||||
readOnly={readOnly}
|
||||
mode={mode}
|
||||
onStateChange={setState}
|
||||
onSelect={(cell) => {
|
||||
setSelected(cell);
|
||||
setActive(true);
|
||||
}}
|
||||
onMode={setMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("aid-mémoire component", () => {
|
||||
it("renders normal-like cells with keyboard entry and full descriptions", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
|
||||
const grid = screen.getByRole("grid", {
|
||||
name: "9 aid-mémoire scratch cells",
|
||||
});
|
||||
expect(within(grid).getAllByRole("row")).toHaveLength(1);
|
||||
const first = within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, empty",
|
||||
});
|
||||
expect(first).toHaveAttribute("aria-colindex", "1");
|
||||
await user.click(first);
|
||||
await user.keyboard("4");
|
||||
expect(
|
||||
within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, value 4",
|
||||
}),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
const second = within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 2, label 2, empty",
|
||||
});
|
||||
second.focus();
|
||||
await user.keyboard("c7");
|
||||
expect(
|
||||
within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 2, label 2, empty, centre marks 7",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Centre" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses row-aware WAI grid navigation without horizontal wrapping", () => {
|
||||
const initial = configureAidMemoire(
|
||||
createAidMemoire(9, { enabled: true }),
|
||||
9,
|
||||
5,
|
||||
2,
|
||||
);
|
||||
render(<Harness initial={initial} />);
|
||||
const cells = screen.getAllByRole("gridcell");
|
||||
const rows = screen.getAllByRole("row");
|
||||
expect(rows[0]).toHaveAttribute("aria-rowindex", "1");
|
||||
expect(rows[2]).toHaveAttribute("aria-rowindex", "3");
|
||||
expect(cells[2]).toHaveAttribute("aria-colindex", "1");
|
||||
|
||||
cells[1]!.focus();
|
||||
fireEvent.keyDown(cells[1]!, { key: "ArrowRight" });
|
||||
expect(document.activeElement).toBe(cells[1]);
|
||||
fireEvent.keyDown(cells[1]!, { key: "Home" });
|
||||
expect(document.activeElement).toBe(cells[0]);
|
||||
fireEvent.keyDown(cells[0]!, { key: "End", ctrlKey: true });
|
||||
expect(document.activeElement).toBe(cells[4]);
|
||||
fireEvent.keyDown(cells[4]!, { key: "PageUp" });
|
||||
expect(document.activeElement).toBe(cells[0]);
|
||||
fireEvent.keyDown(cells[0]!, { key: "PageDown" });
|
||||
expect(document.activeElement).toBe(cells[4]);
|
||||
});
|
||||
|
||||
it("keeps read-only replay cells navigable without allowing edits", () => {
|
||||
const initial = configureAidMemoire(
|
||||
createAidMemoire(9, { enabled: true }),
|
||||
9,
|
||||
4,
|
||||
2,
|
||||
);
|
||||
render(<Harness initial={initial} readOnly />);
|
||||
const cells = screen.getAllByRole("gridcell");
|
||||
|
||||
cells[0]!.focus();
|
||||
fireEvent.keyDown(cells[0]!, { key: "ArrowDown" });
|
||||
expect(document.activeElement).toBe(cells[2]);
|
||||
fireEvent.keyDown(cells[2]!, { key: "5" });
|
||||
expect(cells[2]).toHaveAccessibleName("Aid-mémoire cell 3, label 3, empty");
|
||||
});
|
||||
|
||||
it("edits labels, reflows, clears entries and resets with confirmation", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
render(<Harness />);
|
||||
|
||||
const label = screen.getByLabelText("Selected cell label");
|
||||
await user.clear(label);
|
||||
await user.type(label, "Pseudo A");
|
||||
expect(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label Pseudo A, empty",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText("Configure scratch layout"));
|
||||
await user.click(screen.getByRole("button", { name: "Compact grid" }));
|
||||
expect(screen.getByLabelText("Columns")).toHaveValue(3);
|
||||
await user.click(screen.getByRole("button", { name: "Clear entries" }));
|
||||
await user.click(screen.getByRole("button", { name: "Reset all" }));
|
||||
expect(window.confirm).toHaveBeenCalledTimes(2);
|
||||
expect(screen.getByLabelText("Columns")).toHaveValue(9);
|
||||
expect(screen.getByLabelText("Selected cell label")).toHaveValue("1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { Workbench } from "../../src/components/Workbench";
|
||||
|
||||
class WorkerStub {
|
||||
addEventListener() {}
|
||||
postMessage() {}
|
||||
terminate() {}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal("Worker", WorkerStub);
|
||||
});
|
||||
|
||||
function mainDigit(name: string) {
|
||||
return within(screen.getByRole("group", { name: "Digits" })).getByRole(
|
||||
"button",
|
||||
{ name },
|
||||
);
|
||||
}
|
||||
|
||||
describe("aid-mémoire Workbench integration", () => {
|
||||
it("routes the shared keypad, undo and redo to a selected scratch cell", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
const toggle = screen.getByLabelText("Show aid-mémoire scratch cells");
|
||||
await user.click(toggle);
|
||||
|
||||
const grid = screen.getByRole("grid", {
|
||||
name: "9 aid-mémoire scratch cells",
|
||||
});
|
||||
await user.click(
|
||||
within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, empty",
|
||||
}),
|
||||
);
|
||||
await user.click(mainDigit("5"));
|
||||
expect(
|
||||
within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, value 5",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Undo" }));
|
||||
expect(
|
||||
within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, empty",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Redo" }));
|
||||
expect(
|
||||
within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, value 5",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(toggle);
|
||||
expect(
|
||||
screen.queryByRole("grid", { name: "9 aid-mémoire scratch cells" }),
|
||||
).not.toBeInTheDocument();
|
||||
await user.click(toggle);
|
||||
expect(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, value 5",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restores scratch labels and entries from the local Library", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
await user.click(screen.getByLabelText("Show aid-mémoire scratch cells"));
|
||||
await user.clear(screen.getByLabelText("Selected cell label"));
|
||||
await user.type(screen.getByLabelText("Selected cell label"), "Pseudo A");
|
||||
await user.click(mainDigit("6"));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Library" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save current" }));
|
||||
expect(
|
||||
await screen.findByText("Current puzzle and progress saved locally."),
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Close" }));
|
||||
|
||||
await user.click(mainDigit("7"));
|
||||
expect(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label Pseudo A, value 7",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Library" }));
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: /A first classic/u }),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label Pseudo A, value 6",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restores scratch state with a savepoint and reverses that restore", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
await user.click(screen.getByLabelText("Show aid-mémoire scratch cells"));
|
||||
await user.click(mainDigit("5"));
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "History & branches" }),
|
||||
);
|
||||
await user.type(screen.getByLabelText("Savepoint name"), "Scratch five");
|
||||
await user.click(screen.getByRole("button", { name: "Save current grid" }));
|
||||
await user.click(screen.getByRole("button", { name: "Close" }));
|
||||
|
||||
await user.click(mainDigit("6"));
|
||||
expect(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, value 6",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "History & branches" }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Restore" }));
|
||||
expect(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, value 5",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Undo" }));
|
||||
expect(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, value 6",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
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 { container, unmount } = render(
|
||||
<BoardViewport>
|
||||
<div>Board</div>
|
||||
</BoardViewport>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("100%");
|
||||
expect(container.querySelector(".board-viewport")).not.toHaveClass(
|
||||
"is-zoomed-in",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Zoom board out" }));
|
||||
expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("75%");
|
||||
expect(container.querySelector(".board-viewport__canvas")).toHaveStyle({
|
||||
width: "75%",
|
||||
maxWidth: "36rem",
|
||||
});
|
||||
expect(container.querySelector(".board-viewport")).not.toHaveClass(
|
||||
"is-zoomed-in",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Zoom board in" }));
|
||||
await user.click(screen.getByRole("button", { name: "Zoom board in" }));
|
||||
expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("125%");
|
||||
expect(container.querySelector(".board-viewport")).toHaveClass(
|
||||
"is-zoomed-in",
|
||||
);
|
||||
expect(localStorage.getItem(BOARD_SCALE_STORAGE_KEY)).toBe("1.25");
|
||||
unmount();
|
||||
|
||||
const restored = 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%");
|
||||
expect(restored.container.querySelector(".board-viewport")).not.toHaveClass(
|
||||
"is-zoomed-in",
|
||||
);
|
||||
});
|
||||
|
||||
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,100 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { CandidateLab } from "../../src/components/CandidateLab";
|
||||
import { createEmptyPuzzle, normalizePuzzle } from "../../src/domain";
|
||||
import type { CandidateOverlay } from "../../src/helpers";
|
||||
|
||||
function mask(...values: number[]): number {
|
||||
return values.reduce((result, value) => result | (1 << (value - 1)), 0);
|
||||
}
|
||||
|
||||
const puzzle = normalizePuzzle(createEmptyPuzzle(4));
|
||||
|
||||
describe("candidate lab", () => {
|
||||
it("inspects selected cells, filters digits and focuses a link overlay", async () => {
|
||||
const user = userEvent.setup();
|
||||
const overlays: (CandidateOverlay | undefined)[] = [];
|
||||
const candidateMasks = new Array<number>(16).fill(0);
|
||||
candidateMasks[0] = mask(1, 2);
|
||||
candidateMasks[1] = mask(1, 2);
|
||||
|
||||
render(
|
||||
<CandidateLab
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
candidateMasks={candidateMasks}
|
||||
selectedCells={[0]}
|
||||
onOverlayChange={(overlay) => overlays.push(overlay)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const selected = screen.getByRole("heading", {
|
||||
name: "Selected cells",
|
||||
}).parentElement!;
|
||||
expect(within(selected).getByText("r1c1")).toBeInTheDocument();
|
||||
expect(within(selected).getByText("1 2")).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Filter candidate 1" }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(overlays.at(-1)?.activeValues).toEqual([1]);
|
||||
expect(overlays.at(-1)?.candidateCells).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
const link = screen.getByRole("button", { name: /r1c1\(1\).*r1c2\(1\)/u });
|
||||
await user.click(link);
|
||||
await waitFor(() => expect(overlays.at(-1)?.links).toHaveLength(1));
|
||||
expect(link).toHaveAttribute("aria-pressed", "true");
|
||||
await user.click(screen.getByRole("button", { name: "Show all" }));
|
||||
expect(link).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("reveals weak links on demand within a chosen house", async () => {
|
||||
const user = userEvent.setup();
|
||||
const candidateMasks = new Array<number>(16).fill(0);
|
||||
candidateMasks[0] = mask(3);
|
||||
candidateMasks[1] = mask(3);
|
||||
candidateMasks[2] = mask(3);
|
||||
|
||||
render(
|
||||
<CandidateLab
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
candidateMasks={candidateMasks}
|
||||
selectedCells={[0]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.selectOptions(
|
||||
screen.getByLabelText("Candidate house scope"),
|
||||
"unit:0",
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Filter candidate 3" }),
|
||||
);
|
||||
expect(screen.getByText(/No links match this scope/u)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("checkbox", { name: "Weak links" }));
|
||||
|
||||
expect(
|
||||
screen.getAllByRole("button", { name: /weak · Row 1/u }),
|
||||
).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("clears the board overlay when it unmounts", async () => {
|
||||
const onOverlayChange = vi.fn();
|
||||
const { unmount } = render(
|
||||
<CandidateLab
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
candidateMasks={new Array<number>(16).fill(0)}
|
||||
selectedCells={[0]}
|
||||
onOverlayChange={onOverlayChange}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(onOverlayChange).toHaveBeenCalled());
|
||||
unmount();
|
||||
expect(onOverlayChange).toHaveBeenLastCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { VariantConstraint } from "../../src/domain";
|
||||
import {
|
||||
removeKillerCagesAtCells,
|
||||
replaceOverlappingKillerCages,
|
||||
selectionTouchesKillerCage,
|
||||
} from "../../src/components/constraintEditing";
|
||||
|
||||
const constraints: VariantConstraint[] = [
|
||||
{ type: "killer-cage", cells: [0, 1], sum: 3 },
|
||||
{ type: "killer-cage", cells: [2, 3], sum: 7 },
|
||||
{ type: "thermo", cells: [0, 4] },
|
||||
];
|
||||
|
||||
describe("setter cage editing", () => {
|
||||
it("replaces every overlapping cage while preserving other rules", () => {
|
||||
expect(
|
||||
replaceOverlappingKillerCages(constraints, {
|
||||
type: "killer-cage",
|
||||
cells: [1, 2],
|
||||
sum: 5,
|
||||
}),
|
||||
).toEqual([
|
||||
{ type: "thermo", cells: [0, 4] },
|
||||
{ type: "killer-cage", cells: [1, 2], sum: 5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes cages by any selected member cell", () => {
|
||||
expect(removeKillerCagesAtCells(constraints, [1])).toEqual([
|
||||
{ type: "killer-cage", cells: [2, 3], sum: 7 },
|
||||
{ type: "thermo", cells: [0, 4] },
|
||||
]);
|
||||
expect(selectionTouchesKillerCage(constraints, [1])).toBe(true);
|
||||
expect(selectionTouchesKillerCage(constraints, [4])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DigitCompletionBar } from "../../src/components/DigitCompletionBar";
|
||||
import { digitCompletions } from "../../src/state/gameplayHelpers";
|
||||
|
||||
describe("digit completion bar", () => {
|
||||
it("renders complete digits muted and excess digits as overdone", () => {
|
||||
const values = new Array<number>(81).fill(0);
|
||||
values.fill(1, 0, 9);
|
||||
values.fill(2, 9, 19);
|
||||
|
||||
render(
|
||||
<DigitCompletionBar
|
||||
size={9}
|
||||
completions={digitCompletions(9, values)}
|
||||
highlightedDigit={null}
|
||||
highlightingEnabled
|
||||
onHighlight={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Digit 1: complete/u }),
|
||||
).toHaveClass("is-done");
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Digit 2: overdone by 1/u }),
|
||||
).toHaveClass("is-overdone");
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Digit 3: 9 remaining/u }),
|
||||
).toHaveClass("is-undone");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { Workbench } from "../../src/components/Workbench";
|
||||
|
||||
class WorkerStub {
|
||||
addEventListener() {}
|
||||
postMessage() {}
|
||||
terminate() {}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal("Worker", WorkerStub);
|
||||
});
|
||||
|
||||
function digitButton(name: string) {
|
||||
return within(screen.getByRole("group", { name: "Digits" })).getByRole(
|
||||
"button",
|
||||
{ name },
|
||||
);
|
||||
}
|
||||
|
||||
describe("gameplay history integration", () => {
|
||||
it("restores savepoints and preserves discarded hypotheses for replay", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
|
||||
fireEvent.pointerDown(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Row 1, column 3, empty",
|
||||
}),
|
||||
{ buttons: 1 },
|
||||
);
|
||||
await user.click(digitButton("2"));
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "History & branches" }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: "Branches, savepoints and replay",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await user.type(screen.getByLabelText("Savepoint name"), "After two");
|
||||
await user.click(screen.getByRole("button", { name: "Save current grid" }));
|
||||
expect(screen.getByText("After two")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Close" }));
|
||||
|
||||
await user.click(digitButton("3"));
|
||||
expect(
|
||||
screen.getByRole("gridcell", { name: "Row 1, column 3, 3" }),
|
||||
).toBeInTheDocument();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "History & branches" }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Restore" }));
|
||||
expect(
|
||||
screen.getByRole("gridcell", { name: "Row 1, column 3, 2" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "History & branches" }),
|
||||
);
|
||||
await user.type(screen.getByLabelText("Hypothesis name"), "Try three");
|
||||
await user.click(screen.getByRole("button", { name: "Start hypothesis" }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Hypothesis: Try three" }),
|
||||
).toBeInTheDocument();
|
||||
await user.click(digitButton("3"));
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Hypothesis: Try three" }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Discard and return" }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("gridcell", { name: "Row 1, column 3, 2" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "History & branches" }),
|
||||
);
|
||||
expect(screen.getByText("discarded")).toBeInTheDocument();
|
||||
await user.click(
|
||||
within(screen.getByRole("list", { name: "Solve history" })).getAllByRole(
|
||||
"button",
|
||||
{ name: /Set r1c3 to 3/u },
|
||||
)[0]!,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Set r1c3 to 3" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("gridcell", { name: "Row 1, column 3, 3" }),
|
||||
).toBeInTheDocument();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Return to live grid" }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("gridcell", { name: "Row 1, column 3, 2" }),
|
||||
).toBeInTheDocument();
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { GeneratorWorkspace } from "../../src/components/GeneratorWorkspace";
|
||||
import { classicRegions } from "../../src/domain";
|
||||
import type {
|
||||
GeneratedVariantBatch,
|
||||
GeneratedVariantPuzzle,
|
||||
} from "../../src/solver";
|
||||
|
||||
describe("Sudoku generator workspace", () => {
|
||||
it("offers only reliable sizes and submits an explicit local recipe", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onGenerate = vi.fn();
|
||||
render(
|
||||
<GeneratorWorkspace
|
||||
busy={false}
|
||||
onGenerate={onGenerate}
|
||||
onRate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Variant"), "anti-king");
|
||||
const grid = screen.getByLabelText("Grid");
|
||||
expect(
|
||||
within(grid)
|
||||
.getAllByRole("option")
|
||||
.map((option) => option.textContent),
|
||||
).toEqual(["6 × 6", "9 × 9"]);
|
||||
expect(
|
||||
screen.queryByLabelText("Requested markings"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Variant"), "thermo");
|
||||
expect(screen.getByLabelText("Requested markings")).toBeInTheDocument();
|
||||
await user.selectOptions(screen.getByLabelText("Grid"), "4");
|
||||
await user.selectOptions(
|
||||
screen.getByLabelText("Requested profile"),
|
||||
"easy",
|
||||
);
|
||||
await user.clear(screen.getByLabelText("Seed"));
|
||||
await user.type(screen.getByLabelText("Seed"), "repeatable-demo");
|
||||
await user.click(screen.getByRole("button", { name: "Generate Thermo" }));
|
||||
|
||||
expect(onGenerate).toHaveBeenCalledWith({
|
||||
variant: "thermo",
|
||||
size: 4,
|
||||
targetDifficulty: "easy",
|
||||
symmetry: "rotational",
|
||||
constraintCount: 8,
|
||||
seed: "repeatable-demo",
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes independent rating for the current puzzle", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRate = vi.fn();
|
||||
render(
|
||||
<GeneratorWorkspace busy={false} onGenerate={vi.fn()} onRate={onRate} />,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Rate current puzzle" }),
|
||||
);
|
||||
expect(onRate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("submits a bounded evidence-backed practice target", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onGenerate = vi.fn();
|
||||
render(
|
||||
<GeneratorWorkspace
|
||||
busy={false}
|
||||
onGenerate={onGenerate}
|
||||
onRate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.selectOptions(
|
||||
screen.getByLabelText("Practice technique"),
|
||||
"x-wing",
|
||||
);
|
||||
const attempts = screen.getByLabelText("Mining attempts");
|
||||
await user.clear(attempts);
|
||||
await user.type(attempts, "7");
|
||||
await user.clear(screen.getByLabelText("Seed"));
|
||||
await user.type(screen.getByLabelText("Seed"), "x-wing-training");
|
||||
await user.click(screen.getByRole("button", { name: "Generate Classic" }));
|
||||
|
||||
expect(onGenerate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requiredTechnique: "x-wing",
|
||||
maxTechniqueAttempts: 7,
|
||||
seed: "x-wing-training",
|
||||
}),
|
||||
);
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Variant"), "killer");
|
||||
expect(
|
||||
within(screen.getByLabelText("Practice technique")).getByRole("option", {
|
||||
name: "Killer Cage",
|
||||
}),
|
||||
).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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { HelpersWorkspace } from "../../src/components/HelpersWorkspace";
|
||||
import { createEmptyPuzzle, normalizePuzzle } from "../../src/domain";
|
||||
|
||||
function mask(...digits: number[]): number {
|
||||
return digits.reduce((value, digit) => value | (2 ** (digit - 1)), 0);
|
||||
}
|
||||
|
||||
describe("Sum Lab helper", () => {
|
||||
const puzzle = normalizePuzzle(createEmptyPuzzle(9));
|
||||
|
||||
it("eliminates and restores combinations without changing the inputs", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<HelpersWorkspace
|
||||
size={9}
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
selectedCells={[]}
|
||||
candidateMasks={Array<number>(81).fill(0)}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Sum Lab" }));
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Generalized Sum Lab" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("4", { selector: "strong" })).toBeInTheDocument();
|
||||
|
||||
const combination = screen.getByRole("button", {
|
||||
name: "1 + 9; eliminate",
|
||||
});
|
||||
await user.click(combination);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "1 + 9; restore" }),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
expect(
|
||||
screen.getByText("3", { selector: ".metric-row strong" }).parentElement,
|
||||
).toHaveTextContent("3 active of 4");
|
||||
expect(screen.getByRole("button", { name: "Restore all" })).toBeEnabled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Restore all" }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "1 + 9; eliminate" }),
|
||||
).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("filters through selected board-cell candidate masks", async () => {
|
||||
const user = userEvent.setup();
|
||||
const candidateMasks = Array<number>(81).fill(0);
|
||||
candidateMasks[0] = mask(1, 2);
|
||||
candidateMasks[1] = mask(8, 9);
|
||||
render(
|
||||
<HelpersWorkspace
|
||||
size={9}
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
selectedCells={[0, 1]}
|
||||
candidateMasks={candidateMasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Sum Lab" }));
|
||||
await user.click(
|
||||
screen.getByRole("checkbox", {
|
||||
name: "Use 2 board cells and candidates",
|
||||
}),
|
||||
);
|
||||
|
||||
const positions = screen.getByRole("list");
|
||||
expect(within(positions).getByText("r1c1: 1 2")).toBeInTheDocument();
|
||||
expect(within(positions).getByText("r1c2: 8 9")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "3 + 7; eliminate" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("resets digit bounds when the puzzle size changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(
|
||||
<HelpersWorkspace
|
||||
key={9}
|
||||
size={9}
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
selectedCells={[]}
|
||||
candidateMasks={Array<number>(81).fill(0)}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Sum Lab" }));
|
||||
expect(
|
||||
screen.getByRole("spinbutton", { name: "Maximum digit" }),
|
||||
).toHaveValue(9);
|
||||
|
||||
const smallerPuzzle = normalizePuzzle(createEmptyPuzzle(4));
|
||||
rerender(
|
||||
<HelpersWorkspace
|
||||
key={4}
|
||||
size={4}
|
||||
puzzle={smallerPuzzle}
|
||||
values={smallerPuzzle.givens}
|
||||
selectedCells={[]}
|
||||
candidateMasks={Array<number>(16).fill(0)}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Sum Lab" }));
|
||||
expect(
|
||||
screen.getByRole("spinbutton", { name: "Maximum digit" }),
|
||||
).toHaveValue(4);
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ImportExportDialog } from "../../src/components/ImportExportDialog";
|
||||
import { createEmptyPuzzle } from "../../src/domain";
|
||||
import type { PuzzleDefinition } from "../../src/domain/types";
|
||||
import {
|
||||
fromDomainPuzzle,
|
||||
type PreservedSudokuDocumentExtras,
|
||||
} from "../../src/formats";
|
||||
import type { PortableAidMemoire } from "../../src/state/aidMemoire";
|
||||
import { createSession } from "../../src/state/session";
|
||||
|
||||
const TEST_AID_MEMOIRE: PortableAidMemoire = {
|
||||
version: 1,
|
||||
enabled: true,
|
||||
columns: 1,
|
||||
cells: [
|
||||
{
|
||||
label: "A",
|
||||
value: 2,
|
||||
cornerMarks: [],
|
||||
centerMarks: [],
|
||||
color: 3,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function localScl(overrides: Record<string, unknown> = {}) {
|
||||
const cells = Array.from({ length: 4 }, () =>
|
||||
Array.from({ length: 4 }, () => ({})),
|
||||
);
|
||||
cells[0]![0] = { value: 1 };
|
||||
return JSON.stringify({
|
||||
id: "synthetic-local-test",
|
||||
cells,
|
||||
metadata: { title: "Synthetic local puzzle" },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function renderDialog(
|
||||
options: {
|
||||
readonly puzzle?: PuzzleDefinition;
|
||||
readonly aidMemoire?: PortableAidMemoire;
|
||||
readonly preservedExtras?: PreservedSudokuDocumentExtras;
|
||||
} = {},
|
||||
) {
|
||||
const puzzle = options.puzzle ?? createEmptyPuzzle(4);
|
||||
const onClose = vi.fn();
|
||||
const onImport = vi.fn();
|
||||
render(
|
||||
<ImportExportDialog
|
||||
open
|
||||
puzzle={puzzle}
|
||||
session={createSession(puzzle.givens)}
|
||||
aidMemoire={options.aidMemoire}
|
||||
preservedExtras={options.preservedExtras}
|
||||
onClose={onClose}
|
||||
onImport={onImport}
|
||||
/>,
|
||||
);
|
||||
return { onClose, onImport };
|
||||
}
|
||||
|
||||
describe("ImportExportDialog interoperability", () => {
|
||||
it("reports a detected compatible format before importing it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onClose, onImport } = renderDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText(/Paste 81 characters/u), {
|
||||
target: { value: localScl() },
|
||||
});
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Check compatibility" }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/SudokuPad\/CTC JSON: 4×4, 1 givens and 0 constraints/u,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Import locally" }));
|
||||
expect(onImport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ size: 4, givens: expect.any(Array) }),
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
source: { format: "sudokupad", id: "synthetic-local-test" },
|
||||
}),
|
||||
);
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("previews safe visuals and never fetches short IDs", async () => {
|
||||
const user = userEvent.setup();
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
renderDialog();
|
||||
const input = screen.getByPlaceholderText(/Paste 81 characters/u);
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: {
|
||||
value: localScl({
|
||||
overlays: [
|
||||
{
|
||||
center: [0.5, 0.5],
|
||||
width: 1,
|
||||
height: 1,
|
||||
text: "visual only",
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Check compatibility" }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("region", { name: "Import mapping preview" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Preserved visuals")).toBeInTheDocument();
|
||||
expect(screen.getByText("Text overlay: 1")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: { value: localScl({ overlays: [{ text: "missing center" }] }) },
|
||||
});
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Check compatibility" }),
|
||||
);
|
||||
expect(await screen.findByText(/center.*point/u)).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: { value: "https://sudokupad.app/serverOnly42" },
|
||||
});
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Check compatibility" }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(/server-hosted SudokuPad puzzle ID/u),
|
||||
).toBeInTheDocument();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("downloads the current puzzle as a self-contained SVG", async () => {
|
||||
const user = userEvent.setup();
|
||||
let downloaded: Blob | undefined;
|
||||
vi.mocked(URL.createObjectURL).mockImplementation((value) => {
|
||||
downloaded = value as Blob;
|
||||
return "blob:visual-export-test";
|
||||
});
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
renderDialog();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Download SVG" }));
|
||||
expect(
|
||||
await screen.findByText("SVG rendered and downloaded locally."),
|
||||
).toBeInTheDocument();
|
||||
expect(downloaded?.type).toContain("image/svg+xml");
|
||||
expect(await downloaded?.text()).toContain("<svg");
|
||||
});
|
||||
|
||||
it("round-trips aid-mémoire progress through project import and export", async () => {
|
||||
const user = userEvent.setup();
|
||||
const puzzle = createEmptyPuzzle(4);
|
||||
const { onImport } = renderDialog({
|
||||
puzzle,
|
||||
aidMemoire: TEST_AID_MEMOIRE,
|
||||
});
|
||||
let downloaded: Blob | undefined;
|
||||
vi.mocked(URL.createObjectURL).mockImplementation((value) => {
|
||||
downloaded = value as Blob;
|
||||
return "blob:project-export-test";
|
||||
});
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Download project JSON" }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText("Project JSON downloaded locally."),
|
||||
).toBeInTheDocument();
|
||||
expect(JSON.parse((await downloaded?.text()) ?? "{}").aidMemoire).toEqual(
|
||||
TEST_AID_MEMOIRE,
|
||||
);
|
||||
|
||||
const importedDocument = {
|
||||
...fromDomainPuzzle(puzzle),
|
||||
aidMemoire: TEST_AID_MEMOIRE,
|
||||
};
|
||||
fireEvent.change(screen.getByPlaceholderText(/Paste 81 characters/u), {
|
||||
target: { value: JSON.stringify(importedDocument) },
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "Import locally" }));
|
||||
expect(onImport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ size: 4 }),
|
||||
expect.objectContaining({ aidMemoire: TEST_AID_MEMOIRE }),
|
||||
{ source: { format: "sudoku-tools" } },
|
||||
);
|
||||
});
|
||||
|
||||
it("includes preserved source extras in SudokuPad exports", async () => {
|
||||
const user = userEvent.setup();
|
||||
let downloaded: Blob | undefined;
|
||||
vi.mocked(URL.createObjectURL).mockImplementation((value) => {
|
||||
downloaded = value as Blob;
|
||||
return "blob:scl-export-test";
|
||||
});
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
renderDialog({
|
||||
preservedExtras: {
|
||||
source: { format: "sudokupad", id: "kept-source" },
|
||||
metadata: { edition: "nightly" },
|
||||
visuals: [
|
||||
{
|
||||
type: "text",
|
||||
layer: "overlay",
|
||||
position: { kind: "cell", cell: 0 },
|
||||
text: "kept visual",
|
||||
style: { fill: "#123456" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Download SudokuPad JSON" }),
|
||||
);
|
||||
|
||||
const exported = JSON.parse((await downloaded?.text()) ?? "{}") as {
|
||||
metadata?: Record<string, unknown>;
|
||||
overlays?: unknown[];
|
||||
};
|
||||
expect(exported.metadata?.edition).toBe("nightly");
|
||||
expect(exported.overlays).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ text: "kept visual" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports an f-puzzles export incompatibility without throwing", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog({
|
||||
puzzle: {
|
||||
...createEmptyPuzzle(4),
|
||||
constraints: [{ type: "xv", a: 0, b: 1, total: 5, negated: true }],
|
||||
},
|
||||
});
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Download f-puzzles JSON" }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(/cannot preserve an individually false clue/u),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user