From 34c3eaeca5535ae53c5d2d67e552758ec892eb7b Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 10:04:51 +0100 Subject: [PATCH 01/10] Moteur : niveaux de jeu, analyse d'un coup et du videau, chance d'un jet Co-Authored-By: Claude Opus 5.5 --- engine-cli/main.c | 209 ++++++++++++++++- engine/engine.c | 485 +++++++++++++++++++++++++++++++++++++--- engine/include/engine.h | 178 ++++++++++++++- 3 files changed, 836 insertions(+), 36 deletions(-) diff --git a/engine-cli/main.c b/engine-cli/main.c index 5373e48..38ac0c2 100644 --- a/engine-cli/main.c +++ b/engine-cli/main.c @@ -5,7 +5,7 @@ * Licence : GPLv3 ou ultérieure (voir LICENSE). * * Usage : - * engine-cli [--ply N] [--data DOSSIER] COMMANDE ARGUMENTS + * engine-cli [--ply N | --level NIVEAU] [--data DOSSIER] COMMANDE ARGUMENTS * * Commandes : * bestmove POSITION_ID MATCH_ID DÉS meilleur coup (DÉS : ex. 31) @@ -27,8 +27,23 @@ * référence (tests/reference/README.md) : * évaluation, puis classement des coups * avec DÉS, décision de videau sans + * levels niveaux de jeu, seuils d'appréciation + * et de chance de gnubg + * analysemove POSITION_ID MATCH_ID DÉS POSITION_JOUÉE + * analyse du coup joué (POSITION_JOUÉE : + * position après le coup, vue du joueur + * qui l'a joué, comme position_id dans + * moves et legal) + * analysecube POSITION_ID MATCH_ID DÉCISION + * analyse d'une décision de videau : + * no_double ou double (joueur au trait), + * take ou pass (son adversaire) + * luck POSITION_ID MATCH_ID DÉS chance du jet pour le joueur au trait * - * --ply : niveau de gnubg, de 0 (défaut) à 4. + * --ply : niveau de gnubg, de 0 (défaut) à 4 (meilleur coup comme « hint », + * analyses comme l'analyse de partie de gnubg). + * --level : niveau de jeu de l'ordinateur (voir levels), pour bestmove, moves + * et cube : coups classés comme l'ordinateur de gnubg les joue, bruit compris. * --data : dossier des données ; par défaut « data » à côté du programme. * * En WebAssembly (engine-wasm/), le même programme sert d'interface : le @@ -55,20 +70,32 @@ #define NUMBER "%.9g" /* Liste des commandes, pour les messages d'erreur. */ -#define COMMANDS "bestmove, moves, legal, cube, eval, position, halfmoves, matchid ou reference" +#define COMMANDS "bestmove, moves, legal, cube, eval, position, halfmoves, matchid, reference, " \ + "levels, analysemove, analysecube ou luck" /* Coups gardés dans le classement de la commande reference (comme hint). */ #define REFERENCE_MAX_MOVES 10 /* Arguments de la ligne de commande, une fois lus. */ +#define MAX_ARGS 4 + typedef struct { int ply; + int level; /* -1 : pas de --level */ const char *data_dir; const char *command; - const char *args[3]; + const char *args[MAX_ARGS]; int arg_count; } options; +/* Noms JSON des appréciations (engine_skill), des chances + * (engine_luck_rating) et des décisions de videau (engine_cube_action). */ +static const char *const skill_names[] = { "none", "doubtful", "bad", "very_bad" }; +static const char *const luck_names[] = { + "very_unlucky", "unlucky", "none", "lucky", "very_lucky" +}; +static const char *const cube_action_names[] = { "no_double", "double", "take", "pass" }; + /* Code d'erreur JSON pour chaque engine_status. */ static const char * status_code(engine_status status) @@ -92,6 +119,12 @@ status_code(engine_status status) return "too_many_moves"; case ENGINE_ERROR_INTERNAL: return "internal_error"; + case ENGINE_ERROR_LEVEL: + return "invalid_level"; + case ENGINE_ERROR_MOVE: + return "invalid_move"; + case ENGINE_ERROR_CUBE_ACTION: + return "invalid_cube_action"; } return "unknown_error"; } @@ -178,6 +211,18 @@ print_reference_evaluation(const engine_evaluation *e) printf(", \"equity\": " NUMBER "}", e->cubeless_equity); } +/* « , "ply": N » ou « , "level": "NOM" », selon les options. */ +static void +print_level_or_ply(const options *opt) +{ + engine_level_settings settings; + + if (opt->level >= 0 && engine_level_settings_of(opt->level, &settings) == ENGINE_OK) + printf(", \"level\": \"%s\"", settings.name); + else + printf(", \"ply\": %d", opt->ply); +} + /* Lit des dés au format « 31 » ; renvoie 0 si le format est faux. */ static int parse_dice(const char *text, int *die1, int *die2) @@ -208,7 +253,10 @@ run_moves_command(const options *opt) moves = malloc(sizeof *moves * ENGINE_MAX_MOVES); if (!moves) return print_error("out_of_memory", "mémoire insuffisante", EXIT_ENGINE_ERROR); - if (evaluate) + if (evaluate && opt->level >= 0) + status = engine_level_best_moves(position_id, match_id, die1, die2, opt->level, + moves, ENGINE_MAX_MOVES, &count); + else if (evaluate) status = engine_best_moves(position_id, match_id, die1, die2, opt->ply, moves, ENGINE_MAX_MOVES, &count); else @@ -222,7 +270,7 @@ run_moves_command(const options *opt) printf("{\"position_id\": \"%s\", \"match_id\": \"%s\", \"dice\": [%d, %d]", position_id, match_id, die1, die2); if (evaluate) - printf(", \"ply\": %d", opt->ply); + print_level_or_ply(opt); if (best_only) { printf(", \"best_move\": "); if (count > 0) @@ -270,11 +318,15 @@ run_position_command(const options *opt) e.cubeless_equity, e.cubeful_equity); } else { engine_cube c; - status = engine_cube_decision(position_id, match_id, opt->ply, &c); + if (opt->level >= 0) + status = engine_level_cube_decision(position_id, match_id, opt->level, &c); + else + status = engine_cube_decision(position_id, match_id, opt->ply, &c); if (status != ENGINE_OK) return print_engine_error(status); - printf("{\"position_id\": \"%s\", \"match_id\": \"%s\", \"ply\": %d, \"match\": ", - position_id, match_id, opt->ply); + printf("{\"position_id\": \"%s\", \"match_id\": \"%s\"", position_id, match_id); + print_level_or_ply(opt); + printf(", \"match\": "); print_match(&match); printf(", \"probabilities\": "); print_probabilities(c.probabilities); @@ -507,6 +559,122 @@ run_reference_command(const options *opt) return EXIT_SUCCESS; } +/* levels : niveaux de jeu, seuils d'appréciation et de chance. */ +static int +run_levels_command(const options *opt) +{ + engine_level_settings s; + int level; + + if (opt->arg_count != 0) + return usage_error("levels n'attend pas d'argument"); + printf("{\"levels\": ["); + for (level = 0; level < ENGINE_LEVEL_COUNT; level++) { + engine_level_settings_of(level, &s); + printf("%s{\"name\": \"%s\", \"plies\": %d, \"noise\": " NUMBER + ", \"deterministic\": %s, \"prune\": %s}", level ? ", " : "", s.name, s.plies, + s.noise, s.deterministic ? "true" : "false", s.prune ? "true" : "false"); + } + printf("], \"skill_thresholds\": {\"doubtful\": " NUMBER ", \"bad\": " NUMBER + ", \"very_bad\": " NUMBER "}, \"luck_thresholds\": {\"lucky\": " NUMBER + ", \"very_lucky\": " NUMBER "}}\n", + ENGINE_SKILL_DOUBTFUL_THRESHOLD, ENGINE_SKILL_BAD_THRESHOLD, + ENGINE_SKILL_VERY_BAD_THRESHOLD, ENGINE_LUCK_THRESHOLD, ENGINE_LUCK_VERY_THRESHOLD); + return EXIT_SUCCESS; +} + +/* analysemove : analyse du coup joué. */ +static int +run_analyse_move_command(const options *opt) +{ + const char *position_id = opt->args[0], *match_id = opt->args[1]; + const char *played_id = opt->args[3]; + engine_move_analysis a; + engine_status status; + int die1, die2; + + if (opt->arg_count != 4) + return usage_error("arguments attendus : POSITION_ID MATCH_ID DÉS POSITION_JOUÉE"); + if (!parse_dice(opt->args[2], &die1, &die2)) + return usage_error("dés attendus sous la forme 31"); + status = engine_analyse_move(position_id, match_id, die1, die2, played_id, opt->ply, &a); + if (status != ENGINE_OK) + return print_engine_error(status); + + printf("{\"position_id\": \"%s\", \"match_id\": \"%s\", \"dice\": [%d, %d], \"ply\": %d, " + "\"played_position_id\": \"%s\", \"move_count\": %d", + position_id, match_id, die1, die2, opt->ply, played_id, a.move_count); + if (a.move_count == 0) { + printf(", \"rank\": null, \"played\": null, \"best\": null"); + } else { + printf(", \"rank\": %d, \"played\": ", a.rank); + print_move(&a.played, 1); + printf(", \"best\": "); + print_move(&a.best, 1); + } + printf(", \"equity_loss\": " NUMBER ", \"skill\": \"%s\"}\n", a.equity_loss, + skill_names[a.skill]); + return EXIT_SUCCESS; +} + +/* analysecube : analyse d'une décision de videau. */ +static int +run_analyse_cube_command(const options *opt) +{ + const char *position_id = opt->args[0], *match_id = opt->args[1]; + engine_cube_analysis a; + engine_status status; + int action; + + if (opt->arg_count != 3) + return usage_error("arguments attendus : POSITION_ID MATCH_ID DÉCISION"); + for (action = 0; action < 4 && strcmp(opt->args[2], cube_action_names[action]) != 0; action++) + ; + if (action == 4) + return usage_error("DÉCISION attend no_double, double, take ou pass"); + status = engine_analyse_cube(position_id, match_id, opt->ply, (engine_cube_action) action, &a); + if (status != ENGINE_OK) + return print_engine_error(status); + + printf("{\"position_id\": \"%s\", \"match_id\": \"%s\", \"ply\": %d, \"action\": \"%s\", " + "\"analysed\": %s, \"cube\": ", position_id, match_id, opt->ply, + cube_action_names[action], a.analysed ? "true" : "false"); + if (a.analysed) { + printf("{\"probabilities\": "); + print_probabilities(a.cube.probabilities); + printf(", "); + print_cube_fields(&a.cube); + printf("}"); + } else { + printf("null"); + } + printf(", \"equity_loss\": " NUMBER ", \"skill\": \"%s\"}\n", a.equity_loss, + skill_names[a.skill]); + return EXIT_SUCCESS; +} + +/* luck : chance du jet pour le joueur au trait. */ +static int +run_luck_command(const options *opt) +{ + const char *position_id = opt->args[0], *match_id = opt->args[1]; + engine_luck luck; + engine_status status; + int die1, die2; + + if (opt->arg_count != 3) + return usage_error("arguments attendus : POSITION_ID MATCH_ID DÉS"); + if (!parse_dice(opt->args[2], &die1, &die2)) + return usage_error("dés attendus sous la forme 31"); + status = engine_luck_of(position_id, match_id, die1, die2, &luck); + if (status != ENGINE_OK) + return print_engine_error(status); + printf("{\"position_id\": \"%s\", \"match_id\": \"%s\", \"dice\": [%d, %d], \"luck\": " NUMBER + ", \"rating\": \"%s\"}\n", position_id, match_id, die1, die2, luck.luck, + luck_names[luck.rating]); + return EXIT_SUCCESS; +} + /* Dossier « data » à côté du programme. */ static char * default_data_dir(const char *program) @@ -531,20 +699,28 @@ parse_options(int argc, char **argv, options *opt) { int i; + int has_ply = 0; + memset(opt, 0, sizeof *opt); + opt->level = -1; for (i = 1; i < argc; i++) { if (strcmp(argv[i], "--ply") == 0 && i + 1 < argc) { char *end; opt->ply = (int) strtol(argv[++i], &end, 10); if (*end || end == argv[i]) return "--ply attend un nombre"; + has_ply = 1; + } else if (strcmp(argv[i], "--level") == 0 && i + 1 < argc) { + if (engine_level_from_name(argv[++i], &opt->level) != ENGINE_OK) + return "--level attend un niveau : beginner, casual, intermediate, advanced, " + "0ply, 1ply, 2ply, 3ply ou 4ply"; } else if (strcmp(argv[i], "--data") == 0 && i + 1 < argc) { opt->data_dir = argv[++i]; } else if (argv[i][0] == '-' && argv[i][1] == '-') { return "option inconnue ou sans valeur"; } else if (!opt->command) { opt->command = argv[i]; - } else if (opt->arg_count < 3) { + } else if (opt->arg_count < MAX_ARGS) { opt->args[opt->arg_count++] = argv[i]; } else { return "trop d'arguments"; @@ -552,6 +728,11 @@ parse_options(int argc, char **argv, options *opt) } if (!opt->command) return "commande attendue : " COMMANDS; + if (opt->level >= 0 && has_ply) + return "--ply et --level ne vont pas ensemble"; + if (opt->level >= 0 && strcmp(opt->command, "bestmove") != 0 + && strcmp(opt->command, "moves") != 0 && strcmp(opt->command, "cube") != 0) + return "--level ne s'applique qu'à bestmove, moves et cube"; return NULL; } @@ -591,6 +772,14 @@ main(int argc, char **argv) result = run_half_moves_command(&opt); else if (strcmp(opt.command, "matchid") == 0) result = run_match_id_command(&opt); + else if (strcmp(opt.command, "levels") == 0) + result = run_levels_command(&opt); + else if (strcmp(opt.command, "analysemove") == 0) + result = run_analyse_move_command(&opt); + else if (strcmp(opt.command, "analysecube") == 0) + result = run_analyse_cube_command(&opt); + else if (strcmp(opt.command, "luck") == 0) + result = run_luck_command(&opt); else result = usage_error("commande inconnue : " COMMANDS); diff --git a/engine/engine.c b/engine/engine.c index f8d29e1..37445a4 100644 --- a/engine/engine.c +++ b/engine/engine.c @@ -26,8 +26,12 @@ #define DEFAULT_BEAVERS 3 /* Seuil d'erreur « douteuse » de gnubg (arSkillLevel[SKILL_DOUBTFUL] dans - * gnubg.c), que la commande « hint » passe à FindnSaveBestMoves. */ -#define DOUBTFUL_THRESHOLD 0.03f + * gnubg.c), que la commande « hint » et l'analyse passent à + * FindnSaveBestMoves. */ +#define DOUBTFUL_THRESHOLD ENGINE_SKILL_DOUBTFUL_THRESHOLD + +/* Position de départ : la même vue des deux joueurs. */ +#define START_POSITION_ID "4HPwATDgc/ABMA" /* Fichier de table d'équité de match : celui de gnubg de bureau par défaut. */ #define MET_FILE "Kazaross-XG2.xml" @@ -146,6 +150,12 @@ engine_status_message(engine_status status) return "tableau de coups trop petit"; case ENGINE_ERROR_INTERNAL: return "erreur interne de gnubg"; + case ENGINE_ERROR_LEVEL: + return "niveau de jeu inconnu"; + case ENGINE_ERROR_MOVE: + return "coup joué absent des coups légaux"; + case ENGINE_ERROR_CUBE_ACTION: + return "décision de videau inconnue"; } return "erreur inconnue"; } @@ -252,6 +262,97 @@ eval_context_for_ply(int ply, evalcontext *context) return ENGINE_OK; } +/* -------------------------------------------------------- Niveaux de jeu */ + +/* + * Les niveaux d'engine_level sont, dans le même ordre, les réglages + * prédéfinis de gnubg (aecSettings, eval.c l. 318-328, et noms de + * aszSettings, l. 305-315, où « casual play » devient « casual »). + */ +static const char *const level_names[ENGINE_LEVEL_COUNT] = { + "beginner", "casual", "intermediate", "advanced", "0ply", "1ply", "2ply", "3ply", "4ply" +}; + +/* Réglages d'évaluation d'un niveau (bruit compris). */ +static engine_status +eval_context_for_level(int level, evalcontext *context) +{ + if (level < 0 || level >= ENGINE_LEVEL_COUNT) + return ENGINE_ERROR_LEVEL; + *context = aecSettings[level]; + return ENGINE_OK; +} + +/* Filtre de coups d'un niveau : aiSettingsMoveFilter (eval.c l. 331) vaut + * « Normal » à partir d'1 pli, et « sans objet » à 0 pli, où aucun filtre ne + * sert ; le joueur de gnubg garde alors son filtre par défaut, « Normal » + * aussi (ap dans gnubg.c l. 461). */ +static movefilter (*move_filter_for_level(int level))[MAX_FILTER_PLIES] +{ + int index = aiSettingsMoveFilter[level]; + + return aaamfMoveFilterSettings[index < 0 ? 0 : index]; +} + +engine_status +engine_level_settings_of(int level, engine_level_settings *settings) +{ + evalcontext context; + engine_status status = eval_context_for_level(level, &context); + + if (status != ENGINE_OK) + return status; + settings->name = level_names[level]; + settings->plies = (int) context.nPlies; + settings->noise = context.rNoise; + settings->deterministic = (int) context.fDeterministic; + settings->prune = (int) context.fUsePrune; + return ENGINE_OK; +} + +engine_status +engine_level_from_name(const char *name, int *level) +{ + int i; + + for (i = 0; name && i < ENGINE_LEVEL_COUNT; i++) + if (strcmp(name, level_names[i]) == 0) { + *level = i; + return ENGINE_OK; + } + return ENGINE_ERROR_LEVEL; +} + +/* ----------------------------------------------- Appréciation et chance */ + +/* Skill de gnubg (analysis.c l. 288), avec les seuils de arSkillLevel. */ +engine_skill +engine_skill_of(float equity_loss) +{ + if (equity_loss < -ENGINE_SKILL_VERY_BAD_THRESHOLD) + return ENGINE_SKILL_VERY_BAD; + if (equity_loss < -ENGINE_SKILL_BAD_THRESHOLD) + return ENGINE_SKILL_BAD; + if (equity_loss < -ENGINE_SKILL_DOUBTFUL_THRESHOLD) + return ENGINE_SKILL_DOUBTFUL; + return ENGINE_SKILL_NONE; +} + +/* Luck de gnubg (analysis.c l. 272), avec les seuils de arLuckLevel. */ +engine_luck_rating +engine_luck_rating_of(float luck) +{ + if (luck > ENGINE_LUCK_VERY_THRESHOLD) + return ENGINE_LUCK_VERY_LUCKY; + if (luck > ENGINE_LUCK_THRESHOLD) + return ENGINE_LUCK_LUCKY; + if (luck < -ENGINE_LUCK_VERY_THRESHOLD) + return ENGINE_LUCK_VERY_UNLUCKY; + if (luck < -ENGINE_LUCK_THRESHOLD) + return ENGINE_LUCK_UNLUCKY; + return ENGINE_LUCK_NONE; +} + /* En match, gnubg calcule en chances de gain du match : on convertit. */ static float normalized_equity(float score, const cubeinfo *cube) @@ -307,6 +408,25 @@ engine_legal_moves(const char *position_id, const char *match_id, int die1, int return ENGINE_OK; } +/* Copie une liste classée par gnubg, puis la libère. */ +static engine_status +copy_ranked_moves(movelist *list, const position *pos, engine_move *moves, int max_moves, + int *count) +{ + engine_status status = ENGINE_OK; + unsigned int i; + + if (list->cMoves > (unsigned int) max_moves) + status = ENGINE_ERROR_TOO_MANY_MOVES; + else { + for (i = 0; i < list->cMoves; i++) + fill_move(&moves[i], &list->amMoves[i], pos, 1); + *count = (int) list->cMoves; + } + g_free(list->amMoves); + return status; +} + engine_status engine_best_moves(const char *position_id, const char *match_id, int die1, int die2, int ply, engine_move *moves, int max_moves, int *count) @@ -315,7 +435,6 @@ engine_best_moves(const char *position_id, const char *match_id, int die1, int d movelist list; evalcontext context; engine_status status; - unsigned int i; status = decode_position(position_id, match_id, &pos); if (status == ENGINE_OK) @@ -336,15 +455,94 @@ engine_best_moves(const char *position_id, const char *match_id, int die1, int d aaamfMoveFilterSettings[0]) < 0) return ENGINE_ERROR_INTERNAL; RefreshMoveList(&list, NULL); + return copy_ranked_moves(&list, &pos, moves, max_moves, count); +} - status = ENGINE_OK; - if (list.cMoves > (unsigned int) max_moves) - status = ENGINE_ERROR_TOO_MANY_MOVES; - else { - for (i = 0; i < list.cMoves; i++) - fill_move(&moves[i], &list.amMoves[i], &pos, 1); - *count = (int) list.cMoves; - } +engine_status +engine_level_best_moves(const char *position_id, const char *match_id, int die1, int die2, + int level, engine_move *moves, int max_moves, int *count) +{ + position pos; + movelist list; + evalcontext context; + engine_status status; + + status = decode_position(position_id, match_id, &pos); + if (status == ENGINE_OK) + status = check_dice(die1, die2); + if (status == ENGINE_OK) + status = eval_context_for_level(level, &context); + if (status != ENGINE_OK) + return status; + + /* + * Comme l'ordinateur de gnubg quand c'est son tour (ComputerTurn dans + * play.c, l. 1436-1453) : FindnSaveBestMoves sans coup à garder, sans + * analyse et sans seuil, avec les réglages et le filtre du joueur, puis + * RefreshMoveList ; il joue le premier coup de la liste. + */ + if (FindnSaveBestMoves(&list, die1, die2, (ConstTanBoard) pos.board, NULL, FALSE, 0.0f, + &pos.cube, &context, move_filter_for_level(level)) < 0) + return ENGINE_ERROR_INTERNAL; + RefreshMoveList(&list, NULL); + return copy_ranked_moves(&list, &pos, moves, max_moves, count); +} + +/* ------------------------------------------------- Analyse d'un coup joué */ + +engine_status +engine_analyse_move(const char *position_id, const char *match_id, int die1, int die2, + const char *played_position_id, int ply, engine_move_analysis *analysis) +{ + position pos; + TanBoard played_board; + positionkey played_key; + movelist list; + evalcontext context; + engine_status status; + unsigned int i; + + status = decode_position(position_id, match_id, &pos); + if (status == ENGINE_OK) + status = check_dice(die1, die2); + if (status == ENGINE_OK) + status = eval_context_for_ply(ply, &context); + if (status != ENGINE_OK) + return status; + if (!is_base64_id(played_position_id, L_POSITIONID) + || !PositionFromID(played_board, played_position_id)) + return ENGINE_ERROR_MOVE; + PositionKey((ConstTanBoard) played_board, &played_key); + + /* + * Comme l'analyse de partie de gnubg (AnalyzeMove dans analysis.c, + * l. 685-735) : FindnSaveBestMoves avec la clé du coup joué (évalué au + * niveau le plus profond), fAnalyse vrai, seuil « douteux » et filtre + * d'analyse (aamfAnalysis, « Normal » par défaut, gnubg.c l. 406) ; pas + * de RefreshMoveList. Le rang est la place du coup joué dans cette + * liste, et la perte rChequerSkill (l. 726) son équité moins celle du + * premier. + */ + memset(analysis, 0, sizeof *analysis); + if (FindnSaveBestMoves(&list, die1, die2, (ConstTanBoard) pos.board, &played_key, TRUE, + DOUBTFUL_THRESHOLD, &pos.cube, &context, + aaamfMoveFilterSettings[0]) < 0) + return ENGINE_ERROR_INTERNAL; + if (list.cMoves == 0) + return ENGINE_OK; /* aucun coup possible : rien à analyser */ + + status = ENGINE_ERROR_MOVE; + for (i = 0; i < list.cMoves; i++) + if (EqualKeys(played_key, list.amMoves[i].key)) { + analysis->move_count = (int) list.cMoves; + analysis->rank = (int) i + 1; + fill_move(&analysis->played, &list.amMoves[i], &pos, 1); + fill_move(&analysis->best, &list.amMoves[0], &pos, 1); + analysis->equity_loss = list.amMoves[i].rScore - list.amMoves[0].rScore; + analysis->skill = engine_skill_of(analysis->equity_loss); + status = ENGINE_OK; + break; + } g_free(list.amMoves); return status; } @@ -387,27 +585,19 @@ static const char *const cube_decision_names[] = { "OPTIONAL_DOUBLE_BEAVER", "OPTIONAL_DOUBLE_PASS", "OPTIONAL_REDOUBLE_PASS" }; -engine_status -engine_cube_decision(const char *position_id, const char *match_id, int ply, engine_cube *cube) +/* Décision de videau de la position avec ces réglages d'évaluation. */ +static engine_status +cube_decision(position *pos, const evalcontext *context, engine_cube *cube) { - position pos; - evalcontext context; float output[2][NUM_ROLLOUT_OUTPUTS]; float equities[NUM_CUBEFUL_OUTPUTS]; cubedecision decision; - engine_status status; int i; - status = decode_position(position_id, match_id, &pos); - if (status == ENGINE_OK) - status = eval_context_for_ply(ply, &context); - if (status != ENGINE_OK) - return status; - - if (GeneralCubeDecisionE(output, (ConstTanBoard) pos.board, &pos.cube, &context, NULL) < 0) + if (GeneralCubeDecisionE(output, (ConstTanBoard) pos->board, &pos->cube, context, NULL) < 0) return ENGINE_ERROR_INTERNAL; /* FindCubeDecision renvoie des équités déjà normalisées. */ - decision = FindCubeDecision(equities, output, &pos.cube); + decision = FindCubeDecision(equities, output, &pos->cube); if ((size_t) decision >= sizeof cube_decision_names / sizeof cube_decision_names[0]) return ENGINE_ERROR_INTERNAL; @@ -419,13 +609,258 @@ 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); + 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; } +engine_status +engine_cube_decision(const char *position_id, const char *match_id, int ply, engine_cube *cube) +{ + position pos; + evalcontext context; + engine_status status; + + status = decode_position(position_id, match_id, &pos); + if (status == ENGINE_OK) + status = eval_context_for_ply(ply, &context); + if (status != ENGINE_OK) + return status; + return cube_decision(&pos, &context, cube); +} + +engine_status +engine_level_cube_decision(const char *position_id, const char *match_id, int level, + engine_cube *cube) +{ + position pos; + evalcontext context; + engine_status status; + + status = decode_position(position_id, match_id, &pos); + if (status == ENGINE_OK) + status = eval_context_for_level(level, &context); + if (status != ENGINE_OK) + return status; + return cube_decision(&pos, &context, cube); +} + +/* -------------------------------------------- Analyse d'une décision de videau */ + +/* Le plus petit de deux nombres. */ +static float +min_float(float a, float b) +{ + return a < b ? a : b; +} + +engine_status +engine_analyse_cube(const char *position_id, const char *match_id, int ply, + engine_cube_action action, engine_cube_analysis *analysis) +{ + position pos; + evalcontext context; + engine_status status; + int can_double; + float take, pass, optimal; + + status = decode_position(position_id, match_id, &pos); + if (status == ENGINE_OK) + status = eval_context_for_ply(ply, &context); + if (status == ENGINE_OK && (action < ENGINE_CUBE_ACTION_NO_DOUBLE || action > ENGINE_CUBE_ACTION_PASS)) + status = ENGINE_ERROR_CUBE_ACTION; + if (status != ENGINE_OK) + return status; + + /* + * Quand gnubg analyse-t-il la décision (AnalyzeMove dans analysis.c) ? + * - ne pas doubler (cas MOVE_NORMAL, l. 655) : hors de la position de + * départ, et si le joueur au trait a accès au videau (GetDPEq) ; + * - doubler, prendre, passer (cas MOVE_DOUBLE, l. 760) : si GetDPEq le + * permet, ou si le videau est au centre ou au joueur au trait. + */ + memset(analysis, 0, sizeof *analysis); + can_double = GetDPEq(NULL, NULL, &pos.cube); + if (action == ENGINE_CUBE_ACTION_NO_DOUBLE) + analysis->analysed = can_double && strcmp(position_id, START_POSITION_ID) != 0; + else + analysis->analysed = can_double || pos.cube.fCubeOwner < 0 + || pos.cube.fCubeOwner == pos.cube.fMove; + if (!analysis->analysed) + return ENGINE_OK; + + status = cube_decision(&pos, &context, &analysis->cube); + if (status != ENGINE_OK) + return status; + + /* + * Pertes de gnubg (AnalyzeMove), à partir des équités de FindCubeDecision + * vues du joueur au trait : + * - ne pas doubler : sans double moins optimale (l. 672) ; + * - doubler : la moins bonne des réponses moins optimale (l. 777) ; + * - prendre : Skill(-doubleError) avec doubleError = prend moins passe + * (l. 772 et 803), soit passe moins prend pour l'adversaire ; + * - passer : Skill(doubleError) (l. 823), soit prend moins passe. + * Les deux dernières ne sont des erreurs que négatives : un écart positif + * est un bon choix, ramené à 0. + */ + take = analysis->cube.double_take; + pass = analysis->cube.double_pass; + optimal = analysis->cube.optimal; + switch (action) { + case ENGINE_CUBE_ACTION_NO_DOUBLE: + analysis->equity_loss = analysis->cube.no_double - optimal; + break; + case ENGINE_CUBE_ACTION_DOUBLE: + analysis->equity_loss = (take < pass ? take : pass) - optimal; + break; + case ENGINE_CUBE_ACTION_TAKE: + analysis->equity_loss = min_float(0.0f, pass - take); + break; + case ENGINE_CUBE_ACTION_PASS: + analysis->equity_loss = min_float(0.0f, take - pass); + break; + } + analysis->skill = engine_skill_of(analysis->equity_loss); + return ENGINE_OK; +} + +/* ------------------------------------------------------- Chance d'un jet */ + +/* + * Réglages de l'analyse de la chance : ecLuck de gnubg (analysis.c l. 89), + * 0 pli avec videau, sans bruit. + */ +static const evalcontext luck_context = { + .fCubeful = TRUE, .nPlies = 0, .fUsePrune = FALSE, .fDeterministic = TRUE, .rNoise = 0.0f +}; + +/* + * Équité, pour le joueur au trait (cube), après le meilleur coup du jet + * die1, die2 à 0 pli ; s'il ne peut pas jouer, équité de la position vue + * de l'adversaire (opponent), changée de signe. Repris de la boucle de + * LuckNormal (analysis.c l. 213-243). Renvoie ERR_VAL en cas d'échec. + */ +static float +equity_after_best_move(ConstTanBoard board, int die1, int die2, const cubeinfo *cube, + cubeinfo *opponent) +{ + TanBoard swapped; + float output[NUM_ROLLOUT_OUTPUTS]; + movelist list; + float equity; + + if (FindnSaveBestMoves(&list, die1, die2, board, NULL, FALSE, 0.0f, cube, &luck_context, + defaultFilters) < 0) { + g_free(list.amMoves); + return ERR_VAL; + } + if (list.cMoves) { + equity = list.amMoves[0].rScore; + g_free(list.amMoves); + return equity; + } + memcpy(swapped, board, sizeof swapped); + SwapSides(swapped); + if (GeneralEvaluationE(output, (ConstTanBoard) swapped, opponent, &luck_context) < 0) + return ERR_VAL; + return -normalized_equity(output[OUTPUT_CUBEFUL_EQUITY], opponent); +} + +/* + * LuckNormal (analysis.c l. 200-247) : équité du jet moins la moyenne des + * 36 jets. i et j sont les dés moins 1, i >= j. + */ +static float +luck_normal(ConstTanBoard board, int i0, int j0, cubeinfo *cube) +{ + cubeinfo opponent = *cube; + float equities[6][6], mean = 0.0f; + int i, j; + + opponent.fMove = !cube->fMove; + for (i = 0; i < 6; i++) + for (j = 0; j <= i; j++) { + equities[i][j] = equity_after_best_move(board, i + 1, j + 1, cube, &opponent); + if (equities[i][j] == ERR_VAL) + return ERR_VAL; + mean += i == j ? equities[i][j] : equities[i][j] * 2.0f; + } + return equities[i0][j0] - mean / 36.0f; +} + +/* + * LuckFirst (analysis.c l. 104-197) : au premier jet de la partie, les + * doubles sont impossibles et le jet peut échoir à l'un ou l'autre joueur. + * Équité du jet moins la moyenne des 30 cas : les 15 jets pour le joueur au + * trait, et les 15 pour l'adversaire (meilleur coup de l'adversaire, changé + * de signe). i et j sont les dés moins 1, i > j. + */ +static float +luck_first(ConstTanBoard board, int i0, int j0, cubeinfo *cube) +{ + cubeinfo opponent = *cube; + TanBoard swapped; + float equities[6][6], mean = 0.0f, equity; + int i, j; + + opponent.fMove = !cube->fMove; + for (i = 0; i < 6; i++) + for (j = 0; j < i; j++) { + equities[i][j] = equity_after_best_move(board, i + 1, j + 1, cube, &opponent); + if (equities[i][j] == ERR_VAL) + return ERR_VAL; + mean += equities[i][j]; + } + + /* L'adversaire au trait : son meilleur coup, vu de lui, changé de signe. + * gnubg, s'il ne peut pas jouer (impossible au départ), reprend + * l'évaluation du joueur au trait (l. 168-181). */ + memcpy(swapped, board, sizeof swapped); + SwapSides(swapped); + for (i = 0; i < 6; i++) + for (j = i + 1; j < 6; j++) { + equity = equity_after_best_move((ConstTanBoard) swapped, i + 1, j + 1, &opponent, cube); + if (equity == ERR_VAL) + return ERR_VAL; + equities[i][j] = -equity; + mean += equities[i][j]; + } + return equities[i0][j0] - mean / 30.0f; +} + +engine_status +engine_luck_of(const char *position_id, const char *match_id, int die1, int die2, + engine_luck *luck) +{ + position pos; + engine_status status; + int high, low; + float value; + + status = decode_position(position_id, match_id, &pos); + if (status == ENGINE_OK) + status = check_dice(die1, die2); + if (status != ENGINE_OK) + return status; + + /* LuckAnalysis (analysis.c l. 252-268) : dés rangés du plus fort au plus + * faible ; LuckFirst à la position de départ hors doubles. */ + high = (die1 > die2 ? die1 : die2) - 1; + low = (die1 > die2 ? die2 : die1) - 1; + if (strcmp(position_id, START_POSITION_ID) == 0 && high != low) + value = luck_first((ConstTanBoard) pos.board, high, low, &pos.cube); + else + value = luck_normal((ConstTanBoard) pos.board, high, low, &pos.cube); + if (value == ERR_VAL) + return ENGINE_ERROR_INTERNAL; + luck->luck = value; + luck->rating = engine_luck_rating_of(value); + return ENGINE_OK; +} + /* ------------------------------------------------- Plateau et fin de partie */ static void diff --git a/engine/include/engine.h b/engine/include/engine.h index d5554c0..eac37f3 100644 --- a/engine/include/engine.h +++ b/engine/include/engine.h @@ -48,11 +48,93 @@ typedef enum { ENGINE_ERROR_DICE, /* dés hors de 1..6 */ ENGINE_ERROR_PLY, /* niveau hors de 0..ENGINE_MAX_PLY */ ENGINE_ERROR_TOO_MANY_MOVES, /* tableau de coups trop petit */ - ENGINE_ERROR_INTERNAL /* échec signalé par gnubg */ + ENGINE_ERROR_INTERNAL, /* échec signalé par gnubg */ + ENGINE_ERROR_LEVEL, /* niveau de jeu inconnu */ + ENGINE_ERROR_MOVE, /* coup joué absent des coups légaux */ + ENGINE_ERROR_CUBE_ACTION /* décision de videau inconnue */ } engine_status; #define ENGINE_MAX_PLY 4 +/* + * Niveaux de jeu de l'ordinateur : les réglages prédéfinis de gnubg, dans + * l'ordre de son tableau aecSettings (engine/gnubg/eval.c, l. 305-328, noms + * de aszSettings). Les quatre premiers évaluent à 0 pli et ajoutent un + * bruit (rNoise, écart type en équité) qui fait commettre des erreurs ; ce + * bruit est « déterministe » (fDeterministic) : il est tiré d'un hachage MD5 + * de la position (Noise, eval.c l. 2241), donc la même position donne + * toujours le même coup. Les suivants sont sans bruit, de 0 à 4 plis, avec + * le filtre de coups « Normal » (aiSettingsMoveFilter, eval.c l. 331). + * + * Les anciennes versions de gnubg nommaient « expert » le niveau 0 pli et + * « world class » (champion du monde) le niveau 2 plis. + */ +typedef enum { + ENGINE_LEVEL_BEGINNER = 0, /* « beginner » : 0 pli, bruit 0,060 */ + ENGINE_LEVEL_CASUAL, /* « casual play » : 0 pli, bruit 0,050 */ + ENGINE_LEVEL_INTERMEDIATE, /* « intermediate » : 0 pli, bruit 0,040 */ + ENGINE_LEVEL_ADVANCED, /* « advanced » : 0 pli, bruit 0,015 */ + ENGINE_LEVEL_0PLY, /* « 0ply » (ancien « expert ») */ + ENGINE_LEVEL_1PLY, /* « 1ply » */ + ENGINE_LEVEL_2PLY, /* « 2ply » (ancien « world class ») */ + ENGINE_LEVEL_3PLY, /* « 3ply » */ + ENGINE_LEVEL_4PLY, /* « 4ply » */ + ENGINE_LEVEL_COUNT +} engine_level; + +/* Réglages d'un niveau, tels que gnubg les définit. */ +typedef struct { + const char *name; /* identifiant : « beginner », « casual », + * « intermediate », « advanced », « 0ply »… « 4ply » */ + int plies; /* profondeur, en plis de gnubg */ + float noise; /* écart type du bruit (0 : aucun) */ + int deterministic; /* bruit tiré de la position, reproductible */ + int prune; /* élagage des réponses (gnubg : à partir d'1 pli) */ +} engine_level_settings; + +/* + * Appréciation d'un coup ou d'une décision de videau : seuils de gnubg sur + * l'équité perdue, normalisée pour un videau de 1 (arSkillLevel dans + * gnubg.c l. 280-285 et Skill dans analysis.c l. 288). Une perte + * strictement plus grande que le seuil fait passer à la catégorie. + */ +#define ENGINE_SKILL_DOUBTFUL_THRESHOLD 0.03f /* douteux */ +#define ENGINE_SKILL_BAD_THRESHOLD 0.06f /* mauvais */ +#define ENGINE_SKILL_VERY_BAD_THRESHOLD 0.12f /* très mauvais */ + +typedef enum { + ENGINE_SKILL_NONE = 0, /* rien à signaler */ + ENGINE_SKILL_DOUBTFUL, /* douteux */ + ENGINE_SKILL_BAD, /* mauvais */ + ENGINE_SKILL_VERY_BAD /* très mauvais */ +} engine_skill; + +/* + * Chance d'un jet : seuils de gnubg sur l'équité gagnée ou perdue par + * rapport à la moyenne des jets (arLuckLevel dans gnubg.c l. 274-279 et Luck + * dans analysis.c l. 272). Un écart strictement plus grand que le seuil + * fait passer à la catégorie. + */ +#define ENGINE_LUCK_THRESHOLD 0.3f /* chanceux, malchanceux */ +#define ENGINE_LUCK_VERY_THRESHOLD 0.6f /* très chanceux, très malchanceux */ + +typedef enum { + ENGINE_LUCK_VERY_UNLUCKY = 0, /* très malchanceux */ + ENGINE_LUCK_UNLUCKY, /* malchanceux */ + ENGINE_LUCK_NONE, /* ordinaire */ + ENGINE_LUCK_LUCKY, /* chanceux */ + ENGINE_LUCK_VERY_LUCKY /* très chanceux */ +} engine_luck_rating; + +/* Décision de videau prise par un joueur. Les deux premières sont celles + * du joueur au trait, les deux dernières sa réponse par l'adversaire. */ +typedef enum { + ENGINE_CUBE_ACTION_NO_DOUBLE = 0, + ENGINE_CUBE_ACTION_DOUBLE, + ENGINE_CUBE_ACTION_TAKE, + ENGINE_CUBE_ACTION_PASS +} engine_cube_action; + /* Nombre maximal de coups légaux pour un jet (MAX_MOVES de gnubg). */ #define ENGINE_MAX_MOVES 3060 @@ -124,6 +206,39 @@ typedef struct { * (double_take <= double_pass) */ } engine_cube; +/* Analyse d'un coup joué, comme l'analyse de partie de gnubg. */ +typedef struct { + int move_count; /* nombre de coups légaux (0 : aucun coup + * possible, rien d'autre n'est rempli) */ + int rank; /* rang du coup joué dans le classement, à + * partir de 1 */ + engine_move played; /* le coup joué, évalué */ + engine_move best; /* le meilleur coup, évalué */ + float equity_loss; /* équité du coup joué moins celle du meilleur : + * 0 ou négative */ + engine_skill skill; /* appréciation de gnubg pour cette perte */ +} engine_move_analysis; + +/* Analyse d'une décision de videau, comme l'analyse de partie de gnubg. */ +typedef struct { + int analysed; /* gnubg analyse cette décision ; sinon (videau + * inaccessible, position de départ…), perte + * nulle et cube non rempli */ + engine_cube cube; /* la décision de videau du joueur au trait */ + float equity_loss; /* équité perdue par celui qui décide, du point + * de vue de celui-ci, normalisée pour un videau + * de 1 : 0 ou négative */ + engine_skill skill; /* appréciation de gnubg pour cette perte */ +} engine_cube_analysis; + +/* Chance d'un jet, comme l'analyse de partie de gnubg. */ +typedef struct { + float luck; /* équité gagnée (positive) ou perdue par ce jet + * par rapport à la moyenne des jets, du point + * de vue du joueur au trait */ + engine_luck_rating rating; +} engine_luck; + /* Un camp sur le plateau, vu de lui-même (sa case 1 est la plus proche de * la sortie). */ typedef struct { @@ -237,6 +352,67 @@ engine_status engine_evaluate(const char *position_id, const char *match_id, engine_status engine_cube_decision(const char *position_id, const char *match_id, int ply, engine_cube *cube); +/* ------------------------------------------------------ Niveaux de jeu */ + +/* Réglages d'un niveau (engine_level). */ +engine_status engine_level_settings_of(int level, engine_level_settings *settings); + +/* Le niveau qui porte ce nom, ou ENGINE_ERROR_LEVEL. */ +engine_status engine_level_from_name(const char *name, int *level); + +/* + * Coups légaux classés comme les classe l'ordinateur de gnubg quand il joue + * à ce niveau (ComputerTurn dans play.c : FindnSaveBestMoves sans analyse + * ni seuil, filtre « Normal », puis RefreshMoveList) ; il joue le premier. + * Les équités incluent le bruit du niveau. + */ +engine_status engine_level_best_moves(const char *position_id, const char *match_id, + int die1, int die2, int level, + engine_move *moves, int max_moves, int *count); + +/* Décision de videau évaluée avec les réglages de ce niveau, bruit compris. */ +engine_status engine_level_cube_decision(const char *position_id, const char *match_id, + int level, engine_cube *cube); + +/* ------------------------------------------------------------- Analyse */ + +/* Appréciation de gnubg pour une perte d'équité (0 ou négative). */ +engine_skill engine_skill_of(float equity_loss); + +/* Catégorie de chance de gnubg pour une chance de jet. */ +engine_luck_rating engine_luck_rating_of(float luck); + +/* + * Analyse le coup joué par le joueur au trait avec le jet die1, die2, au + * niveau ply, comme l'analyse de partie de gnubg (AnalyzeMove dans + * analysis.c : FindnSaveBestMoves avec le coup joué, seuil « douteux », + * filtre « Normal », sans RefreshMoveList). played_position_id est la + * position après le coup, vue du joueur qui vient de le jouer (champ + * position_id d'engine_move). ENGINE_ERROR_MOVE si ce n'est pas un coup + * légal ; si aucun coup n'est possible, move_count vaut 0. + */ +engine_status engine_analyse_move(const char *position_id, const char *match_id, + int die1, int die2, const char *played_position_id, int ply, + engine_move_analysis *analysis); + +/* + * Analyse une décision de videau au niveau ply, comme l'analyse de partie de + * gnubg (AnalyzeMove, cas MOVE_NORMAL, MOVE_DOUBLE, MOVE_TAKE et + * MOVE_DROP). La position et le Match ID sont ceux du joueur au trait, qui + * double ou non ; pour TAKE et PASS, la perte est celle de son adversaire. + */ +engine_status engine_analyse_cube(const char *position_id, const char *match_id, int ply, + engine_cube_action action, engine_cube_analysis *analysis); + +/* + * Chance du jet die1, die2 pour le joueur au trait, comme l'analyse de + * partie de gnubg (LuckAnalysis dans analysis.c : meilleur coup de chaque + * jet à 0 pli, avec videau ; à la position de départ, le premier jet ne + * peut pas être un double). + */ +engine_status engine_luck_of(const char *position_id, const char *match_id, + int die1, int die2, engine_luck *luck); + #ifdef __cplusplus } #endif From 242a2433d2a82c8058de4598a7ad4e08e8cd3e83 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 10:23:18 +0100 Subject: [PATCH 02/10] =?UTF-8?q?R=C3=A9f=C3=A9rences=20:=20analyses=20de?= =?UTF-8?q?=20coups,=20de=20videau,=20chance=20et=20niveaux,=20compar?= =?UTF-8?q?=C3=A9es=20=C3=A0=20gnubg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- Makefile | 4 +- tests/reference/analyses.json | 283 ++++++++++ .../analysis/cube-crawford-not-analysed.json | 24 + .../analysis/cube-double-pass-pass.json | 38 ++ .../analysis/cube-double-pass-take.json | 38 ++ .../analysis/cube-double-take-double.json | 38 ++ .../analysis/cube-double-take-no-double.json | 38 ++ .../analysis/cube-double-take-pass.json | 38 ++ .../analysis/cube-double-take-take.json | 38 ++ .../reference/analysis/cube-match-double.json | 38 ++ tests/reference/analysis/cube-match-pass.json | 38 ++ .../analysis/cube-no-double-double.json | 38 ++ .../analysis/cube-no-double-no-double.json | 38 ++ .../cube-opponent-owns-not-analysed.json | 24 + .../analysis/cube-post-crawford-double.json | 38 ++ .../analysis/cube-redouble-take.json | 38 ++ .../analysis/level-blitz-doubles.json | 50 ++ .../analysis/level-cube-double-take.json | 82 +++ .../analysis/level-cube-no-double.json | 82 +++ .../analysis/level-middlegame-1.json | 50 ++ .../analysis/level-middlegame-2.json | 50 ++ .../reference/analysis/level-opening-31.json | 50 ++ .../analysis/level-prime-vs-prime.json | 50 ++ .../analysis/move-bearoff-doubles.json | 54 ++ .../analysis/move-blitz-doubles.json | 522 ++++++++++++++++++ tests/reference/analysis/move-crawford.json | 126 +++++ tests/reference/analysis/move-forced.json | 54 ++ tests/reference/analysis/move-jacoby.json | 142 +++++ tests/reference/analysis/move-luck-lucky.json | 126 +++++ .../analysis/move-luck-unlucky-bearoff.json | 78 +++ .../reference/analysis/move-luck-unlucky.json | 126 +++++ tests/reference/analysis/move-match.json | 126 +++++ .../analysis/move-middlegame-blunder.json | 142 +++++ .../analysis/move-middlegame-close.json | 126 +++++ .../analysis/move-opening-31-best.json | 126 +++++ .../analysis/move-opening-31-error.json | 126 +++++ .../analysis/move-opening-65-error.json | 102 ++++ .../analysis/move-opening-double.json | 126 +++++ tests/reference/analysis/move-race.json | 202 +++++++ tests/reference/compare.py | 125 ++++- tests/reference/generate.py | 254 +++++++++ 41 files changed, 3883 insertions(+), 5 deletions(-) create mode 100644 tests/reference/analyses.json create mode 100644 tests/reference/analysis/cube-crawford-not-analysed.json create mode 100644 tests/reference/analysis/cube-double-pass-pass.json create mode 100644 tests/reference/analysis/cube-double-pass-take.json create mode 100644 tests/reference/analysis/cube-double-take-double.json create mode 100644 tests/reference/analysis/cube-double-take-no-double.json create mode 100644 tests/reference/analysis/cube-double-take-pass.json create mode 100644 tests/reference/analysis/cube-double-take-take.json create mode 100644 tests/reference/analysis/cube-match-double.json create mode 100644 tests/reference/analysis/cube-match-pass.json create mode 100644 tests/reference/analysis/cube-no-double-double.json create mode 100644 tests/reference/analysis/cube-no-double-no-double.json create mode 100644 tests/reference/analysis/cube-opponent-owns-not-analysed.json create mode 100644 tests/reference/analysis/cube-post-crawford-double.json create mode 100644 tests/reference/analysis/cube-redouble-take.json create mode 100644 tests/reference/analysis/level-blitz-doubles.json create mode 100644 tests/reference/analysis/level-cube-double-take.json create mode 100644 tests/reference/analysis/level-cube-no-double.json create mode 100644 tests/reference/analysis/level-middlegame-1.json create mode 100644 tests/reference/analysis/level-middlegame-2.json create mode 100644 tests/reference/analysis/level-opening-31.json create mode 100644 tests/reference/analysis/level-prime-vs-prime.json create mode 100644 tests/reference/analysis/move-bearoff-doubles.json create mode 100644 tests/reference/analysis/move-blitz-doubles.json create mode 100644 tests/reference/analysis/move-crawford.json create mode 100644 tests/reference/analysis/move-forced.json create mode 100644 tests/reference/analysis/move-jacoby.json create mode 100644 tests/reference/analysis/move-luck-lucky.json create mode 100644 tests/reference/analysis/move-luck-unlucky-bearoff.json create mode 100644 tests/reference/analysis/move-luck-unlucky.json create mode 100644 tests/reference/analysis/move-match.json create mode 100644 tests/reference/analysis/move-middlegame-blunder.json create mode 100644 tests/reference/analysis/move-middlegame-close.json create mode 100644 tests/reference/analysis/move-opening-31-best.json create mode 100644 tests/reference/analysis/move-opening-31-error.json create mode 100644 tests/reference/analysis/move-opening-65-error.json create mode 100644 tests/reference/analysis/move-opening-double.json create mode 100644 tests/reference/analysis/move-race.json diff --git a/Makefile b/Makefile index 3883141..758116e 100644 --- a/Makefile +++ b/Makefile @@ -165,8 +165,8 @@ reference-image: ## Construit l'image Docker du gnubg de référence (cache Dock docker build -t $(REFERENCE_IMAGE) docker/gnubg-reference @echo "Image $(REFERENCE_IMAGE) prête." -reference: reference-image ## Régénère tests/reference/results/ avec le gnubg de référence - rm -rf tests/reference/results +reference: reference-image ## Régénère tests/reference/results/ et analysis/ avec le gnubg de référence + rm -rf tests/reference/results tests/reference/analysis docker run --rm --user "$$(id -u):$$(id -g)" -e HOME=/tmp \ -v "$(CURDIR)/tests/reference:/reference" $(REFERENCE_IMAGE) \ gnubg --tty --quiet --no-rc --python=/reference/generate.py diff --git a/tests/reference/analyses.json b/tests/reference/analyses.json new file mode 100644 index 0000000..1eeb7fb --- /dev/null +++ b/tests/reference/analyses.json @@ -0,0 +1,283 @@ +{ + "moves": [ + { + "id": "move-opening-31-best", + "description": "Ouverture 3-1 jouée au mieux (8/5 6/5) ; chance du premier jet.", + "position_id": "4HPwATDgc/ABMA", + "match_id": "cIkFAAAAAAAA", + "dice": [3, 1], + "played": "8/5 6/5" + }, + { + "id": "move-opening-31-error", + "description": "Ouverture 3-1 jouée 24/23 13/10 au lieu de faire le point 5.", + "position_id": "4HPwATDgc/ABMA", + "match_id": "cIkFAAAAAAAA", + "dice": [3, 1], + "played": "24/23 13/10" + }, + { + "id": "move-opening-65-error", + "description": "Ouverture 6-5 jouée 13/8 13/7 au lieu de courir 24/13.", + "position_id": "4HPwATDgc/ABMA", + "match_id": "cAkXAAAAAAAA", + "dice": [6, 5], + "played": "13/8 13/7" + }, + { + "id": "move-middlegame-close", + "description": "Milieu de partie, 6-4 : un coup proche du meilleur.", + "position_id": "mM/gATCw5+ABMA", + "match_id": "cAkTAAAAAAAA", + "dice": [6, 4], + "played": "8/2 6/2" + }, + { + "id": "move-middlegame-blunder", + "description": "Milieu de partie, 6-4 : un des pires coups.", + "position_id": "mM/gATCw5+ABMA", + "match_id": "cAkTAAAAAAAA", + "dice": [6, 4], + "played": "8/4 8/2" + }, + { + "id": "move-match", + "description": "Match en 7 points à 3-3, 5-3 : frappe au lieu du meilleur coup.", + "position_id": "xGfhATDQ2+BBIA", + "match_id": "cImuACAAEAAE", + "dice": [5, 3], + "played": "24/16" + }, + { + "id": "move-race", + "description": "Course, 5-4 : sortie maladroite.", + "position_id": "cLezAQBwN2cGAA", + "match_id": "cIkSAAAAAAAA", + "dice": [5, 4], + "played": "6/1 5/1" + }, + { + "id": "move-blitz-doubles", + "description": "Attaque, double 3 : un coup parmi 110.", + "position_id": "4HPwAVC0XYMHAA", + "match_id": "cIkNAAAAAAAA", + "dice": [3, 3], + "played": "13/10 13/10 5/2 4/1" + }, + { + "id": "move-crawford", + "description": "Match en 5 points, partie Crawford, 4-3.", + "position_id": "4Dn4AGiynYMHAA", + "match_id": "8AmuAEAAEAAE", + "dice": [4, 3], + "played": "5/2 5/1" + }, + { + "id": "move-jacoby", + "description": "Partie libre avec la règle Jacoby, 6-4.", + "position_id": "4HPwAVC0XYMHAA", + "match_id": "cAkTAAAAAAAA", + "dice": [6, 4], + "played": "8/2 5/1" + }, + { + "id": "move-bearoff-doubles", + "description": "Sortie, double 6 : coup unique ; chance d'un double.", + "position_id": "xgAA4MgAAAAAAA", + "match_id": "cAkbAAAAAAAA", + "dice": [6, 6], + "played": "6/off 6/off 4/off 1/off" + }, + { + "id": "move-forced", + "description": "Rentrée forcée depuis la barre, 3-1.", + "position_id": "tm3ABwBw5+ABYA", + "match_id": "cIkFAAAAAAAA", + "dice": [3, 1], + "played": "bar/24" + }, + { + "id": "move-luck-unlucky", + "description": "Milieu de partie, 5-4 : jet malchanceux, joué au mieux.", + "position_id": "xGfhATDQ2+BBIA", + "match_id": "cIkOAAAAAAAA", + "dice": [5, 4], + "played": null + }, + { + "id": "move-luck-unlucky-bearoff", + "description": "Fin de sortie, 2-1 : jet malchanceux, joué au mieux.", + "position_id": "TQAAYAsAAAAAAA", + "match_id": "cAkFAAAAAAAA", + "dice": [2, 1], + "played": null + }, + { + "id": "move-luck-lucky", + "description": "Course, double 4 : jet chanceux, joué au mieux.", + "position_id": "cLezAQBwN2cGAA", + "match_id": "cIkSAAAAAAAA", + "dice": [4, 4], + "played": null + }, + { + "id": "move-opening-double", + "description": "Position de départ et double 6 : pas un premier jet, chance comptée sur les 36 jets.", + "position_id": "4HPwATDgc/ABMA", + "match_id": "cIkFAAAAAAAA", + "dice": [6, 6], + "played": null + } + ], + "cubes": [ + { + "id": "cube-double-take-no-double", + "description": "Double/prend : le joueur au trait ne double pas.", + "position_id": "6O5uAAC4uzsAAA", + "match_id": "cAkAAAAAAAAA", + "action": "no_double" + }, + { + "id": "cube-double-take-double", + "description": "Double/prend : le joueur au trait double.", + "position_id": "6O5uAAC4uzsAAA", + "match_id": "cAkAAAAAAAAA", + "action": "double" + }, + { + "id": "cube-double-take-take", + "description": "Double/prend : l'adversaire prend.", + "position_id": "6O5uAAC4uzsAAA", + "match_id": "cAkAAAAAAAAA", + "action": "take" + }, + { + "id": "cube-double-take-pass", + "description": "Double/prend : l'adversaire passe à tort.", + "position_id": "6O5uAAC4uzsAAA", + "match_id": "cAkAAAAAAAAA", + "action": "pass" + }, + { + "id": "cube-double-pass-take", + "description": "Double/passe : l'adversaire prend à tort.", + "position_id": "cHd3AADvvQMAAA", + "match_id": "cAkAAAAAAAAA", + "action": "take" + }, + { + "id": "cube-double-pass-pass", + "description": "Double/passe : l'adversaire passe.", + "position_id": "cHd3AADvvQMAAA", + "match_id": "cAkAAAAAAAAA", + "action": "pass" + }, + { + "id": "cube-no-double-double", + "description": "Pas de double : le joueur au trait double trop tôt.", + "position_id": "2J0dBgDYnRsGAA", + "match_id": "cAkAAAAAAAAA", + "action": "double" + }, + { + "id": "cube-no-double-no-double", + "description": "Pas de double : le joueur au trait ne double pas.", + "position_id": "2J0dBgDYnRsGAA", + "match_id": "cAkAAAAAAAAA", + "action": "no_double" + }, + { + "id": "cube-redouble-take", + "description": "Redouble : videau à 2 possédé par le joueur au trait, l'adversaire prend.", + "position_id": "6O5uAAC4uzsAAA", + "match_id": "UQkAAAAAAAAA", + "action": "take" + }, + { + "id": "cube-match-double", + "description": "Match en 7 points, 3-5 : le joueur au trait double.", + "position_id": "cHd3AAC4uzsAAA", + "match_id": "cAngAFAAGAAE", + "action": "double" + }, + { + "id": "cube-match-pass", + "description": "Match en 7 points, 3-5 : l'adversaire passe.", + "position_id": "cHd3AAC4uzsAAA", + "match_id": "cAngAFAAGAAE", + "action": "pass" + }, + { + "id": "cube-post-crawford-double", + "description": "Match en 5 points, après Crawford, mené 2-4 : le joueur au trait double.", + "position_id": "2J0dBgDYnRsGAA", + "match_id": "cAmgAEAAEAAE", + "action": "double" + }, + { + "id": "cube-crawford-not-analysed", + "description": "Partie Crawford : pas de double possible, gnubg n'analyse pas le videau.", + "position_id": "cHd3AAC4uzsAAA", + "match_id": "8AmgAEAAEAAE", + "action": "no_double" + }, + { + "id": "cube-opponent-owns-not-analysed", + "description": "Videau à 2 possédé par l'adversaire : pas de double possible, pas d'analyse.", + "position_id": "6O5uAAC4uzsAAA", + "match_id": "EQEAAAAAAAAA", + "action": "no_double" + } + ], + "levels": [ + { + "id": "level-opening-31", + "description": "Ouverture 3-1.", + "position_id": "4HPwATDgc/ABMA", + "match_id": "cIkFAAAAAAAA", + "dice": [3, 1] + }, + { + "id": "level-middlegame-1", + "description": "Milieu de partie, 6-4 : le bruit change le coup choisi.", + "position_id": "mM/gATCw5+ABMA", + "match_id": "cAkTAAAAAAAA", + "dice": [6, 4] + }, + { + "id": "level-middlegame-2", + "description": "Milieu de partie avec contact, 5-3.", + "position_id": "xGfhATDQ2+BBIA", + "match_id": "cIkOAAAAAAAA", + "dice": [5, 3] + }, + { + "id": "level-prime-vs-prime", + "description": "Prime contre prime, 4-2.", + "position_id": "4LZtAAbAbZsBAw", + "match_id": "cAkKAAAAAAAA", + "dice": [4, 2] + }, + { + "id": "level-blitz-doubles", + "description": "Attaque, double 3.", + "position_id": "4HPwAVC0XYMHAA", + "match_id": "cIkNAAAAAAAA", + "dice": [3, 3] + }, + { + "id": "level-cube-double-take", + "description": "Décision de videau double/prend, à chaque niveau.", + "position_id": "6O5uAAC4uzsAAA", + "match_id": "cAkAAAAAAAAA", + "dice": null + }, + { + "id": "level-cube-no-double", + "description": "Décision de videau sans double, à chaque niveau.", + "position_id": "2J0dBgDYnRsGAA", + "match_id": "cAkAAAAAAAAA", + "dice": null + } + ] +} diff --git a/tests/reference/analysis/cube-crawford-not-analysed.json b/tests/reference/analysis/cube-crawford-not-analysed.json new file mode 100644 index 0000000..c355f7a --- /dev/null +++ b/tests/reference/analysis/cube-crawford-not-analysed.json @@ -0,0 +1,24 @@ +{ + "id": "cube-crawford-not-analysed", + "description": "Partie Crawford : pas de double possible, gnubg n'analyse pas le videau.", + "position_id": "cHd3AAC4uzsAAA", + "match_id": "8AmgAEAAEAAE", + "action": "no_double", + "type": "cube", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "analysed": false, + "cube": null, + "equity_loss": -0.0, + "skill": "none" + }, + "2": { + "analysed": false, + "cube": null, + "equity_loss": -0.0, + "skill": "none" + } + } +} diff --git a/tests/reference/analysis/cube-double-pass-pass.json b/tests/reference/analysis/cube-double-pass-pass.json new file mode 100644 index 0000000..b7ee7f0 --- /dev/null +++ b/tests/reference/analysis/cube-double-pass-pass.json @@ -0,0 +1,38 @@ +{ + "id": "cube-double-pass-pass", + "description": "Double/passe : l'adversaire passe.", + "position_id": "cHd3AADvvQMAAA", + "match_id": "cAkAAAAAAAAA", + "action": "pass", + "type": "cube", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "analysed": true, + "cube": { + "no_double": 0.9999689, + "double_take": 2.0565755, + "double_pass": 1.0, + "optimal": 1.0, + "decision": "DOUBLE_PASS", + "decision_text": "Double, pass" + }, + "equity_loss": -0.0, + "skill": "none" + }, + "2": { + "analysed": true, + "cube": { + "no_double": 1.0, + "double_take": 2.0675642, + "double_pass": 1.0, + "optimal": 1.0, + "decision": "DOUBLE_PASS", + "decision_text": "Double, pass" + }, + "equity_loss": -0.0, + "skill": "none" + } + } +} diff --git a/tests/reference/analysis/cube-double-pass-take.json b/tests/reference/analysis/cube-double-pass-take.json new file mode 100644 index 0000000..2991e84 --- /dev/null +++ b/tests/reference/analysis/cube-double-pass-take.json @@ -0,0 +1,38 @@ +{ + "id": "cube-double-pass-take", + "description": "Double/passe : l'adversaire prend à tort.", + "position_id": "cHd3AADvvQMAAA", + "match_id": "cAkAAAAAAAAA", + "action": "take", + "type": "cube", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "analysed": true, + "cube": { + "no_double": 0.9999689, + "double_take": 2.0565755, + "double_pass": 1.0, + "optimal": 1.0, + "decision": "DOUBLE_PASS", + "decision_text": "Double, pass" + }, + "equity_loss": -1.0565755, + "skill": "very_bad" + }, + "2": { + "analysed": true, + "cube": { + "no_double": 1.0, + "double_take": 2.0675642, + "double_pass": 1.0, + "optimal": 1.0, + "decision": "DOUBLE_PASS", + "decision_text": "Double, pass" + }, + "equity_loss": -1.0675642, + "skill": "very_bad" + } + } +} diff --git a/tests/reference/analysis/cube-double-take-double.json b/tests/reference/analysis/cube-double-take-double.json new file mode 100644 index 0000000..755378a --- /dev/null +++ b/tests/reference/analysis/cube-double-take-double.json @@ -0,0 +1,38 @@ +{ + "id": "cube-double-take-double", + "description": "Double/prend : le joueur au trait double.", + "position_id": "6O5uAAC4uzsAAA", + "match_id": "cAkAAAAAAAAA", + "action": "double", + "type": "cube", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "analysed": true, + "cube": { + "no_double": 0.8189599, + "double_take": 0.88185024, + "double_pass": 1.0, + "optimal": 0.88185024, + "decision": "DOUBLE_TAKE", + "decision_text": "Double, take" + }, + "equity_loss": -0.0, + "skill": "none" + }, + "2": { + "analysed": true, + "cube": { + "no_double": 0.83056116, + "double_take": 0.9218164, + "double_pass": 1.0, + "optimal": 0.9218164, + "decision": "DOUBLE_TAKE", + "decision_text": "Double, take" + }, + "equity_loss": -0.0, + "skill": "none" + } + } +} diff --git a/tests/reference/analysis/cube-double-take-no-double.json b/tests/reference/analysis/cube-double-take-no-double.json new file mode 100644 index 0000000..4ec2721 --- /dev/null +++ b/tests/reference/analysis/cube-double-take-no-double.json @@ -0,0 +1,38 @@ +{ + "id": "cube-double-take-no-double", + "description": "Double/prend : le joueur au trait ne double pas.", + "position_id": "6O5uAAC4uzsAAA", + "match_id": "cAkAAAAAAAAA", + "action": "no_double", + "type": "cube", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "analysed": true, + "cube": { + "no_double": 0.8189599, + "double_take": 0.88185024, + "double_pass": 1.0, + "optimal": 0.88185024, + "decision": "DOUBLE_TAKE", + "decision_text": "Double, take" + }, + "equity_loss": -0.06289035, + "skill": "bad" + }, + "2": { + "analysed": true, + "cube": { + "no_double": 0.83056116, + "double_take": 0.9218164, + "double_pass": 1.0, + "optimal": 0.9218164, + "decision": "DOUBLE_TAKE", + "decision_text": "Double, take" + }, + "equity_loss": -0.09125525, + "skill": "bad" + } + } +} diff --git a/tests/reference/analysis/cube-double-take-pass.json b/tests/reference/analysis/cube-double-take-pass.json new file mode 100644 index 0000000..4ab0f07 --- /dev/null +++ b/tests/reference/analysis/cube-double-take-pass.json @@ -0,0 +1,38 @@ +{ + "id": "cube-double-take-pass", + "description": "Double/prend : l'adversaire passe à tort.", + "position_id": "6O5uAAC4uzsAAA", + "match_id": "cAkAAAAAAAAA", + "action": "pass", + "type": "cube", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "analysed": true, + "cube": { + "no_double": 0.8189599, + "double_take": 0.88185024, + "double_pass": 1.0, + "optimal": 0.88185024, + "decision": "DOUBLE_TAKE", + "decision_text": "Double, take" + }, + "equity_loss": -0.11814976, + "skill": "bad" + }, + "2": { + "analysed": true, + "cube": { + "no_double": 0.83056116, + "double_take": 0.9218164, + "double_pass": 1.0, + "optimal": 0.9218164, + "decision": "DOUBLE_TAKE", + "decision_text": "Double, take" + }, + "equity_loss": -0.07818359, + "skill": "bad" + } + } +} diff --git a/tests/reference/analysis/cube-double-take-take.json b/tests/reference/analysis/cube-double-take-take.json new file mode 100644 index 0000000..edb3b36 --- /dev/null +++ b/tests/reference/analysis/cube-double-take-take.json @@ -0,0 +1,38 @@ +{ + "id": "cube-double-take-take", + "description": "Double/prend : l'adversaire prend.", + "position_id": "6O5uAAC4uzsAAA", + "match_id": "cAkAAAAAAAAA", + "action": "take", + "type": "cube", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "analysed": true, + "cube": { + "no_double": 0.8189599, + "double_take": 0.88185024, + "double_pass": 1.0, + "optimal": 0.88185024, + "decision": "DOUBLE_TAKE", + "decision_text": "Double, take" + }, + "equity_loss": -0.0, + "skill": "none" + }, + "2": { + "analysed": true, + "cube": { + "no_double": 0.83056116, + "double_take": 0.9218164, + "double_pass": 1.0, + "optimal": 0.9218164, + "decision": "DOUBLE_TAKE", + "decision_text": "Double, take" + }, + "equity_loss": -0.0, + "skill": "none" + } + } +} diff --git a/tests/reference/analysis/cube-match-double.json b/tests/reference/analysis/cube-match-double.json new file mode 100644 index 0000000..8e89779 --- /dev/null +++ b/tests/reference/analysis/cube-match-double.json @@ -0,0 +1,38 @@ +{ + "id": "cube-match-double", + "description": "Match en 7 points, 3-5 : le joueur au trait double.", + "position_id": "cHd3AAC4uzsAAA", + "match_id": "cAngAFAAGAAE", + "action": "double", + "type": "cube", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "analysed": true, + "cube": { + "no_double": 0.93459773, + "double_take": 1.1651278, + "double_pass": 1.0, + "optimal": 1.0, + "decision": "DOUBLE_PASS", + "decision_text": "Double, pass" + }, + "equity_loss": -0.0, + "skill": "none" + }, + "2": { + "analysed": true, + "cube": { + "no_double": 0.9405141, + "double_take": 1.1657741, + "double_pass": 1.0, + "optimal": 1.0, + "decision": "DOUBLE_PASS", + "decision_text": "Double, pass" + }, + "equity_loss": -0.0, + "skill": "none" + } + } +} diff --git a/tests/reference/analysis/cube-match-pass.json b/tests/reference/analysis/cube-match-pass.json new file mode 100644 index 0000000..2f9519a --- /dev/null +++ b/tests/reference/analysis/cube-match-pass.json @@ -0,0 +1,38 @@ +{ + "id": "cube-match-pass", + "description": "Match en 7 points, 3-5 : l'adversaire passe.", + "position_id": "cHd3AAC4uzsAAA", + "match_id": "cAngAFAAGAAE", + "action": "pass", + "type": "cube", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "analysed": true, + "cube": { + "no_double": 0.93459773, + "double_take": 1.1651278, + "double_pass": 1.0, + "optimal": 1.0, + "decision": "DOUBLE_PASS", + "decision_text": "Double, pass" + }, + "equity_loss": -0.0, + "skill": "none" + }, + "2": { + "analysed": true, + "cube": { + "no_double": 0.9405141, + "double_take": 1.1657741, + "double_pass": 1.0, + "optimal": 1.0, + "decision": "DOUBLE_PASS", + "decision_text": "Double, pass" + }, + "equity_loss": -0.0, + "skill": "none" + } + } +} diff --git a/tests/reference/analysis/cube-no-double-double.json b/tests/reference/analysis/cube-no-double-double.json new file mode 100644 index 0000000..50feee1 --- /dev/null +++ b/tests/reference/analysis/cube-no-double-double.json @@ -0,0 +1,38 @@ +{ + "id": "cube-no-double-double", + "description": "Pas de double : le joueur au trait double trop tôt.", + "position_id": "2J0dBgDYnRsGAA", + "match_id": "cAkAAAAAAAAA", + "action": "double", + "type": "cube", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "analysed": true, + "cube": { + "no_double": 0.35839266, + "double_take": 0.18619786, + "double_pass": 1.0, + "optimal": 0.35839266, + "decision": "NODOUBLE_TAKE", + "decision_text": "No double, take" + }, + "equity_loss": -0.1721948, + "skill": "very_bad" + }, + "2": { + "analysed": true, + "cube": { + "no_double": 0.28532878, + "double_take": 0.11171284, + "double_pass": 1.0, + "optimal": 0.28532878, + "decision": "NODOUBLE_TAKE", + "decision_text": "No double, take" + }, + "equity_loss": -0.17361593, + "skill": "very_bad" + } + } +} diff --git a/tests/reference/analysis/cube-no-double-no-double.json b/tests/reference/analysis/cube-no-double-no-double.json new file mode 100644 index 0000000..6e62667 --- /dev/null +++ b/tests/reference/analysis/cube-no-double-no-double.json @@ -0,0 +1,38 @@ +{ + "id": "cube-no-double-no-double", + "description": "Pas de double : le joueur au trait ne double pas.", + "position_id": "2J0dBgDYnRsGAA", + "match_id": "cAkAAAAAAAAA", + "action": "no_double", + "type": "cube", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "analysed": true, + "cube": { + "no_double": 0.35839266, + "double_take": 0.18619786, + "double_pass": 1.0, + "optimal": 0.35839266, + "decision": "NODOUBLE_TAKE", + "decision_text": "No double, take" + }, + "equity_loss": -0.0, + "skill": "none" + }, + "2": { + "analysed": true, + "cube": { + "no_double": 0.28532878, + "double_take": 0.11171284, + "double_pass": 1.0, + "optimal": 0.28532878, + "decision": "NODOUBLE_TAKE", + "decision_text": "No double, take" + }, + "equity_loss": -0.0, + "skill": "none" + } + } +} diff --git a/tests/reference/analysis/cube-opponent-owns-not-analysed.json b/tests/reference/analysis/cube-opponent-owns-not-analysed.json new file mode 100644 index 0000000..e7d4049 --- /dev/null +++ b/tests/reference/analysis/cube-opponent-owns-not-analysed.json @@ -0,0 +1,24 @@ +{ + "id": "cube-opponent-owns-not-analysed", + "description": "Videau à 2 possédé par l'adversaire : pas de double possible, pas d'analyse.", + "position_id": "6O5uAAC4uzsAAA", + "match_id": "EQEAAAAAAAAA", + "action": "no_double", + "type": "cube", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "analysed": false, + "cube": null, + "equity_loss": -0.0, + "skill": "none" + }, + "2": { + "analysed": false, + "cube": null, + "equity_loss": -0.0, + "skill": "none" + } + } +} diff --git a/tests/reference/analysis/cube-post-crawford-double.json b/tests/reference/analysis/cube-post-crawford-double.json new file mode 100644 index 0000000..4305a6b --- /dev/null +++ b/tests/reference/analysis/cube-post-crawford-double.json @@ -0,0 +1,38 @@ +{ + "id": "cube-post-crawford-double", + "description": "Match en 5 points, après Crawford, mené 2-4 : le joueur au trait double.", + "position_id": "2J0dBgDYnRsGAA", + "match_id": "cAmgAEAAEAAE", + "action": "double", + "type": "cube", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "analysed": true, + "cube": { + "no_double": 0.26475775, + "double_take": 0.2732438, + "double_pass": 1.0, + "optimal": 0.2732438, + "decision": "DOUBLE_TAKE", + "decision_text": "Double, take" + }, + "equity_loss": -0.0, + "skill": "none" + }, + "2": { + "analysed": true, + "cube": { + "no_double": 0.22408937, + "double_take": 0.22408937, + "double_pass": 1.0, + "optimal": 0.22408937, + "decision": "OPTIONAL_DOUBLE_TAKE", + "decision_text": "Optional double, take" + }, + "equity_loss": -0.0, + "skill": "none" + } + } +} diff --git a/tests/reference/analysis/cube-redouble-take.json b/tests/reference/analysis/cube-redouble-take.json new file mode 100644 index 0000000..f02fefc --- /dev/null +++ b/tests/reference/analysis/cube-redouble-take.json @@ -0,0 +1,38 @@ +{ + "id": "cube-redouble-take", + "description": "Redouble : videau à 2 possédé par le joueur au trait, l'adversaire prend.", + "position_id": "6O5uAAC4uzsAAA", + "match_id": "UQkAAAAAAAAA", + "action": "take", + "type": "cube", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "analysed": true, + "cube": { + "no_double": 0.840925, + "double_take": 0.88185024, + "double_pass": 1.0, + "optimal": 0.88185024, + "decision": "REDOUBLE_TAKE", + "decision_text": "Redouble, take" + }, + "equity_loss": -0.0, + "skill": "none" + }, + "2": { + "analysed": true, + "cube": { + "no_double": 0.84627455, + "double_take": 0.9218164, + "double_pass": 1.0, + "optimal": 0.9218164, + "decision": "REDOUBLE_TAKE", + "decision_text": "Redouble, take" + }, + "equity_loss": -0.0, + "skill": "none" + } + } +} diff --git a/tests/reference/analysis/level-blitz-doubles.json b/tests/reference/analysis/level-blitz-doubles.json new file mode 100644 index 0000000..ef18f98 --- /dev/null +++ b/tests/reference/analysis/level-blitz-doubles.json @@ -0,0 +1,50 @@ +{ + "id": "level-blitz-doubles", + "description": "Attaque, double 3.", + "position_id": "4HPwAVC0XYMHAA", + "match_id": "cIkNAAAAAAAA", + "dice": [ + 3, + 3 + ], + "type": "level", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "levels": { + "beginner": { + "plies": 0, + "noise": 0.06, + "move": "5/2(2) 4/1*(2)" + }, + "casual": { + "plies": 0, + "noise": 0.05, + "move": "5/2(2) 4/1*(2)" + }, + "intermediate": { + "plies": 0, + "noise": 0.04, + "move": "5/2(2) 4/1*(2)" + }, + "advanced": { + "plies": 0, + "noise": 0.015, + "move": "13/10 6/3 4/1*(2)" + }, + "0ply": { + "plies": 0, + "noise": 0.0, + "move": "13/10 6/3 4/1*(2)" + }, + "1ply": { + "plies": 1, + "noise": 0.0, + "move": "13/10(2) 8/5 6/3" + }, + "2ply": { + "plies": 2, + "noise": 0.0, + "move": "13/7 13/10 6/3" + } + } +} diff --git a/tests/reference/analysis/level-cube-double-take.json b/tests/reference/analysis/level-cube-double-take.json new file mode 100644 index 0000000..6ab6a8b --- /dev/null +++ b/tests/reference/analysis/level-cube-double-take.json @@ -0,0 +1,82 @@ +{ + "id": "level-cube-double-take", + "description": "Décision de videau double/prend, à chaque niveau.", + "position_id": "6O5uAAC4uzsAAA", + "match_id": "cAkAAAAAAAAA", + "dice": null, + "type": "level", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "levels": { + "beginner": { + "plies": 0, + "noise": 0.06, + "no_double": 0.42431837, + "double_take": 0.26415053, + "double_pass": 1.0, + "optimal": 0.42431837, + "decision": "NODOUBLE_TAKE", + "decision_text": "No double, take" + }, + "casual": { + "plies": 0, + "noise": 0.05, + "no_double": 0.49009216, + "double_take": 0.36710066, + "double_pass": 1.0, + "optimal": 0.49009216, + "decision": "NODOUBLE_TAKE", + "decision_text": "No double, take" + }, + "intermediate": { + "plies": 0, + "noise": 0.04, + "no_double": 0.55586565, + "double_take": 0.47005057, + "double_pass": 1.0, + "optimal": 0.55586565, + "decision": "NODOUBLE_TAKE", + "decision_text": "No double, take" + }, + "advanced": { + "plies": 0, + "noise": 0.015, + "no_double": 0.7202995, + "double_take": 0.7274252, + "double_pass": 1.0, + "optimal": 0.7274252, + "decision": "DOUBLE_TAKE", + "decision_text": "Double, take" + }, + "0ply": { + "plies": 0, + "noise": 0.0, + "no_double": 0.8189599, + "double_take": 0.88185024, + "double_pass": 1.0, + "optimal": 0.88185024, + "decision": "DOUBLE_TAKE", + "decision_text": "Double, take" + }, + "1ply": { + "plies": 1, + "noise": 0.0, + "no_double": 0.79611415, + "double_take": 0.8986737, + "double_pass": 1.0, + "optimal": 0.8986737, + "decision": "DOUBLE_TAKE", + "decision_text": "Double, take" + }, + "2ply": { + "plies": 2, + "noise": 0.0, + "no_double": 0.83056116, + "double_take": 0.9218164, + "double_pass": 1.0, + "optimal": 0.9218164, + "decision": "DOUBLE_TAKE", + "decision_text": "Double, take" + } + } +} diff --git a/tests/reference/analysis/level-cube-no-double.json b/tests/reference/analysis/level-cube-no-double.json new file mode 100644 index 0000000..403257e --- /dev/null +++ b/tests/reference/analysis/level-cube-no-double.json @@ -0,0 +1,82 @@ +{ + "id": "level-cube-no-double", + "description": "Décision de videau sans double, à chaque niveau.", + "position_id": "2J0dBgDYnRsGAA", + "match_id": "cAkAAAAAAAAA", + "dice": null, + "type": "level", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "levels": { + "beginner": { + "plies": 0, + "noise": 0.06, + "no_double": -0.16227412, + "double_take": -0.6363357, + "double_pass": 1.0, + "optimal": -0.16227412, + "decision": "NODOUBLE_BEAVER", + "decision_text": "No double, beaver" + }, + "casual": { + "plies": 0, + "noise": 0.05, + "no_double": -0.075523466, + "double_take": -0.4993003, + "double_pass": 1.0, + "optimal": -0.075523466, + "decision": "NODOUBLE_BEAVER", + "decision_text": "No double, beaver" + }, + "intermediate": { + "plies": 0, + "noise": 0.04, + "no_double": 0.011226978, + "double_take": -0.36226535, + "double_pass": 1.0, + "optimal": 0.011226978, + "decision": "NODOUBLE_BEAVER", + "decision_text": "No double, beaver" + }, + "advanced": { + "plies": 0, + "noise": 0.015, + "no_double": 0.22810358, + "double_take": -0.019677103, + "double_pass": 1.0, + "optimal": 0.22810358, + "decision": "NODOUBLE_BEAVER", + "decision_text": "No double, beaver" + }, + "0ply": { + "plies": 0, + "noise": 0.0, + "no_double": 0.35839266, + "double_take": 0.18619786, + "double_pass": 1.0, + "optimal": 0.35839266, + "decision": "NODOUBLE_TAKE", + "decision_text": "No double, take" + }, + "1ply": { + "plies": 1, + "noise": 0.0, + "no_double": 0.33552775, + "double_take": 0.15480106, + "double_pass": 1.0, + "optimal": 0.33552775, + "decision": "NODOUBLE_TAKE", + "decision_text": "No double, take" + }, + "2ply": { + "plies": 2, + "noise": 0.0, + "no_double": 0.28532878, + "double_take": 0.11171284, + "double_pass": 1.0, + "optimal": 0.28532878, + "decision": "NODOUBLE_TAKE", + "decision_text": "No double, take" + } + } +} diff --git a/tests/reference/analysis/level-middlegame-1.json b/tests/reference/analysis/level-middlegame-1.json new file mode 100644 index 0000000..7e8490f --- /dev/null +++ b/tests/reference/analysis/level-middlegame-1.json @@ -0,0 +1,50 @@ +{ + "id": "level-middlegame-1", + "description": "Milieu de partie, 6-4 : le bruit change le coup choisi.", + "position_id": "mM/gATCw5+ABMA", + "match_id": "cAkTAAAAAAAA", + "dice": [ + 6, + 4 + ], + "type": "level", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "levels": { + "beginner": { + "plies": 0, + "noise": 0.06, + "move": "24/20 24/18" + }, + "casual": { + "plies": 0, + "noise": 0.05, + "move": "24/20 24/18" + }, + "intermediate": { + "plies": 0, + "noise": 0.04, + "move": "24/20 24/18" + }, + "advanced": { + "plies": 0, + "noise": 0.015, + "move": "24/14" + }, + "0ply": { + "plies": 0, + "noise": 0.0, + "move": "24/14" + }, + "1ply": { + "plies": 1, + "noise": 0.0, + "move": "24/14" + }, + "2ply": { + "plies": 2, + "noise": 0.0, + "move": "24/14" + } + } +} diff --git a/tests/reference/analysis/level-middlegame-2.json b/tests/reference/analysis/level-middlegame-2.json new file mode 100644 index 0000000..982ba64 --- /dev/null +++ b/tests/reference/analysis/level-middlegame-2.json @@ -0,0 +1,50 @@ +{ + "id": "level-middlegame-2", + "description": "Milieu de partie avec contact, 5-3.", + "position_id": "xGfhATDQ2+BBIA", + "match_id": "cIkOAAAAAAAA", + "dice": [ + 5, + 3 + ], + "type": "level", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "levels": { + "beginner": { + "plies": 0, + "noise": 0.06, + "move": "13/5" + }, + "casual": { + "plies": 0, + "noise": 0.05, + "move": "13/5" + }, + "intermediate": { + "plies": 0, + "noise": 0.04, + "move": "13/5" + }, + "advanced": { + "plies": 0, + "noise": 0.015, + "move": "13/5" + }, + "0ply": { + "plies": 0, + "noise": 0.0, + "move": "13/5" + }, + "1ply": { + "plies": 1, + "noise": 0.0, + "move": "13/5" + }, + "2ply": { + "plies": 2, + "noise": 0.0, + "move": "13/5" + } + } +} diff --git a/tests/reference/analysis/level-opening-31.json b/tests/reference/analysis/level-opening-31.json new file mode 100644 index 0000000..e31d6e4 --- /dev/null +++ b/tests/reference/analysis/level-opening-31.json @@ -0,0 +1,50 @@ +{ + "id": "level-opening-31", + "description": "Ouverture 3-1.", + "position_id": "4HPwATDgc/ABMA", + "match_id": "cIkFAAAAAAAA", + "dice": [ + 3, + 1 + ], + "type": "level", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "levels": { + "beginner": { + "plies": 0, + "noise": 0.06, + "move": "8/5 6/5" + }, + "casual": { + "plies": 0, + "noise": 0.05, + "move": "8/5 6/5" + }, + "intermediate": { + "plies": 0, + "noise": 0.04, + "move": "8/5 6/5" + }, + "advanced": { + "plies": 0, + "noise": 0.015, + "move": "8/5 6/5" + }, + "0ply": { + "plies": 0, + "noise": 0.0, + "move": "8/5 6/5" + }, + "1ply": { + "plies": 1, + "noise": 0.0, + "move": "8/5 6/5" + }, + "2ply": { + "plies": 2, + "noise": 0.0, + "move": "8/5 6/5" + } + } +} diff --git a/tests/reference/analysis/level-prime-vs-prime.json b/tests/reference/analysis/level-prime-vs-prime.json new file mode 100644 index 0000000..a561b19 --- /dev/null +++ b/tests/reference/analysis/level-prime-vs-prime.json @@ -0,0 +1,50 @@ +{ + "id": "level-prime-vs-prime", + "description": "Prime contre prime, 4-2.", + "position_id": "4LZtAAbAbZsBAw", + "match_id": "cAkKAAAAAAAA", + "dice": [ + 4, + 2 + ], + "type": "level", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "levels": { + "beginner": { + "plies": 0, + "noise": 0.06, + "move": "13/11 7/3" + }, + "casual": { + "plies": 0, + "noise": 0.05, + "move": "13/11 7/3" + }, + "intermediate": { + "plies": 0, + "noise": 0.04, + "move": "13/11 7/3" + }, + "advanced": { + "plies": 0, + "noise": 0.015, + "move": "13/11 7/3" + }, + "0ply": { + "plies": 0, + "noise": 0.0, + "move": "13/9 7/5" + }, + "1ply": { + "plies": 1, + "noise": 0.0, + "move": "13/9 7/5" + }, + "2ply": { + "plies": 2, + "noise": 0.0, + "move": "13/11 13/9" + } + } +} diff --git a/tests/reference/analysis/move-bearoff-doubles.json b/tests/reference/analysis/move-bearoff-doubles.json new file mode 100644 index 0000000..cb72f8f --- /dev/null +++ b/tests/reference/analysis/move-bearoff-doubles.json @@ -0,0 +1,54 @@ +{ + "id": "move-bearoff-doubles", + "description": "Sortie, double 6 : coup unique ; chance d'un double.", + "position_id": "xgAA4MgAAAAAAA", + "match_id": "cAkbAAAAAAAA", + "dice": [ + 6, + 6 + ], + "played": "6/off 6/off 4/off 1/off", + "type": "move", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "played_move": "6/off(2) 4/off 1/off", + "played_position_id": "xgAAYAAAAAAAAA", + "move_count": 1, + "rank": 1, + "played_equity": 0.8888838, + "best_move": "6/off(2) 4/off 1/off", + "best_equity": 0.8888838, + "equity_loss": -0.0, + "skill": "none", + "ranking": [ + { + "move": "6/off(2) 4/off 1/off", + "equity": 0.8888838 + } + ] + }, + "2": { + "played_move": "6/off(2) 4/off 1/off", + "played_position_id": "xgAAYAAAAAAAAA", + "move_count": 1, + "rank": 1, + "played_equity": 0.8888838, + "best_move": "6/off(2) 4/off 1/off", + "best_equity": 0.8888838, + "equity_loss": -0.0, + "skill": "none", + "ranking": [ + { + "move": "6/off(2) 4/off 1/off", + "equity": 0.8888838 + } + ] + } + }, + "luck": { + "value": 1.6398247, + "rating": "very_lucky" + } +} diff --git a/tests/reference/analysis/move-blitz-doubles.json b/tests/reference/analysis/move-blitz-doubles.json new file mode 100644 index 0000000..3360416 --- /dev/null +++ b/tests/reference/analysis/move-blitz-doubles.json @@ -0,0 +1,522 @@ +{ + "id": "move-blitz-doubles", + "description": "Attaque, double 3 : un coup parmi 110.", + "position_id": "4HPwAVC0XYMHAA", + "match_id": "cIkNAAAAAAAA", + "dice": [ + 3, + 3 + ], + "played": "13/10 13/10 5/2 4/1", + "type": "move", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "played_move": "13/10(2) 5/2 4/1*", + "played_position_id": "4HPwAWBVXTMGAA", + "move_count": 110, + "rank": 109, + "played_equity": 0.084023334, + "best_move": "13/10 6/3 4/1*(2)", + "best_equity": 0.9465638, + "equity_loss": -0.8625404, + "skill": "very_bad", + "ranking": [ + { + "move": "13/10 6/3 4/1*(2)", + "equity": 0.9465638 + }, + { + "move": "8/5 6/3 4/1*(2)", + "equity": 0.9422797 + }, + { + "move": "13/7 13/10 6/3", + "equity": 0.94128984 + }, + { + "move": "13/7 8/5 6/3", + "equity": 0.9370698 + }, + { + "move": "13/10(2) 7/4 6/3", + "equity": 0.93615985 + }, + { + "move": "13/10(2) 8/5 6/3", + "equity": 0.9330082 + }, + { + "move": "13/10 8/5 7/4 6/3", + "equity": 0.932737 + }, + { + "move": "13/10 7/1* 6/3", + "equity": 0.9313148 + }, + { + "move": "8/5 7/1* 6/3", + "equity": 0.93129885 + }, + { + "move": "13/4 6/3", + "equity": 0.92980635 + }, + { + "move": "13/10 8/5(2) 6/3", + "equity": 0.9264537 + }, + { + "move": "13/10(3) 6/3", + "equity": 0.9262361 + }, + { + "move": "13/10(2) 4/1*(2)", + "equity": 0.92529553 + }, + { + "move": "8/5(2) 7/4 6/3", + "equity": 0.9252107 + }, + { + "move": "7/1* 6/3 4/1", + "equity": 0.92395884 + }, + { + "move": "13/10 6/3 5/2(2)", + "equity": 0.9204093 + }, + { + "move": "13/7 4/1*(2)", + "equity": 0.9190106 + }, + { + "move": "13/7(2)", + "equity": 0.9184398 + }, + { + "move": "13/10 8/5 4/1*(2)", + "equity": 0.9182539 + }, + { + "move": "7/4 6/3 5/2(2)", + "equity": 0.91752934 + }, + { + "move": "13/7 13/10(2)", + "equity": 0.91483337 + }, + { + "move": "13/10 8/2 6/3", + "equity": 0.912639 + }, + { + "move": "13/7 13/10 8/5", + "equity": 0.91152656 + }, + { + "move": "13/10(2) 7/1*", + "equity": 0.9115156 + }, + { + "move": "13/1*", + "equity": 0.9111869 + }, + { + "move": "13/10(2) 6/3 4/1*", + "equity": 0.91055506 + }, + { + "move": "8/2 8/5 6/3", + "equity": 0.9091699 + }, + { + "move": "8/2 7/4 6/3", + "equity": 0.90897554 + }, + { + "move": "13/7 8/5(2)", + "equity": 0.90817225 + }, + { + "move": "13/7 6/3 4/1*", + "equity": 0.90714073 + }, + { + "move": "13/10(2) 8/5 7/4", + "equity": 0.9070756 + }, + { + "move": "8/2(2)", + "equity": 0.9065211 + }, + { + "move": "13/10 8/5 7/1*", + "equity": 0.90571 + }, + { + "move": "13/10(3) 7/4", + "equity": 0.9040942 + }, + { + "move": "13/10 8/5 6/3 4/1*", + "equity": 0.90343916 + }, + { + "move": "8/5(2) 4/1*(2)", + "equity": 0.90325713 + }, + { + "move": "5/2(2) 4/1*(2)", + "equity": 0.90183336 + }, + { + "move": "13/10 7/1* 4/1", + "equity": 0.9013542 + }, + { + "move": "13/4 7/4", + "equity": 0.9010268 + }, + { + "move": "13/7 5/2(2)", + "equity": 0.90037674 + }, + { + "move": "8/5(2) 7/1*", + "equity": 0.89956194 + }, + { + "move": "13/10 8/5(2) 7/4", + "equity": 0.8988826 + }, + { + "move": "8/2 6/3 5/2", + "equity": 0.8984738 + }, + { + "move": "13/4 13/10", + "equity": 0.89749956 + }, + { + "move": "6/3(2) 4/1*(2)", + "equity": 0.8951012 + }, + { + "move": "13/10(2) 8/5(2)", + "equity": 0.89493525 + }, + { + "move": "13/7 8/2", + "equity": 0.8946096 + }, + { + "move": "13/7 6/3(2)", + "equity": 0.89403474 + }, + { + "move": "7/1* 5/2(2)", + "equity": 0.8924208 + }, + { + "move": "8/5 7/1* 4/1", + "equity": 0.891961 + }, + { + "move": "13/10(2) 6/3(2)", + "equity": 0.89077055 + }, + { + "move": "13/4 8/5", + "equity": 0.8907301 + }, + { + "move": "8/5(2) 6/3 4/1*", + "equity": 0.8900244 + }, + { + "move": "13/10(2) 5/2(2)", + "equity": 0.8890666 + }, + { + "move": "7/4 6/3(3)", + "equity": 0.8890004 + }, + { + "move": "13/10 7/4 5/2(2)", + "equity": 0.8888954 + }, + { + "move": "8/2 8/5 7/4", + "equity": 0.8876569 + }, + { + "move": "13/10(4)", + "equity": 0.88716996 + }, + { + "move": "7/1* 6/3(2)", + "equity": 0.8856077 + }, + { + "move": "8/2 4/1*(2)", + "equity": 0.8853629 + }, + { + "move": "13/10 6/3(3)", + "equity": 0.8853096 + }, + { + "move": "13/7 6/3 5/2", + "equity": 0.8831558 + }, + { + "move": "13/10 8/2 7/4", + "equity": 0.8819176 + }, + { + "move": "7/1* 4/1(2)", + "equity": 0.88113916 + }, + { + "move": "13/10 7/4 6/3(2)", + "equity": 0.88064635 + }, + { + "move": "13/10(3) 8/5", + "equity": 0.88049895 + }, + { + "move": "6/3 5/2(2) 4/1*", + "equity": 0.8790827 + }, + { + "move": "13/10(2) 8/2", + "equity": 0.8782023 + }, + { + "move": "6/3 5/2 4/1*(2)", + "equity": 0.8774586 + }, + { + "move": "8/5 6/3(3)", + "equity": 0.8774268 + }, + { + "move": "8/2 7/1*", + "equity": 0.8772669 + }, + { + "move": "13/7 13/10 4/1*", + "equity": 0.87700987 + }, + { + "move": "13/10 8/5 6/3(2)", + "equity": 0.8757389 + }, + { + "move": "13/10 8/2 8/5", + "equity": 0.87530077 + }, + { + "move": "13/10(2) 6/3 5/2", + "equity": 0.87215745 + }, + { + "move": "13/7 8/5 4/1*", + "equity": 0.87202764 + }, + { + "move": "8/5 7/4 6/3(2)", + "equity": 0.86896765 + }, + { + "move": "7/1* 6/3 5/2", + "equity": 0.8687051 + }, + { + "move": "13/10 7/4 6/3 5/2", + "equity": 0.8679464 + }, + { + "move": "8/2 6/3 4/1*", + "equity": 0.86750233 + }, + { + "move": "13/10(2) 8/5 4/1*", + "equity": 0.86701244 + }, + { + "move": "8/5(2) 6/3(2)", + "equity": 0.86167765 + }, + { + "move": "13/10(3) 4/1*", + "equity": 0.8568015 + }, + { + "move": "6/3(2) 5/2(2)", + "equity": 0.85501754 + }, + { + "move": "8/2 7/4 5/2", + "equity": 0.8537997 + }, + { + "move": "8/2 5/2(2)", + "equity": 0.8524791 + }, + { + "move": "13/10 8/5(2) 4/1*", + "equity": 0.84969926 + }, + { + "move": "13/10 8/2 5/2", + "equity": 0.8466199 + }, + { + "move": "8/2 6/3(2)", + "equity": 0.80814815 + }, + { + "move": "13/10 5/2(2) 4/1*", + "equity": 0.8037353 + }, + { + "move": "13/10 5/2 4/1*(2)", + "equity": 0.77350885 + }, + { + "move": "13/10 6/3(2) 4/1*", + "equity": 0.74067366 + }, + { + "move": "13/7 13/10 5/2", + "equity": 0.72703636 + }, + { + "move": "13/10(2) 7/4 5/2", + "equity": 0.7053956 + }, + { + "move": "6/3(3) 4/1*", + "equity": 0.66575944 + }, + { + "move": "13/10 7/1* 5/2", + "equity": 0.6462204 + }, + { + "move": "8/5 6/3(2) 4/1*", + "equity": 0.6374039 + }, + { + "move": "8/2 8/5 4/1*", + "equity": 0.62353325 + }, + { + "move": "13/10 8/2 4/1*", + "equity": 0.61257124 + }, + { + "move": "13/10 6/3 5/2 4/1*", + "equity": 0.5349428 + }, + { + "move": "7/1* 5/2 4/1", + "equity": 0.45721298 + }, + { + "move": "8/2 5/2 4/1*", + "equity": 0.44362232 + }, + { + "move": "13/4 5/2", + "equity": 0.43828273 + }, + { + "move": "13/10(3) 5/2", + "equity": 0.2798175 + }, + { + "move": "13/10 6/3(2) 5/2", + "equity": 0.24207813 + }, + { + "move": "6/3(3) 5/2", + "equity": 0.23385207 + }, + { + "move": "7/4 6/3(2) 5/2", + "equity": 0.22096781 + }, + { + "move": "13/7 5/2 4/1*", + "equity": 0.18849733 + }, + { + "move": "13/10(2) 5/2 4/1*", + "equity": 0.084023334 + } + ] + }, + "2": { + "played_move": "13/10(2) 5/2 4/1*", + "played_position_id": "4HPwAWBVXTMGAA", + "move_count": 110, + "rank": 7, + "played_equity": -0.022027964, + "best_move": "13/10 7/1* 6/3", + "best_equity": 1.0, + "equity_loss": -1.022028, + "skill": "very_bad", + "ranking": [ + { + "move": "13/10 7/1* 6/3", + "equity": 1.0 + }, + { + "move": "13/7 13/10 6/3", + "equity": 1.0 + }, + { + "move": "13/10(2) 8/5 6/3", + "equity": 1.0 + }, + { + "move": "13/7 8/5 6/3", + "equity": 1.0 + }, + { + "move": "13/10(2) 7/4 6/3", + "equity": 1.0 + }, + { + "move": "13/10 8/5 7/4 6/3", + "equity": 1.0 + }, + { + "move": "13/10(2) 5/2 4/1*", + "equity": -0.022027964 + }, + { + "move": "13/10 8/5(2) 6/3", + "equity": 1.0 + }, + { + "move": "13/4 6/3", + "equity": 1.0 + }, + { + "move": "13/10 6/3 4/1*(2)", + "equity": 0.99692726 + } + ] + } + }, + "luck": { + "value": 0.011263669, + "rating": "none" + } +} diff --git a/tests/reference/analysis/move-crawford.json b/tests/reference/analysis/move-crawford.json new file mode 100644 index 0000000..90985fc --- /dev/null +++ b/tests/reference/analysis/move-crawford.json @@ -0,0 +1,126 @@ +{ + "id": "move-crawford", + "description": "Match en 5 points, partie Crawford, 4-3.", + "position_id": "4Dn4AGiynYMHAA", + "match_id": "8AmuAEAAEAAE", + "dice": [ + 4, + 3 + ], + "played": "5/2 5/1", + "type": "move", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "played_move": "5/2 5/1*", + "played_position_id": "4Dn4AHDNnIMHAA", + "move_count": 20, + "rank": 2, + "played_equity": 0.7229391, + "best_move": "8/5 6/2", + "best_equity": 0.7345603, + "equity_loss": -0.011621237, + "skill": "none", + "ranking": [ + { + "move": "8/5 6/2", + "equity": 0.7345603 + }, + { + "move": "5/2 5/1*", + "equity": 0.7229391 + }, + { + "move": "8/1*", + "equity": 0.71705425 + }, + { + "move": "13/10 6/2", + "equity": 0.7142835 + }, + { + "move": "13/9 8/5", + "equity": 0.6963019 + }, + { + "move": "8/5 8/4", + "equity": 0.68610644 + }, + { + "move": "13/10 8/4", + "equity": 0.6855368 + }, + { + "move": "13/10 13/9", + "equity": 0.6818689 + }, + { + "move": "13/6", + "equity": 0.6633271 + }, + { + "move": "13/9 5/2", + "equity": 0.6538005 + } + ] + }, + "2": { + "played_move": "5/2 5/1*", + "played_position_id": "4Dn4AHDNnIMHAA", + "move_count": 20, + "rank": 4, + "played_equity": 0.6911188, + "best_move": "8/5 6/2", + "best_equity": 0.7697691, + "equity_loss": -0.078650296, + "skill": "bad", + "ranking": [ + { + "move": "8/5 6/2", + "equity": 0.7697691 + }, + { + "move": "13/10 6/2", + "equity": 0.7615671 + }, + { + "move": "13/9 8/5", + "equity": 0.69608325 + }, + { + "move": "5/2 5/1*", + "equity": 0.6911188 + }, + { + "move": "8/1*", + "equity": 0.6836242 + }, + { + "move": "13/10 13/9", + "equity": 0.6807338 + }, + { + "move": "13/10 8/4", + "equity": 0.6733323 + }, + { + "move": "13/6", + "equity": 0.66055346 + }, + { + "move": "8/5 8/4", + "equity": 0.6556259 + }, + { + "move": "13/9 5/2", + "equity": 0.645951 + } + ] + } + }, + "luck": { + "value": -0.050364614, + "rating": "none" + } +} diff --git a/tests/reference/analysis/move-forced.json b/tests/reference/analysis/move-forced.json new file mode 100644 index 0000000..755a2e4 --- /dev/null +++ b/tests/reference/analysis/move-forced.json @@ -0,0 +1,54 @@ +{ + "id": "move-forced", + "description": "Rentrée forcée depuis la barre, 3-1.", + "position_id": "tm3ABwBw5+ABYA", + "match_id": "cIkFAAAAAAAA", + "dice": [ + 3, + 1 + ], + "played": "bar/24", + "type": "move", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "played_move": "bar/24", + "played_position_id": "tm3ABwBw5+ABUA", + "move_count": 1, + "rank": 1, + "played_equity": -1.0, + "best_move": "bar/24", + "best_equity": -1.0, + "equity_loss": -0.0, + "skill": "none", + "ranking": [ + { + "move": "bar/24", + "equity": -1.0 + } + ] + }, + "2": { + "played_move": "bar/24", + "played_position_id": "tm3ABwBw5+ABUA", + "move_count": 1, + "rank": 1, + "played_equity": -1.0, + "best_move": "bar/24", + "best_equity": -1.0, + "equity_loss": -0.0, + "skill": "none", + "ranking": [ + { + "move": "bar/24", + "equity": -1.0 + } + ] + } + }, + "luck": { + "value": 0.0, + "rating": "none" + } +} diff --git a/tests/reference/analysis/move-jacoby.json b/tests/reference/analysis/move-jacoby.json new file mode 100644 index 0000000..1deab70 --- /dev/null +++ b/tests/reference/analysis/move-jacoby.json @@ -0,0 +1,142 @@ +{ + "id": "move-jacoby", + "description": "Partie libre avec la règle Jacoby, 6-4.", + "position_id": "4HPwAVC0XYMHAA", + "match_id": "cAkTAAAAAAAA", + "dice": [ + 6, + 4 + ], + "played": "8/2 5/1", + "type": "move", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "played_move": "8/2 5/1*", + "played_position_id": "4HPwAWDVuoIHAA", + "move_count": 14, + "rank": 14, + "played_equity": 0.50889754, + "best_move": "13/3", + "best_equity": 0.9328213, + "equity_loss": -0.42392373, + "skill": "very_bad", + "ranking": [ + { + "move": "13/3", + "equity": 0.9328213 + }, + { + "move": "13/9 13/7", + "equity": 0.9175232 + }, + { + "move": "8/2 7/3", + "equity": 0.91263336 + }, + { + "move": "13/7 8/4", + "equity": 0.90736973 + }, + { + "move": "13/9 7/1*", + "equity": 0.9053596 + }, + { + "move": "8/2 6/2", + "equity": 0.9017469 + }, + { + "move": "8/4 7/1*", + "equity": 0.89993495 + }, + { + "move": "13/7 6/2", + "equity": 0.8962312 + }, + { + "move": "7/1* 5/1", + "equity": 0.8907822 + }, + { + "move": "7/1* 6/2", + "equity": 0.87481093 + }, + { + "move": "13/9 8/2", + "equity": 0.87251335 + }, + { + "move": "8/4 8/2", + "equity": 0.8716274 + }, + { + "move": "13/7 5/1*", + "equity": 0.87005275 + }, + { + "move": "8/2 5/1*", + "equity": 0.50889754 + } + ] + }, + "2": { + "played_move": "8/2 5/1*", + "played_position_id": "4HPwAWDVuoIHAA", + "move_count": 14, + "rank": 7, + "played_equity": 0.3304975, + "best_move": "13/3", + "best_equity": 1.0, + "equity_loss": -0.6695025, + "skill": "very_bad", + "ranking": [ + { + "move": "13/3", + "equity": 1.0 + }, + { + "move": "13/9 13/7", + "equity": 0.99894416 + }, + { + "move": "8/2 7/3", + "equity": 0.99641544 + }, + { + "move": "13/7 8/4", + "equity": 0.98718643 + }, + { + "move": "13/7 6/2", + "equity": 0.9808178 + }, + { + "move": "13/9 7/1*", + "equity": 0.9452111 + }, + { + "move": "8/2 5/1*", + "equity": 0.3304975 + }, + { + "move": "8/4 7/1*", + "equity": 0.93114305 + }, + { + "move": "7/1* 5/1", + "equity": 0.91711015 + }, + { + "move": "8/2 6/2", + "equity": 0.9160911 + } + ] + } + }, + "luck": { + "value": -0.002478838, + "rating": "none" + } +} diff --git a/tests/reference/analysis/move-luck-lucky.json b/tests/reference/analysis/move-luck-lucky.json new file mode 100644 index 0000000..f98e789 --- /dev/null +++ b/tests/reference/analysis/move-luck-lucky.json @@ -0,0 +1,126 @@ +{ + "id": "move-luck-lucky", + "description": "Course, double 4 : jet chanceux, joué au mieux.", + "position_id": "cLezAQBwN2cGAA", + "match_id": "cIkSAAAAAAAA", + "dice": [ + 4, + 4 + ], + "played": null, + "type": "move", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "played_move": "13/5 11/3", + "played_position_id": "cLezAQDk3ZwEAA", + "move_count": 106, + "rank": 1, + "played_equity": 0.16933289, + "best_move": "13/5 11/3", + "best_equity": 0.16933289, + "equity_loss": -0.0, + "skill": "none", + "ranking": [ + { + "move": "13/5 11/3", + "equity": 0.16933289 + }, + { + "move": "13/9(2) 11/3", + "equity": 0.16701066 + }, + { + "move": "13/5 9/5 7/3", + "equity": 0.16552198 + }, + { + "move": "13/5(2)", + "equity": 0.16473158 + }, + { + "move": "13/5 13/9 11/7", + "equity": 0.16365689 + }, + { + "move": "13/5 13/9 7/3", + "equity": 0.16245355 + }, + { + "move": "13/5 11/7 9/5", + "equity": 0.16167049 + }, + { + "move": "11/3 9/5(2)", + "equity": 0.16043647 + }, + { + "move": "13/9 11/3 11/7", + "equity": 0.1603657 + }, + { + "move": "13/5 11/7(2)", + "equity": 0.1602792 + } + ] + }, + "2": { + "played_move": "13/5 11/3", + "played_position_id": "cLezAQDk3ZwEAA", + "move_count": 106, + "rank": 1, + "played_equity": 0.15129912, + "best_move": "13/5 11/3", + "best_equity": 0.15129912, + "equity_loss": -0.0, + "skill": "none", + "ranking": [ + { + "move": "13/5 11/3", + "equity": 0.15129912 + }, + { + "move": "13/5 13/9 11/7", + "equity": 0.15059008 + }, + { + "move": "13/9(2) 11/3", + "equity": 0.14950007 + }, + { + "move": "13/5 11/7 9/5", + "equity": 0.14858595 + }, + { + "move": "11/3 11/7 9/5", + "equity": 0.14829463 + }, + { + "move": "13/9 11/3 11/7", + "equity": 0.1463402 + }, + { + "move": "13/5 9/5 7/3", + "equity": 0.13938732 + }, + { + "move": "13/5 11/7(2)", + "equity": 0.13921705 + }, + { + "move": "13/9(2) 11/7(2)", + "equity": 0.13873637 + }, + { + "move": "13/5 13/9 6/2", + "equity": 0.13852078 + } + ] + } + }, + "luck": { + "value": 0.43559298, + "rating": "lucky" + } +} diff --git a/tests/reference/analysis/move-luck-unlucky-bearoff.json b/tests/reference/analysis/move-luck-unlucky-bearoff.json new file mode 100644 index 0000000..a96a1ff --- /dev/null +++ b/tests/reference/analysis/move-luck-unlucky-bearoff.json @@ -0,0 +1,78 @@ +{ + "id": "move-luck-unlucky-bearoff", + "description": "Fin de sortie, 2-1 : jet malchanceux, joué au mieux.", + "position_id": "TQAAYAsAAAAAAA", + "match_id": "cAkFAAAAAAAA", + "dice": [ + 2, + 1 + ], + "played": null, + "type": "move", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "played_move": "2/off 1/off", + "played_position_id": "TQAAoAIAAAAAAA", + "move_count": 4, + "rank": 2, + "played_equity": -0.8607614, + "best_move": "3/off", + "best_equity": -0.8607614, + "equity_loss": -0.0, + "skill": "none", + "ranking": [ + { + "move": "3/off", + "equity": -0.8607614 + }, + { + "move": "2/off 1/off", + "equity": -0.8607614 + }, + { + "move": "2/1 2/off", + "equity": -0.95561147 + }, + { + "move": "3/1 2/1", + "equity": -1.0 + } + ] + }, + "2": { + "played_move": "2/off 1/off", + "played_position_id": "TQAAoAIAAAAAAA", + "move_count": 4, + "rank": 2, + "played_equity": -0.8607614, + "best_move": "3/off", + "best_equity": -0.8607614, + "equity_loss": -0.0, + "skill": "none", + "ranking": [ + { + "move": "3/off", + "equity": -0.8607614 + }, + { + "move": "2/off 1/off", + "equity": -0.8607614 + }, + { + "move": "2/1 2/off", + "equity": -0.95561147 + }, + { + "move": "3/1 2/1", + "equity": -1.0 + } + ] + } + }, + "luck": { + "value": -0.32329792, + "rating": "unlucky" + } +} diff --git a/tests/reference/analysis/move-luck-unlucky.json b/tests/reference/analysis/move-luck-unlucky.json new file mode 100644 index 0000000..16cb6c9 --- /dev/null +++ b/tests/reference/analysis/move-luck-unlucky.json @@ -0,0 +1,126 @@ +{ + "id": "move-luck-unlucky", + "description": "Milieu de partie, 5-4 : jet malchanceux, joué au mieux.", + "position_id": "xGfhATDQ2+BBIA", + "match_id": "cIkOAAAAAAAA", + "dice": [ + 5, + 4 + ], + "played": null, + "type": "move", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "played_move": "18/9", + "played_position_id": "xGfhATDQ28IDIA", + "move_count": 24, + "rank": 1, + "played_equity": 0.2544027, + "best_move": "18/9", + "best_equity": 0.2544027, + "equity_loss": -0.0, + "skill": "none", + "ranking": [ + { + "move": "18/9", + "equity": 0.2544027 + }, + { + "move": "18/14 13/8", + "equity": 0.22496489 + }, + { + "move": "24/20 18/13", + "equity": 0.20747662 + }, + { + "move": "24/20 13/8", + "equity": 0.1158272 + }, + { + "move": "13/9 13/8", + "equity": 0.09201278 + }, + { + "move": "24/15", + "equity": 0.061017293 + }, + { + "move": "18/13 6/2", + "equity": 0.03581623 + }, + { + "move": "13/4", + "equity": -0.028887823 + }, + { + "move": "13/8 6/2", + "equity": -0.033572298 + }, + { + "move": "18/13 8/4", + "equity": -0.09662818 + } + ] + }, + "2": { + "played_move": "18/9", + "played_position_id": "xGfhATDQ28IDIA", + "move_count": 24, + "rank": 1, + "played_equity": 0.27687487, + "best_move": "18/9", + "best_equity": 0.27687487, + "equity_loss": -0.0, + "skill": "none", + "ranking": [ + { + "move": "18/9", + "equity": 0.27687487 + }, + { + "move": "18/14 13/8", + "equity": 0.21801135 + }, + { + "move": "24/20 18/13", + "equity": 0.21000355 + }, + { + "move": "24/20 13/8", + "equity": 0.1144446 + }, + { + "move": "13/9 13/8", + "equity": 0.09412725 + }, + { + "move": "24/15", + "equity": 0.07892294 + }, + { + "move": "18/13 6/2", + "equity": -0.020656409 + }, + { + "move": "13/4", + "equity": -0.028887823 + }, + { + "move": "13/8 6/2", + "equity": -0.033572298 + }, + { + "move": "18/13 8/4", + "equity": -0.09662818 + } + ] + } + }, + "luck": { + "value": -0.3615805, + "rating": "unlucky" + } +} diff --git a/tests/reference/analysis/move-match.json b/tests/reference/analysis/move-match.json new file mode 100644 index 0000000..6eb7a82 --- /dev/null +++ b/tests/reference/analysis/move-match.json @@ -0,0 +1,126 @@ +{ + "id": "move-match", + "description": "Match en 7 points à 3-3, 5-3 : frappe au lieu du meilleur coup.", + "position_id": "xGfhATDQ2+BBIA", + "match_id": "cImuACAAEAAE", + "dice": [ + 5, + 3 + ], + "played": "24/16", + "type": "move", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "played_move": "24/16*", + "played_position_id": "xGfwAFjQ2+CRAA", + "move_count": 28, + "rank": 3, + "played_equity": 0.4581785, + "best_move": "13/5", + "best_equity": 0.7553502, + "equity_loss": -0.29717168, + "skill": "very_bad", + "ranking": [ + { + "move": "13/5", + "equity": 0.7553502 + }, + { + "move": "18/13 8/5", + "equity": 0.5814649 + }, + { + "move": "24/16*", + "equity": 0.4581785 + }, + { + "move": "18/10", + "equity": 0.31719947 + }, + { + "move": "8/5 8/3", + "equity": 0.31371918 + }, + { + "move": "18/15 13/8", + "equity": 0.26037523 + }, + { + "move": "24/21 18/13", + "equity": 0.23547156 + }, + { + "move": "8/3 6/3", + "equity": 0.15432969 + }, + { + "move": "13/10 13/8", + "equity": 0.13837479 + }, + { + "move": "18/13 5/2", + "equity": 0.12815686 + } + ] + }, + "2": { + "played_move": "24/16*", + "played_position_id": "xGfwAFjQ2+CRAA", + "move_count": 28, + "rank": 2, + "played_equity": 0.46805125, + "best_move": "13/5", + "best_equity": 0.85497326, + "equity_loss": -0.386922, + "skill": "very_bad", + "ranking": [ + { + "move": "13/5", + "equity": 0.85497326 + }, + { + "move": "24/16*", + "equity": 0.46805125 + }, + { + "move": "18/13 8/5", + "equity": 0.6511238 + }, + { + "move": "18/10", + "equity": 0.31719947 + }, + { + "move": "8/5 8/3", + "equity": 0.31371918 + }, + { + "move": "18/15 13/8", + "equity": 0.26037523 + }, + { + "move": "24/21 18/13", + "equity": 0.23547156 + }, + { + "move": "8/3 6/3", + "equity": 0.15432969 + }, + { + "move": "13/10 13/8", + "equity": 0.13837479 + }, + { + "move": "18/13 5/2", + "equity": 0.12815686 + } + ] + } + }, + "luck": { + "value": 0.026781201, + "rating": "none" + } +} diff --git a/tests/reference/analysis/move-middlegame-blunder.json b/tests/reference/analysis/move-middlegame-blunder.json new file mode 100644 index 0000000..e581af8 --- /dev/null +++ b/tests/reference/analysis/move-middlegame-blunder.json @@ -0,0 +1,142 @@ +{ + "id": "move-middlegame-blunder", + "description": "Milieu de partie, 6-4 : un des pires coups.", + "position_id": "mM/gATCw5+ABMA", + "match_id": "cAkTAAAAAAAA", + "dice": [ + 6, + 4 + ], + "played": "8/4 8/2", + "type": "move", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "played_move": "8/4 8/2", + "played_position_id": "mM/gATDSnuABMA", + "move_count": 14, + "rank": 14, + "played_equity": -0.35321844, + "best_move": "24/14", + "best_equity": 0.15439394, + "equity_loss": -0.50761235, + "skill": "very_bad", + "ranking": [ + { + "move": "24/14", + "equity": 0.15439394 + }, + { + "move": "8/2 6/2", + "equity": 0.10821653 + }, + { + "move": "24/18 13/9", + "equity": 0.10337136 + }, + { + "move": "24/20 24/18", + "equity": 0.072762795 + }, + { + "move": "13/9 13/7", + "equity": 0.0029048775 + }, + { + "move": "24/20 13/7", + "equity": 9.1438415e-05 + }, + { + "move": "13/3", + "equity": -0.012697897 + }, + { + "move": "24/18 6/2", + "equity": -0.075399 + }, + { + "move": "24/18 8/4", + "equity": -0.088734664 + }, + { + "move": "24/20 8/2", + "equity": -0.095189385 + }, + { + "move": "13/9 8/2", + "equity": -0.09528468 + }, + { + "move": "13/7 8/4", + "equity": -0.12819159 + }, + { + "move": "13/7 6/2", + "equity": -0.14467186 + }, + { + "move": "8/4 8/2", + "equity": -0.35321844 + } + ] + }, + "2": { + "played_move": "8/4 8/2", + "played_position_id": "mM/gATDSnuABMA", + "move_count": 14, + "rank": 6, + "played_equity": -0.36824828, + "best_move": "24/14", + "best_equity": 0.13111727, + "equity_loss": -0.49936557, + "skill": "very_bad", + "ranking": [ + { + "move": "24/14", + "equity": 0.13111727 + }, + { + "move": "8/2 6/2", + "equity": 0.11303549 + }, + { + "move": "24/18 13/9", + "equity": 0.105096616 + }, + { + "move": "24/20 24/18", + "equity": 0.049068402 + }, + { + "move": "13/3", + "equity": -0.0040049786 + }, + { + "move": "8/4 8/2", + "equity": -0.36824828 + }, + { + "move": "13/9 13/7", + "equity": -0.014309813 + }, + { + "move": "24/20 13/7", + "equity": -0.029367238 + }, + { + "move": "24/18 8/4", + "equity": -0.081171095 + }, + { + "move": "24/18 6/2", + "equity": -0.09313507 + } + ] + } + }, + "luck": { + "value": -0.07414439, + "rating": "none" + } +} diff --git a/tests/reference/analysis/move-middlegame-close.json b/tests/reference/analysis/move-middlegame-close.json new file mode 100644 index 0000000..59487b5 --- /dev/null +++ b/tests/reference/analysis/move-middlegame-close.json @@ -0,0 +1,126 @@ +{ + "id": "move-middlegame-close", + "description": "Milieu de partie, 6-4 : un coup proche du meilleur.", + "position_id": "mM/gATCw5+ABMA", + "match_id": "cAkTAAAAAAAA", + "dice": [ + 6, + 4 + ], + "played": "8/2 6/2", + "type": "move", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "played_move": "8/2 6/2", + "played_position_id": "mM/gATDGzuABMA", + "move_count": 14, + "rank": 2, + "played_equity": 0.10821653, + "best_move": "24/14", + "best_equity": 0.15439394, + "equity_loss": -0.04617741, + "skill": "doubtful", + "ranking": [ + { + "move": "24/14", + "equity": 0.15439394 + }, + { + "move": "8/2 6/2", + "equity": 0.10821653 + }, + { + "move": "24/18 13/9", + "equity": 0.10337136 + }, + { + "move": "24/20 24/18", + "equity": 0.072762795 + }, + { + "move": "13/9 13/7", + "equity": 0.0029048775 + }, + { + "move": "24/20 13/7", + "equity": 9.1438415e-05 + }, + { + "move": "13/3", + "equity": -0.012697897 + }, + { + "move": "24/18 6/2", + "equity": -0.075399 + }, + { + "move": "24/18 8/4", + "equity": -0.088734664 + }, + { + "move": "24/20 8/2", + "equity": -0.095189385 + } + ] + }, + "2": { + "played_move": "8/2 6/2", + "played_position_id": "mM/gATDGzuABMA", + "move_count": 14, + "rank": 2, + "played_equity": 0.11303549, + "best_move": "24/14", + "best_equity": 0.13111727, + "equity_loss": -0.018081777, + "skill": "none", + "ranking": [ + { + "move": "24/14", + "equity": 0.13111727 + }, + { + "move": "8/2 6/2", + "equity": 0.11303549 + }, + { + "move": "24/18 13/9", + "equity": 0.105096616 + }, + { + "move": "24/20 24/18", + "equity": 0.049068402 + }, + { + "move": "13/3", + "equity": -0.0040049786 + }, + { + "move": "13/9 13/7", + "equity": -0.014309813 + }, + { + "move": "24/20 13/7", + "equity": -0.029367238 + }, + { + "move": "24/18 8/4", + "equity": -0.081171095 + }, + { + "move": "24/18 6/2", + "equity": -0.09313507 + }, + { + "move": "24/20 8/2", + "equity": -0.095189385 + } + ] + } + }, + "luck": { + "value": -0.07414439, + "rating": "none" + } +} diff --git a/tests/reference/analysis/move-opening-31-best.json b/tests/reference/analysis/move-opening-31-best.json new file mode 100644 index 0000000..50dbcd5 --- /dev/null +++ b/tests/reference/analysis/move-opening-31-best.json @@ -0,0 +1,126 @@ +{ + "id": "move-opening-31-best", + "description": "Ouverture 3-1 jouée au mieux (8/5 6/5) ; chance du premier jet.", + "position_id": "4HPwATDgc/ABMA", + "match_id": "cIkFAAAAAAAA", + "dice": [ + 3, + 1 + ], + "played": "8/5 6/5", + "type": "move", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "played_move": "8/5 6/5", + "played_position_id": "4HPwATCwZ/ABMA", + "move_count": 16, + "rank": 1, + "played_equity": 0.21258597, + "best_move": "8/5 6/5", + "best_equity": 0.21258597, + "equity_loss": -0.0, + "skill": "none", + "ranking": [ + { + "move": "8/5 6/5", + "equity": 0.21258597 + }, + { + "move": "24/23 13/10", + "equity": -0.010789523 + }, + { + "move": "24/20", + "equity": -0.017392516 + }, + { + "move": "24/23 24/21", + "equity": -0.023609947 + }, + { + "move": "13/9", + "equity": -0.027675275 + }, + { + "move": "24/21 6/5", + "equity": -0.04369401 + }, + { + "move": "13/10 6/5", + "equity": -0.04984435 + }, + { + "move": "24/23 8/5", + "equity": -0.104100645 + }, + { + "move": "24/23 6/3", + "equity": -0.10542354 + }, + { + "move": "24/21 8/7", + "equity": -0.107585564 + } + ] + }, + "2": { + "played_move": "8/5 6/5", + "played_position_id": "4HPwATCwZ/ABMA", + "move_count": 16, + "rank": 1, + "played_equity": 0.20373425, + "best_move": "8/5 6/5", + "best_equity": 0.20373425, + "equity_loss": -0.0, + "skill": "none", + "ranking": [ + { + "move": "8/5 6/5", + "equity": 0.20373425 + }, + { + "move": "24/23 13/10", + "equity": -0.022293653 + }, + { + "move": "24/20", + "equity": -0.031027086 + }, + { + "move": "24/23 24/21", + "equity": -0.03213631 + }, + { + "move": "13/9", + "equity": -0.040205758 + }, + { + "move": "24/21 6/5", + "equity": -0.04369401 + }, + { + "move": "13/10 6/5", + "equity": -0.04984435 + }, + { + "move": "24/23 8/5", + "equity": -0.104100645 + }, + { + "move": "24/23 6/3", + "equity": -0.10542354 + }, + { + "move": "24/21 8/7", + "equity": -0.107585564 + } + ] + } + }, + "luck": { + "value": 0.21258597, + "rating": "none" + } +} diff --git a/tests/reference/analysis/move-opening-31-error.json b/tests/reference/analysis/move-opening-31-error.json new file mode 100644 index 0000000..0ac69a9 --- /dev/null +++ b/tests/reference/analysis/move-opening-31-error.json @@ -0,0 +1,126 @@ +{ + "id": "move-opening-31-error", + "description": "Ouverture 3-1 jouée 24/23 13/10 au lieu de faire le point 5.", + "position_id": "4HPwATDgc/ABMA", + "match_id": "cIkFAAAAAAAA", + "dice": [ + 3, + 1 + ], + "played": "24/23 13/10", + "type": "move", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "played_move": "24/23 13/10", + "played_position_id": "4HPwATDgc+IBKA", + "move_count": 16, + "rank": 2, + "played_equity": -0.010789523, + "best_move": "8/5 6/5", + "best_equity": 0.21258597, + "equity_loss": -0.2233755, + "skill": "very_bad", + "ranking": [ + { + "move": "8/5 6/5", + "equity": 0.21258597 + }, + { + "move": "24/23 13/10", + "equity": -0.010789523 + }, + { + "move": "24/20", + "equity": -0.017392516 + }, + { + "move": "24/23 24/21", + "equity": -0.023609947 + }, + { + "move": "13/9", + "equity": -0.027675275 + }, + { + "move": "24/21 6/5", + "equity": -0.04369401 + }, + { + "move": "13/10 6/5", + "equity": -0.04984435 + }, + { + "move": "24/23 8/5", + "equity": -0.104100645 + }, + { + "move": "24/23 6/3", + "equity": -0.10542354 + }, + { + "move": "24/21 8/7", + "equity": -0.107585564 + } + ] + }, + "2": { + "played_move": "24/23 13/10", + "played_position_id": "4HPwATDgc+IBKA", + "move_count": 16, + "rank": 2, + "played_equity": -0.0118339015, + "best_move": "8/5 6/5", + "best_equity": 0.1998143, + "equity_loss": -0.21164821, + "skill": "very_bad", + "ranking": [ + { + "move": "8/5 6/5", + "equity": 0.1998143 + }, + { + "move": "24/23 13/10", + "equity": -0.0118339015 + }, + { + "move": "24/20", + "equity": -0.031027086 + }, + { + "move": "24/23 24/21", + "equity": -0.03213631 + }, + { + "move": "13/9", + "equity": -0.040205758 + }, + { + "move": "24/21 6/5", + "equity": -0.04369401 + }, + { + "move": "13/10 6/5", + "equity": -0.04984435 + }, + { + "move": "24/23 8/5", + "equity": -0.104100645 + }, + { + "move": "24/23 6/3", + "equity": -0.10542354 + }, + { + "move": "24/21 8/7", + "equity": -0.107585564 + } + ] + } + }, + "luck": { + "value": 0.21258597, + "rating": "none" + } +} diff --git a/tests/reference/analysis/move-opening-65-error.json b/tests/reference/analysis/move-opening-65-error.json new file mode 100644 index 0000000..0fbce8c --- /dev/null +++ b/tests/reference/analysis/move-opening-65-error.json @@ -0,0 +1,102 @@ +{ + "id": "move-opening-65-error", + "description": "Ouverture 6-5 jouée 13/8 13/7 au lieu de courir 24/13.", + "position_id": "4HPwATDgc/ABMA", + "match_id": "cAkXAAAAAAAA", + "dice": [ + 6, + 5 + ], + "played": "13/8 13/7", + "type": "move", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "played_move": "13/8 13/7", + "played_position_id": "4HPwATDg68EBMA", + "move_count": 7, + "rank": 3, + "played_equity": -0.05142035, + "best_move": "24/13", + "best_equity": 0.082985915, + "equity_loss": -0.13440627, + "skill": "very_bad", + "ranking": [ + { + "move": "24/13", + "equity": 0.082985915 + }, + { + "move": "24/18 13/8", + "equity": 0.03631018 + }, + { + "move": "13/8 13/7", + "equity": -0.05142035 + }, + { + "move": "13/2", + "equity": -0.13522762 + }, + { + "move": "24/18 8/3", + "equity": -0.14635906 + }, + { + "move": "13/7 8/3", + "equity": -0.17384717 + }, + { + "move": "8/3 8/2", + "equity": -0.37779096 + } + ] + }, + "2": { + "played_move": "13/8 13/7", + "played_position_id": "4HPwATDg68EBMA", + "move_count": 7, + "rank": 3, + "played_equity": -0.061850794, + "best_move": "24/13", + "best_equity": 0.07996891, + "equity_loss": -0.1418197, + "skill": "very_bad", + "ranking": [ + { + "move": "24/13", + "equity": 0.07996891 + }, + { + "move": "24/18 13/8", + "equity": 0.03882884 + }, + { + "move": "13/8 13/7", + "equity": -0.061850794 + }, + { + "move": "13/2", + "equity": -0.12083713 + }, + { + "move": "24/18 8/3", + "equity": -0.13999933 + }, + { + "move": "13/7 8/3", + "equity": -0.17384717 + }, + { + "move": "8/3 8/2", + "equity": -0.37779096 + } + ] + } + }, + "luck": { + "value": 0.082985915, + "rating": "none" + } +} diff --git a/tests/reference/analysis/move-opening-double.json b/tests/reference/analysis/move-opening-double.json new file mode 100644 index 0000000..5ab51f1 --- /dev/null +++ b/tests/reference/analysis/move-opening-double.json @@ -0,0 +1,126 @@ +{ + "id": "move-opening-double", + "description": "Position de départ et double 6 : pas un premier jet, chance comptée sur les 36 jets.", + "position_id": "4HPwATDgc/ABMA", + "match_id": "cIkFAAAAAAAA", + "dice": [ + 6, + 6 + ], + "played": null, + "type": "move", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "played_move": "24/18(2) 13/7(2)", + "played_position_id": "4HPwATDg28HBAA", + "move_count": 11, + "rank": 1, + "played_equity": 0.539388, + "best_move": "24/18(2) 13/7(2)", + "best_equity": 0.539388, + "equity_loss": -0.0, + "skill": "none", + "ranking": [ + { + "move": "24/18(2) 13/7(2)", + "equity": 0.539388 + }, + { + "move": "24/18 13/7(3)", + "equity": 0.33099693 + }, + { + "move": "24/18(2) 8/2(2)", + "equity": 0.27119082 + }, + { + "move": "13/7(2) 8/2(2)", + "equity": 0.1563462 + }, + { + "move": "24/18 13/7(2) 8/2", + "equity": 0.14750561 + }, + { + "move": "13/7(4)", + "equity": 0.14310652 + }, + { + "move": "24/18(2) 13/7 8/2", + "equity": 0.12353878 + }, + { + "move": "13/7(3) 8/2", + "equity": 0.101389736 + }, + { + "move": "24/18 13/7 8/2(2)", + "equity": -0.05435923 + }, + { + "move": "24/18 8/2(3)", + "equity": -0.12482335 + } + ] + }, + "2": { + "played_move": "24/18(2) 13/7(2)", + "played_position_id": "4HPwATDg28HBAA", + "move_count": 11, + "rank": 1, + "played_equity": 0.5886327, + "best_move": "24/18(2) 13/7(2)", + "best_equity": 0.5886327, + "equity_loss": -0.0, + "skill": "none", + "ranking": [ + { + "move": "24/18(2) 13/7(2)", + "equity": 0.5886327 + }, + { + "move": "24/18 13/7(3)", + "equity": 0.33312234 + }, + { + "move": "24/18(2) 8/2(2)", + "equity": 0.27119082 + }, + { + "move": "13/7(2) 8/2(2)", + "equity": 0.1563462 + }, + { + "move": "24/18 13/7(2) 8/2", + "equity": 0.14750561 + }, + { + "move": "13/7(4)", + "equity": 0.14310652 + }, + { + "move": "24/18(2) 13/7 8/2", + "equity": 0.12353878 + }, + { + "move": "13/7(3) 8/2", + "equity": 0.101389736 + }, + { + "move": "24/18 13/7 8/2(2)", + "equity": -0.05435923 + }, + { + "move": "24/18 8/2(3)", + "equity": -0.12482335 + } + ] + } + }, + "luck": { + "value": 0.43498537, + "rating": "lucky" + } +} diff --git a/tests/reference/analysis/move-race.json b/tests/reference/analysis/move-race.json new file mode 100644 index 0000000..09dea1e --- /dev/null +++ b/tests/reference/analysis/move-race.json @@ -0,0 +1,202 @@ +{ + "id": "move-race", + "description": "Course, 5-4 : sortie maladroite.", + "position_id": "cLezAQBwN2cGAA", + "match_id": "cIkSAAAAAAAA", + "dice": [ + 5, + 4 + ], + "played": "6/1 5/1", + "type": "move", + "gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b", + "met": "Kazaross-XG2", + "plies": { + "0": { + "played_move": "6/1 5/1", + "played_position_id": "cLezAQDDNmcGAA", + "move_count": 29, + "rank": 29, + "played_equity": -0.31466243, + "best_move": "13/9 11/6", + "best_equity": -0.22230339, + "equity_loss": -0.092359036, + "skill": "bad", + "ranking": [ + { + "move": "13/9 11/6", + "equity": -0.22230339 + }, + { + "move": "11/6 9/5", + "equity": -0.22993731 + }, + { + "move": "13/8 9/5", + "equity": -0.23005769 + }, + { + "move": "13/9 13/8", + "equity": -0.23073325 + }, + { + "move": "13/8 11/7", + "equity": -0.23153114 + }, + { + "move": "11/7 11/6", + "equity": -0.2335976 + }, + { + "move": "13/8 7/3", + "equity": -0.23374501 + }, + { + "move": "11/6 7/3", + "equity": -0.23394687 + }, + { + "move": "13/4", + "equity": -0.23507257 + }, + { + "move": "13/9 7/2", + "equity": -0.23529215 + }, + { + "move": "7/3 7/2", + "equity": -0.23544219 + }, + { + "move": "11/2", + "equity": -0.235758 + }, + { + "move": "11/7 9/4", + "equity": -0.23589191 + }, + { + "move": "13/8 6/2", + "equity": -0.24026787 + }, + { + "move": "9/5 7/2", + "equity": -0.24087214 + }, + { + "move": "13/8 5/1", + "equity": -0.24252185 + }, + { + "move": "9/5 9/4", + "equity": -0.2440444 + }, + { + "move": "9/4 6/2", + "equity": -0.24487257 + }, + { + "move": "9/4 7/3", + "equity": -0.24512678 + }, + { + "move": "11/6 5/1", + "equity": -0.25016445 + }, + { + "move": "9/4 5/1", + "equity": -0.25572774 + }, + { + "move": "7/2 5/1", + "equity": -0.258138 + }, + { + "move": "7/3 6/1", + "equity": -0.25878713 + }, + { + "move": "7/2 6/2", + "equity": -0.26088732 + }, + { + "move": "13/9 6/1", + "equity": -0.26104733 + }, + { + "move": "9/5 6/1", + "equity": -0.26680842 + }, + { + "move": "11/7 6/1", + "equity": -0.2670043 + }, + { + "move": "6/2 6/1", + "equity": -0.27360544 + }, + { + "move": "6/1 5/1", + "equity": -0.31466243 + } + ] + }, + "2": { + "played_move": "6/1 5/1", + "played_position_id": "cLezAQDDNmcGAA", + "move_count": 29, + "rank": 7, + "played_equity": -0.29347342, + "best_move": "13/9 11/6", + "best_equity": -0.21922025, + "equity_loss": -0.07425317, + "skill": "bad", + "ranking": [ + { + "move": "13/9 11/6", + "equity": -0.21922025 + }, + { + "move": "13/9 13/8", + "equity": -0.2202243 + }, + { + "move": "11/6 9/5", + "equity": -0.22022447 + }, + { + "move": "13/8 9/5", + "equity": -0.22088149 + }, + { + "move": "11/7 11/6", + "equity": -0.22277355 + }, + { + "move": "13/4", + "equity": -0.2230124 + }, + { + "move": "6/1 5/1", + "equity": -0.29347342 + }, + { + "move": "11/7 9/4", + "equity": -0.22985312 + }, + { + "move": "13/8 11/7", + "equity": -0.23169218 + }, + { + "move": "13/9 7/2", + "equity": -0.23561361 + } + ] + } + }, + "luck": { + "value": 0.043956697, + "rating": "none" + } +} diff --git a/tests/reference/compare.py b/tests/reference/compare.py index a9e4ec2..677da81 100644 --- a/tests/reference/compare.py +++ b/tests/reference/compare.py @@ -138,6 +138,123 @@ def compare_position(report, cli, path): return len(position["plies"]) +# -------------------------------------------------------------------------- +# Analyses (analysis/.json, voir README.md, section « Analyses »). + +# Seuils de gnubg (arSkillLevel et arLuckLevel) : une appréciation ou une +# chance n'est comparée strictement que loin de ces seuils. +SKILL_THRESHOLDS = (0.03, 0.06, 0.12) +LUCK_THRESHOLDS = (0.3, 0.6) + + +def run_cli(cli, args): + command = shlex.split(cli) + args + output = subprocess.run(command, capture_output=True, text=True) + if output.returncode != 0: + raise SystemExit("%s a échoué : %s" % (" ".join(command), output.stdout + output.stderr)) + return json.loads(output.stdout) + + +def near_threshold(value, thresholds, tolerance): + return any(abs(abs(value) - t) <= tolerance for t in thresholds) + + +def same_label(report, where, field, expected, actual, value, thresholds): + """Même appréciation ou chance, sauf si la valeur de gnubg est à moins de + la tolérance d'un seuil : remarque sans échec.""" + if expected == actual: + return + if near_threshold(value, thresholds, report.tolerance): + report.notes.append("%s : %s %s pour gnubg, %s pour le moteur ; valeur %.4f proche " + "d'un seuil" % (where, field, expected, actual, value)) + else: + report.same(where, field, expected, actual) + + +def compare_move_analysis(report, cli, case): + dice = "%d%d" % tuple(case["dice"]) + args = [case["position_id"], case["match_id"], dice] + luck = run_cli(cli, ["luck"] + args) + where = "%s chance" % case["id"] + report.number(where, "luck", case["luck"]["value"], luck["luck"]) + same_label(report, where, "rating", case["luck"]["rating"], luck["rating"], + case["luck"]["value"], LUCK_THRESHOLDS) + for ply, expected in sorted(case["plies"].items()): + actual = run_cli(cli, ["--ply", ply, "analysemove"] + args + + [expected["played_position_id"]]) + where = "%s à %s pli(s)" % (case["id"], ply) + report.same(where, "move_count", expected["move_count"], actual["move_count"]) + report.same(where, "coup joué", expected["played_move"], actual["played"]["move"]) + # Rang : le même, sauf si les coups entre les deux rangs ont, pour + # gnubg, la même équité que le coup joué à la tolérance près. + if expected["rank"] != actual["rank"]: + low, high = sorted((expected["rank"], actual["rank"])) + between = expected["ranking"][low - 1:high] + tied = len(between) == high - low + 1 and all( + abs(m["equity"] - expected["played_equity"]) <= report.tolerance for m in between) + if tied: + report.notes.append("%s : rang %d pour gnubg, %d pour le moteur ; égalité" + % (where, expected["rank"], actual["rank"])) + else: + report.same(where, "rank", expected["rank"], actual["rank"]) + if expected["best_move"] != actual["best"]["move"]: + ranking = expected["ranking"] + tied = len(ranking) > 1 and ranking[0]["equity"] - ranking[1]["equity"] <= report.tolerance + if tied: + report.notes.append("%s : meilleur coup %s pour gnubg, %s pour le moteur ; égalité" + % (where, expected["best_move"], actual["best"]["move"])) + else: + report.same(where, "meilleur coup", expected["best_move"], actual["best"]["move"]) + report.number(where, "played_equity", expected["played_equity"], actual["played"]["equity"]) + report.number(where, "best_equity", expected["best_equity"], actual["best"]["equity"]) + report.number(where, "equity_loss", expected["equity_loss"], actual["equity_loss"]) + same_label(report, where, "skill", expected["skill"], actual["skill"], + expected["equity_loss"], SKILL_THRESHOLDS) + + +def compare_cube_analysis(report, cli, case): + for ply, expected in sorted(case["plies"].items()): + actual = run_cli(cli, ["--ply", ply, "analysecube", case["position_id"], + case["match_id"], case["action"]]) + where = "%s à %s pli(s)" % (case["id"], ply) + report.same(where, "analysed", expected["analysed"], actual["analysed"]) + if expected["analysed"] and actual["analysed"]: + compare_cube(report, where, expected["cube"], actual["cube"]) + report.number(where, "equity_loss", expected["equity_loss"], actual["equity_loss"]) + same_label(report, where, "skill", expected["skill"], actual["skill"], + expected["equity_loss"], SKILL_THRESHOLDS) + + +def compare_levels(report, cli, case, settings): + for name, expected in sorted(case["levels"].items()): + where = "%s niveau %s" % (case["id"], name) + report.same(where, "réglages", (expected["plies"], expected["noise"]), + (settings[name]["plies"], round(settings[name]["noise"], 6))) + if case["dice"]: + actual = run_cli(cli, ["--level", name, "bestmove", case["position_id"], + case["match_id"], "%d%d" % tuple(case["dice"])]) + report.same(where, "coup choisi", expected["move"], actual["best_move"]["move"]) + else: + actual = run_cli(cli, ["--level", name, "cube", case["position_id"], case["match_id"]]) + compare_cube(report, where, expected, actual) + + +def compare_analyses(report, cli, folder): + """Compare toutes les analyses ; renvoie leur nombre.""" + settings = {level["name"]: level for level in run_cli(cli, ["levels"])["levels"]} + paths = sorted(glob.glob(os.path.join(folder, "*.json"))) + for path in paths: + with open(path, encoding="utf-8") as f: + case = json.load(f) + if case["type"] == "move": + compare_move_analysis(report, cli, case) + elif case["type"] == "cube": + compare_cube_analysis(report, cli, case) + else: + compare_levels(report, cli, case, settings) + return len(paths) + + def print_table(lines): header = ("position et niveau", "champ", "attendu", "obtenu", "écart") rows = [header] + lines @@ -152,6 +269,7 @@ def main(): parser.add_argument("--cli", default="build/engine-cli", help="programme interrogé, éventuellement avec ses arguments") parser.add_argument("--results", default=os.path.join(here, "results")) + parser.add_argument("--analysis", default=os.path.join(here, "analysis")) parser.add_argument("--tolerance", type=float, default=TOLERANCE) parser.add_argument("--details", action="store_true", help="affiche tous les nombres comparés") @@ -160,14 +278,15 @@ def main(): report = Report(args.tolerance) paths = sorted(glob.glob(os.path.join(args.results, "*.json"))) levels = sum(compare_position(report, args.cli, path) for path in paths) + analyses = compare_analyses(report, args.cli, args.analysis) if args.details: print_table(report.lines) for note in report.notes: print(" Remarque : " + note) - print(" %d positions, %d niveaux, %d nombres comparés ; écart maximal %.2e (%s), " - "tolérance %.0e." % (len(paths), levels, len(report.lines), report.max_gap, - report.max_gap_where, args.tolerance)) + print(" %d positions, %d niveaux, %d analyses, %d nombres comparés ; écart maximal %.2e " + "(%s), tolérance %.0e." % (len(paths), levels, analyses, len(report.lines), + report.max_gap, report.max_gap_where, args.tolerance)) if report.failures: print(" %d écart(s) hors critères :" % len(report.failures)) print_table(report.failures) diff --git a/tests/reference/generate.py b/tests/reference/generate.py index 9330197..858abaf 100644 --- a/tests/reference/generate.py +++ b/tests/reference/generate.py @@ -161,6 +161,259 @@ def check_settings(): raise SystemExit("table d'équité de match inattendue") +# -------------------------------------------------------------------------- +# Analyses (analyses.json -> analysis/.json) : coup joué, décision de +# videau, chance d'un jet et niveaux de jeu. Les valeurs viennent de l'analyse +# de partie de gnubg (« analyse game ») sur une courte partie rejouée ici : +# la position, puis le coup ou la décision. Voir README.md. + +# Appréciations et chances : noms de gnubg (skillString et luckString de +# gnubgmodule.c ; absents du dictionnaire quand rien n'est à signaler) vers +# ceux d'engine-cli. +SKILLS = {None: "none", "doubtful": "doubtful", "bad": "bad", "very bad": "very_bad"} +LUCKS = {None: "none", "verybad": "very_unlucky", "bad": "unlucky", "good": "lucky", + "verygood": "very_lucky"} + +# Niveaux de jeu : réglages prédéfinis de gnubg (aecSettings, eval.c +# l. 318-328), en (plis, bruit) ; l'élagage est actif à partir d'1 pli, le +# bruit est déterministe. Ceux qui sont vérifiés : 3 et 4 plis sont trop +# lents pour la suite de tests. +LEVEL_SETTINGS = { + "beginner": (0, 0.06), "casual": (0, 0.05), "intermediate": (0, 0.04), + "advanced": (0, 0.015), "0ply": (0, 0.0), "1ply": (1, 0.0), "2ply": (2, 0.0), + "3ply": (3, 0.0), "4ply": (4, 0.0), +} +CHECKED_LEVELS = ("beginner", "casual", "intermediate", "advanced", "0ply", "1ply", "2ply") +RANKING_KEPT = 10 # coups gardés dans le classement d'une analyse (plus le coup joué) + + +def level_context(name): + plies, noise = LEVEL_SETTINGS[name] + return {"cubeful": 1, "plies": plies, "deterministic": 1, + "prune": 1 if plies > 0 else 0, "noise": noise} + + +def start_game(case): + """Nouvelle partie entre deux joueurs humains (gnubg ne joue rien de + lui-même), placée sur la position du cas, cache vidé.""" + gnubg.command("set player 0 human") + gnubg.command("set player 1 human") + gnubg.command("new session") + gnubg.command("clear cache") + gnubg.command("set gnubgid %s:%s" % (case["position_id"], case["match_id"])) + read_back = (gnubg.positionid(), gnubg.matchid()) + if read_back != (case["position_id"], case["match_id"]): + raise SystemExit("%s : gnubg relit %s" % (case["id"], read_back)) + + +def set_analysis(ply): + """Réglages de l'analyse de partie : niveau ply sans bruit, comme le + réglage prédéfini de gnubg ; filtre de coups et analyse de la chance par + défaut (« Normal », ecLuck).""" + for kind in ("chequerplay", "cubedecision"): + prefix = "set analysis %s evaluation " % kind + gnubg.command("set analysis %s type evaluation" % kind) + gnubg.command(prefix + "plies %d" % ply) + gnubg.command(prefix + "cubeful on") + gnubg.command(prefix + "prune " + ("on" if ply > 0 else "off")) + gnubg.command(prefix + "deterministic on") + gnubg.command(prefix + "noise 0") + + +def analyse_game(): + """Analyse la partie en cours ; renvoie ses enregistrements et ses + statistiques par joueur (« X », « O »). Le cache est vidé avant : les + évaluations à 2 plis faites pour choisir un coup y laissent des + résultats à 0 pli des réseaux d'élagage, que l'analyse à 0 pli + reprendrait (voir analyse_move).""" + gnubg.command("clear cache") + gnubg.command("analyse game") + game = gnubg.match(analysis=1, verbose=1, statistics=1, boards=1)["games"][-1] + return game["game"], game["stats"] + + +def last_record(records, action): + found = [r for r in records if r.get("action") == action] + if not found: + raise SystemExit("aucun enregistrement « %s » dans la partie analysée" % action) + return found[-1] + + +def swapped(board): + """La même position vue de l'autre joueur (gnubg.board() donne le joueur + au trait en second).""" + return (board[1], board[0]) + + +def set_dice(case): + """Les dés du cas, qui priment sur ceux du Match ID.""" + gnubg.command("set dice %d %d" % tuple(case["dice"])) + + +def best_move(): + """Le meilleur coup selon gnubg (findbestmove, réglages d'évaluation + courants), en notation de gnubg. Pas « hint » : gnubg garderait son + analyse dans l'enregistrement du coup, et « analyse game » la + reprendrait au lieu de calculer au niveau demandé.""" + board = gnubg.board() + return gnubg.movetupletostring(gnubg.findbestmove(), board) + + +def play_move(case): + """Joue le coup du cas, ou le meilleur selon gnubg s'il n'est pas donné + (cas qui ne servent qu'à la chance du jet).""" + gnubg.command("move " + (case.get("played") or best_move())) + + +def analyse_move(case, ply): + start_game(case) + set_dice(case) + before = gnubg.board() + play_move(case) + # Sans analyse du videau : gnubg la ferait juste avant la chance du jet, + # et à 2 plis elle laisse dans son cache d'évaluation des résultats à + # 0 pli calculés avec les réseaux d'élagage, que l'analyse de la chance + # (0 pli, sans élagage) reprend ensuite : la chance dépendrait alors de ce + # qui a été calculé avant (écart observé : 5e-3 sur move-middlegame-close). + gnubg.command("set analysis cube off") + # Après le coup, l'adversaire est au trait : la position vue de celui qui + # vient de jouer est la position retournée. + played_id = gnubg.positionid(swapped(gnubg.board())) + set_analysis(ply) + records, stats = analyse_game() + record = last_record(records, "move") + analysis = record["analysis"] + moves, index = analysis["moves"], analysis["imove"] + for m in moves: + if m["type"] != "eval": + raise SystemExit("%s : coup évalué autrement que prévu : %r" % (case["id"], m)) + + def notation(move): + return gnubg.movetupletostring(move, before) + + kept = moves[:max(RANKING_KEPT, index + 1)] + level = { + "played_move": notation(record["move"]), + "played_position_id": played_id, + "move_count": len(moves), + "rank": index + 1, + "played_equity": f32(moves[index]["score"]), + "best_move": notation(moves[0]["move"]), + "best_equity": f32(moves[0]["score"]), + # Perte calculée par gnubg pour ses statistiques (rChequerSkill), + # comptée en positif. + "equity_loss": f32(-stats[record["player"]]["moves"]["error-skill"]), + "skill": SKILLS[analysis.get("skill")], + "ranking": [{"move": notation(m["move"]), "equity": f32(m["score"])} for m in kept], + } + luck = {"value": f32(analysis["luck-value"]), "rating": LUCKS[analysis.get("luck")]} + return level, luck + + +def analyse_cube(case, ply): + start_game(case) + gnubg.command("set analysis cube on") + set_analysis(ply) + # Décision de videau de gnubg au même niveau, avant toute action. + decision = cube_decision(ply) + action = case["action"] + if action == "no_double": + # Ne pas doubler, c'est jeter les dés : n'importe quel jet et le + # meilleur coup, l'analyse du videau portant sur la position avant. + gnubg.command("set dice 2 1") + gnubg.command("move " + best_move()) + records, stats = analyse_game() + record = last_record(records, "move") + analysed = "nd-cubeful-eq" in record["analysis"] + skill = record["analysis"].get("cube-skill") + else: + # gnubg n'analyse un double qu'une fois la réponse donnée : après + # « double », l'adversaire prend, ce qui ne change rien à l'analyse + # du double. + gnubg.command("double") + gnubg.command("drop" if action == "pass" else "take") + records, stats = analyse_game() + analysed = "nd-cubeful-eq" in last_record(records, "double").get("analysis", {}) + record = last_record(records, {"double": "double", "take": "take", "pass": "drop"}[action]) + skill = record.get("analysis", {}).get("skill") + return { + "analysed": analysed, + "cube": decision if analysed else None, + "equity_loss": f32(-stats[record["player"]]["cube"]["error-skill"]), + "skill": SKILLS[skill], + } + + +def level_move(case, name): + """Le coup que joue l'ordinateur de gnubg réglé à ce niveau.""" + start_game(case) + set_dice(case) + before = gnubg.board() + turn = gnubg.posinfo()["turn"] + plies, noise = LEVEL_SETTINGS[name] + prefix = "set player %d chequerplay evaluation " % turn + gnubg.command("set player %d gnubg" % turn) + gnubg.command(prefix + "plies %d" % plies) + gnubg.command(prefix + "noise %g" % noise) + gnubg.command(prefix + "deterministic on") + gnubg.command(prefix + "cubeful on") + gnubg.command(prefix + "prune " + ("on" if plies > 0 else "off")) + gnubg.command("play") + record = last_record(gnubg.match(analysis=0, boards=1)["games"][-1]["game"], "move") + gnubg.command("set player %d human" % turn) + return {"move": gnubg.movetupletostring(record["move"], before)} + + +def level_cube(case, name): + """Décision de videau de gnubg avec les réglages de ce niveau.""" + start_game(case) + r = gnubg.cfevaluate(gnubg.board(), cube_info(), level_context(name)) + return { + "no_double": f32(r[1]), + "double_take": f32(r[2]), + "double_pass": f32(r[3]), + "optimal": f32(r[0]), + "decision": CUBE_DECISIONS[r[4]], + "decision_text": r[5], + } + + +def write_analysis(out_dir, result): + with open(os.path.join(out_dir, result["id"] + ".json"), "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False, indent=2) + f.write("\n") + print("analyse écrite :", result["id"]) + + +def generate_analyses(commit): + with open(os.path.join(FOLDER, "analyses.json"), encoding="utf-8") as f: + cases = json.load(f) + out_dir = os.path.join(FOLDER, "analysis") + os.makedirs(out_dir, exist_ok=True) + # Pas d'affichage du plateau ; pas de nouvelle partie ni de jet de dés + # automatiques, qui ajouteraient des enregistrements après le dernier. + gnubg.command("set display off") + gnubg.command("set automatic game off") + gnubg.command("set automatic roll off") + for kind in ("moves", "cubes", "levels"): + for case in cases[kind]: + result = dict(case, type=kind[:-1], gnubg_commit=commit, met=MET) + if kind == "moves": + result["plies"] = {} + for ply in PLIES: + result["plies"][str(ply)], result["luck"] = analyse_move(case, ply) + elif kind == "cubes": + result["plies"] = {str(ply): analyse_cube(case, ply) for ply in PLIES} + else: + result["levels"] = {} + for name in CHECKED_LEVELS: + plies, noise = LEVEL_SETTINGS[name] + level = {"plies": plies, "noise": noise} + level.update(level_move(case, name) if case["dice"] else level_cube(case, name)) + result["levels"][name] = level + write_analysis(out_dir, result) + + def main(): with open(os.path.join(FOLDER, "positions.json"), encoding="utf-8") as f: positions = json.load(f)["positions"] @@ -195,6 +448,7 @@ def main(): json.dump(result, f, ensure_ascii=False, indent=2) f.write("\n") print("référence écrite :", pos["id"]) + generate_analyses(commit) main() From 7e958fab8e613ee897f0d801391b4d1fa4f4aedc Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 14:51:41 +0100 Subject: [PATCH 03/10] =?UTF-8?q?R=C3=A9f=C3=A9rences=20:=20format=20des?= =?UTF-8?q?=20analyses=20document=C3=A9=20et=20v=C3=A9rifi=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- tests/reference/README.md | 92 +++++++++++++++++++++++++++++++++++++-- tests/test_reference.sh | 47 ++++++++++++++++++++ 2 files changed, 136 insertions(+), 3 deletions(-) diff --git a/tests/reference/README.md b/tests/reference/README.md index 5676d31..31b7f88 100644 --- a/tests/reference/README.md +++ b/tests/reference/README.md @@ -11,14 +11,16 @@ d'un fichier de résultat. | --- | --- | | `positions.json` | Positions d'entrée, versionnées, écrites à la main | | `results/.json` | Résultats de gnubg, un fichier par position, générés | -| `generate.py` | Script exécuté dans gnubg qui produit `results/` | -| `compare.py` | Compare le moteur embarqué à `results/` (dernière section) | +| `analyses.json` | Analyses d'entrée : coups joués, décisions de videau, niveaux de jeu | +| `analysis/.json` | Résultats de l'analyse de partie de gnubg, générés (section « Analyses ») | +| `generate.py` | Script exécuté dans gnubg qui produit `results/` et `analysis/` | +| `compare.py` | Compare le moteur embarqué à `results/` et `analysis/` (dernière section) | Commandes : ``` make reference-image # construit l'image Docker du gnubg de référence -make reference # régénère results/ (une vingtaine de secondes) +make reference # régénère results/ et analysis/ (une quarantaine de secondes) make test # vérifie la cohérence (tests/test_reference.sh) et # compare le moteur embarqué (tests/test_engine_reference.sh) ``` @@ -222,6 +224,69 @@ Sortie de `gnubg.cfevaluate` (`GeneralCubeDecisionE` puis `TOOGOOD_PASS`, `REDOUBLE_TAKE`, `NODOUBLE_DEADCUBE`…) ; - `decision_text` : le libellé anglais correspondant de gnubg. +## Analyses : `analyses.json` et `analysis/.json` + +Ce que gnubg dit d'un coup joué, d'une décision de videau et d'un jet de dés +(sa commande « analyse game »), et le coup que joue son ordinateur à chaque +niveau. Pour chaque cas, `generate.py` rejoue dans gnubg une courte partie +entre deux joueurs humains : la position (`set gnubgid`), puis le coup ou la +décision, puis l'analyse. Rien n'est calculé dans le script : les valeurs +viennent des enregistrements de la partie analysée et des statistiques que +gnubg en tire. + +`analyses.json` a trois listes : + +- `moves` : `position_id`, `match_id`, `dice` et `played`, le coup joué en + notation de gnubg (`null` : le meilleur coup selon gnubg, pour les cas qui + ne servent qu'à la chance du jet) ; +- `cubes` : `position_id`, `match_id` (sans dés) et `action`, la décision : + `no_double` ou `double` pour le joueur au trait, `take` ou `pass` pour son + adversaire ; +- `levels` : `position_id`, `match_id` et `dice` (`null` : décision de + videau). + +Un fichier de résultat reprend le cas, avec `type` (`move`, `cube` ou +`level`), `gnubg_commit` et `met`, puis, aux niveaux d'analyse 0 et 2 plis +(clés de `plies`, réglages prédéfinis de gnubg sans bruit, filtre +« Normal ») : + +- **coup joué** : `played_move` et `played_position_id` (position après le + coup, vue de celui qui l'a joué), `move_count` (coups légaux), `rank` (place + du coup joué dans le classement de l'analyse, à partir de 1 ; `imove` de + gnubg plus 1), `played_equity`, `best_move`, `best_equity`, `equity_loss` + (perte du coup joué, 0 ou négative : statistique « error-skill » de gnubg + changée de signe), `skill` (`none`, `doubtful`, `bad`, `very_bad`) et + `ranking` (les dix premiers coups, et jusqu'au coup joué) ; plus, une fois + pour toutes, `luck` : `value` (chance du jet) et `rating` + (`very_unlucky`, `unlucky`, `none`, `lucky`, `very_lucky`) ; +- **videau** : `analysed` (gnubg analyse-t-il cette décision ? pas à la + partie Crawford ni quand l'adversaire possède le videau), `cube` (la + décision de videau de gnubg au même niveau, format de la section + précédente, `null` si non analysée), `equity_loss` (perte de celui qui + décide, statistique « error-skill » changée de signe) et `skill` ; +- **niveaux** (`levels`, à la place de `plies`) : pour les niveaux + `beginner`, `casual`, `intermediate`, `advanced`, `0ply`, `1ply` et `2ply` + (3 et 4 plis sont trop lents pour la suite de tests), `plies` et `noise` + (réglages de gnubg), puis `move`, le coup que joue l'ordinateur de gnubg + réglé à ce niveau (commande « play »), ou la décision de videau de gnubg + avec ces réglages, bruit compris. + +Deux précautions, faute de quoi gnubg donne des valeurs qui dépendent de ce +qu'il a calculé avant : + +- **le cache d'évaluation est vidé avant chaque analyse**, et l'analyse du + videau est désactivée pour les coups joués. Une évaluation à 2 plis avec + élagage range dans le cache des évaluations à 0 pli faites avec les réseaux + d'élagage, sous la même clé que les évaluations à 0 pli ordinaires (la clé + d'`EvalKey`, dans `eval.c`, n'inclut l'élagage qu'à partir d'1 pli). Une + analyse à 0 pli, ou l'analyse de la chance qui suit l'analyse du videau à + 2 plis, les reprend : écarts observés de 5e-3 à 2,5e-2. Le moteur embarqué + a le même cache ; `compare.py` l'interroge par un processus neuf à chaque + commande ; +- **le coup choisi pour un cas sans `played` vient de `findbestmove`** et non + de « hint », dont gnubg garde l'analyse dans l'enregistrement du coup : + « analyse game » la reprendrait au lieu de calculer au niveau demandé. + ## Comparaison avec le moteur embarqué `make test` compare le moteur embarqué à ces résultats @@ -264,6 +329,22 @@ Pour chaque position et chaque niveau : - même décision de videau (`decision` et `decision_text`), et les quatre équités du videau à la tolérance près. +Pour chaque analyse (`engine-cli analysemove`, `analysecube`, `luck`, et +`--level NIVEAU bestmove` ou `cube`) : + +- coup joué : même nombre de coups légaux, même notation du coup joué, même + rang sauf égalité (les coups entre les deux rangs ont pour gnubg la même + équité que le coup joué, à la tolérance près : remarque), même meilleur coup + sauf égalité, et `played_equity`, `best_equity`, `equity_loss` à la + tolérance près ; +- chance : `value` à la tolérance près ; +- videau : même `analysed`, même décision et équités à la tolérance près, + `equity_loss` à la tolérance près ; +- appréciation (`skill`) et chance (`rating`) identiques, sauf si la valeur + de gnubg est à moins de la tolérance d'un seuil (remarque) ; +- niveaux : mêmes réglages que gnubg (`engine-cli levels`), même coup choisi, + ou même décision de videau et équités à la tolérance près. + Le rapport donne, pour chaque écart hors critères, la position et le niveau, le champ, la valeur attendue (gnubg), la valeur obtenue (moteur) et l'écart ; il donne toujours l'écart maximal observé. @@ -309,6 +390,11 @@ qu'affiche gnubg (trois décimales). Les vrais défauts (mauvais niveau, mauvais contexte de videau, mauvaise conversion d'équité) donnent des écarts d'un ou plusieurs centièmes. +Mesure du 2026-11-03, avec les 37 analyses (2 890 nombres comparés, natif et +WebAssembly) : écart maximal 1,64e-3, sur `move-jacoby` à 2 plis (équité du +coup joué `8/2 5/1*`, et donc sa perte), même phénomène de bascule à 2 plis ; tous les autres +nombres des analyses restent sous 1e-3. La tolérance n'a pas été changée. + Sur téléphone (NEON) les écarts seront à mesurer de la même façon : avec `-ffast-math`, gnubg y remplace aussi la division par une approximation (`vrecpeq_f32` et une itération de Newton), différente de celle de SSE2. diff --git a/tests/test_reference.sh b/tests/test_reference.sh index dd5bfed..12bb5db 100644 --- a/tests/test_reference.sh +++ b/tests/test_reference.sh @@ -117,4 +117,51 @@ for f in results/*.json; do printf '%s\n' "${ids[@]}" | grep -qxF "$id" || signaler "$f : position absente de positions.json" done +# Analyses (README.md, section « Analyses ») : un résultat par cas, et les +# champs du format. +jq empty analyses.json || { echo "analyses.json : JSON invalide"; exit 1; } +read -r -d '' controle_analyse <<'JQ' || true +def nombre: type == "number"; +def skill: IN("none", "doubtful", "bad", "very_bad"); +if .type == "move" then + (if (.plies | keys) == ["0", "2"] and (.luck.value | nombre) + and (.luck.rating | IN("very_unlucky", "unlucky", "none", "lucky", "very_lucky")) + and all(.plies[]; (.move_count | nombre) and (.rank | nombre) and (.played_move | type) == "string" + and (.played_position_id | test("^[A-Za-z0-9+/]{14}$")) and (.played_equity | nombre) + and (.best_move | type) == "string" and (.best_equity | nombre) + and (.equity_loss | nombre) and .equity_loss <= 0 and (.skill | skill) + and (.ranking | length) >= ([.rank, ([.move_count, 10] | min)] | max)) + then empty else "analyse de coup incomplète" end) +elif .type == "cube" then + (if (.plies | keys) == ["0", "2"] and (.action | IN("no_double", "double", "take", "pass")) + and all(.plies[]; (.analysed | type) == "boolean" and (.equity_loss | nombre) + and .equity_loss <= 0 and (.skill | skill) + and (if .analysed then (.cube.decision | type) == "string" else .cube == null end)) + then empty else "analyse de videau incomplète" end) +elif .type == "level" then + (if (.levels | length) > 0 and all(.levels[]; (.plies | nombre) and (.noise | nombre) + and ((.move | type) == "string" or (.decision | type) == "string")) + then empty else "niveaux incomplets" end) +else "type inconnu" end +JQ + +mapfile -t analyses < <(jq -r '(.moves + .cubes + .levels)[].id' analyses.json) +doublons="$(printf '%s\n' "${analyses[@]}" "${ids[@]}" | sort | uniq -d | paste -sd' ' -)" +[ -z "$doublons" ] || signaler "analyses.json : identifiants en double : $doublons" +for id in "${analyses[@]}"; do + f="analysis/$id.json" + if [ ! -f "$f" ]; then + signaler "$f absent : lancer make reference" + continue + fi + while read -r defaut; do + [ -n "$defaut" ] && signaler "$f : $defaut" + done < <(jq -r "$controle_analyse" "$f") +done +for f in analysis/*.json; do + [ -e "$f" ] || continue + printf '%s\n' "${analyses[@]}" | grep -qxF "$(basename "$f" .json)" \ + || signaler "$f : cas absent d'analyses.json" +done + [ "$erreurs" -eq 0 ] From 2f6eb86972e2db6fac6017f8af22482521b939eb Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 14:52:08 +0100 Subject: [PATCH 04/10] Tests du moteur : niveaux, analyses et chance Co-Authored-By: Claude Opus 5.5 --- tests/test_engine.sh | 47 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/tests/test_engine.sh b/tests/test_engine.sh index d733699..d13b4d2 100644 --- a/tests/test_engine.sh +++ b/tests/test_engine.sh @@ -2,7 +2,8 @@ # Tests du moteur embarqué, via build/engine-cli (étape 0a.3) : # meilleurs coups d'ouverture connus, nombre de coups légaux, bornes et # symétrie des probabilités, décision de videau évidente, identifiants -# invalides. La comparaison systématique au gnubg de bureau est faite par +# invalides, niveaux de jeu (bruit reproductible), analyses et chance. La +# comparaison systématique au gnubg de bureau est faite par # tests/test_engine_reference.sh. # # Lance `make engine` (sans effet si tout est à jour). @@ -219,6 +220,50 @@ grep -qF '"available": true, "double": true, "take": false' <<<"$($cli cube "$co grep -qF '"available": false, "double": false' <<<"$($cli cube "$course" "$(valeur match_id "$($cli matchid 2 1)")")" \ || echouer "videau chez l'adversaire : double indisponible attendu" +# Niveaux de jeu : les neuf réglages de gnubg, seuils exposés. +sortie="$($cli levels)" +[ "$(grep -o '"name"' <<<"$sortie" | wc -l)" = 9 ] || echouer "levels : 9 niveaux attendus" +grep -qF '"name": "beginner", "plies": 0, "noise": 0.0599999987' <<<"$sortie" \ + || echouer "levels : réglage débutant inattendu" +grep -qF '"skill_thresholds": {"doubtful": 0.0299999993, "bad": 0.0599999987, "very_bad": 0.119999997}' \ + <<<"$sortie" || echouer "levels : seuils d'appréciation inattendus" +# Le bruit est déterministe : le même coup à chaque appel, et le bruit change +# le coup de cette position (vérifié contre gnubg par analysis/level-*.json). +milieu=mM/gATCw5+ABMA +milieu_mid=cAkTAAAAAAAA +for essai in 1 2 3; do + coup="$(valeur move "$($cli --level beginner bestmove "$milieu" "$milieu_mid" 64)")" + [ "$coup" = "24/20 24/18" ] || echouer "niveau débutant, essai $essai : « $coup »" +done +coup="$(valeur move "$($cli --level 0ply bestmove "$milieu" "$milieu_mid" 64)")" +[ "$coup" = "24/14" ] || echouer "niveau 0 pli : « $coup » au lieu de 24/14" +grep -qF '"level": "advanced"' <<<"$($cli --level advanced cube "$depart" "$libre_j0")" \ + || echouer "cube au niveau avancé : niveau absent de la réponse" +attendre_erreur usage --level champion bestmove "$depart" "$libre_j0" 31 +attendre_erreur usage --level beginner --ply 2 bestmove "$depart" "$libre_j0" 31 +attendre_erreur usage --level beginner eval "$depart" "$libre_j0" + +# Analyse d'un coup : le meilleur coup n'est pas une erreur ; un coup illégal +# est refusé ; sans coup possible, rien à analyser. +sortie="$($cli analysemove "$depart" "$libre_j0" 31 4HPwATCwZ/ABMA)" +[ "$(valeur rank "$sortie") $(valeur skill "$sortie")" = "1 none" ] \ + || echouer "analysemove 8/5 6/5 : rang 1 sans erreur attendus ($sortie)" +attendre_erreur invalid_move analysemove "$depart" "$libre_j0" 31 "$depart" +grep -qF '"move_count": 0, "rank": null' <<<"$($cli analysemove 27YBBwAcz8EDQA cIkWAAAAAAAA 55 27YBBwAcz8EDQA)" \ + || echouer "analysemove sans coup possible : move_count 0 attendu" + +# Analyse du videau : décision inconnue refusée ; pas d'analyse quand le +# joueur au trait ne peut pas doubler. +attendre_erreur usage analysecube "$depart" "$libre_j0" redouble +grep -qF '"analysed": false, "cube": null, "equity_loss": 0, "skill": "none"' \ + <<<"$($cli analysecube "$course" "$(valeur match_id "$($cli matchid 2 1)")" no_double)" \ + || echouer "analysecube, videau chez l'adversaire : pas d'analyse attendue" + +# Chance : même jet, même chance ; ordre des dés indifférent. +[ "$($cli luck "$milieu" "$milieu_mid" 64 | sed 's/"dice": \[[0-9], [0-9]\]//')" \ + = "$($cli luck "$milieu" "$milieu_mid" 46 | sed 's/"dice": \[[0-9], [0-9]\]//')" ] \ + || echouer "luck : l'ordre des dés change la chance" + if [ "$echecs" -gt 0 ]; then echo " $echecs vérification(s) du moteur en échec." exit 1 From f24315fb98914dd27a44778620723452999a993b Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 14:58:25 +0100 Subject: [PATCH 05/10] =?UTF-8?q?Charte=20:=20accueil,=20installation,=20t?= =?UTF-8?q?utoriel=20des=20r=C3=A8gles,=20aide=20en=20questions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/identite/charte/README.md | 1 + docs/identite/charte/accueil-tutoriel-aide.md | 153 ++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 docs/identite/charte/accueil-tutoriel-aide.md diff --git a/docs/identite/charte/README.md b/docs/identite/charte/README.md index 8a5be9a..a4048ce 100644 --- a/docs/identite/charte/README.md +++ b/docs/identite/charte/README.md @@ -14,6 +14,7 @@ Décisions : ADR 0010. Fichiers du logo et des icônes : `../logo/`. | Fichier | Contenu | | --- | --- | | `guide-de-marque.md` | Règles d'usage : ton, couleurs, typographie, formes, mouvement, icônes, composants | +| `accueil-tutoriel-aide.md` | Accueil, installation sans intrusion, tutoriel des règles (forme et première version du texte), aide en questions | | `recette-ux.md` | Critères de recette design de l'écran de jeu, à cocher sur les captures et sur le téléphone | | `interaction.md` | Comportement : réponse immédiate, annuler plutôt que confirmer, mouvement, vibrations, clavier, mises en page de l'écran de jeu (portrait, paysage, ordinateur), pictogrammes | | `tokens.json` | Toutes les valeurs, thèmes clair et sombre, avec une note d'usage par couleur | diff --git a/docs/identite/charte/accueil-tutoriel-aide.md b/docs/identite/charte/accueil-tutoriel-aide.md new file mode 100644 index 0000000..2d7f99c --- /dev/null +++ b/docs/identite/charte/accueil-tutoriel-aide.md @@ -0,0 +1,153 @@ +# Accueil, installation, tutoriel et aide + +Règles de l'étape 0e.5 (et 1g pour le tutoriel). Maquettes : rangée « Étape +0e.5 » du canevas https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe et +l'artboard « Accueil ». Les textes sont une **première version, à valider par +Pierre**. Comportement général : `interaction.md`. + +## Accueil + +**Premier lancement** (aucune partie ni leçon enregistrée) : le logo, le +plateau en position de départ, « Bienvenue à la table », une phrase, puis deux +grands choix : + +- **« Je découvre »** (action principale) → le tutoriel, étape 1 ; +- **« Je connais les règles »** → une partie tout de suite. + +Le choix ne revient plus. Le tutoriel reste dans le menu. + +**Ensuite**, de haut en bas : + +1. **La partie en cours** : miniature du plateau, comptes de points, + « Reprendre la partie » (seule action principale de l'écran) et + « Nouvelle partie ». Sans partie en cours : « Nouvelle partie » devient + l'action principale. +2. **L'invitation à installer**, si elle doit apparaître (voir plus bas). +3. **La prochaine leçon** : une seule carte (titre, état, progression, + « Continuer » en secondaire), avec le lien « Toutes les leçons ». +4. **La progression résumée**, en une ligne `caption` : « 1 leçon terminée + sur 8 · 12 parties jouées · 4 gagnées ». +5. En pied de page, la phrase de l'Atelier du Huit. + +Pas de chiffres d'erreur ou d'équité sur l'accueil : ils viennent avec +l'analyse. + +## Installation + +Le but : proposer l'installation au bon moment, une fois, sans jamais +interrompre. + +- **Jamais** au premier lancement, jamais pendant une partie, jamais en + fenêtre qui s'impose. +- **Quand** : à partir de la fin de la **première partie terminée**, quand le + joueur a vu ce que vaut l'app. +- **Où** : une carte compacte sur l'accueil (symbole, « Installer + Ustagammon », « Sur l'écran d'accueil, en plein écran, sans réseau. », + bouton « Installer » et croix « Plus tard »), et une ligne « Installer + l'application » dans le menu, toujours présente tant que l'app n'est pas + installée. +- **« Plus tard »** masque la carte ; elle revient une seule fois, après + trois parties de plus. Ensuite, seul le menu la propose. +- **Chrome Android** : le bouton déclenche l'invite du système, gardée depuis + l'événement `beforeinstallprompt`. Sans cet événement (navigateur qui ne + propose pas l'installation), le même bouton ouvre la marche à suivre. +- **Firefox Android** : une feuille en trois étapes numérotées (menu ⋮, + « Installer » ou « Ajouter à l'écran d'accueil », confirmer), qui se ferme + par « J'ai compris ». +- **iPhone (Safari)** : même feuille, avec ces étapes : bouton de partage, + « Sur l'écran d'accueil », « Ajouter ». +- **Une fois installée** (`display-mode: standalone`, ou événement + `appinstalled`) : plus aucune proposition, la ligne du menu disparaît. + +## Tutoriel des règles + +### Forme + +- Huit étapes, chacune en **écrans courts** : un titre, un plateau illustré, + deux ou trois phrases. Une étape sur deux finit par un écran **« à vous »** + où le joueur fait le geste sur le vrai plateau, contrôlé par le moteur. +- En haut : une barre de progression en huit segments (`accent` pour les + étapes faites) et « Passer » à droite. En bas : « Retour » et + « Continuer ». +- Les positions sont données par leurs identifiants gnubg (ADR 0013) ; le + plateau est le composant de l'app, non jouable sauf sur les écrans + « à vous ». Aucune règle n'est codée hors du moteur : un écran « à vous » + demande au moteur si le coup du joueur fait partie des coups permis attendus. +- **Les pions bougent** : sur un écran d'explication, le coup illustré se joue + tout seul après une seconde, pion par pion (`duration-fast`), puis reste + affiché ; « Revoir » le rejoue. Sur les écrans à flèches (sens du jeu), un + tracé en pointillés `text` sur un liseré ivoire, jamais en brique (la brique + veut dire « à faire »). +- Un geste juste : coche `verdict-good`, vibration « pion posé », phrase + courte, « Continuer » devient l'action principale. Un geste faux : le pion + revient, et une phrase dit pourquoi, sans le mot « erreur ». +- Chaque terme nouveau est un lien vers sa fiche du glossaire, à sa première + apparition. + +### Texte, première version + +1. **Le but du jeu.** « Vous avez quinze pions ivoire, l'ordinateur quinze + pions noyer. Chacun fait faire le tour du plateau à ses pions, puis les + sort. Le premier qui a sorti tous les siens gagne la partie. » +2. **Dans quel sens avancer.** « Vos pions ivoire font le tour du plateau dans + le sens des pointillés : ils partent d'en haut à droite, passent à gauche, + puis reviennent en bas jusqu'aux six dernières flèches, en bas à droite. + C'est là que vous les rassemblerez avant de les sortir. Les pions noyer de + l'ordinateur font le même chemin, en sens inverse. » +3. **Les dés et les doubles.** « Chaque dé déplace un pion d'autant de + flèches. Avec 5 et 2, vous jouez un pion de 5 et un pion de 2, ou le même + pion de 5 puis de 2. Un double se joue quatre fois : avec 4-4, vous avancez + quatre fois de 4. » *À vous* : jouer 3-1 en faisant le point 5 (8/5 6/5). +4. **Frapper un pion seul.** « Un pion seul sur une flèche s'appelle un blot. + Si vous vous posez dessus, vous le frappez : il part sur la barre et devra + refaire tout le chemin. Une flèche où l'adversaire a deux pions ou plus est + fermée : vous ne pouvez pas vous y poser. » *À vous* : frapper le blot du + point 7 avec 6-1. +5. **Rentrer depuis la barre.** « Votre pion frappé attend sur la barre, au + milieu. Avant de jouer quoi que ce soit d'autre, il doit rentrer dans le + camp adverse, en haut à droite. Chaque dé indique une flèche d'entrée : + avec 3 et 4, la 22 ou la 21. Si les deux flèches sont fermées, votre tour + passe. » +6. **Sortir ses pions.** « Quand vos quinze pions sont dans les six dernières + flèches, vous pouvez commencer à les sortir : un 6 sort un pion de la + flèche 6, un 1 un pion de la flèche 1. Touchez le pion, puis la sortie à + droite. » *À vous* : sortir deux pions avec 6-3. +7. **Gammon et backgammon.** « Une partie gagnée vaut 1 point. Si + l'adversaire n'a sorti aucun pion quand vous finissez, c'est un gammon : + 2 points. S'il lui reste en plus un pion sur la barre ou dans votre camp, + c'est un backgammon : 3 points. » +8. **Le videau.** « Le cube aux faces 2, 4, 8, 16, 32 et 64 sert à miser. + Avant de lancer vos dés, vous pouvez proposer de doubler la valeur de la + partie. L'ordinateur prend, et la partie continue en valant le double, ou il + passe, et vous gagnez tout de suite. Celui qui a pris garde le cube : lui + seul pourra redoubler. » *À vous* : décider de prendre ou passer, avec une + phrase du maître ensuite. + +Fin du tutoriel : « Vous connaissez les règles. Le reste s'apprend en jouant, +et le maître vous accompagne. » → « Jouer ma première partie ». + +## Aide + +L'aide n'est plus une copie brute des fichiers : c'est une page organisée en +questions, avec un ton. **La source reste unique** (ADR 0008) : les mêmes +fichiers servent d'aide et de tests. + +- **Trois onglets** : « Jouer » (les scénarios), « Les mots du jeu » (le + glossaire), « À propos ». Le tutoriel est proposé en tête de page. +- **« Jouer »** : un scénario = une question repliable (`details`), fermée par + défaut sauf celle qui correspond à l'écran d'où l'on vient. + - Le **titre du scénario** est la question, formulée comme le joueur se la + pose : « Comment jouer un coup ? », « Je me suis trompé de pion », « Je ne + peux rien jouer avec mes dés ». C'est un changement de titre dans les + `.feature` : les tests n'en dépendent pas. + - La **description du scénario** (le paragraphe sous le titre) est la + réponse affichée. Elle se lit seule, au présent, au « vous ». + - Les **étapes Gherkin** ne s'affichent pas dans l'aide : elles restent la + preuve que l'app fait ce que dit la réponse. « Voir sur le plateau » ouvre + la position de l'exemple quand le scénario en donne une (lien ADR 0013). + - La **description de la fonctionnalité** sert d'introduction à l'onglet. +- **« Les mots du jeu »** : une fiche du glossaire = une carte (le mot, son nom + anglais en `caption` italique, « En bref », les renvois), triées par ordre + alphabétique, avec un champ de recherche en tête. +- **Ton** : celui du maître de café, patient et concret. Pas de « Étant + donné que », pas de vocabulaire technique (moteur, scénario, événement). From 23f4d47fcf71cd6307043a5c03954c1482bc0fb7 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 14:59:07 +0100 Subject: [PATCH 06/10] =?UTF-8?q?Domaine=20et=20adaptateur=20:=20niveaux,?= =?UTF-8?q?=20analyses=20et=20chance=20;=20cache=20vid=C3=A9=20=C3=A0=20ch?= =?UTF-8?q?aque=20calcul?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- app/src/domain/game/engine.ts | 135 ++++++++++++++++++++++++++++++ app/src/engine/wasm-engine.ts | 134 ++++++++++++++++++++++++++++- app/tests/engine-analysis.test.ts | 126 ++++++++++++++++++++++++++++ app/tests/game.test.ts | 10 +++ docs/glossaire/README.md | 3 + docs/glossaire/appreciation.md | 14 ++++ docs/glossaire/chance.md | 15 ++++ docs/glossaire/niveau-de-jeu.md | 18 ++++ engine/engine.c | 25 +++++- 9 files changed, 476 insertions(+), 4 deletions(-) create mode 100644 app/tests/engine-analysis.test.ts create mode 100644 docs/glossaire/appreciation.md create mode 100644 docs/glossaire/chance.md create mode 100644 docs/glossaire/niveau-de-jeu.md diff --git a/app/src/domain/game/engine.ts b/app/src/domain/game/engine.ts index 12d4663..cee9049 100644 --- a/app/src/domain/game/engine.ts +++ b/app/src/domain/game/engine.ts @@ -38,6 +38,141 @@ export interface Engine { /** Le plateau d'une position, et le résultat si la partie y est finie. */ board(position: PositionId, cube: EngineCube): Promise; + + /** Le coup que joue l'ordinateur de gnubg à ce niveau (bruit compris, + * reproductible), ou null si aucun coup n'est possible. */ + levelMove( + position: PositionId, + cube: EngineCube, + roll: Roll, + level: Level, + ): Promise; + + /** La décision de videau de l'ordinateur à ce niveau, bruit compris. */ + levelCubeDecision(position: PositionId, cube: EngineCube, level: Level): Promise; + + /** L'analyse du coup joué (played : position après le coup, vue du joueur + * qui l'a joué, comme Move.position), au niveau d'analyse ply ; null si + * aucun coup n'était possible. */ + analyseMove( + position: PositionId, + cube: EngineCube, + roll: Roll, + played: PositionId, + ply: Ply, + ): Promise; + + /** L'analyse d'une décision de videau, position vue du joueur au trait + * (celui qui double ou non ; take et pass sont la réponse de son + * adversaire). */ + analyseCube( + position: PositionId, + cube: EngineCube, + action: CubeAction, + ply: Ply, + ): Promise; + + /** La chance d'un jet pour le joueur au trait. */ + luck(position: PositionId, cube: EngineCube, roll: Roll): Promise; +} + +/** + * Niveau de jeu de l'ordinateur : un réglage prédéfini de GNU Backgammon + * (aecSettings dans eval.c ; voir engine/include/engine.h). Les quatre + * premiers jouent à 0 pli avec un bruit qui leur fait commettre des erreurs ; + * les suivants sont sans bruit, de 0 à 4 plis. + * + * Glossaire : docs/glossaire/niveau-de-jeu.md + */ +export type Level = + | "beginner" + | "casual" + | "intermediate" + | "advanced" + | "0ply" + | "1ply" + | "2ply" + | "3ply" + | "4ply"; + +/** + * Appréciation d'un coup ou d'une décision de videau par le moteur, selon + * l'équité perdue : rien à signaler, douteux, mauvais, très mauvais. + * + * Glossaire : docs/glossaire/appreciation.md + */ +export type Skill = "none" | "doubtful" | "bad" | "veryBad"; + +/** + * Seuils de GNU Backgammon (arSkillLevel dans gnubg.c) : une perte + * d'équité strictement plus grande fait passer à l'appréciation. Le moteur + * classe lui-même ; ces valeurs servent à l'expliquer. + */ +export const SKILL_THRESHOLDS = { doubtful: 0.03, bad: 0.06, veryBad: 0.12 } as const; + +/** Catégorie de chance d'un jet, de très malchanceux à très chanceux. */ +export type LuckRating = "veryUnlucky" | "unlucky" | "none" | "lucky" | "veryLucky"; + +/** + * Seuils de GNU Backgammon (arLuckLevel dans gnubg.c) sur la chance d'un + * jet, dans un sens ou dans l'autre. + */ +export const LUCK_THRESHOLDS = { lucky: 0.3, veryLucky: 0.6 } as const; + +/** + * La chance d'un jet : l'équité qu'il fait gagner (positive) ou perdre au + * joueur par rapport à la moyenne des jets. + * + * Glossaire : docs/glossaire/chance.md + */ +export interface Luck { + readonly value: number; + readonly rating: LuckRating; +} + +/** Un coup avec son équité (avec videau), du point de vue de celui qui le joue. */ +export interface EvaluatedMove extends Move { + readonly equity: number; + /** Niveau auquel le coup a réellement été évalué. */ + readonly ply: Ply; +} + +/** Ce que le moteur dit d'un coup joué, comme l'analyse de partie de gnubg. */ +export interface MoveAnalysis { + /** Nombre de coups légaux. */ + readonly moveCount: number; + /** Place du coup joué dans le classement, à partir de 1. */ + readonly rank: number; + readonly played: EvaluatedMove; + readonly best: EvaluatedMove; + /** Équité du coup joué moins celle du meilleur : 0 ou négative. */ + readonly equityLoss: number; + readonly skill: Skill; +} + +/** Décision de videau : doubler ou non (joueur au trait), prendre ou passer + * (son adversaire). */ +export type CubeAction = "noDouble" | "double" | "take" | "pass"; + +/** Ce que le moteur dit d'une décision de videau. */ +export interface CubeAnalysis { + /** gnubg analyse-t-il cette décision ? Pas quand le joueur au trait ne + * peut pas doubler, ni à la position de départ. */ + readonly analysed: boolean; + /** Équités du joueur au trait, normalisées pour un videau de 1 (null si + * non analysée). */ + readonly equities: CubeEquities | null; + /** Équité perdue par celui qui décide : 0 ou négative. */ + readonly equityLoss: number; + readonly skill: Skill; +} + +export interface CubeEquities { + readonly noDouble: number; + readonly doubleTake: number; + readonly doublePass: number; + /** Après les meilleures décisions des deux joueurs. */ + readonly optimal: number; } /** diff --git a/app/src/engine/wasm-engine.ts b/app/src/engine/wasm-engine.ts index 019ccca..ef12813 100644 --- a/app/src/engine/wasm-engine.ts +++ b/app/src/engine/wasm-engine.ts @@ -12,12 +12,20 @@ import type { Board, BoardResult, BoardSide, + CubeAction, + CubeAnalysis, CubeDecision, Engine, EngineCube, + EvaluatedMove, HalfMovesCheck, + Level, + Luck, + LuckRating, Move, + MoveAnalysis, Ply, + Skill, } from "../domain/game/engine.js"; import { formatHalfMoves, type HalfMove, type PositionId } from "../domain/game/position.js"; import type { Roll } from "../domain/game/roll.js"; @@ -32,6 +40,16 @@ interface CliMove { position_id: string; half_moves: [number, number][]; } +interface CliEvaluatedMove extends CliMove { + ply: number; + equity: number; +} +type CliSkill = "none" | "doubtful" | "bad" | "very_bad"; +interface CliCubeDecision { + available: boolean; + double: boolean; + take: boolean; +} interface CliSide { points: number[]; bar: number; @@ -86,8 +104,108 @@ export function createWasmEngine(run: RunCommand): Engine { 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 }; + return toCubeDecision((await run(args)) as CliCubeDecision); + }, + + async levelMove(position: PositionId, cube: EngineCube, roll: Roll, level: Level) { + const args = ["--level", level, "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 levelCubeDecision( + position: PositionId, + cube: EngineCube, + level: Level, + ): Promise { + const args = ["--level", level, "cube", position, await matchId(cube)]; + return toCubeDecision((await run(args)) as CliCubeDecision); + }, + + async analyseMove( + position: PositionId, + cube: EngineCube, + roll: Roll, + played: PositionId, + ply: Ply, + ): Promise { + const args = ["--ply", String(ply), "analysemove", position, await matchId(cube)]; + const answer = (await run([...args, diceText(roll), played])) as { + move_count: number; + rank: number | null; + played: CliEvaluatedMove | null; + best: CliEvaluatedMove | null; + equity_loss: number; + skill: CliSkill; + }; + if (answer.rank === null || answer.played === null || answer.best === null) { + return null; + } + return { + moveCount: answer.move_count, + rank: answer.rank, + played: toEvaluatedMove(answer.played), + best: toEvaluatedMove(answer.best), + equityLoss: answer.equity_loss, + skill: toSkill(answer.skill), + }; + }, + + async analyseCube( + position: PositionId, + cube: EngineCube, + action: CubeAction, + ply: Ply, + ): Promise { + const cliAction = { + noDouble: "no_double", + double: "double", + take: "take", + pass: "pass", + }[action]; + const args = ["--ply", String(ply), "analysecube", position, await matchId(cube)]; + const answer = (await run([...args, cliAction])) as { + analysed: boolean; + cube: { + no_double: number; + double_take: number; + double_pass: number; + optimal: number; + } | null; + equity_loss: number; + skill: CliSkill; + }; + return { + analysed: answer.analysed, + equities: + answer.cube === null + ? null + : { + noDouble: answer.cube.no_double, + doubleTake: answer.cube.double_take, + doublePass: answer.cube.double_pass, + optimal: answer.cube.optimal, + }, + equityLoss: answer.equity_loss, + skill: toSkill(answer.skill), + }; + }, + + async luck(position: PositionId, cube: EngineCube, roll: Roll): Promise { + const args = ["luck", position, await matchId(cube), diceText(roll)]; + const answer = (await run(args)) as { luck: number; rating: string }; + const ratings: Record = { + very_unlucky: "veryUnlucky", + unlucky: "unlucky", + none: "none", + lucky: "lucky", + very_lucky: "veryLucky", + }; + const rating = ratings[answer.rating]; + if (rating === undefined) { + throw new Error(`chance inconnue : ${answer.rating}`); + } + return { value: answer.luck, rating }; }, async checkHalfMoves( @@ -139,6 +257,18 @@ function toMove(move: CliMove): Move { }; } +function toEvaluatedMove(move: CliEvaluatedMove): EvaluatedMove { + return { ...toMove(move), equity: move.equity, ply: move.ply }; +} + +function toCubeDecision(answer: CliCubeDecision): CubeDecision { + return { available: answer.available, double: answer.double, take: answer.take }; +} + +function toSkill(skill: CliSkill): Skill { + return skill === "very_bad" ? "veryBad" : skill; +} + function toSide(side: CliSide): BoardSide { return { points: side.points, bar: side.bar, off: side.off, pipCount: side.pip_count }; } diff --git a/app/tests/engine-analysis.test.ts b/app/tests/engine-analysis.test.ts new file mode 100644 index 0000000..ddb1241 --- /dev/null +++ b/app/tests/engine-analysis.test.ts @@ -0,0 +1,126 @@ +// L'adaptateur du moteur WebAssembly pour les niveaux, l'analyse et la +// chance (étape 1a), sur le vrai moteur. La concordance des nombres avec +// gnubg de bureau est vérifiée par tests/test_wasm_reference.sh ; ici, la +// traduction des réponses en types du domaine. +// +// 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 { fileURLToPath } from "node:url"; +import { type EngineCube, LUCK_THRESHOLDS, SKILL_THRESHOLDS } from "../src/domain/game/engine.js"; +import { START_POSITION } from "../src/domain/game/position.js"; +import { loadNodeEngine } from "./support/node-engine.js"; + +const centered: EngineCube = { value: 1, owner: "centered" }; +const middlegame = "mM/gATCw5+ABMA"; + +test("ouverture 3-1 : 24/23 13/10 est une erreur très mauvaise, 8/5 6/5 le meilleur coup", async () => { + const engine = await loadNodeEngine(); + const analysis = await engine.analyseMove( + START_POSITION, + centered, + [3, 1], + "4HPwATDgc+IBKA", + 2, + ); + assert.ok(analysis !== null); + assert.equal(analysis.moveCount, 16); + assert.equal(analysis.rank, 2); + assert.equal(analysis.played.notation, "24/23 13/10"); + assert.equal(analysis.best.notation, "8/5 6/5"); + assert.equal(analysis.skill, "veryBad"); + assert.ok( + Math.abs(analysis.equityLoss - (analysis.played.equity - analysis.best.equity)) < 1e-6, + ); + assert.ok(analysis.equityLoss < -SKILL_THRESHOLDS.veryBad); +}); + +test("sans coup possible, il n'y a rien à analyser", async () => { + const engine = await loadNodeEngine(); + const position = "27YBBwAcz8EDQA"; + assert.equal(await engine.analyseMove(position, centered, [5, 5], position, 0), null); +}); + +test("double/prend : passer est une erreur de l'adversaire, prendre n'en est pas une", async () => { + const engine = await loadNodeEngine(); + const position = "6O5uAAC4uzsAAA"; + const pass = await engine.analyseCube(position, centered, "pass", 2); + assert.equal(pass.analysed, true); + assert.equal(pass.skill, "bad"); + assert.ok(pass.equities !== null && pass.equities.doubleTake < pass.equities.doublePass); + const take = await engine.analyseCube(position, centered, "take", 2); + assert.deepEqual([take.skill, take.equityLoss], ["none", 0]); + const opponentOwns = await engine.analyseCube( + position, + { value: 2, owner: "opponent" }, + "noDouble", + 2, + ); + assert.deepEqual(opponentOwns, { + analysed: false, + equities: null, + equityLoss: 0, + skill: "none", + }); +}); + +test("la chance d'un jet et sa catégorie", async () => { + const engine = await loadNodeEngine(); + const opening = await engine.luck(START_POSITION, centered, [3, 1]); + assert.ok(Math.abs(opening.value - 0.2126) < 2e-3); + assert.equal(opening.rating, "none"); + const doubleSix = await engine.luck(START_POSITION, centered, [6, 6]); + assert.ok(doubleSix.value > LUCK_THRESHOLDS.lucky); + assert.equal(doubleSix.rating, "lucky"); +}); + +// Régression : sans cache vidé à chaque appel (engine.c, start_evaluation), +// une analyse à 2 plis changeait la chance calculée ensuite (évaluations à +// 0 pli des réseaux d'élagage restées dans le cache de gnubg). +test("un résultat ne dépend pas des calculs faits avant", async () => { + const engine = await loadNodeEngine(); + const before = await engine.luck(middlegame, centered, [6, 4]); + await engine.analyseCube(middlegame, centered, "noDouble", 2); + await engine.analyseMove(middlegame, centered, [6, 4], "mM/gATDGzuABMA", 2); + assert.deepEqual(await engine.luck(middlegame, centered, [6, 4]), before); +}); + +test("le niveau débutant joue toujours le même coup, différent du meilleur", async () => { + const engine = await loadNodeEngine(); + for (let i = 0; i < 3; i++) { + const move = await engine.levelMove(middlegame, centered, [6, 4], "beginner"); + assert.equal(move?.notation, "24/20 24/18"); + } + assert.equal((await engine.levelMove(middlegame, centered, [6, 4], "2ply"))?.notation, "24/14"); + const cube = await engine.levelCubeDecision("6O5uAAC4uzsAAA", centered, "beginner"); + assert.equal(cube.double, false); +}); + +test("les seuils du domaine sont ceux du moteur", async () => { + const root = new URL("../../../", import.meta.url); + 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 raw = await wrapper.loadEngine( + module.default, + fileURLToPath(new URL("build/wasm/", root)), + ); + const { result } = raw.run(["levels"]) as { + result: { + levels: { name: string }[]; + skill_thresholds: { doubtful: number; bad: number; very_bad: number }; + luck_thresholds: { lucky: number; very_lucky: number }; + }; + }; + const close = (a: number, b: number) => Math.abs(a - b) < 1e-6; + assert.ok(close(result.skill_thresholds.doubtful, SKILL_THRESHOLDS.doubtful)); + assert.ok(close(result.skill_thresholds.bad, SKILL_THRESHOLDS.bad)); + assert.ok(close(result.skill_thresholds.very_bad, SKILL_THRESHOLDS.veryBad)); + assert.ok(close(result.luck_thresholds.lucky, LUCK_THRESHOLDS.lucky)); + assert.ok(close(result.luck_thresholds.very_lucky, LUCK_THRESHOLDS.veryLucky)); + assert.deepEqual( + result.levels.map((l) => l.name), + ["beginner", "casual", "intermediate", "advanced", "0ply", "1ply", "2ply", "3ply", "4ply"], + ); +}); diff --git a/app/tests/game.test.ts b/app/tests/game.test.ts index c611f38..dfb1442 100644 --- a/app/tests/game.test.ts +++ b/app/tests/game.test.ts @@ -49,6 +49,16 @@ function fakeEngine(overrides: Partial = {}): Engine { return { valid: false, complete: false, position: null }; }, board: async () => notOver, + levelMove: async () => move, + levelCubeDecision: async () => noDouble, + analyseMove: async () => null, + analyseCube: async () => ({ + analysed: false, + equities: null, + equityLoss: 0, + skill: "none", + }), + luck: async () => ({ value: 0, rating: "none" }), ...overrides, }; } diff --git a/docs/glossaire/README.md b/docs/glossaire/README.md index d915c86..f5e6a05 100644 --- a/docs/glossaire/README.md +++ b/docs/glossaire/README.md @@ -35,10 +35,12 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec | Terme | Anglais (code) | | --- | --- | +| [Appréciation](appreciation.md) | Skill | | [Backgammon](backgammon.md) | Backgammon | | [Barre](barre.md) | Bar | | [Blot](blot.md) | Blot | | [Branche](branche.md) | Branch | +| [Chance](chance.md) | Luck | | [Compte de points](compte-de-points.md) | Pip count | | [Coup](coup.md) | Move | | [Demi-coup](demi-coup.md) | Half-move | @@ -52,6 +54,7 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec | [Jet](jet.md) | Roll | | [Match](match.md) | Match | | [Moteur](moteur.md) | Engine | +| [Niveau de jeu](niveau-de-jeu.md) | Level | | [Partie](partie.md) | Game | | [Passer](passer.md) | Drop | | [Pion](pion.md) | Checker | diff --git a/docs/glossaire/appreciation.md b/docs/glossaire/appreciation.md new file mode 100644 index 0000000..c362ae5 --- /dev/null +++ b/docs/glossaire/appreciation.md @@ -0,0 +1,14 @@ +--- +term: Appréciation +english: Skill +code: Skill +--- +# Appréciation + +**En bref** : le jugement du moteur sur un coup ou une décision de videau, d'après l'équité perdue par rapport au meilleur choix. + +GNU Backgammon distingue trois degrés, selon l'équité perdue : douteux (plus +de 0,03), mauvais (plus de 0,06) et très mauvais (plus de 0,12). En dessous, +il n'y a rien à signaler. + +Voir aussi : [Équité](equite.md), [Coup](coup.md), [Videau](videau.md) diff --git a/docs/glossaire/chance.md b/docs/glossaire/chance.md new file mode 100644 index 0000000..07c0378 --- /dev/null +++ b/docs/glossaire/chance.md @@ -0,0 +1,15 @@ +--- +term: Chance +english: Luck +code: Luck +--- +# Chance + +**En bref** : ce qu'un jet de dés fait gagner ou perdre, en équité, par rapport à la moyenne de tous les jets possibles. + +Un jet est dit chanceux s'il fait gagner plus de 0,3 d'équité par rapport à la +moyenne, très chanceux au-delà de 0,6 ; malchanceux ou très malchanceux dans +l'autre sens. Comparer la chance d'une partie à ses erreurs aide à savoir si +on a perdu à cause des dés ou de son jeu. + +Voir aussi : [Équité](equite.md), [Jet](jet.md) diff --git a/docs/glossaire/niveau-de-jeu.md b/docs/glossaire/niveau-de-jeu.md new file mode 100644 index 0000000..d5d533c --- /dev/null +++ b/docs/glossaire/niveau-de-jeu.md @@ -0,0 +1,18 @@ +--- +term: Niveau de jeu +english: Level +code: Level +--- +# Niveau de jeu + +**En bref** : la force de l'ordinateur, du débutant, qui commet volontairement des erreurs, au plus fort, qui réfléchit plusieurs plis à l'avance. + +Les niveaux sont ceux de GNU Backgammon. Les quatre premiers (débutant, +amateur, intermédiaire, avancé) jugent la position sans regarder les jets +suivants et ajoutent un « bruit » à leur jugement, de plus en plus faible : +c'est ce qui leur fait choisir parfois un moins bon coup. Ce bruit dépend de +la position seule : dans la même position, avec le même jet, l'ordinateur +joue toujours le même coup. Les niveaux suivants, sans bruit, réfléchissent +de 0 à 4 plis. + +Voir aussi : [Pli](pli.md), [Moteur](moteur.md) diff --git a/engine/engine.c b/engine/engine.c index 37445a4..01ed5f5 100644 --- a/engine/engine.c +++ b/engine/engine.c @@ -252,12 +252,31 @@ check_dice(int die1, int die2) return ENGINE_OK; } -/* Réglages prédéfinis de gnubg pour un niveau, sans bruit. */ +/* + * Vide le cache d'évaluation de gnubg avant chaque calcul : un résultat ne + * dépend ainsi que de ses arguments, comme les références (gnubg de bureau, + * « clear cache » avant chaque position). Sans cela, une recherche à 2 plis + * avec élagage laisse dans le cache des évaluations à 0 pli faites avec les + * réseaux d'élagage, rangées sous la même clé que les évaluations à 0 pli + * ordinaires (EvalKey, eval.c l. 2302 : l'élagage n'y compte qu'à partir + * d'1 pli) ; une chance de jet ou un coup à 0 pli calculé ensuite les + * reprendrait (écarts mesurés jusqu'à 2,5e-2, tests/reference/README.md). + * Coût mesuré : environ 3 ms par appel sous Node sur le PC. + */ +static void +start_evaluation(void) +{ + EvalCacheFlush(); +} + +/* Réglages prédéfinis de gnubg pour un niveau, sans bruit ; prépare le + * calcul. */ static engine_status eval_context_for_ply(int ply, evalcontext *context) { if (ply < 0 || ply > ENGINE_MAX_PLY) return ENGINE_ERROR_PLY; + start_evaluation(); *context = aecSettings[SETTINGS_0PLY + ply]; return ENGINE_OK; } @@ -273,12 +292,13 @@ static const char *const level_names[ENGINE_LEVEL_COUNT] = { "beginner", "casual", "intermediate", "advanced", "0ply", "1ply", "2ply", "3ply", "4ply" }; -/* Réglages d'évaluation d'un niveau (bruit compris). */ +/* Réglages d'évaluation d'un niveau (bruit compris) ; prépare le calcul. */ static engine_status eval_context_for_level(int level, evalcontext *context) { if (level < 0 || level >= ENGINE_LEVEL_COUNT) return ENGINE_ERROR_LEVEL; + start_evaluation(); *context = aecSettings[level]; return ENGINE_OK; } @@ -845,6 +865,7 @@ engine_luck_of(const char *position_id, const char *match_id, int die1, int die2 status = check_dice(die1, die2); if (status != ENGINE_OK) return status; + start_evaluation(); /* LuckAnalysis (analysis.c l. 252-268) : dés rangés du plus fort au plus * faible ; LuckFirst à la position de départ hors doubles. */ From 6b5e0d2b5e0e21adeddbdef29ae69879736f0c4f Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 15:00:39 +0100 Subject: [PATCH 07/10] =?UTF-8?q?Mesure=20:=20temps=20de=20l'analyse=20du?= =?UTF-8?q?=20coup=20jou=C3=A9=20=C3=A0=202=20plis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- engine-wasm/bench.mjs | 1 + web/engine-wasm.mjs | 2 ++ web/moteur/bench-page.mjs | 1 + web/moteur/benchmark.mjs | 21 +++++++++++++++++---- web/moteur/engine-client.mjs | 2 ++ 5 files changed, 23 insertions(+), 4 deletions(-) diff --git a/engine-wasm/bench.mjs b/engine-wasm/bench.mjs index f45b59c..0a0e875 100644 --- a/engine-wasm/bench.mjs +++ b/engine-wasm/bench.mjs @@ -35,6 +35,7 @@ const { positions } = JSON.parse(readFileSync(sitePath("wasm/positions.json"), " const times = await measureTimes({ bestMove: (...args) => engine.run(commands.bestMove(...args)), cubeDecision: (...args) => engine.run(commands.cubeDecision(...args)), + analyseMove: (...args) => engine.run(commands.analyseMove(...args)), }, positions, (done, total) => process.stderr.write("\r " + done + "/" + total + " calculs")); process.stderr.write("\n"); diff --git a/web/engine-wasm.mjs b/web/engine-wasm.mjs index 3430d27..7c66372 100644 --- a/web/engine-wasm.mjs +++ b/web/engine-wasm.mjs @@ -78,6 +78,8 @@ export const commands = { ["--ply", ply, "cube", positionId, matchId], evaluate: (positionId, matchId, ply) => ["--ply", ply, "eval", positionId, matchId], + analyseMove: (positionId, matchId, dice, playedPositionId, ply) => + ["--ply", ply, "analysemove", positionId, matchId, diceText(dice), playedPositionId], reference: (positionId, matchId, dice, ply) => ["--ply", ply, "reference", positionId, matchId, ...(dice ? [diceText(dice)] : [])], }; diff --git a/web/moteur/bench-page.mjs b/web/moteur/bench-page.mjs index f6a47c1..dcf5f4d 100644 --- a/web/moteur/bench-page.mjs +++ b/web/moteur/bench-page.mjs @@ -43,6 +43,7 @@ async function measure() { const times = await measureTimes({ bestMove: (...args) => engine.bestMove(...args).then(keepMemory), cubeDecision: (...args) => engine.cubeDecision(...args).then(keepMemory), + analyseMove: (...args) => engine.analyseMove(...args).then(keepMemory), }, positions, (done, total) => { $("status").textContent = "Calcul " + done + " sur " + total + "…"; }); diff --git a/web/moteur/benchmark.mjs b/web/moteur/benchmark.mjs index 2c7d15b..c8ea54f 100644 --- a/web/moteur/benchmark.mjs +++ b/web/moteur/benchmark.mjs @@ -15,6 +15,9 @@ export const TIME_THRESHOLDS = [ { key: "move0", label: "Meilleur coup, 0 pli", limitMs: 300 }, { key: "move2", label: "Meilleur coup, 2 plis", limitMs: 3000 }, { key: "cube2", label: "Décision de videau, 2 plis", limitMs: 3000 }, + // Étape 1a : l'analyse du coup joué, pour le conseil du maître, calculée + // en arrière-plan ; même repère que le meilleur coup (ADR 0009). + { key: "analysis2", label: "Analyse du coup joué, 2 plis", limitMs: 3000 }, ]; // Taille totale à télécharger, compressée : moins de 40 Mo. @@ -34,19 +37,29 @@ export const DOWNLOADED_FILES = [ "wasm/engine.data", ]; -// engine : objet avec bestMove(positionId, matchId, dice, ply) et -// cubeDecision(positionId, matchId, ply), qui renvoient (ou promettent) -// { result, ms }. +// engine : objet avec bestMove(positionId, matchId, dice, ply), +// cubeDecision(positionId, matchId, ply) et analyseMove(positionId, matchId, +// dice, playedPositionId, ply), qui renvoient (ou promettent) { result, ms }. +// Le coup analysé est le meilleur coup à 0 pli : celui qu'un joueur aurait +// pu jouer, analysé à 2 plis comme pour le conseil du maître. // positions : le tableau « positions » de positions.json. // onProgress(done, total) : appelé après chaque calcul. // Renvoie, pour chaque clé de TIME_THRESHOLDS, la liste des // { id, ms } mesurés. export async function measureTimes(engine, positions, onProgress = () => {}) { const tasks = []; + const played = new Map(); for (const p of positions) { if (p.type === "move") { - tasks.push(["move0", p, () => engine.bestMove(p.position_id, p.match_id, p.dice, 0)]); + tasks.push(["move0", p, async () => { + const answer = await engine.bestMove(p.position_id, p.match_id, p.dice, 0); + // Sans coup possible, n'importe quelle position fait l'affaire. + played.set(p.id, answer.result.best_move?.position_id ?? p.position_id); + return answer; + }]); tasks.push(["move2", p, () => engine.bestMove(p.position_id, p.match_id, p.dice, 2)]); + tasks.push(["analysis2", p, () => engine.analyseMove(p.position_id, p.match_id, p.dice, + played.get(p.id), 2)]); } else { tasks.push(["cube2", p, () => engine.cubeDecision(p.position_id, p.match_id, 2)]); } diff --git a/web/moteur/engine-client.mjs b/web/moteur/engine-client.mjs index f8ddfc5..b50b436 100644 --- a/web/moteur/engine-client.mjs +++ b/web/moteur/engine-client.mjs @@ -58,6 +58,8 @@ export function startEngine() { run(commands.bestMove(positionId, matchId, dice, ply)), cubeDecision: (positionId, matchId, ply) => run(commands.cubeDecision(positionId, matchId, ply)), + analyseMove: (positionId, matchId, dice, playedPositionId, ply) => + run(commands.analyseMove(positionId, matchId, dice, playedPositionId, ply)), evaluate: (positionId, matchId, ply) => run(commands.evaluate(positionId, matchId, ply)), reference: (positionId, matchId, dice, ply) => From c061b6d41bda13e053624ef65daa1672020c26c0 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 15:02:01 +0100 Subject: [PATCH 08/10] Plan 0e.5 : accueil, installation, tutoriel, aide Co-Authored-By: Claude Opus 5.5 --- docs/feuille-de-route.md | 2 +- .../plans/2026-11-03-accueil-tutoriel-aide.md | 59 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-11-03-accueil-tutoriel-aide.md diff --git a/docs/feuille-de-route.md b/docs/feuille-de-route.md index 88a9530..d19aa37 100644 --- a/docs/feuille-de-route.md +++ b/docs/feuille-de-route.md @@ -25,7 +25,7 @@ Demandes de Pierre du 2026-11-03, traitées avant d'ouvrir l'étape 1. | --- | --- | --- | --- | | 0e.3 | Révision UX arbitrée (session « identité », sur délégation de Pierre) : portrait stable, bandeaux de compte de points, menu en feuille et nouvelle partie annulable, vibrations, écran allumé, paysage « plateau = écran » dès la rotation (ADR 0012 amendé) | ⏳ | `plans/2026-11-03-revision-ux.md` | | 0e.4 | Position dans l'adresse : chaque position du plateau a un lien direct (ADR 0013) | ⏳ | `plans/2026-11-03-position-dans-l-adresse.md` | -| 0e.5 | Page d'accueil, installation en un toucher (« Ajouter à l'écran d'accueil », proposée par l'app), tutoriel des règles très didactique avec plateaux illustrés, aide réécrite sur un ton plus humain ; textes à valider par Pierre | ⏳ | | +| 0e.5 | Page d'accueil, installation en un toucher (« Ajouter à l'écran d'accueil », proposée par l'app), tutoriel des règles très didactique avec plateaux illustrés, aide réécrite sur un ton plus humain ; textes à valider par Pierre | ⏳ | `plans/2026-11-03-accueil-tutoriel-aide.md` | ## Étapes suivantes diff --git a/docs/plans/2026-11-03-accueil-tutoriel-aide.md b/docs/plans/2026-11-03-accueil-tutoriel-aide.md new file mode 100644 index 0000000..addb98b --- /dev/null +++ b/docs/plans/2026-11-03-accueil-tutoriel-aide.md @@ -0,0 +1,59 @@ +# Accueil, installation, tutoriel des règles, aide (0e.5) + +- **Date** : 2026-11-03 +- **Étape** : 0e.5 (feuille de route), qui couvre aussi le tutoriel de 1g +- **Statut** : prévu (après la fusion de 0e.4) +- **Exécutant** : sous-agent general-purpose, dans un worktree + +## Objectif + +Traiter les points 2 et 3 du message de Pierre du 2026-11-03 et sa demande d'installation en un toucher : +- une page d'accueil ; +- l'installation proposée par l'app ; +- un tutoriel des règles très didactique, illustré par le plateau ; +- une aide plus humaine. + +## Références + +- Règles : `docs/identite/charte/accueil-tutoriel-aide.md`. Le texte du tutoriel y figure en première version, à faire valider par Pierre. +- Maquettes : https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe, rangée « Étape 0e.5 » et artboard « Accueil ». +- ADR 0008 (source unique), ADR 0013 (positions par identifiants gnubg). + +## Consignes + +1. **Accueil**, selon la charte : + - au premier lancement, et une seule fois, le choix entre « Je découvre » et « Je connais les règles » ; + - ensuite : la partie en cours, la carte d'installation, la prochaine leçon et la progression résumée. La leçon et la progression restent en attente tant que 1g et 1h n'existent pas : n'afficher que ce qui existe. +2. **Installation** : + - sous Chrome, garder l'invite reçue avec `beforeinstallprompt` ; + - sous Firefox et sur iPhone, une feuille en 3 étapes ; + - la carte n'apparaît qu'à partir de la première partie terminée. « Plus tard » la masque, et elle ne revient qu'une fois, après 3 parties. Le menu garde une ligne tant que l'app n'est pas installée ; + - plus rien en `display-mode: standalone` ni après l'événement `appinstalled`. +3. **Tutoriel**, en 8 étapes : + - le texte vit dans un fichier source unique sous `docs/` (par exemple `docs/tutoriel/`), copié dans le site par `make site` comme le glossaire, sous licence CC BY-SA 4.0 ; + - positions données par leurs identifiants gnubg ; + - sur les écrans « à vous », le moteur vérifie le coup joué en le comparant aux coups attendus de l'étape ; + - le coup illustré se joue seul après 1 s, avec « Revoir » ; + - un test vérifie que chaque position du tutoriel est valide pour le moteur et que chaque coup attendu est légal ; + - ajouter la fiche du glossaire « camp ». +4. **Aide**, en trois onglets : + - « Jouer » : les scénarios deviennent des questions repliables. Le titre est la question et la description la réponse ; les étapes Gherkin sont cachées. Reformuler les titres des `.feature` sans casser les tests ; + - « Les mots du jeu » : les cartes du glossaire, avec une recherche ; + - « À propos » : licence, crédits, version ; + - le tutoriel est proposé en tête de l'aide. +5. **Scénarios Gherkin** : premier lancement, installation, suivre le tutoriel. +6. **`make test-browser`** : + - premier lancement, tutoriel complet avec ses écrans « à vous », aide ; + - `beforeinstallprompt` simulé dans Chromium ; + - captures dans `build/browser-shots/accueil-tutoriel/` pour la recette design. + +`make lint`, `make test` et `make test-browser` doivent passer. Aucune nouvelle dépendance. + +## Critères de réussite + +- Un débutant suit le tutoriel jusqu'au bout sur le téléphone, et chaque écran « à vous » accepte le bon coup et refuse les autres. +- Les trois commandes passent, et les captures sont livrées pour la recette. + +## Résultat + +À remplir à la clôture. From fa558667f771f5c41c8babad9d7e204b0ccd9fd1 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 15:04:50 +0100 Subject: [PATCH 09/10] =?UTF-8?q?Plan=201a=20:=20r=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-03-moteur-analyse.md | 38 ++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-11-03-moteur-analyse.md b/docs/plans/2026-11-03-moteur-analyse.md index 6e64ee6..5e21429 100644 --- a/docs/plans/2026-11-03-moteur-analyse.md +++ b/docs/plans/2026-11-03-moteur-analyse.md @@ -57,4 +57,40 @@ chemins explicites ; ne pas pousser, pas de MR. ## Résultat -À remplir à la clôture. +Réalisé le 2026-11-03, branche `moteur-analyse`. Critères tenus : nouvelles +commandes d'`engine-cli` en JSON, références concordantes en natif et en +WebAssembly, `make lint` et `make test` au vert. + +- **Niveaux** : les neuf réglages prédéfinis de gnubg 1.08 (`aecSettings`) : + `beginner`, `casual`, `intermediate`, `advanced` (0 pli, bruit 0,06 / 0,05 / + 0,04 / 0,015, déterministe), puis `0ply` à `4ply` sans bruit (anciens noms + gnubg : « expert » = 0 pli, « world class » = 2 plis). Coup choisi comme + l'ordinateur de gnubg (`ComputerTurn` : sans analyse, puis + `RefreshMoveList`). `engine.h` (`engine_level_*`), `engine-cli --level` et + `levels`, `Engine.levelMove` et `levelCubeDecision`. +- **Analyses** : `engine_analyse_move` (comme `AnalyzeMove` : coup joué + évalué au niveau le plus profond, rang, perte, appréciation), + `engine_analyse_cube` (pas de double, double, prendre, passer ; pertes de + `AnalyzeMove`), `engine_luck_of` (portage de `LuckNormal` et `LuckFirst` + d'`analysis.c`, fichier non repris dans `engine/gnubg/`). Commandes + `analysemove`, `analysecube`, `luck` ; `Engine.analyseMove`, `analyseCube`, + `luck`. +- **Seuils** : ceux de gnubg, `gnubg.c` : appréciation 0,03 / 0,06 / 0,12 + (et non 0,04 / 0,08 / 0,16 comme proposé dans `docs/etape-1.md`) ; chance + 0,3 / 0,6. Constantes dans `engine.h` et `SKILL_THRESHOLDS`, + `LUCK_THRESHOLDS` dans `engine.ts`, vérifiées égales par un test. +- **Références** : `tests/reference/analyses.json` (16 coups joués, 14 + décisions de videau, 7 cas de niveaux), résultats dans `analysis/`, produits + par l'analyse de partie de gnubg de bureau (« analyse game ») ; comparés par + `compare.py`. Écart maximal 1,64e-3 (`move-jacoby`, 2 plis), tolérance + inchangée (0,002). +- **Écart au plan, décision à valider** : le moteur vide le cache + d'évaluation de gnubg à chaque calcul. Une recherche à 2 plis avec élagage + y laisse des évaluations à 0 pli des réseaux d'élagage, que les calculs à + 0 pli suivants reprennent (même défaut dans gnubg de bureau : jusqu'à + 2,5e-2 d'écart sur la chance). Coût : environ 3 ms par appel sur le PC. +- **Mesure** (`make bench`, Node 18, i5-2450M) : analyse du coup joué à + 2 plis, médiane 76 ms, maximum 451 ms (`opening-21`) ; seuil 3 s. Aussi + dans `bench.html` pour le téléphone. +- **Glossaire** : Niveau de jeu (`Level`), Appréciation (`Skill`), Chance + (`Luck`). From 7fe3d53034c6a906ee91956b152ca08cfd7a35be Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 15:14:51 +0100 Subject: [PATCH 10/10] =?UTF-8?q?Plans=201c=20(match,=20niveaux,=20r=C3=A9?= =?UTF-8?q?glages)=20et=201d=20(conseil=20du=20ma=C3=AEtre)?= 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-03-conseil-du-maitre.md | 60 ++++++++++++++++++++++ docs/plans/2026-11-03-match-et-reglages.md | 47 +++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 docs/plans/2026-11-03-conseil-du-maitre.md create mode 100644 docs/plans/2026-11-03-match-et-reglages.md diff --git a/docs/plans/2026-11-03-conseil-du-maitre.md b/docs/plans/2026-11-03-conseil-du-maitre.md new file mode 100644 index 0000000..64dc46c --- /dev/null +++ b/docs/plans/2026-11-03-conseil-du-maitre.md @@ -0,0 +1,60 @@ +# Conseil du maître (1d) + +- **Date** : 2026-11-03 +- **Étape** : 1d (`docs/etape-1.md`) +- **Statut** : prévu (après 1c) +- **Exécutant** : sous-agent general-purpose, dans un worktree + +## Objectif + +Le coach bienveillant, « mis en avant mais discret » (Pierre, 2026-11-03) : +après chaque coup et chaque décision de videau du joueur, jamais avant +(ADR 0011), une appréciation et une phrase du maître fondées sur les chiffres +du moteur. + +## Références + +- ADR 0011 ; maquette « conseil du maître après le coup » du canevas + https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe ; règles de la charte + (`docs/identite/charte/`, dont interaction.md) ; la ligne réservée de 44 px + de la révision UX. +- Moteur : `analyseMove`, `analyseCube`, `luck`, seuils `SKILL_THRESHOLDS` + (gnubg : 0,03 / 0,06 / 0,12). + +## Consignes + +1. **Analyse en arrière-plan** : après la validation d'un coup (ou une + décision de videau), l'analyse à 2 plis part dans le Worker sans retarder le + tour de l'ordinateur ; son résultat est rattaché au coup dans le journal + (événement d'analyse rétrocompatible, ou cache séparé justifié) pour servir + au bilan (1e) sans recalcul. +2. **Ligne du maître** : icône d'appréciation, mot en couleur (« Bon coup. », + « Imprécision. », « Erreur. », « Gaffe. » : correspondance avec les seuils + de gnubg documentée), phrase courte en italique, chevron. Toucher ouvre la + feuille d'explication : coup joué et meilleur coup montrés sur le plateau + (pions animés, ADR 0011 « comparaison visuelle »), écart d'équité seulement + sur demande, lien vers la fiche du glossaire du concept en jeu si le moteur + permet de le déterminer, sinon rien. +3. **Phrases du maître** : gabarits versionnés en français dans une source + unique sous `docs/` (CC BY-SA 4.0), ton encourageant, jamais culpabilisant ; + elles ne citent que des faits du moteur (coup recommandé, rang, pions + frappés, écart) : aucune explication inventée. Première version à faire + valider par Pierre (ton). +4. **Discret** : rien n'apparaît pendant que le joueur réfléchit ; la ligne + s'efface à l'action suivante ; désactivable (réglage de 1c et menu). +5. **Videau** : même traitement pour doubler, ne pas doubler, prendre, passer. +6. Scénarios Gherkin (bon coup, erreur avec meilleur coup montré, conseil + désactivé, décision de videau commentée) avec positions et dés imposés ; + tests unitaires des gabarits (chaque gabarit rendu sur des analyses réelles + du moteur). `make lint`, `make test`, `make test-browser`, captures pour la + recette design. + +## Critères de réussite + +Après une erreur volontaire dans une position connue, la ligne du maître dit +« Erreur. », nomme le coup recommandé par le moteur et le montre sur demande ; +le tour de l'ordinateur n'est pas retardé ; les trois commandes passent. + +## Résultat + +À remplir à la clôture. diff --git a/docs/plans/2026-11-03-match-et-reglages.md b/docs/plans/2026-11-03-match-et-reglages.md new file mode 100644 index 0000000..b99fd68 --- /dev/null +++ b/docs/plans/2026-11-03-match-et-reglages.md @@ -0,0 +1,47 @@ +# Match, niveaux et réglages (1c) + +- **Date** : 2026-11-03 +- **Étape** : 1c (`docs/etape-1.md`) +- **Statut** : prévu (après la révision UX et 1b) +- **Exécutant** : sous-agent general-purpose, dans un worktree + +## Objectif + +Choisir sa partie : partie libre ou match en N points, avec les règles de +Crawford et de Jacoby, contre l'ordinateur au niveau voulu. + +## Consignes + +ADR 0007, 0008. Le moteur expose depuis 1a les niveaux de gnubg +(`levelMove`, `levelCubeDecision`) et accepte les matchs dans son API C. + +1. **Domaine** : longueur de match, score, règle de Crawford (partie Crawford + et suivantes, calculées par le moteur via le Match ID), règle de Jacoby en + partie libre ; enchaînement des manches d'un match jusqu'à sa fin ; le + journal d'événements reste rétrocompatible (les parties existantes se + rechargent). +2. **Adaptateur** : `EngineCube` étendu au match (score, longueur, Crawford) + ou remplacé par le Match ID, sans casser les appels existants. +3. **Niveau de l'ordinateur** : ses coups et ses décisions de videau au niveau + choisi. Reprendre de `ComputerTurn` (gnubg, `play.c`) les règles de + déroulement non reprises en 1a : pré-filtre « fenêtre de marché » et double + facultatif à 0 pli, avec citation des sources. +4. **Écran de nouvelle partie** (maquettes de la session « identité » si + disponibles, sinon sobre et conforme à la charte) : type de partie, niveau + (noms montrés au joueur à proposer simplement : Débutant, Amateur, + Intermédiaire, Avancé, Expert, Champion — correspondance avec les niveaux + de gnubg documentée), conseil du maître activé ou non. Par défaut : partie + libre, Intermédiaire, conseil activé. Le réglage est mémorisé. +5. Affichage du score de match et de la partie Crawford sur le plateau. +6. Scénarios Gherkin (match en 3 points jusqu'au bout avec dés imposés, + Crawford, Jacoby) et fiches du glossaire (match, Crawford, Jacoby si + absentes). `make lint`, `make test`, `make test-browser` (un match court). + +## Critères de réussite + +Un match en 3 points se joue jusqu'au bout, au niveau choisi, avec Crawford +appliqué ; les trois commandes passent. + +## Résultat + +À remplir à la clôture.