- engine_best_moves suit hint_move de gnubg : fAnalyse vrai, seuil « douteux », puis RefreshMoveList (classement par niveau d'évaluation et départage des égalités). - Correction : en match, l'équité des coups était convertie deux fois par mwc2eq (ScoreMove la convertit déjà). - engine_cube : ajout de optimal et decision_text ; decision prend le nom de l'énumération cubedecision de gnubg, comme les références. - engine-cli : commande reference (un niveau au format de tests/reference/) et nombres écrits avec 9 chiffres significatifs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
386 lines
12 KiB
C
386 lines
12 KiB
C
/*
|
|
* engine.c -- façade du moteur d'Ustagammon sur le cœur de GNU Backgammon.
|
|
*
|
|
* Copyright (C) 2026 les auteurs d'Ustagammon.
|
|
* Licence : GPLv3 ou ultérieure (voir LICENSE).
|
|
*
|
|
* Chaque fonction décode les identifiants de position, prépare le contexte
|
|
* de gnubg (cubeinfo, réglages d'évaluation) comme le fait gnubg de bureau
|
|
* pour ses commandes « hint » et « eval », puis appelle gnubg.
|
|
*/
|
|
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
|
|
#include "config.h"
|
|
#include "backgammon.h"
|
|
#include "drawboard.h"
|
|
#include "matchequity.h"
|
|
#include "matchid.h"
|
|
#include "multithread.h"
|
|
#include "positionid.h"
|
|
#include "gnubg-support.h"
|
|
#include "engine.h"
|
|
|
|
/* Nombre de castors autorisés en partie libre : valeur par défaut de gnubg. */
|
|
#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
|
|
|
|
/* Fichier de table d'équité de match : celui de gnubg de bureau par défaut. */
|
|
#define MET_FILE "Kazaross-XG2.xml"
|
|
|
|
/* gnubg ne sait pas se réinitialiser après EvalShutdown : on retient l'état. */
|
|
static enum { NOT_STARTED, READY, STOPPED } engine_state = NOT_STARTED;
|
|
|
|
/* Une position décodée, prête pour gnubg. */
|
|
typedef struct {
|
|
TanBoard board;
|
|
cubeinfo cube;
|
|
engine_match match;
|
|
} position;
|
|
|
|
/* -------------------------------------------------------- Initialisation */
|
|
|
|
/* Vérifie l'en-tête de gnubg.wd, pour éviter l'exit() de EvalInitialise. */
|
|
static int
|
|
binary_weights_ok(const char *path)
|
|
{
|
|
float header[2];
|
|
FILE *f = fopen(path, "rb");
|
|
int ok;
|
|
|
|
if (!f)
|
|
return 0;
|
|
ok = fread(header, sizeof header[0], 2, f) == 2
|
|
&& header[0] == WEIGHTS_MAGIC_BINARY && header[1] == WEIGHTS_VERSION_BINARY;
|
|
fclose(f);
|
|
return ok;
|
|
}
|
|
|
|
static engine_status
|
|
load_match_equity_table(const char *data_dir)
|
|
{
|
|
char *path = g_build_filename(data_dir, MET_FILE, NULL);
|
|
int ok;
|
|
|
|
ok = g_file_test(path, G_FILE_TEST_IS_REGULAR);
|
|
if (ok) {
|
|
InitMatchEquity(path);
|
|
/* En cas d'échec, gnubg se rabat sans erreur sur la table de Zadeh. */
|
|
ok = miCurrent.szFileName && strcmp(miCurrent.szFileName, path) == 0;
|
|
}
|
|
g_free(path);
|
|
return ok ? ENGINE_OK : ENGINE_ERROR_DATA;
|
|
}
|
|
|
|
static engine_status
|
|
load_neural_nets_and_bearoff(const char *data_dir)
|
|
{
|
|
char *weights = g_build_filename(data_dir, "gnubg.wd", NULL);
|
|
int ok = binary_weights_ok(weights);
|
|
|
|
if (ok) {
|
|
EvalInitialise(NULL, weights, FALSE, NULL);
|
|
/* Bases de fin de partie : gnubg continue sans elles, pas nous. */
|
|
ok = pbc1 && !pbc1->fHeuristic && pbc2;
|
|
}
|
|
g_free(weights);
|
|
return ok ? ENGINE_OK : ENGINE_ERROR_DATA;
|
|
}
|
|
|
|
engine_status
|
|
engine_init(const char *data_dir)
|
|
{
|
|
engine_status status;
|
|
|
|
if (engine_state == READY)
|
|
return ENGINE_OK;
|
|
if (engine_state == STOPPED || !data_dir)
|
|
return ENGINE_ERROR_NOT_INITIALISED;
|
|
|
|
gnubg_support_set_data_dir(data_dir);
|
|
status = load_match_equity_table(data_dir);
|
|
if (status == ENGINE_OK)
|
|
status = load_neural_nets_and_bearoff(data_dir);
|
|
if (status != ENGINE_OK)
|
|
return status;
|
|
|
|
MT_InitThreads();
|
|
engine_state = READY;
|
|
return ENGINE_OK;
|
|
}
|
|
|
|
void
|
|
engine_shutdown(void)
|
|
{
|
|
if (engine_state != READY)
|
|
return;
|
|
MT_Close();
|
|
td.tld = NULL;
|
|
EvalShutdown();
|
|
engine_state = STOPPED;
|
|
}
|
|
|
|
const char *
|
|
engine_status_message(engine_status status)
|
|
{
|
|
switch (status) {
|
|
case ENGINE_OK:
|
|
return "succès";
|
|
case ENGINE_ERROR_NOT_INITIALISED:
|
|
return "moteur non initialisé";
|
|
case ENGINE_ERROR_DATA:
|
|
return "données du moteur absentes ou invalides";
|
|
case ENGINE_ERROR_POSITION_ID:
|
|
return "Position ID invalide";
|
|
case ENGINE_ERROR_MATCH_ID:
|
|
return "Match ID invalide";
|
|
case ENGINE_ERROR_DICE:
|
|
return "dés invalides (chacun entre 1 et 6)";
|
|
case ENGINE_ERROR_PLY:
|
|
return "niveau invalide";
|
|
case ENGINE_ERROR_TOO_MANY_MOVES:
|
|
return "tableau de coups trop petit";
|
|
case ENGINE_ERROR_INTERNAL:
|
|
return "erreur interne de gnubg";
|
|
}
|
|
return "erreur inconnue";
|
|
}
|
|
|
|
/* ------------------------------------------------------------- Décodage */
|
|
|
|
/* Vrai si id a exactement length caractères de l'alphabet base64 de gnubg. */
|
|
static int
|
|
is_base64_id(const char *id, size_t length)
|
|
{
|
|
size_t i;
|
|
|
|
if (!id || strlen(id) != length)
|
|
return 0;
|
|
for (i = 0; i < length; i++)
|
|
if (Base64((unsigned char) id[i]) == 255)
|
|
return 0;
|
|
return 1;
|
|
}
|
|
|
|
engine_status
|
|
engine_decode_match(const char *match_id, engine_match *match)
|
|
{
|
|
unsigned int dice[2];
|
|
int turn, resigned, doubled;
|
|
gamestate state;
|
|
|
|
if (!is_base64_id(match_id, L_MATCHID))
|
|
return ENGINE_ERROR_MATCH_ID;
|
|
if (MatchFromID(dice, &turn, &resigned, &doubled, &match->player_on_roll,
|
|
&match->cube_owner, &match->crawford, &match->match_length,
|
|
match->score, &match->cube_value, &match->jacoby, &state, match_id) != 0)
|
|
return ENGINE_ERROR_MATCH_ID;
|
|
return ENGINE_OK;
|
|
}
|
|
|
|
/* Décode Position ID et Match ID, et prépare le cubeinfo de gnubg. */
|
|
static engine_status
|
|
decode_position(const char *position_id, const char *match_id, position *pos)
|
|
{
|
|
engine_match *m = &pos->match;
|
|
engine_status status;
|
|
|
|
if (engine_state != READY)
|
|
return ENGINE_ERROR_NOT_INITIALISED;
|
|
if (!is_base64_id(position_id, L_POSITIONID) || !PositionFromID(pos->board, position_id))
|
|
return ENGINE_ERROR_POSITION_ID;
|
|
status = engine_decode_match(match_id, m);
|
|
if (status != ENGINE_OK)
|
|
return status;
|
|
if (SetCubeInfo(&pos->cube, m->cube_value, m->cube_owner, m->player_on_roll,
|
|
m->match_length, m->score, m->crawford, m->jacoby,
|
|
DEFAULT_BEAVERS, VARIATION_STANDARD) != 0)
|
|
return ENGINE_ERROR_MATCH_ID;
|
|
return ENGINE_OK;
|
|
}
|
|
|
|
static engine_status
|
|
check_dice(int die1, int die2)
|
|
{
|
|
if (die1 < 1 || die1 > 6 || die2 < 1 || die2 > 6)
|
|
return ENGINE_ERROR_DICE;
|
|
return ENGINE_OK;
|
|
}
|
|
|
|
/* Réglages prédéfinis de gnubg pour un niveau, sans bruit. */
|
|
static engine_status
|
|
eval_context_for_ply(int ply, evalcontext *context)
|
|
{
|
|
if (ply < 0 || ply > ENGINE_MAX_PLY)
|
|
return ENGINE_ERROR_PLY;
|
|
*context = aecSettings[SETTINGS_0PLY + ply];
|
|
return ENGINE_OK;
|
|
}
|
|
|
|
/* En match, gnubg calcule en chances de gain du match : on convertit. */
|
|
static float
|
|
normalized_equity(float score, const cubeinfo *cube)
|
|
{
|
|
return cube->nMatchTo ? mwc2eq(score, cube) : score;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ Coups */
|
|
|
|
static void
|
|
fill_move(engine_move *out, const move *m, const position *pos, int evaluated)
|
|
{
|
|
int i;
|
|
|
|
FormatMove(out->notation, (ConstTanBoard) pos->board, m->anMove);
|
|
snprintf(out->position_id, sizeof out->position_id, "%s", PositionIDFromKey(&m->key));
|
|
out->ply = evaluated ? (int) m->esMove.ec.nPlies : -1;
|
|
for (i = 0; i < ENGINE_NUM_PROBABILITIES; i++)
|
|
out->probabilities[i] = evaluated ? m->arEvalMove[i] : 0.0f;
|
|
/* ScoreMove a déjà converti rScore en équité normalisée en match. */
|
|
out->equity = evaluated ? m->rScore : 0.0f;
|
|
}
|
|
|
|
engine_status
|
|
engine_legal_moves(const char *position_id, const char *match_id, int die1, int die2,
|
|
engine_move *moves, int max_moves, int *count)
|
|
{
|
|
position pos;
|
|
movelist list;
|
|
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)
|
|
return status;
|
|
|
|
/* La liste pointe vers un tableau interne de gnubg : rien à libérer. */
|
|
GenerateMoves(&list, (ConstTanBoard) pos.board, die1, die2, FALSE);
|
|
if (list.cMoves > (unsigned int) max_moves)
|
|
return ENGINE_ERROR_TOO_MANY_MOVES;
|
|
for (i = 0; i < list.cMoves; i++)
|
|
fill_move(&moves[i], &list.amMoves[i], &pos, 0);
|
|
*count = (int) list.cMoves;
|
|
return ENGINE_OK;
|
|
}
|
|
|
|
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)
|
|
{
|
|
position pos;
|
|
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;
|
|
|
|
/*
|
|
* Comme la commande « hint » de gnubg (hint_move dans gnubg.c), avec le
|
|
* filtre « Normal » : fAnalyse vrai (un coup évalué moins profondément
|
|
* ne peut pas passer devant un coup mieux évalué), puis RefreshMoveList,
|
|
* qui classe d'abord par niveau d'évaluation et départage les égalités.
|
|
*/
|
|
if (FindnSaveBestMoves(&list, die1, die2, (ConstTanBoard) pos.board, NULL, TRUE,
|
|
DOUBTFUL_THRESHOLD, &pos.cube, &context,
|
|
aaamfMoveFilterSettings[0]) < 0)
|
|
return ENGINE_ERROR_INTERNAL;
|
|
RefreshMoveList(&list, NULL);
|
|
|
|
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;
|
|
}
|
|
g_free(list.amMoves);
|
|
return status;
|
|
}
|
|
|
|
/* ------------------------------------------------- Évaluation et videau */
|
|
|
|
engine_status
|
|
engine_evaluate(const char *position_id, const char *match_id, int ply,
|
|
engine_evaluation *evaluation)
|
|
{
|
|
position pos;
|
|
evalcontext context;
|
|
float output[NUM_ROLLOUT_OUTPUTS];
|
|
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 (GeneralEvaluationE(output, (ConstTanBoard) pos.board, &pos.cube, &context) < 0)
|
|
return ENGINE_ERROR_INTERNAL;
|
|
|
|
for (i = 0; i < ENGINE_NUM_PROBABILITIES; i++)
|
|
evaluation->probabilities[i] = output[i];
|
|
evaluation->cubeless_equity = output[OUTPUT_EQUITY];
|
|
evaluation->cubeful_equity = normalized_equity(output[OUTPUT_CUBEFUL_EQUITY], &pos.cube);
|
|
return ENGINE_OK;
|
|
}
|
|
|
|
/* Noms des décisions, dans l'ordre de l'énumération cubedecision (eval.h). */
|
|
static const char *const cube_decision_names[] = {
|
|
"DOUBLE_TAKE", "DOUBLE_PASS", "NODOUBLE_TAKE", "TOOGOOD_TAKE", "TOOGOOD_PASS",
|
|
"DOUBLE_BEAVER", "NODOUBLE_BEAVER", "REDOUBLE_TAKE", "REDOUBLE_PASS",
|
|
"NO_REDOUBLE_TAKE", "TOOGOODRE_TAKE", "TOOGOODRE_PASS",
|
|
"NO_REDOUBLE_BEAVER", "NODOUBLE_DEADCUBE", "NO_REDOUBLE_DEADCUBE",
|
|
"NOT_AVAILABLE", "OPTIONAL_DOUBLE_TAKE", "OPTIONAL_REDOUBLE_TAKE",
|
|
"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)
|
|
{
|
|
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)
|
|
return ENGINE_ERROR_INTERNAL;
|
|
/* FindCubeDecision renvoie des équités déjà normalisées. */
|
|
decision = FindCubeDecision(equities, output, &pos.cube);
|
|
if ((size_t) decision >= sizeof cube_decision_names / sizeof cube_decision_names[0])
|
|
return ENGINE_ERROR_INTERNAL;
|
|
|
|
for (i = 0; i < ENGINE_NUM_PROBABILITIES; i++)
|
|
cube->probabilities[i] = output[0][i];
|
|
cube->no_double = equities[OUTPUT_NODOUBLE];
|
|
cube->double_take = equities[OUTPUT_TAKE];
|
|
cube->double_pass = equities[OUTPUT_DROP];
|
|
cube->optimal = equities[OUTPUT_OPTIMAL];
|
|
cube->decision = cube_decision_names[decision];
|
|
cube->decision_text = GetCubeRecommendation(decision);
|
|
return ENGINE_OK;
|
|
}
|