Fusion de main dans moteur-analyse ; plan 1a clos

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-03 15:06:09 +01:00
co-authored by Claude Opus 5.5
44 changed files with 1264 additions and 740 deletions
+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) {