@wsabol/sudoku-solver 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -8
- package/dist/boardGeo.d.ts +2 -0
- package/dist/boardGeo.d.ts.map +1 -0
- package/dist/boardGeo.js +1 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -11
- package/dist/move.d.ts +24 -0
- package/dist/move.d.ts.map +1 -0
- package/dist/move.js +1 -0
- package/dist/sudokuSolver.d.ts +12 -19
- package/dist/sudokuSolver.d.ts.map +1 -1
- package/dist/sudokuSolver.js +138 -33
- package/dist/utils.d.ts +2 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -30,11 +30,12 @@ console.log(solved.board); // number[][]
|
|
|
30
30
|
|
|
31
31
|
const next = Sudoku.nextMove(board);
|
|
32
32
|
console.log(next.status); // "In progress"
|
|
33
|
-
console.log(next.message); // e.g. "Place 4 in r1c3
|
|
33
|
+
console.log(next.message); // same as next.move?.message when a move exists, e.g. "Place 4 in r1c3 (Naked Single)"
|
|
34
34
|
if (next.move?.type === "placement") {
|
|
35
|
-
console.log(next.move.row, next.move.col, next.move.value);
|
|
35
|
+
console.log(next.move.row, next.move.col, next.move.value, next.move.reasoning);
|
|
36
36
|
} else if (next.move?.type === "elimination") {
|
|
37
37
|
console.log(next.move.eliminations); // [{ row, col, value }, ...]
|
|
38
|
+
// multi-value eliminations: message uses sorted set like "2/5/7" in the Eliminate … part
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
const check = Sudoku.validate(board);
|
|
@@ -81,16 +82,32 @@ Returns:
|
|
|
81
82
|
|
|
82
83
|
```ts
|
|
83
84
|
// digit placement
|
|
84
|
-
{
|
|
85
|
+
{
|
|
86
|
+
type: "placement";
|
|
87
|
+
row: number;
|
|
88
|
+
col: number;
|
|
89
|
+
value: number;
|
|
90
|
+
algorithm: Algorithm;
|
|
91
|
+
message: string;
|
|
92
|
+
reasoning: string;
|
|
93
|
+
}
|
|
85
94
|
|
|
86
|
-
// candidate elimination (e.g. Pointing Pair
|
|
87
|
-
{
|
|
95
|
+
// candidate elimination (e.g. Pointing Pair or Pointing Triple)
|
|
96
|
+
{
|
|
97
|
+
type: "elimination";
|
|
98
|
+
eliminations: Array<{ row: number; col: number; value: number }>;
|
|
99
|
+
algorithm: Algorithm;
|
|
100
|
+
message: string;
|
|
101
|
+
reasoning: string;
|
|
102
|
+
}
|
|
88
103
|
```
|
|
89
104
|
|
|
90
105
|
Notes:
|
|
91
106
|
- `move` is `null` when the board is complete or invalid.
|
|
92
107
|
- `move.type` must be checked before accessing placement-specific fields (`row`, `col`, `value`).
|
|
93
|
-
- `message
|
|
108
|
+
- `message`: placement uses 1-based coords in text, e.g. `Place 4 in r1c3 (Naked Single)`. Elimination counts distinct cells and names a common house when all targets share one row, column, or 3×3 box, e.g. `Eliminate 5 from 2 cells in row 4 (Pointing Pair)` or `Eliminate 2/7/9 from 3 cells in box 7 (Naked Triple)`; several distinct values use `{2/5/7}` in the digit part.
|
|
109
|
+
- `reasoning`: one sentence describing why the move is sound (technique-specific).
|
|
110
|
+
- Top-level `message` matches `move.message` when `move` is non-null.
|
|
94
111
|
|
|
95
112
|
### `Sudoku.validate(boardInput)`
|
|
96
113
|
|
|
@@ -117,12 +134,12 @@ Returns:
|
|
|
117
134
|
isValid: boolean;
|
|
118
135
|
isComplete: boolean;
|
|
119
136
|
message: string;
|
|
120
|
-
difficulty: "Easy" | "Medium" | "Hard" | "Diabolical" | "Impossible";
|
|
137
|
+
difficulty: "Easy" | "Medium" | "Hard" | "Diabolical" | "Impossible" | null;
|
|
121
138
|
solutions: number;
|
|
122
139
|
}
|
|
123
140
|
```
|
|
124
141
|
|
|
125
|
-
`difficulty` is derived from empty cell count
|
|
142
|
+
`difficulty` is derived from empty cell count when the board is valid; `null` when `isValid === false`. `solutions` is `0` when the board is invalid/unsolvable, `1` when a unique solution exists.
|
|
126
143
|
|
|
127
144
|
## Development
|
|
128
145
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"boardGeo.d.ts","sourceRoot":"","sources":["../src/boardGeo.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,KAAK,GAAG,MAAM,EAAE,EAAE,CAAC"}
|
package/dist/boardGeo.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import SudokuSolver, { type Algorithm, type
|
|
2
|
-
import {
|
|
1
|
+
import SudokuSolver, { type Algorithm, type DifficultyLevel } from "./sudokuSolver.js";
|
|
2
|
+
import { ValidationReason, ValidationResult } from "./validate.js";
|
|
3
|
+
import type { Board } from "./boardGeo.js";
|
|
4
|
+
import type { Move, MoveStatus, EliminationMove, PlacementMove } from "./move.js";
|
|
3
5
|
interface SolveResult {
|
|
4
6
|
isValid: boolean;
|
|
5
7
|
board: Board;
|
|
@@ -8,10 +10,9 @@ interface DescribeResult {
|
|
|
8
10
|
isValid: boolean;
|
|
9
11
|
isComplete: boolean;
|
|
10
12
|
message: string;
|
|
11
|
-
difficulty:
|
|
13
|
+
difficulty: DifficultyLevel | null;
|
|
12
14
|
solutions: number;
|
|
13
15
|
}
|
|
14
|
-
type MoveStatus = "Complete" | "In progress" | "Invalid";
|
|
15
16
|
interface MoveResult {
|
|
16
17
|
status: MoveStatus;
|
|
17
18
|
move: Move | null;
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,EAAE,EAAE,KAAK,SAAS,EAAE,KAAK,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,EAAE,EAAE,KAAK,SAAS,EAAE,KAAK,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACvF,OAAO,EAA8C,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAC/G,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAC3C,OAAO,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAElF,UAAU,WAAW;IACjB,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,KAAK,CAAC;CAChB;AAED,UAAU,cAAc;IACpB,OAAO,EAAE,OAAO,CAAC;IACjB,UAAU,EAAE,OAAO,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,eAAe,GAAG,IAAI,CAAC;IACnC,SAAS,EAAE,MAAM,CAAC;CACrB;AAED,UAAU,UAAU;IAChB,MAAM,EAAE,UAAU,CAAC;IACnB,IAAI,EAAE,IAAI,GAAG,IAAI,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;CACnB;AAED,iBAAS,KAAK,CAAC,UAAU,EAAE,MAAM,GAAG,KAAK,GAAG,WAAW,CAOtD;AAED,iBAAS,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,KAAK,GAAG,UAAU,CA0BxD;AAED,iBAAS,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,KAAK,GAAG,gBAAgB,CAY9D;AAED,iBAAS,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,KAAK,GAAG,cAAc,CAoCjE;AAED,QAAA,MAAM,MAAM;;;;;CAKX,CAAC;AAEF,OAAO,EACH,YAAY,EACf,CAAA;AAGD,YAAY,EACR,SAAS,EACT,KAAK,EACL,IAAI,EACJ,aAAa,EACb,eAAe,EACf,gBAAgB,EAChB,gBAAgB,EAChB,WAAW,EACX,UAAU,EACV,UAAU,EACV,cAAc,EACjB,CAAC;AAEF,eAAe,MAAM,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -23,17 +23,9 @@ function nextMove(boardInput) {
|
|
|
23
23
|
if (!move) {
|
|
24
24
|
message = "No more moves";
|
|
25
25
|
}
|
|
26
|
-
else if (move.type === "placement") {
|
|
27
|
-
message = `Place ${move.value} in r${move.row + 1}c${move.col + 1} (${move.algorithm})`;
|
|
28
|
-
sudoku.setSquareValue(move.row, move.col, move.value);
|
|
29
|
-
}
|
|
30
26
|
else {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
? String(digits[0])
|
|
34
|
-
: `{${digits.join(",")}}`;
|
|
35
|
-
message = `Eliminate ${digitPart} from ${move.eliminations.length} cell(s) (${move.algorithm})`;
|
|
36
|
-
sudoku.applyElimination(move);
|
|
27
|
+
message = move.message;
|
|
28
|
+
sudoku.applyMove(move);
|
|
37
29
|
}
|
|
38
30
|
return {
|
|
39
31
|
status: sudoku.isComplete() ? "Complete" : "In progress",
|
|
@@ -61,7 +53,7 @@ function describeBoard(boardInput) {
|
|
|
61
53
|
isValid: false,
|
|
62
54
|
isComplete: false,
|
|
63
55
|
message: initValidation.message,
|
|
64
|
-
difficulty:
|
|
56
|
+
difficulty: null,
|
|
65
57
|
solutions: 0,
|
|
66
58
|
};
|
|
67
59
|
}
|
package/dist/move.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Algorithm } from "./sudokuSolver.js";
|
|
2
|
+
export type MoveStatus = "Complete" | "In progress" | "Invalid";
|
|
3
|
+
export interface PlacementMove {
|
|
4
|
+
type: "placement";
|
|
5
|
+
row: number;
|
|
6
|
+
col: number;
|
|
7
|
+
value: number;
|
|
8
|
+
algorithm: Algorithm;
|
|
9
|
+
message: string;
|
|
10
|
+
reasoning: string;
|
|
11
|
+
}
|
|
12
|
+
export interface EliminationMove {
|
|
13
|
+
type: "elimination";
|
|
14
|
+
eliminations: Array<{
|
|
15
|
+
row: number;
|
|
16
|
+
col: number;
|
|
17
|
+
value: number;
|
|
18
|
+
}>;
|
|
19
|
+
algorithm: Algorithm;
|
|
20
|
+
message: string;
|
|
21
|
+
reasoning: string;
|
|
22
|
+
}
|
|
23
|
+
export type Move = PlacementMove | EliminationMove;
|
|
24
|
+
//# sourceMappingURL=move.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"move.d.ts","sourceRoot":"","sources":["../src/move.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAEnD,MAAM,MAAM,UAAU,GAAG,UAAU,GAAG,aAAa,GAAG,SAAS,CAAC;AAEhE,MAAM,WAAW,aAAa;IAC1B,IAAI,EAAE,WAAW,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,SAAS,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,eAAe;IAC5B,IAAI,EAAE,aAAa,CAAC;IACpB,YAAY,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACjE,SAAS,EAAE,SAAS,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,MAAM,IAAI,GAAG,aAAa,GAAG,eAAe,CAAC"}
|
package/dist/move.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/sudokuSolver.d.ts
CHANGED
|
@@ -1,24 +1,9 @@
|
|
|
1
1
|
import { ValidationResult } from "./validate.js";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
export
|
|
5
|
-
type: "placement";
|
|
6
|
-
row: number;
|
|
7
|
-
col: number;
|
|
8
|
-
value: number;
|
|
9
|
-
algorithm: Algorithm;
|
|
10
|
-
}
|
|
11
|
-
export interface EliminationMove {
|
|
12
|
-
type: "elimination";
|
|
13
|
-
eliminations: Array<{
|
|
14
|
-
row: number;
|
|
15
|
-
col: number;
|
|
16
|
-
value: number;
|
|
17
|
-
}>;
|
|
18
|
-
algorithm: Algorithm;
|
|
19
|
-
}
|
|
20
|
-
export type Move = PlacementMove | EliminationMove;
|
|
2
|
+
import type { Board } from "./boardGeo.js";
|
|
3
|
+
import type { Move, EliminationMove } from "./move.js";
|
|
4
|
+
export type Algorithm = "Last Digit" | "Full House" | "Naked Single" | "Hidden Single" | "Pointing Pair" | "Pointing Triple" | "Naked Pair" | "Naked Triple" | "Naked Quad";
|
|
21
5
|
export type DifficultyLevel = "Easy" | "Medium" | "Hard" | "Diabolical" | "Impossible";
|
|
6
|
+
export type ValidationReasonType = "duplicate_in_row" | "duplicate_in_column" | "duplicate_in_box" | "invalid_value" | "invalid_board_length" | "invalid_board_characters" | "empty_cell_no_candidates" | "too_many_empty_cells";
|
|
22
7
|
export default class SudokuSolver {
|
|
23
8
|
private static readonly SEARCH_PHASES;
|
|
24
9
|
private board;
|
|
@@ -30,8 +15,10 @@ export default class SudokuSolver {
|
|
|
30
15
|
setPossibles(row: number, col: number, possibles: number[]): void;
|
|
31
16
|
setSquareValue(row: number, col: number, value: number): void;
|
|
32
17
|
applyElimination(move: EliminationMove): void;
|
|
18
|
+
applyMove(move: Move): void;
|
|
33
19
|
isComplete(): boolean;
|
|
34
20
|
countEmptyCells(): number;
|
|
21
|
+
countPlaced(value?: number): number;
|
|
35
22
|
validate(): ValidationResult;
|
|
36
23
|
isValid(): boolean;
|
|
37
24
|
difficulty(): DifficultyLevel;
|
|
@@ -45,15 +32,21 @@ export default class SudokuSolver {
|
|
|
45
32
|
private valuesMissing;
|
|
46
33
|
private calcSquarePossibles;
|
|
47
34
|
private calcPossibles;
|
|
35
|
+
private finalizePlacement;
|
|
36
|
+
private finalizeElimination;
|
|
48
37
|
private findBestMove;
|
|
49
38
|
private findMoveForPhase;
|
|
50
39
|
private findNakedSingle;
|
|
40
|
+
private hiddenSingleReasoning;
|
|
41
|
+
private lastDigitReasoning;
|
|
51
42
|
private findHiddenSingleInRow;
|
|
52
43
|
private findHiddenSingleInCol;
|
|
53
44
|
private findHiddenSingleInBox;
|
|
54
45
|
private findPointingPairTriple;
|
|
55
46
|
private eachHouseInOrder;
|
|
56
47
|
private findNakedSubsetElimination;
|
|
48
|
+
/** `houseCells` is one full row, column, or box (9 cells). */
|
|
49
|
+
private getHouseContext;
|
|
57
50
|
private tryNakedSubsetInHouse;
|
|
58
51
|
private findHiddenSingle;
|
|
59
52
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sudokuSolver.d.ts","sourceRoot":"","sources":["../src/sudokuSolver.ts"],"names":[],"mappings":"AACA,OAAO,EAAoB,gBAAgB,EAAoB,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"sudokuSolver.d.ts","sourceRoot":"","sources":["../src/sudokuSolver.ts"],"names":[],"mappings":"AACA,OAAO,EAAoB,gBAAgB,EAAoB,MAAM,eAAe,CAAC;AACrF,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAC3C,OAAO,KAAK,EAAE,IAAI,EAAiB,eAAe,EAAE,MAAM,WAAW,CAAC;AAEtE,MAAM,MAAM,SAAS,GACf,YAAY,GACZ,YAAY,GACZ,cAAc,GACd,eAAe,GACf,eAAe,GACf,iBAAiB,GACjB,YAAY,GACZ,cAAc,GACd,YAAY,CAAC;AAEnB,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,QAAQ,GAAG,MAAM,GAAG,YAAY,GAAG,YAAY,CAAC;AAEvF,MAAM,MAAM,oBAAoB,GAC1B,kBAAkB,GAClB,qBAAqB,GACrB,kBAAkB,GAClB,eAAe,GACf,sBAAsB,GACtB,0BAA0B,GAC1B,0BAA0B,GAC1B,sBAAsB,CAAC;AA+C7B,MAAM,CAAC,OAAO,OAAO,YAAY;IAC7B,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,aAAa,CAKnC;IAEF,OAAO,CAAC,KAAK,CAAQ;IACrB,OAAO,CAAC,aAAa,CAAe;gBAExB,KAAK,EAAE,MAAM,GAAG,KAAK;IAOjC,QAAQ,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI;IAS5B,OAAO,IAAI,KAAK;IAIhB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE;IAIhD,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,IAAI;IAIjE,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI;IAmB7D,gBAAgB,CAAC,IAAI,EAAE,eAAe,GAAG,IAAI;IAM7C,SAAS,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI;IAQ3B,UAAU,IAAI,OAAO;IAIrB,eAAe,IAAI,MAAM;IAIzB,WAAW,CAAC,KAAK,GAAE,MAAU,GAAG,MAAM;IAOtC,QAAQ,IAAI,gBAAgB;IAiG5B,OAAO,IAAI,OAAO;IAKlB,UAAU,IAAI,eAAe;IAiB7B,WAAW,IAAI,IAAI,GAAG,IAAI;IAO1B,KAAK,IAAI,OAAO;IAchB,OAAO,CAAC,QAAQ;IAIhB,OAAO,CAAC,WAAW;IAOnB,OAAO,CAAC,MAAM;IAId,OAAO,CAAC,SAAS;IAIjB,OAAO,CAAC,MAAM;IAYd,OAAO,CAAC,aAAa;IAIrB,OAAO,CAAC,mBAAmB;IAU3B,OAAO,CAAC,aAAa;IAQrB,OAAO,CAAC,iBAAiB;IAkBzB,OAAO,CAAC,mBAAmB;IAqB3B,OAAO,CAAC,YAAY;IAQpB,OAAO,CAAC,gBAAgB;IAaxB,OAAO,CAAC,eAAe;IA+CvB,OAAO,CAAC,qBAAqB;IAI7B,OAAO,CAAC,kBAAkB;IAI1B,OAAO,CAAC,qBAAqB;IAsB7B,OAAO,CAAC,qBAAqB;IAsB7B,OAAO,CAAC,qBAAqB;IAuB7B,OAAO,CAAC,sBAAsB;IAqD9B,OAAO,CAAC,gBAAgB;IAkBxB,OAAO,CAAC,0BAA0B;IAUlC,8DAA8D;IAC9D,OAAO,CAAC,eAAe;IAYvB,OAAO,CAAC,qBAAqB;IAwD7B,OAAO,CAAC,gBAAgB;CAe3B"}
|
package/dist/sudokuSolver.js
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
|
-
import { cloneBoard, assertBoardShape, parseBoardString, duplicateValues } from "./utils.js";
|
|
1
|
+
import { cloneBoard, assertBoardShape, parseBoardString, duplicateValues, boxNumber } from "./utils.js";
|
|
2
2
|
import { pushUniqueReason } from "./validate.js";
|
|
3
3
|
const COMPLETE = [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
4
|
+
function uniqueCellCoordinates(items) {
|
|
5
|
+
const seen = new Set();
|
|
6
|
+
const out = [];
|
|
7
|
+
for (const { row, col } of items) {
|
|
8
|
+
const k = `${row},${col}`;
|
|
9
|
+
if (!seen.has(k)) {
|
|
10
|
+
seen.add(k);
|
|
11
|
+
out.push({ row, col });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* If every cell lies in the same row, same column, or same 3×3 box, return that house.
|
|
18
|
+
* Row wins over column when both apply (e.g. a single cell). Otherwise `null`.
|
|
19
|
+
*/
|
|
20
|
+
function sharedHouseContainingCells(cells) {
|
|
21
|
+
if (cells.length === 0) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const first = cells[0];
|
|
25
|
+
if (cells.every((c) => c.row === first.row)) {
|
|
26
|
+
return { kind: "row", wherePhrase: `row ${first.row + 1}` };
|
|
27
|
+
}
|
|
28
|
+
if (cells.every((c) => c.col === first.col)) {
|
|
29
|
+
return { kind: "column", wherePhrase: `column ${first.col + 1}` };
|
|
30
|
+
}
|
|
31
|
+
const box = boxNumber(first.row, first.col);
|
|
32
|
+
if (cells.every((c) => boxNumber(c.row, c.col) === box)) {
|
|
33
|
+
return { kind: "box", wherePhrase: `box ${box}` };
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
4
37
|
export default class SudokuSolver {
|
|
5
38
|
static SEARCH_PHASES = [
|
|
6
39
|
"NakedSingle",
|
|
@@ -54,12 +87,26 @@ export default class SudokuSolver {
|
|
|
54
87
|
this.possiblesGrid[row][col] = this.possiblesGrid[row][col].filter((v) => v !== value);
|
|
55
88
|
}
|
|
56
89
|
}
|
|
90
|
+
applyMove(move) {
|
|
91
|
+
if (move.type === "placement") {
|
|
92
|
+
this.setSquareValue(move.row, move.col, move.value);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
this.applyElimination(move);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
57
98
|
isComplete() {
|
|
58
99
|
return this.board.every((row) => row.every((v) => v !== 0));
|
|
59
100
|
}
|
|
60
101
|
countEmptyCells() {
|
|
61
102
|
return this.board.flat().filter((v) => v === 0).length;
|
|
62
103
|
}
|
|
104
|
+
countPlaced(value = 0) {
|
|
105
|
+
if (value === 0) {
|
|
106
|
+
return 81 - this.countEmptyCells(); // total cells - empty cells
|
|
107
|
+
}
|
|
108
|
+
return this.board.flat().filter((v) => v === value).length;
|
|
109
|
+
}
|
|
63
110
|
validate() {
|
|
64
111
|
const reasons = [];
|
|
65
112
|
if (this.board.length !== 9 || this.board.some((row) => row.length !== 9)) {
|
|
@@ -180,12 +227,7 @@ export default class SudokuSolver {
|
|
|
180
227
|
}
|
|
181
228
|
let move = this.findBestMove();
|
|
182
229
|
while (move) {
|
|
183
|
-
|
|
184
|
-
this.setSquareValue(move.row, move.col, move.value);
|
|
185
|
-
}
|
|
186
|
-
else {
|
|
187
|
-
this.applyElimination(move);
|
|
188
|
-
}
|
|
230
|
+
this.applyMove(move);
|
|
189
231
|
move = this.findBestMove();
|
|
190
232
|
}
|
|
191
233
|
return this.isComplete();
|
|
@@ -235,6 +277,33 @@ export default class SudokuSolver {
|
|
|
235
277
|
}
|
|
236
278
|
}
|
|
237
279
|
}
|
|
280
|
+
finalizePlacement(row, col, value, algorithm, reasoning) {
|
|
281
|
+
return {
|
|
282
|
+
type: "placement",
|
|
283
|
+
row,
|
|
284
|
+
col,
|
|
285
|
+
value,
|
|
286
|
+
algorithm,
|
|
287
|
+
message: `Place ${value} in r${row + 1}c${col + 1} (${algorithm})`,
|
|
288
|
+
reasoning,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
finalizeElimination(eliminations, algorithm, reasoning) {
|
|
292
|
+
const digits = [...new Set(eliminations.map((e) => e.value))].sort((a, b) => a - b);
|
|
293
|
+
const digitPart = digits.length === 1 ? String(digits[0]) : `{${digits.join("/")}}`;
|
|
294
|
+
const uniqueCells = uniqueCellCoordinates(eliminations);
|
|
295
|
+
const uniqueCellCount = uniqueCells.length;
|
|
296
|
+
const cellWord = uniqueCellCount === 1 ? "cell" : "cells";
|
|
297
|
+
const housePhrase = sharedHouseContainingCells(uniqueCells)?.wherePhrase;
|
|
298
|
+
const wherePart = housePhrase ? ` in ${housePhrase}` : "";
|
|
299
|
+
return {
|
|
300
|
+
type: "elimination",
|
|
301
|
+
eliminations,
|
|
302
|
+
algorithm,
|
|
303
|
+
message: `Eliminate ${digitPart} from ${uniqueCellCount} ${cellWord}${wherePart} (${algorithm})`,
|
|
304
|
+
reasoning,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
238
307
|
findBestMove() {
|
|
239
308
|
for (const phase of SudokuSolver.SEARCH_PHASES) {
|
|
240
309
|
const move = this.findMoveForPhase(phase);
|
|
@@ -263,33 +332,49 @@ export default class SudokuSolver {
|
|
|
263
332
|
if (p.length === 1) {
|
|
264
333
|
const placeValue = p[0];
|
|
265
334
|
let algo = "Naked Single";
|
|
335
|
+
let house = '';
|
|
266
336
|
// check if full house
|
|
267
337
|
const rowValues = this.getRow(row);
|
|
268
338
|
if (rowValues.filter((v) => v === 0).length === 1) {
|
|
269
339
|
algo = "Full House";
|
|
340
|
+
house = `row ${row + 1}`;
|
|
270
341
|
}
|
|
271
342
|
const colValues = this.getColumn(col);
|
|
272
343
|
if (colValues.filter((v) => v === 0).length === 1) {
|
|
273
344
|
algo = "Full House";
|
|
345
|
+
house = `column ${col + 1}`;
|
|
274
346
|
}
|
|
275
347
|
const boxValues = this.getBox(this.boxIndex(row, col));
|
|
276
348
|
if (boxValues.filter((v) => v === 0).length === 1) {
|
|
277
349
|
algo = "Full House";
|
|
350
|
+
house = `box ${boxNumber(row, col)}`;
|
|
278
351
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
352
|
+
let reasoning;
|
|
353
|
+
if (algo === "Full House") {
|
|
354
|
+
reasoning = `This is the last empty cell in ${house} and must be ${placeValue}.`;
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
if (this.countPlaced(placeValue) === 8) {
|
|
283
358
|
algo = "Last Digit";
|
|
359
|
+
reasoning = this.lastDigitReasoning(placeValue);
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
reasoning = `${placeValue} is the only remaining candidate for this cell.`;
|
|
284
363
|
}
|
|
285
364
|
}
|
|
286
|
-
return
|
|
365
|
+
return this.finalizePlacement(row, col, placeValue, algo, reasoning);
|
|
287
366
|
}
|
|
288
367
|
}
|
|
289
368
|
}
|
|
290
369
|
}
|
|
291
370
|
return null;
|
|
292
371
|
}
|
|
372
|
+
hiddenSingleReasoning(house, value) {
|
|
373
|
+
return `This is the only empty cell in ${house} that can be a ${value}.`;
|
|
374
|
+
}
|
|
375
|
+
lastDigitReasoning(value) {
|
|
376
|
+
return `Eight cells already contain ${value}, so the 9th occurrence must be placed here.`;
|
|
377
|
+
}
|
|
293
378
|
findHiddenSingleInRow(row) {
|
|
294
379
|
for (let value = 1; value <= 9; value++) {
|
|
295
380
|
const candidates = [];
|
|
@@ -299,13 +384,14 @@ export default class SudokuSolver {
|
|
|
299
384
|
}
|
|
300
385
|
}
|
|
301
386
|
if (candidates.length === 1) {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
if (
|
|
306
|
-
|
|
387
|
+
const col = candidates[0];
|
|
388
|
+
let algorithm = "Hidden Single";
|
|
389
|
+
let reasoning = this.hiddenSingleReasoning(`row ${row + 1}`, value);
|
|
390
|
+
if (this.countPlaced(value) === 8) {
|
|
391
|
+
algorithm = "Last Digit";
|
|
392
|
+
reasoning = this.lastDigitReasoning(value);
|
|
307
393
|
}
|
|
308
|
-
return
|
|
394
|
+
return this.finalizePlacement(row, col, value, algorithm, reasoning);
|
|
309
395
|
}
|
|
310
396
|
}
|
|
311
397
|
return null;
|
|
@@ -319,13 +405,14 @@ export default class SudokuSolver {
|
|
|
319
405
|
}
|
|
320
406
|
}
|
|
321
407
|
if (candidates.length === 1) {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
if (
|
|
326
|
-
|
|
408
|
+
const row = candidates[0];
|
|
409
|
+
let algorithm = "Hidden Single";
|
|
410
|
+
let reasoning = this.hiddenSingleReasoning(`column ${col + 1}`, value);
|
|
411
|
+
if (this.countPlaced(value) === 8) {
|
|
412
|
+
algorithm = "Last Digit";
|
|
413
|
+
reasoning = this.lastDigitReasoning(value);
|
|
327
414
|
}
|
|
328
|
-
return
|
|
415
|
+
return this.finalizePlacement(row, col, value, algorithm, reasoning);
|
|
329
416
|
}
|
|
330
417
|
}
|
|
331
418
|
return null;
|
|
@@ -340,13 +427,14 @@ export default class SudokuSolver {
|
|
|
340
427
|
}
|
|
341
428
|
}
|
|
342
429
|
if (candidates.length === 1) {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
if (
|
|
347
|
-
|
|
430
|
+
const { row: r, col: c } = candidates[0];
|
|
431
|
+
let algorithm = "Hidden Single";
|
|
432
|
+
let reasoning = this.hiddenSingleReasoning(`box ${boxNumber(r, c)}`, value);
|
|
433
|
+
if (this.countPlaced(value) === 8) {
|
|
434
|
+
algorithm = "Last Digit";
|
|
435
|
+
reasoning = this.lastDigitReasoning(value);
|
|
348
436
|
}
|
|
349
|
-
return
|
|
437
|
+
return this.finalizePlacement(r, c, value, algorithm, reasoning);
|
|
350
438
|
}
|
|
351
439
|
}
|
|
352
440
|
return null;
|
|
@@ -355,6 +443,7 @@ export default class SudokuSolver {
|
|
|
355
443
|
for (let ibox = 0; ibox < 9; ibox++) {
|
|
356
444
|
const boxStartRow = Math.floor(ibox / 3) * 3;
|
|
357
445
|
const boxStartCol = (ibox % 3) * 3;
|
|
446
|
+
const boxNum = ibox + 1;
|
|
358
447
|
for (let digit = 1; digit <= 9; digit++) {
|
|
359
448
|
const cells = [];
|
|
360
449
|
for (let idx = 0; idx < 9; idx++) {
|
|
@@ -376,7 +465,9 @@ export default class SudokuSolver {
|
|
|
376
465
|
}
|
|
377
466
|
}
|
|
378
467
|
if (eliminations.length > 0) {
|
|
379
|
-
|
|
468
|
+
const algorithm = cells.length === 2 ? "Pointing Pair" : "Pointing Triple";
|
|
469
|
+
const reasoning = `In box ${boxNum}, every candidate for ${digit} lies in row ${sharedRow + 1}, so ${digit} cannot appear elsewhere in that row outside the box.`;
|
|
470
|
+
return this.finalizeElimination(eliminations, algorithm, reasoning);
|
|
380
471
|
}
|
|
381
472
|
}
|
|
382
473
|
if (cells.every((c) => c.col === cells[0].col)) {
|
|
@@ -390,7 +481,9 @@ export default class SudokuSolver {
|
|
|
390
481
|
}
|
|
391
482
|
}
|
|
392
483
|
if (eliminations.length > 0) {
|
|
393
|
-
|
|
484
|
+
const algorithm = cells.length === 2 ? "Pointing Pair" : "Pointing Triple";
|
|
485
|
+
const reasoning = `In box ${boxNum}, every candidate for ${digit} lies in column ${sharedCol + 1}, so ${digit} cannot appear elsewhere in that column outside the box.`;
|
|
486
|
+
return this.finalizeElimination(eliminations, algorithm, reasoning);
|
|
394
487
|
}
|
|
395
488
|
}
|
|
396
489
|
}
|
|
@@ -424,12 +517,22 @@ export default class SudokuSolver {
|
|
|
424
517
|
}
|
|
425
518
|
return null;
|
|
426
519
|
}
|
|
520
|
+
/** `houseCells` is one full row, column, or box (9 cells). */
|
|
521
|
+
getHouseContext(houseCells) {
|
|
522
|
+
const shared = sharedHouseContainingCells(houseCells);
|
|
523
|
+
if (shared) {
|
|
524
|
+
return { wherePhrase: shared.wherePhrase, sameKindWord: shared.kind };
|
|
525
|
+
}
|
|
526
|
+
const h = houseCells[0];
|
|
527
|
+
return { wherePhrase: `box ${boxNumber(h.row, h.col)}`, sameKindWord: "box" };
|
|
528
|
+
}
|
|
427
529
|
tryNakedSubsetInHouse(houseCells, k) {
|
|
428
530
|
const empties = houseCells.filter(({ row, col }) => this.board[row][col] === 0 && this.possiblesGrid[row][col].length > 0);
|
|
429
531
|
if (empties.length < k) {
|
|
430
532
|
return null;
|
|
431
533
|
}
|
|
432
534
|
const algorithm = k === 2 ? "Naked Pair" : k === 3 ? "Naked Triple" : "Naked Quad";
|
|
535
|
+
const { wherePhrase, sameKindWord } = this.getHouseContext(houseCells);
|
|
433
536
|
const n = empties.length;
|
|
434
537
|
const indices = [];
|
|
435
538
|
const dfs = (start) => {
|
|
@@ -460,7 +563,9 @@ export default class SudokuSolver {
|
|
|
460
563
|
if (eliminations.length === 0) {
|
|
461
564
|
return null;
|
|
462
565
|
}
|
|
463
|
-
|
|
566
|
+
const digitStr = [...union].sort((a, b) => a - b).join("/");
|
|
567
|
+
const reasoning = `${algorithm} ${digitStr} in ${wherePhrase} means those digits can be eliminated from the other cells in that same ${sameKindWord}.`;
|
|
568
|
+
return this.finalizeElimination(eliminations, algorithm, reasoning);
|
|
464
569
|
}
|
|
465
570
|
for (let i = start; i < n; i++) {
|
|
466
571
|
indices.push(i);
|
package/dist/utils.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Board } from "./boardGeo.js";
|
|
2
|
+
export declare function boxNumber(row: number, col: number): number;
|
|
2
3
|
export declare function cloneBoard(board: Board): Board;
|
|
3
4
|
export declare function assertBoardShape(board: Board): void;
|
|
4
5
|
export declare function assertBoardValues(board: Board): void;
|
package/dist/utils.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,
|
|
1
|
+
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAE3C,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAE1D;AAED,wBAAgB,UAAU,CAAC,KAAK,EAAE,KAAK,GAAG,KAAK,CAE9C;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI,CAInD;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI,CASpD;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,KAAK,CAmBrD;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAS1D;AAID,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAE7C"}
|
package/dist/utils.js
CHANGED
package/package.json
CHANGED