@johnmorrisdotca/kazu 1.2.0 → 1.4.0

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.
Files changed (113) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +69 -27
  3. package/dist/akari-entry.d.ts +1 -0
  4. package/dist/akari-entry.js +1 -0
  5. package/dist/akari.constants.d.ts +12 -0
  6. package/dist/akari.constants.js +12 -0
  7. package/dist/akari.types.d.ts +19 -0
  8. package/dist/akariGenerate.d.ts +10 -5
  9. package/dist/akariGenerate.js +101 -65
  10. package/dist/akariLogic.d.ts +10 -0
  11. package/dist/akariLogic.js +84 -0
  12. package/dist/akariRate.d.ts +6 -0
  13. package/dist/akariRate.js +34 -0
  14. package/dist/akariSolve.d.ts +1 -1
  15. package/dist/akariSolve.js +8 -96
  16. package/dist/akariTemplate.d.ts +7 -0
  17. package/dist/akariTemplate.js +78 -0
  18. package/dist/cells.d.ts +4 -4
  19. package/dist/cells.js +6 -6
  20. package/dist/csp.d.ts +63 -0
  21. package/dist/csp.js +162 -0
  22. package/dist/fillomino-entry.d.ts +1 -0
  23. package/dist/fillomino-entry.js +1 -0
  24. package/dist/fillomino.constants.d.ts +6 -2
  25. package/dist/fillomino.constants.js +6 -2
  26. package/dist/fillomino.types.d.ts +18 -1
  27. package/dist/fillominoBuild.d.ts +10 -0
  28. package/dist/fillominoBuild.js +78 -0
  29. package/dist/fillominoGenerate.d.ts +7 -1
  30. package/dist/fillominoGenerate.js +75 -107
  31. package/dist/fillominoLogic.d.ts +14 -0
  32. package/dist/fillominoLogic.js +177 -0
  33. package/dist/fillominoMount.js +4 -1
  34. package/dist/fillominoRate.d.ts +3 -0
  35. package/dist/fillominoRate.js +57 -0
  36. package/dist/fillominoSolve.js +15 -88
  37. package/dist/groupSolve.d.ts +9 -0
  38. package/dist/groupSolve.js +66 -0
  39. package/dist/hitori-entry.d.ts +1 -0
  40. package/dist/hitori-entry.js +1 -0
  41. package/dist/hitori.constants.d.ts +7 -1
  42. package/dist/hitori.constants.js +7 -1
  43. package/dist/hitori.types.d.ts +18 -1
  44. package/dist/hitoriBoard.js +2 -1
  45. package/dist/hitoriBuild.d.ts +12 -0
  46. package/dist/hitoriBuild.js +117 -0
  47. package/dist/hitoriGenerate.d.ts +10 -3
  48. package/dist/hitoriGenerate.js +59 -157
  49. package/dist/hitoriLogic.d.ts +12 -0
  50. package/dist/hitoriLogic.js +189 -0
  51. package/dist/hitoriRate.d.ts +3 -0
  52. package/dist/hitoriRate.js +32 -0
  53. package/dist/hitoriSolve.d.ts +4 -1
  54. package/dist/hitoriSolve.js +11 -62
  55. package/dist/kakuro-entry.d.ts +1 -0
  56. package/dist/kakuro-entry.js +1 -0
  57. package/dist/kakuro.constants.d.ts +6 -1
  58. package/dist/kakuro.constants.js +6 -1
  59. package/dist/kakuro.types.d.ts +19 -0
  60. package/dist/kakuroBuild.d.ts +30 -0
  61. package/dist/kakuroBuild.js +337 -0
  62. package/dist/kakuroGenerate.d.ts +10 -3
  63. package/dist/kakuroGenerate.js +54 -122
  64. package/dist/kakuroLogic.d.ts +13 -0
  65. package/dist/kakuroLogic.js +153 -0
  66. package/dist/kakuroRate.d.ts +3 -0
  67. package/dist/kakuroRate.js +40 -0
  68. package/dist/kakuroSolve.js +17 -69
  69. package/dist/kakuroTemplate.d.ts +3 -0
  70. package/dist/kakuroTemplate.js +130 -0
  71. package/dist/kinds.js +1 -1
  72. package/dist/layout.js +1 -0
  73. package/dist/mount.d.ts +0 -18
  74. package/dist/mount.js +24 -3
  75. package/dist/names.js +3 -3
  76. package/dist/numberPlace.d.ts +1 -1
  77. package/dist/numberPlace.js +21 -8
  78. package/dist/shikaku-entry.d.ts +1 -0
  79. package/dist/shikaku-entry.js +1 -0
  80. package/dist/shikaku.constants.d.ts +5 -2
  81. package/dist/shikaku.constants.js +5 -2
  82. package/dist/shikaku.types.d.ts +18 -1
  83. package/dist/shikakuBuild.d.ts +21 -0
  84. package/dist/shikakuBuild.js +135 -0
  85. package/dist/shikakuGenerate.d.ts +6 -1
  86. package/dist/shikakuGenerate.js +50 -35
  87. package/dist/shikakuLogic.d.ts +13 -0
  88. package/dist/shikakuLogic.js +84 -0
  89. package/dist/shikakuRate.d.ts +3 -0
  90. package/dist/shikakuRate.js +27 -0
  91. package/dist/shikakuSolve.d.ts +1 -1
  92. package/dist/shikakuSolve.js +23 -44
  93. package/dist/shikakuTemplate.d.ts +3 -0
  94. package/dist/shikakuTemplate.js +43 -0
  95. package/dist/slitherlink-entry.d.ts +1 -0
  96. package/dist/slitherlink-entry.js +1 -0
  97. package/dist/slitherlink.constants.d.ts +5 -0
  98. package/dist/slitherlink.constants.js +5 -0
  99. package/dist/slitherlink.types.d.ts +18 -0
  100. package/dist/slitherlinkGenerate.d.ts +10 -3
  101. package/dist/slitherlinkGenerate.js +161 -80
  102. package/dist/slitherlinkLogic.d.ts +10 -0
  103. package/dist/slitherlinkLogic.js +267 -0
  104. package/dist/slitherlinkRate.d.ts +3 -0
  105. package/dist/slitherlinkRate.js +26 -0
  106. package/dist/slitherlinkSolve.d.ts +1 -1
  107. package/dist/slitherlinkSolve.js +9 -117
  108. package/dist/slitherlinkTemplate.d.ts +3 -0
  109. package/dist/slitherlinkTemplate.js +101 -0
  110. package/dist/strings.js +2 -0
  111. package/dist/version.d.ts +1 -1
  112. package/dist/version.js +1 -1
  113. package/package.json +2 -2
@@ -1,69 +1,18 @@
1
1
  import { HITORI_MAX_NODES } from "./hitori.constants.js";
2
- import { checkHitori, hitoriNeighbors, isHitoriBoard } from "./hitoriBoard.js";
3
- function firstUnshadedDuplicate(board, shaded) {
4
- const size = board.size;
5
- for (let y = 0; y < size; y += 1)
6
- for (let x = 0; x < size; x += 1) {
7
- const cell = y * size + x;
8
- if (shaded[cell])
9
- continue;
10
- for (let otherX = x + 1; otherX < size; otherX += 1) {
11
- const other = y * size + otherX;
12
- if (!shaded[other] && board.numbers[cell] === board.numbers[other])
13
- return [cell, other];
14
- }
15
- for (let otherY = y + 1; otherY < size; otherY += 1) {
16
- const other = otherY * size + x;
17
- if (!shaded[other] && board.numbers[cell] === board.numbers[other])
18
- return [cell, other];
19
- }
20
- }
21
- return null;
22
- }
23
- /** A valid answer cannot contain an unnecessary shade; this avoids treating extra black cells as separate puzzle solutions. */
24
- function isMinimalSolution(board, shaded) {
25
- if (!checkHitori(board, shaded).ok)
26
- return false;
27
- for (let cell = 0; cell < shaded.length; cell += 1)
28
- if (shaded[cell]) {
29
- const smaller = [...shaded];
30
- smaller[cell] = false;
31
- if (checkHitori(board, smaller).ok)
32
- return false;
33
- }
34
- return true;
35
- }
36
- /** Counts distinct minimal shade patterns. A non-complete result never asserts uniqueness. */
2
+ import { isHitoriBoard } from "./hitoriBoard.js";
3
+ import { hitoriModel } from "./hitoriLogic.js";
4
+ import { openSlots, solveCsp } from "./csp.js";
5
+ /**
6
+ * Counts distinct minimal shade patterns: an answer shades only squares that settle a duplicate, so extra shades
7
+ * are never a second answer. A non-complete result never asserts uniqueness.
8
+ */
37
9
  export function solveHitori(board, options = {}) {
38
10
  if (!isHitoriBoard(board))
39
11
  throw new RangeError("Invalid Hitori board");
40
12
  const limit = Math.max(1, Math.floor(options.limit ?? 2));
41
13
  const budget = Math.max(1, Math.floor(options.nodes ?? HITORI_MAX_NODES));
42
- let nodes = 0, count = 0, solution = null, exhausted = false;
43
- const search = (shaded) => {
44
- if (count >= limit || exhausted)
45
- return;
46
- nodes += 1;
47
- if (nodes > budget) {
48
- exhausted = true;
49
- return;
50
- }
51
- const duplicate = firstUnshadedDuplicate(board, shaded);
52
- if (!duplicate) {
53
- if (isMinimalSolution(board, shaded)) {
54
- count += 1;
55
- solution ?? (solution = [...shaded]);
56
- }
57
- return;
58
- }
59
- for (const cell of duplicate) {
60
- if (hitoriNeighbors(board.size, cell).some(neighbor => shaded[neighbor]))
61
- continue;
62
- shaded[cell] = true;
63
- search(shaded);
64
- shaded[cell] = false;
65
- }
66
- };
67
- search(Array(board.size * board.size).fill(false));
68
- return { count, solution, complete: !exhausted && count < limit, nodes: Math.min(nodes, budget) };
14
+ const { csp } = hitoriModel(board);
15
+ const found = solveCsp(csp, openSlots(csp), limit, budget);
16
+ const solution = found.solution ? Array.from({ length: board.size ** 2 }, (_, cell) => found.solution[2 * cell + 1] === 1) : null;
17
+ return { count: found.count, solution, complete: !found.exhausted && !found.stopped, nodes: found.nodes };
69
18
  }
@@ -3,6 +3,7 @@ export * from "./kakuro.constants.ts";
3
3
  export * from "./kakuroBoard.ts";
4
4
  export * from "./kakuroSolve.ts";
5
5
  export * from "./kakuroGenerate.ts";
6
+ export * from "./kakuroRate.ts";
6
7
  export * from "./kakuroGame.ts";
7
8
  export { drawKakuro } from "./kakuroDraw.ts";
8
9
  export type { KakuroDrawOptions } from "./kakuroDraw.ts";
@@ -3,6 +3,7 @@ export * from "./kakuro.constants.js";
3
3
  export * from "./kakuroBoard.js";
4
4
  export * from "./kakuroSolve.js";
5
5
  export * from "./kakuroGenerate.js";
6
+ export * from "./kakuroRate.js";
6
7
  export * from "./kakuroGame.js";
7
8
  export { drawKakuro } from "./kakuroDraw.js";
8
9
  export { mountKakuro } from "./kakuroMount.js";
@@ -1,6 +1,11 @@
1
1
  export declare const KAKURO_MIN_SIDE = 3;
2
- export declare const KAKURO_MAX_SIDE = 10;
2
+ export declare const KAKURO_MAX_SIDE = 12;
3
3
  export declare const KAKURO_MAX_NODES = 250000;
4
4
  export declare const KAKURO_MAX_ATTEMPTS = 120;
5
5
  export declare const KAKURO_GENERATION_ATTEMPTS = 1200;
6
6
  export declare const KAKURO_GENERATION_NODES = 1000000;
7
+ /** How hard a puzzle is made, by what a person must do to solve it; see `rateKakuro`. */
8
+ export declare const KAKURO_LEVELS: readonly ["easy", "medium", "hard", "extra-hard"];
9
+ /** The sides on offer, counting the row and column of totals; any side from 5 to `KAKURO_MAX_SIDE` can be made. */
10
+ export declare const KAKURO_SIZES: readonly [6, 8, 10, 12];
11
+ export declare const KAKURO_LEAST_GENERATED_SIDE = 5;
@@ -1,6 +1,11 @@
1
1
  export const KAKURO_MIN_SIDE = 3;
2
- export const KAKURO_MAX_SIDE = 10;
2
+ export const KAKURO_MAX_SIDE = 12;
3
3
  export const KAKURO_MAX_NODES = 250000;
4
4
  export const KAKURO_MAX_ATTEMPTS = 120;
5
5
  export const KAKURO_GENERATION_ATTEMPTS = 1200;
6
6
  export const KAKURO_GENERATION_NODES = 1000000;
7
+ /** How hard a puzzle is made, by what a person must do to solve it; see `rateKakuro`. */
8
+ export const KAKURO_LEVELS = ["easy", "medium", "hard", "extra-hard"];
9
+ /** The sides on offer, counting the row and column of totals; any side from 5 to `KAKURO_MAX_SIDE` can be made. */
10
+ export const KAKURO_SIZES = [6, 8, 10, 12];
11
+ export const KAKURO_LEAST_GENERATED_SIDE = 5;
@@ -16,10 +16,29 @@ export type KakuroRun = {
16
16
  cells: readonly number[];
17
17
  sum: number;
18
18
  };
19
+ export type KakuroLevel = "easy" | "medium" | "hard" | "extra-hard";
19
20
  export type KakuroPuzzle = KakuroBoard & {
20
21
  seed: number;
22
+ level: KakuroLevel;
21
23
  solution: readonly number[];
22
24
  };
25
+ /**
26
+ * How hard a board is, measured by solving it. `depth` 0 means the rules solve it, 1 that somebody has to suppose a
27
+ * digit and watch it break, 2 that more than that is needed; `plain` says the single-run rules were enough (no
28
+ * digit that must appear had to be traced). `probes` is how many suppositions depth 1 needed. The rest describes the runs.
29
+ */
30
+ export type KakuroRating = {
31
+ depth: 0 | 1 | 2;
32
+ plain: boolean;
33
+ probes: number;
34
+ whites: number;
35
+ runs: number;
36
+ longest: number;
37
+ /** Mean number of squares in a run. */
38
+ meanRun: number;
39
+ /** Runs whose total can be made in only one way, as a share of the runs. */
40
+ fixedShare: number;
41
+ };
23
42
  export type KakuroCheck = {
24
43
  ok: boolean;
25
44
  complete: boolean;
@@ -0,0 +1,30 @@
1
+ import type { KakuroBoard, KakuroLevel } from "./kakuro.types.ts";
2
+ import type { Random } from "./random.ts";
3
+ /** How the black squares are scattered, and the longest run a level allows. */
4
+ export declare const SHAPE: Record<KakuroLevel, {
5
+ black: number;
6
+ longest: number;
7
+ }>;
8
+ /** Which squares are white: no run shorter than two, none longer than the level allows, and all of them one connected piece. */
9
+ export declare function kakuroPattern(side: number, level: KakuroLevel, random: Random): boolean[] | null;
10
+ /** How many different sets of digits can make `sum` out of `length` squares. */
11
+ export declare function kakuroWays(length: number, sum: number): number;
12
+ /** A random digit for every white square with no digit repeated in a run, or null when the search gives up. */
13
+ export declare function kakuroDigits(side: number, white: readonly boolean[], random: Random): number[] | null;
14
+ /**
15
+ * Changes digits one square at a time, keeping every run free of repeats, to make runs whose totals can be made in
16
+ * few ways: a run that says a lot about its digits lets the solver start from the rules alone. A change is kept when
17
+ * it does not raise the count of ways, summed over runs, which is `weight` raised to the number of ways.
18
+ */
19
+ export declare function kakuroTighten(side: number, white: readonly boolean[], digits: number[], random: Random, rounds: number): void;
20
+ /** The totals of a pattern and its digits, as a board. */
21
+ export declare function kakuroBoardOf(side: number, white: readonly boolean[], digits: readonly number[]): KakuroBoard;
22
+ /**
23
+ * A board with exactly one answer, or null. While the board has other answers, each one is broken: a square the
24
+ * two answers disagree on is turned black (when every run stays two long and the squares stay one piece) or given
25
+ * another digit that still repeats nowhere in its runs, which changes two totals.
26
+ */
27
+ export declare function candidateKakuro(side: number, level: KakuroLevel, random: Random): {
28
+ board: KakuroBoard;
29
+ digits: readonly number[];
30
+ } | null;
@@ -0,0 +1,337 @@
1
+ import { isKakuroBoard } from "./kakuroBoard.js";
2
+ import { kakuroModel } from "./kakuroLogic.js";
3
+ import { countCsp, logicCsp, openSlots } from "./csp.js";
4
+ import { shuffled } from "./random.js";
5
+ /** How the black squares are scattered, and the longest run a level allows. */
6
+ export const SHAPE = {
7
+ easy: { black: .34, longest: 4 },
8
+ medium: { black: .3, longest: 5 },
9
+ hard: { black: .26, longest: 7 },
10
+ "extra-hard": { black: .22, longest: 9 },
11
+ };
12
+ /** Which squares are white: no run shorter than two, none longer than the level allows, and all of them one connected piece. */
13
+ export function kakuroPattern(side, level, random) {
14
+ for (let attempt = 0; attempt < 60; attempt += 1) {
15
+ const white = laidRows(side, level, random);
16
+ if (white && oneStrip(side, white))
17
+ return white;
18
+ }
19
+ return null;
20
+ }
21
+ /**
22
+ * Lays the squares out row by row, each row left to right, so that a run is never one square or longer than the
23
+ * level allows: a column whose run is one square long must go on, one at the limit must stop, and a row's own
24
+ * runs are kept the same way. A square is white with the level's chance, except where a rule forces it.
25
+ */
26
+ function laidRows(side, level, random) {
27
+ const { black, longest } = SHAPE[level];
28
+ const white = Array(side * side).fill(false), above = Array(side).fill(0);
29
+ const chance = 1 - black * (.85 + random() * .3);
30
+ for (let row = 1; row < side; row += 1) {
31
+ const last = row === side - 1, chosen = Array(side).fill(false);
32
+ let steps = 0;
33
+ const place = (col, run) => {
34
+ if (++steps > 4000)
35
+ return false;
36
+ if (col === side)
37
+ return run !== 1;
38
+ const mustWhite = above[col] === 1;
39
+ const mustBlack = above[col] === longest || last && above[col] === 0;
40
+ const options = [];
41
+ if (mustWhite && mustBlack)
42
+ return false;
43
+ if (mustWhite)
44
+ options.push(true);
45
+ else if (mustBlack)
46
+ options.push(false);
47
+ else
48
+ options.push(...(random() < chance ? [true, false] : [false, true]));
49
+ for (const isWhite of options) {
50
+ if (isWhite && run + 1 > longest)
51
+ continue;
52
+ if (!isWhite && run === 1)
53
+ continue;
54
+ chosen[col] = isWhite;
55
+ if (place(col + 1, isWhite ? run + 1 : 0))
56
+ return true;
57
+ }
58
+ return false;
59
+ };
60
+ if (!place(1, 0))
61
+ return null;
62
+ for (let col = 1; col < side; col += 1) {
63
+ white[row * side + col] = chosen[col];
64
+ above[col] = chosen[col] ? above[col] + 1 : 0;
65
+ }
66
+ }
67
+ return white;
68
+ }
69
+ /** How many different sets of digits can make `sum` out of `length` squares. */
70
+ export function kakuroWays(length, sum) {
71
+ let ways = 0;
72
+ for (let mask = 1; mask < 512; mask += 1) {
73
+ let n = 0, total = 0;
74
+ for (let d = 1; d <= 9; d += 1)
75
+ if (mask & 1 << d - 1) {
76
+ n += 1;
77
+ total += d;
78
+ }
79
+ if (n === length && total === sum)
80
+ ways += 1;
81
+ }
82
+ return ways;
83
+ }
84
+ const WAYS = Array.from({ length: 10 }, (_, length) => Array.from({ length: 46 }, (_, sum) => length < 2 ? 0 : kakuroWays(length, sum)));
85
+ /** A random digit for every white square with no digit repeated in a run, or null when the search gives up. */
86
+ export function kakuroDigits(side, white, random) {
87
+ const digits = Array(white.length).fill(0);
88
+ let steps = 0;
89
+ const fill = (at) => {
90
+ while (at < white.length && !white[at])
91
+ at += 1;
92
+ if (at === white.length)
93
+ return true;
94
+ if (++steps > 40000)
95
+ return false;
96
+ const used = new Set();
97
+ for (let left = at - 1; left >= 0 && white[left] && (left + 1) % side !== 0; left -= 1)
98
+ used.add(digits[left]);
99
+ for (let up = at - side; up >= 0 && white[up]; up -= side)
100
+ used.add(digits[up]);
101
+ for (const digit of shuffled([1, 2, 3, 4, 5, 6, 7, 8, 9].filter(d => !used.has(d)), random)) {
102
+ digits[at] = digit;
103
+ if (fill(at + 1))
104
+ return true;
105
+ }
106
+ digits[at] = 0;
107
+ return false;
108
+ };
109
+ return fill(0) ? digits : null;
110
+ }
111
+ /**
112
+ * Changes digits one square at a time, keeping every run free of repeats, to make runs whose totals can be made in
113
+ * few ways: a run that says a lot about its digits lets the solver start from the rules alone. A change is kept when
114
+ * it does not raise the count of ways, summed over runs, which is `weight` raised to the number of ways.
115
+ */
116
+ export function kakuroTighten(side, white, digits, random, rounds) {
117
+ const runsAt = (cell) => {
118
+ const across = [cell], down = [cell];
119
+ for (let at = cell - 1; at >= 0 && white[at] && (at + 1) % side !== 0; at -= 1)
120
+ across.push(at);
121
+ for (let at = cell + 1; at < white.length && white[at] && at % side !== 0; at += 1)
122
+ across.push(at);
123
+ for (let at = cell - side; at >= 0 && white[at]; at -= side)
124
+ down.push(at);
125
+ for (let at = cell + side; at < white.length && white[at]; at += side)
126
+ down.push(at);
127
+ return { across, down };
128
+ };
129
+ const ways = (run, swap) => {
130
+ let sum = 0;
131
+ for (const at of run)
132
+ sum += swap && at === swap.cell ? swap.digit : digits[at];
133
+ return WAYS[run.length][sum];
134
+ };
135
+ const cells = white.flatMap((w, i) => w ? [i] : []);
136
+ for (let round = 0; round < rounds; round += 1) {
137
+ const cell = cells[Math.floor(random() * cells.length)];
138
+ const { across, down } = runsAt(cell);
139
+ const used = new Set();
140
+ for (const at of [...across, ...down])
141
+ if (at !== cell)
142
+ used.add(digits[at]);
143
+ const options = [1, 2, 3, 4, 5, 6, 7, 8, 9].filter(d => !used.has(d) && d !== digits[cell]);
144
+ if (!options.length)
145
+ continue;
146
+ const digit = options[Math.floor(random() * options.length)];
147
+ const before = ways(across) + ways(down), after = ways(across, { cell, digit }) + ways(down, { cell, digit });
148
+ if (after <= before)
149
+ digits[cell] = digit;
150
+ }
151
+ }
152
+ /** The totals of a pattern and its digits, as a board. */
153
+ export function kakuroBoardOf(side, white, digits) {
154
+ const cells = white.map(w => w ? { kind: "white" } : { kind: "black", across: null, down: null });
155
+ for (let cell = 0; cell < white.length; cell += 1) {
156
+ if (white[cell])
157
+ continue;
158
+ const black = cells[cell];
159
+ if (cell % side < side - 1 && white[cell + 1]) {
160
+ let sum = 0;
161
+ for (let at = cell + 1; white[at]; at += 1) {
162
+ sum += digits[at];
163
+ if ((at + 1) % side === 0)
164
+ break;
165
+ }
166
+ black.across = sum;
167
+ }
168
+ if (cell + side < white.length && white[cell + side]) {
169
+ let sum = 0;
170
+ for (let at = cell + side; at < white.length && white[at]; at += side)
171
+ sum += digits[at];
172
+ black.down = sum;
173
+ }
174
+ }
175
+ return { width: side, height: side, cells };
176
+ }
177
+ /** Whether the white squares are one connected piece. */
178
+ function oneStrip(side, white) {
179
+ const first = white.indexOf(true);
180
+ if (first < 0)
181
+ return false;
182
+ const seen = new Set([first]), stack = [first];
183
+ while (stack.length) {
184
+ const at = stack.pop();
185
+ for (const next of [at - 1, at + 1, at - side, at + side]) {
186
+ if (next < 0 || next >= white.length || !white[next] || seen.has(next) || (next === at + 1 && next % side === 0))
187
+ continue;
188
+ seen.add(next);
189
+ stack.push(next);
190
+ }
191
+ }
192
+ return seen.size === white.filter(Boolean).length;
193
+ }
194
+ /** The squares in the same row or column strip as `cell` that have the given digit, to see what digits it may take. */
195
+ function digitsAround(side, white, digits, cell) {
196
+ const used = new Set();
197
+ for (let at = cell - 1; at >= 0 && white[at] && (at + 1) % side !== 0; at -= 1)
198
+ used.add(digits[at]);
199
+ for (let at = cell + 1; at < white.length && white[at] && at % side !== 0; at += 1)
200
+ used.add(digits[at]);
201
+ for (let at = cell - side; at >= 0 && white[at]; at -= side)
202
+ used.add(digits[at]);
203
+ for (let at = cell + side; at < white.length && white[at]; at += side)
204
+ used.add(digits[at]);
205
+ return used;
206
+ }
207
+ /**
208
+ * A board with exactly one answer, or null. While the board has other answers, each one is broken: a square the
209
+ * two answers disagree on is turned black (when every run stays two long and the squares stay one piece) or given
210
+ * another digit that still repeats nowhere in its runs, which changes two totals.
211
+ */
212
+ export function candidateKakuro(side, level, random) {
213
+ // Easy and medium boards are also eased until the rules they promise are enough to solve them.
214
+ const eased = level === "easy" ? 0 : level === "medium" ? 1 : -1;
215
+ const white = kakuroPattern(side, level, random);
216
+ if (!white)
217
+ return null;
218
+ const digits = kakuroDigits(side, white, random);
219
+ if (!digits)
220
+ return null;
221
+ if (level === "easy" || level === "medium")
222
+ kakuroTighten(side, white, digits, random, side * side * (level === "easy" ? 60 : 25));
223
+ const valid = () => {
224
+ for (let cell = 0; cell < white.length; cell += 1) {
225
+ if (!white[cell])
226
+ continue;
227
+ let across = 1, down = 1;
228
+ for (let at = cell - 1; at >= 0 && white[at] && (at + 1) % side !== 0; at -= 1)
229
+ across += 1;
230
+ for (let at = cell + 1; at < white.length && white[at] && at % side !== 0; at += 1)
231
+ across += 1;
232
+ for (let at = cell - side; at >= 0 && white[at]; at -= side)
233
+ down += 1;
234
+ for (let at = cell + side; at < white.length && white[at]; at += side)
235
+ down += 1;
236
+ if (across < 2 || down < 2)
237
+ return false;
238
+ }
239
+ return oneStrip(side, white);
240
+ };
241
+ for (let repair = 0; repair < 150; repair += 1) {
242
+ const board = kakuroBoardOf(side, white, digits);
243
+ if (!isKakuroBoard(board))
244
+ return null;
245
+ const model = kakuroModel(board, 1, random);
246
+ let found = countCsp(model.csp, openSlots(model.csp), 8, 3000);
247
+ for (let restart = 0; restart < 5 && found.exhausted; restart += 1)
248
+ found = countCsp(model.csp, openSlots(model.csp), 8, 3000);
249
+ if (found.exhausted) {
250
+ // Too many answers to even count: darken any square that can be darkened and look again.
251
+ const any = shuffled(white.flatMap((w, cell) => w ? [cell] : []), random).find(cell => { white[cell] = false; if (valid())
252
+ return true; white[cell] = true; return false; });
253
+ if (any === undefined)
254
+ return null;
255
+ continue;
256
+ }
257
+ if (found.count === 1 && eased < 0)
258
+ return { board, digits };
259
+ if (found.count === 1) {
260
+ const rules = kakuroModel(board, eased);
261
+ const run = logicCsp(rules.csp, openSlots(rules.csp), 0);
262
+ if (run.solved)
263
+ return { board, digits };
264
+ if (white.filter(Boolean).length < (side - 1) * (side - 1) * .42)
265
+ return null;
266
+ const open = rules.whites.filter((_, i) => { let n = 0; for (let k = 0; k < 9; k += 1)
267
+ n += run.alive[9 * i + k]; return n > 1; });
268
+ // Squares still open, then the others that share a run with one.
269
+ const near = new Set(open);
270
+ for (const cell of open) {
271
+ for (let at = cell - 1; at >= 0 && white[at] && (at + 1) % side !== 0; at -= 1)
272
+ near.add(at);
273
+ for (let at = cell + 1; at < white.length && white[at] && at % side !== 0; at += 1)
274
+ near.add(at);
275
+ for (let at = cell - side; at >= 0 && white[at]; at -= side)
276
+ near.add(at);
277
+ for (let at = cell + side; at < white.length && white[at]; at += side)
278
+ near.add(at);
279
+ }
280
+ const stuck = [...shuffled(open, random), ...shuffled([...near].filter(cell => !open.includes(cell)), random)];
281
+ let eased_ = false;
282
+ for (const cell of stuck) {
283
+ white[cell] = false;
284
+ if (valid()) {
285
+ eased_ = true;
286
+ break;
287
+ }
288
+ white[cell] = true;
289
+ }
290
+ if (!eased_) {
291
+ const any = shuffled(white.flatMap((w, cell) => w ? [cell] : []), random).find(cell => { white[cell] = false; if (valid())
292
+ return true; white[cell] = true; return false; });
293
+ if (any === undefined)
294
+ return null;
295
+ }
296
+ continue;
297
+ }
298
+ const digitOf = (answer, cell) => { const index = model.index[cell]; let d = 0; for (let k = 0; k < 9; k += 1)
299
+ if (answer[9 * index + k])
300
+ d = k + 1; return d; };
301
+ let moved = false;
302
+ for (const answer of found.solutions) {
303
+ const differing = shuffled(white.flatMap((w, cell) => w && digitOf(answer, cell) !== digits[cell] ? [cell] : []), random);
304
+ if (!differing.length)
305
+ continue;
306
+ const blacken = () => {
307
+ for (const cell of differing) {
308
+ if (!white[cell])
309
+ continue;
310
+ white[cell] = false;
311
+ if (valid())
312
+ return true;
313
+ white[cell] = true;
314
+ }
315
+ return false;
316
+ };
317
+ const change = () => {
318
+ for (const cell of differing) {
319
+ if (!white[cell])
320
+ continue;
321
+ const used = digitsAround(side, white, digits, cell);
322
+ const options = [1, 2, 3, 4, 5, 6, 7, 8, 9].filter(d => !used.has(d));
323
+ if (!options.length)
324
+ continue;
325
+ digits[cell] = options[Math.floor(random() * options.length)];
326
+ return true;
327
+ }
328
+ return false;
329
+ };
330
+ if (random() < .5 ? blacken() || change() : change() || blacken())
331
+ moved = true;
332
+ }
333
+ if (!moved)
334
+ return null;
335
+ }
336
+ return null;
337
+ }
@@ -1,3 +1,10 @@
1
- import type { KakuroPuzzle } from "./kakuro.types.ts";
2
- /** Makes a seeded 10×10 family of crossing runs and returns only a uniquely proved puzzle. */
3
- export declare function generateKakuro(seed?: number): KakuroPuzzle;
1
+ import type { KakuroLevel, KakuroPuzzle } from "./kakuro.types.ts";
2
+ /**
3
+ * Makes a seeded board of `size` squares a side (counting the row and column of totals) and proves it has one
4
+ * answer. A random pattern of black squares sets the runs, random digits fill them, and digits are changed until
5
+ * the answer is single. The board is then rated by solving it: easy by the single-run rules, medium once a digit
6
+ * that must appear has to be traced, hard by supposing, and extra-hard the most-supposing of several boards.
7
+ * If no board of the level is found within the attempts the next level down is tried, and the first generator
8
+ * (a 10×10 only) is the last resort, so a seed never throws.
9
+ */
10
+ export declare function generateKakuro(seed?: number, level?: KakuroLevel, size?: number): KakuroPuzzle;