Saisie du coup : un toucher, validation, Annuler, dés joués, videau confirmé #28

Manually merged
pierre merged 9 commits from saisie-du-coup into main 2026-11-03 09:17:38 +00:00
4 changed files with 218 additions and 24 deletions
Showing only changes of commit 2c5278965b - Show all commits
+113 -24
View File
@@ -79,9 +79,10 @@ export class Game {
readonly #dice: DiceSource;
readonly #ply: Ply;
#journal: GameJournal = EMPTY_JOURNAL;
// Coup du joueur en cours de saisie : pas encore dans le journal.
#pending: HalfMove[] = [];
#pendingPosition: PositionId | null = null;
// Coup du joueur en cours de saisie : pas encore dans le journal (il n'y
// entre qu'à sa validation, confirmMove). Pour chaque demi-coup saisi, la
// position après lui, et si le coup est alors complet, selon le moteur.
#pending: PendingHalfMove[] = [];
private constructor(engine: Engine, options: GameOptions) {
this.#engine = engine;
@@ -141,12 +142,37 @@ export class Game {
/** Les demi-coups déjà saisis du coup en cours. */
get pendingHalfMoves(): readonly HalfMove[] {
return this.#pending;
return this.#pending.map((p) => p.halfMove);
}
/** Les demi-coups saisis forment un coup complet (le moteur le dit) : il
* n'attend plus que d'être validé (confirmMove) ou défait
* (undoLastHalfMove). */
get moveComplete(): boolean {
return this.#pending.at(-1)?.complete === true;
}
/** La position à afficher : après les demi-coups déjà saisis. */
get displayedPosition(): PositionId {
return this.#pendingPosition ?? this.state.position;
return this.#pending.at(-1)?.position ?? this.state.position;
}
/**
* Les dés du jet à jouer (quatre pour un double), et pour chacun s'il est
* déjà utilisé par un demi-coup saisi ; liste vide hors de la saisie d'un
* coup du joueur. Un demi-coup utilise le dé qui l'a produit dans
* nextHalfMoves (de from vers from - dé, ou vers la sortie) : aucune
* règle n'est jugée ici, la légalité vient du moteur.
*/
get diceToPlay(): DieToPlay[] {
const state = this.state;
if (state.turn !== "player" || state.phase !== "moving" || state.roll === null) {
return [];
}
const [a, b] = state.roll;
const dice = a === b ? [a, a, a, a] : [a, b];
const used = assignDice(dice, this.pendingHalfMoves);
return dice.map((value, i) => ({ value, used: used[i] === true }));
}
/** Le plateau affiché (après les demi-coups déjà saisis), décodé par le
@@ -205,7 +231,7 @@ export class Game {
*/
async nextHalfMoves(): Promise<HalfMove[]> {
const state = this.state;
if (state.turn !== "player" || state.phase !== "moving") {
if (state.turn !== "player" || state.phase !== "moving" || this.moveComplete) {
return [];
}
const roll = rollToPlay(state);
@@ -220,7 +246,7 @@ export class Game {
const moves = [...candidates.values()];
const checks = await Promise.all(
moves.map((h) =>
this.#engine.checkHalfMoves(state.position, roll, [...this.#pending, h]),
this.#engine.checkHalfMoves(state.position, roll, [...this.pendingHalfMoves, h]),
),
);
return moves.filter((_, i) => checks[i]?.valid === true);
@@ -274,34 +300,51 @@ export class Game {
/**
* Le joueur déplace un pion (un dé). Renvoie false si ce demi-coup ne
* mène à aucun coup légal : il est refusé et rien ne change. Quand les
* demi-coups forment un coup complet, le coup est joué.
* mène à aucun coup légal (ou si le coup est déjà complet) : il est
* refusé et rien ne change. Le coup n'est pas joué pour autant : une fois
* complet (moveComplete), il attend d'être validé (confirmMove).
*/
async playHalfMove(halfMove: HalfMove): Promise<boolean> {
const state = this.#expect("player", "moving");
const halfMoves = [...this.#pending, halfMove];
const check = await this.#engine.checkHalfMoves(
state.position,
rollToPlay(state),
halfMoves,
);
if (this.moveComplete) {
return false;
}
const check = await this.#engine.checkHalfMoves(state.position, rollToPlay(state), [
...this.pendingHalfMoves,
halfMove,
]);
if (!check.valid || check.position === null) {
return false;
}
if (check.complete) {
this.cancelHalfMoves();
await this.#playMove("player", halfMoves, check.position);
} else {
this.#pending = halfMoves;
this.#pendingPosition = check.position;
}
this.#pending = [
...this.#pending,
{ halfMove, position: check.position, complete: check.complete },
];
return true;
}
/** Oublie les demi-coups saisis du coup en cours. */
/** Défait le dernier demi-coup saisi du coup en cours (rien s'il n'y en a
* pas) : il n'a jamais été dans le journal. */
undoLastHalfMove(): void {
this.#pending = this.#pending.slice(0, -1);
}
/** Oublie tous les demi-coups saisis du coup en cours. */
cancelHalfMoves(): void {
this.#pending = [];
this.#pendingPosition = null;
}
/** Le joueur valide son coup complet : il entre dans le journal
* (MovePlayed) et le tour passe. Erreur si le coup n'est pas complet. */
async confirmMove(): Promise<void> {
this.#expect("player", "moving");
const last = this.#pending.at(-1);
if (last?.complete !== true) {
throw new Error("le coup n'est pas complet : jouez encore");
}
const halfMoves = this.pendingHalfMoves;
this.cancelHalfMoves();
await this.#playMove("player", halfMoves, last.position);
}
/** Le joueur double, avant de lancer les dés ; l'ordinateur prend ou passe
@@ -454,6 +497,52 @@ export class Game {
}
}
/** Un dé du jet à jouer, et s'il est déjà utilisé par un demi-coup saisi. */
export interface DieToPlay {
readonly value: number;
readonly used: boolean;
}
/** Un demi-coup saisi, pas encore validé. */
interface PendingHalfMove {
readonly halfMove: HalfMove;
/** La position après lui, vue du joueur. */
readonly position: PositionId;
/** Les demi-coups saisis jusqu'à lui forment un coup complet. */
readonly complete: boolean;
}
/**
* Quels dés les demi-coups ont utilisés : chaque demi-coup reçoit un dé
* différent qui le produit (de from vers from - dé, ou vers la sortie si le
* dé dépasse), comme les candidats de nextHalfMoves. Les dés sont essayés du
* plus petit au plus grand : un pion sorti l'est avec le plus petit dé
* suffisant. Si aucune répartition ne convient, les premiers dés sont
* marqués, un par demi-coup.
*/
function assignDice(dice: readonly number[], halfMoves: readonly HalfMove[]): boolean[] {
const order = dice.map((_, i) => i).sort((i, j) => (dice[i] ?? 0) - (dice[j] ?? 0));
const used = dice.map(() => false);
const fits = (h: HalfMove, die: number) => Math.max(h.from - die, OFF) === h.to;
const place = (k: number): boolean => {
const h = halfMoves[k];
if (h === undefined) {
return true;
}
for (const i of order) {
if (!used[i] && fits(h, dice[i] ?? 0)) {
used[i] = true;
if (place(k + 1)) {
return true;
}
used[i] = false;
}
}
return false;
};
return place(0) ? used : dice.map((_, i) => i < halfMoves.length);
}
/** Le jet à jouer, en phase moving. */
function rollToPlay(state: GameState): Roll {
if (state.roll === null) {
+2
View File
@@ -45,6 +45,8 @@ async function playToTheEnd(game: Game, engine: Awaited<ReturnType<typeof loadNo
for (const halfMove of first.halfMoves) {
assert.equal(await game.playHalfMove(halfMove), true);
}
assert.equal(game.moveComplete, true);
await game.confirmMove();
}
}
return actions;
+100
View File
@@ -78,14 +78,111 @@ test("un coup se saisit demi-coup par demi-coup ; un demi-coup refusé ne change
assert.deepEqual(game.pendingHalfMoves, [{ from: 8, to: 5 }]);
assert.equal(await game.playHalfMove({ from: 6, to: 5 }), true);
// Coup complet : il attend la validation, hors du journal.
assert.equal(game.moveComplete, true);
assert.equal(game.displayedPosition, "AFTER");
assert.equal(game.events.length, 2);
assert.equal(game.state.turn, "player");
assert.deepEqual(await game.nextHalfMoves(), []);
assert.equal(await game.playHalfMove({ from: 8, to: 7 }), false);
await game.confirmMove();
const last = game.events.at(-1);
assert.equal(last?.type, "MovePlayed");
assert.deepEqual(game.pendingHalfMoves, []);
assert.equal(game.moveComplete, false);
assert.equal(game.state.turn, "computer");
assert.equal(game.state.phase, "rolling");
assert.equal(game.state.position, "SWAPPED");
});
test("un coup incomplet ne se valide pas", async () => {
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) });
await assert.rejects(game.confirmMove(), /pas complet/);
await game.playHalfMove({ from: 8, to: 5 });
await assert.rejects(game.confirmMove(), /pas complet/);
assert.equal(game.events.length, 2);
});
test("Annuler défait les demi-coups un par un, sans toucher au journal", async () => {
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) });
await game.playHalfMove({ from: 8, to: 5 });
await game.playHalfMove({ from: 6, to: 5 });
const journal = game.journal;
game.undoLastHalfMove();
assert.deepEqual(game.pendingHalfMoves, [{ from: 8, to: 5 }]);
assert.equal(game.moveComplete, false);
assert.equal(game.displayedPosition, "HALF");
game.undoLastHalfMove();
assert.deepEqual(game.pendingHalfMoves, []);
assert.equal(game.displayedPosition, START_POSITION);
game.undoLastHalfMove(); // rien à défaire : sans effet
assert.equal(game.journal, journal);
// Le coup se ressaisit ensuite normalement.
assert.equal(await game.playHalfMove({ from: 8, to: 5 }), true);
assert.equal(await game.playHalfMove({ from: 6, to: 5 }), true);
assert.equal(game.moveComplete, true);
});
test("les dés utilisés par les demi-coups saisis sont marqués", async () => {
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) });
assert.deepEqual(game.diceToPlay, [
{ value: 3, used: false },
{ value: 1, used: false },
]);
await game.playHalfMove({ from: 8, to: 5 });
assert.deepEqual(game.diceToPlay, [
{ value: 3, used: true },
{ value: 1, used: false },
]);
await game.playHalfMove({ from: 6, to: 5 });
assert.deepEqual(
game.diceToPlay.map((d) => d.used),
[true, true],
);
await game.confirmMove();
assert.deepEqual(game.diceToPlay, []);
});
test("un double donne quatre dés, marqués un par un ; une sortie prend le plus petit dé suffisant", async () => {
const engine = fakeEngine({
checkHalfMoves: async () => ({ valid: true, complete: false, position: "P" }),
});
const double = Game.start(engine, {
dice: fixedDice([2, 2]),
setup: { position: "X", firstTurn: "player" },
});
await double.roll();
assert.equal(double.diceToPlay.length, 4);
await double.playHalfMove({ from: 13, to: 11 });
await double.playHalfMove({ from: 11, to: 9 });
assert.deepEqual(
double.diceToPlay.map((d) => d.used),
[true, true, false, false],
);
const bearOff = Game.start(engine, {
dice: fixedDice([6, 4]),
setup: { position: "X", firstTurn: "player" },
});
await bearOff.roll();
// 3/off : le 4 suffit, le 6 reste.
await bearOff.playHalfMove({ from: 3, to: 0 });
assert.deepEqual(bearOff.diceToPlay, [
{ value: 6, used: false },
{ value: 4, used: true },
]);
// 6/2 demande le 4 : la sortie a donc pris le 6.
await bearOff.playHalfMove({ from: 6, to: 2 });
assert.deepEqual(
bearOff.diceToPlay.map((d) => d.used),
[true, true],
);
});
test("sans coup possible, le tour passe quand le joueur le laisse passer", async () => {
const engine = fakeEngine({ legalMoves: async () => [] });
const game = Game.start(engine, {
@@ -192,6 +289,7 @@ test("annuler repart du parent ; l'action suivante ouvre une branche", async ()
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1, 6, 5, 4, 2]) });
await game.playHalfMove({ from: 8, to: 5 });
await game.playHalfMove({ from: 6, to: 5 });
await game.confirmMove();
const played = game.events.at(-1);
assert.equal(played?.type, "MovePlayed");
@@ -200,6 +298,7 @@ test("annuler repart du parent ; l'action suivante ouvre une branche", async ()
assert.equal(game.state.turn, "player");
await game.playHalfMove({ from: 8, to: 5 });
await game.playHalfMove({ from: 6, to: 5 });
await game.confirmMove();
// Deux coups accrochés au même jet : l'ancien reste dans le journal.
const moves = game.journal.events.filter((e) => e.type === "MovePlayed");
@@ -244,6 +343,7 @@ test("une partie se reprend depuis son journal ; un journal d'une autre version
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) });
await game.playHalfMove({ from: 8, to: 5 });
await game.playHalfMove({ from: 6, to: 5 });
await game.confirmMove();
const saved = JSON.parse(JSON.stringify(game.journal));
const resumed = Game.resume(fakeEngine(), saved);
+3
View File
@@ -42,6 +42,7 @@ async function playerMove(position: PositionId, dice: [number, number], move: st
for (const halfMove of parseHalfMoves(move)) {
assert.equal(await game.playHalfMove(halfMove), true, formatHalfMoves([halfMove]));
}
await game.confirmMove();
return game;
}
@@ -59,6 +60,7 @@ test("un coup simple : un plateau par demi-coup, sans pion frappé", async () =>
for (const halfMove of parseHalfMoves("8/5 6/5")) {
await game.playHalfMove(halfMove);
}
await game.confirmMove();
const steps = await game.lastMoveSteps();
assert.ok(steps !== null);
assert.equal(steps.side, "player");
@@ -121,6 +123,7 @@ test("le coup de l'ordinateur, vu dans sa propre numérotation", async () => {
for (const halfMove of parseHalfMoves("8/5 6/5")) {
await game.playHalfMove(halfMove);
}
await game.confirmMove();
await game.playComputerTurn();
const steps = await game.lastMoveSteps();
assert.ok(steps !== null);