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
66 changes: 66 additions & 0 deletions src/Game/__tests__/restore-state-characterization.test.ts
Original file line number Diff line number Diff line change
@@ -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'"
)
})
})
100 changes: 5 additions & 95 deletions src/Game/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import {
BackgammonPlayerMoving,
BackgammonPlayerRolledForStart,
BackgammonPlayerRolling,
BackgammonPlayerRollingForStart,
BackgammonPlayers,
BackgammonPlayerWinner,
BackgammonPlayMoving,
Expand Down Expand Up @@ -52,6 +51,7 @@ import {
canRoll,
canRollForStart,
} from './guards'
import { restoreState, rollForStart } from './lifecycle'
import { createBaseGameProperties, incrementStateVersion } from './shared'
import { canUndoActivePlay, undoLastInActivePlay } from './undo'

Expand All @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
116 changes: 116 additions & 0 deletions src/Game/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -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
}