From 6af82f73b012845ccbdef2c54617d4783e5f2797 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Mon, 2 Nov 2026 20:23:04 +0100 Subject: [PATCH 1/7] Moteur : plateau, fin de partie, Match ID et saisie demi-coup par demi-coup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ce dont le domaine d'une partie a besoin, sans réimplémenter de règle : décodage d'un Position ID en plateau et position vue de l'adversaire, résultat d'une partie finie (gammon, règle Jacoby), codage d'un Match ID, vérification de demi-coups saisis un par un (la légalité vient de GenerateMoves de gnubg), demi-coups de chaque coup légal, droit de doubler et recommandations de double et de prise. Nouvelles commandes d'engine-cli position, halfmoves et matchid, avec leurs tests natifs. Co-Authored-By: Claude Opus 5.5 --- engine-cli/main.c | 206 ++++++++++++++++++++++++++++- engine/engine.c | 281 +++++++++++++++++++++++++++++++++++++++- engine/include/engine.h | 84 ++++++++++++ tests/test_engine.sh | 81 ++++++++++++ 4 files changed, 644 insertions(+), 8 deletions(-) diff --git a/engine-cli/main.c b/engine-cli/main.c index f32f293..5373e48 100644 --- a/engine-cli/main.c +++ b/engine-cli/main.c @@ -13,6 +13,15 @@ * legal POSITION_ID MATCH_ID DÉS coups légaux, sans évaluation * cube POSITION_ID MATCH_ID décision de videau * eval POSITION_ID MATCH_ID évaluation de la position + * position POSITION_ID MATCH_ID plateau décodé, même position vue de + * l'adversaire, fin de partie éventuelle + * halfmoves POSITION_ID DÉS [DEMI-COUPS] + * vérifie des demi-coups saisis un par + * un (ex. « 8/5 6/5 », « bar/22 », + * « 6/off ») + * matchid VIDEAU PROPRIÉTAIRE Match ID d'une partie libre avec la + * règle Jacoby, joueur 0 au trait ; + * PROPRIÉTAIRE : -1 (centre), 0 ou 1 * reference POSITION_ID MATCH_ID [DÉS] * un niveau au format des positions de * référence (tests/reference/README.md) : @@ -45,6 +54,9 @@ * float32 calculé par gnubg. */ #define NUMBER "%.9g" +/* Liste des commandes, pour les messages d'erreur. */ +#define COMMANDS "bestmove, moves, legal, cube, eval, position, halfmoves, matchid ou reference" + /* Coups gardés dans le classement de la commande reference (comme hint). */ #define REFERENCE_MAX_MOVES 10 @@ -113,10 +125,24 @@ print_probabilities(const float p[ENGINE_NUM_PROBABILITIES]) p[ENGINE_LOSE_GAMMON], p[ENGINE_LOSE_BACKGAMMON]); } +/* Demi-coups sous la forme [[8, 5], [6, 5]]. */ +static void +print_half_moves(const engine_half_move *half_moves, int count) +{ + int i; + + printf("["); + for (i = 0; i < count; i++) + printf("%s[%d, %d]", i ? ", " : "", half_moves[i].from, half_moves[i].to); + printf("]"); +} + static void print_move(const engine_move *m, int with_evaluation) { - printf("{\"move\": \"%s\", \"position_id\": \"%s\"", m->notation, m->position_id); + printf("{\"move\": \"%s\", \"position_id\": \"%s\", \"half_moves\": ", + m->notation, m->position_id); + print_half_moves(m->half_moves, m->half_move_count); if (with_evaluation) { printf(", \"ply\": %d, \"equity\": " NUMBER ", \"probabilities\": ", m->ply, m->equity); print_probabilities(m->probabilities); @@ -254,11 +280,177 @@ run_position_command(const options *opt) print_probabilities(c.probabilities); printf(", "); print_cube_fields(&c); - printf("}\n"); + printf(", \"available\": %s, \"double\": %s, \"take\": %s}\n", + c.available ? "true" : "false", c.should_double ? "true" : "false", + c.should_take ? "true" : "false"); } return EXIT_SUCCESS; } +static void +print_side(const engine_side *side) +{ + int i; + + printf("{\"points\": ["); + for (i = 0; i < 24; i++) + printf("%s%d", i ? ", " : "", side->points[i]); + printf("], \"bar\": %d, \"off\": %d, \"pip_count\": %d}", side->bar, side->off, + side->pip_count); +} + +/* position : plateau, même position vue de l'adversaire, fin de partie. */ +static int +run_board_command(const options *opt) +{ + static const char *const kinds[] = { "", "single", "gammon", "backgammon" }; + const char *position_id = opt->args[0], *match_id = opt->args[1]; + engine_board board; + engine_result result; + engine_status status; + + if (opt->arg_count != 2) + return usage_error("arguments attendus : POSITION_ID MATCH_ID"); + status = engine_decode_position(position_id, &board); + if (status == ENGINE_OK) + status = engine_game_result(position_id, match_id, &result); + if (status != ENGINE_OK) + return print_engine_error(status); + + printf("{\"position_id\": \"%s\", \"match_id\": \"%s\", \"on_roll\": ", position_id, + match_id); + print_side(&board.on_roll); + printf(", \"opponent\": "); + print_side(&board.opponent); + printf(", \"swapped_position_id\": \"%s\", \"result\": ", board.swapped_position_id); + if (result.over) + printf("{\"winner\": \"%s\", \"kind\": \"%s\", \"points\": %d}", + result.winner == 0 ? "on_roll" : "opponent", kinds[result.kind], result.points); + else + printf("null"); + printf("}\n"); + return EXIT_SUCCESS; +} + +/* Lit une case d'un demi-coup : 1 à 25, « bar » (25) ou « off » (0). */ +static int +parse_point(const char *text, size_t length, int *point) +{ + char buffer[4]; + char *end; + long value; + + if (length == 0 || length >= sizeof buffer) + return 0; + memcpy(buffer, text, length); + buffer[length] = '\0'; + if (strcmp(buffer, "bar") == 0) { + *point = ENGINE_BAR; + return 1; + } + if (strcmp(buffer, "off") == 0) { + *point = ENGINE_OFF; + return 1; + } + value = strtol(buffer, &end, 10); + if (*end || value < 1 || value > ENGINE_BAR) + return 0; + *point = (int) value; + return 1; +} + +/* Lit des demi-coups séparés par des espaces, chacun « DÉPART/ARRIVÉE ». + * Renvoie leur nombre, ou -1 si le texte est mal formé. */ +static int +parse_half_moves(const char *text, engine_half_move half_moves[ENGINE_MAX_HALF_MOVES]) +{ + int count = 0; + + while (*text) { + const char *start, *slash; + size_t length; + + while (*text == ' ') + text++; + if (!*text) + break; + start = text; + while (*text && *text != ' ') + text++; + length = (size_t) (text - start); + slash = memchr(start, '/', length); + if (!slash || count == ENGINE_MAX_HALF_MOVES + || !parse_point(start, (size_t) (slash - start), &half_moves[count].from) + || !parse_point(slash + 1, length - (size_t) (slash - start) - 1, &half_moves[count].to)) + return -1; + count++; + } + return count; +} + +/* halfmoves : vérifie des demi-coups saisis un par un. */ +static int +run_half_moves_command(const options *opt) +{ + const char *position_id = opt->args[0]; + engine_half_move half_moves[ENGINE_MAX_HALF_MOVES]; + engine_half_moves_check check; + engine_status status; + int die1, die2, count = 0; + + if (opt->arg_count != 2 && opt->arg_count != 3) + return usage_error("arguments attendus : POSITION_ID DÉS [DEMI-COUPS]"); + if (!parse_dice(opt->args[1], &die1, &die2)) + return usage_error("dés attendus sous la forme 31"); + if (opt->arg_count == 3) + count = parse_half_moves(opt->args[2], half_moves); + if (count < 0) + return usage_error("demi-coups attendus sous la forme 8/5 6/5, bar/22 ou 6/off"); + + status = engine_check_half_moves(position_id, die1, die2, half_moves, count, &check); + if (status != ENGINE_OK) + return print_engine_error(status); + + printf("{\"position_id\": \"%s\", \"dice\": [%d, %d], \"half_moves\": ", position_id, + die1, die2); + print_half_moves(half_moves, count); + printf(", \"valid\": %s, \"complete\": %s, \"result_position_id\": ", + check.valid ? "true" : "false", check.complete ? "true" : "false"); + if (check.valid) + printf("\"%s\"}\n", check.position_id); + else + printf("null}\n"); + return EXIT_SUCCESS; +} + +/* matchid : Match ID d'une partie libre avec la règle Jacoby, joueur 0 au + * trait. */ +static int +run_match_id_command(const options *opt) +{ + engine_match match = { .cube_value = 1, .cube_owner = -1, .jacoby = 1 }; + char match_id[13]; + char *end; + engine_status status; + + if (opt->arg_count != 2) + return usage_error("arguments attendus : VIDEAU PROPRIÉTAIRE"); + match.cube_value = (int) strtol(opt->args[0], &end, 10); + if (*end || end == opt->args[0]) + return usage_error("VIDEAU attend un nombre"); + match.cube_owner = (int) strtol(opt->args[1], &end, 10); + if (*end || end == opt->args[1]) + return usage_error("PROPRIÉTAIRE attend -1, 0 ou 1"); + + status = engine_encode_match(&match, match_id); + if (status != ENGINE_OK) + return print_engine_error(status); + printf("{\"match_id\": \"%s\", \"match\": ", match_id); + print_match(&match); + printf("}\n"); + return EXIT_SUCCESS; +} + /* * reference : un niveau d'un fichier de résultat de tests/reference/, de * même structure que plies[""] (voir tests/reference/README.md). @@ -359,7 +551,7 @@ parse_options(int argc, char **argv, options *opt) } } if (!opt->command) - return "commande attendue : bestmove, moves, legal, cube, eval ou reference"; + return "commande attendue : " COMMANDS; return NULL; } @@ -393,8 +585,14 @@ main(int argc, char **argv) result = run_position_command(&opt); else if (strcmp(opt.command, "reference") == 0) result = run_reference_command(&opt); + else if (strcmp(opt.command, "position") == 0) + result = run_board_command(&opt); + else if (strcmp(opt.command, "halfmoves") == 0) + result = run_half_moves_command(&opt); + else if (strcmp(opt.command, "matchid") == 0) + result = run_match_id_command(&opt); else - result = usage_error("commande inconnue : bestmove, moves, legal, cube, eval ou reference"); + result = usage_error("commande inconnue : " COMMANDS); #ifndef __EMSCRIPTEN__ engine_shutdown(); diff --git a/engine/engine.c b/engine/engine.c index 1b6a157..b8fe441 100644 --- a/engine/engine.c +++ b/engine/engine.c @@ -182,6 +182,38 @@ engine_decode_match(const char *match_id, engine_match *match) return ENGINE_OK; } +/* Code un Match ID : voir engine.h. */ +engine_status +engine_encode_match(const engine_match *match, char match_id[13]) +{ + const unsigned int no_dice[2] = { 0, 0 }; + int value = match->cube_value, log2 = 0; + + while (value > 1 && value % 2 == 0) { + value /= 2; + log2++; + } + if (value != 1 || log2 > 12 || match->cube_owner < -1 || match->cube_owner > 1 + || match->player_on_roll < 0 || match->player_on_roll > 1 || match->match_length < 0) + return ENGINE_ERROR_MATCH_ID; + snprintf(match_id, 13, "%s", + MatchID(no_dice, match->player_on_roll, 0, 0, match->player_on_roll, + match->cube_owner, match->crawford, match->match_length, match->score, + match->cube_value, match->jacoby, GAME_PLAYING)); + return ENGINE_OK; +} + +/* Décode un Position ID en plateau de gnubg. */ +static engine_status +decode_board(const char *position_id, TanBoard board) +{ + if (engine_state != READY) + return ENGINE_ERROR_NOT_INITIALISED; + if (!is_base64_id(position_id, L_POSITIONID) || !PositionFromID(board, position_id)) + return ENGINE_ERROR_POSITION_ID; + return ENGINE_OK; +} + /* Décode Position ID et Match ID, et prépare le cubeinfo de gnubg. */ static engine_status decode_position(const char *position_id, const char *match_id, position *pos) @@ -189,10 +221,9 @@ decode_position(const char *position_id, const char *match_id, position *pos) engine_match *m = &pos->match; engine_status status; - if (engine_state != READY) - return ENGINE_ERROR_NOT_INITIALISED; - if (!is_base64_id(position_id, L_POSITIONID) || !PositionFromID(pos->board, position_id)) - return ENGINE_ERROR_POSITION_ID; + status = decode_board(position_id, pos->board); + if (status != ENGINE_OK) + return status; status = engine_decode_match(match_id, m); if (status != ENGINE_OK) return status; @@ -237,6 +268,13 @@ fill_move(engine_move *out, const move *m, const position *pos, int evaluated) FormatMove(out->notation, (ConstTanBoard) pos->board, m->anMove); snprintf(out->position_id, sizeof out->position_id, "%s", PositionIDFromKey(&m->key)); + /* gnubg numérote les cases de 0 à 23, la barre 24 et la sortie -1. */ + out->half_move_count = 0; + for (i = 0; i < 2 * ENGINE_MAX_HALF_MOVES && m->anMove[i] >= 0; i += 2) { + out->half_moves[i / 2].from = m->anMove[i] + 1; + out->half_moves[i / 2].to = m->anMove[i + 1] + 1; + out->half_move_count++; + } out->ply = evaluated ? (int) m->esMove.ec.nPlies : -1; for (i = 0; i < ENGINE_NUM_PROBABILITIES; i++) out->probabilities[i] = evaluated ? m->arEvalMove[i] : 0.0f; @@ -381,5 +419,240 @@ engine_cube_decision(const char *position_id, const char *match_id, int ply, eng cube->optimal = equities[OUTPUT_OPTIMAL]; cube->decision = cube_decision_names[decision]; cube->decision_text = GetCubeRecommendation(decision); + GetDPEq(&cube->available, NULL, &pos.cube); + cube->should_double = decision == DOUBLE_TAKE || decision == DOUBLE_PASS + || decision == DOUBLE_BEAVER || decision == REDOUBLE_TAKE || decision == REDOUBLE_PASS; + cube->should_take = cube->double_take <= cube->double_pass; + return ENGINE_OK; +} + +/* ------------------------------------------------- Plateau et fin de partie */ + +static void +fill_side(engine_side *side, const unsigned int checkers[25]) +{ + int i, on_board = 0; + + side->pip_count = 0; + for (i = 0; i < 25; i++) { + on_board += (int) checkers[i]; + side->pip_count += (int) checkers[i] * (i + 1); + } + for (i = 0; i < 24; i++) + side->points[i] = (int) checkers[i]; + side->bar = (int) checkers[24]; + side->off = anChequers[VARIATION_STANDARD] - on_board; +} + +engine_status +engine_decode_position(const char *position_id, engine_board *board) +{ + TanBoard b; + engine_status status = decode_board(position_id, b); + + if (status != ENGINE_OK) + return status; + /* Dans gnubg, b[1] est le joueur au trait et b[0] son adversaire. */ + fill_side(&board->on_roll, b[1]); + fill_side(&board->opponent, b[0]); + SwapSides(b); + snprintf(board->swapped_position_id, sizeof board->swapped_position_id, "%s", PositionID((ConstTanBoard) b)); + return ENGINE_OK; +} + +engine_status +engine_game_result(const char *position_id, const char *match_id, engine_result *result) +{ + TanBoard b; + engine_match match; + engine_status status = decode_board(position_id, b); + int i; + + if (status == ENGINE_OK) + status = engine_decode_match(match_id, &match); + if (status != ENGINE_OK) + return status; + + memset(result, 0, sizeof *result); + result->kind = GameStatus((ConstTanBoard) b, VARIATION_STANDARD); + if (result->kind == 0) + return ENGINE_OK; + result->over = 1; + /* Le gagnant est celui qui n'a plus aucune dame sur le plateau. */ + for (i = 0; i < 25 && b[1][i] == 0; i++) + ; + result->winner = i == 25 ? 0 : 1; + /* Règle Jacoby : gammons et backgammons ne comptent qu'une fois le + * videau tourné, c'est-à-dire quand il n'est plus au centre. */ + if (match.match_length == 0 && match.jacoby && match.cube_owner == -1) + result->points = match.cube_value; + else + result->points = result->kind * match.cube_value; + return ENGINE_OK; +} + +/* ------------------------------------------ Saisie demi-coup par demi-coup */ + +/* + * La légalité de chaque demi-coup vient de gnubg : GenerateMoves avec un seul + * dé (le second à 0) donne exactement les demi-coups permis avec ce dé (barre + * d'abord, case libre, règles de sortie). Celle du coup entier aussi : les + * demi-coups doivent pouvoir se compléter en une position de la liste des + * coups légaux, qui impose de jouer le plus de dés possible. + */ + +/* Un demi-coup permis et la position où il mène. */ +typedef struct { + int from, to; /* numérotation de gnubg : 0 à 24, -1 sortie */ + positionkey key; +} step; + +/* Positions d'arrivée des coups légaux. */ +typedef struct { + positionkey *keys; + int count; +} legal_results; + +/* Les demi-coups permis avec un dé. Ils sont copiés : GenerateMoves réutilise + * un tableau interne, que l'appel suivant écraserait. Il y en a au plus un + * par case de départ. */ +static int +single_steps(ConstTanBoard board, int die, step steps[25]) +{ + movelist list; + unsigned int i; + + GenerateMoves(&list, board, die, 0, FALSE); + for (i = 0; i < list.cMoves && i < 25; i++) { + steps[i].from = list.amMoves[i].anMove[0]; + steps[i].to = list.amMoves[i].anMove[1]; + steps[i].key = list.amMoves[i].key; + } + return (int) i; +} + +static int +is_legal_result(ConstTanBoard board, const legal_results *legal) +{ + positionkey key; + int i; + + PositionKey(board, &key); + for (i = 0; i < legal->count; i++) + if (EqualKeys(key, legal->keys[i])) + return 1; + return 0; +} + +/* Les dés sans celui d'indice skip. */ +static void +remove_die(const int dice[], int count, int skip, int rest[]) +{ + int i, j = 0; + + for (i = 0; i < count; i++) + if (i != skip) + rest[j++] = dice[i]; +} + +/* Vrai si board peut encore mener à un coup légal avec les dés restants. */ +static int +can_complete(ConstTanBoard board, const int dice[], int count, const legal_results *legal) +{ + step steps[25]; + int rest[ENGINE_MAX_HALF_MOVES]; + TanBoard next; + int i, j, n; + + if (is_legal_result(board, legal)) + return 1; + for (i = 0; i < count; i++) { + if (i > 0 && dice[i] == dice[0]) + continue; /* même valeur déjà essayée (double) */ + remove_die(dice, count, i, rest); + n = single_steps(board, dice[i], steps); + for (j = 0; j < n; j++) { + PositionFromKey(next, &steps[j].key); + if (can_complete((ConstTanBoard) next, rest, count - 1, legal)) + return 1; + } + } + return 0; +} + +/* Joue les demi-coups ; en cas de succès, result reçoit la position atteinte. + * Un même demi-coup peut correspondre à plusieurs dés (une sortie avec un dé + * plus fort que nécessaire) : chacun est essayé. */ +static int +play_half_moves(ConstTanBoard board, const int dice[], int count, + const engine_half_move *half_moves, int remaining, + const legal_results *legal, TanBoard result) +{ + step steps[25]; + int rest[ENGINE_MAX_HALF_MOVES]; + TanBoard next; + int i, j, n; + + if (remaining == 0) { + if (!can_complete(board, dice, count, legal)) + return 0; + memcpy(result, board, sizeof(TanBoard)); + return 1; + } + for (i = 0; i < count; i++) { + if (i > 0 && dice[i] == dice[0]) + continue; /* même valeur déjà essayée (double) */ + remove_die(dice, count, i, rest); + n = single_steps(board, dice[i], steps); + for (j = 0; j < n; j++) { + if (steps[j].from != half_moves->from - 1 || steps[j].to != half_moves->to - 1) + continue; + PositionFromKey(next, &steps[j].key); + if (play_half_moves((ConstTanBoard) next, rest, count - 1, half_moves + 1, + remaining - 1, legal, result)) + return 1; + } + } + return 0; +} + +engine_status +engine_check_half_moves(const char *position_id, int die1, int die2, + const engine_half_move *half_moves, int count, + engine_half_moves_check *check) +{ + TanBoard board, result; + movelist list; + legal_results legal; + int dice[ENGINE_MAX_HALF_MOVES] = { die1, die2, die1, die2 }; + int dice_count = die1 == die2 ? 4 : 2; + engine_status status; + unsigned int i; + + status = decode_board(position_id, board); + if (status == ENGINE_OK) + status = check_dice(die1, die2); + if (status != ENGINE_OK) + return status; + + memset(check, 0, sizeof *check); + if (count < 0 || count > dice_count) + return ENGINE_OK; + + GenerateMoves(&list, (ConstTanBoard) board, die1, die2, FALSE); + if (list.cMoves == 0) + return ENGINE_OK; /* aucun coup possible : rien à saisir */ + legal.count = (int) list.cMoves; + legal.keys = g_malloc(sizeof *legal.keys * list.cMoves); + for (i = 0; i < list.cMoves; i++) + legal.keys[i] = list.amMoves[i].key; + + if (play_half_moves((ConstTanBoard) board, dice, dice_count, half_moves, count, &legal, result)) { + check->valid = 1; + check->complete = is_legal_result((ConstTanBoard) result, &legal); + snprintf(check->position_id, sizeof check->position_id, "%s", + PositionID((ConstTanBoard) result)); + } + g_free(legal.keys); return ENGINE_OK; } diff --git a/engine/include/engine.h b/engine/include/engine.h index 39cbaeb..58e6ebb 100644 --- a/engine/include/engine.h +++ b/engine/include/engine.h @@ -66,11 +66,29 @@ enum { ENGINE_NUM_PROBABILITIES }; +/* + * Demi-coup : une dame déplacée d'une case avec un seul dé. Les cases sont + * numérotées du point de vue du joueur qui joue, comme dans la notation de + * gnubg : 1 à 24, 25 pour la barre, 0 pour la sortie. + */ +#define ENGINE_BAR 25 +#define ENGINE_OFF 0 + +typedef struct { + int from; + int to; +} engine_half_move; + +/* Un coup complet utilise au plus quatre dés (un double). */ +#define ENGINE_MAX_HALF_MOVES 4 + /* Un coup, avec son évaluation quand il a été classé. */ typedef struct { char notation[32]; /* notation de gnubg, ex. « 8/5 6/5 » */ char position_id[16]; /* Position ID après le coup, plateau vu du * joueur qui vient de jouer */ + engine_half_move half_moves[ENGINE_MAX_HALF_MOVES]; + int half_move_count; /* demi-coups du coup, dans un ordre jouable */ int ply; /* niveau de l'évaluation (-1 : non évalué) */ float probabilities[ENGINE_NUM_PROBABILITIES]; float equity; /* équité de classement (avec videau) */ @@ -96,8 +114,50 @@ typedef struct { * « DOUBLE_TAKE », « DOUBLE_PASS », * « TOOGOOD_PASS », « NOT_AVAILABLE »… */ const char *decision_text; /* libellé anglais de gnubg, ex. « Double, take » */ + int available; /* le joueur au trait a le droit de doubler */ + int should_double; /* gnubg recommande de doubler : décision + * DOUBLE_TAKE, DOUBLE_PASS, DOUBLE_BEAVER, + * REDOUBLE_TAKE ou REDOUBLE_PASS (un double + * « facultatif » n'est pas recommandé) */ + int should_take; /* s'il double, l'adversaire doit prendre : + * prendre lui coûte moins que passer + * (double_take <= double_pass) */ } engine_cube; +/* Un camp sur le plateau, vu de lui-même (sa case 1 est la plus proche de + * la sortie). */ +typedef struct { + int points[24]; /* dames sur les cases 1 à 24 (indices 0 à 23) */ + int bar; /* dames sur la barre */ + int off; /* dames sorties */ + int pip_count; /* compte de points */ +} engine_side; + +/* Plateau décodé d'un Position ID. */ +typedef struct { + engine_side on_roll; /* le joueur au trait */ + engine_side opponent; /* son adversaire */ + char swapped_position_id[16]; /* la même position vue de l'adversaire */ +} engine_board; + +/* Fin de partie. */ +typedef struct { + int over; /* la partie est finie */ + int winner; /* 0 : le joueur au trait, 1 : son adversaire */ + int kind; /* 1 : simple, 2 : gammon, 3 : backgammon */ + int points; /* points gagnés : kind fois la valeur du videau, + * mais 1 fois en partie libre avec la règle + * Jacoby quand le videau n'a pas bougé */ +} engine_result; + +/* Où en est un coup saisi demi-coup par demi-coup. */ +typedef struct { + int valid; /* les demi-coups commencent un coup légal */ + int complete; /* ils forment à eux seuls un coup légal */ + char position_id[16]; /* si valid : position après ces demi-coups, vue + * du joueur qui joue ; sinon chaîne vide */ +} engine_half_moves_check; + /* Description lisible d'un Match ID décodé. */ typedef struct { int match_length; /* 0 : partie libre (money game) */ @@ -125,6 +185,30 @@ const char *engine_status_message(engine_status status); /* Décode un Match ID. */ engine_status engine_decode_match(const char *match_id, engine_match *match); +/* + * Code un Match ID (12 caractères et le zéro final), sans dés ni double en + * cours. cube_value : puissance de 2 de 1 à 4096 ; cube_owner : -1, 0 ou 1. + */ +engine_status engine_encode_match(const engine_match *match, char match_id[13]); + +/* Décode un Position ID en plateau. */ +engine_status engine_decode_position(const char *position_id, engine_board *board); + +/* La partie est-elle finie dans cette position, et combien rapporte-t-elle ? */ +engine_status engine_game_result(const char *position_id, const char *match_id, + engine_result *result); + +/* + * Vérifie des demi-coups saisis un par un (count de 0 à + * ENGINE_MAX_HALF_MOVES), avec le jet die1, die2 : chacun doit être permis + * par les règles, et l'ensemble doit pouvoir se compléter en un des coups + * légaux d'engine_legal_moves. Des demi-coups refusés ne sont pas une erreur : + * check->valid vaut alors 0. + */ +engine_status engine_check_half_moves(const char *position_id, int die1, int die2, + const engine_half_move *half_moves, int count, + engine_half_moves_check *check); + /* * Coups légaux pour une position et un jet (die1, die2 dans 1..6), sans * évaluation (ply vaut -1). moves doit pouvoir contenir max_moves coups ; diff --git a/tests/test_engine.sh b/tests/test_engine.sh index c09f1e6..a3cf6a7 100644 --- a/tests/test_engine.sh +++ b/tests/test_engine.sh @@ -138,6 +138,87 @@ attendre_erreur usage bestmove "$depart" "$libre_j0" attendre_erreur usage inconnue "$depart" "$libre_j0" attendre_erreur invalid_data --data /dossier/absent bestmove "$depart" "$libre_j0" 31 +# 6. Ce dont a besoin le domaine d'une partie (étape 0d.1). + +# Les coups légaux donnent leurs demi-coups, dans un ordre jouable. +grep -qF '"move": "8/5 6/5", "position_id": "4HPwATCwZ/ABMA", "half_moves": [[8, 5], [6, 5]]' \ + <<<"$($cli legal "$depart" "$libre_j0" 31)" || echouer "legal : demi-coups de 8/5 6/5 absents" + +# Match ID : codage, et décodage identique. +[ "$(valeur match_id "$($cli matchid 1 -1)")" = "$libre_j0" ] || echouer "matchid 1 -1 : $libre_j0 attendu" +videau4="$(valeur match_id "$($cli matchid 4 1)")" +sortie="$($cli eval "$depart" "$videau4")" +[ "$(valeur cube_value "$sortie")" = 4 ] && [ "$(valeur cube_owner "$sortie")" = 1 ] \ + || echouer "matchid 4 1 : videau mal codé ($sortie)" +attendre_erreur invalid_match_id matchid 3 0 +attendre_erreur invalid_match_id matchid 2 5 + +# Plateau : position de départ, symétrique, sans résultat. +sortie="$($cli position "$depart" "$libre_j0")" +grep -qF '"on_roll": {"points": [0, 0, 0, 0, 0, 5, 0, 3, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2], "bar": 0, "off": 0, "pip_count": 167}' <<<"$sortie" \ + || echouer "position de départ mal décodée : $sortie" +[ "$(valeur swapped_position_id "$sortie")" = "$depart" ] || echouer "départ : la position vue de l'adversaire diffère" +grep -qF '"result": null' <<<"$sortie" || echouer "départ : la partie ne devrait pas être finie" +attendre_erreur invalid_position_id position 'ceci-nest-pas-un-id' "$libre_j0" + +# Fin de partie : le joueur au trait a tout sorti, l'adversaire aucune dame +# (gammon). Règle Jacoby : 1 point tant que le videau est au centre ; sinon +# 2 fois le videau. +fini=/38AAAAAAAAAAA +grep -qF '"result": {"winner": "on_roll", "kind": "gammon", "points": 1}' \ + <<<"$($cli position "$fini" "$libre_j0")" || echouer "gammon avec Jacoby, videau au centre : 1 point attendu" +grep -qF '"result": {"winner": "on_roll", "kind": "gammon", "points": 8}' \ + <<<"$($cli position "$fini" "$videau4")" || echouer "gammon, videau à 4 : 8 points attendus" +vu_du_perdant="$(valeur swapped_position_id "$($cli position "$fini" "$libre_j0")")" +grep -qF '"winner": "opponent"' <<<"$($cli position "$vu_du_perdant" "$libre_j0")" \ + || echouer "fin de partie vue du perdant : l'adversaire devrait gagner" + +# Demi-coups : « valid complete position_id » pour une saisie. +demi_coups() { + local sortie valid complete + sortie="$($cli halfmoves "$@")" + valid="$(grep -oE '"valid": (true|false)' <<<"$sortie" | cut -d' ' -f2)" + complete="$(grep -oE '"complete": (true|false)' <<<"$sortie" | cut -d' ' -f2)" + echo "$valid $complete $(valeur result_position_id "$sortie")" +} +attendre_demi_coups() { + local attendu="$1" + shift + local obtenu + obtenu="$(demi_coups "$@")" + [ "$obtenu" = "$attendu" ] || echouer "halfmoves $* : « $obtenu » au lieu de « $attendu »" +} +attendre_demi_coups "true false 4HPwATDQZ/ABMA" "$depart" 31 "8/5" +attendre_demi_coups "true true 4HPwATCwZ/ABMA" "$depart" 31 "8/5 6/5" +attendre_demi_coups "true true 4HPwATCwZ/ABMA" "$depart" 31 "6/5 8/5" +attendre_demi_coups "true false $depart" "$depart" 31 +# Case 12 tenue par l'adversaire ; 8/4 demande deux dés. +attendre_demi_coups "false false " "$depart" 31 "13/12" +attendre_demi_coups "false false " "$depart" 31 "8/4" +# Le même pion avec les deux dés. +attendre_demi_coups "true true 4HPwATDgc/ABIQ" "$depart" 31 "24/21 21/20" +# Sortie : la dame la plus éloignée d'abord avec un dé trop fort. +attendre_demi_coups "true true /38AAAAAAAAAAA" "$course" 66 "6/off 5/off" +attendre_demi_coups "false false " "$course" 66 "5/off" +attendre_demi_coups "true false /38AAAAgAAAAAA" "$course" 65 "5/off" +# Course, jet 21 : il faut jouer les deux dés. +attendre_demi_coups "false false " "$course" 21 "6/3" +attendre_demi_coups "true true /38AAAAkAAAAAA" "$course" 21 "6/5 5/3" +# Aucun coup possible : rien n'est valide. +attendre_demi_coups "false false " 27YBBwAcz8EDQA 66 +attendre_erreur usage halfmoves "$depart" 31 "8-5" +attendre_erreur invalid_dice halfmoves "$depart" 71 "8/5" + +# Videau : droit de doubler et recommandations. +sortie="$($cli cube "$depart" "$libre_j0")" +grep -qF '"available": true, "double": false, "take": true' <<<"$sortie" \ + || echouer "videau au départ : disponible, pas de double, prendre attendus" +grep -qF '"available": true, "double": true, "take": false' <<<"$($cli cube "$course" "$libre_j0")" \ + || echouer "videau en course gagnée : double et passe attendus" +# Videau chez l'adversaire : le joueur au trait ne peut pas doubler. +grep -qF '"available": false, "double": false' <<<"$($cli cube "$course" "$(valeur match_id "$($cli matchid 2 1)")")" \ + || echouer "videau chez l'adversaire : double indisponible attendu" + if [ "$echecs" -gt 0 ]; then echo " $echecs vérification(s) du moteur en échec." exit 1 From db76200c6915702b387e0d45701c2a871d838289 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Mon, 2 Nov 2026 20:41:57 +0100 Subject: [PATCH 2/7] =?UTF-8?q?Moteur=20:=20=C2=AB=20pion=20=C2=BB=20plut?= =?UTF-8?q?=C3=B4t=20que=20=C2=AB=20dame=20=C2=BB,=20comme=20le=20glossair?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- engine/engine.c | 2 +- engine/include/engine.h | 8 ++++---- tests/test_engine.sh | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/engine/engine.c b/engine/engine.c index b8fe441..f8d29e1 100644 --- a/engine/engine.c +++ b/engine/engine.c @@ -478,7 +478,7 @@ engine_game_result(const char *position_id, const char *match_id, engine_result if (result->kind == 0) return ENGINE_OK; result->over = 1; - /* Le gagnant est celui qui n'a plus aucune dame sur le plateau. */ + /* Le gagnant est celui qui n'a plus aucun pion sur le plateau. */ for (i = 0; i < 25 && b[1][i] == 0; i++) ; result->winner = i == 25 ? 0 : 1; diff --git a/engine/include/engine.h b/engine/include/engine.h index 58e6ebb..d5554c0 100644 --- a/engine/include/engine.h +++ b/engine/include/engine.h @@ -67,7 +67,7 @@ enum { }; /* - * Demi-coup : une dame déplacée d'une case avec un seul dé. Les cases sont + * Demi-coup : un pion déplacé d'une case avec un seul dé. Les cases sont * numérotées du point de vue du joueur qui joue, comme dans la notation de * gnubg : 1 à 24, 25 pour la barre, 0 pour la sortie. */ @@ -127,9 +127,9 @@ typedef struct { /* Un camp sur le plateau, vu de lui-même (sa case 1 est la plus proche de * la sortie). */ typedef struct { - int points[24]; /* dames sur les cases 1 à 24 (indices 0 à 23) */ - int bar; /* dames sur la barre */ - int off; /* dames sorties */ + int points[24]; /* pions sur les cases 1 à 24 (indices 0 à 23) */ + int bar; /* pions sur la barre */ + int off; /* pions sortis */ int pip_count; /* compte de points */ } engine_side; diff --git a/tests/test_engine.sh b/tests/test_engine.sh index a3cf6a7..d733699 100644 --- a/tests/test_engine.sh +++ b/tests/test_engine.sh @@ -148,7 +148,7 @@ grep -qF '"move": "8/5 6/5", "position_id": "4HPwATCwZ/ABMA", "half_moves": [[8, [ "$(valeur match_id "$($cli matchid 1 -1)")" = "$libre_j0" ] || echouer "matchid 1 -1 : $libre_j0 attendu" videau4="$(valeur match_id "$($cli matchid 4 1)")" sortie="$($cli eval "$depart" "$videau4")" -[ "$(valeur cube_value "$sortie")" = 4 ] && [ "$(valeur cube_owner "$sortie")" = 1 ] \ +[ "$(valeur cube_value "$sortie") $(valeur cube_owner "$sortie")" = "4 1" ] \ || echouer "matchid 4 1 : videau mal codé ($sortie)" attendre_erreur invalid_match_id matchid 3 0 attendre_erreur invalid_match_id matchid 2 5 @@ -161,7 +161,7 @@ grep -qF '"on_roll": {"points": [0, 0, 0, 0, 0, 5, 0, 3, 0, 0, 0, 0, 5, 0, 0, 0, grep -qF '"result": null' <<<"$sortie" || echouer "départ : la partie ne devrait pas être finie" attendre_erreur invalid_position_id position 'ceci-nest-pas-un-id' "$libre_j0" -# Fin de partie : le joueur au trait a tout sorti, l'adversaire aucune dame +# Fin de partie : le joueur au trait a tout sorti, l'adversaire aucun pion # (gammon). Règle Jacoby : 1 point tant que le videau est au centre ; sinon # 2 fois le videau. fini=/38AAAAAAAAAAA @@ -197,7 +197,7 @@ attendre_demi_coups "false false " "$depart" 31 "13/12" attendre_demi_coups "false false " "$depart" 31 "8/4" # Le même pion avec les deux dés. attendre_demi_coups "true true 4HPwATDgc/ABIQ" "$depart" 31 "24/21 21/20" -# Sortie : la dame la plus éloignée d'abord avec un dé trop fort. +# Sortie : le pion le plus éloigné d'abord avec un dé trop fort. attendre_demi_coups "true true /38AAAAAAAAAAA" "$course" 66 "6/off 5/off" attendre_demi_coups "false false " "$course" 66 "5/off" attendre_demi_coups "true false /38AAAAgAAAAAA" "$course" 65 "5/off" From 54883f4ca35c78240528cd211584432c7349950b Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Mon, 2 Nov 2026 20:41:57 +0100 Subject: [PATCH 3/7] Outils de l'application web : image ustagammon/web, make web, lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Image docker/web/ (Node 22.11) avec TypeScript 6.0.3, @types/node, Biome 2.5.15, @cucumber/cucumber 12.9.0 et dependency-cruiser 18.6.0, versions figées (ADR 0009). make web compile app/ dans build/web/ ; make lint passe Biome et tsc --noEmit, et vérifie à part que le domaine compile sans les types de Node ni du navigateur. make setup construit l'image ; CREDITS.md à jour. Co-Authored-By: Claude Opus 5.5 --- CREDITS.md | 8 ++++++++ Makefile | 22 +++++++++++++++++++--- app/biome.json | 20 ++++++++++++++++++++ app/package.json | 6 ++++++ app/tsconfig.json | 18 ++++++++++++++++++ docker/web/Dockerfile | 25 +++++++++++++++++++++++++ tests/lint.sh | 13 +++++++++++++ 7 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 app/biome.json create mode 100644 app/package.json create mode 100644 app/tsconfig.json create mode 100644 docker/web/Dockerfile diff --git a/CREDITS.md b/CREDITS.md index f7e62ec..399d9e0 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -49,6 +49,14 @@ Les contenus pédagogiques (leçons, textes) sont sous licence CC BY-SA 4.0. inclus dans le moteur WebAssembly, licences compatibles GPLv3. L'image fournit aussi **Node.js** (MIT), qui rejoue la suite de contrat sur le moteur WebAssembly et lance `make bench`. +- **Image `ustagammon/web`** (`docker/web/Dockerfile`), construite sur + `node:22.11.0-alpine3.20` : **TypeScript** 6.0.3 (Apache-2.0), qui compile + `app/` ; **@types/node** 22.20.5 (MIT), types de Node pour les tests ; + **Biome** 2.5.15 (MIT ou Apache-2.0), lint et formatage ; + **@cucumber/cucumber** 12.9.0 (MIT), qui exécute les scénarios de + `docs/scenarios/` ; **dependency-cruiser** 18.6.0 (MIT), qui vérifie les + règles d'architecture. Ces outils et leurs dépendances servent seulement à + compiler et tester : rien n'est embarqué dans l'application. ## Remerciements diff --git a/Makefile b/Makefile index 861c59e..4689c18 100644 --- a/Makefile +++ b/Makefile @@ -20,17 +20,21 @@ REFERENCE_IMAGE := ustagammon/gnubg-reference:14b6798 BUILD_C_IMAGE := ustagammon/build-c:2 # Emscripten (compilation WebAssembly ; contient aussi Node et Python 3). WASM_IMAGE := emscripten/emsdk:3.1.64 +# Outils de l'application web (docker/web/) : TypeScript, Biome, Cucumber, +# dependency-cruiser. Changer l'étiquette à chaque modification du Dockerfile. +WEB_IMAGE := ustagammon/web:1 # Port de la page de test servie sur le réseau local (make deploy-web). WEB_PORT := 8080 -export SHELLCHECK_IMAGE GHERKIN_IMAGE BUILD_C_IMAGE WASM_IMAGE +export SHELLCHECK_IMAGE GHERKIN_IMAGE BUILD_C_IMAGE WASM_IMAGE WEB_IMAGE # Lance une commande dans la chaîne C, sous l'utilisateur courant pour que # build/ lui appartienne. DOCKER_C := docker run --rm --user "$$(id -u):$$(id -g)" -v "$(CURDIR):/src" -w /src $(BUILD_C_IMAGE) DOCKER_WASM := docker run --rm --user "$$(id -u):$$(id -g)" -e HOME=/tmp -v "$(CURDIR):/src" -w /src $(WASM_IMAGE) +DOCKER_WEB := docker run --rm --user "$$(id -u):$$(id -g)" -v "$(CURDIR):/src" -w /src $(WEB_IMAGE) -.PHONY: help setup build engine engine-wasm bench test lint deploy deploy-web clean reference-image reference +.PHONY: help setup build engine engine-wasm web bench test lint deploy deploy-web clean reference-image reference help: ## Liste les commandes disponibles @echo "Ustagammon : commandes disponibles" @@ -48,9 +52,11 @@ setup: ## Installe la chaîne d'outils (images Docker) ; sans effet si déjà fa || docker build -t $(BUILD_C_IMAGE) docker/build-c @docker image inspect $(WASM_IMAGE) >/dev/null 2>&1 \ || docker pull $(WASM_IMAGE) + @docker image inspect $(WEB_IMAGE) >/dev/null 2>&1 \ + || docker build -t $(WEB_IMAGE) docker/web @echo "Chaîne d'outils prête." -build: engine ## Compile le moteur (si nécessaire) puis l'application +build: engine-wasm web ## Compile le moteur (si nécessaire) puis l'application engine: ## Compile le moteur, ses données et build/engine-cli (dans Docker) @docker image inspect $(BUILD_C_IMAGE) >/dev/null 2>&1 \ @@ -62,6 +68,16 @@ engine-wasm: engine ## Compile le moteur en WebAssembly dans build/wasm/ (dans D || { echo "Emscripten absent : lancer d'abord make setup"; exit 1; } @$(DOCKER_WASM) make -f engine-wasm/wasm.mk -j2 --no-print-directory +# L'application est recompilée entièrement (quelques secondes) : build/web/ +# ne garde ainsi aucun fichier dont la source a disparu. Le package.json +# indique à Node que les .js produits sont des modules ES. +web: ## Compile l'application TypeScript (app/) dans build/web/ (dans Docker) + @docker image inspect $(WEB_IMAGE) >/dev/null 2>&1 \ + || { echo "Outils web absents : lancer d'abord make setup"; exit 1; } + @rm -rf $(BUILD_DIR)/web + @$(DOCKER_WEB) tsc -p app/tsconfig.json + @cp app/package.json $(BUILD_DIR)/web/package.json + bench: engine-wasm ## Mesure le moteur WebAssembly sous Node, sur ce PC (rapport texte) @$(DOCKER_WASM) node engine-wasm/bench.mjs diff --git a/app/biome.json b/app/biome.json new file mode 100644 index 0000000..8383124 --- /dev/null +++ b/app/biome.json @@ -0,0 +1,20 @@ +{ + "files": { + "includes": ["**", "!**/node_modules"] + }, + "formatter": { + "indentStyle": "space", + "indentWidth": 4, + "lineWidth": 100 + }, + "linter": { + "rules": { + "preset": "recommended" + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + } +} diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..fcec3c7 --- /dev/null +++ b/app/package.json @@ -0,0 +1,6 @@ +{ + "name": "ustagammon-app", + "private": true, + "license": "GPL-3.0-or-later", + "type": "module" +} diff --git a/app/tsconfig.json b/app/tsconfig.json new file mode 100644 index 0000000..59a052b --- /dev/null +++ b/app/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "rootDir": ".", + "outDir": "../build/web" + }, + "include": ["src", "tests"] +} diff --git a/docker/web/Dockerfile b/docker/web/Dockerfile new file mode 100644 index 0000000..9ba20e6 --- /dev/null +++ b/docker/web/Dockerfile @@ -0,0 +1,25 @@ +# Outils de l'application web (ADR 0009) : TypeScript, Biome, Cucumber et +# dependency-cruiser, sous Node. Rien de tout cela n'est embarqué dans +# l'application : ces outils compilent, vérifient et testent. +# +# Les modules sont installés dans /opt/tools, puis rendus visibles sous +# /node_modules, à la racine du conteneur : Node et TypeScript les trouvent en +# remontant depuis le dépôt monté sous /src, sans node_modules dans le dépôt. +# (npm refuse d'installer directement à la racine.) +# +# Versions figées, publiées depuis plus de deux semaines au 2026-11-02. +# Cucumber reste en 12.x : la 13 charge des modules ES avec require(), ce que +# Node 22.11 ne fait pas sans option (il faut Node 22.12). +FROM node:22.11.0-alpine3.20 + +WORKDIR /opt/tools +RUN npm install --no-audit --no-fund --save-exact \ + typescript@6.0.3 \ + @types/node@22.20.5 \ + @biomejs/biome@2.5.15 \ + @cucumber/cucumber@12.9.0 \ + dependency-cruiser@18.6.0 \ + && npm cache clean --force \ + && ln -s /opt/tools/node_modules /node_modules + +ENV PATH="/opt/tools/node_modules/.bin:${PATH}" diff --git a/tests/lint.sh b/tests/lint.sh index 7631a75..7406a01 100755 --- a/tests/lint.sh +++ b/tests/lint.sh @@ -51,6 +51,19 @@ if [ "${#scripts[@]}" -gt 0 ]; then fi fi +# Application web (ADR 0009), dans Docker : Biome (style et erreurs +# courantes) et TypeScript (types). Le domaine est vérifié une seconde fois +# seul, sans les types de Node ni du navigateur : il doit rester pur. +if ! docker image inspect "${WEB_IMAGE:?lancer via make lint}" >/dev/null 2>&1; then + signaler "Outils web absents : lancer d'abord make setup" +else + web=(docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/src:ro" -w /src/app "$WEB_IMAGE") + "${web[@]}" biome check . || signaler "Biome a trouvé des problèmes (voir ci-dessus)" + "${web[@]}" tsc -p tsconfig.json --noEmit || signaler "TypeScript a trouvé des erreurs (voir ci-dessus)" + "${web[@]}" tsc -p src/domain/tsconfig.json \ + || signaler "le domaine utilise Node ou le navigateur (voir ci-dessus)" +fi + if [ "$erreurs" -gt 0 ]; then echo "Lint : $erreurs problème(s)." exit 1 From 602d0eafedaf198e48335b165cc696ce1d30963b Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Mon, 2 Nov 2026 20:42:08 +0100 Subject: [PATCH 4/7] =?UTF-8?q?Domaine=20=C2=AB=20partie=20=C2=BB=20en=20j?= =?UTF-8?q?ournal=20d'=C3=A9v=C3=A9nements,=20branch=C3=A9=20au=20moteur?= =?UTF-8?q?=20WebAssembly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app/src/domain/game/ : une partie libre contre l'ordinateur, enregistrée comme un arbre d'événements immuables et versionnés (GameStarted, DiceRolled, MovePlayed, CubeOffered, CubeTaken, CubeDropped, GameEnded) ; l'état se recalcule en les rejouant, annuler repart du parent, une action après un retour ouvre une branche. Dés injectés. Le joueur saisit son coup demi-coup par demi-coup ; l'ordinateur joue et décide du videau à 2 plis. Toutes les règles viennent du moteur, derrière l'interface Engine que le domaine définit. app/src/engine/wasm-engine.ts : l'adaptateur Engine sur les commandes d'engine-cli du moteur WebAssembly. Tests : unitaires avec un faux moteur, et deux parties complètes jusqu'à GameEnded sur le vrai moteur, avec des dés imposés. Co-Authored-By: Claude Opus 5.5 --- app/src/domain/game/cube.ts | 19 ++ app/src/domain/game/engine.ts | 119 ++++++++++++ app/src/domain/game/events.ts | 148 +++++++++++++++ app/src/domain/game/game.ts | 313 +++++++++++++++++++++++++++++++ app/src/domain/game/position.ts | 73 +++++++ app/src/domain/game/roll.ts | 46 +++++ app/src/domain/game/side.ts | 11 ++ app/src/domain/game/state.ts | 108 +++++++++++ app/src/domain/tsconfig.json | 8 + app/src/engine/wasm-engine.ts | 144 ++++++++++++++ app/src/ui/.gitkeep | 0 app/tests/full-game.test.ts | 68 +++++++ app/tests/game.test.ts | 220 ++++++++++++++++++++++ app/tests/support/node-engine.ts | 37 ++++ 14 files changed, 1314 insertions(+) create mode 100644 app/src/domain/game/cube.ts create mode 100644 app/src/domain/game/engine.ts create mode 100644 app/src/domain/game/events.ts create mode 100644 app/src/domain/game/game.ts create mode 100644 app/src/domain/game/position.ts create mode 100644 app/src/domain/game/roll.ts create mode 100644 app/src/domain/game/side.ts create mode 100644 app/src/domain/game/state.ts create mode 100644 app/src/domain/tsconfig.json create mode 100644 app/src/engine/wasm-engine.ts create mode 100644 app/src/ui/.gitkeep create mode 100644 app/tests/full-game.test.ts create mode 100644 app/tests/game.test.ts create mode 100644 app/tests/support/node-engine.ts diff --git a/app/src/domain/game/cube.ts b/app/src/domain/game/cube.ts new file mode 100644 index 0000000..0534115 --- /dev/null +++ b/app/src/domain/game/cube.ts @@ -0,0 +1,19 @@ +// Le videau. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import type { Side } from "./side.js"; + +/** + * Le videau : sa valeur (1, 2, 4…) et le camp qui le possède, seul à pouvoir + * redoubler (null : au centre, les deux peuvent doubler). + * + * Glossaire : docs/glossaire/videau.md + */ +export interface Cube { + readonly value: number; + readonly owner: Side | null; +} + +export const CENTERED_CUBE: Cube = { value: 1, owner: null }; diff --git a/app/src/domain/game/engine.ts b/app/src/domain/game/engine.ts new file mode 100644 index 0000000..12d4663 --- /dev/null +++ b/app/src/domain/game/engine.ts @@ -0,0 +1,119 @@ +// Ce que le domaine demande au moteur (ADR 0007, point 2). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Le domaine définit cette interface ; un adaptateur par plateforme +// l'implémente (src/engine/ pour le moteur WebAssembly). Toutes les règles du +// jeu viennent d'ici : le domaine n'en réimplémente aucune. +// +// Les positions sont toujours vues du joueur au trait, celui qui joue ou +// double. Les méthodes sont asynchrones : dans le navigateur, le moteur +// calcule dans un Web Worker. + +import type { HalfMove, PositionId } from "./position.js"; +import type { Roll } from "./roll.js"; + +/** + * Le moteur de jeu : GNU Backgammon. + * + * Glossaire : docs/glossaire/moteur.md + */ +export interface Engine { + /** Les coups légaux d'un jet (liste vide : le joueur ne peut pas jouer). */ + legalMoves(position: PositionId, roll: Roll): Promise; + + /** Le meilleur coup au niveau ply, ou null si aucun coup n'est possible. */ + bestMove(position: PositionId, cube: EngineCube, roll: Roll, ply: Ply): Promise; + + /** La décision de videau du joueur au trait, avant de lancer les dés. */ + cubeDecision(position: PositionId, cube: EngineCube, ply: Ply): Promise; + + /** Où en est un coup saisi demi-coup par demi-coup. */ + checkHalfMoves( + position: PositionId, + roll: Roll, + halfMoves: readonly HalfMove[], + ): Promise; + + /** Le plateau d'une position, et le résultat si la partie y est finie. */ + board(position: PositionId, cube: EngineCube): Promise; +} + +/** + * Niveau de réflexion du moteur, en plis : 0 (évaluation directe) à 4. + * + * Glossaire : docs/glossaire/pli.md + */ +export type Ply = number; + +/** Le videau vu du joueur au trait. */ +export interface EngineCube { + readonly value: number; + readonly owner: "centered" | "onRoll" | "opponent"; +} + +/** + * Un coup légal complet. + * + * Glossaire : docs/glossaire/coup.md + */ +export interface Move { + /** Notation de gnubg, par exemple « 8/5 6/5 ». */ + readonly notation: string; + /** Ses demi-coups, dans un ordre jouable. */ + readonly halfMoves: readonly HalfMove[]; + /** La position après le coup, vue du joueur qui vient de jouer. */ + readonly position: PositionId; +} + +export interface CubeDecision { + /** Le joueur au trait a le droit de doubler. */ + readonly available: boolean; + /** Le moteur lui recommande de doubler. */ + readonly double: boolean; + /** S'il double, le moteur recommande à l'adversaire de prendre. */ + readonly take: boolean; +} + +export interface HalfMovesCheck { + /** Les demi-coups commencent un coup légal. */ + readonly valid: boolean; + /** Ils forment un coup légal complet. */ + readonly complete: boolean; + /** La position après eux, vue du joueur qui joue (null si refusés). */ + readonly position: PositionId | null; +} + +/** + * Un plateau décodé, pour l'affichage et pour savoir si la partie est finie. + * + * Glossaire : docs/glossaire/plateau.md + */ +export interface Board { + readonly onRoll: BoardSide; + readonly opponent: BoardSide; + /** La même position vue de l'adversaire. */ + readonly swappedPosition: PositionId; + /** null tant que la partie n'est pas finie. */ + readonly result: BoardResult | null; +} + +/** Un camp, vu de lui-même : sa case 1 est la plus proche de sa sortie. */ +export interface BoardSide { + /** Nombre de pions sur les cases 1 à 24 (indices 0 à 23). */ + readonly points: readonly number[]; + readonly bar: number; + readonly off: number; + readonly pipCount: number; +} + +/** Simple, gammon ou backgammon. */ +export type ResultKind = "single" | "gammon" | "backgammon"; + +export interface BoardResult { + readonly winner: "onRoll" | "opponent"; + readonly kind: ResultKind; + /** Points gagnés, videau et règle Jacoby compris. */ + readonly points: number; +} diff --git a/app/src/domain/game/events.ts b/app/src/domain/game/events.ts new file mode 100644 index 0000000..8bf3596 --- /dev/null +++ b/app/src/domain/game/events.ts @@ -0,0 +1,148 @@ +// Les événements d'une partie et leur journal (ADR 0007, point 3). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Une partie est la suite de ce qui s'est passé. Un événement n'est jamais +// modifié ; chacun pointe vers le précédent (parent). Les événements forment +// donc un arbre : une branche est un nouvel événement accroché à un ancien, +// et annuler revient à repartir du parent. Le format des événements est un +// contrat durable : le changer demande une migration et un ADR. +// +// Chaque événement contient tout ce qu'il faut pour rejouer la partie sans +// le moteur : l'état se recalcule par une simple boucle (state.ts). + +import type { Cube } from "./cube.js"; +import type { ResultKind } from "./engine.js"; +import type { HalfMove, PositionId } from "./position.js"; +import type { Roll } from "./roll.js"; +import type { Side } from "./side.js"; + +/** Version du format des événements. */ +export const EVENT_VERSION = 1; + +interface EventBase { + /** Identifiant, unique dans la partie. */ + readonly id: string; + /** L'événement précédent ; null pour le premier. */ + readonly parent: string | null; + readonly version: number; +} + +/** Début de partie : la position, le videau, et qui commence (null : le jet + * d'ouverture le dira). La position est vue de celui qui commence ; celle de + * départ est la même pour les deux. */ +export interface GameStarted extends EventBase { + readonly type: "GameStarted"; + readonly position: PositionId; + readonly cube: Cube; + readonly firstTurn: Side | null; +} + +/** Des dés tirés. Pour le jet d'ouverture, side vaut null et chacun lance un + * dé : roll[0] est celui du joueur, roll[1] celui de l'ordinateur. */ +export interface DiceRolled extends EventBase { + readonly type: "DiceRolled"; + readonly side: Side | null; + readonly roll: Roll; +} + +/** Un coup joué (aucun demi-coup si le jet ne permettait pas de jouer). La + * position est celle d'après le coup, vue de l'adversaire, qui a la main. */ +export interface MovePlayed extends EventBase { + readonly type: "MovePlayed"; + readonly side: Side; + readonly halfMoves: readonly HalfMove[]; + readonly position: PositionId; +} + +/** side propose de doubler. */ +export interface CubeOffered extends EventBase { + readonly type: "CubeOffered"; + readonly side: Side; +} + +/** side prend le double : le videau double de valeur et passe chez lui. */ +export interface CubeTaken extends EventBase { + readonly type: "CubeTaken"; + readonly side: Side; +} + +/** side passe le double : il perd la partie (GameEnded suit). */ +export interface CubeDropped extends EventBase { + readonly type: "CubeDropped"; + readonly side: Side; +} + +/** Fin de partie. */ +export interface GameEnded extends EventBase { + readonly type: "GameEnded"; + readonly winner: Side; + readonly kind: ResultKind; + readonly points: number; +} + +/** + * Ce qui s'est passé à un moment de la partie. + * + * Glossaire : docs/glossaire/evenement-de-partie.md + */ +export type GameEvent = + | GameStarted + | DiceRolled + | MovePlayed + | CubeOffered + | CubeTaken + | CubeDropped + | GameEnded; + +/** Un événement avant son ajout au journal (sans id, parent ni version). */ +export type NewGameEvent = WithoutBase; + +// Omit appliqué à chaque sorte d'événement séparément (et non à leur union, +// qui ne garderait que les champs communs). +type WithoutBase = E extends unknown ? Omit : never; + +/** Tous les événements d'une partie, branches comprises, et l'événement + * courant (head) : la partie en est là. */ +export interface GameJournal { + readonly events: readonly GameEvent[]; + readonly head: string | null; +} + +export const EMPTY_JOURNAL: GameJournal = { events: [], head: null }; + +/** Ajoute un événement après l'événement courant, qui devient le nouveau. */ +export function appendEvent(journal: GameJournal, event: NewGameEvent): GameJournal { + const added = Object.freeze({ + ...event, + id: String(journal.events.length + 1), + parent: journal.head, + version: EVENT_VERSION, + }) as GameEvent; + return { events: [...journal.events, added], head: added.id }; +} + +/** Les événements du début jusqu'à l'événement courant, dans l'ordre. */ +export function currentLine(journal: GameJournal): GameEvent[] { + const byId = new Map(journal.events.map((event) => [event.id, event])); + const line: GameEvent[] = []; + let id = journal.head; + while (id !== null) { + const event = byId.get(id); + if (event === undefined) { + throw new Error(`événement introuvable : ${id}`); + } + line.push(event); + id = event.parent; + } + return line.reverse(); +} + +/** Revient à un événement passé ; le prochain ajout ouvrira une branche. */ +export function goBackTo(journal: GameJournal, id: string): GameJournal { + if (!journal.events.some((event) => event.id === id)) { + throw new Error(`événement introuvable : ${id}`); + } + return { events: journal.events, head: id }; +} diff --git a/app/src/domain/game/game.ts b/app/src/domain/game/game.ts new file mode 100644 index 0000000..6b2e211 --- /dev/null +++ b/app/src/domain/game/game.ts @@ -0,0 +1,313 @@ +// Une partie libre contre l'ordinateur. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Le joueur et l'ordinateur agissent chacun leur tour ; chaque action ajoute +// des événements au journal (events.ts), d'où l'état est recalculé +// (state.ts). Les règles viennent toutes du moteur : coups légaux, coup de +// 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 { + appendEvent, + currentLine, + EMPTY_JOURNAL, + type GameEvent, + type GameJournal, + goBackTo, + type NewGameEvent, +} from "./events.js"; +import { type HalfMove, 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. + */ +export const STANDARD_PLY: Ply = 2; + +/** Une partie qui ne commence pas à la position de départ. */ +export interface GameSetup { + /** La position, vue de firstTurn. */ + readonly position: PositionId; + /** Le camp qui a la main ; il n'y a pas de jet d'ouverture. */ + readonly firstTurn: Side; + readonly cube?: Cube; +} + +export interface GameOptions { + /** Source des dés ; le hasard ordinaire par défaut. */ + readonly dice?: DiceSource; + /** Niveau de l'ordinateur ; STANDARD_PLY par défaut. */ + readonly ply?: Ply; + readonly setup?: GameSetup; +} + +/** + * Une partie contre l'ordinateur. + * + * Glossaire : docs/glossaire/partie.md + */ +export class Game { + readonly #engine: Engine; + 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; + + private constructor(engine: Engine, options: GameOptions) { + this.#engine = engine; + this.#dice = options.dice ?? randomDice; + this.#ply = options.ply ?? STANDARD_PLY; + } + + /** Commence une partie : à la position de départ avec le jet d'ouverture, + * ou à la position de options.setup. */ + static start(engine: Engine, options: GameOptions = {}): Game { + const game = new Game(engine, options); + const setup = options.setup; + game.#append({ + type: "GameStarted", + position: setup?.position ?? START_POSITION, + cube: setup?.cube ?? CENTERED_CUBE, + firstTurn: setup?.firstTurn ?? null, + }); + while (game.state.phase === "opening") { + game.#append({ type: "DiceRolled", side: null, roll: rollDice(game.#dice) }); + } + return game; + } + + /** Tous les événements, branches comprises, et l'événement courant. */ + get journal(): GameJournal { + return this.#journal; + } + + /** Les événements du début jusqu'à maintenant. */ + get events(): GameEvent[] { + return currentLine(this.#journal); + } + + get state(): GameState { + return replay(this.events); + } + + /** Les demi-coups déjà saisis du coup en cours. */ + get pendingHalfMoves(): readonly HalfMove[] { + return this.#pending; + } + + /** La position à afficher : après les demi-coups déjà saisis. */ + get displayedPosition(): PositionId { + return this.#pendingPosition ?? this.state.position; + } + + // ------------------------------------------------------------- Joueur + + /** Le joueur lance les dés. S'il ne peut pas jouer, son tour passe. */ + async roll(): Promise { + const state = this.#expect("player", "rolling"); + const roll = rollDice(this.#dice); + this.#append({ type: "DiceRolled", side: "player", roll }); + const moves = await this.#engine.legalMoves(state.position, roll); + if (moves.length === 0) { + await this.#playMove("player", [], null); + } + } + + /** + * 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é. + */ + async playHalfMove(halfMove: HalfMove): Promise { + const state = this.#expect("player", "moving"); + const halfMoves = [...this.#pending, halfMove]; + const check = await this.#engine.checkHalfMoves( + state.position, + rollToPlay(state), + halfMoves, + ); + 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; + } + return true; + } + + /** Oublie les demi-coups saisis du coup en cours. */ + cancelHalfMoves(): void { + this.#pending = []; + this.#pendingPosition = null; + } + + /** Le joueur double, avant de lancer les dés ; l'ordinateur prend ou passe + * selon le moteur. */ + async double(): Promise { + const state = this.#expect("player", "rolling"); + const decision = await this.#engine.cubeDecision( + state.position, + engineCube(state.cube, "player"), + this.#ply, + ); + if (!decision.available) { + throw new Error("vous ne pouvez pas doubler : le videau est chez l'ordinateur"); + } + this.#append({ type: "CubeOffered", side: "player" }); + this.#answerDouble("computer", decision.take); + } + + /** Le joueur prend le double de l'ordinateur. */ + take(): void { + this.#expect("computer", "doubled"); + this.#answerDouble("player", true); + } + + /** Le joueur passe le double de l'ordinateur : il perd la partie. */ + drop(): void { + this.#expect("computer", "doubled"); + this.#answerDouble("player", false); + } + + // --------------------------------------------------------- Ordinateur + + /** + * Le tour de l'ordinateur : il double si le moteur le recommande (le + * tour s'arrête alors, en attendant la réponse du joueur), sinon il lance + * les dés et joue le meilleur coup du moteur. + */ + async playComputerTurn(): Promise { + let state = this.#expect("computer", "rolling", "moving"); + if (state.phase === "rolling") { + const decision = await this.#engine.cubeDecision( + state.position, + engineCube(state.cube, "computer"), + this.#ply, + ); + if (decision.double) { + this.#append({ type: "CubeOffered", side: "computer" }); + return; + } + this.#append({ type: "DiceRolled", side: "computer", roll: rollDice(this.#dice) }); + state = this.state; + } + const best = await this.#engine.bestMove( + state.position, + engineCube(state.cube, "computer"), + rollToPlay(state), + this.#ply, + ); + await this.#playMove("computer", best?.halfMoves ?? [], best?.position ?? null); + } + + // ------------------------------------------------------------ Retour + + /** Revient à un événement passé (« Rejouer d'ici ») : la suite reste dans + * le journal, et la prochaine action ouvre une nouvelle branche. */ + goBackTo(eventId: string): void { + this.cancelHalfMoves(); + this.#journal = goBackTo(this.#journal, eventId); + } + + /** Annule le dernier événement : la partie repart de son parent. */ + undo(): void { + const last = this.events.at(-1); + if (last?.parent == null) { + throw new Error("rien à annuler"); + } + this.goBackTo(last.parent); + } + + // ------------------------------------------------------------ Interne + + #append(event: NewGameEvent): void { + this.#journal = appendEvent(this.#journal, event); + } + + /** Vérifie que c'est bien à side d'agir, dans l'une des phases. */ + #expect(side: Side, ...phases: GameState["phase"][]): GameState { + const state = this.state; + if (state.turn !== side || !phases.includes(state.phase)) { + throw new Error( + `action impossible : la main est à ${state.turn ?? "personne"}, phase ${state.phase}`, + ); + } + return state; + } + + /** + * Enregistre un coup. after est la position après le coup, vue de celui + * qui joue (null s'il ne pouvait pas jouer). Le moteur dit si la partie + * est finie et donne la position vue de l'adversaire. + */ + async #playMove( + side: Side, + halfMoves: readonly HalfMove[], + after: PositionId | null, + ): Promise { + const state = this.state; + const board = await this.#engine.board( + after ?? state.position, + engineCube(state.cube, side), + ); + this.#append({ + type: "MovePlayed", + side, + halfMoves: halfMoves.map((h) => ({ from: h.from, to: h.to })), + position: board.swappedPosition, + }); + if (board.result !== null) { + this.#append({ + type: "GameEnded", + winner: board.result.winner === "onRoll" ? side : otherSide(side), + kind: board.result.kind, + points: board.result.points, + }); + } + } + + /** taker prend ou passe le double proposé par l'autre camp. */ + #answerDouble(taker: Side, take: boolean): void { + if (take) { + this.#append({ type: "CubeTaken", side: taker }); + return; + } + // Passer : on perd la partie à la valeur du videau d'avant le double. + this.#append({ type: "CubeDropped", side: taker }); + this.#append({ + type: "GameEnded", + winner: otherSide(taker), + kind: "single", + points: this.state.cube.value, + }); + } +} + +/** Le jet à jouer, en phase moving. */ +function rollToPlay(state: GameState): Roll { + if (state.roll === null) { + throw new Error("aucun jet à jouer"); + } + return state.roll; +} + +/** Le videau vu du camp au trait, pour le moteur. */ +function engineCube(cube: Cube, onRoll: Side): EngineCube { + if (cube.owner === null) { + return { value: cube.value, owner: "centered" }; + } + return { value: cube.value, owner: cube.owner === onRoll ? "onRoll" : "opponent" }; +} diff --git a/app/src/domain/game/position.ts b/app/src/domain/game/position.ts new file mode 100644 index 0000000..66ad199 --- /dev/null +++ b/app/src/domain/game/position.ts @@ -0,0 +1,73 @@ +// Positions et demi-coups. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +/** + * Identifiant d'une position (Position ID de gnubg, 14 caractères). Il code le + * plateau vu du joueur au trait. Le domaine ne le décode jamais lui-même : il + * le passe au moteur. + * + * Glossaire : docs/glossaire/identifiant-de-position.md + */ +export type PositionId = string; + +/** La position de départ, identique pour les deux joueurs. */ +export const START_POSITION: PositionId = "4HPwATDgc/ABMA"; + +/** Case de la barre et de la sortie dans un demi-coup. */ +export const BAR = 25; +export const OFF = 0; + +/** + * Un pion déplacé avec un seul dé, d'une case à une autre. Les cases sont + * vues du joueur qui joue : 1 à 24, BAR (25) pour la barre, OFF (0) pour la + * sortie. + * + * Glossaire : docs/glossaire/demi-coup.md + */ +export interface HalfMove { + readonly from: number; + readonly to: number; +} + +/** Lit un coup noté « 8/5 6/5 », « bar/22 » ou « 6/off » (un dé par demi-coup). */ +export function parseHalfMoves(text: string): HalfMove[] { + return text + .trim() + .split(/\s+/) + .filter((part) => part !== "") + .map((part) => { + const [from, to, ...rest] = part.split("/"); + if (from === undefined || to === undefined || rest.length > 0) { + throw new Error(`demi-coup mal formé : ${part}`); + } + return { from: parsePoint(from), to: parsePoint(to) }; + }); +} + +/** Écrit des demi-coups sous la forme « 8/5 6/5 ». */ +export function formatHalfMoves(halfMoves: readonly HalfMove[]): string { + return halfMoves.map((h) => `${formatPoint(h.from)}/${formatPoint(h.to)}`).join(" "); +} + +function parsePoint(text: string): number { + if (text === "bar") { + return BAR; + } + if (text === "off") { + return OFF; + } + const point = Number(text); + if (!Number.isInteger(point) || point < 1 || point > 24) { + throw new Error(`case invalide : ${text}`); + } + return point; +} + +function formatPoint(point: number): string { + if (point === BAR) { + return "bar"; + } + return point === OFF ? "off" : String(point); +} diff --git a/app/src/domain/game/roll.ts b/app/src/domain/game/roll.ts new file mode 100644 index 0000000..0dcced3 --- /dev/null +++ b/app/src/domain/game/roll.ts @@ -0,0 +1,46 @@ +// Jets de dés et source du hasard. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +/** + * Un jet : les deux dés, chacun de 1 à 6. + * + * Glossaire : docs/glossaire/jet.md + */ +export type Roll = readonly [number, number]; + +/** + * Source de dés : chaque appel renvoie un dé, de 1 à 6. Elle est passée au + * domaine (ADR 0007, point 4) : hasard véritable dans l'application, dés + * imposés dans les tests. + */ +export type DiceSource = () => number; + +/** Le hasard ordinaire. */ +export const randomDice: DiceSource = () => 1 + Math.floor(Math.random() * 6); + +/** Des dés imposés, dans l'ordre ; erreur s'il n'en reste plus. */ +export function fixedDice(values: readonly number[]): DiceSource { + let next = 0; + return () => { + const value = values[next]; + if (value === undefined) { + throw new Error("plus de dés imposés"); + } + next++; + return value; + }; +} + +/** Tire un jet de deux dés. */ +export function rollDice(dice: DiceSource): Roll { + return [checkDie(dice()), checkDie(dice())]; +} + +function checkDie(value: number): number { + if (!Number.isInteger(value) || value < 1 || value > 6) { + throw new Error(`dé invalide : ${value}`); + } + return value; +} diff --git a/app/src/domain/game/side.ts b/app/src/domain/game/side.ts new file mode 100644 index 0000000..3416438 --- /dev/null +++ b/app/src/domain/game/side.ts @@ -0,0 +1,11 @@ +// Les deux camps d'une partie contre l'ordinateur. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +/** Le joueur humain (« vous ») ou l'ordinateur. */ +export type Side = "player" | "computer"; + +export function otherSide(side: Side): Side { + return side === "player" ? "computer" : "player"; +} diff --git a/app/src/domain/game/state.ts b/app/src/domain/game/state.ts new file mode 100644 index 0000000..c9aae94 --- /dev/null +++ b/app/src/domain/game/state.ts @@ -0,0 +1,108 @@ +// L'état d'une partie, recalculé en rejouant ses événements. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import { CENTERED_CUBE, type Cube } from "./cube.js"; +import type { ResultKind } from "./engine.js"; +import type { GameEvent } from "./events.js"; +import { type PositionId, START_POSITION } from "./position.js"; +import type { Roll } from "./roll.js"; +import { otherSide, type Side } from "./side.js"; + +/** + * Où en est la partie : + * - opening : jet d'ouverture, chacun lance un dé ; + * - rolling : turn peut doubler, puis lance les dés ; + * - moving : turn joue le jet roll ; + * - doubled : turn a proposé de doubler, l'adversaire doit prendre ou passer ; + * - over : partie finie, voir result. + */ +export type Phase = "opening" | "rolling" | "moving" | "doubled" | "over"; + +export interface GameResult { + readonly winner: Side; + readonly kind: ResultKind; + readonly points: number; +} + +export interface GameState { + readonly phase: Phase; + /** Le camp qui a la main (null pendant le jet d'ouverture). */ + readonly turn: Side | null; + /** La position, vue du camp qui a la main. */ + readonly position: PositionId; + /** Le jet à jouer (phase moving). */ + readonly roll: Roll | null; + readonly cube: Cube; + readonly result: GameResult | null; +} + +/** Avant le premier événement. */ +const NOT_STARTED: GameState = { + phase: "opening", + turn: null, + position: START_POSITION, + roll: null, + cube: CENTERED_CUBE, + result: null, +}; + +/** Rejoue des événements, du premier au dernier. */ +export function replay(events: readonly GameEvent[]): GameState { + return events.reduce(applyEvent, NOT_STARTED); +} + +/** L'état après un événement. */ +export function applyEvent(state: GameState, event: GameEvent): GameState { + switch (event.type) { + case "GameStarted": + return { + ...NOT_STARTED, + position: event.position, + cube: event.cube, + turn: event.firstTurn, + phase: event.firstTurn === null ? "opening" : "rolling", + }; + case "DiceRolled": + if (event.side !== null) { + return { ...state, phase: "moving", roll: event.roll }; + } + // Jet d'ouverture : le plus fort dé commence et joue les deux + // dés ; en cas d'égalité, on relance. + if (event.roll[0] === event.roll[1]) { + return state; + } + return { + ...state, + phase: "moving", + turn: event.roll[0] > event.roll[1] ? "player" : "computer", + roll: event.roll, + }; + case "MovePlayed": + return { + ...state, + phase: "rolling", + turn: otherSide(event.side), + position: event.position, + roll: null, + }; + case "CubeOffered": + return { ...state, phase: "doubled" }; + case "CubeTaken": + return { + ...state, + phase: "rolling", + cube: { value: state.cube.value * 2, owner: event.side }, + }; + case "CubeDropped": + return state; + case "GameEnded": + return { + ...state, + phase: "over", + roll: null, + result: { winner: event.winner, kind: event.kind, points: event.points }, + }; + } +} diff --git a/app/src/domain/tsconfig.json b/app/src/domain/tsconfig.json new file mode 100644 index 0000000..51a14fc --- /dev/null +++ b/app/src/domain/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": [], + "noEmit": true + }, + "include": ["."] +} diff --git a/app/src/engine/wasm-engine.ts b/app/src/engine/wasm-engine.ts new file mode 100644 index 0000000..019ccca --- /dev/null +++ b/app/src/engine/wasm-engine.ts @@ -0,0 +1,144 @@ +// L'interface Engine du domaine, implémentée avec le moteur WebAssembly. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Le moteur WebAssembly répond aux mêmes commandes qu'engine-cli, en JSON +// (web/engine-wasm.mjs). Cet adaptateur traduit chaque méthode en commande et +// chaque réponse en types du domaine. Il ne sait pas comment la commande est +// exécutée : directement sous Node, dans un Web Worker dans le navigateur. + +import type { + Board, + BoardResult, + BoardSide, + CubeDecision, + Engine, + EngineCube, + HalfMovesCheck, + Move, + Ply, +} from "../domain/game/engine.js"; +import { formatHalfMoves, type HalfMove, type PositionId } from "../domain/game/position.js"; +import type { Roll } from "../domain/game/roll.js"; + +/** Exécute une commande d'engine-cli et renvoie son JSON décodé ; la + * promesse est rejetée si le moteur signale une erreur. */ +export type RunCommand = (args: string[]) => Promise; + +// Les réponses d'engine-cli utilisées ici (voir engine-cli/main.c). +interface CliMove { + move: string; + position_id: string; + half_moves: [number, number][]; +} +interface CliSide { + points: number[]; + bar: number; + off: number; + pip_count: number; +} +interface CliBoard { + on_roll: CliSide; + opponent: CliSide; + swapped_position_id: string; + result: { winner: "on_roll" | "opponent"; kind: BoardResult["kind"]; points: number } | null; +} + +export function createWasmEngine(run: RunCommand): Engine { + // Un Match ID par état du videau : le moteur les code une fois. + const matchIds = new Map(); + + async function matchId(cube: EngineCube): Promise { + // Partie libre, joueur 0 au trait : le propriétaire 0 est le joueur au + // trait, 1 son adversaire, -1 le centre. + const owner = { centered: -1, onRoll: 0, opponent: 1 }[cube.owner]; + const key = `${cube.value} ${owner}`; + let id = matchIds.get(key); + if (id === undefined) { + const answer = (await run(["matchid", String(cube.value), String(owner)])) as { + match_id: string; + }; + id = answer.match_id; + matchIds.set(key, id); + } + return id; + } + + return { + async legalMoves(position: PositionId, roll: Roll): Promise { + // Le Match ID n'a pas d'effet sur les coups légaux. + const answer = (await run(["legal", position, "MAEAAAAAAAAA", diceText(roll)])) as { + moves: CliMove[]; + }; + return answer.moves.map(toMove); + }, + + async bestMove(position: PositionId, cube: EngineCube, roll: Roll, ply: Ply) { + const args = ["--ply", String(ply), "bestmove", position, await matchId(cube)]; + const answer = (await run([...args, diceText(roll)])) as { best_move: CliMove | null }; + return answer.best_move === null ? null : toMove(answer.best_move); + }, + + async cubeDecision( + position: PositionId, + cube: EngineCube, + ply: Ply, + ): Promise { + const args = ["--ply", String(ply), "cube", position, await matchId(cube)]; + const answer = (await run(args)) as CubeDecision; + return { available: answer.available, double: answer.double, take: answer.take }; + }, + + async checkHalfMoves( + position: PositionId, + roll: Roll, + halfMoves: readonly HalfMove[], + ): Promise { + const args = ["halfmoves", position, diceText(roll), formatHalfMoves(halfMoves)]; + const answer = (await run(args)) as { + valid: boolean; + complete: boolean; + result_position_id: string | null; + }; + return { + valid: answer.valid, + complete: answer.complete, + position: answer.result_position_id, + }; + }, + + async board(position: PositionId, cube: EngineCube): Promise { + const answer = (await run(["position", position, await matchId(cube)])) as CliBoard; + return { + onRoll: toSide(answer.on_roll), + opponent: toSide(answer.opponent), + swappedPosition: answer.swapped_position_id, + result: + answer.result === null + ? null + : { + winner: answer.result.winner === "on_roll" ? "onRoll" : "opponent", + kind: answer.result.kind, + points: answer.result.points, + }, + }; + }, + }; +} + +function diceText(roll: Roll): string { + return `${roll[0]}${roll[1]}`; +} + +function toMove(move: CliMove): Move { + return { + notation: move.move, + halfMoves: move.half_moves.map(([from, to]) => ({ from, to })), + position: move.position_id, + }; +} + +function toSide(side: CliSide): BoardSide { + return { points: side.points, bar: side.bar, off: side.off, pipCount: side.pip_count }; +} diff --git a/app/src/ui/.gitkeep b/app/src/ui/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/tests/full-game.test.ts b/app/tests/full-game.test.ts new file mode 100644 index 0000000..23a4c69 --- /dev/null +++ b/app/tests/full-game.test.ts @@ -0,0 +1,68 @@ +// Une partie complète sur le vrai moteur WebAssembly, avec des dés imposés : +// l'ordinateur contre une stratégie simple du joueur (toujours le premier +// coup légal, saisi demi-coup par demi-coup, et toujours prendre). +// +// 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 type { DiceSource } from "../src/domain/game/roll.js"; +import { replay } from "../src/domain/game/state.js"; +import { loadNodeEngine } from "./support/node-engine.js"; + +/** Des dés pseudo-aléatoires reproductibles (générateur congruentiel). */ +function seededDice(seed: number): DiceSource { + let state = seed; + return () => { + state = (state * 1103515245 + 12345) % 2147483648; + return 1 + (Math.floor(state / 65536) % 6); + }; +} + +/** Joue jusqu'à la fin ; renvoie le nombre d'actions. */ +async function playToTheEnd(game: Game, engine: Awaited>) { + let actions = 0; + while (game.state.phase !== "over") { + assert.ok(++actions < 2000, "la partie ne finit pas"); + const state = game.state; + if (state.turn === "computer" && state.phase === "doubled") { + game.take(); + } else if (state.turn === "computer") { + await game.playComputerTurn(); + } else if (state.phase === "rolling") { + await game.roll(); + } else { + assert.ok(state.roll !== null); + const [first] = await engine.legalMoves(state.position, state.roll); + assert.ok(first !== undefined, "le joueur devrait avoir un coup"); + for (const halfMove of first.halfMoves) { + assert.equal(await game.playHalfMove(halfMove), true); + } + } + } + return actions; +} + +for (const [seed, ply] of [ + [1, 0], + [7, 2], +] as const) { + test(`partie complète, dés ${seed}, ordinateur à ${ply} pli(s)`, async () => { + const engine = await loadNodeEngine(); + const game = Game.start(engine, { dice: seededDice(seed), ply }); + await playToTheEnd(game, engine); + + const events = game.events; + const last = events.at(-1); + assert.equal(last?.type, "GameEnded"); + const result = game.state.result; + assert.ok(result !== null); + assert.ok(result.points >= 1); + assert.ok(events.some((e) => e.type === "MovePlayed" && e.side === "player")); + assert.ok(events.some((e) => e.type === "MovePlayed" && e.side === "computer")); + // Rejouer le journal redonne le même état. + assert.deepEqual(replay(events), game.state); + }); +} diff --git a/app/tests/game.test.ts b/app/tests/game.test.ts new file mode 100644 index 0000000..00660db --- /dev/null +++ b/app/tests/game.test.ts @@ -0,0 +1,220 @@ +// Tests unitaires du domaine « partie », avec un faux moteur (ADR 0007, +// point 7) : journal, rejeu, branches, déroulement des tours et du videau. +// +// 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 { + Board, + CubeDecision, + Engine, + HalfMovesCheck, + Move, +} from "../src/domain/game/engine.js"; +import { appendEvent, currentLine, EMPTY_JOURNAL, goBackTo } from "../src/domain/game/events.js"; +import { Game } from "../src/domain/game/game.js"; +import { formatHalfMoves, parseHalfMoves, START_POSITION } from "../src/domain/game/position.js"; +import { fixedDice } from "../src/domain/game/roll.js"; +import { replay } from "../src/domain/game/state.js"; + +/** Un faux moteur : chaque réponse est réglable, et les positions sont des + * textes sans signification. */ +function fakeEngine(overrides: Partial = {}): Engine { + const move: Move = { + notation: "8/5 6/5", + halfMoves: parseHalfMoves("8/5 6/5"), + position: "AFTER", + }; + const notOver: Board = { + onRoll: { points: [], bar: 0, off: 0, pipCount: 0 }, + opponent: { points: [], bar: 0, off: 0, pipCount: 0 }, + swappedPosition: "SWAPPED", + result: null, + }; + const noDouble: CubeDecision = { available: true, double: false, take: true }; + return { + legalMoves: async () => [move], + bestMove: async () => move, + cubeDecision: async () => noDouble, + checkHalfMoves: async (_position, _roll, halfMoves): Promise => { + const text = formatHalfMoves(halfMoves); + if (text === "8/5") { + return { valid: true, complete: false, position: "HALF" }; + } + if (text === "8/5 6/5") { + return { valid: true, complete: true, position: "AFTER" }; + } + return { valid: false, complete: false, position: null }; + }, + board: async () => notOver, + ...overrides, + }; +} + +test("le jet d'ouverture se relance en cas d'égalité, puis le plus fort dé commence", () => { + const game = Game.start(fakeEngine(), { dice: fixedDice([4, 4, 2, 5]) }); + const rolls = game.events.filter((e) => e.type === "DiceRolled"); + assert.deepEqual( + rolls.map((e) => e.roll), + [ + [4, 4], + [2, 5], + ], + ); + assert.equal(game.state.turn, "computer"); + assert.equal(game.state.phase, "moving"); + assert.deepEqual(game.state.roll, [2, 5]); + assert.equal(game.state.position, START_POSITION); +}); + +test("un coup se saisit demi-coup par demi-coup ; un demi-coup refusé ne change rien", async () => { + const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) }); + assert.equal(await game.playHalfMove({ from: 13, to: 12 }), false); + assert.equal(game.events.length, 2); + assert.equal(await game.playHalfMove({ from: 8, to: 5 }), true); + assert.equal(game.displayedPosition, "HALF"); + assert.deepEqual(game.pendingHalfMoves, [{ from: 8, to: 5 }]); + assert.equal(await game.playHalfMove({ from: 6, to: 5 }), true); + + const last = game.events.at(-1); + assert.equal(last?.type, "MovePlayed"); + assert.deepEqual(game.pendingHalfMoves, []); + assert.equal(game.state.turn, "computer"); + assert.equal(game.state.phase, "rolling"); + assert.equal(game.state.position, "SWAPPED"); +}); + +test("sans coup possible, le tour passe", async () => { + const engine = fakeEngine({ legalMoves: async () => [] }); + const game = Game.start(engine, { + dice: fixedDice([6, 6]), + setup: { position: "BLOCKED", firstTurn: "player" }, + }); + await game.roll(); + const last = game.events.at(-1); + assert.ok(last?.type === "MovePlayed"); + assert.deepEqual(last.halfMoves, []); + assert.equal(game.state.turn, "computer"); +}); + +test("l'ordinateur double si le moteur le recommande ; le joueur prend", async () => { + const engine = fakeEngine({ + cubeDecision: async () => ({ available: true, double: true, take: true }), + }); + const game = Game.start(engine, { setup: { position: "X", firstTurn: "computer" } }); + await game.playComputerTurn(); + assert.equal(game.state.phase, "doubled"); + game.take(); + assert.deepEqual(game.state.cube, { value: 2, owner: "player" }); + assert.equal(game.state.phase, "rolling"); + assert.equal(game.state.turn, "computer"); +}); + +test("le joueur passe : il perd la partie à la valeur d'avant le double", async () => { + const engine = fakeEngine({ + cubeDecision: async () => ({ available: true, double: true, take: true }), + }); + const game = Game.start(engine, { + setup: { position: "X", firstTurn: "computer", cube: { value: 2, owner: "computer" } }, + }); + await game.playComputerTurn(); + game.drop(); + assert.equal(game.state.phase, "over"); + assert.deepEqual(game.state.result, { winner: "computer", kind: "single", points: 2 }); +}); + +test("le joueur double : l'ordinateur passe si le moteur le dit", async () => { + const engine = fakeEngine({ + cubeDecision: async () => ({ available: true, double: true, take: false }), + }); + const game = Game.start(engine, { setup: { position: "X", firstTurn: "player" } }); + await game.double(); + assert.deepEqual( + game.events.slice(-3).map((e) => e.type), + ["CubeOffered", "CubeDropped", "GameEnded"], + ); + assert.deepEqual(game.state.result, { winner: "player", kind: "single", points: 1 }); +}); + +test("le joueur ne peut pas doubler si le videau est chez l'ordinateur", async () => { + const engine = fakeEngine({ + cubeDecision: async () => ({ available: false, double: false, take: true }), + }); + const game = Game.start(engine, { + setup: { position: "X", firstTurn: "player", cube: { value: 2, owner: "computer" } }, + }); + await assert.rejects(game.double(), /videau/); + assert.equal(game.events.length, 1); +}); + +test("la fin de partie vient du moteur : gagnant, sorte et points", async () => { + const engine = fakeEngine({ + board: async () => ({ + onRoll: { points: [], bar: 0, off: 15, pipCount: 0 }, + opponent: { points: [], bar: 0, off: 0, pipCount: 100 }, + swappedPosition: "SWAPPED", + result: { winner: "onRoll", kind: "gammon", points: 2 }, + }), + }); + const game = Game.start(engine, { + setup: { position: "X", firstTurn: "computer" }, + dice: fixedDice([2, 1]), + }); + await game.playComputerTurn(); + assert.deepEqual(game.state.result, { winner: "computer", kind: "gammon", points: 2 }); + assert.throws(() => game.take(), /action impossible/); +}); + +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 }); + const played = game.events.at(-1); + assert.equal(played?.type, "MovePlayed"); + + game.undo(); + assert.equal(game.state.phase, "moving"); + assert.equal(game.state.turn, "player"); + await game.playHalfMove({ from: 8, to: 5 }); + await game.playHalfMove({ from: 6, to: 5 }); + + // Deux coups accrochés au même jet : l'ancien reste dans le journal. + const moves = game.journal.events.filter((e) => e.type === "MovePlayed"); + assert.equal(moves.length, 2); + assert.equal(moves[0]?.parent, moves[1]?.parent); + assert.notEqual(game.events.at(-1)?.id, played?.id); + + game.goBackTo(played?.id ?? ""); + assert.equal(game.events.at(-1)?.id, played?.id); +}); + +test("les événements sont immuables et versionnés ; le rejeu est déterministe", () => { + let journal = appendEvent(EMPTY_JOURNAL, { + type: "GameStarted", + position: START_POSITION, + cube: { value: 1, owner: null }, + firstTurn: null, + }); + journal = appendEvent(journal, { type: "DiceRolled", side: null, roll: [5, 2] }); + const [started, rolled] = currentLine(journal); + assert.equal(started?.version, 1); + assert.equal(started?.parent, null); + assert.equal(rolled?.parent, started?.id); + assert.throws(() => { + (rolled as { parent: string | null }).parent = null; + }, TypeError); + assert.deepEqual(replay(currentLine(journal)), replay(currentLine(journal))); + assert.equal(replay(currentLine(goBackTo(journal, "1"))).phase, "opening"); +}); + +test("lecture et écriture des demi-coups", () => { + assert.deepEqual(parseHalfMoves("bar/22 6/off"), [ + { from: 25, to: 22 }, + { from: 6, to: 0 }, + ]); + assert.equal(formatHalfMoves(parseHalfMoves(" 8/5 6/5 ")), "8/5 6/5"); + assert.throws(() => parseHalfMoves("8-5"), /mal formé/); + assert.throws(() => parseHalfMoves("30/5"), /case invalide/); +}); diff --git a/app/tests/support/node-engine.ts b/app/tests/support/node-engine.ts new file mode 100644 index 0000000..a84bc45 --- /dev/null +++ b/app/tests/support/node-engine.ts @@ -0,0 +1,37 @@ +// Le vrai moteur WebAssembly sous Node, pour les tests et les scénarios. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Charge build/wasm/ (make engine-wasm) avec web/engine-wasm.mjs, comme la +// page de test du moteur, et le branche sur l'adaptateur du domaine. Le moteur +// n'est chargé qu'une fois par processus. + +import { fileURLToPath } from "node:url"; +import type { Engine } from "../../src/domain/game/engine.js"; +import { createWasmEngine } from "../../src/engine/wasm-engine.js"; + +// Ce fichier est compilé dans build/web/tests/support/ : la racine du dépôt +// est quatre dossiers plus haut. +const root = new URL("../../../../", import.meta.url); + +interface WasmEngine { + run(args: string[]): { result: unknown; ms: number }; +} + +let loading: Promise | undefined; + +export function loadNodeEngine(): Promise { + loading ??= load(); + return loading; +} + +async function load(): Promise { + const module = await import(new URL("build/wasm/engine.mjs", root).href); + const wrapper = await import(new URL("web/engine-wasm.mjs", root).href); + const wasm: WasmEngine = await wrapper.loadEngine( + module.default, + fileURLToPath(new URL("build/wasm/", root)), + ); + return createWasmEngine(async (args) => wasm.run(args).result); +} From 1f9981dc634a92b2bf8d779a07202464a69824a4 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Mon, 2 Nov 2026 20:42:08 +0100 Subject: [PATCH 5/7] =?UTF-8?q?Sc=C3=A9nario=20=C2=AB=20Jouer=20une=20part?= =?UTF-8?q?ie=20=C2=BB=20ex=C3=A9cut=C3=A9=20;=20garde-fous=20d'architectu?= =?UTF-8?q?re?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les étapes de docs/scenarios/jouer-une-partie.feature pilotent le domaine sur le vrai moteur WebAssembly, avec des dés imposés (Cucumber, sous Node). Deux retouches du scénario : 8/4 avec 3-1 est un coup permis (8/5/4), remplacé par 13/12 (case tenue par l'ordinateur) ; « c'est ensuite au tour de l'ordinateur » distingue le constat de la situation de départ du même nom. dependency-cruiser vérifie que le domaine n'importe que le domaine, que l'interface n'appelle pas le moteur et qu'il n'y a pas de cycle ; tests/test_web.sh prouve aussi que des violations volontaires sont détectées. Co-Authored-By: Claude Opus 5.5 --- app/.dependency-cruiser.cjs | 43 ++++ app/tests/steps/jouer-une-partie.steps.ts | 242 ++++++++++++++++++++++ docs/scenarios/README.md | 6 +- docs/scenarios/jouer-une-partie.feature | 7 +- tests/test_web.sh | 65 ++++++ 5 files changed, 359 insertions(+), 4 deletions(-) create mode 100644 app/.dependency-cruiser.cjs create mode 100644 app/tests/steps/jouer-une-partie.steps.ts create mode 100644 tests/test_web.sh diff --git a/app/.dependency-cruiser.cjs b/app/.dependency-cruiser.cjs new file mode 100644 index 0000000..3b59596 --- /dev/null +++ b/app/.dependency-cruiser.cjs @@ -0,0 +1,43 @@ +// Règles d'architecture de l'application (ADR 0007, point 2 ; ADR 0009), +// vérifiées par dependency-cruiser dans make test (tests/test_web.sh). +// Les chemins sont relatifs au dossier app/. + +/** @type {import('dependency-cruiser').IConfiguration} */ +module.exports = { + forbidden: [ + { + name: "domain-is-pure", + comment: + "Le domaine n'importe que le domaine : ni le moteur (src/engine), ni " + + "l'interface (src/ui), ni Node, ni bibliothèque, ni code du navigateur. " + + "Il décrit ce dont il a besoin (interface Engine) ; les adaptateurs " + + "l'implémentent.", + severity: "error", + from: { path: "^src/domain/" }, + to: { pathNot: "^src/domain/" }, + }, + { + name: "ui-does-not-call-engine", + comment: + "Aucun écran n'appelle le moteur directement : l'interface passe par le domaine.", + severity: "error", + from: { path: "^src/ui/" }, + to: { path: "^src/engine/" }, + }, + { + name: "no-circular", + comment: "Pas de dépendance circulaire.", + severity: "error", + from: {}, + to: { circular: true }, + }, + ], + options: { + // Les imports de types comptent aussi : ils disparaissent à la + // compilation, mais créent bien une dépendance dans le code source. + tsPreCompilationDeps: true, + // Résolution des imports comme TypeScript : « ./events.js » désigne + // le fichier events.ts. Le tsconfig.json est celui du dossier courant. + tsConfig: { fileName: "tsconfig.json" }, + }, +}; diff --git a/app/tests/steps/jouer-une-partie.steps.ts b/app/tests/steps/jouer-une-partie.steps.ts new file mode 100644 index 0000000..f1f3050 --- /dev/null +++ b/app/tests/steps/jouer-une-partie.steps.ts @@ -0,0 +1,242 @@ +// Étapes du scénario docs/scenarios/jouer-une-partie.feature (ADR 0008) : +// elles pilotent le domaine, branché sur le vrai moteur WebAssembly, avec des +// dés imposés pour que chaque exécution soit identique. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import assert from "node:assert/strict"; +import { Before, BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber"; +import type { Engine, Move } from "../../src/domain/game/engine.js"; +import type { GameEvent } from "../../src/domain/game/events.js"; +import { Game, STANDARD_PLY } from "../../src/domain/game/game.js"; +import { type PositionId, parseHalfMoves, START_POSITION } from "../../src/domain/game/position.js"; +import { fixedDice } from "../../src/domain/game/roll.js"; +import type { GameState } from "../../src/domain/game/state.js"; +import { loadNodeEngine } from "../support/node-engine.js"; + +// Le chargement du moteur prend une à deux secondes sur le PC de 2011. +setDefaultTimeout(30_000); + +// Course où l'ordinateur, au trait, mène nettement : le moteur lui recommande +// de doubler, et au joueur de prendre (position de référence +// « cube-double-take », tests/reference/positions.json). +const COMPUTER_DOUBLES = "6O5uAAC4uzsAAA"; + +// Le joueur, au trait, n'a plus que deux pions, sur ses cases 6 et 5 ; +// l'ordinateur n'en a sorti aucun. +const PLAYER_ALMOST_DONE = "/38AAABQAAAAAA"; + +let engine: Engine; +let game: Game; +let accepted: boolean; +let positionBefore: PositionId; +let computerTurn: { before: GameState; ms: number }; + +BeforeAll(async () => { + engine = await loadNodeEngine(); +}); + +Before(() => { + accepted = false; +}); + +/** Joue les demi-coups un par un ; au premier refus, le coup est abandonné. */ +async function play(text: string): Promise { + positionBefore = game.displayedPosition; + for (const halfMove of parseHalfMoves(text)) { + if (!(await game.playHalfMove(halfMove))) { + game.cancelHalfMoves(); + return false; + } + } + return true; +} + +function lastEvent(): GameEvent { + const event = game.events.at(-1); + assert.ok(event !== undefined, "aucun événement"); + return event; +} + +// ------------------------------------------------------- Commencer une partie + +When("vous commencez une nouvelle partie", () => { + // Égalité au premier jet d'ouverture, puis 5 contre 2. + game = Game.start(engine, { dice: fixedDice([4, 4, 5, 2]) }); +}); + +Then("les pions sont en position de départ", async () => { + assert.equal(game.state.position, START_POSITION); + const board = await engine.board(game.state.position, { value: 1, owner: "centered" }); + const start = Array.from({ length: 24 }, (_, i) => ({ 5: 5, 7: 3, 12: 5, 23: 2 })[i] ?? 0); + for (const side of [board.onRoll, board.opponent]) { + assert.deepEqual(side.points, start); + assert.equal(side.bar, 0); + assert.equal(side.off, 0); + } +}); + +Then("le premier jet de dés désigne qui commence", () => { + const opening = game.events.filter((e) => e.type === "DiceRolled" && e.side === null); + const last = opening.at(-1); + assert.ok(last?.type === "DiceRolled"); + // Les jets d'ouverture égaux ont été relancés. + for (const tie of opening.slice(0, -1)) { + assert.ok(tie.type === "DiceRolled" && tie.roll[0] === tie.roll[1]); + } + const [playerDie, computerDie] = last.roll; + assert.notEqual(playerDie, computerDie); + assert.equal(game.state.turn, playerDie > computerDie ? "player" : "computer"); + assert.equal(game.state.phase, "moving"); + assert.deepEqual(game.state.roll, last.roll); +}); + +// ------------------------------------------------------------ Jouer un coup + +Given("c'est à vous de jouer avec le jet {int}-{int}", (first: number, second: number) => { + // Le jet d'ouverture : votre dé, puis celui de l'ordinateur. + game = Game.start(engine, { dice: fixedDice([first, second]) }); + assert.equal(game.state.turn, "player"); + assert.deepEqual(game.state.roll, [first, second]); +}); + +When(/^vous jouez (.+)$/, async (text: string) => { + accepted = await play(text); +}); + +When(/^vous essayez de jouer (.+)$/, async (text: string) => { + accepted = await play(text); +}); + +Then("votre coup est accepté", () => { + assert.equal(accepted, true); + const event = lastEvent(); + assert.ok(event.type === "MovePlayed"); + assert.equal(event.side, "player"); +}); + +Then("c'est ensuite au tour de l'ordinateur", () => { + assert.equal(game.state.turn, "computer"); + assert.equal(game.state.phase, "rolling"); +}); + +Then("votre coup est refusé", () => { + assert.equal(accepted, false); +}); + +Then("la position n'a pas changé", () => { + assert.equal(game.displayedPosition, positionBefore); + assert.equal(game.state.position, positionBefore); + assert.equal(game.state.turn, "player"); + assert.equal(game.state.phase, "moving"); + assert.equal(lastEvent().type, "DiceRolled"); +}); + +// ------------------------------------------------------- L'ordinateur joue + +Given("c'est au tour de l'ordinateur", async () => { + // Vous commencez avec 3-1, puis l'ordinateur tirera 6-4. + game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4]) }); + assert.equal(await play("8/5 6/5"), true); + assert.equal(game.state.turn, "computer"); +}); + +When("ses dés sont tirés", async () => { + const before = game.state; + const start = performance.now(); + await game.playComputerTurn(); + computerTurn = { before, ms: performance.now() - start }; +}); + +Then( + "il joue le coup recommandé par le moteur en moins de {int} secondes", + async (seconds: number) => { + const rolled = game.events.findLast((e) => e.type === "DiceRolled"); + const played = lastEvent(); + assert.ok(rolled?.type === "DiceRolled" && rolled.side === "computer"); + assert.ok(played.type === "MovePlayed" && played.side === "computer"); + const best: Move | null = await engine.bestMove( + computerTurn.before.position, + { value: 1, owner: "centered" }, + rolled.roll, + STANDARD_PLY, + ); + assert.ok(best !== null); + assert.deepEqual(played.halfMoves, best.halfMoves); + assert.ok( + computerTurn.ms < seconds * 1000, + `tour de l'ordinateur en ${Math.round(computerTurn.ms)} ms`, + ); + }, +); + +// ------------------------------------------------------------------ Videau + +Given("l'ordinateur vous propose de doubler", async () => { + game = Game.start(engine, { setup: { position: COMPUTER_DOUBLES, firstTurn: "computer" } }); + await game.playComputerTurn(); + const event = lastEvent(); + assert.ok(event.type === "CubeOffered" && event.side === "computer"); + assert.equal(game.state.phase, "doubled"); +}); + +When("vous prenez", () => { + game.take(); +}); + +When("vous passez", () => { + game.drop(); +}); + +Then("la partie continue à valeur doublée", () => { + assert.equal(game.state.cube.value, 2); + assert.equal(game.state.result, null); + assert.equal(game.state.phase, "rolling"); +}); + +Then("le videau est de votre côté : vous seul pourrez redoubler", async () => { + const { cube, position } = game.state; + assert.equal(cube.owner, "player"); + // Le moteur le confirme : l'ordinateur, au trait, ne peut plus doubler… + const computer = await engine.cubeDecision( + position, + { value: 2, owner: "opponent" }, + STANDARD_PLY, + ); + assert.equal(computer.available, false); + // … alors que vous, à votre tour, le pourrez. + const board = await engine.board(position, { value: 2, owner: "opponent" }); + const player = await engine.cubeDecision( + board.swappedPosition, + { value: 2, owner: "onRoll" }, + STANDARD_PLY, + ); + assert.equal(player.available, true); +}); + +Then("la partie s'arrête", () => { + assert.equal(game.state.phase, "over"); + assert.equal(lastEvent().type, "GameEnded"); +}); + +Then("vous perdez la partie à sa valeur d'avant le double", () => { + assert.deepEqual(game.state.result, { winner: "computer", kind: "single", points: 1 }); +}); + +// --------------------------------------------------------- Fin de partie + +Given("un joueur a sorti tous ses pions", async () => { + game = Game.start(engine, { + setup: { position: PLAYER_ALMOST_DONE, firstTurn: "player" }, + dice: fixedDice([6, 5]), + }); + await game.roll(); + assert.equal(await play("6/off 5/off"), true); +}); + +Then("le résultat indique le gagnant et les points gagnés", () => { + // Gammon (l'ordinateur n'a sorti aucun pion), mais 1 point seulement : + // règle Jacoby, le videau n'a pas bougé. + assert.deepEqual(game.state.result, { winner: "player", kind: "gammon", points: 1 }); +}); diff --git a/docs/scenarios/README.md b/docs/scenarios/README.md index 481e511..6a9aea1 100644 --- a/docs/scenarios/README.md +++ b/docs/scenarios/README.md @@ -5,8 +5,10 @@ joueur, écrit pour lui. Ces fichiers sont la seule source (ADR 0008) : l'application les met en forme comme pages d'aide, et ils sont exécutés comme tests d'intégration sur le domaine et le moteur réel. -Tant que le domaine n'existe pas, les scénarios se déroulent à la main : ils -servent alors de guide de test. +`make test` les exécute avec Cucumber : les étapes, définies dans +`app/tests/steps/`, pilotent le domaine branché sur le vrai moteur +WebAssembly, avec des dés imposés pour que chaque exécution soit identique. Une +étape sans définition fait échouer les tests. ## Format diff --git a/docs/scenarios/jouer-une-partie.feature b/docs/scenarios/jouer-une-partie.feature index 219946c..bd46470 100644 --- a/docs/scenarios/jouer-une-partie.feature +++ b/docs/scenarios/jouer-une-partie.feature @@ -14,11 +14,14 @@ Fonctionnalité: Jouer une partie contre l'ordinateur Étant donné que c'est à vous de jouer avec le jet 3-1 Quand vous jouez 8/5 6/5 Alors votre coup est accepté - Et c'est au tour de l'ordinateur + Et c'est ensuite au tour de l'ordinateur Scénario: Un coup interdit est refusé + La case 12 est tenue par l'ordinateur, qui y a cinq pions : vous ne + pouvez pas vous y poser. + Étant donné que c'est à vous de jouer avec le jet 3-1 - Quand vous essayez de jouer 8/4 + Quand vous essayez de jouer 13/12 Alors votre coup est refusé Et la position n'a pas changé diff --git a/tests/test_web.sh b/tests/test_web.sh new file mode 100644 index 0000000..64baee1 --- /dev/null +++ b/tests/test_web.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Teste l'application web (app/, ADR 0009), dans l'image ustagammon/web : +# - tests unitaires du domaine (faux moteur) et partie complète sur le vrai +# moteur WebAssembly (node:test) ; +# - scénarios de docs/scenarios/ exécutés par Cucumber sur le domaine branché +# au vrai moteur (ADR 0008) ; +# - règles d'architecture (dependency-cruiser, ADR 0007), et preuve qu'une +# violation volontaire de chaque règle est bien détectée. +# +# Lance `make engine-wasm` et `make web` (sans effet si tout est à jour). + +set -euo pipefail +cd "$(dirname "$0")/.." + +image="${WEB_IMAGE:?lancer via make test}" + +# Lancé depuis `make test` : repartir d'un make indépendant du make parent. +unset MAKEFLAGS MFLAGS MAKELEVEL +make engine-wasm >/dev/null +make web >/dev/null + +errors=0 +report() { + echo " $1" + errors=$((errors + 1)) +} + +# Lance une commande des outils web, dépôt monté sous /src ; le premier +# argument est le dossier de travail, relatif au dépôt. +web() { + local dir="$1" + shift + docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/src" -w "/src/$dir" "$image" "$@" +} + +web . node --test --test-reporter=dot 'build/web/tests/**/*.test.js' \ + || report "tests unitaires ou partie complète en échec (voir ci-dessus)" + +web . cucumber-js --format summary --import 'build/web/tests/steps/*.js' docs/scenarios/*.feature \ + || report "scénarios en échec (voir ci-dessus)" + +web app depcruise --config .dependency-cruiser.cjs src tests \ + || report "règles d'architecture non respectées (voir ci-dessus)" + +# Violations volontaires, dans un dossier jetable sous build/ : chaque règle +# doit être signalée par dependency-cruiser. +fixture=build/web-violations +rm -rf "$fixture" +mkdir -p "$fixture/src/domain" "$fixture/src/engine" "$fixture/src/ui" +cp app/tsconfig.json "$fixture/" +echo 'import { engine } from "../engine/adapter.js"; export const a = engine;' >"$fixture/src/domain/uses-engine.ts" +echo 'import { readFileSync } from "node:fs"; export const b = readFileSync;' >"$fixture/src/domain/uses-node.ts" +echo 'export const engine = 1;' >"$fixture/src/engine/adapter.ts" +echo 'import { engine } from "../engine/adapter.js"; export const c = engine;' >"$fixture/src/ui/uses-engine.ts" +echo 'import { e } from "./loop-b.js"; export const d = e;' >"$fixture/src/ui/loop-a.ts" +echo 'import { d } from "./loop-a.js"; export const e = d;' >"$fixture/src/ui/loop-b.ts" +output="$(web "$fixture" depcruise --config /src/app/.dependency-cruiser.cjs src 2>&1)" \ + && report "dependency-cruiser accepte des violations volontaires" +for rule in domain-is-pure ui-does-not-call-engine no-circular; do + grep -q "$rule" <<<"$output" || report "violation volontaire de $rule non détectée" +done +grep -q 'uses-node' <<<"$output" || report "import de Node dans le domaine non détecté" +rm -rf "$fixture" + +[ "$errors" -eq 0 ] From 85cc034611392f53f5eca1d3095faf8b9e6e1757 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Mon, 2 Nov 2026 20:42:08 +0100 Subject: [PATCH 6/7] =?UTF-8?q?Glossaire=20reli=C3=A9=20au=20code=20dans?= =?UTF-8?q?=20les=20deux=20sens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fiches Demi-coup (HalfMove) et Plateau (Board) ajoutées ; champ code des fiches Coup, Jet, Pli, Moteur, Identifiant de position, Videau, Événement de partie et Partie. tests/test_glossary.sh vérifie que chaque référence « Glossaire : » du code mène à une fiche qui nomme le type, et que chaque champ code désigne un type qui renvoie à sa fiche. Co-Authored-By: Claude Opus 5.5 --- docs/glossaire/README.md | 7 +++- docs/glossaire/coup.md | 1 + docs/glossaire/demi-coup.md | 15 +++++++++ docs/glossaire/evenement-de-partie.md | 1 + docs/glossaire/identifiant-de-position.md | 1 + docs/glossaire/jet.md | 1 + docs/glossaire/moteur.md | 1 + docs/glossaire/partie.md | 1 + docs/glossaire/plateau.md | 14 ++++++++ docs/glossaire/pli.md | 1 + docs/glossaire/videau.md | 1 + tests/test_glossary.sh | 40 +++++++++++++++++++++++ 12 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 docs/glossaire/demi-coup.md create mode 100644 docs/glossaire/plateau.md diff --git a/docs/glossaire/README.md b/docs/glossaire/README.md index 6bc6998..d915c86 100644 --- a/docs/glossaire/README.md +++ b/docs/glossaire/README.md @@ -26,7 +26,10 @@ Voir aussi : [Doubler](doubler.md) `code` est facultatif : il désigne le type du domaine qui implémente le terme, dès qu'il existe. Dans le code, ce type porte en commentaire le chemin de sa -fiche (`Glossaire : docs/glossaire/videau.md`). +fiche (`Glossaire : docs/glossaire/videau.md`), juste avant sa déclaration +exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec +`code` est reliée à ce type, chaque référence du code mène à une fiche dont le +`code` est le type qui la porte. ## Termes @@ -38,6 +41,7 @@ fiche (`Glossaire : docs/glossaire/videau.md`). | [Branche](branche.md) | Branch | | [Compte de points](compte-de-points.md) | Pip count | | [Coup](coup.md) | Move | +| [Demi-coup](demi-coup.md) | Half-move | | [Course](course.md) | Race | | [Doubler](doubler.md) | Double | | [Équité](equite.md) | Equity | @@ -51,6 +55,7 @@ fiche (`Glossaire : docs/glossaire/videau.md`). | [Partie](partie.md) | Game | | [Passer](passer.md) | Drop | | [Pion](pion.md) | Checker | +| [Plateau](plateau.md) | Board | | [Pli](pli.md) | Ply | | [Point](point.md) | Point | | [Prendre](prendre.md) | Take | diff --git a/docs/glossaire/coup.md b/docs/glossaire/coup.md index c1272f3..56e1889 100644 --- a/docs/glossaire/coup.md +++ b/docs/glossaire/coup.md @@ -1,6 +1,7 @@ --- term: Coup english: Move +code: Move --- # Coup diff --git a/docs/glossaire/demi-coup.md b/docs/glossaire/demi-coup.md new file mode 100644 index 0000000..2de4a18 --- /dev/null +++ b/docs/glossaire/demi-coup.md @@ -0,0 +1,15 @@ +--- +term: Demi-coup +english: Half-move +code: HalfMove +--- +# Demi-coup + +**En bref** : le déplacement d'un pion avec un seul dé, par exemple 8/5 avec un 3 ; un coup en compte deux, ou quatre avec un double. + +On saisit son coup demi-coup par demi-coup : l'application n'accepte que ceux +qui mènent à un coup permis. Pour un pion qui sort, la case d'arrivée se note +« off » (6/off) ; pour un pion qui rentre depuis la barre, la case de départ se +note « bar » (bar/22). + +Voir aussi : [Coup](coup.md), [Jet](jet.md), [Barre](barre.md), [Sortie](sortie.md) diff --git a/docs/glossaire/evenement-de-partie.md b/docs/glossaire/evenement-de-partie.md index 7a5c77e..12bc4a9 100644 --- a/docs/glossaire/evenement-de-partie.md +++ b/docs/glossaire/evenement-de-partie.md @@ -1,6 +1,7 @@ --- term: Événement de partie english: Game event +code: GameEvent --- # Événement de partie diff --git a/docs/glossaire/identifiant-de-position.md b/docs/glossaire/identifiant-de-position.md index 7ea8165..e002614 100644 --- a/docs/glossaire/identifiant-de-position.md +++ b/docs/glossaire/identifiant-de-position.md @@ -1,6 +1,7 @@ --- term: Identifiant de position english: Position ID +code: PositionId --- # Identifiant de position diff --git a/docs/glossaire/jet.md b/docs/glossaire/jet.md index 1ec2bcf..238c088 100644 --- a/docs/glossaire/jet.md +++ b/docs/glossaire/jet.md @@ -1,6 +1,7 @@ --- term: Jet english: Roll +code: Roll --- # Jet diff --git a/docs/glossaire/moteur.md b/docs/glossaire/moteur.md index 05c292b..79c70ae 100644 --- a/docs/glossaire/moteur.md +++ b/docs/glossaire/moteur.md @@ -1,6 +1,7 @@ --- term: Moteur english: Engine +code: Engine --- # Moteur diff --git a/docs/glossaire/partie.md b/docs/glossaire/partie.md index a8bb813..5d54720 100644 --- a/docs/glossaire/partie.md +++ b/docs/glossaire/partie.md @@ -1,6 +1,7 @@ --- term: Partie english: Game +code: Game --- # Partie diff --git a/docs/glossaire/plateau.md b/docs/glossaire/plateau.md new file mode 100644 index 0000000..14c7704 --- /dev/null +++ b/docs/glossaire/plateau.md @@ -0,0 +1,14 @@ +--- +term: Plateau +english: Board +code: Board +--- +# Plateau + +**En bref** : la surface de jeu, avec ses 24 cases (les points), la barre au milieu, et les pions de chaque joueur. + +Chaque joueur numérote les cases de son point de vue : sa case 1 est la plus +proche de sa sortie, sa case 24 la plus éloignée, qui est la case 1 de +l'adversaire. + +Voir aussi : [Point](point.md), [Pion](pion.md), [Barre](barre.md), [Identifiant de position](identifiant-de-position.md) diff --git a/docs/glossaire/pli.md b/docs/glossaire/pli.md index f6e0125..44e2202 100644 --- a/docs/glossaire/pli.md +++ b/docs/glossaire/pli.md @@ -1,6 +1,7 @@ --- term: Pli english: Ply +code: Ply --- # Pli diff --git a/docs/glossaire/videau.md b/docs/glossaire/videau.md index 1d24b72..d0b077e 100644 --- a/docs/glossaire/videau.md +++ b/docs/glossaire/videau.md @@ -1,6 +1,7 @@ --- term: Videau english: Cube +code: Cube --- # Videau diff --git a/tests/test_glossary.sh b/tests/test_glossary.sh index 43168c7..dc9c236 100755 --- a/tests/test_glossary.sh +++ b/tests/test_glossary.sh @@ -33,4 +33,44 @@ done duplicates="$(find . -name '*.md' ! -name README.md -exec sed -n 's/^english: //p' {} + | sort | uniq -d)" [ -z "$duplicates" ] || report "traductions anglaises en double : $duplicates" +# Lien glossaire <-> code (ADR 0008), dans les deux sens. Dans le code, un +# commentaire « Glossaire : docs/glossaire/.md » désigne le premier +# type exporté qui le suit ; la fiche doit exister et nommer ce type dans son +# champ code. Réciproquement, chaque champ code d'une fiche doit désigner un +# type ainsi relié à elle. +pairs="$(find ../../app/src -name '*.ts' -exec awk ' + function flush() { if (ref != "") print ref, "?"; ref = "" } + FNR == 1 { flush() } + match($0, /Glossaire : docs\/glossaire\/[a-z0-9-]+\.md/) { + flush() + ref = substr($0, RSTART + 27, RLENGTH - 27) + next + } + ref != "" && match($0, /^export (interface|type|class|enum) [A-Za-z0-9_]+/) { + n = split(substr($0, RSTART, RLENGTH), words, " ") + print ref, words[n] + ref = "" + } + END { flush() } +' {} +)" + +while read -r fiche type; do + [ -n "$fiche" ] || continue + if [ "$type" = "?" ]; then + report "code : référence à $fiche sans type exporté juste après" + elif [ ! -f "$fiche" ]; then + report "code : le type $type renvoie à une fiche inexistante ($fiche)" + elif [ "$(sed -n 's/^code: //p' "$fiche")" != "$type" ]; then + report "$fiche : le type $type y renvoie, mais la fiche n'a pas « code: $type »" + fi +done <<<"$pairs" + +for f in *.md; do + [ "$f" = README.md ] && continue + code="$(sed -n 's/^code: //p' "$f")" + [ -n "$code" ] || continue + grep -qx "$f $code" <<<"$pairs" \ + || report "$f : « code: $code » ne désigne aucun type du code qui renvoie à cette fiche" +done + [ "$errors" -eq 0 ] From a556c67fef8337d79b7e12d40bd991af86d941b1 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Mon, 2 Nov 2026 20:45:00 +0100 Subject: [PATCH 7/7] =?UTF-8?q?Plan=20de=20l'=C3=A9tape=200d.1=20:=20r?= =?UTF-8?q?=C3=A9sultat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/plans/2026-11-02-domaine-partie.md | 77 ++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-11-02-domaine-partie.md b/docs/plans/2026-11-02-domaine-partie.md index 3c6281c..6d84174 100644 --- a/docs/plans/2026-11-02-domaine-partie.md +++ b/docs/plans/2026-11-02-domaine-partie.md @@ -2,7 +2,7 @@ - **Date** : 2026-11-02 - **Étape** : 0d.1 -- **Statut** : en cours +- **Statut** : terminé (branche `plateau-domaine-travail`) - **Exécutant** : sous-agent general-purpose, dans un worktree isolé ## Objectif @@ -91,4 +91,77 @@ garde-fous, les difficultés, les écarts au plan. ## Résultat -À remplir à la clôture. +**Terminé.** `make lint` et `make test` passent (9/9, dont le nouveau +`tests/test_web.sh`). Les quatre critères de réussite sont tenus. + +**Moteur** (`engine.h`, `engine-cli`, tests natifs dans `tests/test_engine.sh`) : +- `engine_check_half_moves` / `halfmoves POSITION_ID DÉS "8/5 6/5"` : des + demi-coups saisis un par un sont-ils le début d'un coup légal, le forment-ils + en entier, et quelle position en résulte. La légalité de chaque demi-coup + vient de `GenerateMoves` de gnubg appelé avec un seul dé ; celle du coup + entier, de la liste des coups légaux (jouer le plus de dés possible). L'ordre + des demi-coups est libre (6/5 puis 8/5 est accepté) ; +- `engine_decode_position` / `position` : plateau (pions par case, barre, + sortis, compte de points), même position vue de l'adversaire, et + `engine_game_result` : partie finie, gagnant, simple/gammon/backgammon, + points (videau et règle Jacoby compris) ; +- `engine_encode_match` / `matchid VIDEAU PROPRIÉTAIRE` ; +- les coups (`legal`, `moves`, `bestmove`) donnent leurs demi-coups ; +- `cube` donne `available` (droit de doubler), `double` (décision + DOUBLE_TAKE, DOUBLE_PASS, DOUBLE_BEAVER, REDOUBLE_TAKE ou REDOUBLE_PASS) et + `take` (`double_take <= double_pass`). La sortie de `reference` est + inchangée : les suites de contrat natives et WebAssembly passent toujours. + +**Domaine** (`app/src/domain/game/`) : `Game.start(engine, { dice, ply, setup })`, +puis `roll()`, `playHalfMove({ from, to })` (false si refusé ; le coup est +enregistré dès qu'il est complet), `cancelHalfMoves()`, `double()`, `take()`, +`drop()`, `playComputerTurn()`, `undo()`, `goBackTo(id)` ; lecture par +`state` (phase, main, position vue de celui qui a la main, jet, videau, +résultat), `events`, `journal`, `displayedPosition`. Le journal est un arbre +d'événements gelés (`id`, `parent`, `version: 1`) ; `replay()` recalcule +l'état sans le moteur, chaque événement portant les positions calculées. +Niveau standard : 2 plis (`STANDARD_PLY`) ; sous Node sur le PC, une partie +entière à 2 plis prend 1,6 s, un tour de l'ordinateur quelques dizaines de ms. + +**Adaptateur** (`app/src/engine/wasm-engine.ts`) : `createWasmEngine(run)`, +où `run` exécute une commande d'engine-cli ; sous Node, +`app/tests/support/node-engine.ts` le branche sur `web/engine-wasm.mjs` et +`build/wasm/`. Dans le navigateur (0d.2), `run` passera par le Web Worker +existant (`web/engine-client.mjs`). + +**Scénarios** : les 7 scénarios de `jouer-une-partie.feature` passent (25 +étapes) sur le vrai moteur. Deux retouches : « vous essayez de jouer 8/4 » +avec 3-1 devient « 13/12 » (8/4 se joue légalement en 8/5/4 ; la case 12 est +tenue par l'ordinateur, expliqué dans le scénario) ; le constat « Et c'est +au tour de l'ordinateur » devient « Et c'est ensuite au tour de +l'ordinateur », Cucumber ne distinguant pas une situation de départ et un +constat de même texte. + +**Garde-fous** : dependency-cruiser (le domaine n'importe que le domaine, +l'interface n'importe pas le moteur, pas de cycle) ; `tests/test_web.sh` +génère des violations volontaires et vérifie que chacune est signalée. +`make lint` compile aussi le domaine seul sans types Node ni DOM. Vérifié à +la main : un import du moteur dans `side.ts` et une étape sans définition font +échouer `make test` ; un `code:` faux, une référence vers une fiche absente ou +un mauvais type font échouer `tests/test_glossary.sh`. + +**Glossaire** : fiches ajoutées Demi-coup (`HalfMove`) et Plateau (`Board`), +à relire ; `code:` ajouté à Coup (`Move`), Jet (`Roll`), Pli (`Ply`), Moteur +(`Engine`), Identifiant de position (`PositionId`), Videau (`Cube`), +Événement de partie (`GameEvent`), Partie (`Game`). + +**Écarts et limites** : +- `@cucumber/cucumber` 12.9.0 et non 13.x : la 13 exige Node 22.12 + (`require()` de modules ES) et l'image est en 22.11. Nouvelle dépendance de + types : `@types/node` (MIT), pour compiler les tests. +- Ce que le domaine fait lui-même, faute de place dans le moteur : alterner + les tours, le jet d'ouverture (le plus fort dé commence, égalité : on + relance), doubler la valeur du videau et le donner à celui qui prend, la + perte à la valeur d'avant le double quand on passe. +- Un demi-coup utilise un seul dé : 8/4 avec 3-1 se saisit 8/5 puis 5/4. +- Le coup est enregistré dès que les demi-coups forment un coup légal ; dans + le cas théorique où il pourrait encore être prolongé en un autre coup légal, + le joueur ne le pourrait pas. +- L'ordinateur ne double pas sur un double « facultatif » (OPTIONAL_*), ne + propose pas d'abandon, et les castors ne sont pas gérés. +- `app/src/ui/` ne contient qu'un `.gitkeep`.