From 103a0e11f521d9d7b560215480299f2d46a37d79 Mon Sep 17 00:00:00 2001 From: samtcifihi <565455483@protonmail.com> Date: Fri, 18 Sep 2026 20:17:10 +0000 Subject: [PATCH 1/2] Arimaa: fewer clicks during setup Two independent input conveniences for the setup phase. In standard setup, a player now only has to place their eight non-rabbits. Once those are down, the setup validates as submittable (so Complete Move appears) and the submitted move is topped up with rabbits on the remaining empty cells of the player's setup area. Rabbits placed by hand are left alone, so anyone who wants a rabbit somewhere in particular can still put it there. The setup advice (balance, hiding, elephant file) is evaluated against the filled-in setup rather than the partial one. In free setup, clicking an empty cell with nothing selected now places a rabbit instead of an elephant. The free hand never empties, so "strongest piece remaining" was always the elephant there, which meant two clicks per rabbit. A 16-piece free setup with one elephant drops from 31 clicks to 17. Standard setup is unchanged in this respect and still offers the strongest piece still in hand, which now pairs with the autofill above: EMHHDDCC in eight clicks. The expansion is a no-op on a setup that is already complete, and runs before anything is recorded, so stored notation stays fully explicit and existing games replay identically. No version bump. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QDibMmzgf18ky9fiCRW5cL --- locales/en/apgames.json | 3 +- src/games/arimaa.ts | 73 ++++++++++++++++++++++++++++--- test/games/arimaa.test.ts | 92 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 6 deletions(-) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 8a8786dc0..000f31725 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -578,7 +578,7 @@ "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 pieces with no other like-colored piece adjacent to both. A _crosscut_ is a 2×2 area with two interlocking naked diagonals of opposite colors.\n\nOn their turn, the player places a friendly piece on an empty cell. If this completes a crosscut, the other piece in the crosscut is removed. There must never be more than one naked diagonal of each color on the board — not even momentarily before removing a piece. A player wins if, at the end of their turn, there is a chain of orthogonally connected pieces of their color touching the player's two opposite board edges.\n\nOkimba is a variant where only up to one naked diagonal can exist on the board (so while one player has the diagonal, the adversary cannot create another).", "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.", + "arimaa": "Made available under section 3 of the [Arimaa public license](https://arimaa.com/arimaa/license/).\n\nDuring standard setup, you only need to place your eight non-rabbits. Once they are down, Complete Move fills the rest of your setup area with rabbits. You can still place any or all of the rabbits yourself first if you want them somewhere in particular.\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.", "armadas": "There are known issues with this game on iOS devices.\n\nThis game offers two scenarios:\n\n* The default is each player having two trios of pieces. In the placement phase, each player must place 1 to 3 ships until all ships are placed.\n* The \"Freeform\" variant allows you to place whatever ships you want (maximum of three at a time) until both players \"pass.\" This lets you create unbalanced or otherwise asymmetrical fleets.\n\nGames default to having one island in the centre of the field. Both a \"no islands\" and a \"two islands\" variant are available. You cannot move or fire through islands. To successfully hit a ship, at least one corner of your ship's triangle must have a clear view of at least one corner of the target ship.\n\nRemember that it is possible to get your ship in a position against an island or the edge of the board such that you can no longer move that ship! Be careful!", "ataxx": "On three-fold repetition, the game ends and the scores are calculated.", "bao": "Moves in Bao can be very complex, involving multiple laps around the board and changing directions. The annotations are, therefore, sparse. The initial cell and direction are highlighted, and captured cells are also marked. But detailed annotation of movement is not possible. If you believe you have encountered a bug, please let us know in Discord.", @@ -5140,6 +5140,7 @@ "PARTIAL_PLACE": "Click an empty cell to place the piece.", "PARTIAL_PLAY": "Continue placing your pieces.", "PARTIAL_PUSH": "Complete the push by clicking on the piece doing the pushing.", + "PARTIAL_RABBITS": "The rest of your setup area will be filled with rabbits.", "PARTIAL_FREE": "You may continue placing pieces, or click Complete Move to end your turn.", "PARTIAL_FREE_NO": "You may continue placing pieces, but you must place at least one rabbit, and you may not place any rabbits on the goal row.", "REPEAT": "You may not repeat a given position a third time.", diff --git a/src/games/arimaa.ts b/src/games/arimaa.ts index 57be5b824..75c299239 100644 --- a/src/games/arimaa.ts +++ b/src/games/arimaa.ts @@ -127,6 +127,16 @@ export class ArimaaGame extends GameBase { const str = "EMHDCR"; return str.length - str.indexOf(piece); } + // the two ranks a player sets up on, in the order they get auto-filled + private static homeCells(player: playerid): string[] { + const cells: string[] = []; + for (const row of (player === 1 ? [6,7] : [0,1])) { + for (let col = 0; col < 8; col++) { + cells.push(ArimaaGame.coords2algebraic(col, row)); + } + } + return cells; + } public static EEE(): {gold: [Piece, string][], silver: [Piece, string][]} { const getRanks = (ranks: number[]): string[] => { const cells: string[] = []; @@ -326,6 +336,35 @@ export class ArimaaGame extends GameBase { return []; } + // In standard setup, a player only needs to place their eight non-rabbits. + // Once they have, the rest of their setup area gets filled with rabbits. + // Returns the move untouched in every other circumstance. + private fillRabbits(m: string): string { + if (this.variants.length > 0 || this.hands === undefined || this.hands[this.currplayer - 1].length === 0) { + return m; + } + const mvs = m.split(",").filter(Boolean); + const steps = mvs.map(mv => ArimaaGame.baseMove(mv)); + const myhand = [...this.hands[this.currplayer - 1]]; + for (const [pc, , cell] of steps) { + // a dangling piece selection or anything the validator will reject + if (cell === undefined || !myhand.includes(pc)) { + return m; + } + myhand.splice(myhand.indexOf(pc), 1); + } + // everything but the rabbits has to be placed already + if (myhand.length === 0 || myhand.some(pc => pc !== "R")) { + return m; + } + const placedCells = new Set(steps.map(([,,cell,]) => cell!)); + const empty = ArimaaGame.homeCells(this.currplayer).filter(cell => !this.board.has(cell) && !placedCells.has(cell)); + if (empty.length !== myhand.length) { + return m; + } + return [...mvs, ...empty.map(cell => `${this.currplayer === 1 ? "R" : "r"}${cell}`)].join(","); + } + // this only calculates possible next moves from the current position, // regardless of how many moves have been made so far (no range checks) // needs to support returning multi moves because pushes are atomic @@ -600,10 +639,17 @@ export class ArimaaGame extends GameBase { newmove = stub; } } else { - // if just clicking directly on the board, select the strongest piece in hand + // if just clicking directly on the board, choose a piece for them: + // in free setup the hand never empties, so default to a rabbit; + // otherwise take the strongest piece still in hand if (lastmove === undefined || lastmove === "") { - const sorted = [...cloned.hands![cloned.currplayer - 1]].sort((a,b) => ArimaaGame.strength(b) - ArimaaGame.strength(a)); - lastmove = cloned.currplayer === 1 ? sorted[0] : sorted[0].toLowerCase(); + let dflt: Piece; + if (this.variants.includes("free")) { + dflt = "R"; + } else { + dflt = [...cloned.hands![cloned.currplayer - 1]].sort((a,b) => ArimaaGame.strength(b) - ArimaaGame.strength(a))[0]; + } + lastmove = cloned.currplayer === 1 ? dflt : dflt.toLowerCase(); } newmove = `${stub}${stub.length > 0 ? "," : ""}${lastmove}${cell}`; } @@ -740,12 +786,21 @@ export class ArimaaGame extends GameBase { message = i18next.t("apgames:validation.arimaa.PARTIAL_FREE") } } - // otherwise, you have to place all your pieces + // otherwise, you have to place all your pieces, + // though the rabbits can be filled in for you else { - if (myhand.length > 0) { + const emptyHome = ArimaaGame.homeCells(cloned.currplayer).filter(c => !cloned.board.has(c)); + const autoRabbits = myhand.length > 0 && myhand.every(pc => pc === "R") && emptyHome.length === myhand.length; + if (myhand.length > 0 && !autoRabbits) { complete = -1; message = i18next.t("apgames:validation.arimaa.PARTIAL_PLAY") } else { + // fake place the rabbits so the advice below sees the real setup + if (autoRabbits) { + for (const cell of emptyHome) { + cloned.board.set(cell, ["R", cloned.currplayer]); + } + } // warnings go here const warnings: string[] = []; // same file (only silver) @@ -784,6 +839,9 @@ export class ArimaaGame extends GameBase { complete = 0; message = i18next.t("apgames:validation._general.VALID_MOVE") } + if (autoRabbits) { + message = [i18next.t("apgames:validation.arimaa.PARTIAL_RABBITS"), message].join(" "); + } } } @@ -1018,6 +1076,11 @@ export class ArimaaGame extends GameBase { throw new UserFacingError("VALIDATION_GENERAL", result.message) } } + // top up a standard setup with the rabbits the player didn't place + // (a no-op on a setup that's already complete, so replays are unaffected) + if (!partial) { + m = this.fillRabbits(m); + } const initial = this.clone(); // used to triple check that the board state changes const lastmove: string[] = []; diff --git a/test/games/arimaa.test.ts b/test/games/arimaa.test.ts index b889ae12a..41891e1cb 100644 --- a/test/games/arimaa.test.ts +++ b/test/games/arimaa.test.ts @@ -92,6 +92,98 @@ describe("Arimaa", () => { expect(result.message).to.include(i18next.t("apgames:validation.arimaa.WARN_HIDE")); }); + it ("Rabbit autofill in standard setup", () => { + const nonrabbits = "Ee2,Md2,Hb2,Hg2,Cf2,Cc2,Dd1,De1"; + let g = new ArimaaGame(); + // not until every non-rabbit is down + let result = g.validateMove("Ee2,Md2,Hb2,Hg2,Cf2,Cc2,Dd1"); + expect(result.valid).to.be.true; + expect(result.complete).to.equal(-1); + // eight non-rabbits and no rabbits is submittable + result = g.validateMove(nonrabbits); + expect(result.valid).to.be.true; + expect(result.complete).to.equal(0); + expect(result.message).to.include(i18next.t("apgames:validation.arimaa.PARTIAL_RABBITS")); + // and so is anything between that and a full setup + result = g.validateMove(`${nonrabbits},Ra2,Ra1,Rb1`); + expect(result.valid).to.be.true; + expect(result.complete).to.equal(0); + // advice is given against the filled-in setup, not the partial one + result = g.validateMove("Ea2,Mb2,Hc2,Hd2,Ce2,Df2,Dg2,Ch2"); + expect(result.message).to.include(i18next.t("apgames:validation.arimaa.WARN_BALANCE")); + + // submitting fills the empty cells of the setup area with rabbits + g.move(nonrabbits); + for (const cell of ["a2", "h2", "a1", "b1", "c1", "d1", "e1", "f1", "g1", "h1"]) { + const contents = g.board.get(cell); + expect(contents).to.not.be.undefined; + if (cell === "d1" || cell === "e1") { + expect(contents![0]).to.equal("D"); + } else { + expect(contents![0]).to.equal("R"); + expect(contents![1]).to.equal(1); + } + } + expect(g.hands![0]).to.be.empty; + // silver works the same way + g.move("ee7,md7,hb7,hg7,cf7,cc7,dd8,de8"); + expect(g.board.get("a7")![0]).to.equal("R"); + expect(g.board.get("a7")![1]).to.equal(2); + expect(g.hands).to.be.undefined; + expect([...g.board.values()].filter(([pc,]) => pc === "R")).to.have.lengthOf(16); + + // partially placed rabbits are left where the player put them + g = new ArimaaGame(); + g.move(`${nonrabbits},Ra2,Rh2`); + expect(g.board.get("a2")![0]).to.equal("R"); + expect([...g.board.values()].filter(([pc,]) => pc === "R")).to.have.lengthOf(8); + expect(g.hands![0]).to.be.empty; + + // a complete setup still produces the same result as before + g = new ArimaaGame(); + g.move(`${nonrabbits},Ra2,Rh2,Ra1,Rb1,Rc1,Rf1,Rg1,Rh1`); + const filled = new ArimaaGame(); + filled.move(nonrabbits); + expect(g.signature()).to.equal(filled.signature()); + + // and it stays out of the way when the placements don't add up + // (two pieces on one cell is caught elsewhere, but must not autofill) + g = new ArimaaGame(); + result = g.validateMove("Ee2,Me2,Hb2,Hg2,Cf2,Cc2,Dd1,De1"); + expect(result.complete).to.equal(-1); + + // the shortcut doesn't apply to the free variant + g = new ArimaaGame(undefined, ["free"]); + result = g.validateMove("Ec3"); + expect(result.message).to.not.include(i18next.t("apgames:validation.arimaa.PARTIAL_RABBITS")); + }); + + it ("Free setup defaults to placing a rabbit", () => { + // clicking an empty cell with nothing selected places a rabbit + let g = new ArimaaGame(undefined, ["free"]); + let result = g.handleClick("", 4, 3); + expect(result.valid).to.be.true; + expect(result.move).to.equal("Rd4"); + // but an explicitly chosen piece still wins + result = g.handleClick("E", 4, 3); + expect(result.valid).to.be.true; + expect(result.move).to.equal("Ed4"); + // silver too + g.move("Rd4"); + result = g.handleClick("", 3, 3); + expect(result.valid).to.be.true; + expect(result.move).to.equal("rd5"); + + // standard setup still offers the strongest piece in hand + g = new ArimaaGame(); + result = g.handleClick("", 6, 4); + expect(result.valid).to.be.true; + expect(result.move).to.equal("Ee2"); + result = g.handleClick("Ee2", 6, 3); + expect(result.valid).to.be.true; + expect(result.move).to.equal("Ee2,Md2"); + }); + it ("classifications", () => { expect(ArimaaGame.classify(1, "Ra1,Ed4".split(","))).to.deep.equal(["placement", "placement"]); expect(ArimaaGame.classify(2, "Ra1,Ed4".split(","))).to.deep.equal([undefined, undefined]); From c1c6f9786627576c3c7c30e609963e2338f5e95b Mon Sep 17 00:00:00 2001 From: samtcifihi <565455483@protonmail.com> Date: Fri, 18 Sep 2026 20:48:06 +0000 Subject: [PATCH 2/2] Arimaa: reject two placements on one cell during setup The "cell must be empty" check in setup validation tested the board as it stood at the start of the turn rather than the working copy the loop is accumulating placements into, so a move string naming one cell twice got past it. Only reachable by typing: the click handler declines to drop a held piece onto an occupied cell, and unplacing removes the step from the move rather than overwriting it. It never let an illegal move be submitted, but it failed badly in two different ways. In standard setup the hand emptied while a home cell stayed empty, so the setup advice hit an empty cell it assumed was filled and threw an unhandled TypeError, surfacing as "a generic error occurred". In free setup, where pieces are not consumed from the hand, it was accepted silently and the second piece replaced the first. Checking the working copy fixes both: the cell is now reported as occupied, the same as placing onto an opponent's piece. The working copy starts as a clone of the board, so this is strictly a superset of what the old check caught, and no legitimate move names a cell twice. Replays run trusted and skip validation, so no stored game is affected. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QDibMmzgf18ky9fiCRW5cL --- src/games/arimaa.ts | 4 ++-- test/games/arimaa.test.ts | 27 +++++++++++++++++++++------ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/games/arimaa.ts b/src/games/arimaa.ts index 75c299239..98f505b1c 100644 --- a/src/games/arimaa.ts +++ b/src/games/arimaa.ts @@ -736,8 +736,8 @@ export class ArimaaGame extends GameBase { result.message = i18next.t("apgames:validation._general.INVALIDCELL", {cell}); return result; } - // cell must be empty - if (this.board.has(cell)) { + // cell must be empty, including of anything placed earlier in this move + if (cloned.board.has(cell)) { result.valid = false; result.message = i18next.t("apgames:validation._general.OCCUPIED"); return result; diff --git a/test/games/arimaa.test.ts b/test/games/arimaa.test.ts index 41891e1cb..a82f81467 100644 --- a/test/games/arimaa.test.ts +++ b/test/games/arimaa.test.ts @@ -146,12 +146,6 @@ describe("Arimaa", () => { filled.move(nonrabbits); expect(g.signature()).to.equal(filled.signature()); - // and it stays out of the way when the placements don't add up - // (two pieces on one cell is caught elsewhere, but must not autofill) - g = new ArimaaGame(); - result = g.validateMove("Ee2,Me2,Hb2,Hg2,Cf2,Cc2,Dd1,De1"); - expect(result.complete).to.equal(-1); - // the shortcut doesn't apply to the free variant g = new ArimaaGame(undefined, ["free"]); result = g.validateMove("Ec3"); @@ -184,6 +178,27 @@ describe("Arimaa", () => { expect(result.move).to.equal("Ee2,Md2"); }); + it ("Can't place two pieces on one cell", () => { + // only reachable by typing; the click handler refuses to drop onto an + // occupied cell. Used to throw an unhandled TypeError in standard setup + // (the hand emptied while a home cell stayed empty) and to be accepted + // silently in free setup, overwriting the earlier piece. + let g = new ArimaaGame(); + let result = g.validateMove("Ee2,Me2,Hb2,Hg2,Cf2,Cc2,Dd1,De1,Ra2,Rh2,Ra1,Rb1,Rc1,Rf1,Rg1,Rh1"); + expect(result.valid).to.be.false; + expect(result.message).to.equal(i18next.t("apgames:validation._general.OCCUPIED")); + g = new ArimaaGame(undefined, ["free"]); + result = g.validateMove("Ec3,Mc3,Rd4"); + expect(result.valid).to.be.false; + expect(result.message).to.equal(i18next.t("apgames:validation._general.OCCUPIED")); + // placing onto an opponent's piece is still caught the same way + g = new ArimaaGame(undefined, ["free"]); + g.move("Ec3,Rd4"); + result = g.validateMove("ec3"); + expect(result.valid).to.be.false; + expect(result.message).to.equal(i18next.t("apgames:validation._general.OCCUPIED")); + }); + it ("classifications", () => { expect(ArimaaGame.classify(1, "Ra1,Ed4".split(","))).to.deep.equal(["placement", "placement"]); expect(ArimaaGame.classify(2, "Ra1,Ed4".split(","))).to.deep.equal([undefined, undefined]);