From 3bb762d6c9c3f233fd803fb6af7f0733e4848e06 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Neto?=
<886455+jpneto@users.noreply.github.com>
Date: Fri, 12 Jun 2026 16:42:02 +0100
Subject: [PATCH] Added Akimbo
---
locales/en/apgames.json | 27 +-
locales/en/apresults.json | 1 +
src/games/akimbo.ts | 570 ++++++++++++++++++++++++++++++++++++++
src/games/compart.ts | 3 +
src/games/index.ts | 8 +-
5 files changed, 606 insertions(+), 3 deletions(-)
create mode 100644 src/games/akimbo.ts
diff --git a/locales/en/apgames.json b/locales/en/apgames.json
index ce5063a4..4564be8f 100644
--- a/locales/en/apgames.json
+++ b/locales/en/apgames.json
@@ -4,6 +4,7 @@
"accasta": "Accasta is the first of Dieter Stein's stacking trilogy. The goal is to get three of your own stacks into the enemy's castle area. There are three different types of pieces in the base version. In the \"Pari\" variant, movement is determined by how many friendly pieces are in the stack.",
"acity": "Alien City is a combination Piecepack/Icehouse game about building a city on a newly colonized world. Four guilds compete for customers, and the player has to predict which bets are going to pay off. Each turn, the player places either a tower or dome and may optionally claim a tower they think is going to score big when the game ends. Play continues until no more construction is possible. Scores are based on the relative positions of buildings to claimed towers.",
"agere": "A connection game where you can also stack pieces. Played either on a triangle board where you try to connect all three sides, or a circular \"cobweb\" board where you connect opposite sides.",
+ "akimbo": "A minimalist connection game where it is forbidden to have more than one naked diagonal.",
"akron": "Akron is an extension of standard connection games to three dimensions. On their turn, players may introduce new balls of their colour on the lowest layer of the board, or move a ball that is not pinned (having balls resting on it in two or more directions) to a space that is connected to the same group both before and after movement. When balls move, balls resting on it will drop, changing the connections on the board.",
"almatafl": "AlmaTafl is an asymmetrical game where one side plays the defenders trying to get the king to safety, while the other side tries to capture the king or render him defenseless.",
"alta": "Alta is a connection game played with switches on a diamond-shaped board. Connect your two sides with a path of switches of either player. On your turn, place a new switch or toggle one of your switches to change the orientation.",
@@ -266,6 +267,7 @@
"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."
},
"notes": {
+ "akimbo": "Akimbo, designed by Luis Bolaños Mures in 2026, is a drawless connection game for two players. A _naked diagonal_ is a pair of like-colored, diagonally adjacent stones with no other like-colored stone adjacent to both. A _crosscut_ is a 2×2 area with two interlocking naked diagonals of opposite colors.\n\nOn your turn, place a friendly stone on an empty point. If this completes a crosscut, remove your other stone in the crosscut. There must never be more than one naked diagonal of each color on the board — not even momentarily before removing a stone. You win if, at the end of your turn, there is a chain of orthogonally connected stones of your color touching the two opposite board edges of your color.",
"alta": "There are two ways to place switches on the graphical interface: (1) Select the switch’s orientation in the panel, then click on the desired space on the board to place it, or (2) click on two vertices. The vertices are notated with an asterisk, followed by the algebraic notation of the space where the vertex is at the bottom-left corner. To toggle a switch, simply click on the space.",
"anache": "This implementation follows David Ploog's rulesheet. Three notable changes are that (1) barriers are forbidden, (2) instead of a 16x16, we have a 15x15 variant, where pieces promote to knights on the centreline, and the dragon may not jump to the 7x7 space from the other corner, and (3) against the corners, pieces are captured via crushing capture instead of custodian capture, so there needs to be at least two opponent pieces in a line before a capture is made against the corners.\n\nNote: the stalemate check is expensive. If you cannot make a legal move, you should resign manually.",
"arimaa": "Made available under section 3 of the [Arimaa public license](https://arimaa.com/arimaa/license/).\n\nHarlog is an accepted method for determining material advantage. Positive scores favour Gold, negative Silver. The hard range is ±112, but in practice ±20 is a very strong advantage.\n\nBecause we can't generate comprehensive lists of moves, the system cannot detect the rare cases where your only available moves are illegal due to position repetition. The system won't let you make those illegal repetitions, and you'll have to resign manually.",
@@ -338,7 +340,7 @@
"tbt": "When it's your turn, you will see the die you have to work with, but once your move is complete, the die will reroll. Exploration is not helpful because the die roll is not finalized until after the move is submitted. As you scroll back through the game history, the die you see is for the *next* turn. The die used to make the move you're seeing is displayed below the board.",
"terrace": "The Assassination variant is described in [this BGG thread](https://boardgamegeek.com/thread/551125/variant-for-more-aggressive-less-drawish-play), summarized below:\n\n- Up straight, you must be larger or a smallest-size piece can assassinate a largest-size piece.\n\n- Same level (orthogonally adjacent only), you must be at least the same size.\n\n- Down diagonal, you may be one rank smaller.",
"toguz": "Depicting state changes in sowing games is challenging. The initial chosen pit is marked, as is any capture. Small numbers appear to show the change in the number of stones in each pit. If you believe you have encountered a bug, please let us know in Discord.",
- "tricouleur": "Tricouleur is a 2005 game designed by Bill Taylor. Each player has a army of three types of pieces that are able to capture the next type, in a cyclic way: think Rock-Paper-Scissors. The move sequence is 12* where a player must use different pieces (it is also possible to pass the entire turn). A piece may move to an empty neighboring cell, in which case it leaves an unmoved duplicate behind; or to an empty cell among the twelve 2nd-neighbours, regardless of what is in between.\n\nAny weaker opponent-pieces that the moved piece becomes adjacent to, are recolored as the moving piece. Any same-strength opponent-pieces that the moved piece becomes adjacent to, are removed from the board. When the board is full, or both pass consecutively, the game ends, and whoever has the most pieces of their own colours in total, wins.",
+ "tricouleur": "Tricouleur is a 2005 game designed by Bill Taylor. Each player has a army of three types of pieces that are able to capture the next type, in a cyclic way: think Rock-Paper-Scissors. The move sequence is 12* where a player must use different pieces (it is also possible to pass the entire turn). A piece may move to an empty neighboring cell, in which case it leaves an unmoved duplicate behind; or to an empty cell among the twelve 2nd-neighbours, regardless of what is in between. Any weaker opponent-pieces that the moved piece becomes adjacent to, are recolored as the moving piece. Any same-strength opponent-pieces that the moved piece becomes adjacent to, are removed from the board.\n\nThe game ends when either: (a) both players pass consecutively, (b) the board is full, (c) one army is destroyed, (d) a position is repeated three times. Whoever has the most pieces of their own colours in total, wins.",
"tumbleweed": "A space is claimed by a player if they have a piece on it, or if they have the majority of the line of sights to it. The score is the number of territory own by each player. The game ends when both players pass in succession. If there is no change in score for 20 plies, the game also ends.",
"twinflames": "Twin Flames is a 2026 redesign of the 2013's Product, an original concept by Nick Bentley. The first player must initially place a friendly stone, preventing overly strong opening countermoves and thereby eliminating the need for a pie rule. The remaining moves use a 12* sequence, in which players may place stones of either colour. At the end of the game, a player who has formed a single connected group scores points equal to the size of that group. Another ludeme introduced was the use of walls, or blockers, which reduce overall connectivity and make groups more robust to enemy attacks. The blocker configuration is randomized, increasing game variability and making opening theory more difficult to develop.",
"twixt": "The notation is based on Hansel notation at . Some modifications are that link removal specifically specifies the link direction, and commas separate the moves. To add/remove links, click on the pegs between them. You can also remove a link by clicking on the line itself.",
@@ -406,6 +408,23 @@
"name": "Triangle (14 wide)"
}
},
+ "akimbo": {
+ "size-11": {
+ "name": "11x11 board"
+ },
+ "size-13": {
+ "name": "13x13 board"
+ },
+ "#board": {
+ "name": "15x15 board"
+ },
+ "size-17": {
+ "name": "17x17 board"
+ },
+ "size-19": {
+ "name": "19x19 board"
+ }
+ },
"akron": {
"#board": {
"name": "size-9 board"
@@ -4251,6 +4270,12 @@
"PARTIAL": "Select where you want to move the piece.",
"SAME_HEIGHT": "Pieces may only move between stacks of the same height."
},
+ "akimbo": {
+ "INSTRUCTIONS": "Select a point to place a piece. Each player can only have one naked diagonal.",
+ "OCCUPIED": "Select an empty cell to place a friendly piece.",
+ "EXCESS_NAKED_DIAGONALS": "More than one naked diagonal would be created.",
+ "NO_CROSSINGS": "Lines may not cross."
+ },
"akron": {
"CANNOT_MOVE": "The selected ball at {{where}} cannot be moved.",
"CANNOT_PLACE": "{{where}} is not a valid place to put a ball.",
diff --git a/locales/en/apresults.json b/locales/en/apresults.json
index ee3238e2..c7929126 100644
--- a/locales/en/apresults.json
+++ b/locales/en/apresults.json
@@ -519,6 +519,7 @@
"nowhat_other": "{{count}} pieces were reclaimed."
},
"REMOVE": {
+ "akimbo": "{{player}} removes {{where}} due to the formation of a crosscut.",
"entrapment_opponent": "{{player}} chose to retain the opponent's roamer at {{how}}, removing the roamer at {{where}}.",
"entrapment_self": "{{player}} chose to retain their own roamer at {{how}}, removing the roamer at {{where}}.",
"fourinarow_e": "{{player}} cleared a line, shifting the pieces on the board rightwards.",
diff --git a/src/games/akimbo.ts b/src/games/akimbo.ts
new file mode 100644
index 00000000..ea2beb5a
--- /dev/null
+++ b/src/games/akimbo.ts
@@ -0,0 +1,570 @@
+import { GameBase, IAPGameState, IClickResult, IIndividualState, IValidationResult } from "./_base";
+import { APGamesInformation } from "../schemas/gameinfo";
+import { APRenderRep, MarkerEdge } from "@abstractplay/renderer/src/schemas/schema";
+import { APMoveResult } from "../schemas/moveresults";
+import { Direction, RectGrid, reviver, UserFacingError } from "../common";
+import { UndirectedGraph } from "graphology";
+import { bidirectional } from "graphology-shortest-path/unweighted";
+import i18next from "i18next";
+
+export type playerid = 1 | 2 | 3 | 4; // 3 represents empty cell; 4 is off-board
+
+export interface IMoveState extends IIndividualState {
+ currplayer: playerid;
+ board: Map;
+ connPath: string[];
+ lastmove?: string;
+ nakedDiagonalP1: string[];
+ nakedDiagonalP2: string[];
+};
+
+export interface IAkimboState extends IAPGameState {
+ winner: playerid[];
+ stack: Array;
+};
+
+type PlayerLines = [string[],string[]];
+
+export class AkimboGame extends GameBase {
+ public static readonly gameinfo: APGamesInformation = {
+ name: "Akimbo",
+ uid: "akimbo",
+ playercounts: [2],
+ version: "20260613",
+ dateAdded: "2023-06-13",
+ // i18next.t("apgames:descriptions.akimbo")
+ description: "apgames:descriptions.akimbo",
+ notes: "apgames:notes.akimbo",
+ urls: [
+ "https://boardgamegeek.com/boardgame/466041/akimbo",
+ ],
+ people: [
+ {
+ type: "designer",
+ name: "Luis Bolaños Mures",
+ urls: ["https://boardgamegeek.com/boardgamedesigner/47001/luis-bolanos-mures"],
+ apid: "6b518a3f-7f63-47b8-b92b-a04792fba8e7",
+ },
+ {
+ 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-11", group: "board" },
+ { uid: "size-13", group: "board" },
+ { uid: "#board", }, // 15x15
+ { uid: "size-17", group: "board" },
+ { uid: "size-19", group: "board" },
+ ],
+ categories: ["goal>connect", "mechanic>place", "board>shape>rect", "board>connect>rect", "components>simple>1per"],
+ flags: ["pie", "experimental"]
+ };
+
+ public numplayers = 2;
+ public currplayer: playerid = 1;
+ public board!: Map;
+ public connPath: string[] = [];
+ public gameover = false;
+ public winner: playerid[] = [];
+ public variants: string[] = [];
+ public stack!: Array;
+ public results: Array = [];
+ public nakedDiagonalP1: string[] = [];
+ public nakedDiagonalP2: string[] = [];
+ private boardSize = 0;
+ private lines: [PlayerLines, PlayerLines];
+
+ constructor(state?: IAkimboState | string, variants?: string[]) {
+ super();
+ if (state === undefined) {
+ if (variants !== undefined) {
+ this.variants = [...variants];
+ }
+ const board = new Map();
+ const fresh: IMoveState = {
+ _version: AkimboGame.gameinfo.version,
+ _results: [],
+ _timestamp: new Date(),
+ currplayer: 1,
+ board,
+ connPath: [],
+ nakedDiagonalP1: [],
+ nakedDiagonalP2: [],
+ };
+ this.stack = [fresh];
+ } else {
+ if (typeof state === "string") {
+ state = JSON.parse(state, reviver) as IAkimboState;
+ }
+ if (state.game !== AkimboGame.gameinfo.uid) {
+ throw new Error(`The Akimbo 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();
+ this.lines = this.getLines();
+ }
+
+ public load(idx = -1): AkimboGame {
+ 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.connPath = [...state.connPath];
+ this.boardSize = this.getBoardSize();
+ this.results = [...state._results];
+ this.nakedDiagonalP1 = [...state.nakedDiagonalP1];
+ this.nakedDiagonalP2 = [...state.nakedDiagonalP2];
+ return this;
+ }
+
+ 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);
+ }
+
+ private getLines(): [PlayerLines,PlayerLines] {
+ const lineN: string[] = [];
+ const lineS: string[] = [];
+ for (let x = 0; x < this.boardSize; x++) {
+ const N = this.coords2algebraic(x, 0);
+ const S = this.coords2algebraic(x, this.boardSize - 1);
+ lineN.push(N);
+ lineS.push(S);
+ }
+ const lineE: string[] = [];
+ const lineW: string[] = [];
+ for (let y = 0; y < this.boardSize; y++) {
+ const E = this.coords2algebraic(this.boardSize-1, y);
+ const W = this.coords2algebraic(0, y);
+ lineE.push(E);
+ lineW.push(W);
+ }
+ return [[lineN, lineS], [lineE, lineW]];
+ }
+
+ 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 15;
+ }
+
+ // returns [p1,p2,p3] where `p1` is the player at its `dir1`, `p2` at its `dir2`,
+ // and `p3` at the diagonal of both directions
+ // returns 3 anytime a cell is empty, or 4 if the 'cell' is off-board
+ // requires: dir1 in [N,S] && dir2 in [E,W]
+ private checkDiagonal(cell: string, dir1: Direction, dir2: Direction): [playerid,playerid,playerid] {
+ const [x,y] = this.algebraic2coords(cell);
+ const g = new RectGrid(this.boardSize, this.boardSize);
+ let p1: playerid, p2: playerid, p3: playerid;
+
+ let ray = g.ray(x, y, dir1).map(n => this.coords2algebraic(...n));
+ if (ray.length > 0) {
+ p1 = this.board.has(ray[0]) ? this.board.get(ray[0])! : 3;
+ } else {
+ p1 = 4;
+ };
+
+ ray = g.ray(x, y, dir2).map(n => this.coords2algebraic(...n));
+ if (ray.length > 0) {
+ p2 = this.board.has(ray[0]) ? this.board.get(ray[0])! : 3;
+ } else {
+ p2 = 4;
+ };
+
+ ray = g.ray(x, y, (dir1+dir2) as Direction).map(n => this.coords2algebraic(...n));
+ if (ray.length > 0) {
+ p3 = this.board.has(ray[0]) ? this.board.get(ray[0])! : 3;
+ } else {
+ p3 = 4;
+ };
+
+ return [p1, p2, p3];
+ }
+
+ // check if placing at stone at `cell` creates a naked diagonal for the current player
+ // def: a naked diagonal is a pair of like-colored, diagonally adjacent stones with no other
+ // like-colored stone adjacent to both
+ private numNakedDiagonals(cell: string): number {
+ const dirs: [Direction, Direction][] = [["N","E"],["S","E"],["S","W"],["N","W"]];
+ let nNaked = 0;
+
+ for (const [dir1, dir2] of dirs) {
+ const [p1, p2, p3] = this.checkDiagonal(cell, dir1, dir2);
+ if ( p1 === 4 || p2 === 4 ) { continue; }
+ if ( p1 !== this.currplayer && p2 !== this.currplayer && p3 === this.currplayer ) {
+ nNaked += 1;
+ }
+ }
+ return nNaked;
+ }
+
+ // returns the friendly stone that makes a naked diagonal with `cell`
+ // requires: numNakedDiagonals(cell) === 1
+ private pairNakedDiagonal(cell: string): string {
+ const dirs: [Direction, Direction][] = [["N","E"],["S","E"],["S","W"],["N","W"]];
+ const g = new RectGrid(this.boardSize, this.boardSize);
+ const [x,y] = this.algebraic2coords(cell);
+
+ for (const [dir1, dir2] of dirs) {
+ const [p1, p2, p3] = this.checkDiagonal(cell, dir1, dir2);
+ if ( p1 === 4 || p2 === 4 ) { continue; }
+ if ( p1 !== this.currplayer && p2 !== this.currplayer && p3 === this.currplayer ) {
+ return this.coords2algebraic(...g.ray(x, y, (dir1+dir2) as Direction)[0]);
+ }
+ }
+ throw new Error(`Could not determine the naked diagonal of ${cell}"`);
+ }
+
+ // check if placing at stone at `cell` creates a crosscut for the current player
+ // def: a crosscut is a 2×2 area with two interlocking naked diagonals of opposite colors
+ private isCrosscut(cell: string): boolean {
+ const dirs: [Direction, Direction][] = [["N","E"],["S","E"],["S","W"],["N","W"]];
+ const prevplayer = this.currplayer % 2 + 1 as playerid;
+
+ for (const [dir1, dir2] of dirs) {
+ const [p1, p2, p3] = this.checkDiagonal(cell, dir1, dir2);
+ if ( p1 === 4 || p2 === 4 ) { continue; }
+ if ( p1 === prevplayer && p2 === prevplayer && p3 === this.currplayer ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public handleClick(move: string, row: number, col: number, piece?: string): IClickResult {
+ try {
+ const cell = this.coords2algebraic(col, row);
+ const result = this.validateMove(cell) as IClickResult;
+ result.move = result.valid ? cell : move;
+ 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.complete = -1;
+ result.message = i18next.t("apgames:validation.akimbo.INSTRUCTIONS")
+ return result;
+ }
+
+ m = m.toLowerCase();
+ m = m.replace(/\s+/g, "");
+
+ try { // check if cell is valid
+ this.algebraic2coords(m);
+ } catch {
+ result.valid = false;
+ result.message = i18next.t("apgames:validation._general.INVALID_MOVE", {move: m});
+ return result;
+ }
+
+ if ( this.board.has(m) ) {
+ result.valid = false;
+ result.message = i18next.t("apgames:validation.compart.OCCUPIED");
+ return result;
+ }
+
+ const prevNaked = this.currplayer === 1 ? this.nakedDiagonalP1 : this.nakedDiagonalP2;
+ const wasNaked = prevNaked.length > 0;
+
+ this.board.set(m, this.currplayer); // simulate placement
+ const continuesNaked = wasNaked && this.numNakedDiagonals(prevNaked[0]) > 0;
+ const newNakeds = this.numNakedDiagonals(m);
+ this.board.delete(m); // undo placement
+
+ if ( continuesNaked && newNakeds > 0 || newNakeds > 1 ) {
+ result.valid = false;
+ result.message = i18next.t("apgames:validation.akimbo.EXCESS_NAKED_DIAGONALS");
+ return result;
+ }
+
+ result.valid = true;
+ result.complete = 1;
+ result.message = i18next.t("apgames:validation._general.VALID_MOVE");
+ return result;
+ }
+
+ public move(m: string, {trusted = false} = {}): AkimboGame {
+ if (this.gameover) {
+ throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:MOVES_GAMEOVER"));
+ }
+
+ if (m.length === 0) { return this; }
+
+ m = m.toLowerCase();
+ m = m.replace(/\s+/g, "");
+ this.results = [];
+
+ if (! trusted) {
+ const result = this.validateMove(m);
+ if (! result.valid) { throw new UserFacingError("VALIDATION_GENERAL", result.message) }
+ }
+
+ this.board.set(m, this.currplayer);
+ this.results.push( {type: "place", where: m} );
+
+ const prevNaked = this.currplayer === 1 ? this.nakedDiagonalP1 : this.nakedDiagonalP2;
+ let isNaked = prevNaked.length > 0;
+
+ if ( isNaked ) { // a naked diagonal existed
+ if ( this.numNakedDiagonals(prevNaked[0]) === 0 ) { // but not anymore
+ isNaked = this.numNakedDiagonals(m) > 0; // check if 'm' created a new naked diagonal
+ }
+ }
+
+ if ( !isNaked ) { // remove naked diagonal
+ if ( this.currplayer === 1 ) {
+ this.nakedDiagonalP1 = [];
+ } else {
+ this.nakedDiagonalP2 = [];
+ }
+ }
+
+ if ( this.numNakedDiagonals(m) > 0 ) {
+ if ( this.currplayer === 1 ) {
+ this.nakedDiagonalP1 = [m, this.pairNakedDiagonal(m)];
+ } else {
+ this.nakedDiagonalP2 = [m, this.pairNakedDiagonal(m)];
+ }
+ }
+
+ if ( this.isCrosscut(m) ) {
+ // m and its pair are the one and only naked diagonal of current player
+ // so the naked diagonal disappears, since its pair is about to be removed
+ if ( this.currplayer === 1 ) {
+ this.nakedDiagonalP1 = [];
+ } else {
+ this.nakedDiagonalP2 = [];
+ }
+
+ const toDelete = this.pairNakedDiagonal(m);
+ this.board.delete(toDelete);
+ this.results.push( {type: "remove", where: toDelete} );
+ }
+
+ this.lastmove = m;
+ this.currplayer = this.currplayer % 2 + 1 as playerid;
+ this.checkEOG();
+ this.saveState();
+ return this;
+ }
+
+ private buildGraph(player: playerid): UndirectedGraph {
+ const grid = new RectGrid(this.boardSize, this.boardSize);
+ const graph = new UndirectedGraph();
+ // seed nodes
+ [...this.board.entries()].filter(([,p]) => p === player).forEach(([cell,]) => {
+ graph.addNode(cell);
+ });
+ // for each node, check neighbours
+ // if any are in the graph, add an edge
+ for (const node of graph.nodes()) {
+ const [x,y] = this.algebraic2coords(node);
+ const neighbours = grid.adjacencies(x,y,false).map(n => this.coords2algebraic(...n));
+ for (const n of neighbours) {
+ if ( (graph.hasNode(n)) && (! graph.hasEdge(node, n)) ) {
+ graph.addEdge(node, n);
+ }
+ }
+ }
+ return graph;
+ }
+
+ protected checkEOG(): AkimboGame {
+ const prevplayer = this.currplayer % 2 + 1 as playerid;
+ const graph = this.buildGraph(prevplayer);
+ const [sources, targets] = this.lines[prevplayer - 1];
+
+ for (const source of sources) {
+ for (const target of targets) {
+ if ( (graph.hasNode(source)) && (graph.hasNode(target)) ) {
+ const path = bidirectional(graph, source, target);
+ if (path !== null) {
+ this.gameover = true;
+ this.winner = [prevplayer];
+ this.connPath = [...path];
+ break;
+ }
+ }
+ }
+ if (this.gameover) { break; }
+ }
+
+ if (this.gameover) {
+ this.results.push( {type: "eog"},
+ {type: "winners", players: [...this.winner]} );
+ }
+ return this;
+ }
+
+ public render(): APRenderRep {
+ // Build piece string
+ let pstr = "";
+ for (let row = 0; row < this.boardSize; row++) {
+ if (pstr.length > 0) { pstr += "\n"; }
+ const pieces: string[] = [];
+ for (let col = 0; col < this.boardSize; col++) {
+ const cell = this.coords2algebraic(col, row);
+ if (this.board.has(cell)) {
+ const contents = this.board.get(cell)!;
+ if (contents === 1) {
+ pieces.push("A");
+ } else {
+ pieces.push("B");
+ }
+ } else {
+ pieces.push("-");
+ }
+ }
+ pstr += pieces.join("");
+ }
+ pstr = pstr.replace(new RegExp(`-{${this.boardSize}}`, "g"), "_");
+ const markers: Array = [
+ { type:"edge", edge: "N", colour: 1 },
+ { type:"edge", edge: "S", colour: 1 },
+ { type:"edge", edge: "E", colour: 2 },
+ { type:"edge", edge: "W", colour: 2 },
+ ];
+
+ // Build rep
+ const rep: APRenderRep = {
+ board: {
+ style: "vertex",
+ width: this.boardSize,
+ height: this.boardSize,
+ markers,
+ },
+ legend: {
+ A: { name: "piece", colour: 1 },
+ B: { name: "piece", colour: 2 }
+ },
+ pieces: pstr
+ };
+
+ // Add annotations
+ 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}]});
+ }
+ if (move.type === "remove") {
+ const [x, y] = this.algebraic2coords(move.where!);
+ rep.annotations.push({type: "exit", targets: [{row: y, col: x}]});
+ }
+ }
+
+ if (this.connPath.length > 0) {
+ type RowCol = {row: number; col: number;};
+ const targets: RowCol[] = [];
+ for (const cell of this.connPath) {
+ const [x,y] = this.algebraic2coords(cell);
+ targets.push({row: y, col: x}) ;
+ }
+ rep.annotations.push({type: "move", targets: targets as [RowCol, ...RowCol[]], arrow: false});
+ }
+ }
+
+ // render naked diagonals
+ if ( this.nakedDiagonalP1.length > 0 ) {
+ type RowCol = {row: number; col: number;};
+ const targets: RowCol[] = [];
+ for (const cell of this.nakedDiagonalP1) {
+ const [x,y] = this.algebraic2coords(cell);
+ targets.push({row: y, col: x}) ;
+ }
+ rep.annotations.push({type: "move", targets: targets as [RowCol, ...RowCol[]], arrow: false});
+ }
+
+ if ( this.nakedDiagonalP2.length > 0 ) {
+ type RowCol = {row: number; col: number;};
+ const targets: RowCol[] = [];
+ for (const cell of this.nakedDiagonalP2) {
+ const [x,y] = this.algebraic2coords(cell);
+ targets.push({row: y, col: x}) ;
+ }
+ rep.annotations.push({type: "move", targets: targets as [RowCol, ...RowCol[]], arrow: false});
+ }
+
+ return rep;
+ }
+
+ public state(): IAkimboState {
+ return {
+ game: AkimboGame.gameinfo.uid,
+ numplayers: this.numplayers,
+ variants: this.variants,
+ gameover: this.gameover,
+ winner: [...this.winner],
+ stack: [...this.stack]
+ };
+ }
+
+ public moveState(): IMoveState {
+ return {
+ _version: AkimboGame.gameinfo.version,
+ _results: [...this.results],
+ _timestamp: new Date(),
+ currplayer: this.currplayer,
+ lastmove: this.lastmove,
+ board: new Map(this.board),
+ connPath: [...this.connPath],
+ nakedDiagonalP1: [...this.nakedDiagonalP1],
+ nakedDiagonalP2: [...this.nakedDiagonalP2],
+ };
+ }
+
+ public chat(node: string[], player: string, results: APMoveResult[], r: APMoveResult): boolean {
+ let resolved = false;
+ switch (r.type) {
+ case "remove":
+ node.push(i18next.t("apresults:REMOVE.akimbo", { player, where: r.where }));
+ resolved = true;
+ break;
+ case "eog":
+ node.push(i18next.t("apresults:EOG.default"));
+ resolved = true;
+ break;
+ }
+ return resolved;
+ }
+
+ public clone(): AkimboGame {
+ return new AkimboGame(this.serialize());
+ }
+}
diff --git a/src/games/compart.ts b/src/games/compart.ts
index 3d74e1d3..1c571d85 100644
--- a/src/games/compart.ts
+++ b/src/games/compart.ts
@@ -222,6 +222,9 @@ export class CompartGame extends GameBase {
return result;
}
+ m = m.toLowerCase();
+ m = m.replace(/\s+/g, "");
+
const moves = m.split(',');
try { // check if cells are valid
diff --git a/src/games/index.ts b/src/games/index.ts
index a813789e..95b5c7b2 100644
--- a/src/games/index.ts
+++ b/src/games/index.ts
@@ -263,6 +263,7 @@ import { PositGame, IPositState } from "./posit";
import { VirusWarGame, IVirusWarState } from "./viruswar";
import { FormsGame, IFormsState } from "./forms";
import { CompartGame, ICompartState } from "./compart";
+import { AkimboGame, IAkimboState } from "./akimbo";
export {
APGamesInformation, GameBase, GameBaseSimultaneous, IAPGameState,
@@ -529,6 +530,7 @@ export {
VirusWarGame, IVirusWarState,
FormsGame, IFormsState,
CompartGame, ICompartState,
+ AkimboGame, IAkimboState,
};
const games = new Map();
// Manually add each game to the following array
[
@@ -660,7 +662,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.");
@@ -1198,6 +1200,8 @@ export const GameFactory = (game: string, ...args: any[]): GameBase|GameBaseSimu
return new FormsGame(...args);
case "compart":
return new CompartGame(...args);
+ case "akimbo":
+ return new AkimboGame(...args);
}
return;
}