Étape 0d.2 : plateau jouable en PWA hors ligne, aide intégrée #13
+104
-5
@@ -9,25 +9,25 @@
|
||||
// l'ordinateur, videau, fin de partie et points.
|
||||
|
||||
import { CENTERED_CUBE, type Cube } from "./cube.js";
|
||||
import type { Engine, EngineCube, Ply } from "./engine.js";
|
||||
import type { BoardSide, Engine, EngineCube, Ply } from "./engine.js";
|
||||
import {
|
||||
appendEvent,
|
||||
currentLine,
|
||||
EMPTY_JOURNAL,
|
||||
EVENT_VERSION,
|
||||
type GameEvent,
|
||||
type GameJournal,
|
||||
goBackTo,
|
||||
type NewGameEvent,
|
||||
} from "./events.js";
|
||||
import { type HalfMove, type PositionId, START_POSITION } from "./position.js";
|
||||
import { BAR, 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";
|
||||
|
||||
/**
|
||||
* Niveau « standard » de l'ordinateur : 2 plis. C'est le niveau mesuré à
|
||||
* l'étape 0b (au plus 0,3 s par coup sous Chromium et 2,3 s sous Firefox sur
|
||||
* le PC de 2011) ; il reste à confirmer sur le téléphone.
|
||||
* Niveau « standard » de l'ordinateur : 2 plis. Sur le Fairphone 4 (Chrome),
|
||||
* le meilleur coup à ce niveau prend au plus 348 ms (ADR 0009).
|
||||
*/
|
||||
export const STANDARD_PLY: Ply = 2;
|
||||
|
||||
@@ -40,6 +40,13 @@ export interface GameSetup {
|
||||
readonly cube?: Cube;
|
||||
}
|
||||
|
||||
/** Le plateau vu de chaque camp, chacun avec sa propre numérotation des
|
||||
* cases (sa case 1 est la plus proche de sa sortie). */
|
||||
export interface SideBoards {
|
||||
readonly player: BoardSide;
|
||||
readonly computer: BoardSide;
|
||||
}
|
||||
|
||||
export interface GameOptions {
|
||||
/** Source des dés ; le hasard ordinaire par défaut. */
|
||||
readonly dice?: DiceSource;
|
||||
@@ -85,6 +92,25 @@ export class Game {
|
||||
return game;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reprend une partie enregistrée, à partir de son journal. Erreur si le
|
||||
* journal est d'un autre format ou incohérent.
|
||||
*/
|
||||
static resume(engine: Engine, journal: GameJournal, options: GameOptions = {}): Game {
|
||||
const first = journal.events[0];
|
||||
if (first?.type !== "GameStarted" || journal.head === null) {
|
||||
throw new Error("journal de partie vide ou mal formé");
|
||||
}
|
||||
if (journal.events.some((event) => event.version !== EVENT_VERSION)) {
|
||||
throw new Error("journal de partie d'une autre version");
|
||||
}
|
||||
const game = new Game(engine, options);
|
||||
game.#journal = journal;
|
||||
// Vérifie que chaque événement de la ligne courante existe.
|
||||
currentLine(journal);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Tous les événements, branches comprises, et l'événement courant. */
|
||||
get journal(): GameJournal {
|
||||
return this.#journal;
|
||||
@@ -109,6 +135,68 @@ export class Game {
|
||||
return this.#pendingPosition ?? this.state.position;
|
||||
}
|
||||
|
||||
/** Le plateau affiché (après les demi-coups déjà saisis), décodé par le
|
||||
* moteur. */
|
||||
async displayedBoard(): Promise<SideBoards> {
|
||||
const state = this.state;
|
||||
// La position est vue du camp qui a la main (le joueur pendant le jet
|
||||
// d'ouverture : la position de départ est la même pour les deux).
|
||||
const onRoll = state.turn ?? "player";
|
||||
const board = await this.#engine.board(
|
||||
this.displayedPosition,
|
||||
engineCube(state.cube, onRoll),
|
||||
);
|
||||
return onRoll === "player"
|
||||
? { player: board.onRoll, computer: board.opponent }
|
||||
: { player: board.opponent, computer: board.onRoll };
|
||||
}
|
||||
|
||||
/**
|
||||
* Les demi-coups que le joueur peut saisir maintenant, à la suite de ceux
|
||||
* déjà saisis (liste vide hors de son tour de jeu). Chaque candidat
|
||||
* déplace un de ses pions de la valeur d'un dé (vers la sortie si le dé
|
||||
* dépasse la case 1) ; seuls restent ceux que le moteur déclare menant à
|
||||
* un coup légal. Aucune règle n'est jugée ici.
|
||||
*/
|
||||
async nextHalfMoves(): Promise<HalfMove[]> {
|
||||
const state = this.state;
|
||||
if (state.turn !== "player" || state.phase !== "moving") {
|
||||
return [];
|
||||
}
|
||||
const roll = rollToPlay(state);
|
||||
const { player } = await this.displayedBoard();
|
||||
const candidates = new Map<string, HalfMove>();
|
||||
for (const from of occupiedPoints(player)) {
|
||||
for (const die of roll) {
|
||||
const to = Math.max(from - die, OFF);
|
||||
candidates.set(`${from}/${to}`, { from, to });
|
||||
}
|
||||
}
|
||||
const moves = [...candidates.values()];
|
||||
const checks = await Promise.all(
|
||||
moves.map((h) =>
|
||||
this.#engine.checkHalfMoves(state.position, roll, [...this.#pending, h]),
|
||||
),
|
||||
);
|
||||
return moves.filter((_, i) => checks[i]?.valid === true);
|
||||
}
|
||||
|
||||
/** Le joueur peut-il doubler maintenant ? (Le moteur dit si le videau le
|
||||
* permet.) */
|
||||
async canDouble(): Promise<boolean> {
|
||||
const state = this.state;
|
||||
if (state.turn !== "player" || state.phase !== "rolling") {
|
||||
return false;
|
||||
}
|
||||
// Le droit de doubler ne dépend pas du niveau : 0 pli suffit.
|
||||
const decision = await this.#engine.cubeDecision(
|
||||
state.position,
|
||||
engineCube(state.cube, "player"),
|
||||
0,
|
||||
);
|
||||
return decision.available;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- Joueur
|
||||
|
||||
/** Le joueur lance les dés. S'il ne peut pas jouer, son tour passe. */
|
||||
@@ -304,6 +392,17 @@ function rollToPlay(state: GameState): Roll {
|
||||
return state.roll;
|
||||
}
|
||||
|
||||
/** Les cases d'où un camp a des pions à jouer : la barre, puis 24 à 1. */
|
||||
function occupiedPoints(side: BoardSide): number[] {
|
||||
const points = side.bar > 0 ? [BAR] : [];
|
||||
for (let point = 24; point >= 1; point--) {
|
||||
if ((side.points[point - 1] ?? 0) > 0) {
|
||||
points.push(point);
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/** Le videau vu du camp au trait, pour le moteur. */
|
||||
function engineCube(cube: Cube, onRoll: Side): EngineCube {
|
||||
if (cube.owner === null) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2023"],
|
||||
"types": [],
|
||||
"noEmit": true
|
||||
},
|
||||
|
||||
@@ -218,3 +218,57 @@ test("lecture et écriture des demi-coups", () => {
|
||||
assert.throws(() => parseHalfMoves("8-5"), /mal formé/);
|
||||
assert.throws(() => parseHalfMoves("30/5"), /case invalide/);
|
||||
});
|
||||
|
||||
test("une partie se reprend depuis son journal ; un journal d'une autre version est refusé", 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 saved = JSON.parse(JSON.stringify(game.journal));
|
||||
|
||||
const resumed = Game.resume(fakeEngine(), saved);
|
||||
assert.deepEqual(resumed.state, game.state);
|
||||
assert.deepEqual(resumed.events, game.events);
|
||||
|
||||
const other = { ...saved, events: saved.events.map((e: object) => ({ ...e, version: 99 })) };
|
||||
assert.throws(() => Game.resume(fakeEngine(), other), /version/);
|
||||
assert.throws(() => Game.resume(fakeEngine(), EMPTY_JOURNAL), /mal formé/);
|
||||
assert.throws(() => Game.resume(fakeEngine(), { ...saved, head: "inconnu" }), /introuvable/);
|
||||
});
|
||||
|
||||
test("le droit de doubler vient du moteur, seulement avant de lancer les dés", async () => {
|
||||
let available = true;
|
||||
const engine = fakeEngine({
|
||||
cubeDecision: async () => ({ available, double: false, take: true }),
|
||||
});
|
||||
const game = Game.start(engine, {
|
||||
dice: fixedDice([3, 1]),
|
||||
setup: { position: "X", firstTurn: "player" },
|
||||
});
|
||||
assert.equal(await game.canDouble(), true);
|
||||
available = false;
|
||||
assert.equal(await game.canDouble(), false);
|
||||
available = true;
|
||||
await game.roll();
|
||||
assert.equal(game.state.phase, "moving");
|
||||
assert.equal(await game.canDouble(), false);
|
||||
});
|
||||
|
||||
test("le plateau affiché est donné du point de vue de chaque camp", async () => {
|
||||
const side = (pipCount: number) => ({ points: [], bar: 0, off: 0, pipCount });
|
||||
const engine = fakeEngine({
|
||||
board: async () => ({
|
||||
onRoll: side(1),
|
||||
opponent: side(2),
|
||||
swappedPosition: "SWAPPED",
|
||||
result: null,
|
||||
}),
|
||||
});
|
||||
const playerTurn = Game.start(engine, { setup: { position: "X", firstTurn: "player" } });
|
||||
const sides = await playerTurn.displayedBoard();
|
||||
assert.equal(sides.player.pipCount, 1);
|
||||
assert.equal(sides.computer.pipCount, 2);
|
||||
const computerTurn = Game.start(engine, { setup: { position: "X", firstTurn: "computer" } });
|
||||
const swapped = await computerTurn.displayedBoard();
|
||||
assert.equal(swapped.player.pipCount, 2);
|
||||
assert.equal(swapped.computer.pipCount, 1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// Ce que l'interface propose au joueur, sur le vrai moteur WebAssembly : les
|
||||
// demi-coups possibles (mis en évidence quand il touche un pion) et le
|
||||
// plateau affiché pendant la saisie d'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 { 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, n'a plus que deux pions, sur ses cases 6 et 5
|
||||
// (même position que dans les étapes du scénario).
|
||||
const PLAYER_ALMOST_DONE = "/38AAABQAAAAAA";
|
||||
|
||||
/** Les demi-coups en notation, triés : « 13/10 13/12… ». */
|
||||
function text(halfMoves: readonly HalfMove[]): string {
|
||||
return halfMoves
|
||||
.map((h) => formatHalfMoves([h]))
|
||||
.sort()
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
test("au départ avec 3-1 : chaque pion jouable, avec chaque dé", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const game = Game.start(engine, { dice: fixedDice([3, 1]) });
|
||||
assert.equal(text(await game.nextHalfMoves()), "13/10 24/21 24/23 6/3 6/5 8/5 8/7");
|
||||
// 13/12 manque : votre case 12 est tenue par l'ordinateur. Après 8/5,
|
||||
// il ne reste que le 1.
|
||||
assert.equal(await game.playHalfMove({ from: 8, to: 5 }), true);
|
||||
assert.equal(text(await game.nextHalfMoves()), "24/23 5/4 6/5 8/7");
|
||||
});
|
||||
|
||||
test("en fin de partie, les demi-coups vers la sortie viennent du moteur", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const game = Game.start(engine, {
|
||||
dice: fixedDice([6, 5]),
|
||||
setup: { position: PLAYER_ALMOST_DONE, firstTurn: "player" },
|
||||
});
|
||||
await game.roll();
|
||||
assert.equal(text(await game.nextHalfMoves()), "5/off 6/1 6/off");
|
||||
});
|
||||
|
||||
test("hors du tour de jeu du joueur, rien n'est proposé", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const game = Game.start(engine, { dice: fixedDice([1, 3]) });
|
||||
assert.equal(game.state.turn, "computer");
|
||||
assert.deepEqual(await game.nextHalfMoves(), []);
|
||||
});
|
||||
|
||||
test("le plateau affiché suit les demi-coups saisis", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const game = Game.start(engine, { dice: fixedDice([3, 1]) });
|
||||
const before = await game.displayedBoard();
|
||||
assert.equal(before.player.points[7], 3);
|
||||
assert.equal(before.computer.points[23], 2);
|
||||
await game.playHalfMove({ from: 8, to: 5 });
|
||||
const after = await game.displayedBoard();
|
||||
assert.equal(after.player.points[7], 2);
|
||||
assert.equal(after.player.points[4], 1);
|
||||
assert.equal(after.player.pipCount, before.player.pipCount - 3);
|
||||
});
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"types": ["node"],
|
||||
|
||||
Reference in New Issue
Block a user