@nodots-llc/backgammon-ai
Version:
AI and integration for nodots-backgammon using the @nodots-llc/gnubg-hints native addon.
328 lines (327 loc) • 14.4 kB
JavaScript
/**
* AI Move Selection with Opening Book and Strategic Logic
* This module handles intelligent move selection for backgammon robots
*/
import { buildHintContextFromPlay, getContainerKind, getNormalizedPosition, } from './hintContext.js';
import { gnubgHints } from './gnubg.js';
// Optional policy model support (not required for baseline build)
let selectMoveWithPolicy = null;
async function tryLoadPolicyModel() {
if (selectMoveWithPolicy)
return selectMoveWithPolicy;
try {
const path = './training/' + 'policyModel.js';
const mod = await import(path);
// @ts-ignore
selectMoveWithPolicy = mod.selectMoveWithPolicy;
}
catch {
selectMoveWithPolicy = null;
}
return selectMoveWithPolicy;
}
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
// Simple logger to avoid circular dependency with core
const logger = {
info: (msg) => console.log(`[AI] [INFO] ${msg}`),
warn: (msg) => console.warn(`[AI] [WARN] ${msg}`),
error: (msg) => console.error(`[AI] [ERROR] ${msg}`),
debug: (msg) => console.log(`[AI] [DEBUG] ${msg}`)
};
/**
* Main AI move selection function that tries multiple strategies in order:
* 1. GNU Backgammon AI (required for gbg-bot, optional for others)
* 2. Opening book for common opening rolls
* 3. Strategic heuristics
* 4. Random selection (fallback)
*/
export async function selectBestMove(play, playerNickname) {
if (!play.moves || play.moves.length === 0)
return undefined;
const readyMoves = play.moves.filter((move) => move.stateKind === 'ready');
if (readyMoves.length === 0)
return undefined;
// Determine identity. For now, only two robots exist: gbg-bot and nbg-bot-v1.
// Core currently passes userId here, not nickname. Hardcode detection by name or id.
const passedIdentifier = playerNickname || '';
const playerUserId = play?.player?.userId;
const isRobot = !!play?.player?.isRobot;
// Known mapping (hardcoded for current system):
// gbg-bot userId observed in logs/tests: da7eac85-cf8f-49f4-b97d-9f40d3171b36
const KNOWN_GBG_BOT_IDS = new Set([
'da7eac85-cf8f-49f4-b97d-9f40d3171b36',
]);
const isGbgBot = passedIdentifier === 'gbg-bot' ||
(playerUserId ? KNOWN_GBG_BOT_IDS.has(playerUserId) : false);
// With only two robots in the system, treat any other robot as nbg-bot-v1
const isNbgBot = passedIdentifier === 'nbg-bot-v1' || (isRobot && !isGbgBot);
// Use a friendly name in logs
const robotName = isGbgBot ? 'gbg-bot' : isNbgBot ? 'nbg-bot-v1' : (passedIdentifier || playerUserId || 'Unknown Robot');
logger.info(`[AI] ${robotName} starting move selection with ${readyMoves.length} available moves`);
if (isGbgBot) {
logger.info(`[AI] ${robotName} AI Engine: GNU Backgammon (required)`);
const available = await gnubgHints.isAvailable();
if (!available) {
const instructions = gnubgHints.getBuildInstructions();
logger.error(`[AI] ${robotName} GNU Backgammon hints unavailable — terminating turn selection`);
throw new Error(`gbg-bot cannot function without GNU Backgammon hints.\n\n${instructions}`);
}
try {
const { request, normalization } = buildHintContextFromPlay(play);
logger.debug(`[AI] ${robotName} requesting structured hints from @nodots-llc/gnubg-hints`);
const hints = await gnubgHints.getMoveHints(request, 10);
if (!Array.isArray(hints) || hints.length === 0) {
throw new Error('No move hints returned by @nodots-llc/gnubg-hints');
}
const matched = findMoveMatchingHints(readyMoves, hints, normalization, robotName);
if (matched) {
const { move, hint } = matched;
logger.info(`[AI] ${robotName} Move selected via: GNU Backgammon Engine (hint rank ${hint.rank})`);
;
move.__source = 'gnu-hint';
return move;
}
logger.warn(`[AI] ${robotName} Structured hints received but none matched available moves; falling back to heuristics`);
}
catch (error) {
logger.error(`[AI] ${robotName} GNU Backgammon integration error: ${String(error)}`);
throw new Error(`gbg-bot requires GNU Backgammon hints but the integration failed: ${error}`);
}
}
if (isNbgBot) {
logger.info(`[AI] ${robotName} AI Engine: Nodots AI (GNU BG excluded)`);
// Try trained policy first if available
try {
const modelDir = resolveModelDir();
const modelPath = modelDir ? path.join(modelDir, 'model.json') : undefined;
if (modelPath && fs.existsSync(modelPath)) {
const raw = fs.readFileSync(modelPath, 'utf-8');
const model = JSON.parse(raw);
const policy = await tryLoadPolicyModel();
if (policy) {
const policyMove = policy(play, model);
if (policyMove) {
logger.info(`[AI] ${robotName} Move selected via: Trained Policy`);
policyMove.__source = 'policy';
return policyMove;
}
}
logger.warn(`[AI] ${robotName} Policy available but no move matched; falling back`);
}
else {
logger.debug(`[AI] ${robotName} No trained policy found (searched: ${modelPath || 'n/a'})`);
}
}
catch (err) {
logger.warn(`[AI] ${robotName} Policy load error: ${String(err)} (falling back)`);
}
}
else {
// For other bots (not gbg-bot, not nbg-bot), indicate they use hybrid AI approach
logger.info(`[AI] ${robotName} AI Engine: Hybrid (Opening Book + Strategic Heuristics)`);
}
// Try opening book for opening positions
const openingMove = getOpeningBookMove(readyMoves, robotName);
if (openingMove) {
logger.info(`[AI] ${robotName} Move selected via: Opening Book`);
openingMove.__source = 'opening';
return openingMove;
}
// Use strategic heuristics
const strategicMove = getBestStrategicMove(readyMoves, robotName);
if (strategicMove) {
logger.info(`[AI] ${robotName} Move selected via: Strategic Heuristics`);
strategicMove.__source = 'strategic';
return strategicMove;
}
// Final fallback to first available move
logger.warn(`[AI] ${robotName} Move selected via: Fallback (first available move)`);
readyMoves[0].__source = 'fallback';
return readyMoves[0];
}
/**
* Opening book for common opening rolls
* Returns the theoretically best move for opening positions
*/
function getOpeningBookMove(readyMoves, robotName) {
// Get dice values from the moves
const diceValues = extractDiceFromMoves(readyMoves);
if (diceValues.length !== 2)
return undefined;
const [die1, die2] = diceValues.sort();
const openingKey = `${die1}${die2}`;
// Opening book for key opening rolls
const openingBook = {
'56': '24/13', // Lover's Leap - best opening move for [5,6]
'46': '24/18', // Second best alternative
'53': '24/16', // Common opening
'55': '24/14', // Double fives
'66': '24/12', // Double sixes
'44': '24/16', // Double fours
'33': '24/18', // Double threes
'22': '24/20', // Double twos
'11': '24/22', // Double ones
};
const preferredMove = openingBook[openingKey];
if (!preferredMove)
return undefined;
// Try to find a move that matches the opening book recommendation
for (const move of readyMoves) {
if (move.possibleMoves && move.possibleMoves.length > 0) {
const firstPossibleMove = move.possibleMoves[0];
if (firstPossibleMove.origin && firstPossibleMove.destination) {
// Normalize to mover perspective so openings match symmetrically
const normalizedColor = move.player.direction === 'clockwise' ? 'white' : 'black';
const originPos = getNormalizedPosition(firstPossibleMove.origin, normalizedColor);
const destPos = getNormalizedPosition(firstPossibleMove.destination, normalizedColor);
if (originPos === 24 && destPos === 13 && preferredMove === '24/13') {
logger.info(`[AI] ${robotName} Opening Book: Lover's Leap (24/13) for dice [${die1},${die2}]`);
return move;
}
// Add other opening book matches as needed
}
}
}
return undefined;
}
/**
* Strategic move selection using heuristics
* Prefers moves that advance checkers furthest
*/
function getBestStrategicMove(readyMoves, robotName) {
// Prefer moves that advance checkers furthest
let bestMove = readyMoves[0];
let bestDistance = 0;
logger.debug(`[AI] ${robotName} Strategic analysis: evaluating ${readyMoves.length} moves`);
for (const move of readyMoves) {
if (move.possibleMoves && move.possibleMoves.length > 0) {
const firstPossibleMove = move.possibleMoves[0];
if (firstPossibleMove.origin && firstPossibleMove.destination) {
// Normalize positions to the moving player's perspective so that
// advancing always corresponds to decreasing index (white perspective)
const normalizedColor = move.player.direction === 'clockwise' ? 'white' : 'black';
const originPos = getNormalizedPosition(firstPossibleMove.origin, normalizedColor);
const destPos = getNormalizedPosition(firstPossibleMove.destination, normalizedColor);
if (originPos !== null && destPos !== null) {
const distance = originPos - destPos; // Positive means advancing in normalized coordinates
if (distance > bestDistance) {
bestDistance = distance;
bestMove = move;
logger.debug(`[AI] ${robotName} Strategic: new best move ${originPos}→${destPos} (distance: ${distance})`);
}
}
}
}
}
logger.info(`[AI] ${robotName} Strategic: selected move with distance ${bestDistance}`);
return bestMove;
}
/**
* Extract dice values from the available moves
* Note: For doubles (e.g., [3,3]), this will return [3,3,3,3] as expected
*/
function extractDiceFromMoves(readyMoves) {
const diceValues = [];
for (const move of readyMoves) {
if (move.dieValue) {
diceValues.push(move.dieValue);
}
}
return diceValues;
}
/**
* Extract position number from a checker container
*/
function getPositionNumber(container) {
if (container.kind === 'point' && container.position && typeof container.position === 'object') {
// Type guard to check if it's a point position object
const position = container.position;
if (position.clockwise !== undefined || position.counterclockwise !== undefined) {
// Use clockwise position as reference for now, fallback to counterclockwise
return position.clockwise || position.counterclockwise || null;
}
}
return null;
}
function normalizeMoveSkeleton(move, normalizedColor) {
if (!move.possibleMoves || move.possibleMoves.length === 0) {
return [];
}
const steps = [];
for (const possibleMove of move.possibleMoves) {
const from = getNormalizedPosition(possibleMove.origin, normalizedColor);
const to = getNormalizedPosition(possibleMove.destination, normalizedColor);
if (from === null || to === null) {
continue;
}
steps.push({
from,
to,
fromContainer: getContainerKind(possibleMove.origin),
toContainer: getContainerKind(possibleMove.destination),
});
}
return steps;
}
function stepsMatch(hintStep, moveStep) {
return (hintStep.from === moveStep.from &&
hintStep.to === moveStep.to &&
hintStep.fromContainer === moveStep.fromContainer &&
hintStep.toContainer === moveStep.toContainer);
}
function findMoveMatchingHints(readyMoves, hints, normalization, robotName) {
for (const hint of hints) {
const targetStep = hint.moves[0];
if (!targetStep) {
continue;
}
for (const move of readyMoves) {
const normalizedColor = normalization.toGnu[move.player.color];
if (!normalizedColor) {
continue;
}
const normalizedSteps = normalizeMoveSkeleton(move, normalizedColor);
const matchingStep = normalizedSteps.find((step) => stepsMatch(targetStep, step));
if (matchingStep) {
logger.debug(`[AI] ${robotName} Matched hint step ${formatHintStep(targetStep)} to move ${formatMoveStep(matchingStep)}`);
return { move, hint };
}
}
}
return undefined;
}
function formatHintStep(step) {
return `${step.from}:${step.to}:${step.fromContainer}->${step.toContainer}`;
}
function formatMoveStep(step) {
if (!step) {
return 'unknown-move';
}
return `${step.from}:${step.to}:${step.fromContainer}->${step.toContainer}`;
}
function resolveModelDir() {
// Priority: env var -> repo-local models/latest relative to package
const env = process.env.NDBG_MODEL_DIR;
if (env && fs.existsSync(env))
return env;
try {
// Resolve package root from current module
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// dist/ai/src -> dist/ai/models/latest
const candidate = path.resolve(__dirname, '..', 'models', 'latest');
if (fs.existsSync(candidate))
return candidate;
}
catch { }
try {
// Try project-local ai/models/latest from cwd
const cwdCandidate = path.resolve(process.cwd(), 'ai', 'models', 'latest');
if (fs.existsSync(cwdCandidate))
return cwdCandidate;
}
catch { }
return undefined;
}