@wsabol/sudoku-solver 0.1.8 → 0.1.9

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.
@@ -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" | "Pointing Triple" | "X-Wing" | "Naked Pair" | "Naked Triple" | "Naked Quad" | "Hidden Pair" | "Hidden Triple" | "Hidden Quad";
4
+ export type Algorithm = "Last Digit" | "Full House" | "Naked Single" | "Hidden Single" | "Pointing Pair" | "Pointing Triple" | "X-Wing" | "Swordfish" | "Naked Pair" | "Naked Triple" | "Naked Quad" | "Hidden Pair" | "Hidden Triple" | "Hidden 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 {
@@ -43,12 +43,18 @@ export default class SudokuSolver {
43
43
  private findHiddenSingleInCol;
44
44
  private findHiddenSingleInBox;
45
45
  private findPointingPairTriple;
46
- /** Empty cells in `row` where `digit` is still a candidate (column indices). */
47
- private colsWithDigitCandidates;
48
- /** Empty cells in `col` where `digit` is still a candidate (row indices). */
49
- private rowsWithDigitCandidates;
50
- /** Classic row/column X-Wing only (box forms duplicate pointing / line–box). */
51
- private findXWing;
46
+ /**
47
+ * Cover indices for a base line: column indices when scanning by row, row indices when scanning by column.
48
+ * Returns only empty cells where `digit` is still a candidate.
49
+ */
50
+ private coverIndicesForLine;
51
+ /**
52
+ * Generic N-fish detector (X-Wing = 2, Swordfish = 3).
53
+ * Finds `fishSize` base lines whose candidates for a digit span exactly `fishSize` cover lines,
54
+ * then eliminates that digit from those cover lines outside the base lines.
55
+ * Checks row-based orientation first, then column-based.
56
+ */
57
+ private findFishOfSize;
52
58
  private eachHouseInOrder;
53
59
  private findNakedSubsetElimination;
54
60
  /** `houseCells` is one full row, column, or box (9 cells). */
@@ -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,eAAe,GACf,iBAAiB,GACjB,QAAQ,GACR,YAAY,GACZ,cAAc,GACd,YAAY,GACZ,aAAa,GACb,eAAe,GACf,aAAa,CAAC;AAEpB,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;AAsD7B,MAAM,CAAC,OAAO,OAAO,YAAY;IAC7B,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,aAAa,CAQnC;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;IAqBxB,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,gFAAgF;IAChF,OAAO,CAAC,uBAAuB;IAU/B,6EAA6E;IAC7E,OAAO,CAAC,uBAAuB;IAU/B,gFAAgF;IAChF,OAAO,CAAC,SAAS;IA8DjB,OAAO,CAAC,gBAAgB;IAkBxB,OAAO,CAAC,0BAA0B;IAUlC,8DAA8D;IAC9D,OAAO,CAAC,eAAe;IAYvB,OAAO,CAAC,qBAAqB;IAwD7B,OAAO,CAAC,2BAA2B;IAUnC,OAAO,CAAC,+BAA+B;IAWvC,OAAO,CAAC,sBAAsB;IAgE9B,OAAO,CAAC,gBAAgB;CAe3B"}
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,QAAQ,GACR,WAAW,GACX,YAAY,GACZ,cAAc,GACd,YAAY,GACZ,aAAa,GACb,eAAe,GACf,aAAa,CAAC;AAEpB,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;AAuD7B,MAAM,CAAC,OAAO,OAAO,YAAY;IAC7B,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,aAAa,CASnC;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;IAuBxB,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;;;OAGG;IACH,OAAO,CAAC,mBAAmB;IAW3B;;;;;OAKG;IACH,OAAO,CAAC,cAAc;IAqDtB,OAAO,CAAC,gBAAgB;IAkBxB,OAAO,CAAC,0BAA0B;IAUlC,8DAA8D;IAC9D,OAAO,CAAC,eAAe;IAYvB,OAAO,CAAC,qBAAqB;IAwD7B,OAAO,CAAC,2BAA2B;IAUnC,OAAO,CAAC,+BAA+B;IAWvC,OAAO,CAAC,sBAAsB;IAgE9B,OAAO,CAAC,gBAAgB;CAe3B"}
@@ -42,6 +42,7 @@ export default class SudokuSolver {
42
42
  "NakedSubset",
43
43
  "HiddenSubset",
44
44
  "Fish",
45
+ "Swordfish",
45
46
  "NakedHiddenQuads",
46
47
  ];
47
48
  board;
@@ -324,7 +325,9 @@ export default class SudokuSolver {
324
325
  case "Pointing":
325
326
  return this.findPointingPairTriple();
326
327
  case "Fish":
327
- return this.findXWing();
328
+ return this.findFishOfSize(2);
329
+ case "Swordfish":
330
+ return this.findFishOfSize(3);
328
331
  case "NakedSubset":
329
332
  return this.findNakedSubsetElimination();
330
333
  case "HiddenSubset":
@@ -501,85 +504,69 @@ export default class SudokuSolver {
501
504
  }
502
505
  return null;
503
506
  }
504
- /** Empty cells in `row` where `digit` is still a candidate (column indices). */
505
- colsWithDigitCandidates(row, digit) {
506
- const cols = [];
507
- for (let col = 0; col < 9; col++) {
507
+ /**
508
+ * Cover indices for a base line: column indices when scanning by row, row indices when scanning by column.
509
+ * Returns only empty cells where `digit` is still a candidate.
510
+ */
511
+ coverIndicesForLine(lineIdx, digit, byRow) {
512
+ const result = [];
513
+ for (let crossIdx = 0; crossIdx < 9; crossIdx++) {
514
+ const [row, col] = byRow ? [lineIdx, crossIdx] : [crossIdx, lineIdx];
508
515
  if (this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(digit)) {
509
- cols.push(col);
516
+ result.push(crossIdx);
510
517
  }
511
518
  }
512
- return cols;
513
- }
514
- /** Empty cells in `col` where `digit` is still a candidate (row indices). */
515
- rowsWithDigitCandidates(col, digit) {
516
- const rows = [];
517
- for (let row = 0; row < 9; row++) {
518
- if (this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(digit)) {
519
- rows.push(row);
520
- }
521
- }
522
- return rows;
523
- }
524
- /** Classic row/column X-Wing only (box forms duplicate pointing / line–box). */
525
- findXWing() {
526
- for (let digit = 1; digit <= 9; digit++) {
527
- for (let r1 = 0; r1 < 9; r1++) {
528
- const cols1 = this.colsWithDigitCandidates(r1, digit);
529
- if (cols1.length !== 2)
530
- continue;
531
- const baseCols = [...cols1].sort((a, b) => a - b);
532
- for (let r2 = r1 + 1; r2 < 9; r2++) {
533
- const cols2 = this.colsWithDigitCandidates(r2, digit);
534
- if (cols2.length !== 2)
535
- continue;
536
- const pair = [...cols2].sort((a, b) => a - b);
537
- if (pair[0] !== baseCols[0] || pair[1] !== baseCols[1])
538
- continue;
539
- const eliminations = [];
540
- for (const col of baseCols) {
541
- for (let row = 0; row < 9; row++) {
542
- if (row === r1 || row === r2)
543
- continue;
544
- if (this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(digit)) {
545
- eliminations.push({ row, col, value: digit });
546
- }
547
- }
548
- }
549
- if (eliminations.length > 0) {
550
- const reasoning = `X-Wing on ${digit}: rows ${r1 + 1} and ${r2 + 1} each have ${digit} only in columns ${baseCols[0] + 1} and ${baseCols[1] + 1}, so ${digit} cannot appear elsewhere in those columns.`;
551
- return this.finalizeElimination(eliminations, "X-Wing", reasoning);
519
+ return result;
520
+ }
521
+ /**
522
+ * Generic N-fish detector (X-Wing = 2, Swordfish = 3).
523
+ * Finds `fishSize` base lines whose candidates for a digit span exactly `fishSize` cover lines,
524
+ * then eliminates that digit from those cover lines outside the base lines.
525
+ * Checks row-based orientation first, then column-based.
526
+ */
527
+ findFishOfSize(fishSize) {
528
+ const algorithm = fishSize === 2 ? "X-Wing" : "Swordfish";
529
+ for (const byRow of [true, false]) {
530
+ const baseLabel = byRow ? "rows" : "columns";
531
+ const coverLabel = byRow ? "columns" : "rows";
532
+ for (let digit = 1; digit <= 9; digit++) {
533
+ const eligible = [];
534
+ for (let lineIdx = 0; lineIdx < 9; lineIdx++) {
535
+ const cover = this.coverIndicesForLine(lineIdx, digit, byRow);
536
+ if (cover.length >= 2 && cover.length <= fishSize) {
537
+ eligible.push({ lineIdx, cover });
552
538
  }
553
539
  }
554
- }
555
- }
556
- for (let digit = 1; digit <= 9; digit++) {
557
- for (let c1 = 0; c1 < 9; c1++) {
558
- const rows1 = this.rowsWithDigitCandidates(c1, digit);
559
- if (rows1.length !== 2)
560
- continue;
561
- const baseRows = [...rows1].sort((a, b) => a - b);
562
- for (let c2 = c1 + 1; c2 < 9; c2++) {
563
- const rows2 = this.rowsWithDigitCandidates(c2, digit);
564
- if (rows2.length !== 2)
565
- continue;
566
- const pair = [...rows2].sort((a, b) => a - b);
567
- if (pair[0] !== baseRows[0] || pair[1] !== baseRows[1])
568
- continue;
569
- const eliminations = [];
570
- for (const row of baseRows) {
571
- for (let col = 0; col < 9; col++) {
572
- if (col === c1 || col === c2)
540
+ // Iterate all C(eligible, fishSize) combinations.
541
+ for (let i = 0; i < eligible.length - (fishSize - 1); i++) {
542
+ for (let j = i + 1; j < eligible.length - (fishSize - 2); j++) {
543
+ const pairs = fishSize === 2
544
+ ? [[eligible[i], eligible[j]]]
545
+ : Array.from({ length: eligible.length - j - 1 }, (_, d) => [eligible[i], eligible[j], eligible[j + 1 + d]]);
546
+ for (const combo of pairs) {
547
+ const unionCover = [...new Set(combo.flatMap((e) => e.cover))].sort((a, b) => a - b);
548
+ if (unionCover.length !== fishSize)
573
549
  continue;
574
- if (this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(digit)) {
575
- eliminations.push({ row, col, value: digit });
550
+ const baseIndices = combo.map((e) => e.lineIdx);
551
+ const eliminations = [];
552
+ for (const coverIdx of unionCover) {
553
+ for (let otherLineIdx = 0; otherLineIdx < 9; otherLineIdx++) {
554
+ if (baseIndices.includes(otherLineIdx))
555
+ continue;
556
+ const [row, col] = byRow ? [otherLineIdx, coverIdx] : [coverIdx, otherLineIdx];
557
+ if (this.board[row][col] === 0 && this.possiblesGrid[row][col].includes(digit)) {
558
+ eliminations.push({ row, col, value: digit });
559
+ }
560
+ }
561
+ }
562
+ if (eliminations.length > 0) {
563
+ const baseList = baseIndices.map((i) => i + 1).join(", ").replace(/,([^,]*)$/, " and$1");
564
+ const coverList = unionCover.map((i) => i + 1).join(", ").replace(/,([^,]*)$/, " and$1");
565
+ const reasoning = `${algorithm} on ${digit}: ${baseLabel} ${baseList} have ${digit} only in ${coverLabel} ${coverList}, so ${digit} cannot appear elsewhere in those ${coverLabel}.`;
566
+ return this.finalizeElimination(eliminations, algorithm, reasoning);
576
567
  }
577
568
  }
578
569
  }
579
- if (eliminations.length > 0) {
580
- const reasoning = `X-Wing on ${digit}: columns ${c1 + 1} and ${c2 + 1} each have ${digit} only in rows ${baseRows[0] + 1} and ${baseRows[1] + 1}, so ${digit} cannot appear elsewhere in those rows.`;
581
- return this.finalizeElimination(eliminations, "X-Wing", reasoning);
582
- }
583
570
  }
584
571
  }
585
572
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wsabol/sudoku-solver",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "TypeScript Sudoku solver module with solve, next move, describe, and validate APIs.",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",