Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion locales/en/apgames.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down
77 changes: 70 additions & 7 deletions src/games/arimaa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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<string>(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
Expand Down Expand Up @@ -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}`;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(" ");
}
}
}

Expand Down Expand Up @@ -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[] = [];
Expand Down
107 changes: 107 additions & 0 deletions test/games/arimaa.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
Loading