diff --git a/src/games/arimaa.ts b/src/games/arimaa.ts index 44c9eacf..f636bd92 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 8f7742d7..8b29e45e 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", () => {