From 8adf1ea37abc3029a3f15f93c17e986288213317 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 18:57:55 +0000 Subject: [PATCH] Arimaa Dicey Moves: keep the die through move previews, draw it to contrast with the board The front end previews a complete move with `emulation` before it is submitted. The engine cast a throwaway die for that preview, so the die appeared to jump and reroll before the move was sent, while the real roll only happened on submission. Under emulation the engine now keeps this turn's die where it was and as it fell, consumes no randomness, and runs the game-over checks against a full allowance, since a roll nobody has seen cannot decide them. A preview that ends the game still hides the die, as the submitted move would. The d6 glyph's outline took the `borders` context colour, which is black in both of the site's themes and so vanished on a dark board. The die's outline and pips are now painted, through the glyph's paint slots, in whichever of black and white contrasts best with the board colour, with the body left hollow. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013Cr9LMGCstWRGggpFfnBBS --- src/games/arimaa.ts | 26 ++++++++++++++++++++----- test/games/arimaa.test.ts | 40 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/games/arimaa.ts b/src/games/arimaa.ts index 44c9eacff..f636bd923 100644 --- a/src/games/arimaa.ts +++ b/src/games/arimaa.ts @@ -1,6 +1,6 @@ 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 { APRenderRep, AreaPieces, BoardBasic, Colourfuncs, Glyph, PaintEntry } from "@abstractplay/renderer/build/schemas/schema"; import type { APMoveResult } from "../schemas/moveresults.js"; import { randomInt, RectGrid, reviver, shuffle, SquareOrthGraph, UserFacingError, cloneState } from "../common/index.js"; import i18next from "i18next"; @@ -1392,7 +1392,7 @@ export class ArimaaGame extends GameBase { return i18next.t("apgames:validation.arimaa.NO_MOVE"); } - public move(m: string, {trusted = false, partial = false} = {}): ArimaaGame { + public move(m: string, {trusted = false, partial = false, emulation = false} = {}): ArimaaGame { if (this.gameover) { throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:validation._general.MOVES_GAMEOVER")); } @@ -1504,10 +1504,19 @@ export class ArimaaGame extends GameBase { // 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; + // + // The front end previews a complete move under `emulation` before it + // is submitted. The next die is cast on submission alone, so the + // preview keeps showing this turn's die, where it was and as it fell, + // and the game-over checks assume a full allowance: a roll nobody has + // seen cannot decide them. + const shown = this.die; + this.die = this.variants.includes("dicey") && this.hands === undefined && !emulation ? castDie(this.board) : undefined; this.checkEOG(); if (this.gameover) { this.die = undefined; + } else if (emulation) { + this.die = shown; } this.saveState(); return this; @@ -1851,12 +1860,19 @@ export class ArimaaGame extends GameBase { }; // 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 + // a board marker so that a piece moving onto its square covers it. The + // body stays hollow, and the outline and pips are both drawn in + // whichever of black and white contrasts best with the board colour, + // so the die shows on light and dark boards alike. if (this.die !== undefined) { + const ink: PaintEntry = { + colour: {func: "bestContrast", bg: "_context_board", fg: ["#000", "#fff"]}, + opacity: 0.75, + }; legend.DIE = { name: `d6-${this.die.steps}`, + paint: {border: ink, detail: ink}, scale: 0.6, - opacity: 0.75, }; const [dx, dy] = ArimaaGame.algebraic2coords(this.die.square); (rep.board as BoardBasic).markers!.push({ diff --git a/test/games/arimaa.test.ts b/test/games/arimaa.test.ts index 8f7742d72..8b29e45e0 100644 --- a/test/games/arimaa.test.ts +++ b/test/games/arimaa.test.ts @@ -1227,6 +1227,42 @@ describe("Arimaa Dicey Moves", () => { expect(g.chatLog(["Gold", "Silver"]).flat().join(" ")).to.contain("a single step this turn"); }); + it("keeps this turn's die through the preview of a complete move and casts the next on submission", () => { + const g = freeStart().move("ee7, ra7"); + const die = {...g.die!}; + // the front end previews a complete move under `emulation` before it + // is submitted, and only the submission casts the next die + let calls = 0; + const original = Math.random; + Math.random = () => { calls++; return 0.5; }; + let preview: ArimaaGame; + try { + preview = g.clone().move("Ed3", {emulation: true}); + } finally { + Math.random = original; + } + expect(calls).to.equal(0); + expect(preview.currplayer).to.equal(2); + expect(preview.lastmove).to.equal("Ed3"); + expect(preview.results[0]).to.deep.equal({type: "roll", values: [die.steps]}); + expect(preview.die).to.deep.equal(die); + expect(preview.stack[preview.stack.length - 1].die).to.deep.equal(die); + const rep = preview.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; + expect(markers.find(m => m.type === "glyph" && m.glyph === "DIE")!.points).to.deep.equal([{row: y, col: x}]); + // the submitted move casts the next die + const submitted = withRandom(0, () => g.clone().move("Ed3")); + expect(submitted.die!.steps).to.equal(1); + // a preview that ends the game hides the die, as the submission would + const nearGoal = new ArimaaGame(undefined, ["free", "dicey"]).move("Rd7, Ee2").move("ee5, ra7"); + const won = nearGoal.clone().move("Rd8", {emulation: true}); + expect(won.gameover).to.be.true; + expect(won.die).to.be.undefined; + expect(won.render().legend).to.not.have.property("DIE"); + }); + 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"); @@ -1238,6 +1274,10 @@ describe("Arimaa Dicey Moves", () => { expect(g.gameover).to.be.false; expect(g.die!.steps).to.equal(4); expect(g.validateMove("Ea7").valid).to.be.true; + // a preview has no roll to decide it, so the game goes on + g = withRandom(0, () => boxed().move("ra7, rb8, ch2, dg1", {emulation: true})); + expect(g.gameover).to.be.false; + expect(g.die).to.be.undefined; }); it("hides the die when the rules end the game, but not after a resignation, timeout or draw", () => {