Skip to content

Commit 99381cb

Browse files
committed
Minefield: Revamp canPlace and add unit test
1 parent ab7165f commit 99381cb

6 files changed

Lines changed: 381 additions & 81 deletions

File tree

locales/en/apgames.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1688,6 +1688,10 @@
16881688
},
16891689
"size-15": {
16901690
"name": "15x15 board"
1691+
},
1692+
"cartwheel": {
1693+
"name": "Cartwheel",
1694+
"description": "A variant by Luis Bolaños Mures that tweaks the forbidden glyphs."
16911695
}
16921696
},
16931697
"mixtour": {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"scripts": {
88
"build": "npm run json2ts && npm run build-ts && npm run lint",
99
"build-ts": "tsc && npm pack",
10-
"test0": "mocha -r ts-node/register test/games/wunchunk.test.ts",
10+
"test0": "mocha -r ts-node/register test/games/minefield.test.ts",
1111
"test": "mocha -r ts-node/register test/**/*.test.ts",
1212
"lint": "npx eslint .",
1313
"dist-dev": "rimraf dist && webpack",

playground/playground.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ const nanoid = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklm
33

44
function boardClick(row, col, piece) {
55
console.log("Row: " + row + ", Col: " + col + ", Piece: " + piece);
6-
var state = window.sessionStorage.getItem("state");
7-
var gamename = window.sessionStorage.getItem("gamename");
6+
var state = window.localStorage.getItem("state");
7+
var gamename = window.localStorage.getItem("gamename");
88
var game = APGames.GameFactory(gamename, state);
99
if (game.gameover) {
1010
return;

src/common/plotting.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,3 +230,92 @@ export const linesIntersect = (p1: IPoint, q1: IPoint, p2: IPoint, q2: IPoint):
230230

231231
return false; // Doesn't fall in any of the above cases
232232
}
233+
234+
export type Delta = { dx: number; dy: number; payload: unknown };
235+
236+
/**
237+
* Generate all unique rotations/reflections of a delta-shape.
238+
* - Rotates/flips (dx, dy)
239+
* - Retains `pc` for each corresponding point
240+
*
241+
* Returns: Delta[][] where each inner list is one transformed variant.
242+
*/
243+
export function allRotationsAndReflections(
244+
deltas: Delta[],
245+
options?: {
246+
includeReflections?: boolean; // default true
247+
normalize?: "none" | "minToOrigin" // default "none"
248+
}
249+
): Delta[][] {
250+
const includeReflections = options?.includeReflections ?? true;
251+
const normalize = options?.normalize ?? "none";
252+
253+
// ---- D4 transforms on the (dx,dy) portion only; pc is retained
254+
const rot90 = (d: Delta): Delta => ({ dx: -d.dy, dy: d.dx, payload: d.payload });
255+
const rot180 = (d: Delta): Delta => ({ dx: -d.dx, dy: -d.dy, payload: d.payload });
256+
const rot270 = (d: Delta): Delta => ({ dx: d.dy, dy: -d.dx, payload: d.payload });
257+
258+
// Reflect across Y-axis (mirror left-right). pc retained.
259+
const reflectY = (d: Delta): Delta => ({ dx: -d.dx, dy: d.dy, payload: d.payload });
260+
261+
const rotations: Array<(d: Delta) => Delta> = [
262+
(d) => ({ ...d }),
263+
rot90,
264+
rot180,
265+
rot270,
266+
];
267+
268+
// ---- Optional normalization: shift so min dx/min dy become 0
269+
const normalizeDeltas = (ds: Delta[]): Delta[] => {
270+
if (normalize === "none") return ds;
271+
272+
let minDx = Infinity;
273+
let minDy = Infinity;
274+
for (const { dx, dy } of ds) {
275+
if (dx < minDx) minDx = dx;
276+
if (dy < minDy) minDy = dy;
277+
}
278+
return ds.map(({ dx, dy, payload }) => ({ dx: dx - minDx, dy: dy - minDy, payload }));
279+
};
280+
281+
// ---- Stable key for dedupe (includes pc to avoid collapsing different labeled shapes)
282+
// Sort by dx, dy, pc so that order in input doesn't matter.
283+
const keyOf = (ds: Delta[]): string => {
284+
const sorted = [...ds].sort((a, b) =>
285+
(a.dx - b.dx) || (a.dy - b.dy)
286+
);
287+
return sorted.map(d => `${d.dx},${d.dy},${d.payload}`).join("|");
288+
};
289+
290+
const variants: Delta[][] = [];
291+
const seen = new Set<string>();
292+
293+
const pushIfNew = (ds: Delta[]) => {
294+
const normalized = normalizeDeltas(ds);
295+
const k = keyOf(normalized);
296+
if (!seen.has(k)) {
297+
seen.add(k);
298+
// Return each variant in stable sorted order as well
299+
variants.push(
300+
[...normalized].sort((a, b) =>
301+
(a.dx - b.dx) || (a.dy - b.dy)
302+
)
303+
);
304+
}
305+
};
306+
307+
// 4 rotations of original
308+
for (const r of rotations) {
309+
pushIfNew(deltas.map(r));
310+
}
311+
312+
// 4 rotations of reflected (if enabled)
313+
if (includeReflections) {
314+
const reflected = deltas.map(reflectY);
315+
for (const r of rotations) {
316+
pushIfNew(reflected.map(r));
317+
}
318+
}
319+
320+
return variants;
321+
}

src/games/minefield.ts

Lines changed: 99 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import { GameBase, IAPGameState, IClickResult, IIndividualState, IValidationResu
22
import { APGamesInformation } from "../schemas/gameinfo";
33
import { APRenderRep, MarkerEdge } from "@abstractplay/renderer/src/schemas/schema";
44
import { APMoveResult } from "../schemas/moveresults";
5-
import { Direction, RectGrid, reviver, UserFacingError } from "../common";
5+
import { RectGrid, reviver, UserFacingError } from "../common";
66
import { UndirectedGraph } from "graphology";
77
import { bidirectional } from "graphology-shortest-path/unweighted";
8+
import { type Delta, allRotationsAndReflections } from "../common/plotting";
89
import i18next from "i18next";
910

1011
export type playerid = 1|2;
@@ -35,6 +36,7 @@ export class MinefieldGame extends GameBase {
3536
urls: [
3637
"https://www.marksteeregames.com/Minefield_rules.pdf",
3738
"https://boardgamegeek.com/thread/3295906/new-mark-steere-game-minefield",
39+
"https://boardgamegeek.com/thread/3299199/cartwheel-possibly-free-of-mutual-zugzwang",
3840
],
3941
people: [
4042
{
@@ -52,6 +54,8 @@ export class MinefieldGame extends GameBase {
5254
],
5355
variants: [
5456
{ uid: "size-15", group: "board" },
57+
{ uid: "#board", },
58+
{ uid: "cartwheel", group: "rules" },
5559
],
5660
categories: ["goal>connect", "mechanic>place", "board>shape>rect", "board>connect>rect", "components>simple>1per"],
5761
flags: ["pie", "automove", "experimental"]
@@ -157,86 +161,103 @@ export class MinefieldGame extends GameBase {
157161
return 19;
158162
}
159163

160-
public canPlace(cell: string, player: playerid): boolean {
161-
const [x,y] = this.algebraic2coords(cell);
162-
const g = new RectGrid(this.boardSize, this.boardSize);
164+
public get forbidden(): Delta[][] {
165+
const lst: Delta[][] = [];
163166
// hard corners
164-
for (const diag of ["NE", "SE", "SW", "NW"] as const) {
165-
let next: string|[number,number] = RectGrid.move(x, y, diag);
166-
if (g.inBounds(...next)) {
167-
next = this.coords2algebraic(...next);
168-
}
169-
// white pieces in the diagram
170-
if (!Array.isArray(next) && this.board.get(next) === player) {
171-
const [ldir, rdir] = diag.split("");
172-
let left: string|[number,number] = RectGrid.move(x, y, ldir as Direction);
173-
let lplayer: number|undefined;
174-
if (g.inBounds(...left)) {
175-
left = this.coords2algebraic(...left);
176-
lplayer = this.board.get(left);
177-
}
178-
let right: string|[number,number] = RectGrid.move(x, y, rdir as Direction);
179-
let rplayer: number|undefined;
180-
if (g.inBounds(...right)) {
181-
right = this.coords2algebraic(...right);
182-
rplayer = this.board.get(right);
183-
}
184-
if (lplayer === undefined && rplayer !== undefined && rplayer !== player) {
185-
return false;
186-
}
187-
if (rplayer === undefined && lplayer !== undefined && lplayer !== player) {
188-
return false;
189-
}
190-
}
191-
// black piece in the diagram
192-
else if (!Array.isArray(next) && !this.board.has(next)) {
193-
const [ldir, rdir] = diag.split("");
194-
let left: undefined|string|[number,number] = RectGrid.move(x, y, ldir as Direction);
195-
let lplayer: number|undefined;
196-
if (g.inBounds(...left)) {
197-
left = this.coords2algebraic(...left);
198-
lplayer = this.board.get(left);
199-
}
200-
let right: string|[number,number] = RectGrid.move(x, y, rdir as Direction);
201-
let rplayer: number|undefined;
202-
if (g.inBounds(...right)) {
203-
right = this.coords2algebraic(...right);
204-
rplayer = this.board.get(right);
205-
}
206-
if (lplayer !== undefined && lplayer !== player &&
207-
rplayer !== undefined && rplayer !== player) {
208-
return false;
209-
}
210-
}
211-
}
167+
lst.push([
168+
{dx: 1, dy: 0, payload: null},
169+
{dx: 1, dy: 1, payload: "f"},
170+
{dx: 0, dy: 1, payload: "e"},
171+
]);
172+
lst.push([
173+
{dx: 0, dy: -1, payload: "e"},
174+
{dx: 1, dy: -1, payload: null},
175+
{dx: 1, dy: 0, payload: "e"},
176+
]);
212177
// switches
213-
for (const orth of ["N", "S", "E", "W"] as const) {
214-
const perps = (orth === "N" || orth === "S") ? ["E", "W"] : ["N", "S"];
215-
for (const dist of [2, 3]) {
216-
let ray = g.ray(x, y, orth).map(c => this.coords2algebraic(...c));
217-
if (ray.length >= dist) {
218-
ray = [cell, ...ray.slice(0, dist)];
219-
for (const perp of perps) {
220-
let adj: string|[number,number] = RectGrid.move(x, y, perp as Direction);
221-
let adjRay: undefined|string[];
222-
if (g.inBounds(...adj)) {
223-
adjRay = [this.coords2algebraic(...adj), ...g.ray(...adj, orth).map(c => this.coords2algebraic(...c)).slice(0, dist)];
224-
adj = this.coords2algebraic(...adj);
225-
}
226-
if (adjRay !== undefined) {
227-
// at this point I have two adjacent rays of the appropriate length
228-
// populate each with player numbers and then compare (0 is empty)
229-
const pRay = ray.map(c => this.board.get(c) ?? 0).join("");
230-
const adjPRay = adjRay.map(c => this.board.get(c) ?? 0).join("");
231-
// check player ray first
232-
if (/^0+[1|2]$/.test(pRay) && !pRay.endsWith(player.toString())) {
233-
// now check adjacent ray
234-
if (/^[1|2]0+[1|2]$/.test(adjPRay) && !adjPRay.startsWith(player.toString()) && adjPRay.endsWith(player.toString())) {
235-
return false;
236-
}
237-
}
238-
}
178+
// dist 2
179+
lst.push([
180+
{dx: 1, dy: 0, payload: "e"},
181+
{dx: 1, dy: 1, payload: null},
182+
{dx: 1, dy: 2, payload: "f"},
183+
{dx: 0, dy: 2, payload: "e"},
184+
{dx: 0, dy: 1, payload: null},
185+
]);
186+
// dist 3
187+
if (!this.variants.includes("cartwheel")) {
188+
lst.push([
189+
{dx: 1, dy: 0, payload: "e"},
190+
{dx: 1, dy: 1, payload: null},
191+
{dx: 1, dy: 2, payload: null},
192+
{dx: 1, dy: 3, payload: "f"},
193+
{dx: 0, dy: 3, payload: "e"},
194+
{dx: 0, dy: 2, payload: null},
195+
{dx: 0, dy: 1, payload: null},
196+
]);
197+
}
198+
if (this.variants.includes("cartwheel")) {
199+
// pinwheel
200+
lst.push([
201+
{dx: 1, dy: 0, payload: "e"},
202+
{dx: 2, dy: 1, payload: "f"},
203+
{dx: 2, dy: 2, payload: "e"},
204+
{dx: 1, dy: 3, payload: "f"},
205+
{dx: 0, dy: 3, payload: "e"},
206+
{dx: -1, dy: 2, payload: "f"},
207+
{dx: -1, dy: 1, payload: "e"},
208+
{dx: 0, dy: 1, payload: null},
209+
{dx: 1, dy: 1, payload: null},
210+
{dx: 1, dy: 2, payload: null},
211+
{dx: 0, dy: 2, payload: null},
212+
]);
213+
// cartwheel
214+
lst.push([
215+
{dx: 1, dy: 0, payload: "f"},
216+
{dx: 2, dy: 1, payload: "e"},
217+
{dx: 2, dy: 2, payload: "e"},
218+
{dx: 1, dy: 3, payload: "f"},
219+
{dx: 0, dy: 3, payload: "f"},
220+
{dx: -1, dy: 2, payload: "e"},
221+
{dx: -1, dy: 1, payload: "e"},
222+
{dx: 0, dy: 1, payload: null},
223+
{dx: 1, dy: 1, payload: null},
224+
{dx: 1, dy: 2, payload: null},
225+
{dx: 0, dy: 2, payload: null},
226+
]);
227+
}
228+
229+
return lst;
230+
}
231+
232+
public canPlace(cell: string, player: playerid): boolean {
233+
const [x, y] = this.algebraic2coords(cell);
234+
for (const glyph of this.forbidden) {
235+
for (const transform of allRotationsAndReflections(glyph)) {
236+
let match = true;
237+
for (const delta of transform) {
238+
const nx = x + delta.dx;
239+
const ny = y + delta.dy;
240+
if (nx < 0 || nx >= this.boardSize || ny < 0 || ny >= this.boardSize) {
241+
match = false;
242+
break;
239243
}
244+
const check = this.coords2algebraic(nx, ny);
245+
const contents = this.board.get(check);
246+
if (delta.payload === "f" && contents !== player) {
247+
match = false;
248+
break;
249+
}
250+
if (delta.payload === "e" && (contents === undefined || contents === player)) {
251+
match = false;
252+
break;
253+
}
254+
if (delta.payload === null && contents !== undefined) {
255+
match = false;
256+
break;
257+
}
258+
}
259+
if (match) {
260+
return false;
240261
}
241262
}
242263
}

0 commit comments

Comments
 (0)