From 0a3aa6c9a373dcb74bc7acedd17bdb6945869055 Mon Sep 17 00:00:00 2001 From: Ken Riley Date: Tue, 21 Apr 2026 14:40:17 -0600 Subject: [PATCH 1/2] test(ai): assert analyzers do not consume dice entropy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a test that installs a spy on Dice.rollDie (from @nodots/backgammon-core) and verifies every shipped analyzer leaves it untouched during selectMove. Covers randomMoveAnalyzer, furthestFromOffMoveAnalyzer, nodotsAIMoveAnalyzer, examplePluginAnalyzer, and gnubgMoveAnalyzer (with @nodots/gnubg-hints mocked per the existing pluginAnalyzers.test.ts pattern). The invariant: move selection reads the legal-move set and the board but does not roll dice. An analyzer that calls Dice.rollDie during selection either (a) peeks at an upcoming opponent roll or (b) advances the RNG in a way that biases subsequent game rolls — both of which would regress the fairness story in Paper 12 (§9.1). A baseline-sanity test confirms the spy fires when Dice.rollDie is invoked directly, guarding against silent passes if the spy setup breaks. Phase 1.2 of nodots/backgammon#327. Closes nodots/backgammon#331 --- src/__tests__/ai-independence.test.ts | 121 ++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 src/__tests__/ai-independence.test.ts diff --git a/src/__tests__/ai-independence.test.ts b/src/__tests__/ai-independence.test.ts new file mode 100644 index 0000000..749785c --- /dev/null +++ b/src/__tests__/ai-independence.test.ts @@ -0,0 +1,121 @@ +/** + * AI independence — analyzers must not consume dice entropy. + * + * The invariant: + * Move selection reads the legal-move set and the board; it does not + * roll dice. An analyzer that calls Dice.rollDie during selection + * either (a) peeks at an upcoming opponent roll, or (b) advances the + * RNG in a way that biases subsequent game rolls. Both are regressions + * against the fairness story in Paper 12. + * + * This test spies on Dice.rollDie from @nodots/backgammon-core and + * asserts every shipped analyzer leaves it untouched during selectMove. + * The test is deliberately defensive: no current analyzer calls rollDie. + * The purpose is to catch the regression at the moment a new analyzer + * introduces one. + */ +import path from 'path' +import { jest } from '@jest/globals' +import { fileURLToPath } from 'url' +import type { MoveHint } from '@nodots/gnubg-hints' + +let mockHints: MoveHint[] = [] +jest.unstable_mockModule('@nodots/gnubg-hints', () => ({ + GnuBgHints: { + initialize: jest.fn().mockResolvedValue(undefined), + configure: jest.fn(), + getMoveHints: jest.fn(async () => mockHints), + getDoubleHint: jest.fn(), + getTakeHint: jest.fn(), + shutdown: jest.fn(), + }, +})) + +const { loadAnalyzersFromPluginsDir } = await import('../pluginLoader.js') +const { Dice } = await import('@nodots/backgammon-core/dist/Dice/index.js') + +const moves = [ + { + id: '1', + player: {} as any, + dieValue: 6, + stateKind: 'ready', + moveKind: 'point-to-point', + origin: { position: { clockwise: 10, counterclockwise: 15 } }, + }, + { + id: '2', + player: {} as any, + dieValue: 3, + stateKind: 'ready', + moveKind: 'point-to-point', + origin: { position: { clockwise: 20, counterclockwise: 5 } }, + }, + { + id: '3', + player: {} as any, + dieValue: 1, + stateKind: 'ready', + moveKind: 'point-to-point', + origin: { position: { clockwise: 5, counterclockwise: 20 } }, + }, +] + +const hintRequest = { + board: { points: [], bar: {}, off: {} } as any, + dice: [6, 3] as [number, number], + cubeValue: 1, + cubeOwner: null, + matchScore: [0, 0] as [number, number], + matchLength: 0, + crawford: false, + jacoby: false, + beavers: false, +} + +describe('AI independence — analyzers do not consume dice entropy', () => { + const __dirname = path.dirname(fileURLToPath(import.meta.url)) + const pluginsDir = path.join(__dirname, '../../plugins') + let analyzers: Record = {} + let rollDieSpy: ReturnType + + beforeAll(async () => { + analyzers = await loadAnalyzersFromPluginsDir(pluginsDir) + }) + + beforeEach(() => { + rollDieSpy = jest.spyOn(Dice, 'rollDie') + }) + + afterEach(() => { + rollDieSpy.mockRestore() + }) + + const shippedAnalyzers = [ + 'randomMoveAnalyzer', + 'furthestFromOffMoveAnalyzer', + 'nodotsAIMoveAnalyzer', + 'examplePluginAnalyzer', + 'gnubgMoveAnalyzer', + ] + + it.each(shippedAnalyzers)( + '%s.selectMove does not call Dice.rollDie', + async (analyzerName) => { + const analyzer = analyzers[analyzerName] + expect(analyzer).toBeDefined() + + await analyzer.selectMove(moves as any, { + hintRequest, + positionId: '4HPwATDgc/ABMA', + }) + + expect(rollDieSpy).not.toHaveBeenCalled() + } + ) + + it('baseline sanity — the spy fires when Dice.rollDie is called directly', () => { + Dice.rollDie() + expect(rollDieSpy).toHaveBeenCalledTimes(1) + }) +}) From fb3eec398cd4f9a0a4d9345344625d68f89b3056 Mon Sep 17 00:00:00 2001 From: Ken Riley Date: Tue, 21 Apr 2026 14:46:33 -0600 Subject: [PATCH 2/2] fix(test): enable experimental VM modules for Jest ESM support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Jest config in this package sets extensionsToTreatAsEsm: ['.ts'] and useESM: true on the ts-jest transformer, which requires Node's --experimental-vm-modules flag for jest.unstable_mockModule and top-level await import() to work. Several existing tests (pluginAnalyzers.test.ts, ai-independence.test.ts, and others that mock @nodots/gnubg-hints) rely on both patterns and were silently failing in CI with "await is only valid in async functions and the top level bodies of modules" because the test script did not set the flag. Adds NODE_OPTIONS=--experimental-vm-modules to the test, test:watch, and test:coverage scripts. CI matrix is ubuntu-latest + macos-latest only, so plain shell-prefix form is portable; no cross-env needed. Verified: npm test -- src/__tests__/pluginAnalyzers.test.ts \ src/__tests__/ai-independence.test.ts → 10/10 tests pass, 2/2 suites. Nine other test suites still fail for pre-existing reasons unrelated to this flag (CJS resolver stack overflow, import-after-teardown). Those are separate concerns. --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index add572d..380b5df 100644 --- a/package.json +++ b/package.json @@ -31,9 +31,9 @@ "url": "https://github.com/nodots/backgammon-ai/issues" }, "scripts": { - "test": "jest", - "test:watch": "jest --watch", - "test:coverage": "jest --coverage", + "test": "NODE_OPTIONS=--experimental-vm-modules jest", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch", + "test:coverage": "NODE_OPTIONS=--experimental-vm-modules jest --coverage", "update-coverage": "node scripts/update-coverage.js", "lint": "eslint src/**/*.ts", "lint:fix": "eslint src/**/*.ts --fix",