diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 796f5428..47305616 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -5063,7 +5063,9 @@ "TERRITORY": "Territory" }, "arimaa": { - "HARLOG": "Harlog" + "HARLOG": "Harlog", + "ROLL_MEAN": "Average roll", + "ROLL_TOTAL": "Rolls total" }, "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");