diff --git a/app/src/domain/game/game.ts b/app/src/domain/game/game.ts index 9584690..6b66e7f 100644 --- a/app/src/domain/game/game.ts +++ b/app/src/domain/game/game.ts @@ -27,7 +27,14 @@ import { withAnalysesOf, } from "./events.js"; import { firstGameOf, type Match, nextGameOf } from "./match.js"; -import { BAR, type HalfMove, OFF, type PositionId, START_POSITION } from "./position.js"; +import { + BAR, + formatHalfMoves, + type HalfMove, + OFF, + type PositionId, + START_POSITION, +} from "./position.js"; import { type DiceSource, type Roll, randomDice, rollDice } from "./roll.js"; import { otherSide, type Side } from "./side.js"; import { type GameState, replay } from "./state.js"; @@ -276,6 +283,29 @@ export class Game { return { side, halfMoves, boards, hits }; } + /** + * Le coup joué à l'événement eventId (un MovePlayed), dans la notation + * du moteur : celle du coup légal de gnubg qui mène à la même position + * (« 6/off » et non « 6/5 5/off »), comme dans l'analyse et le bilan. + * À défaut (aucun coup légal ne correspond), les demi-coups ; chaîne + * vide si l'événement n'est pas un coup ou si aucun pion n'a bougé. + */ + async moveNotation(eventId: string): Promise { + const line = currentLine({ ...this.#journal, head: eventId }); + const event = line.at(-1); + if (event?.type !== "MovePlayed" || event.halfMoves.length === 0) { + return ""; + } + const before = replay(line.slice(0, -1)); + const roll = rollToPlay(before); + const [check, legal] = await Promise.all([ + this.#engine.checkHalfMoves(before.position, roll, event.halfMoves), + this.#engine.legalMoves(before.position, roll), + ]); + const move = legal.find((m) => m.position === check.position); + return move?.notation ?? formatHalfMoves(event.halfMoves); + } + /** Les conseils du maître déjà rattachés aux décisions du joueur, par * identifiant d'événement. */ get advice(): Readonly> { diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index 6b0dea7..a4cb543 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -32,7 +32,7 @@ import type { Advice } from "../domain/game/advice.js"; import type { Cube } from "../domain/game/cube.js"; import type { GameJournal } from "../domain/game/events.js"; import type { Game, MoveSteps, NewGame, SideBoards } from "../domain/game/game.js"; -import { formatHalfMoves, type HalfMove } from "../domain/game/position.js"; +import type { HalfMove } from "../domain/game/position.js"; import { type PositionLink, type SharedPosition, setupOf } from "../domain/game/position-link.js"; import type { SavedGameSummary } from "../domain/game/saved-game.js"; import { otherSide, type Side } from "../domain/game/side.js"; @@ -1018,7 +1018,7 @@ export function startApp(options: AppOptions): void { while (game !== null && game.state.turn === "computer" && isPlaying(game)) { await refresh(); await game.playComputerTurn(); - info = describeComputerTurn(game); + info = await describeComputerTurn(game); save(); played = true; } @@ -2500,8 +2500,9 @@ async function computerMoveToShow(game: Game): Promise { return game.lastMoveSteps(); } -/** Ce qu'a fait l'ordinateur pendant son tour (sans durée de calcul). */ -function describeComputerTurn(game: Game): string { +/** Ce qu'a fait l'ordinateur pendant son tour (sans durée de calcul). Son + * coup s'écrit dans la notation du moteur, comme dans l'analyse. */ +async function describeComputerTurn(game: Game): Promise { const last = game.events.at(-1); if (last?.type === "CubeOffered") { return ""; @@ -2514,7 +2515,7 @@ function describeComputerTurn(game: Game): string { if (played.halfMoves.length === 0) { return TEXTS.computerCannotMove(dice); } - return TEXTS.computerMoved(dice, formatHalfMoves(played.halfMoves)); + return TEXTS.computerMoved(dice, await game.moveNotation(played.id)); } /** Le résultat d'une partie finie, en deux parties : le titre du panneau diff --git a/app/tests/game-analysis.test.ts b/app/tests/game-analysis.test.ts index 28fd014..21090fe 100644 --- a/app/tests/game-analysis.test.ts +++ b/app/tests/game-analysis.test.ts @@ -30,7 +30,7 @@ import { withAnalysesOf, } from "../src/domain/game/events.js"; import { Game } from "../src/domain/game/game.js"; -import { START_POSITION } from "../src/domain/game/position.js"; +import { formatHalfMoves, START_POSITION } from "../src/domain/game/position.js"; import { emptyReview, REVIEW_VERSION } from "../src/domain/game/review.js"; import { loadNodeEngine } from "./support/node-engine.js"; import { playToTheEnd, seededDice } from "./support/play.js"; @@ -315,4 +315,30 @@ test("une partie complète analysée par le moteur, comme une partie d'avant 1d" // Le journal analysé se reprend : l'analyse fait partie du format. const resumed = Game.resume(engine, JSON.parse(JSON.stringify(analysed))); assert.deepEqual(resumed.journal.review, analysed.review); + // Chaque coup s'écrit pareil sur l'écran de jeu (« L'ordinateur a + // joué … », moveNotation) et dans l'analyse : la notation du moteur. + let compared = 0; + let rewritten = 0; + for (const move of moves) { + if (move.type !== "MovePlayed" || move.halfMoves.length === 0) { + continue; + } + const advice = + move.side === "computer" + ? analysed.review?.computer[move.id] + : analysed.advice?.[move.id]; + if (advice?.kind !== "move") { + continue; + } + const notation = await resumed.moveNotation(move.id); + assert.equal(notation, advice.played.notation, `coup ${move.id}`); + compared++; + if (notation !== formatHalfMoves(move.halfMoves)) { + rewritten++; + } + } + assert.ok(compared > 5, `${compared} coups comparés`); + // La course finit en sortie : au moins un coup s'écrit autrement que + // ses demi-coups (« 6/off » pour « 6/2 2/off »…). + assert.ok(rewritten > 0, "aucun coup réécrit par le moteur"); });