@johnmorrisdotca/jirai 0.0.0-stage → 0.1.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 +9 -0
- package/LICENSE +21 -0
- package/README.md +135 -2
- package/dist/deduce.d.ts +8 -0
- package/dist/deduce.js +85 -0
- package/dist/draw-entry.d.ts +3 -0
- package/dist/draw-entry.js +2 -0
- package/dist/draw.d.ts +4 -0
- package/dist/draw.js +26 -0
- package/dist/element-define.d.ts +1 -0
- package/dist/element-define.js +3 -0
- package/dist/element.d.ts +14 -0
- package/dist/element.js +28 -0
- package/dist/enumerate.d.ts +6 -0
- package/dist/enumerate.js +83 -0
- package/dist/flood.d.ts +2 -0
- package/dist/flood.js +18 -0
- package/dist/game.d.ts +8 -0
- package/dist/game.js +61 -0
- package/dist/generate.d.ts +10 -0
- package/dist/generate.js +72 -0
- package/dist/grid.d.ts +7 -0
- package/dist/grid.js +46 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/jirai.constants.d.ts +61 -0
- package/dist/jirai.constants.js +23 -0
- package/dist/jirai.types.d.ts +66 -0
- package/dist/jirai.types.js +1 -0
- package/dist/keep.d.ts +7 -0
- package/dist/keep.js +40 -0
- package/dist/mount.d.ts +7 -0
- package/dist/mount.js +295 -0
- package/dist/play-entry.d.ts +3 -0
- package/dist/play-entry.js +2 -0
- package/dist/random.d.ts +4 -0
- package/dist/random.js +20 -0
- package/dist/react.d.ts +3 -0
- package/dist/react.js +17 -0
- package/dist/react.types.d.ts +3 -0
- package/dist/react.types.js +1 -0
- package/dist/shape.d.ts +5 -0
- package/dist/shape.js +33 -0
- package/dist/strings.d.ts +70 -0
- package/dist/strings.js +17 -0
- package/dist/style.d.ts +2 -0
- package/dist/style.js +22 -0
- package/dist/ui.types.d.ts +41 -0
- package/dist/ui.types.js +1 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +10 -0
- package/package.json +43 -4
- package/src/deduce.test.ts +47 -0
- package/src/deduce.ts +66 -0
- package/src/draw-entry.ts +3 -0
- package/src/draw.ts +28 -0
- package/src/element-define.ts +2 -0
- package/src/element.ts +24 -0
- package/src/enumerate.ts +60 -0
- package/src/flood.ts +12 -0
- package/src/game.test.ts +129 -0
- package/src/game.ts +55 -0
- package/src/generate.ts +57 -0
- package/src/grid.ts +42 -0
- package/src/index.ts +12 -0
- package/src/jirai.constants.ts +25 -0
- package/src/jirai.types.ts +56 -0
- package/src/keep.ts +34 -0
- package/src/mount.ts +176 -0
- package/src/play-entry.ts +3 -0
- package/src/random.ts +20 -0
- package/src/react.tsx +17 -0
- package/src/react.types.ts +3 -0
- package/src/shape.test.ts +56 -0
- package/src/shape.ts +32 -0
- package/src/strings.ts +18 -0
- package/src/style.ts +22 -0
- package/src/ui.types.ts +22 -0
- package/src/worker.ts +7 -0
package/dist/shape.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export const SHAPES = ["rectangle", "heart", "star", "hexagon"];
|
|
2
|
+
/** Row-major addresses stay stable; omitted cells have no clue and no neighbours. */
|
|
3
|
+
export function activeCell(settings, cell) {
|
|
4
|
+
if (!Number.isInteger(cell) || cell < 0 || cell >= settings.width * settings.height)
|
|
5
|
+
return false;
|
|
6
|
+
const col = cell % settings.width, row = Math.floor(cell / settings.width);
|
|
7
|
+
const x = 2 * (col + .5) / settings.width - 1, y = 2 * (row + .5) / settings.height - 1;
|
|
8
|
+
switch (settings.shape ?? "rectangle") {
|
|
9
|
+
case "heart": {
|
|
10
|
+
const hx = x * 1.15, hy = -y * 1.25 + .15;
|
|
11
|
+
return (hx * hx + hy * hy - 1) ** 3 - hx * hx * hy ** 3 <= 0;
|
|
12
|
+
}
|
|
13
|
+
case "star": {
|
|
14
|
+
// A filled five-point polygon, never a decorative overlay on hidden square cells.
|
|
15
|
+
const vertices = Array.from({ length: 10 }, (_, i) => {
|
|
16
|
+
const angle = -Math.PI / 2 + i * Math.PI / 5, radius = i % 2 ? .48 : 1;
|
|
17
|
+
return [Math.cos(angle) * radius, Math.sin(angle) * radius];
|
|
18
|
+
});
|
|
19
|
+
let inside = false;
|
|
20
|
+
for (let i = 0, j = 9; i < 10; j = i++) {
|
|
21
|
+
const [xi, yi] = vertices[i], [xj, yj] = vertices[j];
|
|
22
|
+
if ((yi > y) !== (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi)
|
|
23
|
+
inside = !inside;
|
|
24
|
+
}
|
|
25
|
+
return inside;
|
|
26
|
+
}
|
|
27
|
+
case "hexagon": return settings.grid === "hex" ? Math.abs(x + y) <= 1 : Math.abs(x) <= 1 - .5 * Math.abs(y);
|
|
28
|
+
default: return true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function activeCells(settings) {
|
|
32
|
+
return Array.from({ length: settings.width * settings.height }, (_, cell) => cell).filter(cell => activeCell(settings, cell));
|
|
33
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Language } from "./jirai.types.ts";
|
|
2
|
+
export declare const STRINGS: {
|
|
3
|
+
readonly en: {
|
|
4
|
+
readonly board: "Minesweeper board";
|
|
5
|
+
readonly covered: "covered";
|
|
6
|
+
readonly flag: "flagged";
|
|
7
|
+
readonly question: "uncertain";
|
|
8
|
+
readonly empty: "empty";
|
|
9
|
+
readonly mine: "mine";
|
|
10
|
+
readonly wrong: "incorrect flag";
|
|
11
|
+
readonly ready: "Open a cell to begin. Your first cell is safe.";
|
|
12
|
+
readonly playing: "Read the numbers. Open every safe cell.";
|
|
13
|
+
readonly won: "Every safe cell is open. Well played.";
|
|
14
|
+
readonly lost: "A mine. Try the same board again, or start another.";
|
|
15
|
+
readonly checking: "Finding a board that can be solved without guessing…";
|
|
16
|
+
readonly restart: "Start over";
|
|
17
|
+
readonly hint: "Hint";
|
|
18
|
+
readonly reveal: "Open";
|
|
19
|
+
readonly mark: "Mark";
|
|
20
|
+
readonly mines: "Mines left";
|
|
21
|
+
readonly time: "Time";
|
|
22
|
+
readonly moves: "Moves";
|
|
23
|
+
readonly clear: "Cleared";
|
|
24
|
+
readonly view: "Just the board";
|
|
25
|
+
readonly close: "Close";
|
|
26
|
+
readonly hintNone: "No certain move proved. This position may need a guess.";
|
|
27
|
+
readonly hintSafe: "This cell is safe.";
|
|
28
|
+
readonly hintMine: "This cell contains a mine.";
|
|
29
|
+
readonly count: "The neighbouring count settles it.";
|
|
30
|
+
readonly overlap: "Compare the overlapping clues.";
|
|
31
|
+
readonly total: "The total mine count settles it.";
|
|
32
|
+
readonly enumeration: "Every consistent arrangement gives the same answer.";
|
|
33
|
+
readonly help: "Tap to open; right-click or hold to mark. Use the arrows to move, Enter to open, F or Space to mark. Tap an open number to clear its neighbours when the flags match.";
|
|
34
|
+
readonly error: "A verified board was not found. Try another seed, an opening elsewhere, or fewer mines.";
|
|
35
|
+
};
|
|
36
|
+
readonly ja: {
|
|
37
|
+
readonly board: "地雷の盤";
|
|
38
|
+
readonly covered: "未開封";
|
|
39
|
+
readonly flag: "旗";
|
|
40
|
+
readonly question: "不明";
|
|
41
|
+
readonly empty: "空き";
|
|
42
|
+
readonly mine: "地雷";
|
|
43
|
+
readonly wrong: "間違った旗";
|
|
44
|
+
readonly ready: "マスを開けて始めます。最初のマスは安全です。";
|
|
45
|
+
readonly playing: "数字を手がかりに、安全なマスをすべて開けましょう。";
|
|
46
|
+
readonly won: "安全なマスがすべて開きました。クリア!";
|
|
47
|
+
readonly lost: "地雷でした。同じ盤で再挑戦できます。";
|
|
48
|
+
readonly checking: "推測なしで解ける盤を探しています…";
|
|
49
|
+
readonly restart: "やり直す";
|
|
50
|
+
readonly hint: "ヒント";
|
|
51
|
+
readonly reveal: "開く";
|
|
52
|
+
readonly mark: "印を付ける";
|
|
53
|
+
readonly mines: "残りの地雷";
|
|
54
|
+
readonly time: "時間";
|
|
55
|
+
readonly moves: "手数";
|
|
56
|
+
readonly clear: "開いたマス";
|
|
57
|
+
readonly view: "盤だけ";
|
|
58
|
+
readonly close: "閉じる";
|
|
59
|
+
readonly hintNone: "確実な手は見つかりませんでした。推測が必要かもしれません。";
|
|
60
|
+
readonly hintSafe: "このマスは安全です。";
|
|
61
|
+
readonly hintMine: "このマスには地雷があります。";
|
|
62
|
+
readonly count: "周囲の数字から確定できます。";
|
|
63
|
+
readonly overlap: "重なった手がかりを比べましょう。";
|
|
64
|
+
readonly total: "地雷の総数から確定できます。";
|
|
65
|
+
readonly enumeration: "可能な配置すべてで同じ答えになります。";
|
|
66
|
+
readonly help: "タップで開く、右クリックか長押しで印を付けます。矢印キーで移動、Enterで開く、FかSpaceで印を付けます。旗の数が合う数字をタップすると周囲が開きます。";
|
|
67
|
+
readonly error: "推測なしで解ける盤が見つかりませんでした。別のシード、別の開始マス、または少ない地雷で試してください。";
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
export declare function words(language?: Language): typeof STRINGS["en"] | typeof STRINGS["ja"];
|
package/dist/strings.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const STRINGS = {
|
|
2
|
+
en: {
|
|
3
|
+
board: "Minesweeper board", covered: "covered", flag: "flagged", question: "uncertain", empty: "empty", mine: "mine", wrong: "incorrect flag",
|
|
4
|
+
ready: "Open a cell to begin. Your first cell is safe.", playing: "Read the numbers. Open every safe cell.", won: "Every safe cell is open. Well played.", lost: "A mine. Try the same board again, or start another.",
|
|
5
|
+
checking: "Finding a board that can be solved without guessing…", restart: "Start over", hint: "Hint", reveal: "Open", mark: "Mark", mines: "Mines left", time: "Time", moves: "Moves", clear: "Cleared", view: "Just the board", close: "Close", hintNone: "No certain move proved. This position may need a guess.", hintSafe: "This cell is safe.", hintMine: "This cell contains a mine.", count: "The neighbouring count settles it.", overlap: "Compare the overlapping clues.", total: "The total mine count settles it.", enumeration: "Every consistent arrangement gives the same answer.",
|
|
6
|
+
help: "Tap to open; right-click or hold to mark. Use the arrows to move, Enter to open, F or Space to mark. Tap an open number to clear its neighbours when the flags match.",
|
|
7
|
+
error: "A verified board was not found. Try another seed, an opening elsewhere, or fewer mines.",
|
|
8
|
+
},
|
|
9
|
+
ja: {
|
|
10
|
+
board: "地雷の盤", covered: "未開封", flag: "旗", question: "不明", empty: "空き", mine: "地雷", wrong: "間違った旗",
|
|
11
|
+
ready: "マスを開けて始めます。最初のマスは安全です。", playing: "数字を手がかりに、安全なマスをすべて開けましょう。", won: "安全なマスがすべて開きました。クリア!", lost: "地雷でした。同じ盤で再挑戦できます。",
|
|
12
|
+
checking: "推測なしで解ける盤を探しています…", restart: "やり直す", hint: "ヒント", reveal: "開く", mark: "印を付ける", mines: "残りの地雷", time: "時間", moves: "手数", clear: "開いたマス", view: "盤だけ", close: "閉じる", hintNone: "確実な手は見つかりませんでした。推測が必要かもしれません。", hintSafe: "このマスは安全です。", hintMine: "このマスには地雷があります。", count: "周囲の数字から確定できます。", overlap: "重なった手がかりを比べましょう。", total: "地雷の総数から確定できます。", enumeration: "可能な配置すべてで同じ答えになります。",
|
|
13
|
+
help: "タップで開く、右クリックか長押しで印を付けます。矢印キーで移動、Enterで開く、FかSpaceで印を付けます。旗の数が合う数字をタップすると周囲が開きます。",
|
|
14
|
+
error: "推測なしで解ける盤が見つかりませんでした。別のシード、別の開始マス、または少ない地雷で試してください。",
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
export function words(language = "en") { return STRINGS[language]; }
|
package/dist/style.d.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/** Scoped to one mounted board. Its host supplies the font and may override every colour. */
|
|
2
|
+
export declare const JIRAI_STYLE = "\n.jr-root{--jr-ground:#a98954;--jr-cover:#fbf8f1;--jr-open:#efe8d8;--jr-line:#cfc6b2;--jr-ink:#1f2320;--jr-accent:#2f7a4f;color:var(--jr-ui-ink,var(--jr-ink));font:inherit;position:relative}\n.jr-root[data-material=wood]{--jr-ground:#ae804a;--jr-cover:#e0bb7e;--jr-open:#c69d63;--jr-line:#936e40;--jr-ink:#352c20;background-image:repeating-linear-gradient(4deg,transparent 0 8px,#4b2b0c08 9px 10px)}\n.jr-root[data-material=slate]{--jr-ground:#252e32;--jr-cover:#455359;--jr-open:#303c41;--jr-line:#62747a;--jr-ink:#f3f0e5;--jr-accent:#b7d298}\n.jr-scroll{overflow:auto;padding:12px;border:1px solid var(--jr-line);border-radius:14px;background:var(--jr-ground);overscroll-behavior:contain}\n.jr-board{position:relative;width:100%;min-width:240px;margin:auto;isolation:isolate}\n.jr-cell{position:absolute;box-sizing:border-box;display:flex;align-items:center;justify-content:center;border:1px solid var(--jr-line);border-radius:4px;background:var(--jr-cover);color:var(--jr-ink);font:700 clamp(12px,2vw,22px)/1 ui-monospace,monospace;box-shadow:inset 0 2px 1px #fff7,inset 0 -2px 1px #0001;cursor:pointer;user-select:none;-webkit-user-select:none;touch-action:manipulation;padding:0}\n.jr-root[data-grid=hex] .jr-cell{clip-path:polygon(50% 0,100% 25%,100% 75%,50% 100%,0 75%,0 25%);border-radius:0}\n.jr-cell[data-kind=open]{background:var(--jr-open);box-shadow:none}\n.jr-cell[data-number=\"1\"]{color:#326ca0}.jr-cell[data-number=\"2\"]{color:#3d763c}.jr-cell[data-number=\"3\"]{color:#a34438}.jr-cell[data-number=\"4\"]{color:#704b97}.jr-cell[data-number=\"5\"]{color:#8c542f}.jr-cell[data-number=\"6\"]{color:#1c777b}\n.jr-root[data-material=slate] .jr-cell[data-kind=open]{color:#e8e6bd}\n.jr-cell[data-kind=flag]{color:var(--jr-accent)}.jr-cell[data-kind=question]{color:#8a702d}\n.jr-cell[data-kind=mine],.jr-cell[data-kind=wrong]{background:#ead4c5;color:#873e32}.jr-cell[data-kind=exploded]{background:#a34d3c;color:white}\n.jr-cell[data-hint=true]{background:#d4e5bc!important;color:#243d24!important;box-shadow:inset 0 0 0 3px #577b3f}\n.jr-cell:focus-visible{outline:none;box-shadow:inset 0 0 0 3px #c0802e;z-index:2}.jr-cell:hover{filter:brightness(.97)}\n.jr-stats{display:flex;gap:18px;flex-wrap:wrap;font-variant-numeric:tabular-nums;margin:0 0 14px}.jr-stat{display:flex;gap:7px;align-items:baseline}.jr-stat small{opacity:.7;font-size:12px}.jr-stat strong{font-size:20px;font-weight:500}\n.jr-status{font-size:14px;line-height:1.5;min-height:1.5em;margin:12px 0}.jr-help{font-size:12px;line-height:1.65;opacity:.7}\n.jr-controls{display:flex;flex-wrap:wrap;gap:8px}.jr-button{font:inherit;font-size:13px;border:1px solid var(--jr-line);border-radius:999px;min-height:44px;padding:0 14px;background:var(--jr-ui-surface,var(--jr-cover));color:var(--jr-ui-ink,var(--jr-ink));border-color:var(--jr-ui-line,var(--jr-line));cursor:pointer}.jr-button:disabled{opacity:.5;cursor:wait}.jr-button[aria-pressed=true]{background:var(--jr-accent);color:white}.jr-button:focus-visible{outline:2px solid var(--jr-accent);outline-offset:3px}\n.jr-dialog{border:0;border-radius:20px;padding:24px;max-width:min(96vw,1000px);width:850px;background:var(--surface,#fbf8f1);color:var(--ink,#1f2320);--jr-ui-ink:var(--ink,#1f2320);--jr-ui-surface:var(--surface,#fbf8f1);--jr-ui-line:var(--rule,#ddd6c6);box-sizing:border-box}.jr-dialog::backdrop{background:#172019bb}.jr-dialog .jr-board{max-height:75vh}.jr-close{float:right;margin-bottom:10px}\n@media(prefers-reduced-motion:no-preference){.jr-cell{transition:background .12s}}\n";
|
package/dist/style.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Scoped to one mounted board. Its host supplies the font and may override every colour. */
|
|
2
|
+
export const JIRAI_STYLE = `
|
|
3
|
+
.jr-root{--jr-ground:#a98954;--jr-cover:#fbf8f1;--jr-open:#efe8d8;--jr-line:#cfc6b2;--jr-ink:#1f2320;--jr-accent:#2f7a4f;color:var(--jr-ui-ink,var(--jr-ink));font:inherit;position:relative}
|
|
4
|
+
.jr-root[data-material=wood]{--jr-ground:#ae804a;--jr-cover:#e0bb7e;--jr-open:#c69d63;--jr-line:#936e40;--jr-ink:#352c20;background-image:repeating-linear-gradient(4deg,transparent 0 8px,#4b2b0c08 9px 10px)}
|
|
5
|
+
.jr-root[data-material=slate]{--jr-ground:#252e32;--jr-cover:#455359;--jr-open:#303c41;--jr-line:#62747a;--jr-ink:#f3f0e5;--jr-accent:#b7d298}
|
|
6
|
+
.jr-scroll{overflow:auto;padding:12px;border:1px solid var(--jr-line);border-radius:14px;background:var(--jr-ground);overscroll-behavior:contain}
|
|
7
|
+
.jr-board{position:relative;width:100%;min-width:240px;margin:auto;isolation:isolate}
|
|
8
|
+
.jr-cell{position:absolute;box-sizing:border-box;display:flex;align-items:center;justify-content:center;border:1px solid var(--jr-line);border-radius:4px;background:var(--jr-cover);color:var(--jr-ink);font:700 clamp(12px,2vw,22px)/1 ui-monospace,monospace;box-shadow:inset 0 2px 1px #fff7,inset 0 -2px 1px #0001;cursor:pointer;user-select:none;-webkit-user-select:none;touch-action:manipulation;padding:0}
|
|
9
|
+
.jr-root[data-grid=hex] .jr-cell{clip-path:polygon(50% 0,100% 25%,100% 75%,50% 100%,0 75%,0 25%);border-radius:0}
|
|
10
|
+
.jr-cell[data-kind=open]{background:var(--jr-open);box-shadow:none}
|
|
11
|
+
.jr-cell[data-number="1"]{color:#326ca0}.jr-cell[data-number="2"]{color:#3d763c}.jr-cell[data-number="3"]{color:#a34438}.jr-cell[data-number="4"]{color:#704b97}.jr-cell[data-number="5"]{color:#8c542f}.jr-cell[data-number="6"]{color:#1c777b}
|
|
12
|
+
.jr-root[data-material=slate] .jr-cell[data-kind=open]{color:#e8e6bd}
|
|
13
|
+
.jr-cell[data-kind=flag]{color:var(--jr-accent)}.jr-cell[data-kind=question]{color:#8a702d}
|
|
14
|
+
.jr-cell[data-kind=mine],.jr-cell[data-kind=wrong]{background:#ead4c5;color:#873e32}.jr-cell[data-kind=exploded]{background:#a34d3c;color:white}
|
|
15
|
+
.jr-cell[data-hint=true]{background:#d4e5bc!important;color:#243d24!important;box-shadow:inset 0 0 0 3px #577b3f}
|
|
16
|
+
.jr-cell:focus-visible{outline:none;box-shadow:inset 0 0 0 3px #c0802e;z-index:2}.jr-cell:hover{filter:brightness(.97)}
|
|
17
|
+
.jr-stats{display:flex;gap:18px;flex-wrap:wrap;font-variant-numeric:tabular-nums;margin:0 0 14px}.jr-stat{display:flex;gap:7px;align-items:baseline}.jr-stat small{opacity:.7;font-size:12px}.jr-stat strong{font-size:20px;font-weight:500}
|
|
18
|
+
.jr-status{font-size:14px;line-height:1.5;min-height:1.5em;margin:12px 0}.jr-help{font-size:12px;line-height:1.65;opacity:.7}
|
|
19
|
+
.jr-controls{display:flex;flex-wrap:wrap;gap:8px}.jr-button{font:inherit;font-size:13px;border:1px solid var(--jr-line);border-radius:999px;min-height:44px;padding:0 14px;background:var(--jr-ui-surface,var(--jr-cover));color:var(--jr-ui-ink,var(--jr-ink));border-color:var(--jr-ui-line,var(--jr-line));cursor:pointer}.jr-button:disabled{opacity:.5;cursor:wait}.jr-button[aria-pressed=true]{background:var(--jr-accent);color:white}.jr-button:focus-visible{outline:2px solid var(--jr-accent);outline-offset:3px}
|
|
20
|
+
.jr-dialog{border:0;border-radius:20px;padding:24px;max-width:min(96vw,1000px);width:850px;background:var(--surface,#fbf8f1);color:var(--ink,#1f2320);--jr-ui-ink:var(--ink,#1f2320);--jr-ui-surface:var(--surface,#fbf8f1);--jr-ui-line:var(--rule,#ddd6c6);box-sizing:border-box}.jr-dialog::backdrop{background:#172019bb}.jr-dialog .jr-board{max-height:75vh}.jr-close{float:right;margin-bottom:10px}
|
|
21
|
+
@media(prefers-reduced-motion:no-preference){.jr-cell{transition:background .12s}}
|
|
22
|
+
`;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Game, Language, Material, Pieces, Settings } from "./jirai.types.ts";
|
|
2
|
+
export type DrawOptions = {
|
|
3
|
+
material?: Material;
|
|
4
|
+
pieces?: Pieces;
|
|
5
|
+
language?: Language;
|
|
6
|
+
hint?: number | null;
|
|
7
|
+
};
|
|
8
|
+
export type CellModel = {
|
|
9
|
+
cell: number;
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
label: string;
|
|
15
|
+
text: string;
|
|
16
|
+
kind: string;
|
|
17
|
+
hint: boolean;
|
|
18
|
+
};
|
|
19
|
+
export type BoardModel = {
|
|
20
|
+
width: number;
|
|
21
|
+
height: number;
|
|
22
|
+
cells: CellModel[];
|
|
23
|
+
};
|
|
24
|
+
export type MountOptions = DrawOptions & {
|
|
25
|
+
settings?: Settings;
|
|
26
|
+
progress?: string;
|
|
27
|
+
controls?: boolean;
|
|
28
|
+
onChange?: (game: Game) => void;
|
|
29
|
+
onFinish?: (game: Game) => void;
|
|
30
|
+
onError?: (error: Error) => void;
|
|
31
|
+
};
|
|
32
|
+
export type JiraiMount = {
|
|
33
|
+
game: () => Game;
|
|
34
|
+
progress: () => string;
|
|
35
|
+
play: (cell: number, mark?: boolean) => void;
|
|
36
|
+
hint: () => void;
|
|
37
|
+
restart: () => void;
|
|
38
|
+
load: (settings: Settings, progress?: string) => void;
|
|
39
|
+
set: (options: DrawOptions) => void;
|
|
40
|
+
destroy: () => void;
|
|
41
|
+
};
|
package/dist/ui.types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/worker.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/worker.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { makeBoard } from "./generate.js";
|
|
2
|
+
// A separate entry: importing the engine or renderer never creates a worker.
|
|
3
|
+
self.addEventListener("message", (event) => {
|
|
4
|
+
try {
|
|
5
|
+
self.postMessage({ board: makeBoard(event.data.settings, event.data.first) });
|
|
6
|
+
}
|
|
7
|
+
catch (error) {
|
|
8
|
+
self.postMessage({ error: error instanceof Error ? error.message : String(error) });
|
|
9
|
+
}
|
|
10
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnmorrisdotca/jirai",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Minesweeper on square, hexagonal and wraparound boards: seeded games, a safe opening, verified no-guess boards, explained hints, configurable materials and markers, and a playable board for any page. Zero runtime dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "John Morris",
|
|
8
|
+
"repository": { "type": "git", "url": "git+https://github.com/johnmorrisdotca/jirai.git" },
|
|
9
|
+
"homepage": "https://johnmorrisdotca.github.io/jirai/",
|
|
10
|
+
"bugs": { "url": "https://github.com/johnmorrisdotca/jirai/issues" },
|
|
11
|
+
"keywords": ["minesweeper", "puzzle", "hexagonal", "no-guess", "typescript", "seeded", "web-component", "itsutsu"],
|
|
12
|
+
"main": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
|
16
|
+
"./play": { "types": "./dist/play-entry.d.ts", "default": "./dist/play-entry.js" },
|
|
17
|
+
"./draw": { "types": "./dist/draw-entry.d.ts", "default": "./dist/draw-entry.js" },
|
|
18
|
+
"./element": { "types": "./dist/element.d.ts", "default": "./dist/element.js" },
|
|
19
|
+
"./element/define": { "types": "./dist/element-define.d.ts", "default": "./dist/element-define.js" },
|
|
20
|
+
"./react": { "types": "./dist/react.d.ts", "default": "./dist/react.js" }
|
|
21
|
+
},
|
|
22
|
+
"sideEffects": ["./dist/element-define.js"],
|
|
23
|
+
"files": ["dist", "src", "README.md", "LICENSE", "CHANGELOG.md"],
|
|
24
|
+
"engines": { "node": ">=22" },
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc -p tsconfig.json",
|
|
27
|
+
"typecheck": "tsc -p tsconfig.check.json",
|
|
28
|
+
"lint": "eslint .",
|
|
29
|
+
"test": "vitest run",
|
|
30
|
+
"check": "pnpm lint && pnpm typecheck && pnpm test",
|
|
31
|
+
"site": "pnpm build && node scripts/site.mjs",
|
|
32
|
+
"test:demo": "pnpm site && playwright test",
|
|
33
|
+
"test:package": "pnpm build && node scripts/check-package.mjs",
|
|
34
|
+
"prepublishOnly": "pnpm check && pnpm test:package"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": { "react": ">=18" },
|
|
37
|
+
"peerDependenciesMeta": { "react": { "optional": true } },
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@eslint/js": "^9.0.0", "eslint": "^9.0.0", "typescript-eslint": "^8.0.0",
|
|
40
|
+
"typescript": "^5.9.0", "vitest": "^4.0.0", "@playwright/test": "^1.58.0",
|
|
41
|
+
"@types/react": "^19.0.0", "react": "^19.0.0"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": { "access": "public" },
|
|
44
|
+
"packageManager": "pnpm@10.11.0"
|
|
45
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { expect, it } from "vitest";
|
|
2
|
+
import { deduce, hintFor } from "./deduce.ts";
|
|
3
|
+
import { enumerateForced } from "./enumerate.ts";
|
|
4
|
+
import { DEFAULT_SETTINGS, MARKS, STATUSES } from "./jirai.constants.ts";
|
|
5
|
+
import { neighbours } from "./grid.ts";
|
|
6
|
+
import type { VisibleGame, Grid } from "./jirai.types.ts";
|
|
7
|
+
|
|
8
|
+
it("extracts the 1–2–1 wall pattern without trusting flags", () => {
|
|
9
|
+
const game: VisibleGame = {
|
|
10
|
+
settings: { ...DEFAULT_SETTINGS, width: 3, height: 3, mines: 2 }, status: STATUSES.playing,
|
|
11
|
+
clues: [null,null,null,1,2,1,0,0,0], marks: [MARKS.flag,MARKS.flag,MARKS.covered,...Array(6).fill(MARKS.open)],
|
|
12
|
+
};
|
|
13
|
+
const hint = hintFor(game);
|
|
14
|
+
expect(hint.safe).toContain(1); // A wrongly flagged square is still provably safe.
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("enumeration proves only cells forced in every consistent assignment", () => {
|
|
18
|
+
const proof = enumerateForced([
|
|
19
|
+
{ cells: [0,1], mines: 1, sources: [3] },
|
|
20
|
+
{ cells: [1,2], mines: 1, sources: [4] },
|
|
21
|
+
{ cells: [0,2], mines: 0, sources: [5] },
|
|
22
|
+
]);
|
|
23
|
+
expect([...proof.safe].sort()).toEqual([0,2]); expect(proof.mines).toEqual([1]);
|
|
24
|
+
expect(enumerateForced([{ cells: [0,1], mines: 1, sources: [] }]).safe).toEqual([]);
|
|
25
|
+
expect(enumerateForced([{ cells: [0], mines: 2, sources: [] }]).contradiction).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("agrees with an independent exhaustive oracle on square, hex and wrapped 3×3 positions", () => {
|
|
29
|
+
for (const grid of ["square", "hex", "wrap"] as Grid[]) {
|
|
30
|
+
const settings = { ...DEFAULT_SETTINGS, width: 3, height: 3, mines: 2, grid };
|
|
31
|
+
for (let firstMine = 0; firstMine < 9; firstMine += 1) for (let secondMine = firstMine + 1; secondMine < 9; secondMine += 1) {
|
|
32
|
+
const actual = [firstMine, secondMine];
|
|
33
|
+
const clues = Array.from({ length: 9 }, (_, cell) => !actual.includes(cell) && cell % 2 === 0 ? neighbours(settings, cell).filter((n) => actual.includes(n)).length : null);
|
|
34
|
+
const possible: number[][] = [];
|
|
35
|
+
for (let a = 0; a < 9; a += 1) for (let b = a + 1; b < 9; b += 1) {
|
|
36
|
+
const mines = [a,b];
|
|
37
|
+
if (mines.some((cell) => clues[cell] !== null)) continue;
|
|
38
|
+
if (clues.every((clue, cell) => clue === null || neighbours(settings, cell).filter((n) => mines.includes(n)).length === clue)) possible.push(mines);
|
|
39
|
+
}
|
|
40
|
+
const game: VisibleGame = { settings, clues, status: STATUSES.playing, marks: clues.map((n) => n === null ? MARKS.flag : MARKS.open) };
|
|
41
|
+
const result = deduce(game);
|
|
42
|
+
expect(result.contradiction).toBe(false); expect(possible.length).toBeGreaterThan(0);
|
|
43
|
+
for (const cell of result.safe) expect(possible.every((mines) => !mines.includes(cell))).toBe(true);
|
|
44
|
+
for (const cell of result.mines) expect(possible.every((mines) => mines.includes(cell))).toBe(true);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
});
|
package/src/deduce.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { enumerateForced } from "./enumerate.ts";
|
|
2
|
+
import { neighboursOf } from "./grid.ts";
|
|
3
|
+
import type { Constraint, Deduction, VisibleGame } from "./jirai.types.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What the clues prove, without reading the answer or trusting a player's
|
|
7
|
+
* flags. A flag is a note; treating it as evidence makes a bad note a bad hint.
|
|
8
|
+
*/
|
|
9
|
+
export function deduce(game: VisibleGame, knownMines: ReadonlySet<number> = new Set(), enumerate = true): Deduction {
|
|
10
|
+
const adjacent = neighboursOf(game.settings);
|
|
11
|
+
const constraints: Constraint[] = [];
|
|
12
|
+
const empty: Deduction = { safe: [], mines: [], reason: "none", sources: [], contradiction: false };
|
|
13
|
+
for (let cell = 0; cell < game.clues.length; cell += 1) {
|
|
14
|
+
const clue = game.clues[cell];
|
|
15
|
+
if (clue === null || clue === undefined || clue < 0) continue;
|
|
16
|
+
const unknown = adjacent[cell]!.filter((n) => game.clues[n] === null && !knownMines.has(n));
|
|
17
|
+
const mines = clue - adjacent[cell]!.filter((n) => knownMines.has(n)).length;
|
|
18
|
+
if (mines < 0 || mines > unknown.length) return { ...empty, contradiction: true };
|
|
19
|
+
if (unknown.length) constraints.push({ cells: unknown, mines, sources: [cell] });
|
|
20
|
+
}
|
|
21
|
+
const unknown = game.clues.flatMap((clue, cell) => clue === null && !knownMines.has(cell) ? [cell] : []);
|
|
22
|
+
const left = game.settings.mines - knownMines.size;
|
|
23
|
+
if (left < 0 || left > unknown.length) return { ...empty, contradiction: true };
|
|
24
|
+
const total: Constraint = { cells: unknown, mines: left, sources: [] };
|
|
25
|
+
function forced(c: Constraint, reason: Deduction["reason"]): Deduction | null {
|
|
26
|
+
if (c.mines === 0) return { ...empty, safe: c.cells, reason, sources: c.sources };
|
|
27
|
+
if (c.mines === c.cells.length) return { ...empty, mines: c.cells, reason, sources: c.sources };
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
for (const c of constraints) { const result = forced(c, "count"); if (result !== null) return result; }
|
|
31
|
+
if (unknown.length) { const result = forced(total, "total"); if (result !== null) return result; }
|
|
32
|
+
// Compare a smaller clue with a larger one, including the mine counter.
|
|
33
|
+
// Their difference is another exact count. Never subtract mere overlaps.
|
|
34
|
+
const sets = [...constraints, total].map((c) => new Set(c.cells));
|
|
35
|
+
const all = [...constraints, total];
|
|
36
|
+
for (let i = 0; i < all.length; i += 1) for (let j = 0; j < all.length; j += 1) {
|
|
37
|
+
if (i === j || all[i]!.cells.length >= all[j]!.cells.length) continue;
|
|
38
|
+
if (!all[i]!.cells.every((cell) => sets[j]!.has(cell))) continue;
|
|
39
|
+
const c: Constraint = {
|
|
40
|
+
cells: all[j]!.cells.filter((cell) => !sets[i]!.has(cell)),
|
|
41
|
+
mines: all[j]!.mines - all[i]!.mines,
|
|
42
|
+
sources: [...new Set([...all[i]!.sources, ...all[j]!.sources])],
|
|
43
|
+
};
|
|
44
|
+
if (c.mines < 0 || c.mines > c.cells.length) return { ...empty, contradiction: true };
|
|
45
|
+
const result = forced(c, j === all.length - 1 ? "total" : "overlap");
|
|
46
|
+
if (result !== null) return result;
|
|
47
|
+
}
|
|
48
|
+
// A whole-board constraint would join otherwise independent components.
|
|
49
|
+
// Local enumeration is conservative; the global counter is used above.
|
|
50
|
+
return enumerate ? enumerateForced(constraints) : empty;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Keep discovering certain mines until there is a safe cell or nothing more is proved. */
|
|
54
|
+
export function hintFor(game: VisibleGame): Deduction {
|
|
55
|
+
const known = new Set<number>();
|
|
56
|
+
let last: Deduction = { safe: [], mines: [], reason: "none", sources: [], contradiction: false };
|
|
57
|
+
while (known.size <= game.settings.mines) {
|
|
58
|
+
const result = deduce(game, known);
|
|
59
|
+
if (result.contradiction || result.safe.length) return result;
|
|
60
|
+
const fresh = result.mines.filter((cell) => !known.has(cell));
|
|
61
|
+
if (!fresh.length) return last;
|
|
62
|
+
last = result;
|
|
63
|
+
for (const cell of fresh) known.add(cell);
|
|
64
|
+
}
|
|
65
|
+
return last;
|
|
66
|
+
}
|
package/src/draw.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { activeCell } from "./shape.ts";
|
|
2
|
+
import { GRIDS, MARKS, STATUSES } from "./jirai.constants.ts";
|
|
3
|
+
import { words } from "./strings.ts";
|
|
4
|
+
import type { Game } from "./jirai.types.ts";
|
|
5
|
+
import type { BoardModel, DrawOptions } from "./ui.types.ts";
|
|
6
|
+
|
|
7
|
+
/** Where the cells sit, and what may be shown. The drawing never exposes a covered clue. */
|
|
8
|
+
export function boardModel(game: Game, options: DrawOptions = {}): BoardModel {
|
|
9
|
+
const hex = game.settings.grid === GRIDS.hex;
|
|
10
|
+
const ended = game.status === STATUSES.lost || game.status === STATUSES.won;
|
|
11
|
+
const say = words(options.language);
|
|
12
|
+
const width = hex ? game.settings.width + (game.settings.height - 1) / 2 : game.settings.width;
|
|
13
|
+
const height = hex ? (game.settings.height - 1) * .866 + 1.155 : game.settings.height;
|
|
14
|
+
const cells = game.marks.flatMap((mark, cell) => {
|
|
15
|
+
if (!activeCell(game.settings, cell)) return [];
|
|
16
|
+
const col = cell % game.settings.width, row = Math.floor(cell / game.settings.width);
|
|
17
|
+
const mine = ended && game.board?.mines[cell] === true;
|
|
18
|
+
const wrong = ended && mark === MARKS.flag && !mine;
|
|
19
|
+
const clue = mark === MARKS.open ? game.board?.clues[cell] ?? 0 : 0;
|
|
20
|
+
const kind = cell === game.exploded ? "exploded" : mine ? "mine" : wrong ? "wrong" : mark;
|
|
21
|
+
const text = mine ? "✹" : wrong ? "×" : mark === MARKS.flag ? options.pieces === "stones" ? "●" : options.pieces === "flowers" ? "✿" : "⚑"
|
|
22
|
+
: mark === MARKS.question ? "?" : mark === MARKS.open && clue > 0 ? String(clue) : "";
|
|
23
|
+
const description = mine ? say.mine : wrong ? say.wrong : mark === MARKS.open ? clue === 0 ? say.empty : `${clue}` : say[mark];
|
|
24
|
+
return [{ cell, x: col + (hex ? row / 2 : 0), y: row * (hex ? .866 : 1), width: 1, height: hex ? 1.155 : 1,
|
|
25
|
+
label: `${row + 1}, ${col + 1}: ${description}`, text, kind, hint: options.hint === cell }];
|
|
26
|
+
});
|
|
27
|
+
return { width, height, cells };
|
|
28
|
+
}
|
package/src/element.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { DEFAULT_SETTINGS } from "./jirai.constants.ts";
|
|
2
|
+
import { mountJirai } from "./mount.ts";
|
|
3
|
+
import type { Grid, Language, Material, Pieces, Settings } from "./jirai.types.ts";
|
|
4
|
+
import type { JiraiMount } from "./ui.types.ts";
|
|
5
|
+
|
|
6
|
+
// Importing this file on a server is harmless; only defineJirai registers the tag.
|
|
7
|
+
const ElementBase = typeof HTMLElement === "undefined" ? class {} as typeof HTMLElement : HTMLElement;
|
|
8
|
+
export class JiraiElement extends ElementBase {
|
|
9
|
+
private mounted: JiraiMount | null = null;
|
|
10
|
+
static observedAttributes = ["width", "height", "mines", "seed", "grid", "shape", "no-guess", "material", "pieces", "lang"];
|
|
11
|
+
connectedCallback(): void { this.mount(); }
|
|
12
|
+
disconnectedCallback(): void { this.mounted?.destroy(); this.mounted = null; }
|
|
13
|
+
attributeChangedCallback(): void { if (this.isConnected) this.mount(); }
|
|
14
|
+
private mount(): void {
|
|
15
|
+
this.mounted?.destroy(); this.mounted = null;
|
|
16
|
+
try {
|
|
17
|
+
this.mounted = mountJirai(this, {
|
|
18
|
+
settings: { ...DEFAULT_SETTINGS, width: Number(this.getAttribute("width") ?? DEFAULT_SETTINGS.width), height: Number(this.getAttribute("height") ?? DEFAULT_SETTINGS.height), mines: Number(this.getAttribute("mines") ?? DEFAULT_SETTINGS.mines), seed: Number(this.getAttribute("seed") ?? DEFAULT_SETTINGS.seed), shape: (this.getAttribute("shape") ?? "rectangle") as Settings["shape"], grid: (this.getAttribute("grid") ?? "square") as Grid, noGuess: this.getAttribute("no-guess") !== "false" },
|
|
19
|
+
material: (this.getAttribute("material") ?? "ivory") as Material, pieces: (this.getAttribute("pieces") ?? "flags") as Pieces, language: (this.getAttribute("lang") ?? "en") as Language,
|
|
20
|
+
});
|
|
21
|
+
} catch (error) { this.dispatchEvent(new CustomEvent("jirai-error", { detail: error })); }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function defineJirai(registry: CustomElementRegistry = customElements): void { if (!registry.get("jirai-board")) registry.define("jirai-board", JiraiElement); }
|
package/src/enumerate.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ENUMERATION_CELLS, ENUMERATION_NODES } from "./jirai.constants.ts";
|
|
2
|
+
import type { Constraint, Deduction } from "./jirai.types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Exact assignments within a small connected frontier. If the work budget is
|
|
6
|
+
* reached, none of the partial search is evidence: it returns no deductions.
|
|
7
|
+
*/
|
|
8
|
+
export function enumerateForced(constraints: readonly Constraint[]): Deduction {
|
|
9
|
+
const empty: Deduction = { safe: [], mines: [], reason: "none", sources: [], contradiction: false };
|
|
10
|
+
const remaining = new Set(constraints.flatMap((c) => [...c.cells]));
|
|
11
|
+
const safe: number[] = [], mines: number[] = [], sources = new Set<number>();
|
|
12
|
+
while (remaining.size > 0) {
|
|
13
|
+
const component = new Set<number>([remaining.values().next().value!]);
|
|
14
|
+
let grew = true;
|
|
15
|
+
while (grew) {
|
|
16
|
+
grew = false;
|
|
17
|
+
for (const c of constraints) {
|
|
18
|
+
if (!c.cells.some((cell) => component.has(cell))) continue;
|
|
19
|
+
for (const cell of c.cells) if (!component.has(cell)) { component.add(cell); grew = true; }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
for (const cell of component) remaining.delete(cell);
|
|
23
|
+
if (component.size > ENUMERATION_CELLS) continue;
|
|
24
|
+
const cells = [...component];
|
|
25
|
+
const relevant = constraints.filter((c) => c.cells.some((cell) => component.has(cell)));
|
|
26
|
+
const positions = relevant.map((c) => c.cells.map((cell) => cells.indexOf(cell)));
|
|
27
|
+
const assigned = new Int8Array(cells.length).fill(-1);
|
|
28
|
+
const seenMine = new Uint8Array(cells.length), seenSafe = new Uint8Array(cells.length);
|
|
29
|
+
let nodes = 0, solutions = 0, stopped = false;
|
|
30
|
+
function visit(at: number): void {
|
|
31
|
+
nodes += 1;
|
|
32
|
+
if (nodes > ENUMERATION_NODES) { stopped = true; return; }
|
|
33
|
+
for (let i = 0; i < relevant.length; i += 1) {
|
|
34
|
+
let filled = 0, unknown = 0;
|
|
35
|
+
for (const p of positions[i]!) {
|
|
36
|
+
if (assigned[p] === -1) unknown += 1;
|
|
37
|
+
else filled += assigned[p]!;
|
|
38
|
+
}
|
|
39
|
+
if (filled > relevant[i]!.mines || filled + unknown < relevant[i]!.mines) return;
|
|
40
|
+
}
|
|
41
|
+
if (at === cells.length) {
|
|
42
|
+
solutions += 1;
|
|
43
|
+
for (let i = 0; i < cells.length; i += 1) (assigned[i] === 1 ? seenMine : seenSafe)[i] = 1;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
assigned[at] = 0; visit(at + 1);
|
|
47
|
+
if (!stopped) { assigned[at] = 1; visit(at + 1); }
|
|
48
|
+
assigned[at] = -1;
|
|
49
|
+
}
|
|
50
|
+
visit(0);
|
|
51
|
+
if (stopped) continue;
|
|
52
|
+
if (solutions === 0) return { ...empty, contradiction: true };
|
|
53
|
+
for (let i = 0; i < cells.length; i += 1) {
|
|
54
|
+
if (!seenMine[i]) safe.push(cells[i]!);
|
|
55
|
+
if (!seenSafe[i]) mines.push(cells[i]!);
|
|
56
|
+
}
|
|
57
|
+
if (safe.length || mines.length) for (const c of relevant) for (const source of c.sources) sources.add(source);
|
|
58
|
+
}
|
|
59
|
+
return { ...empty, safe, mines, sources: [...sources], reason: safe.length || mines.length ? "enumeration" : "none" };
|
|
60
|
+
}
|
package/src/flood.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Reveal connected empty ground and its numbered boundary, without recursion. */
|
|
2
|
+
export function flood(clues: readonly number[], adjacent: readonly (readonly number[])[], open: boolean[], starts: readonly number[], blocked: ReadonlySet<number> = new Set()): void {
|
|
3
|
+
const queue = [...starts];
|
|
4
|
+
const queued = new Set(queue);
|
|
5
|
+
for (let at = 0; at < queue.length; at += 1) {
|
|
6
|
+
const cell = queue[at]!;
|
|
7
|
+
if (open[cell] || blocked.has(cell) || clues[cell] === undefined || clues[cell]! < 0) continue;
|
|
8
|
+
open[cell] = true;
|
|
9
|
+
if (clues[cell] !== 0) continue;
|
|
10
|
+
for (const n of adjacent[cell]!) if (!open[n] && !queued.has(n)) { queue.push(n); queued.add(n); }
|
|
11
|
+
}
|
|
12
|
+
}
|