@johnmorrisdotca/jirai 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0] - 2026-10-05
4
+
5
+ - Orthogonal fields: four-neighbour clues, independent hints, proof-backed generation, drawing, saved progress and bilingual controls. Existing square, hexagonal and wraparound modes remain available.
6
+
3
7
  ## 0.1.0 — release candidate
4
8
 
5
9
  - Square, hexagonal and wraparound fields, seeded and configurable.
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # Jirai 地雷
2
2
 
3
- **Every number is a clue.** Minesweeper on a square grid, a hexagonal grid, or a board whose opposite edges join. Clear the ground, mark the mines, and finish a field made from a seed.
3
+ **Every number is a clue.** Minesweeper on four-neighbour orthogonal and eight-neighbour square grids, hexagons, or a board whose opposite edges join. Clear the ground, mark the mines, and finish a field made from a seed.
4
4
 
5
5
  Plain TypeScript rules, no runtime dependencies. The rules work without a browser; the playable board works in any page. A React wrapper and a custom element are separate imports.
6
6
 
7
7
  ## What it does
8
8
 
9
- - Square, hexagonal and wraparound boards, with width, height and mine count of your own.
9
+ - Orthogonal four-neighbour, square eight-neighbour, hexagonal and wraparound boards, with width, height and mine count of your own.
10
10
  - Rectangle, heart, star and hexagon outlines; wide and tall presets. Cut-outs are outside the playable field.
11
11
  - A safe first cell, or a clear opening with all its neighbours safe.
12
12
  - Verified no-guess fields. A bounded generator throws `GenerationError` if it cannot prove a board; it never substitutes an ordinary field.
@@ -39,9 +39,11 @@ Every move returns a new game and leaves its input untouched. A move which is un
39
39
 
40
40
  `Settings.shape` may be `rectangle` (the default), `heart`, `star` or `hexagon`. Shaped fields require at least 9 cells on each side and work on square or hexagonal grids; wraparound requires a rectangle. `activeCells(settings)` returns playable row-major cells and `activeCell(settings, cell)` tests membership. Outside cells have clue −2, never contain mines and are excluded from neighbours, solving, drawing and winning. Mine count must be less than the playable cell count.
41
41
 
42
+ The dedicated `@johnmorrisdotca/jirai/orthogonal` entry provides `newOrthogonalGame`, `makeOrthogonalBoard`, `orthogonalNeighbours`, `orthogonalHint`, `encodeOrthogonalGame` and `decodeOrthogonalGame`. It applies four-neighbour rules explicitly, so callers do not need to thread a topology string through setup.
43
+
42
44
  The demo uses the family’s original shared stylesheet and header template, with its five table-cloth choices and bilingual Help controls.
43
45
 
44
- Cells are row-major numbers, zero first: `cell = row * width + column`. Hex boards use axial coordinates: neighbours are left, right, above, above-right, below-left and below. A wrap board joins both opposite edges of a square grid.
46
+ Cells are row-major numbers, zero first: `cell = row * width + column`. Orthogonal clues count only the four edge-sharing cells. Square boards count eight neighbours. Hex boards use axial coordinates: neighbours are left, right, above, above-right, below-left and below. A wrap board joins both opposite edges of a square grid.
45
47
 
46
48
  ```ts
47
49
  import { mountJirai } from "@johnmorrisdotca/jirai/play";
@@ -95,7 +97,7 @@ const restored = gameFromProgress(saved); // null for an invalid record
95
97
  const seed = dailySeed("2026-10-04", "hex");
96
98
  ```
97
99
 
98
- Records are versioned settings and legal moves, not unchecked mine arrays. A seed is only the start of a board's identity: width, height, mine count, grid, no-guess setting, opening policy and first cell matter too. Daily play uses UTC, beginner settings, and the centre opening. The demo's share link includes the first cell after a field has been dealt. Its local save restores the moves, but does not restore elapsed time.
100
+ Ordinary grids keep version 1 progress records. Orthogonal records use version 2 and an explicit `variant: "orthogonal"` marker; `decodeOrthogonalGame` accepts only those records. Existing square, hex and wraparound records keep their format and meaning. A seed is only the start of a board's identity: width, height, mine count, grid, no-guess setting, opening policy and first cell matter too. Daily play uses UTC, beginner settings, and the centre opening. The demo's share link includes the first cell after a field has been dealt. Its local save restores the moves, but does not restore elapsed time.
99
101
 
100
102
  ## The no-guess promise
101
103
 
@@ -121,7 +123,7 @@ The generated standalone page is in `docs/`; the preview serves only on `127.0.0
121
123
 
122
124
  ## Architecture
123
125
 
124
- Rules: `jirai.types.ts`, `jirai.constants.ts`, `grid.ts`, `random.ts`, `generate.ts`, `flood.ts`, `game.ts`, `deduce.ts`, `enumerate.ts`, `keep.ts`.
126
+ Rules: `jirai.types.ts`, `jirai.constants.ts`, `grid.ts`, `random.ts`, `generate.ts`, `flood.ts`, `game.ts`, `deduce.ts`, `enumerate.ts`, `keep.ts`, `orthogonal.ts`.
125
127
 
126
128
  Drawing and play: `draw.ts`, `style.ts`, `strings.ts`, `mount.ts`, `worker.ts`, `ui.types.ts`. Framework and tag wrappers are separate entries.
127
129
 
package/dist/index.d.ts CHANGED
@@ -7,5 +7,6 @@ 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 declare const VERSION = "0.1.0";
10
+ export declare const VERSION = "0.2.0";
11
11
  export { SHAPES, activeCell, activeCells } from "./shape.ts";
12
+ export * from "./orthogonal.ts";
package/dist/index.js CHANGED
@@ -7,5 +7,6 @@ export * from "./game.js";
7
7
  export * from "./deduce.js";
8
8
  export * from "./keep.js";
9
9
  export { seededRandom } from "./random.js";
10
- export const VERSION = "0.1.0";
10
+ export const VERSION = "0.2.0";
11
11
  export { SHAPES, activeCell, activeCells } from "./shape.js";
12
+ export * from "./orthogonal.js";
@@ -1,6 +1,7 @@
1
1
  import type { Grid, Settings } from "./jirai.types.ts";
2
2
  export declare const GRIDS: {
3
3
  readonly square: "square";
4
+ readonly orthogonal: "orthogonal";
4
5
  readonly hex: "hex";
5
6
  readonly wrap: "wrap";
6
7
  };
@@ -1,10 +1,11 @@
1
- export const GRIDS = { square: "square", hex: "hex", wrap: "wrap" };
1
+ export const GRIDS = { square: "square", orthogonal: "orthogonal", hex: "hex", wrap: "wrap" };
2
2
  export const STATUSES = { ready: "ready", playing: "playing", won: "won", lost: "lost" };
3
3
  export const MARKS = { covered: "covered", flag: "flag", question: "question", open: "open" };
4
4
  export const MOVES = { reveal: "reveal", mark: "mark", chord: "chord" };
5
5
  /** Hex coordinates are axial: a row is displaced half a cell to the right. */
6
6
  export const GRID_SPECS = {
7
7
  square: { offsets: [[-1, -1], [0, -1], [1, -1], [-1, 0], [1, 0], [-1, 1], [0, 1], [1, 1]], wrap: false },
8
+ orthogonal: { offsets: [[0, -1], [-1, 0], [1, 0], [0, 1]], wrap: false },
8
9
  hex: { offsets: [[-1, 0], [1, 0], [0, -1], [1, -1], [-1, 1], [0, 1]], wrap: false },
9
10
  wrap: { offsets: [[-1, -1], [0, -1], [1, -1], [-1, 0], [1, 0], [-1, 1], [0, 1], [1, 1]], wrap: true },
10
11
  };
@@ -1,5 +1,5 @@
1
1
  /** A cell's number is its row-major place on the board, starting at zero. */
2
- export type Grid = "square" | "hex" | "wrap";
2
+ export type Grid = "square" | "orthogonal" | "hex" | "wrap";
3
3
  export type Status = "ready" | "playing" | "won" | "lost";
4
4
  export type Mark = "covered" | "flag" | "question" | "open";
5
5
  export type Settings = {
package/dist/keep.js CHANGED
@@ -2,6 +2,9 @@ import { newGame, play } from "./game.js";
2
2
  import { validSettings } from "./grid.js";
3
3
  /** Keep settings and moves, never an unchecked answer array. Version the deal before changing it. */
4
4
  export function gameProgress(game) {
5
+ if (game.settings.grid === "orthogonal") {
6
+ return JSON.stringify({ version: 2, variant: "orthogonal", settings: game.settings, moves: game.moves, helped: game.helped });
7
+ }
5
8
  return JSON.stringify({ version: 1, settings: game.settings, moves: game.moves, helped: game.helped });
6
9
  }
7
10
  /** A saved game is rebuilt under the rules. Malformed or unavailable boards return null. */
@@ -10,7 +13,10 @@ export function gameFromProgress(progress) {
10
13
  return null;
11
14
  try {
12
15
  const data = JSON.parse(progress);
13
- if (data.version !== 1 || !validSettings(data.settings) || !Array.isArray(data.moves) || data.moves.length > 20_000)
16
+ const orthogonalCode = data.version === 2 && data.variant === "orthogonal";
17
+ if ((!orthogonalCode && data.version !== 1) || !validSettings(data.settings)
18
+ || orthogonalCode !== (data.settings.grid === "orthogonal")
19
+ || !Array.isArray(data.moves) || data.moves.length > 20_000)
14
20
  return null;
15
21
  if (data.helped !== undefined && typeof data.helped !== "boolean")
16
22
  return null;
@@ -34,7 +40,8 @@ export function dailySeed(day, grid = "square") {
34
40
  if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || new Date(`${day}T00:00:00Z`).toISOString().slice(0, 10) !== day)
35
41
  throw new RangeError("Use a real day as YYYY-MM-DD.");
36
42
  let hash = 2166136261;
37
- for (const letter of `jirai:1:${day}:${grid}`)
43
+ const version = grid === "orthogonal" ? 2 : 1;
44
+ for (const letter of `jirai:${version}:${day}:${grid}`)
38
45
  hash = Math.imul(hash ^ letter.charCodeAt(0), 16777619) >>> 0;
39
46
  return hash;
40
47
  }
@@ -0,0 +1,16 @@
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
+ export type OrthogonalSettings = Omit<Settings, "grid">;
5
+ /** Starts a game under four-neighbour rules. */
6
+ export declare function newOrthogonalGame(settings: OrthogonalSettings): Game;
7
+ /** Deals a board whose clues count only up, down, left and right. */
8
+ export declare function makeOrthogonalBoard(settings: OrthogonalSettings, first: number, options?: GenerationOptions): Board;
9
+ /** Returns the adjacent cells under the orthogonal rules. */
10
+ export declare function orthogonalNeighbours(settings: OrthogonalSettings, cell: number): number[];
11
+ /** Gives a clue-only deduction; flags remain notes and cannot influence the result. */
12
+ export declare function orthogonalHint(game: Game): import("./jirai.types.ts").Deduction;
13
+ /** Encodes an orthogonal game with an explicit variant marker and version 2. */
14
+ export declare function encodeOrthogonalGame(game: Game): string;
15
+ /** Restores only version 2 orthogonal progress records. */
16
+ 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/package.json CHANGED
@@ -1,27 +1,74 @@
1
1
  {
2
2
  "name": "@johnmorrisdotca/jirai",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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
+ "puzzle",
19
+ "hexagonal",
20
+ "orthogonal",
21
+ "no-guess",
22
+ "typescript",
23
+ "seeded",
24
+ "web-component",
25
+ "itsutsu"
26
+ ],
12
27
  "main": "./dist/index.js",
13
28
  "types": "./dist/index.d.ts",
14
29
  "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" },
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "default": "./dist/index.js"
33
+ },
34
+ "./orthogonal": {
35
+ "types": "./dist/orthogonal.d.ts",
36
+ "default": "./dist/orthogonal.js"
37
+ },
38
+ "./play": {
39
+ "types": "./dist/play-entry.d.ts",
40
+ "default": "./dist/play-entry.js"
41
+ },
42
+ "./draw": {
43
+ "types": "./dist/draw-entry.d.ts",
44
+ "default": "./dist/draw-entry.js"
45
+ },
46
+ "./element": {
47
+ "types": "./dist/element.d.ts",
48
+ "default": "./dist/element.js"
49
+ },
50
+ "./element/define": {
51
+ "types": "./dist/element-define.d.ts",
52
+ "default": "./dist/element-define.js"
53
+ },
54
+ "./react": {
55
+ "types": "./dist/react.d.ts",
56
+ "default": "./dist/react.js"
57
+ }
58
+ },
59
+ "sideEffects": [
60
+ "./dist/element-define.js"
61
+ ],
62
+ "files": [
63
+ "dist",
64
+ "src",
65
+ "README.md",
66
+ "LICENSE",
67
+ "CHANGELOG.md"
68
+ ],
69
+ "engines": {
70
+ "node": ">=22"
71
+ },
25
72
  "scripts": {
26
73
  "build": "tsc -p tsconfig.json",
27
74
  "typecheck": "tsc -p tsconfig.check.json",
@@ -33,13 +80,26 @@
33
80
  "test:package": "pnpm build && node scripts/check-package.mjs",
34
81
  "prepublishOnly": "pnpm check && pnpm test:package"
35
82
  },
36
- "peerDependencies": { "react": ">=18" },
37
- "peerDependenciesMeta": { "react": { "optional": true } },
83
+ "peerDependencies": {
84
+ "react": ">=18"
85
+ },
86
+ "peerDependenciesMeta": {
87
+ "react": {
88
+ "optional": true
89
+ }
90
+ },
38
91
  "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"
92
+ "@eslint/js": "^9.0.0",
93
+ "eslint": "^9.0.0",
94
+ "typescript-eslint": "^8.0.0",
95
+ "typescript": "^5.9.0",
96
+ "vitest": "^4.0.0",
97
+ "@playwright/test": "^1.58.0",
98
+ "@types/react": "^19.0.0",
99
+ "react": "^19.0.0"
100
+ },
101
+ "publishConfig": {
102
+ "access": "public"
42
103
  },
43
- "publishConfig": { "access": "public" },
44
104
  "packageManager": "pnpm@10.11.0"
45
105
  }
@@ -25,8 +25,8 @@ it("enumeration proves only cells forced in every consistent assignment", () =>
25
25
  expect(enumerateForced([{ cells: [0], mines: 2, sources: [] }]).contradiction).toBe(true);
26
26
  });
27
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[]) {
28
+ it("agrees with an independent exhaustive oracle on square, orthogonal, hex and wrapped 3×3 positions", () => {
29
+ for (const grid of ["square", "orthogonal", "hex", "wrap"] as Grid[]) {
30
30
  const settings = { ...DEFAULT_SETTINGS, width: 3, height: 3, mines: 2, grid };
31
31
  for (let firstMine = 0; firstMine < 9; firstMine += 1) for (let secondMine = firstMine + 1; secondMine < 9; secondMine += 1) {
32
32
  const actual = [firstMine, secondMine];
package/src/game.test.ts CHANGED
@@ -2,10 +2,12 @@ import { describe, expect, it } from "vitest";
2
2
  import { newGame, play, visibleGame } from "./game.ts";
3
3
  import { makeBoard, isSolvable, GenerationError } from "./generate.ts";
4
4
  import { DEFAULT_SETTINGS, GRIDS, MARKS, MOVES, STATUSES } from "./jirai.constants.ts";
5
- import { neighbours, neighboursOf, validSettings } from "./grid.ts";
5
+ import { neighbours, neighboursOf, validCell, validSettings } from "./grid.ts";
6
6
  import { gameFromProgress, gameProgress, dailySeed } from "./keep.ts";
7
7
  import { boardModel } from "./draw.ts";
8
+ import { hintFor } from "./deduce.ts";
8
9
  import type { Board, Game, Settings } from "./jirai.types.ts";
10
+ import { decodeOrthogonalGame, encodeOrthogonalGame, makeOrthogonalBoard, newOrthogonalGame, orthogonalHint, orthogonalNeighbours } from "./orthogonal.ts";
9
11
 
10
12
  function fixedGame(minesAt: number[]): Game {
11
13
  const settings: Settings = { ...DEFAULT_SETTINGS, width: 5, height: 5, mines: minesAt.length, noGuess: false, opening: "safe" };
@@ -15,7 +17,7 @@ function fixedGame(minesAt: number[]): Game {
15
17
  return { ...newGame(settings), board };
16
18
  }
17
19
 
18
- describe("the three boards", () => {
20
+ describe("the grid topologies", () => {
19
21
  for (const grid of Object.values(GRIDS)) it(`${grid} has reciprocal, unique neighbours and no self-neighbours`, () => {
20
22
  const settings = { ...DEFAULT_SETTINGS, width: 7, height: 5, grid };
21
23
  const all = neighboursOf(settings);
@@ -25,11 +27,32 @@ describe("the three boards", () => {
25
27
  for (const n of all[cell]!) expect(all[n]).toContain(cell);
26
28
  }
27
29
  });
28
- it("uses six hex neighbours, eight square neighbours, and joins both wrap seams", () => {
30
+ it("uses four orthogonal neighbours, six hex neighbours, eight square neighbours, and joins both wrap seams", () => {
31
+ expect(neighbours({ ...DEFAULT_SETTINGS, grid: GRIDS.orthogonal }, 40)).toHaveLength(4);
29
32
  expect(neighbours({ ...DEFAULT_SETTINGS, grid: GRIDS.hex }, 40)).toHaveLength(6);
30
33
  expect(neighbours(DEFAULT_SETTINGS, 40)).toHaveLength(8);
31
34
  expect(neighbours({ ...DEFAULT_SETTINGS, grid: GRIDS.wrap }, 0)).toEqual([1,8,9,10,17,72,73,80]);
32
35
  });
36
+ it("uses only orthogonal clues, preserves playable voids, and verifies a no-guess deal", () => {
37
+ const settings = { ...DEFAULT_SETTINGS, width: 9, height: 9, mines: 10, grid: GRIDS.orthogonal, seed: 19 };
38
+ const board = makeBoard(settings, 40);
39
+ expect(board.clues[40]).toBe(0);
40
+ for (let cell = 0; cell < 81; cell += 1) {
41
+ expect(board.clues[cell]).toBe(board.mines[cell] ? -1 : neighbours(settings, cell).filter(next => board.mines[next]).length);
42
+ }
43
+ expect(isSolvable(board)).toBe(true);
44
+ const shaped = { ...settings, shape: "heart" as const };
45
+ const shapedBoard = makeBoard(shaped, 40);
46
+ for (let cell = 0; cell < 81; cell += 1) if (!validCell(shaped, cell)) {
47
+ expect(shapedBoard.mines[cell]).toBe(false);
48
+ expect(shapedBoard.clues[cell]).toBe(-2);
49
+ expect(neighbours(shaped, cell)).toEqual([]);
50
+ }
51
+ const playing = play(newGame(settings), { kind: MOVES.reveal, cell: 40 });
52
+ const proof = hintFor(visibleGame(playing));
53
+ expect(proof.safe.every(cell => !playing.board!.mines[cell])).toBe(true);
54
+ expect(proof.mines.every(cell => playing.board!.mines[cell])).toBe(true);
55
+ });
33
56
  it("rejects malformed or excessive settings before making a board", () => {
34
57
  for (const change of [{ width: 1 }, { width: 61 }, { width: 60, height: 60 }, { mines: 81 }, { seed: NaN }, { grid: "__proto__" }, { mines: 1.5 }, { noGuess: "yes" }]) expect(validSettings({ ...DEFAULT_SETTINGS, ...change })).toBe(false);
35
58
  });
@@ -113,8 +136,28 @@ describe("moves", () => {
113
136
  expect(gameFromProgress(gameProgress(game))).toEqual(game);
114
137
  for (const data of ["garbage", "null", '{"version":2}', JSON.stringify({ version: 1, settings: DEFAULT_SETTINGS, moves: [{ kind: "reveal", cell: -1 }] })]) expect(gameFromProgress(data)).toBeNull();
115
138
  });
139
+ it("keeps orthogonal save records distinct and preserves legacy square codes", () => {
140
+ const legacy = JSON.stringify({ version: 1, settings: DEFAULT_SETTINGS, moves: [], helped: false });
141
+ expect(gameFromProgress(legacy)).toEqual(newGame());
142
+ const orthogonal = newGame({ ...DEFAULT_SETTINGS, grid: GRIDS.orthogonal });
143
+ const code = gameProgress(orthogonal);
144
+ expect(JSON.parse(code)).toMatchObject({ version: 2, variant: "orthogonal", settings: { grid: "orthogonal" } });
145
+ expect(gameFromProgress(code)).toEqual(orthogonal);
146
+ expect(gameFromProgress(JSON.stringify({ ...JSON.parse(code), variant: "square" }))).toBeNull();
147
+ });
148
+ it("exposes focused orthogonal helpers and variant-only progress codes", () => {
149
+ const settings = { ...DEFAULT_SETTINGS, width: 9, height: 9, mines: 10, seed: 4 };
150
+ const game = newOrthogonalGame(settings);
151
+ expect(game.settings.grid).toBe("orthogonal");
152
+ expect(orthogonalNeighbours(settings, 40)).toHaveLength(4);
153
+ expect(makeOrthogonalBoard(settings, 40).settings.grid).toBe("orthogonal");
154
+ expect(orthogonalHint(play(game, { kind: MOVES.reveal, cell: 40 })).contradiction).toBe(false);
155
+ expect(decodeOrthogonalGame(encodeOrthogonalGame(game))).toEqual(game);
156
+ expect(decodeOrthogonalGame(gameProgress(newGame()))).toBeNull();
157
+ });
116
158
  it("has stable daily seeds and rejects impossible calendar dates", () => {
117
159
  expect(dailySeed("2026-10-04")).toBe(dailySeed("2026-10-04"));
160
+ expect(dailySeed("2026-10-04", "orthogonal")).not.toBe(dailySeed("2026-10-04"));
118
161
  expect(dailySeed("2026-10-04", "hex")).not.toBe(dailySeed("2026-10-04"));
119
162
  expect(() => dailySeed("2026-02-30")).toThrow();
120
163
  });
package/src/index.ts CHANGED
@@ -7,6 +7,8 @@ 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
+ export const VERSION = "0.2.0";
11
11
 
12
12
  export { SHAPES, activeCell, activeCells } from "./shape.ts";
13
+
14
+ export * from "./orthogonal.ts";
@@ -1,12 +1,13 @@
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
+ export const GRIDS = { square: "square", orthogonal: "orthogonal", hex: "hex", wrap: "wrap" } as const;
4
4
  export const STATUSES = { ready: "ready", playing: "playing", won: "won", lost: "lost" } as const;
5
5
  export const MARKS = { covered: "covered", flag: "flag", question: "question", open: "open" } as const;
6
6
  export const MOVES = { reveal: "reveal", mark: "mark", chord: "chord" } as const;
7
7
  /** Hex coordinates are axial: a row is displaced half a cell to the right. */
8
8
  export const GRID_SPECS: Record<Grid, { offsets: readonly (readonly [number, number])[]; wrap: boolean }> = {
9
9
  square: { offsets: [[-1,-1],[0,-1],[1,-1],[-1,0],[1,0],[-1,1],[0,1],[1,1]], wrap: false },
10
+ orthogonal: { offsets: [[0,-1],[-1,0],[1,0],[0,1]], wrap: false },
10
11
  hex: { offsets: [[-1,0],[1,0],[0,-1],[1,-1],[-1,1],[0,1]], wrap: false },
11
12
  wrap: { offsets: [[-1,-1],[0,-1],[1,-1],[-1,0],[1,0],[-1,1],[0,1],[1,1]], wrap: true },
12
13
  };
@@ -1,5 +1,5 @@
1
1
  /** A cell's number is its row-major place on the board, starting at zero. */
2
- export type Grid = "square" | "hex" | "wrap";
2
+ export type Grid = "square" | "orthogonal" | "hex" | "wrap";
3
3
  export type Status = "ready" | "playing" | "won" | "lost";
4
4
  export type Mark = "covered" | "flag" | "question" | "open";
5
5
  export type Settings = {
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
  }
@@ -0,0 +1,49 @@
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
+ export type OrthogonalSettings = Omit<Settings, "grid">;
11
+
12
+ /** Starts a game under four-neighbour rules. */
13
+ export function newOrthogonalGame(settings: OrthogonalSettings): Game {
14
+ return newGame({ ...settings, grid: ORTHOGONAL_VARIANT });
15
+ }
16
+
17
+ /** Deals a board whose clues count only up, down, left and right. */
18
+ export function makeOrthogonalBoard(
19
+ settings: OrthogonalSettings,
20
+ first: number,
21
+ options?: GenerationOptions,
22
+ ): Board {
23
+ return makeBoard({ ...settings, grid: ORTHOGONAL_VARIANT }, first, options);
24
+ }
25
+
26
+ /** Returns the adjacent cells under the orthogonal rules. */
27
+ export function orthogonalNeighbours(settings: OrthogonalSettings, cell: number): number[] {
28
+ const complete = { ...settings, grid: ORTHOGONAL_VARIANT } as Settings;
29
+ if (!validSettings(complete)) throw new RangeError("Invalid orthogonal settings.");
30
+ return neighbours(complete, cell);
31
+ }
32
+
33
+ /** Gives a clue-only deduction; flags remain notes and cannot influence the result. */
34
+ export function orthogonalHint(game: Game) {
35
+ if (game.settings.grid !== ORTHOGONAL_VARIANT) throw new RangeError("Expected an orthogonal game.");
36
+ return deduce(visibleGame(game));
37
+ }
38
+
39
+ /** Encodes an orthogonal game with an explicit variant marker and version 2. */
40
+ export function encodeOrthogonalGame(game: Game): string {
41
+ if (game.settings.grid !== ORTHOGONAL_VARIANT) throw new RangeError("Expected an orthogonal game.");
42
+ return gameProgress(game);
43
+ }
44
+
45
+ /** Restores only version 2 orthogonal progress records. */
46
+ export function decodeOrthogonalGame(code: string): Game | null {
47
+ const game = gameFromProgress(code);
48
+ return game?.settings.grid === ORTHOGONAL_VARIANT ? game : null;
49
+ }
package/src/shape.test.ts CHANGED
@@ -9,7 +9,7 @@ import { gameFromProgress, gameProgress } from "./keep.ts";
9
9
  import type { Settings } from "./jirai.types.ts";
10
10
 
11
11
  describe("shaped minefields", () => {
12
- for (const shape of ["heart", "star", "hexagon"] as const) for (const grid of [GRIDS.square, GRIDS.hex]) {
12
+ for (const shape of ["heart", "star", "hexagon"] as const) for (const grid of [GRIDS.square, GRIDS.orthogonal, GRIDS.hex]) {
13
13
  it(`${shape}/${grid} omits cells and retains reciprocal neighbours and proved deals`, () => {
14
14
  const settings: Settings = { ...DEFAULT_SETTINGS, width: 17, height: 17, mines: 12, shape, grid };
15
15
  for (const outside of [-1, 289, 1.5, NaN]) expect(activeCell(settings, outside)).toBe(false);