@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/src/solve.ts ADDED
@@ -0,0 +1,253 @@
1
+ import { enumerateForced } from "./enumerate.ts";
2
+ import { neighboursOf } from "./grid.ts";
3
+ import { activeCell } from "./shape.ts";
4
+ import type { Constraint, Settings } from "./jirai.types.ts";
5
+
6
+ /** Why a cell was proved: one clue, two overlapping clues, the mine counter, or every arrangement of a small frontier. */
7
+ export type ProofKind = "count" | "overlap" | "total" | "enumeration";
8
+ /** Cells proved by each kind of reasoning. */
9
+ export type ProofTally = Record<ProofKind, number>;
10
+ /** What a run of the deduction solver found. */
11
+ export type SolveReport = {
12
+ /** Every safe cell was opened using only proved deductions. */
13
+ solved: boolean;
14
+ /** Safe cells still covered when the solver stopped; zero when solved. */
15
+ remaining: number;
16
+ /** Safe cells opened, including the free opening. */
17
+ opened: number;
18
+ /** Covered cells, safe or mine, nothing has been proved about. */
19
+ unresolved: number;
20
+ /** Cells opened by the first reveal and its flood, before any deduction. */
21
+ free: number;
22
+ /** Cells proved (safe or mine) by each kind of reasoning. */
23
+ proved: ProofTally;
24
+ /** The longest chain of deductions, each resting on the one before. */
25
+ depth: number;
26
+ };
27
+
28
+ const COVERED = 0, OPEN = 1, MINE = 2, OUTSIDE = 3;
29
+
30
+ /**
31
+ * The same deductions as `deduce` (one clue, two overlapping clues, the mine counter, exact enumeration of a
32
+ * small frontier), run to a fixed point over one board in a single pass instead of one deduction per scan of the
33
+ * whole field. A deduction found here is a deduction `deduce` would find; the order differs, the closure does not.
34
+ * Built once for a set of settings, then `run` as often as needed on different mine layouts.
35
+ */
36
+ export class Solver {
37
+ readonly adjacent: readonly (readonly number[])[];
38
+ readonly cells: number;
39
+ readonly safeTotal: number;
40
+ readonly mineTotal: number;
41
+ readonly state: Uint8Array;
42
+ private readonly start: Uint8Array;
43
+ private readonly need: Int8Array;
44
+ private readonly unknown: Int8Array;
45
+ private readonly chain: Int16Array;
46
+ private readonly dirty: Uint8Array;
47
+ private readonly searched: Uint8Array;
48
+ private readonly marks: Int32Array;
49
+ private readonly seen: Int32Array;
50
+ private readonly visit: Int32Array;
51
+ private readonly queue: number[] = [];
52
+ private readonly stack: number[] = [];
53
+ private clues: Int8Array = new Int8Array(0);
54
+ private stamp = 0;
55
+ private covered = 0;
56
+ private left = 0;
57
+ private opened = 0;
58
+ private free = 0;
59
+ private deepestMine = 0;
60
+ private depth = 0;
61
+ private broken = false;
62
+ private searching = true;
63
+ private proved: ProofTally = { count: 0, overlap: 0, total: 0, enumeration: 0 };
64
+
65
+ constructor(readonly settings: Settings, adjacent: readonly (readonly number[])[] = neighboursOf(settings)) {
66
+ this.adjacent = adjacent;
67
+ this.cells = settings.width * settings.height;
68
+ this.start = Uint8Array.from({ length: this.cells }, (_, cell) => activeCell(settings, cell) ? COVERED : OUTSIDE);
69
+ this.safeTotal = this.start.reduce((sum, state) => sum + (state === COVERED ? 1 : 0), 0) - settings.mines;
70
+ this.mineTotal = settings.mines;
71
+ this.state = new Uint8Array(this.cells);
72
+ this.need = new Int8Array(this.cells);
73
+ this.unknown = new Int8Array(this.cells);
74
+ this.chain = new Int16Array(this.cells);
75
+ this.dirty = new Uint8Array(this.cells);
76
+ this.searched = new Uint8Array(this.cells);
77
+ this.marks = new Int32Array(this.cells);
78
+ this.seen = new Int32Array(this.cells);
79
+ this.visit = new Int32Array(this.cells);
80
+ }
81
+
82
+ /** Clues of a layout: -1 for a mine, -2 outside the shape, otherwise the count of neighbouring mines. */
83
+ clueFor(mines: ArrayLike<number | boolean>, into: Int8Array = new Int8Array(this.cells)): Int8Array {
84
+ for (let cell = 0; cell < this.cells; cell += 1) {
85
+ if (this.start[cell] === OUTSIDE) { into[cell] = -2; continue; }
86
+ if (mines[cell]) { into[cell] = -1; continue; }
87
+ let count = 0;
88
+ for (const n of this.adjacent[cell]!) if (mines[n]) count += 1;
89
+ into[cell] = count;
90
+ }
91
+ return into;
92
+ }
93
+
94
+ /** Play the opening at `first` and every deduction after it. `clues` is read, never changed. */
95
+ run(clues: Int8Array, first: number, enumerate = true): SolveReport {
96
+ this.clues = clues;
97
+ this.state.set(this.start); this.need.fill(0); this.unknown.fill(0); this.chain.fill(0);
98
+ this.dirty.fill(0); this.searched.fill(0); this.queue.length = 0;
99
+ this.covered = this.safeTotal + this.mineTotal; this.left = this.mineTotal;
100
+ this.opened = 0; this.free = 0; this.deepestMine = 0; this.depth = 0; this.broken = false;
101
+ this.proved = { count: 0, overlap: 0, total: 0, enumeration: 0 };
102
+ if ((clues[first] ?? -1) < 0) return this.report();
103
+ this.reveal(first, 0, null);
104
+ this.free = this.opened;
105
+ for (;;) {
106
+ while (this.queue.length > 0 && !this.broken) {
107
+ const cell = this.queue.pop()!;
108
+ this.dirty[cell] = 0;
109
+ while (this.step(cell)) { /* a clue may settle more than once */ }
110
+ }
111
+ if (this.broken || this.opened === this.safeTotal || this.covered === 0) break;
112
+ if (this.counter()) continue;
113
+ if (enumerate && this.enumerate()) continue;
114
+ break;
115
+ }
116
+ return this.report();
117
+ }
118
+
119
+ private report(): SolveReport {
120
+ const solved = !this.broken && this.opened === this.safeTotal;
121
+ return { solved, remaining: this.safeTotal - this.opened, unresolved: this.covered, opened: this.opened, free: this.free, proved: { ...this.proved }, depth: this.depth };
122
+ }
123
+
124
+ private touch(cell: number): void {
125
+ this.searched[cell] = 0;
126
+ if (!this.dirty[cell]) { this.dirty[cell] = 1; this.queue.push(cell); }
127
+ }
128
+
129
+ /** Open a cell known to be safe, and flood through every zero. `kind` null is the free opening. */
130
+ private reveal(start: number, depth: number, kind: ProofKind | null): void {
131
+ const stack = this.stack;
132
+ stack.length = 0; stack.push(start);
133
+ let first = true;
134
+ while (stack.length > 0) {
135
+ const cell = stack.pop()!;
136
+ if (this.state[cell] !== COVERED) continue;
137
+ if (this.clues[cell]! < 0) { this.broken = true; return; }
138
+ if (first && kind !== null) { this.proved[kind] += 1; if (depth > this.depth) this.depth = depth; }
139
+ first = false;
140
+ this.state[cell] = OPEN; this.covered -= 1; this.opened += 1;
141
+ let mines = 0, unknown = 0;
142
+ for (const n of this.adjacent[cell]!) {
143
+ const s = this.state[n];
144
+ if (s === MINE) mines += 1; else if (s === COVERED) unknown += 1;
145
+ else if (s === OPEN) { this.unknown[n]! -= 1; if (depth > this.chain[n]!) this.chain[n] = depth; this.touch(n); }
146
+ }
147
+ this.need[cell] = this.clues[cell]! - mines; this.unknown[cell] = unknown; this.chain[cell] = depth;
148
+ if (this.clues[cell] === 0) { for (const n of this.adjacent[cell]!) if (this.state[n] === COVERED) stack.push(n); }
149
+ else if (unknown > 0) this.touch(cell);
150
+ }
151
+ }
152
+
153
+ private flag(cell: number, depth: number, kind: ProofKind): void {
154
+ if (this.state[cell] !== COVERED) return;
155
+ if (this.clues[cell]! >= 0) { this.broken = true; return; }
156
+ this.state[cell] = MINE; this.covered -= 1; this.left -= 1;
157
+ this.proved[kind] += 1; if (depth > this.depth) this.depth = depth;
158
+ if (depth > this.deepestMine) this.deepestMine = depth;
159
+ for (const n of this.adjacent[cell]!) if (this.state[n] === OPEN) {
160
+ this.unknown[n]! -= 1; this.need[n]! -= 1; if (depth > this.chain[n]!) this.chain[n] = depth; this.touch(n);
161
+ }
162
+ }
163
+
164
+ private settle(cells: readonly number[], safe: boolean, depth: number, kind: ProofKind): void {
165
+ for (const cell of cells) { if (safe) this.reveal(cell, depth, kind); else this.flag(cell, depth, kind); }
166
+ }
167
+
168
+ /** One deduction from the clue at `cell` alone, or from it and an overlapping clue. */
169
+ private step(cell: number): boolean {
170
+ if (this.broken || this.state[cell] !== OPEN || this.unknown[cell] === 0) return false;
171
+ const around = this.adjacent[cell]!;
172
+ const unknown = this.unknown[cell]!, need = this.need[cell]!;
173
+ if (need < 0 || need > unknown) { this.broken = true; return false; }
174
+ const depth = this.chain[cell]! + 1;
175
+ if (need === 0 || need === unknown) {
176
+ this.settle(around.filter((n) => this.state[n] === COVERED), need === 0, depth, "count");
177
+ return true;
178
+ }
179
+ const mine = ++this.stamp;
180
+ for (const n of around) if (this.state[n] === COVERED) this.marks[n] = mine;
181
+ const compared = ++this.stamp;
182
+ for (const x of around) {
183
+ if (this.state[x] !== COVERED) continue;
184
+ for (const other of this.adjacent[x]!) {
185
+ if (other === cell || this.state[other] !== OPEN || this.unknown[other] === 0 || this.seen[other] === compared) continue;
186
+ this.seen[other] = compared;
187
+ let common = 0;
188
+ for (const y of this.adjacent[other]!) if (this.state[y] === COVERED && this.marks[y] === mine) common += 1;
189
+ const there = this.unknown[other]!;
190
+ let rest: number[], mines: number;
191
+ if (common === there && there < unknown) {
192
+ rest = around.filter((n) => this.state[n] === COVERED && !this.adjacent[other]!.includes(n)); mines = need - this.need[other]!;
193
+ } else if (common === unknown && unknown < there) {
194
+ rest = this.adjacent[other]!.filter((n) => this.state[n] === COVERED && this.marks[n] !== mine); mines = this.need[other]! - need;
195
+ } else continue;
196
+ if (mines < 0 || mines > rest.length) { this.broken = true; return false; }
197
+ if (mines !== 0 && mines !== rest.length) continue;
198
+ this.settle(rest, mines === 0, 1 + Math.max(depth - 1, this.chain[other]!), "overlap");
199
+ return true;
200
+ }
201
+ }
202
+ return false;
203
+ }
204
+
205
+ /** The mine counter: nothing left to find, everything left a mine, or a clue that accounts for all that remain. */
206
+ private counter(): boolean {
207
+ const covered: number[] = [];
208
+ for (let cell = 0; cell < this.cells; cell += 1) if (this.state[cell] === COVERED) covered.push(cell);
209
+ if (covered.length === 0) return false;
210
+ const depth = this.deepestMine + 1;
211
+ if (this.left === 0 || this.left === covered.length) { this.settle(covered, this.left === 0, depth, "total"); return true; }
212
+ for (let cell = 0; cell < this.cells; cell += 1) {
213
+ if (this.state[cell] !== OPEN || this.unknown[cell] === 0) continue;
214
+ const outside = covered.length - this.unknown[cell]!, mines = this.left - this.need[cell]!;
215
+ if (outside === 0 || (mines !== 0 && mines !== outside)) continue;
216
+ const mark = ++this.stamp;
217
+ for (const n of this.adjacent[cell]!) if (this.state[n] === COVERED) this.marks[n] = mark;
218
+ this.settle(covered.filter((n) => this.marks[n] !== mark), mines === 0, 1 + Math.max(depth - 1, this.chain[cell]!), "total");
219
+ return true;
220
+ }
221
+ return false;
222
+ }
223
+
224
+ /**
225
+ * Exact enumeration of every frontier small enough, as `enumerateForced` does. Every component is read from the
226
+ * same position and the answers are applied together, exactly as one `deduce` call does, so that this solver and
227
+ * the hint engine finish the same boards: with a cap on the size of a frontier, the order of enumerating matters.
228
+ */
229
+ private enumerate(): boolean {
230
+ const round = ++this.stamp;
231
+ const found: { mines: number[]; safe: number[]; depth: number }[] = [];
232
+ for (let start = 0; start < this.cells; start += 1) {
233
+ if (this.state[start] !== OPEN || this.unknown[start] === 0 || this.visit[start] === round) continue;
234
+ const clues = [start]; this.visit[start] = round;
235
+ const cells = new Set<number>();
236
+ for (let at = 0; at < clues.length; at += 1) for (const x of this.adjacent[clues[at]!]!) {
237
+ if (this.state[x] !== COVERED || cells.has(x)) continue;
238
+ cells.add(x);
239
+ for (const other of this.adjacent[x]!) if (this.state[other] === OPEN && this.visit[other] !== round) { this.visit[other] = round; clues.push(other); }
240
+ }
241
+ if (cells.size > 18 || clues.every((c) => this.searched[c])) continue;
242
+ clues.sort((a, b) => a - b);
243
+ const constraints: Constraint[] = clues.map((c) => ({ cells: this.adjacent[c]!.filter((n) => this.state[n] === COVERED), mines: this.need[c]!, sources: [c] }));
244
+ const result = enumerateForced(constraints);
245
+ if (result.contradiction) { this.broken = true; return false; }
246
+ if (result.safe.length === 0 && result.mines.length === 0) { for (const c of clues) this.searched[c] = 1; continue; }
247
+ found.push({ mines: [...result.mines], safe: [...result.safe], depth: 1 + clues.reduce((most, c) => Math.max(most, this.chain[c]!), 0) });
248
+ }
249
+ for (const one of found) this.settle(one.mines, false, one.depth, "enumeration");
250
+ for (const one of found) this.settle(one.safe, true, one.depth, "enumeration");
251
+ return found.length > 0;
252
+ }
253
+ }
package/src/strings.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 const STRINGS = {
3
4
  en: {
4
5
  board: "Minesweeper board", covered: "covered", flag: "flagged", question: "uncertain", empty: "empty", mine: "mine", wrong: "incorrect flag",
@@ -15,4 +16,5 @@ export const STRINGS = {
15
16
  error: "推測なしで解ける盤が見つかりませんでした。別のシード、別の開始マス、または少ない地雷で試してください。",
16
17
  },
17
18
  } as const;
19
+ /** Returns the built-in interface strings for a locale. */
18
20
  export function words(language: Language = "en"): typeof STRINGS["en"] | typeof STRINGS["ja"] { return STRINGS[language]; }
package/src/ui.types.ts CHANGED
@@ -1,7 +1,11 @@
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 = { material?: Material; pieces?: Pieces; language?: Language; hint?: number | null };
4
+ /** Accessible presentation data for one active board cell. */
3
5
  export type CellModel = { cell: number; x: number; y: number; width: number; height: number; label: string; text: string; kind: string; hint: boolean };
6
+ /** Layout and cell presentation returned by `boardModel`. */
4
7
  export type BoardModel = { width: number; height: number; cells: CellModel[] };
8
+ /** Initial game, appearance, control, and lifecycle callbacks for a mounted board. */
5
9
  export type MountOptions = DrawOptions & {
6
10
  settings?: Settings;
7
11
  progress?: string;
@@ -10,6 +14,7 @@ export type MountOptions = DrawOptions & {
10
14
  onFinish?: (game: Game) => void;
11
15
  onError?: (error: Error) => void;
12
16
  };
17
+ /** Controls for reading and operating a mounted board. */
13
18
  export type JiraiMount = {
14
19
  game: () => Game;
15
20
  progress: () => string;
@@ -0,0 +1,9 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { expect, it } from "vitest";
3
+ import { VERSION } from "./index.ts";
4
+
5
+ it("names one version in the package, the library and the changelog", () => {
6
+ const pkg = JSON.parse(readFileSync("package.json", "utf8"));
7
+ expect(VERSION).toBe(pkg.version);
8
+ expect(readFileSync("CHANGELOG.md", "utf8")).toMatch(new RegExp(`^## \\[${pkg.version.replaceAll(".", "\\.")}\\] - \\d{4}-\\d{2}-\\d{2}$`, "m"));
9
+ });
@@ -1,47 +0,0 @@
1
- import { expect, it } from "vitest";
2
- import { deduce, hintFor } from "./deduce.ts";
3
- import { enumerateForced } from "./enumerate.ts";
4
- import { DEFAULT_SETTINGS, MARKS, STATUSES } from "./jirai.constants.ts";
5
- import { neighbours } from "./grid.ts";
6
- import type { VisibleGame, Grid } from "./jirai.types.ts";
7
-
8
- it("extracts the 1–2–1 wall pattern without trusting flags", () => {
9
- const game: VisibleGame = {
10
- settings: { ...DEFAULT_SETTINGS, width: 3, height: 3, mines: 2 }, status: STATUSES.playing,
11
- clues: [null,null,null,1,2,1,0,0,0], marks: [MARKS.flag,MARKS.flag,MARKS.covered,...Array(6).fill(MARKS.open)],
12
- };
13
- const hint = hintFor(game);
14
- expect(hint.safe).toContain(1); // A wrongly flagged square is still provably safe.
15
- });
16
-
17
- it("enumeration proves only cells forced in every consistent assignment", () => {
18
- const proof = enumerateForced([
19
- { cells: [0,1], mines: 1, sources: [3] },
20
- { cells: [1,2], mines: 1, sources: [4] },
21
- { cells: [0,2], mines: 0, sources: [5] },
22
- ]);
23
- expect([...proof.safe].sort()).toEqual([0,2]); expect(proof.mines).toEqual([1]);
24
- expect(enumerateForced([{ cells: [0,1], mines: 1, sources: [] }]).safe).toEqual([]);
25
- expect(enumerateForced([{ cells: [0], mines: 2, sources: [] }]).contradiction).toBe(true);
26
- });
27
-
28
- it("agrees with an independent exhaustive oracle on square, orthogonal, hex and wrapped 3×3 positions", () => {
29
- for (const grid of ["square", "orthogonal", "hex", "wrap"] as Grid[]) {
30
- const settings = { ...DEFAULT_SETTINGS, width: 3, height: 3, mines: 2, grid };
31
- for (let firstMine = 0; firstMine < 9; firstMine += 1) for (let secondMine = firstMine + 1; secondMine < 9; secondMine += 1) {
32
- const actual = [firstMine, secondMine];
33
- const clues = Array.from({ length: 9 }, (_, cell) => !actual.includes(cell) && cell % 2 === 0 ? neighbours(settings, cell).filter((n) => actual.includes(n)).length : null);
34
- const possible: number[][] = [];
35
- for (let a = 0; a < 9; a += 1) for (let b = a + 1; b < 9; b += 1) {
36
- const mines = [a,b];
37
- if (mines.some((cell) => clues[cell] !== null)) continue;
38
- if (clues.every((clue, cell) => clue === null || neighbours(settings, cell).filter((n) => mines.includes(n)).length === clue)) possible.push(mines);
39
- }
40
- const game: VisibleGame = { settings, clues, status: STATUSES.playing, marks: clues.map((n) => n === null ? MARKS.flag : MARKS.open) };
41
- const result = deduce(game);
42
- expect(result.contradiction).toBe(false); expect(possible.length).toBeGreaterThan(0);
43
- for (const cell of result.safe) expect(possible.every((mines) => !mines.includes(cell))).toBe(true);
44
- for (const cell of result.mines) expect(possible.every((mines) => mines.includes(cell))).toBe(true);
45
- }
46
- }
47
- });
package/src/game.test.ts DELETED
@@ -1,172 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { newGame, play, visibleGame } from "./game.ts";
3
- import { makeBoard, isSolvable, GenerationError } from "./generate.ts";
4
- import { DEFAULT_SETTINGS, GRIDS, MARKS, MOVES, STATUSES } from "./jirai.constants.ts";
5
- import { neighbours, neighboursOf, validCell, validSettings } from "./grid.ts";
6
- import { gameFromProgress, gameProgress, dailySeed } from "./keep.ts";
7
- import { boardModel } from "./draw.ts";
8
- import { hintFor } from "./deduce.ts";
9
- import type { Board, Game, Settings } from "./jirai.types.ts";
10
- import { decodeOrthogonalGame, encodeOrthogonalGame, makeOrthogonalBoard, newOrthogonalGame, orthogonalHint, orthogonalNeighbours } from "./orthogonal.ts";
11
-
12
- function fixedGame(minesAt: number[]): Game {
13
- const settings: Settings = { ...DEFAULT_SETTINGS, width: 5, height: 5, mines: minesAt.length, noGuess: false, opening: "safe" };
14
- const mines = Array.from({ length: 25 }, (_, cell) => minesAt.includes(cell));
15
- const clues = mines.map((mine, cell) => mine ? -1 : neighbours(settings, cell).filter((n) => mines[n]).length);
16
- const board: Board = { settings, mines, clues, first: 12, attempt: 0 };
17
- return { ...newGame(settings), board };
18
- }
19
-
20
- describe("the grid topologies", () => {
21
- for (const grid of Object.values(GRIDS)) it(`${grid} has reciprocal, unique neighbours and no self-neighbours`, () => {
22
- const settings = { ...DEFAULT_SETTINGS, width: 7, height: 5, grid };
23
- const all = neighboursOf(settings);
24
- for (let cell = 0; cell < all.length; cell += 1) {
25
- expect(new Set(all[cell]).size).toBe(all[cell]!.length);
26
- expect(all[cell]).not.toContain(cell);
27
- for (const n of all[cell]!) expect(all[n]).toContain(cell);
28
- }
29
- });
30
- it("uses four orthogonal neighbours, six hex neighbours, eight square neighbours, and joins both wrap seams", () => {
31
- expect(neighbours({ ...DEFAULT_SETTINGS, grid: GRIDS.orthogonal }, 40)).toHaveLength(4);
32
- expect(neighbours({ ...DEFAULT_SETTINGS, grid: GRIDS.hex }, 40)).toHaveLength(6);
33
- expect(neighbours(DEFAULT_SETTINGS, 40)).toHaveLength(8);
34
- expect(neighbours({ ...DEFAULT_SETTINGS, grid: GRIDS.wrap }, 0)).toEqual([1,8,9,10,17,72,73,80]);
35
- });
36
- it("uses only orthogonal clues, preserves playable voids, and verifies a no-guess deal", () => {
37
- const settings = { ...DEFAULT_SETTINGS, width: 9, height: 9, mines: 10, grid: GRIDS.orthogonal, seed: 19 };
38
- const board = makeBoard(settings, 40);
39
- expect(board.clues[40]).toBe(0);
40
- for (let cell = 0; cell < 81; cell += 1) {
41
- expect(board.clues[cell]).toBe(board.mines[cell] ? -1 : neighbours(settings, cell).filter(next => board.mines[next]).length);
42
- }
43
- expect(isSolvable(board)).toBe(true);
44
- const shaped = { ...settings, shape: "heart" as const };
45
- const shapedBoard = makeBoard(shaped, 40);
46
- for (let cell = 0; cell < 81; cell += 1) if (!validCell(shaped, cell)) {
47
- expect(shapedBoard.mines[cell]).toBe(false);
48
- expect(shapedBoard.clues[cell]).toBe(-2);
49
- expect(neighbours(shaped, cell)).toEqual([]);
50
- }
51
- const playing = play(newGame(settings), { kind: MOVES.reveal, cell: 40 });
52
- const proof = hintFor(visibleGame(playing));
53
- expect(proof.safe.every(cell => !playing.board!.mines[cell])).toBe(true);
54
- expect(proof.mines.every(cell => playing.board!.mines[cell])).toBe(true);
55
- });
56
- it("rejects malformed or excessive settings before making a board", () => {
57
- for (const change of [{ width: 1 }, { width: 61 }, { width: 60, height: 60 }, { mines: 81 }, { seed: NaN }, { grid: "__proto__" }, { mines: 1.5 }, { noGuess: "yes" }]) expect(validSettings({ ...DEFAULT_SETTINGS, ...change })).toBe(false);
58
- });
59
- });
60
-
61
- describe("dealing and verified boards", () => {
62
- for (const grid of Object.values(GRIDS)) it(`${grid} deals exact counts and a clear first opening for many seeds`, () => {
63
- for (let seed = 0; seed < 40; seed += 1) {
64
- const settings = { ...DEFAULT_SETTINGS, grid, seed, noGuess: false };
65
- const board = makeBoard(settings, 40);
66
- expect(board.mines.filter(Boolean)).toHaveLength(10);
67
- expect(board.clues[40]).toBe(0);
68
- for (let cell = 0; cell < 81; cell += 1) {
69
- expect(board.clues[cell]).toBe(board.mines[cell] ? -1 : neighbours(settings, cell).filter((n) => board.mines[n]).length);
70
- }
71
- expect(makeBoard(settings, 40)).toEqual(board);
72
- }
73
- });
74
- for (const grid of Object.values(GRIDS)) it(`${grid} only advertises boards its visible-clue solver actually finishes`, () => {
75
- for (let seed = 0; seed < 15; seed += 1) expect(isSolvable(makeBoard({ ...DEFAULT_SETTINGS, grid, seed }, 40))).toBe(true);
76
- });
77
- it("reports a failed proof budget rather than returning an unverified board", () => {
78
- let failures = 0;
79
- for (let seed = 0; seed < 50; seed += 1) {
80
- try { makeBoard({ ...DEFAULT_SETTINGS, width: 5, height: 5, mines: 10, opening: "safe", seed }, 12, { attempts: 1 }); }
81
- catch (error) { expect(error).toBeInstanceOf(GenerationError); expect((error as GenerationError).code).toBe("exhausted"); failures += 1; }
82
- }
83
- expect(failures).toBeGreaterThan(0);
84
- });
85
- it("rejects a dense board that cannot protect the chosen opening", () => {
86
- expect(() => makeBoard({ ...DEFAULT_SETTINGS, width: 3, height: 3, mines: 1 }, 4)).toThrow(GenerationError);
87
- });
88
- });
89
-
90
- describe("moves", () => {
91
- it("floods empty ground and wins without requiring any flags", () => {
92
- const game = fixedGame([0]);
93
- const before = structuredClone(game);
94
- const next = play(game, { kind: MOVES.reveal, cell: 24 });
95
- expect(next.status).toBe(STATUSES.won);
96
- expect(next.marks.filter((mark) => mark === MARKS.open)).toHaveLength(24);
97
- expect(game).toEqual(before);
98
- });
99
- it("does not open a flagged cell, including inside a flood", () => {
100
- let game = fixedGame([0]);
101
- game = play(game, { kind: MOVES.mark, cell: 24 });
102
- expect(play(game, { kind: MOVES.reveal, cell: 24 })).toBe(game);
103
- game = play(game, { kind: MOVES.reveal, cell: 20 });
104
- expect(game.marks[24]).toBe(MARKS.flag);
105
- expect(game.status).toBe(STATUSES.playing);
106
- });
107
- it("cycles notes through flag, question and covered", () => {
108
- let game = newGame();
109
- for (const mark of [MARKS.flag, MARKS.question, MARKS.covered]) { game = play(game, { kind: MOVES.mark, cell: 0 }); expect(game.marks[0]).toBe(mark); }
110
- expect(game.board).toBeNull();
111
- });
112
- it("chords a matched number and loses if the flags were wrong", () => {
113
- let game = fixedGame([0,24]);
114
- game = play(game, { kind: MOVES.reveal, cell: 6 });
115
- expect(play(game, { kind: MOVES.chord, cell: 6 })).toBe(game);
116
- game = play(game, { kind: MOVES.mark, cell: 1 });
117
- game = play(game, { kind: MOVES.chord, cell: 6 });
118
- expect(game.status).toBe(STATUSES.lost); expect(game.exploded).toBe(0);
119
- expect(play(game, { kind: MOVES.mark, cell: 3 })).toBe(game);
120
- });
121
- it("chords correctly flagged mines without revealing them", () => {
122
- let game = fixedGame([0,24]);
123
- game = play(game, { kind: MOVES.reveal, cell: 6 });
124
- game = play(game, { kind: MOVES.mark, cell: 0 });
125
- game = play(game, { kind: MOVES.chord, cell: 6 });
126
- expect(game.status).not.toBe(STATUSES.lost); expect(game.marks[0]).toBe(MARKS.flag);
127
- });
128
- it("keeps hidden answers out of both the clue view and drawing", () => {
129
- const game = fixedGame([0,24]);
130
- expect(visibleGame(game).clues.every((clue) => clue === null)).toBe(true);
131
- expect(boardModel(game).cells.every((cell) => cell.text === "" && cell.kind === MARKS.covered)).toBe(true);
132
- });
133
- it("replays a saved game from its seed and moves; malformed records are refused", () => {
134
- let game = newGame({ ...DEFAULT_SETTINGS, noGuess: false });
135
- game = play(game, { kind: MOVES.mark, cell: 0 }); game = play(game, { kind: MOVES.reveal, cell: 40 });
136
- expect(gameFromProgress(gameProgress(game))).toEqual(game);
137
- for (const data of ["garbage", "null", '{"version":2}', JSON.stringify({ version: 1, settings: DEFAULT_SETTINGS, moves: [{ kind: "reveal", cell: -1 }] })]) expect(gameFromProgress(data)).toBeNull();
138
- });
139
- it("keeps orthogonal save records distinct and preserves legacy square codes", () => {
140
- const legacy = JSON.stringify({ version: 1, settings: DEFAULT_SETTINGS, moves: [], helped: false });
141
- expect(gameFromProgress(legacy)).toEqual(newGame());
142
- const orthogonal = newGame({ ...DEFAULT_SETTINGS, grid: GRIDS.orthogonal });
143
- const code = gameProgress(orthogonal);
144
- expect(JSON.parse(code)).toMatchObject({ version: 2, variant: "orthogonal", settings: { grid: "orthogonal" } });
145
- expect(gameFromProgress(code)).toEqual(orthogonal);
146
- expect(gameFromProgress(JSON.stringify({ ...JSON.parse(code), variant: "square" }))).toBeNull();
147
- });
148
- it("exposes focused orthogonal helpers and variant-only progress codes", () => {
149
- const settings = { ...DEFAULT_SETTINGS, width: 9, height: 9, mines: 10, seed: 4 };
150
- const game = newOrthogonalGame(settings);
151
- expect(game.settings.grid).toBe("orthogonal");
152
- expect(orthogonalNeighbours(settings, 40)).toHaveLength(4);
153
- expect(makeOrthogonalBoard(settings, 40).settings.grid).toBe("orthogonal");
154
- expect(orthogonalHint(play(game, { kind: MOVES.reveal, cell: 40 })).contradiction).toBe(false);
155
- expect(decodeOrthogonalGame(encodeOrthogonalGame(game))).toEqual(game);
156
- expect(decodeOrthogonalGame(gameProgress(newGame()))).toBeNull();
157
- });
158
- it("has stable daily seeds and rejects impossible calendar dates", () => {
159
- expect(dailySeed("2026-10-04")).toBe(dailySeed("2026-10-04"));
160
- expect(dailySeed("2026-10-04", "orthogonal")).not.toBe(dailySeed("2026-10-04"));
161
- expect(dailySeed("2026-10-04", "hex")).not.toBe(dailySeed("2026-10-04"));
162
- expect(() => dailySeed("2026-02-30")).toThrow();
163
- });
164
- });
165
-
166
- export { fixedGame };
167
-
168
- it("keeps assisted status through public progress and rejects invalid assisted data", () => {
169
- const game = { ...newGame(), helped: true };
170
- expect(gameFromProgress(gameProgress(game))?.helped).toBe(true);
171
- expect(gameFromProgress(JSON.stringify({ version: 1, settings: game.settings, moves: [], helped: "yes" }))).toBeNull();
172
- });
package/src/shape.test.ts DELETED
@@ -1,56 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { activeCell, activeCells } from "./shape.ts";
3
- import { DEFAULT_SETTINGS, GRIDS } from "./jirai.constants.ts";
4
- import { neighboursOf, validSettings } from "./grid.ts";
5
- import { makeBoard, isSolvable } from "./generate.ts";
6
- import { newGame, play, visibleGame } from "./game.ts";
7
- import { boardModel } from "./draw.ts";
8
- import { gameFromProgress, gameProgress } from "./keep.ts";
9
- import type { Settings } from "./jirai.types.ts";
10
-
11
- describe("shaped minefields", () => {
12
- for (const shape of ["heart", "star", "hexagon"] as const) for (const grid of [GRIDS.square, GRIDS.orthogonal, GRIDS.hex]) {
13
- it(`${shape}/${grid} omits cells and retains reciprocal neighbours and proved deals`, () => {
14
- const settings: Settings = { ...DEFAULT_SETTINGS, width: 17, height: 17, mines: 12, shape, grid };
15
- for (const outside of [-1, 289, 1.5, NaN]) expect(activeCell(settings, outside)).toBe(false);
16
- const cells = activeCells(settings), adjacent = neighboursOf(settings);
17
- expect(cells.length).toBeGreaterThan(40); expect(cells.length).toBeLessThan(289);
18
- for (const c of cells) for (const n of adjacent[c]!) {
19
- expect(activeCell(settings, n)).toBe(true); expect(adjacent[n]).toContain(c);
20
- }
21
- const first = cells[Math.floor(cells.length / 2)]!;
22
- for (let seed = 1; seed <= 12; seed += 1) {
23
- const board = makeBoard({ ...settings, seed }, first);
24
- expect(board.mines.filter(Boolean)).toHaveLength(12); expect(isSolvable(board)).toBe(true);
25
- for (let c = 0; c < 289; c += 1) if (!activeCell(settings, c)) {
26
- expect(board.mines[c]).toBe(false); expect(board.clues[c]).toBe(-2); expect(adjacent[c]).toEqual([]);
27
- }
28
- }
29
- });
30
- }
31
- it("never draws or lets the player act on cut-out cells, and restores the shape", () => {
32
- const settings: Settings = { ...DEFAULT_SETTINGS, width: 17, height: 17, shape: "heart", mines: 12 };
33
- let game = newGame(settings);
34
- const absent = Array.from({ length: 289 }, (_, c) => c).find(c => !activeCell(settings, c))!;
35
- expect(play(game, { kind: "reveal", cell: absent })).toBe(game);
36
- expect(play(game, { kind: "mark", cell: absent })).toBe(game);
37
- expect(visibleGame(game).clues[absent]).toBe(-2);
38
- expect(boardModel(game).cells.some(c => c.cell === absent)).toBe(false);
39
- game = play(game, { kind: "reveal", cell: activeCells(settings)[50]! });
40
- expect(gameFromProgress(gameProgress(game))?.settings.shape).toBe("heart");
41
- });
42
- it("wins when every active safe cell opens, without requiring absent cells", () => {
43
- const settings: Settings = { ...DEFAULT_SETTINGS, width: 9, height: 9, shape: "star", mines: 1, noGuess: false };
44
- let game = newGame(settings);
45
- const first = activeCells(settings)[10]!;
46
- game = play(game, { kind: "reveal", cell: first });
47
- for (const c of activeCells(settings)) if (!game.board!.mines[c]) game = play(game, { kind: "reveal", cell: c });
48
- expect(game.status).toBe("won");
49
- });
50
- it("rejects unknown outlines, tiny silhouettes, wrap cut-outs and excess mines", () => {
51
- expect(validSettings({ ...DEFAULT_SETTINGS, shape: "toString" })).toBe(false);
52
- expect(validSettings({ ...DEFAULT_SETTINGS, width: 5, height: 5, shape: "heart" })).toBe(false);
53
- expect(validSettings({ ...DEFAULT_SETTINGS, shape: "heart", grid: "wrap" })).toBe(false);
54
- expect(validSettings({ ...DEFAULT_SETTINGS, width: 9, height: 9, shape: "star", mines: 70 })).toBe(false);
55
- });
56
- });