Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions src/Game/__tests__/undo-characterization.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
24 changes: 5 additions & 19 deletions src/Game/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
40 changes: 40 additions & 0 deletions src/Game/undo.ts
Original file line number Diff line number Diff line change
@@ -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
}