@johnmorrisdotca/jirai 0.1.0 → 0.2.1

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.
@@ -0,0 +1,17 @@
1
+ import type { Board, GenerationOptions, Game, Settings } from "./jirai.types.ts";
2
+ /** Identifies the four-neighbour rules and progress-code format. */
3
+ export declare const ORTHOGONAL_VARIANT: "orthogonal";
4
+ /** Settings accepted by the four-neighbour-specific helpers. */
5
+ export type OrthogonalSettings = Omit<Settings, "grid">;
6
+ /** Starts a game under four-neighbour rules. */
7
+ export declare function newOrthogonalGame(settings: OrthogonalSettings): Game;
8
+ /** Deals a board whose clues count only up, down, left and right. */
9
+ export declare function makeOrthogonalBoard(settings: OrthogonalSettings, first: number, options?: GenerationOptions): Board;
10
+ /** Returns the adjacent cells under the orthogonal rules. */
11
+ export declare function orthogonalNeighbours(settings: OrthogonalSettings, cell: number): number[];
12
+ /** Gives a clue-only deduction; flags remain notes and cannot influence the result. */
13
+ export declare function orthogonalHint(game: Game): import("./jirai.types.ts").Deduction;
14
+ /** Encodes an orthogonal game with an explicit variant marker and version 2. */
15
+ export declare function encodeOrthogonalGame(game: Game): string;
16
+ /** Restores only version 2 orthogonal progress records. */
17
+ export declare function decodeOrthogonalGame(code: string): Game | null;
@@ -0,0 +1,39 @@
1
+ import { deduce } from "./deduce.js";
2
+ import { newGame, visibleGame } from "./game.js";
3
+ import { makeBoard } from "./generate.js";
4
+ import { neighbours, validSettings } from "./grid.js";
5
+ import { gameFromProgress, gameProgress } from "./keep.js";
6
+ /** Identifies the four-neighbour rules and progress-code format. */
7
+ export const ORTHOGONAL_VARIANT = "orthogonal";
8
+ /** Starts a game under four-neighbour rules. */
9
+ export function newOrthogonalGame(settings) {
10
+ return newGame({ ...settings, grid: ORTHOGONAL_VARIANT });
11
+ }
12
+ /** Deals a board whose clues count only up, down, left and right. */
13
+ export function makeOrthogonalBoard(settings, first, options) {
14
+ return makeBoard({ ...settings, grid: ORTHOGONAL_VARIANT }, first, options);
15
+ }
16
+ /** Returns the adjacent cells under the orthogonal rules. */
17
+ export function orthogonalNeighbours(settings, cell) {
18
+ const complete = { ...settings, grid: ORTHOGONAL_VARIANT };
19
+ if (!validSettings(complete))
20
+ throw new RangeError("Invalid orthogonal settings.");
21
+ return neighbours(complete, cell);
22
+ }
23
+ /** Gives a clue-only deduction; flags remain notes and cannot influence the result. */
24
+ export function orthogonalHint(game) {
25
+ if (game.settings.grid !== ORTHOGONAL_VARIANT)
26
+ throw new RangeError("Expected an orthogonal game.");
27
+ return deduce(visibleGame(game));
28
+ }
29
+ /** Encodes an orthogonal game with an explicit variant marker and version 2. */
30
+ export function encodeOrthogonalGame(game) {
31
+ if (game.settings.grid !== ORTHOGONAL_VARIANT)
32
+ throw new RangeError("Expected an orthogonal game.");
33
+ return gameProgress(game);
34
+ }
35
+ /** Restores only version 2 orthogonal progress records. */
36
+ export function decodeOrthogonalGame(code) {
37
+ const game = gameFromProgress(code);
38
+ return game?.settings.grid === ORTHOGONAL_VARIANT ? game : null;
39
+ }
package/dist/react.d.ts CHANGED
@@ -1,3 +1,3 @@
1
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. */
2
+ /** Renders the DOM player inside a React-owned host. Options are read on mount; use a new key to load a new board. */
3
3
  export declare function JiraiBoard({ settings, progress, material, pieces, language, controls, onChange, onFinish, onError, ...element }: JiraiProps): import("react").JSX.Element;
package/dist/react.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useEffect, useRef } from "react";
3
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. */
4
+ /** Renders the DOM player inside a React-owned host. Options are read on mount; use a new key to load a new board. */
5
5
  export function JiraiBoard({ settings, progress, material, pieces, language, controls, onChange, onFinish, onError, ...element }) {
6
6
  const host = useRef(null);
7
7
  const latest = useRef({ onChange, onFinish, onError });
@@ -1,3 +1,4 @@
1
1
  import type { HTMLAttributes } from "react";
2
2
  import type { MountOptions } from "./ui.types.ts";
3
+ /** Mount options plus standard div attributes for the React wrapper. */
3
4
  export type JiraiProps = MountOptions & Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "onError">;
package/dist/shape.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { Settings } from "./jirai.types.ts";
2
+ /** Board silhouettes supported by settings. */
2
3
  export declare const SHAPES: readonly ["rectangle", "heart", "star", "hexagon"];
3
4
  /** Row-major addresses stay stable; omitted cells have no clue and no neighbours. */
4
5
  export declare function activeCell(settings: Settings, cell: number): boolean;
6
+ /** Returns all active cells in stable row-major order. */
5
7
  export declare function activeCells(settings: Settings): number[];
package/dist/shape.js CHANGED
@@ -1,3 +1,4 @@
1
+ /** Board silhouettes supported by settings. */
1
2
  export const SHAPES = ["rectangle", "heart", "star", "hexagon"];
2
3
  /** Row-major addresses stay stable; omitted cells have no clue and no neighbours. */
3
4
  export function activeCell(settings, cell) {
@@ -28,6 +29,7 @@ export function activeCell(settings, cell) {
28
29
  default: return true;
29
30
  }
30
31
  }
32
+ /** Returns all active cells in stable row-major order. */
31
33
  export function activeCells(settings) {
32
34
  return Array.from({ length: settings.width * settings.height }, (_, cell) => cell).filter(cell => activeCell(settings, cell));
33
35
  }
package/dist/strings.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Language } from "./jirai.types.ts";
2
+ /** Built-in English and Japanese strings used by the player and renderer. */
2
3
  export declare const STRINGS: {
3
4
  readonly en: {
4
5
  readonly board: "Minesweeper board";
@@ -67,4 +68,5 @@ export declare const STRINGS: {
67
68
  readonly error: "推測なしで解ける盤が見つかりませんでした。別のシード、別の開始マス、または少ない地雷で試してください。";
68
69
  };
69
70
  };
71
+ /** Returns the built-in interface strings for a locale. */
70
72
  export declare function words(language?: Language): typeof STRINGS["en"] | typeof STRINGS["ja"];
package/dist/strings.js CHANGED
@@ -1,3 +1,4 @@
1
+ /** Built-in English and Japanese strings used by the player and renderer. */
1
2
  export const STRINGS = {
2
3
  en: {
3
4
  board: "Minesweeper board", covered: "covered", flag: "flagged", question: "uncertain", empty: "empty", mine: "mine", wrong: "incorrect flag",
@@ -14,4 +15,5 @@ export const STRINGS = {
14
15
  error: "推測なしで解ける盤が見つかりませんでした。別のシード、別の開始マス、または少ない地雷で試してください。",
15
16
  },
16
17
  };
18
+ /** Returns the built-in interface strings for a locale. */
17
19
  export function words(language = "en") { return STRINGS[language]; }
@@ -1,10 +1,12 @@
1
1
  import type { Game, Language, Material, Pieces, Settings } from "./jirai.types.ts";
2
+ /** Appearance and optional hint marker used by drawing and play. */
2
3
  export type DrawOptions = {
3
4
  material?: Material;
4
5
  pieces?: Pieces;
5
6
  language?: Language;
6
7
  hint?: number | null;
7
8
  };
9
+ /** Accessible presentation data for one active board cell. */
8
10
  export type CellModel = {
9
11
  cell: number;
10
12
  x: number;
@@ -16,11 +18,13 @@ export type CellModel = {
16
18
  kind: string;
17
19
  hint: boolean;
18
20
  };
21
+ /** Layout and cell presentation returned by `boardModel`. */
19
22
  export type BoardModel = {
20
23
  width: number;
21
24
  height: number;
22
25
  cells: CellModel[];
23
26
  };
27
+ /** Initial game, appearance, control, and lifecycle callbacks for a mounted board. */
24
28
  export type MountOptions = DrawOptions & {
25
29
  settings?: Settings;
26
30
  progress?: string;
@@ -29,6 +33,7 @@ export type MountOptions = DrawOptions & {
29
33
  onFinish?: (game: Game) => void;
30
34
  onError?: (error: Error) => void;
31
35
  };
36
+ /** Controls for reading and operating a mounted board. */
32
37
  export type JiraiMount = {
33
38
  game: () => Game;
34
39
  progress: () => string;
package/package.json CHANGED
@@ -1,45 +1,132 @@
1
1
  {
2
2
  "name": "@johnmorrisdotca/jirai",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Minesweeper on square, hexagonal and wraparound boards: seeded games, a safe opening, verified no-guess boards, explained hints, configurable materials and markers, and a playable board for any page. Zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "John Morris",
8
- "repository": { "type": "git", "url": "git+https://github.com/johnmorrisdotca/jirai.git" },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/johnmorrisdotca/jirai.git"
11
+ },
9
12
  "homepage": "https://johnmorrisdotca.github.io/jirai/",
10
- "bugs": { "url": "https://github.com/johnmorrisdotca/jirai/issues" },
11
- "keywords": ["minesweeper", "puzzle", "hexagonal", "no-guess", "typescript", "seeded", "web-component", "itsutsu"],
13
+ "bugs": {
14
+ "url": "https://github.com/johnmorrisdotca/jirai/issues"
15
+ },
16
+ "keywords": [
17
+ "minesweeper",
18
+ "mines",
19
+ "minefield",
20
+ "logic-puzzle",
21
+ "deduction-puzzle",
22
+ "number-clues",
23
+ "hexagonal-minesweeper",
24
+ "hex-grid",
25
+ "orthogonal-minesweeper",
26
+ "four-neighbour",
27
+ "four-neighbor",
28
+ "eight-neighbour",
29
+ "eight-neighbor",
30
+ "wraparound-grid",
31
+ "no-guess",
32
+ "no-guess-minesweeper",
33
+ "seeded-puzzle",
34
+ "daily-puzzle",
35
+ "puzzle-generator",
36
+ "puzzle-solver",
37
+ "typescript",
38
+ "javascript",
39
+ "svg",
40
+ "accessible",
41
+ "keyboard",
42
+ "touch",
43
+ "web-component",
44
+ "custom-element",
45
+ "react-component",
46
+ "zero-dependencies",
47
+ "board-game",
48
+ "logic-game",
49
+ "grid-game"
50
+ ],
12
51
  "main": "./dist/index.js",
13
52
  "types": "./dist/index.d.ts",
14
53
  "exports": {
15
- ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
16
- "./play": { "types": "./dist/play-entry.d.ts", "default": "./dist/play-entry.js" },
17
- "./draw": { "types": "./dist/draw-entry.d.ts", "default": "./dist/draw-entry.js" },
18
- "./element": { "types": "./dist/element.d.ts", "default": "./dist/element.js" },
19
- "./element/define": { "types": "./dist/element-define.d.ts", "default": "./dist/element-define.js" },
20
- "./react": { "types": "./dist/react.d.ts", "default": "./dist/react.js" }
21
- },
22
- "sideEffects": ["./dist/element-define.js"],
23
- "files": ["dist", "src", "README.md", "LICENSE", "CHANGELOG.md"],
24
- "engines": { "node": ">=22" },
54
+ ".": {
55
+ "types": "./dist/index.d.ts",
56
+ "default": "./dist/index.js"
57
+ },
58
+ "./orthogonal": {
59
+ "types": "./dist/orthogonal.d.ts",
60
+ "default": "./dist/orthogonal.js"
61
+ },
62
+ "./play": {
63
+ "types": "./dist/play-entry.d.ts",
64
+ "default": "./dist/play-entry.js"
65
+ },
66
+ "./draw": {
67
+ "types": "./dist/draw-entry.d.ts",
68
+ "default": "./dist/draw-entry.js"
69
+ },
70
+ "./element": {
71
+ "types": "./dist/element.d.ts",
72
+ "default": "./dist/element.js"
73
+ },
74
+ "./element/define": {
75
+ "types": "./dist/element-define.d.ts",
76
+ "default": "./dist/element-define.js"
77
+ },
78
+ "./react": {
79
+ "types": "./dist/react.d.ts",
80
+ "default": "./dist/react.js"
81
+ }
82
+ },
83
+ "sideEffects": [
84
+ "./dist/element-define.js"
85
+ ],
86
+ "files": [
87
+ "dist",
88
+ "src",
89
+ "!src/**/*.test.ts",
90
+ "README.md",
91
+ "LICENSE",
92
+ "CHANGELOG.md"
93
+ ],
94
+ "engines": {
95
+ "node": ">=22"
96
+ },
25
97
  "scripts": {
26
98
  "build": "tsc -p tsconfig.json",
27
99
  "typecheck": "tsc -p tsconfig.check.json",
28
100
  "lint": "eslint .",
29
101
  "test": "vitest run",
30
- "check": "pnpm lint && pnpm typecheck && pnpm test",
102
+ "check": "pnpm lint && pnpm typecheck && pnpm test && pnpm check:presentation",
31
103
  "site": "pnpm build && node scripts/site.mjs",
32
104
  "test:demo": "pnpm site && playwright test",
33
105
  "test:package": "pnpm build && node scripts/check-package.mjs",
34
- "prepublishOnly": "pnpm check && pnpm test:package"
106
+ "prepublishOnly": "pnpm check && pnpm test:package",
107
+ "check:presentation": "node scripts/check-presentation.mjs",
108
+ "pictures": "pnpm site && node scripts/readme-pictures.mjs"
109
+ },
110
+ "peerDependencies": {
111
+ "react": ">=18"
112
+ },
113
+ "peerDependenciesMeta": {
114
+ "react": {
115
+ "optional": true
116
+ }
35
117
  },
36
- "peerDependencies": { "react": ">=18" },
37
- "peerDependenciesMeta": { "react": { "optional": true } },
38
118
  "devDependencies": {
39
- "@eslint/js": "^9.0.0", "eslint": "^9.0.0", "typescript-eslint": "^8.0.0",
40
- "typescript": "^5.9.0", "vitest": "^4.0.0", "@playwright/test": "^1.58.0",
41
- "@types/react": "^19.0.0", "react": "^19.0.0"
119
+ "@eslint/js": "^9.0.0",
120
+ "eslint": "^9.0.0",
121
+ "typescript-eslint": "^8.0.0",
122
+ "typescript": "^5.9.0",
123
+ "vitest": "^4.0.0",
124
+ "@playwright/test": "^1.58.0",
125
+ "@types/react": "^19.0.0",
126
+ "react": "^19.0.0"
127
+ },
128
+ "publishConfig": {
129
+ "access": "public"
42
130
  },
43
- "publishConfig": { "access": "public" },
44
131
  "packageManager": "pnpm@10.11.0"
45
132
  }
package/src/element.ts CHANGED
@@ -5,6 +5,7 @@ import type { JiraiMount } from "./ui.types.ts";
5
5
 
6
6
  // Importing this file on a server is harmless; only defineJirai registers the tag.
7
7
  const ElementBase = typeof HTMLElement === "undefined" ? class {} as typeof HTMLElement : HTMLElement;
8
+ /** Configurable `<jirai-board>` element that owns its mounted game. */
8
9
  export class JiraiElement extends ElementBase {
9
10
  private mounted: JiraiMount | null = null;
10
11
  static observedAttributes = ["width", "height", "mines", "seed", "grid", "shape", "no-guess", "material", "pieces", "lang"];
@@ -21,4 +22,5 @@ export class JiraiElement extends ElementBase {
21
22
  } catch (error) { this.dispatchEvent(new CustomEvent("jirai-error", { detail: error })); }
22
23
  }
23
24
  }
25
+ /** Registers the `<jirai-board>` custom element once in a registry. */
24
26
  export function defineJirai(registry: CustomElementRegistry = customElements): void { if (!registry.get("jirai-board")) registry.define("jirai-board", JiraiElement); }
@@ -0,0 +1,105 @@
1
+ // family.test.js: what every package of the family holds the same way, held by one test copied unchanged into
2
+ // each repository (src/family.test.js, or test/family.test.js in Kyuubu). It reads files and runs nothing else.
3
+ // Changing a shared file means changing it in every repository, with the new hash recorded here.
4
+ import { createHash } from "node:crypto";
5
+ import { readFileSync } from "node:fs";
6
+ import { describe, expect, it } from "vitest";
7
+ import { familyBlockOf, familyReadmeBlock } from "../scripts/family-readme.mjs";
8
+ import { FAMILY, FAMILY_PITCH, FAMILY_TEMPLATE_VERSION } from "../scripts/family-template.mjs";
9
+ import { releaseNotes } from "../scripts/release-notes.mjs";
10
+
11
+ const read = (path) => readFileSync(path, "utf8").replace(/\r\n/g, "\n");
12
+ const sha = (path) => createHash("sha256").update(readFileSync(path)).digest("hex");
13
+ const pkg = JSON.parse(read("package.json"));
14
+ const id = pkg.name.replace(/^@[^/]+\//, "");
15
+
16
+ // The recorded hashes. The template's is the one that says every demo's header and footer, and every README's
17
+ // list of the family, are the same text.
18
+ const TEMPLATE = { version: "2026-10-05", sha256: "061b5ed89c345dccb6e029d5091dff0a5bbc4a9b57812fbcd0bd619038bdb7f1" };
19
+ const FILES = {
20
+ "scripts/family-readme.mjs": "3c9d5b2cbf17a92d31bced98edac7f544616edb0dff90bf2d141722a9d4516c5",
21
+ "scripts/release-notes.mjs": "efab0fb78ad05973a8885624c0d2ce3b458b55799c11eabaa5176603ce8cd1e9",
22
+ "scripts/community/SECURITY.md": "ff6f650be7789396233671d2558439736efe7f96a1d1d39115dd5cf94d29275c",
23
+ "scripts/community/CODE_OF_CONDUCT.md": "34da1f56f004ce8f7f95d4449b64d2ecb5827dd9f0d4eea1da351a20713ebe70",
24
+ };
25
+
26
+ describe("the family template", () => {
27
+ it("is the one file, byte for byte, in every package", () => {
28
+ expect(FAMILY_TEMPLATE_VERSION).toBe(TEMPLATE.version);
29
+ expect(sha("scripts/family-template.mjs")).toBe(TEMPLATE.sha256);
30
+ });
31
+
32
+ it("lists every package of the family, in order, each with its Japanese name and a line on it", () => {
33
+ expect(FAMILY.map((one) => one.id)).toEqual([
34
+ "korokoro", "kyuubu", "hitotsu", "toranpu", "tane", "narabe", "tenka", "kumimoji", "tsunagi", "jarajara",
35
+ "suido", "domino", "kotoba", "sugoroku", "kazu", "meikyuu", "hikidashi", "chizu", "bushu", "tobiishi", "jirai", "gunjin",
36
+ ]);
37
+ for (const one of FAMILY) {
38
+ expect(one.name, one.id).toBe(one.id[0].toUpperCase() + one.id.slice(1));
39
+ expect(one.kana, one.id).toMatch(/\S/);
40
+ expect(FAMILY_PITCH[one.id], one.id).toMatch(/^[^A-Z.].*[^.]$/);
41
+ }
42
+ expect(Object.keys(FAMILY_PITCH).sort()).toEqual(FAMILY.map((one) => one.id).sort());
43
+ expect(FAMILY.map((one) => one.id)).toContain(id);
44
+ });
45
+
46
+ it("is what this package's footer reads: the demo site names the family through it", () => {
47
+ expect(read("scripts/site.mjs")).toContain("familyFooter(");
48
+ });
49
+ });
50
+
51
+ describe("the files every package shares", () => {
52
+ it("are copied unchanged: the README writer, the release notes, and the family's SECURITY.md and CODE_OF_CONDUCT.md", () => {
53
+ for (const [path, hash] of Object.entries(FILES)) expect(sha(path), path).toBe(hash);
54
+ });
55
+
56
+ it("SECURITY.md and CODE_OF_CONDUCT.md are the master text of github.com/johnmorrisdotca/.github, which scripts/community keeps a copy of", () => {
57
+ for (const file of ["SECURITY.md", "CODE_OF_CONDUCT.md"]) expect(read(file), file).toBe(read(`scripts/community/${file}`));
58
+ });
59
+ });
60
+
61
+ describe("the README's family", () => {
62
+ const readme = read("README.md");
63
+
64
+ it("is the block scripts/family-readme.mjs writes from the template, under a heading of its own", () => {
65
+ const block = familyBlockOf(readme);
66
+ expect(block, "README.md has no family markers").not.toBeNull();
67
+ expect(block).toBe(familyReadmeBlock(id));
68
+ expect(readme.split("<!-- family:start").length - 1).toBe(1);
69
+ expect(readme).toMatch(/\n#{2,3} The family\n+<!-- family:start/);
70
+ });
71
+
72
+ it("names every package once, as a link", () => {
73
+ const block = familyBlockOf(readme);
74
+ for (const one of FAMILY) expect(block.split(`](https://github.com/johnmorrisdotca/${one.id})`).length - 1, one.id).toBe(1);
75
+ });
76
+ });
77
+
78
+ describe("the release notes", () => {
79
+ it("are the changelog's section for the version, which the Release workflow puts on the GitHub release", () => {
80
+ const log = "# Changelog\n\n## [Unreleased]\n\n## [1.2.0] - 2026-01-02\n\n### Added\n\n- A thing.\n\n## [1.1.0] - 2026-01-01\n\n- Older.\n\n[Unreleased]: https://example.test\n";
81
+ expect(releaseNotes(log, "1.2.0")).toBe("### Added\n\n- A thing.");
82
+ expect(releaseNotes(log, "1.1.0")).toBe("- Older.");
83
+ expect(releaseNotes(log, "9.9.9")).toBeNull();
84
+ expect(releaseNotes(read("CHANGELOG.md"), pkg.version)?.length, `CHANGELOG.md has nothing under ## [${pkg.version}]`).toBeGreaterThan(40);
85
+ const workflow = read(".github/workflows/release.yml");
86
+ expect(workflow).toContain("scripts/release-notes.mjs");
87
+ expect(workflow).not.toContain("See CHANGELOG.md.");
88
+ });
89
+
90
+ it("come from a changelog in Keep a Changelog form: an Unreleased heading, then each version in brackets with its date", () => {
91
+ const log = read("CHANGELOG.md");
92
+ expect(log).toContain("\n## [Unreleased]\n");
93
+ expect(log).toMatch(/^## \[\d+\.\d+\.\d+\] - \d{4}-\d{2}-\d{2}$/m);
94
+ expect(log).not.toMatch(/^## \d/m);
95
+ });
96
+ });
97
+
98
+ describe("Node", () => {
99
+ it("is 22 or later: engines, the CI matrix, and the words in the README and CONTRIBUTING", () => {
100
+ expect(pkg.engines.node).toBe(">=22");
101
+ expect(read(".github/workflows/ci.yml")).toMatch(/node: \[22, 24\]/);
102
+ expect(read(".github/workflows/ci.yml")).not.toMatch(/node: \[[^\]]*\b(18|20)\b/);
103
+ for (const file of ["README.md", "CONTRIBUTING.md"]) expect(read(file), file).not.toMatch(/\bNode(\.js)? (v)?(18|20)\b|>= ?20\b/);
104
+ });
105
+ });
package/src/game.ts CHANGED
@@ -5,6 +5,7 @@ import { DEFAULT_SETTINGS, MARKS, MOVES, STATUSES } from "./jirai.constants.ts";
5
5
  import { neighbours, neighboursOf, validCell, validSettings } from "./grid.ts";
6
6
  import type { Board, Game, Move, Settings, VisibleGame } from "./jirai.types.ts";
7
7
 
8
+ /** Creates an unstarted game with covered cells and no dealt answer. */
8
9
  export function newGame(settings: Settings = DEFAULT_SETTINGS): Game {
9
10
  if (!validSettings(settings)) throw new RangeError("Invalid Minesweeper settings.");
10
11
  return { settings: { ...settings }, board: null, marks: Array.from({ length: settings.width * settings.height }, () => MARKS.covered), status: STATUSES.ready, exploded: null, moves: [], helped: false };
@@ -16,7 +17,7 @@ export function visibleGame(game: Game): VisibleGame {
16
17
  clues: game.marks.map((mark, cell) => !activeCell(game.settings, cell) ? -2 : mark === MARKS.open ? game.board?.clues[cell] ?? null : null) };
17
18
  }
18
19
 
19
- /** Accept a worker's dealt board without changing any moves made before the opening. */
20
+ /** Attaches a worker-dealt board that matches the game's settings, preserving existing moves. */
20
21
  export function withBoard(game: Game, board: Board): Game {
21
22
  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
23
  return { ...game, board };
package/src/grid.ts CHANGED
@@ -17,6 +17,7 @@ export function validSettings(value: unknown): value is Settings {
17
17
  && (s.opening === "safe" || s.opening === "clear")
18
18
  && Number.isInteger(s.seed) && s.seed >= 0 && s.seed <= 0xffffffff;
19
19
  }
20
+ /** Reports whether a row-major cell is inside the board's active shape. */
20
21
  export function validCell(settings: Settings, cell: number): boolean {
21
22
  return Number.isInteger(cell) && cell >= 0 && cell < settings.width * settings.height && activeCell(settings, cell);
22
23
  }
@@ -37,6 +38,7 @@ export function neighbours(settings: Settings, cell: number): number[] {
37
38
  }
38
39
  return [...out].sort((a, b) => a - b);
39
40
  }
41
+ /** Precomputes the neighbour list for each row-major cell. */
40
42
  export function neighboursOf(settings: Settings): number[][] {
41
43
  return Array.from({ length: settings.width * settings.height }, (_, cell) => neighbours(settings, cell));
42
44
  }
package/src/index.ts CHANGED
@@ -7,6 +7,9 @@ export * from "./game.ts";
7
7
  export * from "./deduce.ts";
8
8
  export * from "./keep.ts";
9
9
  export { seededRandom } from "./random.ts";
10
- export const VERSION = "0.1.0";
10
+ /** Package version, kept in step with the release metadata. */
11
+ export const VERSION = "0.2.1";
11
12
 
12
13
  export { SHAPES, activeCell, activeCells } from "./shape.ts";
14
+
15
+ export * from "./orthogonal.ts";
@@ -1,15 +1,21 @@
1
1
  import type { Grid, Settings } from "./jirai.types.ts";
2
2
 
3
- export const GRIDS = { square: "square", hex: "hex", wrap: "wrap" } as const;
3
+ /** Names for the supported square, four-neighbour, hex, and wrap grids. */
4
+ export const GRIDS = { square: "square", orthogonal: "orthogonal", hex: "hex", wrap: "wrap" } as const;
5
+ /** Names for the game lifecycle states. */
4
6
  export const STATUSES = { ready: "ready", playing: "playing", won: "won", lost: "lost" } as const;
7
+ /** Names for the cell mark cycle. */
5
8
  export const MARKS = { covered: "covered", flag: "flag", question: "question", open: "open" } as const;
9
+ /** Names for the moves accepted by the engine. */
6
10
  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. */
11
+ /** Neighbour offsets and edge behaviour for each topology. Hex coordinates are axial: each row is displaced half a cell to the right. */
8
12
  export const GRID_SPECS: Record<Grid, { offsets: readonly (readonly [number, number])[]; wrap: boolean }> = {
9
13
  square: { offsets: [[-1,-1],[0,-1],[1,-1],[-1,0],[1,0],[-1,1],[0,1],[1,1]], wrap: false },
14
+ orthogonal: { offsets: [[0,-1],[-1,0],[1,0],[0,1]], wrap: false },
10
15
  hex: { offsets: [[-1,0],[1,0],[0,-1],[1,-1],[-1,1],[0,1]], wrap: false },
11
16
  wrap: { offsets: [[-1,-1],[0,-1],[1,-1],[-1,0],[1,0],[-1,1],[0,1],[1,1]], wrap: true },
12
17
  };
18
+ /** Common minefield dimensions and mine counts. */
13
19
  export const PRESETS = {
14
20
  beginner: { width: 9, height: 9, mines: 10 },
15
21
  intermediate: { width: 16, height: 16, mines: 40 },
@@ -17,9 +23,15 @@ export const PRESETS = {
17
23
  wide: { width: 21, height: 9, mines: 24 },
18
24
  tall: { width: 9, height: 21, mines: 24 },
19
25
  } as const;
26
+ /** Default beginner game, with a verified no-guess board and clear opening. */
20
27
  export const DEFAULT_SETTINGS: Settings = { ...PRESETS.beginner, grid: GRIDS.square, noGuess: true, opening: "clear", seed: 1 };
28
+ /** Largest width or height accepted by settings validation. */
21
29
  export const MAX_SIDE = 60;
30
+ /** Largest total cell count accepted by settings validation. */
22
31
  export const MAX_CELLS = 2400;
32
+ /** Default candidate-board budget for verified generation. */
23
33
  export const GENERATION_ATTEMPTS = 128;
34
+ /** Largest frontier enumerated for exact deductions. */
24
35
  export const ENUMERATION_CELLS = 18;
36
+ /** Maximum partial assignments checked in one exact deduction search. */
25
37
  export const ENUMERATION_NODES = 100_000;
@@ -1,7 +1,10 @@
1
- /** A cell's number is its row-major place on the board, starting at zero. */
2
- export type Grid = "square" | "hex" | "wrap";
1
+ /** Neighbour topology used to count the clues around each cell. */
2
+ export type Grid = "square" | "orthogonal" | "hex" | "wrap";
3
+ /** Current game state, including whether a mine has been hit. */
3
4
  export type Status = "ready" | "playing" | "won" | "lost";
5
+ /** Player-facing state of one cell; the answer is never a mark. */
4
6
  export type Mark = "covered" | "flag" | "question" | "open";
7
+ /** Board dimensions and rules used to deal and validate a game. */
5
8
  export type Settings = {
6
9
  /** Shape omits cells; rectangle is the compatible default. Wrap requires rectangle. */
7
10
  shape?: "rectangle" | "heart" | "star" | "hexagon";
@@ -15,7 +18,9 @@ export type Settings = {
15
18
  opening: "safe" | "clear";
16
19
  seed: number;
17
20
  };
21
+ /** A reveal, mark-cycle, or numbered-cell chord applied to a game. */
18
22
  export type Move = { kind: "reveal" | "mark" | "chord"; cell: number };
23
+ /** A dealt board including its answer; keep it off public clients. */
19
24
  export type Board = {
20
25
  settings: Settings;
21
26
  mines: readonly boolean[];
@@ -34,6 +39,7 @@ export type Game = {
34
39
  /** A proved hint has been shown during this run. */
35
40
  helped: boolean;
36
41
  };
42
+ /** Answer-free state suitable for deductions, hints, and public display. */
37
43
  export type VisibleGame = {
38
44
  settings: Settings;
39
45
  /** Null is hidden, including flags. Only opened cells give a clue. */
@@ -41,7 +47,9 @@ export type VisibleGame = {
41
47
  marks: readonly Mark[];
42
48
  status: Status;
43
49
  };
50
+ /** An exact count over unknown cells, with the clues that supplied it. */
44
51
  export type Constraint = { cells: readonly number[]; mines: number; sources: readonly number[] };
52
+ /** Certain safe cells and mines proved from visible clues. */
45
53
  export type Deduction = {
46
54
  safe: readonly number[];
47
55
  mines: readonly number[];
@@ -50,7 +58,11 @@ export type Deduction = {
50
58
  /** A conflicting clue, or a board with no consistent mine placement. */
51
59
  contradiction: boolean;
52
60
  };
61
+ /** Work limits for seeded board generation and its no-guess check. */
53
62
  export type GenerationOptions = { attempts?: number; enumerate?: boolean };
63
+ /** Supported interface copy locales. */
54
64
  export type Language = "en" | "ja";
65
+ /** Board colours for drawing and mounted play. */
55
66
  export type Material = "ivory" | "wood" | "slate";
67
+ /** Symbols used to mark mines. */
56
68
  export type Pieces = "flags" | "stones" | "flowers";
package/src/keep.ts CHANGED
@@ -4,6 +4,9 @@ import type { Game, Move } from "./jirai.types.ts";
4
4
 
5
5
  /** Keep settings and moves, never an unchecked answer array. Version the deal before changing it. */
6
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
+ }
7
10
  return JSON.stringify({ version: 1, settings: game.settings, moves: game.moves, helped: game.helped });
8
11
  }
9
12
 
@@ -12,7 +15,10 @@ export function gameFromProgress(progress: string): Game | null {
12
15
  if (progress.length > 1_000_000) return null;
13
16
  try {
14
17
  const data = JSON.parse(progress);
15
- if (data.version !== 1 || !validSettings(data.settings) || !Array.isArray(data.moves) || data.moves.length > 20_000) return null;
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;
16
22
  if (data.helped !== undefined && typeof data.helped !== "boolean") return null;
17
23
  let game = newGame(data.settings);
18
24
  for (const move of data.moves) {
@@ -29,6 +35,7 @@ export function gameFromProgress(progress: string): Game | null {
29
35
  export function dailySeed(day: string, grid = "square"): number {
30
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.");
31
37
  let hash = 2166136261;
32
- for (const letter of `jirai:1:${day}:${grid}`) hash = Math.imul(hash ^ letter.charCodeAt(0), 16777619) >>> 0;
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;
33
40
  return hash;
34
41
  }