@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
@@ -0,0 +1,27 @@
1
+ import { isShikakuBoard } from "./shikakuBoard.js";
2
+ import { shikakuModel } from "./shikakuLogic.js";
3
+ import { logicCsp, openSlots, solveCsp } from "./csp.js";
4
+ /** Rates a board with one answer by solving it with the first rule, with the first two, with all three, then by supposing. */
5
+ export function rateShikaku(board) {
6
+ if (!isShikakuBoard(board))
7
+ throw new RangeError("Invalid Shikaku board");
8
+ const first = shikakuModel(board, 0), second = shikakuModel(board, 1), strong = shikakuModel(board, 2);
9
+ const proof = solveCsp(strong.csp, openSlots(strong.csp), 2, 100000);
10
+ if (proof.count !== 1 || proof.exhausted)
11
+ throw new RangeError("Shikaku rating needs a board with one answer");
12
+ const one = logicCsp(first.csp, openSlots(first.csp), 0);
13
+ const two = one.solved ? one : logicCsp(second.csp, openSlots(second.csp), 0);
14
+ const rules = two.solved ? two : logicCsp(strong.csp, openSlots(strong.csp), 0);
15
+ const probing = rules.solved ? rules : logicCsp(strong.csp, openSlots(strong.csp), 1);
16
+ const areas = strong.clues.map(cell => board.clues[cell]);
17
+ const options = strong.clues.map((_, v) => strong.csp.starts[v + 1] - strong.csp.starts[v]);
18
+ return {
19
+ depth: rules.solved ? 0 : probing.solved ? 1 : 2,
20
+ rules: one.solved ? 1 : two.solved ? 2 : 3,
21
+ probes: rules.solved ? 0 : probing.probes,
22
+ rectangles: areas.length,
23
+ meanArea: board.clues.length / areas.length,
24
+ largest: Math.max(...areas),
25
+ ambiguity: options.reduce((a, b) => a + b, 0) / options.length,
26
+ };
27
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ShikakuBoard, ShikakuRectangle, ShikakuSolve } from "./shikaku.types.ts";
2
- /** Exact cover over cells. A bounded search reports incompleteness instead of claiming uniqueness. */
2
+ /** Exact cover over squares. A bounded search reports incompleteness instead of claiming uniqueness. */
3
3
  export declare function solveShikaku(board: ShikakuBoard, placed?: readonly ShikakuRectangle[], options?: {
4
4
  limit?: number;
5
5
  nodes?: number;
@@ -1,54 +1,33 @@
1
1
  import { SHIKAKU_MOST_NODES } from "./shikaku.constants.js";
2
- import { checkShikaku, isShikakuBoard, shikakuCandidates, shikakuCells } from "./shikakuBoard.js";
3
- /** Exact cover over cells. A bounded search reports incompleteness instead of claiming uniqueness. */
2
+ import { checkShikaku, isShikakuBoard } from "./shikakuBoard.js";
3
+ import { shikakuModel } from "./shikakuLogic.js";
4
+ import { decide, openSlots, solveCsp } from "./csp.js";
5
+ /** Exact cover over squares. A bounded search reports incompleteness instead of claiming uniqueness. */
4
6
  export function solveShikaku(board, placed = [], options = {}) {
5
7
  if (!isShikakuBoard(board))
6
8
  throw new RangeError("Invalid Shikaku board");
7
9
  const limit = options.limit ?? 2, budget = options.nodes ?? SHIKAKU_MOST_NODES;
8
10
  if (!Number.isInteger(limit) || limit < 1 || !Number.isInteger(budget) || budget < 1)
9
11
  throw new RangeError("Invalid search bounds");
10
- const maskOf = (r) => shikakuCells(board, r).reduce((m, c) => m | 1n << BigInt(c), 0n);
11
12
  if (checkShikaku(board, placed).errors.length)
12
13
  return { count: 0, solution: null, complete: true, nodes: 0 };
13
- const all = (1n << BigInt(board.clues.length)) - 1n;
14
- let occupied = 0n;
15
- for (const r of placed)
16
- occupied |= maskOf(r);
17
- const candidates = board.clues.flatMap((n, c) => n ? shikakuCandidates(board, c).map(r => ({ r, mask: maskOf(r) })) : []);
18
- const byCell = board.clues.map((_, c) => candidates.filter(r => (r.mask & 1n << BigInt(c)) !== 0n));
19
- let count = 0, nodes = 0, complete = true, solution = null;
20
- const visit = (mask, rectangles) => {
21
- if (++nodes > budget) {
22
- complete = false;
23
- return;
24
- }
25
- if (mask === all) {
26
- count += 1;
27
- solution ?? (solution = rectangles.map(r => ({ ...r })));
28
- return;
29
- }
30
- let choices = null;
31
- for (let c = 0; c < board.clues.length; c += 1) {
32
- if ((mask & 1n << BigInt(c)) !== 0n)
33
- continue;
34
- const available = byCell[c].filter(r => (mask & r.mask) === 0n);
35
- if (!available.length)
36
- return;
37
- if (!choices || available.length < choices.length)
38
- choices = available;
39
- if (choices.length === 1)
40
- break;
41
- }
42
- for (const next of choices ?? []) {
43
- visit(mask | next.mask, [...rectangles, next.r]);
44
- if (!complete)
45
- return;
46
- if (count >= limit) {
47
- complete = false;
48
- return;
49
- }
50
- }
51
- };
52
- visit(occupied, placed);
53
- return { count, solution, complete, nodes };
14
+ const { csp, rectangles, clues } = shikakuModel(board);
15
+ const start = openSlots(csp);
16
+ for (const r of placed) {
17
+ const slot = rectangles.findIndex(c => c.x === r.x && c.y === r.y && c.width === r.width && c.height === r.height);
18
+ if (slot < 0)
19
+ return { count: 0, solution: null, complete: true, nodes: 0 };
20
+ decide(csp, start, clues.indexOf(clueOf(board, r)), slot);
21
+ }
22
+ const found = solveCsp(csp, start, limit, budget);
23
+ const solution = found.solution ? [...placed.map(r => ({ ...r })), ...rectangles.filter((_, slot) => found.solution[slot] === 1 && !placed.some(r => sameRectangle(r, rectangles[slot])))] : null;
24
+ return { count: found.count, solution, complete: !found.exhausted && !found.stopped, nodes: found.nodes };
54
25
  }
26
+ const sameRectangle = (a, b) => a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
27
+ const clueOf = (board, r) => {
28
+ for (let y = r.y; y < r.y + r.height; y += 1)
29
+ for (let x = r.x; x < r.x + r.width; x += 1)
30
+ if (board.clues[y * board.width + x])
31
+ return y * board.width + x;
32
+ return -1;
33
+ };
@@ -0,0 +1,3 @@
1
+ import type { ShikakuLevel, ShikakuPuzzle } from "./shikaku.types.ts";
2
+ /** The first generator, kept as the fallback that cannot fail on a board it can make: seeded cuts into rectangles, accepted when the answer is single. */
3
+ export declare function templateShikaku(width?: number, height?: number, level?: ShikakuLevel, seed?: number): ShikakuPuzzle;
@@ -0,0 +1,43 @@
1
+ import { SHIKAKU_LEVELS, SHIKAKU_MOST_ATTEMPTS } from "./shikaku.constants.js";
2
+ import { isShikakuBoard, shikakuCells } from "./shikakuBoard.js";
3
+ import { solveShikaku } from "./shikakuSolve.js";
4
+ import { isKazuSeed, seededRandom } from "./random.js";
5
+ /** The first generator, kept as the fallback that cannot fail on a board it can make: seeded cuts into rectangles, accepted when the answer is single. */
6
+ export function templateShikaku(width = 7, height = width, level = "medium", seed = 1) {
7
+ if (![width, height].every(n => Number.isInteger(n) && n >= 2 && n <= 16))
8
+ throw new RangeError("Invalid Shikaku dimensions");
9
+ const empty = { width, height, clues: Array(width * height).fill(0) };
10
+ if (!isKazuSeed(seed) || !SHIKAKU_LEVELS.includes(level)
11
+ || !isShikakuBoard({ ...empty, clues: [width * height, ...empty.clues.slice(1)] }))
12
+ throw new RangeError("Invalid Shikaku settings");
13
+ const random = seededRandom(seed), maximum = { easy: 5, medium: 9, hard: 15, "extra-hard": 20 }[level];
14
+ for (let attempt = 0; attempt < SHIKAKU_MOST_ATTEMPTS; attempt += 1) {
15
+ const solution = [];
16
+ const split = (r) => {
17
+ if (r.width * r.height <= maximum && (r.width * r.height < 3 || random() < .6)) {
18
+ solution.push(r);
19
+ return;
20
+ }
21
+ const vertical = r.height === 1 || (r.width > 1 && random() < .5);
22
+ const cut = 1 + Math.floor(random() * ((vertical ? r.width : r.height) - 1));
23
+ if (vertical) {
24
+ split({ ...r, width: cut });
25
+ split({ ...r, x: r.x + cut, width: r.width - cut });
26
+ }
27
+ else {
28
+ split({ ...r, height: cut });
29
+ split({ ...r, y: r.y + cut, height: r.height - cut });
30
+ }
31
+ };
32
+ split({ x: 0, y: 0, width, height });
33
+ const clues = [...empty.clues];
34
+ for (const r of solution) {
35
+ const cells = shikakuCells(empty, r);
36
+ clues[cells[Math.floor(random() * cells.length)]] = cells.length;
37
+ }
38
+ const board = { width, height, clues }, counted = solveShikaku(board);
39
+ if (counted.complete && counted.count === 1)
40
+ return { ...board, seed, level, solution: counted.solution };
41
+ }
42
+ throw new Error("No unique Shikaku found within the generation budget; try another seed");
43
+ }
@@ -4,6 +4,7 @@ export * from "./slitherlink.constants.ts";
4
4
  export * from "./slitherlinkBoard.ts";
5
5
  export * from "./slitherlinkSolve.ts";
6
6
  export * from "./slitherlinkGenerate.ts";
7
+ export * from "./slitherlinkRate.ts";
7
8
  export * from "./slitherlinkGame.ts";
8
9
  export { drawSlitherlink } from "./slitherlinkDraw.ts";
9
10
  export type { SlitherlinkDrawOptions } from "./slitherlinkPlay.types.ts";
@@ -4,6 +4,7 @@ export * from "./slitherlink.constants.js";
4
4
  export * from "./slitherlinkBoard.js";
5
5
  export * from "./slitherlinkSolve.js";
6
6
  export * from "./slitherlinkGenerate.js";
7
+ export * from "./slitherlinkRate.js";
7
8
  export * from "./slitherlinkGame.js";
8
9
  export { drawSlitherlink } from "./slitherlinkDraw.js";
9
10
  export { mountSlitherlink } from "./slitherlinkMount.js";
@@ -1,2 +1,7 @@
1
1
  export declare const SLITHERLINK_MOST_SIDE = 10;
2
2
  export declare const SLITHERLINK_MOST_NODES = 300000;
3
+ /** How hard a puzzle is made, by what a person must do to solve it; see `rateSlitherlink`. */
4
+ export declare const SLITHERLINK_LEVELS: readonly ["easy", "medium", "hard", "extra-hard"];
5
+ /** The sides on offer for a square board; any side from 2 to `SLITHERLINK_MOST_SIDE` can be made. */
6
+ export declare const SLITHERLINK_SIZES: readonly [5, 7, 10];
7
+ export declare const SLITHERLINK_MOST_ATTEMPTS = 40;
@@ -1,2 +1,7 @@
1
1
  export const SLITHERLINK_MOST_SIDE = 10;
2
2
  export const SLITHERLINK_MOST_NODES = 300000;
3
+ /** How hard a puzzle is made, by what a person must do to solve it; see `rateSlitherlink`. */
4
+ export const SLITHERLINK_LEVELS = ["easy", "medium", "hard", "extra-hard"];
5
+ /** The sides on offer for a square board; any side from 2 to `SLITHERLINK_MOST_SIDE` can be made. */
6
+ export const SLITHERLINK_SIZES = [5, 7, 10];
7
+ export const SLITHERLINK_MOST_ATTEMPTS = 40;
@@ -3,10 +3,28 @@ export type SlitherlinkBoard = {
3
3
  height: number;
4
4
  clues: readonly (number | null)[];
5
5
  };
6
+ export type SlitherlinkLevel = "easy" | "medium" | "hard" | "extra-hard";
6
7
  export type SlitherlinkPuzzle = SlitherlinkBoard & {
7
8
  seed: number;
9
+ level: SlitherlinkLevel;
8
10
  solution: readonly number[];
9
11
  };
12
+ /**
13
+ * How hard a board is, measured by solving it. `depth` 0 means the plain rules solve it, 1 that somebody has to
14
+ * suppose an edge drawn or crossed and watch it break, 2 that more than that is needed; `probes` is how many
15
+ * suppositions depth 1 needed. The rest describes the board.
16
+ */
17
+ export type SlitherlinkRating = {
18
+ depth: 0 | 1 | 2;
19
+ probes: number;
20
+ clues: number;
21
+ /** Numbered squares as a share of all squares. */
22
+ clueShare: number;
23
+ /** Squares numbered 0 as a share of the numbered ones. */
24
+ zeroShare: number;
25
+ /** Edges in the loop. */
26
+ loop: number;
27
+ };
10
28
  export type SlitherlinkCheck = {
11
29
  ok: boolean;
12
30
  errors: readonly number[];
@@ -1,3 +1,10 @@
1
- import type { SlitherlinkPuzzle } from "./slitherlink.types.ts";
2
- /** Makes a proved puzzle from a seeded rectangle or L-shaped tile-region loop. */
3
- export declare function generateSlitherlink(width?: number, height?: number, seed?: number): SlitherlinkPuzzle;
1
+ import type { SlitherlinkLevel, SlitherlinkPuzzle } from "./slitherlink.types.ts";
2
+ /**
3
+ * Makes a seeded puzzle and proves it has one answer. A random winding loop is grown cell by cell, every square is
4
+ * numbered with how many of its edges the loop uses, and then numbers are taken away for as long as the puzzle can
5
+ * still be solved the way the level asks: easy and medium by the rules alone (easy keeps most numbers), hard by
6
+ * supposing one edge at a time, extra-hard the most-supposing of several such boards. Numbers that say 0 are
7
+ * taken away first, because they say least. If no board of the level is found within the attempts the next level
8
+ * down is tried, and the first generator, which cannot fail, is the last.
9
+ */
10
+ export declare function generateSlitherlink(width?: number, height?: number, seed?: number, level?: SlitherlinkLevel): SlitherlinkPuzzle;
@@ -1,101 +1,182 @@
1
- import { SLITHERLINK_MOST_SIDE } from "./slitherlink.constants.js";
2
- import { checkSlitherlink, isSlitherlinkBoard, slitherlinkCellEdges, } from "./slitherlinkBoard.js";
1
+ import { SLITHERLINK_LEVELS, SLITHERLINK_MOST_ATTEMPTS, SLITHERLINK_MOST_SIDE } from "./slitherlink.constants.js";
2
+ import { checkSlitherlink, slitherlinkCellEdges } from "./slitherlinkBoard.js";
3
+ import { slitherlinkModel } from "./slitherlinkLogic.js";
3
4
  import { solveSlitherlink } from "./slitherlinkSolve.js";
4
- import { isKazuSeed, seededRandom } from "./random.js";
5
- /** Makes a proved puzzle from a seeded rectangle or L-shaped tile-region loop. */
6
- export function generateSlitherlink(width = 5, height = width, seed = 1) {
5
+ import { templateSlitherlink } from "./slitherlinkTemplate.js";
6
+ import { countCsp, logicCsp, openSlots } from "./csp.js";
7
+ import { isKazuSeed, seededRandom, shuffled } from "./random.js";
8
+ /**
9
+ * Makes a seeded puzzle and proves it has one answer. A random winding loop is grown cell by cell, every square is
10
+ * numbered with how many of its edges the loop uses, and then numbers are taken away for as long as the puzzle can
11
+ * still be solved the way the level asks: easy and medium by the rules alone (easy keeps most numbers), hard by
12
+ * supposing one edge at a time, extra-hard the most-supposing of several such boards. Numbers that say 0 are
13
+ * taken away first, because they say least. If no board of the level is found within the attempts the next level
14
+ * down is tried, and the first generator, which cannot fail, is the last.
15
+ */
16
+ export function generateSlitherlink(width = 5, height = width, seed = 1, level = "medium") {
7
17
  if (![width, height].every(side => Number.isInteger(side) && side >= 2 && side <= SLITHERLINK_MOST_SIDE)
8
- || !isKazuSeed(seed))
18
+ || !isKazuSeed(seed) || !SLITHERLINK_LEVELS.includes(level))
9
19
  throw new RangeError("Invalid Slitherlink settings");
10
20
  const random = seededRandom(seed);
11
- const useL = width >= 3 && height >= 3 && random() < .5;
12
- const region = [];
13
- let x0;
14
- let y0;
15
- let blockWidth;
16
- let blockHeight;
17
- if (useL) {
18
- blockWidth = 2 + Math.floor(random() * (width - 1));
19
- blockHeight = 2 + Math.floor(random() * (height - 1));
20
- x0 = Math.floor(random() * (width - blockWidth + 1));
21
- y0 = Math.floor(random() * (height - blockHeight + 1));
22
- for (let y = 0; y < blockHeight; y += 1) {
23
- for (let x = 0; x < blockWidth; x += 1) {
24
- if (x === 0 || y === 0)
25
- region.push((y0 + y) * width + x0 + x);
21
+ // The work an attempt does is counted in rule runs, never in time, so a seed makes the same puzzle on every machine. When a level has used its share the next one down is tried.
22
+ const work = { calls: 0, limit: width * height * 900 };
23
+ for (let aim = SLITHERLINK_LEVELS.indexOf(level); aim >= 0; aim -= 1) {
24
+ work.calls = 0;
25
+ const aimed = SLITHERLINK_LEVELS[aim];
26
+ const wanted = 1;
27
+ let best = null, found = 0;
28
+ for (let attempt = 0; attempt < SLITHERLINK_MOST_ATTEMPTS && found < wanted && work.calls < work.limit; attempt += 1) {
29
+ const made = attemptSlitherlink(width, height, aimed, random, work);
30
+ if (!made)
31
+ continue;
32
+ found += 1;
33
+ if (!best || made.probes > best.probes)
34
+ best = made;
35
+ }
36
+ if (best) {
37
+ const proof = solveSlitherlink(best.board);
38
+ if (proof.complete && proof.count === 1 && proof.solution && checkSlitherlink(best.board, proof.solution).ok) {
39
+ return { ...best.board, seed, level, solution: proof.solution };
26
40
  }
27
41
  }
28
42
  }
29
- else {
30
- blockWidth = 1 + Math.floor(random() * width);
31
- blockHeight = 1 + Math.floor(random() * height);
32
- if (blockWidth === 1 && blockHeight === 1)
33
- blockWidth = 2;
34
- x0 = Math.floor(random() * (width - blockWidth + 1));
35
- y0 = Math.floor(random() * (height - blockHeight + 1));
36
- for (let y = 0; y < blockHeight; y += 1) {
37
- for (let x = 0; x < blockWidth; x += 1)
38
- region.push((y0 + y) * width + x0 + x);
43
+ return { ...templateSlitherlink(width, height, seed), level };
44
+ }
45
+ /** The loop of a random region of squares: connected, without a hole, and never touching itself at a corner. */
46
+ function growRegion(width, height, share, thin, random) {
47
+ const stride = width + 2, inside = new Uint8Array(stride * (height + 2));
48
+ const at = (x, y) => (y + 1) * stride + x + 1;
49
+ const target = Math.max(2, Math.round(width * height * share));
50
+ const start = at(Math.floor(random() * width), Math.floor(random() * height));
51
+ inside[start] = 1;
52
+ let size = 1;
53
+ const outsideReaches = () => {
54
+ // Every square not in the region must still be reachable from beyond the board.
55
+ const seen = new Uint8Array(inside.length), stack = [0];
56
+ seen[0] = 1;
57
+ let reached = 1;
58
+ while (stack.length) {
59
+ const cell = stack.pop();
60
+ for (const next of [cell - 1, cell + 1, cell - stride, cell + stride]) {
61
+ if (next < 0 || next >= inside.length || seen[next] || inside[next])
62
+ continue;
63
+ seen[next] = 1;
64
+ reached += 1;
65
+ stack.push(next);
66
+ }
39
67
  }
68
+ return reached === inside.length - size;
69
+ };
70
+ while (size < target) {
71
+ const options = [];
72
+ for (let y = 0; y < height; y += 1)
73
+ for (let x = 0; x < width; x += 1) {
74
+ const cell = at(x, y);
75
+ if (inside[cell])
76
+ continue;
77
+ const touching = (inside[cell - 1] + inside[cell + 1] + inside[cell - stride] + inside[cell + stride]);
78
+ if (!touching)
79
+ continue;
80
+ // Two squares touching only at a corner would pinch the loop where it passes itself.
81
+ if ([[cell - 1, cell - stride, cell - stride - 1], [cell + 1, cell - stride, cell - stride + 1], [cell - 1, cell + stride, cell + stride - 1], [cell + 1, cell + stride, cell + stride + 1]]
82
+ .some(([a, b, diagonal]) => inside[diagonal] && !inside[a] && !inside[b]))
83
+ continue;
84
+ inside[cell] = 1;
85
+ size += 1;
86
+ const fine = outsideReaches();
87
+ inside[cell] = 0;
88
+ size -= 1;
89
+ if (!fine)
90
+ continue;
91
+ options.push({ cell, touching });
92
+ }
93
+ if (!options.length)
94
+ break;
95
+ const thinnest = options.filter(option => option.touching === 1);
96
+ const pool = thinnest.length && random() < thin ? thinnest : options;
97
+ inside[pool[Math.floor(random() * pool.length)].cell] = 1;
98
+ size += 1;
40
99
  }
100
+ const region = new Uint8Array(width * height);
101
+ for (let y = 0; y < height; y += 1)
102
+ for (let x = 0; x < width; x += 1)
103
+ region[y * width + x] = inside[at(x, y)];
104
+ return region;
105
+ }
106
+ function loopOf(width, height, region) {
41
107
  const blank = { width, height, clues: Array(width * height).fill(null) };
42
- const regionSet = new Set(region);
43
108
  const edges = new Set();
44
- for (const cell of region) {
109
+ for (let cell = 0; cell < region.length; cell += 1) {
110
+ if (!region[cell])
111
+ continue;
45
112
  const [top, right, bottom, left] = slitherlinkCellEdges(blank, cell);
46
113
  const x = cell % width;
47
- if (!regionSet.has(cell - width))
114
+ if (cell < width || !region[cell - width])
48
115
  edges.add(top);
49
- if (x === width - 1 || !regionSet.has(cell + 1))
116
+ if (x === width - 1 || !region[cell + 1])
50
117
  edges.add(right);
51
- if (!regionSet.has(cell + width))
118
+ if (cell >= width * (height - 1) || !region[cell + width])
52
119
  edges.add(bottom);
53
- if (x === 0 || !regionSet.has(cell - 1))
120
+ if (x === 0 || !region[cell - 1])
54
121
  edges.add(left);
55
122
  }
56
- let board = {
57
- width,
58
- height,
59
- clues: Array.from({ length: width * height }, (_, cell) => slitherlinkCellEdges(blank, cell).filter(edge => edges.has(edge)).length),
123
+ return edges;
124
+ }
125
+ function attemptSlitherlink(width, height, level, random, work) {
126
+ const share = { easy: .45, medium: .5, hard: .55, "extra-hard": .6 }[level] * (.9 + random() * .2);
127
+ const region = growRegion(width, height, share, .75, random);
128
+ const edges = loopOf(width, height, region);
129
+ const blank = { width, height, clues: Array(width * height).fill(null) };
130
+ const full = Array.from({ length: width * height }, (_, cell) => slitherlinkCellEdges(blank, cell).filter(edge => edges.has(edge)).length);
131
+ const clues = [...full];
132
+ let calls = 0;
133
+ const csp = () => {
134
+ const { csp: model } = slitherlinkModel({ width, height, clues });
135
+ return { ...model, propagate: (alive, decided) => { calls += 1; work.calls += 1; return model.propagate(alive, decided); } };
136
+ };
137
+ const byRules = () => { const c = csp(); return logicCsp(c, openSlots(c), 0).solved; };
138
+ const bySupposing = () => {
139
+ const c = csp();
140
+ const count = countCsp(c, openSlots(c), 2, 800);
141
+ if (!count.exhausted && count.count !== 1)
142
+ return false;
143
+ return logicCsp(c, openSlots(c), 1).solved;
60
144
  };
61
- let answer = [...edges];
62
- // Seeded axis reflections give each selected shape more than one orientation.
63
- const reflectX = random() < .5;
64
- const reflectY = random() < .5;
65
- if (reflectX || reflectY) {
66
- const horizontalCount = width * (height + 1);
67
- const mapEdge = (edge) => {
68
- if (edge < horizontalCount) {
69
- const x = edge % width;
70
- const y = Math.floor(edge / width);
71
- const nextX = reflectX ? width - 1 - x : x;
72
- const nextY = reflectY ? height - y : y;
73
- return nextY * width + nextX;
145
+ if (!byRules())
146
+ return null;
147
+ const order = shuffled(clues.map((_, cell) => cell), random).sort((a, b) => (full[a] === 0 ? 0 : 1) - (full[b] === 0 ? 0 : 1));
148
+ const keepAtLeast = level === "easy" ? Math.ceil(width * height * .7) : 0;
149
+ let left = clues.length;
150
+ /** Takes numbers away while `test` still passes, until `spent` says the work is used up or `done` says the board is hard enough. */
151
+ const thin = (test, spent, done = () => false) => {
152
+ for (const cell of order) {
153
+ if (clues[cell] === null)
154
+ continue;
155
+ if (left <= keepAtLeast || spent())
156
+ return;
157
+ const was = clues[cell];
158
+ clues[cell] = null;
159
+ if (!test()) {
160
+ clues[cell] = was;
161
+ continue;
74
162
  }
75
- const local = edge - horizontalCount;
76
- const x = local % (width + 1);
77
- const y = Math.floor(local / (width + 1));
78
- const nextX = reflectX ? width - x : x;
79
- const nextY = reflectY ? height - 1 - y : y;
80
- return horizontalCount + nextY * (width + 1) + nextX;
81
- };
82
- const reflected = Array(width * height).fill(null);
83
- board.clues.forEach((clue, cell) => {
84
- const x = cell % width;
85
- const y = Math.floor(cell / width);
86
- const nextX = reflectX ? width - 1 - x : x;
87
- const nextY = reflectY ? height - 1 - y : y;
88
- reflected[nextY * width + nextX] = clue;
89
- });
90
- board = { width, height, clues: reflected };
91
- answer = answer.map(mapEdge);
92
- }
93
- if (!isSlitherlinkBoard(board) || !checkSlitherlink(board, answer).ok) {
94
- throw new Error("Unable to construct Slitherlink loop");
95
- }
96
- const proof = solveSlitherlink(board, { limit: 2 });
97
- if (!proof.complete || proof.count !== 1 || !proof.solution) {
98
- throw new Error("No uniquely solvable Slitherlink found within the generation budget");
99
- }
100
- return { ...board, seed, solution: proof.solution };
163
+ left -= 1;
164
+ if (done())
165
+ return;
166
+ }
167
+ };
168
+ thin(byRules, () => false);
169
+ // Hard stops thinning when its share of work is used; extra-hard is given four times as much and so ends sparser.
170
+ const budget = width * height * (level === "extra-hard" ? 420 : 120);
171
+ const from = calls;
172
+ if (level === "hard" || level === "extra-hard")
173
+ thin(bySupposing, () => calls - from > budget);
174
+ const board = { width, height, clues };
175
+ const c = csp();
176
+ if (logicCsp(c, openSlots(c), 0).solved)
177
+ return level === "easy" || level === "medium" ? { board, probes: 0 } : null;
178
+ if (level === "easy" || level === "medium")
179
+ return null;
180
+ const probing = logicCsp(c, openSlots(c), 1);
181
+ return probing.solved ? { board, probes: probing.probes } : level === "extra-hard" ? { board, probes: 1000 } : null;
101
182
  }
@@ -0,0 +1,10 @@
1
+ import type { Csp } from "./csp.ts";
2
+ import type { SlitherlinkBoard } from "./slitherlink.types.ts";
3
+ /**
4
+ * Slitherlink as variables: one per edge, slot 0 for off and slot 1 for on. The pruning rules are the ones
5
+ * a person uses first: a number counts the edges round its square, every corner has none or two edges, and
6
+ * a loop may not close while any other part of it is still to be drawn or while a number is not yet met.
7
+ */
8
+ export declare function slitherlinkModel(board: SlitherlinkBoard): {
9
+ csp: Csp;
10
+ };