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] 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;