@johnmorrisdotca/jirai 0.3.0 → 0.4.1
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 +23 -0
- package/README.md +100 -23
- package/dist/deduce.d.ts +1 -1
- package/dist/deduce.js +18 -6
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/jirai.constants.d.ts +15 -0
- package/dist/jirai.constants.js +6 -0
- package/dist/levels.d.ts +16 -0
- package/dist/levels.js +19 -1
- package/package.json +11 -4
- package/src/deduce.ts +22 -15
- package/src/family.test.js +21 -4
- package/src/index.ts +3 -3
- package/src/jirai.constants.ts +6 -0
- package/src/levels.ts +21 -1
- package/src/readme.test.js +34 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,29 @@ All notable changes to this project are written here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.1] - 2026-10-05
|
|
10
|
+
|
|
11
|
+
Nothing that was exported has changed.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- A test holds every `@johnmorrisdotca/jirai@N` version pin in the README to this package's major version.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- The family's list, in the README and in the demo's footer, names all twenty-four packages, Karakuri and Houseki included.
|
|
20
|
+
- The npm description is one sentence of 250 characters or fewer, so npm and its search show it whole; it is also the repository's About text. `homepage` is the demo site and `author` is `"John Morris"`, the same in every package.
|
|
21
|
+
- The GitHub Actions workflows use the current versions of the actions (checkout 7, setup-node 7, pnpm/action-setup 6; configure-pages 6, upload-pages-artifact 5 and deploy-pages 5 for Pages), which clears GitHub's Node 20 deprecation warning.
|
|
22
|
+
- Every entry has an `import` condition beside `default`.
|
|
23
|
+
- The README has the family's sections in the family's order (Who it is for, Features, Use it in your project, API, Theming, Limits, Browser support, Languages, Roadmap, Architecture, Where it comes from, Changes), the family list sits under "Where it comes from", and a test holds it to them.
|
|
24
|
+
- The development tools are the family's: Vitest 5 and Playwright 1.63, as in the other packages.
|
|
25
|
+
|
|
26
|
+
## [0.4.0] - 2026-10-05
|
|
27
|
+
|
|
28
|
+
- **Huge fields.** `hugeSettings(level, { grid, shape, size })` and `HUGE_SIZES` (32×32, 48×24 and 24×48) give each of the four levels on a field of four times the medium level's area, 1,024 to 1,152 squares, with the level's share of mines (a 32×32 has 126, 160, 211 and 256 mines at easy, medium, hard and extra-hard), on every rule set and outline. Every one is dealt and proved to need no guess in a median of 3 to 60 ms (73 ms the slowest of ten 32×32 seeds), on the square, orthogonal, hexagonal and wraparound grids and the heart, star and hexagon outlines, and `src/huge.test.ts` wins a 32×32 at easy and at extra-hard by the explained hints alone. The demo's Level menu offers them (`?level=huge-hard`).
|
|
29
|
+
- **A faster hint.** `deduce` compares a count only with the counts that hold its first square, and `hintFor` builds the neighbours once instead of at every step: on a 32×32 extra-hard field the explained hint took 51 ms on average and up to 400 ms late in the game, now 8 ms and at most 43 ms. The hints it finds are the same ones.
|
|
30
|
+
- Measured, not changed: a reveal or a flag on a 1,024-square board is 4 ms of script, 20 ms with the processor slowed fourfold, and the next frame follows (2,400 squares, the most settings accept: 36 ms), so the board stays plain buttons; the README has the numbers.
|
|
31
|
+
|
|
9
32
|
## [0.3.0] - 2026-10-05
|
|
10
33
|
|
|
11
34
|
- An extra-hard level, 40×24 with 240 mines (25%), joins easy, medium and hard on every rule and outline, and every field is still proved to need no guess. `beginner`, `intermediate` and `expert` stay as names for easy, medium and hard, and `PRESETS` keeps them. New: `LEVELS`, `LEVEL_SIZES`, `LEVEL_ALIASES`, `levelNamed`, `levelSettings` (shaped boards keep a level's size and share of mines) and `measureBoard`, which scores density, multi-step share and depth of deduction so the levels can be shown to step up.
|
package/README.md
CHANGED
|
@@ -44,7 +44,13 @@ const game = newOrthogonalGame(settings);
|
|
|
44
44
|
const board = makeOrthogonalBoard(settings, 40); // counts only edge-sharing neighbours
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
##
|
|
47
|
+
## Who it is for
|
|
48
|
+
|
|
49
|
+
- **Puzzle and game sites** that want Minesweeper with boards a player can trust: seeded fields that deal the same everywhere, a safe opening, verified no-guess deals, and the words in English and Japanese.
|
|
50
|
+
- **Developers of other front ends** who want the rules, the dealer and the deduction solver as plain functions, with no DOM, and their own drawing on top.
|
|
51
|
+
- **Players and teachers** who want to learn why a cell is safe: the hints explain the deduction that proves it, and a level steps up a measured difficulty.
|
|
52
|
+
|
|
53
|
+
## Features
|
|
48
54
|
|
|
49
55
|
- **Four rule sets:** square grids count eight neighbours, orthogonal grids count four, hex grids count six axial neighbours, and wraparound grids join opposite square edges.
|
|
50
56
|
- **Board outlines:** rectangles, hearts, stars and hexagon outlines. Shaped boards have cut-outs; wraparound works with rectangles.
|
|
@@ -56,7 +62,7 @@ const board = makeOrthogonalBoard(settings, 40); // counts only edge-sharing nei
|
|
|
56
62
|
- **Accessible controls:** keyboard navigation, pointer and touch, long press to mark, English and Japanese strings, and board labels read by assistive technology.
|
|
57
63
|
- **Materials and markers:** ivory, wood or slate; flags, stones or flowers. Host CSS can replace the palette.
|
|
58
64
|
|
|
59
|
-
## Use it in
|
|
65
|
+
## Use it in your project
|
|
60
66
|
|
|
61
67
|
Mount a player into any element. The first reveal asks the module worker to deal the board, so serve the package over HTTP and allow same-origin module workers in your content security policy.
|
|
62
68
|
|
|
@@ -116,6 +122,18 @@ The optional React entry exports `JiraiBoard` from `@johnmorrisdotca/jirai/react
|
|
|
116
122
|
|
|
117
123
|
`levelNamed(name)` returns the level a name means (`extra-hard` may also be written `extra hard`, `extra_hard` or `extraHard`) or `null`. `levelSettings(level, { grid, shape })` returns `{ width, height, mines }`: a rectangle gets the numbers above, and a heart, star or hexagon outline keeps the level's width and height and its share of mines over the cells that are left. `PRESETS` holds the four levels, the three old names, and `wide` (21×9, 24) and `tall` (9×21, 24).
|
|
118
124
|
|
|
125
|
+
### Huge fields
|
|
126
|
+
|
|
127
|
+
`hugeSettings(level, { grid, shape, size })` is the same four levels on a field of four times the area of the medium one: 32×32 (1,024 squares), or 48×24 or 24×48 (1,152; `HUGE_SIZES` lists them) with the level's own share of the mines, so a 32×32 has 126 mines at `easy`, 160 at `medium`, 211 at `hard` and 256 at `extra-hard`, and a heart, star or hexagon outline keeps the share over the squares it has left. They are the same fields as any other: seeded, a safe opening, and every one proved to need no guess, on all four rule sets and all four outlines (`src/huge.test.ts`, and the winning of 32×32 fields at easy and extra-hard by the explained hints alone).
|
|
128
|
+
|
|
129
|
+
Measured on a Mac (20 cores, busy): dealing and proving a 32×32 takes a median of 3 ms at easy, 5 ms at medium, 11 ms at hard and 60 ms at extra-hard (the slowest of ten seeds 73 ms; 48×24 at extra-hard 54 ms median, 91 ms slowest); the orthogonal four-neighbour field, the hardest to finish, took 46 ms median and 87 ms slowest at 25%. The player deals in a worker, so the page is never held. A reveal or a flag on the 1,024-square board is answered in 4 ms of script on the desk and about 20 ms with the processor slowed fourfold, as a phone's is, and the next frame follows at once (36 ms with 2,400 squares, the most a field may have), so the board is plain buttons still and needs no canvas. The explained `hintFor` takes 8 ms on average and at most 43 ms late in a 32×32 extra-hard field (it was 51 and 400 before 0.4.0).
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
import { DEFAULT_SETTINGS, hugeSettings, newGame } from "@johnmorrisdotca/jirai";
|
|
133
|
+
|
|
134
|
+
const game = newGame({ ...DEFAULT_SETTINGS, ...hugeSettings("hard"), seed: 7 }); // 32 × 32, 211 mines
|
|
135
|
+
```
|
|
136
|
+
|
|
119
137
|
```ts
|
|
120
138
|
import { DEFAULT_SETTINGS, levelSettings, newGame } from "@johnmorrisdotca/jirai";
|
|
121
139
|
|
|
@@ -141,13 +159,17 @@ Orthogonal clues carry less information, so its numbers are smaller at every lev
|
|
|
141
159
|
|
|
142
160
|
The full engine entry is `@johnmorrisdotca/jirai`; four-neighbour helpers are in `@johnmorrisdotca/jirai/orthogonal`. Drawing is `@johnmorrisdotca/jirai/draw`, browser play is `/play`, and the custom element is `/element` or `/element/define`. The [API guide](docs/API.md) lists the entries and public calls.
|
|
143
161
|
|
|
144
|
-
##
|
|
162
|
+
## API
|
|
163
|
+
|
|
164
|
+
Every export of every entry point is in the [API reference](https://johnmorrisdotca.github.io/jirai/api.html), made from the source when the demo is built, and the [API guide](docs/API.md) lists the entries and public calls in prose.
|
|
165
|
+
|
|
166
|
+
## Theming
|
|
145
167
|
|
|
146
168
|
`boardModel(game, options)` returns row-major labelled cells without touching the DOM. The draw entry exports `JIRAI_STYLE` and the board model. The player uses the same theme variables as its SVG and HTML controls; set `material`, `pieces`, and `language` on mount or override the CSS custom properties in your host.
|
|
147
169
|
|
|
148
170
|
Materials are `ivory`, `wood` and `slate`. Marker sets are `flags`, `stones` and `flowers`. They change appearance only; the engine still stores covered, flag, question or open states.
|
|
149
171
|
|
|
150
|
-
## Limits
|
|
172
|
+
## Limits
|
|
151
173
|
|
|
152
174
|
The board is capped at 60 cells per side and 2,400 cells overall. Shapes need at least 9×9; wraparound is rectangular.
|
|
153
175
|
|
|
@@ -170,34 +192,67 @@ The board is capped at 60 cells per side and 2,400 cells overall. Shapes need at
|
|
|
170
192
|
|
|
171
193
|
For synchronous server use, consider running a deal in a worker; a browser deals extra-hard in the board's own worker without a pause.
|
|
172
194
|
|
|
195
|
+
## Browser support
|
|
196
|
+
|
|
173
197
|
The browser player uses ES modules, SVG, custom elements, dialogs and module workers. Serve built files over HTTP; `file:` pages cannot load its worker. The engine and drawing functions do not need DOM globals. Development and tests require Node 22 or later.
|
|
174
198
|
|
|
199
|
+
## Languages
|
|
200
|
+
|
|
201
|
+
The player's words are English and Japanese, chosen with the `language` option or the `lang` attribute: the buttons, the status lines, the hints and the labels read by assistive technology. Corrections to the Japanese are welcome as issues.
|
|
202
|
+
|
|
203
|
+
## Roadmap
|
|
204
|
+
|
|
205
|
+
The engine, the dealer, the hints and the player are in. Nothing else is promised for a date; ideas are welcome in the [issues](https://github.com/johnmorrisdotca/jirai/issues).
|
|
206
|
+
|
|
207
|
+
## Architecture
|
|
208
|
+
|
|
209
|
+
```text
|
|
210
|
+
src/
|
|
211
|
+
├── deduce.ts
|
|
212
|
+
├── draw-entry.ts
|
|
213
|
+
├── draw.ts
|
|
214
|
+
├── element-define.ts
|
|
215
|
+
├── element.ts
|
|
216
|
+
├── enumerate.ts
|
|
217
|
+
├── flood.ts
|
|
218
|
+
├── game.ts
|
|
219
|
+
├── generate.ts
|
|
220
|
+
├── grid.ts
|
|
221
|
+
├── index.ts
|
|
222
|
+
├── jirai.constants.ts
|
|
223
|
+
├── jirai.types.ts
|
|
224
|
+
├── keep.ts
|
|
225
|
+
├── levels.ts
|
|
226
|
+
├── measure.ts
|
|
227
|
+
├── mount.ts
|
|
228
|
+
├── orthogonal.ts
|
|
229
|
+
├── play-entry.ts
|
|
230
|
+
├── random.ts
|
|
231
|
+
├── react.tsx
|
|
232
|
+
├── react.types.ts
|
|
233
|
+
├── repair.ts
|
|
234
|
+
├── shape.ts
|
|
235
|
+
├── solve.ts
|
|
236
|
+
├── strings.ts
|
|
237
|
+
├── style.ts
|
|
238
|
+
├── ui.types.ts
|
|
239
|
+
└── worker.ts
|
|
240
|
+
```
|
|
241
|
+
|
|
175
242
|
## The name
|
|
176
243
|
|
|
177
244
|
*Jirai* (地雷) is Japanese for a land mine, read じらい, said in three beats, *ji-ra-i*. It is made of 地 (*ji*,
|
|
178
245
|
ground) and 雷 (*rai*, thunder): a mine is thunder buried in the ground. Every number on the board is a clue to
|
|
179
246
|
where it lies. ([Wiktionary: 地雷](https://en.wiktionary.org/wiki/地雷).)
|
|
180
247
|
|
|
181
|
-
##
|
|
182
|
-
|
|
183
|
-
```sh
|
|
184
|
-
pnpm install --frozen-lockfile
|
|
185
|
-
pnpm check # lint, types and tests
|
|
186
|
-
pnpm test:package # build and import the actual npm tarball
|
|
187
|
-
pnpm test:demo # browser flows against the built page
|
|
188
|
-
pnpm site # build the standalone page into docs/
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
The standalone game is `docs/index.html`. The preview binds to `127.0.0.1:6713`; see [CONTRIBUTING.md](CONTRIBUTING.md) before changing the engine or player.
|
|
192
|
-
|
|
193
|
-
## Licence
|
|
248
|
+
## Where it comes from
|
|
194
249
|
|
|
195
|
-
|
|
250
|
+
Minesweeper's rules are common property. Everything here, the rules, the dealer, the solver, the hints, the pictures and the words, is written for the package, and no third-party puzzle boards or artwork are included.
|
|
196
251
|
|
|
197
|
-
|
|
252
|
+
### The family
|
|
198
253
|
|
|
199
254
|
<!-- family:start (made by scripts/family-readme.mjs from scripts/family-template.mjs; change those, not this) -->
|
|
200
|
-
Jirai is one of twenty-
|
|
255
|
+
Jirai is one of twenty-four packages, each made for the same site, each at
|
|
201
256
|
[github.com/johnmorrisdotca](https://github.com/johnmorrisdotca). The code of every one is MIT.
|
|
202
257
|
|
|
203
258
|
- [Korokoro](https://github.com/johnmorrisdotca/korokoro) (コロコロ): dice, with notation, exact odds, real sounds and the dice of many games. [Demo](https://johnmorrisdotca.github.io/korokoro/).
|
|
@@ -222,10 +277,32 @@ Jirai is one of twenty-two packages, each made for the same site, each at
|
|
|
222
277
|
- [Tobiishi](https://github.com/johnmorrisdotca/tobiishi) (飛び石): peg solitaire with nine boards and seeded solvable challenges. [Demo](https://johnmorrisdotca.github.io/tobiishi/).
|
|
223
278
|
- [Jirai](https://github.com/johnmorrisdotca/jirai) (地雷): minesweeper on shaped grids with verified no-guess boards. [Demo](https://johnmorrisdotca.github.io/jirai/).
|
|
224
279
|
- [Gunjin](https://github.com/johnmorrisdotca/gunjin) (軍人): five hidden-rank strategy games with pass-the-device play. [Demo](https://johnmorrisdotca.github.io/gunjin/).
|
|
280
|
+
- [Karakuri](https://github.com/johnmorrisdotca/karakuri) (からくり): eight hyper-casual puzzle games, some of them physics: draw a shield, pull pins, cut ropes, slide blocks, pour tubes. [Demo](https://johnmorrisdotca.github.io/karakuri/).
|
|
281
|
+
- [Houseki](https://github.com/johnmorrisdotca/houseki) (宝石): gem and stone matching puzzles: falling triplets, stone collapse, colour chains and gem swap. [Demo](https://johnmorrisdotca.github.io/houseki/).
|
|
225
282
|
|
|
226
|
-
**This package is Jirai.** The demos of all twenty-
|
|
283
|
+
**This package is Jirai.** The demos of all twenty-four share one header and footer, so each links the rest.
|
|
227
284
|
<!-- family:end -->
|
|
228
285
|
|
|
229
|
-
##
|
|
286
|
+
## Development
|
|
287
|
+
|
|
288
|
+
```sh
|
|
289
|
+
pnpm install --frozen-lockfile
|
|
290
|
+
pnpm check # lint, types, tests and the presentation checks
|
|
291
|
+
pnpm test:package # build and import the actual npm tarball
|
|
292
|
+
pnpm test:demo # browser flows against the built page
|
|
293
|
+
pnpm site # build the standalone page into docs/
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
The standalone game is `docs/index.html`. The preview binds to `127.0.0.1:6713`; see [CONTRIBUTING.md](CONTRIBUTING.md) before changing the engine or player.
|
|
297
|
+
|
|
298
|
+
## Contributing
|
|
299
|
+
|
|
300
|
+
Bug reports and pull requests are welcome in the [issues](https://github.com/johnmorrisdotca/jirai/issues). See [CONTRIBUTING.md](CONTRIBUTING.md), the [Code of Conduct](CODE_OF_CONDUCT.md) and the [Security policy](SECURITY.md).
|
|
230
301
|
|
|
231
|
-
|
|
302
|
+
## Changes
|
|
303
|
+
|
|
304
|
+
Every release is written up in [CHANGELOG.md](./CHANGELOG.md).
|
|
305
|
+
|
|
306
|
+
## Licence
|
|
307
|
+
|
|
308
|
+
[MIT](LICENSE) © John Morris. No third-party puzzle boards or artwork are included.
|
package/dist/deduce.d.ts
CHANGED
|
@@ -3,6 +3,6 @@ import type { Deduction, VisibleGame } from "./jirai.types.ts";
|
|
|
3
3
|
* What the clues prove, without reading the answer or trusting a player's
|
|
4
4
|
* flags. A flag is a note; treating it as evidence makes a bad note a bad hint.
|
|
5
5
|
*/
|
|
6
|
-
export declare function deduce(game: VisibleGame, knownMines?: ReadonlySet<number>, enumerate?: boolean): Deduction;
|
|
6
|
+
export declare function deduce(game: VisibleGame, knownMines?: ReadonlySet<number>, enumerate?: boolean, adjacent?: readonly (readonly number[])[]): Deduction;
|
|
7
7
|
/** Keep discovering certain mines until there is a safe cell or nothing more is proved. */
|
|
8
8
|
export declare function hintFor(game: VisibleGame): Deduction;
|
package/dist/deduce.js
CHANGED
|
@@ -4,8 +4,7 @@ import { neighboursOf } from "./grid.js";
|
|
|
4
4
|
* What the clues prove, without reading the answer or trusting a player's
|
|
5
5
|
* flags. A flag is a note; treating it as evidence makes a bad note a bad hint.
|
|
6
6
|
*/
|
|
7
|
-
export function deduce(game, knownMines = new Set(), enumerate = true) {
|
|
8
|
-
const adjacent = neighboursOf(game.settings);
|
|
7
|
+
export function deduce(game, knownMines = new Set(), enumerate = true, adjacent = neighboursOf(game.settings)) {
|
|
9
8
|
const constraints = [];
|
|
10
9
|
const empty = { safe: [], mines: [], reason: "none", sources: [], contradiction: false };
|
|
11
10
|
for (let cell = 0; cell < game.clues.length; cell += 1) {
|
|
@@ -43,10 +42,21 @@ export function deduce(game, knownMines = new Set(), enumerate = true) {
|
|
|
43
42
|
}
|
|
44
43
|
// Compare a smaller clue with a larger one, including the mine counter.
|
|
45
44
|
// Their difference is another exact count. Never subtract mere overlaps.
|
|
46
|
-
const sets = [...constraints, total].map((c) => new Set(c.cells));
|
|
47
45
|
const all = [...constraints, total];
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
const sets = all.map((c) => new Set(c.cells));
|
|
47
|
+
// A constraint can only be inside another that holds its first cell, so each is compared with those alone, in the same order as
|
|
48
|
+
// comparing it with every other. On a large field that is a handful of neighbours instead of every clue on the board.
|
|
49
|
+
const holding = new Map();
|
|
50
|
+
all.forEach((c, index) => { for (const cell of c.cells) {
|
|
51
|
+
const list = holding.get(cell);
|
|
52
|
+
if (list === undefined)
|
|
53
|
+
holding.set(cell, [index]);
|
|
54
|
+
else
|
|
55
|
+
list.push(index);
|
|
56
|
+
} });
|
|
57
|
+
for (let i = 0; i < all.length; i += 1) {
|
|
58
|
+
const inside = all[i].cells.length === 0 ? all.map((_, index) => index) : holding.get(all[i].cells[0]);
|
|
59
|
+
for (const j of inside) {
|
|
50
60
|
if (i === j || all[i].cells.length >= all[j].cells.length)
|
|
51
61
|
continue;
|
|
52
62
|
if (!all[i].cells.every((cell) => sets[j].has(cell)))
|
|
@@ -62,6 +72,7 @@ export function deduce(game, knownMines = new Set(), enumerate = true) {
|
|
|
62
72
|
if (result !== null)
|
|
63
73
|
return result;
|
|
64
74
|
}
|
|
75
|
+
}
|
|
65
76
|
// A whole-board constraint would join otherwise independent components.
|
|
66
77
|
// Local enumeration is conservative; the global counter is used above.
|
|
67
78
|
return enumerate ? enumerateForced(constraints) : empty;
|
|
@@ -69,9 +80,10 @@ export function deduce(game, knownMines = new Set(), enumerate = true) {
|
|
|
69
80
|
/** Keep discovering certain mines until there is a safe cell or nothing more is proved. */
|
|
70
81
|
export function hintFor(game) {
|
|
71
82
|
const known = new Set();
|
|
83
|
+
const adjacent = neighboursOf(game.settings);
|
|
72
84
|
let last = { safe: [], mines: [], reason: "none", sources: [], contradiction: false };
|
|
73
85
|
while (known.size <= game.settings.mines) {
|
|
74
|
-
const result = deduce(game, known);
|
|
86
|
+
const result = deduce(game, known, true, adjacent);
|
|
75
87
|
if (result.contradiction || result.safe.length)
|
|
76
88
|
return result;
|
|
77
89
|
const fresh = result.mines.filter((cell) => !known.has(cell));
|
package/dist/index.d.ts
CHANGED
|
@@ -8,11 +8,11 @@ export * from "./deduce.ts";
|
|
|
8
8
|
export * from "./keep.ts";
|
|
9
9
|
export { seededRandom } from "./random.ts";
|
|
10
10
|
/** Package version, kept in step with the release metadata. */
|
|
11
|
-
export declare const VERSION = "0.
|
|
11
|
+
export declare const VERSION = "0.4.1";
|
|
12
12
|
export { SHAPES, activeCell, activeCells } from "./shape.ts";
|
|
13
13
|
export * from "./orthogonal.ts";
|
|
14
14
|
export { measureBoard } from "./measure.ts";
|
|
15
15
|
export type { Measure } from "./measure.ts";
|
|
16
16
|
export type { ProofKind, ProofTally } from "./solve.ts";
|
|
17
|
-
export { levelNamed, levelSettings } from "./levels.ts";
|
|
18
|
-
export type { LevelSize } from "./levels.ts";
|
|
17
|
+
export { hugeSettings, levelNamed, levelSettings } from "./levels.ts";
|
|
18
|
+
export type { HugeSize, LevelSize } from "./levels.ts";
|
package/dist/index.js
CHANGED
|
@@ -8,8 +8,8 @@ export * from "./deduce.js";
|
|
|
8
8
|
export * from "./keep.js";
|
|
9
9
|
export { seededRandom } from "./random.js";
|
|
10
10
|
/** Package version, kept in step with the release metadata. */
|
|
11
|
-
export const VERSION = "0.
|
|
11
|
+
export const VERSION = "0.4.1";
|
|
12
12
|
export { SHAPES, activeCell, activeCells } from "./shape.js";
|
|
13
13
|
export * from "./orthogonal.js";
|
|
14
14
|
export { measureBoard } from "./measure.js";
|
|
15
|
-
export { levelNamed, levelSettings } from "./levels.js";
|
|
15
|
+
export { hugeSettings, levelNamed, levelSettings } from "./levels.js";
|
|
@@ -45,6 +45,21 @@ export declare const LEVEL_SIZES: Record<(typeof LEVELS)[number], {
|
|
|
45
45
|
height: number;
|
|
46
46
|
mines: number;
|
|
47
47
|
}>;
|
|
48
|
+
/**
|
|
49
|
+
* The huge fields: four times the area of a 16×16 (the medium level), 1,024 to 1,152 squares, for a long solve. The same
|
|
50
|
+
* share of mines as a level, see `hugeSettings`. They are dealt, proved and drawn like any other field: a no-guess deal takes
|
|
51
|
+
* a few milliseconds to a tenth of a second here, and 2,400 squares is the most `validSettings` accepts.
|
|
52
|
+
*/
|
|
53
|
+
export declare const HUGE_SIZES: readonly [{
|
|
54
|
+
readonly width: 32;
|
|
55
|
+
readonly height: 32;
|
|
56
|
+
}, {
|
|
57
|
+
readonly width: 48;
|
|
58
|
+
readonly height: 24;
|
|
59
|
+
}, {
|
|
60
|
+
readonly width: 24;
|
|
61
|
+
readonly height: 48;
|
|
62
|
+
}];
|
|
48
63
|
/** Common minefield dimensions and mine counts. The levels, their older names, and two shapes of field. */
|
|
49
64
|
export declare const PRESETS: {
|
|
50
65
|
readonly beginner: {
|
package/dist/jirai.constants.js
CHANGED
|
@@ -24,6 +24,12 @@ export const LEVEL_SIZES = {
|
|
|
24
24
|
hard: { width: 30, height: 16, mines: 99 },
|
|
25
25
|
"extra-hard": { width: 40, height: 24, mines: 240 },
|
|
26
26
|
};
|
|
27
|
+
/**
|
|
28
|
+
* The huge fields: four times the area of a 16×16 (the medium level), 1,024 to 1,152 squares, for a long solve. The same
|
|
29
|
+
* share of mines as a level, see `hugeSettings`. They are dealt, proved and drawn like any other field: a no-guess deal takes
|
|
30
|
+
* a few milliseconds to a tenth of a second here, and 2,400 squares is the most `validSettings` accepts.
|
|
31
|
+
*/
|
|
32
|
+
export const HUGE_SIZES = [{ width: 32, height: 32 }, { width: 48, height: 24 }, { width: 24, height: 48 }];
|
|
27
33
|
/** Common minefield dimensions and mine counts. The levels, their older names, and two shapes of field. */
|
|
28
34
|
export const PRESETS = {
|
|
29
35
|
...LEVEL_SIZES,
|
package/dist/levels.d.ts
CHANGED
|
@@ -14,3 +14,19 @@ export declare function levelSettings(level: Level | LevelAlias, board?: {
|
|
|
14
14
|
grid?: Grid;
|
|
15
15
|
shape?: Settings["shape"];
|
|
16
16
|
}): LevelSize;
|
|
17
|
+
/** The outline of a huge field: one of `HUGE_SIZES`' width and height. */
|
|
18
|
+
export type HugeSize = {
|
|
19
|
+
width: number;
|
|
20
|
+
height: number;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* The size and mine count of a level on a huge field: a field of `HUGE_SIZES` (32 × 32 unless `size` says another of them),
|
|
24
|
+
* with the level's own share of mines over its squares. Easy is 12% mines, medium 16%, hard 21% and extra-hard 25%, as
|
|
25
|
+
* on the level's own field, and a heart, star or hexagon outline keeps that share over the squares it has left. Every
|
|
26
|
+
* field is still dealt and proved to need no guess, on every grid. Throws `RangeError` for a name that is no level or a size that is not huge.
|
|
27
|
+
*/
|
|
28
|
+
export declare function hugeSettings(level: Level | LevelAlias, board?: {
|
|
29
|
+
grid?: Grid;
|
|
30
|
+
shape?: Settings["shape"];
|
|
31
|
+
size?: HugeSize;
|
|
32
|
+
}): LevelSize;
|
package/dist/levels.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LEVEL_ALIASES, LEVEL_SIZES, LEVELS } from "./jirai.constants.js";
|
|
1
|
+
import { HUGE_SIZES, LEVEL_ALIASES, LEVEL_SIZES, LEVELS } from "./jirai.constants.js";
|
|
2
2
|
import { activeCells } from "./shape.js";
|
|
3
3
|
/**
|
|
4
4
|
* The level a name means, or null. Accepts `easy`, `medium`, `hard` and `extra-hard` (also written `extra hard`,
|
|
@@ -27,3 +27,21 @@ export function levelSettings(level, board = {}) {
|
|
|
27
27
|
const cells = activeCells(probe).length;
|
|
28
28
|
return { width: size.width, height: size.height, mines: Math.max(1, Math.min(cells - 10, Math.round(size.mines / (size.width * size.height) * cells))) };
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* The size and mine count of a level on a huge field: a field of `HUGE_SIZES` (32 × 32 unless `size` says another of them),
|
|
32
|
+
* with the level's own share of mines over its squares. Easy is 12% mines, medium 16%, hard 21% and extra-hard 25%, as
|
|
33
|
+
* on the level's own field, and a heart, star or hexagon outline keeps that share over the squares it has left. Every
|
|
34
|
+
* field is still dealt and proved to need no guess, on every grid. Throws `RangeError` for a name that is no level or a size that is not huge.
|
|
35
|
+
*/
|
|
36
|
+
export function hugeSettings(level, board = {}) {
|
|
37
|
+
const named = levelNamed(level);
|
|
38
|
+
if (named === null)
|
|
39
|
+
throw new RangeError(`Unknown level: ${String(level)}`);
|
|
40
|
+
const size = board.size ?? HUGE_SIZES[0];
|
|
41
|
+
if (!HUGE_SIZES.some((huge) => huge.width === size.width && huge.height === size.height))
|
|
42
|
+
throw new RangeError(`Not a huge size: ${size.width} × ${size.height}`);
|
|
43
|
+
const own = LEVEL_SIZES[named];
|
|
44
|
+
const probe = { width: size.width, height: size.height, mines: 1, grid: board.grid ?? "square", shape: board.shape, noGuess: true, opening: "clear", seed: 1 };
|
|
45
|
+
const cells = activeCells(probe).length;
|
|
46
|
+
return { width: size.width, height: size.height, mines: Math.max(1, Math.min(cells - 10, Math.round(own.mines / (own.width * own.height) * cells))) };
|
|
47
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnmorrisdotca/jirai",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Minesweeper on square, orthogonal, hexagonal and wraparound boards: four levels up to extra-hard, seeded games, a safe opening, verified no-guess boards, explained hints
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "Minesweeper on square, orthogonal, hexagonal and wraparound boards: four levels up to extra-hard, seeded games, a safe opening, verified no-guess boards, explained hints and a playable board for any page. Zero runtime dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "John Morris",
|
|
@@ -54,30 +54,37 @@
|
|
|
54
54
|
"exports": {
|
|
55
55
|
".": {
|
|
56
56
|
"types": "./dist/index.d.ts",
|
|
57
|
+
"import": "./dist/index.js",
|
|
57
58
|
"default": "./dist/index.js"
|
|
58
59
|
},
|
|
59
60
|
"./orthogonal": {
|
|
60
61
|
"types": "./dist/orthogonal.d.ts",
|
|
62
|
+
"import": "./dist/orthogonal.js",
|
|
61
63
|
"default": "./dist/orthogonal.js"
|
|
62
64
|
},
|
|
63
65
|
"./play": {
|
|
64
66
|
"types": "./dist/play-entry.d.ts",
|
|
67
|
+
"import": "./dist/play-entry.js",
|
|
65
68
|
"default": "./dist/play-entry.js"
|
|
66
69
|
},
|
|
67
70
|
"./draw": {
|
|
68
71
|
"types": "./dist/draw-entry.d.ts",
|
|
72
|
+
"import": "./dist/draw-entry.js",
|
|
69
73
|
"default": "./dist/draw-entry.js"
|
|
70
74
|
},
|
|
71
75
|
"./element": {
|
|
72
76
|
"types": "./dist/element.d.ts",
|
|
77
|
+
"import": "./dist/element.js",
|
|
73
78
|
"default": "./dist/element.js"
|
|
74
79
|
},
|
|
75
80
|
"./element/define": {
|
|
76
81
|
"types": "./dist/element-define.d.ts",
|
|
82
|
+
"import": "./dist/element-define.js",
|
|
77
83
|
"default": "./dist/element-define.js"
|
|
78
84
|
},
|
|
79
85
|
"./react": {
|
|
80
86
|
"types": "./dist/react.d.ts",
|
|
87
|
+
"import": "./dist/react.js",
|
|
81
88
|
"default": "./dist/react.js"
|
|
82
89
|
}
|
|
83
90
|
},
|
|
@@ -121,8 +128,8 @@
|
|
|
121
128
|
"eslint": "^9.0.0",
|
|
122
129
|
"typescript-eslint": "^8.0.0",
|
|
123
130
|
"typescript": "^5.9.0",
|
|
124
|
-
"vitest": "^
|
|
125
|
-
"@playwright/test": "^1.
|
|
131
|
+
"vitest": "^5.0.0",
|
|
132
|
+
"@playwright/test": "^1.63.0",
|
|
126
133
|
"@types/react": "^19.0.0",
|
|
127
134
|
"react": "^19.0.0"
|
|
128
135
|
},
|
package/src/deduce.ts
CHANGED
|
@@ -6,8 +6,7 @@ import type { Constraint, Deduction, VisibleGame } from "./jirai.types.ts";
|
|
|
6
6
|
* What the clues prove, without reading the answer or trusting a player's
|
|
7
7
|
* flags. A flag is a note; treating it as evidence makes a bad note a bad hint.
|
|
8
8
|
*/
|
|
9
|
-
export function deduce(game: VisibleGame, knownMines: ReadonlySet<number> = new Set(), enumerate = true): Deduction {
|
|
10
|
-
const adjacent = neighboursOf(game.settings);
|
|
9
|
+
export function deduce(game: VisibleGame, knownMines: ReadonlySet<number> = new Set(), enumerate = true, adjacent: readonly (readonly number[])[] = neighboursOf(game.settings)): Deduction {
|
|
11
10
|
const constraints: Constraint[] = [];
|
|
12
11
|
const empty: Deduction = { safe: [], mines: [], reason: "none", sources: [], contradiction: false };
|
|
13
12
|
for (let cell = 0; cell < game.clues.length; cell += 1) {
|
|
@@ -31,19 +30,26 @@ export function deduce(game: VisibleGame, knownMines: ReadonlySet<number> = new
|
|
|
31
30
|
if (unknown.length) { const result = forced(total, "total"); if (result !== null) return result; }
|
|
32
31
|
// Compare a smaller clue with a larger one, including the mine counter.
|
|
33
32
|
// Their difference is another exact count. Never subtract mere overlaps.
|
|
34
|
-
const sets = [...constraints, total].map((c) => new Set(c.cells));
|
|
35
33
|
const all = [...constraints, total];
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
34
|
+
const sets = all.map((c) => new Set(c.cells));
|
|
35
|
+
// A constraint can only be inside another that holds its first cell, so each is compared with those alone, in the same order as
|
|
36
|
+
// comparing it with every other. On a large field that is a handful of neighbours instead of every clue on the board.
|
|
37
|
+
const holding = new Map<number, number[]>();
|
|
38
|
+
all.forEach((c, index) => { for (const cell of c.cells) { const list = holding.get(cell); if (list === undefined) holding.set(cell, [index]); else list.push(index); } });
|
|
39
|
+
for (let i = 0; i < all.length; i += 1) {
|
|
40
|
+
const inside = all[i]!.cells.length === 0 ? all.map((_, index) => index) : holding.get(all[i]!.cells[0]!)!;
|
|
41
|
+
for (const j of inside) {
|
|
42
|
+
if (i === j || all[i]!.cells.length >= all[j]!.cells.length) continue;
|
|
43
|
+
if (!all[i]!.cells.every((cell) => sets[j]!.has(cell))) continue;
|
|
44
|
+
const c: Constraint = {
|
|
45
|
+
cells: all[j]!.cells.filter((cell) => !sets[i]!.has(cell)),
|
|
46
|
+
mines: all[j]!.mines - all[i]!.mines,
|
|
47
|
+
sources: [...new Set([...all[i]!.sources, ...all[j]!.sources])],
|
|
48
|
+
};
|
|
49
|
+
if (c.mines < 0 || c.mines > c.cells.length) return { ...empty, contradiction: true };
|
|
50
|
+
const result = forced(c, j === all.length - 1 ? "total" : "overlap");
|
|
51
|
+
if (result !== null) return result;
|
|
52
|
+
}
|
|
47
53
|
}
|
|
48
54
|
// A whole-board constraint would join otherwise independent components.
|
|
49
55
|
// Local enumeration is conservative; the global counter is used above.
|
|
@@ -53,9 +59,10 @@ export function deduce(game: VisibleGame, knownMines: ReadonlySet<number> = new
|
|
|
53
59
|
/** Keep discovering certain mines until there is a safe cell or nothing more is proved. */
|
|
54
60
|
export function hintFor(game: VisibleGame): Deduction {
|
|
55
61
|
const known = new Set<number>();
|
|
62
|
+
const adjacent = neighboursOf(game.settings);
|
|
56
63
|
let last: Deduction = { safe: [], mines: [], reason: "none", sources: [], contradiction: false };
|
|
57
64
|
while (known.size <= game.settings.mines) {
|
|
58
|
-
const result = deduce(game, known);
|
|
65
|
+
const result = deduce(game, known, true, adjacent);
|
|
59
66
|
if (result.contradiction || result.safe.length) return result;
|
|
60
67
|
const fresh = result.mines.filter((cell) => !known.has(cell));
|
|
61
68
|
if (!fresh.length) return last;
|
package/src/family.test.js
CHANGED
|
@@ -15,7 +15,9 @@ const id = pkg.name.replace(/^@[^/]+\//, "");
|
|
|
15
15
|
|
|
16
16
|
// The recorded hashes. The template's is the one that says every demo's header and footer, and every README's
|
|
17
17
|
// list of the family, are the same text.
|
|
18
|
-
|
|
18
|
+
// The template of 2026-10-05 lists twenty-four packages, Karakuri and Houseki included. The family's list is swept again, in every
|
|
19
|
+
// repository at once, when a package is added to it, and this hash is then the new one.
|
|
20
|
+
const TEMPLATE = { version: "2026-10-05", sha256: "a2dc81808be980438bdef8b91f5c0bbff920a739bc50930cd4632cb017c8fa48" };
|
|
19
21
|
const FILES = {
|
|
20
22
|
"scripts/family-readme.mjs": "3c9d5b2cbf17a92d31bced98edac7f544616edb0dff90bf2d141722a9d4516c5",
|
|
21
23
|
"scripts/release-notes.mjs": "efab0fb78ad05973a8885624c0d2ce3b458b55799c11eabaa5176603ce8cd1e9",
|
|
@@ -32,7 +34,7 @@ describe("the family template", () => {
|
|
|
32
34
|
it("lists every package of the family, in order, each with its Japanese name and a line on it", () => {
|
|
33
35
|
expect(FAMILY.map((one) => one.id)).toEqual([
|
|
34
36
|
"korokoro", "kyuubu", "hitotsu", "toranpu", "tane", "narabe", "tenka", "kumimoji", "tsunagi", "jarajara",
|
|
35
|
-
"suido", "domino", "kotoba", "sugoroku", "kazu", "meikyuu", "hikidashi", "chizu", "bushu", "tobiishi", "jirai", "gunjin",
|
|
37
|
+
"suido", "domino", "kotoba", "sugoroku", "kazu", "meikyuu", "hikidashi", "chizu", "bushu", "tobiishi", "jirai", "gunjin", "karakuri", "houseki",
|
|
36
38
|
]);
|
|
37
39
|
for (const one of FAMILY) {
|
|
38
40
|
expect(one.name, one.id).toBe(one.id[0].toUpperCase() + one.id.slice(1));
|
|
@@ -75,13 +77,27 @@ describe("the README's family", () => {
|
|
|
75
77
|
});
|
|
76
78
|
});
|
|
77
79
|
|
|
80
|
+
describe("the README's version pins", () => {
|
|
81
|
+
it("name this package's major version, never an older one: a CDN address says @2 once the package is 2.x", () => {
|
|
82
|
+
const major = pkg.version.split(".")[0];
|
|
83
|
+
const pins = read("README.md")
|
|
84
|
+
.split(`${pkg.name}@`)
|
|
85
|
+
.slice(1)
|
|
86
|
+
.map((rest) => /^\d+/.exec(rest)?.[0])
|
|
87
|
+
.filter((pin) => pin !== undefined);
|
|
88
|
+
for (const pin of pins) expect(pin, `${pkg.name}@${pin} in README.md`).toBe(major);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
78
92
|
describe("the release notes", () => {
|
|
79
93
|
it("are the changelog's section for the version, which the Release workflow puts on the GitHub release", () => {
|
|
80
94
|
const log = "# Changelog\n\n## [Unreleased]\n\n## [1.2.0] - 2026-01-02\n\n### Added\n\n- A thing.\n\n## [1.1.0] - 2026-01-01\n\n- Older.\n\n[Unreleased]: https://example.test\n";
|
|
81
95
|
expect(releaseNotes(log, "1.2.0")).toBe("### Added\n\n- A thing.");
|
|
82
96
|
expect(releaseNotes(log, "1.1.0")).toBe("- Older.");
|
|
83
97
|
expect(releaseNotes(log, "9.9.9")).toBeNull();
|
|
84
|
-
|
|
98
|
+
// Until a version is published its notes are under [Unreleased]; the release takes the heading with the version and the date.
|
|
99
|
+
const notes = releaseNotes(read("CHANGELOG.md"), pkg.version) ?? releaseNotes(read("CHANGELOG.md"), "Unreleased");
|
|
100
|
+
expect(notes?.length, `CHANGELOG.md has nothing under ## [${pkg.version}] or ## [Unreleased]`).toBeGreaterThan(40);
|
|
85
101
|
const workflow = read(".github/workflows/release.yml");
|
|
86
102
|
expect(workflow).toContain("scripts/release-notes.mjs");
|
|
87
103
|
expect(workflow).not.toContain("See CHANGELOG.md.");
|
|
@@ -90,7 +106,8 @@ describe("the release notes", () => {
|
|
|
90
106
|
it("come from a changelog in Keep a Changelog form: an Unreleased heading, then each version in brackets with its date", () => {
|
|
91
107
|
const log = read("CHANGELOG.md");
|
|
92
108
|
expect(log).toContain("\n## [Unreleased]\n");
|
|
93
|
-
|
|
109
|
+
// Before the first release there is no versioned heading yet, only Unreleased.
|
|
110
|
+
if (/^## \[\d/m.test(log)) expect(log).toMatch(/^## \[\d+\.\d+\.\d+\] - \d{4}-\d{2}-\d{2}$/m);
|
|
94
111
|
expect(log).not.toMatch(/^## \d/m);
|
|
95
112
|
});
|
|
96
113
|
});
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ export * from "./deduce.ts";
|
|
|
8
8
|
export * from "./keep.ts";
|
|
9
9
|
export { seededRandom } from "./random.ts";
|
|
10
10
|
/** Package version, kept in step with the release metadata. */
|
|
11
|
-
export const VERSION = "0.
|
|
11
|
+
export const VERSION = "0.4.1";
|
|
12
12
|
|
|
13
13
|
export { SHAPES, activeCell, activeCells } from "./shape.ts";
|
|
14
14
|
|
|
@@ -16,5 +16,5 @@ export * from "./orthogonal.ts";
|
|
|
16
16
|
export { measureBoard } from "./measure.ts";
|
|
17
17
|
export type { Measure } from "./measure.ts";
|
|
18
18
|
export type { ProofKind, ProofTally } from "./solve.ts";
|
|
19
|
-
export { levelNamed, levelSettings } from "./levels.ts";
|
|
20
|
-
export type { LevelSize } from "./levels.ts";
|
|
19
|
+
export { hugeSettings, levelNamed, levelSettings } from "./levels.ts";
|
|
20
|
+
export type { HugeSize, LevelSize } from "./levels.ts";
|
package/src/jirai.constants.ts
CHANGED
|
@@ -26,6 +26,12 @@ export const LEVEL_SIZES: Record<(typeof LEVELS)[number], { width: number; heigh
|
|
|
26
26
|
hard: { width: 30, height: 16, mines: 99 },
|
|
27
27
|
"extra-hard": { width: 40, height: 24, mines: 240 },
|
|
28
28
|
};
|
|
29
|
+
/**
|
|
30
|
+
* The huge fields: four times the area of a 16×16 (the medium level), 1,024 to 1,152 squares, for a long solve. The same
|
|
31
|
+
* share of mines as a level, see `hugeSettings`. They are dealt, proved and drawn like any other field: a no-guess deal takes
|
|
32
|
+
* a few milliseconds to a tenth of a second here, and 2,400 squares is the most `validSettings` accepts.
|
|
33
|
+
*/
|
|
34
|
+
export const HUGE_SIZES = [{ width: 32, height: 32 }, { width: 48, height: 24 }, { width: 24, height: 48 }] as const satisfies readonly { width: number; height: number }[];
|
|
29
35
|
/** Common minefield dimensions and mine counts. The levels, their older names, and two shapes of field. */
|
|
30
36
|
export const PRESETS = {
|
|
31
37
|
...LEVEL_SIZES,
|
package/src/levels.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LEVEL_ALIASES, LEVEL_SIZES, LEVELS } from "./jirai.constants.ts";
|
|
1
|
+
import { HUGE_SIZES, LEVEL_ALIASES, LEVEL_SIZES, LEVELS } from "./jirai.constants.ts";
|
|
2
2
|
import { activeCells } from "./shape.ts";
|
|
3
3
|
import type { Grid, Level, LevelAlias, Settings } from "./jirai.types.ts";
|
|
4
4
|
|
|
@@ -29,3 +29,23 @@ export function levelSettings(level: Level | LevelAlias, board: { grid?: Grid; s
|
|
|
29
29
|
const cells = activeCells(probe).length;
|
|
30
30
|
return { width: size.width, height: size.height, mines: Math.max(1, Math.min(cells - 10, Math.round(size.mines / (size.width * size.height) * cells))) };
|
|
31
31
|
}
|
|
32
|
+
|
|
33
|
+
/** The outline of a huge field: one of `HUGE_SIZES`' width and height. */
|
|
34
|
+
export type HugeSize = { width: number; height: number };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The size and mine count of a level on a huge field: a field of `HUGE_SIZES` (32 × 32 unless `size` says another of them),
|
|
38
|
+
* with the level's own share of mines over its squares. Easy is 12% mines, medium 16%, hard 21% and extra-hard 25%, as
|
|
39
|
+
* on the level's own field, and a heart, star or hexagon outline keeps that share over the squares it has left. Every
|
|
40
|
+
* field is still dealt and proved to need no guess, on every grid. Throws `RangeError` for a name that is no level or a size that is not huge.
|
|
41
|
+
*/
|
|
42
|
+
export function hugeSettings(level: Level | LevelAlias, board: { grid?: Grid; shape?: Settings["shape"]; size?: HugeSize } = {}): LevelSize {
|
|
43
|
+
const named = levelNamed(level);
|
|
44
|
+
if (named === null) throw new RangeError(`Unknown level: ${String(level)}`);
|
|
45
|
+
const size = board.size ?? HUGE_SIZES[0];
|
|
46
|
+
if (!HUGE_SIZES.some((huge) => huge.width === size.width && huge.height === size.height)) throw new RangeError(`Not a huge size: ${size.width} × ${size.height}`);
|
|
47
|
+
const own = LEVEL_SIZES[named];
|
|
48
|
+
const probe = { width: size.width, height: size.height, mines: 1, grid: board.grid ?? "square", shape: board.shape, noGuess: true, opening: "clear", seed: 1 } as const;
|
|
49
|
+
const cells = activeCells(probe).length;
|
|
50
|
+
return { width: size.width, height: size.height, mines: Math.max(1, Math.min(cells - 10, Math.round(own.mines / (own.width * own.height) * cells))) };
|
|
51
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// readme.test.js: the README has the sections every package of the family has, in the family's order, each with something in it.
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
const readme = readFileSync("README.md", "utf8").replace(/\r\n/g, "\n");
|
|
6
|
+
const HOUSE = [
|
|
7
|
+
"In 30 seconds", "Who it is for", "Features", "Use it in your project", "API", "Theming", "Limits", "Browser support",
|
|
8
|
+
"Languages", "Roadmap", "Architecture", "The name", "Where it comes from", "Development", "Contributing", "Changes", "Licence",
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
describe("the README's shape", () => {
|
|
12
|
+
it("has the family's sections, in the family's order, each with something in it", () => {
|
|
13
|
+
const headings = [...readme.matchAll(/^## (.+)$/gm)].map((match) => match[1]);
|
|
14
|
+
let from = 0;
|
|
15
|
+
for (const heading of HOUSE) {
|
|
16
|
+
const at = headings.indexOf(heading, from);
|
|
17
|
+
expect(at, `README.md has no "## ${heading}" after the section before it`).toBeGreaterThanOrEqual(from);
|
|
18
|
+
from = at + 1;
|
|
19
|
+
const start = readme.indexOf(`\n## ${heading}\n`) + 1;
|
|
20
|
+
const next = readme.indexOf("\n## ", start + 4);
|
|
21
|
+
const body = readme.slice(start + heading.length + 4, next < 0 ? undefined : next).trim();
|
|
22
|
+
expect(body.length, `"## ${heading}" is empty`).toBeGreaterThan(20);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("puts the family under \"Where it comes from\", one level down", () => {
|
|
27
|
+
const from = readme.indexOf("\n## Where it comes from\n");
|
|
28
|
+
const family = readme.indexOf("\n### The family\n");
|
|
29
|
+
const next = readme.indexOf("\n## Development\n");
|
|
30
|
+
expect(from).toBeGreaterThan(0);
|
|
31
|
+
expect(family).toBeGreaterThan(from);
|
|
32
|
+
expect(family).toBeLessThan(next);
|
|
33
|
+
});
|
|
34
|
+
});
|