@johnmorrisdotca/jirai 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +137 -2
- package/dist/deduce.d.ts +8 -0
- package/dist/deduce.js +85 -0
- package/dist/draw-entry.d.ts +3 -0
- package/dist/draw-entry.js +2 -0
- package/dist/draw.d.ts +4 -0
- package/dist/draw.js +26 -0
- package/dist/element-define.d.ts +1 -0
- package/dist/element-define.js +3 -0
- package/dist/element.d.ts +14 -0
- package/dist/element.js +28 -0
- package/dist/enumerate.d.ts +6 -0
- package/dist/enumerate.js +83 -0
- package/dist/flood.d.ts +2 -0
- package/dist/flood.js +18 -0
- package/dist/game.d.ts +8 -0
- package/dist/game.js +61 -0
- package/dist/generate.d.ts +10 -0
- package/dist/generate.js +72 -0
- package/dist/grid.d.ts +7 -0
- package/dist/grid.js +46 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/jirai.constants.d.ts +62 -0
- package/dist/jirai.constants.js +24 -0
- package/dist/jirai.types.d.ts +66 -0
- package/dist/jirai.types.js +1 -0
- package/dist/keep.d.ts +7 -0
- package/dist/keep.js +47 -0
- package/dist/mount.d.ts +7 -0
- package/dist/mount.js +295 -0
- package/dist/orthogonal.d.ts +16 -0
- package/dist/orthogonal.js +39 -0
- package/dist/play-entry.d.ts +3 -0
- package/dist/play-entry.js +2 -0
- package/dist/random.d.ts +4 -0
- package/dist/random.js +20 -0
- package/dist/react.d.ts +3 -0
- package/dist/react.js +17 -0
- package/dist/react.types.d.ts +3 -0
- package/dist/react.types.js +1 -0
- package/dist/shape.d.ts +5 -0
- package/dist/shape.js +33 -0
- package/dist/strings.d.ts +70 -0
- package/dist/strings.js +17 -0
- package/dist/style.d.ts +2 -0
- package/dist/style.js +22 -0
- package/dist/ui.types.d.ts +41 -0
- package/dist/ui.types.js +1 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +10 -0
- package/package.json +103 -4
- package/src/deduce.test.ts +47 -0
- package/src/deduce.ts +66 -0
- package/src/draw-entry.ts +3 -0
- package/src/draw.ts +28 -0
- package/src/element-define.ts +2 -0
- package/src/element.ts +24 -0
- package/src/enumerate.ts +60 -0
- package/src/flood.ts +12 -0
- package/src/game.test.ts +172 -0
- package/src/game.ts +55 -0
- package/src/generate.ts +57 -0
- package/src/grid.ts +42 -0
- package/src/index.ts +14 -0
- package/src/jirai.constants.ts +26 -0
- package/src/jirai.types.ts +56 -0
- package/src/keep.ts +41 -0
- package/src/mount.ts +176 -0
- package/src/orthogonal.ts +49 -0
- package/src/play-entry.ts +3 -0
- package/src/random.ts +20 -0
- package/src/react.tsx +17 -0
- package/src/react.types.ts +3 -0
- package/src/shape.test.ts +56 -0
- package/src/shape.ts +32 -0
- package/src/strings.ts +18 -0
- package/src/style.ts +22 -0
- package/src/ui.types.ts +22 -0
- package/src/worker.ts +7 -0
package/src/flood.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Reveal connected empty ground and its numbered boundary, without recursion. */
|
|
2
|
+
export function flood(clues: readonly number[], adjacent: readonly (readonly number[])[], open: boolean[], starts: readonly number[], blocked: ReadonlySet<number> = new Set()): void {
|
|
3
|
+
const queue = [...starts];
|
|
4
|
+
const queued = new Set(queue);
|
|
5
|
+
for (let at = 0; at < queue.length; at += 1) {
|
|
6
|
+
const cell = queue[at]!;
|
|
7
|
+
if (open[cell] || blocked.has(cell) || clues[cell] === undefined || clues[cell]! < 0) continue;
|
|
8
|
+
open[cell] = true;
|
|
9
|
+
if (clues[cell] !== 0) continue;
|
|
10
|
+
for (const n of adjacent[cell]!) if (!open[n] && !queued.has(n)) { queue.push(n); queued.add(n); }
|
|
11
|
+
}
|
|
12
|
+
}
|
package/src/game.test.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
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/game.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { activeCell, activeCells } from "./shape.ts";
|
|
2
|
+
import { flood } from "./flood.ts";
|
|
3
|
+
import { makeBoard } from "./generate.ts";
|
|
4
|
+
import { DEFAULT_SETTINGS, MARKS, MOVES, STATUSES } from "./jirai.constants.ts";
|
|
5
|
+
import { neighbours, neighboursOf, validCell, validSettings } from "./grid.ts";
|
|
6
|
+
import type { Board, Game, Move, Settings, VisibleGame } from "./jirai.types.ts";
|
|
7
|
+
|
|
8
|
+
export function newGame(settings: Settings = DEFAULT_SETTINGS): Game {
|
|
9
|
+
if (!validSettings(settings)) throw new RangeError("Invalid Minesweeper settings.");
|
|
10
|
+
return { settings: { ...settings }, board: null, marks: Array.from({ length: settings.width * settings.height }, () => MARKS.covered), status: STATUSES.ready, exploded: null, moves: [], helped: false };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The clue-only view used by the solver. No hidden mine, including under a flag, survives it. */
|
|
14
|
+
export function visibleGame(game: Game): VisibleGame {
|
|
15
|
+
return { settings: { ...game.settings }, marks: [...game.marks], status: game.status,
|
|
16
|
+
clues: game.marks.map((mark, cell) => !activeCell(game.settings, cell) ? -2 : mark === MARKS.open ? game.board?.clues[cell] ?? null : null) };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Accept a worker's dealt board without changing any moves made before the opening. */
|
|
20
|
+
export function withBoard(game: Game, board: Board): Game {
|
|
21
|
+
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
|
+
return { ...game, board };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A legal move returns a new game; an unavailable move returns the same one. */
|
|
26
|
+
export function play(game: Game, move: Move): Game {
|
|
27
|
+
if (!validCell(game.settings, move.cell) || game.status === STATUSES.won || game.status === STATUSES.lost) return game;
|
|
28
|
+
const before = game.marks[move.cell]!;
|
|
29
|
+
if (move.kind === MOVES.mark) {
|
|
30
|
+
if (before === MARKS.open) return game;
|
|
31
|
+
const marks = [...game.marks];
|
|
32
|
+
marks[move.cell] = before === MARKS.covered ? MARKS.flag : before === MARKS.flag ? MARKS.question : MARKS.covered;
|
|
33
|
+
return { ...game, marks, moves: [...game.moves, { ...move }] };
|
|
34
|
+
}
|
|
35
|
+
if (move.kind !== MOVES.reveal && move.kind !== MOVES.chord) return game;
|
|
36
|
+
if (before === MARKS.flag || (move.kind === MOVES.chord && before !== MARKS.open)) return game;
|
|
37
|
+
const board = game.board ?? makeBoard(game.settings, move.cell);
|
|
38
|
+
let starts = [move.cell];
|
|
39
|
+
if (before === MARKS.open) {
|
|
40
|
+
const around = neighbours(game.settings, move.cell);
|
|
41
|
+
if (around.filter((cell) => game.marks[cell] === MARKS.flag).length !== board.clues[move.cell]) return game;
|
|
42
|
+
starts = around.filter((cell) => game.marks[cell] !== MARKS.flag && game.marks[cell] !== MARKS.open);
|
|
43
|
+
}
|
|
44
|
+
if (!starts.length) return game;
|
|
45
|
+
const marks = [...game.marks];
|
|
46
|
+
const exploded = starts.find((cell) => board.mines[cell]) ?? null;
|
|
47
|
+
const open = marks.map((mark) => mark === MARKS.open);
|
|
48
|
+
const blocked = new Set(marks.flatMap((mark, cell) => mark === MARKS.flag ? [cell] : []));
|
|
49
|
+
flood(board.clues, neighboursOf(game.settings), open, starts, blocked);
|
|
50
|
+
for (let cell = 0; cell < marks.length; cell += 1) if (open[cell]) marks[cell] = MARKS.open;
|
|
51
|
+
if (exploded !== null) marks[exploded] = MARKS.open;
|
|
52
|
+
const status = exploded !== null ? STATUSES.lost
|
|
53
|
+
: open.filter(Boolean).length === activeCells(game.settings).length - game.settings.mines ? STATUSES.won : STATUSES.playing;
|
|
54
|
+
return { ...game, board, marks, status, exploded, moves: [...game.moves, { ...move }] };
|
|
55
|
+
}
|
package/src/generate.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
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";
|
|
5
|
+
import { neighbours, neighboursOf, validCell, validSettings } from "./grid.ts";
|
|
6
|
+
import { seededRandom, shuffled } from "./random.ts";
|
|
7
|
+
import type { Board, GenerationOptions, Settings, VisibleGame } from "./jirai.types.ts";
|
|
8
|
+
|
|
9
|
+
/** A bounded generator must say it failed, never quietly return a guessing board. */
|
|
10
|
+
export class GenerationError extends Error {
|
|
11
|
+
constructor(public readonly code: "settings" | "opening" | "exhausted", message: string) { super(message); this.name = "GenerationError"; }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Verify a board from one opening, using only clues that have been uncovered. */
|
|
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;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Deal after the first reveal. A seed fixes the whole candidate stream and the accepted board. */
|
|
40
|
+
export function makeBoard(settings: Settings, first: number, options: GenerationOptions = {}): Board {
|
|
41
|
+
if (!validSettings(settings) || !validCell(settings, first)) throw new GenerationError("settings", "Invalid board settings or opening cell.");
|
|
42
|
+
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));
|
|
44
|
+
if (available.length < settings.mines) throw new GenerationError("opening", "Too many mines for a safe opening at this cell.");
|
|
45
|
+
const attempts = options.attempts ?? GENERATION_ATTEMPTS;
|
|
46
|
+
if (!Number.isInteger(attempts) || attempts < 1 || attempts > 10_000) throw new GenerationError("settings", "Attempts must be between 1 and 10000.");
|
|
47
|
+
const random = seededRandom(settings.seed);
|
|
48
|
+
const adjacent = neighboursOf(settings);
|
|
49
|
+
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;
|
|
55
|
+
}
|
|
56
|
+
throw new GenerationError("exhausted", "No verified board found within the work budget. Try another seed, fewer mines, or a different opening.");
|
|
57
|
+
}
|
package/src/grid.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { GRID_SPECS, MAX_CELLS, MAX_SIDE } from "./jirai.constants.ts";
|
|
2
|
+
import { activeCell, activeCells, SHAPES } from "./shape.ts";
|
|
3
|
+
import type { Settings } from "./jirai.types.ts";
|
|
4
|
+
|
|
5
|
+
/** Reject a setting before it reaches a board allocation or a shuffle. */
|
|
6
|
+
export function validSettings(value: unknown): value is Settings {
|
|
7
|
+
if (value === null || typeof value !== "object") return false;
|
|
8
|
+
const s = value as Settings;
|
|
9
|
+
return Number.isInteger(s.width) && s.width >= 3 && s.width <= MAX_SIDE
|
|
10
|
+
&& Number.isInteger(s.height) && s.height >= 3 && s.height <= MAX_SIDE
|
|
11
|
+
&& s.width * s.height <= MAX_CELLS
|
|
12
|
+
&& Number.isInteger(s.mines) && s.mines >= 1 && s.mines < s.width * s.height
|
|
13
|
+
&& (s.shape === undefined || SHAPES.includes(s.shape))
|
|
14
|
+
&& ((s.shape ?? "rectangle") === "rectangle" || (s.width >= 9 && s.height >= 9 && s.grid !== "wrap"))
|
|
15
|
+
&& s.mines < activeCells(s).length
|
|
16
|
+
&& Object.hasOwn(GRID_SPECS, s.grid) && typeof s.noGuess === "boolean"
|
|
17
|
+
&& (s.opening === "safe" || s.opening === "clear")
|
|
18
|
+
&& Number.isInteger(s.seed) && s.seed >= 0 && s.seed <= 0xffffffff;
|
|
19
|
+
}
|
|
20
|
+
export function validCell(settings: Settings, cell: number): boolean {
|
|
21
|
+
return Number.isInteger(cell) && cell >= 0 && cell < settings.width * settings.height && activeCell(settings, cell);
|
|
22
|
+
}
|
|
23
|
+
/** Every neighbour once. Wrap joins both pairs of opposite edges. */
|
|
24
|
+
export function neighbours(settings: Settings, cell: number): number[] {
|
|
25
|
+
if (!validCell(settings, cell)) return [];
|
|
26
|
+
const spec = GRID_SPECS[settings.grid];
|
|
27
|
+
const x = cell % settings.width;
|
|
28
|
+
const y = Math.floor(cell / settings.width);
|
|
29
|
+
const out = new Set<number>();
|
|
30
|
+
for (const [dx, dy] of spec.offsets) {
|
|
31
|
+
let nx = x + dx, ny = y + dy;
|
|
32
|
+
if (spec.wrap) {
|
|
33
|
+
nx = (nx + settings.width) % settings.width;
|
|
34
|
+
ny = (ny + settings.height) % settings.height;
|
|
35
|
+
}
|
|
36
|
+
if (nx >= 0 && nx < settings.width && ny >= 0 && ny < settings.height) { const next = ny * settings.width + nx; if (validCell(settings, next)) out.add(next); }
|
|
37
|
+
}
|
|
38
|
+
return [...out].sort((a, b) => a - b);
|
|
39
|
+
}
|
|
40
|
+
export function neighboursOf(settings: Settings): number[][] {
|
|
41
|
+
return Array.from({ length: settings.width * settings.height }, (_, cell) => neighbours(settings, cell));
|
|
42
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Jirai 地雷: the rules and deductions, plain functions over plain data. No DOM and no dependencies. */
|
|
2
|
+
export * from "./jirai.types.ts";
|
|
3
|
+
export * from "./jirai.constants.ts";
|
|
4
|
+
export * from "./grid.ts";
|
|
5
|
+
export * from "./generate.ts";
|
|
6
|
+
export * from "./game.ts";
|
|
7
|
+
export * from "./deduce.ts";
|
|
8
|
+
export * from "./keep.ts";
|
|
9
|
+
export { seededRandom } from "./random.ts";
|
|
10
|
+
export const VERSION = "0.2.0";
|
|
11
|
+
|
|
12
|
+
export { SHAPES, activeCell, activeCells } from "./shape.ts";
|
|
13
|
+
|
|
14
|
+
export * from "./orthogonal.ts";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Grid, Settings } from "./jirai.types.ts";
|
|
2
|
+
|
|
3
|
+
export const GRIDS = { square: "square", orthogonal: "orthogonal", hex: "hex", wrap: "wrap" } as const;
|
|
4
|
+
export const STATUSES = { ready: "ready", playing: "playing", won: "won", lost: "lost" } as const;
|
|
5
|
+
export const MARKS = { covered: "covered", flag: "flag", question: "question", open: "open" } as const;
|
|
6
|
+
export const MOVES = { reveal: "reveal", mark: "mark", chord: "chord" } as const;
|
|
7
|
+
/** Hex coordinates are axial: a row is displaced half a cell to the right. */
|
|
8
|
+
export const GRID_SPECS: Record<Grid, { offsets: readonly (readonly [number, number])[]; wrap: boolean }> = {
|
|
9
|
+
square: { offsets: [[-1,-1],[0,-1],[1,-1],[-1,0],[1,0],[-1,1],[0,1],[1,1]], wrap: false },
|
|
10
|
+
orthogonal: { offsets: [[0,-1],[-1,0],[1,0],[0,1]], wrap: false },
|
|
11
|
+
hex: { offsets: [[-1,0],[1,0],[0,-1],[1,-1],[-1,1],[0,1]], wrap: false },
|
|
12
|
+
wrap: { offsets: [[-1,-1],[0,-1],[1,-1],[-1,0],[1,0],[-1,1],[0,1],[1,1]], wrap: true },
|
|
13
|
+
};
|
|
14
|
+
export const PRESETS = {
|
|
15
|
+
beginner: { width: 9, height: 9, mines: 10 },
|
|
16
|
+
intermediate: { width: 16, height: 16, mines: 40 },
|
|
17
|
+
expert: { width: 30, height: 16, mines: 99 },
|
|
18
|
+
wide: { width: 21, height: 9, mines: 24 },
|
|
19
|
+
tall: { width: 9, height: 21, mines: 24 },
|
|
20
|
+
} as const;
|
|
21
|
+
export const DEFAULT_SETTINGS: Settings = { ...PRESETS.beginner, grid: GRIDS.square, noGuess: true, opening: "clear", seed: 1 };
|
|
22
|
+
export const MAX_SIDE = 60;
|
|
23
|
+
export const MAX_CELLS = 2400;
|
|
24
|
+
export const GENERATION_ATTEMPTS = 128;
|
|
25
|
+
export const ENUMERATION_CELLS = 18;
|
|
26
|
+
export const ENUMERATION_NODES = 100_000;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** A cell's number is its row-major place on the board, starting at zero. */
|
|
2
|
+
export type Grid = "square" | "orthogonal" | "hex" | "wrap";
|
|
3
|
+
export type Status = "ready" | "playing" | "won" | "lost";
|
|
4
|
+
export type Mark = "covered" | "flag" | "question" | "open";
|
|
5
|
+
export type Settings = {
|
|
6
|
+
/** Shape omits cells; rectangle is the compatible default. Wrap requires rectangle. */
|
|
7
|
+
shape?: "rectangle" | "heart" | "star" | "hexagon";
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
mines: number;
|
|
11
|
+
grid: Grid;
|
|
12
|
+
/** Only accept boards the deduction solver finishes from the opening. */
|
|
13
|
+
noGuess: boolean;
|
|
14
|
+
/** The opening and every neighbour are safe, not only the first cell. */
|
|
15
|
+
opening: "safe" | "clear";
|
|
16
|
+
seed: number;
|
|
17
|
+
};
|
|
18
|
+
export type Move = { kind: "reveal" | "mark" | "chord"; cell: number };
|
|
19
|
+
export type Board = {
|
|
20
|
+
settings: Settings;
|
|
21
|
+
mines: readonly boolean[];
|
|
22
|
+
clues: readonly number[];
|
|
23
|
+
first: number;
|
|
24
|
+
attempt: number;
|
|
25
|
+
};
|
|
26
|
+
/** Rules data includes the answer; never send it to a competitive client. Use visibleGame for hints and drawing. */
|
|
27
|
+
export type Game = {
|
|
28
|
+
settings: Settings;
|
|
29
|
+
board: Board | null;
|
|
30
|
+
marks: readonly Mark[];
|
|
31
|
+
status: Status;
|
|
32
|
+
exploded: number | null;
|
|
33
|
+
moves: readonly Move[];
|
|
34
|
+
/** A proved hint has been shown during this run. */
|
|
35
|
+
helped: boolean;
|
|
36
|
+
};
|
|
37
|
+
export type VisibleGame = {
|
|
38
|
+
settings: Settings;
|
|
39
|
+
/** Null is hidden, including flags. Only opened cells give a clue. */
|
|
40
|
+
clues: readonly (number | null)[];
|
|
41
|
+
marks: readonly Mark[];
|
|
42
|
+
status: Status;
|
|
43
|
+
};
|
|
44
|
+
export type Constraint = { cells: readonly number[]; mines: number; sources: readonly number[] };
|
|
45
|
+
export type Deduction = {
|
|
46
|
+
safe: readonly number[];
|
|
47
|
+
mines: readonly number[];
|
|
48
|
+
reason: "count" | "overlap" | "total" | "enumeration" | "none";
|
|
49
|
+
sources: readonly number[];
|
|
50
|
+
/** A conflicting clue, or a board with no consistent mine placement. */
|
|
51
|
+
contradiction: boolean;
|
|
52
|
+
};
|
|
53
|
+
export type GenerationOptions = { attempts?: number; enumerate?: boolean };
|
|
54
|
+
export type Language = "en" | "ja";
|
|
55
|
+
export type Material = "ivory" | "wood" | "slate";
|
|
56
|
+
export type Pieces = "flags" | "stones" | "flowers";
|
package/src/keep.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { newGame, play } from "./game.ts";
|
|
2
|
+
import { validSettings } from "./grid.ts";
|
|
3
|
+
import type { Game, Move } from "./jirai.types.ts";
|
|
4
|
+
|
|
5
|
+
/** Keep settings and moves, never an unchecked answer array. Version the deal before changing it. */
|
|
6
|
+
export function gameProgress(game: Game): string {
|
|
7
|
+
if (game.settings.grid === "orthogonal") {
|
|
8
|
+
return JSON.stringify({ version: 2, variant: "orthogonal", settings: game.settings, moves: game.moves, helped: game.helped });
|
|
9
|
+
}
|
|
10
|
+
return JSON.stringify({ version: 1, settings: game.settings, moves: game.moves, helped: game.helped });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A saved game is rebuilt under the rules. Malformed or unavailable boards return null. */
|
|
14
|
+
export function gameFromProgress(progress: string): Game | null {
|
|
15
|
+
if (progress.length > 1_000_000) return null;
|
|
16
|
+
try {
|
|
17
|
+
const data = JSON.parse(progress);
|
|
18
|
+
const orthogonalCode = data.version === 2 && data.variant === "orthogonal";
|
|
19
|
+
if ((!orthogonalCode && data.version !== 1) || !validSettings(data.settings)
|
|
20
|
+
|| orthogonalCode !== (data.settings.grid === "orthogonal")
|
|
21
|
+
|| !Array.isArray(data.moves) || data.moves.length > 20_000) return null;
|
|
22
|
+
if (data.helped !== undefined && typeof data.helped !== "boolean") return null;
|
|
23
|
+
let game = newGame(data.settings);
|
|
24
|
+
for (const move of data.moves) {
|
|
25
|
+
if (move === null || typeof move !== "object" || !["reveal", "mark", "chord"].includes(move.kind) || !Number.isInteger(move.cell)) return null;
|
|
26
|
+
const next = play(game, move as Move);
|
|
27
|
+
if (next === game) return null;
|
|
28
|
+
game = next;
|
|
29
|
+
}
|
|
30
|
+
return { ...game, helped: data.helped ?? false };
|
|
31
|
+
} catch { return null; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** UTC by definition, so two people sharing a daily seed get the same board. */
|
|
35
|
+
export function dailySeed(day: string, grid = "square"): number {
|
|
36
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || new Date(`${day}T00:00:00Z`).toISOString().slice(0, 10) !== day) throw new RangeError("Use a real day as YYYY-MM-DD.");
|
|
37
|
+
let hash = 2166136261;
|
|
38
|
+
const version = grid === "orthogonal" ? 2 : 1;
|
|
39
|
+
for (const letter of `jirai:${version}:${day}:${grid}`) hash = Math.imul(hash ^ letter.charCodeAt(0), 16777619) >>> 0;
|
|
40
|
+
return hash;
|
|
41
|
+
}
|