Domaine : le coup évident du double toucher, jugé par le moteur

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-12 09:50:48 +01:00
co-authored by Claude Opus 5.5
parent cfb2e485c3
commit 6dd0e93d3d
4 changed files with 180 additions and 0 deletions
+63
View File
@@ -365,6 +365,69 @@ export class Game {
return moves.filter((_, i) => checks[i]?.valid === true);
}
/**
* Le coup évident qui commence par le pion de from (une case du joueur,
* ou la barre : BAR), après les demi-coups déjà saisis : les demi-coups
* qui restent à jouer pour le compléter, ou null s'il n'y en a pas
* (charte, ecrans-etape-1.md, 1i : le double toucher). Il existe quand
* un seul coup légal complet commence par ce pion, ou quand le moteur ne
* propose qu'un coup. Deux coups sont différents s'ils mènent à des
* positions différentes, comme dans la liste des coups de gnubg.
*
* Aucune règle n'est jugée ici : chaque demi-coup essayé est soumis au
* moteur (checkHalfMoves), qui dit s'il commence un coup légal et si le
* coup est complet. On parcourt ses réponses et on s'arrête dès que deux
* coups complets différents apparaissent.
*/
async obviousMove(from: number): Promise<HalfMove[] | null> {
// Lus avant toute attente : la réponse vaut pour l'état de l'appel.
const state = this.state;
const pending = this.pendingHalfMoves;
const displayed = this.displayedPosition;
if (state.turn !== "player" || state.phase !== "moving" || !this.#diceLeft(state)) {
return null;
}
const roll = rollToPlay(state);
const limit = (roll[0] === roll[1] ? 4 : 2) - pending.length;
const legal = await this.#engine.legalMoves(state.position, roll);
// Un seul coup légal : il est évident, quel que soit le pion.
const anyChecker = legal.length === 1;
const found = new Map<PositionId, HalfMove[]>();
const explore = async (sequence: HalfMove[], position: PositionId): Promise<void> => {
if (found.size > 1 || sequence.length >= limit) {
return;
}
const { player } = await this.#sideBoards(position, "player", state);
const starts = sequence.length === 0 && !anyChecker ? [from] : occupiedPoints(player);
const candidates = new Map<string, HalfMove>();
for (const start of starts) {
for (const die of roll) {
const to = Math.max(start - die, OFF);
candidates.set(`${start}/${to}`, { from: start, to });
}
}
for (const halfMove of candidates.values()) {
if (found.size > 1) {
return;
}
const next = [...sequence, halfMove];
const check = await this.#engine.checkHalfMoves(state.position, roll, [
...pending,
...next,
]);
if (!check.valid || check.position === null) {
continue;
}
if (check.complete && !found.has(check.position)) {
found.set(check.position, next);
}
await explore(next, check.position);
}
};
await explore([], displayed);
return found.size === 1 ? ([...found.values()][0] ?? null) : null;
}
/** Le joueur peut-il doubler maintenant ? (Le moteur dit si le videau le
* permet.) */
async canDouble(): Promise<boolean> {
+82
View File
@@ -0,0 +1,82 @@
// Le coup évident du double toucher (charte, ecrans-etape-1.md, 1i), sur le
// vrai moteur WebAssembly : un seul coup légal complet commence par le pion
// touché, ou le moteur ne propose qu'un coup.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { test } from "node:test";
import type { Engine } from "../src/domain/game/engine.js";
import { Game } from "../src/domain/game/game.js";
import { formatHalfMoves, type HalfMove } from "../src/domain/game/position.js";
import { fixedDice } from "../src/domain/game/roll.js";
import { loadNodeEngine } from "./support/node-engine.js";
// Le joueur, au trait : un pion sur sa case 6, un sur sa case 1.
const SIX_AND_ONE = "BQAACAIAAAAAAA";
// Le joueur, au trait : deux pions, sur ses cases 6 et 5.
const PLAYER_ALMOST_DONE = "/38AAABQAAAAAA";
let engine: Engine | null = null;
async function loaded(): Promise<Engine> {
engine ??= await loadNodeEngine();
return engine;
}
/** Une partie à partir d'une position, joueur au trait, dés lancés. */
async function from(position: string, dice: [number, number]): Promise<Game> {
const game = Game.start(await loaded(), {
setup: { position, firstTurn: "player" },
dice: fixedDice(dice),
});
await game.roll();
return game;
}
function text(halfMoves: readonly HalfMove[] | null): string | null {
return halfMoves === null ? null : formatHalfMoves(halfMoves);
}
test("au départ avec 3-1 : plusieurs coups commencent par chaque pion, rien d'évident", async () => {
const game = Game.start(await loaded(), { dice: fixedDice([3, 1]) });
for (const point of [24, 13, 8, 6]) {
assert.equal(await game.obviousMove(point), null, `pion de la ${point}`);
}
// Rien n'a été saisi.
assert.deepEqual(game.pendingHalfMoves, []);
});
test("6-1 avec des pions sur 6 et 1 : seul le pion de la 1 a un coup évident", async () => {
const game = await from(SIX_AND_ONE, [6, 1]);
// 1/off, puis le 6 sort l'autre pion : un seul coup.
assert.equal(text(await game.obviousMove(1)), "1/off 6/off");
// Depuis la 6 : 6/off 1/off, ou 6/5/off (le pion de la 1 reste).
assert.equal(await game.obviousMove(6), null);
// Une case sans pion n'a pas de coup.
assert.equal(await game.obviousMove(3), null);
});
test("un seul coup légal : il est évident depuis chaque pion, même à moitié saisi", async () => {
const game = await from(PLAYER_ALMOST_DONE, [6, 6]);
assert.equal((await (await loaded()).legalMoves(game.state.position, [6, 6])).length, 1);
const fromFive = await game.obviousMove(5);
const fromSix = await game.obviousMove(6);
assert.ok(fromFive !== null && fromSix !== null);
assert.equal(fromFive.length, 2);
assert.equal(fromSix.length, 2);
// Une fois 6/off saisi, il ne reste que 5/off.
assert.equal(await game.playHalfMove({ from: 6, to: 0 }), true);
assert.equal(text(await game.obviousMove(5)), "5/off");
// Le coup évident se joue tel quel, demi-coup par demi-coup.
for (const halfMove of (await game.obviousMove(5)) ?? []) {
assert.equal(await game.playHalfMove(halfMove), true);
}
assert.equal(game.moveComplete, true);
});
test("hors du tour de jeu du joueur, aucun coup évident", async () => {
const game = Game.start(await loaded(), { dice: fixedDice([1, 3]) });
assert.equal(game.state.turn, "computer");
assert.equal(await game.obviousMove(13), null);
});
+17
View File
@@ -511,3 +511,20 @@ Then("le résultat indique le gagnant et les points gagnés", () => {
// règle Jacoby, le videau n'a pas bougé.
assert.deepEqual(game.state.result, { winner: "player", kind: "gammon", points: 1 });
});
// ------------------------------------------------- Glisser, double toucher
When("vous demandez le coup évident de votre pion de la flèche {int}", async (point: number) => {
positionBefore = game.displayedPosition;
const obvious = await game.obviousMove(point);
accepted = obvious !== null;
for (const halfMove of obvious ?? []) {
accepted &&= await game.playHalfMove(halfMove);
}
});
Then("aucun pion n'a bougé", () => {
assert.equal(accepted, false);
assert.deepEqual(game.pendingHalfMoves, []);
assert.equal(game.displayedPosition, positionBefore);
});