@johnmorrisdotca/jirai 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/README.md +172 -79
  3. package/dist/element.d.ts +2 -0
  4. package/dist/element.js +2 -0
  5. package/dist/game.d.ts +2 -1
  6. package/dist/game.js +2 -1
  7. package/dist/generate.d.ts +7 -1
  8. package/dist/generate.js +74 -43
  9. package/dist/grid.d.ts +2 -0
  10. package/dist/grid.js +2 -0
  11. package/dist/index.d.ts +7 -1
  12. package/dist/index.js +4 -1
  13. package/dist/jirai.constants.d.ts +55 -10
  14. package/dist/jirai.constants.js +28 -5
  15. package/dist/jirai.types.d.ts +24 -1
  16. package/dist/levels.d.ts +16 -0
  17. package/dist/levels.js +29 -0
  18. package/dist/measure.d.ts +28 -0
  19. package/dist/measure.js +21 -0
  20. package/dist/orthogonal.d.ts +1 -0
  21. package/dist/react.d.ts +1 -1
  22. package/dist/react.js +1 -1
  23. package/dist/react.types.d.ts +1 -0
  24. package/dist/repair.d.ts +14 -0
  25. package/dist/repair.js +112 -0
  26. package/dist/shape.d.ts +2 -0
  27. package/dist/shape.js +2 -0
  28. package/dist/solve.d.ts +79 -0
  29. package/dist/solve.js +336 -0
  30. package/dist/strings.d.ts +2 -0
  31. package/dist/strings.js +2 -0
  32. package/dist/ui.types.d.ts +5 -0
  33. package/package.json +37 -9
  34. package/src/element.ts +2 -0
  35. package/src/family.test.js +105 -0
  36. package/src/game.ts +2 -1
  37. package/src/generate.ts +60 -33
  38. package/src/grid.ts +2 -0
  39. package/src/index.ts +7 -1
  40. package/src/jirai.constants.ts +28 -5
  41. package/src/jirai.types.ts +23 -2
  42. package/src/levels.ts +31 -0
  43. package/src/measure.ts +44 -0
  44. package/src/orthogonal.ts +1 -0
  45. package/src/react.tsx +1 -1
  46. package/src/react.types.ts +1 -0
  47. package/src/repair.ts +88 -0
  48. package/src/shape.ts +2 -0
  49. package/src/solve.ts +253 -0
  50. package/src/strings.ts +2 -0
  51. package/src/ui.types.ts +5 -0
  52. package/src/version.test.js +9 -0
  53. package/src/deduce.test.ts +0 -47
  54. package/src/game.test.ts +0 -172
  55. package/src/shape.test.ts +0 -56
package/dist/solve.js ADDED
@@ -0,0 +1,336 @@
1
+ import { enumerateForced } from "./enumerate.js";
2
+ import { neighboursOf } from "./grid.js";
3
+ import { activeCell } from "./shape.js";
4
+ const COVERED = 0, OPEN = 1, MINE = 2, OUTSIDE = 3;
5
+ /**
6
+ * The same deductions as `deduce` (one clue, two overlapping clues, the mine counter, exact enumeration of a
7
+ * small frontier), run to a fixed point over one board in a single pass instead of one deduction per scan of the
8
+ * whole field. A deduction found here is a deduction `deduce` would find; the order differs, the closure does not.
9
+ * Built once for a set of settings, then `run` as often as needed on different mine layouts.
10
+ */
11
+ export class Solver {
12
+ settings;
13
+ adjacent;
14
+ cells;
15
+ safeTotal;
16
+ mineTotal;
17
+ state;
18
+ start;
19
+ need;
20
+ unknown;
21
+ chain;
22
+ dirty;
23
+ searched;
24
+ marks;
25
+ seen;
26
+ visit;
27
+ queue = [];
28
+ stack = [];
29
+ clues = new Int8Array(0);
30
+ stamp = 0;
31
+ covered = 0;
32
+ left = 0;
33
+ opened = 0;
34
+ free = 0;
35
+ deepestMine = 0;
36
+ depth = 0;
37
+ broken = false;
38
+ searching = true;
39
+ proved = { count: 0, overlap: 0, total: 0, enumeration: 0 };
40
+ constructor(settings, adjacent = neighboursOf(settings)) {
41
+ this.settings = settings;
42
+ this.adjacent = adjacent;
43
+ this.cells = settings.width * settings.height;
44
+ this.start = Uint8Array.from({ length: this.cells }, (_, cell) => activeCell(settings, cell) ? COVERED : OUTSIDE);
45
+ this.safeTotal = this.start.reduce((sum, state) => sum + (state === COVERED ? 1 : 0), 0) - settings.mines;
46
+ this.mineTotal = settings.mines;
47
+ this.state = new Uint8Array(this.cells);
48
+ this.need = new Int8Array(this.cells);
49
+ this.unknown = new Int8Array(this.cells);
50
+ this.chain = new Int16Array(this.cells);
51
+ this.dirty = new Uint8Array(this.cells);
52
+ this.searched = new Uint8Array(this.cells);
53
+ this.marks = new Int32Array(this.cells);
54
+ this.seen = new Int32Array(this.cells);
55
+ this.visit = new Int32Array(this.cells);
56
+ }
57
+ /** Clues of a layout: -1 for a mine, -2 outside the shape, otherwise the count of neighbouring mines. */
58
+ clueFor(mines, into = new Int8Array(this.cells)) {
59
+ for (let cell = 0; cell < this.cells; cell += 1) {
60
+ if (this.start[cell] === OUTSIDE) {
61
+ into[cell] = -2;
62
+ continue;
63
+ }
64
+ if (mines[cell]) {
65
+ into[cell] = -1;
66
+ continue;
67
+ }
68
+ let count = 0;
69
+ for (const n of this.adjacent[cell])
70
+ if (mines[n])
71
+ count += 1;
72
+ into[cell] = count;
73
+ }
74
+ return into;
75
+ }
76
+ /** Play the opening at `first` and every deduction after it. `clues` is read, never changed. */
77
+ run(clues, first, enumerate = true) {
78
+ this.clues = clues;
79
+ this.state.set(this.start);
80
+ this.need.fill(0);
81
+ this.unknown.fill(0);
82
+ this.chain.fill(0);
83
+ this.dirty.fill(0);
84
+ this.searched.fill(0);
85
+ this.queue.length = 0;
86
+ this.covered = this.safeTotal + this.mineTotal;
87
+ this.left = this.mineTotal;
88
+ this.opened = 0;
89
+ this.free = 0;
90
+ this.deepestMine = 0;
91
+ this.depth = 0;
92
+ this.broken = false;
93
+ this.proved = { count: 0, overlap: 0, total: 0, enumeration: 0 };
94
+ if ((clues[first] ?? -1) < 0)
95
+ return this.report();
96
+ this.reveal(first, 0, null);
97
+ this.free = this.opened;
98
+ for (;;) {
99
+ while (this.queue.length > 0 && !this.broken) {
100
+ const cell = this.queue.pop();
101
+ this.dirty[cell] = 0;
102
+ while (this.step(cell)) { /* a clue may settle more than once */ }
103
+ }
104
+ if (this.broken || this.opened === this.safeTotal || this.covered === 0)
105
+ break;
106
+ if (this.counter())
107
+ continue;
108
+ if (enumerate && this.enumerate())
109
+ continue;
110
+ break;
111
+ }
112
+ return this.report();
113
+ }
114
+ report() {
115
+ const solved = !this.broken && this.opened === this.safeTotal;
116
+ return { solved, remaining: this.safeTotal - this.opened, unresolved: this.covered, opened: this.opened, free: this.free, proved: { ...this.proved }, depth: this.depth };
117
+ }
118
+ touch(cell) {
119
+ this.searched[cell] = 0;
120
+ if (!this.dirty[cell]) {
121
+ this.dirty[cell] = 1;
122
+ this.queue.push(cell);
123
+ }
124
+ }
125
+ /** Open a cell known to be safe, and flood through every zero. `kind` null is the free opening. */
126
+ reveal(start, depth, kind) {
127
+ const stack = this.stack;
128
+ stack.length = 0;
129
+ stack.push(start);
130
+ let first = true;
131
+ while (stack.length > 0) {
132
+ const cell = stack.pop();
133
+ if (this.state[cell] !== COVERED)
134
+ continue;
135
+ if (this.clues[cell] < 0) {
136
+ this.broken = true;
137
+ return;
138
+ }
139
+ if (first && kind !== null) {
140
+ this.proved[kind] += 1;
141
+ if (depth > this.depth)
142
+ this.depth = depth;
143
+ }
144
+ first = false;
145
+ this.state[cell] = OPEN;
146
+ this.covered -= 1;
147
+ this.opened += 1;
148
+ let mines = 0, unknown = 0;
149
+ for (const n of this.adjacent[cell]) {
150
+ const s = this.state[n];
151
+ if (s === MINE)
152
+ mines += 1;
153
+ else if (s === COVERED)
154
+ unknown += 1;
155
+ else if (s === OPEN) {
156
+ this.unknown[n] -= 1;
157
+ if (depth > this.chain[n])
158
+ this.chain[n] = depth;
159
+ this.touch(n);
160
+ }
161
+ }
162
+ this.need[cell] = this.clues[cell] - mines;
163
+ this.unknown[cell] = unknown;
164
+ this.chain[cell] = depth;
165
+ if (this.clues[cell] === 0) {
166
+ for (const n of this.adjacent[cell])
167
+ if (this.state[n] === COVERED)
168
+ stack.push(n);
169
+ }
170
+ else if (unknown > 0)
171
+ this.touch(cell);
172
+ }
173
+ }
174
+ flag(cell, depth, kind) {
175
+ if (this.state[cell] !== COVERED)
176
+ return;
177
+ if (this.clues[cell] >= 0) {
178
+ this.broken = true;
179
+ return;
180
+ }
181
+ this.state[cell] = MINE;
182
+ this.covered -= 1;
183
+ this.left -= 1;
184
+ this.proved[kind] += 1;
185
+ if (depth > this.depth)
186
+ this.depth = depth;
187
+ if (depth > this.deepestMine)
188
+ this.deepestMine = depth;
189
+ for (const n of this.adjacent[cell])
190
+ if (this.state[n] === OPEN) {
191
+ this.unknown[n] -= 1;
192
+ this.need[n] -= 1;
193
+ if (depth > this.chain[n])
194
+ this.chain[n] = depth;
195
+ this.touch(n);
196
+ }
197
+ }
198
+ settle(cells, safe, depth, kind) {
199
+ for (const cell of cells) {
200
+ if (safe)
201
+ this.reveal(cell, depth, kind);
202
+ else
203
+ this.flag(cell, depth, kind);
204
+ }
205
+ }
206
+ /** One deduction from the clue at `cell` alone, or from it and an overlapping clue. */
207
+ step(cell) {
208
+ if (this.broken || this.state[cell] !== OPEN || this.unknown[cell] === 0)
209
+ return false;
210
+ const around = this.adjacent[cell];
211
+ const unknown = this.unknown[cell], need = this.need[cell];
212
+ if (need < 0 || need > unknown) {
213
+ this.broken = true;
214
+ return false;
215
+ }
216
+ const depth = this.chain[cell] + 1;
217
+ if (need === 0 || need === unknown) {
218
+ this.settle(around.filter((n) => this.state[n] === COVERED), need === 0, depth, "count");
219
+ return true;
220
+ }
221
+ const mine = ++this.stamp;
222
+ for (const n of around)
223
+ if (this.state[n] === COVERED)
224
+ this.marks[n] = mine;
225
+ const compared = ++this.stamp;
226
+ for (const x of around) {
227
+ if (this.state[x] !== COVERED)
228
+ continue;
229
+ for (const other of this.adjacent[x]) {
230
+ if (other === cell || this.state[other] !== OPEN || this.unknown[other] === 0 || this.seen[other] === compared)
231
+ continue;
232
+ this.seen[other] = compared;
233
+ let common = 0;
234
+ for (const y of this.adjacent[other])
235
+ if (this.state[y] === COVERED && this.marks[y] === mine)
236
+ common += 1;
237
+ const there = this.unknown[other];
238
+ let rest, mines;
239
+ if (common === there && there < unknown) {
240
+ rest = around.filter((n) => this.state[n] === COVERED && !this.adjacent[other].includes(n));
241
+ mines = need - this.need[other];
242
+ }
243
+ else if (common === unknown && unknown < there) {
244
+ rest = this.adjacent[other].filter((n) => this.state[n] === COVERED && this.marks[n] !== mine);
245
+ mines = this.need[other] - need;
246
+ }
247
+ else
248
+ continue;
249
+ if (mines < 0 || mines > rest.length) {
250
+ this.broken = true;
251
+ return false;
252
+ }
253
+ if (mines !== 0 && mines !== rest.length)
254
+ continue;
255
+ this.settle(rest, mines === 0, 1 + Math.max(depth - 1, this.chain[other]), "overlap");
256
+ return true;
257
+ }
258
+ }
259
+ return false;
260
+ }
261
+ /** The mine counter: nothing left to find, everything left a mine, or a clue that accounts for all that remain. */
262
+ counter() {
263
+ const covered = [];
264
+ for (let cell = 0; cell < this.cells; cell += 1)
265
+ if (this.state[cell] === COVERED)
266
+ covered.push(cell);
267
+ if (covered.length === 0)
268
+ return false;
269
+ const depth = this.deepestMine + 1;
270
+ if (this.left === 0 || this.left === covered.length) {
271
+ this.settle(covered, this.left === 0, depth, "total");
272
+ return true;
273
+ }
274
+ for (let cell = 0; cell < this.cells; cell += 1) {
275
+ if (this.state[cell] !== OPEN || this.unknown[cell] === 0)
276
+ continue;
277
+ const outside = covered.length - this.unknown[cell], mines = this.left - this.need[cell];
278
+ if (outside === 0 || (mines !== 0 && mines !== outside))
279
+ continue;
280
+ const mark = ++this.stamp;
281
+ for (const n of this.adjacent[cell])
282
+ if (this.state[n] === COVERED)
283
+ this.marks[n] = mark;
284
+ this.settle(covered.filter((n) => this.marks[n] !== mark), mines === 0, 1 + Math.max(depth - 1, this.chain[cell]), "total");
285
+ return true;
286
+ }
287
+ return false;
288
+ }
289
+ /**
290
+ * Exact enumeration of every frontier small enough, as `enumerateForced` does. Every component is read from the
291
+ * same position and the answers are applied together, exactly as one `deduce` call does, so that this solver and
292
+ * the hint engine finish the same boards: with a cap on the size of a frontier, the order of enumerating matters.
293
+ */
294
+ enumerate() {
295
+ const round = ++this.stamp;
296
+ const found = [];
297
+ for (let start = 0; start < this.cells; start += 1) {
298
+ if (this.state[start] !== OPEN || this.unknown[start] === 0 || this.visit[start] === round)
299
+ continue;
300
+ const clues = [start];
301
+ this.visit[start] = round;
302
+ const cells = new Set();
303
+ for (let at = 0; at < clues.length; at += 1)
304
+ for (const x of this.adjacent[clues[at]]) {
305
+ if (this.state[x] !== COVERED || cells.has(x))
306
+ continue;
307
+ cells.add(x);
308
+ for (const other of this.adjacent[x])
309
+ if (this.state[other] === OPEN && this.visit[other] !== round) {
310
+ this.visit[other] = round;
311
+ clues.push(other);
312
+ }
313
+ }
314
+ if (cells.size > 18 || clues.every((c) => this.searched[c]))
315
+ continue;
316
+ clues.sort((a, b) => a - b);
317
+ const constraints = clues.map((c) => ({ cells: this.adjacent[c].filter((n) => this.state[n] === COVERED), mines: this.need[c], sources: [c] }));
318
+ const result = enumerateForced(constraints);
319
+ if (result.contradiction) {
320
+ this.broken = true;
321
+ return false;
322
+ }
323
+ if (result.safe.length === 0 && result.mines.length === 0) {
324
+ for (const c of clues)
325
+ this.searched[c] = 1;
326
+ continue;
327
+ }
328
+ found.push({ mines: [...result.mines], safe: [...result.safe], depth: 1 + clues.reduce((most, c) => Math.max(most, this.chain[c]), 0) });
329
+ }
330
+ for (const one of found)
331
+ this.settle(one.mines, false, one.depth, "enumeration");
332
+ for (const one of found)
333
+ this.settle(one.safe, true, one.depth, "enumeration");
334
+ return found.length > 0;
335
+ }
336
+ }
package/dist/strings.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Language } from "./jirai.types.ts";
2
+ /** Built-in English and Japanese strings used by the player and renderer. */
2
3
  export declare const STRINGS: {
3
4
  readonly en: {
4
5
  readonly board: "Minesweeper board";
@@ -67,4 +68,5 @@ export declare const STRINGS: {
67
68
  readonly error: "推測なしで解ける盤が見つかりませんでした。別のシード、別の開始マス、または少ない地雷で試してください。";
68
69
  };
69
70
  };
71
+ /** Returns the built-in interface strings for a locale. */
70
72
  export declare function words(language?: Language): typeof STRINGS["en"] | typeof STRINGS["ja"];
package/dist/strings.js CHANGED
@@ -1,3 +1,4 @@
1
+ /** Built-in English and Japanese strings used by the player and renderer. */
1
2
  export const STRINGS = {
2
3
  en: {
3
4
  board: "Minesweeper board", covered: "covered", flag: "flagged", question: "uncertain", empty: "empty", mine: "mine", wrong: "incorrect flag",
@@ -14,4 +15,5 @@ export const STRINGS = {
14
15
  error: "推測なしで解ける盤が見つかりませんでした。別のシード、別の開始マス、または少ない地雷で試してください。",
15
16
  },
16
17
  };
18
+ /** Returns the built-in interface strings for a locale. */
17
19
  export function words(language = "en") { return STRINGS[language]; }
@@ -1,10 +1,12 @@
1
1
  import type { Game, Language, Material, Pieces, Settings } from "./jirai.types.ts";
2
+ /** Appearance and optional hint marker used by drawing and play. */
2
3
  export type DrawOptions = {
3
4
  material?: Material;
4
5
  pieces?: Pieces;
5
6
  language?: Language;
6
7
  hint?: number | null;
7
8
  };
9
+ /** Accessible presentation data for one active board cell. */
8
10
  export type CellModel = {
9
11
  cell: number;
10
12
  x: number;
@@ -16,11 +18,13 @@ export type CellModel = {
16
18
  kind: string;
17
19
  hint: boolean;
18
20
  };
21
+ /** Layout and cell presentation returned by `boardModel`. */
19
22
  export type BoardModel = {
20
23
  width: number;
21
24
  height: number;
22
25
  cells: CellModel[];
23
26
  };
27
+ /** Initial game, appearance, control, and lifecycle callbacks for a mounted board. */
24
28
  export type MountOptions = DrawOptions & {
25
29
  settings?: Settings;
26
30
  progress?: string;
@@ -29,6 +33,7 @@ export type MountOptions = DrawOptions & {
29
33
  onFinish?: (game: Game) => void;
30
34
  onError?: (error: Error) => void;
31
35
  };
36
+ /** Controls for reading and operating a mounted board. */
32
37
  export type JiraiMount = {
33
38
  game: () => Game;
34
39
  progress: () => string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@johnmorrisdotca/jirai",
3
- "version": "0.2.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.",
3
+ "version": "0.3.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, configurable materials and markers, and a playable board for any page. Zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "John Morris",
@@ -15,14 +15,39 @@
15
15
  },
16
16
  "keywords": [
17
17
  "minesweeper",
18
- "puzzle",
19
- "hexagonal",
20
- "orthogonal",
18
+ "mines",
19
+ "minefield",
20
+ "logic-puzzle",
21
+ "deduction-puzzle",
22
+ "number-clues",
23
+ "hexagonal-minesweeper",
24
+ "hex-grid",
25
+ "orthogonal-minesweeper",
26
+ "four-neighbour",
27
+ "four-neighbor",
28
+ "eight-neighbour",
29
+ "eight-neighbor",
30
+ "wraparound-grid",
21
31
  "no-guess",
32
+ "no-guess-minesweeper",
33
+ "difficulty-levels",
34
+ "seeded-puzzle",
35
+ "daily-puzzle",
36
+ "puzzle-generator",
37
+ "puzzle-solver",
22
38
  "typescript",
23
- "seeded",
39
+ "javascript",
40
+ "svg",
41
+ "accessible",
42
+ "keyboard",
43
+ "touch",
24
44
  "web-component",
25
- "itsutsu"
45
+ "custom-element",
46
+ "react-component",
47
+ "zero-dependencies",
48
+ "board-game",
49
+ "logic-game",
50
+ "grid-game"
26
51
  ],
27
52
  "main": "./dist/index.js",
28
53
  "types": "./dist/index.d.ts",
@@ -62,6 +87,7 @@
62
87
  "files": [
63
88
  "dist",
64
89
  "src",
90
+ "!src/**/*.test.ts",
65
91
  "README.md",
66
92
  "LICENSE",
67
93
  "CHANGELOG.md"
@@ -74,11 +100,13 @@
74
100
  "typecheck": "tsc -p tsconfig.check.json",
75
101
  "lint": "eslint .",
76
102
  "test": "vitest run",
77
- "check": "pnpm lint && pnpm typecheck && pnpm test",
103
+ "check": "pnpm lint && pnpm typecheck && pnpm test && pnpm check:presentation",
78
104
  "site": "pnpm build && node scripts/site.mjs",
79
105
  "test:demo": "pnpm site && playwright test",
80
106
  "test:package": "pnpm build && node scripts/check-package.mjs",
81
- "prepublishOnly": "pnpm check && pnpm test:package"
107
+ "prepublishOnly": "pnpm check && pnpm test:package",
108
+ "check:presentation": "node scripts/check-presentation.mjs",
109
+ "pictures": "pnpm site && node scripts/readme-pictures.mjs"
82
110
  },
83
111
  "peerDependencies": {
84
112
  "react": ">=18"
package/src/element.ts CHANGED
@@ -5,6 +5,7 @@ import type { JiraiMount } from "./ui.types.ts";
5
5
 
6
6
  // Importing this file on a server is harmless; only defineJirai registers the tag.
7
7
  const ElementBase = typeof HTMLElement === "undefined" ? class {} as typeof HTMLElement : HTMLElement;
8
+ /** Configurable `<jirai-board>` element that owns its mounted game. */
8
9
  export class JiraiElement extends ElementBase {
9
10
  private mounted: JiraiMount | null = null;
10
11
  static observedAttributes = ["width", "height", "mines", "seed", "grid", "shape", "no-guess", "material", "pieces", "lang"];
@@ -21,4 +22,5 @@ export class JiraiElement extends ElementBase {
21
22
  } catch (error) { this.dispatchEvent(new CustomEvent("jirai-error", { detail: error })); }
22
23
  }
23
24
  }
25
+ /** Registers the `<jirai-board>` custom element once in a registry. */
24
26
  export function defineJirai(registry: CustomElementRegistry = customElements): void { if (!registry.get("jirai-board")) registry.define("jirai-board", JiraiElement); }
@@ -0,0 +1,105 @@
1
+ // family.test.js: what every package of the family holds the same way, held by one test copied unchanged into
2
+ // each repository (src/family.test.js, or test/family.test.js in Kyuubu). It reads files and runs nothing else.
3
+ // Changing a shared file means changing it in every repository, with the new hash recorded here.
4
+ import { createHash } from "node:crypto";
5
+ import { readFileSync } from "node:fs";
6
+ import { describe, expect, it } from "vitest";
7
+ import { familyBlockOf, familyReadmeBlock } from "../scripts/family-readme.mjs";
8
+ import { FAMILY, FAMILY_PITCH, FAMILY_TEMPLATE_VERSION } from "../scripts/family-template.mjs";
9
+ import { releaseNotes } from "../scripts/release-notes.mjs";
10
+
11
+ const read = (path) => readFileSync(path, "utf8").replace(/\r\n/g, "\n");
12
+ const sha = (path) => createHash("sha256").update(readFileSync(path)).digest("hex");
13
+ const pkg = JSON.parse(read("package.json"));
14
+ const id = pkg.name.replace(/^@[^/]+\//, "");
15
+
16
+ // The recorded hashes. The template's is the one that says every demo's header and footer, and every README's
17
+ // list of the family, are the same text.
18
+ const TEMPLATE = { version: "2026-10-05", sha256: "061b5ed89c345dccb6e029d5091dff0a5bbc4a9b57812fbcd0bd619038bdb7f1" };
19
+ const FILES = {
20
+ "scripts/family-readme.mjs": "3c9d5b2cbf17a92d31bced98edac7f544616edb0dff90bf2d141722a9d4516c5",
21
+ "scripts/release-notes.mjs": "efab0fb78ad05973a8885624c0d2ce3b458b55799c11eabaa5176603ce8cd1e9",
22
+ "scripts/community/SECURITY.md": "ff6f650be7789396233671d2558439736efe7f96a1d1d39115dd5cf94d29275c",
23
+ "scripts/community/CODE_OF_CONDUCT.md": "34da1f56f004ce8f7f95d4449b64d2ecb5827dd9f0d4eea1da351a20713ebe70",
24
+ };
25
+
26
+ describe("the family template", () => {
27
+ it("is the one file, byte for byte, in every package", () => {
28
+ expect(FAMILY_TEMPLATE_VERSION).toBe(TEMPLATE.version);
29
+ expect(sha("scripts/family-template.mjs")).toBe(TEMPLATE.sha256);
30
+ });
31
+
32
+ it("lists every package of the family, in order, each with its Japanese name and a line on it", () => {
33
+ expect(FAMILY.map((one) => one.id)).toEqual([
34
+ "korokoro", "kyuubu", "hitotsu", "toranpu", "tane", "narabe", "tenka", "kumimoji", "tsunagi", "jarajara",
35
+ "suido", "domino", "kotoba", "sugoroku", "kazu", "meikyuu", "hikidashi", "chizu", "bushu", "tobiishi", "jirai", "gunjin",
36
+ ]);
37
+ for (const one of FAMILY) {
38
+ expect(one.name, one.id).toBe(one.id[0].toUpperCase() + one.id.slice(1));
39
+ expect(one.kana, one.id).toMatch(/\S/);
40
+ expect(FAMILY_PITCH[one.id], one.id).toMatch(/^[^A-Z.].*[^.]$/);
41
+ }
42
+ expect(Object.keys(FAMILY_PITCH).sort()).toEqual(FAMILY.map((one) => one.id).sort());
43
+ expect(FAMILY.map((one) => one.id)).toContain(id);
44
+ });
45
+
46
+ it("is what this package's footer reads: the demo site names the family through it", () => {
47
+ expect(read("scripts/site.mjs")).toContain("familyFooter(");
48
+ });
49
+ });
50
+
51
+ describe("the files every package shares", () => {
52
+ it("are copied unchanged: the README writer, the release notes, and the family's SECURITY.md and CODE_OF_CONDUCT.md", () => {
53
+ for (const [path, hash] of Object.entries(FILES)) expect(sha(path), path).toBe(hash);
54
+ });
55
+
56
+ it("SECURITY.md and CODE_OF_CONDUCT.md are the master text of github.com/johnmorrisdotca/.github, which scripts/community keeps a copy of", () => {
57
+ for (const file of ["SECURITY.md", "CODE_OF_CONDUCT.md"]) expect(read(file), file).toBe(read(`scripts/community/${file}`));
58
+ });
59
+ });
60
+
61
+ describe("the README's family", () => {
62
+ const readme = read("README.md");
63
+
64
+ it("is the block scripts/family-readme.mjs writes from the template, under a heading of its own", () => {
65
+ const block = familyBlockOf(readme);
66
+ expect(block, "README.md has no family markers").not.toBeNull();
67
+ expect(block).toBe(familyReadmeBlock(id));
68
+ expect(readme.split("<!-- family:start").length - 1).toBe(1);
69
+ expect(readme).toMatch(/\n#{2,3} The family\n+<!-- family:start/);
70
+ });
71
+
72
+ it("names every package once, as a link", () => {
73
+ const block = familyBlockOf(readme);
74
+ for (const one of FAMILY) expect(block.split(`](https://github.com/johnmorrisdotca/${one.id})`).length - 1, one.id).toBe(1);
75
+ });
76
+ });
77
+
78
+ describe("the release notes", () => {
79
+ it("are the changelog's section for the version, which the Release workflow puts on the GitHub release", () => {
80
+ 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
+ expect(releaseNotes(log, "1.2.0")).toBe("### Added\n\n- A thing.");
82
+ expect(releaseNotes(log, "1.1.0")).toBe("- Older.");
83
+ expect(releaseNotes(log, "9.9.9")).toBeNull();
84
+ expect(releaseNotes(read("CHANGELOG.md"), pkg.version)?.length, `CHANGELOG.md has nothing under ## [${pkg.version}]`).toBeGreaterThan(40);
85
+ const workflow = read(".github/workflows/release.yml");
86
+ expect(workflow).toContain("scripts/release-notes.mjs");
87
+ expect(workflow).not.toContain("See CHANGELOG.md.");
88
+ });
89
+
90
+ it("come from a changelog in Keep a Changelog form: an Unreleased heading, then each version in brackets with its date", () => {
91
+ const log = read("CHANGELOG.md");
92
+ expect(log).toContain("\n## [Unreleased]\n");
93
+ expect(log).toMatch(/^## \[\d+\.\d+\.\d+\] - \d{4}-\d{2}-\d{2}$/m);
94
+ expect(log).not.toMatch(/^## \d/m);
95
+ });
96
+ });
97
+
98
+ describe("Node", () => {
99
+ it("is 22 or later: engines, the CI matrix, and the words in the README and CONTRIBUTING", () => {
100
+ expect(pkg.engines.node).toBe(">=22");
101
+ expect(read(".github/workflows/ci.yml")).toMatch(/node: \[22, 24\]/);
102
+ expect(read(".github/workflows/ci.yml")).not.toMatch(/node: \[[^\]]*\b(18|20)\b/);
103
+ for (const file of ["README.md", "CONTRIBUTING.md"]) expect(read(file), file).not.toMatch(/\bNode(\.js)? (v)?(18|20)\b|>= ?20\b/);
104
+ });
105
+ });
package/src/game.ts CHANGED
@@ -5,6 +5,7 @@ import { DEFAULT_SETTINGS, MARKS, MOVES, STATUSES } from "./jirai.constants.ts";
5
5
  import { neighbours, neighboursOf, validCell, validSettings } from "./grid.ts";
6
6
  import type { Board, Game, Move, Settings, VisibleGame } from "./jirai.types.ts";
7
7
 
8
+ /** Creates an unstarted game with covered cells and no dealt answer. */
8
9
  export function newGame(settings: Settings = DEFAULT_SETTINGS): Game {
9
10
  if (!validSettings(settings)) throw new RangeError("Invalid Minesweeper settings.");
10
11
  return { settings: { ...settings }, board: null, marks: Array.from({ length: settings.width * settings.height }, () => MARKS.covered), status: STATUSES.ready, exploded: null, moves: [], helped: false };
@@ -16,7 +17,7 @@ export function visibleGame(game: Game): VisibleGame {
16
17
  clues: game.marks.map((mark, cell) => !activeCell(game.settings, cell) ? -2 : mark === MARKS.open ? game.board?.clues[cell] ?? null : null) };
17
18
  }
18
19
 
19
- /** Accept a worker's dealt board without changing any moves made before the opening. */
20
+ /** Attaches a worker-dealt board that matches the game's settings, preserving existing moves. */
20
21
  export function withBoard(game: Game, board: Board): Game {
21
22
  if (game.board !== null || (game.settings.shape ?? "rectangle") !== (board.settings.shape ?? "rectangle") || (Object.keys(DEFAULT_SETTINGS) as (keyof Settings)[]).some(key => game.settings[key] !== board.settings[key])) throw new Error("The board does not belong to this game.");
22
23
  return { ...game, board };