UNPKG

@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
/** * 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; };