@johnmorrisdotca/jirai 0.0.0-stage → 0.1.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 +9 -0
- package/LICENSE +21 -0
- package/README.md +135 -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 +11 -0
- package/dist/index.js +11 -0
- package/dist/jirai.constants.d.ts +61 -0
- package/dist/jirai.constants.js +23 -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 +40 -0
- package/dist/mount.d.ts +7 -0
- package/dist/mount.js +295 -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 +43 -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 +129 -0
- package/src/game.ts +55 -0
- package/src/generate.ts +57 -0
- package/src/grid.ts +42 -0
- package/src/index.ts +12 -0
- package/src/jirai.constants.ts +25 -0
- package/src/jirai.types.ts +56 -0
- package/src/keep.ts +34 -0
- package/src/mount.ts +176 -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/game.test.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
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, validSettings } from "./grid.ts";
|
|
6
|
+
import { gameFromProgress, gameProgress, dailySeed } from "./keep.ts";
|
|
7
|
+
import { boardModel } from "./draw.ts";
|
|
8
|
+
import type { Board, Game, Settings } from "./jirai.types.ts";
|
|
9
|
+
|
|
10
|
+
function fixedGame(minesAt: number[]): Game {
|
|
11
|
+
const settings: Settings = { ...DEFAULT_SETTINGS, width: 5, height: 5, mines: minesAt.length, noGuess: false, opening: "safe" };
|
|
12
|
+
const mines = Array.from({ length: 25 }, (_, cell) => minesAt.includes(cell));
|
|
13
|
+
const clues = mines.map((mine, cell) => mine ? -1 : neighbours(settings, cell).filter((n) => mines[n]).length);
|
|
14
|
+
const board: Board = { settings, mines, clues, first: 12, attempt: 0 };
|
|
15
|
+
return { ...newGame(settings), board };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("the three boards", () => {
|
|
19
|
+
for (const grid of Object.values(GRIDS)) it(`${grid} has reciprocal, unique neighbours and no self-neighbours`, () => {
|
|
20
|
+
const settings = { ...DEFAULT_SETTINGS, width: 7, height: 5, grid };
|
|
21
|
+
const all = neighboursOf(settings);
|
|
22
|
+
for (let cell = 0; cell < all.length; cell += 1) {
|
|
23
|
+
expect(new Set(all[cell]).size).toBe(all[cell]!.length);
|
|
24
|
+
expect(all[cell]).not.toContain(cell);
|
|
25
|
+
for (const n of all[cell]!) expect(all[n]).toContain(cell);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
it("uses six hex neighbours, eight square neighbours, and joins both wrap seams", () => {
|
|
29
|
+
expect(neighbours({ ...DEFAULT_SETTINGS, grid: GRIDS.hex }, 40)).toHaveLength(6);
|
|
30
|
+
expect(neighbours(DEFAULT_SETTINGS, 40)).toHaveLength(8);
|
|
31
|
+
expect(neighbours({ ...DEFAULT_SETTINGS, grid: GRIDS.wrap }, 0)).toEqual([1,8,9,10,17,72,73,80]);
|
|
32
|
+
});
|
|
33
|
+
it("rejects malformed or excessive settings before making a board", () => {
|
|
34
|
+
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);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("dealing and verified boards", () => {
|
|
39
|
+
for (const grid of Object.values(GRIDS)) it(`${grid} deals exact counts and a clear first opening for many seeds`, () => {
|
|
40
|
+
for (let seed = 0; seed < 40; seed += 1) {
|
|
41
|
+
const settings = { ...DEFAULT_SETTINGS, grid, seed, noGuess: false };
|
|
42
|
+
const board = makeBoard(settings, 40);
|
|
43
|
+
expect(board.mines.filter(Boolean)).toHaveLength(10);
|
|
44
|
+
expect(board.clues[40]).toBe(0);
|
|
45
|
+
for (let cell = 0; cell < 81; cell += 1) {
|
|
46
|
+
expect(board.clues[cell]).toBe(board.mines[cell] ? -1 : neighbours(settings, cell).filter((n) => board.mines[n]).length);
|
|
47
|
+
}
|
|
48
|
+
expect(makeBoard(settings, 40)).toEqual(board);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
for (const grid of Object.values(GRIDS)) it(`${grid} only advertises boards its visible-clue solver actually finishes`, () => {
|
|
52
|
+
for (let seed = 0; seed < 15; seed += 1) expect(isSolvable(makeBoard({ ...DEFAULT_SETTINGS, grid, seed }, 40))).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
it("reports a failed proof budget rather than returning an unverified board", () => {
|
|
55
|
+
let failures = 0;
|
|
56
|
+
for (let seed = 0; seed < 50; seed += 1) {
|
|
57
|
+
try { makeBoard({ ...DEFAULT_SETTINGS, width: 5, height: 5, mines: 10, opening: "safe", seed }, 12, { attempts: 1 }); }
|
|
58
|
+
catch (error) { expect(error).toBeInstanceOf(GenerationError); expect((error as GenerationError).code).toBe("exhausted"); failures += 1; }
|
|
59
|
+
}
|
|
60
|
+
expect(failures).toBeGreaterThan(0);
|
|
61
|
+
});
|
|
62
|
+
it("rejects a dense board that cannot protect the chosen opening", () => {
|
|
63
|
+
expect(() => makeBoard({ ...DEFAULT_SETTINGS, width: 3, height: 3, mines: 1 }, 4)).toThrow(GenerationError);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("moves", () => {
|
|
68
|
+
it("floods empty ground and wins without requiring any flags", () => {
|
|
69
|
+
const game = fixedGame([0]);
|
|
70
|
+
const before = structuredClone(game);
|
|
71
|
+
const next = play(game, { kind: MOVES.reveal, cell: 24 });
|
|
72
|
+
expect(next.status).toBe(STATUSES.won);
|
|
73
|
+
expect(next.marks.filter((mark) => mark === MARKS.open)).toHaveLength(24);
|
|
74
|
+
expect(game).toEqual(before);
|
|
75
|
+
});
|
|
76
|
+
it("does not open a flagged cell, including inside a flood", () => {
|
|
77
|
+
let game = fixedGame([0]);
|
|
78
|
+
game = play(game, { kind: MOVES.mark, cell: 24 });
|
|
79
|
+
expect(play(game, { kind: MOVES.reveal, cell: 24 })).toBe(game);
|
|
80
|
+
game = play(game, { kind: MOVES.reveal, cell: 20 });
|
|
81
|
+
expect(game.marks[24]).toBe(MARKS.flag);
|
|
82
|
+
expect(game.status).toBe(STATUSES.playing);
|
|
83
|
+
});
|
|
84
|
+
it("cycles notes through flag, question and covered", () => {
|
|
85
|
+
let game = newGame();
|
|
86
|
+
for (const mark of [MARKS.flag, MARKS.question, MARKS.covered]) { game = play(game, { kind: MOVES.mark, cell: 0 }); expect(game.marks[0]).toBe(mark); }
|
|
87
|
+
expect(game.board).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
it("chords a matched number and loses if the flags were wrong", () => {
|
|
90
|
+
let game = fixedGame([0,24]);
|
|
91
|
+
game = play(game, { kind: MOVES.reveal, cell: 6 });
|
|
92
|
+
expect(play(game, { kind: MOVES.chord, cell: 6 })).toBe(game);
|
|
93
|
+
game = play(game, { kind: MOVES.mark, cell: 1 });
|
|
94
|
+
game = play(game, { kind: MOVES.chord, cell: 6 });
|
|
95
|
+
expect(game.status).toBe(STATUSES.lost); expect(game.exploded).toBe(0);
|
|
96
|
+
expect(play(game, { kind: MOVES.mark, cell: 3 })).toBe(game);
|
|
97
|
+
});
|
|
98
|
+
it("chords correctly flagged mines without revealing them", () => {
|
|
99
|
+
let game = fixedGame([0,24]);
|
|
100
|
+
game = play(game, { kind: MOVES.reveal, cell: 6 });
|
|
101
|
+
game = play(game, { kind: MOVES.mark, cell: 0 });
|
|
102
|
+
game = play(game, { kind: MOVES.chord, cell: 6 });
|
|
103
|
+
expect(game.status).not.toBe(STATUSES.lost); expect(game.marks[0]).toBe(MARKS.flag);
|
|
104
|
+
});
|
|
105
|
+
it("keeps hidden answers out of both the clue view and drawing", () => {
|
|
106
|
+
const game = fixedGame([0,24]);
|
|
107
|
+
expect(visibleGame(game).clues.every((clue) => clue === null)).toBe(true);
|
|
108
|
+
expect(boardModel(game).cells.every((cell) => cell.text === "" && cell.kind === MARKS.covered)).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
it("replays a saved game from its seed and moves; malformed records are refused", () => {
|
|
111
|
+
let game = newGame({ ...DEFAULT_SETTINGS, noGuess: false });
|
|
112
|
+
game = play(game, { kind: MOVES.mark, cell: 0 }); game = play(game, { kind: MOVES.reveal, cell: 40 });
|
|
113
|
+
expect(gameFromProgress(gameProgress(game))).toEqual(game);
|
|
114
|
+
for (const data of ["garbage", "null", '{"version":2}', JSON.stringify({ version: 1, settings: DEFAULT_SETTINGS, moves: [{ kind: "reveal", cell: -1 }] })]) expect(gameFromProgress(data)).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
it("has stable daily seeds and rejects impossible calendar dates", () => {
|
|
117
|
+
expect(dailySeed("2026-10-04")).toBe(dailySeed("2026-10-04"));
|
|
118
|
+
expect(dailySeed("2026-10-04", "hex")).not.toBe(dailySeed("2026-10-04"));
|
|
119
|
+
expect(() => dailySeed("2026-02-30")).toThrow();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
export { fixedGame };
|
|
124
|
+
|
|
125
|
+
it("keeps assisted status through public progress and rejects invalid assisted data", () => {
|
|
126
|
+
const game = { ...newGame(), helped: true };
|
|
127
|
+
expect(gameFromProgress(gameProgress(game))?.helped).toBe(true);
|
|
128
|
+
expect(gameFromProgress(JSON.stringify({ version: 1, settings: game.settings, moves: [], helped: "yes" }))).toBeNull();
|
|
129
|
+
});
|
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,12 @@
|
|
|
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.1.0";
|
|
11
|
+
|
|
12
|
+
export { SHAPES, activeCell, activeCells } from "./shape.ts";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Grid, Settings } from "./jirai.types.ts";
|
|
2
|
+
|
|
3
|
+
export const GRIDS = { square: "square", 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
|
+
hex: { offsets: [[-1,0],[1,0],[0,-1],[1,-1],[-1,1],[0,1]], wrap: false },
|
|
11
|
+
wrap: { offsets: [[-1,-1],[0,-1],[1,-1],[-1,0],[1,0],[-1,1],[0,1],[1,1]], wrap: true },
|
|
12
|
+
};
|
|
13
|
+
export const PRESETS = {
|
|
14
|
+
beginner: { width: 9, height: 9, mines: 10 },
|
|
15
|
+
intermediate: { width: 16, height: 16, mines: 40 },
|
|
16
|
+
expert: { width: 30, height: 16, mines: 99 },
|
|
17
|
+
wide: { width: 21, height: 9, mines: 24 },
|
|
18
|
+
tall: { width: 9, height: 21, mines: 24 },
|
|
19
|
+
} as const;
|
|
20
|
+
export const DEFAULT_SETTINGS: Settings = { ...PRESETS.beginner, grid: GRIDS.square, noGuess: true, opening: "clear", seed: 1 };
|
|
21
|
+
export const MAX_SIDE = 60;
|
|
22
|
+
export const MAX_CELLS = 2400;
|
|
23
|
+
export const GENERATION_ATTEMPTS = 128;
|
|
24
|
+
export const ENUMERATION_CELLS = 18;
|
|
25
|
+
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" | "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,34 @@
|
|
|
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
|
+
return JSON.stringify({ version: 1, settings: game.settings, moves: game.moves, helped: game.helped });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** A saved game is rebuilt under the rules. Malformed or unavailable boards return null. */
|
|
11
|
+
export function gameFromProgress(progress: string): Game | null {
|
|
12
|
+
if (progress.length > 1_000_000) return null;
|
|
13
|
+
try {
|
|
14
|
+
const data = JSON.parse(progress);
|
|
15
|
+
if (data.version !== 1 || !validSettings(data.settings) || !Array.isArray(data.moves) || data.moves.length > 20_000) return null;
|
|
16
|
+
if (data.helped !== undefined && typeof data.helped !== "boolean") return null;
|
|
17
|
+
let game = newGame(data.settings);
|
|
18
|
+
for (const move of data.moves) {
|
|
19
|
+
if (move === null || typeof move !== "object" || !["reveal", "mark", "chord"].includes(move.kind) || !Number.isInteger(move.cell)) return null;
|
|
20
|
+
const next = play(game, move as Move);
|
|
21
|
+
if (next === game) return null;
|
|
22
|
+
game = next;
|
|
23
|
+
}
|
|
24
|
+
return { ...game, helped: data.helped ?? false };
|
|
25
|
+
} catch { return null; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** UTC by definition, so two people sharing a daily seed get the same board. */
|
|
29
|
+
export function dailySeed(day: string, grid = "square"): number {
|
|
30
|
+
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.");
|
|
31
|
+
let hash = 2166136261;
|
|
32
|
+
for (const letter of `jirai:1:${day}:${grid}`) hash = Math.imul(hash ^ letter.charCodeAt(0), 16777619) >>> 0;
|
|
33
|
+
return hash;
|
|
34
|
+
}
|
package/src/mount.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { activeCells } from "./shape.ts";
|
|
2
|
+
import { hintFor } from "./deduce.ts";
|
|
3
|
+
import { boardModel } from "./draw.ts";
|
|
4
|
+
import { newGame, play, visibleGame, withBoard } from "./game.ts";
|
|
5
|
+
import { MARKS, MOVES, STATUSES } from "./jirai.constants.ts";
|
|
6
|
+
import { validCell } from "./grid.ts";
|
|
7
|
+
import { gameFromProgress, gameProgress } from "./keep.ts";
|
|
8
|
+
import { JIRAI_STYLE } from "./style.ts";
|
|
9
|
+
import { words } from "./strings.ts";
|
|
10
|
+
import type { Board, Game, Move, Settings } from "./jirai.types.ts";
|
|
11
|
+
import type { DrawOptions, JiraiMount, MountOptions } from "./ui.types.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One playable board in any element. The engine owns the rules, this module
|
|
15
|
+
* owns presses, focus, the clock and the worker. Destroy removes only its own
|
|
16
|
+
* root, so two mounted boards never take each other's listeners or children.
|
|
17
|
+
*/
|
|
18
|
+
export function mountJirai(host: HTMLElement, options: MountOptions = {}): JiraiMount {
|
|
19
|
+
const doc = host.ownerDocument;
|
|
20
|
+
let draw: DrawOptions = { material: "ivory", pieces: "flags", language: "en", ...options };
|
|
21
|
+
const game = options.progress === undefined ? newGame(options.settings) : gameFromProgress(options.progress);
|
|
22
|
+
if (game === null) throw new Error("Invalid saved Minesweeper game.");
|
|
23
|
+
let current: Game = game;
|
|
24
|
+
let focus = activeCells(game.settings)[0]!, marking = false, hint: number | null = null;
|
|
25
|
+
let worker: Worker | null = null, pending = false, disposed = false;
|
|
26
|
+
let started: number | null = null, elapsed = 0;
|
|
27
|
+
const root = doc.createElement("div"); root.className = "jr-root";
|
|
28
|
+
const style = doc.createElement("style"); style.textContent = JIRAI_STYLE;
|
|
29
|
+
const stats = doc.createElement("div"); stats.className = "jr-stats";
|
|
30
|
+
const scroll = doc.createElement("div"); scroll.className = "jr-scroll";
|
|
31
|
+
const board = doc.createElement("div"); board.className = "jr-board"; board.setAttribute("role", "group");
|
|
32
|
+
const status = doc.createElement("p"); status.className = "jr-status"; status.setAttribute("role", "status");
|
|
33
|
+
const controls = doc.createElement("div"); controls.className = "jr-controls";
|
|
34
|
+
const help = doc.createElement("p"); help.className = "jr-help";
|
|
35
|
+
const dialog = doc.createElement("dialog"); dialog.className = "jr-dialog";
|
|
36
|
+
const close = doc.createElement("button"); close.className = "jr-button jr-close"; close.type = "button";
|
|
37
|
+
dialog.append(close);
|
|
38
|
+
scroll.append(board); root.append(style, stats, scroll, status, controls, help); host.append(root, dialog);
|
|
39
|
+
let lastOpener: HTMLElement | null = null;
|
|
40
|
+
const leaveDialog = (): void => { root.append(stats, scroll, status, controls, help); lastOpener?.focus(); };
|
|
41
|
+
dialog.addEventListener("close", leaveDialog);
|
|
42
|
+
close.addEventListener("click", () => dialog.close());
|
|
43
|
+
function stopWorker(): void { worker?.terminate(); worker = null; pending = false; }
|
|
44
|
+
function clock(): number { return elapsed + (started === null ? 0 : Math.floor((Date.now() - started) / 1000)); }
|
|
45
|
+
function report(next: Game): void {
|
|
46
|
+
const before = current.status;
|
|
47
|
+
current = next; hint = null;
|
|
48
|
+
if (started === null && current.board !== null && current.status === STATUSES.playing) started = Date.now();
|
|
49
|
+
if (current.status === STATUSES.won || current.status === STATUSES.lost) { elapsed = clock(); started = null; }
|
|
50
|
+
const detail = structuredClone(current);
|
|
51
|
+
host.dispatchEvent(new CustomEvent("jirai-change", { detail })); options.onChange?.(detail);
|
|
52
|
+
if (before !== current.status && (current.status === STATUSES.won || current.status === STATUSES.lost)) {
|
|
53
|
+
host.dispatchEvent(new CustomEvent("jirai-finish", { detail })); options.onFinish?.(detail);
|
|
54
|
+
}
|
|
55
|
+
render();
|
|
56
|
+
}
|
|
57
|
+
function failed(error: Error): void { stopWorker(); render(); status.textContent = words(draw.language).error; options.onError?.(error); }
|
|
58
|
+
function make(move: Move): void {
|
|
59
|
+
if (disposed || pending) return;
|
|
60
|
+
if (move.kind === MOVES.reveal && current.board === null && current.marks[move.cell] !== MARKS.flag && validCell(current.settings, move.cell)) {
|
|
61
|
+
pending = true; render();
|
|
62
|
+
try {
|
|
63
|
+
worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });
|
|
64
|
+
worker.onmessage = (event: MessageEvent<{ board?: Board; error?: string }>) => {
|
|
65
|
+
if (disposed) return;
|
|
66
|
+
stopWorker();
|
|
67
|
+
if (event.data.board === undefined) { failed(new Error(event.data.error ?? "Generation failed.")); return; }
|
|
68
|
+
try { report(play(withBoard(current, event.data.board), move)); } catch (error) { failed(error as Error); }
|
|
69
|
+
};
|
|
70
|
+
worker.onerror = () => failed(new Error("The board worker could not load. Serve the package over HTTP, and allow workers in your content security policy."));
|
|
71
|
+
worker.postMessage({ settings: current.settings, first: move.cell });
|
|
72
|
+
} catch (error) { failed(error as Error); }
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
try { const next = play(current, move); if (next !== current) report(next); } catch (error) { failed(error as Error); }
|
|
76
|
+
}
|
|
77
|
+
function showHint(): void {
|
|
78
|
+
if (pending || current.status !== STATUSES.playing) return;
|
|
79
|
+
const result = hintFor(visibleGame(current));
|
|
80
|
+
const safe = result.safe.find((cell) => current.marks[cell] !== MARKS.open);
|
|
81
|
+
const mine = result.mines.find((cell) => current.marks[cell] !== MARKS.flag);
|
|
82
|
+
const highlighted = safe ?? mine ?? null;
|
|
83
|
+
if (highlighted !== null && !current.helped) report({ ...current, helped: true });
|
|
84
|
+
hint = highlighted;
|
|
85
|
+
render();
|
|
86
|
+
const say = words(draw.language);
|
|
87
|
+
status.textContent = hint === null ? say.hintNone : `${safe !== undefined ? say.hintSafe : say.hintMine} ${result.reason === "none" ? "" : say[result.reason]}`;
|
|
88
|
+
}
|
|
89
|
+
function renderStats(): void {
|
|
90
|
+
const say = words(draw.language);
|
|
91
|
+
const values = [[say.mines, current.settings.mines - current.marks.filter((m) => m === MARKS.flag).length], [say.time, `${Math.floor(clock() / 60)}:${String(clock() % 60).padStart(2,"0")}`], [say.moves, current.moves.length]];
|
|
92
|
+
stats.replaceChildren(...values.map(([label, value]) => {
|
|
93
|
+
const part = doc.createElement("span"); part.className = "jr-stat";
|
|
94
|
+
const name = doc.createElement("small"); name.textContent = String(label);
|
|
95
|
+
const number = doc.createElement("strong"); number.textContent = String(value);
|
|
96
|
+
part.append(name, number); return part;
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
function render(): void {
|
|
100
|
+
if (disposed) return;
|
|
101
|
+
const say = words(draw.language);
|
|
102
|
+
root.dataset.material = draw.material; root.dataset.grid = current.settings.grid; root.dataset.shape = current.settings.shape ?? "rectangle"; root.lang = draw.language ?? "en";
|
|
103
|
+
board.setAttribute("aria-label", say.board); board.setAttribute("aria-busy", String(pending));
|
|
104
|
+
const model = boardModel(current, { ...draw, hint });
|
|
105
|
+
const active = doc.activeElement instanceof HTMLElement && board.contains(doc.activeElement);
|
|
106
|
+
board.style.aspectRatio = `${model.width} / ${model.height}`;
|
|
107
|
+
board.style.minWidth = `${model.width * 27}px`;
|
|
108
|
+
board.replaceChildren(...model.cells.map((cell) => {
|
|
109
|
+
const button = doc.createElement("button"); button.type = "button"; button.className = "jr-cell";
|
|
110
|
+
button.dataset.cell = String(cell.cell); button.dataset.kind = cell.kind; button.dataset.hint = String(cell.hint);
|
|
111
|
+
button.dataset.number = cell.kind === MARKS.open ? cell.text : "";
|
|
112
|
+
button.setAttribute("aria-label", cell.label); button.tabIndex = cell.cell === focus ? 0 : -1;
|
|
113
|
+
button.disabled = pending; button.textContent = cell.text;
|
|
114
|
+
button.style.left = `${cell.x / model.width * 100}%`; button.style.top = `${cell.y / model.height * 100}%`;
|
|
115
|
+
button.style.width = `${cell.width / model.width * 100}%`; button.style.height = `${cell.height / model.height * 100}%`;
|
|
116
|
+
return button;
|
|
117
|
+
}));
|
|
118
|
+
if (active) board.querySelector<HTMLButtonElement>(`[data-cell="${focus}"]`)?.focus({ preventScroll: true });
|
|
119
|
+
renderStats(); status.textContent = pending ? say.checking : say[current.status]; help.textContent = say.help; close.textContent = say.close;
|
|
120
|
+
controls.replaceChildren();
|
|
121
|
+
if (options.controls === false) { stats.hidden = true; status.hidden = true; help.hidden = true; return; }
|
|
122
|
+
const button = (text: string, action: () => void): HTMLButtonElement => {
|
|
123
|
+
const made = doc.createElement("button"); made.type = "button"; made.className = "jr-button"; made.textContent = text;
|
|
124
|
+
made.addEventListener("click", action); controls.append(made); return made;
|
|
125
|
+
};
|
|
126
|
+
button(say.restart, () => handle.restart());
|
|
127
|
+
const mark = button(marking ? say.mark : say.reveal, () => { marking = !marking; render(); }); mark.setAttribute("aria-pressed", String(marking));
|
|
128
|
+
const hinted = button(say.hint, showHint); hinted.disabled = pending || current.status !== STATUSES.playing;
|
|
129
|
+
if (!dialog.open) button(say.view, () => { lastOpener = doc.activeElement as HTMLElement; dialog.append(stats, scroll, status, controls); dialog.showModal(); render(); });
|
|
130
|
+
}
|
|
131
|
+
function cellOf(event: Event): number | null {
|
|
132
|
+
const target = event.target instanceof Element ? event.target.closest<HTMLButtonElement>("[data-cell]") : null;
|
|
133
|
+
return target === null ? null : Number(target.dataset.cell);
|
|
134
|
+
}
|
|
135
|
+
let hold: ReturnType<typeof setTimeout> | null = null, held = false, origin = { x: 0, y: 0 };
|
|
136
|
+
const cancelHold = (): void => { if (hold !== null) clearTimeout(hold); hold = null; };
|
|
137
|
+
board.addEventListener("pointerdown", (event) => {
|
|
138
|
+
held = false; cancelHold(); const cell = cellOf(event); if (cell === null) return;
|
|
139
|
+
focus = cell; origin = { x: event.clientX, y: event.clientY };
|
|
140
|
+
if (event.pointerType !== "mouse") hold = setTimeout(() => { held = true; make({ kind: MOVES.mark, cell }); }, 450);
|
|
141
|
+
});
|
|
142
|
+
board.addEventListener("pointermove", (event) => { if (Math.hypot(event.clientX - origin.x, event.clientY - origin.y) > 8) cancelHold(); });
|
|
143
|
+
board.addEventListener("pointerup", cancelHold); board.addEventListener("pointercancel", cancelHold);
|
|
144
|
+
board.addEventListener("click", (event) => { const cell = cellOf(event); if (cell !== null && !held) { focus = cell; make({ kind: marking ? MOVES.mark : MOVES.reveal, cell }); } held = false; });
|
|
145
|
+
board.addEventListener("contextmenu", (event) => { event.preventDefault(); cancelHold(); const cell = cellOf(event); if (cell !== null && !held) { focus = cell; make({ kind: MOVES.mark, cell }); } held = false; });
|
|
146
|
+
board.addEventListener("keydown", (event) => {
|
|
147
|
+
const cell = cellOf(event); if (cell === null) return;
|
|
148
|
+
const x = cell % current.settings.width, y = Math.floor(cell / current.settings.width);
|
|
149
|
+
const shifts: Record<string, number> = { ArrowLeft: x > 0 ? -1 : 0, ArrowRight: x < current.settings.width - 1 ? 1 : 0, ArrowUp: y > 0 ? -current.settings.width : 0, ArrowDown: y < current.settings.height - 1 ? current.settings.width : 0 };
|
|
150
|
+
if (event.key in shifts) { event.preventDefault(); const step = shifts[event.key]!;
|
|
151
|
+
let next = cell + step;
|
|
152
|
+
while (step !== 0 && next >= 0 && next < current.marks.length && !validCell(current.settings, next)) {
|
|
153
|
+
if (Math.abs(step) === 1 && Math.floor(next / current.settings.width) !== y) break;
|
|
154
|
+
next += step;
|
|
155
|
+
}
|
|
156
|
+
if (validCell(current.settings, next) && (Math.abs(step) !== 1 || Math.floor(next / current.settings.width) === y)) focus = next;
|
|
157
|
+
render(); board.querySelector<HTMLButtonElement>(`[data-cell="${focus}"]`)?.focus(); }
|
|
158
|
+
else if (event.key === " " || event.key.toLowerCase() === "f") { event.preventDefault(); focus = cell; make({ kind: MOVES.mark, cell }); }
|
|
159
|
+
else if (event.key === "Enter") { event.preventDefault(); focus = cell; make({ kind: MOVES.reveal, cell }); }
|
|
160
|
+
});
|
|
161
|
+
const timer = setInterval(() => { if (started !== null && !disposed) renderStats(); }, 1000);
|
|
162
|
+
const handle: JiraiMount = {
|
|
163
|
+
game: () => structuredClone(current), progress: () => gameProgress(current), play: (cell, mark = false) => make({ kind: mark ? MOVES.mark : MOVES.reveal, cell }), hint: showHint,
|
|
164
|
+
restart: () => { if (disposed) return; stopWorker(); elapsed = 0; started = null; report(newGame(current.settings)); },
|
|
165
|
+
load: (settings: Settings, progress?: string) => {
|
|
166
|
+
if (disposed) return;
|
|
167
|
+
const restored = progress === undefined ? newGame(settings) : gameFromProgress(progress);
|
|
168
|
+
if (restored === null) throw new Error("Invalid saved Minesweeper game.");
|
|
169
|
+
stopWorker(); elapsed = 0; started = null; focus = activeCells(restored.settings)[0]!; report(restored);
|
|
170
|
+
},
|
|
171
|
+
set: (changes) => { draw = { ...draw, ...changes }; render(); },
|
|
172
|
+
destroy: () => { disposed = true; stopWorker(); cancelHold(); clearInterval(timer); if (dialog.open) dialog.close(); root.remove(); dialog.remove(); },
|
|
173
|
+
};
|
|
174
|
+
if (current.status === STATUSES.playing) started = Date.now();
|
|
175
|
+
render(); return handle;
|
|
176
|
+
}
|
package/src/random.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Mulberry32, pinned by the replay tests: changing it would change every kept board. */
|
|
2
|
+
export function seededRandom(seed: number): () => number {
|
|
3
|
+
let state = seed >>> 0;
|
|
4
|
+
return () => {
|
|
5
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
6
|
+
let t = state;
|
|
7
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
8
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
9
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/** Fisher–Yates on a copy. A shuffle never changes the list given. */
|
|
13
|
+
export function shuffled<T>(items: readonly T[], random: () => number): T[] {
|
|
14
|
+
const out = [...items];
|
|
15
|
+
for (let i = out.length - 1; i > 0; i -= 1) {
|
|
16
|
+
const j = Math.floor(random() * (i + 1));
|
|
17
|
+
[out[i], out[j]] = [out[j]!, out[i]!];
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
package/src/react.tsx
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { mountJirai } from "./mount.ts";
|
|
3
|
+
import type { JiraiProps } from "./react.types.ts";
|
|
4
|
+
|
|
5
|
+
/** The plain-DOM game as a React component. Options are read on mount; give it a new key to load a new board. */
|
|
6
|
+
export function JiraiBoard({ settings, progress, material, pieces, language, controls, onChange, onFinish, onError, ...element }: JiraiProps) {
|
|
7
|
+
const host = useRef<HTMLDivElement>(null);
|
|
8
|
+
const latest = useRef({ onChange, onFinish, onError });
|
|
9
|
+
useEffect(() => { latest.current = { onChange, onFinish, onError }; });
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (host.current === null) return;
|
|
12
|
+
const board = mountJirai(host.current, { settings, progress, material, pieces, language, controls,
|
|
13
|
+
onChange: (game) => latest.current.onChange?.(game), onFinish: (game) => latest.current.onFinish?.(game), onError: (error) => latest.current.onError?.(error) });
|
|
14
|
+
return () => board.destroy();
|
|
15
|
+
}, []);
|
|
16
|
+
return <div ref={host} {...element} />;
|
|
17
|
+
}
|