From 34c3eaeca5535ae53c5d2d67e552758ec892eb7b Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 10:04:51 +0100 Subject: [PATCH] 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