@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/dist/grid.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { GRID_SPECS, MAX_CELLS, MAX_SIDE } from "./jirai.constants.js";
|
|
2
|
+
import { activeCell, activeCells, SHAPES } from "./shape.js";
|
|
3
|
+
/** Reject a setting before it reaches a board allocation or a shuffle. */
|
|
4
|
+
export function validSettings(value) {
|
|
5
|
+
if (value === null || typeof value !== "object")
|
|
6
|
+
return false;
|
|
7
|
+
const s = value;
|
|
8
|
+
return Number.isInteger(s.width) && s.width >= 3 && s.width <= MAX_SIDE
|
|
9
|
+
&& Number.isInteger(s.height) && s.height >= 3 && s.height <= MAX_SIDE
|
|
10
|
+
&& s.width * s.height <= MAX_CELLS
|
|
11
|
+
&& Number.isInteger(s.mines) && s.mines >= 1 && s.mines < s.width * s.height
|
|
12
|
+
&& (s.shape === undefined || SHAPES.includes(s.shape))
|
|
13
|
+
&& ((s.shape ?? "rectangle") === "rectangle" || (s.width >= 9 && s.height >= 9 && s.grid !== "wrap"))
|
|
14
|
+
&& s.mines < activeCells(s).length
|
|
15
|
+
&& Object.hasOwn(GRID_SPECS, s.grid) && typeof s.noGuess === "boolean"
|
|
16
|
+
&& (s.opening === "safe" || s.opening === "clear")
|
|
17
|
+
&& Number.isInteger(s.seed) && s.seed >= 0 && s.seed <= 0xffffffff;
|
|
18
|
+
}
|
|
19
|
+
export function validCell(settings, cell) {
|
|
20
|
+
return Number.isInteger(cell) && cell >= 0 && cell < settings.width * settings.height && activeCell(settings, cell);
|
|
21
|
+
}
|
|
22
|
+
/** Every neighbour once. Wrap joins both pairs of opposite edges. */
|
|
23
|
+
export function neighbours(settings, cell) {
|
|
24
|
+
if (!validCell(settings, cell))
|
|
25
|
+
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();
|
|
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) {
|
|
37
|
+
const next = ny * settings.width + nx;
|
|
38
|
+
if (validCell(settings, next))
|
|
39
|
+
out.add(next);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return [...out].sort((a, b) => a - b);
|
|
43
|
+
}
|
|
44
|
+
export function neighboursOf(settings) {
|
|
45
|
+
return Array.from({ length: settings.width * settings.height }, (_, cell) => neighbours(settings, cell));
|
|
46
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
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 declare const VERSION = "0.1.0";
|
|
11
|
+
export { SHAPES, activeCell, activeCells } from "./shape.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Jirai 地雷: the rules and deductions, plain functions over plain data. No DOM and no dependencies. */
|
|
2
|
+
export * from "./jirai.types.js";
|
|
3
|
+
export * from "./jirai.constants.js";
|
|
4
|
+
export * from "./grid.js";
|
|
5
|
+
export * from "./generate.js";
|
|
6
|
+
export * from "./game.js";
|
|
7
|
+
export * from "./deduce.js";
|
|
8
|
+
export * from "./keep.js";
|
|
9
|
+
export { seededRandom } from "./random.js";
|
|
10
|
+
export const VERSION = "0.1.0";
|
|
11
|
+
export { SHAPES, activeCell, activeCells } from "./shape.js";
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Grid, Settings } from "./jirai.types.ts";
|
|
2
|
+
export declare const GRIDS: {
|
|
3
|
+
readonly square: "square";
|
|
4
|
+
readonly hex: "hex";
|
|
5
|
+
readonly wrap: "wrap";
|
|
6
|
+
};
|
|
7
|
+
export declare const STATUSES: {
|
|
8
|
+
readonly ready: "ready";
|
|
9
|
+
readonly playing: "playing";
|
|
10
|
+
readonly won: "won";
|
|
11
|
+
readonly lost: "lost";
|
|
12
|
+
};
|
|
13
|
+
export declare const MARKS: {
|
|
14
|
+
readonly covered: "covered";
|
|
15
|
+
readonly flag: "flag";
|
|
16
|
+
readonly question: "question";
|
|
17
|
+
readonly open: "open";
|
|
18
|
+
};
|
|
19
|
+
export declare const MOVES: {
|
|
20
|
+
readonly reveal: "reveal";
|
|
21
|
+
readonly mark: "mark";
|
|
22
|
+
readonly chord: "chord";
|
|
23
|
+
};
|
|
24
|
+
/** Hex coordinates are axial: a row is displaced half a cell to the right. */
|
|
25
|
+
export declare const GRID_SPECS: Record<Grid, {
|
|
26
|
+
offsets: readonly (readonly [number, number])[];
|
|
27
|
+
wrap: boolean;
|
|
28
|
+
}>;
|
|
29
|
+
export declare const PRESETS: {
|
|
30
|
+
readonly beginner: {
|
|
31
|
+
readonly width: 9;
|
|
32
|
+
readonly height: 9;
|
|
33
|
+
readonly mines: 10;
|
|
34
|
+
};
|
|
35
|
+
readonly intermediate: {
|
|
36
|
+
readonly width: 16;
|
|
37
|
+
readonly height: 16;
|
|
38
|
+
readonly mines: 40;
|
|
39
|
+
};
|
|
40
|
+
readonly expert: {
|
|
41
|
+
readonly width: 30;
|
|
42
|
+
readonly height: 16;
|
|
43
|
+
readonly mines: 99;
|
|
44
|
+
};
|
|
45
|
+
readonly wide: {
|
|
46
|
+
readonly width: 21;
|
|
47
|
+
readonly height: 9;
|
|
48
|
+
readonly mines: 24;
|
|
49
|
+
};
|
|
50
|
+
readonly tall: {
|
|
51
|
+
readonly width: 9;
|
|
52
|
+
readonly height: 21;
|
|
53
|
+
readonly mines: 24;
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
export declare const DEFAULT_SETTINGS: Settings;
|
|
57
|
+
export declare const MAX_SIDE = 60;
|
|
58
|
+
export declare const MAX_CELLS = 2400;
|
|
59
|
+
export declare const GENERATION_ATTEMPTS = 128;
|
|
60
|
+
export declare const ENUMERATION_CELLS = 18;
|
|
61
|
+
export declare const ENUMERATION_NODES = 100000;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const GRIDS = { square: "square", hex: "hex", wrap: "wrap" };
|
|
2
|
+
export const STATUSES = { ready: "ready", playing: "playing", won: "won", lost: "lost" };
|
|
3
|
+
export const MARKS = { covered: "covered", flag: "flag", question: "question", open: "open" };
|
|
4
|
+
export const MOVES = { reveal: "reveal", mark: "mark", chord: "chord" };
|
|
5
|
+
/** Hex coordinates are axial: a row is displaced half a cell to the right. */
|
|
6
|
+
export const GRID_SPECS = {
|
|
7
|
+
square: { offsets: [[-1, -1], [0, -1], [1, -1], [-1, 0], [1, 0], [-1, 1], [0, 1], [1, 1]], wrap: false },
|
|
8
|
+
hex: { offsets: [[-1, 0], [1, 0], [0, -1], [1, -1], [-1, 1], [0, 1]], wrap: false },
|
|
9
|
+
wrap: { offsets: [[-1, -1], [0, -1], [1, -1], [-1, 0], [1, 0], [-1, 1], [0, 1], [1, 1]], wrap: true },
|
|
10
|
+
};
|
|
11
|
+
export const PRESETS = {
|
|
12
|
+
beginner: { width: 9, height: 9, mines: 10 },
|
|
13
|
+
intermediate: { width: 16, height: 16, mines: 40 },
|
|
14
|
+
expert: { width: 30, height: 16, mines: 99 },
|
|
15
|
+
wide: { width: 21, height: 9, mines: 24 },
|
|
16
|
+
tall: { width: 9, height: 21, mines: 24 },
|
|
17
|
+
};
|
|
18
|
+
export const DEFAULT_SETTINGS = { ...PRESETS.beginner, grid: GRIDS.square, noGuess: true, opening: "clear", seed: 1 };
|
|
19
|
+
export const MAX_SIDE = 60;
|
|
20
|
+
export const MAX_CELLS = 2400;
|
|
21
|
+
export const GENERATION_ATTEMPTS = 128;
|
|
22
|
+
export const ENUMERATION_CELLS = 18;
|
|
23
|
+
export const ENUMERATION_NODES = 100_000;
|
|
@@ -0,0 +1,66 @@
|
|
|
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 = {
|
|
19
|
+
kind: "reveal" | "mark" | "chord";
|
|
20
|
+
cell: number;
|
|
21
|
+
};
|
|
22
|
+
export type Board = {
|
|
23
|
+
settings: Settings;
|
|
24
|
+
mines: readonly boolean[];
|
|
25
|
+
clues: readonly number[];
|
|
26
|
+
first: number;
|
|
27
|
+
attempt: number;
|
|
28
|
+
};
|
|
29
|
+
/** Rules data includes the answer; never send it to a competitive client. Use visibleGame for hints and drawing. */
|
|
30
|
+
export type Game = {
|
|
31
|
+
settings: Settings;
|
|
32
|
+
board: Board | null;
|
|
33
|
+
marks: readonly Mark[];
|
|
34
|
+
status: Status;
|
|
35
|
+
exploded: number | null;
|
|
36
|
+
moves: readonly Move[];
|
|
37
|
+
/** A proved hint has been shown during this run. */
|
|
38
|
+
helped: boolean;
|
|
39
|
+
};
|
|
40
|
+
export type VisibleGame = {
|
|
41
|
+
settings: Settings;
|
|
42
|
+
/** Null is hidden, including flags. Only opened cells give a clue. */
|
|
43
|
+
clues: readonly (number | null)[];
|
|
44
|
+
marks: readonly Mark[];
|
|
45
|
+
status: Status;
|
|
46
|
+
};
|
|
47
|
+
export type Constraint = {
|
|
48
|
+
cells: readonly number[];
|
|
49
|
+
mines: number;
|
|
50
|
+
sources: readonly number[];
|
|
51
|
+
};
|
|
52
|
+
export type Deduction = {
|
|
53
|
+
safe: readonly number[];
|
|
54
|
+
mines: readonly number[];
|
|
55
|
+
reason: "count" | "overlap" | "total" | "enumeration" | "none";
|
|
56
|
+
sources: readonly number[];
|
|
57
|
+
/** A conflicting clue, or a board with no consistent mine placement. */
|
|
58
|
+
contradiction: boolean;
|
|
59
|
+
};
|
|
60
|
+
export type GenerationOptions = {
|
|
61
|
+
attempts?: number;
|
|
62
|
+
enumerate?: boolean;
|
|
63
|
+
};
|
|
64
|
+
export type Language = "en" | "ja";
|
|
65
|
+
export type Material = "ivory" | "wood" | "slate";
|
|
66
|
+
export type Pieces = "flags" | "stones" | "flowers";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/keep.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Game } from "./jirai.types.ts";
|
|
2
|
+
/** Keep settings and moves, never an unchecked answer array. Version the deal before changing it. */
|
|
3
|
+
export declare function gameProgress(game: Game): string;
|
|
4
|
+
/** A saved game is rebuilt under the rules. Malformed or unavailable boards return null. */
|
|
5
|
+
export declare function gameFromProgress(progress: string): Game | null;
|
|
6
|
+
/** UTC by definition, so two people sharing a daily seed get the same board. */
|
|
7
|
+
export declare function dailySeed(day: string, grid?: string): number;
|
package/dist/keep.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { newGame, play } from "./game.js";
|
|
2
|
+
import { validSettings } from "./grid.js";
|
|
3
|
+
/** Keep settings and moves, never an unchecked answer array. Version the deal before changing it. */
|
|
4
|
+
export function gameProgress(game) {
|
|
5
|
+
return JSON.stringify({ version: 1, settings: game.settings, moves: game.moves, helped: game.helped });
|
|
6
|
+
}
|
|
7
|
+
/** A saved game is rebuilt under the rules. Malformed or unavailable boards return null. */
|
|
8
|
+
export function gameFromProgress(progress) {
|
|
9
|
+
if (progress.length > 1_000_000)
|
|
10
|
+
return null;
|
|
11
|
+
try {
|
|
12
|
+
const data = JSON.parse(progress);
|
|
13
|
+
if (data.version !== 1 || !validSettings(data.settings) || !Array.isArray(data.moves) || data.moves.length > 20_000)
|
|
14
|
+
return null;
|
|
15
|
+
if (data.helped !== undefined && typeof data.helped !== "boolean")
|
|
16
|
+
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))
|
|
20
|
+
return null;
|
|
21
|
+
const next = play(game, move);
|
|
22
|
+
if (next === game)
|
|
23
|
+
return null;
|
|
24
|
+
game = next;
|
|
25
|
+
}
|
|
26
|
+
return { ...game, helped: data.helped ?? false };
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** UTC by definition, so two people sharing a daily seed get the same board. */
|
|
33
|
+
export function dailySeed(day, grid = "square") {
|
|
34
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || new Date(`${day}T00:00:00Z`).toISOString().slice(0, 10) !== day)
|
|
35
|
+
throw new RangeError("Use a real day as YYYY-MM-DD.");
|
|
36
|
+
let hash = 2166136261;
|
|
37
|
+
for (const letter of `jirai:1:${day}:${grid}`)
|
|
38
|
+
hash = Math.imul(hash ^ letter.charCodeAt(0), 16777619) >>> 0;
|
|
39
|
+
return hash;
|
|
40
|
+
}
|
package/dist/mount.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { JiraiMount, MountOptions } from "./ui.types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* One playable board in any element. The engine owns the rules, this module
|
|
4
|
+
* owns presses, focus, the clock and the worker. Destroy removes only its own
|
|
5
|
+
* root, so two mounted boards never take each other's listeners or children.
|
|
6
|
+
*/
|
|
7
|
+
export declare function mountJirai(host: HTMLElement, options?: MountOptions): JiraiMount;
|
package/dist/mount.js
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { activeCells } from "./shape.js";
|
|
2
|
+
import { hintFor } from "./deduce.js";
|
|
3
|
+
import { boardModel } from "./draw.js";
|
|
4
|
+
import { newGame, play, visibleGame, withBoard } from "./game.js";
|
|
5
|
+
import { MARKS, MOVES, STATUSES } from "./jirai.constants.js";
|
|
6
|
+
import { validCell } from "./grid.js";
|
|
7
|
+
import { gameFromProgress, gameProgress } from "./keep.js";
|
|
8
|
+
import { JIRAI_STYLE } from "./style.js";
|
|
9
|
+
import { words } from "./strings.js";
|
|
10
|
+
/**
|
|
11
|
+
* One playable board in any element. The engine owns the rules, this module
|
|
12
|
+
* owns presses, focus, the clock and the worker. Destroy removes only its own
|
|
13
|
+
* root, so two mounted boards never take each other's listeners or children.
|
|
14
|
+
*/
|
|
15
|
+
export function mountJirai(host, options = {}) {
|
|
16
|
+
const doc = host.ownerDocument;
|
|
17
|
+
let draw = { material: "ivory", pieces: "flags", language: "en", ...options };
|
|
18
|
+
const game = options.progress === undefined ? newGame(options.settings) : gameFromProgress(options.progress);
|
|
19
|
+
if (game === null)
|
|
20
|
+
throw new Error("Invalid saved Minesweeper game.");
|
|
21
|
+
let current = game;
|
|
22
|
+
let focus = activeCells(game.settings)[0], marking = false, hint = null;
|
|
23
|
+
let worker = null, pending = false, disposed = false;
|
|
24
|
+
let started = null, elapsed = 0;
|
|
25
|
+
const root = doc.createElement("div");
|
|
26
|
+
root.className = "jr-root";
|
|
27
|
+
const style = doc.createElement("style");
|
|
28
|
+
style.textContent = JIRAI_STYLE;
|
|
29
|
+
const stats = doc.createElement("div");
|
|
30
|
+
stats.className = "jr-stats";
|
|
31
|
+
const scroll = doc.createElement("div");
|
|
32
|
+
scroll.className = "jr-scroll";
|
|
33
|
+
const board = doc.createElement("div");
|
|
34
|
+
board.className = "jr-board";
|
|
35
|
+
board.setAttribute("role", "group");
|
|
36
|
+
const status = doc.createElement("p");
|
|
37
|
+
status.className = "jr-status";
|
|
38
|
+
status.setAttribute("role", "status");
|
|
39
|
+
const controls = doc.createElement("div");
|
|
40
|
+
controls.className = "jr-controls";
|
|
41
|
+
const help = doc.createElement("p");
|
|
42
|
+
help.className = "jr-help";
|
|
43
|
+
const dialog = doc.createElement("dialog");
|
|
44
|
+
dialog.className = "jr-dialog";
|
|
45
|
+
const close = doc.createElement("button");
|
|
46
|
+
close.className = "jr-button jr-close";
|
|
47
|
+
close.type = "button";
|
|
48
|
+
dialog.append(close);
|
|
49
|
+
scroll.append(board);
|
|
50
|
+
root.append(style, stats, scroll, status, controls, help);
|
|
51
|
+
host.append(root, dialog);
|
|
52
|
+
let lastOpener = null;
|
|
53
|
+
const leaveDialog = () => { root.append(stats, scroll, status, controls, help); lastOpener?.focus(); };
|
|
54
|
+
dialog.addEventListener("close", leaveDialog);
|
|
55
|
+
close.addEventListener("click", () => dialog.close());
|
|
56
|
+
function stopWorker() { worker?.terminate(); worker = null; pending = false; }
|
|
57
|
+
function clock() { return elapsed + (started === null ? 0 : Math.floor((Date.now() - started) / 1000)); }
|
|
58
|
+
function report(next) {
|
|
59
|
+
const before = current.status;
|
|
60
|
+
current = next;
|
|
61
|
+
hint = null;
|
|
62
|
+
if (started === null && current.board !== null && current.status === STATUSES.playing)
|
|
63
|
+
started = Date.now();
|
|
64
|
+
if (current.status === STATUSES.won || current.status === STATUSES.lost) {
|
|
65
|
+
elapsed = clock();
|
|
66
|
+
started = null;
|
|
67
|
+
}
|
|
68
|
+
const detail = structuredClone(current);
|
|
69
|
+
host.dispatchEvent(new CustomEvent("jirai-change", { detail }));
|
|
70
|
+
options.onChange?.(detail);
|
|
71
|
+
if (before !== current.status && (current.status === STATUSES.won || current.status === STATUSES.lost)) {
|
|
72
|
+
host.dispatchEvent(new CustomEvent("jirai-finish", { detail }));
|
|
73
|
+
options.onFinish?.(detail);
|
|
74
|
+
}
|
|
75
|
+
render();
|
|
76
|
+
}
|
|
77
|
+
function failed(error) { stopWorker(); render(); status.textContent = words(draw.language).error; options.onError?.(error); }
|
|
78
|
+
function make(move) {
|
|
79
|
+
if (disposed || pending)
|
|
80
|
+
return;
|
|
81
|
+
if (move.kind === MOVES.reveal && current.board === null && current.marks[move.cell] !== MARKS.flag && validCell(current.settings, move.cell)) {
|
|
82
|
+
pending = true;
|
|
83
|
+
render();
|
|
84
|
+
try {
|
|
85
|
+
worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });
|
|
86
|
+
worker.onmessage = (event) => {
|
|
87
|
+
if (disposed)
|
|
88
|
+
return;
|
|
89
|
+
stopWorker();
|
|
90
|
+
if (event.data.board === undefined) {
|
|
91
|
+
failed(new Error(event.data.error ?? "Generation failed."));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
report(play(withBoard(current, event.data.board), move));
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
failed(error);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
worker.onerror = () => failed(new Error("The board worker could not load. Serve the package over HTTP, and allow workers in your content security policy."));
|
|
102
|
+
worker.postMessage({ settings: current.settings, first: move.cell });
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
failed(error);
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const next = play(current, move);
|
|
111
|
+
if (next !== current)
|
|
112
|
+
report(next);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
failed(error);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function showHint() {
|
|
119
|
+
if (pending || current.status !== STATUSES.playing)
|
|
120
|
+
return;
|
|
121
|
+
const result = hintFor(visibleGame(current));
|
|
122
|
+
const safe = result.safe.find((cell) => current.marks[cell] !== MARKS.open);
|
|
123
|
+
const mine = result.mines.find((cell) => current.marks[cell] !== MARKS.flag);
|
|
124
|
+
const highlighted = safe ?? mine ?? null;
|
|
125
|
+
if (highlighted !== null && !current.helped)
|
|
126
|
+
report({ ...current, helped: true });
|
|
127
|
+
hint = highlighted;
|
|
128
|
+
render();
|
|
129
|
+
const say = words(draw.language);
|
|
130
|
+
status.textContent = hint === null ? say.hintNone : `${safe !== undefined ? say.hintSafe : say.hintMine} ${result.reason === "none" ? "" : say[result.reason]}`;
|
|
131
|
+
}
|
|
132
|
+
function renderStats() {
|
|
133
|
+
const say = words(draw.language);
|
|
134
|
+
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]];
|
|
135
|
+
stats.replaceChildren(...values.map(([label, value]) => {
|
|
136
|
+
const part = doc.createElement("span");
|
|
137
|
+
part.className = "jr-stat";
|
|
138
|
+
const name = doc.createElement("small");
|
|
139
|
+
name.textContent = String(label);
|
|
140
|
+
const number = doc.createElement("strong");
|
|
141
|
+
number.textContent = String(value);
|
|
142
|
+
part.append(name, number);
|
|
143
|
+
return part;
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
function render() {
|
|
147
|
+
if (disposed)
|
|
148
|
+
return;
|
|
149
|
+
const say = words(draw.language);
|
|
150
|
+
root.dataset.material = draw.material;
|
|
151
|
+
root.dataset.grid = current.settings.grid;
|
|
152
|
+
root.dataset.shape = current.settings.shape ?? "rectangle";
|
|
153
|
+
root.lang = draw.language ?? "en";
|
|
154
|
+
board.setAttribute("aria-label", say.board);
|
|
155
|
+
board.setAttribute("aria-busy", String(pending));
|
|
156
|
+
const model = boardModel(current, { ...draw, hint });
|
|
157
|
+
const active = doc.activeElement instanceof HTMLElement && board.contains(doc.activeElement);
|
|
158
|
+
board.style.aspectRatio = `${model.width} / ${model.height}`;
|
|
159
|
+
board.style.minWidth = `${model.width * 27}px`;
|
|
160
|
+
board.replaceChildren(...model.cells.map((cell) => {
|
|
161
|
+
const button = doc.createElement("button");
|
|
162
|
+
button.type = "button";
|
|
163
|
+
button.className = "jr-cell";
|
|
164
|
+
button.dataset.cell = String(cell.cell);
|
|
165
|
+
button.dataset.kind = cell.kind;
|
|
166
|
+
button.dataset.hint = String(cell.hint);
|
|
167
|
+
button.dataset.number = cell.kind === MARKS.open ? cell.text : "";
|
|
168
|
+
button.setAttribute("aria-label", cell.label);
|
|
169
|
+
button.tabIndex = cell.cell === focus ? 0 : -1;
|
|
170
|
+
button.disabled = pending;
|
|
171
|
+
button.textContent = cell.text;
|
|
172
|
+
button.style.left = `${cell.x / model.width * 100}%`;
|
|
173
|
+
button.style.top = `${cell.y / model.height * 100}%`;
|
|
174
|
+
button.style.width = `${cell.width / model.width * 100}%`;
|
|
175
|
+
button.style.height = `${cell.height / model.height * 100}%`;
|
|
176
|
+
return button;
|
|
177
|
+
}));
|
|
178
|
+
if (active)
|
|
179
|
+
board.querySelector(`[data-cell="${focus}"]`)?.focus({ preventScroll: true });
|
|
180
|
+
renderStats();
|
|
181
|
+
status.textContent = pending ? say.checking : say[current.status];
|
|
182
|
+
help.textContent = say.help;
|
|
183
|
+
close.textContent = say.close;
|
|
184
|
+
controls.replaceChildren();
|
|
185
|
+
if (options.controls === false) {
|
|
186
|
+
stats.hidden = true;
|
|
187
|
+
status.hidden = true;
|
|
188
|
+
help.hidden = true;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const button = (text, action) => {
|
|
192
|
+
const made = doc.createElement("button");
|
|
193
|
+
made.type = "button";
|
|
194
|
+
made.className = "jr-button";
|
|
195
|
+
made.textContent = text;
|
|
196
|
+
made.addEventListener("click", action);
|
|
197
|
+
controls.append(made);
|
|
198
|
+
return made;
|
|
199
|
+
};
|
|
200
|
+
button(say.restart, () => handle.restart());
|
|
201
|
+
const mark = button(marking ? say.mark : say.reveal, () => { marking = !marking; render(); });
|
|
202
|
+
mark.setAttribute("aria-pressed", String(marking));
|
|
203
|
+
const hinted = button(say.hint, showHint);
|
|
204
|
+
hinted.disabled = pending || current.status !== STATUSES.playing;
|
|
205
|
+
if (!dialog.open)
|
|
206
|
+
button(say.view, () => { lastOpener = doc.activeElement; dialog.append(stats, scroll, status, controls); dialog.showModal(); render(); });
|
|
207
|
+
}
|
|
208
|
+
function cellOf(event) {
|
|
209
|
+
const target = event.target instanceof Element ? event.target.closest("[data-cell]") : null;
|
|
210
|
+
return target === null ? null : Number(target.dataset.cell);
|
|
211
|
+
}
|
|
212
|
+
let hold = null, held = false, origin = { x: 0, y: 0 };
|
|
213
|
+
const cancelHold = () => { if (hold !== null)
|
|
214
|
+
clearTimeout(hold); hold = null; };
|
|
215
|
+
board.addEventListener("pointerdown", (event) => {
|
|
216
|
+
held = false;
|
|
217
|
+
cancelHold();
|
|
218
|
+
const cell = cellOf(event);
|
|
219
|
+
if (cell === null)
|
|
220
|
+
return;
|
|
221
|
+
focus = cell;
|
|
222
|
+
origin = { x: event.clientX, y: event.clientY };
|
|
223
|
+
if (event.pointerType !== "mouse")
|
|
224
|
+
hold = setTimeout(() => { held = true; make({ kind: MOVES.mark, cell }); }, 450);
|
|
225
|
+
});
|
|
226
|
+
board.addEventListener("pointermove", (event) => { if (Math.hypot(event.clientX - origin.x, event.clientY - origin.y) > 8)
|
|
227
|
+
cancelHold(); });
|
|
228
|
+
board.addEventListener("pointerup", cancelHold);
|
|
229
|
+
board.addEventListener("pointercancel", cancelHold);
|
|
230
|
+
board.addEventListener("click", (event) => { const cell = cellOf(event); if (cell !== null && !held) {
|
|
231
|
+
focus = cell;
|
|
232
|
+
make({ kind: marking ? MOVES.mark : MOVES.reveal, cell });
|
|
233
|
+
} held = false; });
|
|
234
|
+
board.addEventListener("contextmenu", (event) => { event.preventDefault(); cancelHold(); const cell = cellOf(event); if (cell !== null && !held) {
|
|
235
|
+
focus = cell;
|
|
236
|
+
make({ kind: MOVES.mark, cell });
|
|
237
|
+
} held = false; });
|
|
238
|
+
board.addEventListener("keydown", (event) => {
|
|
239
|
+
const cell = cellOf(event);
|
|
240
|
+
if (cell === null)
|
|
241
|
+
return;
|
|
242
|
+
const x = cell % current.settings.width, y = Math.floor(cell / current.settings.width);
|
|
243
|
+
const shifts = { 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 };
|
|
244
|
+
if (event.key in shifts) {
|
|
245
|
+
event.preventDefault();
|
|
246
|
+
const step = shifts[event.key];
|
|
247
|
+
let next = cell + step;
|
|
248
|
+
while (step !== 0 && next >= 0 && next < current.marks.length && !validCell(current.settings, next)) {
|
|
249
|
+
if (Math.abs(step) === 1 && Math.floor(next / current.settings.width) !== y)
|
|
250
|
+
break;
|
|
251
|
+
next += step;
|
|
252
|
+
}
|
|
253
|
+
if (validCell(current.settings, next) && (Math.abs(step) !== 1 || Math.floor(next / current.settings.width) === y))
|
|
254
|
+
focus = next;
|
|
255
|
+
render();
|
|
256
|
+
board.querySelector(`[data-cell="${focus}"]`)?.focus();
|
|
257
|
+
}
|
|
258
|
+
else if (event.key === " " || event.key.toLowerCase() === "f") {
|
|
259
|
+
event.preventDefault();
|
|
260
|
+
focus = cell;
|
|
261
|
+
make({ kind: MOVES.mark, cell });
|
|
262
|
+
}
|
|
263
|
+
else if (event.key === "Enter") {
|
|
264
|
+
event.preventDefault();
|
|
265
|
+
focus = cell;
|
|
266
|
+
make({ kind: MOVES.reveal, cell });
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
const timer = setInterval(() => { if (started !== null && !disposed)
|
|
270
|
+
renderStats(); }, 1000);
|
|
271
|
+
const handle = {
|
|
272
|
+
game: () => structuredClone(current), progress: () => gameProgress(current), play: (cell, mark = false) => make({ kind: mark ? MOVES.mark : MOVES.reveal, cell }), hint: showHint,
|
|
273
|
+
restart: () => { if (disposed)
|
|
274
|
+
return; stopWorker(); elapsed = 0; started = null; report(newGame(current.settings)); },
|
|
275
|
+
load: (settings, progress) => {
|
|
276
|
+
if (disposed)
|
|
277
|
+
return;
|
|
278
|
+
const restored = progress === undefined ? newGame(settings) : gameFromProgress(progress);
|
|
279
|
+
if (restored === null)
|
|
280
|
+
throw new Error("Invalid saved Minesweeper game.");
|
|
281
|
+
stopWorker();
|
|
282
|
+
elapsed = 0;
|
|
283
|
+
started = null;
|
|
284
|
+
focus = activeCells(restored.settings)[0];
|
|
285
|
+
report(restored);
|
|
286
|
+
},
|
|
287
|
+
set: (changes) => { draw = { ...draw, ...changes }; render(); },
|
|
288
|
+
destroy: () => { disposed = true; stopWorker(); cancelHold(); clearInterval(timer); if (dialog.open)
|
|
289
|
+
dialog.close(); root.remove(); dialog.remove(); },
|
|
290
|
+
};
|
|
291
|
+
if (current.status === STATUSES.playing)
|
|
292
|
+
started = Date.now();
|
|
293
|
+
render();
|
|
294
|
+
return handle;
|
|
295
|
+
}
|
package/dist/random.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Mulberry32, pinned by the replay tests: changing it would change every kept board. */
|
|
2
|
+
export declare function seededRandom(seed: number): () => number;
|
|
3
|
+
/** Fisher–Yates on a copy. A shuffle never changes the list given. */
|
|
4
|
+
export declare function shuffled<T>(items: readonly T[], random: () => number): T[];
|
package/dist/random.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Mulberry32, pinned by the replay tests: changing it would change every kept board. */
|
|
2
|
+
export function seededRandom(seed) {
|
|
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(items, random) {
|
|
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/dist/react.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { JiraiProps } from "./react.types.ts";
|
|
2
|
+
/** The plain-DOM game as a React component. Options are read on mount; give it a new key to load a new board. */
|
|
3
|
+
export declare function JiraiBoard({ settings, progress, material, pieces, language, controls, onChange, onFinish, onError, ...element }: JiraiProps): import("react").JSX.Element;
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import { mountJirai } from "./mount.js";
|
|
4
|
+
/** The plain-DOM game as a React component. Options are read on mount; give it a new key to load a new board. */
|
|
5
|
+
export function JiraiBoard({ settings, progress, material, pieces, language, controls, onChange, onFinish, onError, ...element }) {
|
|
6
|
+
const host = useRef(null);
|
|
7
|
+
const latest = useRef({ onChange, onFinish, onError });
|
|
8
|
+
useEffect(() => { latest.current = { onChange, onFinish, onError }; });
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
if (host.current === null)
|
|
11
|
+
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 _jsx("div", { ref: host, ...element });
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/shape.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Settings } from "./jirai.types.ts";
|
|
2
|
+
export declare const SHAPES: readonly ["rectangle", "heart", "star", "hexagon"];
|
|
3
|
+
/** Row-major addresses stay stable; omitted cells have no clue and no neighbours. */
|
|
4
|
+
export declare function activeCell(settings: Settings, cell: number): boolean;
|
|
5
|
+
export declare function activeCells(settings: Settings): number[];
|