@wsabol/sudoku-solver 0.1.5 → 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/sudokuSolver.d.ts +1 -1
- package/dist/sudokuSolver.d.ts.map +1 -1
- package/dist/sudokuSolver.js +47 -9
- 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
|
|
package/dist/sudokuSolver.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ValidationResult } from "./validate.js";
|
|
2
2
|
import type { Board } from "./boardGeo.js";
|
|
3
3
|
import type { Move, EliminationMove } from "./move.js";
|
|
4
|
-
export type Algorithm = "Last Digit" | "Full House" | "Naked Single" | "Hidden Single" | "Pointing Pair
|
|
4
|
+
export type Algorithm = "Last Digit" | "Full House" | "Naked Single" | "Hidden Single" | "Pointing Pair" | "Pointing Triple" | "Naked Pair" | "Naked Triple" | "Naked Quad";
|
|
5
5
|
export type DifficultyLevel = "Easy" | "Medium" | "Hard" | "Diabolical" | "Impossible";
|
|
6
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";
|
|
7
7
|
export default class SudokuSolver {
|
|
@@ -1 +1 @@
|
|
|
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,
|
|
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
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",
|
|
@@ -258,11 +291,16 @@ export default class SudokuSolver {
|
|
|
258
291
|
finalizeElimination(eliminations, algorithm, reasoning) {
|
|
259
292
|
const digits = [...new Set(eliminations.map((e) => e.value))].sort((a, b) => a - b);
|
|
260
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}` : "";
|
|
261
299
|
return {
|
|
262
300
|
type: "elimination",
|
|
263
301
|
eliminations,
|
|
264
302
|
algorithm,
|
|
265
|
-
message: `Eliminate ${digitPart} from ${
|
|
303
|
+
message: `Eliminate ${digitPart} from ${uniqueCellCount} ${cellWord}${wherePart} (${algorithm})`,
|
|
266
304
|
reasoning,
|
|
267
305
|
};
|
|
268
306
|
}
|
|
@@ -427,8 +465,9 @@ export default class SudokuSolver {
|
|
|
427
465
|
}
|
|
428
466
|
}
|
|
429
467
|
if (eliminations.length > 0) {
|
|
468
|
+
const algorithm = cells.length === 2 ? "Pointing Pair" : "Pointing Triple";
|
|
430
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.`;
|
|
431
|
-
return this.finalizeElimination(eliminations,
|
|
470
|
+
return this.finalizeElimination(eliminations, algorithm, reasoning);
|
|
432
471
|
}
|
|
433
472
|
}
|
|
434
473
|
if (cells.every((c) => c.col === cells[0].col)) {
|
|
@@ -442,8 +481,9 @@ export default class SudokuSolver {
|
|
|
442
481
|
}
|
|
443
482
|
}
|
|
444
483
|
if (eliminations.length > 0) {
|
|
484
|
+
const algorithm = cells.length === 2 ? "Pointing Pair" : "Pointing Triple";
|
|
445
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.`;
|
|
446
|
-
return this.finalizeElimination(eliminations,
|
|
486
|
+
return this.finalizeElimination(eliminations, algorithm, reasoning);
|
|
447
487
|
}
|
|
448
488
|
}
|
|
449
489
|
}
|
|
@@ -479,13 +519,11 @@ export default class SudokuSolver {
|
|
|
479
519
|
}
|
|
480
520
|
/** `houseCells` is one full row, column, or box (9 cells). */
|
|
481
521
|
getHouseContext(houseCells) {
|
|
482
|
-
const
|
|
483
|
-
if (
|
|
484
|
-
return { wherePhrase:
|
|
485
|
-
}
|
|
486
|
-
if (houseCells.every((c) => c.col === h.col)) {
|
|
487
|
-
return { wherePhrase: `column ${h.col + 1}`, sameKindWord: "column" };
|
|
522
|
+
const shared = sharedHouseContainingCells(houseCells);
|
|
523
|
+
if (shared) {
|
|
524
|
+
return { wherePhrase: shared.wherePhrase, sameKindWord: shared.kind };
|
|
488
525
|
}
|
|
526
|
+
const h = houseCells[0];
|
|
489
527
|
return { wherePhrase: `box ${boxNumber(h.row, h.col)}`, sameKindWord: "box" };
|
|
490
528
|
}
|
|
491
529
|
tryNakedSubsetInHouse(houseCells, k) {
|
package/package.json
CHANGED