@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
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
});
|
package/src/shape.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Settings } from "./jirai.types.ts";
|
|
2
|
+
export const SHAPES = ["rectangle", "heart", "star", "hexagon"] as const;
|
|
3
|
+
/** Row-major addresses stay stable; omitted cells have no clue and no neighbours. */
|
|
4
|
+
export function activeCell(settings: Settings, cell: number): boolean {
|
|
5
|
+
if (!Number.isInteger(cell) || cell < 0 || cell >= settings.width * settings.height) return false;
|
|
6
|
+
const col = cell % settings.width, row = Math.floor(cell / settings.width);
|
|
7
|
+
const x = 2 * (col + .5) / settings.width - 1, y = 2 * (row + .5) / settings.height - 1;
|
|
8
|
+
switch (settings.shape ?? "rectangle") {
|
|
9
|
+
case "heart": {
|
|
10
|
+
const hx = x * 1.15, hy = -y * 1.25 + .15;
|
|
11
|
+
return (hx * hx + hy * hy - 1) ** 3 - hx * hx * hy ** 3 <= 0;
|
|
12
|
+
}
|
|
13
|
+
case "star": {
|
|
14
|
+
// A filled five-point polygon, never a decorative overlay on hidden square cells.
|
|
15
|
+
const vertices = Array.from({ length: 10 }, (_, i) => {
|
|
16
|
+
const angle = -Math.PI / 2 + i * Math.PI / 5, radius = i % 2 ? .48 : 1;
|
|
17
|
+
return [Math.cos(angle) * radius, Math.sin(angle) * radius] as const;
|
|
18
|
+
});
|
|
19
|
+
let inside = false;
|
|
20
|
+
for (let i = 0, j = 9; i < 10; j = i++) {
|
|
21
|
+
const [xi, yi] = vertices[i]!, [xj, yj] = vertices[j]!;
|
|
22
|
+
if ((yi > y) !== (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi) inside = !inside;
|
|
23
|
+
}
|
|
24
|
+
return inside;
|
|
25
|
+
}
|
|
26
|
+
case "hexagon": return settings.grid === "hex" ? Math.abs(x + y) <= 1 : Math.abs(x) <= 1 - .5 * Math.abs(y);
|
|
27
|
+
default: return true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function activeCells(settings: Settings): number[] {
|
|
31
|
+
return Array.from({ length: settings.width * settings.height }, (_, cell) => cell).filter(cell => activeCell(settings, cell));
|
|
32
|
+
}
|
package/src/strings.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Language } from "./jirai.types.ts";
|
|
2
|
+
export const STRINGS = {
|
|
3
|
+
en: {
|
|
4
|
+
board: "Minesweeper board", covered: "covered", flag: "flagged", question: "uncertain", empty: "empty", mine: "mine", wrong: "incorrect flag",
|
|
5
|
+
ready: "Open a cell to begin. Your first cell is safe.", playing: "Read the numbers. Open every safe cell.", won: "Every safe cell is open. Well played.", lost: "A mine. Try the same board again, or start another.",
|
|
6
|
+
checking: "Finding a board that can be solved without guessing…", restart: "Start over", hint: "Hint", reveal: "Open", mark: "Mark", mines: "Mines left", time: "Time", moves: "Moves", clear: "Cleared", view: "Just the board", close: "Close", hintNone: "No certain move proved. This position may need a guess.", hintSafe: "This cell is safe.", hintMine: "This cell contains a mine.", count: "The neighbouring count settles it.", overlap: "Compare the overlapping clues.", total: "The total mine count settles it.", enumeration: "Every consistent arrangement gives the same answer.",
|
|
7
|
+
help: "Tap to open; right-click or hold to mark. Use the arrows to move, Enter to open, F or Space to mark. Tap an open number to clear its neighbours when the flags match.",
|
|
8
|
+
error: "A verified board was not found. Try another seed, an opening elsewhere, or fewer mines.",
|
|
9
|
+
},
|
|
10
|
+
ja: {
|
|
11
|
+
board: "地雷の盤", covered: "未開封", flag: "旗", question: "不明", empty: "空き", mine: "地雷", wrong: "間違った旗",
|
|
12
|
+
ready: "マスを開けて始めます。最初のマスは安全です。", playing: "数字を手がかりに、安全なマスをすべて開けましょう。", won: "安全なマスがすべて開きました。クリア!", lost: "地雷でした。同じ盤で再挑戦できます。",
|
|
13
|
+
checking: "推測なしで解ける盤を探しています…", restart: "やり直す", hint: "ヒント", reveal: "開く", mark: "印を付ける", mines: "残りの地雷", time: "時間", moves: "手数", clear: "開いたマス", view: "盤だけ", close: "閉じる", hintNone: "確実な手は見つかりませんでした。推測が必要かもしれません。", hintSafe: "このマスは安全です。", hintMine: "このマスには地雷があります。", count: "周囲の数字から確定できます。", overlap: "重なった手がかりを比べましょう。", total: "地雷の総数から確定できます。", enumeration: "可能な配置すべてで同じ答えになります。",
|
|
14
|
+
help: "タップで開く、右クリックか長押しで印を付けます。矢印キーで移動、Enterで開く、FかSpaceで印を付けます。旗の数が合う数字をタップすると周囲が開きます。",
|
|
15
|
+
error: "推測なしで解ける盤が見つかりませんでした。別のシード、別の開始マス、または少ない地雷で試してください。",
|
|
16
|
+
},
|
|
17
|
+
} as const;
|
|
18
|
+
export function words(language: Language = "en"): typeof STRINGS["en"] | typeof STRINGS["ja"] { return STRINGS[language]; }
|
package/src/style.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Scoped to one mounted board. Its host supplies the font and may override every colour. */
|
|
2
|
+
export const JIRAI_STYLE = `
|
|
3
|
+
.jr-root{--jr-ground:#a98954;--jr-cover:#fbf8f1;--jr-open:#efe8d8;--jr-line:#cfc6b2;--jr-ink:#1f2320;--jr-accent:#2f7a4f;color:var(--jr-ui-ink,var(--jr-ink));font:inherit;position:relative}
|
|
4
|
+
.jr-root[data-material=wood]{--jr-ground:#ae804a;--jr-cover:#e0bb7e;--jr-open:#c69d63;--jr-line:#936e40;--jr-ink:#352c20;background-image:repeating-linear-gradient(4deg,transparent 0 8px,#4b2b0c08 9px 10px)}
|
|
5
|
+
.jr-root[data-material=slate]{--jr-ground:#252e32;--jr-cover:#455359;--jr-open:#303c41;--jr-line:#62747a;--jr-ink:#f3f0e5;--jr-accent:#b7d298}
|
|
6
|
+
.jr-scroll{overflow:auto;padding:12px;border:1px solid var(--jr-line);border-radius:14px;background:var(--jr-ground);overscroll-behavior:contain}
|
|
7
|
+
.jr-board{position:relative;width:100%;min-width:240px;margin:auto;isolation:isolate}
|
|
8
|
+
.jr-cell{position:absolute;box-sizing:border-box;display:flex;align-items:center;justify-content:center;border:1px solid var(--jr-line);border-radius:4px;background:var(--jr-cover);color:var(--jr-ink);font:700 clamp(12px,2vw,22px)/1 ui-monospace,monospace;box-shadow:inset 0 2px 1px #fff7,inset 0 -2px 1px #0001;cursor:pointer;user-select:none;-webkit-user-select:none;touch-action:manipulation;padding:0}
|
|
9
|
+
.jr-root[data-grid=hex] .jr-cell{clip-path:polygon(50% 0,100% 25%,100% 75%,50% 100%,0 75%,0 25%);border-radius:0}
|
|
10
|
+
.jr-cell[data-kind=open]{background:var(--jr-open);box-shadow:none}
|
|
11
|
+
.jr-cell[data-number="1"]{color:#326ca0}.jr-cell[data-number="2"]{color:#3d763c}.jr-cell[data-number="3"]{color:#a34438}.jr-cell[data-number="4"]{color:#704b97}.jr-cell[data-number="5"]{color:#8c542f}.jr-cell[data-number="6"]{color:#1c777b}
|
|
12
|
+
.jr-root[data-material=slate] .jr-cell[data-kind=open]{color:#e8e6bd}
|
|
13
|
+
.jr-cell[data-kind=flag]{color:var(--jr-accent)}.jr-cell[data-kind=question]{color:#8a702d}
|
|
14
|
+
.jr-cell[data-kind=mine],.jr-cell[data-kind=wrong]{background:#ead4c5;color:#873e32}.jr-cell[data-kind=exploded]{background:#a34d3c;color:white}
|
|
15
|
+
.jr-cell[data-hint=true]{background:#d4e5bc!important;color:#243d24!important;box-shadow:inset 0 0 0 3px #577b3f}
|
|
16
|
+
.jr-cell:focus-visible{outline:none;box-shadow:inset 0 0 0 3px #c0802e;z-index:2}.jr-cell:hover{filter:brightness(.97)}
|
|
17
|
+
.jr-stats{display:flex;gap:18px;flex-wrap:wrap;font-variant-numeric:tabular-nums;margin:0 0 14px}.jr-stat{display:flex;gap:7px;align-items:baseline}.jr-stat small{opacity:.7;font-size:12px}.jr-stat strong{font-size:20px;font-weight:500}
|
|
18
|
+
.jr-status{font-size:14px;line-height:1.5;min-height:1.5em;margin:12px 0}.jr-help{font-size:12px;line-height:1.65;opacity:.7}
|
|
19
|
+
.jr-controls{display:flex;flex-wrap:wrap;gap:8px}.jr-button{font:inherit;font-size:13px;border:1px solid var(--jr-line);border-radius:999px;min-height:44px;padding:0 14px;background:var(--jr-ui-surface,var(--jr-cover));color:var(--jr-ui-ink,var(--jr-ink));border-color:var(--jr-ui-line,var(--jr-line));cursor:pointer}.jr-button:disabled{opacity:.5;cursor:wait}.jr-button[aria-pressed=true]{background:var(--jr-accent);color:white}.jr-button:focus-visible{outline:2px solid var(--jr-accent);outline-offset:3px}
|
|
20
|
+
.jr-dialog{border:0;border-radius:20px;padding:24px;max-width:min(96vw,1000px);width:850px;background:var(--surface,#fbf8f1);color:var(--ink,#1f2320);--jr-ui-ink:var(--ink,#1f2320);--jr-ui-surface:var(--surface,#fbf8f1);--jr-ui-line:var(--rule,#ddd6c6);box-sizing:border-box}.jr-dialog::backdrop{background:#172019bb}.jr-dialog .jr-board{max-height:75vh}.jr-close{float:right;margin-bottom:10px}
|
|
21
|
+
@media(prefers-reduced-motion:no-preference){.jr-cell{transition:background .12s}}
|
|
22
|
+
`;
|
package/src/ui.types.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Game, Language, Material, Pieces, Settings } from "./jirai.types.ts";
|
|
2
|
+
export type DrawOptions = { material?: Material; pieces?: Pieces; language?: Language; hint?: number | null };
|
|
3
|
+
export type CellModel = { cell: number; x: number; y: number; width: number; height: number; label: string; text: string; kind: string; hint: boolean };
|
|
4
|
+
export type BoardModel = { width: number; height: number; cells: CellModel[] };
|
|
5
|
+
export type MountOptions = DrawOptions & {
|
|
6
|
+
settings?: Settings;
|
|
7
|
+
progress?: string;
|
|
8
|
+
controls?: boolean;
|
|
9
|
+
onChange?: (game: Game) => void;
|
|
10
|
+
onFinish?: (game: Game) => void;
|
|
11
|
+
onError?: (error: Error) => void;
|
|
12
|
+
};
|
|
13
|
+
export type JiraiMount = {
|
|
14
|
+
game: () => Game;
|
|
15
|
+
progress: () => string;
|
|
16
|
+
play: (cell: number, mark?: boolean) => void;
|
|
17
|
+
hint: () => void;
|
|
18
|
+
restart: () => void;
|
|
19
|
+
load: (settings: Settings, progress?: string) => void;
|
|
20
|
+
set: (options: DrawOptions) => void;
|
|
21
|
+
destroy: () => void;
|
|
22
|
+
};
|
package/src/worker.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { makeBoard } from "./generate.ts";
|
|
2
|
+
import type { Settings } from "./jirai.types.ts";
|
|
3
|
+
// A separate entry: importing the engine or renderer never creates a worker.
|
|
4
|
+
self.addEventListener("message", (event: MessageEvent<{ settings: Settings; first: number }>) => {
|
|
5
|
+
try { self.postMessage({ board: makeBoard(event.data.settings, event.data.first) }); }
|
|
6
|
+
catch (error) { self.postMessage({ error: error instanceof Error ? error.message : String(error) }); }
|
|
7
|
+
});
|