From 8ca9300ab305962c3fa65194e1e961b314673e67 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sun, 30 Aug 2026 23:21:56 +0200 Subject: [PATCH] feat: expand sudoku analysis and interoperability --- CHANGELOG.md | 35 + README.md | 61 +- THIRD_PARTY_NOTICES.md | 10 +- public/CHANGELOG.md | 35 + public/README.md | 61 +- public/THIRD_PARTY_NOTICES.md | 10 +- src/components/AidMemoire.tsx | 378 ++++++++ src/components/CandidateLab.tsx | 408 ++++++++ src/components/ConstraintEditor.tsx | 396 +++++++- src/components/GameplayHistoryDialog.tsx | 295 ++++++ src/components/GeneratorWorkspace.tsx | 68 +- src/components/HelpDialog.tsx | 93 +- src/components/HelpersWorkspace.tsx | 343 ++++++- src/components/ImportExportDialog.tsx | 260 ++++-- src/components/SolveWorkspace.tsx | 16 +- src/components/SudokuBoard.tsx | 518 +++++++++-- src/components/Workbench.tsx | 630 +++++++++++-- src/data/samples.ts | 51 + src/domain/compile.ts | 40 +- src/domain/geometry.ts | 33 + src/domain/rules.ts | 379 +++++++- src/domain/types.ts | 53 +- src/domain/validation.ts | 252 ++++- src/formats/boundedLz.ts | 247 +++++ src/formats/document.ts | 142 ++- src/formats/fpuzzles.ts | 209 ++++- src/formats/import.ts | 189 ++++ src/formats/index.ts | 5 + src/formats/interoperability.ts | 37 + src/formats/penpa.ts | 581 ++++++++++++ src/formats/share.ts | 30 +- src/formats/sudokupad.ts | 489 ++++++++++ src/formats/types.ts | 73 +- src/formats/visual.ts | 669 ++++++++++++++ src/helpers/candidates.ts | 379 ++++++++ src/helpers/index.ts | 2 + src/helpers/sumLab.ts | 373 ++++++++ src/solver/exact.ts | 16 + src/solver/logical.ts | 3 +- src/solver/variantGenerator.ts | 86 +- src/state/aidMemoire.ts | 404 ++++++++ src/state/gridNavigation.ts | 54 ++ src/state/playHistory.ts | 352 +++++++ src/storage/record.ts | 26 + src/storage/types.ts | 3 +- src/styles.css | 870 +++++++++++++++++- tests/browser/workbench.spec.ts | 111 +++ tests/components/aidMemoire.test.tsx | 148 +++ tests/components/aidMemoireWorkbench.test.tsx | 139 +++ tests/components/candidateLab.test.tsx | 100 ++ .../gameplayHistoryWorkbench.test.tsx | 104 +++ tests/components/generatorWorkspace.test.tsx | 38 + tests/components/helpersWorkspace.test.tsx | 117 +++ tests/components/importExportDialog.test.tsx | 186 ++++ tests/components/solveWorkspace.test.tsx | 44 + tests/components/sudokuBoard.test.tsx | 176 +++- tests/components/workbench.test.tsx | 144 +++ tests/data/samples.test.ts | 19 +- tests/domain/extraBusyConstraints.test.ts | 505 ++++++++++ tests/formats/aidMemoireDocument.test.ts | 75 ++ tests/formats/document.test.ts | 31 +- tests/formats/fpuzzles.test.ts | 95 ++ tests/formats/importSecurity.test.ts | 160 ++++ tests/formats/interoperability.test.ts | 175 ++++ tests/formats/visual.test.ts | 117 +++ tests/helpers/candidates.test.ts | 97 ++ tests/helpers/sumLab.test.ts | 146 +++ tests/solver/logical.test.ts | 22 + tests/solver/variantGenerator.test.ts | 29 + tests/state/aidMemoire.test.ts | 149 +++ tests/state/gridNavigation.test.ts | 27 + tests/state/playHistory.test.ts | 176 ++++ tests/storage/aidMemoireProgress.test.ts | 72 ++ 73 files changed, 12482 insertions(+), 384 deletions(-) create mode 100644 src/components/AidMemoire.tsx create mode 100644 src/components/CandidateLab.tsx create mode 100644 src/components/GameplayHistoryDialog.tsx create mode 100644 src/formats/boundedLz.ts create mode 100644 src/formats/import.ts create mode 100644 src/formats/interoperability.ts create mode 100644 src/formats/penpa.ts create mode 100644 src/formats/sudokupad.ts create mode 100644 src/formats/visual.ts create mode 100644 src/helpers/candidates.ts create mode 100644 src/helpers/sumLab.ts create mode 100644 src/state/aidMemoire.ts create mode 100644 src/state/gridNavigation.ts create mode 100644 src/state/playHistory.ts create mode 100644 tests/components/aidMemoire.test.tsx create mode 100644 tests/components/aidMemoireWorkbench.test.tsx create mode 100644 tests/components/candidateLab.test.tsx create mode 100644 tests/components/gameplayHistoryWorkbench.test.tsx create mode 100644 tests/components/helpersWorkspace.test.tsx create mode 100644 tests/components/importExportDialog.test.tsx create mode 100644 tests/components/solveWorkspace.test.tsx create mode 100644 tests/domain/extraBusyConstraints.test.ts create mode 100644 tests/formats/aidMemoireDocument.test.ts create mode 100644 tests/formats/importSecurity.test.ts create mode 100644 tests/formats/interoperability.test.ts create mode 100644 tests/formats/visual.test.ts create mode 100644 tests/helpers/candidates.test.ts create mode 100644 tests/helpers/sumLab.test.ts create mode 100644 tests/state/aidMemoire.test.ts create mode 100644 tests/state/gridNavigation.test.ts create mode 100644 tests/state/playHistory.test.ts create mode 100644 tests/storage/aidMemoireProgress.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index efc7a58..a30e3ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,41 @@ All notable changes are documented here. constraint family. - Added bounded, seedable generation for classic and twelve variant families, plus independent uniqueness and evidence-based difficulty assessment. +- Added X-sum, skyscraper, quadruple and maximum-cell constraints across the + model, validator, exact solver, renderer, setter and f-puzzles interchange. +- Added per-clue false semantics for liar/Wrogn puzzles, including bounded + impossible decoy numbers and a setter-wide false-clue mode. +- Added the original, uniquely verified “Truth and lies” example demonstrating + true and false outside, inclusion, maximum, cage, line and pair clues. +- Added outside-grid clue margins, combined dual X-sum/skyscraper labels, + outward maximum arrows and visually distinct false clues. +- Added strict, bounded local imports for SudokuPad/CTC data and supported + Penpa+ long links, including source reporting and explicit incompatibility + lists instead of silently discarded constructs. +- 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. +- Added named savepoints, isolated hypothesis branches, keep/discard decisions + and read-only replay of complete solving states. +- Added an optional configurable aid-mémoire whose labelled scratch cells + support values, both note styles and colours without constraining the puzzle; + its state is preserved in undo, replay, Library progress and project/share + interchange. +- Added a generalized Sum Lab with independent digit bounds, repeats, + required/excluded digits, selected-cell candidates, positional assignments + and reversible manual eliminations. +- Added Candidate Lab filters, selected/touching/specific-house scopes, house + positions, strong conjugate/bivalue links, weak links and bounded board + overlays. +- Added technique-targeted practice generation which mines a bounded, + deterministic set of uniquely checked puzzles and verifies the requested + technique in the logical solve path. +- Added real ARIA row/gridcell semantics, detailed cell descriptions and + non-wrapping Arrow, Home/End, Control/Command + Home/End and Page Up/Down + keyboard navigation for the puzzle and scratch grids. +- Made Killer cage repeat semantics explicit in the setter and independent of + whether a clue is required to be true or false. ## 0.1.0 - 2026-08-30 diff --git a/README.md b/README.md index 941c390..7e8418c 100644 --- a/README.md +++ b/README.md @@ -8,33 +8,63 @@ release also runs independently from any static HTTPS host or local preview. ## Workspaces - **Play** — keyboard, mouse and touch entry; multi-cell selection; values, - corner/centre notes and colours; undo/redo; conflict highlighting; timer and - local progress; optional digit-completion counts and matching-digit - highlights. + 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. + assessment; deterministic mining for a requested logical technique. - **Solve** — exact solution counting plus an original human-style engine whose steps include structured evidence, placements and eliminations. -- **Helpers** — Killer combinations and candidate-aware assignments, 45-rule - residuals, and Kropki, XV or inequality relation pairs. +- **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, anti-knight, -anti-king and non-consecutive rules. Thirteen bundled, uniquely checked examples -demonstrate every supported constraint. Import accepts compact grid strings, -this project's JSON format, share fragments, and the common f-puzzles fields -supported by the model. Unknown constraints are reported rather than silently -discarded. +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. + +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. + +## 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. + +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. + +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. 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 @@ -46,7 +76,8 @@ Exported files and share URLs contain the puzzle data you chose to include. SudokuPad short IDs identify server-hosted records and therefore cannot be resolved by this deliberately offline application. Embedded f-puzzles payloads -are accepted without contacting their originating site. +and user-provided Sudoku Tools JSON documents are accepted without contacting +their originating site. Externally authored puzzle definitions are not bundled. ## Development @@ -76,8 +107,8 @@ browser workflows and Toolbox contract before creating a deterministic The interaction ideas and published solving concepts explored by SudokuPad, SudokuWiki and the wider puzzle community informed the product requirements. -No code, artwork, wording or proprietary puzzle data from those applications is -included. Algorithms and explanations in this repository are independently +No externally authored puzzle definitions, code, artwork or documentation text +is bundled. Algorithms, UI, original examples and explanations are independently implemented from mathematical rules and public standards. ## Licence diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index ad56c53..f7ca811 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -16,4 +16,12 @@ runtime package. Development-only packages build and test the source but are not shipped as runtime modules. SudokuPad, SudokuWiki and HoDoKu are research references only. Their source, -documentation, wording, visual assets and puzzle data are not bundled or copied. +documentation wording, visual assets and externally authored puzzle data are +not bundled or copied. + +The local compatibility code was tested against the open format behaviour in +[Penpa+](https://github.com/swaroopg92/penpa-edit) (MIT), +[penpa-to-scl](https://github.com/marktekfan/penpa-to-scl) (MIT), and +[SudokuPad sudokutools](https://github.com/SudokuPad/sudokutools) (MPL-2.0). +Those projects are format references only: no upstream executable code, +artwork, documentation text or puzzle data is distributed with Sudoku Tools. diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md index efc7a58..a30e3ee 100644 --- a/public/CHANGELOG.md +++ b/public/CHANGELOG.md @@ -16,6 +16,41 @@ All notable changes are documented here. constraint family. - Added bounded, seedable generation for classic and twelve variant families, plus independent uniqueness and evidence-based difficulty assessment. +- Added X-sum, skyscraper, quadruple and maximum-cell constraints across the + model, validator, exact solver, renderer, setter and f-puzzles interchange. +- Added per-clue false semantics for liar/Wrogn puzzles, including bounded + impossible decoy numbers and a setter-wide false-clue mode. +- Added the original, uniquely verified “Truth and lies” example demonstrating + true and false outside, inclusion, maximum, cage, line and pair clues. +- Added outside-grid clue margins, combined dual X-sum/skyscraper labels, + outward maximum arrows and visually distinct false clues. +- Added strict, bounded local imports for SudokuPad/CTC data and supported + Penpa+ long links, including source reporting and explicit incompatibility + lists instead of silently discarded constructs. +- 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. +- Added named savepoints, isolated hypothesis branches, keep/discard decisions + and read-only replay of complete solving states. +- Added an optional configurable aid-mémoire whose labelled scratch cells + support values, both note styles and colours without constraining the puzzle; + its state is preserved in undo, replay, Library progress and project/share + interchange. +- Added a generalized Sum Lab with independent digit bounds, repeats, + required/excluded digits, selected-cell candidates, positional assignments + and reversible manual eliminations. +- Added Candidate Lab filters, selected/touching/specific-house scopes, house + positions, strong conjugate/bivalue links, weak links and bounded board + overlays. +- Added technique-targeted practice generation which mines a bounded, + deterministic set of uniquely checked puzzles and verifies the requested + technique in the logical solve path. +- Added real ARIA row/gridcell semantics, detailed cell descriptions and + non-wrapping Arrow, Home/End, Control/Command + Home/End and Page Up/Down + keyboard navigation for the puzzle and scratch grids. +- Made Killer cage repeat semantics explicit in the setter and independent of + whether a clue is required to be true or false. ## 0.1.0 - 2026-08-30 diff --git a/public/README.md b/public/README.md index 941c390..7e8418c 100644 --- a/public/README.md +++ b/public/README.md @@ -8,33 +8,63 @@ release also runs independently from any static HTTPS host or local preview. ## Workspaces - **Play** — keyboard, mouse and touch entry; multi-cell selection; values, - corner/centre notes and colours; undo/redo; conflict highlighting; timer and - local progress; optional digit-completion counts and matching-digit - highlights. + 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. + assessment; deterministic mining for a requested logical technique. - **Solve** — exact solution counting plus an original human-style engine whose steps include structured evidence, placements and eliminations. -- **Helpers** — Killer combinations and candidate-aware assignments, 45-rule - residuals, and Kropki, XV or inequality relation pairs. +- **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, anti-knight, -anti-king and non-consecutive rules. Thirteen bundled, uniquely checked examples -demonstrate every supported constraint. Import accepts compact grid strings, -this project's JSON format, share fragments, and the common f-puzzles fields -supported by the model. Unknown constraints are reported rather than silently -discarded. +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. + +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. + +## 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. + +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. + +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. 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 @@ -46,7 +76,8 @@ Exported files and share URLs contain the puzzle data you chose to include. SudokuPad short IDs identify server-hosted records and therefore cannot be resolved by this deliberately offline application. Embedded f-puzzles payloads -are accepted without contacting their originating site. +and user-provided Sudoku Tools JSON documents are accepted without contacting +their originating site. Externally authored puzzle definitions are not bundled. ## Development @@ -76,8 +107,8 @@ browser workflows and Toolbox contract before creating a deterministic The interaction ideas and published solving concepts explored by SudokuPad, SudokuWiki and the wider puzzle community informed the product requirements. -No code, artwork, wording or proprietary puzzle data from those applications is -included. Algorithms and explanations in this repository are independently +No externally authored puzzle definitions, code, artwork or documentation text +is bundled. Algorithms, UI, original examples and explanations are independently implemented from mathematical rules and public standards. ## Licence diff --git a/public/THIRD_PARTY_NOTICES.md b/public/THIRD_PARTY_NOTICES.md index ad56c53..f7ca811 100644 --- a/public/THIRD_PARTY_NOTICES.md +++ b/public/THIRD_PARTY_NOTICES.md @@ -16,4 +16,12 @@ runtime package. Development-only packages build and test the source but are not shipped as runtime modules. SudokuPad, SudokuWiki and HoDoKu are research references only. Their source, -documentation, wording, visual assets and puzzle data are not bundled or copied. +documentation wording, visual assets and externally authored puzzle data are +not bundled or copied. + +The local compatibility code was tested against the open format behaviour in +[Penpa+](https://github.com/swaroopg92/penpa-edit) (MIT), +[penpa-to-scl](https://github.com/marktekfan/penpa-to-scl) (MIT), and +[SudokuPad sudokutools](https://github.com/SudokuPad/sudokutools) (MPL-2.0). +Those projects are format references only: no upstream executable code, +artwork, documentation text or puzzle data is distributed with Sudoku Tools. diff --git a/src/components/AidMemoire.tsx b/src/components/AidMemoire.tsx new file mode 100644 index 0000000..c477e11 --- /dev/null +++ b/src/components/AidMemoire.tsx @@ -0,0 +1,378 @@ +import { useRef, type CSSProperties, type KeyboardEvent } from "react"; +import { + aidMemoireCellDescription, + clearAidMemoireEntries, + compactAidMemoireColumns, + configureAidMemoire, + enterAidMemoireCell, + eraseAidMemoireCell, + labelAidMemoireCell, + MAX_AID_MEMOIRE_CELLS, + MAX_AID_MEMOIRE_COLUMNS, + resetAidMemoire, + setAidMemoireEnabled, + type AidMemoireState, +} from "../state/aidMemoire"; +import { + maskValues, + symbolFor, + valueForKey, + type EntryMode, +} from "../state/session"; + +interface AidMemoireProps { + readonly size: number; + readonly state: AidMemoireState; + readonly selectedCell: number; + readonly active: boolean; + readonly readOnly?: boolean; + readonly mode: EntryMode; + readonly onStateChange: (state: AidMemoireState) => void; + readonly onSelect: (cell: number) => void; + readonly onMode: (mode: EntryMode) => void; +} + +const modes: ReadonlyArray<{ mode: EntryMode; label: string; key: string }> = [ + { mode: "value", label: "Value", key: "Z" }, + { mode: "corner", label: "Corner", key: "X" }, + { mode: "center", label: "Centre", key: "C" }, + { mode: "color", label: "Colour", key: "V" }, +]; + +export function AidMemoire({ + size, + state, + selectedCell, + active, + readOnly = false, + mode, + onStateChange, + onSelect, + onMode, +}: AidMemoireProps) { + const cellRefs = useRef>([]); + const selected = Math.max(0, Math.min(state.cells.length - 1, selectedCell)); + + const selectAndFocus = (cell: number) => { + const next = Math.max(0, Math.min(state.cells.length - 1, cell)); + onSelect(next); + cellRefs.current[next]?.focus(); + }; + + const handleKeyDown = ( + cell: number, + event: KeyboardEvent, + ) => { + const row = Math.floor(cell / state.columns); + const column = cell % state.columns; + const rowStart = row * state.columns; + const rowEnd = Math.min( + state.cells.length - 1, + rowStart + state.columns - 1, + ); + let destination: number | undefined; + if (event.key === "ArrowLeft") destination = Math.max(rowStart, cell - 1); + if (event.key === "ArrowRight") destination = Math.min(rowEnd, cell + 1); + if (event.key === "ArrowUp") { + destination = cell - state.columns >= 0 ? cell - state.columns : cell; + } + if (event.key === "ArrowDown") { + destination = + cell + state.columns < state.cells.length ? cell + state.columns : cell; + } + if (event.key === "Home") { + destination = event.ctrlKey || event.metaKey ? 0 : rowStart; + } + if (event.key === "End") { + destination = + event.ctrlKey || event.metaKey ? state.cells.length - 1 : rowEnd; + } + if (event.key === "PageUp") destination = column; + if (event.key === "PageDown") { + const lastRowStart = + Math.floor((state.cells.length - 1) / state.columns) * state.columns; + destination = Math.min(state.cells.length - 1, lastRowStart + column); + } + if (destination !== undefined) { + event.preventDefault(); + selectAndFocus(destination); + return; + } + if (readOnly) return; + if ( + event.key === "Backspace" || + event.key === "Delete" || + event.key === "0" + ) { + event.preventDefault(); + onStateChange(eraseAidMemoireCell(state, cell, mode)); + return; + } + if (!event.ctrlKey && !event.metaKey && !event.altKey) { + const modeKey: Record = { + z: "value", + x: "corner", + c: "center", + v: "color", + }; + const nextMode = modeKey[event.key.toLowerCase()]; + if (nextMode !== undefined) { + event.preventDefault(); + onMode(nextMode); + return; + } + const value = valueForKey(event.key, mode === "color" ? 8 : size); + if (value !== null) { + event.preventDefault(); + onStateChange(enterAidMemoireCell(state, cell, mode, value, size)); + } + } + }; + + const configure = (cellCount: number, columns: number) => { + const next = configureAidMemoire(state, size, cellCount, columns); + onStateChange(next); + if (selected >= next.cells.length) onSelect(next.cells.length - 1); + }; + + return ( +
+
+
+

Optional scratch cells

+

Aid-mémoire

+

+ Track contextual sets, pseudo-digits or mappings. These cells do not + constrain the Sudoku. +

+
+ +
+ +
+
+ {Array.from( + { length: Math.ceil(state.cells.length / state.columns) }, + (_, rowIndex) => ( +
+ {state.cells + .slice( + rowIndex * state.columns, + (rowIndex + 1) * state.columns, + ) + .map((cell, columnIndex) => { + const index = rowIndex * state.columns + columnIndex; + const cornerMarks = maskValues(cell.cornerMarks, size); + const centerMarks = maskValues(cell.centerMarks, size); + return ( + + ); + })} +
+ ), + )} +
+
+ +
+ {modes.map((item) => ( + + ))} +
+ +
+ +

+ The regular keypad edits this cell in {mode} mode. Digits, Z/X/C/V, + Delete and arrow keys also work directly in the scratch grid. +

+
+ +
+ Configure scratch layout +
+
+ + +
+
+ + + + +
+
+
+
+ ); +} diff --git a/src/components/CandidateLab.tsx b/src/components/CandidateLab.tsx new file mode 100644 index 0000000..2a3db7c --- /dev/null +++ b/src/components/CandidateLab.tsx @@ -0,0 +1,408 @@ +import { useEffect, useMemo, useState } from "react"; +import { compilePuzzle, type NormalizedPuzzle } from "../domain"; +import { + candidateCellsForValues, + candidateUnitIndicesForCells, + cellLabel, + deriveCandidateLinks, + houseLabel, + inspectCandidateCells, + inspectCandidateHouses, + type CandidateLink, + type CandidateOverlay, +} from "../helpers"; +import { symbolFor } from "../state/session"; + +const LINK_LIST_LIMIT = 160; +const OVERLAY_LINK_LIMIT = 500; + +interface CandidateLabProps { + readonly puzzle: NormalizedPuzzle; + readonly values: readonly number[]; + readonly candidateMasks: readonly number[]; + readonly selectedCells: readonly number[]; + readonly onOverlayChange?: (overlay: CandidateOverlay | undefined) => void; +} + +function nodeLabel(node: CandidateLink["a"], size: number): string { + return `${cellLabel(node.cell, size)}(${symbolFor(node.value, size)})`; +} + +function linkLabel(link: CandidateLink, size: number): string { + return `${nodeLabel(link.a, size)} ${link.kind === "strong" ? "⇔" : "—"} ${nodeLabel(link.b, size)}`; +} + +export function CandidateLab({ + puzzle, + values, + candidateMasks, + selectedCells, + onOverlayChange, +}: CandidateLabProps) { + const [activeValues, setActiveValues] = useState([]); + const [scope, setScope] = useState("selection"); + const [showStrong, setShowStrong] = useState(true); + const [showWeak, setShowWeak] = useState(false); + const [includeCellLinks, setIncludeCellLinks] = useState(true); + const [focusedLinkId, setFocusedLinkId] = useState(); + const compiled = useMemo(() => compilePuzzle(puzzle), [puzzle]); + const effectiveActiveValues = useMemo( + () => activeValues.filter((value) => value <= puzzle.size), + [activeValues, puzzle.size], + ); + const relatedUnitIndices = useMemo( + () => candidateUnitIndicesForCells(puzzle, selectedCells), + [puzzle, selectedCells], + ); + const selectedUnitIndex = scope.startsWith("unit:") + ? Number(scope.slice(5)) + : undefined; + const effectiveScope = + selectedUnitIndex !== undefined && + !relatedUnitIndices.includes(selectedUnitIndex) + ? "selection" + : scope; + const scopedUnitIndices = useMemo(() => { + if (effectiveScope === "all") + return compiled.units.map((_, index) => index); + if ( + selectedUnitIndex !== undefined && + Number.isInteger(selectedUnitIndex) && + compiled.units[selectedUnitIndex] !== undefined + ) { + return [selectedUnitIndex]; + } + return relatedUnitIndices; + }, [compiled.units, effectiveScope, relatedUnitIndices, selectedUnitIndex]); + const scopedCells = useMemo(() => { + if (effectiveScope === "selection") return [...new Set(selectedCells)]; + return [ + ...new Set( + scopedUnitIndices.flatMap( + (unitIndex) => compiled.units[unitIndex]?.cells ?? [], + ), + ), + ]; + }, [compiled.units, effectiveScope, scopedUnitIndices, selectedCells]); + const cellInspections = useMemo( + () => inspectCandidateCells(puzzle, candidateMasks, selectedCells), + [candidateMasks, puzzle, selectedCells], + ); + const houseInspections = useMemo( + () => + inspectCandidateHouses(puzzle, values, candidateMasks, scopedUnitIndices), + [candidateMasks, puzzle, scopedUnitIndices, values], + ); + const links = useMemo( + () => + deriveCandidateLinks(puzzle, candidateMasks, { + unitIndices: scopedUnitIndices, + cellIndices: scopedCells, + ...(effectiveActiveValues.length === 0 + ? {} + : { values: effectiveActiveValues }), + includeCellLinks, + }).filter(({ kind }) => (kind === "strong" ? showStrong : showWeak)), + [ + candidateMasks, + effectiveActiveValues, + includeCellLinks, + puzzle, + scopedCells, + scopedUnitIndices, + showStrong, + showWeak, + ], + ); + const focusedLink = links.find(({ id }) => id === focusedLinkId); + const visibleLinks = useMemo( + () => + focusedLink === undefined + ? links.slice(0, OVERLAY_LINK_LIMIT) + : [focusedLink], + [focusedLink, links], + ); + const candidateCells = useMemo(() => { + if (effectiveActiveValues.length === 0) return []; + return candidateCellsForValues(candidateMasks, effectiveActiveValues); + }, [candidateMasks, effectiveActiveValues]); + const overlay = useMemo( + () => ({ + activeValues: effectiveActiveValues, + candidateCells, + links: visibleLinks, + }), + [candidateCells, effectiveActiveValues, visibleLinks], + ); + + useEffect(() => { + onOverlayChange?.(overlay); + }, [onOverlayChange, overlay]); + useEffect( + () => () => { + onOverlayChange?.(undefined); + }, + [onOverlayChange], + ); + + const toggleValue = (value: number) => { + setFocusedLinkId(undefined); + setActiveValues((current) => { + const valid = current.filter((candidate) => candidate <= puzzle.size); + return valid.includes(value) + ? valid.filter((candidate) => candidate !== value) + : [...valid, value].sort((a, b) => a - b); + }); + }; + const strongCount = links.filter(({ kind }) => kind === "strong").length; + const weakCount = links.length - strongCount; + + return ( +
+
+

Candidate graph

+

Links and houses

+

+ Inspect legal candidates without changing any notes. A solid line is + strong (at least one endpoint is true); a dashed line is weak (both + endpoints cannot be true). +

+
+ +
+ Digit filters +
9 ? " candidate-filter__digits--wide" : ""}`} + > + + {Array.from({ length: puzzle.size }, (_, index) => index + 1).map( + (value) => ( + + ), + )} +
+
+ +
+ + + + +
+ +
+ + Candidate cells {candidateCells.length} + + + Strong links {strongCount} + + + Weak links {weakCount} + +
+ +
+

Selected cells

+
+ {cellInspections.map((inspection) => ( +
+ {cellLabel(inspection.cell, puzzle.size)} + + {inspection.values.length === 0 + ? values[inspection.cell] + ? `Filled ${symbolFor(values[inspection.cell]!, puzzle.size)}` + : "No legal candidates" + : inspection.values + .map((value) => symbolFor(value, puzzle.size)) + .join(" ")} + + {inspection.houseLabels.join(" · ")} +
+ ))} +
+
+ +
+

House positions

+
+ {houseInspections.map((inspection) => ( +
+ + {inspection.label} + {inspection.missingValues.length} missing + +
+ {inspection.positions + .filter( + ({ value }) => + effectiveActiveValues.length === 0 || + effectiveActiveValues.includes(value), + ) + .map((position) => ( +
+
{symbolFor(position.value, puzzle.size)}
+
+ {position.cells.length === 0 + ? "nowhere" + : position.cells + .map((cell) => cellLabel(cell, puzzle.size)) + .join(" · ")} + {position.linkKind === "strong" ? " · strong" : ""} +
+
+ ))} +
+
+ ))} +
+
+ +
+
+ + {focusedLink !== undefined && ( + + )} +
+ {links.length === 0 ? ( +

+ No links match this scope and filter. Enable weak links or inspect + another house. +

+ ) : ( +
+ {links.slice(0, LINK_LIST_LIMIT).map((link) => ( + + ))} +
+ )} + {links.length > LINK_LIST_LIMIT && ( +

+ Showing the first {LINK_LIST_LIMIT} of {links.length} links. Narrow + the digit or house filter to inspect the rest. +

+ )} + {focusedLink === undefined && links.length > OVERLAY_LINK_LIMIT && ( +

+ The board overlay is capped at {OVERLAY_LINK_LIMIT} links for + responsiveness. Focus a listed link or narrow the filters to see a + precise relationship. +

+ )} +
+
+ ); +} diff --git a/src/components/ConstraintEditor.tsx b/src/components/ConstraintEditor.tsx index 0ba5e27..6012a44 100644 --- a/src/components/ConstraintEditor.tsx +++ b/src/components/ConstraintEditor.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { cellsFormQuadruple } from "../domain/geometry"; import type { PuzzleDefinition, VariantConstraint } from "../domain/types"; import { removeKillerCagesAtCells, @@ -19,6 +20,10 @@ interface ConstraintEditorProps { function describeConstraint(constraint: VariantConstraint, size: number) { const cell = (index: number) => `r${String(Math.floor(index / size) + 1)}c${String((index % size) + 1)}`; + const marked = (description: string) => + "negated" in constraint && constraint.negated === true + ? `false · ${description}` + : description; switch (constraint.type) { case "diagonal": return `${constraint.direction} diagonal`; @@ -29,22 +34,67 @@ function describeConstraint(constraint: VariantConstraint, size: number) { case "non-consecutive": return "non-consecutive"; case "killer-cage": - return `${String(constraint.sum)} cage · ${String(constraint.cells.length)} cells`; + return marked( + `${String(constraint.sum)} cage · ${String(constraint.cells.length)} cells${constraint.noRepeat === false ? " · repeats allowed" : ""}`, + ); case "thermo": case "renban": case "palindrome": - return `${constraint.type} · ${String(constraint.cells.length)} cells`; + return marked( + `${constraint.type} · ${String(constraint.cells.length)} cells`, + ); case "arrow": - return `arrow · ${String(constraint.bulb.length)} bulb / ${String(constraint.line.length)} line`; + return marked( + `arrow · ${String(constraint.bulb.length)} bulb / ${String(constraint.line.length)} line`, + ); case "kropki": - return `${constraint.kind} dot · ${cell(constraint.a)}–${cell(constraint.b)}`; + return marked( + `${constraint.kind} dot · ${cell(constraint.a)}–${cell(constraint.b)}`, + ); case "xv": - return `sum ${String(constraint.total)} pair · ${cell(constraint.a)}–${cell(constraint.b)}`; + return marked( + `sum ${String(constraint.total)} pair · ${cell(constraint.a)}–${cell(constraint.b)}`, + ); case "inequality": - return `${cell(constraint.lesser)} < ${cell(constraint.greater)}`; + return marked(`${cell(constraint.lesser)} < ${cell(constraint.greater)}`); + case "x-sum": + return marked( + `Σ${String(constraint.sum)} · ${constraint.side} ${String(constraint.index + 1)}`, + ); + case "skyscraper": + return marked( + `skyscraper ${String(constraint.count)} · ${constraint.side} ${String(constraint.index + 1)}`, + ); + case "quadruple": + return marked( + `quadruple ${constraint.digits.join("")} · ${String(constraint.cells.length)} cells`, + ); + case "maximum": + return marked(`maximum · ${cell(constraint.cell)}`); } } +function supportsPolarity(constraint: VariantConstraint): boolean { + return !["diagonal", "anti-knight", "anti-king", "non-consecutive"].includes( + constraint.type, + ); +} + +function parseClueDigits(text: string, size: number): number[] { + const trimmed = text.trim(); + if (!trimmed) return []; + const rawTokens = trimmed.split(/[\s,;]+/u).filter(Boolean); + const tokens = + size <= 9 && rawTokens.length === 1 && /^\d{2,4}$/u.test(rawTokens[0]!) + ? [...rawTokens[0]!] + : rawTokens; + return tokens.map((token) => + /^[A-G]$/iu.test(token) + ? token.toUpperCase().charCodeAt(0) - 55 + : Number(token), + ); +} + export function ConstraintEditor({ puzzle, selection, @@ -55,23 +105,55 @@ export function ConstraintEditor({ busy, }: ConstraintEditorProps) { const [cageSum, setCageSum] = useState(10); + const [cageAllowsRepeats, setCageAllowsRepeats] = useState(false); 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 [outsideLine, setOutsideLine] = useState(1); + const [outsideValue, setOutsideValue] = useState(3); const constraints = puzzle.constraints ?? []; const append = (constraint: VariantConstraint) => - onChange({ ...puzzle, constraints: [...constraints, constraint] }); + onChange({ + ...puzzle, + constraints: [ + ...constraints, + supportsPolarity(constraint) && newCluesAreFalse + ? ({ ...constraint, negated: true } as VariantConstraint) + : constraint, + ], + }); const need = (count: number) => selection.length === count; const atLeast = (count: number) => selection.length >= count; const cageCellCount = Math.max(1, selection.length); - const minimumCageSum = (cageCellCount * (cageCellCount + 1)) / 2; - const maximumCageSum = - (cageCellCount * (2 * puzzle.size - cageCellCount + 1)) / 2; + const minimumCageSum = cageAllowsRepeats + ? cageCellCount + : (cageCellCount * (cageCellCount + 1)) / 2; + const maximumCageSum = cageAllowsRepeats + ? cageCellCount * puzzle.size + : (cageCellCount * (2 * puzzle.size - cageCellCount + 1)) / 2; const validCage = selection.length >= 1 && selection.length <= puzzle.size && Number.isInteger(cageSum) && - cageSum >= minimumCageSum && - cageSum <= maximumCageSum; + (newCluesAreFalse + ? cageSum >= 1 && cageSum <= puzzle.size ** 3 + : cageSum >= minimumCageSum && cageSum <= maximumCageSum); + const parsedQuadrupleDigits = parseClueDigits(quadrupleDigits, puzzle.size); + const validQuadruple = + cellsFormQuadruple(puzzle.size, selection) && + parsedQuadrupleDigits.length >= 1 && + parsedQuadrupleDigits.length <= 4 && + parsedQuadrupleDigits.length <= selection.length && + parsedQuadrupleDigits.every( + (digit) => Number.isInteger(digit) && digit >= 1 && digit <= puzzle.size, + ); const selectedCageExists = selectionTouchesKillerCage(constraints, selection); const toggleGlobal = ( @@ -154,13 +236,21 @@ export function ConstraintEditor({ Selection order defines lines and pair direction. Shift-click or drag to build a selection.

+
+

Adding replaces any cage touching the selection. Removing clears every cage touching a selected cell. @@ -316,7 +416,41 @@ export function ConstraintEditor({ > 1st > 2nd (inequality) + +

+ + +
+

+ Select the four cells meeting at one grid intersection for a + quadruple. +

+
+

Outside clues

+

X-sums and skyscrapers

+

+ Choose the edge and row or column. Σ badges are X-sums; ▥ badges are + visibility counts. +

+
+ + + + + +
+
+

Global rules

@@ -398,27 +646,107 @@ export function ConstraintEditor({ {constraints.length > 0 && (
-

Constraints

+
+

Constraints

+
+ + +
+
    - {constraints.map((constraint, index) => ( -
  • - {describeConstraint(constraint, puzzle.size)} - -
  • - ))} + {constraints.map((constraint, index) => { + const description = describeConstraint(constraint, puzzle.size); + const negated = + "negated" in constraint && constraint.negated === true; + return ( +
  • + {description} + + {supportsPolarity(constraint) && ( + + )} + + +
  • + ); + })}
)} diff --git a/src/components/GameplayHistoryDialog.tsx b/src/components/GameplayHistoryDialog.tsx new file mode 100644 index 0000000..5a120a0 --- /dev/null +++ b/src/components/GameplayHistoryDialog.tsx @@ -0,0 +1,295 @@ +import { useMemo, useState, type FormEvent } from "react"; +import { + activeHypothesis, + gameplayMoment, + MAIN_BRANCH_ID, + type GameplayHistory, +} from "../state/playHistory"; +import { Modal } from "./Modal"; + +interface GameplayHistoryDialogProps { + readonly open: boolean; + readonly history: GameplayHistory; + readonly replayMomentId?: string; + readonly onClose: () => void; + readonly onCreateSavepoint: (name: string) => void; + readonly onRestoreSavepoint: (savepointId: string) => void; + readonly onDeleteSavepoint: (savepointId: string) => void; + readonly onStartHypothesis: (name: string, fromMomentId?: string) => void; + readonly onFinishHypothesis: (decision: "keep" | "discard") => void; + readonly onReplayMoment: (momentId: string) => void; + readonly onReturnLive: () => void; +} + +function formatElapsed(seconds: number): string { + const hours = Math.floor(seconds / 3_600); + const minutes = Math.floor((seconds % 3_600) / 60); + const rest = seconds % 60; + return [hours, minutes, rest] + .map((part) => String(part).padStart(2, "0")) + .join(":"); +} + +export function GameplayHistoryDialog({ + open, + history, + replayMomentId, + onClose, + onCreateSavepoint, + onRestoreSavepoint, + onDeleteSavepoint, + onStartHypothesis, + onFinishHypothesis, + onReplayMoment, + onReturnLive, +}: GameplayHistoryDialogProps) { + const [hypothesisName, setHypothesisName] = useState(""); + const [savepointName, setSavepointName] = useState(""); + const hypothesis = activeHypothesis(history); + const replayMoment = replayMomentId + ? gameplayMoment(history, replayMomentId) + : undefined; + const replayIndex = replayMoment + ? history.moments.findIndex((moment) => moment.id === replayMoment.id) + : history.moments.length - 1; + const branchNames = useMemo( + () => + new Map([ + [MAIN_BRANCH_ID, "Main solve"], + ...history.branches.map((branch) => [branch.id, branch.name] as const), + ]), + [history.branches], + ); + + const submitHypothesis = (event: FormEvent) => { + event.preventDefault(); + const name = hypothesisName.trim(); + if (!name) return; + onStartHypothesis(name, replayMoment?.id); + setHypothesisName(""); + }; + + const submitSavepoint = (event: FormEvent) => { + event.preventDefault(); + const name = savepointName.trim(); + if (!name) return; + onCreateSavepoint(name); + setSavepointName(""); + }; + + return ( + +
+
+
+
+

Hypotheses

+

Try a path without losing your solve

+
+ {hypothesis ? ( +
+
+ {hypothesis.name} + Every move is being kept on this branch. +
+
+ + +
+
+ ) : ( +
+ + +
+ )} + {history.branches.length > 0 && ( +
    + {history.branches.map((branch) => ( +
  • + {branch.name} + + {branch.status} + +
  • + ))} +
+ )} +
+ +
+
+

Named savepoints

+

Return to a deliberate checkpoint

+
+
+ + +
+ {history.savepoints.length === 0 ? ( +

No savepoints yet.

+ ) : ( +
    + {history.savepoints.map((savepoint) => ( +
  • +
    + {savepoint.name} + + {formatElapsed(savepoint.state.elapsedSeconds)} + +
    +
    + + +
    +
  • + ))} +
+ )} +
+
+ +
+
+
+

Replay

+

Review every recorded state

+
+ + {String(Math.max(0, history.moments.length - 1))} changes + +
+ {replayMoment ? ( +

+ Viewing “{replayMoment.label}”. The board is read-only until you + return live or branch from this step. +

+ ) : ( +

+ Choose a step to inspect its complete grid, notes, colours and + elapsed time. +

+ )} +
+ + + +
+
    + {[...history.moments].reverse().map((moment) => { + const isReplay = moment.id === replayMoment?.id; + const isCurrent = + replayMoment === undefined && + moment.id === history.currentMomentId; + return ( +
  1. + +
  2. + ); + })} +
+
+
+
+ ); +} diff --git a/src/components/GeneratorWorkspace.tsx b/src/components/GeneratorWorkspace.tsx index 71275ee..9580545 100644 --- a/src/components/GeneratorWorkspace.tsx +++ b/src/components/GeneratorWorkspace.tsx @@ -1,11 +1,13 @@ import { useMemo, useState, type FormEvent } from "react"; import { GENERATOR_VARIANTS, + PRACTICE_TECHNIQUES, type DifficultyAssessment, type GeneratedVariantPuzzle, type GenerationDifficultyTarget, type GeneratorVariant, type GenerateVariantOptions, + type PracticeTechnique, } from "../solver"; const difficultyTargets: readonly GenerationDifficultyTarget[] = [ @@ -49,6 +51,10 @@ export function GeneratorWorkspace({ const [symmetry, setSymmetry] = useState<"none" | "rotational">("rotational"); const [constraintCount, setConstraintCount] = useState(8); const [seed, setSeed] = useState(""); + const [requiredTechnique, setRequiredTechnique] = useState< + PracticeTechnique | "" + >(""); + const [maxTechniqueAttempts, setMaxTechniqueAttempts] = useState(10); const usesMarkingCount = ![ "classic", "diagonal", @@ -66,6 +72,9 @@ export function GeneratorWorkspace({ targetDifficulty, symmetry, ...(usesMarkingCount ? { constraintCount } : {}), + ...(requiredTechnique === "" + ? {} + : { requiredTechnique, maxTechniqueAttempts }), seed: seed.trim() || `local-${Date.now().toString(36)}`, }); }; @@ -101,6 +110,11 @@ export function GeneratorWorkspace({ ) { setSize(nextDefinition.supportedSizes[0]); } + if (next !== "killer") { + setRequiredTechnique((current) => + current === "killer-cage" ? "" : current, + ); + } }} > {GENERATOR_VARIANTS.map((item) => ( @@ -175,6 +189,41 @@ export function GeneratorWorkspace({ onChange={(event) => setSeed(event.target.value)} /> + + {requiredTechnique !== "" && ( + + )}

{definition.description}

@@ -187,7 +236,9 @@ export function GeneratorWorkspace({

Difficulty is an estimate from reproducible solver evidence, not a - universal promise. Uniqueness is never claimed after a safety limit. + universal promise. Technique practice only returns a puzzle whose + logical path contains the requested move. Uniqueness is never claimed + after a safety limit.

@@ -229,11 +280,24 @@ export function GeneratorWorkspace({ Markings {generation.generatedConstraintCount} )} + {generation?.requestedTechnique && ( + + Practice{" "} + + {techniqueLabel(generation.requestedTechnique)} × + {generation.difficulty.techniqueCounts[ + generation.requestedTechnique + ] ?? 0} + + + )}

{assessment.summary}

{generation && (

- Seed: {String(generation.seed)} + Seed: {String(generation.seed)} · Found in{" "} + {generation.generationAttempts} attempt + {generation.generationAttempts === 1 ? "" : "s"}.

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

Five complementary workspaces

Play keeps values, two kinds of notes, colours, - history 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 - questions without changing the board. + 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.

@@ -32,6 +33,18 @@ export function HelpDialog({
Shift + arrows
Extend the selection
+
+
Home / End
+
Move to the first or last cell in the row
+
+
+
Ctrl/⌘ + Home/End
+
Move to the first or last cell in the grid
+
+
+
Page Up / Down
+
Move to the first or last row in the same column
+
1–9 / A–G
Enter the selected symbol
@@ -64,13 +77,62 @@ export function HelpDialog({ explanation.

+
+

Branches, savepoints and replay

+

+ Open History & branches while playing to name a + checkpoint, isolate a hypothesis, or inspect an earlier grid. + Discarding a hypothesis restores its exact starting state but keeps + the abandoned path available in replay. Replayed grids are read-only + until you return live or deliberately branch from that step. This + working history stays in the current browser session; save the + puzzle to the Library for durable puzzle progress. +

+
+
+

Sum and Candidate Labs

+

+ Sum Lab enumerates bounded combinations and, when selected board + cells are enabled, candidate-compatible assignments per position. + Required, excluded and manually eliminated combinations remain local + helper state. Candidate Lab inspects selected cells and houses, + filters one or more digits, and can overlay strong and weak links + without changing handwritten notes. +

+
+
+

Aid-mémoire scratch cells

+

+ Enable the optional aid-mémoire in Play for a non-constraining row + or compact grid of scratch cells. Each cell has its own label and + accepts values, corner marks, centre marks and colours through the + regular keypad or keyboard. Arrow keys stay within the configured + grid; Home/End move within a row, Ctrl/⌘ + Home/End reach the first + or last cell, and Page Up/Down move to the first or last row. The + layout and entries are preserved with local Library progress. +

+
+
+

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

+

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. A limit never becomes a false + 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.

@@ -78,10 +140,21 @@ export function HelpDialog({

Import and privacy

Files, text, solving and generation stay in this browser. Compact - grids, project JSON, share fragments and supported f-puzzles data - are decoded locally. SudokuPad short IDs need its server and are - intentionally rejected. Review an export before sharing: titles, - authors, rules, solutions and progress may be included. + grids, project JSON, share fragments, supported f-puzzles, + SudokuPad/CTC inline data and supported Penpa+ long links are + decoded locally. Server short IDs are intentionally rejected. 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. +

+ +
+

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.

diff --git a/src/components/HelpersWorkspace.tsx b/src/components/HelpersWorkspace.tsx index 9ffcc71..cbe9abc 100644 --- a/src/components/HelpersWorkspace.tsx +++ b/src/components/HelpersWorkspace.tsx @@ -1,11 +1,15 @@ import { useMemo, useState } from "react"; +import type { NormalizedPuzzle } from "../domain"; import { analyzeKillerCage, + analyzeSumLab, calculateResidual, relationPairs, + type CandidateOverlay, type RelationSpec, } from "../helpers"; import { maskValues, symbolFor } from "../state/session"; +import { CandidateLab } from "./CandidateLab"; function digits(text: string) { return [ @@ -25,18 +29,24 @@ function numbers(text: string) { export function HelpersWorkspace({ size, + puzzle, + values, selectedCells, candidateMasks, + onCandidateOverlayChange, }: { size: number; + puzzle: NormalizedPuzzle; + values: readonly number[]; selectedCells: readonly number[]; candidateMasks: readonly number[]; + onCandidateOverlayChange?: (overlay: CandidateOverlay | undefined) => void; }) { - const [helper, setHelper] = useState<"killer" | "residual" | "relations">( - "killer", - ); + const [helper, setHelper] = useState< + "candidates" | "killer" | "sum" | "residual" | "relations" + >("killer"); const [cellCount, setCellCount] = useState(2); - const [sum, setSum] = useState(10); + const [sum, setSum] = useState(Math.min(10, size + 1)); const [allowed, setAllowed] = useState(""); const [required, setRequired] = useState(""); const [excluded, setExcluded] = useState(""); @@ -47,6 +57,19 @@ export function HelpersWorkspace({ const [relation, setRelation] = useState("white"); const [firstCandidates, setFirstCandidates] = useState(""); const [secondCandidates, setSecondCandidates] = useState(""); + const [sumLabCellCount, setSumLabCellCount] = useState(2); + const [sumLabTarget, setSumLabTarget] = useState(Math.min(10, size + 1)); + const [sumLabMinimum, setSumLabMinimum] = useState(1); + const [sumLabMaximum, setSumLabMaximum] = useState(size); + const [sumLabRequired, setSumLabRequired] = useState(""); + const [sumLabExcluded, setSumLabExcluded] = useState(""); + const [sumLabRepeats, setSumLabRepeats] = useState(false); + const [sumLabUseCandidates, setSumLabUseCandidates] = useState(false); + const [sumLabShowEliminated, setSumLabShowEliminated] = useState(true); + const [sumLabEliminations, setSumLabEliminations] = useState<{ + readonly scope: string; + readonly keys: ReadonlySet; + }>({ scope: "", keys: new Set() }); const effectiveCount = useBoardCandidates && selectedCells.length > 0 @@ -137,6 +160,84 @@ export function HelpersWorkspace({ } }, [firstCandidates, relation, secondCandidates, size]); + const sumLabCandidateMasks = useMemo( + () => + sumLabUseCandidates && selectedCells.length > 0 + ? selectedCells.map((cell) => candidateMasks[cell] ?? 0) + : undefined, + [candidateMasks, selectedCells, sumLabUseCandidates], + ); + const sumLabEffectiveCount = + sumLabCandidateMasks === undefined + ? sumLabCellCount + : sumLabCandidateMasks.length; + const sumLabScope = JSON.stringify({ + count: sumLabEffectiveCount, + target: sumLabTarget, + minimum: sumLabMinimum, + maximum: sumLabMaximum, + required: sumLabRequired, + excluded: sumLabExcluded, + repeats: sumLabRepeats, + candidates: sumLabCandidateMasks, + }); + const sumLabEliminatedKeys = useMemo( + () => + sumLabEliminations.scope === sumLabScope + ? sumLabEliminations.keys + : new Set(), + [sumLabEliminations, sumLabScope], + ); + const sumLab = useMemo(() => { + try { + return { + result: analyzeSumLab({ + cellCount: sumLabEffectiveCount, + target: sumLabTarget, + minimumDigit: sumLabMinimum, + maximumDigit: sumLabMaximum, + allowRepeats: sumLabRepeats, + ...(sumLabRequired.trim() + ? { requiredDigits: digits(sumLabRequired) } + : {}), + ...(sumLabExcluded.trim() + ? { excludedDigits: digits(sumLabExcluded) } + : {}), + ...(sumLabCandidateMasks === undefined + ? {} + : { candidateMasks: sumLabCandidateMasks }), + eliminatedKeys: sumLabEliminatedKeys, + }), + }; + } catch (error) { + return { + error: + error instanceof Error ? error.message : "Invalid Sum Lab input.", + }; + } + }, [ + sumLabCandidateMasks, + sumLabEffectiveCount, + sumLabExcluded, + sumLabMaximum, + sumLabMinimum, + sumLabEliminatedKeys, + sumLabRepeats, + sumLabRequired, + sumLabTarget, + ]); + + const toggleSumLabCombination = (key: string) => { + setSumLabEliminations((current) => { + const keys = new Set( + current.scope === sumLabScope ? current.keys : undefined, + ); + if (keys.has(key)) keys.delete(key); + else keys.add(key); + return { scope: sumLabScope, keys }; + }); + }; + return (
@@ -151,6 +252,8 @@ export function HelpersWorkspace({ {( [ ["killer", "Killer combinations"], + ["sum", "Sum Lab"], + ["candidates", "Candidate links"], ["residual", `${String((size * (size + 1)) / 2)}-rule residual`], ["relations", "Pair relations"], ] as const @@ -168,6 +271,238 @@ export function HelpersWorkspace({ ))}
+ {helper === "candidates" && ( +
+ +
+ )} + + {helper === "sum" && ( +
+
+
+

Generalized Sum Lab

+

+ Explore sums with custom ranges and repeats. Select board cells + first to filter cell orderings through their live candidates. +

+
+ +
+
+ + + + + + + + + +
+ {sumLab.error ? ( +

+ {sumLab.error} +

+ ) : sumLab.result ? ( +
+
+ + {sumLab.result.activeCombinations.length}{" "} + active of {sumLab.result.combinations.length} + + + Possible{" "} + + {sumLab.result.possibleDigits + .map((value) => symbolFor(value, size)) + .join(" ") || "none"} + + + + Necessary{" "} + + {sumLab.result.necessaryDigits + .map((value) => symbolFor(value, size)) + .join(" ") || "none"} + + +
+ {sumLabCandidateMasks !== undefined && ( +
    + {sumLab.result.possibleByCell.map((entry, index) => { + const cell = selectedCells[index]; + const label = + cell === undefined + ? `Cell ${String(index + 1)}` + : `r${String(Math.floor(cell / size) + 1)}c${String((cell % size) + 1)}`; + return ( +
  1. + {label}:{" "} + {entry + .map((value) => symbolFor(value, size)) + .join(" ") || "—"} +
  2. + ); + })} +
+ )} +

+ Select a combination to eliminate it; select it again to restore + it. Eliminated combinations are excluded from the summaries + above. +

+
+ {sumLab.result.combinations + .filter( + (combination) => + sumLabShowEliminated || !combination.eliminated, + ) + .slice(0, 500) + .map((combination) => ( + + ))} + {sumLab.result.combinations.length === 0 && ( + No compatible combinations. + )} +
+ {(sumLab.result.combinations.length > 500 || + sumLab.result.truncated) && ( +

+ {sumLab.result.truncated + ? `Analysis reached its ${sumLab.result.truncationReason ?? "safety"} limit. ` + : ""} + The visible list is bounded; narrow the range or add filters + before making deductions from an incomplete result. +

+ )} +
+ ) : null} +
+ )} + {helper === "killer" && (
diff --git a/src/components/ImportExportDialog.tsx b/src/components/ImportExportDialog.tsx index 3eb60d4..eb25124 100644 --- a/src/components/ImportExportDialog.tsx +++ b/src/components/ImportExportDialog.tsx @@ -1,15 +1,15 @@ import { useMemo, useRef, useState } from "react"; import type { PuzzleDefinition } from "../domain/types"; +import { normalizePuzzle } from "../domain/validation"; import { - decodePuzzleHash, encodePuzzleHash, exportFpuzzlesJson, exportFpuzzlesUrl, fromDomainPuzzle, - importFpuzzles, - parseFpuzzles, - parsePlainGrid, - parseSudokuDocument, + importPuzzle, + renderPuzzlePdf, + renderPuzzlePng, + renderPuzzleSvg, serializePlainGrid, serializeSudokuDocument, toDomainPuzzle, @@ -17,31 +17,13 @@ import { } from "../formats"; import type { PlaySession } from "../state/session"; import { maskValues } from "../state/session"; +import type { PortableAidMemoire } from "../state/aidMemoire"; import { Modal } from "./Modal"; -function parseImport(input: string): SudokuDocument { - const trimmed = input.trim(); - if (trimmed.startsWith("#sudoku=") || trimmed.includes("#sudoku=")) - return decodePuzzleHash(trimmed); - if ( - /^(?:https?:\/\/)?(?:www\.)?(?:f-puzzles\.com|sudokupad\.app)\//iu.test( - trimmed, - ) - ) - return importFpuzzles(trimmed); - if (trimmed.startsWith("fpuzzles")) return importFpuzzles(trimmed); - if (trimmed.startsWith("{")) { - const parsed = JSON.parse(trimmed) as unknown; - if (typeof parsed === "object" && parsed !== null && "schema" in parsed) - return parseSudokuDocument(trimmed); - return parseFpuzzles(parsed); - } - return parsePlainGrid(trimmed); -} - function withProgress( puzzle: PuzzleDefinition, session: PlaySession, + aidMemoire?: PortableAidMemoire, ): SudokuDocument { const base = fromDomainPuzzle(puzzle); return { @@ -55,22 +37,38 @@ function withProgress( ), colors: [...session.colors], elapsedMs: session.elapsedSeconds * 1_000, + ...(aidMemoire === undefined ? {} : { aidMemoire }), }; } -function download(name: string, contents: string, type: string) { - const url = URL.createObjectURL(new Blob([contents], { type })); +function downloadBlob(name: string, blob: Blob) { + const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = name; + document.body.append(anchor); anchor.click(); - URL.revokeObjectURL(url); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 0); +} + +function download(name: string, contents: string, type: string) { + downloadBlob(name, new Blob([contents], { type })); +} + +function errorMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback; +} + +function checkedPuzzle(document: SudokuDocument): PuzzleDefinition { + return normalizePuzzle(toDomainPuzzle(document) as PuzzleDefinition); } interface ImportExportDialogProps { open: boolean; puzzle: PuzzleDefinition; session: PlaySession; + aidMemoire?: PortableAidMemoire; onClose: () => void; onImport: ( puzzle: PuzzleDefinition, @@ -82,6 +80,7 @@ interface ImportExportDialogProps { | "candidates" | "colors" | "elapsedMs" + | "aidMemoire" >, ) => void; } @@ -90,19 +89,21 @@ export function ImportExportDialog({ open, puzzle, session, + aidMemoire, onClose, onImport, }: ImportExportDialogProps) { const [input, setInput] = useState(""); const [feedback, setFeedback] = useState(""); const [includeProgress, setIncludeProgress] = useState(true); + const [busy, setBusy] = useState(false); const fileRef = useRef(null); const documentValue = useMemo( () => includeProgress - ? withProgress(puzzle, session) + ? withProgress(puzzle, session, aidMemoire) : fromDomainPuzzle(puzzle), - [includeProgress, puzzle, session], + [aidMemoire, includeProgress, puzzle, session], ); const copy = async (value: string, label: string) => { @@ -116,18 +117,119 @@ export function ImportExportDialog({ } }; + const copyExport = async (render: () => string, label: string) => { + let value: string; + try { + value = render(); + } catch (error) { + setFeedback(errorMessage(error, `${label} could not be created.`)); + return; + } + await copy(value, label); + }; + + const exportText = ( + name: string, + type: string, + label: string, + render: () => string, + ) => { + try { + download(name, render(), type); + setFeedback(`${label} downloaded locally.`); + } catch (error) { + setFeedback(errorMessage(error, `${label} export failed.`)); + } + }; + + const inspectImport = async () => { + setBusy(true); + try { + const result = await importPuzzle(input); + checkedPuzzle(result.document); + const givenCount = result.document.givens.filter( + (value) => value !== 0, + ).length; + setFeedback( + `${result.label}: ${String(result.document.size)}×${String(result.document.size)}, ${String(givenCount)} givens and ${String(result.document.constraints.length)} constraints. Compatible and ready to import.`, + ); + } catch (error) { + setFeedback(errorMessage(error, "The puzzle could not be inspected.")); + } finally { + setBusy(false); + } + }; + + const applyImport = async () => { + setBusy(true); + try { + const 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, + }); + setFeedback(`${result.label} imported locally.`); + onClose(); + } catch (error) { + setFeedback(errorMessage(error, "The puzzle could not be imported.")); + } finally { + setBusy(false); + } + }; + + const exportVisual = async (format: "svg" | "png" | "pdf") => { + setBusy(true); + try { + const options = { + includeProgress, + includeNotes: includeProgress, + }; + if (format === "svg") { + download( + "sudoku.svg", + renderPuzzleSvg(documentValue, options), + "image/svg+xml;charset=utf-8", + ); + } else if (format === "png") { + downloadBlob( + "sudoku.png", + await renderPuzzlePng(documentValue, options), + ); + } else { + downloadBlob( + "sudoku.pdf", + await renderPuzzlePdf(documentValue, options), + ); + } + setFeedback(`${format.toUpperCase()} rendered and downloaded locally.`); + } catch (error) { + setFeedback( + errorMessage(error, `The ${format.toUpperCase()} export failed.`), + ); + } finally { + setBusy(false); + } + }; + return ( -
+

Import

Paste puzzle data

- Accepts a plain grid, Sudoku Tools JSON/share link, raw f-puzzles - JSON, or a self-contained f-puzzles/SudokuPad link. Server-only - short IDs are never fetched. + Accepts a plain grid, Sudoku Tools JSON/share link, f-puzzles or + SudokuPad/CTC data, and supported Penpa+ long links. Everything is + decoded locally. Server-only short IDs are never fetched. + Unsupported constructs stop the import instead of being discarded.