@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,50 @@
1
+ import { deduce } from "./deduce.ts";
2
+ import { newGame, visibleGame } from "./game.ts";
3
+ import { makeBoard } from "./generate.ts";
4
+ import { neighbours, validSettings } from "./grid.ts";
5
+ import { gameFromProgress, gameProgress } from "./keep.ts";
6
+ import type { Board, GenerationOptions, Game, Settings } from "./jirai.types.ts";
7
+
8
+ /** Identifies the four-neighbour rules and progress-code format. */
9
+ export const ORTHOGONAL_VARIANT = "orthogonal" as const;
10
+ /** Settings accepted by the four-neighbour-specific helpers. */
11
+ export type OrthogonalSettings = Omit<Settings, "grid">;
12
+
13
+ /** Starts a game under four-neighbour rules. */
14
+ export function newOrthogonalGame(settings: OrthogonalSettings): Game {
15
+ return newGame({ ...settings, grid: ORTHOGONAL_VARIANT });
16
+ }
17
+
18
+ /** Deals a board whose clues count only up, down, left and right. */
19
+ export function makeOrthogonalBoard(
20
+ settings: OrthogonalSettings,
21
+ first: number,
22
+ options?: GenerationOptions,
23
+ ): Board {
24
+ return makeBoard({ ...settings, grid: ORTHOGONAL_VARIANT }, first, options);
25
+ }
26
+
27
+ /** Returns the adjacent cells under the orthogonal rules. */
28
+ export function orthogonalNeighbours(settings: OrthogonalSettings, cell: number): number[] {
29
+ const complete = { ...settings, grid: ORTHOGONAL_VARIANT } as Settings;
30
+ if (!validSettings(complete)) throw new RangeError("Invalid orthogonal settings.");
31
+ return neighbours(complete, cell);
32
+ }
33
+
34
+ /** Gives a clue-only deduction; flags remain notes and cannot influence the result. */
35
+ export function orthogonalHint(game: Game) {
36
+ if (game.settings.grid !== ORTHOGONAL_VARIANT) throw new RangeError("Expected an orthogonal game.");
37
+ return deduce(visibleGame(game));
38
+ }
39
+
40
+ /** Encodes an orthogonal game with an explicit variant marker and version 2. */
41
+ export function encodeOrthogonalGame(game: Game): string {
42
+ if (game.settings.grid !== ORTHOGONAL_VARIANT) throw new RangeError("Expected an orthogonal game.");
43
+ return gameProgress(game);
44
+ }
45
+
46
+ /** Restores only version 2 orthogonal progress records. */
47
+ export function decodeOrthogonalGame(code: string): Game | null {
48
+ const game = gameFromProgress(code);
49
+ return game?.settings.grid === ORTHOGONAL_VARIANT ? game : null;
50
+ }
package/src/react.tsx CHANGED
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
2
2
  import { mountJirai } from "./mount.ts";
3
3
  import type { JiraiProps } from "./react.types.ts";
4
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. */
5
+ /** Renders the DOM player inside a React-owned host. Options are read on mount; use a new key to load a new board. */
6
6
  export function JiraiBoard({ settings, progress, material, pieces, language, controls, onChange, onFinish, onError, ...element }: JiraiProps) {
7
7
  const host = useRef<HTMLDivElement>(null);
8
8
  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/src/shape.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Settings } from "./jirai.types.ts";
2
+ /** Board silhouettes supported by settings. */
2
3
  export const SHAPES = ["rectangle", "heart", "star", "hexagon"] as const;
3
4
  /** Row-major addresses stay stable; omitted cells have no clue and no neighbours. */
4
5
  export function activeCell(settings: Settings, cell: number): boolean {
@@ -27,6 +28,7 @@ export function activeCell(settings: Settings, cell: number): boolean {
27
28
  default: return true;
28
29
  }
29
30
  }
31
+ /** Returns all active cells in stable row-major order. */
30
32
  export function activeCells(settings: Settings): number[] {
31
33
  return Array.from({ length: settings.width * settings.height }, (_, cell) => cell).filter(cell => activeCell(settings, cell));
32
34
  }
package/src/strings.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 const STRINGS = {
3
4
  en: {
4
5
  board: "Minesweeper board", covered: "covered", flag: "flagged", question: "uncertain", empty: "empty", mine: "mine", wrong: "incorrect flag",
@@ -15,4 +16,5 @@ export const STRINGS = {
15
16
  error: "推測なしで解ける盤が見つかりませんでした。別のシード、別の開始マス、または少ない地雷で試してください。",
16
17
  },
17
18
  } as const;
19
+ /** Returns the built-in interface strings for a locale. */
18
20
  export function words(language: Language = "en"): typeof STRINGS["en"] | typeof STRINGS["ja"] { return STRINGS[language]; }
package/src/ui.types.ts CHANGED
@@ -1,7 +1,11 @@
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 = { material?: Material; pieces?: Pieces; language?: Language; hint?: number | null };
4
+ /** Accessible presentation data for one active board cell. */
3
5
  export type CellModel = { cell: number; x: number; y: number; width: number; height: number; label: string; text: string; kind: string; hint: boolean };
6
+ /** Layout and cell presentation returned by `boardModel`. */
4
7
  export type BoardModel = { width: number; height: number; cells: CellModel[] };
8
+ /** Initial game, appearance, control, and lifecycle callbacks for a mounted board. */
5
9
  export type MountOptions = DrawOptions & {
6
10
  settings?: Settings;
7
11
  progress?: string;
@@ -10,6 +14,7 @@ export type MountOptions = DrawOptions & {
10
14
  onFinish?: (game: Game) => void;
11
15
  onError?: (error: Error) => void;
12
16
  };
17
+ /** Controls for reading and operating a mounted board. */
13
18
  export type JiraiMount = {
14
19
  game: () => Game;
15
20
  progress: () => string;
@@ -1,47 +0,0 @@
1
- import { expect, it } from "vitest";
2
- import { deduce, hintFor } from "./deduce.ts";
3
- import { enumerateForced } from "./enumerate.ts";
4
- import { DEFAULT_SETTINGS, MARKS, STATUSES } from "./jirai.constants.ts";
5
- import { neighbours } from "./grid.ts";
6
- import type { VisibleGame, Grid } from "./jirai.types.ts";
7
-
8
- it("extracts the 1–2–1 wall pattern without trusting flags", () => {
9
- const game: VisibleGame = {
10
- settings: { ...DEFAULT_SETTINGS, width: 3, height: 3, mines: 2 }, status: STATUSES.playing,
11
- clues: [null,null,null,1,2,1,0,0,0], marks: [MARKS.flag,MARKS.flag,MARKS.covered,...Array(6).fill(MARKS.open)],
12
- };
13
- const hint = hintFor(game);
14
- expect(hint.safe).toContain(1); // A wrongly flagged square is still provably safe.
15
- });
16
-
17
- it("enumeration proves only cells forced in every consistent assignment", () => {
18
- const proof = enumerateForced([
19
- { cells: [0,1], mines: 1, sources: [3] },
20
- { cells: [1,2], mines: 1, sources: [4] },
21
- { cells: [0,2], mines: 0, sources: [5] },
22
- ]);
23
- expect([...proof.safe].sort()).toEqual([0,2]); expect(proof.mines).toEqual([1]);
24
- expect(enumerateForced([{ cells: [0,1], mines: 1, sources: [] }]).safe).toEqual([]);
25
- expect(enumerateForced([{ cells: [0], mines: 2, sources: [] }]).contradiction).toBe(true);
26
- });
27
-
28
- it("agrees with an independent exhaustive oracle on square, hex and wrapped 3×3 positions", () => {
29
- for (const grid of ["square", "hex", "wrap"] as Grid[]) {
30
- const settings = { ...DEFAULT_SETTINGS, width: 3, height: 3, mines: 2, grid };
31
- for (let firstMine = 0; firstMine < 9; firstMine += 1) for (let secondMine = firstMine + 1; secondMine < 9; secondMine += 1) {
32
- const actual = [firstMine, secondMine];
33
- const clues = Array.from({ length: 9 }, (_, cell) => !actual.includes(cell) && cell % 2 === 0 ? neighbours(settings, cell).filter((n) => actual.includes(n)).length : null);
34
- const possible: number[][] = [];
35
- for (let a = 0; a < 9; a += 1) for (let b = a + 1; b < 9; b += 1) {
36
- const mines = [a,b];
37
- if (mines.some((cell) => clues[cell] !== null)) continue;
38
- if (clues.every((clue, cell) => clue === null || neighbours(settings, cell).filter((n) => mines.includes(n)).length === clue)) possible.push(mines);
39
- }
40
- const game: VisibleGame = { settings, clues, status: STATUSES.playing, marks: clues.map((n) => n === null ? MARKS.flag : MARKS.open) };
41
- const result = deduce(game);
42
- expect(result.contradiction).toBe(false); expect(possible.length).toBeGreaterThan(0);
43
- for (const cell of result.safe) expect(possible.every((mines) => !mines.includes(cell))).toBe(true);
44
- for (const cell of result.mines) expect(possible.every((mines) => mines.includes(cell))).toBe(true);
45
- }
46
- }
47
- });
package/src/game.test.ts DELETED
@@ -1,129 +0,0 @@
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/shape.test.ts DELETED
@@ -1,56 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { activeCell, activeCells } from "./shape.ts";
3
- import { DEFAULT_SETTINGS, GRIDS } from "./jirai.constants.ts";
4
- import { neighboursOf, validSettings } from "./grid.ts";
5
- import { makeBoard, isSolvable } from "./generate.ts";
6
- import { newGame, play, visibleGame } from "./game.ts";
7
- import { boardModel } from "./draw.ts";
8
- import { gameFromProgress, gameProgress } from "./keep.ts";
9
- import type { Settings } from "./jirai.types.ts";
10
-
11
- describe("shaped minefields", () => {
12
- for (const shape of ["heart", "star", "hexagon"] as const) for (const grid of [GRIDS.square, GRIDS.hex]) {
13
- it(`${shape}/${grid} omits cells and retains reciprocal neighbours and proved deals`, () => {
14
- const settings: Settings = { ...DEFAULT_SETTINGS, width: 17, height: 17, mines: 12, shape, grid };
15
- for (const outside of [-1, 289, 1.5, NaN]) expect(activeCell(settings, outside)).toBe(false);
16
- const cells = activeCells(settings), adjacent = neighboursOf(settings);
17
- expect(cells.length).toBeGreaterThan(40); expect(cells.length).toBeLessThan(289);
18
- for (const c of cells) for (const n of adjacent[c]!) {
19
- expect(activeCell(settings, n)).toBe(true); expect(adjacent[n]).toContain(c);
20
- }
21
- const first = cells[Math.floor(cells.length / 2)]!;
22
- for (let seed = 1; seed <= 12; seed += 1) {
23
- const board = makeBoard({ ...settings, seed }, first);
24
- expect(board.mines.filter(Boolean)).toHaveLength(12); expect(isSolvable(board)).toBe(true);
25
- for (let c = 0; c < 289; c += 1) if (!activeCell(settings, c)) {
26
- expect(board.mines[c]).toBe(false); expect(board.clues[c]).toBe(-2); expect(adjacent[c]).toEqual([]);
27
- }
28
- }
29
- });
30
- }
31
- it("never draws or lets the player act on cut-out cells, and restores the shape", () => {
32
- const settings: Settings = { ...DEFAULT_SETTINGS, width: 17, height: 17, shape: "heart", mines: 12 };
33
- let game = newGame(settings);
34
- const absent = Array.from({ length: 289 }, (_, c) => c).find(c => !activeCell(settings, c))!;
35
- expect(play(game, { kind: "reveal", cell: absent })).toBe(game);
36
- expect(play(game, { kind: "mark", cell: absent })).toBe(game);
37
- expect(visibleGame(game).clues[absent]).toBe(-2);
38
- expect(boardModel(game).cells.some(c => c.cell === absent)).toBe(false);
39
- game = play(game, { kind: "reveal", cell: activeCells(settings)[50]! });
40
- expect(gameFromProgress(gameProgress(game))?.settings.shape).toBe("heart");
41
- });
42
- it("wins when every active safe cell opens, without requiring absent cells", () => {
43
- const settings: Settings = { ...DEFAULT_SETTINGS, width: 9, height: 9, shape: "star", mines: 1, noGuess: false };
44
- let game = newGame(settings);
45
- const first = activeCells(settings)[10]!;
46
- game = play(game, { kind: "reveal", cell: first });
47
- for (const c of activeCells(settings)) if (!game.board!.mines[c]) game = play(game, { kind: "reveal", cell: c });
48
- expect(game.status).toBe("won");
49
- });
50
- it("rejects unknown outlines, tiny silhouettes, wrap cut-outs and excess mines", () => {
51
- expect(validSettings({ ...DEFAULT_SETTINGS, shape: "toString" })).toBe(false);
52
- expect(validSettings({ ...DEFAULT_SETTINGS, width: 5, height: 5, shape: "heart" })).toBe(false);
53
- expect(validSettings({ ...DEFAULT_SETTINGS, shape: "heart", grid: "wrap" })).toBe(false);
54
- expect(validSettings({ ...DEFAULT_SETTINGS, width: 9, height: 9, shape: "star", mines: 70 })).toBe(false);
55
- });
56
- });