@johnmorrisdotca/kazu 1.1.0 → 1.2.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.
- package/CHANGELOG.md +16 -0
- package/README.md +379 -7
- package/dist/akari-draw-entry.d.ts +2 -0
- package/dist/akari-draw-entry.js +1 -0
- package/dist/akari-entry.d.ts +12 -0
- package/dist/akari-entry.js +10 -0
- package/dist/akari-play-entry.d.ts +2 -0
- package/dist/akari-play-entry.js +1 -0
- package/dist/akari.constants.d.ts +2 -0
- package/dist/akari.constants.js +2 -0
- package/dist/akari.types.d.ts +33 -0
- package/dist/akari.types.js +1 -0
- package/dist/akariBoard.d.ts +11 -0
- package/dist/akariBoard.js +96 -0
- package/dist/akariDraw.d.ts +4 -0
- package/dist/akariDraw.js +38 -0
- package/dist/akariGame.d.ts +15 -0
- package/dist/akariGame.js +71 -0
- package/dist/akariGenerate.d.ts +7 -0
- package/dist/akariGenerate.js +78 -0
- package/dist/akariMount.d.ts +3 -0
- package/dist/akariMount.js +214 -0
- package/dist/akariPlay.types.d.ts +25 -0
- package/dist/akariPlay.types.js +1 -0
- package/dist/akariSolve.d.ts +6 -0
- package/dist/akariSolve.js +106 -0
- package/dist/akariStrings.d.ts +98 -0
- package/dist/akariStrings.js +53 -0
- package/dist/akariStyle.d.ts +1 -0
- package/dist/akariStyle.js +9 -0
- package/dist/fillomino-draw-entry.d.ts +2 -0
- package/dist/fillomino-draw-entry.js +1 -0
- package/dist/fillomino-entry.d.ts +8 -0
- package/dist/fillomino-entry.js +7 -0
- package/dist/fillomino-play-entry.d.ts +4 -0
- package/dist/fillomino-play-entry.js +3 -0
- package/dist/fillomino.constants.d.ts +5 -0
- package/dist/fillomino.constants.js +5 -0
- package/dist/fillomino.types.d.ts +30 -0
- package/dist/fillomino.types.js +1 -0
- package/dist/fillominoBoard.d.ts +6 -0
- package/dist/fillominoBoard.js +84 -0
- package/dist/fillominoDraw.d.ts +3 -0
- package/dist/fillominoDraw.js +31 -0
- package/dist/fillominoGame.d.ts +13 -0
- package/dist/fillominoGame.js +65 -0
- package/dist/fillominoGenerate.d.ts +3 -0
- package/dist/fillominoGenerate.js +125 -0
- package/dist/fillominoMount.d.ts +2 -0
- package/dist/fillominoMount.js +211 -0
- package/dist/fillominoPlay.types.d.ts +25 -0
- package/dist/fillominoPlay.types.js +1 -0
- package/dist/fillominoSolve.d.ts +6 -0
- package/dist/fillominoSolve.js +111 -0
- package/dist/fillominoStrings.d.ts +102 -0
- package/dist/fillominoStrings.js +13 -0
- package/dist/fillominoStyle.d.ts +1 -0
- package/dist/fillominoStyle.js +13 -0
- package/dist/fillominoWorker.d.ts +1 -0
- package/dist/fillominoWorker.js +10 -0
- package/dist/heyawake-draw-entry.d.ts +2 -0
- package/dist/heyawake-draw-entry.js +1 -0
- package/dist/heyawake-entry.d.ts +13 -0
- package/dist/heyawake-entry.js +11 -0
- package/dist/heyawake-play-entry.d.ts +2 -0
- package/dist/heyawake-play-entry.js +1 -0
- package/dist/heyawake.constants.d.ts +5 -0
- package/dist/heyawake.constants.js +5 -0
- package/dist/heyawake.types.d.ts +35 -0
- package/dist/heyawake.types.js +1 -0
- package/dist/heyawakeBoard.d.ts +7 -0
- package/dist/heyawakeBoard.js +116 -0
- package/dist/heyawakeDraw.d.ts +3 -0
- package/dist/heyawakeDraw.js +27 -0
- package/dist/heyawakeGame.d.ts +12 -0
- package/dist/heyawakeGame.js +51 -0
- package/dist/heyawakeGenerate.d.ts +2 -0
- package/dist/heyawakeGenerate.js +114 -0
- package/dist/heyawakeMount.d.ts +2 -0
- package/dist/heyawakeMount.js +106 -0
- package/dist/heyawakePlay.types.d.ts +24 -0
- package/dist/heyawakePlay.types.js +1 -0
- package/dist/heyawakeSolve.d.ts +5 -0
- package/dist/heyawakeSolve.js +52 -0
- package/dist/heyawakeStrings.d.ts +19 -0
- package/dist/heyawakeStrings.js +7 -0
- package/dist/heyawakeStyle.d.ts +1 -0
- package/dist/heyawakeStyle.js +2 -0
- package/dist/heyawakeWorker.d.ts +1 -0
- package/dist/heyawakeWorker.js +10 -0
- package/dist/hitori-draw-entry.d.ts +2 -0
- package/dist/hitori-draw-entry.js +1 -0
- package/dist/hitori-entry.d.ts +6 -0
- package/dist/hitori-entry.js +6 -0
- package/dist/hitori-play-entry.d.ts +4 -0
- package/dist/hitori-play-entry.js +3 -0
- package/dist/hitori.constants.d.ts +2 -0
- package/dist/hitori.constants.js +2 -0
- package/dist/hitori.types.d.ts +20 -0
- package/dist/hitori.types.js +1 -0
- package/dist/hitoriBoard.d.ts +8 -0
- package/dist/hitoriBoard.js +72 -0
- package/dist/hitoriDraw.d.ts +4 -0
- package/dist/hitoriDraw.js +25 -0
- package/dist/hitoriGame.d.ts +10 -0
- package/dist/hitoriGame.js +48 -0
- package/dist/hitoriGenerate.d.ts +3 -0
- package/dist/hitoriGenerate.js +163 -0
- package/dist/hitoriMount.d.ts +3 -0
- package/dist/hitoriMount.js +111 -0
- package/dist/hitoriPlay.types.d.ts +25 -0
- package/dist/hitoriPlay.types.js +1 -0
- package/dist/hitoriSolve.d.ts +6 -0
- package/dist/hitoriSolve.js +69 -0
- package/dist/hitoriStrings.d.ts +70 -0
- package/dist/hitoriStrings.js +17 -0
- package/dist/hitoriStyle.d.ts +1 -0
- package/dist/hitoriStyle.js +9 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/juosan-draw-entry.d.ts +2 -0
- package/dist/juosan-draw-entry.js +1 -0
- package/dist/juosan-entry.d.ts +11 -0
- package/dist/juosan-entry.js +10 -0
- package/dist/juosan-play-entry.d.ts +4 -0
- package/dist/juosan-play-entry.js +3 -0
- package/dist/juosan.constants.d.ts +4 -0
- package/dist/juosan.constants.js +4 -0
- package/dist/juosan.types.d.ts +33 -0
- package/dist/juosan.types.js +1 -0
- package/dist/juosanBoard.d.ts +7 -0
- package/dist/juosanBoard.js +102 -0
- package/dist/juosanDraw.d.ts +4 -0
- package/dist/juosanDraw.js +49 -0
- package/dist/juosanGame.d.ts +20 -0
- package/dist/juosanGame.js +86 -0
- package/dist/juosanGenerate.d.ts +3 -0
- package/dist/juosanGenerate.js +31 -0
- package/dist/juosanMount.d.ts +3 -0
- package/dist/juosanMount.js +206 -0
- package/dist/juosanPlay.types.d.ts +24 -0
- package/dist/juosanPlay.types.js +1 -0
- package/dist/juosanSolve.d.ts +3 -0
- package/dist/juosanSolve.js +82 -0
- package/dist/juosanStrings.d.ts +77 -0
- package/dist/juosanStrings.js +43 -0
- package/dist/juosanStyle.d.ts +1 -0
- package/dist/juosanStyle.js +1 -0
- package/dist/kakuro-draw-entry.d.ts +2 -0
- package/dist/kakuro-draw-entry.js +1 -0
- package/dist/kakuro-entry.d.ts +12 -0
- package/dist/kakuro-entry.js +10 -0
- package/dist/kakuro-play-entry.d.ts +4 -0
- package/dist/kakuro-play-entry.js +3 -0
- package/dist/kakuro.constants.d.ts +6 -0
- package/dist/kakuro.constants.js +6 -0
- package/dist/kakuro.types.d.ts +46 -0
- package/dist/kakuro.types.js +1 -0
- package/dist/kakuroBoard.d.ts +8 -0
- package/dist/kakuroBoard.js +117 -0
- package/dist/kakuroDraw.d.ts +10 -0
- package/dist/kakuroDraw.js +21 -0
- package/dist/kakuroGame.d.ts +12 -0
- package/dist/kakuroGame.js +74 -0
- package/dist/kakuroGenerate.d.ts +3 -0
- package/dist/kakuroGenerate.js +130 -0
- package/dist/kakuroMount.d.ts +2 -0
- package/dist/kakuroMount.js +109 -0
- package/dist/kakuroPlay.types.d.ts +20 -0
- package/dist/kakuroPlay.types.js +1 -0
- package/dist/kakuroSolve.d.ts +6 -0
- package/dist/kakuroSolve.js +85 -0
- package/dist/kakuroStrings.d.ts +82 -0
- package/dist/kakuroStrings.js +5 -0
- package/dist/kakuroStyle.d.ts +1 -0
- package/dist/kakuroStyle.js +1 -0
- package/dist/masyu-draw-entry.d.ts +2 -0
- package/dist/masyu-draw-entry.js +1 -0
- package/dist/masyu-entry.d.ts +6 -0
- package/dist/masyu-entry.js +6 -0
- package/dist/masyu-play-entry.d.ts +4 -0
- package/dist/masyu-play-entry.js +3 -0
- package/dist/masyu.constants.d.ts +2 -0
- package/dist/masyu.constants.js +2 -0
- package/dist/masyu.types.d.ts +46 -0
- package/dist/masyu.types.js +1 -0
- package/dist/masyuBoard.d.ts +10 -0
- package/dist/masyuBoard.js +88 -0
- package/dist/masyuDraw.d.ts +2 -0
- package/dist/masyuDraw.js +33 -0
- package/dist/masyuGame.d.ts +9 -0
- package/dist/masyuGame.js +46 -0
- package/dist/masyuGenerate.d.ts +3 -0
- package/dist/masyuGenerate.js +56 -0
- package/dist/masyuMount.d.ts +2 -0
- package/dist/masyuMount.js +118 -0
- package/dist/masyuPlay.types.d.ts +1 -0
- package/dist/masyuPlay.types.js +1 -0
- package/dist/masyuSolve.d.ts +6 -0
- package/dist/masyuSolve.js +88 -0
- package/dist/masyuStrings.d.ts +28 -0
- package/dist/masyuStrings.js +4 -0
- package/dist/masyuStyle.d.ts +1 -0
- package/dist/masyuStyle.js +11 -0
- package/dist/nurikabe-draw-entry.d.ts +2 -0
- package/dist/nurikabe-draw-entry.js +1 -0
- package/dist/nurikabe-entry.d.ts +6 -0
- package/dist/nurikabe-entry.js +6 -0
- package/dist/nurikabe-play-entry.d.ts +4 -0
- package/dist/nurikabe-play-entry.js +3 -0
- package/dist/nurikabe.constants.d.ts +2 -0
- package/dist/nurikabe.constants.js +2 -0
- package/dist/nurikabe.types.d.ts +20 -0
- package/dist/nurikabe.types.js +1 -0
- package/dist/nurikabeBoard.d.ts +8 -0
- package/dist/nurikabeBoard.js +72 -0
- package/dist/nurikabeDraw.d.ts +4 -0
- package/dist/nurikabeDraw.js +15 -0
- package/dist/nurikabeGame.d.ts +8 -0
- package/dist/nurikabeGame.js +40 -0
- package/dist/nurikabeGenerate.d.ts +3 -0
- package/dist/nurikabeGenerate.js +31 -0
- package/dist/nurikabeMount.d.ts +3 -0
- package/dist/nurikabeMount.js +99 -0
- package/dist/nurikabePlay.types.d.ts +25 -0
- package/dist/nurikabePlay.types.js +1 -0
- package/dist/nurikabeSolve.d.ts +6 -0
- package/dist/nurikabeSolve.js +75 -0
- package/dist/nurikabeStrings.d.ts +70 -0
- package/dist/nurikabeStrings.js +5 -0
- package/dist/nurikabeStyle.d.ts +1 -0
- package/dist/nurikabeStyle.js +9 -0
- package/dist/ripple-draw-entry.d.ts +2 -0
- package/dist/ripple-draw-entry.js +1 -0
- package/dist/ripple-entry.d.ts +12 -0
- package/dist/ripple-entry.js +11 -0
- package/dist/ripple-play-entry.d.ts +4 -0
- package/dist/ripple-play-entry.js +3 -0
- package/dist/ripple.constants.d.ts +4 -0
- package/dist/ripple.constants.js +4 -0
- package/dist/ripple.types.d.ts +34 -0
- package/dist/ripple.types.js +1 -0
- package/dist/rippleBoard.d.ts +7 -0
- package/dist/rippleBoard.js +99 -0
- package/dist/rippleDraw.d.ts +3 -0
- package/dist/rippleDraw.js +32 -0
- package/dist/rippleGame.d.ts +13 -0
- package/dist/rippleGame.js +85 -0
- package/dist/rippleGenerate.d.ts +3 -0
- package/dist/rippleGenerate.js +57 -0
- package/dist/rippleMount.d.ts +2 -0
- package/dist/rippleMount.js +179 -0
- package/dist/ripplePlay.types.d.ts +23 -0
- package/dist/ripplePlay.types.js +1 -0
- package/dist/rippleSolve.d.ts +6 -0
- package/dist/rippleSolve.js +83 -0
- package/dist/rippleStrings.d.ts +86 -0
- package/dist/rippleStrings.js +5 -0
- package/dist/rippleStyle.d.ts +1 -0
- package/dist/rippleStyle.js +1 -0
- package/dist/shikaku-entry.d.ts +1 -0
- package/dist/shikaku-entry.js +1 -0
- package/dist/shikakuPacks.d.ts +27 -0
- package/dist/shikakuPacks.js +26 -0
- package/dist/slitherlink-draw-entry.d.ts +2 -0
- package/dist/slitherlink-draw-entry.js +1 -0
- package/dist/slitherlink-entry.d.ts +11 -0
- package/dist/slitherlink-entry.js +9 -0
- package/dist/slitherlink-play-entry.d.ts +4 -0
- package/dist/slitherlink-play-entry.js +3 -0
- package/dist/slitherlink.constants.d.ts +2 -0
- package/dist/slitherlink.constants.js +2 -0
- package/dist/slitherlink.types.d.ts +34 -0
- package/dist/slitherlink.types.js +1 -0
- package/dist/slitherlinkBoard.d.ts +12 -0
- package/dist/slitherlinkBoard.js +132 -0
- package/dist/slitherlinkDraw.d.ts +4 -0
- package/dist/slitherlinkDraw.js +51 -0
- package/dist/slitherlinkGame.d.ts +8 -0
- package/dist/slitherlinkGame.js +63 -0
- package/dist/slitherlinkGenerate.d.ts +3 -0
- package/dist/slitherlinkGenerate.js +101 -0
- package/dist/slitherlinkMount.d.ts +3 -0
- package/dist/slitherlinkMount.js +267 -0
- package/dist/slitherlinkPlay.types.d.ts +23 -0
- package/dist/slitherlinkPlay.types.js +1 -0
- package/dist/slitherlinkSolve.d.ts +6 -0
- package/dist/slitherlinkSolve.js +127 -0
- package/dist/slitherlinkStrings.d.ts +86 -0
- package/dist/slitherlinkStrings.js +47 -0
- package/dist/slitherlinkStyle.d.ts +1 -0
- package/dist/slitherlinkStyle.js +18 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/yajilin-draw-entry.d.ts +2 -0
- package/dist/yajilin-draw-entry.js +1 -0
- package/dist/yajilin-entry.d.ts +6 -0
- package/dist/yajilin-entry.js +6 -0
- package/dist/yajilin-play-entry.d.ts +4 -0
- package/dist/yajilin-play-entry.js +3 -0
- package/dist/yajilin.constants.d.ts +2 -0
- package/dist/yajilin.constants.js +2 -0
- package/dist/yajilin.types.d.ts +63 -0
- package/dist/yajilin.types.js +1 -0
- package/dist/yajilinBoard.d.ts +9 -0
- package/dist/yajilinBoard.js +91 -0
- package/dist/yajilinDraw.d.ts +2 -0
- package/dist/yajilinDraw.js +33 -0
- package/dist/yajilinGame.d.ts +15 -0
- package/dist/yajilinGame.js +58 -0
- package/dist/yajilinGenerate.d.ts +3 -0
- package/dist/yajilinGenerate.js +224 -0
- package/dist/yajilinMount.d.ts +2 -0
- package/dist/yajilinMount.js +162 -0
- package/dist/yajilinPlay.types.d.ts +1 -0
- package/dist/yajilinPlay.types.js +1 -0
- package/dist/yajilinSolve.d.ts +6 -0
- package/dist/yajilinSolve.js +82 -0
- package/dist/yajilinStrings.d.ts +32 -0
- package/dist/yajilinStrings.js +4 -0
- package/dist/yajilinStyle.d.ts +1 -0
- package/dist/yajilinStyle.js +12 -0
- package/package.json +180 -3
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { MASYU_NODE_BUDGET } from "./masyu.constants.js";
|
|
2
|
+
import { checkMasyu, isMasyuBoard, masyuEdgeCount, masyuNeighbors } from "./masyuBoard.js";
|
|
3
|
+
/** Counts distinct single loops with local pearl rules; budget stops never claim a proof. */
|
|
4
|
+
export function solveMasyu(board, options = {}) {
|
|
5
|
+
if (!isMasyuBoard(board))
|
|
6
|
+
throw new RangeError("Invalid Masyu board");
|
|
7
|
+
const limit = options.limit ?? 2, budget = options.nodes ?? MASYU_NODE_BUDGET;
|
|
8
|
+
if (!Number.isInteger(limit) || limit < 1 || !Number.isInteger(budget) || budget < 1)
|
|
9
|
+
throw new RangeError("Invalid search bounds");
|
|
10
|
+
const edgeState = Array(masyuEdgeCount(board)).fill(-1);
|
|
11
|
+
const configs = board.pearls.map((pearl, cell) => {
|
|
12
|
+
const validDirections = masyuNeighbors(board, cell).map(next => next.direction);
|
|
13
|
+
const choices = [];
|
|
14
|
+
for (let mask = 0; mask < 16; mask += 1) {
|
|
15
|
+
const bits = [0, 1, 2, 3].filter(direction => mask & (1 << direction));
|
|
16
|
+
if (bits.length !== 0 && bits.length !== 2)
|
|
17
|
+
continue;
|
|
18
|
+
if (bits.some(direction => !validDirections.includes(direction)))
|
|
19
|
+
continue;
|
|
20
|
+
if (pearl && bits.length !== 2)
|
|
21
|
+
continue;
|
|
22
|
+
const straight = bits.length === 2 && (bits[0] + 2) % 4 === bits[1];
|
|
23
|
+
if (pearl === 1 && !straight || pearl === 2 && straight)
|
|
24
|
+
continue;
|
|
25
|
+
choices.push(mask);
|
|
26
|
+
}
|
|
27
|
+
return choices;
|
|
28
|
+
});
|
|
29
|
+
let count = 0, nodes = 0, complete = true, answer = null;
|
|
30
|
+
const assigned = Array(board.pearls.length).fill(false);
|
|
31
|
+
const visit = (depth) => {
|
|
32
|
+
if (++nodes > budget) {
|
|
33
|
+
complete = false;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (depth === board.pearls.length) {
|
|
37
|
+
const edges = edgeState.flatMap((value, edge) => value === 1 ? [edge] : []);
|
|
38
|
+
if (!checkMasyu(board, edges).ok)
|
|
39
|
+
return;
|
|
40
|
+
count += 1;
|
|
41
|
+
answer ?? (answer = edges);
|
|
42
|
+
if (count >= limit)
|
|
43
|
+
complete = false;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
let chosen = -1, available = null;
|
|
47
|
+
for (let cell = 0; cell < board.pearls.length; cell += 1) {
|
|
48
|
+
if (assigned[cell])
|
|
49
|
+
continue;
|
|
50
|
+
const neighbors = masyuNeighbors(board, cell);
|
|
51
|
+
const candidates = configs[cell].filter(mask => neighbors.every(next => edgeState[next.edge] < 0 || edgeState[next.edge] === (mask & (1 << next.direction) ? 1 : 0)));
|
|
52
|
+
if (!candidates.length)
|
|
53
|
+
return;
|
|
54
|
+
if (!available || candidates.length < available.length) {
|
|
55
|
+
chosen = cell;
|
|
56
|
+
available = candidates;
|
|
57
|
+
if (candidates.length === 1)
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const neighbors = masyuNeighbors(board, chosen);
|
|
62
|
+
assigned[chosen] = true;
|
|
63
|
+
for (const mask of available ?? []) {
|
|
64
|
+
const changes = [];
|
|
65
|
+
let valid = true;
|
|
66
|
+
for (const next of neighbors) {
|
|
67
|
+
const value = mask & (1 << next.direction) ? 1 : 0;
|
|
68
|
+
if (edgeState[next.edge] >= 0 && edgeState[next.edge] !== value) {
|
|
69
|
+
valid = false;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
if (edgeState[next.edge] < 0) {
|
|
73
|
+
edgeState[next.edge] = value;
|
|
74
|
+
changes.push(next.edge);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (valid)
|
|
78
|
+
visit(depth + 1);
|
|
79
|
+
for (const edge of changes)
|
|
80
|
+
edgeState[edge] = -1;
|
|
81
|
+
if (!complete)
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
assigned[chosen] = false;
|
|
85
|
+
};
|
|
86
|
+
visit(0);
|
|
87
|
+
return { count, solution: answer, complete, nodes };
|
|
88
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export declare const MASYU_STRINGS: {
|
|
2
|
+
readonly en: {
|
|
3
|
+
readonly title: "Masyu";
|
|
4
|
+
readonly help: "Draw one loop through every pearl. White pearls go straight and turn at a neighboring cell; black pearls turn and go straight through the neighboring cell on each side.";
|
|
5
|
+
readonly undo: "Undo";
|
|
6
|
+
readonly hint: "Hint";
|
|
7
|
+
readonly check: "Check";
|
|
8
|
+
readonly restart: "Restart";
|
|
9
|
+
readonly won: "Solved!";
|
|
10
|
+
readonly helped: "Solved with help.";
|
|
11
|
+
readonly good: "The loop is still open. Keep going.";
|
|
12
|
+
readonly bad: "Check the marked line ends and pearls.";
|
|
13
|
+
readonly pearl: readonly ["empty", "white pearl", "black pearl"];
|
|
14
|
+
};
|
|
15
|
+
readonly ja: {
|
|
16
|
+
readonly title: "ましゅ";
|
|
17
|
+
readonly help: "すべての真珠を通る一つの輪を描きます。白真珠では直進して隣のマスで曲がり、黒真珠では曲がって両隣のマスで直進します。";
|
|
18
|
+
readonly undo: "戻す";
|
|
19
|
+
readonly hint: "ヒント";
|
|
20
|
+
readonly check: "確認";
|
|
21
|
+
readonly restart: "やり直す";
|
|
22
|
+
readonly won: "完成!";
|
|
23
|
+
readonly helped: "ヒントを使って完成。";
|
|
24
|
+
readonly good: "輪はまだ閉じていません。続けましょう。";
|
|
25
|
+
readonly bad: "線の端や真珠の周りを確認してください。";
|
|
26
|
+
readonly pearl: readonly ["なし", "白真珠", "黒真珠"];
|
|
27
|
+
};
|
|
28
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export const MASYU_STRINGS = {
|
|
2
|
+
en: { title: "Masyu", help: "Draw one loop through every pearl. White pearls go straight and turn at a neighboring cell; black pearls turn and go straight through the neighboring cell on each side.", undo: "Undo", hint: "Hint", check: "Check", restart: "Restart", won: "Solved!", helped: "Solved with help.", good: "The loop is still open. Keep going.", bad: "Check the marked line ends and pearls.", pearl: ["empty", "white pearl", "black pearl"] },
|
|
3
|
+
ja: { title: "ましゅ", help: "すべての真珠を通る一つの輪を描きます。白真珠では直進して隣のマスで曲がり、黒真珠では曲がって両隣のマスで直進します。", undo: "戻す", hint: "ヒント", check: "確認", restart: "やり直す", won: "完成!", helped: "ヒントを使って完成。", good: "輪はまだ閉じていません。続けましょう。", bad: "線の端や真珠の周りを確認してください。", pearl: ["なし", "白真珠", "黒真珠"] },
|
|
4
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const MASYU_PLAY_STYLE = "\n.kazu-masyu { color:var(--kz-ink,#1f2320); font:16px/1.4 system-ui,sans-serif; }\n.kazu-masyu .km-board { position:relative; max-width:440px; margin:auto; }\n.kazu-masyu svg { border:1px solid var(--kz-frame,#a98954); border-radius:4px; }\n.kazu-masyu .km-cells { position:absolute; inset:6%; display:grid; }\n.kazu-masyu .km-cell { border:0; background:transparent; border-radius:50%; color:transparent; cursor:pointer; }\n.kazu-masyu .km-cell:focus-visible { outline:3px solid var(--kz-focus,#c57d2d); outline-offset:-5px; }\n.kazu-masyu .km-tools { display:flex; flex-wrap:wrap; gap:.5rem; justify-content:center; margin:.8rem 0; }\n.kazu-masyu button { font:inherit; padding:.45rem .8rem; }\n.kazu-masyu .km-status { min-height:1.5em; text-align:center; }\n";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const MASYU_PLAY_STYLE = `
|
|
2
|
+
.kazu-masyu { color:var(--kz-ink,#1f2320); font:16px/1.4 system-ui,sans-serif; }
|
|
3
|
+
.kazu-masyu .km-board { position:relative; max-width:440px; margin:auto; }
|
|
4
|
+
.kazu-masyu svg { border:1px solid var(--kz-frame,#a98954); border-radius:4px; }
|
|
5
|
+
.kazu-masyu .km-cells { position:absolute; inset:6%; display:grid; }
|
|
6
|
+
.kazu-masyu .km-cell { border:0; background:transparent; border-radius:50%; color:transparent; cursor:pointer; }
|
|
7
|
+
.kazu-masyu .km-cell:focus-visible { outline:3px solid var(--kz-focus,#c57d2d); outline-offset:-5px; }
|
|
8
|
+
.kazu-masyu .km-tools { display:flex; flex-wrap:wrap; gap:.5rem; justify-content:center; margin:.8rem 0; }
|
|
9
|
+
.kazu-masyu button { font:inherit; padding:.45rem .8rem; }
|
|
10
|
+
.kazu-masyu .km-status { min-height:1.5em; text-align:center; }
|
|
11
|
+
`;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { drawNurikabe } from "./nurikabeDraw.js";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type NurikabeBoard = {
|
|
2
|
+
size: 5;
|
|
3
|
+
clues: readonly number[];
|
|
4
|
+
};
|
|
5
|
+
export type NurikabePuzzle = NurikabeBoard & {
|
|
6
|
+
seed: number;
|
|
7
|
+
solution: readonly boolean[];
|
|
8
|
+
};
|
|
9
|
+
export type NurikabeSolve = {
|
|
10
|
+
count: number;
|
|
11
|
+
solution: readonly boolean[] | null;
|
|
12
|
+
complete: boolean;
|
|
13
|
+
nodes: number;
|
|
14
|
+
};
|
|
15
|
+
export type NurikabeGame = {
|
|
16
|
+
board: NurikabeBoard;
|
|
17
|
+
sea: readonly boolean[];
|
|
18
|
+
history: readonly (readonly boolean[])[];
|
|
19
|
+
helped: boolean;
|
|
20
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { NurikabeBoard } from "./nurikabe.types.ts";
|
|
2
|
+
export declare function isNurikabeBoard(board: NurikabeBoard): boolean;
|
|
3
|
+
export declare function nurikabeNeighbors(size: number, cell: number): number[];
|
|
4
|
+
/** Checks island areas and separation, the connected sea, and the ban on 2×2 sea blocks. */
|
|
5
|
+
export declare function checkNurikabe(board: NurikabeBoard, sea: readonly boolean[]): {
|
|
6
|
+
ok: boolean;
|
|
7
|
+
errors: readonly number[];
|
|
8
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export function isNurikabeBoard(board) {
|
|
2
|
+
return !!board && board.size === 5 && Array.isArray(board.clues)
|
|
3
|
+
&& board.clues.length === board.size * board.size
|
|
4
|
+
&& board.clues.every(value => Number.isInteger(value) && value >= 0 && value <= board.size ** 2)
|
|
5
|
+
&& board.clues.some(value => value > 0)
|
|
6
|
+
&& board.clues.reduce((sum, value) => sum + value, 0) < board.size * board.size;
|
|
7
|
+
}
|
|
8
|
+
export function nurikabeNeighbors(size, cell) {
|
|
9
|
+
const x = cell % size, y = Math.floor(cell / size), neighbors = [];
|
|
10
|
+
if (x > 0)
|
|
11
|
+
neighbors.push(cell - 1);
|
|
12
|
+
if (x < size - 1)
|
|
13
|
+
neighbors.push(cell + 1);
|
|
14
|
+
if (y > 0)
|
|
15
|
+
neighbors.push(cell - size);
|
|
16
|
+
if (y < size - 1)
|
|
17
|
+
neighbors.push(cell + size);
|
|
18
|
+
return neighbors;
|
|
19
|
+
}
|
|
20
|
+
/** Checks island areas and separation, the connected sea, and the ban on 2×2 sea blocks. */
|
|
21
|
+
export function checkNurikabe(board, sea) {
|
|
22
|
+
if (!isNurikabeBoard(board) || sea.length !== board.size * board.size || !sea.every(value => typeof value === "boolean"))
|
|
23
|
+
return { ok: false, errors: [] };
|
|
24
|
+
const size = board.size, total = size * size, errors = new Set();
|
|
25
|
+
for (let cell = 0; cell < total; cell += 1)
|
|
26
|
+
if (board.clues[cell] && sea[cell])
|
|
27
|
+
errors.add(cell);
|
|
28
|
+
for (let y = 0; y < size - 1; y += 1)
|
|
29
|
+
for (let x = 0; x < size - 1; x += 1) {
|
|
30
|
+
const square = [y * size + x, y * size + x + 1, (y + 1) * size + x, (y + 1) * size + x + 1];
|
|
31
|
+
if (square.every(cell => sea[cell]))
|
|
32
|
+
square.forEach(cell => errors.add(cell));
|
|
33
|
+
}
|
|
34
|
+
const visited = new Set(), islands = [];
|
|
35
|
+
for (let start = 0; start < total; start += 1)
|
|
36
|
+
if (!sea[start] && !visited.has(start)) {
|
|
37
|
+
const stack = [start], cells = [], clues = [];
|
|
38
|
+
visited.add(start);
|
|
39
|
+
while (stack.length) {
|
|
40
|
+
const cell = stack.pop();
|
|
41
|
+
cells.push(cell);
|
|
42
|
+
if (board.clues[cell])
|
|
43
|
+
clues.push(cell);
|
|
44
|
+
for (const next of nurikabeNeighbors(size, cell))
|
|
45
|
+
if (!sea[next] && !visited.has(next)) {
|
|
46
|
+
visited.add(next);
|
|
47
|
+
stack.push(next);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
islands.push({ cells, clues });
|
|
51
|
+
if (clues.length !== 1 || board.clues[clues[0]] !== cells.length)
|
|
52
|
+
cells.forEach(cell => errors.add(cell));
|
|
53
|
+
}
|
|
54
|
+
for (let cell = 0; cell < total; cell += 1)
|
|
55
|
+
if (board.clues[cell] && !islands.some(island => island.clues[0] === cell))
|
|
56
|
+
errors.add(cell);
|
|
57
|
+
const black = sea.map((value, cell) => value ? cell : -1).filter(cell => cell >= 0), connected = new Set();
|
|
58
|
+
if (black.length) {
|
|
59
|
+
const stack = [black[0]];
|
|
60
|
+
connected.add(black[0]);
|
|
61
|
+
while (stack.length)
|
|
62
|
+
for (const next of nurikabeNeighbors(size, stack.pop()))
|
|
63
|
+
if (sea[next] && !connected.has(next)) {
|
|
64
|
+
connected.add(next);
|
|
65
|
+
stack.push(next);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
for (const cell of black)
|
|
69
|
+
if (!connected.has(cell))
|
|
70
|
+
errors.add(cell);
|
|
71
|
+
return { ok: errors.size === 0, errors: [...errors].sort((a, b) => a - b) };
|
|
72
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { NurikabeBoard } from "./nurikabe.types.ts";
|
|
2
|
+
import type { NurikabeDrawOptions } from "./nurikabePlay.types.ts";
|
|
3
|
+
/** SVG text contains the numbered clues and the player's sea marks only. */
|
|
4
|
+
export declare function drawNurikabe(board: NurikabeBoard, options?: NurikabeDrawOptions): string;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { isNurikabeBoard } from "./nurikabeBoard.js";
|
|
2
|
+
/** SVG text contains the numbered clues and the player's sea marks only. */
|
|
3
|
+
export function drawNurikabe(board, options = {}) {
|
|
4
|
+
if (!isNurikabeBoard(board))
|
|
5
|
+
throw new RangeError("Invalid Nurikabe board");
|
|
6
|
+
const n = board.size, cell = 52;
|
|
7
|
+
const palettes = { ivory: ["var(--kz-paper,#fbf8f1)", "var(--kz-ink,#1f2320)", "var(--kz-grid,#cfc6b2)"], wood: ["#e5cda6", "#493e2f", "#bca582"], slate: ["#333a42", "#f1eee7", "#707984"] };
|
|
8
|
+
const [paper, ink, grid] = palettes[options.material ?? "ivory"], sea = options.sea ?? [];
|
|
9
|
+
const cells = board.clues.map((clue, c) => {
|
|
10
|
+
const x = c % n * cell, y = Math.floor(c / n) * cell, black = sea[c], selected = options.selected === c, error = options.errors?.includes(c);
|
|
11
|
+
const tile = options.pieces === "tiles" && clue && !black ? `<rect x="${x + 10}" y="${y + 10}" width="32" height="32" rx="6" fill="${paper}" stroke="${grid}"/>` : "";
|
|
12
|
+
return `<g><rect x="${x}" y="${y}" width="${cell}" height="${cell}" fill="${black ? ink : selected ? `${ink}22` : paper}" stroke="${grid}" stroke-width="1"/>${tile}${clue ? `<text x="${x + cell / 2}" y="${y + cell / 2}" text-anchor="middle" dominant-baseline="middle" fill="${black ? paper : error ? "#b5452c" : ink}" font-size="22" font-weight="700" font-family="system-ui,sans-serif">${clue}</text>` : ""}</g>`;
|
|
13
|
+
}).join("");
|
|
14
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 ${n * cell + 4} ${n * cell + 4}" role="img" aria-label="Nurikabe" style="display:block;width:100%;height:auto;background:${paper}">${cells}<rect width="${n * cell}" height="${n * cell}" fill="none" stroke="var(--kz-frame,#a98954)" stroke-width="2"/></svg>`;
|
|
15
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { NurikabeBoard, NurikabeGame } from "./nurikabe.types.ts";
|
|
2
|
+
export declare function newNurikabe(board: NurikabeBoard): NurikabeGame;
|
|
3
|
+
export declare function markNurikabeSea(game: NurikabeGame, cell: number): NurikabeGame;
|
|
4
|
+
export declare function undoNurikabe(game: NurikabeGame): NurikabeGame;
|
|
5
|
+
export declare function restartNurikabe(game: NurikabeGame): NurikabeGame;
|
|
6
|
+
export declare function hintNurikabe(game: NurikabeGame): number | null;
|
|
7
|
+
export declare function encodeNurikabe(game: NurikabeGame): string;
|
|
8
|
+
export declare function decodeNurikabe(code: string): NurikabeGame | null;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { isNurikabeBoard } from "./nurikabeBoard.js";
|
|
2
|
+
import { solveNurikabe } from "./nurikabeSolve.js";
|
|
3
|
+
export function newNurikabe(board) {
|
|
4
|
+
if (!isNurikabeBoard(board))
|
|
5
|
+
throw new RangeError("Invalid Nurikabe board");
|
|
6
|
+
return { board: { size: board.size, clues: [...board.clues] }, sea: Array(board.size ** 2).fill(false), history: [], helped: false };
|
|
7
|
+
}
|
|
8
|
+
export function markNurikabeSea(game, cell) {
|
|
9
|
+
if (!Number.isInteger(cell) || cell < 0 || cell >= game.sea.length || game.board.clues[cell])
|
|
10
|
+
return game;
|
|
11
|
+
const sea = [...game.sea];
|
|
12
|
+
sea[cell] = !sea[cell];
|
|
13
|
+
return { ...game, sea, history: [...game.history, [...game.sea]] };
|
|
14
|
+
}
|
|
15
|
+
export function undoNurikabe(game) {
|
|
16
|
+
const prior = game.history.at(-1);
|
|
17
|
+
return prior ? { ...game, sea: [...prior], history: game.history.slice(0, -1) } : game;
|
|
18
|
+
}
|
|
19
|
+
export function restartNurikabe(game) { return { ...game, sea: Array(game.sea.length).fill(false), history: [], helped: false }; }
|
|
20
|
+
export function hintNurikabe(game) {
|
|
21
|
+
const solved = solveNurikabe(game.board);
|
|
22
|
+
if (!solved.complete || solved.count !== 1 || !solved.solution)
|
|
23
|
+
return null;
|
|
24
|
+
const cell = solved.solution.findIndex((value, i) => value !== game.sea[i]);
|
|
25
|
+
return cell < 0 ? null : cell;
|
|
26
|
+
}
|
|
27
|
+
export function encodeNurikabe(game) { return JSON.stringify({ version: 1, board: game.board, sea: game.sea, helped: game.helped }); }
|
|
28
|
+
export function decodeNurikabe(code) {
|
|
29
|
+
try {
|
|
30
|
+
if (code.length > 10000)
|
|
31
|
+
return null;
|
|
32
|
+
const value = JSON.parse(code);
|
|
33
|
+
if (value.version !== 1 || !isNurikabeBoard(value.board) || !Array.isArray(value.sea) || value.sea.length !== value.board.size ** 2 || !value.sea.every((item) => typeof item === "boolean") || typeof value.helped !== "boolean")
|
|
34
|
+
return null;
|
|
35
|
+
return { ...newNurikabe(value.board), sea: [...value.sea], helped: value.helped };
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { NURIKABE_MAX_NODES } from "./nurikabe.constants.js";
|
|
2
|
+
import { checkNurikabe } from "./nurikabeBoard.js";
|
|
3
|
+
import { solveNurikabe } from "./nurikabeSolve.js";
|
|
4
|
+
import { isKazuSeed, seededRandom } from "./random.js";
|
|
5
|
+
const originals = [
|
|
6
|
+
{ clues: [1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 7, 0, 0], solution: [false, true, true, false, true, true, false, true, true, true, true, true, true, false, true, false, true, false, false, false, true, true, false, false, false] },
|
|
7
|
+
{ clues: [0, 0, 0, 0, 0, 0, 2, 0, 0, 1, 2, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 3], solution: [true, true, true, true, true, true, false, false, true, false, false, true, true, true, true, false, true, false, true, false, true, true, true, false, false] },
|
|
8
|
+
{ clues: [1, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 4, 0, 3, 0, 0, 0, 0, 0, 0, 1], solution: [false, true, true, true, true, true, true, false, true, false, false, true, false, true, false, false, true, false, true, true, false, false, true, true, false] },
|
|
9
|
+
{ clues: [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 6, 0], solution: [false, true, true, true, false, false, true, false, true, true, false, true, false, false, true, true, true, true, false, true, false, false, true, false, false] },
|
|
10
|
+
{ clues: [0, 0, 0, 1, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 0, 1, 0, 0, 0], solution: [true, true, true, false, true, true, false, false, true, true, true, false, true, true, false, true, true, true, false, false, true, false, true, false, false] },
|
|
11
|
+
];
|
|
12
|
+
/** Seeded symmetries of original 5×5 layouts are returned only after the solver proves uniqueness. */
|
|
13
|
+
export function generateNurikabe(seed = 1) {
|
|
14
|
+
if (!isKazuSeed(seed))
|
|
15
|
+
throw new RangeError("Invalid Nurikabe seed");
|
|
16
|
+
const random = seededRandom(seed), sample = originals[Math.floor(random() * originals.length)], rotation = Math.floor(random() * 4), reflect = random() < 0.5;
|
|
17
|
+
const size = 5, clues = Array(size * size).fill(0), solution = Array(size * size).fill(false);
|
|
18
|
+
for (let y = 0; y < size; y += 1)
|
|
19
|
+
for (let x = 0; x < size; x += 1) {
|
|
20
|
+
let sx = reflect ? size - 1 - x : x, sy = y;
|
|
21
|
+
for (let turn = 0; turn < rotation; turn += 1)
|
|
22
|
+
[sx, sy] = [size - 1 - sy, sx];
|
|
23
|
+
const from = sy * size + sx, to = y * size + x;
|
|
24
|
+
clues[to] = sample.clues[from];
|
|
25
|
+
solution[to] = sample.solution[from];
|
|
26
|
+
}
|
|
27
|
+
const board = { size: 5, clues }, proof = solveNurikabe(board, { nodes: NURIKABE_MAX_NODES });
|
|
28
|
+
if (!checkNurikabe(board, solution).ok || !proof.complete || proof.count !== 1 || !proof.solution)
|
|
29
|
+
throw new Error("No uniquely solvable Nurikabe found within the generation budget; try another seed");
|
|
30
|
+
return { ...board, seed, solution: proof.solution };
|
|
31
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { checkNurikabe } from "./nurikabeBoard.js";
|
|
2
|
+
import { decodeNurikabe, encodeNurikabe, hintNurikabe, markNurikabeSea, newNurikabe, restartNurikabe, undoNurikabe } from "./nurikabeGame.js";
|
|
3
|
+
import { drawNurikabe } from "./nurikabeDraw.js";
|
|
4
|
+
import { KAZU_STYLE } from "./style.js";
|
|
5
|
+
import { NURIKABE_PLAY_STYLE } from "./nurikabeStyle.js";
|
|
6
|
+
import { nurikabeWords } from "./nurikabeStrings.js";
|
|
7
|
+
/** A keyboard- and touch-friendly Nurikabe player, mounted in any host element. */
|
|
8
|
+
export function mountNurikabe(host, initial) {
|
|
9
|
+
const doc = host.ownerDocument;
|
|
10
|
+
let options = { ...initial }, game = initial.progress ? decodeNurikabe(initial.progress) : null;
|
|
11
|
+
game ?? (game = newNurikabe(initial.board));
|
|
12
|
+
let selected = 0, errors = [], message = "", finished = checkNurikabe(game.board, game.sea).ok, dead = false;
|
|
13
|
+
const root = doc.createElement("div"), style = doc.createElement("style"), board = doc.createElement("div"), status = doc.createElement("p"), tools = doc.createElement("div"), help = doc.createElement("p");
|
|
14
|
+
root.className = "kn-root";
|
|
15
|
+
style.textContent = KAZU_STYLE.replace(/\.kazu/g, ".ks-palette") + NURIKABE_PLAY_STYLE;
|
|
16
|
+
board.className = "kn-board";
|
|
17
|
+
status.className = "kn-status";
|
|
18
|
+
status.setAttribute("role", "status");
|
|
19
|
+
tools.className = "kn-tools";
|
|
20
|
+
help.className = "kn-help";
|
|
21
|
+
root.append(style, board, status, tools, help);
|
|
22
|
+
host.append(root);
|
|
23
|
+
const copy = () => ({ ...game, board: { ...game.board, clues: [...game.board.clues] }, sea: [...game.sea], history: game.history.map(turn => [...turn]) });
|
|
24
|
+
const button = (label, action, disabled = false) => {
|
|
25
|
+
const control = doc.createElement("button");
|
|
26
|
+
control.type = "button";
|
|
27
|
+
control.textContent = label;
|
|
28
|
+
control.disabled = disabled;
|
|
29
|
+
control.onclick = action;
|
|
30
|
+
tools.append(control);
|
|
31
|
+
};
|
|
32
|
+
const changed = () => {
|
|
33
|
+
errors = [];
|
|
34
|
+
message = "";
|
|
35
|
+
const wasFinished = finished;
|
|
36
|
+
finished = checkNurikabe(game.board, game.sea).ok;
|
|
37
|
+
render();
|
|
38
|
+
options.onChange?.(copy());
|
|
39
|
+
if (finished && !wasFinished)
|
|
40
|
+
options.onFinish?.(copy());
|
|
41
|
+
};
|
|
42
|
+
const mark = (cell) => { if (finished)
|
|
43
|
+
return; selected = cell; game = markNurikabeSea(game, cell); changed(); };
|
|
44
|
+
const render = () => {
|
|
45
|
+
if (dead)
|
|
46
|
+
return;
|
|
47
|
+
const words = nurikabeWords(options.language), focused = board.contains(doc.activeElement);
|
|
48
|
+
board.innerHTML = drawNurikabe(game.board, { ...options, sea: game.sea, selected, errors });
|
|
49
|
+
const cells = doc.createElement("div");
|
|
50
|
+
cells.className = "kn-cells";
|
|
51
|
+
cells.style.gridTemplateColumns = `repeat(${game.board.size},1fr)`;
|
|
52
|
+
for (let cell = 0; cell < game.sea.length; cell += 1) {
|
|
53
|
+
const control = doc.createElement("button");
|
|
54
|
+
control.type = "button";
|
|
55
|
+
control.className = "kn-cell";
|
|
56
|
+
control.dataset.cell = String(cell);
|
|
57
|
+
control.tabIndex = cell === selected ? 0 : -1;
|
|
58
|
+
control.setAttribute("aria-label", `${words.row} ${Math.floor(cell / game.board.size) + 1}, ${words.column} ${cell % game.board.size + 1}: ${game.board.clues[cell] || (game.sea[cell] ? words.sea : words.white)}`);
|
|
59
|
+
control.onclick = () => mark(cell);
|
|
60
|
+
control.onkeydown = event => {
|
|
61
|
+
const n = game.board.size, moves = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -n, ArrowDown: n };
|
|
62
|
+
if (event.key in moves) {
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
selected = Math.max(0, Math.min(game.sea.length - 1, cell + moves[event.key]));
|
|
65
|
+
render();
|
|
66
|
+
}
|
|
67
|
+
else if (event.key === " " || event.key === "Enter") {
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
mark(cell);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
cells.append(control);
|
|
73
|
+
}
|
|
74
|
+
board.append(cells);
|
|
75
|
+
if (focused)
|
|
76
|
+
board.querySelector(`[data-cell="${selected}"]`)?.focus();
|
|
77
|
+
status.textContent = finished ? (game.helped ? words.helped : words.won) : message;
|
|
78
|
+
help.textContent = words.help;
|
|
79
|
+
tools.replaceChildren();
|
|
80
|
+
button(words.undo, () => { game = undoNurikabe(game); changed(); }, !game.history.length);
|
|
81
|
+
button(words.check, () => { errors = checkNurikabe(game.board, game.sea).errors; message = errors.length ? words.wrong : words.good; render(); });
|
|
82
|
+
button(words.hint, () => {
|
|
83
|
+
const cell = hintNurikabe(game);
|
|
84
|
+
if (cell === null) {
|
|
85
|
+
message = words.noHint;
|
|
86
|
+
render();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
game = { ...markNurikabeSea(game, cell), helped: true };
|
|
90
|
+
changed();
|
|
91
|
+
}, finished);
|
|
92
|
+
button(words.restart, () => { game = restartNurikabe(game); finished = false; changed(); });
|
|
93
|
+
};
|
|
94
|
+
render();
|
|
95
|
+
return {
|
|
96
|
+
game: copy, progress: () => encodeNurikabe(game), restart: () => { game = restartNurikabe(game); finished = false; changed(); },
|
|
97
|
+
set: (next) => { options = { ...options, ...next }; render(); }, destroy: () => { dead = true; root.remove(); },
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { NurikabeBoard, NurikabeGame } from "./nurikabe.types.ts";
|
|
2
|
+
export type NurikabeLanguage = "en" | "ja";
|
|
3
|
+
export type NurikabeMaterial = "ivory" | "wood" | "slate";
|
|
4
|
+
export type NurikabePieces = "ink" | "tiles";
|
|
5
|
+
export type NurikabeDrawOptions = {
|
|
6
|
+
sea?: readonly boolean[];
|
|
7
|
+
selected?: number | null;
|
|
8
|
+
errors?: readonly number[];
|
|
9
|
+
material?: NurikabeMaterial;
|
|
10
|
+
pieces?: NurikabePieces;
|
|
11
|
+
language?: NurikabeLanguage;
|
|
12
|
+
};
|
|
13
|
+
export type NurikabeMountOptions = NurikabeDrawOptions & {
|
|
14
|
+
board: NurikabeBoard;
|
|
15
|
+
progress?: string;
|
|
16
|
+
onChange?: (game: NurikabeGame) => void;
|
|
17
|
+
onFinish?: (game: NurikabeGame) => void;
|
|
18
|
+
};
|
|
19
|
+
export type NurikabeMount = {
|
|
20
|
+
game: () => NurikabeGame;
|
|
21
|
+
progress: () => string;
|
|
22
|
+
restart: () => void;
|
|
23
|
+
set: (options: NurikabeDrawOptions) => void;
|
|
24
|
+
destroy: () => void;
|
|
25
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { NurikabeBoard, NurikabeSolve } from "./nurikabe.types.ts";
|
|
2
|
+
/** Counts valid island placements and checks the remaining cells as the sea under a finite budget. */
|
|
3
|
+
export declare function solveNurikabe(board: NurikabeBoard, options?: {
|
|
4
|
+
limit?: number;
|
|
5
|
+
nodes?: number;
|
|
6
|
+
}): NurikabeSolve;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { NURIKABE_MAX_NODES } from "./nurikabe.constants.js";
|
|
2
|
+
import { checkNurikabe, isNurikabeBoard, nurikabeNeighbors } from "./nurikabeBoard.js";
|
|
3
|
+
function islandShapes(board, clue) {
|
|
4
|
+
let shapes = new Map([[String(clue.cell), new Set([clue.cell])]]);
|
|
5
|
+
for (let area = 1; area < clue.size; area += 1) {
|
|
6
|
+
const next = new Map();
|
|
7
|
+
for (const shape of shapes.values()) {
|
|
8
|
+
const fringe = new Set();
|
|
9
|
+
for (const cell of shape)
|
|
10
|
+
for (const neighbor of nurikabeNeighbors(board.size, cell)) {
|
|
11
|
+
if ((!board.clues[neighbor] || neighbor === clue.cell) && !shape.has(neighbor))
|
|
12
|
+
fringe.add(neighbor);
|
|
13
|
+
}
|
|
14
|
+
for (const cell of fringe) {
|
|
15
|
+
const grown = new Set(shape);
|
|
16
|
+
grown.add(cell);
|
|
17
|
+
const key = [...grown].sort((a, b) => a - b).join(",");
|
|
18
|
+
next.set(key, grown);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
shapes = next;
|
|
22
|
+
}
|
|
23
|
+
return [...shapes.values()].map(shape => [...shape]);
|
|
24
|
+
}
|
|
25
|
+
/** Counts valid island placements and checks the remaining cells as the sea under a finite budget. */
|
|
26
|
+
export function solveNurikabe(board, options = {}) {
|
|
27
|
+
if (!isNurikabeBoard(board))
|
|
28
|
+
throw new RangeError("Invalid Nurikabe board");
|
|
29
|
+
const total = board.size ** 2, limit = Math.max(1, Math.floor(options.limit ?? 2)), budget = Math.max(1, Math.floor(options.nodes ?? NURIKABE_MAX_NODES));
|
|
30
|
+
const clues = board.clues.map((size, cell) => size ? { cell, size } : null).filter((clue) => !!clue);
|
|
31
|
+
const choices = clues.map(clue => islandShapes(board, clue));
|
|
32
|
+
const placed = [];
|
|
33
|
+
let nodes = 0, count = 0, solution = null, stopped = false;
|
|
34
|
+
const visit = (depth) => {
|
|
35
|
+
if (count >= limit || stopped)
|
|
36
|
+
return;
|
|
37
|
+
nodes += 1;
|
|
38
|
+
if (nodes > budget) {
|
|
39
|
+
stopped = true;
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (depth === clues.length) {
|
|
43
|
+
const sea = Array(total).fill(true);
|
|
44
|
+
for (const island of placed)
|
|
45
|
+
for (const cell of island)
|
|
46
|
+
sea[cell] = false;
|
|
47
|
+
if (checkNurikabe(board, sea).ok) {
|
|
48
|
+
count += 1;
|
|
49
|
+
solution ?? (solution = sea);
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
let selected = -1, candidates = null;
|
|
54
|
+
for (let clue = 0; clue < clues.length; clue += 1)
|
|
55
|
+
if (!placed[clue]) {
|
|
56
|
+
const available = choices[clue].filter(shape => placed.every(other => !other
|
|
57
|
+
|| !shape.some(cell => other.some(neighbor => cell === neighbor || nurikabeNeighbors(board.size, cell).includes(neighbor)))));
|
|
58
|
+
if (!candidates || available.length < candidates.length) {
|
|
59
|
+
selected = clue;
|
|
60
|
+
candidates = available;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (selected < 0 || !candidates?.length)
|
|
64
|
+
return;
|
|
65
|
+
for (const shape of candidates) {
|
|
66
|
+
placed[selected] = shape;
|
|
67
|
+
visit(depth + 1);
|
|
68
|
+
delete placed[selected];
|
|
69
|
+
if (count >= limit || stopped)
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
visit(0);
|
|
74
|
+
return { count, solution, complete: !stopped && count < limit, nodes: Math.min(nodes, budget) };
|
|
75
|
+
}
|