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
4 changes: 3 additions & 1 deletion locales/en/apgames.json
Original file line number Diff line number Diff line change
Expand Up @@ -5063,7 +5063,9 @@
"TERRITORY": "Territory"
},
"arimaa": {
"HARLOG": "Harlog"
"HARLOG": "Harlog",
"ROLL_MEAN": "Average roll",
"ROLL_TOTAL": "Rolls total"
},
"asli": {
"AREA": "Area + komi",
Expand Down
46 changes: 45 additions & 1 deletion src/games/arimaa.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
63 changes: 63 additions & 0 deletions test/games/arimaa.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading