From 4bd7bb5209c640a88ae1246b12a9626499fa9c61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Neto?= Date: Thu, 9 Apr 2026 21:06:31 +0100 Subject: [PATCH 1/3] Updates and corrections Added Situational SuperKo to Go Added Pie rule to Product Some bugs solved in Spora, improved initial phase Added no-moves flag to Xana and Spora --- locales/en/apgames.json | 15 ++++++- src/games/go.ts | 25 +++++++++--- src/games/product.ts | 2 +- src/games/spora.ts | 87 +++++++++++++++++++++++++++++------------ src/games/xana.ts | 2 +- 5 files changed, 96 insertions(+), 35 deletions(-) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 8ba050ed..a995f5bc 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -1344,6 +1344,14 @@ }, "size-37": { "name": "37x37 board" + }, + "#ruleset": { + "description": "Area counting; situational superko", + "name": "AGA Rules" + }, + "positional": { + "description": "Area counting; positional superko", + "name": "Positional Superko" } }, "gonnect": { @@ -3583,6 +3591,9 @@ }, "tumbleweed": "Score (pieces + influence)", "ESTIMATEDSCORES": "Estimated scores", + "spora" : { + "RESERVE": "Reserve:" + }, "TOPLACE": "Piece to place", "valley": "Moon Tokens", "waldmeister": { @@ -4677,7 +4688,7 @@ "PARTIAL": "Select one of the highlighted cells to move the glider. If a bar has appeared at the edge of the board, you can click it to move the glider off the board." }, "go": { - "INITIAL_SETUP": "Choose a number of points to add to the second player's score, and the next player will choose sides. This implementation uses Chinese area rules. Scores are based on the current area ownership. Super-Ko applies to prevent positional cycles. Players do need to capture all dead stones before ending the game with two consecutive passes.", + "INITIAL_SETUP": "Choose a number of points to add to the second player's score, and the next player will choose sides. This implementation uses Chinese area rules. Scores are based on the current area ownership. Super-Ko applies to prevent cycles. Players do need to capture all dead stones before ending the game with two consecutive passes.", "CYCLE" : "A previous position cannot be repeated; super-Ko applies.", "INSTRUCTIONS": "Select an intersection to place a piece.", "INVALID_KOMI": "You must choose an number in increments of 0.5 (like 4 or 4.5) to add to the second player's score.", @@ -5705,7 +5716,7 @@ "ENEMY_PIECE": "It is illegal to add pieces to an opponent stack.", "INVALID_KOMI": "You must choose an number in increments of 0.5 (like 4 or 4.5) to add to the second player's score.", "INVALID_PLAYSECOND": "You cannot choose to play second from this board state.", - "INVALID_SOW_PATH": "The selected path does not follow Spora rules: (a) pick (part of) a stack and leave one piece per orthogonal adjacent intersection, (b) sowing can turn left/right after each placed piece, (c) sowing a stack is only legal if the player can legally place all pieces of the stack, (d) a stack can never have more than four pieces.", + "INVALID_SOW_PATH": "The selected path does not follow Spora rules: (a) pick (part of) a stack and leave one piece per orthogonal adjacent intersection, (b) sowing can turn left/right after each placed piece, (c) sowing a stack is only legal if the player can legally place all pieces of the stack, (d) a stack can never have more than four pieces, (e) sowing onto intersections vacated by captures in the same turn is not allowed.", "KOMI_CHOICE": "You may either make the first move on the board and let your opponent keep the bonus points (an integer) or you may choose \"Play second\" and take the bonus points for yourself.", "MAXIMUM_STACK": "A stack can have, at most, four friendly pieces.", "NOT_ENOUGH_PIECES": "The pieces in reserve are not enough for this placement.", diff --git a/src/games/go.ts b/src/games/go.ts index 9471bc80..eda01f7c 100644 --- a/src/games/go.ts +++ b/src/games/go.ts @@ -69,6 +69,7 @@ export class GoGame extends GameBase { { uid: "size-21", group: "board" }, { uid: "size-25", group: "board" }, { uid: "size-37", group: "board" }, + { uid: "positional", group: "ruleset" }, ], categories: ["goal>area", "mechanic>place", "mechanic>capture", "mechanic>enclose", "board>shape>rect", "components>simple>1per"], flags: ["scores", "custom-buttons", "custom-colours"], @@ -97,6 +98,7 @@ export class GoGame extends GameBase { private boardSize = 19; private grid: RectGrid; + private ruleset: "default" | "positional"; constructor(state?: IGoState | string, variants?: string[]) { super(); @@ -134,6 +136,7 @@ export class GoGame extends GameBase { this.stack = [...state.stack]; } this.load(); + this.ruleset = this.getRuleset(); this.grid = new RectGrid(this.boardSize, this.boardSize); } @@ -181,6 +184,11 @@ export class GoGame extends GameBase { return 19; } + private getRuleset(): "default" | "positional" { + if (this.variants.includes("positional")) { return "positional"; } + return "default"; + } + public isKomiTurn(): boolean { return this.stack.length === 1; } @@ -237,13 +245,20 @@ export class GoGame extends GameBase { private numRepeats(): number { let num = 0; const sigCurr = this.signature(); - //const parityCurr = this.stack.length % 2 === 0 ? "even" : "odd"; + const parityCurr = this.stack.length % 2 === 0; + for (let i = 0; i < this.stack.length; i++) { - //const parity = i % 2 === 0 ? "even" : "odd"; const sig = this.signature(this.stack[i].board); - //if (sig === sigCurr && parity === parityCurr) { - if (sig === sigCurr) { - num++; + + if (this.ruleset === "positional") { // apply positional superko + if (sig === sigCurr) { + num++; + } + } else { // apply situational superko + const parity = i % 2 === 0; + if (sig === sigCurr && parity === parityCurr) { + num++; + } } } return num; diff --git a/src/games/product.ts b/src/games/product.ts index bb36fa1f..096577a1 100644 --- a/src/games/product.ts +++ b/src/games/product.ts @@ -53,7 +53,7 @@ export class ProductGame extends GameBase { { uid: "size-7", group: "board" }, { uid: "1-group", group: "ruleset" }, ], - flags: ["no-moves"] + flags: ["pie", "no-moves"] }; public numplayers = 2; diff --git a/src/games/spora.ts b/src/games/spora.ts index 60f747df..e5717b2d 100644 --- a/src/games/spora.ts +++ b/src/games/spora.ts @@ -2,7 +2,7 @@ import { GameBase, IAPGameState, IClickResult, ICustomButton, IIndividualState, import { APGamesInformation } from "../schemas/gameinfo"; import { APRenderRep, BoardBasic, MarkerDots, RowCol } from "@abstractplay/renderer/src/schemas/schema"; import { APMoveResult } from "../schemas/moveresults"; -import { replacer, reviver, UserFacingError, SquareOrthGraph } from "../common"; +import { replacer, reviver, SquareOrthGraph, UserFacingError } from "../common"; import { connectedComponents } from "graphology-components"; import pako, { Data } from "pako"; @@ -24,9 +24,10 @@ interface IMoveState extends IIndividualState { board: Map; lastmove?: string; scores: [number, number]; + reserve: [number, number]; + maxGroups: [number, number]; // relevant for the opening to define which groups are alive komi?: number; swapped: boolean; - reserve: [number, number]; } export interface ISporaState extends IAPGameState { @@ -69,7 +70,7 @@ export class SporaGame extends GameBase { ], categories: ["goal>area", "mechanic>place", "mechanic>move>sow", "mechanic>capture", "mechanic>stack", "mechanic>enclose", "board>shape>rect", "components>simple>2c"], - flags: ["scores", "custom-buttons", "custom-colours", "experimental"], + flags: ["scores", "no-moves", "custom-buttons", "custom-colours", "experimental"], }; public coords2algebraic(x: number, y: number): string { @@ -89,9 +90,10 @@ export class SporaGame extends GameBase { public results: Array = []; public variants: string[] = []; public scores: [number, number] = [0, 0]; + public reserve: [number, number] = [this.getReserveSize(), this.getReserveSize()]; // #pieces off-board + public maxGroups: [number, number] = [0, 0]; public komi?: number; public swapped = true; - public reserve: [number, number] = [this.getReserveSize(), this.getReserveSize()]; // number of pieces initially off-board private boardSize = 13; @@ -108,8 +110,9 @@ export class SporaGame extends GameBase { currplayer: 1, board: new Map(), scores: [0, 0], - swapped: true, reserve: [this.getReserveSize(), this.getReserveSize()], + maxGroups: [0, 0], + swapped: true, }; this.stack = [fresh]; } else { @@ -153,6 +156,7 @@ export class SporaGame extends GameBase { this.boardSize = this.getBoardSize(); this.scores = [...state.scores]; this.reserve = [...state.reserve]; + this.maxGroups = state.maxGroups === undefined ? [0,0] : [...state.maxGroups]; this.komi = state.komi; this.swapped = false; // We have to check the first state because we store the updated version in later states @@ -183,8 +187,8 @@ export class SporaGame extends GameBase { // lower and upper bounds for the amount of stones each player should have, then the arithmetic // mean of these two bounds give us the initial budget private getReserveSize() : number { - const a = 2*(this.getBoardSize() * this.getBoardSize())/3; - const b = (this.getBoardSize() * this.getBoardSize())/2; + const a = (this.getBoardSize() * this.getBoardSize())/2; + const b = 2*(this.getBoardSize() * this.getBoardSize())/3; return Math.ceil((a+b)/2); } @@ -274,21 +278,17 @@ export class SporaGame extends GameBase { return new SquareOrthGraph(this.boardSize, this.boardSize); } - private toXY(c: string): [number, number] { // TODO: change to algebraic2coords - const x = c.charCodeAt(0) - "a".charCodeAt(0); - const y = Number(c.slice(1)) - 1; - return [x, y]; - } - // check orthogonal adjacency private isOrthAdjacent(a: string, b: string): boolean { - const [x1, y1] = this.toXY(a); - const [x2, y2] = this.toXY(b); + const [x1, y1] = this.algebraic2coords(a); + const [x2, y2] = this.algebraic2coords(b); return Math.abs(x1 - x2) + Math.abs(y1 - y2) === 1; } // checks if the given path is legal according to Spora's rules - private isValidPath(start: string, remainingSowSize: number, path: string[]): boolean { + // we need (placedStack, n) because the player might sow over the just placed/increased stack + private isValidPath(placedStack: string, n: number, + start: string, remainingSowSize: number, path: string[]): boolean { if (path.length === 0) return true; // first step must be adjacent to start @@ -302,8 +302,8 @@ export class SporaGame extends GameBase { for (const cell of path) { if (! this.isOrthAdjacent(prev, cell)) { return false; } - const [x1, y1] = this.toXY(prev); - const [x2, y2] = this.toXY(cell); + const [x1, y1] = this.algebraic2coords(prev); //this.toXY(prev); // + const [x2, y2] = this.algebraic2coords(cell); //this.toXY(cell); // const dir: [number, number] = [x2 - x1, y2 - y1]; if (prevDir) { // check no 180° turn @@ -319,10 +319,20 @@ export class SporaGame extends GameBase { return false; // enemy stack is too big; this path is invalid } } - // also check if there's a friendly stack with size 4 (size 5 is illegal) - if ( this.board.has(cell) && this.board.get(cell)![0] === this.currplayer ) { + // also check if there's a friendly stack with size 4 (size 5 is illegal), + // unless the last piece is the start (ie, the sowing made a complete square) + if ( this.board.has(cell) && this.board.get(cell)![0] === this.currplayer && start !== cell ) { const size = this.board.get(cell)![1]; - if ( size === 4) { + if ( cell !== placedStack && size === 4 ) { + return false; // friendly stack is too big; this path is invalid + } + if ( cell === placedStack && size + n === 4 ) { + return false; // friendly stack is too big; this path is invalid + } + } + + if (! this.board.has(cell) ) { // the player might have placed an entire 4-stack + if ( cell === placedStack && n === 4 ) { return false; // friendly stack is too big; this path is invalid } } @@ -355,6 +365,12 @@ export class SporaGame extends GameBase { } const groupsOwned = connectedComponents(gOwned.graph); + // if there's only one group, and that's all there has ever been + // then this single group is, by definition, alive + if (groupsOwned.length === 1 && this.maxGroups[p - 1] <= 1) { + return []; + } + // check connecting paths // first generate a new graph with owned pcs and empties @@ -646,7 +662,7 @@ export class SporaGame extends GameBase { cellsPath = tokens[3].split('-'); } - if (! this.isValidPath(sowingStack, remainingSowSize, cellsPath) ) { + if (! this.isValidPath(initialCell, n, sowingStack, remainingSowSize, cellsPath) ) { result.valid = false; result.message = i18next.t("apgames:validation.spora.INVALID_SOW_PATH"); return result; @@ -660,8 +676,8 @@ export class SporaGame extends GameBase { } private doCaptures(): string[] { - const firstPly = this.swapped ? 6 : 5; - if ( this.stack.length <= firstPly ) return []; + //const firstPly = this.swapped ? 6 : 5; + //if ( this.stack.length <= firstPly ) return []; const result = []; const prevplayer = this.currplayer === 1 ? 2 : 1; @@ -676,6 +692,20 @@ export class SporaGame extends GameBase { return result; } + public updateGroupCounts(): void { + for (const p of [1, 2] as const) { + const owned = [...this.board.entries()].filter(e => e[1][0] === p).map(e => e[0]); + const gOwned = this.getGraph(); + for (const node of gOwned.graph.nodes()) { + if (! owned.includes(node)) { + gOwned.graph.dropNode(node); + } + } + const groups = connectedComponents(gOwned.graph); + this.maxGroups[p - 1] = Math.max(this.maxGroups[p - 1], groups.length); + } + } + public move(m: string, {partial = false, trusted = false} = {}): SporaGame { if (this.gameover) { throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:MOVES_GAMEOVER")); @@ -790,6 +820,9 @@ export class SporaGame extends GameBase { } } + if (this.stack.length > 3) { + this.updateGroupCounts(); + } if ( partial ) { return this; } this.lastmove = m; @@ -803,7 +836,7 @@ export class SporaGame extends GameBase { } protected checkEOG(): SporaGame { - if (this.stack.length <= 4) return this; // player must place at least one stack each + if (this.stack.length <= 4) return this; // players must place at least one stack each const p1Pieces = [...this.board.entries()].filter(e => e[1][0] === 1).map(e => e[0]); const p2Pieces = [...this.board.entries()].filter(e => e[1][0] === 2).map(e => e[0]); @@ -843,6 +876,7 @@ export class SporaGame extends GameBase { board: this.cloneBoard(), scores: [...this.scores], reserve: [...this.reserve], + maxGroups: [...this.maxGroups], komi: this.komi, swapped: this.swapped }; @@ -911,7 +945,8 @@ export class SporaGame extends GameBase { } } - if ( this.stack.length > 4 ) { // only show territories after the initial moves + // add territory dots + if (this.maxGroups[0] > 0 && this.maxGroups[1] > 0) { const territories = this.getTerritories(); const markers: Array = [] for (const t of territories) { diff --git a/src/games/xana.ts b/src/games/xana.ts index 3ee75edb..bc6fc746 100644 --- a/src/games/xana.ts +++ b/src/games/xana.ts @@ -77,7 +77,7 @@ export class XanaGame extends GameBase { ], categories: ["goal>area", "mechanic>place", "mechanic>move", "mechanic>stack", "mechanic>enclose", "board>shape>hex", "components>simple>3c"], - flags: ["pie", "custom-buttons", "custom-colours", "scores", "experimental"], + flags: ["pie", "no-moves", "custom-buttons", "custom-colours", "scores", "experimental"], }; public numplayers = 2; From 2f4be8e9171f74579c43fcfff18e52e00184b136 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Neto?= Date: Wed, 15 Apr 2026 19:35:40 +0100 Subject: [PATCH 2/3] Spora correction + Adding Pie rule to Product --- locales/en/apgames.json | 4 ++-- src/games/product.ts | 2 +- src/games/spora.ts | 32 +++++++++++++++++++++++++++----- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index a995f5bc..ccbc8375 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -1346,11 +1346,11 @@ "name": "37x37 board" }, "#ruleset": { - "description": "Area counting; situational superko", + "description": "Area scoring; situational superko", "name": "AGA Rules" }, "positional": { - "description": "Area counting; positional superko", + "description": "Area scoring; positional superko", "name": "Positional Superko" } }, diff --git a/src/games/product.ts b/src/games/product.ts index 096577a1..375933e6 100644 --- a/src/games/product.ts +++ b/src/games/product.ts @@ -451,7 +451,7 @@ export class ProductGame extends GameBase { if (this.gameover) { // tied scores is a P2 win - this.winner = this.getPlayerScore(1) > this.getPlayerScore(2) ? [1] : [2]; + this.winner = this.getPlayerScore(1) >= this.getPlayerScore(2) ? [1] : [2]; this.results.push( {type: "eog"}, {type: "winners", players: [...this.winner]} diff --git a/src/games/spora.ts b/src/games/spora.ts index e5717b2d..e3f96279 100644 --- a/src/games/spora.ts +++ b/src/games/spora.ts @@ -416,6 +416,27 @@ export class SporaGame extends GameBase { return this.findDead(this.currplayer, cloned).length === 0; } + // this method is called at the end, when the last player makes a sequence of placements + // in this case, after placing some stones, some captures might become legal, + // so we need to try placing piece by piece, and evaluate their validity + private validSequence(cells: string[]): boolean { + const prevplayer = this.currplayer === 1 ? 2 : 1; + // we'll simulate the process in a cloned board + const cloned = this.cloneBoard(); + for (const cell of cells) { + // place the stack (herein, its size is irrelevant) + cloned.set(cell, [this.currplayer, 1]); + // compute all enemy captures + const dead = this.findDead(prevplayer, cloned); + dead.forEach(cell => cloned.delete(cell)); + // if there are still friendly captures, the placement is illegal + if (this.findDead(this.currplayer, cloned).length > 0) { + return false; + } + } + return true; + } + // What pieces are orthogonally adjacent to a given area? public getAdjacentPieces(area: string[], pieces: string[]): string[] { // convert area strings to numeric coordinates @@ -546,11 +567,12 @@ export class SporaGame extends GameBase { result.message = i18next.t("apgames:validation.spora.MAXIMUM_STACK"); return result; } - if (! this.validPlacement(cell) ) { - result.valid = false; - result.message = i18next.t("apgames:validation.spora.SELF_CAPTURE"); - return result; - } + } + // check if the sequence of placements are able to make legal captures + if (! this.validSequence(cells) ) { + result.valid = false; + result.message = i18next.t("apgames:validation.spora.SELF_CAPTURE"); + return result; } result.valid = true; result.complete = this.reserve[this.currplayer - 1] > cells.length ? -1 : 1; // end when all pieces are on board From 68095fc7001b51158c69924216b8eecc767aaf02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Neto?= Date: Fri, 17 Apr 2026 21:52:31 +0100 Subject: [PATCH 3/3] Added Squirm --- locales/en/apgames.json | 22 +- src/games/index.ts | 9 +- src/games/squirm.ts | 459 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 487 insertions(+), 3 deletions(-) create mode 100644 src/games/squirm.ts diff --git a/locales/en/apgames.json b/locales/en/apgames.json index ccbc8375..362daabd 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -192,6 +192,7 @@ "spora" : "A territorial game with limited pieces to build stacks that capture by enclosure and/or by sowing.", "spree": "An impartial n-in-a-row game where players take turns handing a ball to their opponent to place. The goal is to try to make a full line of one of two colours (red and blue by default). There is a third wild neutral colour (green by default) that can play the role of either colour.", "squaredance": "A game where you move groups of pieces by rotation, trying to force your opponent into a position where they cannot move.", + "squirm" : "Players need to create the largest possible snake-like group.", "stairs": "Stack higher than your opponent in this game of one-space, one-level jumps where you also have to move your lowest pieces first. To take the lead and win, you must surpass your opponent's tallest stack height or, failing that, their number of stacks at the tallest height.", "stawvs": "A set collection game for 2-4 players that's similar to Amazons, with old-style Volcano scoring. On a checkerboard randomly filled with individual pyramids, player pieces can move and capture pyramids in a straight line (orthogonally or diagonally), if and only if all intervening spaces and the target space contain pyramids but not pieces.", "stibro": "Win by forming a loop anywhere on the board, while adhering to the placement restriction that ensures loop-forming is always possible.", @@ -2468,6 +2469,20 @@ "name": "5x5 board" } }, + "squirm": { + "#board": { + "name": "Hexagonal board (bases 3-5)" + }, + "size-4": { + "name": "Hexagonal board (bases 4-6)" + }, + "size-5": { + "name": "Hexagonal board (bases 5-7)" + }, + "size-6": { + "name": "Hexagonal board (bases 6-8)" + } + }, "stawvs": { "#setup": { "description": "In the default setup, the corners of the board are empty.", @@ -5743,6 +5758,11 @@ "PARTIAL_DESTINATION": "Select the point where the end stone will end up. You may not cover friendly pieces, and any covered enemy pieces will be captured.", "PARTIAL_ENDSTONE": "Select the end stone. This is the stone that will move the furthest." }, + "squirm": { + "INITIAL_INSTRUCTIONS": "Drop a friendly stone on an empty cell. Only serpent-like groups are valid. A serpent is a group where each stone has, at most, two friendly neighbors; and there are no three friendly stones in line.", + "INVALID_3ROW": "The stone cannot make a three in-a-row.", + "INVALID_NEIGH": "The stone cannot have more than two friendly neighbors." + }, "stairs": { "NORMAL_MOVE": "Choose a piece to move.", "INVALID_MOVE": "You must move a piece to a neighboring stack of the same height. You must move one of your lowest movable pieces.", @@ -6178,7 +6198,7 @@ "TOO_MANY_PIECES": "There are too many pieces at {{where}} to add {{num}} more." }, "xana": { - "INITIAL_INSTRUCTIONS": "Drop pieces from reserve on empty cells or friendly stacks. In alternative, move a stack: click it then click on an empty cell within moving range. Afterwards, and optionally, one or two walls can be placed on any empty accessible cells (closed adversary areas are not accessible).", + "INITIAL_INSTRUCTIONS": "Drop pieces from reserve on empty cells or friendly stacks. Alternatively, move a stack: click it then click on an empty cell within moving range. Afterwards, and optionally, one or two walls can be placed on any empty accessible cells (closed adversary areas are not accessible).", "DROP_MOVE_INSTRUCTIONS" : "Click again on the stack to place a new stone from the reserve. Otherwise, click N times on an empty cell (within moving range) to move N pieces.", "ENEMY_PIECE" : "Cannot change an enemy piece.", "MOVE_NOT_INSIDE_CIRCLE": "Stack cannot be moved outside its circle, ie, its moving range.", diff --git a/src/games/index.ts b/src/games/index.ts index 43c34eeb..4ef8642e 100644 --- a/src/games/index.ts +++ b/src/games/index.ts @@ -236,6 +236,7 @@ import { MinefieldGame, IMinefieldState } from "./minefield"; import { SentinelGame, ISentinelState } from "./sentinel"; import { XanaGame, IXanaState } from "./xana"; import { SporaGame, ISporaState } from "./spora"; +import { SquirmGame, ISquirmState } from "./squirm"; export { APGamesInformation, GameBase, GameBaseSimultaneous, IAPGameState, @@ -475,6 +476,7 @@ export { SentinelGame, ISentinelState, XanaGame, IXanaState, SporaGame, ISporaState, + SquirmGame, ISquirmState, }; const games = new Map(); // Manually add each game to the following array [ @@ -593,7 +595,8 @@ const games = new Map { if (games.has(g.gameinfo.uid)) { throw new Error("Another game with the UID '" + g.gameinfo.uid + "' has already been used. Duplicates are not allowed."); @@ -1077,6 +1080,8 @@ export const GameFactory = (game: string, ...args: any[]): GameBase|GameBaseSimu return new XanaGame(...args); case "spora": return new SporaGame(...args); + case "squirm": + return new SquirmGame(...args); } return; } diff --git a/src/games/squirm.ts b/src/games/squirm.ts new file mode 100644 index 00000000..cec1d386 --- /dev/null +++ b/src/games/squirm.ts @@ -0,0 +1,459 @@ +import { GameBase, IAPGameState, IClickResult, ICustomButton, IIndividualState, IScores, IValidationResult } from "./_base"; +import { APGamesInformation } from "../schemas/gameinfo"; +import { APRenderRep } from "@abstractplay/renderer/src/schemas/schema"; +import { APMoveResult } from "../schemas/moveresults"; +import { HexTriGraph, reviver, UserFacingError } from "../common"; +import type { HexDir } from "../common/graphs/hextri"; +import i18next from "i18next"; + +export type playerid = 1|2; + +export interface IMoveState extends IIndividualState { + currplayer: playerid; + board: Map; + lastmove?: string; +}; + +export interface ISquirmState extends IAPGameState { + winner: playerid[]; + stack: Array; +}; + +export class SquirmGame extends GameBase { + public static readonly gameinfo: APGamesInformation = { + name: "Squirm", + uid: "squirm", + playercounts: [2], + version: "20260417", + dateAdded: "2026-04-17", + // i18next.t("apgames:descriptions.squirm") + description: "apgames:descriptions.squirm", + urls: ["https://jpneto.github.io/world_abstract_games/squirm.htm"], + people: [ + { + type: "designer", + name: "Bryce Wilcox", + }, + { + type: "coder", + name: "João Pedro Neto", + urls: ["https://boardgamegeek.com/boardgamedesigner/3829/joao-pedro-neto"], + apid: "9228bccd-a1bd-452b-b94f-d05380e6638f", + }, + ], + categories: ["goal>score>eog", "mechanic>place", "board>shape>hex", "components>simple>1per"], + variants: [ + { uid: "#board", }, + { uid: "size-4", group: "board" }, + { uid: "size-5", group: "board" }, + { uid: "size-6", group: "board" }, + ], + flags: ["pie", "custom-buttons", "experimental"] + }; + + public numplayers = 2; + public currplayer: playerid = 1; + public board!: Map; + public graph: HexTriGraph = new HexTriGraph(3, 7, true); + public gameover = false; + public winner: playerid[] = []; + public variants: string[] = []; + public stack!: Array; + public results: Array = []; + public boardSize = 3; + + constructor(state?: ISquirmState | string, variants?: string[]) { + super(); + if (state === undefined) { + if (variants !== undefined) { + this.variants = [...variants]; + } + const fresh: IMoveState = { + _version: SquirmGame.gameinfo.version, + _results: [], + _timestamp: new Date(), + currplayer: 1, + board: new Map(), + }; + this.stack = [fresh]; + } else { + if (typeof state === "string") { + state = JSON.parse(state, reviver) as ISquirmState; + } + if (state.game !== SquirmGame.gameinfo.uid) { + throw new Error(`The Squirm engine cannot process a game of '${state.game}'.`); + } + this.gameover = state.gameover; + this.winner = [...state.winner]; + this.variants = state.variants; + this.stack = [...state.stack]; + } + this.load(); + } + + public load(idx = -1): SquirmGame { + if (idx < 0) { + idx += this.stack.length; + } + if ( (idx < 0) || (idx >= this.stack.length) ) { + throw new Error("Could not load the requested state from the stack."); + } + + const state = this.stack[idx]; + this.currplayer = state.currplayer; + this.board = new Map(state.board); + this.lastmove = state.lastmove; + this.boardSize = this.getBoardSize(); + this.buildGraph(); + return this; + } + + private getBoardSize(): number { + // Get board size from variants. + if ( (this.variants !== undefined) && (this.variants.length > 0) && (this.variants[0] !== undefined) && (this.variants[0].length > 0) ) { + const sizeVariants = this.variants.filter(v => v.includes("size")); + if (sizeVariants.length > 0) { + const size = sizeVariants[0].match(/\d+/); + return parseInt(size![0], 10); + } + if (isNaN(this.boardSize)) { + throw new Error(`Could not determine the board size from variant "${this.variants[0]}"`); + } + } + return 3; + } + + private getGraph(): HexTriGraph { + return new HexTriGraph(this.boardSize, 2*this.boardSize+1, true); + } + + private buildGraph(): SquirmGame { + this.graph = this.getGraph(); + return this; + } + + // Get all groups of pieces for `player`, sorted by decreasing size + private getGroupSizes(player: playerid): number[] { + const groups: Set[] = []; + const pieces = [...this.board.entries()].filter(e => e[1] === player).map(e => e[0]); + const seen: Set = new Set(); + for (const piece of pieces) { + if (seen.has(piece)) { + continue; + } + const group: Set = new Set(); + const todo: string[] = [piece]; + while (todo.length > 0) { + const cell = todo.pop()!; + if (seen.has(cell)) { + continue; + } + group.add(cell); + seen.add(cell); + const neighbours = this.graph.neighbours(cell); + for (const n of neighbours) { + if (pieces.includes(n)) { + todo.push(n); + } + } + } + groups.push(group); + } + return groups.map(g => g.size).sort((a, b) => b - a); + } + + // checks if a given player has a 'len' in-a-row + public checkLines(len: number, player: playerid): boolean { + const collate = (cells: string[], dir: HexDir): string[] => { + const localLines: string[] = []; + for (const cell of cells) { + const [cx, cy] = this.graph.algebraic2coords(cell); + const ray = this.graph.ray(cx, cy, dir, true) + .map(c => this.graph.coords2algebraic(...c)) + .map(n => this.board.has(n) ? this.board.get(n)! : "-") + .join(""); + localLines.push(ray); + } + return localLines; + } + + const g = new HexTriGraph(this.boardSize, 2*this.boardSize+1, true); + const lines: string[] = []; + const edges = g.getEdges(); + + lines.push(...collate([...new Set([...edges.get("SW")!, ...edges.get("S")!]).values()], "NE")); + lines.push(...collate([...new Set([...edges.get("SW")!, ...edges.get("NW")!]).values()], "E")); + lines.push(...collate([...new Set([...edges.get("NW")!, ...edges.get("N")!]).values()], "SE")); + + const target = Array.from({length: len}, () => player).join(""); + for (const line of lines) { + if (line.includes(target)) { + return true; + } + } + return false; + } + + private hasThrees(player: playerid): boolean { + return this.checkLines(3, player); + } + + // get all adjacent cells with friendly pieces + private friendlyNeighbors(cell: string): string[] { + return (this.graph.neighbours(cell) as string[]) + .filter(c => this.board.has(c) && this.board.get(c)! === this.currplayer); + } + + // check if this stone creates a non-serpent group + private allSerpents(cell: string): boolean { + // each stone of a serpent can only have one or two adjacent friendly pieces + for (const neigh of this.graph.neighbours(cell)) { + // only interested about cells with friendly stones: + if ( !this.board.has(neigh) || this.board.get(neigh)! !== this.currplayer ) { continue; } + if ( this.friendlyNeighbors(neigh).length > 2 ) { + return false; + } + } + return this.friendlyNeighbors(cell).length <= 2; + } + + public moves(): string[] { + if (this.gameover) { return []; } + const moves: string[] = ["pass"]; + + for (const cell of this.graph.listCells(false) as string[]) { + if ( this.board.has(cell) ) { continue; } + // place the stone to check if conditions are valid + this.board.set(cell, this.currplayer); + // serpents also cannot have three adjacent stones in a line + if (this.allSerpents(cell) && !this.hasThrees(this.currplayer) ) { + moves.push(cell); + } + // remove the stone after check + this.board.delete(cell); + } + return moves.sort((a,b) => a.localeCompare(b)); + } + + public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { + try { + const newmove = this.graph.coords2algebraic(col, row); + const result = this.validateMove(newmove) as IClickResult; + result.move = result.valid ? newmove : ""; + return result; + } catch (e) { + return { + move, + valid: false, + message: i18next.t("apgames:validation._general.GENERIC", {move, row, col, piece, emessage: (e as Error).message}) + } + } + } + + public validateMove(m: string): IValidationResult { + const result: IValidationResult = + {valid: false, message: i18next.t("apgames:validation._general.DEFAULT_HANDLER")}; + + m = m.toLowerCase(); + m = m.replace(/\s+/g, ""); + + if (m.length === 0) { + result.valid = true; + result.complete = -1; + result.message = i18next.t("apgames:validation.squirm.INITIAL_INSTRUCTIONS") + return result; + } + + if (m === "pass") { + result.valid = true; + result.complete = 1; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + } + + const allMoves = this.moves(); + if (! allMoves.includes(m)) { + result.valid = false; + this.board.set(m, this.currplayer); + if ( this.hasThrees(this.currplayer) ) { + result.message = i18next.t("apgames:validation.squirm.INVALID_3ROW"); + } else { + result.message = i18next.t("apgames:validation.squirm.INVALID_NEIGH"); + } + this.board.delete(m); + return result; + } + + // Looks good + result.valid = true; + result.complete = 1; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + } + + public move(m: string, {trusted = false} = {}): SquirmGame { + if (this.gameover) { + throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:MOVES_GAMEOVER")); + } + + m = m.toLowerCase(); + m = m.replace(/\s+/g, ""); + if (! trusted) { + const result = this.validateMove(m); + if (! result.valid) { + throw new UserFacingError("VALIDATION_GENERAL", result.message) + } + if (! this.moves().includes(m)) { + throw new UserFacingError("VALIDATION_FAILSAFE", i18next.t("apgames:validation._general.FAILSAFE", {move: m})) + } + } + + this.results = []; + + if (m === "pass") { + this.results.push({type: "pass"}); + } else { + this.board.set(m, this.currplayer); + this.results.push({ type: "place", where: m }); + } + + this.lastmove = m; + this.currplayer = this.currplayer % 2 + 1 as playerid; + this.checkEOG(); + this.saveState(); + return this; + } + + // compare two lists using lexicographic order (+1 if a>b, -1 if a b[i]) return 1; + } + // all equal so far, so shorter one is "smaller" + if (a.length < b.length) return -1; + if (a.length > b.length) return 1; + return 0; + } + + protected checkEOG(): SquirmGame { + // game ends if two consecutive passes occurred + this.gameover = this.lastmove === "pass" && + this.stack[this.stack.length - 1].lastmove === "pass"; + + if (this.gameover) { + const result = this.compare(this.getGroupSizes(1), this.getGroupSizes(2)); + if (result === 0) { + this.winner = [1, 2]; + } else { + this.winner = result > 0 ? [1] : [2]; + } + this.results.push( + {type: "eog"}, + {type: "winners", players: [...this.winner]} + ); + } + return this; + } + + public state(): ISquirmState { + return { + game: SquirmGame.gameinfo.uid, + numplayers: this.numplayers, + variants: this.variants, + gameover: this.gameover, + winner: [...this.winner], + stack: [...this.stack] + }; + } + + public moveState(): IMoveState { + return { + _version: SquirmGame.gameinfo.version, + _results: [...this.results], + _timestamp: new Date(), + currplayer: this.currplayer, + lastmove: this.lastmove, + board: new Map(this.board), + }; + } + + public render(): APRenderRep { + // Build piece string + let pstr = ""; + for (const row of this.graph.listCells(true) as string[][]) { + if (pstr.length > 0) { + pstr += "\n"; + } + pstr += row.map(c => this.board.has(c) ? this.board.get(c)! === 1 ? "A" : "B" : "-").join(""); + } + + // Build rep + const rep: APRenderRep = { + board: { + style: "hex-of-hex", + minWidth: this.boardSize, + maxWidth: 2*this.boardSize + 1, + alternatingSymmetry: true, + }, + legend: { + A: { name: "piece", colour: 1 }, + B: { name: "piece", colour: 2 } + }, + pieces: pstr + }; + + // Add annotations + if (this.stack[this.stack.length - 1]._results.length > 0) { + rep.annotations = []; + for (const move of this.stack[this.stack.length - 1]._results) { + if (move.type === "place") { + const [x, y] = this.graph.algebraic2coords(move.where!); + rep.annotations.push({type: "enter", targets: [{row: y, col: x}]}); + } + } + } + return rep; + } + + public getButtons(): ICustomButton[] { + return [{ label: "pass", move: "pass" }]; + } + + // First element = integer part + // Remaining elements = decimal part, each padded to 2 digits + private encode(list: number[]): number { + if (list.length === 0) return 0; + const [head, ...tail] = list; + const decimal = tail.map(n => n.toString().padStart(2, '0')).join(''); + return Number(`${head}.${decimal}`); + } + + public getPlayerScore(player: playerid): number { + return this.encode(this.getGroupSizes(player)); + } + + public sidebarScores(): IScores[] { + return [ + { name: i18next.t("apgames:status.SCORES"), + scores: [this.getGroupSizes(1).join(","), + this.getGroupSizes(2).join(",")] } + ] + } + + public chat(node: string[], player: string, results: APMoveResult[], r: APMoveResult): boolean { + let resolved = false; + switch (r.type) { + case "place": + node.push(i18next.t("apresults:PLACE.nowhat", {player, where: r.where})); + resolved = true; + break; + } + return resolved; + } + + public clone(): SquirmGame { + return new SquirmGame(this.serialize()); + } +} \ No newline at end of file