From 7302ee88fea0707e9173933cd471f15299d6d140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Neto?= Date: Wed, 6 May 2026 09:14:48 +0100 Subject: [PATCH] Corrected Pie problem in Xana --- locales/en/apgames.json | 7 +++-- package-lock.json | 17 +---------- src/games/product.ts | 1 + src/games/shapechess.ts | 6 ++++ src/games/twinflames.ts | 3 +- src/games/xana.ts | 63 +++++++++++++++++++++++++++++++++++------ 6 files changed, 69 insertions(+), 28 deletions(-) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index b102c05d..e117e89f 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -224,7 +224,7 @@ "trike": "Trike is a game on a triangular board. Players take turn moving a neutral pawn around the board (passing is not allowed). When a player moves the pawn, they place a checker of their own color, onto the destination point. When the pawn is trapped, the game is over. At the end of the game, each player gets a point for each checker of their own color adjacent to, or underneath, the pawn. The person with the highest score wins.", "tritium": "Two players take turns creating and growing regions and claiming them. Once the board is full, the player who controls most tiles wins.", "tumbleweed": "Tumbleweed is a game of line of sight. Towers are built, and cells are controlled by the player with the most towers that can see that cell. Compete to control the majority of the hexagonal board.", - "twinflames": "Players score by multiplying their two largest groups. Largest score wins. If tied, the second player wins. Uses a random setup to place some walls.", + "twinflames": "Players score by multiplying their two largest groups. Largest score wins. If tied, the second player wins. The game uses a random setup to place a fixed number of walls.", "twixt": "Twixt is a connection game where players alternate turns placing pegs and links on a pegboard in an attempt to link their opposite sides.", "unlur": "An asymmetric connection game where one player tries to form a Y and the other tries to connect opposite sides. Achieving the opponent's goal without achieving your own is a loss. The game starts with a contract phase where both players place the colour of the Y player until someone passes, and then the opponent becomes the Line player.", "upperhand": "A shedding game where you try to be the first to place all your pieces. If a platform is created and at least three of the pieces are of a player's colour, an additional piece of that colour is automatically placed on top of it, causing chain reactions. The pie rule applies.", @@ -309,7 +309,8 @@ "twixt": "The notation is based on Hansel notation at . Some modifications are that link removal specifically specifies the link direction, and commas separate the moves. To add/remove links, click on the pegs between them. You can also remove a link by clicking on the line itself.", "waldmeister": "Players play two games sequentially. In the first round, Player 1 is playing for colours and Player 2 is playing for heights. At the end of the first round, scores are tabulated and scoring groups highlighted. Then in the second round, Player 2 plays first and plays for colours, and Player 1 plays for heights.", "witch": "The first player does not start as owning any pieces and may remove any piece (other than a crown) on their first turn. The second player chooses their colour on their first turn, after which, removing your opponent's pieces is no longer possible.", - "xana": "Players, on their turns, drop/move a stack (there's a limited amount of stackable pieces) and optionally drop two walls into empty hexes. Stacks without liberties are captured. A stack has liberty if at least one of the adjacent hexes is empty (there is no concept of group of stacks). A hex is accessible if it is empty and connected to a friendly stack by a path of empty hexes. The goal is to build the highest score of territory plus captures. Xana was designed in 2005." + "xana": "Players, on their turns, drop/move a stack (there's a limited amount of stackable pieces) and optionally drop two walls into empty hexes. Stacks without liberties are captured. A stack has liberty if at least one of the adjacent hexes is empty (there is no concept of group of stacks). A hex is accessible if it is empty and connected to a friendly stack by a path of empty hexes. The goal is to build the highest score of territory plus captures. Xana was designed in 2005.", + "y": "The game was designed by Charles Titus and Craige Schensted (Ea Ea) in 1953. A direct descended of Hex, the game was originally played on a triangular board, where the goal is to connect all three sides by a chain of friendly stones. Y is considered a more fundamental game than Hex, given its simpler rules and given that a Hex match can be setup on a sufficient large Y board. In their 1975 book **Mudcrack Y & Poly-Y**, the designers introduced the _mudcrack principle_ stating that connection games can be played on a wide range of progressively elaborate boards. One of those designs ('the bent Y', with three pentagonal cells) became the commercial Y board, instead of the standard triangular one." }, "variants": { "abande": { @@ -6399,11 +6400,13 @@ "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.", + "INVALID_PLAYSECOND": "You cannot choose to swap positions from this board state.", "MOVE_NOT_INSIDE_CIRCLE": "Stack cannot be moved outside its circle (i.e., its moving range).", "MOVE_TO_OCCUPIED_CELL": "Pieces from a stack must be moved to an empty cell.", "NOT_ENOUGH_PIECES_TO_MOVE": "The stack does not have that many pieces to be moved.", "NOT_PLACED_ON_FRIEND": "Pieces cannot be moved from opposing stacks.", "OCCUPIED_WALL": "Walls must be placed on empty cells (does not include cells from pieces just captured).", + "PIE_CHOICE": "Either play to be the second player, or click the Pie button to swap colors and be the first player. Your adversary will get 0.5 points for offering the pie.", "RESERVE_EMPTY": "Reserve empty; no more placements possible.", "SAME_WALL" : "Cannot place the same wall twice.", "UNACCESSIBLE_PIECE": "Cannot place/move pieces on inaccessible cells.", diff --git a/package-lock.json b/package-lock.json index fe826adb..cfa5b9d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4113,7 +4113,6 @@ "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4178,7 +4177,6 @@ "integrity": "sha512-B2MdzyWxCE2+SqiZHAjPphft+/2x2FlO9YBx7eKE1BCb+rqBlQdhtAEhzIEdozHd55DXPmxBdpMygFJjfjjA9A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.32.0", "@typescript-eslint/types": "8.32.0", @@ -4608,7 +4606,6 @@ "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4667,7 +4664,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5366,7 +5362,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001716", "electron-to-chromium": "^1.5.149", @@ -7186,7 +7181,6 @@ "integrity": "sha512-Hx0MOjPh6uK9oq9nVsATZKE/Wlbai7KFjfCuw9UHaguDW3x+HF0O5nIi3ud39TWgrTjTO5nHxmL3R1eANinWHQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -7825,7 +7819,6 @@ "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -9008,8 +9001,7 @@ "version": "0.24.8", "resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz", "integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/graphology-utils": { "version": "2.5.2", @@ -10576,7 +10568,6 @@ "integrity": "sha512-ek8NRg/OPvS9ISOJNWNAz5vZcpYacWNFDWNJjj5OXsc6YuKacfey6wF04cXz/tOJIVrZ2nGSkHpAY5qKtF6ISg==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "d": "^1.0.2", "duration": "^0.2.2", @@ -13956,7 +13947,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -14423,7 +14413,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14754,7 +14743,6 @@ "integrity": "sha512-lQ3CPiSTpfOnrEGeXDwoq5hIGzSjmwD72GdfVzF7CQAI7t47rJG9eDWvcEkEn3CUQymAElVvDg3YNTlCYj+qUQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", @@ -14803,7 +14791,6 @@ "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -15149,7 +15136,6 @@ "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8.3.0" }, @@ -15381,7 +15367,6 @@ "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/games/product.ts b/src/games/product.ts index b643fe5e..54e3d246 100644 --- a/src/games/product.ts +++ b/src/games/product.ts @@ -37,6 +37,7 @@ export class ProductGame extends GameBase { type: "designer", name: "Nick Bentley", urls: ["https://boardgamegeek.com/boardgamedesigner/7958/nick-bentley"], + apid: "52077877-93bb-4fff-9e5f-f1c41ac8e866", }, { type: "coder", diff --git a/src/games/shapechess.ts b/src/games/shapechess.ts index c8e2b4df..d20ae1f7 100644 --- a/src/games/shapechess.ts +++ b/src/games/shapechess.ts @@ -209,6 +209,7 @@ export class ShapeChessGame extends GameBase { } } + /* The original ruleset states that the symmetric adversary groups should wait for the adversary to make an action. I'm commenting the code in case I refactor back in the future // now, do the same for the opponent's groups that might be formed due to currplayer's actions const prevplayer = clone.currplayer % 2 + 1 as playerid; for (const group of clone.getGroups(prevplayer)) { @@ -222,6 +223,7 @@ export class ShapeChessGame extends GameBase { } } } + */ } // for (actions) return [hadCaptures, clone]; } @@ -468,6 +470,7 @@ export class ShapeChessGame extends GameBase { } } + /* see note about this above, at method willLastActionCapture() // now, do the same for the opponent's groups that might be formed due to currplayer's actions const prevplayer = this.currplayer % 2 + 1 as playerid; for(const group of this.getGroups(prevplayer)) { @@ -482,6 +485,7 @@ export class ShapeChessGame extends GameBase { } } } + */ } // for (actions) const moves = actions.at(-1)!.split("-"); @@ -522,6 +526,7 @@ export class ShapeChessGame extends GameBase { } } + /* const prevplayer = this.currplayer % 2 + 1 as playerid; for(const group of this.getGroups(prevplayer)) { const symmetry = this.computeSymmetry(group); @@ -535,6 +540,7 @@ export class ShapeChessGame extends GameBase { } } } + */ if (partial) { return this; } diff --git a/src/games/twinflames.ts b/src/games/twinflames.ts index e9e01939..7783f24f 100644 --- a/src/games/twinflames.ts +++ b/src/games/twinflames.ts @@ -30,13 +30,14 @@ export class TwinFlamesGame extends GameBase { description: "apgames:descriptions.twinflames", notes: "apgames:notes.twinflames", urls: [ - "https://docs.google.com/document/d/1oLiUll3GKfy1kt9wWIWi3--WrAMZ24x3DMdljPxIQIs/edit?tab=t.0#heading=h.gg6hnnlkcc3o" // TODO: update new link + "https://boardgamegeek.com/boardgame/470021/twin-flames", ], people: [ { type: "designer", name: "Nick Bentley", urls: ["https://boardgamegeek.com/boardgamedesigner/7958/nick-bentley"], + apid: "52077877-93bb-4fff-9e5f-f1c41ac8e866", }, { type: "coder", diff --git a/src/games/xana.ts b/src/games/xana.ts index 18ba00cf..7ac49189 100644 --- a/src/games/xana.ts +++ b/src/games/xana.ts @@ -25,6 +25,7 @@ export interface IMoveState extends IIndividualState { scores: [number, number]; prisoners: [number, number]; reserve: [number, number]; + swapped: boolean; }; export interface IXanaState extends IAPGameState { @@ -89,6 +90,7 @@ export class XanaGame extends GameBase { public stack!: Array; public results: Array = []; public graph: HexTriGraph = new HexTriGraph(BOARD_SIZE, 2*BOARD_SIZE-1); + public swapped = true; private scores: [number, number] = [0, 0]; private prisoners: [number, number] = [0, 0]; // number of enemy pieces (not stacks) captured @@ -110,6 +112,7 @@ export class XanaGame extends GameBase { scores: [0, 0], prisoners: [0, 0], reserve: [RESERVE_SIZE, RESERVE_SIZE], + swapped: true, }; this.stack = [fresh]; } else { @@ -144,11 +147,22 @@ export class XanaGame extends GameBase { this.prisoners = [...state.prisoners]; this.reserve = [...state.reserve]; this.graph = new HexTriGraph(BOARD_SIZE, 2*BOARD_SIZE-1); + this.swapped = false; + // We have to check the first state because we store the updated version in later states + if (state.swapped === undefined) { + this.swapped = this.stack.length < 3 || this.stack[2].lastmove !== "swap"; + } else { + this.swapped = state.swapped; + } return this; } /////////////// helper functions /////////////// + public isPieTurn(): boolean { + return this.stack.length === 2; + } + // all the cells accessible to the pieces of a given player private accessibleCells(player: playerid): string[] { const pieces = [...this.board.entries()].filter(e => e[1][0] === player) @@ -339,7 +353,24 @@ export class XanaGame extends GameBase { result.valid = true; result.complete = -1; result.canrender = true; - result.message = i18next.t("apgames:validation.xana.INITIAL_INSTRUCTIONS"); + if ( this.isPieTurn() ) { + result.message = i18next.t("apgames:validation.xana.PIE_CHOICE"); + } else { + result.message = i18next.t("apgames:validation.xana.INITIAL_INSTRUCTIONS"); + } + return result; + } + + if (m === "swap") { + if ( this.isPieTurn() ) { + result.valid = true; + result.complete = 1; + result.canrender = true; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + } else { + result.valid = false; + result.message = i18next.t("apgames:validation.xana.INVALID_PLAYSECOND"); + } return result; } @@ -491,6 +522,15 @@ export class XanaGame extends GameBase { if (m === "pass") { this.results.push({type: "pass"}); + } else if (m === "swap") { // pie was accepted + this.swapped = true; + this.board.forEach((v, k) => { + if (v[0] !== 3) { // if it's not a wall, swap colors + this.board.set(k, [v[0] === 1 ? 2 : 1, v[1]]); + } + }) + this.reserve = [this.reserve[1], this.reserve[0]]; + this.results.push({ type: "pie" }); } else { const commands: string[] = m.split(','); const initialCell: string = m.split(/[<>]/)[0]; @@ -564,7 +604,7 @@ export class XanaGame extends GameBase { this.stack[this.stack.length - 1].lastmove === "pass"; // if no shared accessible cells, the game is over, since all areas ownership are decided - if (this.stack.length > 3 && !this.gameover) { + if (this.stack.length > 4 && !this.gameover) { const p1cells: string[] = this.accessibleCells(1); const p2cells: Set = new Set(this.accessibleCells(2)); const shareCells: string[] = p1cells.filter(c => p2cells.has(c)); @@ -585,13 +625,12 @@ export class XanaGame extends GameBase { } public getPlayerScore(player: playerid): number { - let nPrisoners = this.prisoners[player - 1]; - if (player === 1) { - nPrisoners += 0.5; // P2 decides the pie, so P1 receives an additional 0.5 points - } + const nPrisoners = this.prisoners[player - 1]; + const komi = player === 1 ? 0.5 : 0.0; + return this.getTerritories() .filter(t => t.owner === player) - .reduce((prev, curr) => prev + curr.cells.length, nPrisoners); + .reduce((prev, curr) => prev + curr.cells.length, nPrisoners+komi); } // What pieces are adjacent to a given area? @@ -669,6 +708,7 @@ export class XanaGame extends GameBase { scores: [...this.scores], prisoners: [...this.prisoners], reserve: [...this.reserve], + swapped: this.swapped }; } @@ -708,7 +748,7 @@ export class XanaGame extends GameBase { legend: { A: { name: "piece", colour: this.getPlayerColour(1) }, B: { name: "piece", colour: this.getPlayerColour(2) }, - C: { name: "piece", colour: wallColour }, // color 1 is red + C: { name: "piece", colour: wallColour }, }, pieces: pieces.map(r => r.join(",")).join("\n"), }; @@ -744,7 +784,7 @@ export class XanaGame extends GameBase { } // add territorial dots for area controlled by players - if (this.stack.length > 2) { + if (this.stack.length > 3) { const territories = this.getTerritories(); const markers: Array = [] for (const t of territories) { @@ -764,10 +804,15 @@ export class XanaGame extends GameBase { } public getButtons(): ICustomButton[] { + if ( this.isPieTurn() ) { + return [{ label: "swap", move: "swap" }]; + } return [{ label: "pass", move: "pass" }]; } public getPlayerColour(p: playerid): Colourfuncs { + p = (p == 1 && !this.swapped) || (p == 2 && this.swapped) ? 1 : 2; + if (p === 1) { return { func: "custom",