From d53df5e399359ba34dd26a67fc49bed7c2a642ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Neto?= Date: Wed, 1 Apr 2026 14:17:41 +0100 Subject: [PATCH 1/2] Added Sentinel --- locales/en/apgames.json | 15 ++ src/games/index.ts | 8 +- src/games/sentinel.ts | 534 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 555 insertions(+), 2 deletions(-) create mode 100644 src/games/sentinel.ts diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 063c2c37..e39b620a 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -179,6 +179,7 @@ "rootbound": "In Root Bound, two groups of ‘plants’ spread in lifelike ways and vie for control of territory as space for new growth rapidly dwindles.", "saltire": "Connect your two opposite sides of the board by placing stones and swapping pairs of orthogonally or diagonally adjacent stones. A swap must decrease either the number of crosscuts or the number of supercuts on the board, and it must not increase the number of crosscuts.", "scaffold": "Scaffold is a drawless connection game played on the intersections of a square grid using stones (as in Go). Black is trying to connect N-S edges of the board, White E-W with an orthogonally connected group. On your turn, place a stone of your color on an empty point. Then, if possible, place a stone of your color on an empty point that is orthogonally adjacent to two groups of your color, and keep making such placements until no more are possible.", + "sentinel": "A vigil game using a sowing mechanism with stacks. A vigil game is one where some board position must always be on line-of-sight with some or all of the player's pieces. In Sentinel, players are required to see the board center at the end of each one of their turns.", "shifty": "A connection game where you either place a piece orthogonally adjacent to a friendly piece, or move one of your pieces orthogonally adjacent to a friendly piece, by a chess queen's move, to a location not orthogonally adjacent to any other friendly pieces. Crosscuts are illegal. The player that forms an orthogonal or diagonal path connecting their two sides wins.", "siegeofj": "Place cards, representing armies, to exert pressure on segments of the wall around the city of Jacynth. Exert the most pressure on the most segments to win.", "slither": "Square-board connection game with updated rules from 2018. Optionally move a piece and then place a piece, such that no two pieces of a colour are diagonally adjacent to each other unless they are orthogonally connected by a like-coloured stone. In Advanced Slither, a piece may only move if it is part of a contiguous orthogonal group consisting of pieces of both colours.", @@ -2320,6 +2321,11 @@ "name": "Larger 25x25 board" } }, + "sentinel": { + "#board": { + "name": "9x9 board" + } + }, "shifty": { "#board": { "name": "9x9 board" @@ -5546,6 +5552,15 @@ "INITIAL_INSTRUCTIONS": "Select a point to place a piece.", "INVALID_MOVES": "This sequence of moves {{moves}} is invalid." }, + "sentinel": { + "INITIAL_INSTRUCTIONS": "Move a piece forward or sow a stack on any direction. The center cannot be crossed over. Captures are by replacement.", + "EDGE_INSTRUCTIONS": "Move the piece one step forward (orthogonally or diagonally). The piece can be moved out of the board by clicking at the center.", + "PIECE_INSTRUCTIONS": "Move the piece one step forward (orthogonally or diagonally).", + "STACK_INSTRUCTIONS": "Sow the stack in any direction provided there's space to place all of its pieces (plus one). It is illegal to create un-sowable stacks. Captures are by replacement.", + "INVALID_CENTER": "It is illegal to move or sow over the board center!", + "NO_STACK_TURN_3": "Notice that the first player cannot make a stack in his second move.", + "MOVE_INSTRUCTIONS": "First, click on a friendly piece." + }, "shifty": { "INITIAL_INSTRUCTIONS": "Select an intersection to place a piece next to one of your own pieces, or select a piece to move.", "INITIAL_INSTRUCTIONS_OPENING": "Select an intersection to place your first piece.", diff --git a/src/games/index.ts b/src/games/index.ts index e69cb9c0..1b3d679f 100644 --- a/src/games/index.ts +++ b/src/games/index.ts @@ -233,6 +233,7 @@ import { GoGame, IGoState } from "./go"; import { StilettoGame, IStilettoState } from "./stiletto"; import { BTTGame, IBTTState } from "./btt"; import { MinefieldGame, IMinefieldState } from "./minefield"; +import { SentinelGame, ISentinelState } from "./sentinel"; export { APGamesInformation, GameBase, GameBaseSimultaneous, IAPGameState, @@ -469,6 +470,7 @@ export { StilettoGame, IStilettoState, BTTGame, IBTTState, MinefieldGame, IMinefieldState, + SentinelGame, ISentinelState, }; const games = new Map(); // Manually add each game to the following array [ @@ -586,7 +588,7 @@ 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."); @@ -1064,6 +1066,8 @@ export const GameFactory = (game: string, ...args: any[]): GameBase|GameBaseSimu return new BTTGame(args[0], ...args.slice(1)); case "minefield": return new MinefieldGame(...args); + case "sentinel": + return new SentinelGame(...args); } return; } diff --git a/src/games/sentinel.ts b/src/games/sentinel.ts new file mode 100644 index 00000000..99e3af2a --- /dev/null +++ b/src/games/sentinel.ts @@ -0,0 +1,534 @@ +import { GameBase, IAPGameState, IClickResult, IIndividualState, IValidationResult } from "./_base"; +import { APGamesInformation } from "../schemas/gameinfo"; +import { APRenderRep } from "@abstractplay/renderer/src/schemas/schema"; +import { APMoveResult } from "../schemas/moveresults"; +import { RectGrid, reviver, UserFacingError, Direction, allDirections } from "../common"; +import i18next from "i18next"; + +export type playerid = 1|2; +export type cellcontents = [playerid, number]; + +const CENTER = 'e5'; +const BOARD_SIZE = 9; + +// RAYS contains, for each direction, the cells that radiate out of the center +const RAYS = [ // made this manually, since the initial board size (9x9) is fixed + ['f5', 'g5', 'h5', 'i5'], // East + ['d5', 'c5', 'b5', 'a5'], // West + ['e6', 'e7', 'e8', 'e9'], // North + ['e4', 'e3', 'e2', 'e1'], // South + ['f6', 'g7', 'h8', 'i9'], // Northeast + ['d6', 'c7', 'b8', 'a9'], // Northwest + ['f4', 'g3', 'h2', 'i1'], // Southeast + ['d4', 'c3', 'b2', 'a1'] // Southwest +]; + +export interface IMoveState extends IIndividualState { + currplayer: playerid; + board: Map; + lastmove?: string; +}; + +export interface ISentinelState extends IAPGameState { + winner: playerid[]; + stack: Array; +}; + +export class SentinelGame extends GameBase { + public static readonly gameinfo: APGamesInformation = { + name: "Sentinel", + uid: "sentinel", + playercounts: [2], + version: "20260328", + dateAdded: "2026-03-28", + description: "apgames:descriptions.sentinel", + // notes: "apgames:notes.sentinel", + urls: [ + "https://boardgamegeek.com/thread/3651706/rules-of-sentinel", + ], + people: [ + { + type: "designer", + name: "João Pedro Neto", + urls: ["https://boardgamegeek.com/boardgamedesigner/3829/joao-pedro-neto"], + apid: "9228bccd-a1bd-452b-b94f-d05380e6638f", + }, + { + type: "coder", + name: "João Pedro Neto", + urls: ["https://boardgamegeek.com/boardgamedesigner/3829/joao-pedro-neto"], + apid: "9228bccd-a1bd-452b-b94f-d05380e6638f", + }, + ], + categories: ["goal>annihilate", "goal>vigil", "mechanic>capture", "mechanic>move", + "mechanic>stack", "board>shape>rect", "components>simple>1per"], + flags: ["perspective", "experimental"] + }; + + public static coords2algebraic(x: number, y: number): string { + return GameBase.coords2algebraic(x, y, BOARD_SIZE); + } + public static algebraic2coords(cell: string): [number, number] { + return GameBase.algebraic2coords(cell, BOARD_SIZE); + } + + public numplayers = 2; + public currplayer: playerid = 1; + public board!: Map; + public gameover = false; + public winner: playerid[] = []; + public variants: string[] = []; + public stack!: Array; + public results: Array = []; + private _points: [number, number][] = []; // if there are points here, the renderer will show them + + constructor(state?: ISentinelState | string, variants?: string[]) { + super(); + if (state === undefined) { + const board = new Map([ // initial setup + ["c1", [1,1]], ["d1", [1,1]], ["e1", [1,1]], ["f1", [1,1]], ["g1", [1,1]], + ["c9", [2,1]], ["d9", [2,1]], ["e9", [2,1]], ["f9", [2,1]], ["g9", [2,1]], + ]); + if ( (variants !== undefined) && (variants.length > 0) ) { + this.variants = [...variants]; + } + const fresh: IMoveState = { + _version: SentinelGame.gameinfo.version, + _results: [], + _timestamp: new Date(), + currplayer: 1, + board, + }; + this.stack = [fresh]; + } else { + if (typeof state === "string") { + state = JSON.parse(state, reviver) as ISentinelState; + } + if (state.game !== SentinelGame.gameinfo.uid) { + throw new Error(`The Sentinel 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): SentinelGame { + 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.results = [...state._results]; + return this; + } + + // check is a stack can be sowed, in the sense that it cannot create new + // intermediate stacks that are not sow-able (stacks must be sowed inside the board) + private isSowable(cell: string, player: playerid): boolean { + const infoPiece = this.board.get(cell); + if ( infoPiece === undefined ) { return true; } // an empty cell is always ok + + const [playerPiece, heightPiece] = infoPiece; + if (player !== playerPiece) { return true; } // a capture is always ok + + const grid = new RectGrid(BOARD_SIZE, BOARD_SIZE); + + // there is a friendly stone/stack at cell: + // we need to check if there is at least one possible direction to sow + // the resulting stack (that does not pass thru the center) + const [x, y] = SentinelGame.algebraic2coords(cell); + for (const dir of allDirections) { + // get all cells in that direction until the end of the board + const ray = grid.ray(x, y, dir).map(n => SentinelGame.coords2algebraic(...n)); + if (!ray.includes(CENTER) && ray.length > heightPiece+1) { // +1 is the future piece being sowed here + return true; + } + } + return false; + } + + public moves(player?: playerid): string[] { + if (this.gameover) { return []; } + if (player === undefined) { player = this.currplayer; } + + const moves: string[] = []; + + const grid = new RectGrid(BOARD_SIZE, BOARD_SIZE); + const pieces = [...this.board.entries()].filter(e => e[1][0] === player) + .map(e => [e[0], e[1][1]] as [string,number]); + let forwardDirs: Direction[]; + + for (const [cell, height] of pieces) { + const [x, y] = SentinelGame.algebraic2coords(cell); + if (height === 1) { // it is a single stone + forwardDirs = player === 1 ? ["NW", "N", "NE"] : ["SW", "S", "SE"]; + for (const dir of forwardDirs) { + // get all cells in that direction until the end of the board + const ray = grid.ray(x, y, dir).map(n => SentinelGame.coords2algebraic(...n)); + if (ray.length === 0) { // the stone is at the edge and can move out of the board + moves.push(`${cell}-off`); + } + if (ray.length > 0) { // there is, at least, an adjacent square in this direction + if (ray[0] === CENTER) { // cannot move into the center + continue; + } + const adj = this.board.get(ray[0]); // get piece (if any) at adjacent cell + if (adj === undefined) { // if it is an empty cell, + moves.push(`${cell}-${ray[0]}`); // stone can move there + } else if (adj[0] === player && // if there is a friendly stone or stack, + this.stack.length !== 3) { // and we are *not* at turn 3 + moves.push(`${cell}-${ray[0]}`); // the stone can move on top of it + } else if (adj[0] !== player) { // if there is an enemy, + moves.push(`${cell}-${ray[0]}`); // the stone can move and capture it + } + } + } + } else { // it is a stack + for (const dir of allDirections) { + const ray = grid.ray(x, y, dir).map(n => SentinelGame.coords2algebraic(...n)); + // cannot sow over the center, and the entire stack must be sown inside the board + if (ray.includes(CENTER) || ray.length <= height) { + continue; + } + // check if any intermediate stack remains sow-able (sowing a stack includes a new stone) + const sowableCells: string[] = ray.slice(0, height+1); + if ( sowableCells.every(c => this.isSowable(c, player)) ) { + moves.push(`${cell}-${ray[height]}`); + } + } + } + } + + moves.sort((a, b) => a.localeCompare(b)); + return moves; + } + + private atEdge(move: string, player: playerid): boolean { + const [x, y] = SentinelGame.algebraic2coords(move); + + if ( player === 1 && (x === 0 || x === BOARD_SIZE-1 || y === 0) ) { + return true; + } + if ( player === 2 && (x === 0 || x === BOARD_SIZE-1 || y === BOARD_SIZE-1) ) { + return true; + } + return false; + } + + public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { + try { + const cell = SentinelGame.coords2algebraic(col, row); + let newmove = ""; + + if ( move === "" ) { + newmove = cell; // starting fresh + } else if (! move.includes('-') ) { + if ( move === cell ) { // if first cell is reclicked, clear everything + newmove = ""; + } else if ( this.atEdge(move, this.currplayer) && cell === CENTER ) { + newmove = `${move}-off`; + } else { + newmove = `${move}-${cell}`; + } + } + + const result = this.validateMove(newmove) as IClickResult; + if (! result.valid) { + result.move = ""; + } else { + result.move = 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")}; + + if (m.length === 0) { + result.valid = true; + result.canrender = true; + result.complete = -1; + result.message = i18next.t("apgames:validation.sentinel.INITIAL_INSTRUCTIONS"); + return result; + } + + if (! m.includes("-") ) { // partial move + if (!this.board.has(m) || this.board.get(m)![0] !== this.currplayer) { + result.valid = false; // can only move friendly pieces + result.message = i18next.t("apgames:validation.sentinel.MOVE_INSTRUCTIONS"); + return result; + } + result.valid = true; // it is a friendly piece or stack (still need to move it) + result.canrender = true; + result.complete = -1; + if (this.board.get(m)![1] === 1) { // pieces have size 1 + if ( this.atEdge(m, this.currplayer) ) { + result.message = i18next.t("apgames:validation.sentinel.EDGE_INSTRUCTIONS"); + } else { + result.message = i18next.t("apgames:validation.sentinel.PIECE_INSTRUCTIONS"); + } + } else { + result.message = i18next.t("apgames:validation.sentinel.STACK_INSTRUCTIONS"); + } + return result; + } + + const allMoves = this.moves(); + + if (! allMoves.includes(m) ) { + result.valid = false; + const [start, end] = m.split(/[-]/); + if ( this.path(start, end).includes(CENTER) ) { + result.message = i18next.t("apgames:validation.sentinel.INVALID_CENTER"); + } else if (this.stack.length === 3 && this.board.has(m.slice(-2))) { + result.message = i18next.t("apgames:validation.sentinel.NO_STACK_TURN_3"); + } else { + result.message = i18next.t("apgames:validation._general.INVALID_MOVE", { move: m }); + } + return result; + } + + result.valid = true; + result.complete = 1; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + } + + // return the list of cells between start and end (or [] if they are not colinear) + private path(start: string, end: string): string[] { + const grid = new RectGrid(BOARD_SIZE, BOARD_SIZE); + const [x, y] = SentinelGame.algebraic2coords(start); + const height = this.board.get(start)![1]; + let result: string[] = []; + + for (const dir of allDirections) { + const ray = grid.ray(x, y, dir).map(n => SentinelGame.coords2algebraic(...n)); + if ( ray.includes(end) ) { // found direction + if ( height === 1 ) { + result = [ray[0]]; + } else { // sowing a stack gains one extra piece + result = ray.slice(0, height+1); + } + break; + } + } + return result; + } + + // return the list of cells that a given piece at 'cell' can move to + private findPoints(cell: string): string[] { + return this.moves().map(move => move.split('-')) // ["a1-b1"] --> ["a1", "b1"] + .filter(([from,]) => from === cell) // keep moves starting at cell + .map(([, to]) => to) // extract destination + .map(c => c === "off" ? CENTER : c); // off moves must point to board center + } + + public move(m: string, {partial = false, trusted = false} = {}): SentinelGame { + 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 ( (! partial) && (! this.moves().includes(m)) ) { + throw new UserFacingError("VALIDATION_FAILSAFE", i18next.t("apgames:validation._general.FAILSAFE", {move: m})) + } + } + + if ( partial && !m.includes("-") ) { // if partial, set the points to be shown + this._points = this.findPoints(m).map(c => SentinelGame.algebraic2coords(c)); + return this; + } else { + this._points = []; // otherwise delete the points and process the full move + } + + const [start, end] = m.split(/[-]/); + + if ( end !== 'off' ) { + for (const cell of this.path(start, end)) { + if (! this.board.has(cell) ) { + // empty cell, so add one friendly stone + this.board.set(cell, [this.currplayer, 1]); + } else if (this.board.get(cell)![0] === this.currplayer) { + // already some friendly piece here, add an extra stone + const height = this.board.get(cell)![1]; + this.board.set(cell, [this.currplayer, height+1]); + } else { + // enemy piece, so capture it before adding a new friendly stone + this.board.delete(cell); + this.board.set(cell, [this.currplayer, 1]); + } + } + } + this.board.delete(start); // the original piece is always removed + + // update currplayer + this.lastmove = m; + let newplayer = (this.currplayer as number) + 1; + if (newplayer > this.numplayers) { + newplayer = 1; + } + this.currplayer = newplayer as playerid; + + this.checkEOG(); + this.saveState(); + return this; + } + + // return the number of line-of-sight to the center wrt player's pieces (needed for EOG) + private linesSeen(player : playerid): number { + let numLines = 0; + for (const ray of RAYS) { + for (const cell of ray) { + // if the first non-empty cell has a friendly stone, the player 'sees' the center + if ( this.board.has(cell) ) { + if ( this.board.get(cell)![0] === player ) { + numLines += 1; + } + break; + } + } + } + return numLines; + } + + protected checkEOG(): SentinelGame { + const prevPlayer = this.currplayer % 2 + 1 as playerid; + + if ( this.linesSeen(prevPlayer) === 0 || this.linesSeen(this.currplayer) >= 5 ) { + this.gameover = true; + this.winner = [this.currplayer]; + } + + if (this.gameover) { + this.results.push( + {type: "eog"}, + {type: "winners", players: [...this.winner]} + ); + } + return this; + } + + public state(): ISentinelState { + return { + game: SentinelGame.gameinfo.uid, + numplayers: this.numplayers, + variants: this.variants, + gameover: this.gameover, + winner: [...this.winner], + stack: [...this.stack] + }; + } + + public moveState(): IMoveState { + return { + _version: SentinelGame.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 (let row = 0; row < BOARD_SIZE; row++) { + if (pstr.length > 0) { + pstr += "\n"; + } + const pieces: string[] = []; + for (let col = 0; col < BOARD_SIZE; col++) { + const cell = SentinelGame.coords2algebraic(col, row); + if (this.board.has(cell)) { + const contents = this.board.get(cell)!; + let str = ""; + for (let i = 0; i < contents[1]; i++) { + if (contents[0] === 1) { + str += "A"; + } else { + str += "B"; + } + } + pieces.push(str); + } else { + pieces.push("-"); + } + } + pstr += pieces.join(","); + } + pstr = pstr.replace(/-{9}/g, "_"); + + // Build rep + const rep: APRenderRep = { + renderer: "stacking-offset", + board: { + style: "squares-checkered", + width: BOARD_SIZE, + height: BOARD_SIZE, + markers: [ + { + type: "glyph", + glyph: "Center", + points: [ {row: 4, col: 4} ] + }, + ] + }, + legend: { + A: { + name: "piece", + colour: 1 + }, + B: { + name: "piece", + colour: 2 + }, + Center: { + name: "piecepack-suit-suns", // cf. https://github.com/AbstractPlay/renderer + colour: 5, // https://renderer.dev.abstractplay.com/ + opacity: 0.85, + scale: 0.85 + } + }, + pieces: pstr + }; + + rep.annotations = []; + if (this._points.length > 0) { // show the dots where the selected piece can move to + const points = []; + for (const [x,y] of this._points) { + points.push({row: y, col: x}); + } + rep.annotations.push({type: "dots", + targets: points as [{row: number; col: number;}, ...{row: number; col: number;}[]]}); + } + + return rep; + } + + public clone(): SentinelGame { + return new SentinelGame(this.serialize()); + } +} \ No newline at end of file From 1bab6b33d31c1fed707e3a15163ad4b1c56aad8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Neto?= Date: Wed, 8 Apr 2026 16:56:03 +0100 Subject: [PATCH 2/2] Added Xana and Spora corrected some Sentinel bugs --- locales/en/apgames.json | 60 +++ src/games/index.ts | 13 +- src/games/sentinel.ts | 95 +++- src/games/spora.ts | 1004 +++++++++++++++++++++++++++++++++++++++ src/games/xana.ts | 784 ++++++++++++++++++++++++++++++ 5 files changed, 1933 insertions(+), 23 deletions(-) create mode 100644 src/games/spora.ts create mode 100644 src/games/xana.ts diff --git a/locales/en/apgames.json b/locales/en/apgames.json index e39b620a..8ba050ed 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -189,6 +189,7 @@ "sploof": "Create a string of four touching balls at any level visible as a straight line from directly above the board. Players start with two balls in their hand, but can take two balls from their stash every time a neutral ball is removed. Players also lose when they have no moves on their turn.", "sponnect": "A simple Shibumi connection game where every internal space that can be buried in a fully-filled board is pre-filled with neutral pieces. You may pass on your turn as long as your opponent didn't pass immediately before.", "spook": "Guide Spooky the ghost around the pyramid and try to be the first player to have all balls of your colour removed. The game takes place in two phases: the setup phase where the pyramid is built, and the getaway phase, where Spooky starts at the top of the pyramid and starts eliminating balls.", + "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.", "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.", @@ -232,6 +233,7 @@ "witch": "Four types of pieces are arrayed randomly on a grid. Each player takes turns removing pieces until one reaches a score of 50.", "wunchunk": "Unify your pieces as much as you can by the end of the game, while keeping your pieces separate in the early and mid-game so as to increase their power. Fewest groups at the end wins.", "wyke": "A two-player game where one plays the builder and the other the destroyer. Claim cells by either completing or completely destroying lots. Win a certain number of lots or lots in a particular pattern to win.", + "xana": "A stack game where players have a limited amount of pieces, and an unlimited amount of walls, to conquer territory.", "yavalath": "Form a line of four or more pieces of your colour *without* forming a line of three pieces first.", "yonmoque": "Try to form four in a row in a game where not all spaces are equal and opposing pieces can be converted.", "zola": "A game where your movement is constrained by your distance from the centre of the board. Capturing moves must not increase that distance. Non-capturing moves must increase that distance. First person to capture all opposing pieces wins." @@ -2433,6 +2435,23 @@ "name": "5x5 board" } }, + "spora": { + "size-9": { + "name": "9x9 board (48 pieces)" + }, + "#board": { + "name": "13x13 board (99 pieces)" + }, + "size-15": { + "name": "15x15 board (132 pieces)" + }, + "size-19": { + "name": "19x19 board (211 pieces)" + }, + "size-25": { + "name": "25x25 board (365 pieces)" + } + }, "spree": { "#board": { "name": "4x4 board" @@ -2972,6 +2991,11 @@ "name": "6x6 board" } }, + "xana": { + "#board": { + "name": "Hexhex 8 (169 spaces)" + } + }, "zola": { "#board": { "name": "6x6 board" @@ -3566,6 +3590,9 @@ "GOALS": "Goals", "HEIGHT": "heights" }, + "xana" : { + "RESERVE": "Reserve:" + }, "YEAR": "Year" }, "validation": { @@ -5670,6 +5697,25 @@ "WRONG_PREFIX_PLACE": "Placements should not include a prefix.", "WRONG_PREFIX_REMOVE": "Removals should be prefixed with 'r'." }, + "spora": { + "INITIAL_SETUP": "Choose a number of points to add to the second player's score, and the next player will choose sides.", + "INSTRUCTIONS": "Place between one to four pieces either an empty intersection or on a friendly stack (self-captures are illegal). Then, optionally, select a different friendly stack, click on it as many times as pieces to move, then click on a orthogonal path that starts adjacent to this sowing stack (one piece per intersection). The path must follow Spora's rules.", + "END_PHASE_INSTRUCTIONS": "The adversary ended his reserve. You need to place the remaining {{remaining}} pieces from your reserve.", + "END_PHASE_LAST" : "This is your last stone to place! The game will end next.", + "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.", + "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.", + "SAME_PLACE_SOW_STACK": "The sowing stack cannot be the stack just created/enlarged.", + "SELF_CAPTURE": "You may not place your stones in self-capture.", + "SOW_FRIENDLY": "Only friendly stacks can be sowed.", + "SOW_INSTRUCTIONS": "Either select more pieces to sow, or start sowing with the ones already selected. When sowing, the next intersection must be adjacent to the previous one. Left and right turns are valid. Cannot sow over (and capture) opponent stacks with more than half the size of the number of pieces that still need to be sowed.", + "SOW_SIZE_SELECTION": "Either place more pieces (up to four), select a different friendly stack to sow (if any), or just finish your move.", + "SOW_TOO_LARGE": "The current sowing is larger than the available pieces at the sowing stack." + }, "spree": { "ALREADY_WON": "You have already won the game, so you don't have to pick a colour to hand to your opponent.", "CANNOT_PLACE": "{{where}} is not a valid place to put a ball.", @@ -6120,6 +6166,20 @@ "TOO_MANY_CELLS": "You have selected more cells than the move type allows.", "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).", + "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.", + "MOVE_TO_OCCUPIED_CELL": "Pieces from a stack must be moved to an empty cell.", + "NOT_ENOUGH_PIECES_TO_MOVE": "The stacks does not have that many pieces to be moved.", + "NOT_PLACED_ON_FRIEND": "Pieces cannot be moved from adversary stacks.", + "OCCUPIED_WALL": "Walls must be placed on empty cells (does not include cells from pieces just captured).", + "RESERVE_EMPTY": "Reserve empty, no more placements possible.", + "SAME_WALL" : "Cannot place the same wall twice.", + "UNACCESSIBLE_PIECE": "Cannot place/move piece on unaccessible cells.", + "UNACCESSIBLE_WALL": "Cannot place walls on unaccessible cells." + }, "yavalath": { "BAD_PASS": "You may only pass if you have been eliminated.", "INITIAL_INSTRUCTIONS": "Click an empty cell to place a piece.", diff --git a/src/games/index.ts b/src/games/index.ts index 1b3d679f..43c34eeb 100644 --- a/src/games/index.ts +++ b/src/games/index.ts @@ -234,6 +234,8 @@ import { StilettoGame, IStilettoState } from "./stiletto"; import { BTTGame, IBTTState } from "./btt"; import { MinefieldGame, IMinefieldState } from "./minefield"; import { SentinelGame, ISentinelState } from "./sentinel"; +import { XanaGame, IXanaState } from "./xana"; +import { SporaGame, ISporaState } from "./spora"; export { APGamesInformation, GameBase, GameBaseSimultaneous, IAPGameState, @@ -471,6 +473,8 @@ export { BTTGame, IBTTState, MinefieldGame, IMinefieldState, SentinelGame, ISentinelState, + XanaGame, IXanaState, + SporaGame, ISporaState, }; const games = new Map(); // Manually add each game to the following array [ @@ -588,7 +593,7 @@ 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."); @@ -1068,6 +1073,10 @@ export const GameFactory = (game: string, ...args: any[]): GameBase|GameBaseSimu return new MinefieldGame(...args); case "sentinel": return new SentinelGame(...args); + case "xana": + return new XanaGame(...args); + case "spora": + return new SporaGame(...args); } return; } diff --git a/src/games/sentinel.ts b/src/games/sentinel.ts index 99e3af2a..a1171e25 100644 --- a/src/games/sentinel.ts +++ b/src/games/sentinel.ts @@ -1,6 +1,6 @@ import { GameBase, IAPGameState, IClickResult, IIndividualState, IValidationResult } from "./_base"; import { APGamesInformation } from "../schemas/gameinfo"; -import { APRenderRep } from "@abstractplay/renderer/src/schemas/schema"; +import { APRenderRep, MarkerGlyph } from "@abstractplay/renderer/src/schemas/schema"; import { APMoveResult } from "../schemas/moveresults"; import { RectGrid, reviver, UserFacingError, Direction, allDirections } from "../common"; import i18next from "i18next"; @@ -61,8 +61,8 @@ export class SentinelGame extends GameBase { }, ], categories: ["goal>annihilate", "goal>vigil", "mechanic>capture", "mechanic>move", - "mechanic>stack", "board>shape>rect", "components>simple>1per"], - flags: ["perspective", "experimental"] + "mechanic>stack", "board>shape>rect", "components>simple>2c"], + flags: ["experimental"] }; public static coords2algebraic(x: number, y: number): string { @@ -176,9 +176,11 @@ export class SentinelGame extends GameBase { const ray = grid.ray(x, y, dir).map(n => SentinelGame.coords2algebraic(...n)); if (ray.length === 0) { // the stone is at the edge and can move out of the board moves.push(`${cell}-off`); - } - if (ray.length > 0) { // there is, at least, an adjacent square in this direction - if (ray[0] === CENTER) { // cannot move into the center + } else { // there is, at least, an adjacent square in this direction + if (ray[0] === CENTER) { // cannot move into the center + continue; + } + if (! this.isSowable(ray[0], player) ) { // cannot make an unsowable stack continue; } const adj = this.board.get(ray[0]); // get piece (if any) at adjacent cell @@ -196,7 +198,7 @@ export class SentinelGame extends GameBase { for (const dir of allDirections) { const ray = grid.ray(x, y, dir).map(n => SentinelGame.coords2algebraic(...n)); // cannot sow over the center, and the entire stack must be sown inside the board - if (ray.includes(CENTER) || ray.length <= height) { + if ( ray.length <= height || ray.slice(0, height+1).includes(CENTER) ) { continue; } // check if any intermediate stack remains sow-able (sowing a stack includes a new stone) @@ -356,6 +358,8 @@ export class SentinelGame extends GameBase { } } + this.results = []; + if ( partial && !m.includes("-") ) { // if partial, set the points to be shown this._points = this.findPoints(m).map(c => SentinelGame.algebraic2coords(c)); return this; @@ -379,9 +383,11 @@ export class SentinelGame extends GameBase { this.board.delete(cell); this.board.set(cell, [this.currplayer, 1]); } + this.results.push({ type: "move", from: start, to: end, count: this.path(start, end).length}); } } this.board.delete(start); // the original piece is always removed + this.results.push({ type: "capture", where: start, count: 1 }); // update currplayer this.lastmove = m; @@ -397,30 +403,43 @@ export class SentinelGame extends GameBase { } // return the number of line-of-sight to the center wrt player's pieces (needed for EOG) - private linesSeen(player : playerid): number { - let numLines = 0; + private linesSeen(player : playerid): string[] { + const whichCells = []; for (const ray of RAYS) { for (const cell of ray) { // if the first non-empty cell has a friendly stone, the player 'sees' the center if ( this.board.has(cell) ) { if ( this.board.get(cell)![0] === player ) { - numLines += 1; + whichCells.push(cell); } break; } } } - return numLines; + return whichCells; } protected checkEOG(): SentinelGame { const prevPlayer = this.currplayer % 2 + 1 as playerid; - if ( this.linesSeen(prevPlayer) === 0 || this.linesSeen(this.currplayer) >= 5 ) { + 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]); + + if ( this.linesSeen(prevPlayer).length === 0 || this.linesSeen(this.currplayer).length >= 5 ) { this.gameover = true; this.winner = [this.currplayer]; } + if (p1Pieces.length === 0) { + this.gameover = true; + this.winner = [2]; + } + + if (p2Pieces.length === 0) { + this.gameover = true; + this.winner = [1]; + } + if (this.gameover) { this.results.push( {type: "eog"}, @@ -479,7 +498,10 @@ export class SentinelGame extends GameBase { } pstr += pieces.join(","); } - pstr = pstr.replace(/-{9}/g, "_"); + + const markers: Array = [ + { type: "glyph", glyph: "Center", points: [ {row: 4, col: 4} ] } + ]; // Build rep const rep: APRenderRep = { @@ -488,13 +510,7 @@ export class SentinelGame extends GameBase { style: "squares-checkered", width: BOARD_SIZE, height: BOARD_SIZE, - markers: [ - { - type: "glyph", - glyph: "Center", - points: [ {row: 4, col: 4} ] - }, - ] + markers }, legend: { A: { @@ -516,7 +532,44 @@ export class SentinelGame extends GameBase { }; rep.annotations = []; - if (this._points.length > 0) { // show the dots where the selected piece can move to + + // show the lines of sight + const [toX, toY] = SentinelGame.algebraic2coords(CENTER); + const prevplayer = this.currplayer % 2 + 1 as playerid; + for (const cell of this.linesSeen(this.currplayer)) { + const [fromX, fromY] = SentinelGame.algebraic2coords(cell); + rep.annotations.push({ type: "move", targets: [{row: fromY, col: fromX}, {row: toY, col: toX}], + style: "dashed", opacity: 0.3, arrow: false, strokeWidth: 0.05, + colour: this.currplayer }); + } + for (const cell of this.linesSeen(prevplayer)) { + const [fromX, fromY] = SentinelGame.algebraic2coords(cell); + rep.annotations.push({ type: "move", targets: [{row: fromY, col: fromX}, {row: toY, col: toX}], + style: "dashed", opacity: 0.3, arrow: false, strokeWidth: 0.05, + colour: prevplayer }); + } + + // show the current move + if ( this.results.length > 0 ) { + for (const move of this.results) { + if (move.type === "place") { + const [x, y] = SentinelGame.algebraic2coords(move.where!); + rep.annotations.push({ type: "enter", targets: [{ row: y, col: x }] }); + } else if (move.type === "move") { + const [fromX, fromY] = SentinelGame.algebraic2coords(move.from); + const [toX, toY] = SentinelGame.algebraic2coords(move.to); + rep.annotations.push({type: "move", targets: [{row: fromY, col: fromX}, {row: toY, col: toX}]}); + } else if (move.type === "capture") { + for (const cell of move.where!.split(",")) { + const [x, y] = SentinelGame.algebraic2coords(cell); + rep.annotations.push({type: "exit", targets: [{row: y, col: x}]}); + } + } + } + } + + // show the dots where the selected piece can move to + if (this._points.length > 0) { const points = []; for (const [x,y] of this._points) { points.push({row: y, col: x}); diff --git a/src/games/spora.ts b/src/games/spora.ts new file mode 100644 index 00000000..60f747df --- /dev/null +++ b/src/games/spora.ts @@ -0,0 +1,1004 @@ +import { GameBase, IAPGameState, IClickResult, ICustomButton, IIndividualState, IScores, IStatus, IValidationResult } from "./_base"; +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 { connectedComponents } from "graphology-components"; +import pako, { Data } from "pako"; + +import i18next from "i18next"; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const Buffer = require('buffer/').Buffer // note: the trailing slash is important! + +type playerid = 1 | 2; +export type cellcontents = [playerid, number]; + +type Territory = { + cells: string[]; + owner: playerid|undefined; +}; + +interface IMoveState extends IIndividualState { + currplayer: playerid; + board: Map; + lastmove?: string; + scores: [number, number]; + komi?: number; + swapped: boolean; + reserve: [number, number]; +} + +export interface ISporaState extends IAPGameState { + winner: playerid[]; + stack: Array; +}; + +export class SporaGame extends GameBase { + public static readonly gameinfo: APGamesInformation = { + name: "Spora", + uid: "spora", + playercounts: [2], + version: "20260407", + dateAdded: "2026-04-07", + // i18next.t("apgames:descriptions.spora") + description: "apgames:descriptions.spora", + urls: [ + "https://boardgamegeek.com/thread/3493284/rules-of-spora" + ], + people: [ + { + type: "designer", + name: "João Pedro Neto", + urls: ["https://boardgamegeek.com/boardgamedesigner/3829/joao-pedro-neto"], + apid: "9228bccd-a1bd-452b-b94f-d05380e6638f", + }, + { + type: "coder", + name: "João Pedro Neto", + urls: ["https://boardgamegeek.com/boardgamedesigner/3829/joao-pedro-neto"], + apid: "9228bccd-a1bd-452b-b94f-d05380e6638f", + }, + ], + variants: [ + { uid: "size-9", group: "board" }, + { uid: "#board", group: "board" }, // 13x13 + { uid: "size-15", group: "board" }, + { uid: "size-19", group: "board" }, + { uid: "size-25", group: "board" } + ], + 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"], + }; + + public coords2algebraic(x: number, y: number): string { + return GameBase.coords2algebraic(x, y, this.boardSize); + } + + public algebraic2coords(cell: string): [number, number] { + return GameBase.algebraic2coords(cell, this.boardSize); + } + + public numplayers = 2; + public currplayer!: playerid; + public board!: Map; + public gameover = false; + public winner: playerid[] = []; + public stack!: Array; + public results: Array = []; + public variants: string[] = []; + public scores: [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; + + constructor(state?: ISporaState | string, variants?: string[]) { + super(); + if (state === undefined) { + if (variants !== undefined) { + this.variants = [...variants]; + } + const fresh: IMoveState = { + _version: SporaGame.gameinfo.version, + _results: [], + _timestamp: new Date(), + currplayer: 1, + board: new Map(), + scores: [0, 0], + swapped: true, + reserve: [this.getReserveSize(), this.getReserveSize()], + }; + this.stack = [fresh]; + } else { + if (typeof state === "string") { + // is the state a raw JSON obj + if (state.startsWith("{")) { + state = JSON.parse(state, reviver) as ISporaState; + } else { + const decoded = Buffer.from(state, "base64") as Data; + const decompressed = pako.ungzip(decoded, {to: "string"}); + state = JSON.parse(decompressed, reviver) as ISporaState; + } + } + if (state.game !== SporaGame.gameinfo.uid) { + throw new Error(`The Spora game code 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): SporaGame { + 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]; + if (state === undefined) { + throw new Error(`Could not load state index ${idx}`); + } + this.currplayer = state.currplayer; + this.board = new Map([...state.board].map(([k, v]) => [k, [...v]])); + this.results = [...state._results]; + this.lastmove = state.lastmove; + this.boardSize = this.getBoardSize(); + this.scores = [...state.scores]; + this.reserve = [...state.reserve]; + this.komi = state.komi; + 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 !== "play-second"; + } else { + this.swapped = state.swapped; + } + 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 13; + } + + // consider the number of stones to be 1/2 or 2/3 of the board intersections, as reasonable + // 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; + return Math.ceil((a+b)/2); + } + + public isKomiTurn(): boolean { + return this.stack.length === 1; + } + + public isPieTurn(): boolean { + return this.stack.length === 2; + } + + public isEndPhase(): boolean { + const prevplayer = this.currplayer === 1 ? 2 : 1; + return this.reserve[prevplayer - 1] == 0; + } + + public moves(): string[] { + return []; // too many moves to list + } + + public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { + try { + if (this.isKomiTurn()) { // Komi time, so no clicks are acceptable + const dummyResult = this.validateMove("") as IClickResult; + dummyResult.move = ""; + dummyResult.valid = false; + return dummyResult; + } + + const cell = this.coords2algebraic(col, row); + let newmove = ""; + + // there are two phases: the normal playing phase, and the end phase after the adversary has no reserve + if (! this.isEndPhase() ) { + if ( move === "" ) { // starting fresh + newmove = `${cell}<1`; + } else if (! move.includes(',') ) { // still in the placement phase + const [c, n] = move.split(/[<]/); + if ( c === cell ) { // first cell is reclicked, add one more piece top stack + newmove = `${c}<${Number(n)+1}`; + } else { + newmove = `${move},${cell}>1`; // otherwise, the click was elsewhere, so now the sow phase starts + } + } else if ( move.includes(',') && !move.includes('@')) { // sowing still not started (eg, a<1,b1>1) + const [placeStack, n1, sowingStack, n2] = move.split(/[<,>]/); + if ( sowingStack === cell ) { + newmove = `${placeStack}<${n1},${sowingStack}>${Number(n2)+1}`; // add a new piece for sowing + } else if (Number(n2) === 1) { + newmove = `${placeStack}<${n1},${sowingStack}@${cell}`; // sow just one stone + } else { + newmove = `${placeStack}<${n1},${sowingStack}>${Number(n2)-1}@${cell}`; // start sowing + } + } else if ( move.includes('>') && move.includes('@') ) { // in the middle of the sowing phase (eg, a1<1,b1>3@c1) + const [placeStack, n1, sowingStack, n2, sowingPath] = move.split(/[<,>@]/); + if ( Number(n2) > 1 ) { + newmove = `${placeStack}<${n1},${sowingStack}>${Number(n2)-1}@${sowingPath}-${cell}`; // continue sowing + } else { // all pieces were sowed (eg, a1<1,b1>1@c1-d1 becomes a1<1,b1@c1-d1-cell) + newmove = `${placeStack}<${n1},${sowingStack}@${sowingPath}-${cell}`; // end sowing + } + } else { + throw new Error(); + } + } else { // otherwise, the game is at its end phase + // the current player must place all of his pieces in sequence + newmove = move === "" ? `${cell}` : `${move},${cell}`; + } + + const result = this.validateMove(newmove) as IClickResult; + if (!result.valid) { + result.move = ""; + } else { + result.move = 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 }) + } + } + } + + //////////////////////// helper functions //////////////////////// + + public getGraph(): SquareOrthGraph { // just orthogonal connections + 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); + 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 { + if (path.length === 0) return true; + + // first step must be adjacent to start + if (! this.isOrthAdjacent(start, path[0])) { + return false; + } + + let currentSowSize = remainingSowSize + path.length; // initial amount of pieces to sow + let prev = start; + let prevDir: [number, number] | null = null; + + 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 dir: [number, number] = [x2 - x1, y2 - y1]; + + if (prevDir) { // check no 180° turn + const isOpposite = dir[0] === -prevDir[0] && dir[1] === -prevDir[1]; + if (isOpposite) { return false; } + } + + // check if current position is occupied by an un-capturable enemy stack + if ( this.board.has(cell) && this.board.get(cell)![0] !== this.currplayer ) { + // this cell has an enemy stack, check if size is compatible + const size = this.board.get(cell)![1]; + if ( currentSowSize < 2*size ) { + 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 ) { + const size = this.board.get(cell)![1]; + if ( size === 4) { + return false; // friendly stack is too big; this path is invalid + } + } + + prevDir = dir; + prev = cell; + currentSowSize -= 1; // one piece stays here, the rest are to be sowed in the remaining path + } + + return true; + } + + // returns all the dead pieces, and how many dead groups they define + public findDead(p: playerid, board?: Map): string[] { + if (board === undefined) { + board = this.cloneBoard(); //new Map(this.board); + } + const dead: string[] = []; + + // get list of pieces owned by each player + const pcsOwned = [...board.entries()].filter(e => e[1][0] === p).map(e => e[0]); + const pcsUnowned = [...board.entries()].filter(e => e[1][0] !== p).map(e => e[0]); + + // get groups of owned pieces (just owned pieces, no empty spaces) + const gOwned = this.getGraph(); + for (const node of gOwned.graph.nodes()) { + if (! pcsOwned.includes(node)) { + gOwned.graph.dropNode(node); + } + } + const groupsOwned = connectedComponents(gOwned.graph); + + // check connecting paths + + // first generate a new graph with owned pcs and empties + const gLos = this.getGraph(); + for (const node of gLos.graph.nodes()) { + if (pcsUnowned.includes(node)) { + gLos.graph.dropNode(node); + } + } + // now test that there's a path from the first cell of each group + // to the first cell in at least one other group + for (let i = 0; i < groupsOwned.length; i++) { + const comp = groupsOwned[i]; + const others = [...groupsOwned.slice(0,i), ...groupsOwned.slice(i+1)]; + let hasLos = false; + for (const test of others) { + const path = gLos.path(comp[0], test[0]); + if (path !== null) { + hasLos = true; + break; + } + } + if (! hasLos) { + dead.push(...comp); + } + } + + return dead; + } + + // a stack placement is legal iff + // after placing it, and removing the dead pieces, that position is accessible to other friendly groups + private validPlacement(initialCell: string): boolean { + const prevplayer = this.currplayer === 1 ? 2 : 1; + // we'll simulate the process in a cloned board + const cloned = this.cloneBoard(); + // place the stack (herein, its size is irrelevant) + cloned.set(initialCell, [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 + return this.findDead(this.currplayer, cloned).length === 0; + } + + // What pieces are orthogonally adjacent to a given area? + public getAdjacentPieces(area: string[], pieces: string[]): string[] { + // convert area strings to numeric coordinates + const areaCoords = area.map(cell => this.algebraic2coords(cell)); + + return pieces.filter(pieceStr => { // Filter the pieces array + const piece = this.algebraic2coords(pieceStr); + + return areaCoords.some(square => { // check adjacency + const dx = Math.abs(piece[0] - square[0]); + const dy = Math.abs(piece[1] - square[1]); + return (dx == 1 && dy == 0) || (dx == 0 && dy == 1); + }); + }); + } + + // Get all available territories + // Used in (1) computing scores, and (2) in the render process + public getTerritories(): Territory[] { + 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]); + const allPieces = [...p1Pieces, ...p2Pieces]; + + // compute empty areas + const gEmpties = this.getGraph(); + for (const node of gEmpties.graph.nodes()) { + if (allPieces.includes(node)) { // remove intersections/nodes with pieces + gEmpties.graph.dropNode(node); + } + } + const emptyAreas : Array> = connectedComponents(gEmpties.graph); + + const territories: Territory[] = []; + for(const area of emptyAreas) { + let owner : playerid | undefined = undefined; + // find who owns it + const p1AdjacentCells = this.getAdjacentPieces(area, p1Pieces); + const p2AdjacentCells = this.getAdjacentPieces(area, p2Pieces); + if (p1AdjacentCells.length > 0 && p2AdjacentCells.length == 0) { + owner = 1; + } + if (p1AdjacentCells.length == 0 && p2AdjacentCells.length > 0) { + owner = 2; + } + territories.push({cells: area, owner}); + } + return territories; + } + + /** + * Move type | Requirements + * ----------------+------------------------------------------------------------------------------------------------- + * c1 size(c1)+n <= 4 + * c1n1 | requirements(c1n1@path | requirements(c1n1), path orthogonal & adj(c2) + * c1n1@path) + */ + public validateMove(m: string): IValidationResult { + const result: IValidationResult = {valid: false, message: i18next.t("apgames:validation._general.DEFAULT_HANDLER")}; + + if (this.isKomiTurn()) { + if (m.length === 0) { + // game is starting, show initial KOMI message + result.valid = true; + result.complete = -1; + result.message = i18next.t("apgames:validation.spora.INITIAL_SETUP"); + return result; + } + + // player typed something in the move textbox, + // check if it is an integer or a number with 0.5 decimal part + if (! /^-?\d+(\.[05])?$/.test(m)) { + result.valid = false; + result.message = i18next.t("apgames:validation.spora.INVALID_KOMI"); + return result + } + result.valid = true; + result.complete = 0; // partial because player can continue typing + result.canrender = true; + result.message = i18next.t("apgames:validation.spora.INSTRUCTIONS"); + return result; + } + + if (m.length === 0) { + result.valid = true; + result.complete = -1; + result.canrender = true; + if ( this.isPieTurn() ) { + result.message = i18next.t("apgames:validation.spora.KOMI_CHOICE"); + } if ( this.isEndPhase() ) { + result.message = i18next.t("apgames:validation.spora.END_PHASE_INSTRUCTIONS", + { remaining: this.reserve[this.currplayer - 1] }); + } else { + result.message = i18next.t("apgames:validation.spora.INSTRUCTIONS") + } + return result; + } + + if (m === "play-second") { + 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.spora.INVALID_PLAYSECOND"); + } + return result; + } + + // check if the game is in its end phase and, if so, deal with the situation + if ( this.isEndPhase() ) { + const cells = m.split(/[,]/); + if ( cells.length > this.reserve[this.currplayer - 1] ) { + result.valid = false; + result.message = i18next.t("apgames:validation.spora.NOT_ENOUGH_PIECES"); + return result; + } + for (const cell of cells) { + if (this.board.has(cell) && this.board.get(cell)![0] !== this.currplayer) { + result.valid = false; + result.message = i18next.t("apgames:validation.spora.ENEMY_PIECE"); + return result; + } + if ( this.board.has(cell) && this.board.get(cell)![1] + cells.filter(c => c === cell).length > 4 ) { + result.valid = false; + 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; + } + } + result.valid = true; + result.complete = this.reserve[this.currplayer - 1] > cells.length ? -1 : 1; // end when all pieces are on board + result.canrender = true; + if ( this.reserve[this.currplayer - 1] === cells.length + 1 ) { + result.message = i18next.t("apgames:validation.spora.END_PHASE_LAST"); + } else { + const remaining = this.reserve[this.currplayer - 1] - cells.length; + result.message = i18next.t("apgames:validation.spora.END_PHASE_INSTRUCTIONS", { remaining: remaining }); + } + return result; + } + + const initialCell = m.split(/[<,>@]/)[0]; + const isEmpty = !this.board.has(initialCell); + const hasEnemy = this.board.has(initialCell) && this.board.get(initialCell)![0] !== this.currplayer; + const hasFriend = this.board.has(initialCell) && this.board.get(initialCell)![0] === this.currplayer; + + const commands: string[] = m.split(/[,]/); + const n = Number(commands[0].split(/[<]/)[1]); // get the amount of pieces to place + + try { + this.algebraic2coords(initialCell); // check if valid cell + } catch { + result.valid = false; + result.message = i18next.t("apgames:validation._general.INVALIDCELL", { cell: m }); + return result; + } + + // otherwise, we are in the playing phase + + if ( hasEnemy ) { + result.valid = false; + result.message = i18next.t("apgames:validation.spora.ENEMY_PIECE"); + return result; + } + + if ( n > this.reserve[this.currplayer - 1] ) { + result.valid = false; + result.canrender = false; + result.message = i18next.t("apgames:validation.spora.NOT_ENOUGH_PIECES"); + return result; + } + + if ( (isEmpty && n > 4) || (hasFriend && n + this.board.get(initialCell)![1] > 4) ) { + result.valid = false; + result.message = i18next.t("apgames:validation.spora.MAXIMUM_STACK"); + return result; + } + + // a placement is legal iff we are still placing the first two stacks of each player, or + // after placing it, and removing the dead pieces, that position is accessible to other friendly groups + if (this.stack.length > 5 && isEmpty && !this.validPlacement(initialCell) ) { + result.valid = false; + result.message = i18next.t("apgames:validation.spora.SELF_CAPTURE"); + return result; + } + + // at this moment, the place phase is correct + + // if the move does not have a comma, it is a valid move (but might not be complete) + if (! m.includes(',') ) { + result.valid = true; + result.complete = 0; // the sowing phase is optional (the player might still choose to sow) + result.canrender = true; + result.message = i18next.t("apgames:validation.spora.SOW_SIZE_SELECTION"); + return result; + } + + // otherwise, the sow phase already began + + if (! m.includes('@') ) { // we are still finding how many pieces are to be sowed + const info = m.split(/[<,>]/); // eg, c1n1 + const sowingStack = info[2]; + const n1 = Number(info[3]); + + if ( !this.board.has(sowingStack) || this.board.get(sowingStack)![0] !== this.currplayer ) { + result.valid = false; + result.message = i18next.t("apgames:validation.spora.SOW_FRIENDLY"); + return result; + } + if ( initialCell === sowingStack ) { + result.valid = false; + result.message = i18next.t("apgames:validation.spora.SAME_PLACE_SOW_STACK"); + return result; + } + if ( n1 > this.board.get(sowingStack)![1] ) { + result.valid = false; + result.message = i18next.t("apgames:validation.spora.SOW_TOO_LARGE"); + return result; + } + result.valid = true; + result.complete = -1; // still necessary to state the sowing path, next + result.canrender = true; + result.message = i18next.t("apgames:validation.spora.SOW_INSTRUCTIONS"); + return result; + } + + // there is already a (partial) path; check if path is correct + const tokens = m.split(/[<,>@]/); + const sowingStack = tokens[2]; + let remainingSowSize : number = 0; + let cellsPath : string[]; + + if ( m.includes('>') ) { // eg, c1n1@path + remainingSowSize = Number(tokens[3]); // get n1 + cellsPath = tokens[4].split('-'); + } else { // eg, c1') ? -1 : 1; // incomplete until all pieces are sowed + result.canrender = true; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + } + + private doCaptures(): string[] { + const firstPly = this.swapped ? 6 : 5; + if ( this.stack.length <= firstPly ) return []; + const result = []; + const prevplayer = this.currplayer === 1 ? 2 : 1; + + for (const cell of this.findDead(prevplayer)) { + this.board.delete(cell); + result.push(cell); + } + for (const cell of this.findDead(this.currplayer)) { + this.board.delete(cell); + result.push(cell); + } + return result; + } + + public move(m: string, {partial = false, trusted = false} = {}): SporaGame { + 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 (m.length === 0) { return this; } + this.results = []; + const captures = []; // all the captures made in the turn + let totalPiecesPlaced = 0; + + if (this.isKomiTurn()) { + // first move, get the Komi proposed value, and add komi to game state + this.komi = Number(m); + this.results.push({type: "komi", value: this.komi}); + this.komi *= -1; // invert it for backwards compatibility reasons + } else if (m === "play-second") { + this.komi! *= -1; + this.swapped = false; + this.results.push({type: "play-second"}); + } else if ( this.isEndPhase() ) { + const cells = m.split(/[,]/); + totalPiecesPlaced = cells.length; + for (const cell of cells) { + const prevsize = this.board.has(cell) ? this.board.get(cell)![1] : 0; + this.board.set(cell, [this.currplayer, prevsize + 1]); + this.results.push({ type: "place", where: cell }); + captures.push(...this.doCaptures()); + if (captures.length > 0) { + this.results.push({ type: "capture", where: [...captures].join(), count: captures.length }); + } + } + } else { // normal play + // the possible commands have format "c1n1@path" or "c1@]/); + const placeStack = commands[0]; + const n = Number(commands[1]); + totalPiecesPlaced = n; + + // first, do the stack placement + const prevsize = this.board.has(placeStack) ? this.board.get(placeStack)![1] : 0; + this.board.set(placeStack, [this.currplayer, prevsize + n]); + this.results = [{type: "place", where: placeStack}] + + captures.push(...this.doCaptures()); + if (captures.length > 0) { + this.results.push({ type: "capture", where: [...captures].join(), count: captures.length }); + } + + // second, do the (optional) partial sowing + if ( m.includes('>') && m.includes('@') ) { + const sowingStack = commands[2]; + const originalSize = this.board.get(sowingStack)![1]; + const n1 = Number(commands[3]); // pieces that await their turn to be sowed + const cells = m.split(/[@]/)[1].split(/[-]/); + const totalSowed = n1 + cells.length; // the total number of pieces in the sowing move + + this.board.set(sowingStack, [this.currplayer, originalSize - totalSowed + n1]); + + for (const cell of cells) { + const size = this.board.has(cell) && this.board.get(cell)![0] == this.currplayer + ? this.board.get(cell)![1] : 0; + this.board.set(cell, [this.currplayer, size+1]); // place a friendly piece (possibly capturing an enemy stack) + captures.push(...this.doCaptures()); // each sowed piece can capture + } + + if (captures.length > 0) { + this.results.push({ type: "capture", where: [...captures].join(), count: captures.length }); + } + + const sowingPath = [sowingStack, ...cells] + for(let i = 0; i < sowingPath.length-1; i++ ) { // mark path + this.results.push({type: "move", from: sowingPath[i], to: sowingPath[i+1]}); + } + return this; + } + + // or do the (optional) complete sowing + if ( m.includes('@') ) { + const sowingStack = commands[2]; + const cells = commands[3].split(/[-]/); + const n1: number = cells.length; // number of pieces to be removed from sowingStack + + if ( this.board.get(sowingStack)![1] === n1 ) { // the entire stack is moving + this.board.delete(sowingStack); + } else { // otherwise, just update the stack's size + this.board.set(sowingStack, [this.currplayer, this.board.get(sowingStack)![1] - n1]); + } + + for (const cell of cells) { + const size: number = this.board.has(cell) && this.board.get(cell)![0] == this.currplayer + ? this.board.get(cell)![1] : 0; + this.board.set(cell, [this.currplayer, size+1]); // place a friendly piece (possibly capturing an enemy stack) + captures.push(...this.doCaptures()); // each sowed piece can capture + } + + if (captures.length > 0) { + this.results.push({ type: "capture", where: [...captures].join(), count: captures.length }); + } + + const sowingPath = [sowingStack, ...cells] + for(let i = 0; i < sowingPath.length-1; i++ ) { // mark path + this.results.push({type: "move", from: sowingPath[i], to: sowingPath[i+1]}); + } + } + } + + if ( partial ) { return this; } + + this.lastmove = m; + this.scores = [this.getPlayerScore(1), this.getPlayerScore(2)]; + this.reserve[this.currplayer - 1] -= totalPiecesPlaced; + this.currplayer = this.currplayer % 2 + 1 as playerid; + + this.checkEOG(); + this.saveState(); + return this; + } + + protected checkEOG(): SporaGame { + if (this.stack.length <= 4) return this; // player 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]); + + this.gameover = (this.reserve[0] === 0 && this.reserve[1] === 0) // game ends when both reserves are empty + || p1Pieces.length === 0 || p2Pieces.length === 0; // or one player is without pieces on board + + if (this.gameover) { + this.scores = [this.getPlayerScore(1), this.getPlayerScore(2)]; + this.winner = this.scores[0] > this.scores[1] ? [1] : [2]; + this.results.push( + {type: "eog"}, + {type: "winners", players: [...this.winner]} + ); + } + return this; + } + + public state(): ISporaState { + return { + game: SporaGame.gameinfo.uid, + numplayers: 2, + variants: this.variants, + gameover: this.gameover, + winner: [...this.winner], + stack: [...this.stack], + }; + } + + protected moveState(): IMoveState { + return { + _version: SporaGame.gameinfo.version, + _results: [...this.results], + _timestamp: new Date(), + currplayer: this.currplayer, + lastmove: this.lastmove, + board: this.cloneBoard(), + scores: [...this.scores], + reserve: [...this.reserve], + komi: this.komi, + swapped: this.swapped + }; + } + + public render(): APRenderRep { + let pstr = ""; + for (let row = 0; row < this.getBoardSize(); row++) { + if (pstr.length > 0) { + pstr += "\n"; + } + const pieces: string[] = []; + for (let col = 0; col < this.getBoardSize(); col++) { + const cell = this.coords2algebraic(col, row); + if (this.board.has(cell)) { + const contents = this.board.get(cell)!; + let str = ""; + for (let i = 0; i < contents[1]; i++) { + if (contents[0] === 1) { + str += "A"; + } else { + str += "B"; + } + } + pieces.push(str); + } else { + pieces.push("-"); + } + } + pstr += pieces.join(","); + } + pstr = pstr.replace(/-{9}/g, "_"); + + // Build rep + const rep: APRenderRep = { + renderer: "stacking-offset", + board: { + style: "vertex", + width: this.boardSize, + height: this.boardSize, + }, + legend: { + A: [{ name: "piece", colour: this.getPlayerColour(1) }], + B: [{ name: "piece", colour: this.getPlayerColour(2) }], + }, + pieces: pstr, + }; + + rep.annotations = []; + + if ( this.results.length > 0 ) { + for (const move of this.results) { + if (move.type === "place") { + const [x, y] = this.algebraic2coords(move.where!); + rep.annotations.push({ type: "enter", targets: [{ row: y, col: x }] }); + } else if (move.type === "move") { + const [fromX, fromY] = this.algebraic2coords(move.from); + const [toX, toY] = this.algebraic2coords(move.to); + rep.annotations.push({type: "move", targets: [{row: fromY, col: fromX}, {row: toY, col: toX}]}); + } else if (move.type === "capture") { + for (const cell of move.where!.split(",")) { + const [x, y] = this.algebraic2coords(cell); + rep.annotations.push({type: "exit", targets: [{row: y, col: x}]}); + } + } + } + } + + if ( this.stack.length > 4 ) { // only show territories after the initial moves + const territories = this.getTerritories(); + const markers: Array = [] + for (const t of territories) { + if (t.owner !== undefined) { + const points = t.cells.map(c => this.algebraic2coords(c)); + if (t.owner !== undefined) { + markers.push({type: "dots", + colour: this.getPlayerColour(t.owner), + points: points.map(p => { return {col: p[0], row: p[1]}; }) as [RowCol, ...RowCol[]]}); + } + } + } + if (markers.length > 0) { + (rep.board as BoardBasic).markers = markers; + } + } + + return rep; + } + + public getPlayerScore(player: playerid): number { + const playerPieces = [...this.board.entries()].filter(e => e[1][0] === player).map(e => e[0]); + + let komi = 0.0; + if (player === 1 && this.komi !== undefined && this.komi < 0) + komi = -this.komi; + if (player === 2 && this.komi !== undefined && this.komi > 0) + komi = this.komi; + + const terr = this.getTerritories(); + return terr.filter(t => t.owner === player) + .reduce((prev, curr) => prev + curr.cells.length, komi + playerPieces.length); + } + + public getPlayerColour(player: playerid): number | string { + return (player == 1 && !this.swapped) || (player == 2 && this.swapped) ? 1 : 2; + } + + public cloneBoard(): Map { + return new Map([...this.board].map(([k, v]) => [k, [...v]])); + } + + public getButtons(): ICustomButton[] { + if ( this.isPieTurn() ) { + return [{ label: "playsecond", move: "play-second" }]; + } + return []; + } + + public sidebarStatuses(): IStatus[] { + const status = `Player 1: ${this.reserve[0]}\nPlayer 2: ${this.reserve[1]}` + return [{ key: i18next.t("apgames:status.spora.RESERVE"), + value: [status] } as IStatus]; + } + + public sidebarScores(): IScores[] { + return [{ name: i18next.t("apgames:status.SCORES"), + scores: [this.getPlayerScore(1), this.getPlayerScore(2)] }]; + } + + 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; + case "capture": + node.push(i18next.t("apresults:CAPTURE.noperson.group_nowhere", { player, count: r.count })); + resolved = true; + break; + case "eog": + node.push(i18next.t("apresults:EOG.default")); + resolved = true; + break; + } + return resolved; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public serialize(opts?: {strip?: boolean, player?: number}): string { + const json = JSON.stringify(this.state(), replacer); + const compressed = pako.gzip(json); + return Buffer.from(compressed).toString("base64") as string; + } + + public clone(): SporaGame { + return new SporaGame(this.serialize()); + } +} diff --git a/src/games/xana.ts b/src/games/xana.ts new file mode 100644 index 00000000..3ee75edb --- /dev/null +++ b/src/games/xana.ts @@ -0,0 +1,784 @@ +import { GameBase, IAPGameState, IClickResult, ICustomButton, IIndividualState, IValidationResult, IScores, IStatus } from "./_base"; +import { APGamesInformation } from "../schemas/gameinfo"; +import { APRenderRep, BoardBasic, MarkerDots, RowCol, Colourfuncs } from "@abstractplay/renderer/src/schemas/schema"; +import { APMoveResult } from "../schemas/moveresults"; +import { reviver, UserFacingError } from "../common"; +import { HexTriGraph } from "../common/graphs"; +import { connectedComponents } from "graphology-components"; +import i18next from "i18next"; + +export type playerid = 1 | 2 | 3 ; // 3 are neutral stones, ie, walls +export type cellcontents = [playerid, number]; + +const BOARD_SIZE = 8; // the game is played on a hexhex board of size 8 +const RESERVE_SIZE = 16; // how many pieces each player has off-board + +type Territory = { + cells: string[]; + owner: playerid|undefined; +}; + +export interface IMoveState extends IIndividualState { + currplayer: playerid; + board: Map; + lastmove?: string; + scores: [number, number]; + prisoners: [number, number]; + reserve: [number, number]; +}; + +export interface IXanaState extends IAPGameState { + winner: playerid[]; + stack: Array; +}; + +export class XanaGame extends GameBase { + public static readonly gameinfo: APGamesInformation = { + name: "Xana", + uid: "xana", + playercounts: [2], + version: "20260404", + dateAdded: "2026-04-04", + // i18next.t("apgames:descriptions.xana") + description: "apgames:descriptions.xana", + urls: [ + "https://boardgamegeek.com/thread/3482800", + ], + people: [ + { + type: "designer", + name: "João Pedro Neto", + urls: ["https://boardgamegeek.com/boardgamedesigner/3829/joao-pedro-neto"], + apid: "9228bccd-a1bd-452b-b94f-d05380e6638f", + }, + { + type: "coder", + name: "João Pedro Neto", + urls: ["https://boardgamegeek.com/boardgamedesigner/3829/joao-pedro-neto"], + apid: "9228bccd-a1bd-452b-b94f-d05380e6638f", + }, + ], + customizations: [ + { + num: 1, + default: 2, + explanation: "Colour of player 1" + }, + { + num: 2, + default: "#FFD700", + explanation: "Colour of player 2" + }, + { + num: 3, + default: 1, + explanation: "Colour of wall" + }, + ], + 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"], + }; + + public numplayers = 2; + public currplayer: playerid = 1; + public board!: Map; + public gameover = false; + public winner: playerid[] = []; + public variants: string[] = []; + public stack!: Array; + public results: Array = []; + public graph: HexTriGraph = new HexTriGraph(BOARD_SIZE, 2*BOARD_SIZE-1); + + private scores: [number, number] = [0, 0]; + private prisoners: [number, number] = [0, 0]; // number of enemy pieces (not stacks) captured + private reserve: [number, number] = [RESERVE_SIZE, RESERVE_SIZE]; // number of pieces initially off-board + private _points: [number, number][] = []; // if there are points here, the renderer will show them + + constructor(state?: IXanaState | string, variants?: string[]) { + super(); + if (state === undefined) { + if ( (variants !== undefined) && (variants.length > 0) ) { + this.variants = [...variants]; + } + const fresh: IMoveState = { + _version: XanaGame.gameinfo.version, + _results: [], + _timestamp: new Date(), + currplayer: 1, + board: new Map(), + scores: [0, 0], + prisoners: [0, 0], + reserve: [RESERVE_SIZE, RESERVE_SIZE], + }; + this.stack = [fresh]; + } else { + if (typeof state === "string") { + state = JSON.parse(state, reviver) as IXanaState; + } + if (state.game !== XanaGame.gameinfo.uid) { + throw new Error(`The Xana 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): XanaGame { + 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.results = [...state._results]; + this.lastmove = state.lastmove; + this.scores = [...state.scores]; + this.prisoners = [...state.prisoners]; + this.reserve = [...state.reserve]; + this.graph = new HexTriGraph(BOARD_SIZE, 2*BOARD_SIZE-1); + return this; + } + + /////////////// helper functions /////////////// + + // 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) + .map(e => [e[0], e[1][1]] as [string,number]); + + // frontier will start with all adjacent empty cells around the given pieces + const frontier = new Set(); // a set removes duplicates + for (const [cell,] of pieces) { + for (const adj of this.graph.neighbours(cell)) { + if ( !this.board.has(adj) ) { // an accessible empty cell + frontier.add(adj); + } + } + } + + const frontier2 = [...frontier]; + const visited = new Set(); + while (frontier2.length > 0) { + const cell = frontier2.shift()!; // dequeue the first cell + visited.add(cell); + for (const adj of this.graph.neighbours(cell)) { + if (! this.board.has(adj) && ! visited.has(adj) ) { + frontier2.push(adj); // enqueue new empty non-visited neighbors + } + } + } + + return [...visited]; + } + + // return all the player's pieces that are not adjacent to an empty cell + private withoutLiberties(player: playerid): string[] { + const result = []; + const pieces = [...this.board.entries()].filter(e => e[1][0] === player) + .map(e => [e[0], e[1][1]] as [string,number]); + + for(const [cell,] of pieces) { // for each friendly stack... + let found = false; + for (const adj of this.graph.neighbours(cell)) { + if (! this.board.has(adj) ) { found = true; break; } + } + if (! found ) { result.push(cell); } // if no liberties found, include it + } + return result; + } + + // the empty cells reachable by the stack at 'cell' + private circle(cell: string): string[] { + if (! this.board.has(cell) ) { return []; } + const size = Number(this.board.get(cell)![1]); + + const frontier:[number, string][] = + (this.graph.neighbours(cell) as string[]) + .filter(c => !this.board.has(c)) + .map(c => [size-1, c]); + + const visited = new Set(); + while (frontier.length > 0) { + const [n, c] = frontier.shift()!; // dequeue the first cell + if ( !visited.has(c) ) { + visited.add(c); + for (const adj of this.graph.neighbours(c)) { + if ( !this.board.has(adj) && !visited.has(adj) && n > 0 ) { + frontier.push([n-1, adj]); // enqueue new empty non-visited neighbors + } + } + } + } + + return [...visited]; + } + + // remove all captured pieces + private makeCaptures(): string[] { + const captures: string[] = []; + + let prevPlayer: playerid = 1; + if (this.currplayer === 1) { prevPlayer = 2; } + + // first check if some enemy captures are possible + const enemyPieces = this.withoutLiberties(prevPlayer); + for (const capturedCell of enemyPieces) { + // account prisoners + this.prisoners[this.currplayer-1] += this.board.get(capturedCell)![1]; // sum the size of the stack + this.board.delete(capturedCell); + captures.push(capturedCell) + } + + // then check if there are friendly captures + const friendlyPieces = this.withoutLiberties(this.currplayer); + for (const capturedCell of friendlyPieces) { + // account prisoners + this.prisoners[prevPlayer-1] += this.board.get(capturedCell)![1]; // sum the size of the stack + this.board.delete(capturedCell); + captures.push(capturedCell) + } + + return captures; + } + + /** + * A Xana move is composed of a (mandatory) stack interaction, and (optional) wall placement(s) + * 1) Pieces can be (a) dropped on new empty cells (making a new stack) + * (b) dropped on friendly stack (so increasing the stack) + * (c) move a stack (or part of it) within its range + * 2) Optionally place one or two walls on empty cells + * + * Notation examples: + * b6<5 <- dropped five stones at b6, no walls placed + * e2<2,e1 <- dropped two stones at e2, one wall at e1 + * h8<1,e1,e2 <- dropped a single stone at h8, two walls placed at e1 and e2 + * e4>3-e5,e1,e2 <- moved three stones from e4 to e5, and then dropped walls at e1 and e2 + */ + + public moves(): string[] { + return []; // too many moves to list + } + + public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { + try { + const cell = this.graph.coords2algebraic(col, row); + let newmove = ""; + + if ( move === "" ) { // starting fresh + if ( this.board.has(cell) ) { + newmove = `${cell}`; // it can be a placement or movement, that will be decided in the next click + } else { + newmove = `${cell}<1`; // if the cell is empty, we already know it is a placement + } + } else if (!move.includes('<') && !move.includes('>') ) { // player must decide to place or to move + if (move === cell) { + newmove = `${move}<1`; // placement + } else { + newmove = `${move}>1-${cell}`; // movement + } + } else if (move.includes('<') && move.split(',').length === 1) { // it is a placement + const [c, n] = move.split(/[<]/); + if ( c === cell ) { // first cell is reclicked, add one more piece top stack + newmove = `${c}<${Number(n)+1}`; + } else { + newmove = `${move},${cell}`; // otherwise, the click was elsewhere, so now one wall is placed + } + } else if (move.includes('>') && move.split(',').length === 1) { // it is a movement + const commands = move.split(/[>-]/); + const n = Number(commands[1]); + if ( commands[2] === cell ) { + newmove = `${commands[0]}>${n+1}-${commands[2]}`; // click the destiny again to increase #pieces transfer + } else { + newmove = `${move},${cell}`; // otherwise, the click was elsewhere, so now one wall is placed + } + } else if (move.split(',').length === 2) { + newmove = `${move},${cell}`; // and now there is a second wall + } else { + throw new Error(); + } + + const result = this.validateMove(newmove) as IClickResult; + if (! result.valid) { + result.move = move; + } else { + result.move = 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}) + } + } + } + + /** + * Move type | Requirements + * --------------+------------------------------------------------------------------------------------------------- + * c1n-c2 | c1 friendly, n <= size(c1), c2 empty, c2 in circle(c1) + * c1>n-c2,w1 | c1 friendly, n <= size(c1), c2 empty, c2 in circle(c1), w1 empty and accessible + * c1>n-c2,w1,w2 | c1 friendly, n <= size(c1), c2 empty, c2 in circle(c1), w1 and w2 empty and accessible, w1 != w2 + */ + public validateMove(m: string): IValidationResult { + const result: IValidationResult = {valid: false, message: i18next.t("apgames:validation._general.DEFAULT_HANDLER")}; + + if (m.length === 0) { + result.valid = true; + result.complete = -1; + result.canrender = true; + result.message = i18next.t("apgames:validation.xana.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 initialCell = m.split(/[<>]/)[0]; + const hasEnemy = this.board.has(initialCell) && this.board.get(initialCell)![0] !== this.currplayer; + const hasFriend = this.board.has(initialCell) && this.board.get(initialCell)![0] === this.currplayer; + + if ( hasEnemy ) { + result.valid = false; + result.message = i18next.t("apgames:validation.xana.ENEMY_PIECE"); + return result; + } + + if ( m.includes('<') && this.reserve[this.currplayer - 1] === 0 ) { + result.valid = false; + result.message = i18next.t("apgames:validation.xana.RESERVE_EMPTY"); + return result; + } + + if ( !m.includes('<') && !m.includes('>') ) { + result.valid = true; + result.complete = -1; // player still needs to decide to place or move + result.canrender = true; + result.message = i18next.t("apgames:validation.xana.DROP_MOVE_INSTRUCTIONS"); + return result; + } + + const commands: string[] = m.split(','); + if ( commands.length > 3 ) { // something strange happened + result.valid = false; + result.message = i18next.t("apgames:validation._general.INVALID_MOVE", {m}); + return result; + } + + if ( m.includes('<') ) { + const isAccessible = this.reserve[this.currplayer-1] === RESERVE_SIZE || // no piece played yet + this.accessibleCells(this.currplayer).includes(initialCell); + const reserve = this.reserve[this.currplayer - 1]; // how many pieces are still off-board + const n = Number(commands[0].split(/[<]/)[1]); // get the amount of pieces to place + + if ( (!isAccessible && !hasFriend) || n > reserve ) { + result.valid = false; + if ( !isAccessible && !hasFriend ) { + result.message = i18next.t("apgames:validation.xana.UNACCESSIBLE_PIECE"); + } else { + result.message = i18next.t("apgames:validation.xana.RESERVE_EMPTY"); + } + return result; + } + } + + if ( m.includes('>') ) { + const n = Number(commands[0].split(/[>-]/)[1]); // get the amount of pieces to move + + if ( !hasFriend || n > this.board.get(initialCell)![1] ) { + result.valid = false; + if (! hasFriend ) { + result.message = i18next.t("apgames:validation.xana.NOT_PLACED_ON_FRIEND"); + } else { + result.message = i18next.t("apgames:validation.xana.NOT_ENOUGH_PIECES_TO_MOVE"); + } + return result; + } + + const finalCell = commands[0].split(/[>-]/)[2]; // get the cell where the n pieces will move to + if ( this.board.has(finalCell) ) { // pieces cannot move to an occupied cell + result.valid = false; + result.message = i18next.t("apgames:validation.xana.MOVE_TO_OCCUPIED_CELL"); + return result; + } + + if (! this.circle(initialCell).includes(finalCell) ) { + result.valid = false; + result.message = i18next.t("apgames:validation.xana.MOVE_NOT_INSIDE_CIRCLE"); + return result; + } + } + + if (commands.length > 1) { + // check walls: both cells must be empty and accessible to the current player + const walls = commands.length === 3 ? [commands[1], commands[2]] : [commands[1]]; + // check if the stack moved entirely out of its original cell (if so, a wall can be placed there) + let cellLeft = ""; + if ( m.includes('>') ) { + const n = Number(commands[0].split(/[>-]/)[1]); // get the amount of pieces that moved + if ( this.board.get(initialCell)![1] === n ) { // if stack 100% moved + cellLeft = initialCell; + } + } + + for (const wall of walls) { + const isAccessible = this.reserve[this.currplayer-1] === RESERVE_SIZE || // no piece played yet + this.accessibleCells(this.currplayer).includes(wall) || + wall === cellLeft; + if ( (this.board.has(wall) && cellLeft === "") || !isAccessible ) { + result.valid = false; + if ( this.board.has(wall) ) { + result.message = i18next.t("apgames:validation.xana.OCCUPIED_WALL"); + } else { + result.message = i18next.t("apgames:validation.xana.UNACCESSIBLE_WALL"); + } + return result; + } + } + if ( commands.length === 3 && commands[1] === commands[2]) { + result.valid = false; + result.message = i18next.t("apgames:validation.xana.SAME_WALL"); + return result; + } + } + + result.valid = true; + result.complete = commands.length === 3 ? 1 : 0; // complete only when both walls were placed + result.canrender = true; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + } + + public move(m: string, {partial = false, trusted = false} = {}): XanaGame { + 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) + } + } + + this.results = []; + const captures = []; // all the captures made in the turn + + if ( partial && !m.includes('<') && !m.includes('>') ) { + this._points = this.circle(m).map(c => this.graph.algebraic2coords(c)); + return this; + } else { + this._points = []; + } + + if (m === "pass") { + this.results.push({type: "pass"}); + } else { + const commands: string[] = m.split(','); + const initialCell: string = m.split(/[<>]/)[0]; + const initialN = this.board.has(initialCell) ? this.board.get(initialCell)![1] : 0; // current #pieces + const n = Number(commands[0].split(/[<>-]/)[1]); + + if ( m.includes('<') ) { + this.board.set(initialCell, [this.currplayer, initialN + n]); + this.reserve[this.currplayer - 1] -= n; + this.results.push({ type: "place", where: initialCell }); + } else { // m.includes('>') + const finalCell = commands[0].split(/[>-]/)[2]; // get the cell where the n pieces will move to + if ( initialN === n ) { + this.board.delete(initialCell); // the entire stack has moved + } else { + this.board.set(initialCell, [this.currplayer, initialN - n]); // the stack split + } + this.board.set(finalCell, [this.currplayer, n]); + this.results.push({ type: "move", from: initialCell, to: finalCell, count: n}); + } + + // before walls: check pieces without liberty and capture them + captures.push(...this.makeCaptures()); + if (captures.length > 0) { + this.results.push({ type: "capture", where: [...captures].join(), count: captures.length }); + } + + /////// place walls + if ( commands.length === 2 ) { // one wall + this.board.set(commands[1], [3 as playerid, 1]); + this.results.push({ type: "place", where: commands[1] }); + captures.push(...this.makeCaptures()); + if (captures.length > 0) { + this.results.push({ type: "capture", where: [...captures].join(), count: captures.length }); + } + } + + if ( commands.length === 3 ) { // two walls + this.board.set(commands[1], [3 as playerid, 1]); + this.results.push({ type: "place", where: commands[1] }); + captures.push(...this.makeCaptures()); + if (captures.length > 0) { + this.results.push({ type: "capture", where: [...captures].join(), count: captures.length }); + } + + this.board.set(commands[2], [3 as playerid, 1]); + this.results.push({ type: "place", where: commands[2] }); + captures.push(...this.makeCaptures()); + if (captures.length > 0) { + this.results.push({ type: "capture", where: [...captures].join(), count: captures.length }); + } + } + } + + if ( partial ) { return this; } + + // update currplayer + this.lastmove = m; + this.currplayer = this.currplayer % 2 + 1 as playerid; + this.scores = [this.getPlayerScore(1), this.getPlayerScore(2)]; + this.checkEOG(); + this.saveState(); + return this; + } + + //////////// Scoring and End-of-Game //////////// + + protected checkEOG(): XanaGame { + // game ends if two consecutive passes occurred + this.gameover = this.lastmove === "pass" && + this.stack[this.stack.length - 1].lastmove === "pass"; + + if (this.gameover) { + this.scores = [this.getPlayerScore(1), this.getPlayerScore(2)]; + this.winner = this.scores[0] > this.scores[1] ? [1] : [2]; + this.results.push( + {type: "eog"}, + {type: "winners", players: [...this.winner]} + ); + } + return this; + } + + 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 + } + return this.getTerritories() + .filter(t => t.owner === player) + .reduce((prev, curr) => prev + curr.cells.length, nPrisoners); + } + + // What pieces are adjacent to a given area? + public getAdjacentPieces(area: string[], pieces: string[]): string[] { + const result: string[] = []; + + for (const cell of pieces) { + for (const adj of this.graph.neighbours(cell)) { + if ( area.includes(adj) ) { // current piece is adjacent to area + result.push(cell); + break; + } + } + } + return result; + } + + /** + * Get all available territories (based in Asli/Plurality code) + * This is used in (1) computing scores, and (2) in the render process + */ + public getTerritories(): Territory[] { + 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]); + const walls = [...this.board.entries()].filter(e => e[1][0] === 3).map(e => e[0]); + const allPieces = [...p1Pieces, ...p2Pieces, ...walls]; + + // compute empty areas + const gEmpties = new HexTriGraph(BOARD_SIZE, 2*BOARD_SIZE-1); + for (const node of gEmpties.graph.nodes()) { + if (allPieces.includes(node)) { // remove intersections/nodes with pieces + gEmpties.graph.dropNode(node); + } + } + const emptyAreas : Array> = connectedComponents(gEmpties.graph); + + const territories: Territory[] = []; + for(const area of emptyAreas) { + let owner : playerid = 3; // default value: neutral area + // find who owns it + const p1AdjacentCells = this.getAdjacentPieces(area, p1Pieces); + const p2AdjacentCells = this.getAdjacentPieces(area, p2Pieces); + if (p1AdjacentCells.length > 0 && p2AdjacentCells.length == 0) { + owner = 1; + } + if (p1AdjacentCells.length == 0 && p2AdjacentCells.length > 0) { + owner = 2; + } + territories.push({cells: area, owner}); + } + return territories; + } + + ///////////////////////////////////////////////// + + public state(): IXanaState { + return { + game: XanaGame.gameinfo.uid, + numplayers: this.numplayers, + variants: this.variants, + gameover: this.gameover, + winner: [...this.winner], + stack: [...this.stack] + }; + } + + public moveState(): IMoveState { + return { + _version: XanaGame.gameinfo.version, + _results: [...this.results], + _timestamp: new Date(), + currplayer: this.currplayer, + lastmove: this.lastmove, + board: new Map(this.board), + scores: [...this.scores], + prisoners: [...this.prisoners], + reserve: [...this.reserve], + }; + } + + public render(): APRenderRep { + const pieces: string[][] = []; + for (const row of this.graph.listCells(true)) { + const nodes: string[] = []; + for (const cell of row) { + if (this.board.has(cell)) { + const [player, size] = this.board.get(cell)!; + if (player === 3) { + nodes.push("C"); + } else { + nodes.push(player===1 ? "A".repeat(size) : "B".repeat(size)); + } + } else { + nodes.push("-"); + } + } + pieces.push(nodes); + } + + // Build rep + const rep: APRenderRep = { + renderer: "stacking-offset", + board: { + style: "hex-of-hex", + minWidth: BOARD_SIZE, + maxWidth: 2*BOARD_SIZE - 1, + }, + legend: { + A: { name: "piece", colour: this.getPlayerColour(1) }, + B: { name: "piece", colour: this.getPlayerColour(2) }, + C: { name: "piece", colour: 1 }, // color 1 is red + }, + pieces: pieces.map(r => r.join(",")).join("\n"), + }; + + rep.annotations = []; + + if ( this.results.length > 0 ) { + for (const move of this.results) { + if (move.type === "place") { + const [x, y] = this.graph.algebraic2coords(move.where!); + rep.annotations.push({ type: "enter", targets: [{ row: y, col: x }] }); + } else if (move.type === "move") { + const [fromX, fromY] = this.graph.algebraic2coords(move.from); + const [toX, toY] = this.graph.algebraic2coords(move.to); + rep.annotations.push({type: "move", targets: [{row: fromY, col: fromX}, {row: toY, col: toX}]}); + } else if (move.type === "capture") { + for (const cell of move.where!.split(",")) { + const [x, y] = this.graph.algebraic2coords(cell); + rep.annotations.push({type: "exit", targets: [{row: y, col: x}]}); + } + } + } + } + + // add dots to define moving-range of current stack + if (this._points.length > 0) { + const points = []; + for (const [x,y] of this._points) { + points.push({row: y, col: x}); + } + rep.annotations.push({type: "dots", + targets: points as [{row: number; col: number;}, ...{row: number; col: number;}[]]}); + } + + // add territorial dots for area controlled by players + if (this.stack.length > 2) { + const territories = this.getTerritories(); + const markers: Array = [] + for (const t of territories) { + if (t.owner !== undefined && t.owner !== 3) { + const points = t.cells.map(c => this.graph.algebraic2coords(c)); + markers.push({type: "dots", + colour: this.getPlayerColour(t.owner), + points: points.map(p => { return {col: p[0], row: p[1]}; }) as [RowCol, ...RowCol[]]}); + } + } + if (markers.length > 0) { + (rep.board as BoardBasic).markers = markers; + } + } + + return rep; + } + + public getButtons(): ICustomButton[] { + return [{ label: "pass", move: "pass" }]; + } + + public getPlayerColour(p: playerid): Colourfuncs { + if (p === 1) { + return { + func: "custom", + default: 2, + palette: 1 + }; + } else { + return { + func: "custom", + default: "#FFD700", + palette: 2 + }; + } + } + + public sidebarStatuses(): IStatus[] { + const status = `Player 1: ${this.reserve[0]}\nPlayer 2: ${this.reserve[1]}` + return [{ key: i18next.t("apgames:status.xana.RESERVE"), + value: [status] } as IStatus]; + } + + public sidebarScores(): IScores[] { + return [{ name: i18next.t("apgames:status.SCORES"), + scores: [this.getPlayerScore(1), this.getPlayerScore(2)] }]; + } + + public clone(): XanaGame { + return new XanaGame(this.serialize()); + } +}