From 63f58b641225d8931c5bcca40b638d86917bf880 Mon Sep 17 00:00:00 2001 From: Francisco Vico Date: Wed, 14 Oct 2026 12:41:07 +0200 Subject: [PATCH 1/2] Selfo: link the Wikipedia article --- src/games/selfo.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/games/selfo.ts b/src/games/selfo.ts index e7fe24fae..3af304bca 100644 --- a/src/games/selfo.ts +++ b/src/games/selfo.ts @@ -221,7 +221,7 @@ export class SelfoGame extends GameBase { "A connection game on a hexagonal board. Each player starts with the same number of pieces scattered at random, and on your turn you slide one piece to an adjacent empty cell. The first player to bring all of their own pieces together into a single group wins.", notes: "The starting position is random: both players' pieces are scattered over the board, with no piece already enclosed, no player already connected, and no immediate win for the first player.\n\nOn your turn, move one of your pieces to an adjacent empty cell. You win by moving so that all of your pieces form one group of mutually adjacent pieces; only the player who has just moved can win this way. A player who has no legal move loses.\n\nA piece is _enclosed_ when your pieces cannot all reach each other through cells not occupied by your opponent, because opposing pieces and the board edge wall some of them off. If, after a move, both players have enclosed pieces, neither can ever connect and the game is a draw. With the **No enclosure** variant, a move that would enclose an opposing piece is not allowed, unless that move wins the game outright.\n\nThe second player may use the pie rule to swap sides after the first move.", - urls: [], + urls: ["https://en.wikipedia.org/wiki/Selfo"], people: [ { type: "designer", From 4704fe368ced105d741dbf780dd0149f2185ff15 Mon Sep 17 00:00:00 2001 From: Francisco Vico Date: Wed, 14 Oct 2026 13:29:01 +0200 Subject: [PATCH 2/2] Selfo: show the selected piece and the cells it can reach --- src/games/selfo.ts | 37 +++++++++++++++++++-- test/games/selfo.test.ts | 72 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/src/games/selfo.ts b/src/games/selfo.ts index 3af304bca..c4ee2640e 100644 --- a/src/games/selfo.ts +++ b/src/games/selfo.ts @@ -1,6 +1,6 @@ import { GameBase, IAPGameState, IClickResult, IIndividualState, IValidationResult, type ChatLogCollectContext, type ChatLogLine } from "./_base.js"; import type { APGamesInformation } from "../schemas/gameinfo.js"; -import { APRenderRep } from "@abstractplay/renderer/build/schemas/schema"; +import { APRenderRep, RowCol } from "@abstractplay/renderer/build/schemas/schema"; import type { APMoveResult } from "../schemas/moveresults.js"; import { HexTriGraph, reviver, UserFacingError, cloneState } from "../common/index.js"; import i18next from "i18next"; @@ -252,6 +252,9 @@ export class SelfoGame extends GameBase { private pieces = 9; private noEnclosure = false; private graph!: HexTriGraph; + /** Piece being moved and the cells it can reach: only set while a move is half entered (see move()). */ + private selected: string | undefined; + private dots: string[] = []; constructor(state?: ISelfoState | string, variants?: string[]) { super(); @@ -411,6 +414,7 @@ export class SelfoGame extends GameBase { if (m.length === 0) { result.valid = true; result.complete = -1; + result.canrender = true; result.message = i18next.t(this.isPieTurn() ? "apgames:validation.selfo.INITIAL_INSTRUCTIONS_PIE" : "apgames:validation.selfo.INITIAL_INSTRUCTIONS"); @@ -466,6 +470,7 @@ export class SelfoGame extends GameBase { if (to === undefined || to === "") { result.valid = true; result.complete = -1; + result.canrender = true; result.message = i18next.t("apgames:validation.selfo.PARTIAL"); return result; } @@ -503,7 +508,7 @@ export class SelfoGame extends GameBase { return result; } - public move(m: string, {trusted = false} = {}): SelfoGame { + public move(m: string, {trusted = false, partial = false} = {}): SelfoGame { if (this.gameover) { throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:validation._general.MOVES_GAMEOVER")); } @@ -516,11 +521,23 @@ export class SelfoGame extends GameBase { if (! result.valid) { throw new UserFacingError("VALIDATION_GENERAL", result.message) } - if (! this.moves().includes(m)) { + if (!partial && ! this.moves().includes(m)) { throw new UserFacingError("VALIDATION_FAILSAFE", i18next.t("apgames:validation._general.FAILSAFE", {move: m})) } } + this.selected = undefined; + this.dots = []; + // A move that is only half entered: just mark the selected piece and where it + // can go, without playing anything (the front end previews it on a copy). + if (partial && (m === "" || this.graph.graph.hasNode(m))) { + if (m !== "") { + this.selected = m; + this.dots = this.moves().filter(mv => mv.startsWith(`${m}-`)).map(mv => mv.split("-")[1]); + } + return this; + } + this.results = []; if (m === "pie") { // The second player takes the first player's side: every piece changes @@ -651,6 +668,20 @@ export class SelfoGame extends GameBase { } } + // A half-entered move: ring the selected piece and dot the cells it can reach + if (this.selected !== undefined) { + rep.annotations ??= []; + const [sx, sy] = this.graph.algebraic2coords(this.selected); + rep.annotations.push({type: "enter", targets: [{row: sy, col: sx}], shape: "circle"}); + if (this.dots.length > 0) { + const targets: RowCol[] = this.dots.map(cell => { + const [x, y] = this.graph.algebraic2coords(cell); + return {row: y, col: x}; + }); + rep.annotations.push({type: "dots", targets: targets as [RowCol, ...RowCol[]]}); + } + } + return rep; } diff --git a/test/games/selfo.test.ts b/test/games/selfo.test.ts index bffc30590..b13e987a5 100644 --- a/test/games/selfo.test.ts +++ b/test/games/selfo.test.ts @@ -191,6 +191,78 @@ describe("Selfo", () => { }); }); + describe("half-entered moves", () => { + type Mark = {type: string; targets: {row: number; col: number}[]}; + const marks = (game: SelfoGame, type: string): Mark | undefined => + game.render().annotations?.find(a => a.type === type) as unknown as Mark | undefined; + const rc = (cell: string): {row: number; col: number} => { + const [x, y] = graph.algebraic2coords(cell); + return {row: y, col: x}; + }; + + for (const variants of [["r2f5"], ["r2f5", "no-enclosure"]]) { + it(`rings the selected piece and dots the cells it can reach (${variants.join(",")})`, () => { + const game = setup(variants, ["c3", "a1"], ["e1", "e3"]); + game.move("c3", {partial: true}); + const reach = game.moves().filter(m => m.startsWith("c3-")).map(m => rc(m.split("-")[1])); + expect(reach.length).to.be.greaterThan(0); + expect(marks(game, "dots")!.targets).to.have.deep.members(reach); + expect(marks(game, "enter")!.targets).to.deep.equal([rc("c3")]); + }); + } + + it("tells the front end that a half-entered move can be rendered", () => { + const game = setup(["r2f5"], ["c3", "a1"], ["e1", "e3"]); + const [fx, fy] = graph.algebraic2coords("c3"); + const click = game.handleClick("", fy, fx); + expect(click.valid).to.be.true; + expect(click.complete).to.equal(-1); + expect(click.canrender).to.be.true; + expect(game.validateMove("c3").canrender).to.be.true; + expect(game.validateMove("").canrender).to.be.true; + expect(game.validateMove("e1").canrender).to.be.undefined; // not your piece + }); + + it("does not play anything", () => { + const game = setup(["r2f5"], ["c3", "a1"], ["e1", "e3"]); + const board = [...game.board.entries()]; + const stack = game.stack.length; + game.move("c3", {partial: true}); + expect(game.currplayer).to.equal(1); + expect(game.stack.length).to.equal(stack); + expect([...game.board.entries()]).to.deep.equal(board); + expect(game.gameover).to.be.false; + }); + + it("marks nothing for an empty move, and the marks go away once a move is played", () => { + const game = setup(["r2f5"], ["c3", "a1"], ["e1", "e3"]); + game.move("", {partial: true}); + expect(marks(game, "dots")).to.be.undefined; + expect(marks(game, "enter")).to.be.undefined; + game.move("c3", {partial: true}); + expect(marks(game, "dots")).to.not.be.undefined; + const free = graph.neighbours("c3").find(c => !game.board.has(c))!; + game.move(`c3-${free}`); + expect(marks(game, "dots")).to.be.undefined; + expect(marks(game, "enter")).to.be.undefined; + expect(game.currplayer).to.equal(2); + }); + + it("still plays a complete move that is flagged as partial", () => { + const game = setup(["r2f5"], ["c3", "a1"], ["e1", "e3"]); + const free = graph.neighbours("c3").find(c => !game.board.has(c))!; + game.move(`c3-${free}`, {partial: true}); + expect(game.board.get(free)).to.equal(1); + expect(game.board.has("c3")).to.be.false; + expect(game.currplayer).to.equal(2); + }); + + it("rejects an invalid selection even when partial", () => { + const game = setup(["r2f5"], ["c3", "a1"], ["e1", "e3"]); + expect(() => game.move("e1", {partial: true})).to.throw(); + }); + }); + describe("pie rule", () => { // a first move that does not end the game const firstMove = (game: SelfoGame): string => game.moves().find(m => !game.clone().move(m).gameover)!;