diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 8a8786dc..000f3172 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 57be5b82..98f505b1 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}`; } @@ -690,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; @@ -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 b889ae12..a82f8146 100644 --- a/test/games/arimaa.test.ts +++ b/test/games/arimaa.test.ts @@ -92,6 +92,113 @@ 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()); + + // 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 ("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]);