@johnmorrisdotca/kazu 1.2.0 → 1.3.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 (101) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +58 -16
  3. package/dist/akari-entry.d.ts +1 -0
  4. package/dist/akari-entry.js +1 -0
  5. package/dist/akari.constants.d.ts +5 -0
  6. package/dist/akari.constants.js +5 -0
  7. package/dist/akari.types.d.ts +19 -0
  8. package/dist/akariGenerate.d.ts +8 -5
  9. package/dist/akariGenerate.js +88 -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/csp.d.ts +63 -0
  19. package/dist/csp.js +162 -0
  20. package/dist/fillomino-entry.d.ts +1 -0
  21. package/dist/fillomino-entry.js +1 -0
  22. package/dist/fillomino.constants.d.ts +6 -2
  23. package/dist/fillomino.constants.js +6 -2
  24. package/dist/fillomino.types.d.ts +18 -1
  25. package/dist/fillominoBuild.d.ts +10 -0
  26. package/dist/fillominoBuild.js +78 -0
  27. package/dist/fillominoGenerate.d.ts +7 -1
  28. package/dist/fillominoGenerate.js +75 -107
  29. package/dist/fillominoLogic.d.ts +14 -0
  30. package/dist/fillominoLogic.js +177 -0
  31. package/dist/fillominoMount.js +4 -1
  32. package/dist/fillominoRate.d.ts +3 -0
  33. package/dist/fillominoRate.js +57 -0
  34. package/dist/fillominoSolve.js +15 -88
  35. package/dist/hitori-entry.d.ts +1 -0
  36. package/dist/hitori-entry.js +1 -0
  37. package/dist/hitori.constants.d.ts +7 -1
  38. package/dist/hitori.constants.js +7 -1
  39. package/dist/hitori.types.d.ts +18 -1
  40. package/dist/hitoriBoard.js +2 -1
  41. package/dist/hitoriBuild.d.ts +12 -0
  42. package/dist/hitoriBuild.js +117 -0
  43. package/dist/hitoriGenerate.d.ts +10 -3
  44. package/dist/hitoriGenerate.js +59 -157
  45. package/dist/hitoriLogic.d.ts +12 -0
  46. package/dist/hitoriLogic.js +189 -0
  47. package/dist/hitoriRate.d.ts +3 -0
  48. package/dist/hitoriRate.js +32 -0
  49. package/dist/hitoriSolve.d.ts +4 -1
  50. package/dist/hitoriSolve.js +11 -62
  51. package/dist/kakuro-entry.d.ts +1 -0
  52. package/dist/kakuro-entry.js +1 -0
  53. package/dist/kakuro.constants.d.ts +6 -1
  54. package/dist/kakuro.constants.js +6 -1
  55. package/dist/kakuro.types.d.ts +19 -0
  56. package/dist/kakuroBuild.d.ts +30 -0
  57. package/dist/kakuroBuild.js +337 -0
  58. package/dist/kakuroGenerate.d.ts +10 -3
  59. package/dist/kakuroGenerate.js +54 -122
  60. package/dist/kakuroLogic.d.ts +13 -0
  61. package/dist/kakuroLogic.js +153 -0
  62. package/dist/kakuroRate.d.ts +3 -0
  63. package/dist/kakuroRate.js +40 -0
  64. package/dist/kakuroSolve.js +17 -69
  65. package/dist/kakuroTemplate.d.ts +3 -0
  66. package/dist/kakuroTemplate.js +130 -0
  67. package/dist/shikaku-entry.d.ts +1 -0
  68. package/dist/shikaku-entry.js +1 -0
  69. package/dist/shikaku.constants.d.ts +5 -2
  70. package/dist/shikaku.constants.js +5 -2
  71. package/dist/shikaku.types.d.ts +18 -1
  72. package/dist/shikakuBuild.d.ts +21 -0
  73. package/dist/shikakuBuild.js +135 -0
  74. package/dist/shikakuGenerate.d.ts +6 -1
  75. package/dist/shikakuGenerate.js +50 -35
  76. package/dist/shikakuLogic.d.ts +13 -0
  77. package/dist/shikakuLogic.js +84 -0
  78. package/dist/shikakuRate.d.ts +3 -0
  79. package/dist/shikakuRate.js +27 -0
  80. package/dist/shikakuSolve.d.ts +1 -1
  81. package/dist/shikakuSolve.js +23 -44
  82. package/dist/shikakuTemplate.d.ts +3 -0
  83. package/dist/shikakuTemplate.js +43 -0
  84. package/dist/slitherlink-entry.d.ts +1 -0
  85. package/dist/slitherlink-entry.js +1 -0
  86. package/dist/slitherlink.constants.d.ts +5 -0
  87. package/dist/slitherlink.constants.js +5 -0
  88. package/dist/slitherlink.types.d.ts +18 -0
  89. package/dist/slitherlinkGenerate.d.ts +10 -3
  90. package/dist/slitherlinkGenerate.js +161 -80
  91. package/dist/slitherlinkLogic.d.ts +10 -0
  92. package/dist/slitherlinkLogic.js +267 -0
  93. package/dist/slitherlinkRate.d.ts +3 -0
  94. package/dist/slitherlinkRate.js +26 -0
  95. package/dist/slitherlinkSolve.d.ts +1 -1
  96. package/dist/slitherlinkSolve.js +9 -117
  97. package/dist/slitherlinkTemplate.d.ts +3 -0
  98. package/dist/slitherlinkTemplate.js +101 -0
  99. package/dist/version.d.ts +1 -1
  100. package/dist/version.js +1 -1
  101. package/package.json +1 -1
@@ -0,0 +1,153 @@
1
+ import { kakuroRuns } from "./kakuroBoard.js";
2
+ /** Every way to choose `length` different digits that add to `sum`, as 9-bit masks (bit d - 1 for digit d), by length then sum. */
3
+ const COMBINATIONS = (() => {
4
+ const table = Array.from({ length: 10 }, () => Array.from({ length: 46 }, () => []));
5
+ for (let mask = 1; mask < 512; mask += 1) {
6
+ let length = 0, sum = 0;
7
+ for (let d = 1; d <= 9; d += 1)
8
+ if (mask & 1 << d - 1) {
9
+ length += 1;
10
+ sum += d;
11
+ }
12
+ table[length][sum].push(mask);
13
+ }
14
+ return table;
15
+ })();
16
+ /**
17
+ * Kakuro as variables: one per white square, nine slots for the digits 1 to 9. A run's pruning keeps only the digit
18
+ * sets that add up to its total and fit what its squares can still be, so a square keeps only digits some such
19
+ * set uses; a digit that every such set uses must appear somewhere (`strength` 1), and a digit that only one square
20
+ * can take is that square's.
21
+ */
22
+ export function kakuroModel(board, strength = 1, random) {
23
+ const whites = board.cells.flatMap((tile, cell) => tile.kind === "white" ? [cell] : []);
24
+ const index = new Int32Array(board.cells.length).fill(-1);
25
+ whites.forEach((cell, i) => { index[cell] = i; });
26
+ const runs = kakuroRuns(board).map(run => ({ cells: Int32Array.from(run.cells.map(cell => index[cell])), sum: run.sum, combos: COMBINATIONS[run.cells.length][run.sum] }));
27
+ const runsOf = whites.map(() => []);
28
+ runs.forEach((run, r) => run.cells.forEach(cell => runsOf[cell].push(r)));
29
+ const starts = Int32Array.from({ length: whites.length + 1 }, (_, i) => i * 9);
30
+ const domain = new Int16Array(whites.length), loaded = new Int32Array(whites.length), touched = new Int32Array(whites.length), changedCells = new Int32Array(whites.length);
31
+ const queue = new Int32Array(runs.length * 4 + 4), queued = new Uint8Array(runs.length);
32
+ let stamp = 0;
33
+ const propagate = (alive, decided) => {
34
+ stamp += 1;
35
+ let changes = 0;
36
+ // A square's digits are read from `alive` the first time a run asks, so a call after one decision touches only what that reaches.
37
+ const read = (i) => {
38
+ if (loaded[i] !== stamp) {
39
+ let mask = 0;
40
+ for (let d = 0; d < 9; d += 1)
41
+ if (alive[9 * i + d])
42
+ mask |= 1 << d;
43
+ domain[i] = mask;
44
+ loaded[i] = stamp;
45
+ }
46
+ return domain[i];
47
+ };
48
+ let head = 0, tail = 0;
49
+ const push = (r) => { if (!queued[r]) {
50
+ queued[r] = 1;
51
+ queue[tail++ % queue.length] = r;
52
+ } };
53
+ if (decided === undefined)
54
+ for (let r = 0; r < runs.length; r += 1)
55
+ push(r);
56
+ else
57
+ for (const r of runsOf[decided])
58
+ push(r);
59
+ while (head < tail) {
60
+ const r = queue[head++ % queue.length], run = runs[r], cells = run.cells;
61
+ queued[r] = 0;
62
+ let known = 0, union = 0;
63
+ for (let k = 0; k < cells.length; k += 1) {
64
+ const mask = read(cells[k]);
65
+ if (!mask)
66
+ return fail();
67
+ union |= mask;
68
+ if (!(mask & mask - 1)) {
69
+ if (known & mask)
70
+ return fail();
71
+ known |= mask;
72
+ }
73
+ }
74
+ let usable = 0, always = 511, viable = 0;
75
+ for (let c = 0; c < run.combos.length; c += 1) {
76
+ const combo = run.combos[c];
77
+ if (combo & ~union || known & ~combo)
78
+ continue;
79
+ let fits = true;
80
+ for (let k = 0; k < cells.length && fits; k += 1)
81
+ if (!(domain[cells[k]] & combo))
82
+ fits = false;
83
+ if (!fits)
84
+ continue;
85
+ viable += 1;
86
+ usable |= combo;
87
+ always &= combo;
88
+ }
89
+ if (!viable)
90
+ return fail();
91
+ for (let k = 0; k < cells.length; k += 1) {
92
+ const cell = cells[k], was = domain[cell];
93
+ let mask = was & usable;
94
+ // Squares not yet decided cannot take the digits the decided ones of the run hold.
95
+ if (was & was - 1)
96
+ mask &= ~known;
97
+ if (strength > 0 && always && mask & mask - 1) {
98
+ // A digit every viable set uses must go somewhere: if only this square can hold it, it does.
99
+ for (let d = 0; d < 9; d += 1) {
100
+ if (!(always & 1 << d) || !(mask & 1 << d))
101
+ continue;
102
+ let holders = 0;
103
+ for (let j = 0; j < cells.length; j += 1)
104
+ if (domain[cells[j]] & 1 << d)
105
+ holders += 1;
106
+ if (holders === 1) {
107
+ mask = 1 << d;
108
+ break;
109
+ }
110
+ }
111
+ }
112
+ if (!mask)
113
+ return fail();
114
+ if (mask !== was) {
115
+ if (touched[cell] !== stamp) {
116
+ touched[cell] = stamp;
117
+ changedCells[changes++] = cell;
118
+ }
119
+ domain[cell] = mask;
120
+ for (const other of runsOf[cell])
121
+ push(other);
122
+ }
123
+ }
124
+ }
125
+ for (let c = 0; c < changes; c += 1) {
126
+ const i = changedCells[c];
127
+ for (let d = 0; d < 9; d += 1)
128
+ alive[9 * i + d] = domain[i] & 1 << d ? 1 : 0;
129
+ }
130
+ return true;
131
+ };
132
+ const fail = () => { queued.fill(0); return false; };
133
+ // A search that picks at random among the squares with fewest digits left can be restarted when it gets lost.
134
+ const choose = random ? (alive) => {
135
+ let best = 10, count = 0, pick = -1;
136
+ for (let i = 0; i < whites.length; i += 1) {
137
+ let left = 0;
138
+ for (let d = 0; d < 9; d += 1)
139
+ left += alive[9 * i + d];
140
+ if (left < 2 || left > best)
141
+ continue;
142
+ if (left < best) {
143
+ best = left;
144
+ count = 0;
145
+ }
146
+ count += 1;
147
+ if (random() * count < 1)
148
+ pick = i;
149
+ }
150
+ return pick;
151
+ } : undefined;
152
+ return { csp: { starts, propagate, choose }, whites, index };
153
+ }
@@ -0,0 +1,3 @@
1
+ import type { KakuroBoard, KakuroRating } from "./kakuro.types.ts";
2
+ /** Rates a board with one answer by solving it: with the single-run rules, with the digit-that-must-appear rule, then by supposing. */
3
+ export declare function rateKakuro(board: KakuroBoard): KakuroRating;
@@ -0,0 +1,40 @@
1
+ import { isKakuroBoard, kakuroRuns } from "./kakuroBoard.js";
2
+ import { kakuroModel } from "./kakuroLogic.js";
3
+ import { logicCsp, openSlots, solveCsp } from "./csp.js";
4
+ const COMBOS = (length, sum) => {
5
+ let count = 0;
6
+ for (let mask = 1; mask < 512; mask += 1) {
7
+ let n = 0, total = 0;
8
+ for (let d = 1; d <= 9; d += 1)
9
+ if (mask & 1 << d - 1) {
10
+ n += 1;
11
+ total += d;
12
+ }
13
+ if (n === length && total === sum)
14
+ count += 1;
15
+ }
16
+ return count;
17
+ };
18
+ /** Rates a board with one answer by solving it: with the single-run rules, with the digit-that-must-appear rule, then by supposing. */
19
+ export function rateKakuro(board) {
20
+ if (!isKakuroBoard(board))
21
+ throw new RangeError("Invalid Kakuro board");
22
+ const weak = kakuroModel(board, 0), strong = kakuroModel(board, 1);
23
+ const proof = solveCsp(strong.csp, openSlots(strong.csp), 2, 250000);
24
+ if (proof.count !== 1 || proof.exhausted)
25
+ throw new RangeError("Kakuro rating needs a board with one answer");
26
+ const plain = logicCsp(weak.csp, openSlots(weak.csp), 0);
27
+ const rules = plain.solved ? plain : logicCsp(strong.csp, openSlots(strong.csp), 0);
28
+ const probing = rules.solved ? rules : logicCsp(strong.csp, openSlots(strong.csp), 1);
29
+ const runs = kakuroRuns(board);
30
+ return {
31
+ depth: rules.solved ? 0 : probing.solved ? 1 : 2,
32
+ plain: plain.solved,
33
+ probes: rules.solved ? 0 : probing.probes,
34
+ whites: strong.whites.length,
35
+ runs: runs.length,
36
+ longest: Math.max(...runs.map(run => run.cells.length)),
37
+ meanRun: runs.reduce((total, run) => total + run.cells.length, 0) / runs.length,
38
+ fixedShare: runs.filter(run => COMBOS(run.cells.length, run.sum) === 1).length / runs.length,
39
+ };
40
+ }
@@ -1,5 +1,7 @@
1
1
  import { KAKURO_MAX_NODES } from "./kakuro.constants.js";
2
- import { canReach, isKakuroBoard, kakuroRuns } from "./kakuroBoard.js";
2
+ import { isKakuroBoard } from "./kakuroBoard.js";
3
+ import { kakuroModel } from "./kakuroLogic.js";
4
+ import { decide, openSlots, solveCsp } from "./csp.js";
3
5
  /** Bounded exact counter over across and down runs; an interrupted search never proves uniqueness. */
4
6
  export function solveKakuro(board, entries = [], options = {}) {
5
7
  if (!isKakuroBoard(board))
@@ -9,77 +11,23 @@ export function solveKakuro(board, entries = [], options = {}) {
9
11
  throw new RangeError("Invalid search bounds");
10
12
  if (entries.length && entries.length !== board.cells.length)
11
13
  throw new RangeError("Invalid Kakuro entries");
12
- const runs = kakuroRuns(board), values = entries.length ? [...entries] : Array(board.cells.length).fill(0);
13
- const memberships = board.cells.map(() => []);
14
- runs.forEach((run, index) => run.cells.forEach(cell => memberships[cell].push(index)));
15
- for (let cell = 0; cell < values.length; cell += 1) {
16
- const value = values[cell];
14
+ const { csp, whites } = kakuroModel(board);
15
+ const start = openSlots(csp);
16
+ for (let cell = 0; cell < entries.length; cell += 1) {
17
+ const value = entries[cell];
17
18
  if (board.cells[cell].kind === "black" ? value !== 0 : !Number.isInteger(value) || value < 0 || value > 9) {
18
19
  return { count: 0, solution: null, complete: true, nodes: 0 };
19
20
  }
20
21
  }
21
- let count = 0, nodes = 0, complete = true, solution = null;
22
- const domains = (cell) => {
23
- const possible = [];
24
- for (let digit = 1; digit <= 9; digit += 1) {
25
- if (memberships[cell].every(index => runAllows(runs[index], values, cell, digit)))
26
- possible.push(digit);
27
- }
28
- return possible;
29
- };
30
- const visit = () => {
31
- if (++nodes > budget) {
32
- complete = false;
33
- return;
34
- }
35
- let chosen = -1, optionsForCell = [];
36
- for (let cell = 0; cell < values.length; cell += 1) {
37
- if (board.cells[cell].kind === "black" || values[cell])
38
- continue;
39
- const possible = domains(cell);
40
- if (!possible.length)
41
- return;
42
- if (chosen < 0 || possible.length < optionsForCell.length) {
43
- chosen = cell;
44
- optionsForCell = possible;
45
- if (possible.length === 1)
46
- break;
47
- }
48
- }
49
- if (chosen < 0) {
50
- count += 1;
51
- solution ?? (solution = [...values]);
52
- return;
53
- }
54
- for (const digit of optionsForCell) {
55
- values[chosen] = digit;
56
- visit();
57
- values[chosen] = 0;
58
- if (!complete || count >= limit) {
59
- complete = false;
60
- return;
61
- }
62
- }
63
- };
64
- // Reject contradictory entries before branching.
65
- for (const run of runs)
66
- if (!runAllows(run, values, -1, 0))
67
- return { count: 0, solution: null, complete: true, nodes: 0 };
68
- visit();
69
- return { count, solution, complete, nodes };
70
- }
71
- function runAllows(run, values, targetCell, candidate) {
72
- let sum = 0;
73
- const used = new Set();
74
- for (const cell of run.cells) {
75
- const value = cell === targetCell ? candidate : values[cell];
76
- if (!value)
77
- continue;
78
- if (used.has(value))
79
- return false;
80
- used.add(value);
81
- sum += value;
22
+ whites.forEach((cell, i) => { const value = entries[cell]; if (value)
23
+ decide(csp, start, i, 9 * i + value - 1); });
24
+ const found = solveCsp(csp, start, limit, budget);
25
+ let solution = null;
26
+ if (found.solution) {
27
+ solution = Array(board.cells.length).fill(0);
28
+ whites.forEach((cell, i) => { for (let d = 0; d < 9; d += 1)
29
+ if (found.solution[9 * i + d])
30
+ solution[cell] = d + 1; });
82
31
  }
83
- const slots = run.cells.filter(cell => !values[cell] && cell !== targetCell).length;
84
- return sum <= run.sum && canReach(run.sum - sum, slots, [...used]);
32
+ return { count: found.count, solution, complete: !found.exhausted && !found.stopped, nodes: found.nodes };
85
33
  }
@@ -0,0 +1,3 @@
1
+ import type { KakuroPuzzle } from "./kakuro.types.ts";
2
+ /** The first generator, kept as the fallback that cannot fail on a 10×10: a seeded family of crossing runs, returned only when uniquely proved. */
3
+ export declare function templateKakuro(seed?: number): Omit<KakuroPuzzle, "level">;
@@ -0,0 +1,130 @@
1
+ import { KAKURO_GENERATION_ATTEMPTS, KAKURO_GENERATION_NODES, KAKURO_MAX_ATTEMPTS, KAKURO_MAX_NODES } from "./kakuro.constants.js";
2
+ import { isKakuroBoard } from "./kakuroBoard.js";
3
+ import { solveKakuro } from "./kakuroSolve.js";
4
+ import { isKazuSeed, seededRandom } from "./random.js";
5
+ /** The first generator, kept as the fallback that cannot fail on a 10×10: a seeded family of crossing runs, returned only when uniquely proved. */
6
+ export function templateKakuro(seed = 1) {
7
+ if (!isKazuSeed(seed))
8
+ throw new RangeError("Invalid Kakuro seed.");
9
+ const random = seededRandom(seed);
10
+ const budget = { attempts: KAKURO_GENERATION_ATTEMPTS, nodes: KAKURO_GENERATION_NODES };
11
+ for (let attempt = 0; attempt < KAKURO_MAX_ATTEMPTS && budget.attempts > 0 && budget.nodes > 0; attempt += 1) {
12
+ budget.attempts -= 1;
13
+ const rowGroups = random() < .5 ? [2, 2, 2] : random() < .5 ? [2, 3] : [3, 2];
14
+ const columnGroups = rowGroups.length === 3 ? [2, 2, 2] : [2, 3];
15
+ if (random() < .5)
16
+ columnGroups.reverse();
17
+ const rowStarts = groupStarts(rowGroups, random);
18
+ const columnStarts = groupStarts(columnGroups, random);
19
+ const cells = Array.from({ length: 100 }, () => ({ kind: "black", across: null, down: null }));
20
+ const solution = Array(100).fill(0);
21
+ const board = { width: 10, height: 10, cells };
22
+ let failed = false;
23
+ for (const [rowIndex, rowStart] of rowStarts.entries()) {
24
+ for (const [columnIndex, columnStart] of columnStarts.entries()) {
25
+ const shape = { height: rowGroups[rowIndex], width: columnGroups[columnIndex] };
26
+ const local = chooseUniqueTile(shape.width, shape.height, random, budget);
27
+ if (!local) {
28
+ failed = true;
29
+ break;
30
+ }
31
+ placeTile(board, solution, rowStart, columnStart, local);
32
+ }
33
+ if (failed)
34
+ break;
35
+ }
36
+ if (failed)
37
+ continue;
38
+ if (!isKakuroBoard(board))
39
+ throw new Error(JSON.stringify(board));
40
+ const proof = solveKakuro(board, [], { limit: 2, nodes: Math.min(KAKURO_MAX_NODES, budget.nodes) });
41
+ budget.nodes -= Math.min(budget.nodes, proof.nodes);
42
+ if (proof.complete && proof.count === 1 && proof.solution) {
43
+ return { ...board, seed, solution: proof.solution };
44
+ }
45
+ }
46
+ throw new Error("No uniquely solvable Kakuro board was proved within the generation budget.");
47
+ }
48
+ function groupStarts(groups, random) {
49
+ const used = groups.reduce((sum, size) => sum + size, 0) + groups.length - 1;
50
+ let at = 1 + (8 - used > 0 && random() < .5 ? 8 - used : 0);
51
+ return groups.map(size => { const start = at; at += size + 1; return start; });
52
+ }
53
+ function chooseUniqueTile(width, height, random, budget) {
54
+ for (let attempt = 0; attempt < KAKURO_MAX_ATTEMPTS && budget.attempts > 0 && budget.nodes > 0; attempt += 1) {
55
+ budget.attempts -= 1;
56
+ const candidate = randomRectangle(width, height, random);
57
+ if (!candidate)
58
+ continue;
59
+ const board = tileBoard(candidate);
60
+ const proof = solveKakuro(board, [], { limit: 2, nodes: Math.min(25000, budget.nodes) });
61
+ budget.nodes -= Math.min(budget.nodes, proof.nodes);
62
+ if (!budget.nodes)
63
+ return null;
64
+ if (proof.complete && proof.count === 1)
65
+ return proof.solution ? splitSolution(proof.solution, width, height) : candidate;
66
+ }
67
+ return null;
68
+ }
69
+ function randomRectangle(width, height, random) {
70
+ const rows = [];
71
+ for (let y = 0; y < height; y += 1) {
72
+ let row = null;
73
+ for (let attempt = 0; attempt < 100; attempt += 1) {
74
+ const available = shuffled([1, 2, 3, 4, 5, 6, 7, 8, 9], random);
75
+ const candidate = available.slice(0, width);
76
+ if (candidate.every((digit, x) => rows.every(previous => previous[x] !== digit))) {
77
+ row = candidate;
78
+ break;
79
+ }
80
+ }
81
+ if (!row)
82
+ return null;
83
+ rows.push(row);
84
+ }
85
+ return rows;
86
+ }
87
+ function tileBoard(solution) {
88
+ const height = solution.length + 1, width = solution[0].length + 1;
89
+ const cells = Array.from({ length: width * height }, () => ({ kind: "black", across: null, down: null }));
90
+ for (let y = 0; y < solution.length; y += 1)
91
+ for (let x = 0; x < solution[y].length; x += 1)
92
+ cells[(y + 1) * width + x + 1] = { kind: "white" };
93
+ for (let y = 0; y < solution.length; y += 1)
94
+ cells[(y + 1) * width].across = solution[y].reduce((sum, value) => sum + value, 0);
95
+ for (let x = 0; x < solution[0].length; x += 1)
96
+ cells[x + 1].down = solution.reduce((sum, row) => sum + row[x], 0);
97
+ return { width, height, cells };
98
+ }
99
+ function placeTile(board, full, top, left, solution) {
100
+ const width = board.width;
101
+ const cells = board.cells;
102
+ for (let y = 0; y < solution.length; y += 1)
103
+ for (let x = 0; x < solution[y].length; x += 1)
104
+ full[(top + y) * width + left + x] = solution[y][x];
105
+ for (let y = 0; y < solution.length; y += 1) {
106
+ const clue = (top + y) * width + left - 1;
107
+ cells[clue].across = solution[y].reduce((sum, value) => sum + value, 0);
108
+ }
109
+ for (let x = 0; x < solution[0].length; x += 1) {
110
+ const clue = (top - 1) * width + left + x;
111
+ cells[clue].down = solution.reduce((sum, row) => sum + row[x], 0);
112
+ }
113
+ for (let y = 0; y < solution.length; y += 1)
114
+ for (let x = 0; x < solution[y].length; x += 1)
115
+ cells[(top + y) * width + left + x] = { kind: "white" };
116
+ }
117
+ function splitSolution(values, width, height) {
118
+ const result = [];
119
+ for (let y = 1; y <= height; y += 1)
120
+ result.push(values.slice(y * (width + 1) + 1, y * (width + 1) + width + 1));
121
+ return result;
122
+ }
123
+ function shuffled(items, random) {
124
+ const result = [...items];
125
+ for (let i = result.length - 1; i > 0; i -= 1) {
126
+ const j = Math.floor(random() * (i + 1));
127
+ [result[i], result[j]] = [result[j], result[i]];
128
+ }
129
+ return result;
130
+ }
@@ -4,5 +4,6 @@ export * from "./shikaku.constants.ts";
4
4
  export * from "./shikakuBoard.ts";
5
5
  export * from "./shikakuSolve.ts";
6
6
  export * from "./shikakuGenerate.ts";
7
+ export * from "./shikakuRate.ts";
7
8
  export * from "./shikakuGame.ts";
8
9
  export * from "./shikakuPacks.ts";
@@ -4,5 +4,6 @@ export * from "./shikaku.constants.js";
4
4
  export * from "./shikakuBoard.js";
5
5
  export * from "./shikakuSolve.js";
6
6
  export * from "./shikakuGenerate.js";
7
+ export * from "./shikakuRate.js";
7
8
  export * from "./shikakuGame.js";
8
9
  export * from "./shikakuPacks.js";
@@ -1,5 +1,8 @@
1
1
  /** Bounds keep custom boards and searches finite. */
2
2
  export declare const SHIKAKU_MOST_SIDE = 16;
3
3
  export declare const SHIKAKU_MOST_NODES = 100000;
4
- export declare const SHIKAKU_MOST_ATTEMPTS = 200;
5
- export declare const SHIKAKU_LEVELS: readonly ["easy", "medium", "hard"];
4
+ export declare const SHIKAKU_MOST_ATTEMPTS = 1500;
5
+ /** How hard a puzzle is made, by what a person must do to solve it; see `rateShikaku`. */
6
+ export declare const SHIKAKU_LEVELS: readonly ["easy", "medium", "hard", "extra-hard"];
7
+ /** The sides on offer for a square board; any side from 2 to `SHIKAKU_MOST_SIDE` can be made. */
8
+ export declare const SHIKAKU_SIZES: readonly [5, 7, 10, 14];
@@ -1,5 +1,8 @@
1
1
  /** Bounds keep custom boards and searches finite. */
2
2
  export const SHIKAKU_MOST_SIDE = 16;
3
3
  export const SHIKAKU_MOST_NODES = 100000;
4
- export const SHIKAKU_MOST_ATTEMPTS = 200;
5
- export const SHIKAKU_LEVELS = ["easy", "medium", "hard"];
4
+ export const SHIKAKU_MOST_ATTEMPTS = 1500;
5
+ /** How hard a puzzle is made, by what a person must do to solve it; see `rateShikaku`. */
6
+ export const SHIKAKU_LEVELS = ["easy", "medium", "hard", "extra-hard"];
7
+ /** The sides on offer for a square board; any side from 2 to `SHIKAKU_MOST_SIDE` can be made. */
8
+ export const SHIKAKU_SIZES = [5, 7, 10, 14];
@@ -11,7 +11,7 @@ export type ShikakuBoard = {
11
11
  height: number;
12
12
  clues: readonly number[];
13
13
  };
14
- export type ShikakuLevel = "easy" | "medium" | "hard";
14
+ export type ShikakuLevel = "easy" | "medium" | "hard" | "extra-hard";
15
15
  export type ShikakuPuzzle = ShikakuBoard & {
16
16
  seed: number;
17
17
  level: ShikakuLevel;
@@ -34,3 +34,20 @@ export type ShikakuGame = {
34
34
  history: readonly (readonly ShikakuRectangle[])[];
35
35
  helped: boolean;
36
36
  };
37
+ /**
38
+ * How hard a board is, measured by solving it. `depth` 0 means the rules solve it, 1 that somebody has to suppose a
39
+ * rectangle and watch it break, 2 that more than that is needed. `rules` is how many of the three rules a depth-0
40
+ * solve needed: 1 for a number with one fitting rectangle (a settled rectangle clears its squares), 2 adding that a
41
+ * square only one rectangle can cover is covered by it, 3 adding that a square only one number can reach makes that
42
+ * number's rectangle cover it. `probes` is the suppositions depth 1 needed.
43
+ */
44
+ export type ShikakuRating = {
45
+ depth: 0 | 1 | 2;
46
+ rules: 1 | 2 | 3;
47
+ probes: number;
48
+ rectangles: number;
49
+ meanArea: number;
50
+ largest: number;
51
+ /** Mean number of rectangles a number could be at the start. */
52
+ ambiguity: number;
53
+ };
@@ -0,0 +1,21 @@
1
+ import type { ShikakuBoard, ShikakuLevel, ShikakuRectangle } from "./shikaku.types.ts";
2
+ import type { Random } from "./random.ts";
3
+ /** The smallest and largest rectangle a level cuts the board into. */
4
+ export declare const SHIKAKU_AREAS: Record<ShikakuLevel, {
5
+ least: number;
6
+ most: number;
7
+ }>;
8
+ /**
9
+ * Cuts a board into rectangles by packing: the square with the fewest free neighbours is covered next, by a random
10
+ * rectangle of a random allowed area that fits the free squares round it. Unlike cutting the board in two over and
11
+ * over, this makes pinwheels and rectangles that interlock.
12
+ */
13
+ export declare function packRectangles(width: number, height: number, level: ShikakuLevel, random: Random): ShikakuRectangle[];
14
+ /**
15
+ * A board with exactly one answer, or null. Each rectangle's number sits in a random square of it; while there is
16
+ * another answer, the numbers of the rectangles the two answers disagree on move to new squares.
17
+ */
18
+ export declare function candidateShikaku(width: number, height: number, level: ShikakuLevel, random: Random): {
19
+ board: ShikakuBoard;
20
+ solution: readonly ShikakuRectangle[];
21
+ } | null;
@@ -0,0 +1,135 @@
1
+ import { shikakuModel } from "./shikakuLogic.js";
2
+ import { countCsp, openSlots } from "./csp.js";
3
+ import { shuffled } from "./random.js";
4
+ /** The smallest and largest rectangle a level cuts the board into. */
5
+ export const SHIKAKU_AREAS = {
6
+ easy: { least: 2, most: 6 },
7
+ medium: { least: 3, most: 12 },
8
+ hard: { least: 3, most: 9 },
9
+ "extra-hard": { least: 4, most: 9 },
10
+ };
11
+ /**
12
+ * Cuts a board into rectangles by packing: the square with the fewest free neighbours is covered next, by a random
13
+ * rectangle of a random allowed area that fits the free squares round it. Unlike cutting the board in two over and
14
+ * over, this makes pinwheels and rectangles that interlock.
15
+ */
16
+ export function packRectangles(width, height, level, random) {
17
+ const free = new Uint8Array(width * height).fill(1);
18
+ const { least, most: wanted } = SHIKAKU_AREAS[level];
19
+ const most = Math.max(least, Math.min(wanted, Math.round(width * height * .3)));
20
+ const freeAt = (x, y) => x >= 0 && y >= 0 && x < width && y < height && free[y * width + x] === 1;
21
+ const rectangles = [];
22
+ for (;;) {
23
+ let anchor = -1, fewest = 9, ties = 0;
24
+ for (let cell = 0; cell < free.length; cell += 1) {
25
+ if (!free[cell])
26
+ continue;
27
+ const x = cell % width, y = Math.floor(cell / width);
28
+ const around = +freeAt(x - 1, y) + +freeAt(x + 1, y) + +freeAt(x, y - 1) + +freeAt(x, y + 1);
29
+ if (around < fewest) {
30
+ fewest = around;
31
+ anchor = cell;
32
+ ties = 1;
33
+ }
34
+ else if (around === fewest && random() * ++ties < 1)
35
+ anchor = cell;
36
+ }
37
+ if (anchor < 0)
38
+ return rectangles;
39
+ const ax = anchor % width, ay = Math.floor(anchor / width);
40
+ const fits = [];
41
+ for (let w = 1; w <= Math.min(width, most); w += 1)
42
+ for (let h = 1; w * h <= most; h += 1) {
43
+ for (let ox = 0; ox < w; ox += 1)
44
+ for (let oy = 0; oy < h; oy += 1) {
45
+ const x = ax - ox, y = ay - oy;
46
+ let all = x >= 0 && y >= 0 && x + w <= width && y + h <= height;
47
+ for (let k = 0; all && k < w * h; k += 1)
48
+ if (!free[(y + Math.floor(k / w)) * width + x + k % w])
49
+ all = false;
50
+ if (all)
51
+ fits.push({ x, y, width: w, height: h });
52
+ }
53
+ }
54
+ // Prefer rectangles that leave no square with nowhere to go, then rectangles of a decent size.
55
+ const leavesIsland = (r) => {
56
+ const inside = (x, y) => x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height;
57
+ for (let x = r.x - 1; x <= r.x + r.width; x += 1)
58
+ for (let y = r.y - 1; y <= r.y + r.height; y += 1) {
59
+ if (inside(x, y) || !freeAt(x, y))
60
+ continue;
61
+ const exits = [[x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1]].filter(([nx, ny]) => freeAt(nx, ny) && !inside(nx, ny)).length;
62
+ if (!exits)
63
+ return true;
64
+ }
65
+ return false;
66
+ };
67
+ const sound = fits.filter(r => !leavesIsland(r));
68
+ const roomy = (sound.length ? sound : fits).filter(r => r.width * r.height >= least);
69
+ const pool = roomy.length ? roomy : sound.length ? sound : fits;
70
+ const areas = [...new Set(pool.map(r => r.width * r.height))];
71
+ const area = areas[Math.floor(random() * areas.length)];
72
+ const same = pool.filter(r => r.width * r.height === area);
73
+ const pick = same[Math.floor(random() * same.length)];
74
+ rectangles.push(pick);
75
+ for (let k = 0; k < pick.width * pick.height; k += 1)
76
+ free[(pick.y + Math.floor(k / pick.width)) * width + pick.x + k % pick.width] = 0;
77
+ }
78
+ }
79
+ /**
80
+ * A board with exactly one answer, or null. Each rectangle's number sits in a random square of it; while there is
81
+ * another answer, the numbers of the rectangles the two answers disagree on move to new squares.
82
+ */
83
+ export function candidateShikaku(width, height, level, random) {
84
+ const solution = packRectangles(width, height, level, random);
85
+ if (solution.length < 2 || solution.some(r => r.width * r.height === 1))
86
+ return null;
87
+ const cellsOf = (r) => Array.from({ length: r.width * r.height }, (_, i) => (r.y + Math.floor(i / r.width)) * width + r.x + i % r.width);
88
+ // Where a number sits decides how many rectangles it could be: easy boards put it where it has the fewest, the others anywhere.
89
+ const freedom = (r, cell) => {
90
+ const area = r.width * r.height;
91
+ let count = 0;
92
+ for (let w = 1; w <= area && w <= width; w += 1) {
93
+ if (area % w || area / w > height)
94
+ continue;
95
+ const h = area / w;
96
+ count += (Math.min(cell % width, width - w) - Math.max(0, cell % width - w + 1) + 1) * (Math.min(Math.floor(cell / width), height - h) - Math.max(0, Math.floor(cell / width) - h + 1) + 1);
97
+ }
98
+ return count;
99
+ };
100
+ const chooseSpot = (r, not) => {
101
+ const cells = shuffled(cellsOf(r).filter(c => c !== not), random);
102
+ if (!cells.length)
103
+ return not;
104
+ if (level !== "easy")
105
+ return cells[0];
106
+ const ranked = [...cells].sort((a, b) => freedom(r, a) - freedom(r, b));
107
+ return ranked[Math.floor(random() * Math.min(2, ranked.length))];
108
+ };
109
+ const spot = solution.map(r => chooseSpot(r));
110
+ for (let repair = 0; repair < 40; repair += 1) {
111
+ const clues = Array(width * height).fill(0);
112
+ solution.forEach((r, i) => { clues[spot[i]] = r.width * r.height; });
113
+ const board = { width, height, clues };
114
+ const model = shikakuModel(board);
115
+ const found = countCsp(model.csp, openSlots(model.csp), 2, 20000);
116
+ if (found.exhausted)
117
+ return null;
118
+ if (found.count === 1)
119
+ return { board, solution };
120
+ const alt = found.solutions[1];
121
+ let moved = false;
122
+ solution.forEach((r, i) => {
123
+ const slot = model.rectangles.findIndex((c, s) => model.owner[s] === model.clues.indexOf(spot[i]) && c.x === r.x && c.y === r.y && c.width === r.width && c.height === r.height);
124
+ if (slot >= 0 && alt[slot])
125
+ return;
126
+ if (cellsOf(r).length < 2)
127
+ return;
128
+ spot[i] = shuffled(cellsOf(r).filter(c => c !== spot[i]), random)[0];
129
+ moved = true;
130
+ });
131
+ if (!moved)
132
+ return null;
133
+ }
134
+ return null;
135
+ }