From 1d763c826611e76b83b866d98d1d45d236fc5135 Mon Sep 17 00:00:00 2001 From: Ken Riley Date: Sun, 7 Jun 2026 14:28:43 -0600 Subject: [PATCH] fix: resolve GNU bear-off die ambiguity to the larger die MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A GNU bear-off step carries no die value and the matcher compares origin position only, so when both dice can bear off the same point the first ready move in roll order won. Core's must-use-larger-die rule rejects the smaller die when only one die can be played, so the robot's turn was rejected on every retry and the game froze. Production game 2d98cf14-e262-433c-baf5-95a2f878311d: one checker on the 2-point, 14 off, roll [3,5] — the matcher picked die 3. Sort ready moves by die value descending before matching. Point-to- point and reenter matching is die-unique (origin+destination determine the die), so the sort only affects the ambiguous bear-off case. Also heals pre-fix plays persisted with both moves still ready. Bump to 1.0.5. --- package.json | 2 +- .../robot-bearoff-larger-die.test.ts | 203 ++++++++++++++++++ src/robotExecution.ts | 9 +- 3 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/robot-bearoff-larger-die.test.ts diff --git a/package.json b/package.json index 18e175f..48705bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nodots/backgammon-ai", - "version": "1.0.4", + "version": "1.0.5", "description": "AI and integration for nodots-backgammon using the @nodots/gnubg-hints native addon.", "type": "module", "main": "dist/index.js", diff --git a/src/__tests__/robot-bearoff-larger-die.test.ts b/src/__tests__/robot-bearoff-larger-die.test.ts new file mode 100644 index 0000000..41a25d8 --- /dev/null +++ b/src/__tests__/robot-bearoff-larger-die.test.ts @@ -0,0 +1,203 @@ +/** + * Regression for production game 2d98cf14-e262-433c-baf5-95a2f878311d. + * + * The robot (black, clockwise) had one checker on its 2-point, 14 off, and + * rolled [3,5]. Either die bears off the last checker, so core computes + * maxDiceUsable = 1 with both dice playable at turn start — the rules then + * require the larger die (5). + * + * GNU's bearoff hint is a single step {from: 2, to: 0} with no die value. + * The matcher in robotExecution.ts matches bear-off steps on origin position + * only, so both the die-3 and die-5 ready moves match. It took the first + * ready move in roll order (die 3); core rejected the turn with + * MustUseLargerDieError on every retry, and the game froze in 'moving'. + * + * The fix sorts ready moves by die value descending before matching, so the + * ambiguous bear-off resolves to the larger die. Point-to-point and reenter + * matching is die-unique (origin+destination determine the die), so the sort + * has no effect there. + * + * The game fixture reproduces the stale persisted play: both moves ready, + * smaller die first — the shape written to the database before core started + * pruning the smaller-die move in Play.initialize. + */ + +import { beforeEach, describe, expect, it, jest } from '@jest/globals' + +let plannedHints: any[] = [] +let executeCalls: Array<{ originId: string; options?: any }> = [] +let currentReadyMoves: any[] = [] + +const initializeMock = jest.fn().mockResolvedValue(undefined) +const configureMock = jest.fn().mockResolvedValue(undefined) +const getHintsMock = jest.fn(() => Promise.resolve(plannedHints)) + +const executeAndRecalculateMock = jest.fn( + (game: any, originId: string, options?: any) => { + executeCalls.push({ originId, options }) + // Bearing off the last checker wins the game. + currentReadyMoves = [] + return { + ...game, + activePlay: { ...game.activePlay, moves: [] }, + stateKind: 'completed', + } + } +) +const checkAndCompleteTurnMock = jest.fn((game: any) => ({ + ...game, + stateKind: 'moved', +})) +const handleRobotMovedStateMock = jest.fn((game: any) => ({ + ...game, + stateKind: 'rolling', +})) +const exportToGnuPositionIdMock = jest.fn(() => '37sEIAACAAAAAA') + +jest.unstable_mockModule('@nodots/gnubg-hints', () => ({ + GnuBgHints: { + initialize: initializeMock, + configure: configureMock, + getHintsFromPositionId: getHintsMock, + }, +})) + +const noopLogger = { + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, +} + +const getPossibleMovesMock = jest.fn( + (_board: any, _player: any, dieValue: any) => { + const match = currentReadyMoves.find((m) => m?.dieValue === dieValue) + return match?.possibleMoves || [] + } +) + +jest.unstable_mockModule('@nodots/backgammon-core', () => ({ + Board: { getPossibleMoves: getPossibleMovesMock }, + Game: { + executeAndRecalculate: executeAndRecalculateMock, + checkAndCompleteTurn: checkAndCompleteTurnMock, + handleRobotMovedState: handleRobotMovedStateMock, + }, + exportToGnuPositionId: exportToGnuPositionIdMock, + logger: noopLogger, +})) + +const { executeRobotTurnWithGNU } = await import('../robotExecution.js') + +const buildLastCheckerGame = () => { + // Black is clockwise: GNU `from` values are clockwise position numbers. + const point2 = { + id: 'point-cw2', + kind: 'point', + position: { clockwise: 2, counterclockwise: 23 }, + checkers: [{ color: 'black' }], + } + const offCw = { id: 'off-cw', kind: 'off', checkers: [] } + const offCcw = { id: 'off-ccw', kind: 'off', checkers: [] } + + // Stale persisted play: smaller die first, both ready, both bear off the + // same checker — exactly what the database held for game 2d98cf14. + const die3Move = { + id: 'm-die3', + stateKind: 'ready', + moveKind: 'bear-off', + dieValue: 3, + possibleMoves: [{ origin: point2, destination: offCw, dieValue: 3 }], + } + const die5Move = { + id: 'm-die5', + stateKind: 'ready', + moveKind: 'bear-off', + dieValue: 5, + possibleMoves: [{ origin: point2, destination: offCw, dieValue: 5 }], + } + + const activePlayer = { + id: 'player-black', + color: 'black', + direction: 'clockwise', + stateKind: 'moving', + isRobot: true, + dice: { stateKind: 'rolled', currentRoll: [3, 5] }, + } + + currentReadyMoves = [die3Move, die5Move] + + return { + id: 'game-2d98cf14', + stateKind: 'moving', + board: { + id: 'board-1', + points: Array.from({ length: 24 }, (_, i) => { + if (i + 1 === 2) return point2 + return { + id: `point-${i + 1}`, + kind: 'point', + position: { clockwise: i + 1, counterclockwise: 25 - (i + 1) }, + checkers: [], + } + }), + bar: { + clockwise: { id: 'bar-cw', kind: 'bar', checkers: [] }, + counterclockwise: { id: 'bar-ccw', kind: 'bar', checkers: [] }, + }, + off: { clockwise: offCw, counterclockwise: offCcw }, + }, + activeColor: 'black', + activePlayer, + activePlay: { + id: 'play-1', + stateKind: 'moving', + player: activePlayer, + moves: [die3Move, die5Move], + }, + players: [activePlayer], + } +} + +beforeEach(() => { + plannedHints = [] + executeCalls = [] + currentReadyMoves = [] + executeAndRecalculateMock.mockClear() + getHintsMock.mockClear() +}) + +describe('last-checker bear-off resolves the GNU die ambiguity to the larger die', () => { + it('executes the single planned bear-off with die 5, not die 3', async () => { + plannedHints = [ + { + equity: 2.0185277462005615, + moves: [ + { + from: 2, + to: 0, + moveKind: 'bear-off', + player: 'black', + isHit: false, + fromContainer: 'point', + toContainer: 'off', + }, + ], + }, + ] + + const game = buildLastCheckerGame() + const result = await executeRobotTurnWithGNU(game as any) // cast: fixture stands in for BackgammonGameMoving + + expect(result.stateKind).toBe('completed') + expect(executeCalls).toHaveLength(1) + expect(executeCalls[0].originId).toBe('point-cw2') + expect(executeCalls[0].options?.expectedDieValue).toBe(5) + + // cast: telemetry is attached as a non-enumerable property + const telemetry = (result as any).__aiTelemetry + expect(telemetry).toHaveLength(1) + expect(telemetry[0].executedDieValue).toBe(5) + }) +}) diff --git a/src/robotExecution.ts b/src/robotExecution.ts index 03b25d7..c333211 100644 --- a/src/robotExecution.ts +++ b/src/robotExecution.ts @@ -271,7 +271,14 @@ export const executeRobotTurnWithGNU = async ( while (guard-- > 0 && workingGame.stateKind === 'moving') { const moves = (workingGame.activePlay?.moves || []) as any[] - const ready = moves.filter((m) => m.stateKind === 'ready') + // Larger die first: a GNU bear-off step matches on origin position only, + // so when both dice can bear off the same point the first match wins. + // Core's must-use-larger-die rule requires the larger die when only one + // die can be played (production game 2d98cf14 froze on this). Point-to- + // point and reenter matching is die-unique, so order has no effect there. + const ready = moves + .filter((m) => m.stateKind === 'ready') + .sort((a, b) => (b.dieValue ?? 0) - (a.dieValue ?? 0)) // Debug: log start of iteration fs.appendFileSync('/tmp/exec-debug.json', JSON.stringify({ startOfIteration: true,