Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions src/games/arimaa.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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"));
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand Down
40 changes: 40 additions & 0 deletions test/games/arimaa.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, {name: string}>).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");
Expand All @@ -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", () => {
Expand Down
Loading