From 73c4edd6c8e323da4b1b8e064d7426b15dd72f5d Mon Sep 17 00:00:00 2001 From: Francisco Vico Date: Wed, 14 Oct 2026 00:50:14 +0200 Subject: [PATCH] Selfo: no colour starts one move away from winning --- src/games/selfo.ts | 16 ++++++++++------ test/games/selfo.test.ts | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/games/selfo.ts b/src/games/selfo.ts index 6ec06fba3..e7fe24fae 100644 --- a/src/games/selfo.ts +++ b/src/games/selfo.ts @@ -122,6 +122,12 @@ const after = (board: Board, from: string, to: string): Board => { return next; }; +/** Whether `player` could connect all their pieces with a single move (if it were their turn). */ +const canWinInOne = (graph: HexTriGraph, board: Board, player: playerid): boolean => + piecesOf(board, player).some(from => + graph.neighbours(from).some(to => !board.has(to) && isConnected(graph, after(board, from, to), player)) + ); + /** Whether the move would enclose an opposing piece without winning the game outright. */ const wouldEnclose = (graph: HexTriGraph, board: Board, from: string, to: string): boolean => { const player = board.get(from)!; @@ -291,7 +297,7 @@ export class SelfoGame extends GameBase { /** * Random starting position: both colours scattered with no group larger * than the board radius (and never one big enough to be connected), no - * piece already enclosed, and no immediate win for the first player (and, + * piece already enclosed, and no colour one move away from winning (and, * with `no-enclosure`, at least one legal move for both). */ private randomStart(): Board { @@ -307,11 +313,9 @@ export class SelfoGame extends GameBase { for (const c of first) { board.set(c, 1); } for (const c of second) { board.set(c, 2); } if (hasEnclosedPiece(this.graph, board, 1) || hasEnclosedPiece(this.graph, board, 2)) { continue; } - // the first player must not be able to win on their first move - const wins = piecesOf(board, 1).some(from => - this.graph.neighbours(from).some(to => !board.has(to) && isConnected(this.graph, after(board, from, to), 1)) - ); - if (wins) { continue; } + // neither colour may be one move away from winning (the second player + // could otherwise win at once, or take that colour with the pie rule) + if (canWinInOne(this.graph, board, 1) || canWinInOne(this.graph, board, 2)) { continue; } fallback = board; if (!this.noEnclosure) { return board; } if (legalMoves(this.graph, board, 1, true).length > 0 && legalMoves(this.graph, board, 2, true).length > 0) { diff --git a/test/games/selfo.test.ts b/test/games/selfo.test.ts index 14430ff61..bffc30590 100644 --- a/test/games/selfo.test.ts +++ b/test/games/selfo.test.ts @@ -29,6 +29,14 @@ const connected = (cells: string[], g: HexTriGraph = graph): boolean => { return seen.size === set.size; }; +// Whether `player` could connect all their pieces with a single move, if it were their turn. +const canWinInOne = (board: Map, player: playerid, g: HexTriGraph): boolean => { + const own = [...board.entries()].filter(([, p]) => p === player).map(([c]) => c); + return own.some(from => g.neighbours(from).some(to => + !board.has(to) && connected(own.map(c => c === from ? to : c), g) + )); +}; + const corners = (graph.listCells() as string[]).filter(c => graph.neighbours(c).length === 3); describe("Selfo", () => { @@ -59,6 +67,31 @@ describe("Selfo", () => { }); } + it("never starts with a colour one move away from winning", () => { + // the small board is where this used to happen (about 2% of the starts) + for (const [variants, radius] of [[["r2f5"], 2], [["r2f5", "no-enclosure"], 2], [[], 3]] as [string[], number][]) { + const g = new HexTriGraph(radius + 1, radius * 2 + 1); + for (let i = 0; i < 200; i++) { + const game = new SelfoGame(undefined, variants); + expect(canWinInOne(game.board, 1, g), `black, ${variants.join(",")}`).to.be.false; + expect(canWinInOne(game.board, 2, g), `white, ${variants.join(",")}`).to.be.false; + } + } + }); + + it("detects a colour that is one move away from winning", () => { + // c3 + n0 + t: t can step next to c3 and complete the group (see "moves and winning") + const nbrs = graph.neighbours("c3").sort(); + const n0 = nbrs[0]; + const n1 = nbrs.find(n => n !== n0 && !graph.neighbours(n0).includes(n))!; + const t = graph.neighbours(n1).sort().find(c => c !== "c3" && !nbrs.includes(c))!; + const board = new Map([["c3", 1], [n0, 1], [t, 1]]); + expect(canWinInOne(board, 1, graph)).to.be.true; + // two pieces at distance 3 or more cannot be joined by a single step + const far = corners.find(c => c !== corners[0] && graph.path(corners[0], c)!.length > 3)!; + expect(canWinInOne(new Map([[corners[0], 1], [far, 1]]), 1, graph)).to.be.false; + }); + it("records the starting position", () => { const game = new SelfoGame(undefined, ["r2f5"]); const [first, second] = game.getStartingPosition().split("|");