From 507fc10245111395d325675bffa019ec2392021f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 13:32:24 +0000 Subject: [PATCH 1/2] Arimaa: show die statistics in Dicey Moves In the Dicey Moves variant, the status panel now shows two score tables: each player's mean steps rolled per turn and their total steps rolled. Both count the die as rolled, not the steps actually taken, and include the die for the turn in progress. The half-turn opening roll of Endless endgame counts double towards the mean only. Both tables are marked as spoilers, so players who hide spoilers see them once the game is over. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Bz69bdiuCW6FK5fhhVJ47k --- locales/en/apgames.json | 4 ++- src/games/arimaa.ts | 46 +++++++++++++++++++++++++++- test/games/arimaa.test.ts | 63 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 2 deletions(-) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 796f5428..dc8a3dd9 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -5063,7 +5063,9 @@ "TERRITORY": "Territory" }, "arimaa": { - "HARLOG": "Harlog" + "HARLOG": "Harlog", + "ROLL_MEAN": "Mean steps rolled per turn", + "ROLL_TOTAL": "Total steps rolled" }, "asli": { "AREA": "Area + komi", diff --git a/src/games/arimaa.ts b/src/games/arimaa.ts index 4c86de1e..7c6c434b 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, type FlagContext, type GameFlag } from "./_base.js"; +import { GameBase, IAPGameState, IClickResult, ICustomButton, IIndividualState, IScores, 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"; @@ -1894,6 +1894,50 @@ export class ArimaaGame extends GameBase { return [{ key, value: [glyph, magnitude] }]; } + // Dicey Moves: how the die has been rolling for each player, from the + // rolls heading the turns played and the die waiting for the turn in + // progress. Steps a player left unused do not enter into it. The opening + // move of Endless endgame is half a turn, so its roll counts double + // towards the mean, which keeps it comparable, but as rolled towards the + // total. + public sidebarScores(): IScores[] { + if (!this.variants.includes("dicey")) { + return []; + } + const totals: [number, number] = [0, 0]; + const weighted: [number, number] = [0, 0]; + const turns: [number, number] = [0, 0]; + const tally = (player: playerid, steps: number, opening: boolean): void => { + totals[player - 1] += steps; + weighted[player - 1] += opening ? steps * 2 : steps; + turns[player - 1]++; + }; + const eee = this.variants.includes("eee"); + // a roll heads the results of the turn played from the state before + for (let i = 1; i < this.stack.length; i++) { + for (const r of this.stack[i]._results) { + if (r.type === "roll") { + tally(this.stack[i - 1].currplayer, r.values[0], eee && i === 1); + } + } + } + // the die waiting for the turn in progress, read from the top of the + // stack alone: a resignation, timeout or agreed draw carries it onto + // one more state, but no turn was played to record its roll + const top = this.stack[this.stack.length - 1]; + if (top.die !== undefined) { + tally(top.currplayer, top.die.steps, eee && this.stack.length === 1); + } + if (turns[0] + turns[1] === 0) { + return []; + } + const means = weighted.map((sum, i) => turns[i] === 0 ? "-" : (sum / turns[i]).toFixed(2)); + return [ + { name: this.neutralAreaLabel("apgames:status.arimaa.ROLL_MEAN"), scores: means, spoiler: true }, + { name: this.neutralAreaLabel("apgames:status.arimaa.ROLL_TOTAL"), scores: totals, spoiler: true }, + ]; + } + public collectChatLogLine(lines: ChatLogLine[], r: APMoveResult, ctx: ChatLogCollectContext): boolean { switch (r.type) { diff --git a/test/games/arimaa.test.ts b/test/games/arimaa.test.ts index 3d06cd99..a6e246a3 100644 --- a/test/games/arimaa.test.ts +++ b/test/games/arimaa.test.ts @@ -1281,6 +1281,69 @@ describe("Arimaa Dicey Moves", () => { expect(resumed.variants).to.deep.equal(["free", "dicey"]); }); + it("reports how the die has been rolling, whatever steps were left unused", () => { + const means = (g: ArimaaGame) => g.sidebarScores()[0].scores; + const totals = (g: ArimaaGame) => g.sidebarScores()[1].scores; + expect(new ArimaaGame().sidebarScores()).to.deep.equal([]); + expect(new ArimaaGame(undefined, ["eee"]).sidebarScores()).to.deep.equal([]); + // nothing to report before the first die + expect(freeStart().sidebarScores()).to.deep.equal([]); + // the die for the turn in progress counts as soon as it is cast + const g = withRandom(0.34, () => freeStart().move("ee7, ra7")); + expect(g.die!.steps).to.equal(3); + const scores = g.sidebarScores(); + expect(scores.map(s => (s.name as {textKey: string}).textKey)).to.deep.equal([ + "apgames:status.arimaa.ROLL_MEAN", + "apgames:status.arimaa.ROLL_TOTAL", + ]); + expect(scores.every(s => s.spoiler === true)).to.be.true; + expect(means(g)).to.deep.equal(["3.00", "-"]); + expect(totals(g)).to.deep.equal([3, 0]); + // one step of the three rolled, then one of the one + withRandom(0, () => g.move("Ed3")); + withRandom(0.99, () => g.move("ee6")); + expect(means(g)).to.deep.equal(["3.50", "1.00"]); + expect(totals(g)).to.deep.equal([7, 1]); + // a resignation keeps the die it ended on, which counts only once + g.resign(2); + g.load(); + expect(means(g)).to.deep.equal(["3.50", "1.00"]); + expect(totals(g)).to.deep.equal([7, 1]); + }); + + it("counts the opening roll of Endless endgame double towards the mean only", () => { + // any step onto an empty square for the player to move (the setup is + // random, and an arrow onto an occupied square can fit several moves) + const oneStep = (g: ArimaaGame): string => { + for (const [cell, [pc, owner]] of g.board) { + if (owner !== g.currplayer) { + continue; + } + const [x, y] = ArimaaGame.algebraic2coords(cell); + for (const [dx, dy] of [[0, -1], [0, 1], [-1, 0], [1, 0]]) { + if (x + dx < 0 || x + dx > 7 || y + dy < 0 || y + dy > 7) { + continue; + } + const to = ArimaaGame.coords2algebraic(x + dx, y + dy); + const mv = `${owner === 1 ? pc : pc.toLowerCase()}${cell}${to}`; + const result = g.validateMove(mv); + if (!g.board.has(to) && result.valid && result.complete !== -1) { + return mv; + } + } + } + throw new Error("No one-step move found"); + }; + const g = new ArimaaGame(undefined, ["eee", "dicey"]); + const opening = g.die!.steps; + expect(g.sidebarScores()[0].scores).to.deep.equal([(opening * 2).toFixed(2), "-"]); + expect(g.sidebarScores()[1].scores).to.deep.equal([opening, 0]); + withRandom(0.99, () => g.move(oneStep(g))); + withRandom(0, () => g.move(oneStep(g))); + expect(g.sidebarScores()[0].scores).to.deep.equal([((opening * 2 + 1) / 2).toFixed(2), "4.00"]); + expect(g.sidebarScores()[1].scores).to.deep.equal([opening + 1, 4]); + }); + 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"); From d3465dde9b46513645d7f8d246707a0318343ed0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 13:39:54 +0000 Subject: [PATCH 2/2] Arimaa: shorten the Dicey Moves statistics labels Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Bz69bdiuCW6FK5fhhVJ47k --- locales/en/apgames.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index dc8a3dd9..47305616 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -5064,8 +5064,8 @@ }, "arimaa": { "HARLOG": "Harlog", - "ROLL_MEAN": "Mean steps rolled per turn", - "ROLL_TOTAL": "Total steps rolled" + "ROLL_MEAN": "Average roll", + "ROLL_TOTAL": "Rolls total" }, "asli": { "AREA": "Area + komi",