@nodots-llc/backgammon-ai
Version:
AI and integration for nodots-backgammon using the @nodots-llc/gnubg-hints native addon.
479 lines (478 loc) • 21.2 kB
JavaScript
/**
* Robot Turn Execution with GNU Backgammon
*
* This module contains GNU-specific logic for executing complete robot turns.
* It was moved from @nodots-llc/backgammon-core to maintain separation of concerns
* and keep GNU dependencies isolated to the AI package.
*/
import { GnuBgHints } from '@nodots-llc/gnubg-hints';
import fs from 'fs';
import path from 'path';
// Lazy imports to break circular dependency (ESM-compatible)
let Core = null;
let Board = null;
const getCore = async () => {
if (!Core) {
Core = await import('@nodots-llc/backgammon-core');
}
return Core;
};
const getBoard = async () => {
if (!Board) {
const core = await getCore();
Board = core.Board;
}
return Board;
};
// Simple logger to avoid circular dependency issues
const logger = {
debug: (msg, ...args) => console.log(`[AI] [DEBUG] ${msg}`, ...args),
info: (msg, ...args) => console.log(`[AI] [INFO] ${msg}`, ...args),
warn: (msg, ...args) => console.warn(`[AI] [WARN] ${msg}`, ...args),
error: (msg, ...args) => console.error(`[AI] [ERROR] ${msg}`, ...args),
};
/**
* Convert GNU Backgammon move step to Nodots checker containers
*
* Maps GNU's position notation to Nodots' checker container system,
* handling point-to-point moves, bear-offs, and bar re-entries.
*/
const getCheckercontainersForGnuStep = (move, game) => {
const { activePlayer, board } = game;
const gnuTo = move.to;
const gnuFrom = move.from;
const gnuMoveKind = move.moveKind;
const gnuColor = move.player;
let origin = undefined;
let destination = undefined;
// Always use the active player's actual direction from the game state
const direction = game.activePlayer.direction;
switch (gnuMoveKind) {
case 'point-to-point':
{
origin = board.points.find((p) => p.position[activePlayer.direction] === gnuFrom);
destination = board.points.find((p) => p.position[activePlayer.direction] === gnuTo);
if (!origin || !destination)
throw new Error(`Missing Nodots origin ${JSON.stringify(origin)} or destination ${JSON.stringify(destination)}`);
}
break;
case 'bear-off':
{
{
origin = board.points.find((p) => p.position[activePlayer.direction] === gnuFrom);
if (!origin)
throw new Error(`Invalid origin for ${JSON.stringify(move)}`);
destination = board.off[activePlayer.direction];
}
}
break;
case 'reenter':
{
origin = board.bar[activePlayer.direction];
destination = board.points.find((p) => p.position[activePlayer.direction] === gnuTo);
if (!destination)
throw new Error(`Invalid destination for ${JSON.stringify(move)}`);
}
break;
default:
throw new Error(`Invalid move kind ${gnuMoveKind}`);
}
logger.debug('getCheckercontainersForGnuStep origin, destination:', origin, destination);
return { origin, destination, direction };
};
/**
* Execute a complete robot turn using GNU Backgammon hints
*
* This function:
* 1. Initializes GNU Backgammon engine
* 2. Requests hints for the current position
* 3. Executes the top-ranked move sequence
* 4. Transitions game state to rolling for next player
*
* @param game - Game in moving state with robot as active player
* @returns Game in rolling state ready for next player
* @throws Error if gnuPositionId is missing
* @throws Error if GNU Backgammon returns no hints
* @throws Error if move execution fails
*/
export const executeRobotTurnWithGNU = async (game) => {
await GnuBgHints.initialize();
const CoreUtil = await getCore();
let workingGame = game;
let aiFallbackUsed = false;
const fallbackReasons = [];
const telemetry = [];
let guard = 8; // prevent infinite loops per turn
// One-shot plan: ask GNU once for the full sequence and execute without re-asking mid-turn
const startMoves = (workingGame.activePlay?.moves || []);
const startReady = startMoves.filter((m) => m.stateKind === 'ready');
const playerRoll = workingGame.activePlayer?.dice?.currentRoll;
let roll;
let rollSource = 'ready-derived';
if (Array.isArray(playerRoll) && playerRoll.length === 2) {
const d1 = (playerRoll[0] ?? 1);
const d2 = (playerRoll[1] ?? 1);
roll = [d1, d2];
rollSource = 'player-currentRoll';
}
else {
const d1 = (startReady[0]?.dieValue ?? 1);
const d2 = (startReady[1]?.dieValue ??
(startReady.length > 1 ? startReady[1]?.dieValue ?? d1 : d1));
roll = [d1, d2];
rollSource = 'ready-derived';
}
const planPositionId = workingGame.gnuPositionId;
let plan = [];
try {
const hints = await GnuBgHints.getHintsFromPositionId(planPositionId, roll, 1);
const hint = hints && hints[0];
plan = hint?.moves || [];
}
catch {
plan = [];
}
let planIdx = 0;
const planLength = plan.length;
while (guard-- > 0 && workingGame.stateKind === 'moving') {
const moves = (workingGame.activePlay?.moves || []);
const ready = moves.filter((m) => m.stateKind === 'ready');
// If no READY moves remain, let core decide turn completion
if (ready.length === 0) {
workingGame = CoreUtil.Game.checkAndCompleteTurn(workingGame);
break;
}
// Next planned step (if any)
const positionId = workingGame.gnuPositionId;
let mappedOriginId = null;
let plannedFrom = null;
let plannedTo = null;
let plannedKind;
let expectedDie;
let matchedDie;
const stepFromPlan = planIdx < planLength ? plan[planIdx] : undefined;
if (stepFromPlan) {
plannedFrom = stepFromPlan.from ?? null;
plannedTo = stepFromPlan.to ?? null;
plannedKind = stepFromPlan.moveKind;
try {
const { origin } = getCheckercontainersForGnuStep(stepFromPlan, workingGame);
mappedOriginId = origin?.id ?? null;
}
catch {
mappedOriginId = null;
}
}
// Validate planned origin
const legalOriginIds = [];
const isPlanOriginLegal = mappedOriginId
? ready.some((m) => Array.isArray(m.possibleMoves) &&
m.possibleMoves.some((pm) => {
const id = pm?.origin?.id;
if (id)
legalOriginIds.push(id);
return id === mappedOriginId;
}))
: false;
let originIdToUse = null;
let usedFallback = false;
let fallbackReason;
if (isPlanOriginLegal && mappedOriginId) {
originIdToUse = mappedOriginId;
}
else {
// Attempt position-based mapping before declaring fallback (origin+destination+kind match)
let posMatchedId = null;
let expectedDie;
let matchedDie;
const dir = workingGame.activePlayer?.direction || 'clockwise';
for (const m of ready) {
if (!Array.isArray(m.possibleMoves))
continue;
for (const pm of m.possibleMoves) {
const org = pm?.origin;
const dst = pm?.destination;
if (!org || !dst)
continue;
// Planned reentry: origin must be bar; optional destination position check
if (plannedKind === 'reenter' && org.kind === 'bar') {
if (typeof plannedTo === 'number') {
const dpos = dst?.position?.[dir];
if (typeof dpos === 'number' && dpos === plannedTo) {
expectedDie = plannedTo;
matchedDie = pm?.dieValue;
if (typeof matchedDie === 'number' && matchedDie !== expectedDie) {
continue;
}
posMatchedId = org.id;
break;
}
}
else {
posMatchedId = org.id;
break;
}
}
// Planned bear-off: destination must be off; check origin position
if (plannedKind === 'bear-off' && dst?.kind === 'off') {
if (typeof plannedFrom === 'number') {
const opos = org?.position?.[dir];
if (typeof opos === 'number' && opos === plannedFrom) {
// Expected die is typically plannedFrom; allow pm.dieValue >= plannedFrom (higher die allowed when no higher checkers)
expectedDie = plannedFrom;
matchedDie = pm?.dieValue;
if (typeof matchedDie === 'number' && matchedDie < expectedDie) {
continue;
}
posMatchedId = org.id;
break;
}
}
}
// Planned point-to-point: check both origin and destination positions
if (plannedKind === 'point-to-point') {
const opos = org?.position?.[dir];
const dpos = dst?.position?.[dir];
if (typeof plannedFrom === 'number' &&
typeof plannedTo === 'number' &&
typeof opos === 'number' &&
typeof dpos === 'number' &&
opos === plannedFrom &&
dpos === plannedTo) {
// Expected die is absolute difference (relative to mover perspective)
expectedDie = Math.abs(plannedFrom - plannedTo);
matchedDie = pm?.dieValue;
if (typeof matchedDie === 'number' && matchedDie !== expectedDie) {
continue;
}
posMatchedId = org.id;
break;
}
}
}
if (posMatchedId)
break;
}
if (posMatchedId) {
// Position-based mapping succeeded; do not treat as override
originIdToUse = posMatchedId;
// Update mapping telemetry fields to reflect position-based match
mappedOriginId = posMatchedId;
// We intentionally do NOT set usedFallback/aiFallbackUsed here
}
else {
// Fallback: planned step could not be matched by id or position+die
// Treat as CORE move mismatch when we had a planned step
aiFallbackUsed = true;
usedFallback = true;
fallbackReason = stepFromPlan ? 'core-move-mismatch' : 'no-gnu-hints-or-mapping-failed';
if (fallbackReason)
fallbackReasons.push(fallbackReason);
try {
if (fallbackReason === 'core-move-mismatch') {
const diag = {
ts: new Date().toISOString(),
gameId: workingGame?.id,
positionId,
roll,
dir: workingGame.activePlayer?.direction || 'clockwise',
planned: { from: plannedFrom, to: plannedTo, kind: plannedKind },
readyMovesSample: ready.slice(0, 5).map((m) => {
const pm = Array.isArray(m.possibleMoves) && m.possibleMoves[0];
const oPos = pm?.origin?.position?.[workingGame.activePlayer?.direction || 'clockwise'];
const dPos = pm?.destination?.position?.[workingGame.activePlayer?.direction || 'clockwise'];
return { die: m?.dieValue, originPos: typeof oPos === 'number' ? oPos : null, destPos: typeof dPos === 'number' ? dPos : null, kind: m?.moveKind || pm?.moveKind };
}),
};
const outDir = path.join(process.cwd(), 'scripts', 'diagnostics');
const outFile = path.join(outDir, 'core-mismatch.log');
try {
fs.mkdirSync(outDir, { recursive: true });
}
catch { }
fs.appendFile(outFile, JSON.stringify(diag) + '\n', () => { });
}
}
catch { }
const prioritize = (m) => {
if (!Array.isArray(m.possibleMoves) || m.possibleMoves.length === 0)
return 3;
const mk = m.moveKind || m.possibleMoves[0]?.moveKind;
if (mk === 'bear-off')
return 0;
if (m.possibleMoves[0]?.isHit)
return 1;
return 2;
};
ready.sort((a, b) => prioritize(a) - prioritize(b));
originIdToUse = ready[0]?.possibleMoves?.[0]?.origin?.id ?? null;
}
}
if (!originIdToUse) {
// Nothing executable — ask core to complete the turn if possible
workingGame = CoreUtil.Game.checkAndCompleteTurn(workingGame);
// Build CORE legality snapshot
const dirSnap = workingGame.activePlayer?.direction || 'clockwise';
const barCnt = (workingGame.board?.bar?.[dirSnap]?.checkers || []).length;
const offCnt = (workingGame.board?.off?.[dirSnap]?.checkers || []).length;
const sample = [];
for (const m of ready) {
if (!Array.isArray(m.possibleMoves) || m.possibleMoves.length === 0)
continue;
const pm = m.possibleMoves[0];
const o = pm?.origin;
const d = pm?.destination;
const oPos = o?.position ? o.position[dirSnap] : null;
const dPos = d?.position ? d.position[dirSnap] : null;
sample.push({ die: m?.dieValue, originPos: typeof oPos === 'number' ? oPos : null, destPos: typeof dPos === 'number' ? dPos : null, kind: m?.moveKind || pm?.moveKind });
if (sample.length >= 5)
break;
}
telemetry.push({
step: 8 - guard,
positionId,
roll,
rollSource,
singleDieRemaining: ready.length === 1,
planLength,
planIndex: planIdx,
planSource: 'turn-plan',
hintCount: planLength > 0 ? 1 : 0,
mappedOriginId,
usedFallback: true,
fallbackReason: 'no-executable-origin',
postState: workingGame.stateKind,
plannedFrom,
plannedTo,
plannedKind,
legalOriginIds,
mappingStrategy: mappedOriginId ? 'id' : 'none',
mappingOutcome: 'no-legal',
activeDirection: dirSnap,
barCount: barCnt,
offCount: offCnt,
readyMovesSample: sample,
});
logger.info('[AI] Fallback completion: no executable origin', {
positionId,
roll,
planLength,
planIndex: planIdx,
postState: workingGame.stateKind,
});
break;
}
// Ensure dice order consumes the intended die first (avoid CORE picking the other die)
try {
const cr = (workingGame.activePlayer?.dice?.currentRoll || []);
if (typeof expectedDie === 'number' &&
Array.isArray(cr) &&
cr.length === 2 &&
cr[0] !== cr[1] &&
cr[1] === expectedDie &&
cr[0] !== expectedDie) {
workingGame = CoreUtil.Game.switchDice(workingGame);
}
}
catch { }
// Execute via core to ensure correctness and win checks
workingGame = CoreUtil.Game.executeAndRecalculate(workingGame, originIdToUse);
// Build CORE legality snapshot for telemetry
const dirSnap2 = workingGame.activePlayer?.direction || 'clockwise';
const barCnt2 = (workingGame.board?.bar?.[dirSnap2]?.checkers || []).length;
const offCnt2 = (workingGame.board?.off?.[dirSnap2]?.checkers || []).length;
const sample2 = [];
for (const m of ready) {
if (!Array.isArray(m.possibleMoves) || m.possibleMoves.length === 0)
continue;
const pm = m.possibleMoves[0];
const o = pm?.origin;
const d = pm?.destination;
const oPos = o?.position ? o.position[dirSnap2] : null;
const dPos = d?.position ? d.position[dirSnap2] : null;
sample2.push({ die: m?.dieValue, originPos: typeof oPos === 'number' ? oPos : null, destPos: typeof dPos === 'number' ? dPos : null, kind: m?.moveKind || pm?.moveKind });
if (sample2.length >= 5)
break;
}
telemetry.push({
step: 8 - guard,
positionId,
roll,
rollSource,
singleDieRemaining: ready.length === 1,
planLength,
planIndex: planIdx,
planSource: 'turn-plan',
hintCount: planLength > 0 ? 1 : 0,
mappedOriginId,
usedFallback,
fallbackReason,
postState: workingGame.stateKind,
plannedFrom,
plannedTo,
plannedKind,
legalOriginIds,
mappingStrategy: mappedOriginId
? (isPlanOriginLegal ? 'id' : (originIdToUse && originIdToUse === mappedOriginId ? 'position' : 'rehint'))
: 'none',
mappingOutcome: usedFallback
? (mappedOriginId ? 'id-miss' : 'no-origin')
: (mappedOriginId ? ((isPlanOriginLegal || (originIdToUse && originIdToUse === mappedOriginId)) ? 'ok' : 'ok-rehint') : 'no-origin'),
expectedDie: expectedDie,
matchedDie: matchedDie,
activeDirection: dirSnap2,
barCount: barCnt2,
offCount: offCnt2,
readyMovesSample: sample2,
});
logger.info('[AI] Step executed (turn-plan)', {
positionId,
roll,
planLength,
planIndex: planIdx,
mappedOriginId,
usedFallback,
fallbackReason,
postState: workingGame.stateKind,
});
if (!usedFallback && stepFromPlan) {
planIdx += 1;
}
if (workingGame.stateKind === 'completed')
break;
}
const result = workingGame;
if (aiFallbackUsed || fallbackReasons.length > 0) {
const primaryReason = (fallbackReasons[0] || 'unknown');
const info = {
reasonCode: primaryReason,
reasonText: primaryReason === 'plan-origin-not-legal'
? 'Planned origin not legal under current READY set'
: primaryReason === 'core-move-mismatch'
? 'GNU planned step not present in CORE READY set (position/kind/die)'
: primaryReason === 'mapping-failed'
? 'Failed to map GNU step to Nodots containers'
: primaryReason === 'no-gnu-hints' || primaryReason === 'no-gnu-hints-or-mapping-failed'
? 'GNU returned no hints or mapping failed'
: 'AI fallback was used',
};
Object.defineProperty(result, '__aiFallback', {
value: info,
enumerable: false,
configurable: true,
});
}
Object.defineProperty(result, '__aiTelemetry', {
value: telemetry,
enumerable: false,
configurable: true,
});
if (fallbackReasons.length > 0) {
Object.defineProperty(result, '__aiFallbackReasons', {
value: fallbackReasons,
enumerable: false,
configurable: true,
});
}
return result;
};