4 Commits
135 changed files with 35700 additions and 781 deletions
+111
View File
@@ -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 75200% board viewport, Fit and board-scoped zoom shortcuts,
plus an announced Pan mode which pauses cell pointer targeting.
- Added tap-toggle multi-selection and a single safe-area-aware sticky entry pad
at narrow widths, with touch-sized zoom controls.
- Added eight hue-and-pattern colour marks, configurable off/concise/detailed
candidate narration, and fog-safe board, toolbar, helper and guided-hint
boundaries.
- Added real ARIA row/gridcell semantics, detailed cell descriptions and
non-wrapping Arrow, Home/End, Control/Command + Home/End and Page Up/Down
keyboard navigation for the puzzle and scratch grids.
- 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
View File
@@ -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 puzzles appearance, metadata or a truncated search.
## Setter-quality analysis
The quick setter check performs a bounded zero/one/two-solution audit. A pair of
solutions produces a concrete ambiguity witness with every differing cell and
value. For a uniquely proved baseline, full analysis uses leave-one-out exact
searches to classify each given and constraint as critical, redundant or
unknown and creates a per-cell criticality heatmap. For an unsatisfiable
baseline it instead uses bounded deletion tests to localise a contradictory
core. Optional minimality is proved only when the unique baseline and every
required removal check complete.
Every quality search has configurable per-check node/time limits and shared
aggregate check/node/time limits. Results retain the checks performed, elapsed
time and reasons for unknown conclusions. Reaching a safety limit is never
silently converted into uniqueness, redundancy, contradiction localisation or
minimality. The worker analysis can be cancelled without discarding the last
completed result.
## Generator 2.0
Generation supports the existing classic, diagonal, anti-knight, anti-king,
non-consecutive, Killer, thermo, arrow, Kropki, XV, inequality, renban and
palindrome families. Compatible families can be combined when they share a
supported grid size. Local marking density can be sparse, balanced or dense;
for Killer this changes cage granularity, while inherently global rules retain
their fixed meaning.
Clue removal supports no symmetry, half-turn rotation, horizontal or vertical
reflection, either diagonal reflection, and four-way quarter-turn symmetry.
Minimal-givens mode checks every retained given individually and reports either
a completed proof or the exact unknown reasons. Individual minimisation may
break the requested visual symmetry, which is reported rather than hidden.
Technique profiles can require or forbid supported logical techniques, set
minimum/maximum occurrence counts and require an exact hardest technique. A
puzzle is accepted only when an independent, complete logical path matches the
whole profile and an independent exact check proves uniqueness. Deterministic
batches contain at most 12 candidates and can be ranked by difficulty, fewest
givens or most givens; failed bounded candidates remain visible as failures and
are never included in the verified ranking. Generation is cancellable by
terminating and restarting its worker.
Requested difficulty still controls the clue-removal target rather than
promising a rating. The displayed 0100 result is calculated afterwards from
logical techniques, clue load and reproducible exact-search evidence. Supported
size/family combinations are deliberately restricted, and a bounded mixed
construction may fail cleanly for an incompatible or unusually difficult
recipe.
## Mobile and accessibility
The board viewport offers a locally persisted 75200% scale in 25% steps,
increment/decrement controls and a Fit action that returns to 100% and the
origin. Control/Command + plus, minus or zero are board-scoped equivalents.
Explicit Pan mode announces its state, cancels any in-progress drag selection
and disables cell pointer targeting until it is stopped. Tap multi-select, also
available with <kbd>M</kbd>, toggles cells individually without drag selection
and never leaves the active selection empty.
At widths up to 48rem, Play and Set show exactly one sticky entry pad (outside
read-only replay) with safe-area padding and touch-sized zoom controls. Eight
colour marks use distinct patterns as well as hues and expose the hue/pattern
name on both the Sudoku board and aid-mémoire. Screen-reader candidate detail is
independently selectable as Off, concise counts or detailed digits without
changing the visible board.
The board exposes real row/gridcell semantics and concise per-cell state to
assistive technology, including candidates, notes, colours, conflicts,
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
View File
@@ -1,8 +1,8 @@
# Corresponding source
The corresponding source for Sudoku Tools 0.1.0 is available at:
The corresponding source for Sudoku Tools 0.2.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,
+9 -1
View File
@@ -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.
+2
View File
@@ -8,6 +8,8 @@
content="Set, play, solve and analyse Sudoku puzzles locally in your browser."
/>
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<link rel="manifest" href="./manifest.webmanifest" />
<meta name="theme-color" content="#272421" />
<title>Sudoku Tools</title>
</head>
<body>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sudoku-tools",
"version": "0.1.0",
"version": "0.2.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
View File
@@ -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",
+4 -1
View File
@@ -3,7 +3,10 @@ import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/browser",
fullyParallel: false,
workers: 2,
// Run the two browser projects serially. Concurrent service-worker installs
// against the shared nested-path test server can make Firefox wait forever
// for the load event even though the application itself is healthy.
workers: 1,
timeout: 120_000,
expect: { timeout: 10_000 },
reporter: [["list"]],
+111
View File
@@ -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 75200% board viewport, Fit and board-scoped zoom shortcuts,
plus an announced Pan mode which pauses cell pointer targeting.
- Added tap-toggle multi-selection and a single safe-area-aware sticky entry pad
at narrow widths, with touch-sized zoom controls.
- Added eight hue-and-pattern colour marks, configurable off/concise/detailed
candidate narration, and fog-safe board, toolbar, helper and guided-hint
boundaries.
- Added real ARIA row/gridcell semantics, detailed cell descriptions and
non-wrapping Arrow, Home/End, Control/Command + Home/End and Page Up/Down
keyboard navigation for the puzzle and scratch grids.
- 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
View File
@@ -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 puzzles appearance, metadata or a truncated search.
## Setter-quality analysis
The quick setter check performs a bounded zero/one/two-solution audit. A pair of
solutions produces a concrete ambiguity witness with every differing cell and
value. For a uniquely proved baseline, full analysis uses leave-one-out exact
searches to classify each given and constraint as critical, redundant or
unknown and creates a per-cell criticality heatmap. For an unsatisfiable
baseline it instead uses bounded deletion tests to localise a contradictory
core. Optional minimality is proved only when the unique baseline and every
required removal check complete.
Every quality search has configurable per-check node/time limits and shared
aggregate check/node/time limits. Results retain the checks performed, elapsed
time and reasons for unknown conclusions. Reaching a safety limit is never
silently converted into uniqueness, redundancy, contradiction localisation or
minimality. The worker analysis can be cancelled without discarding the last
completed result.
## Generator 2.0
Generation supports the existing classic, diagonal, anti-knight, anti-king,
non-consecutive, Killer, thermo, arrow, Kropki, XV, inequality, renban and
palindrome families. Compatible families can be combined when they share a
supported grid size. Local marking density can be sparse, balanced or dense;
for Killer this changes cage granularity, while inherently global rules retain
their fixed meaning.
Clue removal supports no symmetry, half-turn rotation, horizontal or vertical
reflection, either diagonal reflection, and four-way quarter-turn symmetry.
Minimal-givens mode checks every retained given individually and reports either
a completed proof or the exact unknown reasons. Individual minimisation may
break the requested visual symmetry, which is reported rather than hidden.
Technique profiles can require or forbid supported logical techniques, set
minimum/maximum occurrence counts and require an exact hardest technique. A
puzzle is accepted only when an independent, complete logical path matches the
whole profile and an independent exact check proves uniqueness. Deterministic
batches contain at most 12 candidates and can be ranked by difficulty, fewest
givens or most givens; failed bounded candidates remain visible as failures and
are never included in the verified ranking. Generation is cancellable by
terminating and restarting its worker.
Requested difficulty still controls the clue-removal target rather than
promising a rating. The displayed 0100 result is calculated afterwards from
logical techniques, clue load and reproducible exact-search evidence. Supported
size/family combinations are deliberately restricted, and a bounded mixed
construction may fail cleanly for an incompatible or unusually difficult
recipe.
## Mobile and accessibility
The board viewport offers a locally persisted 75200% scale in 25% steps,
increment/decrement controls and a Fit action that returns to 100% and the
origin. Control/Command + plus, minus or zero are board-scoped equivalents.
Explicit Pan mode announces its state, cancels any in-progress drag selection
and disables cell pointer targeting until it is stopped. Tap multi-select, also
available with <kbd>M</kbd>, toggles cells individually without drag selection
and never leaves the active selection empty.
At widths up to 48rem, Play and Set show exactly one sticky entry pad (outside
read-only replay) with safe-area padding and touch-sized zoom controls. Eight
colour marks use distinct patterns as well as hues and expose the hue/pattern
name on both the Sudoku board and aid-mémoire. Screen-reader candidate detail is
independently selectable as Off, concise counts or detailed digits without
changing the visible board.
The board exposes real row/gridcell semantics and concise per-cell state to
assistive technology, including candidates, notes, colours, conflicts,
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
View File
@@ -1,8 +1,8 @@
# Corresponding source
The corresponding source for Sudoku Tools 0.1.0 is available at:
The corresponding source for Sudoku Tools 0.2.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,
+9 -1
View File
@@ -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.
+19
View File
@@ -0,0 +1,19 @@
{
"name": "Sudoku Tools",
"short_name": "Sudoku",
"description": "Set, play, solve and analyse Sudoku puzzles locally.",
"id": "./",
"start_url": "./",
"scope": "./",
"display": "standalone",
"background_color": "#f4f0e8",
"theme_color": "#272421",
"icons": [
{
"src": "./favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
]
}
+119
View File
@@ -0,0 +1,119 @@
/* Sudoku Tools offline worker. This file intentionally has no dependencies. */
const CACHE_PREFIX = "sudoku-tools-shell-";
const CACHE_VERSION = "v1";
const CACHE_NAME = `${CACHE_PREFIX}${CACHE_VERSION}`;
const CORE_ASSETS = ["./", "./manifest.webmanifest", "./favicon.svg"];
const BUILD_ASSET_PATTERN =
/(?:(?:\.\/)?assets\/)?[A-Za-z0-9][A-Za-z0-9._-]*-[A-Za-z0-9_-]{6,}\.(?:css|js|json|png|svg|wasm)/gu;
async function cacheAssetTree(cache, assetUrl, discovered, depth = 0) {
const url = new URL(assetUrl, self.registration.scope).href;
if (discovered.has(url)) return;
discovered.add(url);
const response = await fetch(url, { cache: "no-cache" });
if (!response.ok) return;
await cache.put(url, response.clone());
const contentType = response.headers.get("content-type") ?? "";
if (depth >= 3 || !/(?:javascript|text\/css)/iu.test(contentType)) return;
const source = await response.text();
await Promise.all(
[...source.matchAll(BUILD_ASSET_PATTERN)].map((match) => {
const reference = match[0];
const base = reference.includes("assets/")
? self.registration.scope
: url;
return cacheAssetTree(
cache,
new URL(reference, base).href,
discovered,
depth + 1,
);
}),
);
}
async function cacheDocumentAssets(cache) {
const response = await fetch("./", { cache: "no-cache" });
if (!response.ok) throw new Error("Could not cache the application shell.");
await cache.put("./", response.clone());
const html = await response.text();
const urls = new Set(CORE_ASSETS.slice(1));
for (const match of html.matchAll(/(?:src|href)=["']([^"'#]+)["']/giu)) {
const raw = match[1];
if (raw === undefined) continue;
const resolved = new URL(raw, self.registration.scope);
if (resolved.origin === self.location.origin) urls.add(resolved.href);
}
const canonicalUrls = new Set([new URL("./", self.registration.scope).href]);
await Promise.all(
[...urls].map((url) => cacheAssetTree(cache, url, canonicalUrls)),
);
await Promise.all(
(await cache.keys())
.filter((request) => !canonicalUrls.has(request.url))
.map((request) => cache.delete(request)),
);
}
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then(cacheDocumentAssets)
.then(() => self.skipWaiting()),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((key) => key.startsWith(CACHE_PREFIX) && key !== CACHE_NAME)
.map((key) => caches.delete(key)),
),
)
.then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (event) => {
const request = event.request;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
if (request.mode === "navigate") {
event.respondWith(
fetch(request)
.then(async (response) => {
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
await cache.put("./", response.clone());
}
return response;
})
.catch(async () => {
const cached = await caches.match("./");
return cached ?? Response.error();
}),
);
return;
}
event.respondWith(
caches.match(request).then(
(cached) =>
cached ??
fetch(request).then(async (response) => {
if (response.ok && response.type === "basic") {
const cache = await caches.open(CACHE_NAME);
await cache.put(request, response.clone());
}
return response;
}),
),
);
});
+1 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1,
"id": "de.add-ideas.sudoku-tools",
"name": "Sudoku Tools",
"version": "0.1.0",
"version": "0.2.1",
"description": "Set, play, solve and analyse Sudoku puzzles locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
+2
View File
@@ -50,6 +50,8 @@ if (!force && ((await exists(output)) || (await exists(checksumOutput))))
const input = path.join(root, "dist");
for (const name of [
"index.html",
"manifest.webmanifest",
"sw.js",
"toolbox-app.json",
"favicon.svg",
"README.md",
+1
View File
@@ -14,6 +14,7 @@ const mediaTypes = new Map([
[".html", "text/html; charset=utf-8"],
[".js", "text/javascript; charset=utf-8"],
[".json", "application/json; charset=utf-8"],
[".webmanifest", "application/manifest+json; charset=utf-8"],
[".svg", "image/svg+xml"],
[".md", "text/markdown; charset=utf-8"],
[".txt", "text/plain; charset=utf-8"],
+378
View File
@@ -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>
);
}
+183
View File
@@ -0,0 +1,183 @@
import {
useEffect,
useRef,
useState,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
} from "react";
import {
BOARD_SCALE_MAX,
BOARD_SCALE_MIN,
BOARD_SCALE_STEP,
normalizeBoardScale,
readStoredBoardScale,
writeStoredBoardScale,
} from "../state/uiPreferences";
interface PanOrigin {
readonly pointerId: number;
readonly clientX: number;
readonly clientY: number;
readonly scrollLeft: number;
readonly scrollTop: number;
}
export function BoardViewport({
children,
onPanModeChange,
}: {
readonly children: ReactNode;
readonly onPanModeChange?: (enabled: boolean) => void;
}) {
const [scale, setScale] = useState(readStoredBoardScale);
const [panMode, setPanMode] = useState(false);
const scrollerRef = useRef<HTMLDivElement>(null);
const panOriginRef = useRef<PanOrigin | undefined>(undefined);
useEffect(() => writeStoredBoardScale(scale), [scale]);
const updateScale = (value: number) => setScale(normalizeBoardScale(value));
const fitBoard = () => {
updateScale(1);
if (scrollerRef.current !== null) {
scrollerRef.current.scrollLeft = 0;
scrollerRef.current.scrollTop = 0;
}
};
const onKeyDownCapture = (event: KeyboardEvent<HTMLElement>) => {
if (!(event.ctrlKey || event.metaKey)) return;
if (["+", "="].includes(event.key)) {
event.preventDefault();
event.stopPropagation();
updateScale(scale + BOARD_SCALE_STEP);
} else if (event.key === "-") {
event.preventDefault();
event.stopPropagation();
updateScale(scale - BOARD_SCALE_STEP);
} else if (event.key === "0") {
event.preventDefault();
event.stopPropagation();
fitBoard();
}
};
const beginPan = (event: PointerEvent<HTMLDivElement>) => {
if (!panMode || (event.pointerType === "mouse" && event.button !== 0))
return;
const scroller = scrollerRef.current;
if (scroller === null) return;
event.preventDefault();
scroller.setPointerCapture?.(event.pointerId);
panOriginRef.current = {
pointerId: event.pointerId,
clientX: event.clientX,
clientY: event.clientY,
scrollLeft: scroller.scrollLeft,
scrollTop: scroller.scrollTop,
};
};
const continuePan = (event: PointerEvent<HTMLDivElement>) => {
const origin = panOriginRef.current;
const scroller = scrollerRef.current;
if (
!panMode ||
origin === undefined ||
origin.pointerId !== event.pointerId ||
scroller === null
) {
return;
}
event.preventDefault();
scroller.scrollLeft = origin.scrollLeft - (event.clientX - origin.clientX);
scroller.scrollTop = origin.scrollTop - (event.clientY - origin.clientY);
};
const endPan = (event: PointerEvent<HTMLDivElement>) => {
if (panOriginRef.current?.pointerId !== event.pointerId) return;
scrollerRef.current?.releasePointerCapture?.(event.pointerId);
panOriginRef.current = undefined;
};
const percentage = Math.round(scale * 100);
return (
<section
className={`board-viewport${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>
);
}
+408
View File
@@ -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>
);
}
File diff suppressed because it is too large Load Diff
+57
View File
@@ -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>
);
}
+295
View File
@@ -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>
);
}
+715
View File
@@ -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>
);
}
+273
View File
@@ -0,0 +1,273 @@
import type { LogicalStep } from "../solver";
import {
GUIDED_HINT_STAGES,
guidedHintEffectItems,
guidedHintFocusSummary,
isGuidedHintStageRevealed,
logicalTechniqueDescription,
logicalTechniqueName,
nextGuidedHintStage,
type GuidedHintStage,
} from "./guidedHint";
const STAGE_LABELS: Record<GuidedHintStage, string> = {
focus: "Where to look",
technique: "Technique",
reasoning: "Reasoning",
preview: "Effects preview",
};
function revealButtonLabel(stage: GuidedHintStage): string | undefined {
const next = nextGuidedHintStage(stage);
if (next === "technique") return "Reveal technique";
if (next === "reasoning") return "Reveal reasoning";
if (next === "preview") return "Preview effects";
return undefined;
}
function stageStatus(stage: GuidedHintStage): string {
if (stage === "focus") return "Hint ready: where to look.";
if (stage === "technique") return "Technique revealed.";
if (stage === "reasoning") return "Reasoning revealed.";
return "Effects ready to preview and apply.";
}
export interface GuidedHintProps {
readonly size: number;
readonly step?: LogicalStep;
readonly stage: GuidedHintStage;
readonly busy?: boolean;
readonly error?: string;
readonly candidateTrackingActive?: boolean;
readonly autoMaintainPeerNotes: boolean;
readonly onRequestHint: () => void;
readonly onRevealNext: () => void;
readonly onApply: () => void;
readonly onDismiss: () => void;
readonly onFillLegalCandidates: () => void;
readonly onRemoveInvalidNotes: () => void;
readonly onAutoMaintainPeerNotesChange: (enabled: boolean) => void;
}
/**
* A controlled, progressively disclosed hint. The caller owns the hint step
* and stage so it can keep the board overlay and undo history in sync.
*/
export function GuidedHint({
size,
step,
stage,
busy = false,
error,
candidateTrackingActive = false,
autoMaintainPeerNotes,
onRequestHint,
onRevealNext,
onApply,
onDismiss,
onFillLegalCandidates,
onRemoveInvalidNotes,
onAutoMaintainPeerNotesChange,
}: GuidedHintProps) {
const nextLabel = step === undefined ? undefined : revealButtonLabel(stage);
const effectItems =
step !== undefined && stage === "preview"
? guidedHintEffectItems(step, size)
: [];
return (
<section className="guided-hint stack" aria-labelledby="guided-hint-title">
<div className="guided-hint__heading">
<div>
<p className="eyebrow">Guided solving</p>
<h3 id="guided-hint-title">One clue at a time</h3>
</div>
{step !== undefined && (
<button
type="button"
className="guided-hint__dismiss"
onClick={onDismiss}
disabled={busy}
>
Dismiss
</button>
)}
</div>
<p className="guided-hint__intro muted">
Reveal only as much help as you want. Nothing changes until you apply
the fully previewed step.
</p>
<p
className="guided-hint__status status-line"
role="status"
aria-live="polite"
aria-atomic="true"
>
{busy
? "Finding a logical next step locally…"
: error !== undefined
? "The hint could not be prepared."
: step === undefined
? "No guided hint is open."
: stageStatus(stage)}
</p>
{error !== undefined && (
<p className="guided-hint__error error-callout" role="alert">
{error}
</p>
)}
{step !== undefined && (
<>
<ol
className="guided-hint__progress"
aria-label="Hint reveal progress"
>
{GUIDED_HINT_STAGES.map((item) => {
const revealed = isGuidedHintStageRevealed(stage, item);
return (
<li
key={item}
className={revealed ? "is-revealed" : "is-concealed"}
aria-current={item === stage ? "step" : undefined}
>
{STAGE_LABELS[item]}
</li>
);
})}
</ol>
<div className="guided-hint__stage">
<section
className="guided-hint__focus"
aria-labelledby="hint-focus-title"
>
<p className="eyebrow" id="hint-focus-title">
Where to look
</p>
<p>{guidedHintFocusSummary(step, size)}</p>
</section>
{isGuidedHintStageRevealed(stage, "technique") && (
<section
className="guided-hint__technique"
aria-labelledby="hint-technique-title"
>
<p className="eyebrow" id="hint-technique-title">
Technique
</p>
<p>
<strong>{logicalTechniqueName(step.technique)}</strong>
</p>
<p className="muted">
{logicalTechniqueDescription(step.technique)}
</p>
</section>
)}
{isGuidedHintStageRevealed(stage, "reasoning") && (
<section
className="guided-hint__reasoning"
aria-labelledby="hint-reasoning-title"
>
<p className="eyebrow" id="hint-reasoning-title">
Why it works
</p>
<p>{step.explanation}</p>
</section>
)}
{stage === "preview" && (
<section
className="guided-hint__preview"
aria-labelledby="hint-preview-title"
>
<p className="eyebrow" id="hint-preview-title">
Effects preview
</p>
{effectItems.length > 0 ? (
<ul className="guided-hint__effect-list">
{effectItems.map((effect, index) => (
<li key={`${String(index)}-${effect}`}>{effect}</li>
))}
</ul>
) : (
<p>This deduction does not change the board.</p>
)}
{step.eliminations.length > 0 && !candidateTrackingActive && (
<p className="guided-hint__tracking-note muted">
Applying this elimination will start a complete legal
centre-candidate grid, then remove the previewed candidates.
</p>
)}
</section>
)}
</div>
<div className="guided-hint__actions action-row">
{nextLabel !== undefined && (
<button type="button" onClick={onRevealNext} disabled={busy}>
{nextLabel}
</button>
)}
{stage === "preview" && (
<button
type="button"
className="guided-hint__apply"
onClick={onApply}
disabled={busy || effectItems.length === 0}
>
Apply this step
</button>
)}
<button type="button" onClick={onRequestHint} disabled={busy}>
New hint
</button>
</div>
</>
)}
{step === undefined && (
<div className="guided-hint__actions action-row">
<button type="button" onClick={onRequestHint} disabled={busy}>
Get a guided hint
</button>
</div>
)}
<fieldset className="guided-hint__maintenance">
<legend>Candidate maintenance</legend>
<p className="muted" id="guided-candidate-status">
{candidateTrackingActive
? "The guided candidate grid is active."
: "Candidate tracking is currently inactive."}
</p>
<div
className="guided-hint__maintenance-actions action-row"
aria-describedby="guided-candidate-status"
>
<button type="button" onClick={onFillLegalCandidates} disabled={busy}>
Fill legal candidates
</button>
<button type="button" onClick={onRemoveInvalidNotes} disabled={busy}>
Remove invalid notes
</button>
</div>
<label className="guided-hint__auto-maintain check-row">
<input
type="checkbox"
checked={autoMaintainPeerNotes}
disabled={busy}
onChange={(event) =>
onAutoMaintainPeerNotesChange(event.currentTarget.checked)
}
/>
Automatically remove peer notes after placing a digit
</label>
</fieldset>
</section>
);
}
+200 -14
View File
@@ -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>19 / AG</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 &amp; 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 0100 rating is then calculated
from logical techniques, clue load and exact-search evidence. A
cancelled or bounded-out candidate remains a failure or unknown,
never a uniqueness/minimality claim.
</p>
</section>
<section>
<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>
+349 -10
View File
@@ -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 &lt; second</option>
<option value="greater">First &gt; 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 &lt; second cell · inequality
</option>
<option value="greater">
First cell &gt; second cell · inequality
</option>
</select>
</label>
<label>
+321 -71
View File
@@ -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,
+263 -14
View File
@@ -1,4 +1,4 @@
import { useRef } from "react";
import { useMemo, useRef, useState } from "react";
import type { SudokuProjectSummary } from "../storage";
import { Modal } from "./Modal";
@@ -9,6 +9,26 @@ function date(value: number) {
}).format(new Date(value));
}
function LibraryThumbnail({ item }: { item: SudokuProjectSummary }) {
return (
<span
className="library-thumbnail"
style={{
gridTemplateColumns: `repeat(${String(item.size)}, 1fr)`,
fontSize: `${String(Math.max(0.18, Math.min(0.62, 2.7 / item.size)))}rem`,
}}
role="img"
aria-label={`${String(item.size)} by ${String(item.size)} puzzle preview`}
>
{[...item.thumbnail].map((symbol, index) => (
<span key={index} aria-hidden="true">
{symbol === "." ? "" : symbol}
</span>
))}
</span>
);
}
export function LibraryDialog({
open,
summaries,
@@ -21,6 +41,10 @@ export function LibraryDialog({
onDelete,
onClear,
onExport,
onExportSelected,
onDuplicateSelected,
onDeleteSelected,
onUpdateTags,
onImport,
}: {
open: boolean;
@@ -34,16 +58,73 @@ export function LibraryDialog({
onDelete: (id: string) => void;
onClear: () => void;
onExport: () => void;
onExportSelected: (ids: readonly string[]) => void;
onDuplicateSelected: (ids: readonly string[]) => void;
onDeleteSelected: (ids: readonly string[]) => void;
onUpdateTags: (id: string, tags: readonly string[]) => void;
onImport: (file: File) => void;
}) {
const fileRef = useRef<HTMLInputElement>(null);
const [search, setSearch] = useState("");
const [completion, setCompletion] = useState<"all" | "open" | "complete">(
"all",
);
const [tag, setTag] = useState("");
const [selected, setSelected] = useState<ReadonlySet<string>>(
() => new Set(),
);
const [editingTagsFor, setEditingTagsFor] = useState<string>();
const [tagDraft, setTagDraft] = useState("");
const allTags = useMemo(
() =>
[...new Set(summaries.flatMap((item) => item.tags))].sort((a, b) =>
a.localeCompare(b),
),
[summaries],
);
const filtered = useMemo(() => {
const query = search.trim().toLocaleLowerCase();
return summaries.filter((item) => {
if (
query &&
!item.title.toLocaleLowerCase().includes(query) &&
!item.tags.some((value) => value.toLocaleLowerCase().includes(query))
) {
return false;
}
if (tag && !item.tags.includes(tag)) return false;
return (
completion === "all" ||
(completion === "complete" ? item.completed : !item.completed)
);
});
}, [completion, search, summaries, tag]);
const knownIds = useMemo(
() => new Set(summaries.map((item) => item.id)),
[summaries],
);
const selectedIds = useMemo(
() => [...selected].filter((id) => knownIds.has(id)),
[knownIds, selected],
);
const toggleSelected = (id: string) => {
setSelected((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<Modal open={open} title="Local puzzle library" onClose={onClose} wide>
<div className="library-toolbar">
<div>
<p>
{mode === "indexeddb"
? "Saved in this browser profile."
? "Saved in this browser profile. Working changes are autosaved separately."
: "IndexedDB is unavailable; saves last only for this open tab."}
</p>
<p className="muted">
@@ -81,38 +162,206 @@ export function LibraryDialog({
/>
</div>
</div>
<div className="library-filters" aria-label="Filter saved puzzles">
<label>
<span>Search</span>
<input
type="search"
value={search}
placeholder="Title or tag"
onChange={(event) => setSearch(event.target.value)}
/>
</label>
<label>
<span>Status</span>
<select
aria-label="Completion filter"
value={completion}
onChange={(event) =>
setCompletion(event.target.value as typeof completion)
}
>
<option value="all">All progress</option>
<option value="open">In progress</option>
<option value="complete">Complete</option>
</select>
</label>
<label>
<span>Tag</span>
<select value={tag} onChange={(event) => setTag(event.target.value)}>
<option value="">All tags</option>
{allTags.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</label>
</div>
{feedback && (
<p className="status-line" role="status">
{feedback}
</p>
)}
{summaries.length > 0 && (
<div className="library-selection-toolbar">
<span>
{String(selectedIds.length)} selected · {String(filtered.length)}{" "}
shown
</span>
<div className="action-row">
<button
type="button"
className="text-button"
disabled={busy || filtered.length === 0}
onClick={() =>
setSelected(new Set(filtered.map((item) => item.id)))
}
>
Select shown
</button>
<button
type="button"
className="text-button"
disabled={busy || selectedIds.length === 0}
onClick={() => setSelected(new Set())}
>
Clear selection
</button>
<button
type="button"
disabled={busy || selectedIds.length === 0}
onClick={() => onExportSelected(selectedIds)}
>
Export selected
</button>
<button
type="button"
disabled={busy || selectedIds.length === 0}
onClick={() => onDuplicateSelected(selectedIds)}
>
Duplicate selected
</button>
<button
type="button"
className="danger"
disabled={busy || selectedIds.length === 0}
onClick={() => onDeleteSelected(selectedIds)}
>
Delete selected
</button>
</div>
</div>
)}
{summaries.length === 0 ? (
<div className="empty-state">
<h3>No saved puzzles</h3>
<p>Save the current puzzle to build a local library.</p>
</div>
) : filtered.length === 0 ? (
<div className="empty-state">
<h3>No matching puzzles</h3>
<p>Adjust the title, tag or progress filters.</p>
</div>
) : (
<ul className="library-list">
{summaries.map((item) => (
{filtered.map((item) => (
<li key={item.id}>
<label className="library-selector">
<input
type="checkbox"
checked={selected.has(item.id)}
aria-label={`Select ${item.title || "Untitled puzzle"}`}
onChange={() => toggleSelected(item.id)}
/>
</label>
<button
className="library-open"
type="button"
onClick={() => onOpen(item.id)}
>
<strong>{item.title || "Untitled puzzle"}</strong>
<span>
{item.size}×{item.size} · {date(item.updatedAt)}
{item.completed ? " · complete" : ""}
<LibraryThumbnail item={item} />
<span className="library-copy">
<strong>{item.title || "Untitled puzzle"}</strong>
<span>
{item.size}×{item.size} · {date(item.updatedAt)}
{item.completed ? " · complete" : " · in progress"}
</span>
</span>
</button>
<button
className="text-button danger"
type="button"
onClick={() => onDelete(item.id)}
>
Delete
</button>
<div className="library-item-actions">
<div className="library-tags" aria-label="Project tags">
{item.tags.map((value) => (
<button
key={value}
type="button"
className="tag-chip"
onClick={() => setTag(value)}
>
{value}
</button>
))}
</div>
{editingTagsFor === item.id ? (
<form
className="library-tag-editor"
onSubmit={(event) => {
event.preventDefault();
onUpdateTags(
item.id,
tagDraft
.split(",")
.map((value) => value.trim())
.filter(Boolean),
);
setEditingTagsFor(undefined);
}}
>
<label>
<span className="sr-only">Comma-separated tags</span>
<input
aria-label={`Tags for ${item.title || "Untitled puzzle"}`}
value={tagDraft}
maxLength={400}
placeholder="classic, hard"
onChange={(event) => setTagDraft(event.target.value)}
/>
</label>
<button type="submit" disabled={busy}>
Apply
</button>
<button
type="button"
className="text-button"
onClick={() => setEditingTagsFor(undefined)}
>
Cancel
</button>
</form>
) : (
<button
className="text-button"
type="button"
onClick={() => {
setEditingTagsFor(item.id);
setTagDraft(item.tags.join(", "));
}}
>
Edit tags
</button>
)}
<button
className="text-button danger"
type="button"
onClick={() => onDelete(item.id)}
>
Delete
</button>
</div>
</li>
))}
</ul>
+4 -1
View File
@@ -1,5 +1,6 @@
import type { EntryMode } from "../state/session";
import { symbolFor } from "../state/session";
import { colorMarkDescription } from "../state/uiPreferences";
const modes: Array<{ mode: EntryMode; label: string; key: string }> = [
{ mode: "value", label: "Value", key: "Z" },
@@ -56,7 +57,9 @@ export function NumberPad({
{mode === "color" ? (
<>
<span aria-hidden="true" />{" "}
<span className="sr-only">Colour {value}</span>
<span className="sr-only">
Colour {value}: {colorMarkDescription(value)}
</span>
</>
) : (
symbolFor(value, size)
+139
View File
@@ -0,0 +1,139 @@
import type { SVGAttributes } from "react";
import type {
SafeVisualAnchor,
SafeVisualPrimitive,
SafeVisualStyle,
} from "../formats";
export interface SafeVisualLayerProps {
readonly size: number;
readonly visuals: readonly SafeVisualPrimitive[];
readonly layer: "underlay" | "overlay";
}
function anchorPoint(size: number, anchor: SafeVisualAnchor) {
if (anchor.kind === "coordinate") {
return { x: anchor.x, y: anchor.y };
}
return {
x: (anchor.cell % size) + 0.5 + (anchor.offsetX ?? 0),
y: Math.floor(anchor.cell / size) + 0.5 + (anchor.offsetY ?? 0),
};
}
function visualStyle(
style: SafeVisualStyle | undefined,
): SVGAttributes<SVGElement> {
return {
stroke: style?.stroke ?? "transparent",
fill: style?.fill ?? "transparent",
...(style?.strokeWidth === undefined
? {}
: { strokeWidth: style.strokeWidth }),
...(style?.opacity === undefined ? {} : { opacity: style.opacity }),
};
}
/**
* Render already-normalized inert drawings with typed SVG properties only.
* Text is supplied as a React text node; no source markup is ever interpreted.
*/
export function SafeVisualLayer({
size,
visuals,
layer,
}: SafeVisualLayerProps) {
return (
<g
className={`safe-visual-layer safe-visual-layer--${layer}`}
data-visual-layer={layer}
>
{visuals.map((visual, index) => {
if (visual.layer !== layer) return null;
const common = {
...visualStyle(visual.style),
className: `source-visual source-visual--${visual.type}`,
"data-visual": index,
};
if (visual.type === "line") {
const start = anchorPoint(size, visual.start);
const end = anchorPoint(size, visual.end);
return (
<line
key={index}
{...common}
x1={start.x}
y1={start.y}
x2={end.x}
y2={end.y}
/>
);
}
if (visual.type === "polyline") {
const points = visual.points
.map((anchor) => anchorPoint(size, anchor))
.map(({ x, y }) => `${String(x)},${String(y)}`)
.join(" ");
return visual.closed === true ? (
<polygon key={index} {...common} points={points} />
) : (
<polyline key={index} {...common} points={points} />
);
}
if (visual.type === "rectangle") {
const center = anchorPoint(size, visual.center);
return (
<rect
key={index}
{...common}
x={center.x - visual.width / 2}
y={center.y - visual.height / 2}
width={visual.width}
height={visual.height}
rx={visual.cornerRadius}
/>
);
}
if (visual.type === "ellipse") {
const center = anchorPoint(size, visual.center);
return (
<ellipse
key={index}
{...common}
cx={center.x}
cy={center.y}
rx={visual.radiusX}
ry={visual.radiusY}
/>
);
}
if (visual.type === "circle") {
const center = anchorPoint(size, visual.center);
return (
<circle
key={index}
{...common}
cx={center.x}
cy={center.y}
r={visual.radius}
/>
);
}
const position = anchorPoint(size, visual.position);
return (
<text
key={index}
{...common}
x={position.x}
y={position.y}
fontSize={visual.style?.fontSize}
textAnchor="middle"
dominantBaseline="central"
>
{visual.text}
</text>
);
})}
</g>
);
}
+740
View File
@@ -0,0 +1,740 @@
import { useState, type FormEvent } from "react";
import type {
PuzzleQualityAnalysis,
QualityItemAssessment,
QualityItemReference,
analyzePuzzleQuality,
} from "../solver/quality";
import { symbolFor } from "../state/session";
export type SetterQualityRunOptions = NonNullable<
Parameters<typeof analyzePuzzleQuality>[1]
>;
export type SetterQualityRunKind = "quick" | "minimality";
export interface SetterQualityLabProps {
readonly size: number;
readonly result?: PuzzleQualityAnalysis;
readonly running?: SetterQualityRunKind;
readonly error?: string;
readonly initialOptions?: Partial<SetterQualityRunOptions>;
readonly onRunQuick: (options: SetterQualityRunOptions) => void;
readonly onRunMinimality: (options: SetterQualityRunOptions) => void;
readonly onCancel: () => void;
readonly onFocusCells: (cells: readonly number[]) => void;
readonly onFocusItem: (item: QualityItemReference) => void;
}
interface QualityControls {
readonly perCheckMaxNodes: number;
readonly perCheckTimeoutMs: number;
readonly aggregateMaxChecks: number;
readonly aggregateMaxNodes: number;
readonly aggregateTimeoutMs: number;
}
const DEFAULT_CONTROLS: QualityControls = {
perCheckMaxNodes: 2_000_000,
perCheckTimeoutMs: 10_000,
aggregateMaxChecks: 1_000,
aggregateMaxNodes: 20_000_000,
aggregateTimeoutMs: 30_000,
};
const CONTROL_MAXIMUMS: Record<keyof QualityControls, number> = {
perCheckMaxNodes: 100_000_000,
perCheckTimeoutMs: 120_000,
aggregateMaxChecks: 20_000,
aggregateMaxNodes: 2_000_000_000,
aggregateTimeoutMs: 600_000,
};
function boundedPositiveInteger(
value: string,
fallback: number,
maximum: number,
): number {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0
? Math.min(parsed, maximum)
: fallback;
}
function humanize(value: string): string {
return value
.replaceAll(/([a-z])([A-Z])/gu, "$1 $2")
.replaceAll("-", " ")
.replace(/^./u, (first) => first.toUpperCase());
}
function cellName(cell: number, size: number): string {
return `r${String(Math.floor(cell / size) + 1)}c${String((cell % size) + 1)}`;
}
function itemLabel(item: QualityItemReference, size: number): string {
if (item.kind === "given") {
return `Given ${symbolFor(item.value, size)} at ${cellName(item.cell, size)}`;
}
return `Constraint ${String(item.index + 1)} · ${humanize(item.constraintType)}`;
}
function itemKey(item: QualityItemReference): string {
return item.kind === "given"
? `given-${String(item.cell)}`
: `constraint-${String(item.index)}`;
}
function ItemList({
title,
items,
size,
emptyLabel,
onFocusItem,
}: {
title: string;
items: readonly QualityItemReference[];
size: number;
emptyLabel: string;
onFocusItem: (item: QualityItemReference) => void;
}) {
return (
<section className="setter-quality__item-group">
<h5>{title}</h5>
{items.length === 0 ? (
<p className="muted">{emptyLabel}</p>
) : (
<ul className="setter-quality__item-list">
{items.map((item) => (
<li key={itemKey(item)}>
<button type="button" onClick={() => onFocusItem(item)}>
{itemLabel(item, size)}
</button>
</li>
))}
</ul>
)}
</section>
);
}
function AssessmentTable({
title,
assessments,
size,
onFocusItem,
}: {
title: string;
assessments: readonly QualityItemAssessment[];
size: number;
onFocusItem: (item: QualityItemReference) => void;
}) {
const counts = {
critical: assessments.filter(
({ classification }) => classification === "critical",
).length,
redundant: assessments.filter(
({ classification }) => classification === "redundant",
).length,
unknown: assessments.filter(
({ classification }) => classification === "unknown",
).length,
};
return (
<section className="setter-quality__assessments">
<div className="section-heading">
<h4>{title}</h4>
<p className="setter-quality__counts muted">
{counts.critical} critical · {counts.redundant} redundant ·{" "}
{counts.unknown} unknown
</p>
</div>
{assessments.length === 0 ? (
<p className="muted">
No {title.toLowerCase()} were available to test.
</p>
) : (
<div className="setter-quality__table-wrap">
<table className="setter-quality__table">
<thead>
<tr>
<th scope="col">Clue</th>
<th scope="col">Finding</th>
<th scope="col">Evidence</th>
</tr>
</thead>
<tbody>
{assessments.map((assessment) => (
<tr
key={itemKey(assessment.item)}
className={`is-${assessment.classification}`}
>
<th scope="row">
<button
type="button"
onClick={() => onFocusItem(assessment.item)}
>
{itemLabel(assessment.item, size)}
</button>
</th>
<td>
<strong>{humanize(assessment.classification)}</strong>
</td>
<td>
{assessment.classification === "unknown"
? `Unknown / incomplete${assessment.unknownReason ? `: ${humanize(assessment.unknownReason)}` : ""}`
: assessment.solutionStatus
? `Without clue: ${humanize(assessment.solutionStatus)}`
: "Completed bounded check"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
function solutionTitle(
status: PuzzleQualityAnalysis["solutionStatus"],
): string {
if (status === "unique") return "Unique completion proven";
if (status === "multiple") return "Multiple completions found";
if (status === "unsatisfiable") return "No completion exists";
return "Solution status unknown";
}
function solutionDescription(
status: PuzzleQualityAnalysis["solutionStatus"],
): string {
if (status === "unique")
return "The bounded exact check completed and proved one solution.";
if (status === "multiple")
return "Two different completions are enough to prove ambiguity.";
if (status === "unsatisfiable")
return "The bounded exact check completed without finding any valid completion.";
return "Incomplete / unknown: a configured safety bound was reached before the solution status could be proved.";
}
function QualityResults({
size,
result,
onFocusCells,
onFocusItem,
}: {
size: number;
result: PuzzleQualityAnalysis;
onFocusCells: (cells: readonly number[]) => void;
onFocusItem: (item: QualityItemReference) => void;
}) {
const witness = result.ambiguityWitness;
const minimality = result.minimality;
return (
<div className="setter-quality__results stack">
<section className="setter-quality__solution analysis-summary">
<div className="section-heading">
<div>
<p className="eyebrow">Solution status</p>
<h3>{solutionTitle(result.solutionStatus)}</h3>
</div>
<span
className={`status-pill status-${result.solutionStatus}`}
aria-label={`Solution status: ${humanize(result.solutionStatus)}`}
>
{humanize(result.solutionStatus)}
</span>
</div>
<p>{solutionDescription(result.solutionStatus)}</p>
</section>
{witness !== undefined && (
<section
className="setter-quality__witness panel-section"
aria-labelledby="quality-witness-title"
>
<div className="section-heading">
<div>
<p className="eyebrow">Ambiguity witness</p>
<h4 id="quality-witness-title">
{witness.differences.length} differing cell
{witness.differences.length === 1 ? "" : "s"}
</h4>
</div>
<button
type="button"
disabled={witness.differences.length === 0}
onClick={() =>
onFocusCells(witness.differences.map(({ cell }) => cell))
}
>
Focus differences
</button>
</div>
<p className="muted">
These values come from two concrete valid completions; they are not
guesses.
</p>
<div className="setter-quality__table-wrap">
<table className="setter-quality__table">
<thead>
<tr>
<th scope="col">Cell</th>
<th scope="col">Solution A</th>
<th scope="col">Solution B</th>
</tr>
</thead>
<tbody>
{witness.differences.map(({ cell, first, second }) => (
<tr key={cell}>
<th scope="row">
<button
type="button"
onClick={() => onFocusCells([cell])}
>
{cellName(cell, size)}
</button>
</th>
<td>{symbolFor(first, size)}</td>
<td>{symbolFor(second, size)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
<section
className={`setter-quality__contradiction panel-section status-${result.contradiction.status}`}
aria-labelledby="quality-contradiction-title"
>
<div className="section-heading">
<div>
<p className="eyebrow">Contradiction localization</p>
<h4 id="quality-contradiction-title">
{result.contradiction.status === "localized"
? "Contradiction suspects localized"
: result.contradiction.status === "incomplete"
? "Incomplete / unknown"
: "Not applicable"}
</h4>
</div>
<span className="status-pill">
{humanize(result.contradiction.status)}
</span>
</div>
{result.contradiction.reason && <p>{result.contradiction.reason}</p>}
{result.contradiction.status === "not-applicable" && (
<p className="muted">
Contradiction localization only applies after unsatisfiability is
proved.
</p>
)}
<div className="setter-quality__contradiction-groups">
<ItemList
title="Retained suspect core"
items={result.contradiction.core}
size={size}
emptyLabel="No retained suspects were established."
onFocusItem={onFocusItem}
/>
<ItemList
title="Proven necessary suspects"
items={result.contradiction.necessary}
size={size}
emptyLabel="No individual suspect was proved necessary."
onFocusItem={onFocusItem}
/>
<ItemList
title="Removable while still contradictory"
items={result.contradiction.removable}
size={size}
emptyLabel="No removable suspects were established."
onFocusItem={onFocusItem}
/>
<ItemList
title="Unknown at safety bounds"
items={result.contradiction.unknown}
size={size}
emptyLabel="No unresolved suspects."
onFocusItem={onFocusItem}
/>
</div>
</section>
<section className="setter-quality__redundancy panel-section">
<p className="eyebrow">Leave-one-out evidence</p>
<h3>Clue criticality and redundancy</h3>
<p className="muted">
Critical means removing that clue admits another completion. Redundant
means uniqueness was proved without it. Unknown always remains unknown
when a bound interrupts proof.
</p>
{result.analysisDepth === "baseline" ? (
<p className="setter-quality__not-analysed">
Not analysed in the quick baseline. Run the full bounded minimality
analysis to classify individual clues.
</p>
) : (
<>
<AssessmentTable
title="Givens"
assessments={result.redundancy.givens}
size={size}
onFocusItem={onFocusItem}
/>
<AssessmentTable
title="Constraints"
assessments={result.redundancy.constraints}
size={size}
onFocusItem={onFocusItem}
/>
</>
)}
</section>
<section
className="setter-quality__heatmap panel-section"
aria-labelledby="quality-heatmap-title"
>
<p className="eyebrow">Criticality heatmap</p>
<h3 id="quality-heatmap-title">Cell evidence</h3>
<p className="muted" id="quality-heatmap-help">
Every button states its evidence, so colour is never the only signal.
Select a cell to focus it on the puzzle.
</p>
{result.criticalityHeatmap.length === 0 ? (
<p>
{result.analysisDepth === "baseline"
? "Not analysed in the quick baseline."
: "No cell-level evidence is available."}
</p>
) : (
<div
className="setter-quality__heatmap-grid"
role="group"
aria-label="Cell criticality heatmap"
aria-describedby="quality-heatmap-help"
style={{
gridTemplateColumns: `repeat(${String(size)}, minmax(0, 1fr))`,
}}
>
{result.criticalityHeatmap.map((entry) => {
const evidenceClass =
entry.score === null
? "unknown"
: entry.criticalWeight > entry.redundantWeight
? "critical"
: entry.redundantWeight > entry.criticalWeight
? "redundant"
: entry.criticalWeight + entry.redundantWeight > 0
? "mixed"
: "neutral";
const score =
entry.score === null ? "unknown" : entry.score.toFixed(2);
const label = `${cellName(entry.cell, size)}: ${evidenceClass}; score ${score}; critical weight ${String(entry.criticalWeight)}, redundant weight ${String(entry.redundantWeight)}, unknown weight ${String(entry.unknownWeight)}`;
return (
<button
key={entry.cell}
type="button"
className={`setter-quality__heat-cell is-${evidenceClass}`}
aria-label={label}
title={label}
onClick={() => onFocusCells([entry.cell])}
>
<span>{cellName(entry.cell, size)}</span>
<strong>
{entry.score === null ? "?" : entry.score.toFixed(1)}
</strong>
</button>
);
})}
</div>
)}
</section>
<section className="setter-quality__minimality panel-section">
<div className="section-heading">
<div>
<p className="eyebrow">Bounded minimality</p>
<h3>
{minimality === undefined
? "Not requested"
: humanize(minimality.status)}
</h3>
</div>
<span className="status-pill">
{minimality === undefined ? "Not run" : humanize(minimality.status)}
</span>
</div>
{minimality === undefined ? (
<p>
Minimality is unknown because the full bounded minimality pass was
not requested.
</p>
) : (
<>
{minimality.reason && <p>{minimality.reason}</p>}
<ItemList
title="Redundant clues"
items={minimality.redundant}
size={size}
emptyLabel="No redundant clue was proved."
onFocusItem={onFocusItem}
/>
<ItemList
title="Unknown clues"
items={minimality.unknown}
size={size}
emptyLabel="No clue remained unknown."
onFocusItem={onFocusItem}
/>
</>
)}
</section>
<section className="setter-quality__metrics panel-section">
<p className="eyebrow">Bounded-search record</p>
<h3>Checks and limits</h3>
<dl className="setter-quality__metric-list">
<div>
<dt>Analysis depth</dt>
<dd>
{result.analysisDepth === "baseline"
? "Quick baseline"
: "Full setter QC"}
</dd>
</div>
<div>
<dt>Analysis completeness</dt>
<dd>
{result.budget.truncated
? "Incomplete / unknown conclusions remain"
: "All requested checks completed"}
</dd>
</div>
<div>
<dt>Checks performed / planned</dt>
<dd>
{result.budget.checksPerformed.toLocaleString()} /{" "}
{result.budget.checksPlanned.toLocaleString()}
</dd>
</div>
<div>
<dt>Recorded check details</dt>
<dd>{result.checks.length.toLocaleString()}</dd>
</div>
<div>
<dt>Nodes searched</dt>
<dd>{result.budget.nodes.toLocaleString()}</dd>
</div>
<div>
<dt>Elapsed</dt>
<dd>{result.budget.elapsedMs.toLocaleString()} ms</dd>
</div>
<div>
<dt>Unknown reasons</dt>
<dd>
{result.budget.unknownReasons.length === 0
? "None"
: result.budget.unknownReasons.map(humanize).join(", ")}
</dd>
</div>
<div>
<dt>Per-check node / time bound</dt>
<dd>
{result.bounds.perCheck.maxNodes.toLocaleString()} nodes /{" "}
{result.bounds.perCheck.timeoutMs.toLocaleString()} ms
</dd>
</div>
<div>
<dt>Aggregate check / node / time bound</dt>
<dd>
{result.bounds.aggregate.maxChecks.toLocaleString()} checks /{" "}
{result.bounds.aggregate.maxNodes.toLocaleString()} nodes /{" "}
{result.bounds.aggregate.timeoutMs.toLocaleString()} ms
</dd>
</div>
</dl>
</section>
</div>
);
}
export function SetterQualityLab({
size,
result,
running,
error,
initialOptions,
onRunQuick,
onRunMinimality,
onCancel,
onFocusCells,
onFocusItem,
}: SetterQualityLabProps) {
const [controls, setControls] = useState<QualityControls>(() => ({
perCheckMaxNodes:
initialOptions?.perCheckMaxNodes ?? DEFAULT_CONTROLS.perCheckMaxNodes,
perCheckTimeoutMs:
initialOptions?.perCheckTimeoutMs ?? DEFAULT_CONTROLS.perCheckTimeoutMs,
aggregateMaxChecks:
initialOptions?.aggregateMaxChecks ?? DEFAULT_CONTROLS.aggregateMaxChecks,
aggregateMaxNodes:
initialOptions?.aggregateMaxNodes ?? DEFAULT_CONTROLS.aggregateMaxNodes,
aggregateTimeoutMs:
initialOptions?.aggregateTimeoutMs ?? DEFAULT_CONTROLS.aggregateTimeoutMs,
}));
const options = (
analysisDepth: "baseline" | "full",
proveMinimality: boolean,
): SetterQualityRunOptions => ({
...controls,
analysisDepth,
proveMinimality,
});
const runQuick = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
onRunQuick(options("baseline", false));
};
const setControl = (key: keyof QualityControls, value: string) => {
setControls((current) => ({
...current,
[key]: boundedPositiveInteger(value, current[key], CONTROL_MAXIMUMS[key]),
}));
};
return (
<section
className="setter-quality stack"
aria-labelledby="setter-quality-title"
>
<div>
<p className="eyebrow">Setter quality</p>
<h2 id="setter-quality-title">Uniqueness, redundancy and minimality</h2>
<p className="muted">
Every claim is backed by a bounded local exact-search check. Reaching
a limit produces an explicit unknown result, never a guessed verdict.
</p>
</div>
<form
className="setter-quality__controls panel-section"
onSubmit={runQuick}
>
<div className="field-grid">
<label>
Nodes per check
<input
type="number"
min="1"
max="100000000"
value={controls.perCheckMaxNodes}
disabled={running !== undefined}
onChange={(event) =>
setControl("perCheckMaxNodes", event.target.value)
}
/>
</label>
<label>
Time per check (ms)
<input
type="number"
min="1"
max="120000"
value={controls.perCheckTimeoutMs}
disabled={running !== undefined}
onChange={(event) =>
setControl("perCheckTimeoutMs", event.target.value)
}
/>
</label>
<label>
Maximum checks
<input
type="number"
min="1"
max="20000"
value={controls.aggregateMaxChecks}
disabled={running !== undefined}
onChange={(event) =>
setControl("aggregateMaxChecks", event.target.value)
}
/>
</label>
<label>
Total node budget
<input
type="number"
min="1"
max="2000000000"
value={controls.aggregateMaxNodes}
disabled={running !== undefined}
onChange={(event) =>
setControl("aggregateMaxNodes", event.target.value)
}
/>
</label>
<label>
Total time budget (ms)
<input
type="number"
min="1"
max="600000"
value={controls.aggregateTimeoutMs}
disabled={running !== undefined}
onChange={(event) =>
setControl("aggregateTimeoutMs", event.target.value)
}
/>
</label>
</div>
<div className="setter-quality__actions action-row">
<button type="submit" disabled={running !== undefined}>
Run quick quality check
</button>
<button
type="button"
disabled={running !== undefined}
onClick={() => onRunMinimality(options("full", true))}
>
Run full bounded minimality
</button>
{running !== undefined && (
<button type="button" onClick={onCancel}>
Cancel analysis
</button>
)}
</div>
</form>
{running !== undefined && (
<p
className="setter-quality__running status-line"
role="status"
aria-live="polite"
>
{running === "quick"
? "Quick quality check running locally…"
: "Full bounded minimality analysis running locally…"}
</p>
)}
{error !== undefined && (
<p className="setter-quality__error error-callout" role="alert">
{error}
</p>
)}
{result !== undefined && (
<QualityResults
size={size}
result={result}
onFocusCells={onFocusCells}
onFocusItem={onFocusItem}
/>
)}
</section>
);
}
+9 -7
View File
@@ -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
File diff suppressed because it is too large Load Diff
+1680 -175
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -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),
);
}
+60
View File
@@ -0,0 +1,60 @@
import type { FogConstraint, NormalizedPuzzle } from "../domain";
/**
* Resolve display-only Fog of War visibility without changing puzzle rules.
* A trusted correct entry and every given act as reveal sources in addition to
* the authored lights.
*/
export function foggedCellsForPuzzle(
puzzle: NormalizedPuzzle,
values: readonly number[],
): ReadonlySet<number> {
const fogConstraints = puzzle.constraints.filter(
(constraint): constraint is FogConstraint => constraint.type === "fog",
);
if (fogConstraints.length === 0) return new Set<number>();
const correctEntries = values.flatMap((value, cell) =>
value !== 0 && puzzle.solution?.[cell] === value ? [cell] : [],
);
const givens = puzzle.givens.flatMap((value, cell) =>
value === 0 ? [] : [cell],
);
const visible = new Set<number>();
for (const constraint of fogConstraints) {
const radius = constraint.revealRadius ?? 1;
const sources = new Set([
...constraint.lights,
...givens,
...correctEntries,
]);
for (const source of sources) {
const row = Math.floor(source / puzzle.size);
const column = source % puzzle.size;
for (let rowOffset = -radius; rowOffset <= radius; rowOffset += 1) {
for (
let columnOffset = -radius;
columnOffset <= radius;
columnOffset += 1
) {
const targetRow = row + rowOffset;
const targetColumn = column + columnOffset;
if (
targetRow >= 0 &&
targetRow < puzzle.size &&
targetColumn >= 0 &&
targetColumn < puzzle.size
) {
visible.add(targetRow * puzzle.size + targetColumn);
}
}
}
}
}
return new Set(
Array.from({ length: puzzle.size * puzzle.size }, (_, cell) => cell).filter(
(cell) => !visible.has(cell),
),
);
}
+223
View File
@@ -0,0 +1,223 @@
import type { LogicalStep, LogicalTechnique } from "../solver";
import { symbolFor } from "../state/session";
export const GUIDED_HINT_STAGES = [
"focus",
"technique",
"reasoning",
"preview",
] as const;
export type GuidedHintStage = (typeof GUIDED_HINT_STAGES)[number];
export interface GuidedHintCellSets {
readonly focusCells: readonly number[];
readonly placementCells: readonly number[];
readonly eliminationCells: readonly number[];
readonly affectedCells: readonly number[];
}
export interface GuidedHintOverlay {
readonly focusCells: readonly number[];
readonly placementCells: readonly number[];
readonly eliminationCells: readonly number[];
}
const TECHNIQUE_DESCRIPTIONS: Partial<Record<LogicalTechnique, string>> = {
"naked-single": "A cell has only one legal candidate left.",
"hidden-single": "A digit has only one possible position in a house.",
"naked-pair":
"Two cells reserve the same two candidates, excluding them elsewhere in their house.",
"naked-triple":
"Three cells reserve three candidates, excluding them elsewhere in their house.",
"naked-quad":
"Four cells reserve four candidates, excluding them elsewhere in their house.",
"hidden-pair": "Two digits can occur in only the same two cells of a house.",
"hidden-triple":
"Three digits can occur in only the same three cells of a house.",
"hidden-quad":
"Four digits can occur in only the same four cells of a house.",
pointing:
"A candidate confined to one line inside a region can be removed farther along that line.",
claiming:
"A candidate confined to one region along a line can be removed from the rest of that region.",
"x-wing":
"Two matching rows or columns lock a candidate into two opposite positions.",
swordfish:
"Three matching rows or columns lock a candidate into three crossing lines.",
"xy-wing":
"Three linked bivalue cells force a shared candidate out of cells that see both wings.",
"xyz-wing":
"A three-candidate pivot and two wings force their shared candidate elsewhere.",
"killer-cage":
"A cage's remaining sum and legal combinations restrict its unsolved cells.",
};
function uniqueCells(cells: readonly number[]): number[] {
return [...new Set(cells)].filter(
(cell) => Number.isInteger(cell) && cell >= 0,
);
}
function naturalList(items: readonly string[]): string {
if (items.length <= 1) return items[0] ?? "";
if (items.length === 2) return `${items[0]} and ${items[1]}`;
return `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`;
}
export function guidedHintStageIndex(stage: GuidedHintStage): number {
return GUIDED_HINT_STAGES.indexOf(stage);
}
export function nextGuidedHintStage(
stage: GuidedHintStage,
): GuidedHintStage | undefined {
return GUIDED_HINT_STAGES[guidedHintStageIndex(stage) + 1];
}
export function isGuidedHintStageRevealed(
current: GuidedHintStage,
stage: GuidedHintStage,
): boolean {
return guidedHintStageIndex(current) >= guidedHintStageIndex(stage);
}
export function logicalTechniqueName(technique: LogicalTechnique): string {
return technique
.split("-")
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
.join(" ");
}
export function logicalTechniqueDescription(
technique: LogicalTechnique,
): string {
return (
TECHNIQUE_DESCRIPTIONS[technique] ??
`This ${logicalTechniqueName(technique).toLowerCase()} pattern creates a logical deduction.`
);
}
export function guidedHintCellName(cell: number, size: number): string {
return `r${String(Math.floor(cell / size) + 1)}c${String((cell % size) + 1)}`;
}
export function deriveGuidedHintCellSets(
step: LogicalStep,
): GuidedHintCellSets {
const focusCells = uniqueCells(step.focusCells);
const placementCells = uniqueCells(
step.placements.map((placement) => placement.cell),
);
const eliminationCells = uniqueCells(
step.eliminations.map((elimination) => elimination.cell),
);
return {
focusCells,
placementCells,
eliminationCells,
affectedCells: uniqueCells([...placementCells, ...eliminationCells]),
};
}
/**
* A guided step is safe to disclose only when every premise and every effect
* is currently visible. This deliberately includes focus cells: an otherwise
* harmless-looking technique name or explanation can reveal a hidden clue.
*/
export function guidedHintStepIsVisible(
step: LogicalStep,
hiddenCells: ReadonlySet<number>,
): boolean {
const { focusCells, placementCells, eliminationCells } =
deriveGuidedHintCellSets(step);
return [...focusCells, ...placementCells, ...eliminationCells].every(
(cell) => !hiddenCells.has(cell),
);
}
/**
* Returns only the cells that may be visualised at the current disclosure
* stage. In particular, effect cells stay absent until the preview stage.
*/
export function guidedHintOverlay(
step: LogicalStep,
stage: GuidedHintStage,
): GuidedHintOverlay {
const cells = deriveGuidedHintCellSets(step);
return {
focusCells: cells.focusCells,
placementCells: stage === "preview" ? cells.placementCells : [],
eliminationCells: stage === "preview" ? cells.eliminationCells : [],
};
}
export function guidedHintFocusSummary(
step: LogicalStep,
size: number,
): string {
const { focusCells, affectedCells } = deriveGuidedHintCellSets(step);
const cells = focusCells.length > 0 ? focusCells : affectedCells;
if (cells.length === 0) return "Review the current candidate grid.";
if (cells.length === 1) {
return `Look closely at ${guidedHintCellName(cells[0]!, size)}.`;
}
const rows = new Set(cells.map((cell) => Math.floor(cell / size)));
if (rows.size === 1) {
return `Look across row ${String((Math.floor(cells[0]! / size) || 0) + 1)}.`;
}
const columns = new Set(cells.map((cell) => cell % size));
if (columns.size === 1) {
return `Look down column ${String(((cells[0] ?? 0) % size) + 1)}.`;
}
const boxSize = Math.sqrt(size);
if (Number.isInteger(boxSize)) {
const boxes = new Set(
cells.map((cell) => {
const row = Math.floor(cell / size);
const column = cell % size;
return (
Math.floor(row / boxSize) * boxSize + Math.floor(column / boxSize)
);
}),
);
if (boxes.size === 1) {
return `Look within box ${String((boxes.values().next().value as number) + 1)}.`;
}
}
if (cells.length <= 4) {
return `Compare ${naturalList(cells.map((cell) => guidedHintCellName(cell, size)))}.`;
}
return `Compare the ${String(cells.length)} highlighted cells.`;
}
export function guidedHintEffectItems(
step: LogicalStep,
size: number,
): readonly string[] {
const placements = step.placements.map(
({ cell, value }) =>
`Place ${symbolFor(value, size)} in ${guidedHintCellName(cell, size)}.`,
);
const eliminations = step.eliminations.map(({ cell, values }) => {
const symbols = values.map((value) => symbolFor(value, size));
const object =
symbols.length === 1
? symbols[0]
: naturalList(symbols.map((symbol) => String(symbol)));
return `Remove ${object} from ${guidedHintCellName(cell, size)}.`;
});
return [...placements, ...eliminations];
}
export function guidedHintEffectSummary(
step: LogicalStep,
size: number,
): string {
const effects = guidedHintEffectItems(step, size);
if (effects.length === 0) return "This deduction does not change the board.";
return effects.join(" ");
}
+304 -4
View File
@@ -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 19 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 19 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 16 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 16 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 16 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 16 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 16 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 16 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 16 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 14 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 16 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 16 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
View File
@@ -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);
}
+482
View File
@@ -0,0 +1,482 @@
import {
correspondingBoxPositionCells,
littleKillerCells,
orthogonalNeighbours,
outsideLineCells,
} from "./geometry";
import type { CellId, VariantConstraint } from "./types";
export type ConstraintType = VariantConstraint["type"];
export type ConstraintCategory =
"global" | "region" | "line" | "adjacency" | "outside" | "cell";
export type ConstraintFieldKind =
| "discriminator"
| "boolean"
| "integer"
| "enum"
| "cell"
| "cells"
| "integers"
| "outside-side";
export interface ConstraintFieldMetadata {
readonly key: string;
readonly kind: ConstraintFieldKind;
readonly required: boolean;
}
export interface ConstraintRegistryEntry<
Type extends ConstraintType = ConstraintType,
> {
readonly type: Type;
readonly label: string;
readonly category: ConstraintCategory;
readonly negatable: boolean;
readonly fields: readonly ConstraintFieldMetadata[];
readonly cells: (
size: number,
constraint: Extract<VariantConstraint, { readonly type: Type }>,
) => readonly CellId[];
}
type ConstraintRegistry = {
readonly [Type in ConstraintType]: ConstraintRegistryEntry<Type>;
};
const typeField: ConstraintFieldMetadata = {
key: "type",
kind: "discriminator",
required: true,
};
const negatedField: ConstraintFieldMetadata = {
key: "negated",
kind: "boolean",
required: false,
};
const allCells = (size: number): CellId[] =>
Array.from({ length: size * size }, (_, cell) => cell);
const cellField = (key = "cell"): ConstraintFieldMetadata => ({
key,
kind: "cell",
required: true,
});
const cellsField = (key = "cells"): ConstraintFieldMetadata => ({
key,
kind: "cells",
required: true,
});
const integerField = (key: string): ConstraintFieldMetadata => ({
key,
kind: "integer",
required: true,
});
const enumField = (key: string): ConstraintFieldMetadata => ({
key,
kind: "enum",
required: true,
});
const outsideFields = (
valueField: string,
): readonly ConstraintFieldMetadata[] => [
typeField,
{ key: "side", kind: "outside-side", required: true },
integerField("index"),
integerField(valueField),
negatedField,
];
function entry<Type extends ConstraintType>(
value: ConstraintRegistryEntry<Type>,
): ConstraintRegistryEntry<Type> {
return value;
}
export const CONSTRAINT_REGISTRY = {
diagonal: entry({
type: "diagonal",
label: "Diagonal",
category: "global",
negatable: false,
fields: [typeField, enumField("direction")],
cells: (size, constraint) =>
Array.from({ length: size }, (_, index) =>
constraint.direction === "main"
? index * size + index
: index * size + size - index - 1,
),
}),
"anti-knight": entry({
type: "anti-knight",
label: "Anti-knight",
category: "global",
negatable: false,
fields: [typeField],
cells: (size) => allCells(size),
}),
"anti-king": entry({
type: "anti-king",
label: "Anti-king",
category: "global",
negatable: false,
fields: [typeField],
cells: (size) => allCells(size),
}),
"non-consecutive": entry({
type: "non-consecutive",
label: "Non-consecutive",
category: "global",
negatable: false,
fields: [typeField],
cells: (size) => allCells(size),
}),
"disjoint-groups": entry({
type: "disjoint-groups",
label: "Disjoint groups",
category: "global",
negatable: false,
fields: [typeField],
cells: (size) => allCells(size),
}),
"killer-cage": entry({
type: "killer-cage",
label: "Killer cage",
category: "region",
negatable: true,
fields: [
typeField,
cellsField(),
integerField("sum"),
{ key: "noRepeat", kind: "boolean", required: false },
negatedField,
],
cells: (_size, constraint) => constraint.cells,
}),
thermo: entry({
type: "thermo",
label: "Thermometer",
category: "line",
negatable: true,
fields: [typeField, cellsField(), negatedField],
cells: (_size, constraint) => constraint.cells,
}),
arrow: entry({
type: "arrow",
label: "Arrow",
category: "line",
negatable: true,
fields: [typeField, cellsField("bulb"), cellsField("line"), negatedField],
cells: (_size, constraint) => [...constraint.bulb, ...constraint.line],
}),
kropki: entry({
type: "kropki",
label: "Kropki dot",
category: "adjacency",
negatable: true,
fields: [
typeField,
cellField("a"),
cellField("b"),
enumField("kind"),
negatedField,
],
cells: (_size, constraint) => [constraint.a, constraint.b],
}),
xv: entry({
type: "xv",
label: "XV pair",
category: "adjacency",
negatable: true,
fields: [
typeField,
cellField("a"),
cellField("b"),
integerField("total"),
negatedField,
],
cells: (_size, constraint) => [constraint.a, constraint.b],
}),
inequality: entry({
type: "inequality",
label: "Inequality",
category: "adjacency",
negatable: true,
fields: [
typeField,
cellField("lesser"),
cellField("greater"),
negatedField,
],
cells: (_size, constraint) => [constraint.lesser, constraint.greater],
}),
renban: entry({
type: "renban",
label: "Renban line",
category: "line",
negatable: true,
fields: [typeField, cellsField(), negatedField],
cells: (_size, constraint) => constraint.cells,
}),
palindrome: entry({
type: "palindrome",
label: "Palindrome line",
category: "line",
negatable: true,
fields: [typeField, cellsField(), negatedField],
cells: (_size, constraint) => constraint.cells,
}),
"x-sum": entry({
type: "x-sum",
label: "X-sum",
category: "outside",
negatable: true,
fields: outsideFields("sum"),
cells: (size, constraint) =>
outsideLineCells(size, constraint.side, constraint.index),
}),
skyscraper: entry({
type: "skyscraper",
label: "Skyscraper",
category: "outside",
negatable: true,
fields: outsideFields("count"),
cells: (size, constraint) =>
outsideLineCells(size, constraint.side, constraint.index),
}),
quadruple: entry({
type: "quadruple",
label: "Quadruple",
category: "cell",
negatable: true,
fields: [
typeField,
cellsField(),
{ key: "digits", kind: "integers", required: true },
negatedField,
],
cells: (_size, constraint) => constraint.cells,
}),
maximum: entry({
type: "maximum",
label: "Maximum cell",
category: "cell",
negatable: true,
fields: [typeField, cellField(), negatedField],
cells: (size, constraint) => [
constraint.cell,
...orthogonalNeighbours(size, constraint.cell),
],
}),
minimum: entry({
type: "minimum",
label: "Minimum cell",
category: "cell",
negatable: true,
fields: [typeField, cellField(), negatedField],
cells: (size, constraint) => [
constraint.cell,
...orthogonalNeighbours(size, constraint.cell),
],
}),
odd: entry({
type: "odd",
label: "Odd cell",
category: "cell",
negatable: true,
fields: [typeField, cellField(), negatedField],
cells: (_size, constraint) => [constraint.cell],
}),
even: entry({
type: "even",
label: "Even cell",
category: "cell",
negatable: true,
fields: [typeField, cellField(), negatedField],
cells: (_size, constraint) => [constraint.cell],
}),
"little-killer": entry({
type: "little-killer",
label: "Little killer",
category: "outside",
negatable: true,
fields: [
typeField,
{ key: "side", kind: "outside-side", required: true },
integerField("index"),
enumField("direction"),
integerField("sum"),
negatedField,
],
cells: (size, constraint) =>
littleKillerCells(
size,
constraint.side,
constraint.index,
constraint.direction,
),
}),
sandwich: entry({
type: "sandwich",
label: "Sandwich sum",
category: "outside",
negatable: true,
fields: outsideFields("sum"),
cells: (size, constraint) =>
outsideLineCells(size, constraint.side, constraint.index),
}),
"between-line": entry({
type: "between-line",
label: "Between line",
category: "line",
negatable: true,
fields: [typeField, cellsField(), negatedField],
cells: (_size, constraint) => constraint.cells,
}),
"german-whisper": entry({
type: "german-whisper",
label: "German whisper",
category: "line",
negatable: true,
fields: [
typeField,
cellsField(),
{ key: "minimumDifference", kind: "integer", required: false },
negatedField,
],
cells: (_size, constraint) => constraint.cells,
}),
"region-sum-line": entry({
type: "region-sum-line",
label: "Region-sum line",
category: "line",
negatable: true,
fields: [typeField, cellsField(), negatedField],
cells: (_size, constraint) => constraint.cells,
}),
clone: entry({
type: "clone",
label: "Clone regions",
category: "region",
negatable: true,
fields: [typeField, cellsField(), cellsField("cloneCells"), negatedField],
cells: (_size, constraint) => [
...constraint.cells,
...constraint.cloneCells,
],
}),
"extra-region": entry({
type: "extra-region",
label: "Extra region",
category: "region",
negatable: false,
fields: [typeField, cellsField()],
cells: (_size, constraint) => constraint.cells,
}),
"modular-line": entry({
type: "modular-line",
label: "Modular line",
category: "line",
negatable: true,
fields: [typeField, cellsField(), negatedField],
cells: (_size, constraint) => constraint.cells,
}),
"entropic-line": entry({
type: "entropic-line",
label: "Entropic line",
category: "line",
negatable: true,
fields: [typeField, cellsField(), negatedField],
cells: (_size, constraint) => constraint.cells,
}),
"zipper-line": entry({
type: "zipper-line",
label: "Zipper line",
category: "line",
negatable: true,
fields: [typeField, cellsField(), negatedField],
cells: (_size, constraint) => constraint.cells,
}),
"double-arrow": entry({
type: "double-arrow",
label: "Double arrow",
category: "line",
negatable: true,
fields: [typeField, cellsField(), negatedField],
cells: (_size, constraint) => constraint.cells,
}),
indexer: entry({
type: "indexer",
label: "Indexer",
category: "cell",
negatable: true,
fields: [typeField, enumField("kind"), cellField(), negatedField],
cells: (size, constraint) => {
const row = Math.floor(constraint.cell / size);
const column = constraint.cell % size;
if (constraint.kind === "row") {
return Array.from(
{ length: size },
(_, targetRow) => targetRow * size + column,
);
}
if (constraint.kind === "column") {
return Array.from(
{ length: size },
(_, targetColumn) => row * size + targetColumn,
);
}
return correspondingBoxPositionCells(size, constraint.cell);
},
}),
fog: entry({
type: "fog",
label: "Fog of war",
category: "global",
negatable: false,
fields: [
typeField,
cellsField("lights"),
{ key: "revealRadius", kind: "integer", required: false },
],
cells: (_size, constraint) => constraint.lights,
}),
} satisfies ConstraintRegistry;
export const CONSTRAINT_TYPES = Object.freeze(
Object.keys(CONSTRAINT_REGISTRY) as ConstraintType[],
);
export function isConstraintType(value: string): value is ConstraintType {
return Object.hasOwn(CONSTRAINT_REGISTRY, value);
}
export function constraintMetadata<Type extends ConstraintType>(
type: Type,
): ConstraintRegistryEntry<Type> {
return CONSTRAINT_REGISTRY[type] as unknown as ConstraintRegistryEntry<Type>;
}
export function constraintLabel(type: string): string {
if (isConstraintType(type)) return CONSTRAINT_REGISTRY[type].label;
return type
.split("-")
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
.join(" ");
}
export function constraintAllowedFields(
type: ConstraintType,
): ReadonlySet<string> {
return new Set(CONSTRAINT_REGISTRY[type].fields.map(({ key }) => key));
}
export function constraintCells(
size: number,
constraint: VariantConstraint,
): readonly CellId[] {
const resolver = CONSTRAINT_REGISTRY[constraint.type].cells as (
size: number,
constraint: never,
) => readonly CellId[];
return [...new Set(resolver(size, constraint as never))];
}
+176 -7
View File
@@ -3,6 +3,8 @@ import {
MAX_PUZZLE_SIZE,
MIN_PUZZLE_SIZE,
type CellId,
type LittleKillerDirection,
type OutsideClueSide,
type PuzzleDefinition,
} from "./types";
@@ -46,15 +48,17 @@ export function assertCell(size: number, cell: CellId): void {
}
}
/**
* Builds conventional rectangular regions. For non-composite sizes this falls
* back to 1 x size regions, which is still a valid Latin-square topology.
*/
export function classicRegions(
export interface ClassicBoxDimensions {
readonly rows: number;
readonly columns: number;
readonly boxesPerRow: number;
}
export function classicBoxDimensions(
size: number,
boxRows?: number,
boxColumns?: number,
): number[] {
): ClassicBoxDimensions {
assertSize(size);
let rows = boxRows;
let columns = boxColumns;
@@ -84,8 +88,24 @@ export function classicRegions(
"Box rows and columns must be positive factors whose product is size.",
);
}
return { rows, columns, boxesPerRow: size / columns };
}
/**
* Builds conventional rectangular regions. For non-composite sizes this falls
* back to 1 x size regions, which is still a valid Latin-square topology.
*/
export function classicRegions(
size: number,
boxRows?: number,
boxColumns?: number,
): number[] {
const { rows, columns, boxesPerRow } = classicBoxDimensions(
size,
boxRows,
boxColumns,
);
const regions = new Array<number>(size * size);
const boxesPerRow = size / columns;
for (let row = 0; row < size; row += 1) {
for (let column = 0; column < size; column += 1) {
regions[row * size + column] =
@@ -95,6 +115,52 @@ export function classicRegions(
return regions;
}
export function classicBoxIndex(size: number, cell: CellId): number {
assertCell(size, cell);
const { rows, columns, boxesPerRow } = classicBoxDimensions(size);
return (
Math.floor(cellRow(size, cell) / rows) * boxesPerRow +
Math.floor(cellColumn(size, cell) / columns)
);
}
export function classicBoxPosition(size: number, cell: CellId): number {
assertCell(size, cell);
const { rows, columns } = classicBoxDimensions(size);
return (
(cellRow(size, cell) % rows) * columns + (cellColumn(size, cell) % columns)
);
}
export function classicBoxCell(
size: number,
boxIndex: number,
position: number,
): CellId {
const { rows, columns, boxesPerRow } = classicBoxDimensions(size);
if (!Number.isInteger(boxIndex) || boxIndex < 0 || boxIndex >= size) {
throw new RangeError("Box index is outside the grid.");
}
if (!Number.isInteger(position) || position < 0 || position >= size) {
throw new RangeError("Box position is outside the grid.");
}
const boxRow = Math.floor(boxIndex / boxesPerRow);
const boxColumn = boxIndex % boxesPerRow;
const row = boxRow * rows + Math.floor(position / columns);
const column = boxColumn * columns + (position % columns);
return cellId(size, row, column);
}
export function correspondingBoxPositionCells(
size: number,
cell: CellId,
): CellId[] {
const position = classicBoxPosition(size, cell);
return Array.from({ length: size }, (_, box) =>
classicBoxCell(size, box, position),
);
}
export function createEmptyPuzzle(
size = 9,
options: {
@@ -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
View File
@@ -1,4 +1,5 @@
export * from "./compile";
export * from "./constraintRegistry";
export * from "./geometry";
export * from "./rules";
export * from "./types";
+853 -30
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+247
View File
@@ -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 062 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);
}
+409
View File
@@ -0,0 +1,409 @@
import { littleKillerCells } from "../domain/geometry";
import type {
PortableConstraint,
SafeVisualAnchor,
SafeVisualPrimitive,
SafeVisualStyle,
SudokuDocument,
} from "./types";
const lineStyle = (
stroke: string,
strokeWidth: number,
opacity = 1,
): SafeVisualStyle => ({ stroke, fill: "transparent", strokeWidth, opacity });
const cell = (cellIndex: number): SafeVisualAnchor => ({
kind: "cell",
cell: cellIndex,
});
const coordinate = (x: number, y: number): SafeVisualAnchor => ({
kind: "coordinate",
x,
y,
});
function cellPoint(size: number, cellIndex: number) {
return {
x: (cellIndex % size) + 0.5,
y: Math.floor(cellIndex / size) + 0.5,
};
}
function average(size: number, cells: readonly number[]): SafeVisualAnchor {
const points = cells.map((entry) => cellPoint(size, entry));
return coordinate(
points.reduce((sum, point) => sum + point.x, 0) / points.length,
points.reduce((sum, point) => sum + point.y, 0) / points.length,
);
}
function midpoint(size: number, a: number, b: number): SafeVisualAnchor {
return average(size, [a, b]);
}
function outsideAnchor(
size: number,
side: "top" | "right" | "bottom" | "left",
index: number,
): SafeVisualAnchor {
switch (side) {
case "top":
return coordinate(index + 0.5, -0.45);
case "right":
return coordinate(size + 0.45, index + 0.5);
case "bottom":
return coordinate(index + 0.5, size + 0.45);
case "left":
return coordinate(-0.45, index + 0.5);
}
}
function polyline(
cells: readonly number[],
style: SafeVisualStyle,
layer: "underlay" | "overlay" = "underlay",
): SafeVisualPrimitive {
return { type: "polyline", layer, points: cells.map(cell), style };
}
function text(
position: SafeVisualAnchor,
value: string,
fontSize = 0.34,
): SafeVisualPrimitive {
return {
type: "text",
layer: "overlay",
position,
text: value,
style: { fill: "#172033", fontSize, opacity: 1 },
};
}
function renderConstraint(
constraint: PortableConstraint,
size: number,
): SafeVisualPrimitive[] {
switch (constraint.type) {
case "anti-knight":
case "anti-king":
case "non-consecutive":
case "disjoint-groups":
case "fog":
return [];
case "diagonal":
return [
{
type: "line",
layer: "underlay",
start: coordinate(constraint.direction === "main" ? 0 : size, 0),
end: coordinate(constraint.direction === "main" ? size : 0, size),
style: lineStyle("#34bbe6", 0.04),
},
];
case "killer-cage":
// SCL has a native cage representation; it is emitted separately.
return [];
case "thermo":
return [
polyline(constraint.cells, lineStyle("#cfcfcf", 0.32)),
{
type: "circle",
layer: "underlay",
center: cell(constraint.cells[0]!),
radius: 0.42,
style: {
fill: "#cfcfcf",
stroke: "#cfcfcf",
strokeWidth: 0.03,
},
},
];
case "arrow": {
const start = constraint.bulb.at(-1)!;
return [
{
type: "circle",
layer: "underlay",
center: average(size, constraint.bulb),
radius: Math.max(0.38, Math.sqrt(constraint.bulb.length) * 0.3),
style: {
fill: "#ffffff",
stroke: "#a1a1a1",
strokeWidth: 0.07,
},
},
polyline([start, ...constraint.line], lineStyle("#a1a1a1", 0.07)),
];
}
case "kropki":
return [
{
type: "circle",
layer: "overlay",
center: midpoint(size, constraint.a, constraint.b),
radius: 0.12,
style: {
fill: constraint.kind === "black" ? "#000000" : "#ffffff",
stroke: "#000000",
strokeWidth: 0.025,
},
},
];
case "xv": {
const position = midpoint(size, constraint.a, constraint.b);
return [
{
type: "circle",
layer: "overlay",
center: position,
radius: 0.2,
style: { fill: "#ffffff", stroke: "#ffffff", strokeWidth: 0.01 },
},
text(position, constraint.total === 5 ? "V" : "X", 0.3),
];
}
case "inequality": {
const lesser = cellPoint(size, constraint.lesser);
const greater = cellPoint(size, constraint.greater);
const center = {
x: (lesser.x + greater.x) / 2,
y: (lesser.y + greater.y) / 2,
};
const dx = greater.x - lesser.x;
const dy = greater.y - lesser.y;
const distance = Math.hypot(dx, dy) || 1;
const ux = dx / distance;
const uy = dy / distance;
const px = -uy;
const py = ux;
return [
{
type: "polyline",
layer: "overlay",
points: [
coordinate(
center.x + ux * 0.18 + px * 0.16,
center.y + uy * 0.18 + py * 0.16,
),
coordinate(center.x - ux * 0.18, center.y - uy * 0.18),
coordinate(
center.x + ux * 0.18 - px * 0.16,
center.y + uy * 0.18 - py * 0.16,
),
],
style: lineStyle("#172033", 0.05),
},
];
}
case "renban":
return [polyline(constraint.cells, lineStyle("#b55b8c", 0.2, 0.72))];
case "palindrome":
return [
polyline(constraint.cells, lineStyle("#cfcfcf", 0.2)),
...constraint.cells.map((entry): SafeVisualPrimitive => ({
type: "circle",
layer: "underlay",
center: cell(entry),
radius: 0.13,
style: { fill: "#d8dbe1", stroke: "transparent" },
})),
];
case "x-sum":
return [
text(
outsideAnchor(size, constraint.side, constraint.index),
`Σ ${String(constraint.sum)}`,
),
];
case "skyscraper":
return [
text(
outsideAnchor(size, constraint.side, constraint.index),
`${String(constraint.count)}`,
),
];
case "quadruple": {
const position = average(size, constraint.cells);
return [
{
type: "circle",
layer: "overlay",
center: position,
radius: 0.29,
style: { fill: "#ffffff", stroke: "#596273", strokeWidth: 0.03 },
},
text(position, constraint.digits.join(""), 0.23),
];
}
case "maximum":
return [text(cell(constraint.cell), "◆", 0.34)];
case "minimum":
return [text(cell(constraint.cell), "◇", 0.34)];
case "odd":
return [
{
type: "circle",
layer: "underlay",
center: cell(constraint.cell),
radius: 0.19,
style: { fill: "#cfcfcf", stroke: "transparent" },
},
];
case "even":
return [
{
type: "rectangle",
layer: "underlay",
center: cell(constraint.cell),
width: 0.38,
height: 0.38,
style: { fill: "#cfcfcf", stroke: "transparent" },
},
];
case "little-killer": {
const clue = outsideAnchor(size, constraint.side, constraint.index);
const first = littleKillerCells(
size,
constraint.side,
constraint.index,
constraint.direction,
)[0];
return [
text(clue, String(constraint.sum)),
...(first === undefined
? []
: [
{
type: "line" as const,
layer: "overlay" as const,
start: clue,
end: cell(first),
style: lineStyle("#172033", 0.03),
},
]),
];
}
case "sandwich":
return [
text(
outsideAnchor(size, constraint.side, constraint.index),
`1⋯N ${String(constraint.sum)}`,
),
];
case "between-line":
return [
polyline(constraint.cells, lineStyle("#7e8796", 0.07)),
...[constraint.cells[0]!, constraint.cells.at(-1)!].map(
(entry): SafeVisualPrimitive => ({
type: "circle",
layer: "underlay",
center: cell(entry),
radius: 0.27,
style: { fill: "#ffffff", stroke: "#7e8796", strokeWidth: 0.06 },
}),
),
];
case "german-whisper":
return [polyline(constraint.cells, lineStyle("#4d9b69", 0.14))];
case "region-sum-line":
return [polyline(constraint.cells, lineStyle("#4a94a3", 0.1))];
case "clone":
return [...constraint.cells, ...constraint.cloneCells].map(
(entry): SafeVisualPrimitive => ({
type: "rectangle",
layer: "underlay",
center: cell(entry),
width: 0.88,
height: 0.88,
style: {
fill: "#6d62b533",
stroke: "#6d62b5",
strokeWidth: 0.025,
},
}),
);
case "extra-region":
return constraint.cells.map((entry): SafeVisualPrimitive => ({
type: "rectangle",
layer: "underlay",
center: cell(entry),
width: 0.9,
height: 0.9,
style: {
fill: "transparent",
stroke: "#6d62b5",
strokeWidth: 0.04,
},
}));
case "modular-line":
return [polyline(constraint.cells, lineStyle("#167f8f", 0.14))];
case "entropic-line":
return [polyline(constraint.cells, lineStyle("#d47742", 0.14))];
case "zipper-line":
return [
polyline(constraint.cells, lineStyle("#7b60ad", 0.1)),
{
type: "circle",
layer: "underlay",
center: cell(
constraint.cells[Math.floor(constraint.cells.length / 2)]!,
),
radius: 0.22,
style: { fill: "#ffffff", stroke: "#7b60ad", strokeWidth: 0.06 },
},
];
case "double-arrow":
return [
polyline(constraint.cells, lineStyle("#596273", 0.07)),
...[constraint.cells[0]!, constraint.cells.at(-1)!].map(
(entry): SafeVisualPrimitive => ({
type: "circle",
layer: "underlay",
center: cell(entry),
radius: 0.22,
style: { fill: "#ffffff", stroke: "#596273", strokeWidth: 0.05 },
}),
),
];
case "indexer":
return [
{
type: "rectangle",
layer: "overlay",
center: cell(constraint.cell),
width: 0.48,
height: 0.48,
cornerRadius: 0.1,
style: {
fill: "#ffffff",
stroke:
constraint.kind === "row"
? "#287fba"
: constraint.kind === "column"
? "#b94c50"
: "#438b58",
strokeWidth: 0.03,
},
},
text(
cell(constraint.cell),
constraint.kind === "row"
? "R"
: constraint.kind === "column"
? "C"
: "B",
0.24,
),
];
}
}
/** Canonical, inert visual equivalents for every shape-based domain clue. */
export function canonicalConstraintVisuals(
document: Pick<SudokuDocument, "size" | "constraints">,
): SafeVisualPrimitive[] {
return document.constraints.flatMap((constraint) =>
renderConstraint(constraint, document.size),
);
}
+369 -2
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -69,6 +69,7 @@ export function parsePlainGrid(
size,
givens,
constraints: [],
source: { format: "plain-grid" },
...(options.title === undefined ? {} : { title: options.title }),
...(options.author === undefined ? {} : { author: options.author }),
};
+174
View File
@@ -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",
);
}
+7
View File
@@ -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";
+154
View File
@@ -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";
}
}
+582
View File
@@ -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))),
);
}
+407
View File
@@ -0,0 +1,407 @@
import type {
SafeVisualAnchor,
SafeVisualPrimitive,
SafeVisualStyle,
ScalarMetadata,
ScalarMetadataValue,
SudokuSourceFormat,
SudokuSourceIdentity,
} from "./types";
export const MAX_VISUAL_PRIMITIVES = 2_000;
export const MAX_VISUAL_POINTS = 20_000;
export const MAX_VISUAL_TEXT_LENGTH = 4_096;
export const MAX_SCALAR_METADATA_FIELDS = 128;
export const MAX_SCALAR_METADATA_KEY_LENGTH = 96;
export const MAX_SCALAR_METADATA_TEXT_LENGTH = 8_192;
const SOURCE_FORMATS = new Set<SudokuSourceFormat>([
"sudoku-tools",
"plain-grid",
"fpuzzles",
"sudokupad",
"penpa",
]);
const EXECUTABLE_KEY =
/^(?:on[a-z]+|script|javascript|html|svg|css|style|src|href|url|code|customcode|customstyle)$/iu;
const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/iu;
const NAMED_COLORS: Readonly<Record<string, string>> = {
black: "#000000",
white: "#ffffff",
transparent: "transparent",
none: "transparent",
};
export class SafeVisualValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "SafeVisualValidationError";
}
}
function fail(message: string): never {
throw new SafeVisualValidationError(message);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function exactFields(
value: Record<string, unknown>,
allowed: ReadonlySet<string>,
label: string,
): void {
const invalid = Object.keys(value).find((key) => !allowed.has(key));
if (invalid !== undefined) {
fail(`${label}.${invalid} is not an allowlisted visual property.`);
}
}
function finite(
value: unknown,
label: string,
minimum: number,
maximum: number,
): number {
if (
typeof value !== "number" ||
!Number.isFinite(value) ||
value < minimum ||
value > maximum
) {
return fail(
`${label} must be a finite number from ${minimum} to ${maximum}.`,
);
}
return value;
}
/** Accept only inert hexadecimal tokens (or transparent), never CSS syntax. */
export function normalizeSafeVisualColor(
value: unknown,
label = "visual colour",
): string {
if (typeof value !== "string")
return fail(`${label} must be a colour token.`);
const trimmed = value.trim().toLowerCase();
const named = NAMED_COLORS[trimmed];
if (named !== undefined) return named;
if (!HEX_COLOR.test(trimmed)) {
return fail(
`${label} must be a hexadecimal colour or transparent; CSS expressions are not accepted.`,
);
}
if (trimmed.length === 4 || trimmed.length === 5) {
return `#${[...trimmed.slice(1)].map((digit) => digit + digit).join("")}`;
}
return trimmed;
}
function normalizeAnchor(
value: unknown,
size: number,
label: string,
): SafeVisualAnchor {
if (!isRecord(value)) return fail(`${label} must be a visual anchor.`);
if (value.kind === "coordinate") {
exactFields(value, new Set(["kind", "x", "y"]), label);
return {
kind: "coordinate",
x: finite(value.x, `${label}.x`, -4, size + 4),
y: finite(value.y, `${label}.y`, -4, size + 4),
};
}
if (value.kind === "cell") {
exactFields(value, new Set(["kind", "cell", "offsetX", "offsetY"]), label);
if (
!Number.isInteger(value.cell) ||
(value.cell as number) < 0 ||
(value.cell as number) >= size * size
) {
return fail(`${label}.cell is outside the grid.`);
}
return {
kind: "cell",
cell: value.cell as number,
...(value.offsetX === undefined
? {}
: { offsetX: finite(value.offsetX, `${label}.offsetX`, -4, 4) }),
...(value.offsetY === undefined
? {}
: { offsetY: finite(value.offsetY, `${label}.offsetY`, -4, 4) }),
};
}
return fail(`${label}.kind must be coordinate or cell.`);
}
function normalizeStyle(
value: unknown,
label: string,
): SafeVisualStyle | undefined {
if (value === undefined) return undefined;
if (!isRecord(value)) return fail(`${label} must be a visual style object.`);
exactFields(
value,
new Set(["stroke", "fill", "strokeWidth", "opacity", "fontSize"]),
label,
);
return {
...(value.stroke === undefined
? {}
: { stroke: normalizeSafeVisualColor(value.stroke, `${label}.stroke`) }),
...(value.fill === undefined
? {}
: { fill: normalizeSafeVisualColor(value.fill, `${label}.fill`) }),
...(value.strokeWidth === undefined
? {}
: {
strokeWidth: finite(value.strokeWidth, `${label}.strokeWidth`, 0, 4),
}),
...(value.opacity === undefined
? {}
: { opacity: finite(value.opacity, `${label}.opacity`, 0, 1) }),
...(value.fontSize === undefined
? {}
: { fontSize: finite(value.fontSize, `${label}.fontSize`, 0.05, 8) }),
};
}
function layer(value: unknown, label: string): "underlay" | "overlay" {
if (value !== "underlay" && value !== "overlay") {
return fail(`${label} must be underlay or overlay.`);
}
return value;
}
function primitive(
value: unknown,
size: number,
index: number,
): SafeVisualPrimitive {
const label = `visuals[${String(index)}]`;
if (!isRecord(value) || typeof value.type !== "string") {
return fail(`${label} must be a typed visual primitive.`);
}
const common = {
layer: layer(value.layer, `${label}.layer`),
...(value.style === undefined
? {}
: { style: normalizeStyle(value.style, `${label}.style`)! }),
} as const;
switch (value.type) {
case "line":
exactFields(
value,
new Set(["type", "layer", "style", "start", "end"]),
label,
);
return {
type: "line",
...common,
start: normalizeAnchor(value.start, size, `${label}.start`),
end: normalizeAnchor(value.end, size, `${label}.end`),
};
case "polyline": {
exactFields(
value,
new Set(["type", "layer", "style", "points", "closed"]),
label,
);
if (
!Array.isArray(value.points) ||
value.points.length < 2 ||
value.points.length > MAX_VISUAL_POINTS
) {
return fail(
`${label}.points must contain 2 to ${MAX_VISUAL_POINTS} anchors.`,
);
}
if (value.closed !== undefined && typeof value.closed !== "boolean") {
return fail(`${label}.closed must be true or false.`);
}
return {
type: "polyline",
...common,
points: value.points.map((point, pointIndex) =>
normalizeAnchor(
point,
size,
`${label}.points[${String(pointIndex)}]`,
),
),
...(value.closed === true ? { closed: true } : {}),
};
}
case "rectangle":
exactFields(
value,
new Set([
"type",
"layer",
"style",
"center",
"width",
"height",
"cornerRadius",
]),
label,
);
return {
type: "rectangle",
...common,
center: normalizeAnchor(value.center, size, `${label}.center`),
width: finite(value.width, `${label}.width`, 0.01, size + 8),
height: finite(value.height, `${label}.height`, 0.01, size + 8),
...(value.cornerRadius === undefined
? {}
: {
cornerRadius: finite(
value.cornerRadius,
`${label}.cornerRadius`,
0,
size + 8,
),
}),
};
case "ellipse":
exactFields(
value,
new Set(["type", "layer", "style", "center", "radiusX", "radiusY"]),
label,
);
return {
type: "ellipse",
...common,
center: normalizeAnchor(value.center, size, `${label}.center`),
radiusX: finite(value.radiusX, `${label}.radiusX`, 0.005, size + 4),
radiusY: finite(value.radiusY, `${label}.radiusY`, 0.005, size + 4),
};
case "circle":
exactFields(
value,
new Set(["type", "layer", "style", "center", "radius"]),
label,
);
return {
type: "circle",
...common,
center: normalizeAnchor(value.center, size, `${label}.center`),
radius: finite(value.radius, `${label}.radius`, 0.005, size + 4),
};
case "text":
exactFields(
value,
new Set(["type", "layer", "style", "position", "text"]),
label,
);
if (
typeof value.text !== "string" ||
value.text.length > MAX_VISUAL_TEXT_LENGTH
) {
return fail(
`${label}.text must be text of at most ${MAX_VISUAL_TEXT_LENGTH} characters.`,
);
}
return {
type: "text",
...common,
position: normalizeAnchor(value.position, size, `${label}.position`),
text: value.text,
};
default:
return fail(`${label}.type is not an allowlisted visual primitive.`);
}
}
export function normalizeSafeVisualPrimitives(
value: unknown,
size: number,
): SafeVisualPrimitive[] | undefined {
if (value === undefined) return undefined;
if (!Array.isArray(value) || value.length > MAX_VISUAL_PRIMITIVES) {
return fail(
`visuals must contain at most ${MAX_VISUAL_PRIMITIVES} primitives.`,
);
}
const result = value.map((entry, index) => primitive(entry, size, index));
const pointCount = result.reduce((count, entry) => {
if (entry.type === "polyline") return count + entry.points.length;
if (entry.type === "line") return count + 2;
return count + 1;
}, 0);
if (pointCount > MAX_VISUAL_POINTS) {
return fail(`visuals contain more than ${MAX_VISUAL_POINTS} anchors.`);
}
return result;
}
export function normalizeSourceIdentity(
value: unknown,
): SudokuSourceIdentity | undefined {
if (value === undefined) return undefined;
if (!isRecord(value)) return fail("source must be an identity object.");
exactFields(value, new Set(["format", "id", "version"]), "source");
if (
typeof value.format !== "string" ||
!SOURCE_FORMATS.has(value.format as SudokuSourceFormat)
) {
return fail("source.format is not supported.");
}
const text = (entry: unknown, label: string): string | undefined => {
if (entry === undefined) return undefined;
if (typeof entry !== "string" || entry.length > 512) {
return fail(`${label} must be bounded text.`);
}
return entry;
};
const id = text(value.id, "source.id");
const version = text(value.version, "source.version");
return {
format: value.format as SudokuSourceFormat,
...(id === undefined ? {} : { id }),
...(version === undefined ? {} : { version }),
};
}
function scalarMetadataValue(
value: unknown,
label: string,
): ScalarMetadataValue {
if (value === null || typeof value === "boolean") return value;
if (typeof value === "number" && Number.isFinite(value)) return value;
if (
typeof value === "string" &&
value.length <= MAX_SCALAR_METADATA_TEXT_LENGTH
)
return value;
return fail(`${label} must be a bounded JSON scalar.`);
}
export function normalizeScalarMetadata(
value: unknown,
): ScalarMetadata | undefined {
if (value === undefined) return undefined;
if (!isRecord(value)) return fail("metadata must be a scalar object.");
const entries = Object.entries(value);
if (entries.length > MAX_SCALAR_METADATA_FIELDS) {
return fail(
`metadata must contain at most ${MAX_SCALAR_METADATA_FIELDS} fields.`,
);
}
const output: Record<string, ScalarMetadataValue> = {};
for (const [key, raw] of entries) {
if (
key.length === 0 ||
key.length > MAX_SCALAR_METADATA_KEY_LENGTH ||
EXECUTABLE_KEY.test(key) ||
key === "__proto__" ||
key === "prototype" ||
key === "constructor"
) {
return fail(`metadata key “${key}” is not accepted.`);
}
output[key] = scalarMetadataValue(raw, `metadata.${key}`);
}
return output;
}
+24 -11
View File
@@ -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
View File
@@ -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,
+866
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
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" });
}
+383
View File
@@ -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)}`;
}
+2
View File
@@ -1,3 +1,5 @@
export * from "./candidates";
export * from "./killer";
export * from "./relations";
export * from "./residual";
export * from "./sumLab";
+373
View File
@@ -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 }),
};
}
+14
View File
@@ -7,3 +7,17 @@ createRoot(document.getElementById("root")!).render(
<App />
</StrictMode>,
);
if ("serviceWorker" in navigator && import.meta.env.PROD) {
window.addEventListener("load", () => {
const url = new URL("./sw.js", document.baseURI);
void navigator.serviceWorker
.register(url, {
scope: new URL("./", document.baseURI).pathname,
})
.catch(() => {
// Offline support is progressive enhancement; the workbench remains
// fully usable when a host or private browsing mode blocks workers.
});
});
}
+954
View File
@@ -0,0 +1,954 @@
import type { CellId, SudokuUnit } from "../domain";
import type {
LogicalElimination,
LogicalStep,
LogicalTechnique,
} from "./logical";
export interface AdvancedLogicalContext {
readonly size: number;
readonly values: readonly number[];
/** Candidate masks use bit `1 << digit`, matching the logical solver. */
readonly masks: readonly number[];
readonly regions: readonly number[];
readonly peers: readonly ReadonlySet<CellId>[];
readonly units: readonly SudokuUnit[];
/** Must come from a completed uniqueness proof; false/omitted is the default. */
readonly uniquenessProven?: boolean;
/** Extra constraints can invalidate uniqueness-pattern swap arguments. */
readonly uniquenessPatternsSafe?: boolean;
}
const MAX_CHAIN_NODES = 10;
const MAX_CHAIN_VISITS = 200_000;
function digitBit(value: number): number {
return 1 << value;
}
function popcount(mask: number): number {
let value = mask >>> 0;
let count = 0;
while (value !== 0) {
value &= value - 1;
count += 1;
}
return count;
}
function maskDigits(mask: number, size: number): number[] {
const result: number[] = [];
for (let value = 1; value <= size; value += 1) {
if ((mask & digitBit(value)) !== 0) result.push(value);
}
return result;
}
function hasCandidate(
context: AdvancedLogicalContext,
cell: CellId,
value: number,
): boolean {
return (
context.values[cell] === 0 &&
((context.masks[cell] ?? 0) & digitBit(value)) !== 0
);
}
function combinations<T>(values: readonly T[], count: number): T[][] {
const result: T[][] = [];
const selected: T[] = [];
const visit = (start: number): void => {
if (selected.length === count) {
result.push([...selected]);
return;
}
for (
let index = start;
index <= values.length - (count - selected.length);
index += 1
) {
const value = values[index];
if (value === undefined) continue;
selected.push(value);
visit(index + 1);
selected.pop();
}
};
visit(0);
return result;
}
function uniqueSorted(values: Iterable<number>): number[] {
return [...new Set(values)].sort((a, b) => a - b);
}
function step(
technique: LogicalTechnique,
eliminations: readonly LogicalElimination[],
focusCells: Iterable<CellId>,
explanation: string,
): LogicalStep | undefined {
if (eliminations.length === 0) return undefined;
const byCell = new Map<number, Set<number>>();
for (const elimination of eliminations) {
const values = byCell.get(elimination.cell) ?? new Set<number>();
for (const value of elimination.values) values.add(value);
byCell.set(elimination.cell, values);
}
return {
technique,
placements: [],
eliminations: [...byCell]
.sort(([a], [b]) => a - b)
.map(([cell, values]) => ({
cell,
values: [...values].sort((a, b) => a - b),
})),
focusCells: uniqueSorted(focusCells),
explanation,
};
}
function unitCandidates(
context: AdvancedLogicalContext,
unit: SudokuUnit,
value: number,
): number[] {
return unit.cells.filter((cell) => hasCandidate(context, cell, value));
}
function lineUnits(
context: AdvancedLogicalContext,
kind: "row" | "column",
): SudokuUnit[] {
return context.units
.filter((unit) => unit.kind === kind)
.sort((a, b) => a.index - b.index);
}
function baseIndex(
context: AdvancedLogicalContext,
kind: "row" | "column",
cell: CellId,
): number {
return kind === "row" ? Math.floor(cell / context.size) : cell % context.size;
}
function coverIndex(
context: AdvancedLogicalContext,
kind: "row" | "column",
cell: CellId,
): number {
return kind === "row" ? cell % context.size : Math.floor(cell / context.size);
}
function commonPeers(
context: AdvancedLogicalContext,
cells: readonly CellId[],
): Set<CellId> {
const first = cells[0];
if (first === undefined) return new Set<CellId>();
const result = new Set(context.peers[first] ?? []);
for (const cell of cells.slice(1)) {
for (const candidate of result) {
if (!(context.peers[cell]?.has(candidate) ?? false)) {
result.delete(candidate);
}
}
}
for (const cell of cells) result.delete(cell);
return result;
}
function commonPeerEliminations(
context: AdvancedLogicalContext,
endpoints: readonly CellId[],
value: number,
excluded: ReadonlySet<CellId> = new Set(),
): LogicalElimination[] {
return [...commonPeers(context, endpoints)]
.filter((cell) => !excluded.has(cell) && hasCandidate(context, cell, value))
.sort((a, b) => a - b)
.map((cell) => ({ cell, values: [value] }));
}
export function findJellyfish(
context: AdvancedLogicalContext,
): LogicalStep | undefined {
const count = 4;
for (const baseKind of ["row", "column"] as const) {
const bases = lineUnits(context, baseKind);
for (let value = 1; value <= context.size; value += 1) {
const eligible = bases.filter((unit) => {
const total = unitCandidates(context, unit, value).length;
return total >= 2 && total <= count;
});
for (const selected of combinations(eligible, count)) {
const covers = new Set<number>();
const focus: number[] = [];
for (const unit of selected) {
for (const cell of unitCandidates(context, unit, value)) {
covers.add(coverIndex(context, baseKind, cell));
focus.push(cell);
}
}
if (covers.size !== count) continue;
const selectedBases = new Set(selected.map((unit) => unit.index));
const eliminations: LogicalElimination[] = [];
for (let cell = 0; cell < context.values.length; cell += 1) {
if (
!selectedBases.has(baseIndex(context, baseKind, cell)) &&
covers.has(coverIndex(context, baseKind, cell)) &&
hasCandidate(context, cell, value)
) {
eliminations.push({ cell, values: [value] });
}
}
const found = step(
"jellyfish",
eliminations,
focus,
`${value} is confined to four cover lines across four ${baseKind}s, forming a Jellyfish.`,
);
if (found !== undefined) return found;
}
}
}
return undefined;
}
function findFinnedFishOfSize(
context: AdvancedLogicalContext,
count: 2 | 3,
): LogicalStep | undefined {
for (const baseKind of ["row", "column"] as const) {
const bases = lineUnits(context, baseKind);
for (let value = 1; value <= context.size; value += 1) {
const eligible = bases.filter((unit) => {
const total = unitCandidates(context, unit, value).length;
return total >= 2 && total <= count + 2;
});
for (const selected of combinations(eligible, count)) {
const allCovers = uniqueSorted(
selected.flatMap((unit) =>
unitCandidates(context, unit, value).map((cell) =>
coverIndex(context, baseKind, cell),
),
),
);
// One or two extra cover lines provide ordinary fins without allowing
// an unbounded cover-subset search on large grids.
if (allCovers.length < count + 1 || allCovers.length > count + 2) {
continue;
}
for (const covers of combinations(allCovers, count)) {
const coverSet = new Set(covers);
for (const finBase of selected) {
let compatible = true;
const bodyCells: number[] = [];
const finCells: number[] = [];
for (const unit of selected) {
for (const cell of unitCandidates(context, unit, value)) {
if (coverSet.has(coverIndex(context, baseKind, cell))) {
bodyCells.push(cell);
} else if (unit.index === finBase.index) {
finCells.push(cell);
} else {
compatible = false;
}
}
}
if (!compatible || finCells.length === 0) continue;
if (
selected.some(
(unit) =>
unitCandidates(context, unit, value).filter((cell) =>
coverSet.has(coverIndex(context, baseKind, cell)),
).length === 0,
)
) {
continue;
}
const usedBodyCovers = new Set(
bodyCells.map((cell) => coverIndex(context, baseKind, cell)),
);
if (usedBodyCovers.size !== count) continue;
const finRegion = context.regions[finCells[0] as number];
if (
finRegion === undefined ||
finCells.some((cell) => context.regions[cell] !== finRegion)
) {
continue;
}
const baseSet = new Set(selected.map((unit) => unit.index));
const finPeers = commonPeers(context, finCells);
const eliminations: LogicalElimination[] = [];
for (let cell = 0; cell < context.values.length; cell += 1) {
if (
!baseSet.has(baseIndex(context, baseKind, cell)) &&
coverSet.has(coverIndex(context, baseKind, cell)) &&
finPeers.has(cell) &&
hasCandidate(context, cell, value)
) {
eliminations.push({ cell, values: [value] });
}
}
const technique: LogicalTechnique =
count === 2 ? "finned-x-wing" : "finned-swordfish";
const found = step(
technique,
eliminations,
[...bodyCells, ...finCells],
`${value} forms a ${count === 2 ? "Finned X-Wing" : "Finned Swordfish"}; the fin and fish body both eliminate it in their shared region.`,
);
if (found !== undefined) return found;
}
}
}
}
}
return undefined;
}
export function findFinnedFish(
context: AdvancedLogicalContext,
): LogicalStep | undefined {
return findFinnedFishOfSize(context, 2) ?? findFinnedFishOfSize(context, 3);
}
export function findSkyscraper(
context: AdvancedLogicalContext,
): LogicalStep | undefined {
for (const baseKind of ["row", "column"] as const) {
const bases = lineUnits(context, baseKind);
for (let value = 1; value <= context.size; value += 1) {
const strongLines = bases
.map((unit) => ({ unit, cells: unitCandidates(context, unit, value) }))
.filter(({ cells }) => cells.length === 2);
for (const pair of combinations(strongLines, 2)) {
const first = pair[0];
const second = pair[1];
if (first === undefined || second === undefined) continue;
for (let firstBase = 0; firstBase < 2; firstBase += 1) {
for (let secondBase = 0; secondBase < 2; secondBase += 1) {
const aBase = first.cells[firstBase];
const bBase = second.cells[secondBase];
const aRoof = first.cells[1 - firstBase];
const bRoof = second.cells[1 - secondBase];
if (
aBase === undefined ||
bBase === undefined ||
aRoof === undefined ||
bRoof === undefined ||
coverIndex(context, baseKind, aBase) !==
coverIndex(context, baseKind, bBase)
) {
continue;
}
const pattern = new Set([aBase, bBase, aRoof, bRoof]);
if (pattern.size !== 4) continue;
const found = step(
"skyscraper",
commonPeerEliminations(context, [aRoof, bRoof], value, pattern),
pattern,
`${value} forms a Skyscraper: at least one of the two roof cells must contain it.`,
);
if (found !== undefined) return found;
}
}
}
}
}
return undefined;
}
export function findTwoStringKite(
context: AdvancedLogicalContext,
): LogicalStep | undefined {
const rows = lineUnits(context, "row");
const columns = lineUnits(context, "column");
for (let value = 1; value <= context.size; value += 1) {
const rowLinks = rows
.map((unit) => ({ unit, cells: unitCandidates(context, unit, value) }))
.filter(({ cells }) => cells.length === 2);
const columnLinks = columns
.map((unit) => ({ unit, cells: unitCandidates(context, unit, value) }))
.filter(({ cells }) => cells.length === 2);
for (const row of rowLinks) {
for (const column of columnLinks) {
for (let rowBaseIndex = 0; rowBaseIndex < 2; rowBaseIndex += 1) {
for (
let columnBaseIndex = 0;
columnBaseIndex < 2;
columnBaseIndex += 1
) {
const rowBase = row.cells[rowBaseIndex];
const columnBase = column.cells[columnBaseIndex];
const rowRoof = row.cells[1 - rowBaseIndex];
const columnRoof = column.cells[1 - columnBaseIndex];
if (
rowBase === undefined ||
columnBase === undefined ||
rowRoof === undefined ||
columnRoof === undefined
) {
continue;
}
const pattern = new Set([rowBase, columnBase, rowRoof, columnRoof]);
if (
pattern.size !== 4 ||
context.regions[rowBase] !== context.regions[columnBase]
) {
continue;
}
const found = step(
"two-string-kite",
commonPeerEliminations(
context,
[rowRoof, columnRoof],
value,
pattern,
),
pattern,
`${value} forms a Two-String Kite through conjugate row and column links.`,
);
if (found !== undefined) return found;
}
}
}
}
}
return undefined;
}
interface CellStrongLink {
readonly a: CellId;
readonly b: CellId;
readonly value: number;
}
function cellStrongLinks(
context: AdvancedLogicalContext,
requestedValue?: number,
): CellStrongLink[] {
const links = new Map<string, CellStrongLink>();
const first = requestedValue ?? 1;
const last = requestedValue ?? context.size;
for (let value = first; value <= last; value += 1) {
for (const unit of context.units) {
const cells = unitCandidates(context, unit, value);
if (cells.length !== 2) continue;
const a = Math.min(cells[0] as number, cells[1] as number);
const b = Math.max(cells[0] as number, cells[1] as number);
links.set(`${value}:${a}:${b}`, { a, b, value });
}
}
return [...links.values()].sort(
(a, b) => a.value - b.value || a.a - b.a || a.b - b.b,
);
}
function strongCellAdjacency(
context: AdvancedLogicalContext,
value: number,
): readonly ReadonlySet<CellId>[] {
const adjacency = Array.from(
{ length: context.values.length },
() => new Set<CellId>(),
);
for (const link of cellStrongLinks(context, value)) {
adjacency[link.a]?.add(link.b);
adjacency[link.b]?.add(link.a);
}
return adjacency;
}
export function findSimpleColouring(
context: AdvancedLogicalContext,
): LogicalStep | undefined {
for (let value = 1; value <= context.size; value += 1) {
const adjacency = strongCellAdjacency(context, value);
const visited = new Set<number>();
for (let start = 0; start < context.values.length; start += 1) {
if (visited.has(start) || (adjacency[start]?.size ?? 0) === 0) continue;
const colours = new Map<number, 0 | 1>([[start, 0]]);
const queue = [start];
let bipartite = true;
while (queue.length > 0) {
const cell = queue.shift() as number;
visited.add(cell);
const colour = colours.get(cell) as 0 | 1;
const neighbours = [...(adjacency[cell] ?? [])].sort((a, b) => a - b);
for (const neighbour of neighbours) {
const expected = colour === 0 ? 1 : 0;
const existing = colours.get(neighbour);
if (existing === undefined) {
colours.set(neighbour, expected);
queue.push(neighbour);
} else if (existing !== expected) {
bipartite = false;
}
}
}
if (!bipartite) continue;
const component = uniqueSorted(colours.keys());
const componentSet = new Set(component);
for (const colour of [0, 1] as const) {
const cells = component.filter((cell) => colours.get(cell) === colour);
const conflict = cells.some((cell, index) =>
cells
.slice(index + 1)
.some((other) => context.peers[cell]?.has(other) ?? false),
);
if (!conflict) continue;
const found = step(
"simple-colouring",
cells.map((cell) => ({ cell, values: [value] })),
component,
`Two ${value} candidates with the same colour see each other, so that colour is false.`,
);
if (found !== undefined) return found;
}
const colourZero = component.filter((cell) => colours.get(cell) === 0);
const colourOne = component.filter((cell) => colours.get(cell) === 1);
const eliminations: LogicalElimination[] = [];
for (let cell = 0; cell < context.values.length; cell += 1) {
if (!hasCandidate(context, cell, value) || componentSet.has(cell)) {
continue;
}
if (
colourZero.some(
(coloured) => context.peers[cell]?.has(coloured) ?? false,
) &&
colourOne.some(
(coloured) => context.peers[cell]?.has(coloured) ?? false,
)
) {
eliminations.push({ cell, values: [value] });
}
}
const found = step(
"simple-colouring",
eliminations,
component,
`This ${value} candidate sees both colours of one conjugate chain.`,
);
if (found !== undefined) return found;
}
}
return undefined;
}
export function findWWing(
context: AdvancedLogicalContext,
): LogicalStep | undefined {
const bivalue = context.masks
.map((mask, cell) => ({ cell, mask }))
.filter(
({ cell, mask }) => context.values[cell] === 0 && popcount(mask) === 2,
);
const links = cellStrongLinks(context);
for (const pair of combinations(bivalue, 2)) {
const first = pair[0];
const second = pair[1];
if (
first === undefined ||
second === undefined ||
first.mask !== second.mask ||
(context.peers[first.cell]?.has(second.cell) ?? false)
) {
continue;
}
for (const linkValue of maskDigits(first.mask, context.size)) {
const otherValue = maskDigits(
first.mask & ~digitBit(linkValue),
context.size,
)[0];
if (otherValue === undefined) continue;
for (const link of links) {
if (link.value !== linkValue) continue;
if (new Set([first.cell, second.cell, link.a, link.b]).size !== 4) {
continue;
}
const connected =
((context.peers[first.cell]?.has(link.a) ?? false) &&
(context.peers[second.cell]?.has(link.b) ?? false)) ||
((context.peers[first.cell]?.has(link.b) ?? false) &&
(context.peers[second.cell]?.has(link.a) ?? false));
if (!connected) continue;
const pattern = new Set([first.cell, second.cell, link.a, link.b]);
const found = step(
"w-wing",
commonPeerEliminations(
context,
[first.cell, second.cell],
otherValue,
pattern,
),
pattern,
`The ${linkValue} conjugate link joins two ${linkValue}/${otherValue} cells, so one wing must contain ${otherValue}.`,
);
if (found !== undefined) return found;
}
}
}
return undefined;
}
export function findXChain(
context: AdvancedLogicalContext,
): LogicalStep | undefined {
for (let value = 1; value <= context.size; value += 1) {
const strong = strongCellAdjacency(context, value);
const candidates = Array.from(
{ length: context.values.length },
(_unused, cell) => cell,
).filter((cell) => hasCandidate(context, cell, value));
let visits = 0;
for (const start of candidates) {
const path = [start];
const inPath = new Set(path);
const search = (nextStrong: boolean): LogicalStep | undefined => {
if (visits >= MAX_CHAIN_VISITS) return undefined;
visits += 1;
const current = path[path.length - 1] as number;
const edges = path.length - 1;
if (!nextStrong && edges >= 3) {
const found = step(
"x-chain",
commonPeerEliminations(context, [start, current], value, inPath),
path,
`An alternating strong/weak X-Chain proves that at least one endpoint is ${value}.`,
);
if (found !== undefined) return found;
}
if (path.length >= MAX_CHAIN_NODES) return undefined;
const neighbours = nextStrong
? [...(strong[current] ?? [])]
: [...(context.peers[current] ?? [])].filter((cell) =>
hasCandidate(context, cell, value),
);
neighbours.sort((a, b) => a - b);
for (const neighbour of neighbours) {
if (inPath.has(neighbour)) continue;
path.push(neighbour);
inPath.add(neighbour);
const found = search(!nextStrong);
if (found !== undefined) return found;
inPath.delete(neighbour);
path.pop();
}
return undefined;
};
const found = search(true);
if (found !== undefined) return found;
if (visits >= MAX_CHAIN_VISITS) return undefined;
}
}
return undefined;
}
export function findXYChain(
context: AdvancedLogicalContext,
): LogicalStep | undefined {
const bivalue = context.masks
.map((mask, cell) => ({ cell, mask }))
.filter(
({ cell, mask }) => context.values[cell] === 0 && popcount(mask) === 2,
);
const byCell = new Map(bivalue.map((entry) => [entry.cell, entry.mask]));
let visits = 0;
for (const start of bivalue) {
for (const target of maskDigits(start.mask, context.size)) {
const initialLink = maskDigits(
start.mask & ~digitBit(target),
context.size,
)[0];
if (initialLink === undefined) continue;
const path = [start.cell];
const inPath = new Set(path);
const search = (linkValue: number): LogicalStep | undefined => {
if (visits >= MAX_CHAIN_VISITS) return undefined;
visits += 1;
if (path.length >= MAX_CHAIN_NODES) return undefined;
const current = path[path.length - 1] as number;
const neighbours = [...(context.peers[current] ?? [])]
.filter(
(cell) =>
!inPath.has(cell) &&
((byCell.get(cell) ?? 0) & digitBit(linkValue)) !== 0,
)
.sort((a, b) => a - b);
for (const neighbour of neighbours) {
const mask = byCell.get(neighbour) as number;
const outgoing = maskDigits(
mask & ~digitBit(linkValue),
context.size,
)[0];
if (outgoing === undefined) continue;
path.push(neighbour);
inPath.add(neighbour);
if (outgoing === target && path.length >= 3) {
const found = step(
"xy-chain",
commonPeerEliminations(
context,
[start.cell, neighbour],
target,
inPath,
),
path,
`This XY-Chain forces ${target} into at least one endpoint.`,
);
if (found !== undefined) return found;
}
const found = search(outgoing);
if (found !== undefined) return found;
inPath.delete(neighbour);
path.pop();
}
return undefined;
};
const found = search(initialLink);
if (found !== undefined) return found;
if (visits >= MAX_CHAIN_VISITS) return undefined;
}
}
return undefined;
}
interface CandidateNode {
readonly cell: CellId;
readonly value: number;
}
function nodeId(context: AdvancedLogicalContext, node: CandidateNode): number {
return node.cell * (context.size + 1) + node.value;
}
function nodeFromId(
context: AdvancedLogicalContext,
id: number,
): CandidateNode {
return {
cell: Math.floor(id / (context.size + 1)),
value: id % (context.size + 1),
};
}
function aicStrongAdjacency(
context: AdvancedLogicalContext,
): ReadonlyMap<number, ReadonlySet<number>> {
const adjacency = new Map<number, Set<number>>();
const add = (a: CandidateNode, b: CandidateNode): void => {
const aId = nodeId(context, a);
const bId = nodeId(context, b);
const aLinks = adjacency.get(aId) ?? new Set<number>();
const bLinks = adjacency.get(bId) ?? new Set<number>();
aLinks.add(bId);
bLinks.add(aId);
adjacency.set(aId, aLinks);
adjacency.set(bId, bLinks);
};
for (const link of cellStrongLinks(context)) {
add(
{ cell: link.a, value: link.value },
{ cell: link.b, value: link.value },
);
}
for (let cell = 0; cell < context.values.length; cell += 1) {
const values = maskDigits(context.masks[cell] ?? 0, context.size);
if (context.values[cell] !== 0 || values.length !== 2) continue;
add(
{ cell, value: values[0] as number },
{ cell, value: values[1] as number },
);
}
return adjacency;
}
function aicWeakNeighbours(
context: AdvancedLogicalContext,
node: CandidateNode,
): number[] {
const ids: number[] = [];
for (const value of maskDigits(context.masks[node.cell] ?? 0, context.size)) {
if (value !== node.value)
ids.push(nodeId(context, { cell: node.cell, value }));
}
for (const cell of context.peers[node.cell] ?? []) {
if (hasCandidate(context, cell, node.value)) {
ids.push(nodeId(context, { cell, value: node.value }));
}
}
return uniqueSorted(ids);
}
export function findAic(
context: AdvancedLogicalContext,
): LogicalStep | undefined {
const strong = aicStrongAdjacency(context);
const starts = [...strong.keys()].sort((a, b) => a - b);
let visits = 0;
for (const startId of starts) {
const start = nodeFromId(context, startId);
const path = [startId];
const inPath = new Set(path);
const search = (nextStrong: boolean): LogicalStep | undefined => {
if (visits >= MAX_CHAIN_VISITS) return undefined;
visits += 1;
const currentId = path[path.length - 1] as number;
const current = nodeFromId(context, currentId);
const edges = path.length - 1;
if (
!nextStrong &&
edges >= 3 &&
current.cell !== start.cell &&
current.value === start.value
) {
const pathCells = new Set(
path.map((id) => nodeFromId(context, id).cell),
);
const found = step(
"aic",
commonPeerEliminations(
context,
[start.cell, current.cell],
start.value,
pathCells,
),
pathCells,
`An Alternating Inference Chain proves that at least one endpoint is ${start.value}.`,
);
if (found !== undefined) return found;
}
if (path.length >= MAX_CHAIN_NODES) return undefined;
const neighbours = nextStrong
? [...(strong.get(currentId) ?? [])].sort((a, b) => a - b)
: aicWeakNeighbours(context, current);
for (const neighbour of neighbours) {
if (inPath.has(neighbour)) continue;
path.push(neighbour);
inPath.add(neighbour);
const found = search(!nextStrong);
if (found !== undefined) return found;
inPath.delete(neighbour);
path.pop();
}
return undefined;
};
const found = search(true);
if (found !== undefined) return found;
if (visits >= MAX_CHAIN_VISITS) return undefined;
}
return undefined;
}
export function findUniqueRectangle(
context: AdvancedLogicalContext,
): LogicalStep | undefined {
if (
context.uniquenessProven !== true ||
context.uniquenessPatternsSafe !== true
) {
return undefined;
}
for (let firstRow = 0; firstRow < context.size; firstRow += 1) {
for (
let secondRow = firstRow + 1;
secondRow < context.size;
secondRow += 1
) {
for (let firstColumn = 0; firstColumn < context.size; firstColumn += 1) {
for (
let secondColumn = firstColumn + 1;
secondColumn < context.size;
secondColumn += 1
) {
const cells = [
firstRow * context.size + firstColumn,
firstRow * context.size + secondColumn,
secondRow * context.size + firstColumn,
secondRow * context.size + secondColumn,
];
if (cells.some((cell) => context.values[cell] !== 0)) continue;
const regionCounts = new Map<number, number>();
for (const cell of cells) {
const region = context.regions[cell];
if (region === undefined) continue;
regionCounts.set(region, (regionCounts.get(region) ?? 0) + 1);
}
if (
regionCounts.size !== 2 ||
[...regionCounts.values()].some((count) => count !== 2)
) {
continue;
}
const regionsPreservePair = [...regionCounts.keys()].every(
(region) => {
const pair = cells.filter(
(cell) => context.regions[cell] === region,
);
const first = pair[0];
const second = pair[1];
return (
first !== undefined &&
second !== undefined &&
(Math.floor(first / context.size) ===
Math.floor(second / context.size) ||
first % context.size === second % context.size)
);
},
);
if (!regionsPreservePair) continue;
for (let roofIndex = 0; roofIndex < cells.length; roofIndex += 1) {
const roof = cells[roofIndex] as number;
const floor = cells.filter((_cell, index) => index !== roofIndex);
const pairMask = context.masks[floor[0] as number] ?? 0;
if (
popcount(pairMask) !== 2 ||
floor.some((cell) => (context.masks[cell] ?? 0) !== pairMask)
) {
continue;
}
const roofMask = context.masks[roof] ?? 0;
if (
(roofMask & pairMask) !== pairMask ||
popcount(roofMask & ~pairMask) === 0
) {
continue;
}
const values = maskDigits(pairMask, context.size);
const found = step(
"unique-rectangle",
[{ cell: roof, values }],
cells,
`A proven-unique puzzle cannot complete this ${values.join("/")} rectangle in two interchangeable ways.`,
);
if (found !== undefined) return found;
}
}
}
}
}
return undefined;
}
export function findAdvancedLogicalStep(
context: AdvancedLogicalContext,
): LogicalStep | undefined {
return (
findJellyfish(context) ??
findFinnedFish(context) ??
findSkyscraper(context) ??
findTwoStringKite(context) ??
findSimpleColouring(context) ??
findWWing(context) ??
findUniqueRectangle(context) ??
findXChain(context) ??
findXYChain(context) ??
findAic(context)
);
}
+268
View File
@@ -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 0100 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,
};
}
+16
View File
@@ -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
View File
@@ -7,12 +7,45 @@ import {
import { solveExact } from "./exact";
import { seededRandom, shuffled } from "./random";
export type ClueSymmetry = "none" | "rotational";
export type ClueSymmetry =
| "none"
| "rotational"
| "horizontal"
| "vertical"
| "diagonal-main"
| "diagonal-anti"
| "orthogonal"
/** Alias for orthogonal quarter-turn symmetry. */
| "four-way";
export type MinimalGivensStatus =
"not-requested" | "proven-minimal" | "unknown";
export type MinimalityLimitReason =
"check-cap" | "node-cap" | "timeout" | "not-unique" | "inconsistent-result";
export interface MinimalGivensEvidence {
readonly status: MinimalGivensStatus;
readonly checksPerformed: number;
readonly nodes: number;
readonly removedClues: number;
readonly criticalCells: readonly number[];
readonly unknownCells: readonly number[];
readonly limitReasons: readonly MinimalityLimitReason[];
readonly symmetryPreserved: boolean;
}
export interface MinimizePuzzleResult {
readonly puzzle: NormalizedPuzzle;
readonly minimality: MinimalGivensEvidence;
}
export interface MinimizeOptions {
readonly seed?: string | number;
readonly targetClues?: number;
readonly symmetry?: ClueSymmetry;
/** Continue with individual clue deletion tests until givens are minimal. */
readonly minimalGivens?: boolean;
readonly maxChecks?: number;
readonly solveMaxNodes?: number;
readonly solveTimeoutMs?: number;
@@ -117,11 +150,106 @@ function boundedOption(
return result;
}
export function minimizePuzzle(
function validatedSymmetry(value: ClueSymmetry | undefined): ClueSymmetry {
const symmetry = value ?? "rotational";
if (
symmetry !== "none" &&
symmetry !== "rotational" &&
symmetry !== "horizontal" &&
symmetry !== "vertical" &&
symmetry !== "diagonal-main" &&
symmetry !== "diagonal-anti" &&
symmetry !== "orthogonal" &&
symmetry !== "four-way"
) {
throw new RangeError(`Unsupported clue symmetry: ${String(symmetry)}.`);
}
return symmetry;
}
/** Returns the complete deterministic clue orbit for a symmetry. */
export function clueOrbit(
size: number,
cell: number,
requestedSymmetry: ClueSymmetry,
): readonly number[] {
if (
!Number.isInteger(size) ||
size < 1 ||
!Number.isInteger(cell) ||
cell < 0 ||
cell >= size * size
) {
throw new RangeError("Clue orbit requires a valid square-grid cell.");
}
const symmetry = validatedSymmetry(requestedSymmetry);
const row = Math.floor(cell / size);
const column = cell % size;
const at = (nextRow: number, nextColumn: number): number =>
nextRow * size + nextColumn;
const horizontal = at(size - row - 1, column);
const vertical = at(row, size - column - 1);
const rotational = at(size - row - 1, size - column - 1);
const quarterTurn = at(column, size - row - 1);
const threeQuarterTurn = at(size - column - 1, row);
const cells = (() => {
switch (symmetry) {
case "none":
return [cell];
case "rotational":
return [cell, rotational];
case "horizontal":
return [cell, horizontal];
case "vertical":
return [cell, vertical];
case "diagonal-main":
return [cell, at(column, row)];
case "diagonal-anti":
return [cell, at(size - column - 1, size - row - 1)];
case "orthogonal":
case "four-way":
return [cell, quarterTurn, rotational, threeQuarterTurn];
}
})();
return [...new Set(cells)].sort((a, b) => a - b);
}
function cluePatternPreservesSymmetry(
givens: readonly number[],
size: number,
symmetry: ClueSymmetry,
): boolean {
for (let cell = 0; cell < givens.length; cell += 1) {
const present = (givens[cell] ?? 0) !== 0;
if (
clueOrbit(size, cell, symmetry).some(
(other) => ((givens[other] ?? 0) !== 0) !== present,
)
) {
return false;
}
}
return true;
}
function uniqueAfterRemoval(
puzzle: PuzzleDefinition,
maxNodes: number,
timeoutMs: number,
) {
return solveExact(puzzle, {
maxSolutions: 2,
maxNodes,
timeoutMs,
});
}
export function minimizePuzzleWithReport(
puzzle: PuzzleDefinition | NormalizedPuzzle,
options: MinimizeOptions = {},
): NormalizedPuzzle {
): MinimizePuzzleResult {
const normalized = normalizePuzzle(puzzle);
const minimalGivens = options.minimalGivens === true;
const targetClues = boundedOption(
options.targetClues,
Math.max(
@@ -134,17 +262,30 @@ export function minimizePuzzle(
);
const maxChecks = boundedOption(
options.maxChecks,
normalized.size * normalized.size * 2,
normalized.size * normalized.size * (minimalGivens ? 3 : 2),
1,
normalized.size * normalized.size * 10,
"maxChecks",
);
const symmetry = options.symmetry ?? "rotational";
if (symmetry !== "none" && symmetry !== "rotational") {
throw new RangeError('symmetry must be "none" or "rotational"');
}
const random = seededRandom(options.seed ?? "sudoku-tools");
const solveMaxNodes = boundedOption(
options.solveMaxNodes,
2_000_000,
1,
100_000_000,
"solveMaxNodes",
);
const solveTimeoutMs = boundedOption(
options.solveTimeoutMs,
10_000,
1,
120_000,
"solveTimeoutMs",
);
const symmetry = validatedSymmetry(options.symmetry);
const seed = options.seed ?? "sudoku-tools";
const random = seededRandom(seed);
const givens = [...normalized.givens];
const initialClues = givens.filter((value) => value !== 0).length;
const countClues = (): number =>
givens.reduce((count, value) => count + (value === 0 ? 0 : 1), 0);
const order = shuffled(
@@ -152,36 +293,139 @@ export function minimizePuzzle(
random,
);
let checks = 0;
for (const cell of order) {
if (checks >= maxChecks || countClues() <= targetClues) break;
if (givens[cell] === 0) continue;
const mirror = givens.length - cell - 1;
const group =
symmetry === "rotational" && mirror !== cell ? [cell, mirror] : [cell];
if (group.some((entry) => givens[entry] === 0)) continue;
if (countClues() - group.length < targetClues) continue;
const saved = group.map((entry) => givens[entry] ?? 0);
group.forEach((entry) => {
givens[entry] = 0;
});
let nodes = 0;
const criticalCells: number[] = [];
const unknownCells: number[] = [];
const limitReasons = new Set<MinimalityLimitReason>();
let uniquenessPrerequisiteProven = true;
// Minimality is meaningful only for a uniquely solvable starting puzzle.
// This check shares the same explicit check, node and wall-clock budgets as
// every subsequent deletion test.
if (minimalGivens) {
checks += 1;
const candidate: PuzzleDefinition = {
...normalized,
givens,
solution: normalized.solution,
};
const result = solveExact(candidate, {
maxSolutions: 2,
maxNodes: options.solveMaxNodes ?? 2_000_000,
timeoutMs: options.solveTimeoutMs ?? 10_000,
});
if (result.count !== 1 || result.truncated) {
group.forEach((entry, index) => {
givens[entry] = saved[index] ?? 0;
});
const baseline = uniqueAfterRemoval(
{ ...normalized, givens, solution: normalized.solution },
solveMaxNodes,
solveTimeoutMs,
);
nodes += baseline.nodes;
if (baseline.count !== 1 || baseline.truncated) {
uniquenessPrerequisiteProven = false;
unknownCells.push(
...givens.flatMap((value, cell) => (value === 0 ? [] : [cell])),
);
if (
baseline.count >= 2 ||
(baseline.count === 0 && !baseline.truncated)
) {
limitReasons.add("not-unique");
} else if (baseline.limitReason === "node-cap") {
limitReasons.add("node-cap");
} else if (baseline.limitReason === "timeout") {
limitReasons.add("timeout");
} else {
limitReasons.add("inconsistent-result");
}
}
}
return normalizePuzzle({ ...normalized, givens });
if (!minimalGivens || uniquenessPrerequisiteProven) {
for (const cell of order) {
if (checks >= maxChecks || countClues() <= targetClues) break;
if (givens[cell] === 0) continue;
const group = clueOrbit(normalized.size, cell, symmetry);
if (group.some((entry) => givens[entry] === 0)) continue;
if (countClues() - group.length < targetClues) continue;
const saved = group.map((entry) => givens[entry] ?? 0);
group.forEach((entry) => {
givens[entry] = 0;
});
checks += 1;
const result = uniqueAfterRemoval(
{ ...normalized, givens, solution: normalized.solution },
solveMaxNodes,
solveTimeoutMs,
);
nodes += result.nodes;
if (result.count !== 1 || result.truncated) {
group.forEach((entry, index) => {
givens[entry] = saved[index] ?? 0;
});
}
}
}
if (minimalGivens && uniquenessPrerequisiteProven) {
const minimalOrder = shuffled(
Array.from({ length: givens.length }, (_, cell) => cell).filter(
(cell) => givens[cell] !== 0,
),
seededRandom(`${String(seed)}:minimal-givens`),
);
for (let index = 0; index < minimalOrder.length; index += 1) {
const cell = minimalOrder[index] as number;
if (givens[cell] === 0) continue;
if (checks >= maxChecks) {
limitReasons.add("check-cap");
unknownCells.push(
...minimalOrder
.slice(index)
.filter((remaining) => givens[remaining] !== 0),
);
break;
}
const saved = givens[cell] as number;
givens[cell] = 0;
checks += 1;
const result = uniqueAfterRemoval(
{ ...normalized, givens, solution: normalized.solution },
solveMaxNodes,
solveTimeoutMs,
);
nodes += result.nodes;
if (result.count === 1 && !result.truncated) continue;
givens[cell] = saved;
if (result.count >= 2) {
criticalCells.push(cell);
} else {
unknownCells.push(cell);
if (result.limitReason === "node-cap") limitReasons.add("node-cap");
else if (result.limitReason === "timeout") limitReasons.add("timeout");
else limitReasons.add("inconsistent-result");
}
}
}
const minimized = normalizePuzzle({ ...normalized, givens });
return {
puzzle: minimized,
minimality: {
status: minimalGivens
? uniquenessPrerequisiteProven && unknownCells.length === 0
? "proven-minimal"
: "unknown"
: "not-requested",
checksPerformed: checks,
nodes,
removedClues: initialClues - countClues(),
criticalCells: [...new Set(criticalCells)].sort((a, b) => a - b),
unknownCells: [...new Set(unknownCells)].sort((a, b) => a - b),
limitReasons: [...limitReasons].sort(),
symmetryPreserved: cluePatternPreservesSymmetry(
givens,
normalized.size,
symmetry,
),
},
};
}
export function minimizePuzzle(
puzzle: PuzzleDefinition | NormalizedPuzzle,
options: MinimizeOptions = {},
): NormalizedPuzzle {
return minimizePuzzleWithReport(puzzle, options).puzzle;
}
export function generateClassic(
+4
View File
@@ -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
View File
@@ -12,6 +12,7 @@ import {
type SudokuUnit,
type ValidationIssue,
} from "../domain";
import { findAdvancedLogicalStep } from "./advancedLogical";
export type LogicalTechnique =
| "naked-single"
@@ -26,6 +27,17 @@ export type LogicalTechnique =
| "claiming"
| "x-wing"
| "swordfish"
| "jellyfish"
| "finned-x-wing"
| "finned-swordfish"
| "skyscraper"
| "two-string-kite"
| "simple-colouring"
| "w-wing"
| "x-chain"
| "xy-chain"
| "aic"
| "unique-rectangle"
| "xy-wing"
| "xyz-wing"
| "killer-cage";
@@ -52,6 +64,19 @@ export type LogicalSolveStatus = "solved" | "stuck" | "invalid" | "step-limit";
export interface LogicalSolveOptions {
readonly values?: readonly number[];
/**
* Optional per-cell candidate restrictions to resume a logical solve after
* applying an elimination-only step. Each entry contains ordinary Sudoku
* digits (1 through the puzzle size), rather than an implementation-specific
* bit mask. Restrictions are intersected with the candidates that remain
* legal on the supplied board; entries for filled cells are ignored.
*/
readonly candidates?: readonly (readonly number[])[];
/**
* Enables uniqueness-dependent deductions only after the caller has proved
* exactly one solution with an exhaustive solver result. Never inferred.
*/
readonly uniquenessProven?: boolean;
readonly maxSteps?: number;
}
@@ -148,9 +173,73 @@ function validateStart(
if (issues.length > 0) throw new PuzzleValidationError(issues);
}
function compileCandidateRestrictions(
normalized: NormalizedPuzzle,
input: unknown,
): number[] | undefined {
if (input === undefined) return undefined;
const issues: ValidationIssue[] = [];
const cellCount = normalized.size * normalized.size;
if (!Array.isArray(input)) {
throw new PuzzleValidationError([
{ path: "candidates", message: "must be an array of candidate arrays" },
]);
}
if (input.length !== cellCount) {
issues.push({
path: "candidates",
message: `must contain exactly ${cellCount} candidate arrays`,
});
}
const masks = new Array<number>(cellCount).fill(0);
for (let cell = 0; cell < Math.min(input.length, cellCount); cell += 1) {
const cellCandidates: unknown = input[cell];
if (!Array.isArray(cellCandidates)) {
issues.push({
path: `candidates[${cell}]`,
message: "must be an array of candidate digits",
});
continue;
}
let mask = 0;
for (let index = 0; index < cellCandidates.length; index += 1) {
const value: unknown = cellCandidates[index];
if (
typeof value !== "number" ||
!Number.isInteger(value) ||
value < 1 ||
value > normalized.size
) {
issues.push({
path: `candidates[${cell}][${index}]`,
message: `must be an integer from 1 to ${normalized.size}`,
});
continue;
}
const bit = digitBit(value);
if ((mask & bit) !== 0) {
issues.push({
path: `candidates[${cell}][${index}]`,
message: `contains duplicate candidate ${value}`,
});
continue;
}
mask |= bit;
}
masks[cell] = mask;
}
if (issues.length > 0) throw new PuzzleValidationError(issues);
return masks;
}
function initializeState(
normalized: NormalizedPuzzle,
values: readonly number[],
candidateRestrictions?: readonly number[],
): LogicalState {
const compiled = compilePuzzle(normalized);
return {
@@ -158,10 +247,13 @@ function initializeState(
values: [...values],
masks: values.map((value, cell) => {
if (value !== 0) return 0;
return candidatesForCell(compiled, values, cell).reduce(
const legalMask = candidatesForCell(compiled, values, cell).reduce(
(mask, candidate) => mask | digitBit(candidate),
0,
);
return candidateRestrictions === undefined
? legalMask
: legalMask & (candidateRestrictions[cell] ?? 0);
}),
};
}
@@ -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",
+791
View File
@@ -0,0 +1,791 @@
import {
PuzzleValidationError,
constraintCells,
normalizePuzzle,
type NormalizedPuzzle,
type PuzzleDefinition,
type VariantConstraint,
} from "../domain";
import {
solveExact,
type ExactLimitReason,
type ExactSolveResult,
} from "./exact";
export type QualitySolutionStatus =
"unsatisfiable" | "unique" | "multiple" | "unknown";
export type QualityClassification = "critical" | "redundant" | "unknown";
export type QualityUnknownReason =
| "baseline-not-unique"
| "baseline-unknown"
| "per-check-node-cap"
| "per-check-timeout"
| "aggregate-check-cap"
| "aggregate-node-cap"
| "aggregate-timeout"
| "unexpected-unsatisfiable-relaxation";
export type QualityCheckPurpose =
"baseline" | "redundancy" | "contradiction-core";
export type QualityItemReference =
| {
readonly kind: "given";
readonly cell: number;
readonly value: number;
}
| {
readonly kind: "constraint";
readonly index: number;
readonly constraintType: VariantConstraint["type"];
};
export interface PuzzleQualityOptions {
/** Baseline only is a fast 0/1/2-solution check; full adds setter QC. */
readonly analysisDepth?: "baseline" | "full";
/** Maximum search nodes consumed by any one exact-solver check. */
readonly perCheckMaxNodes?: number;
/** Wall-clock limit for any one exact-solver check. */
readonly perCheckTimeoutMs?: number;
/** Maximum number of exact-solver checks in the whole analysis. */
readonly aggregateMaxChecks?: number;
/** Maximum exact-solver nodes shared by the whole analysis. */
readonly aggregateMaxNodes?: number;
/** Wall-clock limit shared by the whole analysis. */
readonly aggregateTimeoutMs?: number;
/** Derive a bounded single-clue/constraint minimality proof. */
readonly proveMinimality?: boolean;
}
export interface QualityBounds {
readonly perCheck: {
readonly maxNodes: number;
readonly timeoutMs: number;
};
readonly aggregate: {
readonly maxChecks: number;
readonly maxNodes: number;
readonly timeoutMs: number;
};
}
export interface QualitySearchCheck {
readonly index: number;
readonly purpose: QualityCheckPurpose;
readonly item?: QualityItemReference;
readonly solutionStatus: QualitySolutionStatus;
readonly solutionsFound: number;
/** False only when a node/time limit prevented a conclusion. */
readonly conclusive: boolean;
/** Mirrors the exact solver; two found solutions intentionally hit solution-cap. */
readonly truncated: boolean;
readonly limitReason?: ExactLimitReason;
readonly unknownReason?: QualityUnknownReason;
readonly nodes: number;
readonly elapsedMs: number;
}
export interface AmbiguityDifference {
readonly cell: number;
readonly first: number;
readonly second: number;
}
export interface AmbiguityWitness {
readonly firstSolution: readonly number[];
readonly secondSolution: readonly number[];
readonly differences: readonly AmbiguityDifference[];
}
export interface QualityItemAssessment {
readonly item: QualityItemReference;
readonly classification: QualityClassification;
readonly checkIndex?: number;
readonly solutionStatus?: QualitySolutionStatus;
readonly unknownReason?: QualityUnknownReason;
}
export interface ContradictionLocalization {
readonly status: "not-applicable" | "localized" | "incomplete";
/** A deletion-minimized unsatisfiable core. Unknown items remain in this set. */
readonly core: readonly QualityItemReference[];
/** Core items proven necessary: removing one made the current core satisfiable. */
readonly necessary: readonly QualityItemReference[];
/** Items proven unnecessary to retain unsatisfiability. */
readonly removable: readonly QualityItemReference[];
readonly unknown: readonly QualityItemReference[];
readonly reason?: string;
}
export interface CellCriticality {
readonly cell: number;
/** Critical share among conclusive assessments, or null without one. */
readonly score: number | null;
readonly criticalWeight: number;
readonly redundantWeight: number;
readonly unknownWeight: number;
}
export interface QualityMinimalityAnalysis {
readonly status:
"proven-minimal" | "not-minimal" | "unknown" | "not-applicable";
readonly redundant: readonly QualityItemReference[];
readonly unknown: readonly QualityItemReference[];
readonly reason?: string;
}
export interface QualityBudgetSummary {
readonly checksPlanned: number;
readonly checksPerformed: number;
readonly nodes: number;
readonly elapsedMs: number;
/** True when at least one requested conclusion remained unknown. */
readonly truncated: boolean;
readonly unknownReasons: readonly QualityUnknownReason[];
}
export interface PuzzleQualityAnalysis {
readonly analysisDepth: "baseline" | "full";
readonly solutionStatus: QualitySolutionStatus;
readonly solution?: readonly number[];
readonly baselineCheckIndex?: number;
readonly ambiguityWitness?: AmbiguityWitness;
readonly contradiction: ContradictionLocalization;
readonly redundancy: {
readonly givens: readonly QualityItemAssessment[];
readonly constraints: readonly QualityItemAssessment[];
};
readonly criticalityHeatmap: readonly CellCriticality[];
readonly minimality?: QualityMinimalityAnalysis;
readonly checks: readonly QualitySearchCheck[];
readonly bounds: QualityBounds;
readonly budget: QualityBudgetSummary;
}
const DEFAULT_BOUNDS: QualityBounds = {
perCheck: { maxNodes: 2_000_000, timeoutMs: 10_000 },
aggregate: { maxChecks: 1_000, maxNodes: 20_000_000, timeoutMs: 30_000 },
};
function boundedInteger(
value: number | undefined,
fallback: number,
minimum: number,
maximum: number,
name: string,
): number {
const result = value ?? fallback;
if (!Number.isInteger(result) || result < minimum || result > maximum) {
throw new RangeError(
`${name} must be an integer from ${minimum} to ${maximum}.`,
);
}
return result;
}
function resolveBounds(options: PuzzleQualityOptions): QualityBounds {
return {
perCheck: {
maxNodes: boundedInteger(
options.perCheckMaxNodes,
DEFAULT_BOUNDS.perCheck.maxNodes,
1,
100_000_000,
"perCheckMaxNodes",
),
timeoutMs: boundedInteger(
options.perCheckTimeoutMs,
DEFAULT_BOUNDS.perCheck.timeoutMs,
1,
120_000,
"perCheckTimeoutMs",
),
},
aggregate: {
maxChecks: boundedInteger(
options.aggregateMaxChecks,
DEFAULT_BOUNDS.aggregate.maxChecks,
1,
20_000,
"aggregateMaxChecks",
),
maxNodes: boundedInteger(
options.aggregateMaxNodes,
DEFAULT_BOUNDS.aggregate.maxNodes,
1,
2_000_000_000,
"aggregateMaxNodes",
),
timeoutMs: boundedInteger(
options.aggregateTimeoutMs,
DEFAULT_BOUNDS.aggregate.timeoutMs,
1,
600_000,
"aggregateTimeoutMs",
),
},
};
}
/**
* Quality analysis deliberately accepts structurally valid but contradictory
* givens. The regular normalizer rejects those because they are not playable,
* so validate the same document with an empty board and retain the original
* givens for the diagnostic search.
*/
function normalizeForQuality(
puzzle: PuzzleDefinition | NormalizedPuzzle,
): NormalizedPuzzle {
try {
return normalizePuzzle(puzzle);
} catch (error) {
if (!(error instanceof PuzzleValidationError)) throw error;
if (error.issues.some((issue) => issue.path !== "givens")) throw error;
const cellCount = puzzle.size * puzzle.size;
if (
!Number.isInteger(puzzle.size) ||
puzzle.givens.length !== cellCount ||
puzzle.givens.some(
(value) => !Number.isInteger(value) || value < 0 || value > puzzle.size,
)
) {
throw error;
}
const skeleton = normalizePuzzle({
...puzzle,
givens: new Array<number>(cellCount).fill(0),
solution: undefined,
});
return {
...skeleton,
givens: [...puzzle.givens],
};
}
}
function itemKey(item: QualityItemReference): string {
return item.kind === "given"
? `given:${item.cell}`
: `constraint:${item.index}`;
}
function puzzleItems(puzzle: NormalizedPuzzle): QualityItemReference[] {
const givens: QualityItemReference[] = [];
puzzle.givens.forEach((value, cell) => {
if (value !== 0) givens.push({ kind: "given", cell, value });
});
const constraints: QualityItemReference[] = puzzle.constraints.map(
(constraint, index) => ({
kind: "constraint",
index,
constraintType: constraint.type,
}),
);
return [...givens, ...constraints];
}
function withActiveItems(
puzzle: NormalizedPuzzle,
active: ReadonlySet<string>,
): PuzzleDefinition {
return {
...puzzle,
givens: puzzle.givens.map((value, cell) =>
active.has(`given:${cell}`) ? value : 0,
),
constraints: puzzle.constraints.filter((_constraint, index) =>
active.has(`constraint:${index}`),
),
};
}
function withoutItem(
puzzle: NormalizedPuzzle,
item: QualityItemReference,
): PuzzleDefinition {
if (item.kind === "given") {
return {
...puzzle,
givens: puzzle.givens.map((value, cell) =>
cell === item.cell ? 0 : value,
),
};
}
return {
...puzzle,
constraints: puzzle.constraints.filter(
(_constraint, index) => index !== item.index,
),
};
}
function statusOf(result: ExactSolveResult): QualitySolutionStatus {
if (result.count >= 2) return "multiple";
if (result.truncated) return "unknown";
return result.count === 1 ? "unique" : "unsatisfiable";
}
interface SearchOutcome {
readonly status: QualitySolutionStatus;
readonly checkIndex?: number;
readonly unknownReason?: QualityUnknownReason;
readonly solutions: readonly (readonly number[])[];
}
interface SearchBudget {
readonly bounds: QualityBounds;
readonly started: number;
readonly checks: QualitySearchCheck[];
readonly unknownReasons: Set<QualityUnknownReason>;
nodes: number;
}
function skipped(
budget: SearchBudget,
reason: QualityUnknownReason,
): SearchOutcome {
budget.unknownReasons.add(reason);
return { status: "unknown", unknownReason: reason, solutions: [] };
}
function runCheck(
budget: SearchBudget,
puzzle: PuzzleDefinition,
purpose: QualityCheckPurpose,
item?: QualityItemReference,
): SearchOutcome {
if (budget.checks.length >= budget.bounds.aggregate.maxChecks) {
return skipped(budget, "aggregate-check-cap");
}
const remainingNodes = budget.bounds.aggregate.maxNodes - budget.nodes;
if (remainingNodes <= 0) return skipped(budget, "aggregate-node-cap");
const aggregateElapsed = Date.now() - budget.started;
const remainingMs = budget.bounds.aggregate.timeoutMs - aggregateElapsed;
if (remainingMs <= 0) return skipped(budget, "aggregate-timeout");
const maxNodes = Math.min(budget.bounds.perCheck.maxNodes, remainingNodes);
const timeoutMs = Math.min(budget.bounds.perCheck.timeoutMs, remainingMs);
const searchStarted = Date.now();
let result: ExactSolveResult;
try {
result = solveExact(puzzle, {
maxSolutions: 2,
maxNodes,
timeoutMs,
});
} catch (error) {
if (!(error instanceof PuzzleValidationError)) throw error;
if (error.issues.some((issue) => issue.path !== "givens")) throw error;
// All generated relaxations are structurally normalized. A validation
// failure here therefore means their active givens already conflict.
result = {
solutions: [],
count: 0,
truncated: false,
nodes: 0,
elapsedMs: Date.now() - searchStarted,
};
}
budget.nodes += result.nodes;
const status = statusOf(result);
let unknownReason: QualityUnknownReason | undefined;
if (status === "unknown") {
if (result.limitReason === "node-cap") {
unknownReason =
maxNodes < budget.bounds.perCheck.maxNodes
? "aggregate-node-cap"
: "per-check-node-cap";
} else {
unknownReason =
timeoutMs < budget.bounds.perCheck.timeoutMs
? "aggregate-timeout"
: "per-check-timeout";
}
budget.unknownReasons.add(unknownReason);
}
const checkIndex = budget.checks.length;
budget.checks.push({
index: checkIndex,
purpose,
...(item === undefined ? {} : { item }),
solutionStatus: status,
solutionsFound: result.count,
conclusive: status !== "unknown",
truncated: result.truncated,
...(result.limitReason === undefined
? {}
: { limitReason: result.limitReason }),
...(unknownReason === undefined ? {} : { unknownReason }),
nodes: result.nodes,
elapsedMs: result.elapsedMs,
});
return {
status,
checkIndex,
...(unknownReason === undefined ? {} : { unknownReason }),
solutions: result.solutions,
};
}
function unknownAssessment(
item: QualityItemReference,
reason: QualityUnknownReason,
): QualityItemAssessment {
return { item, classification: "unknown", unknownReason: reason };
}
function assessItem(
baseline: QualitySolutionStatus,
item: QualityItemReference,
outcome: SearchOutcome,
): QualityItemAssessment {
if (outcome.status === "unknown") {
return {
item,
classification: "unknown",
...(outcome.checkIndex === undefined
? {}
: { checkIndex: outcome.checkIndex }),
solutionStatus: "unknown",
...(outcome.unknownReason === undefined
? {}
: { unknownReason: outcome.unknownReason }),
};
}
if (baseline === "unique") {
if (outcome.status === "unique") {
return {
item,
classification: "redundant",
checkIndex: outcome.checkIndex,
solutionStatus: outcome.status,
};
}
if (outcome.status === "multiple") {
return {
item,
classification: "critical",
checkIndex: outcome.checkIndex,
solutionStatus: outcome.status,
};
}
return {
item,
classification: "unknown",
checkIndex: outcome.checkIndex,
solutionStatus: outcome.status,
unknownReason: "unexpected-unsatisfiable-relaxation",
};
}
// For an inconsistent definition, an item is critical if removing it makes
// the definition satisfiable, and redundant if inconsistency remains.
if (baseline === "unsatisfiable") {
return {
item,
classification:
outcome.status === "unsatisfiable" ? "redundant" : "critical",
checkIndex: outcome.checkIndex,
solutionStatus: outcome.status,
};
}
return unknownAssessment(
item,
baseline === "multiple" ? "baseline-not-unique" : "baseline-unknown",
);
}
function redundancyAnalysis(
budget: SearchBudget,
puzzle: NormalizedPuzzle,
items: readonly QualityItemReference[],
baseline: QualitySolutionStatus,
): {
readonly givens: readonly QualityItemAssessment[];
readonly constraints: readonly QualityItemAssessment[];
} {
const assessments: QualityItemAssessment[] = [];
if (baseline === "multiple" || baseline === "unknown") {
const reason: QualityUnknownReason =
baseline === "multiple" ? "baseline-not-unique" : "baseline-unknown";
budget.unknownReasons.add(reason);
assessments.push(...items.map((item) => unknownAssessment(item, reason)));
} else {
for (const item of items) {
const outcome = runCheck(
budget,
withoutItem(puzzle, item),
"redundancy",
item,
);
const assessment = assessItem(baseline, item, outcome);
if (assessment.unknownReason !== undefined) {
budget.unknownReasons.add(assessment.unknownReason);
}
assessments.push(assessment);
}
}
return {
givens: assessments.filter(
(assessment) => assessment.item.kind === "given",
),
constraints: assessments.filter(
(assessment) => assessment.item.kind === "constraint",
),
};
}
function localizeContradiction(
budget: SearchBudget,
puzzle: NormalizedPuzzle,
items: readonly QualityItemReference[],
baseline: QualitySolutionStatus,
): ContradictionLocalization {
if (baseline !== "unsatisfiable") {
return {
status: "not-applicable",
core: [],
necessary: [],
removable: [],
unknown: [],
reason:
baseline === "unknown"
? "Unsatisfiability was not proven within the search bounds."
: "The puzzle is satisfiable.",
};
}
const active = new Set(items.map(itemKey));
const necessary: QualityItemReference[] = [];
const removable: QualityItemReference[] = [];
const unknown: QualityItemReference[] = [];
for (const item of items) {
active.delete(itemKey(item));
const outcome = runCheck(
budget,
withActiveItems(puzzle, active),
"contradiction-core",
item,
);
if (outcome.status === "unsatisfiable") {
removable.push(item);
continue;
}
active.add(itemKey(item));
if (outcome.status === "unique" || outcome.status === "multiple") {
necessary.push(item);
} else {
unknown.push(item);
}
}
const core = items.filter((item) => active.has(itemKey(item)));
return {
status: unknown.length === 0 ? "localized" : "incomplete",
core,
necessary,
removable,
unknown,
...(unknown.length === 0
? {}
: { reason: "One or more deletion checks exhausted the shared bounds." }),
};
}
function buildHeatmap(
puzzle: NormalizedPuzzle,
assessments: readonly QualityItemAssessment[],
): CellCriticality[] {
const weights = Array.from({ length: puzzle.size * puzzle.size }, () => ({
critical: 0,
redundant: 0,
unknown: 0,
}));
for (const assessment of assessments) {
const cells =
assessment.item.kind === "given"
? [assessment.item.cell]
: constraintCells(
puzzle.size,
puzzle.constraints[assessment.item.index] as VariantConstraint,
);
for (const cell of new Set(cells)) {
const weight = weights[cell];
if (weight === undefined) continue;
if (assessment.classification === "critical") weight.critical += 1;
else if (assessment.classification === "redundant") weight.redundant += 1;
else weight.unknown += 1;
}
}
return weights.map((weight, cell) => {
const conclusive = weight.critical + weight.redundant;
return {
cell,
score: conclusive === 0 ? null : weight.critical / conclusive,
criticalWeight: weight.critical,
redundantWeight: weight.redundant,
unknownWeight: weight.unknown,
};
});
}
/** Resolves the board cells represented by a quality finding. */
export function qualityItemCells(
puzzle: PuzzleDefinition | NormalizedPuzzle,
item: QualityItemReference,
): readonly number[] {
const normalized = normalizeForQuality(puzzle);
if (item.kind === "given") {
if (item.cell < 0 || item.cell >= normalized.size * normalized.size) {
throw new RangeError("Given quality item cell is outside the puzzle.");
}
return [item.cell];
}
const constraint = normalized.constraints[item.index];
if (constraint === undefined || constraint.type !== item.constraintType) {
throw new RangeError("Constraint quality item does not match the puzzle.");
}
return constraintCells(normalized.size, constraint);
}
function minimalityFrom(
baseline: QualitySolutionStatus,
assessments: readonly QualityItemAssessment[],
): QualityMinimalityAnalysis {
if (baseline !== "unique") {
return {
status: "not-applicable",
redundant: [],
unknown: assessments.map((assessment) => assessment.item),
reason: "Minimality requires a proven unique baseline puzzle.",
};
}
const redundant = assessments
.filter((assessment) => assessment.classification === "redundant")
.map((assessment) => assessment.item);
const unknown = assessments
.filter((assessment) => assessment.classification === "unknown")
.map((assessment) => assessment.item);
if (redundant.length > 0) {
return { status: "not-minimal", redundant, unknown };
}
if (unknown.length > 0) {
return {
status: "unknown",
redundant,
unknown,
reason: "At least one removal check was inconclusive.",
};
}
return { status: "proven-minimal", redundant, unknown };
}
/**
* Runs a bounded, immutable setter-quality audit. Every exact-solver call uses
* both the per-check limits and one shared aggregate budget. A capped search
* is always reported as unknown unless two solutions already prove ambiguity.
*/
export function analyzePuzzleQuality(
puzzle: PuzzleDefinition | NormalizedPuzzle,
options: PuzzleQualityOptions = {},
): PuzzleQualityAnalysis {
const normalized = normalizeForQuality(puzzle);
const analysisDepth = options.analysisDepth ?? "full";
if (analysisDepth !== "baseline" && analysisDepth !== "full") {
throw new RangeError('analysisDepth must be "baseline" or "full".');
}
const bounds = resolveBounds(options);
const budget: SearchBudget = {
bounds,
started: Date.now(),
checks: [],
unknownReasons: new Set<QualityUnknownReason>(),
nodes: 0,
};
const items = puzzleItems(normalized);
const baseline = runCheck(budget, normalized, "baseline");
const solutionStatus = baseline.status;
const ambiguityWitness = (() => {
const firstSolution = baseline.solutions[0];
const secondSolution = baseline.solutions[1];
if (
solutionStatus !== "multiple" ||
firstSolution === undefined ||
secondSolution === undefined
) {
return undefined;
}
return {
firstSolution: [...firstSolution],
secondSolution: [...secondSolution],
differences: firstSolution.flatMap((first, cell) => {
const second = secondSolution[cell] as number;
return first === second ? [] : [{ cell, first, second }];
}),
} satisfies AmbiguityWitness;
})();
const redundancy =
analysisDepth === "full"
? redundancyAnalysis(budget, normalized, items, solutionStatus)
: { givens: [], constraints: [] };
const contradiction =
analysisDepth === "full"
? localizeContradiction(budget, normalized, items, solutionStatus)
: {
status: "not-applicable" as const,
core: [],
necessary: [],
removable: [],
unknown: [],
reason: "Full quality analysis was not requested.",
};
const assessments = [...redundancy.givens, ...redundancy.constraints];
const elapsedMs = Date.now() - budget.started;
const result: PuzzleQualityAnalysis = {
analysisDepth,
solutionStatus,
...(solutionStatus === "unique" && baseline.solutions[0] !== undefined
? { solution: [...baseline.solutions[0]] }
: {}),
...(baseline.checkIndex === undefined
? {}
: { baselineCheckIndex: baseline.checkIndex }),
...(ambiguityWitness === undefined ? {} : { ambiguityWitness }),
contradiction,
redundancy,
criticalityHeatmap:
analysisDepth === "full" ? buildHeatmap(normalized, assessments) : [],
...(options.proveMinimality === true
? {
minimality:
analysisDepth === "full"
? minimalityFrom(solutionStatus, assessments)
: {
status: "not-applicable" as const,
redundant: [],
unknown: [],
reason: "A minimality proof requires full quality analysis.",
},
}
: {}),
checks: budget.checks,
bounds,
budget: {
checksPlanned:
analysisDepth === "baseline" ||
solutionStatus === "multiple" ||
solutionStatus === "unknown"
? 1
: 1 +
items.length +
(solutionStatus === "unsatisfiable" ? items.length : 0),
checksPerformed: budget.checks.length,
nodes: budget.nodes,
elapsedMs,
truncated: budget.unknownReasons.size > 0,
unknownReasons: [...budget.unknownReasons],
},
};
return result;
}
File diff suppressed because it is too large Load Diff
+409
View File
@@ -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,
})),
};
}
+213
View File
@@ -0,0 +1,213 @@
import {
allCandidates,
type CompiledPuzzle,
type NormalizedPuzzle,
type PuzzleDefinition,
} from "../domain";
import type { LogicalStep } from "../solver/logical";
import type { PlaySession } from "./session";
export type CandidatePuzzle =
PuzzleDefinition | NormalizedPuzzle | CompiledPuzzle;
export interface NoteMaintenanceOptions {
/** Maintain small/corner pencil marks. Defaults to true. */
readonly cornerMarks?: boolean;
/** Maintain central candidate marks. Defaults to true. */
readonly centerMarks?: boolean;
}
function sizeOf(puzzle: CandidatePuzzle): number {
return "puzzle" in puzzle ? puzzle.puzzle.size : puzzle.size;
}
function cloneSession(session: PlaySession): PlaySession {
return {
...session,
values: [...session.values],
cornerMarks: [...session.cornerMarks],
centerMarks: [...session.centerMarks],
colors: [...session.colors],
};
}
function assertSessionShape(
session: PlaySession,
puzzle: CandidatePuzzle,
label = "session",
): number {
const size = sizeOf(puzzle);
const cells = size * size;
for (const [name, values] of [
["values", session.values],
["cornerMarks", session.cornerMarks],
["centerMarks", session.centerMarks],
["colors", session.colors],
] as const) {
if (values.length !== cells) {
throw new RangeError(
`${label}.${name} must contain exactly ${String(cells)} entries`,
);
}
}
return size;
}
function valuesMask(values: readonly number[], size: number): number {
let mask = 0;
for (const value of values) {
if (Number.isInteger(value) && value >= 1 && value <= size) {
mask |= 1 << (value - 1);
}
}
return mask;
}
function legalMasks(
session: PlaySession,
puzzle: CandidatePuzzle,
size: number,
): number[] {
return allCandidates(puzzle, session.values).map((values) =>
valuesMask(values, size),
);
}
function pruneWithOptions(
session: PlaySession,
puzzle: CandidatePuzzle,
options: NoteMaintenanceOptions,
): PlaySession {
const size = assertSessionShape(session, puzzle);
const allowed = legalMasks(session, puzzle, size);
const next = cloneSession(session);
if (options.cornerMarks !== false) {
next.cornerMarks = next.cornerMarks.map(
(mask, cell) => mask & (allowed[cell] ?? 0),
);
}
if (options.centerMarks !== false) {
next.centerMarks = next.centerMarks.map(
(mask, cell) => mask & (allowed[cell] ?? 0),
);
}
return next;
}
/**
* Replace every center-mark set with the candidates that are legal in the
* current position. Filled cells receive no center marks. Other session state,
* including deliberately entered corner marks, is retained unchanged.
*/
export function fillLegalCenterCandidates(
session: PlaySession,
puzzle: CandidatePuzzle,
): PlaySession {
const size = assertSessionShape(session, puzzle);
const next = cloneSession(session);
next.centerMarks = legalMasks(session, puzzle, size);
return next;
}
/**
* Remove notes that cannot currently be placed according to all active Sudoku
* constraints. This never adds a note; use fillLegalCenterCandidates when a
* complete center-candidate grid is desired.
*/
export function pruneInvalidNotes(
session: PlaySession,
puzzle: CandidatePuzzle,
options: NoteMaintenanceOptions = {},
): PlaySession {
return pruneWithOptions(session, puzzle, options);
}
/**
* Prune selected note kinds after one or more values have just been placed.
* Erasures alone do not add candidates or otherwise rewrite notes. Both input
* sessions remain untouched, and the returned session is detached from them.
*/
export function autoRemoveNotesAfterPlacements(
previous: PlaySession,
nextSession: PlaySession,
puzzle: CandidatePuzzle,
options: NoteMaintenanceOptions = {},
): PlaySession {
assertSessionShape(previous, puzzle, "previous");
assertSessionShape(nextSession, puzzle, "nextSession");
const hasPlacement = nextSession.values.some(
(value, cell) => value !== 0 && value !== previous.values[cell],
);
return hasPlacement
? pruneWithOptions(nextSession, puzzle, options)
: cloneSession(nextSession);
}
function assertCell(cell: number, cells: number, path: string): void {
if (!Number.isInteger(cell) || cell < 0 || cell >= cells) {
throw new RangeError(
`${path} must identify a cell from 0 to ${String(cells - 1)}`,
);
}
}
/**
* Apply a solver-produced logical step as one immutable session transition.
* Placements update values and clear notes in the placed cells. Existing center
* candidates are pruned after placements, then the step's explicit candidate
* eliminations are removed. Empty center-mark sets stay empty: callers that
* want a complete tracked candidate grid should call fillLegalCenterCandidates
* before applying the first elimination. Corner marks outside placed cells are
* intentionally left alone.
*/
export function applyLogicalStepToSession(
session: PlaySession,
step: LogicalStep,
puzzle: CandidatePuzzle,
): PlaySession {
const size = assertSessionShape(session, puzzle);
const cells = size * size;
const next = cloneSession(session);
for (const [index, placement] of step.placements.entries()) {
assertCell(placement.cell, cells, `placements[${String(index)}].cell`);
if (
!Number.isInteger(placement.value) ||
placement.value < 1 ||
placement.value > size
) {
throw new RangeError(
`placements[${String(index)}].value must be from 1 to ${String(size)}`,
);
}
if (next.values[placement.cell] !== 0) {
throw new RangeError(
`placements[${String(index)}] targets a filled cell`,
);
}
next.values[placement.cell] = placement.value;
next.cornerMarks[placement.cell] = 0;
next.centerMarks[placement.cell] = 0;
}
const maintained = autoRemoveNotesAfterPlacements(session, next, puzzle, {
cornerMarks: false,
centerMarks: true,
});
for (const [index, elimination] of step.eliminations.entries()) {
assertCell(elimination.cell, cells, `eliminations[${String(index)}].cell`);
let mask = maintained.centerMarks[elimination.cell] ?? 0;
for (const value of elimination.values) {
if (!Number.isInteger(value) || value < 1 || value > size) {
throw new RangeError(
`eliminations[${String(index)}].values must be from 1 to ${String(size)}`,
);
}
mask &= ~(1 << (value - 1));
}
maintained.centerMarks[elimination.cell] = mask;
}
return maintained;
}
+121
View File
@@ -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;
}
+54
View File
@@ -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;
}
}
+634
View File
@@ -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}`;
}
+70
View File
@@ -0,0 +1,70 @@
export type CandidateVerbosity = "off" | "concise" | "detailed";
export const BOARD_SCALE_MIN = 0.75;
export const BOARD_SCALE_MAX = 2;
export const BOARD_SCALE_STEP = 0.25;
export const BOARD_SCALE_STORAGE_KEY = "sudoku-tools:board-scale:v1";
export const COLOR_MARK_DESCRIPTIONS = [
"red, diagonal stripes",
"orange, reverse diagonal stripes",
"yellow, dots",
"green, crosshatch",
"teal, horizontal bars",
"blue, vertical bars",
"purple, checkerboard",
"pink, rings",
] as const;
export function normalizeBoardScale(value: unknown): number {
const numeric = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(numeric)) return 1;
const clamped = Math.min(BOARD_SCALE_MAX, Math.max(BOARD_SCALE_MIN, numeric));
return Math.round(clamped / BOARD_SCALE_STEP) * BOARD_SCALE_STEP;
}
function browserStorage(): Storage | undefined {
if (typeof window === "undefined") return undefined;
try {
return window.localStorage;
} catch {
return undefined;
}
}
export function readStoredBoardScale(
storage: Pick<Storage, "getItem"> | undefined = browserStorage(),
): number {
if (storage === undefined) return 1;
try {
const value = storage.getItem(BOARD_SCALE_STORAGE_KEY);
return value === null ? 1 : normalizeBoardScale(value);
} catch {
return 1;
}
}
export function writeStoredBoardScale(
scale: number,
storage: Pick<Storage, "setItem"> | undefined = browserStorage(),
): void {
if (storage === undefined) return;
try {
storage.setItem(
BOARD_SCALE_STORAGE_KEY,
String(normalizeBoardScale(scale)),
);
} catch {
// A blocked or full storage area must not make the local board unusable.
}
}
export function parseCandidateVerbosity(value: unknown): CandidateVerbosity {
return value === "off" || value === "concise" || value === "detailed"
? value
: "detailed";
}
export function colorMarkDescription(value: number): string {
return COLOR_MARK_DESCRIPTIONS[value - 1] ?? `mark ${String(value)}`;
}
+171 -5
View File
@@ -1,19 +1,23 @@
import { SudokuFormatError } from "../formats";
import {
cloneProjectRecord,
createProjectRecord,
MAX_PROJECT_BYTES,
normalizeProjectRecord,
} from "./record";
import type {
ProjectLibraryExport,
ProjectLibraryQuery,
SudokuProjectRecord,
SudokuProjectSummary,
} from "./types";
export const MAX_LIBRARY_PROJECTS = 256;
export const MAX_MEMORY_LIBRARY_BYTES = 32 * 1_048_576;
const DATABASE_VERSION = 1;
const DATABASE_VERSION = 2;
const STORE_NAME = "projects";
const AUTOSAVE_STORE_NAME = "autosaves";
const DEFAULT_AUTOSAVE_SLOT = "current";
export type ProjectLibraryMode = "indexeddb" | "memory";
@@ -30,9 +34,34 @@ function summary(record: SudokuProjectRecord): SudokuProjectSummary {
updatedAt: record.updatedAt,
size: record.puzzle.size,
completed: record.progress?.completed ?? false,
tags: [...(record.tags ?? [])],
thumbnail:
record.thumbnail ??
record.puzzle.givens
.map((value) => (value === 0 ? "." : String(value)))
.join(""),
};
}
function matchesQuery(
item: SudokuProjectSummary,
query: ProjectLibraryQuery,
): boolean {
const search = query.search?.trim().toLocaleLowerCase();
if (
search &&
!item.title.toLocaleLowerCase().includes(search) &&
!item.tags.some((tag) => tag.toLocaleLowerCase().includes(search))
) {
return false;
}
const tags = query.tags?.filter(Boolean) ?? [];
if (tags.length > 0 && !tags.every((tag) => item.tags.includes(tag))) {
return false;
}
return query.completed === undefined || item.completed === query.completed;
}
function bytes(record: SudokuProjectRecord): number {
return new TextEncoder().encode(JSON.stringify(record)).byteLength;
}
@@ -63,10 +92,21 @@ async function openDatabase(
const request = factory.open(name, DATABASE_VERSION);
request.onupgradeneeded = () => {
const database = request.result;
if (!database.objectStoreNames.contains(STORE_NAME)) {
const store = database.createObjectStore(STORE_NAME, { keyPath: "id" });
const store = database.objectStoreNames.contains(STORE_NAME)
? request.transaction!.objectStore(STORE_NAME)
: database.createObjectStore(STORE_NAME, { keyPath: "id" });
if (!store.indexNames.contains("updatedAt")) {
store.createIndex("updatedAt", "updatedAt");
}
if (!store.indexNames.contains("title")) {
store.createIndex("title", "title");
}
if (!store.indexNames.contains("tags")) {
store.createIndex("tags", "tags", { multiEntry: true });
}
if (!database.objectStoreNames.contains(AUTOSAVE_STORE_NAME)) {
database.createObjectStore(AUTOSAVE_STORE_NAME, { keyPath: "slot" });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () =>
@@ -80,6 +120,7 @@ export class ProjectLibrary {
readonly #factory: IDBFactory | null;
readonly #databaseName: string;
readonly #memory = new Map<string, SudokuProjectRecord>();
readonly #memoryAutosaves = new Map<string, SudokuProjectRecord>();
#database: Promise<IDBDatabase> | undefined;
#mode: ProjectLibraryMode;
@@ -143,11 +184,14 @@ export class ProjectLibrary {
this.#memory.set(record.id, cloneProjectRecord(record));
}
async list(): Promise<readonly SudokuProjectSummary[]> {
async list(
query: ProjectLibraryQuery = {},
): Promise<readonly SudokuProjectSummary[]> {
const database = await this.#db();
if (database === undefined) {
return [...this.#memory.values()]
.map(summary)
.filter((item) => matchesQuery(item, query))
.sort(
(a, b) => b.updatedAt - a.updatedAt || a.title.localeCompare(b.title),
);
@@ -166,13 +210,14 @@ export class ProjectLibrary {
}
return records
.map((record) => summary(normalizeProjectRecord(record)))
.filter((item) => matchesQuery(item, query))
.sort(
(a, b) => b.updatedAt - a.updatedAt || a.title.localeCompare(b.title),
);
} catch (error) {
if (error instanceof SudokuFormatError) throw error;
this.#mode = "memory";
return this.list();
return this.list(query);
}
}
@@ -264,6 +309,82 @@ export class ProjectLibrary {
}
}
/** Persist the latest working state separately from explicit Library saves. */
async putAutosave(
value: SudokuProjectRecord,
slot = DEFAULT_AUTOSAVE_SLOT,
): Promise<SudokuProjectRecord> {
if (!slot || slot.length > 128) {
throw new SudokuFormatError(
"INVALID_PROJECT",
"Autosave slot is invalid.",
);
}
const record = normalizeProjectRecord(value);
const database = await this.#db();
if (database === undefined) {
this.#memoryAutosaves.set(slot, cloneProjectRecord(record));
return cloneProjectRecord(record);
}
try {
const transaction = database.transaction(
AUTOSAVE_STORE_NAME,
"readwrite",
);
transaction.objectStore(AUTOSAVE_STORE_NAME).put({ slot, record });
await transactionDone(transaction);
return cloneProjectRecord(record);
} catch {
this.#mode = "memory";
this.#memoryAutosaves.set(slot, cloneProjectRecord(record));
return cloneProjectRecord(record);
}
}
async getAutosave(
slot = DEFAULT_AUTOSAVE_SLOT,
): Promise<SudokuProjectRecord | undefined> {
const database = await this.#db();
if (database === undefined) {
const record = this.#memoryAutosaves.get(slot);
return record === undefined ? undefined : cloneProjectRecord(record);
}
try {
const transaction = database.transaction(AUTOSAVE_STORE_NAME, "readonly");
const value = await requestResult(
transaction.objectStore(AUTOSAVE_STORE_NAME).get(slot),
);
await transactionDone(transaction);
if (typeof value !== "object" || value === null || !("record" in value)) {
return undefined;
}
return normalizeProjectRecord((value as { record: unknown }).record);
} catch (error) {
if (error instanceof SudokuFormatError) throw error;
this.#mode = "memory";
return this.getAutosave(slot);
}
}
async clearAutosave(slot = DEFAULT_AUTOSAVE_SLOT): Promise<void> {
const database = await this.#db();
if (database === undefined) {
this.#memoryAutosaves.delete(slot);
return;
}
try {
const transaction = database.transaction(
AUTOSAVE_STORE_NAME,
"readwrite",
);
transaction.objectStore(AUTOSAVE_STORE_NAME).delete(slot);
await transactionDone(transaction);
} catch {
this.#mode = "memory";
this.#memoryAutosaves.delete(slot);
}
}
async exportAll(): Promise<ProjectLibraryExport> {
const summaries = await this.list();
const projects: SudokuProjectRecord[] = [];
@@ -279,6 +400,51 @@ export class ProjectLibrary {
};
}
async exportSelected(ids: readonly string[]): Promise<ProjectLibraryExport> {
const unique = [...new Set(ids)].slice(0, MAX_LIBRARY_PROJECTS);
const projects: SudokuProjectRecord[] = [];
for (const id of unique) {
const record = await this.get(id);
if (record !== undefined) projects.push(record);
}
return {
schema: "de.add-ideas.sudoku-tools.library",
version: 1,
exportedAt: Date.now(),
projects,
};
}
/** Create independent local copies without reusing source record IDs. */
async duplicateSelected(ids: readonly string[]): Promise<number> {
const unique = [...new Set(ids)].slice(0, MAX_LIBRARY_PROJECTS);
const sources: SudokuProjectRecord[] = [];
for (const id of unique) {
const source = await this.get(id);
if (source !== undefined) sources.push(source);
}
if ((await this.list()).length + sources.length > MAX_LIBRARY_PROJECTS) {
throw new SudokuFormatError(
"STORAGE_LIMIT",
`Copying this selection would exceed the ${String(MAX_LIBRARY_PROJECTS)}-project limit.`,
);
}
let copied = 0;
for (const source of sources) {
const now = Date.now() + copied;
await this.put(
createProjectRecord(source.puzzle, {
title: `${source.title || "Untitled puzzle"} copy`,
progress: source.progress,
tags: source.tags,
now,
}),
);
copied += 1;
}
return copied;
}
async importAll(value: unknown, replace = false): Promise<number> {
if (
typeof value !== "object" ||
+114
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1,
"id": "de.add-ideas.sudoku-tools",
"name": "Sudoku Tools",
"version": "0.1.0",
"version": "0.2.1",
"description": "Set, play, solve and analyse Sudoku puzzles locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
+1 -1
View File
@@ -1 +1 @@
export const APPLICATION_VERSION = "0.1.0";
export const APPLICATION_VERSION = "0.2.1";
+30
View File
@@ -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 {
+12
View File
@@ -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":
+409
View File
@@ -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);
}
});
+148
View File
@@ -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();
});
});
+91
View File
@@ -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();
});
});
+100
View File
@@ -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);
});
});
+474
View File
@@ -0,0 +1,474 @@
import { useState } from "react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ConstraintEditor } from "../../src/components/ConstraintEditor";
import { createEmptyPuzzle, type PuzzleDefinition } from "../../src/domain";
function EditorHarness({
selection,
initial = createEmptyPuzzle(4),
}: {
readonly selection: readonly number[];
readonly initial?: PuzzleDefinition;
}) {
const [puzzle, setPuzzle] = useState(initial);
return (
<>
<ConstraintEditor
puzzle={puzzle}
selection={selection}
onChange={setPuzzle}
onNewGrid={vi.fn()}
onCheck={vi.fn()}
onGenerate={vi.fn()}
busy={false}
/>
<output data-testid="puzzle-state">{JSON.stringify(puzzle)}</output>
</>
);
}
function currentPuzzle(): PuzzleDefinition {
return JSON.parse(
screen.getByTestId("puzzle-state").textContent ?? "{}",
) as PuzzleDefinition;
}
describe("ConstraintEditor expansion controls", () => {
it("validates, replaces, polarizes and removes selected-cell markers", async () => {
const user = userEvent.setup();
render(<EditorHarness selection={[5]} />);
await user.click(
screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
);
await user.click(screen.getByRole("button", { name: "Minimum cell" }));
expect(screen.getByText("false · minimum · r2c2")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Odd cell (circle)" }));
expect(screen.getByText("false · odd circle · r2c2")).toBeInTheDocument();
await user.click(
screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
);
await user.click(screen.getByRole("button", { name: "Odd cell (circle)" }));
expect(screen.getByText("odd circle · r2c2")).toBeInTheDocument();
expect(
currentPuzzle().constraints?.filter(({ type }) => type === "odd"),
).toHaveLength(1);
await user.click(
screen.getByRole("button", { name: "Even cell (square)" }),
);
expect(screen.getByText("even square · r2c2")).toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "Remove selected cell markers" }),
);
expect(currentPuzzle().constraints).toEqual([]);
});
it("requires exactly one selected cell for cell-marker creation", () => {
render(<EditorHarness selection={[0, 1]} />);
for (const name of [
"Maximum cell",
"Minimum cell",
"Odd cell (circle)",
"Even cell (square)",
]) {
expect(screen.getByRole("button", { name })).toBeDisabled();
}
expect(
screen.getByRole("button", { name: "Remove selected cell markers" }),
).toBeDisabled();
});
it("toggles disjoint groups and validates/replaces new outside clues", async () => {
const user = userEvent.setup();
render(<EditorHarness selection={[]} />);
const disjoint = screen.getByRole("button", { name: "Disjoint groups" });
expect(disjoint).toHaveAttribute("aria-pressed", "false");
await user.click(disjoint);
expect(disjoint).toHaveAttribute("aria-pressed", "true");
expect(
screen.getByText(
"disjoint groups · matching box positions do not repeat",
),
).toBeInTheDocument();
await user.click(disjoint);
expect(disjoint).toHaveAttribute("aria-pressed", "false");
await user.selectOptions(screen.getByLabelText("Type"), "little-killer");
await user.selectOptions(screen.getByLabelText("Direction"), "down-left");
const add = screen.getByRole("button", {
name: "Add / replace outside clue",
});
// Top line 1 travelling down-left leaves the grid after one cell.
expect(add).toBeDisabled();
const line = screen.getByRole("spinbutton", { name: "Row / column" });
await user.clear(line);
await user.type(line, "2");
expect(add).toBeEnabled();
await user.click(add);
expect(
screen.getByText("little killer 3 · top 2 · down left"),
).toBeInTheDocument();
const sum = screen.getByRole("spinbutton", { name: "Sum" });
await user.clear(sum);
await user.type(sum, "4");
await user.click(add);
expect(
screen.queryByText("little killer 3 · top 2 · down left"),
).not.toBeInTheDocument();
expect(
screen.getByText("little killer 4 · top 2 · down left"),
).toBeInTheDocument();
expect(
currentPuzzle().constraints?.filter(
({ type }) => type === "little-killer",
),
).toHaveLength(1);
await user.selectOptions(screen.getByLabelText("Type"), "sandwich");
await user.clear(sum);
await user.type(sum, "1");
expect(add).toBeDisabled();
await user.clear(sum);
await user.type(sum, "3");
expect(add).toBeEnabled();
await user.click(add);
expect(screen.getByText("sandwich 3 · top 2")).toBeInTheDocument();
const constraintList = screen.getByRole("list");
await user.click(
within(constraintList).getByRole("button", {
name: "Remove little killer 4 · top 2 · down left",
}),
);
expect(
currentPuzzle().constraints?.some(({ type }) => type === "little-killer"),
).toBe(false);
});
it("creates ordered Pack 2 lines with bounded whisper settings", async () => {
const user = userEvent.setup();
render(<EditorHarness selection={[0, 1, 2]} />);
await user.click(
screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
);
await user.click(screen.getByRole("button", { name: "Between line" }));
expect(
screen.getByText("false · between line · 3 ordered cells"),
).toBeInTheDocument();
await user.click(
screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
);
const difference = screen.getByRole("spinbutton", {
name: "Whisper minimum difference",
});
await user.clear(difference);
await user.type(difference, "0");
expect(
screen.getByRole("button", { name: "German whisper" }),
).toBeDisabled();
await user.clear(difference);
await user.type(difference, "3");
await user.click(screen.getByRole("button", { name: "German whisper" }));
expect(
screen.getByText("German whisper ≥ 3 · 3 ordered cells"),
).toBeInTheDocument();
await user.clear(difference);
await user.type(difference, "2");
await user.click(screen.getByRole("button", { name: "German whisper" }));
expect(
screen.queryByText("German whisper ≥ 3 · 3 ordered cells"),
).not.toBeInTheDocument();
expect(
screen.getByText("German whisper ≥ 2 · 3 ordered cells"),
).toBeInTheDocument();
expect(
currentPuzzle().constraints?.filter(
({ type }) => type === "german-whisper",
),
).toHaveLength(1);
await user.click(screen.getByRole("button", { name: "Region-sum line" }));
expect(
screen.getByText("region-sum line · 3 ordered cells"),
).toBeInTheDocument();
expect(currentPuzzle().constraints).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "between-line",
cells: [0, 1, 2],
negated: true,
}),
expect.objectContaining({
type: "german-whisper",
cells: [0, 1, 2],
minimumDifference: 2,
}),
expect.objectContaining({
type: "region-sum-line",
cells: [0, 1, 2],
}),
]),
);
await user.click(
screen.getByRole("button", {
name: "Remove region-sum line · 3 ordered cells",
}),
);
expect(
currentPuzzle().constraints?.some(
({ type }) => type === "region-sum-line",
),
).toBe(false);
});
it("splits an ordered selection into clone pairs and builds an extra house", async () => {
const user = userEvent.setup();
render(<EditorHarness selection={[0, 1, 4, 5]} />);
const cloneButton = screen.getByRole("button", {
name: "Clone selection halves",
});
const extraButton = screen.getByRole("button", {
name: "Extra region (4 cells)",
});
expect(cloneButton).toBeEnabled();
expect(extraButton).toBeEnabled();
await user.click(cloneButton);
await user.click(cloneButton);
expect(
screen.getByText("clone regions · 2 + 2 paired cells"),
).toBeInTheDocument();
expect(
currentPuzzle().constraints?.filter(({ type }) => type === "clone"),
).toHaveLength(1);
expect(currentPuzzle().constraints).toContainEqual({
type: "clone",
cells: [0, 1],
cloneCells: [4, 5],
});
await user.click(extraButton);
await user.click(extraButton);
const extraDescription = screen.getByText("extra region · 4 cells");
expect(
currentPuzzle().constraints?.filter(
({ type }) => type === "extra-region",
),
).toHaveLength(1);
const extraItem = extraDescription.closest("li");
expect(extraItem).not.toBeNull();
expect(
within(extraItem as HTMLElement).queryByRole("button", {
name: /require false/iu,
}),
).not.toBeInTheDocument();
expect(
within(
screen
.getByText("clone regions · 2 + 2 paired cells")
.closest("li") as HTMLElement,
).getByRole("button", { name: /require false/iu }),
).toBeInTheDocument();
await user.click(
screen.getByRole("button", {
name: "Remove clone regions · 2 + 2 paired cells",
}),
);
await user.click(
screen.getByRole("button", { name: "Remove extra region · 4 cells" }),
);
expect(currentPuzzle().constraints).toEqual([]);
});
it("rejects incomplete Pack 2 selections", () => {
render(<EditorHarness selection={[0, 1, 2]} />);
expect(
screen.getByRole("button", { name: "Clone selection halves" }),
).toBeDisabled();
expect(
screen.getByRole("button", { name: "Extra region (4 cells)" }),
).toBeDisabled();
});
it("creates, replaces, polarizes and removes ordered Pack 3 lines", async () => {
const user = userEvent.setup();
render(
<EditorHarness selection={[0, 1, 2]} initial={createEmptyPuzzle(6)} />,
);
await user.click(
screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
);
for (const name of [
"Modular line",
"Entropic line",
"Zipper line",
"Double arrow",
]) {
const button = screen.getByRole("button", { name });
expect(button).toBeEnabled();
await user.click(button);
}
expect(
screen.getByText("false · modular line · 3 ordered cells"),
).toBeInTheDocument();
expect(
screen.getByText("false · entropic line · 3 ordered cells"),
).toBeInTheDocument();
expect(
screen.getByText("false · zipper line · 3 ordered cells"),
).toBeInTheDocument();
expect(
screen.getByText("false · double arrow · 3 ordered cells"),
).toBeInTheDocument();
expect(currentPuzzle().constraints).toHaveLength(4);
await user.click(screen.getByRole("button", { name: "Modular line" }));
expect(
currentPuzzle().constraints?.filter(
({ type }) => type === "modular-line",
),
).toHaveLength(1);
await user.click(
screen.getByRole("button", {
name: "Remove false · zipper line · 3 ordered cells",
}),
);
expect(
currentPuzzle().constraints?.some(({ type }) => type === "zipper-line"),
).toBe(false);
});
it("rejects incompatible Pack 3 grid and line lengths", () => {
const { unmount } = render(<EditorHarness selection={[0, 1, 2]} />);
expect(
screen.getByRole("button", { name: "Entropic line" }),
).toBeDisabled();
unmount();
render(
<EditorHarness selection={[0, 1, 2, 3]} initial={createEmptyPuzzle(6)} />,
);
expect(screen.getByRole("button", { name: "Modular line" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Double arrow" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Entropic line" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Zipper line" })).toBeDisabled();
expect(
screen.getByRole("button", { name: "Add / replace indexer" }),
).toBeDisabled();
});
it("replaces indexer kinds at one marker and keeps polarity controls", async () => {
const user = userEvent.setup();
render(<EditorHarness selection={[5]} />);
await user.click(
screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
);
await user.click(
screen.getByRole("button", { name: "Add / replace indexer" }),
);
expect(screen.getByText("false · row indexer · r2c2")).toBeInTheDocument();
await user.selectOptions(screen.getByLabelText("Indexer kind"), "column");
await user.click(
screen.getByRole("button", { name: "Add / replace indexer" }),
);
expect(
screen.queryByText("false · row indexer · r2c2"),
).not.toBeInTheDocument();
expect(
screen.getByText("false · column indexer · r2c2"),
).toBeInTheDocument();
expect(
currentPuzzle().constraints?.filter(({ type }) => type === "indexer"),
).toHaveLength(1);
await user.click(
screen.getByRole("button", {
name: "Remove false · column indexer · r2c2",
}),
);
expect(currentPuzzle().constraints).toEqual([]);
});
it("requires a trusted solution for fog and replaces its lights and radius", async () => {
const user = userEvent.setup();
const { unmount } = render(<EditorHarness selection={[0, 5]} />);
const addFog = screen.getByRole("button", {
name: "Add / replace Fog of War",
});
expect(addFog).toBeDisabled();
expect(
screen.getByText(
"Fog of War requires a complete trusted solution. Generate or import one before choosing initial lights.",
),
).toBeInTheDocument();
unmount();
const solution = [1, 2, 3, 4, 3, 4, 1, 2, 2, 1, 4, 3, 4, 3, 2, 1];
render(
<EditorHarness
selection={[0, 5]}
initial={{ ...createEmptyPuzzle(4), solution }}
/>,
);
const enabledFog = screen.getByRole("button", {
name: "Add / replace Fog of War",
});
expect(enabledFog).toBeEnabled();
await user.click(enabledFog);
expect(
screen.getByText("fog · 2 initial lights · radius 1"),
).toBeInTheDocument();
expect(currentPuzzle().constraints).toContainEqual({
type: "fog",
lights: [0, 5],
revealRadius: 1,
});
await user.selectOptions(screen.getByLabelText("Fog reveal radius"), "0");
await user.click(enabledFog);
expect(
screen.queryByText("fog · 2 initial lights · radius 1"),
).not.toBeInTheDocument();
const description = screen.getByText("fog · 2 initial lights · radius 0");
expect(description).toBeInTheDocument();
expect(
currentPuzzle().constraints?.filter(({ type }) => type === "fog"),
).toHaveLength(1);
const item = description.closest("li");
expect(item).not.toBeNull();
expect(
within(item as HTMLElement).queryByRole("button", {
name: /require false/iu,
}),
).not.toBeInTheDocument();
await user.click(
within(item as HTMLElement).getByRole("button", {
name: "Remove fog · 2 initial lights · radius 0",
}),
);
expect(currentPuzzle().constraints).toEqual([]);
});
});
@@ -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);
});
});
+181
View File
@@ -0,0 +1,181 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import {
GuidedHint,
type GuidedHintProps,
} from "../../src/components/GuidedHint";
import {
deriveGuidedHintCellSets,
guidedHintEffectItems,
guidedHintFocusSummary,
guidedHintOverlay,
guidedHintStepIsVisible,
nextGuidedHintStage,
} from "../../src/components/guidedHint";
import type { LogicalStep } from "../../src/solver";
const step: LogicalStep = {
technique: "naked-pair",
focusCells: [0, 1, 0],
placements: [{ cell: 9, value: 4 }],
eliminations: [{ cell: 10, values: [2, 3] }],
explanation: "The private pair reasoning is now visible.",
};
function props(overrides: Partial<GuidedHintProps> = {}): GuidedHintProps {
return {
size: 9,
step,
stage: "focus",
autoMaintainPeerNotes: false,
onRequestHint: vi.fn(),
onRevealNext: vi.fn(),
onApply: vi.fn(),
onDismiss: vi.fn(),
onFillLegalCandidates: vi.fn(),
onRemoveInvalidNotes: vi.fn(),
onAutoMaintainPeerNotesChange: vi.fn(),
...overrides,
};
}
describe("guided hint presentation helpers", () => {
it("derives unique focus and effect cells without revealing effects early", () => {
expect(deriveGuidedHintCellSets(step)).toEqual({
focusCells: [0, 1],
placementCells: [9],
eliminationCells: [10],
affectedCells: [9, 10],
});
expect(guidedHintOverlay(step, "reasoning")).toEqual({
focusCells: [0, 1],
placementCells: [],
eliminationCells: [],
});
expect(guidedHintOverlay(step, "preview")).toEqual({
focusCells: [0, 1],
placementCells: [9],
eliminationCells: [10],
});
});
it("describes focus locations, effects and the finite reveal sequence", () => {
expect(guidedHintFocusSummary(step, 9)).toBe("Look across row 1.");
expect(guidedHintFocusSummary({ ...step, focusCells: [0, 9] }, 9)).toBe(
"Look down column 1.",
);
expect(guidedHintFocusSummary({ ...step, focusCells: [0, 10] }, 9)).toBe(
"Look within box 1.",
);
expect(guidedHintEffectItems(step, 9)).toEqual([
"Place 4 in r2c1.",
"Remove 2 and 3 from r2c2.",
]);
expect(nextGuidedHintStage("focus")).toBe("technique");
expect(nextGuidedHintStage("technique")).toBe("reasoning");
expect(nextGuidedHintStage("reasoning")).toBe("preview");
expect(nextGuidedHintStage("preview")).toBeUndefined();
});
it("rejects a hint when fog hides a premise, placement or elimination", () => {
expect(guidedHintStepIsVisible(step, new Set())).toBe(true);
expect(guidedHintStepIsVisible(step, new Set([0]))).toBe(false);
expect(guidedHintStepIsVisible(step, new Set([9]))).toBe(false);
expect(guidedHintStepIsVisible(step, new Set([10]))).toBe(false);
expect(guidedHintStepIsVisible(step, new Set([80]))).toBe(true);
});
});
describe("guided hint", () => {
it("keeps unrevealed answer content out of the document", () => {
const base = props();
const { rerender } = render(<GuidedHint {...base} />);
expect(screen.getByText("Look across row 1.")).toBeInTheDocument();
expect(screen.queryByText("Naked Pair")).not.toBeInTheDocument();
expect(screen.queryByText(step.explanation)).not.toBeInTheDocument();
expect(screen.queryByText("Place 4 in r2c1.")).not.toBeInTheDocument();
rerender(<GuidedHint {...base} stage="technique" />);
expect(screen.getByText("Naked Pair")).toBeInTheDocument();
expect(screen.queryByText(step.explanation)).not.toBeInTheDocument();
expect(screen.queryByText("Place 4 in r2c1.")).not.toBeInTheDocument();
rerender(<GuidedHint {...base} stage="reasoning" />);
expect(screen.getByText(step.explanation)).toBeInTheDocument();
expect(screen.queryByText("Place 4 in r2c1.")).not.toBeInTheDocument();
rerender(<GuidedHint {...base} stage="preview" />);
expect(screen.getByText("Place 4 in r2c1.")).toBeInTheDocument();
expect(screen.getByText("Remove 2 and 3 from r2c2.")).toBeInTheDocument();
expect(
screen.getByText(/will start a complete legal centre-candidate grid/u),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Apply this step" }),
).toBeEnabled();
});
it("delegates reveal, apply, replacement and dismissal to its parent", async () => {
const user = userEvent.setup();
const callbacks = {
onRevealNext: vi.fn(),
onApply: vi.fn(),
onRequestHint: vi.fn(),
onDismiss: vi.fn(),
};
const { rerender } = render(
<GuidedHint {...props(callbacks)} stage="focus" />,
);
await user.click(screen.getByRole("button", { name: "Reveal technique" }));
expect(callbacks.onRevealNext).toHaveBeenCalledOnce();
await user.click(screen.getByRole("button", { name: "New hint" }));
await user.click(screen.getByRole("button", { name: "Dismiss" }));
expect(callbacks.onRequestHint).toHaveBeenCalledOnce();
expect(callbacks.onDismiss).toHaveBeenCalledOnce();
rerender(<GuidedHint {...props(callbacks)} stage="preview" />);
await user.click(screen.getByRole("button", { name: "Apply this step" }));
expect(callbacks.onApply).toHaveBeenCalledOnce();
});
it("offers candidate maintenance without requiring an open hint", async () => {
const user = userEvent.setup();
const onRequestHint = vi.fn();
const onFillLegalCandidates = vi.fn();
const onRemoveInvalidNotes = vi.fn();
const onAutoMaintainPeerNotesChange = vi.fn();
render(
<GuidedHint
{...props({
step: undefined,
onRequestHint,
onFillLegalCandidates,
onRemoveInvalidNotes,
onAutoMaintainPeerNotesChange,
})}
/>,
);
await user.click(screen.getByRole("button", { name: "Get a guided hint" }));
await user.click(
screen.getByRole("button", { name: "Fill legal candidates" }),
);
await user.click(
screen.getByRole("button", { name: "Remove invalid notes" }),
);
await user.click(
screen.getByRole("checkbox", {
name: "Automatically remove peer notes after placing a digit",
}),
);
expect(onRequestHint).toHaveBeenCalledOnce();
expect(onFillLegalCandidates).toHaveBeenCalledOnce();
expect(onRemoveInvalidNotes).toHaveBeenCalledOnce();
expect(onAutoMaintainPeerNotesChange).toHaveBeenCalledWith(true);
});
});
+117
View File
@@ -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();
});
});
+109
View File
@@ -0,0 +1,109 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { LibraryDialog } from "../../src/components/LibraryDialog";
const summaries = [
{
id: "killer",
title: "Evening Killer",
createdAt: 1,
updatedAt: 2,
size: 4,
completed: false,
tags: ["killer", "hard"],
thumbnail: "1...............",
},
{
id: "classic",
title: "Morning Classic",
createdAt: 1,
updatedAt: 3,
size: 4,
completed: true,
tags: ["classic"],
thumbnail: "....2...........",
},
] as const;
function renderDialog(overrides: Record<string, unknown> = {}) {
const props = {
open: true,
summaries,
mode: "indexeddb" as const,
busy: false,
onClose: vi.fn(),
onSave: vi.fn(),
onOpen: vi.fn(),
onDelete: vi.fn(),
onClear: vi.fn(),
onExport: vi.fn(),
onExportSelected: vi.fn(),
onDuplicateSelected: vi.fn(),
onDeleteSelected: vi.fn(),
onUpdateTags: vi.fn(),
onImport: vi.fn(),
...overrides,
};
render(<LibraryDialog {...props} />);
return props;
}
describe("LibraryDialog", () => {
it("filters by text, tag and completion while showing safe previews", async () => {
const user = userEvent.setup();
renderDialog();
expect(
screen.getAllByRole("img", { name: /puzzle preview/u }),
).toHaveLength(2);
await user.type(screen.getByRole("searchbox"), "killer");
expect(screen.getByText("Evening Killer")).toBeInTheDocument();
expect(screen.queryByText("Morning Classic")).not.toBeInTheDocument();
await user.clear(screen.getByRole("searchbox"));
await user.selectOptions(
screen.getByLabelText("Completion filter"),
"complete",
);
expect(screen.getByText("Morning Classic")).toBeInTheDocument();
expect(screen.queryByText("Evening Killer")).not.toBeInTheDocument();
});
it("exports, duplicates and deletes an explicit selection", async () => {
const user = userEvent.setup();
const props = renderDialog();
await user.click(screen.getByLabelText("Select Evening Killer"));
const selection = screen.getByText(/1 selected/u).parentElement!;
await user.click(
within(selection).getByRole("button", { name: "Export selected" }),
);
await user.click(
within(selection).getByRole("button", { name: "Duplicate selected" }),
);
await user.click(
within(selection).getByRole("button", { name: "Delete selected" }),
);
expect(props.onExportSelected).toHaveBeenCalledWith(["killer"]);
expect(props.onDuplicateSelected).toHaveBeenCalledWith(["killer"]);
expect(props.onDeleteSelected).toHaveBeenCalledWith(["killer"]);
});
it("edits bounded comma-separated tags", async () => {
const user = userEvent.setup();
const props = renderDialog();
const item = screen.getByText("Evening Killer").closest("li")!;
await user.click(within(item).getByRole("button", { name: "Edit tags" }));
const input = within(item).getByLabelText("Tags for Evening Killer");
await user.clear(input);
await user.type(input, "killer, weekend");
await user.click(within(item).getByRole("button", { name: "Apply" }));
expect(props.onUpdateTags).toHaveBeenCalledWith("killer", [
"killer",
"weekend",
]);
});
});

Some files were not shown because too many files have changed in this diff Show More