@johnmorrisdotca/kazu 1.1.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.
- package/CHANGELOG.md +39 -0
- package/README.md +424 -10
- package/dist/akari-draw-entry.d.ts +2 -0
- package/dist/akari-draw-entry.js +1 -0
- package/dist/akari-entry.d.ts +13 -0
- package/dist/akari-entry.js +11 -0
- package/dist/akari-play-entry.d.ts +2 -0
- package/dist/akari-play-entry.js +1 -0
- package/dist/akari.constants.d.ts +7 -0
- package/dist/akari.constants.js +7 -0
- package/dist/akari.types.d.ts +52 -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 +10 -0
- package/dist/akariGenerate.js +101 -0
- package/dist/akariLogic.d.ts +10 -0
- package/dist/akariLogic.js +84 -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/akariRate.d.ts +6 -0
- package/dist/akariRate.js +34 -0
- package/dist/akariSolve.d.ts +6 -0
- package/dist/akariSolve.js +18 -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/akariTemplate.d.ts +7 -0
- package/dist/akariTemplate.js +78 -0
- package/dist/csp.d.ts +63 -0
- package/dist/csp.js +162 -0
- package/dist/fillomino-draw-entry.d.ts +2 -0
- package/dist/fillomino-draw-entry.js +1 -0
- package/dist/fillomino-entry.d.ts +9 -0
- package/dist/fillomino-entry.js +8 -0
- package/dist/fillomino-play-entry.d.ts +4 -0
- package/dist/fillomino-play-entry.js +3 -0
- package/dist/fillomino.constants.d.ts +9 -0
- package/dist/fillomino.constants.js +9 -0
- package/dist/fillomino.types.d.ts +47 -0
- package/dist/fillomino.types.js +1 -0
- package/dist/fillominoBoard.d.ts +6 -0
- package/dist/fillominoBoard.js +84 -0
- package/dist/fillominoBuild.d.ts +10 -0
- package/dist/fillominoBuild.js +78 -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 +9 -0
- package/dist/fillominoGenerate.js +93 -0
- package/dist/fillominoLogic.d.ts +14 -0
- package/dist/fillominoLogic.js +177 -0
- package/dist/fillominoMount.d.ts +2 -0
- package/dist/fillominoMount.js +214 -0
- package/dist/fillominoPlay.types.d.ts +25 -0
- package/dist/fillominoPlay.types.js +1 -0
- package/dist/fillominoRate.d.ts +3 -0
- package/dist/fillominoRate.js +57 -0
- package/dist/fillominoSolve.d.ts +6 -0
- package/dist/fillominoSolve.js +38 -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 +7 -0
- package/dist/hitori-entry.js +7 -0
- package/dist/hitori-play-entry.d.ts +4 -0
- package/dist/hitori-play-entry.js +3 -0
- package/dist/hitori.constants.d.ts +8 -0
- package/dist/hitori.constants.js +8 -0
- package/dist/hitori.types.d.ts +37 -0
- package/dist/hitori.types.js +1 -0
- package/dist/hitoriBoard.d.ts +8 -0
- package/dist/hitoriBoard.js +73 -0
- package/dist/hitoriBuild.d.ts +12 -0
- package/dist/hitoriBuild.js +117 -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 +10 -0
- package/dist/hitoriGenerate.js +65 -0
- package/dist/hitoriLogic.d.ts +12 -0
- package/dist/hitoriLogic.js +189 -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/hitoriRate.d.ts +3 -0
- package/dist/hitoriRate.js +32 -0
- package/dist/hitoriSolve.d.ts +9 -0
- package/dist/hitoriSolve.js +18 -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 +13 -0
- package/dist/kakuro-entry.js +11 -0
- package/dist/kakuro-play-entry.d.ts +4 -0
- package/dist/kakuro-play-entry.js +3 -0
- package/dist/kakuro.constants.d.ts +11 -0
- package/dist/kakuro.constants.js +11 -0
- package/dist/kakuro.types.d.ts +65 -0
- package/dist/kakuro.types.js +1 -0
- package/dist/kakuroBoard.d.ts +8 -0
- package/dist/kakuroBoard.js +117 -0
- package/dist/kakuroBuild.d.ts +30 -0
- package/dist/kakuroBuild.js +337 -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 +10 -0
- package/dist/kakuroGenerate.js +62 -0
- package/dist/kakuroLogic.d.ts +13 -0
- package/dist/kakuroLogic.js +153 -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/kakuroRate.d.ts +3 -0
- package/dist/kakuroRate.js +40 -0
- package/dist/kakuroSolve.d.ts +6 -0
- package/dist/kakuroSolve.js +33 -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/kakuroTemplate.d.ts +3 -0
- package/dist/kakuroTemplate.js +130 -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 +2 -0
- package/dist/shikaku-entry.js +2 -0
- package/dist/shikaku.constants.d.ts +5 -2
- package/dist/shikaku.constants.js +5 -2
- package/dist/shikaku.types.d.ts +18 -1
- package/dist/shikakuBuild.d.ts +21 -0
- package/dist/shikakuBuild.js +135 -0
- package/dist/shikakuGenerate.d.ts +6 -1
- package/dist/shikakuGenerate.js +50 -35
- package/dist/shikakuLogic.d.ts +13 -0
- package/dist/shikakuLogic.js +84 -0
- package/dist/shikakuPacks.d.ts +27 -0
- package/dist/shikakuPacks.js +26 -0
- package/dist/shikakuRate.d.ts +3 -0
- package/dist/shikakuRate.js +27 -0
- package/dist/shikakuSolve.d.ts +1 -1
- package/dist/shikakuSolve.js +23 -44
- package/dist/shikakuTemplate.d.ts +3 -0
- package/dist/shikakuTemplate.js +43 -0
- package/dist/slitherlink-draw-entry.d.ts +2 -0
- package/dist/slitherlink-draw-entry.js +1 -0
- package/dist/slitherlink-entry.d.ts +12 -0
- package/dist/slitherlink-entry.js +10 -0
- package/dist/slitherlink-play-entry.d.ts +4 -0
- package/dist/slitherlink-play-entry.js +3 -0
- package/dist/slitherlink.constants.d.ts +7 -0
- package/dist/slitherlink.constants.js +7 -0
- package/dist/slitherlink.types.d.ts +52 -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 +10 -0
- package/dist/slitherlinkGenerate.js +182 -0
- package/dist/slitherlinkLogic.d.ts +10 -0
- package/dist/slitherlinkLogic.js +267 -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/slitherlinkRate.d.ts +3 -0
- package/dist/slitherlinkRate.js +26 -0
- package/dist/slitherlinkSolve.d.ts +6 -0
- package/dist/slitherlinkSolve.js +19 -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/slitherlinkTemplate.d.ts +3 -0
- package/dist/slitherlinkTemplate.js +101 -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,99 @@
|
|
|
1
|
+
import { RIPPLE_MAX_SIDE, RIPPLE_MIN_SIDE } from "./ripple.constants.js";
|
|
2
|
+
export function isRippleBoard(value) {
|
|
3
|
+
if (!value || typeof value !== "object")
|
|
4
|
+
return false;
|
|
5
|
+
const board = value;
|
|
6
|
+
if (!Number.isInteger(board.width) || board.width < RIPPLE_MIN_SIDE || board.width > RIPPLE_MAX_SIDE
|
|
7
|
+
|| !Number.isInteger(board.height) || board.height < RIPPLE_MIN_SIDE || board.height > RIPPLE_MAX_SIDE
|
|
8
|
+
|| !Array.isArray(board.rooms) || !Array.isArray(board.clues)
|
|
9
|
+
|| board.rooms.length !== board.width * board.height || board.clues.length !== board.rooms.length
|
|
10
|
+
|| board.rooms.some(id => !Number.isInteger(id) || id < 0))
|
|
11
|
+
return false;
|
|
12
|
+
const members = new Map();
|
|
13
|
+
board.rooms.forEach((id, cell) => members.set(id, [...(members.get(id) ?? []), cell]));
|
|
14
|
+
const ids = [...members.keys()].sort((a, b) => a - b);
|
|
15
|
+
if (ids.some((id, index) => id !== index))
|
|
16
|
+
return false;
|
|
17
|
+
const sizes = new Map([...members].map(([id, cells]) => [id, cells.length]));
|
|
18
|
+
if (board.clues.some((clue, cell) => clue !== null && (!Number.isInteger(clue) || clue < 1 || clue > sizes.get(board.rooms[cell]))))
|
|
19
|
+
return false;
|
|
20
|
+
return [...members].every(([id, cells]) => connected(board, id, cells));
|
|
21
|
+
}
|
|
22
|
+
function connected(board, room, cells) {
|
|
23
|
+
const reached = new Set([cells[0]]);
|
|
24
|
+
const queue = [cells[0]];
|
|
25
|
+
while (queue.length) {
|
|
26
|
+
const cell = queue.pop();
|
|
27
|
+
for (const next of [cell - board.width, cell + board.width, cell - 1, cell + 1]) {
|
|
28
|
+
if (next < 0 || next >= board.rooms.length || (Math.abs(next - cell) === 1 && Math.floor(next / board.width) !== Math.floor(cell / board.width)))
|
|
29
|
+
continue;
|
|
30
|
+
if (board.rooms[next] === room && !reached.has(next)) {
|
|
31
|
+
reached.add(next);
|
|
32
|
+
queue.push(next);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return reached.size === cells.length;
|
|
37
|
+
}
|
|
38
|
+
export function rippleRoomSizes(board) {
|
|
39
|
+
const sizes = [];
|
|
40
|
+
board.rooms.forEach(id => { sizes[id] = (sizes[id] ?? 0) + 1; });
|
|
41
|
+
return sizes;
|
|
42
|
+
}
|
|
43
|
+
/** Checks room permutations and the at-least-N-cell gap between equal N clues in a line. */
|
|
44
|
+
export function checkRipple(board, values) {
|
|
45
|
+
if (!isRippleBoard(board))
|
|
46
|
+
throw new RangeError("Invalid Ripple Effect board");
|
|
47
|
+
if (values.length !== board.rooms.length)
|
|
48
|
+
throw new RangeError("Invalid Ripple Effect values");
|
|
49
|
+
const errors = new Set();
|
|
50
|
+
const sizes = rippleRoomSizes(board);
|
|
51
|
+
const seen = new Map();
|
|
52
|
+
values.forEach((value, cell) => {
|
|
53
|
+
const clue = board.clues[cell];
|
|
54
|
+
if (!Number.isInteger(value) || value < 0 || value > sizes[board.rooms[cell]]
|
|
55
|
+
|| clue !== null && value !== clue) {
|
|
56
|
+
errors.add(cell);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (value > 0)
|
|
60
|
+
seen.set(board.rooms[cell], [...(seen.get(board.rooms[cell]) ?? []), value]);
|
|
61
|
+
});
|
|
62
|
+
for (const room of seen.keys()) {
|
|
63
|
+
const cells = values.flatMap((value, cell) => board.rooms[cell] === room && value > 0 ? [value] : []);
|
|
64
|
+
const duplicates = new Set(cells.filter((value, index) => cells.indexOf(value) !== index));
|
|
65
|
+
if (duplicates.size)
|
|
66
|
+
values.forEach((value, cell) => { if (board.rooms[cell] === room && duplicates.has(value))
|
|
67
|
+
errors.add(cell); });
|
|
68
|
+
}
|
|
69
|
+
const lines = [...Array.from({ length: board.height }, (_, y) => Array.from({ length: board.width }, (_, x) => y * board.width + x)),
|
|
70
|
+
...Array.from({ length: board.width }, (_, x) => Array.from({ length: board.height }, (_, y) => y * board.width + x))];
|
|
71
|
+
for (const line of lines)
|
|
72
|
+
for (let a = 0; a < line.length; a += 1) {
|
|
73
|
+
const cell = line[a], value = values[cell];
|
|
74
|
+
if (!value)
|
|
75
|
+
continue;
|
|
76
|
+
for (let b = a + 1; b < line.length; b += 1) {
|
|
77
|
+
const other = line[b];
|
|
78
|
+
if (values[other] === value && b - a <= value) {
|
|
79
|
+
errors.add(cell);
|
|
80
|
+
errors.add(other);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const complete = values.every(value => value > 0);
|
|
85
|
+
if (complete) {
|
|
86
|
+
for (let room = 0; room < sizes.length; room += 1) {
|
|
87
|
+
const list = values.flatMap((value, cell) => board.rooms[cell] === room ? [value] : []).sort((a, b) => a - b);
|
|
88
|
+
if (list.some((value, index) => value !== index + 1))
|
|
89
|
+
board.rooms.forEach((id, cell) => { if (id === room)
|
|
90
|
+
errors.add(cell); });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { ok: errors.size === 0 && complete, errors: [...errors].sort((a, b) => a - b), complete };
|
|
94
|
+
}
|
|
95
|
+
/** Partial progress check; missing entries are allowed, but impossible room/ripple pairs are flagged. */
|
|
96
|
+
export function progressRipple(board, values) {
|
|
97
|
+
const result = checkRipple(board, values);
|
|
98
|
+
return { ...result, ok: result.errors.length === 0 };
|
|
99
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { rippleRoomSizes } from "./rippleBoard.js";
|
|
2
|
+
export function drawRipple(board, options = {}) {
|
|
3
|
+
const size = 56, margin = 8, width = board.width * size, height = board.height * size;
|
|
4
|
+
const ink = options.material === "slate" ? "#f1eee5" : options.material === "wood" ? "#382d20" : "#252824";
|
|
5
|
+
const line = options.material === "slate" ? "#899697" : options.material === "wood" ? "#80613c" : "#8b8578";
|
|
6
|
+
const surface = options.material === "slate" ? "#354247" : options.material === "wood" ? "#dfc18f" : "#faf7ef";
|
|
7
|
+
const values = options.values ?? board.clues.map(clue => clue ?? 0);
|
|
8
|
+
const notes = options.notes ?? board.rooms.map(() => []);
|
|
9
|
+
const sizes = rippleRoomSizes(board);
|
|
10
|
+
const cells = Array.from({ length: board.rooms.length }, (_, cell) => {
|
|
11
|
+
const x = cell % board.width * size, y = Math.floor(cell / board.width) * size;
|
|
12
|
+
const room = board.rooms[cell];
|
|
13
|
+
const clue = board.clues[cell];
|
|
14
|
+
const value = values[cell] ?? 0;
|
|
15
|
+
const text = clue !== null ? `<text class="rp-clue" x="${x + size / 2}" y="${y + size * .64}">${clue}</text>`
|
|
16
|
+
: value ? `<text class="rp-value" x="${x + size / 2}" y="${y + size * .64}">${value}</text>`
|
|
17
|
+
: (notes[cell] ?? []).map((note, i) => `<text class="rp-note" x="${x + 14 + i % 3 * 14}" y="${y + 18 + Math.floor(i / 3) * 14}">${note}</text>`).join("");
|
|
18
|
+
const selected = options.selected === cell;
|
|
19
|
+
const piece = options.pieces === "tiles" ? `<rect class="rp-piece" x="${x + 9}" y="${y + 9}" width="${size - 18}" height="${size - 18}" rx="6"/>` : "";
|
|
20
|
+
const error = options.errors?.includes(cell) ?? false;
|
|
21
|
+
return `<rect x="${x}" y="${y}" width="${size}" height="${size}" fill="${selected ? "#dce9c9" : surface}" stroke="${error ? "#c34b3e" : line}" stroke-width="1"/>${piece}${text}<title>${Math.floor(cell / board.width) + 1}, ${cell % board.width + 1}: ${clue !== null ? `clue ${clue}` : value ? `entry ${value}` : `room of ${sizes[room]}`}</title>`;
|
|
22
|
+
}).join("");
|
|
23
|
+
const walls = board.rooms.flatMap((room, cell) => {
|
|
24
|
+
const x = cell % board.width, y = Math.floor(cell / board.width), edges = [];
|
|
25
|
+
if (x + 1 < board.width && board.rooms[cell + 1] !== room)
|
|
26
|
+
edges.push(`<path d="M${(x + 1) * size} ${y * size}v${size}"/>`);
|
|
27
|
+
if (y + 1 < board.height && board.rooms[cell + board.width] !== room)
|
|
28
|
+
edges.push(`<path d="M${x * size} ${(y + 1) * size}h${size}"/>`);
|
|
29
|
+
return edges;
|
|
30
|
+
}).join("");
|
|
31
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Ripple Effect ${board.width} by ${board.height}" viewBox="-${margin} -${margin} ${width + margin * 2} ${height + margin * 2}" style="color:${ink}"><style>.rp-clue,.rp-value{font:500 27px system-ui;text-anchor:middle;fill:${ink}}.rp-clue{font-weight:700}.rp-note{font:12px system-ui;text-anchor:middle;fill:${ink}}.rp-piece{fill:none;stroke:${line};stroke-width:2}</style>${cells}<g fill="none" stroke="${ink}" stroke-width="3">${walls}</g></svg>`;
|
|
32
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { RippleBoard, RippleGame } from "./ripple.types.ts";
|
|
2
|
+
export declare function newRipple(board: RippleBoard): RippleGame;
|
|
3
|
+
export declare function setRippleValue(game: RippleGame, cell: number, value: number): RippleGame;
|
|
4
|
+
export declare function toggleRippleNote(game: RippleGame, cell: number, value: number): RippleGame;
|
|
5
|
+
export declare function undoRipple(game: RippleGame): RippleGame;
|
|
6
|
+
export declare function rippleFinished(game: RippleGame): boolean;
|
|
7
|
+
/** Offers a cell only when the current public entries leave one proved completion. */
|
|
8
|
+
export declare function hintRipple(game: RippleGame): {
|
|
9
|
+
cell: number;
|
|
10
|
+
value: number;
|
|
11
|
+
} | null;
|
|
12
|
+
export declare function encodeRipple(game: RippleGame): string;
|
|
13
|
+
export declare function decodeRipple(code: string): RippleGame | null;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { isRippleBoard, checkRipple, rippleRoomSizes } from "./rippleBoard.js";
|
|
2
|
+
import { solveRipple } from "./rippleSolve.js";
|
|
3
|
+
export function newRipple(board) {
|
|
4
|
+
if (!isRippleBoard(board))
|
|
5
|
+
throw new RangeError("Invalid Ripple Effect board");
|
|
6
|
+
return { board: { width: board.width, height: board.height, rooms: [...board.rooms], clues: [...board.clues] },
|
|
7
|
+
values: board.clues.map(clue => clue ?? 0), notes: board.rooms.map(() => []), history: [], helped: false };
|
|
8
|
+
}
|
|
9
|
+
export function setRippleValue(game, cell, value) {
|
|
10
|
+
if (!Number.isInteger(cell) || cell < 0 || cell >= game.values.length || game.board.clues[cell] !== null
|
|
11
|
+
|| !Number.isInteger(value) || value < 0 || value > rippleRoomSizes(game.board)[game.board.rooms[cell]])
|
|
12
|
+
return game;
|
|
13
|
+
if (game.values[cell] === value)
|
|
14
|
+
return game;
|
|
15
|
+
const values = [...game.values];
|
|
16
|
+
values[cell] = value;
|
|
17
|
+
const notes = game.notes.map((list, index) => index === cell ? [] : [...list]);
|
|
18
|
+
return { ...game, values, notes, history: [...game.history, snapshot(game)] };
|
|
19
|
+
}
|
|
20
|
+
export function toggleRippleNote(game, cell, value) {
|
|
21
|
+
if (!Number.isInteger(cell) || cell < 0 || cell >= game.values.length || game.board.clues[cell] !== null || game.values[cell] !== 0
|
|
22
|
+
|| !Number.isInteger(value) || value < 1 || value > rippleRoomSizes(game.board)[game.board.rooms[cell]])
|
|
23
|
+
return game;
|
|
24
|
+
const notes = game.notes.map(list => [...list]);
|
|
25
|
+
const current = new Set(notes[cell]);
|
|
26
|
+
if (current.has(value))
|
|
27
|
+
current.delete(value);
|
|
28
|
+
else
|
|
29
|
+
current.add(value);
|
|
30
|
+
notes[cell] = [...current].sort((a, b) => a - b);
|
|
31
|
+
return { ...game, notes, history: [...game.history, snapshot(game)] };
|
|
32
|
+
}
|
|
33
|
+
export function undoRipple(game) {
|
|
34
|
+
const previous = game.history.at(-1);
|
|
35
|
+
return previous ? { ...game, values: [...previous.values], notes: previous.notes.map(list => [...list]), history: game.history.slice(0, -1) } : game;
|
|
36
|
+
}
|
|
37
|
+
export function rippleFinished(game) { return checkRipple(game.board, game.values).ok; }
|
|
38
|
+
/** Offers a cell only when the current public entries leave one proved completion. */
|
|
39
|
+
export function hintRipple(game) {
|
|
40
|
+
const clues = game.values.map((value, cell) => game.board.clues[cell] ?? (value || null));
|
|
41
|
+
const solved = solveRipple({ ...game.board, clues });
|
|
42
|
+
if (!solved.complete || solved.count !== 1 || !solved.solution)
|
|
43
|
+
return null;
|
|
44
|
+
const cell = solved.solution.findIndex((value, index) => game.values[index] === 0 && value > 0);
|
|
45
|
+
return cell < 0 ? null : { cell, value: solved.solution[cell] };
|
|
46
|
+
}
|
|
47
|
+
export function encodeRipple(game) {
|
|
48
|
+
return JSON.stringify({ version: 1, board: game.board, values: game.values, notes: game.notes, helped: game.helped });
|
|
49
|
+
}
|
|
50
|
+
export function decodeRipple(code) {
|
|
51
|
+
try {
|
|
52
|
+
if (code.length > 40000)
|
|
53
|
+
return null;
|
|
54
|
+
const data = JSON.parse(code);
|
|
55
|
+
if (data.version !== 1 || !isRippleBoard(data.board) || !Array.isArray(data.values)
|
|
56
|
+
|| data.values.length !== data.board.rooms.length || !Array.isArray(data.notes)
|
|
57
|
+
|| data.notes.length !== data.board.rooms.length || typeof data.helped !== "boolean")
|
|
58
|
+
return null;
|
|
59
|
+
let game = newRipple(data.board);
|
|
60
|
+
for (let cell = 0; cell < data.values.length; cell += 1) {
|
|
61
|
+
const value = data.values[cell];
|
|
62
|
+
if (game.board.clues[cell] !== null && value !== game.board.clues[cell])
|
|
63
|
+
return null;
|
|
64
|
+
if (game.board.clues[cell] === null && value) {
|
|
65
|
+
const next = setRippleValue(game, cell, value);
|
|
66
|
+
if (next === game)
|
|
67
|
+
return null;
|
|
68
|
+
game = next;
|
|
69
|
+
}
|
|
70
|
+
const list = data.notes[cell];
|
|
71
|
+
if (!Array.isArray(list) || new Set(list).size !== list.length
|
|
72
|
+
|| list.some(note => !Number.isInteger(note) || note < 1 || note > rippleRoomSizes(game.board)[game.board.rooms[cell]]))
|
|
73
|
+
return null;
|
|
74
|
+
for (const note of list)
|
|
75
|
+
game = toggleRippleNote(game, cell, note);
|
|
76
|
+
}
|
|
77
|
+
return { ...game, history: [], helped: data.helped };
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function snapshot(game) {
|
|
84
|
+
return { values: [...game.values], notes: game.notes.map(list => [...list]) };
|
|
85
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { RIPPLE_MAX_ATTEMPTS } from "./ripple.constants.js";
|
|
2
|
+
import { checkRipple } from "./rippleBoard.js";
|
|
3
|
+
import { solveRipple } from "./rippleSolve.js";
|
|
4
|
+
import { isKazuSeed, seededRandom } from "./random.js";
|
|
5
|
+
/** Builds a seeded block-room family and accepts it only after a bounded uniqueness proof. */
|
|
6
|
+
export function generateRipple(width = 9, height = width, seed = 1) {
|
|
7
|
+
if (width !== 9 || height !== 9 || !isKazuSeed(seed)) {
|
|
8
|
+
throw new RangeError("Ripple Effect currently supports original 9×9 boards and valid integer seeds.");
|
|
9
|
+
}
|
|
10
|
+
const random = seededRandom(seed);
|
|
11
|
+
for (let attempt = 0; attempt < RIPPLE_MAX_ATTEMPTS; attempt += 1) {
|
|
12
|
+
const rooms = Array.from({ length: 81 }, (_, cell) => Math.floor(Math.floor(cell / 9) / 3) * 3 + Math.floor(cell % 9 / 3));
|
|
13
|
+
const solution = blockSolution(random);
|
|
14
|
+
const clues = [...solution];
|
|
15
|
+
const order = Array.from({ length: clues.length }, (_, cell) => cell);
|
|
16
|
+
order.splice(0, order.length, ...shuffled(order, random));
|
|
17
|
+
const target = 42 + Math.floor(random() * 7);
|
|
18
|
+
let clueCount = clues.length;
|
|
19
|
+
for (const cell of order) {
|
|
20
|
+
if (clueCount <= target)
|
|
21
|
+
break;
|
|
22
|
+
const previous = clues[cell];
|
|
23
|
+
clues[cell] = null;
|
|
24
|
+
const board = { width, height, rooms, clues };
|
|
25
|
+
const proof = solveRipple(board, { limit: 2 });
|
|
26
|
+
if (!proof.complete || proof.count !== 1) {
|
|
27
|
+
clues[cell] = previous;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
clueCount -= 1;
|
|
31
|
+
}
|
|
32
|
+
if (clueCount > 58 || clueCount >= clues.length)
|
|
33
|
+
continue;
|
|
34
|
+
const board = { width, height, rooms, clues };
|
|
35
|
+
const proof = solveRipple(board, { limit: 2 });
|
|
36
|
+
if (proof.complete && proof.count === 1 && proof.solution && checkRipple(board, proof.solution).ok) {
|
|
37
|
+
return { ...board, seed, solution: proof.solution };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
throw new Error("No uniquely solvable Ripple Effect board was proved within the generation budget.");
|
|
41
|
+
}
|
|
42
|
+
function blockSolution(random) {
|
|
43
|
+
const bands = shuffled([0, 1, 2], random);
|
|
44
|
+
const stacks = shuffled([0, 1, 2], random);
|
|
45
|
+
const rows = bands.flatMap(band => shuffled([0, 1, 2], random).map(row => band * 3 + row));
|
|
46
|
+
const columns = stacks.flatMap(stack => shuffled([0, 1, 2], random).map(column => stack * 3 + column));
|
|
47
|
+
const digits = shuffled([1, 2, 3, 4, 5, 6, 7, 8, 9], random);
|
|
48
|
+
return rows.flatMap(row => columns.map(column => digits[(row * 3 + Math.floor(row / 3) + column) % 9]));
|
|
49
|
+
}
|
|
50
|
+
function shuffled(items, random) {
|
|
51
|
+
const result = [...items];
|
|
52
|
+
for (let i = result.length - 1; i > 0; i -= 1) {
|
|
53
|
+
const j = Math.floor(random() * (i + 1));
|
|
54
|
+
[result[i], result[j]] = [result[j], result[i]];
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { KAZU_STYLE } from "./style.js";
|
|
2
|
+
import { checkRipple, progressRipple, rippleRoomSizes } from "./rippleBoard.js";
|
|
3
|
+
import { decodeRipple, encodeRipple, hintRipple, newRipple, rippleFinished, setRippleValue, toggleRippleNote, undoRipple } from "./rippleGame.js";
|
|
4
|
+
import { RIPPLE_PLAY_STYLE } from "./rippleStyle.js";
|
|
5
|
+
import { rippleWords } from "./rippleStrings.js";
|
|
6
|
+
export function mountRipple(host, initial) {
|
|
7
|
+
const doc = host.ownerDocument;
|
|
8
|
+
let options = { ...initial };
|
|
9
|
+
let game = initial.progress ? decodeRipple(initial.progress) : null;
|
|
10
|
+
game ?? (game = newRipple(initial.board));
|
|
11
|
+
let selected = game.values.findIndex((value, cell) => game.board.clues[cell] === null);
|
|
12
|
+
if (selected < 0)
|
|
13
|
+
selected = 0;
|
|
14
|
+
let pencil = false, errors = [], message = "", dead = false;
|
|
15
|
+
const root = doc.createElement("section");
|
|
16
|
+
root.className = "rp-root";
|
|
17
|
+
const style = doc.createElement("style");
|
|
18
|
+
style.textContent = KAZU_STYLE.replace(/\.kazu/g, ".rp-palette") + RIPPLE_PLAY_STYLE;
|
|
19
|
+
const board = doc.createElement("div");
|
|
20
|
+
board.className = "rp-board";
|
|
21
|
+
board.setAttribute("role", "grid");
|
|
22
|
+
const status = doc.createElement("p");
|
|
23
|
+
status.className = "rp-status";
|
|
24
|
+
status.setAttribute("role", "status");
|
|
25
|
+
const help = doc.createElement("p");
|
|
26
|
+
help.className = "rp-help";
|
|
27
|
+
const controls = doc.createElement("div");
|
|
28
|
+
controls.className = "rp-controls";
|
|
29
|
+
const numpad = doc.createElement("div");
|
|
30
|
+
numpad.className = "rp-numpad";
|
|
31
|
+
numpad.setAttribute("aria-label", "Numbers");
|
|
32
|
+
root.append(style, board, status, help, numpad, controls);
|
|
33
|
+
host.append(root);
|
|
34
|
+
const words = () => rippleWords(options.language);
|
|
35
|
+
const copy = () => ({ ...game, board: { ...game.board, rooms: [...game.board.rooms], clues: [...game.board.clues] }, values: [...game.values], notes: game.notes.map(list => [...list]), history: game.history.map(turn => ({ values: [...turn.values], notes: turn.notes.map(list => [...list]) })) });
|
|
36
|
+
const changed = () => {
|
|
37
|
+
errors = [];
|
|
38
|
+
message = "";
|
|
39
|
+
render();
|
|
40
|
+
const snapshot = copy();
|
|
41
|
+
options.onChange?.(snapshot);
|
|
42
|
+
root.dispatchEvent(new CustomEvent("ripple-change", { detail: snapshot, bubbles: true }));
|
|
43
|
+
if (rippleFinished(game)) {
|
|
44
|
+
options.onFinish?.(snapshot);
|
|
45
|
+
root.dispatchEvent(new CustomEvent("ripple-finish", { detail: snapshot, bubbles: true }));
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const enter = (value) => {
|
|
49
|
+
if (game.board.clues[selected] !== null)
|
|
50
|
+
return;
|
|
51
|
+
game = pencil ? toggleRippleNote(game, selected, value) : setRippleValue(game, selected, value);
|
|
52
|
+
changed();
|
|
53
|
+
};
|
|
54
|
+
const button = (label, action, disabled = false) => {
|
|
55
|
+
const item = doc.createElement("button");
|
|
56
|
+
item.type = "button";
|
|
57
|
+
item.className = "rp-tool";
|
|
58
|
+
item.textContent = label;
|
|
59
|
+
item.disabled = disabled;
|
|
60
|
+
item.onclick = action;
|
|
61
|
+
controls.append(item);
|
|
62
|
+
return item;
|
|
63
|
+
};
|
|
64
|
+
const render = () => {
|
|
65
|
+
if (dead)
|
|
66
|
+
return;
|
|
67
|
+
const focused = board.contains(doc.activeElement);
|
|
68
|
+
const w = words(), current = game.board, sizes = rippleRoomSizes(current), validation = progressRipple(current, game.values);
|
|
69
|
+
root.dataset.material = options.material ?? "ivory";
|
|
70
|
+
root.dataset.pieces = options.pieces ?? "ink";
|
|
71
|
+
root.classList.toggle("rp-tiles", options.pieces === "tiles");
|
|
72
|
+
root.style.setProperty("--rp-width", String(current.width));
|
|
73
|
+
board.setAttribute("aria-label", w.title);
|
|
74
|
+
board.replaceChildren();
|
|
75
|
+
for (let cell = 0; cell < current.rooms.length; cell += 1) {
|
|
76
|
+
const x = cell % current.width, y = Math.floor(cell / current.width), room = current.rooms[cell];
|
|
77
|
+
const control = doc.createElement("button");
|
|
78
|
+
control.type = "button";
|
|
79
|
+
control.className = "rp-cell";
|
|
80
|
+
if (x + 1 < current.width && current.rooms[cell + 1] !== room)
|
|
81
|
+
control.classList.add("rp-room-right");
|
|
82
|
+
if (y + 1 < current.height && current.rooms[cell + current.width] !== room)
|
|
83
|
+
control.classList.add("rp-room-bottom");
|
|
84
|
+
const clue = current.clues[cell] !== null, value = game.values[cell];
|
|
85
|
+
control.dataset.cell = String(cell);
|
|
86
|
+
control.dataset.clue = String(clue);
|
|
87
|
+
control.dataset.error = String(errors.includes(cell));
|
|
88
|
+
control.dataset.selected = String(cell === selected);
|
|
89
|
+
control.setAttribute("aria-disabled", String(clue));
|
|
90
|
+
control.tabIndex = cell === selected ? 0 : -1;
|
|
91
|
+
control.setAttribute("role", "gridcell");
|
|
92
|
+
control.setAttribute("aria-label", `${w.cell.replace("{row}", String(y + 1)).replace("{column}", String(x + 1))}, ${clue ? w.clue.replace("{value}", String(current.clues[cell])) : value ? w.entry.replace("{value}", String(value)) : game.notes[cell].length ? w.note.replace("{values}", game.notes[cell].join(", ")) : w.empty}`);
|
|
93
|
+
if (clue)
|
|
94
|
+
control.textContent = String(current.clues[cell]);
|
|
95
|
+
else if (value)
|
|
96
|
+
control.textContent = String(value);
|
|
97
|
+
else if (game.notes[cell].length) {
|
|
98
|
+
const mini = doc.createElement("span");
|
|
99
|
+
mini.className = "rp-notes";
|
|
100
|
+
for (let digit = 1; digit <= sizes[room]; digit += 1) {
|
|
101
|
+
const span = doc.createElement("span");
|
|
102
|
+
span.textContent = game.notes[cell].includes(digit) ? String(digit) : "";
|
|
103
|
+
mini.append(span);
|
|
104
|
+
}
|
|
105
|
+
control.append(mini);
|
|
106
|
+
}
|
|
107
|
+
control.onclick = () => { selected = cell; render(); };
|
|
108
|
+
control.onkeydown = event => {
|
|
109
|
+
if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) {
|
|
110
|
+
event.preventDefault();
|
|
111
|
+
move(cell, event.key);
|
|
112
|
+
}
|
|
113
|
+
else if (/^[1-9]$/.test(event.key)) {
|
|
114
|
+
event.preventDefault();
|
|
115
|
+
enter(Number(event.key));
|
|
116
|
+
}
|
|
117
|
+
else if (event.key === "Backspace" || event.key === "Delete") {
|
|
118
|
+
event.preventDefault();
|
|
119
|
+
enter(0);
|
|
120
|
+
}
|
|
121
|
+
else if (event.key.toLowerCase() === "p") {
|
|
122
|
+
event.preventDefault();
|
|
123
|
+
pencil = !pencil;
|
|
124
|
+
render();
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
board.append(control);
|
|
128
|
+
}
|
|
129
|
+
if (focused)
|
|
130
|
+
board.querySelector(`[data-cell="${selected}"]`)?.focus({ preventScroll: true });
|
|
131
|
+
numpad.replaceChildren();
|
|
132
|
+
for (let digit = 1; digit <= Math.max(...sizes); digit += 1) {
|
|
133
|
+
const key = doc.createElement("button");
|
|
134
|
+
key.type = "button";
|
|
135
|
+
key.className = "rp-key";
|
|
136
|
+
key.textContent = String(digit);
|
|
137
|
+
key.disabled = digit > sizes[current.rooms[selected]];
|
|
138
|
+
key.onclick = () => enter(digit);
|
|
139
|
+
numpad.append(key);
|
|
140
|
+
}
|
|
141
|
+
status.textContent = rippleFinished(game) ? (game.helped ? `${w.won} · ${w.hint}` : w.won) : message || (validation.ok ? w.won : w.checked);
|
|
142
|
+
help.textContent = w.help;
|
|
143
|
+
controls.replaceChildren();
|
|
144
|
+
button(w.undo, () => { game = undoRipple(game); changed(); }, !game.history.length);
|
|
145
|
+
button(w.check, () => { errors = [...checkRipple(current, game.values).errors]; message = checkRipple(current, game.values).ok ? w.won : w.wrong; render(); });
|
|
146
|
+
button(w.hint, () => { const hint = hintRipple(game); if (!hint) {
|
|
147
|
+
message = w.noHint;
|
|
148
|
+
render();
|
|
149
|
+
return;
|
|
150
|
+
} game = { ...setRippleValue(game, hint.cell, hint.value), helped: true }; selected = hint.cell; changed(); message = w.hintWhy; render(); });
|
|
151
|
+
button(pencil ? `${w.pencil} ✓` : w.pencil, () => { pencil = !pencil; render(); });
|
|
152
|
+
button(w.restart, restart);
|
|
153
|
+
button(w.view, expand);
|
|
154
|
+
};
|
|
155
|
+
function move(cell, direction) {
|
|
156
|
+
const dx = direction === "ArrowLeft" ? -1 : direction === "ArrowRight" ? 1 : 0;
|
|
157
|
+
const dy = direction === "ArrowUp" ? -1 : direction === "ArrowDown" ? 1 : 0;
|
|
158
|
+
const x = cell % game.board.width, y = Math.floor(cell / game.board.width);
|
|
159
|
+
const nx = Math.min(game.board.width - 1, Math.max(0, x + dx)), ny = Math.min(game.board.height - 1, Math.max(0, y + dy));
|
|
160
|
+
selected = ny * game.board.width + nx;
|
|
161
|
+
render();
|
|
162
|
+
board.querySelector(`[data-cell="${selected}"]`)?.focus();
|
|
163
|
+
}
|
|
164
|
+
function restart() { game = newRipple(game.board); selected = game.values.findIndex((value, cell) => game.board.clues[cell] === null); changed(); }
|
|
165
|
+
function expand() {
|
|
166
|
+
const dialog = doc.createElement("dialog"), close = doc.createElement("button");
|
|
167
|
+
dialog.className = "rp-dialog";
|
|
168
|
+
close.type = "button";
|
|
169
|
+
close.className = "rp-tool rp-close";
|
|
170
|
+
close.textContent = words().close;
|
|
171
|
+
close.onclick = () => dialog.close();
|
|
172
|
+
dialog.append(close, root);
|
|
173
|
+
host.append(dialog);
|
|
174
|
+
dialog.onclose = () => { host.append(root); dialog.remove(); };
|
|
175
|
+
dialog.showModal();
|
|
176
|
+
}
|
|
177
|
+
render();
|
|
178
|
+
return { game: copy, progress: () => encodeRipple(game), restart, set: (next) => { options = { ...options, ...next }; render(); }, destroy: () => { dead = true; root.remove(); root.closest("dialog")?.remove(); } };
|
|
179
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { RippleBoard, RippleGame, RippleLanguage, RippleMaterial, RipplePieces } from "./ripple.types.ts";
|
|
2
|
+
export type RippleDrawOptions = {
|
|
3
|
+
values?: readonly number[];
|
|
4
|
+
notes?: readonly (readonly number[])[];
|
|
5
|
+
errors?: readonly number[];
|
|
6
|
+
selected?: number | null;
|
|
7
|
+
material?: RippleMaterial;
|
|
8
|
+
pieces?: RipplePieces;
|
|
9
|
+
language?: RippleLanguage;
|
|
10
|
+
};
|
|
11
|
+
export type RippleMountOptions = RippleDrawOptions & {
|
|
12
|
+
board: RippleBoard;
|
|
13
|
+
progress?: string;
|
|
14
|
+
onChange?: (game: RippleGame) => void;
|
|
15
|
+
onFinish?: (game: RippleGame) => void;
|
|
16
|
+
};
|
|
17
|
+
export type RippleMount = {
|
|
18
|
+
game: () => RippleGame;
|
|
19
|
+
progress: () => string;
|
|
20
|
+
set: (options: RippleDrawOptions) => void;
|
|
21
|
+
restart: () => void;
|
|
22
|
+
destroy: () => void;
|
|
23
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { RIPPLE_MAX_NODES } from "./ripple.constants.js";
|
|
2
|
+
import { isRippleBoard, rippleRoomSizes } from "./rippleBoard.js";
|
|
3
|
+
/** Counts rule-valid fillings within an explicit node budget. */
|
|
4
|
+
export function solveRipple(board, options = {}) {
|
|
5
|
+
if (!isRippleBoard(board))
|
|
6
|
+
throw new RangeError("Invalid Ripple Effect board");
|
|
7
|
+
const limit = options.limit ?? 2, budget = options.nodes ?? RIPPLE_MAX_NODES;
|
|
8
|
+
if (!Number.isInteger(limit) || limit < 1 || !Number.isInteger(budget) || budget < 1)
|
|
9
|
+
throw new RangeError("Invalid search bounds");
|
|
10
|
+
const sizes = rippleRoomSizes(board);
|
|
11
|
+
const values = board.clues.map(clue => clue ?? 0);
|
|
12
|
+
const roomUsed = sizes.map(() => new Set());
|
|
13
|
+
let count = 0, nodes = 0, complete = true, solution = null;
|
|
14
|
+
for (let cell = 0; cell < values.length; cell += 1) {
|
|
15
|
+
const value = values[cell];
|
|
16
|
+
if (!value)
|
|
17
|
+
continue;
|
|
18
|
+
const room = board.rooms[cell];
|
|
19
|
+
if (roomUsed[room].has(value) || conflicts(cell, value, values, board)) {
|
|
20
|
+
return { count: 0, solution: null, complete: true, nodes: 0 };
|
|
21
|
+
}
|
|
22
|
+
roomUsed[room].add(value);
|
|
23
|
+
}
|
|
24
|
+
const visit = () => {
|
|
25
|
+
if (count >= limit || !complete) {
|
|
26
|
+
complete = false;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (++nodes > budget) {
|
|
30
|
+
complete = false;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
let bestCell = -1, bestValues = [];
|
|
34
|
+
for (let cell = 0; cell < values.length; cell += 1) {
|
|
35
|
+
if (values[cell])
|
|
36
|
+
continue;
|
|
37
|
+
const room = board.rooms[cell];
|
|
38
|
+
const candidates = [];
|
|
39
|
+
for (let value = 1; value <= sizes[room]; value += 1) {
|
|
40
|
+
if (!roomUsed[room].has(value) && !conflicts(cell, value, values, board))
|
|
41
|
+
candidates.push(value);
|
|
42
|
+
}
|
|
43
|
+
if (!candidates.length)
|
|
44
|
+
return;
|
|
45
|
+
if (bestCell < 0 || candidates.length < bestValues.length) {
|
|
46
|
+
bestCell = cell;
|
|
47
|
+
bestValues = candidates;
|
|
48
|
+
if (candidates.length === 1)
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (bestCell < 0) {
|
|
53
|
+
count += 1;
|
|
54
|
+
solution ?? (solution = [...values]);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const room = board.rooms[bestCell];
|
|
58
|
+
for (const value of bestValues) {
|
|
59
|
+
values[bestCell] = value;
|
|
60
|
+
roomUsed[room].add(value);
|
|
61
|
+
visit();
|
|
62
|
+
roomUsed[room].delete(value);
|
|
63
|
+
values[bestCell] = 0;
|
|
64
|
+
if (!complete || count >= limit) {
|
|
65
|
+
complete = false;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
visit();
|
|
71
|
+
return { count, solution, complete, nodes };
|
|
72
|
+
}
|
|
73
|
+
function conflicts(cell, value, values, board) {
|
|
74
|
+
const x = cell % board.width, y = Math.floor(cell / board.width);
|
|
75
|
+
for (let other = 0; other < values.length; other += 1) {
|
|
76
|
+
if (values[other] !== value || other === cell)
|
|
77
|
+
continue;
|
|
78
|
+
const ox = other % board.width, oy = Math.floor(other / board.width);
|
|
79
|
+
if ((oy === y && Math.abs(ox - x) <= value) || (ox === x && Math.abs(oy - y) <= value))
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|