From 11bd932b07b3809d17d949dd5e7b470efa39b696 Mon Sep 17 00:00:00 2001 From: Ken Riley Date: Wed, 29 Jul 2026 18:43:42 -0600 Subject: [PATCH 1/3] XGID <-> GNU position id converter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds src/XGID: bidirectional conversion between XGID strings and GNU Backgammon position ids, for the BGBlitz plugin-protocol bridge (nodots/backgammon#426). Both XGID dialects are supported and the dialect is a required argument everywhere. There is no in-band marker distinguishing them, and guessing corrupts the cube on every position: a field value v means cube v in the Berger dialect and 2**v in the canonical one, and v === 2**v has no solution. The corruption is also invisible in checker-play tests, since cube value barely affects best checker play. The overloaded rules field is decoded against matchLength — it means Crawford in a match and Jacoby/beavers in money play — and Draft 0.02's inability to express a pending double is surfaced rather than papered over: parseXgid rejects 'D' in that dialect and formatXgid refuses to emit it. Note that encodeGnuPositionId here is on-roll-first, the ordering gnubg itself produces, while Board/gnuPositionId.ts is opponent-first. Both now live in core; neither is changed by this commit. See the header comment in src/XGID for why. Verified: 43/43 pass, including six differential checks against gnubg over 10,000-board corpora (encoder, decoder, mutual inverses, and the XGID bridge in both turn directions). The differential suite skips when GNUBG_ADDON/GNUBG_WEIGHTS are unset, and was mutation-tested — flipping the bit order fails 9 of 43 rather than passing. --- src/XGID/__tests__/crossValidate.test.ts | 236 +++++++++ src/XGID/__tests__/xgid.test.ts | 314 ++++++++++++ src/XGID/index.ts | 596 +++++++++++++++++++++++ src/index.ts | 4 + 4 files changed, 1150 insertions(+) create mode 100644 src/XGID/__tests__/crossValidate.test.ts create mode 100644 src/XGID/__tests__/xgid.test.ts create mode 100644 src/XGID/index.ts diff --git a/src/XGID/__tests__/crossValidate.test.ts b/src/XGID/__tests__/crossValidate.test.ts new file mode 100644 index 0000000..2c6e389 --- /dev/null +++ b/src/XGID/__tests__/crossValidate.test.ts @@ -0,0 +1,236 @@ +// Differential test against GNU Backgammon itself. +// +// The converter fails silently when it fails: a wrong board is still a legal +// board, and an engine will happily return a good move for the wrong position. +// Inspection does not catch that, so the position-id half is checked against +// gnubg's own encoder and decoder over a large corpus, and the XGID half is +// checked end-to-end by asking gnubg for a play and verifying it is legal on the +// board we believe we encoded. +// +// Skips rather than passing when the addon is absent: +// +// GNUBG_ADDON=/path/to/build/Release/gnubg_hints.node \ +// GNUBG_WEIGHTS=/path/to/gnubg.wd \ +// npx jest src/XGID +// +// CORPUS_SIZE defaults to 10000. + +import { existsSync } from 'fs' +import { + decodeGnuPositionId, + emptyXgidBoard, + encodeGnuPositionId, + formatXgid, + parseXgid, + XgidBoard, + xgidToPositionId, +} from '../index' + +const ADDON_PATH = process.env.GNUBG_ADDON +const WEIGHTS_PATH = process.env.GNUBG_WEIGHTS +const available = Boolean( + ADDON_PATH && existsSync(ADDON_PATH) && WEIGHTS_PATH +) +const CORPUS_SIZE = Number(process.env.CORPUS_SIZE ?? 10000) + +/** The addon is a native module with no type declarations, hence the loose shape. */ +interface GnubgAddon { + initialize: (weights: string, cb: (err: unknown) => void) => void + shutdown: () => void + getPositionId: (board: XgidBoard) => string + decodePositionId: (id: string) => [ArrayLike, ArrayLike] + getMoveHints: ( + request: Record, + ply: number, + cb: (err: unknown, res: Array<{ moves?: number[][] }>) => void + ) => void +} + +const call = (fn: (cb: (err: unknown, res: T) => void) => void): Promise => + new Promise((resolve, reject) => + fn((err, res) => (err ? reject(err) : resolve(res))) + ) + +let addon: GnubgAddon + +// Deterministic PRNG: the corpus is reproducible from the seed alone. +let seed = 0x9e3779b1 +function rnd(n: number): number { + seed ^= seed << 13 + seed >>>= 0 + seed ^= seed >> 17 + seed ^= seed << 5 + seed >>>= 0 + return seed % n +} + +/** + * A legal, arbitrary board. The two sides are mirrored, so a physical point is + * claimed by at most one of them. Checkers not placed are treated as borne off, + * which is legal and exercises the short-bitstream path. + */ +function makeBoard(): XgidBoard { + const board = emptyXgidBoard() + const owner = new Array(24).fill(-1) + const remaining = [15, 15] + + for (const side of [0, 1]) { + if (rnd(5) === 0) { + const n = 1 + rnd(3) + board[side][24] = n + remaining[side] -= n + } + } + + let guard = 0 + while ((remaining[0] > 0 || remaining[1] > 0) && guard++ < 6000) { + const side = remaining[0] > 0 && (remaining[1] === 0 || rnd(2) === 0) ? 0 : 1 + const idx = rnd(24) + const physical = side === 0 ? idx : 23 - idx + if (owner[physical] !== -1 && owner[physical] !== side) continue + if (board[side][idx] >= 15) continue + const n = Math.min(1 + rnd(3), remaining[side]) + board[side][idx] += n + owner[physical] = side + remaining[side] -= n + } + // Occasionally leave checkers off entirely. + if (rnd(4) === 0) { + const side = rnd(2) + const idx = rnd(24) + board[side][idx] = 0 + } + return board +} + +const describeIf = available ? describe : describe.skip + +describeIf('differential against gnubg', () => { + beforeAll(async () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + addon = require(ADDON_PATH as string) // narrowed by `available` + await call((cb) => addon.initialize(WEIGHTS_PATH as string, cb)) + }, 60000) + + afterAll(() => { + if (addon) addon.shutdown() + }) + + it(`encodeGnuPositionId matches gnubg over ${CORPUS_SIZE} boards`, () => { + const mismatches: unknown[] = [] + for (let i = 0; i < CORPUS_SIZE; i++) { + const board = makeBoard() + const mine = encodeGnuPositionId(board) + const theirs = addon.getPositionId(board) + if (mine !== theirs) mismatches.push({ i, mine, theirs, board }) + if (mismatches.length >= 3) break + } + expect(mismatches).toEqual([]) + }, 120000) + + it(`decodeGnuPositionId matches gnubg over ${CORPUS_SIZE} boards`, () => { + const mismatches: unknown[] = [] + for (let i = 0; i < CORPUS_SIZE; i++) { + const board = makeBoard() + const id = addon.getPositionId(board) + const mine = decodeGnuPositionId(id) + const theirs = addon.decodePositionId(id) + const same = + JSON.stringify(mine[0]) === JSON.stringify([...theirs[0]]) && + JSON.stringify(mine[1]) === JSON.stringify([...theirs[1]]) + if (!same) { + mismatches.push({ + i, + id, + mine, + theirs: [[...theirs[0]], [...theirs[1]]], + }) + } + if (mismatches.length >= 3) break + } + expect(mismatches).toEqual([]) + }, 120000) + + it('encode and decode are mutual inverses across the corpus', () => { + for (let i = 0; i < CORPUS_SIZE; i++) { + const board = makeBoard() + const back = decodeGnuPositionId(encodeGnuPositionId(board)) + expect(back).toEqual(board) + } + }, 120000) + + it('an XGID converts to a position id gnubg reads as the same board', () => { + // Build an XGID from a board, convert it to a position id with our bridge, + // and let gnubg decode that id. With turn=1 the lowercase side leads, so + // gnubg's decoded side 0 must equal the XGID's lowercase side. + for (let i = 0; i < 500; i++) { + const board = makeBoard() + const text = formatXgid( + { + board, + cubeValue: 1, + cubeOwner: 0, + turn: 1, + dice: { kind: 'roll', dice: [3, 1] }, + score: [0, 0], + matchLength: 0, + crawford: false, + jacoby: false, + beavers: false, + maxCube: 0, + }, + 'canonical' + ) + const reparsed = parseXgid(text, 'canonical') + const positionId = xgidToPositionId(reparsed) + const viaGnubg = addon.decodePositionId(positionId) + expect([...viaGnubg[0]]).toEqual(board[0]) + expect([...viaGnubg[1]]).toEqual(board[1]) + } + }, 120000) + + it('turn = -1 makes gnubg read the uppercase side as on roll', () => { + for (let i = 0; i < 500; i++) { + const board = makeBoard() + const positionId = xgidToPositionId({ board, turn: -1 }) + const viaGnubg = addon.decodePositionId(positionId) + expect([...viaGnubg[0]]).toEqual(board[1]) + expect([...viaGnubg[1]]).toEqual(board[0]) + } + }, 120000) + + it('gnubg returns plays that are legal on the board we encoded', async () => { + let checked = 0 + for (let i = 0; i < 120; i++) { + const board = makeBoard() + // The on-roll side needs a checker somewhere to have a play at all. + if (board[0].reduce((a, b) => a + b, 0) === 0) continue + const positionId = xgidToPositionId({ board, turn: 1 }) + const hints = await call>((cb) => + addon.getMoveHints( + { + positionId, + dice: [3, 1], + cubeValue: 1, + cubeOwner: null, + matchScore: [0, 0], + matchLength: 0, + crawford: false, + jacoby: true, + beavers: false, + }, + 1, + cb + ) + ) + const moves = hints?.[0]?.moves + if (!moves?.length) continue + const [from] = moves[0] + // 24 is the bar in the addon's indexing. Either way the origin must be a + // point the on-roll side actually occupies on OUR board. + expect(board[0][from] ?? 0).toBeGreaterThan(0) + checked++ + } + expect(checked).toBeGreaterThanOrEqual(40) + }, 300000) +}) diff --git a/src/XGID/__tests__/xgid.test.ts b/src/XGID/__tests__/xgid.test.ts new file mode 100644 index 0000000..40c03ee --- /dev/null +++ b/src/XGID/__tests__/xgid.test.ts @@ -0,0 +1,314 @@ +import { + decodeGnuPositionId, + emptyXgidBoard, + encodeGnuPositionId, + formatXgid, + parseXgid, + positionIdToXgid, + splitXgid, + swapXgidSides, + Xgid, + XgidBoard, + XgidDialect, + XgidError, + xgidOnRollSide, + xgidToPositionId, +} from '../index' + +/** The standard opening position, in gnubg's own per-side numbering. */ +function openingBoard(): XgidBoard { + const board = emptyXgidBoard() + for (const side of board) { + side[5] = 5 + side[7] = 3 + side[12] = 5 + side[23] = 2 + } + return board +} + +const OPENING_POSITION_ID = '4HPwATDgc/ABMA' + +describe('position id', () => { + it('encodes the standard opening to the canonical id', () => { + expect(encodeGnuPositionId(openingBoard())).toBe(OPENING_POSITION_ID) + }) + + it('round-trips the standard opening', () => { + expect(decodeGnuPositionId(OPENING_POSITION_ID)).toEqual(openingBoard()) + }) + + it('is always 14 characters', () => { + expect(encodeGnuPositionId(emptyXgidBoard())).toHaveLength(14) + expect(encodeGnuPositionId(openingBoard())).toHaveLength(14) + }) + + it('side 0 is serialized first', () => { + // A single checker for side 0 versus the same for side 1 must differ, and + // swapping the sides of a board must change its id. + const a = emptyXgidBoard() + a[0][0] = 1 + const b = emptyXgidBoard() + b[1][0] = 1 + expect(encodeGnuPositionId(a)).not.toBe(encodeGnuPositionId(b)) + expect(encodeGnuPositionId(openingBoard())).not.toBe( + encodeGnuPositionId(swapXgidSides(a)) + ) + }) + + it('bits are written least-significant-first', () => { + // One checker on side 0 point 1 sets bit 0, which is 0x01 -> "AQ...". + const board = emptyXgidBoard() + board[0][0] = 1 + expect(encodeGnuPositionId(board)).toBe('AQAAAAAAAAAAAA') + }) + + it('rejects an id of the wrong length', () => { + expect(() => decodeGnuPositionId('4HPwATDgc/ABM')).toThrow(XgidError) + expect(() => decodeGnuPositionId('4HPwATDgc/ABMAA')).toThrow(XgidError) + }) + + it('rejects a non-base64 character', () => { + expect(() => decodeGnuPositionId('4HPwATDgc/ABM!')).toThrow(XgidError) + }) + + it('rejects a board with more than 15 checkers on a side', () => { + const board = emptyXgidBoard() + board[0][0] = 16 + expect(() => encodeGnuPositionId(board)).toThrow(XgidError) + }) + + it('rejects both sides occupying one physical point', () => { + const board = emptyXgidBoard() + board[0][0] = 1 + board[1][23] = 1 // the same physical point + expect(() => encodeGnuPositionId(board)).toThrow(XgidError) + }) +}) + +describe('XGID parsing', () => { + // The worked example from Open Backgammon Plugin Protocol Draft 0.02. + const CANONICAL = 'XGID=-a-B--E-B-a-dDB--b-bcb----:1:1:-1:63:0:0:0:3:8' + const BERGER = '-a-B--E-B-a-dDB--b-bcb----:2:1:-1:63:0:0:0:3' + + it('parses the canonical worked example', () => { + const xgid = parseXgid(CANONICAL, 'canonical') + expect(xgid.cubeValue).toBe(2) // cube field 1 is a logarithm + expect(xgid.cubeOwner).toBe(1) + expect(xgid.turn).toBe(-1) + expect(xgid.dice).toEqual({ kind: 'roll', dice: [6, 3] }) + expect(xgid.score).toEqual([0, 0]) + expect(xgid.matchLength).toBe(3) + expect(xgid.crawford).toBe(false) + expect(xgid.maxCube).toBe(8) + }) + + it('both sides have 15 checkers in the worked example', () => { + const { board } = parseXgid(CANONICAL, 'canonical') + expect(board[0].reduce((a, b) => a + b, 0)).toBe(15) + expect(board[1].reduce((a, b) => a + b, 0)).toBe(15) + }) + + it("reproduces Draft 0.02's stated canonical-to-berger conversion", () => { + const xgid = parseXgid(CANONICAL, 'canonical') + expect(formatXgid(xgid, 'berger')).toBe(BERGER) + }) + + it('the berger dialect reads the cube field literally', () => { + expect(parseXgid(BERGER, 'berger').cubeValue).toBe(2) + }) + + it('the same field means different cubes in the two dialects', () => { + const canonical = parseXgid( + `${'-'.repeat(26)}:2:0:1:31:0:0:0:0:0`, + 'canonical' + ) + const berger = parseXgid(`${'-'.repeat(26)}:2:0:1:31:0:0:0:0`, 'berger') + expect(canonical.cubeValue).toBe(4) + expect(berger.cubeValue).toBe(2) + }) + + it('round-trips both dialects', () => { + const cases: Array<[string, XgidDialect]> = [ + [CANONICAL.slice('XGID='.length), 'canonical'], + [BERGER, 'berger'], + ] + for (const [text, dialect] of cases) { + expect(formatXgid(parseXgid(text, dialect), dialect)).toBe(text) + } + }) + + it('accepts the XGID= prefix and can re-emit it', () => { + const xgid = parseXgid(CANONICAL, 'canonical') + expect(formatXgid(xgid, 'canonical', { prefix: true })).toBe(CANONICAL) + }) +}) + +describe('the overloaded rules field', () => { + const pos = '-'.repeat(26) + + it('means Crawford in match play', () => { + expect(parseXgid(`${pos}:0:0:1:31:0:0:1:7:0`, 'canonical').crawford).toBe( + true + ) + expect(parseXgid(`${pos}:0:0:1:31:0:0:0:7:0`, 'canonical').crawford).toBe( + false + ) + }) + + it('means Jacoby and beavers in money play', () => { + const at = (rules: number): Xgid => + parseXgid(`${pos}:0:0:1:31:0:0:${rules}:0:0`, 'canonical') + expect([at(0).jacoby, at(0).beavers]).toEqual([false, false]) + expect([at(1).jacoby, at(1).beavers]).toEqual([true, false]) + expect([at(2).jacoby, at(2).beavers]).toEqual([false, true]) + expect([at(3).jacoby, at(3).beavers]).toEqual([true, true]) + }) + + it('the same field value yields different flags at different match lengths', () => { + // rules=1 is Crawford in a match and Jacoby in money play. + const match = parseXgid(`${pos}:0:0:1:31:0:0:1:7:0`, 'canonical') + const money = parseXgid(`${pos}:0:0:1:31:0:0:1:0:0`, 'canonical') + expect(match.crawford).toBe(true) + expect(match.jacoby).toBe(false) + expect(money.crawford).toBe(false) + expect(money.jacoby).toBe(true) + }) + + it('rejects rules=2 in match play', () => { + expect(() => + parseXgid(`${pos}:0:0:1:31:0:0:2:7:0`, 'canonical') + ).toThrow(XgidError) + }) + + it('rejects rules=4 in money play', () => { + expect(() => + parseXgid(`${pos}:0:0:1:31:0:0:4:0:0`, 'canonical') + ).toThrow(XgidError) + }) +}) + +describe('dice field', () => { + const pos = '-'.repeat(26) + const canonical = (dice: string): Xgid => + parseXgid(`${pos}:0:0:1:${dice}:0:0:0:0:0`, 'canonical') + + it('reads a roll', () => { + expect(canonical('63').dice).toEqual({ kind: 'roll', dice: [6, 3] }) + }) + + it("reads canonical 'D' as a pending double", () => { + expect(canonical('D').dice).toEqual({ kind: 'doubled' }) + }) + + it("reads '00' as a cube decision", () => { + expect(canonical('00').dice).toEqual({ kind: 'cube-decision' }) + }) + + it("the berger dialect has no 'D' — Draft 0.02 cannot express a pending double", () => { + expect(() => parseXgid(`${pos}:1:0:1:D:0:0:0:0`, 'berger')).toThrow( + XgidError + ) + expect(() => + formatXgid({ ...canonical('D'), cubeValue: 1 }, 'berger') + ).toThrow(XgidError) + }) + + it('rejects a zero or seven in a roll', () => { + expect(() => canonical('70')).toThrow(XgidError) + expect(() => canonical('07')).toThrow(XgidError) + }) +}) + +describe('field-count and range validation', () => { + const pos = '-'.repeat(26) + + it('rejects the wrong field count for the dialect', () => { + expect(() => parseXgid(`${pos}:0:0:1:31:0:0:0:0`, 'canonical')).toThrow( + XgidError + ) + expect(() => parseXgid(`${pos}:0:0:1:31:0:0:0:0:0`, 'berger')).toThrow( + XgidError + ) + }) + + it('rejects a position field that is not 26 characters', () => { + expect(() => + parseXgid(`${'-'.repeat(25)}:0:0:1:31:0:0:0:0:0`, 'canonical') + ).toThrow(XgidError) + expect(() => + parseXgid(`${'-'.repeat(27)}:0:0:1:31:0:0:0:0:0`, 'canonical') + ).toThrow(XgidError) + }) + + it('rejects a score at or above the match length', () => { + expect(() => parseXgid(`${pos}:0:0:1:31:7:0:0:7:0`, 'canonical')).toThrow( + XgidError + ) + expect(() => parseXgid(`${pos}:0:0:1:31:0:9:0:7:0`, 'canonical')).toThrow( + XgidError + ) + }) + + it('allows any score in money play', () => { + expect( + parseXgid(`${pos}:0:0:1:31:9:9:0:0:0`, 'canonical').matchLength + ).toBe(0) + }) + + it('rejects a cube value that is not a power of two', () => { + expect(() => parseXgid(`${pos}:3:0:1:31:0:0:0:0`, 'berger')).toThrow( + XgidError + ) + }) + + it('rejects an out-of-range cube owner', () => { + expect(() => parseXgid(`${pos}:0:2:1:31:0:0:0:0:0`, 'canonical')).toThrow( + XgidError + ) + }) + + it('rejects a letter of the wrong case on either bar character', () => { + const bad0 = `A${'-'.repeat(25)}:0:0:1:31:0:0:0:0:0` + const bad25 = `${'-'.repeat(25)}a:0:0:1:31:0:0:0:0:0` + expect(() => parseXgid(bad0, 'canonical')).toThrow(XgidError) + expect(() => parseXgid(bad25, 'canonical')).toThrow(XgidError) + }) +}) + +describe('the bridge', () => { + const pos = '-'.repeat(26) + + it('turn 1 selects the lowercase side, anything else the uppercase side', () => { + expect(xgidOnRollSide({ turn: 1 })).toBe(0) + expect(xgidOnRollSide({ turn: -1 })).toBe(1) + }) + + it('turn decides which side leads the position id', () => { + const board = emptyXgidBoard() + board[0][0] = 1 + const asLower = xgidToPositionId({ board, turn: 1 }) + const asUpper = xgidToPositionId({ board, turn: -1 }) + expect(asLower).not.toBe(asUpper) + expect(asLower).toBe(encodeGnuPositionId(board)) + expect(asUpper).toBe(encodeGnuPositionId(swapXgidSides(board))) + }) + + it('position id and XGID round-trip through each other', () => { + const text = '-a-B--E-B-a-dDB--b-bcb----:1:1:-1:63:0:0:0:3:8' + const xgid = parseXgid(text, 'canonical') + const { context } = splitXgid(xgid) + const positionId = xgidToPositionId(xgid) + expect(positionIdToXgid(positionId, context, 'canonical')).toBe(text) + }) + + it('the round-trip holds for both turn values', () => { + for (const turn of [1, -1]) { + const text = `${pos.slice(0, 25)}A:0:0:${turn}:31:0:0:0:0:0` + const xgid = parseXgid(text, 'canonical') + const { context } = splitXgid(xgid) + const positionId = xgidToPositionId(xgid) + expect(positionIdToXgid(positionId, context, 'canonical')).toBe(text) + } + }) +}) diff --git a/src/XGID/index.ts b/src/XGID/index.ts new file mode 100644 index 0000000..0df6448 --- /dev/null +++ b/src/XGID/index.ts @@ -0,0 +1,596 @@ +/** + * Conversion between XGID strings and GNU Backgammon position ids. + * + * Zero dependencies on the rest of core, and no GPL-derived content: the format + * details come from `docs/spec-xgid-gnu-position-id.md` in the workspace, not + * from gnubg sources. + * + * The two identifiers carry different amounts of information and use different + * frames of reference, which is most of what makes this non-trivial: + * + * - A **position id** encodes the board only, and encodes it *relative to the + * player on roll*: the first-serialized side is the side to move. It carries + * no turn marker, no cube, no score. + * - An **XGID** encodes the board in an absolute frame (one side is lowercase, + * the other uppercase) plus the cube, dice, score, match length and rules, + * and names the player on roll in a separate field. + * + * So converting an XGID to a position id means selecting the on-roll side and + * reordering the board accordingly; converting back requires supplying the turn + * and everything else from outside. + * + * ## Relationship to `exportToGnuPositionId` + * + * Core contains a second GNU position id encoder, `Board/gnuPositionId.ts`, and + * the two use **opposite side orderings**. {@link encodeGnuPositionId} here is + * `on-roll-first` — the ordering gnubg itself produces, established empirically + * against the compiled addon rather than by reading its sources. Core's + * `exportToGnuPositionId` serializes the opponent first, i.e. `opponent-first`, + * which is the engine protocol's default `positionIdConvention`. Neither is + * "the" encoder: an id is only meaningful alongside the convention it was + * written in. Do not assume they are interchangeable. + */ + +/** Checker counts for one side: indices 0..23 are that side's points 1..24, index 24 is its bar. */ +export type XgidSide = number[] + +/** + * A board as two sides. + * + * The two sides are mirrored: `board[0][i]` and `board[1][23 - i]` name the same + * physical point, so at most one of them may be non-zero. + */ +export type XgidBoard = [XgidSide, XgidSide] + +/** Which side of a decoded XGID board is on roll. */ +export type XgidSideIndex = 0 | 1 + +/** `1` = uppercase side owns the cube, `-1` = lowercase side, `0` = centred. */ +export type XgidCubeOwner = -1 | 0 | 1 + +/** + * XGID dialects. + * + * - `canonical` — as produced by eXtreme Gammon and accepted by gnubg: ten + * fields after the position, cube encoded as a base-2 logarithm, trailing + * max-cube field present. + * - `berger` — the dialect in Frank Berger's Open Backgammon Plugin Protocol + * (Draft 0.02): nine fields, cube encoded as its literal value, no max-cube, + * and `00` in the dice field to request a cube decision. + * + * There is no in-band marker distinguishing them, so the dialect is always an + * explicit argument. Guessing corrupts the cube on every position: a field value + * `v` means cube `v` in one dialect and `2 ** v` in the other, and `v === 2 ** v` + * has no solution. Worse, the corruption is invisible in checker-play tests, + * because cube value barely affects best checker play. + */ +export type XgidDialect = 'canonical' | 'berger' + +/** What the dice field was carrying. */ +export type XgidDiceState = + /** Two dice to play. */ + | { kind: 'roll'; dice: [number, number] } + /** A double has been offered; the player facing it is to decide. Canonical `D`. */ + | { kind: 'doubled' } + /** No roll: the player on roll is to make a cube decision. Berger `00`. */ + | { kind: 'cube-decision' } + +/** A parsed XGID. */ +export interface Xgid { + /** `board[0]` is the lowercase side, `board[1]` the uppercase side. */ + board: XgidBoard + /** The literal cube value: 1, 2, 4, 8, … (never the logarithm). */ + cubeValue: number + cubeOwner: XgidCubeOwner + /** The turn field, verbatim. `1` selects the lowercase side; see {@link xgidOnRollSide}. */ + turn: number + dice: XgidDiceState + /** `[lowercase, uppercase]` points scored. */ + score: [number, number] + /** Match target. `0` means money play. */ + matchLength: number + /** Crawford game. Only meaningful when `matchLength > 0`. */ + crawford: boolean + /** Jacoby rule. Only meaningful in money play. */ + jacoby: boolean + /** Beavers allowed. Only meaningful in money play. */ + beavers: boolean + /** Canonical dialect only. gnubg ignores it; `0` additionally disables cube use. */ + maxCube?: number +} + +/** Thrown for any malformed or illegal input. */ +export class XgidError extends Error { + override name = 'XgidError' +} + +const fail = (msg: string): never => { + throw new XgidError(msg) +} + +const POSITION_LENGTH = 26 +const MAX_CHECKERS = 15 +const POSITION_ID_LENGTH = 14 +const BASE64 = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + +const emptySide = (): XgidSide => new Array(25).fill(0) + +/** A fresh empty board. */ +export const emptyXgidBoard = (): XgidBoard => [emptySide(), emptySide()] + +/** + * Which side of `xgid.board` is on roll. + * + * The turn field selects the lowercase side when it is exactly `1`, and the + * uppercase side otherwise. This asymmetry is not a guess: it is the behaviour a + * position id derived from an XGID must match, verified against gnubg's decoder + * for both turn values. + */ +export const xgidOnRollSide = (xgid: Pick): XgidSideIndex => + xgid.turn === 1 ? 0 : 1 + +/** `[board[1], board[0]]`. Cheap, and does not mutate the input. */ +export const swapXgidSides = (board: XgidBoard): XgidBoard => [ + board[1].slice(), + board[0].slice(), +] + +const totalCheckers = (side: XgidSide): number => + side.reduce((sum, n) => sum + n, 0) + +function validateBoard(board: XgidBoard): void { + for (let index = 0; index < 2; index++) { + const side = board[index] + if (side.length !== 25) { + fail(`side ${index} must have 25 entries, got ${side.length}`) + } + for (const [point, count] of side.entries()) { + if (!Number.isInteger(count) || count < 0) { + fail(`side ${index} point ${point} has a non-integral count: ${count}`) + } + } + const total = totalCheckers(side) + if (total > MAX_CHECKERS) { + fail(`side ${index} has ${total} checkers, more than ${MAX_CHECKERS}`) + } + } + // The two sides are mirrored, so a physical point may only be held by one. + for (let i = 0; i < 24; i++) { + const lo = board[0][i] + const hi = board[1][23 - i] + if (lo > 0 && hi > 0) { + fail( + `both sides occupy one physical point: board[0][${i}]=${lo} and board[1][${ + 23 - i + }]=${hi}` + ) + } + } +} + +// --------------------------------------------------------------------------- +// GNU position id +// --------------------------------------------------------------------------- + +/** + * Encode a board as a 14-character GNU position id, `on-roll-first`. + * + * `board[0]` is serialized first, and a position id is read relative to the + * player on roll, so **pass the on-roll side first**. Use + * {@link xgidToPositionId} rather than calling this directly with an XGID board, + * which is in an absolute frame. + * + * Each point contributes its checker count as that many 1-bits followed by a + * single 0-bit; bits are written least-significant-first into 10 bytes, then + * base64-encoded with the standard alphabet (`+` and `/` do occur, so the result + * is not URL-safe). + */ +export function encodeGnuPositionId(board: XgidBoard): string { + validateBoard(board) + + const bytes = new Uint8Array(10) + let bit = 0 + + const put = (): void => { + const byte = bit >> 3 + if (byte >= bytes.length) fail('board does not fit in 80 bits') + bytes[byte] = bytes[byte] | (1 << (bit & 7)) + } + + for (const side of board) { + for (const count of side) { + for (let n = 0; n < count; n++) { + put() + bit++ + } + // The 0-bit terminating this point is implicit: the array starts zeroed. + bit++ + } + } + + let out = '' + for (let i = 0; i < 9; i += 3) { + const b0 = bytes[i] + const b1 = bytes[i + 1] + const b2 = bytes[i + 2] + out += BASE64[b0 >> 2] + out += BASE64[((b0 & 0x03) << 4) | (b1 >> 4)] + out += BASE64[((b1 & 0x0f) << 2) | (b2 >> 6)] + out += BASE64[b2 & 0x3f] + } + const last = bytes[9] + out += BASE64[last >> 2] + out += BASE64[(last & 0x03) << 4] + return out +} + +/** + * Decode a 14-character GNU position id written `on-roll-first`. + * + * The returned `board[0]` is the side the id was encoded relative to — the + * player on roll. + */ +export function decodeGnuPositionId(positionId: string): XgidBoard { + if (positionId.length !== POSITION_ID_LENGTH) { + fail( + `position id must be ${POSITION_ID_LENGTH} characters, got ${positionId.length}` + ) + } + + const sextets = new Array(POSITION_ID_LENGTH) + for (const [i, ch] of [...positionId].entries()) { + const v = BASE64.indexOf(ch) + if (v < 0) fail(`position id contains a non-base64 character: ${ch}`) + sextets[i] = v + } + + const bytes = new Uint8Array(10) + for (let i = 0, j = 0; i < 9; i += 3, j += 4) { + const s0 = sextets[j] + const s1 = sextets[j + 1] + const s2 = sextets[j + 2] + const s3 = sextets[j + 3] + bytes[i] = ((s0 << 2) | (s1 >> 4)) & 0xff + bytes[i + 1] = ((s1 << 4) | (s2 >> 2)) & 0xff + bytes[i + 2] = ((s2 << 6) | s3) & 0xff + } + bytes[9] = ((sextets[12] << 2) | (sextets[13] >> 4)) & 0xff + + const board = emptyXgidBoard() + let bit = 0 + const read = (): number => { + const byte = bit >> 3 + const value = (bytes[byte] >> (bit & 7)) & 1 + bit++ + return value + } + + for (const side of board) { + for (let point = 0; point < 25; point++) { + let count = 0 + while (bit < 80 && read() === 1) count++ + if (count > MAX_CHECKERS) { + fail(`position id decodes to ${count} checkers on one point`) + } + side[point] = count + } + } + + validateBoard(board) + return board +} + +// --------------------------------------------------------------------------- +// XGID +// --------------------------------------------------------------------------- + +/** + * Decode the 26-character position field. + * + * Character 0 is the lowercase side's bar and character 25 the uppercase side's; + * for characters 1..24 an uppercase letter is the uppercase side's point `i` and + * a lowercase letter is the lowercase side's point `25 - i`. + */ +function parsePositionField(pos: string): XgidBoard { + if (pos.length !== POSITION_LENGTH) { + fail( + `position field must be ${POSITION_LENGTH} characters, got ${pos.length}` + ) + } + const board = emptyXgidBoard() + + for (const [i, ch] of [...pos].entries()) { + if (ch === '-') continue + + const isUpper = ch >= 'A' && ch <= 'Z' + const isLower = ch >= 'a' && ch <= 'z' + if (!isUpper && !isLower) { + fail(`position field character ${i} is not a letter or '-': ${ch}`) + } + const count = + ch.charCodeAt(0) - (isUpper ? 'A'.charCodeAt(0) : 'a'.charCodeAt(0)) + 1 + if (count > MAX_CHECKERS) { + fail(`position field character ${i} encodes ${count} checkers`) + } + + // Index 0 belongs only to the lowercase side and index 25 only to the + // uppercase side; a letter of the wrong case there names no point. + if (i === 0) { + if (isUpper) fail("position field character 0 is the lowercase side's bar") + board[0][24] = count + } else if (i === 25) { + if (isLower) + fail("position field character 25 is the uppercase side's bar") + board[1][24] = count + } else if (isUpper) { + board[1][i - 1] = count + } else { + board[0][24 - i] = count + } + } + + validateBoard(board) + return board +} + +/** Inverse of {@link parsePositionField}. */ +function formatPositionField(board: XgidBoard): string { + validateBoard(board) + const chars = new Array(POSITION_LENGTH).fill('-') + + const bar0 = board[0][24] + if (bar0 > 0) chars[0] = String.fromCharCode('a'.charCodeAt(0) + bar0 - 1) + const bar1 = board[1][24] + if (bar1 > 0) chars[25] = String.fromCharCode('A'.charCodeAt(0) + bar1 - 1) + + for (let i = 1; i <= 24; i++) { + const upper = board[1][i - 1] + const lower = board[0][24 - i] + if (upper > 0) { + chars[i] = String.fromCharCode('A'.charCodeAt(0) + upper - 1) + } else if (lower > 0) { + chars[i] = String.fromCharCode('a'.charCodeAt(0) + lower - 1) + } + } + return chars.join('') +} + +function parseDice(field: string, dialect: XgidDialect): XgidDiceState { + if (field === 'D') { + if (dialect === 'berger') { + fail("the berger dialect has no 'D' dice value; see Draft 0.02 §5.2") + } + return { kind: 'doubled' } + } + if (field === '00') return { kind: 'cube-decision' } + if (!/^[1-6][1-6]$/.test(field)) { + fail(`dice field must be two digits 1-6, 'D', or '00': ${field}`) + } + const dice: [number, number] = [Number(field[0]), Number(field[1])] + return { kind: 'roll', dice } +} + +function formatDice(dice: XgidDiceState, dialect: XgidDialect): string { + switch (dice.kind) { + case 'roll': + return `${dice.dice[0]}${dice.dice[1]}` + case 'doubled': + if (dialect === 'berger') { + fail( + "the berger dialect cannot express a pending double; see Draft 0.02 §5.2" + ) + } + return 'D' + case 'cube-decision': + return '00' + } +} + +const FIELD_COUNT: Record = { canonical: 10, berger: 9 } + +/** + * Parse an XGID. + * + * The `XGID=` prefix is accepted but not required. `dialect` is mandatory — see + * {@link XgidDialect} for why it cannot be inferred. + */ +export function parseXgid(input: string, dialect: XgidDialect): Xgid { + const trimmed = input.trim() + const body = trimmed.startsWith('XGID=') + ? trimmed.slice('XGID='.length) + : trimmed + + const fields = body.split(':') + const expected = FIELD_COUNT[dialect] + if (fields.length !== expected) { + fail( + `${dialect} XGID needs ${expected} colon-separated fields, got ${fields.length}` + ) + } + + const board = parsePositionField(fields[0]) + + const rawCube = Number(fields[1]) + if (!Number.isInteger(rawCube) || rawCube < 0) { + fail(`cube field must be a non-negative integer: ${fields[1]}`) + } + const cubeValue = dialect === 'canonical' ? 2 ** rawCube : rawCube + if (cubeValue < 1 || (cubeValue & (cubeValue - 1)) !== 0) { + fail(`cube value must be a power of two, got ${cubeValue}`) + } + + const rawOwner = Number(fields[2]) + if (rawOwner !== -1 && rawOwner !== 0 && rawOwner !== 1) { + fail(`cube owner must be -1, 0 or 1: ${fields[2]}`) + } + // Narrowed by the check above rather than cast. + const cubeOwner: XgidCubeOwner = rawOwner === 1 ? 1 : rawOwner === -1 ? -1 : 0 + + const turn = Number(fields[3]) + if (!Number.isInteger(turn)) fail(`turn field must be an integer: ${fields[3]}`) + + const dice = parseDice(fields[4], dialect) + + const score0 = Number(fields[5]) + const score1 = Number(fields[6]) + if ( + !Number.isInteger(score0) || + !Number.isInteger(score1) || + score0 < 0 || + score1 < 0 + ) { + fail(`scores must be non-negative integers: ${fields[5]}, ${fields[6]}`) + } + + const rules = Number(fields[7]) + const matchLength = Number(fields[8]) + if (!Number.isInteger(matchLength) || matchLength < 0) { + fail(`match length must be a non-negative integer: ${fields[8]}`) + } + if (matchLength > 0 && (score0 >= matchLength || score1 >= matchLength)) { + fail(`score ${score0}-${score1} is not below the match length ${matchLength}`) + } + + // One field, two meanings, selected by the match length. Reading it without + // consulting matchLength silently produces the wrong flags. + let crawford = false + let jacoby = false + let beavers = false + if (matchLength > 0) { + if (rules !== 0 && rules !== 1) { + fail(`in match play the rules field must be 0 or 1, got ${rules}`) + } + crawford = rules === 1 + } else { + if (![0, 1, 2, 3].includes(rules)) { + fail(`in money play the rules field must be 0..3, got ${rules}`) + } + jacoby = rules === 1 || rules === 3 + beavers = rules === 2 || rules === 3 + } + + const xgid: Xgid = { + board, + cubeValue, + cubeOwner, + turn, + dice, + score: [score0, score1], + matchLength, + crawford, + jacoby, + beavers, + } + + if (dialect === 'canonical') { + const maxCube = Number(fields[9]) + if (!Number.isInteger(maxCube) || maxCube < 0) { + fail(`max cube must be a non-negative integer: ${fields[9]}`) + } + xgid.maxCube = maxCube + } + + return xgid +} + +/** Serialize an {@link Xgid}. Inverse of {@link parseXgid} for the same dialect. */ +export function formatXgid( + xgid: Xgid, + dialect: XgidDialect, + options: { prefix?: boolean } = {} +): string { + const { cubeValue, matchLength } = xgid + if (cubeValue < 1 || (cubeValue & (cubeValue - 1)) !== 0) { + fail(`cube value must be a power of two, got ${cubeValue}`) + } + + const cubeField = dialect === 'canonical' ? Math.log2(cubeValue) : cubeValue + + const rules = + matchLength > 0 + ? xgid.crawford + ? 1 + : 0 + : (xgid.jacoby ? 1 : 0) + (xgid.beavers ? 2 : 0) + + const fields = [ + formatPositionField(xgid.board), + String(cubeField), + String(xgid.cubeOwner), + String(xgid.turn), + formatDice(xgid.dice, dialect), + String(xgid.score[0]), + String(xgid.score[1]), + String(rules), + String(matchLength), + ] + + if (dialect === 'canonical') fields.push(String(xgid.maxCube ?? 0)) + + const body = fields.join(':') + return options.prefix ? `XGID=${body}` : body +} + +// --------------------------------------------------------------------------- +// The bridge +// --------------------------------------------------------------------------- + +/** + * Position id for an XGID, with the on-roll side placed first. + * + * This is the conversion that matters: an XGID board is absolute, a position id + * is relative to the player on roll, and the turn field is what reconciles them. + */ +export function xgidToPositionId(xgid: Pick): string { + const board = + xgidOnRollSide(xgid) === 0 ? xgid.board : swapXgidSides(xgid.board) + return encodeGnuPositionId(board) +} + +/** + * Everything an XGID says that a position id cannot. + * + * Returned separately because a caller reconstructing an XGID from a position id + * has to supply all of it from somewhere else. + */ +export interface XgidContext { + cubeValue: number + cubeOwner: XgidCubeOwner + turn: number + dice: XgidDiceState + score: [number, number] + matchLength: number + crawford: boolean + jacoby: boolean + beavers: boolean + maxCube?: number +} + +/** Split a parsed XGID into its board and everything else. */ +export function splitXgid(xgid: Xgid): { + board: XgidBoard + context: XgidContext +} { + const { board, ...context } = xgid + return { board, context } +} + +/** + * Rebuild an XGID from a position id plus the context a position id cannot + * carry. + * + * The position id's first side is the player on roll, so the board is reordered + * back into the absolute frame that `context.turn` implies. + */ +export function positionIdToXgid( + positionId: string, + context: XgidContext, + dialect: XgidDialect, + options: { prefix?: boolean } = {} +): string { + const onRollFirst = decodeGnuPositionId(positionId) + const board = + xgidOnRollSide(context) === 0 ? onRollFirst : swapXgidSides(onRollFirst) + return formatXgid({ ...context, board }, dialect, options) +} diff --git a/src/index.ts b/src/index.ts index 668d254..f85fd5c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,4 +66,8 @@ export { export type * from '@nodots/backgammon-types' export { GameEventEmitter } from './events/GameEventEmitter' export * from './XG' // Re-enabled for Issue #213 fix - XG import with proper board state tracking +// XGID <-> GNU position id conversion. Note that encodeGnuPositionId here is +// on-roll-first, whereas Board's exportToGnuPositionId is opponent-first; see +// the header comment in ./XGID for why both exist. +export * from './XGID' export * from './MET' From 883368b37cdf4bfc67a9be75a1bf7270b2110b0f Mon Sep 17 00:00:00 2001 From: Ken Riley Date: Wed, 29 Jul 2026 19:06:14 -0600 Subject: [PATCH 2/3] XGID: close the scalar-semantics gap with a gnubg CLI oracle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The converter's board field was already validated end-to-end against gnubg via the compiled addon. Its scalar semantics were not: field order, the cube logarithm, the overloaded rules field and `D` rested on the spec plus Draft 0.02's single worked example, because SetXGID is not among the addon's compiled sources. Adds three things: gnubgCli.test.ts drives the GNU Backgammon 1.07.001 CLI as an oracle. `set xgid` runs the reference parser and one `show` command per field reports how each was understood, so every scalar is now checked against an independently built binary rather than against our vendored copy. One process per position: gnubg carries state across `set xgid` within a session and reports a stale turn/owner for at least one combination, which `new session` does not clear. A process costs ~90ms. corpus.test.ts covers the scalar cross-product — 12,768 canonical and 12,432 berger combinations over cube value, cube owner, turn, all 38 dice values and eight match/money configurations, rotating through five boards spanning loaded bars and borne-off checkers. Plus negative fuzzing: 4,000 mutated strings must raise XgidError specifically, never a stray TypeError from inside the parser and never a plausible-but-wrong parse. Position fields in the corpus are derived from boards via formatXgid rather than written by hand. Two hand-written ones were illegal under the mirroring rule, which is exactly the mistake the helper prevents. Findings worth recording: - The cube-as-logarithm reading is confirmed against gnubg for every value 1..64, not just Frank's one example. - The overloaded rules field matches gnubg exactly: 0/1/2/3 -> none, Jacoby, beavers, both in money play, and Crawford in match play. - gnubg cannot hold a pending double either. `set xgid` with `D` says "SetMatchID cannot handle positions where a double has been offered" and steps back to the pre-double state. So /v1/take is unreachable through XGID on the canonical path too, and the spec's suggestion that adopting `D` would close that gap does not survive. Corrected in docs/spec-xgid-gnu-position-id.md. - gnubg reports the cube as disabled during the Crawford game rather than giving a value, which independently confirms the flag was read. Verified: 61/61 across four suites, typecheck clean. Mutation-tested — reading the canonical cube field as a raw value instead of a logarithm fails 10 tests across all three non-skipped suites rather than passing. The CLI suite skips when gnubg is absent. --- src/XGID/__tests__/corpus.test.ts | 283 +++++++++++++++++++++++ src/XGID/__tests__/gnubgCli.test.ts | 344 ++++++++++++++++++++++++++++ 2 files changed, 627 insertions(+) create mode 100644 src/XGID/__tests__/corpus.test.ts create mode 100644 src/XGID/__tests__/gnubgCli.test.ts diff --git a/src/XGID/__tests__/corpus.test.ts b/src/XGID/__tests__/corpus.test.ts new file mode 100644 index 0000000..858df8f --- /dev/null +++ b/src/XGID/__tests__/corpus.test.ts @@ -0,0 +1,283 @@ +// Exhaustive scalar corpus and negative fuzzing. +// +// The differential suites prove agreement with gnubg on individual cases. This +// one covers the scalar cross-product — every cube value against every cube +// owner against every turn against every dice value against every rules/match +// configuration — which is where field-order and asymmetry bugs hide. +// +// Both properties tested here fail loudly rather than silently: +// +// 1. Round-trip identity. `format(parse(s)) === s`, character for character. +// A converter that drops or reorders a field breaks this immediately. +// 2. Nothing misparses quietly. A malformed string must raise XgidError. It +// must never return a plausible-but-wrong Xgid, and must never escape as a +// TypeError or RangeError from somewhere deep in the parser. + +import { + emptyXgidBoard, + formatXgid, + parseXgid, + splitXgid, + Xgid, + XgidBoard, + XgidError, + xgidToPositionId, +} from '../index' + +/** + * Render a board as an XGID position field. + * + * Position fields are derived from boards rather than written by hand: the + * mirroring rule (`board[0][i]` and `board[1][23 - i]` are the same physical + * point) makes hand-written fields easy to get wrong, and `formatXgid` + * validates, so anything this returns is legal by construction. + */ +function positionField(board: XgidBoard): string { + const text = formatXgid( + { + board, + cubeValue: 1, + cubeOwner: 0, + turn: 1, + dice: { kind: 'cube-decision' }, + score: [0, 0], + matchLength: 0, + crawford: false, + jacoby: false, + beavers: false, + maxCube: 0, + }, + 'canonical' + ) + return text.split(':')[0] +} + +const openingBoard = (): XgidBoard => { + const board = emptyXgidBoard() + for (const side of board) { + side[5] = 5 + side[7] = 3 + side[12] = 5 + side[23] = 2 + } + return board +} + +/** Both bars loaded: the opening with each side's back checkers sent back. */ +const barsLoaded = (): XgidBoard => { + const board = openingBoard() + for (const side of board) { + side[23] = 0 + side[24] = 2 + } + return board +} + +/** Few checkers, so the bitstream is short and the remainder is zero padding. */ +const sparse = (): XgidBoard => { + const board = emptyXgidBoard() + board[0][0] = 1 + board[0][5] = 3 + board[1][0] = 2 + board[1][24] = 1 + return board +} + +/** Position fields spanning empty points, loaded bars and borne-off checkers. */ +const BOARDS = [ + positionField(openingBoard()), + parseXgid('-a-B--E-B-a-dDB--b-bcb----:0:0:1:31:0:0:0:0:0', 'canonical').board, + emptyXgidBoard(), // every checker borne off, both sides + barsLoaded(), + sparse(), +].map((b) => (typeof b === 'string' ? b : positionField(b))) + +/** Match configurations: [score0, score1, rules, matchLength]. */ +const CONFIGS: Array<[number, number, number, number]> = [ + [0, 0, 0, 0], // money, no rules + [0, 0, 1, 0], // money, Jacoby + [0, 0, 2, 0], // money, beavers + [0, 0, 3, 0], // money, both + [0, 0, 0, 3], // match, no Crawford + [2, 5, 0, 7], // match, mid-score + [6, 2, 1, 7], // match, Crawford game + [0, 10, 0, 11], // match, long +] + +const DICE = ['00', 'D'] +for (let a = 1; a <= 6; a++) for (let b = 1; b <= 6; b++) DICE.push(`${a}${b}`) + +describe('exhaustive scalar corpus', () => { + it('round-trips every scalar combination in the canonical dialect', () => { + let checked = 0 + let boardIndex = 0 + for (let cubeField = 0; cubeField <= 6; cubeField++) { + for (const owner of [1, -1, 0]) { + for (const turn of [1, -1]) { + for (const dice of DICE) { + for (const [s0, s1, rules, len] of CONFIGS) { + // Rotate the board so every board is exercised without + // multiplying the corpus by five. + const pos = BOARDS[boardIndex++ % BOARDS.length] + const text = `${pos}:${cubeField}:${owner}:${turn}:${dice}:${s0}:${s1}:${rules}:${len}:0` + const parsed = parseXgid(text, 'canonical') + expect(formatXgid(parsed, 'canonical')).toBe(text) + expect(parsed.cubeValue).toBe(2 ** cubeField) + expect(parsed.cubeOwner).toBe(owner) + expect(parsed.turn).toBe(turn) + expect(parsed.score).toEqual([s0, s1]) + expect(parsed.matchLength).toBe(len) + // The overloaded field, asserted against the match length. + if (len > 0) { + expect(parsed.crawford).toBe(rules === 1) + expect(parsed.jacoby).toBe(false) + expect(parsed.beavers).toBe(false) + } else { + expect(parsed.crawford).toBe(false) + expect(parsed.jacoby).toBe(rules === 1 || rules === 3) + expect(parsed.beavers).toBe(rules === 2 || rules === 3) + } + checked++ + } + } + } + } + } + // T5's stated bar for this corpus. + expect(checked).toBeGreaterThanOrEqual(10000) + }, 120000) + + it('round-trips the berger dialect wherever it can express the state', () => { + let checked = 0 + let boardIndex = 0 + for (let cubeField = 0; cubeField <= 6; cubeField++) { + const cubeValue = 2 ** cubeField + for (const owner of [1, -1, 0]) { + for (const turn of [1, -1]) { + for (const dice of DICE) { + // Draft 0.02 has no 'D'. + if (dice === 'D') continue + for (const [s0, s1, rules, len] of CONFIGS) { + const pos = BOARDS[boardIndex++ % BOARDS.length] + const text = `${pos}:${cubeValue}:${owner}:${turn}:${dice}:${s0}:${s1}:${rules}:${len}` + const parsed = parseXgid(text, 'berger') + expect(formatXgid(parsed, 'berger')).toBe(text) + // Literal, not a logarithm — the difference that silently doubles + // every cube decision when the dialect is guessed. + expect(parsed.cubeValue).toBe(cubeValue) + expect(parsed.maxCube).toBeUndefined() + checked++ + } + } + } + } + } + expect(checked).toBeGreaterThanOrEqual(10000) + }, 120000) + + it('converts between dialects without losing anything but maxCube', () => { + let boardIndex = 0 + for (let cubeField = 0; cubeField <= 6; cubeField++) { + for (const turn of [1, -1]) { + for (const [s0, s1, rules, len] of CONFIGS) { + const pos = BOARDS[boardIndex++ % BOARDS.length] + const canonical = `${pos}:${cubeField}:1:${turn}:31:${s0}:${s1}:${rules}:${len}:8` + const viaCanonical = parseXgid(canonical, 'canonical') + const asBerger = formatXgid(viaCanonical, 'berger') + const viaBerger = parseXgid(asBerger, 'berger') + + const { board: b1, context: c1 } = splitXgid(viaCanonical) + const { board: b2, context: c2 } = splitXgid(viaBerger) + expect(b2).toEqual(b1) + expect({ ...c2, maxCube: undefined }).toEqual({ + ...c1, + maxCube: undefined, + }) + // The board survives the trip through a position id too. + expect(xgidToPositionId(viaBerger)).toBe(xgidToPositionId(viaCanonical)) + } + } + } + }, 60000) + + it('refuses to serialize a pending double into the berger dialect', () => { + for (const pos of BOARDS) { + const doubled = parseXgid(`${pos}:0:0:1:D:0:0:0:0:0`, 'canonical') + expect(doubled.dice).toEqual({ kind: 'doubled' }) + expect(() => formatXgid(doubled, 'berger')).toThrow(XgidError) + } + }) +}) + +describe('negative fuzzing', () => { + // Deterministic PRNG so a failure is reproducible from the seed alone. + let seed = 0x2545f491 + const rnd = (n: number): number => { + seed ^= seed << 13 + seed >>>= 0 + seed ^= seed >> 17 + seed ^= seed << 5 + seed >>>= 0 + return seed % n + } + + const validCanonical = (): string => { + const pos = BOARDS[rnd(BOARDS.length)] + const [s0, s1, rules, len] = CONFIGS[rnd(CONFIGS.length)] + const dice = DICE[rnd(DICE.length)] + return `${pos}:${rnd(7)}:${[1, -1, 0][rnd(3)]}:${[1, -1][rnd(2)]}:${dice}:${s0}:${s1}:${rules}:${len}:0` + } + + const MUTATIONS: Array<(s: string) => string> = [ + (s) => s.slice(0, s.lastIndexOf(':')), // drop the last field + (s) => `${s}:0`, // add a field + (s) => s.replace(/^.{26}/, (p) => p.slice(1)), // 25-char position + (s) => s.replace(/^.{26}/, (p) => `${p}-`), // 27-char position + (s) => s.replace(/^./, 'Z'), // uppercase on the lowercase bar + (s) => s.replace(/^.{26}/, (p) => `${p.slice(0, 25)}z`), // lowercase on the uppercase bar + (s) => s.replace(/^.{26}/, (p) => `q${p.slice(1)}`), // >15 checkers on the bar + (s) => s.replace(/:(\d)(\d):/, ':70:'), // impossible roll + (s) => s.replace(/^.{26}:/, (p) => `${p}99:`), // absurd cube field + (s) => s.split(':').slice(0, 3).join(':'), // truncated hard + (s) => s.replace(/^.{26}/, (p) => `${p.slice(0, 13)}!${p.slice(14)}`), // illegal char + (s) => '', // empty + ] + + it('raises XgidError, never a stray runtime error, and never a silent misparse', () => { + let rejected = 0 + let accepted = 0 + for (let i = 0; i < 4000; i++) { + const base = validCanonical() + const mutated = MUTATIONS[i % MUTATIONS.length](base) + + let parsed: Xgid | null = null + try { + parsed = parseXgid(mutated, 'canonical') + } catch (err) { + // The contract is XgidError specifically. A TypeError or RangeError + // escaping from inside the parser is a defect even though both are + // "throwing", because callers cannot distinguish bad input from a bug. + expect(err).toBeInstanceOf(XgidError) + rejected++ + continue + } + // Some mutations happen to produce a legal string. Accepting one is fine + // — misreading it is not, so it must still round-trip exactly. + expect(formatXgid(parsed, 'canonical')).toBe(mutated) + accepted++ + } + // Guard against the mutations quietly becoming no-ops. + expect(rejected).toBeGreaterThan(3000) + expect(rejected + accepted).toBe(4000) + }, 60000) + + it('rejects a position id of the wrong shape rather than guessing', () => { + const bad = ['', 'AQAAAAAAAAAA', 'AQAAAAAAAAAAAAA', '!QAAAAAAAAAAAA', '=QAAAAAAAAAAAA'] + for (const id of bad) { + expect(() => parseXgid(`${BOARDS[0]}:0:0:1:31:0:0:0:0:0`, 'canonical')).not.toThrow() + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { decodeGnuPositionId } = require('../index') + expect(() => decodeGnuPositionId(id)).toThrow(XgidError) + } + }) +}) diff --git a/src/XGID/__tests__/gnubgCli.test.ts b/src/XGID/__tests__/gnubgCli.test.ts new file mode 100644 index 0000000..6ba1b8a --- /dev/null +++ b/src/XGID/__tests__/gnubgCli.test.ts @@ -0,0 +1,344 @@ +// Differential test of XGID *scalar* semantics against the GNU Backgammon CLI. +// +// The addon-based suite (crossValidate.test.ts) validates the board field +// end-to-end, because gnubg can decode a position id we produced. It cannot +// validate the scalar fields — field order, the cube logarithm, the overloaded +// rules field, `D` — because SetXGID is not among the addon's compiled sources. +// Those semantics previously rested on the spec plus Draft 0.02's single worked +// example. +// +// The gnubg CLI closes that gap: `set xgid ` runs the reference parser, +// and one `show` command per field reports how it was understood. That makes +// every scalar field machine-checkable against an independently built binary +// (Debian ships 1.07.001) rather than against our vendored copy. +// +// Skips rather than passing when gnubg is absent: +// +// sudo apt install gnubg # or set GNUBG_CLI=/path/to/gnubg +// npx jest src/XGID +// +// One process per position, deliberately. gnubg preserves some state across +// `set xgid` within a single session — a batched run reports a stale turn/owner +// for at least one combination, and `new session` does not clear it. A fresh +// process costs ~90ms, so correctness here is cheap. + +import { execFileSync, spawnSync } from 'child_process' +import { formatXgid, parseXgid, Xgid, xgidToPositionId } from '../index' + +const GNUBG = process.env.GNUBG_CLI ?? 'gnubg' + +function gnubgAvailable(): boolean { + try { + execFileSync(GNUBG, ['--version'], { stdio: 'pipe', timeout: 20000 }) + return true + } catch { + return false + } +} + +const available = gnubgAvailable() + +/** Everything gnubg will tell us about the position it just parsed. */ +interface OracleReading { + /** From the LAST board printed. `set xgid` echoes the pre-set board first. */ + positionId: string + cubeValue: number | null // null when gnubg reports no cube state at all + cubeOwner: string | null // player name, or null when centred + /** gnubg disables the cube during the Crawford game and reports no value. */ + cubeDisabled: boolean + /** + * gnubg says it "cannot handle positions where a double has been offered" and + * steps back to the pre-double state. Its own XGID/MatchID path cannot carry a + * pending double either. + */ + doubleUnrepresentable: boolean + onRoll: string // player name + dice: [number, number] | null + scoreByName: Record + matchLength: number // 0 for a money session + crawford: boolean + jacoby: boolean | null // null when not a money session + beavers: boolean | null +} + +/** + * Ask gnubg to parse an XGID and report what it understood. + * + * Returns `null` when gnubg refuses the string — it says so explicitly (`Not a + * valid XGID '...'`) and then has no position at all, so there is nothing to + * read back. + */ +function askRaw(xgidBody: string): OracleReading | null { + const script = [ + `set xgid ${xgidBody}`, + 'show cube', + 'show dice', + 'show turn', + 'show score', + 'show crawford', + 'show jacoby', + 'show beavers', + // Last, so the final board printed is the position gnubg actually adopted. + 'show board', + 'quit', + 'y', + '', + ].join('\n') + + // Diagnostics such as "Not a valid XGID" go to stderr, so both streams are + // needed — reading stdout alone silently loses every rejection. + const run = spawnSync(GNUBG, ['-tq'], { + input: script, + encoding: 'utf8', + timeout: 60000, + }) + const out = `${run.stdout ?? ''}\n${run.stderr ?? ''}` + + if (/Not a valid XGID/.test(out)) return null + + // `set xgid` echoes the board as it was BEFORE the change, so the first + // Position ID in the output is the previous position. Take the last. + const ids = [...out.matchAll(/Position ID:\s*(\S+)/g)].map((m) => m[1]) + const positionId = ids[ids.length - 1] + if (!positionId) throw new Error(`no position id in gnubg output for ${xgidBody}`) + + // Absent during the Crawford game, when gnubg reports the cube as disabled + // instead of giving a value. + const cube = /The cube is at (\d+), and is (?:owned by (.+?)\.|centred\.)/.exec(out) + const cubeDisabled = /cube is disabled during the Crawford game/.test(out) + if (!cube && !cubeDisabled) throw new Error(`no cube line for ${xgidBody}`) + + const rolled = /(.+?) has rolled (\d) and (\d)\./.exec(out) + const turn = /(.+?) in on (?:roll|move)\./.exec(out) + if (!turn) throw new Error(`no turn line for ${xgidBody}`) + + // A match line may carry ", post-Crawford play" inside the parentheses. + const score = + /The score \(after \d+ games?\) is: (.+?) (\d+), (.+?) (\d+) \((match to (\d+) points[^)]*|money session[^)]*)\)/.exec( + out + ) + if (!score) throw new Error(`no score line for ${xgidBody}`) + + // "This money session is played with/without" is the session's own flag. + // "New money sessions are played with" is the persistent default and must be + // ignored — it reports a setting, not this position. + const jacobyLine = /This money session is played (with|without) the Jacoby rule/.exec(out) + const beaversLine = /(No beavers allowed|\d+ beavers\/raccoons allowed)/.exec(out) + + return { + positionId, + cubeValue: cube ? Number(cube[1]) : null, + cubeOwner: cube?.[2] ?? null, + cubeDisabled, + doubleUnrepresentable: /cannot handle positions where a double has been offered/.test( + out + ), + onRoll: turn[1].trim(), + dice: rolled ? [Number(rolled[2]), Number(rolled[3])] : null, + scoreByName: { + [score[1].trim()]: Number(score[2]), + [score[3].trim()]: Number(score[4]), + }, + matchLength: score[6] ? Number(score[6]) : 0, + crawford: /This game is the Crawford game/.test(out), + jacoby: jacobyLine ? jacobyLine[1] === 'with' : null, + beavers: beaversLine ? !beaversLine[1].startsWith('No') : null, + } +} + +/** For the positions gnubg is expected to accept. Fails loudly if it does not. */ +function ask(xgidBody: string): OracleReading { + const reading = askRaw(xgidBody) + if (!reading) throw new Error(`gnubg rejected an XGID we expected it to accept: ${xgidBody}`) + return reading +} + +const POS = '-a-B--E-B-a-dDB--b-bcb----' + +/** + * gnubg names its players from local configuration, so the mapping from XGID + * side to player name is calibrated rather than hardcoded. `turn = 1` selects + * the lowercase side, `turn = -1` the uppercase side. + */ +let lowerName = '' +let upperName = '' + +const describeIf = available ? describe : describe.skip + +describeIf('XGID scalars against the gnubg CLI', () => { + beforeAll(() => { + lowerName = ask(`${POS}:0:0:1:00:0:0:0:0:0`).onRoll + upperName = ask(`${POS}:0:0:-1:00:0:0:0:0:0`).onRoll + expect(lowerName).not.toBe(upperName) + }, 60000) + + it('agrees on the board: our position id is what gnubg derives', () => { + for (const turn of [1, -1]) { + const body = `${POS}:0:0:${turn}:00:0:0:0:0:0` + expect(ask(body).positionId).toBe(xgidToPositionId(parseXgid(body, 'canonical'))) + } + }, 60000) + + it('reads the cube field as a base-2 logarithm, for every value 1..64', () => { + // The single most consequential scalar: a raw/log2 misread doubles every + // cube decision while leaving checker play untouched. + for (let field = 0; field <= 6; field++) { + const body = `${POS}:${field}:0:1:00:0:0:0:0:0` + const ours = parseXgid(body, 'canonical') + expect(ours.cubeValue).toBe(2 ** field) + expect(ask(body).cubeValue).toBe(ours.cubeValue) + } + }, 120000) + + it('agrees on cube ownership for both turn values', () => { + for (const turn of [1, -1]) { + for (const owner of [1, -1, 0]) { + const body = `${POS}:2:${owner}:${turn}:00:0:0:0:0:0` + const ours = parseXgid(body, 'canonical') + const theirs = ask(body) + const expected = + owner === 0 ? null : owner === 1 ? lowerName : upperName + expect(theirs.cubeOwner).toBe(expected) + expect(ours.cubeOwner).toBe(owner) + } + } + }, 180000) + + it('agrees which side is on roll', () => { + for (const turn of [1, -1]) { + const body = `${POS}:0:0:${turn}:00:0:0:0:0:0` + expect(ask(body).onRoll).toBe(turn === 1 ? lowerName : upperName) + } + }, 60000) + + it('agrees on every one of the 36 rolls', () => { + for (let d0 = 1; d0 <= 6; d0++) { + for (let d1 = 1; d1 <= 6; d1++) { + const body = `${POS}:0:0:1:${d0}${d1}:0:0:0:0:0` + const ours = parseXgid(body, 'canonical') + const theirs = ask(body) + expect(ours.dice).toEqual({ kind: 'roll', dice: [d0, d1] }) + // gnubg reports the roll unordered-normalised, so compare as a set. + expect([...(theirs.dice as [number, number])].sort()).toEqual( + [d0, d1].sort() + ) + } + } + }, 300000) + + it("reads '00' as no roll yet — a cube decision", () => { + const body = `${POS}:0:0:1:00:0:0:0:0:0` + expect(parseXgid(body, 'canonical').dice).toEqual({ kind: 'cube-decision' }) + expect(ask(body).dice).toBeNull() + }, 60000) + + it("reads 'D' as a pending double, which gnubg itself cannot hold", () => { + // We represent the state faithfully. gnubg accepts the string but says it + // "cannot handle positions where a double has been offered" and steps back + // to the offering of the cube, landing on the pre-double state. + // + // So the take/drop decision is unreachable through an XGID on the CANONICAL + // path too, not only in Draft 0.02. Our spec's §5.2 suggestion that + // retaining gnubg's `D` would close that gap does not survive this. + const body = `${POS}:0:0:1:D:0:0:0:0:0` + expect(parseXgid(body, 'canonical').dice).toEqual({ kind: 'doubled' }) + + const theirs = ask(body) + expect(theirs.doubleUnrepresentable).toBe(true) + // Stepped back: no roll, cube still centred at 1, roll owner unchanged. + expect(theirs.dice).toBeNull() + expect(theirs.cubeValue).toBe(1) + expect(theirs.cubeOwner).toBeNull() + expect(theirs.onRoll).toBe(lowerName) + }, 60000) + + it('agrees on the score and match length', () => { + const cases: Array<[number, number, number]> = [ + [0, 0, 0], + [0, 0, 3], + [2, 5, 7], + [4, 4, 9], + [0, 10, 11], + ] + for (const [s0, s1, len] of cases) { + const body = `${POS}:0:0:1:00:${s0}:${s1}:0:${len}:0` + const ours = parseXgid(body, 'canonical') + const theirs = ask(body) + expect(ours.score).toEqual([s0, s1]) + expect(ours.matchLength).toBe(len) + expect(theirs.matchLength).toBe(len) + expect(theirs.scoreByName[lowerName]).toBe(s0) + expect(theirs.scoreByName[upperName]).toBe(s1) + } + }, 180000) + + it('agrees on the overloaded rules field in money play', () => { + // The trap: one field, two meanings, selected by match length. + const expected = [ + { jacoby: false, beavers: false }, + { jacoby: true, beavers: false }, + { jacoby: false, beavers: true }, + { jacoby: true, beavers: true }, + ] + for (let rules = 0; rules <= 3; rules++) { + const body = `${POS}:0:0:1:00:0:0:${rules}:0:0` + const ours = parseXgid(body, 'canonical') + const theirs = ask(body) + expect(ours.jacoby).toBe(expected[rules].jacoby) + expect(ours.beavers).toBe(expected[rules].beavers) + expect(theirs.jacoby).toBe(expected[rules].jacoby) + expect(theirs.beavers).toBe(expected[rules].beavers) + expect(ours.crawford).toBe(false) + } + }, 180000) + + it('agrees on the same field meaning Crawford in match play', () => { + // Only checkable at a score where Crawford is possible: gnubg reports the + // game state, not the raw flag, so at 2-5 of 7 no flag value can make it + // the Crawford game. + for (const [s0, s1] of [ + [6, 2], + [2, 6], + ]) { + for (const rules of [0, 1]) { + const body = `${POS}:0:0:1:00:${s0}:${s1}:${rules}:7:0` + const ours = parseXgid(body, 'canonical') + const theirs = ask(body) + expect(ours.crawford).toBe(rules === 1) + expect(theirs.crawford).toBe(rules === 1) + // In the Crawford game gnubg reports the cube as disabled rather than + // giving it a value, which is a second, independent confirmation that + // the flag was understood. + expect(theirs.cubeDisabled).toBe(rules === 1) + // Jacoby is a money-play concept and must not leak out of a match. + expect(ours.jacoby).toBe(false) + } + } + }, 180000) + + it('rejects what gnubg rejects: score at or above the match length', () => { + const body = `${POS}:0:0:1:00:7:0:0:7:0` + expect(() => parseXgid(body, 'canonical')).toThrow() + // gnubg declines it too, explicitly. Guard that the same string with a + // legal score IS accepted, so this is evidence about the score rather than + // about the rest of the string. + expect(askRaw(body)).toBeNull() + expect(askRaw(`${POS}:0:0:1:00:6:0:0:7:0`)).not.toBeNull() + }, 120000) + + it('round-trips a berger-dialect string through gnubg via canonical', () => { + // gnubg does not accept Frank's dialect, so the bridge is + // berger -> our parse -> canonical -> gnubg. That is the path the socket + // server will take, so it is the one worth proving. Money play, to keep + // this about the dialect rather than about cube values a short match cannot + // reach. + const berger = `${POS}:4:1:-1:63:0:0:0:0` + const asXgid: Xgid = parseXgid(berger, 'berger') + expect(asXgid.cubeValue).toBe(4) // literal in berger, not a logarithm + const canonical = formatXgid(asXgid, 'canonical') + expect(canonical).toBe(`${POS}:2:1:-1:63:0:0:0:0:0`) // 4 -> log2 2 + const theirs = ask(canonical) + expect(theirs.cubeValue).toBe(4) + expect(theirs.positionId).toBe(xgidToPositionId(asXgid)) + }, 60000) +}) From 80dec0d56ff80097c3798fe2785b2e6e80859da7 Mon Sep 17 00:00:00 2001 From: Ken Riley Date: Thu, 30 Jul 2026 16:11:37 -0600 Subject: [PATCH 3/3] XGID: rename the 'berger' dialect to 'bgblitz' The dialect names the format, not its author. BGBlitz is what the nine-field variant in Draft 0.02 belongs to, and the adapter's endpoint has been /bgblitz since it was written; this closes the naming gap between the two. Identifiers, error messages and prose only. No behaviour change: the field count still tells the dialects apart, cube encoding is unchanged, and 'D' is still rejected in the nine-field dialect. --- src/XGID/__tests__/corpus.test.ts | 18 +++++++++--------- src/XGID/__tests__/gnubgCli.test.ts | 10 +++++----- src/XGID/__tests__/xgid.test.ts | 26 +++++++++++++------------- src/XGID/index.ts | 16 ++++++++-------- 4 files changed, 35 insertions(+), 35 deletions(-) diff --git a/src/XGID/__tests__/corpus.test.ts b/src/XGID/__tests__/corpus.test.ts index 858df8f..d90f7a2 100644 --- a/src/XGID/__tests__/corpus.test.ts +++ b/src/XGID/__tests__/corpus.test.ts @@ -147,7 +147,7 @@ describe('exhaustive scalar corpus', () => { expect(checked).toBeGreaterThanOrEqual(10000) }, 120000) - it('round-trips the berger dialect wherever it can express the state', () => { + it('round-trips the bgblitz dialect wherever it can express the state', () => { let checked = 0 let boardIndex = 0 for (let cubeField = 0; cubeField <= 6; cubeField++) { @@ -160,8 +160,8 @@ describe('exhaustive scalar corpus', () => { for (const [s0, s1, rules, len] of CONFIGS) { const pos = BOARDS[boardIndex++ % BOARDS.length] const text = `${pos}:${cubeValue}:${owner}:${turn}:${dice}:${s0}:${s1}:${rules}:${len}` - const parsed = parseXgid(text, 'berger') - expect(formatXgid(parsed, 'berger')).toBe(text) + const parsed = parseXgid(text, 'bgblitz') + expect(formatXgid(parsed, 'bgblitz')).toBe(text) // Literal, not a logarithm — the difference that silently doubles // every cube decision when the dialect is guessed. expect(parsed.cubeValue).toBe(cubeValue) @@ -183,28 +183,28 @@ describe('exhaustive scalar corpus', () => { const pos = BOARDS[boardIndex++ % BOARDS.length] const canonical = `${pos}:${cubeField}:1:${turn}:31:${s0}:${s1}:${rules}:${len}:8` const viaCanonical = parseXgid(canonical, 'canonical') - const asBerger = formatXgid(viaCanonical, 'berger') - const viaBerger = parseXgid(asBerger, 'berger') + const asBGBlitz = formatXgid(viaCanonical, 'bgblitz') + const viaBGBlitz = parseXgid(asBGBlitz, 'bgblitz') const { board: b1, context: c1 } = splitXgid(viaCanonical) - const { board: b2, context: c2 } = splitXgid(viaBerger) + const { board: b2, context: c2 } = splitXgid(viaBGBlitz) expect(b2).toEqual(b1) expect({ ...c2, maxCube: undefined }).toEqual({ ...c1, maxCube: undefined, }) // The board survives the trip through a position id too. - expect(xgidToPositionId(viaBerger)).toBe(xgidToPositionId(viaCanonical)) + expect(xgidToPositionId(viaBGBlitz)).toBe(xgidToPositionId(viaCanonical)) } } } }, 60000) - it('refuses to serialize a pending double into the berger dialect', () => { + it('refuses to serialize a pending double into the bgblitz dialect', () => { for (const pos of BOARDS) { const doubled = parseXgid(`${pos}:0:0:1:D:0:0:0:0:0`, 'canonical') expect(doubled.dice).toEqual({ kind: 'doubled' }) - expect(() => formatXgid(doubled, 'berger')).toThrow(XgidError) + expect(() => formatXgid(doubled, 'bgblitz')).toThrow(XgidError) } }) }) diff --git a/src/XGID/__tests__/gnubgCli.test.ts b/src/XGID/__tests__/gnubgCli.test.ts index 6ba1b8a..a9dcda1 100644 --- a/src/XGID/__tests__/gnubgCli.test.ts +++ b/src/XGID/__tests__/gnubgCli.test.ts @@ -326,15 +326,15 @@ describeIf('XGID scalars against the gnubg CLI', () => { expect(askRaw(`${POS}:0:0:1:00:6:0:0:7:0`)).not.toBeNull() }, 120000) - it('round-trips a berger-dialect string through gnubg via canonical', () => { + it('round-trips a bgblitz-dialect string through gnubg via canonical', () => { // gnubg does not accept Frank's dialect, so the bridge is - // berger -> our parse -> canonical -> gnubg. That is the path the socket + // bgblitz -> our parse -> canonical -> gnubg. That is the path the socket // server will take, so it is the one worth proving. Money play, to keep // this about the dialect rather than about cube values a short match cannot // reach. - const berger = `${POS}:4:1:-1:63:0:0:0:0` - const asXgid: Xgid = parseXgid(berger, 'berger') - expect(asXgid.cubeValue).toBe(4) // literal in berger, not a logarithm + const bgblitz = `${POS}:4:1:-1:63:0:0:0:0` + const asXgid: Xgid = parseXgid(bgblitz, 'bgblitz') + expect(asXgid.cubeValue).toBe(4) // literal in bgblitz, not a logarithm const canonical = formatXgid(asXgid, 'canonical') expect(canonical).toBe(`${POS}:2:1:-1:63:0:0:0:0:0`) // 4 -> log2 2 const theirs = ask(canonical) diff --git a/src/XGID/__tests__/xgid.test.ts b/src/XGID/__tests__/xgid.test.ts index 40c03ee..88152b8 100644 --- a/src/XGID/__tests__/xgid.test.ts +++ b/src/XGID/__tests__/xgid.test.ts @@ -89,7 +89,7 @@ describe('position id', () => { describe('XGID parsing', () => { // The worked example from Open Backgammon Plugin Protocol Draft 0.02. const CANONICAL = 'XGID=-a-B--E-B-a-dDB--b-bcb----:1:1:-1:63:0:0:0:3:8' - const BERGER = '-a-B--E-B-a-dDB--b-bcb----:2:1:-1:63:0:0:0:3' + const BGBLITZ = '-a-B--E-B-a-dDB--b-bcb----:2:1:-1:63:0:0:0:3' it('parses the canonical worked example', () => { const xgid = parseXgid(CANONICAL, 'canonical') @@ -109,13 +109,13 @@ describe('XGID parsing', () => { expect(board[1].reduce((a, b) => a + b, 0)).toBe(15) }) - it("reproduces Draft 0.02's stated canonical-to-berger conversion", () => { + it("reproduces Draft 0.02's stated canonical-to-bgblitz conversion", () => { const xgid = parseXgid(CANONICAL, 'canonical') - expect(formatXgid(xgid, 'berger')).toBe(BERGER) + expect(formatXgid(xgid, 'bgblitz')).toBe(BGBLITZ) }) - it('the berger dialect reads the cube field literally', () => { - expect(parseXgid(BERGER, 'berger').cubeValue).toBe(2) + it('the bgblitz dialect reads the cube field literally', () => { + expect(parseXgid(BGBLITZ, 'bgblitz').cubeValue).toBe(2) }) it('the same field means different cubes in the two dialects', () => { @@ -123,15 +123,15 @@ describe('XGID parsing', () => { `${'-'.repeat(26)}:2:0:1:31:0:0:0:0:0`, 'canonical' ) - const berger = parseXgid(`${'-'.repeat(26)}:2:0:1:31:0:0:0:0`, 'berger') + const bgblitz = parseXgid(`${'-'.repeat(26)}:2:0:1:31:0:0:0:0`, 'bgblitz') expect(canonical.cubeValue).toBe(4) - expect(berger.cubeValue).toBe(2) + expect(bgblitz.cubeValue).toBe(2) }) it('round-trips both dialects', () => { const cases: Array<[string, XgidDialect]> = [ [CANONICAL.slice('XGID='.length), 'canonical'], - [BERGER, 'berger'], + [BGBLITZ, 'bgblitz'], ] for (const [text, dialect] of cases) { expect(formatXgid(parseXgid(text, dialect), dialect)).toBe(text) @@ -205,12 +205,12 @@ describe('dice field', () => { expect(canonical('00').dice).toEqual({ kind: 'cube-decision' }) }) - it("the berger dialect has no 'D' — Draft 0.02 cannot express a pending double", () => { - expect(() => parseXgid(`${pos}:1:0:1:D:0:0:0:0`, 'berger')).toThrow( + it("the bgblitz dialect has no 'D' — Draft 0.02 cannot express a pending double", () => { + expect(() => parseXgid(`${pos}:1:0:1:D:0:0:0:0`, 'bgblitz')).toThrow( XgidError ) expect(() => - formatXgid({ ...canonical('D'), cubeValue: 1 }, 'berger') + formatXgid({ ...canonical('D'), cubeValue: 1 }, 'bgblitz') ).toThrow(XgidError) }) @@ -227,7 +227,7 @@ describe('field-count and range validation', () => { expect(() => parseXgid(`${pos}:0:0:1:31:0:0:0:0`, 'canonical')).toThrow( XgidError ) - expect(() => parseXgid(`${pos}:0:0:1:31:0:0:0:0:0`, 'berger')).toThrow( + expect(() => parseXgid(`${pos}:0:0:1:31:0:0:0:0:0`, 'bgblitz')).toThrow( XgidError ) }) @@ -257,7 +257,7 @@ describe('field-count and range validation', () => { }) it('rejects a cube value that is not a power of two', () => { - expect(() => parseXgid(`${pos}:3:0:1:31:0:0:0:0`, 'berger')).toThrow( + expect(() => parseXgid(`${pos}:3:0:1:31:0:0:0:0`, 'bgblitz')).toThrow( XgidError ) }) diff --git a/src/XGID/index.ts b/src/XGID/index.ts index 0df6448..b1dc6b3 100644 --- a/src/XGID/index.ts +++ b/src/XGID/index.ts @@ -54,7 +54,7 @@ export type XgidCubeOwner = -1 | 0 | 1 * - `canonical` — as produced by eXtreme Gammon and accepted by gnubg: ten * fields after the position, cube encoded as a base-2 logarithm, trailing * max-cube field present. - * - `berger` — the dialect in Frank Berger's Open Backgammon Plugin Protocol + * - `bgblitz` — the dialect in Frank Berger's Open Backgammon Plugin Protocol * (Draft 0.02): nine fields, cube encoded as its literal value, no max-cube, * and `00` in the dice field to request a cube decision. * @@ -64,7 +64,7 @@ export type XgidCubeOwner = -1 | 0 | 1 * has no solution. Worse, the corruption is invisible in checker-play tests, * because cube value barely affects best checker play. */ -export type XgidDialect = 'canonical' | 'berger' +export type XgidDialect = 'canonical' | 'bgblitz' /** What the dice field was carrying. */ export type XgidDiceState = @@ -72,7 +72,7 @@ export type XgidDiceState = | { kind: 'roll'; dice: [number, number] } /** A double has been offered; the player facing it is to decide. Canonical `D`. */ | { kind: 'doubled' } - /** No roll: the player on roll is to make a cube decision. Berger `00`. */ + /** No roll: the player on roll is to make a cube decision. BGBlitz `00`. */ | { kind: 'cube-decision' } /** A parsed XGID. */ @@ -358,8 +358,8 @@ function formatPositionField(board: XgidBoard): string { function parseDice(field: string, dialect: XgidDialect): XgidDiceState { if (field === 'D') { - if (dialect === 'berger') { - fail("the berger dialect has no 'D' dice value; see Draft 0.02 §5.2") + if (dialect === 'bgblitz') { + fail("the bgblitz dialect has no 'D' dice value; see Draft 0.02 §5.2") } return { kind: 'doubled' } } @@ -376,9 +376,9 @@ function formatDice(dice: XgidDiceState, dialect: XgidDialect): string { case 'roll': return `${dice.dice[0]}${dice.dice[1]}` case 'doubled': - if (dialect === 'berger') { + if (dialect === 'bgblitz') { fail( - "the berger dialect cannot express a pending double; see Draft 0.02 §5.2" + "the bgblitz dialect cannot express a pending double; see Draft 0.02 §5.2" ) } return 'D' @@ -387,7 +387,7 @@ function formatDice(dice: XgidDiceState, dialect: XgidDialect): string { } } -const FIELD_COUNT: Record = { canonical: 10, berger: 9 } +const FIELD_COUNT: Record = { canonical: 10, bgblitz: 9 } /** * Parse an XGID.