diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 17c8d90e..8a407f22 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -601,7 +601,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\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\nTo enter a move, click a piece and then the square it should end your turn on. The engine finds the shortest legal way to get it there, pushes and pulls included, and draws one arrow per piece from where it started to where it ends up. You can point at enemy pieces too. Click an arrow's head to extend it, or its tail to remove it. Clicking a selected piece a second time keeps it where it stands, which is how you stop the engine moving it aside. A few rarer turns need one more click; the wiki page for this game lists them all.\n\nMoves are recorded in Lightvector notation, which names each piece and where it ends up rather than listing every step. You can type it into the move box, and the older step-by-step notation is still accepted. The wiki page explains the notation in full.\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\nTo enter a move, click a piece and then the square it should end your turn on. The engine finds the shortest legal way to get it there, pushes and pulls included, and draws one arrow per piece from where it started to where it ends up. You can point at enemy pieces too. Click an arrow's head to extend it, or its tail to remove it. Clicking a selected piece a second time keeps it where it stands, which is how you stop the engine moving it aside. A few rarer turns need one more click; the wiki page for this game lists them all.\n\nMoves are recorded in Lightvector notation, which names each piece and where it ends up rather than listing every step. You can type it into the move box, and the older step-by-step notation is still accepted. The wiki page explains the notation in full.\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\nIn Dicey Moves, a player whose die allows a single step has no move, and loses, when only pushes and pulls remain.\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.", @@ -993,11 +993,15 @@ }, "eee": { "name": "Endless endgame", - "description": "Randomly generated setup with reduced army size" + "description": "Randomly generated setup with reduced army size. Devised by clyring." }, "free": { "name": "Free placement", "description": "Lets players place as many pieces as they want wherever they want, including creating illegal or otherwise broken positions. Players should communicate clearly before starting. These games cannot be rated." + }, + "dicey": { + "name": "Dicey Moves", + "description": "A die determines how many steps you can take each turn." } }, "armadas": { @@ -5483,7 +5487,9 @@ "AMBIGUOUS": "That fits {{count}} different moves. Say where another piece ends up to settle it.", "INFERRED": "Also moving: {{pieces}}.", "INCOMPLETE": "A piece is selected. Click a square to send it there, or click it again to keep it where it is.", - "TOO_LONG": "What you have drawn needs more than {{num}} steps." + "TOO_LONG": "What you have drawn needs more than {{num}} steps.", + "DIE_one": "The die allows a single step this turn.", + "DIE_other": "The die allows up to {{count}} steps this turn." }, "armadas": { "BAD_PASS": "When passing, that must be the only command you provide.", diff --git a/locales/en/apresults.json b/locales/en/apresults.json index 8b784659..e12d8d9e 100644 --- a/locales/en/apresults.json +++ b/locales/en/apresults.json @@ -643,6 +643,8 @@ }, "RESIGN": "{{player}} has resigned.", "ROLL": { + "arimaa_one": "{{player}}'s die shows 1: a single step this turn.", + "arimaa_other": "{{player}}'s die shows {{count}}: up to {{count}} steps this turn.", "magnate": "{{who}} rolled a {{values}}.", "magnate_eight": "{{who}} rolled an {{values}}.", "canoe": "{{player}} rolled: {{values}}.", diff --git a/src/games/arimaa.ts b/src/games/arimaa.ts index 2da4c461..4c86de1e 100644 --- a/src/games/arimaa.ts +++ b/src/games/arimaa.ts @@ -1,4 +1,4 @@ -import { GameBase, IAPGameState, IClickResult, ICustomButton, IIndividualState, IStatus, IValidationResult, type ChatLogCollectContext, type ChatLogLine } from "./_base.js"; +import { GameBase, IAPGameState, IClickResult, ICustomButton, IIndividualState, IStatus, IValidationResult, type ChatLogCollectContext, type ChatLogLine, type FlagContext, type GameFlag } from "./_base.js"; import type { APGamesInformation } from "../schemas/gameinfo.js"; import { APRenderRep, AreaPieces, BoardBasic, Colourfuncs, Glyph } from "@abstractplay/renderer/build/schemas/schema"; import type { APMoveResult } from "../schemas/moveresults.js"; @@ -6,6 +6,7 @@ import { randomInt, RectGrid, reviver, shuffle, SquareOrthGraph, UserFacingError import i18next from "i18next"; import { arrowToken, captureToken, isArrow, isLegacy, isMark, isHold, joinMove, normalize, parseMove, pieceChar, holdToken, tokenText, NotationError, type ParsedMove, type Token } from "./arimaa/notation.js"; import { hasAnyMove, inferred, resolve, serializeTurn, sqName, turnFromSteps, type Turn } from "./arimaa/turns.js"; +import { castDie, type IDie } from "./arimaa/dice.js"; export type playerid = 1|2; export type Piece = "E" | "M" | "H" | "D" | "C" | "R"; @@ -26,6 +27,13 @@ export interface IMoveState extends IIndividualState { board: Map; lastmove?: string; hands?: [Piece[], Piece[]]; + /** + * Dicey Moves: the die for the turn starting here; absent during setup and + * in other variants. A move that ends the game clears it, as no turn + * follows, but a resignation, timeout or agreed draw keeps it, since it + * may have informed that decision. + */ + die?: IDie; }; export interface IArimaaState extends IAPGameState { @@ -75,8 +83,19 @@ export class ArimaaGame extends GameBase { }, ], variants: [ - { uid: "eee", group: "setup" }, + { + uid: "eee", + group: "setup", + fans: true, + people: [ + { + type: "designer", + name: "clyring", + }, + ], + }, { uid: "free", group: "setup", unrated: true }, + { uid: "dicey", experimental: true }, ], customizations: [ { @@ -119,6 +138,15 @@ export class ArimaaGame extends GameBase { categories: ["goal>breakthrough", "goal>cripple", "goal>immobilize", "mechanic>capture", "mechanic>move", "mechanic>coopt", "mechanic>random>setup", "board>shape>rect", "board>connect>rect", "components>chess"], flags: ["perspective", "no-moves", "custom-buttons", "random-start", "custom-colours"] }; + public static resolveFlags(context: FlagContext = {}): readonly GameFlag[] { + const flags: GameFlag[] = [...(this.gameinfo.flags ?? [])]; + // a Dicey Moves turn opens with a roll nobody has seen yet, so there + // is no position beyond the current one to explore + if (context.variants?.includes("dicey")) { + flags.push("no-explore"); + } + return flags; + } public static coords2algebraic(x: number, y: number): string { return GameBase.coords2algebraic(x, y, 8); } @@ -228,6 +256,8 @@ export class ArimaaGame extends GameBase { public currplayer: playerid = 1; public board!: Map; public hands?: [Piece[], Piece[]]; + // Dicey Moves: the die for the turn in progress + public die?: IDie; public lastmove?: string; public gameover = false; public winner: playerid[] = []; @@ -267,6 +297,11 @@ export class ArimaaGame extends GameBase { ]; } + // Dicey Moves: only Endless endgame opens with a move, and that move + // is half a turn; the other setups cast their first die in move() + // once the pieces are down + const die = this.variants.includes("dicey") && this.variants.includes("eee") ? castDie(board, "opening") : undefined; + const fresh: IMoveState = { _version: ArimaaGame.gameinfo.version, _results: [], @@ -274,6 +309,7 @@ export class ArimaaGame extends GameBase { currplayer: 1, board, hands, + die, }; this.stack = [fresh]; } else { @@ -304,6 +340,7 @@ export class ArimaaGame extends GameBase { this.board = cloneState(state.board); this.lastmove = state.lastmove; this.hands = cloneState(state.hands); + this.die = cloneState(state.die); this.results = [...state._results]; // what a turn did is drawn from its results; anything left over from a // partial move belongs to the entry being typed, not to this state @@ -314,9 +351,14 @@ export class ArimaaGame extends GameBase { return this; } + // the standard 16-piece setup, which the setup variants replace + private standardSetup(): boolean { + return !this.variants.includes("eee") && !this.variants.includes("free"); + } + public getButtons(): ICustomButton[] { // base game, gold setup - if (this.variants.length === 0 && this.stack.length === 1) { + if (this.standardSetup() && this.stack.length === 1) { return [ { label: "apgames:buttons.arimaa.gold99", @@ -325,7 +367,7 @@ export class ArimaaGame extends GameBase { ]; } // base game, silver setup - else if (this.variants.length === 0 && this.stack.length === 2) { + else if (this.standardSetup() && this.stack.length === 2) { return [ { label: "apgames:buttons.arimaa.silver99e7", @@ -344,7 +386,7 @@ export class ArimaaGame extends GameBase { // 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) { + if (!this.standardSetup() || this.hands === undefined || this.hands[this.currplayer - 1].length === 0) { return m; } const mvs = m.split(",").filter(Boolean); @@ -484,7 +526,7 @@ export class ArimaaGame extends GameBase { // - either still pieces in hand // - or we're in standard setup and ply 1 or 2, no matter the hands const placing = (this.hands !== undefined && this.hands[this.currplayer - 1].length > 0) || - (this.variants.length === 0 && this.stack.length <= 2); + (this.standardSetup() && this.stack.length <= 2); const newmove = placing ? this.setupClick(move, row, col, piece) : this.moveClick(move, row, col); let result = this.validateMove(newmove) as IClickResult; if (! result.valid) { @@ -730,6 +772,10 @@ export class ArimaaGame extends GameBase { } result.canrender = true; result.message = i18next.t("apgames:validation.arimaa.INITIAL_INSTRUCTIONS", {context: (this.hands !== undefined && this.hands[this.currplayer - 1].length > 0) ? "place" : "play"}); + // Dicey Moves: lead with what the die allows + if (this.die !== undefined) { + result.message = [i18next.t("apgames:validation.arimaa.DIE", {count: this.die.steps}), result.message].join(" "); + } return result; } @@ -897,10 +943,7 @@ export class ArimaaGame extends GameBase { const classifications = ArimaaGame.classify(this.currplayer, m.split(",").filter(Boolean)); const steps = m.split(",").filter(Boolean).map(mv => ArimaaGame.baseMove(mv)); // can't make too many moves - let maxMoves = 4; - if (this.variants.includes("eee") && this.stack.length === 1) { - maxMoves = 2; - } + const maxMoves = this.maxSteps(); if (steps.length > maxMoves) { result.valid = false; result.message = i18next.t("apgames:validation.arimaa.TOO_MANY", {num: maxMoves}); @@ -1106,7 +1149,12 @@ export class ArimaaGame extends GameBase { return result; } + // Steps the mover may take this turn: what the die says in Dicey Moves, + // two for the opening move of Endless endgame, otherwise four. private maxSteps(): number { + if (this.die !== undefined) { + return this.die.steps; + } if (this.variants.includes("eee") && this.stack.length === 1) { return 2; } @@ -1289,6 +1337,11 @@ export class ArimaaGame extends GameBase { const initial = this.clone(); // used to triple check that the board state changes const lastmove: string[] = []; this.results = []; + // Dicey Moves: the die that governs this turn heads its results, which + // is how the roll reaches the move log and the game record + if (this.die !== undefined) { + this.results.push({type: "roll", values: [this.die.steps]}); + } this._selected = undefined; this._arrows = undefined; this._holds = undefined; @@ -1362,7 +1415,19 @@ export class ArimaaGame extends GameBase { } this.currplayer = newplayer as playerid; + // Dicey Moves: cast the die for the turn about to start, once setup is + // over and no hands are left. It comes before the game-over checks, + // which need its allowance to tell whether the next player can move + // at all. If the rules have ended the game, no turn follows, so the + // die goes. Resignations, timeouts and agreed draws end the game in + // GameBase instead and keep the die, since it may have informed the + // decision. Partial moves have already returned, so the die never + // changes while a move is entered. + this.die = this.variants.includes("dicey") && this.hands === undefined ? castDie(this.board) : undefined; this.checkEOG(); + if (this.gameover) { + this.die = undefined; + } this.saveState(); return this; @@ -1505,9 +1570,10 @@ export class ArimaaGame extends GameBase { this.winner = [this.currplayer]; } } - // Check if currplayer has no possible move (all pieces are frozen or have no place to move). If so prevPlayer wins. + // Check if currplayer has no possible move (all pieces are frozen or have no place to move, + // or in Dicey Moves a single step is all the die allows and only pushes and pulls remain). If so prevPlayer wins. if (!this.gameover) { - if (!hasAnyMove(this.board, this.currplayer)) { + if (!hasAnyMove(this.board, this.currplayer, this.maxSteps())) { this.gameover = true; this.winner = [prevPlayer]; } @@ -1546,6 +1612,7 @@ export class ArimaaGame extends GameBase { lastmove: this.lastmove, board: new Map(this.board), hands: cloneState(this.hands), + die: cloneState(this.die), }; } @@ -1702,6 +1769,22 @@ export class ArimaaGame extends GameBase { areas, }; + // Dicey Moves: the turn's die, one of the renderer's d6 faces, drawn as + // a board marker so that a piece moving onto its square covers it + if (this.die !== undefined) { + legend.DIE = { + name: `d6-${this.die.steps}`, + scale: 0.6, + opacity: 0.75, + }; + const [dx, dy] = ArimaaGame.algebraic2coords(this.die.square); + (rep.board as BoardBasic).markers!.push({ + type: "glyph", + glyph: "DIE", + points: [{row: dy, col: dx}], + }); + } + // Add annotations: one arrow per piece from where it started the turn // to where it ended up, the enter glyph for a piece back where it began, // and the exit glyph on a trap that claimed a piece @@ -1826,6 +1909,10 @@ export class ArimaaGame extends GameBase { case "announce": this.pushNeutralChatLine(lines, "apresults:ANNOUNCE.arimaa"); return true; + case "roll": + // Dicey Moves: the die that governed the turn + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:ROLL.arimaa", {count: r.values[0]}); + return true; default: return super.collectChatLogLine(lines, r, ctx); } diff --git a/src/games/arimaa/dice.ts b/src/games/arimaa/dice.ts new file mode 100644 index 00000000..c03595b4 --- /dev/null +++ b/src/games/arimaa/dice.ts @@ -0,0 +1,97 @@ +/** + * Dicey Moves, an official Arimaa variant: a die cast before each move caps + * the steps that turn may take. + * + * The engine stores each turn's die on the state that turn starts from, so + * loading or undoing to a position shows the same roll again. The rolls + * cannot be recovered from the moves, but each one also heads its turn's + * results as a `roll` result, which is how it reaches the move log and the + * game record. Apart from the roll itself, everything here is a pure + * function of the position. + */ +import { randomInt } from "../../common/index.js"; +import { sqIndex, sqName, TRAPS, type CellContents } from "./turns.js"; + +/** The die cast for one turn. */ +export interface IDie { + /** the most steps the mover may take this turn, from 1 to 4 */ + steps: number; + /** where the die is shown: a square empty as the turn began, which a piece moving there covers */ + square: string; +} + +/** + * Cast the die for a turn starting from `board`. A normal turn rolls a d6 + * capped at four, so one, two and three steps each come up a sixth of the + * time and four the other half. The opening move of Endless endgame is half + * a turn, so its die shows one or two, evenly, which halves both the maximum + * and the mean. + */ +export function castDie(board: Map, turn: "normal" | "opening" = "normal"): IDie { + const steps = turn === "opening" ? randomInt(2, 1) : Math.min(4, randomInt(6, 1)); + return { steps, square: dieSquare(board) }; +} + +/** + * Where the die is shown for a turn starting from `board`: the unoccupied + * square with the highest openness, and among equals the first reading from + * rank 8 down and from a to h. Only a free setup can fill every square; the + * die then goes on d4, under a piece. + */ +export function dieSquare(board: Map): string { + const occupied = new Set([...board.keys()].map(sqIndex)); + // what openness counts as full: occupied squares, traps, and anything off the board + const full = (file: number, rank: number): boolean => + file < 0 || file > 7 || rank < 0 || rank > 7 || occupied.has(rank * 8 + file) || TRAPS.includes(rank * 8 + file); + let best: string | undefined; + let bestOpenness = -Infinity; + for (let rank = 7; rank >= 0; rank--) { + for (let file = 0; file < 8; file++) { + // an empty trap can take the die, though it scores nothing for itself + if (occupied.has(rank * 8 + file)) { + continue; + } + const value = openness(full, file, rank); + if (value > bestOpenness) { + bestOpenness = value; + best = sqName(rank * 8 + file); + } + } + } + return best ?? "d4"; +} + +/** + * The openness of the square at `file` and `rank`, both counted from 0: the + * sum of f(a) = SE(a) * (5 - a) / S(a) for a from 0 to 4. SE(a) counts the + * squares exactly a steps away by Manhattan distance that `full` does not + * report, the square itself for a = 0, and S(a) counts every square that + * far. An empty trap counts as full for its own term too, which puts it 5 + * behind any other empty square. + * + * The result is in 48ths. As 48 is the least common multiple of S(0) to + * S(4), every term is a whole number, so equal openness compares as equal. + */ +function openness(full: (file: number, rank: number) => boolean, file: number, rank: number): number { + let sum = 0; + for (let a = 0; a < 5; a++) { + // SE(a), walking the ring of squares exactly a steps away + let emptyInRing = 0; + for (let dx = -a; dx <= a; dx++) { + const dy = a - Math.abs(dx); + if (!full(file + dx, rank + dy)) { + emptyInRing++; + } + if (dy > 0 && !full(file + dx, rank - dy)) { + emptyInRing++; + } + } + sum += emptyInRing * (5 - a) * 48 / ringSize(a); + } + return sum; +} + +/** S(a): how many squares lie exactly a steps from any square, counting those off the board. */ +function ringSize(a: number): number { + return a === 0 ? 1 : 4 * a; +} diff --git a/src/games/arimaa/turns.ts b/src/games/arimaa/turns.ts index 8cc878af..cc1d669f 100644 --- a/src/games/arimaa/turns.ts +++ b/src/games/arimaa/turns.ts @@ -838,9 +838,12 @@ export function resolve(board: Map, player: playerid, maxS return { status: "unsatisfiable" }; } -/** Whether the player to move has any legal step, push or pull. */ -export function hasAnyMove(board: Map, player: playerid): boolean { - return new Search(board, player).atoms(2).length > 0; +/** + * Whether the player to move has any legal step, or, with at least two steps + * to spend, any push or pull. Only Dicey Moves allows fewer than two. + */ +export function hasAnyMove(board: Map, player: playerid, maxSteps = 4): boolean { + return new Search(board, player).atoms(Math.min(2, maxSteps)).length > 0; } /** Replay an explicit step list (no legality checks) and describe the turn. */ diff --git a/test/games/arimaa.test.ts b/test/games/arimaa.test.ts index cde4a6f3..fc0a8323 100644 --- a/test/games/arimaa.test.ts +++ b/test/games/arimaa.test.ts @@ -3,7 +3,8 @@ import "mocha"; import { expect } from "chai"; import i18next from "i18next"; import { addResource } from "../../src"; -import { ArimaaGame } from '../../src/games'; +import { ArimaaGame, resolveGameFlags } from '../../src/games'; +import { dieSquare } from "../../src/games/arimaa/dice"; describe("Arimaa", () => { before(() => { @@ -1096,3 +1097,208 @@ describe("Arimaa click coverage", () => { }); } }); + +describe("Arimaa Dicey Moves", () => { + before(() => { + addResource("en"); + }); + + // run `fn` with the engine's source of randomness pinned to `value` + const withRandom = (value: number, fn: () => T): T => { + const original = Math.random; + Math.random = () => value; + try { + return fn(); + } finally { + Math.random = original; + } + }; + // a free setup with Gold's pieces placed; Silver's placement then casts + // the die for Gold's first move + const freeStart = (): ArimaaGame => { + const g = new ArimaaGame(undefined, ["free", "dicey"]); + g.move("Ed2, Ra2"); + return g; + }; + + it("casts no die during setup and one for every move after", () => { + const g = new ArimaaGame(undefined, ["dicey"]); + expect(g.die).to.be.undefined; + // the standard setup keeps its conveniences + expect(g.getButtons().length).to.equal(1); + g.move("Ee2,Md2,Hb2,Hg2,Dc2,Df2,Cb1,Cg1"); + expect(g.board.size).to.equal(16); + expect(g.die).to.be.undefined; + expect(g.getButtons().length).to.equal(2); + g.move("ee7,md7,hb7,hg7,dc7,df7,cb8,cg8"); + expect(g.board.size).to.equal(32); + expect(g.die).to.not.be.undefined; + expect(g.die!.steps).to.be.within(1, 4); + // shown on an empty square in the open centre: the four centre + // squares tie, and d5 is the first of them read from rank 8 down + expect(g.board.has(g.die!.square)).to.be.false; + expect(g.die!.square).to.equal("d5"); + expect(g.stack[g.stack.length - 1].die).to.deep.equal(g.die); + expect(g.validateMove("").message).to.contain(i18next.t("apgames:validation.arimaa.DIE", {count: g.die!.steps})); + }); + + it("places the die by openness, empty traps included", () => { + // every square full but the ones named + const boardWithout = (...empty: string[]): Map => { + const board = new Map(); + for (let row = 0; row < 8; row++) { + for (let col = 0; col < 8; col++) { + const cell = ArimaaGame.coords2algebraic(col, row); + if (!empty.includes(cell)) { + board.set(cell, ["R", 1]); + } + } + } + return board; + }; + // b4 scores 5 + 2 + 3/8 + 1/6. That beats b5 and a4, which have e4 + // four steps away rather than three, though both are read first. + expect(dieSquare(boardWithout("a4", "a5", "b4", "b5", "e4"))).to.equal("b4"); + // an empty trap can take the die + expect(dieSquare(boardWithout("c3"))).to.equal("c3"); + // but it counts as full in its own openness, so even a corner beats it + expect(dieSquare(boardWithout("c3", "a1"))).to.equal("a1"); + // with every square full, the die sits under the piece on d4 + expect(dieSquare(boardWithout())).to.equal("d4"); + }); + + it("caps a d6 at four steps", () => { + expect(withRandom(0, () => freeStart().move("ee7, ra7").die!.steps)).to.equal(1); + expect(withRandom(0.34, () => freeStart().move("ee7, ra7").die!.steps)).to.equal(3); + expect(withRandom(0.5, () => freeStart().move("ee7, ra7").die!.steps)).to.equal(4); + expect(withRandom(0.99, () => freeStart().move("ee7, ra7").die!.steps)).to.equal(4); + const seen = new Set(); + for (let i = 0; i < 120; i++) { + seen.add(freeStart().move("ee7, ra7").die!.steps); + } + expect([...seen].sort()).to.deep.equal([1, 2, 3, 4]); + }); + + it("gives the opening move of Endless endgame one step or two", () => { + const seen = new Set(); + for (let i = 0; i < 60; i++) { + const g = new ArimaaGame(undefined, ["eee", "dicey"]); + expect(g.die).to.not.be.undefined; + expect([1, 2]).to.include(g.die!.steps); + expect(g.board.has(g.die!.square)).to.be.false; + seen.add(g.die!.steps); + } + expect(seen.size).to.equal(2); + // without the die, the opening move is still two steps + expect(new ArimaaGame(undefined, ["eee"]).validateMove("Ed4").valid).to.be.false; + }); + + it("limits the turn to the die in both notations", () => { + const g = freeStart().move("ee7, ra7"); + g.die = {steps: 1, square: g.die!.square}; + let result = g.validateMove("Ed3"); + expect(result.valid).to.be.true; + expect(result.complete).to.equal(1); + result = g.validateMove("Ed4"); + expect(result.valid).to.be.false; + result = g.validateMove("Ed2d4"); + expect(result.valid).to.be.false; + expect(result.message).to.equal(i18next.t("apgames:validation.arimaa.TOO_LONG", {num: 1})); + result = g.validateMove("Ed2d3"); + expect(result.valid).to.be.true; + expect(result.complete).to.equal(1); + result = g.validateMove("Ed2d3,Ed3d4"); + expect(result.valid).to.be.false; + expect(result.message).to.equal(i18next.t("apgames:validation.arimaa.TOO_MANY", {num: 1})); + // the roll heads the turn's record, and the next die is cast for Silver + g.move("Ed3"); + expect(g.results[0]).to.deep.equal({type: "roll", values: [1]}); + expect(g.lastmove).to.equal("Ed3"); + expect(g.die!.steps).to.be.within(1, 4); + expect(g.board.has(g.die!.square)).to.be.false; + expect(g.currplayer).to.equal(2); + const lines = g.chatLogEntries(["Gold", "Silver"]).pop()!.lines; + expect(lines[0].textKey).to.equal("apresults:ROLL.arimaa"); + expect(lines[0].textParams!.count).to.equal(1); + expect(g.chatLog(["Gold", "Silver"]).flat().join(" ")).to.contain("a single step this turn"); + }); + + it("ends the game when the die allows one step and only a push remains", () => { + // Gold's elephant is boxed in by rabbits it could push; its rabbit is frozen + const boxed = (): ArimaaGame => new ArimaaGame(undefined, ["free", "dicey"]).move("Ea8, Rh1"); + let g = withRandom(0, () => boxed().move("ra7, rb8, ch2, dg1")); + expect(g.gameover).to.be.true; + expect(g.winner).to.deep.equal([2]); + expect(g.die).to.be.undefined; + g = withRandom(0.99, () => boxed().move("ra7, rb8, ch2, dg1")); + expect(g.gameover).to.be.false; + expect(g.die!.steps).to.equal(4); + expect(g.validateMove("Ea7").valid).to.be.true; + }); + + it("hides the die when the rules end the game, but not after a resignation, timeout or draw", () => { + const drawsDie = (g: ArimaaGame): boolean => (g.render().legend as Record).DIE !== undefined; + // a Gold rabbit one step from goal, so any roll lets it score + const nearGoal = (): ArimaaGame => new ArimaaGame(undefined, ["free", "dicey"]).move("Rd7, Ee2").move("ee5, ra7"); + const won = nearGoal().move("Rd8"); + expect(won.gameover).to.be.true; + expect(won.winner).to.deep.equal([1]); + expect(won.die).to.be.undefined; + expect(drawsDie(won)).to.be.false; + // the die that was showing may have informed the decision, so it stays + const endings: Array<(g: ArimaaGame) => void> = [g => g.resign(1), g => g.resign(2), g => g.timeout(1), g => g.draw()]; + for (const end of endings) { + const g = nearGoal(); + const die = {...g.die!}; + end(g); + expect(g.gameover).to.be.true; + g.load(); + expect(g.die).to.deep.equal(die); + expect(drawsDie(g)).to.be.true; + } + }); + + it("keeps the die where it is while a move is entered and draws it under the pieces", () => { + const g = freeStart().move("ee7, ra7"); + const die = {...g.die!}; + const partial = g.clone(); + partial.move("Ed3", {partial: true}); + expect(partial.die).to.deep.equal(die); + const rep = partial.render(); + expect((rep.legend as Record).DIE.name).to.equal(`d6-${die.steps}`); + const [x, y] = ArimaaGame.algebraic2coords(die.square); + const markers = (rep.board as {markers: Array<{type: string; glyph?: string; points?: Array<{row: number; col: number}>}>}).markers; + const marker = markers.find(m => m.type === "glyph" && m.glyph === "DIE"); + expect(marker).to.not.be.undefined; + expect(marker!.points).to.deep.equal([{row: y, col: x}]); + // a resumed game carries it + const resumed = new ArimaaGame(g.serialize()); + expect(resumed.die).to.deep.equal(die); + expect(resumed.variants).to.deep.equal(["free", "dicey"]); + }); + + it("turns exploration off only with the die", () => { + expect(new ArimaaGame().getFlags()).to.not.include("no-explore"); + expect(new ArimaaGame(undefined, ["eee"]).getFlags()).to.not.include("no-explore"); + expect(new ArimaaGame(undefined, ["dicey"]).getFlags()).to.include("no-explore"); + expect(resolveGameFlags("arimaa", {variants: ["free", "dicey"]})).to.include("no-explore"); + expect(resolveGameFlags("arimaa", {variants: []})).to.not.include("no-explore"); + }); + + it("leaves games without the die untouched", () => { + const g = new ArimaaGame(); + g.move("Ee2,Md2,Hb2,Hg2,Dc2,Df2,Cb1,Cg1"); + g.move("ee7,md7,hb7,hg7,dc7,df7,cb8,cg8"); + g.move("Ee4"); + expect(g.die).to.be.undefined; + expect(JSON.stringify(g.state())).to.not.contain('"die"'); + expect(g.results.some(r => r.type === "roll")).to.be.false; + expect(g.render().legend).to.not.have.property("DIE"); + expect(g.validateMove("").message).to.equal(i18next.t("apgames:validation.arimaa.INITIAL_INSTRUCTIONS", {context: "play"})); + const variants = g.allvariants()!; + expect(variants.find(v => v.uid === "eee")!.fans).to.equal(true); + expect(variants.find(v => v.uid === "dicey")!.fans).to.not.equal(true); + expect(ArimaaGame.gameinfo.variants!.find(v => v.uid === "dicey")!.experimental).to.equal(true); + expect(ArimaaGame.gameinfo.variants!.find(v => v.uid === "eee")!.people![0].name).to.equal("clyring"); + }); +}); diff --git a/test/games/flags.test.ts b/test/games/flags.test.ts index 842fa053..bf90b0fc 100644 --- a/test/games/flags.test.ts +++ b/test/games/flags.test.ts @@ -63,6 +63,8 @@ describe("resolveFlags / getFlags", () => { { uid: "crosscontrol", variants: ["nokomi"] }, { uid: "connections" }, { uid: "lifeline" }, + { uid: "arimaa" }, + { uid: "arimaa", variants: ["eee", "dicey"] }, ]; for (const { uid, variants, numplayers } of cases) {