From 0a1bdc1a8c5ac2948db6974b79fe37dff8289482 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 31 Aug 2026 08:20:30 +0200 Subject: [PATCH] feat: complete advanced Sudoku workbench --- CHANGELOG.md | 67 +- README.md | 255 +++- SOURCE.md | 4 +- index.html | 2 + package-lock.json | 4 +- package.json | 2 +- playwright.config.ts | 5 +- public/CHANGELOG.md | 67 +- public/README.md | 255 +++- public/SOURCE.md | 4 +- public/manifest.webmanifest | 19 + public/sw.js | 119 ++ public/toolbox-app.json | 2 +- scripts/package-release.mjs | 2 + scripts/serve-test.mjs | 1 + src/components/BoardViewport.tsx | 183 +++ src/components/ConstraintEditor.tsx | 730 +++++++++- src/components/GeneratorWorkspace.tsx | 448 +++++- src/components/GuidedHint.tsx | 273 ++++ src/components/HelpDialog.tsx | 170 ++- src/components/ImportExportDialog.tsx | 156 ++- src/components/LibraryDialog.tsx | 277 +++- src/components/NumberPad.tsx | 5 +- src/components/SafeVisualLayer.tsx | 139 ++ src/components/SetterQualityLab.tsx | 740 ++++++++++ src/components/SudokuBoard.tsx | 915 +++++++++++-- src/components/Workbench.tsx | 1048 ++++++++++++-- src/components/fogVisibility.ts | 60 + src/components/guidedHint.ts | 223 +++ src/domain/compile.ts | 97 +- src/domain/constraintRegistry.ts | 482 +++++++ src/domain/geometry.ts | 150 +- src/domain/index.ts | 1 + src/domain/rules.ts | 618 +++++++-- src/domain/types.ts | 146 +- src/domain/validation.ts | 457 ++++++- src/formats/constraintVisuals.ts | 409 ++++++ src/formats/document.ts | 235 +++- src/formats/fpuzzles.ts | 785 ++++++++++- src/formats/grid.ts | 1 + src/formats/import.ts | 105 +- src/formats/index.ts | 2 + src/formats/interoperability.ts | 121 +- src/formats/penpa.ts | 1 + src/formats/safeVisuals.ts | 407 ++++++ src/formats/share.ts | 5 +- src/formats/sudokupad.ts | 837 +++++++++++- src/formats/types.ts | 274 +++- src/formats/visual.ts | 215 ++- src/helpers/candidates.ts | 4 + src/main.tsx | 14 + src/solver/advancedLogical.ts | 954 +++++++++++++ src/solver/difficulty.ts | 16 +- src/solver/generator.ts | 316 ++++- src/solver/index.ts | 2 + src/solver/logical.ts | 123 +- src/solver/quality.ts | 791 +++++++++++ src/solver/variantGenerator.ts | 612 +++++++-- src/state/aidMemoire.ts | 7 +- src/state/candidateMaintenance.ts | 213 +++ src/state/playHistory.ts | 282 ++++ src/state/uiPreferences.ts | 70 + src/storage/library.ts | 176 ++- src/storage/record.ts | 88 ++ src/storage/types.ts | 13 + src/styles.css | 1285 +++++++++++++++++- src/toolbox/manifest.source.json | 2 +- src/version.ts | 2 +- src/workers/protocol.ts | 15 + src/workers/solver.worker.ts | 6 + tests/browser/workbench.spec.ts | 126 ++ tests/components/boardViewport.test.tsx | 72 + tests/components/constraintEditor.test.tsx | 474 +++++++ tests/components/generatorWorkspace.test.tsx | 159 +++ tests/components/guidedHint.test.tsx | 181 +++ tests/components/importExportDialog.test.tsx | 80 +- tests/components/libraryDialog.test.tsx | 109 ++ tests/components/numberPad.test.tsx | 30 + tests/components/safeVisualLayer.test.tsx | 126 ++ tests/components/setterQualityLab.test.tsx | 279 ++++ tests/components/sudokuBoard.test.tsx | 597 +++++++- tests/components/workbench.test.tsx | 190 ++- tests/domain/constraintRegistry.test.ts | 123 ++ tests/domain/pack1Constraints.test.ts | 273 ++++ tests/domain/pack2Constraints.test.ts | 222 +++ tests/domain/pack3Constraints.test.ts | 355 +++++ tests/formats/document.test.ts | 7 +- tests/formats/fpuzzles.test.ts | 61 +- tests/formats/interoperability.test.ts | 44 +- tests/formats/sourcePreservation.test.ts | 360 +++++ tests/solver/advancedLogical.test.ts | 311 +++++ tests/solver/generatorV2.test.ts | 297 ++++ tests/solver/logical.test.ts | 111 +- tests/solver/quality.test.ts | 267 ++++ tests/state/aidMemoire.test.ts | 2 +- tests/state/candidateMaintenance.test.ts | 145 ++ tests/state/playHistoryPersistence.test.ts | 36 + tests/state/uiPreferences.test.ts | 51 + tests/storage/library.test.ts | 114 ++ 99 files changed, 20793 insertions(+), 923 deletions(-) create mode 100644 public/manifest.webmanifest create mode 100644 public/sw.js create mode 100644 src/components/BoardViewport.tsx create mode 100644 src/components/GuidedHint.tsx create mode 100644 src/components/SafeVisualLayer.tsx create mode 100644 src/components/SetterQualityLab.tsx create mode 100644 src/components/fogVisibility.ts create mode 100644 src/components/guidedHint.ts create mode 100644 src/domain/constraintRegistry.ts create mode 100644 src/formats/constraintVisuals.ts create mode 100644 src/formats/safeVisuals.ts create mode 100644 src/solver/advancedLogical.ts create mode 100644 src/solver/quality.ts create mode 100644 src/state/candidateMaintenance.ts create mode 100644 src/state/uiPreferences.ts create mode 100644 tests/components/boardViewport.test.tsx create mode 100644 tests/components/constraintEditor.test.tsx create mode 100644 tests/components/guidedHint.test.tsx create mode 100644 tests/components/libraryDialog.test.tsx create mode 100644 tests/components/numberPad.test.tsx create mode 100644 tests/components/safeVisualLayer.test.tsx create mode 100644 tests/components/setterQualityLab.test.tsx create mode 100644 tests/domain/constraintRegistry.test.ts create mode 100644 tests/domain/pack1Constraints.test.ts create mode 100644 tests/domain/pack2Constraints.test.ts create mode 100644 tests/domain/pack3Constraints.test.ts create mode 100644 tests/formats/sourcePreservation.test.ts create mode 100644 tests/solver/advancedLogical.test.ts create mode 100644 tests/solver/generatorV2.test.ts create mode 100644 tests/solver/quality.test.ts create mode 100644 tests/state/candidateMaintenance.test.ts create mode 100644 tests/state/playHistoryPersistence.test.ts create mode 100644 tests/state/uiPreferences.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a30e3ee..ab1d3dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ All notable changes are documented here. ## Unreleased +## 0.2.0 - 2026-08-31 + +- Added staged guided hints that reveal focus, technique, reasoning and effects + before an explicit, undoable apply action. +- Added legal centre-candidate filling, invalid note pruning and optional peer + note maintenance after placements; erasing a value never invents candidates. +- Added Jellyfish, finned X-Wing/Swordfish, Skyscraper, Two-String Kite, Simple + Colouring, W-Wing, X-Chain, XY-Chain and alternating-inference-chain logical + steps with deterministic evidence. +- Added Unique Rectangle only behind a completed exact uniqueness proof; a + truncated search never enables uniqueness-dependent logic. +- Added quick and full setter-quality audits with ambiguity witnesses, + contradiction suspects, per-item critical/redundant/unknown findings, a cell + heatmap and optional bounded minimality proof. +- Added explicit per-check and aggregate quality budgets, cancellation and + serializable check evidence so a capped search remains unknown rather than + becoming a false proof. +- Added minimum, odd/even, disjoint-groups, little-killer and sandwich rules + across validation, candidate filtering, exact solving, rendering and setting. +- Added between, German-whisper, region-sum, modular, entropic and zipper lines; + clone and extra regions; double arrows; and row, column and box indexers. +- Added display-only Fog of War backed by a complete locally stored solution. + Only initial lights, givens and correctly entered values reveal cells; fog is + never treated as an extra solution constraint. - Added optional digit-completion counts with muted complete digits and red over-completion warnings. - Added toggleable Ctrl/Command-click matching-digit highlights without turning @@ -12,10 +36,16 @@ All notable changes are documented here. remain accidentally active. - Replaced ambiguous V/X pair labels on the board with numbered 5/10 badges and gave inequalities a directional chevron with a marked lesser-value tip. -- Added thirteen uniquely checked built-in examples covering every supported - constraint family. -- Added bounded, seedable generation for classic and twelve variant families, - plus independent uniqueness and evidence-based difficulty assessment. +- Added fourteen uniquely checked built-in examples covering the original core + constraint families. +- Expanded bounded, seedable generation for classic and twelve variant families + with mixed-family recipes, sparse/balanced/dense clue controls and additional + reflection, diagonal and four-way clue symmetries. +- Added explicit minimal-givens mode with proof/unknown reporting, deterministic + batches of up to 12 candidates and rankings by difficulty or clue count. +- Added required/forbidden/minimum/maximum logical-technique profiles and an + exact hardest-technique target, accepted only against an independent complete + logical path and exact uniqueness check. - Added X-sum, skyscraper, quadruple and maximum-cell constraints across the model, validator, exact solver, renderer, setter and f-puzzles interchange. - Added per-clue false semantics for liar/Wrogn puzzles, including bounded @@ -25,14 +55,30 @@ All notable changes are documented here. - Added outside-grid clue margins, combined dual X-sum/skyscraper labels, outward maximum arrows and visually distinct false clues. - Added strict, bounded local imports for SudokuPad/CTC data and supported - Penpa+ long links, including source reporting and explicit incompatibility - lists instead of silently discarded constructs. + Penpa+ long links, with a structured preview of mapped semantics, preserved + drawings, metadata and compatibility warnings. +- Added a bounded inert visual model for f-puzzles and SCL lines, polylines, + rectangles, ellipses, circles and text; source identity, scalar metadata and + drawings survive edits, explicit saves, autosaves, reopening and project/share + round-trips without becoming rules, and render beneath Fog on the board. +- Added SudokuPad/CTC JSON and self-contained `scl…` export with progress, + regions, cages and canonical constraint drawings, while rejecting negated + clues or free-form global rules it cannot preserve safely. - Added a compatibility check which never fetches SudokuPad short IDs or other server-hosted puzzle references. - Added standalone SVG, high-resolution PNG and single-page PDF visual exports - for supported constraints, givens and optional solving progress. + for supported constraints, safe preserved drawings, givens and optional + solving progress. - Added named savepoints, isolated hypothesis branches, keep/discard decisions and read-only replay of complete solving states. +- Added debounced recovery snapshots for puzzle state, progress, aid-mémoire + and a validated, size-bounded gameplay history, with explicit restore/discard + handling after an interruption. +- Upgraded the local Library with title/tag search, tag and completion filters, + safe grid thumbnails, tag editing and selected export, duplication or + confirmed deletion. +- Added a relative-scope web app manifest and subpath-aware service worker with + an offline application-shell fallback after the first successful load. - Added an optional configurable aid-mémoire whose labelled scratch cells support values, both note styles and colours without constraining the puzzle; its state is preserved in undo, replay, Library progress and project/share @@ -46,6 +92,13 @@ All notable changes are documented here. - Added technique-targeted practice generation which mines a bounded, deterministic set of uniquely checked puzzles and verifies the requested technique in the logical solve path. +- Added a persisted 75–200% board viewport, Fit and board-scoped zoom shortcuts, + plus an announced Pan mode which pauses cell pointer targeting. +- Added tap-toggle multi-selection and a single safe-area-aware sticky entry pad + at narrow widths, with touch-sized zoom controls. +- Added eight hue-and-pattern colour marks, configurable off/concise/detailed + candidate narration, and fog-safe board, toolbar, helper and guided-hint + boundaries. - Added real ARIA row/gridcell semantics, detailed cell descriptions and non-wrapping Arrow, Home/End, Control/Command + Home/End and Page Up/Down keyboard navigation for the puzzle and scratch grids. diff --git a/README.md b/README.md index 7e8418c..c047285 100644 --- a/README.md +++ b/README.md @@ -9,76 +9,243 @@ release also runs independently from any static HTTPS host or local preview. - **Play** — keyboard, mouse and touch entry; multi-cell selection; values, corner/centre notes and colours; undo/redo; named savepoints, hypothesis - branches and read-only replay; conflict highlighting; timer and local - progress; optional digit-completion counts, matching-digit highlights and a - configurable non-constraining aid-mémoire. -- **Set** — givens, metadata, regions and typed constraints; uniqueness checks - with overlap-safe cage replacement and selection-based cage removal. -- **Generate** — seedable, bounded construction for classic and 12 variant - families; independent uniqueness verification and evidence-based difficulty - assessment; deterministic mining for a requested logical technique. + branches and read-only replay; staged guided hints; candidate maintenance; + conflict highlighting; timer and local progress; optional digit-completion + counts, matching-digit highlights and a configurable non-constraining + aid-mémoire. +- **Set** — givens, metadata, regions and registry-backed constraints; + overlap-safe clue replacement and removal; quick uniqueness checks; and a + bounded quality lab for ambiguity, contradictions, redundancy and + minimality. +- **Generate** — seedable single or batch construction for classic and 12 + variant families, including mixed-family recipes, clue symmetry, constraint + density, optional minimal-givens proof and independently checked technique + profiles. - **Solve** — exact solution counting plus an original human-style engine whose - steps include structured evidence, placements and eliminations. + deterministic steps include structured evidence, placements and + eliminations. Uniqueness-dependent logic is disabled unless uniqueness has + already been proved by a completed exact search. - **Helpers** — generalized sum combinations with positional candidates and manual eliminations; selected-cell and house candidate-link analysis; Killer combinations, 45-rule residuals, and Kropki, sum-pair or inequality pairs. -Classic grids and common variants share one bounded puzzle model: irregular -regions, diagonals, Killer cages, thermometers, arrows, Kropki and numbered -5/10 sum-pair clues, inequalities, renban lines, palindromes, X-sums, -skyscrapers, quadruples, maximum cells, anti-knight, anti-king and -non-consecutive rules. Every local clue can also be required to be false for -liar/Wrogn constructions. Fourteen original bundled examples demonstrate every -supported constraint and are exact-search verified as unique. +## Constraint coverage + +Classic grids and variants share one validated puzzle model and constraint +registry. The production engine currently supports: + +- classic, irregular and extra regions; diagonals; disjoint groups; + anti-knight, anti-king and non-consecutive rules; +- Killer cages, ordered clone regions, quadruples, maximum/minimum cells and + odd/even cells; +- thermometers, arrows, renban and palindrome lines, between lines, German + whispers, region-sum lines, modular lines, entropic lines, zipper lines and + double arrows; +- Kropki dots, numbered 5/10 sum pairs and inequalities; +- X-sums, skyscrapers, little-killer diagonals and sandwich sums; and +- row, column and rectangular-box indexers, plus display-only Fog of War. + +Each supported semantic constraint is validated and participates in candidate +filtering and exact solving. Negatable clues can instead be required to be false +for liar/Wrogn constructions; global house rules, extra regions and fog are not +given a misleading false mode. Fourteen original bundled examples cover the +core families and the uniquely checked “Truth and lies” construction. The +newer constraint packs are covered by focused validation, feasibility and exact +solver tests rather than bundled third-party puzzles. + +Fog is deliberately a presentation rule, not a hidden solver constraint. It +can be set only when the puzzle carries a complete solution that validates +against the current givens and constraints. Initial lights, givens and correctly +entered digits reveal a radius-zero or radius-one Chebyshev neighbourhood; +wrong entries reveal nothing. Obscured cells are disabled and their values, +notes, candidates, hints, quality overlays and fully hidden clue graphics are +omitted. The trusted solution remains part of the local puzzle document. + +## Guided solving and candidate maintenance + +A guided hint asks the worker for one supported logical step and discloses it in +four explicit stages: where to look, technique, reasoning and effects preview. +Later-stage answer content is not placed in the document before it is revealed, +and the board does not change until **Apply this step** is chosen. Applying a +step is one undoable transition. + +Candidate controls can fill every empty cell with its currently legal centre +candidates, remove invalid centre/corner notes, and optionally prune peer notes +after a placement. Erasing a digit never invents candidates. If an +elimination-only hint is applied without tracked candidates, the app first +creates a complete legal centre-candidate grid and then applies the explicit +elimination. + +The logical engine covers singles; naked and hidden pairs, triples and quads; +pointing and claiming; X-Wing, Swordfish and Jellyfish; finned X-Wing and +finned Swordfish; XY-Wing and XYZ-Wing; Skyscraper; Two-String Kite; Simple +Colouring; W-Wing; X-Chains, XY-Chains and alternating inference chains; and +Killer-cage deductions. Unique Rectangle is available only when the caller +supplies a completed uniqueness proof. The engine never infers uniqueness from +a puzzle’s appearance, metadata or a truncated search. + +## Setter-quality analysis + +The quick setter check performs a bounded zero/one/two-solution audit. A pair of +solutions produces a concrete ambiguity witness with every differing cell and +value. For a uniquely proved baseline, full analysis uses leave-one-out exact +searches to classify each given and constraint as critical, redundant or +unknown and creates a per-cell criticality heatmap. For an unsatisfiable +baseline it instead uses bounded deletion tests to localise a contradictory +core. Optional minimality is proved only when the unique baseline and every +required removal check complete. + +Every quality search has configurable per-check node/time limits and shared +aggregate check/node/time limits. Results retain the checks performed, elapsed +time and reasons for unknown conclusions. Reaching a safety limit is never +silently converted into uniqueness, redundancy, contradiction localisation or +minimality. The worker analysis can be cancelled without discarding the last +completed result. + +## Generator 2.0 + +Generation supports the existing classic, diagonal, anti-knight, anti-king, +non-consecutive, Killer, thermo, arrow, Kropki, XV, inequality, renban and +palindrome families. Compatible families can be combined when they share a +supported grid size. Local marking density can be sparse, balanced or dense; +for Killer this changes cage granularity, while inherently global rules retain +their fixed meaning. + +Clue removal supports no symmetry, half-turn rotation, horizontal or vertical +reflection, either diagonal reflection, and four-way quarter-turn symmetry. +Minimal-givens mode checks every retained given individually and reports either +a completed proof or the exact unknown reasons. Individual minimisation may +break the requested visual symmetry, which is reported rather than hidden. + +Technique profiles can require or forbid supported logical techniques, set +minimum/maximum occurrence counts and require an exact hardest technique. A +puzzle is accepted only when an independent, complete logical path matches the +whole profile and an independent exact check proves uniqueness. Deterministic +batches contain at most 12 candidates and can be ranked by difficulty, fewest +givens or most givens; failed bounded candidates remain visible as failures and +are never included in the verified ranking. Generation is cancellable by +terminating and restarting its worker. + +Requested difficulty still controls the clue-removal target rather than +promising a rating. The displayed 0–100 result is calculated afterwards from +logical techniques, clue load and reproducible exact-search evidence. Supported +size/family combinations are deliberately restricted, and a bounded mixed +construction may fail cleanly for an incompatible or unusually difficult +recipe. + +## Mobile and accessibility + +The board viewport offers a locally persisted 75–200% scale in 25% steps, +increment/decrement controls and a Fit action that returns to 100% and the +origin. Control/Command + plus, minus or zero are board-scoped equivalents. +Explicit Pan mode announces its state, cancels any in-progress drag selection +and disables cell pointer targeting until it is stopped. Tap multi-select, also +available with M, toggles cells individually without drag selection +and never leaves the active selection empty. + +At widths up to 48rem, Play and Set show exactly one sticky entry pad (outside +read-only replay) with safe-area padding and touch-sized zoom controls. Eight +colour marks use distinct patterns as well as hues and expose the hue/pattern +name on both the Sudoku board and aid-mémoire. Screen-reader candidate detail is +independently selectable as Off, concise counts or detailed digits without +changing the visible board. The board exposes real row/gridcell semantics and concise per-cell state to -assistive technology, including candidates, notes, colours, conflicts and -touching variant clues. Arrow keys never wrap; Home/End, Control/Command + -Home/End and Page Up/Down provide row- and grid-level navigation. The same -navigation model is available in the aid-mémoire scratch grid. +assistive technology, including candidates, notes, colours, conflicts, +touching variant clues, hint previews and quality findings. Arrow keys never +wrap; Home/End, Control/Command + Home/End and Page Up/Down provide row- and +grid-level navigation. The same navigation model is available in the +aid-mémoire scratch grid. Guided-hint and quality status changes use live +regions. + +Fogged cells remain disabled and expose only an obscured status to assistive +technology. Hidden values, notes, constraint descriptions, candidate/helper +overlays and hint effects are masked, and toolbar/helper selection is reduced +to visible cells. A guided hint is shown only if all of its focus and effect +cells are visible; otherwise the app returns a generic no-visible-hint status. ## Interoperability and visual export The import dialog recognises compact grids, Sudoku Tools JSON/share fragments, raw or inline f-puzzles data, SudokuPad/CTC (SCL) data, and Penpa+ self-contained long links. It never follows a URL or resolves a server-side short ID. Imports -are size/decompression bounded, report their detected source before applying, -and stop with a list when a construct cannot be represented faithfully. +are size/decompression bounded. Before applying one, a structured mapping +preview separates solver-enforced semantics, preserved drawings, retained +metadata and warnings. -SudokuPad compatibility currently preserves cells, givens and progress, notes, -regions, metadata, solutions, Killer cages and supported global rules. Penpa+ -compatibility is intentionally narrower: square, unrotated Sudoku grids with -givens/progress, thermometers and arrows. Visual-only lines, overlays, custom -symbols and other unsupported geometry are reported rather than ignored. -f-puzzles export likewise refuses false clues it cannot preserve. +The shared inert visual model supports bounded lines, polylines, rectangles, +ellipses, circles and text. f-puzzles maps its decorative line, rectangle, +circle and text fields; SCL maps supported lines, underlays, overlays and +arrows. Drawings, local source identity and scalar metadata survive domain +edits, explicit saves, autosaves, reopening, and project/share exports. They +never become Sudoku rules unless the preview also lists a mapped semantic +constraint. Raw SVG, HTML, CSS, paths or code, unsafe colour syntax, unknown +visual fields, excessive data and rotated or otherwise unrepresentable layouts +are rejected rather than executed or silently changed. + +SudokuPad compatibility maps cells, givens and progress, notes, regions, +metadata, solutions, Killer cages and supported global rules. Export can create +readable SudokuPad/CTC JSON or a self-contained `scl…` payload with progress, +regions, cages and canonical drawings for registered constraints. Sudoku Tools +retains its bounded constraint record for its own exact round-trip; another SCL +consumer should only be assumed to enforce constructs it represents natively. +Negated clues and free-form global rules cannot be exported to SCL. + +Penpa+ compatibility remains intentionally narrower: square, unrotated Sudoku +grids with givens/progress, thermometers and arrows. f-puzzles export likewise +refuses false clues and underlay, free-coordinate or offset drawings it cannot +preserve faithfully. Preserved source drawings appear on the interactive board +and in static/SCL exports; Fog covers their obscured in-grid portions. Export can produce a standalone SVG, a high-resolution PNG, or a single-page PDF entirely in the browser. Each visual uses the same trusted puzzle model and -renders region boundaries, supported variant clues and givens; current values, -notes and cell colours can be included or omitted with the progress switch. +renders region boundaries, supported variant clues, safe preserved drawings and +givens; current values, notes and cell colours can be included or omitted with +the progress switch. -Generation supports only size/variant combinations which pass the bounded -construction checks. The requested difficulty controls clue-removal targets; -the displayed 0–100 result is calculated afterwards from logical techniques, -clue load and reproducible exact-search evidence. A timeout or node limit is -reported as unknown and never promoted to a uniqueness or difficulty claim. -Technique practice performs a bounded, deterministic search and returns a -puzzle only when its independently analysed solve path actually contains the -requested technique. - -## Privacy and storage +## Privacy, recovery and Library Puzzle parsing, solving, generation and export happen entirely in the browser. There are no analytics, advertisements, accounts or network-backed puzzle -lookups. Projects and progress are saved only in local IndexedDB after an -explicit save action and can be cleared from inside the app. -Exported files and share URLs contain the puzzle data you chose to include. +lookups. Explicit projects are stored in IndexedDB. A separate, debounced +autosave slot retains the current puzzle state, progress, aid-mémoire and a +bounded gameplay history. After an interrupted session, the app offers to +restore or discard that recovery state without silently replacing an explicit +project. Its status distinguishes checking, saving, saved, ready and unavailable +states. + +Gameplay history includes undo/redo moments, named savepoints and hypothesis +branches, and is validated when restored. It is bounded to 500 moments and one +MiB for its serialized history payload. The Library supports title and tag +search, tag and completion filters, script-free grid thumbnails, bounded tag +editing, and selected export, duplication or confirmed deletion. Whole-library +import and export remain versioned operations. + +If IndexedDB is unavailable, a bounded in-memory fallback keeps work for the +current tab and the interface reports that persistence is unavailable. Browser +storage can still be removed by the browser or user, so important projects +should be exported. Exported files and share URLs contain the puzzle data you +chose to include. SudokuPad short IDs identify server-hosted records and therefore cannot be resolved by this deliberately offline application. Embedded f-puzzles payloads and user-provided Sudoku Tools JSON documents are accepted without contacting their originating site. Externally authored puzzle definitions are not bundled. +## Offline application shell + +Production builds include a relative-scope web app manifest and register a +subpath-aware service worker after the page has loaded. It caches the application +shell and same-origin built assets, uses network-first navigation with a cached +fallback, serves cached static assets when available and removes obsolete cache +versions. + +The first successful online load is still required to install those resources, +and service-worker availability depends on browser support and a secure origin. +Registration is progressive enhancement: the application continues to work +when a service worker is unavailable, and puzzle operations never depend on a +network connection. + ## Development Requirements: Node.js 22 or newer and npm 11 or newer. @@ -101,7 +268,7 @@ npm run release:artifact The command verifies the manifest, types, formatting, tests, production build, browser workflows and Toolbox contract before creating a deterministic -`release/sudoku-tools-0.1.0.zip` with a matching SHA-256 sidecar. +`release/sudoku-tools-0.2.0.zip` with a matching SHA-256 sidecar. ## Scope and references diff --git a/SOURCE.md b/SOURCE.md index d2e59d2..78807b7 100644 --- a/SOURCE.md +++ b/SOURCE.md @@ -1,8 +1,8 @@ # Corresponding source -The corresponding source for Sudoku Tools 0.1.0 is available at: +The corresponding source for Sudoku Tools 0.2.0 is available at: -https://git.add-ideas.de/lotobo/sudoku-tools/src/tag/v0.1.0 +https://git.add-ideas.de/lotobo/sudoku-tools/src/tag/v0.2.0 The production archive is generated from that tag with the scripts and exact dependency lock contained in the repository. It contains source identity, diff --git a/index.html b/index.html index 66873cf..8514723 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,8 @@ content="Set, play, solve and analyse Sudoku puzzles locally in your browser." /> + + Sudoku Tools diff --git a/package-lock.json b/package-lock.json index 84a2934..4a8365a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sudoku-tools", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sudoku-tools", - "version": "0.1.0", + "version": "0.2.0", "license": "GPL-3.0-or-later", "dependencies": { "@add-ideas/toolbox-contract": "0.2.3", diff --git a/package.json b/package.json index 002a5d4..a46db04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sudoku-tools", - "version": "0.1.0", + "version": "0.2.0", "description": "Set, play, solve and analyse Sudoku puzzles locally in the browser.", "license": "GPL-3.0-or-later", "author": "Albrecht Degering", diff --git a/playwright.config.ts b/playwright.config.ts index 60dbc27..0f4c61e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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"]], diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md index a30e3ee..ab1d3dc 100644 --- a/public/CHANGELOG.md +++ b/public/CHANGELOG.md @@ -4,6 +4,30 @@ All notable changes are documented here. ## Unreleased +## 0.2.0 - 2026-08-31 + +- Added staged guided hints that reveal focus, technique, reasoning and effects + before an explicit, undoable apply action. +- Added legal centre-candidate filling, invalid note pruning and optional peer + note maintenance after placements; erasing a value never invents candidates. +- Added Jellyfish, finned X-Wing/Swordfish, Skyscraper, Two-String Kite, Simple + Colouring, W-Wing, X-Chain, XY-Chain and alternating-inference-chain logical + steps with deterministic evidence. +- Added Unique Rectangle only behind a completed exact uniqueness proof; a + truncated search never enables uniqueness-dependent logic. +- Added quick and full setter-quality audits with ambiguity witnesses, + contradiction suspects, per-item critical/redundant/unknown findings, a cell + heatmap and optional bounded minimality proof. +- Added explicit per-check and aggregate quality budgets, cancellation and + serializable check evidence so a capped search remains unknown rather than + becoming a false proof. +- Added minimum, odd/even, disjoint-groups, little-killer and sandwich rules + across validation, candidate filtering, exact solving, rendering and setting. +- Added between, German-whisper, region-sum, modular, entropic and zipper lines; + clone and extra regions; double arrows; and row, column and box indexers. +- Added display-only Fog of War backed by a complete locally stored solution. + Only initial lights, givens and correctly entered values reveal cells; fog is + never treated as an extra solution constraint. - Added optional digit-completion counts with muted complete digits and red over-completion warnings. - Added toggleable Ctrl/Command-click matching-digit highlights without turning @@ -12,10 +36,16 @@ All notable changes are documented here. remain accidentally active. - Replaced ambiguous V/X pair labels on the board with numbered 5/10 badges and gave inequalities a directional chevron with a marked lesser-value tip. -- Added thirteen uniquely checked built-in examples covering every supported - constraint family. -- Added bounded, seedable generation for classic and twelve variant families, - plus independent uniqueness and evidence-based difficulty assessment. +- Added fourteen uniquely checked built-in examples covering the original core + constraint families. +- Expanded bounded, seedable generation for classic and twelve variant families + with mixed-family recipes, sparse/balanced/dense clue controls and additional + reflection, diagonal and four-way clue symmetries. +- Added explicit minimal-givens mode with proof/unknown reporting, deterministic + batches of up to 12 candidates and rankings by difficulty or clue count. +- Added required/forbidden/minimum/maximum logical-technique profiles and an + exact hardest-technique target, accepted only against an independent complete + logical path and exact uniqueness check. - Added X-sum, skyscraper, quadruple and maximum-cell constraints across the model, validator, exact solver, renderer, setter and f-puzzles interchange. - Added per-clue false semantics for liar/Wrogn puzzles, including bounded @@ -25,14 +55,30 @@ All notable changes are documented here. - Added outside-grid clue margins, combined dual X-sum/skyscraper labels, outward maximum arrows and visually distinct false clues. - Added strict, bounded local imports for SudokuPad/CTC data and supported - Penpa+ long links, including source reporting and explicit incompatibility - lists instead of silently discarded constructs. + Penpa+ long links, with a structured preview of mapped semantics, preserved + drawings, metadata and compatibility warnings. +- Added a bounded inert visual model for f-puzzles and SCL lines, polylines, + rectangles, ellipses, circles and text; source identity, scalar metadata and + drawings survive edits, explicit saves, autosaves, reopening and project/share + round-trips without becoming rules, and render beneath Fog on the board. +- Added SudokuPad/CTC JSON and self-contained `scl…` export with progress, + regions, cages and canonical constraint drawings, while rejecting negated + clues or free-form global rules it cannot preserve safely. - Added a compatibility check which never fetches SudokuPad short IDs or other server-hosted puzzle references. - Added standalone SVG, high-resolution PNG and single-page PDF visual exports - for supported constraints, givens and optional solving progress. + for supported constraints, safe preserved drawings, givens and optional + solving progress. - Added named savepoints, isolated hypothesis branches, keep/discard decisions and read-only replay of complete solving states. +- Added debounced recovery snapshots for puzzle state, progress, aid-mémoire + and a validated, size-bounded gameplay history, with explicit restore/discard + handling after an interruption. +- Upgraded the local Library with title/tag search, tag and completion filters, + safe grid thumbnails, tag editing and selected export, duplication or + confirmed deletion. +- Added a relative-scope web app manifest and subpath-aware service worker with + an offline application-shell fallback after the first successful load. - Added an optional configurable aid-mémoire whose labelled scratch cells support values, both note styles and colours without constraining the puzzle; its state is preserved in undo, replay, Library progress and project/share @@ -46,6 +92,13 @@ All notable changes are documented here. - Added technique-targeted practice generation which mines a bounded, deterministic set of uniquely checked puzzles and verifies the requested technique in the logical solve path. +- Added a persisted 75–200% board viewport, Fit and board-scoped zoom shortcuts, + plus an announced Pan mode which pauses cell pointer targeting. +- Added tap-toggle multi-selection and a single safe-area-aware sticky entry pad + at narrow widths, with touch-sized zoom controls. +- Added eight hue-and-pattern colour marks, configurable off/concise/detailed + candidate narration, and fog-safe board, toolbar, helper and guided-hint + boundaries. - Added real ARIA row/gridcell semantics, detailed cell descriptions and non-wrapping Arrow, Home/End, Control/Command + Home/End and Page Up/Down keyboard navigation for the puzzle and scratch grids. diff --git a/public/README.md b/public/README.md index 7e8418c..c047285 100644 --- a/public/README.md +++ b/public/README.md @@ -9,76 +9,243 @@ release also runs independently from any static HTTPS host or local preview. - **Play** — keyboard, mouse and touch entry; multi-cell selection; values, corner/centre notes and colours; undo/redo; named savepoints, hypothesis - branches and read-only replay; conflict highlighting; timer and local - progress; optional digit-completion counts, matching-digit highlights and a - configurable non-constraining aid-mémoire. -- **Set** — givens, metadata, regions and typed constraints; uniqueness checks - with overlap-safe cage replacement and selection-based cage removal. -- **Generate** — seedable, bounded construction for classic and 12 variant - families; independent uniqueness verification and evidence-based difficulty - assessment; deterministic mining for a requested logical technique. + branches and read-only replay; staged guided hints; candidate maintenance; + conflict highlighting; timer and local progress; optional digit-completion + counts, matching-digit highlights and a configurable non-constraining + aid-mémoire. +- **Set** — givens, metadata, regions and registry-backed constraints; + overlap-safe clue replacement and removal; quick uniqueness checks; and a + bounded quality lab for ambiguity, contradictions, redundancy and + minimality. +- **Generate** — seedable single or batch construction for classic and 12 + variant families, including mixed-family recipes, clue symmetry, constraint + density, optional minimal-givens proof and independently checked technique + profiles. - **Solve** — exact solution counting plus an original human-style engine whose - steps include structured evidence, placements and eliminations. + deterministic steps include structured evidence, placements and + eliminations. Uniqueness-dependent logic is disabled unless uniqueness has + already been proved by a completed exact search. - **Helpers** — generalized sum combinations with positional candidates and manual eliminations; selected-cell and house candidate-link analysis; Killer combinations, 45-rule residuals, and Kropki, sum-pair or inequality pairs. -Classic grids and common variants share one bounded puzzle model: irregular -regions, diagonals, Killer cages, thermometers, arrows, Kropki and numbered -5/10 sum-pair clues, inequalities, renban lines, palindromes, X-sums, -skyscrapers, quadruples, maximum cells, anti-knight, anti-king and -non-consecutive rules. Every local clue can also be required to be false for -liar/Wrogn constructions. Fourteen original bundled examples demonstrate every -supported constraint and are exact-search verified as unique. +## Constraint coverage + +Classic grids and variants share one validated puzzle model and constraint +registry. The production engine currently supports: + +- classic, irregular and extra regions; diagonals; disjoint groups; + anti-knight, anti-king and non-consecutive rules; +- Killer cages, ordered clone regions, quadruples, maximum/minimum cells and + odd/even cells; +- thermometers, arrows, renban and palindrome lines, between lines, German + whispers, region-sum lines, modular lines, entropic lines, zipper lines and + double arrows; +- Kropki dots, numbered 5/10 sum pairs and inequalities; +- X-sums, skyscrapers, little-killer diagonals and sandwich sums; and +- row, column and rectangular-box indexers, plus display-only Fog of War. + +Each supported semantic constraint is validated and participates in candidate +filtering and exact solving. Negatable clues can instead be required to be false +for liar/Wrogn constructions; global house rules, extra regions and fog are not +given a misleading false mode. Fourteen original bundled examples cover the +core families and the uniquely checked “Truth and lies” construction. The +newer constraint packs are covered by focused validation, feasibility and exact +solver tests rather than bundled third-party puzzles. + +Fog is deliberately a presentation rule, not a hidden solver constraint. It +can be set only when the puzzle carries a complete solution that validates +against the current givens and constraints. Initial lights, givens and correctly +entered digits reveal a radius-zero or radius-one Chebyshev neighbourhood; +wrong entries reveal nothing. Obscured cells are disabled and their values, +notes, candidates, hints, quality overlays and fully hidden clue graphics are +omitted. The trusted solution remains part of the local puzzle document. + +## Guided solving and candidate maintenance + +A guided hint asks the worker for one supported logical step and discloses it in +four explicit stages: where to look, technique, reasoning and effects preview. +Later-stage answer content is not placed in the document before it is revealed, +and the board does not change until **Apply this step** is chosen. Applying a +step is one undoable transition. + +Candidate controls can fill every empty cell with its currently legal centre +candidates, remove invalid centre/corner notes, and optionally prune peer notes +after a placement. Erasing a digit never invents candidates. If an +elimination-only hint is applied without tracked candidates, the app first +creates a complete legal centre-candidate grid and then applies the explicit +elimination. + +The logical engine covers singles; naked and hidden pairs, triples and quads; +pointing and claiming; X-Wing, Swordfish and Jellyfish; finned X-Wing and +finned Swordfish; XY-Wing and XYZ-Wing; Skyscraper; Two-String Kite; Simple +Colouring; W-Wing; X-Chains, XY-Chains and alternating inference chains; and +Killer-cage deductions. Unique Rectangle is available only when the caller +supplies a completed uniqueness proof. The engine never infers uniqueness from +a puzzle’s appearance, metadata or a truncated search. + +## Setter-quality analysis + +The quick setter check performs a bounded zero/one/two-solution audit. A pair of +solutions produces a concrete ambiguity witness with every differing cell and +value. For a uniquely proved baseline, full analysis uses leave-one-out exact +searches to classify each given and constraint as critical, redundant or +unknown and creates a per-cell criticality heatmap. For an unsatisfiable +baseline it instead uses bounded deletion tests to localise a contradictory +core. Optional minimality is proved only when the unique baseline and every +required removal check complete. + +Every quality search has configurable per-check node/time limits and shared +aggregate check/node/time limits. Results retain the checks performed, elapsed +time and reasons for unknown conclusions. Reaching a safety limit is never +silently converted into uniqueness, redundancy, contradiction localisation or +minimality. The worker analysis can be cancelled without discarding the last +completed result. + +## Generator 2.0 + +Generation supports the existing classic, diagonal, anti-knight, anti-king, +non-consecutive, Killer, thermo, arrow, Kropki, XV, inequality, renban and +palindrome families. Compatible families can be combined when they share a +supported grid size. Local marking density can be sparse, balanced or dense; +for Killer this changes cage granularity, while inherently global rules retain +their fixed meaning. + +Clue removal supports no symmetry, half-turn rotation, horizontal or vertical +reflection, either diagonal reflection, and four-way quarter-turn symmetry. +Minimal-givens mode checks every retained given individually and reports either +a completed proof or the exact unknown reasons. Individual minimisation may +break the requested visual symmetry, which is reported rather than hidden. + +Technique profiles can require or forbid supported logical techniques, set +minimum/maximum occurrence counts and require an exact hardest technique. A +puzzle is accepted only when an independent, complete logical path matches the +whole profile and an independent exact check proves uniqueness. Deterministic +batches contain at most 12 candidates and can be ranked by difficulty, fewest +givens or most givens; failed bounded candidates remain visible as failures and +are never included in the verified ranking. Generation is cancellable by +terminating and restarting its worker. + +Requested difficulty still controls the clue-removal target rather than +promising a rating. The displayed 0–100 result is calculated afterwards from +logical techniques, clue load and reproducible exact-search evidence. Supported +size/family combinations are deliberately restricted, and a bounded mixed +construction may fail cleanly for an incompatible or unusually difficult +recipe. + +## Mobile and accessibility + +The board viewport offers a locally persisted 75–200% scale in 25% steps, +increment/decrement controls and a Fit action that returns to 100% and the +origin. Control/Command + plus, minus or zero are board-scoped equivalents. +Explicit Pan mode announces its state, cancels any in-progress drag selection +and disables cell pointer targeting until it is stopped. Tap multi-select, also +available with M, toggles cells individually without drag selection +and never leaves the active selection empty. + +At widths up to 48rem, Play and Set show exactly one sticky entry pad (outside +read-only replay) with safe-area padding and touch-sized zoom controls. Eight +colour marks use distinct patterns as well as hues and expose the hue/pattern +name on both the Sudoku board and aid-mémoire. Screen-reader candidate detail is +independently selectable as Off, concise counts or detailed digits without +changing the visible board. The board exposes real row/gridcell semantics and concise per-cell state to -assistive technology, including candidates, notes, colours, conflicts and -touching variant clues. Arrow keys never wrap; Home/End, Control/Command + -Home/End and Page Up/Down provide row- and grid-level navigation. The same -navigation model is available in the aid-mémoire scratch grid. +assistive technology, including candidates, notes, colours, conflicts, +touching variant clues, hint previews and quality findings. Arrow keys never +wrap; Home/End, Control/Command + Home/End and Page Up/Down provide row- and +grid-level navigation. The same navigation model is available in the +aid-mémoire scratch grid. Guided-hint and quality status changes use live +regions. + +Fogged cells remain disabled and expose only an obscured status to assistive +technology. Hidden values, notes, constraint descriptions, candidate/helper +overlays and hint effects are masked, and toolbar/helper selection is reduced +to visible cells. A guided hint is shown only if all of its focus and effect +cells are visible; otherwise the app returns a generic no-visible-hint status. ## Interoperability and visual export The import dialog recognises compact grids, Sudoku Tools JSON/share fragments, raw or inline f-puzzles data, SudokuPad/CTC (SCL) data, and Penpa+ self-contained long links. It never follows a URL or resolves a server-side short ID. Imports -are size/decompression bounded, report their detected source before applying, -and stop with a list when a construct cannot be represented faithfully. +are size/decompression bounded. Before applying one, a structured mapping +preview separates solver-enforced semantics, preserved drawings, retained +metadata and warnings. -SudokuPad compatibility currently preserves cells, givens and progress, notes, -regions, metadata, solutions, Killer cages and supported global rules. Penpa+ -compatibility is intentionally narrower: square, unrotated Sudoku grids with -givens/progress, thermometers and arrows. Visual-only lines, overlays, custom -symbols and other unsupported geometry are reported rather than ignored. -f-puzzles export likewise refuses false clues it cannot preserve. +The shared inert visual model supports bounded lines, polylines, rectangles, +ellipses, circles and text. f-puzzles maps its decorative line, rectangle, +circle and text fields; SCL maps supported lines, underlays, overlays and +arrows. Drawings, local source identity and scalar metadata survive domain +edits, explicit saves, autosaves, reopening, and project/share exports. They +never become Sudoku rules unless the preview also lists a mapped semantic +constraint. Raw SVG, HTML, CSS, paths or code, unsafe colour syntax, unknown +visual fields, excessive data and rotated or otherwise unrepresentable layouts +are rejected rather than executed or silently changed. + +SudokuPad compatibility maps cells, givens and progress, notes, regions, +metadata, solutions, Killer cages and supported global rules. Export can create +readable SudokuPad/CTC JSON or a self-contained `scl…` payload with progress, +regions, cages and canonical drawings for registered constraints. Sudoku Tools +retains its bounded constraint record for its own exact round-trip; another SCL +consumer should only be assumed to enforce constructs it represents natively. +Negated clues and free-form global rules cannot be exported to SCL. + +Penpa+ compatibility remains intentionally narrower: square, unrotated Sudoku +grids with givens/progress, thermometers and arrows. f-puzzles export likewise +refuses false clues and underlay, free-coordinate or offset drawings it cannot +preserve faithfully. Preserved source drawings appear on the interactive board +and in static/SCL exports; Fog covers their obscured in-grid portions. Export can produce a standalone SVG, a high-resolution PNG, or a single-page PDF entirely in the browser. Each visual uses the same trusted puzzle model and -renders region boundaries, supported variant clues and givens; current values, -notes and cell colours can be included or omitted with the progress switch. +renders region boundaries, supported variant clues, safe preserved drawings and +givens; current values, notes and cell colours can be included or omitted with +the progress switch. -Generation supports only size/variant combinations which pass the bounded -construction checks. The requested difficulty controls clue-removal targets; -the displayed 0–100 result is calculated afterwards from logical techniques, -clue load and reproducible exact-search evidence. A timeout or node limit is -reported as unknown and never promoted to a uniqueness or difficulty claim. -Technique practice performs a bounded, deterministic search and returns a -puzzle only when its independently analysed solve path actually contains the -requested technique. - -## Privacy and storage +## Privacy, recovery and Library Puzzle parsing, solving, generation and export happen entirely in the browser. There are no analytics, advertisements, accounts or network-backed puzzle -lookups. Projects and progress are saved only in local IndexedDB after an -explicit save action and can be cleared from inside the app. -Exported files and share URLs contain the puzzle data you chose to include. +lookups. Explicit projects are stored in IndexedDB. A separate, debounced +autosave slot retains the current puzzle state, progress, aid-mémoire and a +bounded gameplay history. After an interrupted session, the app offers to +restore or discard that recovery state without silently replacing an explicit +project. Its status distinguishes checking, saving, saved, ready and unavailable +states. + +Gameplay history includes undo/redo moments, named savepoints and hypothesis +branches, and is validated when restored. It is bounded to 500 moments and one +MiB for its serialized history payload. The Library supports title and tag +search, tag and completion filters, script-free grid thumbnails, bounded tag +editing, and selected export, duplication or confirmed deletion. Whole-library +import and export remain versioned operations. + +If IndexedDB is unavailable, a bounded in-memory fallback keeps work for the +current tab and the interface reports that persistence is unavailable. Browser +storage can still be removed by the browser or user, so important projects +should be exported. Exported files and share URLs contain the puzzle data you +chose to include. SudokuPad short IDs identify server-hosted records and therefore cannot be resolved by this deliberately offline application. Embedded f-puzzles payloads and user-provided Sudoku Tools JSON documents are accepted without contacting their originating site. Externally authored puzzle definitions are not bundled. +## Offline application shell + +Production builds include a relative-scope web app manifest and register a +subpath-aware service worker after the page has loaded. It caches the application +shell and same-origin built assets, uses network-first navigation with a cached +fallback, serves cached static assets when available and removes obsolete cache +versions. + +The first successful online load is still required to install those resources, +and service-worker availability depends on browser support and a secure origin. +Registration is progressive enhancement: the application continues to work +when a service worker is unavailable, and puzzle operations never depend on a +network connection. + ## Development Requirements: Node.js 22 or newer and npm 11 or newer. @@ -101,7 +268,7 @@ npm run release:artifact The command verifies the manifest, types, formatting, tests, production build, browser workflows and Toolbox contract before creating a deterministic -`release/sudoku-tools-0.1.0.zip` with a matching SHA-256 sidecar. +`release/sudoku-tools-0.2.0.zip` with a matching SHA-256 sidecar. ## Scope and references diff --git a/public/SOURCE.md b/public/SOURCE.md index d2e59d2..78807b7 100644 --- a/public/SOURCE.md +++ b/public/SOURCE.md @@ -1,8 +1,8 @@ # Corresponding source -The corresponding source for Sudoku Tools 0.1.0 is available at: +The corresponding source for Sudoku Tools 0.2.0 is available at: -https://git.add-ideas.de/lotobo/sudoku-tools/src/tag/v0.1.0 +https://git.add-ideas.de/lotobo/sudoku-tools/src/tag/v0.2.0 The production archive is generated from that tag with the scripts and exact dependency lock contained in the repository. It contains source identity, diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..164865d --- /dev/null +++ b/public/manifest.webmanifest @@ -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" + } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..edc01c1 --- /dev/null +++ b/public/sw.js @@ -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; + }), + ), + ); +}); diff --git a/public/toolbox-app.json b/public/toolbox-app.json index 189b401..754ea75 100644 --- a/public/toolbox-app.json +++ b/public/toolbox-app.json @@ -3,7 +3,7 @@ "schemaVersion": 1, "id": "de.add-ideas.sudoku-tools", "name": "Sudoku Tools", - "version": "0.1.0", + "version": "0.2.0", "description": "Set, play, solve and analyse Sudoku puzzles locally in the browser.", "entry": "./", "icon": "./favicon.svg", diff --git a/scripts/package-release.mjs b/scripts/package-release.mjs index 9d4aae2..e6d8460 100644 --- a/scripts/package-release.mjs +++ b/scripts/package-release.mjs @@ -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", diff --git a/scripts/serve-test.mjs b/scripts/serve-test.mjs index 185b9fa..5aa5743 100644 --- a/scripts/serve-test.mjs +++ b/scripts/serve-test.mjs @@ -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"], diff --git a/src/components/BoardViewport.tsx b/src/components/BoardViewport.tsx new file mode 100644 index 0000000..a5834bd --- /dev/null +++ b/src/components/BoardViewport.tsx @@ -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(null); + const panOriginRef = useRef(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) => { + 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) => { + 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) => { + 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) => { + if (panOriginRef.current?.pointerId !== event.pointerId) return; + scrollerRef.current?.releasePointerCapture?.(event.pointerId); + panOriginRef.current = undefined; + }; + const percentage = Math.round(scale * 100); + + return ( +
+
+
+ + + {percentage}% + + + +
+ +
+ {panMode && ( +

+ Pan mode: drag the board to move it. Cell taps are paused. +

+ )} +
+
+ {children} +
+
+
+ ); +} diff --git a/src/components/ConstraintEditor.tsx b/src/components/ConstraintEditor.tsx index 6012a44..49e1fab 100644 --- a/src/components/ConstraintEditor.tsx +++ b/src/components/ConstraintEditor.tsx @@ -1,6 +1,19 @@ -import { useState } from "react"; -import { cellsFormQuadruple } from "../domain/geometry"; -import type { PuzzleDefinition, VariantConstraint } from "../domain/types"; +import { useId, useState } from "react"; +import { + cellsFormQuadruple, + classicRegions, + constraintLabel, + constraintMetadata, + littleKillerCells, + littleKillerDirectionEntersGrid, + validatePuzzle, +} from "../domain"; +import type { + LittleKillerDirection, + OutsideClueSide, + PuzzleDefinition, + VariantConstraint, +} from "../domain/types"; import { removeKillerCagesAtCells, replaceOverlappingKillerCages, @@ -17,6 +30,65 @@ interface ConstraintEditorProps { busy: boolean; } +type OutsideConstraintType = + "x-sum" | "skyscraper" | "little-killer" | "sandwich"; + +const LITTLE_KILLER_DIRECTIONS = [ + ["down-right", "Down right ↘"], + ["down-left", "Down left ↙"], + ["up-right", "Up right ↗"], + ["up-left", "Up left ↖"], +] as const satisfies readonly (readonly [LittleKillerDirection, string])[]; + +function firstLittleKillerDirection(side: OutsideClueSide) { + return ( + LITTLE_KILLER_DIRECTIONS.find(([direction]) => + littleKillerDirectionEntersGrid(side, direction), + )?.[0] ?? "down-right" + ); +} + +function reachableSandwichSums(size: number): ReadonlySet { + let totals = new Set([0]); + for (let digit = 2; digit < size; digit += 1) { + totals = new Set([...totals, ...[...totals].map((total) => total + digit)]); + } + return totals; +} + +function sameOrderedCells( + first: readonly number[], + second: readonly number[], +): boolean { + return ( + first.length === second.length && + first.every((cell, index) => cell === second[index]) + ); +} + +function hasClassicRegionPartition(puzzle: PuzzleDefinition): boolean { + const expected = classicRegions(puzzle.size); + const actual = puzzle.regions ?? expected; + if (actual.length !== expected.length) return false; + const expectedToActual = new Map(); + const actualToExpected = new Map(); + return expected.every((expectedRegion, cell) => { + const actualRegion = actual[cell]; + if (actualRegion === undefined) return false; + const mappedActual = expectedToActual.get(expectedRegion); + const mappedExpected = actualToExpected.get(actualRegion); + if ( + (mappedActual !== undefined && mappedActual !== actualRegion) || + (mappedExpected !== undefined && mappedExpected !== expectedRegion) + ) { + return false; + } + expectedToActual.set(expectedRegion, actualRegion); + actualToExpected.set(actualRegion, expectedRegion); + return true; + }); +} + function describeConstraint(constraint: VariantConstraint, size: number) { const cell = (index: number) => `r${String(Math.floor(index / size) + 1)}c${String((index % size) + 1)}`; @@ -33,6 +105,8 @@ function describeConstraint(constraint: VariantConstraint, size: number) { return "anti-king"; case "non-consecutive": return "non-consecutive"; + case "disjoint-groups": + return "disjoint groups · matching box positions do not repeat"; case "killer-cage": return marked( `${String(constraint.sum)} cage · ${String(constraint.cells.length)} cells${constraint.noRepeat === false ? " · repeats allowed" : ""}`, @@ -71,13 +145,63 @@ function describeConstraint(constraint: VariantConstraint, size: number) { ); case "maximum": return marked(`maximum · ${cell(constraint.cell)}`); + case "minimum": + return marked(`minimum · ${cell(constraint.cell)}`); + case "odd": + return marked(`odd circle · ${cell(constraint.cell)}`); + case "even": + return marked(`even square · ${cell(constraint.cell)}`); + case "little-killer": + return marked( + `little killer ${String(constraint.sum)} · ${constraint.side} ${String(constraint.index + 1)} · ${constraint.direction.replace("-", " ")}`, + ); + case "sandwich": + return marked( + `sandwich ${String(constraint.sum)} · ${constraint.side} ${String(constraint.index + 1)}`, + ); + case "between-line": + return marked( + `between line · ${String(constraint.cells.length)} ordered cells`, + ); + case "german-whisper": + return marked( + `German whisper ≥ ${String(constraint.minimumDifference ?? Math.ceil(size / 2))} · ${String(constraint.cells.length)} ordered cells`, + ); + case "region-sum-line": + return marked( + `region-sum line · ${String(constraint.cells.length)} ordered cells`, + ); + case "clone": + return marked( + `clone regions · ${String(constraint.cells.length)} + ${String(constraint.cloneCells.length)} paired cells`, + ); + case "extra-region": + return `extra region · ${String(constraint.cells.length)} cells`; + case "modular-line": + return marked( + `modular line · ${String(constraint.cells.length)} ordered cells`, + ); + case "entropic-line": + return marked( + `entropic line · ${String(constraint.cells.length)} ordered cells`, + ); + case "zipper-line": + return marked( + `zipper line · ${String(constraint.cells.length)} ordered cells`, + ); + case "double-arrow": + return marked( + `double arrow · ${String(constraint.cells.length)} ordered cells`, + ); + case "indexer": + return marked(`${constraint.kind} indexer · ${cell(constraint.cell)}`); + case "fog": + return `fog · ${String(constraint.lights.length)} initial light${constraint.lights.length === 1 ? "" : "s"} · radius ${String(constraint.revealRadius ?? 1)}`; } } function supportsPolarity(constraint: VariantConstraint): boolean { - return !["diagonal", "anti-knight", "anti-king", "non-consecutive"].includes( - constraint.type, - ); + return constraintMetadata(constraint.type).negatable; } function parseClueDigits(text: string, size: number): number[] { @@ -109,21 +233,31 @@ export function ConstraintEditor({ const [region, setRegion] = useState(1); const [newCluesAreFalse, setNewCluesAreFalse] = useState(false); const [quadrupleDigits, setQuadrupleDigits] = useState("1, 2, 3"); - const [outsideType, setOutsideType] = useState<"x-sum" | "skyscraper">( - "x-sum", - ); - const [outsideSide, setOutsideSide] = useState< - "top" | "right" | "bottom" | "left" - >("top"); + const [outsideType, setOutsideType] = + useState("x-sum"); + const [outsideSide, setOutsideSide] = useState("top"); + const [littleKillerDirection, setLittleKillerDirection] = + useState("down-right"); const [outsideLine, setOutsideLine] = useState(1); const [outsideValue, setOutsideValue] = useState(3); + const [whisperMinimumDifference, setWhisperMinimumDifference] = useState( + Math.ceil(puzzle.size / 2), + ); + const [indexerKind, setIndexerKind] = useState<"row" | "column" | "box">( + "row", + ); + const [fogRevealRadius, setFogRevealRadius] = useState<0 | 1>(1); + const fogReasonId = useId(); const constraints = puzzle.constraints ?? []; - const append = (constraint: VariantConstraint) => + const append = ( + constraint: VariantConstraint, + replace: (candidate: VariantConstraint) => boolean = () => false, + ) => onChange({ ...puzzle, constraints: [ - ...constraints, + ...constraints.filter((candidate) => !replace(candidate)), supportsPolarity(constraint) && newCluesAreFalse ? ({ ...constraint, negated: true } as VariantConstraint) : constraint, @@ -146,6 +280,7 @@ export function ConstraintEditor({ ? cageSum >= 1 && cageSum <= puzzle.size ** 3 : cageSum >= minimumCageSum && cageSum <= maximumCageSum); const parsedQuadrupleDigits = parseClueDigits(quadrupleDigits, puzzle.size); + const selectionIsUnique = new Set(selection).size === selection.length; const validQuadruple = cellsFormQuadruple(puzzle.size, selection) && parsedQuadrupleDigits.length >= 1 && @@ -155,9 +290,95 @@ export function ConstraintEditor({ (digit) => Number.isInteger(digit) && digit >= 1 && digit <= puzzle.size, ); const selectedCageExists = selectionTouchesKillerCage(constraints, selection); + const selectedCells = new Set(selection); + const selectedCellMarkerExists = constraints.some( + (constraint) => + (constraint.type === "maximum" || + constraint.type === "minimum" || + constraint.type === "odd" || + constraint.type === "even") && + selectedCells.has(constraint.cell), + ); + + const littleKillerLine = littleKillerCells( + puzzle.size, + outsideSide, + outsideLine - 1, + littleKillerDirection, + ); + const validOutsidePosition = + Number.isInteger(outsideLine) && + outsideLine >= 1 && + outsideLine <= puzzle.size; + const validLittleKillerDirection = littleKillerDirectionEntersGrid( + outsideSide, + littleKillerDirection, + ); + const outsideMinimum = newCluesAreFalse + ? outsideType === "sandwich" + ? 0 + : 1 + : outsideType === "sandwich" + ? 0 + : outsideType === "little-killer" + ? littleKillerLine.length + : 1; + const outsideMaximum = newCluesAreFalse + ? puzzle.size ** 4 + : outsideType === "x-sum" + ? (puzzle.size * (puzzle.size + 1)) / 2 + : outsideType === "skyscraper" + ? puzzle.size + : outsideType === "little-killer" + ? littleKillerLine.length * puzzle.size + : (puzzle.size * (puzzle.size + 1)) / 2 - puzzle.size - 1; + const validOutsideValue = + Number.isInteger(outsideValue) && + outsideValue >= outsideMinimum && + outsideValue <= outsideMaximum && + (newCluesAreFalse || + outsideType !== "sandwich" || + reachableSandwichSums(puzzle.size).has(outsideValue)); + const validOutsideClue = + validOutsidePosition && + validOutsideValue && + (outsideType !== "little-killer" || + (validLittleKillerDirection && littleKillerLine.length >= 2)); + const validWhisperDifference = + Number.isInteger(whisperMinimumDifference) && + whisperMinimumDifference >= 1 && + whisperMinimumDifference < puzzle.size; + const cloneHalf = selection.length / 2; + const cloneSource = Number.isInteger(cloneHalf) + ? selection.slice(0, cloneHalf) + : []; + const cloneTarget = Number.isInteger(cloneHalf) + ? selection.slice(cloneHalf) + : []; + const validClone = + selectionIsUnique && + cloneSource.length >= 1 && + cloneSource.length === cloneTarget.length && + cloneTarget.every((cell) => !new Set(cloneSource).has(cell)); + const solutionIsComplete = + puzzle.solution?.length === puzzle.size * puzzle.size && + puzzle.solution.every( + (value, cell) => + Number.isInteger(value) && + value >= 1 && + value <= puzzle.size && + ((puzzle.givens[cell] ?? 0) === 0 || puzzle.givens[cell] === value), + ); + const trustedSolution = solutionIsComplete && validatePuzzle(puzzle).valid; + const boxIndexerAvailable = hasClassicRegionPartition(puzzle); + const fogDisabledReason = !trustedSolution + ? "Fog of War requires a complete trusted solution. Generate or import one before choosing initial lights." + : !atLeast(1) || !selectionIsUnique + ? "Select at least one unique initial light cell." + : undefined; const toggleGlobal = ( - type: "anti-knight" | "anti-king" | "non-consecutive", + type: "anti-knight" | "anti-king" | "non-consecutive" | "disjoint-groups", ) => { const exists = constraints.some((constraint) => constraint.type === type); onChange({ @@ -180,7 +401,11 @@ export function ConstraintEditor({ Grid + setWhisperMinimumDifference(Number(event.target.value)) + } + /> + + +
+ + + + + +
+ +
+
+

Pattern lines, indexers & fog

+

+ Selection order runs from the first selected cell to the last. + Indexers use one selected marker cell. Fog uses every selected + cell as an initial light. +

+
+
+ + +
+

+ Entropic lines require a grid size divisible by 3; zipper lines + require an odd number of selected cells. + {!boxIndexerAvailable && + " Box indexers require the standard rectangular box layout."} +

+
+ + + + + + +
+

+ {fogDisabledReason ?? + "Fog is ready: correct entries and givens reveal nearby cells."} +

diff --git a/src/components/GuidedHint.tsx b/src/components/GuidedHint.tsx new file mode 100644 index 0000000..13bb178 --- /dev/null +++ b/src/components/GuidedHint.tsx @@ -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 = { + 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 ( +
+
+
+

Guided solving

+

One clue at a time

+
+ {step !== undefined && ( + + )} +
+ +

+ Reveal only as much help as you want. Nothing changes until you apply + the fully previewed step. +

+ +

+ {busy + ? "Finding a logical next step locally…" + : error !== undefined + ? "The hint could not be prepared." + : step === undefined + ? "No guided hint is open." + : stageStatus(stage)} +

+ + {error !== undefined && ( +

+ {error} +

+ )} + + {step !== undefined && ( + <> +
    + {GUIDED_HINT_STAGES.map((item) => { + const revealed = isGuidedHintStageRevealed(stage, item); + return ( +
  1. + {STAGE_LABELS[item]} +
  2. + ); + })} +
+ +
+
+

+ Where to look +

+

{guidedHintFocusSummary(step, size)}

+
+ + {isGuidedHintStageRevealed(stage, "technique") && ( +
+

+ Technique +

+

+ {logicalTechniqueName(step.technique)} +

+

+ {logicalTechniqueDescription(step.technique)} +

+
+ )} + + {isGuidedHintStageRevealed(stage, "reasoning") && ( +
+

+ Why it works +

+

{step.explanation}

+
+ )} + + {stage === "preview" && ( +
+

+ Effects preview +

+ {effectItems.length > 0 ? ( +
    + {effectItems.map((effect, index) => ( +
  • {effect}
  • + ))} +
+ ) : ( +

This deduction does not change the board.

+ )} + {step.eliminations.length > 0 && !candidateTrackingActive && ( +

+ Applying this elimination will start a complete legal + centre-candidate grid, then remove the previewed candidates. +

+ )} +
+ )} +
+ +
+ {nextLabel !== undefined && ( + + )} + {stage === "preview" && ( + + )} + +
+ + )} + + {step === undefined && ( +
+ +
+ )} + +
+ Candidate maintenance +

+ {candidateTrackingActive + ? "The guided candidate grid is active." + : "Candidate tracking is currently inactive."} +

+
+ + +
+ +
+
+ ); +} diff --git a/src/components/HelpDialog.tsx b/src/components/HelpDialog.tsx index 49c65ad..2a58c9c 100644 --- a/src/components/HelpDialog.tsx +++ b/src/components/HelpDialog.tsx @@ -14,12 +14,13 @@ export function HelpDialog({

Five complementary workspaces

Play keeps values, two kinds of notes, colours, - branches, replay and elapsed time. Set edits clues - and constraints. Generate constructs and rates - bounded, seedable variants. Solve explains logical - steps and can verify uniqueness. Helpers answers - focused sum, candidate and relation questions without changing the - board. + branches, replay, guided hints and elapsed time.{" "} + Set edits registry-backed clues and runs bounded + setter-quality checks. Generate constructs and + ranks bounded, seedable single or batch variants.{" "} + Solve explains logical steps and can verify + uniqueness. Helpers answers focused sum, candidate + and relation questions without changing the board.

@@ -65,16 +66,63 @@ export function HelpDialog({
Ctrl/⌘ + click
Highlight every placed copy of that digit
+
+
M
+
Toggle tap-by-tap multi-selection
+
+
+
Ctrl/⌘ + + / −
+
Zoom the board while focus is in the board area
+
+
+
Ctrl/⌘ + 0
+
Fit the board at its default scale
+
+
+

Touch, zoom and selection

+

+ Board zoom is stored in this browser. Use Pan board + before dragging a zoomed board; its strong border and status message + indicate that cell taps are temporarily paused. Stop panning to edit + again. Tap multi-select 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. +

+

Hints and solutions

- 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 Apply this step. 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. +

+

+ 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. +

+
+
+

Setter-quality checks

+

+ 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.

@@ -84,9 +132,10 @@ export function HelpDialog({ checkpoint, isolate a hypothesis, or inspect an earlier grid. Discarding a hypothesis restores its exact starting state but keeps the abandoned path available in replay. Replayed grids are read-only - until you return live or deliberately branch from that step. This - working history stays in the current browser session; save the - puzzle to the Library for durable puzzle progress. + until you return live or deliberately branch from that step. + Validated history is included in local autosaves and explicit + Library saves, so restoring that progress also restores its + savepoints and branches.

@@ -115,25 +164,44 @@ export function HelpDialog({

Variant and false clues

- The setter supports cages, lines, pair clues, X-sums, skyscrapers, - quadruples and maximum cells. Enable Wrogn mode to make new local - clues false, or switch existing clues individually or as a batch. - Red dashed artwork and a ≠ mark identify false clues; Σ and ▥ - identify X-sum and skyscraper readings. A false multi-cell clue - often stays undecided until enough of its cells are known, so exact - searches for dense liar puzzles can be substantially slower. + The shared registry covers classic/irregular/extra regions; + diagonal, anti, disjoint and non-consecutive rules; cages, parity, + extrema and quadruples; thermo, arrow, renban, palindrome, between, + whisper, region-sum, modular, entropic, zipper and double-arrow + lines; Kropki, 5/10 and inequality pairs; X-sum, skyscraper, + little-killer and sandwich clues; clone regions and row/column/box + indexers. +

+

+ 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.

Generation and ratings

- Generation runs in a worker with explicit time and search limits. A - requested level guides clue removal; the reported rating is then - calculated independently from logical techniques, clue load and - bounded exact-search evidence. Practice mode deterministically mines - several candidates and succeeds only when the analysed solve path - contains the requested technique. A limit never becomes a false - uniqueness claim. + Generation runs in a worker with explicit time and search limits. It + supports single or deterministic batch generation, compatible mixed + families, sparse/balanced/dense local constraints and several + rotational, reflection and diagonal clue symmetries. Minimal-givens + mode reports proof or the exact unknown reason; individual + minimisation can break the requested visual symmetry and reports + that fact. +

+

+ Technique profiles can require, forbid or count techniques and set + an exact hardest technique. A result is accepted only when an + independent complete logical path matches the whole profile and an + independent exact search proves uniqueness. Requested difficulty + guides clue removal; the reported 0–100 rating is then calculated + from logical techniques, clue load and exact-search evidence. A + cancelled or bounded-out candidate remains a failure or unknown, + never a uniqueness/minimality claim.

@@ -142,19 +210,49 @@ export function HelpDialog({ Files, text, solving and generation stay in this browser. Compact grids, project JSON, share fragments, supported f-puzzles, SudokuPad/CTC inline data and supported Penpa+ long links are - decoded locally. Server short IDs are intentionally rejected. SVG, - PNG and PDF rendering also stays in the browser. Review an export - before sharing: titles, authors, rules, solutions, progress and - aid-mémoire entries may be included. + decoded locally. Server short IDs are intentionally rejected. The + compatibility check separates mapped Sudoku semantics, preserved + inert drawings, retained metadata and warnings before import. Source + identity and allowlisted drawings survive local edits and saves but + never become rules by appearance alone. +

+

+ 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. +

+
+
+

Recovery, Library and offline use

+

+ 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. +

+

+ 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.

Screen-reader detail

- Each Sudoku cell reports its row, column, region, value or notes, - colour, conflict state, candidate highlights and touching variant - clues. The board and aid-mémoire use real row and gridcell roles; - their keyboard instructions are attached to the grids. + Each visible Sudoku cell reports its row, column, region, value, + colour-and-pattern mark, conflict state and touching variant clues. + Candidate detail can be set to Off, 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.

diff --git a/src/components/ImportExportDialog.tsx b/src/components/ImportExportDialog.tsx index eb25124..3df48cd 100644 --- a/src/components/ImportExportDialog.tsx +++ b/src/components/ImportExportDialog.tsx @@ -3,8 +3,11 @@ import type { PuzzleDefinition } from "../domain/types"; import { normalizePuzzle } from "../domain/validation"; import { encodePuzzleHash, + exportSudokuPadJson, + exportSudokuPadPayload, exportFpuzzlesJson, exportFpuzzlesUrl, + extractPreservedDocumentExtras, fromDomainPuzzle, importPuzzle, renderPuzzlePdf, @@ -13,6 +16,9 @@ import { serializePlainGrid, serializeSudokuDocument, toDomainPuzzle, + type PreservedSudokuDocumentExtras, + type PuzzleImportMappingPreview, + type PuzzleImportResult, type SudokuDocument, } from "../formats"; import type { PlaySession } from "../state/session"; @@ -24,8 +30,9 @@ function withProgress( puzzle: PuzzleDefinition, session: PlaySession, aidMemoire?: PortableAidMemoire, + preservedExtras?: PreservedSudokuDocumentExtras, ): SudokuDocument { - const base = fromDomainPuzzle(puzzle); + const base = fromDomainPuzzle(puzzle, preservedExtras); return { ...base, values: [...session.values], @@ -64,11 +71,13 @@ function checkedPuzzle(document: SudokuDocument): PuzzleDefinition { return normalizePuzzle(toDomainPuzzle(document) as PuzzleDefinition); } -interface ImportExportDialogProps { +export interface ImportExportDialogProps { open: boolean; puzzle: PuzzleDefinition; session: PlaySession; aidMemoire?: PortableAidMemoire; + /** Source-only data retained while the domain puzzle is edited. */ + preservedExtras?: PreservedSudokuDocumentExtras; onClose: () => void; onImport: ( puzzle: PuzzleDefinition, @@ -82,14 +91,41 @@ interface ImportExportDialogProps { | "elapsedMs" | "aidMemoire" >, + preservedExtras?: PreservedSudokuDocumentExtras, ) => void; } +function PreviewList({ + title, + entries, +}: { + readonly title: string; + readonly entries: PuzzleImportMappingPreview["mappedSemantics"]; +}) { + return ( +
+

{title}

+ {entries.length === 0 ? ( +

None.

+ ) : ( +
    + {entries.map((entry) => ( +
  • + {entry.label}: {entry.count} +
  • + ))} +
+ )} +
+ ); +} + export function ImportExportDialog({ open, puzzle, session, aidMemoire, + preservedExtras, onClose, onImport, }: ImportExportDialogProps) { @@ -97,13 +133,17 @@ export function ImportExportDialog({ const [feedback, setFeedback] = useState(""); const [includeProgress, setIncludeProgress] = useState(true); const [busy, setBusy] = useState(false); + const [inspected, setInspected] = useState<{ + readonly input: string; + readonly result: PuzzleImportResult; + }>(); const fileRef = useRef(null); const documentValue = useMemo( () => includeProgress - ? withProgress(puzzle, session, aidMemoire) - : fromDomainPuzzle(puzzle), - [aidMemoire, includeProgress, puzzle, session], + ? withProgress(puzzle, session, aidMemoire, preservedExtras) + : fromDomainPuzzle(puzzle, preservedExtras), + [aidMemoire, includeProgress, preservedExtras, puzzle, session], ); const copy = async (value: string, label: string) => { @@ -147,6 +187,7 @@ export function ImportExportDialog({ try { const result = await importPuzzle(input); checkedPuzzle(result.document); + setInspected({ input, result }); const givenCount = result.document.givens.filter( (value) => value !== 0, ).length; @@ -154,6 +195,7 @@ export function ImportExportDialog({ `${result.label}: ${String(result.document.size)}×${String(result.document.size)}, ${String(givenCount)} givens and ${String(result.document.constraints.length)} constraints. Compatible and ready to import.`, ); } catch (error) { + setInspected(undefined); setFeedback(errorMessage(error, "The puzzle could not be inspected.")); } finally { setBusy(false); @@ -163,17 +205,24 @@ export function ImportExportDialog({ const applyImport = async () => { setBusy(true); try { - const result = await importPuzzle(input); + const result = + inspected?.input === input + ? inspected.result + : await importPuzzle(input); const parsed = result.document; - onImport(checkedPuzzle(parsed), { - values: parsed.values, - cornerMarks: parsed.cornerMarks, - centerMarks: parsed.centerMarks, - candidates: parsed.candidates, - colors: parsed.colors, - elapsedMs: parsed.elapsedMs, - aidMemoire: parsed.aidMemoire, - }); + onImport( + checkedPuzzle(parsed), + { + values: parsed.values, + cornerMarks: parsed.cornerMarks, + centerMarks: parsed.centerMarks, + candidates: parsed.candidates, + colors: parsed.colors, + elapsedMs: parsed.elapsedMs, + aidMemoire: parsed.aidMemoire, + }, + extractPreservedDocumentExtras(parsed), + ); setFeedback(`${result.label} imported locally.`); onClose(); } catch (error) { @@ -239,6 +288,7 @@ export function ImportExportDialog({ placeholder="Paste 81 characters, JSON or a puzzle URL…" onChange={(event) => { setInput(event.target.value); + setInspected(undefined); setFeedback(""); }} /> @@ -263,6 +313,7 @@ export function ImportExportDialog({ .text() .then((contents) => { setInput(contents); + setInspected(undefined); setFeedback( `${file.name} loaded locally; review and import it.`, ); @@ -287,6 +338,41 @@ export function ImportExportDialog({ Import locally + {inspected?.input === input && ( +
+
+

Mapping preview

+

{inspected.result.label}

+
+ + + +
+

Warnings

+ {inspected.result.preview.warnings.length === 0 ? ( +

None.

+ ) : ( +
    + {inspected.result.preview.warnings.map((warning) => ( +
  • {warning}
  • + ))} +
+ )} +
+
+ )}
@@ -357,6 +443,46 @@ export function ImportExportDialog({ > Copy f-puzzles URL + + + + + + + + + + )} + {summaries.length === 0 ? (

No saved puzzles

Save the current puzzle to build a local library.

+ ) : filtered.length === 0 ? ( +
+

No matching puzzles

+

Adjust the title, tag or progress filters.

+
) : (
    - {summaries.map((item) => ( + {filtered.map((item) => (
  • + - +
    +
    + {item.tags.map((value) => ( + + ))} +
    + {editingTagsFor === item.id ? ( +
    { + event.preventDefault(); + onUpdateTags( + item.id, + tagDraft + .split(",") + .map((value) => value.trim()) + .filter(Boolean), + ); + setEditingTagsFor(undefined); + }} + > + + + +
    + ) : ( + + )} + +
  • ))}
diff --git a/src/components/NumberPad.tsx b/src/components/NumberPad.tsx index a0c9967..c652b72 100644 --- a/src/components/NumberPad.tsx +++ b/src/components/NumberPad.tsx @@ -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" ? ( <>
void checkDefinition()} onGenerate={() => setWorkspace("generate")} /> + void runQualityAudit("quick", options)} + onRunMinimality={(options) => + void runQualityAudit("minimality", options) + } + onCancel={cancelQualityAudit} + onFocusCells={focusCells} + onFocusItem={focusQualityItem} + /> )} @@ -1844,7 +2729,13 @@ export function Workbench() { busy={busy} assessment={difficulty} generation={generation} + batch={generationBatch} onGenerate={(options) => void generateConfigured(options)} + onGenerateBatch={(options) => + void generateBatchConfigured(options) + } + onSelectGenerated={selectGeneratedCandidate} + onCancel={generationRunning ? cancelGeneration : undefined} onRate={() => void rateCurrentPuzzle()} /> )} @@ -1869,7 +2760,7 @@ export function Workbench() { size={puzzle.size} puzzle={normalized.puzzle} values={session.values} - selectedCells={selection} + selectedCells={effectiveSelection} candidateMasks={candidateMasks} onCandidateOverlayChange={handleCandidateOverlayChange} /> @@ -1882,10 +2773,11 @@ export function Workbench() { puzzle={puzzle} session={session} aidMemoire={aidMemoireToPortable(aidMemoire, puzzle.size)} + preservedExtras={preservedDocumentExtras} onClose={() => setImportOpen(false)} - onImport={(next, progress) => { + onImport={(next, progress, preservedExtras) => { try { - loadPuzzle(next, progress); + loadPuzzle(next, progress, undefined, preservedExtras); setWorkspace("play"); } catch (error) { setFeedback({ kind: "error", message: errorMessage(error) }); @@ -1904,6 +2796,10 @@ export function Workbench() { onDelete={(id) => void deleteProject(id)} onClear={() => void clearLibrary()} onExport={() => void exportLibrary()} + onExportSelected={(ids) => void exportSelectedProjects(ids)} + onDuplicateSelected={(ids) => void duplicateSelectedProjects(ids)} + onDeleteSelected={(ids) => void deleteSelectedProjects(ids)} + onUpdateTags={(id, tags) => void updateProjectTags(id, tags)} onImport={(file) => void importLibrary(file)} /> { + const fogConstraints = puzzle.constraints.filter( + (constraint): constraint is FogConstraint => constraint.type === "fog", + ); + if (fogConstraints.length === 0) return new Set(); + + 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(); + 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), + ), + ); +} diff --git a/src/components/guidedHint.ts b/src/components/guidedHint.ts new file mode 100644 index 0000000..265db47 --- /dev/null +++ b/src/components/guidedHint.ts @@ -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> = { + "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, +): 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(" "); +} diff --git a/src/domain/compile.ts b/src/domain/compile.ts index c786cc3..0640ec4 100644 --- a/src/domain/compile.ts +++ b/src/domain/compile.ts @@ -1,12 +1,11 @@ +import { constraintCells } from "./constraintRegistry"; import { cellColumn, cellRow, orthogonalNeighbours } from "./geometry"; -import type { - CellId, - NormalizedPuzzle, - OutsideClueSide, - VariantConstraint, -} from "./types"; +import type { CellId, NormalizedPuzzle } from "./types"; -export type UnitKind = "row" | "column" | "region" | "diagonal"; +export { outsideLineCells } from "./geometry"; + +export type UnitKind = + "row" | "column" | "region" | "diagonal" | "disjoint-group" | "extra-region"; export interface SudokuUnit { readonly kind: UnitKind; @@ -30,63 +29,18 @@ function addPeerPair(peers: Set[], a: CellId, b: CellId): void { peers[b]?.add(a); } -/** Cells seen from an outside clue, ordered from the clue into the grid. */ -export function outsideLineCells( - size: number, - side: OutsideClueSide, - index: number, +function disjointGroupCells( + puzzle: NormalizedPuzzle, + position: number, ): CellId[] { - return Array.from({ length: size }, (_, offset) => { - switch (side) { - case "top": - return offset * size + index; - case "right": - return index * size + size - offset - 1; - case "bottom": - return (size - offset - 1) * size + index; - case "left": - return index * size + offset; - } + const regions = Array.from({ length: puzzle.size }, () => [] as CellId[]); + puzzle.regions.forEach((region, cell) => regions[region]?.push(cell)); + return regions.flatMap((cells) => { + const cell = cells[position]; + return cell === undefined ? [] : [cell]; }); } -function cellsForConstraint( - size: number, - constraint: VariantConstraint, -): readonly CellId[] { - switch (constraint.type) { - case "diagonal": - return Array.from({ length: size }, (_, index) => - constraint.direction === "main" - ? index * size + index - : index * size + size - index - 1, - ); - case "anti-knight": - case "anti-king": - case "non-consecutive": - return Array.from({ length: size * size }, (_, cell) => cell); - case "killer-cage": - case "thermo": - case "renban": - case "palindrome": - return constraint.cells; - case "arrow": - return [...constraint.bulb, ...constraint.line]; - case "kropki": - case "xv": - return [constraint.a, constraint.b]; - case "inequality": - return [constraint.lesser, constraint.greater]; - case "x-sum": - case "skyscraper": - return outsideLineCells(size, constraint.side, constraint.index); - case "quadruple": - return constraint.cells; - case "maximum": - return [constraint.cell, ...orthogonalNeighbours(size, constraint.cell)]; - } -} - export function compilePuzzle(puzzle: NormalizedPuzzle): CompiledPuzzle { const { size } = puzzle; const count = size * size; @@ -115,9 +69,28 @@ export function compilePuzzle(puzzle: NormalizedPuzzle): CompiledPuzzle { units.push({ kind: "diagonal", index: constraint.direction === "main" ? 0 : 1, - cells: cellsForConstraint(size, constraint), + cells: constraintCells(size, constraint), }); } + if (puzzle.constraints.some(({ type }) => type === "disjoint-groups")) { + for (let position = 0; position < size; position += 1) { + units.push({ + kind: "disjoint-group", + index: position, + cells: disjointGroupCells(puzzle, position), + }); + } + } + let extraRegionIndex = 0; + for (const constraint of puzzle.constraints) { + if (constraint.type !== "extra-region") continue; + units.push({ + kind: "extra-region", + index: extraRegionIndex, + cells: constraint.cells, + }); + extraRegionIndex += 1; + } const peers = Array.from({ length: count }, () => new Set()); const unitsByCell = Array.from({ length: count }, () => [] as number[]); @@ -130,7 +103,7 @@ export function compilePuzzle(puzzle: NormalizedPuzzle): CompiledPuzzle { const constraintsByCell = Array.from({ length: count }, () => [] as number[]); puzzle.constraints.forEach((constraint, constraintIndex) => { - for (const cell of new Set(cellsForConstraint(size, constraint))) { + for (const cell of constraintCells(size, constraint)) { constraintsByCell[cell]?.push(constraintIndex); } if ( diff --git a/src/domain/constraintRegistry.ts b/src/domain/constraintRegistry.ts new file mode 100644 index 0000000..969d479 --- /dev/null +++ b/src/domain/constraintRegistry.ts @@ -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, + ) => readonly CellId[]; +} + +type ConstraintRegistry = { + readonly [Type in ConstraintType]: ConstraintRegistryEntry; +}; + +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( + value: ConstraintRegistryEntry, +): ConstraintRegistryEntry { + 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: Type, +): ConstraintRegistryEntry { + return CONSTRAINT_REGISTRY[type] as unknown as ConstraintRegistryEntry; +} + +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 { + 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))]; +} diff --git a/src/domain/geometry.ts b/src/domain/geometry.ts index 4d04612..212cec1 100644 --- a/src/domain/geometry.ts +++ b/src/domain/geometry.ts @@ -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(size * size); - const boxesPerRow = size / columns; for (let row = 0; row < size; row += 1) { for (let column = 0; column < size; column += 1) { regions[row * size + column] = @@ -95,6 +115,52 @@ export function classicRegions( return regions; } +export function classicBoxIndex(size: number, cell: CellId): number { + assertCell(size, cell); + const { rows, columns, boxesPerRow } = classicBoxDimensions(size); + return ( + Math.floor(cellRow(size, cell) / rows) * boxesPerRow + + Math.floor(cellColumn(size, cell) / columns) + ); +} + +export function classicBoxPosition(size: number, cell: CellId): number { + assertCell(size, cell); + const { rows, columns } = classicBoxDimensions(size); + return ( + (cellRow(size, cell) % rows) * columns + (cellColumn(size, cell) % columns) + ); +} + +export function classicBoxCell( + size: number, + boxIndex: number, + position: number, +): CellId { + const { rows, columns, boxesPerRow } = classicBoxDimensions(size); + if (!Number.isInteger(boxIndex) || boxIndex < 0 || boxIndex >= size) { + throw new RangeError("Box index is outside the grid."); + } + if (!Number.isInteger(position) || position < 0 || position >= size) { + throw new RangeError("Box position is outside the grid."); + } + const boxRow = Math.floor(boxIndex / boxesPerRow); + const boxColumn = boxIndex % boxesPerRow; + const row = boxRow * rows + Math.floor(position / columns); + const column = boxColumn * columns + (position % columns); + return cellId(size, row, column); +} + +export function correspondingBoxPositionCells( + size: number, + cell: CellId, +): CellId[] { + const position = classicBoxPosition(size, cell); + return Array.from({ length: size }, (_, box) => + classicBoxCell(size, box, position), + ); +} + export function createEmptyPuzzle( size = 9, options: { @@ -127,6 +193,76 @@ export function orthogonalNeighbours(size: number, cell: CellId): CellId[] { return result; } +/** Cells seen from an outside clue, ordered from the clue into the grid. */ +export function outsideLineCells( + size: number, + side: OutsideClueSide, + index: number, +): CellId[] { + assertSize(size); + if (!Number.isInteger(index) || index < 0 || index >= size) return []; + return Array.from({ length: size }, (_, offset) => { + switch (side) { + case "top": + return offset * size + index; + case "right": + return index * size + size - offset - 1; + case "bottom": + return (size - offset - 1) * size + index; + case "left": + return index * size + offset; + } + }); +} + +export function littleKillerDirectionEntersGrid( + side: OutsideClueSide, + direction: LittleKillerDirection, +): boolean { + switch (side) { + case "top": + return direction === "down-left" || direction === "down-right"; + case "right": + return direction === "down-left" || direction === "up-left"; + case "bottom": + return direction === "up-left" || direction === "up-right"; + case "left": + return direction === "down-right" || direction === "up-right"; + } +} + +/** Diagonal cells crossed by a little-killer clue, from its outside origin. */ +export function littleKillerCells( + size: number, + side: OutsideClueSide, + index: number, + direction: LittleKillerDirection, +): CellId[] { + assertSize(size); + if (!Number.isInteger(index) || index < 0 || index >= size) return []; + const [rowStep, columnStep] = (() => { + switch (direction) { + case "down-right": + return [1, 1] as const; + case "down-left": + return [1, -1] as const; + case "up-right": + return [-1, 1] as const; + case "up-left": + return [-1, -1] as const; + } + })(); + let row = side === "top" ? 0 : side === "bottom" ? size - 1 : index; + let column = side === "left" ? 0 : side === "right" ? size - 1 : index; + const cells: CellId[] = []; + while (row >= 0 && row < size && column >= 0 && column < size) { + cells.push(row * size + column); + row += rowStep; + column += columnStep; + } + return cells; +} + /** True when four cells meet at one internal grid intersection. */ export function cellsFormQuadruple( size: number, diff --git a/src/domain/index.ts b/src/domain/index.ts index abce4ac..dfdd660 100644 --- a/src/domain/index.ts +++ b/src/domain/index.ts @@ -1,4 +1,5 @@ export * from "./compile"; +export * from "./constraintRegistry"; export * from "./geometry"; export * from "./rules"; export * from "./types"; diff --git a/src/domain/rules.ts b/src/domain/rules.ts index 8faa670..d02d130 100644 --- a/src/domain/rules.ts +++ b/src/domain/rules.ts @@ -1,9 +1,14 @@ +import { compilePuzzle, type CompiledPuzzle } from "./compile"; +import { constraintCells } from "./constraintRegistry"; import { - compilePuzzle, + classicBoxCell, + classicBoxIndex, + classicBoxPosition, + classicRegions, + littleKillerCells, + orthogonalNeighbours, outsideLineCells, - type CompiledPuzzle, -} from "./compile"; -import { orthogonalNeighbours } from "./geometry"; +} from "./geometry"; import type { CellId, NormalizedPuzzle, @@ -255,33 +260,382 @@ function quadrupleCanMeet( return missing <= blanks; } -function maximumCanMeet( +function extremumCanMeet( cell: CellId, values: readonly number[], size: number, + kind: "maximum" | "minimum", ): boolean { - const maximum = values[cell] ?? 0; + const extremum = values[cell] ?? 0; const neighbours = orthogonalNeighbours(size, cell).map( (neighbour) => values[neighbour] ?? 0, ); - if (maximum === 0) { - return neighbours.every((value) => value === 0 || value < size); + if (extremum === 0) { + return neighbours.every((value) => + value === 0 ? true : kind === "maximum" ? value < size : value > 1, + ); } - return neighbours.every((value) => - value === 0 ? maximum > 1 : value < maximum, + return neighbours.every((value) => { + if (value === 0) return kind === "maximum" ? extremum > 1 : extremum < size; + return kind === "maximum" ? value < extremum : value > extremum; + }); +} + +function littleKillerCanMeet( + values: readonly number[], + cells: readonly CellId[], + size: number, + target: number, +): boolean { + const [minimum, maximum] = sumsCanMeet(values, cells, size); + return target >= minimum && target <= maximum; +} + +function sandwichPossibleSums( + values: readonly number[], + cells: readonly CellId[], + size: number, +): ReadonlySet { + 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(); + 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(); + 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, + values: readonly number[], + size: number, + negated: boolean, +): boolean { + const fixed = values[constraint.cell] ?? 0; + const candidates = + fixed === 0 + ? Array.from({ length: size }, (_, index) => index + 1) + : [fixed]; + for (const value of candidates) { + const [target, required] = indexerTarget( + constraint.kind, + constraint.cell, + value, + size, + ); + const targetValue = + target === constraint.cell ? value : (values[target] ?? 0); + if (!negated && (targetValue === 0 || targetValue === required)) + return true; + if (negated && (targetValue === 0 || targetValue !== required)) return true; + } + return false; +} + function positiveConstraintIsFeasible( constraint: VariantConstraint, values: readonly number[], size: number, + regions: readonly number[], ): boolean { switch (constraint.type) { case "diagonal": case "anti-knight": case "anti-king": + case "disjoint-groups": + case "extra-region": return true; // Equality conflicts are represented in compiled peers/units. + case "fog": + return true; // Fog is presentation state and never changes solutions. case "non-consecutive": { for (let cell = 0; cell < size * size; cell += 1) { const value = values[cell] ?? 0; @@ -400,7 +754,82 @@ function positiveConstraintIsFeasible( case "quadruple": return quadrupleCanMeet(constraint, values); case "maximum": - return maximumCanMeet(constraint.cell, values, size); + return extremumCanMeet(constraint.cell, values, size, "maximum"); + case "minimum": + return extremumCanMeet(constraint.cell, values, size, "minimum"); + case "odd": { + const value = values[constraint.cell] ?? 0; + return value === 0 || value % 2 === 1; + } + case "even": { + const value = values[constraint.cell] ?? 0; + return value === 0 || value % 2 === 0; + } + case "little-killer": + return littleKillerCanMeet( + values, + littleKillerCells( + size, + constraint.side, + constraint.index, + constraint.direction, + ), + size, + constraint.sum, + ); + case "sandwich": + return sandwichPossibleSums( + values, + outsideLineCells(size, constraint.side, constraint.index), + size, + ).has(constraint.sum); + case "between-line": + return betweenLineCanMeet(constraint.cells, values, size, false); + case "german-whisper": + return germanWhisperCanMeet( + constraint.cells, + values, + size, + constraint.minimumDifference ?? Math.ceil(size / 2), + false, + ); + case "region-sum-line": + return regionSumLineCanMeet( + constraint.cells, + values, + regions, + size, + false, + ); + case "clone": + return cloneCanMeet( + constraint.cells, + constraint.cloneCells, + values, + false, + ); + case "modular-line": + return threeClassLineCanMeet( + constraint.cells, + values, + (value) => (value % 3) as 0 | 1 | 2, + false, + ); + case "entropic-line": { + const bandSize = size / 3; + return threeClassLineCanMeet( + constraint.cells, + values, + (value) => Math.floor((value - 1) / bandSize) as 0 | 1 | 2, + false, + ); + } + case "zipper-line": + return zipperLineCanMeet(constraint.cells, values, size, false); + case "double-arrow": + return doubleArrowCanMeet(constraint.cells, values, size, false); + case "indexer": + return indexerCanMeet(constraint, values, size, false); } } @@ -408,31 +837,8 @@ function completedCells( constraint: VariantConstraint, size: number, ): readonly CellId[] | undefined { - switch (constraint.type) { - case "diagonal": - case "anti-knight": - case "anti-king": - case "non-consecutive": - return undefined; - case "killer-cage": - case "thermo": - case "renban": - case "palindrome": - case "quadruple": - return constraint.cells; - case "arrow": - return [...constraint.bulb, ...constraint.line]; - case "kropki": - case "xv": - return [constraint.a, constraint.b]; - case "inequality": - return [constraint.lesser, constraint.greater]; - case "x-sum": - case "skyscraper": - return outsideLineCells(size, constraint.side, constraint.index); - case "maximum": - return [constraint.cell, ...orthogonalNeighbours(size, constraint.cell)]; - } + if (!("negated" in constraint)) return undefined; + return constraintCells(size, constraint); } /** @@ -446,6 +852,7 @@ function negatedConstraintIsFeasible( constraint: VariantConstraint, values: readonly number[], size: number, + regions: readonly number[], ): boolean { if (constraint.type === "x-sum") { const line = valuesAt( @@ -476,15 +883,89 @@ function negatedConstraintIsFeasible( } if (constraint.type === "maximum") { - const maximum = values[constraint.cell] ?? 0; - if (maximum === 0) return true; + const extremum = values[constraint.cell] ?? 0; + if (extremum === 0) return true; const neighbours = orthogonalNeighbours(size, constraint.cell).map( (cell) => values[cell] ?? 0, ); - if (neighbours.some((value) => value !== 0 && value >= maximum)) { + if (neighbours.some((value) => value !== 0 && value >= extremum)) { return true; } - return !(maximum === size || neighbours.every((value) => value !== 0)); + return !(extremum === size || neighbours.every((value) => value !== 0)); + } + + if (constraint.type === "minimum") { + const extremum = values[constraint.cell] ?? 0; + if (extremum === 0) return true; + const neighbours = orthogonalNeighbours(size, constraint.cell).map( + (cell) => values[cell] ?? 0, + ); + if (neighbours.some((value) => value !== 0 && value <= extremum)) { + return true; + } + return !(extremum === 1 || neighbours.every((value) => value !== 0)); + } + + if (constraint.type === "sandwich") { + const sums = sandwichPossibleSums( + values, + outsideLineCells(size, constraint.side, constraint.index), + size, + ); + return [...sums].some((sum) => sum !== constraint.sum); + } + + if (constraint.type === "between-line") { + return betweenLineCanMeet(constraint.cells, values, size, true); + } + + if (constraint.type === "german-whisper") { + return germanWhisperCanMeet( + constraint.cells, + values, + size, + constraint.minimumDifference ?? Math.ceil(size / 2), + true, + ); + } + + if (constraint.type === "region-sum-line") { + return regionSumLineCanMeet(constraint.cells, values, regions, size, true); + } + + if (constraint.type === "clone") { + return cloneCanMeet(constraint.cells, constraint.cloneCells, values, true); + } + + if (constraint.type === "modular-line") { + return threeClassLineCanMeet( + constraint.cells, + values, + (value) => (value % 3) as 0 | 1 | 2, + true, + ); + } + + if (constraint.type === "entropic-line") { + const bandSize = size / 3; + return threeClassLineCanMeet( + constraint.cells, + values, + (value) => Math.floor((value - 1) / bandSize) as 0 | 1 | 2, + true, + ); + } + + if (constraint.type === "zipper-line") { + return zipperLineCanMeet(constraint.cells, values, size, true); + } + + if (constraint.type === "double-arrow") { + return doubleArrowCanMeet(constraint.cells, values, size, true); + } + + if (constraint.type === "indexer") { + return indexerCanMeet(constraint, values, size, true); } if (constraint.type === "palindrome") { @@ -511,17 +992,19 @@ function negatedConstraintIsFeasible( ) { return true; } - return !positiveConstraintIsFeasible(constraint, values, size); + return !positiveConstraintIsFeasible(constraint, values, size, regions); } export function constraintIsFeasible( constraint: VariantConstraint, values: readonly number[], size: number, + regions: readonly number[] = classicRegions(size), ): boolean { const negated = "negated" in constraint && constraint.negated === true; - if (!negated) return positiveConstraintIsFeasible(constraint, values, size); - return negatedConstraintIsFeasible(constraint, values, size); + if (!negated) + return positiveConstraintIsFeasible(constraint, values, size, regions); + return negatedConstraintIsFeasible(constraint, values, size, regions); } function asCompiled( @@ -550,7 +1033,7 @@ export function canPlaceValue( const constraint = compiled.puzzle.constraints[index]; if ( constraint !== undefined && - !constraintIsFeasible(constraint, next, size) + !constraintIsFeasible(constraint, next, size, compiled.puzzle.regions) ) return false; } @@ -646,44 +1129,15 @@ export function findConflicts( } }); compiled.puzzle.constraints.forEach((constraint, constraintIndex) => { - if (!constraintIsFeasible(constraint, board, compiled.puzzle.size)) { - const cells = (() => { - switch (constraint.type) { - case "diagonal": - case "anti-knight": - case "anti-king": - case "non-consecutive": - return Array.from( - { length: compiled.puzzle.size * compiled.puzzle.size }, - (_, cell) => cell, - ); - case "killer-cage": - case "thermo": - case "renban": - case "palindrome": - case "quadruple": - return constraint.cells; - case "arrow": - return [...constraint.bulb, ...constraint.line]; - case "kropki": - case "xv": - return [constraint.a, constraint.b]; - case "inequality": - return [constraint.lesser, constraint.greater]; - case "x-sum": - case "skyscraper": - return outsideLineCells( - compiled.puzzle.size, - constraint.side, - constraint.index, - ); - case "maximum": - return [ - constraint.cell, - ...orthogonalNeighbours(compiled.puzzle.size, constraint.cell), - ]; - } - })(); + if ( + !constraintIsFeasible( + constraint, + board, + compiled.puzzle.size, + compiled.puzzle.regions, + ) + ) { + const cells = constraintCells(compiled.puzzle.size, constraint); conflicts.push({ kind: "constraint", cells, diff --git a/src/domain/types.ts b/src/domain/types.ts index 0bf003a..14511f4 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -23,6 +23,11 @@ export interface NonConsecutiveConstraint { readonly type: "non-consecutive"; } +/** Corresponding positions in every standard box form an additional house. */ +export interface DisjointGroupsConstraint { + readonly type: "disjoint-groups"; +} + export interface KillerCageConstraint { readonly type: "killer-cage"; readonly cells: readonly CellId[]; @@ -122,11 +127,134 @@ export interface MaximumConstraint { readonly negated?: boolean; } +/** The marked cell is less than each orthogonally adjacent cell. */ +export interface MinimumConstraint { + readonly type: "minimum"; + readonly cell: CellId; + readonly negated?: boolean; +} + +/** The marked cell contains an odd digit. */ +export interface OddConstraint { + readonly type: "odd"; + readonly cell: CellId; + readonly negated?: boolean; +} + +/** The marked cell contains an even digit. */ +export interface EvenConstraint { + readonly type: "even"; + readonly cell: CellId; + readonly negated?: boolean; +} + +export type LittleKillerDirection = + "down-right" | "down-left" | "up-right" | "up-left"; + +/** An outside sum along a diagonal entering the grid from one edge. */ +export interface LittleKillerConstraint { + readonly type: "little-killer"; + readonly side: OutsideClueSide; + readonly index: number; + readonly direction: LittleKillerDirection; + readonly sum: number; + readonly negated?: boolean; +} + +/** Sum of the digits strictly between 1 and N on an outside line. */ +export interface SandwichConstraint { + readonly type: "sandwich"; + readonly side: OutsideClueSide; + readonly index: number; + readonly sum: number; + readonly negated?: boolean; +} + +/** Interior line digits lie strictly between the two endpoint values. */ +export interface BetweenLineConstraint { + readonly type: "between-line"; + readonly cells: readonly CellId[]; + readonly negated?: boolean; +} + +/** Adjacent line digits differ by at least the configured amount. */ +export interface GermanWhisperConstraint { + readonly type: "german-whisper"; + readonly cells: readonly CellId[]; + /** Defaults to ceil(size / 2). */ + readonly minimumDifference?: number; + readonly negated?: boolean; +} + +/** Every contiguous segment in a region has the same sum. */ +export interface RegionSumLineConstraint { + readonly type: "region-sum-line"; + readonly cells: readonly CellId[]; + readonly negated?: boolean; +} + +/** Ordered cells in the two regions contain pairwise equal digits. */ +export interface CloneConstraint { + readonly type: "clone"; + readonly cells: readonly CellId[]; + readonly cloneCells: readonly CellId[]; + readonly negated?: boolean; +} + +/** An additional all-different house containing exactly N cells. */ +export interface ExtraRegionConstraint { + readonly type: "extra-region"; + readonly cells: readonly CellId[]; +} + +/** Every three consecutive cells contain all three digit residues modulo 3. */ +export interface ModularLineConstraint { + readonly type: "modular-line"; + readonly cells: readonly CellId[]; + readonly negated?: boolean; +} + +/** Every three consecutive cells contain one digit from each equal value band. */ +export interface EntropicLineConstraint { + readonly type: "entropic-line"; + readonly cells: readonly CellId[]; + readonly negated?: boolean; +} + +/** Equidistant pairs sum to the digit in the line's centre cell. */ +export interface ZipperLineConstraint { + readonly type: "zipper-line"; + readonly cells: readonly CellId[]; + readonly negated?: boolean; +} + +/** The two endpoint digits sum to all interior digits combined. */ +export interface DoubleArrowConstraint { + readonly type: "double-arrow"; + readonly cells: readonly CellId[]; + readonly negated?: boolean; +} + +export interface IndexerConstraint { + readonly type: "indexer"; + readonly kind: "row" | "column" | "box"; + readonly cell: CellId; + readonly negated?: boolean; +} + +/** Canonical reveal seed data; fog never changes Sudoku solution semantics. */ +export interface FogConstraint { + readonly type: "fog"; + readonly lights: readonly CellId[]; + readonly revealRadius?: 0 | 1; +} + export type VariantConstraint = | DiagonalConstraint | AntiKnightConstraint | AntiKingConstraint | NonConsecutiveConstraint + | DisjointGroupsConstraint | KillerCageConstraint | ThermoConstraint | ArrowConstraint @@ -138,7 +266,23 @@ export type VariantConstraint = | XSumConstraint | SkyscraperConstraint | QuadrupleConstraint - | MaximumConstraint; + | MaximumConstraint + | MinimumConstraint + | OddConstraint + | EvenConstraint + | LittleKillerConstraint + | SandwichConstraint + | BetweenLineConstraint + | GermanWhisperConstraint + | RegionSumLineConstraint + | CloneConstraint + | ExtraRegionConstraint + | ModularLineConstraint + | EntropicLineConstraint + | ZipperLineConstraint + | DoubleArrowConstraint + | IndexerConstraint + | FogConstraint; export interface PuzzleDefinition { readonly version: 1; diff --git a/src/domain/validation.ts b/src/domain/validation.ts index 468a0d5..c42bcda 100644 --- a/src/domain/validation.ts +++ b/src/domain/validation.ts @@ -1,11 +1,22 @@ -import { cellsFormQuadruple, classicRegions } from "./geometry"; +import { + cellsFormQuadruple, + classicRegions, + littleKillerCells, + littleKillerDirectionEntersGrid, +} from "./geometry"; import { compilePuzzle } from "./compile"; +import { + constraintAllowedFields, + isConstraintType, +} from "./constraintRegistry"; import { findConflicts } from "./rules"; import { MAX_PUZZLE_SIZE, MIN_PUZZLE_SIZE, PuzzleValidationError, + type LittleKillerDirection, type NormalizedPuzzle, + type OutsideClueSide, type PuzzleDefinition, type ValidationIssue, type ValidationResult, @@ -30,28 +41,8 @@ const ROOT_KEYS = new Set([ "solution", ]); -const CONSTRAINT_KEYS: Readonly< - Record> -> = { - diagonal: new Set(["type", "direction"]), - "anti-knight": new Set(["type"]), - "anti-king": new Set(["type"]), - "non-consecutive": new Set(["type"]), - "killer-cage": new Set(["type", "cells", "sum", "noRepeat", "negated"]), - thermo: new Set(["type", "cells", "negated"]), - arrow: new Set(["type", "bulb", "line", "negated"]), - kropki: new Set(["type", "a", "b", "kind", "negated"]), - xv: new Set(["type", "a", "b", "total", "negated"]), - inequality: new Set(["type", "lesser", "greater", "negated"]), - renban: new Set(["type", "cells", "negated"]), - palindrome: new Set(["type", "cells", "negated"]), - "x-sum": new Set(["type", "side", "index", "sum", "negated"]), - skyscraper: new Set(["type", "side", "index", "count", "negated"]), - quadruple: new Set(["type", "cells", "digits", "negated"]), - maximum: new Set(["type", "cell", "negated"]), -}; - const reachableXSumCache = new Map>(); +const reachableSandwichCache = new Map>(); function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -182,6 +173,30 @@ function reachableXSumTotals(size: number): ReadonlySet { return reachable; } +function reachableSandwichTotals(size: number): ReadonlySet { + const cached = reachableSandwichCache.get(size); + if (cached !== undefined) return cached; + const reachable = new Set([0]); + for (let digit = 2; digit < size; digit += 1) { + for (const sum of [...reachable]) reachable.add(sum + digit); + } + reachableSandwichCache.set(size, reachable); + return reachable; +} + +function sameRegionPartition( + first: readonly number[], + second: readonly number[], +): boolean { + if (first.length !== second.length) return false; + for (let a = 0; a < first.length; a += 1) { + for (let b = a + 1; b < first.length; b += 1) { + if ((first[a] === first[b]) !== (second[a] === second[b])) return false; + } + } + return true; +} + function validateConstraint( value: unknown, index: number, @@ -193,12 +208,12 @@ function validateConstraint( add(issues, path, "must be a constraint object with a type"); return; } - const type = value.type as VariantConstraint["type"]; - const allowed = CONSTRAINT_KEYS[type]; - if (allowed === undefined) { + if (!isConstraintType(value.type)) { add(issues, `${path}.type`, "is not a supported constraint type"); return; } + const type = value.type; + const allowed = constraintAllowedFields(type); for (const key of Object.keys(value)) { if (!allowed.has(key)) add(issues, `${path}.${key}`, "is not a recognized field"); @@ -220,6 +235,7 @@ function validateConstraint( case "anti-knight": case "anti-king": case "non-consecutive": + case "disjoint-groups": break; case "killer-cage": { const cellsValid = validateCells( @@ -410,8 +426,258 @@ function validateConstraint( break; } case "maximum": + case "minimum": + case "odd": + case "even": validateCell(value.cell, `${path}.cell`, cellCount, issues); break; + case "little-killer": { + validateOutsideClue(value, path, size, issues); + const directionValid = + value.direction === "down-right" || + value.direction === "down-left" || + value.direction === "up-right" || + value.direction === "up-left"; + if (!directionValid) { + add( + issues, + `${path}.direction`, + 'must be "down-right", "down-left", "up-right" or "up-left"', + ); + } else if ( + (value.side === "top" || + value.side === "right" || + value.side === "bottom" || + value.side === "left") && + !littleKillerDirectionEntersGrid( + value.side, + value.direction as LittleKillerDirection, + ) + ) { + add(issues, `${path}.direction`, "must point into the grid"); + } + const indexValid = + Number.isInteger(value.index) && + (value.index as number) >= 0 && + (value.index as number) < size; + const sideValid = + value.side === "top" || + value.side === "right" || + value.side === "bottom" || + value.side === "left"; + const cells = + directionValid && indexValid && sideValid + ? littleKillerCells( + size, + value.side as OutsideClueSide, + value.index as number, + value.direction as LittleKillerDirection, + ) + : []; + if (cells.length === 1) { + add( + issues, + path, + "little-killer diagonal must cross at least two cells", + ); + } + if (!Number.isInteger(value.sum)) { + add(issues, `${path}.sum`, "must be an integer"); + } else if (cells.length >= 2) { + const minimum = value.negated === true ? 1 : cells.length; + const maximum = + value.negated === true + ? maximumFalseClueValue(size) + : cells.length * size; + if ( + (value.sum as number) < minimum || + (value.sum as number) > maximum + ) { + add( + issues, + `${path}.sum`, + value.negated === true + ? `must be a bounded positive integer (1 to ${maximum})` + : `must be reachable (${minimum} to ${maximum})`, + ); + } + } + break; + } + case "sandwich": { + validateOutsideClue(value, path, size, issues); + const valid = validateIntegerRange( + value.sum, + `${path}.sum`, + 0, + value.negated === true + ? maximumFalseClueValue(size) + : (size * (size + 1)) / 2 - size - 1, + issues, + ); + if ( + valid && + value.negated !== true && + !reachableSandwichTotals(size).has(value.sum as number) + ) { + add( + issues, + `${path}.sum`, + "cannot be formed by digits between 1 and the maximum digit", + ); + } + break; + } + case "between-line": + validateCells( + value.cells, + `${path}.cells`, + cellCount, + 3, + cellCount, + issues, + ); + break; + case "german-whisper": + validateCells( + value.cells, + `${path}.cells`, + cellCount, + 2, + cellCount, + issues, + ); + if (value.minimumDifference !== undefined) { + validateIntegerRange( + value.minimumDifference, + `${path}.minimumDifference`, + 1, + size - 1, + issues, + ); + } + break; + case "region-sum-line": + validateCells( + value.cells, + `${path}.cells`, + cellCount, + 2, + cellCount, + issues, + ); + break; + case "clone": { + const sourceValid = validateCells( + value.cells, + `${path}.cells`, + cellCount, + 1, + cellCount, + issues, + ); + const cloneValid = validateCells( + value.cloneCells, + `${path}.cloneCells`, + cellCount, + 1, + cellCount, + issues, + ); + if (sourceValid && cloneValid) { + const source = value.cells as readonly number[]; + const clone = value.cloneCells as readonly number[]; + if (source.length !== clone.length) { + add(issues, path, "clone cell lists must have equal length"); + } + } + break; + } + case "extra-region": + validateCells( + value.cells, + `${path}.cells`, + cellCount, + size, + size, + issues, + ); + break; + case "modular-line": + validateCells( + value.cells, + `${path}.cells`, + cellCount, + 3, + cellCount, + issues, + ); + break; + case "entropic-line": + validateCells( + value.cells, + `${path}.cells`, + cellCount, + 3, + cellCount, + issues, + ); + if (size % 3 !== 0) { + add(issues, path, "entropic lines require a grid size divisible by 3"); + } + break; + case "zipper-line": + if ( + validateCells( + value.cells, + `${path}.cells`, + cellCount, + 3, + cellCount, + issues, + ) && + (value.cells as readonly number[]).length % 2 === 0 + ) { + add(issues, `${path}.cells`, "must contain an odd number of cells"); + } + break; + case "double-arrow": + validateCells( + value.cells, + `${path}.cells`, + cellCount, + 3, + cellCount, + issues, + ); + break; + case "indexer": + validateCell(value.cell, `${path}.cell`, cellCount, issues); + if ( + value.kind !== "row" && + value.kind !== "column" && + value.kind !== "box" + ) { + add(issues, `${path}.kind`, 'must be "row", "column" or "box"'); + } + break; + case "fog": + validateCells( + value.lights, + `${path}.lights`, + cellCount, + 1, + cellCount, + issues, + ); + if ( + value.revealRadius !== undefined && + value.revealRadius !== 0 && + value.revealRadius !== 1 + ) { + add(issues, `${path}.revealRadius`, "must be 0 or 1"); + } + break; } } @@ -448,6 +714,7 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint { case "anti-knight": case "anti-king": case "non-consecutive": + case "disjoint-groups": return { type: constraint.type }; case "killer-cage": return { @@ -464,6 +731,12 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint { case "thermo": case "renban": case "palindrome": + case "between-line": + case "region-sum-line": + case "modular-line": + case "entropic-line": + case "zipper-line": + case "double-arrow": return { type: constraint.type, cells: [...constraint.cells], @@ -539,6 +812,9 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint { : { negated: constraint.negated }), }; case "maximum": + case "minimum": + case "odd": + case "even": return { type: constraint.type, cell: constraint.cell, @@ -546,6 +822,66 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint { ? {} : { negated: constraint.negated }), }; + case "little-killer": + return { + type: constraint.type, + side: constraint.side, + index: constraint.index, + direction: constraint.direction, + sum: constraint.sum, + ...(constraint.negated === undefined + ? {} + : { negated: constraint.negated }), + }; + case "sandwich": + return { + type: constraint.type, + side: constraint.side, + index: constraint.index, + sum: constraint.sum, + ...(constraint.negated === undefined + ? {} + : { negated: constraint.negated }), + }; + case "german-whisper": + return { + type: constraint.type, + cells: [...constraint.cells], + ...(constraint.minimumDifference === undefined + ? {} + : { minimumDifference: constraint.minimumDifference }), + ...(constraint.negated === undefined + ? {} + : { negated: constraint.negated }), + }; + case "clone": + return { + type: constraint.type, + cells: [...constraint.cells], + cloneCells: [...constraint.cloneCells], + ...(constraint.negated === undefined + ? {} + : { negated: constraint.negated }), + }; + case "extra-region": + return { type: constraint.type, cells: [...constraint.cells] }; + case "indexer": + return { + type: constraint.type, + kind: constraint.kind, + cell: constraint.cell, + ...(constraint.negated === undefined + ? {} + : { negated: constraint.negated }), + }; + case "fog": + return { + type: constraint.type, + lights: [...constraint.lights], + ...(constraint.revealRadius === undefined + ? {} + : { revealRadius: constraint.revealRadius }), + }; } } @@ -654,6 +990,77 @@ export function validatePuzzle(puzzle: unknown): ValidationResult { .forEach((constraint, index) => { validateConstraint(constraint, index, n, issues); }); + if (regionsValid) { + const actualRegions = + puzzle.regions === undefined + ? classicRegions(n) + : (puzzle.regions as readonly number[]); + if (!sameRegionPartition(actualRegions, classicRegions(n))) { + puzzle.constraints.forEach((constraint, index) => { + if (!isRecord(constraint)) return; + if (constraint.type === "disjoint-groups") { + add( + issues, + `constraints[${index}]`, + "disjoint groups require the standard rectangular box layout", + ); + } + if (constraint.type === "indexer" && constraint.kind === "box") { + add( + issues, + `constraints[${index}]`, + "box indexers require the standard rectangular box layout", + ); + } + }); + } + puzzle.constraints.forEach((constraint, index) => { + if ( + !isRecord(constraint) || + constraint.type !== "region-sum-line" || + !Array.isArray(constraint.cells) || + constraint.cells.length < 2 || + constraint.cells.some( + (cell) => + !Number.isInteger(cell) || + (cell as number) < 0 || + (cell as number) >= n * n, + ) + ) { + return; + } + const cells = constraint.cells as readonly number[]; + let segmentCount = 1; + for (let position = 1; position < cells.length; position += 1) { + if ( + actualRegions[cells[position]!] !== + actualRegions[cells[position - 1]!] + ) { + segmentCount += 1; + } + } + if (segmentCount < 2) { + add( + issues, + `constraints[${index}].cells`, + "region-sum line must cross at least one region boundary", + ); + } + }); + } + puzzle.constraints.forEach((constraint, index) => { + if ( + isRecord(constraint) && + constraint.type === "fog" && + puzzle.solution === undefined + ) { + add( + issues, + `constraints[${index}]`, + "fog requires a complete trusted puzzle solution", + ); + } + }); } validateText(puzzle.id, "id", MAX_SHORT_TEXT, issues); validateText(puzzle.title, "title", MAX_SHORT_TEXT, issues); diff --git a/src/formats/constraintVisuals.ts b/src/formats/constraintVisuals.ts new file mode 100644 index 0000000..f25334c --- /dev/null +++ b/src/formats/constraintVisuals.ts @@ -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, +): SafeVisualPrimitive[] { + return document.constraints.flatMap((constraint) => + renderConstraint(constraint, document.size), + ); +} diff --git a/src/formats/document.ts b/src/formats/document.ts index 5fe15f6..ea2e3f7 100644 --- a/src/formats/document.ts +++ b/src/formats/document.ts @@ -1,9 +1,16 @@ import { cellsFormQuadruple } from "../domain/geometry"; import { normalizePortableAidMemoire } from "../state/aidMemoire"; +import { + SafeVisualValidationError, + normalizeSafeVisualPrimitives, + normalizeScalarMetadata, + normalizeSourceIdentity, +} from "./safeVisuals"; import { SUDOKU_DOCUMENT_SCHEMA, SUDOKU_DOCUMENT_VERSION, cloneConstraint, + cloneVisualPrimitive, type PortableAidMemoire, type PortableConstraint, type SudokuDocument, @@ -15,6 +22,30 @@ export const MAX_BOARD_SIZE = 16; export const MAX_CONSTRAINTS = 5_000; const MAX_TEXT_LENGTH = 20_000; const MAX_RULES = 1_000; +const DOCUMENT_FIELDS = new Set([ + "schema", + "version", + "size", + "givens", + "values", + "cornerMarks", + "centerMarks", + "candidates", + "colors", + "elapsedMs", + "aidMemoire", + "solution", + "regions", + "constraints", + "title", + "author", + "rules", + "globalRules", + "id", + "visuals", + "source", + "metadata", +]); export class SudokuFormatError extends Error { readonly code: string; @@ -176,6 +207,7 @@ function parseConstraint( case "anti-knight": case "anti-king": case "non-consecutive": + case "disjoint-groups": return { type: value.type }; case "killer-cage": { const cageCells = cells(value.cells, "killer-cage.cells", cellCount); @@ -202,11 +234,119 @@ function parseConstraint( case "thermo": case "renban": case "palindrome": + case "region-sum-line": return { type: value.type, cells: cells(value.cells, `${value.type}.cells`, cellCount, 2), ...cluePolarity(value, value.type), }; + case "between-line": + return { + type: "between-line", + cells: cells(value.cells, "between-line.cells", cellCount, 3), + ...cluePolarity(value, "between-line"), + }; + case "modular-line": + case "entropic-line": + case "double-arrow": + return { + type: value.type, + cells: cells(value.cells, `${value.type}.cells`, cellCount, 3), + ...cluePolarity(value, value.type), + }; + case "zipper-line": { + const lineCells = cells(value.cells, "zipper-line.cells", cellCount, 3); + if (lineCells.length % 2 === 0) { + return fail( + "INVALID_CELLS", + "zipper-line.cells must contain an odd number of cells.", + ); + } + return { + type: "zipper-line", + cells: lineCells, + ...cluePolarity(value, "zipper-line"), + }; + } + case "german-whisper": + return { + type: "german-whisper", + cells: cells(value.cells, "german-whisper.cells", cellCount, 2), + ...(value.minimumDifference === undefined + ? {} + : { + minimumDifference: integer( + value.minimumDifference, + "german-whisper.minimumDifference", + 1, + size - 1, + ), + }), + ...cluePolarity(value, "german-whisper"), + }; + case "clone": { + const original = cells(value.cells, "clone.cells", cellCount); + const cloned = cells(value.cloneCells, "clone.cloneCells", cellCount); + if (original.length !== cloned.length) { + return fail( + "INVALID_CELLS", + "clone.cells and clone.cloneCells must have equal lengths.", + ); + } + return { + type: "clone", + cells: original, + cloneCells: cloned, + ...cluePolarity(value, "clone"), + }; + } + case "extra-region": { + const regionCells = cells(value.cells, "extra-region.cells", cellCount); + if (regionCells.length !== size) { + return fail( + "INVALID_CELLS", + `extra-region.cells must contain exactly ${size} cells.`, + ); + } + return { type: "extra-region", cells: regionCells }; + } + case "indexer": { + if ( + value.kind !== "row" && + value.kind !== "column" && + value.kind !== "box" + ) { + return fail( + "INVALID_CONSTRAINT", + "indexer.kind must be row, column, or box.", + ); + } + return { + type: "indexer", + kind: value.kind, + cell: cell(value.cell, "indexer.cell", cellCount), + ...cluePolarity(value, "indexer"), + }; + } + case "fog": { + if ( + value.revealRadius !== undefined && + value.revealRadius !== 0 && + value.revealRadius !== 1 + ) { + return fail( + "INVALID_CONSTRAINT", + "fog.revealRadius must be zero or one.", + ); + } + return { + type: "fog", + lights: cells(value.lights, "fog.lights", cellCount), + ...(value.revealRadius === undefined + ? {} + : { revealRadius: value.revealRadius }), + }; + } case "arrow": return { type: "arrow", @@ -322,11 +462,57 @@ function parseConstraint( }; } case "maximum": + case "minimum": + case "odd": + case "even": return { - type: "maximum", - cell: cell(value.cell, "maximum.cell", cellCount), - ...cluePolarity(value, "maximum"), + type: value.type, + cell: cell(value.cell, `${value.type}.cell`, cellCount), + ...cluePolarity(value, value.type), }; + case "little-killer": { + const direction = value.direction; + if ( + direction !== "down-right" && + direction !== "down-left" && + direction !== "up-right" && + direction !== "up-left" + ) { + return fail( + "INVALID_CONSTRAINT", + "A little-killer direction must point diagonally into the grid.", + ); + } + const polarity = cluePolarity(value, "little-killer"); + return { + type: "little-killer", + side: outsideSide(value.side), + index: integer(value.index, "little-killer.index", 0, size - 1), + direction, + sum: integer( + value.sum, + "little-killer.sum", + 1, + polarity.negated === true ? size ** 4 : size ** 3, + ), + ...polarity, + }; + } + case "sandwich": { + const polarity = cluePolarity(value, "sandwich"); + return { + type: "sandwich", + side: outsideSide(value.side), + index: integer(value.index, "sandwich.index", 0, size - 1), + sum: integer( + value.sum, + "sandwich.sum", + 0, + polarity.negated === true ? size ** 4 : size ** 3, + ), + ...polarity, + }; + } default: return fail( "UNSUPPORTED_CONSTRAINT", @@ -354,6 +540,15 @@ function textList(value: unknown, label: string): string[] | undefined { export function normalizeSudokuDocument(value: unknown): SudokuDocument { if (!isRecord(value)) return fail("INVALID_DOCUMENT", "The puzzle document must be an object."); + const unknownField = Object.keys(value).find( + (field) => !DOCUMENT_FIELDS.has(field), + ); + if (unknownField !== undefined) { + return fail( + "UNSUPPORTED_DOCUMENT_FIELD", + `The puzzle document field “${unknownField}” is not supported. Raw code, markup and custom fields are never imported.`, + ); + } if (value.schema !== SUDOKU_DOCUMENT_SCHEMA) { return fail("INVALID_SCHEMA", `Expected schema ${SUDOKU_DOCUMENT_SCHEMA}.`); } @@ -458,11 +653,33 @@ export function normalizeSudokuDocument(value: unknown): SudokuDocument { const constraints = value.constraints.map((constraint) => parseConstraint(constraint, cellCount), ); + if ( + constraints.some(({ type }) => type === "fog") && + solution === undefined + ) { + return fail( + "INVALID_CONSTRAINT", + "Fog of War requires a complete trusted solution for correct reveals.", + ); + } const title = optionalText(value.title, "title"); const author = optionalText(value.author, "author"); const id = optionalText(value.id, "id"); const rules = textList(value.rules, "rules"); const globalRules = textList(value.globalRules, "globalRules"); + let visuals; + let source; + let metadata; + try { + visuals = normalizeSafeVisualPrimitives(value.visuals, size); + source = normalizeSourceIdentity(value.source); + metadata = normalizeScalarMetadata(value.metadata); + } catch (error) { + if (error instanceof SafeVisualValidationError) { + return fail("INVALID_SOURCE_EXTRAS", error.message); + } + throw error; + } return { schema: SUDOKU_DOCUMENT_SCHEMA, @@ -484,6 +701,9 @@ export function normalizeSudokuDocument(value: unknown): SudokuDocument { ...(id === undefined ? {} : { id }), ...(rules === undefined ? {} : { rules }), ...(globalRules === undefined ? {} : { globalRules }), + ...(visuals === undefined ? {} : { visuals }), + ...(source === undefined ? {} : { source }), + ...(metadata === undefined ? {} : { metadata }), }; } @@ -561,5 +781,14 @@ export function cloneSudokuDocument(value: SudokuDocument): SudokuDocument { ...(normalized.globalRules === undefined ? {} : { globalRules: [...normalized.globalRules] }), + ...(normalized.visuals === undefined + ? {} + : { visuals: normalized.visuals.map(cloneVisualPrimitive) }), + ...(normalized.source === undefined + ? {} + : { source: { ...normalized.source } }), + ...(normalized.metadata === undefined + ? {} + : { metadata: { ...normalized.metadata } }), }; } diff --git a/src/formats/fpuzzles.ts b/src/formats/fpuzzles.ts index 5d37e5d..7e8f823 100644 --- a/src/formats/fpuzzles.ts +++ b/src/formats/fpuzzles.ts @@ -11,10 +11,18 @@ import { SudokuFormatError, } from "./document"; import { UnsupportedPuzzleConstructsError } from "./interoperability"; +import { + SafeVisualValidationError, + normalizeSafeVisualColor, + normalizeSafeVisualPrimitives, +} from "./safeVisuals"; import { SUDOKU_DOCUMENT_SCHEMA, SUDOKU_DOCUMENT_VERSION, type PortableConstraint, + type SafeVisualAnchor, + type SafeVisualPrimitive, + type ScalarMetadataValue, type SudokuDocument, } from "./types"; @@ -33,6 +41,7 @@ const SUPPORTED_ROOT_FIELDS = new Set([ "antiking", "antikingsmove", "nonconsecutive", + "disjointgroups", "killercage", "thermometer", "arrow", @@ -44,48 +53,45 @@ const SUPPORTED_ROOT_FIELDS = new Set([ "skyscraper", "quadruple", "maximum", + "minimum", + "even", + "odd", + "littlekillersum", + "sandwichsum", + "extraregion", + "clone", + "betweenline", + "whispers", + "regionsumline", + "entropicline", + "modularline", + "zipperline", + "doublearrow", + "rowindexer", + "columnindexer", + "boxindexer", + "fogofwar", + "foglight", "renban", "palindrome", "disabledlogic", "truecandidatesoptions", "successMessage", "successmessage", + "id", + "line", + "rectangle", + "circle", + "text", ]); const UNSUPPORTED_RULE_FIELDS: Readonly> = { - disjointgroups: "disjoint groups", - littlekillersum: "little killer sums", - sandwichsum: "sandwich sums", - even: "even cells", - odd: "odd cells", - extraregion: "extra regions", - clone: "clone regions", - betweenline: "between lines", - minimum: "minimum cells", - whispers: "whisper lines", - regionsumline: "region-sum lines", - entropicline: "entropic lines", - modularline: "modular lines", - zipperline: "zipper lines", nabner: "Nabner lines", - doublearrow: "double arrows", lockout: "lockout lines", - rowindexer: "row indexers", - columnindexer: "column indexers", - boxindexer: "box indexers", - fogofwar: "fog of war", - foglight: "fog lights", cage: "generic cages", negative: "negative constraints", }; -const DECORATION_FIELDS: Readonly> = { - line: "decorative lines", - rectangle: "rectangles", - circle: "circles", - text: "text decorations", -}; - export class NetworkPuzzleIdError extends SudokuFormatError { readonly puzzleId: string; @@ -124,16 +130,7 @@ function assertSupportedRootFields(value: JsonRecord): void { constructs.push(unsupported); continue; } - const decoration = DECORATION_FIELDS[field]; - if (decoration !== undefined && present(raw)) { - constructs.push(decoration); - continue; - } - if ( - !SUPPORTED_ROOT_FIELDS.has(field) && - unsupported === undefined && - decoration === undefined - ) { + if (!SUPPORTED_ROOT_FIELDS.has(field) && unsupported === undefined) { constructs.push(`Unknown fpuzzles field “${field}”`); } } @@ -307,6 +304,337 @@ function numeric( return parsed as number; } +function finiteNumber( + value: unknown, + label: string, + minimum: number, + maximum: number, + fallback?: number, +): number { + if (value === undefined && fallback !== undefined) return fallback; + const parsed = + typeof value === "string" && value.trim() !== "" ? Number(value) : value; + if ( + typeof parsed !== "number" || + !Number.isFinite(parsed) || + parsed < minimum || + parsed > maximum + ) { + return fail("INVALID_FPUZZLES", `${label} is outside the supported range.`); + } + return parsed; +} + +function visualColor(value: unknown, label: string, fallback: string): string { + try { + return normalizeSafeVisualColor(value ?? fallback, label); + } catch (error) { + if (error instanceof SafeVisualValidationError) { + return fail("UNSAFE_VISUAL_STYLE", error.message); + } + throw error; + } +} + +function assertVisualFields( + value: JsonRecord, + allowed: ReadonlySet, + label: string, +): void { + const unknown = Object.keys(value).find((field) => !allowed.has(field)); + if (unknown !== undefined) { + return fail( + "UNSAFE_VISUAL_CONSTRUCT", + `${label}.${unknown} is not an allowlisted decorative field. Raw paths, styles and custom code are not imported.`, + ); + } +} + +function fpVisualAnchor( + value: unknown, + size: number, + label: string, +): SafeVisualAnchor { + if (typeof value !== "string") { + return fail("INVALID_FPUZZLES", `${label} must be an RnCn position.`); + } + const match = /^R(-?\d+(?:\.\d+)?)C(-?\d+(?:\.\d+)?)$/iu.exec(value.trim()); + if (match === null) { + return fail("INVALID_FPUZZLES", `${label} is not an RnCn position.`); + } + const row = Number(match[1]); + const column = Number(match[2]); + if ( + !Number.isFinite(row) || + !Number.isFinite(column) || + row < -3.5 || + row > size + 4.5 || + column < -3.5 || + column > size + 4.5 + ) { + return fail("INVALID_FPUZZLES", `${label} is outside the visual canvas.`); + } + if ( + Number.isInteger(row) && + Number.isInteger(column) && + row >= 1 && + row <= size && + column >= 1 && + column <= size + ) { + return { kind: "cell", cell: (row - 1) * size + column - 1 }; + } + return { kind: "coordinate", x: column - 0.5, y: row - 0.5 }; +} + +function fpVisualCenter( + value: JsonRecord, + size: number, + label: string, +): SafeVisualAnchor { + if (value.cell !== undefined) + return fpVisualAnchor(value.cell, size, `${label}.cell`); + if (!Array.isArray(value.cells) || value.cells.length === 0) { + return fail("INVALID_FPUZZLES", `${label} needs a cell or cells anchor.`); + } + if (value.cells.length > size * size) { + return fail("LIMIT_EXCEEDED", `${label}.cells contains too many anchors.`); + } + const anchors = value.cells.map((entry, index) => + fpVisualAnchor(entry, size, `${label}.cells[${String(index)}]`), + ); + if (anchors.length === 1) return anchors[0]!; + const coordinates = anchors.map((anchor) => + anchor.kind === "coordinate" + ? anchor + : { + x: (anchor.cell % size) + 0.5 + (anchor.offsetX ?? 0), + y: Math.floor(anchor.cell / size) + 0.5 + (anchor.offsetY ?? 0), + }, + ); + return { + kind: "coordinate", + x: + coordinates.reduce((sum, anchor) => sum + anchor.x, 0) / + coordinates.length, + y: + coordinates.reduce((sum, anchor) => sum + anchor.y, 0) / + coordinates.length, + }; +} + +function parseFpuzzlesVisuals( + value: JsonRecord, + size: number, +): SafeVisualPrimitive[] { + const output: SafeVisualPrimitive[] = []; + const lineFields = new Set([ + "lines", + "outlineC", + "width", + "opacity", + "isLLConstraint", + "fromConstraint", + ]); + for (const [index, item] of objects(value.line).entries()) { + assertVisualFields(item, lineFields, `line[${String(index)}]`); + if (!Array.isArray(item.lines) || item.lines.length > size * size) { + return fail("INVALID_FPUZZLES", "line.lines must be a bounded array."); + } + for (const [lineIndex, rawLine] of item.lines.entries()) { + if ( + !Array.isArray(rawLine) || + rawLine.length < 2 || + rawLine.length > size * size + ) { + return fail( + "INVALID_FPUZZLES", + "A decorative line needs bounded points.", + ); + } + output.push({ + type: "polyline", + layer: "overlay", + points: rawLine.map((entry, pointIndex) => + fpVisualAnchor( + entry, + size, + `line[${String(index)}].lines[${String(lineIndex)}][${String(pointIndex)}]`, + ), + ), + style: { + stroke: visualColor(item.outlineC, "line.outlineC", "#000000"), + fill: "transparent", + strokeWidth: finiteNumber(item.width, "line.width", 0, 4, 0.05), + opacity: finiteNumber(item.opacity, "line.opacity", 0, 1, 1), + }, + }); + } + } + + const shapeFields = new Set([ + "cell", + "cells", + "baseC", + "outlineC", + "fontC", + "width", + "height", + "angle", + "value", + "opacity", + "isLLConstraint", + "fromConstraint", + ]); + for (const kind of ["rectangle", "circle"] as const) { + for (const [index, item] of objects(value[kind]).entries()) { + const label = `${kind}[${String(index)}]`; + assertVisualFields(item, shapeFields, label); + if (item.angle !== undefined && Number(item.angle) !== 0) { + return fail( + "UNSAFE_VISUAL_CONSTRUCT", + `${label}.angle cannot be represented by the safe visual model.`, + ); + } + const center = fpVisualCenter(item, size, label); + const width = finiteNumber( + item.width, + `${label}.width`, + 0.01, + size + 8, + 1, + ); + const height = finiteNumber( + item.height, + `${label}.height`, + 0.01, + size + 8, + 1, + ); + const style = { + stroke: visualColor(item.outlineC, `${label}.outlineC`, "transparent"), + fill: visualColor(item.baseC, `${label}.baseC`, "transparent"), + strokeWidth: 0.02, + opacity: finiteNumber(item.opacity, `${label}.opacity`, 0, 1, 1), + }; + output.push( + kind === "circle" + ? width === height + ? { + type: "circle", + layer: "overlay", + center, + radius: width / 2, + style, + } + : { + type: "ellipse", + layer: "overlay", + center, + radiusX: width / 2, + radiusY: height / 2, + style, + } + : { + type: "rectangle", + layer: "overlay", + center, + width, + height, + style, + }, + ); + if (item.value !== undefined && String(item.value).length > 0) { + output.push({ + type: "text", + layer: "overlay", + position: center, + text: String(item.value), + style: { + fill: visualColor(item.fontC, `${label}.fontC`, "#000000"), + fontSize: 0.5, + opacity: style.opacity, + }, + }); + } + } + } + + const textFields = new Set([ + "cell", + "cells", + "value", + "fontC", + "size", + "angle", + "opacity", + ]); + for (const [index, item] of objects(value.text).entries()) { + const label = `text[${String(index)}]`; + assertVisualFields(item, textFields, label); + if (item.angle !== undefined && Number(item.angle) !== 0) { + return fail( + "UNSAFE_VISUAL_CONSTRUCT", + `${label}.angle cannot be represented by the safe visual model.`, + ); + } + if (item.value === undefined) continue; + output.push({ + type: "text", + layer: "overlay", + position: fpVisualCenter(item, size, label), + text: String(item.value), + style: { + fill: visualColor(item.fontC, `${label}.fontC`, "#000000"), + fontSize: 0.5 * finiteNumber(item.size, `${label}.size`, 0.1, 16, 1), + opacity: finiteNumber(item.opacity, `${label}.opacity`, 0, 1, 1), + }, + }); + } + try { + return normalizeSafeVisualPrimitives(output, size) ?? []; + } catch (error) { + if (error instanceof SafeVisualValidationError) { + return fail("INVALID_FPUZZLES_VISUAL", error.message); + } + throw error; + } +} + +function fpuzzlesScalarMetadata( + value: JsonRecord, +): Record { + const output: Record = {}; + for (const key of [ + "successMessage", + "successmessage", + "disabledlogic", + "truecandidatesoptions", + ] as const) { + const raw = value[key]; + if (raw === undefined) continue; + if ( + raw !== null && + typeof raw !== "string" && + typeof raw !== "number" && + typeof raw !== "boolean" + ) { + return fail( + "UNSUPPORTED_FPUZZLES", + `${key} is not scalar metadata and cannot be retained safely.`, + ); + } + if (typeof raw === "number" && !Number.isFinite(raw)) { + return fail("INVALID_FPUZZLES", `${key} must be finite.`); + } + if (typeof raw === "string" && raw.length > 8_192) { + return fail("LIMIT_EXCEEDED", `${key} is too long.`); + } + output[key] = raw; + } + return output; +} + function readGrid(value: unknown, size: number): JsonRecord[] { if (!Array.isArray(value)) return fail("INVALID_FPUZZLES", "fpuzzles.grid must be an array."); @@ -341,7 +669,17 @@ function addLineConstraints( output: PortableConstraint[], source: unknown, size: number, - type: "thermo" | "renban" | "palindrome", + type: + | "thermo" + | "renban" + | "palindrome" + | "between-line" + | "german-whisper" + | "region-sum-line" + | "modular-line" + | "entropic-line" + | "zipper-line" + | "double-arrow", ): void { for (const item of objects(source)) { for (const line of lines(item.lines ?? item.cells, size)) @@ -349,6 +687,50 @@ function addLineConstraints( } } +function littleKillerDirection(value: unknown) { + if (typeof value !== "string") { + return fail( + "INVALID_FPUZZLES", + "A little-killer direction must be UL, UR, DL, or DR.", + ); + } + const normalized = value.replaceAll(/[^a-z]/giu, "").toUpperCase(); + switch (normalized) { + case "UL": + case "UPLEFT": + return "up-left" as const; + case "UR": + case "UPRIGHT": + return "up-right" as const; + case "DL": + case "DOWNLEFT": + return "down-left" as const; + case "DR": + case "DOWNRIGHT": + return "down-right" as const; + default: + return fail( + "INVALID_FPUZZLES", + "A little-killer direction must be UL, UR, DL, or DR.", + ); + } +} + +function fpLittleKillerDirection( + value: "up-left" | "up-right" | "down-left" | "down-right", +): "UL" | "UR" | "DL" | "DR" { + switch (value) { + case "up-left": + return "UL"; + case "up-right": + return "UR"; + case "down-left": + return "DL"; + case "down-right": + return "DR"; + } +} + function parseRules(value: unknown): string[] | undefined { if (value === undefined || value === "") return undefined; if (typeof value === "string") return [value]; @@ -429,6 +811,8 @@ export function parseFpuzzles(value: unknown): SudokuDocument { constraints.push({ type: "anti-king" }); if (value.nonconsecutive === true) constraints.push({ type: "non-consecutive" }); + if (value.disjointgroups === true) + constraints.push({ type: "disjoint-groups" }); for (const cage of objects(value.killercage)) { if (cage.value === undefined || cage.value === "") { @@ -449,6 +833,25 @@ export function parseFpuzzles(value: unknown): SudokuDocument { addLineConstraints(constraints, value.thermometer, size, "thermo"); addLineConstraints(constraints, value.renban, size, "renban"); addLineConstraints(constraints, value.palindrome, size, "palindrome"); + addLineConstraints(constraints, value.betweenline, size, "between-line"); + addLineConstraints(constraints, value.regionsumline, size, "region-sum-line"); + addLineConstraints(constraints, value.modularline, size, "modular-line"); + addLineConstraints(constraints, value.entropicline, size, "entropic-line"); + addLineConstraints(constraints, value.zipperline, size, "zipper-line"); + addLineConstraints(constraints, value.doublearrow, size, "double-arrow"); + for (const whisper of objects(value.whispers)) { + const minimumDifference = + whisper.value === undefined || whisper.value === "" + ? undefined + : numeric(whisper.value, "whispers.value", 1, size - 1); + for (const line of lines(whisper.lines ?? whisper.cells, size)) { + constraints.push({ + type: "german-whisper", + cells: line, + ...(minimumDifference === undefined ? {} : { minimumDifference }), + }); + } + } for (const arrow of objects(value.arrow)) { const bulb = fpCells(arrow.cells, size); @@ -576,6 +979,95 @@ export function parseFpuzzles(value: unknown): SudokuDocument { cell: cellIndexFromAddress(clue.cell, size), }); } + for (const clue of objects(value.minimum)) { + constraints.push({ + type: "minimum", + cell: cellIndexFromAddress(clue.cell, size), + }); + } + for (const clue of objects(value.odd)) { + constraints.push({ + type: "odd", + cell: cellIndexFromAddress(clue.cell, size), + }); + } + for (const clue of objects(value.even)) { + constraints.push({ + type: "even", + cell: cellIndexFromAddress(clue.cell, size), + }); + } + for (const clue of objects(value.littlekillersum)) { + constraints.push({ + type: "little-killer", + ...outsideClueFromAddress(clue.cell, size), + direction: littleKillerDirection(clue.direction), + sum: numeric(clue.value, "littlekillersum.value", 1, size ** 3), + }); + } + for (const clue of objects(value.sandwichsum)) { + constraints.push({ + type: "sandwich", + ...outsideClueFromAddress(clue.cell, size), + sum: numeric(clue.value, "sandwichsum.value", 0, size ** 3), + }); + } + for (const clue of objects(value.extraregion)) { + constraints.push({ + type: "extra-region", + cells: fpCells(clue.cells, size), + }); + } + for (const clue of objects(value.clone)) { + constraints.push({ + type: "clone", + cells: fpCells(clue.cells, size), + cloneCells: fpCells(clue.cloneCells, size), + }); + } + for (const kind of ["row", "column", "box"] as const) { + const field = `${kind}indexer`; + for (const clue of objects(value[field])) { + const rawCells = clue.cells; + if (Array.isArray(rawCells)) { + for (const indexedCell of fpCells(rawCells, size)) { + constraints.push({ type: "indexer", kind, cell: indexedCell }); + } + } else { + constraints.push({ + type: "indexer", + kind, + cell: cellIndexFromAddress(clue.cell, size), + }); + } + } + } + + if (value.fogofwar === true || present(value.foglight)) { + const lights: number[] = []; + for (const clue of objects(value.foglight)) { + if (Array.isArray(clue.cells)) lights.push(...fpCells(clue.cells, size)); + else lights.push(cellIndexFromAddress(clue.cell, size)); + } + if (lights.length === 0) { + for (let cell = 0; cell < givens.length; cell += 1) { + if ((givens[cell] ?? 0) !== 0) lights.push(cell); + } + } + if (lights.length === 0) { + return fail( + "INVALID_FPUZZLES", + "Fog of War requires at least one fog light or given cell.", + ); + } + if (solution === undefined) { + return fail( + "INVALID_FPUZZLES", + "Fog of War requires an embedded solution for safe local reveals.", + ); + } + constraints.push({ type: "fog", lights: [...new Set(lights)] }); + } const regions = readRegions(grid, size); const rules = parseRules(value.ruleset); @@ -585,6 +1077,14 @@ export function parseFpuzzles(value: unknown): SudokuDocument { typeof value.author === "string" ? value.author.slice(0, 20_000) : undefined; + const visuals = parseFpuzzlesVisuals(value, size); + const metadata = fpuzzlesScalarMetadata(value); + const sourceId = + value.id === undefined + ? undefined + : typeof value.id === "string" && value.id.length <= 512 + ? value.id + : fail("INVALID_FPUZZLES", "fpuzzles.id must be bounded text."); return { schema: SUDOKU_DOCUMENT_SCHEMA, @@ -600,6 +1100,13 @@ export function parseFpuzzles(value: unknown): SudokuDocument { ...(rules === undefined ? {} : { rules }), ...(title === undefined ? {} : { title }), ...(author === undefined ? {} : { author }), + ...(sourceId === undefined ? {} : { id: sourceId }), + ...(visuals.length === 0 ? {} : { visuals }), + source: { + format: "fpuzzles", + ...(sourceId === undefined ? {} : { id: sourceId }), + }, + ...(Object.keys(metadata).length === 0 ? {} : { metadata }), }; } @@ -607,6 +1114,109 @@ function constraintCells(cells: readonly number[], size: number): string[] { return cells.map((cell) => addressFromCellIndex(cell, size)); } +function fpAddressFromVisualAnchor( + anchor: SafeVisualAnchor, + size: number, +): string { + if (anchor.kind === "cell") { + if ((anchor.offsetX ?? 0) !== 0 || (anchor.offsetY ?? 0) !== 0) { + return fail( + "UNSUPPORTED_FPUZZLES_VISUAL", + "f-puzzles cannot preserve an offset visual anchor.", + ); + } + return addressFromCellIndex(anchor.cell, size); + } + const row = anchor.y + 0.5; + const column = anchor.x + 0.5; + if ( + !Number.isInteger(row) || + !Number.isInteger(column) || + row < 1 || + row > size || + column < 1 || + column > size + ) { + return fail( + "UNSUPPORTED_FPUZZLES_VISUAL", + "f-puzzles cannot preserve a free-coordinate visual anchor.", + ); + } + return `R${String(row)}C${String(column)}`; +} + +function exportFpuzzlesVisual( + visual: SafeVisualPrimitive, + size: number, +): readonly [ + field: "line" | "rectangle" | "circle" | "text", + value: JsonRecord, +] { + if (visual.layer !== "overlay") { + return fail( + "UNSUPPORTED_FPUZZLES_VISUAL", + "f-puzzles cannot preserve explicit underlay ordering.", + ); + } + const style = visual.style ?? {}; + switch (visual.type) { + case "line": + case "polyline": { + const anchors = + visual.type === "line" ? [visual.start, visual.end] : visual.points; + return [ + "line", + { + lines: [ + anchors.map((anchor) => fpAddressFromVisualAnchor(anchor, size)), + ], + outlineC: style.stroke ?? "#000000", + width: style.strokeWidth ?? 0.05, + ...(style.opacity === undefined ? {} : { opacity: style.opacity }), + }, + ]; + } + case "rectangle": + return [ + "rectangle", + { + cells: [fpAddressFromVisualAnchor(visual.center, size)], + width: visual.width, + height: visual.height, + baseC: style.fill ?? "transparent", + outlineC: style.stroke ?? "transparent", + ...(style.opacity === undefined ? {} : { opacity: style.opacity }), + }, + ]; + case "ellipse": + case "circle": + return [ + "circle", + { + cells: [fpAddressFromVisualAnchor(visual.center, size)], + width: + visual.type === "circle" ? visual.radius * 2 : visual.radiusX * 2, + height: + visual.type === "circle" ? visual.radius * 2 : visual.radiusY * 2, + baseC: style.fill ?? "transparent", + outlineC: style.stroke ?? "transparent", + ...(style.opacity === undefined ? {} : { opacity: style.opacity }), + }, + ]; + case "text": + return [ + "text", + { + cells: [fpAddressFromVisualAnchor(visual.position, size)], + value: visual.text, + fontC: style.fill ?? "#000000", + size: (style.fontSize ?? 0.5) / 0.5, + ...(style.opacity === undefined ? {} : { opacity: style.opacity }), + }, + ]; + } +} + export function exportFpuzzles(document: SudokuDocument): JsonRecord { const { size } = document; const output: JsonRecord = { @@ -672,6 +1282,9 @@ export function exportFpuzzles(document: SudokuDocument): JsonRecord { case "non-consecutive": output.nonconsecutive = true; break; + case "disjoint-groups": + output.disjointgroups = true; + break; case "killer-cage": append("killercage", { cells: constraintCells(constraint.cells, size), @@ -692,6 +1305,31 @@ export function exportFpuzzles(document: SudokuDocument): JsonRecord { lines: [constraintCells(constraint.cells, size)], }); break; + case "between-line": + case "region-sum-line": + case "modular-line": + case "entropic-line": + case "zipper-line": + case "double-arrow": { + const field = + constraint.type === "between-line" + ? "betweenline" + : constraint.type === "region-sum-line" + ? "regionsumline" + : constraint.type.replace("-", ""); + append(field, { + lines: [constraintCells(constraint.cells, size)], + }); + break; + } + case "german-whisper": + append("whispers", { + lines: [constraintCells(constraint.cells, size)], + ...(constraint.minimumDifference === undefined + ? {} + : { value: String(constraint.minimumDifference) }), + }); + break; case "arrow": append("arrow", { cells: constraintCells(constraint.bulb, size), @@ -764,9 +1402,80 @@ export function exportFpuzzles(document: SudokuDocument): JsonRecord { cell: addressFromCellIndex(constraint.cell, size), }); break; + case "minimum": + case "odd": + case "even": + append(constraint.type, { + cell: addressFromCellIndex(constraint.cell, size), + }); + break; + case "little-killer": + append("littlekillersum", { + cell: addressFromOutsideClue( + constraint.side, + numeric(constraint.index, "littlekillersum.index", 0, size - 1), + size, + ), + direction: fpLittleKillerDirection(constraint.direction), + value: String(constraint.sum), + }); + break; + case "sandwich": + append("sandwichsum", { + cell: addressFromOutsideClue( + constraint.side, + numeric(constraint.index, "sandwichsum.index", 0, size - 1), + size, + ), + value: String(constraint.sum), + }); + break; + case "clone": + append("clone", { + cells: constraintCells(constraint.cells, size), + cloneCells: constraintCells(constraint.cloneCells, size), + }); + break; + case "extra-region": + append("extraregion", { + cells: constraintCells(constraint.cells, size), + }); + break; + case "indexer": + append(`${constraint.kind}indexer`, { + cell: addressFromCellIndex(constraint.cell, size), + }); + break; + case "fog": + output.fogofwar = true; + append("foglight", { + cells: constraintCells(constraint.lights, size), + }); + break; } } + for (const visual of document.visuals ?? []) { + const [field, value] = exportFpuzzlesVisual(visual, size); + append(field, value); + } + for (const [key, value] of Object.entries(document.metadata ?? {})) { + if ( + key === "successMessage" || + key === "successmessage" || + key === "disabledlogic" || + key === "truecandidatesoptions" + ) { + output[key] = value; + } + } + if ( + document.source?.format === "fpuzzles" && + document.source.id !== undefined + ) { + output.id = document.source.id; + } + if ((document.globalRules?.length ?? 0) > 0) { return fail( "UNSUPPORTED_FPUZZLES", diff --git a/src/formats/grid.ts b/src/formats/grid.ts index 60ce249..521b76b 100644 --- a/src/formats/grid.ts +++ b/src/formats/grid.ts @@ -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 }), }; diff --git a/src/formats/import.ts b/src/formats/import.ts index c0e5af3..aa2ce9a 100644 --- a/src/formats/import.ts +++ b/src/formats/import.ts @@ -9,6 +9,7 @@ import { parseFpuzzles, } from "./fpuzzles"; import { + puzzleImportResult, type PuzzleImportResult, RemotePuzzleReferenceError, } from "./interoperability"; @@ -88,61 +89,45 @@ export async function importPuzzle( throw new RemotePuzzleReferenceError("The pasted URL"); } if (url?.kind === "penpa") { - return { - document: await importPenpa(url.value), - format: "penpa", - label: "Penpa+", - }; + return puzzleImportResult(await importPenpa(url.value), "penpa", "Penpa+"); } if (url?.kind === "sudokupad") { - return { - document: importSudokuPad(url.value), - format: "sudokupad", - label: "SudokuPad/CTC", - }; + return puzzleImportResult( + importSudokuPad(url.value), + "sudokupad", + "SudokuPad/CTC", + ); } if (url?.kind === "fpuzzles") { - return { - document: importFpuzzles(url.value), - format: "fpuzzles", - label: "f-puzzles", - }; + return puzzleImportResult( + importFpuzzles(url.value), + "fpuzzles", + "f-puzzles", + ); } if (trimmed.startsWith("#sudoku=") || trimmed.includes("#sudoku=")) { - return { - document: decodePuzzleHash(trimmed), - format: "sudoku-tools", - label: "Sudoku Tools share link", - }; + return puzzleImportResult( + decodePuzzleHash(trimmed), + "sudoku-tools", + "Sudoku Tools share link", + ); } if (/^(?:ctc|scl)/iu.test(trimmed)) { - return { - document: importSudokuPad(trimmed), - format: "sudokupad", - label: "SudokuPad/CTC", - }; + return puzzleImportResult( + importSudokuPad(trimmed), + "sudokupad", + "SudokuPad/CTC", + ); } if (/^penpa:/iu.test(trimmed) || /^[?#]?(?:m=[^&]+&)?p=/iu.test(trimmed)) { - return { - document: await importPenpa(trimmed), - format: "penpa", - label: "Penpa+", - }; + return puzzleImportResult(await importPenpa(trimmed), "penpa", "Penpa+"); } if (/^(?:square|sudoku),[^\r\n]+[\r\n]/iu.test(trimmed)) { - return { - document: parsePenpaText(trimmed), - format: "penpa", - label: "Penpa+ text", - }; + return puzzleImportResult(parsePenpaText(trimmed), "penpa", "Penpa+ text"); } if (/^fpuzzles/iu.test(trimmed)) { - return { - document: importFpuzzles(trimmed), - format: "fpuzzles", - label: "f-puzzles", - }; + return puzzleImportResult(importFpuzzles(trimmed), "fpuzzles", "f-puzzles"); } if (trimmed.startsWith("{")) { if (new TextEncoder().encode(trimmed).byteLength > MAX_DOCUMENT_BYTES) { @@ -162,28 +147,28 @@ export async function importPuzzle( ); } if (isRecord(parsed) && "schema" in parsed) { - return { - document: parseSudokuDocument(trimmed), - format: "sudoku-tools", - label: "Sudoku Tools JSON", - }; + return puzzleImportResult( + parseSudokuDocument(trimmed), + "sudoku-tools", + "Sudoku Tools JSON", + ); } if (isRecord(parsed) && "cells" in parsed && !("grid" in parsed)) { - return { - document: parseSudokuPadPuzzle(parsed), - format: "sudokupad", - label: "SudokuPad/CTC JSON", - }; + return puzzleImportResult( + parseSudokuPadPuzzle(parsed), + "sudokupad", + "SudokuPad/CTC JSON", + ); } - return { - document: parseFpuzzles(parsed), - format: "fpuzzles", - label: "f-puzzles JSON", - }; + return puzzleImportResult( + parseFpuzzles(parsed), + "fpuzzles", + "f-puzzles JSON", + ); } - return { - document: parsePlainGrid(trimmed), - format: "plain-grid", - label: "plain grid", - }; + return puzzleImportResult( + parsePlainGrid(trimmed), + "plain-grid", + "plain grid", + ); } diff --git a/src/formats/index.ts b/src/formats/index.ts index cbe2823..ae54e3f 100644 --- a/src/formats/index.ts +++ b/src/formats/index.ts @@ -1,9 +1,11 @@ +export * from "./constraintVisuals"; export * from "./document"; export * from "./fpuzzles"; export * from "./grid"; export * from "./import"; export * from "./interoperability"; export * from "./penpa"; +export * from "./safeVisuals"; export * from "./share"; export * from "./sudokupad"; export * from "./types"; diff --git a/src/formats/interoperability.ts b/src/formats/interoperability.ts index adbffd7..c0a030d 100644 --- a/src/formats/interoperability.ts +++ b/src/formats/interoperability.ts @@ -1,12 +1,129 @@ import { SudokuFormatError } from "./document"; +import type { SudokuDocument, SudokuSourceFormat } from "./types"; -export type PuzzleSourceFormat = - "sudoku-tools" | "plain-grid" | "fpuzzles" | "sudokupad" | "penpa"; +export type PuzzleSourceFormat = SudokuSourceFormat; + +export interface PuzzleMappingEntry { + readonly key: string; + readonly label: string; + readonly count: number; +} + +export interface PuzzleImportMappingPreview { + readonly mappedSemantics: readonly PuzzleMappingEntry[]; + readonly preservedVisuals: readonly PuzzleMappingEntry[]; + readonly preservedMetadata: readonly PuzzleMappingEntry[]; + readonly warnings: readonly string[]; +} export interface PuzzleImportResult { 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(); + 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(); + 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 { + 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 { diff --git a/src/formats/penpa.ts b/src/formats/penpa.ts index 30d61c8..26e5b66 100644 --- a/src/formats/penpa.ts +++ b/src/formats/penpa.ts @@ -452,6 +452,7 @@ export function parsePenpaText(compressedText: string): SudokuDocument { givens, values, constraints, + source: { format: "penpa" }, ...(title === undefined || title === "" ? {} : { title }), ...(author === undefined || author === "" ? {} : { author }), ...(rules === undefined ? {} : { rules: [rules] }), diff --git a/src/formats/safeVisuals.ts b/src/formats/safeVisuals.ts new file mode 100644 index 0000000..1b386e6 --- /dev/null +++ b/src/formats/safeVisuals.ts @@ -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([ + "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> = { + 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function exactFields( + value: Record, + allowed: ReadonlySet, + 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 = {}; + 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; +} diff --git a/src/formats/share.ts b/src/formats/share.ts index 24a534e..d96e920 100644 --- a/src/formats/share.ts +++ b/src/formats/share.ts @@ -72,5 +72,8 @@ export function decodePuzzleHash(hashOrUrl: string): SudokuDocument { "The share payload could not be decompressed.", ); } - return parseSudokuDocument(json); + const document = parseSudokuDocument(json); + return document.source === undefined + ? { ...document, source: { format: "sudoku-tools" } } + : document; } diff --git a/src/formats/sudokupad.ts b/src/formats/sudokupad.ts index 188f855..5913796 100644 --- a/src/formats/sudokupad.ts +++ b/src/formats/sudokupad.ts @@ -1,3 +1,4 @@ +import { compressToBase64 } from "lz-string"; import { LzStringOutputLimitError, decompressFromBase64OrUriComponentBounded, @@ -7,16 +8,38 @@ import { MAX_DOCUMENT_BYTES, MIN_BOARD_SIZE, SudokuFormatError, + normalizeSudokuDocument, } from "./document"; +import { canonicalConstraintVisuals } from "./constraintVisuals"; import { UnsupportedPuzzleConstructsError } from "./interoperability"; +import { + SafeVisualValidationError, + normalizeSafeVisualColor, + normalizeSafeVisualPrimitives, + normalizeScalarMetadata, +} from "./safeVisuals"; import { SUDOKU_DOCUMENT_SCHEMA, SUDOKU_DOCUMENT_VERSION, type PortableConstraint, + type SafeVisualAnchor, + type SafeVisualPrimitive, + type SafeVisualStyle, type SudokuDocument, } from "./types"; export const MAX_SUDOKUPAD_PAYLOAD_LENGTH = 262_144; +const SUDOKU_TOOLS_CONSTRAINTS_METADATA = "sudokuToolsConstraints"; +const RESERVED_SEMANTIC_METADATA = new Set([ + "title", + "author", + "rules", + "solution", + "antiknight", + "antiking", + "nonconsecutive", + SUDOKU_TOOLS_CONSTRAINTS_METADATA, +]); type JsonRecord = Record; @@ -38,6 +61,8 @@ const ROOT_FIELDS = new Set([ "author", "rules", "solution", + "duration", + "foglight", ]); const CELL_FIELDS = new Set(["value", "given", "pencilMarks", "centremarks"]); @@ -58,6 +83,21 @@ function present(value: unknown): boolean { return true; } +function objects(value: unknown, maximum = 5_000): JsonRecord[] { + if (value === undefined) return []; + if ( + !Array.isArray(value) || + value.length > maximum || + !value.every(isRecord) + ) { + return fail( + "LIMIT_EXCEEDED", + "A SudokuPad collection is invalid or too large.", + ); + } + return value; +} + function boundedText(value: unknown, label: string, maximum = 16_384) { if (value === undefined || value === null || value === "") return undefined; if (typeof value !== "string" || value.length > maximum) { @@ -90,6 +130,378 @@ function integer( return parsed as number; } +function finiteNumber( + value: unknown, + label: string, + minimum: number, + maximum: number, + fallback?: number, +): number { + if (value === undefined && fallback !== undefined) return fallback; + const parsed = + typeof value === "string" && value.trim() !== "" ? Number(value) : value; + if ( + typeof parsed !== "number" || + !Number.isFinite(parsed) || + parsed < minimum || + parsed > maximum + ) { + return fail( + "INVALID_SUDOKUPAD", + `${label} must be a finite number from ${minimum} to ${maximum}.`, + ); + } + return parsed; +} + +function visualColor(value: unknown, label: string, fallback: string): string { + try { + return normalizeSafeVisualColor(value ?? fallback, label); + } catch (error) { + if (error instanceof SafeVisualValidationError) { + return fail("UNSAFE_VISUAL_STYLE", error.message); + } + throw error; + } +} + +function visualPoint( + value: unknown, + size: number, + label: string, +): Extract { + if (!Array.isArray(value) || value.length !== 2) { + return fail("INVALID_SUDOKUPAD", `${label} must be a [row, column] point.`); + } + return { + kind: "coordinate", + y: finiteNumber(value[0], `${label}[0]`, -4, size + 4), + x: finiteNumber(value[1], `${label}[1]`, -4, size + 4), + }; +} + +function visualLayer( + value: unknown, + fallback: "underlay" | "overlay", + label: string, +): "underlay" | "overlay" { + if (value === undefined || value === "arrows" || value === "cell-grids") + return fallback; + if (value === "underlay" || value === "overlay") return value; + return fail( + "UNSAFE_VISUAL_CONSTRUCT", + `${label} targets an unsupported layer.`, + ); +} + +function assertVisualFields( + value: JsonRecord, + allowed: ReadonlySet, + label: string, +): void { + const unknown = Object.keys(value).find((field) => !allowed.has(field)); + if (unknown !== undefined) { + return fail( + "UNSAFE_VISUAL_CONSTRUCT", + `${label}.${unknown} is not an allowlisted visual field. Raw SVG paths, styles and custom code are never imported.`, + ); + } +} + +function visualWaypoints( + value: unknown, + size: number, + label: string, +): Array> { + if ( + !Array.isArray(value) || + value.length < 2 || + value.length > size * size * 4 + ) { + return fail( + "INVALID_SUDOKUPAD", + `${label} must contain bounded waypoints.`, + ); + } + return value.map((point, index) => + visualPoint(point, size, `${label}[${String(index)}]`), + ); +} + +function parseSudokuPadVisuals( + root: JsonRecord, + size: number, + cellSize: number, +): SafeVisualPrimitive[] { + const output: SafeVisualPrimitive[] = []; + const lineFields = new Set([ + "target", + "color", + "thickness", + "wayPoints", + "opacity", + ]); + for (const [index, raw] of objects(root.lines).entries()) { + const label = `lines[${String(index)}]`; + assertVisualFields(raw, lineFields, label); + output.push({ + type: "polyline", + layer: visualLayer(raw.target, "overlay", `${label}.target`), + points: visualWaypoints(raw.wayPoints, size, `${label}.wayPoints`), + style: { + stroke: visualColor(raw.color, `${label}.color`, "#000000"), + fill: "transparent", + strokeWidth: + finiteNumber( + raw.thickness, + `${label}.thickness`, + 0, + cellSize * 4, + 2, + ) / cellSize, + opacity: finiteNumber(raw.opacity, `${label}.opacity`, 0, 1, 1), + }, + }); + } + + const shapeFields = new Set([ + "target", + "center", + "width", + "height", + "rounded", + "roundedRadius", + "borderColor", + "backgroundColor", + "borderSize", + "thickness", + "opacity", + "text", + "textColor", + "color", + "fontSize", + "textStroke", + "textAnchor", + "maxWidth", + "angle", + ]); + for (const [rootField, fallbackLayer] of [ + ["underlays", "underlay"], + ["overlays", "overlay"], + ] as const) { + for (const [index, raw] of objects(root[rootField]).entries()) { + const label = `${rootField}[${String(index)}]`; + assertVisualFields(raw, shapeFields, label); + if (raw.angle !== undefined && Number(raw.angle) !== 0) { + return fail( + "UNSAFE_VISUAL_CONSTRUCT", + `${label}.angle cannot be represented by the safe visual model.`, + ); + } + if (raw.textAnchor !== undefined && raw.textAnchor !== "middle") { + return fail( + "UNSAFE_VISUAL_CONSTRUCT", + `${label}.textAnchor cannot be represented without changing layout.`, + ); + } + const center = visualPoint(raw.center, size, `${label}.center`); + const width = finiteNumber( + raw.width, + `${label}.width`, + 0.01, + size + 8, + 1, + ); + const height = finiteNumber( + raw.height, + `${label}.height`, + 0.01, + size + 8, + 1, + ); + const layer = visualLayer(raw.target, fallbackLayer, `${label}.target`); + const style: SafeVisualStyle = { + stroke: visualColor( + raw.borderColor, + `${label}.borderColor`, + "transparent", + ), + fill: visualColor( + raw.backgroundColor, + `${label}.backgroundColor`, + "transparent", + ), + strokeWidth: + finiteNumber( + raw.borderSize ?? raw.thickness, + `${label}.borderSize`, + 0, + cellSize * 4, + 0, + ) / cellSize, + opacity: finiteNumber(raw.opacity, `${label}.opacity`, 0, 1, 1), + }; + if (raw.rounded === true && width === height) { + output.push({ + type: "circle", + layer, + center, + radius: width / 2, + style, + }); + } else if (raw.rounded === true) { + output.push({ + type: "ellipse", + layer, + center, + radiusX: width / 2, + radiusY: height / 2, + style, + }); + } else { + output.push({ + type: "rectangle", + layer, + center, + width, + height, + ...(raw.roundedRadius === undefined + ? {} + : { + cornerRadius: + finiteNumber( + raw.roundedRadius, + `${label}.roundedRadius`, + 0, + cellSize * (size + 8), + ) / cellSize, + }), + style, + }); + } + if (raw.text !== undefined) { + output.push({ + type: "text", + layer, + position: center, + text: String(raw.text), + style: { + fill: visualColor( + raw.textColor ?? raw.color, + `${label}.textColor`, + "#000000", + ), + ...(raw.textStroke === undefined + ? {} + : { + stroke: visualColor( + raw.textStroke, + `${label}.textStroke`, + "transparent", + ), + strokeWidth: 0.01, + }), + fontSize: + finiteNumber( + raw.fontSize, + `${label}.fontSize`, + 1, + cellSize * 8, + cellSize * 0.5, + ) / cellSize, + opacity: style.opacity, + }, + }); + } + } + } + + const arrowFields = new Set([ + "target", + "color", + "opacity", + "thickness", + "headLength", + "headStyle", + "headAngle", + "headIndent", + "wayPoints", + ]); + for (const [index, raw] of objects(root.arrows).entries()) { + const label = `arrows[${String(index)}]`; + assertVisualFields(raw, arrowFields, label); + if ( + raw.headStyle !== undefined && + raw.headStyle !== "stroke" && + raw.headStyle !== "fill" + ) { + return fail( + "UNSAFE_VISUAL_CONSTRUCT", + `${label}.headStyle is unsupported.`, + ); + } + const points = visualWaypoints(raw.wayPoints, size, `${label}.wayPoints`); + const visualStyle: SafeVisualStyle = { + stroke: visualColor(raw.color, `${label}.color`, "#000000"), + fill: "transparent", + strokeWidth: + finiteNumber(raw.thickness, `${label}.thickness`, 0, cellSize * 4, 2) / + cellSize, + opacity: finiteNumber(raw.opacity, `${label}.opacity`, 0, 1, 1), + }; + const layer = visualLayer(raw.target, "overlay", `${label}.target`); + output.push({ type: "polyline", layer, points, style: visualStyle }); + const before = points.at(-2)!; + const tip = points.at(-1)!; + const dx = tip.x - before.x; + const dy = tip.y - before.y; + const distance = Math.hypot(dx, dy); + if (distance === 0) { + return fail( + "INVALID_SUDOKUPAD", + `${label} has a zero-length final segment.`, + ); + } + const length = finiteNumber( + raw.headLength, + `${label}.headLength`, + 0.02, + 4, + 0.3, + ); + const angle = + (finiteNumber(raw.headAngle, `${label}.headAngle`, 10, 170, 90) / 2) * + (Math.PI / 180); + const ux = -dx / distance; + const uy = -dy / distance; + const wing = (sign: -1 | 1): SafeVisualAnchor => ({ + kind: "coordinate", + x: tip.x + (ux * Math.cos(angle) - sign * uy * Math.sin(angle)) * length, + y: tip.y + (uy * Math.cos(angle) + sign * ux * Math.sin(angle)) * length, + }); + output.push({ + type: "polyline", + layer, + points: [wing(-1), tip, wing(1)], + ...(raw.headStyle === "fill" ? { closed: true } : {}), + style: { + ...visualStyle, + ...(raw.headStyle === "fill" + ? { fill: visualStyle.stroke, strokeWidth: 0 } + : {}), + }, + }); + } + try { + return normalizeSafeVisualPrimitives(output, size) ?? []; + } catch (error) { + if (error instanceof SafeVisualValidationError) { + return fail("INVALID_SUDOKUPAD_VISUAL", error.message); + } + throw error; + } +} + function coordinate(value: unknown, size: number, label: string): number { if ( !Array.isArray(value) || @@ -272,6 +684,44 @@ function parseCages( return constraints; } +function embeddedConstraints( + value: unknown, + size: number, + givens: readonly number[], + solution: readonly number[] | undefined, +): PortableConstraint[] | undefined { + if (value === undefined) return undefined; + if (typeof value !== "string" || value.length > 131_072) { + return fail( + "INVALID_SUDOKUPAD", + `${SUDOKU_TOOLS_CONSTRAINTS_METADATA} must be bounded JSON text.`, + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(value) as unknown; + } catch { + return fail( + "INVALID_SUDOKUPAD", + `${SUDOKU_TOOLS_CONSTRAINTS_METADATA} is not valid JSON.`, + ); + } + if (!Array.isArray(parsed)) { + return fail( + "INVALID_SUDOKUPAD", + `${SUDOKU_TOOLS_CONSTRAINTS_METADATA} must contain a constraint array.`, + ); + } + return normalizeSudokuDocument({ + schema: SUDOKU_DOCUMENT_SCHEMA, + version: SUDOKU_DOCUMENT_VERSION, + size, + givens, + constraints: parsed, + ...(solution === undefined ? {} : { solution }), + }).constraints as PortableConstraint[]; +} + /** Parse raw, already-decoded SudokuPad/CTC (often called SCL) JSON. */ export function parseSudokuPadPuzzle(value: unknown): SudokuDocument { if (!isRecord(value)) { @@ -282,9 +732,6 @@ export function parseSudokuPadPuzzle(value: unknown): SudokuDocument { if (!ROOT_FIELDS.has(field)) unsupported.push(`unknown root field “${field}”`); } - for (const field of ["lines", "underlays", "overlays", "arrows"] as const) { - if (present(value[field])) unsupported.push(`visual ${field}`); - } if (present(value.global)) unsupported.push("custom global rules"); if (isRecord(value.settings)) { for (const key of Object.keys(value.settings)) { @@ -306,6 +753,13 @@ export function parseSudokuPadPuzzle(value: unknown): SudokuDocument { MIN_BOARD_SIZE, MAX_BOARD_SIZE, ); + const cellSize = finiteNumber( + value.cellSize, + "SudokuPad cellSize", + 16, + 256, + 50, + ); if (!value.cells.every((row) => Array.isArray(row) && row.length === size)) { return fail( "INVALID_SUDOKUPAD", @@ -364,15 +818,47 @@ export function parseSudokuPadPuzzle(value: unknown): SudokuDocument { } const metadata = metadataFrom(value); - const constraints = parseCages(value.cages, size, metadata); - if (metadata.antiknight === true || metadata.antiknight === "true") { - constraints.push({ type: "anti-knight" }); - } - if (metadata.antiking === true || metadata.antiking === "true") { - constraints.push({ type: "anti-king" }); - } - if (metadata.nonconsecutive === true || metadata.nonconsecutive === "true") { - constraints.push({ type: "non-consecutive" }); + const title = metaText(value, metadata, "title"); + const author = metaText(value, metadata, "author"); + const rules = metaText(value, metadata, "rules"); + const solution = solutionDigits(metaText(value, metadata, "solution"), size); + const cageConstraints = parseCages(value.cages, size, metadata); + const transported = embeddedConstraints( + metadata[SUDOKU_TOOLS_CONSTRAINTS_METADATA], + size, + givens, + solution, + ); + const constraints = transported ?? cageConstraints; + if (transported === undefined) { + if (metadata.antiknight === true || metadata.antiknight === "true") { + constraints.push({ type: "anti-knight" }); + } + if (metadata.antiking === true || metadata.antiking === "true") { + constraints.push({ type: "anti-king" }); + } + if ( + metadata.nonconsecutive === true || + metadata.nonconsecutive === "true" + ) { + constraints.push({ type: "non-consecutive" }); + } + if (value.foglight !== undefined) { + if (!Array.isArray(value.foglight) || value.foglight.length === 0) { + return fail("INVALID_SUDOKUPAD", "foglight must be a non-empty array."); + } + constraints.push({ + type: "fog", + lights: [ + ...new Set( + value.foglight.map((entry, index) => + coordinate(entry, size, `foglight[${String(index)}]`), + ), + ), + ], + revealRadius: 1, + }); + } } const knownMetadata = new Set([ "title", @@ -382,23 +868,41 @@ export function parseSudokuPadPuzzle(value: unknown): SudokuDocument { "antiknight", "antiking", "nonconsecutive", + SUDOKU_TOOLS_CONSTRAINTS_METADATA, ]); - const unknownMetadata = Object.keys(metadata).filter( - (key) => !knownMetadata.has(key), - ); - if (unknownMetadata.length > 0) { - throw new UnsupportedPuzzleConstructsError( - "SudokuPad/CTC", - unknownMetadata.map((key) => `metadata “${key}”`), - ); + const rawScalarMetadata: Record = {}; + for (const [key, raw] of Object.entries(metadata)) { + if (!knownMetadata.has(key)) rawScalarMetadata[key] = raw; } - - const title = metaText(value, metadata, "title"); - const author = metaText(value, metadata, "author"); - const rules = metaText(value, metadata, "rules"); - const solution = solutionDigits(metaText(value, metadata, "solution"), size); + if ( + isRecord(value.settings) && + value.settings.conflictchecker !== undefined + ) { + rawScalarMetadata["setting.conflictchecker"] = + value.settings.conflictchecker; + } + rawScalarMetadata.cellSize = cellSize; + let scalarMetadata; + try { + scalarMetadata = normalizeScalarMetadata(rawScalarMetadata); + } catch (error) { + if (error instanceof SafeVisualValidationError) { + return fail("UNSAFE_SUDOKUPAD_METADATA", error.message); + } + throw error; + } + const visuals = parseSudokuPadVisuals(value, size, cellSize); const regions = parseRegions(value.regions, size); const id = boundedText(value.id, "SudokuPad id", 256); + const elapsedMs = + value.duration === undefined + ? undefined + : finiteNumber( + value.duration, + "SudokuPad duration", + 0, + 1_000_000_000_000, + ); return { schema: SUDOKU_DOCUMENT_SCHEMA, version: SUDOKU_DOCUMENT_VERSION, @@ -414,6 +918,13 @@ export function parseSudokuPadPuzzle(value: unknown): SudokuDocument { ...(rules === undefined ? {} : { rules: [rules.slice(0, 16_384)] }), ...(solution === undefined ? {} : { solution }), ...(id === undefined ? {} : { id }), + ...(elapsedMs === undefined ? {} : { elapsedMs }), + ...(visuals.length === 0 ? {} : { visuals }), + source: { + format: "sudokupad", + ...(id === undefined ? {} : { id }), + }, + ...(scalarMetadata === undefined ? {} : { metadata: scalarMetadata }), }; } @@ -487,3 +998,279 @@ export function importSudokuPad(input: string): SudokuDocument { } return parseSudokuPadPuzzle(boundedJson(decodePayload(payload))); } + +function sclPoint( + anchor: SafeVisualAnchor, + size: number, +): readonly [number, number] { + if (anchor.kind === "coordinate") return [anchor.y, anchor.x]; + return [ + Math.floor(anchor.cell / size) + 0.5 + (anchor.offsetY ?? 0), + (anchor.cell % size) + 0.5 + (anchor.offsetX ?? 0), + ]; +} + +interface SclVisualCollections { + readonly lines: JsonRecord[]; + readonly underlays: JsonRecord[]; + readonly overlays: JsonRecord[]; +} + +function appendSclVisual( + output: SclVisualCollections, + visual: SafeVisualPrimitive, + size: number, + cellSize: number, +): void { + const style = visual.style ?? {}; + const target = visual.layer; + if (visual.type === "line" || visual.type === "polyline") { + const points = + visual.type === "line" + ? [visual.start, visual.end] + : visual.closed + ? [...visual.points, visual.points[0]!] + : visual.points; + output.lines.push({ + target, + color: style.stroke ?? "#000000", + thickness: (style.strokeWidth ?? 0.03) * cellSize, + wayPoints: points.map((point) => sclPoint(point, size)), + ...(style.opacity === undefined ? {} : { opacity: style.opacity }), + }); + return; + } + + const collection = + visual.layer === "underlay" ? output.underlays : output.overlays; + if (visual.type === "text") { + collection.push({ + center: sclPoint(visual.position, size), + width: 0.25, + height: 0.25, + rounded: false, + backgroundColor: "transparent", + borderColor: "transparent", + color: style.fill ?? "#000000", + fontSize: (style.fontSize ?? 0.5) * cellSize, + text: visual.text, + ...(style.opacity === undefined ? {} : { opacity: style.opacity }), + }); + return; + } + + const center = sclPoint(visual.center, size); + const base = { + center, + backgroundColor: style.fill ?? "transparent", + borderColor: style.stroke ?? "transparent", + borderSize: (style.strokeWidth ?? 0) * cellSize, + ...(style.opacity === undefined ? {} : { opacity: style.opacity }), + }; + if (visual.type === "rectangle") { + collection.push({ + ...base, + width: visual.width, + height: visual.height, + rounded: false, + ...(visual.cornerRadius === undefined + ? {} + : { roundedRadius: visual.cornerRadius * cellSize }), + }); + } else if (visual.type === "circle") { + collection.push({ + ...base, + width: visual.radius * 2, + height: visual.radius * 2, + rounded: true, + }); + } else { + collection.push({ + ...base, + width: visual.radiusX * 2, + height: visual.radiusY * 2, + rounded: true, + }); + } +} + +/** Build uncompressed, standards-shaped SudokuPad/CTC SCL JSON data. */ +export function exportSudokuPadPuzzle(document: SudokuDocument): JsonRecord { + const normalized = normalizeSudokuDocument(document); + if ((normalized.globalRules?.length ?? 0) > 0) { + return fail( + "UNSUPPORTED_SUDOKUPAD", + "SudokuPad export cannot preserve free-form global rules safely.", + ); + } + const negated = normalized.constraints.find( + (constraint) => "negated" in constraint && constraint.negated === true, + ); + if (negated !== undefined) { + return fail( + "UNSUPPORTED_SUDOKUPAD", + "SudokuPad export cannot enforce individually false clues. Use Sudoku Tools JSON instead.", + ); + } + const { size } = normalized; + const sourceId = + normalized.source?.format === "sudokupad" + ? normalized.source.id + : undefined; + const outputId = sourceId ?? normalized.id; + const cellSize = + typeof normalized.metadata?.cellSize === "number" && + normalized.metadata.cellSize >= 16 && + normalized.metadata.cellSize <= 256 + ? normalized.metadata.cellSize + : 50; + const retainedMetadata = Object.fromEntries( + Object.entries(normalized.metadata ?? {}).filter( + ([key]) => !RESERVED_SEMANTIC_METADATA.has(key), + ), + ); + const metadata: JsonRecord = { + ...retainedMetadata, + ...(normalized.title === undefined ? {} : { title: normalized.title }), + ...(normalized.author === undefined ? {} : { author: normalized.author }), + ...(normalized.rules === undefined + ? {} + : { rules: normalized.rules.join("\n") }), + ...(normalized.solution === undefined + ? {} + : { solution: normalized.solution.join(",") }), + [SUDOKU_TOOLS_CONSTRAINTS_METADATA]: JSON.stringify(normalized.constraints), + }; + delete metadata.cellSize; + delete metadata["setting.conflictchecker"]; + if (normalized.constraints.some(({ type }) => type === "anti-knight")) + metadata.antiknight = true; + if (normalized.constraints.some(({ type }) => type === "anti-king")) + metadata.antiking = true; + if (normalized.constraints.some(({ type }) => type === "non-consecutive")) + metadata.nonconsecutive = true; + + const visuals: SclVisualCollections = { + lines: [], + underlays: [], + overlays: [], + }; + const renderedVisuals = [ + ...canonicalConstraintVisuals(normalized), + ...(normalized.visuals ?? []), + ]; + for (const visual of renderedVisuals) { + appendSclVisual(visuals, visual, size, cellSize); + } + + const cages = normalized.constraints + .filter( + ( + constraint, + ): constraint is Extract< + PortableConstraint, + { readonly type: "killer-cage" } + > => constraint.type === "killer-cage", + ) + .map((constraint) => ({ + cells: constraint.cells.map((cell) => [ + Math.floor(cell / size), + cell % size, + ]), + value: String(constraint.sum), + sum: constraint.sum, + unique: constraint.noRepeat !== false, + })); + const fogLights = normalized.constraints + .filter( + ( + constraint, + ): constraint is Extract => + constraint.type === "fog", + ) + .flatMap((constraint) => constraint.lights) + .map((cell) => [Math.floor(cell / size), cell % size]); + const regions = + normalized.regions === undefined + ? undefined + : Array.from({ length: size }, (_, region) => + normalized + .regions!.map((candidate, cell) => + candidate === region + ? ([Math.floor(cell / size), cell % size] as const) + : undefined, + ) + .filter( + (entry): entry is readonly [number, number] => + entry !== undefined, + ), + ); + const values = normalized.values ?? normalized.givens; + const output: JsonRecord = { + ...(outputId === undefined ? {} : { id: outputId }), + cellSize, + cells: Array.from({ length: size }, (_, row) => + Array.from({ length: size }, (_unused, column) => { + const index = row * size + column; + const given = normalized.givens[index] ?? 0; + const value = given || values[index] || 0; + return { + ...(value === 0 ? {} : { value }), + ...(value === 0 ? {} : { given: given !== 0 }), + ...(normalized.cornerMarks?.[index]?.length + ? { pencilMarks: [...normalized.cornerMarks[index]!] } + : {}), + ...((normalized.centerMarks ?? normalized.candidates)?.[index]?.length + ? { + centremarks: [ + ...(normalized.centerMarks ?? normalized.candidates)![index]!, + ], + } + : {}), + }; + }), + ), + metadata, + settings: { + conflictchecker: normalized.metadata?.["setting.conflictchecker"] ?? true, + }, + ...(normalized.elapsedMs === undefined + ? {} + : { duration: normalized.elapsedMs }), + ...(regions === undefined ? {} : { regions }), + ...(cages.length === 0 ? {} : { cages }), + ...(visuals.lines.length === 0 ? {} : { lines: visuals.lines }), + ...(visuals.underlays.length === 0 ? {} : { underlays: visuals.underlays }), + ...(visuals.overlays.length === 0 ? {} : { overlays: visuals.overlays }), + ...(fogLights.length === 0 ? {} : { foglight: fogLights }), + }; + return output; +} + +export function exportSudokuPadJson( + document: SudokuDocument, + pretty = false, +): string { + const json = JSON.stringify( + exportSudokuPadPuzzle(document), + null, + pretty ? 2 : undefined, + ); + if (new TextEncoder().encode(json).byteLength > MAX_DOCUMENT_BYTES) { + return fail("LIMIT_EXCEEDED", "The exported SudokuPad JSON is too large."); + } + return json; +} + +/** Self-contained `scl…` payload accepted by SudokuPad without a request. */ +export function exportSudokuPadPayload(document: SudokuDocument): string { + const compressed = compressToBase64(exportSudokuPadJson(document)); + const payload = `scl${encodeURIComponent(compressed)}`; + if (payload.length > MAX_SUDOKUPAD_PAYLOAD_LENGTH) { + return fail( + "LIMIT_EXCEEDED", + "The compressed SudokuPad payload is too large.", + ); + } + return payload; +} diff --git a/src/formats/types.ts b/src/formats/types.ts index 03404cc..14cc831 100644 --- a/src/formats/types.ts +++ b/src/formats/types.ts @@ -11,6 +11,89 @@ export const SUDOKU_DOCUMENT_VERSION = 1 as const; export type CellIndex = number; export type OutsideSide = "top" | "right" | "bottom" | "left"; +export type SudokuSourceFormat = + "sudoku-tools" | "plain-grid" | "fpuzzles" | "sudokupad" | "penpa"; + +/** Provenance only; it is never dereferenced or fetched by Sudoku Tools. */ +export interface SudokuSourceIdentity { + readonly format: SudokuSourceFormat; + readonly id?: string; + readonly version?: string; +} + +export type ScalarMetadataValue = string | number | boolean | null; +export type ScalarMetadata = Readonly>; + +/** + * Grid-relative anchor used by inert visual primitives. Coordinates have their + * origin at the grid's top-left corner; one unit equals one cell. + */ +export type SafeVisualAnchor = + | { + readonly kind: "coordinate"; + readonly x: number; + readonly y: number; + } + | { + readonly kind: "cell"; + readonly cell: CellIndex; + readonly offsetX?: number; + readonly offsetY?: number; + }; + +/** Only these inert presentation properties can cross an import boundary. */ +export interface SafeVisualStyle { + /** A normalized hexadecimal colour or `transparent`. */ + readonly stroke?: string; + /** A normalized hexadecimal colour or `transparent`. */ + readonly fill?: string; + /** Grid-relative width, not a CSS value. */ + readonly strokeWidth?: number; + readonly opacity?: number; + /** Grid-relative size, not a CSS value. */ + readonly fontSize?: number; +} + +interface SafeVisualBase { + readonly layer: "underlay" | "overlay"; + readonly style?: SafeVisualStyle; +} + +export type SafeVisualPrimitive = + | (SafeVisualBase & { + readonly type: "line"; + readonly start: SafeVisualAnchor; + readonly end: SafeVisualAnchor; + }) + | (SafeVisualBase & { + readonly type: "polyline"; + readonly points: readonly SafeVisualAnchor[]; + readonly closed?: boolean; + }) + | (SafeVisualBase & { + readonly type: "rectangle"; + readonly center: SafeVisualAnchor; + readonly width: number; + readonly height: number; + readonly cornerRadius?: number; + }) + | (SafeVisualBase & { + readonly type: "ellipse"; + readonly center: SafeVisualAnchor; + readonly radiusX: number; + readonly radiusY: number; + }) + | (SafeVisualBase & { + readonly type: "circle"; + readonly center: SafeVisualAnchor; + readonly radius: number; + }) + | (SafeVisualBase & { + readonly type: "text"; + readonly position: SafeVisualAnchor; + readonly text: string; + }); + export interface CluePolarity { /** When true, the completed clue must be false rather than true. */ readonly negated?: boolean; @@ -21,6 +104,7 @@ export type PortableConstraint = | { readonly type: "anti-knight" } | { readonly type: "anti-king" } | { readonly type: "non-consecutive" } + | { readonly type: "disjoint-groups" } | ({ readonly type: "killer-cage"; readonly cells: readonly CellIndex[]; @@ -71,6 +155,59 @@ export type PortableConstraint = readonly digits: readonly number[]; } & CluePolarity) | ({ readonly type: "maximum"; readonly cell: CellIndex } & CluePolarity) + | ({ readonly type: "minimum"; readonly cell: CellIndex } & CluePolarity) + | ({ readonly type: "odd"; readonly cell: CellIndex } & CluePolarity) + | ({ readonly type: "even"; readonly cell: CellIndex } & CluePolarity) + | ({ + readonly type: "little-killer"; + readonly side: OutsideSide; + readonly index: number; + readonly direction: "down-right" | "down-left" | "up-right" | "up-left"; + readonly sum: number; + } & CluePolarity) + | ({ + readonly type: "sandwich"; + readonly side: OutsideSide; + readonly index: number; + readonly sum: number; + } & CluePolarity) + | ({ + readonly type: "between-line"; + readonly cells: readonly CellIndex[]; + } & CluePolarity) + | ({ + readonly type: "german-whisper"; + readonly cells: readonly CellIndex[]; + readonly minimumDifference?: number; + } & CluePolarity) + | ({ + readonly type: "region-sum-line"; + readonly cells: readonly CellIndex[]; + } & CluePolarity) + | ({ + readonly type: "clone"; + readonly cells: readonly CellIndex[]; + readonly cloneCells: readonly CellIndex[]; + } & CluePolarity) + | { + readonly type: "extra-region"; + readonly cells: readonly CellIndex[]; + } + | ({ + readonly type: + "modular-line" | "entropic-line" | "zipper-line" | "double-arrow"; + readonly cells: readonly CellIndex[]; + } & CluePolarity) + | ({ + readonly type: "indexer"; + readonly kind: "row" | "column" | "box"; + readonly cell: CellIndex; + } & CluePolarity) + | { + readonly type: "fog"; + readonly lights: readonly CellIndex[]; + readonly revealRadius?: 0 | 1; + } | ({ readonly type: "renban"; readonly cells: readonly CellIndex[]; @@ -107,6 +244,12 @@ export interface SudokuDocument { /** Named boolean/global rules which cannot be represented by a local shape. */ readonly globalRules?: readonly string[]; readonly id?: string; + /** Inert, bounded drawings retained without accepting source SVG or HTML. */ + readonly visuals?: readonly SafeVisualPrimitive[]; + /** Original local interchange identity; never used as a network location. */ + readonly source?: SudokuSourceIdentity; + /** Uninterpreted bounded scalar source metadata. */ + readonly metadata?: ScalarMetadata; } /** Minimal structural type accepted by the domain adapter. */ @@ -149,24 +292,113 @@ export function toDomainPuzzle(document: SudokuDocument): DomainPuzzleShape { }; } -export function fromDomainPuzzle(puzzle: DomainPuzzleShape): SudokuDocument { +export type PreservedSudokuDocumentExtras = Pick< + SudokuDocument, + "visuals" | "source" | "metadata" +>; + +/** + * Copy the source-only portion of a document before adapting it to the domain + * puzzle model. An undefined result lets callers avoid carrying empty state. + */ +export function extractPreservedDocumentExtras( + document: SudokuDocument, +): PreservedSudokuDocumentExtras | undefined { + if ( + document.visuals === undefined && + document.source === undefined && + document.metadata === undefined + ) { + return undefined; + } return { - schema: SUDOKU_DOCUMENT_SCHEMA, - version: SUDOKU_DOCUMENT_VERSION, - size: puzzle.size, - givens: [...puzzle.givens], - constraints: (puzzle.constraints ?? []).map(cloneConstraint), - ...(puzzle.regions === undefined ? {} : { regions: [...puzzle.regions] }), - ...(puzzle.title === undefined ? {} : { title: puzzle.title }), - ...(puzzle.author === undefined ? {} : { author: puzzle.author }), - ...(puzzle.id === undefined ? {} : { id: puzzle.id }), - ...(puzzle.rules === undefined ? {} : { rules: [puzzle.rules] }), - ...(puzzle.solution === undefined + ...(document.visuals === undefined ? {} - : { solution: [...puzzle.solution] }), + : { visuals: document.visuals.map(cloneVisualPrimitive) }), + ...(document.source === undefined + ? {} + : { source: { ...document.source } }), + ...(document.metadata === undefined + ? {} + : { metadata: { ...document.metadata } }), }; } +export function cloneVisualPrimitive( + primitive: SafeVisualPrimitive, +): SafeVisualPrimitive { + const style = + primitive.style === undefined ? {} : { style: { ...primitive.style } }; + switch (primitive.type) { + case "line": + return { + ...primitive, + start: { ...primitive.start }, + end: { ...primitive.end }, + ...style, + }; + case "polyline": + return { + ...primitive, + points: primitive.points.map((point) => ({ ...point })), + ...style, + }; + case "rectangle": + case "ellipse": + case "circle": + return { ...primitive, center: { ...primitive.center }, ...style }; + case "text": + return { ...primitive, position: { ...primitive.position }, ...style }; + } +} + +/** + * Merge data which the domain model intentionally cannot carry. Callers keep + * ownership of the returned arrays and records. + */ +export function mergePreservedDocumentExtras( + document: SudokuDocument, + preserved?: Partial, +): 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, +): SudokuDocument { + return mergePreservedDocumentExtras( + { + schema: SUDOKU_DOCUMENT_SCHEMA, + version: SUDOKU_DOCUMENT_VERSION, + size: puzzle.size, + givens: [...puzzle.givens], + constraints: (puzzle.constraints ?? []).map(cloneConstraint), + ...(puzzle.regions === undefined ? {} : { regions: [...puzzle.regions] }), + ...(puzzle.title === undefined ? {} : { title: puzzle.title }), + ...(puzzle.author === undefined ? {} : { author: puzzle.author }), + ...(puzzle.id === undefined ? {} : { id: puzzle.id }), + ...(puzzle.rules === undefined ? {} : { rules: [puzzle.rules] }), + ...(puzzle.solution === undefined + ? {} + : { solution: [...puzzle.solution] }), + }, + preserved, + ); +} + export function cloneConstraint( constraint: PortableConstraint, ): PortableConstraint { @@ -176,7 +408,23 @@ export function cloneConstraint( case "thermo": case "renban": case "palindrome": + case "between-line": + case "german-whisper": + case "region-sum-line": + case "extra-region": + case "modular-line": + case "entropic-line": + case "zipper-line": + case "double-arrow": return { ...constraint, cells: [...constraint.cells] }; + case "fog": + return { ...constraint, lights: [...constraint.lights] }; + case "clone": + return { + ...constraint, + cells: [...constraint.cells], + cloneCells: [...constraint.cloneCells], + }; case "quadruple": return { ...constraint, diff --git a/src/formats/visual.ts b/src/formats/visual.ts index 744e889..6fb370e 100644 --- a/src/formats/visual.ts +++ b/src/formats/visual.ts @@ -1,3 +1,4 @@ +import { littleKillerCells } from "../domain/geometry"; import { normalizePuzzle } from "../domain/validation"; import type { NormalizedPuzzle, @@ -6,7 +7,12 @@ import type { } from "../domain/types"; import { symbolFor } from "../state/session"; import { normalizeSudokuDocument, SudokuFormatError } from "./document"; -import { toDomainPuzzle, type SudokuDocument } from "./types"; +import { + toDomainPuzzle, + type SafeVisualAnchor, + type SafeVisualPrimitive, + type SudokuDocument, +} from "./types"; export interface VisualExportOptions { readonly includeProgress?: boolean; @@ -76,6 +82,73 @@ function linePoints( .join(" "); } +function safeVisualPoint( + size: number, + anchor: SafeVisualAnchor, + origin: BoardOrigin, +) { + if (anchor.kind === "coordinate") { + return { + x: origin.x + anchor.x * CELL_SIZE, + y: origin.y + anchor.y * CELL_SIZE, + }; + } + const center = point(size, anchor.cell, origin); + return { + x: center.x + (anchor.offsetX ?? 0) * CELL_SIZE, + y: center.y + (anchor.offsetY ?? 0) * CELL_SIZE, + }; +} + +function renderSafeVisual( + visual: SafeVisualPrimitive, + size: number, + origin: BoardOrigin, + index: number, +): string { + const style = visual.style ?? {}; + const attributes = [ + `class="source-visual source-visual--${visual.type}"`, + `data-visual="${String(index)}"`, + `stroke="${escapeXml(style.stroke ?? "transparent")}"`, + `fill="${escapeXml(style.fill ?? "transparent")}"`, + ...(style.strokeWidth === undefined + ? [] + : [`stroke-width="${number(style.strokeWidth * CELL_SIZE)}"`]), + ...(style.opacity === undefined + ? [] + : [`opacity="${number(style.opacity)}"`]), + ].join(" "); + if (visual.type === "line") { + const start = safeVisualPoint(size, visual.start, origin); + const end = safeVisualPoint(size, visual.end, origin); + return ``; + } + 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 ``; + } + if (visual.type === "ellipse") { + const center = safeVisualPoint(size, visual.center, origin); + return ``; + } + if (visual.type === "circle") { + const center = safeVisualPoint(size, visual.center, origin); + return ``; + } + const position = safeVisualPoint(size, visual.position, origin); + return `${escapeXml(visual.text)}`; +} + function pathBoundary( size: number, cells: ReadonlySet, @@ -141,6 +214,8 @@ function globalRuleLabels(puzzle: NormalizedPuzzle): string[] { labels.push("Anti-king"); if (puzzle.constraints.some(({ type }) => type === "non-consecutive")) labels.push("Non-consecutive"); + if (puzzle.constraints.some(({ type }) => type === "disjoint-groups")) + labels.push("Disjoint groups"); return labels; } @@ -157,10 +232,41 @@ function renderConstraint( if ( constraint.type === "anti-knight" || constraint.type === "anti-king" || - constraint.type === "non-consecutive" + constraint.type === "non-consecutive" || + constraint.type === "disjoint-groups" ) { return ""; } + if ( + constraint.type === "modular-line" || + constraint.type === "entropic-line" + ) { + return ``; + } + if (constraint.type === "zipper-line") { + const center = point( + size, + constraint.cells[Math.floor(constraint.cells.length / 2)]!, + origin, + ); + return ``; + } + if (constraint.type === "double-arrow") { + const first = point(size, constraint.cells[0]!, origin); + const last = point(size, constraint.cells.at(-1)!, origin); + return ``; + } + if (constraint.type === "indexer") { + const center = point(size, constraint.cell, origin); + const label = + constraint.kind === "row" + ? "R" + : constraint.kind === "column" + ? "C" + : "B"; + return `${marker}${label}`; + } + if (constraint.type === "fog") return ""; if (constraint.type === "diagonal") { const startX = origin.x + (constraint.direction === "main" ? 0 : size * CELL_SIZE); @@ -173,6 +279,9 @@ function renderConstraint( const label = point(size, first, origin); return `${marker}${String(constraint.sum)}`; } + if (constraint.type === "extra-region") { + return ``; + } if (constraint.type === "thermo") { const bulb = point(size, constraint.cells[0]!, origin); return `${negated ? `` : ""}`; @@ -188,7 +297,12 @@ function renderConstraint( const maximumY = Math.max(...bulbPoints.map(({ y }) => y)); return `${negated ? `` : ""}`; } - if (constraint.type === "renban" || constraint.type === "palindrome") { + if ( + constraint.type === "renban" || + constraint.type === "palindrome" || + constraint.type === "german-whisper" || + constraint.type === "region-sum-line" + ) { return `${ constraint.type === "palindrome" ? constraint.cells @@ -200,12 +314,43 @@ function renderConstraint( : "" }`; } + if (constraint.type === "between-line") { + const first = point(size, constraint.cells[0]!, origin); + const last = point(size, constraint.cells.at(-1)!, origin); + return `${negated ? `` : ""}`; + } + 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 ``; + }) + .join(""); + return `${connectors}${negated ? `` : ""}`; + } if (constraint.type === "maximum") { const center = point(size, constraint.cell, origin); const offset = CELL_SIZE * 0.28; const inner = CELL_SIZE * 0.14; return ``; } + if (constraint.type === "minimum") { + const center = point(size, constraint.cell, origin); + const offset = CELL_SIZE * 0.28; + const inner = CELL_SIZE * 0.14; + return `${negated ? `` : ""}`; + } + if (constraint.type === "odd" || constraint.type === "even") { + const center = point(size, constraint.cell, origin); + const shape = + constraint.type === "odd" + ? `` + : ``; + return `${shape}${negated ? `` : ""}`; + } if (constraint.type === "quadruple") { const positions = constraint.cells.map((cell) => point(size, cell, origin)); const center = { @@ -218,7 +363,11 @@ function renderConstraint( }; return `${marker}${escapeXml(constraint.digits.map((digit) => symbolFor(digit, size)).join(""))}`; } - if (constraint.type === "x-sum" || constraint.type === "skyscraper") { + if ( + constraint.type === "x-sum" || + constraint.type === "skyscraper" || + constraint.type === "sandwich" + ) { const position = outsidePoint( size, constraint.side, @@ -226,9 +375,10 @@ function renderConstraint( origin, ); const value = - constraint.type === "x-sum" ? constraint.sum : constraint.count; + constraint.type === "skyscraper" ? constraint.count : constraint.sum; const companionIndex = puzzle.constraints.findIndex( (candidate) => + constraint.type !== "sandwich" && candidate.type !== constraint.type && (candidate.type === "x-sum" || candidate.type === "skyscraper") && candidate.side === constraint.side && @@ -242,7 +392,36 @@ function renderConstraint( if (combined) { return `Σ · ▥${marker}${String(value)}`; } - return `${constraint.type === "x-sum" ? "Σ" : "▥"}${marker}${String(value)}`; + const symbol = + constraint.type === "x-sum" + ? "Σ" + : constraint.type === "skyscraper" + ? "▥" + : "1⋯N "; + return `${symbol}${marker}${String(value)}`; + } + 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 `${marker}${String(constraint.sum)}`; + } + if ( + constraint.type !== "kropki" && + constraint.type !== "xv" && + constraint.type !== "inequality" + ) { + return ""; } const a = point( size, @@ -329,7 +508,11 @@ export function renderPuzzleSvg( const includeProgress = options.includeProgress ?? true; const includeNotes = options.includeNotes ?? includeProgress; const hasOutside = puzzle.constraints.some( - ({ type }) => type === "x-sum" || type === "skyscraper", + ({ type }) => + type === "x-sum" || + type === "skyscraper" || + type === "little-killer" || + type === "sandwich", ); const sideMargin = hasOutside ? OUTSIDE_MARGIN : BOARD_MARGIN; const boardSize = puzzle.size * CELL_SIZE; @@ -384,6 +567,20 @@ export function renderPuzzleSvg( renderConstraint(constraint, index, puzzle, origin), ) .join(""); + const sourceUnderlays = (normalizedDocument.visuals ?? []) + .map((visual, index) => ({ visual, index })) + .filter(({ visual }) => visual.layer === "underlay") + .map(({ visual, index }) => + renderSafeVisual(visual, puzzle.size, origin, index), + ) + .join(""); + const sourceOverlays = (normalizedDocument.visuals ?? []) + .map((visual, index) => ({ visual, index })) + .filter(({ visual }) => visual.layer === "overlay") + .map(({ visual, index }) => + renderSafeVisual(visual, puzzle.size, origin, index), + ) + .join(""); const digits = Array.from({ length: cellCount }, (_, cell) => { const value = puzzle.givens[cell] || values[cell] || 0; const center = point(puzzle.size, cell, origin); @@ -425,11 +622,11 @@ export function renderPuzzleSvg( ${escapeXml(title)} ${escapeXml(`${String(puzzle.size)} by ${String(puzzle.size)} Sudoku${includeProgress ? " with current progress" : ""}`)} ${escapeXml(title)}${globals.length > 0 ? `${escapeXml(globals.join(" · "))}` : ""} - ${backgrounds}${gridLines}${constraints}${renderRegionBoundaries(puzzle, origin)}${digits} + ${backgrounds}${sourceUnderlays}${gridLines}${constraints}${renderRegionBoundaries(puzzle, origin)}${digits}${sourceOverlays} `; if (new TextEncoder().encode(svg).byteLength > MAX_VISUAL_EXPORT_BYTES) { throw new SudokuFormatError( diff --git a/src/helpers/candidates.ts b/src/helpers/candidates.ts index 265366f..cd05064 100644 --- a/src/helpers/candidates.ts +++ b/src/helpers/candidates.ts @@ -139,6 +139,10 @@ export function houseLabel(unit: Pick): string { return `Region ${String(unit.index + 1)}`; case "diagonal": return unit.index === 0 ? "Main diagonal" : "Anti-diagonal"; + case "disjoint-group": + return `Disjoint group ${String(unit.index + 1)}`; + case "extra-region": + return `Extra region ${String(unit.index + 1)}`; } } diff --git a/src/main.tsx b/src/main.tsx index 1c711a7..058daae 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,3 +7,17 @@ createRoot(document.getElementById("root")!).render( , ); + +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. + }); + }); +} diff --git a/src/solver/advancedLogical.ts b/src/solver/advancedLogical.ts new file mode 100644 index 0000000..b078456 --- /dev/null +++ b/src/solver/advancedLogical.ts @@ -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[]; + 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(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[] { + return [...new Set(values)].sort((a, b) => a - b); +} + +function step( + technique: LogicalTechnique, + eliminations: readonly LogicalElimination[], + focusCells: Iterable, + explanation: string, +): LogicalStep | undefined { + if (eliminations.length === 0) return undefined; + const byCell = new Map>(); + for (const elimination of eliminations) { + const values = byCell.get(elimination.cell) ?? new Set(); + 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 { + const first = cells[0]; + if (first === undefined) return new Set(); + 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 = 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(); + 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(); + 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[] { + const adjacency = Array.from( + { length: context.values.length }, + () => new Set(), + ); + 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(); + for (let start = 0; start < context.values.length; start += 1) { + if (visited.has(start) || (adjacency[start]?.size ?? 0) === 0) continue; + const colours = new Map([[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> { + const adjacency = new Map>(); + const add = (a: CandidateNode, b: CandidateNode): void => { + const aId = nodeId(context, a); + const bId = nodeId(context, b); + const aLinks = adjacency.get(aId) ?? new Set(); + const bLinks = adjacency.get(bId) ?? new Set(); + 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(); + 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) + ); +} diff --git a/src/solver/difficulty.ts b/src/solver/difficulty.ts index 0741568..755acf9 100644 --- a/src/solver/difficulty.ts +++ b/src/solver/difficulty.ts @@ -58,6 +58,17 @@ const TECHNIQUE_WEIGHT: Readonly> = { "xy-wing": 57, "xyz-wing": 61, swordfish: 66, + skyscraper: 67, + "two-string-kite": 68, + "finned-x-wing": 69, + "simple-colouring": 70, + jellyfish: 72, + "w-wing": 73, + "unique-rectangle": 74, + "finned-swordfish": 75, + "x-chain": 78, + "xy-chain": 82, + aic: 88, }; function boundedInteger( @@ -151,12 +162,15 @@ export function evaluateDifficulty( 120_000, "exactTimeoutMs", ); - const logical = solveLogically(normalized, { maxSteps: logicalMaxSteps }); const exact = solveExact(normalized, { maxSolutions: 2, maxNodes: exactMaxNodes, timeoutMs: exactTimeoutMs, }); + const logical = solveLogically(normalized, { + maxSteps: logicalMaxSteps, + uniquenessProven: exact.count === 1 && !exact.truncated, + }); if (exact.count === 0 && !exact.truncated) { return unrated( diff --git a/src/solver/generator.ts b/src/solver/generator.ts index e41e36c..84d5c09 100644 --- a/src/solver/generator.ts +++ b/src/solver/generator.ts @@ -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(); + 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( diff --git a/src/solver/index.ts b/src/solver/index.ts index 8f208f5..3209eef 100644 --- a/src/solver/index.ts +++ b/src/solver/index.ts @@ -1,6 +1,8 @@ +export * from "./advancedLogical"; export * from "./difficulty"; export * from "./exact"; export * from "./generator"; export * from "./killer"; export * from "./logical"; +export * from "./quality"; export * from "./variantGenerator"; diff --git a/src/solver/logical.ts b/src/solver/logical.ts index dd73487..d613176 100644 --- a/src/solver/logical.ts +++ b/src/solver/logical.ts @@ -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(cellCount).fill(0); + for (let cell = 0; cell < Math.min(input.length, cellCount); cell += 1) { + const cellCandidates: unknown = input[cell]; + if (!Array.isArray(cellCandidates)) { + issues.push({ + path: `candidates[${cell}]`, + message: "must be an array of candidate digits", + }); + continue; + } + + let mask = 0; + for (let index = 0; index < cellCandidates.length; index += 1) { + const value: unknown = cellCandidates[index]; + if ( + typeof value !== "number" || + !Number.isInteger(value) || + value < 1 || + value > normalized.size + ) { + issues.push({ + path: `candidates[${cell}][${index}]`, + message: `must be an integer from 1 to ${normalized.size}`, + }); + continue; + } + const bit = digitBit(value); + if ((mask & bit) !== 0) { + issues.push({ + path: `candidates[${cell}][${index}]`, + message: `contains duplicate candidate ${value}`, + }); + continue; + } + mask |= bit; + } + masks[cell] = mask; + } + + if (issues.length > 0) throw new PuzzleValidationError(issues); + return masks; +} + function initializeState( normalized: NormalizedPuzzle, values: readonly number[], + candidateRestrictions?: readonly number[], ): LogicalState { const compiled = compilePuzzle(normalized); return { @@ -158,10 +247,13 @@ function initializeState( values: [...values], masks: values.map((value, cell) => { if (value !== 0) return 0; - return candidatesForCell(compiled, values, cell).reduce( + const legalMask = candidatesForCell(compiled, values, cell).reduce( (mask, candidate) => mask | digitBit(candidate), 0, ); + return candidateRestrictions === undefined + ? legalMask + : legalMask & (candidateRestrictions[cell] ?? 0); }), }; } @@ -624,7 +716,10 @@ function findKillerReduction(state: LogicalState): LogicalStep | undefined { return undefined; } -function findStep(state: LogicalState): LogicalStep | undefined { +function findStep( + state: LogicalState, + uniquenessProven: boolean, +): LogicalStep | undefined { return ( findNakedSingle(state) ?? findHiddenSingle(state) ?? @@ -634,6 +729,16 @@ function findStep(state: LogicalState): LogicalStep | undefined { findFish(state) ?? findXyWing(state) ?? findXyzWing(state) ?? + findAdvancedLogicalStep({ + size: state.compiled.puzzle.size, + values: state.values, + masks: state.masks, + regions: state.compiled.puzzle.regions, + peers: state.compiled.peers, + units: state.compiled.units, + uniquenessProven, + uniquenessPatternsSafe: state.compiled.puzzle.constraints.length === 0, + }) ?? findKillerReduction(state) ); } @@ -677,7 +782,17 @@ export function solveLogically( if (!Number.isInteger(maxSteps) || maxSteps < 1 || maxSteps > 10_000) { throw new RangeError("maxSteps must be an integer from 1 to 10000"); } - const state = initializeState(normalized, values); + if ( + options.uniquenessProven !== undefined && + typeof options.uniquenessProven !== "boolean" + ) { + throw new TypeError("uniquenessProven must be a boolean when supplied"); + } + const candidateRestrictions = compileCandidateRestrictions( + normalized, + options.candidates, + ); + const state = initializeState(normalized, values, candidateRestrictions); const steps: LogicalStep[] = []; if (findConflicts(state.compiled, state.values).length > 0) { return { @@ -708,7 +823,7 @@ export function solveLogically( steps, }; } - const step = findStep(state); + const step = findStep(state, options.uniquenessProven === true); if (step === undefined) { return { status: "stuck", diff --git a/src/solver/quality.ts b/src/solver/quality.ts new file mode 100644 index 0000000..7e46c2f --- /dev/null +++ b/src/solver/quality.ts @@ -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(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, +): 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; + 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(), + 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; +} diff --git a/src/solver/variantGenerator.ts b/src/solver/variantGenerator.ts index 472d6ec..3c783bf 100644 --- a/src/solver/variantGenerator.ts +++ b/src/solver/variantGenerator.ts @@ -15,8 +15,9 @@ import { solveExact } from "./exact"; import type { LogicalTechnique } from "./logical"; import { generateClassic, - minimizePuzzle, + minimizePuzzleWithReport, type ClueSymmetry, + type MinimalGivensEvidence, } from "./generator"; import { seededRandom, shuffled, type RandomSource } from "./random"; @@ -118,16 +119,61 @@ export const PRACTICE_TECHNIQUES = [ "naked-quad", "hidden-quad", "x-wing", + "finned-x-wing", "xy-wing", "xyz-wing", "swordfish", + "finned-swordfish", + "jellyfish", + "skyscraper", + "two-string-kite", + "simple-colouring", + "w-wing", + "x-chain", + "xy-chain", + "aic", + "unique-rectangle", "killer-cage", ] as const satisfies readonly LogicalTechnique[]; export type PracticeTechnique = (typeof PRACTICE_TECHNIQUES)[number]; +export type ConstraintDensity = "sparse" | "balanced" | "dense"; +export type GeneratedVariantKind = GeneratorVariant | "mixed"; + +export interface TechniqueCountRequirement { + readonly technique: PracticeTechnique; + readonly min?: number; + readonly max?: number; +} + +export interface TechniqueProfile { + readonly required?: readonly PracticeTechnique[]; + readonly forbidden?: readonly PracticeTechnique[]; + readonly counts?: readonly TechniqueCountRequirement[]; + readonly hardestTechnique?: PracticeTechnique; +} + +export interface TechniqueRequirementEvidence { + readonly technique: PracticeTechnique; + readonly actual: number; + readonly minimum?: number; + readonly maximum?: number; + readonly matched: boolean; +} + +export interface TechniqueProfileEvidence { + readonly status: "matched" | "not-matched"; + readonly completePath: boolean; + readonly requirements: readonly TechniqueRequirementEvidence[]; + readonly requestedHardestTechnique?: PracticeTechnique; + readonly actualHardestTechnique?: LogicalTechnique; + readonly hardestMatched: boolean; +} export interface GenerateVariantOptions { readonly variant?: GeneratorVariant; + /** Canonicalized set of families for mixed-variant generation. */ + readonly variants?: readonly GeneratorVariant[]; readonly size?: number; readonly boxRows?: number; readonly boxColumns?: number; @@ -136,14 +182,18 @@ export interface GenerateVariantOptions { readonly targetDifficulty?: GenerationDifficultyTarget; readonly targetClues?: number; readonly symmetry?: ClueSymmetry; + readonly minimalGivens?: boolean; /** Number of local markings requested. Global rules, Killer and diagonal use structural counts. */ readonly constraintCount?: number; + readonly constraintDensity?: ConstraintDensity; readonly maxChecks?: number; readonly solveMaxNodes?: number; readonly solveTimeoutMs?: number; readonly difficulty?: DifficultyOptions; /** Require the independently rated logical path to use this technique. */ readonly requiredTechnique?: PracticeTechnique; + /** Full independently checked logical-path profile. */ + readonly techniqueProfile?: TechniqueProfile; /** Bounded deterministic attempts used to find the requested technique. */ readonly maxTechniqueAttempts?: number; } @@ -151,11 +201,50 @@ export interface GenerateVariantOptions { export interface GeneratedVariantPuzzle { readonly puzzle: NormalizedPuzzle; readonly difficulty: DifficultyAssessment; - readonly variant: GeneratorVariant; + readonly variant: GeneratedVariantKind; + readonly families: readonly GeneratorVariant[]; readonly seed: string | number; readonly generatedConstraintCount: number; readonly generationAttempts: number; + readonly constraintDensity: ConstraintDensity; + readonly minimality: MinimalGivensEvidence; readonly requestedTechnique?: PracticeTechnique; + readonly techniqueProfile?: TechniqueProfileEvidence; +} + +export type BatchRanking = "difficulty" | "fewest-givens" | "most-givens"; + +export interface GenerateVariantBatchOptions extends GenerateVariantOptions { + readonly batchSize?: number; + readonly ranking?: BatchRanking; +} + +export interface GeneratedPuzzleSummary { + readonly rank: number; + readonly seed: string | number; + readonly families: readonly GeneratorVariant[]; + readonly clueCount: number; + readonly constraintCount: number; + readonly score: number | null; + readonly level: DifficultyAssessment["level"]; + readonly minimalityStatus: MinimalGivensEvidence["status"]; + readonly profileStatus?: TechniqueProfileEvidence["status"]; +} + +export interface BatchGenerationFailure { + readonly seed: string | number; + readonly message: string; +} + +export interface GeneratedVariantBatch { + readonly entries: readonly GeneratedVariantPuzzle[]; + readonly summaries: readonly GeneratedPuzzleSummary[]; + readonly failures: readonly BatchGenerationFailure[]; + readonly requested: number; + readonly completed: number; + readonly truncated: boolean; + readonly ranking: BatchRanking; + readonly baseSeed: string | number; } const DEFAULT_CONSTRAINT_COUNTS: Readonly< @@ -229,6 +318,47 @@ function getDefinition(variant: string) { return GENERATOR_VARIANTS.find((definition) => definition.id === variant); } +function canonicalFamilies( + options: GenerateVariantOptions, +): GeneratorVariant[] { + const requested = + options.variants === undefined + ? [options.variant ?? "classic"] + : [...options.variants]; + if (requested.length === 0 || requested.length > GENERATOR_VARIANTS.length) { + throw new RangeError( + `variants must contain 1 to ${String(GENERATOR_VARIANTS.length)} supported families.`, + ); + } + const requestedSet = new Set(requested); + for (const variant of requestedSet) { + if (getDefinition(variant) === undefined) { + throw new RangeError( + `Unsupported generator variant: ${String(variant)}.`, + ); + } + } + return GENERATOR_VARIANTS.map(({ id }) => id).filter((id) => + requestedSet.has(id), + ); +} + +function validatedDensity( + value: ConstraintDensity | undefined, +): ConstraintDensity { + const density = value ?? "balanced"; + if (density !== "sparse" && density !== "balanced" && density !== "dense") { + throw new RangeError( + 'constraintDensity must be "sparse", "balanced" or "dense".', + ); + } + return density; +} + +function densityMultiplier(density: ConstraintDensity): number { + return density === "sparse" ? 0.55 : density === "dense" ? 1.55 : 1; +} + function clueTarget(size: number, target: GenerationDifficultyTarget): number { const ratio: Record = { beginner: 0.58, @@ -240,6 +370,171 @@ function clueTarget(size: number, target: GenerationDifficultyTarget): number { return Math.max(size, Math.round(size * size * ratio[target])); } +function isPracticeTechnique(value: unknown): value is PracticeTechnique { + return ( + typeof value === "string" && + (PRACTICE_TECHNIQUES as readonly string[]).includes(value) + ); +} + +interface NormalizedTechniqueProfile { + readonly requirements: readonly { + readonly technique: PracticeTechnique; + readonly minimum?: number; + readonly maximum?: number; + }[]; + readonly hardestTechnique?: PracticeTechnique; +} + +function techniqueBound( + value: number | undefined, + name: string, +): number | undefined { + if (value === undefined) return undefined; + return boundedInteger(value, 0, 0, 10_000, name); +} + +function normalizeTechniqueProfile( + profile: TechniqueProfile | undefined, + legacyRequired?: PracticeTechnique, +): NormalizedTechniqueProfile | undefined { + if (legacyRequired !== undefined && !isPracticeTechnique(legacyRequired)) { + throw new RangeError( + `Unsupported practice technique: ${String(legacyRequired)}.`, + ); + } + const requirements = new Map< + PracticeTechnique, + { minimum?: number; maximum?: number } + >(); + const ensure = (technique: unknown) => { + if (!isPracticeTechnique(technique)) { + throw new RangeError( + `Unsupported practice technique: ${String(technique)}.`, + ); + } + const current = requirements.get(technique) ?? {}; + requirements.set(technique, current); + return current; + }; + if (legacyRequired !== undefined) { + ensure(legacyRequired).minimum = 1; + } + for (const technique of profile?.required ?? []) { + const requirement = ensure(technique); + requirement.minimum = Math.max(requirement.minimum ?? 0, 1); + } + for (const technique of profile?.forbidden ?? []) { + const requirement = ensure(technique); + requirement.maximum = Math.min(requirement.maximum ?? 0, 0); + } + const seenCounts = new Set(); + for (const count of profile?.counts ?? []) { + const requirement = ensure(count.technique); + if (seenCounts.has(count.technique)) { + throw new RangeError( + `Technique count profile repeats ${count.technique}.`, + ); + } + seenCounts.add(count.technique); + const minimum = techniqueBound( + count.min, + `techniqueProfile.counts.${count.technique}.min`, + ); + const maximum = techniqueBound( + count.max, + `techniqueProfile.counts.${count.technique}.max`, + ); + if (minimum === undefined && maximum === undefined) { + throw new RangeError( + `Technique count profile for ${count.technique} needs min or max.`, + ); + } + if (minimum !== undefined) { + requirement.minimum = Math.max(requirement.minimum ?? 0, minimum); + } + if (maximum !== undefined) { + requirement.maximum = Math.min(requirement.maximum ?? maximum, maximum); + } + } + for (const [technique, requirement] of requirements) { + if ( + requirement.minimum !== undefined && + requirement.maximum !== undefined && + requirement.minimum > requirement.maximum + ) { + throw new RangeError( + `Technique profile for ${technique} has a minimum above its maximum.`, + ); + } + } + const hardestTechnique = profile?.hardestTechnique; + if ( + hardestTechnique !== undefined && + !isPracticeTechnique(hardestTechnique) + ) { + throw new RangeError( + `Unsupported hardest technique: ${String(hardestTechnique)}.`, + ); + } + if (requirements.size === 0 && hardestTechnique === undefined) { + return undefined; + } + const ordered = PRACTICE_TECHNIQUES.flatMap((technique) => { + const requirement = requirements.get(technique); + return requirement === undefined ? [] : [{ technique, ...requirement }]; + }); + return { + requirements: ordered, + ...(hardestTechnique === undefined ? {} : { hardestTechnique }), + }; +} + +function evaluateNormalizedTechniqueProfile( + assessment: DifficultyAssessment, + profile: NormalizedTechniqueProfile, +): TechniqueProfileEvidence { + const requirements = profile.requirements.map((requirement) => { + const actual = assessment.techniqueCounts[requirement.technique] ?? 0; + const matched = + (requirement.minimum === undefined || actual >= requirement.minimum) && + (requirement.maximum === undefined || actual <= requirement.maximum); + return { ...requirement, actual, matched }; + }); + const completePath = assessment.logicalStatus === "solved"; + const hardestMatched = + profile.hardestTechnique === undefined || + assessment.hardestTechnique === profile.hardestTechnique; + return { + status: + completePath && + requirements.every(({ matched }) => matched) && + hardestMatched + ? "matched" + : "not-matched", + completePath, + requirements, + ...(profile.hardestTechnique === undefined + ? {} + : { requestedHardestTechnique: profile.hardestTechnique }), + ...(assessment.hardestTechnique === undefined + ? {} + : { actualHardestTechnique: assessment.hardestTechnique }), + hardestMatched, + }; +} + +export function evaluateTechniqueProfile( + assessment: DifficultyAssessment, + profile: TechniqueProfile, +): TechniqueProfileEvidence { + const normalized = normalizeTechniqueProfile(profile); + if (normalized === undefined) { + throw new RangeError("Technique profile must contain a requirement."); + } + return evaluateNormalizedTechniqueProfile(assessment, normalized); +} + function allEdges(size: number): Array { const edges: Array = []; for (let cell = 0; cell < size * size; cell += 1) { @@ -285,6 +580,7 @@ function killerCages( size: number, solution: readonly number[], random: RandomSource, + density: ConstraintDensity, ): VariantConstraint[] { const unassigned = new Set( Array.from({ length: size * size }, (_, cell) => cell), @@ -292,7 +588,16 @@ function killerCages( const constraints: VariantConstraint[] = []; for (const start of shuffled([...unassigned], random)) { if (!unassigned.has(start)) continue; - const target = 2 + Math.floor(random() * Math.min(3, size - 1)); + const minimumTarget = density === "sparse" ? Math.min(3, size) : 2; + const maximumTarget = + density === "dense" + ? Math.min(3, size) + : density === "sparse" + ? Math.min(5, size) + : Math.min(4, size); + const target = + minimumTarget + + Math.floor(random() * (maximumTarget - minimumTarget + 1)); const cells = [start]; const digits = new Set([solution[start]]); unassigned.delete(start); @@ -497,8 +802,11 @@ function localConstraints( solution: readonly number[], count: number, random: RandomSource, + density: ConstraintDensity, ): VariantConstraint[] { - if (variant === "killer") return killerCages(size, solution, random); + if (variant === "killer") { + return killerCages(size, solution, random, density); + } const candidates = (() => { switch (variant) { case "thermo": @@ -527,8 +835,29 @@ function localConstraints( ); } +function globalConstraints( + families: readonly GeneratorVariant[], +): VariantConstraint[] { + const constraints: VariantConstraint[] = []; + for (const variant of families) { + if (variant === "diagonal") { + constraints.push( + { type: "diagonal", direction: "main" }, + { type: "diagonal", direction: "anti" }, + ); + } else if ( + variant === "anti-knight" || + variant === "anti-king" || + variant === "non-consecutive" + ) { + constraints.push({ type: variant }); + } + } + return constraints; +} + function fullSolution( - variant: GeneratorVariant, + families: readonly GeneratorVariant[], size: number, boxRows: number | undefined, boxColumns: number | undefined, @@ -536,12 +865,8 @@ function fullSolution( maxNodes: number, timeoutMs: number, ): NormalizedPuzzle { - const globalVariant = - variant === "diagonal" || - variant === "anti-knight" || - variant === "anti-king" || - variant === "non-consecutive"; - if (!globalVariant) { + const constraints = globalConstraints(families); + if (constraints.length === 0) { return generateClassic({ size, boxRows, @@ -551,13 +876,6 @@ function fullSolution( maxChecks: 1, }); } - const constraints: VariantConstraint[] = - variant === "diagonal" - ? [ - { type: "diagonal", direction: "main" }, - { type: "diagonal", direction: "anti" }, - ] - : [{ type: variant }]; const empty: PuzzleDefinition = { version: 1, size, @@ -569,17 +887,51 @@ function fullSolution( maxSolutions: 1, maxNodes, timeoutMs, - seed: `${String(seed)}:${variant}-solution`, + seed: `${String(seed)}:${families.join("+")}-solution`, }); const solution = solved.solutions[0]; if (solution === undefined) { throw new Error( - `Could not construct a ${variant} solution within the ${String(timeoutMs)} ms / ${String(maxNodes)} node generation limit.`, + `Could not construct a ${families.join(" + ")} solution within the ${String(timeoutMs)} ms / ${String(maxNodes)} node generation limit.`, ); } return normalizePuzzle({ ...empty, givens: solution, solution }); } +type LocalGeneratorVariant = Exclude< + GeneratorVariant, + "classic" | "diagonal" | "anti-knight" | "anti-king" | "non-consecutive" +>; + +function isLocalGeneratorVariant( + variant: GeneratorVariant, +): variant is LocalGeneratorVariant { + return ( + variant !== "classic" && + variant !== "diagonal" && + variant !== "anti-knight" && + variant !== "anti-king" && + variant !== "non-consecutive" + ); +} + +function rulesForFamilies(families: readonly GeneratorVariant[]): string { + if (families.length === 1) return RULES[families[0] as GeneratorVariant]; + return families + .map((family) => RULES[family]) + .join(" ") + .slice(0, 16_384); +} + +function labelForFamilies(families: readonly GeneratorVariant[]): string { + if (families.length === 1) { + return getDefinition(families[0] as GeneratorVariant)?.label ?? "Sudoku"; + } + return `Mixed · ${families + .map((family) => getDefinition(family)?.label ?? family) + .join(" + ")}`; +} + /** * Creates a unique, seedable variant puzzle. Generation is suitable for a Web * Worker: every exact search has explicit node/time caps and worker termination @@ -587,17 +939,21 @@ function fullSolution( */ function generateVariantAttempt( options: GenerateVariantOptions = {}, + techniqueProfile?: NormalizedTechniqueProfile, ): GeneratedVariantPuzzle { - const variant = options.variant ?? "classic"; - const definition = getDefinition(variant); - if (definition === undefined) { - throw new RangeError(`Unsupported generator variant: ${String(variant)}.`); - } + const families = canonicalFamilies(options); + const variant: GeneratedVariantKind = + families.length === 1 ? (families[0] as GeneratorVariant) : "mixed"; const size = boundedInteger(options.size, 9, 4, 16, "size"); - if (!(definition.supportedSizes as readonly number[]).includes(size)) { - throw new RangeError( - `${definition.label} generation supports sizes ${definition.supportedSizes.join(", ")}.`, - ); + for (const family of families) { + const definition = getDefinition( + family, + ) as (typeof GENERATOR_VARIANTS)[number]; + if (!(definition.supportedSizes as readonly number[]).includes(size)) { + throw new RangeError( + `${definition.label} generation supports sizes ${definition.supportedSizes.join(", ")}.`, + ); + } } const seed = options.seed ?? "sudoku-tools"; const targetDifficulty = options.targetDifficulty ?? "medium"; @@ -610,7 +966,9 @@ function generateVariantAttempt( ); const maxChecks = boundedInteger( options.maxChecks, - Math.min(size * size, 72), + options.minimalGivens === true + ? Math.min(size * size * 3, size * size * 10) + : Math.min(size * size, 72), 1, size * size * 10, "maxChecks", @@ -629,23 +987,19 @@ function generateVariantAttempt( 120_000, "solveTimeoutMs", ); - const requestedConstraintCount = boundedInteger( - options.constraintCount, - variant === "classic" || - variant === "diagonal" || - variant === "anti-knight" || - variant === "anti-king" || - variant === "non-consecutive" || - variant === "killer" - ? 1 - : DEFAULT_CONSTRAINT_COUNTS[variant], - 1, - size * size * 2, - "constraintCount", - ); - const random = seededRandom(`${String(seed)}:${variant}:constraints`); + const requestedConstraintCount = + options.constraintCount === undefined + ? undefined + : boundedInteger( + options.constraintCount, + 1, + 1, + size * size * 2, + "constraintCount", + ); + const constraintDensity = validatedDensity(options.constraintDensity); const full = fullSolution( - variant, + families, size, options.boxRows, options.boxColumns, @@ -657,22 +1011,28 @@ function generateVariantAttempt( if (solution === undefined) { throw new Error("Internal generator error: the completed grid was lost."); } - const constraints: VariantConstraint[] = - variant === "classic" - ? [] - : variant === "diagonal" || - variant === "anti-knight" || - variant === "anti-king" || - variant === "non-consecutive" - ? [...full.constraints] - : localConstraints( - variant, - size, - solution, - requestedConstraintCount, - random, - ); - const title = `${definition.label} · ${String(seed)}`.slice(0, 256); + const constraints: VariantConstraint[] = [...full.constraints]; + for (const family of families) { + if (!isLocalGeneratorVariant(family)) continue; + const baseCount = + requestedConstraintCount ?? + (family === "killer" ? 1 : DEFAULT_CONSTRAINT_COUNTS[family]); + const familyCount = Math.max( + 1, + Math.round(baseCount * densityMultiplier(constraintDensity)), + ); + constraints.push( + ...localConstraints( + family, + size, + solution, + familyCount, + seededRandom(`${String(seed)}:${family}:constraints`), + constraintDensity, + ), + ); + } + const title = `${labelForFamilies(families)} · ${String(seed)}`.slice(0, 256); const complete = normalizePuzzle({ version: 1, size, @@ -682,29 +1042,41 @@ function generateVariantAttempt( solution, title, author: "Sudoku Tools generator", - rules: RULES[variant], + rules: rulesForFamilies(families), }); - const puzzle = minimizePuzzle(complete, { - seed: `${String(seed)}:${variant}:clues`, + const minimized = minimizePuzzleWithReport(complete, { + seed: `${String(seed)}:${families.join("+")}:clues`, targetClues, symmetry: options.symmetry ?? "rotational", maxChecks, solveMaxNodes, solveTimeoutMs, + minimalGivens: options.minimalGivens, }); + const puzzle = minimized.puzzle; const difficulty = evaluateDifficulty(puzzle, options.difficulty); if (difficulty.uniqueness !== "unique") { throw new Error( "Generated puzzle did not pass the bounded uniqueness audit; no puzzle was returned.", ); } + const profileEvidence = + techniqueProfile === undefined + ? undefined + : evaluateNormalizedTechniqueProfile(difficulty, techniqueProfile); return { puzzle, difficulty, variant, + families, seed, generatedConstraintCount: constraints.length, generationAttempts: 1, + constraintDensity, + minimality: minimized.minimality, + ...(profileEvidence === undefined + ? {} + : { techniqueProfile: profileEvidence }), }; } @@ -716,15 +1088,18 @@ export function generateVariant( options: GenerateVariantOptions = {}, ): GeneratedVariantPuzzle { const requiredTechnique = options.requiredTechnique; - if ( - requiredTechnique !== undefined && - !(PRACTICE_TECHNIQUES as readonly string[]).includes(requiredTechnique) - ) { - throw new RangeError( - `Unsupported practice technique: ${String(requiredTechnique)}.`, - ); - } - if (requiredTechnique === "killer-cage" && options.variant !== "killer") { + const profile = normalizeTechniqueProfile( + options.techniqueProfile, + requiredTechnique, + ); + const families = canonicalFamilies(options); + const requiresKiller = + profile?.hardestTechnique === "killer-cage" || + profile?.requirements.some( + ({ technique, minimum }) => + technique === "killer-cage" && (minimum ?? 0) > 0, + ) === true; + if (requiresKiller && !families.includes("killer")) { throw new RangeError( "Killer-cage practice requires the Killer generator variant.", ); @@ -732,7 +1107,7 @@ export function generateVariant( const maxAttempts = boundedInteger( options.maxTechniqueAttempts, - requiredTechnique === undefined ? 1 : 10, + profile === undefined ? 1 : 10, 1, 32, "maxTechniqueAttempts", @@ -742,11 +1117,14 @@ export function generateVariant( const seed = attempt === 0 ? baseSeed - : `${String(baseSeed)}:practice:${String(requiredTechnique)}:${String(attempt + 1)}`; - const generated = generateVariantAttempt({ ...options, seed }); + : options.techniqueProfile === undefined && + requiredTechnique !== undefined + ? `${String(baseSeed)}:practice:${String(requiredTechnique)}:${String(attempt + 1)}` + : `${String(baseSeed)}:profile:${String(attempt + 1)}`; + const generated = generateVariantAttempt({ ...options, seed }, profile); if ( - requiredTechnique === undefined || - (generated.difficulty.techniqueCounts[requiredTechnique] ?? 0) > 0 + profile === undefined || + generated.techniqueProfile?.status === "matched" ) { return { ...generated, @@ -759,6 +1137,80 @@ export function generateVariant( } throw new Error( - `No uniquely checked puzzle using ${requiredTechnique?.replaceAll("-", " ")} was found in ${String(maxAttempts)} deterministic attempts. Try a different seed, difficulty profile, or a larger attempt limit.`, + `No uniquely checked puzzle matching the requested technique profile was found in ${String(maxAttempts)} deterministic attempts. Try a different seed, profile, or a larger attempt limit.`, ); } + +function rankingComparator(ranking: BatchRanking) { + return ( + left: GeneratedVariantPuzzle, + right: GeneratedVariantPuzzle, + ): number => { + const leftClues = left.puzzle.givens.filter(Boolean).length; + const rightClues = right.puzzle.givens.filter(Boolean).length; + if (ranking === "fewest-givens" && leftClues !== rightClues) { + return leftClues - rightClues; + } + if (ranking === "most-givens" && leftClues !== rightClues) { + return rightClues - leftClues; + } + const scoreDifference = + (right.difficulty.score ?? -1) - (left.difficulty.score ?? -1); + if (scoreDifference !== 0) return scoreDifference; + if (leftClues !== rightClues) return leftClues - rightClues; + return String(left.seed).localeCompare(String(right.seed)); + }; +} + +/** Generates a deterministic bounded batch and ranks only verified results. */ +export function generateVariantBatch( + options: GenerateVariantBatchOptions = {}, +): GeneratedVariantBatch { + const batchSize = boundedInteger(options.batchSize, 4, 1, 12, "batchSize"); + const ranking = options.ranking ?? "difficulty"; + if ( + ranking !== "difficulty" && + ranking !== "fewest-givens" && + ranking !== "most-givens" + ) { + throw new RangeError(`Unsupported batch ranking: ${String(ranking)}.`); + } + const baseSeed = options.seed ?? "sudoku-tools"; + const entries: GeneratedVariantPuzzle[] = []; + const failures: BatchGenerationFailure[] = []; + for (let index = 0; index < batchSize; index += 1) { + const seed = `${String(baseSeed)}:batch:${String(index + 1)}`; + try { + entries.push(generateVariant({ ...options, seed })); + } catch (error) { + failures.push({ + seed, + message: error instanceof Error ? error.message : "Generation failed.", + }); + } + } + entries.sort(rankingComparator(ranking)); + const summaries = entries.map((entry, index): GeneratedPuzzleSummary => ({ + rank: index + 1, + seed: entry.seed, + families: entry.families, + clueCount: entry.puzzle.givens.filter(Boolean).length, + constraintCount: entry.generatedConstraintCount, + score: entry.difficulty.score, + level: entry.difficulty.level, + minimalityStatus: entry.minimality.status, + ...(entry.techniqueProfile === undefined + ? {} + : { profileStatus: entry.techniqueProfile.status }), + })); + return { + entries, + summaries, + failures, + requested: batchSize, + completed: entries.length, + truncated: failures.length > 0, + ranking, + baseSeed, + }; +} diff --git a/src/state/aidMemoire.ts b/src/state/aidMemoire.ts index 9981b4a..18e8a57 100644 --- a/src/state/aidMemoire.ts +++ b/src/state/aidMemoire.ts @@ -1,4 +1,5 @@ import { maskValues, symbolFor, type EntryMode } from "./session"; +import { colorMarkDescription } from "./uiPreferences"; export const AID_MEMOIRE_VERSION = 1 as const; export const MAX_AID_MEMOIRE_CELLS = 36; @@ -260,7 +261,11 @@ export function aidMemoireCellDescription( `centre marks ${center.map((value) => symbolFor(value, size)).join(", ")}`, ); } - if (cell.color) parts.push(`colour ${String(cell.color)}`); + if (cell.color) { + parts.push( + `colour ${String(cell.color)}: ${colorMarkDescription(cell.color)}`, + ); + } return parts.join(", "); } diff --git a/src/state/candidateMaintenance.ts b/src/state/candidateMaintenance.ts new file mode 100644 index 0000000..5e07e4b --- /dev/null +++ b/src/state/candidateMaintenance.ts @@ -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; +} diff --git a/src/state/playHistory.ts b/src/state/playHistory.ts index 86f3867..d5dbd13 100644 --- a/src/state/playHistory.ts +++ b/src/state/playHistory.ts @@ -4,6 +4,8 @@ import { type PlaySnapshot, } from "./session"; import { + aidMemoireFromPortable, + aidMemoireToPortable, cloneAidMemoire, createAidMemoire, type AidMemoireState, @@ -57,6 +59,286 @@ export interface HistoryTransition { export const MAIN_BRANCH_ID = "main"; export const MAX_GAMEPLAY_MOMENTS = 500; +export const MAX_GAMEPLAY_HISTORY_BYTES = 1_048_576; +const GAMEPLAY_HISTORY_SCHEMA = + "de.add-ideas.sudoku-tools.gameplay-history" as const; + +function historyRecord(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new TypeError(`${label} must be an object.`); + } + return value as Record; +} + +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 { diff --git a/src/state/uiPreferences.ts b/src/state/uiPreferences.ts new file mode 100644 index 0000000..8aae204 --- /dev/null +++ b/src/state/uiPreferences.ts @@ -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 | 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 | 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)}`; +} diff --git a/src/storage/library.ts b/src/storage/library.ts index ee40941..953c791 100644 --- a/src/storage/library.ts +++ b/src/storage/library.ts @@ -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(); + readonly #memoryAutosaves = new Map(); #database: Promise | undefined; #mode: ProjectLibraryMode; @@ -143,11 +184,14 @@ export class ProjectLibrary { this.#memory.set(record.id, cloneProjectRecord(record)); } - async list(): Promise { + async list( + query: ProjectLibraryQuery = {}, + ): Promise { 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 { + 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 { + 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 { + 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 { const summaries = await this.list(); const projects: SudokuProjectRecord[] = []; @@ -279,6 +400,51 @@ export class ProjectLibrary { }; } + async exportSelected(ids: readonly string[]): Promise { + 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 { + 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 { if ( typeof value !== "object" || diff --git a/src/storage/record.ts b/src/storage/record.ts index 13f956a..05ad0e9 100644 --- a/src/storage/record.ts +++ b/src/storage/record.ts @@ -8,6 +8,8 @@ import { normalizePortableAidMemoire, type PortableAidMemoire, } from "../state/aidMemoire"; +import { parseGameplayHistory } from "../state/playHistory"; +import { symbolFor } from "../state/session"; import { PROJECT_RECORD_SCHEMA, PROJECT_RECORD_VERSION, @@ -18,6 +20,8 @@ import { export const MAX_PROJECT_BYTES = MAX_DOCUMENT_BYTES * 2; export const MAX_PROJECT_ID_LENGTH = 128; export const MAX_PROJECT_TITLE_LENGTH = 500; +export const MAX_PROJECT_TAGS = 20; +export const MAX_PROJECT_TAG_LENGTH = 40; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -143,6 +147,26 @@ function progress(value: unknown, size: number): SudokuProgress | undefined { ); } } + let gameplayHistory: string | undefined; + if (value.gameplayHistory !== undefined) { + if (typeof value.gameplayHistory !== "string") { + return storageError( + "INVALID_PROJECT", + "Progress gameplayHistory must be serialized text.", + ); + } + try { + void parseGameplayHistory(value.gameplayHistory, size); + gameplayHistory = value.gameplayHistory; + } catch (error) { + return storageError( + "INVALID_PROJECT", + error instanceof Error + ? error.message + : "Progress gameplay history is invalid.", + ); + } + } return { version: 1, values, @@ -155,9 +179,57 @@ function progress(value: unknown, size: number): SudokuProgress | undefined { : { elapsedMs: value.elapsedMs as number }), ...(value.completed === undefined ? {} : { completed: value.completed }), ...(aidMemoire === undefined ? {} : { aidMemoire }), + ...(gameplayHistory === undefined ? {} : { gameplayHistory }), }; } +function projectTags(value: unknown): string[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value) || value.length > MAX_PROJECT_TAGS) { + return storageError( + "INVALID_PROJECT", + `Project tags must contain at most ${MAX_PROJECT_TAGS} entries.`, + ); + } + const tags = value.map((tag, index) => { + if ( + typeof tag !== "string" || + tag.trim().length === 0 || + tag.length > MAX_PROJECT_TAG_LENGTH + ) { + return storageError( + "INVALID_PROJECT", + `Project tag ${String(index + 1)} is empty or too long.`, + ); + } + return tag.trim(); + }); + return [...new Set(tags)].sort((a, b) => a.localeCompare(b)); +} + +function projectThumbnail( + value: unknown, + givens: readonly number[], + size: number, +) { + if (value === undefined) { + return givens + .map((digit) => (digit === 0 ? "." : symbolFor(digit, size))) + .join(""); + } + if ( + typeof value !== "string" || + [...value].length !== size * size || + !/^[.1-9A-G]+$/u.test(value) + ) { + return storageError( + "INVALID_PROJECT", + "Project thumbnail must be a safe row-major grid preview.", + ); + } + return value; +} + export function normalizeProjectRecord(value: unknown): SudokuProjectRecord { if (!isRecord(value)) return storageError("INVALID_PROJECT", "A project must be an object."); @@ -199,6 +271,12 @@ export function normalizeProjectRecord(value: unknown): SudokuProjectRecord { ); } const puzzle = normalizeSudokuDocument(value.puzzle); + const tags = projectTags(value.tags); + const thumbnail = projectThumbnail( + value.thumbnail, + puzzle.givens, + puzzle.size, + ); const normalized: SudokuProjectRecord = { schema: PROJECT_RECORD_SCHEMA, version: PROJECT_RECORD_VERSION, @@ -210,6 +288,8 @@ export function normalizeProjectRecord(value: unknown): SudokuProjectRecord { ...(value.progress === undefined ? {} : { progress: progress(value.progress, puzzle.size) }), + ...(tags === undefined ? {} : { tags }), + thumbnail, }; const bytes = new TextEncoder().encode(JSON.stringify(normalized)).byteLength; if (bytes > MAX_PROJECT_BYTES) { @@ -226,6 +306,7 @@ export interface NewProjectOptions { readonly title?: string; readonly now?: number; readonly progress?: SudokuProgress; + readonly tags?: readonly string[]; } function randomId(): string { @@ -252,6 +333,7 @@ export function createProjectRecord( updatedAt: now, puzzle, ...(options.progress === undefined ? {} : { progress: options.progress }), + ...(options.tags === undefined ? {} : { tags: options.tags }), }); } @@ -262,6 +344,7 @@ export function cloneProjectRecord( return { ...normalized, puzzle: cloneSudokuDocument(normalized.puzzle), + ...(normalized.tags === undefined ? {} : { tags: [...normalized.tags] }), ...(normalized.progress === undefined ? {} : { @@ -300,6 +383,11 @@ export function cloneProjectRecord( normalized.puzzle.size, ), }), + ...(normalized.progress.gameplayHistory === undefined + ? {} + : { + gameplayHistory: normalized.progress.gameplayHistory, + }), }, }), }; diff --git a/src/storage/types.ts b/src/storage/types.ts index 4ed0e6c..d9328b4 100644 --- a/src/storage/types.ts +++ b/src/storage/types.ts @@ -15,6 +15,8 @@ export interface SudokuProgress { readonly elapsedMs?: number; readonly completed?: boolean; readonly aidMemoire?: PortableAidMemoire; + /** Canonical, bounded replay/savepoint history JSON. */ + readonly gameplayHistory?: string; } export interface SudokuProjectRecord { @@ -26,6 +28,9 @@ export interface SudokuProjectRecord { readonly updatedAt: number; readonly puzzle: SudokuDocument; readonly progress?: SudokuProgress; + readonly tags?: readonly string[]; + /** Compact row-major symbols used for a script-free Library preview. */ + readonly thumbnail?: string; } export interface SudokuProjectSummary { @@ -35,6 +40,14 @@ export interface SudokuProjectSummary { readonly updatedAt: number; readonly size: number; readonly completed: boolean; + readonly tags: readonly string[]; + readonly thumbnail: string; +} + +export interface ProjectLibraryQuery { + readonly search?: string; + readonly tags?: readonly string[]; + readonly completed?: boolean; } export interface ProjectLibraryExport { diff --git a/src/styles.css b/src/styles.css index 08c3f66..81614b8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -571,6 +571,59 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { letter-spacing: 0.04em; } +.autosave-status { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.28rem 0.52rem; + border: 1px solid var(--toolbox-border); + border-radius: 999px; + background: var(--toolbox-surface); + color: var(--toolbox-muted); + white-space: nowrap; +} + +.autosave-status::before { + width: 0.48rem; + height: 0.48rem; + border-radius: 50%; + background: var(--toolbox-success, #2b8a64); + content: ""; +} + +.autosave-status--checking::before, +.autosave-status--saving::before { + background: var(--toolbox-accent); + box-shadow: 0 0 0 0.18rem + color-mix(in srgb, var(--toolbox-accent) 18%, transparent); +} + +.autosave-status--error::before { + background: var(--toolbox-danger, #b23a48); +} + +.recovery-callout { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.8rem 1.2rem; + padding: 0.8rem 0.95rem; + border: 1px solid + color-mix(in srgb, var(--toolbox-accent) 44%, var(--toolbox-border)); + border-radius: 0.78rem; + background: color-mix( + in srgb, + var(--toolbox-accent) 9%, + var(--toolbox-surface) + ); +} + +.recovery-callout p { + margin-top: 0.18rem; + color: var(--toolbox-muted); + font-size: 0.78rem; +} + .workbench-grid { min-width: 0; display: grid; @@ -639,6 +692,141 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { overflow: hidden; } +.board-viewport { + width: 100%; + min-width: 0; + display: grid; + gap: 0.55rem; +} + +.board-viewport__controls { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.4rem; +} + +.board-zoom-buttons { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.3rem; +} + +.board-viewport__controls button { + min-height: 2.1rem; + padding: 0.35rem 0.58rem; + font-size: 0.72rem; +} + +.board-viewport__controls button.is-active { + border-color: var(--toolbox-accent); + background: var(--toolbox-accent); + color: var(--toolbox-accent-contrast); +} + +.board-zoom-buttons output { + min-width: 3.3rem; + color: var(--toolbox-muted); + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 0.72rem; + font-variant-numeric: tabular-nums; + font-weight: 760; + text-align: center; +} + +.board-viewport__scroller { + width: 100%; + max-height: min(82dvh, 56rem); + overflow: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.board-viewport__scroller:focus-visible { + outline: 2px solid var(--toolbox-focus); + outline-offset: 2px; +} + +.board-viewport__canvas { + min-width: 100%; + margin-inline: auto; +} + +.board-viewport__canvas > .sudoku-board-frame { + width: 100%; + max-width: none; +} + +.board-viewport.is-pan-mode .board-viewport__scroller { + border: 2px dashed var(--toolbox-accent); + border-radius: 0.62rem; + background: color-mix( + in srgb, + var(--toolbox-accent-soft) 34%, + var(--toolbox-surface) + ); + box-shadow: 0 0 0 3px + color-mix(in srgb, var(--toolbox-accent) 12%, transparent); + cursor: grab; + touch-action: none; +} + +.board-viewport.is-pan-mode .board-viewport__scroller:active { + cursor: grabbing; +} + +.board-viewport.is-pan-mode .sudoku-board-frame { + opacity: 0.82; + pointer-events: none; +} + +.board-pan-notice { + margin: 0; + padding: 0.5rem 0.65rem; + border: 1px solid + color-mix(in srgb, var(--toolbox-accent) 52%, var(--toolbox-border)); + border-radius: 0.52rem; + background: var(--toolbox-accent-soft); + color: var(--toolbox-accent); + font-size: 0.74rem; + font-weight: 760; +} + +.mobile-number-pad { + position: sticky; + z-index: 12; + bottom: 0; + width: min(100%, 45rem); + margin-inline: auto; + padding: 0.65rem 0.7rem max(0.65rem, env(safe-area-inset-bottom)); + border: 1px solid + color-mix(in srgb, var(--toolbox-accent) 26%, var(--toolbox-border)); + border-radius: 0.78rem 0.78rem 0 0; + background: color-mix( + in srgb, + var(--toolbox-surface) 94%, + var(--toolbox-accent-soft) + ); + box-shadow: 0 -0.7rem 1.6rem rgb(20 30 60 / 14%); +} + +.accessibility-select { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(8.5rem, auto); + align-items: center; + gap: 0.65rem; + padding-block: 0.35rem; + color: var(--toolbox-text); + font-size: 0.8rem; + font-weight: 610; +} + +.accessibility-select select { + min-height: 2.35rem; +} + .board-surface.is-paused .sudoku-board-frame { opacity: 0.72; filter: saturate(0.6); @@ -1102,6 +1290,413 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { gap: 0.72rem; } +.guided-hint { + gap: 0.72rem; + padding: clamp(0.8rem, 1.45vw, 1rem); + border: 1px solid + color-mix(in srgb, var(--toolbox-accent) 28%, var(--toolbox-border)); + border-radius: 0.78rem; + background: color-mix( + in srgb, + var(--toolbox-accent-soft) 25%, + var(--toolbox-surface) + ); +} + +.guided-hint__heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.65rem; +} + +.guided-hint__heading h3 { + font-size: 1.02rem; +} + +.guided-hint__dismiss { + min-height: 2rem !important; + padding: 0.3rem 0.5rem !important; + border-color: transparent !important; + background: transparent !important; + box-shadow: none !important; + color: var(--toolbox-muted) !important; +} + +.guided-hint__intro { + font-size: 0.78rem; +} + +.guided-hint__status { + padding: 0.5rem 0.62rem; + font-size: 0.74rem; +} + +.guided-hint__progress { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.3rem; + margin: 0; + padding: 0; + list-style: none; +} + +.guided-hint__progress li { + min-width: 0; + padding: 0.36rem 0.22rem; + border: 1px solid var(--toolbox-border); + border-radius: 0.42rem; + background: var(--toolbox-surface); + color: var(--toolbox-muted); + font-size: 0.61rem; + font-weight: 720; + line-height: 1.2; + text-align: center; +} + +.guided-hint__progress li.is-revealed { + border-color: color-mix( + in srgb, + var(--toolbox-accent) 42%, + var(--toolbox-border) + ); + background: var(--toolbox-accent-soft); + color: var(--toolbox-accent); +} + +.guided-hint__progress li[aria-current="step"] { + box-shadow: inset 0 -2px 0 var(--toolbox-accent); +} + +.guided-hint__stage { + display: grid; + gap: 0.55rem; +} + +.guided-hint__stage > section { + display: grid; + gap: 0.25rem; + padding: 0.62rem; + border-left: 3px solid var(--toolbox-accent); + border-radius: 0.32rem 0.55rem 0.55rem 0.32rem; + background: var(--toolbox-surface); + font-size: 0.8rem; +} + +.guided-hint__effect-list { + display: grid; + gap: 0.28rem; + margin: 0; + padding-left: 1.15rem; + color: var(--toolbox-text); +} + +.guided-hint__tracking-note { + margin-top: 0.2rem; + font-size: 0.73rem; +} + +.guided-hint__actions .guided-hint__apply { + border-color: var(--toolbox-accent); + background: var(--toolbox-accent); + color: var(--toolbox-accent-contrast); +} + +.guided-hint__maintenance { + min-width: 0; + display: grid; + gap: 0.55rem; + margin: 0; + padding: 0.68rem; + border: 1px solid var(--toolbox-border); + border-radius: 0.58rem; +} + +.guided-hint__maintenance legend { + padding-inline: 0.25rem; + color: var(--toolbox-text); + font-size: 0.72rem; + font-weight: 760; +} + +.guided-hint__maintenance > p { + font-size: 0.72rem; +} + +.guided-hint__maintenance-actions button { + flex: 1 1 8rem; + min-height: 2.25rem; + padding: 0.38rem 0.52rem; + font-size: 0.7rem; +} + +/* Setter quality and bounded proof lab */ + +.setter-quality { + gap: 0.85rem; + padding-top: 0.25rem; + border-top: 1px solid var(--toolbox-border); +} + +.setter-quality :where(h3, h4, h5) { + margin: 0; + color: var(--toolbox-text); + line-height: 1.25; +} + +.setter-quality h4 { + font-size: 0.9rem; +} + +.setter-quality h5 { + font-size: 0.76rem; +} + +.setter-quality__controls .field-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.setter-quality__controls .field-grid label:last-child { + grid-column: 1 / -1; +} + +.setter-quality__actions button { + flex: 1 1 9rem; +} + +.setter-quality__results { + gap: 0.75rem; +} + +.setter-quality__solution { + display: grid; + gap: 0.55rem; +} + +.status-pill { + display: inline-flex; + align-items: center; + align-self: flex-start; + padding: 0.24rem 0.48rem; + border: 1px solid var(--toolbox-border); + border-radius: 999px; + background: var(--toolbox-surface); + color: var(--toolbox-muted); + font-size: 0.64rem; + font-weight: 780; + white-space: nowrap; +} + +.status-pill.status-unique, +.status-pill.status-localized { + border-color: color-mix( + in srgb, + var(--sudoku-success) 38%, + var(--toolbox-border) + ); + color: var(--sudoku-success); +} + +.status-pill.status-multiple, +.status-pill.status-unsatisfiable { + border-color: color-mix( + in srgb, + var(--toolbox-danger) 38%, + var(--toolbox-border) + ); + color: var(--toolbox-danger); +} + +.setter-quality__table-wrap { + max-width: 100%; + overflow: auto; + border: 1px solid var(--toolbox-border); + border-radius: 0.55rem; +} + +.setter-quality__table { + width: 100%; + min-width: 30rem; + border-collapse: collapse; + background: var(--toolbox-surface); + color: var(--toolbox-text); + font-size: 0.72rem; +} + +.setter-quality__table :where(th, td) { + padding: 0.48rem 0.55rem; + border-bottom: 1px solid var(--toolbox-border); + text-align: left; + vertical-align: top; +} + +.setter-quality__table tr:last-child :where(th, td) { + border-bottom: 0; +} + +.setter-quality__table thead th { + background: var(--toolbox-surface-soft); + color: var(--toolbox-muted); + font-size: 0.64rem; + letter-spacing: 0.035em; + text-transform: uppercase; +} + +.setter-quality__table button, +.setter-quality__item-list button { + min-height: 1.8rem !important; + padding: 0.2rem 0.32rem !important; + border-color: transparent !important; + background: transparent !important; + box-shadow: none !important; + color: var(--toolbox-accent) !important; + font-size: inherit; + text-align: left; +} + +.setter-quality__table tr.is-critical strong { + color: var(--sudoku-success); +} + +.setter-quality__table tr.is-redundant strong { + color: var(--toolbox-danger); +} + +.setter-quality__table tr.is-unknown strong { + color: var(--sudoku-warning); +} + +.setter-quality__assessments, +.setter-quality__contradiction-groups, +.setter-quality__item-group { + display: grid; + gap: 0.45rem; +} + +.setter-quality__counts { + font-size: 0.68rem; + text-align: right; +} + +.setter-quality__contradiction-groups { + grid-template-columns: repeat(3, minmax(0, 1fr)); + align-items: start; +} + +.setter-quality__item-group { + align-content: start; + padding: 0.55rem; + border: 1px solid var(--toolbox-border); + border-radius: 0.5rem; + background: var(--toolbox-surface); +} + +.setter-quality__item-list { + display: grid; + gap: 0.2rem; + margin: 0; + padding: 0; + list-style: none; +} + +.setter-quality__heatmap-grid { + width: min(100%, 28rem); + display: grid; + gap: 0.2rem; +} + +.setter-quality__heat-cell { + min-width: 0 !important; + min-height: 2.5rem !important; + display: grid !important; + gap: 0.04rem !important; + padding: 0.2rem !important; + border-radius: 0.35rem !important; +} + +.setter-quality__heat-cell span { + overflow: hidden; + font-size: 0.54rem; + text-overflow: ellipsis; +} + +.setter-quality__heat-cell strong { + font-size: 0.72rem; +} + +.setter-quality__heat-cell.is-critical { + border-color: color-mix( + in srgb, + var(--sudoku-success) 48%, + var(--toolbox-border) + ); + background: color-mix( + in srgb, + var(--sudoku-success) 16%, + var(--toolbox-surface) + ); +} + +.setter-quality__heat-cell.is-redundant { + border-color: color-mix( + in srgb, + var(--toolbox-danger) 42%, + var(--toolbox-border) + ); + background: color-mix( + in srgb, + var(--toolbox-danger) 13%, + var(--toolbox-surface) + ); +} + +.setter-quality__heat-cell.is-mixed { + border-color: color-mix( + in srgb, + var(--sudoku-warning) 44%, + var(--toolbox-border) + ); + background: color-mix( + in srgb, + var(--sudoku-warning) 14%, + var(--toolbox-surface) + ); +} + +.setter-quality__heat-cell.is-unknown { + border-style: dashed; + color: var(--toolbox-muted); +} + +.setter-quality__metric-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.4rem; +} + +.setter-quality__metric-list > div { + min-width: 0; + display: grid; + gap: 0.1rem; + padding: 0.48rem; + border: 1px solid var(--toolbox-border); + border-radius: 0.45rem; + background: var(--toolbox-surface); +} + +.setter-quality__metric-list dt { + color: var(--toolbox-muted); + font-size: 0.62rem; +} + +.setter-quality__metric-list dd { + margin: 0; + overflow-wrap: anywhere; + color: var(--toolbox-text); + font-size: 0.72rem; + font-weight: 720; +} + .panel-section.action-row { display: flex; } @@ -1270,6 +1865,41 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { ); } +.generator-family-picker { + grid-column: 1 / -1; + min-width: 0; + margin: 0; + padding: 0.65rem 0.7rem; + border: 1px solid var(--toolbox-border); + border-radius: 0.62rem; + background: color-mix(in srgb, var(--toolbox-surface) 84%, transparent); +} + +.generator-family-picker legend { + padding-inline: 0.25rem; + color: var(--toolbox-muted); + font-size: 0.74rem; + font-weight: 720; +} + +.generator-family-options { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr)); + gap: 0.45rem 0.7rem; +} + +.generator-family-options label { + display: flex; + align-items: center; + gap: 0.42rem; + color: var(--toolbox-text); + font-weight: 610; +} + +.generator-batch { + overflow: hidden; +} + .generator-description { padding: 0.55rem 0.65rem; border-left: 3px solid var(--toolbox-accent); @@ -1428,6 +2058,24 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { cursor: default; } +.sudoku-cell.is-fogged, +.sudoku-cell.is-fogged:disabled { + --cell-fill: color-mix( + in srgb, + var(--toolbox-text) 82%, + var(--toolbox-surface) + ); + opacity: 1; + background: repeating-linear-gradient( + 135deg, + color-mix(in srgb, var(--toolbox-text) 80%, var(--toolbox-surface)) 0 5px, + color-mix(in srgb, var(--toolbox-muted) 90%, var(--toolbox-surface)) 5px + 10px + ) !important; + color: transparent !important; + cursor: not-allowed; +} + .sudoku-cell[class*="has-color-"] { --cell-fill: color-mix( in srgb, @@ -1436,6 +2084,18 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { ); } +.cell-color-pattern, +.aid-memoire__cell[class*="has-color-"]::before { + position: absolute; + z-index: 2; + inset: 0; + background-image: var(--sudoku-mark-pattern); + background-size: var(--sudoku-mark-pattern-size, auto); + content: ""; + opacity: 0.52; + pointer-events: none; +} + .sudoku-cell.is-digit-highlighted { background: color-mix( in srgb, @@ -1452,6 +2112,68 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { ) !important; } +.sudoku-cell.is-quality-critical { + --cell-fill: color-mix( + in srgb, + var(--sudoku-success) 20%, + var(--toolbox-surface) + ); +} + +.sudoku-cell.is-quality-redundant { + --cell-fill: color-mix( + in srgb, + var(--toolbox-danger) 18%, + var(--toolbox-surface) + ); +} + +.sudoku-cell.is-quality-mixed { + --cell-fill: color-mix( + in srgb, + var(--sudoku-warning) 20%, + var(--toolbox-surface) + ); +} + +.sudoku-cell.is-quality-unknown { + --cell-fill: color-mix( + in srgb, + var(--toolbox-muted) 14%, + var(--toolbox-surface) + ); +} + +.sudoku-cell.is-hint-focus { + background: color-mix( + in srgb, + var(--toolbox-focus) 16%, + var(--cell-fill) + ) !important; + box-shadow: inset 0 0 0 max(1.5px, 0.25cqi) + color-mix(in srgb, var(--toolbox-focus) 72%, transparent) !important; +} + +.sudoku-cell.is-hint-placement { + background: color-mix( + in srgb, + var(--sudoku-success) 18%, + var(--cell-fill) + ) !important; + box-shadow: inset 0 0 0 max(2px, 0.3cqi) + color-mix(in srgb, var(--sudoku-success) 76%, transparent) !important; +} + +.sudoku-cell.is-hint-elimination { + background: color-mix( + in srgb, + var(--toolbox-danger) 12%, + var(--cell-fill) + ) !important; + box-shadow: inset 0 0 0 max(2px, 0.3cqi) + color-mix(in srgb, var(--toolbox-danger) 62%, transparent) !important; +} + .sudoku-cell.is-selected { background: color-mix( in srgb, @@ -1522,6 +2244,30 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { pointer-events: none; } +.hint-placement-preview { + position: relative; + z-index: 5; + color: var(--sudoku-success); + font-size: clamp(0.74rem, 4.2cqi, 2rem); + font-weight: 820; + line-height: 1; + pointer-events: none; +} + +.hint-elimination-preview { + position: absolute; + z-index: 5; + right: 7%; + bottom: 5%; + max-width: 86%; + overflow: hidden; + color: var(--toolbox-danger); + font-size: clamp(0.34rem, 1.28cqi, 0.62rem); + font-weight: 820; + line-height: 1; + pointer-events: none; +} + .cell-value { display: block; font-size: clamp(0.74rem, 4.2cqi, 2rem); @@ -1618,10 +2364,14 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { text-anchor: middle; } -.constraint-layer :where(path, polyline, line, circle, rect) { +.constraint-layer :where(path, polyline, polygon, line, circle, ellipse, rect) { vector-effect: non-scaling-stroke; } +.safe-visual-layer { + pointer-events: none; +} + .region-boundaries path { fill: none; stroke: var(--sudoku-line-strong); @@ -1637,6 +2387,15 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { opacity: 0.66; } +.constraint-disjoint-groups path { + fill: none; + stroke: color-mix(in srgb, var(--toolbox-accent) 48%, transparent); + stroke-dasharray: 1.2 3; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: clamp(1px, 0.16cqi, 1.6px); +} + .constraint-cage path { fill: none; stroke: color-mix(in srgb, var(--toolbox-text) 76%, var(--toolbox-muted)); @@ -1670,6 +2429,143 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { stroke-width: clamp(1px, 0.15cqi, 1.5px); } +.constraint-between-line polyline { + fill: none; + stroke: color-mix(in srgb, var(--toolbox-text) 70%, var(--toolbox-muted)); + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: clamp(2.2px, 0.36cqi, 3.5px); +} + +.constraint-between-line circle { + fill: color-mix(in srgb, var(--toolbox-surface) 88%, transparent); + stroke: color-mix(in srgb, var(--toolbox-text) 72%, var(--toolbox-muted)); + stroke-width: clamp(1.7px, 0.27cqi, 2.7px); +} + +.constraint-german-whisper polyline { + fill: none; + stroke: color-mix(in srgb, #36a66a 72%, var(--toolbox-surface)); + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: clamp(7px, 1.55cqi, 13px); +} + +.constraint-region-sum-line polyline { + fill: none; + stroke: color-mix(in srgb, var(--sudoku-info) 72%, var(--toolbox-accent)); + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: clamp(3px, 0.58cqi, 5px); +} + +.constraint-region-sum-line .region-sum-divider { + fill: var(--toolbox-surface); + stroke: color-mix(in srgb, var(--sudoku-info) 76%, var(--toolbox-text)); + stroke-width: clamp(1.2px, 0.2cqi, 2px); +} + +.constraint-modular-line .modular-line-path { + fill: none; + stroke: color-mix(in srgb, #7c4db8 76%, var(--toolbox-text)); + stroke-dasharray: 7 2.4; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: clamp(3px, 0.56cqi, 5px); +} + +.constraint-modular-line .modular-line-node { + fill: var(--toolbox-surface); + stroke: color-mix(in srgb, #7c4db8 84%, var(--toolbox-text)); + stroke-width: clamp(1px, 0.16cqi, 1.7px); +} + +.constraint-entropic-line .entropic-line-underlay { + fill: none; + stroke: color-mix(in srgb, #b56e39 38%, var(--toolbox-surface)); + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: clamp(9px, 1.85cqi, 16px); +} + +.constraint-entropic-line .entropic-line-path { + fill: none; + stroke: color-mix(in srgb, #b05b25 82%, var(--toolbox-text)); + stroke-dasharray: 1.4 3.2; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: clamp(2.5px, 0.43cqi, 4px); +} + +.constraint-zipper-line .zipper-line-path { + fill: none; + stroke: color-mix(in srgb, #237b86 78%, var(--toolbox-text)); + stroke-dasharray: 5 1.8 1 1.8; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: clamp(4px, 0.72cqi, 6px); +} + +.constraint-zipper-line .zipper-line-centre { + fill: color-mix(in srgb, var(--toolbox-surface) 88%, #237b86); + stroke: color-mix(in srgb, #237b86 84%, var(--toolbox-text)); + stroke-width: clamp(1.4px, 0.22cqi, 2.2px); +} + +.constraint-double-arrow polyline { + fill: none; + stroke: color-mix(in srgb, #a34755 76%, var(--toolbox-text)); + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: clamp(2.3px, 0.38cqi, 3.7px); +} + +.constraint-double-arrow circle { + fill: color-mix(in srgb, var(--toolbox-surface) 88%, #a34755); + stroke: color-mix(in srgb, #a34755 78%, var(--toolbox-text)); + stroke-width: clamp(1.5px, 0.25cqi, 2.5px); +} + +.constraint-double-arrow .double-arrow-chevron { + fill: none; + stroke: color-mix(in srgb, #a34755 88%, var(--toolbox-text)); + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: clamp(1.8px, 0.29cqi, 2.9px); +} + +.constraint-indexer :where(circle, rect) { + fill: color-mix(in srgb, var(--toolbox-surface) 90%, var(--sudoku-info)); + stroke: color-mix(in srgb, var(--sudoku-info) 72%, var(--toolbox-text)); + stroke-width: clamp(1.4px, 0.23cqi, 2.3px); +} + +.constraint-indexer--column rect { + stroke-dasharray: 3 1.5; +} + +.constraint-indexer--box rect { + fill: color-mix(in srgb, var(--sudoku-warning) 15%, var(--toolbox-surface)); + stroke: color-mix(in srgb, var(--sudoku-warning) 78%, var(--toolbox-text)); +} + +.constraint-indexer text { + dominant-baseline: central; + fill: var(--toolbox-text); + font-family: var(--toolbox-font); + font-size: 0.18px; + font-weight: 900; + paint-order: stroke; + stroke: var(--toolbox-surface); + stroke-width: 0.025px; + text-anchor: middle; +} + +.fog-constraint-mask rect { + fill: color-mix(in srgb, var(--toolbox-text) 82%, var(--toolbox-surface)); + stroke: none; +} + .constraint-arrow polyline { fill: none; stroke: color-mix(in srgb, var(--toolbox-text) 62%, var(--toolbox-muted)); @@ -1757,19 +2653,93 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { stroke-width: clamp(0.8px, 0.12cqi, 1.2px); } -.constraint-maximum circle { +.constraint-maximum circle, +.constraint-minimum circle { fill: color-mix(in srgb, var(--toolbox-muted) 38%, var(--toolbox-surface)); stroke: color-mix(in srgb, var(--toolbox-text) 62%, var(--toolbox-muted)); stroke-width: clamp(1.2px, 0.2cqi, 2px); } -.constraint-maximum path { +.constraint-maximum path, +.constraint-minimum path { fill: none; stroke: color-mix(in srgb, var(--toolbox-text) 84%, var(--toolbox-accent)); + stroke-linecap: round; stroke-linejoin: round; stroke-width: clamp(1.4px, 0.23cqi, 2.3px); } +.constraint-minimum circle { + fill: color-mix(in srgb, var(--sudoku-info) 14%, var(--toolbox-surface)); + stroke: color-mix(in srgb, var(--sudoku-info) 56%, var(--toolbox-text)); +} + +.constraint-odd circle, +.constraint-even rect { + fill: color-mix(in srgb, var(--toolbox-muted) 22%, var(--toolbox-surface)); + stroke: color-mix(in srgb, var(--toolbox-text) 58%, var(--toolbox-muted)); + stroke-width: clamp(1.2px, 0.2cqi, 2px); +} + +.constraint-odd circle { + fill: color-mix(in srgb, var(--sudoku-warning) 13%, var(--toolbox-surface)); +} + +.constraint-even rect { + fill: color-mix(in srgb, var(--sudoku-info) 12%, var(--toolbox-surface)); +} + +.constraint-clone .clone-cell-fill { + fill: color-mix(in srgb, #9668d5 14%, transparent); + stroke: none; +} + +.constraint-clone .clone-boundary { + fill: none; + stroke: color-mix(in srgb, #8652c5 72%, var(--toolbox-text)); + stroke-dasharray: 5 2.5; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: clamp(1.5px, 0.25cqi, 2.5px); +} + +.constraint-clone .clone-link { + stroke: color-mix(in srgb, #8652c5 58%, var(--toolbox-muted)); + stroke-dasharray: 2.5 3; + stroke-width: clamp(1px, 0.16cqi, 1.7px); +} + +.constraint-clone .clone-label, +.constraint-extra-region .extra-region-label { + dominant-baseline: central; + fill: color-mix(in srgb, #7441b5 80%, var(--toolbox-text)); + font-family: var(--toolbox-font); + font-size: 0.2px; + font-weight: 900; + paint-order: stroke; + stroke: var(--toolbox-surface); + stroke-width: 0.045px; + text-anchor: middle; +} + +.constraint-extra-region .extra-region-cell-fill { + fill: color-mix(in srgb, var(--sudoku-warning) 10%, transparent); + stroke: none; +} + +.constraint-extra-region path { + fill: none; + stroke: color-mix(in srgb, var(--sudoku-warning) 74%, var(--toolbox-text)); + stroke-dasharray: 1.5 2.5; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: clamp(1.6px, 0.27cqi, 2.7px); +} + +.constraint-extra-region .extra-region-label { + fill: color-mix(in srgb, var(--sudoku-warning) 82%, var(--toolbox-text)); +} + .constraint-quadruple circle { fill: color-mix(in srgb, var(--toolbox-surface) 94%, var(--toolbox-accent)); stroke: color-mix(in srgb, var(--toolbox-text) 80%, var(--toolbox-muted)); @@ -1814,6 +2784,23 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { fill: color-mix(in srgb, var(--sudoku-info) 10%, var(--toolbox-surface)); } +.constraint-sandwich rect { + fill: color-mix(in srgb, var(--sudoku-warning) 11%, var(--toolbox-surface)); +} + +.constraint-little-killer rect { + fill: color-mix(in srgb, var(--toolbox-accent) 10%, var(--toolbox-surface)); +} + +.constraint-little-killer .little-killer-arrow, +.constraint-little-killer .little-killer-arrow-tip { + fill: none; + stroke: color-mix(in srgb, var(--toolbox-accent) 76%, var(--toolbox-text)); + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: clamp(1.8px, 0.29cqi, 2.8px); +} + .constraint-layer .is-negated :where(path, polyline, line, circle, rect), .constraint-layer :where(path, polyline, line, circle, rect).is-negated { stroke: var(--toolbox-danger) !important; @@ -1899,7 +2886,9 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { display: block; border: 2px solid color-mix(in srgb, var(--sudoku-mark-color) 75%, white); border-radius: 50%; - background: var(--sudoku-mark-color); + background-color: var(--sudoku-mark-color); + background-image: var(--sudoku-mark-pattern); + background-size: var(--sudoku-mark-pattern-size, auto); box-shadow: 0 0 0 1px color-mix(in srgb, var(--sudoku-mark-color) 72%, black), inset 0 0.18rem 0.28rem rgb(255 255 255 / 22%); @@ -1908,41 +2897,101 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { .color-1, .has-color-1 { --sudoku-mark-color: #e56f65; + --sudoku-mark-pattern: repeating-linear-gradient( + 45deg, + transparent 0 5px, + color-mix(in srgb, var(--sudoku-mark-color) 42%, var(--toolbox-text)) 5px + 7px + ); } .color-2, .has-color-2 { --sudoku-mark-color: #eca73f; + --sudoku-mark-pattern: repeating-linear-gradient( + -45deg, + transparent 0 5px, + color-mix(in srgb, var(--sudoku-mark-color) 42%, var(--toolbox-text)) 5px + 7px + ); } .color-3, .has-color-3 { --sudoku-mark-color: #e0c84f; + --sudoku-mark-pattern: radial-gradient( + circle, + color-mix(in srgb, var(--sudoku-mark-color) 36%, var(--toolbox-text)) 0 + 1.5px, + transparent 1.7px + ); + --sudoku-mark-pattern-size: 8px 8px; } .color-4, .has-color-4 { --sudoku-mark-color: #62b77a; + --sudoku-mark-pattern: + repeating-linear-gradient( + 45deg, + transparent 0 6px, + color-mix(in srgb, var(--sudoku-mark-color) 38%, var(--toolbox-text)) 6px + 7.5px + ), + repeating-linear-gradient( + -45deg, + transparent 0 6px, + color-mix(in srgb, var(--sudoku-mark-color) 38%, var(--toolbox-text)) 6px + 7.5px + ); } .color-5, .has-color-5 { --sudoku-mark-color: #4bb8b4; + --sudoku-mark-pattern: repeating-linear-gradient( + 0deg, + transparent 0 5px, + color-mix(in srgb, var(--sudoku-mark-color) 38%, var(--toolbox-text)) 5px + 7px + ); } .color-6, .has-color-6 { --sudoku-mark-color: #5d93dd; + --sudoku-mark-pattern: repeating-linear-gradient( + 90deg, + transparent 0 5px, + color-mix(in srgb, var(--sudoku-mark-color) 38%, var(--toolbox-text)) 5px + 7px + ); } .color-7, .has-color-7 { --sudoku-mark-color: #9a7cda; + --sudoku-mark-pattern: conic-gradient( + from 90deg, + color-mix(in srgb, var(--sudoku-mark-color) 35%, var(--toolbox-text)) 25%, + transparent 0 50%, + color-mix(in srgb, var(--sudoku-mark-color) 35%, var(--toolbox-text)) 0 75%, + transparent 0 + ); + --sudoku-mark-pattern-size: 8px 8px; } .color-8, .has-color-8 { --sudoku-mark-color: #ce78b0; + --sudoku-mark-pattern: radial-gradient( + circle, + transparent 0 2px, + color-mix(in srgb, var(--sudoku-mark-color) 38%, var(--toolbox-text)) 2px + 3.5px, + transparent 3.7px + ); + --sudoku-mark-pattern-size: 11px 11px; } /* Setter, solver, and helper result surfaces */ @@ -2003,6 +3052,36 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); } +.pack-constraint-controls { + padding: 0.72rem; + border: 1px solid + color-mix(in srgb, var(--toolbox-accent) 22%, var(--toolbox-border)); + border-radius: 0.68rem; + background: color-mix( + in srgb, + var(--toolbox-accent-soft) 22%, + var(--toolbox-surface) + ); +} + +.pack-constraint-controls .inline-fields { + grid-template-columns: minmax(10rem, 15rem); +} + +.pack-constraint-controls .compact-field { + width: 100%; +} + +.pack-three-controls .inline-fields { + grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); +} + +.pack-three-requirements, +.fog-setter-reason { + min-height: 1.4em; + margin: 0; +} + .subtabs { grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); } @@ -2617,6 +3696,35 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { margin-bottom: 1rem; } +.library-filters { + display: grid; + grid-template-columns: minmax(12rem, 2fr) repeat(2, minmax(9rem, 1fr)); + gap: 0.65rem; + margin-bottom: 0.8rem; + padding: 0.7rem; + border: 1px solid var(--toolbox-border); + border-radius: 0.72rem; + background: var(--toolbox-surface-soft); +} + +.library-filters label { + display: grid; + gap: 0.28rem; + color: var(--toolbox-muted); + font-size: 0.72rem; + font-weight: 680; +} + +.library-selection-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.55rem 1rem; + margin-bottom: 0.7rem; + color: var(--toolbox-muted); + font-size: 0.76rem; +} + .library-list { display: grid; gap: 0.45rem; @@ -2625,7 +3733,7 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { .library-list li { min-width: 0; display: grid; - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 0.5rem; padding: 0.38rem; @@ -2634,17 +3742,55 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { background: var(--toolbox-surface); } +.library-selector { + display: grid; + place-items: center; + padding: 0.35rem; +} + .library-open { min-width: 0; display: grid !important; + grid-template-columns: 3.25rem minmax(0, 1fr); + align-items: center !important; justify-items: start !important; - gap: 0.18rem !important; + gap: 0.7rem !important; border-color: transparent !important; background: transparent !important; box-shadow: none !important; text-align: left !important; } +.library-thumbnail { + width: 3.25rem; + aspect-ratio: 1; + display: grid; + overflow: hidden; + border: 1px solid var(--toolbox-border-strong, var(--toolbox-border)); + border-radius: 0.24rem; + background: var(--toolbox-surface); +} + +.library-thumbnail > span { + min-width: 0; + display: grid; + place-items: center; + border: 0 solid color-mix(in srgb, var(--toolbox-border) 60%, transparent); + border-right-width: 1px; + border-bottom-width: 1px; + color: var(--toolbox-text); + font-size: inherit; + font-weight: 720; + line-height: 1; +} + +.library-copy { + min-width: 0; + display: grid; + justify-items: start; + gap: 0.18rem; +} + .library-open strong { max-width: 100%; overflow: hidden; @@ -2659,6 +3805,40 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { font-weight: 520; } +.library-item-actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 0.35rem; +} + +.library-tags { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.25rem; +} + +.tag-chip { + min-height: 1.7rem !important; + padding: 0.18rem 0.45rem !important; + border-radius: 999px !important; + color: var(--toolbox-muted) !important; + font-size: 0.67rem !important; +} + +.library-tag-editor { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.35rem; +} + +.library-tag-editor input { + width: min(14rem, 100%); +} + .empty-state { display: grid; justify-items: center; @@ -2812,7 +3992,9 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { @media (max-width: 48rem) { .toolbox-shell__main { - padding-inline: 0.75rem; + padding-right: 0.75rem; + padding-bottom: max(0.75rem, env(safe-area-inset-bottom)); + padding-left: 0.75rem; } .workbench-hero { @@ -2846,6 +4028,23 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { padding: 0.75rem; } + .board-viewport__controls button { + min-width: 2.75rem; + min-height: 2.75rem; + } + + .board-viewport__scroller { + max-height: min(72dvh, 48rem); + } + + .mobile-number-pad .number-pad { + gap: 0.45rem; + } + + .mobile-number-pad .digit-pad button { + min-height: 2.75rem; + } + .subtabs, .import-export-grid, .help-grid, @@ -2872,6 +4071,16 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { .library-toolbar { flex-direction: column; } + + .recovery-callout, + .library-selection-toolbar { + align-items: stretch; + flex-direction: column; + } + + .library-filters { + grid-template-columns: minmax(0, 1fr); + } } @media (max-width: 38rem) { @@ -2881,10 +4090,17 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { .field-grid, .helper-controls, - .candidate-lab__controls { + .candidate-lab__controls, + .setter-quality__controls .field-grid, + .setter-quality__contradiction-groups, + .setter-quality__metric-list { grid-template-columns: minmax(0, 1fr); } + .setter-quality__controls .field-grid label:last-child { + grid-column: auto; + } + .section-heading { align-items: stretch; flex-direction: column; @@ -2894,6 +4110,10 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { width: 100%; } + .accessibility-select { + grid-template-columns: minmax(0, 1fr); + } + .panel-section.action-row, .action-row, .hero-actions { @@ -2925,8 +4145,17 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { grid-template-columns: minmax(0, 1fr); } - .library-list li > .text-button { - justify-self: end; + .library-selector { + justify-content: start; + } + + .library-item-actions, + .library-tags { + justify-content: flex-start; + } + + .library-open { + width: 100%; } } @@ -3039,11 +4268,22 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { outline-offset: -4px; } - .constraint-layer :where(path, polyline, line, circle), + .constraint-layer + :where(path, polyline, polygon, line, circle, ellipse, rect), .candidate-link-layer :where(line, circle), .region-boundaries path { stroke: CanvasText; } + + .safe-visual-layer text { + fill: CanvasText; + } + + .sudoku-cell.is-fogged, + .fog-constraint-mask rect { + background: CanvasText !important; + fill: CanvasText; + } } /* A print is a clean puzzle sheet, not a snapshot of the controls. */ @@ -3075,6 +4315,9 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { .digit-completion, .side-panel, .number-pad, + .mobile-number-pad, + .board-viewport__controls, + .board-pan-notice, .paused-cover, .feedback-callout, .status-line, @@ -3121,7 +4364,18 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { box-shadow: none !important; } - .sudoku-board-frame { + .board-viewport__scroller { + max-height: none; + overflow: visible; + } + + .board-viewport__canvas { + width: 100% !important; + max-width: none !important; + } + + .sudoku-board-frame, + .board-viewport__canvas > .sudoku-board-frame { --toolbox-surface: #fff; --toolbox-text: #000; --toolbox-muted: #444; @@ -3139,7 +4393,8 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { print-color-adjust: exact; } - .sudoku-board-frame.has-outside-clues { + .sudoku-board-frame.has-outside-clues, + .board-viewport__canvas > .sudoku-board-frame.has-outside-clues { --board-pad: 0.85cm; width: min(100%, 19.7cm); } @@ -3158,6 +4413,10 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { display: none !important; } + .cell-color-pattern { + display: none !important; + } + .board-meta, .rule-card { width: min(100%, 18cm); diff --git a/src/toolbox/manifest.source.json b/src/toolbox/manifest.source.json index 189b401..754ea75 100644 --- a/src/toolbox/manifest.source.json +++ b/src/toolbox/manifest.source.json @@ -3,7 +3,7 @@ "schemaVersion": 1, "id": "de.add-ideas.sudoku-tools", "name": "Sudoku Tools", - "version": "0.1.0", + "version": "0.2.0", "description": "Set, play, solve and analyse Sudoku puzzles locally in the browser.", "entry": "./", "icon": "./favicon.svg", diff --git a/src/version.ts b/src/version.ts index 162ff34..da2384c 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const APPLICATION_VERSION = "0.1.0"; +export const APPLICATION_VERSION = "0.2.0"; diff --git a/src/workers/protocol.ts b/src/workers/protocol.ts index 2259b83..6d52ed0 100644 --- a/src/workers/protocol.ts +++ b/src/workers/protocol.ts @@ -6,12 +6,16 @@ import type { DifficultyOptions, GenerateClassicOptions, GeneratedVariantPuzzle, + GeneratedVariantBatch, + GenerateVariantBatchOptions, GenerateVariantOptions, KillerCombinationOptions, KillerCombinationResult, LogicalSolveOptions, LogicalSolveResult, MinimizeOptions, + PuzzleQualityAnalysis, + PuzzleQualityOptions, } from "../solver"; import type { NormalizedPuzzle, ValidationIssue } from "../domain"; @@ -31,11 +35,20 @@ export type SolverWorkerOperation = readonly kind: "generate-variant"; readonly options?: GenerateVariantOptions; } + | { + readonly kind: "generate-batch"; + readonly options?: GenerateVariantBatchOptions; + } | { readonly kind: "difficulty"; readonly puzzle: PuzzleDefinition; readonly options?: DifficultyOptions; } + | { + readonly kind: "quality"; + readonly puzzle: PuzzleDefinition; + readonly options?: PuzzleQualityOptions; + } | { readonly kind: "minimize"; readonly puzzle: PuzzleDefinition; @@ -56,7 +69,9 @@ export type SolverWorkerValue = | LogicalSolveResult | NormalizedPuzzle | GeneratedVariantPuzzle + | GeneratedVariantBatch | DifficultyAssessment + | PuzzleQualityAnalysis | KillerCombinationResult; export interface SolverWorkerError { diff --git a/src/workers/solver.worker.ts b/src/workers/solver.worker.ts index 92fd7b8..b12ab05 100644 --- a/src/workers/solver.worker.ts +++ b/src/workers/solver.worker.ts @@ -2,9 +2,11 @@ import { PuzzleValidationError } from "../domain"; import { + analyzePuzzleQuality, evaluateDifficulty, generateClassic, generateVariant, + generateVariantBatch, killerDigitCombinations, minimizePuzzle, solveExact, @@ -29,8 +31,12 @@ function run(request: SolverWorkerRequest): SolverWorkerValue { return generateClassic(operation.options); case "generate-variant": return generateVariant(operation.options); + case "generate-batch": + return generateVariantBatch(operation.options); case "difficulty": return evaluateDifficulty(operation.puzzle, operation.options); + case "quality": + return analyzePuzzleQuality(operation.puzzle, operation.options); case "minimize": return minimizePuzzle(operation.puzzle, operation.options); case "killer-combinations": diff --git a/tests/browser/workbench.spec.ts b/tests/browser/workbench.spec.ts index 60d6211..fa1c933 100644 --- a/tests/browser/workbench.spec.ts +++ b/tests/browser/workbench.spec.ts @@ -248,3 +248,129 @@ test("keeps navigation, scratch work, branches and analysis tools local", async expect(runtimeErrors).toEqual([]); }); + +test("migrates the local database and recovers autosaved history", async ({ + page, +}) => { + await page.goto("/deep/nested/sudoku/favicon.svg"); + await page.evaluate(async () => { + await new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase("sudoku-tools"); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + await new Promise((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((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); + } +}); diff --git a/tests/components/boardViewport.test.tsx b/tests/components/boardViewport.test.tsx new file mode 100644 index 0000000..864a785 --- /dev/null +++ b/tests/components/boardViewport.test.tsx @@ -0,0 +1,72 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { BoardViewport } from "../../src/components/BoardViewport"; +import { BOARD_SCALE_STORAGE_KEY } from "../../src/state/uiPreferences"; + +describe("BoardViewport", () => { + beforeEach(() => localStorage.clear()); + + it("zooms, fits and restores the persisted scale", async () => { + const user = userEvent.setup(); + const { unmount } = render( + +
Board
+
, + ); + + expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("100%"); + await user.click(screen.getByRole("button", { name: "Zoom board in" })); + expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("125%"); + expect(localStorage.getItem(BOARD_SCALE_STORAGE_KEY)).toBe("1.25"); + unmount(); + + render( + +
Board
+
, + ); + expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("125%"); + await user.click(screen.getByRole("button", { name: "Fit board" })); + expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("100%"); + }); + + it("offers explicit pan mode and board-scoped zoom shortcuts", async () => { + const user = userEvent.setup(); + const onCellKeyDown = vi.fn(); + render( + + + , + ); + + 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(); + }); +}); diff --git a/tests/components/constraintEditor.test.tsx b/tests/components/constraintEditor.test.tsx new file mode 100644 index 0000000..52577f6 --- /dev/null +++ b/tests/components/constraintEditor.test.tsx @@ -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 ( + <> + + {JSON.stringify(puzzle)} + + ); +} + +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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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( + , + ); + + 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(); + + expect( + screen.getByRole("button", { name: "Entropic line" }), + ).toBeDisabled(); + unmount(); + + render( + , + ); + + 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(); + + 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(); + 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( + , + ); + 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([]); + }); +}); diff --git a/tests/components/generatorWorkspace.test.tsx b/tests/components/generatorWorkspace.test.tsx index e585fe5..8e1eef3 100644 --- a/tests/components/generatorWorkspace.test.tsx +++ b/tests/components/generatorWorkspace.test.tsx @@ -2,6 +2,11 @@ import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { GeneratorWorkspace } from "../../src/components/GeneratorWorkspace"; +import { classicRegions } from "../../src/domain"; +import type { + GeneratedVariantBatch, + GeneratedVariantPuzzle, +} from "../../src/solver"; describe("Sudoku generator workspace", () => { it("offers only reliable sizes and submits an explicit local recipe", async () => { @@ -97,4 +102,158 @@ describe("Sudoku generator workspace", () => { }), ).toBeInTheDocument(); }); + + it("submits mixed families, density, minimality and a full profile as a batch", async () => { + const user = userEvent.setup(); + const onGenerateBatch = vi.fn(); + render( + , + ); + + 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( + , + ); + + expect(screen.getByText("1 of 1 generated")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Use" })); + expect(onSelectGenerated).toHaveBeenCalledWith(generation); + }); }); diff --git a/tests/components/guidedHint.test.tsx b/tests/components/guidedHint.test.tsx new file mode 100644 index 0000000..2143055 --- /dev/null +++ b/tests/components/guidedHint.test.tsx @@ -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 { + 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(); + + 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(); + expect(screen.getByText("Naked Pair")).toBeInTheDocument(); + expect(screen.queryByText(step.explanation)).not.toBeInTheDocument(); + expect(screen.queryByText("Place 4 in r2c1.")).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByText(step.explanation)).toBeInTheDocument(); + expect(screen.queryByText("Place 4 in r2c1.")).not.toBeInTheDocument(); + + rerender(); + 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( + , + ); + + 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(); + 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( + , + ); + + 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); + }); +}); diff --git a/tests/components/importExportDialog.test.tsx b/tests/components/importExportDialog.test.tsx index 1e620eb..8977ab2 100644 --- a/tests/components/importExportDialog.test.tsx +++ b/tests/components/importExportDialog.test.tsx @@ -4,7 +4,10 @@ import { describe, expect, it, vi } from "vitest"; import { ImportExportDialog } from "../../src/components/ImportExportDialog"; import { createEmptyPuzzle } from "../../src/domain"; import type { PuzzleDefinition } from "../../src/domain/types"; -import { fromDomainPuzzle } from "../../src/formats"; +import { + fromDomainPuzzle, + type PreservedSudokuDocumentExtras, +} from "../../src/formats"; import type { PortableAidMemoire } from "../../src/state/aidMemoire"; import { createSession } from "../../src/state/session"; @@ -40,6 +43,7 @@ function renderDialog( options: { readonly puzzle?: PuzzleDefinition; readonly aidMemoire?: PortableAidMemoire; + readonly preservedExtras?: PreservedSudokuDocumentExtras; } = {}, ) { const puzzle = options.puzzle ?? createEmptyPuzzle(4); @@ -51,6 +55,7 @@ function renderDialog( puzzle={puzzle} session={createSession(puzzle.givens)} aidMemoire={options.aidMemoire} + preservedExtras={options.preservedExtras} onClose={onClose} onImport={onImport} />, @@ -79,11 +84,14 @@ describe("ImportExportDialog interoperability", () => { expect(onImport).toHaveBeenCalledWith( expect.objectContaining({ size: 4, givens: expect.any(Array) }), expect.any(Object), + expect.objectContaining({ + source: { format: "sudokupad", id: "synthetic-local-test" }, + }), ); expect(onClose).toHaveBeenCalledOnce(); }); - it("reports unsupported constructs and never fetches short IDs", async () => { + it("previews safe visuals and never fetches short IDs", async () => { const user = userEvent.setup(); const fetchSpy = vi.fn(); vi.stubGlobal("fetch", fetchSpy); @@ -91,12 +99,35 @@ describe("ImportExportDialog interoperability", () => { const input = screen.getByPlaceholderText(/Paste 81 characters/u); fireEvent.change(input, { - target: { value: localScl({ overlays: [{ text: "visual only" }] }) }, + target: { + value: localScl({ + overlays: [ + { + center: [0.5, 0.5], + width: 1, + height: 1, + text: "visual only", + }, + ], + }), + }, }); await user.click( screen.getByRole("button", { name: "Check compatibility" }), ); - expect(await screen.findByText(/visual overlays/u)).toBeInTheDocument(); + expect( + await screen.findByRole("region", { name: "Import mapping preview" }), + ).toBeInTheDocument(); + expect(screen.getByText("Preserved visuals")).toBeInTheDocument(); + expect(screen.getByText("Text overlay: 1")).toBeInTheDocument(); + + fireEvent.change(input, { + target: { value: localScl({ overlays: [{ text: "missing center" }] }) }, + }); + await user.click( + screen.getByRole("button", { name: "Check compatibility" }), + ); + expect(await screen.findByText(/center.*point/u)).toBeInTheDocument(); fireEvent.change(input, { target: { value: "https://sudokupad.app/serverOnly42" }, @@ -164,6 +195,47 @@ describe("ImportExportDialog interoperability", () => { expect(onImport).toHaveBeenCalledWith( expect.objectContaining({ size: 4 }), expect.objectContaining({ aidMemoire: TEST_AID_MEMOIRE }), + { source: { format: "sudoku-tools" } }, + ); + }); + + it("includes preserved source extras in SudokuPad exports", async () => { + const user = userEvent.setup(); + let downloaded: Blob | undefined; + vi.mocked(URL.createObjectURL).mockImplementation((value) => { + downloaded = value as Blob; + return "blob:scl-export-test"; + }); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + renderDialog({ + preservedExtras: { + source: { format: "sudokupad", id: "kept-source" }, + metadata: { edition: "nightly" }, + visuals: [ + { + type: "text", + layer: "overlay", + position: { kind: "cell", cell: 0 }, + text: "kept visual", + style: { fill: "#123456" }, + }, + ], + }, + }); + + await user.click( + screen.getByRole("button", { name: "Download SudokuPad JSON" }), + ); + + const exported = JSON.parse((await downloaded?.text()) ?? "{}") as { + metadata?: Record; + overlays?: unknown[]; + }; + expect(exported.metadata?.edition).toBe("nightly"); + expect(exported.overlays).toEqual( + expect.arrayContaining([ + expect.objectContaining({ text: "kept visual" }), + ]), ); }); diff --git a/tests/components/libraryDialog.test.tsx b/tests/components/libraryDialog.test.tsx new file mode 100644 index 0000000..49eb219 --- /dev/null +++ b/tests/components/libraryDialog.test.tsx @@ -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 = {}) { + 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(); + 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", + ]); + }); +}); diff --git a/tests/components/numberPad.test.tsx b/tests/components/numberPad.test.tsx new file mode 100644 index 0000000..272cdb4 --- /dev/null +++ b/tests/components/numberPad.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { NumberPad } from "../../src/components/NumberPad"; + +describe("NumberPad colour accessibility", () => { + it("names each colour by both hue and pattern", async () => { + const user = userEvent.setup(); + const onValue = vi.fn(); + const { container } = render( + , + ); + + const stripes = screen.getByRole("button", { + name: "Colour 1: red, diagonal stripes", + }); + expect(stripes).toHaveClass("color-1"); + expect(container.querySelector(".color-8")).toHaveAccessibleName( + "Colour 8: pink, rings", + ); + await user.click(stripes); + expect(onValue).toHaveBeenCalledWith(1); + }); +}); diff --git a/tests/components/safeVisualLayer.test.tsx b/tests/components/safeVisualLayer.test.tsx new file mode 100644 index 0000000..4e4a584 --- /dev/null +++ b/tests/components/safeVisualLayer.test.tsx @@ -0,0 +1,126 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { SafeVisualLayer } from "../../src/components/SafeVisualLayer"; +import type { SafeVisualPrimitive } from "../../src/formats"; + +const visuals: readonly SafeVisualPrimitive[] = [ + { + type: "line", + layer: "underlay", + start: { kind: "coordinate", x: 0.25, y: 0.75 }, + end: { kind: "cell", cell: 5, offsetX: 0.1, offsetY: -0.1 }, + style: { stroke: "#123456", strokeWidth: 0.05, opacity: 0.75 }, + }, + { + type: "polyline", + layer: "underlay", + points: [ + { kind: "cell", cell: 0 }, + { kind: "cell", cell: 1 }, + { kind: "cell", cell: 5 }, + ], + style: { stroke: "#abcdef", fill: "transparent" }, + }, + { + type: "rectangle", + layer: "overlay", + center: { kind: "cell", cell: 6 }, + width: 0.8, + height: 0.6, + cornerRadius: 0.1, + style: { fill: "#ffeecc", stroke: "#112233" }, + }, + { + type: "ellipse", + layer: "overlay", + center: { kind: "coordinate", x: 2.5, y: 2.5 }, + radiusX: 0.4, + radiusY: 0.2, + }, + { + type: "circle", + layer: "overlay", + center: { kind: "cell", cell: 10 }, + radius: 0.25, + }, + { + type: "text", + layer: "overlay", + position: { kind: "cell", cell: 15 }, + text: "source label", + style: { fill: "#010203", fontSize: 0.4 }, + }, +]; + +describe("SafeVisualLayer", () => { + it("renders every canonical primitive with stable classes and grid geometry", () => { + const { container } = render( + + + + , + ); + + const line = container.querySelector(".source-visual--line"); + expect(line).toHaveAttribute("x1", "0.25"); + expect(line).toHaveAttribute("y1", "0.75"); + expect(line).toHaveAttribute("x2", "1.6"); + expect(line).toHaveAttribute("y2", "1.4"); + expect(line).toHaveAttribute("stroke", "#123456"); + expect(line).toHaveAttribute("stroke-width", "0.05"); + expect(container.querySelector(".source-visual--polyline")).toHaveAttribute( + "points", + "0.5,0.5 1.5,0.5 1.5,1.5", + ); + expect( + container.querySelector(".source-visual--rectangle"), + ).toHaveAttribute("rx", "0.1"); + expect(container.querySelector(".source-visual--ellipse")).toHaveAttribute( + "rx", + "0.4", + ); + expect(container.querySelector(".source-visual--circle")).toHaveAttribute( + "r", + "0.25", + ); + expect(container.querySelector(".source-visual--text")).toHaveTextContent( + "source label", + ); + expect( + container.querySelectorAll(".safe-visual-layer--underlay > *"), + ).toHaveLength(2); + expect( + container.querySelectorAll(".safe-visual-layer--overlay > *"), + ).toHaveLength(4); + }); + + it("uses a React text node and never creates executable descendants", () => { + const malicious = + ''; + const { container } = render( + + + , + ); + + expect(container.querySelector(".source-visual--text")?.textContent).toBe( + malicious, + ); + expect(container.querySelector("script, image")).toBeNull(); + expect( + container.querySelector("[href], [src], [onclick], [onload]"), + ).toBeNull(); + }); +}); diff --git a/tests/components/setterQualityLab.test.tsx b/tests/components/setterQualityLab.test.tsx new file mode 100644 index 0000000..2acf65e --- /dev/null +++ b/tests/components/setterQualityLab.test.tsx @@ -0,0 +1,279 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { + SetterQualityLab, + type SetterQualityLabProps, +} from "../../src/components/SetterQualityLab"; +import type { + PuzzleQualityAnalysis, + QualityItemReference, +} from "../../src/solver/quality"; + +const given: QualityItemReference = { kind: "given", cell: 0, value: 1 }; +const constraint: QualityItemReference = { + kind: "constraint", + index: 0, + constraintType: "diagonal", +}; + +const bounds = { + perCheck: { maxNodes: 2_000_000, timeoutMs: 10_000 }, + aggregate: { maxChecks: 1_000, maxNodes: 20_000_000, timeoutMs: 30_000 }, +} as const; + +const multiple: PuzzleQualityAnalysis = { + analysisDepth: "full", + solutionStatus: "multiple", + ambiguityWitness: { + firstSolution: [1, 2, 3, 4], + secondSolution: [2, 1, 3, 4], + differences: [ + { cell: 0, first: 1, second: 2 }, + { cell: 1, first: 2, second: 1 }, + ], + }, + contradiction: { + status: "not-applicable", + core: [], + necessary: [], + removable: [], + unknown: [], + }, + redundancy: { + givens: [ + { + item: given, + classification: "critical", + checkIndex: 1, + solutionStatus: "multiple", + }, + ], + constraints: [ + { + item: constraint, + classification: "redundant", + checkIndex: 2, + solutionStatus: "unique", + }, + ], + }, + criticalityHeatmap: [ + { + cell: 0, + score: 1, + criticalWeight: 1, + redundantWeight: 0, + unknownWeight: 0, + }, + { + cell: 1, + score: null, + criticalWeight: 0, + redundantWeight: 0, + unknownWeight: 1, + }, + ], + minimality: { + status: "not-minimal", + redundant: [constraint], + unknown: [], + }, + checks: [ + { + index: 0, + purpose: "baseline", + solutionStatus: "multiple", + solutionsFound: 2, + conclusive: true, + truncated: true, + limitReason: "solution-cap", + nodes: 10, + elapsedMs: 2, + }, + ], + bounds, + budget: { + checksPlanned: 3, + checksPerformed: 3, + nodes: 42, + elapsedMs: 12, + truncated: false, + unknownReasons: [], + }, +}; + +function props( + overrides: Partial = {}, +): SetterQualityLabProps { + return { + size: 4, + onRunQuick: vi.fn(), + onRunMinimality: vi.fn(), + onCancel: vi.fn(), + onFocusCells: vi.fn(), + onFocusItem: vi.fn(), + ...overrides, + }; +} + +describe("setter quality lab", () => { + it("runs distinct quick and bounded-minimality profiles and supports cancel", async () => { + const user = userEvent.setup(); + const onRunQuick = vi.fn(); + const onRunMinimality = vi.fn(); + const onCancel = vi.fn(); + const base = props({ onRunQuick, onRunMinimality, onCancel }); + const { rerender } = render(); + + fireEvent.change(screen.getByLabelText("Nodes per check"), { + target: { value: "123456" }, + }); + await user.click( + screen.getByRole("button", { name: "Run quick quality check" }), + ); + expect(onRunQuick).toHaveBeenCalledWith({ + perCheckMaxNodes: 123_456, + perCheckTimeoutMs: 10_000, + aggregateMaxChecks: 1_000, + aggregateMaxNodes: 20_000_000, + aggregateTimeoutMs: 30_000, + analysisDepth: "baseline", + proveMinimality: false, + }); + + await user.click( + screen.getByRole("button", { name: "Run full bounded minimality" }), + ); + expect(onRunMinimality).toHaveBeenCalledWith( + expect.objectContaining({ + analysisDepth: "full", + proveMinimality: true, + }), + ); + + rerender(); + expect( + screen.getByText("Full bounded minimality analysis running locally…"), + ).toHaveAttribute("role", "status"); + expect( + screen.getByRole("button", { name: "Run quick quality check" }), + ).toBeDisabled(); + await user.click(screen.getByRole("button", { name: "Cancel analysis" })); + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it("renders an ambiguity witness, findings, heatmap and bounded metrics", async () => { + const user = userEvent.setup(); + const onFocusCells = vi.fn(); + const onFocusItem = vi.fn(); + render( + , + ); + + expect( + screen.getByRole("heading", { name: "Multiple completions found" }), + ).toBeInTheDocument(); + expect(screen.getByText("2 differing cells")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Focus differences" })); + expect(onFocusCells).toHaveBeenCalledWith([0, 1]); + + await user.click( + screen.getByRole("button", { + name: /r1c1: critical; score 1\.00/u, + }), + ); + expect(onFocusCells).toHaveBeenLastCalledWith([0]); + expect( + screen.getByRole("button", { name: /r1c2: unknown; score unknown/u }), + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Given 1 at r1c1" })); + expect(onFocusItem).toHaveBeenCalledWith(given); + + const metrics = screen + .getByRole("heading", { name: "Checks and limits" }) + .closest("section")!; + expect(within(metrics).getByText("3 / 3")).toBeInTheDocument(); + expect( + within(metrics).getByText("2,000,000 nodes / 10,000 ms"), + ).toBeInTheDocument(); + expect( + within(metrics).getByText("1,000 checks / 20,000,000 nodes / 30,000 ms"), + ).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Not minimal" }), + ).toBeInTheDocument(); + }); + + it("labels bounded unknowns and exposes contradiction suspects", async () => { + const user = userEvent.setup(); + const onFocusItem = vi.fn(); + const unknown: PuzzleQualityAnalysis = { + ...multiple, + solutionStatus: "unknown", + ambiguityWitness: undefined, + contradiction: { + status: "incomplete", + core: [given, constraint], + necessary: [given], + removable: [], + unknown: [constraint], + reason: "The aggregate time budget ended localization.", + }, + redundancy: { + givens: [ + { + item: given, + classification: "unknown", + unknownReason: "aggregate-timeout", + }, + ], + constraints: [], + }, + criticalityHeatmap: [ + { + cell: 0, + score: null, + criticalWeight: 0, + redundantWeight: 0, + unknownWeight: 1, + }, + ], + minimality: { + status: "unknown", + redundant: [], + unknown: [constraint], + reason: "One or more clue checks were bounded.", + }, + budget: { + ...multiple.budget, + checksPerformed: 2, + truncated: true, + unknownReasons: ["aggregate-timeout"], + }, + }; + render(); + + expect( + screen.getByRole("heading", { name: "Solution status unknown" }), + ).toBeInTheDocument(); + expect( + screen.getAllByText(/incomplete \/ unknown/iu).length, + ).toBeGreaterThan(1); + expect( + screen.getByText("The aggregate time budget ended localization."), + ).toBeInTheDocument(); + expect(screen.getByText("Aggregate timeout")).toBeInTheDocument(); + + const necessary = screen + .getByRole("heading", { name: "Proven necessary suspects" }) + .closest("section")!; + await user.click( + within(necessary).getByRole("button", { name: "Given 1 at r1c1" }), + ); + expect(onFocusItem).toHaveBeenCalledWith(given); + }); +}); diff --git a/tests/components/sudokuBoard.test.tsx b/tests/components/sudokuBoard.test.tsx index 5269028..51aca62 100644 --- a/tests/components/sudokuBoard.test.tsx +++ b/tests/components/sudokuBoard.test.tsx @@ -1,9 +1,77 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { normalizePuzzle } from "../../src/domain"; import { SudokuBoard } from "../../src/components/SudokuBoard"; +import { foggedCellsForPuzzle } from "../../src/components/fogVisibility"; describe("Sudoku board constraint visuals", () => { + it("orders retained underlays before semantic clues and overlays afterward", () => { + const puzzle = normalizePuzzle({ + version: 1, + size: 4, + givens: new Array(16).fill(0), + solution: [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3], + constraints: [ + { type: "diagonal", direction: "main" }, + { type: "fog", lights: [0], revealRadius: 0 }, + ], + }); + const { container } = render( + , + ); + + const underlay = container.querySelector(".safe-visual-layer--underlay")!; + const regions = container.querySelector(".region-boundaries")!; + const semantic = container.querySelector(".constraint-diagonal")!; + const overlay = container.querySelector(".safe-visual-layer--overlay")!; + const fogMask = container.querySelector(".fog-constraint-mask")!; + expect( + underlay.compareDocumentPosition(regions) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect( + underlay.compareDocumentPosition(semantic) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect( + semantic.compareDocumentPosition(overlay) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect( + overlay.compareDocumentPosition(fogMask) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(fogMask.querySelectorAll("rect").length).toBeGreaterThan(0); + expect(underlay.querySelector(".source-visual--circle")).toBeTruthy(); + expect(overlay.querySelector(".source-visual--text")).toHaveTextContent( + "overlay", + ); + }); + it("distinguishes XV sums from directional inequalities", () => { const puzzle = normalizePuzzle({ version: 1, @@ -112,6 +180,412 @@ describe("Sudoku board constraint visuals", () => { ); }); + it("renders Pack 1 cell, global and outside clues with distinct semantics", () => { + const puzzle = normalizePuzzle({ + version: 1, + size: 4, + givens: new Array(16).fill(0), + constraints: [ + { type: "maximum", cell: 4 }, + { type: "minimum", cell: 5, negated: true }, + { type: "odd", cell: 9 }, + { type: "even", cell: 10 }, + { type: "disjoint-groups" }, + { + type: "little-killer", + side: "top", + index: 1, + direction: "down-right", + sum: 7, + }, + { type: "sandwich", side: "left", index: 2, sum: 3 }, + ], + }); + + const { container } = render( + , + ); + + const maximumPath = container + .querySelector(".constraint-maximum path") + ?.getAttribute("d"); + const minimumPath = container + .querySelector(".constraint-minimum path") + ?.getAttribute("d"); + expect(maximumPath).toBeTruthy(); + expect(minimumPath).toBeTruthy(); + expect(minimumPath).not.toBe(maximumPath); + expect(container.querySelector(".constraint-minimum")).toHaveClass( + "is-negated", + ); + expect( + container.querySelector(".constraint-odd circle"), + ).toBeInTheDocument(); + expect( + container.querySelector(".constraint-even rect"), + ).toBeInTheDocument(); + expect( + container.querySelectorAll(".constraint-disjoint-groups path"), + ).toHaveLength(4); + expect( + container.querySelector( + '.constraint-little-killer[data-direction="down-right"] .little-killer-arrow', + ), + ).toBeInTheDocument(); + expect( + container.querySelector(".constraint-little-killer text"), + ).toHaveTextContent("7"); + expect( + container.querySelector(".constraint-sandwich .outside-clue-kinds"), + ).toHaveTextContent("1⋯N"); + expect( + container.querySelector(".constraint-sandwich .outside-clue-value"), + ).toHaveTextContent("3"); + + const board = screen.getByRole("grid", { name: "4 by 4 Sudoku grid" }); + expect(board).toHaveAccessibleDescription( + expect.stringContaining( + "Disjoint groups rule: corresponding positions in every standard box contain each digit once.", + ), + ); + expect(board).toHaveAccessibleDescription( + expect.stringContaining( + "little killer sum 7 from the top, column 2, travelling down right.", + ), + ); + expect(board).toHaveAccessibleDescription( + expect.stringContaining( + "sandwich sum 3 from the left, row 3, between 1 and 4.", + ), + ); + expect( + screen.getByRole("gridcell", { name: "Row 3, column 2, empty" }), + ).toHaveAccessibleDescription( + expect.stringMatching(/odd digit.*sandwich sum 3/iu), + ); + }); + + it("renders Pack 2 lines and regions as distinct accessible clues", () => { + const puzzle = normalizePuzzle({ + version: 1, + size: 4, + givens: new Array(16).fill(0), + constraints: [ + { type: "between-line", cells: [0, 1, 2], negated: true }, + { + type: "german-whisper", + cells: [4, 5, 6], + minimumDifference: 2, + }, + { type: "region-sum-line", cells: [8, 9, 10] }, + { type: "clone", cells: [0, 4], cloneCells: [3, 7] }, + { type: "extra-region", cells: [0, 3, 12, 15] }, + ], + }); + + const { container } = render( + , + ); + + expect(container.querySelector(".constraint-between-line")).toHaveClass( + "is-negated", + ); + expect( + container.querySelectorAll(".constraint-between-line > circle"), + ).toHaveLength(2); + expect( + container.querySelector(".constraint-german-whisper polyline"), + ).toBeInTheDocument(); + expect( + container.querySelector(".constraint-region-sum-line polyline"), + ).toBeInTheDocument(); + expect( + container.querySelectorAll( + ".constraint-region-sum-line .region-sum-divider", + ), + ).toHaveLength(1); + expect( + container.querySelectorAll(".constraint-clone .clone-cell-fill"), + ).toHaveLength(4); + expect( + container.querySelectorAll(".constraint-clone .clone-boundary"), + ).toHaveLength(2); + expect( + container.querySelector(".constraint-clone .clone-link"), + ).toBeInTheDocument(); + expect( + container.querySelectorAll(".constraint-clone .clone-label"), + ).toHaveLength(2); + expect( + container.querySelectorAll( + ".constraint-extra-region .extra-region-cell-fill", + ), + ).toHaveLength(4); + + const board = screen.getByRole("grid", { name: "4 by 4 Sudoku grid" }); + expect(board).toHaveAccessibleDescription( + expect.stringContaining( + "False clue: between line from row 1, column 1 through row 1, column 2 to row 1, column 3", + ), + ); + expect(board).toHaveAccessibleDescription( + expect.stringContaining( + "German whisper through row 2, column 1; row 2, column 2; row 2, column 3; adjacent digits differ by at least 2.", + ), + ); + expect(board).toHaveAccessibleDescription( + expect.stringContaining( + "clone regions pairing row 1, column 1; row 2, column 1 with row 1, column 4; row 2, column 4 in order.", + ), + ); + expect(board).toHaveAccessibleDescription( + expect.stringContaining( + "Extra region through row 1, column 1; row 1, column 4; row 4, column 1; row 4, column 4; every digit appears exactly once.", + ), + ); + expect( + screen.getByRole("gridcell", { name: "Row 1, column 1, empty" }), + ).toHaveAccessibleDescription( + expect.stringMatching(/between line.*clone regions.*extra region/iu), + ); + }); + + it("renders Pack 3 pattern lines and indexers with distinct non-colour shapes", () => { + const puzzle = normalizePuzzle({ + version: 1, + size: 6, + givens: new Array(36).fill(0), + constraints: [ + { type: "modular-line", cells: [0, 1, 2], negated: true }, + { type: "entropic-line", cells: [6, 7, 8] }, + { type: "zipper-line", cells: [12, 13, 14, 15, 16] }, + { type: "double-arrow", cells: [18, 19, 20] }, + { type: "indexer", kind: "row", cell: 24 }, + { type: "indexer", kind: "column", cell: 25 }, + { type: "indexer", kind: "box", cell: 26 }, + ], + }); + + const { container } = render( + , + ); + + expect(container.querySelector(".constraint-modular-line")).toHaveClass( + "is-negated", + ); + expect( + container.querySelectorAll(".constraint-modular-line .modular-line-node"), + ).toHaveLength(3); + expect( + container.querySelector( + ".constraint-entropic-line .entropic-line-underlay", + ), + ).toBeInTheDocument(); + expect( + container.querySelector(".constraint-entropic-line .entropic-line-path"), + ).toBeInTheDocument(); + expect( + container.querySelector(".constraint-zipper-line .zipper-line-centre"), + ).toBeInTheDocument(); + expect( + container.querySelectorAll(".constraint-double-arrow > circle"), + ).toHaveLength(2); + expect( + container.querySelectorAll( + ".constraint-double-arrow .double-arrow-chevron", + ), + ).toHaveLength(2); + expect( + container.querySelector(".constraint-indexer--row circle"), + ).toBeInTheDocument(); + expect( + container.querySelector(".constraint-indexer--column rect"), + ).toBeInTheDocument(); + expect( + container.querySelector(".constraint-indexer--box rect"), + ).toBeInTheDocument(); + expect( + container.querySelector(".constraint-indexer--row text"), + ).toHaveTextContent("R"); + expect( + container.querySelector(".constraint-indexer--column text"), + ).toHaveTextContent("C"); + expect( + container.querySelector(".constraint-indexer--box text"), + ).toHaveTextContent("B"); + + const board = screen.getByRole("grid", { name: "6 by 6 Sudoku grid" }); + expect(board).toHaveAccessibleDescription( + expect.stringContaining( + "False clue: modular line through row 1, column 1; row 1, column 2; row 1, column 3", + ), + ); + expect(board).toHaveAccessibleDescription( + expect.stringContaining( + "entropic line through row 2, column 1; row 2, column 2; row 2, column 3", + ), + ); + expect(board).toHaveAccessibleDescription( + expect.stringContaining( + "row indexer at row 5, column 1; the marker digit selects a row in the same column", + ), + ); + }); + + it("masks fogged content and clues until a correct entry reveals them", () => { + const solution = [1, 2, 3, 4, 3, 4, 1, 2, 2, 1, 4, 3, 4, 3, 2, 1]; + const puzzle = normalizePuzzle({ + version: 1, + size: 4, + givens: new Array(16).fill(0), + solution, + constraints: [ + { type: "fog", lights: [0], revealRadius: 1 }, + { type: "even", cell: 10 }, + ], + }); + const pointerDown = vi.fn(); + const keyDown = vi.fn(); + const wrongValues = new Array(16).fill(0); + wrongValues[5] = 2; + wrongValues[10] = 3; + const { container, rerender } = render( + (16).fill(15)} + selected={new Set([10])} + highlighted={new Set([10])} + conflicts={new Set([10])} + activeCell={1} + candidateOverlay={{ + activeValues: [4], + candidateCells: [0, 10], + links: [ + { + id: "hidden-link", + kind: "strong", + a: { cell: 0, value: 4 }, + b: { cell: 10, value: 4 }, + contexts: [{ kind: "house", label: "Test" }], + }, + ], + }} + guidedHintOverlay={{ + focusCells: [10], + placements: [{ cell: 10, value: 4 }], + }} + onCellPointerDown={pointerDown} + onCellPointerEnter={vi.fn()} + onKeyDown={keyDown} + />, + ); + + const hidden = screen.getByRole("gridcell", { + name: "Row 3, column 3, obscured by fog", + }); + expect(hidden).toBeDisabled(); + expect(hidden).toHaveClass("is-fogged"); + expect(hidden).not.toHaveClass( + "is-selected", + "has-conflict", + "is-hint-focus", + ); + expect(hidden).toHaveAccessibleDescription( + "Obscured by Fog of War. This cell cannot be selected until revealed.", + ); + expect(hidden).toBeEmptyDOMElement(); + expect(container.querySelector(".constraint-even")).not.toBeInTheDocument(); + expect( + container.querySelectorAll(".fog-constraint-mask rect"), + ).toHaveLength(12); + expect( + container.querySelector(".candidate-link-layer"), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("grid", { name: "4 by 4 Sudoku grid" }), + ).not.toHaveAccessibleDescription(expect.stringContaining("even digit")); + + fireEvent.pointerDown(hidden); + expect(pointerDown).not.toHaveBeenCalled(); + fireEvent.pointerDown( + screen.getByRole("gridcell", { name: "Row 1, column 1, empty" }), + ); + expect(pointerDown).toHaveBeenCalledWith(0, expect.anything()); + fireEvent.keyDown( + screen.getByRole("gridcell", { name: "Row 1, column 2, empty" }), + { key: "ArrowRight" }, + ); + fireEvent.keyDown( + screen.getByRole("gridcell", { name: "Row 1, column 2, empty" }), + { key: "a", ctrlKey: true }, + ); + expect(keyDown).not.toHaveBeenCalled(); + + const correctValues = new Array(16).fill(0); + correctValues[5] = 4; + rerender( + , + ); + expect( + screen.getByRole("gridcell", { name: "Row 3, column 3, empty" }), + ).toHaveAccessibleDescription(expect.stringContaining("even digit")); + expect(container.querySelector(".constraint-even")).toBeInTheDocument(); + expect( + container.querySelectorAll(".fog-constraint-mask rect"), + ).toHaveLength(7); + }); + + it("reveals given cells and their Chebyshev neighbourhood under fog", () => { + const solution = [1, 2, 3, 4, 3, 4, 1, 2, 2, 1, 4, 3, 4, 3, 2, 1]; + const givens = new Array(16).fill(0); + givens[15] = 1; + const puzzle = normalizePuzzle({ + version: 1, + size: 4, + givens, + solution, + constraints: [{ type: "fog", lights: [0], revealRadius: 1 }], + }); + + const fogged = foggedCellsForPuzzle(puzzle, givens); + expect(fogged.has(10)).toBe(false); + expect(fogged.has(15)).toBe(false); + expect(fogged.has(9)).toBe(true); + }); + it("renders candidate filters and strong/weak graph overlays", () => { const puzzle = normalizePuzzle({ version: 1, @@ -164,6 +638,51 @@ describe("Sudoku board constraint visuals", () => { ); }); + it("keeps guided effects distinct from focus cells and exposes the preview", () => { + const puzzle = normalizePuzzle({ + version: 1, + size: 4, + givens: new Array(16).fill(0), + }); + const { container } = render( + (13).fill(0)]} + selected={new Set()} + activeCell={0} + guidedHintOverlay={{ + focusCells: [0, 1, 2], + placements: [{ cell: 0, value: 4 }], + eliminations: [{ cell: 1, values: [2, 3] }], + }} + onCellPointerDown={vi.fn()} + onCellPointerEnter={vi.fn()} + onKeyDown={vi.fn()} + />, + ); + + expect(container.querySelectorAll(".is-hint-focus")).toHaveLength(3); + expect(container.querySelectorAll(".is-hint-placement")).toHaveLength(1); + expect(container.querySelectorAll(".is-hint-elimination")).toHaveLength(1); + expect( + container.querySelector(".hint-placement-preview"), + ).toHaveTextContent("4"); + expect( + container.querySelector(".hint-elimination-preview"), + ).toHaveTextContent("−23"); + expect( + screen.getByRole("gridcell", { name: "Row 1, column 1, empty" }), + ).toHaveAccessibleDescription( + expect.stringContaining("hint preview: place 4"), + ); + expect( + screen.getByRole("gridcell", { name: "Row 1, column 2, empty" }), + ).toHaveAccessibleDescription( + expect.stringContaining("hint preview: remove 2, 3 from the candidates"), + ); + }); + it("exposes real row semantics and detailed per-cell state", () => { const puzzle = normalizePuzzle({ version: 1, @@ -215,4 +734,80 @@ describe("Sudoku board constraint visuals", () => { expect(annotated).toHaveAttribute("aria-invalid", "true"); expect(annotated).toHaveAttribute("aria-selected", "true"); }); + + it("offers off, concise and detailed screen-reader candidate output", () => { + const puzzle = normalizePuzzle({ + version: 1, + size: 4, + givens: new Array(16).fill(0), + }); + const baseProps = { + puzzle, + values: puzzle.givens, + cornerMarks: [3, ...new Array(15).fill(0)], + centerMarks: [12, ...new Array(15).fill(0)], + selected: new Set([0]), + activeCell: 0, + onCellPointerDown: vi.fn(), + onCellPointerEnter: vi.fn(), + onKeyDown: vi.fn(), + } as const; + const { rerender } = render( + , + ); + const cell = () => + screen.getByRole("gridcell", { name: "Row 1, column 1, empty" }); + + expect(cell()).toHaveAccessibleDescription( + expect.stringContaining("corner notes 1, 2"), + ); + expect(cell()).toHaveAccessibleDescription( + expect.stringContaining("centre notes 3, 4"), + ); + + rerender(); + expect(cell()).toHaveAccessibleDescription( + expect.stringContaining("2 corner notes"), + ); + expect(cell()).toHaveAccessibleDescription( + expect.stringContaining("2 centre notes"), + ); + expect(cell()).not.toHaveAccessibleDescription( + expect.stringContaining("corner notes 1, 2"), + ); + + rerender(); + expect(cell()).not.toHaveAccessibleDescription( + expect.stringMatching(/candidate|corner note|centre note/iu), + ); + }); + + it("adds a non-colour pattern layer and names the pattern", () => { + const puzzle = normalizePuzzle({ + version: 1, + size: 4, + givens: new Array(16).fill(0), + }); + const { container } = render( + (15).fill(0)]} + selected={new Set()} + activeCell={0} + onCellPointerDown={vi.fn()} + onCellPointerEnter={vi.fn()} + onKeyDown={vi.fn()} + />, + ); + + expect( + container.querySelector(".has-color-1 .cell-color-pattern"), + ).toBeInTheDocument(); + expect( + screen.getByRole("gridcell", { name: "Row 1, column 1, empty" }), + ).toHaveAccessibleDescription( + expect.stringContaining("colour 1: red, diagonal stripes"), + ); + }); }); diff --git a/tests/components/workbench.test.tsx b/tests/components/workbench.test.tsx index 0746570..4445835 100644 --- a/tests/components/workbench.test.tsx +++ b/tests/components/workbench.test.tsx @@ -2,6 +2,8 @@ import { fireEvent, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeAll, describe, expect, it, vi } from "vitest"; import { Workbench } from "../../src/components/Workbench"; +import { encodePuzzleHash, fromDomainPuzzle } from "../../src/formats"; +import { SUDOKU_DOCUMENT_SCHEMA } from "../../src/formats"; class WorkerStub { addEventListener() {} @@ -63,6 +65,37 @@ describe("Sudoku workbench", () => { expect(undo).toBeDisabled(); }); + it("fills a tracked candidate grid as one undoable maintenance action", async () => { + const user = userEvent.setup(); + render(); + + expect( + screen.getByText("Candidate tracking is currently inactive."), + ).toBeInTheDocument(); + await user.click( + screen.getByRole("button", { name: "Fill legal candidates" }), + ); + + expect( + screen.getByText("The guided candidate grid is active."), + ).toBeInTheDocument(); + expect( + screen.getByRole("gridcell", { + name: "Row 1, column 3, empty", + }), + ).toHaveAccessibleDescription(expect.stringContaining("centre notes")); + + await user.click(screen.getByRole("button", { name: "Undo" })); + expect( + screen.getByText("Candidate tracking is currently inactive."), + ).toBeInTheDocument(); + expect( + screen.getByRole("gridcell", { + name: "Row 1, column 3, empty", + }), + ).not.toHaveAccessibleDescription(expect.stringContaining("centre notes")); + }); + it("supports non-wrapping WAI-ARIA grid navigation", () => { render(); @@ -91,6 +124,94 @@ describe("Sudoku workbench", () => { expect(cell(1, 1)).toHaveAttribute("tabindex", "0"); }); + it("offers tap-by-tap multi-selection without drag selection", async () => { + const user = userEvent.setup(); + render(); + const grid = screen.getByRole("grid", { name: "9 by 9 Sudoku grid" }); + const cell = (column: number) => + within(grid).getByRole("gridcell", { + name: new RegExp(`^Row 1, column ${String(column)},`, "u"), + }); + + fireEvent.pointerDown(cell(3), { buttons: 1 }); + const toggleMode = screen.getByRole("button", { name: "Tap multi-select" }); + expect(toggleMode).toHaveAttribute("aria-pressed", "false"); + await user.click(toggleMode); + expect(toggleMode).toHaveAttribute("aria-pressed", "true"); + + fireEvent.pointerDown(cell(4), { buttons: 1 }); + expect(cell(3)).toHaveAttribute("aria-selected", "true"); + expect(cell(4)).toHaveAttribute("aria-selected", "true"); + fireEvent.pointerEnter(cell(5), { buttons: 1 }); + expect(cell(5)).toHaveAttribute("aria-selected", "false"); + + fireEvent.pointerDown(cell(3), { buttons: 1 }); + expect(cell(3)).toHaveAttribute("aria-selected", "false"); + expect(cell(4)).toHaveAttribute("aria-selected", "true"); + }); + + it("renders one sticky entry pad in a narrow viewport", () => { + const original = Object.getOwnPropertyDescriptor(window, "matchMedia"); + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: (query: string) => ({ + matches: query === "(max-width: 48rem)", + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }), + }); + + const { container, unmount } = render(); + expect(container.querySelectorAll(".mobile-number-pad")).toHaveLength(1); + expect(screen.getAllByRole("group", { name: "Entry mode" })).toHaveLength( + 1, + ); + unmount(); + if (original === undefined) Reflect.deleteProperty(window, "matchMedia"); + else Object.defineProperty(window, "matchMedia", original); + }); + + it("keeps fogged selections out of toolbar and helper output", async () => { + const user = userEvent.setup(); + const previousHash = window.location.hash; + window.location.hash = encodePuzzleHash( + fromDomainPuzzle({ + version: 1, + size: 4, + givens: new Array(16).fill(0), + solution: [1, 2, 3, 4, 3, 4, 1, 2, 2, 1, 4, 3, 4, 3, 2, 1], + constraints: [{ type: "fog", lights: [15], revealRadius: 0 }], + }), + ); + const { unmount } = render(); + + expect( + screen.getByRole("gridcell", { + name: "Row 1, column 1, obscured by fog", + }), + ).toBeDisabled(); + expect(screen.getByText("r4c4")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Helpers" })); + await user.click(screen.getByRole("tab", { name: "Sum Lab" })); + await user.clear(screen.getByLabelText("Target sum")); + await user.type(screen.getByLabelText("Target sum"), "4"); + await user.click( + screen.getByRole("checkbox", { + name: "Use 1 board cell and candidates", + }), + ); + expect(screen.getByText(/^r4c4:/u)).toBeInTheDocument(); + + unmount(); + window.location.hash = previousHash; + }); + it("shows digit progress and toggles matching-digit highlights separately from selection", async () => { const user = userEvent.setup(); render(); @@ -257,8 +378,8 @@ describe("Sudoku workbench", () => { ).toBeInTheDocument(); expect(container.querySelectorAll(".is-negated")).toHaveLength(2); - await user.clear(screen.getByLabelText("Clue")); - await user.type(screen.getByLabelText("Clue"), "6562"); + await user.clear(screen.getByLabelText("Sum")); + await user.type(screen.getByLabelText("Sum"), "6562"); expect( screen.getByRole("button", { name: "Add / replace outside clue" }), ).toBeDisabled(); @@ -375,4 +496,69 @@ describe("Sudoku workbench", () => { ).toBeInTheDocument(); expect(fetchSpy).not.toHaveBeenCalled(); }); + + it("keeps imported visuals, provenance and metadata after a board edit", async () => { + const user = userEvent.setup(); + let downloaded: Blob | undefined; + vi.mocked(URL.createObjectURL).mockImplementation((value) => { + downloaded = value as Blob; + return "blob:preserved-workbench-export"; + }); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + render(); + + const imported = { + schema: SUDOKU_DOCUMENT_SCHEMA, + version: 1, + size: 4, + givens: Array(16).fill(0), + constraints: [], + title: "Preservation regression", + visuals: [ + { + type: "text", + layer: "overlay", + position: { kind: "cell", cell: 0 }, + text: "source label", + style: { fill: "#123456" }, + }, + ], + source: { format: "sudokupad", id: "original-source" }, + metadata: { edition: "kept" }, + }; + await user.click(screen.getByRole("button", { name: "Import / export" })); + fireEvent.change(screen.getByPlaceholderText(/Paste 81 characters/u), { + target: { value: JSON.stringify(imported) }, + }); + await user.click(screen.getByRole("button", { name: "Import locally" })); + expect(document.querySelector(".source-visual--text")).toHaveTextContent( + "source label", + ); + + fireEvent.pointerDown( + screen.getByRole("gridcell", { name: "Row 1, column 1, empty" }), + { buttons: 1 }, + ); + await user.click( + within(screen.getByRole("group", { name: "Digits" })).getByRole( + "button", + { name: "1" }, + ), + ); + await user.click(screen.getByRole("button", { name: "Import / export" })); + await user.click( + screen.getByRole("button", { name: "Download project JSON" }), + ); + + const exported = JSON.parse((await downloaded?.text()) ?? "{}") as { + values?: number[]; + visuals?: unknown[]; + source?: unknown; + metadata?: unknown; + }; + expect(exported.values?.[0]).toBe(1); + expect(exported.visuals).toEqual(imported.visuals); + expect(exported.source).toEqual(imported.source); + expect(exported.metadata).toEqual(imported.metadata); + }); }); diff --git a/tests/domain/constraintRegistry.test.ts b/tests/domain/constraintRegistry.test.ts new file mode 100644 index 0000000..fa84bd1 --- /dev/null +++ b/tests/domain/constraintRegistry.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { + CONSTRAINT_REGISTRY, + CONSTRAINT_TYPES, + constraintAllowedFields, + constraintCells, + constraintLabel, + constraintMetadata, + isConstraintType, + type ConstraintType, +} from "../../src/domain"; + +const expectedTypes = [ + "diagonal", + "anti-knight", + "anti-king", + "non-consecutive", + "disjoint-groups", + "killer-cage", + "thermo", + "arrow", + "kropki", + "xv", + "inequality", + "renban", + "palindrome", + "x-sum", + "skyscraper", + "quadruple", + "maximum", + "minimum", + "odd", + "even", + "little-killer", + "sandwich", + "between-line", + "german-whisper", + "region-sum-line", + "clone", + "extra-region", + "modular-line", + "entropic-line", + "zipper-line", + "double-arrow", + "indexer", + "fog", +] as const satisfies readonly ConstraintType[]; + +describe("constraint registry", () => { + it("contains one complete metadata entry for every constraint type", () => { + expect([...CONSTRAINT_TYPES].sort()).toEqual([...expectedTypes].sort()); + expect(Object.keys(CONSTRAINT_REGISTRY).sort()).toEqual( + [...expectedTypes].sort(), + ); + for (const type of expectedTypes) { + const metadata = constraintMetadata(type); + expect(metadata.type).toBe(type); + expect(metadata.label.length).toBeGreaterThan(0); + expect(metadata.fields[0]).toEqual({ + key: "type", + kind: "discriminator", + required: true, + }); + expect(new Set(metadata.fields.map(({ key }) => key)).size).toBe( + metadata.fields.length, + ); + expect(constraintAllowedFields(type).has("negated")).toBe( + metadata.negatable, + ); + } + }); + + it("provides labels, field metadata and a safe type guard", () => { + expect(constraintLabel("little-killer")).toBe("Little killer"); + expect(constraintLabel("future-rule")).toBe("Future Rule"); + expect(isConstraintType("sandwich")).toBe(true); + expect(isConstraintType("not-a-rule")).toBe(false); + expect(constraintAllowedFields("little-killer")).toEqual( + new Set(["type", "side", "index", "direction", "sum", "negated"]), + ); + }); + + it("resolves global, line, outside and local footprints centrally", () => { + expect(constraintCells(4, { type: "diagonal", direction: "anti" })).toEqual( + [3, 6, 9, 12], + ); + expect(constraintCells(4, { type: "disjoint-groups" })).toHaveLength(16); + expect(constraintCells(4, { type: "minimum", cell: 5 })).toEqual([ + 5, 1, 9, 4, 6, + ]); + expect(constraintCells(4, { type: "odd", cell: 5 })).toEqual([5]); + expect( + constraintCells(4, { + type: "little-killer", + side: "top", + index: 0, + direction: "down-right", + sum: 10, + }), + ).toEqual([0, 5, 10, 15]); + expect( + constraintCells(4, { + type: "sandwich", + side: "right", + index: 2, + sum: 3, + }), + ).toEqual([11, 10, 9, 8]); + expect( + constraintCells(4, { + type: "clone", + cells: [0, 1], + cloneCells: [10, 11], + }), + ).toEqual([0, 1, 10, 11]); + expect( + constraintCells(4, { type: "indexer", kind: "box", cell: 0 }), + ).toEqual([0, 2, 8, 10]); + expect( + constraintCells(4, { type: "fog", lights: [0, 5], revealRadius: 1 }), + ).toEqual([0, 5]); + }); +}); diff --git a/tests/domain/pack1Constraints.test.ts b/tests/domain/pack1Constraints.test.ts new file mode 100644 index 0000000..ab6052c --- /dev/null +++ b/tests/domain/pack1Constraints.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, it } from "vitest"; +import { + candidatesForCell, + classicRegions, + compilePuzzle, + constraintIsFeasible, + normalizePuzzle, + validatePuzzle, + type PuzzleDefinition, + type VariantConstraint, +} from "../../src/domain"; +import { solveExact } from "../../src/solver"; + +const solved4 = [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3] as const; + +function puzzle4(overrides: Partial = {}): PuzzleDefinition { + return { + version: 1, + size: 4, + givens: new Array(16).fill(0), + regions: classicRegions(4), + constraints: [], + ...overrides, + }; +} + +const packOneConstraints: readonly VariantConstraint[] = [ + { type: "minimum", cell: 0 }, + { type: "odd", cell: 0 }, + { type: "even", cell: 1 }, + { type: "disjoint-groups" }, + { + type: "little-killer", + side: "top", + index: 0, + direction: "down-right", + sum: 10, + }, + { type: "sandwich", side: "left", index: 0, sum: 5 }, +]; + +describe("Pack 1 constraint validation", () => { + it("accepts and clones every production shape", () => { + const source = puzzle4({ constraints: packOneConstraints }); + expect(validatePuzzle(source)).toEqual({ valid: true, issues: [] }); + const normalized = normalizePuzzle(source); + expect(normalized.constraints).toEqual(packOneConstraints); + expect(normalized.constraints).not.toBe(packOneConstraints); + }); + + it("rejects invalid cells, fields, directions, paths and unreachable sums", () => { + const invalid: readonly unknown[] = [ + { type: "minimum", cell: 16 }, + { type: "odd", cell: 0, surprise: true }, + { type: "even", cell: -1 }, + { type: "disjoint-groups", negated: true }, + { + type: "little-killer", + side: "top", + index: 0, + direction: "up-right", + sum: 10, + }, + { + type: "little-killer", + side: "top", + index: 0, + direction: "down-left", + sum: 4, + }, + { + type: "little-killer", + side: "top", + index: 0, + direction: "down-right", + sum: 17, + }, + { type: "sandwich", side: "left", index: 0, sum: 1 }, + { type: "sandwich", side: "inside", index: 0, sum: 5 }, + ]; + for (const constraint of invalid) { + const result = validatePuzzle({ + ...puzzle4(), + constraints: [constraint], + }); + expect(result.valid, JSON.stringify(constraint)).toBe(false); + expect( + result.issues.some(({ path }) => path.startsWith("constraints[0]")), + ).toBe(true); + } + }); + + it("rejects disjoint groups on jigsaw regions but accepts renamed standard boxes", () => { + const jigsaw = [0, 0, 1, 1, 0, 2, 2, 1, 0, 2, 3, 1, 2, 3, 3, 3]; + const rejected = validatePuzzle( + puzzle4({ regions: jigsaw, constraints: [{ type: "disjoint-groups" }] }), + ); + expect(rejected.valid).toBe(false); + expect(rejected.issues).toContainEqual({ + path: "constraints[0]", + message: "disjoint groups require the standard rectangular box layout", + }); + + const renamed = classicRegions(4).map((region) => [2, 0, 3, 1][region]!); + expect( + validatePuzzle( + puzzle4({ + regions: renamed, + constraints: [{ type: "disjoint-groups" }], + }), + ).valid, + ).toBe(true); + }); +}); + +describe("Pack 1 partial feasibility", () => { + it("prunes minimum, odd and even cells before the grid is complete", () => { + const values = new Array(16).fill(0); + values[1] = 3; + expect( + candidatesForCell( + puzzle4({ constraints: [{ type: "minimum", cell: 0 }] }), + values, + 0, + ), + ).toEqual([1, 2]); + expect( + candidatesForCell( + puzzle4({ constraints: [{ type: "odd", cell: 0 }] }), + new Array(16).fill(0), + 0, + ), + ).toEqual([1, 3]); + expect( + candidatesForCell( + puzzle4({ constraints: [{ type: "even", cell: 0 }] }), + new Array(16).fill(0), + 0, + ), + ).toEqual([2, 4]); + + const impossibleMinimum = new Array(16).fill(0); + impossibleMinimum[1] = 1; + expect( + constraintIsFeasible({ type: "minimum", cell: 0 }, impossibleMinimum, 4), + ).toBe(false); + expect( + constraintIsFeasible( + { type: "minimum", cell: 0, negated: true }, + [2, 1, ...new Array(14).fill(0)], + 4, + ), + ).toBe(true); + }); + + it("compiles four disjoint houses and enforces their remote peers", () => { + const plain = normalizePuzzle(puzzle4()); + const disjoint = compilePuzzle( + normalizePuzzle(puzzle4({ constraints: [{ type: "disjoint-groups" }] })), + ); + expect( + disjoint.units.filter(({ kind }) => kind === "disjoint-group"), + ).toHaveLength(4); + const values = new Array(16).fill(0); + values[0] = 1; + expect(candidatesForCell(plain, values, 10)).toContain(1); + expect(candidatesForCell(disjoint, values, 10)).not.toContain(1); + }); + + it("uses the whole little-killer diagonal for partial sum bounds", () => { + const constraint = { + type: "little-killer", + side: "top", + index: 1, + direction: "down-right", + sum: 6, + } as const; + const values = new Array(16).fill(0); + values[1] = 4; + expect( + candidatesForCell(puzzle4({ constraints: [constraint] }), values, 6), + ).toEqual([1]); + + const complete = [...solved4]; + expect( + constraintIsFeasible( + { + type: "little-killer", + side: "top", + index: 0, + direction: "down-right", + sum: 10, + }, + complete, + 4, + ), + ).toBe(true); + expect( + constraintIsFeasible( + { + type: "little-killer", + side: "top", + index: 0, + direction: "down-right", + sum: 10, + negated: true, + }, + complete, + 4, + ), + ).toBe(false); + }); + + it("computes exact sandwich possibilities from partial permutations", () => { + const partial = [1, 2, 0, 4, ...new Array(12).fill(0)]; + expect( + constraintIsFeasible( + { type: "sandwich", side: "left", index: 0, sum: 5 }, + partial, + 4, + ), + ).toBe(true); + expect( + constraintIsFeasible( + { type: "sandwich", side: "left", index: 0, sum: 3 }, + partial, + 4, + ), + ).toBe(false); + expect( + constraintIsFeasible( + { + type: "sandwich", + side: "left", + index: 0, + sum: 5, + negated: true, + }, + solved4, + 4, + ), + ).toBe(false); + expect( + constraintIsFeasible( + { + type: "sandwich", + side: "left", + index: 0, + sum: 3, + negated: true, + }, + solved4, + 4, + ), + ).toBe(true); + }); +}); + +describe("Pack 1 exact solving", () => { + it.each( + packOneConstraints.map( + (constraint) => [constraint.type, constraint] as const, + ), + )("solves a puzzle containing %s", (_type, constraint) => { + const givens: number[] = [...solved4]; + givens[5] = 0; + givens[10] = 0; + const result = solveExact(puzzle4({ givens, constraints: [constraint] })); + expect(result.count).toBe(1); + expect(result.truncated).toBe(false); + expect(result.solutions[0]).toEqual(solved4); + }); +}); diff --git a/tests/domain/pack2Constraints.test.ts b/tests/domain/pack2Constraints.test.ts new file mode 100644 index 0000000..4487ce1 --- /dev/null +++ b/tests/domain/pack2Constraints.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from "vitest"; +import { + candidatesForCell, + classicRegions, + compilePuzzle, + constraintIsFeasible, + normalizePuzzle, + validatePuzzle, + type PuzzleDefinition, + type VariantConstraint, +} from "../../src/domain"; +import { solveExact } from "../../src/solver"; + +const solved4 = [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3] as const; + +function puzzle4(overrides: Partial = {}): PuzzleDefinition { + return { + version: 1, + size: 4, + givens: new Array(16).fill(0), + regions: classicRegions(4), + constraints: [], + ...overrides, + }; +} + +const packTwoConstraints: readonly VariantConstraint[] = [ + { type: "between-line", cells: [0, 1, 2] }, + { type: "german-whisper", cells: [0, 3, 1] }, + { type: "region-sum-line", cells: [0, 1, 2] }, + { type: "clone", cells: [0, 1], cloneCells: [6, 7] }, + { type: "extra-region", cells: [0, 7, 9, 14] }, +]; + +describe("Pack 2 constraint validation", () => { + it("accepts and clones every production shape", () => { + const source = puzzle4({ constraints: packTwoConstraints }); + expect(validatePuzzle(source)).toEqual({ valid: true, issues: [] }); + const normalized = normalizePuzzle(source); + expect(normalized.constraints).toEqual(packTwoConstraints); + expect(normalized.constraints).not.toBe(packTwoConstraints); + }); + + it.each([ + { type: "between-line", cells: [0, 1] }, + { type: "between-line", cells: [0, 1, 0] }, + { type: "german-whisper", cells: [0] }, + { type: "german-whisper", cells: [0, 1], minimumDifference: 0 }, + { type: "german-whisper", cells: [0, 1], minimumDifference: 4 }, + { type: "region-sum-line", cells: [0, 1] }, + { type: "clone", cells: [0, 1], cloneCells: [8] }, + { type: "clone", cells: [0, 1], cloneCells: [8, 8] }, + { type: "extra-region", cells: [0, 1, 2] }, + { type: "extra-region", cells: [0, 1, 2, 3], negated: true }, + ] as const)("rejects malformed constraint $type", (constraint) => { + const result = validatePuzzle({ ...puzzle4(), constraints: [constraint] }); + expect(result.valid).toBe(false); + expect( + result.issues.some(({ path }) => path.startsWith("constraints[0]")), + ).toBe(true); + }); + + it("uses the puzzle's actual regions when checking region-sum crossings", () => { + expect( + validatePuzzle( + puzzle4({ constraints: [{ type: "region-sum-line", cells: [0, 1] }] }), + ).issues, + ).toContainEqual({ + path: "constraints[0].cells", + message: "region-sum line must cross at least one region boundary", + }); + expect( + validatePuzzle( + puzzle4({ constraints: [{ type: "region-sum-line", cells: [1, 2] }] }), + ).valid, + ).toBe(true); + }); +}); + +describe("Pack 2 partial and completed semantics", () => { + it("enforces between-line interiors and its negated truth", () => { + const constraint = { type: "between-line", cells: [0, 1, 2] } as const; + const partial = new Array(16).fill(0); + partial[0] = 1; + partial[2] = 4; + expect( + candidatesForCell(puzzle4({ constraints: [constraint] }), partial, 1), + ).toEqual([2, 3]); + expect( + constraintIsFeasible( + constraint, + [2, 0, 3, ...new Array(13).fill(0)], + 4, + ), + ).toBe(false); + expect(constraintIsFeasible(constraint, solved4, 4)).toBe(true); + expect( + constraintIsFeasible({ ...constraint, negated: true }, solved4, 4), + ).toBe(false); + expect( + constraintIsFeasible( + { ...constraint, negated: true }, + [1, 4, 3, ...new Array(13).fill(0)], + 4, + ), + ).toBe(true); + }); + + it("runs exact dynamic feasibility for default and explicit German whispers", () => { + const constraint = { + type: "german-whisper", + cells: [0, 5, 10], + } as const; + const partial = new Array(16).fill(0); + partial[0] = 2; + expect( + candidatesForCell(puzzle4({ constraints: [constraint] }), partial, 5), + ).toEqual([4]); + const impossible = new Array(16).fill(0); + impossible[5] = 2; + expect( + constraintIsFeasible( + { ...constraint, minimumDifference: 3 }, + impossible, + 4, + ), + ).toBe(false); + + const complete = [1, 4, 2, ...new Array(13).fill(0)]; + const short = { type: "german-whisper", cells: [0, 1, 2] } as const; + expect(constraintIsFeasible(short, complete, 4)).toBe(true); + expect(constraintIsFeasible({ ...short, negated: true }, complete, 4)).toBe( + false, + ); + complete[1] = 2; + expect(constraintIsFeasible({ ...short, negated: true }, complete, 4)).toBe( + true, + ); + }); + + it("balances contiguous region sums using the supplied region map", () => { + const constraint = { + type: "region-sum-line", + cells: [0, 1, 2], + } as const; + const partial = new Array(16).fill(0); + partial[0] = 1; + partial[2] = 3; + expect( + candidatesForCell(puzzle4({ constraints: [constraint] }), partial, 1), + ).toEqual([2]); + expect(constraintIsFeasible(constraint, solved4, 4)).toBe(true); + expect( + constraintIsFeasible({ ...constraint, negated: true }, solved4, 4), + ).toBe(false); + const unequal = [...solved4]; + unequal[2] = 4; + expect( + constraintIsFeasible({ ...constraint, negated: true }, unequal, 4), + ).toBe(true); + }); + + it("enforces ordered clone equality and exact negated state", () => { + const constraint = { + type: "clone", + cells: [0, 1], + cloneCells: [6, 7], + } as const; + const partial = new Array(16).fill(0); + partial[0] = 1; + partial[1] = 2; + partial[6] = 1; + expect( + candidatesForCell(puzzle4({ constraints: [constraint] }), partial, 7), + ).toEqual([2]); + expect(constraintIsFeasible(constraint, solved4, 4)).toBe(true); + expect( + constraintIsFeasible({ ...constraint, negated: true }, solved4, 4), + ).toBe(false); + const mismatch = [...solved4]; + mismatch[7] = 3; + expect(constraintIsFeasible(constraint, mismatch, 4)).toBe(false); + expect( + constraintIsFeasible({ ...constraint, negated: true }, mismatch, 4), + ).toBe(true); + }); + + it("compiles extra regions as all-different units and remote peers", () => { + const constraint = { + type: "extra-region", + cells: [0, 7, 9, 14], + } as const; + const compiled = compilePuzzle( + normalizePuzzle(puzzle4({ constraints: [constraint] })), + ); + expect( + compiled.units.filter(({ kind }) => kind === "extra-region"), + ).toEqual([{ kind: "extra-region", index: 0, cells: constraint.cells }]); + const partial = new Array(16).fill(0); + partial[0] = 1; + expect(candidatesForCell(compiled, partial, 7)).not.toContain(1); + expect(compilePuzzle(normalizePuzzle(puzzle4())).peers[7]?.has(0)).toBe( + false, + ); + }); +}); + +describe("Pack 2 exact solving", () => { + it.each( + packTwoConstraints.map( + (constraint) => [constraint.type, constraint] as const, + ), + )("solves a puzzle containing %s", (_type, constraint) => { + const givens: number[] = [...solved4]; + givens[5] = 0; + givens[10] = 0; + const result = solveExact(puzzle4({ givens, constraints: [constraint] })); + expect(result.count).toBe(1); + expect(result.truncated).toBe(false); + expect(result.solutions[0]).toEqual(solved4); + }); +}); diff --git a/tests/domain/pack3Constraints.test.ts b/tests/domain/pack3Constraints.test.ts new file mode 100644 index 0000000..a1377b5 --- /dev/null +++ b/tests/domain/pack3Constraints.test.ts @@ -0,0 +1,355 @@ +import { describe, expect, it } from "vitest"; +import { + candidatesForCell, + classicRegions, + constraintIsFeasible, + normalizePuzzle, + validatePuzzle, + type PuzzleDefinition, + type VariantConstraint, +} from "../../src/domain"; +import { solveExact } from "../../src/solver"; + +const solved4 = [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3] as const; +const solved6 = [ + 1, 2, 3, 4, 5, 6, 4, 5, 6, 1, 2, 3, 2, 3, 4, 5, 6, 1, 5, 6, 1, 2, 3, 4, 3, 4, + 5, 6, 1, 2, 6, 1, 2, 3, 4, 5, +] as const; + +function puzzle( + size: number, + overrides: Partial = {}, +): PuzzleDefinition { + return { + version: 1, + size, + givens: new Array(size * size).fill(0), + regions: classicRegions(size), + constraints: [], + ...overrides, + }; +} + +const semantic4: readonly VariantConstraint[] = [ + { type: "modular-line", cells: [0, 1, 2, 3] }, + { type: "zipper-line", cells: [0, 2, 1] }, + { type: "double-arrow", cells: [0, 2, 1] }, + { type: "indexer", kind: "row", cell: 7 }, + { type: "indexer", kind: "column", cell: 5 }, + { type: "indexer", kind: "box", cell: 14 }, +]; + +describe("Pack 3 validation and cloning", () => { + it("accepts all production shapes and preserves canonical fog data", () => { + const definition = puzzle(4, { + solution: solved4, + constraints: [ + ...semantic4, + { type: "fog", lights: [0, 5], revealRadius: 1 }, + ], + }); + expect(validatePuzzle(definition)).toEqual({ valid: true, issues: [] }); + const normalized = normalizePuzzle(definition); + expect(normalized.constraints).toEqual(definition.constraints); + expect(normalized.constraints.find(({ type }) => type === "fog")).toEqual({ + type: "fog", + lights: [0, 5], + revealRadius: 1, + }); + + expect( + validatePuzzle( + puzzle(6, { + constraints: [{ type: "entropic-line", cells: [0, 2, 4] }], + }), + ).valid, + ).toBe(true); + }); + + it.each([ + [4, { type: "modular-line", cells: [0, 1] }], + [4, { type: "entropic-line", cells: [0, 1, 2] }], + [6, { type: "entropic-line", cells: [0, 1] }], + [4, { type: "zipper-line", cells: [0, 1, 2, 3] }], + [4, { type: "zipper-line", cells: [0, 1] }], + [4, { type: "double-arrow", cells: [0, 1] }], + [4, { type: "indexer", kind: "diagonal", cell: 0 }], + [4, { type: "indexer", kind: "row", cell: 16 }], + [4, { type: "fog", lights: [], revealRadius: 1 }], + [4, { type: "fog", lights: [0], revealRadius: 2 }], + ] as const)( + "rejects malformed Pack 3 data on size %i", + (size, constraint) => { + const result = validatePuzzle({ + ...puzzle(size), + solution: size === 4 ? solved4 : solved6, + constraints: [constraint], + }); + expect(result.valid).toBe(false); + expect( + result.issues.some(({ path }) => path.startsWith("constraints[0]")), + ).toBe(true); + }, + ); + + it("requires a complete valid trusted solution for fog", () => { + const withoutSolution = validatePuzzle( + puzzle(4, { constraints: [{ type: "fog", lights: [0] }] }), + ); + expect(withoutSolution.issues).toContainEqual({ + path: "constraints[0]", + message: "fog requires a complete trusted puzzle solution", + }); + expect( + validatePuzzle( + puzzle(4, { + solution: new Array(16).fill(0), + constraints: [{ type: "fog", lights: [0] }], + }), + ).valid, + ).toBe(false); + }); + + it("rejects box indexers on custom regions while row and column remain valid", () => { + const jigsaw = [0, 0, 1, 1, 0, 2, 2, 1, 0, 2, 3, 1, 2, 3, 3, 3]; + expect( + validatePuzzle( + puzzle(4, { + regions: jigsaw, + constraints: [{ type: "indexer", kind: "box", cell: 0 }], + }), + ).issues, + ).toContainEqual({ + path: "constraints[0]", + message: "box indexers require the standard rectangular box layout", + }); + expect( + validatePuzzle( + puzzle(4, { + regions: jigsaw, + constraints: [{ type: "indexer", kind: "row", cell: 0 }], + }), + ).valid, + ).toBe(true); + }); +}); + +describe("Pack 3 line semantics", () => { + it("enforces modular residue windows and their negation", () => { + const constraint = { + type: "modular-line", + cells: [0, 1, 2, 3], + } as const; + const partial = new Array(16).fill(0); + partial[0] = 1; + partial[1] = 2; + expect( + candidatesForCell(puzzle(4, { constraints: [constraint] }), partial, 2), + ).toEqual([3]); + expect(constraintIsFeasible(constraint, solved4, 4)).toBe(true); + expect( + constraintIsFeasible({ ...constraint, negated: true }, solved4, 4), + ).toBe(false); + const invalid = [1, 2, 4, 1, ...new Array(12).fill(0)]; + expect(constraintIsFeasible(constraint, invalid, 4)).toBe(false); + expect( + constraintIsFeasible({ ...constraint, negated: true }, invalid, 4), + ).toBe(true); + }); + + it("enforces equal entropic bands on divisible grids", () => { + const constraint = { + type: "entropic-line", + cells: [0, 1, 2], + } as const; + const partial = new Array(36).fill(0); + partial[0] = 1; + partial[1] = 3; + expect( + candidatesForCell(puzzle(6, { constraints: [constraint] }), partial, 2), + ).toEqual([5, 6]); + const valid = [1, 3, 5, ...new Array(33).fill(0)]; + const invalid = [1, 2, 5, ...new Array(33).fill(0)]; + expect(constraintIsFeasible(constraint, valid, 6)).toBe(true); + expect(constraintIsFeasible(constraint, invalid, 6)).toBe(false); + expect( + constraintIsFeasible({ ...constraint, negated: true }, valid, 6), + ).toBe(false); + expect( + constraintIsFeasible({ ...constraint, negated: true }, invalid, 6), + ).toBe(true); + }); + + it("enforces zipper pair sums around the centre", () => { + const constraint = { type: "zipper-line", cells: [0, 1, 2] } as const; + const partial = new Array(16).fill(0); + partial[0] = 1; + partial[1] = 3; + expect( + candidatesForCell(puzzle(4, { constraints: [constraint] }), partial, 2), + ).toEqual([2]); + const valid = [1, 3, 2, ...new Array(13).fill(0)]; + const invalid = [1, 3, 3, ...new Array(13).fill(0)]; + expect(constraintIsFeasible(constraint, valid, 4)).toBe(true); + expect(constraintIsFeasible(constraint, invalid, 4)).toBe(false); + expect( + constraintIsFeasible({ ...constraint, negated: true }, valid, 4), + ).toBe(false); + expect( + constraintIsFeasible({ ...constraint, negated: true }, invalid, 4), + ).toBe(true); + const impossibleCentre = new Array(16).fill(0); + impossibleCentre[1] = 1; + expect(constraintIsFeasible(constraint, impossibleCentre, 4)).toBe(false); + }); + + it("balances double-arrow endpoints against all interior digits", () => { + const constraint = { type: "double-arrow", cells: [0, 1, 2] } as const; + const partial = new Array(16).fill(0); + partial[0] = 1; + partial[2] = 2; + expect( + candidatesForCell(puzzle(4, { constraints: [constraint] }), partial, 1), + ).toEqual([3]); + const valid = [1, 3, 2, ...new Array(13).fill(0)]; + const invalid = [1, 4, 2, ...new Array(13).fill(0)]; + expect(constraintIsFeasible(constraint, valid, 4)).toBe(true); + expect(constraintIsFeasible(constraint, invalid, 4)).toBe(false); + expect( + constraintIsFeasible({ ...constraint, negated: true }, valid, 4), + ).toBe(false); + expect( + constraintIsFeasible({ ...constraint, negated: true }, invalid, 4), + ).toBe(true); + }); + + it("keeps sound partial bounds when peer rules may narrow remaining digits", () => { + const empty = new Array(16).fill(0); + expect( + constraintIsFeasible( + { type: "zipper-line", cells: [0, 1, 2, 3, 4] }, + empty, + 4, + ), + ).toBe(true); + expect( + constraintIsFeasible( + { type: "double-arrow", cells: [0, 1, 2, 3] }, + empty, + 4, + ), + ).toBe(true); + expect( + constraintIsFeasible( + { type: "modular-line", cells: [0, 1, 2], negated: true }, + empty, + 4, + ), + ).toBe(true); + expect( + constraintIsFeasible( + { type: "zipper-line", cells: [0, 1, 2], negated: true }, + empty, + 4, + ), + ).toBe(true); + expect( + constraintIsFeasible( + { type: "double-arrow", cells: [0, 1, 2], negated: true }, + empty, + 4, + ), + ).toBe(true); + expect( + constraintIsFeasible( + { type: "indexer", kind: "row", cell: 0, negated: true }, + empty, + 4, + ), + ).toBe(true); + }); +}); + +describe("Pack 3 indexers and fog", () => { + it("resolves row, column and rectangular-box targets", () => { + const row = { type: "indexer", kind: "row", cell: 1 } as const; + const rowValues = new Array(16).fill(0); + rowValues[1] = 2; + expect( + candidatesForCell(puzzle(4, { constraints: [row] }), rowValues, 5), + ).toEqual([1]); + + const column = { type: "indexer", kind: "column", cell: 5 } as const; + expect(constraintIsFeasible(column, solved4, 4)).toBe(true); + + const box = { type: "indexer", kind: "box", cell: 1 } as const; + const boxValues = new Array(16).fill(0); + boxValues[1] = 2; + expect( + candidatesForCell(puzzle(4, { constraints: [box] }), boxValues, 3), + ).toEqual([1]); + boxValues[3] = 2; + expect(constraintIsFeasible(box, boxValues, 4)).toBe(false); + expect(constraintIsFeasible({ ...box, negated: true }, boxValues, 4)).toBe( + true, + ); + + // In a 2x3-box grid, r2c5 has within-box position 5. Digit 3 points at + // the same position in box 3 (r4c2), which must contain box index 2. + const rectangularBox = { + type: "indexer", + kind: "box", + cell: 10, + } as const; + const rectangularValues = new Array(36).fill(0); + rectangularValues[10] = 3; + expect( + candidatesForCell( + puzzle(6, { constraints: [rectangularBox] }), + rectangularValues, + 19, + ), + ).toEqual([2]); + }); + + it("keeps fog entirely non-semantic in exact search", () => { + const givens = [1, 0, 0, 4, 0, 4, 1, 0, 4, 0, 2, 0, 0, 1, 0, 3]; + const plain = solveExact(puzzle(4, { givens })); + const fogged = solveExact( + puzzle(4, { + givens, + solution: solved4, + constraints: [{ type: "fog", lights: [0], revealRadius: 1 }], + }), + ); + expect(fogged.solutions).toEqual(plain.solutions); + expect(fogged.count).toBe(plain.count); + }); +}); + +describe("Pack 3 exact solving", () => { + it.each( + semantic4.map((constraint) => [constraint.type, constraint] as const), + )("solves a 4x4 puzzle containing %s", (_type, constraint) => { + const givens: number[] = [...solved4]; + givens[5] = 0; + givens[10] = 0; + const result = solveExact(puzzle(4, { givens, constraints: [constraint] })); + expect(result.count).toBe(1); + expect(result.solutions[0]).toEqual(solved4); + }); + + it("solves a 6x6 puzzle with an entropic line", () => { + const givens: number[] = [...solved6]; + givens[2] = 0; + givens[20] = 0; + const result = solveExact( + puzzle(6, { + givens, + constraints: [{ type: "entropic-line", cells: [0, 2, 4] }], + }), + ); + expect(result.count).toBe(1); + expect(result.solutions[0]).toEqual(solved6); + }); +}); diff --git a/tests/formats/document.test.ts b/tests/formats/document.test.ts index 2d31bbe..f6805fc 100644 --- a/tests/formats/document.test.ts +++ b/tests/formats/document.test.ts @@ -98,9 +98,10 @@ describe("Sudoku Tools document format", () => { }; const hash = encodePuzzleHash(source); expect(hash).toMatch(/^#sudoku=v1\./u); - expect(decodePuzzleHash(`https://example.invalid/tools/${hash}`)).toEqual( - source, - ); + expect(decodePuzzleHash(`https://example.invalid/tools/${hash}`)).toEqual({ + ...source, + source: { format: "sudoku-tools" }, + }); }); it("round-trips grids for every supported size and symbol", () => { diff --git a/tests/formats/fpuzzles.test.ts b/tests/formats/fpuzzles.test.ts index 43f674b..ba7646e 100644 --- a/tests/formats/fpuzzles.test.ts +++ b/tests/formats/fpuzzles.test.ts @@ -115,6 +115,63 @@ describe("fpuzzles interoperability", () => { expect(imported.constraints).toEqual(expect.arrayContaining(expected)); }); + it("round-trips the registered expansion-pack constraints", () => { + const solution = [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 4, 5, 6, 7, 8, 9, 1, 2, 3, 7, 8, 9, 1, 2, 3, 4, + 5, 6, 2, 3, 4, 5, 6, 7, 8, 9, 1, 5, 6, 7, 8, 9, 1, 2, 3, 4, 8, 9, 1, 2, 3, + 4, 5, 6, 7, 3, 4, 5, 6, 7, 8, 9, 1, 2, 6, 7, 8, 9, 1, 2, 3, 4, 5, 9, 1, 2, + 3, 4, 5, 6, 7, 8, + ]; + const source: SudokuDocument = { + schema: SUDOKU_DOCUMENT_SCHEMA, + version: 1, + size: 9, + givens: Array(81).fill(0), + solution, + constraints: [ + { type: "disjoint-groups" }, + { type: "minimum", cell: 0 }, + { type: "odd", cell: 1 }, + { type: "even", cell: 2 }, + { + type: "little-killer", + side: "top", + index: 0, + direction: "down-right", + sum: 45, + }, + { type: "sandwich", side: "left", index: 0, sum: 20 }, + { type: "between-line", cells: [0, 1, 2] }, + { type: "german-whisper", cells: [9, 10], minimumDifference: 4 }, + { type: "region-sum-line", cells: [18, 19, 20, 21] }, + { type: "clone", cells: [27, 28], cloneCells: [36, 37] }, + { + type: "extra-region", + cells: [0, 10, 20, 30, 40, 50, 60, 70, 80], + }, + { type: "modular-line", cells: [45, 46, 47] }, + { type: "entropic-line", cells: [54, 55, 56] }, + { type: "zipper-line", cells: [63, 64, 65] }, + { type: "double-arrow", cells: [72, 73, 74] }, + { type: "indexer", kind: "row", cell: 3 }, + { type: "indexer", kind: "column", cell: 4 }, + { type: "indexer", kind: "box", cell: 5 }, + { type: "fog", lights: [0, 40], revealRadius: 1 }, + ], + }; + + const imported = parseFpuzzles(exportFpuzzles(source)); + expect(imported.constraints).toEqual( + expect.arrayContaining( + source.constraints.map((constraint) => + constraint.type === "fog" + ? { type: "fog", lights: constraint.lights } + : constraint, + ), + ), + ); + }); + it("recognizes server-only short puzzle IDs without making a request", () => { expect(() => importFpuzzles("https://sudokupad.app/abc123")).toThrowError( NetworkPuzzleIdError, @@ -220,9 +277,9 @@ describe("fpuzzles interoperability", () => { parseFpuzzles({ size: 9, grid: emptyGrid(), - odd: [{ cell: "R1C1" }], + nabner: [{ lines: [["R1C1", "R1C2"]] }], }), - ).toThrow(/odd cells.*silently weakening/u); + ).toThrow(/Nabner lines.*silently weakening/u); expect(() => parseFpuzzles({ size: 9, diff --git a/tests/formats/interoperability.test.ts b/tests/formats/interoperability.test.ts index 813a962..84cd1e7 100644 --- a/tests/formats/interoperability.test.ts +++ b/tests/formats/interoperability.test.ts @@ -2,7 +2,6 @@ import { compressToBase64 } from "lz-string"; import { describe, expect, it, vi } from "vitest"; import { NetworkPuzzleIdError, - UnsupportedPuzzleConstructsError, importPenpa, importPuzzle, importSudokuPad, @@ -91,27 +90,38 @@ describe("local puzzle interoperability", () => { expect(detected.document.givens[0]).toBe(1); }); - it("rejects visual-only SCL constructs instead of weakening them", () => { - expect(() => - parseSudokuPadPuzzle({ - ...sclPuzzle(), - lines: [ - { - wayPoints: [ - [0.5, 0.5], - [1.5, 1.5], - ], - }, - ], - overlays: [{ text: "?" }], - }), - ).toThrow(UnsupportedPuzzleConstructsError); + it("preserves allowlisted SCL drawings without treating them as rules", async () => { + const source = { + ...sclPuzzle(), + lines: [ + { + wayPoints: [ + [0.5, 0.5], + [1.5, 1.5], + ], + }, + ], + overlays: [{ center: [1, 1], width: 1, height: 1, text: "?" }], + }; + const parsed = parseSudokuPadPuzzle(source); + const imported = await importPuzzle(JSON.stringify(source)); + + expect(parsed.visuals).toHaveLength(3); + expect(parsed.source).toEqual({ format: "sudokupad", id: "local-scl" }); + expect(imported.preview.preservedVisuals).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: "overlay:polyline", count: 1 }), + expect.objectContaining({ key: "overlay:text", count: 1 }), + ]), + ); + expect(imported.preview.warnings.join(" ")).toMatch(/not solver-enforced/u); + expect(() => parseSudokuPadPuzzle({ ...sclPuzzle(), lines: [{}], }), - ).toThrow(/visual lines/u); + ).toThrow(/wayPoints|bounded/u); }); it("parses semantic Penpa+ Sudoku layers and local progress", () => { diff --git a/tests/formats/sourcePreservation.test.ts b/tests/formats/sourcePreservation.test.ts new file mode 100644 index 0000000..58e3d89 --- /dev/null +++ b/tests/formats/sourcePreservation.test.ts @@ -0,0 +1,360 @@ +import { describe, expect, it } from "vitest"; +import { normalizePuzzle } from "../../src/domain"; +import { + MAX_VISUAL_POINTS, + MAX_VISUAL_PRIMITIVES, + SUDOKU_DOCUMENT_SCHEMA, + cloneSudokuDocument, + exportFpuzzles, + exportSudokuPadJson, + exportSudokuPadPayload, + extractPreservedDocumentExtras, + fromDomainPuzzle, + importSudokuPad, + normalizeSudokuDocument, + parseFpuzzles, + parseSudokuPadPuzzle, + renderPuzzleSvg, + toDomainPuzzle, + type SafeVisualPrimitive, + type SudokuDocument, +} from "../../src/formats"; + +const REGIONS_4X4 = [0, 0, 1, 1, 0, 0, 1, 1, 2, 2, 3, 3, 2, 2, 3, 3] as const; + +function emptyCells(size = 4): Record[][] { + return Array.from({ length: size }, () => + Array.from({ length: size }, () => ({})), + ); +} + +function sourceVisuals(): SafeVisualPrimitive[] { + return [ + { + type: "line", + layer: "underlay", + start: { kind: "coordinate", x: 0, y: 0 }, + end: { kind: "coordinate", x: 4, y: 4 }, + style: { stroke: "#123456", strokeWidth: 0.04, opacity: 0.8 }, + }, + { + type: "polyline", + layer: "overlay", + points: [ + { kind: "cell", cell: 0 }, + { kind: "cell", cell: 1 }, + { kind: "cell", cell: 5 }, + ], + style: { stroke: "#abcdef", fill: "transparent" }, + }, + { + type: "rectangle", + layer: "underlay", + center: { kind: "cell", cell: 6 }, + width: 0.8, + height: 0.6, + cornerRadius: 0.1, + style: { fill: "#ffeedd", stroke: "#112233" }, + }, + { + type: "ellipse", + layer: "overlay", + center: { kind: "cell", cell: 9 }, + radiusX: 0.35, + radiusY: 0.2, + style: { fill: "transparent", stroke: "#445566" }, + }, + { + type: "circle", + layer: "overlay", + center: { kind: "cell", cell: 10 }, + radius: 0.25, + style: { fill: "#778899", opacity: 0.5 }, + }, + { + type: "text", + layer: "overlay", + position: { kind: "cell", cell: 15 }, + text: "safe label", + style: { fill: "#010203", fontSize: 0.4 }, + }, + ]; +} + +function sourceDocument(): SudokuDocument { + return { + schema: SUDOKU_DOCUMENT_SCHEMA, + version: 1, + size: 4, + givens: [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + values: [1, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + cornerMarks: Array.from({ length: 16 }, (_unused, cell) => + cell === 2 ? [3, 4] : [], + ), + centerMarks: Array.from({ length: 16 }, (_unused, cell) => + cell === 3 ? [2, 3] : [], + ), + elapsedMs: 12_345, + regions: REGIONS_4X4, + constraints: [ + { type: "killer-cage", cells: [0, 1], sum: 3, noRepeat: true }, + { type: "thermo", cells: [4, 5] }, + ], + title: "Preserved fixture", + author: "Local setter", + id: "domain-id", + visuals: sourceVisuals(), + source: { format: "sudokupad", id: "source-id", version: "1" }, + metadata: { edition: "nightly", featured: true, attempt: 2 }, + }; +} + +describe("source-preserving interoperability", () => { + it("clones and explicitly merges non-domain source extras", () => { + const source = normalizeSudokuDocument(sourceDocument()); + const clone = cloneSudokuDocument(source); + const extras = extractPreservedDocumentExtras(source); + const domain = normalizePuzzle(toDomainPuzzle(source)); + const merged = fromDomainPuzzle(domain, extras); + + expect(clone.visuals).toEqual(source.visuals); + expect(clone.visuals).not.toBe(source.visuals); + expect(clone.source).not.toBe(source.source); + expect(clone.metadata).not.toBe(source.metadata); + expect(merged.visuals).toEqual(source.visuals); + expect(merged.source).toEqual(source.source); + expect(merged.metadata).toEqual(source.metadata); + }); + + it("round-trips f-puzzles decorative primitives and source scalars", () => { + const grid = emptyCells(); + grid[0]![0] = { value: 1, given: true }; + const parsed = parseFpuzzles({ + id: "fp-source", + size: 4, + grid, + successMessage: "Done", + line: [ + { + lines: [["R1C1", "R1C2"]], + outlineC: "#123456", + width: 0.1, + }, + ], + rectangle: [ + { + cells: ["R2C2"], + width: 0.8, + height: 0.6, + baseC: "#abcdef", + }, + ], + circle: [ + { + cells: ["R3C3"], + width: 0.5, + height: 0.5, + outlineC: "#112233", + }, + ], + text: [{ cells: ["R4C4"], value: "A&B", fontC: "#445566" }], + }); + + expect(parsed.visuals?.map(({ type }) => type)).toEqual([ + "polyline", + "rectangle", + "circle", + "text", + ]); + expect(parsed.source).toEqual({ format: "fpuzzles", id: "fp-source" }); + expect(parsed.metadata).toEqual({ successMessage: "Done" }); + + const exported = exportFpuzzles(parsed); + expect(exported.id).toBe("fp-source"); + expect(exported.line).toBeInstanceOf(Array); + expect(exported.rectangle).toBeInstanceOf(Array); + expect(exported.circle).toBeInstanceOf(Array); + expect(exported.text).toBeInstanceOf(Array); + expect(exported.successMessage).toBe("Done"); + }); + + it("exports real SCL JSON and payload with progress and exact semantics", () => { + const source = sourceDocument(); + const json = exportSudokuPadJson(source, true); + const raw = JSON.parse(json) as Record; + const payload = exportSudokuPadPayload(source); + const importedJson = importSudokuPad(json); + const importedPayload = importSudokuPad(payload); + + expect(raw.id).toBe("source-id"); + expect(raw.cells).toBeInstanceOf(Array); + expect(raw.regions).toBeInstanceOf(Array); + expect(raw.cages).toBeInstanceOf(Array); + expect(raw.lines).toBeInstanceOf(Array); + expect(raw.underlays).toBeInstanceOf(Array); + expect(raw.overlays).toBeInstanceOf(Array); + expect(payload).toMatch(/^scl/u); + for (const imported of [importedJson, importedPayload]) { + expect(imported.constraints).toEqual(source.constraints); + expect(imported.values).toEqual(source.values); + expect(imported.cornerMarks).toEqual(source.cornerMarks); + expect(imported.centerMarks).toEqual(source.centerMarks); + expect(imported.elapsedMs).toBe(source.elapsedMs); + expect(imported.regions).toEqual(source.regions); + expect(imported.metadata).toEqual( + expect.objectContaining({ edition: "nightly", featured: true }), + ); + expect(imported.source).toEqual({ + format: "sudokupad", + id: "source-id", + }); + expect(imported.visuals?.length).toBeGreaterThanOrEqual( + source.visuals!.length, + ); + } + }); + + it("does not promote retained metadata into SudokuPad rule fields", () => { + const source: SudokuDocument = { + ...sourceDocument(), + constraints: [], + title: undefined, + author: undefined, + rules: undefined, + solution: undefined, + metadata: { + antiknight: true, + antiking: true, + nonconsecutive: true, + title: "not a title", + author: "not an author", + rules: "not a rule", + solution: "1234341221434321", + sudokuToolsConstraints: '[{"type":"anti-knight"}]', + edition: "retained", + }, + }; + const raw = JSON.parse(exportSudokuPadJson(source)) as { + metadata: Record; + }; + const imported = importSudokuPad(JSON.stringify(raw)); + + expect(raw.metadata).not.toHaveProperty("antiknight"); + expect(raw.metadata).not.toHaveProperty("antiking"); + expect(raw.metadata).not.toHaveProperty("nonconsecutive"); + expect(raw.metadata).not.toHaveProperty("title"); + expect(raw.metadata).not.toHaveProperty("author"); + expect(raw.metadata).not.toHaveProperty("rules"); + expect(raw.metadata).not.toHaveProperty("solution"); + expect(raw.metadata.edition).toBe("retained"); + expect(imported.constraints).toEqual([]); + expect(imported.title).toBeUndefined(); + expect(imported.solution).toBeUndefined(); + }); + + it.each(["onclick", "style", "html", "d", "href"])( + "rejects the executable or raw visual field %s", + (field) => { + expect(() => + parseSudokuPadPuzzle({ + cells: emptyCells(), + overlays: [ + { + center: [0.5, 0.5], + width: 1, + height: 1, + [field]: "javascript:alert(1)", + }, + ], + }), + ).toThrow(/not an allowlisted visual field/u); + }, + ); + + it("rejects unsafe colours and non-finite or out-of-bounds geometry", () => { + expect(() => + parseSudokuPadPuzzle({ + cells: emptyCells(), + lines: [ + { + wayPoints: [ + [0.5, 0.5], + [1.5, 1.5], + ], + color: "url(javascript:alert(1))", + }, + ], + }), + ).toThrow(/hexadecimal colour/u); + expect(() => + parseSudokuPadPuzzle({ + cells: emptyCells(), + overlays: [ + { center: [Number.POSITIVE_INFINITY, 0], width: 1, height: 1 }, + ], + }), + ).toThrow(/finite number/u); + expect(() => + parseSudokuPadPuzzle({ + cells: emptyCells(), + overlays: [{ center: [99, 99], width: 1, height: 1 }], + }), + ).toThrow(/finite number/u); + }); + + it("enforces primitive and aggregate point limits", () => { + const visual = sourceVisuals()[0]!; + expect(() => + normalizeSudokuDocument({ + ...sourceDocument(), + visuals: Array.from( + { length: MAX_VISUAL_PRIMITIVES + 1 }, + () => visual, + ), + }), + ).toThrow(/at most .* primitives/u); + + const points = Array.from({ length: MAX_VISUAL_POINTS / 2 + 1 }, () => ({ + kind: "coordinate" as const, + x: 0, + y: 0, + })); + expect(() => + normalizeSudokuDocument({ + ...sourceDocument(), + visuals: [ + { type: "polyline", layer: "overlay", points }, + { type: "polyline", layer: "overlay", points }, + ], + }), + ).toThrow(/more than .* anchors/u); + }); + + it("escapes imported text and emits no executable SVG attributes", () => { + const malicious = + ''; + const document = normalizeSudokuDocument({ + ...sourceDocument(), + visuals: [ + { + type: "text", + layer: "overlay", + position: { kind: "cell", cell: 0 }, + text: malicious, + style: { fill: "#000000" }, + }, + ], + }); + const svg = renderPuzzleSvg(document); + const parsed = new DOMParser().parseFromString(svg, "image/svg+xml"); + + expect(parsed.querySelector("parsererror")).toBeNull(); + expect(parsed.querySelector("script, image")).toBeNull(); + expect( + parsed.querySelector("[href], [src], [onclick], [onload]"), + ).toBeNull(); + expect(parsed.querySelector(".source-visual")?.textContent).toBe(malicious); + expect(svg).not.toContain("