@johnmorrisdotca/jirai 0.0.0-stage → 0.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 +13 -0
- package/LICENSE +21 -0
- package/README.md +137 -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 +12 -0
- package/dist/index.js +12 -0
- package/dist/jirai.constants.d.ts +62 -0
- package/dist/jirai.constants.js +24 -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 +47 -0
- package/dist/mount.d.ts +7 -0
- package/dist/mount.js +295 -0
- package/dist/orthogonal.d.ts +16 -0
- package/dist/orthogonal.js +39 -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 +103 -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 +172 -0
- package/src/game.ts +55 -0
- package/src/generate.ts +57 -0
- package/src/grid.ts +42 -0
- package/src/index.ts +14 -0
- package/src/jirai.constants.ts +26 -0
- package/src/jirai.types.ts +56 -0
- package/src/keep.ts +41 -0
- package/src/mount.ts +176 -0
- package/src/orthogonal.ts +49 -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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.2.0] - 2026-10-05
|
|
4
|
+
|
|
5
|
+
- Orthogonal fields: four-neighbour clues, independent hints, proof-backed generation, drawing, saved progress and bilingual controls. Existing square, hexagonal and wraparound modes remain available.
|
|
6
|
+
|
|
7
|
+
## 0.1.0 — release candidate
|
|
8
|
+
|
|
9
|
+
- Square, hexagonal and wraparound fields, seeded and configurable.
|
|
10
|
+
- Safe and clear openings, verified no-guess generation and explained deductions.
|
|
11
|
+
- Immutable rules, versioned saved games, daily seeds and separate browser worker.
|
|
12
|
+
- Configurable materials and markers, English/Japanese controls, keyboard and touch play.
|
|
13
|
+
- Plain-DOM mounting, optional React component and custom element.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 John Morris
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,138 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Jirai 地雷
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Every number is a clue.** Minesweeper on four-neighbour orthogonal and eight-neighbour square grids, hexagons, or a board whose opposite edges join. Clear the ground, mark the mines, and finish a field made from a seed.
|
|
4
|
+
|
|
5
|
+
Plain TypeScript rules, no runtime dependencies. The rules work without a browser; the playable board works in any page. A React wrapper and a custom element are separate imports.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
- Orthogonal four-neighbour, square eight-neighbour, hexagonal and wraparound boards, with width, height and mine count of your own.
|
|
10
|
+
- Rectangle, heart, star and hexagon outlines; wide and tall presets. Cut-outs are outside the playable field.
|
|
11
|
+
- A safe first cell, or a clear opening with all its neighbours safe.
|
|
12
|
+
- Verified no-guess fields. A bounded generator throws `GenerationError` if it cannot prove a board; it never substitutes an ordinary field.
|
|
13
|
+
- Fixed mines after the opening, seeded deals, replayable progress and daily seeds.
|
|
14
|
+
- Flood opening, flags and question marks, chording, explained hints, a clock and a just-the-board dialog.
|
|
15
|
+
- Wood, ivory or slate; flags, stones or flowers. Host CSS may replace the palette.
|
|
16
|
+
- English and Japanese, mouse, touch, long press, and keyboard navigation.
|
|
17
|
+
- Browser generation runs in a module worker, so searching for a no-guess field does not block input.
|
|
18
|
+
|
|
19
|
+
## Start a game
|
|
20
|
+
|
|
21
|
+
Once published:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npm install @johnmorrisdotca/jirai
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The present checkout is a release candidate; its repository URL and package name are intended destinations, not a claim that it has been published.
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { DEFAULT_SETTINGS, newGame, play, visibleGame, hintFor } from "@johnmorrisdotca/jirai";
|
|
31
|
+
|
|
32
|
+
let game = newGame({ ...DEFAULT_SETTINGS, width: 16, height: 16, mines: 40, seed: 42 });
|
|
33
|
+
game = play(game, { kind: "reveal", cell: 136 });
|
|
34
|
+
game = play(game, { kind: "mark", cell: 0 });
|
|
35
|
+
const hint = hintFor(visibleGame(game));
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Every move returns a new game and leaves its input untouched. A move which is unavailable returns the game given. The first reveal deals the board. A mark made before it does not influence the deal.
|
|
39
|
+
|
|
40
|
+
`Settings.shape` may be `rectangle` (the default), `heart`, `star` or `hexagon`. Shaped fields require at least 9 cells on each side and work on square or hexagonal grids; wraparound requires a rectangle. `activeCells(settings)` returns playable row-major cells and `activeCell(settings, cell)` tests membership. Outside cells have clue −2, never contain mines and are excluded from neighbours, solving, drawing and winning. Mine count must be less than the playable cell count.
|
|
41
|
+
|
|
42
|
+
The dedicated `@johnmorrisdotca/jirai/orthogonal` entry provides `newOrthogonalGame`, `makeOrthogonalBoard`, `orthogonalNeighbours`, `orthogonalHint`, `encodeOrthogonalGame` and `decodeOrthogonalGame`. It applies four-neighbour rules explicitly, so callers do not need to thread a topology string through setup.
|
|
43
|
+
|
|
44
|
+
The demo uses the family’s original shared stylesheet and header template, with its five table-cloth choices and bilingual Help controls.
|
|
45
|
+
|
|
46
|
+
Cells are row-major numbers, zero first: `cell = row * width + column`. Orthogonal clues count only the four edge-sharing cells. Square boards count eight neighbours. Hex boards use axial coordinates: neighbours are left, right, above, above-right, below-left and below. A wrap board joins both opposite edges of a square grid.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { mountJirai } from "@johnmorrisdotca/jirai/play";
|
|
50
|
+
|
|
51
|
+
const board = mountJirai(document.querySelector<HTMLElement>("#board")!, {
|
|
52
|
+
settings: { ...DEFAULT_SETTINGS, grid: "hex", seed: 7 },
|
|
53
|
+
material: "wood",
|
|
54
|
+
pieces: "stones",
|
|
55
|
+
language: "en",
|
|
56
|
+
onChange(game) { /* save gameProgress(game) */ },
|
|
57
|
+
onFinish(game) { /* game.status is won or lost */ },
|
|
58
|
+
onError(error) { /* show a generation or worker-loading error */ },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
board.set({ material: "slate", language: "ja" });
|
|
62
|
+
board.restart();
|
|
63
|
+
board.destroy();
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The handle also offers `game()`, `progress()`, `load(settings, progress?)`, `play(cell, mark?)`, and `hint()`. `controls: false` gives a bare board for a host which supplies its own controls, status and timer. Events `jirai-change` and `jirai-finish` are dispatched on the host.
|
|
67
|
+
|
|
68
|
+
The engine's `Game` contains the answer. Keep that in the process doing the checking. `visibleGame` removes all hidden clues and is the only input the hint solver reads; a player's flags never count as evidence. This is a local puzzle, not a secure multiplayer protocol.
|
|
69
|
+
|
|
70
|
+
## In a framework
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
import { JiraiBoard } from "@johnmorrisdotca/jirai/react";
|
|
74
|
+
<JiraiBoard key={seed} settings={{ ...DEFAULT_SETTINGS, seed }} material="wood" />
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Options are read when mounted; a new key starts a different board. `onChange`, `onFinish` and `onError` stay current.
|
|
78
|
+
|
|
79
|
+
Vue, Svelte and Angular can call `mountJirai` on their element in the mount lifecycle and call `destroy` when it leaves. Or use the tag:
|
|
80
|
+
|
|
81
|
+
```html
|
|
82
|
+
<script type="module">
|
|
83
|
+
import "@johnmorrisdotca/jirai/element/define";
|
|
84
|
+
</script>
|
|
85
|
+
<jirai-board grid="hex" width="9" height="9" mines="10" seed="7"
|
|
86
|
+
no-guess="true" material="wood" pieces="stones" lang="ja"></jirai-board>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Use your bundler or an import map to resolve the package name. The worker is `dist/worker.js`, resolved beside the mounting module; serve the built files over HTTP and allow module workers in your CSP. It is included in the npm tarball. A deployment that rewrites module paths must keep this worker URL working too.
|
|
90
|
+
|
|
91
|
+
## Keep a game
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
import { gameProgress, gameFromProgress, dailySeed } from "@johnmorrisdotca/jirai";
|
|
95
|
+
const saved = gameProgress(game);
|
|
96
|
+
const restored = gameFromProgress(saved); // null for an invalid record
|
|
97
|
+
const seed = dailySeed("2026-10-04", "hex");
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Ordinary grids keep version 1 progress records. Orthogonal records use version 2 and an explicit `variant: "orthogonal"` marker; `decodeOrthogonalGame` accepts only those records. Existing square, hex and wraparound records keep their format and meaning. A seed is only the start of a board's identity: width, height, mine count, grid, no-guess setting, opening policy and first cell matter too. Daily play uses UTC, beginner settings, and the centre opening. The demo's share link includes the first cell after a field has been dealt. Its local save restores the moves, but does not restore elapsed time.
|
|
101
|
+
|
|
102
|
+
## The no-guess promise
|
|
103
|
+
|
|
104
|
+
The generator deals candidates and plays them using deductions made from visible clues. It uses local counts, differences between contained clue sets, the total mine count, and complete enumeration of small connected frontiers. Enumeration stops at a fixed work budget and discards incomplete results. A candidate is accepted only when all safe cells were reached by proved moves.
|
|
105
|
+
|
|
106
|
+
This is deliberately conservative. Failing to prove a board does not mean no human could solve it. Very dense custom fields or large expert boards may exceed the search budget. Call `makeBoard(settings, first, { attempts })` to set the candidate budget (1–10000; default 128). Server generation is synchronous; run it in a worker when serving large boards. The mounted browser board already does so.
|
|
107
|
+
|
|
108
|
+
Hints identify a certain safe cell or mine and say which deduction proved it. They do not offer approximate probabilities. Chording still requires correctly placed flags: a matched count of incorrect flags can expose a mine.
|
|
109
|
+
|
|
110
|
+
## Build and check
|
|
111
|
+
|
|
112
|
+
```sh
|
|
113
|
+
pnpm install
|
|
114
|
+
pnpm check
|
|
115
|
+
pnpm test:package
|
|
116
|
+
pnpm exec playwright install chromium
|
|
117
|
+
pnpm test:demo
|
|
118
|
+
pnpm site
|
|
119
|
+
node scripts/serve.mjs
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The generated standalone page is in `docs/`; the preview serves only on `127.0.0.1:6713`. The tests include an exhaustive independent small-board oracle for solver soundness, deterministic dealing, flood/chord rules, immutable moves, replay, and phone/desktop browser flows. The package check installs the actual tarball into an empty temporary project and imports it in Node.
|
|
123
|
+
|
|
124
|
+
## Architecture
|
|
125
|
+
|
|
126
|
+
Rules: `jirai.types.ts`, `jirai.constants.ts`, `grid.ts`, `random.ts`, `generate.ts`, `flood.ts`, `game.ts`, `deduce.ts`, `enumerate.ts`, `keep.ts`, `orthogonal.ts`.
|
|
127
|
+
|
|
128
|
+
Drawing and play: `draw.ts`, `style.ts`, `strings.ts`, `mount.ts`, `worker.ts`, `ui.types.ts`. Framework and tag wrappers are separate entries.
|
|
129
|
+
|
|
130
|
+
Types and domain constants are kept beside their modules. A new grid is a topology row, not a second copy of the rules. A theme changes drawing, never the answer.
|
|
131
|
+
|
|
132
|
+
## References
|
|
133
|
+
|
|
134
|
+
[Simon Tatham's Mines](https://www.chiark.greenend.org.uk/~sgtatham/puzzles/doc/mines.html) is the reference for no-guess play, alternate tilings and wrapping. [David Hill's JSMinesweeper](https://github.com/DavidNHill/JSMinesweeper) is the reference for analysis, opening choices and chording. This implementation does not incorporate their source code.
|
|
135
|
+
|
|
136
|
+
The package follows the pure rules / separate draw / separate play structure of [Suido](https://github.com/johnmorrisdotca/suido), and the optional React wrapper pattern used by Tenka. MIT © John Morris.
|
|
137
|
+
|
|
138
|
+
A run has `helped: true` after a proved hint is shown. This flag survives saved progress and lets a host distinguish assisted finishes. It is client-side state, not a competitive score verification mechanism.
|
package/dist/deduce.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Deduction, VisibleGame } from "./jirai.types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* What the clues prove, without reading the answer or trusting a player's
|
|
4
|
+
* flags. A flag is a note; treating it as evidence makes a bad note a bad hint.
|
|
5
|
+
*/
|
|
6
|
+
export declare function deduce(game: VisibleGame, knownMines?: ReadonlySet<number>, enumerate?: boolean): Deduction;
|
|
7
|
+
/** Keep discovering certain mines until there is a safe cell or nothing more is proved. */
|
|
8
|
+
export declare function hintFor(game: VisibleGame): Deduction;
|
package/dist/deduce.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { enumerateForced } from "./enumerate.js";
|
|
2
|
+
import { neighboursOf } from "./grid.js";
|
|
3
|
+
/**
|
|
4
|
+
* What the clues prove, without reading the answer or trusting a player's
|
|
5
|
+
* flags. A flag is a note; treating it as evidence makes a bad note a bad hint.
|
|
6
|
+
*/
|
|
7
|
+
export function deduce(game, knownMines = new Set(), enumerate = true) {
|
|
8
|
+
const adjacent = neighboursOf(game.settings);
|
|
9
|
+
const constraints = [];
|
|
10
|
+
const empty = { safe: [], mines: [], reason: "none", sources: [], contradiction: false };
|
|
11
|
+
for (let cell = 0; cell < game.clues.length; cell += 1) {
|
|
12
|
+
const clue = game.clues[cell];
|
|
13
|
+
if (clue === null || clue === undefined || clue < 0)
|
|
14
|
+
continue;
|
|
15
|
+
const unknown = adjacent[cell].filter((n) => game.clues[n] === null && !knownMines.has(n));
|
|
16
|
+
const mines = clue - adjacent[cell].filter((n) => knownMines.has(n)).length;
|
|
17
|
+
if (mines < 0 || mines > unknown.length)
|
|
18
|
+
return { ...empty, contradiction: true };
|
|
19
|
+
if (unknown.length)
|
|
20
|
+
constraints.push({ cells: unknown, mines, sources: [cell] });
|
|
21
|
+
}
|
|
22
|
+
const unknown = game.clues.flatMap((clue, cell) => clue === null && !knownMines.has(cell) ? [cell] : []);
|
|
23
|
+
const left = game.settings.mines - knownMines.size;
|
|
24
|
+
if (left < 0 || left > unknown.length)
|
|
25
|
+
return { ...empty, contradiction: true };
|
|
26
|
+
const total = { cells: unknown, mines: left, sources: [] };
|
|
27
|
+
function forced(c, reason) {
|
|
28
|
+
if (c.mines === 0)
|
|
29
|
+
return { ...empty, safe: c.cells, reason, sources: c.sources };
|
|
30
|
+
if (c.mines === c.cells.length)
|
|
31
|
+
return { ...empty, mines: c.cells, reason, sources: c.sources };
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
for (const c of constraints) {
|
|
35
|
+
const result = forced(c, "count");
|
|
36
|
+
if (result !== null)
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
if (unknown.length) {
|
|
40
|
+
const result = forced(total, "total");
|
|
41
|
+
if (result !== null)
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
// Compare a smaller clue with a larger one, including the mine counter.
|
|
45
|
+
// Their difference is another exact count. Never subtract mere overlaps.
|
|
46
|
+
const sets = [...constraints, total].map((c) => new Set(c.cells));
|
|
47
|
+
const all = [...constraints, total];
|
|
48
|
+
for (let i = 0; i < all.length; i += 1)
|
|
49
|
+
for (let j = 0; j < all.length; j += 1) {
|
|
50
|
+
if (i === j || all[i].cells.length >= all[j].cells.length)
|
|
51
|
+
continue;
|
|
52
|
+
if (!all[i].cells.every((cell) => sets[j].has(cell)))
|
|
53
|
+
continue;
|
|
54
|
+
const c = {
|
|
55
|
+
cells: all[j].cells.filter((cell) => !sets[i].has(cell)),
|
|
56
|
+
mines: all[j].mines - all[i].mines,
|
|
57
|
+
sources: [...new Set([...all[i].sources, ...all[j].sources])],
|
|
58
|
+
};
|
|
59
|
+
if (c.mines < 0 || c.mines > c.cells.length)
|
|
60
|
+
return { ...empty, contradiction: true };
|
|
61
|
+
const result = forced(c, j === all.length - 1 ? "total" : "overlap");
|
|
62
|
+
if (result !== null)
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
// A whole-board constraint would join otherwise independent components.
|
|
66
|
+
// Local enumeration is conservative; the global counter is used above.
|
|
67
|
+
return enumerate ? enumerateForced(constraints) : empty;
|
|
68
|
+
}
|
|
69
|
+
/** Keep discovering certain mines until there is a safe cell or nothing more is proved. */
|
|
70
|
+
export function hintFor(game) {
|
|
71
|
+
const known = new Set();
|
|
72
|
+
let last = { safe: [], mines: [], reason: "none", sources: [], contradiction: false };
|
|
73
|
+
while (known.size <= game.settings.mines) {
|
|
74
|
+
const result = deduce(game, known);
|
|
75
|
+
if (result.contradiction || result.safe.length)
|
|
76
|
+
return result;
|
|
77
|
+
const fresh = result.mines.filter((cell) => !known.has(cell));
|
|
78
|
+
if (!fresh.length)
|
|
79
|
+
return last;
|
|
80
|
+
last = result;
|
|
81
|
+
for (const cell of fresh)
|
|
82
|
+
known.add(cell);
|
|
83
|
+
}
|
|
84
|
+
return last;
|
|
85
|
+
}
|
package/dist/draw.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Game } from "./jirai.types.ts";
|
|
2
|
+
import type { BoardModel, DrawOptions } from "./ui.types.ts";
|
|
3
|
+
/** Where the cells sit, and what may be shown. The drawing never exposes a covered clue. */
|
|
4
|
+
export declare function boardModel(game: Game, options?: DrawOptions): BoardModel;
|
package/dist/draw.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { activeCell } from "./shape.js";
|
|
2
|
+
import { GRIDS, MARKS, STATUSES } from "./jirai.constants.js";
|
|
3
|
+
import { words } from "./strings.js";
|
|
4
|
+
/** Where the cells sit, and what may be shown. The drawing never exposes a covered clue. */
|
|
5
|
+
export function boardModel(game, options = {}) {
|
|
6
|
+
const hex = game.settings.grid === GRIDS.hex;
|
|
7
|
+
const ended = game.status === STATUSES.lost || game.status === STATUSES.won;
|
|
8
|
+
const say = words(options.language);
|
|
9
|
+
const width = hex ? game.settings.width + (game.settings.height - 1) / 2 : game.settings.width;
|
|
10
|
+
const height = hex ? (game.settings.height - 1) * .866 + 1.155 : game.settings.height;
|
|
11
|
+
const cells = game.marks.flatMap((mark, cell) => {
|
|
12
|
+
if (!activeCell(game.settings, cell))
|
|
13
|
+
return [];
|
|
14
|
+
const col = cell % game.settings.width, row = Math.floor(cell / game.settings.width);
|
|
15
|
+
const mine = ended && game.board?.mines[cell] === true;
|
|
16
|
+
const wrong = ended && mark === MARKS.flag && !mine;
|
|
17
|
+
const clue = mark === MARKS.open ? game.board?.clues[cell] ?? 0 : 0;
|
|
18
|
+
const kind = cell === game.exploded ? "exploded" : mine ? "mine" : wrong ? "wrong" : mark;
|
|
19
|
+
const text = mine ? "✹" : wrong ? "×" : mark === MARKS.flag ? options.pieces === "stones" ? "●" : options.pieces === "flowers" ? "✿" : "⚑"
|
|
20
|
+
: mark === MARKS.question ? "?" : mark === MARKS.open && clue > 0 ? String(clue) : "";
|
|
21
|
+
const description = mine ? say.mine : wrong ? say.wrong : mark === MARKS.open ? clue === 0 ? say.empty : `${clue}` : say[mark];
|
|
22
|
+
return [{ cell, x: col + (hex ? row / 2 : 0), y: row * (hex ? .866 : 1), width: 1, height: hex ? 1.155 : 1,
|
|
23
|
+
label: `${row + 1}, ${col + 1}: ${description}`, text, kind, hint: options.hint === cell }];
|
|
24
|
+
});
|
|
25
|
+
return { width, height, cells };
|
|
26
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
declare const ElementBase: {
|
|
2
|
+
new (): HTMLElement;
|
|
3
|
+
prototype: HTMLElement;
|
|
4
|
+
};
|
|
5
|
+
export declare class JiraiElement extends ElementBase {
|
|
6
|
+
private mounted;
|
|
7
|
+
static observedAttributes: string[];
|
|
8
|
+
connectedCallback(): void;
|
|
9
|
+
disconnectedCallback(): void;
|
|
10
|
+
attributeChangedCallback(): void;
|
|
11
|
+
private mount;
|
|
12
|
+
}
|
|
13
|
+
export declare function defineJirai(registry?: CustomElementRegistry): void;
|
|
14
|
+
export {};
|
package/dist/element.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { DEFAULT_SETTINGS } from "./jirai.constants.js";
|
|
2
|
+
import { mountJirai } from "./mount.js";
|
|
3
|
+
// Importing this file on a server is harmless; only defineJirai registers the tag.
|
|
4
|
+
const ElementBase = typeof HTMLElement === "undefined" ? class {
|
|
5
|
+
} : HTMLElement;
|
|
6
|
+
export class JiraiElement extends ElementBase {
|
|
7
|
+
mounted = null;
|
|
8
|
+
static observedAttributes = ["width", "height", "mines", "seed", "grid", "shape", "no-guess", "material", "pieces", "lang"];
|
|
9
|
+
connectedCallback() { this.mount(); }
|
|
10
|
+
disconnectedCallback() { this.mounted?.destroy(); this.mounted = null; }
|
|
11
|
+
attributeChangedCallback() { if (this.isConnected)
|
|
12
|
+
this.mount(); }
|
|
13
|
+
mount() {
|
|
14
|
+
this.mounted?.destroy();
|
|
15
|
+
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"), grid: (this.getAttribute("grid") ?? "square"), noGuess: this.getAttribute("no-guess") !== "false" },
|
|
19
|
+
material: (this.getAttribute("material") ?? "ivory"), pieces: (this.getAttribute("pieces") ?? "flags"), language: (this.getAttribute("lang") ?? "en"),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
this.dispatchEvent(new CustomEvent("jirai-error", { detail: error }));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function defineJirai(registry = customElements) { if (!registry.get("jirai-board"))
|
|
28
|
+
registry.define("jirai-board", JiraiElement); }
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Constraint, Deduction } from "./jirai.types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Exact assignments within a small connected frontier. If the work budget is
|
|
4
|
+
* reached, none of the partial search is evidence: it returns no deductions.
|
|
5
|
+
*/
|
|
6
|
+
export declare function enumerateForced(constraints: readonly Constraint[]): Deduction;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { ENUMERATION_CELLS, ENUMERATION_NODES } from "./jirai.constants.js";
|
|
2
|
+
/**
|
|
3
|
+
* Exact assignments within a small connected frontier. If the work budget is
|
|
4
|
+
* reached, none of the partial search is evidence: it returns no deductions.
|
|
5
|
+
*/
|
|
6
|
+
export function enumerateForced(constraints) {
|
|
7
|
+
const empty = { safe: [], mines: [], reason: "none", sources: [], contradiction: false };
|
|
8
|
+
const remaining = new Set(constraints.flatMap((c) => [...c.cells]));
|
|
9
|
+
const safe = [], mines = [], sources = new Set();
|
|
10
|
+
while (remaining.size > 0) {
|
|
11
|
+
const component = new Set([remaining.values().next().value]);
|
|
12
|
+
let grew = true;
|
|
13
|
+
while (grew) {
|
|
14
|
+
grew = false;
|
|
15
|
+
for (const c of constraints) {
|
|
16
|
+
if (!c.cells.some((cell) => component.has(cell)))
|
|
17
|
+
continue;
|
|
18
|
+
for (const cell of c.cells)
|
|
19
|
+
if (!component.has(cell)) {
|
|
20
|
+
component.add(cell);
|
|
21
|
+
grew = true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
for (const cell of component)
|
|
26
|
+
remaining.delete(cell);
|
|
27
|
+
if (component.size > ENUMERATION_CELLS)
|
|
28
|
+
continue;
|
|
29
|
+
const cells = [...component];
|
|
30
|
+
const relevant = constraints.filter((c) => c.cells.some((cell) => component.has(cell)));
|
|
31
|
+
const positions = relevant.map((c) => c.cells.map((cell) => cells.indexOf(cell)));
|
|
32
|
+
const assigned = new Int8Array(cells.length).fill(-1);
|
|
33
|
+
const seenMine = new Uint8Array(cells.length), seenSafe = new Uint8Array(cells.length);
|
|
34
|
+
let nodes = 0, solutions = 0, stopped = false;
|
|
35
|
+
function visit(at) {
|
|
36
|
+
nodes += 1;
|
|
37
|
+
if (nodes > ENUMERATION_NODES) {
|
|
38
|
+
stopped = true;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
for (let i = 0; i < relevant.length; i += 1) {
|
|
42
|
+
let filled = 0, unknown = 0;
|
|
43
|
+
for (const p of positions[i]) {
|
|
44
|
+
if (assigned[p] === -1)
|
|
45
|
+
unknown += 1;
|
|
46
|
+
else
|
|
47
|
+
filled += assigned[p];
|
|
48
|
+
}
|
|
49
|
+
if (filled > relevant[i].mines || filled + unknown < relevant[i].mines)
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (at === cells.length) {
|
|
53
|
+
solutions += 1;
|
|
54
|
+
for (let i = 0; i < cells.length; i += 1)
|
|
55
|
+
(assigned[i] === 1 ? seenMine : seenSafe)[i] = 1;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
assigned[at] = 0;
|
|
59
|
+
visit(at + 1);
|
|
60
|
+
if (!stopped) {
|
|
61
|
+
assigned[at] = 1;
|
|
62
|
+
visit(at + 1);
|
|
63
|
+
}
|
|
64
|
+
assigned[at] = -1;
|
|
65
|
+
}
|
|
66
|
+
visit(0);
|
|
67
|
+
if (stopped)
|
|
68
|
+
continue;
|
|
69
|
+
if (solutions === 0)
|
|
70
|
+
return { ...empty, contradiction: true };
|
|
71
|
+
for (let i = 0; i < cells.length; i += 1) {
|
|
72
|
+
if (!seenMine[i])
|
|
73
|
+
safe.push(cells[i]);
|
|
74
|
+
if (!seenSafe[i])
|
|
75
|
+
mines.push(cells[i]);
|
|
76
|
+
}
|
|
77
|
+
if (safe.length || mines.length)
|
|
78
|
+
for (const c of relevant)
|
|
79
|
+
for (const source of c.sources)
|
|
80
|
+
sources.add(source);
|
|
81
|
+
}
|
|
82
|
+
return { ...empty, safe, mines, sources: [...sources], reason: safe.length || mines.length ? "enumeration" : "none" };
|
|
83
|
+
}
|
package/dist/flood.d.ts
ADDED
package/dist/flood.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Reveal connected empty ground and its numbered boundary, without recursion. */
|
|
2
|
+
export function flood(clues, adjacent, open, starts, blocked = new Set()) {
|
|
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)
|
|
8
|
+
continue;
|
|
9
|
+
open[cell] = true;
|
|
10
|
+
if (clues[cell] !== 0)
|
|
11
|
+
continue;
|
|
12
|
+
for (const n of adjacent[cell])
|
|
13
|
+
if (!open[n] && !queued.has(n)) {
|
|
14
|
+
queue.push(n);
|
|
15
|
+
queued.add(n);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
package/dist/game.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Board, Game, Move, Settings, VisibleGame } from "./jirai.types.ts";
|
|
2
|
+
export declare function newGame(settings?: Settings): Game;
|
|
3
|
+
/** The clue-only view used by the solver. No hidden mine, including under a flag, survives it. */
|
|
4
|
+
export declare function visibleGame(game: Game): VisibleGame;
|
|
5
|
+
/** Accept a worker's dealt board without changing any moves made before the opening. */
|
|
6
|
+
export declare function withBoard(game: Game, board: Board): Game;
|
|
7
|
+
/** A legal move returns a new game; an unavailable move returns the same one. */
|
|
8
|
+
export declare function play(game: Game, move: Move): Game;
|
package/dist/game.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { activeCell, activeCells } from "./shape.js";
|
|
2
|
+
import { flood } from "./flood.js";
|
|
3
|
+
import { makeBoard } from "./generate.js";
|
|
4
|
+
import { DEFAULT_SETTINGS, MARKS, MOVES, STATUSES } from "./jirai.constants.js";
|
|
5
|
+
import { neighbours, neighboursOf, validCell, validSettings } from "./grid.js";
|
|
6
|
+
export function newGame(settings = DEFAULT_SETTINGS) {
|
|
7
|
+
if (!validSettings(settings))
|
|
8
|
+
throw new RangeError("Invalid Minesweeper settings.");
|
|
9
|
+
return { settings: { ...settings }, board: null, marks: Array.from({ length: settings.width * settings.height }, () => MARKS.covered), status: STATUSES.ready, exploded: null, moves: [], helped: false };
|
|
10
|
+
}
|
|
11
|
+
/** The clue-only view used by the solver. No hidden mine, including under a flag, survives it. */
|
|
12
|
+
export function visibleGame(game) {
|
|
13
|
+
return { settings: { ...game.settings }, marks: [...game.marks], status: game.status,
|
|
14
|
+
clues: game.marks.map((mark, cell) => !activeCell(game.settings, cell) ? -2 : mark === MARKS.open ? game.board?.clues[cell] ?? null : null) };
|
|
15
|
+
}
|
|
16
|
+
/** Accept a worker's dealt board without changing any moves made before the opening. */
|
|
17
|
+
export function withBoard(game, board) {
|
|
18
|
+
if (game.board !== null || (game.settings.shape ?? "rectangle") !== (board.settings.shape ?? "rectangle") || Object.keys(DEFAULT_SETTINGS).some(key => game.settings[key] !== board.settings[key]))
|
|
19
|
+
throw new Error("The board does not belong to this game.");
|
|
20
|
+
return { ...game, board };
|
|
21
|
+
}
|
|
22
|
+
/** A legal move returns a new game; an unavailable move returns the same one. */
|
|
23
|
+
export function play(game, move) {
|
|
24
|
+
if (!validCell(game.settings, move.cell) || game.status === STATUSES.won || game.status === STATUSES.lost)
|
|
25
|
+
return game;
|
|
26
|
+
const before = game.marks[move.cell];
|
|
27
|
+
if (move.kind === MOVES.mark) {
|
|
28
|
+
if (before === MARKS.open)
|
|
29
|
+
return game;
|
|
30
|
+
const marks = [...game.marks];
|
|
31
|
+
marks[move.cell] = before === MARKS.covered ? MARKS.flag : before === MARKS.flag ? MARKS.question : MARKS.covered;
|
|
32
|
+
return { ...game, marks, moves: [...game.moves, { ...move }] };
|
|
33
|
+
}
|
|
34
|
+
if (move.kind !== MOVES.reveal && move.kind !== MOVES.chord)
|
|
35
|
+
return game;
|
|
36
|
+
if (before === MARKS.flag || (move.kind === MOVES.chord && before !== MARKS.open))
|
|
37
|
+
return game;
|
|
38
|
+
const board = game.board ?? makeBoard(game.settings, move.cell);
|
|
39
|
+
let starts = [move.cell];
|
|
40
|
+
if (before === MARKS.open) {
|
|
41
|
+
const around = neighbours(game.settings, move.cell);
|
|
42
|
+
if (around.filter((cell) => game.marks[cell] === MARKS.flag).length !== board.clues[move.cell])
|
|
43
|
+
return game;
|
|
44
|
+
starts = around.filter((cell) => game.marks[cell] !== MARKS.flag && game.marks[cell] !== MARKS.open);
|
|
45
|
+
}
|
|
46
|
+
if (!starts.length)
|
|
47
|
+
return game;
|
|
48
|
+
const marks = [...game.marks];
|
|
49
|
+
const exploded = starts.find((cell) => board.mines[cell]) ?? null;
|
|
50
|
+
const open = marks.map((mark) => mark === MARKS.open);
|
|
51
|
+
const blocked = new Set(marks.flatMap((mark, cell) => mark === MARKS.flag ? [cell] : []));
|
|
52
|
+
flood(board.clues, neighboursOf(game.settings), open, starts, blocked);
|
|
53
|
+
for (let cell = 0; cell < marks.length; cell += 1)
|
|
54
|
+
if (open[cell])
|
|
55
|
+
marks[cell] = MARKS.open;
|
|
56
|
+
if (exploded !== null)
|
|
57
|
+
marks[exploded] = MARKS.open;
|
|
58
|
+
const status = exploded !== null ? STATUSES.lost
|
|
59
|
+
: open.filter(Boolean).length === activeCells(game.settings).length - game.settings.mines ? STATUSES.won : STATUSES.playing;
|
|
60
|
+
return { ...game, board, marks, status, exploded, moves: [...game.moves, { ...move }] };
|
|
61
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Board, GenerationOptions, Settings } from "./jirai.types.ts";
|
|
2
|
+
/** A bounded generator must say it failed, never quietly return a guessing board. */
|
|
3
|
+
export declare class GenerationError extends Error {
|
|
4
|
+
readonly code: "settings" | "opening" | "exhausted";
|
|
5
|
+
constructor(code: "settings" | "opening" | "exhausted", message: string);
|
|
6
|
+
}
|
|
7
|
+
/** Verify a board from one opening, using only clues that have been uncovered. */
|
|
8
|
+
export declare function isSolvable(board: Board, enumerate?: boolean): boolean;
|
|
9
|
+
/** Deal after the first reveal. A seed fixes the whole candidate stream and the accepted board. */
|
|
10
|
+
export declare function makeBoard(settings: Settings, first: number, options?: GenerationOptions): Board;
|
package/dist/generate.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { activeCell, activeCells } from "./shape.js";
|
|
2
|
+
import { deduce } from "./deduce.js";
|
|
3
|
+
import { flood } from "./flood.js";
|
|
4
|
+
import { GENERATION_ATTEMPTS, MARKS, STATUSES } from "./jirai.constants.js";
|
|
5
|
+
import { neighbours, neighboursOf, validCell, validSettings } from "./grid.js";
|
|
6
|
+
import { seededRandom, shuffled } from "./random.js";
|
|
7
|
+
/** A bounded generator must say it failed, never quietly return a guessing board. */
|
|
8
|
+
export class GenerationError extends Error {
|
|
9
|
+
code;
|
|
10
|
+
constructor(code, message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.name = "GenerationError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** Verify a board from one opening, using only clues that have been uncovered. */
|
|
17
|
+
export function isSolvable(board, enumerate = true) {
|
|
18
|
+
const adjacent = neighboursOf(board.settings);
|
|
19
|
+
const open = board.clues.map(() => false);
|
|
20
|
+
const known = new Set();
|
|
21
|
+
flood(board.clues, adjacent, open, [board.first]);
|
|
22
|
+
while (open.filter(Boolean).length < activeCells(board.settings).length - board.settings.mines) {
|
|
23
|
+
const visible = {
|
|
24
|
+
settings: board.settings, clues: board.clues.map((n, cell) => activeCell(board.settings, cell) ? open[cell] ? n : null : -2),
|
|
25
|
+
marks: open.map((yes) => yes ? MARKS.open : MARKS.covered), status: STATUSES.playing,
|
|
26
|
+
};
|
|
27
|
+
const result = deduce(visible, known, enumerate);
|
|
28
|
+
if (result.contradiction)
|
|
29
|
+
return false;
|
|
30
|
+
let changed = false;
|
|
31
|
+
for (const cell of result.mines) {
|
|
32
|
+
if (!board.mines[cell])
|
|
33
|
+
return false;
|
|
34
|
+
if (!known.has(cell)) {
|
|
35
|
+
known.add(cell);
|
|
36
|
+
changed = true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (result.safe.some((cell) => board.mines[cell]))
|
|
40
|
+
return false;
|
|
41
|
+
if (result.safe.some((cell) => !open[cell])) {
|
|
42
|
+
flood(board.clues, adjacent, open, result.safe);
|
|
43
|
+
changed = true;
|
|
44
|
+
}
|
|
45
|
+
if (!changed)
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
/** Deal after the first reveal. A seed fixes the whole candidate stream and the accepted board. */
|
|
51
|
+
export function makeBoard(settings, first, options = {}) {
|
|
52
|
+
if (!validSettings(settings) || !validCell(settings, first))
|
|
53
|
+
throw new GenerationError("settings", "Invalid board settings or opening cell.");
|
|
54
|
+
const protectedCells = new Set([first, ...(settings.opening === "clear" ? neighbours(settings, first) : [])]);
|
|
55
|
+
const available = Array.from({ length: settings.width * settings.height }, (_, cell) => cell).filter((cell) => validCell(settings, cell) && !protectedCells.has(cell));
|
|
56
|
+
if (available.length < settings.mines)
|
|
57
|
+
throw new GenerationError("opening", "Too many mines for a safe opening at this cell.");
|
|
58
|
+
const attempts = options.attempts ?? GENERATION_ATTEMPTS;
|
|
59
|
+
if (!Number.isInteger(attempts) || attempts < 1 || attempts > 10_000)
|
|
60
|
+
throw new GenerationError("settings", "Attempts must be between 1 and 10000.");
|
|
61
|
+
const random = seededRandom(settings.seed);
|
|
62
|
+
const adjacent = neighboursOf(settings);
|
|
63
|
+
for (let attempt = 0; attempt < (settings.noGuess ? attempts : 1); attempt += 1) {
|
|
64
|
+
const places = new Set(shuffled(available, random).slice(0, settings.mines));
|
|
65
|
+
const mines = available.length ? Array.from({ length: settings.width * settings.height }, (_, cell) => places.has(cell)) : [];
|
|
66
|
+
const clues = mines.map((mine, cell) => !activeCell(settings, cell) ? -2 : mine ? -1 : adjacent[cell].filter((n) => mines[n]).length);
|
|
67
|
+
const board = { settings: { ...settings }, mines, clues, first, attempt };
|
|
68
|
+
if (!settings.noGuess || isSolvable(board, options.enumerate ?? true))
|
|
69
|
+
return board;
|
|
70
|
+
}
|
|
71
|
+
throw new GenerationError("exhausted", "No verified board found within the work budget. Try another seed, fewer mines, or a different opening.");
|
|
72
|
+
}
|