From 8b99e3122ecc67d367b87c375ddded0f7ab4b19e Mon Sep 17 00:00:00 2001 From: Ken Riley Date: Wed, 15 Jul 2026 09:10:51 -0600 Subject: [PATCH 1/2] test(game): characterization tests for undo (Phase 2 of #134) Lock in current behavior of Game.canUndoActivePlay and Game.undoLastInActivePlay before refactoring: defensive/guard branches and the real-move happy path (snapshot push -> undo pop). 10 tests. Refs #132, #134 --- .../__tests__/undo-characterization.test.ts | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 src/Game/__tests__/undo-characterization.test.ts diff --git a/src/Game/__tests__/undo-characterization.test.ts b/src/Game/__tests__/undo-characterization.test.ts new file mode 100644 index 0000000..0a161e4 --- /dev/null +++ b/src/Game/__tests__/undo-characterization.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect } from '@jest/globals' +import { Board } from '../../Board' +import { Game } from '..' +import { Player } from '../../Player' +import { Play } from '../../Play' +import { Cube } from '../../Cube' +import type { + BackgammonCheckerContainerImport, + BackgammonGameMoving, + BackgammonPlayerInactive, + BackgammonPlayerRolling, +} from '@nodots/backgammon-types' + +// Helper to define a point by counterclockwise position. +const pointCC = ( + posCC: number, + qty: number, + color: 'black' | 'white' +): BackgammonCheckerContainerImport => ({ + // Positions are branded numeric literals in the type; the arithmetic result + // is a plain number, so narrow it back for the import shape. + position: { clockwise: (25 - posCC) as any, counterclockwise: posCC as any }, + checkers: { qty, color }, +}) + +// Build a moving-state game with a single black checker at CC 24 and an open +// board, so die 1 (CC 24 -> CC 23) is legal. Fresh activePlay has no undo stack. +function buildMovingGame(): BackgammonGameMoving { + const boardImport: BackgammonCheckerContainerImport[] = [pointCC(24, 1, 'black')] + const board = Board.buildBoard(boardImport) + + const blackRolling = Player.initialize( + 'black', + 'counterclockwise', + 'rolling', + false + ) as BackgammonPlayerRolling + const blackRolled = Player.roll(blackRolling) + blackRolled.dice.currentRoll = [1, 2] + const blackMoving = Player.toMoving(blackRolled) + + const whiteInactive = Player.initialize( + 'white', + 'clockwise', + 'inactive', + false + ) as BackgammonPlayerInactive + + const play = Play.initialize(board, blackMoving) + + // Composing a moving game from parts requires the loose tuple shape used by + // the other Game unit tests. + return Game.initialize( + [blackMoving, whiteInactive] as any, + 'undo-char-game', + 'moving', + board, + Cube.initialize(), + play, + 'black', + blackMoving, + whiteInactive + ) as BackgammonGameMoving +} + +// Find the id of the black checker at CC 24 on a game's board. +function ccOriginCheckerId(game: BackgammonGameMoving): string { + const originPoint = Board.getPoints(game.board).find( + (p) => p.position.counterclockwise === 24 + )! + return originPoint.checkers.find((c) => c.color === 'black')!.id +} + +describe('Game.canUndoActivePlay() — characterization', () => { + it('returns false for a falsy game', () => { + // Exercising the defensive null branch; the signature forbids undefined. + expect(Game.canUndoActivePlay(undefined as any)).toBe(false) + }) + + it('returns false when stateKind is not moving or moved', () => { + const game = buildMovingGame() + const rolling = { ...game, stateKind: 'rolling' } as any + expect(Game.canUndoActivePlay(rolling)).toBe(false) + }) + + it('returns false when activePlay has no undo stack', () => { + const game = buildMovingGame() + expect(Game.canUndoActivePlay(game)).toBe(false) + }) + + it('returns false when the undo stack is empty', () => { + const game = buildMovingGame() + ;(game.activePlay as any).undo = { frames: [] } + expect(Game.canUndoActivePlay(game)).toBe(false) + }) + + it('returns true after a real move pushes a snapshot', () => { + const game = buildMovingGame() + const after = Game.move(game, ccOriginCheckerId(game)) as BackgammonGameMoving + expect(Game.canUndoActivePlay(after)).toBe(true) + }) +}) + +describe('Game.undoLastInActivePlay() — characterization', () => { + it('throws when no game state is provided', () => { + // Defensive branch; the signature forbids undefined. + expect(() => Game.undoLastInActivePlay(undefined as any)).toThrow( + 'No game state provided' + ) + }) + + it('throws when stateKind is not moving or moved', () => { + const game = buildMovingGame() + const rolling = { ...game, stateKind: 'rolling' } as any + expect(() => Game.undoLastInActivePlay(rolling)).toThrow( + "Cannot undo in rolling state" + ) + }) + + it('throws when there are no moves to undo', () => { + const game = buildMovingGame() + ;(game.activePlay as any).undo = { frames: [] } + expect(() => Game.undoLastInActivePlay(game)).toThrow('No moves to undo') + }) + + it('throws when the undo snapshot is not a moving state', () => { + const game = buildMovingGame() + ;(game.activePlay as any).undo = { frames: [{ stateKind: 'moved' }] } + expect(() => Game.undoLastInActivePlay(game)).toThrow( + 'Undo snapshot is invalid or not a moving state' + ) + }) + + it('returns the pre-move moving snapshot on the happy path', () => { + const game = buildMovingGame() + const before = Game.canUndoActivePlay(game) + expect(before).toBe(false) + + const after = Game.move(game, ccOriginCheckerId(game)) as BackgammonGameMoving + const restored = Game.undoLastInActivePlay(after) + + expect(restored.stateKind).toBe('moving') + // The frame is the exact pre-move snapshot: undo stack was empty in it. + expect(Game.canUndoActivePlay(restored)).toBe(false) + // Popping the only frame empties the stack. + expect(Game.canUndoActivePlay(after)).toBe(false) + }) +}) From 961ad662ba5e939243769baa2deb68c368053ed0 Mon Sep 17 00:00:00 2001 From: Ken Riley Date: Wed, 15 Jul 2026 09:14:25 -0600 Subject: [PATCH 2/2] refactor(game): extract undo.ts, drop as-any casts (Phase 2 of #132/#96) Move undoLastInActivePlay and canUndoActivePlay into src/Game/undo.ts as free functions. Now that types promotes undo.frames to a typed BackgammonGameMoving[] on BasePlay, the functions read game.activePlay.undo.frames directly with no 'as any' casts (removes 5 casts from the undo slice of #96). The public Game.* statics remain as thin delegating wrappers; canUndoActivePlay (deferred from guards) lands here with its concern. Depends on backgammon-types refactor/undo-frames-typed. Characterization tests (10) added in the prior commit stay green. No behavior change. Core: 458 passed / 12 skipped, tsc -b clean. Refs #132, #96 --- src/Game/index.ts | 24 +++++------------------- src/Game/undo.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 19 deletions(-) create mode 100644 src/Game/undo.ts diff --git a/src/Game/index.ts b/src/Game/index.ts index 2e37af2..b629ccc 100644 --- a/src/Game/index.ts +++ b/src/Game/index.ts @@ -44,6 +44,7 @@ import { canRollForStart, } from './guards' import { createBaseGameProperties, incrementStateVersion } from './shared' +import { canUndoActivePlay, undoLastInActivePlay } from './undo' export * from '../index' // Import tuple aliases from types package @@ -2418,31 +2419,16 @@ export class Game { * Undo the last executed move within the current activePlay using the turn-local undo stack. * Returns the exact pre-move moving game state. */ - public static undoLastInActivePlay = function undoLastInActivePlay( + public static undoLastInActivePlay = function ( game: BackgammonGame ): BackgammonGameMoving { - if (!game) throw new Error('No game state provided') - if (game.stateKind !== 'moving' && game.stateKind !== 'moved') { - throw new Error(`Cannot undo in ${game.stateKind} state. Must be in 'moving' or 'moved'`) - } - const ap: any = (game as any).activePlay - if (!ap) throw new Error('No active play found for undo') - const frames: any[] | undefined = ap.undo?.frames - if (!frames || frames.length === 0) throw new Error('No moves to undo for current player') - const previous = frames.pop() - if (!previous || previous.stateKind !== 'moving') throw new Error('Undo snapshot is invalid or not a moving state') - return previous as BackgammonGameMoving + return undoLastInActivePlay(game) } /** * Game-level check for whether an undo is currently possible within activePlay. */ - public static canUndoActivePlay = function canUndoActivePlay(game: BackgammonGame): boolean { - if (!game) return false - if (game.stateKind !== 'moving' && game.stateKind !== 'moved') return false - const ap: any = (game as any).activePlay - if (!ap || !ap.undo) return false - const frames: any[] | undefined = ap.undo.frames - return Array.isArray(frames) && frames.length > 0 + public static canUndoActivePlay = function (game: BackgammonGame): boolean { + return canUndoActivePlay(game) } } diff --git a/src/Game/undo.ts b/src/Game/undo.ts new file mode 100644 index 0000000..54c83cb --- /dev/null +++ b/src/Game/undo.ts @@ -0,0 +1,40 @@ +import { + BackgammonGame, + BackgammonGameMoving, +} from '@nodots/backgammon-types' + +/** + * Undo the last executed move within the current activePlay using the + * turn-local undo stack. Returns the exact pre-move moving game state. + */ +export function undoLastInActivePlay( + game: BackgammonGame +): BackgammonGameMoving { + if (!game) throw new Error('No game state provided') + if (game.stateKind !== 'moving' && game.stateKind !== 'moved') { + throw new Error( + `Cannot undo in ${game.stateKind} state. Must be in 'moving' or 'moved'` + ) + } + const ap = game.activePlay + if (!ap) throw new Error('No active play found for undo') + const frames = ap.undo?.frames + if (!frames || frames.length === 0) + throw new Error('No moves to undo for current player') + const previous = frames.pop() + if (!previous || previous.stateKind !== 'moving') + throw new Error('Undo snapshot is invalid or not a moving state') + return previous +} + +/** + * Game-level check for whether an undo is currently possible within activePlay. + */ +export function canUndoActivePlay(game: BackgammonGame): boolean { + if (!game) return false + if (game.stateKind !== 'moving' && game.stateKind !== 'moved') return false + const ap = game.activePlay + if (!ap || !ap.undo) return false + const frames = ap.undo.frames + return Array.isArray(frames) && frames.length > 0 +}