@johnmorrisdotca/jirai 0.2.1 → 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.
package/dist/repair.js ADDED
@@ -0,0 +1,112 @@
1
+ /** How readily a move that leaves more safe cells covered is kept, to climb out of a dead end. Small: nearly all such moves are refused. */
2
+ const WARMTH = 0.3;
3
+ /** Tries without getting closer after which the search begins again from a new layout, at least. */
4
+ const PATIENCE = 1500;
5
+ /** How far from the stuck cell a mine may be moved from and to. */
6
+ const REACH = 2;
7
+ function pick(items, random) {
8
+ return items.length === 0 ? undefined : items[Math.floor(random() * items.length)];
9
+ }
10
+ /** Move a mine from `from` to `to`, keeping every clue in step. Calling it again with the arguments swapped undoes it. */
11
+ function move(solver, mines, clues, from, to) {
12
+ mines[from] = 0;
13
+ mines[to] = 1;
14
+ for (const cell of [from, to]) {
15
+ const delta = cell === to ? 1 : -1;
16
+ for (const n of solver.adjacent[cell])
17
+ if (!mines[n])
18
+ clues[n] += delta;
19
+ }
20
+ clues[to] = -1;
21
+ let count = 0;
22
+ for (const n of solver.adjacent[from])
23
+ if (mines[n])
24
+ count += 1;
25
+ clues[from] = count;
26
+ }
27
+ /**
28
+ * Make an unsolvable layout solvable by moving single mines next to the place the solver got stuck, never touching a
29
+ * pinned cell, and keeping a move when it leaves the solver no worse off (and now and then when it does, so that a
30
+ * dead end can be left). The search is counted in layouts tried, never in time, so a seed always gives the same board.
31
+ * `mines` and `clues` are changed in place and hold the answer when the outcome is solved.
32
+ */
33
+ export function repair(solver, mines, clues, first, pinned, random, budget, enumerate, restart) {
34
+ const around = (cell, radius) => {
35
+ const seen = new Set([cell]);
36
+ let ring = [cell];
37
+ for (let step = 0; step < radius; step += 1) {
38
+ const next = [];
39
+ for (const c of ring)
40
+ for (const n of solver.adjacent[c])
41
+ if (!seen.has(n)) {
42
+ seen.add(n);
43
+ next.push(n);
44
+ }
45
+ ring = next;
46
+ }
47
+ return [...seen].filter((c) => !pinned.has(c));
48
+ };
49
+ let report = solver.run(clues, first, enumerate);
50
+ let tries = 0, closest = report.remaining, idle = 0;
51
+ const patience = Math.max(PATIENCE, 2 * solver.cells);
52
+ while (!report.solved && tries < budget) {
53
+ tries += 1;
54
+ const frontier = [], covered = [], cutOff = [];
55
+ for (let cell = 0; cell < solver.cells; cell += 1) {
56
+ if (solver.state[cell] !== 0)
57
+ continue;
58
+ covered.push(cell);
59
+ if (solver.adjacent[cell].some((n) => solver.state[n] === 1))
60
+ frontier.push(cell);
61
+ else if (!mines[cell] && !pinned.has(cell))
62
+ cutOff.push(cell);
63
+ }
64
+ let from, to;
65
+ const lost = cutOff.length > 0 && random() < 0.5 ? pick(cutOff, random) : undefined;
66
+ if (lost !== undefined) {
67
+ // A safe cell no opened clue touches is walled in by mines. Either wall one in more, or take a wall mine away.
68
+ const walls = solver.adjacent[lost].filter((n) => mines[n] && !pinned.has(n));
69
+ if (walls.length > 0 && random() < 0.5) {
70
+ from = pick(walls, random);
71
+ to = pick(around(from, REACH).filter((c) => !mines[c]), random);
72
+ }
73
+ else {
74
+ to = lost;
75
+ from = pick(around(lost, REACH + 1).filter((c) => mines[c]), random);
76
+ }
77
+ }
78
+ else {
79
+ const focus = pick(frontier.length > 0 ? frontier : covered, random);
80
+ if (focus === undefined)
81
+ break;
82
+ let near = around(focus, REACH);
83
+ from = pick(near.filter((c) => mines[c]), random);
84
+ if (from === undefined) {
85
+ near = around(focus, REACH + 2);
86
+ from = pick(near.filter((c) => mines[c]), random);
87
+ }
88
+ to = pick(near.filter((c) => !mines[c]), random);
89
+ }
90
+ if (from === undefined || to === undefined)
91
+ continue;
92
+ move(solver, mines, clues, from, to);
93
+ const next = solver.run(clues, first, enumerate);
94
+ const worse = next.remaining - report.remaining;
95
+ if (worse < 0 || (worse === 0 && next.unresolved <= report.unresolved) || (worse > 0 && random() < Math.exp(-worse / WARMTH)))
96
+ report = next;
97
+ else
98
+ move(solver, mines, clues, to, from);
99
+ if (report.remaining < closest) {
100
+ closest = report.remaining;
101
+ idle = 0;
102
+ }
103
+ else if (++idle > patience) {
104
+ // Stuck for a long while in one corner of the search: begin again from a new layout.
105
+ restart();
106
+ report = solver.run(clues, first, enumerate);
107
+ closest = report.remaining;
108
+ idle = 0;
109
+ }
110
+ }
111
+ return { solved: report.solved, tries, remaining: report.remaining };
112
+ }
@@ -0,0 +1,79 @@
1
+ import type { Settings } from "./jirai.types.ts";
2
+ /** Why a cell was proved: one clue, two overlapping clues, the mine counter, or every arrangement of a small frontier. */
3
+ export type ProofKind = "count" | "overlap" | "total" | "enumeration";
4
+ /** Cells proved by each kind of reasoning. */
5
+ export type ProofTally = Record<ProofKind, number>;
6
+ /** What a run of the deduction solver found. */
7
+ export type SolveReport = {
8
+ /** Every safe cell was opened using only proved deductions. */
9
+ solved: boolean;
10
+ /** Safe cells still covered when the solver stopped; zero when solved. */
11
+ remaining: number;
12
+ /** Safe cells opened, including the free opening. */
13
+ opened: number;
14
+ /** Covered cells, safe or mine, nothing has been proved about. */
15
+ unresolved: number;
16
+ /** Cells opened by the first reveal and its flood, before any deduction. */
17
+ free: number;
18
+ /** Cells proved (safe or mine) by each kind of reasoning. */
19
+ proved: ProofTally;
20
+ /** The longest chain of deductions, each resting on the one before. */
21
+ depth: number;
22
+ };
23
+ /**
24
+ * The same deductions as `deduce` (one clue, two overlapping clues, the mine counter, exact enumeration of a
25
+ * small frontier), run to a fixed point over one board in a single pass instead of one deduction per scan of the
26
+ * whole field. A deduction found here is a deduction `deduce` would find; the order differs, the closure does not.
27
+ * Built once for a set of settings, then `run` as often as needed on different mine layouts.
28
+ */
29
+ export declare class Solver {
30
+ readonly settings: Settings;
31
+ readonly adjacent: readonly (readonly number[])[];
32
+ readonly cells: number;
33
+ readonly safeTotal: number;
34
+ readonly mineTotal: number;
35
+ readonly state: Uint8Array;
36
+ private readonly start;
37
+ private readonly need;
38
+ private readonly unknown;
39
+ private readonly chain;
40
+ private readonly dirty;
41
+ private readonly searched;
42
+ private readonly marks;
43
+ private readonly seen;
44
+ private readonly visit;
45
+ private readonly queue;
46
+ private readonly stack;
47
+ private clues;
48
+ private stamp;
49
+ private covered;
50
+ private left;
51
+ private opened;
52
+ private free;
53
+ private deepestMine;
54
+ private depth;
55
+ private broken;
56
+ private searching;
57
+ private proved;
58
+ constructor(settings: Settings, adjacent?: readonly (readonly number[])[]);
59
+ /** Clues of a layout: -1 for a mine, -2 outside the shape, otherwise the count of neighbouring mines. */
60
+ clueFor(mines: ArrayLike<number | boolean>, into?: Int8Array): Int8Array;
61
+ /** Play the opening at `first` and every deduction after it. `clues` is read, never changed. */
62
+ run(clues: Int8Array, first: number, enumerate?: boolean): SolveReport;
63
+ private report;
64
+ private touch;
65
+ /** Open a cell known to be safe, and flood through every zero. `kind` null is the free opening. */
66
+ private reveal;
67
+ private flag;
68
+ private settle;
69
+ /** One deduction from the clue at `cell` alone, or from it and an overlapping clue. */
70
+ private step;
71
+ /** The mine counter: nothing left to find, everything left a mine, or a clue that accounts for all that remain. */
72
+ private counter;
73
+ /**
74
+ * Exact enumeration of every frontier small enough, as `enumerateForced` does. Every component is read from the
75
+ * same position and the answers are applied together, exactly as one `deduce` call does, so that this solver and
76
+ * the hint engine finish the same boards: with a cap on the size of a frontier, the order of enumerating matters.
77
+ */
78
+ private enumerate;
79
+ }
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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@johnmorrisdotca/jirai",
3
- "version": "0.2.1",
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",
@@ -30,6 +30,7 @@
30
30
  "wraparound-grid",
31
31
  "no-guess",
32
32
  "no-guess-minesweeper",
33
+ "difficulty-levels",
33
34
  "seeded-puzzle",
34
35
  "daily-puzzle",
35
36
  "puzzle-generator",
package/src/generate.ts CHANGED
@@ -1,10 +1,10 @@
1
- import { activeCell, activeCells } from "./shape.ts";
2
- import { deduce } from "./deduce.ts";
3
- import { flood } from "./flood.ts";
4
- import { GENERATION_ATTEMPTS, MARKS, STATUSES } from "./jirai.constants.ts";
1
+ import { activeCells } from "./shape.ts";
2
+ import { GENERATION_ATTEMPTS } from "./jirai.constants.ts";
5
3
  import { neighbours, neighboursOf, validCell, validSettings } from "./grid.ts";
4
+ import { repair } from "./repair.ts";
6
5
  import { seededRandom, shuffled } from "./random.ts";
7
- import type { Board, GenerationOptions, Settings, VisibleGame } from "./jirai.types.ts";
6
+ import { Solver } from "./solve.ts";
7
+ import type { Board, GenerationOptions, Settings } from "./jirai.types.ts";
8
8
 
9
9
  /** A bounded generator must say it failed, never quietly return a guessing board. */
10
10
  export class GenerationError extends Error {
@@ -13,45 +13,72 @@ export class GenerationError extends Error {
13
13
 
14
14
  /** Verify a board from one opening, using only clues that have been uncovered. */
15
15
  export function isSolvable(board: Board, enumerate = true): boolean {
16
- const adjacent = neighboursOf(board.settings);
17
- const open = board.clues.map(() => false);
18
- const known = new Set<number>();
19
- flood(board.clues, adjacent, open, [board.first]);
20
- while (open.filter(Boolean).length < activeCells(board.settings).length - board.settings.mines) {
21
- const visible: VisibleGame = {
22
- settings: board.settings, clues: board.clues.map((n, cell) => activeCell(board.settings, cell) ? open[cell] ? n : null : -2),
23
- marks: open.map((yes) => yes ? MARKS.open : MARKS.covered), status: STATUSES.playing,
24
- };
25
- const result = deduce(visible, known, enumerate);
26
- if (result.contradiction) return false;
27
- let changed = false;
28
- for (const cell of result.mines) {
29
- if (!board.mines[cell]) return false;
30
- if (!known.has(cell)) { known.add(cell); changed = true; }
31
- }
32
- if (result.safe.some((cell) => board.mines[cell])) return false;
33
- if (result.safe.some((cell) => !open[cell])) { flood(board.clues, adjacent, open, result.safe); changed = true; }
34
- if (!changed) return false;
35
- }
36
- return true;
16
+ const solver = new Solver(board.settings);
17
+ return solver.run(Int8Array.from(board.clues), board.first, enumerate).solved;
37
18
  }
38
19
 
39
- /** Deal after the first reveal. A seed fixes the whole candidate stream and the accepted board. */
20
+ /** Layouts the repair stage may try, per cell of the field. */
21
+ const REPAIRS_PER_CELL = 12;
22
+
23
+ /**
24
+ * Deal after the first reveal. A seed fixes the whole candidate stream and the accepted board.
25
+ *
26
+ * A no-guess deal first draws random layouts, as 0.1 and 0.2 did, so every board those versions dealt is still dealt.
27
+ * If none of them can be finished by deduction it takes the closest and repairs it: single mines are moved near the
28
+ * place the solver stopped until the solver finishes the whole field. The result is proved the same way either route.
29
+ */
40
30
  export function makeBoard(settings: Settings, first: number, options: GenerationOptions = {}): Board {
41
31
  if (!validSettings(settings) || !validCell(settings, first)) throw new GenerationError("settings", "Invalid board settings or opening cell.");
42
32
  const protectedCells = new Set([first, ...(settings.opening === "clear" ? neighbours(settings, first) : [])]);
43
- const available = Array.from({ length: settings.width * settings.height }, (_, cell) => cell).filter((cell) => validCell(settings, cell) && !protectedCells.has(cell));
33
+ const available = activeCells(settings).filter((cell) => !protectedCells.has(cell));
44
34
  if (available.length < settings.mines) throw new GenerationError("opening", "Too many mines for a safe opening at this cell.");
35
+ if (settings.noGuess && neighbours(settings, first).length === 0 && available.length > settings.mines)
36
+ throw new GenerationError("opening", "The opening cell touches no other cell, so no clue can follow from it. Open another cell.");
45
37
  const attempts = options.attempts ?? GENERATION_ATTEMPTS;
46
38
  if (!Number.isInteger(attempts) || attempts < 1 || attempts > 10_000) throw new GenerationError("settings", "Attempts must be between 1 and 10000.");
39
+ const repairs = options.repairs ?? (options.repair === false ? 0 : REPAIRS_PER_CELL * settings.width * settings.height);
40
+ if (!Number.isInteger(repairs) || repairs < 0 || repairs > 1_000_000) throw new GenerationError("settings", "Repairs must be between 0 and 1000000.");
41
+ const enumerate = options.enumerate ?? true;
47
42
  const random = seededRandom(settings.seed);
48
43
  const adjacent = neighboursOf(settings);
44
+ const solver = new Solver(settings, adjacent);
45
+ const cells = settings.width * settings.height;
46
+ const layout = new Uint8Array(cells), clues = new Int8Array(cells);
47
+ let best: Uint8Array | null = null, fewest = Infinity;
48
+ const board = (attempt: number): Board => ({
49
+ settings: { ...settings }, mines: Array.from(layout, Boolean), clues: Array.from(clues), first, attempt,
50
+ });
49
51
  for (let attempt = 0; attempt < (settings.noGuess ? attempts : 1); attempt += 1) {
50
- const places = new Set(shuffled(available, random).slice(0, settings.mines));
51
- const mines = available.length ? Array.from({ length: settings.width * settings.height }, (_, cell) => places.has(cell)) : [];
52
- const clues = mines.map((mine, cell) => !activeCell(settings, cell) ? -2 : mine ? -1 : adjacent[cell]!.filter((n) => mines[n]).length);
53
- const board: Board = { settings: { ...settings }, mines, clues, first, attempt };
54
- if (!settings.noGuess || isSolvable(board, options.enumerate ?? true)) return board;
52
+ layout.fill(0);
53
+ for (const cell of shuffled(available, random).slice(0, settings.mines)) layout[cell] = 1;
54
+ solver.clueFor(layout, clues);
55
+ if (!settings.noGuess) return board(attempt);
56
+ const report = solver.run(clues, first, enumerate);
57
+ if (report.solved) return board(attempt);
58
+ if (report.remaining < fewest) { fewest = report.remaining; best = layout.slice(); }
59
+ }
60
+ if (best !== null && repairs > 0) {
61
+ // A cell no chain of neighbours reaches from the opening can never be told by a clue. Make each a mine and leave it
62
+ // be: nobody has to find a mine to win, and nobody could find that one. Fewer mines than such cells: do not try.
63
+ const reached = new Set([first]);
64
+ for (const cell of reached) for (const n of adjacent[cell]!) reached.add(n);
65
+ const stranded = available.filter((cell) => !reached.has(cell));
66
+ if (stranded.length <= settings.mines) {
67
+ const reachable = available.filter((cell) => reached.has(cell));
68
+ const place = (): void => {
69
+ layout.fill(0);
70
+ for (const cell of stranded) layout[cell] = 1;
71
+ for (const cell of shuffled(reachable, random).slice(0, settings.mines - stranded.length)) layout[cell] = 1;
72
+ solver.clueFor(layout, clues);
73
+ };
74
+ // Begin from the closest of the random layouts, with the stranded cells made mines at the cost of other mines.
75
+ layout.set(best);
76
+ const movable = reachable.filter((cell) => layout[cell]);
77
+ for (const cell of shuffled(stranded.filter((c) => !layout[c]), random)) { const from = movable.pop(); layout[from!] = 0; layout[cell] = 1; }
78
+ solver.clueFor(layout, clues);
79
+ const outcome = repair(solver, layout, clues, first, new Set([...protectedCells, ...stranded]), random, repairs, enumerate, place);
80
+ if (outcome.solved) return board(attempts + outcome.tries);
81
+ }
55
82
  }
56
83
  throw new GenerationError("exhausted", "No verified board found within the work budget. Try another seed, fewer mines, or a different opening.");
57
84
  }
package/src/index.ts CHANGED
@@ -8,8 +8,13 @@ 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.2.1";
11
+ export const VERSION = "0.3.0";
12
12
 
13
13
  export { SHAPES, activeCell, activeCells } from "./shape.ts";
14
14
 
15
15
  export * from "./orthogonal.ts";
16
+ export { measureBoard } from "./measure.ts";
17
+ export type { Measure } from "./measure.ts";
18
+ export type { ProofKind, ProofTally } from "./solve.ts";
19
+ export { levelNamed, levelSettings } from "./levels.ts";
20
+ export type { LevelSize } from "./levels.ts";