diff --git a/src/Game/__tests__/restore-state-characterization.test.ts b/src/Game/__tests__/restore-state-characterization.test.ts new file mode 100644 index 0000000..389e21f --- /dev/null +++ b/src/Game/__tests__/restore-state-characterization.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from '@jest/globals' +import { BackgammonGame } from '@nodots/backgammon-types' +import { Game } from '../index' + +// A complete, restorable game state. +function buildRestorableGame(): BackgammonGame { + return Game.createNewGame( + { userId: 'player1', isRobot: false }, + { userId: 'player2', isRobot: false } + ) +} + +describe('Game.restoreState() — characterization', () => { + it('returns the state unchanged for a valid, restorable game', () => { + const game = buildRestorableGame() + const restored = Game.restoreState(game) + expect(restored).toBe(game) + }) + + it('throws when state is null or undefined', () => { + // Defensive branch; the signature forbids null. + expect(() => Game.restoreState(null as any)).toThrow( + 'Cannot restore: state is null or undefined' + ) + }) + + it('throws when stateKind is missing', () => { + const game = buildRestorableGame() + const noKind = { ...game, stateKind: undefined } as any + expect(() => Game.restoreState(noKind)).toThrow( + 'invalid state - missing stateKind' + ) + }) + + it('throws when there are not exactly 2 players', () => { + const game = buildRestorableGame() + const onePlayer = { ...game, players: [game.players[0]] } as any + expect(() => Game.restoreState(onePlayer)).toThrow( + 'must have exactly 2 players' + ) + }) + + it('throws when the board is missing', () => { + const game = buildRestorableGame() + const noBoard = { ...game, board: undefined } as any + expect(() => Game.restoreState(noBoard)).toThrow( + 'invalid state - missing board' + ) + }) + + it('throws when the cube is missing', () => { + const game = buildRestorableGame() + const noCube = { ...game, cube: undefined } as any + expect(() => Game.restoreState(noCube)).toThrow( + 'invalid state - missing cube' + ) + }) + + it('throws when the stateKind is not restorable', () => { + const game = buildRestorableGame() + const badKind = { ...game, stateKind: 'not-a-real-state' } as any + expect(() => Game.restoreState(badKind)).toThrow( + "Cannot restore: invalid stateKind 'not-a-real-state'" + ) + }) +}) diff --git a/src/Game/index.ts b/src/Game/index.ts index 9a9dc3a..af7e278 100644 --- a/src/Game/index.ts +++ b/src/Game/index.ts @@ -22,7 +22,6 @@ import { BackgammonPlayerMoving, BackgammonPlayerRolledForStart, BackgammonPlayerRolling, - BackgammonPlayerRollingForStart, BackgammonPlayers, BackgammonPlayerWinner, BackgammonPlayMoving, @@ -52,6 +51,7 @@ import { canRoll, canRollForStart, } from './guards' +import { restoreState, rollForStart } from './lifecycle' import { createBaseGameProperties, incrementStateVersion } from './shared' import { canUndoActivePlay, undoLastInActivePlay } from './undo' @@ -64,7 +64,6 @@ import type { BackgammonPlayersRollingForStartTuple, BackgammonPlayersRollingTuple, } from '@nodots/backgammon-types' -import { RESTORABLE_GAME_STATE_KINDS } from '@nodots/backgammon-types' import { executeRobotTurn } from './executeRobotTurn' export class Game { @@ -331,67 +330,10 @@ export class Game { // GAME STATE TRANSITION METHODS // ============================================================================ - public static rollForStart = function rollForStart( + public static rollForStart = function ( game: BackgammonGameRollingForStart ): BackgammonGameRolledForStart { - const { players } = game - const clockwise = players.find( - (p) => p.direction === 'clockwise' && p.stateKind === 'rolling-for-start' - ) - const counterclockwise = players.find( - (p) => - p.direction === 'counterclockwise' && - p.stateKind === 'rolling-for-start' - ) - - if (!clockwise || !counterclockwise) { - throw new Error( - 'Cannot rollForStart without clockwise and counterclockwise players' - ) - } - - // Roll dice for both players - const rolledClockwise = Player.rollForStart( - clockwise as BackgammonPlayerRollingForStart - ) - const rolledCounterclockwise = Player.rollForStart( - counterclockwise as BackgammonPlayerRollingForStart - ) - - // Determine who goes first based on the rolls - const clockwiseRoll = rolledClockwise.dice.currentRoll![0] - const counterclockwiseRoll = rolledCounterclockwise.dice.currentRoll![0] - - let activeColor: BackgammonColor - if (clockwiseRoll > counterclockwiseRoll) { - activeColor = clockwise.color - } else if (counterclockwiseRoll > clockwiseRoll) { - activeColor = counterclockwise.color - } else { - // Tie - need to reroll (for now, default to clockwise) - return Game.rollForStart(game) - } - - const rollingForStartPlayers = [rolledClockwise, rolledCounterclockwise] - const activePlayer = rollingForStartPlayers.find( - (p) => p.color === activeColor - )! - const inactivePlayer = rollingForStartPlayers.find( - (p) => p.color !== activeColor - )! - - return incrementStateVersion({ - ...game, - stateKind: 'rolled-for-start', - activeColor, - // Ensure tuple order is [active, inactive] for stricter typing - players: [ - activePlayer, - inactivePlayer, - ] as BackgammonPlayersRolledForStartTuple, - activePlayer, - inactivePlayer, - } as BackgammonGameRolledForStart) + return rollForStart(game) } public static roll = function roll( @@ -1940,42 +1882,10 @@ export class Game { * @param state Complete game state to restore to * @returns Validated game state */ - public static restoreState = function restoreState( + public static restoreState = function ( state: BackgammonGame ): BackgammonGame { - // Validate that this is a valid game state - if (!state) { - throw new Error('Cannot restore: state is null or undefined') - } - - if (!state.stateKind) { - throw new Error('Cannot restore: invalid state - missing stateKind') - } - - if (!state.players || state.players.length !== 2) { - throw new Error( - 'Cannot restore: invalid state - must have exactly 2 players' - ) - } - - if (!state.board) { - throw new Error('Cannot restore: invalid state - missing board') - } - - if (!state.cube) { - throw new Error('Cannot restore: invalid state - missing cube') - } - - // Validate state kind is one of the known restorable states from TYPES - if (!RESTORABLE_GAME_STATE_KINDS.includes(state.stateKind)) { - throw new Error(`Cannot restore: invalid stateKind '${state.stateKind}'`) - } - - // State is valid - return it - // Note: We return the state as-is because it's already a complete, valid game state - // The API layer is responsible for persisting this state - logger.info(`State restored successfully to ${state.stateKind}`) - return state + return restoreState(state) } public static startMove = function startMove( diff --git a/src/Game/lifecycle.ts b/src/Game/lifecycle.ts new file mode 100644 index 0000000..7f6a5de --- /dev/null +++ b/src/Game/lifecycle.ts @@ -0,0 +1,116 @@ +import { + BackgammonColor, + BackgammonGame, + BackgammonGameRolledForStart, + BackgammonGameRollingForStart, + BackgammonPlayerRollingForStart, + BackgammonPlayersRolledForStartTuple, + RESTORABLE_GAME_STATE_KINDS, +} from '@nodots/backgammon-types' +import { Player } from '..' +import { logger } from '../utils/logger' +import { incrementStateVersion } from './shared' + +export function rollForStart( + game: BackgammonGameRollingForStart +): BackgammonGameRolledForStart { + const { players } = game + const clockwise = players.find( + (p) => p.direction === 'clockwise' && p.stateKind === 'rolling-for-start' + ) + const counterclockwise = players.find( + (p) => + p.direction === 'counterclockwise' && p.stateKind === 'rolling-for-start' + ) + + if (!clockwise || !counterclockwise) { + throw new Error( + 'Cannot rollForStart without clockwise and counterclockwise players' + ) + } + + // Roll dice for both players + const rolledClockwise = Player.rollForStart( + clockwise as BackgammonPlayerRollingForStart + ) + const rolledCounterclockwise = Player.rollForStart( + counterclockwise as BackgammonPlayerRollingForStart + ) + + // Determine who goes first based on the rolls + const clockwiseRoll = rolledClockwise.dice.currentRoll![0] + const counterclockwiseRoll = rolledCounterclockwise.dice.currentRoll![0] + + let activeColor: BackgammonColor + if (clockwiseRoll > counterclockwiseRoll) { + activeColor = clockwise.color + } else if (counterclockwiseRoll > clockwiseRoll) { + activeColor = counterclockwise.color + } else { + // Tie - need to reroll (for now, default to clockwise) + return rollForStart(game) + } + + const rollingForStartPlayers = [rolledClockwise, rolledCounterclockwise] + const activePlayer = rollingForStartPlayers.find( + (p) => p.color === activeColor + )! + const inactivePlayer = rollingForStartPlayers.find( + (p) => p.color !== activeColor + )! + + return incrementStateVersion({ + ...game, + stateKind: 'rolled-for-start', + activeColor, + // Ensure tuple order is [active, inactive] for stricter typing + players: [ + activePlayer, + inactivePlayer, + ] as BackgammonPlayersRolledForStartTuple, + activePlayer, + inactivePlayer, + } as BackgammonGameRolledForStart) +} + +/** + * Restores a game to a previous state + * This is the new architecture for state restoration - CORE validates but doesn't manage history + * @param state Complete game state to restore to + * @returns Validated game state + */ +export function restoreState(state: BackgammonGame): BackgammonGame { + // Validate that this is a valid game state + if (!state) { + throw new Error('Cannot restore: state is null or undefined') + } + + if (!state.stateKind) { + throw new Error('Cannot restore: invalid state - missing stateKind') + } + + if (!state.players || state.players.length !== 2) { + throw new Error( + 'Cannot restore: invalid state - must have exactly 2 players' + ) + } + + if (!state.board) { + throw new Error('Cannot restore: invalid state - missing board') + } + + if (!state.cube) { + throw new Error('Cannot restore: invalid state - missing cube') + } + + // Validate state kind is one of the known restorable states from TYPES + if (!RESTORABLE_GAME_STATE_KINDS.includes(state.stateKind)) { + throw new Error(`Cannot restore: invalid stateKind '${state.stateKind}'`) + } + + // State is valid - return it + // Note: We return the state as-is because it's already a complete, valid game state + // The API layer is responsible for persisting this state + logger.info(`State restored successfully to ${state.stateKind}`) + return state +}